Skip to content

[datasets] fix zero-dimension synthetic image for blank glyphs (#2016) - #2123

Draft
ousamabenyounes wants to merge 2 commits into
mindee:mainfrom
ousamabenyounes:fix/issue-2016
Draft

[datasets] fix zero-dimension synthetic image for blank glyphs (#2016)#2123
ousamabenyounes wants to merge 2 commits into
mindee:mainfrom
ousamabenyounes:fix/issue-2016

Conversation

@ousamabenyounes

@ousamabenyounes ousamabenyounes commented Aug 15, 2026

Copy link
Copy Markdown

Summary

Closes #2016

Recognition training with the synthetic WordGenerator intermittently dies in a DataLoader worker with:

RuntimeError: Input and output sizes should be greater than 0, but got input (H: 0, W: 29) output (H: 1, W: 128)

Root cause. synthesize_text_img sizes the canvas from font.getbbox(text). A font may map a codepoint to an empty outline: the glyph carries a horizontal advance but draws no ink, so getbbox returns a zero-height box. h = round(1.3 * text_h) becomes 0 and the function returns a 0-dimension image, which F.resize then rejects.

This is reachable from shipped components alone, not only from custom fonts. _BASE_VOCABS["currency"] = "£€¥¢฿" (vocabs.py:18) flows into VOCABS["english"] (:242) and from there into 73 of the 215 shipped vocabs, including polish (:308) — and 6 of the 12 Liberation faces on a stock Ubuntu draw ฿ (U+0E3F) blank:

from PIL import ImageFont
f = ImageFont.truetype("LiberationSerif-Regular.ttf", 32)
f.getbbox("฿")            # (0, 29, 25, 29)  -> 25px advance, 0px height
f.getmask("฿").getbbox()  # None             -> nothing drawn

from doctr.datasets.generator.base import synthesize_text_img
synthesize_text_img("฿฿", font_family="LiberationSerif-Regular.ttf").size
# (55, 0)

Affected: all four LiberationSerif-*, plus LiberationSans-Italic and -BoldItalic. Checked on Ubuntu 24.04.3, Pillow 12.2.0, main @ 2d8e244.

Note it is not the font lacking the glyph: most families draw a tofu box for .notdef, which has ink and a non-zero bbox height. It is the ink that decides, not cmap coverage.

Fix. Per review, fail loudly instead of papering over the degenerate canvas:

  • find_unrenderable_chars() (doctr/utils/fonts.py) lists every (character, font) pair a font draws without ink, using the rendered mask — getmask(char).getbbox() is None. getbbox(char) cannot be the predicate: it returns the advance box, which stays non-zero for a blank glyph.
  • Both _CharacterGenerator.__init__ and _WordGenerator.__init__ run it, so an unrenderable vocabulary raises before the first batch and reports every offending pair at once, instead of surfacing through data.reraise() inside a worker several epochs in.
  • synthesize_text_img raises on a zero-dimension canvas as a last resort.

Whitespace is exempt from the pre-check: it is inkless by design, and VOCABS["latex"] ships a space (vocabs.py:13). Open question for the maintainer — see the discussion below.

Only _WordGenerator can actually crash: for len(text) == 1 the canvas is (max(h, w), max(h, w)), so a single blank glyph yields a square (28x28 for ฿), no exception, just a black image with a non-empty label. _CharacterGenerator gets the check for label quality.

Test verification (RED -> GREEN)

RED — unmodified main @ 2d8e244, new tests only:

--- tests/common/test_datasets.py::test_synthesize_text_img_rejects_inkless_text
>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE ValueError
2 failed in 0.08s

--- tests/pytorch/test_datasets_pt.py::test_generator_rejects_unrenderable_vocab
E       AttributeError: module 'doctr.datasets.generator.base' has no attribute 'find_unrenderable_chars'
2 failed in 0.10s

--- tests/common/test_utils_fonts.py
E       ImportError: cannot import name 'find_unrenderable_chars' from 'doctr.utils.fonts'

GREEN — with the fix:

tests/common/                                      416 passed
tests/pytorch/test_datasets_pt.py (generators)       4 passed

No baseline regression: tests/common/ was 414 passed before the change, 416 after (+2 new tests, the clamp test replaced one for one).

Local quality gates

ruff format --check    153 files already formatted
ruff check .           All checks passed!
mypy doctr/            Success: no issues found in 171 source files

Files changed

File Change
doctr/utils/fonts.py find_unrenderable_chars() — ink-based detection of blank glyphs
doctr/datasets/generator/base.py Pre-check at generator init; raise on a zero-dimension canvas; drop the clamp
tests/common/test_utils_fonts.py Predicate: blank glyphs reported, whitespace skipped, renderable vocab accepted
tests/common/test_datasets.py synthesize_text_img rejects inkless text
tests/pytorch/test_datasets_pt.py Both generators refuse an unrenderable vocabulary at init

Kept as two commits for now so the rework can be tested against the original clamp; will squash before marking ready for review.

…e#2016)

font.getbbox returns a zero-height (or zero-width) box for whitespace and glyphs
a font renders blank. synthesize_text_img then built a 0-dimension image, which
crashes the recognition training resize with 'Input and output sizes should be
greater than 0'. Clamp each dimension to a minimum of 1 pixel.
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.99%. Comparing base (5332574) to head (abb0afe).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2123      +/-   ##
==========================================
- Coverage   97.00%   96.99%   -0.01%     
==========================================
  Files         169      169              
  Lines        9611     9613       +2     
==========================================
+ Hits         9323     9324       +1     
- Misses        288      289       +1     
Flag Coverage Δ
unittests 96.99% <100.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@felixdittrich92 felixdittrich92 left a comment

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.

Hi @ousamabenyounes 👋,

Thanks for the PR! However, this is a false positive.

  1. Whitespace characters should not be included in the vocabulary
  2. Instead of silently overriding the character when there is no font that can render it, we should explicitly raise an error. In this case, it’s a user error that should be fixed by providing font(s) capable of rendering all characters

Maybe a pre-check would be useful to ensure that all characters in the vocabulary can be rendered correctly by the provided font(s)?

@felixdittrich92
felixdittrich92 marked this pull request as draft August 18, 2026 10:40
…ing (mindee#2016)

Clamping a degenerate canvas to 1px turned the crash into silent label noise:
an empty image mapped to a non-empty target. Replace it with the pre-check
suggested in review.

- find_unrenderable_chars() reports every (character, font) pair the font draws
  without ink. The predicate is the rendered mask, not getbbox(): getbbox()
  returns the advance box, which stays non-zero for a blank glyph. Whitespace is
  inkless by design and is skipped, since VOCABS["latex"] ships a space.
- both generators run it at __init__, so an unrenderable vocabulary fails before
  the first batch and lists every offending pair at once, instead of surfacing
  through data.reraise() inside a DataLoader worker several epochs in.
- synthesize_text_img() raises on a zero-dimension canvas as a last resort.
@ousamabenyounes

Copy link
Copy Markdown
Author

Thanks for the review — agreed on the clamp, I've dropped it. Turning a degenerate canvas into a 1px strip trades a crash for silent label noise, which is worse.

Digging into the mechanism though, I don't think the "user error" framing covers the whole case. ฿ (U+0E3F) ships in VOCABS["english"], and half the Liberation faces on a stock Ubuntu draw it blank.

_BASE_VOCABS["currency"] = "£€¥¢฿" (vocabs.py:18) flows into VOCABS["english"] (:242) and from there into 73 of the 215 shipped vocabs — including polish (:308), the one used in #2016.

from PIL import ImageFont
f = ImageFont.truetype("LiberationSerif-Regular.ttf", 32)
f.getbbox("฿")            # (0, 29, 25, 29)  -> 25px advance, 0px height
f.getmask("฿").getbbox()  # None             -> nothing drawn

from doctr.datasets.generator.base import synthesize_text_img
synthesize_text_img("฿฿", font_family="LiberationSerif-Regular.ttf").size
# (55, 0)  -> the zero-dimension image behind #2016

6 of the 12 Liberation faces installed here are affected — all four LiberationSerif-*, plus LiberationSans-Italic and -BoldItalic. LiberationSans-Regular, doctr's own third default candidate, is fine. Checked on Ubuntu 24.04.3, Pillow 12.2.0, main @ 2d8e244.

So --vocab english --font LiberationSerif-Regular.ttf reaches the crash with nothing misconfigured on the user's side.

This is probably not the reporter's exact trigger: their W: 29 means text_w = 26px of total advance, so at least two blank glyphs narrower than ฿ (25px each here) — some other character in their custom font list. Same mechanism, and it's reachable from shipped components alone.

On the mechanism itself: it isn't about the font lacking the glyph. Most families draw a tofu box for .notdef, which has ink and a non-zero bbox height, so a missing character usually can't produce this — though .notdef is a normal glyph and some families ship it empty. Either way it's the ink that decides, not cmap coverage, which is what makes getmask the right predicate.

What I pushed

I've implemented your pre-check suggestion in a separate commit (a0c6e41) rather than squashing, so you can check out just that commit and test it against the clamp if you want. I'll squash before marking the PR ready for review.

  • find_unrenderable_chars() in doctr/utils/fonts.py returns every (character, font) pair the font draws without ink, keyed on get_font(family, size).getmask(char).getbbox() is None. getbbox(char) can't be the predicate — it returns the advance box, which stays non-zero for a blank glyph.
  • Both _CharacterGenerator.__init__ and _WordGenerator.__init__ run it, so an unrenderable vocabulary fails before the first batch and reports every offending pair at once. Raising from synthesize_text_img alone would fire inside a DataLoader worker and reach the user through data.reraise() — the same unreadable traceback as today, possibly several epochs in.
  • synthesize_text_img still raises on a zero-dimension canvas, as a last resort for anything the pre-check exempts.
  • Cost: one getmask per (character, font) at init.

Two decisions I'd like from you

1. Whitespace. I exempted it from the pre-check, because VOCABS["latex"] contains a space — vocabs.py:13 builds it as "".join(sorted(set("...| "))), so " " is its first character, and _BASE_VOCABS is copied into VOCABS wholesale at :235. Rejecting inkless characters unconditionally would make WordGenerator(vocab=VOCABS["latex"], ...) raise for every font. If you'd rather have the strict rule you described, the space should come out of VOCABS["latex"] first — happy to do that instead.

2. Existing setups. This starts raising for configurations that currently "work": --vocab english with a Liberation Serif font has been silently producing black ฿ samples. Hard error, or error with an opt-out?

One detail that shaped the placement: only _WordGenerator can actually crash. For len(text) == 1, synthesize_text_img takes img_size = (max(h, w), max(h, w)), so a single blank glyph yields a square (28x28 for ฿) — no exception, just a black image labelled ฿. _CharacterGenerator gets the check for label quality, not for the crash.

Validation

RED on unmodified main @ 2d8e244 with only the new tests applied:

--- tests/common/test_datasets.py::test_synthesize_text_img_rejects_inkless_text
>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE ValueError
2 failed in 0.08s

--- tests/pytorch/test_datasets_pt.py::test_generator_rejects_unrenderable_vocab
E       AttributeError: module 'doctr.datasets.generator.base' has no attribute 'find_unrenderable_chars'
2 failed in 0.10s

--- tests/common/test_utils_fonts.py
E       ImportError: cannot import name 'find_unrenderable_chars' from 'doctr.utils.fonts'

GREEN with the commit, and no baseline regression:

tests/common/          414 passed  (branch HEAD, before)  ->  416 passed  (after, +2 new tests)
tests/pytorch/test_datasets_pt.py  charactergenerator / wordgenerator / new  ->  4 passed
ruff format --check    153 files already formatted
ruff check .           All checks passed!
mypy doctr/            Success: no issues found in 171 source files

Happy to reshape any of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fatal error while training with Word Generator on multi GPU

2 participants