Skip to content

Commit 47546ff

Browse files
committed
feat: Add masking support for improved OCR
This allows masking artifacts in the image that could be confusing for the OCR process, like units or unrelated values on the meter screen.
1 parent 8f159ac commit 47546ff

6 files changed

Lines changed: 338 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ All options can be set via CLI flags or environment variables. Flags take preced
123123
| `--crop` | `CROP` | *(disabled)* | Crop rectangle as `x0,y0,x1,y1` applied before OCR |
124124
| `--ocr-match-regex` | `OCR_MATCH_REGEX` | `^000\d+$` | Regex to identify the meter reading from OCR text |
125125
| `--ocr-fix-regex` | `OCR_FIX_REGEX` | *(disabled)* | Comma-separated list of regex substitutions as `pattern=replacement` applied in order before matching (e.g. `^O=0,^030=000`) |
126+
| `--ocr-mask-regions` | `OCR_MASK_REGIONS` | *(disabled)* | Comma-separated rectangle coordinates to mask before OCR, as `x1,y1,x2,y2[,x3,y3,x4,y4,...]` (applied after crop) |
127+
| `--ocr-mask-colors` | `OCR_MASK_COLORS` | `000000` | Comma-separated hex colors for mask regions. One color applies to all; otherwise must match the number of regions |
126128
| `--mqtt-broker` | `MQTT_BROKER` | *(disabled)* | MQTT broker URL, e.g. `tcp://192.168.1.100:1883` |
127129
| `--mqtt-user` | `MQTT_USER` | | MQTT username |
128130
| `--mqtt-password` | `MQTT_PASSWORD` | | MQTT password |

http.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,30 @@ func handleOCR(w http.ResponseWriter, r *http.Request) {
6060
}
6161

6262
func processOCR(imageData []byte, batLevel, batVoltage int) {
63-
var croppedData []byte
63+
var cropped, masked bool
6464
ocrData := imageData
6565
if cropRect != nil {
66-
cropped, err := cropImage(imageData, cropRect)
66+
data, err := cropImage(imageData, cropRect)
6767
if err != nil {
6868
log.Printf("crop error: %v, using original image", err)
6969
} else {
70-
croppedData = cropped
71-
ocrData = cropped
70+
ocrData = data
71+
cropped = true
72+
}
73+
}
74+
75+
if len(ocrMasks) > 0 {
76+
data, err := maskImage(ocrData, ocrMasks)
77+
if err != nil {
78+
log.Printf("mask error: %v, using unmasked image", err)
79+
} else {
80+
ocrData = data
81+
masked = true
7282
}
7383
}
7484

7585
// Store images to disk before OCR so we have them even if OCR fails.
76-
imagePath := storeImages(imageData, croppedData)
86+
imagePath := storeImages(imageData, ocrData, cropped, masked)
7787

7888
tmpDir, err := os.MkdirTemp("", "ocr-")
7989
if err != nil {

main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
cropRect *cropConfig
2121
ocrMatchRe *regexp.Regexp
2222
ocrFixRules []ocrFixRule
23+
ocrMasks []maskRegion
2324

2425
mqttBroker string
2526
mqttUser string
@@ -76,6 +77,16 @@ func main() {
7677
Usage: "Comma-separated list of regex substitutions applied to OCR text before matching, each as pattern=replacement (e.g. ^O=0,^030=000)",
7778
Sources: cli.EnvVars("OCR_FIX_REGEX"),
7879
},
80+
&cli.StringFlag{
81+
Name: "ocr-mask-regions",
82+
Usage: "Comma-separated rectangle coordinates to mask before OCR, as x1,y1,x2,y2[,x3,y3,x4,y4,...] (applied after crop)",
83+
Sources: cli.EnvVars("OCR_MASK_REGIONS"),
84+
},
85+
&cli.StringFlag{
86+
Name: "ocr-mask-colors",
87+
Usage: "Comma-separated hex colors for mask regions (e.g. 0099CC). One color applies to all regions; otherwise must match the number of regions",
88+
Sources: cli.EnvVars("OCR_MASK_COLORS"),
89+
},
7990
&cli.StringFlag{
8091
Name: "mqtt-broker",
8192
Usage: "MQTT broker URL, e.g. tcp://192.168.1.100:1883 (disabled if empty)",
@@ -143,6 +154,7 @@ func run(_ context.Context, cmd *cli.Command) error {
143154
cropRect = parseCropRect(cmd.String("crop"))
144155
ocrMatchRe = regexp.MustCompile(cmd.String("ocr-match-regex"))
145156
ocrFixRules = parseOCRFixRules(cmd.String("ocr-fix-regex"))
157+
ocrMasks = parseMaskRegions(cmd.String("ocr-mask-regions"), cmd.String("ocr-mask-colors"))
146158

147159
mqttBroker = cmd.String("mqtt-broker")
148160
mqttUser = cmd.String("mqtt-user")

ocr.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/hex"
56
"encoding/json"
67
"fmt"
78
"image"
9+
"image/color"
10+
"image/draw"
811
"image/jpeg"
912
"log"
1013
"os/exec"
@@ -76,6 +79,122 @@ func cropImage(data []byte, c *cropConfig) ([]byte, error) {
7679
return buf.Bytes(), nil
7780
}
7881

82+
type maskRegion struct {
83+
Rect image.Rectangle
84+
Color color.RGBA
85+
}
86+
87+
func parseMaskRegions(regions, colors string) []maskRegion {
88+
regions = strings.TrimSpace(regions)
89+
if regions == "" {
90+
return nil
91+
}
92+
93+
coords := strings.Split(regions, ",")
94+
if len(coords)%4 != 0 {
95+
log.Fatalf("ocr-mask-regions: must have groups of 4 coordinates (x1,y1,x2,y2), got %d values", len(coords))
96+
}
97+
98+
vals := make([]int, len(coords))
99+
for i, c := range coords {
100+
v, err := strconv.Atoi(strings.TrimSpace(c))
101+
if err != nil {
102+
log.Fatalf("ocr-mask-regions: invalid integer %q: %v", c, err)
103+
}
104+
vals[i] = v
105+
}
106+
107+
numRects := len(vals) / 4
108+
rectColors := parseMaskColors(colors, numRects)
109+
110+
masks := make([]maskRegion, numRects)
111+
for i := 0; i < numRects; i++ {
112+
x0, y0 := vals[i*4], vals[i*4+1]
113+
x1, y1 := vals[i*4+2], vals[i*4+3]
114+
if x0 >= x1 || y0 >= y1 {
115+
log.Fatalf("ocr-mask-regions: invalid rectangle #%d (x0,y0 must be < x1,y1): %d,%d,%d,%d", i+1, x0, y0, x1, y1)
116+
}
117+
masks[i] = maskRegion{
118+
Rect: image.Rect(x0, y0, x1, y1),
119+
Color: rectColors[i],
120+
}
121+
}
122+
return masks
123+
}
124+
125+
func parseMaskColors(s string, numRects int) []color.RGBA {
126+
s = strings.TrimSpace(s)
127+
defaultColor := color.RGBA{0, 0, 0, 255}
128+
129+
if s == "" {
130+
colors := make([]color.RGBA, numRects)
131+
for i := range colors {
132+
colors[i] = defaultColor
133+
}
134+
return colors
135+
}
136+
137+
parts := strings.Split(s, ",")
138+
parsed := make([]color.RGBA, 0, len(parts))
139+
for _, p := range parts {
140+
p = strings.TrimSpace(p)
141+
if p == "" {
142+
continue
143+
}
144+
parsed = append(parsed, parseHexColor(p))
145+
}
146+
147+
if len(parsed) == 1 {
148+
colors := make([]color.RGBA, numRects)
149+
for i := range colors {
150+
colors[i] = parsed[0]
151+
}
152+
return colors
153+
}
154+
155+
if len(parsed) != numRects {
156+
log.Fatalf("ocr-mask-colors: must specify 1 color or exactly %d (one per rectangle), got %d", numRects, len(parsed))
157+
}
158+
return parsed
159+
}
160+
161+
func parseHexColor(s string) color.RGBA {
162+
s = strings.TrimPrefix(s, "#")
163+
if len(s) != 6 {
164+
log.Fatalf("ocr-mask-colors: invalid hex color %q (must be 6 hex digits like 0099CC)", s)
165+
}
166+
b, err := hex.DecodeString(s)
167+
if err != nil {
168+
log.Fatalf("ocr-mask-colors: invalid hex color %q: %v", s, err)
169+
}
170+
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
171+
}
172+
173+
func maskImage(data []byte, masks []maskRegion) ([]byte, error) {
174+
img, _, err := image.Decode(bytes.NewReader(data))
175+
if err != nil {
176+
return nil, fmt.Errorf("decode image: %w", err)
177+
}
178+
179+
bounds := img.Bounds()
180+
dst := image.NewRGBA(bounds)
181+
draw.Draw(dst, bounds, img, bounds.Min, draw.Src)
182+
183+
for _, m := range masks {
184+
r := m.Rect.Intersect(bounds)
185+
if r.Empty() {
186+
continue
187+
}
188+
draw.Draw(dst, r, &image.Uniform{m.Color}, image.Point{}, draw.Src)
189+
}
190+
191+
var buf bytes.Buffer
192+
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil {
193+
return nil, fmt.Errorf("encode masked image: %w", err)
194+
}
195+
return buf.Bytes(), nil
196+
}
197+
79198
type ocrOutput struct {
80199
Texts []string `json:"texts"`
81200
Scores []float64 `json:"scores"`

0 commit comments

Comments
 (0)