mirror of
https://github.com/RapidAI/RapidOCR.git
synced 2026-11-10 13:03:58 +08:00
feat(python): draw Arabic text in vis() with khatt from the rtl extra (#763)
* feat(python): draw Arabic text in vis() with khatt from the rtl extra Recognized Arabic, Persian and Urdu text is returned in reading order, but vis() draws it with ImageDraw.text, which only joins Arabic letters and lays out right-to-left text when Pillow is built with Raqm. Without it the visualization shows separate letters in reverse order, running out of the box. When khatt is installed (now part of the rtl extra on Python 3.10+), right-to-left lines are shaped, start at the right edge of their box, are centered vertically and shrink to fit the box. Other text, and RTL text without khatt, is drawn by Pillow as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(python): ruff format the vis RTL changes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(python): let RTL text shrink below 10px to fit small boxes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80b427d69b
commit
a538d3a75e
4 files changed
+154
-7
No files matched your search
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user