为相机网格笔画增加双视图导出

沿用彩色纹理的视图归属、接缝留白和自动页面尺寸。
已验证双视图与单视图 Blender 导出、相关测试及文档门禁。
This commit is contained in:
ykcheungab
2026-10-06 11:01:45 +08:00
parent e8d231ec39
commit afe1ce39cb
16 changed files with 197989 additions and 86 deletions
+28 -21
View File
@@ -63,11 +63,9 @@ def _pixel_polygon(uv_polygon, width, height):
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)]
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None, artifact_prefix="camera_grid_stroke"):
width, height = settings["width"], settings["height"]
nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0
def render_stroke_polygons(uv_name, mesh, strokes, width, height, vector,
base):
"""Write the shared raster and vector artwork from UV polygons."""
image_name = f"CameraGridStroke_{uv_name}"
if image_name in bpy.data.images:
bpy.data.images.remove(bpy.data.images[image_name])
@@ -75,21 +73,14 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
pixels = list(image.pixels)
fill_background(pixels, width, height, (0, 0, 0, 1))
mesh_polygons = [_pixel_polygon(poly, width, height) for poly in geometry["mesh"]]
mesh_polygons = [_pixel_polygon(poly, width, height) for poly in mesh]
mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3]
for poly in mesh_polygons:
fill_polygon(None, pixels, width, height, poly, (1, 1, 1, 1))
shapes = []
outline_cache = {}
for i, j, fragment, loop_uvs in geometry["camera_fragments"]:
key = (i, j)
if key not in outline_cache:
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]))
uv_poly = projected_stroke_uv(fragment, loop_uvs, outline_cache[key])
pixel_poly = _pixel_polygon(uv_poly, width, height)
for polygon in strokes:
pixel_poly = _pixel_polygon(polygon, width, height)
if len(pixel_poly) < 3:
continue
shapes.append((pixel_poly, (0, 0, 0, 1)))
@@ -97,14 +88,9 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
image.pixels[:] = pixels
image.update()
label = artifact_prefix
if angle_map is not None and artifact_prefix == "camera_grid_stroke":
label += "_optimized"
base = output_dir / f"{label}_{uv_name}"
save_png_image(image, base.with_suffix(".png"))
page_mesh = _clipped_polygons(mesh_polygons, width, height)
page_shapes = _clipped_shapes(shapes, width, height)
vector = settings["vector"]
vector_args = (width, height, vector["width_mm"], vector["height_mm"])
write_svg_vector(base.with_suffix(".svg"), *vector_args,
page_mesh, page_shapes, [], (0, 0, 0, 1), uv_name,
@@ -112,7 +98,28 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
write_pdf_vector(base.with_suffix(".pdf"), *vector_args,
page_mesh, page_shapes, [], (0, 0, 0, 1),
artwork_label="stroke")
print(f"{uv_name}: {len(outline_cache)} cells, {len(shapes)} projected fragments")
print(f"{uv_name}: {len(shapes)} projected stroke fragments")
def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio,
angle_map=None, artifact_prefix="camera_grid_stroke"):
nx, ny = settings["nx"], settings["ny"]
seed = settings.get("seed") or 0
outline_cache = {}
strokes = []
for i, j, fragment, loop_uvs in geometry["camera_fragments"]:
key = (i, j)
if key not in outline_cache:
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]))
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)
def main():
+124 -40
View File
@@ -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(