Bump version to 0.11.2 and update uv version to 0.11.3

- Updated version in pyproject.toml and blender_manifest.toml to 0.11.2.
- Enhanced uv executable path documentation in client.py.
- Added inference_mode parameter to reconstruct_request function in client.py.
- Implemented uv download functionality in install_env.py with improved error handling and checksum verification.
- Modified install_uv function to download and extract uv binary instead of using bundled version.
- Updated job progress handling in operators.py to track progress stages.
- Improved download progress reporting in worker.py and adjusted related tests.
- Refactored tests to accommodate changes in status reporting and model downloading.
- Removed vendor.lock.toml as part of the cleanup process.
This commit is contained in:
2026-08-14 23:43:01 +08:00
parent 102e1bfa9b
commit 0c8d3a8988
22 changed files with 497 additions and 511 deletions

View File

@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "snapform"
version = "0.11.1"
version = "0.11.2"
name = "SnapForm"
tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab"

View File

@@ -269,7 +269,7 @@ def job_id_from_status(status_path):
def uv_executable():
"""Return the bundled uv path inside the shared storage."""
"""Return the downloaded uv path inside the shared storage."""
return str(uv_cache_path())
@@ -833,6 +833,7 @@ def reconstruct_request(
mesh_filter_edges=None,
start_frame=1,
device=None,
inference_mode="joint",
):
snapform_selected_key = model_key or selected_model_key(settings, export_format)
snapform_selected_process_res = (
@@ -867,6 +868,7 @@ def reconstruct_request(
"device": (device or settings.device).lower(),
"use_ray_pose": 1 if snapform_selected_ray_pose else 0,
"ref_view_strategy": settings.ref_view_strategy,
"inference_mode": inference_mode,
"remove_background_model_dir": "",
"mesh_filter_edges": bool(
getattr(settings, "mesh_filter_edges", True)

View File

@@ -1,4 +1,6 @@
import argparse
import hashlib
import http.client
import json
import os
import platform
@@ -36,13 +38,30 @@ PILLOW_VERSION = "12.1.1"
AV_VERSION = "17.0.0"
TRIMESH_VERSION = "4.11.2"
HUGGINGFACE_HUB_VERSION = "1.4.1"
UV_VERSION = "0.11.3"
UV_OFFICIAL_CONNECT_TIMEOUT = 3.0
UV_MIRROR_CONNECT_TIMEOUT = 15.0
DOWNLOAD_CHUNK_SIZE = 256 * 1024
UV_OFFICIAL_BASE_URL = (
f"https://github.com/astral-sh/uv/releases/download/{UV_VERSION}"
)
UV_MIRROR_BASE_URL = f"https://mirror.omoolab.xyz/uv/{UV_VERSION}"
UV_ARTIFACTS = {
"x86_64-pc-windows-msvc": (
"uv-x86_64-pc-windows-msvc.zip",
"ae681c0aaec7cc96af184648cb88d73f8393ed60fa5880abdd6bdb910f9b227c",
),
"aarch64-apple-darwin": (
"uv-aarch64-apple-darwin.tar.gz",
"2bc3d0c7bf2bd08325b1e170abac6f7e5b3346e1d4eab3370d17cefec934996f",
),
"x86_64-unknown-linux-gnu": (
"uv-x86_64-unknown-linux-gnu.tar.gz",
"c0f3236f146e55472663cfbcc9be3042a9f1092275bbe3fe2a56a6cbfd3da5ce",
),
}
_resolved_pypi_index = None
DEFAULT_MODEL_KEYS = (MOGE2_MODEL.key, BEN2_MODEL.key)
RUNTIME_STAGE_COUNT = 2
ENVIRONMENT_SLICE = (0.02, 0.06)
AI_COMPONENTS_SLICE = (0.10, 0.85)
MODELS_SLICE_START = 0.85
MODELS_SLICE_END = 1.0
RAMP_RATE = 0.02
CREEP_CAP = 0.60
CHECK_CODE = (
@@ -54,6 +73,48 @@ CHECK_CODE = (
)
class _ConnectHTTPSConnection(http.client.HTTPSConnection):
"""Limit TCP/TLS setup without limiting artifact reads."""
connect_timeout = UV_OFFICIAL_CONNECT_TIMEOUT
def connect(self):
try:
self.timeout = self.connect_timeout
super().connect()
finally:
self.timeout = None
self.sock.settimeout(None)
class _MirrorConnectHTTPSConnection(_ConnectHTTPSConnection):
connect_timeout = UV_MIRROR_CONNECT_TIMEOUT
class _ConnectHTTPSHandler(urllib.request.HTTPSHandler):
def __init__(self, connection_type):
super().__init__()
self.connection_type = connection_type
def https_open(self, request):
return self.do_open(self.connection_type, request)
def build_uv_download_opener(connection_type):
return urllib.request.build_opener(
urllib.request.ProxyHandler(),
_ConnectHTTPSHandler(connection_type),
)
_uv_download_openers = {
UV_OFFICIAL_BASE_URL: build_uv_download_opener(_ConnectHTTPSConnection),
UV_MIRROR_BASE_URL: build_uv_download_opener(
_MirrorConnectHTTPSConnection
),
}
def configure_standard_streams():
"""Windows 图形进程可能继承 GBK stdout,安装日志统一写为 UTF-8。"""
for stream in (sys.stdout, sys.stderr):
@@ -484,22 +545,6 @@ def uv_download_target():
raise RuntimeError(f"Unsupported platform for uv download: {sys.platform}")
def bundled_vendor_directory():
packaged = Path(__file__).with_name("vendor")
if packaged.is_dir():
return packaged
project_root = Path(__file__).resolve().parents[2]
if (project_root / "pyproject.toml").is_file():
return project_root / "vendor"
return packaged
def bundled_uv_archive():
target, archive = uv_download_target()
path = bundled_vendor_directory() / f"uv-{target}.{archive}"
return path if path.is_file() else None
def archive_is_valid(path):
if zipfile.is_zipfile(path):
return True
@@ -526,6 +571,99 @@ def extract_uv(archive_path, uv_path):
os.chmod(uv_path, 0o755)
def file_sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def format_download_rate(rate):
size = float(rate)
units = ("B/s", "KB/s", "MB/s", "GB/s")
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
precision = 0 if unit_index == 0 else 1
return f"{size:.{precision}f} {units[unit_index]}"
def download_uv_archive(
destination,
status_path,
start,
end,
stage_fields,
clock=time.monotonic,
):
target, _archive_type = uv_download_target()
artifact = UV_ARTIFACTS.get(target)
if artifact is None:
raise RuntimeError(f"SnapForm does not provide uv for {target}")
filename, expected_checksum = artifact
failures = []
sources = (
(UV_OFFICIAL_BASE_URL, ""),
(UV_MIRROR_BASE_URL, " (mirror)"),
)
for base_url, source_label in sources:
destination.unlink(missing_ok=True)
url = f"{base_url}/{filename}"
write_status(
status_path,
start,
f"Downloading uv{source_label}",
**stage_fields,
)
request = urllib.request.Request(
url,
headers={"User-Agent": "SnapForm uv bootstrap"},
)
try:
with _uv_download_openers[base_url].open(request) as response:
total = int(response.headers.get("Content-Length", 0))
downloaded = 0
started_at = clock()
last_report_at = started_at
with destination.open("wb") as output:
while chunk := response.read(DOWNLOAD_CHUNK_SIZE):
output.write(chunk)
downloaded += len(chunk)
now = clock()
if now - last_report_at < 0.20 and downloaded < total:
continue
last_report_at = now
elapsed = max(now - started_at, 1e-6)
rate = downloaded / elapsed
message = (
f"Downloading uv{source_label} "
f"{format_download_rate(rate)}"
)
progress = start
if total:
progress += (end - start) * downloaded / total
write_status(
status_path,
min(progress, end),
message,
**stage_fields,
)
actual_checksum = file_sha256(destination)
if actual_checksum != expected_checksum:
raise RuntimeError(
f"SHA-256 mismatch: {actual_checksum}"
)
if not archive_is_valid(destination):
raise RuntimeError("Downloaded uv archive is invalid")
return destination
except Exception as error:
destination.unlink(missing_ok=True)
failures.append(f"{url}: {error}")
raise RuntimeError("Unable to download uv; " + "; ".join(failures))
def install_uv(
uv,
status_path,
@@ -535,29 +673,31 @@ def install_uv(
start=0.0,
end=1.0,
):
"""Extract the bundled uv binary; fail clearly when it is missing."""
"""Download, verify, and extract the pinned uv binary."""
stage_fields = {
"stage": stage,
"stages": stages,
"stage_label": stage_label,
}
bundled_uv = bundled_uv_archive()
if bundled_uv is None:
raise RuntimeError(
"Bundled uv is missing; reinstall the extension matching your "
f"platform (searched {bundled_vendor_directory()})"
)
if not archive_is_valid(bundled_uv):
raise RuntimeError(f"Bundled uv archive is invalid: {bundled_uv}")
print(f"Extracting bundled uv from {bundled_uv}")
write_status(
status_path,
start,
"extracting bundled uv",
**stage_fields,
)
Path(uv).parent.mkdir(parents=True, exist_ok=True)
extract_uv(bundled_uv, uv)
archive_path = Path(uv).with_name("uv-download.tmp")
try:
download_uv_archive(
archive_path,
status_path,
start,
end,
stage_fields,
)
write_status(
status_path,
end,
"Extracting uv",
**stage_fields,
)
extract_uv(archive_path, uv)
finally:
archive_path.unlink(missing_ok=True)
def run_stage(
@@ -649,38 +789,30 @@ def install(args):
stages = install_stages(args, venv_dir, python, uv)
env_stages = [stage for stage in stages if stage[3] != "download"]
model_stages = [stage for stage in stages if stage[3] == "download"]
model_span = (
(MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages)
if model_stages
else 0.0
)
total_stages = int(uv_missing) + 1 + len(model_stages)
stage = 1
installed_packages = []
for index, (label, command, environment, kind) in enumerate(env_stages):
if index == 0:
stage = 1
stage_start, stage_end = ENVIRONMENT_SLICE
elif index == 1:
stage = 2
stage_start, stage_end = AI_COMPONENTS_SLICE
else:
raise RuntimeError(f"Unexpected runtime stage: {label}")
if uv_missing and index == 0:
midpoint = stage_start + (stage_end - stage_start) * 0.5
install_uv(
uv,
args.status,
stage=stage,
stages=RUNTIME_STAGE_COUNT,
stage_label=label,
start=stage_start,
end=midpoint,
)
stage_start = midpoint
if uv_missing:
install_uv(
uv,
args.status,
stage=stage,
stages=total_stages,
stage_label="uv",
start=0.0,
end=1.0,
)
stage += 1
environment_label = "ONNX Environment"
for index, (_label, command, environment, kind) in enumerate(env_stages):
if index >= 2:
raise RuntimeError("Unexpected ONNX Environment stage")
stage_start, stage_end = ((0.0, 0.05) if index == 0 else (0.05, 1.0))
stage_installed = run_stage(
args.status,
stage,
RUNTIME_STAGE_COUNT,
label,
total_stages,
environment_label,
command,
environment,
kind,
@@ -689,6 +821,7 @@ def install(args):
)
if isinstance(stage_installed, list):
installed_packages.extend(stage_installed)
stage += 1
devices = installed_devices()
venv_dir.mkdir(parents=True, exist_ok=True)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
@@ -711,44 +844,50 @@ def install(args):
),
encoding="utf-8",
)
model_offset = MODELS_SLICE_START
failed_models = []
for label, command, environment, kind in model_stages:
stage_start = model_offset
stage_end = stage_start + model_span
try:
run_stage(
args.status,
None,
None,
stage,
total_stages,
label,
command,
environment,
kind,
start=stage_start,
end=stage_end,
start=0.0,
end=1.0,
)
except Exception as error:
print(f"Model download failed: {label}: {error}")
failed_models.append(label)
write_status(
args.status,
stage_end,
1.0,
f"Skipping {label}; continuing",
stage=stage,
stages=total_stages,
stage_label=label,
)
model_offset = stage_end
stage += 1
if failed_models:
labels = ", ".join(failed_models)
write_status(
args.status,
1.0,
f"Environment is installed; retry {labels} from Models",
stage=total_stages,
stages=total_stages,
stage_label=model_stages[-1][0],
)
return
write_status(
args.status,
1.0,
"Environment and recommended models ready",
stage=total_stages,
stages=total_stages,
stage_label=model_stages[-1][0] if model_stages else environment_label,
)

View File

@@ -108,6 +108,7 @@ class ActiveJob:
started: bool = False
start_error: str = ""
start_thread: threading.Thread | None = None
progress_stage: int | None = None
_active_job = None
@@ -520,6 +521,11 @@ class RunJob(bpy.types.Operator):
and remove_background
),
device=self._task_device(settings),
inference_mode=(
"per_frame"
if self.job_type in DA3_IMAGE_JOBS
else "joint"
),
),
paths,
)
@@ -612,10 +618,17 @@ class RunJob(bpy.types.Operator):
reported_progress = float(
status.get("progress", settings.progress)
)
settings.progress = max(
settings.progress,
min(max(reported_progress, 0.0), 1.0),
)
reported_progress = min(max(reported_progress, 0.0), 1.0)
reported_stage = status.get("stage")
if reported_stage is not None and reported_stage != getattr(
job,
"progress_stage",
None,
):
job.progress_stage = reported_stage
settings.progress = reported_progress
else:
settings.progress = max(settings.progress, reported_progress)
settings.status = self._format_status(status, settings.status)
self._redraw_ui(context, force=True)
@@ -624,38 +637,19 @@ class RunJob(bpy.types.Operator):
stage = status.get("stage")
stages = status.get("stages")
stage_label = status.get("stage_label")
parts = []
if stage and stages:
stage_text = f"{stage}/{stages}"
prefix = f"{stage}/{stages}"
normalized = str(message or "").strip()
if normalized and normalized.lower() != str(
stage_label or ""
).lower():
return f"{prefix} {normalized}"
if stage_label:
stage_text = f"{stage_text} {stage_label}"
parts.append(stage_text)
elif stage_label:
parts.append(str(stage_label))
return f"{prefix} {stage_label}"
return prefix
if message:
normalized = str(message).strip()
if normalized.lower() != str(stage_label or "").lower():
parts.append(normalized)
eta = self._format_eta(status.get("eta_seconds"))
if eta:
parts.append(eta)
return " | ".join(parts) if parts else (message or fallback)
@staticmethod
def _format_eta(seconds):
if seconds is None:
return ""
seconds = max(float(seconds), 0.0)
if seconds <= 10:
return "A few seconds left"
if seconds < 60:
rounded = max(5, int((seconds + 2.5) // 5) * 5)
return f"About {rounded} sec left"
if seconds < 3600:
minutes = int((seconds + 59) // 60)
return f"About {minutes} min left"
hours = int((seconds + 3599) // 3600)
return f"About {hours} hr left"
return str(message).strip()
return str(stage_label or fallback)
def _finish(self, context, job, return_code):
settings = context.scene.snapform_settings

View File

@@ -49,7 +49,8 @@ EXPORT_FORMATS = (
HF_ENDPOINT = "https://huggingface.co"
R2_ENDPOINT = "https://models.omoolab.xyz"
# 连接(TCP + TLS 握手)超时,源不可达时快速失败切换;下载阶段不设读超时。
CONNECT_TIMEOUT = 6.0
CONNECT_TIMEOUT = 3.0
DOWNLOAD_CHUNK_SIZE = 256 * 1024
class _ShortConnectHTTPSConnection(http.client.HTTPSConnection):
@@ -898,7 +899,7 @@ class DownloadProgressReporter:
with self.lock:
if progress <= self.progress:
return
if now - self.last_write_time < 0.20 and progress < 0.95:
if now - self.last_write_time < 0.20 and factor < 1.0:
return
self.progress = progress
self.last_write_time = now
@@ -907,20 +908,10 @@ class DownloadProgressReporter:
if not effective_rate and elapsed > 0:
effective_rate = current / elapsed
speed = format_rate(effective_rate) if effective_rate else ""
eta_seconds = None
if effective_rate and effective_rate > 0:
eta_seconds = max(total - current, 0) / effective_rate
if self.stage is not None:
message = (
f"Downloading packages | {speed}"
if speed
else "Downloading packages"
)
else:
prefix = self.label or self.repository
message = f"Downloading {prefix}"
if speed:
message = f"{message} | {speed}"
prefix = self.label or self.repository
message = f"Downloading {prefix}"
if speed:
message = f"{message} {speed}"
write_status(
self.status_path,
progress,
@@ -928,7 +919,6 @@ class DownloadProgressReporter:
stage=self.stage,
stages=self.stages,
stage_label=self.stage_label,
eta_seconds=eta_seconds,
)
@@ -1063,7 +1053,7 @@ def _download_declared_or_fail(args, model_dir, label, start, span, stage_kwargs
(
R2_ENDPOINT,
download_from_r2,
f"Downloading {label} | OmooLab mirror",
f"Downloading {label} (mirror)",
),
)
else:
@@ -1072,7 +1062,7 @@ def _download_declared_or_fail(args, model_dir, label, start, span, stage_kwargs
(
R2_ENDPOINT,
download_from_r2,
f"Downloading {label} | OmooLab mirror",
f"Downloading {label} (mirror)",
),
)
failures = []
@@ -1209,7 +1199,7 @@ def _download_file_from_url(
with opener(request) as response:
with open(temporary, "wb") as output:
while True:
chunk = response.read(1024 * 1024)
chunk = response.read(DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
output.write(chunk)
@@ -1310,7 +1300,7 @@ def download_from_r2(
lambda filename: r2_file_url(args.r2_directory, filename),
start,
span,
label,
f"{label} (mirror)" if label else "mirror",
stage_kwargs,
)
@@ -1935,6 +1925,25 @@ def run_moge(args, model_cache=None, cancel_check=None):
shutil.rmtree(temporary_directory, ignore_errors=True)
def merge_da3_frame_predictions(predictions):
"""Combine independent single-frame DA3 results for existing exporters."""
import numpy as np
if not predictions:
raise ValueError("DA3 returned no frame predictions")
merged = predictions[0]
for name in (
"depth",
"conf",
"extrinsics",
"intrinsics",
"processed_images",
):
values = [getattr(prediction, name) for prediction in predictions]
setattr(merged, name, np.concatenate(values, axis=0))
return merged
def reconstruct(args, model_cache=None, cancel_check=None):
write_status(args.status, 0.02, "Starting Environment")
check_cancelled(cancel_check)
@@ -2016,15 +2025,32 @@ def reconstruct(args, model_cache=None, cancel_check=None):
0.25,
f"Estimating depth for {len(images)} frame(s)",
)
inference_options = {
"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,
}
try:
prediction = infer(
model,
[str(path) for path in images],
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,
)
if getattr(args, "inference_mode", "joint") == "per_frame":
predictions = []
for index, image in enumerate(images):
check_cancelled(cancel_check)
write_status(
args.status,
0.25 + 0.60 * index / len(images),
f"Estimating DA3 depth ({index + 1}/{len(images)})",
)
predictions.append(
infer(model, [str(image)], **inference_options)
)
prediction = merge_da3_frame_predictions(predictions)
else:
prediction = infer(
model,
[str(path) for path in images],
**inference_options,
)
except UnicodeDecodeError as error:
# DirectML reports failures with a locale-specific message that
# ONNX Runtime cannot always decode, masking the real error.