fix(hints): cap OCR processes per scan
- commit
- ce28c3608856048f341730bb6e56b14f5be52534
- author
- brice · · 2026-10-19 15:28 UTC
- parent
- 71feaa7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +58 -1
M internal/adapters/hints/ocr.go +13 -1
| @@ -154,12 +154,24 @@ func bandsOf(height int) []band { | |||
| 154 | 154 | func (w *worker) recognize(ctx context.Context, jobs []*bandJob) { | |
| 155 | 155 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) | |
| 156 | 156 | defer cancel() | |
| 157 | + | // One scan-wide pool: at most ocrWorkers tesseract processes run at once, | |
| 158 | + | // whatever the number of windows. | |
| 159 | + | sem := make(chan struct{}, max(ocrWorkers, 1)) | |
| 157 | 160 | var wg sync.WaitGroup | |
| 158 | 161 | for _, j := range jobs { | |
| 159 | 162 | if j.hit { | |
| 160 | 163 | continue | |
| 161 | 164 | } | |
| 162 | - | wg.Go(func() { j.words, j.err = readBand(ctx, &w.ocr, j) }) | |
| 165 | + | select { | |
| 166 | + | case sem <- struct{}{}: | |
| 167 | + | case <-ctx.Done(): | |
| 168 | + | j.err = ctx.Err() | |
| 169 | + | continue | |
| 170 | + | } | |
| 171 | + | wg.Go(func() { | |
| 172 | + | defer func() { <-sem }() | |
| 173 | + | j.words, j.err = readBand(ctx, &w.ocr, j) | |
| 174 | + | }) | |
| 163 | 175 | } | |
| 164 | 176 | wg.Wait() | |
| 165 | 177 | } | |
| @@ -154,12 +154,24 @@ func bandsOf(height int) []band { | |||||
| 154 | func (w *worker) recognize(ctx context.Context, jobs []*bandJob) { | 154 | func (w *worker) recognize(ctx context.Context, jobs []*bandJob) { | ||
| 155 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) | 155 | ctx, cancel := context.WithTimeout(ctx, 15*time.Second) | ||
| 156 | defer cancel() | 156 | defer cancel() | ||
| 157 | + | // One scan-wide pool: at most ocrWorkers tesseract processes run at once, | |||
| 158 | + | // whatever the number of windows. | |||
| 159 | + | sem := make(chan struct{}, max(ocrWorkers, 1)) | |||
| 157 | var wg sync.WaitGroup | 160 | var wg sync.WaitGroup | ||
| 158 | for _, j := range jobs { | 161 | for _, j := range jobs { | ||
| 159 | if j.hit { | 162 | if j.hit { | ||
| 160 | continue | 163 | continue | ||
| 161 | } | 164 | } | ||
| 162 | - | wg.Go(func() { j.words, j.err = readBand(ctx, &w.ocr, j) }) | 165 | + | select { |
| 166 | + | case sem <- struct{}{}: | |||
| 167 | + | case <-ctx.Done(): | |||
| 168 | + | j.err = ctx.Err() | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | wg.Go(func() { | |||
| 172 | + | defer func() { <-sem }() | |||
| 173 | + | j.words, j.err = readBand(ctx, &w.ocr, j) | |||
| 174 | + | }) | |||
| 163 | } | 175 | } | ||
| 164 | wg.Wait() | 176 | wg.Wait() | ||
| 165 | } | 177 | } | ||
M internal/adapters/hints/ocr_test.go +45 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | 10 | "os/exec" | |
| 11 | + | "path/filepath" | |
| 11 | 12 | "reflect" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "testing" | |
| @@ -191,3 +192,47 @@ func TestPGMMatchesPNG(t *testing.T) { | |||
| 191 | 192 | t.Fatalf("PNG %d words, PGM %d words", len(res[0]), len(res[1])) | |
| 192 | 193 | } | |
| 193 | 194 | } | |
| 195 | + | ||
| 196 | + | // TestRecognizeCapsProcesses runs the bands of several windows through a | |
| 197 | + | // stand-in tesseract that logs when each process starts and ends. | |
| 198 | + | func TestRecognizeCapsProcesses(t *testing.T) { | |
| 199 | + | dir := t.TempDir() | |
| 200 | + | logPath := filepath.Join(dir, "log") | |
| 201 | + | script := filepath.Join(dir, "tesseract") | |
| 202 | + | body := "#!/bin/sh\ncat >/dev/null\necho s >> " + logPath + "\nsleep 0.1\necho e >> " + logPath + "\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n'\n" | |
| 203 | + | if err := os.WriteFile(script, []byte(body), 0o700); err != nil { | |
| 204 | + | t.Fatal(err) | |
| 205 | + | } | |
| 206 | + | old := ocrWorkers | |
| 207 | + | ocrWorkers = 2 | |
| 208 | + | t.Cleanup(func() { ocrWorkers = old }) | |
| 209 | + | w := &worker{ocr: ocrState{done: true, binary: script, lang: "eng"}} | |
| 210 | + | var jobs []*bandJob | |
| 211 | + | for range 8 { // several windows' bands | |
| 212 | + | g := image.NewGray(image.Rect(0, 0, 8, 8)) | |
| 213 | + | jobs = append(jobs, &bandJob{ext: g.Rect, own: [2]int{0, 8}, gray: g}) | |
| 214 | + | } | |
| 215 | + | w.recognize(context.Background(), jobs) | |
| 216 | + | data, err := os.ReadFile(logPath) | |
| 217 | + | if err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | cur, peak, starts := 0, 0, 0 | |
| 221 | + | for _, l := range strings.Fields(string(data)) { | |
| 222 | + | if l == "s" { | |
| 223 | + | cur++ | |
| 224 | + | starts++ | |
| 225 | + | peak = max(peak, cur) | |
| 226 | + | } else { | |
| 227 | + | cur-- | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | if starts != 8 || peak > 2 { | |
| 231 | + | t.Fatalf("starts=%d peak=%d", starts, peak) | |
| 232 | + | } | |
| 233 | + | for _, j := range jobs { | |
| 234 | + | if j.err != nil { | |
| 235 | + | t.Fatal(j.err) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "io" | 8 | "io" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | "os/exec" | 10 | "os/exec" | ||
| 11 | + | "path/filepath" | |||
| 11 | "reflect" | 12 | "reflect" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "testing" | 14 | "testing" | ||
| @@ -191,3 +192,47 @@ func TestPGMMatchesPNG(t *testing.T) { | |||||
| 191 | t.Fatalf("PNG %d words, PGM %d words", len(res[0]), len(res[1])) | 192 | t.Fatalf("PNG %d words, PGM %d words", len(res[0]), len(res[1])) | ||
| 192 | } | 193 | } | ||
| 193 | } | 194 | } | ||
| 195 | + | ||||
| 196 | + | // TestRecognizeCapsProcesses runs the bands of several windows through a | |||
| 197 | + | // stand-in tesseract that logs when each process starts and ends. | |||
| 198 | + | func TestRecognizeCapsProcesses(t *testing.T) { | |||
| 199 | + | dir := t.TempDir() | |||
| 200 | + | logPath := filepath.Join(dir, "log") | |||
| 201 | + | script := filepath.Join(dir, "tesseract") | |||
| 202 | + | body := "#!/bin/sh\ncat >/dev/null\necho s >> " + logPath + "\nsleep 0.1\necho e >> " + logPath + "\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n'\n" | |||
| 203 | + | if err := os.WriteFile(script, []byte(body), 0o700); err != nil { | |||
| 204 | + | t.Fatal(err) | |||
| 205 | + | } | |||
| 206 | + | old := ocrWorkers | |||
| 207 | + | ocrWorkers = 2 | |||
| 208 | + | t.Cleanup(func() { ocrWorkers = old }) | |||
| 209 | + | w := &worker{ocr: ocrState{done: true, binary: script, lang: "eng"}} | |||
| 210 | + | var jobs []*bandJob | |||
| 211 | + | for range 8 { // several windows' bands | |||
| 212 | + | g := image.NewGray(image.Rect(0, 0, 8, 8)) | |||
| 213 | + | jobs = append(jobs, &bandJob{ext: g.Rect, own: [2]int{0, 8}, gray: g}) | |||
| 214 | + | } | |||
| 215 | + | w.recognize(context.Background(), jobs) | |||
| 216 | + | data, err := os.ReadFile(logPath) | |||
| 217 | + | if err != nil { | |||
| 218 | + | t.Fatal(err) | |||
| 219 | + | } | |||
| 220 | + | cur, peak, starts := 0, 0, 0 | |||
| 221 | + | for _, l := range strings.Fields(string(data)) { | |||
| 222 | + | if l == "s" { | |||
| 223 | + | cur++ | |||
| 224 | + | starts++ | |||
| 225 | + | peak = max(peak, cur) | |||
| 226 | + | } else { | |||
| 227 | + | cur-- | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | if starts != 8 || peak > 2 { | |||
| 231 | + | t.Fatalf("starts=%d peak=%d", starts, peak) | |||
| 232 | + | } | |||
| 233 | + | for _, j := range jobs { | |||
| 234 | + | if j.err != nil { | |||
| 235 | + | t.Fatal(j.err) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | } | |||