ImageDraw is not translation-invariant for negative coordinates (truncation toward zero instead of floor)
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
- #10229 của @Eryx-Z — đã đóng, không merge
Đánh giá
- Độ khó
- 5/5
- Thời gian dự kiến
- Hơn một tuần
- Mức phù hợp với người mới
- 38/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Lĩnh vực
- computer-graphics
Hướng nghiên cứu
Start by reading the coordinate conversions in src/_imaging.c (_draw_polygon and _draw_lines) and the rounding macros and rasterization paths in src/libImaging/Draw.c (ROUND_UP, ROUND_DOWN, polygon_generic, and ImagingDrawWideLine). Then inspect the curve-joint path in Python’s ImageDraw.line and run the reproducer from the issue against each change. Done means shifted-and-cropped drawings match for the reported cases, including curve joints; the proposed fix is unbuilt and untested.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
What did you do?
I am honest and this was a long time of investigations. I have started with creating my own OSM tiles as a prototype project with the support of pillow. At the same time I have created a native swift implementation of the same maps. But while I compared the results, some pixel differences have been identified by my tools. Therefore this is the outcome of the investigation for you review. THANKS!
Here the created example:
Draw the same shape twice: once as given, once shifted right by an integer number of pixels, then cropped the second image back by the same amount. A translation-invariant rasterizer gives identical images.
from PIL import Image, ImageDraw
SHIFT = 10
W = H = 12
def draw(shape, dx):
im = Image.new("L", (W + SHIFT, H), 0)
d = ImageDraw.Draw(im)
kind, pts, *rest = shape
pts = [(x + dx, y) for x, y in pts]
if kind == "polygon":
d.polygon(pts, fill=255, outline=255)
else:
d.line(pts, fill=255, width=rest[0])
return im.crop((dx, 0, dx + W, H))
SHAPES = {
# a sliver lying entirely left of x = 0, i.e. outside the image
"polygon x -0.9 .. -0.2": ("polygon", [(-0.9, 2), (-0.2, 2), (-0.2, 8), (-0.9, 8)]),
# a box whose left edge is at x = -0.3
"polygon x -0.3 .. 5.5": ("polygon", [(-0.3, 2), (5.5, 2), (5.5, 8), (-0.3, 8)]),
# a vertical line, 1 px wide, centred at x = -0.6
"line x = -0.6, width 1": ("line", [(-0.6, 2), (-0.6, 9)], 1),
# a vertical line, 3 px wide, centred at x = -1.6
"line x = -1.6, width 3": ("line", [(-1.6, 2), (-1.6, 9)], 3),
}
for name, shape in SHAPES.items():
a, b = draw(shape, 0), draw(shape, SHIFT)
pa, pb = a.tobytes(), b.tobytes()
diff = sum(1 for u, v in zip(pa, pb) if u != v)
cols = sorted({i % W for i, (u, v) in enumerate(zip(pa, pb)) if u != v})
print(f"{name:26s} offset 0: {sum(1 for v in pa if v)} px set, "
f"offset {SHIFT}: {sum(1 for v in pb if v)} px set, differ: {diff} (columns {cols})")
What did you expect to happen?
Identical images for every shape. In particular, shapes lying entirely left of x = 0 should not set any pixel.
What actually happened?
polygon x -0.9 .. -0.2 offset 0: 7 px set, offset 10: 0 px set, differ: 7 (columns [0])
polygon x -0.3 .. 5.5 offset 0: 42 px set, offset 10: 42 px set, differ: 0 (columns [])
line x = -0.6, width 1 offset 0: 8 px set, offset 10: 0 px set, differ: 8 (columns [0])
line x = -1.6, width 3 offset 0: 8 px set, offset 10: 0 px set, differ: 8 (columns [0])
Shapes entirely outside the image on the left (or top) leak into column 0 (or row 0). The 3 px wide line covers
x = -3.1 .. -0.1 and still sets column 0.
The same happens with integer coordinates, where the casts in _imaging.c change nothing, and then not only
in column 0:
from PIL import Image, ImageDraw
SHIFT = 64
W = H = 16
def draw(shape, dx):
im = Image.new("L", (W + SHIFT, H), 0)
d = ImageDraw.Draw(im)
kind, pts, kw = shape
pts = [(x + dx, y) for x, y in pts]
if kind == "polygon":
d.polygon(pts, fill=255)
else:
d.line(pts, fill=255, **kw)
return im.crop((dx, 0, dx + W, H))
# integer coordinates only -- the int() casts in _imaging.c change nothing here
SHAPES = {
"polygon, slanted edges": ("polygon", [(-4, 8), (-9, 13), (9, 2)], {}),
"line, width 7": ("line", [(8, 7), (-3, 13)], {"width": 7}),
"line, width 7, joint=curve": ("line", [(-4, 9), (0, 3), (-12, 4)],
{"width": 7, "joint": "curve"}),
}
for name, shape in SHAPES.items():
a, b = draw(shape, 0).tobytes(), draw(shape, SHIFT).tobytes()
px = [(i % W, i // W) for i, (u, v) in enumerate(zip(a, b)) if u != v]
print(f"{name:28s} differ: {len(px)} at (x, y) {px}")
polygon, slanted edges differ: 1 at (x, y) [(2, 5)]
line, width 7 differ: 1 at (x, y) [(1, 7)]
line, width 7, joint=curve differ: 3 at (x, y) [(1, 0), (2, 1), (2, 4)]
In a random test (shapes with integer vertices between -45 and 30, 6,000 per kind, 40 × 40 crop, drawn at offset 0 and 128) the differing pixels were: polygons 191, wide lines 249, wide lines with joint="curve" 2,758, lines of width 1 none. They reach more than 11 px into the image, further for curve joints. No difference occurred when all geometry, including half the line width, lay at non-negative coordinates.
Cause
src/_imaging.c converts coordinates with a plain C cast, which truncates toward zero: _draw_polygon
(ixy[i + i] = (int)xy[i + i];) and _draw_lines ((int)p[0] ... (int)p[3]). For x >= 0 this equals
floor(x); for x < 0 it rounds up, so -0.9 becomes 0 instead of -1. ROUND_UP and ROUND_DOWN in
src/libImaging/Draw.c are likewise symmetric about zero (-floor(fabs(f) + 0.5F) for negative f).
Passing math.floor-ed coordinates to the same calls makes the four cases of the first example identical, but not those of the second: they already have integer coordinates. There, the symmetric rounding in ROUND_UP/ROUND_DOWN is the cause -- in polygon_generic (edge intersections per scanline) and in ImagingDrawWideLine (edge offsets of a wide line). The joint="curve" path in ImageDraw.line (Python) adds pie slices around each joint and shows the largest effect; we have not traced it further.
Why it matters
Anyone who renders a large image in tiles (map tiles, mosaics) gets seams: along the left and top edge of every tile, geometry coming in from outside is drawn differently than in one large image. Our application renders OpenStreetMap land-use classes in 1024 × 1024 tiles at zoom 15. Our renderer already floors all coordinates before calling ImageDraw, so what remains comes from the rounding in ROUND_UP/ROUND_DOWN and from curve joints. It affected 1,606 pixels in 840 of 2,460 tiles when each tile was drawn as is and again with a 16 px margin on the left and top (i.e. shifted, then cropped). They disagree on 443 building pixels. Judged by whether the pixel centre lies inside an OSM building, the shifted rendering is right at 407 of them and the plain tiled rendering at 36. A margin only helps for geometry that ends within it; ours reaches much further out.
Possible fix
Both parts are needed: use floor instead of the C cast when converting coordinates in _draw_polygon and _draw_lines (first example), and make ROUND_UP/ROUND_DOWN use floor(f + 0.5) / ceil(f - 0.5) for all f (second example). The curve joints in ImageDraw.line would need the same check. This changes output only where coordinates are negative, i.e. for geometry extending beyond the image on the left or top. We have not built Pillow with these changes, so whether they remove every difference in the second example is untested.
What are your OS, Python and Pillow versions?
- OS: macOS 27.0.1 (arm64)
- Python: 3.12.13
- Pillow: 12.3.0 (also checked against
mainatfef1035, source only)
- Ngôn ngữ chính
- Python
- Star
- 13.9k
- Fork
- 2.5k
- Merge trung bình
- 1 ngày 2 giờ
- Pull request đã merge (30 ngày)
- 150
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của python-pillow/Pillow
-
Warn that pre-applying arabic_reshaper + python-bidi corrupts text on a Raqm buildCó thể đã có người làm @Syamjith-NK đã nhận 53 ngày trước. Đang mởDocumentation Font Rendering PNG Third Party Framework
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
python-pillow/Pillow#9907 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Image.putpixel incorrectly labels supported arguments in type annotationsCó thể đã có người làm @radarhere đã nhận 181 ngày trước. Đang mởPNG
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
python-pillow/Pillow#9584 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Future of JPEG2000 supportĐang mởJPEG
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 20/100
python-pillow/Pillow#10241 · 4 bình luận · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
`frombytes()` and in-place `ImageCms` transforms write into read-only image memoryCó thể đã có người làm @tongwentao đã nhận 2 ngày trước. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 50/100
python-pillow/Pillow#10235 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 35/100
python-pillow/Pillow#10191 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của python-pillow/Pillow
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
volcengine/OpenViking#6445 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
BUG:Edit Album strips spaces from the password so albums with a leading/trailing space in the password can't be editedCó thể đã có người làm @ManyaJain111 đã nhận hôm nay. Đang mởbug enhancement possible-duplicate
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
AOSSIE-Org/PictoPy#1637 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
deepspeedai/DeepSpeed#8884 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Flaky: test_advisor_live_calibrates_an_unmeasured_circuit ranks engines by a 1.8ms sleep marginĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
flagos-ai/FlagQuantum#718 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug CI-failure high priority
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 86/100
vllm-project/afd-plugin#483 ·
Maintainer thường phản hồi trong vòng 1 ngày