Bump version to 0.10.11 and update installation instructions; enhance progress reporting and estimated time features

This commit is contained in:
2026-08-09 10:23:14 +08:00
parent 9c896213a5
commit 97a720051f
12 changed files with 382 additions and 150 deletions

View File

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

View File

@@ -39,16 +39,24 @@ TORCH_INDEXES = {
"cpu": "https://download.pytorch.org/whl/cpu",
}
_resolved_pypi_index = None
DEFAULT_MODEL_KEYS = ("SMALL", "BASE", BEN2_MODEL.key)
RUNTIME_SLICE_START = 0.07
RUNTIME_SLICE_END = 0.88
VERIFY_SLICE_START = 0.88
VERIFY_SLICE_END = 0.90
MODELS_SLICE_START = 0.90
DEFAULT_MODEL_KEYS = ("SMALL", BEN2_MODEL.key)
RUNTIME_STAGE_COUNT = 3
ENVIRONMENT_SLICE = (0.02, 0.06)
PYTORCH_SLICE = (0.06, 0.48)
XFORMERS_SLICE = (0.48, 0.62)
AI_COMPONENTS_SLICE = (0.62, 0.85)
MODELS_SLICE_START = 0.85
MODELS_SLICE_END = 1.0
PLAIN_SLICE_SPAN = 0.02
RAMP_RATE = 0.02
CREEP_CAP = 0.60
INSTALL_ESTIMATES = {
"PyTorch": 8 * 60,
"AI components": 8 * 60,
}
INSTALL_ESTIMATE_RANGES = {
"PyTorch": PYTORCH_SLICE,
"AI components": (XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1]),
}
CHECK_CODE = (
"import json; import torch; "
"from depth_anything_3.api import DepthAnything3; "
@@ -70,6 +78,7 @@ def write_status(
stage=None,
stages=None,
stage_label=None,
eta_seconds=None,
):
payload = {"progress": progress, "message": message}
if error:
@@ -78,6 +87,8 @@ def write_status(
payload["stage"] = stage
payload["stages"] = stages
payload["stage_label"] = stage_label
if eta_seconds is not None:
payload["eta_seconds"] = max(int(eta_seconds), 0)
Path(path).write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
@@ -120,6 +131,21 @@ def parse_package_count(line):
return 0
def estimated_remaining(label, progress, elapsed=0.0):
expected_seconds = INSTALL_ESTIMATES.get(label)
progress_range = INSTALL_ESTIMATE_RANGES.get(label)
if expected_seconds is None or progress_range is None:
return None
start, end = progress_range
if end <= start:
return 0.0
remaining_fraction = 1.0 - min(
max((progress - start) / (end - start), 0.0),
1.0,
)
return max(expected_seconds * remaining_fraction - elapsed, 0.0)
def effective_torch_index(backend):
return os.environ.get("SNAPFORM_TORCH_INDEX") or TORCH_INDEXES.get(backend)
@@ -166,16 +192,6 @@ def _run_install_once(
threading.Thread(target=read_output, daemon=True).start()
def write(progress, message):
write_status(
status_path,
progress,
message,
stage=stage,
stages=stages,
stage_label=label,
)
def eased(fraction):
return start + min(max(fraction, 0.0), 1.0) * (end - start)
@@ -186,6 +202,21 @@ def _run_install_once(
started = clock()
last_tick = started
last_heartbeat = 0.0
def detail(message):
write_status(
status_path,
displayed,
message,
stage=stage,
stages=stages,
stage_label=label,
eta_seconds=estimated_remaining(
label,
displayed,
clock() - started,
),
)
while True:
try:
raw_line = output_queue.get(timeout=0.2)
@@ -206,7 +237,7 @@ def _run_install_once(
if empty:
if elapsed - last_heartbeat >= 5.0:
last_heartbeat = elapsed
write(displayed, f"downloading {format_elapsed(elapsed)}")
detail("Downloading packages")
continue
line = raw_line.rstrip()
print(line)
@@ -215,17 +246,17 @@ def _run_install_once(
resolved_progress = eased(0.02)
displayed = max(displayed, resolved_progress)
target = max(target, resolved_progress)
write(displayed, f"{total} packages")
detail(f"Resolving {total} packages")
elif line.startswith("Prepared ") and "package" in line:
done = parse_package_count(line)
if total:
target = max(target, eased(0.10 + 0.60 * done / total))
write(displayed, f"{done}/{total} packages")
detail(f"Downloading packages {done}/{total}")
elif line.startswith("Installed ") and "package" in line:
done = parse_package_count(line)
if total:
target = max(target, eased(0.75 + 0.25 * done / total))
write(displayed, f"{done}/{total} packages")
detail(f"Installing packages {done}/{total}")
elif line.startswith("+"):
entry = line[1:].strip()
installed.append(entry)
@@ -315,19 +346,19 @@ def install_stages(args, runtime_dir, python, uv):
torch_command.extend(["--index-url", torch_index])
stages = [
(
"uv",
"Environment",
[uv, "venv", "--clear", "--python", "3.12", runtime_dir],
None,
"plain",
),
(
"pytorch deps",
"PyTorch",
torch_command,
None,
"install",
),
(
"ai deps",
"AI components",
[
uv,
"pip",
@@ -363,7 +394,7 @@ def install_stages(args, runtime_dir, python, uv):
xformers_command.append("--no-deps")
stages.insert(
2,
("xformers", xformers_command, None, "install"),
("AI components", xformers_command, None, "install"),
)
models = {**MODELS, BEN2_MODEL.key: BEN2_MODEL}
worker_script = Path(__file__).with_name("worker.py")
@@ -380,12 +411,14 @@ def install_stages(args, runtime_dir, python, uv):
models_dir / model.directory_name,
"--status",
args.status,
"--label",
model.label,
]
for pattern in model.download_patterns:
download_command.extend(["--allow-pattern", pattern])
stages.append(
(
model.label.lower(),
model.label,
download_command,
None,
"download",
@@ -405,6 +438,17 @@ def runtime_ready(runtime_dir):
return payload.get("runtime_schema") == RUNTIME_SCHEMA
def installed_devices(args):
devices = []
machine = platform.machine().lower()
if sys.platform == "darwin" and machine in {"arm64", "aarch64"}:
devices.append("mps")
elif args.backend.startswith("cu"):
devices.append("cuda")
devices.append("cpu")
return devices
def verify_command(python):
return [python, "-c", CHECK_CODE]
@@ -412,9 +456,12 @@ def verify_command(python):
def run_verify(
python,
status_path,
start=VERIFY_SLICE_START,
end=VERIFY_SLICE_END,
start=0.0,
end=1.0,
clock=time.monotonic,
stage=None,
stages=None,
stage_label=None,
):
"""Run the runtime import check with heartbeat progress; return the device report."""
command = verify_command(python)
@@ -457,7 +504,11 @@ def run_verify(
write_status(
status_path,
progress,
f"verifying runtime {format_elapsed(elapsed)}",
"Checking Runtime",
stage=stage,
stages=stages,
stage_label=stage_label,
eta_seconds=max(60.0 - elapsed, 0.0),
)
continue
line = raw_line.rstrip()
@@ -585,13 +636,20 @@ def run_stage(
start=0.0,
end=1.0,
):
if stage is None:
opening_message = f"Downloading {label}"
elif kind == "install":
opening_message = "Preparing packages"
else:
opening_message = ""
write_status(
status_path,
start,
"",
opening_message,
stage=stage,
stages=stages,
stage_label=label,
eta_seconds=estimated_remaining(label, start),
)
installed = []
if kind == "install":
@@ -606,28 +664,31 @@ def run_stage(
end=end,
)
elif kind == "download":
run(
[
*command,
"--progress-start",
str(start),
"--progress-end",
str(end),
"--stage",
str(stage),
"--stages",
str(stages),
"--stage-label",
label,
],
environment,
)
progress_command = [
*command,
"--progress-start",
str(start),
"--progress-end",
str(end),
]
if stage is not None:
progress_command.extend(
[
"--stage",
str(stage),
"--stages",
str(stages),
"--stage-label",
label,
]
)
run(progress_command, environment)
else:
run(command, environment)
write_status(
status_path,
end,
"",
f"{label} ready" if stage is None else "",
stage=stage,
stages=stages,
stage_label=label,
@@ -662,44 +723,45 @@ def install(args):
stages = install_stages(args, runtime_dir, python, uv)
runtime_stages = [stage for stage in stages if stage[3] != "download"]
model_stages = [stage for stage in stages if stage[3] == "download"]
plain_count = sum(1 for stage in runtime_stages if stage[3] != "install")
runtime_slice = RUNTIME_SLICE_END - RUNTIME_SLICE_START
install_count = len(runtime_stages) - plain_count
install_span = (
(runtime_slice - PLAIN_SLICE_SPAN * plain_count) / install_count
if install_count
else 0.0
)
model_span = (
(MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages)
if model_stages
else 0.0
)
installed_packages = []
runtime_offset = RUNTIME_SLICE_START
for index, (label, command, environment, kind) in enumerate(
runtime_stages,
start=1,
):
stage_start = runtime_offset
stage_end = stage_start + (
install_span if kind == "install" else PLAIN_SLICE_SPAN
)
if uv_missing and index == 1:
component_stages = runtime_stages[2:]
component_ranges = (
[XFORMERS_SLICE, AI_COMPONENTS_SLICE]
if len(component_stages) == 2
else [(XFORMERS_SLICE[0], AI_COMPONENTS_SLICE[1])]
)
for index, (label, command, environment, kind) in enumerate(runtime_stages):
if index == 0:
stage = 1
stage_start, stage_end = ENVIRONMENT_SLICE
elif index == 1:
stage = 2
stage_start, stage_end = PYTORCH_SLICE
else:
stage = 3
component_index = index - 2
stage_start, stage_end = component_ranges[component_index]
if uv_missing and index == 0:
midpoint = stage_start + (stage_end - stage_start) * 0.5
install_uv(
uv,
args.status,
stage=index,
stages=len(stages),
stage=stage,
stages=RUNTIME_STAGE_COUNT,
stage_label=label,
start=stage_start,
end=stage_start + PLAIN_SLICE_SPAN * 0.5,
end=midpoint,
)
stage_start += PLAIN_SLICE_SPAN * 0.5
stage_start = midpoint
stage_installed = run_stage(
args.status,
index,
len(stages),
stage,
RUNTIME_STAGE_COUNT,
label,
command,
environment,
@@ -709,17 +771,7 @@ def install(args):
)
if isinstance(stage_installed, list):
installed_packages.extend(stage_installed)
runtime_offset = stage_end
write_status(args.status, VERIFY_SLICE_START, "verifying runtime")
device_report = run_verify(python, args.status)
write_status(args.status, VERIFY_SLICE_END, "Runtime is ready")
devices = []
if device_report.get("cuda"):
devices.append("cuda")
if device_report.get("mps"):
devices.append("mps")
devices.append("cpu")
devices = installed_devices(args)
runtime_dir.mkdir(parents=True, exist_ok=True)
ready_path = runtime_dir / "snapform_ready.json"
ready_path.write_text(
@@ -741,19 +793,16 @@ def install(args):
),
encoding="utf-8",
)
offset = len(runtime_stages)
model_offset = MODELS_SLICE_START
for index, (label, command, environment, kind) in enumerate(
model_stages,
start=offset + 1,
):
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,
index,
len(stages),
None,
None,
label,
command,
environment,
@@ -763,14 +812,22 @@ def install(args):
)
except Exception as error:
print(f"Model download failed: {label}: {error}")
failed_models.append(label)
write_status(
args.status,
MODELS_SLICE_END,
f"Runtime is ready; {label} failed",
stage_end,
f"Skipping {label}; continuing",
)
return
model_offset = stage_end
write_status(args.status, 1.0, "Runtime and models ready")
if failed_models:
labels = ", ".join(failed_models)
write_status(
args.status,
1.0,
f"Runtime is ready; retry {labels} from Models",
)
return
write_status(args.status, 1.0, "Runtime and recommended models ready")
def parse_args():

View File

@@ -475,23 +475,53 @@ class RunJob(bpy.types.Operator):
if status is None:
return
settings = context.scene.snapform_settings
settings.progress = float(status.get("progress", settings.progress))
reported_progress = float(
status.get("progress", settings.progress)
)
settings.progress = max(
settings.progress,
min(max(reported_progress, 0.0), 1.0),
)
settings.status = self._format_status(status, settings.status)
self._redraw_ui(context, force=True)
def _format_status(self, status, fallback):
message = status.get("message", fallback)
message = status.get("message")
stage = status.get("stage")
stages = status.get("stages")
stage_label = status.get("stage_label")
if not stage or not stages:
return message
text = (
f"{stage}/{stages} {stage_label}"
if stage_label
else f"{stage}/{stages}"
)
return f"{text}: {message}" if message else text
parts = []
if stage and stages:
stage_text = f"{stage}/{stages}"
if stage_label:
stage_text = f"{stage_text} {stage_label}"
parts.append(stage_text)
elif stage_label:
parts.append(str(stage_label))
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"
def _finish(self, context, job, return_code):
settings = context.scene.snapform_settings

View File

@@ -14,7 +14,7 @@ def draw_status_bar(self, context):
progress = min(max(settings.progress, 0.0), 1.0)
row = self.layout.row(align=True)
progress_row = row.row(align=True)
progress_row.ui_units_x = 12.0
progress_row.ui_units_x = 18.0
progress_row.progress(
factor=progress,
type="BAR",

View File

@@ -49,6 +49,7 @@ def write_status(
stage=None,
stages=None,
stage_label=None,
eta_seconds=None,
):
payload = {"progress": progress, "message": message}
if error:
@@ -57,6 +58,8 @@ def write_status(
payload["stage"] = stage
payload["stages"] = stages
payload["stage_label"] = stage_label
if eta_seconds is not None:
payload["eta_seconds"] = max(int(eta_seconds), 0)
if state is None:
if error:
state = "failed"
@@ -229,6 +232,7 @@ class DownloadProgressReporter:
self.label = label
self.progress = start
self.last_write_time = 0.0
self.started_at = self.clock()
def report(self, current, total, rate=None):
if not total or current <= 0:
@@ -244,14 +248,25 @@ class DownloadProgressReporter:
return
self.progress = progress
self.last_write_time = now
speed = format_rate(rate) if rate else ""
effective_rate = rate
elapsed = now - self.started_at
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 {speed}" if speed else "downloading"
message = (
f"Downloading packages | {speed}"
if speed
else "Downloading packages"
)
else:
prefix = self.label or self.repository
message = f"downloading {prefix}"
message = f"Downloading {prefix}"
if speed:
message = f"{message} {speed}"
message = f"{message} | {speed}"
write_status(
self.status_path,
progress,
@@ -259,6 +274,7 @@ class DownloadProgressReporter:
stage=self.stage,
stages=self.stages,
stage_label=self.stage_label,
eta_seconds=eta_seconds,
)
@@ -356,7 +372,7 @@ def download_model(args):
failures.append((endpoint, "endpoint unreachable"))
continue
os.environ["HF_ENDPOINT"] = endpoint
message = f"downloading {label}"
message = f"Downloading {label}"
if endpoint != HF_ENDPOINT:
message = f"{message} (mirror)"
write_status(args.status, start, message, **stage_kwargs)
@@ -375,7 +391,7 @@ def download_model(args):
write_status(
args.status,
start + span,
"model ready",
f"{label} ready",
**stage_kwargs,
)
return
@@ -384,7 +400,7 @@ def download_model(args):
write_status(
args.status,
start,
f"downloading {label} (mirror: modelscope.cn)",
f"Downloading {label} | ModelScope mirror",
**stage_kwargs,
)
download_from_modelscope(
@@ -402,7 +418,7 @@ def download_model(args):
write_status(
args.status,
start + span,
"model ready",
f"{label} ready",
**stage_kwargs,
)
return