From 6e511111c4eca7b1134994d85e977e97b04e3b06 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 14 Jul 2026 20:07:49 +0800 Subject: [PATCH] fix(perception): harden PaddleOCR result handling --- perception/ocr.py | 60 ++++++++++++++++++++++++---------- pyproject.toml | 1 + tests/test_ocr.py | 62 +++++++++++++++++++++++++++++++++++ uv.lock | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 tests/test_ocr.py diff --git a/perception/ocr.py b/perception/ocr.py index 03fa214..376c601 100644 --- a/perception/ocr.py +++ b/perception/ocr.py @@ -2,13 +2,17 @@ from __future__ import annotations import os import tempfile +import logging from collections.abc import Iterable from dataclasses import dataclass +from numbers import Real from pathlib import Path from typing import Any from core.models import Bounds, SceneElement +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class OCRBox: @@ -61,9 +65,10 @@ def run_ocr( ) -> list[SceneElement]: try: boxes = (engine or PaddleOCREngine()).extract(image) - except ImportError: + except Exception: if strict: raise + logger.warning("OCR extraction failed; continuing without OCR", exc_info=True) return [] return [box.to_scene_element(f"ocr-{index:03d}") for index, box in enumerate(boxes)] @@ -105,20 +110,18 @@ def _flatten_pages(raw: Any) -> Iterable[Any]: return flattened json_attr = getattr(raw, "json", None) if callable(json_attr): + return _flatten_pages(json_attr()) + if isinstance(json_attr, (dict, list)): return _flatten_pages(json_attr) return [] def _dict_lines(data: dict[str, Any]) -> list[Any]: - payload = data.get("res") if isinstance(data.get("res"), dict) else data - texts = payload.get("rec_texts") or payload.get("texts") or [] - scores = payload.get("rec_scores") or payload.get("scores") or [] - boxes = ( - payload.get("rec_boxes") - or payload.get("rec_polys") - or payload.get("dt_polys") - or [] - ) + result = data.get("res") + payload = result if isinstance(result, dict) else data + texts = _first_nonempty(payload, "rec_texts", "texts") + scores = _first_nonempty(payload, "rec_scores", "scores") + boxes = _first_nonempty(payload, "rec_boxes", "rec_polys", "dt_polys") return [ { "text": text, @@ -136,11 +139,13 @@ def _looks_like_ocr_line(value: Any) -> bool: def _parse_line(line: Any) -> OCRBox | None: if isinstance(line, dict): text = line.get("text") - points = line.get("points") or line.get("box") or line.get("bounds") + points = _first_nonempty(line, "points", "box", "bounds") confidence = line.get("confidence") - if not text or not points: + if not _has_items(text) or not _has_items(points): return None - return OCRBox(str(text), _bounds_from_points(points), _float_or_none(confidence)) + return OCRBox( + str(text), _bounds_from_points(points), _float_or_none(confidence) + ) if not _looks_like_ocr_line(line): return None @@ -161,11 +166,7 @@ def _parse_line(line: Any) -> OCRBox | None: def _bounds_from_points(points: Any) -> Bounds: if isinstance(points, dict): return Bounds.from_dict(points) - if ( - isinstance(points, (list, tuple)) - and len(points) == 4 - and all(isinstance(value, (int, float)) for value in points) - ): + if _has_length(points, 4) and all(isinstance(value, Real) for value in points): x1, y1, x2, y2 = [float(value) for value in points] return Bounds(x1, y1, x2 - x1, y2 - y1) @@ -186,3 +187,26 @@ def _float_or_none(value: Any) -> float | None: return None return float(value) + +def _first_nonempty(data: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = data.get(key) + if _has_items(value): + return value + return [] + + +def _has_items(value: Any) -> bool: + if value is None: + return False + try: + return len(value) > 0 + except TypeError: + return bool(value) + + +def _has_length(value: Any, length: int) -> bool: + try: + return len(value) == length + except TypeError: + return False diff --git a/pyproject.toml b/pyproject.toml index 51022c8..6ec6dda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "httpx>=0.27.0", "mcp>=1.27,<2", "openai>=1.0.0", + "paddlepaddle>=3.0.0", "paddleocr>=3.0.0", "uvicorn[standard]>=0.30.0", ] diff --git a/tests/test_ocr.py b/tests/test_ocr.py new file mode 100644 index 0000000..2c68a32 --- /dev/null +++ b/tests/test_ocr.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from core.models import Bounds +from perception.ocr import OCRBox, parse_paddle_result, run_ocr + + +def test_parse_paddle_result_accepts_ndarray_fields() -> None: + boxes = parse_paddle_result( + { + "rec_texts": np.array(["Search"]), + "rec_scores": np.array([0.95]), + "rec_boxes": np.array([[1, 2, 10, 12]]), + } + ) + + assert boxes == [ + OCRBox( + text="Search", + bounds=Bounds(x=1.0, y=2.0, width=9.0, height=10.0), + confidence=0.95, + ) + ] + + +class CallableJsonResult: + def json(self) -> dict[str, object]: + return { + "res": { + "rec_texts": ["Search"], + "rec_scores": [0.95], + "rec_boxes": [[1, 2, 10, 12]], + } + } + + +def test_parse_paddle_result_accepts_callable_json_result() -> None: + assert parse_paddle_result(CallableJsonResult()) == [ + OCRBox( + text="Search", + bounds=Bounds(x=1.0, y=2.0, width=9.0, height=10.0), + confidence=0.95, + ) + ] + + +class FailingOCREngine: + def extract(self, image: bytes | str) -> list[OCRBox]: + raise ValueError( + "The truth value of an array with more than one element is ambiguous" + ) + + +def test_run_ocr_degrades_when_ocr_engine_raises() -> None: + assert run_ocr(b"image", engine=FailingOCREngine()) == [] # type: ignore[arg-type] + + +def test_run_ocr_strict_mode_preserves_engine_error() -> None: + with pytest.raises(ValueError, match="truth value"): + run_ocr(b"image", engine=FailingOCREngine(), strict=True) # type: ignore[arg-type] diff --git a/uv.lock b/uv.lock index 61f7a1a..dec0273 100644 --- a/uv.lock +++ b/uv.lock @@ -406,6 +406,7 @@ dependencies = [ { name = "mcp" }, { name = "openai" }, { name = "paddleocr" }, + { name = "paddlepaddle" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -423,6 +424,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.27,<2" }, { name = "openai", specifier = ">=1.0.0" }, { name = "paddleocr", specifier = ">=3.0.0" }, + { name = "paddlepaddle", specifier = ">=3.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, ] @@ -950,6 +952,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762" }, +] + [[package]] name = "numpy" version = "2.3.5" @@ -1016,6 +1027,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/9e/7110d2c5d543ab03b9581dbb1f8e2429863e44e0c9b4960b766f230c1279/opencv_contrib_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:47ec3160dae75f70e099b286d1a2e086d20dac8b06e759f60eaf867e6bdecba7" }, ] +[[package]] +name = "opt-einsum" +version = "3.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/bf/9257e53a0e7715bc1127e15063e831f076723c6cd60985333a1c18878fb8/opt_einsum-3.3.0.tar.gz", hash = "sha256:59f6475f77bbc37dcf7cd748519c0ec60722e91e63ca114e68821c0c54a46549" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/bc/19/404708a7e54ad2798907210462fd950c3442ea51acc8790f3da48d2bee8b/opt_einsum-3.3.0-py3-none-any.whl", hash = "sha256:2455e59e3947d3c275477df7f5205b30635e266fe6dc300e3d9f9646bfcea147" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1053,6 +1076,27 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d2/79/2ade66cf72074a3d2a013e2ba1f256d6116fec797b908fc1b4f9c320d8c4/paddleocr-3.7.0-py3-none-any.whl", hash = "sha256:c0f0a81ad4112727f30c6fcf986ac0ef6a120d31ee0991a01fae0357ee32d338" }, ] +[[package]] +name = "paddlepaddle" +version = "3.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "httpx" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "opt-einsum" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "safetensors" }, + { name = "setuptools" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/65/f8/bebb829227ac5574454b4342bcf512dbb02268118b62222fc7c1b420a3ef/paddlepaddle-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ce30bfb0023d8c9314b1413ca7a775a4ddd076e4551e74fbed2366cf1395c98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/8d/5a700dc20c0d3d581f1d429f95f5064eb766c2818c5a05992dfef66b0765/paddlepaddle-3.3.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:4f28038427649bb2fcfd4d52efa85ed8311df19122b4ebedc942484a0b57b032" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/c8/408b9e94ac2e10a55c1122b787359f7a6f79ef894ee1d57d78c5cca838d0/paddlepaddle-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:1203e2e1114b49e73a8440b68837ce5c93fdab51fe4838c5e5874ab40f58f747" }, +] + [[package]] name = "paddlex" version = "3.7.2" @@ -1204,6 +1248,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1594,6 +1653,30 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93" }, ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452" }, +] + [[package]] name = "selenium" version = "4.46.0"