diff --git a/blender/scripts/camera_grid_stroke.py b/blender/scripts/camera_grid_stroke.py index 115d25d..36a6525 100644 --- a/blender/scripts/camera_grid_stroke.py +++ b/blender/scripts/camera_grid_stroke.py @@ -15,6 +15,7 @@ import bpy sys.path.insert(0, str(Path(__file__).resolve().parent)) from camera_grid_stroke_geometry import capsule_in_cell, projected_stroke_uv +from camera_grid_stroke_optimizer import optimize_angles from patch_geometry import clip_rect from patch_projection import build_patch_geometry from random_patch_dd02c import ( @@ -31,6 +32,10 @@ def _args(): 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) + parser.add_argument("--orientation-mode", choices=("random", "optimized"), + default="random", help="Use seeded random or experimental optimized angles") + parser.add_argument("--proposals", type=int, default=100000, + help="Angle changes attempted in optimized mode") return parser.parse_args(argv) @@ -39,7 +44,8 @@ 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): +def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio, + angle_map=None): width, height = settings["width"], settings["height"] nx, ny = settings["nx"], settings["ny"] seed = settings.get("seed") or 0 @@ -61,7 +67,8 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio): key = (i, j) if key not in outline_cache: outline_cache[key] = capsule_in_cell( - i, j, nx, ny, seed, width_ratio, length_ratio) + 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) if len(pixel_poly) < 3: @@ -71,7 +78,8 @@ def _render(uv_name, geometry, settings, output_dir, width_ratio, length_ratio): image.pixels[:] = pixels image.update() - base = output_dir / f"camera_grid_stroke_{uv_name}" + label = "camera_grid_stroke_optimized" if angle_map is not None else "camera_grid_stroke" + 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) @@ -99,6 +107,15 @@ def main(): _require_uv_layer(obj, name) args.output_dir.mkdir(parents=True, exist_ok=True) args.output_dir = args.output_dir.resolve() + angle_map = None + if args.orientation_mode == "optimized": + angle_map, report = optimize_angles( + settings["nx"], settings["ny"], settings.get("seed") or 0, + args.width_ratio, args.length_ratio, args.proposals, + ) + report_path = args.output_dir / "camera_grid_stroke_optimization.json" + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(f"Camera-grid optimization: {report}") geometry = build_patch_geometry( obj, cam, bpy.context.scene, bpy.context.evaluated_depsgraph_get(), names, settings["nx"], settings["ny"], settings["edge_sample_grid"], @@ -106,7 +123,7 @@ def main(): ) for name in names: _render(name, geometry[name], settings, args.output_dir, - args.width_ratio, args.length_ratio) + args.width_ratio, args.length_ratio, angle_map) if __name__ == "__main__": diff --git a/blender/scripts/camera_grid_stroke_geometry.py b/blender/scripts/camera_grid_stroke_geometry.py index d357109..80776fb 100644 --- a/blender/scripts/camera_grid_stroke_geometry.py +++ b/blender/scripts/camera_grid_stroke_geometry.py @@ -6,12 +6,19 @@ 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): +def stroke_angle(i, j, seed): + """Return the original seeded orientation in radians, modulo a half-turn.""" + rng = random.Random((seed * 1000003 + i * 9176 + j * 6113) & 0xFFFFFFFF) + return rng.uniform(0, math.pi) + + +def capsule_in_cell(i, j, nx, ny, seed, width_ratio=0.42, length_ratio=0.84, + angle=None): """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) + if angle is None: + angle = stroke_angle(i, j, seed) radius = width_ratio / 2 half_body = (length_ratio - width_ratio) / 2 outline = [] diff --git a/blender/scripts/camera_grid_stroke_optimizer.py b/blender/scripts/camera_grid_stroke_optimizer.py new file mode 100644 index 0000000..bd29aba --- /dev/null +++ b/blender/scripts/camera_grid_stroke_optimizer.py @@ -0,0 +1,117 @@ +"""Choose stroke angles that make nearby camera-grid neighborhoods distinct.""" + +import math +import random + +import numpy as np + +from camera_grid_stroke_geometry import stroke_angle + + +ANGLE_COUNT = 12 +OFFSETS = ((1, 0), (0, 1), (1, 1), (1, -1), + (2, 0), (0, 2), (2, 2), (2, -2)) + + +def _cell_template(angle, width_ratio, length_ratio, resolution): + sample = 4 + side = resolution * sample + axis = (np.arange(side, dtype=np.float64) + 0.5) / side - 0.5 + x, y = np.meshgrid(axis, axis) + along = x * math.cos(angle) + y * math.sin(angle) + across = -x * math.sin(angle) + y * math.cos(angle) + half_body = (length_ratio - width_ratio) / 2 + outside = np.maximum(np.abs(along) - half_body, 0) + binary = (outside * outside + across * across <= (width_ratio / 2) ** 2) + return binary.reshape(resolution, sample, resolution, sample).mean(axis=(1, 3)) + + +def _angle_distances(width_ratio, length_ratio): + angles = np.arange(ANGLE_COUNT) * math.pi / ANGLE_COUNT + distances = np.zeros((ANGLE_COUNT, ANGLE_COUNT), dtype=np.float64) + for resolution in (4, 8): + templates = np.stack([_cell_template(a, width_ratio, length_ratio, resolution) + for a in angles]) + diff = templates[:, None] - templates[None, :] + distances += np.mean(diff * diff, axis=(2, 3)) / 2 + peak = distances.max() + if peak > 0: + distances /= peak + return angles, distances + + +def _neighborhood_pairs(nx, ny): + windows_a = [] + windows_b = [] + incident = [[] for _ in range(nx * ny)] + for y in range(1, ny - 1): + for x in range(1, nx - 1): + for dx, dy in OFFSETS: + xx, yy = x + dx, y + dy + if not (1 <= xx < nx - 1 and 1 <= yy < ny - 1): + continue + a = [(y + v) * nx + x + u for v in (-1, 0, 1) + for u in (-1, 0, 1)] + b = [(yy + v) * nx + xx + u for v in (-1, 0, 1) + for u in (-1, 0, 1)] + index = len(windows_a) + windows_a.append(a) + windows_b.append(b) + for cell in set(a + b): + incident[cell].append(index) + return (np.asarray(windows_a, dtype=np.int32), + np.asarray(windows_b, dtype=np.int32), + [np.asarray(indices, dtype=np.int32) for indices in incident]) + + +def _pair_distances(assignments, distances, windows_a, windows_b): + return distances[assignments[windows_a], assignments[windows_b]].mean(axis=1) + + +def optimize_angles(nx, ny, seed, width_ratio=0.42, length_ratio=0.84, + proposals=100000): + """Optimize 3x3 neighborhood separation within a two-cell search radius. + + The 4/8-pixel camera templates are a cheap screening model. The rendered + optical-flow probe remains the acceptance test for a candidate pattern. + """ + if nx < 5 or ny < 5 or proposals < 0: + raise ValueError("require at least 5x5 cells and nonnegative proposals") + angles, distances = _angle_distances(width_ratio, length_ratio) + wa, wb, incident = _neighborhood_pairs(nx, ny) + assignments = np.asarray([ + int(round(stroke_angle(x, y, seed) * ANGLE_COUNT / math.pi)) % ANGLE_COUNT + for y in range(ny) for x in range(nx) + ], dtype=np.int16) + initial = _pair_distances(assignments, distances, wa, wb) + pair_loss = np.exp(-6 * initial) + rng = random.Random(seed ^ 0x71C3A5) + accepted = 0 + for _ in range(proposals): + cell = rng.randrange(nx * ny) + affected = incident[cell] + if not len(affected): + continue + previous = assignments[cell] + candidate = rng.randrange(ANGLE_COUNT - 1) + if candidate >= previous: + candidate += 1 + assignments[cell] = candidate + trial = _pair_distances(assignments, distances, wa[affected], wb[affected]) + trial_loss = np.exp(-6 * trial) + if trial_loss.sum() < pair_loss[affected].sum() - 1e-12: + pair_loss[affected] = trial_loss + accepted += 1 + else: + assignments[cell] = previous + final = _pair_distances(assignments, distances, wa, wb) + report = { + "pairs": len(initial), + "proposals": proposals, + "accepted": accepted, + "initial_distance_p10": float(np.percentile(initial, 10)), + "final_distance_p10": float(np.percentile(final, 10)), + "initial_mean_loss": float(np.exp(-6 * initial).mean()), + "final_mean_loss": float(pair_loss.mean()), + } + return angles[assignments].reshape(ny, nx), report diff --git a/blender/scripts/test_camera_grid_stroke_optimizer.py b/blender/scripts/test_camera_grid_stroke_optimizer.py new file mode 100644 index 0000000..3082a7c --- /dev/null +++ b/blender/scripts/test_camera_grid_stroke_optimizer.py @@ -0,0 +1,24 @@ +"""Run with python -m unittest discover -s blender/scripts -p test_camera_grid_stroke_optimizer.py.""" + +import unittest + +import numpy as np + +from camera_grid_stroke_optimizer import optimize_angles + + +class CameraGridStrokeOptimizerTests(unittest.TestCase): + def test_seeded_optimization_improves_neighborhood_separation(self): + first, report = optimize_angles(16, 16, 7, proposals=4000) + second, repeated = optimize_angles(16, 16, 7, proposals=4000) + self.assertTrue(np.array_equal(first, second)) + self.assertEqual(report, repeated) + self.assertGreater(report["final_distance_p10"], + report["initial_distance_p10"]) + self.assertLess(report["final_mean_loss"], + report["initial_mean_loss"]) + self.assertEqual(first.shape, (16, 16)) + + +if __name__ == "__main__": + unittest.main()