[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Draw dashed lines on a PIL image in Python

[Dashed lines on a PIL image in Python]

My post Provide antialiasing with PIL and Python explained a workaround for one of PIL's omissions: antialiasing. This post describes another workaround, this time for drawing dashed lines.

To draw dashed lines, this program uses two code cells, each containing two methods.

  • Cell 1: These methods break a list of points into segments that should be drawn and skipped to produce the dashed lines.
    • follow_path - This method returns a "segment" that follows a path for a specified distance.
    • get_dashed_segments - This method uses follow_path to find the segments that should be drawn to make up the dashes.
  • Cell 2: These methods draw shapes.
    • draw_dashed_polyline - This method calls get_dashed_segments to get the segments that define the path's dashes and then draws them.
    • draw_dashed_polygon - This method modifies the list of points to create a closed polygon and then calls draw_dashed_polyline to draw the polygon.

follow_path

This method is the heart of the dashed line algorithm. It's only 25 lines of code (if you ignore comments and blank lines), but it's pretty confusing.

The method follows a path defined by a list of points for a certain distance. It helps to imagine that you're an ant (or ladybug or whatever your favorite bug is) crawling along a zigzag path while wearing a pedometer. When you reach one of the path's vertices, you change direction and continue along the path. You keep doing that until you've crawled the desired distance.

There are two main conditions that you have to watch out for.

First, the distance that you are supposed to crawl might not reach the next vertex. For example, a dash might only be 5 pixels long but you are crawling along a 100-pixel line segment. In that case, you crawl 5 pixels and then stop.

Second, the distance that you are supposed to crawl might be longer than the distance to the next vertex. For example, you might be crawling 100 pixels along a zigzag path that changes direction every 5 pixels. In that case, you need to change directions and keep crawling.

The follow_path method shown below handles this. It takes as inputs a list of points defining the path and the distance we should crawl. It returns a segment, which is a list of points that define the crawl.

It also updates the path list to remove the part that it crawls. That means the method may add a new point to the beginning of path if the current crawl doesn't end exactly at one of the initial path's vertices. (That's usually the case except when the crawl is longer than the entire path.)

Here's the code.

def follow_path(path, distance): ''' Follow the points in path for the given distance. Return the points visited. Update path to continue following its points. ''' from_point = path.pop(0) segment = [from_point] while len(path) > 0 and distance > 0: # Move from from_point toward path[0]. to_point = path[0] segment_length = math.dist(from_point, to_point) # See how far it is to path[0]. if segment_length >= distance: # It's too far. Only move part of the way there. dx = to_point[0] - from_point[0] dy = to_point[1] - from_point[1] nx = dx / segment_length ny = dy / segment_length stop_point = ( from_point[0] + nx * distance, from_point[1] + ny * distance ) segment.append(stop_point) # If stop_point is not close to path[0], # start the next segment from stop_point. if not math.isclose(segment_length, distance): path.insert(0, stop_point) # Return. return segment else: # We can reach path[0]. Move there. segment.append(to_point) distance -= segment_length from_point = to_point path.pop(0) # If we get here, we either ran out of path or distance. # Return whatever is left. return segment

The method pops the first point out of the path list of points. It adds that point to a new segment list, which will hold the crawl's points.

Next, the code enters a loop that runs as long as there are any points left in the path list and the distance that we still need to crawl is greater than zero.

Within the loop, the code gets the distance from the current point from_point to the next point in the path. Now there are two cases: we can't reach that point or we can reach it.

Can't Reach to_point

If the distance we have yet to crawl is too small to reach to_point, we move in that direction as far as we can. The code calculates dx and dy, the difference in X and Y coordinates between the two points. The result <dx, dy> gives a vector pointing from from_point to to_point. The code then divides dx and dy by the segment's length to get a unit vector <nx, ny> pointing in the same direction but with length 1. (A vector that points in the direction of some other vector but that has length 1 is said to be normalized. That's why the component names begin with n.)

The code then multiplies nx and ny by the distance that we still need to crawl and adds the results to from_point's coordinates. The result, stop_point, is a point along the line segment from from_point toward to_point that is the desired distance from from_point.

This is the point where we should stop so the code adds this point to the segment list.

If stop_point is close to the next point in the original path (to_point), then that point is already in the path list so we don't need to do anything.

If stop_point is not close to to_point, then we need to start crawling from stop_point the next time we start crawling the path, so the code adds stop_point to the front of the path.

The method then returns the new segment.

Can Reach to_point

If we cannot reach the next point to_point with the distance we have left to move, the code adds to_point to the new segment and subtracts the length of the from_pointto_point section from the distance we still need to crawl. It sets from_point equal to to_point so we start the next piece of this crawl there and removes to_point from the path. The loop then repeats so we continue crawling from the new position.

Out of Points

There's one more way to return from the method. If we run out of points in the path list but we still have distance that we should crawl, the loop ends. At that point, the method simply returns whatever segment it has built so far.

get_dashed_segments

The get_dashed_segments method uses follow_path to break the path into segments that it should draw and skip. It takes two parameters. The first is a path list holding the points that we're trying to connect with dashed lines.

The second parameter, dashes, is a list of pixel lengths that we should alternately draw and skip. For example, if dashes is (10, 5), the program should draw 10 pixels, skip 5 pixels, and repeat until it finishes drawing the path.

Note that dashes doesn't need to have 2 entries. For example, if dashes is (10), then the program draws 10, skips 10, and repeats.

For another example, if dashes is (10, 5, 2), the program draws 10, skips 5, draws 2, skips 10, draws 5, skips 2, and then repeats.

Here's the get_dashed_segments method's code.

def get_dashed_segments(path, dashes): '''Follow the points in path. The dashes parameter holds draw/skip values.''' segments = [] dash_index = 0 while len(path) > 0: # Draw a segment. segment = follow_path(path, dashes[dash_index]) segments.append(segment) if len(path) == 0: break dash_index = (dash_index + 1) % len(dashes) # Skip a segment. segment = follow_path(path, dashes[dash_index]) dash_index = (dash_index + 1) % len(dashes) return segments

The code first creates an empty segments list. It uses the variable dash_index to keep track of the index of the next value that it should use in the dashes list.

The code then enters a loop that runs as long as there's more path to crawl.

Within the loop, the method calls follow_path to get a segment that has length equal to the current dash value and it appends that segment to the segments list. If we have finished crawling the path, the method breaks out of its loop.

If we have more path to crawl, the code increments the dash index number dash_index and calls follow_path again. This time we're skipping a section of the path, so the code doesn't add this segment to the segments list.

The code increments dash_index again and the loop repeats until we've finished crawling the path. At that point the loop ends and returns the segments it found.

draw_dashed_polyline

The draw_dashed_polyline method shown in the following code draws a dashed polyline.

def draw_dashed_polyline(dr, points, outline=None, fill=None, thickness=1, joint='miter', dashes=(5, 5)): '''Draw a dashed polyline.''' # If we should fill the shape, do so. if fill is not None: dr.polygon(points, fill=fill, width=thickness) # Copy the points so we don't mess up the original list. path = points.copy() # Draw the outline. for segment in get_dashed_segments(path, dashes): dr.line(segment, fill=outline, width=thickness, joint=joint)

First, if the fill parameter is not None, the code fills the path. PIL's line method doesn't fill areas, so the code must fill the path as a polygon.

Next, the code makes a copy of the path list so we don't mess up the original. (Recall that the follow_path methods modifies its path list so the points parameter will definitely be messed up if we don't work on a copy.)

The method now calls get_dashed_segments to get the segments necessary to draw the dashed path. It loops through those segments and draws them.

That's all there is to it!

This is the only code that calls get_dashed_segments and it only iterates through the results, so you could easily modify get_dashed_segments to yield its results. That might reduce memory use if you're drawing something ginormous, but normally it seems hardly worth the effort. It might also let you draw the dashes one at a time with a pause in between so you could see the curve grow incrementally. I'll leave that as an exercise.)

draw_dashed_polygon

The draw_dashed_polygon method shown in the following code calls draw_dashed_polyline to draw a polygon. Doing that is easy but a little weird.

def draw_dashed_polygon(dr, points, outline=None, fill=None, thickness=1, joint='miter', dashes=(5, 5)): '''Draw a dashed polygon.''' # Copy the points list so we don't mess up the original. points = points.copy() points = points + [points[0]] # Close the polygon. # Add a tiny bit of the next line segment to round the final corner. dx = points[1][0] - points[0][0] dy = points[1][1] - points[0][1] dist = math.dist(points[0], points[1]) dx = dx / dist * 0.000001 dy = dy / dist * 0.000001 points.append((points[0][0] + dx, points[0][1] + dy)) # Draw the polyline points. draw_dashed_polyline(dr, points, outline, fill, thickness, joint, dashes)

First, the method makes a copy of the points list so we don't mess up the original list.

Next, the code closes the polygon by adding a copy of the last point at the beginning of the list. Now if we connect the points, we get a polygon instead of just a sequence of line segments.

The next piece of code is important if you want to use the joint parameter. When you draw a sequence of line segments, the joint parameter determines how they are connected. If you omit this or set it to None, each line segment is basically a rectangle and its end is cut off squarely. If you set joint to curve, the joint between line segments is rounded. (Other drawing systems have other join parameters. I saw a post where someone used miter in a PIL program but I haven't been able to make that work. I don't know if that's because it really doesn't work or if it works in Linux or macOS.)

Anyway, if the sequence of line segments ends at the start point, then there's no joint between the last segment and the first segment so PIL doesn't round it if you set joint to curve.

To fix that, the program adds another tiny line segment pointing in the direction of the path's second point and having length 0.000001. (See the earlier discussion of normalized line segments.) That segment shouldn't appear but it gives PIL a joint that it can curve.

After that weird little trick, the method simply calls draw_dashed_polyline and the rest is history (or perhaps art class).

Conclusion

The rest of the program just draws some dashed shapes several times with different line thicknesses and joint types so you can see how those work. For example, notice how the corners of the shapes in the lower right part of the program are rounded. If you like, you can comment out the code in draw_dashed_polygon that adds the little 0.000001-pixel line segment to see what it looks like if the final corner isn't rounded.

Download the example to experiment and to see additional details.

© 2024 Rocky Mountain Computer Consulting, Inc. All rights reserved.