Add new functionality for image rectification and depth metadata handling

- Implemented a preview tool for rectifying images with interactive aspect controls in `preview.py`.
- Added tests for depth metadata loading to ensure consistent schema usage in `test_depth_metadata.py`.
- Developed comprehensive tests for cutout calibration processes in `test_cutout_calibration.py`, including stability checks and depth calibration.
- Created tests for frame compositing operations in `test_frame_compositing.py`, ensuring correct handling of depth and transparency.
- Introduced material slot inheritance functionality in `material_slots.py` to manage geometry material assignments.
- Built shared shader nodes for material processing in `shader.py`, including roughness and bump mapping setups.
This commit is contained in:
2026-09-12 19:20:06 +08:00
parent e8268504c2
commit 938d95db0f
175 changed files with 3812 additions and 6864 deletions

View File

@@ -15,271 +15,6 @@ class FrameToolTest(BlenderTestCase):
)
def test_frame_output_size_uses_viewport_pixels(self):
frame = (
(-50.0, 150.0),
(150.0, 150.0),
(150.0, -50.0),
(-50.0, -50.0),
)
self.assertEqual(
self.image_frame.frame_output_size(
frame,
(1000, 800),
2048,
),
(200, 200),
)
def test_frame_output_size_obeys_region_and_preference_limits(self):
frame = (
(0.0, 1000.0),
(2000.0, 1000.0),
(2000.0, 0.0),
(0.0, 0.0),
)
self.assertEqual(
self.image_frame.frame_output_size(frame, (1000, 800), 2048),
(1000, 500),
)
self.assertEqual(
self.image_frame.frame_output_size(frame, (4000, 2000), 500),
(500, 250),
)
def test_frame_requires_positive_valid_projection_overlap(self):
import numpy as np
source = self.image_frame.frame_source_projection(
np.identity(4),
np.identity(4),
np.identity(4),
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=False,
)
self.assertTrue(
self.image_frame.frame_source_overlap(
source,
((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)),
)
)
self.assertFalse(
self.image_frame.frame_source_overlap(
source,
((100.0, 100.0), (200.0, 100.0), (200.0, 0.0), (100.0, 0.0)),
)
)
def test_frame_orthographic_projection_accepts_non_positive_depth(self):
import numpy as np
matrix_world = np.identity(4)
matrix_world[2, 3] = 1.0
source = self.image_frame.frame_source_projection(
np.identity(4),
np.identity(4),
matrix_world,
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=False,
)
source["image_size"] = (100, 100)
points, depth, valid, normalized = self.image_frame.frame_source_coordinates(
source,
np.asarray(((25.0, 75.0),)),
)
np.testing.assert_allclose(points[0], (25.0, 25.0))
np.testing.assert_allclose(normalized[0], (0.25, 0.25))
self.assertEqual(depth[0], -1.0)
self.assertTrue(valid[0])
self.assertTrue(
self.image_frame.frame_source_overlap(
source,
((0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)),
)
)
def test_frame_rejects_an_orthographic_plane_parallel_to_view_rays(self):
import numpy as np
matrix_world = np.asarray(
(
(0.0, 0.0, 1.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
)
with self.assertRaisesRegex(ValueError, "edge-on"):
self.image_frame.frame_source_projection(
np.identity(4),
np.identity(4),
matrix_world,
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=False,
)
def test_frame_perspective_projection_samples_front_of_crossing_image(self):
import numpy as np
projection = np.identity(4)
projection[3] = (0.0, 0.0, -1.0, 0.0)
matrix_world = np.identity(4)
matrix_world[0, 3] = 1.0
matrix_world[2, 0] = 1.0
matrix_world[2, 3] = 0.2
source = self.image_frame.frame_source_projection(
projection,
np.identity(4),
matrix_world,
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=True,
)
source["image_size"] = (100, 100)
front_points, front_depth, front_valid, _normalized = (
self.image_frame.frame_source_coordinates(
source,
np.asarray(((75.0, 50.0),)),
)
)
_rear_points, rear_depth, rear_valid, _normalized = (
self.image_frame.frame_source_coordinates(
source,
np.asarray(((-50.0, 50.0),)),
)
)
overlap = self.image_frame.frame_source_overlap(
source,
((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)),
)
self.assertTrue(front_valid[0])
self.assertGreater(front_depth[0], 0.0)
self.assertGreaterEqual(front_points[0, 0], 0.0)
self.assertFalse(rear_valid[0])
self.assertLess(rear_depth[0], 0.0)
self.assertTrue(overlap)
_parallel_points, _parallel_depth, parallel_valid, _normalized = (
self.image_frame.frame_source_coordinates(
source,
np.asarray(((0.0, 50.0),)),
)
)
self.assertFalse(parallel_valid[0])
def test_frame_perspective_projection_accepts_extremely_close_image(self):
import numpy as np
projection = np.identity(4)
projection[3] = (0.0, 0.0, -1.0, 0.0)
matrix_world = np.identity(4)
matrix_world[2, 3] = -1e-14
source = self.image_frame.frame_source_projection(
projection,
np.identity(4),
matrix_world,
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=True,
)
source["image_size"] = (100, 100)
_points, depth, valid, _normalized = self.image_frame.frame_source_coordinates(
source,
np.asarray(((50.0, 50.0),)),
)
self.assertTrue(valid[0])
self.assertEqual(depth[0], 1e-14)
def test_frame_overlap_does_not_depend_on_output_pixel_centers(self):
import numpy as np
source = self.image_frame.frame_source_projection(
np.identity(4),
np.identity(4),
np.identity(4),
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=False,
)
self.assertTrue(
self.image_frame.frame_source_overlap(
source,
(
(99.999, 60.0),
(100.001, 60.0),
(100.001, 40.0),
(99.999, 40.0),
),
)
)
def test_frame_uses_crossing_active_overlap_for_visible_result_depth(self):
import numpy as np
projection = np.identity(4)
projection[3] = (0.0, 0.0, -1.0, 0.0)
matrix_world = np.identity(4)
matrix_world[0, 3] = 1.0
matrix_world[2, 0] = 1.0
matrix_world[2, 3] = 0.2
source = self.image_frame.frame_source_projection(
projection,
np.identity(4),
matrix_world,
(-1.0, 1.0, -1.0, 1.0),
(100, 100),
perspective=True,
)
source.update(
{
"image_size": (100, 100),
"object": SimpleNamespace(matrix_world=matrix_world),
}
)
frame = ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0))
overlap = self.image_frame.frame_source_overlap(source, frame)
depth_location = self.image_frame.frame_depth_location(source, overlap)
self.assertLess(depth_location[2], 0.0)
def test_frame_keeps_positive_active_origin_as_result_depth_location(self):
import numpy as np
marker = SimpleNamespace(copy=lambda: "active-origin")
source = {
"object": SimpleNamespace(
matrix_world=SimpleNamespace(translation=marker),
),
"origin_depth": 1e-14,
"perspective": True,
}
self.assertEqual(
self.image_frame.frame_depth_location(source, ((0.0, 0.0),)),
"active-origin",
)
def test_frame_reports_missing_active_overlap_as_warning(self):
active = SimpleNamespace(name="Active")
context = SimpleNamespace(
@@ -303,304 +38,6 @@ class FrameToolTest(BlenderTestCase):
)
def test_frame_geometry_faces_the_view_and_uses_frame_center(self):
import numpy as np
matrix, display_size = self.image_frame.view_facing_frame_geometry(
(
(-2.0, 1.0, 5.0),
(2.0, 1.0, 5.0),
(2.0, -1.0, 5.0),
(-2.0, -1.0, 5.0),
),
(400, 200),
)
np.testing.assert_allclose(matrix[:3, 0], (1.0, 0.0, 0.0))
np.testing.assert_allclose(matrix[:3, 1], (0.0, 1.0, 0.0))
np.testing.assert_allclose(matrix[:3, 2], (0.0, 0.0, 1.0))
np.testing.assert_allclose(matrix[:3, 3], (0.0, 0.0, 5.0))
self.assertEqual(display_size, 4.0)
def test_frame_composites_far_to_near_with_active_on_top(self):
import numpy as np
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
def source(name, color, depth, active=False):
pixels = np.empty((2, 2, 4), dtype=np.float32)
pixels[:] = color
return {
"active": active,
"image_size": (2, 2),
"name": name,
"perspective": True,
"pixels": pixels,
"rgba": self.image_data.straight_rgba(pixels),
"region_size": (2.0, 2.0),
"screen_to_source": np.asarray(
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
),
"source_to_view": np.asarray(
(
(2.0, 0.0, -1.0),
(0.0, -2.0, 1.0),
(0.0, 0.0, -depth),
(0.0, 0.0, 1.0),
)
),
}
far = source("Far", (1.0, 0.0, 0.0, 1.0), 2.0)
active = source("Active", (0.0, 0.0, 0.5, 0.5), 1.0, active=True)
pixels = self.image_frame.composite_frame_pixels(
(active, far), frame, (2, 2)
)
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
np.testing.assert_allclose(result[:, :, 0], 0.5, atol=1e-6)
np.testing.assert_allclose(result[:, :, 2], 0.5, atol=1e-6)
np.testing.assert_allclose(result[:, :, 3], 1.0, atol=1e-6)
def test_frame_puts_active_above_an_equal_depth_source(self):
import numpy as np
frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0))
screen_to_source = np.asarray(
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
)
def source(name, color, active):
pixels = np.empty((2, 2, 4), dtype=np.float32)
pixels[:] = color
return {
"active": active,
"image_size": (2, 2),
"name": name,
"perspective": True,
"pixels": pixels,
"rgba": pixels.copy(),
"region_size": (2.0, 2.0),
"screen_to_source": screen_to_source,
"source_to_view": np.asarray(
(
(2.0, 0.0, -1.0),
(0.0, -2.0, 1.0),
(0.0, 0.0, -1.0),
(0.0, 0.0, 1.0),
)
),
}
pixels = self.image_frame.composite_frame_pixels(
(
source("Active", (0.0, 0.0, 1.0, 1.0), True),
source("Other", (1.0, 0.0, 0.0, 1.0), False),
),
frame,
(2, 2),
)
result = np.asarray(pixels).reshape((2, 2, 4))
np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6)
np.testing.assert_allclose(result[:, :, 0], 0.0, atol=1e-6)
def test_frame_composites_orthographic_samples_at_negative_depth(self):
import numpy as np
pixels = np.empty((2, 2, 4), dtype=np.float32)
pixels[:] = (0.25, 0.5, 0.75, 1.0)
source = {
"active": True,
"image_size": (2, 2),
"name": "Active",
"perspective": False,
"pixels": pixels,
"rgba": pixels.copy(),
"region_size": (2.0, 2.0),
"screen_to_source": np.asarray(
((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0))
),
"source_to_view": np.asarray(
(
(2.0, 0.0, -1.0),
(0.0, -2.0, 1.0),
(0.0, 0.0, 1.0),
(0.0, 0.0, 1.0),
)
),
}
result = self.image_frame.composite_frame_pixels(
(source,),
((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)),
(2, 2),
)
np.testing.assert_allclose(
np.asarray(result).reshape((2, 2, 4)),
pixels,
atol=1e-6,
)
def test_frame_preserves_masked_rgb_and_can_restore_alpha(self):
import numpy as np
rgba = np.random.default_rng(7).random((4, 4, 4)).astype(np.float32)
mask = self.image_selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4))
removed = self.edit_mask.apply_alpha_mask(rgba, mask, "SUBTRACT")
source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape)))
framed = self.image_frame.composite_frame_pixels(
(source,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
)
result = np.flipud(framed.reshape(rgba.shape))
np.testing.assert_allclose(result[..., :3], rgba[..., :3])
np.testing.assert_array_equal(result[..., 3], 0)
restored = self.edit_mask.apply_alpha_mask(result, mask, "ADD")
restored = np.flipud(restored.reshape(rgba.shape))
np.testing.assert_allclose(restored[..., :3], rgba[..., :3])
np.testing.assert_array_equal(restored[..., 3], 1)
def test_frame_hidden_rgb_uses_bottom_valid_depth_and_stable_ties(self):
import numpy as np
blue = np.tile((0, 0, 1, 0), (2, 2, 1))
red = np.tile((1, 0, 0, 0), (2, 2, 1))
frame = ((0, 2), (2, 2), (2, 0), (0, 0))
for depth in (2.0, 1.0, -1.0):
with self.subTest(depth=depth):
bottom = self._frame_pixel_source(blue, depth=depth, name="A", active=False)
top = self._frame_pixel_source(red, depth=min(depth, 1.0))
result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2))
np.testing.assert_allclose(result.reshape((2, 2, 4)), blue)
middle = self._frame_pixel_source(red, name="B", active=False)
bottom = self._frame_pixel_source(blue, name="A", active=False)
top = self._frame_pixel_source(red)
result = self.image_frame.composite_frame_pixels((middle, top, bottom), frame, (2, 2))
np.testing.assert_allclose(result.reshape((2, 2, 4)), blue)
bottom["screen_to_source"][0, 2] += 0.5
result = np.flipud(self.image_frame.composite_frame_pixels(
(top, bottom), frame, (2, 2)
).reshape((2, 2, 4)))
np.testing.assert_allclose(result[:, 0], blue[:, 0])
np.testing.assert_allclose(result[:, 1], red[:, 1])
bottom["perspective"] = True
bottom["source_to_view"][2, 2] = 1.0
result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2))
np.testing.assert_allclose(result.reshape((2, 2, 4)), red)
def test_frame_hidden_rgb_tracks_crossing_planes(self):
import numpy as np
bottom = self._frame_pixel_source(np.tile((0, 0, 1, 0), (2, 2, 1)), active=False)
top = self._frame_pixel_source(np.tile((1, 0, 0, 0), (2, 2, 1)))
bottom["source_to_view"][2] = (2, 0, -2)
result = np.flipud(self.image_frame.composite_frame_pixels(
(top, bottom), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2)
).reshape((2, 2, 4)))
np.testing.assert_allclose(result[:, 0], np.tile((0, 0, 1, 0), (2, 1)))
np.testing.assert_allclose(result[:, 1], np.tile((1, 0, 0, 0), (2, 1)))
def test_frame_blends_rgb_over_bottom_color_with_independent_alpha(self):
import numpy as np
for bottom_alpha in (0.0, 0.5, 1.0):
blue = np.tile((0, 0, 1, bottom_alpha), (2, 5, 1)).astype(np.float32)
red = np.tile((1, 0, 0, 0), (2, 5, 1)).astype(np.float32)
red[..., 3] = (0, 0.25, 0.5, 0.75, 1)
sources = (self._frame_pixel_source(blue, depth=2, active=False),
self._frame_pixel_source(red))
result = np.flipud(self.image_frame.composite_frame_pixels(
sources, ((0, 2), (5, 2), (5, 0), (0, 0)), (5, 2)
).reshape((2, 5, 4)))
alpha = red[..., 3] + bottom_alpha * (1 - red[..., 3])
expected = np.zeros_like(red)
expected[..., 0] = red[..., 3]
expected[..., 2] = 1 - red[..., 3]
np.testing.assert_allclose(result[..., 3], alpha)
np.testing.assert_allclose(result[..., :3], expected[..., :3])
def test_frame_three_layers_preserve_foreground_gradient_over_white(self):
import numpy as np
white = np.tile((1, 1, 1, 0), (2, 6, 1)).astype(np.float32)
red = np.tile((1, 0, 0, 0), (2, 6, 1)).astype(np.float32)
red[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
blue = np.tile((0, 0, 1, 0.25), (2, 6, 1)).astype(np.float32)
sources = (
self._frame_pixel_source(white, depth=3, active=False),
self._frame_pixel_source(red, depth=2, active=False),
self._frame_pixel_source(blue),
)
result = np.flipud(self.image_frame.composite_frame_pixels(
sources, ((0, 2), (6, 2), (6, 0), (0, 0)), (6, 2)
).reshape((2, 6, 4)))
red_alpha = red[..., 3:4]
expected = ((red[..., :3] * red_alpha + 1 - red_alpha) * 0.75
+ blue[..., :3] * 0.25)
np.testing.assert_allclose(result[..., :3], expected)
np.testing.assert_allclose(result[..., 3], 0.25 + red[..., 3] * 0.75)
def test_frame_foreground_resampling_excludes_hidden_black(self):
import numpy as np
white = self._frame_pixel_source(
np.tile((1, 1, 1, 0), (1, 2, 1)), depth=2, active=False
)
foreground = self._frame_pixel_source(
np.asarray([[(1, 0, 0, 1), (0, 0, 0, 0)]])
)
result = self.image_frame.composite_frame_pixels(
(foreground, white), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
)
np.testing.assert_allclose(result, (1, 0.5, 0.5, 0.5))
def test_frame_raw_sampling_and_empty_canvas_are_chunk_independent(self):
import numpy as np
rgba = np.tile((0, 1, 0, 0), (4, 4, 1)).astype(np.float32)
rgba[1:3, 2:] = (1, 0, 0, 1)
source = self._frame_pixel_source(rgba)
frame = ((-3, 7), (7, 7), (7, -3), (-3, -3))
results = []
for rows in (1, 2, 256):
with patch.object(self.image_frame, "PROJECTIVE_CHUNK_ROWS", rows):
results.append(np.flipud(self.image_frame.composite_frame_pixels(
(source,), frame, (10, 10)
).reshape((10, 10, 4))))
for result in results:
np.testing.assert_array_equal(result, results[0])
np.testing.assert_allclose(result[3:7, 3:7], rgba)
np.testing.assert_array_equal(result[4, 7], 0)
np.testing.assert_array_equal(result[0], 0)
transparent = np.asarray([[(1, 0, 0, 0), (0, 0, 1, 0)]], dtype=np.float32)
source = self._frame_pixel_source(transparent)
framed = self.image_frame.composite_frame_pixels(
(source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
)
np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0))
sampled = self.image_data.sample_rgba(
transparent, np.asarray(((1, 0.5), (-1, 0.5)))
)
np.testing.assert_allclose(sampled, ((0.5, 0, 0.5, 0), (0, 0, 0, 0)))
transparent[0, 0, 3] = 1
source = self._frame_pixel_source(transparent)
framed = self.image_frame.composite_frame_pixels(
(source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1)
)
np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0.5))
def test_frame_uses_selected_still_images_and_ignores_other_objects(self):
active = SimpleNamespace(
name="Active",
@@ -888,23 +325,3 @@ class FrameToolTest(BlenderTestCase):
create_result.assert_not_called()
replace_result.assert_not_called()
self.assertIs(active.data, active_image)
def _frame_pixel_source(self, rgba, *, depth=1.0, name="Active", active=True):
import numpy as np
rgba = np.asarray(rgba, dtype=np.float32)
height, width = rgba.shape[:2]
world = np.identity(4)
world[2, 3] = -depth
source = self.image_frame.frame_source_projection(
np.identity(4), np.identity(4), world,
(-1.0, 1.0, -1.0, 1.0), (width, height), perspective=False,
)
source.update(
active=active, name=name, image_size=(width, height), rgba=rgba,
pixels=self.image_data.premultiplied_rgba(
np.flipud(rgba).ravel(), (width, height)
),
)
return source