Add dash parameter for line, polygon, and rectangle drawing - #9490
Add dash parameter for line, polygon, and rectangle drawing#9490Krishnachaitanyakc wants to merge 21 commits into
Conversation
Add a `dash` parameter to `ImageDraw.line()`, `ImageDraw.polygon()`, and `ImageDraw.rectangle()` methods that allows drawing dashed outlines. The dash pattern follows the SVG `stroke-dasharray` specification: - Accepts a tuple of ints specifying alternating drawn/blank segment lengths - Odd-length patterns are automatically doubled per the SVG spec - The dash pattern is continuous across connected line segments and polygon edges - Empty dash tuples raise ValueError Closes python-pillow#9127
for more information, see https://pre-commit.ci
- Annotate pixels_used as float to fix float/int assignment mismatch - Rename redefined 'points' variable to 'joint_points' in curve joint code - Cast flat xy to Sequence[float] before slicing to fix type compatibility
for more information, see https://pre-commit.ci
|
Could you link to the SVG specification that you used as a reference? |
|
@radarhere updated the description |
|
Did you use AI to create this PR? |
|
@radarhere I used AI to plan and implement yes, but did manually verify the changes and tested them |
Assert that odd pattern image matches even pattern image
|
@radarhere can you please review this? |
|
This is on my list of things to look at. However, as I'm doing this in my spare time in an underpaid capacity, and trying to also address other issues and PRs, I may not get to it immediately. |
|
I think this could also be implemented in the C drawing code, where it's rather easy to just skip drawing some pixels that would otherwise be drawn, based on a pattern mask? |
Merging this PR will degrade performance by 23.8%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | test_draw_lines[1237x811-RGBA] |
243 µs | 329.6 µs | -26.28% |
| ❌ | test_draw_lines[1237x811-LA] |
242.9 µs | 329.4 µs | -26.27% |
| ❌ | test_draw_lines[1237x811-RGB] |
248.5 µs | 335.7 µs | -25.97% |
| ❌ | test_draw_lines[1237x811-L] |
405.1 µs | 483.4 µs | -16.2% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing Krishnachaitanyakc:add-dashed-line-support (9341c21) with main (a9cbfbe)2
Footnotes
-
335 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
No successful run was found on
main(807d689) during the generation of this report, so a9cbfbe was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
| static inline int | ||
| should_draw_dash(int i, PyObject *dash) { | ||
| if (dash == NULL) { | ||
| return 1; | ||
| } | ||
| int total = 0; | ||
| int tuple_index = -1; | ||
| int tuple_size = PyTuple_GET_SIZE(dash); | ||
| while (total <= i) { | ||
| tuple_index += 1; | ||
| if (tuple_index == tuple_size) { | ||
| tuple_index = 0; | ||
| } | ||
| PyObject *value = PyTuple_GetItem(dash, tuple_index); | ||
| if (!PyLong_Check(value)) { | ||
| return 0; | ||
| } | ||
| int v = PyLong_AsLongLong(value); | ||
| if (v == -1 && PyErr_Occurred()) { | ||
| return 0; | ||
| } | ||
| total += v; | ||
| } | ||
| return tuple_index % 2 == 0; | ||
| } |
There was a problem hiding this comment.
Mmmm, I'd maybe not do Python object conversions for every dashed pixel to be drawn or not drawn, but instead convert the tuple into a C struct before passing to line*?
EDIT: it may also be a good idea to look at separate dashed variants of these functions, since a per-pixel check in the hot path is likely to degrade perf for all drawing operations for a somewhat niche feature.
EDIT 2: In fact, doesn't the dash array basically decompose into an array of on/off states + length, so accessing it to figure out whether to draw is then just [i % state_array_length]...
There was a problem hiding this comment.
In fact, doesn't the dash array basically decompose into an array of on/off states + length, so accessing it to figure out whether to draw is then just
[i % state_array_length]...
Isn't this complicated by the fact that the dash on/off states might have uneven lengths, under the API of this PR?
instead convert the tuple into a C struct before passing to
line*?
You're suggesting allocating a struct that contains the length of the dash array, and the values within? Is that manifestly different from what the Python API is does under the hood?
a per-pixel check in the hot path
It shouldn't be per-pixel anymore, since 1af2a67
There was a problem hiding this comment.
You're suggesting allocating a struct that contains the length of the dash array, and the values within? Is that manifestly different from what the Python API is does under the hood?
Yep, and I think it is. Right now each call to should_draw_dash involves, at a quick look, (not including the trivial operations like field retrievals that would need to be done with the struct too):
- Type/subtype check (
PyTuple_Check) inPyTuple_GET_SIZE - Type/subtype check (
PyTuple_Check) inPyTuple_GetItem - Bounds check in
PyTuple_GetItem - Type/subtype check
PyLong_Check - Type/subtype check (
PyLong_Check) inPyLong_AsLongLong _PyLong_CompactValueor_PyLong_AsByteArrayof the value
So it does sound like that's worth doing upfront, at least to something like
struct DashArray {
size_t n_states;
uint32_t states[];
}
in a single pre-pass. (I'm assuming we might not really need long long (64-bit) values for states?)
Isn't [
[i % state_array_length]] complicated by the fact that the dash on/off states might have uneven lengths, under the API of this PR?
Just "exploding" the on-off states into chars, (at the expense of taking up sum(dash_array) bytes of memory plus a size_t for the length) is not complicated. (Could also pack 8 states down to a byte, so the expense is ceil(sum(dash_array) / 8), but the indexing becomes fiddlier.) That implementation of course means we'd probably need to bound the maximum length of the dash states, maybe to... 16K entries altogether? 🤷
But just converting the tuple to something like that DashArray above should be a lot more efficient, anyway, so might not be worth it to bother with exploding on top of that.
It shouldn't be per-pixel anymore, since 1af2a67
Ah yeah, checking for dashness at all before the loops is a good idea. Whether to draw the pixel or not has to be done for every pixel though, and that involves the above Python value operations right now.
30c316d to
9341c21
Compare
| for (i = 0; i < dy; i++) { | ||
| point8(im, x0, y0, ink); | ||
| y0 += ys; | ||
| if (dash != NULL) { |
There was a problem hiding this comment.
It would probably be fine to always use the Bresenham algorithm when dashing and leave the fast paths (dx == 0, dy == 0) alone. Dashed line drawing will be slower anyhow due to the dash pattern check.
The GEN_LINE() stuff from #9772 would help there, I think - that could maybe be merged first? :)

Summary
Adds a
dashparameter toImageDraw.line(),ImageDraw.polygon(), andImageDraw.rectangle()that enables drawing dashed outlines, implemented in the Python layer.stroke-dasharrayspecification: a tuple of ints specifying alternating drawn/blank segment lengths (e.g.(10, 5)draws 10px, skips 5px, repeats)ValueErrordashis not specifiedReference
This implementation follows the SVG
stroke-dasharrayspecification:https://www.w3.org/TR/SVG2/painting.html#StrokeDashing
Example usage
Closes #9127
Test plan
test_imagedraw.pytests continue to pass (backward compatibility verified)versionadded:: 12.2.0