From 0ee02fb6d8a899e8bef8b0a5097d9cbc2643ef0f Mon Sep 17 00:00:00 2001 From: Taranum Wasu <81034301+Taranum01@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:02:03 +0530 Subject: [PATCH] [Fix] Rename StandardizeImag -> StandardizeImage to fix deepdoc OCR preprocessing (#7316) (#16785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7316. ## Problem `deepdoc/vision/operators.py` defines the image-standardize preprocessing op as `class StandardizeImag` (missing the final `e`), but every caller — including `deepdoc/vision/recognizer.py::Recognizer.preprocess` — looks the class up by the canonical string `"StandardizeImage"` via: ```python op_type = new_op_info.pop("type") # "StandardizeImage" preprocess_ops.append(getattr(operators, op_type)(**new_op_info)) ``` So `getattr(operators, "StandardizeImage")` raised `AttributeError`, and the "StandardizeImage" preprocessing step silently never ran for any image pipeline that used the dynamic dispatch (LayoutLMv3 and friends). The user-visible symptom is that the standardize step is missing entirely from the preprocessing chain, so the model gets un-normalized images. ## Production fix ```diff -class StandardizeImag: +class StandardizeImage: """normalize image Args: mean (list): im - mean std (list): im / std is_scale (bool): whether need im / 255 norm_type (str): type in ['mean_std', 'none'] """ ``` That's the entire production change — a one-character class rename. The misnamed `StandardizeImag` had no other references in the codebase (verified via `git grep`), so removing it is safe; every caller uses the canonical `"StandardizeImage"` string and will now resolve correctly. ## Tests New `test/unit_test/deepdoc/vision/test_operators_standardize_image.py` with six regression tests, all green locally: ``` test_standardize_image_class_resolves_by_canonical_name PASSED test_standardize_image_callable_matches_legacy_alias_name PASSED test_standardize_image_normalizes_input_with_mean_std_and_is_scale PASSED test_standardize_image_skips_scaling_when_is_scale_false PASSED test_standardize_image_norm_type_none_passes_image_through PASSED test_standardize_image_via_module_getattr_dispatch_path PASSED 6 passed in 0.18s ``` The tests: 1. **Pin the dispatch contract** (`hasattr(operators, "StandardizeImage")`) — this is the exact check the recognizer's `getattr` would do, so any future regression fails the same way the runtime would. 2. **Pin that the misspelled name is gone** — if a downstream caller ever relied on it, this fails loudly. 3–5. **Behavioural coverage** of the three documented code paths: `is_scale=True, norm_type="mean_std"`, `is_scale=False, norm_type="mean_std"`, and `norm_type="none"`. 6. **End-to-end via the same `getattr(operators, "StandardizeImage")` call** the recognizer uses, with a real numpy image, so any rename or removal surfaces as `AttributeError` instead of silently skipping the step. Verified both ways: - Without the fix → **all 6 tests fail** (Python even suggests `'StandardizeImag' → 'StandardizeImage'`) - With the fix → all 6 pass in 0.15s The test file follows the project's existing pattern (`test/unit_test/deepdoc/parser/test_html_parser.py`): load the target module via `importlib.util.spec_from_file_location`, stub the only project-internal import (`rag.utils.lazy_image`), and assert against the loaded module — no full RAGFlow runtime required. ## Risk Very low. The class is renamed; no public Python API was using the misnamed class. The only reference path is the `"StandardizeImage"` string in `recognizer.py:270`, which now resolves correctly. ## Out of scope - No other ops in `operators.py` are affected; checked all the others (DecodeImage, NormalizeImage, Permute, etc.) and they all use correct names. - The dynamic-dispatch lookups in `recognizer.py` for `LinearResize`, `StandardizeImage`, `Permute`, `PadStride` all use the same dispatch path; only the `StandardizeImage` key was broken. No other keys need fixing. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Taranum01 Co-authored-by: Cursor Co-authored-by: Zhichang Yu --- deepdoc/vision/operators.py | 2 +- .../test_operators_standardize_image.py | 244 ++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 test/unit_test/deepdoc/vision/test_operators_standardize_image.py diff --git a/deepdoc/vision/operators.py b/deepdoc/vision/operators.py index 6bcbcb2ee9..8735f7f1f2 100644 --- a/deepdoc/vision/operators.py +++ b/deepdoc/vision/operators.py @@ -59,7 +59,7 @@ class DecodeImage: return data -class StandardizeImag: +class StandardizeImage: """normalize image Args: mean (list): im - mean diff --git a/test/unit_test/deepdoc/vision/test_operators_standardize_image.py b/test/unit_test/deepdoc/vision/test_operators_standardize_image.py new file mode 100644 index 0000000000..a95a330575 --- /dev/null +++ b/test/unit_test/deepdoc/vision/test_operators_standardize_image.py @@ -0,0 +1,244 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Regression tests for the ``StandardizeImage`` operator in +``deepdoc/vision/operators.py``. + +Issue: infiniflow/ragflow#7316. + +The class was defined as ``StandardizeImag`` (typo, missing the final ``e``) +but ``deepdoc/vision/recognizer.py`` dispatches preprocessing ops via:: + + op_type = new_op_info.pop("type") # "StandardizeImage" + preprocess_ops.append(getattr(operators, op_type)(**new_op_info)) + +so ``getattr(operators, "StandardizeImage")`` raised ``AttributeError`` and the +standardize step silently never ran. The fix renames the class to match the +canonical name used by every caller. + +These tests pin both contracts: + +1. ``deepdoc.vision.operators`` exposes the class under its canonical name + (``StandardizeImage``), which is the name the recognizer looks up. +2. The operator performs the documented mean/std normalization. +""" + +import importlib.util +import os +import sys +from types import ModuleType + +import pytest + + +# Names of the real project-internal modules that the operators.py loader +# stubs out so we don't need the full RAGFlow runtime. The fixture below +# records the pre-test value of each entry in ``sys.modules`` and restores +# it on teardown, so neighbouring tests that legitimately import these +# modules are never silently handed the stub. +_STUB_MODULE_NAMES = ("rag", "rag.utils", "rag.utils.lazy_image") + + +@pytest.fixture +def operators_module(): + """Load ``deepdoc.vision.operators`` from source with stubbed project + imports, and clean the stubs up afterwards. + + The fixture records every ``sys.modules`` entry it touches and restores + the pre-test state in the teardown phase, so a later test that imports + the real ``rag.utils.lazy_image`` continues to receive the real module + rather than the identity-pass-through stub installed here. + """ + project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + + # Snapshot the entries we'll mutate so teardown can restore them + # exactly, even when some of them were already populated. + snapshot = {name: sys.modules.get(name) for name in _STUB_MODULE_NAMES} + + rag_pkg = sys.modules.setdefault( + "rag", ModuleType("rag"), + ) + rag_pkg.__path__ = [os.path.join(project_root, "rag")] + + rag_utils = sys.modules.setdefault( + "rag.utils", ModuleType("rag.utils"), + ) + rag_utils.__path__ = [os.path.join(project_root, "rag", "utils")] + + lazy_image = ModuleType("rag.utils.lazy_image") + lazy_image.ensure_pil_image = lambda im: im + sys.modules["rag.utils.lazy_image"] = lazy_image + + operators_path = os.path.join( + project_root, "deepdoc", "vision", "operators.py" + ) + spec = importlib.util.spec_from_file_location( + "_test_operators_under_test", operators_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + try: + yield module + finally: + # Restore the exact pre-test state. We delete first so a sibling + # module that did ``import rag`` mid-test doesn't get a hidden + # half-populated stub left over. + for name in _STUB_MODULE_NAMES: + if name in sys.modules and sys.modules[name] is snapshot.get(name): + continue + if snapshot[name] is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = snapshot[name] + + +def test_standardize_image_class_resolves_by_canonical_name(operators_module): + """Regression for #7316. + + The recognizer dispatches preprocessing ops by their string ``"type"`` + key (see ``deepdoc/vision/recognizer.py`` ``preprocess()``), and the + canonical name it uses is ``"StandardizeImage"``. The class must be + importable from ``deepdoc.vision.operators`` under that name so + ``getattr(operators, "StandardizeImage")`` succeeds. + """ + assert hasattr(operators_module, "StandardizeImage"), ( + "deepdoc.vision.operators must expose a 'StandardizeImage' class — " + "recognizer.py dispatches preprocessing ops by this name; a typo in " + "the class name causes AttributeError at runtime." + ) + assert isinstance(operators_module.StandardizeImage, type), ( + "StandardizeImage must be a class." + ) + + +def test_standardize_image_callable_matches_legacy_alias_name(operators_module): + """The previously-broken alias ``StandardizeImag`` must no longer be + available — the typo is gone. If a downstream caller ever relied on the + misnamed class, this test will fail loudly so we can decide whether to + re-add a compatibility shim. + """ + assert not hasattr(operators_module, "StandardizeImag"), ( + "The misspelled 'StandardizeImag' class name should have been " + "removed; if something still references it, add a compatibility " + "shim and revisit this assertion." + ) + + +def test_standardize_image_normalizes_input_with_mean_std_and_is_scale(operators_module): + """End-to-end behavior: with is_scale=True, mean_std, the operator must + divide by 255 and then subtract mean / divide by std (per the class + docstring). + """ + import numpy as np + + op = operators_module.StandardizeImage( + mean=[0.5, 0.5, 0.5], + std=[0.5, 0.5, 0.5], + is_scale=True, + norm_type="mean_std", + ) + + # A 1x1x3 image with all-ones in the range [0, 255]. + im = np.array([[[255.0, 255.0, 255.0]]], dtype=np.float32) + im_info = {} + + out_im, out_info = op(im, im_info) + + # After /255 -> 1.0; (1.0 - 0.5) / 0.5 = 1.0 + assert out_im.shape == im.shape + assert np.allclose(out_im, [[[1.0, 1.0, 1.0]]]), ( + f"Expected mean-std normalized output of [[[1,1,1]]], got {out_im!r}" + ) + # im_info is passed through unchanged. + assert out_info is im_info + + +def test_standardize_image_skips_scaling_when_is_scale_false(operators_module): + """When is_scale=False, the operator must NOT divide by 255 before + applying mean/std. + """ + import numpy as np + + op = operators_module.StandardizeImage( + mean=[1.0, 1.0, 1.0], + std=[2.0, 2.0, 2.0], + is_scale=False, + norm_type="mean_std", + ) + + # A 1x1x3 image with values 9, 9, 9. + im = np.array([[[9.0, 9.0, 9.0]]], dtype=np.float32) + out_im, _ = op(im, {}) + + # No /255; (9 - 1) / 2 = 4 + assert np.allclose(out_im, [[[4.0, 4.0, 4.0]]]), ( + f"Expected is_scale=False path to skip /255, got {out_im!r}" + ) + + +def test_standardize_image_norm_type_none_passes_image_through(operators_module): + """With norm_type='none' the operator must not subtract mean or divide by + std. is_scale is still applied if True. + """ + import numpy as np + + op = operators_module.StandardizeImage( + mean=[123.0, 456.0, 789.0], # values that would corrupt the output + std=[1.0, 1.0, 1.0], + is_scale=True, + norm_type="none", + ) + + im = np.array([[[255.0, 255.0, 255.0]]], dtype=np.float32) + out_im, _ = op(im, {}) + + # /255 = 1.0; no mean/std applied. + assert np.allclose(out_im, [[[1.0, 1.0, 1.0]]]), ( + f"Expected norm_type='none' to skip mean/std, got {out_im!r}" + ) + + +def test_standardize_image_via_module_getattr_dispatch_path(operators_module): + """Mirrors the exact dispatch path used by + ``deepdoc/vision/recognizer.py:preprocess()``:: + + op_type = new_op_info.pop("type") + preprocess_ops.append(getattr(operators, op_type)(**new_op_info)) + + so any future regression in the class name will fail this test as + ``AttributeError`` rather than silently producing broken preprocessing. + """ + import numpy as np + + op_info = { + "is_scale": True, + "mean": [0.5, 0.5, 0.5], + "std": [0.5, 0.5, 0.5], + "type": "StandardizeImage", + } + new_op_info = op_info.copy() + op_type = new_op_info.pop("type") + + # This is the exact line from recognizer.py; if it raises AttributeError + # the bug is back. + op = getattr(operators_module, op_type)(**new_op_info) + + im = np.array([[[255.0, 255.0, 255.0]]], dtype=np.float32) + out_im, _ = op(im, {}) + + assert np.allclose(out_im, [[[1.0, 1.0, 1.0]]])