Download app/ocr.py from Safak11/Lilly-api: direct link, hf CLI and curl.
- Browser
- Download file 32.9 kB
-
https://huggingface.co/spaces/Safak11/Lilly-api/resolve/main/app/ocr.py
- Command line
-
hf download hf://spaces/Safak11/Lilly-api/app/ocr.py
-
curl -L -o ocr.py https://huggingface.co/spaces/Safak11/Lilly-api/resolve/main/app/ocr.py
32.9 kB
| #!/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()) | |