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()