Merge branch 'feat/hint-mem' into feat/hint-hybrid
7 files · +182 -54
M internal/adapters/hints/benchmark_test.go +18 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "os/exec" | |
| 10 | 10 | "path/filepath" | |
| 11 | + | "strconv" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| 13 | 14 | ) | |
| @@ -25,6 +26,13 @@ func TestFrameBenchmark(t *testing.T) { | |||
| 25 | 26 | if err != nil || len(files) == 0 { | |
| 26 | 27 | t.Fatal("no PNG frames") | |
| 27 | 28 | } | |
| 29 | + | // Settings under test: NEFERWL_HINT_BENCH_WORKERS=<n>, NEFERWL_HINT_BENCH_PNG=speed|none. | |
| 30 | + | if v, _ := strconv.Atoi(os.Getenv("NEFERWL_HINT_BENCH_WORKERS")); v > 0 { | |
| 31 | + | ocrWorkers = v | |
| 32 | + | } | |
| 33 | + | if os.Getenv("NEFERWL_HINT_BENCH_PNG") == "none" { | |
| 34 | + | pngLevel = png.NoCompression | |
| 35 | + | } | |
| 28 | 36 | for i, file := range files { | |
| 29 | 37 | f, err := os.Open(file) | |
| 30 | 38 | if err != nil { | |
| @@ -38,11 +46,14 @@ func TestFrameBenchmark(t *testing.T) { | |||
| 38 | 46 | img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy())) | |
| 39 | 47 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 40 | 48 | start := time.Now() | |
| 41 | - | words, bands, err := readWords(context.Background(), img) | |
| 49 | + | words, bands, err := readWords(context.Background(), toGray(img)) | |
| 42 | 50 | if err != nil { | |
| 43 | 51 | t.Fatal("recognition failed") | |
| 44 | 52 | } | |
| 45 | 53 | t.Logf("frame=%d px=%d ocr_ms=%.1f words=%d links=%d bands=%d", i, img.Bounds().Dx()*img.Bounds().Dy(), ms(time.Since(start)), len(words), len(textLinks(words, 1)), bands) | |
| 54 | + | if os.Getenv("NEFERWL_HINT_BENCH_STRIPS") == "" { | |
| 55 | + | continue | |
| 56 | + | } | |
| 46 | 57 | // Experimental strips use known OCR word regions, not pixel targets. | |
| 47 | 58 | // This is an oracle comparison, not a production optimization. | |
| 48 | 59 | lines := make([]image.Rectangle, 0, len(words)) | |
| @@ -59,3 +70,9 @@ func TestFrameBenchmark(t *testing.T) { | |||
| 59 | 70 | } | |
| 60 | 71 | } | |
| 61 | 72 | } | |
| 73 | + | ||
| 74 | + | func toGray(img *image.RGBA) *image.Gray { | |
| 75 | + | g := image.NewGray(img.Bounds()) | |
| 76 | + | draw.Draw(g, g.Bounds(), img, img.Bounds().Min, draw.Src) | |
| 77 | + | return g | |
| 78 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | + | "strconv" | |||
| 11 | "testing" | 12 | "testing" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | ) | 14 | ) | ||
| @@ -25,6 +26,13 @@ func TestFrameBenchmark(t *testing.T) { | |||||
| 25 | if err != nil || len(files) == 0 { | 26 | if err != nil || len(files) == 0 { | ||
| 26 | t.Fatal("no PNG frames") | 27 | t.Fatal("no PNG frames") | ||
| 27 | } | 28 | } | ||
| 29 | + | // Settings under test: NEFERWL_HINT_BENCH_WORKERS=<n>, NEFERWL_HINT_BENCH_PNG=speed|none. | |||
| 30 | + | if v, _ := strconv.Atoi(os.Getenv("NEFERWL_HINT_BENCH_WORKERS")); v > 0 { | |||
| 31 | + | ocrWorkers = v | |||
| 32 | + | } | |||
| 33 | + | if os.Getenv("NEFERWL_HINT_BENCH_PNG") == "none" { | |||
| 34 | + | pngLevel = png.NoCompression | |||
| 35 | + | } | |||
| 28 | for i, file := range files { | 36 | for i, file := range files { | ||
| 29 | f, err := os.Open(file) | 37 | f, err := os.Open(file) | ||
| 30 | if err != nil { | 38 | if err != nil { | ||
| @@ -38,11 +46,14 @@ func TestFrameBenchmark(t *testing.T) { | |||||
| 38 | img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy())) | 46 | img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy())) | ||
| 39 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | 47 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | ||
| 40 | start := time.Now() | 48 | start := time.Now() | ||
| 41 | - | words, bands, err := readWords(context.Background(), img) | 49 | + | words, bands, err := readWords(context.Background(), toGray(img)) |
| 42 | if err != nil { | 50 | if err != nil { | ||
| 43 | t.Fatal("recognition failed") | 51 | t.Fatal("recognition failed") | ||
| 44 | } | 52 | } | ||
| 45 | t.Logf("frame=%d px=%d ocr_ms=%.1f words=%d links=%d bands=%d", i, img.Bounds().Dx()*img.Bounds().Dy(), ms(time.Since(start)), len(words), len(textLinks(words, 1)), bands) | 53 | t.Logf("frame=%d px=%d ocr_ms=%.1f words=%d links=%d bands=%d", i, img.Bounds().Dx()*img.Bounds().Dy(), ms(time.Since(start)), len(words), len(textLinks(words, 1)), bands) | ||
| 54 | + | if os.Getenv("NEFERWL_HINT_BENCH_STRIPS") == "" { | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 46 | // Experimental strips use known OCR word regions, not pixel targets. | 57 | // Experimental strips use known OCR word regions, not pixel targets. | ||
| 47 | // This is an oracle comparison, not a production optimization. | 58 | // This is an oracle comparison, not a production optimization. | ||
| 48 | lines := make([]image.Rectangle, 0, len(words)) | 59 | lines := make([]image.Rectangle, 0, len(words)) | ||
| @@ -59,3 +70,9 @@ func TestFrameBenchmark(t *testing.T) { | |||||
| 59 | } | 70 | } | ||
| 60 | } | 71 | } | ||
| 61 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | func toGray(img *image.RGBA) *image.Gray { | |||
| 75 | + | g := image.NewGray(img.Bounds()) | |||
| 76 | + | draw.Draw(g, g.Bounds(), img, img.Bounds().Min, draw.Src) | |||
| 77 | + | return g | |||
| 78 | + | } | |||
M internal/adapters/hints/dump.go +15 -6
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package hints | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bufio" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "image" | |
| @@ -31,8 +32,8 @@ type Options struct { | |||
| 31 | 32 | Source targetSource | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | - | // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it | |
| 35 | - | // stays untouched until the worker serves the next request. | |
| 35 | + | // dumpJob is one scan to write. pix is the capture buffer of the scan, owned | |
| 36 | + | // by the job: writeDump converts it in place. | |
| 36 | 37 | type dumpJob struct { | |
| 37 | 38 | when time.Time | |
| 38 | 39 | req ports.HintScanRequest | |
| @@ -104,14 +105,16 @@ func writeDump(dir string, j *dumpJob) error { | |||
| 104 | 105 | } | |
| 105 | 106 | prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output)) | |
| 106 | 107 | ||
| 107 | - | img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | |
| 108 | + | // The frame is converted in place to RGBA and encoded straight from the | |
| 109 | + | // capture buffer: no copy. The job owns the buffer, which is released | |
| 110 | + | // with it. | |
| 108 | 111 | for y := 0; y < j.req.Height; y++ { | |
| 109 | 112 | row := j.pix[y*j.stride : y*j.stride+j.req.Width*4] | |
| 110 | - | out := img.Pix[y*j.stride:] | |
| 111 | 113 | for x := 0; x < len(row); x += 4 { | |
| 112 | - | out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff | |
| 114 | + | row[x], row[x+2], row[x+3] = row[x+2], row[x], 0xff | |
| 113 | 115 | } | |
| 114 | 116 | } | |
| 117 | + | img := &image.RGBA{Pix: j.pix, Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | |
| 115 | 118 | if err := writePNG(prefix+".png", img); err != nil { | |
| 116 | 119 | return err | |
| 117 | 120 | } | |
| @@ -171,7 +174,13 @@ func safeName(s string) string { | |||
| 171 | 174 | ||
| 172 | 175 | func writePNG(path string, img image.Image) error { | |
| 173 | 176 | enc := png.Encoder{CompressionLevel: png.BestSpeed} | |
| 174 | - | return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) }) | |
| 177 | + | return writeFile(path, func(f *os.File) error { | |
| 178 | + | bw := bufio.NewWriterSize(f, 1<<16) | |
| 179 | + | if err := enc.Encode(bw, img); err != nil { | |
| 180 | + | return err | |
| 181 | + | } | |
| 182 | + | return bw.Flush() | |
| 183 | + | }) | |
| 175 | 184 | } | |
| 176 | 185 | ||
| 177 | 186 | func writeFile(path string, fill func(*os.File) error) error { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package hints | 1 | package hints | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bufio" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "image" | 7 | "image" | ||
| @@ -31,8 +32,8 @@ type Options struct { | |||||
| 31 | Source targetSource | 32 | Source targetSource | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | - | // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it | 35 | + | // dumpJob is one scan to write. pix is the capture buffer of the scan, owned |
| 35 | - | // stays untouched until the worker serves the next request. | 36 | + | // by the job: writeDump converts it in place. |
| 36 | type dumpJob struct { | 37 | type dumpJob struct { | ||
| 37 | when time.Time | 38 | when time.Time | ||
| 38 | req ports.HintScanRequest | 39 | req ports.HintScanRequest | ||
| @@ -104,14 +105,16 @@ func writeDump(dir string, j *dumpJob) error { | |||||
| 104 | } | 105 | } | ||
| 105 | prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output)) | 106 | prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output)) | ||
| 106 | 107 | ||||
| 107 | - | img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | 108 | + | // The frame is converted in place to RGBA and encoded straight from the |
| 109 | + | // capture buffer: no copy. The job owns the buffer, which is released | |||
| 110 | + | // with it. | |||
| 108 | for y := 0; y < j.req.Height; y++ { | 111 | for y := 0; y < j.req.Height; y++ { | ||
| 109 | row := j.pix[y*j.stride : y*j.stride+j.req.Width*4] | 112 | row := j.pix[y*j.stride : y*j.stride+j.req.Width*4] | ||
| 110 | - | out := img.Pix[y*j.stride:] | |||
| 111 | for x := 0; x < len(row); x += 4 { | 113 | for x := 0; x < len(row); x += 4 { | ||
| 112 | - | out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff | 114 | + | row[x], row[x+2], row[x+3] = row[x+2], row[x], 0xff |
| 113 | } | 115 | } | ||
| 114 | } | 116 | } | ||
| 117 | + | img := &image.RGBA{Pix: j.pix, Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | |||
| 115 | if err := writePNG(prefix+".png", img); err != nil { | 118 | if err := writePNG(prefix+".png", img); err != nil { | ||
| 116 | return err | 119 | return err | ||
| 117 | } | 120 | } | ||
| @@ -171,7 +174,13 @@ func safeName(s string) string { | |||||
| 171 | 174 | ||||
| 172 | func writePNG(path string, img image.Image) error { | 175 | func writePNG(path string, img image.Image) error { | ||
| 173 | enc := png.Encoder{CompressionLevel: png.BestSpeed} | 176 | enc := png.Encoder{CompressionLevel: png.BestSpeed} | ||
| 174 | - | return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) }) | 177 | + | return writeFile(path, func(f *os.File) error { |
| 178 | + | bw := bufio.NewWriterSize(f, 1<<16) | |||
| 179 | + | if err := enc.Encode(bw, img); err != nil { | |||
| 180 | + | return err | |||
| 181 | + | } | |||
| 182 | + | return bw.Flush() | |||
| 183 | + | }) | |||
| 175 | } | 184 | } | ||
| 176 | 185 | ||||
| 177 | func writeFile(path string, fill func(*os.File) error) error { | 186 | func writeFile(path string, fill func(*os.File) error) error { | ||
M internal/adapters/hints/hints.go +60 -28
| @@ -8,8 +8,8 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "hash/maphash" | |
| 10 | 10 | "image" | |
| 11 | - | "image/draw" | |
| 12 | 11 | "math" | |
| 12 | + | "runtime/debug" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| @@ -55,7 +55,6 @@ type atspiResult struct { | |||
| 55 | 55 | ||
| 56 | 56 | type worker struct { | |
| 57 | 57 | source targetSource | |
| 58 | - | buf []byte | |
| 59 | 58 | cache map[cropKey]cropCache | |
| 60 | 59 | cacheSeed maphash.Seed | |
| 61 | 60 | warnedSource bool | |
| @@ -76,6 +75,8 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch | |||
| 76 | 75 | return nil | |
| 77 | 76 | } | |
| 78 | 77 | w.dump(ctx) | |
| 78 | + | // Nothing of the scan is kept: give the frame back to the system. | |
| 79 | + | debug.FreeOSMemory() | |
| 79 | 80 | } | |
| 80 | 81 | } | |
| 81 | 82 | } | |
| @@ -118,11 +119,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 118 | 119 | // It is always waited for: the source is not shared between requests. | |
| 119 | 120 | atspi := w.startSource(ctx, req) | |
| 120 | 121 | defer func() { <-atspi.done }() | |
| 121 | - | size := req.Width * req.Height * 4 | |
| 122 | - | if cap(w.buf) < size { | |
| 123 | - | w.buf = make([]byte, size) | |
| 124 | - | } | |
| 125 | - | buf := w.buf[:size] | |
| 122 | + | // The capture buffer lives for this scan only; a dump job may keep it | |
| 123 | + | // until the dump is written. | |
| 124 | + | buf := make([]byte, req.Width*req.Height*4) | |
| 126 | 125 | ||
| 127 | 126 | w.n++ | |
| 128 | 127 | id := ports.CaptureIDInternal | w.n | |
| @@ -168,11 +167,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||
| 168 | 167 | return fail(ctx.Err()) | |
| 169 | 168 | } | |
| 170 | 169 | case <-timer.C: | |
| 171 | - | // The output may still write into buf: do not reuse it. | |
| 172 | - | w.buf = nil | |
| 170 | + | // The output may still write into buf: it is never reused. | |
| 173 | 171 | return fail(ErrTimeout) | |
| 174 | 172 | case <-ctx.Done(): | |
| 175 | - | w.buf = nil | |
| 176 | 173 | return fail(ctx.Err()) | |
| 177 | 174 | } | |
| 178 | 175 | } | |
| @@ -201,6 +198,16 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||
| 201 | 198 | var allWords []OCRWord | |
| 202 | 199 | pixels, links := 0, 0 | |
| 203 | 200 | cacheHit := true | |
| 201 | + | // Phase 1 reads the frame: only the crops that miss the cache are | |
| 202 | + | // copied, as grayscale. Phase 2 (OCR) does not need the frame. | |
| 203 | + | type job struct { | |
| 204 | + | key cropKey | |
| 205 | + | r image.Rectangle | |
| 206 | + | hash uint64 | |
| 207 | + | gray *image.Gray | |
| 208 | + | hit bool | |
| 209 | + | } | |
| 210 | + | var jobs []job | |
| 204 | 211 | for _, win := range req.Windows { | |
| 205 | 212 | if ar.covered[win.ID] { | |
| 206 | 213 | continue | |
| @@ -211,24 +218,35 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||
| 211 | 218 | } | |
| 212 | 219 | crops = append(crops, r) | |
| 213 | 220 | pixels += r.Dx() * r.Dy() | |
| 214 | - | img := cropImage(buf, stride, r) | |
| 215 | 221 | key := cropKey{output: req.Output, window: win.ID} | |
| 216 | - | hash := maphash.Bytes(w.cacheSeed, img.Pix) | |
| 222 | + | hash := w.cropHash(buf, stride, r) | |
| 217 | 223 | entry, ok := w.cache[key] | |
| 218 | - | hit := ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy() | |
| 219 | - | cacheHit = cacheHit && hit | |
| 220 | - | if !hit { | |
| 221 | - | words, _, err := readWords(ctx, img) | |
| 224 | + | j := job{key: key, r: r, hash: hash, hit: ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy()} | |
| 225 | + | cacheHit = cacheHit && j.hit | |
| 226 | + | if !j.hit { | |
| 227 | + | j.gray = cropGray(buf, stride, r) | |
| 228 | + | } | |
| 229 | + | jobs = append(jobs, j) | |
| 230 | + | } | |
| 231 | + | if w.dumpDir != "" { | |
| 232 | + | // The dump reads the frame after the result is sent. | |
| 233 | + | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, crops: crops, captureD: captureDur, atspiD: ar.dur} | |
| 234 | + | } | |
| 235 | + | buf = nil | |
| 236 | + | for _, j := range jobs { | |
| 237 | + | entry := w.cache[j.key] | |
| 238 | + | if !j.hit { | |
| 239 | + | words, _, err := readWords(ctx, j.gray) | |
| 222 | 240 | if err != nil { | |
| 223 | 241 | failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") }) | |
| 224 | 242 | continue | |
| 225 | 243 | } | |
| 226 | - | entry = cropCache{hash: hash, width: r.Dx(), height: r.Dy(), words: words} | |
| 244 | + | entry = cropCache{hash: j.hash, width: j.r.Dx(), height: j.r.Dy(), words: words} | |
| 227 | 245 | } | |
| 228 | - | nextCache[key] = entry | |
| 246 | + | nextCache[j.key] = entry | |
| 229 | 247 | words := append([]OCRWord(nil), entry.words...) | |
| 230 | 248 | for i := range words { | |
| 231 | - | words[i].Rect = words[i].Rect.Add(r.Min) | |
| 249 | + | words[i].Rect = words[i].Rect.Add(j.r.Min) | |
| 232 | 250 | } | |
| 233 | 251 | allWords = append(allWords, words...) | |
| 234 | 252 | targets := textLinks(words, req.Scale) | |
| @@ -238,8 +256,8 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||
| 238 | 256 | w.cache = nextCache | |
| 239 | 257 | ocrDur := time.Since(t0) | |
| 240 | 258 | res.Targets = out | |
| 241 | - | if w.dumpDir != "" { | |
| 242 | - | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, crops: crops, words: allWords, captureD: captureDur, scanD: ocrDur, atspiD: ar.dur} | |
| 259 | + | if p := w.pending; p != nil { | |
| 260 | + | p.targets, p.atspi, p.words, p.scanD = out, nAtspi, allWords, ocrDur | |
| 243 | 261 | } | |
| 244 | 262 | log.Info().Str("output", req.Output). | |
| 245 | 263 | Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi). | |
| @@ -271,13 +289,27 @@ func windowCrop(r ports.Rect, scale float64, width, height int) image.Rectangle | |||
| 271 | 289 | int(math.Ceil(float64(r.X+r.W)*scale)), int(math.Ceil(float64(r.Y+r.H)*scale))).Intersect(image.Rect(0, 0, width, height)) | |
| 272 | 290 | } | |
| 273 | 291 | ||
| 274 | - | func cropImage(buf []byte, stride int, r image.Rectangle) *image.RGBA { | |
| 275 | - | img := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) | |
| 276 | - | // Convert only this window's BGRA pixels, not the full output. | |
| 277 | - | src := &image.RGBA{Pix: buf, Stride: stride, Rect: image.Rect(0, 0, stride/4, len(buf)/stride)} | |
| 278 | - | draw.Draw(img, img.Bounds(), src, r.Min, draw.Src) | |
| 279 | - | for i := 0; i < len(img.Pix); i += 4 { | |
| 280 | - | img.Pix[i], img.Pix[i+2], img.Pix[i+3] = img.Pix[i+2], img.Pix[i], 255 | |
| 292 | + | // cropHash hashes the raw pixels of r without copying them. | |
| 293 | + | func (w *worker) cropHash(buf []byte, stride int, r image.Rectangle) uint64 { | |
| 294 | + | var h maphash.Hash | |
| 295 | + | h.SetSeed(w.cacheSeed) | |
| 296 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 297 | + | _, _ = h.Write(buf[y*stride+r.Min.X*4 : y*stride+r.Max.X*4]) | |
| 298 | + | } | |
| 299 | + | return h.Sum64() | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // cropGray converts the BGRA pixels of r to 8-bit luma; only this window is | |
| 303 | + | // converted, not the full output. | |
| 304 | + | func cropGray(buf []byte, stride int, r image.Rectangle) *image.Gray { | |
| 305 | + | img := image.NewGray(image.Rect(0, 0, r.Dx(), r.Dy())) | |
| 306 | + | for y := 0; y < r.Dy(); y++ { | |
| 307 | + | src := buf[(r.Min.Y+y)*stride+r.Min.X*4:][:r.Dx()*4] | |
| 308 | + | dst := img.Pix[y*img.Stride:][:r.Dx()] | |
| 309 | + | for x := range dst { | |
| 310 | + | p := src[x*4 : x*4+4 : x*4+4] | |
| 311 | + | dst[x] = uint8((uint32(p[2])*299 + uint32(p[1])*587 + uint32(p[0])*114 + 500) / 1000) | |
| 312 | + | } | |
| 281 | 313 | } | |
| 282 | 314 | return img | |
| 283 | 315 | } | |
| @@ -8,8 +8,8 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "hash/maphash" | 9 | "hash/maphash" | ||
| 10 | "image" | 10 | "image" | ||
| 11 | - | "image/draw" | |||
| 12 | "math" | 11 | "math" | ||
| 12 | + | "runtime/debug" | |||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/neferwl/internal/adapters/logging" | 15 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| @@ -55,7 +55,6 @@ type atspiResult struct { | |||||
| 55 | 55 | ||||
| 56 | type worker struct { | 56 | type worker struct { | ||
| 57 | source targetSource | 57 | source targetSource | ||
| 58 | - | buf []byte | |||
| 59 | cache map[cropKey]cropCache | 58 | cache map[cropKey]cropCache | ||
| 60 | cacheSeed maphash.Seed | 59 | cacheSeed maphash.Seed | ||
| 61 | warnedSource bool | 60 | warnedSource bool | ||
| @@ -76,6 +75,8 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch | |||||
| 76 | return nil | 75 | return nil | ||
| 77 | } | 76 | } | ||
| 78 | w.dump(ctx) | 77 | w.dump(ctx) | ||
| 78 | + | // Nothing of the scan is kept: give the frame back to the system. | |||
| 79 | + | debug.FreeOSMemory() | |||
| 79 | } | 80 | } | ||
| 80 | } | 81 | } | ||
| 81 | } | 82 | } | ||
| @@ -118,11 +119,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||||
| 118 | // It is always waited for: the source is not shared between requests. | 119 | // It is always waited for: the source is not shared between requests. | ||
| 119 | atspi := w.startSource(ctx, req) | 120 | atspi := w.startSource(ctx, req) | ||
| 120 | defer func() { <-atspi.done }() | 121 | defer func() { <-atspi.done }() | ||
| 121 | - | size := req.Width * req.Height * 4 | 122 | + | // The capture buffer lives for this scan only; a dump job may keep it |
| 122 | - | if cap(w.buf) < size { | 123 | + | // until the dump is written. |
| 123 | - | w.buf = make([]byte, size) | 124 | + | buf := make([]byte, req.Width*req.Height*4) |
| 124 | - | } | |||
| 125 | - | buf := w.buf[:size] | |||
| 126 | 125 | ||||
| 127 | w.n++ | 126 | w.n++ | ||
| 128 | id := ports.CaptureIDInternal | w.n | 127 | id := ports.CaptureIDInternal | w.n | ||
| @@ -168,11 +167,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests | |||||
| 168 | return fail(ctx.Err()) | 167 | return fail(ctx.Err()) | ||
| 169 | } | 168 | } | ||
| 170 | case <-timer.C: | 169 | case <-timer.C: | ||
| 171 | - | // The output may still write into buf: do not reuse it. | 170 | + | // The output may still write into buf: it is never reused. |
| 172 | - | w.buf = nil | |||
| 173 | return fail(ErrTimeout) | 171 | return fail(ErrTimeout) | ||
| 174 | case <-ctx.Done(): | 172 | case <-ctx.Done(): | ||
| 175 | - | w.buf = nil | |||
| 176 | return fail(ctx.Err()) | 173 | return fail(ctx.Err()) | ||
| 177 | } | 174 | } | ||
| 178 | } | 175 | } | ||
| @@ -201,6 +198,16 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||||
| 201 | var allWords []OCRWord | 198 | var allWords []OCRWord | ||
| 202 | pixels, links := 0, 0 | 199 | pixels, links := 0, 0 | ||
| 203 | cacheHit := true | 200 | cacheHit := true | ||
| 201 | + | // Phase 1 reads the frame: only the crops that miss the cache are | |||
| 202 | + | // copied, as grayscale. Phase 2 (OCR) does not need the frame. | |||
| 203 | + | type job struct { | |||
| 204 | + | key cropKey | |||
| 205 | + | r image.Rectangle | |||
| 206 | + | hash uint64 | |||
| 207 | + | gray *image.Gray | |||
| 208 | + | hit bool | |||
| 209 | + | } | |||
| 210 | + | var jobs []job | |||
| 204 | for _, win := range req.Windows { | 211 | for _, win := range req.Windows { | ||
| 205 | if ar.covered[win.ID] { | 212 | if ar.covered[win.ID] { | ||
| 206 | continue | 213 | continue | ||
| @@ -211,24 +218,35 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||||
| 211 | } | 218 | } | ||
| 212 | crops = append(crops, r) | 219 | crops = append(crops, r) | ||
| 213 | pixels += r.Dx() * r.Dy() | 220 | pixels += r.Dx() * r.Dy() | ||
| 214 | - | img := cropImage(buf, stride, r) | |||
| 215 | key := cropKey{output: req.Output, window: win.ID} | 221 | key := cropKey{output: req.Output, window: win.ID} | ||
| 216 | - | hash := maphash.Bytes(w.cacheSeed, img.Pix) | 222 | + | hash := w.cropHash(buf, stride, r) |
| 217 | entry, ok := w.cache[key] | 223 | entry, ok := w.cache[key] | ||
| 218 | - | hit := ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy() | 224 | + | j := job{key: key, r: r, hash: hash, hit: ok && entry.hash == hash && entry.width == r.Dx() && entry.height == r.Dy()} |
| 219 | - | cacheHit = cacheHit && hit | 225 | + | cacheHit = cacheHit && j.hit |
| 220 | - | if !hit { | 226 | + | if !j.hit { |
| 221 | - | words, _, err := readWords(ctx, img) | 227 | + | j.gray = cropGray(buf, stride, r) |
| 228 | + | } | |||
| 229 | + | jobs = append(jobs, j) | |||
| 230 | + | } | |||
| 231 | + | if w.dumpDir != "" { | |||
| 232 | + | // The dump reads the frame after the result is sent. | |||
| 233 | + | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, crops: crops, captureD: captureDur, atspiD: ar.dur} | |||
| 234 | + | } | |||
| 235 | + | buf = nil | |||
| 236 | + | for _, j := range jobs { | |||
| 237 | + | entry := w.cache[j.key] | |||
| 238 | + | if !j.hit { | |||
| 239 | + | words, _, err := readWords(ctx, j.gray) | |||
| 222 | if err != nil { | 240 | if err != nil { | ||
| 223 | failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") }) | 241 | failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") }) | ||
| 224 | continue | 242 | continue | ||
| 225 | } | 243 | } | ||
| 226 | - | entry = cropCache{hash: hash, width: r.Dx(), height: r.Dy(), words: words} | 244 | + | entry = cropCache{hash: j.hash, width: j.r.Dx(), height: j.r.Dy(), words: words} |
| 227 | } | 245 | } | ||
| 228 | - | nextCache[key] = entry | 246 | + | nextCache[j.key] = entry |
| 229 | words := append([]OCRWord(nil), entry.words...) | 247 | words := append([]OCRWord(nil), entry.words...) | ||
| 230 | for i := range words { | 248 | for i := range words { | ||
| 231 | - | words[i].Rect = words[i].Rect.Add(r.Min) | 249 | + | words[i].Rect = words[i].Rect.Add(j.r.Min) |
| 232 | } | 250 | } | ||
| 233 | allWords = append(allWords, words...) | 251 | allWords = append(allWords, words...) | ||
| 234 | targets := textLinks(words, req.Scale) | 252 | targets := textLinks(words, req.Scale) | ||
| @@ -238,8 +256,8 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||||
| 238 | w.cache = nextCache | 256 | w.cache = nextCache | ||
| 239 | ocrDur := time.Since(t0) | 257 | ocrDur := time.Since(t0) | ||
| 240 | res.Targets = out | 258 | res.Targets = out | ||
| 241 | - | if w.dumpDir != "" { | 259 | + | if p := w.pending; p != nil { |
| 242 | - | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, atspi: nAtspi, crops: crops, words: allWords, captureD: captureDur, scanD: ocrDur, atspiD: ar.dur} | 260 | + | p.targets, p.atspi, p.words, p.scanD = out, nAtspi, allWords, ocrDur |
| 243 | } | 261 | } | ||
| 244 | log.Info().Str("output", req.Output). | 262 | log.Info().Str("output", req.Output). | ||
| 245 | Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi). | 263 | Float64("atspi_ms", ms(ar.dur)).Int("atspi_windows", len(ar.covered)).Int("atspi_targets", nAtspi). | ||
| @@ -271,13 +289,27 @@ func windowCrop(r ports.Rect, scale float64, width, height int) image.Rectangle | |||||
| 271 | int(math.Ceil(float64(r.X+r.W)*scale)), int(math.Ceil(float64(r.Y+r.H)*scale))).Intersect(image.Rect(0, 0, width, height)) | 289 | int(math.Ceil(float64(r.X+r.W)*scale)), int(math.Ceil(float64(r.Y+r.H)*scale))).Intersect(image.Rect(0, 0, width, height)) | ||
| 272 | } | 290 | } | ||
| 273 | 291 | ||||
| 274 | - | func cropImage(buf []byte, stride int, r image.Rectangle) *image.RGBA { | 292 | + | // cropHash hashes the raw pixels of r without copying them. |
| 275 | - | img := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) | 293 | + | func (w *worker) cropHash(buf []byte, stride int, r image.Rectangle) uint64 { |
| 276 | - | // Convert only this window's BGRA pixels, not the full output. | 294 | + | var h maphash.Hash |
| 277 | - | src := &image.RGBA{Pix: buf, Stride: stride, Rect: image.Rect(0, 0, stride/4, len(buf)/stride)} | 295 | + | h.SetSeed(w.cacheSeed) |
| 278 | - | draw.Draw(img, img.Bounds(), src, r.Min, draw.Src) | 296 | + | for y := r.Min.Y; y < r.Max.Y; y++ { |
| 279 | - | for i := 0; i < len(img.Pix); i += 4 { | 297 | + | _, _ = h.Write(buf[y*stride+r.Min.X*4 : y*stride+r.Max.X*4]) |
| 280 | - | img.Pix[i], img.Pix[i+2], img.Pix[i+3] = img.Pix[i+2], img.Pix[i], 255 | 298 | + | } |
| 299 | + | return h.Sum64() | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // cropGray converts the BGRA pixels of r to 8-bit luma; only this window is | |||
| 303 | + | // converted, not the full output. | |||
| 304 | + | func cropGray(buf []byte, stride int, r image.Rectangle) *image.Gray { | |||
| 305 | + | img := image.NewGray(image.Rect(0, 0, r.Dx(), r.Dy())) | |||
| 306 | + | for y := 0; y < r.Dy(); y++ { | |||
| 307 | + | src := buf[(r.Min.Y+y)*stride+r.Min.X*4:][:r.Dx()*4] | |||
| 308 | + | dst := img.Pix[y*img.Stride:][:r.Dx()] | |||
| 309 | + | for x := range dst { | |||
| 310 | + | p := src[x*4 : x*4+4 : x*4+4] | |||
| 311 | + | dst[x] = uint8((uint32(p[2])*299 + uint32(p[1])*587 + uint32(p[0])*114 + 500) / 1000) | |||
| 312 | + | } | |||
| 281 | } | 313 | } | ||
| 282 | return img | 314 | return img | ||
| 283 | } | 315 | } | ||
M internal/adapters/hints/hints_test.go +27 -0
| @@ -332,3 +332,30 @@ func TestScanSourceErrorUsesOCR(t *testing.T) { | |||
| 332 | 332 | require.NoError(t, res.Err) | |
| 333 | 333 | require.NotEmpty(t, res.Targets) | |
| 334 | 334 | } | |
| 335 | + | ||
| 336 | + | // The worker keeps no frame: the buffer belongs to the scan, and to the dump | |
| 337 | + | // job until it is written. | |
| 338 | + | func TestWorkerHoldsNoFrameAfterScan(t *testing.T) { | |
| 339 | + | for _, dumpDir := range []string{"", filepath.Join(t.TempDir(), "hints")} { | |
| 340 | + | w := &worker{dumpDir: dumpDir} | |
| 341 | + | requests := make(chan ports.HintScanRequest) | |
| 342 | + | captures := make(chan ports.CaptureRequest, 1) | |
| 343 | + | captured := make(chan ports.CaptureDone, 1) | |
| 344 | + | results := make(chan ports.HintScanResult, 1) | |
| 345 | + | go func() { | |
| 346 | + | creq := <-captures | |
| 347 | + | captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 348 | + | }() | |
| 349 | + | req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1} | |
| 350 | + | res := w.serve(context.Background(), req, requests, captures, captured, results, 5*time.Second) | |
| 351 | + | require.NoError(t, res.Err) | |
| 352 | + | if dumpDir == "" { | |
| 353 | + | require.Nil(t, w.pending) | |
| 354 | + | } else { | |
| 355 | + | require.NotNil(t, w.pending) | |
| 356 | + | } | |
| 357 | + | w.dump(context.Background()) | |
| 358 | + | require.Nil(t, w.pending) | |
| 359 | + | require.Empty(t, w.cache) // no window, no cached crop | |
| 360 | + | } | |
| 361 | + | } | |
| @@ -332,3 +332,30 @@ func TestScanSourceErrorUsesOCR(t *testing.T) { | |||||
| 332 | require.NoError(t, res.Err) | 332 | require.NoError(t, res.Err) | ||
| 333 | require.NotEmpty(t, res.Targets) | 333 | require.NotEmpty(t, res.Targets) | ||
| 334 | } | 334 | } | ||
| 335 | + | ||||
| 336 | + | // The worker keeps no frame: the buffer belongs to the scan, and to the dump | |||
| 337 | + | // job until it is written. | |||
| 338 | + | func TestWorkerHoldsNoFrameAfterScan(t *testing.T) { | |||
| 339 | + | for _, dumpDir := range []string{"", filepath.Join(t.TempDir(), "hints")} { | |||
| 340 | + | w := &worker{dumpDir: dumpDir} | |||
| 341 | + | requests := make(chan ports.HintScanRequest) | |||
| 342 | + | captures := make(chan ports.CaptureRequest, 1) | |||
| 343 | + | captured := make(chan ports.CaptureDone, 1) | |||
| 344 | + | results := make(chan ports.HintScanResult, 1) | |||
| 345 | + | go func() { | |||
| 346 | + | creq := <-captures | |||
| 347 | + | captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |||
| 348 | + | }() | |||
| 349 | + | req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1} | |||
| 350 | + | res := w.serve(context.Background(), req, requests, captures, captured, results, 5*time.Second) | |||
| 351 | + | require.NoError(t, res.Err) | |||
| 352 | + | if dumpDir == "" { | |||
| 353 | + | require.Nil(t, w.pending) | |||
| 354 | + | } else { | |||
| 355 | + | require.NotNil(t, w.pending) | |||
| 356 | + | } | |||
| 357 | + | w.dump(context.Background()) | |||
| 358 | + | require.Nil(t, w.pending) | |||
| 359 | + | require.Empty(t, w.cache) // no window, no cached crop | |||
| 360 | + | } | |||
| 361 | + | } | |||
M internal/adapters/hints/hybrid_test.go +30 -1
| @@ -39,7 +39,7 @@ func TestHybridCacheAndMerge(t *testing.T) { | |||
| 39 | 39 | req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins} | |
| 40 | 40 | seed := maphash.MakeSeed() | |
| 41 | 41 | r := windowCrop(wins[1].Rect, 1, 200, 100) | |
| 42 | - | entry := cropCache{hash: maphash.Bytes(seed, cropImage(buf, 800, r).Pix), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}} | |
| 42 | + | entry := cropCache{hash: (&worker{cacheSeed: seed}).cropHash(buf, 800, r), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}} | |
| 43 | 43 | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}} | |
| 44 | 44 | link := ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}} | |
| 45 | 45 | uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}} | |
| @@ -86,3 +86,32 @@ func TestStripMappingAndPolarity(t *testing.T) { | |||
| 86 | 86 | small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true) | |
| 87 | 87 | require.Equal(t, 2, small[0].offsets[0].factor) | |
| 88 | 88 | } | |
| 89 | + | ||
| 90 | + | func TestCropGrayAndHash(t *testing.T) { | |
| 91 | + | buf := make([]byte, 4*4*4) | |
| 92 | + | for y := 0; y < 4; y++ { | |
| 93 | + | for x := 0; x < 4; x++ { | |
| 94 | + | copy(buf[y*16+x*4:], []byte{0, 0, 255, 255}) // BGRA red | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | g := cropGray(buf, 16, image.Rect(1, 1, 3, 3)) | |
| 98 | + | require.Equal(t, image.Rect(0, 0, 2, 2), g.Bounds()) | |
| 99 | + | require.Equal(t, uint8(76), g.GrayAt(1, 1).Y) | |
| 100 | + | w := worker{cacheSeed: maphash.MakeSeed()} | |
| 101 | + | a := w.cropHash(buf, 16, image.Rect(1, 1, 3, 3)) | |
| 102 | + | buf[0]++ // outside the crop | |
| 103 | + | require.Equal(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |
| 104 | + | buf[16+4]++ | |
| 105 | + | require.NotEqual(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestUpscaleBandGray(t *testing.T) { | |
| 109 | + | g := image.NewGray(image.Rect(0, 0, 3, 2)) | |
| 110 | + | for i := range g.Pix { | |
| 111 | + | g.Pix[i] = uint8(i + 1) | |
| 112 | + | } | |
| 113 | + | sub := g.SubImage(image.Rect(1, 1, 3, 2)).(*image.Gray) | |
| 114 | + | up := upscaleBand(sub) | |
| 115 | + | require.Equal(t, image.Rect(0, 0, 4, 2), up.Bounds()) | |
| 116 | + | require.Equal(t, []uint8{5, 5, 6, 6, 5, 5, 6, 6}, up.Pix) | |
| 117 | + | } | |
| @@ -39,7 +39,7 @@ func TestHybridCacheAndMerge(t *testing.T) { | |||||
| 39 | req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins} | 39 | req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins} | ||
| 40 | seed := maphash.MakeSeed() | 40 | seed := maphash.MakeSeed() | ||
| 41 | r := windowCrop(wins[1].Rect, 1, 200, 100) | 41 | r := windowCrop(wins[1].Rect, 1, 200, 100) | ||
| 42 | - | entry := cropCache{hash: maphash.Bytes(seed, cropImage(buf, 800, r).Pix), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}} | 42 | + | entry := cropCache{hash: (&worker{cacheSeed: seed}).cropHash(buf, 800, r), width: 100, height: 100, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}} |
| 43 | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}} | 43 | button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}} | ||
| 44 | link := ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}} | 44 | link := ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}} | ||
| 45 | uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}} | 45 | uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}} | ||
| @@ -86,3 +86,32 @@ func TestStripMappingAndPolarity(t *testing.T) { | |||||
| 86 | small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true) | 86 | small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true) | ||
| 87 | require.Equal(t, 2, small[0].offsets[0].factor) | 87 | require.Equal(t, 2, small[0].offsets[0].factor) | ||
| 88 | } | 88 | } | ||
| 89 | + | ||||
| 90 | + | func TestCropGrayAndHash(t *testing.T) { | |||
| 91 | + | buf := make([]byte, 4*4*4) | |||
| 92 | + | for y := 0; y < 4; y++ { | |||
| 93 | + | for x := 0; x < 4; x++ { | |||
| 94 | + | copy(buf[y*16+x*4:], []byte{0, 0, 255, 255}) // BGRA red | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | g := cropGray(buf, 16, image.Rect(1, 1, 3, 3)) | |||
| 98 | + | require.Equal(t, image.Rect(0, 0, 2, 2), g.Bounds()) | |||
| 99 | + | require.Equal(t, uint8(76), g.GrayAt(1, 1).Y) | |||
| 100 | + | w := worker{cacheSeed: maphash.MakeSeed()} | |||
| 101 | + | a := w.cropHash(buf, 16, image.Rect(1, 1, 3, 3)) | |||
| 102 | + | buf[0]++ // outside the crop | |||
| 103 | + | require.Equal(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |||
| 104 | + | buf[16+4]++ | |||
| 105 | + | require.NotEqual(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestUpscaleBandGray(t *testing.T) { | |||
| 109 | + | g := image.NewGray(image.Rect(0, 0, 3, 2)) | |||
| 110 | + | for i := range g.Pix { | |||
| 111 | + | g.Pix[i] = uint8(i + 1) | |||
| 112 | + | } | |||
| 113 | + | sub := g.SubImage(image.Rect(1, 1, 3, 2)).(*image.Gray) | |||
| 114 | + | up := upscaleBand(sub) | |||
| 115 | + | require.Equal(t, image.Rect(0, 0, 4, 2), up.Bounds()) | |||
| 116 | + | require.Equal(t, []uint8{5, 5, 6, 6, 5, 5, 6, 6}, up.Pix) | |||
| 117 | + | } | |||
M internal/adapters/hints/ocr.go +30 -16
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "image" | |
| 9 | 9 | "image/png" | |
| 10 | + | "io" | |
| 10 | 11 | "math" | |
| 11 | 12 | "net" | |
| 12 | 13 | "os" | |
| @@ -89,7 +90,15 @@ func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) { | |||
| 89 | 90 | return words, scanner.Err() | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | - | func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |
| 93 | + | // ocrWorkers is the maximum number of bands recognized in parallel. 4 keeps | |
| 94 | + | // the tesseract processes at about 60% of the memory of 8 for the same time. | |
| 95 | + | var ocrWorkers = 4 | |
| 96 | + | ||
| 97 | + | // pngLevel is the compression of the images piped to tesseract. | |
| 98 | + | var pngLevel = png.BestSpeed | |
| 99 | + | ||
| 100 | + | // readWords recognizes the words of img, a window crop, in parallel bands. | |
| 101 | + | func readWords(ctx context.Context, img *image.Gray) ([]OCRWord, int, error) { | |
| 93 | 102 | binary, err := exec.LookPath("tesseract") | |
| 94 | 103 | if err != nil { | |
| 95 | 104 | return nil, 0, err | |
| @@ -100,7 +109,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||
| 100 | 109 | if err != nil { | |
| 101 | 110 | return nil, 0, err | |
| 102 | 111 | } | |
| 103 | - | n := min(runtime.GOMAXPROCS(0), 8, img.Bounds().Dy()) | |
| 112 | + | n := min(runtime.GOMAXPROCS(0), ocrWorkers, img.Bounds().Dy()) | |
| 104 | 113 | results := make([][]OCRWord, n) | |
| 105 | 114 | errs := make([]error, n) | |
| 106 | 115 | var wg sync.WaitGroup | |
| @@ -108,7 +117,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||
| 108 | 117 | wg.Go(func() { | |
| 109 | 118 | top := max(0, i*img.Bounds().Dy()/n-40) | |
| 110 | 119 | bottom := min(img.Bounds().Dy(), (i+1)*img.Bounds().Dy()/n+40) | |
| 111 | - | crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom)) | |
| 120 | + | crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom)).(*image.Gray) | |
| 112 | 121 | results[i], errs[i] = recognizeBand(ctx, binary, lang, crop) | |
| 113 | 122 | if errs[i] != nil { | |
| 114 | 123 | return | |
| @@ -121,8 +130,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||
| 121 | 130 | } | |
| 122 | 131 | sort.Ints(heights) | |
| 123 | 132 | if len(heights) > 0 && heights[len(heights)/2] < 12 { | |
| 124 | - | larger := upscaleBand(crop) | |
| 125 | - | results[i], errs[i] = recognizeBand(ctx, binary, lang, larger) | |
| 133 | + | results[i], errs[i] = recognizeBand(ctx, binary, lang, upscaleBand(crop)) | |
| 126 | 134 | for j := range results[i] { | |
| 127 | 135 | r := results[i][j].Rect | |
| 128 | 136 | results[i][j].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2) | |
| @@ -264,19 +272,21 @@ func recognizeBand(ctx context.Context, binary, lang string, img image.Image) ([ | |||
| 264 | 272 | } | |
| 265 | 273 | ||
| 266 | 274 | func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) { | |
| 267 | - | var input bytes.Buffer | |
| 268 | - | enc := png.Encoder{CompressionLevel: png.BestSpeed} | |
| 269 | - | if err := enc.Encode(&input, img); err != nil { | |
| 270 | - | return nil, err | |
| 271 | - | } | |
| 272 | 275 | args := []string{"stdin", "stdout", "--psm", psm, "-l", lang} | |
| 273 | 276 | if normalized { | |
| 274 | 277 | args = append(args, "-c", "tessedit_do_invert=0") | |
| 275 | 278 | } | |
| 276 | 279 | args = append(args, "tsv") | |
| 280 | + | // The PNG streams into tesseract; no encoded copy is held. | |
| 281 | + | pr, pw := io.Pipe() | |
| 282 | + | defer func() { _ = pr.Close() }() | |
| 283 | + | go func() { | |
| 284 | + | enc := png.Encoder{CompressionLevel: pngLevel} | |
| 285 | + | _ = pw.CloseWithError(enc.Encode(pw, img)) | |
| 286 | + | }() | |
| 277 | 287 | cmd := exec.CommandContext(ctx, binary, args...) | |
| 278 | 288 | cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1") | |
| 279 | - | cmd.Stdin = &input | |
| 289 | + | cmd.Stdin = pr | |
| 280 | 290 | var stderr bytes.Buffer | |
| 281 | 291 | cmd.Stderr = &stderr | |
| 282 | 292 | output, err := cmd.Output() | |
| @@ -286,13 +296,17 @@ func recognizeImage(ctx context.Context, binary, lang string, img image.Image, p | |||
| 286 | 296 | return parseTSV(output, image.Point{}) | |
| 287 | 297 | } | |
| 288 | 298 | ||
| 289 | - | func upscaleBand(src image.Image) *image.RGBA { | |
| 299 | + | // upscaleBand doubles a grayscale band with nearest-neighbour sampling. | |
| 300 | + | func upscaleBand(src *image.Gray) *image.Gray { | |
| 290 | 301 | b := src.Bounds() | |
| 291 | - | dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*2, b.Dy()*2)) | |
| 292 | - | for y := 0; y < dst.Bounds().Dy(); y++ { | |
| 293 | - | for x := 0; x < dst.Bounds().Dx(); x++ { | |
| 294 | - | dst.Set(x, y, src.At(b.Min.X+x/2, b.Min.Y+y/2)) | |
| 302 | + | dst := image.NewGray(image.Rect(0, 0, b.Dx()*2, b.Dy()*2)) | |
| 303 | + | for y := 0; y < b.Dy(); y++ { | |
| 304 | + | row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride+(b.Min.X-src.Rect.Min.X):][:b.Dx()] | |
| 305 | + | top := dst.Pix[2*y*dst.Stride:][:dst.Stride] | |
| 306 | + | for x, v := range row { | |
| 307 | + | top[2*x], top[2*x+1] = v, v | |
| 295 | 308 | } | |
| 309 | + | copy(dst.Pix[(2*y+1)*dst.Stride:], top) | |
| 296 | 310 | } | |
| 297 | 311 | return dst | |
| 298 | 312 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "image" | 8 | "image" | ||
| 9 | "image/png" | 9 | "image/png" | ||
| 10 | + | "io" | |||
| 10 | "math" | 11 | "math" | ||
| 11 | "net" | 12 | "net" | ||
| 12 | "os" | 13 | "os" | ||
| @@ -89,7 +90,15 @@ func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) { | |||||
| 89 | return words, scanner.Err() | 90 | return words, scanner.Err() | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 92 | - | func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | 93 | + | // ocrWorkers is the maximum number of bands recognized in parallel. 4 keeps |
| 94 | + | // the tesseract processes at about 60% of the memory of 8 for the same time. | |||
| 95 | + | var ocrWorkers = 4 | |||
| 96 | + | ||||
| 97 | + | // pngLevel is the compression of the images piped to tesseract. | |||
| 98 | + | var pngLevel = png.BestSpeed | |||
| 99 | + | ||||
| 100 | + | // readWords recognizes the words of img, a window crop, in parallel bands. | |||
| 101 | + | func readWords(ctx context.Context, img *image.Gray) ([]OCRWord, int, error) { | |||
| 93 | binary, err := exec.LookPath("tesseract") | 102 | binary, err := exec.LookPath("tesseract") | ||
| 94 | if err != nil { | 103 | if err != nil { | ||
| 95 | return nil, 0, err | 104 | return nil, 0, err | ||
| @@ -100,7 +109,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||||
| 100 | if err != nil { | 109 | if err != nil { | ||
| 101 | return nil, 0, err | 110 | return nil, 0, err | ||
| 102 | } | 111 | } | ||
| 103 | - | n := min(runtime.GOMAXPROCS(0), 8, img.Bounds().Dy()) | 112 | + | n := min(runtime.GOMAXPROCS(0), ocrWorkers, img.Bounds().Dy()) |
| 104 | results := make([][]OCRWord, n) | 113 | results := make([][]OCRWord, n) | ||
| 105 | errs := make([]error, n) | 114 | errs := make([]error, n) | ||
| 106 | var wg sync.WaitGroup | 115 | var wg sync.WaitGroup | ||
| @@ -108,7 +117,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||||
| 108 | wg.Go(func() { | 117 | wg.Go(func() { | ||
| 109 | top := max(0, i*img.Bounds().Dy()/n-40) | 118 | top := max(0, i*img.Bounds().Dy()/n-40) | ||
| 110 | bottom := min(img.Bounds().Dy(), (i+1)*img.Bounds().Dy()/n+40) | 119 | bottom := min(img.Bounds().Dy(), (i+1)*img.Bounds().Dy()/n+40) | ||
| 111 | - | crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom)) | 120 | + | crop := img.SubImage(image.Rect(0, top, img.Bounds().Dx(), bottom)).(*image.Gray) |
| 112 | results[i], errs[i] = recognizeBand(ctx, binary, lang, crop) | 121 | results[i], errs[i] = recognizeBand(ctx, binary, lang, crop) | ||
| 113 | if errs[i] != nil { | 122 | if errs[i] != nil { | ||
| 114 | return | 123 | return | ||
| @@ -121,8 +130,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) { | |||||
| 121 | } | 130 | } | ||
| 122 | sort.Ints(heights) | 131 | sort.Ints(heights) | ||
| 123 | if len(heights) > 0 && heights[len(heights)/2] < 12 { | 132 | if len(heights) > 0 && heights[len(heights)/2] < 12 { | ||
| 124 | - | larger := upscaleBand(crop) | 133 | + | results[i], errs[i] = recognizeBand(ctx, binary, lang, upscaleBand(crop)) |
| 125 | - | results[i], errs[i] = recognizeBand(ctx, binary, lang, larger) | |||
| 126 | for j := range results[i] { | 134 | for j := range results[i] { | ||
| 127 | r := results[i][j].Rect | 135 | r := results[i][j].Rect | ||
| 128 | results[i][j].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2) | 136 | results[i][j].Rect = image.Rect(r.Min.X/2, r.Min.Y/2, (r.Max.X+1)/2, (r.Max.Y+1)/2) | ||
| @@ -264,19 +272,21 @@ func recognizeBand(ctx context.Context, binary, lang string, img image.Image) ([ | |||||
| 264 | } | 272 | } | ||
| 265 | 273 | ||||
| 266 | func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) { | 274 | func recognizeImage(ctx context.Context, binary, lang string, img image.Image, psm string, normalized bool) ([]OCRWord, error) { | ||
| 267 | - | var input bytes.Buffer | |||
| 268 | - | enc := png.Encoder{CompressionLevel: png.BestSpeed} | |||
| 269 | - | if err := enc.Encode(&input, img); err != nil { | |||
| 270 | - | return nil, err | |||
| 271 | - | } | |||
| 272 | args := []string{"stdin", "stdout", "--psm", psm, "-l", lang} | 275 | args := []string{"stdin", "stdout", "--psm", psm, "-l", lang} | ||
| 273 | if normalized { | 276 | if normalized { | ||
| 274 | args = append(args, "-c", "tessedit_do_invert=0") | 277 | args = append(args, "-c", "tessedit_do_invert=0") | ||
| 275 | } | 278 | } | ||
| 276 | args = append(args, "tsv") | 279 | args = append(args, "tsv") | ||
| 280 | + | // The PNG streams into tesseract; no encoded copy is held. | |||
| 281 | + | pr, pw := io.Pipe() | |||
| 282 | + | defer func() { _ = pr.Close() }() | |||
| 283 | + | go func() { | |||
| 284 | + | enc := png.Encoder{CompressionLevel: pngLevel} | |||
| 285 | + | _ = pw.CloseWithError(enc.Encode(pw, img)) | |||
| 286 | + | }() | |||
| 277 | cmd := exec.CommandContext(ctx, binary, args...) | 287 | cmd := exec.CommandContext(ctx, binary, args...) | ||
| 278 | cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1") | 288 | cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1") | ||
| 279 | - | cmd.Stdin = &input | 289 | + | cmd.Stdin = pr |
| 280 | var stderr bytes.Buffer | 290 | var stderr bytes.Buffer | ||
| 281 | cmd.Stderr = &stderr | 291 | cmd.Stderr = &stderr | ||
| 282 | output, err := cmd.Output() | 292 | output, err := cmd.Output() | ||
| @@ -286,13 +296,17 @@ func recognizeImage(ctx context.Context, binary, lang string, img image.Image, p | |||||
| 286 | return parseTSV(output, image.Point{}) | 296 | return parseTSV(output, image.Point{}) | ||
| 287 | } | 297 | } | ||
| 288 | 298 | ||||
| 289 | - | func upscaleBand(src image.Image) *image.RGBA { | 299 | + | // upscaleBand doubles a grayscale band with nearest-neighbour sampling. |
| 300 | + | func upscaleBand(src *image.Gray) *image.Gray { | |||
| 290 | b := src.Bounds() | 301 | b := src.Bounds() | ||
| 291 | - | dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*2, b.Dy()*2)) | 302 | + | dst := image.NewGray(image.Rect(0, 0, b.Dx()*2, b.Dy()*2)) |
| 292 | - | for y := 0; y < dst.Bounds().Dy(); y++ { | 303 | + | for y := 0; y < b.Dy(); y++ { |
| 293 | - | for x := 0; x < dst.Bounds().Dx(); x++ { | 304 | + | row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride+(b.Min.X-src.Rect.Min.X):][:b.Dx()] |
| 294 | - | dst.Set(x, y, src.At(b.Min.X+x/2, b.Min.Y+y/2)) | 305 | + | top := dst.Pix[2*y*dst.Stride:][:dst.Stride] |
| 306 | + | for x, v := range row { | |||
| 307 | + | top[2*x], top[2*x+1] = v, v | |||
| 295 | } | 308 | } | ||
| 309 | + | copy(dst.Pix[(2*y+1)*dst.Stride:], top) | |||
| 296 | } | 310 | } | ||
| 297 | return dst | 311 | return dst | ||
| 298 | } | 312 | } | ||
M internal/adapters/hints/ocr_test.go +2 -2
| @@ -71,7 +71,7 @@ func TestRealOCR(t *testing.T) { | |||
| 71 | 71 | img := image.NewRGBA(src.Bounds()) | |
| 72 | 72 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | |
| 73 | 73 | start := time.Now() | |
| 74 | - | words, bands, err := readWords(context.Background(), img) | |
| 74 | + | words, bands, err := readWords(context.Background(), toGray(img)) | |
| 75 | 75 | if err != nil { | |
| 76 | 76 | t.Fatal(err) | |
| 77 | 77 | } | |
| @@ -116,7 +116,7 @@ func TestSmallGlyphOCR(t *testing.T) { | |||
| 116 | 116 | if err != nil { | |
| 117 | 117 | t.Fatal(err) | |
| 118 | 118 | } | |
| 119 | - | small := image.NewRGBA(image.Rect(0, 0, 640, 200)) | |
| 119 | + | small := image.NewGray(image.Rect(0, 0, 640, 200)) | |
| 120 | 120 | for y := 0; y < 200; y++ { | |
| 121 | 121 | for x := 0; x < 640; x++ { | |
| 122 | 122 | small.Set(x, y, src.At(x*2, y*2)) | |
| @@ -71,7 +71,7 @@ func TestRealOCR(t *testing.T) { | |||||
| 71 | img := image.NewRGBA(src.Bounds()) | 71 | img := image.NewRGBA(src.Bounds()) | ||
| 72 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | 72 | draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src) | ||
| 73 | start := time.Now() | 73 | start := time.Now() | ||
| 74 | - | words, bands, err := readWords(context.Background(), img) | 74 | + | words, bands, err := readWords(context.Background(), toGray(img)) |
| 75 | if err != nil { | 75 | if err != nil { | ||
| 76 | t.Fatal(err) | 76 | t.Fatal(err) | ||
| 77 | } | 77 | } | ||
| @@ -116,7 +116,7 @@ func TestSmallGlyphOCR(t *testing.T) { | |||||
| 116 | if err != nil { | 116 | if err != nil { | ||
| 117 | t.Fatal(err) | 117 | t.Fatal(err) | ||
| 118 | } | 118 | } | ||
| 119 | - | small := image.NewRGBA(image.Rect(0, 0, 640, 200)) | 119 | + | small := image.NewGray(image.Rect(0, 0, 640, 200)) |
| 120 | for y := 0; y < 200; y++ { | 120 | for y := 0; y < 200; y++ { | ||
| 121 | for x := 0; x < 640; x++ { | 121 | for x := 0; x < 640; x++ { | ||
| 122 | small.Set(x, y, src.At(x*2, y*2)) | 122 | small.Set(x, y, src.At(x*2, y*2)) | ||