Skip to content

Instantly share code, notes, and snippets.

@mikeemoo
Created January 11, 2025 20:04
Show Gist options
  • Save mikeemoo/372033379e552b3c90230bcda1ef7196 to your computer and use it in GitHub Desktop.
Save mikeemoo/372033379e552b3c90230bcda1ef7196 to your computer and use it in GitHub Desktop.
line hatching -> svg & gcode
import cv2
import numpy as np
import cairo
import time
import serial # Import serial first
import serial.tools.list_ports # Then import list_ports
from tqdm import tqdm
import matplotlib.pyplot as plt # Add this import at the top
pens = {
'fineliner': {
'width_mm': 0.8, # Width in millimeters
'color': [0, 0, 0, 0.5]
}
}
def optimize_path_order(paths):
"""Optimize path order using a faster greedy approach"""
if not paths:
return []
# Convert paths to numpy arrays for faster operations
paths = [np.array(path) for path in paths]
n_paths = len(paths)
# Pre-calculate all start and end points
starts = np.array([path[0] for path in paths])
ends = np.array([path[-1] for path in paths])
# Initialize result
optimized = []
used = np.zeros(n_paths, dtype=bool)
current_pos = np.array([0, 0])
# Pre-calculate all distances from origin
origin_to_start = np.linalg.norm(starts - current_pos, axis=1)
origin_to_end = np.linalg.norm(ends - current_pos, axis=1)
# Get first path (closest to origin)
first_idx = np.argmin(np.minimum(origin_to_start, origin_to_end))
reverse = origin_to_end[first_idx] < origin_to_start[first_idx]
optimized.append(paths[first_idx][::-1] if reverse else paths[first_idx])
used[first_idx] = True
current_pos = paths[first_idx][0] if reverse else paths[first_idx][-1]
# Process remaining paths
for _ in range(n_paths - 1):
# Calculate distances to current position
distances_to_start = np.linalg.norm(starts - current_pos, axis=1)
distances_to_end = np.linalg.norm(ends - current_pos, axis=1)
# Mask out used paths
distances_to_start[used] = np.inf
distances_to_end[used] = np.inf
# Find closest path
start_min = np.min(distances_to_start)
end_min = np.min(distances_to_end)
if start_min <= end_min:
next_idx = np.argmin(distances_to_start)
reverse = False
else:
next_idx = np.argmin(distances_to_end)
reverse = True
# Add path to result
path = paths[next_idx]
optimized.append(path[::-1] if reverse else path)
used[next_idx] = True
current_pos = path[0] if reverse else path[-1]
return optimized
def send_to_plotter(commands, port, baud_rate):
"""Send GCode commands directly to plotter with buffer management"""
try:
# Open serial connection
print(f"Attempting to connect to {port} at {baud_rate} baud...")
plotter = serial.Serial(port, baud_rate, timeout=1)
time.sleep(2) # Wait for connection to establish
print("Connected to plotter")
# Track commands in flight
commands_in_flight = []
MAX_BUFFER_LENGTH = 4 # Maximum number of commands to buffer
def read_response():
"""Read response with retries, handling welcome message"""
while True:
try:
response = plotter.readline().decode('ascii').strip()
if not response:
continue
if "DrawCore" in response:
print(f"Plotter: {response}")
continue
print(f"Received response: {response}") # Debug: Print all responses
return response
except UnicodeDecodeError:
print("Warning: UnicodeDecodeError while reading response")
continue
def command(cmd, wait_for_ok=False):
"""Send a command and optionally wait for acknowledgment"""
if not cmd or cmd.startswith(';'):
return True
try:
plotter.write(f"{cmd}\n".encode('ascii'))
commands_in_flight.append(cmd)
if wait_for_ok or len(commands_in_flight) >= MAX_BUFFER_LENGTH:
print(f"Waiting for response (buffer: {len(commands_in_flight)} commands)") # Debug
while commands_in_flight:
response = read_response()
if response.startswith("ok"):
commands_in_flight.pop(0)
else:
print(f"\nUnexpected response: {response}")
print(f" Command was: {commands_in_flight[0]}")
return False
return True
except (serial.SerialException, IOError, RuntimeError, OSError) as err:
if not cmd.strip().lower() == "rb":
print(f"\nFailed after command: {cmd}")
print(f"Error: {err}")
return False
# Count actual drawing commands for progress bar
draw_commands = [cmd for cmd in commands if not cmd.startswith(';')]
# Send main commands with progress bar
with tqdm(total=len(draw_commands), desc="Plotting") as pbar:
for cmd in commands:
if cmd.startswith(';'):
if "Color group" in cmd:
pbar.write(f"\n{cmd}") # Show color changes above progress bar
continue
if cmd.strip().startswith('M0'):
# Wait for buffer to clear
while commands_in_flight:
if read_response().startswith("ok"):
commands_in_flight.pop(0)
pbar.write("\nChange pen and press Enter to continue...")
input()
continue
if not command(cmd):
raise Exception(f"Command failed")
pbar.update(1)
# Wait for remaining commands
while commands_in_flight:
if read_response().startswith("ok"):
commands_in_flight.pop(0)
plotter.close()
print("\nPlotting complete!")
except Exception as e:
print(f"\nError: {e}")
print("Available ports:")
for port in list(serial.tools.list_ports.comports()):
print(f" {port.device}")
def prepare_image_for_a3(image_color, px_to_mm=0.3, padding_mm=10):
"""
Resize and crop images to fit A3 paper (297x420mm) with padding
"""
# A3 dimensions in mm
a3_width_mm = 297
a3_height_mm = 420
# Calculate available space in mm (subtracting padding from both sides)
available_width_mm = a3_width_mm - (2 * padding_mm)
available_height_mm = a3_height_mm - (2 * padding_mm)
# Convert to pixels
target_width_px = int(available_width_mm / px_to_mm)
target_height_px = int(available_height_mm / px_to_mm)
# Calculate scale to fit minimum side
current_height, current_width = image_color.shape[:2]
width_scale = target_width_px / current_width
height_scale = target_height_px / current_height
scale = max(width_scale, height_scale) # Use max to fit to minimum side
# Calculate new dimensions
new_width = int(current_width * scale)
new_height = int(current_height * scale)
# Resize both images
image_color_resized = cv2.resize(image_color, (new_width, new_height))
# Calculate crop amounts
crop_x = max(0, (new_width - target_width_px) // 2)
crop_y = max(0, (new_height - target_height_px) // 2)
# Crop from center
image_color_cropped = image_color_resized[
crop_y:crop_y + target_height_px,
crop_x:crop_x + target_width_px
]
print(f"Original size: {current_width}x{current_height}px")
print(f"Resized to: {new_width}x{new_height}px")
print(f"Cropped to: {target_width_px}x{target_height_px}px")
print(f"Will print as: {target_width_px * px_to_mm:.1f}x{target_height_px * px_to_mm:.1f}mm")
return image_color_cropped, available_height_mm
def create_lightness_mask(image_color, threshold=200):
"""
Convert color image to grayscale and create a mask of dark pixels
"""
# Convert BGR to grayscale
gray = cv2.cvtColor(image_color, cv2.COLOR_BGR2GRAY)
# Create mask where pixels are darker than threshold
mask = gray < threshold
return mask
def apply_perpendicular_offset(start_point, end_point, angle_degrees, start_max_offset=1, end_max_offset=1.5):
"""
Shift start and end points perpendicular to line direction,
with configurable max offsets
"""
# Calculate perpendicular direction
angle_rad = np.radians(angle_degrees)
# Perpendicular vector (-sin, cos) to (cos, sin)
dx = -np.sin(angle_rad)
dy = np.cos(angle_rad)
# Generate random offsets from 0 to max
start_offset = np.random.uniform(0, start_max_offset)
end_offset = np.random.uniform(0, end_max_offset)
# Apply offsets
shifted_start = start_point + np.array([dx * start_offset, dy * start_offset])
shifted_end = end_point + np.array([dx * end_offset, dy * end_offset])
return shifted_start.astype(np.int32), shifted_end.astype(np.int32)
def calculate_rotated_bounds(width, height, angle_degrees):
"""
Calculate the starting and ending coordinates needed to cover the entire image
with lines at the given angle
"""
angle_rad = np.radians(angle_degrees)
# For lines at angle θ:
# - To cover top edge: start at x = -h/tan(θ)
# - To cover bottom edge: end at x = w + h/tan(θ)
# - For steep angles, also consider width projection
if angle_degrees == 90:
# Special case: vertical lines
return 0, width, 0, height
if angle_degrees == 0:
# Special case: horizontal lines
return 0, width, 0, height
# Calculate how far back we need to start
tan_angle = np.tan(angle_rad)
# Calculate x-coordinates where lines need to start/end
# to cover all corners of the image
if angle_degrees < 90:
# For angles 0° to 90°
x_start = min(0, -height / tan_angle)
x_end = max(width, width + height / tan_angle)
else:
# For angles > 90° (if needed)
x_start = min(0, width - height / tan_angle)
x_end = max(width, -height / tan_angle)
# Y bounds are simple - we always start at bottom and go to top
y_start = 0
y_end = height
return x_start, x_end, y_start, y_end
def create_diagonal_paths(mask, spacing=5, angle_degrees=45):
"""
Create diagonal paths across the masked area using the rotated bounding box approach.
Returns simple line segments at fixed spacing.
"""
height, width = mask.shape
print(f"\nStarting path creation for {angle_degrees}° lines...")
# Calculate rotated bounding box
angle_rad = np.radians(angle_degrees)
cos_a = abs(np.cos(angle_rad))
sin_a = abs(np.sin(angle_rad))
# Calculate dimensions of bounding box
bound_w = width * cos_a + height * sin_a
bound_h = width * sin_a + height * cos_a
# Create rotated bounding box corners (centered at origin)
bound_corners = np.array([
[-bound_w/2, -bound_h/2], # Top-left
[bound_w/2, -bound_h/2], # Top-right
[bound_w/2, bound_h/2], # Bottom-right
[-bound_w/2, bound_h/2] # Bottom-left
])
# Rotation matrices
rotation_matrix = np.array([
[np.cos(angle_rad), -np.sin(angle_rad)],
[np.sin(angle_rad), np.cos(angle_rad)]
])
rotated_corners = bound_corners @ rotation_matrix
# Translate corners to image center
image_center = np.array([width/2, height/2])
rotated_corners = rotated_corners + image_center
# Get vectors for line generation
edge_vector = rotated_corners[1] - rotated_corners[0] # Top edge vector
edge_length = np.sqrt(np.sum(edge_vector**2))
edge_unit = edge_vector / edge_length
# Perpendicular vector going DOWN
perp_vector = np.array([-edge_unit[1], edge_unit[0]])
# Generate starting points along top edge with fixed spacing
start_points = []
current_dist = 0
while current_dist < edge_length:
start_point = rotated_corners[0] + edge_unit * current_dist
start_points.append(start_point)
current_dist += spacing
print(f"Generated {len(start_points)} starting points")
# Generate line segments
line_segments = []
for start_point in start_points:
# Create end point by moving down perpendicular vector
line_length = max(width, height) * 2
end_point = start_point + perp_vector * line_length
# Sample points along the line
num_samples = int(line_length * 2)
points = np.linspace(start_point, end_point, num_samples)
current_start = None
last_valid_point = None
for point in points:
x, y = int(point[0]), int(point[1])
if 0 <= x < width and 0 <= y < height:
if mask[y, x]:
if current_start is None:
current_start = np.array([x, y])
last_valid_point = np.array([x, y])
elif current_start is not None:
# Line segment ends
line_segments.append((current_start, last_valid_point))
current_start = None
elif current_start is not None:
# Line exits image bounds
line_segments.append((current_start, last_valid_point))
current_start = None
print(f"Generated {len(line_segments)} lines at {angle_degrees}°")
return line_segments
def evaluate_cubic_bezier(p0, p1, p2, p3, num_points=50):
"""
Evaluate a cubic bezier curve and return points along it.
Args:
p0: Start point
p1, p2: Control points
p3: End point
num_points: Number of points to generate along curve
Returns:
Array of points along the bezier curve
"""
points = []
for t in np.linspace(0, 1, num_points):
# Cubic Bezier formula:
# B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
t2 = t * t
t3 = t2 * t
mt = 1 - t
mt2 = mt * mt
mt3 = mt2 * mt
x = mt3 * p0[0] + 3 * mt2 * t * p1[0] + 3 * mt * t2 * p2[0] + t3 * p3[0]
y = mt3 * p0[1] + 3 * mt2 * t * p1[1] + 3 * mt * t2 * p2[1] + t3 * p3[1]
points.append(np.array([x, y]))
return np.array(points)
def convert_to_bezier_curves(paths, max_control_offset=4.0, end_bias=0.7, points_per_curve=20):
"""
Convert line segments into bezier curves and return points along the curves.
Args:
paths: List of (start, end) line segments
max_control_offset: Maximum perpendicular offset for control points
end_bias: How much to bias the curve towards the end (0-1)
points_per_curve: Number of points to generate per curve
Returns:
List of point arrays, each representing a bezier curve
"""
bezier_curves = []
for start, end in paths:
# Convert to float arrays
start = start.astype(float)
end = end.astype(float)
# Calculate line direction and length
direction = end - start
length = np.sqrt(np.sum(direction**2))
if length < 1e-6:
continue
# Normalize direction vector
direction = direction / length
# Calculate perpendicular vector
perp = np.array([-direction[1], direction[0]])
# Generate random offsets for control points
# First control point has minimal offset
offset1 = np.random.uniform(-max_control_offset * 0.2, max_control_offset * 0.2)
# Second control point has full offset biased by end_bias
offset2 = np.random.uniform(-max_control_offset, max_control_offset) * (1 + end_bias)
# Calculate control points positions
t1 = 0.33
t2 = 0.67 + (0.33 * end_bias) # Move second control point closer to end
# Calculate control points
control1 = start + direction * (length * t1) + perp * offset1
control2 = start + direction * (length * t2) + perp * offset2
# Generate points along the bezier curve
curve_points = evaluate_cubic_bezier(
start, control1, control2, end,
num_points=points_per_curve
)
# Convert to integer coordinates
bezier_curves.append(curve_points.astype(np.int32))
return bezier_curves
def trim_line_ends(paths, min_line_length=100, max_trim=10):
"""
Randomly trim the ends of lines that are longer than min_line_length.
Args:
paths: List of (start, end) line segments
min_line_length: Only trim lines longer than this
max_trim: Maximum number of pixels to trim from each end
Returns:
List of trimmed line segments
"""
trimmed_paths = []
for start, end in paths:
# Convert to float arrays for precise calculations
start = start.astype(float)
end = end.astype(float)
# Calculate line length
direction = end - start
length = np.sqrt(np.sum(direction**2))
if length < min_line_length:
# Line too short, keep as is
trimmed_paths.append((start.astype(np.int32), end.astype(np.int32)))
continue
# Normalize direction vector
direction = direction / length
# Generate random trim amounts for each end
start_trim = np.random.uniform(0, max_trim)
end_trim = np.random.uniform(0, max_trim)
# Make sure we don't trim more than the line length
total_trim = start_trim + end_trim
if total_trim > length * 0.8: # Don't trim more than 80% of the line
scale = (length * 0.8) / total_trim
start_trim *= scale
end_trim *= scale
# Calculate new endpoints
new_start = start + direction * start_trim
new_end = end - direction * end_trim
# Add trimmed line
trimmed_paths.append((new_start.astype(np.int32), new_end.astype(np.int32)))
return trimmed_paths
def render_paths_with_cairo(path_groups, width, height, output_filename="output.png", scale_factor=0.3):
"""
Render curves from multiple path groups using Cairo
Args:
path_groups: List of tuples (pen_id, paths), where paths is a list of point arrays
width, height: Image dimensions
output_filename: Output file path
"""
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
ctx = cairo.Context(surface)
ctx.set_antialias(cairo.ANTIALIAS_BEST)
ctx.set_source_rgb(1, 1, 1)
ctx.paint()
# Draw each group
for pen_id, curves in path_groups:
# Get pen settings from pens dictionary
pen = pens[pen_id]
# Convert mm to pixels
width_px = pen['width_mm'] / scale_factor
ctx.set_line_width(width_px)
ctx.set_source_rgba(*pen['color'])
# Draw each curve in the group
for curve_points in curves:
if len(curve_points) < 2:
continue
ctx.move_to(curve_points[0][0], curve_points[0][1])
for point in curve_points[1:]:
ctx.line_to(point[0], point[1])
ctx.stroke()
surface.write_to_png(output_filename)
print(f"Saved to {output_filename}")
def apply_perpendicular_jitter(paths, max_jitter=2.0):
"""
Apply small random offsets perpendicular to each line segment.
Args:
paths: List of (start, end) line segments
max_jitter: Maximum perpendicular offset in pixels
Returns:
List of jittered line segments
"""
jittered_paths = []
for start, end in paths:
# Convert to float arrays for precise calculations
start = start.astype(float)
end = end.astype(float)
# Calculate line direction vector
direction = end - start
length = np.sqrt(np.sum(direction**2))
if length < 1e-6: # Skip if points are too close
continue
# Normalize direction vector
direction = direction / length
# Calculate perpendicular vector (-dy, dx)
perp = np.array([-direction[1], direction[0]])
# Generate random offset
offset = np.random.uniform(-max_jitter, max_jitter)
# Apply offset to both points
offset_vector = perp * offset
new_start = start + offset_vector
new_end = end + offset_vector
# Convert back to integer coordinates
jittered_paths.append((new_start.astype(np.int32), new_end.astype(np.int32)))
return jittered_paths
def break_paths_into_segments(paths, min_length=150, max_length=300, min_gap=15, max_gap=30):
"""
Break long paths into shorter segments with gaps between them.
Args:
paths: List of (start, end) line segments
min_length: Minimum length of each segment
max_length: Maximum length of each segment
min_gap: Minimum gap between segments
max_gap: Maximum gap between segments
Returns:
List of shorter line segments
"""
segmented_paths = []
for start, end in paths:
# Convert to float arrays for precise calculations
start = start.astype(float)
end = end.astype(float)
# Calculate total line length
direction = end - start
total_length = np.sqrt(np.sum(direction**2))
if total_length < min_length:
# Line is already shorter than minimum length, keep as is
segmented_paths.append((start.astype(np.int32), end.astype(np.int32)))
continue
# Normalize direction vector
direction = direction / total_length
# Start from the beginning of the line
current_pos = 0
current_point = start
while current_pos < total_length:
# Determine length of this segment
segment_length = np.random.randint(min_length, max_length + 1)
segment_length = min(segment_length, total_length - current_pos)
# Calculate segment end point
segment_end = current_point + direction * segment_length
# Add segment to list
segmented_paths.append((
current_point.astype(np.int32),
segment_end.astype(np.int32)
))
# Move position past this segment plus a gap
gap = np.random.randint(min_gap, max_gap + 1)
current_pos += segment_length + gap
current_point = current_point + direction * (segment_length + gap)
# Stop if we've gone past the end
if current_pos >= total_length:
break
return segmented_paths
def generate_gcode(path_groups, penUp, penDown, scale_factor, available_height_mm):
"""
Generate GCode for all path groups.
Coordinates are converted to match plotter's coordinate system where:
- (0,0) is at bottom left
- X increases to the right
- Y increases upward
"""
commands = []
# Track bounds
min_x = float('inf')
max_x = float('-inf')
min_y = float('inf')
max_y = float('-inf')
# Process each group
for group_idx, (pen_id, paths) in enumerate(path_groups):
# Add color change comment and pause
if group_idx > 0:
commands.append(f"; Color group {group_idx + 1}: Change to pen {pen_id}")
commands.append("M0") # Pause for pen change
else:
commands.append(f"; Color group {group_idx + 1}: pen {pen_id}")
# Optimize path order
optimized_paths = optimize_path_order(paths)
# a3 paper
img_height = available_height_mm
# Process each path
for path in optimized_paths:
# Convert path points from pixels to mm
# Convert from top-left origin to bottom-left origin
points_mm = []
for point in path:
x_mm = point[0] * scale_factor # X stays the same
y_mm = img_height - (point[1] * scale_factor) # Y is flipped from bottom
points_mm.append((x_mm, y_mm))
# Track bounds
min_x = min(min_x, x_mm)
max_x = max(max_x, x_mm)
min_y = min(min_y, y_mm)
max_y = max(max_y, y_mm)
# Move to start with pen up
x, y = points_mm[0]
commands.extend([
f"G0 Z{penUp}", # Lift pen
f"G0 X{x:.3f} Y{y:.3f}" # Move to start
])
# Lower pen
commands.append(f"G1 Z{penDown}")
# Draw the path
for x, y in points_mm[1:]:
commands.append(f"G1 X{x:.3f} Y{y:.3f}")
# Finish with pen up and return to origin
commands.extend([
f"G0 Z{penUp}",
"G0 X0 Y0"
])
# Report bounds
print(f"\nGCode bounds (mm):")
print(f"X: {min_x:.1f} to {max_x:.1f} (width: {max_x - min_x:.1f})")
print(f"Y: {min_y:.1f} to {max_y:.1f} (height: {max_y - min_y:.1f})")
return commands
def smooth_paths(paths, tolerance=0.5):
"""
Smooth paths by removing points that don't significantly change the path direction.
Uses Ramer-Douglas-Peucker algorithm.
Args:
paths: List of point arrays
tolerance: Maximum distance a point can deviate from simplified line
Returns:
List of simplified point arrays
"""
def rdp(points, epsilon):
"""Ramer-Douglas-Peucker algorithm"""
if len(points) <= 2:
return points
# Find point with max distance from line between start and end
line_start = points[0]
line_end = points[-1]
# Calculate distances from points to line
max_dist = 0
max_idx = 0
for i in range(1, len(points) - 1):
dist = np.abs(np.cross(line_end - line_start, points[i] - line_start)) / np.linalg.norm(line_end - line_start)
if dist > max_dist:
max_dist = dist
max_idx = i
# If max distance is greater than epsilon, recursively simplify
if max_dist > epsilon:
left = rdp(points[:max_idx + 1], epsilon)
right = rdp(points[max_idx:], epsilon)
return np.vstack((left[:-1], right))
else:
return np.vstack((points[0], points[-1]))
smoothed = []
for path in paths:
if len(path) > 2:
smoothed.append(rdp(path, tolerance))
else:
smoothed.append(path)
return smoothed
def export_to_svg(path_groups, width, height, filename="output.svg"):
"""
Export path groups to SVG file.
Args:
path_groups: List of (pen_id, paths) tuples
width, height: Image dimensions in pixels
filename: Output SVG filename
"""
# SVG header with white background
svg = [
f'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg width="{width}px" height="{height}px" viewBox="0 0 {width} {height}"',
' xmlns="http://www.w3.org/2000/svg"',
' style="background-color: white;">',
]
# Draw each group
for pen_id, paths in path_groups:
# Get pen color from pens dictionary
if pen_id in pens:
color = pens[pen_id]['color']
# Convert color from 0-1 to hex
hex_color = '#{:02x}{:02x}{:02x}'.format(
int(color[0] * 255),
int(color[1] * 255),
int(color[2] * 255)
)
opacity = color[3]
else:
hex_color = '#000000'
opacity = 0.5
# Start group for this pen
svg.append(f' <g id="pen_{pen_id}" opacity="{opacity}">')
# Create paths in this group
for path in paths:
if len(path) < 2:
continue
# Move to first point
path_data = f'M {path[0][0]:.3f} {path[0][1]:.3f}'
# Line to remaining points
for point in path[1:]:
path_data += f' L {point[0]:.3f} {point[1]:.3f}'
# Add path element with style
svg.append(
f' <path d="{path_data}" fill="none" stroke="{hex_color}" '
f'stroke-width="{pens[pen_id]["width_mm"]}"/>'
)
# Close group
svg.append(' </g>')
# Close SVG
svg.append('</svg>')
# Write to file
with open(filename, 'w') as f:
f.write('\n'.join(svg))
print(f"Saved SVG to {filename}")
def main():
scale_factor = 0.3
penUp = 0
penDown = 5
print("\nInitializing test sequence...")
setup_commands = [
"G21", # Set units to mm
"G90", # Absolute positioning
"G0 F12000", # Set rapid move speed
"G1 F6000", # Set linear move speed
f"G92 X0 Y0 Z0" # Set current position as origin
]
# we're plotting on A3 with a 10px border, so lets move by 10px and reset the origin
position_commands = [
'G0 X10 Y10', # Move to start position
f"G92 X0 Y0 Z0" # Set current position as origin
]
# Load image
image_color = cv2.imread("ComfyUI_temp_qvgts_00005_.png", cv2.IMREAD_COLOR)
# Resize image to fit A3 with padding
image_color, available_height_mm = prepare_image_for_a3(image_color, scale_factor)
height, width = image_color.shape[:2]
# Create all masks
dark_mask = create_lightness_mask(image_color, threshold=155)
darker_mask = create_lightness_mask(image_color, threshold=100)
darkest_mask = create_lightness_mask(image_color, threshold=65)
dark_detail_mask = create_lightness_mask(image_color, threshold=30)
darkest_detail_mask = create_lightness_mask(image_color, threshold=15)
# Create paths using original masks
paths1 = create_diagonal_paths(dark_mask, spacing=10, angle_degrees=45)
paths2 = create_diagonal_paths(darker_mask, spacing=7, angle_degrees=75)
paths3 = create_diagonal_paths(darkest_mask, spacing=5, angle_degrees=15)
paths4 = create_diagonal_paths(dark_detail_mask, spacing=4, angle_degrees=60)
paths5 = create_diagonal_paths(darkest_detail_mask, spacing=3, angle_degrees=30)
# Process all paths
hatching_paths = paths1 + paths2 + paths3 + paths4 + paths5
hatching_paths = break_paths_into_segments(hatching_paths,
min_length=100, max_length=200,
min_gap=15, max_gap=30)
hatching_paths = apply_perpendicular_jitter(hatching_paths, max_jitter=1)
hatching_paths = trim_line_ends(hatching_paths, min_line_length=100, max_trim=10)
hatching_paths = convert_to_bezier_curves(hatching_paths, max_control_offset=4.0, end_bias=0.7, points_per_curve=20)
hatching_paths = smooth_paths(hatching_paths, tolerance=0.5)
# Create path groups
path_groups = [("fineliner", hatching_paths)]
# Render all groups
render_paths_with_cairo(path_groups, width, height,
"combined_layers.png",
scale_factor=scale_factor)
export_to_svg(path_groups, width, height, "preview.svg")
gcode = generate_gcode(path_groups, penUp, penDown, scale_factor, available_height_mm)
# Show summary and ask for confirmation
print("\nReady to plot:")
print(f"- Number of path groups: {len(path_groups)}")
print(f"- Total number of commands: {len(gcode)}")
print(f"- Using pen heights: Up={penUp}mm, Down={penDown}mm")
print(f"- Scale factor: {scale_factor}mm per pixel")
response = input("\nSend to plotter? (y/n): ")
if response.lower() == 'y':
send_to_plotter(setup_commands + position_commands + gcode, "COM4", 115200)
else:
print("Plotting cancelled")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment