Let's say I have a set of coordinates that when plotted looks like this:
I can turn the dots into a smooth-ish line by simply drawing lines from adjacent pair of points:
That one's easy.
However, I need to draw a line with a pattern because it represents a railroad track, so it should look like this:
(This is simulated using Paint.Net, hence the non-uniform spacing. I would like the spacing between pairs of black pips to be uniform, of course.)
That is where I'm stumped. How do I paint such a patterned line?
I currently only know how to use pillow
, but if need be I will learn how to use other packages.
Edited to Add: Do note that pillow
is UNABLE to draw a patterned line natively.
I got it!
Okay first a bit of maths theory. There are several ways of depicting a line in geometry.
The first is the "slope-intercept" form: y = mx + c
Then there's the "point-slope" form: y = y1 + m * (x - x1)
And finally there's the "generalized form":
None of these forms are practical for several reasons:
y
increases even though x
stays the same.x
reaaally slowly or else y
increases too fastx
) with vertically-oriented lines (iterate on y
)However, just this morning I got reminded that there's yet another form, the "parametric form":
R = P + tD
Where D
is the "displacement vector", P
is the "starting point", and R
is the "resultant vector". t
is a parameter that can be defined any which way you want, depending on D
's dimension.
By adjusting D
and/or t
's steps, I can get as precise as I want, and I don't have to concern myself with special cases!
With this concept, I can imagine someone walking down the line segment with a marker, and whenever they have traversed a certain distance, replace the marker with another one, and continue.
Based on this principle, here's the (quick-n-dirty) program:
import math
from itertools import pairwise, cycle
from math import sqrt, isclose
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)
# Example data points
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),
]
class ParametricLine:
def __init__(self, p1: Point, p2: Point):
self.p1 = p1
self.x1, self.y1 = p1
self.p2 = p2
self.x2, self.y2 = p2
self._len = -1.0
@property
def length(self):
if self._len < 0.0:
dx, dy = self.displacement
self._len = sqrt(dx ** 2 + dy ** 2)
return self._len
@property
def displacement(self):
return (self.x2 - self.x1), (self.y2 - self.y1)
def replace_start(self, p: Point):
self.p1 = p
self.x1, self.y1 = p
self._len = -1.0
def get_point(self, t: float) -> Point:
dx, dy = self.displacement
xr = self.x1 + (t / self.length) * dx
xy = self.y1 + (t / self.length) * dy
return Point(xr, xy)
image = Image.new("RGBA", (1000, 1000))
idraw = ImageDraw.Draw(image)
def draw(segments: list[tuple[Point, Point]], phase: str):
drawpoints = []
prev_p2 = segments[0][0]
p2 = None
for p1, p2 in segments:
assert isclose(p1.x, prev_p2.x)
assert isclose(p1.y, prev_p2.y)
drawpoints.append(p1.rounded())
prev_p2 = p2
drawpoints.append(p2.rounded())
if phase == "dash" or phase == "gapp":
idraw.line(drawpoints, fill=(255, 255, 0), width=10, joint="curve")
elif phase == "pip1" or phase == "pip2":
idraw.line(drawpoints, fill=(0, 0, 0), width=10, joint="curve")
def main():
limits: dict[str, float] = {
"dash": 40.0,
"pip1": 8.0,
"gapp": 8.0,
"pip2": 8.0,
}
pointpairs = pairwise(points)
climit = cycle(limits.items())
phase, tleft = next(climit)
segments: list[tuple[Point, Point]] = []
pline: ParametricLine | None = None
p1 = p2 = Point(math.nan, math.nan)
while True:
if pline is None:
try:
p1, p2 = next(pointpairs)
except StopIteration:
break
pline = ParametricLine(p1, p2)
if pline.length > tleft:
# The line segment is longer than our leftover budget.
# Find where we should truncate the line and draw the
# segments until the truncation point.
p3 = pline.get_point(tleft)
segments.append((p1, p3))
draw(segments, phase)
segments.clear()
pline.replace_start(p3)
p1 = p3
phase, tleft = next(climit)
else:
# The segment is shorter than our leftover budget.
# Record that and reduce the budget.
segments.append((p1, p2))
tleft -= pline.length
pline = None
if abs(tleft) < 0.01:
# The leftover is too small, let's just assume that
# this is insignificant and go to the next phase.
draw(segments, phase)
segments.clear()
phase, tleft = next(climit)
if segments:
draw(segments, phase)
image.save("results.png")
if __name__ == '__main__':
main()
And here's the result:
A bit rough, but usable for my purposes.
And the beauty of this solution is that by varying what happens in draw()
(and the contents of limits
), my solution can also handle dashed lines quite easily; just make the limits
toggle back and forth between, say, "dash"
and "blank"
, and in draw()
only actually draw a line when phase == "dash"
.
Note: I am 100% certain that the algorithm can be optimized / tidied up further. As of now I'm happy that it works at all. I'll probably skedaddle over to CodeReview SE for suggestions on optimization.
Edit: The final version of the code is live and open for review on CodeReview SE. If you arrived here via a search engine because you're looking for a way to draw a patterned line, please use the version on CodeReview SE instead.