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:
384
src/snapform/operators.py
Normal file
384
src/snapform/operators.py
Normal file
@@ -0,0 +1,384 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from . import runtime
|
||||
from .models import get_model, selected_model_key
|
||||
|
||||
|
||||
RECONSTRUCTION_JOB = "RECONSTRUCT"
|
||||
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
|
||||
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
|
||||
IMAGE_DEPTH_PROCESS_RES_METHOD = "lower_bound_resize"
|
||||
|
||||
|
||||
def open_job_directory(result_directory):
|
||||
result_directory = Path(result_directory)
|
||||
if not result_directory.is_dir():
|
||||
raise RuntimeError(f"Job directory does not exist: {result_directory}")
|
||||
result = bpy.ops.wm.path_open(filepath=str(result_directory))
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError(f"Unable to open job directory: {result_directory}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveJob:
|
||||
kind: str
|
||||
process: subprocess.Popen
|
||||
log_file: object
|
||||
log_path: Path
|
||||
status_path: Path
|
||||
result_directory: Path
|
||||
export_format: str = ""
|
||||
source_object_name: str = ""
|
||||
process_res: int = IMAGE_DEPTH_DEFAULT_PROCESS_RES
|
||||
subdivision_level: int = 5
|
||||
delete_source: bool = True
|
||||
cancelled: bool = False
|
||||
|
||||
|
||||
_active_job = None
|
||||
|
||||
|
||||
def active_job():
|
||||
return _active_job
|
||||
|
||||
|
||||
def cancel_active_job():
|
||||
global _active_job
|
||||
|
||||
if _active_job is None:
|
||||
return
|
||||
_active_job.cancelled = True
|
||||
if _active_job.process.poll() is None:
|
||||
_active_job.process.terminate()
|
||||
|
||||
|
||||
def close_active_job():
|
||||
global _active_job
|
||||
|
||||
if _active_job is None:
|
||||
return
|
||||
_active_job.log_file.close()
|
||||
_active_job = None
|
||||
|
||||
|
||||
class RunJob(bpy.types.Operator):
|
||||
bl_idname = "snapform.run_job"
|
||||
bl_label = "Run SnapForm Job"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
job_type: bpy.props.EnumProperty(
|
||||
items=(
|
||||
("INSTALL_RUNTIME", "Install Runtime", ""),
|
||||
("DOWNLOAD_MODEL", "Download Model", ""),
|
||||
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
|
||||
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
|
||||
),
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
model_key: bpy.props.StringProperty(
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_input_path: bpy.props.StringProperty(
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_source_object_name: bpy.props.StringProperty(
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_model_key: bpy.props.StringProperty(
|
||||
default="BASE",
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_use_ray_pose: bpy.props.BoolProperty(
|
||||
default=True,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_process_res: bpy.props.IntProperty(
|
||||
default=IMAGE_DEPTH_DEFAULT_PROCESS_RES,
|
||||
min=14,
|
||||
max=2016,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_delete_source: bpy.props.BoolProperty(
|
||||
default=True,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_subdivision_level: bpy.props.IntProperty(
|
||||
default=5,
|
||||
min=0,
|
||||
max=10,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
global _active_job
|
||||
|
||||
if active_job() is not None:
|
||||
self.report({"WARNING"}, "Another SnapForm task is running")
|
||||
return {"CANCELLED"}
|
||||
|
||||
settings = context.scene.snapform_settings
|
||||
try:
|
||||
command, paths = self._prepare_command(context, settings)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
|
||||
log_file = paths["log"].open("w", encoding="utf-8")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=runtime.storage_root(),
|
||||
creationflags=runtime.creation_flags(),
|
||||
)
|
||||
except OSError as error:
|
||||
log_file.close()
|
||||
self.report({"ERROR"}, f"Unable to start DA3 task: {error}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
_active_job = ActiveJob(
|
||||
kind=self.job_type,
|
||||
process=process,
|
||||
log_file=log_file,
|
||||
log_path=paths["log"],
|
||||
status_path=paths["status"],
|
||||
result_directory=paths["result"],
|
||||
export_format=(
|
||||
settings.export_format if self.job_type == RECONSTRUCTION_JOB
|
||||
else "mini_npz" if self.job_type == IMAGE_DEPTH_JOB
|
||||
else ""
|
||||
),
|
||||
source_object_name=self.snapform_source_object_name,
|
||||
process_res=self.snapform_process_res,
|
||||
subdivision_level=self.snapform_subdivision_level,
|
||||
delete_source=self.snapform_delete_source,
|
||||
)
|
||||
settings.status = self._starting_message(settings)
|
||||
settings.progress = 0.0
|
||||
self._timer = context.window_manager.event_timer_add(
|
||||
0.25,
|
||||
window=context.window,
|
||||
)
|
||||
context.window_manager.modal_handler_add(self)
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _prepare_command(self, _context, settings):
|
||||
paths = runtime.new_job_paths(self.job_type)
|
||||
if self.job_type == "INSTALL_RUNTIME":
|
||||
self._require_online_access()
|
||||
return runtime.install_runtime_command(paths["status"]), paths
|
||||
|
||||
if not runtime.runtime_ready():
|
||||
raise RuntimeError("Install the SnapForm Runtime first")
|
||||
|
||||
if self.job_type == "DOWNLOAD_MODEL":
|
||||
self._require_online_access()
|
||||
model = get_model(self.model_key)
|
||||
return (
|
||||
runtime.download_model_command(model.key, paths["status"]),
|
||||
paths,
|
||||
)
|
||||
|
||||
configured_input = (
|
||||
self.snapform_input_path
|
||||
if self.job_type == IMAGE_DEPTH_JOB
|
||||
else settings.input_path
|
||||
)
|
||||
input_path = Path(bpy.path.abspath(configured_input))
|
||||
if not input_path.is_file():
|
||||
raise ValueError("Select a valid image or video file")
|
||||
if self.job_type not in {RECONSTRUCTION_JOB, IMAGE_DEPTH_JOB}:
|
||||
raise RuntimeError(f"Unsupported task type: {self.job_type}")
|
||||
export_format = (
|
||||
"mini_npz"
|
||||
if self.job_type == IMAGE_DEPTH_JOB
|
||||
else settings.export_format
|
||||
)
|
||||
use_ray_pose = None
|
||||
process_res = None
|
||||
model_key = selected_model_key(settings, export_format)
|
||||
if self.job_type == IMAGE_DEPTH_JOB:
|
||||
model_key = self.snapform_model_key
|
||||
use_ray_pose = self.snapform_use_ray_pose
|
||||
process_res = self.snapform_process_res
|
||||
model = get_model(model_key)
|
||||
if not runtime.model_ready(model_key):
|
||||
raise RuntimeError(f"Download {model.label} first")
|
||||
if export_format == "gs_ply" and not model.supports_gs:
|
||||
raise RuntimeError(
|
||||
"PLY export requires a DA3 Giant or Nested Giant model"
|
||||
)
|
||||
return (
|
||||
runtime.reconstruct_command(
|
||||
settings,
|
||||
export_format,
|
||||
paths["status"],
|
||||
paths["result"],
|
||||
input_path=input_path,
|
||||
model_key=model_key,
|
||||
process_res=process_res,
|
||||
process_res_method=(
|
||||
IMAGE_DEPTH_PROCESS_RES_METHOD
|
||||
if self.job_type == IMAGE_DEPTH_JOB
|
||||
else None
|
||||
),
|
||||
use_ray_pose=use_ray_pose,
|
||||
),
|
||||
paths,
|
||||
)
|
||||
|
||||
def _require_online_access(self):
|
||||
if not bpy.app.online_access:
|
||||
raise RuntimeError(
|
||||
"Online Access is disabled in Blender Preferences"
|
||||
)
|
||||
|
||||
def _starting_message(self, settings):
|
||||
labels = {
|
||||
"INSTALL_RUNTIME": "Starting Runtime installation",
|
||||
"DOWNLOAD_MODEL": "Starting model download",
|
||||
}
|
||||
if self.job_type == RECONSTRUCTION_JOB:
|
||||
return f"Starting DA3 {settings.export_format} export"
|
||||
if self.job_type == IMAGE_DEPTH_JOB:
|
||||
return "Starting DA3 image depth conversion"
|
||||
if self.job_type == "DOWNLOAD_MODEL":
|
||||
return f"Starting {get_model(self.model_key).label} download"
|
||||
return labels[self.job_type]
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type == "ESC":
|
||||
cancel_active_job()
|
||||
|
||||
if event.type != "TIMER":
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
job = active_job()
|
||||
if job is None:
|
||||
self._remove_timer(context)
|
||||
return {"CANCELLED"}
|
||||
|
||||
self._update_status(context, job)
|
||||
return_code = job.process.poll()
|
||||
if return_code is None:
|
||||
return {"RUNNING_MODAL"}
|
||||
return self._finish(context, job, return_code)
|
||||
|
||||
def _update_status(self, context, job):
|
||||
status = runtime.read_status(job.status_path)
|
||||
if status is None:
|
||||
return
|
||||
settings = context.scene.snapform_settings
|
||||
settings.progress = float(status.get("progress", settings.progress))
|
||||
settings.status = status.get("message", settings.status)
|
||||
self._redraw_ui(context)
|
||||
|
||||
def _finish(self, context, job, return_code):
|
||||
settings = context.scene.snapform_settings
|
||||
self._remove_timer(context)
|
||||
job.log_file.close()
|
||||
|
||||
if job.cancelled:
|
||||
settings.status = "Task cancelled"
|
||||
settings.progress = 0.0
|
||||
close_active_job()
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"CANCELLED"}
|
||||
|
||||
status = runtime.read_status(job.status_path) or {}
|
||||
error = status.get("error")
|
||||
if return_code != 0 or error:
|
||||
settings.status = "Task failed"
|
||||
settings.progress = 0.0
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"{error or 'DA3 subprocess failed'}; log: {job.log_path}",
|
||||
)
|
||||
close_active_job()
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"CANCELLED"}
|
||||
|
||||
if job.kind == RECONSTRUCTION_JOB:
|
||||
try:
|
||||
open_job_directory(job.result_directory)
|
||||
except Exception as open_error:
|
||||
self.report({"WARNING"}, str(open_error))
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Exported {job.export_format} to {job.result_directory}",
|
||||
)
|
||||
if job.kind == IMAGE_DEPTH_JOB:
|
||||
try:
|
||||
settings.status = "Building depth plane"
|
||||
settings.progress = 0.96
|
||||
self._redraw_ui(context, force=True)
|
||||
self._build_depth_plane(context, job)
|
||||
except (OSError, RuntimeError, ValueError) as import_error:
|
||||
settings.status = "Depth import failed"
|
||||
settings.progress = 0.0
|
||||
self.report({"ERROR"}, str(import_error))
|
||||
close_active_job()
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"CANCELLED"}
|
||||
status["message"] = "DA3 depth plane is ready"
|
||||
|
||||
settings.status = status.get("message", "Task complete")
|
||||
settings.progress = 1.0
|
||||
close_active_job()
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"FINISHED"}
|
||||
|
||||
def _build_depth_plane(self, context, job):
|
||||
npz_path = (
|
||||
job.result_directory
|
||||
/ "exports"
|
||||
/ "mini_npz"
|
||||
/ "results.npz"
|
||||
)
|
||||
result = bpy.ops.snapform.build_depth_plane(
|
||||
snapform_source_object_name=job.source_object_name,
|
||||
snapform_npz_path=str(npz_path),
|
||||
snapform_subdivision_level=job.subdivision_level,
|
||||
snapform_delete_source=job.delete_source,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Unable to build the depth plane")
|
||||
undo_result = bpy.ops.ed.undo_push(message="Convert to Depth Plane")
|
||||
if "FINISHED" not in undo_result:
|
||||
raise RuntimeError("Unable to create the depth plane Undo step")
|
||||
|
||||
def _remove_timer(self, context):
|
||||
timer = getattr(self, "_timer", None)
|
||||
if timer is None:
|
||||
return
|
||||
context.window_manager.event_timer_remove(timer)
|
||||
self._timer = None
|
||||
|
||||
def _redraw_ui(self, context, force=False):
|
||||
from .status_bar import redraw_ui
|
||||
|
||||
redraw_ui(context, force=force)
|
||||
|
||||
def cancel(self, context):
|
||||
cancel_active_job()
|
||||
self._remove_timer(context)
|
||||
self._redraw_ui(context, force=True)
|
||||
|
||||
|
||||
class CancelJob(bpy.types.Operator):
|
||||
bl_idname = "snapform.cancel_job"
|
||||
bl_label = "Cancel SnapForm Task"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, _context):
|
||||
return active_job() is not None
|
||||
|
||||
def execute(self, _context):
|
||||
cancel_active_job()
|
||||
return {"FINISHED"}
|
||||
Reference in New Issue
Block a user