将 Xynova 笔画延长至 50% 黑色覆盖率
沿现有笔画向空白处增长,保留最小线宽和笔画间隙。41 项测试通过,双 UV 矢量图无重叠。
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"min_cell_pixels": 20,
|
||||
"neighbourhood_mm": 0.5,
|
||||
"seed": 7,
|
||||
"target_coverage": 0.5,
|
||||
"width_fraction": 0.5,
|
||||
"width_max_mm": 0.28,
|
||||
"width_min_mm": 0.2
|
||||
|
||||
@@ -25,15 +25,16 @@
|
||||
"coverage_cap": 0.45,
|
||||
"neighbourhood_mm": 0.5,
|
||||
"clearance_mm": 0.03,
|
||||
"candidate_count": 12
|
||||
"candidate_count": 12,
|
||||
"target_coverage": 0.5
|
||||
},
|
||||
"design_yaml": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
|
||||
"sha256": "428a2f9c50ec7283c3030de830e24467669a12967be60b348007f6381c160b32"
|
||||
"sha256": "f6d2536f66256ff4c0a6b210d9dbab7b1c0c9d263149171f4da3298a22c4166c"
|
||||
},
|
||||
"params": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
|
||||
"sha256": "95a2d8a7a1a775d03dbc98e98bc4b2ae28f87c815acadf12fe8bdaa5dd073fe8"
|
||||
"sha256": "35498f3b08aa93f7e89a7f27080ec0655997b36f69fd8da30002e5d34df6083f"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
@@ -52,15 +53,15 @@
|
||||
"artifacts": {
|
||||
"png": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
|
||||
"sha256": "f76dc820bef5fbc3dd004578683e8166d8ef8ce5666d2b6e2d1a94538a6f01cc"
|
||||
"sha256": "a00cee4113337fae6ccddd9213b832bd5a1756c78c7e23bf0caabfeccda680e6"
|
||||
},
|
||||
"svg": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
|
||||
"sha256": "c2d90aa1c033f26820fe007af239e9f09f2cf8d3c8ed560119355f9a0bc0cf5c"
|
||||
"sha256": "d880e71c55daa842757f5a558227c715b45c41d9e980ec4d056749eb21cd3d9f"
|
||||
},
|
||||
"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": {
|
||||
"png": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
|
||||
"sha256": "119d7c47b7454f1f36534fd888381e3c9c0300d82458c44b13aa534697375f2a"
|
||||
"sha256": "ff75bba350475e4ae01cc841700e16b601e5a6fd06708597536ceb7bf8700ae6"
|
||||
},
|
||||
"svg": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
|
||||
"sha256": "340eb5dd9f1a06a8266a5f7ded3868513ceb0a9d1c5ca629df8a46cba18174c3"
|
||||
"sha256": "511b324431b0d95dc6bbf66709f3221e33dfc3a34e6416f3d24c490e3c79d1cc"
|
||||
},
|
||||
"pdf": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
|
||||
"sha256": "0a431236abae3171fb67cc6f5864d59296c0f28d9bcd5006c74edff4e39c7516"
|
||||
"sha256": "b1123696d80a03b2f257f6ad397d96a1b2d9416039832df33e9d7b40a455528e"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 619 KiB After Width: | Height: | Size: 702 KiB |
|
Before Width: | Height: | Size: 920 KiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 705 KiB |
|
Before Width: | Height: | Size: 934 KiB After Width: | Height: | Size: 934 KiB |
@@ -104,10 +104,11 @@ stroke_pattern:
|
||||
width_min_mm: 0.20
|
||||
width_max_mm: 0.28
|
||||
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
|
||||
clearance_mm: 0.03 # minimum edge-to-edge spacing on the vector page
|
||||
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.
|
||||
# Millimetres are measured on the vector page declared above.
|
||||
|
||||
@@ -47,6 +47,7 @@ STROKE_DEFAULTS: dict[str, Any] = {
|
||||
"neighbourhood_mm": 0.5,
|
||||
"clearance_mm": 0.0,
|
||||
"candidate_count": 1,
|
||||
"target_coverage": 0.0,
|
||||
}
|
||||
|
||||
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
|
||||
@@ -115,6 +116,10 @@ def validate_stroke_block(block: Any) -> list[str]:
|
||||
errors.append(
|
||||
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}}
|
||||
if merged["width_min_mm"] > merged["width_max_mm"]:
|
||||
errors.append(
|
||||
|
||||
@@ -24,3 +24,12 @@ def test_near_parallel_capsules_need_edge_spacing() -> None:
|
||||
def test_disjoint_collinear_capsules_can_clear() -> None:
|
||||
existing = [((0, 0), (0.2, 0), 0.1)]
|
||||
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)
|
||||
|
||||
@@ -116,6 +116,8 @@ def test_boolean_is_not_a_number() -> None:
|
||||
("neighbourhood_mm", 0.0, "must be > 0"),
|
||||
("clearance_mm", -0.01, "must be >= 0"),
|
||||
("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:
|
||||
@@ -184,5 +186,6 @@ def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
|
||||
resolved = stroke_pattern.resolve_stroke_settings(block)
|
||||
expected = dict(stroke_pattern.STROKE_DEFAULTS)
|
||||
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
|
||||
|
||||