新增基于摄像机网格投影的黑白笔画原型
沿用彩色块的网格与可见性投影,便于评估透视适配的笔画。已通过几何单元测试及 250/500 像素光流探针。
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""Convert camera-projected colour-patch cells to black capsule strokes.
|
||||
|
||||
Run with Blender's source geometry and the existing texture job. This first
|
||||
camera-space prototype preserves the colour pattern's grid and visibility
|
||||
projection. It does not enforce a physical minimum stroke width.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv
|
||||
from patch_geometry import clip_rect
|
||||
from patch_projection import build_patch_geometry
|
||||
from random_patch_dd02c import (
|
||||
_clipped_polygons, _clipped_shapes, _prepare_mesh_object, _require_object,
|
||||
_require_uv_layer, fill_background, fill_polygon, save_png_image,
|
||||
write_pdf_vector, write_svg_vector,
|
||||
)
|
||||
|
||||
|
||||
def _args():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", type=Path, required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--width-ratio", type=float, default=0.42)
|
||||
parser.add_argument("--length-ratio", type=float, default=0.84)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _pixel_polygon(uv_polygon, width, height):
|
||||
return [(u * (width - 1), v * (height - 1))
|
||||
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):
|
||||
width, height = settings["width"], settings["height"]
|
||||
nx, ny = settings["nx"], settings["ny"]
|
||||
seed = settings.get("seed") or 0
|
||||
image_name = f"CameraGridStroke_{uv_name}"
|
||||
if image_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[image_name])
|
||||
image = bpy.data.images.new(image_name, width=width, height=height, alpha=True)
|
||||
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 = [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)
|
||||
uv_poly = projected_stroke_uv(fragment, loop_uvs, outline_cache[key])
|
||||
pixel_poly = _pixel_polygon(uv_poly, width, height)
|
||||
if len(pixel_poly) < 3:
|
||||
continue
|
||||
shapes.append((pixel_poly, (0, 0, 0, 1)))
|
||||
fill_polygon(None, pixels, width, height, pixel_poly, (0, 0, 0, 1))
|
||||
|
||||
image.pixels[:] = pixels
|
||||
image.update()
|
||||
base = output_dir / f"camera_grid_stroke_{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,
|
||||
artwork_label="stroke")
|
||||
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")
|
||||
|
||||
|
||||
def main():
|
||||
args = _args()
|
||||
if not 0 < args.width_ratio <= args.length_ratio < 1:
|
||||
raise ValueError("require 0 < width-ratio <= length-ratio < 1")
|
||||
job = json.loads(args.job.read_text(encoding="utf-8"))
|
||||
settings = job["settings"]
|
||||
obj = _prepare_mesh_object(job["objects"]["mesh"])
|
||||
cam = _require_object(job["objects"]["camera"], "CAMERA", "Camera")
|
||||
names = [entry["uv_map"] for entry in job["outputs"]]
|
||||
for name in names:
|
||||
_require_uv_layer(obj, name)
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.output_dir = args.output_dir.resolve()
|
||||
geometry = build_patch_geometry(
|
||||
obj, cam, bpy.context.scene, bpy.context.evaluated_depsgraph_get(),
|
||||
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
||||
capture_camera_fragments=True,
|
||||
)
|
||||
for name in names:
|
||||
_render(name, geometry[name], settings, args.output_dir,
|
||||
args.width_ratio, args.length_ratio)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Convex camera-cell stroke shapes and perspective-preserving clipping."""
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from patch_geometry import clip_halfspace, interpolate_uv
|
||||
|
||||
|
||||
def capsule_in_cell(i, j, nx, ny, seed, width_ratio=0.42, length_ratio=0.84):
|
||||
"""Return a round-ended stroke wholly inside one normalized camera cell."""
|
||||
if not 0 < width_ratio <= length_ratio < 1:
|
||||
raise ValueError("require 0 < width_ratio <= length_ratio < 1")
|
||||
rng = random.Random((seed * 1000003 + i * 9176 + j * 6113) & 0xFFFFFFFF)
|
||||
angle = rng.uniform(0, math.pi)
|
||||
radius = width_ratio / 2
|
||||
half_body = (length_ratio - width_ratio) / 2
|
||||
outline = []
|
||||
for centre, start in ((half_body, -math.pi / 2), (-half_body, math.pi / 2)):
|
||||
for step in range(9):
|
||||
theta = start + math.pi * step / 8
|
||||
x = centre + radius * math.cos(theta)
|
||||
y = radius * math.sin(theta)
|
||||
xr = x * math.cos(angle) - y * math.sin(angle)
|
||||
yr = x * math.sin(angle) + y * math.cos(angle)
|
||||
outline.append(((i + 0.5 + xr) / nx, (j + 0.5 + yr) / ny))
|
||||
return outline
|
||||
|
||||
|
||||
def clip_to_stroke(fragment, outline):
|
||||
"""Clip a projected triangle while retaining its perspective attributes."""
|
||||
points = fragment
|
||||
for a, b in zip(outline, outline[1:] + outline[:1]):
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
points = clip_halfspace(
|
||||
points,
|
||||
lambda p, a=a, dx=dx, dy=dy: dx * (p[1] - a[1]) - dy * (p[0] - a[0]),
|
||||
)
|
||||
if len(points) < 3:
|
||||
return []
|
||||
return points
|
||||
|
||||
|
||||
def projected_stroke_uv(fragment, loop_uvs, outline):
|
||||
"""Resolve a clipped camera stroke through the same UV interpolation as patches."""
|
||||
return [interpolate_uv(point, loop_uvs)
|
||||
for point in clip_to_stroke(fragment, outline)]
|
||||
@@ -37,7 +37,7 @@ def _min_seam_distance(point, segments):
|
||||
|
||||
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid,
|
||||
fill_unresolved_leaves=False, seam_segments=None,
|
||||
seam_search_mm=0.0):
|
||||
seam_search_mm=0.0, capture_camera_fragments=False):
|
||||
"""Evaluated loop-triangle fragments for one camera, per UV target.
|
||||
|
||||
``fill_unresolved_leaves`` accepts fragments whose visibility could not be resolved at
|
||||
@@ -72,6 +72,9 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
|
||||
origin = camera.matrix_world.translation
|
||||
forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
|
||||
output = {name: {'mesh': [], 'patches': []} for name in uv_names}
|
||||
if capture_camera_fragments:
|
||||
for name in uv_names:
|
||||
output[name]['camera_fragments'] = []
|
||||
ray_count = 0
|
||||
|
||||
# Triangle-level and fragment-level records, shared by every UV target: the
|
||||
@@ -184,6 +187,10 @@ def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_samp
|
||||
for name in uv_names:
|
||||
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
|
||||
output[name]['patches'].append((i, j, polygon))
|
||||
if capture_camera_fragments:
|
||||
output[name]['camera_fragments'].append(
|
||||
(i, j, fragment, uvs[name])
|
||||
)
|
||||
|
||||
output['_meta'] = {
|
||||
'triangle_count': len(mesh.loop_triangles),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Run with python -m unittest discover -s blender/scripts -p test_camera_grid_stroke_geometry.py."""
|
||||
|
||||
import unittest
|
||||
|
||||
from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv
|
||||
from patch_geometry import area, project_triangle
|
||||
|
||||
|
||||
class CameraGridStrokeGeometryTests(unittest.TestCase):
|
||||
def test_each_stroke_stays_inside_its_camera_cell(self):
|
||||
for i, j in ((0, 0), (10, 20), (79, 79)):
|
||||
outline = capsule_in_cell(i, j, 80, 80, 7)
|
||||
self.assertGreater(area(outline), 0)
|
||||
self.assertTrue(all(i / 80 < u < (i + 1) / 80 and
|
||||
j / 80 < v < (j + 1) / 80
|
||||
for u, v in outline))
|
||||
|
||||
def test_camera_clipping_interpolates_uv_at_new_vertices(self):
|
||||
triangle = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (0, 1, 0, 1)])
|
||||
outline = capsule_in_cell(0, 0, 1, 1, 7)
|
||||
uv = projected_stroke_uv(triangle, [(0, 0), (1, 0), (0.5, 1)], outline)
|
||||
self.assertGreater(len(uv), 3)
|
||||
self.assertTrue(all(0 <= u <= 1 and 0 <= v <= 1 for u, v in uv))
|
||||
self.assertGreater(area(uv), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user