From 2dfa9b2add003514e823c866559d72d1e8628560 Mon Sep 17 00:00:00 2001 From: nan Date: Tue, 15 Sep 2026 22:50:16 +0800 Subject: [PATCH] Refactor depth reference handling and cleanup - Unified reference depth calculation to use a single implementation in common/depth.py. - Removed server-side reference depth computation and adjusted depth.json to only include image_size and intrinsics. - Updated depth texture handling to ignore transparent, invalid, and non-positive pixels when calculating reference depth. - Changed reference depth calculation to use the 95th percentile of valid depth values. - Adjusted depth direction fitting to return a flat direction when no samples are available. - Removed cleanup threshold parameters from Cutout and related nodes, simplifying the interface. - Updated tests to verify new reference depth logic and ensure compatibility with existing functionality. --- .../unify-reference-depth/.openspec.yaml | 2 + .../changes/unify-reference-depth/design.md | 75 +++++++ .../changes/unify-reference-depth/proposal.md | 28 +++ .../specs/reference-depth-calibration/spec.md | 104 ++++++++++ .../changes/unify-reference-depth/tasks.md | 32 +++ src/anyimage/assets/O_AnyImage.blend | 4 +- src/anyimage/common/depth.py | 125 +++++++++++- .../operators/convert_to_plane/object.py | 18 +- .../cutout_tool/depth_calibration.py | 131 ------------ .../operators/cutout_tool/geometry.py | 34 +--- src/anyimage/operators/cutout_tool/object.py | 77 +++---- .../operators/cutout_tool/operators.py | 3 - src/anyimage/server/geometry/depth_texture.py | 9 +- .../server/geometry/prediction_artifacts.py | 2 - src/anyimage/server/jobs/cutout.py | 1 - tests/anyimage/common/test_depth.py | 190 ++++++++++++++++++ tests/anyimage/common/test_depth_metadata.py | 25 --- .../test_conversion_source_images.py | 3 +- .../operators/test_cutout_boundary_padding.py | 4 +- .../operators/test_cutout_calibration.py | 159 --------------- .../operators/test_cutout_edge_length.py | 13 +- .../operators/test_cutout_geometry.py | 27 +-- .../anyimage/operators/test_cutout_objects.py | 44 ++-- .../operators/test_cutout_operators.py | 2 +- .../operators/test_plane_conversion.py | 7 +- .../anyimage/operators/test_plane_objects.py | 5 +- .../operators/test_plane_operators.py | 10 +- .../server/test_geometry_artifacts.py | 10 +- tests/anyimage/server/test_model_geometry.py | 2 +- tests/support/depth_surface.py | 4 +- tests/support/image_objects.py | 87 +------- tests/tools/nodes/test_depth_surface_split.py | 16 +- tests/tools/nodes/test_inventory.py | 2 +- tools/nodes/common/cutout.py | 21 -- tools/nodes/groups/image_cutout.py | 12 +- tools/nodes/groups/image_depth_cutout.py | 14 +- tools/nodes/groups/image_relief_plane.py | 2 +- 37 files changed, 665 insertions(+), 639 deletions(-) create mode 100644 openspec/changes/unify-reference-depth/.openspec.yaml create mode 100644 openspec/changes/unify-reference-depth/design.md create mode 100644 openspec/changes/unify-reference-depth/proposal.md create mode 100644 openspec/changes/unify-reference-depth/specs/reference-depth-calibration/spec.md create mode 100644 openspec/changes/unify-reference-depth/tasks.md delete mode 100644 src/anyimage/operators/cutout_tool/depth_calibration.py create mode 100644 tests/anyimage/common/test_depth.py delete mode 100644 tests/anyimage/common/test_depth_metadata.py delete mode 100644 tests/anyimage/operators/test_cutout_calibration.py diff --git a/openspec/changes/unify-reference-depth/.openspec.yaml b/openspec/changes/unify-reference-depth/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/unify-reference-depth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/unify-reference-depth/design.md b/openspec/changes/unify-reference-depth/design.md new file mode 100644 index 0000000..7f2924f --- /dev/null +++ b/openspec/changes/unify-reference-depth/design.md @@ -0,0 +1,75 @@ +## Context + +参考深度是深度几何的零点,同时是 `Uniform Scale` 的归一化基准:`depth_uniform_scale()` 用 `参考深度 × fx / (宽度 - 1)` 把模型相机单位换算成 Image Empty 的局部距离。同一张深度图因此只能有一个基准,否则投影尺度与位移零点会互相矛盾。 + +当前有两个算法。服务端 `write_depth_metadata()` 以「模型有效性 ∧ 深度有限为正 ∧ 源图 Alpha ≥ 阈值」取中位数写入 `depth.json`;Cutout 的 `metric_depth_reference()` 以「选区 ∩ 源图 Alpha ≥ 阈值」取 95 百分位,并在选区透明像素不足 5% 时改用固定值 `8.0`。Depth Plane 与 Relief Plane 用前者,Depth Solid 与 Depth Symmetry 用后者,Relief Plane 又用 `Depth Offset = 1` 把中位数基准向后补偿。 + +`depth.exr` 的 Alpha 是「源图 Alpha × 模型有效性」,随深度纹理一起被 Blender 读入,`camera_depth_values()` 已经在读它的 Alpha 与相机 Z,服务端那次计算没有额外信息可用。 + +## Goals / Non-Goals + +**Goals:** + +- 参考深度只有一处实现、一条有效性判据、一个统计量。 +- 服务端只提供相机内参和图像尺寸,不再判断参考区域。 +- 删除 Cutout 的透明比例闸门与固定基准兜底。 +- 让 Relief Plane 的基准平面由参考深度决定,而不是由 `Depth Offset` 的默认值补偿。 + +**Non-Goals:** + +- 不改变 AI 推理、深度纹理协议、节点组接口与 Depth Symmetry 的方向拟合行为。 +- 不为旧 `depth.json` 字段保留兼容读取或转发函数。 +- 不把参考深度暴露成新的用户参数。 + +## Decisions + +### 参考深度在 Blender 侧由深度纹理计算 + +`common/depth.py` 新增 `reference_depth(image, mask=None)`,读取 `camera_depth_values()` 的相机 Z 与 Alpha,在 `Alpha > 0.5 且深度有限且为正` 的像素上取 95 百分位,返回模型单位。调用方再乘 `Uniform Scale`。服务端删除参考深度写入,`depth.json` 只保留 `image_size` 与 `intrinsics`。 + +服务端没有选区信息,也无法在加载时知道几何目标的意图;把判据放在读取深度纹理的一侧,才能让「有效性」只有 Alpha 一个来源。备选是保留服务端字段作为默认值,但这会让同一张深度图继续存在两个基准。 + +### 有效性只由 Alpha 与深度值决定 + +Alpha 已经包含源图 Alpha 与模型有效性两个条件,因此不再需要 `alpha_threshold` 参与参考深度,也不再需要 `validity` 与 Alpha 双重过滤。有效性阈值取 `0.95`:模型的有效性信号接近二值,抬高阈值主要排除源图 Alpha 的半透明边缘,让参考深度只统计完全不透明且模型判定有效的像素。代价是毛发、玻璃等本身就半透明的主体可能没有可用像素,此时几何创建明确失败而不是回退到固定基准。非有限与非正深度仍被排除,因为它们无法参与深度换算。 + +### 统计量统一为 95 百分位 + +基准平面取有效深度的上分位,主体整体落在基准之前:Depth Solid、Depth Symmetry 与 Relief Plane 的位移与厚度归一化因此都从「内容后端」起算,Depth Plane 与 Relief Plane 不再需要靠 `Depth Offset` 人工后移。作为 `Uniform Scale` 的锚点,这个取值同时决定近处内容相对源图轮廓的缩进量,属于可见的行为变化,由几何测试与人工检查确认。 + +备选是统一取中位数:实现同样简单,但 Depth Solid 与 Depth Symmetry 会有一半内容被钳制在深度零点,厚度归一化也随之改变。 + +### 参考深度丢弃超出米制上限的样本 + +参考深度同时是 `Uniform Scale` 的锚点,`Uniform Scale` 与参考深度成反比,所以远景会把整个重建结果缩小并整体推向基准之前。风景图没有主体,中位数与上分位都落在远景上,这个效应最明显。因此统计前丢弃距离超过 `REFERENCE_DEPTH_LIMIT`(米)的样本,让基准由主要内容的距离决定;距离上限之外的内容仍保留在深度纹理里,只影响几何投影,不影响标定。 + +上限「选不出样本」和「没有有效像素」是同一个问题的两面——有效像素的选择多了一条距离条件——所以两者用同一条规则:没有样本剩下时,参考深度取上限本身。这样参考深度的取值域始终是 `(0, REFERENCE_DEPTH_LIMIT]`,同一张图不会因为少量近景像素出现而跳到另一个尺度,也不会因为深度不可信而失败或弹出提示。上限取固定米数而不是相对中位数的比例,因为米制深度是这个工具与 Blender 场景的既有接口,固定上限可以直接说明「主要内容在多少米以内」。 + +### 参考域可选,Cutout 传入选区 + +`reference_depth(image, mask=None)` 的 `mask` 由目标自己决定:Cutout 传入当前选区,Plane 转换不传。选区只是参考深度的作用域,不再参与透明度比例判断。 + +全幅照片没有透明区域时,若忽略选区,参考深度会落在背景深度上,投影归一化会让被套索的主体缩小并与源图轮廓错开。备选是完全按整幅深度图计算,但会破坏套索全幅照片这一既有用法。 + +### 计算函数返回模型单位 + +`depth_uniform_scale()` 需要未缩放的参考深度才能求出 `uniform_scale`,因此 `reference_depth()` 返回模型单位,由调用方乘以尺度后写入 Modifier。 + +### Relief Plane 的 Depth Offset 默认值改为 0 + +基准平面已由 95 百分位放到内容后端,`Depth Offset` 恢复为纯粹的微调参数。默认值属于节点组接口,修改构建脚本后重新生成 `O_AnyImage.blend`。 + +### 删除 Cutout 标定模块 + +`depth_calibration.py` 的参考深度计算与透明比例闸门被 `reference_depth()` 取代;其中的基础形状 UV 采样与平面方向拟合下沉到 `common/depth.py` 的 `fit_symmetry_depth_direction()`,与 `fit_depth_direction()` 同处。`geometry.py` 的 `selection_calibration()` 只服务于旧标定,随之删除。 + +### 方向拟合沿用同一条选择规则 + +`fit_depth_direction()` 在没有可用样本时返回无倾斜的规范方向 `(0, 1, 0)`,而不是报错;Relief Plane 与 Depth Symmetry 因此都得到 `(0, 0, 1)` 的 `Direction`。兜底放在拟合函数里而不是调用点,是因为「有效像素选不出来」对参考深度和方向是同一个问题,放在一处可以删掉 Plane 转换里原有的 `try/except`,也让两个深度目标行为一致。 + +## Risks / Trade-offs + +- [Depth Plane 与 Relief Plane 的基准从内容中位数移到上分位,几何尺度变化明显] → 这是本次明确要求的行为变化,用几何测试锁定 `Reference Depth` 与投影结果,不增加兼容分支。 +- [套索选区在源图全不透明时决定参考深度] → 保留可选 `mask` 参数,让 Cutout 继续以选区为参考域。 +- [节点组默认值与发布资产不一致] → 修改构建脚本后运行 `uv run node-group build`。 +- [已有场景保存的 Modifier 仍带旧 `Reference Depth` 与 `Depth Offset`] → 已保存数值按原样保留,只有重新生成深度几何时才使用新基准。 diff --git a/openspec/changes/unify-reference-depth/proposal.md b/openspec/changes/unify-reference-depth/proposal.md new file mode 100644 index 0000000..64c66d8 --- /dev/null +++ b/openspec/changes/unify-reference-depth/proposal.md @@ -0,0 +1,28 @@ +## Why + +参考深度现在有两条互不相干的算法:服务端按「源图 Alpha 阈值」取中位数写进 `depth.json`,Cutout 又按「选区透明比例」决定用 95 分位还是固定值 `8.0`。同一个深度场在不同目标下得到不同基准,历史补偿(`Depth Offset = 1`、固定兜底)也在互相抵消。深度纹理的 Alpha 已经同时携带源图 Alpha 与模型有效性,足以单独作为有效性判据。 + +## What Changes + +- **BREAKING**:服务端不再计算参考深度,`depth.json` 只保留 `image_size` 与 `intrinsics`。 +- 在 `common/depth.py` 提供唯一的参考深度计算:以 `depth.exr` 的 Alpha 大于 `0.95`、深度有限且为正为有效域,取相机 Z 的 95 百分位。 +- 统计前丢弃距离超过 `REFERENCE_DEPTH_LIMIT`(`8` 米)的样本,避免远景把参考深度与整个重建尺度拖到远处;选择后没有样本时参考深度取该上限,不报错也不提示。 +- 深度方向拟合没有可用样本时返回无倾斜方向,Relief Plane 与 Depth Symmetry 的 `Direction` 一律回退为 `(0, 0, 1)`,不再报错,也不在调用点各自兜底。 +- Depth Plane、Relief Plane、Depth Solid、Depth Symmetry 共用同一实现与同一统计量,不再区分「服务端基准」与「标定基准」;Cutout 把选区作为可选参考域传入,Plane 转换使用整幅深度图。 +- **BREAKING**:参考深度从内容中位数改为上分位,基准平面移动到场景后端,Depth Plane 与 Relief Plane 的几何结果随之变化。 +- 删除 Cutout 的透明比例闸门与固定基准兜底,并停止向服务端透传 Alpha Threshold;Alpha Threshold 只保留控制轮廓的既有职责。 +- 把 Relief Plane 的 `Depth Offset` 默认值从 `1` 改为 `0`,让基准由参考深度本身决定。 + +## Capabilities + +### New Capabilities + +- `reference-depth-calibration`: 定义参考深度的唯一来源、有效性判据、统计量,以及它作为深度几何与 `Uniform Scale` 归一化基准的职责。 + +### Modified Capabilities + +无。 + +## Impact + +影响 `src/anyimage/server/geometry/depth_texture.py`、`geometry/prediction_artifacts.py`、`jobs/cutout.py`、`src/anyimage/common/depth.py`、Plane 转换与 Cutout 的对象创建逻辑、`tools/nodes/groups/image_relief_plane.py`、相关测试与 `src/anyimage/assets/O_AnyImage.blend`。不新增依赖、不改变 AI 推理与节点组接口;已有场景中保存的 Modifier 数值不变,但重新生成深度几何的结果会变化。 diff --git a/openspec/changes/unify-reference-depth/specs/reference-depth-calibration/spec.md b/openspec/changes/unify-reference-depth/specs/reference-depth-calibration/spec.md new file mode 100644 index 0000000..0171a38 --- /dev/null +++ b/openspec/changes/unify-reference-depth/specs/reference-depth-calibration/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: Reference depth has one implementation +参考深度 SHALL 只由 `common/depth.py` 从 Depth 纹理计算。Server MUST NOT 计算或写出参考深度,`depth.json` SHALL 只包含 `image_size` 与 `intrinsics`。 + +#### Scenario: Write depth metadata +- **WHEN** Server 写出 `depth.json` +- **THEN** 文件包含 `image_size` 与 `intrinsics` +- **AND** 文件不包含 `reference_depth` + +#### Scenario: Load depth artifacts +- **WHEN** Blender 加载 Depth 纹理与元数据 +- **THEN** 参考深度由 Depth 纹理计算 +- **AND** 元数据只提供相机内参与图像尺寸 + +### Requirement: Depth texture alpha and depth values define the reference domain +参考深度 SHALL 只统计同时满足 `Alpha > 0.95`、深度有限且深度大于零的像素。系统 MUST NOT 使用 Cutout 的 Alpha Threshold 或选区透明度比例参与该统计。 + +#### Scenario: Exclude transparent and invalid pixels +- **WHEN** 某像素的 Alpha 不大于 `0.95`,或相机 Z 非有限、非正 +- **THEN** 该像素不参与参考深度统计 + +#### Scenario: Exclude semi-transparent source edges +- **WHEN** 源图 Alpha 与模型有效性的乘积落在 `0.5` 与 `0.95` 之间 +- **THEN** 该像素不参与参考深度统计 + +#### Scenario: Exclude everything +- **WHEN** 没有任何像素满足有效条件 +- **THEN** 参考深度取 `REFERENCE_DEPTH_LIMIT` + +### Requirement: Reference depth uses the upper depth percentile +参考深度 SHALL 取有效像素相机 Z 的 95 百分位,并以模型单位返回。所有深度几何 SHALL 使用同一判据,MUST NOT 按目标区分统计量。 + +#### Scenario: Distant background is dropped before the percentile +- **WHEN** 部分有效像素的距离超过 `REFERENCE_DEPTH_LIMIT` 米 +- **THEN** 这些像素不参与百分位统计 +- **AND** 参考深度只由上限之内的像素决定 + +#### Scenario: No sample survives the selection +- **WHEN** 有效像素的距离全部超过 `REFERENCE_DEPTH_LIMIT` 米 +- **THEN** 参考深度取 `REFERENCE_DEPTH_LIMIT` +- **AND** 系统不报告错误,也不提示基准来源 + +#### Scenario: Depth distribution is skewed +- **WHEN** 有效深度在前后景之间分布不均 +- **THEN** 参考深度取有效深度的 95 百分位 +- **AND** 结果不小于同一深度场的中位数 + +#### Scenario: Share the reference across depth shapes +- **WHEN** 同一 Depth 纹理分别用于 Depth Plane、Relief Plane、Depth Solid 与 Depth Symmetry +- **THEN** 四者使用相同的有效域规则与相同的百分位 + +#### Scenario: Selection has little transparency +- **WHEN** 参考域内几乎没有透明像素 +- **THEN** 系统仍按该参考域的有效像素计算 95 百分位 +- **AND** 不使用固定基准值 + +### Requirement: Depth direction fits fall back to a flat plane +深度方向拟合 SHALL 在没有可用样本时返回无倾斜的规范方向,MUST NOT 报错。Relief Plane 与 Depth Symmetry 的 `Direction` SHALL 因此回退为 `(0, 0, 1)`,调用方 MUST NOT 各自实现兜底。 + +#### Scenario: Fit a relief without usable depth samples +- **WHEN** 深度图没有可参与拟合的有效像素 +- **THEN** Relief Plane 仍成功创建 +- **AND** `Depth Direction` 为 `(0, 0, 1)` + +#### Scenario: Fit depth symmetry without usable samples +- **WHEN** 基础形状的采样点全部落在没有有效像素的区域 +- **THEN** Depth Symmetry 仍成功创建 +- **AND** `Direction` 为 `(0, 0, 1)` + +### Requirement: Cutout scopes the reference depth to its selection +Cutout SHALL 把当前选区作为参考域传入参考深度计算,并额外要求像素位于该选区内。Plane 转换 SHALL 使用整幅 Depth 纹理作为参考域。 + +#### Scenario: Selection covers part of an opaque image +- **WHEN** 源图没有透明区域且用户只选中局部主体 +- **THEN** 参考深度只统计选区内的有效像素 + +#### Scenario: Convert a whole image to a depth plane +- **WHEN** 用户把整幅图像转换为 Depth 或 Relief Plane +- **THEN** 参考深度统计整幅 Depth 纹理的有效像素 + +### Requirement: Reference depth anchors the uniform scale +参考深度 SHALL 作为 `Uniform Scale` 的归一化基准参与深度几何换算,MUST NOT 被预先乘以尺度。系统 MUST NOT 为参考深度新增用户参数。 + +#### Scenario: Convert model units to image plane units +- **WHEN** 系统创建深度几何 +- **THEN** 参考深度以模型单位参与 `Uniform Scale` 计算 +- **AND** 已缩放的参考深度写入 `Reference Depth` Modifier 输入 + +### Requirement: Alpha Threshold only controls the outline +Server MUST NOT 接收 Alpha Threshold 参数。Cutout 的 Alpha Threshold SHALL 只用于轮廓提取与内容有效性判定,MUST NOT 改变参考深度。 + +#### Scenario: Change alpha threshold +- **WHEN** 用户改变 Alpha Threshold 并重新生成深度几何 +- **THEN** 轮廓按新阈值变化 +- **AND** 参考深度保持不变 + +### Requirement: Relief Plane places its base plane by reference depth +`O Image Relief Plane` SHALL 使用默认值为 `0` 的 `Depth Offset`,基准平面由 95 百分位参考深度决定,`Depth Offset` 只保留微调职责。 + +#### Scenario: Create a relief plane +- **WHEN** 用户新建 Relief Plane +- **THEN** `Depth Offset` 为 `0` +- **AND** 基准平面位于参考深度 diff --git a/openspec/changes/unify-reference-depth/tasks.md b/openspec/changes/unify-reference-depth/tasks.md new file mode 100644 index 0000000..8dba803 --- /dev/null +++ b/openspec/changes/unify-reference-depth/tasks.md @@ -0,0 +1,32 @@ +## 1. Shared reference depth + +- [x] 1.1 Add `reference_depth(image, mask=None)` to `src/anyimage/common/depth.py`: take the 95th percentile of camera Z over pixels whose Depth texture Alpha is greater than `0.95` and whose depth is finite and positive, and return model units. +- [x] 1.2 Drop `reference_depth` from `load_depth_metadata()` and change `depth_uniform_scale()` to take the computed reference depth instead of reading it from metadata. +- [x] 1.3 Move the base-shape direction fit into `common/depth.py` as `fit_symmetry_depth_direction()` beside `fit_depth_direction()`, then delete `src/anyimage/operators/cutout_tool/depth_calibration.py`. +- [x] 1.4 Delete `selection_calibration()` from `src/anyimage/operators/cutout_tool/geometry.py` and remove its test. +- [x] 1.5 Drop reference samples farther than `REFERENCE_DEPTH_LIMIT` meters before the percentile and use that limit as the reference when the selection leaves no sample. +- [x] 1.6 Return the flat canonical direction from `fit_depth_direction()` when no sample is usable, and delete the caller-side `try/except` in plane conversion. + +## 2. Server artifacts + +- [x] 2.1 Remove the `reference_valid` argument and the `reference_depth` field from `write_depth_metadata()` in `src/anyimage/server/geometry/depth_texture.py`. +- [x] 2.2 Drop `alpha_threshold` from `generate_moge_artifacts()` and the cutout job parameters in `src/anyimage/server/jobs/cutout.py`. +- [x] 2.3 Update the server metadata and artifact tests to expect a `depth.json` with only `image_size` and `intrinsics`. + +## 3. Depth shape initialization + +- [x] 3.1 Compute the reference depth in `create_depth_plane_object()` and `create_shape_object()`; pass the cutout selection as the reference mask and use the whole depth image for plane conversion. +- [x] 3.2 Delete the transparency gate, `DEFAULT_DEPTH_BASELINE`, `DEFAULT_DEPTH_DIRECTION` fallback usage, and the adaptive calibration branch from the Cutout object creation path. +- [x] 3.3 Update the operator and shared support fixtures so depth metadata no longer carries a reference depth and Cutout depth shapes build from the shared reference. +- [x] 3.4 Add regression coverage that a skewed depth field returns the 95th percentile and that a selection mask narrows the reference domain. + +## 4. Relief base plane + +- [x] 4.1 Change the `Depth Offset` default to `0` in `tools/nodes/groups/image_relief_plane.py`. +- [x] 4.2 Run `uv run node-group build` to rebuild and validate `src/anyimage/assets/O_AnyImage.blend`. +- [x] 4.3 Update the Relief Plane node tests and interface inventory for the new default and the reference-depth-driven base plane. + +## 5. Verification + +- [x] 5.1 Run the affected `pytest` modules for `common.depth`, server artifacts, plane conversion, and cutout operators. +- [x] 5.2 Search for stale `reference_depth` metadata reads, `depth_calibration` imports, and fixed-baseline references, then review the final diff. diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index f1fa3cf..93837fa 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:359f47ac74756ffb875f126b79b34c58d4035b64302d6865eecd7ecb6b5daa2a -size 7802158 +oid sha256:96dbc2b540c6e0b329b98b7fdbae2cf9b8fef43aca9d9b2d551f8e5130a90072 +size 7757917 diff --git a/src/anyimage/common/depth.py b/src/anyimage/common/depth.py index ed4d9af..4cf9792 100644 --- a/src/anyimage/common/depth.py +++ b/src/anyimage/common/depth.py @@ -9,6 +9,10 @@ from .image import depth_data_name, image_empty_bounds PLANE_VARIANCE_FLOOR_RATIO = 0.025 +REFERENCE_DEPTH_PERCENTILE = 95.0 +DEPTH_ALPHA_THRESHOLD = 0.95 +REFERENCE_DEPTH_LIMIT = 8.0 +FLAT_DEPTH_DIRECTION = (0.0, 1.0, 0.0) def load_depth_metadata(path): @@ -20,7 +24,6 @@ def load_depth_metadata(path): tuple(float(value) for value in row) for row in metadata["intrinsics"] ) - reference_depth = float(metadata["reference_depth"]) except (KeyError, TypeError, ValueError) as error: raise ValueError("Depth metadata is invalid") from error if ( @@ -30,13 +33,11 @@ def load_depth_metadata(path): or any(len(row) != 3 for row in intrinsics) or intrinsics[0][0] <= 0.0 or intrinsics[1][1] <= 0.0 - or reference_depth <= 0.0 ): raise ValueError("Depth metadata is invalid") return { "image_size": image_size, "intrinsics": intrinsics, - "reference_depth": reference_depth, } @@ -64,7 +65,32 @@ def camera_depth_values(image): pixels = np.empty(width * height * 4, dtype=np.float32) image.pixels.foreach_get(pixels) pixels = np.flipud(pixels.reshape((height, width, 4))) - return pixels[..., 2], pixels[..., 3] > 0.5 + return pixels[..., 2], pixels[..., 3] > DEPTH_ALPHA_THRESHOLD + + +def reference_depth(image, mask=None): + """Return the upper-percentile camera depth of a loaded Depth texture. + + Distances are in model meters. Samples farther than + ``REFERENCE_DEPTH_LIMIT`` are dropped so a distant background cannot push + the reference plane, and with it the whole reconstruction scale, out to + the far field. The limit itself is the reference when the selection leaves + no sample. + """ + import numpy as np + + depth, valid = camera_depth_values(image) + usable = valid & np.isfinite(depth) & (depth > 0.0) + if mask is not None: + mask = np.asarray(mask, dtype=bool) + if mask.shape != depth.shape: + raise ValueError("Depth reference mask dimensions do not match") + usable &= mask + values = depth[usable] + values = values[values <= REFERENCE_DEPTH_LIMIT] + if not values.size: + return REFERENCE_DEPTH_LIMIT + return float(np.percentile(values, REFERENCE_DEPTH_PERCENTILE)) def _weighted_depth_plane(coordinates, values, weights): @@ -90,7 +116,11 @@ def _weighted_depth_plane(coordinates, values, weights): def fit_depth_direction(coordinates, depth, valid, uniform_scale): - """Fit a robust metric depth plane on the XZ plane and return its direction.""" + """Fit a robust metric depth plane on the XZ plane and return its direction. + + The direction is canonical `(slope_x, 1, slope_z)`. A selection without + usable samples returns `FLAT_DEPTH_DIRECTION`. + """ import numpy as np coordinates = np.asarray(coordinates, dtype=np.float64) @@ -103,10 +133,10 @@ def fit_depth_direction(coordinates, depth, valid, uniform_scale): if not uniform_scale > 0.0: raise ValueError("Depth Uniform Scale must be positive") usable = valid & np.isfinite(depth) & (depth > 0.0) - if not usable.any(): - raise ValueError("The depth field contains no valid metric depth") values = depth[usable] * float(uniform_scale) points = coordinates[usable] + if not len(points): + return FLAT_DEPTH_DIRECTION if len(points) < 2: plane = np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32) else: @@ -126,6 +156,80 @@ def fit_depth_direction(coordinates, depth, valid, uniform_scale): return tuple(float(value) for value in direction) +def _sample_valid_bilinear(values, valid, x, y): + import numpy as np + + height, width = values.shape + x = np.clip(x, 0.0, max(width - 1, 0)) + y = np.clip(y, 0.0, max(height - 1, 0)) + x0 = np.floor(x).astype(np.int64) + y0 = np.floor(y).astype(np.int64) + x1 = np.minimum(x0 + 1, width - 1) + y1 = np.minimum(y0 + 1, height - 1) + fx = x - x0 + fy = y - y0 + samples = np.stack( + ( + values[y0, x0], + values[y0, x1], + values[y1, x0], + values[y1, x1], + ), + axis=1, + ) + weights = np.stack( + ( + (1.0 - fx) * (1.0 - fy), + fx * (1.0 - fy), + (1.0 - fx) * fy, + fx * fy, + ), + axis=1, + ) + sample_valid = np.stack( + ( + valid[y0, x0], + valid[y0, x1], + valid[y1, x0], + valid[y1, x1], + ), + axis=1, + ) + weights *= sample_valid + totals = weights.sum(axis=1) + result = np.zeros(len(x), dtype=np.float32) + usable = totals > 1e-8 + result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable] + return result, usable + + +def fit_symmetry_depth_direction(image, vertices, vertex_uv, uniform_scale): + """Fit the Depth Symmetry base plane direction from base shape UV samples.""" + import numpy as np + + vertices = np.asarray(vertices, dtype=np.float32) + uv = np.asarray(vertex_uv, dtype=np.float32) + if vertices.ndim != 2 or vertices.shape[1] != 3: + raise ValueError("Depth Symmetry vertices must have three components") + if uv.shape != (len(vertices), 2): + raise ValueError("Depth Symmetry UV coordinates do not match its vertices") + depth, valid = camera_depth_values(image) + height, width = depth.shape + usable = valid & np.isfinite(depth) & (depth > 0.0) + sampled, sampled_usable = _sample_valid_bilinear( + depth, + usable, + uv[:, 0] * max(width - 1, 0), + (1.0 - uv[:, 1]) * max(height - 1, 0), + ) + return fit_depth_direction( + vertices[:, (0, 2)], + sampled, + sampled_usable, + uniform_scale, + ) + + def fit_image_depth_direction(image, bounds, uniform_scale): """Fit the depth direction across a complete XZ image plane.""" import numpy as np @@ -145,17 +249,16 @@ def fit_image_depth_direction(image, bounds, uniform_scale): ) -def depth_uniform_scale(source_object, metadata, bounds=None): +def depth_uniform_scale(source_object, metadata, model_reference, bounds=None): """Convert model camera units uniformly into Image Empty local distance.""" import numpy as np intrinsics = metadata["intrinsics"] width, height = metadata["image_size"] - reference_depth = metadata["reference_depth"] x_min, x_max, y_min, y_max = bounds or image_empty_bounds(source_object) plane_width = x_max - x_min plane_height = y_max - y_min fx, fy = float(intrinsics[0][0]), float(intrinsics[1][1]) - scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth) - scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth) + scale_x = plane_width * fx / (max(width - 1, 1) * model_reference) + scale_y = plane_height * fy / (max(height - 1, 1) * model_reference) return float(np.sqrt(scale_x * scale_y)) diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index f2d781d..591a24d 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -12,6 +12,7 @@ from ...common.depth import ( fit_image_depth_direction, load_depth_metadata, load_depth_result_image, + reference_depth, ) from ...common.coordinate import canonical_direction_to_legacy from ...common.material import create_image_material @@ -81,23 +82,26 @@ def create_depth_plane_object( modifier = plane.modifiers.new(name=node_group.name, type="NODES") modifier.node_group = node_group modifier.show_group_selector = False - uniform_scale = depth_uniform_scale(source_object, depth_metadata) + model_reference = reference_depth(depth_image) + uniform_scale = depth_uniform_scale( + source_object, + depth_metadata, + model_reference, + ) inputs = [ ("Subdivide", max(0, min(10, int(subdivisions)))), - ("Reference Depth", depth_metadata["reference_depth"] * uniform_scale), + ("Reference Depth", model_reference * uniform_scale), ("Uniform Scale", uniform_scale), ("Depth Image", depth_image), ] if plane_type == "RELIEF": - try: - depth_direction = fit_image_depth_direction( + depth_direction = canonical_direction_to_legacy( + fit_image_depth_direction( depth_image, bounds, uniform_scale, ) - except ValueError: - depth_direction = (0.0, 0.0, 1.0) - depth_direction = canonical_direction_to_legacy(depth_direction) + ) inputs.append(( "Depth Direction", depth_direction, diff --git a/src/anyimage/operators/cutout_tool/depth_calibration.py b/src/anyimage/operators/cutout_tool/depth_calibration.py deleted file mode 100644 index bd398fa..0000000 --- a/src/anyimage/operators/cutout_tool/depth_calibration.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Calibrate metric reference depth and the Depth Symmetry base plane.""" - -from ...common.depth import fit_depth_direction -from ...common.coordinate import canonical_direction_to_symmetry_legacy - - -DEPTH_REFERENCE_PERCENTILE = 95.0 -ADAPTIVE_DEPTH_TRANSPARENCY_RATIO = 0.05 -DEFAULT_DEPTH_BASELINE = 8.0 -DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0) - - -def use_adaptive_depth_calibration(selection_transparent_fraction): - """Return whether the Selection input has enough transparent context.""" - return float(selection_transparent_fraction) >= ADAPTIVE_DEPTH_TRANSPARENCY_RATIO - - -def metric_depth_reference(camera_depth, valid, uniform_scale): - """Return the shared upper-percentile scene depth for both Depth shapes.""" - import numpy as np - - camera_depth = np.asarray(camera_depth, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - if valid.shape != camera_depth.shape: - raise ValueError("Depth values and validity mask dimensions do not match") - usable = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) - if not usable.any(): - raise ValueError("The depth field contains no valid metric depth") - if not uniform_scale > 0.0: - raise ValueError("Depth Uniform Scale must be positive") - reference = np.percentile( - camera_depth[usable], - DEPTH_REFERENCE_PERCENTILE, - ) - return float(reference) * float(uniform_scale) - - -def _sample_valid_bilinear(values, valid, x, y): - import numpy as np - - height, width = values.shape - x = np.clip(x, 0.0, max(width - 1, 0)) - y = np.clip(y, 0.0, max(height - 1, 0)) - x0 = np.floor(x).astype(np.int64) - y0 = np.floor(y).astype(np.int64) - x1 = np.minimum(x0 + 1, width - 1) - y1 = np.minimum(y0 + 1, height - 1) - fx = x - x0 - fy = y - y0 - samples = np.stack( - ( - values[y0, x0], - values[y0, x1], - values[y1, x0], - values[y1, x1], - ), - axis=1, - ) - weights = np.stack( - ( - (1.0 - fx) * (1.0 - fy), - fx * (1.0 - fy), - (1.0 - fx) * fy, - fx * fy, - ), - axis=1, - ) - sample_valid = np.stack( - ( - valid[y0, x0], - valid[y0, x1], - valid[y1, x0], - valid[y1, x1], - ), - axis=1, - ) - weights *= sample_valid - totals = weights.sum(axis=1) - result = np.zeros(len(x), dtype=np.float32) - usable = totals > 1e-8 - result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable] - return result, usable - - -def depth_symmetry_projection_parameters( - vertices, - vertex_uv, - camera_depth, - valid, - uniform_scale, -): - """Fit a metric base plane and return its center depth and direction.""" - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float32) - uv = np.asarray(vertex_uv, dtype=np.float32) - if vertices.ndim != 2 or vertices.shape[1] != 3: - raise ValueError("Depth Symmetry vertices must have three components") - if uv.shape != (len(vertices), 2): - raise ValueError("Depth Symmetry UV coordinates do not match its vertices") - camera_depth = np.asarray(camera_depth, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - if valid.shape != camera_depth.shape: - raise ValueError("Depth Symmetry depth and mask dimensions do not match") - if not uniform_scale > 0.0: - raise ValueError("Depth Symmetry Uniform Scale must be positive") - height, width = camera_depth.shape - sample_x = uv[:, 0] * max(width - 1, 0) - sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0) - calibration_valid = valid & np.isfinite(camera_depth) & (camera_depth > 0.0) - sampled, usable = _sample_valid_bilinear( - camera_depth, - calibration_valid, - sample_x, - sample_y, - ) - if not usable.any(): - raise ValueError("MoGe-2 found no valid Depth Symmetry samples") - local_coordinates = vertices[:, (0, 2)] - reference = metric_depth_reference( - camera_depth, - calibration_valid, - uniform_scale, - ) - direction = fit_depth_direction( - local_coordinates, - sampled, - usable, - uniform_scale, - ) - return reference, canonical_direction_to_symmetry_legacy(direction) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 6b16921..f82b268 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -464,7 +464,6 @@ def boundary_vertices_from_faces(vertex_count, faces): CUTOUT_ALPHA_THRESHOLD = 0.9 -CUTOUT_CLEANUP_UI_SCALE = 100.0 @dataclass(frozen=True) @@ -532,17 +531,6 @@ def world_plane_metric(matrix_world): return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length -def cutout_cleanup_value(spacing, world_metric): - import numpy as np - - if spacing <= 0.0: - raise ValueError("Cutout point spacing must be greater than zero") - area_scale = abs(float(np.linalg.det(metric_transform(world_metric)))) - if area_scale <= 1e-12: - raise ValueError("The Image Empty has a degenerate world transform") - return CUTOUT_CLEANUP_UI_SCALE * float(spacing) ** 2 / area_scale - - def counter_clockwise_faces(points, faces): import numpy as np @@ -620,27 +608,13 @@ def effective_cutout_edge_length( ) -def selection_calibration( - content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD -): - """Return Cutout validity and transparency ratio at geometry resolution.""" +def build_reference_mask(content_values, image_size): + """Resample Cutout content into a boolean mask at Depth texture resolution.""" import numpy as np values = np.asarray(content_values, dtype=np.float32) - target_height, target_width = (int(value) for value in target_shape) - source_height, source_width = values.shape - y = np.minimum( - (np.arange(target_height) * source_height / target_height).astype(np.int64), - source_height - 1, - ) - x = np.minimum( - (np.arange(target_width) * source_width / target_width).astype(np.int64), - source_width - 1, - ) - resized = values[y[:, None], x[None, :]] - valid = (resized > 0.0) & (resized >= float(threshold)) - transparent_fraction = float(np.count_nonzero(~valid) / valid.size) - return valid, transparent_fraction + target_width, target_height = (int(value) for value in image_size) + return _resize_values(values, (target_width, target_height)) > 0.5 def build_base_shape( diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index d5b0b27..857e4a5 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -4,7 +4,12 @@ from enum import StrEnum import bpy -from ...common.depth import camera_depth_values, depth_uniform_scale +from ...common.coordinate import canonical_direction_to_symmetry_legacy +from ...common.depth import ( + depth_uniform_scale, + fit_symmetry_depth_direction, + reference_depth, +) from ...common.material import create_image_material from ...common.object import ( blender_frame_rotation, @@ -13,19 +18,9 @@ from ...common.object import ( set_modifier_input, ) from ...common.node import load_node_group -from .depth_calibration import ( - DEFAULT_DEPTH_BASELINE, - DEFAULT_DEPTH_DIRECTION, - depth_symmetry_projection_parameters, - metric_depth_reference, - use_adaptive_depth_calibration, -) from .geometry import ( - CUTOUT_ALPHA_THRESHOLD, + build_reference_mask, crop_plane_bounds, - cutout_cleanup_value, - selection_calibration, - world_plane_metric, ) from .shape import ( BALLOON_ATTRIBUTE_NAME, @@ -80,12 +75,10 @@ def create_shape_object( context, source_object, shape, - edge_length, content_values, content_bounds, base_shape, *, - alpha_threshold=CUTOUT_ALPHA_THRESHOLD, depth_image=None, depth_metadata=None, normal_image=None, @@ -106,11 +99,6 @@ def create_shape_object( (bounds[0] + bounds[1]) * 0.5, (bounds[2] + bounds[3]) * 0.5, ) - world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world) - cleanup_value = cutout_cleanup_value( - edge_length, - world_metric, - ) material = None mesh = None mesh_object = None @@ -132,33 +120,25 @@ def create_shape_object( if shape in DEPTH_CUTOUT_SHAPES: if depth_image is None or depth_metadata is None: raise ValueError("Depth Shape requires a Depth texture and metadata") - camera_depth, valid = camera_depth_values(depth_image) - calibration_valid, transparent_fraction = selection_calibration( - content_values, valid.shape, threshold=alpha_threshold + model_reference = reference_depth( + depth_image, + build_reference_mask(content_values, depth_image.size), ) - calibration_valid &= valid uniform_scale = depth_uniform_scale( source_object, depth_metadata, + model_reference, bounds, ) - if not use_adaptive_depth_calibration(transparent_fraction): - depth_reference = DEFAULT_DEPTH_BASELINE - if shape == "DEPTH_SYMMETRY": - depth_direction = DEFAULT_DEPTH_DIRECTION - elif shape == "DEPTH_SOLID": - depth_reference = metric_depth_reference( - camera_depth, - calibration_valid, - uniform_scale, - ) - else: - depth_reference, depth_direction = depth_symmetry_projection_parameters( - base_shape.vertices, - base_shape.uv, - camera_depth, - calibration_valid, - uniform_scale, + depth_reference = model_reference * uniform_scale + if shape == "DEPTH_SYMMETRY": + depth_direction = canonical_direction_to_symmetry_legacy( + fit_symmetry_depth_direction( + depth_image, + base_shape.vertices, + base_shape.uv, + uniform_scale, + ) ) source_name = source_object.name @@ -195,22 +175,15 @@ def create_shape_object( modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") modifier.node_group = node_group modifier.show_group_selector = False - for name, value in ( - ("Cleanup Threshold", cleanup_value), - ( - "Thickness", - 0.0 if shape in {"FLAT", "DEPTH_SYMMETRY"} else 1.0, - ), - ): - if name == "Thickness" and shape == "DEPTH_SYMMETRY": - continue + if shape != "DEPTH_SYMMETRY": set_modifier_input( modifier, modifier_input_identifier( - node_group, name, - subtype="NONE" if name == "Thickness" and shape in DEPTH_CUTOUT_SHAPES else None, + node_group, + "Thickness", + subtype="NONE" if shape in DEPTH_CUTOUT_SHAPES else None, ), - value, + 0.0 if shape == "FLAT" else 1.0, ) if shape == "DEPTH_SOLID": set_modifier_input( diff --git a/src/anyimage/operators/cutout_tool/operators.py b/src/anyimage/operators/cutout_tool/operators.py index bd76f80..753ae69 100644 --- a/src/anyimage/operators/cutout_tool/operators.py +++ b/src/anyimage/operators/cutout_tool/operators.py @@ -138,11 +138,9 @@ def create_cutout_shape( context, source_object, shape, - effective_length, content_values, content_bounds, base_shape, - alpha_threshold=alpha_threshold, depth_image=depth_image, depth_metadata=depth_metadata, normal_image=normal_image, @@ -320,7 +318,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): "generate_depth": generate_depth, "normal_mode": normal_mode.value, "max_input_size": int(self.max_ai_input_size), - "alpha_threshold": float(self.alpha_threshold), } if generate_depth or normal_mode != NormalMode.NONE: parameters.update( diff --git a/src/anyimage/server/geometry/depth_texture.py b/src/anyimage/server/geometry/depth_texture.py index 727d6f0..1c34101 100644 --- a/src/anyimage/server/geometry/depth_texture.py +++ b/src/anyimage/server/geometry/depth_texture.py @@ -90,30 +90,23 @@ def write_depth_texture(frame, output_path, *, alpha): return write_float_exr(rgba, output_path) -def write_depth_metadata(frame, reference_valid, output_path): +def write_depth_metadata(frame, output_path): """Write camera values needed to scale a model-space depth texture.""" import numpy as np depth = np.asarray(frame.depth, dtype=np.float32) validity = np.asarray(frame.validity, dtype=np.float32) intrinsics = np.asarray(frame.intrinsics, dtype=np.float32) - reference_valid = np.asarray(reference_valid, dtype=bool) if depth.ndim != 2 or validity.shape != depth.shape: raise ValueError("Depth metadata dimensions do not match") - if reference_valid.shape != depth.shape: - raise ValueError("Depth metadata reference mask dimensions do not match") if intrinsics.shape != (3, 3): raise ValueError("Depth metadata requires 3x3 camera intrinsics") valid = (validity > 0.5) & np.isfinite(depth) & (depth > 0.0) - reference_valid &= valid if not valid.any(): raise ValueError("Geometry frame contains no valid depth pixels") - if not reference_valid.any(): - raise ValueError("The image contains no valid depth pixels") metadata = { "image_size": [int(depth.shape[1]), int(depth.shape[0])], "intrinsics": intrinsics.tolist(), - "reference_depth": float(np.median(depth[reference_valid])), } output_path = Path(output_path) output_path.write_text( diff --git a/src/anyimage/server/geometry/prediction_artifacts.py b/src/anyimage/server/geometry/prediction_artifacts.py index 6e1ff78..c1f1b74 100644 --- a/src/anyimage/server/geometry/prediction_artifacts.py +++ b/src/anyimage/server/geometry/prediction_artifacts.py @@ -52,7 +52,6 @@ def generate_moge_artifacts( *, generate_depth=False, normal_mode="NONE", - alpha_threshold=0.9, ): """Run MoGe once and write only the requested file artifacts.""" normal_mode = str(normal_mode).upper() @@ -80,7 +79,6 @@ def generate_moge_artifacts( ) result["depth_metadata"] = write_depth_metadata( frame, - (alpha > 0.0) & (alpha >= alpha_threshold), context.directory / "depth.json", ) diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index 607370a..79d1efa 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -19,7 +19,6 @@ def run(context, parameters): input_path, generate_depth=generate_depth, normal_mode=normal_mode, - alpha_threshold=float(parameters.get("alpha_threshold", 0.9)), ) result.update({key: path.name for key, path in artifacts.items()}) context.check_cancelled() diff --git a/tests/anyimage/common/test_depth.py b/tests/anyimage/common/test_depth.py new file mode 100644 index 0000000..30188ef --- /dev/null +++ b/tests/anyimage/common/test_depth.py @@ -0,0 +1,190 @@ +"""Verify shared Depth calibration and metadata from Depth artifacts.""" + +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from anyimage.common.coordinate import canonical_direction_to_symmetry_legacy +from anyimage.common.depth import ( + FLAT_DEPTH_DIRECTION, + REFERENCE_DEPTH_LIMIT, + depth_uniform_scale, + fit_depth_direction, + fit_symmetry_depth_direction, + load_depth_metadata, + reference_depth, +) +from tests.support.image_objects import _depth_image + + +def _camera_depth_image(depth, alpha=None): + depth = np.asarray(depth, dtype=np.float32) + points = np.zeros((*depth.shape, 3), dtype=np.float32) + points[..., 2] = depth + if alpha is None: + alpha = np.ones(depth.shape, dtype=np.float32) + return _depth_image(points, alpha) + + +def test_depth_metadata_loader_reads_only_size_and_intrinsics(tmp_path): + path = tmp_path / "depth.json" + path.write_text( + json.dumps( + { + "image_size": [3, 2], + "intrinsics": np.eye(3).tolist(), + } + ), + encoding="utf-8", + ) + + metadata = load_depth_metadata(path) + + assert metadata == { + "image_size": (3, 2), + "intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + } + + +def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): + along = np.linspace(-1.0, 1.0, 101) + across = np.resize(np.asarray((-0.01, 0.0, 0.01)), len(along)) + coordinates = np.column_stack((along - across, along + across)) + values = 0.4 * along + np.sign(across) * 0.05 + + direction = fit_depth_direction( + coordinates, + values + 2.0, + np.ones(len(values), dtype=bool), + 1.0, + ) + slopes = np.asarray(direction[:2]) / direction[2] + selected_plane = coordinates @ slopes + corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0))) + corner_plane = corners @ slopes + + assert np.ptp(corner_plane) <= np.ptp(selected_plane) * 1.25 + assert np.corrcoef(selected_plane, along)[0, 1] > 0.99 + + +def test_reference_depth_uses_the_upper_depth_percentile(): + depth = np.linspace(1.0, 2.0, 400, dtype=np.float32).reshape(20, 20) + + reference = reference_depth(_camera_depth_image(depth)) + + assert reference == pytest.approx(float(np.percentile(depth, 95.0))) + assert reference > float(np.median(depth)) + + +def test_reference_depth_ignores_transparent_invalid_and_non_positive_pixels(): + depth = np.asarray( + ((1.0, 2.0, 3.0, 4.0), (5.0, 0.0, np.nan, 6.0)), + dtype=np.float32, + ) + alpha = np.asarray( + ((True, True, True, False), (True, True, True, True)), + ) + + reference = reference_depth(_camera_depth_image(depth, alpha)) + + assert reference == pytest.approx( + float(np.percentile(np.asarray((1.0, 2.0, 3.0, 5.0, 6.0)), 95.0)) + ) + + +def test_reference_depth_narrows_its_domain_to_the_mask(): + depth = np.asarray(((4.0, 5.0, 1.0, 2.0),), dtype=np.float32) + image = _camera_depth_image(depth) + mask = np.asarray(((False, False, True, True),)) + + assert reference_depth(image) == pytest.approx( + float(np.percentile(depth, 95.0)) + ) + assert reference_depth(image, mask) == pytest.approx( + float(np.percentile(np.asarray((1.0, 2.0)), 95.0)) + ) + + +def test_reference_depth_ignores_soft_depth_alpha(): + depth = np.asarray(((1.0, 2.0, 3.0, 4.0),), dtype=np.float32) + image = _camera_depth_image(depth, ((1.0, 1.0, 0.9, 0.6),)) + + assert reference_depth(image) == pytest.approx( + float(np.percentile(np.asarray((1.0, 2.0)), 95.0)) + ) + + +def test_reference_depth_ignores_samples_beyond_the_metric_limit(): + depth = np.asarray(((2.0, 4.0, 6.0, 1000.0),), dtype=np.float32) + + reference = reference_depth(_camera_depth_image(depth)) + + assert reference == pytest.approx( + float(np.percentile(np.asarray((2.0, 4.0, 6.0)), 95.0)) + ) + + +def test_reference_depth_falls_back_to_the_limit_without_a_selection(): + transparent = _camera_depth_image( + np.zeros((2, 2), dtype=np.float32), + np.zeros((2, 2), dtype=np.float32), + ) + far = _camera_depth_image( + np.asarray(((1000.0, 2000.0), (4000.0, 8000.0)), dtype=np.float32), + ) + + assert reference_depth(transparent) == pytest.approx(REFERENCE_DEPTH_LIMIT) + assert reference_depth(far) == pytest.approx(REFERENCE_DEPTH_LIMIT) + + +def test_symmetry_depth_direction_fits_a_sloped_base_plane(): + vertices = [] + uv = [] + camera_depth = np.empty((3, 3), dtype=np.float32) + for row in range(3): + for column in range(3): + local_x, local_y = column - 1.0, 1.0 - row + vertices.append((local_x, 0.0, local_y)) + uv.append((column * 0.5, 1.0 - row * 0.5)) + camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y + + direction = fit_symmetry_depth_direction( + _camera_depth_image(camera_depth), + vertices, + uv, + 0.5, + ) + + expected = np.asarray((0.1, 1.0, 0.05), dtype=np.float64) + expected /= np.linalg.norm(expected) + assert np.allclose(direction, expected, atol=1e-6) + + +def test_symmetry_depth_direction_returns_the_flat_direction_without_samples(): + vertices = ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0)) + uv = ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)) + depth = np.ones((2, 2), dtype=np.float32) + image = _camera_depth_image(depth, np.zeros((2, 2), dtype=bool)) + + direction = fit_symmetry_depth_direction(image, vertices, uv, 1.0) + + assert direction == FLAT_DEPTH_DIRECTION + assert canonical_direction_to_symmetry_legacy(direction) == (0.0, 0.0, 1.0) + + +def test_depth_uniform_scale_converts_model_units_at_the_reference_depth(): + metadata = { + "intrinsics": ((100.0, 0.0, 50.0), (0.0, 100.0, 25.0), (0.0, 0.0, 1.0)), + "image_size": (101, 51), + } + + uniform_scale = depth_uniform_scale( + SimpleNamespace(), + metadata, + 2.0, + bounds=(-2.0, 2.0, -1.0, 1.0), + ) + + assert uniform_scale == pytest.approx(2.0) diff --git a/tests/anyimage/common/test_depth_metadata.py b/tests/anyimage/common/test_depth_metadata.py deleted file mode 100644 index 8d75623..0000000 --- a/tests/anyimage/common/test_depth_metadata.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -import numpy as np - -from anyimage.common.depth import load_depth_metadata - - -def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): - path = tmp_path / "depth.json" - path.write_text( - json.dumps( - { - "image_size": [3, 2], - "intrinsics": np.eye(3).tolist(), - "reference_depth": 1.0, - } - ), - encoding="utf-8", - ) - - metadata = load_depth_metadata(path) - - assert metadata["image_size"] == (3, 2) - assert metadata["intrinsics"] == ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) - assert metadata["reference_depth"] == 1.0 diff --git a/tests/anyimage/operators/test_conversion_source_images.py b/tests/anyimage/operators/test_conversion_source_images.py index c30087d..52e5c79 100644 --- a/tests/anyimage/operators/test_conversion_source_images.py +++ b/tests/anyimage/operators/test_conversion_source_images.py @@ -48,12 +48,13 @@ def run_conversion(source, entry, monkeypatch): if entry == "PLANE": operators.ConvertToPlane.execute(SimpleNamespace(mesh_detail=0), bpy.context) else: - metadata = {"image_size": (2, 1), "reference_depth": 1, + metadata = {"image_size": (2, 1), "intrinsics": ((2, 0, 1), (0, 2, 0.5), (0, 0, 1))} monkeypatch.setattr(conversion, "load_depth_metadata", lambda _: metadata) def load_depth(*_): image = bpy.data.images.new("Depth", width=2, height=1, float_buffer=True) image.colorspace_settings.name = "Non-Color" + image.pixels.foreach_set(np.ones(8, dtype=np.float32)) return image monkeypatch.setattr(conversion, "load_depth_result_image", load_depth) monkeypatch.setattr(conversion, "load_normal_result_image", load_depth) diff --git a/tests/anyimage/operators/test_cutout_boundary_padding.py b/tests/anyimage/operators/test_cutout_boundary_padding.py index c6e19ef..23f78ec 100644 --- a/tests/anyimage/operators/test_cutout_boundary_padding.py +++ b/tests/anyimage/operators/test_cutout_boundary_padding.py @@ -128,8 +128,8 @@ def test_client_processing_updates_and_packs_color_and_depth_together(): depth = Image(depth_values) metadata = { "intrinsics": ((8.0, 0.0, 8.0), (0.0, 8.0, 8.0), (0.0, 0.0, 1.0)), - "reference_depth": 12.0, } + original_metadata = dict(metadata) pad_cutout_images(color, depth, metadata, content, 0.9, 2) @@ -139,5 +139,5 @@ def test_client_processing_updates_and_packs_color_and_depth_together(): assert padded_color[2, 8, 3] == 1 assert padded_depth[2, 8, 2] > depth_values[3, 8, 2] assert padded_depth[2, 8, 0] == 0 - assert metadata["reference_depth"] == 12 + assert metadata == original_metadata assert (color.updated, color.packed, depth.updated, depth.packed) == (1, 1, 1, 1) diff --git a/tests/anyimage/operators/test_cutout_calibration.py b/tests/anyimage/operators/test_cutout_calibration.py deleted file mode 100644 index e0b3209..0000000 --- a/tests/anyimage/operators/test_cutout_calibration.py +++ /dev/null @@ -1,159 +0,0 @@ -from types import SimpleNamespace - -import numpy as np - -from anyimage.common.selection import SelectionMask -from anyimage.common.depth import depth_uniform_scale, fit_depth_direction -from anyimage.operators.cutout_tool import geometry as cutout_geometry -from anyimage.operators.cutout_tool import depth_calibration - - -def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain(): - along = np.linspace(-1.0, 1.0, 101) - across = np.resize(np.asarray((-0.01, 0.0, 0.01)), len(along)) - coordinates = np.column_stack( - ( - along - across, - along + across, - ) - ) - values = 0.4 * along + np.sign(across) * 0.05 - - direction = fit_depth_direction( - coordinates, - values + 2.0, - np.ones(len(values), dtype=bool), - 1.0, - ) - slopes = np.asarray(direction[:2]) / direction[2] - selected_plane = coordinates @ slopes - corners = np.asarray(((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0))) - corner_plane = corners @ slopes - - assert np.ptp(corner_plane) <= np.ptp(selected_plane) * 1.25 - assert np.corrcoef(selected_plane, along)[0, 1] > 0.99 - - -def test_depth_symmetry_plane_calibration_matches_cutout_axes(): - vertices = [] - uv = [] - camera_depth = np.empty((3, 3), dtype=np.float32) - for row in range(3): - local_y = 1.0 - row - for column in range(3): - local_x = column - 1.0 - vertices.append((local_x, 0.0, local_y)) - uv.append((column * 0.5, 1.0 - row * 0.5)) - camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y - - reference, direction = depth_calibration.depth_symmetry_projection_parameters( - vertices, - uv, - camera_depth, - np.ones((3, 3), dtype=bool), - 0.5, - ) - expected_direction = np.asarray((-0.1, -0.05, 1.0), dtype=np.float64) - expected_direction /= np.linalg.norm(expected_direction) - - assert np.isclose( - reference, - np.percentile(camera_depth, 95.0) * 0.5, - atol=1e-6, - ) - assert np.allclose(direction, expected_direction, atol=1e-6) - - -def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile(): - size = 10 - row, column = np.indices((size, size)) - camera_depth = np.full((size, size), 2.0, dtype=np.float32) - camera_depth[(row + column) % 2 == 0] = 2.2 - vertices = [] - uv = [] - for y in range(size): - for x in range(size): - vertices.append((float(x), 0.0, float(size - 1 - y))) - uv.append((x / (size - 1), 1.0 - y / (size - 1))) - - reference, direction = depth_calibration.depth_symmetry_projection_parameters( - vertices, - uv, - camera_depth, - np.ones((size, size), dtype=bool), - 1.0, - ) - - assert reference > float(np.median(camera_depth)) - assert np.isclose(reference, 2.2, atol=1e-6) - assert np.allclose(direction, (0.0, 0.0, 1.0), atol=1e-6) - - -def test_depth_surface_projection_uses_one_scale_for_all_camera_axes(): - points = np.zeros((51, 101, 3), dtype=np.float32) - points[..., 2] = np.linspace(1.0, 3.0, 51 * 101).reshape(51, 101) - metadata = { - "intrinsics": ( - (100.0, 0.0, 50.0), - (0.0, 100.0, 25.0), - (0.0, 0.0, 1.0), - ), - "image_size": (101, 51), - "reference_depth": 2.0, - } - - uniform_scale = depth_uniform_scale( - SimpleNamespace(), - metadata, - bounds=(-2.0, 2.0, -1.0, 1.0), - ) - - assert uniform_scale == 2.0 - reference_depth = depth_calibration.metric_depth_reference( - points[..., 2], - np.ones((51, 101), dtype=bool), - uniform_scale, - ) - assert np.isclose(reference_depth, 5.8, atol=1e-6) - - -def test_cutout_threshold_controls_contour_and_calibration(): - alpha = np.zeros((12, 16), dtype=np.float32) - alpha[2:10, 2:14] = 0.2 - alpha[4:8, 5:11] = 1.0 - for threshold, lower, upper in [ - (0.0, (2, 2), (14, 10)), - (0.1, (2, 2), (14, 10)), - (0.5, (5, 4), (11, 8)), - (1.0, (5, 4), (11, 8)), - ]: - contours = cutout_geometry.extract_cutout_contours( - alpha, 64, threshold=threshold - ) - contour = np.asarray(contours.components[0][0]) - np.testing.assert_allclose(contour.min(axis=0), lower) - np.testing.assert_allclose(contour.max(axis=0), upper) - valid, _ = cutout_geometry.selection_calibration( - alpha, alpha.shape, threshold=threshold - ) - np.testing.assert_array_equal(valid, (alpha > 0) & (alpha >= threshold)) - - -def test_cutout_depth_calibration_uses_five_percent_transparency_threshold(): - assert not depth_calibration.use_adaptive_depth_calibration(0.0499) - assert depth_calibration.use_adaptive_depth_calibration(0.05) - - -def test_selection_calibration_uses_the_final_selection_mask(): - selection_mask = SelectionMask( - np.asarray(((1.0, 1.0), (1.0, 0.0)), dtype=np.float32), - (0, 0, 2, 2), - ) - - valid, transparent_fraction = cutout_geometry.selection_calibration( - selection_mask.values, - (2, 2), - ) - - assert np.array_equal(valid, ((True, True), (True, False))) - assert transparent_fraction == 0.25 diff --git a/tests/anyimage/operators/test_cutout_edge_length.py b/tests/anyimage/operators/test_cutout_edge_length.py index e7c33bc..db6108b 100644 --- a/tests/anyimage/operators/test_cutout_edge_length.py +++ b/tests/anyimage/operators/test_cutout_edge_length.py @@ -118,25 +118,30 @@ def test_operator_length_property_accepts_world_units(): bpy.utils.unregister_class(CutoutLengthTestOperator) -def test_effective_length_report_and_geometry_share_value(): +def test_effective_length_report_and_geometry_build_share_value(): report = Mock() context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01))) + source = object() + content = np.ones((10, 10)) with patch.object(operators, "effective_cutout_edge_length", return_value=20), \ patch.object(operators, "build_base_shape") as build, \ patch.object(operators, "create_shape_object") as create, \ patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}): operators.create_cutout_shape( context, - object(), + source, "FLAT", 10, - np.ones((10, 10)), + content, (0, 0, 10, 10), boundary_padding=0.0, color_image=object(), report=report, ) assert build.call_args.args[2] == 20 - assert create.call_args.args[3] == 20 + assert create.call_args.args[0] is context + assert create.call_args.args[1] is source + assert create.call_args.args[2] == "FLAT" + assert create.call_args.args[3] is content assert report.call_args.args[0] == {"INFO"} assert "20 cm" in report.call_args.args[1] diff --git a/tests/anyimage/operators/test_cutout_geometry.py b/tests/anyimage/operators/test_cutout_geometry.py index 795f172..d8f5bac 100644 --- a/tests/anyimage/operators/test_cutout_geometry.py +++ b/tests/anyimage/operators/test_cutout_geometry.py @@ -115,6 +115,16 @@ def test_depth_surface_cutout_uses_default_alpha_boundary(): np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0)) +def test_cutout_reference_mask_resamples_content_to_depth_resolution(): + content = np.zeros((4, 4), dtype=np.float32) + content[:, 2:] = 1.0 + + mask = cutout_geometry.build_reference_mask(content, (2, 2)) + + assert mask.dtype == np.bool_ + np.testing.assert_array_equal(mask, ((False, True), (False, True))) + + def test_blender_cutout_alpha_pipeline_does_not_import_pillow(): original_import = builtins.__import__ @@ -154,23 +164,6 @@ def test_blender_cutout_contour_resolution_tracks_sampling_resolution(): assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 -def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area(): - assert np.isclose( - cutout_geometry.cutout_cleanup_value( - 0.25, - ((1.0, 0.0), (0.0, 1.0)), - ), - 6.25, - ) - assert np.isclose( - cutout_geometry.cutout_cleanup_value( - 0.25, - ((4.0, 0.0), (0.0, 9.0)), - ), - 6.25 / 6.0, - ) - - def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection(): components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),) diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index 709e9fe..f376c8d 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -26,7 +26,7 @@ from anyimage.server.models.geometry import GeometryFrame from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node -def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path): +def test_depth_result_image_loads_camera_exr_and_keeps_data_name(tmp_path): bpy.ops.wm.read_factory_settings(use_empty=True) source_image = bpy.data.images.new("Source.png", width=2, height=2) source = bpy.data.objects.new("Source", None) @@ -42,7 +42,7 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path path = tmp_path / "depth.exr" alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32) write_depth_texture(frame, path, alpha=alpha) - metadata = _depth_metadata(np.eye(3), (2, 2), 1.0) + metadata = _depth_metadata(np.eye(3), (2, 2)) image = depth_data.load_depth_result_image(path, source, metadata) @@ -53,7 +53,10 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path assert np.array_equal(packed[..., 3], alpha * validity) camera_depth, valid = depth_data.camera_depth_values(image) np.testing.assert_array_equal(camera_depth, points[..., 2]) - np.testing.assert_array_equal(valid, alpha * validity > 0.5) + np.testing.assert_array_equal( + valid, + alpha * validity > depth_data.DEPTH_ALPHA_THRESHOLD, + ) assert image.alpha_mode == "CHANNEL_PACKED" assert image.name == "Source_depth.exr" @@ -75,7 +78,7 @@ def test_depth_texture_keeps_points_outside_positive_validity(tmp_path): image = depth_data.load_depth_result_image( path, source, - _depth_metadata(np.eye(3), (2, 1), 2.0), + _depth_metadata(np.eye(3), (2, 1)), ) packed = np.empty(8, dtype=np.float32) @@ -141,7 +144,7 @@ def test_depth_surface_object_evaluates(gesture): points[..., 2] = depth valid = np.ones((size, size), dtype=bool) depth_image = _depth_image(points, valid) - depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0) + depth_metadata = _depth_metadata(intrinsics, (size, size)) result = _create_cutout_shape( source, @@ -166,22 +169,21 @@ def test_depth_surface_object_evaluates(gesture): object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 0.0, ) - expected_cleanup = cutout_geometry.cutout_cleanup_value( - 0.25, - cutout_geometry.world_plane_metric(source.matrix_world)[0], - ) - assert np.isclose( - modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")), - expected_cleanup, - ) assert np.isclose(modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")), 1.0) for name, value in (("Mode", 1), ("Front Inflation", 0.0)): object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value) + scale_id = object_data.modifier_input_identifier( + modifier.node_group, + "Uniform Scale", + ) reference_id = object_data.modifier_input_identifier( modifier.node_group, "Reference Depth", ) - assert modifier_value(modifier, reference_id) == 8.0 + # The Depth texture holds a constant unit camera depth. + assert modifier_value(modifier, reference_id) == pytest.approx( + modifier_value(modifier, scale_id) + ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"), @@ -241,7 +243,7 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): "DEPTH_SOLID", 0.1, depth_image=_depth_image(points, validity), - depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0), + depth_metadata=_depth_metadata(intrinsics, (size, size)), ) modifier = result.modifiers[0] object_data.set_modifier_input( @@ -288,7 +290,7 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture): ) valid = np.ones((61, 41), dtype=bool) depth_image = _depth_image(points, valid) - depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0) + depth_metadata = _depth_metadata(intrinsics, (41, 61)) selection_values = np.ones((60, 40), dtype=np.float32) selection_values[:, :2] = 0.0 @@ -324,7 +326,7 @@ def test_depth_symmetry_uses_metric_plane_inputs(gesture): assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0) for subtype in ("NONE", "DISTANCE"): assert value(modifier, "Thickness", subtype) == 0 - initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"} + initialized = {"Mode", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"} for socket in modifier.node_group.interface.items_tree: if (socket.item_type == "SOCKET" and socket.in_out == "INPUT" and socket.name not in initialized @@ -469,14 +471,6 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): assert result["anyimage_mesh_shape"] == "FLAT" modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"] - expected_cleanup = cutout_geometry.cutout_cleanup_value( - 0.1, - cutout_geometry.world_plane_metric(source.matrix_world)[0], - ) - assert np.isclose( - modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")), - expected_cleanup, - ) assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert all(polygon.use_smooth for polygon in result.data.polygons) diff --git a/tests/anyimage/operators/test_cutout_operators.py b/tests/anyimage/operators/test_cutout_operators.py index 2d7854c..5b8cb91 100644 --- a/tests/anyimage/operators/test_cutout_operators.py +++ b/tests/anyimage/operators/test_cutout_operators.py @@ -309,7 +309,7 @@ class CutoutOperatorsTest(BlenderTestCase): [operator.model], ) self.assertTrue(parameters["generate_depth"]) - self.assertEqual(parameters["alpha_threshold"], 0.25) + self.assertNotIn("alpha_threshold", parameters) self.assertEqual(parameters["normal_mode"], "OBJECT") diff --git a/tests/anyimage/operators/test_plane_conversion.py b/tests/anyimage/operators/test_plane_conversion.py index 5aae35f..64fc41b 100644 --- a/tests/anyimage/operators/test_plane_conversion.py +++ b/tests/anyimage/operators/test_plane_conversion.py @@ -7,6 +7,7 @@ from tests.support.planes import surface def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch): from anyimage.operators.convert_to_plane import object as conversion from anyimage.common.image import image_empty_bounds + from anyimage.common.depth import reference_depth from mathutils import Matrix, Vector obj, _, _, inputs, image = surface @@ -22,7 +23,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf bounds = image_empty_bounds(source) matrix = source.matrix_world.copy() material = bpy.data.materials.new("Conversion") - metadata = {"image_size": (16, 8), "reference_depth": 2.0, + metadata = {"image_size": (16, 8), "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} result = conversion.create_depth_plane_object( bpy.context, source, 2, material, image, metadata, "DEPTH") @@ -40,7 +41,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf assert modifier[inputs["Depth Image"].identifier] == image assert modifier[inputs["Depth Mask"].identifier] is True assert modifier[inputs["Reference Depth"].identifier] == pytest.approx( - 2 * modifier[inputs["Uniform Scale"].identifier]) + reference_depth(image) * modifier[inputs["Uniform Scale"].identifier]) assert result.data.materials[0] == material @@ -60,7 +61,7 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa raise RuntimeError("input failed") monkeypatch.setattr(conversion, "set_modifier_input", fail) - metadata = {"image_size": (16, 8), "reference_depth": 2, + metadata = {"image_size": (16, 8), "intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))} with pytest.raises(RuntimeError, match="input failed"): conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH") diff --git a/tests/anyimage/operators/test_plane_objects.py b/tests/anyimage/operators/test_plane_objects.py index b9edafb..b334d41 100644 --- a/tests/anyimage/operators/test_plane_objects.py +++ b/tests/anyimage/operators/test_plane_objects.py @@ -143,7 +143,6 @@ def test_converted_planes_center_the_origin_without_moving_world_corners(): _depth_metadata( ((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)), (2, 2), - 1.0, ), "DEPTH", ) @@ -192,7 +191,6 @@ def test_relief_plane_builds_a_fixed_base_terrain_block(): _depth_metadata( ((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)), (4, 4), - 1.0, ), "RELIEF", ) @@ -208,7 +206,7 @@ def test_relief_plane_builds_a_fixed_base_terrain_block(): ] == 0.5 assert modifier[ object_data.modifier_input_identifier(group, "Depth Offset") - ] == 1.0 + ] == 0.0 def set_input(name, value): object_data.set_modifier_input( @@ -299,7 +297,6 @@ def test_relief_plane_initializes_a_fitted_depth_direction(): metadata = _depth_metadata( ((2.0, 0.0, 2.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0)), (5, 3), - 2.0, ) result = plane_object.create_depth_plane_object( bpy.context, diff --git a/tests/anyimage/operators/test_plane_operators.py b/tests/anyimage/operators/test_plane_operators.py index 0873c74..98ab02c 100644 --- a/tests/anyimage/operators/test_plane_operators.py +++ b/tests/anyimage/operators/test_plane_operators.py @@ -67,7 +67,10 @@ class PlaneOperatorsTest(BlenderTestCase): depth_image = SimpleNamespace(users=0) normal_image = SimpleNamespace(users=0) material = SimpleNamespace(users=0) - metadata = {"reference_depth": 1.0} + metadata = { + "image_size": (2, 1), + "intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + } files = { "depth_metadata": Path("depth.json"), "depth": Path("depth.exr"), @@ -153,7 +156,10 @@ class PlaneOperatorsTest(BlenderTestCase): patch.object( module, "load_depth_metadata", - return_value={"reference_depth": 1.0}, + return_value={ + "image_size": (2, 1), + "intrinsics": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), + }, ), patch.object(module, "load_depth_result_image", return_value=depth_image), patch.object(module, "load_normal_result_image", return_value=normal_image), diff --git a/tests/anyimage/server/test_geometry_artifacts.py b/tests/anyimage/server/test_geometry_artifacts.py index a98c62a..51aa639 100644 --- a/tests/anyimage/server/test_geometry_artifacts.py +++ b/tests/anyimage/server/test_geometry_artifacts.py @@ -43,7 +43,6 @@ class GeometryArtifactsTest(ServerTestCase): ((1.0, 1.0, 0.0), (1.0, 1.0, 1.0)), dtype=np.float32, ) - calibration = np.asarray(((False, True, True), (False, False, True))) frame = GeometryFrame( depth=np.asarray( ((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)), @@ -55,14 +54,14 @@ class GeometryArtifactsTest(ServerTestCase): ) depth.write_depth_texture(frame, texture_path, alpha=np.ones_like(validity)) - depth.write_depth_metadata(frame, calibration, metadata_path) + depth.write_depth_metadata(frame, metadata_path) self.assertTrue(texture_path.is_file()) self.assertEqual(texture_path.read_bytes()[:4], b"v/1\x01") metadata = json.loads(metadata_path.read_text(encoding="utf-8")) self.assertEqual(metadata["image_size"], [3, 2]) self.assertEqual(metadata["intrinsics"], np.eye(3).tolist()) - self.assertAlmostEqual(metadata["reference_depth"], 4.0) + self.assertEqual(set(metadata), {"image_size", "intrinsics"}) def test_object_normal_texture_matches_depth_surface_axes(self): @@ -192,7 +191,9 @@ class GeometryArtifactsTest(ServerTestCase): np.testing.assert_array_equal(write.call_args.args[0][..., 3], [[1, 0], [1, 1]]) np.testing.assert_array_equal(frame.validity, np.ones((2, 2))) - self.assertEqual(json.loads(result["depth_metadata"].read_text())["reference_depth"], 1) + metadata = json.loads(result["depth_metadata"].read_text()) + self.assertEqual(metadata["image_size"], [2, 2]) + self.assertNotIn("reference_depth", metadata) infer.assert_called_once() self.assertEqual( set(result), @@ -251,5 +252,4 @@ class GeometryArtifactsTest(ServerTestCase): input_path, generate_depth=True, normal_mode="OBJECT", - alpha_threshold=0.9, ) diff --git a/tests/anyimage/server/test_model_geometry.py b/tests/anyimage/server/test_model_geometry.py index 908a21c..a7623e1 100644 --- a/tests/anyimage/server/test_model_geometry.py +++ b/tests/anyimage/server/test_model_geometry.py @@ -30,7 +30,7 @@ def test_moge_infer_returns_geometry_frame_with_model_validity(): ) -def test_moge_z_depth_frame_does_not_require_points(): +def test_moge_frame_omits_points_when_not_requested(): prediction = { "depth": np.ones((2, 2), dtype=np.float32), "normal": np.zeros((2, 2, 3), dtype=np.float32), diff --git a/tests/support/depth_surface.py b/tests/support/depth_surface.py index ed492f9..061f8d2 100644 --- a/tests/support/depth_surface.py +++ b/tests/support/depth_surface.py @@ -8,7 +8,7 @@ import numpy as np from tests.support.nodes import CUTOUT_BUILDERS -def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): +def surface(*, step=6.0, size=1.0, resolution=2): bpy.ops.wm.read_factory_settings(use_empty=True) group = CUTOUT_BUILDERS["DEPTH_SOLID"]() # Two columns with a complete depth jump along their common boundary. @@ -70,8 +70,6 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): ("Boundary Smooth", 0), ): set_value(name, value) - if wrapper: - set_value("Cleanup Threshold", 0.0) return obj, set_value diff --git a/tests/support/image_objects.py b/tests/support/image_objects.py index 7a9bf91..4a1ee73 100644 --- a/tests/support/image_objects.py +++ b/tests/support/image_objects.py @@ -60,7 +60,6 @@ def _create_cutout_shape( bpy.context, source, shape, - edge_length, selection_mask.values, selection_mask.bounds, base_shape, @@ -71,13 +70,13 @@ def _create_cutout_shape( ) -def _depth_image(points, valid, name="Depth"): +def _depth_image(points, alpha, name="Depth"): points = np.asarray(points, dtype=np.float32) - valid = np.asarray(valid, dtype=bool) - height, width = valid.shape + alpha = np.asarray(alpha, dtype=np.float32) + height, width = alpha.shape pixels = np.zeros((height, width, 4), dtype=np.float32) pixels[..., :3] = points - pixels[..., 3] = valid + pixels[..., 3] = alpha image = bpy.data.images.new( name, width=width, @@ -92,11 +91,10 @@ def _depth_image(points, valid, name="Depth"): return image -def _depth_metadata(intrinsics, image_size, reference_depth): +def _depth_metadata(intrinsics, image_size): return { "intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics), "image_size": tuple(int(value) for value in image_size), - "reference_depth": float(reference_depth), } @@ -115,11 +113,6 @@ def _add_cutout_modifier(obj, shape): modifier.node_group = group if shape in {"FLAT", "DEPTH_SOLID"}: object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(group, "Mode"), 1) - object_data.set_modifier_input( - modifier, - object_data.modifier_input_identifier(group, "Cleanup Threshold"), - 0.0, - ) if shape == "DEPTH_SOLID": object_data.set_modifier_input( modifier, @@ -129,73 +122,3 @@ def _add_cutout_modifier(obj, shape): return modifier -def _depth_surface_step_object(): - vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] - faces = [] - for y in range(2): - for x in range(2): - a = y * 3 + x - faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) - data = bpy.data.meshes.new("Depth Surface Step") - data.from_pydata(vertices, (), faces) - uv_layer = data.uv_layers.new(name="UVMap") - for loop in data.loops: - coordinate = data.vertices[loop.vertex_index].co - uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) - obj = bpy.data.objects.new("Depth Surface Step", data) - bpy.context.collection.objects.link(obj) - modifier = _add_cutout_modifier(obj, "DEPTH_SOLID") - - points = np.empty((3, 3, 3), dtype=np.float32) - for y in range(3): - for x in range(3): - points[y, x] = (float(x), -float(y), 1.0) - points[0, 0, 2] = 6.0 - depth_image = _depth_image(points, np.ones((3, 3), dtype=bool)) - for name, value in ( - ("Depth Image", depth_image), - ("Depth Scale", 1.0), - ("Uniform Scale", 1.0), - ("Reference Depth", 1.0), - ("Thickness", 0.0), - ): - object_data.set_modifier_input( - modifier, - object_data.modifier_input_identifier( - modifier.node_group, name, - subtype="DISTANCE" if name == "Thickness" else None, - ), - value, - ) - return obj, modifier - - -def _evaluated_topology(obj): - evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - result = evaluated.to_mesh() - try: - face_edges = { - tuple(sorted((start, end))) - for polygon in result.polygons - for start, end in zip( - polygon.vertices, - (*polygon.vertices[1:], polygon.vertices[0]), - ) - } - mesh_edges = { - tuple(sorted(edge.vertices)) - for edge in result.edges - } - face_vertices = { - vertex - for polygon in result.polygons - for vertex in polygon.vertices - } - return ( - len(result.vertices), - len(result.polygons), - mesh_edges - face_edges, - set(range(len(result.vertices))) - face_vertices, - ) - finally: - evaluated.to_mesh_clear() diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index 1a875b2..c274013 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -87,18 +87,6 @@ def test_midpoint_selects_relative_ray_jump_and_preserves_faces(): assert set(np.round(coords[:, 1], 4)) == {2.0, -2.0} -def test_cleanup_keeps_split_parts_of_a_large_source_island(): - obj, set_value = surface(wrapper=True) - set_value("Cleanup Threshold", 110.0) - assert len(evaluated(obj)[1]) == 8 - set_value("Depth Split", 0.5) - assert len(evaluated(obj)[1]) == 8 - set_value("Thickness", 0.2) - assert_closed(evaluated(obj)[1]) - set_value("Cleanup Threshold", 210.0) - assert len(evaluated(obj)[1]) == 0 - - @pytest.mark.parametrize("depth_scale", [0.0, 2.0]) def test_split_topology_uses_depth_before_display_scaling(depth_scale): obj, set_value = surface(step=4.0) @@ -112,7 +100,7 @@ def test_split_topology_uses_depth_before_display_scaling(depth_scale): def test_full_wrapper_keeps_closed_shell(): - obj, set_value = surface(wrapper=True) + obj, set_value = surface() set_value("Depth Split", 0.5) set_value("Thickness", 0.2) coords, faces = evaluated(obj) @@ -196,7 +184,7 @@ def test_source_surface_sampling_preserves_depth_on_side_walls(): @pytest.mark.parametrize("thickness", [0.0, 0.2]) @pytest.mark.parametrize("split", [0.0, 0.5]) def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, split): - obj, set_value = surface(wrapper=True) + obj, set_value = surface() obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT") attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT") obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT") diff --git a/tests/tools/nodes/test_inventory.py b/tests/tools/nodes/test_inventory.py index 384e86c..5aebb1b 100644 --- a/tests/tools/nodes/test_inventory.py +++ b/tests/tools/nodes/test_inventory.py @@ -94,7 +94,7 @@ def test_saved_parameter_descriptions_match_definitions(): thickness = next(item for item in sockets if item.name == "Thickness") direction = next(item for item in sockets if item.name == "Depth Direction") offset = next(item for item in sockets if item.name == "Depth Offset") - assert offset.default_value == pytest.approx(1.0) + assert offset.default_value == pytest.approx(0.0) assert (offset.min_value, offset.max_value) == (-10.0, 10.0) assert offset.subtype == "DISTANCE" assert direction.parent == thickness.parent == offset.parent diff --git a/tools/nodes/common/cutout.py b/tools/nodes/common/cutout.py index 46e4715..6ceb301 100644 --- a/tools/nodes/common/cutout.py +++ b/tools/nodes/common/cutout.py @@ -3,27 +3,6 @@ from .nodes import math_node from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME -def _island_cleanup(group, geometry, cleanup): - nodes = group.nodes - links = group.links - mesh_island = nodes.new("GeometryNodeInputMeshIsland") - face_area = nodes.new("GeometryNodeInputMeshFaceArea") - island_area = nodes.new("GeometryNodeAccumulateField") - island_area.data_type = "FLOAT" - island_area.domain = "FACE" - links.new(face_area.outputs["Area"], island_area.inputs["Value"]) - links.new(mesh_island.outputs["Island Index"], island_area.inputs["Group ID"]) - - tiny = math_node(nodes, "LESS_THAN") - links.new(island_area.outputs["Total"], tiny.inputs[0]) - links.new(cleanup, tiny.inputs[1]) - delete = nodes.new("GeometryNodeDeleteGeometry") - delete.domain = "FACE" - links.new(geometry, delete.inputs["Geometry"]) - links.new(tiny.outputs[0], delete.inputs["Selection"]) - return delete.outputs["Geometry"] - - def _profile_field(group, geometry): nodes = group.nodes links = group.links diff --git a/tools/nodes/groups/image_cutout.py b/tools/nodes/groups/image_cutout.py index c1dcbe9..41d11b5 100644 --- a/tools/nodes/groups/image_cutout.py +++ b/tools/nodes/groups/image_cutout.py @@ -2,7 +2,7 @@ import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group -from ..common.cutout import _extrude_layers, _island_cleanup, _offset_geometry_y, _remove_zero_thickness, _set_layer_y, _shade_output +from ..common.cutout import _extrude_layers, _offset_geometry_y, _remove_zero_thickness, _set_layer_y, _shade_output NODE_GROUP_NAME = "O Image Cutout" @@ -157,8 +157,6 @@ def build_image_cutout_group(): interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu') float_input(group, 'Thickness', 1.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.' float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.' - options = group.interface.new_panel(name='Options', default_closed=True) - float_input(group, 'Cleanup Threshold', 0.0, parent=options) interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') controls = group_input(group.nodes, set()) balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness']) @@ -167,14 +165,6 @@ def build_image_cutout_group(): group.links.new(balloon, choose.inputs['Balloon']) group.links.new(shell, choose.inputs['Shell']) geometry = choose.outputs[0] - planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == 'NodeGroupInput' and link.from_socket.name == 'Geometry'] - base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) - area = math_node(group.nodes, 'MULTIPLY') - area.inputs[1].default_value = 0.01 - group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0]) - cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0]) - for target in planar_inputs: - group.links.new(cleaned, target) profile = read_float_attribute(group, 'o_balloon') amount = group_input(group.nodes, {'Thickness'}) enabled = math_node(group.nodes, 'GREATER_THAN') diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index d5af7fa..7e935e5 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -1,9 +1,9 @@ """Build O Image Depth Cutout.""" import bpy from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME -from ..common.nodes import interface_socket, group_input, math_node, float_input, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field +from ..common.nodes import interface_socket, group_input, float_input, read_float_attribute, store_float_attribute, prepare_node_group, sample_field, evaluate_field from ..common.boundary_smoothing import smooth_cut_boundary -from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output +from ..common.cutout import _position_field, _remove_zero_thickness, _shade_output from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera, thicken_depth_surface from ..common.smoothing import edge_boundary_field from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile @@ -344,7 +344,6 @@ def build_image_depth_cutout_group(): float_input(group, 'Depth Scale', 1.0, maximum=2.0) float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Higher values detect smaller depth jumps. Zero disables splitting.' options = group.interface.new_panel(name='Options', default_closed=True) - float_input(group, 'Cleanup Threshold', 0.0, parent=options) float_input(group, 'Smooth Weight', 1.0, maximum=1, subtype='FACTOR', parent=options).description = 'Control the strength of boundary smoothing. Higher values move boundary vertices more.' float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options) @@ -358,14 +357,9 @@ def build_image_depth_cutout_group(): float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry') - base = group_input(group.nodes, {'Geometry', 'Cleanup Threshold'}) - area = math_node(group.nodes, 'MULTIPLY') - area.inputs[1].default_value = 0.01 - group.links.new(base.outputs['Cleanup Threshold'], area.inputs[0]) - cleaned = _island_cleanup(group, base.outputs['Geometry'], area.outputs[0]) - cleaned = store_float_attribute(group, cleaned, NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE') controls = group_input(group.nodes, set()) - geometry = _build_depth_surface(group, cleaned, controls) + geometry = store_float_attribute(group, controls.outputs['Geometry'], NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0, domain='FACE') + geometry = _build_depth_surface(group, geometry, controls) _shade_output(group, geometry) prepare_node_group(group) for item in group.interface.items_tree: diff --git a/tools/nodes/groups/image_relief_plane.py b/tools/nodes/groups/image_relief_plane.py index 30fbe0b..cee4b88 100644 --- a/tools/nodes/groups/image_relief_plane.py +++ b/tools/nodes/groups/image_relief_plane.py @@ -42,7 +42,7 @@ def build_image_relief_plane_group(image_plane_group): thickness.description = "Set the thickness of the flat base beneath the relief." vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION").description = "Set the direction used to orient the reference depth plane." depth_offset = _interface_socket(group, "Depth Offset", "INPUT", "NodeSocketFloat") - depth_offset.default_value = 1.0 + depth_offset.default_value = 0.0 depth_offset.min_value = -10.0 depth_offset.max_value = 10.0 depth_offset.subtype = "DISTANCE"