Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/pipeline_usage/tutorials/ocr_pipelines/OCR.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <b>Simply modify the `--device` parameter</b> to seamlessly switch between different hardware devices.

Expand Down
15 changes: 15 additions & 0 deletions docs/pipeline_usage/tutorials/ocr_pipelines/OCR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 等多种主流硬件设备,<b>仅需修改 `--device`参数</b>即可完成不同硬件之间的无缝切换。

Expand Down
81 changes: 68 additions & 13 deletions paddlex/inference/models/text_recognition/predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
37 changes: 33 additions & 4 deletions paddlex/inference/models/text_recognition/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import math
import re
from numbers import Integral, Real
from typing import List, Optional

import numpy as np
Expand Down Expand Up @@ -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"""
Expand All @@ -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:
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion paddlex/inference/pipelines/ocr/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
75 changes: 75 additions & 0 deletions tests/test_text_rec_width.py
Original file line number Diff line number Diff line change
@@ -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()