生成无重叠的 0.2 mm Xynova 笔画图案

在打印页毫米坐标中筛选候选笔画,更新双 UV 产物。38 项测试通过;SVG 检查无交叠。
This commit is contained in:
ykcheungab
2026-10-05 14:56:34 +08:00
parent 0a3c3995f3
commit b2c39bd1b5
15 changed files with 34996 additions and 69989 deletions
+38 -3
View File
@@ -39,6 +39,7 @@ script_dir = str(Path(__file__).resolve().parent)
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
from random_patch_dd02c import save_png_image, write_pdf_vector, write_svg_vector # noqa: E402 - Blender omits the script directory from sys.path
from stroke_geometry import capsule_segment, has_clearance # noqa: E402 - Blender script directory is on sys.path
MODE = "camera_compensated_width"
@@ -56,6 +57,8 @@ DEFAULT_PARAMS: dict[str, float] = {
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
}
@@ -72,6 +75,10 @@ def validate_params(params: dict) -> None:
raise ValueError(f"params.{key}: must be finite (got {value!r})")
if params["min_cell_pixels"] < 1:
raise ValueError("params.min_cell_pixels must be >= 1")
if not isinstance(params["candidate_count"], int) or params["candidate_count"] < 1:
raise ValueError("params.candidate_count must be an integer >= 1")
if params["clearance_mm"] < 0:
raise ValueError("params.clearance_mm must be >= 0")
if not 0.0 <= params["jitter_fraction"] <= 1.0:
raise ValueError("params.jitter_fraction must be within [0, 1]")
if params["angle_span_deg"] < 0.0:
@@ -146,7 +153,8 @@ def resolve_params(args: argparse.Namespace) -> dict:
def cell_code(
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float
rgb: np.ndarray, seed: int, jitter_fraction: float, angle_span_deg: float,
candidate_index: int = 0,
) -> tuple[float, float, float]:
"""Return bounded offsets and angle from one exact source colour.
@@ -154,7 +162,8 @@ def cell_code(
views without storing anything.
"""
digest = hashlib.blake2b(
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True),
bytes(int(v) for v in rgb) + seed.to_bytes(8, "little", signed=True)
+ (candidate_index.to_bytes(4, "little") if candidate_index else b""),
digest_size=8,
).digest()
values = [int.from_bytes(digest[k : k + 2], "little") / 65535 for k in (0, 2, 4, 6)]
@@ -287,7 +296,7 @@ def capsule_polygon(
return points
def draw_mark(
def draw_mark_candidate(
canvas: np.ndarray,
region,
params: dict,
@@ -295,6 +304,8 @@ def draw_mark(
patterned: np.ndarray | None = None,
camera_cell: tuple[int, int] | None = None,
vector_shapes: list | None = None,
accepted_capsules: list | None = None,
candidate_index: int = 0,
) -> float:
"""Draw one camera-compensated capsule. Returns its printed width in pixels, or 0."""
colour, cell_x, cell_y, width, height = region
@@ -304,6 +315,7 @@ def draw_mark(
params["seed"],
params["jitter_fraction"],
params["angle_span_deg"],
candidate_index,
)
span = min(width, height)
centre_x = cell_x + dx * width
@@ -359,7 +371,15 @@ def draw_mark(
added = np.count_nonzero(footprint & ~canvas[y0:y1, x0:x1])
if (existing + added) / local_area > params["coverage_cap"]:
return 0.0
capsule = (*capsule_segment(centre_x / px_per_mm, centre_y / px_per_mm,
half_length / px_per_mm, angle), radius / px_per_mm)
if accepted_capsules is not None and not has_clearance(
capsule, accepted_capsules, params["clearance_mm"]
):
return 0.0
canvas[y0:y1, x0:x1] |= footprint
if accepted_capsules is not None:
accepted_capsules.append(capsule)
if vector_shapes is not None:
vector_shapes.append(
(
@@ -370,6 +390,19 @@ def draw_mark(
return 2 * radius
def draw_mark(canvas, region, params, px_per_mm, patterned=None,
camera_cell=None, vector_shapes=None, accepted_capsules=None) -> float:
"""Try deterministic alternatives without relaxing width or clearance."""
for candidate_index in range(params["candidate_count"]):
width = draw_mark_candidate(
canvas, region, params, px_per_mm, patterned, camera_cell,
vector_shapes, accepted_capsules, candidate_index,
)
if width:
return width
return 0.0
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)
@@ -569,6 +602,7 @@ def run(
flush=True,
)
marks = np.zeros((height, width), dtype=bool)
accepted_capsules = []
vector_shapes = [] if vector_page is not None else None
mode_regions = [
region
@@ -584,6 +618,7 @@ def run(
patterned,
camera_cell=lookup[region[0]],
vector_shapes=vector_shapes,
accepted_capsules=accepted_capsules,
)
for region in mode_regions
]
+51
View File
@@ -0,0 +1,51 @@
"""Physical-space clearance checks for capsule-shaped laser marks."""
from __future__ import annotations
import math
def point_segment_distance_sq(point, start, end):
dx, dy = end[0] - start[0], end[1] - start[1]
length_sq = dx * dx + dy * dy
if length_sq == 0:
return (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2
t = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_sq))
return (point[0] - start[0] - t * dx) ** 2 + (point[1] - start[1] - t * dy) ** 2
def segments_distance_sq(a0, a1, b0, b1):
"""Minimum squared distance, including a proper crossing at zero."""
def cross(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
ab0, ab1 = cross(a0, a1, b0), cross(a0, a1, b1)
ba0, ba1 = cross(b0, b1, a0), cross(b0, b1, a1)
if ab0 * ab1 < 0 and ba0 * ba1 < 0:
return 0.0
return min(
point_segment_distance_sq(a0, b0, b1),
point_segment_distance_sq(a1, b0, b1),
point_segment_distance_sq(b0, a0, a1),
point_segment_distance_sq(b1, a0, a1),
)
def capsule_segment(cx, cy, half_length, angle):
dx, dy = half_length * math.cos(angle), half_length * math.sin(angle)
return (cx - dx, cy - dy), (cx + dx, cy + dy)
def has_clearance(candidate, accepted, clearance):
"""Capsules are (start, end, radius), all in the same physical units."""
start, end, radius = candidate
for other_start, other_end, other_radius in accepted:
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])
or max(start[1], end[1]) + limit < min(other_start[1], other_end[1])
or max(other_start[1], other_end[1]) + limit < min(start[1], end[1])):
continue
if segments_distance_sq(start, end, other_start, other_end) < limit * limit:
return False
return True
@@ -0,0 +1,14 @@
{
"angle_span_deg": 180.0,
"candidate_count": 12,
"clearance_mm": 0.03,
"coverage_cap": 0.45,
"jitter_fraction": 0.66,
"length_ratio": 2.4,
"min_cell_pixels": 20,
"neighbourhood_mm": 0.5,
"seed": 7,
"width_fraction": 0.5,
"width_max_mm": 0.28,
"width_min_mm": 0.2
}
@@ -12,7 +12,29 @@
"settings": {
"mode": "camera_compensated_width",
"seed": 7,
"camera_width_fraction": 0.5
"camera_width_fraction": 0.5,
"resolved": {
"seed": 7,
"min_cell_pixels": 20,
"jitter_fraction": 0.66,
"angle_span_deg": 180.0,
"width_fraction": 0.5,
"width_min_mm": 0.2,
"width_max_mm": 0.28,
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.03,
"candidate_count": 12
},
"design_yaml": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\sensors\\finger-xynova\\r01\\design.yaml",
"sha256": "428a2f9c50ec7283c3030de830e24467669a12967be60b348007f6381c160b32"
},
"params": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\_stroke_pattern_params.json",
"sha256": "95a2d8a7a1a775d03dbc98e98bc4b2ae28f87c815acadf12fe8bdaa5dd073fe8"
}
},
"outputs": [
{
@@ -30,15 +52,15 @@
"artifacts": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.png",
"sha256": "18be631386542efce2269df36c3eaa806ff9a1e42281dbd3db6caf0459793be7"
"sha256": "f76dc820bef5fbc3dd004578683e8166d8ef8ce5666d2b6e2d1a94538a6f01cc"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.svg",
"sha256": "5dd5725a3777b4c79a485ae3c073559de4160076d7821713c7b8437fb0e6b535"
"sha256": "c2d90aa1c033f26820fe007af239e9f09f2cf8d3c8ed560119355f9a0bc0cf5c"
},
"pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_1.pdf",
"sha256": "ba25a36e82a9d979707e89f09aea0471cd90ba648909fc812c3c940fe6551af6"
"sha256": "2e1be9d1718f05e0a6898ba72abcfaae185a413843b2096fb71ffcd1200da48b"
}
}
},
@@ -57,15 +79,15 @@
"artifacts": {
"png": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.png",
"sha256": "7a9b5504cce2b95982adc035a4e11b13cb721d17b2ecaf865a4cfcd95d73a013"
"sha256": "119d7c47b7454f1f36534fd888381e3c9c0300d82458c44b13aa534697375f2a"
},
"svg": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.svg",
"sha256": "dbd1771f6a5b7437c4d9f5b1f2d91c529b0a2a5950819bad51840441f04531e6"
"sha256": "340eb5dd9f1a06a8266a5f7ded3868513ceb0a9d1c5ca629df8a46cba18174c3"
},
"pdf": {
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_uv_2.pdf",
"sha256": "ed9df4cf7b8fb2fa6b4b9b508f83261dbe34db9c32ad356e915e94e94a20af04"
"sha256": "0a431236abae3171fb67cc6f5864d59296c0f28d9bcd5006c74edff4e39c7516"
}
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 619 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 920 KiB

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

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 621 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 934 KiB

+5 -4
View File
@@ -92,9 +92,8 @@ texture_patch_views:
# One mode: a capsule of `width` swept `length`, rotated per cell, sized from the camera
# cell's print-page footprint so a cell the projection stretches gets a proportionally
# larger mark. `--seed` / `--min-cell-pixels` / `--camera-width-fraction` override per run.
# Field reference: the inline comments below. These defaults are an image-space experiment,
# not a print master: `width_min_mm: 0.11` is below the demonstrated 0.2 mm dot (see
# docs/probe-findings.md). The resolved values are written to _stroke_pattern_params.json and
# Field reference: the inline comments below. This is an image-space experiment, not a
# print master. The resolved values are written to _stroke_pattern_params.json and
# recorded in the stroke-texture manifest, which is what makes a run reproducible.
stroke_pattern:
seed: 7 # per-cell offset and angle; reproducible
@@ -102,11 +101,13 @@ stroke_pattern:
jitter_fraction: 0.66 # mark offset range inside its cell; 0 pins to the centre
angle_span_deg: 180.0 # rotation range; 0 makes every stroke parallel
width_fraction: 0.50 # width = clamp(cell footprint x this, width_min_mm, width_max_mm)
width_min_mm: 0.11
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
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
# Advisory only: reports a verdict, never blocks generation or changes exit codes.
# Millimetres are measured on the vector page declared above.
+1 -1
View File
@@ -598,7 +598,7 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
"source_blend": {"path": str(paths.source_blend),
"sha256": sha256_file(paths.source_blend)},
# The v1 keys stay for compatibility; `resolved` carries what the block and the flag
# overrides actually produced, because with ten knobs the flags alone no longer
# overrides actually produced, because the flags alone no longer
# describe the run. The design.yaml and params hashes pin the tuning either way.
"settings": {
"mode": STROKE_MODE,
+8 -2
View File
@@ -3,7 +3,7 @@
The stroke generator is one mode, ``camera_compensated_width``: each source colour of the colour
artwork is one cell, and the mark inside it is a capsule whose width is scaled from that cell's
footprint on the print page — so a cell the projection stretches gets a proportionally larger
mark — then clamped. That gives ten live knobs, and this module owns their names, defaults and
mark — then clamped. This module owns the live knobs, their defaults and
ranges on the host side.
Why the keys are exactly these
@@ -45,12 +45,14 @@ STROKE_DEFAULTS: dict[str, Any] = {
"length_ratio": 2.4,
"coverage_cap": 0.45,
"neighbourhood_mm": 0.5,
"clearance_mm": 0.0,
"candidate_count": 1,
}
STROKE_KEYS = frozenset(STROKE_DEFAULTS)
#: Integer-valued keys, validated as ints so a fractional seed cannot reach the palette lookup.
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels"})
STROKE_INT_KEYS = frozenset({"seed", "min_cell_pixels", "candidate_count"})
STROKE_PARAMS_FILENAME = "_stroke_pattern_params.json"
@@ -93,6 +95,10 @@ def validate_stroke_block(block: Any) -> list[str]:
if "min_cell_pixels" in block and block["min_cell_pixels"] < 1:
errors.append(f"{where}.min_cell_pixels: must be >= 1 (got {block['min_cell_pixels']!r})")
if "candidate_count" in block and block["candidate_count"] < 1:
errors.append(f"{where}.candidate_count: must be >= 1 (got {block['candidate_count']!r})")
if "clearance_mm" in block and block["clearance_mm"] < 0:
errors.append(f"{where}.clearance_mm: must be >= 0 (got {block['clearance_mm']!r})")
if "jitter_fraction" in block and not 0.0 <= block["jitter_fraction"] <= 1.0:
errors.append(
f"{where}.jitter_fraction: must be within [0, 1] (got {block['jitter_fraction']!r})"
+26
View File
@@ -0,0 +1,26 @@
"""Check laser-mark clearance in physical page coordinates."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "blender" / "scripts"))
from stroke_geometry import has_clearance # noqa: E402
def test_crossing_capsules_are_rejected() -> None:
existing = [((-0.2, 0), (0.2, 0), 0.1)]
assert not has_clearance(((0, -0.2), (0, 0.2), 0.1), existing, 0.03)
def test_near_parallel_capsules_need_edge_spacing() -> None:
existing = [((-0.2, 0), (0.2, 0), 0.1)]
assert not has_clearance(((-0.2, 0.22), (0.2, 0.22), 0.1), existing, 0.03)
assert has_clearance(((-0.2, 0.24), (0.2, 0.24), 0.1), existing, 0.03)
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)
+9 -3
View File
@@ -114,6 +114,8 @@ def test_boolean_is_not_a_number() -> None:
("coverage_cap", 0.0, "within (0, 1]"),
("coverage_cap", 1.5, "within (0, 1]"),
("neighbourhood_mm", 0.0, "must be > 0"),
("clearance_mm", -0.01, "must be >= 0"),
("candidate_count", 0, "must be >= 1"),
],
)
def test_range_rules(key: str, value, expected: str) -> None:
@@ -173,10 +175,14 @@ def test_written_params_round_trip(tmp_path: Path) -> None:
@pytest.mark.parametrize("sensor", ["finger", "finger-xynova"])
def test_shipped_revisions_declare_the_block_at_the_defaults(sensor: str) -> None:
"""The yaml documents the shipped values, so an untouched revision changes nothing."""
def test_shipped_revisions_declare_the_expected_block(sensor: str) -> None:
"""The YAML records both legacy and Xynova manufacturing settings."""
paths = RevisionPaths.from_revision_dir(repo_root() / "sensors" / sensor / "r01")
design = yaml.safe_load(paths.design_yaml.read_text(encoding="utf-8"))
block = design.get("stroke_pattern")
assert block is not None, f"{sensor}/r01 has no stroke_pattern block"
assert stroke_pattern.resolve_stroke_settings(block) == stroke_pattern.STROKE_DEFAULTS
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)
assert resolved == expected