增加笔画生产尺寸与数量报告
按最终矢量页面汇总每个相机单元的笔画包络,并标出低于 0.2 mm 的数量。 已核对 SVG、清单哈希和 Blender 导出,175 项相关测试通过。
This commit is contained in:
@@ -246,8 +246,14 @@ The stroke output is independent of the color artwork pixels. The revision's
|
||||
mode; `--output-dir` selects a fresh destination. `--views` uses the existing
|
||||
`texture-views` job's triangle ownership, seam margin and
|
||||
per-view page sizes. The command writes PNG/SVG/PDF, resolved settings and a hash manifest.
|
||||
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
|
||||
measure the production SVG before manufacturing.
|
||||
It also writes `stroke_texture_production_report.json`: per-view stroke and
|
||||
vector-fragment counts, minimum/median/maximum printed width and length in mm,
|
||||
the number of strokes below the 0.2 mm width target, and an estimated black
|
||||
area ratio. One visible camera cell counts as one stroke even when UV triangles
|
||||
split its SVG outline. Width and length are the short and long sides of the
|
||||
minimum-area rectangle around the emitted fragments on the print page. This
|
||||
envelope does not certify the narrowest local feature or guarantee 0.2 mm
|
||||
manufacturing width; inspect the production SVG before manufacturing.
|
||||
|
||||
## Inspect printability (advisory)
|
||||
|
||||
|
||||
@@ -24,12 +24,14 @@ from random_patch_dd02c import (
|
||||
_prepare_mesh_object,
|
||||
_require_object,
|
||||
_require_uv_layer,
|
||||
clip_polygon_to_page,
|
||||
fill_background,
|
||||
fill_polygon,
|
||||
save_png_image,
|
||||
write_pdf_vector,
|
||||
write_svg_vector,
|
||||
)
|
||||
from stroke_production_report import summarize_view, write_report
|
||||
|
||||
MODE = "camera_grid_capsules"
|
||||
DEFAULT_PARAMS = {
|
||||
@@ -64,8 +66,8 @@ def _pixel_polygon(uv_polygon, width, height):
|
||||
|
||||
|
||||
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
|
||||
base):
|
||||
"""Write the shared raster and vector artwork from UV polygons."""
|
||||
base, view_id=None):
|
||||
"""Write artwork and measure the same page-clipped vector polygons."""
|
||||
image_name = f"CameraGridStroke_{uv_name}"
|
||||
if image_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[image_name])
|
||||
@@ -79,11 +81,13 @@ def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
|
||||
fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1))
|
||||
|
||||
shapes = []
|
||||
for polygon in strokes:
|
||||
labelled_shapes = []
|
||||
for i, j, polygon in strokes:
|
||||
pixel_poly = _pixel_polygon(polygon, width, height)
|
||||
if len(pixel_poly) < 3:
|
||||
continue
|
||||
shapes.append((pixel_poly, (0, 0, 0, 1)))
|
||||
labelled_shapes.append((i, j, pixel_poly))
|
||||
fill_polygon(None, pixels, width, height, pixel_poly, (0, 0, 0, 1))
|
||||
|
||||
image.pixels[:] = pixels
|
||||
@@ -99,6 +103,16 @@ def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
|
||||
page_mesh, page_shapes, [], (0, 0, 0, 1),
|
||||
artwork_label="stroke")
|
||||
print(f"{uv_name}: {len(shapes)} projected stroke fragments")
|
||||
report_shapes = [
|
||||
(i, j, [(round(x, 4), round(y, 4)) for x, y in clipped])
|
||||
for i, j, polygon in labelled_shapes
|
||||
if (clipped := clip_polygon_to_page(polygon, width, height))
|
||||
]
|
||||
return summarize_view(
|
||||
view_id or uv_name, uv_name, report_shapes, page_mesh,
|
||||
(width, height), (vector["width_mm"], vector["height_mm"]),
|
||||
base.with_suffix(".svg"),
|
||||
)
|
||||
|
||||
|
||||
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
|
||||
@@ -113,13 +127,14 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
|
||||
outline_cache[key] = capsule_in_cell(
|
||||
i, j, nx, ny, seed, width_ratio, length_ratio,
|
||||
angle=None if angle_map is None else float(angle_map[j, i]))
|
||||
strokes.append(projected_stroke_uv(fragment, loop_uvs, outline_cache[key]))
|
||||
strokes.append((i, j, projected_stroke_uv(fragment, loop_uvs, outline_cache[key])))
|
||||
label = artifact_prefix
|
||||
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
|
||||
label += "_optimized"
|
||||
base = output_dir / f"{label}_{uv_name}"
|
||||
render_stroke_polygons(uv_name, geometry["mesh"], strokes,
|
||||
settings["width"], settings["height"], settings["vector"], base)
|
||||
return render_stroke_polygons(uv_name, geometry["mesh"], strokes,
|
||||
settings["width"], settings["height"], settings["vector"],
|
||||
base)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -164,10 +179,14 @@ def main():
|
||||
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
||||
capture_camera_fragments=True,
|
||||
)
|
||||
views = []
|
||||
for name in names:
|
||||
_render(name, geometry[name], settings, args.output_dir,
|
||||
params["width_ratio"], params["length_ratio"], angle_map,
|
||||
args.artifact_prefix)
|
||||
views.append(_render(name, geometry[name], settings, args.output_dir,
|
||||
params["width_ratio"], params["length_ratio"], angle_map,
|
||||
args.artifact_prefix))
|
||||
report_path = args.output_dir / f"{args.artifact_prefix}_production_report.json"
|
||||
write_report(report_path, args.job.resolve(), params, views)
|
||||
print(f"Stroke production report: {report_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -57,6 +57,8 @@ from camera_grid_stroke import DEFAULT_PARAMS as STROKE_DEFAULT_PARAMS # noqa:
|
||||
from camera_grid_stroke import render_stroke_polygons # noqa: E402
|
||||
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv # noqa: E402
|
||||
from camera_grid_stroke_optimizer import optimize_angles # noqa: E402
|
||||
from stroke_production_report import REPORT_FILENAME as STROKE_REPORT_FILENAME # noqa: E402
|
||||
from stroke_production_report import write_report as write_stroke_report # noqa: E402
|
||||
from texture_merge import ( # noqa: E402
|
||||
MergeConfigError,
|
||||
MergePolicy,
|
||||
@@ -966,6 +968,7 @@ def main() -> int:
|
||||
|
||||
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
||||
emitted_stats: dict[str, dict[str, Any]] = {}
|
||||
stroke_views = []
|
||||
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
||||
pair_id = pair["id"]
|
||||
uv_map = pair["uv_map"]
|
||||
@@ -1191,12 +1194,13 @@ def main() -> int:
|
||||
geometry=filtered,
|
||||
)
|
||||
else:
|
||||
render_stroke_polygons(
|
||||
uv_map, base_layer, [poly for _, _, poly in emitted_patches],
|
||||
stroke_views.append(render_stroke_polygons(
|
||||
uv_map, base_layer, emitted_patches,
|
||||
texture_width, texture_height,
|
||||
{"width_mm": page_width_mm, "height_mm": page_height_mm},
|
||||
Path(pair["artifacts"]["png"]).with_suffix(""),
|
||||
)
|
||||
view_id=pair_id,
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
||||
@@ -1407,6 +1411,12 @@ def main() -> int:
|
||||
_write_ground_truth(truth_path, truth)
|
||||
detail(f" ground truth -> {truth_path}")
|
||||
|
||||
if stroke_params is not None:
|
||||
stroke_report_path = truth_path.parent / STROKE_REPORT_FILENAME
|
||||
write_stroke_report(stroke_report_path, args.job.resolve(), stroke_params,
|
||||
stroke_views)
|
||||
report(f"stroke production report -> {stroke_report_path}")
|
||||
|
||||
# The artifact inventory is owned by the HOST, which is the only side that can verify the
|
||||
# files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice.
|
||||
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Measure final camera-grid stroke polygons on their physical print pages.
|
||||
|
||||
One camera cell is one intended mark. Projection, UV triangles, view ownership and
|
||||
seam clipping can split it into many vector polygons; this module groups those
|
||||
pieces before measuring a mark's planar envelope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPORT_SCHEMA = "orisys-sensor-design/stroke-production-report-v1"
|
||||
REPORT_FILENAME = "stroke_texture_production_report.json"
|
||||
WIDTH_TARGET_MM = 0.2
|
||||
|
||||
|
||||
def _area(points):
|
||||
return abs(sum(x * points[(k + 1) % len(points)][1]
|
||||
- points[(k + 1) % len(points)][0] * y
|
||||
for k, (x, y) in enumerate(points))) / 2
|
||||
|
||||
|
||||
def _hull(points):
|
||||
unique = sorted(set(points))
|
||||
if len(unique) < 3:
|
||||
return unique
|
||||
|
||||
def cross(a, b, c):
|
||||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
|
||||
lower = []
|
||||
for point in unique:
|
||||
while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0:
|
||||
lower.pop()
|
||||
lower.append(point)
|
||||
upper = []
|
||||
for point in reversed(unique):
|
||||
while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0:
|
||||
upper.pop()
|
||||
upper.append(point)
|
||||
return lower[:-1] + upper[:-1]
|
||||
|
||||
|
||||
def _envelope(points):
|
||||
"""Short and long sides of the minimum-area rotated rectangle in mm."""
|
||||
hull = _hull(points)
|
||||
if len(hull) < 3:
|
||||
return (0.0, math.dist(*hull) if len(hull) == 2 else 0.0)
|
||||
best = None
|
||||
for a, b in zip(hull, hull[1:] + hull[:1]):
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
edge = math.hypot(dx, dy)
|
||||
if edge == 0:
|
||||
continue
|
||||
ux, uy = dx / edge, dy / edge
|
||||
along = [x * ux + y * uy for x, y in hull]
|
||||
across = [-x * uy + y * ux for x, y in hull]
|
||||
side_a = max(along) - min(along)
|
||||
side_b = max(across) - min(across)
|
||||
candidate = (side_a * side_b, min(side_a, side_b), max(side_a, side_b))
|
||||
if best is None or candidate < best:
|
||||
best = candidate
|
||||
return best[1:]
|
||||
|
||||
|
||||
def _stats(values):
|
||||
if not values:
|
||||
return {"min": None, "p5": None, "median": None, "p95": None, "max": None}
|
||||
ordered = sorted(values)
|
||||
|
||||
def percentile(fraction):
|
||||
position = (len(ordered) - 1) * fraction
|
||||
lo = math.floor(position)
|
||||
hi = math.ceil(position)
|
||||
return ordered[lo] + (ordered[hi] - ordered[lo]) * (position - lo)
|
||||
|
||||
return {"min": round(ordered[0], 6), "p5": round(percentile(0.05), 6),
|
||||
"median": round(percentile(0.5), 6), "p95": round(percentile(0.95), 6),
|
||||
"max": round(ordered[-1], 6)}
|
||||
|
||||
|
||||
def summarize_view(view_id, uv_map, polygons, mesh_polygons, raster, page_mm, svg_path):
|
||||
"""Measure page-clipped pixel polygons labelled with their source camera cell."""
|
||||
width_px, height_px = raster
|
||||
width_mm, height_mm = page_mm
|
||||
sx, sy = width_mm / width_px, height_mm / height_px
|
||||
cells = defaultdict(lambda: {"points": [], "fragments": 0})
|
||||
ink_area = 0.0
|
||||
fragment_count = 0
|
||||
for i, j, polygon in polygons:
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
scaled = [(x * sx, y * sy) for x, y in polygon]
|
||||
area = _area(scaled)
|
||||
if area <= 0:
|
||||
continue
|
||||
item = cells[(i, j)]
|
||||
item["points"].extend(scaled)
|
||||
item["fragments"] += 1
|
||||
fragment_count += 1
|
||||
ink_area += area
|
||||
marks = []
|
||||
for (i, j), item in cells.items():
|
||||
width, length = _envelope(item["points"])
|
||||
marks.append({"cell": [i, j], "width_mm": width, "length_mm": length,
|
||||
"fragments": item["fragments"]})
|
||||
marks.sort(key=lambda item: (item["width_mm"], item["cell"]))
|
||||
base_area = sum(_area([(x * sx, y * sy) for x, y in polygon])
|
||||
for polygon in mesh_polygons if len(polygon) >= 3)
|
||||
return {
|
||||
"id": view_id,
|
||||
"uv_map": uv_map,
|
||||
"svg": {"path": str(svg_path),
|
||||
"sha256": hashlib.sha256(Path(svg_path).read_bytes()).hexdigest()},
|
||||
"page_mm": [width_mm, height_mm],
|
||||
"raster_px": [width_px, height_px],
|
||||
"stroke_count": len(marks),
|
||||
"vector_fragment_count": fragment_count,
|
||||
"width_mm": _stats([mark["width_mm"] for mark in marks]),
|
||||
"length_mm": _stats([mark["length_mm"] for mark in marks]),
|
||||
"width_below_target_count": sum(
|
||||
mark["width_mm"] < WIDTH_TARGET_MM - 1e-9 for mark in marks
|
||||
),
|
||||
"smallest_width_strokes": [
|
||||
{**mark, "width_mm": round(mark["width_mm"], 6),
|
||||
"length_mm": round(mark["length_mm"], 6)} for mark in marks[:10]
|
||||
],
|
||||
"camera_cells": [mark["cell"] for mark in sorted(marks, key=lambda m: m["cell"])],
|
||||
"summed_black_polygon_area_mm2": round(ink_area, 6),
|
||||
"summed_white_base_area_mm2": round(base_area, 6),
|
||||
"black_to_base_area_ratio_estimate": (
|
||||
round(ink_area / base_area, 6) if base_area > 0 else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def write_report(path, source_job, parameters, views):
|
||||
"""Write one report with distinct per-view and cross-view stroke counts."""
|
||||
counts = defaultdict(int)
|
||||
for view in views:
|
||||
for cell in view["camera_cells"]:
|
||||
counts[tuple(cell)] += 1
|
||||
job = json.loads(Path(source_job).read_text(encoding="utf-8"))
|
||||
nx, ny = job["settings"]["nx"], job["settings"]["ny"]
|
||||
report = {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"units": {"width": "mm", "length": "mm", "area": "mm2"},
|
||||
"measurement_method": (
|
||||
"Each visible camera cell is one stroke per view. SVG-bound polygons are clipped "
|
||||
"to the print page, grouped by camera cell, then measured as the short and long "
|
||||
"sides of their minimum-area rotated convex-hull rectangle. These are planar "
|
||||
"envelope dimensions, not a guarantee of minimum local feature width. Summed "
|
||||
"polygon areas may double-count overlaps and are only an occupancy estimate."
|
||||
),
|
||||
"width_target_mm": WIDTH_TARGET_MM,
|
||||
"source_job": {"path": str(source_job),
|
||||
"sha256": hashlib.sha256(Path(source_job).read_bytes()).hexdigest()},
|
||||
"parameters": dict(parameters),
|
||||
"camera_grid": [nx, ny],
|
||||
"grid_cell_count": nx * ny,
|
||||
"total_view_strokes": sum(view["stroke_count"] for view in views),
|
||||
"unique_camera_cell_count": len(counts),
|
||||
"cells_in_multiple_views": sum(n > 1 for n in counts.values()),
|
||||
"views": views,
|
||||
}
|
||||
Path(path).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
return report
|
||||
@@ -72,6 +72,10 @@
|
||||
"_print_views.json": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\_print_views.json",
|
||||
"sha256": "abd5938d35fc8863b91753b2915fb5b39d261f0920286ef3eca2cc4e6a6c89bf"
|
||||
},
|
||||
"stroke_texture_production_report.json": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes-views\\stroke_texture_production_report.json",
|
||||
"sha256": "d4286f0b1f446755a623aea297a34c470d4d87c92e2e4a79dc63e162009e08a9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11335
File diff suppressed because it is too large
Load Diff
@@ -62,5 +62,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"auxiliary": {
|
||||
"stroke_texture_production_report.json": {
|
||||
"path": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\strokes\\stroke_texture_production_report.json",
|
||||
"sha256": "c128562a57d78291db4d8157a89eabbc8af179fec3675ed81ab8f8eaa7ee3e44"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -557,8 +557,11 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
}
|
||||
manifest_path = output_dir / "stroke_texture_manifest.json"
|
||||
temporary = manifest_path.with_suffix(".json.tmp")
|
||||
auxiliary = ([output_dir / "_multiview_ground_truth.json",
|
||||
output_dir / "_print_views.json"] if args.views else [])
|
||||
production_report_path = output_dir / "stroke_texture_production_report.json"
|
||||
auxiliary = [production_report_path]
|
||||
if args.views:
|
||||
auxiliary.extend((output_dir / "_multiview_ground_truth.json",
|
||||
output_dir / "_print_views.json"))
|
||||
if settings["orientation_mode"] == "optimized":
|
||||
auxiliary.append(output_dir / "stroke_texture_optimization.json")
|
||||
if (manifest_path.exists() or temporary.exists()
|
||||
@@ -585,6 +588,21 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
for path in missing:
|
||||
print(f"ERROR: stroke artifact missing or empty: {path}", file=sys.stderr)
|
||||
return 3
|
||||
try:
|
||||
production_report = json.loads(production_report_path.read_text(encoding="utf-8"))
|
||||
if production_report["schema"] != "orisys-sensor-design/stroke-production-report-v1":
|
||||
raise ValueError("unexpected production report schema")
|
||||
measured = production_report["views"]
|
||||
if [view["id"] for view in measured] != source_ids:
|
||||
raise ValueError("production report views differ from the source job")
|
||||
for view in measured:
|
||||
if view["svg"]["sha256"] != sha256_file(outputs[view["id"]]["svg"]):
|
||||
raise ValueError(f"production report SVG hash differs for {view['id']}")
|
||||
if view["stroke_count"] < 1 or view["width_mm"]["min"] is None:
|
||||
raise ValueError(f"production report has no measurable strokes for {view['id']}")
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
print(f"ERROR: invalid stroke production report: {exc}", file=sys.stderr)
|
||||
return 3
|
||||
manifest = {
|
||||
"schema": "orisys-sensor-design/stroke-texture-manifest-v2",
|
||||
"revision_dir": str(paths.root),
|
||||
@@ -613,11 +631,22 @@ def cmd_stroke_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
temporary.replace(manifest_path)
|
||||
artifacts = [path for entry in outputs.values() for path in entry.values()]
|
||||
if console.get_level() == console.QUIET:
|
||||
console.artifact_paths([*artifacts, manifest_path])
|
||||
console.artifact_paths([*artifacts, production_report_path, manifest_path])
|
||||
else:
|
||||
console.report(f"OK: wrote {len(artifacts)} stroke texture artifacts to {output_dir}")
|
||||
for path in artifacts:
|
||||
console.report(f" {path}")
|
||||
for view in measured:
|
||||
width = view["width_mm"]
|
||||
length = view["length_mm"]
|
||||
console.report(
|
||||
f" {view['id']}: {view['stroke_count']} strokes, "
|
||||
f"width {width['min']:.4g}-{width['max']:.4g} mm, "
|
||||
f"length {length['min']:.4g}-{length['max']:.4g} mm, "
|
||||
f"{view['width_below_target_count']} below "
|
||||
f"{production_report['width_target_mm']:g} mm width target"
|
||||
)
|
||||
console.report(f" production report: {production_report_path}")
|
||||
console.report(f" manifest: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Production dimensions are measured per visible stroke, not per UV triangle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "blender" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from stroke_production_report import summarize_view, write_report # noqa: E402
|
||||
|
||||
|
||||
def test_fragments_of_one_cell_are_one_stroke(tmp_path):
|
||||
svg = tmp_path / "stroke_texture_uv_1.svg"
|
||||
svg.write_text("<svg/>", encoding="utf-8")
|
||||
# 100 px/mm; the first 0.6 x 0.2 mm mark is split by a UV triangle.
|
||||
polygons = [
|
||||
(1, 2, [(10, 10), (40, 10), (40, 30), (10, 30)]),
|
||||
(1, 2, [(40, 10), (70, 10), (70, 30), (40, 30)]),
|
||||
(3, 4, [(100, 10), (130, 10), (130, 20), (100, 20)]),
|
||||
]
|
||||
mesh = [[(0, 0), (200, 0), (200, 200), (0, 200)]]
|
||||
view = summarize_view("uv_1", "uv_1", polygons, mesh,
|
||||
(1000, 1000), (10, 10), svg)
|
||||
assert view["stroke_count"] == 2
|
||||
assert view["vector_fragment_count"] == 3
|
||||
assert view["width_mm"]["min"] == pytest.approx(0.1)
|
||||
assert view["width_mm"]["max"] == pytest.approx(0.2)
|
||||
assert view["length_mm"]["max"] == pytest.approx(0.6)
|
||||
assert view["width_below_target_count"] == 1
|
||||
assert view["black_to_base_area_ratio_estimate"] == pytest.approx(0.0375)
|
||||
|
||||
|
||||
def test_rotated_stroke_dimensions_use_print_page_units(tmp_path):
|
||||
svg = tmp_path / "stroke.svg"
|
||||
svg.write_text("<svg/>", encoding="utf-8")
|
||||
angle = math.radians(31)
|
||||
ux, uy = math.cos(angle), math.sin(angle)
|
||||
vx, vy = -uy, ux
|
||||
# 2 px/mm horizontally, 4 px/mm vertically: convert the mm corners to pixels.
|
||||
corners = [(sx * 0.3 * ux + sy * 0.1 * vx,
|
||||
sx * 0.3 * uy + sy * 0.1 * vy) for sx, sy in
|
||||
((-1, -1), (1, -1), (1, 1), (-1, 1))]
|
||||
pixels = [(x * 2 + 50, y * 4 + 50) for x, y in corners]
|
||||
view = summarize_view("compute", "compute", [(7, 8, pixels)], [],
|
||||
(200, 400), (100, 100), svg)
|
||||
assert view["width_mm"]["min"] == pytest.approx(0.2)
|
||||
assert view["length_mm"]["max"] == pytest.approx(0.6)
|
||||
|
||||
|
||||
def test_report_counts_split_camera_cells_across_views(tmp_path):
|
||||
source = tmp_path / "job.json"
|
||||
source.write_text('{"settings": {"nx": 10, "ny": 8}}', encoding="utf-8")
|
||||
a = {"camera_cells": [[1, 2], [3, 4]], "stroke_count": 2}
|
||||
b = {"camera_cells": [[3, 4], [5, 6]], "stroke_count": 2}
|
||||
output = tmp_path / "report.json"
|
||||
report = write_report(output, source, {"seed": 7}, [a, b])
|
||||
assert report["unique_camera_cell_count"] == 3
|
||||
assert report["cells_in_multiple_views"] == 1
|
||||
assert report["total_view_strokes"] == 4
|
||||
assert report["camera_grid"] == [10, 8]
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["width_target_mm"] == 0.2
|
||||
@@ -12,6 +12,21 @@ from orisys_sensor_design.manifest import RevisionPaths, sha256_file
|
||||
REVISION = Path(__file__).resolve().parents[1] / "sensors" / "finger-xynova" / "r01"
|
||||
|
||||
|
||||
def _production_report(output_dir: Path, ids: tuple[str, ...]) -> None:
|
||||
views = []
|
||||
for view_id in ids:
|
||||
svg = output_dir / f"stroke_texture_{view_id}.svg"
|
||||
views.append({
|
||||
"id": view_id, "stroke_count": 3, "width_mm": {"min": 0.1, "max": 0.3},
|
||||
"length_mm": {"min": 0.2, "max": 0.5}, "width_below_target_count": 1,
|
||||
"svg": {"sha256": sha256_file(svg)},
|
||||
})
|
||||
(output_dir / "stroke_texture_production_report.json").write_text(json.dumps({
|
||||
"schema": "orisys-sensor-design/stroke-production-report-v1",
|
||||
"width_target_mm": 0.2, "views": views,
|
||||
}), encoding="utf-8")
|
||||
|
||||
|
||||
def _args(job: Path, output_dir: Path, views=False) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
views=views, source_job=job, output_dir=output_dir,
|
||||
@@ -42,6 +57,7 @@ def test_exports_from_geometry_job(tmp_path, monkeypatch):
|
||||
calls.append((script, blend, args))
|
||||
for fmt in ("png", "svg", "pdf"):
|
||||
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
||||
_production_report(output_dir, ("compute",))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||
@@ -55,6 +71,7 @@ def test_exports_from_geometry_job(tmp_path, monkeypatch):
|
||||
assert manifest["schema"] == "orisys-sensor-design/stroke-texture-manifest-v2"
|
||||
assert manifest["source_job"]["sha256"] == sha256_file(job)
|
||||
assert set(manifest["outputs"][0]["artifacts"]) == {"png", "svg", "pdf"}
|
||||
assert "stroke_texture_production_report.json" in manifest["auxiliary"]
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 2
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -78,6 +95,7 @@ def test_multiview_exports_owned_pairs_without_colour_artwork(tmp_path, monkeypa
|
||||
(output_dir / f"stroke_texture_{pair}.{fmt}").write_bytes(pair.encode())
|
||||
for name in ("_multiview_ground_truth.json", "_print_views.json"):
|
||||
(output_dir / name).write_text("{}", encoding="utf-8")
|
||||
_production_report(output_dir, ("uv_1", "uv_2"))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||
@@ -87,5 +105,24 @@ def test_multiview_exports_owned_pairs_without_colour_artwork(tmp_path, monkeypa
|
||||
manifest = json.loads((output_dir / "stroke_texture_manifest.json").read_text())
|
||||
assert manifest["views"] is True
|
||||
assert [entry["id"] for entry in manifest["outputs"]] == ["uv_1", "uv_2"]
|
||||
assert set(manifest["auxiliary"]) == {"_multiview_ground_truth.json", "_print_views.json"}
|
||||
assert set(manifest["auxiliary"]) == {
|
||||
"_multiview_ground_truth.json", "_print_views.json",
|
||||
"stroke_texture_production_report.json",
|
||||
}
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_rejects_report_for_a_different_svg(tmp_path, monkeypatch):
|
||||
job = _job(tmp_path)
|
||||
output_dir = tmp_path / "strokes"
|
||||
|
||||
def run_blender(_script, _blend, _args):
|
||||
for fmt in ("png", "svg", "pdf"):
|
||||
(output_dir / f"stroke_texture_compute.{fmt}").write_bytes(fmt.encode())
|
||||
_production_report(output_dir, ("compute",))
|
||||
(output_dir / "stroke_texture_compute.svg").write_bytes(b"changed")
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(cli, "_run_blender_script", run_blender)
|
||||
assert cli.cmd_stroke_texture(REVISION, _args(job, output_dir)) == 3
|
||||
assert not (output_dir / "stroke_texture_manifest.json").exists()
|
||||
|
||||
Reference in New Issue
Block a user