Add geometry nodes asset and validation script

- Implemented a new geometry nodes asset in `build_geometry_nodes_asset.py` to handle mesh refinement and topology fixing.
- Created a validation script in `validate_geometry_nodes_asset.py` to ensure the integrity and functionality of the geometry node group.
- Added input handling for various parameters including resolution, iterations, and smoothing options.
- Included checks for node group structure and attributes to validate the geometry processing pipeline.
This commit is contained in:
2026-08-11 13:23:13 +08:00
parent fc27c23fd1
commit 07d836d9ad
15 changed files with 2408 additions and 68 deletions

View File

@@ -8,6 +8,8 @@ from tempfile import TemporaryDirectory
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
@@ -28,6 +30,7 @@ class AddonTest(unittest.TestCase):
)
fake_bpy.types = SimpleNamespace(
AddonPreferences=object,
Menu=object,
Operator=object,
Panel=object,
PropertyGroup=object,
@@ -79,6 +82,7 @@ class AddonTest(unittest.TestCase):
self.context_menu_draws.clear()
self.addon.operators._active_job = None
self.addon.image_depth._last_depth_plane_model = "BASE"
self.addon.image_mesh._last_mesh_model = "BASE"
self.fake_bpy.data = SimpleNamespace(
objects=SimpleNamespace(get=lambda _name: None),
)
@@ -125,6 +129,135 @@ class AddonTest(unittest.TestCase):
)
self.assertFalse(hasattr(self.addon, "ImportDepthPlane"))
def test_registers_perspective_mesh_operators(self):
self.assertIn(self.addon.ConvertImageToMesh, self.addon.CLASSES)
self.assertIn(self.addon.BuildImageMesh, self.addon.CLASSES)
self.assertEqual(self.addon.BuildImageMesh.bl_options, {"INTERNAL"})
self.assertIn(self.addon.SnapFormImageMenu, self.addon.CLASSES)
def test_image_context_menu_groups_actions_and_separates_background(self):
calls = []
class Layout:
operator_context = None
def operator(self, identifier, **_options):
calls.append(identifier)
def separator(self):
calls.append("separator")
menu = self.addon.SnapFormImageMenu()
menu.layout = Layout()
menu.draw(None)
self.assertEqual(
calls,
[
"snapform.convert_image_to_depth_plane",
"snapform.convert_image_to_mesh",
"separator",
"snapform.remove_image_background",
],
)
def test_image_mesh_reference_plane_matches_image_empty_bounds(self):
source_object = SimpleNamespace(
data=SimpleNamespace(size=(401, 201)),
empty_display_size=4.0,
empty_image_offset=(-0.25, -0.75),
)
intrinsics = np.asarray(
[[200.0, 0.0, 200.0], [0.0, 200.0, 100.0], [0.0, 0.0, 1.0]],
dtype=np.float32,
)
camera_vertices = np.asarray(
[
[-2.0, -1.0, 2.0],
[2.0, -1.0, 2.0],
[-2.0, 1.0, 2.0],
[2.0, 1.0, 2.0],
],
dtype=np.float32,
)
vertices, camera = self.addon.image_mesh.empty_anchored_vertices(
source_object,
camera_vertices,
intrinsics,
(401, 201),
2.0,
)
x_min, x_max, y_min, y_max = self.addon.image_depth.plane_bounds(
source_object
)
np.testing.assert_allclose(
vertices,
[
[x_min, y_max, 0.0],
[x_max, y_max, 0.0],
[x_min, y_min, 0.0],
[x_max, y_min, 0.0],
],
)
np.testing.assert_allclose(
camera["location"],
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 2.0024922),
)
def test_mesh_conversion_dialog_only_keeps_model_and_camera_options(self):
annotations = self.addon.ConvertImageToMesh.__annotations__
for property_name in (
"snapform_confidence_percentile",
"snapform_edge_culling",
"snapform_face_reduction",
"snapform_depth_smoothing",
"snapform_remove_background",
"snapform_postprocess_method",
):
with self.subTest(property_name=property_name):
self.assertNotIn(property_name, annotations)
self.assertFalse(annotations["snapform_create_camera"]["default"])
self.assertFalse(annotations["snapform_unshaded"]["default"])
self.assertEqual(annotations["snapform_unshaded"]["name"], "Unshaded")
def test_image_mesh_uses_o_prefixed_attributes_and_bundled_nodes(self):
image_mesh = self.addon.image_mesh
names = set(image_mesh.POINT_ATTRIBUTES) | set(
image_mesh.FACE_ATTRIBUTES
)
self.assertTrue(all(name.startswith("o_") for name in names))
self.assertTrue(image_mesh.node_asset_path().is_file())
self.assertEqual(image_mesh.NODE_GROUP_NAME, "O Refine DA3 Mesh")
self.assertEqual(image_mesh.NODE_ASSET_NAME, "O_SnapForm.blend")
def test_image_mesh_material_depth_scale_matches_fitted_geometry(self):
mesh_object = SimpleNamespace(scale=SimpleNamespace(z=-2.0))
self.assertEqual(
self.addon.image_mesh.material_depth_scale(
mesh_object,
{"scale_z": 0.25},
),
0.5,
)
def test_image_mesh_default_resolution_uses_double_reduction_estimate(self):
vertices = np.asarray(
((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
dtype=np.float32,
)
faces = np.asarray(((0, 1, 2),), dtype=np.int32)
edge_lengths = np.asarray((1.0, 1.0, np.sqrt(2.0)))
self.assertAlmostEqual(
self.addon.image_mesh.default_remesh_resolution(vertices, faces),
float(np.median(edge_lengths) * np.sqrt(10.0) * 2.0),
)
def test_registers_image_background_operators(self):
self.assertIn(self.addon.RemoveImageBackground, self.addon.CLASSES)
self.assertIn(self.addon.ApplyRemovedBackground, self.addon.CLASSES)
@@ -1486,6 +1619,51 @@ class AddonTest(unittest.TestCase):
)
self.assertEqual(parameters["use_ray_pose"], 0)
def test_reconstruct_request_enables_ben2_for_image_mesh(self):
runtime = importlib.import_module("snapform.runtime")
settings = SimpleNamespace(
input_path="input.png",
model="BASE",
gs_model="GIANT_1_1",
process_res=504,
process_res_method="upper_bound_resize",
conf_thresh_percentile=40.0,
num_max_points=1_000_000,
show_cameras=True,
gs_views_interval=1,
video_frames=16,
device="AUTO",
use_ray_pose=False,
ref_view_strategy="saddle_balanced",
)
with patch.object(
runtime,
"runtime_python",
return_value=Path("python"),
), patch.object(
runtime,
"model_directory",
side_effect=lambda key: Path(key.lower()),
):
request = runtime.reconstruct_request(
settings,
"mesh_data",
Path("status.json"),
Path("result"),
remove_background=True,
)
parameters = request.payload["parameters"]
self.assertNotIn("mesh_confidence_percentile", parameters)
self.assertNotIn("mesh_edge_culling", parameters)
self.assertNotIn("mesh_face_reduction", parameters)
self.assertNotIn("mesh_depth_smoothing", parameters)
self.assertNotIn("mesh_postprocess_method", parameters)
self.assertEqual(
parameters["remove_background_model_dir"],
str(Path("ben2_base")),
)
def test_export_format_property_includes_all_da3_formats(self):
export_format = self.addon.SnapFormSettings.__annotations__[
"export_format"

View File

@@ -21,7 +21,7 @@ def load_image_depth():
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Operator=object)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
with patch.dict(sys.modules, {"bpy": fake_bpy}):
specification = importlib.util.spec_from_file_location(

View File

@@ -56,6 +56,10 @@ class PackTest(unittest.TestCase):
self.assertIn("__init__.py", names)
self.assertIn("blender_manifest.toml", names)
self.assertIn("image_depth.py", names)
self.assertIn(
"assets/O_SnapForm.blend",
names,
)
self.assertIn("worker.py", names)
self.assertIn("ai_host.py", names)
self.assertNotIn("snapform/__init__.py", names)

View File

@@ -982,7 +982,14 @@ class WorkerTest(unittest.TestCase):
"export_textured_mesh_glb",
side_effect=fake_mesh_export,
):
for export_format in self.worker.EXPORT_FORMATS:
debug_formats = (
"glb",
"gs_ply",
"depth_vis",
"mini_npz",
"mesh",
)
for export_format in debug_formats:
args = SimpleNamespace(
input=str(input_path),
video_frames=16,
@@ -1172,6 +1179,124 @@ class WorkerTest(unittest.TestCase):
self.assertEqual(threshold, 2.5)
def test_zero_mesh_confidence_disables_confidence_filter(self):
depth = np.ones((2, 2), dtype=np.float32)
confidence = np.full((2, 2), np.nan, dtype=np.float32)
x, y = np.meshgrid([0.0, 1.0], [0.0, 1.0])
points = np.stack((x, y, depth), axis=-1)
threshold = self.worker.confidence_percentile_threshold(
depth,
confidence,
0.0,
)
faces = self.worker.mesh_triangle_faces(
points,
depth,
confidence,
threshold,
filter_edges=False,
max_edge_length=0.0,
)
self.assertIsNone(threshold)
self.assertEqual(len(faces), 2)
def test_mesh_filters_triangles_independently(self):
depth = np.ones((2, 2), dtype=np.float32)
confidence = np.ones((2, 2), dtype=np.float32)
confidence[1, 0] = 0.0
x, y = np.meshgrid(
np.arange(2, dtype=np.float32),
np.arange(2, dtype=np.float32),
)
points = np.stack((x, y, depth), axis=-1)
faces = self.worker.mesh_triangle_faces(
points,
depth,
confidence,
confidence_threshold=0.5,
filter_edges=False,
max_edge_length=0.0,
)
np.testing.assert_array_equal(faces, [[0, 3, 1]])
def test_mesh_background_mask_combines_with_confidence(self):
depth = np.ones((2, 3), dtype=np.float32)
confidence = np.ones((2, 3), dtype=np.float32)
x, y = np.meshgrid(
np.arange(3, dtype=np.float32),
np.arange(2, dtype=np.float32),
)
points = np.stack((x, y, depth), axis=-1)
foreground = np.ones((2, 3), dtype=bool)
foreground[:, 0] = False
faces = self.worker.mesh_triangle_faces(
points,
depth,
confidence,
confidence_threshold=None,
filter_edges=False,
max_edge_length=0.0,
vertex_mask=foreground,
)
np.testing.assert_array_equal(faces, [[1, 4, 5], [1, 5, 2]])
def test_depth_edge_filter_ignores_smooth_slope(self):
smooth = np.tile(
np.linspace(2.0, 2.04, 5, dtype=np.float32),
(3, 1),
)
discontinuous = smooth.copy()
discontinuous[:, 3:] += 1.0
self.assertFalse(self.worker.depth_edge_mask(smooth).any())
self.assertTrue(self.worker.depth_edge_mask(discontinuous)[:, 2:4].all())
def test_higher_edge_culling_allows_less_stretch(self):
limits = [
self.worker.edge_culling_stretch_limit(value)
for value in (0.0, 50.0, 100.0)
]
self.assertEqual(limits, [10000.0, 2.0, 1.0])
def test_stretched_edge_culling_uses_camera_space_edge_ratio(self):
flat_points = np.asarray(
[
[[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]],
[[0.0, 1.0, 1.0], [1.0, 1.0, 1.0]],
],
dtype=np.float32,
)
stretched_points = flat_points.copy()
stretched_points[0, 1] = (2.0, 0.0, 2.0)
triangles = np.asarray([[0, 2, 3], [0, 3, 1]], dtype=np.int32)
flat_mask = self.worker.triangle_edge_stretch_mask(
flat_points,
triangles,
1.0,
)
loose_mask = self.worker.triangle_edge_stretch_mask(
stretched_points,
triangles,
2.0,
)
strict_mask = self.worker.triangle_edge_stretch_mask(
stretched_points,
triangles,
1.0,
)
self.assertTrue(flat_mask.all())
self.assertTrue(loose_mask.all())
self.assertFalse(strict_mask.all())
def test_mesh_faces_apply_confidence_threshold_and_face_direction(self):
points = np.array(
[
@@ -1228,6 +1353,113 @@ class WorkerTest(unittest.TestCase):
self.assertEqual(face_counts, [8, 2, 0])
def test_mesh_compaction_removes_unreferenced_vertices(self):
vertices = np.arange(18, dtype=np.float32).reshape(6, 3)
faces = np.asarray([[1, 3, 5]], dtype=np.int32)
compact_vertices, compact_faces = self.worker.compact_mesh(
vertices,
faces,
)
np.testing.assert_array_equal(
compact_vertices,
vertices[[1, 3, 5]],
)
np.testing.assert_array_equal(compact_faces, [[0, 1, 2]])
def test_image_mesh_data_keeps_camera_geometry_and_uv(self):
class FakeImage:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def convert(self, mode):
self.mode = mode
return self
def save(self, path, **_options):
Path(path).write_bytes(b"texture")
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(open=lambda _path: FakeImage())
depth = np.full((1, 3, 3), 2.0, dtype=np.float32)
confidence = np.ones((1, 3, 3), dtype=np.float32)
intrinsics = np.asarray(
[[[2.0, 0.0, 1.0], [0.0, 2.0, 1.0], [0.0, 0.0, 1.0]]],
dtype=np.float32,
)
prediction = SimpleNamespace(
depth=depth,
conf=confidence,
intrinsics=intrinsics,
)
progress = []
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
{"PIL": fake_pil},
), patch.object(
self.worker,
"compact_mesh",
side_effect=lambda vertices, faces: (
vertices,
faces,
),
):
result_path, texture_path = self.worker.export_image_mesh_data(
prediction,
Path(directory) / "input.png",
Path(directory) / "result",
progress_callback=lambda step, total, label: progress.append(
(step, total, label)
),
)
with np.load(result_path) as result:
self.assertEqual(result["vertices"].shape, (9, 3))
self.assertEqual(result["faces"].shape, (8, 3))
self.assertEqual(result["uv"].shape, (9, 2))
self.assertEqual(float(result["reference_depth"]), 2.0)
self.assertNotIn("postprocess_method", result.files)
self.assertEqual(result["point_depth"].shape, (9,))
self.assertEqual(
result["point_confidence_percentile"].shape,
(9,),
)
self.assertEqual(
result["face_edge_stretch"].shape,
(8,),
)
self.assertEqual(result["face_background"].shape, (8,))
np.testing.assert_allclose(result["uv"][0], [0.0, 1.0])
np.testing.assert_allclose(result["uv"][-1], [1.0, 0.0])
self.assertTrue(texture_path.is_file())
self.assertEqual([item[0] for item in progress], list(range(1, 7)))
self.assertTrue(all(item[1] == 6 for item in progress))
def test_prediction_attributes_keep_debug_structure(self):
prediction = SimpleNamespace(
depth=np.ones((1, 2, 3), dtype=np.float32),
conf=np.arange(6, dtype=np.float32).reshape(1, 2, 3),
intrinsics=np.eye(3, dtype=np.float32)[None],
extrinsics=np.eye(4, dtype=np.float32)[None],
)
with tempfile.TemporaryDirectory() as directory:
output_path = self.worker.export_prediction_attributes(
prediction,
directory,
)
with np.load(output_path) as attributes:
self.assertEqual(attributes["depth"].shape, (1, 2, 3))
self.assertEqual(attributes["conf"].shape, (1, 2, 3))
self.assertEqual(
attributes["confidence_percentile"].shape,
(1, 2, 3),
)
if __name__ == "__main__":
unittest.main()