From 5a011b3b57d157b75c4b7ddd70efc925e2ece3c7 Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 2 Sep 2026 10:50:02 +0800 Subject: [PATCH] feat: Update CoreML device support and server controls - Replace 'mps' with 'coreml' as the identifier for Apple CoreML Execution Provider across the codebase. - Remove Apple chip generation checks and ensure CoreML is available on all macOS arm64 devices. - Adjust ONNX Runtime session creation to use default CoreML configurations without forcing 'MLProgram'. - Implement support for MoGe-2 to utilize CoreML with static input shape requirements. - Enhance server panel to display restart and stop controls consistently across running states, including 'BUSY'. - Update UI tests to verify new behavior for server controls and cached model display. - Bump blendjob dependency version to 0.1.16 in packaging tests and configurations. --- docs/getting-started/installation.md | 4 +- docs/internals/runtime.md | 2 +- .../.openspec.yaml | 2 + .../correct-coreml-device-support/design.md | 68 +++++++ .../correct-coreml-device-support/proposal.md | 30 +++ .../specs/coreml-inference/spec.md | 69 +++++++ .../correct-coreml-device-support/tasks.md | 18 ++ .../.openspec.yaml | 2 + .../design.md | 40 ++++ .../proposal.md | 27 +++ .../specs/server-cache-display/spec.md | 16 ++ .../specs/server-lifecycle-controls/spec.md | 16 ++ .../tasks.md | 10 + pyproject.toml | 2 +- src/anyimage/blender_manifest.toml | 2 +- src/anyimage/panel.py | 50 +++-- src/anyimage/properties.py | 52 ++--- src/anyimage/server/models/onnx_moge2.py | 9 +- src/anyimage/server/models/onnx_runtime.py | 28 +-- tests/test_blender_addon.py | 192 ++++++++++++++++-- tests/test_extension_packaging.py | 12 +- tests/test_model_moge2_onnx.py | 27 +-- tests/test_model_onnx_runtime.py | 49 ++++- uv.lock | 9 +- 24 files changed, 619 insertions(+), 117 deletions(-) create mode 100644 openspec/changes/correct-coreml-device-support/.openspec.yaml create mode 100644 openspec/changes/correct-coreml-device-support/design.md create mode 100644 openspec/changes/correct-coreml-device-support/proposal.md create mode 100644 openspec/changes/correct-coreml-device-support/specs/coreml-inference/spec.md create mode 100644 openspec/changes/correct-coreml-device-support/tasks.md create mode 100644 openspec/changes/show-server-controls-while-running/.openspec.yaml create mode 100644 openspec/changes/show-server-controls-while-running/design.md create mode 100644 openspec/changes/show-server-controls-while-running/proposal.md create mode 100644 openspec/changes/show-server-controls-while-running/specs/server-cache-display/spec.md create mode 100644 openspec/changes/show-server-controls-while-running/specs/server-lifecycle-controls/spec.md create mode 100644 openspec/changes/show-server-controls-while-running/tasks.md diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b8a0928..581e8b5 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -20,7 +20,9 @@ AnyImage 的安装分为两步:先安装 Blender Extension,再安装独立 AnyImage 的扩展偏好设置按功能分组。Environment 区包含: - **Storage Root**:保存运行环境、模型和任务文件的位置。默认是用户目录下的 `.anyimage`,一般无需修改。 -- **Device**:正式转换任务使用的计算设备。AnyImage 默认选择本机可用的最快设备;如果 GPU Provider 不可用,会使用 CPU。 +- **Device**:正式转换任务使用的计算设备。Apple Silicon Mac 默认使用 CPU,Windows 默认使用 DirectML;也可手动选择其他可用设备。 + +macOS arm64 提供 **CPU** 与 **CoreML**。CoreML 由系统在 Apple CPU、GPU 与 Neural Engine 之间调度,不是 PyTorch MPS backend,也不表示只使用 GPU;它的实际速度取决于模型。 Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中: diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md index 25443aa..dbad813 100644 --- a/docs/internals/runtime.md +++ b/docs/internals/runtime.md @@ -64,7 +64,7 @@ Depth Plane、Cutout Depth 与 Normal Map 使用 MoGe-2,Remove Background 与 `ModelManager` 按模型族管理下载目录与 ONNX Session,同一族只保留最近使用的模型和 Device,避免同类模型重复占用资源。 -BEN2、DA3、MoGe-2 与 Upscale 共用 ONNX Runtime 推理入口。CUDA Session 使用可收缩的 memory arena;单次图片、完整帧集合或完整 Upscale tile 集合只在最后一次模型运行结束时收缩空闲 arena。Session 持有的模型权重与执行计划继续由 `ModelManager` 缓存,后续相同模型与 Device 直接复用。CPU、DirectML 与 CoreML 按各自 Provider 的普通运行方式执行。 +BEN2、DA3、MoGe-2 与 Upscale 共用 ONNX Runtime 推理入口。CUDA Session 使用可收缩的 memory arena;单次图片、完整帧集合或完整 Upscale tile 集合只在最后一次模型运行结束时收缩空闲 arena。Session 持有的模型权重与执行计划继续由 `ModelManager` 缓存,后续相同模型与 Device 直接复用。CPU、DirectML 与 CoreML 按各自 Provider 的普通运行方式执行。macOS arm64 默认使用 CPU;用户可选择 `coreml`,由 ONNX Runtime CoreML Execution Provider 在 Apple CPU、GPU 与 Neural Engine 之间调度,不使用 PyTorch MPS backend。MoGe-2 的动态 ONNX 图只把静态输入 shape 分区交给 CoreML,其余节点由 CPU Provider 执行;其他模型不使用该限制。 ```mermaid flowchart TD diff --git a/openspec/changes/correct-coreml-device-support/.openspec.yaml b/openspec/changes/correct-coreml-device-support/.openspec.yaml new file mode 100644 index 0000000..b4b3ece --- /dev/null +++ b/openspec/changes/correct-coreml-device-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/openspec/changes/correct-coreml-device-support/design.md b/openspec/changes/correct-coreml-device-support/design.md new file mode 100644 index 0000000..b625719 --- /dev/null +++ b/openspec/changes/correct-coreml-device-support/design.md @@ -0,0 +1,68 @@ +## Context + +AnyImage 的 ONNX 推理把设备值 `mps` 映射到 `CoreMLExecutionProvider`,但该名称通常指 PyTorch Metal backend,与实际实现不一致。Blender 端还通过 CPU brand string 将 Apple 加速限制为 M4 及以上,而 macOS 扩展本身只发布 `macos-arm64`,支持范围已经限定为 Apple Silicon。 + +共享 Session 工厂目前为所有 CoreML 模型强制传入 `ModelFormat=MLProgram`。MoGe-2 的 ONNX 图具有动态空间尺寸、动态 `num_tokens`、控制流和大量 shape 运算,CoreML 在 MacBook M4 上会在 Session 创建阶段因执行计划构建失败;加入该配置前,同一 Provider 使用默认模型格式可以运行。 + +## Goals / Non-Goals + +**Goals:** + +- 让设备名称准确表达 CoreML Execution Provider。 +- 在全部受支持的 macOS arm64 设备上提供 CoreML。 +- 恢复 MoGe-2 在 MacBook M4 CoreML 模式下的 Session 创建与推理。 +- 保持共享 ONNX Session 工厂简单,并保留 CPU fallback。 + +**Non-Goals:** + +- 引入 PyTorch 或实现 PyTorch MPS backend。 +- 保证所有 CoreML 算子只在 GPU 上执行。 +- 支持 macOS x64、iOS 或新的模型格式。 +- 保留 `mps` 任务值、配置值或转发映射。 + +## Decisions + +### 1. 使用 `coreml` 作为唯一 Apple 加速设备标识 + +Blender Enum、Job 参数、运行时设备清单和 Provider 选择表统一使用小写 `coreml`,界面显示 `CoreML`。旧 `mps` 标识直接删除,因为它描述的是不同的推理 API;增加别名会继续扩大协议边界并违反项目不保留兼容层的约束。 + +### 2. 按平台能力判断 CoreML,不按芯片代际判断 + +macOS 且机器架构为 `arm64` 或 `aarch64` 时提供 `coreml` 和 `cpu`,其他 Unix 平台只提供 `cpu`,Windows 维持 DirectML 与 CPU。删除 CPU brand string 正则、`sysctl` 子进程和 M4 代际测试。这样支持范围与 `macos-arm64` 扩展包以及 ONNX Runtime macOS arm64 wheel 一致。 + +保留 M4 门槛的替代方案被放弃,因为它不是 Provider 能力判断,只会让 M1–M3 在具备 CoreML 时被迫使用 CPU。 + +### 3. 默认 CoreML 配置与 MoGe-2 静态分区配置分离 + +共享 Session 工厂默认向 ONNX Runtime 传递普通的 `CoreMLExecutionProvider` 名称,不再为所有模型强制 `MLProgram`。它同时接受可选的模型级 CoreML options;只有调用方明确提供时才把 CoreML Provider 配置为 tuple。CUDA 的独立 arena 配置保持不变。 + +MoGe-2 传入 `RequireStaticInputShapes=1`,使 CoreML EP 只接管输入 shape 已静态确定的分区,动态空间尺寸、动态 `num_tokens` 和相关控制流留给 CPU fallback。BEN2、DA3 与 Upscale 不携带该选项,继续使用普通 CoreML 配置。 + +该决定取代“所有模型都使用普通 CoreML 配置”的原方案:M2 真机证明 NeuralNetwork 格式虽然能创建 MoGe-2 Session,但动态 CoreML 分区仍会在执行时失败。也不选择 `CPUAndGPU`,因为故障来自动态图兼容性,而不是 Neural Engine 选择;不选择整体强制 CPU,因为静态限制仍允许安全分区使用 CoreML。 + +### 4. macOS 界面默认 CPU,CoreML Session 保持 CPU fallback + +macOS arm64 的设备枚举把 CPU 放在 CoreML 之前,使新安装和未保存设备选择时默认使用 CPU。CoreML 的性能取决于模型和设备,用户需要时可手动选择,不根据芯片代际自动启用。 + +显式选择 `coreml` 时仍以 `CoreMLExecutionProvider` 为首选并显式附带 `CPUExecutionProvider`;共享 Session 工厂的自动 Provider 顺序也保持不变。这样 MoGe-2 被静态限制排除的动态节点具有确定的 CPU fallback;明确选择 CPU 时则只请求 CPU Provider。 + +## Risks / Trade-offs + +- [CoreML 默认配置在未来 ONNX Runtime 版本中可能变化] → 固定 ONNX Runtime 版本,并以 Session 参数测试保证 AnyImage 不再主动强制 `MLProgram`。 +- [MoGe-2 静态分区限制可能减少 CoreML 加速比例] → 接受 CPU fallback 以换取正确性,并用真机 smoke test 验证;不把 CoreML 设备描述为纯 GPU。 +- [M1–M3 未覆盖真实设备集成测试] → 单元测试覆盖平台枚举,并在可用 Apple Silicon 设备上执行 MoGe-2 smoke test;真实失败仍可选择 CPU。 +- [旧 Blender 配置保存了 `MPS`] → 这是明确的 breaking rename;不解析旧值,用户在新版中选择 `CoreML` 或 `CPU`。 +- [CoreML 不保证纯 GPU] → 界面和文档只承诺 CoreML,不再使用 Metal/MPS 或纯 GPU 表述。 + +## Migration Plan + +1. 同步替换设备协议与界面枚举中的 `mps` 为 `coreml`,删除芯片代际检测。 +2. 移除共享的强制 CoreML options,让普通模型使用默认配置,并让 MoGe-2 单独传入 `RequireStaticInputShapes=1`。 +3. 更新测试和内部文档,再运行相关单元测试。 +4. 在可用的 M2 或 M4 设备选择 CoreML,分别执行 MoGe-2、BEN2 和 Upscale smoke test;MoGe-2 必须完成 Session 创建和一次推理。 + +若真实设备验证失败,回滚本变更并使用 CPU;不重新引入 `mps` 兼容层。 + +## Open Questions + +无。 diff --git a/openspec/changes/correct-coreml-device-support/proposal.md b/openspec/changes/correct-coreml-device-support/proposal.md new file mode 100644 index 0000000..79ce15e --- /dev/null +++ b/openspec/changes/correct-coreml-device-support/proposal.md @@ -0,0 +1,30 @@ +## Why + +macOS 设备当前以 `mps` 表示实际的 ONNX Runtime CoreML Execution Provider,并人为限制为 M4 及以上;同时强制使用 `MLProgram` 会使具有动态输入和控制流的 MoGe-2 在 MacBook M4 上创建模型时触发 `Error in building plan`。需要让设备名称、支持范围和实际推理后端一致,并恢复 MoGe-2 可用性。 + +## What Changes + +- **BREAKING**:将公开任务参数、运行时设备清单和界面枚举中的设备标识由 `mps` 改为 `coreml`,不保留旧标识兼容层。 +- macOS arm64 构建对所有 Apple Silicon 设备提供 CoreML 与 CPU 选项,移除 Apple 芯片代际解析和 M4 最低代际限制。 +- Apple Silicon Mac 默认使用 CPU,CoreML 保留为用户手动选择的可选设备。 +- 通用 CoreML Session 使用 ONNX Runtime 的兼容默认模型格式,不再强制把所有模型转换为 `MLProgram`。 +- MoGe-2 为 CoreML 单独启用静态输入分区限制,只将静态 shape 分区交给 CoreML,并把其余节点交给 CPU Execution Provider。 +- 显式保留 CoreML 之后的 CPU fallback,并验证 MoGe-2、BEN2 与 Upscale 的设备选择不回归。 +- 同步更新设备相关测试和内部文档,使 `coreml` 明确表示 CoreML 调度的 Apple CPU、GPU 与 Neural Engine,而不是 PyTorch MPS backend。 + +## Capabilities + +### New Capabilities + +- `coreml-inference`: 规定 macOS arm64 的 CoreML 设备标识、支持范围、Provider 选择和 MoGe-2 兼容行为。 + +### Modified Capabilities + +无。 + +## Impact + +- 影响 Blender 设备枚举、Job 参数、运行时 Manifest 和 ONNX Runtime Session 创建。 +- 影响 `properties.py`、`server/models/onnx_runtime.py`、`server/models/onnx_moge2.py` 及对应测试与内部文档。 +- `mps` 设备值会被删除;尚未完成或外部构造的旧任务参数必须改用 `coreml`。 +- 不新增依赖,不改变模型文件、模型输出协议或 Blender Operator 标识符。 diff --git a/openspec/changes/correct-coreml-device-support/specs/coreml-inference/spec.md b/openspec/changes/correct-coreml-device-support/specs/coreml-inference/spec.md new file mode 100644 index 0000000..6260439 --- /dev/null +++ b/openspec/changes/correct-coreml-device-support/specs/coreml-inference/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: CoreML 使用唯一设备标识 +系统 SHALL 在界面、任务参数、运行时设备清单和 ONNX Provider 选择中使用 `coreml` 表示 Apple CoreML Execution Provider,并 SHALL NOT 接受或生成 `mps` 设备标识。 + +#### Scenario: macOS 用户查看推理设备 +- **WHEN** 用户在受支持的 macOS arm64 设备上查看推理设备枚举 +- **THEN** 系统显示 `CoreML` 和 `CPU`,且不显示 `MPS` + +#### Scenario: CoreML 任务参数 +- **WHEN** 用户选择 CoreML 并创建 AI 任务 +- **THEN** 系统提交的设备参数为 `coreml` + +#### Scenario: 旧设备标识不再受支持 +- **WHEN** Provider 选择入口接收到 `mps` +- **THEN** 系统拒绝该值,而不将其转换或转发为 CoreML + +### Requirement: Apple Silicon 支持范围按平台确定 +系统 SHALL 在 macOS 的 `arm64` 或 `aarch64` 架构上提供 CoreML,不得根据 Apple 芯片代际限制该设备。 + +#### Scenario: M1 至 M3 设备 +- **WHEN** 系统运行于 M1、M2 或 M3 的 macOS arm64 环境 +- **THEN** 可用设备包含 `coreml` 和 `cpu` + +#### Scenario: M4 或更新设备 +- **WHEN** 系统运行于 M4 或更新的 macOS arm64 环境 +- **THEN** 可用设备包含 `coreml` 和 `cpu` + +#### Scenario: Apple Silicon 默认设备 +- **WHEN** macOS arm64 同时提供 CPU 与 CoreML +- **THEN** 设备枚举默认选择 CPU,且用户仍可手动选择 CoreML + +#### Scenario: 非 Apple Silicon 平台 +- **WHEN** 系统不运行于 macOS arm64 或 aarch64 环境 +- **THEN** 系统不因 CoreML 规则新增 `coreml` 设备 + +### Requirement: CoreML Session 使用模型兼容配置 +系统 SHALL 以 `CoreMLExecutionProvider` 创建 CoreML Session,并 SHALL NOT 强制设置 `ModelFormat=MLProgram`。普通模型 SHALL 使用默认 CoreML 配置;MoGe-2 SHALL 只允许 CoreML 接管静态输入 shape 分区;CoreML 不支持或未接管的执行 SHALL 由 CPU fallback 处理。 + +#### Scenario: 显式选择 CoreML +- **WHEN** 任务请求设备 `coreml` 且 ONNX Runtime 提供 CoreML Execution Provider +- **THEN** Session 以 CoreML 为首选 Provider、以 CPU 为后备 Provider,且创建参数不包含强制的 CoreML `MLProgram` 配置 + +#### Scenario: MoGe-2 限制 CoreML 动态分区 +- **WHEN** 系统以 CoreML 创建 MoGe-2 Session +- **THEN** CoreML Provider options 包含 `RequireStaticInputShapes=1`,且不包含 `ModelFormat=MLProgram` + +#### Scenario: 其他模型不继承 MoGe-2 限制 +- **WHEN** 系统以 CoreML 创建 BEN2、DA3 或 Upscale Session +- **THEN** CoreML Provider 不包含 MoGe-2 的静态输入限制 + +#### Scenario: 自动选择 CoreML +- **WHEN** 任务请求自动设备且 CoreML 与 CPU Provider 都可用 +- **THEN** CoreML 的优先级高于 CPU,并保留 CPU fallback + +#### Scenario: CoreML 不可用 +- **WHEN** 任务请求 CoreML 但 ONNX Runtime 只提供 CPU Provider +- **THEN** 系统使用 CPU Provider + +### Requirement: MoGe-2 可在 CoreML 模式推理 +系统 MUST 支持 MoGe-2 ONNX 模型在兼容 Apple Silicon CoreML 环境中创建 Session 并完成推理,不得因 AnyImage 强制选择 MLProgram 而在创建模型时失败。 + +#### Scenario: Apple Silicon 执行 MoGe-2 +- **WHEN** M2 或 M4 用户选择 CoreML 并运行 MoGe-2 功能 +- **THEN** 系统成功创建 CoreML Session 并完成至少一帧 MoGe-2 推理 + +#### Scenario: 其他 AI 模型继续使用 CoreML +- **WHEN** 用户选择 CoreML 并运行 BEN2 或 Upscale +- **THEN** 对应模型继续完成 Session 创建和推理 diff --git a/openspec/changes/correct-coreml-device-support/tasks.md b/openspec/changes/correct-coreml-device-support/tasks.md new file mode 100644 index 0000000..a5e2c4a --- /dev/null +++ b/openspec/changes/correct-coreml-device-support/tasks.md @@ -0,0 +1,18 @@ +## 1. 统一 CoreML 设备协议 + +- [x] 1.1 将 Blender 设备标签、Enum 值、任务参数和 ONNX Provider 映射中的 `mps` 全部替换为 `coreml`,删除旧值的兼容路径。 +- [x] 1.2 删除 Apple 芯片品牌解析、`sysctl` 调用和 M4 代际限制,按 macOS arm64/aarch64 平台提供 `coreml` 与 `cpu`。 +- [x] 1.3 调整设备枚举测试,覆盖 M1–M4 所在的 macOS arm64、非 Apple Silicon 平台、Manifest 设备清单和旧 `mps` 值拒绝行为。 +- [x] 1.4 在全部 Apple Silicon Mac 上默认选择 CPU,并保留 CoreML 手动选项。 + +## 2. 恢复 MoGe-2 CoreML 兼容性 + +- [x] 2.1 让共享 Session 工厂默认使用普通 `CoreMLExecutionProvider`,同时支持可选模型级 CoreML options;显式 CoreML 选择保留 CPU fallback,CUDA arena 配置不变。 +- [x] 2.2 更新共享 ONNX Runtime 测试,验证默认配置、可选 CoreML options、显式/自动 CPU fallback 以及 `mps` 拒绝行为。 +- [x] 2.3 让 MoGe-2 CoreML Session 传入 `RequireStaticInputShapes=1` 且不强制 `MLProgram`,更新测试并保持动态图片与 `num_tokens` 输入不变。 + +## 3. 文档与验证 + +- [x] 3.1 更新用户设备说明和 `docs/internals/runtime.md`,统一使用 CoreML,并说明它由 Apple CPU、GPU 与 Neural Engine 调度而非 PyTorch MPS。 +- [x] 3.2 运行 `uv run pytest tests/test_blender_addon.py tests/test_model_onnx_runtime.py tests/test_model_moge2_onnx.py tests/test_docs.py`。 +- [ ] 3.3 在可用的 M2 或 M4 设备选择 CoreML,分别完成 MoGe-2、BEN2 和 Upscale 的单次 smoke test,确认 MoGe-2 不再出现 CoreML 计划构建或预测执行错误。 diff --git a/openspec/changes/show-server-controls-while-running/.openspec.yaml b/openspec/changes/show-server-controls-while-running/.openspec.yaml new file mode 100644 index 0000000..b4b3ece --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/openspec/changes/show-server-controls-while-running/design.md b/openspec/changes/show-server-controls-while-running/design.md new file mode 100644 index 0000000..ca76f51 --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/design.md @@ -0,0 +1,40 @@ +## Context + +`ServerPanel.draw()` 目前把 `READY` 与 `BUSY` 都显示为 Running,但只在 `READY` 分支创建重启和停止按钮。Cached 模型名称则通过逗号拼接到单个 Label,模型较多时会形成过长的一行。 + +BlendJob 当前以 `READY` 表示等待任务、以 `BUSY` 表示执行任务;二者都是 Server 已运行的子状态。此次调整只改变 Blender 面板布局,不改变状态协议或生命周期 Operator。 + +## Goals / Non-Goals + +**Goals:** + +- 统一 Running 判定,使所有运行中子状态共享重启和停止按钮。 +- 保留各子状态原有的状态文本和图标。 +- Cached 包含多个模型时,将模型名称逐项分行显示。 +- 用 UI 测试锁定状态与布局行为。 + +**Non-Goals:** + +- 不改变 Server 启动、重启、停止或任务取消语义。 +- 不新增 Server 状态、API 或依赖。 +- 不改变模型缓存的内容、顺序或清理行为。 + +## Decisions + +### 统一复用 Running 判定 + +在读取 `server_status()` 后,以同一个 Running 判定驱动状态标签、生命周期按钮和 Cached 区域。当前 Running 状态集合为 `READY` 与 `BUSY`:`READY` 表示运行并等待,`BUSY` 表示运行并处理任务。 + +相比在 `READY` 与 `BUSY` 分支分别创建按钮,集中判定可避免后续运行中展示再次分叉。非 Running 状态继续沿用现有 Start 或状态提示分支。 + +### Cached 多项使用纵向列表 + +无缓存和单个缓存模型保留紧凑的一行展示;两个及以上缓存模型使用纵向布局,每个模型名称占一行,并保留同一区域的清理按钮。 + +相比在 Label 文本中插入换行,使用 Blender Layout 的独立 Label 能稳定参与面板布局,也便于测试每个模型项。 + +## Risks / Trade-offs + +- [Busy 时停止或重启会中断当前任务] → 这是用户主动触发的既有生命周期行为;本变更只恢复入口,不改变 Operator 语义。 +- [多个模型使面板变高] → 仅在确有多个缓存模型时展开,并以可读性优先。 +- [状态分支与按钮分支再次偏离] → 使用同一个 Running 判定,并覆盖 `READY`、`BUSY` 与非 Running 状态测试。 diff --git a/openspec/changes/show-server-controls-while-running/proposal.md b/openspec/changes/show-server-controls-while-running/proposal.md new file mode 100644 index 0000000..317ae61 --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/proposal.md @@ -0,0 +1,27 @@ +## Why + +Server 处于 Busy 时仍然是 Running,但当前面板会隐藏重启和停止按钮,导致用户在任务执行期间无法直接管理 Server 生命周期。Running 的控制入口应保持一致,不应受 Busy、等待或其他运行中子状态影响。 + +## What Changes + +- Server 只要处于 Running,就显示重启和停止按钮。 +- Busy、等待及其他运行中子状态继续显示各自的状态信息,但不再隐藏生命周期控制按钮。 +- Cached 列表包含多个模型时,每个模型换行显示,避免单行内容过长。 +- 补充 UI 测试,覆盖运行中状态的按钮可见性与 Cached 列表布局。 + +## Capabilities + +### New Capabilities + +- `server-lifecycle-controls`: 规定 Server 在所有 Running 子状态下提供一致的重启与停止入口。 +- `server-cache-display`: 规定 Server 面板中 Cached 模型列表的分行展示行为。 + +### Modified Capabilities + +无。 + +## Impact + +- `src/anyimage/panel.py` 中 Server 状态与控制按钮的展示逻辑。 +- Blender Add-on UI 测试。 +- 不改变 Server API、状态协议或依赖。 diff --git a/openspec/changes/show-server-controls-while-running/specs/server-cache-display/spec.md b/openspec/changes/show-server-controls-while-running/specs/server-cache-display/spec.md new file mode 100644 index 0000000..2814059 --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/specs/server-cache-display/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Cached model list layout +Server 面板 SHALL 显示所有 Cached 模型名称,并 SHALL 在存在多个模型时让每个模型名称占据独立的视觉行。 + +#### Scenario: Multiple models are cached +- **WHEN** Server 为 Running 且 Cached 列表包含两个或更多模型 +- **THEN** 面板逐行显示每个模型名称,并保留缓存清理按钮 + +#### Scenario: One model is cached +- **WHEN** Server 为 Running 且 Cached 列表只包含一个模型 +- **THEN** 面板在一行内显示 `Cached` 与该模型名称,并保留缓存清理按钮 + +#### Scenario: No model is cached +- **WHEN** Server 为 Running 且 Cached 列表为空 +- **THEN** 面板显示 `Cached: None`,并保留缓存清理按钮 diff --git a/openspec/changes/show-server-controls-while-running/specs/server-lifecycle-controls/spec.md b/openspec/changes/show-server-controls-while-running/specs/server-lifecycle-controls/spec.md new file mode 100644 index 0000000..d97bd36 --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/specs/server-lifecycle-controls/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Running Server lifecycle controls +Server 面板 SHALL 在 Server 的每个 Running 子状态下显示重启和停止控制,并 SHALL 保留该子状态对应的状态文本与图标。 + +#### Scenario: Server is running and waiting +- **WHEN** Server 状态为 `READY` +- **THEN** 面板显示 Running 状态、重启按钮和停止按钮 + +#### Scenario: Server is running and busy +- **WHEN** Server 状态为 `BUSY` +- **THEN** 面板显示 Running (Busy) 状态、重启按钮和停止按钮 + +#### Scenario: Server is not running +- **WHEN** Server 状态不是 Running 子状态 +- **THEN** 面板不显示 Running 状态的重启和停止按钮,并沿用该状态对应的现有控制或提示 diff --git a/openspec/changes/show-server-controls-while-running/tasks.md b/openspec/changes/show-server-controls-while-running/tasks.md new file mode 100644 index 0000000..0e7ea4a --- /dev/null +++ b/openspec/changes/show-server-controls-while-running/tasks.md @@ -0,0 +1,10 @@ +## 1. Server Panel 布局 + +- [x] 1.1 统一 `READY` 与 `BUSY` 的 Running 判定,让状态标签、重启按钮、停止按钮和 Cached 区域复用同一分支 +- [x] 1.2 调整 Cached 模型布局:空列表和单模型保持单行,多个模型使用逐项纵向 Label,并保留清理按钮 + +## 2. UI 行为验证 + +- [x] 2.1 补充 Server Panel 测试,验证 `READY` 与 `BUSY` 都显示重启和停止按钮,非 Running 状态不显示这些按钮 +- [x] 2.2 补充 Cached 列表测试,验证零个、一个和多个模型的文本、分行与清理按钮 +- [x] 2.3 运行相关测试与完整测试套件,确认 Server Panel 和既有 Add-on 行为通过 diff --git a/pyproject.toml b/pyproject.toml index e94deee..8effd5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11,<3.12" license = "GPL-3.0-or-later" -dependencies = ["blendjob==0.1.14"] +dependencies = ["blendjob==0.1.16"] [project.scripts] pack = "tools.pack:main" diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml index 6bea52c..dfd06b6 100644 --- a/src/anyimage/blender_manifest.toml +++ b/src/anyimage/blender_manifest.toml @@ -10,7 +10,7 @@ type = "add-on" blender_version_min = "4.5.0" platforms = ["windows-x64", "macos-arm64", "linux-x64"] license = ["SPDX:GPL-3.0-or-later"] -wheels = ["./wheels/blendjob-0.1.14-py3-none-any.whl"] +wheels = ["./wheels/blendjob-0.1.16-py3-none-any.whl"] [permissions] network = "Access the local Job Server and download environment dependencies and model weights" diff --git a/src/anyimage/panel.py b/src/anyimage/panel.py index e51be31..0460a13 100644 --- a/src/anyimage/panel.py +++ b/src/anyimage/panel.py @@ -17,14 +17,14 @@ class ServerPanel(bpy.types.Panel): def draw(self, _context): layout = self.layout - running = runtime.server_busy() + busy = runtime.server_busy() if not runtime.environment_ready(): layout.label( text="Environment is not installed", icon="ERROR", ) setup_row = layout.row() - setup_row.enabled = not running + setup_row.enabled = not busy setup_row.operator( "anyimage.setup_ai_environment", text="Set Up AI Environment…", @@ -34,6 +34,7 @@ class ServerPanel(bpy.types.Panel): status = runtime.server_status() state = status.get("state", "STOPPED") + server_running = state in {"READY", "BUSY"} icons = { "READY": "RADIOBUT_ON", "BUSY": "TIME", @@ -43,7 +44,7 @@ class ServerPanel(bpy.types.Panel): "STOPPED": "PAUSE", } row = layout.row(align=True) - if state in {"READY", "BUSY"}: + if server_running: row.label( text=( "Running" @@ -52,18 +53,17 @@ class ServerPanel(bpy.types.Panel): ), icon=icons[state], ) - if state == "READY": - controls = row.row(align=True) - controls.operator( - "anyimage.restart_server", - text="", - icon="FILE_REFRESH", - ) - controls.operator( - "anyimage.stop_server", - text="", - icon="PAUSE", - ) + controls = row.row(align=True) + controls.operator( + "anyimage.restart_server", + text="", + icon="FILE_REFRESH", + ) + controls.operator( + "anyimage.stop_server", + text="", + icon="PAUSE", + ) elif state == "STOPPED": row.operator( "anyimage.start_server", @@ -82,7 +82,7 @@ class ServerPanel(bpy.types.Panel): icon="PLAY", ) - if state in {"READY", "BUSY"}: + if server_running: cached = ( status.get("resources", {}) .get("model_manager", {}) @@ -90,13 +90,19 @@ class ServerPanel(bpy.types.Panel): ) cached_names = [name for name in cached.values() if name] cached_row = layout.row(align=True) - cached_row.label( - text=( - f"Cached: {', '.join(cached_names)}" - if cached_names - else "Cached: None" + if len(cached_names) > 1: + cached_list = cached_row.column(align=True) + cached_list.label(text="Cached:") + for name in cached_names: + cached_list.label(text=name) + else: + cached_row.label( + text=( + f"Cached: {cached_names[0]}" + if cached_names + else "Cached: None" + ) ) - ) clear = cached_row.row(align=True) clear.enabled = state == "READY" clear.operator( diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 3df199f..bcb25ea 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -1,7 +1,4 @@ -import functools import platform -import re -import subprocess import sys import bpy @@ -26,55 +23,34 @@ REQUIRED_MODEL_KEYS = ( DEVICE_LABELS = { "cuda": ("CUDA", "NVIDIA CUDA"), "directml": ("DirectML", "Windows GPU (DirectML)"), - "mps": ("MPS", "Apple Metal (MPS)"), + "coreml": ("CoreML", "Apple CoreML"), "cpu": ("CPU", "CPU"), } -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 { +def supports_coreml(): + return sys.platform == "darwin" and platform.machine().lower() in { "arm64", "aarch64", - }: - 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"] + names = [ + name + for device in devices + if (name := str(device).lower()) in DEVICE_LABELS + ] + if not supports_coreml(): + names = [name for name in names if name != "coreml"] + elif "cpu" in names: + names = ["cpu", *(name for name in names if name != "cpu")] return names or ["cpu"] if sys.platform == "win32": return ["directml", "cpu"] - return ["mps", "cpu"] if supports_mps() else ["cpu"] + return ["cpu", "coreml"] if supports_coreml() else ["cpu"] def device_enum_items(_owner, _context): diff --git a/src/anyimage/server/models/onnx_moge2.py b/src/anyimage/server/models/onnx_moge2.py index 6c9e995..f712932 100644 --- a/src/anyimage/server/models/onnx_moge2.py +++ b/src/anyimage/server/models/onnx_moge2.py @@ -10,6 +10,9 @@ from .onnx_runtime import select_providers as select_providers ONNX_FILENAME = "model.onnx" MIN_TOKENS = 1200 MAX_TOKENS = 3600 +COREML_PROVIDER_OPTIONS = { + "RequireStaticInputShapes": "1", +} def is_onnx_model(model_directory): @@ -18,7 +21,11 @@ def is_onnx_model(model_directory): def create_session(model_directory, requested_device="auto"): path = Path(model_directory) / ONNX_FILENAME - return create_runtime_session(path, requested_device) + return create_runtime_session( + path, + requested_device, + coreml_options=COREML_PROVIDER_OPTIONS, + ) def normalized_view_plane_uv(width, height, dtype): diff --git a/src/anyimage/server/models/onnx_runtime.py b/src/anyimage/server/models/onnx_runtime.py index cb170d8..fe5bdb2 100644 --- a/src/anyimage/server/models/onnx_runtime.py +++ b/src/anyimage/server/models/onnx_runtime.py @@ -1,12 +1,5 @@ """Shared ONNX Runtime session helpers.""" -COREML_PROVIDER_OPTIONS = { - "ModelFormat": "MLProgram", - "MLComputeUnits": "ALL", - "RequireStaticInputShapes": "0", - "EnableOnSubgraphs": "0", -} - CUDA_PROVIDER_OPTIONS = { "arena_extend_strategy": "kSameAsRequested", } @@ -17,14 +10,20 @@ def select_providers(requested, available): providers_for_device = { "cuda": ("CUDAExecutionProvider", "DmlExecutionProvider"), "directml": ("DmlExecutionProvider",), - "mps": ("CoreMLExecutionProvider",), + "coreml": ("CoreMLExecutionProvider",), "cpu": ("CPUExecutionProvider",), } if requested != "auto": candidates = providers_for_device[requested] for provider in candidates: if provider in available: - return [provider] + providers = [provider] + if ( + provider == "CoreMLExecutionProvider" + and "CPUExecutionProvider" in available + ): + providers.append("CPUExecutionProvider") + return providers if "CPUExecutionProvider" in available: return ["CPUExecutionProvider"] raise RuntimeError( @@ -44,7 +43,12 @@ def select_providers(requested, available): return providers -def create_session(path, requested_device="auto"): +def create_session( + path, + requested_device="auto", + *, + coreml_options=None, +): import onnxruntime providers = select_providers( @@ -55,8 +59,8 @@ def create_session(path, requested_device="auto"): for provider in providers: if provider == "CUDAExecutionProvider": provider = (provider, dict(CUDA_PROVIDER_OPTIONS)) - elif provider == "CoreMLExecutionProvider": - provider = (provider, dict(COREML_PROVIDER_OPTIONS)) + elif provider == "CoreMLExecutionProvider" and coreml_options: + provider = (provider, dict(coreml_options)) configured_providers.append(provider) return onnxruntime.InferenceSession( str(path), diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 1859fa3..5e0494c 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -17,10 +17,20 @@ class BlenderAddonTest(unittest.TestCase): def setUpClass(cls): cls.remove_modules() + def draw_status_bar(_owner, _context): + pass + + draw_status_bar._draw_funcs = [draw_status_bar] + def append_status_bar(draw): draw._owner = "anyimage" + draw_status_bar._draw_funcs.append(draw) cls.status_bar_draws.append(draw) + def remove_status_bar(draw): + draw_status_bar._draw_funcs.remove(draw) + cls.status_bar_draws.remove(draw) + fake_bpy = ModuleType("bpy") fake_bpy.props = SimpleNamespace( BoolProperty=lambda **options: options, @@ -41,8 +51,9 @@ class BlenderAddonTest(unittest.TestCase): Image=type("Image", (), {}), Scene=type("Scene", (), {}), STATUSBAR_HT_header=SimpleNamespace( + draw=draw_status_bar, append=append_status_bar, - remove=lambda draw: cls.status_bar_draws.remove(draw), + remove=remove_status_bar, ), VIEW3D_MT_object_context_menu=SimpleNamespace( append=lambda draw: cls.context_menu_draws.append(draw), @@ -158,26 +169,55 @@ class BlenderAddonTest(unittest.TestCase): if hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"): del self.fake_bpy.types.Image.anyimage_upscale_source - def test_mps_requires_m4_or_newer(self): + def test_all_apple_silicon_supports_coreml(self): properties = self.anyimage.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)) + for generation in range(1, 5): + with ( + self.subTest(generation=generation), + patch.object( + self.anyimage.runtime, + "environment_manifest", + return_value={}, + ), + patch.object(properties.sys, "platform", "darwin"), + patch.object( + properties.platform, + "machine", + return_value="arm64", + ), + ): + self.assertEqual( + properties.supported_devices(), + ["cpu", "coreml"], + ) - def test_unsupported_mac_hides_manifest_mps_device(self): + def test_non_apple_silicon_mac_uses_cpu(self): properties = self.anyimage.properties - manifest = {"devices": ["mps", "cpu"]} + with ( + patch.object( + self.anyimage.runtime, + "environment_manifest", + return_value={}, + ), + patch.object(properties.sys, "platform", "darwin"), + patch.object( + properties.platform, + "machine", + return_value="x86_64", + ), + ): + self.assertEqual(properties.supported_devices(), ["cpu"]) + + def test_manifest_coreml_requires_apple_silicon(self): + properties = self.anyimage.properties + manifest = {"devices": ["coreml", "cpu"]} with ( patch.object( self.anyimage.runtime, "environment_manifest", return_value=manifest, ), - patch.object(properties, "supports_mps", return_value=False), + patch.object(properties.sys, "platform", "linux"), ): self.assertEqual(properties.supported_devices(), ["cpu"]) with ( @@ -186,9 +226,34 @@ class BlenderAddonTest(unittest.TestCase): "environment_manifest", return_value=manifest, ), - patch.object(properties, "supports_mps", return_value=True), + patch.object(properties.sys, "platform", "darwin"), + patch.object( + properties.platform, + "machine", + return_value="aarch64", + ), ): - self.assertEqual(properties.supported_devices(), ["mps", "cpu"]) + self.assertEqual( + properties.supported_devices(), + ["cpu", "coreml"], + ) + + def test_manifest_rejects_old_mps_device(self): + properties = self.anyimage.properties + with ( + patch.object( + self.anyimage.runtime, + "environment_manifest", + return_value={"devices": ["mps", "cpu"]}, + ), + patch.object(properties.sys, "platform", "darwin"), + patch.object( + properties.platform, + "machine", + return_value="arm64", + ), + ): + self.assertEqual(properties.supported_devices(), ["cpu"]) def test_windows_keeps_default_directml_device(self): properties = self.anyimage.properties @@ -2957,6 +3022,105 @@ class BlenderAddonTest(unittest.TestCase): offenders.append(path.relative_to(source_root)) self.assertEqual(offenders, []) + def _draw_server_panel(self, state, cached_names=()): + events = SimpleNamespace(labels=[], operators=[]) + + class Layout: + def __init__(self, kind="layout"): + self.kind = kind + self.enabled = True + + def row(self, **_options): + return Layout("row") + + def column(self, **_options): + return Layout("column") + + def label(self, **options): + events.labels.append( + (self.kind, options.get("text"), options.get("icon")) + ) + + def operator(self, identifier, **_options): + events.operators.append((identifier, self.enabled)) + return SimpleNamespace() + + panel_module = importlib.import_module("anyimage.panel") + panel = panel_module.ServerPanel() + panel.layout = Layout() + loaded = { + str(index): name + for index, name in enumerate(cached_names) + } + with ( + patch.object( + panel_module.runtime, + "environment_ready", + return_value=True, + ), + patch.object( + panel_module.runtime, + "server_busy", + return_value=state == "BUSY", + ), + patch.object( + panel_module.runtime, + "server_status", + return_value={ + "state": state, + "message": state.title(), + "resources": { + "model_manager": {"loaded": loaded}, + }, + }, + ), + ): + panel.draw(None) + return events + + def test_server_panel_shows_controls_for_every_running_state(self): + for state, label, icon in ( + ("READY", "Running", "RADIOBUT_ON"), + ("BUSY", "Running (Busy)", "TIME"), + ): + with self.subTest(state=state): + events = self._draw_server_panel(state) + operators = [identifier for identifier, _enabled in events.operators] + self.assertIn(("row", label, icon), events.labels) + self.assertIn("anyimage.restart_server", operators) + self.assertIn("anyimage.stop_server", operators) + + for state in ("STOPPED", "STARTING", "ERROR"): + with self.subTest(state=state): + events = self._draw_server_panel(state) + operators = [identifier for identifier, _enabled in events.operators] + self.assertNotIn("anyimage.restart_server", operators) + self.assertNotIn("anyimage.stop_server", operators) + + def test_server_panel_wraps_multiple_cached_models(self): + for cached_names, expected_labels in ( + ((), [("row", "Cached: None")]), + (("Model A",), [("row", "Cached: Model A")]), + ( + ("Model A", "Model B"), + [ + ("column", "Cached:"), + ("column", "Model A"), + ("column", "Model B"), + ], + ), + ): + with self.subTest(cached_names=cached_names): + events = self._draw_server_panel("READY", cached_names) + cached_labels = [ + (kind, text) + for kind, text, _icon in events.labels + if text != "Running" + ] + self.assertEqual(cached_labels, expected_labels) + operators = [identifier for identifier, _enabled in events.operators] + self.assertIn("anyimage.clear_models", operators) + def test_status_bar_reads_runtime_state(self): calls = [] diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py index 34c3210..98e681c 100644 --- a/tests/test_extension_packaging.py +++ b/tests/test_extension_packaging.py @@ -24,7 +24,7 @@ class ExtensionPackagingTest(unittest.TestCase): pack = load_pack() with tempfile.TemporaryDirectory() as directory: directory_path = Path(directory) - wheel_path = directory_path / "blendjob-0.1.14-py3-none-any.whl" + wheel_path = directory_path / "blendjob-0.1.16-py3-none-any.whl" wheel_path.write_bytes(b"wheel") original_dist = pack.DIST_DIR pack.DIST_DIR = directory_path / "dist" @@ -132,7 +132,7 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/models/upscale.py", names) self.assertIn("server/__init__.py", names) self.assertIn( - "wheels/blendjob-0.1.14-py3-none-any.whl", + "wheels/blendjob-0.1.16-py3-none-any.whl", names, ) self.assertFalse(any(name.startswith("blendjob/") for name in names)) @@ -147,7 +147,7 @@ class ExtensionPackagingTest(unittest.TestCase): def test_reads_extension_dependencies_from_pyproject(self): pack = load_pack() - self.assertEqual(pack.project_dependencies(), ["blendjob==0.1.14"]) + self.assertEqual(pack.project_dependencies(), ["blendjob==0.1.16"]) def test_downloads_declared_wheels_for_target_platform(self): pack = load_pack() @@ -156,20 +156,20 @@ class ExtensionPackagingTest(unittest.TestCase): def fake_run(command, check): self.assertTrue(check) - (destination / "blendjob-0.1.14-py3-none-any.whl").write_bytes(b"wheel") + (destination / "blendjob-0.1.16-py3-none-any.whl").write_bytes(b"wheel") with patch.object(pack.subprocess, "run", side_effect=fake_run) as run: wheels = pack.download_wheels("linux-x64", destination) command = run.call_args.args[0] - self.assertIn("blendjob==0.1.14", command) + self.assertIn("blendjob==0.1.16", command) self.assertIn("--find-links", command) self.assertIn(str(pack.SOURCE_DIR / "wheels"), command) self.assertIn("--python-version=3.11", command) self.assertIn("--platform=manylinux2014_x86_64", command) self.assertEqual( [wheel.name for wheel in wheels], - ["blendjob-0.1.14-py3-none-any.whl"], + ["blendjob-0.1.16-py3-none-any.whl"], ) def test_rejects_unknown_platform(self): diff --git a/tests/test_model_moge2_onnx.py b/tests/test_model_moge2_onnx.py index 2d43d81..ff7c14a 100644 --- a/tests/test_model_moge2_onnx.py +++ b/tests/test_model_moge2_onnx.py @@ -79,7 +79,7 @@ class Moge2OnnxTest(unittest.TestCase): ["DmlExecutionProvider", "CPUExecutionProvider"], ) - def test_coreml_session_uses_mlprogram(self): + def test_coreml_session_requires_static_input_partitions(self): calls = [] fake_runtime = ModuleType("onnxruntime") fake_runtime.get_available_providers = lambda: [ @@ -91,20 +91,21 @@ class Moge2OnnxTest(unittest.TestCase): ) with patch.dict(sys.modules, {"onnxruntime": fake_runtime}): - self.moge.create_runtime_session("model.onnx") + self.moge.create_session("moge-model", "coreml") - self.assertEqual(calls[0][0], "model.onnx") self.assertEqual( - calls[0][1][0], - ( - "CoreMLExecutionProvider", - { - "ModelFormat": "MLProgram", - "MLComputeUnits": "ALL", - "RequireStaticInputShapes": "0", - "EnableOnSubgraphs": "0", - }, - ), + calls[0][0], + str(Path("moge-model") / "model.onnx"), + ) + self.assertEqual( + calls[0][1], + [ + ( + "CoreMLExecutionProvider", + {"RequireStaticInputShapes": "1"}, + ), + "CPUExecutionProvider", + ], ) def test_explicit_unavailable_provider_falls_back_to_cpu(self): diff --git a/tests/test_model_onnx_runtime.py b/tests/test_model_onnx_runtime.py index de02999..15db497 100644 --- a/tests/test_model_onnx_runtime.py +++ b/tests/test_model_onnx_runtime.py @@ -61,7 +61,7 @@ class OnnxRuntimeTest(unittest.TestCase): ), ) - def test_coreml_and_cpu_session_options_are_preserved(self): + def test_coreml_and_cpu_use_plain_session_providers(self): runtime, calls = self.create_runtime( ["CoreMLExecutionProvider", "CPUExecutionProvider"] ) @@ -69,17 +69,62 @@ class OnnxRuntimeTest(unittest.TestCase): with patch.dict(sys.modules, {"onnxruntime": runtime}): onnx_runtime.create_session("model.onnx", "auto") + self.assertEqual( + calls[0][1], + [ + "CoreMLExecutionProvider", + "CPUExecutionProvider", + ], + ) + + def test_model_options_only_configure_coreml_provider(self): + runtime, calls = self.create_runtime( + ["CoreMLExecutionProvider", "CPUExecutionProvider"] + ) + + with patch.dict(sys.modules, {"onnxruntime": runtime}): + onnx_runtime.create_session( + "model.onnx", + "coreml", + coreml_options={"RequireStaticInputShapes": "1"}, + ) + self.assertEqual( calls[0][1], [ ( "CoreMLExecutionProvider", - dict(onnx_runtime.COREML_PROVIDER_OPTIONS), + {"RequireStaticInputShapes": "1"}, ), "CPUExecutionProvider", ], ) + def test_coreml_device_selects_coreml_provider(self): + available = [ + "CoreMLExecutionProvider", + "CPUExecutionProvider", + ] + + self.assertEqual( + onnx_runtime.select_providers("coreml", available), + ["CoreMLExecutionProvider", "CPUExecutionProvider"], + ) + self.assertEqual( + onnx_runtime.select_providers( + "coreml", + ["CPUExecutionProvider"], + ), + ["CPUExecutionProvider"], + ) + + def test_old_mps_device_is_rejected(self): + with self.assertRaises(KeyError): + onnx_runtime.select_providers( + "mps", + ["CoreMLExecutionProvider", "CPUExecutionProvider"], + ) + def test_completing_cuda_run_shrinks_memory_arena(self): configs = [] diff --git a/uv.lock b/uv.lock index a100c78..3d3fac2 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "blendjob", specifier = "==0.1.14" }] +requires-dist = [{ name = "blendjob", specifier = "==0.1.16" }] [package.metadata.requires-dev] dev = [ @@ -110,11 +110,10 @@ wheels = [ [[package]] name = "blendjob" -version = "0.1.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/ad/b968528d83ffb4b97678a6ae98d744186c62e59003b1d3947d6fe517b83b/blendjob-0.1.14.tar.gz", hash = "sha256:a5da8867dbaac7f04373a97211daaf53b626554cf7f15959f977e577cfbc542e", size = 21466, upload-time = "2026-08-23T03:44:09.887Z" } +version = "0.1.16" +source = { registry = "src/anyimage/wheels" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/19/c7d81a8377a9146d26d454f6eceab42b358ca1ef775f9e93d979fc254e7f/blendjob-0.1.14-py3-none-any.whl", hash = "sha256:fed773b1e091c4bea4a869eca7cd8028de56b65e92033da75b24d2ab15198b4e", size = 26842, upload-time = "2026-08-23T03:44:09.091Z" }, + { path = "blendjob-0.1.16-py3-none-any.whl" }, ] [[package]]