Files
AnyImage/src/snapform/worker.py
nan b02372c457 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.
2026-08-03 10:13:33 +08:00

382 lines
12 KiB
Python

import argparse
import json
import shutil
import tempfile
import threading
import time
from pathlib import Path
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
VIDEO_SUFFIXES = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
".m4v",
".mts",
".mpg",
".mpeg",
}
SCENE_FILENAME = "scene.glb"
EXPORT_FORMATS = ("glb", "gs_ply", "depth_vis", "mini_npz")
def write_status(path, progress, message, error=None):
payload = {"progress": progress, "message": message}
if error:
payload["error"] = error
Path(path).write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
)
def collect_images(directory):
directory = Path(directory)
return sorted(
path
for path in directory.iterdir()
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
)
def extract_video_frames(video_path, output_directory, maximum_frames, cv2):
capture = cv2.VideoCapture(str(video_path))
if not capture.isOpened():
raise RuntimeError(f"Unable to open video: {video_path}")
output_directory = Path(output_directory)
output_directory.mkdir(parents=True, exist_ok=True)
total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
if total_frames <= 0:
capture.release()
raise RuntimeError("The video does not report a readable frame count")
import numpy as np
sample_count = min(maximum_frames, total_frames)
frame_indices = np.linspace(
0,
total_frames - 1,
sample_count,
dtype=np.int64,
)
image_paths = []
try:
for output_index, frame_index in enumerate(frame_indices):
capture.set(cv2.CAP_PROP_POS_FRAMES, int(frame_index))
success, frame = capture.read()
if not success:
raise RuntimeError(
f"Unable to read video frame {int(frame_index)}"
)
image_path = output_directory / f"frame-{output_index:04d}.jpg"
if not cv2.imwrite(str(image_path), frame):
raise RuntimeError(f"Unable to write video frame: {image_path}")
image_paths.append(image_path)
finally:
capture.release()
return image_paths
def prepare_input(input_path, video_frames, temporary_root):
input_path = Path(input_path)
suffix = input_path.suffix.lower()
if suffix in IMAGE_SUFFIXES:
return [input_path], None
if suffix not in VIDEO_SUFFIXES:
supported = ", ".join(sorted(IMAGE_SUFFIXES | VIDEO_SUFFIXES))
raise RuntimeError(f"Unsupported input type; expected one of: {supported}")
import cv2
temporary_directory = Path(
tempfile.mkdtemp(prefix="snapform-video-", dir=temporary_root)
)
try:
images = extract_video_frames(
input_path,
temporary_directory,
video_frames,
cv2,
)
except Exception:
shutil.rmtree(temporary_directory, ignore_errors=True)
raise
return images, temporary_directory
class DownloadProgressReporter:
def __init__(self, status_path, repository, clock=time.monotonic):
self.status_path = status_path
self.repository = repository
self.clock = clock
self.lock = threading.Lock()
self.progress = 0.05
self.last_write_time = 0.0
def report(self, current, total):
if not total or current <= 0:
return
factor = min(max(current / total, 0.0), 1.0)
progress = 0.05 + factor * 0.90
now = self.clock()
with self.lock:
if progress <= self.progress:
return
if now - self.last_write_time < 0.20 and progress < 0.95:
return
self.progress = progress
self.last_write_time = now
write_status(
self.status_path,
progress,
f"Downloading {self.repository}",
)
def create_download_progress_class(
status_path,
repository,
tqdm_type=None,
clock=time.monotonic,
):
if tqdm_type is None:
from tqdm.auto import tqdm as tqdm_type
reporter = DownloadProgressReporter(status_path, repository, clock)
class DownloadProgress(tqdm_type):
def __init__(self, *args, **kwargs):
description = str(kwargs.get("desc", ""))
self.snapform_reports_progress = (
description == "Downloading bytes"
or description.startswith("Fetching ")
)
super().__init__(*args, **kwargs)
def update(self, amount=1):
result = super().update(amount)
if self.snapform_reports_progress:
reporter.report(self.n, self.total)
return result
def refresh(self, *args, **kwargs):
result = super().refresh(*args, **kwargs)
if self.snapform_reports_progress:
reporter.report(self.n, self.total)
return result
return DownloadProgress
def download_model(args):
from huggingface_hub import snapshot_download
model_dir = Path(args.model_dir)
model_dir.mkdir(parents=True, exist_ok=True)
write_status(args.status, 0.05, f"Downloading {args.repository}")
progress_class = create_download_progress_class(
args.status,
args.repository,
)
snapshot_download(
repo_id=args.repository,
local_dir=model_dir,
tqdm_class=progress_class,
)
write_status(args.status, 1.0, "Model is ready")
def select_device(requested, torch):
if requested == "cpu":
return torch.device("cpu")
if requested == "cuda":
if not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but is not available")
return torch.device("cuda")
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def exported_paths(output_directory, export_format):
output_directory = Path(output_directory)
if export_format == "glb":
scene_path = output_directory / SCENE_FILENAME
return [scene_path] if scene_path.is_file() else []
if export_format == "gs_ply":
return sorted((output_directory / "gs_ply").glob("*.ply"))
if export_format == "depth_vis":
return sorted((output_directory / "depth_vis").glob("*.jpg"))
if export_format == "mini_npz":
result_path = output_directory / "exports" / "mini_npz" / "results.npz"
return [result_path] if result_path.is_file() else []
return []
def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
# 上游 NPZ exporter 使用后台线程,因此需要等待文件实际落盘。
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result_paths = exported_paths(output_directory, export_format)
if result_paths:
return result_paths
time.sleep(0.05)
return []
def reconstruct(args):
write_status(args.status, 0.02, "Starting DA3 runtime")
import torch
from depth_anything_3.api import DepthAnything3
from depth_anything_3.utils.export import export
output_directory = Path(args.output_dir)
output_directory.mkdir(parents=True, exist_ok=False)
write_status(args.status, 0.08, "Preparing input")
images, temporary_directory = prepare_input(
args.input,
args.video_frames,
output_directory.parent,
)
try:
device = select_device(args.device, torch)
write_status(args.status, 0.15, f"Loading model on {device.type}")
model = DepthAnything3.from_pretrained(args.model_dir)
model = model.to(device=device)
model.eval()
write_status(
args.status,
0.25,
f"Estimating depth for {len(images)} frame(s)",
)
export_kwargs = {}
if args.export_format == "glb":
export_kwargs["glb"] = {
"conf_thresh_percentile": args.conf_thresh_percentile,
"num_max_points": args.num_max_points,
"show_cameras": bool(args.show_cameras),
"export_depth_vis": False,
}
if args.export_format == "gs_ply":
export_kwargs["gs_ply"] = {
"gs_views_interval": args.gs_views_interval,
}
with torch.inference_mode():
prediction = model.inference(
[str(path) for path in images],
infer_gs=args.export_format == "gs_ply",
process_res=args.process_res,
process_res_method=args.process_res_method,
use_ray_pose=bool(args.use_ray_pose),
ref_view_strategy=args.ref_view_strategy,
)
write_status(args.status, 0.88, f"Exporting {args.export_format}")
export(
prediction,
args.export_format,
str(output_directory),
**export_kwargs,
)
result_paths = wait_for_exported_paths(
output_directory,
args.export_format,
)
if not result_paths:
raise RuntimeError(
f"DA3 did not export any {args.export_format} files"
)
write_status(
args.status,
1.0,
f"Exported {len(result_paths)} {args.export_format} file(s)",
)
finally:
if temporary_directory is not None:
shutil.rmtree(temporary_directory, ignore_errors=True)
def parser():
root = argparse.ArgumentParser()
commands = root.add_subparsers(dest="command", required=True)
download = commands.add_parser("download")
download.add_argument("--repository", required=True)
download.add_argument("--model-dir", required=True)
download.add_argument("--status", required=True)
reconstruction = commands.add_parser("reconstruct")
reconstruction.add_argument("--input", required=True)
reconstruction.add_argument("--model-dir", required=True)
reconstruction.add_argument("--output-dir", required=True)
reconstruction.add_argument(
"--export-format",
choices=EXPORT_FORMATS,
required=True,
)
reconstruction.add_argument("--status", required=True)
reconstruction.add_argument("--process-res", type=int, required=True)
reconstruction.add_argument(
"--process-res-method",
choices=("upper_bound_resize", "lower_bound_resize"),
required=True,
)
reconstruction.add_argument(
"--conf-thresh-percentile",
type=float,
required=True,
)
reconstruction.add_argument("--num-max-points", type=int, required=True)
reconstruction.add_argument(
"--show-cameras",
type=int,
choices=(0, 1),
required=True,
)
reconstruction.add_argument(
"--gs-views-interval",
type=int,
required=True,
)
reconstruction.add_argument("--video-frames", type=int, required=True)
reconstruction.add_argument(
"--device",
choices=("auto", "cuda", "cpu"),
required=True,
)
reconstruction.add_argument(
"--use-ray-pose",
type=int,
choices=(0, 1),
required=True,
)
reconstruction.add_argument(
"--ref-view-strategy",
choices=("first", "middle", "saddle_balanced", "saddle_sim_range"),
required=True,
)
return root
def main():
args = parser().parse_args()
try:
if args.command == "download":
download_model(args)
else:
reconstruct(args)
except Exception as error:
write_status(args.status, 1.0, "DA3 task failed", str(error))
raise
if __name__ == "__main__":
main()