1
\$\begingroup\$

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.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.