- Implement JobClient for handling HTTP requests to the JobServer. - Create ModalJobOperatorBase for managing Blender job lifecycle. - Introduce JobContext and JobServer for job management and state handling. - Add ServiceConnection and ServiceController for managing service processes. - Implement job submission, cancellation, and resource management endpoints in the server. - Ensure proper error handling and job state updates throughout the service.
2523 lines
89 KiB
Python
2523 lines
89 KiB
Python
import importlib
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
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"
|
|
|
|
|
|
class AddonTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.remove_modules()
|
|
fake_bpy = ModuleType("bpy")
|
|
fake_bpy.props = SimpleNamespace(
|
|
BoolProperty=lambda **options: options,
|
|
EnumProperty=lambda **options: options,
|
|
FloatProperty=lambda **options: options,
|
|
IntProperty=lambda **options: options,
|
|
PointerProperty=lambda **options: options,
|
|
StringProperty=lambda **options: options,
|
|
)
|
|
fake_bpy.types = SimpleNamespace(
|
|
AddonPreferences=object,
|
|
Menu=object,
|
|
Operator=object,
|
|
Panel=object,
|
|
PropertyGroup=object,
|
|
Scene=type("Scene", (), {}),
|
|
STATUSBAR_HT_header=SimpleNamespace(
|
|
append=lambda draw: cls.status_bar_draws.append(draw),
|
|
remove=lambda draw: cls.status_bar_draws.remove(draw),
|
|
),
|
|
VIEW3D_MT_object_context_menu=SimpleNamespace(
|
|
append=lambda draw: cls.context_menu_draws.append(draw),
|
|
remove=lambda draw: cls.context_menu_draws.remove(draw),
|
|
),
|
|
OUTLINER_MT_object=SimpleNamespace(
|
|
append=lambda draw: cls.outliner_menu_draws.append(draw),
|
|
remove=lambda draw: cls.outliner_menu_draws.remove(draw),
|
|
),
|
|
)
|
|
cls.registered = []
|
|
cls.status_bar_draws = []
|
|
cls.context_menu_draws = []
|
|
cls.outliner_menu_draws = []
|
|
fake_bpy.utils = SimpleNamespace(
|
|
register_class=cls.registered.append,
|
|
unregister_class=cls.registered.remove,
|
|
)
|
|
fake_bpy.context = SimpleNamespace(
|
|
preferences=SimpleNamespace(addons={}),
|
|
)
|
|
fake_bpy.app = SimpleNamespace(online_access=True)
|
|
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
|
|
fake_bpy.data = SimpleNamespace(
|
|
objects=SimpleNamespace(get=lambda _name: None),
|
|
)
|
|
sys.modules["bpy"] = fake_bpy
|
|
sys.path.insert(0, str(SOURCE_ROOT))
|
|
cls.addon = importlib.import_module("snapform")
|
|
cls.fake_bpy = fake_bpy
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
sys.path.remove(str(SOURCE_ROOT))
|
|
sys.modules.pop("bpy", None)
|
|
cls.remove_modules()
|
|
|
|
@staticmethod
|
|
def remove_modules():
|
|
for module_name in tuple(sys.modules):
|
|
if module_name == "snapform" or module_name.startswith("snapform."):
|
|
sys.modules.pop(module_name)
|
|
|
|
def tearDown(self):
|
|
self.registered.clear()
|
|
self.status_bar_draws.clear()
|
|
self.context_menu_draws.clear()
|
|
self.outliner_menu_draws.clear()
|
|
self.addon.operators.close_active_job()
|
|
self.fake_bpy.data = SimpleNamespace(
|
|
objects=SimpleNamespace(get=lambda _name: None),
|
|
)
|
|
if hasattr(self.fake_bpy.types.Scene, "snapform_settings"):
|
|
del self.fake_bpy.types.Scene.snapform_settings
|
|
|
|
def test_uses_extension_manifest_instead_of_legacy_bl_info(self):
|
|
self.assertFalse(hasattr(self.addon, "bl_info"))
|
|
|
|
def test_registers_and_unregisters_extension_types(self):
|
|
self.addon.register()
|
|
|
|
self.assertEqual(self.registered, list(self.addon.CLASSES))
|
|
self.assertTrue(
|
|
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
|
|
)
|
|
self.assertEqual(
|
|
self.status_bar_draws,
|
|
[self.addon.draw_status_bar],
|
|
)
|
|
self.assertEqual(
|
|
self.context_menu_draws,
|
|
[self.addon.draw_image_context_menu],
|
|
)
|
|
self.assertEqual(
|
|
self.outliner_menu_draws,
|
|
[self.addon.draw_image_context_menu],
|
|
)
|
|
|
|
self.addon.unregister()
|
|
|
|
self.assertEqual(self.registered, [])
|
|
self.assertFalse(
|
|
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
|
|
)
|
|
self.assertEqual(self.status_bar_draws, [])
|
|
self.assertEqual(self.context_menu_draws, [])
|
|
self.assertEqual(self.outliner_menu_draws, [])
|
|
|
|
def test_registers_depth_plane_operator_without_cache_rebuild(self):
|
|
self.assertIn(
|
|
self.addon.ConvertImageToDepthPlane,
|
|
self.addon.CLASSES,
|
|
)
|
|
self.assertIn(self.addon.BuildDepthPlane, self.addon.CLASSES)
|
|
self.assertEqual(
|
|
self.addon.BuildDepthPlane.bl_options,
|
|
{"INTERNAL"},
|
|
)
|
|
self.assertIn(
|
|
"snapform_color_frames_dir",
|
|
self.addon.BuildDepthPlane.__annotations__,
|
|
)
|
|
self.assertIn(
|
|
"snapform_depth_frames_dir",
|
|
self.addon.BuildDepthPlane.__annotations__,
|
|
)
|
|
self.assertIn("execute", self.addon.BuildDepthPlane.__dict__)
|
|
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_registered_job_operators_own_their_blender_properties(self):
|
|
expected = {
|
|
self.addon.operators.GenerateDa3Surface: {
|
|
"snapform_input_path",
|
|
"snapform_use_ray_pose",
|
|
"snapform_process_res",
|
|
"snapform_mesh_create_camera",
|
|
},
|
|
self.addon.operators.GenerateMoge2Surface: {
|
|
"snapform_input_path",
|
|
"snapform_moge_resolution_level",
|
|
"snapform_mesh_create_camera",
|
|
},
|
|
self.addon.operators.GenerateDa3Depth: {
|
|
"snapform_depth_data_name",
|
|
"snapform_use_ray_pose",
|
|
"snapform_process_res",
|
|
},
|
|
self.addon.operators.GenerateMoge2Depth: {
|
|
"snapform_depth_data_name",
|
|
"snapform_moge_resolution_level",
|
|
},
|
|
}
|
|
|
|
for operator, properties in expected.items():
|
|
with self.subTest(operator=operator.__name__):
|
|
self.assertTrue(properties <= set(operator.__annotations__))
|
|
|
|
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()
|
|
with patch.object(self.addon.client, "env_installed", return_value=True):
|
|
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_context_menu_only_installs_env_when_missing(self):
|
|
calls = []
|
|
|
|
class Operator:
|
|
job_type = None
|
|
|
|
class Layout:
|
|
operator_context = None
|
|
|
|
def operator(self, identifier, **options):
|
|
operator = Operator()
|
|
calls.append((identifier, options, operator))
|
|
return operator
|
|
|
|
def separator(self):
|
|
calls.append("separator")
|
|
|
|
menu = self.addon.SnapFormImageMenu()
|
|
menu.layout = Layout()
|
|
with patch.object(self.addon.client, "env_installed", return_value=False):
|
|
menu.draw(None)
|
|
|
|
self.assertEqual(len(calls), 1)
|
|
identifier, options, operator = calls[0]
|
|
self.assertEqual(identifier, "snapform.install_environment")
|
|
self.assertEqual(options["text"], "Install Environment")
|
|
|
|
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_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")
|
|
self.assertFalse(annotations["snapform_remove_background"]["default"])
|
|
self.assertFalse(annotations["snapform_keep_original"]["default"])
|
|
self.assertEqual(
|
|
annotations["snapform_process_res_factor"]["default"],
|
|
100.0,
|
|
)
|
|
self.assertEqual(annotations["snapform_process_res_factor"]["min"], 1.0)
|
|
self.assertEqual(annotations["snapform_process_res_factor"]["max"], 100.0)
|
|
self.assertEqual(
|
|
annotations["snapform_process_res_factor"]["subtype"],
|
|
"PERCENTAGE",
|
|
)
|
|
self.assertEqual(
|
|
annotations["snapform_model"]["default"],
|
|
0,
|
|
)
|
|
|
|
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 DA3 Surface")
|
|
self.assertEqual(image_mesh.NODE_ASSET_NAME, "O_SnapForm.blend")
|
|
|
|
def test_depth_plane_uses_bundled_nodes_and_material_attributes(self):
|
|
image_depth = self.addon.image_depth
|
|
|
|
self.assertTrue(image_depth.node_asset_path().is_file())
|
|
self.assertEqual(
|
|
image_depth.DEPTH_PLANE_NODE_GROUP_NAME,
|
|
"O DA3 Depth Plane",
|
|
)
|
|
self.assertEqual(
|
|
image_depth.MATERIAL_DISPLACEMENT_ATTRIBUTE,
|
|
"o_material_displacement",
|
|
)
|
|
self.assertEqual(
|
|
image_depth.BACKGROUND_DEPTH_ATTRIBUTE,
|
|
"o_background_depth",
|
|
)
|
|
|
|
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)
|
|
self.assertIn(self.addon.BuildOriginalDepthPlane, self.addon.CLASSES)
|
|
self.assertEqual(
|
|
self.addon.ApplyRemovedBackground.bl_options,
|
|
{"INTERNAL"},
|
|
)
|
|
|
|
def test_background_result_creates_new_packed_image(self):
|
|
original_image = SimpleNamespace(
|
|
name="img.png",
|
|
use_fake_user=False,
|
|
)
|
|
transform = object()
|
|
source_object = SimpleNamespace(
|
|
data=original_image,
|
|
matrix_world=transform,
|
|
empty_display_size=2.5,
|
|
empty_image_offset=(-0.25, -0.75),
|
|
)
|
|
pack_calls = []
|
|
result_image = SimpleNamespace(
|
|
name="foreground.png",
|
|
pack=lambda: pack_calls.append(True),
|
|
)
|
|
load_calls = []
|
|
remove_calls = []
|
|
self.fake_bpy.data = SimpleNamespace(
|
|
images=SimpleNamespace(
|
|
load=lambda *args, **options: load_calls.append(
|
|
(args, options)
|
|
)
|
|
or result_image,
|
|
remove=remove_calls.append,
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
output_path = Path(directory) / "foreground.png"
|
|
output_path.touch()
|
|
created = self.addon.image_depth.create_removed_background_image(
|
|
source_object,
|
|
output_path,
|
|
)
|
|
|
|
self.assertIs(created, result_image)
|
|
self.assertIs(source_object.data, result_image)
|
|
self.assertIsNot(source_object.data, original_image)
|
|
self.assertTrue(original_image.use_fake_user)
|
|
self.assertIs(source_object.matrix_world, transform)
|
|
self.assertEqual(source_object.empty_display_size, 2.5)
|
|
self.assertEqual(
|
|
source_object.empty_image_offset,
|
|
(-0.25, -0.75),
|
|
)
|
|
self.assertEqual(result_image.name, "img.foreground")
|
|
self.assertEqual(result_image.alpha_mode, "CHANNEL_PACKED")
|
|
self.assertEqual(pack_calls, [True])
|
|
self.assertEqual(remove_calls, [])
|
|
self.assertEqual(
|
|
load_calls[0][1],
|
|
{"check_existing": False},
|
|
)
|
|
|
|
def test_background_result_name_handles_numbered_format_suffix(self):
|
|
source_object = SimpleNamespace(
|
|
data=SimpleNamespace(name="img.png.001"),
|
|
)
|
|
|
|
self.assertEqual(
|
|
self.addon.image_depth.removed_background_data_name(
|
|
source_object
|
|
),
|
|
"img.001.foreground",
|
|
)
|
|
|
|
def test_depth_background_image_does_not_replace_source_empty(self):
|
|
original_image = SimpleNamespace(
|
|
name="img.png",
|
|
use_fake_user=False,
|
|
)
|
|
source_object = SimpleNamespace(data=original_image)
|
|
pack_calls = []
|
|
result_image = SimpleNamespace(
|
|
name="foreground.png",
|
|
pack=lambda: pack_calls.append(True),
|
|
)
|
|
self.fake_bpy.data = SimpleNamespace(
|
|
images=SimpleNamespace(
|
|
load=lambda *_args, **_options: result_image,
|
|
remove=lambda _image: None,
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
output_path = Path(directory) / "foreground.png"
|
|
output_path.touch()
|
|
created = self.addon.image_depth.create_removed_background_image(
|
|
source_object,
|
|
output_path,
|
|
assign_to_source=False,
|
|
)
|
|
|
|
self.assertIs(created, result_image)
|
|
self.assertIs(source_object.data, original_image)
|
|
self.assertFalse(original_image.use_fake_user)
|
|
self.assertEqual(result_image.name, "img.foreground")
|
|
self.assertEqual(result_image.alpha_mode, "CHANNEL_PACKED")
|
|
self.assertEqual(pack_calls, [True])
|
|
|
|
def test_sequence_background_result_preserves_empty_image_start(self):
|
|
original_image = SimpleNamespace(
|
|
name="img.png",
|
|
use_fake_user=False,
|
|
)
|
|
image_user = SimpleNamespace(
|
|
frame_start=4,
|
|
frame_offset=0,
|
|
frame_duration=1,
|
|
use_auto_refresh=False,
|
|
use_cyclic=False,
|
|
)
|
|
source_object = SimpleNamespace(
|
|
data=original_image,
|
|
image_user=image_user,
|
|
)
|
|
result_image = SimpleNamespace(
|
|
name="foreground.png",
|
|
source="SEQUENCE",
|
|
pack=lambda: None,
|
|
unpack=lambda **_options: None,
|
|
packed_files=(),
|
|
)
|
|
self.fake_bpy.data = SimpleNamespace(
|
|
images=SimpleNamespace(
|
|
load=lambda *_args, **_options: result_image,
|
|
remove=lambda _image: None,
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
result_directory = Path(directory)
|
|
(result_directory / "foreground.0001.png").touch()
|
|
(result_directory / "foreground.0002.png").touch()
|
|
created = self.addon.image_depth.create_removed_background_image(
|
|
source_object,
|
|
result_directory,
|
|
)
|
|
|
|
self.assertIs(created, result_image)
|
|
self.assertIs(source_object.data, result_image)
|
|
self.assertEqual(result_image.alpha_mode, "CHANNEL_PACKED")
|
|
self.assertEqual(image_user.frame_start, 4)
|
|
self.assertEqual(image_user.frame_duration, 2)
|
|
self.assertEqual(image_user.frame_offset, 0)
|
|
self.assertTrue(image_user.use_auto_refresh)
|
|
self.assertFalse(image_user.use_cyclic)
|
|
|
|
def test_image_empty_background_action_starts_production_job(self):
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(name="img.png"),
|
|
)
|
|
context = SimpleNamespace(
|
|
object=source_object,
|
|
scene=SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(video_frames=16)
|
|
),
|
|
)
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
process_image_background=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
operator = self.addon.RemoveImageBackground()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, True),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertNotIn("job_type", calls[0])
|
|
self.assertEqual(calls[0]["snapform_input_path"], str(input_path))
|
|
self.assertEqual(
|
|
calls[0]["snapform_source_object_name"],
|
|
"Reference",
|
|
)
|
|
self.assertTrue(calls[0]["snapform_delete_input"])
|
|
|
|
def test_original_depth_background_starts_independent_job(self):
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
operator.snapform_model = "ORIGINAL"
|
|
operator.snapform_unshaded = False
|
|
operator.snapform_remove_background = True
|
|
operator.snapform_keep_original = True
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(name="img.png"),
|
|
)
|
|
context = SimpleNamespace(object=source_object, scene=object())
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
generate_original_background=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, False),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertNotIn("job_type", calls[0])
|
|
self.assertNotIn("snapform_background_depth_distance", calls[0])
|
|
|
|
def test_original_depth_animation_extracts_frames_and_background(self):
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
operator.snapform_model = "ORIGINAL"
|
|
operator.snapform_unshaded = False
|
|
operator.snapform_remove_background = True
|
|
operator.snapform_keep_original = True
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(
|
|
name="clip.mp4",
|
|
source="MOVIE",
|
|
frame_duration=97,
|
|
),
|
|
)
|
|
scene = SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(video_frames=16)
|
|
)
|
|
context = SimpleNamespace(object=source_object, scene=scene)
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
generate_original_background=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "clip.mp4"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, False),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertEqual(scene.snapform_settings.video_frames, 97)
|
|
self.assertNotIn("job_type", calls[0])
|
|
self.assertEqual(calls[0]["snapform_input_path"], str(input_path))
|
|
self.assertEqual(calls[0]["snapform_source_object_name"], "Reference")
|
|
self.assertFalse(calls[0]["snapform_delete_input"])
|
|
self.assertFalse(calls[0]["snapform_delete_source"])
|
|
|
|
def test_original_depth_without_background_uses_frame_extraction(self):
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
operator.snapform_model = "ORIGINAL"
|
|
operator.snapform_unshaded = False
|
|
operator.snapform_remove_background = False
|
|
operator.snapform_keep_original = False
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(name="img.png"),
|
|
)
|
|
context = SimpleNamespace(object=source_object, scene=object())
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
extract_original_frames=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, False),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertNotIn("job_type", calls[0])
|
|
self.assertTrue(calls[0]["snapform_delete_source"])
|
|
|
|
def test_conversion_dialog_exposes_requested_settings(self):
|
|
operator = self.addon.ConvertImageToDepthPlane
|
|
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_model"]["default"],
|
|
0,
|
|
)
|
|
self.assertFalse(operator.__annotations__["snapform_use_ray_pose"]["default"])
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_process_res_factor"]["default"],
|
|
100.0,
|
|
)
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_process_res_factor"]["min"],
|
|
1.0,
|
|
)
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_process_res_factor"]["max"],
|
|
100.0,
|
|
)
|
|
self.assertFalse(
|
|
operator.__annotations__["snapform_remove_background"]["default"]
|
|
)
|
|
self.assertFalse(
|
|
operator.__annotations__["snapform_keep_original"]["default"]
|
|
)
|
|
self.assertFalse(
|
|
operator.__annotations__["snapform_unshaded"]["default"]
|
|
)
|
|
self.assertEqual(
|
|
set(operator.__annotations__),
|
|
{
|
|
"snapform_unshaded",
|
|
"snapform_model",
|
|
"snapform_use_ray_pose",
|
|
"snapform_process_res_factor",
|
|
"snapform_moge_resolution_level",
|
|
"snapform_remove_background",
|
|
"snapform_keep_original",
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_model"]["name"],
|
|
"Depth From",
|
|
)
|
|
self.assertEqual(
|
|
operator.__annotations__["snapform_unshaded"]["name"],
|
|
"Unshaded",
|
|
)
|
|
model_items_callback = operator.__annotations__["snapform_model"]["items"]
|
|
with patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
):
|
|
model_items = model_items_callback(None, None)
|
|
self.assertEqual(
|
|
[item[0] for item in model_items],
|
|
[
|
|
"MOGE2_VITS_NORMAL",
|
|
"MOGE2_VITB_NORMAL",
|
|
"MOGE2_VITL_NORMAL",
|
|
"SMALL",
|
|
"BASE",
|
|
"ORIGINAL",
|
|
],
|
|
)
|
|
|
|
def test_conversion_dialog_leaves_last_properties_to_blender(self):
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
context = SimpleNamespace(
|
|
object=SimpleNamespace(
|
|
data=SimpleNamespace(source="FILE", frame_duration=1)
|
|
),
|
|
scene=SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(video_frames=16)
|
|
),
|
|
window_manager=SimpleNamespace(
|
|
invoke_props_dialog=lambda invoked, **_options: invoked,
|
|
),
|
|
)
|
|
|
|
result = operator.invoke(context, None)
|
|
|
|
self.assertIs(result, operator)
|
|
|
|
def test_remove_background_defaults_to_empty_image_frames(self):
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
|
image_user=SimpleNamespace(frame_duration=24),
|
|
)
|
|
scene = SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(video_frames=16)
|
|
)
|
|
context = SimpleNamespace(object=source_object, scene=scene)
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
process_image_background=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
operator = self.addon.RemoveImageBackground()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, False),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertEqual(scene.snapform_settings.video_frames, 24)
|
|
|
|
def test_conversion_execute_uses_background_mask_frame_range(self):
|
|
source_object = SimpleNamespace(
|
|
name="Reference",
|
|
data=SimpleNamespace(
|
|
name="clip.mp4",
|
|
source="MOVIE",
|
|
frame_duration=97,
|
|
size=(1920, 1080),
|
|
),
|
|
image_user=SimpleNamespace(frame_duration=24),
|
|
)
|
|
scene = SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(video_frames=16)
|
|
)
|
|
context = SimpleNamespace(object=source_object, scene=scene)
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
operator.snapform_model = "BASE"
|
|
operator.snapform_use_ray_pose = True
|
|
operator.snapform_process_res_factor = 50.0
|
|
operator.snapform_unshaded = False
|
|
operator.snapform_remove_background = True
|
|
operator.snapform_keep_original = True
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
generate_da3_depth=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "clip.mp4"
|
|
input_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"prepare_image_input",
|
|
return_value=(input_path, False),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
self.assertEqual(scene.snapform_settings.video_frames, 24)
|
|
self.assertNotIn("job_type", calls[0])
|
|
self.assertTrue(calls[0]["snapform_remove_background"])
|
|
self.assertNotIn("snapform_background_depth_distance", calls[0])
|
|
|
|
def test_conversion_dialog_marks_missing_models(self):
|
|
items_callback = self.addon.ConvertImageToDepthPlane.__annotations__[
|
|
"snapform_model"
|
|
]["items"]
|
|
with patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
side_effect=lambda key: key in {"SMALL", "BASE"},
|
|
):
|
|
items = items_callback(None, None)
|
|
|
|
self.assertEqual(
|
|
[item[0] for item in items],
|
|
[
|
|
"MOGE2_VITS_NORMAL",
|
|
"MOGE2_VITB_NORMAL",
|
|
"MOGE2_VITL_NORMAL",
|
|
"SMALL",
|
|
"BASE",
|
|
"ORIGINAL",
|
|
],
|
|
)
|
|
labels = {item[0]: item[1] for item in items}
|
|
self.assertEqual(labels["BASE"], "DA3 Base")
|
|
self.assertEqual(labels["MOGE2_VITB_NORMAL"], "MoGe-2 ViT-B Normal (Missing)")
|
|
|
|
def test_update_status_formats_stage_progress(self):
|
|
operator = self.addon.operators.ModalJobOperatorBase()
|
|
|
|
self.assertEqual(
|
|
operator.format_job_status(
|
|
{
|
|
"progress": 0.5,
|
|
"message": "2 downloaded",
|
|
"stage": 3,
|
|
"stages": 8,
|
|
"stage_label": "pytorch",
|
|
},
|
|
"fallback",
|
|
),
|
|
"3/8 2 downloaded",
|
|
)
|
|
self.assertEqual(
|
|
operator.format_job_status(
|
|
{
|
|
"progress": 0.0,
|
|
"message": "",
|
|
"stage": 6,
|
|
"stages": 8,
|
|
"stage_label": "da3 small",
|
|
},
|
|
"fallback",
|
|
),
|
|
"6/8 da3 small",
|
|
)
|
|
self.assertEqual(
|
|
operator.format_job_status(
|
|
{
|
|
"progress": 0.5,
|
|
"message": "Estimating depth",
|
|
},
|
|
"fallback",
|
|
),
|
|
"Estimating depth",
|
|
)
|
|
self.assertEqual(
|
|
operator.format_job_status(
|
|
{
|
|
"progress": 0.25,
|
|
"message": "Downloading packages",
|
|
"stage": 2,
|
|
"stages": 3,
|
|
"stage_label": "PyTorch",
|
|
"eta_seconds": 125,
|
|
},
|
|
"fallback",
|
|
),
|
|
"2/3 Downloading packages",
|
|
)
|
|
self.assertEqual(
|
|
operator.format_job_status(
|
|
{
|
|
"progress": 0.5,
|
|
"message": "",
|
|
"stage": 2,
|
|
"stages": 3,
|
|
"stage_label": "PyTorch",
|
|
},
|
|
"2/3 PyTorch | Downloading packages",
|
|
),
|
|
"2/3 PyTorch",
|
|
)
|
|
|
|
def test_modal_job_operator_only_requires_type_and_parameters(self):
|
|
controller = object()
|
|
|
|
class ExampleJob(self.addon.operators.ModalJobOperatorBase):
|
|
controller_factory = staticmethod(lambda: controller)
|
|
job_type = "example"
|
|
|
|
def job_parameters(self, context):
|
|
return {"value": context.value}
|
|
|
|
operator = ExampleJob()
|
|
request = operator.create_job_request(SimpleNamespace(value=42))
|
|
|
|
self.assertIs(operator.get_service_controller(), controller)
|
|
self.assertEqual(request.job_type, "example")
|
|
self.assertEqual(request.parameters, {"value": 42})
|
|
|
|
def test_update_status_resets_progress_when_stage_changes(self):
|
|
operator = self.addon.operators.SnapFormJobOperator()
|
|
settings = SimpleNamespace(progress=0.9, status="previous")
|
|
context = SimpleNamespace(
|
|
scene=SimpleNamespace(snapform_settings=settings)
|
|
)
|
|
job = SimpleNamespace(progress_stage=1, progress=0.9)
|
|
|
|
with patch.object(operator, "redraw_job_ui"):
|
|
operator.update_from_job_status(
|
|
context,
|
|
job,
|
|
{
|
|
"progress": 0.1,
|
|
"message": "Downloading model",
|
|
"stage": 2,
|
|
"stages": 4,
|
|
"stage_label": "MoGe-2",
|
|
},
|
|
)
|
|
|
|
self.assertEqual(job.progress_stage, 2)
|
|
self.assertEqual(settings.progress, 0.1)
|
|
|
|
def test_model_options_mark_only_models_that_are_not_downloaded(self):
|
|
items_callback = self.addon.SnapFormSettings.__annotations__["model"][
|
|
"items"
|
|
]
|
|
with patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
side_effect=lambda key: key == "BASE",
|
|
):
|
|
items = items_callback(None, None)
|
|
|
|
labels = {item[0]: item[1] for item in items}
|
|
self.assertEqual(labels["BASE"], "DA3 Base")
|
|
self.assertEqual(labels["SMALL"], "DA3 Small (Missing)")
|
|
|
|
def test_packed_image_is_exported_without_unpacking(self):
|
|
operator = self.addon.ConvertImageToDepthPlane()
|
|
operator.snapform_model = "GIANT_1_1"
|
|
operator.snapform_use_ray_pose = True
|
|
operator.snapform_process_res_factor = 50.0
|
|
operator.snapform_unshaded = True
|
|
operator.snapform_remove_background = True
|
|
operator.snapform_keep_original = False
|
|
image = SimpleNamespace(
|
|
name="img.png",
|
|
filepath="",
|
|
packed_file=object(),
|
|
size=(1920, 1080),
|
|
)
|
|
context = SimpleNamespace(
|
|
object=SimpleNamespace(name="Reference", data=image),
|
|
scene=object(),
|
|
)
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
generate_da3_depth=lambda **options: calls.append(options)
|
|
or {"RUNNING_MODAL"},
|
|
),
|
|
)
|
|
|
|
with TemporaryDirectory() as directory:
|
|
packed_path = Path(directory) / "packed.png"
|
|
packed_path.touch()
|
|
with patch.object(
|
|
self.addon.image_depth,
|
|
"save_packed_image",
|
|
return_value=packed_path,
|
|
) as save_packed_image, patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
save_packed_image.assert_called_once_with(image, context.scene)
|
|
self.assertEqual(calls[0]["snapform_input_path"], str(packed_path))
|
|
self.assertEqual(calls[0]["snapform_process_res"], 966)
|
|
self.assertEqual(calls[0]["snapform_model_key"], "GIANT_1_1")
|
|
self.assertTrue(calls[0]["snapform_remove_background"])
|
|
self.assertTrue(calls[0]["snapform_unshaded"])
|
|
self.assertEqual(calls[0]["snapform_depth_data_name"], "img.depth")
|
|
self.assertTrue(calls[0]["snapform_delete_input"])
|
|
|
|
def test_packed_image_export_writes_a_png_copy(self):
|
|
class PackedImage:
|
|
def save_render(self, filepath, scene):
|
|
self.filepath = filepath
|
|
self.scene = scene
|
|
self.file_format = scene.render.image_settings.file_format
|
|
Path(filepath).write_bytes(b"packed image")
|
|
|
|
image = PackedImage()
|
|
image_settings = SimpleNamespace(file_format="OPEN_EXR")
|
|
scene = SimpleNamespace(
|
|
render=SimpleNamespace(image_settings=image_settings),
|
|
)
|
|
with TemporaryDirectory() as directory:
|
|
output_path = Path(directory) / "input.png"
|
|
with patch.object(
|
|
self.addon.client,
|
|
"temporary_input_path",
|
|
return_value=output_path,
|
|
):
|
|
result = self.addon.image_depth.save_packed_image(image, scene)
|
|
|
|
self.assertEqual(result, output_path)
|
|
self.assertEqual(output_path.read_bytes(), b"packed image")
|
|
self.assertEqual(image.filepath, str(output_path))
|
|
self.assertIs(image.scene, scene)
|
|
self.assertEqual(image.file_format, "PNG")
|
|
self.assertEqual(image_settings.file_format, "OPEN_EXR")
|
|
|
|
def test_temporary_packed_input_is_removed_when_job_closes(self):
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "packed.png"
|
|
input_path.touch()
|
|
data = self.addon.operators.SnapFormJobData(
|
|
kind="test",
|
|
temporary_input_path=input_path,
|
|
)
|
|
|
|
self.addon.operators.SnapFormJobOperator().cleanup_job_data(data)
|
|
|
|
self.assertFalse(input_path.exists())
|
|
|
|
def test_temporary_frame_directory_is_removed_when_job_closes(self):
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
temporary_directory = root / "inputs" / "frames"
|
|
temporary_directory.mkdir(parents=True)
|
|
(temporary_directory / "frame-0000.jpg").touch()
|
|
data = self.addon.operators.SnapFormJobData(
|
|
kind="test",
|
|
temporary_input_path=temporary_directory,
|
|
)
|
|
|
|
self.addon.operators.SnapFormJobOperator().cleanup_job_data(data)
|
|
|
|
self.assertFalse(temporary_directory.exists())
|
|
|
|
def test_prepare_image_input_passes_movie_file_through(self):
|
|
with TemporaryDirectory() as directory:
|
|
video_path = Path(directory) / "clip.mp4"
|
|
video_path.touch()
|
|
image = SimpleNamespace(
|
|
source="MOVIE",
|
|
filepath=str(video_path),
|
|
)
|
|
|
|
result, delete_input = self.addon.image_depth.prepare_image_input(
|
|
image,
|
|
object(),
|
|
)
|
|
|
|
self.assertEqual(result, video_path)
|
|
self.assertFalse(delete_input)
|
|
|
|
def test_prepare_image_input_copies_sequence_frames(self):
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
sequence_directory = root / "seq"
|
|
sequence_directory.mkdir()
|
|
(sequence_directory / "frame-0001.jpg").touch()
|
|
(sequence_directory / "frame-0002.jpg").touch()
|
|
image = SimpleNamespace(
|
|
source="SEQUENCE",
|
|
filepath=str(sequence_directory / "frame-0001.jpg"),
|
|
)
|
|
temporary_directory = root / "temp-frames"
|
|
temporary_directory.mkdir()
|
|
with patch.object(
|
|
self.addon.client,
|
|
"temporary_input_directory",
|
|
return_value=temporary_directory,
|
|
):
|
|
result, delete_input = (
|
|
self.addon.image_depth.prepare_image_input(
|
|
image,
|
|
object(),
|
|
)
|
|
)
|
|
|
|
self.assertEqual(result, temporary_directory)
|
|
self.assertTrue(delete_input)
|
|
self.assertTrue((temporary_directory / "frame.0001.jpg").is_file())
|
|
self.assertTrue((temporary_directory / "frame.0002.jpg").is_file())
|
|
|
|
def test_image_depth_model_and_ray_pose_are_independent(self):
|
|
settings = SimpleNamespace(
|
|
input_path="debug-input.png",
|
|
export_format="gs_ply",
|
|
model="BASE",
|
|
gs_model="GIANT_1_1",
|
|
)
|
|
operator = self.addon.operators.GenerateDa3Depth()
|
|
operator.snapform_source_object_name = "Reference"
|
|
operator.snapform_process_res = 336
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": Path(directory) / "job.log",
|
|
"status": Path(directory) / "job.json",
|
|
"result": Path(directory) / "result",
|
|
}
|
|
for snapform_model, snapform_ray_pose in (
|
|
("SMALL", True),
|
|
("BASE", False),
|
|
("GIANT_1_1", False),
|
|
("NESTED_GIANT_LARGE_1_1", True),
|
|
):
|
|
with self.subTest(model=snapform_model), patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
) as model_ready, patch.object(
|
|
self.addon.client,
|
|
"generate_da3_depth",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as reconstruct:
|
|
operator.snapform_model_key = snapform_model
|
|
operator.snapform_use_ray_pose = snapform_ray_pose
|
|
command = operator.create_request(settings)
|
|
|
|
self.assertIsInstance(
|
|
command,
|
|
self.addon.operators.JobRequest,
|
|
)
|
|
model_ready.assert_called_once_with(snapform_model)
|
|
self.assertEqual(reconstruct.call_args.args[1], input_path)
|
|
self.assertEqual(reconstruct.call_args.args[2], snapform_model)
|
|
self.assertEqual(
|
|
reconstruct.call_args.kwargs["process_res"],
|
|
336,
|
|
)
|
|
self.assertEqual(
|
|
reconstruct.call_args.kwargs["use_ray_pose"],
|
|
snapform_ray_pose,
|
|
)
|
|
|
|
def test_image_depth_background_mask_requires_ben2(self):
|
|
settings = SimpleNamespace(
|
|
input_path="debug-input.png",
|
|
export_format="mini_npz",
|
|
model="BASE",
|
|
gs_model="GIANT_1_1",
|
|
)
|
|
operator = self.addon.operators.GenerateDa3Depth()
|
|
operator.snapform_model_key = "BASE"
|
|
operator.snapform_use_ray_pose = True
|
|
operator.snapform_process_res = 1024
|
|
operator.snapform_remove_background = True
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
side_effect=lambda key: key == "BASE",
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, "BEN2 Base"):
|
|
operator.create_request(settings)
|
|
|
|
def test_image_depth_background_option_reaches_service_request(self):
|
|
client = importlib.import_module("snapform.client")
|
|
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(
|
|
client,
|
|
"venv_python",
|
|
return_value=Path("python"),
|
|
), patch.object(
|
|
client,
|
|
"model_directory",
|
|
side_effect=lambda key: Path(key.lower()),
|
|
):
|
|
request = importlib.import_module("snapform.client").generate_da3_depth(
|
|
settings,
|
|
Path("input.png"),
|
|
"BASE",
|
|
process_res=504,
|
|
use_ray_pose=False,
|
|
remove_background=True,
|
|
)
|
|
|
|
self.assertEqual(
|
|
request.payload["parameters"]["remove_background_model"],
|
|
"BEN2_BASE",
|
|
)
|
|
self.assertNotIn(
|
|
"background_depth_distance",
|
|
request.payload["parameters"],
|
|
)
|
|
|
|
def test_service_busy_snapshot_is_released_for_completed_local_job(self):
|
|
client = self.addon.client
|
|
controller = client.service_controller()
|
|
previous_snapshot = controller.snapshot
|
|
controller.snapshot = {
|
|
"state": "BUSY",
|
|
"busy": True,
|
|
"active_job": {"job_id": "depth-job"},
|
|
}
|
|
try:
|
|
controller.mark_job_complete("depth-job")
|
|
|
|
self.assertEqual(client.service_status()["state"], "READY")
|
|
self.assertFalse(client.service_busy())
|
|
finally:
|
|
controller.snapshot = previous_snapshot
|
|
|
|
def test_job_submission_starts_without_blocking_execute(self):
|
|
operator = self.addon.operators.GenerateDa3Depth()
|
|
operator.snapform_source_object_name = "Reference"
|
|
operator.snapform_depth_data_name = "img.depth"
|
|
operator.snapform_process_res = 1024
|
|
operator.snapform_delete_source = False
|
|
operator.snapform_delete_input = False
|
|
operator.snapform_unshaded = False
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def submit(_request):
|
|
started.set()
|
|
release.wait(timeout=2.0)
|
|
|
|
window_manager = SimpleNamespace(
|
|
event_timer_add=lambda *_args, **_kwargs: object(),
|
|
modal_handler_add=lambda *_args: None,
|
|
)
|
|
context = SimpleNamespace(
|
|
scene=SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(status="", progress=0.0)
|
|
),
|
|
window_manager=window_manager,
|
|
window=object(),
|
|
)
|
|
request = self.addon.operators.JobRequest({"job_id": "job"})
|
|
try:
|
|
with patch.object(
|
|
operator,
|
|
"create_job_request",
|
|
return_value=request,
|
|
), patch.object(
|
|
self.addon.client.service_controller(),
|
|
"submit",
|
|
side_effect=submit,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"service_log_path",
|
|
return_value=Path("host.log"),
|
|
), patch.object(
|
|
self.addon.client,
|
|
"storage_root",
|
|
return_value=Path("."),
|
|
), patch.object(
|
|
self.addon.client,
|
|
"creation_flags",
|
|
return_value=0,
|
|
), patch.object(operator, "redraw_job_ui"):
|
|
before = time.perf_counter()
|
|
result = operator.execute(context)
|
|
elapsed = time.perf_counter() - before
|
|
|
|
self.assertEqual(result, {"RUNNING_MODAL"})
|
|
self.assertLess(elapsed, 0.2)
|
|
self.assertTrue(started.wait(timeout=1.0))
|
|
finally:
|
|
release.set()
|
|
job = self.addon.operators.active_job()
|
|
if job is not None and job.start_thread is not None:
|
|
job.start_thread.join(timeout=2.0)
|
|
self.addon.operators.close_active_job()
|
|
|
|
def test_preferences_use_package_identifier(self):
|
|
self.assertEqual(
|
|
self.addon.SnapFormPreferences.bl_idname,
|
|
"snapform",
|
|
)
|
|
|
|
def test_default_storage_is_shared_in_user_home(self):
|
|
client = importlib.import_module("snapform.client")
|
|
|
|
self.assertEqual(client.DEFAULT_STORAGE_ROOT.name, ".snapform")
|
|
self.assertEqual(
|
|
self.addon.SnapFormPreferences.__annotations__["storage_root"][
|
|
"default"
|
|
],
|
|
str(client.DEFAULT_STORAGE_ROOT),
|
|
)
|
|
|
|
def test_ai_service_uses_loopback_address(self):
|
|
client = importlib.import_module("snapform.client")
|
|
|
|
self.assertEqual(client.SERVICE_ADDRESS, "127.0.0.1")
|
|
|
|
def test_closing_blender_terminates_service_it_started(self):
|
|
client = importlib.import_module("snapform.client")
|
|
controller = client.service_controller()
|
|
with patch.object(controller, "detach") as detach:
|
|
client.detach_service_client()
|
|
detach.assert_called_once_with()
|
|
|
|
def test_process_res_method_defaults_to_upper_bound_resize(self):
|
|
process_res = self.addon.SnapFormSettings.__annotations__[
|
|
"process_res"
|
|
]
|
|
process_res_method = (
|
|
self.addon.SnapFormSettings.__annotations__[
|
|
"process_res_method"
|
|
]
|
|
)
|
|
|
|
self.assertEqual(process_res["default"], 504)
|
|
self.assertEqual(process_res_method["default"], "upper_bound_resize")
|
|
|
|
def test_reconstruct_request_preserves_da3_parameter_names(self):
|
|
client = importlib.import_module("snapform.client")
|
|
settings = SimpleNamespace(
|
|
input_path="input.png",
|
|
export_format="gs_ply",
|
|
model="GIANT_1_1",
|
|
gs_model="NESTED_GIANT_LARGE_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="CUDA",
|
|
use_ray_pose=False,
|
|
ref_view_strategy="saddle_balanced",
|
|
)
|
|
with patch.object(
|
|
client,
|
|
"venv_python",
|
|
return_value=Path("python"),
|
|
), patch.object(
|
|
client,
|
|
"model_directory",
|
|
return_value=Path("model"),
|
|
):
|
|
request = importlib.import_module("snapform.client").debug_da3(
|
|
settings,
|
|
Path("input.png"),
|
|
"NESTED_GIANT_LARGE_1_1",
|
|
)
|
|
|
|
parameters = request.payload["parameters"]
|
|
self.assertEqual(request.payload["job_type"], "debug-da3")
|
|
self.assertEqual(parameters["inference_mode"], "joint")
|
|
self.assertEqual(parameters["export_format"], "gs_ply")
|
|
for parameter in (
|
|
"process_res",
|
|
"process_res_method",
|
|
"conf_thresh_percentile",
|
|
"num_max_points",
|
|
"show_cameras",
|
|
"gs_views_interval",
|
|
"use_ray_pose",
|
|
"ref_view_strategy",
|
|
):
|
|
with self.subTest(parameter=parameter):
|
|
self.assertIn(parameter, parameters)
|
|
|
|
def test_remove_background_request_passes_video_frames(self):
|
|
client = importlib.import_module("snapform.client")
|
|
settings = SimpleNamespace(device="AUTO", video_frames=24)
|
|
with patch.object(
|
|
client,
|
|
"venv_python",
|
|
return_value=Path("python"),
|
|
), patch.object(
|
|
client,
|
|
"model_directory",
|
|
return_value=Path("ben2_base"),
|
|
):
|
|
request = importlib.import_module("snapform.client").remove_background(
|
|
settings,
|
|
Path("clip.mp4"),
|
|
"BEN2_BASE",
|
|
start_frame=70,
|
|
)
|
|
|
|
parameters = request.payload["parameters"]
|
|
self.assertEqual(parameters["video_frames"], 24)
|
|
self.assertEqual(parameters["start_frame"], 70)
|
|
self.assertNotIn("background_depth_distance", parameters)
|
|
|
|
def test_extract_frames_request_passes_frame_range(self):
|
|
client = importlib.import_module("snapform.client")
|
|
settings = SimpleNamespace(device="AUTO", video_frames=24)
|
|
|
|
request = importlib.import_module("snapform.client").extract_frames(
|
|
settings,
|
|
Path("clip.mp4"),
|
|
start_frame=70,
|
|
)
|
|
|
|
parameters = request.payload["parameters"]
|
|
self.assertEqual(request.payload["job_type"], "extract-frames")
|
|
self.assertEqual(parameters["input"], str(Path("clip.mp4")))
|
|
self.assertNotIn("output_dir", parameters)
|
|
self.assertEqual(parameters["video_frames"], 24)
|
|
self.assertEqual(parameters["start_frame"], 70)
|
|
|
|
def test_empty_image_start_frame_honors_image_user(self):
|
|
operators = self.addon.operators
|
|
|
|
self.assertEqual(
|
|
operators.empty_image_start_frame(
|
|
SimpleNamespace(
|
|
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
|
image_user=SimpleNamespace(
|
|
frame_start=1,
|
|
frame_offset=70,
|
|
frame_duration=24,
|
|
)
|
|
)
|
|
),
|
|
71,
|
|
)
|
|
self.assertEqual(
|
|
operators.empty_image_start_frame(
|
|
SimpleNamespace(
|
|
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
|
image_user=SimpleNamespace(
|
|
frame_start=10,
|
|
frame_offset=5,
|
|
frame_duration=24,
|
|
)
|
|
)
|
|
),
|
|
6,
|
|
)
|
|
self.assertEqual(
|
|
operators.empty_image_start_frame(
|
|
SimpleNamespace(image_user=None)
|
|
),
|
|
1,
|
|
)
|
|
|
|
def test_image_depth_job_passes_empty_frame_offset(self):
|
|
settings = SimpleNamespace(
|
|
input_path="debug-input.png",
|
|
export_format="mini_npz",
|
|
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",
|
|
)
|
|
operator = self.addon.operators.GenerateDa3Depth()
|
|
operator.snapform_source_object_name = "Reference"
|
|
operator.snapform_model_key = "BASE"
|
|
operator.snapform_use_ray_pose = True
|
|
operator.snapform_process_res = 1024
|
|
original_get = self.fake_bpy.data.objects.get
|
|
self.fake_bpy.data.objects.get = lambda _name: SimpleNamespace(
|
|
data=SimpleNamespace(source="MOVIE", frame_duration=97),
|
|
image_user=SimpleNamespace(
|
|
frame_start=1,
|
|
frame_offset=70,
|
|
frame_duration=24,
|
|
),
|
|
)
|
|
try:
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": Path(directory) / "job.log",
|
|
"status": Path(directory) / "job.json",
|
|
"result": Path(directory) / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"generate_da3_depth",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as reconstruct:
|
|
operator.create_request(settings)
|
|
finally:
|
|
self.fake_bpy.data.objects.get = original_get
|
|
|
|
self.assertEqual(reconstruct.call_args.kwargs["start_frame"], 71)
|
|
|
|
def test_reconstruct_request_accepts_image_depth_overrides(self):
|
|
client = importlib.import_module("snapform.client")
|
|
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(
|
|
client,
|
|
"venv_python",
|
|
return_value=Path("python"),
|
|
), patch.object(
|
|
client,
|
|
"model_directory",
|
|
return_value=Path("large-model"),
|
|
) as snapform_model_directory:
|
|
request = importlib.import_module("snapform.client").generate_da3_depth(
|
|
settings,
|
|
Path("input.png"),
|
|
"BASE",
|
|
process_res=252,
|
|
use_ray_pose=False,
|
|
)
|
|
|
|
snapform_model_directory.assert_not_called()
|
|
parameters = request.payload["parameters"]
|
|
self.assertEqual(parameters["model"], "BASE")
|
|
self.assertEqual(parameters["process_res"], 252)
|
|
self.assertEqual(
|
|
parameters["process_res_method"],
|
|
"upper_bound_resize",
|
|
)
|
|
self.assertEqual(parameters["use_ray_pose"], 0)
|
|
|
|
def test_reconstruct_request_enables_ben2_for_image_mesh(self):
|
|
client = importlib.import_module("snapform.client")
|
|
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(
|
|
client,
|
|
"venv_python",
|
|
return_value=Path("python"),
|
|
), patch.object(
|
|
client,
|
|
"model_directory",
|
|
side_effect=lambda key: Path(key.lower()),
|
|
):
|
|
request = importlib.import_module("snapform.client").generate_da3_surface(
|
|
settings,
|
|
Path("input.png"),
|
|
"BASE",
|
|
process_res=504,
|
|
use_ray_pose=False,
|
|
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"],
|
|
"BEN2_BASE",
|
|
)
|
|
|
|
def test_export_format_property_includes_all_da3_formats(self):
|
|
export_format = self.addon.SnapFormSettings.__annotations__[
|
|
"export_format"
|
|
]
|
|
identifiers = [item[0] for item in export_format["items"]]
|
|
|
|
self.assertEqual(
|
|
identifiers,
|
|
["glb", "gs_ply", "depth_vis", "mini_npz", "mesh"],
|
|
)
|
|
self.assertEqual(export_format["default"], "glb")
|
|
self.assertEqual(export_format["name"], "Output Type")
|
|
self.assertEqual(
|
|
[item[1] for item in export_format["items"]],
|
|
[
|
|
"Point Cloud (GLB)",
|
|
"Gaussian Splats (PLY)",
|
|
"Depth Preview (Images)",
|
|
"Depth Data (NPZ)",
|
|
"Textured Surface (GLB)",
|
|
],
|
|
)
|
|
|
|
def test_mesh_is_a_da3_output_type(self):
|
|
settings = SimpleNamespace(
|
|
input_path="",
|
|
export_format="mesh",
|
|
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="CPU",
|
|
use_ray_pose=False,
|
|
ref_view_strategy="saddle_balanced",
|
|
)
|
|
operator = self.addon.operators.DebugDa3()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
input_path = Path(directory) / "input.png"
|
|
input_path.touch()
|
|
settings.input_path = str(input_path)
|
|
paths = {
|
|
"log": Path(directory) / "job.log",
|
|
"status": Path(directory) / "job.json",
|
|
"result": Path(directory) / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"debug_da3",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as reconstruct:
|
|
operator.create_request(settings)
|
|
|
|
self.assertEqual(reconstruct.call_args.args[1], input_path)
|
|
self.assertEqual(reconstruct.call_args.args[2], "BASE")
|
|
|
|
def test_confidence_threshold_is_a_bounded_percentage(self):
|
|
confidence = self.addon.SnapFormSettings.__annotations__[
|
|
"conf_thresh_percentile"
|
|
]
|
|
|
|
self.assertEqual(confidence["name"], "Confidence Threshold")
|
|
self.assertEqual(confidence["min"], 0.0)
|
|
self.assertEqual(confidence["max"], 100.0)
|
|
self.assertEqual(confidence["subtype"], "PERCENTAGE")
|
|
|
|
def test_gs_model_property_only_includes_gaussian_models(self):
|
|
gs_model = self.addon.SnapFormSettings.__annotations__[
|
|
"gs_model"
|
|
]
|
|
identifiers = [item[0] for item in gs_model["items"](None, None)]
|
|
|
|
self.assertEqual(
|
|
identifiers,
|
|
[],
|
|
)
|
|
|
|
def test_device_enums_list_only_supported_devices(self):
|
|
client = importlib.import_module("snapform.client")
|
|
with patch.object(
|
|
client,
|
|
"supported_devices",
|
|
return_value=["cuda", "cpu"],
|
|
):
|
|
items = client.device_enum_items(None, None)
|
|
|
|
self.assertEqual([item[0] for item in items], ["CUDA", "CPU"])
|
|
self.assertEqual(items[0][2], "NVIDIA CUDA")
|
|
|
|
def test_device_enums_default_to_the_fastest_supported_device(self):
|
|
for class_type in (
|
|
self.addon.SnapFormSettings,
|
|
self.addon.SnapFormPreferences,
|
|
):
|
|
with self.subTest(class_type=class_type.__name__):
|
|
self.assertEqual(
|
|
class_type.__annotations__["device"]["default"],
|
|
0,
|
|
)
|
|
|
|
def test_preferences_expose_production_device(self):
|
|
preferences = self.addon.SnapFormPreferences
|
|
|
|
self.assertIn("device", preferences.__annotations__)
|
|
items_callback = preferences.__annotations__["device"]["items"]
|
|
with patch.object(
|
|
self.addon.client,
|
|
"supported_devices",
|
|
return_value=["cpu"],
|
|
):
|
|
items = items_callback(None, None)
|
|
self.assertEqual([item[0] for item in items], ["CPU"])
|
|
|
|
def test_remove_background_poll_requires_ben2_model(self):
|
|
context = SimpleNamespace(
|
|
object=SimpleNamespace(
|
|
type="EMPTY",
|
|
empty_display_type="IMAGE",
|
|
data=object(),
|
|
)
|
|
)
|
|
with patch.object(
|
|
self.addon.client,
|
|
"service_busy",
|
|
return_value=False,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=False,
|
|
):
|
|
self.assertFalse(self.addon.RemoveImageBackground.poll(context))
|
|
with patch.object(
|
|
self.addon.client,
|
|
"service_busy",
|
|
return_value=False,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
):
|
|
self.assertTrue(self.addon.RemoveImageBackground.poll(context))
|
|
|
|
def test_convert_to_depth_plane_poll_requires_da3_and_ben2_models(self):
|
|
context = SimpleNamespace(
|
|
object=SimpleNamespace(
|
|
type="EMPTY",
|
|
empty_display_type="IMAGE",
|
|
data=object(),
|
|
)
|
|
)
|
|
with patch.object(
|
|
self.addon.client,
|
|
"service_busy",
|
|
return_value=False,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
side_effect=lambda key: key in {"BASE", "BEN2_BASE"},
|
|
):
|
|
self.assertTrue(
|
|
self.addon.ConvertImageToDepthPlane.poll(context)
|
|
)
|
|
with patch.object(
|
|
self.addon.client,
|
|
"service_busy",
|
|
return_value=False,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=False,
|
|
):
|
|
self.assertTrue(
|
|
self.addon.ConvertImageToDepthPlane.poll(context)
|
|
)
|
|
|
|
def test_debug_panel_is_closed_by_default(self):
|
|
self.assertIn(
|
|
"DEFAULT_CLOSED",
|
|
self.addon.DebugPanel.bl_options,
|
|
)
|
|
panels = (
|
|
self.addon.Moge2ApiDebugPanel,
|
|
self.addon.Da3OutputDebugPanel,
|
|
self.addon.Ben2RemovalDebugPanel,
|
|
)
|
|
for panel in panels:
|
|
self.assertEqual(panel.bl_parent_id, self.addon.DebugPanel.bl_idname)
|
|
self.assertIn("DEFAULT_CLOSED", panel.bl_options)
|
|
self.assertEqual(
|
|
[panel.bl_label for panel in panels],
|
|
["MoGe-2", "DA3", "BEN2"],
|
|
)
|
|
|
|
def test_service_panel_exposes_shared_service_state_and_controls(self):
|
|
self.assertIn(self.addon.AiServicePanel, self.addon.CLASSES)
|
|
self.assertIn(self.addon.ControlService, self.addon.CLASSES)
|
|
self.assertEqual(self.addon.AiServicePanel.bl_label, "AI Service")
|
|
|
|
def test_models_is_an_independent_closed_panel(self):
|
|
self.assertIn(self.addon.ModelsPanel, self.addon.CLASSES)
|
|
self.assertNotIn("bl_parent_id", self.addon.ModelsPanel.__dict__)
|
|
self.assertIn("DEFAULT_CLOSED", self.addon.ModelsPanel.bl_options)
|
|
|
|
def test_ai_operation_restarts_a_manually_stopped_service(self):
|
|
client = self.addon.client
|
|
controller = client.service_controller()
|
|
connection = SimpleNamespace(client=object())
|
|
with patch.object(controller, "ensure", return_value=connection) as ensure:
|
|
result = client.ensure_service(timeout=0.1)
|
|
ensure.assert_called_once_with(0.1)
|
|
self.assertIs(result, connection)
|
|
|
|
def test_blender_startup_does_not_start_ai_service(self):
|
|
client = self.addon.client
|
|
controller = client.service_controller()
|
|
with patch.object(client, "env_installed", return_value=True), patch.object(
|
|
controller,
|
|
"poll",
|
|
return_value=2.0,
|
|
) as poll:
|
|
interval = client._service_timer()
|
|
poll.assert_called_once_with(available=True)
|
|
self.assertEqual(interval, 2.0)
|
|
|
|
def test_ben2_debug_job_uses_its_image_and_model(self):
|
|
settings = SimpleNamespace(
|
|
background_input_path="input.png",
|
|
device="CUDA",
|
|
)
|
|
operator = self.addon.operators.DebugBen2()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "input.png"
|
|
input_path.touch()
|
|
settings.background_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
) as model_ready, patch.object(
|
|
self.addon.client,
|
|
"debug_ben2",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as remove_background:
|
|
command = operator.create_request(settings)
|
|
|
|
self.assertIsInstance(command, self.addon.operators.JobRequest)
|
|
model_ready.assert_called_once_with("BEN2_BASE")
|
|
self.assertEqual(remove_background.call_args.args[1], input_path)
|
|
self.assertEqual(remove_background.call_args.args[2], "BEN2_BASE")
|
|
|
|
def test_debug_jobs_use_the_scene_device(self):
|
|
settings = SimpleNamespace(
|
|
background_input_path="input.png",
|
|
device="CPU",
|
|
)
|
|
operator = self.addon.operators.DebugBen2()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "input.png"
|
|
input_path.touch()
|
|
settings.background_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"debug_ben2",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as remove_background:
|
|
operator.create_request(settings)
|
|
|
|
self.assertEqual(
|
|
remove_background.call_args.kwargs["device"],
|
|
"CPU",
|
|
)
|
|
|
|
def test_production_jobs_use_the_preferences_device(self):
|
|
settings = SimpleNamespace(device="CPU", video_frames=16)
|
|
operator = self.addon.operators.ProcessImageBackground()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "input.png"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"production_device",
|
|
return_value="MPS",
|
|
), patch.object(
|
|
self.addon.client,
|
|
"remove_background",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as remove_background:
|
|
operator.create_request(settings)
|
|
|
|
self.assertEqual(
|
|
remove_background.call_args.kwargs["device"],
|
|
"MPS",
|
|
)
|
|
|
|
def test_image_background_job_uses_operator_input(self):
|
|
settings = SimpleNamespace(device="AUTO")
|
|
operator = self.addon.operators.ProcessImageBackground()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "input.png"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"remove_background",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as remove_background:
|
|
command = operator.create_request(settings)
|
|
|
|
self.assertIsInstance(command, self.addon.operators.JobRequest)
|
|
self.assertEqual(remove_background.call_args.args[1], input_path)
|
|
|
|
def test_image_background_job_accepts_video_input(self):
|
|
settings = SimpleNamespace(device="AUTO", video_frames=16)
|
|
operator = self.addon.operators.ProcessImageBackground()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "clip.mp4"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"model_ready",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"remove_background",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as remove_background:
|
|
command = operator.create_request(settings)
|
|
|
|
self.assertIsInstance(command, self.addon.operators.JobRequest)
|
|
self.assertEqual(remove_background.call_args.args[1], input_path)
|
|
|
|
def test_original_frames_job_uses_frame_extraction_request(self):
|
|
settings = SimpleNamespace(device="AUTO", video_frames=16)
|
|
operator = self.addon.operators.ExtractOriginalFrames()
|
|
|
|
with TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
input_path = root / "clip.mp4"
|
|
input_path.touch()
|
|
operator.snapform_input_path = str(input_path)
|
|
paths = {
|
|
"log": root / "job.log",
|
|
"status": root / "job.json",
|
|
"result": root / "result",
|
|
}
|
|
with patch.object(
|
|
self.addon.client,
|
|
"env_installed",
|
|
return_value=True,
|
|
), patch.object(
|
|
self.addon.client,
|
|
"extract_frames",
|
|
return_value=self.addon.operators.JobRequest(
|
|
{"job_id": "test"}
|
|
),
|
|
) as extract_frames:
|
|
command = operator.create_request(settings)
|
|
|
|
self.assertIsInstance(command, self.addon.operators.JobRequest)
|
|
self.assertEqual(extract_frames.call_args.args[1], input_path)
|
|
self.assertEqual(
|
|
extract_frames.call_args.kwargs["start_frame"],
|
|
1,
|
|
)
|
|
|
|
def test_completed_image_background_job_applies_result_with_undo(self):
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
apply_removed_background=lambda **options: calls.append(
|
|
("apply", options)
|
|
)
|
|
or {"FINISHED"},
|
|
),
|
|
ed=SimpleNamespace(
|
|
undo_push=lambda **options: calls.append(("undo", options))
|
|
or {"FINISHED"},
|
|
),
|
|
)
|
|
operator = self.addon.operators.SnapFormJobOperator()
|
|
job = SimpleNamespace(
|
|
result_directory=Path("result"),
|
|
source_object_name="Reference",
|
|
)
|
|
|
|
operator._apply_removed_background(job)
|
|
|
|
self.assertEqual(
|
|
calls,
|
|
[
|
|
(
|
|
"apply",
|
|
{
|
|
"snapform_source_object_name": "Reference",
|
|
"snapform_output_path": str(
|
|
Path("result") / "foreground.png"
|
|
),
|
|
},
|
|
),
|
|
("undo", {"message": "Remove Background"}),
|
|
],
|
|
)
|
|
|
|
def test_completed_image_background_job_applies_foreground_sequence(self):
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
apply_removed_background=lambda **options: calls.append(
|
|
("apply", options)
|
|
)
|
|
or {"FINISHED"},
|
|
),
|
|
ed=SimpleNamespace(
|
|
undo_push=lambda **options: calls.append(("undo", options))
|
|
or {"FINISHED"},
|
|
),
|
|
)
|
|
operator = self.addon.operators.SnapFormJobOperator()
|
|
with TemporaryDirectory() as directory:
|
|
result_directory = Path(directory)
|
|
(result_directory / "foreground.0000.png").touch()
|
|
(result_directory / "foreground.0001.png").touch()
|
|
job = SimpleNamespace(
|
|
result_directory=result_directory,
|
|
source_object_name="Reference",
|
|
)
|
|
|
|
operator._apply_removed_background(job)
|
|
|
|
self.assertEqual(
|
|
calls[0][1]["snapform_output_path"],
|
|
str(result_directory),
|
|
)
|
|
self.assertEqual(
|
|
calls[1],
|
|
("undo", {"message": "Remove Background"}),
|
|
)
|
|
|
|
def test_completed_original_background_job_builds_with_one_undo(self):
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
build_original_depth_plane=lambda **options: calls.append(
|
|
("build", options)
|
|
)
|
|
or {"FINISHED"},
|
|
),
|
|
ed=SimpleNamespace(
|
|
undo_push=lambda **options: calls.append(("undo", options))
|
|
or {"FINISHED"},
|
|
),
|
|
)
|
|
operator = self.addon.operators.SnapFormJobOperator()
|
|
job = SimpleNamespace(
|
|
kind="generate_original_background",
|
|
result_directory=Path("result"),
|
|
source_object_name="Reference",
|
|
subdivision_level=5,
|
|
delete_source=False,
|
|
displacement_via="MODIFIER",
|
|
unshaded=False,
|
|
)
|
|
|
|
operator._build_original_depth_plane(job)
|
|
|
|
self.assertEqual(calls[0][0], "build")
|
|
self.assertEqual(
|
|
calls[0][1]["snapform_background_path"],
|
|
str(Path("result") / "foreground.png"),
|
|
)
|
|
self.assertFalse(calls[0][1]["snapform_delete_source"])
|
|
self.assertEqual(
|
|
calls[0][1]["snapform_depth_npz_path"],
|
|
"",
|
|
)
|
|
self.assertEqual(
|
|
calls[1],
|
|
("undo", {"message": "Convert to Depth Plane"}),
|
|
)
|
|
|
|
def test_completed_original_frames_job_builds_with_color_frames(self):
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
snapform=SimpleNamespace(
|
|
build_original_depth_plane=lambda **options: calls.append(
|
|
("build", options)
|
|
)
|
|
or {"FINISHED"},
|
|
),
|
|
ed=SimpleNamespace(
|
|
undo_push=lambda **options: calls.append(("undo", options))
|
|
or {"FINISHED"},
|
|
),
|
|
)
|
|
operator = self.addon.operators.SnapFormJobOperator()
|
|
with TemporaryDirectory() as directory:
|
|
result_directory = Path(directory)
|
|
(result_directory / "frame.0001.jpg").touch()
|
|
(result_directory / "frame.0002.jpg").touch()
|
|
job = SimpleNamespace(
|
|
kind="extract_original_frames",
|
|
result_directory=result_directory,
|
|
source_object_name="Reference",
|
|
subdivision_level=5,
|
|
delete_source=False,
|
|
displacement_via="MODIFIER",
|
|
unshaded=False,
|
|
)
|
|
|
|
operator._build_original_depth_plane(job)
|
|
|
|
build_options = calls[0][1]
|
|
self.assertEqual(build_options["snapform_background_path"], "")
|
|
self.assertEqual(
|
|
build_options["snapform_color_frames_dir"],
|
|
str(result_directory),
|
|
)
|
|
self.assertEqual(
|
|
build_options["snapform_depth_frames_dir"],
|
|
str(result_directory),
|
|
)
|
|
self.assertFalse(build_options["snapform_delete_source"])
|
|
self.assertEqual(
|
|
calls[1],
|
|
("undo", {"message": "Convert to Depth Plane"}),
|
|
)
|
|
|
|
def test_ben2_download_uses_model_job(self):
|
|
client = importlib.import_module("snapform.client")
|
|
request = importlib.import_module("snapform.client").download_model(
|
|
"BEN2_BASE"
|
|
)
|
|
|
|
self.assertEqual(request.payload["job_type"], "download-model")
|
|
self.assertEqual(request.payload["parameters"]["model"], "BEN2_BASE")
|
|
|
|
def test_install_env_command_has_no_model_label_reference(self):
|
|
client = importlib.import_module("snapform.client")
|
|
with patch.object(
|
|
client,
|
|
"uv_executable",
|
|
return_value="C:/uv/uv.exe",
|
|
), patch.object(
|
|
client,
|
|
"machine_devices",
|
|
return_value=["cuda", "cpu"],
|
|
), patch.object(
|
|
client,
|
|
"venv_directory",
|
|
return_value=Path(".venv"),
|
|
), patch.object(
|
|
client,
|
|
"models_directory",
|
|
return_value=Path("models"),
|
|
):
|
|
command = client.install_env_command(Path("status.json"))
|
|
|
|
self.assertNotIn("--label", command)
|
|
self.assertEqual(command[0], sys.executable)
|
|
self.assertIn("--uv", command)
|
|
self.assertIn("C:/uv/uv.exe", command)
|
|
self.assertNotIn("--torch-variant", command)
|
|
self.assertTrue(
|
|
any("install_env.py" in str(value) for value in command)
|
|
)
|
|
|
|
def test_install_env_command_does_not_select_legacy_runtime(self):
|
|
client = importlib.import_module("snapform.client")
|
|
with patch.object(
|
|
client,
|
|
"uv_executable",
|
|
return_value="C:/uv/uv.exe",
|
|
), patch.object(
|
|
client,
|
|
"machine_devices",
|
|
return_value=["cpu"],
|
|
), patch.object(
|
|
client,
|
|
"venv_directory",
|
|
return_value=Path(".venv"),
|
|
), patch.object(
|
|
client,
|
|
"models_directory",
|
|
return_value=Path("models"),
|
|
):
|
|
command = client.install_env_command(Path("status.json"))
|
|
|
|
self.assertNotIn("--pypi-index", command)
|
|
self.assertNotIn("--torch-variant", command)
|
|
self.assertIn("C:/uv/uv.exe", command)
|
|
|
|
def test_completed_export_opens_job_directory(self):
|
|
calls = []
|
|
self.fake_bpy.ops = SimpleNamespace(
|
|
wm=SimpleNamespace(
|
|
path_open=lambda **options: calls.append(options) or {"FINISHED"},
|
|
),
|
|
)
|
|
with TemporaryDirectory() as directory:
|
|
self.addon.operators.open_job_directory(directory)
|
|
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertIn("filepath", calls[0])
|
|
|
|
def test_status_bar_draws_progress_and_cancel_button(self):
|
|
progress_calls = []
|
|
operator_calls = []
|
|
|
|
class Layout:
|
|
ui_units_x = 0.0
|
|
|
|
def row(self, align=False):
|
|
return self
|
|
|
|
def label(self, **_options):
|
|
return None
|
|
|
|
def progress(self, **options):
|
|
progress_calls.append(options)
|
|
|
|
def operator(self, operator, **options):
|
|
operator_calls.append((operator, options))
|
|
|
|
context = SimpleNamespace(
|
|
scene=SimpleNamespace(
|
|
snapform_settings=SimpleNamespace(
|
|
status="Processing",
|
|
progress=0.25,
|
|
),
|
|
),
|
|
)
|
|
|
|
job_operator = importlib.import_module("snapform.blendjob.operator")
|
|
with patch.object(job_operator, "_active_job", SimpleNamespace()):
|
|
self.addon.draw_status_bar(
|
|
SimpleNamespace(layout=Layout()),
|
|
context,
|
|
)
|
|
|
|
self.assertEqual(
|
|
progress_calls,
|
|
[{"factor": 0.25, "type": "BAR", "text": "Processing"}],
|
|
)
|
|
self.assertEqual(
|
|
operator_calls,
|
|
[("snapform.cancel_job", {"text": "", "icon": "X"})],
|
|
)
|
|
|
|
def test_ui_redraw_targets_status_bar_and_view_3d_areas(self):
|
|
redrawn = []
|
|
status_regions = (
|
|
SimpleNamespace(
|
|
tag_redraw=lambda: redrawn.append("status-region"),
|
|
),
|
|
)
|
|
areas = (
|
|
SimpleNamespace(
|
|
type="STATUSBAR",
|
|
tag_redraw=lambda: redrawn.append("status"),
|
|
regions=status_regions,
|
|
),
|
|
SimpleNamespace(
|
|
type="VIEW_3D",
|
|
tag_redraw=lambda: redrawn.append("view"),
|
|
regions=(),
|
|
),
|
|
)
|
|
context = SimpleNamespace(
|
|
window_manager=SimpleNamespace(
|
|
windows=(
|
|
SimpleNamespace(screen=SimpleNamespace(areas=areas)),
|
|
),
|
|
),
|
|
)
|
|
|
|
self.addon.status_bar.redraw_ui(context)
|
|
self.addon.status_bar.redraw_ui(context)
|
|
|
|
self.assertEqual(
|
|
redrawn,
|
|
[
|
|
"status",
|
|
"status-region",
|
|
"view",
|
|
"status",
|
|
"status-region",
|
|
"view",
|
|
],
|
|
)
|
|
|
|
def test_force_flag_refreshes_status_text(self):
|
|
redrawn = []
|
|
context = SimpleNamespace(
|
|
window_manager=SimpleNamespace(
|
|
windows=(
|
|
SimpleNamespace(
|
|
screen=SimpleNamespace(
|
|
areas=(
|
|
SimpleNamespace(
|
|
type="STATUSBAR",
|
|
tag_redraw=lambda: redrawn.append(
|
|
"status"
|
|
),
|
|
regions=(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
workspace=SimpleNamespace(
|
|
status_text_set_internal=lambda _value: redrawn.append(
|
|
"status-text"
|
|
),
|
|
),
|
|
)
|
|
|
|
self.addon.status_bar.redraw_ui(context, force=True)
|
|
|
|
self.assertEqual(
|
|
redrawn,
|
|
[
|
|
"status",
|
|
"status-text",
|
|
],
|
|
)
|
|
|
|
def test_force_flag_ignores_missing_workspace(self):
|
|
redrawn = []
|
|
context = SimpleNamespace(
|
|
window_manager=SimpleNamespace(
|
|
windows=(
|
|
SimpleNamespace(
|
|
screen=SimpleNamespace(
|
|
areas=(
|
|
SimpleNamespace(
|
|
type="STATUSBAR",
|
|
tag_redraw=lambda: redrawn.append(
|
|
"status"
|
|
),
|
|
regions=(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
self.addon.status_bar.redraw_ui(context, force=True)
|
|
|
|
self.assertEqual(redrawn, ["status"])
|