Skip to content
Draft
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
154 changes: 122 additions & 32 deletions doctr/datasets/generator/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,105 @@
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

import random
from collections.abc import Callable
from collections.abc import Callable, Sequence
from functools import lru_cache
from typing import Any

from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageFont

from doctr.io.image import tensor_from_pil
from doctr.utils import Sample
from doctr.utils.fonts import get_font
from doctr.utils.fonts import get_font, get_font_candidates

from ..datasets import AbstractDataset

# Size the synthetic samples are rendered at, and therefore the size the vocabulary is
# probed at: whether a glyph carries ink is a property of the rasterized outline.
DEFAULT_FONT_SIZE = 32
# Unicode non-characters: permanently unassigned, so a font never maps them and always
# falls back to ".notdef". Rendering them is how we learn what that fallback looks like.
NON_CHARACTERS = ("\ufffe", "\U0010ffff")


@lru_cache(maxsize=32)
def _load_font(font_family: str | None, font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return get_font(font_family, font_size)


def _glyph_ink(font: ImageFont.FreeTypeFont | ImageFont.ImageFont, char: str) -> tuple[tuple[int, int], bytes] | None:
"""Rasterized glyph of a character, None if it is drawn without ink."""
mask = font.getmask(char, mode="L")
return (mask.size, bytes(mask)) if mask.getbbox() is not None else None


@lru_cache(maxsize=32)
def _notdef_ink(font_family: str | None, font_size: int) -> frozenset[tuple[tuple[int, int], bytes]]:
"""Glyph a font falls back to for characters it does not cover (the ".notdef" box)."""
font = _load_font(font_family, font_size)
return frozenset(ink for char in NON_CHARACTERS if (ink := _glyph_ink(font, char)) is not None)


@lru_cache(maxsize=8192)
def _renders_char(char: str, font_family: str | None, font_size: int) -> bool:
"""Whether a font draws the actual glyph of a character, rather than nothing or a box."""
ink = _glyph_ink(_load_font(font_family, font_size), char)
return ink is not None and ink not in _notdef_ink(font_family, font_size)


def _resolve_fonts(font_family: str | list[str] | None) -> list[str | None]:
font_families: list[str | None] = [*font_family] if isinstance(font_family, list) else [font_family]
for font in font_families:
try:
_ = _load_font(font, DEFAULT_FONT_SIZE)
except OSError:
raise ValueError(f"unable to locate font: {font}")
return font_families


def _fonts_per_char(
vocab: str, font_families: Sequence[str | None], font_size: int = DEFAULT_FONT_SIZE
) -> dict[str, tuple[str | None, ...]]:
"""Maps each character of the vocab to the fonts able to render it.

Check notice on line 65 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L65

Missing blank line after last section ('Raises') (D413)

Check notice on line 65 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L65

Missing dashed underline after section ('Returns') (D407)

Check notice on line 65 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L65

Multi-line docstring summary should start at the second line (D213)

Check notice on line 65 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L65

Section name should end with a newline ('Returns', not 'Returns:') (D406)

Characters none of the given fonts can render fall back to the system fonts.

Args:
vocab: the characters to render
font_families: the font families to pick from
font_size: the size the characters are rendered at

Returns:
the fonts able to render each character

Raises:
ValueError: if a character is rendered by none of the fonts
"""
# Whitespace is inkless in every font by design, so no font can be said to "cover" it;
# judging it by ink would reject any vocabulary containing a space.
mapping = {
char: tuple(font for font in font_families if char.isspace() or _renders_char(char, font, font_size))
for char in dict.fromkeys(vocab)
}
missing = [char for char, fonts in mapping.items() if not fonts]
if missing:
fallbacks = [font for font in get_font_candidates() if font not in font_families]
mapping.update({
char: tuple(font for font in fallbacks if _renders_char(char, font, font_size)) for char in missing
})
unrenderable = [char for char in missing if not mapping[char]]
if unrenderable:
raise ValueError(
f"the following characters cannot be rendered, neither by the given fonts {list(font_families)} "
f"nor by the system fonts {fallbacks}: "
f"{', '.join(f'{char!r} (U+{ord(char):04X})' for char in unrenderable)}. "
"They are drawn blank or as a '.notdef' box, please provide font(s) covering the whole vocab."
)
return mapping


def synthesize_text_img(
text: str,
font_size: int = 32,
font_size: int = DEFAULT_FONT_SIZE,
font_family: str | None = None,
background_color: tuple[int, int, int] | None = None,
text_color: tuple[int, int, int] | None = None,
Expand All @@ -34,13 +118,22 @@

Returns:
PIL image of the text

Raises:
ValueError: if the text is empty or rendered without ink
"""
if not text:
raise ValueError("unable to synthesize an image from an empty text")

background_color = (0, 0, 0) if background_color is None else background_color
text_color = (255, 255, 255) if text_color is None else text_color

font = get_font(font_family, font_size)
font = _load_font(font_family, font_size)
left, top, right, bottom = font.getbbox(text)
text_w, text_h = right - left, bottom - top
if text_w <= 0 or text_h <= 0:
raise ValueError(f"font {font_family!r} draws no ink for {text!r}, resulting in a zero-dimension image")

h, w = int(round(1.3 * text_h)), int(round(1.1 * text_w))
# If single letter, make the image square, otherwise expand to meet the text size
img_size = (h, w) if len(text) > 1 else (max(h, w), max(h, w))
Expand All @@ -67,14 +160,8 @@
) -> None:
self.vocab = vocab
self._num_samples = num_samples
self.font_family = font_family if isinstance(font_family, list) else [font_family]
# Validate fonts
if isinstance(font_family, list):
for font in self.font_family:
try:
_ = get_font(font, 10)
except OSError:
raise ValueError(f"unable to locate font: {font}")
self.font_family = _resolve_fonts(font_family)
self._fonts_per_char = _fonts_per_char(self.vocab, self.font_family)
self.img_transforms = img_transforms
self.sample_transforms = sample_transforms

Expand All @@ -83,7 +170,7 @@
self._data = [
(synthesize_text_img(char, font_family=font), idx) # type: ignore[misc]
for idx, char in enumerate(self.vocab)
for font in self.font_family
for font in self._fonts_per_char[char]
]

def __len__(self) -> int:
Expand All @@ -96,7 +183,8 @@
pil_img, target = self._data[idx] # type: ignore[misc]
else:
target = index % len(self.vocab)
pil_img = synthesize_text_img(self.vocab[target], font_family=random.choice(self.font_family))
char = self.vocab[target]
pil_img = synthesize_text_img(char, font_family=random.choice(self._fonts_per_char[char]))

Check warning on line 187 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L187

Standard pseudo-random generators are not suitable for security/cryptographic purposes.
img = tensor_from_pil(pil_img)

return img, target
Expand All @@ -117,28 +205,31 @@
self.vocab = vocab
self.wordlen_range = (min_chars, max_chars)
self._num_samples = num_samples
self.font_family = font_family if isinstance(font_family, list) else [font_family]
# Validate fonts
if isinstance(font_family, list):
for font in self.font_family:
try:
_ = get_font(font, 10)
except OSError:
raise ValueError(f"unable to locate font: {font}")
self.font_family = _resolve_fonts(font_family)
fonts_per_char = _fonts_per_char(self.vocab, self.font_family)
# A word is drawn with a single font, so the font is picked first and the word is
# built from the characters this font can render, weighted by its coverage
self._vocab_per_font = {
font: "".join(char for char in self.vocab if font in fonts_per_char[char])
for font in dict.fromkeys(font for fonts in fonts_per_char.values() for font in fonts)
}
self._fonts = list(self._vocab_per_font)
self._font_weights = [len(vocab_) for vocab_ in self._vocab_per_font.values()]
self.img_transforms = img_transforms
self.sample_transforms = sample_transforms

self._data: list[Image.Image] = []
if cache_samples:
_words = [self._generate_string(*self.wordlen_range) for _ in range(num_samples)]
self._data = [
(synthesize_text_img(text, font_family=random.choice(self.font_family)), text) # type: ignore[misc]
for text in _words
]
self._data = [self._synthesize_sample() for _ in range(num_samples)] # type: ignore[misc]

def _generate_string(self, min_chars: int, max_chars: int) -> str:
def _generate_string(self, min_chars: int, max_chars: int, vocab: str | None = None) -> str:
num_chars = random.randint(min_chars, max_chars)
return "".join(random.choice(self.vocab) for _ in range(num_chars))
return "".join(random.choice(self.vocab if vocab is None else vocab) for _ in range(num_chars))

Check warning on line 227 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L227

Standard pseudo-random generators are not suitable for security/cryptographic purposes.

def _synthesize_sample(self) -> tuple[Image.Image, str]:
font = random.choices(self._fonts, weights=self._font_weights)[0]

Check warning on line 230 in doctr/datasets/generator/base.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/datasets/generator/base.py#L230

Standard pseudo-random generators are not suitable for security/cryptographic purposes.
text = self._generate_string(*self.wordlen_range, vocab=self._vocab_per_font[font])
return synthesize_text_img(text, font_family=font), text

def __len__(self) -> int:
return self._num_samples
Expand All @@ -148,8 +239,7 @@
if len(self._data) > 0:
pil_img, target = self._data[index] # type: ignore[misc]
else:
target = self._generate_string(*self.wordlen_range)
pil_img = synthesize_text_img(target, font_family=random.choice(self.font_family))
pil_img, target = self._synthesize_sample()
img = tensor_from_pil(pil_img)

return img, target
30 changes: 21 additions & 9 deletions doctr/utils/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from PIL import ImageFont

__all__ = ["get_font"]
__all__ = ["get_font", "get_font_candidates"]

logger = logging.getLogger(__name__)

Expand All @@ -34,17 +34,29 @@
}


def _is_available(font_family: str) -> bool:
try:
_ = ImageFont.truetype(font_family, 10)
except OSError:
return False
return True


def get_font_candidates() -> tuple[str, ...]:
"""Lists the recommended fonts which are installed on this system

Check notice on line 46 in doctr/utils/fonts.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/utils/fonts.py#L46

First line should end with a period, question mark, or exclamation point (not 'm') (D415)

Check notice on line 46 in doctr/utils/fonts.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/utils/fonts.py#L46

Missing blank line after last section ('Returns') (D413)

Check notice on line 46 in doctr/utils/fonts.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/utils/fonts.py#L46

Missing dashed underline after section ('Returns') (D407)

Check notice on line 46 in doctr/utils/fonts.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/utils/fonts.py#L46

Multi-line docstring summary should start at the second line (D213)

Check notice on line 46 in doctr/utils/fonts.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

doctr/utils/fonts.py#L46

Section name should end with a newline ('Returns', not 'Returns:') (D406)

Returns:
the available font families, by order of preference
"""
candidates = _FONT_CANDIDATES.get(platform.system(), _FONT_CANDIDATES["Linux"])
return tuple(family for family in candidates if _is_available(family))


@lru_cache(maxsize=1)
def _resolve_default_font_family() -> str | None:
"""Find the first available candidate font for this platform."""
candidates = _FONT_CANDIDATES.get(platform.system(), _FONT_CANDIDATES["Linux"])
for family in candidates:
try:
ImageFont.truetype(family, 10)
return family
except OSError:
continue
return None
candidates = get_font_candidates()
return candidates[0] if candidates else None


def get_font(font_family: str | None = None, font_size: int = 13) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
Expand Down
61 changes: 61 additions & 0 deletions tests/common/test_datasets.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove all the comments under the functions here - the test names are clear enough

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,67 @@
import pytest

from doctr import datasets
from doctr.datasets.generator import base
from doctr.datasets.generator.base import _fonts_per_char, _renders_char, synthesize_text_img
from doctr.utils.fonts import get_font_candidates


@pytest.mark.parametrize("text", ["", " ", " "])
def test_synthesize_text_img_rejects_inkless_text(text):
# Empty text, and text a font draws with no ink, both used to yield a 0-dimension image
# and an opaque resize failure deep in a DataLoader worker (#2016).
with pytest.raises(ValueError):
synthesize_text_img(text)


def test_renders_char_smoke_on_a_real_font():
# One real-font assertion, kept deliberately minimal: basic Latin coverage in the
# default system font. It proves the Pillow/FreeType path works; every capability
# rule below is asserted deterministically instead of through installed fonts.
if not get_font_candidates():
pytest.skip("no recommended system font installed")
assert _renders_char("a", get_font_candidates()[0], 32)


def test_renders_char_rejects_blank_and_notdef(monkeypatch):
# A character the font does not cover is drawn as a ".notdef" box: it carries ink, so
# ink alone cannot tell it apart from a real glyph — it must be compared to the box.
notdef = ((4, 4), b"notdef")
ink = {"a": ((3, 5), b"glyph"), "?": notdef, " ": None}
monkeypatch.setattr(base, "_glyph_ink", lambda font, char: ink[char])
monkeypatch.setattr(base, "_notdef_ink", lambda family, size: frozenset({notdef}))
base._renders_char.cache_clear()

assert base._renders_char("a", None, 32)
assert not base._renders_char("?", None, 32) # .notdef box
assert not base._renders_char(" ", None, 32) # no ink at all
base._renders_char.cache_clear()


def test_fonts_per_char_falls_back_to_system_fonts(monkeypatch):
# "x" is drawn by neither given font, so it must fall back — and only to the system
# candidate that actually draws it, in candidate order.
monkeypatch.setattr(base, "get_font_candidates", lambda: ("blind.ttf", "rescue.ttf"))
monkeypatch.setattr(base, "_renders_char", lambda char, font, size: char != "x" or font == "rescue.ttf")

mapping = base._fonts_per_char("ax", ["given.ttf"])

assert mapping["a"] == ("given.ttf",)
assert mapping["x"] == ("rescue.ttf",)


def test_fonts_per_char_accepts_whitespace():
# A space is inkless in every font by design; judging it by ink would reject any
# vocabulary containing one, such as VOCABS["latex"].
assert _fonts_per_char("a b", [None])[" "] == (None,)


def test_fonts_per_char_raises_when_no_font_renders(monkeypatch):
monkeypatch.setattr(base, "get_font_candidates", lambda: ("blind.ttf",))
monkeypatch.setattr(base, "_renders_char", lambda char, font, size: False)

with pytest.raises(ValueError, match="cannot be rendered"):
base._fonts_per_char("x", ["given.ttf"])


def test_visiondataset():
Expand Down
20 changes: 19 additions & 1 deletion tests/common/test_utils_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from PIL.ImageFont import FreeTypeFont, ImageFont

from doctr.utils import fonts
from doctr.utils.fonts import get_font
from doctr.utils.fonts import get_font, get_font_candidates


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -65,3 +65,21 @@ def test_get_font_fallback(monkeypatch):
assert isinstance(font, (ImageFont, FreeTypeFont))
# The fallback font must still be usable for text measurement
assert font.getbbox("hello")[2] > 0


def test_get_font_candidates_only_lists_installed_fonts():
candidates = get_font_candidates()

# Every returned family must actually load, and the default is the first of them
for family in candidates:
assert isinstance(get_font(family, 10), FreeTypeFont)
assert fonts._resolve_default_font_family() == (candidates[0] if candidates else None)


def test_get_font_candidates_skips_missing(monkeypatch):
monkeypatch.setattr(
fonts, "_FONT_CANDIDATES", dict.fromkeys(fonts._FONT_CANDIDATES, ("missing-font.ttf", "DejaVuSans.ttf"))
)
fonts._resolve_default_font_family.cache_clear()

assert "missing-font.ttf" not in get_font_candidates()
Loading