feat: implement separate geometry handling for panorama mask regions

- Replace Delete Geometry with Separate Geometry in O Image Depth Panorama to manage low and high mask areas independently.
- Adjust depth processing to ensure low mask regions are displaced to Dome Radius while high mask regions follow existing depth rules.
- Update tests to validate new behavior, ensuring outputs align with expected results for both mask states.
- Enhance material handling by introducing emission strength in the shadeless group.
This commit is contained in:
2026-09-17 02:23:19 +08:00
parent efd117dd9b
commit ac7f5a7f6d
13 changed files with 391 additions and 42 deletions

View File

@@ -49,6 +49,11 @@ def evaluated(obj):
result.to_mesh_clear()
def free_edges(faces):
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
return sum(count == 1 for count in edges.values())
@pytest.mark.parametrize("dome", [1.0, 50.0])
@pytest.mark.parametrize("subdivide", [0, 4])
def test_quad_sphere_topology_and_radial_endpoints(subdivide, dome):
@@ -71,11 +76,13 @@ def test_quad_sphere_topology_and_radial_endpoints(subdivide, dome):
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
def test_mask_threshold_culls_faces():
def test_depth_mask_keeps_only_the_faces_above_the_threshold():
obj, set_value = panorama(alpha=0.5)
assert not len(evaluated(obj)[0])
set_value("Depth Mask", False)
assert len(evaluated(obj)[1]) == 384
points, faces, _ = evaluated(obj)
assert len(faces) == 384
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-5)
set_value("Depth Mask", True)
set_value("Mask Threshold", 0.49)
assert len(evaluated(obj)[1]) == 384
@@ -115,8 +122,8 @@ def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
assert np.max(abs(error)) < 0.004
def test_masked_area_falls_back_to_the_dome_radius():
"""The mask decides validity; what Depth Mask keeps lands on the dome sphere."""
def test_masked_area_rests_on_the_dome_sphere():
"""Depth Mask keeps only the valid half; the excluded half rests on the dome."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e-7)
@@ -140,6 +147,70 @@ def test_masked_area_falls_back_to_the_dome_radius():
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 50, atol=1e-3)
def test_masked_region_ignores_its_own_depth_and_depth_scale():
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e4)
obj, set_value = panorama(depth, alpha, subdivide=4)
set_value("Depth Mask", False)
for scale in (0.0, 1.0):
set_value("Depth Scale", scale)
radii = np.linalg.norm(evaluated(obj)[0], axis=1)
on_dome = np.isclose(radii, 50, atol=1e-3)
assert on_dome.any()
assert np.all(on_dome | np.isclose(radii, 3.0, atol=1e-3))
def test_masked_regions_keep_their_own_seam_edges():
"""Both regions reach the output, so the seam is a free edge on each side."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
plain, _ = panorama(3.0, 1.0, subdivide=4)
whole, whole_faces, _ = evaluated(plain)
obj, set_value = panorama(3.0, alpha, subdivide=4)
set_value("Depth Mask", False)
points, faces, _ = evaluated(obj)
assert len(faces) == len(whole_faces)
assert len(points) > len(whole)
set_value("Depth Mask", True)
assert free_edges(faces) == 2 * free_edges(evaluated(obj)[1])
def test_boundary_smooth_relaxes_both_masked_region_boundaries():
"""Boundary Smooth relaxes both regions at the seam and leaves their interiors."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
depth = np.where(alpha, 3.0, 1e4)
depth[:, 56] = 30.0
obj, set_value = panorama(depth, alpha, subdivide=4)
set_value("Depth Mask", False)
before = evaluated(obj)[0]
low = np.isclose(np.linalg.norm(before, axis=1), 50, atol=1e-3)
assert low.any()
set_value("Boundary Smooth", 5)
after = evaluated(obj)[0]
moved = np.linalg.norm(after - before, axis=1) > 1e-6
assert moved[low].any() and moved[~low].any()
assert not moved[low].all()
np.testing.assert_allclose(np.linalg.norm(after[low & ~moved], axis=1), 50, atol=1e-5)
def test_flat_scale_without_mask_returns_a_whole_sphere():
"""Depth Scale 0 without the mask leaves the dome sphere uncut and unsplit."""
alpha = np.ones((64, 128))
alpha[:, 64:] = 0
plain, _ = panorama(3.0, 1.0, subdivide=4)
whole, whole_faces, _ = evaluated(plain)
obj, set_value = panorama(np.where(alpha, 3.0, 1e4), alpha, subdivide=4)
set_value("Depth Mask", False)
set_value("Depth Scale", 0)
points, faces, _ = evaluated(obj)
assert len(faces) == len(whole_faces)
assert len(points) == len(whole)
assert free_edges(faces) == 0
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 50, atol=1e-6)
def test_controls_and_material_slot_inheritance():
obj, set_value = panorama()
group = obj.modifiers[0].node_group

View File

@@ -12,14 +12,16 @@ def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
addon_utils.enable("cycles", default_set=False)
group = load_node_group("O Shadeless")
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
assert list(inputs) == ["Color", "Alpha"] and inputs["Alpha"].default_value == 1
assert list(inputs) == ["Color", "Alpha", "Strength"]
assert inputs["Alpha"].default_value == 1 and inputs["Strength"].default_value == 1
assert inputs["Strength"].min_value == 0
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 8
scene.cycles.use_denoising = False
scene.render.film_transparent = True
scene.render.resolution_x, scene.render.resolution_y = 96, 32
scene.render.resolution_x, scene.render.resolution_y = 128, 32
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "OPEN_EXR"
scene.render.image_settings.color_mode = "RGBA"
@@ -28,27 +30,29 @@ def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.data.type = "ORTHO"
scene.camera.data.ortho_scale = 3
for index, alpha in enumerate((0, 0.5, 1)):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
material = bpy.data.materials.new(f"Alpha {alpha}")
scene.camera.data.ortho_scale = 4
for index, (alpha, strength) in enumerate(((0, 1), (0.5, 1), (1, 1), (1, 2))):
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1.5, 0, 0))
material = bpy.data.materials.new(f"Alpha {alpha} Strength {strength}")
material.use_nodes = True
material.node_tree.nodes.clear()
shader = material.node_tree.nodes.new("ShaderNodeGroup")
shader.node_tree = group
shader.inputs["Color"].default_value = (4, 2, 0.5, 1)
shader.inputs["Alpha"].default_value = alpha
shader.inputs["Strength"].default_value = strength
output = material.node_tree.nodes.new("ShaderNodeOutputMaterial")
material.node_tree.links.new(shader.outputs[0], output.inputs["Surface"])
bpy.context.object.data.materials.append(material)
bpy.ops.render.render(write_still=True)
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
try:
pixels = np.array(image.pixels[:]).reshape(32, 96, 4)
for index, alpha in enumerate((0, 0.5, 1)):
pixels = np.array(image.pixels[:]).reshape(32, 128, 4)
for index, alpha in enumerate((0, 0.5, 1, 1)):
patch = pixels[8:24, index * 32 + 8:index * 32 + 24]
np.testing.assert_allclose(patch[..., 3].mean(), alpha, atol=0.035)
np.testing.assert_allclose(pixels[16, 80, :3], (4, 2, 0.5), atol=1e-4)
np.testing.assert_allclose(pixels[16, 112, :3], (8, 4, 1), atol=1e-4)
finally:
bpy.data.images.remove(image)
bpy.ops.wm.read_factory_settings(use_empty=True)