diff --git a/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.en.md b/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.en.md index a338232a07..a4fd25b94e 100644 --- a/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.en.md +++ b/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.en.md @@ -3183,6 +3183,21 @@ SubModules: Subsequently, refer to the command-line or Python script methods in [2.2 Local Experience](#22-local-experience) to load the modified pipeline configuration file. +### 4.3 Dense or connected scripts: widening the recognition input +The text recognition model resizes each text line to the model's input height, with a width that follows the line's aspect ratio, up to a maximum of 3200 px. For dense, connected scripts such as Arabic this can give the recognition head too few time steps per character. Two optional parameters of `TextRecognition` change the resize; with their defaults the behavior is exactly the same as before: + +```yaml +SubModules: + TextRecognition: + module_name: text_recognition + model_name: arabic_PP-OCRv5_mobile_rec + model_dir: null + width_scale: 2.0 # multiplier applied to the aspect-ratio-derived width, default 1.0 + width_limit: 1280 # maximum resized width in pixels (an integer), default null (the existing 3200) +``` + +They only apply to dynamic-shape recognition with the Paddle engine and are ignored when `input_shape` is fixed; the Transformers engine raises an error if either is set. The effect depends on the script and model, so measure on your own data before enabling them: on dense Arabic book lines they reduced the character error rate substantially, while on English word images they did not help. + ## 5. Multi-Hardware Support PaddleX supports a variety of mainstream hardware devices, including NVIDIA GPUs, Kunlunxin XPUs, Ascend NPUs, and Cambricon MLUs. Simply modify the `--device` parameter to seamlessly switch between different hardware devices. diff --git a/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.md b/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.md index 84990a19eb..e12d8c4316 100644 --- a/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.md +++ b/docs/pipeline_usage/tutorials/ocr_pipelines/OCR.md @@ -3174,6 +3174,21 @@ SubModules: ``` 随后, 参考[2.2 本地体验](#22-本地体验)中的命令行方式或Python脚本方式,加载修改后的产线配置文件即可。 +### 4.3 密集连笔文字:加宽文本识别输入 +文本识别模型会将每个文本行缩放到模型输入高度,宽度随行的宽高比变化,最大为 3200 px。对于阿拉伯文等密集连笔文字,这可能使识别头每个字符对应的时间步过少。`TextRecognition` 提供两个可选参数来调整缩放;使用默认值时行为与之前完全一致: + +```yaml +SubModules: + TextRecognition: + module_name: text_recognition + model_name: arabic_PP-OCRv5_mobile_rec + model_dir: null + width_scale: 2.0 # 作用于按宽高比计算出的宽度的倍数,默认 1.0 + width_limit: 1280 # 缩放后宽度的上限(像素,整数),默认 null(沿用现有的 3200) +``` + +这两个参数仅适用于 Paddle 引擎的动态形状识别,固定 `input_shape` 时会被忽略;Transformers 引擎设置它们会报错。效果取决于文字类型和模型,启用前请在自己的数据上评估:在密集的阿拉伯文书籍文本行上,它们显著降低了字符错误率,而在英文单词图像上没有帮助。 + ## 5. 多硬件支持 PaddleX 支持英伟达 GPU、昆仑芯 XPU、昇腾 NPU和寒武纪 MLU 等多种主流硬件设备,仅需修改 `--device`参数即可完成不同硬件之间的无缝切换。 diff --git a/paddlex/inference/models/text_recognition/predictor.py b/paddlex/inference/models/text_recognition/predictor.py index 66211a8327..37178e451a 100644 --- a/paddlex/inference/models/text_recognition/predictor.py +++ b/paddlex/inference/models/text_recognition/predictor.py @@ -68,6 +68,37 @@ ] +def _word_box_width_ratios( + batch_raw_imgs, + indices, + rec_image_shape, + batch_num, + width_scale, + width_limit, + input_shape, +): + """Return the valid and padded width ratios used to place word boxes.""" + _, imgH, imgW = rec_image_shape[:3] + max_wh_ratio = imgW / imgH + dynamic = input_shape is None + cap_ratio = None + if dynamic and (width_scale != 1.0 or width_limit is not None): + cap_ratio = (3200 if width_limit is None else width_limit) / imgH + max_wh_ratio = min(max_wh_ratio, cap_ratio) + + wh_ratio_list = [] + for ino in range(min(len(batch_raw_imgs), batch_num)): + h, w = batch_raw_imgs[indices[ino]].shape[0:2] + wh_ratio = w * 1.0 / h + if dynamic: + wh_ratio *= width_scale + if cap_ratio is not None: + wh_ratio = min(wh_ratio, cap_ratio) + max_wh_ratio = max(max_wh_ratio, wh_ratio) + wh_ratio_list.append(wh_ratio) + return wh_ratio_list, max_wh_ratio + + def get_text_rec_vis_font(model_name): if model_name.startswith(("PP-OCR", "en_PP-OCR")): return SIMFANG_FONT @@ -125,9 +156,19 @@ class TextRecRunnerPredictor(RunnerPredictor): _FUNC_MAP = {} register = FuncRegister(_FUNC_MAP) - def __init__(self, *args, input_shape=None, return_word_box=False, **kwargs): + def __init__( + self, + *args, + input_shape=None, + width_scale=1.0, + width_limit=None, + return_word_box=False, + **kwargs, + ): super().__init__(*args, **kwargs) self.input_shape = input_shape + self.width_scale = width_scale + self.width_limit = width_limit self.return_word_box = return_word_box self.vis_font = self.get_vis_font() self.pre_tfs, self.post_op = self._build() @@ -165,21 +206,20 @@ def process(self, batch_data, return_word_box=False): x = self.pre_tfs["ToBatch"](imgs=batch_imgs) batch_preds = self.runner(x=x) batch_num = self.batch_sampler.batch_size - img_num = len(batch_raw_imgs) rec_image_shape = next( op["RecResizeImg"]["image_shape"] for op in self.config["PreProcess"]["transform_ops"] if "RecResizeImg" in op ) - imgC, imgH, imgW = rec_image_shape[:3] - max_wh_ratio = imgW / imgH - end_img_no = min(img_num, batch_num) - wh_ratio_list = [] - for ino in range(0, end_img_no): - h, w = batch_raw_imgs[indices[ino]].shape[0:2] - wh_ratio = w * 1.0 / h - max_wh_ratio = max(max_wh_ratio, wh_ratio) - wh_ratio_list.append(wh_ratio) + wh_ratio_list, max_wh_ratio = _word_box_width_ratios( + batch_raw_imgs, + indices, + rec_image_shape, + batch_num, + self.width_scale, + self.width_limit, + self.input_shape, + ) texts, scores = self.post_op( batch_preds, return_word_box=return_word_box or self.return_word_box, @@ -211,7 +251,10 @@ def build_readimg(self, channel_first, img_mode): @register("RecResizeImg") def build_resize(self, image_shape, **kwargs): return "ReisizeNorm", OCRReisizeNormImg( - rec_image_shape=image_shape, input_shape=self.input_shape + rec_image_shape=image_shape, + input_shape=self.input_shape, + width_scale=self.width_scale, + width_limit=self.width_limit, ) def build_postprocess(self, **kwargs): @@ -237,7 +280,19 @@ def get_vis_font(self): class TextRecTransformersPredictor(TransformersPredictor): """Text recognition predictor backed by Hugging Face transformers.""" - def __init__(self, *args, return_word_box: bool = False, **kwargs): + def __init__( + self, + *args, + return_word_box: bool = False, + width_scale=1.0, + width_limit=None, + **kwargs, + ): + if width_scale != 1.0 or width_limit is not None: + raise ValueError( + "width_scale and width_limit are only supported by the Paddle " + "text recognition preprocessing path." + ) super().__init__(*args, **kwargs) self.return_word_box = return_word_box self.vis_font = get_text_rec_vis_font(self.model_name) diff --git a/paddlex/inference/models/text_recognition/processors.py b/paddlex/inference/models/text_recognition/processors.py index cbde2ef264..e3c6614e16 100644 --- a/paddlex/inference/models/text_recognition/processors.py +++ b/paddlex/inference/models/text_recognition/processors.py @@ -15,6 +15,7 @@ import math import re +from numbers import Integral, Real from typing import List, Optional import numpy as np @@ -47,11 +48,39 @@ def validate_text_rec_image_array(img: np.ndarray, index: Optional[int] = None) class OCRReisizeNormImg: """for ocr image resize and normalization""" - def __init__(self, rec_image_shape=[3, 48, 320], input_shape=None): + def __init__( + self, + rec_image_shape=[3, 48, 320], + input_shape=None, + width_scale=1.0, + width_limit=None, + ): + """ + Args: + width_scale (float): Horizontal stretch applied to each crop's aspect ratio + before the dynamic resize. More width gives the CTC head more time steps, + which helps dense scripts such as Arabic. 1.0 keeps the default behavior. + width_limit (int|None): Maximum resized width. None keeps the default of 3200. + Neither option affects the static path used when `input_shape` is set. + """ super().__init__() + if ( + isinstance(width_scale, bool) + or not isinstance(width_scale, Real) + or not math.isfinite(width_scale) + or width_scale <= 0 + ): + raise ValueError(f"width_scale must be positive; got {width_scale}.") + if width_limit is not None and ( + isinstance(width_limit, bool) + or not isinstance(width_limit, Integral) + or width_limit <= 0 + ): + raise ValueError(f"width_limit must be positive; got {width_limit}.") self.rec_image_shape = rec_image_shape self.input_shape = input_shape - self.max_imgW = 3200 + self.width_scale = width_scale + self.max_imgW = 3200 if width_limit is None else int(width_limit) def resize_norm_img(self, img, max_wh_ratio): """resize and normalize the img""" @@ -64,7 +93,7 @@ def resize_norm_img(self, img, max_wh_ratio): imgW = self.max_imgW else: h, w = img.shape[:2] - ratio = w / float(h) + ratio = w / float(h) * self.width_scale if math.ceil(imgH * ratio) > imgW: resized_w = imgW else: @@ -91,7 +120,7 @@ def resize(self, img): imgC, imgH, imgW = self.rec_image_shape max_wh_ratio = imgW / imgH h, w = img.shape[:2] - wh_ratio = w * 1.0 / h + wh_ratio = w * 1.0 / h * self.width_scale max_wh_ratio = max(max_wh_ratio, wh_ratio) img = self.resize_norm_img(img, max_wh_ratio) return img diff --git a/paddlex/inference/pipelines/ocr/pipeline.py b/paddlex/inference/pipelines/ocr/pipeline.py index 05b9ffbd40..77b8092d4f 100644 --- a/paddlex/inference/pipelines/ocr/pipeline.py +++ b/paddlex/inference/pipelines/ocr/pipeline.py @@ -142,7 +142,10 @@ def __init__( self.return_word_box = text_rec_config.get("return_word_box", False) self.input_shape = text_rec_config.get("input_shape", None) self.text_rec_model = self.create_model( - text_rec_config, input_shape=self.input_shape + text_rec_config, + input_shape=self.input_shape, + width_scale=text_rec_config.get("width_scale", 1.0), + width_limit=text_rec_config.get("width_limit", None), ) self.batch_sampler = ImageBatchSampler(batch_size=config.get("batch_size", 1)) self.img_reader = ReadImage(format="BGR") diff --git a/tests/test_text_rec_width.py b/tests/test_text_rec_width.py new file mode 100644 index 0000000000..dfd8457595 --- /dev/null +++ b/tests/test_text_rec_width.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 PaddlePaddle 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. + +import unittest + +import numpy as np + +from paddlex.inference.models.text_recognition.predictor import ( + TextRecTransformersPredictor, + _word_box_width_ratios, +) +from paddlex.inference.models.text_recognition.processors import OCRReisizeNormImg + + +class TestTextRecognitionWidth(unittest.TestCase): + def test_invalid_options(self): + for scale in (0, -1, float("nan"), float("inf"), True, "2"): + with self.subTest(scale=scale), self.assertRaises(ValueError): + OCRReisizeNormImg(width_scale=scale) + for limit in (0, -1, 0.5, float("inf"), True): + with self.subTest(limit=limit), self.assertRaises(ValueError): + OCRReisizeNormImg(width_limit=limit) + + def test_scaled_crop_uses_cap_and_padding(self): + crop = np.full((48, 400, 3), 255, dtype=np.uint8) + default = OCRReisizeNormImg()(imgs=[crop])[0] + wider = OCRReisizeNormImg(width_scale=2.0, width_limit=640)(imgs=[crop])[0] + self.assertEqual(default.shape, (3, 48, 400)) + self.assertEqual(wider.shape, (3, 48, 640)) + + short = np.full((48, 100, 3), 255, dtype=np.uint8) + padded = OCRReisizeNormImg(width_scale=2.0)(imgs=[short])[0] + self.assertEqual(padded.shape, (3, 48, 320)) + self.assertTrue(np.all(padded[:, :, :200] == 1)) + self.assertTrue(np.all(padded[:, :, 200:] == 0)) + + def test_word_boxes_use_actual_cap_for_batched_crops(self): + crops = [np.zeros((48, w, 3), dtype=np.uint8) for w in (720, 960)] + ratios, padded_ratio = _word_box_width_ratios( + crops, [0, 1], [3, 48, 320], 2, 2.0, 1280, None + ) + self.assertEqual(ratios, [1280 / 48, 1280 / 48]) + self.assertEqual(padded_ratio, 1280 / 48) + + def test_static_shape_ignores_width_options(self): + crop = np.full((48, 720, 3), 255, dtype=np.uint8) + default = OCRReisizeNormImg(input_shape=[3, 48, 320])([crop])[0] + wider = OCRReisizeNormImg( + input_shape=[3, 48, 320], width_scale=2.0, width_limit=1280 + )([crop])[0] + np.testing.assert_array_equal(default, wider) + ratios, padded_ratio = _word_box_width_ratios( + [crop], [0], [3, 48, 320], 1, 2.0, 1280, [3, 48, 320] + ) + self.assertEqual(ratios, [15.0]) + self.assertEqual(padded_ratio, 15.0) + + def test_transformers_engine_rejects_options_it_cannot_apply(self): + with self.assertRaisesRegex(ValueError, "only supported by the Paddle"): + TextRecTransformersPredictor(width_scale=2.0) + + +if __name__ == "__main__": + unittest.main()