加入摄像机网格笔画方向优化实验
用邻域差异筛选方向并保留原随机模式。已通过单元测试、纹理重现检查和多分辨率光流探针。
This commit is contained in:
@@ -15,6 +15,7 @@ import bpy
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
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_geometry import capsule_in_cell, projected_stroke_uv
|
||||||
|
from camera_grid_stroke_optimizer import optimize_angles
|
||||||
from patch_geometry import clip_rect
|
from patch_geometry import clip_rect
|
||||||
from patch_projection import build_patch_geometry
|
from patch_projection import build_patch_geometry
|
||||||
from random_patch_dd02c import (
|
from random_patch_dd02c import (
|
||||||
@@ -31,6 +32,10 @@ def _args():
|
|||||||
parser.add_argument("--output-dir", 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("--width-ratio", type=float, default=0.42)
|
||||||
parser.add_argument("--length-ratio", type=float, default=0.84)
|
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)
|
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)]
|
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"]
|
width, height = settings["width"], settings["height"]
|
||||||
nx, ny = settings["nx"], settings["ny"]
|
nx, ny = settings["nx"], settings["ny"]
|
||||||
seed = settings.get("seed") or 0
|
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)
|
key = (i, j)
|
||||||
if key not in outline_cache:
|
if key not in outline_cache:
|
||||||
outline_cache[key] = capsule_in_cell(
|
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])
|
uv_poly = projected_stroke_uv(fragment, loop_uvs, outline_cache[key])
|
||||||
pixel_poly = _pixel_polygon(uv_poly, width, height)
|
pixel_poly = _pixel_polygon(uv_poly, width, height)
|
||||||
if len(pixel_poly) < 3:
|
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.pixels[:] = pixels
|
||||||
image.update()
|
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"))
|
save_png_image(image, base.with_suffix(".png"))
|
||||||
page_mesh = _clipped_polygons(mesh_polygons, width, height)
|
page_mesh = _clipped_polygons(mesh_polygons, width, height)
|
||||||
page_shapes = _clipped_shapes(shapes, width, height)
|
page_shapes = _clipped_shapes(shapes, width, height)
|
||||||
@@ -99,6 +107,15 @@ def main():
|
|||||||
_require_uv_layer(obj, name)
|
_require_uv_layer(obj, name)
|
||||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
args.output_dir = args.output_dir.resolve()
|
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(
|
geometry = build_patch_geometry(
|
||||||
obj, cam, bpy.context.scene, bpy.context.evaluated_depsgraph_get(),
|
obj, cam, bpy.context.scene, bpy.context.evaluated_depsgraph_get(),
|
||||||
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
names, settings["nx"], settings["ny"], settings["edge_sample_grid"],
|
||||||
@@ -106,7 +123,7 @@ def main():
|
|||||||
)
|
)
|
||||||
for name in names:
|
for name in names:
|
||||||
_render(name, geometry[name], settings, args.output_dir,
|
_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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import random
|
|||||||
from patch_geometry import clip_halfspace, interpolate_uv
|
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."""
|
"""Return a round-ended stroke wholly inside one normalized camera cell."""
|
||||||
if not 0 < width_ratio <= length_ratio < 1:
|
if not 0 < width_ratio <= length_ratio < 1:
|
||||||
raise ValueError("require 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)
|
if angle is None:
|
||||||
angle = rng.uniform(0, math.pi)
|
angle = stroke_angle(i, j, seed)
|
||||||
radius = width_ratio / 2
|
radius = width_ratio / 2
|
||||||
half_body = (length_ratio - width_ratio) / 2
|
half_body = (length_ratio - width_ratio) / 2
|
||||||
outline = []
|
outline = []
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user