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.
This commit is contained in:
2
openspec/changes/unify-reference-depth/.openspec.yaml
Normal file
2
openspec/changes/unify-reference-depth/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-15
|
||||||
75
openspec/changes/unify-reference-depth/design.md
Normal file
75
openspec/changes/unify-reference-depth/design.md
Normal file
@@ -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`] → 已保存数值按原样保留,只有重新生成深度几何时才使用新基准。
|
||||||
28
openspec/changes/unify-reference-depth/proposal.md
Normal file
28
openspec/changes/unify-reference-depth/proposal.md
Normal file
@@ -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 数值不变,但重新生成深度几何的结果会变化。
|
||||||
@@ -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** 基准平面位于参考深度
|
||||||
32
openspec/changes/unify-reference-depth/tasks.md
Normal file
32
openspec/changes/unify-reference-depth/tasks.md
Normal file
@@ -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.
|
||||||
Binary file not shown.
@@ -9,6 +9,10 @@ from .image import depth_data_name, image_empty_bounds
|
|||||||
|
|
||||||
|
|
||||||
PLANE_VARIANCE_FLOOR_RATIO = 0.025
|
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):
|
def load_depth_metadata(path):
|
||||||
@@ -20,7 +24,6 @@ def load_depth_metadata(path):
|
|||||||
tuple(float(value) for value in row)
|
tuple(float(value) for value in row)
|
||||||
for row in metadata["intrinsics"]
|
for row in metadata["intrinsics"]
|
||||||
)
|
)
|
||||||
reference_depth = float(metadata["reference_depth"])
|
|
||||||
except (KeyError, TypeError, ValueError) as error:
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
raise ValueError("Depth metadata is invalid") from error
|
raise ValueError("Depth metadata is invalid") from error
|
||||||
if (
|
if (
|
||||||
@@ -30,13 +33,11 @@ def load_depth_metadata(path):
|
|||||||
or any(len(row) != 3 for row in intrinsics)
|
or any(len(row) != 3 for row in intrinsics)
|
||||||
or intrinsics[0][0] <= 0.0
|
or intrinsics[0][0] <= 0.0
|
||||||
or intrinsics[1][1] <= 0.0
|
or intrinsics[1][1] <= 0.0
|
||||||
or reference_depth <= 0.0
|
|
||||||
):
|
):
|
||||||
raise ValueError("Depth metadata is invalid")
|
raise ValueError("Depth metadata is invalid")
|
||||||
return {
|
return {
|
||||||
"image_size": image_size,
|
"image_size": image_size,
|
||||||
"intrinsics": intrinsics,
|
"intrinsics": intrinsics,
|
||||||
"reference_depth": reference_depth,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +65,32 @@ def camera_depth_values(image):
|
|||||||
pixels = np.empty(width * height * 4, dtype=np.float32)
|
pixels = np.empty(width * height * 4, dtype=np.float32)
|
||||||
image.pixels.foreach_get(pixels)
|
image.pixels.foreach_get(pixels)
|
||||||
pixels = np.flipud(pixels.reshape((height, width, 4)))
|
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):
|
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):
|
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
|
import numpy as np
|
||||||
|
|
||||||
coordinates = np.asarray(coordinates, dtype=np.float64)
|
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:
|
if not uniform_scale > 0.0:
|
||||||
raise ValueError("Depth Uniform Scale must be positive")
|
raise ValueError("Depth Uniform Scale must be positive")
|
||||||
usable = valid & np.isfinite(depth) & (depth > 0.0)
|
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)
|
values = depth[usable] * float(uniform_scale)
|
||||||
points = coordinates[usable]
|
points = coordinates[usable]
|
||||||
|
if not len(points):
|
||||||
|
return FLAT_DEPTH_DIRECTION
|
||||||
if len(points) < 2:
|
if len(points) < 2:
|
||||||
plane = np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32)
|
plane = np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32)
|
||||||
else:
|
else:
|
||||||
@@ -126,6 +156,80 @@ def fit_depth_direction(coordinates, depth, valid, uniform_scale):
|
|||||||
return tuple(float(value) for value in direction)
|
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):
|
def fit_image_depth_direction(image, bounds, uniform_scale):
|
||||||
"""Fit the depth direction across a complete XZ image plane."""
|
"""Fit the depth direction across a complete XZ image plane."""
|
||||||
import numpy as np
|
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."""
|
"""Convert model camera units uniformly into Image Empty local distance."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
intrinsics = metadata["intrinsics"]
|
intrinsics = metadata["intrinsics"]
|
||||||
width, height = metadata["image_size"]
|
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)
|
x_min, x_max, y_min, y_max = bounds or image_empty_bounds(source_object)
|
||||||
plane_width = x_max - x_min
|
plane_width = x_max - x_min
|
||||||
plane_height = y_max - y_min
|
plane_height = y_max - y_min
|
||||||
fx, fy = float(intrinsics[0][0]), float(intrinsics[1][1])
|
fx, fy = float(intrinsics[0][0]), float(intrinsics[1][1])
|
||||||
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
|
scale_x = plane_width * fx / (max(width - 1, 1) * model_reference)
|
||||||
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
|
scale_y = plane_height * fy / (max(height - 1, 1) * model_reference)
|
||||||
return float(np.sqrt(scale_x * scale_y))
|
return float(np.sqrt(scale_x * scale_y))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ...common.depth import (
|
|||||||
fit_image_depth_direction,
|
fit_image_depth_direction,
|
||||||
load_depth_metadata,
|
load_depth_metadata,
|
||||||
load_depth_result_image,
|
load_depth_result_image,
|
||||||
|
reference_depth,
|
||||||
)
|
)
|
||||||
from ...common.coordinate import canonical_direction_to_legacy
|
from ...common.coordinate import canonical_direction_to_legacy
|
||||||
from ...common.material import create_image_material
|
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 = plane.modifiers.new(name=node_group.name, type="NODES")
|
||||||
modifier.node_group = node_group
|
modifier.node_group = node_group
|
||||||
modifier.show_group_selector = False
|
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 = [
|
inputs = [
|
||||||
("Subdivide", max(0, min(10, int(subdivisions)))),
|
("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),
|
("Uniform Scale", uniform_scale),
|
||||||
("Depth Image", depth_image),
|
("Depth Image", depth_image),
|
||||||
]
|
]
|
||||||
if plane_type == "RELIEF":
|
if plane_type == "RELIEF":
|
||||||
try:
|
depth_direction = canonical_direction_to_legacy(
|
||||||
depth_direction = fit_image_depth_direction(
|
fit_image_depth_direction(
|
||||||
depth_image,
|
depth_image,
|
||||||
bounds,
|
bounds,
|
||||||
uniform_scale,
|
uniform_scale,
|
||||||
)
|
)
|
||||||
except ValueError:
|
)
|
||||||
depth_direction = (0.0, 0.0, 1.0)
|
|
||||||
depth_direction = canonical_direction_to_legacy(depth_direction)
|
|
||||||
inputs.append((
|
inputs.append((
|
||||||
"Depth Direction",
|
"Depth Direction",
|
||||||
depth_direction,
|
depth_direction,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -464,7 +464,6 @@ def boundary_vertices_from_faces(vertex_count, faces):
|
|||||||
|
|
||||||
|
|
||||||
CUTOUT_ALPHA_THRESHOLD = 0.9
|
CUTOUT_ALPHA_THRESHOLD = 0.9
|
||||||
CUTOUT_CLEANUP_UI_SCALE = 100.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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
|
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):
|
def counter_clockwise_faces(points, faces):
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -620,27 +608,13 @@ def effective_cutout_edge_length(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def selection_calibration(
|
def build_reference_mask(content_values, image_size):
|
||||||
content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD
|
"""Resample Cutout content into a boolean mask at Depth texture resolution."""
|
||||||
):
|
|
||||||
"""Return Cutout validity and transparency ratio at geometry resolution."""
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
values = np.asarray(content_values, dtype=np.float32)
|
values = np.asarray(content_values, dtype=np.float32)
|
||||||
target_height, target_width = (int(value) for value in target_shape)
|
target_width, target_height = (int(value) for value in image_size)
|
||||||
source_height, source_width = values.shape
|
return _resize_values(values, (target_width, target_height)) > 0.5
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def build_base_shape(
|
def build_base_shape(
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ from enum import StrEnum
|
|||||||
|
|
||||||
import bpy
|
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.material import create_image_material
|
||||||
from ...common.object import (
|
from ...common.object import (
|
||||||
blender_frame_rotation,
|
blender_frame_rotation,
|
||||||
@@ -13,19 +18,9 @@ from ...common.object import (
|
|||||||
set_modifier_input,
|
set_modifier_input,
|
||||||
)
|
)
|
||||||
from ...common.node import load_node_group
|
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 (
|
from .geometry import (
|
||||||
CUTOUT_ALPHA_THRESHOLD,
|
build_reference_mask,
|
||||||
crop_plane_bounds,
|
crop_plane_bounds,
|
||||||
cutout_cleanup_value,
|
|
||||||
selection_calibration,
|
|
||||||
world_plane_metric,
|
|
||||||
)
|
)
|
||||||
from .shape import (
|
from .shape import (
|
||||||
BALLOON_ATTRIBUTE_NAME,
|
BALLOON_ATTRIBUTE_NAME,
|
||||||
@@ -80,12 +75,10 @@ def create_shape_object(
|
|||||||
context,
|
context,
|
||||||
source_object,
|
source_object,
|
||||||
shape,
|
shape,
|
||||||
edge_length,
|
|
||||||
content_values,
|
content_values,
|
||||||
content_bounds,
|
content_bounds,
|
||||||
base_shape,
|
base_shape,
|
||||||
*,
|
*,
|
||||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
|
||||||
depth_image=None,
|
depth_image=None,
|
||||||
depth_metadata=None,
|
depth_metadata=None,
|
||||||
normal_image=None,
|
normal_image=None,
|
||||||
@@ -106,11 +99,6 @@ def create_shape_object(
|
|||||||
(bounds[0] + bounds[1]) * 0.5,
|
(bounds[0] + bounds[1]) * 0.5,
|
||||||
(bounds[2] + bounds[3]) * 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
|
material = None
|
||||||
mesh = None
|
mesh = None
|
||||||
mesh_object = None
|
mesh_object = None
|
||||||
@@ -132,34 +120,26 @@ def create_shape_object(
|
|||||||
if shape in DEPTH_CUTOUT_SHAPES:
|
if shape in DEPTH_CUTOUT_SHAPES:
|
||||||
if depth_image is None or depth_metadata is None:
|
if depth_image is None or depth_metadata is None:
|
||||||
raise ValueError("Depth Shape requires a Depth texture and metadata")
|
raise ValueError("Depth Shape requires a Depth texture and metadata")
|
||||||
camera_depth, valid = camera_depth_values(depth_image)
|
model_reference = reference_depth(
|
||||||
calibration_valid, transparent_fraction = selection_calibration(
|
depth_image,
|
||||||
content_values, valid.shape, threshold=alpha_threshold
|
build_reference_mask(content_values, depth_image.size),
|
||||||
)
|
)
|
||||||
calibration_valid &= valid
|
|
||||||
uniform_scale = depth_uniform_scale(
|
uniform_scale = depth_uniform_scale(
|
||||||
source_object,
|
source_object,
|
||||||
depth_metadata,
|
depth_metadata,
|
||||||
|
model_reference,
|
||||||
bounds,
|
bounds,
|
||||||
)
|
)
|
||||||
if not use_adaptive_depth_calibration(transparent_fraction):
|
depth_reference = model_reference * uniform_scale
|
||||||
depth_reference = DEFAULT_DEPTH_BASELINE
|
|
||||||
if shape == "DEPTH_SYMMETRY":
|
if shape == "DEPTH_SYMMETRY":
|
||||||
depth_direction = DEFAULT_DEPTH_DIRECTION
|
depth_direction = canonical_direction_to_symmetry_legacy(
|
||||||
elif shape == "DEPTH_SOLID":
|
fit_symmetry_depth_direction(
|
||||||
depth_reference = metric_depth_reference(
|
depth_image,
|
||||||
camera_depth,
|
|
||||||
calibration_valid,
|
|
||||||
uniform_scale,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
depth_reference, depth_direction = depth_symmetry_projection_parameters(
|
|
||||||
base_shape.vertices,
|
base_shape.vertices,
|
||||||
base_shape.uv,
|
base_shape.uv,
|
||||||
camera_depth,
|
|
||||||
calibration_valid,
|
|
||||||
uniform_scale,
|
uniform_scale,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
source_name = source_object.name
|
source_name = source_object.name
|
||||||
mesh = bpy.data.meshes.new(source_name)
|
mesh = bpy.data.meshes.new(source_name)
|
||||||
@@ -195,22 +175,15 @@ def create_shape_object(
|
|||||||
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
|
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
|
||||||
modifier.node_group = node_group
|
modifier.node_group = node_group
|
||||||
modifier.show_group_selector = False
|
modifier.show_group_selector = False
|
||||||
for name, value in (
|
if shape != "DEPTH_SYMMETRY":
|
||||||
("Cleanup Threshold", cleanup_value),
|
|
||||||
(
|
|
||||||
"Thickness",
|
|
||||||
0.0 if shape in {"FLAT", "DEPTH_SYMMETRY"} else 1.0,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
if name == "Thickness" and shape == "DEPTH_SYMMETRY":
|
|
||||||
continue
|
|
||||||
set_modifier_input(
|
set_modifier_input(
|
||||||
modifier,
|
modifier,
|
||||||
modifier_input_identifier(
|
modifier_input_identifier(
|
||||||
node_group, name,
|
node_group,
|
||||||
subtype="NONE" if name == "Thickness" and shape in DEPTH_CUTOUT_SHAPES else None,
|
"Thickness",
|
||||||
|
subtype="NONE" if shape in DEPTH_CUTOUT_SHAPES else None,
|
||||||
),
|
),
|
||||||
value,
|
0.0 if shape == "FLAT" else 1.0,
|
||||||
)
|
)
|
||||||
if shape == "DEPTH_SOLID":
|
if shape == "DEPTH_SOLID":
|
||||||
set_modifier_input(
|
set_modifier_input(
|
||||||
|
|||||||
@@ -138,11 +138,9 @@ def create_cutout_shape(
|
|||||||
context,
|
context,
|
||||||
source_object,
|
source_object,
|
||||||
shape,
|
shape,
|
||||||
effective_length,
|
|
||||||
content_values,
|
content_values,
|
||||||
content_bounds,
|
content_bounds,
|
||||||
base_shape,
|
base_shape,
|
||||||
alpha_threshold=alpha_threshold,
|
|
||||||
depth_image=depth_image,
|
depth_image=depth_image,
|
||||||
depth_metadata=depth_metadata,
|
depth_metadata=depth_metadata,
|
||||||
normal_image=normal_image,
|
normal_image=normal_image,
|
||||||
@@ -320,7 +318,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
|||||||
"generate_depth": generate_depth,
|
"generate_depth": generate_depth,
|
||||||
"normal_mode": normal_mode.value,
|
"normal_mode": normal_mode.value,
|
||||||
"max_input_size": int(self.max_ai_input_size),
|
"max_input_size": int(self.max_ai_input_size),
|
||||||
"alpha_threshold": float(self.alpha_threshold),
|
|
||||||
}
|
}
|
||||||
if generate_depth or normal_mode != NormalMode.NONE:
|
if generate_depth or normal_mode != NormalMode.NONE:
|
||||||
parameters.update(
|
parameters.update(
|
||||||
|
|||||||
@@ -90,30 +90,23 @@ def write_depth_texture(frame, output_path, *, alpha):
|
|||||||
return write_float_exr(rgba, output_path)
|
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."""
|
"""Write camera values needed to scale a model-space depth texture."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
depth = np.asarray(frame.depth, dtype=np.float32)
|
depth = np.asarray(frame.depth, dtype=np.float32)
|
||||||
validity = np.asarray(frame.validity, dtype=np.float32)
|
validity = np.asarray(frame.validity, dtype=np.float32)
|
||||||
intrinsics = np.asarray(frame.intrinsics, 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:
|
if depth.ndim != 2 or validity.shape != depth.shape:
|
||||||
raise ValueError("Depth metadata dimensions do not match")
|
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):
|
if intrinsics.shape != (3, 3):
|
||||||
raise ValueError("Depth metadata requires 3x3 camera intrinsics")
|
raise ValueError("Depth metadata requires 3x3 camera intrinsics")
|
||||||
valid = (validity > 0.5) & np.isfinite(depth) & (depth > 0.0)
|
valid = (validity > 0.5) & np.isfinite(depth) & (depth > 0.0)
|
||||||
reference_valid &= valid
|
|
||||||
if not valid.any():
|
if not valid.any():
|
||||||
raise ValueError("Geometry frame contains no valid depth pixels")
|
raise ValueError("Geometry frame contains no valid depth pixels")
|
||||||
if not reference_valid.any():
|
|
||||||
raise ValueError("The image contains no valid depth pixels")
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"image_size": [int(depth.shape[1]), int(depth.shape[0])],
|
"image_size": [int(depth.shape[1]), int(depth.shape[0])],
|
||||||
"intrinsics": intrinsics.tolist(),
|
"intrinsics": intrinsics.tolist(),
|
||||||
"reference_depth": float(np.median(depth[reference_valid])),
|
|
||||||
}
|
}
|
||||||
output_path = Path(output_path)
|
output_path = Path(output_path)
|
||||||
output_path.write_text(
|
output_path.write_text(
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ def generate_moge_artifacts(
|
|||||||
*,
|
*,
|
||||||
generate_depth=False,
|
generate_depth=False,
|
||||||
normal_mode="NONE",
|
normal_mode="NONE",
|
||||||
alpha_threshold=0.9,
|
|
||||||
):
|
):
|
||||||
"""Run MoGe once and write only the requested file artifacts."""
|
"""Run MoGe once and write only the requested file artifacts."""
|
||||||
normal_mode = str(normal_mode).upper()
|
normal_mode = str(normal_mode).upper()
|
||||||
@@ -80,7 +79,6 @@ def generate_moge_artifacts(
|
|||||||
)
|
)
|
||||||
result["depth_metadata"] = write_depth_metadata(
|
result["depth_metadata"] = write_depth_metadata(
|
||||||
frame,
|
frame,
|
||||||
(alpha > 0.0) & (alpha >= alpha_threshold),
|
|
||||||
context.directory / "depth.json",
|
context.directory / "depth.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ def run(context, parameters):
|
|||||||
input_path,
|
input_path,
|
||||||
generate_depth=generate_depth,
|
generate_depth=generate_depth,
|
||||||
normal_mode=normal_mode,
|
normal_mode=normal_mode,
|
||||||
alpha_threshold=float(parameters.get("alpha_threshold", 0.9)),
|
|
||||||
)
|
)
|
||||||
result.update({key: path.name for key, path in artifacts.items()})
|
result.update({key: path.name for key, path in artifacts.items()})
|
||||||
context.check_cancelled()
|
context.check_cancelled()
|
||||||
|
|||||||
190
tests/anyimage/common/test_depth.py
Normal file
190
tests/anyimage/common/test_depth.py
Normal file
@@ -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)
|
||||||
@@ -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
|
|
||||||
@@ -48,12 +48,13 @@ def run_conversion(source, entry, monkeypatch):
|
|||||||
if entry == "PLANE":
|
if entry == "PLANE":
|
||||||
operators.ConvertToPlane.execute(SimpleNamespace(mesh_detail=0), bpy.context)
|
operators.ConvertToPlane.execute(SimpleNamespace(mesh_detail=0), bpy.context)
|
||||||
else:
|
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))}
|
"intrinsics": ((2, 0, 1), (0, 2, 0.5), (0, 0, 1))}
|
||||||
monkeypatch.setattr(conversion, "load_depth_metadata", lambda _: metadata)
|
monkeypatch.setattr(conversion, "load_depth_metadata", lambda _: metadata)
|
||||||
def load_depth(*_):
|
def load_depth(*_):
|
||||||
image = bpy.data.images.new("Depth", width=2, height=1, float_buffer=True)
|
image = bpy.data.images.new("Depth", width=2, height=1, float_buffer=True)
|
||||||
image.colorspace_settings.name = "Non-Color"
|
image.colorspace_settings.name = "Non-Color"
|
||||||
|
image.pixels.foreach_set(np.ones(8, dtype=np.float32))
|
||||||
return image
|
return image
|
||||||
monkeypatch.setattr(conversion, "load_depth_result_image", load_depth)
|
monkeypatch.setattr(conversion, "load_depth_result_image", load_depth)
|
||||||
monkeypatch.setattr(conversion, "load_normal_result_image", load_depth)
|
monkeypatch.setattr(conversion, "load_normal_result_image", load_depth)
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ def test_client_processing_updates_and_packs_color_and_depth_together():
|
|||||||
depth = Image(depth_values)
|
depth = Image(depth_values)
|
||||||
metadata = {
|
metadata = {
|
||||||
"intrinsics": ((8.0, 0.0, 8.0), (0.0, 8.0, 8.0), (0.0, 0.0, 1.0)),
|
"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)
|
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_color[2, 8, 3] == 1
|
||||||
assert padded_depth[2, 8, 2] > depth_values[3, 8, 2]
|
assert padded_depth[2, 8, 2] > depth_values[3, 8, 2]
|
||||||
assert padded_depth[2, 8, 0] == 0
|
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)
|
assert (color.updated, color.packed, depth.updated, depth.packed) == (1, 1, 1, 1)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -118,25 +118,30 @@ def test_operator_length_property_accepts_world_units():
|
|||||||
bpy.utils.unregister_class(CutoutLengthTestOperator)
|
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()
|
report = Mock()
|
||||||
context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01)))
|
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), \
|
with patch.object(operators, "effective_cutout_edge_length", return_value=20), \
|
||||||
patch.object(operators, "build_base_shape") as build, \
|
patch.object(operators, "build_base_shape") as build, \
|
||||||
patch.object(operators, "create_shape_object") as create, \
|
patch.object(operators, "create_shape_object") as create, \
|
||||||
patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}):
|
patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}):
|
||||||
operators.create_cutout_shape(
|
operators.create_cutout_shape(
|
||||||
context,
|
context,
|
||||||
object(),
|
source,
|
||||||
"FLAT",
|
"FLAT",
|
||||||
10,
|
10,
|
||||||
np.ones((10, 10)),
|
content,
|
||||||
(0, 0, 10, 10),
|
(0, 0, 10, 10),
|
||||||
boundary_padding=0.0,
|
boundary_padding=0.0,
|
||||||
color_image=object(),
|
color_image=object(),
|
||||||
report=report,
|
report=report,
|
||||||
)
|
)
|
||||||
assert build.call_args.args[2] == 20
|
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 report.call_args.args[0] == {"INFO"}
|
||||||
assert "20 cm" in report.call_args.args[1]
|
assert "20 cm" in report.call_args.args[1]
|
||||||
|
|||||||
@@ -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))
|
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():
|
def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
|
||||||
original_import = builtins.__import__
|
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
|
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():
|
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
|
||||||
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
|
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
source_image = bpy.data.images.new("Source.png", width=2, height=2)
|
source_image = bpy.data.images.new("Source.png", width=2, height=2)
|
||||||
source = bpy.data.objects.new("Source", None)
|
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"
|
path = tmp_path / "depth.exr"
|
||||||
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
|
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
|
||||||
write_depth_texture(frame, path, alpha=alpha)
|
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)
|
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)
|
assert np.array_equal(packed[..., 3], alpha * validity)
|
||||||
camera_depth, valid = depth_data.camera_depth_values(image)
|
camera_depth, valid = depth_data.camera_depth_values(image)
|
||||||
np.testing.assert_array_equal(camera_depth, points[..., 2])
|
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.alpha_mode == "CHANNEL_PACKED"
|
||||||
assert image.name == "Source_depth.exr"
|
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(
|
image = depth_data.load_depth_result_image(
|
||||||
path,
|
path,
|
||||||
source,
|
source,
|
||||||
_depth_metadata(np.eye(3), (2, 1), 2.0),
|
_depth_metadata(np.eye(3), (2, 1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
packed = np.empty(8, dtype=np.float32)
|
packed = np.empty(8, dtype=np.float32)
|
||||||
@@ -141,7 +144,7 @@ def test_depth_surface_object_evaluates(gesture):
|
|||||||
points[..., 2] = depth
|
points[..., 2] = depth
|
||||||
valid = np.ones((size, size), dtype=bool)
|
valid = np.ones((size, size), dtype=bool)
|
||||||
depth_image = _depth_image(points, valid)
|
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(
|
result = _create_cutout_shape(
|
||||||
source,
|
source,
|
||||||
@@ -166,22 +169,21 @@ def test_depth_surface_object_evaluates(gesture):
|
|||||||
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
|
||||||
0.0,
|
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)
|
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)):
|
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)
|
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(
|
reference_id = object_data.modifier_input_identifier(
|
||||||
modifier.node_group,
|
modifier.node_group,
|
||||||
"Reference Depth",
|
"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(
|
object_data.set_modifier_input(
|
||||||
modifier,
|
modifier,
|
||||||
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
|
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",
|
"DEPTH_SOLID",
|
||||||
0.1,
|
0.1,
|
||||||
depth_image=_depth_image(points, validity),
|
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]
|
modifier = result.modifiers[0]
|
||||||
object_data.set_modifier_input(
|
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)
|
valid = np.ones((61, 41), dtype=bool)
|
||||||
depth_image = _depth_image(points, valid)
|
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 = np.ones((60, 40), dtype=np.float32)
|
||||||
selection_values[:, :2] = 0.0
|
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)
|
assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0)
|
||||||
for subtype in ("NONE", "DISTANCE"):
|
for subtype in ("NONE", "DISTANCE"):
|
||||||
assert value(modifier, "Thickness", subtype) == 0
|
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:
|
for socket in modifier.node_group.interface.items_tree:
|
||||||
if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
|
if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
|
||||||
and socket.name not in initialized
|
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"
|
assert result["anyimage_mesh_shape"] == "FLAT"
|
||||||
modifier = result.modifiers[0]
|
modifier = result.modifiers[0]
|
||||||
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"]
|
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.vertices) > 4
|
||||||
assert len(result.data.polygons) > 1
|
assert len(result.data.polygons) > 1
|
||||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ class CutoutOperatorsTest(BlenderTestCase):
|
|||||||
[operator.model],
|
[operator.model],
|
||||||
)
|
)
|
||||||
self.assertTrue(parameters["generate_depth"])
|
self.assertTrue(parameters["generate_depth"])
|
||||||
self.assertEqual(parameters["alpha_threshold"], 0.25)
|
self.assertNotIn("alpha_threshold", parameters)
|
||||||
self.assertEqual(parameters["normal_mode"], "OBJECT")
|
self.assertEqual(parameters["normal_mode"], "OBJECT")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from tests.support.planes import surface
|
|||||||
def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch):
|
def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surface, monkeypatch):
|
||||||
from anyimage.operators.convert_to_plane import object as conversion
|
from anyimage.operators.convert_to_plane import object as conversion
|
||||||
from anyimage.common.image import image_empty_bounds
|
from anyimage.common.image import image_empty_bounds
|
||||||
|
from anyimage.common.depth import reference_depth
|
||||||
from mathutils import Matrix, Vector
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
obj, _, _, inputs, image = surface
|
obj, _, _, inputs, image = surface
|
||||||
@@ -22,7 +23,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf
|
|||||||
bounds = image_empty_bounds(source)
|
bounds = image_empty_bounds(source)
|
||||||
matrix = source.matrix_world.copy()
|
matrix = source.matrix_world.copy()
|
||||||
material = bpy.data.materials.new("Conversion")
|
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))}
|
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
|
||||||
result = conversion.create_depth_plane_object(
|
result = conversion.create_depth_plane_object(
|
||||||
bpy.context, source, 2, material, image, metadata, "DEPTH")
|
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 Image"].identifier] == image
|
||||||
assert modifier[inputs["Depth Mask"].identifier] is True
|
assert modifier[inputs["Depth Mask"].identifier] is True
|
||||||
assert modifier[inputs["Reference Depth"].identifier] == pytest.approx(
|
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
|
assert result.data.materials[0] == material
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa
|
|||||||
raise RuntimeError("input failed")
|
raise RuntimeError("input failed")
|
||||||
|
|
||||||
monkeypatch.setattr(conversion, "set_modifier_input", fail)
|
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))}
|
"intrinsics": ((4, 0, 5), (0, 4, 3), (0, 0, 1))}
|
||||||
with pytest.raises(RuntimeError, match="input failed"):
|
with pytest.raises(RuntimeError, match="input failed"):
|
||||||
conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH")
|
conversion.create_depth_plane_object(bpy.context, source, 2, material, image, metadata, "DEPTH")
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
|
|||||||
_depth_metadata(
|
_depth_metadata(
|
||||||
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
||||||
(2, 2),
|
(2, 2),
|
||||||
1.0,
|
|
||||||
),
|
),
|
||||||
"DEPTH",
|
"DEPTH",
|
||||||
)
|
)
|
||||||
@@ -192,7 +191,6 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
|
|||||||
_depth_metadata(
|
_depth_metadata(
|
||||||
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
||||||
(4, 4),
|
(4, 4),
|
||||||
1.0,
|
|
||||||
),
|
),
|
||||||
"RELIEF",
|
"RELIEF",
|
||||||
)
|
)
|
||||||
@@ -208,7 +206,7 @@ def test_relief_plane_builds_a_fixed_base_terrain_block():
|
|||||||
] == 0.5
|
] == 0.5
|
||||||
assert modifier[
|
assert modifier[
|
||||||
object_data.modifier_input_identifier(group, "Depth Offset")
|
object_data.modifier_input_identifier(group, "Depth Offset")
|
||||||
] == 1.0
|
] == 0.0
|
||||||
|
|
||||||
def set_input(name, value):
|
def set_input(name, value):
|
||||||
object_data.set_modifier_input(
|
object_data.set_modifier_input(
|
||||||
@@ -299,7 +297,6 @@ def test_relief_plane_initializes_a_fitted_depth_direction():
|
|||||||
metadata = _depth_metadata(
|
metadata = _depth_metadata(
|
||||||
((2.0, 0.0, 2.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0)),
|
((2.0, 0.0, 2.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0)),
|
||||||
(5, 3),
|
(5, 3),
|
||||||
2.0,
|
|
||||||
)
|
)
|
||||||
result = plane_object.create_depth_plane_object(
|
result = plane_object.create_depth_plane_object(
|
||||||
bpy.context,
|
bpy.context,
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class PlaneOperatorsTest(BlenderTestCase):
|
|||||||
depth_image = SimpleNamespace(users=0)
|
depth_image = SimpleNamespace(users=0)
|
||||||
normal_image = SimpleNamespace(users=0)
|
normal_image = SimpleNamespace(users=0)
|
||||||
material = 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 = {
|
files = {
|
||||||
"depth_metadata": Path("depth.json"),
|
"depth_metadata": Path("depth.json"),
|
||||||
"depth": Path("depth.exr"),
|
"depth": Path("depth.exr"),
|
||||||
@@ -153,7 +156,10 @@ class PlaneOperatorsTest(BlenderTestCase):
|
|||||||
patch.object(
|
patch.object(
|
||||||
module,
|
module,
|
||||||
"load_depth_metadata",
|
"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_depth_result_image", return_value=depth_image),
|
||||||
patch.object(module, "load_normal_result_image", return_value=normal_image),
|
patch.object(module, "load_normal_result_image", return_value=normal_image),
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ class GeometryArtifactsTest(ServerTestCase):
|
|||||||
((1.0, 1.0, 0.0), (1.0, 1.0, 1.0)),
|
((1.0, 1.0, 0.0), (1.0, 1.0, 1.0)),
|
||||||
dtype=np.float32,
|
dtype=np.float32,
|
||||||
)
|
)
|
||||||
calibration = np.asarray(((False, True, True), (False, False, True)))
|
|
||||||
frame = GeometryFrame(
|
frame = GeometryFrame(
|
||||||
depth=np.asarray(
|
depth=np.asarray(
|
||||||
((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)),
|
((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_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.assertTrue(texture_path.is_file())
|
||||||
self.assertEqual(texture_path.read_bytes()[:4], b"v/1\x01")
|
self.assertEqual(texture_path.read_bytes()[:4], b"v/1\x01")
|
||||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(metadata["image_size"], [3, 2])
|
self.assertEqual(metadata["image_size"], [3, 2])
|
||||||
self.assertEqual(metadata["intrinsics"], np.eye(3).tolist())
|
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):
|
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(write.call_args.args[0][..., 3], [[1, 0], [1, 1]])
|
||||||
np.testing.assert_array_equal(frame.validity, np.ones((2, 2)))
|
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()
|
infer.assert_called_once()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
set(result),
|
set(result),
|
||||||
@@ -251,5 +252,4 @@ class GeometryArtifactsTest(ServerTestCase):
|
|||||||
input_path,
|
input_path,
|
||||||
generate_depth=True,
|
generate_depth=True,
|
||||||
normal_mode="OBJECT",
|
normal_mode="OBJECT",
|
||||||
alpha_threshold=0.9,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = {
|
prediction = {
|
||||||
"depth": np.ones((2, 2), dtype=np.float32),
|
"depth": np.ones((2, 2), dtype=np.float32),
|
||||||
"normal": np.zeros((2, 2, 3), dtype=np.float32),
|
"normal": np.zeros((2, 2, 3), dtype=np.float32),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import numpy as np
|
|||||||
from tests.support.nodes import CUTOUT_BUILDERS
|
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)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
|
group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
|
||||||
# Two columns with a complete depth jump along their common boundary.
|
# 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),
|
("Boundary Smooth", 0),
|
||||||
):
|
):
|
||||||
set_value(name, value)
|
set_value(name, value)
|
||||||
if wrapper:
|
|
||||||
set_value("Cleanup Threshold", 0.0)
|
|
||||||
return obj, set_value
|
return obj, set_value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ def _create_cutout_shape(
|
|||||||
bpy.context,
|
bpy.context,
|
||||||
source,
|
source,
|
||||||
shape,
|
shape,
|
||||||
edge_length,
|
|
||||||
selection_mask.values,
|
selection_mask.values,
|
||||||
selection_mask.bounds,
|
selection_mask.bounds,
|
||||||
base_shape,
|
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)
|
points = np.asarray(points, dtype=np.float32)
|
||||||
valid = np.asarray(valid, dtype=bool)
|
alpha = np.asarray(alpha, dtype=np.float32)
|
||||||
height, width = valid.shape
|
height, width = alpha.shape
|
||||||
pixels = np.zeros((height, width, 4), dtype=np.float32)
|
pixels = np.zeros((height, width, 4), dtype=np.float32)
|
||||||
pixels[..., :3] = points
|
pixels[..., :3] = points
|
||||||
pixels[..., 3] = valid
|
pixels[..., 3] = alpha
|
||||||
image = bpy.data.images.new(
|
image = bpy.data.images.new(
|
||||||
name,
|
name,
|
||||||
width=width,
|
width=width,
|
||||||
@@ -92,11 +91,10 @@ def _depth_image(points, valid, name="Depth"):
|
|||||||
return image
|
return image
|
||||||
|
|
||||||
|
|
||||||
def _depth_metadata(intrinsics, image_size, reference_depth):
|
def _depth_metadata(intrinsics, image_size):
|
||||||
return {
|
return {
|
||||||
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
|
"intrinsics": tuple(tuple(float(value) for value in row) for row in intrinsics),
|
||||||
"image_size": tuple(int(value) for value in image_size),
|
"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
|
modifier.node_group = group
|
||||||
if shape in {"FLAT", "DEPTH_SOLID"}:
|
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, "Mode"), 1)
|
||||||
object_data.set_modifier_input(
|
|
||||||
modifier,
|
|
||||||
object_data.modifier_input_identifier(group, "Cleanup Threshold"),
|
|
||||||
0.0,
|
|
||||||
)
|
|
||||||
if shape == "DEPTH_SOLID":
|
if shape == "DEPTH_SOLID":
|
||||||
object_data.set_modifier_input(
|
object_data.set_modifier_input(
|
||||||
modifier,
|
modifier,
|
||||||
@@ -129,73 +122,3 @@ def _add_cutout_modifier(obj, shape):
|
|||||||
return modifier
|
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()
|
|
||||||
|
|||||||
@@ -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}
|
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])
|
@pytest.mark.parametrize("depth_scale", [0.0, 2.0])
|
||||||
def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
||||||
obj, set_value = surface(step=4.0)
|
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():
|
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("Depth Split", 0.5)
|
||||||
set_value("Thickness", 0.2)
|
set_value("Thickness", 0.2)
|
||||||
coords, faces = evaluated(obj)
|
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("thickness", [0.0, 0.2])
|
||||||
@pytest.mark.parametrize("split", [0.0, 0.5])
|
@pytest.mark.parametrize("split", [0.0, 0.5])
|
||||||
def test_temporary_fields_are_removed_and_user_attributes_survive(thickness, split):
|
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")
|
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
|
||||||
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
||||||
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
|
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def test_saved_parameter_descriptions_match_definitions():
|
|||||||
thickness = next(item for item in sockets if item.name == "Thickness")
|
thickness = next(item for item in sockets if item.name == "Thickness")
|
||||||
direction = next(item for item in sockets if item.name == "Depth Direction")
|
direction = next(item for item in sockets if item.name == "Depth Direction")
|
||||||
offset = next(item for item in sockets if item.name == "Depth Offset")
|
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.min_value, offset.max_value) == (-10.0, 10.0)
|
||||||
assert offset.subtype == "DISTANCE"
|
assert offset.subtype == "DISTANCE"
|
||||||
assert direction.parent == thickness.parent == offset.parent
|
assert direction.parent == thickness.parent == offset.parent
|
||||||
|
|||||||
@@ -3,27 +3,6 @@ from .nodes import math_node
|
|||||||
from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME
|
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):
|
def _profile_field(group, geometry):
|
||||||
nodes = group.nodes
|
nodes = group.nodes
|
||||||
links = group.links
|
links = group.links
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import bpy
|
import bpy
|
||||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
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.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"
|
NODE_GROUP_NAME = "O Image Cutout"
|
||||||
@@ -157,8 +157,6 @@ def build_image_cutout_group():
|
|||||||
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
|
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, '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.'
|
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')
|
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||||
controls = group_input(group.nodes, set())
|
controls = group_input(group.nodes, set())
|
||||||
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
|
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(balloon, choose.inputs['Balloon'])
|
||||||
group.links.new(shell, choose.inputs['Shell'])
|
group.links.new(shell, choose.inputs['Shell'])
|
||||||
geometry = choose.outputs[0]
|
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')
|
profile = read_float_attribute(group, 'o_balloon')
|
||||||
amount = group_input(group.nodes, {'Thickness'})
|
amount = group_input(group.nodes, {'Thickness'})
|
||||||
enabled = math_node(group.nodes, 'GREATER_THAN')
|
enabled = math_node(group.nodes, 'GREATER_THAN')
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Build O Image Depth Cutout."""
|
"""Build O Image Depth Cutout."""
|
||||||
import bpy
|
import bpy
|
||||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
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.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.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.smoothing import edge_boundary_field
|
||||||
from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile
|
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 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.'
|
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)
|
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, '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)
|
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', 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
|
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, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
|
||||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
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())
|
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)
|
_shade_output(group, geometry)
|
||||||
prepare_node_group(group)
|
prepare_node_group(group)
|
||||||
for item in group.interface.items_tree:
|
for item in group.interface.items_tree:
|
||||||
|
|||||||
@@ -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."
|
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."
|
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 = _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.min_value = -10.0
|
||||||
depth_offset.max_value = 10.0
|
depth_offset.max_value = 10.0
|
||||||
depth_offset.subtype = "DISTANCE"
|
depth_offset.subtype = "DISTANCE"
|
||||||
|
|||||||
Reference in New Issue
Block a user