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:
2026-09-15 22:50:16 +08:00
parent 94130ff38f
commit 2dfa9b2add
37 changed files with 665 additions and 639 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15

View 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`] → 已保存数值按原样保留,只有重新生成深度几何时才使用新基准。

View 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 数值不变,但重新生成深度几何的结果会变化。

View File

@@ -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** 基准平面位于参考深度

View 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.

View File

@@ -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))

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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",
) )

View File

@@ -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()

View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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)),),)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,
) )

View File

@@ -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),

View File

@@ -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

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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:

View File

@@ -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"