diff --git a/README.md b/README.md index 71b3eaa..51883c2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一 2. 打开 Blender,进入 `Preferences > Extensions` 3. 点击 `Install from Disk`,选择下载的压缩包 4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机 - 最快的 DirectML/CoreML provider,否则 CPU + 最快的 DirectML provider;Apple M4 或更新芯片可选 MPS/CoreML,其他 Mac 只提供 CPU 5. 打开 3D 视图右侧的 `SnapForm` 面板:使用官方源点击 `Install Environment`,使用国内镜像点击 `Install Environment (Mirror)` 扩展不内置 uv 或 AI 运行库。首次使用会先下载并校验固定版本的 uv,随后自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和 @@ -68,7 +68,7 @@ Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点 当前此功能仅支持单张图片。 MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base 与 BEN2 全部使用 ONNX。模型执行、相机与深度恢复、 -点云重投影和网格生成均不依赖 PyTorch。Windows 使用 DirectML 加速,macOS 使用 CoreML 的 `MLProgram` 模型格式;provider 不可用时会回退到 ONNX Runtime CPU, +点云重投影和网格生成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU, 并在任务状态中显示实际 provider。 转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示 diff --git a/docs/server.md b/docs/server.md index a5b9d33..87c7f78 100644 --- a/docs/server.md +++ b/docs/server.md @@ -66,7 +66,7 @@ ONNX 实现位于 `models/`;纯投影、网格与唯一 Surface Builder 位于 运行环境不安装 PyTorch、TorchVision、xFormers,也不安装 DA3、MoGe、BEN2 或 utils3d 的 Python 源码包。模型推理和 SnapForm 所需的相机、点云、网格后处理均由 ONNX Runtime、NumPy 与内部实现完成。 -Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExecutionProvider`。显式指定不可用设备时会安全回退到 CPU。其他平台安装标准 ONNX Runtime,并按可用 provider 选择 CoreML、OpenVINO 或 CPU。macOS 的 CoreML Session 使用 `MLProgram` 模型格式与全部可用计算单元,允许动态输入 Shape;未被 CoreML 接管的节点继续由 CPU 执行。 +Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExecutionProvider`。显式指定不可用设备时会安全回退到 CPU。其他平台安装标准 ONNX Runtime,并按可用 provider 选择 CoreML、OpenVINO 或 CPU。Blender 侧通过 `sysctl` 识别 Apple Silicon 代际,只有 M4 或更新芯片才提供 MPS 选项;其他 Mac 只提供 CPU。M4 或更新芯片的 CoreML Session 使用 `MLProgram` 模型格式、全部可用计算单元并允许动态输入 Shape。 `environment.json` 保存规范化后的声明,`manifest.json` 保存该声明与基础 Server packages 的 Hash。Python、packages、平台 packages、基础 Server packages 或 Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。BlendJob 直接随 Extension 更新,不参与 Environment Hash。 diff --git a/pyproject.toml b/pyproject.toml index b61d7d2..2ddcca2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "snapform" -version = "0.12.14" +version = "0.12.15" description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11" diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 0d4db20..c2ec8bc 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "snapform" -version = "0.12.14" +version = "0.12.15" name = "SnapForm" tagline = "Build spatial structures from images, fast" maintainer = "OmooLab" diff --git a/src/snapform/properties.py b/src/snapform/properties.py index c9980ae..faae138 100644 --- a/src/snapform/properties.py +++ b/src/snapform/properties.py @@ -1,4 +1,7 @@ +import functools import platform +import re +import subprocess import sys import bpy @@ -21,18 +24,50 @@ DEVICE_LABELS = { } -def supported_devices(): - devices = runtime.environment_manifest().get("devices") - if isinstance(devices, list) and devices: - return [str(device).lower() for device in devices] - if sys.platform == "win32": - return ["directml", "cpu"] - if sys.platform == "darwin" and platform.machine().lower() in { +APPLE_SILICON_PATTERN = re.compile(r"\bApple M(\d+)\b", re.IGNORECASE) + + +def parse_apple_silicon_generation(brand): + match = APPLE_SILICON_PATTERN.search(str(brand)) + return int(match.group(1)) if match else None + + +@functools.lru_cache(maxsize=1) +def apple_silicon_generation(): + if sys.platform != "darwin" or platform.machine().lower() not in { "arm64", "aarch64", }: - return ["mps", "cpu"] - return ["cpu"] + return None + try: + result = subprocess.run( + ("sysctl", "-n", "machdep.cpu.brand_string"), + check=True, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + return parse_apple_silicon_generation(result.stdout) + + +def supports_mps(generation=None): + if generation is None: + generation = apple_silicon_generation() + return generation is not None and generation >= 4 + + +def supported_devices(): + devices = runtime.environment_manifest().get("devices") + if isinstance(devices, list) and devices: + names = [str(device).lower() for device in devices] + if not supports_mps(): + names = [name for name in names if name != "mps"] + return names or ["cpu"] + if sys.platform == "win32": + return ["directml", "cpu"] + return ["mps", "cpu"] if supports_mps() else ["cpu"] def device_enum_items(_owner, _context): diff --git a/tests/test_addon.py b/tests/test_addon.py index 7a53d39..fead88c 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -124,6 +124,53 @@ class AddonTest(unittest.TestCase): self.assertFalse((SOURCE_ROOT / "snapform" / name).exists(), name) self.assertFalse(hasattr(self.addon, "client")) + def test_mps_requires_m4_or_newer(self): + properties = self.addon.properties + parse = properties.parse_apple_silicon_generation + self.assertEqual(parse("Apple M2"), 2) + self.assertEqual(parse("Apple M4 Pro"), 4) + self.assertEqual(parse("Apple M10 Max"), 10) + self.assertIsNone(parse("Intel(R) Core(TM) i9")) + self.assertFalse(properties.supports_mps(3)) + self.assertTrue(properties.supports_mps(4)) + + def test_unsupported_mac_hides_manifest_mps_device(self): + properties = self.addon.properties + manifest = {"devices": ["mps", "cpu"]} + with ( + patch.object( + self.addon.runtime, + "environment_manifest", + return_value=manifest, + ), + patch.object(properties, "supports_mps", return_value=False), + ): + self.assertEqual(properties.supported_devices(), ["cpu"]) + with ( + patch.object( + self.addon.runtime, + "environment_manifest", + return_value=manifest, + ), + patch.object(properties, "supports_mps", return_value=True), + ): + self.assertEqual(properties.supported_devices(), ["mps", "cpu"]) + + def test_windows_keeps_default_directml_device(self): + properties = self.addon.properties + with ( + patch.object( + self.addon.runtime, + "environment_manifest", + return_value={}, + ), + patch.object(properties.sys, "platform", "win32"), + ): + self.assertEqual( + properties.supported_devices(), + ["directml", "cpu"], + ) + def test_import_does_not_add_addon_directories_to_sys_path(self): self.assertEqual(self.paths_after_import, self.paths_before_import) self.assertFalse( diff --git a/uv.lock b/uv.lock index 82d1cfc..b909a91 100644 --- a/uv.lock +++ b/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "snapform" -version = "0.12.14" +version = "0.12.15" source = { virtual = "." } dependencies = [ { name = "blendjob" },