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

@@ -24,9 +24,6 @@ jobs:
- name: 安装测试依赖 - name: 安装测试依赖
run: python -m pip install numpy pytest run: python -m pip install numpy pytest
- name: 准备 Vendor
run: python prepare_vendor.py
- name: 运行测试 - name: 运行测试
run: python -m pytest run: python -m pytest

1
.gitignore vendored
View File

@@ -7,5 +7,4 @@ build/
*.zip *.zip
.pytest_cache/ .pytest_cache/
/vendor/
/models/ /models/

View File

@@ -19,9 +19,10 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
最快的 DirectML/CoreML provider,否则 CPU。下载超时会自动重试国内镜像,无需手动配置 最快的 DirectML/CoreML provider,否则 CPU。下载超时会自动重试国内镜像,无需手动配置
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Environment` 5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Environment`
扩展只内置对应平台的 uv。首次使用会自动下载轻量的 ONNX Runtime 环境,随后自动下载 `MoGe-2 ViT-S Normal` 和 扩展不内置 uv 或 AI 运行库。首次使用会先下载并校验固定版本的 uv,随后自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和
`BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在 `BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。 `Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
安装时 uv、ONNX Environment、MoGe-2 和 BEN2 各自使用独立的 0–100% 进度;下载阶段显示速度与连续字节进度。
## 快速上手 ## 快速上手
@@ -69,7 +70,8 @@ Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间
### 视频与序列帧 ### 视频与序列帧
SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。处理范围默认 SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。处理范围默认
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。 跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
Session 逐帧推理,显存峰值不随视频帧数平方增长;Debug 多帧输出仍使用多视图联合推理。
## 任务结果保存在哪里 ## 任务结果保存在哪里

View File

@@ -22,6 +22,8 @@
因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。 因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计 Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致; 深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
DA3 会复用同一个 ONNX Session 对每帧独立推理,再合并为原有序列输出,避免多帧 Attention 的平方级显存占用。
该行为只用于 Depth Plane 与 Surface 转换;Debug 多帧输出仍联合推理。
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度, 一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度,
`foreground.0001.png` 只提供前景 Alpha。 `foreground.0001.png` 只提供前景 Alpha。

View File

@@ -3,7 +3,6 @@
## 开发 ## 开发
```bash ```bash
uv run python prepare_vendor.py
uv run pytest tests -v uv run pytest tests -v
uv run python pack.py uv run python pack.py
``` ```
@@ -12,10 +11,7 @@ uv run python pack.py
```text ```text
. .
├── vendor.lock.toml 各平台 uv 的版本、URL 与 SHA-256
├── prepare_vendor.py 下载并校验各平台 uv
├── pack.py ├── pack.py
├── vendor/ 不进入 Git 的 uv 下载缓存
├── src/ ├── src/
│ └── snapform/ │ └── snapform/
│ ├── blender_manifest.toml │ ├── blender_manifest.toml

View File

@@ -37,7 +37,8 @@
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片 - Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片 - Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片
- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度, - Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度,
`Remove Background` 逐帧抠图,入口保持不变 `Remove Background` 逐帧抠图,入口保持不变。DA3 转换时每帧独立执行 ONNX 推理并复用模型 Session;
Debug 多帧任务继续联合推理,以保留跨视图相机与深度关系
- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧); - 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧);
Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧 Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧
- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片 - 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片
@@ -49,10 +50,9 @@
## 使用 ## 使用
1. 先运行 `uv run python prepare_vendor.py`,按 `vendor.lock.toml` 下载并校验固定版本的 1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、
第三方构建输入;再运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、 `linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.11.2.macos-arm64.zip`)。
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.11.1.macos-arm64.zip`)。 安装包不携带 uv 或 AI 项目源码;首次安装 Environment 时下载固定版本 uv,官方源建连超过 3 秒后回退 OmooLab 镜像,并校验固定 SHA-256。
每个包只含对应平台的 uv 二进制,不再携带 AI 项目的源码包。
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。 2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能 3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 DirectML/CoreML provider,否则 CPU,且只列出 (Image Empty 右键菜单)使用的推理设备,默认取本机最快的 DirectML/CoreML provider,否则 CPU,且只列出
@@ -60,12 +60,15 @@
4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn; 4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
uv 已内置在扩展中,DA3、BEN2 与 MoGe 不安装源码包, uv 已内置在扩展中,DA3、BEN2 与 MoGe 不安装源码包,
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。Environment 安装 并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。Environment 安装
固定显示 `Environment`、`ONNX Runtime` 两个用户阶段;FastAPI 等内部安装步骤合并在 `ONNX Runtime` 中。 首次安装依次显示 `uv`、`ONNX Environment`、`MoGe-2 ViT-S Normal` 与 `BEN2 Base`;每个阶段
安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装 都独立从 0 走到 100%,进入下一阶段时进度条归零。uv 已存在时省略其阶段并重新编号。
后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。 安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`。依赖安装只显示阶段与已解析的 状态文字使用 `阶段/总数 当前操作`,例如
包总数,不再承诺剩余时间;模型下载根据字节数和速度计算剩余时间,例如 `3/4 Downloading MoGe-2 ViT-S Normal 8.4 MB/s`。依赖安装只显示阶段与已解析的
`Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。 包总数;模型下载显示速度,例如
`Downloading DA3 Small 14.2 MB/s`;镜像来源显示为
`Downloading DA3 Small (mirror) 14.2 MB/s`。下载按字节连续推进,重试或切换镜像时进度不会倒退。
Environment 安装阶段与 `Models` 面板单独下载使用相同的模型名称格式,不会改写为通用的 `Downloading packages`。
Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。 Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。
安装不做启动网络预检:依赖安装默认依次尝试清华、阿里镜像并以 pypi.org 兜底;模型下载由 安装不做启动网络预检:依赖安装默认依次尝试清华、阿里镜像并以 pypi.org 兜底;模型下载由
worker 自行探测回退到 OmooLab 镜像,切换结果写入 service.json 的 models_from_r2 字段, worker 自行探测回退到 OmooLab 镜像,切换结果写入 service.json 的 models_from_r2 字段,

View File

@@ -21,7 +21,7 @@ Environment、模型和任务结果默认保存在 `~/.snapform`:
## ONNX-only Environment ## ONNX-only Environment
扩展只内置对应平台的 `uv` 可执行文件。`Install Environment` 使用它创建环境并安装: 扩展不内置 `uv` 可执行文件或 AI 运行库。`Install Environment` 首先下载固定版本的 uv,校验 SHA-256 后解压到共享 Storage Root,再用它创建环境并安装:
- ONNX Runtime 1.24.4;Windows 使用 `onnxruntime-directml` - ONNX Runtime 1.24.4;Windows 使用 `onnxruntime-directml`
- NumPy 2.4.2、Pillow 12.1.1、PyAV 17.0.0、trimesh 4.11.2 - NumPy 2.4.2、Pillow 12.1.1、PyAV 17.0.0、trimesh 4.11.2
@@ -41,7 +41,7 @@ Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExec
- DA3 Small、DA3 Base - DA3 Small、DA3 Base
- BEN2 Base - BEN2 Base
首次安装自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`,其他模型在 `Models` panel 按需下载。模型按 Hugging Face 仓库末段名称保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型会直接走 Hugging Face `resolve` 直链流式下载,跳过文件清单与 ETag 预校验;连接源不可达时 6 秒内快速失败并回退到 `https://models.omoolab.xyz/<repository-name>/<relative-path>`,并把 `models_from_r2` 写入 service.json,本次 service 周期内后续模型直接走 R2,不再重复等待 Hugging Face。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。 首次安装自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`,其他模型在 `Models` panel 按需下载。模型按 Hugging Face 仓库末段名称保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型会直接走 Hugging Face `resolve` 直链流式下载,跳过文件清单与 ETag 预校验;连接源不可达时使用 3 秒 TCP 与 TLS 建连超时,随后回退到 `https://models.omoolab.xyz/<repository-name>/<relative-path>`,并把 `models_from_r2` 写入 service.json,本次 service 周期内后续模型直接走 R2,不再重复等待 Hugging Face。建连成功后不限制模型文件读取时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray Pose` 参数只为任务协议兼容保留,不参与 ONNX 推理。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。 DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray Pose` 参数只为任务协议兼容保留,不参与 ONNX 推理。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
@@ -50,7 +50,9 @@ DA3 ONNX 使用模型的 camera decoder 恢复外参与内参;旧版 `Use Ray
安装器直接开始工作,不做阻塞式网络预检。依赖安装默认依次尝试清华、阿里云 PyPI 镜像并以 pypi.org 兜底,也可用 `UV_INDEX_URL` 显式指定。Windows 系统代理会注入子进程;同时支持 `HTTP_PROXY`、`HTTPS_PROXY` 与 `ALL_PROXY`。 安装器直接开始工作,不做阻塞式网络预检。依赖安装默认依次尝试清华、阿里云 PyPI 镜像并以 pypi.org 兜底,也可用 `UV_INDEX_URL` 显式指定。Windows 系统代理会注入子进程;同时支持 `HTTP_PROXY`、`HTTPS_PROXY` 与 `ALL_PROXY`。
GitHub 不参与用户安装流程。`prepare_vendor.py` 只为扩展打包准备各平台的 `uv` 二进制。 uv 默认从 Astral 的 GitHub Release 下载,TCP 与 TLS 建连超过 3 秒时回退到 `https://mirror.omoolab.xyz/uv/<version>/`;镜像建连超时为 15 秒,避免较慢的 TLS 握手再次过早失败。两个来源使用相同的下载速度与连续字节进度显示,并共用固定 SHA-256 校验,建连成功后不限制文件读取时长。下载压缩包只作临时文件,提取完成后删除。模型下载不经过 GitHub。
首次安装按阶段分别显示 0–100%:uv 下载与解压、ONNX Environment 创建与依赖安装、MoGe-2 下载、BEN2 下载。进入下一阶段时进度条归零;若 uv 已存在则省略该阶段并重新编号。
## 生命周期 ## 生命周期
@@ -64,9 +66,11 @@ Service 全局同时执行一个 AI 任务。BEN2 独立缓存,深度模型只
DA3 ONNX 的注意力是朴素实现(`MatMul → Softmax → MatMul`),峰值显存与 `(帧数 × 每帧 patch 数)²` 成正比。迁移到 ONNX 前的 PyTorch 版使用 `scaled_dot_product_attention`(`XFORMERS_DISABLED=1`),按块计算注意力分数、不落地完整注意力矩阵,显存近似线性,因此视频能处理几十上百帧;ONNX 导出把 SDPA 回退成朴素注意力,多帧在中高分辨率下会显存爆炸(约 12 帧 @ 1008 即需 ~90 GB)。 DA3 ONNX 的注意力是朴素实现(`MatMul → Softmax → MatMul`),峰值显存与 `(帧数 × 每帧 patch 数)²` 成正比。迁移到 ONNX 前的 PyTorch 版使用 `scaled_dot_product_attention`(`XFORMERS_DISABLED=1`),按块计算注意力分数、不落地完整注意力矩阵,显存近似线性,因此视频能处理几十上百帧;ONNX 导出把 SDPA 回退成朴素注意力,多帧在中高分辨率下会显存爆炸(约 12 帧 @ 1008 即需 ~90 GB)。
作为兜底,`da3_onnx.infer` 在推理前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。 Depth Plane 与 Surface 的视频、序列帧转换会复用同一个 ONNX Session 逐帧独立推理,再把结果合并到现有序列输出,因此峰值显存只取决于单帧分辨率。逐帧结果不保留 DA3 的跨视图尺度与相机位姿一致性,适用于当前逐帧动画结构。Debug 多帧输出仍使用联合推理,以保留多视图关系。
联合推理时,`da3_onnx.infer` 会在执行前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。
待解决方向: 待解决方向:
- 重新导出 DA3 ONNX,使用 ORT 的 `Attention` / `MultiHeadAttention` 贡献算子,或注入内存高效的注意力算子; - 重新导出 DA3 ONNX,使用 ORT 的 `Attention` / `MultiHeadAttention` 贡献算子,或注入内存高效的注意力算子;
- 或把视频按小窗口分批推理,再按 `ref_view_strategy` 对齐相机位姿拼接。 - 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。

68
pack.py
View File

@@ -1,4 +1,3 @@
import hashlib
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile from zipfile import ZIP_DEFLATED, ZipFile
@@ -6,49 +5,8 @@ from zipfile import ZIP_DEFLATED, ZipFile
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
SOURCE_DIR = PROJECT_ROOT / "src" / "snapform" SOURCE_DIR = PROJECT_ROOT / "src" / "snapform"
VENDOR_DIR = PROJECT_ROOT / "vendor"
DIST_DIR = PROJECT_ROOT / "dist" DIST_DIR = PROJECT_ROOT / "dist"
LOCK_PATH = PROJECT_ROOT / "vendor.lock.toml" PLATFORMS = ("windows-x64", "macos-arm64", "linux-x64")
def load_vendor_lock():
"""Return the locked third-party artifact metadata."""
with LOCK_PATH.open("rb") as stream:
lock = tomllib.load(stream)
if lock.get("schema_version") != 1:
raise RuntimeError("Unsupported vendor.lock.toml schema")
return lock
VENDOR_LOCK = load_vendor_lock()
PLATFORMS = {
platform: artifact["filename"]
for platform, artifact in VENDOR_LOCK["platforms"].items()
}
def file_sha256(path):
"""Return the SHA-256 digest for a file."""
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def validate_artifact(path, expected_sha256):
"""Require an artifact to exist and match its locked digest."""
if not path.is_file():
raise RuntimeError(
f"Missing vendor artifact: {path}. "
"Run: uv run python prepare_vendor.py"
)
actual = file_sha256(path)
if actual != expected_sha256:
raise RuntimeError(
f"Vendor artifact hash mismatch: {path}. "
"Run: uv run python prepare_vendor.py"
)
def manifest_for_platform(platform): def manifest_for_platform(platform):
@@ -73,21 +31,6 @@ def manifest_for_platform(platform):
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def bundled_vendor_files(platform):
return {
f"vendor/{PLATFORMS[platform]}": VENDOR_DIR / PLATFORMS[platform]
}
def validate_vendor_files(platform):
"""Validate common archives and the selected uv archive."""
uv_artifact = VENDOR_LOCK["platforms"][platform]
validate_artifact(
VENDOR_DIR / uv_artifact["filename"],
uv_artifact["sha256"],
)
def build_extension(platform): def build_extension(platform):
if platform not in PLATFORMS: if platform not in PLATFORMS:
raise ValueError(f"Unsupported platform: {platform}") raise ValueError(f"Unsupported platform: {platform}")
@@ -96,8 +39,6 @@ def build_extension(platform):
f"{manifest['name']}.v{manifest['version']}.{platform}.zip" f"{manifest['name']}.v{manifest['version']}.{platform}.zip"
) )
DIST_DIR.mkdir(exist_ok=True) DIST_DIR.mkdir(exist_ok=True)
vendor_files = bundled_vendor_files(platform)
validate_vendor_files(platform)
with ZipFile(output_path, "w", ZIP_DEFLATED) as archive: with ZipFile(output_path, "w", ZIP_DEFLATED) as archive:
for source_path in sorted(SOURCE_DIR.rglob("*")): for source_path in sorted(SOURCE_DIR.rglob("*")):
if not source_path.is_file(): if not source_path.is_file():
@@ -105,8 +46,6 @@ def build_extension(platform):
if "__pycache__" in source_path.parts: if "__pycache__" in source_path.parts:
continue continue
relative_path = source_path.relative_to(SOURCE_DIR) relative_path = source_path.relative_to(SOURCE_DIR)
if relative_path.parts[0] == "vendor":
continue
if source_path.name == "blender_manifest.toml": if source_path.name == "blender_manifest.toml":
continue continue
archive.write(source_path, relative_path) archive.write(source_path, relative_path)
@@ -114,11 +53,6 @@ def build_extension(platform):
"blender_manifest.toml", "blender_manifest.toml",
manifest_for_platform(platform), manifest_for_platform(platform),
) )
for archive_name, source_path in vendor_files.items():
archive.writestr(
archive_name,
source_path.read_bytes(),
)
return output_path return output_path

View File

@@ -1,110 +0,0 @@
import argparse
import hashlib
import sys
import tomllib
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent
LOCK_PATH = PROJECT_ROOT / "vendor.lock.toml"
VENDOR_DIR = PROJECT_ROOT / "vendor"
CHUNK_SIZE = 1024 * 1024
def load_lock(path=LOCK_PATH):
"""Load and validate the vendor lock file."""
with Path(path).open("rb") as stream:
lock = tomllib.load(stream)
if lock.get("schema_version") != 1:
raise RuntimeError("Unsupported vendor.lock.toml schema")
return lock
def file_sha256(path):
"""Return the SHA-256 digest for a file."""
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
while chunk := stream.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def download_artifact(artifact, destination_dir=VENDOR_DIR):
"""Download one locked artifact and verify its content."""
destination_dir = Path(destination_dir)
destination = destination_dir / artifact["filename"]
expected = artifact["sha256"]
if destination.is_file() and file_sha256(destination) == expected:
print(f"Ready: {destination.name}")
return destination
destination_dir.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f"{destination.name}.part")
temporary.unlink(missing_ok=True)
print(f"Downloading: {destination.name}")
request = urllib.request.Request(
artifact["url"],
headers={"User-Agent": "SnapForm vendor preparation"},
)
try:
with urllib.request.urlopen(request) as response, temporary.open("wb") as output:
while chunk := response.read(CHUNK_SIZE):
output.write(chunk)
actual = file_sha256(temporary)
if actual != expected:
raise RuntimeError(
f"SHA-256 mismatch for {destination.name}: "
f"expected {expected}, got {actual}"
)
temporary.replace(destination)
except Exception:
temporary.unlink(missing_ok=True)
raise
return destination
def prepare(platforms=None, lock_path=LOCK_PATH, destination_dir=VENDOR_DIR):
"""Prepare bundled uv executables for selected platforms."""
lock = load_lock(lock_path)
available = lock["platforms"]
selected = list(available) if platforms is None else platforms
unknown = sorted(set(selected) - set(available))
if unknown:
raise ValueError(f"Unsupported platform: {', '.join(unknown)}")
artifacts = [available[platform] for platform in selected]
return [
download_artifact(artifact, destination_dir)
for artifact in artifacts
]
def parse_args(argv=None):
"""Parse command-line options."""
parser = argparse.ArgumentParser(
description="Download and verify third-party files for SnapForm.",
)
parser.add_argument(
"--platform",
action="append",
dest="platforms",
help="Prepare one platform; repeat as needed. Default: all platforms.",
)
return parser.parse_args(argv)
def main(argv=None):
"""Run vendor preparation from the command line."""
args = parse_args(argv)
try:
paths = prepare(args.platforms)
except (OSError, RuntimeError, ValueError) as error:
print(f"Preparation failed: {error}", file=sys.stderr)
return 1
print(f"Prepared {len(paths)} files")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "snapform" name = "snapform"
version = "0.11.1" version = "0.11.2"
description = "Fast image-guided structure building tools for Blender" description = "Fast image-guided structure building tools for Blender"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

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

View File

@@ -269,7 +269,7 @@ def job_id_from_status(status_path):
def uv_executable(): 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()) return str(uv_cache_path())
@@ -833,6 +833,7 @@ def reconstruct_request(
mesh_filter_edges=None, mesh_filter_edges=None,
start_frame=1, start_frame=1,
device=None, device=None,
inference_mode="joint",
): ):
snapform_selected_key = model_key or selected_model_key(settings, export_format) snapform_selected_key = model_key or selected_model_key(settings, export_format)
snapform_selected_process_res = ( snapform_selected_process_res = (
@@ -867,6 +868,7 @@ def reconstruct_request(
"device": (device or settings.device).lower(), "device": (device or settings.device).lower(),
"use_ray_pose": 1 if snapform_selected_ray_pose else 0, "use_ray_pose": 1 if snapform_selected_ray_pose else 0,
"ref_view_strategy": settings.ref_view_strategy, "ref_view_strategy": settings.ref_view_strategy,
"inference_mode": inference_mode,
"remove_background_model_dir": "", "remove_background_model_dir": "",
"mesh_filter_edges": bool( "mesh_filter_edges": bool(
getattr(settings, "mesh_filter_edges", True) getattr(settings, "mesh_filter_edges", True)

View File

@@ -1,4 +1,6 @@
import argparse import argparse
import hashlib
import http.client
import json import json
import os import os
import platform import platform
@@ -36,13 +38,30 @@ PILLOW_VERSION = "12.1.1"
AV_VERSION = "17.0.0" AV_VERSION = "17.0.0"
TRIMESH_VERSION = "4.11.2" TRIMESH_VERSION = "4.11.2"
HUGGINGFACE_HUB_VERSION = "1.4.1" 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 _resolved_pypi_index = None
DEFAULT_MODEL_KEYS = (MOGE2_MODEL.key, BEN2_MODEL.key) 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 RAMP_RATE = 0.02
CREEP_CAP = 0.60 CREEP_CAP = 0.60
CHECK_CODE = ( 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(): def configure_standard_streams():
"""Windows 图形进程可能继承 GBK stdout,安装日志统一写为 UTF-8。""" """Windows 图形进程可能继承 GBK stdout,安装日志统一写为 UTF-8。"""
for stream in (sys.stdout, sys.stderr): 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}") 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): def archive_is_valid(path):
if zipfile.is_zipfile(path): if zipfile.is_zipfile(path):
return True return True
@@ -526,6 +571,99 @@ def extract_uv(archive_path, uv_path):
os.chmod(uv_path, 0o755) 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( def install_uv(
uv, uv,
status_path, status_path,
@@ -535,29 +673,31 @@ def install_uv(
start=0.0, start=0.0,
end=1.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_fields = {
"stage": stage, "stage": stage,
"stages": stages, "stages": stages,
"stage_label": stage_label, "stage_label": stage_label,
} }
bundled_uv = bundled_uv_archive() Path(uv).parent.mkdir(parents=True, exist_ok=True)
if bundled_uv is None: archive_path = Path(uv).with_name("uv-download.tmp")
raise RuntimeError( try:
"Bundled uv is missing; reinstall the extension matching your " download_uv_archive(
f"platform (searched {bundled_vendor_directory()})" archive_path,
)
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, status_path,
start, start,
"extracting bundled uv", end,
stage_fields,
)
write_status(
status_path,
end,
"Extracting uv",
**stage_fields, **stage_fields,
) )
Path(uv).parent.mkdir(parents=True, exist_ok=True) extract_uv(archive_path, uv)
extract_uv(bundled_uv, uv) finally:
archive_path.unlink(missing_ok=True)
def run_stage( def run_stage(
@@ -649,38 +789,30 @@ def install(args):
stages = install_stages(args, venv_dir, python, uv) stages = install_stages(args, venv_dir, python, uv)
env_stages = [stage for stage in stages if stage[3] != "download"] env_stages = [stage for stage in stages if stage[3] != "download"]
model_stages = [stage for stage in stages if stage[3] == "download"] model_stages = [stage for stage in stages if stage[3] == "download"]
model_span = ( total_stages = int(uv_missing) + 1 + len(model_stages)
(MODELS_SLICE_END - MODELS_SLICE_START) / len(model_stages)
if model_stages
else 0.0
)
installed_packages = []
for index, (label, command, environment, kind) in enumerate(env_stages):
if index == 0:
stage = 1 stage = 1
stage_start, stage_end = ENVIRONMENT_SLICE installed_packages = []
elif index == 1: if uv_missing:
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( install_uv(
uv, uv,
args.status, args.status,
stage=stage, stage=stage,
stages=RUNTIME_STAGE_COUNT, stages=total_stages,
stage_label=label, stage_label="uv",
start=stage_start, start=0.0,
end=midpoint, end=1.0,
) )
stage_start = midpoint 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( stage_installed = run_stage(
args.status, args.status,
stage, stage,
RUNTIME_STAGE_COUNT, total_stages,
label, environment_label,
command, command,
environment, environment,
kind, kind,
@@ -689,6 +821,7 @@ def install(args):
) )
if isinstance(stage_installed, list): if isinstance(stage_installed, list):
installed_packages.extend(stage_installed) installed_packages.extend(stage_installed)
stage += 1
devices = installed_devices() devices = installed_devices()
venv_dir.mkdir(parents=True, exist_ok=True) venv_dir.mkdir(parents=True, exist_ok=True)
manifest_path.parent.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", encoding="utf-8",
) )
model_offset = MODELS_SLICE_START
failed_models = [] failed_models = []
for label, command, environment, kind in model_stages: for label, command, environment, kind in model_stages:
stage_start = model_offset
stage_end = stage_start + model_span
try: try:
run_stage( run_stage(
args.status, args.status,
None, stage,
None, total_stages,
label, label,
command, command,
environment, environment,
kind, kind,
start=stage_start, start=0.0,
end=stage_end, end=1.0,
) )
except Exception as error: except Exception as error:
print(f"Model download failed: {label}: {error}") print(f"Model download failed: {label}: {error}")
failed_models.append(label) failed_models.append(label)
write_status( write_status(
args.status, args.status,
stage_end, 1.0,
f"Skipping {label}; continuing", f"Skipping {label}; continuing",
stage=stage,
stages=total_stages,
stage_label=label,
) )
model_offset = stage_end stage += 1
if failed_models: if failed_models:
labels = ", ".join(failed_models) labels = ", ".join(failed_models)
write_status( write_status(
args.status, args.status,
1.0, 1.0,
f"Environment is installed; retry {labels} from Models", f"Environment is installed; retry {labels} from Models",
stage=total_stages,
stages=total_stages,
stage_label=model_stages[-1][0],
) )
return return
write_status( write_status(
args.status, args.status,
1.0, 1.0,
"Environment and recommended models ready", "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 started: bool = False
start_error: str = "" start_error: str = ""
start_thread: threading.Thread | None = None start_thread: threading.Thread | None = None
progress_stage: int | None = None
_active_job = None _active_job = None
@@ -520,6 +521,11 @@ class RunJob(bpy.types.Operator):
and remove_background and remove_background
), ),
device=self._task_device(settings), device=self._task_device(settings),
inference_mode=(
"per_frame"
if self.job_type in DA3_IMAGE_JOBS
else "joint"
),
), ),
paths, paths,
) )
@@ -612,10 +618,17 @@ class RunJob(bpy.types.Operator):
reported_progress = float( reported_progress = float(
status.get("progress", settings.progress) status.get("progress", settings.progress)
) )
settings.progress = max( reported_progress = min(max(reported_progress, 0.0), 1.0)
settings.progress, reported_stage = status.get("stage")
min(max(reported_progress, 0.0), 1.0), 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) settings.status = self._format_status(status, settings.status)
self._redraw_ui(context, force=True) self._redraw_ui(context, force=True)
@@ -624,38 +637,19 @@ class RunJob(bpy.types.Operator):
stage = status.get("stage") stage = status.get("stage")
stages = status.get("stages") stages = status.get("stages")
stage_label = status.get("stage_label") stage_label = status.get("stage_label")
parts = []
if stage and stages: 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: if stage_label:
stage_text = f"{stage_text} {stage_label}" return f"{prefix} {stage_label}"
parts.append(stage_text) return prefix
elif stage_label:
parts.append(str(stage_label))
if message: if message:
normalized = str(message).strip() return str(message).strip()
if normalized.lower() != str(stage_label or "").lower(): return str(stage_label or fallback)
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): def _finish(self, context, job, return_code):
settings = context.scene.snapform_settings settings = context.scene.snapform_settings

View File

@@ -49,7 +49,8 @@ EXPORT_FORMATS = (
HF_ENDPOINT = "https://huggingface.co" HF_ENDPOINT = "https://huggingface.co"
R2_ENDPOINT = "https://models.omoolab.xyz" R2_ENDPOINT = "https://models.omoolab.xyz"
# 连接(TCP + TLS 握手)超时,源不可达时快速失败切换;下载阶段不设读超时。 # 连接(TCP + TLS 握手)超时,源不可达时快速失败切换;下载阶段不设读超时。
CONNECT_TIMEOUT = 6.0 CONNECT_TIMEOUT = 3.0
DOWNLOAD_CHUNK_SIZE = 256 * 1024
class _ShortConnectHTTPSConnection(http.client.HTTPSConnection): class _ShortConnectHTTPSConnection(http.client.HTTPSConnection):
@@ -898,7 +899,7 @@ class DownloadProgressReporter:
with self.lock: with self.lock:
if progress <= self.progress: if progress <= self.progress:
return 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 return
self.progress = progress self.progress = progress
self.last_write_time = now self.last_write_time = now
@@ -907,20 +908,10 @@ class DownloadProgressReporter:
if not effective_rate and elapsed > 0: if not effective_rate and elapsed > 0:
effective_rate = current / elapsed effective_rate = current / elapsed
speed = format_rate(effective_rate) if effective_rate else "" 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 prefix = self.label or self.repository
message = f"Downloading {prefix}" message = f"Downloading {prefix}"
if speed: if speed:
message = f"{message} | {speed}" message = f"{message} {speed}"
write_status( write_status(
self.status_path, self.status_path,
progress, progress,
@@ -928,7 +919,6 @@ class DownloadProgressReporter:
stage=self.stage, stage=self.stage,
stages=self.stages, stages=self.stages,
stage_label=self.stage_label, 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, R2_ENDPOINT,
download_from_r2, download_from_r2,
f"Downloading {label} | OmooLab mirror", f"Downloading {label} (mirror)",
), ),
) )
else: else:
@@ -1072,7 +1062,7 @@ def _download_declared_or_fail(args, model_dir, label, start, span, stage_kwargs
( (
R2_ENDPOINT, R2_ENDPOINT,
download_from_r2, download_from_r2,
f"Downloading {label} | OmooLab mirror", f"Downloading {label} (mirror)",
), ),
) )
failures = [] failures = []
@@ -1209,7 +1199,7 @@ def _download_file_from_url(
with opener(request) as response: with opener(request) as response:
with open(temporary, "wb") as output: with open(temporary, "wb") as output:
while True: while True:
chunk = response.read(1024 * 1024) chunk = response.read(DOWNLOAD_CHUNK_SIZE)
if not chunk: if not chunk:
break break
output.write(chunk) output.write(chunk)
@@ -1310,7 +1300,7 @@ def download_from_r2(
lambda filename: r2_file_url(args.r2_directory, filename), lambda filename: r2_file_url(args.r2_directory, filename),
start, start,
span, span,
label, f"{label} (mirror)" if label else "mirror",
stage_kwargs, stage_kwargs,
) )
@@ -1935,6 +1925,25 @@ def run_moge(args, model_cache=None, cancel_check=None):
shutil.rmtree(temporary_directory, ignore_errors=True) 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): def reconstruct(args, model_cache=None, cancel_check=None):
write_status(args.status, 0.02, "Starting Environment") write_status(args.status, 0.02, "Starting Environment")
check_cancelled(cancel_check) check_cancelled(cancel_check)
@@ -2016,14 +2025,31 @@ def reconstruct(args, model_cache=None, cancel_check=None):
0.25, 0.25,
f"Estimating depth for {len(images)} frame(s)", 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: try:
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( prediction = infer(
model, model,
[str(path) for path in images], [str(path) for path in images],
process_res=args.process_res, **inference_options,
process_res_method=args.process_res_method,
use_ray_pose=bool(args.use_ray_pose),
ref_view_strategy=args.ref_view_strategy,
) )
except UnicodeDecodeError as error: except UnicodeDecodeError as error:
# DirectML reports failures with a locale-specific message that # DirectML reports failures with a locale-specific message that

View File

@@ -849,7 +849,7 @@ class AddonTest(unittest.TestCase):
}, },
"fallback", "fallback",
), ),
"3/8 pytorch | 2 downloaded", "3/8 2 downloaded",
) )
self.assertEqual( self.assertEqual(
operator._format_status( operator._format_status(
@@ -886,7 +886,7 @@ class AddonTest(unittest.TestCase):
}, },
"fallback", "fallback",
), ),
"2/3 PyTorch | Downloading packages | About 3 min left", "2/3 Downloading packages",
) )
self.assertEqual( self.assertEqual(
operator._format_status( operator._format_status(
@@ -902,6 +902,30 @@ class AddonTest(unittest.TestCase):
"2/3 PyTorch", "2/3 PyTorch",
) )
def test_update_status_resets_progress_when_stage_changes(self):
operator = self.addon.RunJob()
settings = SimpleNamespace(progress=0.9, status="previous")
context = SimpleNamespace(
scene=SimpleNamespace(snapform_settings=settings)
)
job = SimpleNamespace(status_path=Path("status.json"), progress_stage=1)
with patch.object(
self.addon.client,
"read_status",
return_value={
"progress": 0.1,
"message": "Downloading model",
"stage": 2,
"stages": 4,
"stage_label": "MoGe-2",
},
), patch.object(operator, "_redraw_ui"):
operator._update_status(context, job)
self.assertEqual(job.progress_stage, 2)
self.assertEqual(settings.progress, 0.1)
def test_model_options_mark_only_models_that_are_not_downloaded(self): def test_model_options_mark_only_models_that_are_not_downloaded(self):
items_callback = self.addon.SnapFormSettings.__annotations__["model"][ items_callback = self.addon.SnapFormSettings.__annotations__["model"][
"items" "items"
@@ -1419,6 +1443,7 @@ class AddonTest(unittest.TestCase):
parameters = request.payload["parameters"] parameters = request.payload["parameters"]
self.assertEqual(request.payload["command"], "reconstruct") self.assertEqual(request.payload["command"], "reconstruct")
self.assertEqual(parameters["inference_mode"], "joint")
self.assertEqual(parameters["export_format"], "gs_ply") self.assertEqual(parameters["export_format"], "gs_ply")
for parameter in ( for parameter in (
"process_res", "process_res",
@@ -1660,6 +1685,10 @@ class AddonTest(unittest.TestCase):
self.fake_bpy.data.objects.get = original_get self.fake_bpy.data.objects.get = original_get
self.assertEqual(reconstruct.call_args.kwargs["start_frame"], 71) self.assertEqual(reconstruct.call_args.kwargs["start_frame"], 71)
self.assertEqual(
reconstruct.call_args.kwargs["inference_mode"],
"per_frame",
)
def test_reconstruct_request_accepts_image_depth_overrides(self): def test_reconstruct_request_accepts_image_depth_overrides(self):
client = importlib.import_module("snapform.client") client = importlib.import_module("snapform.client")

View File

@@ -110,10 +110,10 @@ class DownloadProgressTest(unittest.TestCase):
status = json.loads(status_path.read_text(encoding="utf-8")) status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertEqual( self.assertEqual(
status["message"], status["message"],
"Downloading da3 small | 13.4 MB/s", "Downloading da3 small 13.4 MB/s",
) )
def test_reports_estimated_time_remaining(self): def test_does_not_report_estimated_time_remaining(self):
times = iter((0.0, 10.0)) times = iter((0.0, 10.0))
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json" status_path = Path(directory) / "status.json"
@@ -133,9 +133,9 @@ class DownloadProgressTest(unittest.TestCase):
progress.update(50) progress.update(50)
status = json.loads(status_path.read_text(encoding="utf-8")) status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertEqual(status["eta_seconds"], 10) self.assertNotIn("eta_seconds", status)
def test_staged_download_prefixes_speed_with_downloading(self): def test_staged_download_keeps_model_label(self):
class FakeTqdmWithRate(FakeTqdm): class FakeTqdmWithRate(FakeTqdm):
@property @property
def format_dict(self): def format_dict(self):
@@ -165,7 +165,7 @@ class DownloadProgressTest(unittest.TestCase):
status = json.loads(status_path.read_text(encoding="utf-8")) status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertEqual( self.assertEqual(
status["message"], status["message"],
"Downloading packages | 13.4 MB/s", "Downloading da3 small 13.4 MB/s",
) )
self.assertEqual(status["stage"], 6) self.assertEqual(status["stage"], 6)
self.assertEqual(status["stages"], 8) self.assertEqual(status["stages"], 8)

View File

@@ -1,7 +1,9 @@
import importlib.util import importlib.util
import io import io
import hashlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import tarfile import tarfile
import tempfile import tempfile
@@ -129,8 +131,8 @@ class InstallEnvTest(unittest.TestCase):
if value == "--allow-pattern" if value == "--allow-pattern"
] ]
self.assertEqual(patterns, ["onnx/model_fp16.onnx"]) self.assertEqual(patterns, ["onnx/model_fp16.onnx"])
self.assertNotIn("--stage", ben2_download) self.assertIn("--stage", ben2_download)
self.assertNotIn("--stage-label", ben2_download) self.assertIn("--stage-label", ben2_download)
self.assertEqual( self.assertEqual(
ben2_download[ben2_download.index("--label") + 1], ben2_download[ben2_download.index("--label") + 1],
"BEN2 Base", "BEN2 Base",
@@ -305,7 +307,7 @@ class InstallEnvTest(unittest.TestCase):
) )
self.assertEqual(called_command[-1], "da3 small") self.assertEqual(called_command[-1], "da3 small")
def test_install_allocates_contiguous_progress_slices(self): def test_install_resets_progress_for_each_stage(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@@ -358,21 +360,8 @@ class InstallEnvTest(unittest.TestCase):
): ):
bootstrap.install(args) bootstrap.install(args)
starts = [item[1] for item in stage_ranges] self.assertEqual(stage_ranges[:2], [(1, 0.0, 0.05), (1, 0.05, 1.0)])
ends = [item[2] for item in stage_ranges] self.assertEqual(stage_ranges[2:], [(2, 0.0, 1.0), (3, 0.0, 1.0)])
self.assertTrue(
all(starts[i] < starts[i + 1] for i in range(len(starts) - 1))
)
self.assertEqual(ends[-1], 1.0)
self.assertEqual(starts[0], bootstrap.ENVIRONMENT_SLICE[0])
runtime_ranges = stage_ranges[:2]
self.assertEqual(
[item[0] for item in runtime_ranges],
[1, 2],
)
self.assertTrue(
all(item[0] is None for item in stage_ranges[4:])
)
status = json.loads( status = json.loads(
(root / "status.json").read_text(encoding="utf-8") (root / "status.json").read_text(encoding="utf-8")
) )
@@ -884,13 +873,12 @@ class InstallEnvTest(unittest.TestCase):
) )
) )
def test_install_fails_when_bundled_uv_missing(self): def test_install_fails_when_all_uv_sources_are_unavailable(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
venv_dir = root / ".venv" venv_dir = root / ".venv"
venv_dir.mkdir() venv_dir.mkdir()
self.create_bundled_vendor(root)
uv_path = root / "uv" / "uv.exe" uv_path = root / "uv" / "uv.exe"
args = SimpleNamespace( args = SimpleNamespace(
uv=str(uv_path), uv=str(uv_path),
@@ -916,29 +904,23 @@ class InstallEnvTest(unittest.TestCase):
"rmtree", "rmtree",
), patch.object( ), patch.object(
bootstrap, bootstrap,
"bundled_uv_archive", "download_uv_archive",
return_value=None, side_effect=RuntimeError("all sources unavailable"),
): ):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
"Bundled uv is missing", "all sources unavailable",
): ):
bootstrap.install(args) bootstrap.install(args)
def test_install_uses_bundled_vendor_and_uv_without_network(self): def test_install_downloads_and_extracts_uv(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
venv_dir = root / ".venv" venv_dir = root / ".venv"
venv_dir.mkdir() venv_dir.mkdir()
bundled = root / "bundled"
bundled.mkdir()
(bundled / "depth-anything-3.tar.gz").write_bytes(b"da3")
(bundled / "ben2.tar.gz").write_bytes(b"ben2")
(bundled / "utils3d.tar.gz").write_bytes(b"utils3d")
(bundled / "moge.tar.gz").write_bytes(b"moge")
target, archive_name = bootstrap.uv_download_target() target, archive_name = bootstrap.uv_download_target()
archive = bundled / f"uv-{target}.{archive_name}" archive = root / f"uv-{target}.{archive_name}"
if archive_name.endswith("zip"): if archive_name.endswith("zip"):
with zipfile.ZipFile(archive, "w") as bundle: with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("uv/uv.exe", b"uv-binary") bundle.writestr("uv/uv.exe", b"uv-binary")
@@ -978,14 +960,18 @@ class InstallEnvTest(unittest.TestCase):
install_commands.append(command) install_commands.append(command)
return [] return []
def fake_download(destination, *_args):
shutil.copy2(archive, destination)
return destination
with patch.object( with patch.object(
bootstrap,
"bundled_vendor_directory",
return_value=bundled,
), patch.object(
bootstrap, bootstrap,
"resolve_indexes", "resolve_indexes",
return_value=None, return_value=None,
), patch.object(
bootstrap,
"download_uv_archive",
side_effect=fake_download,
), patch.object( ), patch.object(
bootstrap, bootstrap,
"run", "run",
@@ -1003,13 +989,6 @@ class InstallEnvTest(unittest.TestCase):
self.assertEqual(uv_path.read_bytes(), b"uv-binary") self.assertEqual(uv_path.read_bytes(), b"uv-binary")
all_commands = run_commands + install_commands all_commands = run_commands + install_commands
self.assertFalse(
any(
"github.com" in str(value)
for command in all_commands
for value in command
)
)
runtime_command = next( runtime_command = next(
command command
for command in install_commands for command in install_commands
@@ -1017,6 +996,9 @@ class InstallEnvTest(unittest.TestCase):
) )
self.assertFalse(any(" @ file:" in str(value) for value in runtime_command)) self.assertFalse(any(" @ file:" in str(value) for value in runtime_command))
self.assertFalse(any("torch" in str(value) for value in runtime_command)) self.assertFalse(any("torch" in str(value) for value in runtime_command))
status = json.loads(Path(args.status).read_text(encoding="utf-8"))
self.assertEqual(status["stage"], 4)
self.assertEqual(status["stages"], 4)
def test_install_reuses_existing_uv(self): def test_install_reuses_existing_uv(self):
bootstrap = load_install_env() bootstrap = load_install_env()
@@ -1062,6 +1044,78 @@ class InstallEnvTest(unittest.TestCase):
self.assertEqual(uv_path.read_bytes(), b"binary") self.assertEqual(uv_path.read_bytes(), b"binary")
def test_uv_download_falls_back_to_omoolab_and_verifies_checksum(self):
bootstrap = load_install_env()
content = io.BytesIO()
with zipfile.ZipFile(content, "w") as archive:
archive.writestr("uv/uv.exe", b"uv-binary")
payload = content.getvalue()
class Response(io.BytesIO):
headers = {"Content-Length": str(len(payload))}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
destination = root / "uv.tmp"
status = root / "status.json"
urls = []
clock_state = {"now": 0.0}
def clock():
clock_state["now"] += 0.25
return clock_state["now"]
def open_url(request):
urls.append(request.full_url)
if len(urls) == 1:
raise OSError("official unavailable")
return Response(payload)
with patch.object(
bootstrap,
"uv_download_target",
return_value=("test-target", "zip"),
), patch.dict(
bootstrap.UV_ARTIFACTS,
{
"test-target": (
"uv-test.zip",
hashlib.sha256(payload).hexdigest(),
)
},
), patch.dict(
bootstrap._uv_download_openers,
{
bootstrap.UV_OFFICIAL_BASE_URL: SimpleNamespace(
open=open_url
),
bootstrap.UV_MIRROR_BASE_URL: SimpleNamespace(
open=open_url
),
},
), patch.object(
bootstrap,
"write_status",
) as write_status:
result = bootstrap.download_uv_archive(
destination,
status,
0.0,
1.0,
{},
clock=clock,
)
self.assertEqual(result.read_bytes(), payload)
self.assertTrue(urls[0].startswith(bootstrap.UV_OFFICIAL_BASE_URL))
self.assertTrue(urls[1].startswith(bootstrap.UV_MIRROR_BASE_URL))
self.assertEqual(bootstrap.UV_OFFICIAL_CONNECT_TIMEOUT, 3.0)
self.assertEqual(bootstrap.UV_MIRROR_CONNECT_TIMEOUT, 15.0)
messages = [call.args[2] for call in write_status.call_args_list]
self.assertTrue(any("uv (mirror)" in value for value in messages))
self.assertTrue(any("B/s" in value for value in messages))
self.assertFalse(any("left" in value for value in messages))
def test_install_stages_use_only_runtime_packages(self): def test_install_stages_use_only_runtime_packages(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1244,15 +1298,13 @@ class InstallEnvTest(unittest.TestCase):
) )
self.assertEqual(report, {}) self.assertEqual(report, {})
def test_install_uv_extracts_bundled_archive(self): def test_install_uv_extracts_downloaded_archive(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
uv_path = root / "uv" uv_path = root / "uv"
bundled = root / "bundled"
bundled.mkdir(parents=True)
target, archive_name = bootstrap.uv_download_target() target, archive_name = bootstrap.uv_download_target()
archive = bundled / f"uv-{target}.{archive_name}" archive = root / f"uv-{target}.{archive_name}"
if archive_name.endswith("zip"): if archive_name.endswith("zip"):
with zipfile.ZipFile(archive, "w") as bundle: with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("uv/uv.exe", b"uv-binary") bundle.writestr("uv/uv.exe", b"uv-binary")
@@ -1262,30 +1314,32 @@ class InstallEnvTest(unittest.TestCase):
info = tarfile.TarInfo("uv") info = tarfile.TarInfo("uv")
info.size = len(content) info.size = len(content)
tar.addfile(info, io.BytesIO(content)) tar.addfile(info, io.BytesIO(content))
def fake_download(destination, *_args):
shutil.copy2(archive, destination)
return destination
with patch.object( with patch.object(
bootstrap, bootstrap,
"bundled_vendor_directory", "download_uv_archive",
return_value=bundled, side_effect=fake_download,
): ):
bootstrap.install_uv(uv_path, str(root / "status.json")) bootstrap.install_uv(uv_path, str(root / "status.json"))
self.assertEqual(uv_path.read_bytes(), b"uv-binary") self.assertEqual(uv_path.read_bytes(), b"uv-binary")
def test_install_uv_raises_when_bundled_missing(self): def test_install_uv_raises_when_download_fails(self):
bootstrap = load_install_env() bootstrap = load_install_env()
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
uv_path = root / "uv" uv_path = root / "uv"
bundled = root / "bundled"
bundled.mkdir()
with patch.object( with patch.object(
bootstrap, bootstrap,
"bundled_vendor_directory", "download_uv_archive",
return_value=bundled, side_effect=RuntimeError("all uv sources failed"),
): ):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
"Bundled uv is missing", "all uv sources failed",
): ):
bootstrap.install_uv(uv_path, str(root / "status.json")) bootstrap.install_uv(uv_path, str(root / "status.json"))

View File

@@ -61,21 +61,11 @@ class PackTest(unittest.TestCase):
self.assertIn("service.py", names) self.assertIn("service.py", names)
self.assertNotIn("snapform/__init__.py", names) self.assertNotIn("snapform/__init__.py", names)
self.assertNotIn("importer.py", names) self.assertNotIn("importer.py", names)
self.assertIn(
f"vendor/{pack.PLATFORMS[platform]}",
names,
)
self.assertFalse( self.assertFalse(
any(name.startswith("sources/") for name in names) any(name.startswith("sources/") for name in names)
) )
bundled_uv = { self.assertFalse(
name any(name.startswith("vendor/") for name in names)
for name in names
if name.startswith("vendor/uv-")
}
self.assertEqual(
bundled_uv,
{f"vendor/{pack.PLATFORMS[platform]}"},
) )
self.assertIn(f'platforms = ["{platform}"]', manifest) self.assertIn(f'platforms = ["{platform}"]', manifest)
self.assertNotIn( self.assertNotIn(

View File

@@ -1,67 +0,0 @@
import hashlib
import importlib.util
import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_prepare_vendor():
specification = importlib.util.spec_from_file_location(
"snapform_prepare_vendor",
PROJECT_ROOT / "prepare_vendor.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class PrepareVendorTest(unittest.TestCase):
def test_downloads_and_verifies_artifact(self):
prepare = load_prepare_vendor()
content = b"locked artifact"
artifact = {
"filename": "artifact.tar.gz",
"url": "https://example.invalid/artifact.tar.gz",
"sha256": hashlib.sha256(content).hexdigest(),
}
with tempfile.TemporaryDirectory() as directory, patch.object(
prepare.urllib.request,
"urlopen",
return_value=io.BytesIO(content),
):
path = prepare.download_artifact(artifact, Path(directory))
self.assertEqual(path.read_bytes(), content)
def test_rejects_hash_mismatch_without_leaving_partial_file(self):
prepare = load_prepare_vendor()
artifact = {
"filename": "artifact.tar.gz",
"url": "https://example.invalid/artifact.tar.gz",
"sha256": hashlib.sha256(b"expected").hexdigest(),
}
with tempfile.TemporaryDirectory() as directory, patch.object(
prepare.urllib.request,
"urlopen",
return_value=io.BytesIO(b"unexpected"),
):
destination = Path(directory)
with self.assertRaisesRegex(RuntimeError, "SHA-256 mismatch"):
prepare.download_artifact(artifact, destination)
self.assertFalse((destination / artifact["filename"]).exists())
self.assertFalse(
(destination / f"{artifact['filename']}.part").exists()
)
def test_rejects_unknown_platform(self):
prepare = load_prepare_vendor()
with self.assertRaisesRegex(ValueError, "Unsupported platform"):
prepare.prepare(["windows-arm64"])
if __name__ == "__main__":
unittest.main()

View File

@@ -369,7 +369,7 @@ class WorkerTest(unittest.TestCase):
"_verify_model_complete", "_verify_model_complete",
return_value=None, return_value=None,
): ):
args.model_dir = str(Path(directory) / "model") args.model_dir = str(Path(directory) / "models" / "DA3")
args.status = str(Path(directory) / "status.json") args.status = str(Path(directory) / "status.json")
self.worker.download_model(args) self.worker.download_model(args)
@@ -456,7 +456,7 @@ class WorkerTest(unittest.TestCase):
"_verify_model_complete", "_verify_model_complete",
return_value=None, return_value=None,
): ):
args.model_dir = str(Path(directory) / "model") args.model_dir = str(Path(directory) / "models" / "DA3")
args.status = str(Path(directory) / "status.json") args.status = str(Path(directory) / "status.json")
self.worker.download_model(args) self.worker.download_model(args)
@@ -937,15 +937,21 @@ class WorkerTest(unittest.TestCase):
self.assertEqual(mesh_calls[0][2:], (35.0, True, 0.2)) self.assertEqual(mesh_calls[0][2:], (35.0, True, 0.2))
def test_reconstruct_copies_frames_for_multi_frame_input(self): def test_reconstruct_copies_frames_for_multi_frame_input(self):
inference_inputs = []
fake_da3 = ModuleType("da3_onnx") fake_da3 = ModuleType("da3_onnx")
fake_da3.create_session = lambda _model_dir, _device: object() fake_da3.create_session = lambda _model_dir, _device: object()
fake_da3.infer = lambda *_args, **_kwargs: SimpleNamespace(
depth=np.full((2, 2, 2), 2.0, dtype=np.float32), def fake_infer(_session, images, **_options):
conf=np.ones((2, 2, 2), dtype=np.float32), inference_inputs.append(images)
extrinsics=np.repeat(np.eye(4, dtype=np.float32)[None, :3], 2, axis=0), return SimpleNamespace(
intrinsics=np.repeat(np.eye(3, dtype=np.float32)[None], 2, axis=0), depth=np.full((1, 2, 2), 2.0, dtype=np.float32),
processed_images=np.zeros((2, 2, 2, 3), dtype=np.uint8), conf=np.ones((1, 2, 2), dtype=np.float32),
extrinsics=np.eye(4, dtype=np.float32)[None, :3],
intrinsics=np.eye(3, dtype=np.float32)[None],
processed_images=np.zeros((1, 2, 2, 3), dtype=np.uint8),
) )
fake_da3.infer = fake_infer
fake_runtime = ModuleType("onnx_runtime") fake_runtime = ModuleType("onnx_runtime")
fake_runtime.provider_label = lambda _session: "CPU" fake_runtime.provider_label = lambda _session: "CPU"
@@ -979,6 +985,7 @@ class WorkerTest(unittest.TestCase):
show_cameras=0, show_cameras=0,
gs_views_interval=1, gs_views_interval=1,
remove_background_model_dir="", remove_background_model_dir="",
inference_mode="per_frame",
) )
self.worker.reconstruct(args) self.worker.reconstruct(args)
@@ -989,6 +996,10 @@ class WorkerTest(unittest.TestCase):
self.assertTrue( self.assertTrue(
(result_directory / "frame.0002.jpg").is_file() (result_directory / "frame.0002.jpg").is_file()
) )
self.assertEqual(len(inference_inputs), 2)
self.assertTrue(all(len(paths) == 1 for paths in inference_inputs))
with np.load(result_directory / "results.npz") as result:
self.assertEqual(result["depth"].shape, (2, 2, 2))
self.assertTrue( self.assertTrue(
(result_directory / "results.npz").is_file() (result_directory / "results.npz").is_file()
) )

View File

@@ -1,19 +0,0 @@
schema_version = 1
[uv]
version = "0.11.3"
[platforms.windows-x64]
filename = "uv-x86_64-pc-windows-msvc.zip"
url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-x86_64-pc-windows-msvc.zip"
sha256 = "ae681c0aaec7cc96af184648cb88d73f8393ed60fa5880abdd6bdb910f9b227c"
[platforms.macos-arm64]
filename = "uv-aarch64-apple-darwin.tar.gz"
url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-aarch64-apple-darwin.tar.gz"
sha256 = "2bc3d0c7bf2bd08325b1e170abac6f7e5b3346e1d4eab3370d17cefec934996f"
[platforms.linux-x64]
filename = "uv-x86_64-unknown-linux-gnu.tar.gz"
url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-x86_64-unknown-linux-gnu.tar.gz"
sha256 = "c0f3236f146e55472663cfbcc9be3042a9f1092275bbe3fe2a56a6cbfd3da5ce"