File size: 32,895 Bytes
0e499ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
#!/usr/bin/env python3
"""Photo scan: image of Bosnian text -> text.

The reader is PaddleOCR PP-OCRv6, untrained, with a confidence floor
(docs/OCR-ROADMAP.md step 6, owner decision 5; the floor from
training/RESULTS-ocr-paddle-floor.md). EasyOCR with the fine-tuned Lilly
recogniser is the way back -- LILLY_READER=easyocr -- and the "before" every
OCR number is compared against; its weights are read from models/lilly/read/.

Usage:
    python3 app/ocr.py photo.jpg
"""
import os
import sys
import threading
from pathlib import Path

from app.lilly import READ_DIR, BadInput

# A photo's file size says nothing about what it costs to read: PNG compresses
# flat colour to almost nothing, so a 172 KB file can decode to a gigabyte. The
# limit that matters is pixels, and it has to be checked before decoding.
MAX_DECLARED_PIXELS = 50_000_000   # beyond this, refuse to decode at all
MAX_WORKING_PIXELS = 2_000_000     # above this, shrink first
# Measured on a 12 MP photo of a sign: reading it at 8 MP takes 22 s, at 4 MP
# 18 s, at 2 MP 9 s — and all three misread the same diacritic, so the detail
# was not buying accuracy. Half the wait is worth more than pixels nobody reads.


def _easyocr_gpu() -> bool:
    """CUDA only. The Mac has none, so the app stays on CPU. Kaggle T4 does.

    Pass-7b harvest auto-crop ran EasyOCR on CPU for ~3.4 hours on a T4 box
    because Reader was hardcoded gpu=False. EasyOCR's maintainers say GPU
    mode is gpu=True when CUDA is present (JaidedAI/EasyOCR#426, docs).
    """
    try:
        import torch
        return bool(torch.cuda.is_available())
    except Exception:
        return False

# easyocr's Bosnian character list is missing six of the language's own letters:
# c, c and d with their diacritics, plus the capitals, are absent from lang_char
# while s and z are present. That is not a cosmetic gap. easyocr turns the
# language list into an ignore set and subtracts it from the logits before
# decoding, so those six letters cannot come out at all -- not off a cleaner
# photo, not off a better-trained reader. Measured on a plain white image
# reading "ccdsz CCDSZ": the six came back as different letters every time.
# It means "Carsija" could never have read as "Čaršija" however good the photo,
# which is exactly the failure we were about to blame on the reader's training.
# The allowlist puts them back while keeping the rest of the language
# restriction, which is what stops Cyrillic and Vietnamese lookalikes.
BOSNIAN_LETTERS = "čćđšžČĆĐŠŽ"

_reader = None
_allowlist = None
_cyrillic_reader = None
_cyrillic_allowlist = None
_paddle_reader = None
_reader_lock = threading.Lock()
_read_lock = threading.Lock()

# Two engines behind one door (read_regions): same paragraph grouping, same
# triples out, so training/evaluate_ocr.py scores either on the same
# photographs by the same code. PaddleOCR is the app's reader since 4 Sep 2026
# (docs/OCR-ROADMAP.md, step 6). At the confidence floor of 0.9 that ships it
# reads 67.0% of a photograph's words with 65 invented against the fine-tuned
# EasyOCR reader's 54.5% / 182 on the 40, and 57.8% / 450 against 34.6% / 2,071
# on the 132 held-out photographs of test-v2 (training/RESULTS-ocr-paddle-
# floor.md; the bake-off's floor-0 figures, 67.7% and 60.0%, are in
# RESULTS-ocr-bakeoff.md). EasyOCR stays as LILLY_READER=easyocr: the way
# back, and the "before" build.
#
# LILLY_READER names the engine; unset means the app's reader:
#   paddle    PaddleOCR PP-OCRv6 (LILLY_PADDLE_VERSION=PP-OCRv5 for the other)
#   easyocr   EasyOCR with the fine-tuned Lilly recogniser (alias: lilly)
#   stock     EasyOCR's own latin_g2, the control
#   cyrillic  EasyOCR with the Cyrillic second pass (measured worse; kept)
# Anything else stops the process rather than quietly picking one.
DEFAULT_READER = "paddle"
READERS = ("paddle", "easyocr", "lilly", "stock", "cyrillic")
#
# The model pair is written down rather than left to the library's default, so
# the cache stamp and the results file name exactly what read the photographs.
# PP-OCRv6 medium is what paddleocr 3.7 picks for lang="bs"; PP-OCRv5 is the
# Latin model whose dictionary was checked to hold all of čćđšžČĆĐŠŽ. Document
# preprocessing (orientation, unwarping, textline rotation) is off: these are
# photographs of signs, not scans, and the app's EasyOCR path has none of it.
PADDLE_MODELS = {
    "PP-OCRv6": ("PP-OCRv6_medium_det", "PP-OCRv6_medium_rec"),
    "PP-OCRv5": ("PP-OCRv5_server_det", "latin_PP-OCRv5_mobile_rec"),
}


def reader_choice() -> str:
    """The engine LILLY_READER names, or the app's default."""
    choice = os.environ.get("LILLY_READER", "").strip().lower() or DEFAULT_READER
    if choice not in READERS:
        raise RuntimeError(f"LILLY_READER={choice!r}; known: {', '.join(READERS)}")
    return "easyocr" if choice == "lilly" else choice


def _paddle_enabled() -> bool:
    return reader_choice() == "paddle"


def paddle_rec_floor():
    """The recogniser confidence below which a Paddle region is dropped, or None.

    LILLY_PADDLE_REC_THRESH unset means DEFAULT_REC_FLOOR, the value
    training/RESULTS-ocr-paddle-floor.md chose on the 40 and decided on test-v2
    (training/PREREGISTRATION.md, "PP-OCRv6 confidence floor"). "0" means no
    floor: every recognition whatever its confidence, which is what the bake-off
    measured and what scripts/bakeoff_ocr.py still asks for. The value is part
    of reader_identity(), so a reading cache written at one floor is never
    scored under another.
    """
    raw = os.environ.get("LILLY_PADDLE_REC_THRESH", "").strip()
    floor = DEFAULT_REC_FLOOR if not raw else float(raw)
    if floor is None or floor <= 0.0:
        return None
    if floor > 1.0:
        raise RuntimeError(f"LILLY_PADDLE_REC_THRESH={raw!r}; want a confidence in [0, 1]")
    return floor


# Chosen on the 40 (the highest floor that kept the shipped arm's 54.5%) and
# decided once on test-v2: training/RESULTS-ocr-paddle-floor.md.
DEFAULT_REC_FLOOR = 0.9

# PP-OCRv6's dictionary has no Serbian Cyrillic, so a Cyrillic sign is a certain
# miss. LILLY_PADDLE_CYRILLIC_RESCUE=1 reads the boxes the floor drops -- and
# only those -- once more with Baidu's Cyrillic recogniser, and keeps that
# reading when it clears the same floor. It never touches a box the shipped
# configuration emits. Pre-registered before any run: training/PREREGISTRATION.md,
# "Cyrillic rescue under PP-OCRv6".
CYRILLIC_RESCUE_REC = "cyrillic_PP-OCRv5_mobile_rec"


def paddle_cyrillic_rescue() -> bool:
    raw = os.environ.get("LILLY_PADDLE_CYRILLIC_RESCUE", "").strip().lower()
    if raw in ("", "0", "false", "no", "off"):
        return False
    if raw not in ("1", "true", "yes", "on"):
        raise RuntimeError(f"LILLY_PADDLE_CYRILLIC_RESCUE={raw!r}; want 1 or 0")
    if paddle_rec_floor() is None:
        raise RuntimeError("LILLY_PADDLE_CYRILLIC_RESCUE needs a floor: with "
                           "LILLY_PADDLE_REC_THRESH=0 nothing is dropped, so there is nothing to rescue")
    return True


_rec_dir_stamp = {}


def paddle_rec_dir():
    """A recogniser exported by a fine-tune (inference.pdiparams + inference.yml), or None.

    LILLY_PADDLE_REC_DIR points the pipeline at that directory instead of the
    named official model. Step 7's photograph bars are measured through this
    door (training/PREREGISTRATION.md), so the directory's weights are part of
    reader_identity(): a cache written by the stock model is never read back
    under a fine-tuned name, or the reverse.
    """
    raw = os.environ.get("LILLY_PADDLE_REC_DIR", "").strip()
    if not raw:
        return None
    d = Path(raw)
    if not (d / "inference.pdiparams").is_file():
        raise RuntimeError(f"LILLY_PADDLE_REC_DIR={raw!r}: no inference.pdiparams there")
    return d


def paddle_rec_dir_stamp() -> str:
    """Eight hex digits of the exported weights' md5, cached per file and mtime."""
    d = paddle_rec_dir()
    if d is None:
        return ""
    import hashlib
    f = d / "inference.pdiparams"
    key = (str(f), f.stat().st_mtime_ns)
    if key not in _rec_dir_stamp:
        h = hashlib.md5()
        with f.open("rb") as fh:
            for chunk in iter(lambda: fh.read(1 << 20), b""):
                h.update(chunk)
        _rec_dir_stamp[key] = h.hexdigest()[:8]
    return _rec_dir_stamp[key]


def paddle_models() -> tuple:
    """(detector, recogniser) names the paddle path loads, from the environment."""
    version = os.environ.get("LILLY_PADDLE_VERSION", "PP-OCRv6")
    if version not in PADDLE_MODELS:
        raise RuntimeError(f"LILLY_PADDLE_VERSION={version!r}; known: {sorted(PADDLE_MODELS)}")
    det, rec = PADDLE_MODELS[version]
    return (os.environ.get("LILLY_PADDLE_DET", det),
            os.environ.get("LILLY_PADDLE_REC", rec))


def paddle_det_side_len():
    """A larger text_det_limit_side_len for the detector, or None.

    LILLY_PADDLE_DET_SIDE_LEN raises the resolution the detector sees (limit type
    'max'), so few-pixel type survives to be boxed instead of being down-scaled
    away before detection. Step 8's photograph bars are measured through this knob
    (training/PREREGISTRATION.md, "the detector's small-type misses"); unset, the
    library default holds and nothing changes.
    """
    raw = os.environ.get("LILLY_PADDLE_DET_SIDE_LEN", "").strip()
    if not raw:
        return None
    n = int(raw)
    if n < 1:
        raise RuntimeError(f"LILLY_PADDLE_DET_SIDE_LEN={raw!r}; want a positive pixel length")
    return n


def reader_identity() -> str:
    """Which reader a read goes through, as a string a cache can be stamped with.

    evaluate_ocr.py stamps its reading cache with the weight files on disk.
    That stamp does not change when LILLY_READER switches engines or falls back
    to the stock weights, so without this the first stock or paddle score after
    a trained run would read the trained reader's answers back out of the cache
    and print them under the other name -- the exact failure the stamp exists
    to prevent.
    """
    if _paddle_enabled():
        det, rec = paddle_models()
        try:
            import paddleocr
            version = getattr(paddleocr, "__version__", "?")
        except Exception:
            version = "?"
        floor = paddle_rec_floor()
        identity = f"paddle:{det}+{rec}"
        if paddle_rec_dir() is not None:
            identity += f"@{paddle_rec_dir_stamp()}"
        identity += f":{version}" + (f":rec>={floor:g}" if floor is not None else "")
        if paddle_cyrillic_rescue():
            identity += f"+rescue:{CYRILLIC_RESCUE_REC}>={floor:g}"
        if paddle_det_side_len() is not None:
            identity += f":det<={paddle_det_side_len()}"
        return identity
    if _cyrillic_enabled():
        return "easyocr:lilly+cyrillic"
    trained = READ_DIR / "lilly.pth"
    network = READ_DIR / "user_network"
    stock = reader_choice() == "stock"
    if not stock and trained.exists() and (network / "lilly.yaml").exists():
        return "easyocr:lilly"
    return "easyocr:stock"


def get_paddle_reader():
    global _paddle_reader
    if _paddle_reader is None:
        with _reader_lock:
            if _paddle_reader is None:
                from paddleocr import PaddleOCR
                det, rec = paddle_models()
                # oneDNN off. PaddleX turns it on by default on x86 CPUs, and
                # with paddlepaddle 3.3.1 the detector's first run then dies in
                # the executor ("ConvertPirAttribute2RuntimeAttribute not
                # support pir::ArrayAttribute<pir::DoubleAttribute>",
                # onednn_instruction.cc) -- both PP-OCRv5 and PP-OCRv6, measured
                # 3 Sep 2026. The plain CPU kernels read the same weights and
                # are slower, so the timing row of the bake-off says which
                # kernels it timed. The Mac has no oneDNN and is unaffected.
                floor = paddle_rec_floor()
                # With the rescue on, the pipeline runs with no floor and
                # _read_regions_paddle applies it, so the dropped boxes are
                # still there to be read once more. Same test (score >= floor)
                # the pipeline applies, so rescue-off equals shipped.
                extra = ({"text_rec_score_thresh": floor}
                         if floor is not None and not paddle_cyrillic_rescue() else {})
                if paddle_rec_dir() is not None:
                    extra["text_recognition_model_dir"] = str(paddle_rec_dir())
                side = paddle_det_side_len()
                if side is not None:
                    extra["text_det_limit_side_len"] = side
                    extra["text_det_limit_type"] = "max"
                _paddle_reader = PaddleOCR(
                    text_detection_model_name=det,
                    text_recognition_model_name=rec,
                    use_doc_orientation_classify=False,
                    use_doc_unwarping=False,
                    use_textline_orientation=False,
                    enable_mkldnn=False,
                    **extra)
    return _paddle_reader


def _field(result, key):
    """A key off a PaddleX result, which is a dict that may lack the key."""
    try:
        return result[key]
    except (KeyError, TypeError, IndexError):
        return None


_cyrillic_rescue_reader = None


def get_cyrillic_rescue_reader():
    global _cyrillic_rescue_reader
    if _cyrillic_rescue_reader is None:
        with _reader_lock:
            if _cyrillic_rescue_reader is None:
                from paddleocr import TextRecognition
                _cyrillic_rescue_reader = TextRecognition(model_name=CYRILLIC_RESCUE_REC,
                                                          enable_mkldnn=False)
    return _cyrillic_rescue_reader


def _rescue_dropped(bgr, regions: list, polys: list, floor: float) -> list:
    """Read the boxes below the floor once more with the Cyrillic recogniser.

    Boxes at or above the floor pass through untouched, in their order. A box
    below it is cropped the way the pipeline crops for its own recogniser
    (CropByPolys on the detector's quad) and read by CYRILLIC_RESCUE_REC; the
    reading is kept only when its confidence clears the same floor. Every
    rescued box is appended to LILLY_PADDLE_RESCUE_LOG (JSON lines) when that
    is set, so a run can say how many words the rescue added and where.
    """
    import json
    import numpy as np

    low = [i for i, r in enumerate(regions) if r[2] < floor]
    if not low:
        return regions
    from paddlex.inference.pipelines.components import CropByPolys
    quads = []
    for i in low:
        pts = np.asarray(polys[i], dtype=np.float32).reshape(-1, 2)
        if len(pts) != 4:
            x0, y0 = pts.min(axis=0)
            x1, y1 = pts.max(axis=0)
            pts = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.float32)
        quads.append(pts)
    crops = list(CropByPolys(det_box_type="quad")(bgr, quads))
    usable = [(i, c) for i, c in zip(low, crops) if c is not None and c.size and c.shape[0] > 0 and c.shape[1] > 0]
    rescued = {}
    if usable:
        results = list(get_cyrillic_rescue_reader().predict([c for _, c in usable]))
        for (i, _), res in zip(usable, results):
            text = str(_field(res, "rec_text") or "").strip()
            score = float(_field(res, "rec_score") or 0.0)
            if text and score >= floor:
                rescued[i] = (text, score)
    log = os.environ.get("LILLY_PADDLE_RESCUE_LOG", "").strip()
    if log:
        with open(log, "a", encoding="utf-8") as fh:
            for i in low:
                kept = rescued.get(i)
                fh.write(json.dumps({"v6_text": regions[i][1], "v6_score": regions[i][2],
                                     "cyr_text": kept[0] if kept else None,
                                     "cyr_score": kept[1] if kept else None,
                                     "kept": kept is not None}, ensure_ascii=False) + "\n")
    out = []
    for i, r in enumerate(regions):
        if r[2] >= floor:
            out.append(r)
        elif i in rescued:
            out.append([r[0], rescued[i][0], rescued[i][1]])
    return out


def _paddle_results_to_regions(results, polys_out: list = None) -> list:
    """PaddleOCR's per-image results as EasyOCR's (box, text, confidence) triples.

    A box is four [x, y] corners, the way readtext returns them, so
    easyocr.utils.get_paragraph and measure_detection.py take them unchanged.
    Paddle's polygons can carry more than four points; the axis-aligned bounds
    are what grouping and overlays need, so that is what is kept. `polys_out`,
    when given, receives the detector's own polygon for every region kept, in
    the same order, for the rescue's crops.
    """
    import numpy as np

    regions = []
    for result in results:
        texts = _field(result, "rec_texts") or []
        scores = _field(result, "rec_scores")
        scores = list(scores) if scores is not None else [1.0] * len(texts)
        polys = _field(result, "rec_polys")
        if polys is None or len(polys) == 0:
            polys = _field(result, "dt_polys") or []
        for poly, text, score in zip(polys, texts, scores):
            text = str(text)
            if not text.strip():
                continue
            pts = np.asarray(poly, dtype=float).reshape(-1, 2)
            x0, y0 = pts.min(axis=0)
            x1, y1 = pts.max(axis=0)
            box = [[int(round(x0)), int(round(y0))], [int(round(x1)), int(round(y0))],
                   [int(round(x1)), int(round(y1))], [int(round(x0)), int(round(y1))]]
            regions.append([box, text, float(score)])
            if polys_out is not None:
                polys_out.append(pts)
    return regions


def _read_regions_paddle(image, detail: int, paragraph: bool) -> list:
    import numpy as np

    array = np.asarray(image)
    if array.ndim == 2:
        array = np.stack([array] * 3, axis=-1)
    # The app hands over RGB (PIL); Paddle, like OpenCV, expects BGR.
    bgr = np.ascontiguousarray(array[:, :, :3][:, :, ::-1])
    reader = get_paddle_reader()
    with _read_lock:
        polys = []
        regions = _paddle_results_to_regions(list(reader.predict(bgr)), polys)
        if paddle_cyrillic_rescue():
            regions = _rescue_dropped(bgr, regions, polys, paddle_rec_floor())
    if paragraph:
        from easyocr.utils import get_paragraph
        regions = get_paragraph(regions, x_ths=1.0, y_ths=0.5, mode="ltr")
    if detail == 0:
        return [r[1] for r in regions]
    return regions

# Bosnian is written in both alphabets and the recogniser only knows one.
# latin_g2 has 351 output classes and not one is Cyrillic, so a Cyrillic sign
# could not be read however good the photograph or the training -- measured on
# the scored set, 7.0% of the answer key's words are Cyrillic across 7 of the 40
# photographs, and 16.2% of the hand-transcribed crops. That is a ceiling, and
# no amount of Latin fine-tuning moves it.
#
# So there is a second recogniser. The detector is shared: CRAFT finds where
# text is without caring what alphabet it is in, and it is the expensive half.
# Only recognition runs twice, once per script, over the same boxes, and each
# region keeps whichever reading came back more confident.
#
# CYRILLIC_MARGIN is how much more confident the Cyrillic reading has to be
# before it displaces the Latin one. It is not a taste setting: 0.0 is the
# obvious choice and it is wrong, because the Cyrillic model will happily read
# Latin text as Cyrillic lookalikes -- CTOP for STOP -- with real confidence.
# The value here was measured on the 40 scored photographs, see
# training/RESULTS-ocr-cyrillic.md.
CYRILLIC_MARGIN = float(os.environ.get("LILLY_CYRILLIC_MARGIN", "0.10"))


class ImageTooLarge(BadInput):
    """Raised when an image is too big to be worth decoding."""


class UnreadableImage(BadInput):
    """Raised when the upload is not an image we can open at all."""


def language_characters(languages) -> set:
    """The letters easyocr says these languages are written with."""
    from easyocr.config import BASE_PATH

    found = set()
    for language in languages:
        path = Path(BASE_PATH) / "character" / f"{language}_char.txt"
        if path.exists():
            found |= set(path.read_text(encoding="utf-8-sig").split())
    return found


def get_reader():
    global _reader, _allowlist
    if _reader is None:
        with _reader_lock:
            if _reader is None:
                import easyocr

                # Our fine-tuned reader ships as a network of its own rather
                # than as a replacement for latin_g2.pth. easyocr checks that
                # file's MD5 against the weights it published, so writing our
                # own there makes it refuse to load and report a corrupt
                # download — which is what happened the first time the trained
                # reader was installed: the photo endpoint returned 500 for
                # every image, and the training that had just improved word
                # accuracy from 67.1% to 86.8% had made the feature unusable.
                # LILLY_READER=stock loads easyocr's own Latin weights
                # instead, which is the reader this one was trained from. The
                # project keeps a "before" build of the translator and of the
                # listener and compares against them; the reader had no such
                # thing, so there was no way to ask whether its training helped
                # on anything but the synthetic set it was trained on. This is
                # that build. It is a measurement path, and it is here rather
                # than in the evaluation script because a comparison is only
                # worth anything if both sides go through the same code.
                trained = READ_DIR / "lilly.pth"
                network = READ_DIR / "user_network"
                stock = reader_choice() == "stock"
                extra = {} if stock else (
                    {"recog_network": "lilly",
                     "user_network_directory": str(network)}
                    if trained.exists() and (network / "lilly.yaml").exists() else {})
                reader = easyocr.Reader(["bs", "en"], gpu=_easyocr_gpu(),
                                        model_storage_directory=str(READ_DIR),
                                        download_enabled=False, **extra)
                # Stop loudly rather than read Bosnian without its own letters.
                # If the weights are ever swapped for a set that cannot produce
                # these, silence would look like a reader that reads badly.
                cannot = [c for c in BOSNIAN_LETTERS if c not in reader.character]
                if cannot:
                    raise RuntimeError(
                        f"the recogniser cannot produce {''.join(cannot)} — these "
                        f"weights are not the Latin set Lilly reads Bosnian with")
                # Built from the language files rather than from
                # reader.lang_char, because the two are not the same thing once
                # a custom network is registered: easyocr then sets lang_char to
                # the network's whole character set, and the restriction that
                # keeps Cyrillic and Vietnamese lookalikes out of the guesses
                # quietly disappears. Reading the files keeps the restriction
                # identical whichever reader is loaded.
                # Letters are restricted to the two languages; everything that
                # is not a letter is not. The language files hold only letters —
                # easyocr keeps digits, punctuation and space in a separate
                # symbol set — so a list built from them alone silently forbids
                # every number on every sign. Measured: "Radovi 24. jula, 1995.
                # godine!" came back as "RadoviZAjulaIIIDgodinel", with both
                # dates destroyed.
                symbols = {c for c in reader.character if not c.isalpha()}
                _allowlist = "".join(sorted(language_characters(["bs", "en"])
                                            | set(BOSNIAN_LETTERS) | symbols))
                _reader = reader
    return _reader


def load_within_limits(image_path: str):
    """Open a photo at a size worth working on, or refuse it."""
    return load_within_limits_scaled(image_path)[0]


def load_within_limits_scaled(image_path: str):
    """Open a photo at a size worth working on, and say how it was shrunk.

    The (image, scale) pair: scale is 1.0 when nothing was resized, else the
    factor the pixels were multiplied by. Overlay callers need it to put
    boxes read off the working image back into the original's coordinates.
    """
    import numpy as np
    from PIL import Image

    Image.MAX_IMAGE_PIXELS = MAX_DECLARED_PIXELS
    try:
        img = Image.open(image_path)         # reads the header, not the pixels
    except Exception as exc:
        raise UnreadableImage("that file is not an image we can read") from exc
    with img:
        width, height = img.size
        if width * height > MAX_DECLARED_PIXELS:
            raise ImageTooLarge(f"that image is {width}x{height}; too large to read")
        img = img.convert("RGB")
        scale = 1.0
        if width * height > MAX_WORKING_PIXELS:
            scale = (MAX_WORKING_PIXELS / (width * height)) ** 0.5
            img = img.resize((max(int(width * scale), 1), max(int(height * scale), 1)))
        return np.asarray(img), scale


def get_cyrillic_reader():
    """The second recogniser, for the half of Bosnian signage written in Cyrillic.

    easyocr's rs_cyrillic is Serbian Cyrillic, which is the same alphabet
    Bosnian uses -- all twelve of Ђ Ј Љ Њ Ћ Џ and their lower case are in its
    character file, checked rather than assumed. The weights are cyrillic_g2,
    fetched from easyocr's own distribution like latin_g2 and CRAFT before it.

    No custom network here: nothing has been fine-tuned on Cyrillic. This reads
    it as well as the published model does, which is the whole of the gain --
    going from "cannot represent the alphabet at all" to "reads it ordinarily".
    """
    global _cyrillic_reader, _cyrillic_allowlist
    if _cyrillic_reader is None:
        with _reader_lock:
            if _cyrillic_reader is None:
                import easyocr
                reader = easyocr.Reader(["rs_cyrillic", "en"], gpu=_easyocr_gpu(),
                                        model_storage_directory=str(READ_DIR),
                                        download_enabled=False)
                symbols = {c for c in reader.character if not c.isalpha()}
                _cyrillic_allowlist = "".join(sorted(
                    language_characters(["rs_cyrillic"]) | symbols))
                _cyrillic_reader = reader
    return _cyrillic_reader


def _pick(latin, cyrillic):
    """One region, two readings, one answer.

    Confidence with a margin, not confidence alone. The Cyrillic model reads
    Latin words as Cyrillic lookalikes — С for S, Р for P, О for O — and does it
    confidently, so a bare comparison hands it text it has merely transliterated
    into the wrong alphabet. The margin makes it earn the region.
    """
    if cyrillic is None:
        return latin
    if latin is None:
        return cyrillic
    return cyrillic if cyrillic[2] > latin[2] + CYRILLIC_MARGIN else latin


def read_regions(image, **kwargs):
    """Every text region the reader finds — the only place recognition happens.

    The allowlist is not optional and not the caller's business. Leaving it to
    each call site is how the label-making path in training/prepare_ocr_data.py
    spent its life producing first-draft labels that could not contain c, c or d
    with their diacritics: it built its reader from this module and then called
    readtext itself, so it inherited the check on the weights and none of the
    fix. One door, and forgetting stops being possible.

    Detection runs once and recognition runs twice, once per alphabet, over the
    same boxes. Grouping into paragraphs happens after the two are merged rather
    than inside either, or each reader would group its own guesses and the
    merge would be comparing paragraphs that do not correspond.
    """
    import numpy as np

    detail = kwargs.pop("detail", 1)
    paragraph = kwargs.pop("paragraph", False)
    if _paddle_enabled():
        return _read_regions_paddle(image, detail, paragraph)
    get_reader()

    with _read_lock:
        if not _cyrillic_enabled():
            out = _reader.readtext(image, allowlist=_allowlist,
                                   detail=detail, paragraph=paragraph, **kwargs)
            return out

        img, img_grey = _reader_module_reformat(image)
        horizontal, free = _reader.detect(img, **kwargs)
        horizontal, free = horizontal[0], free[0]

        latin = _reader.recognize(img_grey, horizontal, free,
                                  allowlist=_allowlist, detail=1,
                                  paragraph=False, reformat=False)
        cyrillic = get_cyrillic_reader().recognize(
            img_grey, horizontal, free, allowlist=_cyrillic_allowlist,
            detail=1, paragraph=False, reformat=False)

        if len(latin) != len(cyrillic):
            # Should not happen: same boxes, same order. If it ever does, the
            # merge would silently pair the wrong regions, so take the Latin
            # side whole rather than produce a scrambled reading.
            merged = latin
        else:
            merged = [_pick(a, b) for a, b in zip(latin, cyrillic)]

    if paragraph:
        from easyocr.utils import get_paragraph
        merged = get_paragraph(merged, x_ths=1.0, y_ths=0.5, mode="ltr")
    if detail == 0:
        return [r[1] for r in merged]
    return merged


def _cyrillic_enabled() -> bool:
    """Opt-in, with LILLY_READER=cyrillic. Default off, because it measured worse.

    The second recogniser reads Cyrillic properly -- Сарајево where the Latin
    model produced "Capajebo" -- and it still made the product worse on the 40
    scored photographs at every setting tried. Numbers in
    training/RESULTS-ocr-cyrillic.md. The short version: choosing between the
    two by confidence loses more good Latin readings than it rescues Cyrillic
    ones, and emitting both gains 2.3 points of recall while tripling the words
    invented out of nothing. For an app that translates what it reads, an
    invented word becomes an invented sentence, so that trade is the wrong way
    round.

    The plumbing stays because it is correct and because the route that should
    work needs it: fine-tuning cyrillic_g2 on the 272 Cyrillic crops now
    transcribed (HANDOFF.md counted them three ways; 276 was a miscount), the
    same move that took the Latin reader from 36.0% to 54.7%.
    An untrained second model arbitrated at read time is not that.
    """
    return reader_choice() == "cyrillic"


def _reader_module_reformat(image):
    """easyocr's own colour/grey pair for an already-loaded array."""
    from easyocr.utils import reformat_input
    return reformat_input(image)


def scan(image_path: str) -> str:
    image = load_within_limits(image_path)
    results = read_regions(image, detail=0, paragraph=True)
    return "\n".join(results).strip()


def scan_regions(image_path: str) -> tuple:
    """The same reading scan() makes, plus a box for every paragraph of it.

    Returns (text, regions) where regions are the reader's paragraph groups
    as (box, text) pairs — the paragraph merge joins per-region confidence
    away, so there is none to return — with each box rescaled back into the
    original photo's pixels. The overlay endpoint draws these boxes over the
    photograph the way it arrived, whatever size the reader had to shrink it
    to. The text is the same string scan() returns, so the full translation
    and the per-region ones agree on what was read.
    """
    image, scale = load_within_limits_scaled(image_path)
    regions = read_regions(image, detail=1, paragraph=True)
    if scale != 1.0:
        for region in regions:
            region[0] = [[int(round(x / scale)), int(round(y / scale))]
                         for x, y in region[0]]
    text = "\n".join(str(r[1]) for r in regions).strip()
    return text, regions


def main() -> int:
    if len(sys.argv) < 2 or not Path(sys.argv[1]).exists():
        print("usage: python3 app/ocr.py <image-file>", file=sys.stderr)
        return 1
    print(scan(sys.argv[1]))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())