diff --git a/.github/workflows/gen_whl_to_pypi_rapidocr.yml b/.github/workflows/gen_whl_to_pypi_rapidocr.yml index f9a5c32..9e34b01 100644 --- a/.github/workflows/gen_whl_to_pypi_rapidocr.yml +++ b/.github/workflows/gen_whl_to_pypi_rapidocr.yml @@ -29,7 +29,7 @@ jobs: cd python python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install pytest build setuptools wheel setuptools-scm openvino==2026.2.1 onnxruntime python-bidi MNN + python -m pip install pytest build setuptools wheel setuptools-scm openvino==2026.2.1 onnxruntime python-bidi khatt MNN python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu python -m pip install paddlepaddle==3.1.1 -i https://www.paddlepaddle.org.cn/packages/stable/cpu/ diff --git a/python/pyproject.toml b/python/pyproject.toml index 014d39c..63d85db 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -44,7 +44,11 @@ Changelog = "https://github.com/RapidAI/RapidOCR/releases" rapidocr = "rapidocr.main:main" [project.optional-dependencies] -rtl = ["python-bidi>=0.4.2,<1.0"] +rtl = [ + "python-bidi>=0.4.2,<1.0", + # Shapes Arabic text in vis(); needs Python 3.10+ + "khatt>=0.4.0; python_version >= '3.10'", +] [tool.setuptools] include-package-data = true diff --git a/python/rapidocr/utils/vis_res.py b/python/rapidocr/utils/vis_res.py index 215e744..1a6a219 100644 --- a/python/rapidocr/utils/vis_res.py +++ b/python/rapidocr/utils/vis_res.py @@ -3,6 +3,7 @@ # @Contact: liekkaskono@163.com import math import random +import re from functools import lru_cache from pathlib import Path from typing import List, Optional, Sequence, Tuple, Union @@ -18,6 +19,13 @@ from .log import logger from .model_resolver import normalize_lang from .typings import LangRec +try: + # Part of the rtl extra: joins Arabic letters and lays out right-to-left + # text, which Pillow only does when it is built with Raqm + import khatt +except ImportError: + khatt = None + root_dir = Path(__file__).resolve().parent.parent InputType = Union[str, np.ndarray, bytes, Path, Image.Image] @@ -25,6 +33,11 @@ DEFAULT_FONT_DIR = root_dir / "models" DEFAULT_FONT_PATH = DEFAULT_FONT_DIR / "FZYTK.TTF" FONT_YAML_PATH = root_dir / "default_models.yaml" +# Hebrew, Arabic, Syriac, Thaana, N'Ko and the Arabic presentation forms +RTL_PATTERN = re.compile("[֐-ࣿיִ-﷿ﹰ-ﻼ]") +# Arabic glyphs for a font that has none, e.g. the default FZYTK.TTF +RTL_FALLBACK_FONTS = ["noto-naskh"] + @lru_cache() def font_cfg(): @@ -173,8 +186,7 @@ class VisRes: cur_y += self.get_char_size(font, c) else: font_size = max(int(box_height * 0.8), 10) - font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8") - draw_right.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font) + self.draw_line(img_right, draw_right, box, txt, font_size) img_left = Image.blend(image, img_left, 0.5) img_show = Image.new("RGB", (w * 2, h), 255) @@ -224,8 +236,7 @@ class VisRes: cur_y += self.get_char_size(font, c) else: font_size = max(int(box_height * 0.8), 10) - font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8") - draw_right.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font) + self.draw_line(img_right, draw_right, box, txt, font_size) img_left = Image.blend(image, img_left, 0.5) img_show = Image.new("RGB", (w * 2, h), (255, 255, 255)) @@ -233,6 +244,50 @@ class VisRes: img_show.paste(img_right, (w, 0, w * 2, h)) return np.array(img_show) + def draw_line( + self, + img: Image.Image, + draw: ImageDraw.ImageDraw, + box: List[List[Union[float, int]]], + txt: str, + font_size: int, + ) -> None: + """Draw a horizontal line of text in its box. + + With khatt installed (``pip install "rapidocr[rtl]"``), right-to-left text + is shaped and starts at the right edge of the box, where it was read. It + is centered vertically, and made smaller if it would not fit the box. + """ + if khatt is not None and RTL_PATTERN.search(txt): + options = {"font": self.font_path, "fallback_fonts": RTL_FALLBACK_FONTS} + left, top, right, bottom = khatt.text_bbox( + txt, size_px=font_size, **options + ) + scale = min( + self.get_box_width(box) / max(right - left, 1), + self.get_box_height(box) / max(bottom - top, 1), + ) + if scale < 1: + font_size = max(int(font_size * scale), 1) + left, top, right, bottom = khatt.text_bbox( + txt, size_px=font_size, **options + ) + + middle_y = (box[1][1] + box[2][1]) / 2 + khatt.draw_text( + img, + (float(box[1][0]), float(middle_y - (bottom - top) / 2)), + txt, + size_px=font_size, + fill=(0, 0, 0), + anchor="rt", + **options, + ) + return + + font = ImageFont.truetype(self.font_path, font_size, encoding="utf-8") + draw.text([box[0][0], box[0][1]], txt, fill=(0, 0, 0), font=font) + @staticmethod def get_random_color() -> Tuple[int, int, int]: return ( diff --git a/python/tests/test_vis.py b/python/tests/test_vis.py index 20ead95..5d531de 100644 --- a/python/tests/test_vis.py +++ b/python/tests/test_vis.py @@ -5,11 +5,13 @@ import sys from pathlib import Path import pytest +from PIL import Image, ImageDraw, ImageOps root_dir = Path(__file__).resolve().parent.parent sys.path.append(str(root_dir)) -from rapidocr import RapidOCR +from rapidocr import LangRec, ModelType, OCRVersion, RapidOCR +from rapidocr.utils import vis_res tests_dir = root_dir / "tests" / "test_files" img_path = tests_dir / "ch_en_num.jpg" @@ -47,3 +49,89 @@ def test_vis_det_cls_rec(engine): result = engine(img_path, use_det=True, use_cls=True, use_rec=True) vis_img = result.vis() assert vis_img.shape[2] == 3 + + +ARABIC_TEXT = "امروز هوا خوبه" + + +def ink_box(img): + return ImageOps.invert(img.convert("L")).getbbox() + + +def test_vis_draws_rtl_text_with_khatt(monkeypatch): + khatt = pytest.importorskip("khatt") + drawn = [] + draw_text = khatt.draw_text + + def spy(img, xy, txt, **kwargs): + drawn.append(txt) + return draw_text(img, xy, txt, **kwargs) + + monkeypatch.setattr(vis_res.khatt, "draw_text", spy) + engine = RapidOCR( + params={ + "Rec.lang_type": LangRec.ARABIC, + "Rec.model_type": ModelType.MOBILE, + "Rec.ocr_version": OCRVersion.PPOCRV5, + } + ) + result = engine( + tests_dir / "arabic.png", use_det=False, use_cls=False, use_rec=True + ) + vis_img = result.vis() + assert vis_img.shape[2] == 3 + assert drawn == list(result.txts) + + +def test_rtl_line_starts_at_the_right_edge_and_fits_its_box(): + pytest.importorskip("khatt") + vis = vis_res.VisRes(lang_type=LangRec.ARABIC) + img = Image.new("RGB", (300, 60), (255, 255, 255)) + box = [[20, 10], [280, 10], [280, 50], [20, 50]] + vis.draw_line(img, ImageDraw.Draw(img), box, ARABIC_TEXT, 40) + + left, top, right, bottom = ink_box(img) + assert right == 280 + assert left >= 20 and top >= 10 and bottom <= 50 + + +def test_rtl_line_shrinks_below_10px_to_fit_a_small_box(): + pytest.importorskip("khatt") + vis = vis_res.VisRes(lang_type=LangRec.ARABIC) + img = Image.new("RGB", (100, 30), (255, 255, 255)) + box = [[20, 10], [60, 10], [60, 18], [20, 18]] + vis.draw_line(img, ImageDraw.Draw(img), box, ARABIC_TEXT, 10) + + left, top, right, bottom = ink_box(img) + assert left >= 20 and right <= 60 and top >= 10 and bottom <= 18 + + +def test_ltr_line_is_drawn_by_pillow(monkeypatch): + pytest.importorskip("khatt") + monkeypatch.setattr( + vis_res.khatt, "draw_text", lambda *args, **kwargs: pytest.fail("khatt used") + ) + vis = vis_res.VisRes() + img = Image.new("RGB", (300, 60), (255, 255, 255)) + vis.draw_line( + img, + ImageDraw.Draw(img), + [[20, 10], [280, 10], [280, 50], [20, 50]], + "Hello", + 32, + ) + assert ink_box(img) is not None + + +def test_rtl_line_without_khatt_is_drawn_by_pillow(monkeypatch): + monkeypatch.setattr(vis_res, "khatt", None) + vis = vis_res.VisRes(lang_type=LangRec.ARABIC) + img = Image.new("RGB", (300, 60), (255, 255, 255)) + vis.draw_line( + img, + ImageDraw.Draw(img), + [[20, 10], [280, 10], [280, 50], [20, 50]], + ARABIC_TEXT, + 32, + ) + assert ink_box(img)[0] >= 20