detached dee1bbc

perf(hints): allocate scan buffers per request and release them

The capture buffer, the debug dump copy and the OCR band images no longer
outlive a scan. Crops are cut as 8-bit grayscale from the capture buffer, the
cache keeps only hash and words, PNGs stream into tesseract through a pipe, the
dump encodes from the capture buffer, and the heap is returned to the system
after each scan. OCR uses 4 parallel bands instead of 8.
commit
dee1bbce2a82e69a3e81624d3201577f9a942558
author
brice · · 2026-10-19 14:09 UTC
parent
5f783b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +182 -54
M internal/adapters/hints/benchmark_test.go +18 -1
@@ -8,6 +8,7 @@ import (
88 "os"
99 "os/exec"
1010 "path/filepath"
11+ "strconv"
1112 "testing"
1213 "time"
1314 )
@@ -25,6 +26,13 @@ func TestFrameBenchmark(t *testing.T) {
2526 if err != nil || len(files) == 0 {
2627 t.Fatal("no PNG frames")
2728 }
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+ }
2836 for i, file := range files {
2937 f, err := os.Open(file)
3038 if err != nil {
@@ -38,11 +46,14 @@ func TestFrameBenchmark(t *testing.T) {
3846 img := image.NewRGBA(image.Rect(0, 0, src.Bounds().Dx(), src.Bounds().Dy()))
3947 draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
4048 start := time.Now()
41- words, bands, err := readWords(context.Background(), img)
49+ words, bands, err := readWords(context.Background(), toGray(img))
4250 if err != nil {
4351 t.Fatal("recognition failed")
4452 }
4553 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+ }
4657 // Experimental strips use known OCR word regions, not pixel targets.
4758 // This is an oracle comparison, not a production optimization.
4859 lines := make([]image.Rectangle, 0, len(words))
@@ -59,3 +70,9 @@ func TestFrameBenchmark(t *testing.T) {
5970 }
6071 }
6172 }
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 @@
11 package hints
22
33 import (
4+ "bufio"
45 "encoding/json"
56 "fmt"
67 "image"
@@ -31,8 +32,8 @@ type Options struct {
3132 Source targetSource
3233 }
3334
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.
3637 type dumpJob struct {
3738 when time.Time
3839 req ports.HintScanRequest
@@ -104,14 +105,16 @@ func writeDump(dir string, j *dumpJob) error {
104105 }
105106 prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output))
106107
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.
108111 for y := 0; y < j.req.Height; y++ {
109112 row := j.pix[y*j.stride : y*j.stride+j.req.Width*4]
110- out := img.Pix[y*j.stride:]
111113 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
113115 }
114116 }
117+ img := &image.RGBA{Pix: j.pix, Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)}
115118 if err := writePNG(prefix+".png", img); err != nil {
116119 return err
117120 }
@@ -171,7 +174,13 @@ func safeName(s string) string {
171174
172175 func writePNG(path string, img image.Image) error {
173176 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+ })
175184 }
176185
177186 func writeFile(path string, fill func(*os.File) error) error {
@@ -1,6 +1,7 @@
1 package hints1 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 targetSource32 Source targetSource
32 }33 }
33 34
34-// dumpJob is one scan to write. Pix is the worker buffer, not a copy: it35+// 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.Time38 when time.Time
38 req ports.HintScanRequest39 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], 0xff114+ 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 err119 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 (
88 "fmt"
99 "hash/maphash"
1010 "image"
11- "image/draw"
1211 "math"
12+ "runtime/debug"
1313 "time"
1414
1515 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -55,7 +55,6 @@ type atspiResult struct {
5555
5656 type worker struct {
5757 source targetSource
58- buf []byte
5958 cache map[cropKey]cropCache
6059 cacheSeed maphash.Seed
6160 warnedSource bool
@@ -76,6 +75,8 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
7675 return nil
7776 }
7877 w.dump(ctx)
78+ // Nothing of the scan is kept: give the frame back to the system.
79+ debug.FreeOSMemory()
7980 }
8081 }
8182 }
@@ -118,11 +119,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
118119 // It is always waited for: the source is not shared between requests.
119120 atspi := w.startSource(ctx, req)
120121 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)
126125
127126 w.n++
128127 id := ports.CaptureIDInternal | w.n
@@ -168,11 +167,9 @@ func (w *worker) serve(ctx context.Context, req ports.HintScanRequest, requests
168167 return fail(ctx.Err())
169168 }
170169 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.
173171 return fail(ErrTimeout)
174172 case <-ctx.Done():
175- w.buf = nil
176173 return fail(ctx.Err())
177174 }
178175 }
@@ -201,6 +198,16 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
201198 var allWords []OCRWord
202199 pixels, links := 0, 0
203200 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
204211 for _, win := range req.Windows {
205212 if ar.covered[win.ID] {
206213 continue
@@ -211,24 +218,35 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
211218 }
212219 crops = append(crops, r)
213220 pixels += r.Dx() * r.Dy()
214- img := cropImage(buf, stride, r)
215221 key := cropKey{output: req.Output, window: win.ID}
216- hash := maphash.Bytes(w.cacheSeed, img.Pix)
222+ hash := w.cropHash(buf, stride, r)
217223 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)
222240 if err != nil {
223241 failureWarning.Do(func() { log.Warn().Err(err).Msg("OCR unavailable; install tesseract and tesseract-data-eng") })
224242 continue
225243 }
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}
227245 }
228- nextCache[key] = entry
246+ nextCache[j.key] = entry
229247 words := append([]OCRWord(nil), entry.words...)
230248 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)
232250 }
233251 allWords = append(allWords, words...)
234252 targets := textLinks(words, req.Scale)
@@ -238,8 +256,8 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte
238256 w.cache = nextCache
239257 ocrDur := time.Since(t0)
240258 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
243261 }
244262 log.Info().Str("output", req.Output).
245263 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
271289 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))
272290 }
273291
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+ }
281313 }
282314 return img
283315 }
@@ -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 targetSource57 source targetSource
58- buf []byte
59 cache map[cropKey]cropCache58 cache map[cropKey]cropCache
60 cacheSeed maphash.Seed59 cacheSeed maphash.Seed
61 warnedSource bool60 warnedSource bool
@@ -76,6 +75,8 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch
76 return nil75 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 * 4122+ // 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.n127 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 []OCRWord198 var allWords []OCRWord
202 pixels, links := 0, 0199 pixels, links := 0, 0
203 cacheHit := true200 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 continue213 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 && hit225+ 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 continue242 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] = entry246+ 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 = nextCache256 w.cache = nextCache
239 ocrDur := time.Since(t0)257 ocrDur := time.Since(t0)
240 res.Targets = out258 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], 255298+ }
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 img314 return img
283 }315 }
M internal/adapters/hints/hints_test.go +27 -0
@@ -332,3 +332,30 @@ func TestScanSourceErrorUsesOCR(t *testing.T) {
332332 require.NoError(t, res.Err)
333333 require.NotEmpty(t, res.Targets)
334334 }
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) {
3939 req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins}
4040 seed := maphash.MakeSeed()
4141 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)}}}
4343 button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}}
4444 link := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}}
4545 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) {
8686 small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true)
8787 require.Equal(t, 2, small[0].offsets[0].factor)
8888 }
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, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}}44 link := ports.HintTarget{Kind: ports.HintLink, 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 (
77 "fmt"
88 "image"
99 "image/png"
10+ "io"
1011 "math"
1112 "net"
1213 "os"
@@ -89,7 +90,15 @@ func parseTSV(data []byte, offset image.Point) ([]OCRWord, error) {
8990 return words, scanner.Err()
9091 }
9192
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) {
93102 binary, err := exec.LookPath("tesseract")
94103 if err != nil {
95104 return nil, 0, err
@@ -100,7 +109,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) {
100109 if err != nil {
101110 return nil, 0, err
102111 }
103- n := min(runtime.GOMAXPROCS(0), 8, img.Bounds().Dy())
112+ n := min(runtime.GOMAXPROCS(0), ocrWorkers, img.Bounds().Dy())
104113 results := make([][]OCRWord, n)
105114 errs := make([]error, n)
106115 var wg sync.WaitGroup
@@ -108,7 +117,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) {
108117 wg.Go(func() {
109118 top := max(0, i*img.Bounds().Dy()/n-40)
110119 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)
112121 results[i], errs[i] = recognizeBand(ctx, binary, lang, crop)
113122 if errs[i] != nil {
114123 return
@@ -121,8 +130,7 @@ func readWords(ctx context.Context, img *image.RGBA) ([]OCRWord, int, error) {
121130 }
122131 sort.Ints(heights)
123132 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))
126134 for j := range results[i] {
127135 r := results[i][j].Rect
128136 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) ([
264272 }
265273
266274 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- }
272275 args := []string{"stdin", "stdout", "--psm", psm, "-l", lang}
273276 if normalized {
274277 args = append(args, "-c", "tessedit_do_invert=0")
275278 }
276279 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+ }()
277287 cmd := exec.CommandContext(ctx, binary, args...)
278288 cmd.Env = append(os.Environ(), "OMP_THREAD_LIMIT=1")
279- cmd.Stdin = &input
289+ cmd.Stdin = pr
280290 var stderr bytes.Buffer
281291 cmd.Stderr = &stderr
282292 output, err := cmd.Output()
@@ -286,13 +296,17 @@ func recognizeImage(ctx context.Context, binary, lang string, img image.Image, p
286296 return parseTSV(output, image.Point{})
287297 }
288298
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 {
290301 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
295308 }
309+ copy(dst.Pix[(2*y+1)*dst.Stride:], top)
296310 }
297311 return dst
298312 }
@@ -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, err104 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, err110 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.WaitGroup115 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 return123 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].Rect135 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 = &input289+ cmd.Stdin = pr
280 var stderr bytes.Buffer290 var stderr bytes.Buffer
281 cmd.Stderr = &stderr291 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 dst311 return dst
298 }312 }
M internal/adapters/hints/ocr_test.go +2 -2
@@ -71,7 +71,7 @@ func TestRealOCR(t *testing.T) {
7171 img := image.NewRGBA(src.Bounds())
7272 draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
7373 start := time.Now()
74- words, bands, err := readWords(context.Background(), img)
74+ words, bands, err := readWords(context.Background(), toGray(img))
7575 if err != nil {
7676 t.Fatal(err)
7777 }
@@ -116,7 +116,7 @@ func TestSmallGlyphOCR(t *testing.T) {
116116 if err != nil {
117117 t.Fatal(err)
118118 }
119- small := image.NewRGBA(image.Rect(0, 0, 640, 200))
119+ small := image.NewGray(image.Rect(0, 0, 640, 200))
120120 for y := 0; y < 200; y++ {
121121 for x := 0; x < 640; x++ {
122122 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))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close