将 Xynova 笔画延长至 50% 黑色覆盖率

沿现有笔画向空白处增长,保留最小线宽和笔画间隙。41 项测试通过,双 UV 矢量图无重叠。
This commit is contained in:
ykcheungab
2026-10-05 15:56:01 +08:00
parent b2c39bd1b5
commit f81418c542
14 changed files with 57264 additions and 57148 deletions
+94
View File
@@ -15,6 +15,10 @@ One mode, ``camera_compensated_width``: the mark inside a cell is a capsule of `
page so a cell the projection stretches receives a proportionally larger mark. The width is then
clamped to ``width_min_mm``..``width_max_mm``.
When ``target_coverage`` is positive, accepted capsules grow from their endpoints into free
space, up to twice their initial length. Width and edge clearance stay fixed. The reported
coverage is measured only inside the eligible colour-pattern region.
**This is an image-space experiment, not a print master.** Clipping and merged neighbours change
the visible widths, so verify dimensions on the printed part before manufacturing.
@@ -59,6 +63,7 @@ DEFAULT_PARAMS: dict[str, float] = {
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
"target_coverage": 0.0,
}
@@ -91,6 +96,8 @@ def validate_params(params: dict) -> None:
raise ValueError("params.width_min_mm must be <= params.width_max_mm")
if not 0.0 < params["coverage_cap"] <= 1.0:
raise ValueError("params.coverage_cap must be within (0, 1]")
if not 0.0 <= params["target_coverage"] < 1.0:
raise ValueError("params.target_coverage must be within [0, 1)")
def parse_args(argv: list[str]) -> argparse.Namespace:
@@ -403,6 +410,87 @@ def draw_mark(canvas, region, params, px_per_mm, patterned=None,
return 0.0
def capsule_footprint(capsule, px_per_mm, shape):
"""Raster footprint of a millimetre-space capsule, or None outside the page."""
start, end, radius_mm = capsule
cx = (start[0] + end[0]) * px_per_mm / 2
cy = (start[1] + end[1]) * px_per_mm / 2
dx = (end[0] - start[0]) * px_per_mm / 2
dy = (end[1] - start[1]) * px_per_mm / 2
half_length = math.hypot(dx, dy)
angle = math.atan2(dy, dx)
radius = radius_mm * px_per_mm
extent = half_length + radius
if cx - extent < 0 or cy - extent < 0 or cx + extent >= shape[1] or cy + extent >= shape[0]:
return None
x0, x1 = math.floor(cx - extent), min(shape[1], math.ceil(cx + extent + 1))
y0, y1 = math.floor(cy - extent), min(shape[0], math.ceil(cy + extent + 1))
yy, xx = np.ogrid[y0:y1, x0:x1]
along = (xx - cx) * math.cos(angle) + (yy - cy) * math.sin(angle)
across = -(xx - cx) * math.sin(angle) + (yy - cy) * math.cos(angle)
footprint = np.maximum(np.abs(along) - half_length, 0) ** 2 + across**2 <= radius**2
return (slice(y0, y1), slice(x0, x1)), footprint
def extend_marks(marks, patterned, capsules, vector_shapes, params, px_per_mm):
"""Grow existing marks into free space until target occupancy or geometry stops them."""
target = math.ceil(params["target_coverage"] * np.count_nonzero(patterned))
occupied = np.count_nonzero(marks & patterned)
if target <= occupied or not capsules:
return occupied
step_mm = 2 / px_per_mm
initial_lengths = [math.dist(start, end) + 2 * radius
for start, end, radius in capsules]
blocked = [[False, False] for _ in capsules]
while occupied < target and any(not all(pair) for pair in blocked):
for index, capsule in enumerate(capsules):
for side in (0, 1):
if blocked[index][side]:
continue
start, end, radius = capsule
length = math.dist(start, end)
if length + step_mm + 2 * radius > 2 * initial_lengths[index]:
blocked[index][side] = True
continue
ux = (end[0] - start[0]) / length
uy = (end[1] - start[1]) / length
candidate = (
(start[0] - ux * step_mm, start[1] - uy * step_mm) if side == 0 else start,
(end[0] + ux * step_mm, end[1] + uy * step_mm) if side == 1 else end,
radius,
)
if not has_clearance(candidate, capsules, params["clearance_mm"], index):
blocked[index][side] = True
continue
raster = capsule_footprint(candidate, px_per_mm, marks.shape)
if raster is None:
blocked[index][side] = True
continue
region, footprint = raster
if np.any(footprint & ~patterned[region]):
blocked[index][side] = True
continue
occupied += np.count_nonzero(footprint & ~marks[region])
marks[region] |= footprint
capsules[index] = candidate
capsule = candidate
if occupied >= target:
break
if occupied >= target:
break
if vector_shapes is not None:
vector_shapes[:] = [
(capsule_polygon((start[0] + end[0]) * px_per_mm / 2,
(start[1] + end[1]) * px_per_mm / 2,
radius * px_per_mm,
math.dist(start, end) * px_per_mm / 2,
math.atan2(end[1] - start[1], end[0] - start[0])),
(0.0, 0.0, 0.0, 1.0))
for start, end, radius in capsules
]
return occupied
def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None:
height, width = marks.shape
rgba = np.zeros((height, width, 4), dtype=np.float32)
@@ -622,6 +710,12 @@ def run(
)
for region in mode_regions
]
initial_coverage = np.count_nonzero(marks & patterned) / np.count_nonzero(patterned)
if params["target_coverage"] > initial_coverage:
extend_marks(marks, patterned, accepted_capsules, vector_shapes, params, px_per_mm)
print(f"{source_id}: endpoint extension {initial_coverage:.1%} -> "
f"{np.count_nonzero(marks & patterned) / np.count_nonzero(patterned):.1%} "
f"(target {params['target_coverage']:.1%})", flush=True)
stem = artifact_stem(source_id, artifact_prefix)
if "png" in formats:
write_binary_png(output_dir / f"{stem}.png", marks, surface)
+4 -2
View File
@@ -36,10 +36,12 @@ def capsule_segment(cx, cy, half_length, angle):
return (cx - dx, cy - dy), (cx + dx, cy + dy)
def has_clearance(candidate, accepted, clearance):
def has_clearance(candidate, accepted, clearance, skip_index=None):
"""Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate
for other_start, other_end, other_radius in accepted:
for index, (other_start, other_end, other_radius) in enumerate(accepted):
if index == skip_index:
continue
limit = radius + other_radius + clearance
if (max(start[0], end[0]) + limit < min(other_start[0], other_end[0])
or max(other_start[0], other_end[0]) + limit < min(start[0], end[0])