Skip to content

ImageDraw is not translation-invariant for negative coordinates (truncation toward zero instead of floor) #10228

Open

Description

@strike19

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 main at fef1035, source only)

Activity

  1. added a commit that references this issue on Oct 16, 2026
    6c67241
  2. radarhere commented on Oct 18, 2026

    @radarhere
    Member

    #10229 has been created for this. Did you have any thoughts?

    shapes lying entirely left of x = 0 should not set any pixel.

    In the code you've provided, the rightmost points are at -0.2. Your expectation is that this will be rounded to -1, not 0.

    https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#coordinates

    The graphics interface uses the same coordinate system as PIL itself, with (0, 0) in the upper left corner.

    I'm not saying you're incorrect, I'm just leaving a note that isn't as clear cut as it might sound. If we had the ability to draw half-pixels, this would be another matter.

    I'd like to make sure that we agree that

    from PIL import Image, ImageDraw
    im = Image.new("RGB", (1, 1))
    d = ImageDraw.Draw(im)
    d.rectangle((-1, -1, 0, 0), "#f00")
    im.save("out.png")

    will produce an image of 1 red pixel, and that's not a bug.

    I'd also like to ask something now, rather than have it appear later - are you interested in drawing text at all? I don't know if that would be translation-invariant.

    #10242 has raised a similar question, purely about cropping operations.

  3. strike19 commented on Oct 20, 2026

    @strike19
    Author

    Thanks for pointing to #10229, and for the notes.

    Your questions first. Yes, d.rectangle((-1, -1, 0, 0), "#f00") on a 1×1 image should give one red pixel, and
    it still does with floor (−1 and 0 stay −1 and 0, and the rectangle includes its end point). My sentence "shapes
    lying entirely left of x = 0 should not set any pixel" was misleading. What I need is simpler: the same shape,
    drawn at a different position, should give the same pixels at that position.

    Why this matters to me. I draw OSM maps in tiles. A shape, e.g. a road, can start outside a tile and end inside
    it; after drawing, the tile is cropped and everything outside is thrown away. The neighbouring tile contains the same
    road at different coordinates. Inside a tile, drawing works perfectly. But when part of a shape lies outside the tile
    (at negative coordinates), some pixels can change, so the tiles do not match the same map drawn in one piece. That
    surprised me.

    How I tested. I built #10229 from source. I draw a shape, then draw it again moved by 63 pixels (right, down, or
    both) and crop the same window. Both images should be identical. I use 63, not 64, because an odd shift also
    catches rounding to even.

    Different shapes are affected differently. I only draw text at positive coordinates, so I don't need that part, but
    it is affected too. Discussion #10242 looks like the same rounding-to-even question as rounded_rectangle below.

    Places with the same problem (every place where a coordinate becomes an integer):

    • cut towards zero: _draw_arc, _draw_bitmap, _draw_chord, _draw_ellipse, _draw_pieslice, _draw_points,
      _draw_rectangle in _imaging.c; ImagingOutlineLine, ImagingOutlineCurve, ImagingOutlineTransform in
      Draw.c (used by draw.shape()); int() in ImageDraw.text;
    • halves rounded away from zero: the centre of a pieslice outline (lround), the corner fix-up in
      polygon_generic (round), clip_tree_do_clip (lround);
    • halves rounded to even: round() in rounded_rectangle.

    With whole numbers, #10229 still differs in polygon_generic: the point where an edge crosses a pixel row is
    computed in float, and on my Mac (arm64) that small error decides which pixel is drawn.

    A change that fixes all of them, in addition to #10229: compute that crossing exactly from the whole-number end
    points, use floor instead of cutting towards zero, and floor(x + 0.5) instead of lround/round.

    Results (shapes that differ, out of 18,000 random shapes per row, half with whole and half with decimal
    coordinates; the shape row counts two kinds, 36,000; macOS arm64):

    12.3.0 #10229 with the propsed additional change
    line, width 1 1,814 0 0
    regular_polygon 2,176 34 0
    polygon, filled 3,674 184 0
    polygon, outline with width 3,927 251 0
    line, width 2 to 8, rounded joints 2,561 204 0
    rounded_rectangle 517 517 0
    shape (outline with lines or curves) 6,871 6,739 0
    ellipse, pieslice, chord, arc, rectangle, point, bitmap, text only with decimal coordinates (e.g. ellipse 900, point 287, text 66) same 0

    pieslice with an outline also differs with whole numbers (74). rounded_rectangle differs only with an odd shift.

    With the change, every tested shape is translation-invariant, at negative and positive coordinates. Pillow's own test suite passes unchanged.

    Suggestion I have a patch with these changes on top of #10229. I can open it as a separate pull request, or push it to a branch so @Eryx-Z can take the edge-crossing part into #10229. Whichever you prefer.

    Tested on macOS 27 arm64 (clang), Python 3.14.8, against Pillow 12.3.0 and #10229 at 2fb3e0d (based on 262e8e4). Not
    tested on x86_64 or Windows; the exact crossing does not depend on the platform, but how much #10229 alone still
    differs may.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions