How to draw a patterned curve with Python

Question:

Let’s say I have a set of coordinates that when plotted looks like this:

Points

I can turn the dots into a smooth-ish line by simply drawing lines from adjacent pair of points:

Lines between 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:

enter image description here

(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.

Asked By: pepoluan

||

Answers:

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":

Generalized Line Equation

None of these forms are practical for several reasons:

  • For the first 2 forms, there’s the edge case of vertical lines which means y increases even though x stays the same.
  • Near-verticals mean I have to advance x reaaally slowly or else y increases too fast
  • Even with the "generalized form", to make the segment lengths uniform, I have to handle the iterations differently for horizontally-oriented lines (iterate on x) 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:

enter image description here

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.

Answered By: pepoluan
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.