为相机网格笔画增加双视图导出
沿用彩色纹理的视图归属、接缝留白和自动页面尺寸。 已验证双视图与单视图 Blender 导出、相关测试及文档门禁。
This commit is contained in:
@@ -53,6 +53,10 @@ from random_patch_dd02c import ( # noqa: E402
|
||||
write_svg_vector,
|
||||
)
|
||||
from patch_geometry import clip_rect # noqa: E402
|
||||
from camera_grid_stroke import DEFAULT_PARAMS as STROKE_DEFAULT_PARAMS # noqa: E402
|
||||
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 texture_merge import ( # noqa: E402
|
||||
MergeConfigError,
|
||||
MergePolicy,
|
||||
@@ -109,9 +113,39 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
description="Multi-view colored-patch texture generator (job JSON only)"
|
||||
)
|
||||
parser.add_argument("--job", required=True, type=Path, help="path to the job JSON")
|
||||
parser.add_argument("--stroke-params", type=Path, default=None)
|
||||
parser.add_argument("--stroke-output-dir", type=Path, default=None)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _stroke_job(job: dict[str, Any], params_path: Path, output_dir: Path):
|
||||
"""Point a multiview geometry job at stroke artifacts without changing the source job."""
|
||||
if job.get("schema") != SCHEMA:
|
||||
raise ConfigError(f"stroke views require {SCHEMA}")
|
||||
try:
|
||||
params = json.loads(params_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ConfigError(f"cannot read stroke params: {exc}") from exc
|
||||
if not isinstance(params, dict) or set(params) != set(STROKE_DEFAULT_PARAMS):
|
||||
raise ConfigError("stroke params must contain the complete camera-grid settings")
|
||||
if not 0 < params["width_ratio"] <= params["length_ratio"] < 1:
|
||||
raise ConfigError("stroke ratios require 0 < width_ratio <= length_ratio < 1")
|
||||
if params["orientation_mode"] not in ("random", "optimized") or params["proposals"] < 1:
|
||||
raise ConfigError("invalid stroke orientation settings")
|
||||
copied = dict(job)
|
||||
copied["pairs"] = []
|
||||
for pair in job["pairs"]:
|
||||
pair_id = pair["id"]
|
||||
copied["pairs"].append({
|
||||
**pair,
|
||||
"artifacts": {
|
||||
fmt: str(output_dir / f"stroke_texture_{pair_id}.{fmt}")
|
||||
for fmt in ARTIFACT_FORMATS
|
||||
},
|
||||
})
|
||||
return copied, params
|
||||
|
||||
|
||||
def _load_job(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
@@ -707,7 +741,17 @@ def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None:
|
||||
def main() -> int:
|
||||
args = parse_args(_argv_after_double_dash())
|
||||
try:
|
||||
config = _resolve_config(_load_job(args.job.resolve()))
|
||||
job = _load_job(args.job.resolve())
|
||||
stroke_params = None
|
||||
if args.stroke_params is not None:
|
||||
if args.stroke_output_dir is None:
|
||||
raise ConfigError("--stroke-params requires --stroke-output-dir")
|
||||
job, stroke_params = _stroke_job(
|
||||
job, args.stroke_params.resolve(), args.stroke_output_dir.resolve()
|
||||
)
|
||||
elif args.stroke_output_dir is not None:
|
||||
raise ConfigError("--stroke-output-dir requires --stroke-params")
|
||||
config = _resolve_config(job)
|
||||
except (ConfigError, OSError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
@@ -747,6 +791,15 @@ def main() -> int:
|
||||
start_time = time.time()
|
||||
policy: MergePolicy = config["policy"]
|
||||
priority = [pair["id"] for pair, _a, _b, _c in resolved]
|
||||
angle_map = None
|
||||
if stroke_params is not None and stroke_params["orientation_mode"] == "optimized":
|
||||
angle_map, report_data = optimize_angles(
|
||||
config["nx"], config["ny"], stroke_params["seed"],
|
||||
stroke_params["width_ratio"], stroke_params["length_ratio"],
|
||||
stroke_params["proposals"],
|
||||
)
|
||||
report_path = args.stroke_output_dir / "stroke_texture_optimization.json"
|
||||
report_path.write_text(json.dumps(report_data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
detail(
|
||||
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
|
||||
@@ -878,6 +931,9 @@ def main() -> int:
|
||||
pattern_cameras: dict[str, Any] = {}
|
||||
for pair, _rc, pattern_cam, _layer in resolved:
|
||||
pattern_cameras[pair["pattern_camera"]] = pattern_cam
|
||||
if stroke_params is not None and len(pattern_cameras) != 1:
|
||||
print("ERROR: stroke views require one shared pattern camera", file=sys.stderr)
|
||||
return 2
|
||||
uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved]
|
||||
pattern_geometry: dict[str, Any] | None = None
|
||||
pattern_meta: dict[str, Any] | None = None
|
||||
@@ -889,6 +945,7 @@ def main() -> int:
|
||||
fill_unresolved_leaves=True,
|
||||
seam_segments=seam_segments,
|
||||
seam_search_mm=gap["inset_mm"],
|
||||
capture_camera_fragments=stroke_params is not None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
@@ -905,6 +962,7 @@ def main() -> int:
|
||||
assert pattern_geometry is not None and pattern_meta is not None
|
||||
|
||||
pattern_triangles = list(pattern_meta["fragment_triangle"])
|
||||
stroke_outline_cache = {}
|
||||
|
||||
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
||||
emitted_stats: dict[str, dict[str, Any]] = {}
|
||||
@@ -912,10 +970,11 @@ def main() -> int:
|
||||
pair_id = pair["id"]
|
||||
uv_map = pair["uv_map"]
|
||||
detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}")
|
||||
if pair["seed"] is not None:
|
||||
if stroke_params is None and pair["seed"] is not None:
|
||||
np.random.seed(pair["seed"])
|
||||
detail(f" colour seed {pair['seed']}")
|
||||
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
patch_colors = (precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
if stroke_params is None else None)
|
||||
|
||||
# ---- page: derived per view, or inherited from texture_patch ----------------
|
||||
pair_page_mode = str(pair.get("page") or config["page"]["mode"])
|
||||
@@ -956,18 +1015,35 @@ def main() -> int:
|
||||
}
|
||||
|
||||
patches = pattern_geometry[uv_map]["patches"]
|
||||
camera_fragments = pattern_geometry[uv_map].get("camera_fragments")
|
||||
mesh_layer = pattern_geometry[uv_map]["mesh"]
|
||||
|
||||
def fragment_polygon(k: int, patches=patches, camera_fragments=camera_fragments):
|
||||
if stroke_params is None:
|
||||
return patches[k][2]
|
||||
i, j, fragment, loop_uvs = camera_fragments[k]
|
||||
key = (i, j)
|
||||
if key not in stroke_outline_cache:
|
||||
stroke_outline_cache[key] = capsule_in_cell(
|
||||
i, j, config["nx"], config["ny"], stroke_params["seed"],
|
||||
stroke_params["width_ratio"], stroke_params["length_ratio"],
|
||||
angle=None if angle_map is None else float(angle_map[j, i]),
|
||||
)
|
||||
return projected_stroke_uv(fragment, loop_uvs, stroke_outline_cache[key])
|
||||
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
|
||||
triangle_seam = pattern_meta.get("triangle_seam_distance_mm")
|
||||
gap_cfg = config["gap"]
|
||||
inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0
|
||||
is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges
|
||||
|
||||
def outside_margin(distance: float | None) -> bool:
|
||||
def outside_margin(distance: float | None, inset=inset) -> bool:
|
||||
"""True when this fragment/triangle is clear of the seam margin."""
|
||||
return distance is None or distance >= inset
|
||||
|
||||
def gap_planes(triangle_index: int):
|
||||
plane_cache: dict[int, list] = {}
|
||||
|
||||
def gap_planes(triangle_index: int, plane_cache=plane_cache,
|
||||
mesh_layer=mesh_layer, inset=inset):
|
||||
"""Cached UV half-planes cutting this triangle back from its seam edges."""
|
||||
cached = plane_cache.get(triangle_index)
|
||||
if cached is None:
|
||||
@@ -984,7 +1060,6 @@ def main() -> int:
|
||||
plane_cache[triangle_index] = cached
|
||||
return cached
|
||||
|
||||
plane_cache: dict[int, list] = {}
|
||||
owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id]
|
||||
if is_post:
|
||||
# Post-process: every owned fragment is kept, then clipped. A fragment is a
|
||||
@@ -994,7 +1069,10 @@ def main() -> int:
|
||||
emitted_patches = []
|
||||
trimmed = 0
|
||||
for k in keep:
|
||||
i, j, polygon = patches[k]
|
||||
i, j, _ = patches[k]
|
||||
polygon = fragment_polygon(k)
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
planes = gap_planes(pattern_triangles[k])
|
||||
if planes:
|
||||
before_area = seam_gap.polygon_area(polygon)
|
||||
@@ -1016,7 +1094,11 @@ def main() -> int:
|
||||
k for k in owned_here
|
||||
if outside_margin(None if fragment_seam is None else fragment_seam[k])
|
||||
]
|
||||
emitted_patches = [patches[k] for k in keep]
|
||||
emitted_patches = [
|
||||
(patches[k][0], patches[k][1], polygon)
|
||||
for k in keep
|
||||
if len(polygon := fragment_polygon(k)) >= 3
|
||||
]
|
||||
|
||||
owned_triangles = sorted(t for t, v in owner.items() if v == pair_id)
|
||||
kept_triangles = sorted({pattern_triangles[k] for k in keep})
|
||||
@@ -1056,7 +1138,8 @@ def main() -> int:
|
||||
"owned_triangles": len(owned_triangles),
|
||||
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
|
||||
"inked_triangles": len(kept_triangles),
|
||||
"inked_mm2": sum(meta["triangle_area_mm2"][t] for t in kept_triangles),
|
||||
"inked_mm2": (sum(meta["triangle_area_mm2"][t] for t in kept_triangles)
|
||||
if stroke_params is None else None),
|
||||
"emitted_fragments": len(emitted_patches),
|
||||
"total_fragments": len(patches),
|
||||
"gap_enabled": gap_cfg["enabled"],
|
||||
@@ -1067,7 +1150,7 @@ def main() -> int:
|
||||
"gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0,
|
||||
"gap_trimmed_polygons": trimmed_polygons if is_post else 0,
|
||||
"base_triangles": len(base_layer),
|
||||
"seed": pair["seed"],
|
||||
"seed": stroke_params["seed"] if stroke_params is not None else pair["seed"],
|
||||
"artifacts": pair["artifacts"],
|
||||
}
|
||||
summary = (
|
||||
@@ -1089,33 +1172,31 @@ def main() -> int:
|
||||
)
|
||||
report(summary)
|
||||
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
try:
|
||||
generate_patch_texture(
|
||||
obj,
|
||||
pattern_cameras[pair["pattern_camera"]],
|
||||
scene,
|
||||
depsgraph,
|
||||
nx=config["nx"],
|
||||
ny=config["ny"],
|
||||
r=config["color_r"],
|
||||
texture_width=texture_width,
|
||||
texture_height=texture_height,
|
||||
output_path=pair["artifacts"]["png"],
|
||||
svg_path=pair["artifacts"]["svg"],
|
||||
pdf_path=pair["artifacts"]["pdf"],
|
||||
vector_width_mm=page_width_mm,
|
||||
vector_height_mm=page_height_mm,
|
||||
uv_centre=uv_centre,
|
||||
cross_length=config["cross_length"],
|
||||
cross_thickness=config["cross_thickness"],
|
||||
cross_color=config["cross_color"],
|
||||
edge_sample_grid=config["edge_sample_grid"],
|
||||
uv_layer=uv_layer,
|
||||
image_name=f"PatchTexture_{pair_id}",
|
||||
patch_colors=patch_colors,
|
||||
geometry=filtered,
|
||||
)
|
||||
if stroke_params is None:
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
generate_patch_texture(
|
||||
obj, pattern_cameras[pair["pattern_camera"]], scene, depsgraph,
|
||||
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
||||
texture_width=texture_width, texture_height=texture_height,
|
||||
output_path=pair["artifacts"]["png"],
|
||||
svg_path=pair["artifacts"]["svg"],
|
||||
pdf_path=pair["artifacts"]["pdf"],
|
||||
vector_width_mm=page_width_mm, vector_height_mm=page_height_mm,
|
||||
uv_centre=uv_centre, cross_length=config["cross_length"],
|
||||
cross_thickness=config["cross_thickness"],
|
||||
cross_color=config["cross_color"],
|
||||
edge_sample_grid=config["edge_sample_grid"], uv_layer=uv_layer,
|
||||
image_name=f"PatchTexture_{pair_id}", patch_colors=patch_colors,
|
||||
geometry=filtered,
|
||||
)
|
||||
else:
|
||||
render_stroke_polygons(
|
||||
uv_map, base_layer, [poly for _, _, poly in emitted_patches],
|
||||
texture_width, texture_height,
|
||||
{"width_mm": page_width_mm, "height_mm": page_height_mm},
|
||||
Path(pair["artifacts"]["png"]).with_suffix(""),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
||||
@@ -1217,10 +1298,11 @@ def main() -> int:
|
||||
"hole_mm2": round(result["hole_mm2"], 6),
|
||||
"total_mm2": round(result["total_mm2"], 6),
|
||||
"hole_fraction": round(result["hole_fraction"], 8),
|
||||
"inked_mm2": round(sum(triangle_area[t] for t in inked_triangles), 6),
|
||||
"inked_fraction": round(
|
||||
"inked_mm2": (round(sum(triangle_area[t] for t in inked_triangles), 6)
|
||||
if stroke_params is None else None),
|
||||
"inked_fraction": (round(
|
||||
sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8
|
||||
) if result["total_mm2"] else 0.0,
|
||||
) if result["total_mm2"] else 0.0) if stroke_params is None else None,
|
||||
},
|
||||
"seam_margin": dict(config["gap"]),
|
||||
"views": [],
|
||||
@@ -1255,7 +1337,9 @@ def main() -> int:
|
||||
f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n"
|
||||
f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} "
|
||||
f"polar={local['polar_from_z_deg']:7.2f}\n"
|
||||
f" owned={entry['owned_mm2']:.3f}mm^2 inked={entry['inked_mm2']:.3f}mm^2"
|
||||
f" owned={entry['owned_mm2']:.3f}mm^2 "
|
||||
+ (f"inked={entry['inked_mm2']:.3f}mm^2" if stroke_params is None
|
||||
else "stroke ink area not measured")
|
||||
)
|
||||
pc = angle_report["pattern_camera"]
|
||||
detail(
|
||||
|
||||
Reference in New Issue
Block a user