VLM Image Cropping#
Two related failure modes in the Docling VLM pipeline: (1) zero-area image crops produced by integer rounding during bounding box coordinate conversion in load_from_doctags, and (2) the absence of size-validation guards in ImageRef.from_pil before encoding a crop to PNG.
The Zero-Area Crop Bug#
During DocTags parsing in load_from_doctags(), the pipeline converts normalized bounding box coordinates (0–1 range) to pixel coordinates using bare int() truncation :
crop_box = (
int(bbox.l * im_width),
int(bbox.t * im_height),
int(bbox.r * im_width),
int(bbox.b * im_height),
)
cropped_image = image.crop(crop_box)
When a VLM predicts a degenerate bounding box — for example {'l': 0.398, 't': 0.998, 'r': 0.606, 'b': 0.999} — the difference between the top and bottom coordinates is only 0.001. On a 500px image, both values truncate to 499, yielding a crop box with zero height. PIL cannot encode a zero-area image and crashes with SystemError: tile cannot extend outside image .
The crash surfaces inside ImageRef.from_pil when it tries to PNG-encode the zero-area crop . There is no minimum-size guard between the int() conversion at lines 4493–4498 and the subsequent image.crop() / ImageRef.from_pil() calls at lines 4499–4502.
Why int() Makes It Worse Than round()#
int() always truncates toward zero. Two float values like 0.998 and 0.999 that differ by 0.001, when multiplied by 500 and truncated, both produce 499. Using round() would not fully solve this either — the reporter demonstrated that even 0.998 and 0.999 map to the same integer — but int() maximizes the collapse probability for near-boundary values .
The get_page_image Minimum-Size Fix (PR #3414)#
A related but separate manifestation occurs in Page.get_image(), where a cropbox with sub-pixel dimensions (e.g., width = 0.3 pixels) rounds to zero at the requested scale, causing PIL.Image.resize to fail. PR #3414 added a max(1, round(...)) guard at the scale-application step to enforce a minimum 1-pixel dimension:
"crop box width = 0.3 pixels → round(0.3) = 0 → resize fails"
This fix applies to the image_backend.py path (at lines 112–113 per the fix), not to the load_from_doctags path. The zero-area bug in load_from_doctags (issue #2763) remained unaddressed as of the latest checked commit .
ImageRef.from_pil — No Size Guard#
ImageRef.from_pil accepts any PIL image and immediately encodes it to PNG. The function does not check image.width > 0 or image.height > 0 before encoding . The PNG encoding path branches between:
- OpenCV (
cv2.imencode(".png", ...)) whencv2is installed — added by PR #562 for a ~55% speedup - PIL fallback (
image.save(buffered, format="PNG")) otherwise
Both paths will crash or produce undefined behavior on a zero-area image. The OpenCV path (cv2.imencode) fails with an assertion error; the PIL path raises SystemError: tile cannot extend outside image.
Recommended Fix Pattern#
The correct fix is to enforce a minimum pixel size after the int() conversion, before calling image.crop(). The suggested approach from the bug report :
l = int(bbox.l * im_width)
t = int(bbox.t * im_height)
r = max(l + 1, int(bbox.r * im_width))
b = max(t + 1, int(bbox.b * im_height))
crop_box = (l, t, r, b)
This ensures width and height are always at least 1 pixel, regardless of how the VLM quantizes bounding boxes.
Key Source Locations#
| Location | Relevance |
|---|---|
docling_core/types/doc/document.py lines 4490–4510 | load_from_doctags() — crop box int() conversion with no size guard |
docling_core/types/doc/common/reference.py lines 165–179 | ImageRef.from_pil() — PNG encoding entry point; no zero-size guard |
docling_core/types/doc/common/reference.py lines 139–163 | _to_img_str_cv2 / _to_img_str_pil — the two encoding backends |
| GitHub issue #2763 | Root cause analysis and reproduction script |
| PR #3414 | Partial fix: max(1, round(...)) guard in get_page_image backend |
| PR #562 (docling-core) | OpenCV encoding introduced; performance context for from_pil |