fix(perception): harden PaddleOCR result handling
Tests / Test passed: 793

This commit is contained in:
2026-07-14 20:07:49 +08:00
parent 25ebc10a8a
commit 6e511111c4
4 changed files with 188 additions and 18 deletions
+42 -18
View File
@@ -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
+1
View File
@@ -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",
]
+62
View File
@@ -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]
Generated
+83
View File
@@ -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"