So I was in need of a way to draw a patterned line that passes through several points on a 2D space, something that no Python library seems able to do out of the box (at least, the ones I've been looking at). I stated my problem (with visual illustrations) in this question, but no one answered.
After mulling over the problem for some time, I had an idea, and implemented the idea in my own answer.
The following is a refined version of my answer above:
from __future__ import annotations
import math
from itertools import cycle, pairwise
from typing import NamedTuple
from PIL import Image, ImageDraw
class Point(NamedTuple):
x: float
y: float
def rounded(self) -> tuple[int, int]:
return round(self.x), round(self.y)
def is_close(self, other: Point) -> bool:
return math.isclose(self.x, other.x) and math.isclose(self.y, other.y)
class ParametricLine:
def __init__(self, p1: Point, p2: Point):
self.p1 = p1
self.p2 = p2
@property
def displacement(self) -> Point:
x1, y1 = self.p1
x2, y2 = self.p2
return Point((x2 - x1), (y2 - y1))
@property
def length(self):
dx, dy = self.displacement
return math.sqrt(dx**2 + dy**2)
def move_start_by(self, t: float) -> Point:
leng = self.length
dx, dy = self.displacement
new_x = self.p1.x + (t / leng) * dx
new_y = self.p1.y + (t / leng) * dy
self.p1 = Point(new_x, new_y)
return self.p1
class Pattern(NamedTuple):
length: float
color: tuple[int, int, int] | None
def drawline_patterned(
im: Image.Image, patterns: dict[str, Pattern], points: list[Point], width: int = 10, min_len: float = 0.01
):
drawer = ImageDraw.Draw(im)
segments: list[tuple[Point, Point]] = []
pattern_cycle = cycle(patterns.items())
point_pairs = pairwise(points)
phase, (phase_len, phase_clr) = next(pattern_cycle)
pline: ParametricLine | None = None
p1 = p2 = Point(math.nan, math.nan)
def _do_draw():
if phase_clr is None:
# None here represents a blank line
# E.g., when you want to make dashed line
segments.clear()
return
# segments is a list of tuple of a pair of coords
# but draw.line needs a list of coords
# So we need to do a transform here
drawpoints: list[tuple[int, int]] = []
prev_p2 = segments[0][0]
_p2 = None
for _p1, _p2 in segments:
# Make sure that the endpoint of prev segment
# is the start point of this segment.
# We don't do disjointed/disconnected segments
assert _p1.is_close(prev_p2)
drawpoints.append(_p1.rounded())
prev_p2 = _p2
drawpoints.append(_p2.rounded())
drawer.line(drawpoints, fill=phase_clr, width=width, joint="curve")
segments.clear()
while True:
if pline is None:
try:
p1, p2 = next(point_pairs)
pline = ParametricLine(p1, p2)
except StopIteration:
break
pline_len = pline.length
if pline_len > phase_len:
p3 = pline.move_start_by(phase_len)
segments.append((p1, p3))
_do_draw()
p1 = p3
phase, (phase_len, phase_clr) = next(pattern_cycle)
continue
segments.append((p1, p2))
phase_len -= pline_len
pline = None
if abs(phase_len) < min_len:
_do_draw()
phase, (phase_len, phase_clr) = next(pattern_cycle)
if segments:
_do_draw()
########################################
example_points: list[Point] = [
Point(108.0, 272.0),
Point(150.0, 227.0),
Point(171.0, 218.0),
Point(187.0, 221.0),
Point(192.0, 234.0),
Point(205, 315),
Point(216, 402),
Point(275, 565),
Point(289, 586),
Point(312, 603),
Point(343, 609),
Point(387, 601),
Point(420, 577),
Point(484, 513),
Point(505, 500),
Point(526, 500),
Point(551, 509),
Point(575, 550),
Point(575, 594),
Point(546, 656),
Point(496, 686),
Point(409, 712),
Point(329, 715),
Point(287, 701),
]
def main():
image = Image.new("RGBA", (1000, 1000))
pattern_dict: dict[str, Pattern] = {
"dash": Pattern(40.0, (255, 255, 0)),
"pip1": Pattern(8.0, (0, 0, 0)),
"gapp": Pattern(8.0, (255, 255, 0)),
"pip2": Pattern(8.0, (0, 0, 0)),
}
drawline_patterned(image, pattern_dict, example_points)
image.save("patterned_result.png")
if __name__ == "__main__":
main()
So the general principle is:
- I have a set of coordinates
- I make a parametric line between pairs of coordinates
- I check if the line is shorter than available 'budget'
- If yes, add the line to a list of line segments
- If no, try to figure out where the 'budget' runs out, add the portion of the line up to that point to the list of line segments, and grab a new budget for the next phase
- And draw the collected line segments so far with the desired property for that segment.
In short, this is a path traversal algorithm where the program traversed a long path to split the long path into shorter parts, which will be acted upon individually depending on certain strategy.
I am looking forward to your review of the part of the code above the #####
separator line, whether I can optimize / tidy it up further.