Add SnapForm panels, preferences, properties, and runtime management
- Implemented RuntimeModelsPanel and DebugPanel for UI integration in Blender. - Created SnapFormPreferences to manage user settings for storage and runtime configuration. - Defined SnapFormSettings to hold various properties related to input, model selection, and export options. - Developed runtime management functions for installation, model downloading, and reconstruction commands. - Added status bar functionality to display job progress and status in the Blender UI. - Implemented worker script for handling model downloads and depth reconstruction tasks. - Introduced unit tests for download progress reporting and image depth extraction functionalities.
This commit is contained in:
@@ -32,12 +32,17 @@ class AddonTest(unittest.TestCase):
|
||||
PropertyGroup=object,
|
||||
Scene=type("Scene", (), {}),
|
||||
STATUSBAR_HT_header=SimpleNamespace(
|
||||
prepend=lambda draw: cls.status_bar_draws.append(draw),
|
||||
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),
|
||||
),
|
||||
)
|
||||
cls.registered = []
|
||||
cls.status_bar_draws = []
|
||||
cls.context_menu_draws = []
|
||||
fake_bpy.utils = SimpleNamespace(
|
||||
register_class=cls.registered.append,
|
||||
unregister_class=cls.registered.remove,
|
||||
@@ -49,7 +54,7 @@ class AddonTest(unittest.TestCase):
|
||||
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
|
||||
sys.modules["bpy"] = fake_bpy
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
cls.addon = importlib.import_module("omoo_da3")
|
||||
cls.addon = importlib.import_module("snapform")
|
||||
cls.fake_bpy = fake_bpy
|
||||
|
||||
@classmethod
|
||||
@@ -61,15 +66,16 @@ class AddonTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def remove_modules():
|
||||
for module_name in tuple(sys.modules):
|
||||
if module_name == "omoo_da3" or module_name.startswith("omoo_da3."):
|
||||
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.addon.operators._active_job = None
|
||||
if hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings"):
|
||||
del self.fake_bpy.types.Scene.omoo_da3_settings
|
||||
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"))
|
||||
@@ -79,40 +85,215 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self.registered, list(self.addon.CLASSES))
|
||||
self.assertTrue(
|
||||
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
|
||||
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.addon.unregister()
|
||||
|
||||
self.assertEqual(self.registered, [])
|
||||
self.assertFalse(
|
||||
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
|
||||
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
|
||||
)
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
self.assertEqual(self.context_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.assertFalse(hasattr(self.addon, "ImportDepthPlane"))
|
||||
|
||||
def test_completed_depth_job_uses_undoable_build_operator(self):
|
||||
source = inspect.getsource(
|
||||
self.addon.RunJob._build_depth_plane
|
||||
)
|
||||
|
||||
self.assertIn("bpy.ops.snapform.build_depth_plane(", source)
|
||||
self.assertIn('if "FINISHED" not in result:', source)
|
||||
self.assertIn(
|
||||
'bpy.ops.ed.undo_push(message="Convert to Depth Plane")',
|
||||
source,
|
||||
)
|
||||
self.assertIn('if "FINISHED" not in undo_result:', source)
|
||||
|
||||
def test_image_context_menu_only_exposes_conversion(self):
|
||||
draw_source = inspect.getsource(
|
||||
self.addon.draw_image_context_menu
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'"snapform.convert_image_to_depth_plane"',
|
||||
draw_source,
|
||||
)
|
||||
self.assertIn('operator_context = "INVOKE_DEFAULT"', draw_source)
|
||||
self.assertNotIn("import_depth_plane", draw_source)
|
||||
self.assertNotIn("Rebuild", draw_source)
|
||||
|
||||
def test_conversion_dialog_exposes_requested_settings(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane
|
||||
|
||||
self.assertEqual(
|
||||
operator.__annotations__["snapform_model"]["default"],
|
||||
"BASE",
|
||||
)
|
||||
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
|
||||
self.assertEqual(
|
||||
operator.__annotations__["snapform_process_res"]["default"],
|
||||
1024,
|
||||
)
|
||||
self.assertTrue(
|
||||
operator.__annotations__["snapform_delete_original"]["default"]
|
||||
)
|
||||
self.assertEqual(
|
||||
operator.__annotations__["snapform_subdivision_level"]["default"],
|
||||
5,
|
||||
)
|
||||
self.assertEqual(
|
||||
set(operator.__annotations__),
|
||||
{
|
||||
"snapform_model",
|
||||
"snapform_use_ray_pose",
|
||||
"snapform_process_res",
|
||||
"snapform_delete_original",
|
||||
"snapform_subdivision_level",
|
||||
},
|
||||
)
|
||||
model_items = operator.__annotations__["snapform_model"]["items"]
|
||||
self.assertEqual(
|
||||
[item[0] for item in model_items],
|
||||
[
|
||||
"SMALL",
|
||||
"BASE",
|
||||
"LARGE_1_1",
|
||||
"GIANT_1_1",
|
||||
"NESTED_GIANT_LARGE_1_1",
|
||||
],
|
||||
)
|
||||
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
|
||||
|
||||
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.RunJob()
|
||||
operator.job_type = self.addon.operators.IMAGE_DEPTH_JOB
|
||||
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),
|
||||
("LARGE_1_1", True),
|
||||
("GIANT_1_1", False),
|
||||
("NESTED_GIANT_LARGE_1_1", True),
|
||||
):
|
||||
with self.subTest(model=snapform_model), patch.object(
|
||||
self.addon.runtime,
|
||||
"new_job_paths",
|
||||
return_value=paths,
|
||||
), patch.object(
|
||||
self.addon.runtime,
|
||||
"runtime_ready",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
self.addon.runtime,
|
||||
"model_ready",
|
||||
return_value=True,
|
||||
) as model_ready, patch.object(
|
||||
self.addon.runtime,
|
||||
"reconstruct_command",
|
||||
return_value=["worker"],
|
||||
) as reconstruct:
|
||||
operator.snapform_model_key = snapform_model
|
||||
operator.snapform_use_ray_pose = snapform_ray_pose
|
||||
command, prepared_paths = operator._prepare_command(
|
||||
None,
|
||||
settings,
|
||||
)
|
||||
|
||||
self.assertEqual(command, ["worker"])
|
||||
self.assertEqual(prepared_paths, paths)
|
||||
model_ready.assert_called_once_with(snapform_model)
|
||||
self.assertEqual(reconstruct.call_args.args[1], "mini_npz")
|
||||
self.assertEqual(
|
||||
reconstruct.call_args.kwargs["input_path"],
|
||||
input_path,
|
||||
)
|
||||
self.assertEqual(
|
||||
reconstruct.call_args.kwargs["model_key"],
|
||||
snapform_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
reconstruct.call_args.kwargs["process_res"],
|
||||
336,
|
||||
)
|
||||
self.assertEqual(
|
||||
reconstruct.call_args.kwargs["process_res_method"],
|
||||
"lower_bound_resize",
|
||||
)
|
||||
self.assertEqual(
|
||||
reconstruct.call_args.kwargs["use_ray_pose"],
|
||||
snapform_ray_pose,
|
||||
)
|
||||
|
||||
def test_preferences_use_package_identifier(self):
|
||||
self.assertEqual(
|
||||
self.addon.OMOO_DA3_preferences.bl_idname,
|
||||
"omoo_da3",
|
||||
self.addon.SnapFormPreferences.bl_idname,
|
||||
"snapform",
|
||||
)
|
||||
|
||||
def test_default_storage_is_shared_in_user_home(self):
|
||||
runtime = importlib.import_module("omoo_da3.runtime")
|
||||
runtime = importlib.import_module("snapform.runtime")
|
||||
|
||||
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".omoo_da3")
|
||||
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".snapform")
|
||||
self.assertEqual(
|
||||
self.addon.OMOO_DA3_preferences.__annotations__["storage_root"][
|
||||
self.addon.SnapFormPreferences.__annotations__["storage_root"][
|
||||
"default"
|
||||
],
|
||||
str(runtime.DEFAULT_STORAGE_ROOT),
|
||||
)
|
||||
|
||||
def test_process_res_method_defaults_to_lower_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"], 1024)
|
||||
self.assertEqual(process_res_method["default"], "lower_bound_resize")
|
||||
|
||||
def test_reconstruct_command_preserves_da3_parameter_names(self):
|
||||
runtime = importlib.import_module("omoo_da3.runtime")
|
||||
runtime = importlib.import_module("snapform.runtime")
|
||||
settings = SimpleNamespace(
|
||||
input_path="input.png",
|
||||
model="GIANT_1_1",
|
||||
@@ -159,8 +340,56 @@ class AddonTest(unittest.TestCase):
|
||||
with self.subTest(parameter=parameter):
|
||||
self.assertIn(parameter, command)
|
||||
|
||||
def test_reconstruct_command_accepts_image_depth_overrides(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",
|
||||
return_value=Path("large-model"),
|
||||
) as snapform_model_directory:
|
||||
snapform_command = runtime.reconstruct_command(
|
||||
settings,
|
||||
"mini_npz",
|
||||
Path("status.json"),
|
||||
Path("result"),
|
||||
model_key="BASE",
|
||||
process_res=252,
|
||||
process_res_method="lower_bound_resize",
|
||||
use_ray_pose=False,
|
||||
)
|
||||
|
||||
snapform_model_directory.assert_called_once_with("BASE")
|
||||
snapform_process_res_index = snapform_command.index("--process-res")
|
||||
self.assertEqual(snapform_command[snapform_process_res_index + 1], "252")
|
||||
snapform_process_res_method_index = snapform_command.index("--process-res-method")
|
||||
self.assertEqual(
|
||||
snapform_command[snapform_process_res_method_index + 1],
|
||||
"lower_bound_resize",
|
||||
)
|
||||
snapform_ray_pose_index = snapform_command.index("--use-ray-pose")
|
||||
self.assertEqual(snapform_command[snapform_ray_pose_index + 1], "0")
|
||||
|
||||
def test_export_format_property_includes_all_da3_formats(self):
|
||||
export_format = self.addon.OMOO_DA3_PG_settings.__annotations__[
|
||||
export_format = self.addon.SnapFormSettings.__annotations__[
|
||||
"export_format"
|
||||
]
|
||||
identifiers = [item[0] for item in export_format["items"]]
|
||||
@@ -172,7 +401,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(export_format["default"], "glb")
|
||||
|
||||
def test_gs_model_property_only_includes_gaussian_models(self):
|
||||
gs_model = self.addon.OMOO_DA3_PG_settings.__annotations__[
|
||||
gs_model = self.addon.SnapFormSettings.__annotations__[
|
||||
"gs_model"
|
||||
]
|
||||
identifiers = [item[0] for item in gs_model["items"]]
|
||||
@@ -185,12 +414,12 @@ class AddonTest(unittest.TestCase):
|
||||
def test_debug_panel_is_closed_by_default(self):
|
||||
self.assertIn(
|
||||
"DEFAULT_CLOSED",
|
||||
self.addon.OMOO_DA3_PT_debug.bl_options,
|
||||
self.addon.DebugPanel.bl_options,
|
||||
)
|
||||
|
||||
def test_debug_panel_uses_one_export_button_and_dynamic_settings(self):
|
||||
draw_source = inspect.getsource(
|
||||
self.addon.OMOO_DA3_PT_debug.draw
|
||||
self.addon.DebugPanel.draw
|
||||
)
|
||||
|
||||
self.assertEqual(draw_source.count("layout.operator("), 1)
|
||||
@@ -207,10 +436,10 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_runtime_models_panel_assigns_each_download_target(self):
|
||||
draw_source = inspect.getsource(
|
||||
self.addon.OMOO_DA3_PT_runtime_models.draw
|
||||
self.addon.RuntimeModelsPanel.draw
|
||||
)
|
||||
model_source = inspect.getsource(
|
||||
self.addon.OMOO_DA3_PT_runtime_models._draw_model
|
||||
self.addon.RuntimeModelsPanel._draw_model
|
||||
)
|
||||
|
||||
self.assertIn("for model in MODELS.values()", draw_source)
|
||||
@@ -218,7 +447,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertNotIn("Isolated Runtime ready", draw_source)
|
||||
self.assertNotIn("License:", model_source)
|
||||
self.assertEqual(
|
||||
self.addon.OMOO_DA3_PT_runtime_models.bl_label,
|
||||
self.addon.RuntimeModelsPanel.bl_label,
|
||||
"Setup",
|
||||
)
|
||||
|
||||
@@ -257,7 +486,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.addon.operators._active_job = SimpleNamespace()
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
omoo_da3_settings=SimpleNamespace(
|
||||
snapform_settings=SimpleNamespace(
|
||||
status="Processing",
|
||||
progress=0.25,
|
||||
),
|
||||
@@ -271,23 +500,92 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
progress_calls,
|
||||
[{"factor": 0.25, "type": "BAR", "text": "25%"}],
|
||||
[{"factor": 0.25, "type": "BAR", "text": "Processing"}],
|
||||
)
|
||||
self.assertEqual(
|
||||
operator_calls,
|
||||
[("omoo_da3.cancel_job", {"text": "", "icon": "X"})],
|
||||
[("snapform.cancel_job", {"text": "", "icon": "X"})],
|
||||
)
|
||||
|
||||
def test_image_depth_status_uses_same_progress_layout(self):
|
||||
progress_calls = []
|
||||
operator_calls = []
|
||||
|
||||
class Layout:
|
||||
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))
|
||||
|
||||
self.addon.operators._active_job = SimpleNamespace(
|
||||
kind=self.addon.operators.IMAGE_DEPTH_JOB,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
scene=SimpleNamespace(
|
||||
snapform_settings=SimpleNamespace(
|
||||
status="Estimating depth",
|
||||
progress=0.25,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.addon.draw_status_bar(
|
||||
SimpleNamespace(layout=Layout()),
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
progress_calls,
|
||||
[
|
||||
{
|
||||
"factor": 0.25,
|
||||
"type": "BAR",
|
||||
"text": "Estimating depth",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
operator_calls,
|
||||
[("snapform.cancel_job", {"text": "", "icon": "X"})],
|
||||
)
|
||||
|
||||
def test_progress_is_appended_before_cancel_button(self):
|
||||
register_source = inspect.getsource(self.addon.register)
|
||||
draw_source = inspect.getsource(self.addon.draw_status_bar)
|
||||
|
||||
self.assertIn("STATUSBAR_HT_header.append", register_source)
|
||||
self.assertNotIn("STATUSBAR_HT_header.prepend", register_source)
|
||||
self.assertLess(
|
||||
draw_source.index("progress_row.progress("),
|
||||
draw_source.index('"snapform.cancel_job"'),
|
||||
)
|
||||
self.assertIn("progress_row.ui_units_x = 12.0", draw_source)
|
||||
self.assertNotIn('label(text="SnapForm"', draw_source)
|
||||
|
||||
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(
|
||||
@@ -298,25 +596,46 @@ class AddonTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
self.addon.status_bar.redraw_ui(context)
|
||||
self.addon.status_bar.redraw_ui(context)
|
||||
|
||||
self.assertEqual(redrawn, ["status", "view"])
|
||||
|
||||
def test_forced_ui_redraw_swaps_window_immediately(self):
|
||||
calls = []
|
||||
self.fake_bpy.ops = SimpleNamespace(
|
||||
wm=SimpleNamespace(
|
||||
redraw_timer=lambda **options: calls.append(options),
|
||||
),
|
||||
self.assertEqual(
|
||||
redrawn,
|
||||
[
|
||||
"status",
|
||||
"status-region",
|
||||
"view",
|
||||
"status",
|
||||
"status-region",
|
||||
"view",
|
||||
],
|
||||
)
|
||||
|
||||
def test_force_flag_does_not_trigger_window_draw_and_swap(self):
|
||||
redrawn = []
|
||||
context = SimpleNamespace(
|
||||
window=SimpleNamespace(),
|
||||
window_manager=SimpleNamespace(windows=()),
|
||||
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(
|
||||
calls,
|
||||
[{"type": "DRAW_WIN_SWAP", "iterations": 1}],
|
||||
)
|
||||
self.assertEqual(redrawn, ["status"])
|
||||
source = inspect.getsource(self.addon.status_bar)
|
||||
self.assertNotIn("redraw_timer", source)
|
||||
self.assertNotIn("DRAW_WIN_SWAP", source)
|
||||
|
||||
@@ -11,8 +11,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def load_bootstrap_runtime():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"omoo_da3_bootstrap_runtime",
|
||||
PROJECT_ROOT / "src" / "omoo_da3" / "bootstrap_runtime.py",
|
||||
"snapform_bootstrap_runtime",
|
||||
PROJECT_ROOT / "src" / "snapform" / "bootstrap_runtime.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
|
||||
83
tests/test_download_progress.py
Normal file
83
tests/test_download_progress.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_worker():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"snapform_download_worker",
|
||||
PROJECT_ROOT / "src" / "snapform" / "worker.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class FakeTqdm:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.n = kwargs.get("initial", 0)
|
||||
self.total = kwargs.get("total", 0)
|
||||
|
||||
def update(self, amount=1):
|
||||
self.n += amount
|
||||
return True
|
||||
|
||||
def refresh(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
|
||||
class DownloadProgressTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.worker = load_worker()
|
||||
|
||||
def test_reports_aggregated_download_bytes(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
status_path = Path(directory) / "status.json"
|
||||
progress_class = self.worker.create_download_progress_class(
|
||||
status_path,
|
||||
"owner/model",
|
||||
tqdm_type=FakeTqdm,
|
||||
clock=lambda: 1.0,
|
||||
)
|
||||
progress = progress_class(
|
||||
desc="Downloading bytes",
|
||||
total=100,
|
||||
initial=0,
|
||||
unit="B",
|
||||
)
|
||||
|
||||
progress.update(25)
|
||||
|
||||
status = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
self.assertAlmostEqual(status["progress"], 0.275)
|
||||
self.assertEqual(status["message"], "Downloading owner/model")
|
||||
|
||||
def test_ignores_unrelated_progress_bars(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
status_path = Path(directory) / "status.json"
|
||||
progress_class = self.worker.create_download_progress_class(
|
||||
status_path,
|
||||
"owner/model",
|
||||
tqdm_type=FakeTqdm,
|
||||
clock=lambda: 1.0,
|
||||
)
|
||||
progress = progress_class(
|
||||
desc="Reconstructing",
|
||||
total=100,
|
||||
initial=0,
|
||||
unit="B",
|
||||
)
|
||||
|
||||
progress.update(25)
|
||||
|
||||
self.assertFalse(status_path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
330
tests/test_image_depth.py
Normal file
330
tests/test_image_depth.py
Normal file
@@ -0,0 +1,330 @@
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_image_depth():
|
||||
fake_bpy = ModuleType("bpy")
|
||||
fake_bpy.props = SimpleNamespace(
|
||||
BoolProperty=lambda **options: options,
|
||||
EnumProperty=lambda **options: options,
|
||||
FloatProperty=lambda **options: options,
|
||||
IntProperty=lambda **options: options,
|
||||
StringProperty=lambda **options: options,
|
||||
)
|
||||
fake_bpy.types = SimpleNamespace(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(
|
||||
"snapform_image_depth_test",
|
||||
PROJECT_ROOT / "src" / "snapform" / "image_depth.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ImageDepthTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.image_depth = load_image_depth()
|
||||
|
||||
def test_extracts_depth_and_uses_first_view(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
npz_path = Path(directory) / "results.npz"
|
||||
np.savez_compressed(
|
||||
npz_path,
|
||||
depth=np.array(
|
||||
(
|
||||
((1.0, 2.0), (3.0, 4.0)),
|
||||
((5.0, 6.0), (7.0, 8.0)),
|
||||
),
|
||||
dtype=np.float32,
|
||||
),
|
||||
)
|
||||
|
||||
depth = self.image_depth.extract_depth_array(npz_path)
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
depth,
|
||||
np.array(((1.0, 2.0), (3.0, 4.0)), dtype=np.float32),
|
||||
)
|
||||
|
||||
def test_accepts_depths_key_with_single_channel(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
npz_path = Path(directory) / "results.npz"
|
||||
np.savez_compressed(
|
||||
npz_path,
|
||||
depths=np.ones((1, 2, 3, 1), dtype=np.float32),
|
||||
)
|
||||
|
||||
depth = self.image_depth.extract_depth_array(npz_path)
|
||||
|
||||
self.assertEqual(depth.shape, (2, 3))
|
||||
|
||||
def test_accepts_single_depth_map_with_channel_axis(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
npz_path = Path(directory) / "results.npz"
|
||||
np.savez_compressed(
|
||||
npz_path,
|
||||
depth=np.ones((2, 3, 1), dtype=np.float32),
|
||||
)
|
||||
|
||||
depth = self.image_depth.extract_depth_array(npz_path)
|
||||
|
||||
self.assertEqual(depth.shape, (2, 3))
|
||||
|
||||
def test_extracts_camera_intrinsics(self):
|
||||
expected = np.array(
|
||||
((100.0, 0.0, 50.0), (0.0, 120.0, 40.0), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
npz_path = Path(directory) / "results.npz"
|
||||
np.savez_compressed(
|
||||
npz_path,
|
||||
intrinsics=expected[None, ...],
|
||||
)
|
||||
|
||||
intrinsics = self.image_depth.extract_intrinsics_array(
|
||||
npz_path
|
||||
)
|
||||
|
||||
np.testing.assert_array_equal(intrinsics, expected)
|
||||
|
||||
def test_camera_plane_bounds_use_intrinsics_and_median_depth(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(200, 100)),
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
depth = np.full((100, 200), 2.0, dtype=np.float32)
|
||||
intrinsics = np.array(
|
||||
((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
bounds, reference_depth = self.image_depth.camera_plane_bounds(
|
||||
source,
|
||||
depth,
|
||||
intrinsics,
|
||||
-1.0,
|
||||
)
|
||||
|
||||
self.assertEqual(bounds, (-2.0, 2.0, -1.0, 1.0))
|
||||
self.assertEqual(reference_depth, 2.0)
|
||||
|
||||
def test_camera_plane_bounds_follow_displacement_unit_scale(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(200, 100)),
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
depth = np.full((100, 200), 2.0, dtype=np.float32)
|
||||
intrinsics = np.array(
|
||||
((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
bounds, _reference_depth = (
|
||||
self.image_depth.camera_plane_bounds(
|
||||
source,
|
||||
depth,
|
||||
intrinsics,
|
||||
-0.5,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
|
||||
|
||||
def test_camera_plane_bounds_preserve_square_source_aspect(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(447, 447)),
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
depth = np.full((140, 140), 0.7366, dtype=np.float32)
|
||||
intrinsics = np.array(
|
||||
(
|
||||
(460.758, 0.0, 70.0),
|
||||
(0.0, 393.372, 70.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
bounds, _reference_depth = (
|
||||
self.image_depth.camera_plane_bounds(
|
||||
source,
|
||||
depth,
|
||||
intrinsics,
|
||||
-1.0,
|
||||
)
|
||||
)
|
||||
width = bounds[1] - bounds[0]
|
||||
height = bounds[3] - bounds[2]
|
||||
|
||||
self.assertAlmostEqual(width / height, 1.0)
|
||||
|
||||
def test_raw_distance_preserves_original_values(self):
|
||||
depth = np.array(
|
||||
((1.25, 2.5), (5.0, np.nan)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
mapped = self.image_depth.prepare_depth_values(depth)
|
||||
|
||||
self.assertEqual(float(mapped[0, 0]), 1.25)
|
||||
self.assertEqual(float(mapped[0, 1]), 2.5)
|
||||
self.assertEqual(float(mapped[1, 0]), 5.0)
|
||||
self.assertEqual(float(mapped[1, 1]), 0.0)
|
||||
|
||||
def test_depth_plane_uses_fixed_import_settings(self):
|
||||
self.assertEqual(self.image_depth.DISPLACEMENT_STRENGTH, -1.0)
|
||||
self.assertEqual(self.image_depth.DISPLACEMENT_MIDLEVEL, 1.0)
|
||||
self.assertEqual(self.image_depth.BUMP_BASE_DISTANCE, 0.001)
|
||||
|
||||
def test_grid_segments_follow_aspect_ratio_from_subdivision_power(self):
|
||||
self.assertEqual(
|
||||
self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 1),
|
||||
(3, 2),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_depth.grid_segments((0.0, 2.0, 0.0, 3.0), 1),
|
||||
(2, 3),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 3),
|
||||
(12, 8),
|
||||
)
|
||||
|
||||
def test_modifiers_use_native_names_and_fixed_levels(self):
|
||||
source = inspect.getsource(self.image_depth.build_depth_plane)
|
||||
|
||||
self.assertIn('modifiers.new("Subdivision", "SUBSURF")', source)
|
||||
self.assertIn('modifiers.new("Displace", "DISPLACE")', source)
|
||||
self.assertNotIn("DA3 Subdivision", source)
|
||||
self.assertNotIn("DA3 Displace", source)
|
||||
self.assertIn("subdivision.levels = 1", source)
|
||||
self.assertIn("subdivision.render_levels = 2", source)
|
||||
|
||||
def test_plane_fit_transform_matches_source_size_and_offset(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(800, 400)),
|
||||
empty_display_size=2.0,
|
||||
empty_image_offset=(-0.25, -0.75),
|
||||
)
|
||||
|
||||
transform = self.image_depth.plane_fit_transform(
|
||||
source,
|
||||
(-0.1, 0.3, -0.1, 0.1),
|
||||
)
|
||||
|
||||
for value, expected in zip(
|
||||
transform,
|
||||
(5.0, 5.0, 5.0, 0.0, -0.25),
|
||||
):
|
||||
self.assertAlmostEqual(value, expected)
|
||||
|
||||
def test_source_empty_is_only_removed_when_requested(self):
|
||||
source = inspect.getsource(self.image_depth.build_depth_plane)
|
||||
|
||||
self.assertIn("source_name = source_object.name", source)
|
||||
self.assertIn("if delete_source:", source)
|
||||
self.assertIn(
|
||||
"bpy.data.objects.remove(source_object, do_unlink=True)",
|
||||
source,
|
||||
)
|
||||
self.assertNotIn("source_object.name =", source)
|
||||
self.assertIn(
|
||||
"bpy.data.objects.new(\n source_name,",
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
"bpy.data.objects.remove(source_object, do_unlink=True)\n"
|
||||
" plane_object.name = source_name",
|
||||
source,
|
||||
)
|
||||
|
||||
def test_displace_texture_uses_extend(self):
|
||||
source = inspect.getsource(
|
||||
self.image_depth.build_depth_plane
|
||||
)
|
||||
|
||||
self.assertIn('depth_name = f"{source_name}.depth"', source)
|
||||
self.assertIn("create_depth_image(\n depth_name,", source)
|
||||
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
|
||||
self.assertIn('texture.extension = "EXTEND"', source)
|
||||
|
||||
def test_original_image_drives_bump_instead_of_displacement(self):
|
||||
source = inspect.getsource(self.image_depth.create_material)
|
||||
|
||||
self.assertIn('nodes.new("ShaderNodeBump")', source)
|
||||
self.assertIn(
|
||||
'color_texture.outputs["Color"], bump.inputs["Height"]',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'bump.outputs["Normal"], shader.inputs["Normal"]',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'bump.inputs["Distance"].default_value = '
|
||||
"BUMP_BASE_DISTANCE * depth_scale",
|
||||
source,
|
||||
)
|
||||
self.assertIn('nodes.new("ShaderNodeFloatCurve")', source)
|
||||
self.assertIn(
|
||||
'roughness_curve.inputs["Value"]',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'shader.inputs["Roughness"]',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'curve_points[0].location = (0.0, 0.5)',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'curve_points[-1].location = (1.0, 0.1)',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'shader.inputs["IOR"].default_value = 1.2',
|
||||
source,
|
||||
)
|
||||
self.assertIn(
|
||||
'roughness_curve.location = (-300.0, 320.0)',
|
||||
source,
|
||||
)
|
||||
self.assertIn('bump.location = (-300.0, -80.0)', source)
|
||||
self.assertNotIn("ShaderNodeDisplacement", source)
|
||||
|
||||
def test_plane_transform_scales_depth_axis(self):
|
||||
source = inspect.getsource(self.image_depth.apply_plane_transform)
|
||||
|
||||
self.assertIn("(0.0, 0.0, scale_z, 0.0)", source)
|
||||
self.assertIn("plane_object.scale.z", source)
|
||||
|
||||
def test_plane_bounds_keep_image_aspect_and_empty_offset(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(800, 400)),
|
||||
empty_display_size=2.0,
|
||||
empty_image_offset=(-0.5, -0.5),
|
||||
)
|
||||
|
||||
bounds = self.image_depth.plane_bounds(source)
|
||||
|
||||
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_PATH = PROJECT_ROOT / "src" / "omoo_da3" / "blender_manifest.toml"
|
||||
MANIFEST_PATH = PROJECT_ROOT / "src" / "snapform" / "blender_manifest.toml"
|
||||
|
||||
|
||||
class ManifestTest(unittest.TestCase):
|
||||
@@ -14,7 +14,7 @@ class ManifestTest(unittest.TestCase):
|
||||
|
||||
def test_declares_modern_extension_metadata(self):
|
||||
self.assertEqual(self.manifest["schema_version"], "1.0.0")
|
||||
self.assertEqual(self.manifest["id"], "omoo_da3")
|
||||
self.assertEqual(self.manifest["id"], "snapform")
|
||||
self.assertEqual(self.manifest["type"], "add-on")
|
||||
self.assertNotIn("bl_info", self.manifest)
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ class ModelTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.models = load_module(
|
||||
"omoo_da3_models",
|
||||
"src/omoo_da3/models.py",
|
||||
"snapform_models",
|
||||
"src/snapform/models.py",
|
||||
)
|
||||
|
||||
def test_base_model_is_default_friendly_apache_model(self):
|
||||
|
||||
@@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def load_pack():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"omoo_da3_pack",
|
||||
"snapform_pack",
|
||||
PROJECT_ROOT / "pack.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
@@ -29,11 +29,14 @@ class PackTest(unittest.TestCase):
|
||||
finally:
|
||||
pack.DIST_DIR = original_dist
|
||||
|
||||
self.assertEqual(output_path.name, "SnapForm.v0.9.27.b45.zip")
|
||||
with ZipFile(output_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
|
||||
self.assertIn("__init__.py", names)
|
||||
self.assertIn("blender_manifest.toml", names)
|
||||
self.assertIn("image_depth.py", names)
|
||||
self.assertNotIn("snapform_depth_plane.py", names)
|
||||
self.assertIn("worker.py", names)
|
||||
self.assertNotIn("importer.py", names)
|
||||
self.assertNotIn("omoo_da3/__init__.py", names)
|
||||
self.assertNotIn("snapform/__init__.py", names)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -14,8 +15,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
def load_worker():
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"omoo_da3_worker",
|
||||
PROJECT_ROOT / "src" / "omoo_da3" / "worker.py",
|
||||
"snapform_worker",
|
||||
PROJECT_ROOT / "src" / "snapform" / "worker.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
@@ -38,6 +39,14 @@ class WorkerTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual([path.name for path in images], ["a.jpg", "b.PNG"])
|
||||
|
||||
def test_reconstruct_reports_status_before_loading_torch(self):
|
||||
source = inspect.getsource(self.worker.reconstruct)
|
||||
|
||||
self.assertLess(
|
||||
source.index('"Starting DA3 runtime"'),
|
||||
source.index("import torch"),
|
||||
)
|
||||
|
||||
def test_prepares_a_single_image_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
image_path = Path(directory) / "input.png"
|
||||
|
||||
Reference in New Issue
Block a user