Refactor AI Host to Backend Service and update related components
- Renamed references from "AI Host" to "Backend Service" throughout the codebase for consistency. - Updated the registration and unregistration of context menus to include the Outliner. - Changed the job protocol versioning and related constants for clarity. - Modified the runtime installation messages and error handling to reflect the new terminology. - Adjusted the user interface labels and messages in panels and operators to align with the new Backend Service terminology. - Updated tests to ensure they reflect the changes in naming and functionality. - Changed default value for background depth in geometry nodes asset to -1.0 for better handling of depth planes.
This commit is contained in:
10
README.md
10
README.md
@@ -17,11 +17,11 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
|
||||
3. 点击 `Install from Disk`,选择下载的压缩包
|
||||
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
|
||||
最快的 CUDA/MPS,否则 CPU。下载超时会自动重试国内镜像,无需手动配置
|
||||
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Runtime`
|
||||
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Backend`
|
||||
|
||||
DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动
|
||||
下载运行环境依赖(PyTorch 等),随后自动下载 `DA3 Small` 和 `BEN2 Base`。其他 AI 模型可在
|
||||
`Host > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
`AI Backend > Models` 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
|
||||
## 快速上手
|
||||
|
||||
@@ -55,7 +55,7 @@ confidence 与边拉伸结果会保存为 Surface attributes,再由一个可
|
||||
以及分割线下方的 `Keep Original`。
|
||||
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
|
||||
Geometry Nodes 修改器中调整;`Unshaded` 可使用与 Depth Plane 相同的 Emission 材质。普通材质的
|
||||
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Host 准备过程显示
|
||||
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Backend Service 准备过程显示
|
||||
`Preparing surface data n/6`,可看到当前阶段与总步骤。
|
||||
|
||||
### 视频与序列帧
|
||||
@@ -72,8 +72,8 @@ SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 首次使用需要下载模型,如果下载慢或失败,稍后在 `Host > Models` 里重试即可
|
||||
- 安装 Runtime 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然
|
||||
- 首次使用需要下载模型,如果下载慢或失败,稍后在 `AI Backend > Models` 里重试即可
|
||||
- 安装 Backend 时提示下载超时:新版会自动探测并切换到镜像、失败时自动重试一次;如果仍然
|
||||
超时,在扩展偏好设置的 `PyPI Index` 中填入镜像地址(如清华)后重新安装即可
|
||||
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
|
||||
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
|
||||
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
|
||||
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
||||
作为首个 AI 能力,在独立 Runtime 中运行 DA3,可将官方导出结果保存在独立 job 目录,
|
||||
作为首个 AI 能力,在独立 Backend Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录,
|
||||
也可把 Image Empty 转换成带几何与材质置换的立体面片。Debug 面板还可运行
|
||||
[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
|
||||
- 在独立 Python Runtime 的常驻 HTTP AI Host 中运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程
|
||||
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Host;请求带随机 Token
|
||||
- 常驻 Host 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载
|
||||
- 多个 Blender 实例自动连接同一个共享 Host;`Host` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制
|
||||
- Runtime、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
|
||||
- 在独立 Backend Environment 中通过常驻 Backend Service 运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程
|
||||
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Backend Service;请求带随机 Token
|
||||
- 常驻 Backend Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载
|
||||
- 多个 Blender 实例自动连接同一个共享 Backend Service;`AI Backend` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制
|
||||
- Backend Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
|
||||
- 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1
|
||||
- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时
|
||||
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
|
||||
- 视频按顺序截取前若干帧,可设置参与重建的最大帧数
|
||||
- `Host > Models` 默认折叠,展开后可逐项查看和下载所有可用模型
|
||||
- Runtime 已就绪时不额外占用面板空间;模型列表每个模型只占一行
|
||||
- `AI Backend > Models` 默认折叠,展开后可逐项查看和下载所有可用模型
|
||||
- Backend Environment 已就绪时不额外占用面板空间;模型列表每个模型只占一行
|
||||
- `Debug` 默认折叠,内部的 `DA3 Output Test` 与 `BEN2 Removal Test` 也分别默认折叠
|
||||
- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮
|
||||
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
|
||||
@@ -42,7 +42,7 @@
|
||||
- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整
|
||||
- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景
|
||||
- 完整保留每次任务生成的所有官方文件
|
||||
- 安装和模型下载使用后台子进程;推理提交给常驻 AI Host,状态栏实时显示阶段、百分比进度条与取消按钮
|
||||
- 安装和模型下载使用后台子进程;推理提交给常驻 Backend Service,状态栏实时显示阶段、百分比进度条与取消按钮
|
||||
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
|
||||
|
||||
## 使用
|
||||
@@ -55,31 +55,31 @@
|
||||
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
|
||||
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出
|
||||
本机支持的设备。
|
||||
4. 在 `Host` 点击 `Install Runtime`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn;
|
||||
4. 在 `AI Backend` 点击 `Install Backend`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn;
|
||||
DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/vendor/` 解包使用,
|
||||
并在 Runtime 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Runtime 安装
|
||||
并在 Backend Environment 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Backend Environment 安装
|
||||
固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、
|
||||
FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度
|
||||
区间。安装完成后直接写入 Runtime 就绪信息,不再额外导入全部组件进行验证。推荐模型属于安装
|
||||
区间。安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装
|
||||
后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
|
||||
状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际
|
||||
进度修正预期;模型下载根据字节数和速度计算剩余时间,例如
|
||||
`Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。
|
||||
Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。
|
||||
Backend Environment 就绪后即使模型下载中断也不影响 Backend Service 使用,可稍后在 `Models` 面板单独补下。
|
||||
安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker
|
||||
自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。
|
||||
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
|
||||
6. 如需其他模型,展开 `Host > Models`,找到模型并点击该行的 `Download`。
|
||||
6. 如需其他模型,展开 `AI Backend > Models`,找到模型并点击该行的 `Download`。
|
||||
7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。
|
||||
8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。
|
||||
9. 测试背景剔除时,确认 `Host > Models` 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
|
||||
9. 测试背景剔除时,确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
|
||||
`BEN2 Removal Test` 区域选择单张图片并点击 `Remove Background`。
|
||||
完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示
|
||||
BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。
|
||||
|
||||
## Image Empty 去背景
|
||||
|
||||
1. 确认 `Host > Models` 中的 `BEN2 Base` 已就绪;首次安装 Runtime 时会默认下载。
|
||||
1. 确认 `AI Backend > Models` 中的 `BEN2 Base` 已就绪;首次安装 Backend 时会默认下载。
|
||||
2. 在 3D View 选中 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Remove Background`。
|
||||
3. 完成后以 Fake User 保留原 Image datablock,新建并 Pack 一张透明 PNG 到当前 `.blend`,然后只把
|
||||
当前 Empty 的 `data` 换成新图;Empty 的 Transform、显示尺寸、Offset 及其他设置保持不变。
|
||||
@@ -107,16 +107,16 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比
|
||||
显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。
|
||||
转换导出 `mini_npz`,使用前需要在
|
||||
`Host > Models` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
||||
`AI Backend > Models` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
||||
继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到
|
||||
`Host > Models` 下载。
|
||||
`AI Backend > Models` 下载。
|
||||
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
|
||||
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
|
||||
`Original` 是默认深度来源。选择 `AI Generated` 时,其 Model、Use Ray Pose 与 Resolution Scale
|
||||
参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠;
|
||||
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
|
||||
`BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立
|
||||
Mask,不在 Host 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
|
||||
Mask,不在 Backend Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
|
||||
因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。
|
||||
Image Empty 引用视频或序列帧时,AI Generated 会按 Empty 的 Image Frames 数值逐帧估计
|
||||
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
|
||||
@@ -139,7 +139,7 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换;
|
||||
- `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`,
|
||||
长边按宽高比计算并四舍五入;例如 16:9 图片在等级 `5` 时生成 `57×32` 个面;
|
||||
- `Background Depth`:默认 `0`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或
|
||||
- `Background Depth`:默认 `-1`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或
|
||||
材质置换使用该值;是否存在背景只取决于转换所得色彩贴图的 `Alpha > 0.5` 二值结果;
|
||||
- `Depth Scale`:默认 `1`,允许正负值,Structure Type 为 `Single`,同时控制 Geometry 与 Material
|
||||
两条置换路径。
|
||||
@@ -167,11 +167,11 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
`Convert to Depth Plane`,当前不处理视频或序列帧。
|
||||
对话框中的 Resolution Scale 与 Depth Plane 使用相同设计:原图长边乘以 `1–100%` 的比例后,
|
||||
将实际 Process Res 钳制在 `252–1008` 并按 `14` 对齐。
|
||||
2. Host 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得
|
||||
前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Host
|
||||
2. Backend Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得
|
||||
前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Backend Service
|
||||
反投影深度网格,建立源三角面,计算 confidence percentile 与 camera-space edge stretch,并写入
|
||||
基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、
|
||||
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Host 端源数据准备。
|
||||
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Backend Service 端源数据准备。
|
||||
3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器
|
||||
添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成:
|
||||
- 修改器顶层依次显示 `Resolution`、`Relax Iterations`、`Smooth`;默认展开的 `Culling` Panel
|
||||
@@ -192,9 +192,9 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
修改器关闭 `show_group_selector`。`Fix Topology` 位于默认折叠的 `Options` Panel。
|
||||
节点图按 Culling、Remesh、Topology Fix、Island Cleanup 与 UV 分区摆放;各区就近使用独立的
|
||||
Group Input,并隐藏没有连线的接口,避免跨越整个节点图的输入连线。
|
||||
5. Host 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始
|
||||
5. Backend Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始
|
||||
源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。
|
||||
6. Host 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、
|
||||
6. Backend Service 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、
|
||||
`faces`、`uv`、`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。Blender 读取这些
|
||||
数据后直接创建 Surface 与原图材质,不需要 Open3D 或 OpenCV 进入 Blender Python 环境。
|
||||
Surface 保留下列结构化 attributes:
|
||||
@@ -205,7 +205,7 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向
|
||||
观察相机,启用 Backface Culling 时可从正面看到。
|
||||
8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、
|
||||
`Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Host
|
||||
`Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Backend Service
|
||||
使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。
|
||||
`Unshaded` 使用与 Depth Plane 相同的 Emission/Transparent 材质路径;普通材质的
|
||||
Bump Scale 按 `abs(object.scale.z) × camera scale_z` 设置,并在材质组内乘以固定基准 `0.001`,
|
||||
@@ -232,9 +232,9 @@ depth、confidence、intrinsics、extrinsics 与 confidence percentile。当前
|
||||
|
||||
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
|
||||
位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
|
||||
进度单调递增。任务进程与 Host 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存
|
||||
进度单调递增。任务进程与 Backend Service 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Backend Environment 启动、输入准备、模型加载、深度估算、缓存
|
||||
写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的
|
||||
半成品 Runtime 会在下次安装前自动清理,不会因为中断而无法再次安装。
|
||||
半成品 Backend Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。
|
||||
|
||||
导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在
|
||||
`Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到
|
||||
@@ -393,9 +393,15 @@ Point Cloud GLB 任务会关闭其默认附带的深度预览,使五种输出
|
||||
官方格式与限制可参阅
|
||||
[DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。
|
||||
|
||||
## Runtime
|
||||
## AI Backend
|
||||
|
||||
Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12
|
||||
AI Backend 是统一上位概念,由两部分组成:Backend Environment 是磁盘上的 Python、依赖和模型执行环境;
|
||||
Backend Service 是使用该环境启动、接收 Blender 任务的常驻 HTTP 进程。环境的
|
||||
`environment_manifest.json` 使用 `environment_manifest_revision` 判断安装配方是否匹配;Service 的
|
||||
`/health` 使用 `job_protocol_version` 判断 Blender 与服务之间的任务协议是否匹配。两者生命周期不同,
|
||||
因此分别维护版本,而不合并成一个 Schema。
|
||||
|
||||
Extension 本身不包含 Torch。`Install Backend` 使用 `uv` 创建独立 Python 3.12
|
||||
环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender
|
||||
自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载
|
||||
Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。
|
||||
@@ -427,12 +433,12 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序
|
||||
并核对覆盖文件是否仍适用
|
||||
- `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖)
|
||||
|
||||
安装 Runtime 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装
|
||||
安装 Backend Environment 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装
|
||||
CPU 版;macOS 始终安装 CPU 版 PyTorch(MPS 包含其中)。CPU 可以用于验证,但实际推理通常很慢。
|
||||
Extension Preferences 的 `Device` 决定正式功能的推理设备,默认取本机最快的 CUDA/MPS,否则
|
||||
CPU;Debug 面板的 `Debug Device` 只影响 Debug 任务。两处设备选项都只列出本机支持的设备:
|
||||
Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Runtime 安装完成时会检测真实
|
||||
可用的设备并写入 `snapform_ready.json`,设备列表以该检测结果为准。
|
||||
Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Backend Environment 安装完成时会检测真实
|
||||
可用的设备并写入 `environment_manifest.json`,设备列表以该检测结果为准。
|
||||
CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要
|
||||
NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时,
|
||||
设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。
|
||||
@@ -442,30 +448,30 @@ macOS(Apple Silicon)会自动从 PyPI 安装 CPU 版 PyTorch,推理设备
|
||||
Mac(x86_64)轮子,Intel Mac 暂不支持。DA3 射线位姿估计中的 3×3 QR 分解在 MPS
|
||||
上尚未实现,SnapForm 的覆盖文件会单独把该分解放到 CPU 执行,其余推理仍走 MPS。
|
||||
|
||||
Extension 启用后只会查找已有的共享 AI Host,不会主动启动新进程。第一次执行 AI 操作或在侧边栏
|
||||
手动点击 `Start` 时才会启动 Host。Host 只绑定回环地址,并让操作系统分配动态端口;第一个实例把
|
||||
Extension 启用后只会查找已有的共享 Backend Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏
|
||||
手动点击 `Start` 时才会启动 Backend Service。Backend Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把
|
||||
端口、进程和随机 Token 写入 `host.json`,其余 Blender 通过标准库
|
||||
`urllib.request` 连接同一个 Host。`host.lock` 保证同一 Storage Root 同时只有一个 Host,Token
|
||||
则阻止同机其他进程直接调用接口。Host 全局同时只执行一个 AI 任务。
|
||||
`urllib.request` 连接同一个 Backend Service。`host.lock` 保证同一 Storage Root 同时只有一个 Backend Service,Token
|
||||
则阻止同机其他进程直接调用接口。Backend Service 全局同时只执行一个 AI 任务。
|
||||
|
||||
每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Host
|
||||
就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Host
|
||||
在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Host 由其他进程启动等场景的兜底。
|
||||
取消任务不会重启 Host,并会保留已经加载的模型;
|
||||
每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Backend Service
|
||||
就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Backend Service
|
||||
在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Service 由其他进程启动等场景的兜底。
|
||||
取消任务不会重启 Backend Service,并会保留已经加载的模型;
|
||||
如果取消发生在一次不可中断的 Torch 调用中,任务会在该调用返回后的最近检查点结束。
|
||||
|
||||
侧边栏 `Host` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务,
|
||||
侧边栏 `AI Backend` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务,
|
||||
并显示缓存模型和活动任务。Blender 完成结果导入时会立即释放当前任务对应的本地 Busy 快照,无需等待
|
||||
下一次 Host 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Host;
|
||||
下一次 Backend Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service;
|
||||
多客户端或任务执行期间不会允许破坏共享服务。
|
||||
|
||||
BEN2 在首次使用后保持加载;DA3 保留最近使用的一个模型。切换 DA3 模型时会释放旧模型;加载时如果
|
||||
显存不足,会先释放另一类缓存模型并重试。
|
||||
|
||||
Runtime Schema 已升级,旧 Runtime 需要重新执行 `Install Runtime` 才能安装 HTTP Host 依赖。
|
||||
Host 的启动和崩溃日志写入 `~/.snapform/ai-host.log`。
|
||||
Environment Manifest Revision 变化时,旧 Backend Environment 需要重新执行 `Install Backend`。
|
||||
Backend Service 的启动和崩溃日志写入 `~/.snapform/ai-host.log`。
|
||||
|
||||
Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform`:
|
||||
Backend Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`:
|
||||
|
||||
```text
|
||||
~/.snapform/
|
||||
@@ -473,8 +479,8 @@ Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform`
|
||||
├── models/
|
||||
├── jobs/
|
||||
├── ai-host.log
|
||||
├── host.json # Host 运行时存在
|
||||
└── host.lock # Host 运行时持有
|
||||
├── host.json # Backend Service 运行时存在
|
||||
└── host.lock # Backend Service 运行时持有
|
||||
```
|
||||
|
||||
`models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`),
|
||||
@@ -483,7 +489,7 @@ BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在
|
||||
不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension
|
||||
Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录,
|
||||
因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行
|
||||
`Install Runtime`;推理 Host、任务队列和模型读取可以共享。
|
||||
`Install Backend`;Backend Service、任务队列和模型读取可以共享。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ def register():
|
||||
)
|
||||
bpy.types.STATUSBAR_HT_header.append(draw_status_bar)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.append(draw_image_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.append(draw_image_context_menu)
|
||||
runtime.schedule_host_start()
|
||||
|
||||
|
||||
@@ -69,6 +70,7 @@ def unregister():
|
||||
runtime.unschedule_host()
|
||||
runtime.detach_host_client()
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
|
||||
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)
|
||||
if hasattr(bpy.types.Scene, "snapform_settings"):
|
||||
del bpy.types.Scene.snapform_settings
|
||||
|
||||
@@ -19,7 +19,7 @@ if str(SCRIPT_DIRECTORY) not in sys.path:
|
||||
import worker
|
||||
|
||||
|
||||
HOST_SCHEMA = 2
|
||||
JOB_PROTOCOL_VERSION = 2
|
||||
CLIENT_TIMEOUT_SECONDS = 10.0
|
||||
IDLE_SHUTDOWN_SECONDS = 20.0
|
||||
|
||||
@@ -212,7 +212,7 @@ class HostState:
|
||||
with self.lock:
|
||||
return {
|
||||
"ready": True,
|
||||
"host_schema": HOST_SCHEMA,
|
||||
"job_protocol_version": JOB_PROTOCOL_VERSION,
|
||||
"instance_id": self.instance_id,
|
||||
"busy": self.active_job is not None,
|
||||
"active_job": dict(self.active_job) if self.active_job else None,
|
||||
@@ -265,7 +265,7 @@ class HostState:
|
||||
worker.write_status(
|
||||
status_path,
|
||||
0.0,
|
||||
"AI Host accepted the task",
|
||||
"Backend Service accepted the task",
|
||||
state="running",
|
||||
)
|
||||
try:
|
||||
@@ -372,7 +372,7 @@ def create_app(token, instance_id="test"):
|
||||
state.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="SnapForm AI Host",
|
||||
title="SnapForm Backend Service",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
@@ -445,7 +445,7 @@ def create_app(token, instance_id="test"):
|
||||
command,
|
||||
parameters["status"],
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="AI Host is busy")
|
||||
raise HTTPException(status_code=409, detail="Backend Service is busy")
|
||||
worker.write_status(
|
||||
parameters["status"],
|
||||
0.0,
|
||||
@@ -485,7 +485,7 @@ def create_app(token, instance_id="test"):
|
||||
def clear_models(x_snapform_token: str = Header(default="")):
|
||||
authorize(x_snapform_token)
|
||||
if not state.clear_models():
|
||||
raise HTTPException(status_code=409, detail="AI Host is busy")
|
||||
raise HTTPException(status_code=409, detail="Backend Service is busy")
|
||||
return {"state": "cleared"}
|
||||
|
||||
@app.post("/shutdown", status_code=202)
|
||||
@@ -498,7 +498,7 @@ def create_app(token, instance_id="test"):
|
||||
if result == "shared":
|
||||
raise HTTPException(status_code=409, detail="Other Blender clients are connected")
|
||||
if result == "busy":
|
||||
raise HTTPException(status_code=409, detail="AI Host is busy")
|
||||
raise HTTPException(status_code=409, detail="Backend Service is busy")
|
||||
if result != "accepted":
|
||||
raise HTTPException(status_code=404, detail="Client is not registered")
|
||||
return {"state": "stopping"}
|
||||
@@ -524,7 +524,7 @@ def write_discovery(args):
|
||||
temporary.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host_schema": HOST_SCHEMA,
|
||||
"job_protocol_version": JOB_PROTOCOL_VERSION,
|
||||
"port": args.port,
|
||||
"token": args.token,
|
||||
"pid": os.getpid(),
|
||||
|
||||
Binary file not shown.
@@ -12,7 +12,7 @@ platforms = ["windows-x64", "macos-arm64", "linux-x64"]
|
||||
license = ["SPDX:GPL-3.0-or-later"]
|
||||
|
||||
[permissions]
|
||||
network = "Access the local AI Host and download runtime dependencies and model weights"
|
||||
network = "Access the local Backend Service and download environment dependencies and model weights"
|
||||
files = "Read source images and store runtime data"
|
||||
|
||||
[build]
|
||||
|
||||
@@ -34,7 +34,8 @@ XFORMERS_VERSION = "0.0.33.post2"
|
||||
ADDICT_VERSION = "2.4.0"
|
||||
FASTAPI_VERSION = "0.139.2"
|
||||
UVICORN_VERSION = "0.51.0"
|
||||
RUNTIME_SCHEMA = 4
|
||||
ENVIRONMENT_MANIFEST_REVISION = 4
|
||||
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
|
||||
TORCH_INDEXES = {
|
||||
"cu128": "https://download.pytorch.org/whl/cu128",
|
||||
"cpu": "https://download.pytorch.org/whl/cpu",
|
||||
@@ -429,14 +430,17 @@ def install_stages(args, runtime_dir, python, uv):
|
||||
|
||||
|
||||
def runtime_ready(runtime_dir):
|
||||
ready_path = runtime_dir / "snapform_ready.json"
|
||||
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
|
||||
if not environment_python(runtime_dir).is_file() or not ready_path.is_file():
|
||||
return False
|
||||
try:
|
||||
payload = json.loads(ready_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
return payload.get("runtime_schema") == RUNTIME_SCHEMA
|
||||
return (
|
||||
payload.get("environment_manifest_revision")
|
||||
== ENVIRONMENT_MANIFEST_REVISION
|
||||
)
|
||||
|
||||
|
||||
def installed_devices(args):
|
||||
@@ -505,7 +509,7 @@ def run_verify(
|
||||
write_status(
|
||||
status_path,
|
||||
progress,
|
||||
"Checking Runtime",
|
||||
"Checking Backend Environment",
|
||||
stage=stage,
|
||||
stages=stages,
|
||||
stage_label=stage_label,
|
||||
@@ -777,11 +781,13 @@ def install(args):
|
||||
installed_packages.extend(stage_installed)
|
||||
devices = installed_devices(args)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
ready_path = runtime_dir / "snapform_ready.json"
|
||||
ready_path = runtime_dir / ENVIRONMENT_MANIFEST_FILENAME
|
||||
ready_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"runtime_schema": RUNTIME_SCHEMA,
|
||||
"environment_manifest_revision": (
|
||||
ENVIRONMENT_MANIFEST_REVISION
|
||||
),
|
||||
"python": "3.12",
|
||||
"torch": TORCH_VERSION,
|
||||
"xformers": XFORMERS_VERSION,
|
||||
@@ -828,10 +834,14 @@ def install(args):
|
||||
write_status(
|
||||
args.status,
|
||||
1.0,
|
||||
f"Runtime is ready; retry {labels} from Models",
|
||||
f"Backend Environment is ready; retry {labels} from Models",
|
||||
)
|
||||
return
|
||||
write_status(args.status, 1.0, "Runtime and recommended models ready")
|
||||
write_status(
|
||||
args.status,
|
||||
1.0,
|
||||
"Backend Environment and recommended models ready",
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
@@ -852,7 +862,12 @@ def main():
|
||||
try:
|
||||
install(args)
|
||||
except Exception as error:
|
||||
write_status(args.status, 1.0, "Runtime installation failed", str(error))
|
||||
write_status(
|
||||
args.status,
|
||||
1.0,
|
||||
"Backend Environment installation failed",
|
||||
str(error),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -1823,7 +1823,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
available_models = image_depth_model_items(self, context)
|
||||
if not available_models:
|
||||
estimate_box.label(text="No DA3 models installed", icon="ERROR")
|
||||
estimate_box.label(text="Download one in Host > Models")
|
||||
estimate_box.label(text="Download one in AI Backend > Models")
|
||||
else:
|
||||
estimate_box.prop(self, "snapform_model")
|
||||
estimate_box.prop(self, "snapform_use_ray_pose")
|
||||
@@ -1917,8 +1917,18 @@ class SnapFormImageMenu(bpy.types.Menu):
|
||||
bl_label = "SnapForm"
|
||||
|
||||
def draw(self, _context):
|
||||
from .runtime import runtime_ready
|
||||
|
||||
layout = self.layout
|
||||
layout.operator_context = "INVOKE_DEFAULT"
|
||||
if not runtime_ready():
|
||||
operator = layout.operator(
|
||||
"snapform.run_job",
|
||||
text="Install Backend",
|
||||
icon="IMPORT",
|
||||
)
|
||||
operator.job_type = "INSTALL_RUNTIME"
|
||||
return
|
||||
layout.operator(
|
||||
"snapform.convert_image_to_depth_plane",
|
||||
text="Convert to Depth Plane",
|
||||
|
||||
@@ -22,6 +22,22 @@ BACKGROUND_REMOVAL_JOB = "REMOVE_BACKGROUND"
|
||||
IMAGE_BACKGROUND_JOB = "IMAGE_BACKGROUND"
|
||||
ORIGINAL_BACKGROUND_JOB = "ORIGINAL_BACKGROUND"
|
||||
ORIGINAL_FRAMES_JOB = "ORIGINAL_FRAMES"
|
||||
DA3_IMAGE_JOBS = frozenset({IMAGE_DEPTH_JOB, IMAGE_MESH_JOB})
|
||||
IMAGE_EMPTY_JOBS = frozenset(
|
||||
{
|
||||
*DA3_IMAGE_JOBS,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
}
|
||||
)
|
||||
PRODUCTION_AI_JOBS = frozenset(
|
||||
{
|
||||
*DA3_IMAGE_JOBS,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
}
|
||||
)
|
||||
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
|
||||
IMAGE_DEPTH_PROCESS_RES_METHOD = "upper_bound_resize"
|
||||
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
@@ -134,7 +150,7 @@ class RunJob(bpy.types.Operator):
|
||||
|
||||
job_type: bpy.props.EnumProperty(
|
||||
items=(
|
||||
("INSTALL_RUNTIME", "Install Runtime", ""),
|
||||
("INSTALL_RUNTIME", "Install Backend", ""),
|
||||
("DOWNLOAD_MODEL", "Download Model", ""),
|
||||
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
|
||||
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
|
||||
@@ -242,13 +258,7 @@ class RunJob(bpy.types.Operator):
|
||||
),
|
||||
temporary_input_path=(
|
||||
Path(self.snapform_input_path)
|
||||
if self.job_type in {
|
||||
IMAGE_DEPTH_JOB,
|
||||
IMAGE_MESH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
}
|
||||
if self.job_type in IMAGE_EMPTY_JOBS
|
||||
and self.snapform_delete_input
|
||||
else None
|
||||
),
|
||||
@@ -311,13 +321,7 @@ class RunJob(bpy.types.Operator):
|
||||
paths = runtime.new_job_paths(self.job_type)
|
||||
if (
|
||||
getattr(self, "snapform_delete_input", False)
|
||||
and self.job_type in {
|
||||
IMAGE_DEPTH_JOB,
|
||||
IMAGE_MESH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
}
|
||||
and self.job_type in IMAGE_EMPTY_JOBS
|
||||
):
|
||||
archived_input = runtime.move_input_into_job(
|
||||
self.snapform_input_path,
|
||||
@@ -333,7 +337,7 @@ class RunJob(bpy.types.Operator):
|
||||
return runtime.install_runtime_command(paths["status"]), paths
|
||||
|
||||
if not runtime.runtime_ready():
|
||||
raise RuntimeError("Install the SnapForm Runtime first")
|
||||
raise RuntimeError("Install the SnapForm Backend first")
|
||||
|
||||
if self.job_type == "DOWNLOAD_MODEL":
|
||||
self._require_online_access()
|
||||
@@ -343,13 +347,7 @@ class RunJob(bpy.types.Operator):
|
||||
paths,
|
||||
)
|
||||
|
||||
if self.job_type in {
|
||||
IMAGE_DEPTH_JOB,
|
||||
IMAGE_MESH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
}:
|
||||
if self.job_type in IMAGE_EMPTY_JOBS:
|
||||
configured_input = self.snapform_input_path
|
||||
elif self.job_type == BACKGROUND_REMOVAL_JOB:
|
||||
configured_input = settings.background_input_path
|
||||
@@ -413,7 +411,7 @@ class RunJob(bpy.types.Operator):
|
||||
use_ray_pose = None
|
||||
process_res = None
|
||||
model_key = selected_model_key(settings, export_format)
|
||||
if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}:
|
||||
if self.job_type in DA3_IMAGE_JOBS:
|
||||
model_key = self.snapform_model_key
|
||||
use_ray_pose = self.snapform_use_ray_pose
|
||||
process_res = self.snapform_process_res
|
||||
@@ -421,7 +419,7 @@ class RunJob(bpy.types.Operator):
|
||||
if not runtime.model_ready(model_key):
|
||||
raise RuntimeError(f"Download {model.label} first")
|
||||
if (
|
||||
self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
|
||||
self.job_type in DA3_IMAGE_JOBS
|
||||
and remove_background
|
||||
and not runtime.model_ready(BEN2_MODEL.key)
|
||||
):
|
||||
@@ -441,13 +439,13 @@ class RunJob(bpy.types.Operator):
|
||||
process_res=process_res,
|
||||
process_res_method=(
|
||||
IMAGE_DEPTH_PROCESS_RES_METHOD
|
||||
if self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
|
||||
if self.job_type in DA3_IMAGE_JOBS
|
||||
else None
|
||||
),
|
||||
use_ray_pose=use_ray_pose,
|
||||
start_frame=start_frame,
|
||||
remove_background=(
|
||||
self.job_type in {IMAGE_DEPTH_JOB, IMAGE_MESH_JOB}
|
||||
self.job_type in DA3_IMAGE_JOBS
|
||||
and remove_background
|
||||
),
|
||||
device=self._task_device(settings),
|
||||
@@ -457,12 +455,7 @@ class RunJob(bpy.types.Operator):
|
||||
|
||||
def _task_device(self, settings):
|
||||
"""Debug panels use the scene device; production tasks use Preferences."""
|
||||
if self.job_type in {
|
||||
IMAGE_DEPTH_JOB,
|
||||
IMAGE_MESH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
}:
|
||||
if self.job_type in PRODUCTION_AI_JOBS:
|
||||
return runtime.production_device()
|
||||
return settings.device
|
||||
|
||||
@@ -474,7 +467,7 @@ class RunJob(bpy.types.Operator):
|
||||
|
||||
def _starting_message(self, settings):
|
||||
labels = {
|
||||
"INSTALL_RUNTIME": "Starting Runtime installation",
|
||||
"INSTALL_RUNTIME": "Starting Backend installation",
|
||||
"DOWNLOAD_MODEL": "Starting model download",
|
||||
}
|
||||
if self.job_type == RECONSTRUCTION_JOB:
|
||||
@@ -612,7 +605,7 @@ class RunJob(bpy.types.Operator):
|
||||
settings.status = "Task failed"
|
||||
settings.progress = 0.0
|
||||
fallback_error = (
|
||||
"AI Host task failed"
|
||||
"Backend Service task failed"
|
||||
if job.uses_host
|
||||
else "Task subprocess failed"
|
||||
)
|
||||
@@ -845,15 +838,15 @@ class CancelJob(bpy.types.Operator):
|
||||
|
||||
class ControlHost(bpy.types.Operator):
|
||||
bl_idname = "snapform.control_host"
|
||||
bl_label = "Control SnapForm AI Host"
|
||||
bl_label = "Control SnapForm Backend Service"
|
||||
|
||||
action: bpy.props.EnumProperty(
|
||||
items=(
|
||||
("START", "Start", "Start or connect to the shared AI Host"),
|
||||
("START", "Start", "Start or connect to the Backend Service"),
|
||||
("CLEAR", "Clear Models", "Release cached AI models"),
|
||||
("RESTART", "Restart", "Restart the shared AI Host"),
|
||||
("STOP", "Stop", "Stop the shared AI Host"),
|
||||
("LOG", "Open Log", "Open the AI Host log"),
|
||||
("RESTART", "Restart", "Restart the Backend Service"),
|
||||
("STOP", "Stop", "Stop the Backend Service"),
|
||||
("LOG", "Open Log", "Open the Backend Service log"),
|
||||
),
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
@@ -862,22 +855,22 @@ class ControlHost(bpy.types.Operator):
|
||||
try:
|
||||
if self.action == "START":
|
||||
runtime.enable_host()
|
||||
message = "AI Host is ready"
|
||||
message = "Backend Service is ready"
|
||||
elif self.action == "CLEAR":
|
||||
runtime.clear_host_models()
|
||||
message = "AI model cache is cleared"
|
||||
elif self.action == "RESTART":
|
||||
runtime.restart_host()
|
||||
message = "AI Host is restarting"
|
||||
message = "Backend Service is restarting"
|
||||
elif self.action == "STOP":
|
||||
runtime.stop_host()
|
||||
message = "AI Host is stopping"
|
||||
message = "Backend Service is stopping"
|
||||
else:
|
||||
log_path = runtime.host_log_path()
|
||||
target = log_path if log_path.is_file() else log_path.parent
|
||||
result = bpy.ops.wm.path_open(filepath=str(target))
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Unable to open the AI Host log")
|
||||
raise RuntimeError("Unable to open the Backend Service log")
|
||||
return {"FINISHED"}
|
||||
except (OSError, RuntimeError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
|
||||
@@ -7,7 +7,7 @@ from .runtime import model_ready, runtime_ready
|
||||
|
||||
|
||||
class HostPanel(bpy.types.Panel):
|
||||
bl_label = "Host"
|
||||
bl_label = "AI Backend"
|
||||
bl_idname = "SNAPFORM_PT_host"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
@@ -18,12 +18,15 @@ class HostPanel(bpy.types.Panel):
|
||||
layout = self.layout
|
||||
running = active_job() is not None or runtime.host_busy()
|
||||
if not runtime_ready():
|
||||
layout.label(text="Runtime is not installed", icon="ERROR")
|
||||
layout.label(
|
||||
text="Backend Environment is not installed",
|
||||
icon="ERROR",
|
||||
)
|
||||
install_row = layout.row()
|
||||
install_row.enabled = not running
|
||||
operator = install_row.operator(
|
||||
"snapform.run_job",
|
||||
text="Install Runtime",
|
||||
text="Install Backend",
|
||||
icon="IMPORT",
|
||||
)
|
||||
operator.job_type = "INSTALL_RUNTIME"
|
||||
|
||||
@@ -11,7 +11,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
|
||||
storage_root: bpy.props.StringProperty(
|
||||
name="Storage Root",
|
||||
description=(
|
||||
"Shared user-writable storage for Runtime, models and jobs; "
|
||||
"Shared user-writable storage for Backend Environment, models and jobs; "
|
||||
"all Blender versions can use the same directory"
|
||||
),
|
||||
subtype="DIR_PATH",
|
||||
@@ -35,22 +35,22 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
|
||||
layout.prop(self, "storage_root")
|
||||
layout.prop(self, "device")
|
||||
layout.separator()
|
||||
layout.label(text=f"Runtime: {runtime_directory()}")
|
||||
layout.label(text=f"Backend Environment: {runtime_directory()}")
|
||||
if runtime_ready():
|
||||
layout.label(text="Runtime is ready", icon="CHECKMARK")
|
||||
layout.label(text="Backend Environment is ready", icon="CHECKMARK")
|
||||
else:
|
||||
install_row = layout.row()
|
||||
install_row.enabled = active_job() is None
|
||||
operator = install_row.operator(
|
||||
"snapform.run_job",
|
||||
text="Install Runtime",
|
||||
text="Install Backend",
|
||||
icon="IMPORT",
|
||||
)
|
||||
operator.job_type = "INSTALL_RUNTIME"
|
||||
layout.separator()
|
||||
operator = layout.operator(
|
||||
"snapform.control_host",
|
||||
text="Open Host Log",
|
||||
text="Open Service Log",
|
||||
icon="TEXT",
|
||||
)
|
||||
operator.action = "LOG"
|
||||
|
||||
@@ -28,8 +28,9 @@ from .models import (
|
||||
|
||||
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
|
||||
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
|
||||
RUNTIME_SCHEMA = 4
|
||||
HOST_SCHEMA = 2
|
||||
ENVIRONMENT_MANIFEST_REVISION = 4
|
||||
JOB_PROTOCOL_VERSION = 2
|
||||
ENVIRONMENT_MANIFEST_FILENAME = "environment_manifest.json"
|
||||
HOST_ADDRESS = "127.0.0.1"
|
||||
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
|
||||
JOB_FOLDER_NAMES = {
|
||||
@@ -103,14 +104,17 @@ def runtime_python():
|
||||
|
||||
|
||||
def runtime_ready():
|
||||
ready_path = runtime_directory() / "snapform_ready.json"
|
||||
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
|
||||
if not runtime_python().is_file() or not ready_path.is_file():
|
||||
return False
|
||||
try:
|
||||
ready = json.loads(ready_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
return ready.get("runtime_schema") == RUNTIME_SCHEMA
|
||||
return (
|
||||
ready.get("environment_manifest_revision")
|
||||
== ENVIRONMENT_MANIFEST_REVISION
|
||||
)
|
||||
|
||||
|
||||
def models_directory():
|
||||
@@ -140,8 +144,8 @@ def model_ready(model_key):
|
||||
|
||||
|
||||
def runtime_devices():
|
||||
"""Devices detected by the installed Runtime, or None when unknown."""
|
||||
ready_path = runtime_directory() / "snapform_ready.json"
|
||||
"""Devices detected by the Backend Environment, or None when unknown."""
|
||||
ready_path = runtime_directory() / ENVIRONMENT_MANIFEST_FILENAME
|
||||
if not ready_path.is_file():
|
||||
return None
|
||||
try:
|
||||
@@ -155,7 +159,7 @@ def runtime_devices():
|
||||
|
||||
|
||||
def machine_devices():
|
||||
"""Devices the machine can run, fastest first; used before Runtime install."""
|
||||
"""Devices the machine can run before Backend Environment installation."""
|
||||
devices = ["cpu"]
|
||||
if shutil.which("nvidia-smi"):
|
||||
devices.insert(0, "cuda")
|
||||
@@ -329,9 +333,11 @@ def _host_http_request(connection, method, path, payload=None, timeout=2.0):
|
||||
detail = json.loads(error.read().decode("utf-8")).get("detail")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
detail = None
|
||||
raise RuntimeError(detail or f"AI Host returned HTTP {error.code}")
|
||||
raise RuntimeError(
|
||||
detail or f"Backend Service returned HTTP {error.code}"
|
||||
)
|
||||
except (OSError, URLError) as error:
|
||||
raise RuntimeError(f"Unable to reach AI Host: {error}")
|
||||
raise RuntimeError(f"Unable to reach Backend Service: {error}")
|
||||
|
||||
|
||||
def _read_host_discovery():
|
||||
@@ -354,10 +360,10 @@ def _check_host(connection, timeout=0.3):
|
||||
"/health",
|
||||
timeout=timeout,
|
||||
)
|
||||
if health.get("host_schema") != HOST_SCHEMA:
|
||||
raise RuntimeError("AI Host protocol is out of date")
|
||||
if health.get("job_protocol_version") != JOB_PROTOCOL_VERSION:
|
||||
raise RuntimeError("Backend Service job protocol is out of date")
|
||||
if health.get("instance_id") != connection.instance_id:
|
||||
raise RuntimeError("AI Host discovery is stale")
|
||||
raise RuntimeError("Backend Service discovery is stale")
|
||||
return health
|
||||
|
||||
|
||||
@@ -439,7 +445,9 @@ def ensure_host(timeout=60.0):
|
||||
start_host()
|
||||
last_launch = time.monotonic()
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f"AI Host did not become ready; log: {host_log_path()}")
|
||||
raise RuntimeError(
|
||||
f"Backend Service did not become ready; log: {host_log_path()}"
|
||||
)
|
||||
|
||||
|
||||
def submit_host_job(request):
|
||||
@@ -485,7 +493,7 @@ def stop_host():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("AI Host did not stop")
|
||||
raise RuntimeError("Backend Service did not stop")
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
_close_finished_launched_host()
|
||||
@@ -563,7 +571,7 @@ def detach_host_client():
|
||||
pass
|
||||
_host_connection = None
|
||||
_registered_instance_id = None
|
||||
# 如果 Host 是由当前 Blender 启动的,关闭 Blender 时直接终止它,无需等待空闲超时
|
||||
# 当前 Blender 启动的 Service 随 Blender 关闭,不等待空闲超时。
|
||||
_close_launched_host(terminate=True)
|
||||
|
||||
|
||||
@@ -616,7 +624,7 @@ def _service_host_timer():
|
||||
if not runtime_ready():
|
||||
_host_snapshot = {
|
||||
"state": "UNAVAILABLE",
|
||||
"message": "Runtime is not installed",
|
||||
"message": "Backend Environment is not installed",
|
||||
}
|
||||
return 2.0
|
||||
connection, health = _discover_live_host(timeout=0.2)
|
||||
|
||||
@@ -1282,7 +1282,7 @@ def infer_foreground_frames(
|
||||
|
||||
def remove_background(args, model_cache=None, cancel_check=None):
|
||||
total_started = time.perf_counter()
|
||||
write_status(args.status, 0.02, "Starting BEN2 Runtime")
|
||||
write_status(args.status, 0.02, "Starting Backend Environment")
|
||||
check_cancelled(cancel_check)
|
||||
|
||||
if model_cache is None:
|
||||
@@ -1501,7 +1501,7 @@ def flatten_exported_paths(
|
||||
|
||||
|
||||
def reconstruct(args, model_cache=None, cancel_check=None):
|
||||
write_status(args.status, 0.02, "Starting AI runtime")
|
||||
write_status(args.status, 0.02, "Starting Backend Environment")
|
||||
check_cancelled(cancel_check)
|
||||
|
||||
if model_cache is None:
|
||||
|
||||
@@ -43,10 +43,15 @@ class AddonTest(unittest.TestCase):
|
||||
append=lambda draw: cls.context_menu_draws.append(draw),
|
||||
remove=lambda draw: cls.context_menu_draws.remove(draw),
|
||||
),
|
||||
OUTLINER_MT_object=SimpleNamespace(
|
||||
append=lambda draw: cls.outliner_menu_draws.append(draw),
|
||||
remove=lambda draw: cls.outliner_menu_draws.remove(draw),
|
||||
),
|
||||
)
|
||||
cls.registered = []
|
||||
cls.status_bar_draws = []
|
||||
cls.context_menu_draws = []
|
||||
cls.outliner_menu_draws = []
|
||||
fake_bpy.utils = SimpleNamespace(
|
||||
register_class=cls.registered.append,
|
||||
unregister_class=cls.registered.remove,
|
||||
@@ -80,6 +85,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.registered.clear()
|
||||
self.status_bar_draws.clear()
|
||||
self.context_menu_draws.clear()
|
||||
self.outliner_menu_draws.clear()
|
||||
self.addon.operators._active_job = None
|
||||
self.addon.image_depth._last_depth_plane_model = "BASE"
|
||||
self.addon.image_mesh._last_mesh_model = "BASE"
|
||||
@@ -107,6 +113,10 @@ class AddonTest(unittest.TestCase):
|
||||
self.context_menu_draws,
|
||||
[self.addon.draw_image_context_menu],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.outliner_menu_draws,
|
||||
[self.addon.draw_image_context_menu],
|
||||
)
|
||||
|
||||
self.addon.unregister()
|
||||
|
||||
@@ -116,6 +126,7 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(self.status_bar_draws, [])
|
||||
self.assertEqual(self.context_menu_draws, [])
|
||||
self.assertEqual(self.outliner_menu_draws, [])
|
||||
|
||||
def test_registers_depth_plane_operator_without_cache_rebuild(self):
|
||||
self.assertIn(
|
||||
@@ -149,6 +160,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
menu = self.addon.SnapFormImageMenu()
|
||||
menu.layout = Layout()
|
||||
with patch.object(self.addon.runtime, "runtime_ready", return_value=True):
|
||||
menu.draw(None)
|
||||
|
||||
self.assertEqual(
|
||||
@@ -161,6 +173,34 @@ class AddonTest(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_image_context_menu_only_installs_backend_when_missing(self):
|
||||
calls = []
|
||||
|
||||
class Operator:
|
||||
job_type = None
|
||||
|
||||
class Layout:
|
||||
operator_context = None
|
||||
|
||||
def operator(self, identifier, **options):
|
||||
operator = Operator()
|
||||
calls.append((identifier, options, operator))
|
||||
return operator
|
||||
|
||||
def separator(self):
|
||||
calls.append("separator")
|
||||
|
||||
menu = self.addon.SnapFormImageMenu()
|
||||
menu.layout = Layout()
|
||||
with patch.object(self.addon.runtime, "runtime_ready", return_value=False):
|
||||
menu.draw(None)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
identifier, options, operator = calls[0]
|
||||
self.assertEqual(identifier, "snapform.run_job")
|
||||
self.assertEqual(options["text"], "Install Backend")
|
||||
self.assertEqual(operator.job_type, "INSTALL_RUNTIME")
|
||||
|
||||
def test_image_mesh_reference_plane_matches_image_empty_bounds(self):
|
||||
source_object = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(401, 201)),
|
||||
@@ -1945,13 +1985,13 @@ class AddonTest(unittest.TestCase):
|
||||
def test_host_panel_exposes_shared_host_state_and_controls(self):
|
||||
self.assertIn(self.addon.HostPanel, self.addon.CLASSES)
|
||||
self.assertIn(self.addon.ControlHost, self.addon.CLASSES)
|
||||
self.assertEqual(self.addon.HostPanel.bl_label, "Host")
|
||||
self.assertEqual(self.addon.HostPanel.bl_label, "AI Backend")
|
||||
|
||||
def test_ai_operation_restarts_a_manually_stopped_host(self):
|
||||
runtime = self.addon.runtime
|
||||
connection = runtime.HostConnection(51234, "token", 42, "host")
|
||||
health = {
|
||||
"host_schema": runtime.HOST_SCHEMA,
|
||||
"job_protocol_version": runtime.JOB_PROTOCOL_VERSION,
|
||||
"instance_id": "host",
|
||||
"busy": False,
|
||||
"clients": 1,
|
||||
|
||||
@@ -689,7 +689,7 @@ class BootstrapRuntimeTest(unittest.TestCase):
|
||||
bootstrap.install(args)
|
||||
|
||||
ready = json.loads(
|
||||
(runtime_dir / "snapform_ready.json").read_text(
|
||||
(runtime_dir / "environment_manifest.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
@@ -730,7 +730,9 @@ class BootstrapRuntimeTest(unittest.TestCase):
|
||||
bootstrap.install(args)
|
||||
|
||||
self.assertFalse((runtime_dir / "partial-python.exe").exists())
|
||||
self.assertTrue((runtime_dir / "snapform_ready.json").is_file())
|
||||
self.assertTrue(
|
||||
(runtime_dir / "environment_manifest.json").is_file()
|
||||
)
|
||||
|
||||
def test_ready_runtime_is_not_cleaned(self):
|
||||
bootstrap = load_bootstrap_runtime()
|
||||
@@ -741,8 +743,14 @@ class BootstrapRuntimeTest(unittest.TestCase):
|
||||
self.create_bundled_vendor(root)
|
||||
(runtime_dir / "Scripts").mkdir()
|
||||
(runtime_dir / "Scripts" / "python.exe").write_text("python")
|
||||
(runtime_dir / "snapform_ready.json").write_text(
|
||||
json.dumps({"runtime_schema": bootstrap.RUNTIME_SCHEMA}),
|
||||
(runtime_dir / "environment_manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"environment_manifest_revision": (
|
||||
bootstrap.ENVIRONMENT_MANIFEST_REVISION
|
||||
)
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(runtime_dir / "marker.txt").write_text("keep")
|
||||
@@ -804,12 +812,14 @@ class BootstrapRuntimeTest(unittest.TestCase):
|
||||
):
|
||||
bootstrap.install(args)
|
||||
|
||||
self.assertTrue((runtime_dir / "snapform_ready.json").is_file())
|
||||
self.assertTrue(
|
||||
(runtime_dir / "environment_manifest.json").is_file()
|
||||
)
|
||||
status = json.loads(
|
||||
(root / "status.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(status["progress"], 1.0)
|
||||
self.assertIn("Runtime is ready", status["message"])
|
||||
self.assertIn("Backend Environment is ready", status["message"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
"PramaLLC/BEN2" in map(str, command)
|
||||
@@ -1208,7 +1218,10 @@ class BootstrapRuntimeTest(unittest.TestCase):
|
||||
|
||||
messages = [call.args[2] for call in mocked_write.call_args_list]
|
||||
self.assertTrue(
|
||||
any("Checking Runtime" in message for message in messages)
|
||||
any(
|
||||
"Checking Backend Environment" in message
|
||||
for message in messages
|
||||
)
|
||||
)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
|
||||
@@ -708,7 +708,7 @@ def build_depth_plane_group():
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
)
|
||||
background_depth.default_value = 0.0
|
||||
background_depth.default_value = -1.0
|
||||
background_depth.min_value = -10000.0
|
||||
background_depth.max_value = 10000.0
|
||||
background_depth.subtype = "DISTANCE"
|
||||
|
||||
@@ -29,6 +29,7 @@ def main():
|
||||
)
|
||||
assert background_depth.subtype == "DISTANCE"
|
||||
assert background_depth.structure_type == "SINGLE"
|
||||
assert background_depth.default_value == -1.0
|
||||
frame_input = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
|
||||
Reference in New Issue
Block a user