将 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 page so a cell the projection stretches receives a proportionally larger mark. The width is then
clamped to ``width_min_mm``..``width_max_mm``. 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 **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. 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, "neighbourhood_mm": 0.5,
"clearance_mm": 0.0, "clearance_mm": 0.0,
"candidate_count": 1, "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") raise ValueError("params.width_min_mm must be <= params.width_max_mm")
if not 0.0 < params["coverage_cap"] <= 1.0: if not 0.0 < params["coverage_cap"] <= 1.0:
raise ValueError("params.coverage_cap must be within (0, 1]") 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: 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 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: def write_binary_png(path: Path, marks: np.ndarray, surface: np.ndarray) -> None:
height, width = marks.shape height, width = marks.shape
rgba = np.zeros((height, width, 4), dtype=np.float32) rgba = np.zeros((height, width, 4), dtype=np.float32)
@@ -622,6 +710,12 @@ def run(
) )
for region in mode_regions 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) stem = artifact_stem(source_id, artifact_prefix)
if "png" in formats: if "png" in formats:
write_binary_png(output_dir / f"{stem}.png", marks, surface) 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) 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.""" """Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate 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 limit = radius + other_radius + clearance
if (max(start[0], end[0]) + limit < min(other_start[0], other_end[0]) 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]) or max(other_start[0], other_end[0]) + limit < min(start[0], end[0])
@@ -8,6 +8,7 @@
"min_cell_pixels": 20, "min_cell_pixels": 20,
"neighbourhood_mm": 0.5, "neighbourhood_mm": 0.5,
"seed": 7, "seed": 7,
"target_coverage": 0.5,
"width_fraction": 0.5, "width_fraction": 0.5,
"width_max_mm": 0.28, "width_max_mm": 0.28,
"width_min_mm": 0.2 "width_min_mm": 0.2
@@ -25,15 +25,16 @@
"coverage_cap": 0.45, "coverage_cap": 0.45,
"neighbourhood_mm": 0.5, "neighbourhood_mm": 0.5,
"clearance_mm": 0.03, "clearance_mm": 0.03,
"candidate_count": 12 "candidate_count": 12,
"target_coverage": 0.5
}, },
"design_yaml": { "design_yaml": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml", "path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
"sha256": "428a2f9c50ec7283c3030de830e24467669a12967be60b348007f6381c160b32" "sha256": "f6d2536f66256ff4c0a6b210d9dbab7b1c0c9d263149171f4da3298a22c4166c"
}, },
"params": { "params": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
"sha256": "95a2d8a7a1a775d03dbc98e98bc4b2ae28f87c815acadf12fe8bdaa5dd073fe8" "sha256": "35498f3b08aa93f7e89a7f27080ec0655997b36f69fd8da30002e5d34df6083f"
} }
}, },
"outputs": [ "outputs": [
@@ -52,15 +53,15 @@
"artifacts": { "artifacts": {
"png": { "png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
"sha256": "f76dc820bef5fbc3dd004578683e8166d8ef8ce5666d2b6e2d1a94538a6f01cc" "sha256": "a00cee4113337fae6ccddd9213b832bd5a1756c78c7e23bf0caabfeccda680e6"
}, },
"svg": { "svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
"sha256": "c2d90aa1c033f26820fe007af239e9f09f2cf8d3c8ed560119355f9a0bc0cf5c" "sha256": "d880e71c55daa842757f5a558227c715b45c41d9e980ec4d056749eb21cd3d9f"
}, },
"pdf": { "pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf",
"sha256": "2e1be9d1718f05e0a6898ba72abcfaae185a413843b2096fb71ffcd1200da48b" "sha256": "6cc5910f86b687a0334829b23decf3fd5d9166378ab05e3a93cbaca24278c92c"
} }
} }
}, },
@@ -79,15 +80,15 @@
"artifacts": { "artifacts": {
"png": { "png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
"sha256": "119d7c47b7454f1f36534fd888381e3c9c0300d82458c44b13aa534697375f2a" "sha256": "ff75bba350475e4ae01cc841700e16b601e5a6fd06708597536ceb7bf8700ae6"
}, },
"svg": { "svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
"sha256": "340eb5dd9f1a06a8266a5f7ded3868513ceb0a9d1c5ca629df8a46cba18174c3" "sha256": "511b324431b0d95dc6bbf66709f3221e33dfc3a34e6416f3d24c490e3c79d1cc"
}, },
"pdf": { "pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf", "path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
"sha256": "0a431236abae3171fb67cc6f5864d59296c0f28d9bcd5006c74edff4e39c7516" "sha256": "b1123696d80a03b2f257f6ad397d96a1b2d9416039832df33e9d7b40a455528e"
} }
} }
} }
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

After

Width:  |  Height:  |  Size: 702 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 920 KiB

After

Width:  |  Height:  |  Size: 920 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 705 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 934 KiB

After

Width:  |  Height:  |  Size: 934 KiB

+2 -1
View File
@@ -104,10 +104,11 @@ stroke_pattern:
width_min_mm: 0.20 width_min_mm: 0.20
width_max_mm: 0.28 width_max_mm: 0.28
length_ratio: 2.4 # length = ratio x width length_ratio: 2.4 # length = ratio x width
coverage_cap: 0.45 # skip a mark that would exceed this local fill coverage_cap: 0.45 # initial placement limit; endpoint growth uses target_coverage
neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood neighbourhood_mm: 0.5 # radius of the local-fill neighbourhood
clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page
candidate_count: 12 # try alternatives before skipping a cell candidate_count: 12 # try alternatives before skipping a cell
target_coverage: 0.50 # extend accepted strokes into free space, without overlap
# Advisory only: reports a verdict, never blocks generation or changes exit codes. # Advisory only: reports a verdict, never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above. # Millimetres are measured on the vector page declared above.
@@ -47,6 +47,7 @@ STROKE_DEFAULTS: dict[str, Any] = {
"neighbourhood_mm": 0.5, "neighbourhood_mm": 0.5,
"clearance_mm": 0.0, "clearance_mm": 0.0,
"candidate_count": 1, "candidate_count": 1,
"target_coverage": 0.0,
} }
STROKE_KEYS = frozenset(STROKE_DEFAULTS) STROKE_KEYS = frozenset(STROKE_DEFAULTS)
@@ -115,6 +116,10 @@ def validate_stroke_block(block: Any) -> list[str]:
errors.append( errors.append(
f"{where}.coverage_cap: must be within (0, 1] (got {block['coverage_cap']!r})" f"{where}.coverage_cap: must be within (0, 1] (got {block['coverage_cap']!r})"
) )
if "target_coverage" in block and not 0.0 <= block["target_coverage"] < 1.0:
errors.append(
f"{where}.target_coverage: must be within [0, 1) (got {block['target_coverage']!r})"
)
merged = {**STROKE_DEFAULTS, **{k: v for k, v in block.items() if k in STROKE_KEYS}} merged = {**STROKE_DEFAULTS, **{k: v for k, v in block.items() if k in STROKE_KEYS}}
if merged["width_min_mm"] > merged["width_max_mm"]: if merged["width_min_mm"] > merged["width_max_mm"]:
errors.append( errors.append(
+9
View File
@@ -24,3 +24,12 @@ def test_near_parallel_capsules_need_edge_spacing() -> None:
def test_disjoint_collinear_capsules_can_clear() -> None: def test_disjoint_collinear_capsules_can_clear() -> None:
existing = [((0, 0), (0.2, 0), 0.1)] existing = [((0, 0), (0.2, 0), 0.1)]
assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03) assert has_clearance(((0.45, 0), (0.65, 0), 0.1), existing, 0.03)
def test_extension_ignores_only_its_original_capsule() -> None:
original = ((0, 0), (0.2, 0), 0.1)
neighbour = ((0.6, 0), (0.8, 0), 0.1)
assert has_clearance(((-0.1, 0), (0.3, 0), 0.1), [original, neighbour], 0.03,
skip_index=0)
assert not has_clearance(((-0.1, 0), (0.4, 0), 0.1), [original, neighbour], 0.03,
skip_index=0)
+4 -1
View File
@@ -116,6 +116,8 @@ def test_boolean_is_not_a_number() -> None:
("neighbourhood_mm", 0.0, "must be > 0"), ("neighbourhood_mm", 0.0, "must be > 0"),
("clearance_mm", -0.01, "must be >= 0"), ("clearance_mm", -0.01, "must be >= 0"),
("candidate_count", 0, "must be >= 1"), ("candidate_count", 0, "must be >= 1"),
("target_coverage", -0.01, "within [0, 1)"),
("target_coverage", 1.0, "within [0, 1)"),
], ],
) )
def test_range_rules(key: str, value, expected: str) -> None: def test_range_rules(key: str, value, expected: str) -> None:
@@ -184,5 +186,6 @@ def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
resolved = stroke_pattern.resolve_stroke_settings(block) resolved = stroke_pattern.resolve_stroke_settings(block)
expected = dict(stroke_pattern.STROKE_DEFAULTS) expected = dict(stroke_pattern.STROKE_DEFAULTS)
if sensor == "finger-xynova": if sensor == "finger-xynova":
expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12) expected.update(width_min_mm=0.20, clearance_mm=0.03, candidate_count=12,
target_coverage=0.50)
assert resolved == expected assert resolved == expected