svg2ild/ilda/frame.py

636 lines
26 KiB
Python

# ilda/frame.py
"""
ILDA frame
(ILDA - image data transfer format for laser shows)
"""
import math
import re
from typing import List, Tuple, Dict, Any, Optional
from xml.etree.ElementTree import Element, SubElement, ElementTree
from svgpathtools import svg2paths2, Path
from shapely.geometry import LineString, box
import numpy as np
from .utils import order_paths_greedy_with_2opt
PointF = Tuple[float, float]
PointI = Tuple[int, int, int] # x,y,z as signed 16-bit integers
RGB = Tuple[int, int, int]
ILDA_CANVAS = 65536
ILDA_HALF = ILDA_CANVAS // 2 # 32768
INT16_MIN = -32768
INT16_MAX = 32767
# Global/default palette (64 entries) as (index, (r,g,b))
DEFAULT_ilda_palette: List[Tuple[int, RGB]] = [
(0, (255, 0, 0)), (1, (255, 16, 0)), (2, (255, 32, 0)), (3, (255, 48, 0)),
(4, (255, 64, 0)), (5, (255, 80, 0)), (6, (255, 96, 0)), (7, (255, 112, 0)),
(8, (255, 128, 0)), (9, (255, 144, 0)), (10, (255, 160, 0)), (11, (255, 176, 0)),
(12, (255, 192, 0)), (13, (255, 208, 0)), (14, (255, 224, 0)), (15, (255, 240, 0)),
(16, (255, 255, 0)), (17, (224, 255, 0)), (18, (192, 255, 0)), (19, (160, 255, 0)),
(20, (128, 255, 0)), (21, ( 96, 255, 0)), (22, ( 64, 255, 0)), (23, ( 32, 255, 0)),
(24, ( 0, 255, 0)), (25, ( 0, 255, 36)), (26, ( 0, 255, 73)), (27, ( 0, 255, 109)),
(28, ( 0, 255, 146)), (29, ( 0, 255, 182)), (30, ( 0, 255, 219)), (31, ( 0, 255, 255)),
(32, ( 0, 227, 255)), (33, ( 0, 198, 255)), (34, ( 0, 170, 255)), (35, ( 0, 142, 255)),
(36, ( 0, 113, 255)), (37, ( 0, 85, 255)), (38, ( 0, 56, 255)), (39, ( 0, 28, 255)),
(40, ( 0, 0, 255)), (41, ( 32, 0, 255)), (42, ( 64, 0, 255)), (43, ( 96, 0, 255)),
(44, (128, 0, 255)), (45, (160, 0, 255)), (46, (192, 0, 255)), (47, (224, 0, 255)),
(48, (255, 0, 255)), (49, (255, 32, 255)), (50, (255, 64, 255)), (51, (255, 96, 255)),
(52, (255, 128, 255)), (53, (255, 160, 255)), (54, (255, 192, 255)), (55, (255, 224, 255)),
(56, (255, 255, 255)), (57, (255, 224, 224)), (58, (255, 192, 192)), (59, (255, 160, 160)),
(60, (255, 128, 128)), (61, (255, 96, 96)), (62, (255, 64, 64)), (63, (255, 32, 32))
]
class Frame:
# Public instance state: only normalized ILDA-format points and colors plus original svg size
points_list: List[List[PointI]] # each path -> list of (x,y,z) int16
colors_rgb: List[RGB] # per-path colors as 8-bit rgb
colors_indexed: List[int] # per-path palette index (0-255)
svg_size: Tuple[float, float] # (width, height) of original SVG in user units (floats)
point_cnt: int # total points of all paths
point_cnt_simpl: int # total points of all paths simplified
def __init__(self) -> None:
self.points_list = []
self.colors_rgb = []
self.colors_indexed = []
self.svg_size = (0.0, 0.0)
self.point_cnt = 0
self.point_cnt_simpl = 0
def read_svg(self, infile: str, simplify: bool = False, tol: float = 1.0, mintravel: bool = False) -> None:
"""
Read SVG paths and convert to internal normalized ILDA integer coordinates.
Args:
infile: SVG file path
simplify: if True apply Ramer-Douglas-Peucker simplification with tolerance tol
tol: tolerance for simplification (same units as SVG coordinates)
mintravel: optional order polylines to minimize blank travel
Result:
self.points_list: list of paths; each path is list of (x,y,z) int16 with z=0
self.colors_rgb: per-path (r,g,b) tuples
self.colors_indexed: per-path 8bit color index from DEFAULT_ilda_palette
self.svg_size: (width, height) from svg attributes (floats)
Notes:
the viewbox passed to _path_to_points is (xmin, ymin, xmax, ymax) derived from svg size.
"""
# Helper: compare two polylines with tolerance
def _poly_equal(a: List[Tuple[float, float]], b: List[Tuple[float, float]], eps: float = 1e-6) -> bool:
if len(a) != len(b):
return False
for (x1, y1), (x2, y2) in zip(a, b):
if abs(x1 - x2) > eps or abs(y1 - y2) > eps:
return False
return True
# Helper: append one polyline (list of (x,y) floats) and its attributes to internal lists
def _append_poly_and_attr(poly: List[Tuple[float, float]], attr: Dict[str, str]) -> None:
# If poly too short, append empty placeholder and still record color info
if not poly or len(poly) < 2:
self.points_list.append([])
color = self._extract_stroke(attr)
rgb = self._parse_color_to_rgb(color)
self.colors_rgb.append(rgb)
idx = self._find_best_palette_index(rgb)
self.colors_indexed.append(idx)
return
# Simplify polyline using class RDP if requested
if simplify:
processed = self._rdp(poly, tol)
else:
processed = poly
if not processed or len(processed) < 2:
self.points_list.append([])
color = self._extract_stroke(attr)
rgb = self._parse_color_to_rgb(color)
self.colors_rgb.append(rgb)
idx = self._find_best_palette_index(rgb)
self.colors_indexed.append(idx)
return
# Normalize to ILDA coordinates (use svg_size width/height)
width, height = self.svg_size
ptsi = [self._normalize_point_to_ilda((x, y), width, height) for (x, y) in processed]
ptsi3 = [(int(x), int(y), 0) for (x, y) in ptsi]
self.points_list.append(ptsi3)
# Color handling (duplicate original attr for this fragment)
color = self._extract_stroke(attr)
rgb = self._parse_color_to_rgb(color)
self.colors_rgb.append(rgb)
idx = self._find_best_palette_index(rgb)
self.colors_indexed.append(idx)
# Update simplified point counter
self.point_cnt_simpl += len(processed)
# ---- main body ----
paths, attribs, svg_attrib = svg2paths2(infile)
width, height = self._extract_svg_size(svg_attrib)
self.svg_size = (width, height)
# Reset internal lists and counters
self.points_list = []
self.colors_rgb = []
self.colors_indexed = []
sample_step = max(0.5, tol / 3.0)
self.point_cnt = 0
self.point_cnt_simpl = 0
# Collect all polylines (after sampling+clipping) and their attributes
all_polylines: List[List[Tuple[float, float]]] = []
all_attrs: List[Dict[str, str]] = []
for i, path_obj in enumerate(paths):
# Pass viewbox as (xmin, ymin, xmax, ymax) to _path_to_points
xmin, ymin = 0.0, 0.0
xmax, ymax = width, height
ptsf_parts = self._path_to_points(path_obj, sample_step, viewbox=(xmin, ymin, xmax, ymax))
# ptsf_parts is list of polylines (each polyline is list of (x,y))
self.point_cnt += sum(len(row) for row in ptsf_parts)
attr = attribs[i] if i < len(attribs) else {}
for part in ptsf_parts:
all_polylines.append(part)
all_attrs.append(attr)
# If nothing found, keep behavior: print and return
if not all_polylines:
print(f"Processing file: {infile} Points: {self.point_cnt} Points (simplified): {self.point_cnt_simpl}")
return
# optional order polylines to minimize blank travel
if mintravel:
ordered_result = order_paths_greedy_with_2opt(all_polylines, start_point=None, do_2opt=True)
ordered_polylines = ordered_result.paths
else:
ordered_polylines = list(all_polylines)
# Map ordered polylines back to original attributes by matching geometry (or reversed)
used_indices = set()
for poly in ordered_polylines:
matched_idx = None
matched_attr: Dict[str, str] = {}
for j, orig in enumerate(all_polylines):
if j in used_indices:
continue
if _poly_equal(orig, poly) or _poly_equal(list(reversed(orig)), poly):
matched_idx = j
matched_attr = all_attrs[j]
used_indices.add(j)
break
if matched_idx is None:
# If no exact match found, use empty attr (rare)
matched_attr = {}
_append_poly_and_attr(poly, matched_attr)
print(f"Processing file: {infile} Points: {self.point_cnt} Points (simplified): {self.point_cnt_simpl}")
def get_ilda(self,
format_code: int = 0,
frame_name: str = '00000001',
company_name: str = 'MIKLOBIT',
frame_number: int = 0,
total_frames: int = 1,
projector: int = 0) -> bytes:
"""
Prepare ILDA binary data for this frame: header (32B) + records.
- Does not append EOF header (NumberOfRecords = 0) — this is left to the Animation class.
- If the frame does not contain points, it raises a ValueError (to avoid accidentally
inserting EOF in the middle of an animation).
Params:
format_code: ILDA format (only 0 implemented)
frame_name, company_name: ASCII, up to 8 characters (will be truncated/padded)
frame_number, total_frames, projector: header fields
Returns:
bytes with header + records (without EOF)
"""
if format_code != 0:
raise NotImplementedError("Only Format 0 (3D Indexed) is implemented")
# helper creating a 32-byte header
def _make_header(fmt: int, name: str, company: str, num_records: int,
frame_number: int = 0, total_frames: int = 1, projector: int = 0) -> bytes:
b = bytearray(32)
b[0:4] = b'ILDA'
b[7] = fmt & 0xFF
name_enc = name.encode('ascii', errors='ignore')[:8]
b[8:8+len(name_enc)] = name_enc
comp_enc = company.encode('ascii', errors='ignore')[:8]
b[16:16+len(comp_enc)] = comp_enc
b[24:26] = num_records.to_bytes(2, byteorder='big', signed=False)
b[26:28] = frame_number.to_bytes(2, byteorder='big', signed=False)
b[28:30] = total_frames.to_bytes(2, byteorder='big', signed=False)
b[30] = projector & 0xFF
return bytes(b)
# total number of records (each record = 8 bytes)
total_points = 0
for path in self.points_list:
total_points += max(0, len(path))
if total_points == 0:
# Note: a header with num_records=0 is treated as EOF — we do not want to include this
# in the middle of the animation; we return an error so that Animation can skip this frame.
raise ValueError("Frame contains no points; get_ilda() would produce EOF header")
records = bytearray()
# determine the index of the last point in this frame -> set the LastPoint bit at the last point of this section
last_path_idx = -1
last_point_idx = -1
for pi, path in enumerate(self.points_list):
if path:
last_path_idx = pi
last_point_idx = len(path) - 1
for pi, path in enumerate(self.points_list):
if not path:
continue
# first point as blanking (duplicate of first point) with Blanking bit set
fx, fy, fz = path[0]
status = 0
status |= (1 << 6) # Blanking bit
ci = 0
if pi < len(self.colors_indexed):
ci = int(self.colors_indexed[pi]) & 0xFF
records += int(fx).to_bytes(2, byteorder='big', signed=True)
records += int(fy).to_bytes(2, byteorder='big', signed=True)
records += int(fz).to_bytes(2, byteorder='big', signed=True)
records += bytes([status, ci])
# subsequent points (draw), set LastPoint to the last point of this frame/section
for pti, (x, y, z) in enumerate(path):
status = 0
if (pi == last_path_idx) and (pti == last_point_idx):
status |= (1 << 7) # LastPoint bit
ci = 0
if pi < len(self.colors_indexed):
ci = int(self.colors_indexed[pi]) & 0xFF
records += int(x).to_bytes(2, byteorder='big', signed=True)
records += int(y).to_bytes(2, byteorder='big', signed=True)
records += int(z).to_bytes(2, byteorder='big', signed=True)
records += bytes([status, ci])
header = _make_header(format_code, frame_name, company_name, len(records) // 8,
frame_number=frame_number, total_frames=total_frames, projector=projector)
return header + bytes(records)
def write_ild(self, filename: str, format_code: int = 0,
frame_name: str = '00000001', company_name: str = 'MIKLO',
frame_number: int = 0, total_frames: int = 1, projector: int = 0) -> None:
"""
Save a single ILDA file for this frame (including the EOF header).
The implementation uses get_ilda() to generate the frame section and then
adds the EOF header (NumberOfRecords = 0).
"""
# Get binary for (header + records)
section = self.get_ilda(format_code=format_code,
frame_name=frame_name,
company_name=company_name,
frame_number=frame_number,
total_frames=total_frames,
projector=projector)
# Create EOF header (format_code, 0 records)
def _make_eof_header(fmt: int, name: str, company: str, frame_number: int = 0, total_frames: int = 0, projector: int = 0) -> bytes:
b = bytearray(32)
b[0:4] = b'ILDA'
b[7] = fmt & 0xFF
name_enc = name.encode('ascii', errors='ignore')[:8]
b[8:8+len(name_enc)] = name_enc
comp_enc = company.encode('ascii', errors='ignore')[:8]
b[16:16+len(comp_enc)] = comp_enc
b[24:26] = (0).to_bytes(2, byteorder='big', signed=False) # NumberOfRecords = 0 -> EOF
b[26:28] = frame_number.to_bytes(2, byteorder='big', signed=False)
b[28:30] = total_frames.to_bytes(2, byteorder='big', signed=False)
b[30] = projector & 0xFF
return bytes(b)
eof_header = _make_eof_header(format_code, frame_name, company_name, frame_number=0, total_frames=0, projector=projector)
with open(filename, 'wb') as f:
f.write(section)
f.write(eof_header)
def write_svg(self, outfile: str, canvas_size: Optional[Tuple[float, float]] = None) -> None:
"""
Build an SVG with paths from internal ILDA-normalized integer coordinates.
Args:
outfile: output SVG filename
canvas_size: optional (width, height) in target SVG units. If None, uses ILDA canvas size (65536,65536).
If provided, ILDA coordinates are scaled to fit this canvas preserving the ILDA aspect.
"""
if canvas_size is None:
target_w, target_h = (ILDA_CANVAS, ILDA_CANVAS)
else:
target_w, target_h = canvas_size
svg_attrs = {
'xmlns': 'http://www.w3.org/2000/svg',
'width': str(target_w),
'height': str(target_h),
'viewBox': f"0 0 {target_w} {target_h}"
}
root = Element('svg', svg_attrs)
scale_x = target_w / float(ILDA_CANVAS)
scale_y = target_h / float(ILDA_CANVAS)
for idx, pts in enumerate(self.points_list):
if not pts:
continue
ptsf = []
for (xi, yi, zi) in pts:
nx = (xi / ILDA_CANVAS) + 0.5
ny = 0.5 - (yi / ILDA_CANVAS)
fx = nx * ILDA_CANVAS * scale_x
fy = ny * ILDA_CANVAS * scale_y
ptsf.append((float(fx), float(fy)))
closed = False
d = self._points_to_path_d(ptsf, closed)
el = SubElement(root, 'path')
el.set('d', d)
rgb = self.colors_rgb[idx] if idx < len(self.colors_rgb) else (0, 0, 0)
el.set('stroke', f"rgb({rgb[0]},{rgb[1]},{rgb[2]})")
el.set('fill', 'none')
el.set('stroke-width', '1.0')
tree = ElementTree(root)
tree.write(outfile, encoding='utf-8', xml_declaration=True)
def _extract_svg_size(self, svg_attrib: Dict[str, str]) -> Tuple[float, float]:
vb = svg_attrib.get('viewBox') or svg_attrib.get('viewbox')
if vb:
parts = vb.strip().replace(',', ' ').split()
if len(parts) >= 4:
try:
w = float(parts[2])
h = float(parts[3])
if w > 0 and h > 0:
return (w, h)
except Exception:
pass
def parse_dim(v: Optional[str]) -> Optional[float]:
if not v:
return None
m = re.match(r'([0-9.+-eE]+)', v.strip())
if m:
try:
return float(m.group(1))
except Exception:
return None
return None
w = parse_dim(svg_attrib.get('width'))
h = parse_dim(svg_attrib.get('height'))
if w and h:
return (w, h)
return (ILDA_CANVAS, ILDA_CANVAS)
def _extract_stroke(self, attr: Dict[str, str]) -> str:
if 'stroke' in attr and attr['stroke'].strip():
return attr['stroke'].strip()
style = attr.get('style', '')
for part in style.split(';'):
if not part:
continue
if ':' not in part:
continue
k, v = part.split(':', 1)
if k.strip() == 'stroke' and v.strip():
return v.strip()
return ''
def _parse_color_to_rgb(self, color_str: str) -> RGB:
if not color_str:
return (0, 0, 0)
s = color_str.strip()
if s.startswith('#'):
s2 = s[1:]
if len(s2) == 6:
try:
r = int(s2[0:2], 16)
g = int(s2[2:4], 16)
b = int(s2[4:6], 16)
return (r, g, b)
except Exception:
return (0, 0, 0)
if len(s2) == 3:
try:
r = int(s2[0]*2, 16)
g = int(s2[1]*2, 16)
b = int(s2[2]*2, 16)
return (r, g, b)
except Exception:
return (0, 0, 0)
m = re.match(r'rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)', s, re.IGNORECASE)
if m:
try:
r = max(0, min(255, int(m.group(1))))
g = max(0, min(255, int(m.group(2))))
b = max(0, min(255, int(m.group(3))))
return (r, g, b)
except Exception:
return (0, 0, 0)
named = {
'black': (0,0,0), 'white': (255,255,255), 'red': (255,0,0),
'green': (0,128,0), 'blue': (0,0,255)
}
lc = s.lower()
if lc in named:
return named[lc]
return (0, 0, 0)
def _path_to_points(self, path: Path, max_segment_length: float, viewbox: Optional[Tuple[float, float, float, float]] = None) -> List[List[PointF]]:
"""
Sample an svgpathtools Path into one or more polylines (list of point lists).
- If viewbox is provided as (xmin, ymin, width, height) or (xmin,ymin,xmax,ymax),
the resulting single polyline is clipped to the rectangle and may produce multiple pieces.
- Returns list of polylines (each polyline is a list of (x,y) floats).
"""
# existing sampling logic (produce single polyline)
pts: List[PointF] = []
for seg in path:
seg_pts = self._sample_segment(seg, max_segment_length)
if not seg_pts:
continue
if not pts:
pts.extend(seg_pts)
else:
if abs(pts[-1][0] - seg_pts[0][0]) < 1e-9 and abs(pts[-1][1] - seg_pts[0][1]) < 1e-9:
pts.extend(seg_pts[1:])
else:
pts.extend(seg_pts)
# ensure final endpoint included
if len(path) > 0:
try:
endc = path[-1].point(1.0)
endpt = (endc.real, endc.imag)
if not pts or (abs(pts[-1][0] - endpt[0]) > 1e-9 or abs(pts[-1][1] - endpt[1]) > 1e-9):
pts.append(endpt)
except Exception:
pass
# If no clipping requested, return single polyline as single-item list (or empty list if too short)
if not viewbox:
return [pts] if len(pts) >= 2 else []
# Convert to (xmin,ymin,xmax,ymax) exactly (read_svg should provide this form)
xmin, ymin, xmax, ymax = viewbox
if len(pts) < 2:
return []
pieces = self._clip_polyline_to_rect(pts, (xmin, ymin, xmax, ymax))
return pieces
def _sample_segment(self, seg: Any, max_segment_length: float) -> List[PointF]:
try:
seg_len = seg.length(error=1e-5)
except Exception:
bbox = seg.bbox()
dx = bbox[2] - bbox[0]
dy = bbox[3] - bbox[1]
seg_len = math.hypot(dx, dy)
if seg_len == 0:
return []
n = max(1, int(math.ceil(seg_len / max_segment_length)))
pts: List[PointF] = []
for i in range(n):
t = i / n
c = seg.point(t)
pts.append((c.real, c.imag))
return pts
def _rdp(self, points: List[PointF], eps: float) -> List[PointF]:
if len(points) < 3:
return points[:]
start = np.array(points[0])
end = np.array(points[-1])
line_vec = end - start
line_len_sq = np.dot(line_vec, line_vec)
if line_len_sq == 0.0:
dists = [np.linalg.norm(np.array(p) - start) for p in points]
else:
def point_line_distance(pt: PointF) -> float:
v = np.array(pt) - start
t = np.dot(v, line_vec) / line_len_sq
t = max(0.0, min(1.0, t))
proj = start + t * line_vec
return float(np.linalg.norm(np.array(pt) - proj))
dists = [point_line_distance(p) for p in points]
idx = int(np.argmax(dists))
dmax = dists[idx]
if dmax > eps:
left = self._rdp(points[:idx+1], eps)
right = self._rdp(points[idx:], eps)
return left[:-1] + right
else:
return [points[0], points[-1]]
def _normalize_point_to_ilda(self, p: PointF, svg_w: float, svg_h: float) -> Tuple[int, int]:
x, y = p
if svg_w <= 0 or svg_h <= 0:
svg_w, svg_h = (ILDA_CANVAS, ILDA_CANVAS)
nx = x / svg_w
ny = y / svg_h
ix = int(round((nx - 0.5) * ILDA_CANVAS))
iy = int(round((0.5 - ny) * ILDA_CANVAS))
ix = max(INT16_MIN, min(INT16_MAX, ix))
iy = max(INT16_MIN, min(INT16_MAX, iy))
return (ix, iy)
def _ilda_to_canvas_float(self, p3: PointI) -> Tuple[float, float]:
x_int, y_int, _ = p3
nx = (x_int / ILDA_CANVAS) + 0.5
ny = 0.5 - (y_int / ILDA_CANVAS)
fx = nx * ILDA_CANVAS
fy = ny * ILDA_CANVAS
return (float(fx), float(fy))
def _points_to_path_d(self, points: List[Tuple[float, float]], closed: bool) -> str:
if not points:
return ""
parts: List[str] = []
x0, y0 = points[0]
parts.append(f"M {x0:.6f} {y0:.6f}")
for (x, y) in points[1:]:
parts.append(f"L {x:.6f} {y:.6f}")
if closed:
parts.append("Z")
return " ".join(parts)
def _find_best_palette_index(self, rgb: RGB) -> int:
# If no default palette provided, return 0
if not DEFAULT_ilda_palette:
return 0
r0, g0, b0 = rgb
best_idx = DEFAULT_ilda_palette[0][0]
best_dist = None
for idx, (pr, pg, pb) in DEFAULT_ilda_palette:
dr = abs(pr - r0)
dg = abs(pg - g0)
db = abs(pb - b0)
dist = dr*dr + dg*dg + db*db
if best_dist is None or dist < best_dist:
best_dist = dist
best_idx = idx
# Ensure 0-255 range
return max(0, min(255, int(best_idx)))
def _clip_polyline_to_rect(self,
poly: List[PointF],
rect: Tuple[float, float, float, float],
eps: float = 1e-9) -> List[List[PointF]]:
"""
Clip a polyline to axis-aligned rectangle.
- poly: list of (x,y) floats representing the sampled polyline
- rect: (xmin, ymin, xmax, ymax)
- Returns list of polylines (each a list of (x,y)) that lie inside the rect.
- Uses Shapely
"""
xmin, ymin, xmax, ymax = rect
if not poly or xmin >= xmax or ymin >= ymax:
return []
if LineString is not None:
# robust, uses geometric intersection
ls = LineString(poly)
clip = box(xmin, ymin, xmax, ymax)
inter = clip.intersection(ls)
parts: List[List[PointF]] = []
# inter can be LineString or MultiLineString or GeometryCollection
def _extract_lines(geom):
if geom.is_empty:
return
geom_type = geom.geom_type
if geom_type == 'LineString':
coords = list(geom.coords)
if len(coords) >= 2:
parts.append([(float(x), float(y)) for x, y in coords])
elif geom_type == 'MultiLineString':
for g in geom.geoms:
_extract_lines(g)
# ignore points etc.
_extract_lines(inter)
return parts