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
36 changes: 34 additions & 2 deletions ppocr/postprocess/rec_postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ def pred_reverse(self, pred):

return "".join(pred_re[::-1])

def reverse_selection(self, pred, selection):
"""Reorder the selected columns to match pred_reverse's token reversal.

pred_reverse keeps runs of LTR characters together and reverses the
unit sequence. Returns the reordered columns as an index array, since a
boolean mask cannot express out-of-order columns.
"""
units = []
c_current = []
for i, c in enumerate(pred):
if not bool(re.search("[a-zA-Z0-9 :*./%+-]", c)):
if c_current:
units.append(c_current)
c_current = []
units.append([i])
else:
c_current.append(i)
if c_current:
units.append(c_current)

valid_col = np.where(selection == True)[0]
return np.array(
[valid_col[i] for unit in units[::-1] for i in unit], dtype="int64"
)

def add_special_char(self, dict_character):
return dict_character

Expand All @@ -75,7 +100,7 @@ def get_word_info(self, text, selection):

Args:
text: the decoded text
selection: the bool array that identifies which columns of features are decoded as non-separated characters
selection: the bool array that identifies which columns of features are decoded as non-separated characters, or an int array of the explicit columns in text order (used by the reversed path)
Returns:
word_list: list of the grouped words
word_col_list: list of decoding positions corresponding to each character in the grouped word
Expand All @@ -90,7 +115,11 @@ def get_word_info(self, text, selection):
word_list = []
word_col_list = []
state_list = []
valid_col = np.where(selection == True)[0]
valid_col = (
np.where(selection == True)[0]
if selection.dtype == bool
else np.asarray(selection)
)

for c_i, char in enumerate(text):
if "\u4e00" <= char <= "\u9fff":
Expand Down Expand Up @@ -172,6 +201,9 @@ def decode(
text = "".join(char_list)

if self.reverse: # for arabic rec
if return_word_box:
# keep the reported columns aligned with the reversed text
selection = self.reverse_selection(text, selection)
text = self.pred_reverse(text)

if return_word_box:
Expand Down
144 changes: 144 additions & 0 deletions tests/unit/test_rec_postprocess.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# 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.

"""Tests for the RTL (reverse=True) word-box column alignment in rec_postprocess.

The decoder reverses arabic text with pred_reverse before get_word_info, so the
selection columns must be reordered by the same token mapping. These tests load
the module directly with a paddle stub to avoid the full paddle runtime.
"""

import importlib.util
import sys
import types
from pathlib import Path

import numpy as np

REPO_ROOT = Path(__file__).resolve().parents[2]


def _import_rec_postprocess():
paddle_stub = types.ModuleType("paddle")
nn_stub = types.ModuleType("paddle.nn")
functional_stub = types.ModuleType("paddle.nn.functional")
paddle_stub.nn = nn_stub
nn_stub.functional = functional_stub
for name, mod in (
("paddle", paddle_stub),
("paddle.nn", nn_stub),
("paddle.nn.functional", functional_stub),
):
sys.modules.setdefault(name, mod)

spec = importlib.util.spec_from_file_location(
"ppocr.postprocess.rec_postprocess",
REPO_ROOT / "ppocr" / "postprocess" / "rec_postprocess.py",
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def _make_decoder(tmp_path, reverse):
dict_file = tmp_path / ("arabic_dict.txt" if reverse else "dict.txt")
chars = "abcdefghijklmnopqrstuvwxyz" + "ابحرم"
dict_file.write_text("\n".join(chars) + "\n", encoding="utf-8")
return _import_rec_postprocess().BaseRecLabelDecode(
character_dict_path=str(dict_file), use_space_char=True
)


# "Hello مرحبا": LTR run "Hello " (indices 0-5, space joins the run) then five
# arabic letters (indices 6-10). pred_reverse keeps the LTR run whole, so the
# reversed order is the five arabic letters, then "Hello ".
ARABIC_TEXT = "Hello مرحبا"
REVERSED_CHARS = [10, 9, 8, 7, 6, 0, 1, 2, 3, 4, 5]


def _selection_with_leading_blanks(n_valid, n_blank):
selection = np.array([False] * n_blank + [True] * n_valid)
return selection


class TestReverseSelection:
def test_columns_follow_pred_reverse_units(self, tmp_path):
decoder = _make_decoder(tmp_path, reverse=True)
selection = _selection_with_leading_blanks(len(ARABIC_TEXT), 3)

selection_re = decoder.reverse_selection(ARABIC_TEXT, selection)

expected = np.arange(3, 3 + len(ARABIC_TEXT))[REVERSED_CHARS]
assert selection_re.tolist() == expected.tolist()

def test_reversed_text_matches_selection_order(self, tmp_path):
decoder = _make_decoder(tmp_path, reverse=True)
text_re = decoder.pred_reverse(ARABIC_TEXT)
assert list(text_re) == [ARABIC_TEXT[i] for i in REVERSED_CHARS]


class TestRtlWordBoxAlignment:
@staticmethod
def _flat(cols):
return [c for group in cols for c in group]

def test_rtl_columns_match_original_positions(self, tmp_path):
decoder = _make_decoder(tmp_path, reverse=True)
selection = _selection_with_leading_blanks(len(ARABIC_TEXT), 3)
selection_re = decoder.reverse_selection(ARABIC_TEXT, selection)
text_re = decoder.pred_reverse(ARABIC_TEXT)

_, word_col_list, _ = decoder.get_word_info(text_re, selection_re)

# The reported columns must be the original feature columns of each
# non-separator character, in the reversed text's reading order. The
# arabic run and "Hello" land in separate word groups; the space
# splits them and its column is not reported.
expected = [
c
for c in np.arange(3, 3 + len(ARABIC_TEXT))[REVERSED_CHARS].tolist()
if c != 8
]
assert self._flat(word_col_list) == expected

def test_ltr_behavior_unchanged(self, tmp_path):
decoder = _make_decoder(tmp_path, reverse=False)
selection = _selection_with_leading_blanks(len(ARABIC_TEXT), 3)

_, word_col_list, _ = decoder.get_word_info(ARABIC_TEXT, selection)

assert self._flat(word_col_list) == [
c for c in range(3, 3 + len(ARABIC_TEXT)) if c != 8
]

def test_blank_interleaving_keeps_column_mapping(self, tmp_path):
decoder = _make_decoder(tmp_path, reverse=True)
# Blanks in the middle as well: columns 3-7 ("Hello") and 9-13
# (arabic) survive; the space at column 8 is dropped.
selection = np.array([False] * 3 + [True] * 5 + [False] + [True] * 5 + [False])
kept = [3, 4, 5, 6, 7, 9, 10, 11, 12, 13]
# Feature columns 3-7 decode to "Hello", column 8 is a blank, 9-13
# decode to the five arabic letters.
kept_text = "Helloمرحبا"
text_re = decoder.pred_reverse(kept_text)

selection_re = decoder.reverse_selection(kept_text, selection)

# "Helloمرحبا" (no space): units are "Hello" and five single arabic
# letters, reversed to the letters first then "Hello".
kept_map = [9, 8, 7, 6, 5, 0, 1, 2, 3, 4]
expected = np.array(kept)[kept_map]
assert selection_re.tolist() == expected.tolist()
_, word_col_list, _ = decoder.get_word_info(text_re, selection_re)
assert self._flat(word_col_list) == expected.tolist()