Files
AnyImage/src/snapform/runtime.py
nan 6d4d1c4ec2 Refactor and enhance DA3 and BEN2 debug panels, introduce mesh export functionality
- Renamed DA3 and BEN2 debug panels for clarity and consistency.
- Added a new function to draw DA3 input settings to reduce code duplication.
- Implemented mesh export functionality, including confidence thresholding and edge filtering.
- Updated export format options to include new mesh type and adjusted related properties.
- Enhanced job handling to move generated inputs into job root and manage temporary directories.
- Improved error handling and file management during export processes.
- Added unit tests for new mesh functionality and ensured existing tests cover changes.
2026-08-09 21:31:08 +08:00

884 lines
24 KiB
Python

import json
import os
import platform
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, Request, build_opener
import bpy
from .models import (
BEN2_MODEL,
get_downloadable_model,
model_directory_name,
selected_model_key,
)
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
RUNTIME_SCHEMA = 4
HOST_SCHEMA = 2
HOST_ADDRESS = "127.0.0.1"
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
JOB_FOLDER_NAMES = {
"INSTALL_RUNTIME": "install-runtime",
"DOWNLOAD_MODEL": "download-model",
"RECONSTRUCT": "export-reconstruction",
"IMAGE_DEPTH": "generate-depth-plane",
"REMOVE_BACKGROUND": "remove-background",
"IMAGE_BACKGROUND": "remove-image-background",
"ORIGINAL_BACKGROUND": "generate-original-depth-plane",
"ORIGINAL_FRAMES": "extract-original-frames",
}
@dataclass(frozen=True)
class HostJobRequest:
payload: dict
@dataclass
class HostConnection:
port: int
token: str
pid: int
instance_id: str
@dataclass
class LaunchedHost:
process: subprocess.Popen
log_file: object
_host_connection = None
_launched_host = None
_registered_instance_id = None
_client_id = uuid.uuid4().hex
_host_auto_start = False
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_local_opener = build_opener(ProxyHandler({}))
_client_label = f"Blender {getattr(bpy.app, 'version_string', '')}".strip()
def preferences():
addon = bpy.context.preferences.addons.get(__package__)
return addon.preferences if addon is not None else None
def storage_root():
addon_preferences = preferences()
configured = getattr(addon_preferences, "storage_root", "")
root = (
Path(bpy.path.abspath(configured)).expanduser()
if configured
else DEFAULT_STORAGE_ROOT
)
root.mkdir(parents=True, exist_ok=True)
return root
def runtime_directory():
return storage_root() / "runtime"
def runtime_python():
directory = runtime_directory()
if os.name == "nt":
return directory / "Scripts" / "python.exe"
return directory / "bin" / "python"
def runtime_ready():
ready_path = runtime_directory() / "snapform_ready.json"
if not runtime_python().is_file() or not ready_path.is_file():
return False
try:
ready = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return False
return ready.get("runtime_schema") == RUNTIME_SCHEMA
def models_directory():
path = storage_root() / "models"
path.mkdir(parents=True, exist_ok=True)
return path
def model_directory(model_key):
root = models_directory()
directory = root / model_directory_name(model_key)
legacy_directory = root / model_key.lower()
if (
not directory.is_dir()
and legacy_directory.is_dir()
and legacy_directory != directory
):
legacy_directory.rename(directory)
return directory
def model_ready(model_key):
path = model_directory(model_key)
return (path / "config.json").is_file() and any(
path.glob("*.safetensors")
)
def runtime_devices():
"""Devices detected by the installed Runtime, or None when unknown."""
ready_path = runtime_directory() / "snapform_ready.json"
if not ready_path.is_file():
return None
try:
payload = json.loads(ready_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
devices = payload.get("devices")
if not isinstance(devices, list) or not devices:
return None
return [str(name).lower() for name in devices]
def machine_devices():
"""Devices the machine can run, fastest first; used before Runtime install."""
devices = ["cpu"]
if shutil.which("nvidia-smi"):
devices.insert(0, "cuda")
elif sys.platform == "darwin" and platform.machine().lower() in {
"arm64",
"aarch64",
}:
devices.insert(0, "mps")
return devices
def supported_devices():
"""Inference devices available on this machine, fastest first."""
return runtime_devices() or machine_devices()
DEVICE_LABELS = {
"cuda": ("CUDA", "NVIDIA CUDA"),
"mps": ("MPS", "Apple Metal (MPS)"),
"cpu": ("CPU", "CPU"),
}
def device_enum_items(_owner, _context):
"""Blender EnumProperty items listing only devices supported on this machine."""
return [
(name.upper(), *DEVICE_LABELS[name], index)
for index, name in enumerate(supported_devices())
]
def production_device():
"""Device configured for production tasks, falling back to Auto."""
addon_preferences = preferences()
configured = getattr(addon_preferences, "device", "")
return configured or "AUTO"
def jobs_directory():
path = storage_root() / "jobs"
path.mkdir(parents=True, exist_ok=True)
return path
def temporary_input_path():
directory = Path(tempfile.mkdtemp(prefix="snapform-input-"))
return directory / "input.png"
def temporary_input_directory():
return Path(tempfile.mkdtemp(prefix="snapform-input-"))
def move_input_into_job(input_path, job_directory):
"""Move a Blender-generated input copy into the flat job directory."""
input_path = Path(input_path)
job_directory = Path(job_directory)
if input_path.is_file():
destination = job_directory / "input.png"
input_path.replace(destination)
try:
input_path.parent.rmdir()
except OSError:
pass
return destination
if not input_path.is_dir():
raise ValueError(f"Temporary input does not exist: {input_path}")
for source in input_path.iterdir():
if not source.is_file():
raise ValueError(f"Temporary input contains a directory: {source}")
destination = job_directory / source.name
if destination.exists():
raise ValueError(f"Job input filename already exists: {source.name}")
source.replace(destination)
input_path.rmdir()
return job_directory
def job_folder_name(job_type):
"""Return the folder name segment for a job type."""
return JOB_FOLDER_NAMES.get(job_type, job_type.lower())
def new_job_paths(job_type):
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
stem = f"{job_folder_name(job_type)}_{timestamp}"
directory = jobs_directory() / stem
directory.mkdir(parents=True, exist_ok=True)
return {
"log": directory / f"{directory.name}.log",
"status": directory / f"{directory.name}.json",
"result": directory,
}
def uv_executable():
"""Return the bundled uv path inside the shared storage."""
return str(uv_cache_path())
def uv_cache_path():
if os.name == "nt":
return storage_root() / "uv" / "uv.exe"
return storage_root() / "uv" / "uv"
def install_runtime_command(status_path):
executable = uv_executable()
backend = "cu128" if "cuda" in machine_devices() else "cpu"
script = Path(__file__).with_name("bootstrap_runtime.py")
command = [
sys.executable,
str(script),
"--uv",
executable,
"--runtime-dir",
str(runtime_directory()),
"--models-dir",
str(models_directory()),
"--backend",
backend,
"--da3-commit",
DA3_COMMIT,
"--ben2-commit",
BEN2_COMMIT,
"--status",
str(status_path),
]
return command
def host_log_path():
return storage_root() / "ai-host.log"
def host_discovery_path():
return storage_root() / "host.json"
def host_lock_path():
return storage_root() / "host.lock"
def _available_host_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind((HOST_ADDRESS, 0))
return listener.getsockname()[1]
def _host_url(connection, path):
return f"http://{HOST_ADDRESS}:{connection.port}{path}"
def _host_http_request(connection, method, path, payload=None, timeout=2.0):
data = None
headers = {"X-SnapForm-Token": connection.token}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
_host_url(connection, path),
data=data,
headers=headers,
method=method,
)
try:
with _local_opener.open(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as error:
try:
detail = json.loads(error.read().decode("utf-8")).get("detail")
except (json.JSONDecodeError, UnicodeDecodeError):
detail = None
raise RuntimeError(detail or f"AI Host returned HTTP {error.code}")
except (OSError, URLError) as error:
raise RuntimeError(f"Unable to reach AI Host: {error}")
def _read_host_discovery():
try:
payload = json.loads(host_discovery_path().read_text(encoding="utf-8"))
return HostConnection(
port=int(payload["port"]),
token=str(payload["token"]),
pid=int(payload["pid"]),
instance_id=str(payload["instance_id"]),
)
except (KeyError, TypeError, ValueError, OSError, json.JSONDecodeError):
return None
def _check_host(connection, timeout=0.3):
health = _host_http_request(
connection,
"GET",
"/health",
timeout=timeout,
)
if health.get("host_schema") != HOST_SCHEMA:
raise RuntimeError("AI Host protocol is out of date")
if health.get("instance_id") != connection.instance_id:
raise RuntimeError("AI Host discovery is stale")
return health
def _discover_live_host(timeout=0.3):
connection = _read_host_discovery()
if connection is None:
return None, None
try:
return connection, _check_host(connection, timeout=timeout)
except RuntimeError:
return None, None
def start_host():
global _launched_host
if _launched_host is not None and _launched_host.process.poll() is None:
return
_close_launched_host()
port = _available_host_port()
token = secrets.token_urlsafe(32)
instance_id = uuid.uuid4().hex
log_file = host_log_path().open("a", encoding="utf-8")
command = [
str(runtime_python()),
"-u",
str(Path(__file__).with_name("ai_host.py")),
"--host",
HOST_ADDRESS,
"--port",
str(port),
"--token",
token,
"--instance-id",
instance_id,
"--discovery",
str(host_discovery_path()),
"--lock",
str(host_lock_path()),
]
try:
process = subprocess.Popen(
command,
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=storage_root(),
creationflags=creation_flags(),
)
except OSError:
log_file.close()
raise
_launched_host = LaunchedHost(process, log_file)
def ensure_host(timeout=60.0):
global _host_auto_start, _host_connection, _host_snapshot
_host_auto_start = True
deadline = time.monotonic() + timeout
last_launch = 0.0
starting = False
while time.monotonic() < deadline:
connection, health = _discover_live_host(timeout=0.5)
if connection is not None:
_host_connection = connection
_register_host_client(connection)
_host_snapshot = _format_host_snapshot(connection, health)
_redraw_host_ui()
return connection
if not starting:
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
starting = True
_close_finished_launched_host()
if (
_launched_host is None
and time.monotonic() - last_launch >= 1.0
):
start_host()
last_launch = time.monotonic()
time.sleep(0.05)
raise RuntimeError(f"AI Host did not become ready; log: {host_log_path()}")
def submit_host_job(request):
connection = _host_connection or ensure_host()
payload = dict(request.payload)
payload["client_id"] = _client_id
_host_http_request(
connection,
"POST",
"/jobs",
payload=payload,
timeout=5.0,
)
return connection
def stop_host():
global _host_auto_start, _host_connection, _host_snapshot
global _registered_instance_id
connection = _host_connection
if connection is None:
connection, _health = _discover_live_host(timeout=0.5)
if connection is None:
_close_launched_host(terminate=True)
_host_auto_start = False
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
return
_register_host_client(connection)
_host_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_host_auto_start = False
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
_check_host(connection, timeout=0.2)
except RuntimeError:
break
time.sleep(0.05)
else:
raise RuntimeError("AI Host did not stop")
_host_connection = None
_registered_instance_id = None
_close_finished_launched_host()
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
def restart_host():
global _host_auto_start, _host_connection, _registered_instance_id
global _host_snapshot
connection = ensure_host()
_host_http_request(
connection,
"POST",
"/shutdown",
payload={"client_id": _client_id},
timeout=2.0,
)
_host_auto_start = True
_host_connection = None
_registered_instance_id = None
_host_snapshot = {"state": "STARTING", "message": "Restarting"}
def enable_host():
global _host_auto_start, _host_connection, _host_snapshot
_host_auto_start = True
connection, health = _discover_live_host(timeout=0.2)
if connection is not None:
_host_connection = connection
_register_host_client(connection)
_host_snapshot = _format_host_snapshot(connection, health)
_redraw_host_ui()
return
start_host()
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
def clear_host_models():
connection = ensure_host()
return _host_http_request(
connection,
"POST",
"/models/clear",
timeout=5.0,
)
def cancel_host_job(job_id):
connection = ensure_host()
return _host_http_request(
connection,
"DELETE",
f"/jobs/{job_id}",
payload={"client_id": _client_id},
timeout=2.0,
)
def detach_host_client():
global _host_connection, _registered_instance_id
connection = _host_connection
if connection is not None and _registered_instance_id is not None:
try:
_host_http_request(
connection,
"DELETE",
f"/clients/{_client_id}",
timeout=0.5,
)
except RuntimeError:
pass
_host_connection = None
_registered_instance_id = None
# 如果 Host 是由当前 Blender 启动的,关闭 Blender 时直接终止它,无需等待空闲超时
_close_launched_host(terminate=True)
def schedule_host_start():
timers = getattr(bpy.app, "timers", None)
if timers is None:
return
if timers.is_registered(_service_host_timer):
return
timers.register(_service_host_timer, first_interval=1.0, persistent=True)
def unschedule_host():
timers = getattr(bpy.app, "timers", None)
if timers is None:
return
if timers.is_registered(_service_host_timer):
timers.unregister(_service_host_timer)
def host_status():
return dict(_host_snapshot)
def host_busy():
return _host_snapshot.get("state") == "BUSY"
def mark_host_job_complete(job_id):
"""Release a stale local busy snapshot after its terminal status is observed."""
global _host_snapshot
active = _host_snapshot.get("active_job")
if active and active.get("job_id") != job_id:
return
if _host_snapshot.get("state") != "BUSY":
return
_host_snapshot = {
**_host_snapshot,
"busy": False,
"active_job": None,
"state": "READY",
"message": "Ready",
}
def _service_host_timer():
global _host_connection, _host_snapshot, _registered_instance_id
if not runtime_ready():
_host_snapshot = {
"state": "UNAVAILABLE",
"message": "Runtime is not installed",
}
return 2.0
connection, health = _discover_live_host(timeout=0.2)
if connection is None:
_host_connection = None
_registered_instance_id = None
_close_finished_launched_host()
if not _host_auto_start:
if _host_snapshot.get("state") != "STOPPED":
_host_snapshot = {"state": "STOPPED", "message": "Stopped"}
_redraw_host_ui()
return 2.0
try:
start_host()
except OSError as error:
_host_snapshot = {"state": "ERROR", "message": str(error)}
return 2.0
_host_snapshot = {"state": "STARTING", "message": "Starting"}
_redraw_host_ui()
return 1.0
try:
_host_connection = connection
if _registered_instance_id == connection.instance_id:
try:
_host_http_request(
connection,
"POST",
f"/clients/{_client_id}/heartbeat",
timeout=0.5,
)
except RuntimeError:
_registered_instance_id = None
_register_host_client(connection)
health = _check_host(connection, timeout=0.5)
_host_snapshot = _format_host_snapshot(connection, health)
except RuntimeError as error:
_host_connection = None
_registered_instance_id = None
_host_snapshot = {"state": "ERROR", "message": str(error)}
_redraw_host_ui()
return 5.0
def _register_host_client(connection):
global _registered_instance_id
if _registered_instance_id == connection.instance_id:
return
_host_http_request(
connection,
"POST",
"/clients",
payload={"client_id": _client_id, "label": _client_label},
timeout=2.0,
)
_registered_instance_id = connection.instance_id
def _format_host_snapshot(connection, health):
state = "BUSY" if health.get("busy") else "READY"
return {
**health,
"state": state,
"message": "Busy" if state == "BUSY" else "Ready",
"port": connection.port,
}
def _redraw_host_ui():
if threading.current_thread() is not threading.main_thread():
return
try:
from .status_bar import redraw_ui
redraw_ui(bpy.context, force=True)
except (AttributeError, RuntimeError):
pass
def _close_finished_launched_host():
if _launched_host is None or _launched_host.process.poll() is None:
return
_close_launched_host()
def _close_launched_host(terminate=False):
global _launched_host
launched = _launched_host
_launched_host = None
if launched is None:
return
if terminate and launched.process.poll() is None:
launched.process.terminate()
try:
launched.process.wait(timeout=3.0)
except subprocess.TimeoutExpired:
launched.process.kill()
launched.process.wait(timeout=3.0)
launched.log_file.close()
def download_model_command(model_key, status_path):
model = get_downloadable_model(model_key)
command = [
str(runtime_python()),
str(Path(__file__).with_name("worker.py")),
"download",
"--repository",
model.repository,
"--model-dir",
str(model_directory(model_key)),
"--status",
str(status_path),
"--label",
model.label,
]
for pattern in model.download_patterns:
command.extend(["--allow-pattern", pattern])
return command
def remove_background_request(
settings,
input_path,
status_path,
result_directory,
model_key,
video_frames=None,
start_frame=1,
device=None,
background_depth_distance=0.0,
):
return HostJobRequest(
{
"job_id": Path(status_path).stem,
"command": "remove-background",
"parameters": {
"input": str(input_path),
"model_dir": str(model_directory(model_key)),
"output_dir": str(result_directory),
"status": str(status_path),
"device": (device or settings.device).lower(),
"video_frames": (
settings.video_frames
if video_frames is None
else video_frames
),
"start_frame": start_frame,
"background_depth_distance": float(
background_depth_distance
),
},
}
)
def extract_frames_request(
settings,
input_path,
status_path,
result_directory,
video_frames=None,
start_frame=1,
):
return HostJobRequest(
{
"job_id": Path(status_path).stem,
"command": "extract-frames",
"parameters": {
"input": str(input_path),
"output_dir": str(result_directory),
"status": str(status_path),
"video_frames": (
settings.video_frames
if video_frames is None
else video_frames
),
"start_frame": start_frame,
},
}
)
def reconstruct_request(
settings,
export_format,
status_path,
result_directory,
input_path=None,
model_key=None,
process_res=None,
process_res_method=None,
use_ray_pose=None,
remove_background=False,
background_depth_distance=0.0,
start_frame=1,
device=None,
):
snapform_selected_key = model_key or selected_model_key(settings, export_format)
snapform_selected_process_res = (
settings.process_res if process_res is None else process_res
)
snapform_selected_process_res_method = (
settings.process_res_method
if process_res_method is None
else process_res_method
)
snapform_selected_ray_pose = (
settings.use_ray_pose if use_ray_pose is None else use_ray_pose
)
parameters = {
"input": str(
Path(input_path)
if input_path is not None
else Path(bpy.path.abspath(settings.input_path))
),
"model_dir": str(model_directory(snapform_selected_key)),
"output_dir": str(result_directory),
"export_format": export_format,
"status": str(status_path),
"process_res": snapform_selected_process_res,
"process_res_method": snapform_selected_process_res_method,
"conf_thresh_percentile": settings.conf_thresh_percentile,
"num_max_points": settings.num_max_points,
"show_cameras": 1 if settings.show_cameras else 0,
"gs_views_interval": settings.gs_views_interval,
"video_frames": settings.video_frames,
"start_frame": start_frame,
"device": (device or settings.device).lower(),
"use_ray_pose": 1 if snapform_selected_ray_pose else 0,
"ref_view_strategy": settings.ref_view_strategy,
"remove_background_model_dir": "",
"background_depth_distance": float(background_depth_distance),
"mesh_filter_edges": bool(
getattr(settings, "mesh_filter_edges", True)
),
"mesh_max_edge_length": float(
getattr(settings, "mesh_max_edge_length", 0.1)
),
}
if remove_background or background_depth_distance > 0.0:
parameters["remove_background_model_dir"] = str(
model_directory(BEN2_MODEL.key)
)
return HostJobRequest(
{
"job_id": Path(status_path).stem,
"command": "reconstruct",
"parameters": parameters,
}
)
def creation_flags():
if os.name != "nt":
return 0
return subprocess.CREATE_NO_WINDOW
def read_status(path):
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None