Add soft min and max constraints for frame resolution and cutout edge length properties

This commit is contained in:
2026-09-13 11:22:20 +08:00
parent 18799e8828
commit 2a00ed0ee6
21 changed files with 3834 additions and 1293 deletions

View File

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

View File

@@ -0,0 +1,64 @@
## Context
当前 Fine Outline 使用轮廓近似、内部支撑、自由点松弛和 Blender CDT,再通过 `support_flat_regions` 拆分边界弦及扇形补面;普通模式在 `cutout_domain` 中采用另一套三角化及 `refine_balloon_triangles`。两条最终建网路径需要统一,减少质量细化后的再次碎分。
临时实验采用相同轮廓、延伸中线和 10°目标,Tangle 的鱼/甲虫分别为 2797/655 面,Triangle 为 2529/605 面;Tangle 的六张样例均通过面积、孔洞数、中线覆盖和非退化检查。这些是外部 Python 实验,尚不能替代 Blender 验收。实验保留全部有效孔洞,因此新增微孔策略需要独立验证。
现有世界 Edge Length 与相对下限已完成,所有几何尺度沿用其有效间距 h。早期变化中的 Mesh Detail 档位描述以该世界边长行为为准。
## Goals / Non-Goals
**Goals:** 在统一基础网格中保持轮廓、有效孔洞和细分支内部支撑,控制普通区域密度,使用可随 Blender 分发的纯 Python 后端。
**Non-Goals:** 本次不新增算法选择器、纯平面专用算法或 C++ 二进制,保留现有高度算法。
## Decisions
### 1. 随包分发固定版本的 Tangle Python 源码
第三方实现放在 `src/anyimage/operators/cutout_tool/tangle.py`,MIT 文本放在现有 `src/anyimage/licenses/`。实施时固定上游提交及源码哈希,记录来源与必要本地改动,避免跟随浮动 master。实验版本标识为 0.4.18,实际分发以核验的提交为准。
将数值调用整理为内存输入输出:点、带身份的约束边、孔内种子、质量参数,返回顶点、三角面和约束来源。保留上游算法结构,业务预处理和验证由 Cutout 网格模块负责。调用不依赖命令行参数、临时 `.poly` 文件或额外 Python 进程;隔离 Decimal 精度、模块状态和退出处理,重复调用互不影响。优先使用已有标准库功能,Tangle 本身不新增第三方包。
### 2. Fine Outline 决定建网区域与轮廓精度
从当前 Alpha Threshold 和 Selection 得到前景,沿用极小独立前景分量清理规则。先按世界平面度量确定有效 h,再做孔洞策略,之后重新提取组件与轮廓:
| 模式 | 内部封闭孔洞 | 外轮廓 | 支撑依据 |
|---|---|---|---|
| 开启 | 保留有效孔洞,填充微孔 | 随 h 保留精细形状 | 微孔处理后的区域 |
| 关闭 | 全部填充 | 随 h 平滑、概括 | 填孔及轮廓概括后的区域 |
微孔初始规则采用双条件:世界平面中的包围盒最长边 ≤ 0.25h,且面积 ≤ 0.04h²,才填充。长而窄的孔保留;0.9h 的孔不会因略小于 h 被移除。实施中先按此保守规则建立边界用例,真实图只用于校准与记录,不依赖其像素尺寸定义阈值。填孔后重建拓扑,处理孔中岛合并,避免重复面。
几何填孔不改写图片或材质 Alpha。背景连通的凹口不是封闭孔洞,仍参与外轮廓。简化后的最终轮廓必须通过有效性检查,内部支撑直接来自最终网格。
### 3. 质量网格按需补足内部支撑
输入 PSLG 仅包含最终轮廓约束,在与 h 关联的归一化坐标中合并重合点、分割点落边并保留约束身份。各组件以辅助三角化的最大面中心作为初始内部点,孔内种子必须位于对应孔内。像素角自接触处按相邻边长的 1/8 分离,消除零宽连接;简化结果重新验证,保留有效孔洞。
Tangle 使用 10°目标最小角和最大面积 √3/4 × h²。完成面积、孔洞、组件和边界约束校验后,给两端都在边界上的内部边添加共享中点,并一致拆分相邻面;只有孤立全边界三角形需要补重心。已有内部支撑的区域保持原网格。此操作单次完成,新增点数受组件累计顶点上限约束,不重新进行全局三角化。
局部拆分保持轮廓、面积和面朝向,形成细分支到主体的连续内部支撑。它可能降低部分三角形最小角,10°作为基础三角化目标,最终角度与凸起效果共同验收。删除不再使用的中线图、宽度距离场和路径约束处理;辅助 Blender CDT 用于初始点和孔种子。
低分辨率深度投影可能将相邻轮廓采样映射到同一点。深度 Cutout 在投影和平滑后,以平均边长的百万分之一合并已连接重合点,再求壳体厚度,避免数值退化面翻转法线。节点资产与实际求值测试同步验证。
### 4. 验收同时覆盖平面几何和高度
- 自动化用例使用可再生的合成多孔形状、弯曲细腿、尖角、孔中岛和点落边约束,避免测试依赖用户临时图片。
- 最终网格验证面积、孔洞及组件数量、非退化面、轮廓覆盖;细分支到主体的内部网格边连续连接,内部支撑高度为有限正值。
- 六张用户图用于本地视觉和性能复核,重点比较鱼的多孔区域、甲虫上角到根部及细腿。报告面数、低于 10°占比、小面面积占比、压平面积与位置、耗时,不要求三角面索引或面数与实验逐项相同。
- 分别验证 Fine Outline 两种模式及世界缩放、非均匀缩放、分辨率和透明画布变化。关闭模式使用填孔后的期望拓扑。
- 在项目支持的 Blender 环境中验证源码导入、重复数值调用、基础网格创建与同步/AI 返回路径;包括现有分发覆盖的 Python 3.11/3.13 环境。缺少其中环境时明确记录,不以外部解释器验证代替。
## Risks / Trade-offs
- 纯 Python 在密集输入上变慢 → 用预热与重复测量分别记录三角化、完整建网与 Blender 创建;对大规模多孔输入实现确定的迭代/顶点资源上限和明确失败结果,不静默降级。
- 上游命令行实现含进程级状态 → 在数值接口测试 Decimal 上下文、错误退出、重复调用与失败后恢复。
- 孔洞密集或边界尖角仍要求小面 → 保守微孔策略和质量统计共同控制,避免为了禁止短边破坏主体。
- 局部支撑拆分降低部分面角度 → 复核最终角度和粗网格凸起效果。
- 六张图覆盖有限 → 合成回归、Blender 实测与高复杂度资源测试共同验收。
## Migration Plan
先固定后端与数值接口,再接入统一区域、中线及三角化,最后清理旧调用。检查源码和许可被现有打包规则收录;仅在实际改变依赖元数据时同步相关清单。运行相关测试,不构建扩展包或同步其他说明文档。若验收失败,在当前实现阶段修复;已集成变更的回退使用版本控制恢复整个变更,不保留运行时旧算法分支。

View File

@@ -0,0 +1,28 @@
## Why
Cutout 在多孔区域和细长结构附近产生拉伸三角形,现有补面细分进一步增加碎面。六张透明图的实验表明,Tangle Python 版能够保留孔洞与延伸中线,效果接近已认可的 Triangle,且仅依赖 Python 标准库、采用 MIT 许可,适合随 Blender 插件分发。
## What Changes
- 使用随插件分发的 Tangle Python 实现统一构建 Cutout 基础三角网格,质量目标为 10°,密度由有效世界 Edge Length 驱动。
- Fine Outline 开启时保留精细轮廓和有效孔洞,仅过滤远小于目标尺度的微孔;关闭时填充全部封闭内部孔洞并概括外轮廓。
- 两种模式均先生成质量网格,拆分两端位于边界的内部弦,为孤立的全边界三角形补一个内部点,保持细长分支到主体的连续支撑。
- 用质量三角化替换最终建网中的旧布点与扇形补面流程,检查孔洞、实际约束链、退化面和高度结果。
- 固定第三方源码版本并保留许可;验证 Blender 实际调用与资源边界。
## Capabilities
### New Capabilities
- `cutout-quality-meshing`: 定义统一三角化、Fine Outline 孔洞策略、内部支撑与 Blender 接入验收。当前 `openspec/specs/` 为空,本变更建立此能力规格;世界边长行为承接已完成的 `use-world-space-cutout-edge-length`。
### Modified Capabilities
无已归档主规格需要修改。
## Impact
- 涉及 `src/anyimage/operators/cutout_tool/geometry.py`、统一建网模块 `mesh.py`(替换 `fine_mesh.py`)及基础网格调用链,新增第三方 Tangle 源码和 `licenses/tangle.txt`。
- 涉及 Cutout 几何、世界边长、Operator 与依赖分发相关测试。
- 运行时新增依赖为随包携带的纯 Python 源码;原有 NumPy、SciPy 继续服务于轮廓和高度计算。
- Fine Outline 的设置捕获继续覆盖同步与 AI 返回路径,基础网格供现有 Cutout Shape 使用。

View File

@@ -0,0 +1,71 @@
## ADDED Requirements
### Requirement: Unified quality meshing
系统 SHALL 在 Fine Outline 两种状态下使用 Tangle Python 版构建最终基础三角网格,采用 10°质量目标及与有效世界边长 h 对应的最大面积 √3/4 × h²。系统 SHALL 保持现有世界 Edge Length、相对下限和非均匀缩放语义,允许细结构产生短于 h 的边。
#### Scenario: Shared density controls
- **WHEN** 相同物理主体更换分辨率、透明画布或对象旋转后创建 Cutout
- **THEN** 同一有效 h 驱动三角化;两种 Fine Outline 模式均使用 Tangle
#### Scenario: Acute constrained feature
- **WHEN** 必须保留的细结构包含尖角或短约束
- **THEN** 系统保留结构,报告实际质量统计,不以删除有效结构强行满足最短边或角度目标
### Requirement: Outline mode defines holes
系统 SHALL 在关闭 Fine Outline 时填充所有封闭内部孔洞并概括外轮廓;开启时仅在孔最长世界跨度 ≤ 0.25h 且面积 ≤ 0.04h² 时填孔,保留其他孔洞。系统 SHALL 从处理后区域重建组件,且不修改原图片和材质 Alpha。
#### Scenario: Fine mode keeps a moderately small hole
- **WHEN** 孔最长跨度为 0.9h,或细长孔的最长跨度大于 0.25h
- **THEN** 开启 Fine Outline 时孔被保留,即使孔面积很小
#### Scenario: Fine mode fills a micro hole
- **WHEN** 封闭孔满足两个微孔条件
- **THEN** 开启 Fine Outline 也填充该孔,后续支撑和三角化使用填孔结果
#### Scenario: Coarse mode fills a nested hole
- **WHEN** 关闭 Fine Outline,内部孔包含独立前景岛
- **THEN** 填孔后区域重新合并,不产生重叠面;与外部背景相通的凹口仍属于外轮廓
### Requirement: Continuous interior support
系统 SHALL 在两种模式下按实际质量网格补足内部支撑。系统 SHALL 拆分两端位于边界的内部弦,为孤立全边界三角形添加内部点,保持细分支经关节到主体的连续内部连接。
#### Scenario: Coarse branch requires support
- **WHEN** 低密度网格的细腿或根部缺少内部顶点
- **THEN** 系统添加局部支撑,Poisson 求解后内部顶点高度为正,不产生全零高度三角形
#### Scenario: Dense region already has support
- **WHEN** 网格区域已经具有连续内部支撑
- **THEN** 系统保持该区域,不生成额外全局中线约束,不重新三角化
### Requirement: Final constraints and topology are validated
系统 SHALL 验证面积、轮廓约束链、孔洞和组件数量、非退化性,且局部支撑拆分 SHALL 保持已验证的轮廓、拓扑与面朝向。像素角自接触 SHALL 做亚像素分离并重新验证。
#### Scenario: Pixel-corner hole contact
- **WHEN** 孔洞轮廓在像素角形成零宽自接触
- **THEN** 系统分离接触点,生成拓扑有效且保留孔洞的网格
#### Scenario: Mesher loses a boundary constraint
- **WHEN** 后端遗漏边界约束或产生错误面积
- **THEN** 系统明确失败,不创建残缺对象
### Requirement: Height support preserves mesh quality
系统 SHALL 在单次局部拆分中补足支撑,并限制累计顶点数。最终角度允许低于基础三角化的 10°目标,验收 SHALL 同时检查角度、连续正高度与压平面积。
#### Scenario: Connected depth samples collapse
- **WHEN** 深度投影将相邻顶点映射到数值上重合的位置
- **THEN** 系统按当前平均边长的百万分之一合并已连接重合点,壳体厚度保持正确方向
### Requirement: Blender distribution and bounded execution
系统 SHALL 分发固定版本的 Tangle Python 源码和 MIT 许可,通过内存数值接口调用,仅使用标准库作为 Tangle 自身依赖。系统 SHALL 隔离调用状态,保持 Blender 数据创建在主线程,并对资源超限明确终止。
#### Scenario: Clean Blender installation
- **WHEN** 在项目支持的 Blender 环境加载插件并创建 Cutout
- **THEN** Tangle 无需用户 pip 安装或外部编译器即可调用,源码和许可被分发规则收录
#### Scenario: Repeated and failed calls
- **WHEN** 连续构建不同输入,包含一次无效输入或资源超限
- **THEN** 后续调用不受先前状态影响,Decimal 上下文保持隔离,失败不创建残缺对象
#### Scenario: Local and AI return paths
- **WHEN** 同步创建或 AI 返回后创建 Cutout
- **THEN** 两者采用捕获的 Fine Outline 设置及同一有效世界边长与网格校验规则

View File

@@ -0,0 +1,52 @@
## 1. 固定数值后端
- [x] 1.1 核验并固定 Tangle Python 上游提交、源码哈希和 MIT 许可,加入随插件分发的源码与许可文件。
- [x] 1.2 提供内存数值入口,返回三角面和约束来源;隔离 Decimal 上下文、命令行退出与可变状态,验证重复调用和失败后恢复。
- [x] 1.3 验证现有分发规则收录源码与许可;检查依赖清单一致性,不构建扩展包。
## 2. 统一轮廓和孔洞策略
- [x] 2.1 在现有有效世界 Edge Length 度量下实现 Fine Outline 开启的保守微孔双阈值,以及关闭时的全部封闭孔填充。
- [x] 2.2 填孔后重建组件与轮廓,处理孔中岛、背景连通凹口和空输入;保持原图片和材质 Alpha。
- [x] 2.3 接入两种外轮廓精度策略,确保中线距离场、域查询和最终轮廓一致;覆盖分辨率、画布与非均匀缩放用例。
## 3. 延伸中线并接入 Tangle
- [x] 3.1 将细分支支撑沿有效内部图延伸至所在主体,保留稀疏性并检查关节、根部和跨孔路径。
- [x] 3.2 规范化 PSLG 重合点、点落边和重复约束,生成可靠孔内种子并保留中线身份。
- [x] 3.3 两种模式共用 10°和最大面积参数的 Tangle 最终三角化,输出现有基础网格所需组件、UV、边界及高度输入。
- [x] 3.4 验证最终约束链、面积、孔洞、组件与非退化性;增加有界细化和明确错误传播,失败不创建残缺对象。
- [x] 3.5 删除最终建网中失去用途的旧布点、三角化及扇形补面路径,保留仍用于中线构图的必要几何函数并理顺命名。
## 4. 验证与复核
- [x] 4.1 补充合成多孔、细腿根部、微孔阈值两侧、细长孔、孔中岛与点落边回归;检查实际边链及正高度支撑。
- [x] 4.2 用六张用户图复核两种模式,重点输出鱼与甲虫的网格和高度对照;记录面数、角度、小面和压平面积、位置及耗时。
- [x] 4.3 在支持的 Blender/Python 环境执行导入、重复调用、基础网格创建及同步/AI 返回路径验证,记录实际覆盖版本。
- [x] 4.4 对高复杂度多孔输入验证迭代/顶点限制、失败恢复及耗时,确认不会无界细分或静默切回旧算法。
- [x] 4.5 运行相关 Cutout 几何、边长、对象、Operator 和分发测试,检查最终差异与旧引用;仅在发现关联故障时扩大测试范围。
## 验证记录
- 相关几何、边长、对象、Operator、交互、节点消费与分发规则测试:141 passed,7 subtests passed。分发测试使用内存归档替身,未构建扩展包。
- Blender 4.5.10 / Python 3.11.11、Blender 5.2.0 / Python 3.13.13:各 81 passed、4 subtests passed,正常退出。使用 Blender 内置 NumPy 和对应随包 SciPy wheel;5.2 测试通过运行时 socket API 读取修改器输入。
- 六张原图的两种模式均通过;各预热一次并测量两次。鱼精细/普通为 2266/972 面、25/0 孔,甲虫为 669/382 面。精细模式压平面积:鱼 0.056%,甲虫 0%;精细建网中位耗时分别约 0.67s、0.13s(不含图片读取及高度求解)。
- 256 孔合成图:2K 与 4K 在同一物理间距下均得到 3448 顶点、6044 面,构建分别约 2.25s、6.23s;资源上限及失败后恢复另有自动化测试覆盖。
- 临时对照结果:`C:/Users/icrdr/AppData/Local/Temp/anyimage-mesh-trial-e5fd3fe134d24686900f07f9308db158/integrated-results.html`,含六图网格、Poisson 高度投影与两种模式对照。
- 最终源码使用 `mesh.py` 承担两种模式的统一建网职责,替换原 `fine_mesh.py`;第三方版本与原始哈希记录于 `tangle.py` 文件头。
## 5. 按实际网格补足支撑
- [x] 5.1 用单次局部补点替换中线图和距离场,保留连续内部支撑及累计顶点上限,删除旧中线调用与专用测试。
- [x] 5.2 分离像素角孔洞自接触,保持面积、孔洞和边界约束校验,复核密鱼能正常完成。
- [x] 5.3 修复低分辨率深度投影的相邻点重合,构建节点资产并验证壳体方向。
- [x] 5.4 运行粗密网格、两种 Fine Outline、真实样本和 Blender 相关回归,记录结果。
### 局部支撑复核
- 删除中线图、宽度场和全局支撑约束,单次局部补点保持内部连通;新增粗密分支、孤立三角形、共享弦、资源上限、像素角接触及边界损坏测试。最终网格专项 45 passed。
- 六张用户原图、两种 Fine Outline 共 20 组配置通过;鱼和甲虫包含最长边 / 5、/ 20、/ 100 三档。全部 Poisson 高度有限、内部顶点为正,无全零高度面。密鱼精细模式 27077 面、55 孔,约 2–3 秒;密甲虫 9272 面,约 0.67–1.03 秒(本机单次建网,区间含并行测试负载)。
- 极粗精细模式:甲虫 156 面,鱼 585 面,压平面积均为 0%。局部拆分允许部分角度低于 10°。
- 深度投影合并数值重合的已连接顶点,修复角部退化面导致的壳体厚度翻转。已运行 `uv run node-group build`:10 个保存节点组加载验证通过,构建流程全部 1024 测试通过并正常退出。
- Blender 4.5.10 / Python 3.11.11、Blender 5.2.0 / Python 3.13.13:各 81 passed、4 subtests passed,正常退出;未构建扩展包。
- 临时原图结果:`C:/Users/icrdr/AppData/Local/Temp/anyimage-support-integrated/results.json`。

Binary file not shown.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 David Meeker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,636 +0,0 @@
"""Fine Cutout contours, sparse constrained support and interior sampling."""
import heapq
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
from scipy import ndimage
from scipy.spatial import cKDTree
from .polygon import approximate_polygon
from . import geometry as g
def valid_contours(contours):
"""Reject crossing rings and holes moved outside the outer ring."""
domain = ContourDomain(contours)
count = len(domain.starts)
following = np.concatenate(
[
np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1)
for index, contour in enumerate(contours)
]
)
for first in range(0, count, 32):
ids = np.arange(first, min(first + 32, count))[:, None]
other = np.arange(count)[None, :]
a = domain.starts[first : first + 32, None]
v = domain.delta[first : first + 32, None]
w = domain.delta[None]
d = domain.starts[None] - a
determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0]
divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0)
t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor
u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor
adjacent = (other == following[ids]) | (ids == following[other])
if np.any(
(other > ids)
& ~adjacent
& (np.abs(determinant) > 1e-12)
& (t > 1e-9)
& (t < 1 - 1e-9)
& (u > 1e-9)
& (u < 1 - 1e-9)
):
return False
return all(
g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:]
)
def support_flat_regions(points, faces):
"""Split interior boundary chords so inflated faces remain connected."""
boundary = g.boundary_vertices_from_faces(len(points), faces)
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
unique, counts = np.unique(edges, axis=0, return_counts=True)
chords = unique[(counts == 2) & boundary[unique].all(axis=1)]
midpoints = {tuple(edge): len(points) + index for index, edge in enumerate(chords)}
vertices = points.tolist() + points[chords].mean(axis=1).tolist()
triangles = []
for face in faces:
ring = []
for index, start in enumerate(face):
end = face[(index + 1) % 3]
ring.append(int(start))
midpoint = midpoints.get(tuple(sorted((int(start), int(end)))))
if midpoint is not None:
ring.append(midpoint)
if len(ring) > 3 or boundary[face].all():
center = len(vertices)
vertices.append(points[face].mean(axis=0).tolist())
triangles.extend(
(center, start, ring[(index + 1) % len(ring)])
for index, start in enumerate(ring)
)
else:
triangles.append(tuple(face))
return np.asarray(vertices), np.asarray(triangles, dtype=np.int32)
def build_fine_mesh(alpha, spacing, threshold, *, pixel_transform=None):
"""Return pixel-space triangles and component ranges for height solving."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
raise ValueError("The Cutout Alpha must be a finite two-dimensional array")
if not np.isfinite(spacing) or spacing <= 0:
raise ValueError("Cutout point spacing must be greater than zero")
filtered, _statistics = filter_alpha(alpha, threshold)
mask = (filtered > 0) & (filtered >= threshold)
if not mask.any():
raise ValueError("The Cutout selection contains no visible image pixels")
reference = g.extract_selection_contours(
filtered, max_resolution=max(mask.shape), threshold=threshold
)
# Normalize geometric tolerances and support sampling to the target length.
transform = (
np.eye(2) if pixel_transform is None else np.asarray(pixel_transform)
) * (32 / spacing)
inverse = np.linalg.inv(transform)
width_sizes = WidthSamples(mask, transform=transform)
spacing = 32.0
points, faces, components = [], [], []
offset = 0
for component in reference.components:
contours = contours_for_component([np.asarray(c) @ transform for c in component], spacing)
spine, path_edges = interior_paths(
contours, spacing, width_sizes
)
contours, spine, path_edges = subdivide_constraints(
contours, spine, path_edges, spacing
)
free = relax_points(contours, spine, spacing)
vertices, triangles, constraints = triangulate(
contours, spine, path_edges, free, spacing
)
if not len(triangles):
raise ValueError(
"Fine Outline could not triangulate a foreground component"
)
vertices, triangles = support_flat_regions(vertices, triangles)
boundary = g.boundary_vertices_from_faces(len(vertices), triangles)
if boundary[constraints].any():
raise ValueError(
"Fine Outline placed an interior constraint on the boundary"
)
points.append(vertices @ inverse)
faces.append(triangles + offset)
components.append((offset, offset + len(vertices), triangles, contours[0] @ inverse))
offset += len(vertices)
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
def clean_mesh(points, faces):
faces = np.asarray(faces, dtype=np.int32).reshape(-1, 3)
triangles = points[faces]
area = np.abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
faces = faces[area > 1e-8]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
return points[used], remap[faces]
def filter_alpha(alpha, threshold, min_area=16):
mask = (alpha > 0) & (alpha >= threshold)
labels, count = ndimage.label(mask)
areas = np.bincount(labels.ravel(), minlength=count + 1)
keep = areas >= min_area
keep[0] = False
if count:
keep[1 + np.argmax(areas[1:])] = True
removed = mask & ~keep[labels]
result = alpha.copy()
result[removed] = 0
return (
result,
{
"components_before": count,
"components_after": int(keep.sum()),
"removed_pixels": int(removed.sum()),
"foreground_pixels": int(mask.sum()),
},
)
def width_ridge_samples(image):
"""Supply ridge disks for width estimation, not a connected skeleton."""
distance = ndimage.distance_transform_edt(image)
ridge = np.zeros(image.shape, dtype=bool)
dominated = np.zeros(image.shape, dtype=bool)
for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)):
before = np.roll(distance, (dy, dx), axis=(0, 1))
after = np.roll(distance, (-dy, -dx), axis=(0, 1))
ridge |= (
(distance >= before)
& (distance >= after)
& ((distance > before) | (distance > after))
)
dominated |= np.maximum(before, after) >= distance + np.hypot(dx, dy) - 1e-9
ridge &= image & ~dominated
return ridge, distance
class WidthSamples:
"""Estimate local width from indexed distance-ridge disks."""
def __init__(self, mask, transform=None):
transform = np.eye(2) if transform is None else transform
self.inverse = np.linalg.inv(transform)
self.size_scale = np.linalg.svd(transform, compute_uv=False).min()
ridge, distance = width_ridge_samples(np.pad(mask, 1))
ridge = ridge[1:-1, 1:-1]
self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3)
yy, xx = np.nonzero(ridge)
self.centers = np.column_stack((xx + 0.5, yy + 0.5))
self.radius = self.distance[yy, xx]
self.reach_squared = (self.radius + 0.75) ** 2
buckets = np.floor(np.log2(self.radius)).astype(int)
self.groups = []
for bucket in np.unique(buckets)[::-1]:
ids = np.flatnonzero(buckets == bucket)
self.groups.append(
(ids, cKDTree(self.centers[ids]), float(self.radius[ids].max() + 0.75))
)
def __call__(self, points):
points = points @ self.inverse
radius = np.full(len(points), 0.5)
pending = np.arange(len(points))
for ids, tree, reach in self.groups:
if not len(pending):
break
neighbors = tree.query_ball_point(points[pending], reach)
solved = np.zeros(len(pending), dtype=bool)
for index, local in enumerate(neighbors):
if not len(local):
continue
candidates = ids[np.asarray(local)]
delta = self.centers[candidates] - points[pending[index]]
covered = (
np.sum(delta * delta, axis=1) <= self.reach_squared[candidates]
)
if covered.any():
radius[pending[index]] = max(
0.5, float(self.radius[candidates[covered]].max())
)
solved[index] = True
pending = pending[~solved]
distance = ndimage.map_coordinates(
self.distance,
[points[:, 1] - 0.5, points[:, 0] - 0.5],
order=1,
mode="nearest",
)
return np.maximum(
0.7, np.minimum(32, np.minimum(16, radius * self.size_scale * 0.9) + 0.4 * distance * self.size_scale)
)
class ContourDomain:
"""Reuse boundary segments for exact internal segment validation."""
def __init__(self, contours):
self.contours = contours
self.starts = np.concatenate(contours)
self.ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours])
self.delta = self.ends - self.starts
self.lengths = np.maximum(np.sum(self.delta**2, axis=1), 1e-20)
def clearance(self, points):
result = np.empty(len(points))
for first in range(0, len(points), 64):
d = points[first : first + 64, None, :] - self.starts[None, :, :]
t = np.clip(np.sum(d * self.delta, axis=2) / self.lengths, 0, 1)
result[first : first + 64] = np.sqrt(
np.min(np.sum((d - t[:, :, None] * self.delta) ** 2, axis=2), axis=1)
)
return result
def contains(self, points):
result = np.empty(len(points), dtype=bool)
a = self.starts
b = self.ends
dy = b[:, 1] - a[:, 1]
divisor = np.where(dy != 0, dy, 1.0)
for first in range(0, len(points), 64):
p = points[first : first + 64]
y = p[:, 1, None]
x = p[:, 0, None]
crosses = (a[:, 1] > y) != (b[:, 1] > y)
hit = a[:, 0] + (y - a[:, 1]) * (b[:, 0] - a[:, 0]) / divisor
result[first : first + 64] = (
np.count_nonzero(crosses & (x < hit), axis=1) % 2 == 1
)
return result
def valid_segments(self, starts, ends):
starts = np.asarray(starts)
ends = np.asarray(ends)
valid = self.contains(np.concatenate((starts, ends))).reshape(2, -1).all(axis=0)
valid &= np.minimum(self.clearance(starts), self.clearance(ends)) > 1e-05
for first in range(0, len(starts), 32):
a = starts[first : first + 32, None, :]
v = (ends - starts)[first : first + 32, None, :]
d = self.starts[None, :, :] - a
w = self.delta[None, :, :]
det = v[:, :, 0] * w[:, :, 1] - v[:, :, 1] * w[:, :, 0]
nonparallel = np.abs(det) > 1e-12
divisor = np.where(nonparallel, det, 1.0)
t = (d[:, :, 0] * w[:, :, 1] - d[:, :, 1] * w[:, :, 0]) / divisor
u = (d[:, :, 0] * v[:, :, 1] - d[:, :, 1] * v[:, :, 0]) / divisor
crossing = (nonparallel & (t >= 0) & (t <= 1) & (u >= 0) & (u <= 1)).any(
axis=1
)
factors = np.clip(
np.sum(d * v, axis=2) / np.maximum(np.sum(v * v, axis=2), 1e-20), 0, 1
)
distance = np.min(
np.sum((d - factors[:, :, None] * v) ** 2, axis=2), axis=1
)
valid[first : first + 32] &= ~crossing & (distance > 1e-10)
return valid
def contours_for_component(component, spacing, strength=1.0):
result = []
tolerance = (
2.5 if spacing > 16 else 1.25 if spacing > 8 else 0.625 if spacing > 4 else 0.5
)
for contour in component:
original = np.asarray(contour, dtype=float)
if spacing <= 16 and strength > 0:
original = ndimage.gaussian_filter1d(
g.resample_closed_contour(original, 1.0),
0.8 * strength,
axis=0,
mode="wrap",
)
for error in (tolerance * strength, tolerance * strength * 0.5, 0):
simplified = approximate_polygon(np.vstack((original, original[0])), error)[
:-1
]
if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05:
break
points = []
for a, b in zip(simplified, np.roll(simplified, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / 32)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
result.append(np.asarray(points))
if strength > 0 and not valid_contours(result):
return contours_for_component(
component, spacing, strength * 0.5 if strength > 0.125 else 0
)
return result
def interior_paths(contours, spacing, width_sizes):
p, f, _ = g._triangulate_contour_component(contours, np.empty((0, 2)), spacing)
p, f = clean_mesh(p, f)
users = {}
for ti, face in enumerate(f):
for i in range(3):
edge = tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
users.setdefault(edge, []).append(ti)
nodes = []
connections = []
midpoints = {}
for edge, owners in users.items():
if len(owners) == 2:
midpoints[edge] = len(nodes)
nodes.append(p[list(edge)].mean(axis=0))
for face in f:
mids = [
midpoints[e]
for i in range(3)
if (e := tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))) in midpoints
]
if len(mids) == 2:
connections.append(tuple(mids))
else:
center = len(nodes)
nodes.append(p[face].mean(axis=0))
connections.extend(((m, center) for m in mids))
if not nodes:
return (np.empty((0, 2)), [])
nodes = np.asarray(nodes)
domain = ContourDomain(contours)
radius = domain.clearance(nodes)
local_sizes = width_sizes(nodes)
connections = [(a, b) for a, b in connections if min(radius[a], radius[b]) > 0.0001]
if connections:
pairs = np.asarray(connections)
valid = domain.valid_segments(nodes[pairs[:, 0]], nodes[pairs[:, 1]])
connections = [edge for edge, ok in zip(connections, valid) if ok]
full_graph = {}
for a, b in connections:
length = float(np.linalg.norm(nodes[a] - nodes[b]))
full_graph.setdefault(a, []).append((b, length))
full_graph.setdefault(b, []).append((a, length))
selected = {
tuple(sorted((a, b)))
for a, b in connections
if min(radius[a], radius[b]) < spacing * 0.4
and min(local_sizes[a], local_sizes[b]) < min(16, spacing * 0.6)
}
if selected:
parents = {i: i for edge in selected for i in edge}
def root(i):
while parents[i] != i:
parents[i] = parents[parents[i]]
i = parents[i]
return i
for a, b in selected:
parents[root(a)] = root(b)
owners = {i: root(i) for i in parents}
distances = {i: 0.0 for i in parents}
previous = {}
queue = [(0.0, i) for i in parents]
heapq.heapify(queue)
while queue:
distance, node = heapq.heappop(queue)
if distance != distances[node]:
continue
for neighbor, length in full_graph.get(node, []):
if max(radius[node], radius[neighbor]) > spacing * 0.75:
continue
value = distance + length
if value < distances.get(neighbor, np.inf):
distances[neighbor] = value
owners[neighbor] = owners[node]
previous[neighbor] = node
heapq.heappush(queue, (value, neighbor))
candidates = []
for a, b in connections:
if (
a in owners
and b in owners
and (owners[a] != owners[b])
and (max(radius[a], radius[b]) <= spacing * 0.75)
):
candidates.append(
(
distances[a]
+ np.linalg.norm(nodes[a] - nodes[b])
+ distances[b],
a,
b,
)
)
for _, a, b in sorted(candidates):
ra, rb = (root(owners[a]), root(owners[b]))
if ra == rb:
continue
parents[ra] = rb
selected.add(tuple(sorted((a, b))))
for cursor in (a, b):
while cursor in previous:
other = previous[cursor]
selected.add(tuple(sorted((cursor, other))))
cursor = other
connections = sorted(selected)
graph = {}
for a, b in connections:
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
used = set()
paths = []
starts = [i for i in graph if len(graph[i]) != 2] + list(graph)
for start in starts:
for neighbor in graph[start]:
edge = tuple(sorted((start, neighbor)))
if edge in used:
continue
used.add(edge)
ids = [start, neighbor]
while len(graph[ids[-1]]) == 2:
nxt = next((x for x in graph[ids[-1]] if x != ids[-2]))
edge = tuple(sorted((ids[-1], nxt)))
if edge in used:
break
used.add(edge)
ids.append(nxt)
path = nodes[ids]
length = np.linalg.norm(np.diff(path, axis=0), axis=1).sum()
if length < 5 and (len(graph[ids[0]]) == 1 or len(graph[ids[-1]]) == 1):
continue
simplified = path
for tol in (2.0, 1.0, 0.25, 0):
candidate = approximate_polygon(path, tol)
if domain.valid_segments(candidate[:-1], candidate[1:]).all():
simplified = candidate
break
expanded = []
for a, b in zip(simplified, simplified[1:]):
n = max(1, int(np.ceil(np.linalg.norm(b - a) / 24)))
expanded.extend(a + np.arange(n)[:, None] / n * (b - a))
expanded.append(simplified[-1])
paths.append(np.asarray(expanded))
points = []
edges = []
lookup = {}
for path in paths:
ids = []
for p in path:
key = tuple(np.round(p, 7))
if key not in lookup:
lookup[key] = len(points)
points.append(p)
ids.append(lookup[key])
edges.extend(zip(ids, ids[1:]))
return (np.asarray(points).reshape(-1, 2), edges)
def relax_points(contours, spine, spacing):
free = g.uniform_interior_points_for_contours(contours, spacing)
if len(free) and len(spine):
free = free[cKDTree(spine).query(free)[0] > spacing * 0.55]
step = max(1, int(spacing / 4))
phase = step // 2
points = np.concatenate(contours)
minimum = points.min(axis=0)
maximum = points.max(axis=0)
xx = np.arange(minimum[0] + phase, maximum[0], step) + 0.5
yy = np.arange(minimum[1] + phase, maximum[1], step) + 0.5
xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])]
yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])]
xx, yy = np.meshgrid(xx, yy)
samples = np.column_stack((xx.ravel(), yy.ravel()))
samples = samples[g.points_inside_contours(samples, contours)]
if not len(samples):
return free
fixed = np.concatenate((*contours, spine))
for iteration in range(12):
if not len(free):
break
sites = np.concatenate((fixed, free))
labels = cKDTree(sites).query(samples)[1] - len(fixed)
next_free = free.copy()
valid = labels >= 0
owners = labels[valid]
owned = samples[valid]
counts = np.bincount(owners, minlength=len(free))
used = counts > 0
for axis in range(2):
sums = np.bincount(owners, weights=owned[:, axis], minlength=len(free))
next_free[used, axis] = sums[used] / counts[used]
proposed = free * 0.3 + next_free * 0.7
usable = g.points_inside_contours(proposed, contours)
movement = np.linalg.norm(proposed[usable] - free[usable], axis=1)
free[usable] = proposed[usable]
if iteration >= 3 and (
not len(movement) or np.sqrt(np.mean(movement**2)) < spacing * 0.01
):
break
return free
def subdivide_constraints(contours, spine, path_edges, spacing):
dense = []
for c in contours:
points = []
for a, b in zip(c, np.roll(c, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
dense.append(np.asarray(points))
points = spine.tolist()
edges = []
for a, b in path_edges:
count = max(1, int(np.ceil(np.linalg.norm(spine[b] - spine[a]) / spacing)))
ids = [a]
for i in range(1, count):
ids.append(len(points))
points.append((spine[a] + i / count * (spine[b] - spine[a])).tolist())
ids.append(b)
edges.extend(zip(ids, ids[1:]))
return (dense, np.asarray(points).reshape(-1, 2), edges)
def triangulate(contours, spine, path_edges, free, spacing):
boundary_count = sum(map(len, contours))
p = np.concatenate((*contours, spine, free))
edges = []
offset = 0
for c in contours:
edges.extend(((offset + i, offset + (i + 1) % len(c)) for i in range(len(c))))
offset += len(c)
constraint_start = len(edges)
edges.extend(((boundary_count + a, boundary_count + b) for a, b in path_edges))
result = delaunay_2d_cdt([Vector(v) for v in p], edges, [], 0, 1e-07, True)
points = np.asarray([tuple(v) for v in result[0]])
# Restore input precision before testing area and containment. Float32 CDT
# coordinates can turn collinear outline samples into artificial slivers.
for vertex, originals in enumerate(result[3]):
if originals:
points[vertex] = p[originals].mean(axis=0)
faces = np.asarray(result[2], dtype=np.int32)
faces = faces[g.points_inside_contours(points[faces].mean(axis=1), contours)]
area = np.abs(
np.cross(
points[faces[:, 1]] - points[faces[:, 0]],
points[faces[:, 2]] - points[faces[:, 0]],
)
)
faces = faces[area > 1e-08]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
face_edges = {
tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
for face in faces
for i in range(3)
}
path_result = []
constraint_graphs = [{} for _ in path_edges]
for edge, originals in zip(result[1], result[4]):
if tuple(sorted(edge)) not in face_edges:
continue
for original in originals:
if constraint_start <= original < len(edges):
graph = constraint_graphs[original - constraint_start]
a, b = edge
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
path_result.append(tuple(remap[list(edge)]))
# CDT coordinates are float32. Validate endpoint connectivity via provenance,
# so short constraints at large pixel coordinates survive rounding.
original_vertices = {
original: vertex
for vertex, originals in enumerate(result[3])
for original in originals
}
for (a, b), graph in zip(edges[constraint_start:], constraint_graphs):
start = original_vertices.get(a)
end = original_vertices.get(b)
seen = set()
pending = [start] if start in graph else []
while pending:
vertex = pending.pop()
if vertex in seen:
continue
seen.add(vertex)
pending.extend(graph[vertex] - seen)
if end not in seen:
raise ValueError(
"Fine Outline lost an interior constraint during triangulation"
)
return (
points[used],
remap[faces],
np.asarray(path_result, dtype=np.int32).reshape(-1, 2),
)

View File

@@ -380,88 +380,6 @@ def points_inside_contours(points, contours):
return inside
def uniform_interior_points_for_contours(contours, spacing):
import numpy as np
contours = [np.asarray(contour, dtype=np.float64) for contour in contours]
boundary_points = np.concatenate(contours, axis=0)
minimum = boundary_points.min(axis=0)
maximum = boundary_points.max(axis=0)
row_spacing = spacing * math.sqrt(3.0) * 0.5
row_count = int(math.ceil((maximum[1] - minimum[1]) / row_spacing)) + 1
column_count = int(math.ceil((maximum[0] - minimum[0]) / spacing)) + 1
rows = np.arange(row_count, dtype=np.float64)
columns = np.arange(column_count, dtype=np.float64)
y = minimum[1] + rows * row_spacing
x = (
minimum[0]
+ columns[None, :] * spacing
+ (rows.astype(np.int64) % 2)[:, None] * spacing * 0.5
)
x = np.broadcast_to(x, (row_count, column_count))
points = np.column_stack((x.ravel(), np.repeat(y, column_count)))
inside = points_inside_contours(points, contours).reshape(row_count, column_count)
clearance = spacing * 0.4
near_boundary = np.zeros_like(inside)
for contour in contours:
for start, end in zip(contour, np.roll(contour, -1, axis=0)):
segment = end - start
length_squared = float(segment.dot(segment))
if length_squared <= 1e-16:
continue
first_row = max(
0,
int(
math.ceil(
(min(start[1], end[1]) - clearance - minimum[1]) / row_spacing
)
),
)
last_row = min(
row_count - 1,
int(
math.floor(
(max(start[1], end[1]) + clearance - minimum[1]) / row_spacing
)
),
)
for row in range(first_row, last_row + 1):
offset = spacing * 0.5 if row % 2 else 0.0
first_column = max(
0,
int(
math.ceil(
(min(start[0], end[0]) - clearance - minimum[0] - offset)
/ spacing
)
),
)
last_column = min(
column_count - 1,
int(
math.floor(
(max(start[0], end[0]) + clearance - minimum[0] - offset)
/ spacing
)
),
)
if last_column < first_column:
continue
selected = np.arange(first_column, last_column + 1)
row_points = np.column_stack(
(x[row, selected], np.full(len(selected), y[row]))
)
factor = np.clip(
(row_points - start) @ segment / length_squared, 0.0, 1.0
)
nearest = start + factor[:, None] * segment
close = np.sum((row_points - nearest) ** 2, axis=1) < clearance**2
near_boundary[row, selected[close]] = True
usable = inside & ~near_boundary
return np.column_stack((x[usable], np.broadcast_to(y[:, None], x.shape)[usable]))
def metric_transform(world_metric=None):
import numpy as np
@@ -478,82 +396,13 @@ def metric_transform(world_metric=None):
raise ValueError("The Image Empty has a degenerate world transform") from error
def prepare_planar_contours(
contours,
image_size,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
image_width, image_height = image_size
x_min, x_max, y_min, y_max = local_bounds
local_width = x_max - x_min
local_height = y_max - y_min
transform = metric_transform(world_metric)
prepared = []
for contour in contours:
pixels = np.asarray(contour, dtype=np.float64)
if pixels.ndim != 2 or pixels.shape[0] < 3 or pixels.shape[1] != 2:
continue
local = np.empty_like(pixels)
local[:, 0] = x_min + local_width * pixels[:, 0] / image_width
local[:, 1] = y_max - local_height * pixels[:, 1] / image_height
planar = local @ transform
perimeter = float(
np.linalg.norm(np.roll(planar, -1, axis=0) - planar, axis=1).sum()
)
if perimeter <= 1e-12:
continue
contour_spacing = min(spacing, perimeter / 8.0)
smoothed = smooth_selection_contour(planar, contour_spacing)
smoothed_local = smoothed @ np.linalg.inv(transform)
smoothed_local[:, 0] = np.clip(smoothed_local[:, 0], x_min, x_max)
smoothed_local[:, 1] = np.clip(smoothed_local[:, 1], y_min, y_max)
prepared.append(smoothed_local @ transform)
if not prepared:
raise ValueError("The Cutout has no selection large enough for this resolution")
interior = uniform_interior_points_for_contours(prepared, spacing)
return prepared, interior, spacing
def _prepare_selection_contours(
selection_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
prepared = []
errors = []
for component in selection_contours.components:
try:
contours, interior, _spacing = prepare_planar_contours(
component,
selection_contours.image_size,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
except ValueError as error:
errors.append(error)
continue
prepared.append((contours, interior))
if not prepared:
if errors:
raise errors[0]
raise ValueError("The Cutout has no usable selection")
return prepared, spacing
def _triangulate_contour_component(contours, interior, spacing):
def contour_interior_point(contours, spacing):
"""Choose an interior seed from the largest auxiliary triangle."""
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
input_points = np.concatenate((*contours, interior), axis=0)
input_points = np.concatenate(contours, axis=0)
boundary_edges = []
offset = 0
for contour in contours:
@@ -583,15 +432,15 @@ def _triangulate_contour_component(contours, interior, spacing):
faces = faces[points_inside_contours(centers, contours)]
if not len(faces):
raise ValueError("Unable to triangulate the Cutout selection components")
used = np.unique(faces)
if len(used) != len(planar_points):
remap = np.full(len(planar_points), -1, dtype=np.int32)
remap[used] = np.arange(len(used), dtype=np.int32)
planar_points = planar_points[used]
faces = remap[faces]
boundary = boundary_vertices_from_faces(len(planar_points), faces)
planar_faces = [tuple(int(value) for value in face) for face in faces]
return planar_points, planar_faces, boundary
triangles = planar_points[faces]
first = triangles[:, 1] - triangles[:, 0]
second = triangles[:, 2] - triangles[:, 0]
areas = abs(first[:, 0] * second[:, 1] - first[:, 1] * second[:, 0])
seed = triangles[np.argmax(areas)].mean(axis=0)
if not points_inside_contours(seed[None], contours)[0]:
raise ValueError("Cutout seed is outside its contour")
return seed
def boundary_vertices_from_faces(vertex_count, faces):
@@ -614,54 +463,6 @@ def boundary_vertices_from_faces(vertex_count, faces):
return boundary
def triangulate_selection_contours(
selection_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
return_components=False,
):
import numpy as np
prepared, spacing = _prepare_selection_contours(
selection_contours,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
points = []
faces = []
boundaries = []
components = []
offset = 0
for contours, interior in prepared:
component_points, component_faces, component_boundary = _triangulate_contour_component(
contours,
interior,
spacing,
)
components.append(
(
offset,
offset + len(component_points),
component_faces,
contours[0],
)
)
points.append(component_points)
faces.extend(tuple(offset + value for value in face) for face in component_faces)
boundaries.append(component_boundary)
offset += len(component_points)
if not points:
raise ValueError("Unable to triangulate the Cutout selection components")
result = (
np.concatenate(points, axis=0),
faces,
np.concatenate(boundaries),
)
return (*result, components) if return_components else result
CUTOUT_ALPHA_THRESHOLD = 0.9
CUTOUT_CLEANUP_UI_SCALE = 100.0
@@ -674,84 +475,26 @@ class BaseShape:
balloon: object
def refine_balloon_triangles(points, faces, boundary):
"""Add interior samples to boundary ears and interior boundary chords."""
from collections import Counter
import numpy as np
edges = Counter(tuple(sorted((face[i], face[(i + 1) % 3]))) for face in faces for i in range(3))
values, midpoints = list(points), {}
for edge, count in edges.items():
if count == 2 and all(boundary[index] for index in edge):
midpoints[edge] = len(values)
values.append(points[list(edge)].mean(axis=0))
refined = []
for face in faces:
ring = []
for index, vertex in enumerate(face):
ring.append(vertex)
midpoint = midpoints.get(tuple(sorted((vertex, face[(index + 1) % 3]))))
if midpoint is not None:
ring.append(midpoint)
if len(ring) == 3 and not all(boundary[vertex] for vertex in face):
refined.append(tuple(face))
continue
center = len(values)
values.append(points[list(face)].mean(axis=0))
refined.extend((center, ring[i], ring[(i + 1) % len(ring)]) for i in range(len(ring)))
points = np.asarray(values)
return points, refined, boundary_vertices_from_faces(len(points), refined)
def cutout_domain(
selection_contours,
bounds,
spacing,
world_metric,
balloon_algorithm="POISSON",
*,
fine_mesh=None,
):
def cutout_domain(mesh, bounds, world_metric, balloon_algorithm="POISSON"):
"""Map the quality mesh into local coordinates and solve its height."""
import numpy as np
x_min, x_max, y_min, y_max = bounds
pixels, planar_faces, boundary, pixel_components, image_size = mesh
image_width, image_height = image_size
transform = metric_transform(world_metric)
if fine_mesh is None:
planar, planar_faces, boundary, components = triangulate_selection_contours(
selection_contours,
bounds,
spacing=spacing,
world_metric=world_metric,
return_components=True,
)
else:
pixels, planar_faces, boundary, pixel_components, image_size = fine_mesh
image_width, image_height = image_size
transform = metric_transform(world_metric)
def pixel_to_planar(points):
local = np.asarray(points, dtype=np.float64).copy()
local[:, 0] = x_min + local[:, 0] * (x_max - x_min) / image_width
local[:, 1] = y_max - local[:, 1] * (y_max - y_min) / image_height
return local @ transform
def pixel_to_planar(points):
local = np.asarray(points, dtype=np.float64).copy()
local[:, 0] = x_min + local[:, 0] * (x_max - x_min) / image_width
local[:, 1] = y_max - local[:, 1] * (y_max - y_min) / image_height
return local @ transform
planar = pixel_to_planar(pixels)
planar_faces = planar_faces.tolist()
components = [
(start, end, faces.tolist(), pixel_to_planar(contour))
for start, end, faces, contour in pixel_components
]
refined_points, refined_faces, refined_components = [], [], []
offset = 0
for start, end, component_faces, contour in components:
points, faces, _ = refine_balloon_triangles(planar[start:end], component_faces, boundary[start:end])
refined_points.append(points)
refined_faces.extend(tuple(offset + index for index in face) for face in faces)
refined_components.append((offset, offset + len(points), faces, contour))
offset += len(points)
planar = np.concatenate(refined_points)
planar_faces, components = refined_faces, refined_components
boundary = boundary_vertices_from_faces(len(planar), planar_faces)
planar = pixel_to_planar(pixels)
components = [
(start, end, faces.tolist(), pixel_to_planar(contour))
for start, end, faces, contour in pixel_components
]
profile = balloon.balloon_profile(
balloon_algorithm, planar, planar_faces, boundary, components
)
@@ -836,7 +579,11 @@ def extract_cutout_contours(
def effective_cutout_edge_length(
context, source_object, edge_length, content_values, content_bounds,
context,
source_object,
edge_length,
content_values,
content_bounds,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
"""Resolve target spacing in Blender world units from visible content bounds."""
@@ -850,16 +597,25 @@ def effective_cutout_edge_length(
columns = np.flatnonzero(visible.any(axis=0))
if not len(rows):
raise ValueError("The Cutout selection contains no visible image pixels")
bounds = crop_plane_bounds(source_object, content_bounds, tuple(source_object.data.size))
bounds = crop_plane_bounds(
source_object, content_bounds, tuple(source_object.data.size)
)
metric, _ = world_plane_metric(source_object.matrix_world)
metric_transform(metric)
width = (bounds[1] - bounds[0]) * (columns.max() + 1 - columns.min()) / values.shape[1]
width = (
(bounds[1] - bounds[0]) * (columns.max() + 1 - columns.min()) / values.shape[1]
)
height = (bounds[3] - bounds[2]) * (rows.max() + 1 - rows.min()) / values.shape[0]
longest = max(width * math.sqrt(metric[0][0]), height * math.sqrt(metric[1][1]))
return max(float(edge_length), longest * configured_min_cutout_relative_edge_length(context))
return max(
float(edge_length),
longest * configured_min_cutout_relative_edge_length(context),
)
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
def selection_calibration(
content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD
):
"""Return Cutout validity and transparency ratio at geometry resolution."""
import numpy as np
@@ -900,39 +656,27 @@ def build_base_shape(
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world)
if world_z_scale <= 1e-10:
raise ValueError("The Image Empty has a degenerate world transform")
pixel_transform = np.diag((
(bounds[1] - bounds[0]) / content_values.shape[1],
-(bounds[3] - bounds[2]) / content_values.shape[0],
)) @ metric_transform(world_metric)
longest = max(
np.linalg.norm(pixel_transform[0]) * content_values.shape[1],
np.linalg.norm(pixel_transform[1]) * content_values.shape[0],
)
fine_mesh = None
selection_contours = None
if fine_outline:
from .fine_mesh import build_fine_mesh
pixel_transform = np.diag(
(
(bounds[1] - bounds[0]) / content_values.shape[1],
-(bounds[3] - bounds[2]) / content_values.shape[0],
)
) @ metric_transform(world_metric)
from .mesh import build_mesh
fine_mesh = (
*build_fine_mesh(content_values, edge_length, alpha_threshold, pixel_transform=pixel_transform),
(content_values.shape[1], content_values.shape[0]),
)
else:
selection_contours = extract_cutout_contours(
mesh = (
*build_mesh(
content_values,
longest / edge_length,
threshold=alpha_threshold,
)
edge_length,
alpha_threshold,
pixel_transform=pixel_transform,
fine_outline=fine_outline,
),
(content_values.shape[1], content_values.shape[0]),
)
preferences = addon_preferences(context)
balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON")
domain = cutout_domain(
selection_contours,
bounds,
edge_length,
world_metric,
balloon_algorithm,
fine_mesh=fine_mesh,
)
domain = cutout_domain(mesh, bounds, world_metric, balloon_algorithm)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(
vertices=np.asarray(source_vertices, dtype=np.float64),

View File

@@ -0,0 +1,426 @@
"""Cutout contours, quality triangulation and local height support."""
import numpy as np
from scipy import ndimage
from scipy.spatial import cKDTree
from .polygon import approximate_polygon
from . import geometry as g
# Keep predicate tolerances independent of scene units and requested density.
NORMALIZED_EDGE_LENGTH = 32.0
def separate_contour_contacts(contours):
"""Open pixel-corner contacts with a subpixel bevel in each incident ring."""
points = np.concatenate(contours)
pairs = cKDTree(points).query_pairs(1e-8)
if not pairs:
return contours
contacts = {i for pair in pairs for i in pair}
result = []
offset = 0
for contour in contours:
ring = contour.copy()
for i in range(len(ring)):
if offset + i in contacts:
before = contour[i-1] - contour[i]
after = contour[(i+1) % len(ring)] - contour[i]
length = min(np.linalg.norm(before), np.linalg.norm(after))
direction = (
before / np.linalg.norm(before) + after / np.linalg.norm(after)
)
ring[i] += direction * length * 0.125
result.append(ring)
offset += len(ring)
return result
def support_mesh(points, faces, *, max_vertices=50000):
"""Split boundary chords once so every face has an interior height sample."""
boundary = g.boundary_vertices_from_faces(len(points), faces)
edges, counts = np.unique(
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
axis=0, return_counts=True,
)
chords = edges[(counts == 2) & boundary[edges].all(axis=1)]
midpoints = {tuple(edge): len(points) + i for i, edge in enumerate(chords)}
isolated = sum(
boundary[face].all() and not any(
tuple(sorted((face[i], face[(i+1) % 3]))) in midpoints for i in range(3)
) for face in faces
)
if len(points) + len(chords) + isolated > max_vertices:
raise ValueError("Cutout support vertex limit exceeded")
if not len(chords) and not isolated:
return points, faces
vertices = points.tolist() + points[chords].mean(axis=1).tolist()
output = []
for face in faces:
pieces = [list(face)]
for i in range(3):
a, b = face[i], face[(i+1) % 3]
midpoint = midpoints.get(tuple(sorted((a, b))))
if midpoint is None:
continue
split = []
for piece in pieces:
if a in piece and b in piece:
k = next(
j for j in range(3)
if piece[j] in (a, b) and piece[(j+1) % 3] in (a, b)
)
x, y, z = piece[k], piece[(k+1) % 3], piece[(k+2) % 3]
split.extend(((x, midpoint, z), (midpoint, y, z)))
else:
split.append(piece)
pieces = split
if len(pieces) == 1 and boundary[face].all():
midpoint = len(vertices)
vertices.append(points[face].mean(axis=0).tolist())
pieces = [(face[i], face[(i+1) % 3], midpoint) for i in range(3)]
output.extend(pieces)
return np.asarray(vertices), np.asarray(output, dtype=np.int32)
def valid_contours(contours):
"""Reject crossing rings and holes moved outside the outer ring."""
starts = np.concatenate(contours)
if cKDTree(starts).query_pairs(1e-8):
return False
ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours])
delta = ends - starts
count = len(starts)
following = np.concatenate(
[
np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1)
for index, contour in enumerate(contours)
]
)
for first in range(0, count, 32):
ids = np.arange(first, min(first + 32, count))[:, None]
other = np.arange(count)[None, :]
a = starts[first : first + 32, None]
v = delta[first : first + 32, None]
w = delta[None]
d = starts[None] - a
determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0]
divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0)
t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor
u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor
adjacent = (other == following[ids]) | (ids == following[other])
if np.any(
(other > ids)
& ~adjacent
& (np.abs(determinant) > 1e-12)
& (t >= -1e-9)
& (t <= 1 + 1e-9)
& (u >= -1e-9)
& (u <= 1 + 1e-9)
):
return False
return all(
g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:]
)
def prepare_mask(alpha, threshold, transform, fine_outline):
"""Fill holes according to the normalized world-space target length."""
filtered = filter_alpha(alpha, threshold)
mask = (filtered > 0) & (filtered >= threshold)
filled = ndimage.binary_fill_holes(mask)
if not fine_outline:
return filled
holes, _ = ndimage.label(filled & ~mask)
areas = np.bincount(holes.ravel()) * abs(np.linalg.det(transform))
for index, slices in enumerate(ndimage.find_objects(holes), 1):
if slices is None or areas[index] > 0.04 * NORMALIZED_EDGE_LENGTH**2:
continue
y, x = slices
corners = (
np.array(
(
(x.start, y.start),
(x.stop, y.start),
(x.stop, y.stop),
(x.start, y.stop),
)
)
@ transform
)
if np.ptp(corners, axis=0).max() <= 0.25 * NORMALIZED_EDGE_LENGTH:
mask[slices] |= holes[slices] == index
return mask
def build_mesh(alpha, spacing, threshold, *, pixel_transform=None, fine_outline=True):
"""Return pixel-space quality triangles and component ranges."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
raise ValueError("The Cutout Alpha must be a finite two-dimensional array")
if not np.isfinite(spacing) or spacing <= 0:
raise ValueError("Cutout point spacing must be greater than zero")
transform = (
np.eye(2) if pixel_transform is None else np.asarray(pixel_transform)
) * (NORMALIZED_EDGE_LENGTH / spacing)
inverse = np.linalg.inv(transform)
mask = prepare_mask(alpha, threshold, transform, fine_outline)
if not mask.any():
raise ValueError("The Cutout selection contains no visible image pixels")
reference = g.extract_selection_contours(
mask.astype(float), max_resolution=max(mask.shape), threshold=0.5
)
prepared = []
for component in reference.components:
raw = separate_contour_contacts([np.asarray(c) @ transform for c in component])
if fine_outline:
contours = contours_for_component(raw, NORMALIZED_EDGE_LENGTH, pixel_transform=transform)
else:
contours = [
g.smooth_selection_contour(
c,
min(
NORMALIZED_EDGE_LENGTH,
np.linalg.norm(np.roll(c, -1, axis=0) - c, axis=1).sum() / 8,
),
)
for c in raw
]
contours = [
np.clip(c @ inverse, (0, 0), mask.shape[::-1]) @ transform
for c in contours
]
if not valid_contours(contours):
raise ValueError("Cutout simplified outline is invalid")
prepared.append(contours)
points, faces, components = [], [], []
offset = 0
for contours in prepared:
vertices, triangles = triangulate(contours, NORMALIZED_EDGE_LENGTH)
vertices, triangles = support_mesh(
vertices, triangles, max_vertices=50000-offset
)
points.append(vertices @ inverse)
faces.append(triangles + offset)
components.append(
(offset, offset + len(vertices), triangles, contours[0] @ inverse)
)
offset += len(vertices)
if offset > 50000:
raise ValueError("Cutout mesh vertex limit exceeded")
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
def filter_alpha(alpha, threshold, min_area=16):
mask = (alpha > 0) & (alpha >= threshold)
labels, count = ndimage.label(mask)
areas = np.bincount(labels.ravel(), minlength=count + 1)
keep = areas >= min_area
keep[0] = False
if count:
keep[1 + np.argmax(areas[1:])] = True
removed = mask & ~keep[labels]
result = alpha.copy()
result[removed] = 0
return result
def contours_for_component(component, spacing, strength=1.0, *, pixel_transform=None):
"""Soften pixel steps before simplifying in the target edge-length metric."""
result = []
transform = np.eye(2) if pixel_transform is None else pixel_transform
inverse = np.linalg.inv(transform)
tolerance = spacing * 0.078125
for contour in component:
original = np.asarray(contour, dtype=float)
if strength > 0:
original = ndimage.gaussian_filter1d(
g.resample_closed_contour(original @ inverse, 0.5),
1.6 * strength,
axis=0,
mode="wrap",
) @ transform
for error in (tolerance * strength, tolerance * strength * 0.5, 0):
simplified = approximate_polygon(np.vstack((original, original[0])), error)[
:-1
]
if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05:
break
points = []
for a, b in zip(simplified, np.roll(simplified, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
result.append(np.asarray(points))
if not valid_contours(result):
if strength == 0:
raise ValueError("Cutout outline is invalid")
return contours_for_component(
component, spacing, strength * 0.5 if strength > 0.125 else 0,
pixel_transform=transform,
)
return result
def normalize_constraints(points, edges):
"""Merge coincident vertices and split constraints at existing vertices."""
tree = cKDTree(points)
parents = np.arange(len(points))
def root(index):
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
for a, b in sorted(tree.query_pairs(1e-8)):
parents[root(b)] = root(a)
representatives = np.array([root(i) for i in range(len(points))])
used, inverse = np.unique(representatives, return_inverse=True)
points = points[used]
tree = cKDTree(points)
segments = []
seen = {}
sources = {}
for marker, (a, b) in enumerate(edges, 1):
a, b = inverse[a], inverse[b]
delta = points[b] - points[a]
length = np.linalg.norm(delta)
if length <= 1e-8:
raise ValueError("Cutout constraint collapsed during normalization")
candidates = np.array(
tree.query_ball_point((points[a] + points[b]) / 2, length / 2 + 1e-8),
dtype=int,
)
fraction = (points[candidates] - points[a]) @ delta / length**2
distance = np.linalg.norm(
points[candidates] - points[a] - fraction[:, None] * delta, axis=1
)
valid = (fraction >= -1e-10) & (fraction <= 1 + 1e-10) & (distance < 1e-8)
ids = candidates[valid][np.argsort(fraction[valid])]
for x, y in zip(ids, ids[1:]):
key = tuple(sorted((int(x), int(y))))
if key in seen:
sources[seen[key]].add(marker)
continue
identity = len(segments) + 1
seen[key] = identity
sources[identity] = {marker}
segments.append((int(x), int(y), identity))
return points, segments, sources
def triangulate(contours, spacing):
"""Build and validate a quality mesh with complete constraint chains."""
from . import tangle
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
points = np.concatenate(contours)
if len(points) > 20000:
raise ValueError("Cutout constraint vertex limit exceeded")
edges = []
offset = 0
for contour in contours:
edges.extend(
(offset + i, offset + (i + 1) % len(contour)) for i in range(len(contour))
)
offset += len(contour)
input_points = points
points, segments, sources = normalize_constraints(points, edges)
points = np.vstack((points, g.contour_interior_point(contours, spacing)))
holes = [g.contour_interior_point([contour], spacing).tolist() for contour in contours[1:]]
raw_points, raw_faces, raw_segments = tangle.build_mesh(
points.tolist(), segments, holes, np.sqrt(3) / 4 * spacing**2
)
points = np.asarray(raw_points)
faces = np.asarray(raw_faces, dtype=np.int32).reshape(-1, 3)
if not len(faces) or not np.isfinite(points).all():
raise ValueError("Cutout triangulation is empty or nonfinite")
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
points, faces = points[used], remap[faces]
triangles = points[faces]
areas = (
abs(
np.cross(
triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
)
)
/ 2
)
expected_area = abs(g.contour_area(contours[0])) - sum(
abs(g.contour_area(c)) for c in contours[1:]
)
if np.any(areas <= 1e-10) or not np.isclose(
areas.sum(), expected_area, rtol=1e-8, atol=1e-7
):
raise ValueError("Cutout triangulation has degenerate faces or incorrect area")
face_edges, counts = np.unique(
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
axis=0,
return_counts=True,
)
if counts.max() > 2:
raise ValueError("Cutout triangulation has nonmanifold edges")
graph = coo_matrix(
(np.ones(len(face_edges)), (face_edges[:, 0], face_edges[:, 1])),
shape=(len(points), len(points)),
).tocsr()
components, _ = connected_components(graph, directed=False)
if (
components != 1
or components - len(points) + len(face_edges) - len(faces) != len(contours) - 1
):
raise ValueError(
"Cutout triangulation has incorrect component or hole topology"
)
actual_edges = {tuple(e) for e in face_edges}
chains = [[] for _ in edges]
for a, b, marker in raw_segments:
if marker in sources and min(remap[a], remap[b]) >= 0:
edge = tuple(sorted((int(remap[a]), int(remap[b]))))
if edge in actual_edges:
for original in sources[marker]:
chains[original - 1].append(edge)
tree = cKDTree(points)
for (a, b), chain in zip(edges, chains):
error = "Cutout lost a boundary constraint"
if not chain:
raise ValueError(error)
chain = np.asarray(chain)
start, end = input_points[[a, b]]
delta = end - start
length = np.linalg.norm(delta)
vertices = np.unique(chain)
distance = np.linalg.norm(
points[vertices]
- start
- ((points[vertices] - start) @ delta / length**2)[:, None] * delta,
axis=1,
)
covered = np.linalg.norm(
points[chain[:, 1]] - points[chain[:, 0]], axis=1
).sum()
endpoint_distance, endpoints = tree.query([start, end])
adjacency = {}
for x, y in chain:
adjacency.setdefault(x, set()).add(y)
adjacency.setdefault(y, set()).add(x)
visited = set()
pending = [endpoints[0]]
while pending:
vertex = pending.pop()
if vertex not in visited:
visited.add(vertex)
pending.extend(adjacency.get(vertex, ()))
if (
distance.max() > 1e-6
or endpoint_distance.max() > 1e-6
or endpoints[1] not in visited
or not np.isclose(covered, length, rtol=1e-7, atol=1e-6)
):
raise ValueError(error)
return points, faces

File diff suppressed because it is too large Load Diff

View File

@@ -170,6 +170,8 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
default=1.0,
min=0.001,
max=100.0,
soft_min=0.5,
soft_max=10.0,
subtype="PERCENTAGE",
)
max_frame_resolution: bpy.props.IntProperty(

View File

@@ -237,7 +237,7 @@ def cutout_alpha_threshold_property():
def cutout_fine_outline_property():
return bpy.props.BoolProperty(
name="Fine Outline",
description="Fit detailed outlines and support thin parts; disable for simpler, faster meshes",
description="Preserve detailed outlines and meaningful holes; disable to fill holes and simplify outlines",
default=False,
)
@@ -265,6 +265,8 @@ def cutout_edge_length_property(*, store_meters=False, **options):
description="Target mesh edge length in world space",
default=0.1,
min=1e-6,
soft_min=0.01,
soft_max=1.0,
unit="LENGTH",
subtype="DISTANCE",
**options,

View File

@@ -3,7 +3,7 @@ import numpy as np
import pytest
from anyimage.operators.cutout_tool.object import load_cutout_node_group
from anyimage.operators.cutout_tool.operators import normal_mode_for_shape
from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles
from anyimage.operators.cutout_tool.mesh import build_mesh
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
@@ -38,9 +38,8 @@ def test_depth_cutout_loads_uniform_thickness_revision_and_preserves_existing_gr
assert load_cutout_node_group("DEPTH_SOLID") == current
def test_refined_boundary_ears_have_interior_balloon_samples():
points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float)
points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool))
def test_small_quality_mesh_has_positive_balloon_support():
points, faces, boundary, _ = build_mesh(np.ones((2, 2)), 32, .9)
profile = poisson_balloon_profile(points, faces, boundary)
assert np.all(profile[boundary] == 0)
assert all(any(profile[index] > 0 for index in face) for face in faces)
assert profile.max() > 0

View File

@@ -1,300 +0,0 @@
import numpy as np
import pytest
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
from scipy.spatial import cKDTree
from anyimage.operators.cutout_tool import geometry as g
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
from anyimage.operators.cutout_tool import fine_mesh as fine
def branch_alpha(offset=0):
yy, xx = np.mgrid[:192, :192]
xx = xx - offset
yy = yy - offset
alpha = (xx - 46) ** 2 + (yy - 48) ** 2 < 30**2
path = np.asarray(((48, 48), (91, 70), (130, 110), (149, 167)))
for a, b in zip(path[:-1], path[1:]):
d = np.stack((xx - a[0], yy - a[1]), axis=-1)
v = b - a
t = np.clip(d @ v / (v @ v), 0, 1)
alpha |= np.sum((d - t[..., None] * v) ** 2, axis=-1) < 2.5**2
alpha |= (xx - 130) ** 2 + (yy - 110) ** 2 < 7**2
return alpha.astype(float)
def assert_supported(points, faces, boundary, components):
triangles = points[faces]
area = np.abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
assert np.isfinite(points).all() and np.all(area > 1e-8)
assert not boundary[faces].all(axis=1).any()
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
edges, counts = np.unique(edges, axis=0, return_counts=True)
assert counts.max() <= 2
assert not boundary[edges[counts == 2]].all(axis=1).any()
interior = np.flatnonzero(~boundary)
links = edges[~boundary[edges].any(axis=1)]
graph = coo_matrix(
(np.ones(len(links)), (links[:, 0], links[:, 1])),
shape=(len(points), len(points)),
).tocsr()
count, _labels = connected_components(graph[interior][:, interior], directed=False)
assert count == len(components)
profile = poisson_balloon_profile(points, faces, boundary)
assert np.isfinite(profile).all()
assert np.all(profile[interior] > 0)
@pytest.mark.parametrize("spacing", (4, 8, 16, 28, 32, 36))
@pytest.mark.parametrize("offset", (0, 1))
def test_fine_bent_leg_and_joint_have_connected_positive_support(spacing, offset):
points, faces, boundary, components = fine.build_fine_mesh(
branch_alpha(offset), spacing, 0.9
)
assert_supported(points, faces, boundary, components)
interior = points[~boundary]
for checkpoint in ((90, 70), (130, 110), (146, 159)):
assert cKDTree(interior).query(np.asarray(checkpoint) + offset)[0] < 12
@pytest.mark.parametrize("spacing", (4, 8, 16, 32))
def test_fine_comb_hole_and_one_pixel_bridge(spacing):
alpha = np.zeros((160, 180))
alpha[15:145, 15:42] = 1
alpha[45:115, 125:165] = 1
alpha[75, 40:126] = 1
alpha[65:90, 140:150] = 0
for row in range(20, 140, 12):
alpha[row : row + 2, 35:110] = 1
result = fine.build_fine_mesh(alpha, spacing, 0.9)
assert_supported(*result)
points, faces, _boundary, components = result
assert len(components) == 1
assert not np.any(
g.points_inside_contours(
points[faces].mean(axis=1),
[np.asarray(((140, 65), (150, 65), (150, 90), (140, 90)))],
)
)
def test_fine_cleanup_uses_threshold_and_preserves_input_and_largest_island():
alpha = np.zeros((40, 60))
alpha[5:25, 5:25] = 0.5
alpha[12, 20:45] = 0.5
alpha[32:34, 50:52] = 1
original = alpha.copy()
filtered, stats = fine.filter_alpha(alpha, 0.3)
assert stats["components_after"] == 1
assert filtered[12, 44] == 0.5 and filtered[32, 50] == 0
np.testing.assert_array_equal(alpha, original)
assert_supported(*fine.build_fine_mesh(alpha, 32, 0.3))
assert_supported(*fine.build_fine_mesh(alpha, 32, 0.9))
with pytest.raises(ValueError, match="no visible"):
fine.build_fine_mesh(np.zeros((10, 10)), 32, 0.9)
def test_higher_detail_recovers_curve_instead_of_splitting_low_edges():
yy, xx = np.mgrid[:240, :240]
alpha = ((xx - 120) ** 2 + (yy - 120) ** 2 < 92**2).astype(float)
reference = g.extract_selection_contours(alpha, max_resolution=240, threshold=0.9)
source = g.resample_closed_contour(np.asarray(reference.components[0][0]), 0.5)
errors = []
for spacing in (32, 8, 4):
contours = fine.contours_for_component(reference.components[0], spacing)
sampled = g.resample_closed_contour(contours[0], 0.5)
forward = fine.ContourDomain(contours).clearance(source)
backward = fine.ContourDomain(
[np.asarray(reference.components[0][0])]
).clearance(sampled)
errors.append(max(np.percentile(forward, 95), np.percentile(backward, 95)))
assert errors[1] < errors[0]
assert errors[2] <= errors[1] + 0.1
def test_segment_validation_rejects_holes_tangency_and_outside():
domain = fine.ContourDomain(
[
np.asarray(((0, 0), (20, 0), (20, 20), (0, 20))),
np.asarray(((8, 8), (12, 8), (12, 12), (8, 12))),
]
)
starts = np.asarray(((2, 2), (2, 10), (2, 8), (-1, 2), (2, 2)))
ends = np.asarray(((18, 2), (18, 10), (18, 8), (2, 2), (2, 2)))
assert domain.valid_segments(starts, ends).tolist() == [
True,
False,
False,
False,
True,
]
def test_closed_mode_uses_original_geometry_without_fine_import(monkeypatch):
from types import SimpleNamespace
from mathutils import Matrix
from anyimage.common.image import image_empty_bounds
source = SimpleNamespace(
data=SimpleNamespace(size=(80, 60)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
matrix_world=Matrix.Identity(4),
)
monkeypatch.setattr(
g,
"addon_preferences",
lambda _context: SimpleNamespace(balloon_profile_algorithm="POISSON"),
)
monkeypatch.setattr(
fine,
"build_fine_mesh",
lambda *_args: pytest.fail("Fine algorithm called while disabled"),
)
alpha = np.ones((60, 80))
actual = g.build_base_shape(
None, source, 0.4, alpha, (0, 0, 80, 60), fine_outline=False
)
contours = g.extract_cutout_contours(alpha, 5, threshold=0.9)
expected = g.cutout_domain(
contours,
image_empty_bounds(source),
0.4,
((1.0, 0.0), (0.0, 1.0)),
)
np.testing.assert_array_equal(actual.vertices, expected[0])
np.testing.assert_array_equal(actual.uv, expected[2])
np.testing.assert_array_equal(actual.balloon, expected[-1])
def test_width_index_matches_exhaustive_covering_disks():
from scipy import ndimage
width = fine.WidthSamples(branch_alpha().astype(bool))
points = np.asarray(((48, 48), (85, 70), (130, 110), (147, 160), (2, 2)))
radii = []
for point in points:
covered = np.sum((width.centers - point) ** 2, axis=1) <= width.reach_squared
radii.append(max(0.5, width.radius[covered].max() if covered.any() else 0.5))
distances = ndimage.map_coordinates(
width.distance,
[points[:, 1] - 0.5, points[:, 0] - 0.5],
order=1,
mode="nearest",
)
expected = np.maximum(
0.7, np.minimum(32, np.minimum(16, np.asarray(radii) * 0.9) + 0.4 * distances)
)
np.testing.assert_allclose(width(points), expected)
def test_relaxation_reduces_sampling_error_without_adding_points():
contours = [np.asarray(((4.0, 4.0), (194.0, 4.0), (194.0, 104.0), (4.0, 104.0)))]
spine = np.empty((0, 2))
original = g.uniform_interior_points_for_contours(contours, 16)
relaxed = fine.relax_points(contours, spine, 16)
yy, xx = np.mgrid[6:104:4, 6:194:4]
samples = np.column_stack((xx.ravel() + 0.5, yy.ravel() + 0.5))
def energy(free):
return np.mean(
cKDTree(np.concatenate((*contours, free))).query(samples)[0] ** 2
)
assert len(relaxed) == len(original)
assert energy(relaxed) < energy(original)
assert fine.ContourDomain(contours).contains(relaxed).all()
def test_cdt_preserves_constraint_split_by_an_interior_vertex():
contours = [np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)))]
spine = np.asarray(((2.0, 5.0), (18.0, 5.0)))
points, faces, constraints = fine.triangulate(
contours, spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8
)
assert len(constraints) == 2
assert np.isclose(
np.linalg.norm(
points[constraints[:, 1]] - points[constraints[:, 0]], axis=1
).sum(),
16,
)
assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any()
@pytest.mark.parametrize("offset", [0, 4096, 16384])
def test_cdt_collinear_outline_samples_do_not_become_sliver_faces(offset):
contour = np.asarray(((0., 0.), (240., 0.), (240., 160.), (0., 160.)))
contour = contour @ np.asarray(((.763, -.646), (.646, .763)))
contour += np.asarray((420.123456, 540.234567)) + offset
contours, spine, edges = fine.subdivide_constraints(
[contour], np.empty((0, 2)), [], 16
)
free = g.uniform_interior_points_for_contours(contours, 16)
points, faces, _ = fine.triangulate(contours, spine, edges, free, 16)
triangles = points[faces]
area = abs(np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]))
longest_squared = np.max(np.sum((triangles - np.roll(triangles, 1, axis=1))**2, axis=2), axis=1)
assert np.min(area / longest_squared) > 1e-4
assert area.sum() / 2 == pytest.approx(abs(g.contour_area(contour)), rel=1e-9)
boundary = g.boundary_vertices_from_faces(len(points), faces)
np.testing.assert_allclose(fine.ContourDomain([contour]).clearance(points[boundary]), 0, atol=1e-9)
# Every subdivided outline point survives; removing slivers must not skip it.
assert cKDTree(points[boundary]).query(contours[0])[0].max() < 1e-9
def test_contour_validation_accepts_pixel_contacts_and_rejects_crossings():
crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0)))
assert not fine.valid_contours([crossing])
touching = np.asarray(
((0, 0), (2, 0), (2, 2), (4, 2), (4, 4), (2, 4), (2, 2), (0, 2))
)
assert fine.valid_contours([touching])
@pytest.mark.parametrize("offset", [0, 2048, 4096, 8192, 16384])
@pytest.mark.parametrize("spacing", [4, 0.5])
def test_cdt_preserves_short_constraints_at_large_pixel_coordinates(offset, spacing):
contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0))) + offset
spine = np.asarray(((5.1234567, 5.654321), (6.456789, 6.234567))) + offset
free = g.uniform_interior_points_for_contours([contour], spacing)
points, faces, constraints = fine.triangulate(
[contour], spine, [(0, 1)], free, spacing
)
assert len(constraints) >= 1
assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any()
@pytest.mark.parametrize(
"damage", ["missing_edge", "missing_faces", "missing_endpoint"]
)
def test_cdt_rejects_broken_constraint_coverage(monkeypatch, damage):
real_cdt = fine.delaunay_2d_cdt
def damaged_cdt(*args):
result = list(real_cdt(*args))
result[2] = list(result[2])
result[3] = [list(ids) for ids in result[3]]
result[4] = [list(ids) for ids in result[4]]
constraint = len(args[1]) - 1
edge_id = next(i for i, ids in enumerate(result[4]) if constraint in ids)
if damage == "missing_edge":
result[4][edge_id].remove(constraint)
elif damage == "missing_faces":
a, b = result[1][edge_id]
result[2] = [face for face in result[2] if not (a in face and b in face)]
else:
endpoint = args[1][-1][1]
for originals in result[3]:
if endpoint in originals:
originals.remove(endpoint)
return result
monkeypatch.setattr(fine, "delaunay_2d_cdt", damaged_cdt)
contour = np.asarray(((0.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)))
spine = np.asarray(((2.0, 5.0), (18.0, 5.0)))
with pytest.raises(ValueError, match="lost an interior constraint"):
fine.triangulate([contour], spine, [(0, 1)], np.asarray(((10.0, 5.0),)), 8)

View File

@@ -175,7 +175,7 @@ def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area():
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
domain = cutout_geometry.cutout_domain(
domain = quality_domain(
SelectionContours(components, (4096, 4096)),
(-1.0, 1.0, -1.0, 1.0),
0.5,
@@ -189,7 +189,7 @@ def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
def test_cutout_domain_builds_a_full_image_at_minimum_detail():
components = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
domain = cutout_geometry.cutout_domain(
domain = quality_domain(
SelectionContours(components, (4096, 4096)),
(-1.0, 1.0, -1.0, 1.0),
0.5,
@@ -203,7 +203,7 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail():
def test_cutout_domain_centers_vertices_on_crop_bounds():
components = ((((0, 0), (100, 0), (100, 50), (0, 50)),),)
vertices, _faces, uv, _boundary, _planar, _profile = cutout_geometry.cutout_domain(
vertices, _faces, uv, _boundary, _planar, _profile = quality_domain(
SelectionContours(components, (100, 50)),
(2.0, 6.0, -3.0, 1.0),
0.5,
@@ -224,7 +224,7 @@ def test_cutout_domain_keeps_overlapping_selection_components_disconnected():
),
(((0, 20), (40, 20), (40, 50), (0, 50)),),
)
domain = cutout_geometry.cutout_domain(
domain = quality_domain(
SelectionContours(components, (120, 80)),
(0.0, 1.0, 0.0, 1.0),
0.025,
@@ -286,3 +286,19 @@ class CutoutContourTest(unittest.TestCase):
return float(np.std(points[selected, 1] - points[selected, 0]))
self.assertLess(diagonal_noise(smooth), 0.05)
def quality_domain(selection, bounds, spacing, metric):
from anyimage.operators.cutout_tool import mesh
ps, fs, cs = [], [], []
offset = 0
width, height = selection.image_size
pixel_scale = max((bounds[1]-bounds[0])/width, (bounds[3]-bounds[2])/height)
for component in selection.components:
contours = [np.array(c, dtype=float) for c in component]
p, f = mesh.triangulate(contours, spacing/pixel_scale)
p, f = mesh.support_mesh(p, f)
ps.append(p);fs.append(f+offset)
cs.append((offset, offset+len(p), f, contours[0]));offset += len(p)
p, f = np.concatenate(ps), np.concatenate(fs)
return cutout_geometry.cutout_domain((p, f, cutout_geometry.boundary_vertices_from_faces(len(p), f), cs, selection.image_size), bounds, metric)

View File

@@ -0,0 +1,334 @@
import numpy as np
import pytest
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
from scipy.spatial import cKDTree
from anyimage.operators.cutout_tool import geometry as g
from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
from anyimage.operators.cutout_tool import mesh
def boundary_distance(points, contours):
starts = np.concatenate(contours)
delta = np.concatenate([np.roll(c, -1, axis=0) - c for c in contours])
offset = points[:, None] - starts
fraction = np.clip(np.sum(offset * delta, axis=2) / np.sum(delta**2, axis=1), 0, 1)
return np.linalg.norm(offset - fraction[..., None] * delta, axis=2).min(axis=1)
@pytest.mark.parametrize("fine_outline", [False, True])
@pytest.mark.parametrize("spacing", [2, 12, 48, 96])
def test_local_support_connects_coarse_and_dense_branches(fine_outline, spacing):
result = mesh.build_mesh(branch_alpha(), spacing, 0.9, fine_outline=fine_outline)
assert_supported(*result)
def test_support_splits_shared_chords_and_preserves_boundary_and_area():
points = np.array(((0., 0.), (20., 0.), (20., 2.), (0., 2.)))
faces = np.array(((0, 1, 2), (0, 2, 3)))
p, f = mesh.support_mesh(points, faces)
assert len(p) == 5 and len(f) == 4
assert_supported(p, f, g.boundary_vertices_from_faces(len(p), f), [None])
triangles = p[f]
assert np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]).sum() == pytest.approx(80)
np.testing.assert_array_equal(p[:4], points)
with pytest.raises(ValueError, match="limit"):
mesh.support_mesh(points, faces, max_vertices=4)
def test_support_adds_one_sample_to_isolated_triangle_and_is_idempotent():
points = np.array(((0., 0.), (20., 0.), (0., 2.)))
faces = np.array(((0, 1, 2),))
p, f = mesh.support_mesh(points, faces)
assert len(p) == 4 and len(f) == 3
assert_supported(p, f, g.boundary_vertices_from_faces(len(p), f), [None])
again_p, again_f = mesh.support_mesh(p, f)
np.testing.assert_array_equal(p, again_p)
np.testing.assert_array_equal(f, again_f)
@pytest.mark.parametrize("spacing", [1, 4, 16])
def test_pixel_corner_hole_contacts_produce_valid_supported_mesh(spacing):
alpha = np.ones((40, 40))
alpha[10:17, 10:17] = 0
alpha[17:24, 17:24] = 0
original = alpha.copy()
result = mesh.build_mesh(alpha, spacing, .9)
assert_supported(*result)
np.testing.assert_array_equal(alpha, original)
p, f, b, _ = result
edges, counts = np.unique(np.sort(np.concatenate((f[:, :2], f[:, 1:], f[:, ::2])), axis=1), axis=0, return_counts=True)
assert np.all(np.bincount(edges[counts == 1].ravel(), minlength=len(p))[b] == 2)
centers = p[f].mean(axis=1)
# The pixel-scale smoothing may round the hole corners; its interior stays open.
assert not np.any((centers[:, 0] > 11) & (centers[:, 0] < 16) & (centers[:, 1] > 11) & (centers[:, 1] < 16))
@pytest.mark.parametrize("damage", ["missing_edge", "missing_faces", "missing_endpoint"])
def test_quality_mesh_rejects_broken_boundary_coverage(monkeypatch, damage):
from anyimage.operators.cutout_tool import tangle
original = tangle.build_mesh
def damaged(*args, **kwargs):
points, faces, segments = original(*args, **kwargs)
if damage == "missing_edge":
segments = [segment for segment in segments if segment[2] != 1]
elif damage == "missing_faces":
faces = faces[1:]
else:
_, b, _ = next(segment for segment in segments if segment[2] == 1)
points[b] = (points[b][0] + .05, points[b][1] + .05)
return points, faces, segments
monkeypatch.setattr(tangle, "build_mesh", damaged)
contour = np.array(((0., 0.), (20., 0.), (20., 10.), (0., 10.)))
with pytest.raises(ValueError):
mesh.triangulate([contour], 8)
def branch_alpha(offset=0):
yy, xx = np.mgrid[:192, :192]
xx = xx - offset
yy = yy - offset
alpha = (xx - 46) ** 2 + (yy - 48) ** 2 < 30**2
path = np.asarray(((48, 48), (91, 70), (130, 110), (149, 167)))
for a, b in zip(path[:-1], path[1:]):
d = np.stack((xx - a[0], yy - a[1]), axis=-1)
v = b - a
t = np.clip(d @ v / (v @ v), 0, 1)
alpha |= np.sum((d - t[..., None] * v) ** 2, axis=-1) < 2.5**2
alpha |= (xx - 130) ** 2 + (yy - 110) ** 2 < 7**2
return alpha.astype(float)
def assert_supported(points, faces, boundary, components):
triangles = points[faces]
area = np.abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
assert np.isfinite(points).all() and np.all(area > 1e-8)
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
edges, counts = np.unique(edges, axis=0, return_counts=True)
assert counts.max() <= 2
interior = np.flatnonzero(~boundary)
links = edges[~boundary[edges].any(axis=1)]
graph = coo_matrix(
(np.ones(len(links)), (links[:, 0], links[:, 1])),
shape=(len(points), len(points)),
).tocsr()
count, _labels = connected_components(graph[interior][:, interior], directed=False)
assert count == len(components)
assert not np.any(boundary[faces].all(axis=1))
assert len(interior) >= len(components)
profile = poisson_balloon_profile(points, faces, boundary)
assert np.isfinite(profile).all()
assert np.all(profile[interior] > 0)
@pytest.mark.parametrize("spacing", (4, 8, 16, 28, 32, 36))
@pytest.mark.parametrize("offset", (0, 1))
def test_fine_bent_leg_and_joint_have_connected_positive_support(spacing, offset):
points, faces, boundary, components = mesh.build_mesh(
branch_alpha(offset), spacing, 0.9
)
assert_supported(points, faces, boundary, components)
interior = points[~boundary]
checkpoints = np.array(((48, 48), (90, 70), (130, 110), (146, 159))) + offset
distance, nearest = cKDTree(interior).query(checkpoints)
assert distance.max() < 12
edges = np.unique(
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
axis=0,
)
links = edges[~boundary[edges].any(1)]
graph = coo_matrix(
(np.ones(len(links)), (links[:, 0], links[:, 1])),
shape=(len(points), len(points)),
).tocsr()
_, labels = connected_components(graph, directed=False)
assert len(set(labels[np.flatnonzero(~boundary)[nearest]])) == 1
@pytest.mark.parametrize("spacing", (4, 8, 16, 32))
def test_fine_comb_hole_and_one_pixel_bridge(spacing):
alpha = np.zeros((160, 180))
alpha[15:145, 15:42] = 1
alpha[45:115, 125:165] = 1
alpha[75, 40:126] = 1
alpha[65:90, 140:150] = 0
for row in range(20, 140, 12):
alpha[row : row + 2, 35:110] = 1
result = mesh.build_mesh(alpha, spacing, 0.9)
assert_supported(*result)
points, faces, _boundary, components = result
assert len(components) == 1
assert not np.any(
g.points_inside_contours(
points[faces].mean(axis=1),
[np.asarray(((140, 65), (150, 65), (150, 90), (140, 90)))],
)
)
def test_fine_cleanup_uses_threshold_and_preserves_input_and_largest_island():
alpha = np.zeros((40, 60))
alpha[5:25, 5:25] = 0.5
alpha[12, 20:45] = 0.5
alpha[32:34, 50:52] = 1
original = alpha.copy()
filtered = mesh.filter_alpha(alpha, 0.3)
assert filtered[12, 44] == 0.5 and filtered[32, 50] == 0
np.testing.assert_array_equal(alpha, original)
assert_supported(*mesh.build_mesh(alpha, 32, 0.3))
assert_supported(*mesh.build_mesh(alpha, 32, 0.9))
with pytest.raises(ValueError, match="no visible"):
mesh.build_mesh(np.zeros((10, 10)), 32, 0.9)
def test_higher_detail_recovers_curve_instead_of_splitting_low_edges():
yy, xx = np.mgrid[:240, :240]
alpha = ((xx - 120) ** 2 + (yy - 120) ** 2 < 92**2).astype(float)
reference = g.extract_selection_contours(alpha, max_resolution=240, threshold=0.9)
source = g.resample_closed_contour(np.asarray(reference.components[0][0]), 0.5)
errors = []
for spacing in (32, 8, 4):
contours = mesh.contours_for_component(reference.components[0], spacing)
sampled = g.resample_closed_contour(contours[0], 0.5)
forward = boundary_distance(source, contours)
backward = boundary_distance(sampled, [np.asarray(reference.components[0][0])])
errors.append(max(np.percentile(forward, 95), np.percentile(backward, 95)))
assert errors[1] < errors[0]
assert errors[2] <= errors[1] + 0.1
def test_fine_outline_softens_pixel_steps_in_normalized_coordinates():
yy, xx = np.mgrid[:120, :120]
alpha = ((xx - 60)**2 + (yy - 60)**2 < 45**2).astype(float)
source = np.asarray(g.extract_selection_contours(alpha, max_resolution=120).components[0][0])
transform = np.diag((8., 12.))
contours = mesh.contours_for_component([source @ transform], 32, pixel_transform=transform)
smoothed = contours[0] @ np.linalg.inv(transform)
def turning(points):
edges = np.roll(points, -1, axis=0) - points
angles = np.arctan2(edges[:, 1], edges[:, 0])
change = (np.roll(angles, -1) - angles + np.pi) % (2*np.pi) - np.pi
return abs(change).sum()
assert turning(smoothed) < turning(source) * .5
assert boundary_distance(smoothed, [source]).max() < 1
assert abs(g.contour_area(smoothed) / g.contour_area(source) - 1) < .02
@pytest.mark.parametrize("offset", [0, 4096, 16384])
def test_cdt_collinear_outline_samples_do_not_become_sliver_faces(offset):
contour = np.asarray(((0.0, 0.0), (240.0, 0.0), (240.0, 160.0), (0.0, 160.0)))
contour = contour @ np.asarray(((0.763, -0.646), (0.646, 0.763)))
contour += np.asarray((420.123456, 540.234567)) + offset
contours = [g.resample_closed_contour(contour, 16)]
points, faces = mesh.triangulate(contours, 16)
triangles = points[faces]
area = abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
longest_squared = np.max(
np.sum((triangles - np.roll(triangles, 1, axis=1)) ** 2, axis=2), axis=1
)
assert np.min(area / longest_squared) > 1e-4
assert area.sum() / 2 == pytest.approx(abs(g.contour_area(contour)), rel=1e-9)
boundary = g.boundary_vertices_from_faces(len(points), faces)
np.testing.assert_allclose(
boundary_distance(points[boundary], [contour]), 0, atol=1e-9
)
# Every subdivided outline point survives; removing slivers must not skip it.
assert cKDTree(points[boundary]).query(contours[0])[0].max() < 1e-9
def test_contour_validation_requires_separation_of_pixel_contacts():
crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0)))
assert not mesh.valid_contours([crossing])
touching = np.asarray(
((0, 0), (2, 0), (2, 2), (4, 2), (4, 4), (2, 4), (2, 2), (0, 2))
)
assert not mesh.valid_contours([touching])
assert mesh.valid_contours(mesh.separate_contour_contacts([touching.astype(float)]))
@pytest.mark.parametrize("fine_outline", [False, True])
def test_hole_policy_preserves_alpha_and_external_notches(fine_outline):
alpha = np.ones((80, 80))
alpha[10:12, 10:12] = 0
alpha[20:29, 20:29] = 0
alpha[40:41, 10:40] = 0
alpha[:15, 60:65] = 0
original = alpha.copy()
mask = mesh.prepare_mask(alpha, 0.9, np.eye(2) * 3.2, fine_outline)
assert mask[10, 10]
assert mask[24, 24] == (not fine_outline)
assert mask[40, 25] == (not fine_outline)
assert not mask[0, 60]
np.testing.assert_array_equal(alpha, original)
@pytest.mark.parametrize("size,filled", [(2, True), (3, False)])
def test_micro_hole_threshold_respects_world_transform(size, filled):
alpha = np.ones((40, 40))
alpha[10 : 10 + size, 10 : 10 + size] = 0
mask = mesh.prepare_mask(alpha, 0.9, np.eye(2) * 3.2, True)
assert bool(mask[10, 10]) == filled
stretched = mesh.prepare_mask(alpha, 0.9, np.diag((6.4, 3.2)), True)
assert not stretched[10, 10]
def test_filling_nested_hole_merges_island_without_overlap():
alpha = np.zeros((80, 80))
alpha[5:75, 5:75] = 1
alpha[20:60, 20:60] = 0
alpha[30:50, 30:50] = 1
result = mesh.build_mesh(alpha, 8, 0.9, fine_outline=False)
p, f, _, components = result
assert len(components) == 1
assert_supported(*result)
edges = np.unique(
np.sort(np.concatenate((f[:, :2], f[:, 1:], f[:, ::2])), axis=1), axis=0
)
assert 1 - len(p) + len(edges) - len(f) == 0
def test_tangle_numeric_context_and_failure_recovery():
from decimal import getcontext
from anyimage.operators.cutout_tool import tangle
points = [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)]
segments = [(0, 1, 1), (1, 2, 2), (2, 3, 3), (3, 0, 4)]
before = getcontext().copy()
epsilon = tangle.EPS
first = tangle.build_mesh(points, segments, [], 10.0)
with pytest.raises(ValueError, match="limit"):
tangle.build_mesh(points, segments, [], 0.01, max_vertices=12)
assert getcontext().prec == before.prec and getcontext().flags == before.flags
assert tangle.EPS == epsilon
assert first == tangle.build_mesh(points, segments, [], 10.0)
def test_large_hole_grid_stops_at_vertex_limit_and_recovers():
from anyimage.operators.cutout_tool import tangle
points = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]
segments = [(0, 1, 1), (1, 2, 2), (2, 3, 3), (3, 0, 4)]
holes = []
for y in range(10, 90, 10):
for x in range(10, 90, 10):
start = len(points)
points.extend([(x, y), (x + 3, y), (x + 3, y + 3), (x, y + 3)])
segments.extend(
(start + i, start + (i + 1) % 4, len(segments) + i + 1)
for i in range(4)
)
holes.append((x + 1.5, y + 1.5))
with pytest.raises(ValueError, match="limit"):
tangle.build_mesh(points, segments, holes, 0.001, max_vertices=300)
p, f, _ = tangle.build_mesh(points, segments, holes, 40.0, max_vertices=5000)
assert len(p) < 5000 and len(f) > 0

View File

@@ -39,6 +39,7 @@ def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
bpy.context.view_layer.update()
positive_x = _create_cutout_shape(
source,
@@ -56,11 +57,11 @@ def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
for result in (positive_x, negative_z):
modifier = result.modifiers[0]
mode = object_data.modifier_input_identifier(modifier.node_group, "Mode")
assert modifier[mode] == (1 if gesture == "POLYLINE" else 0)
assert modifier_value(modifier, mode) == (1 if gesture == "POLYLINE" else 0)
thickness = object_data.modifier_input_identifier(
modifier.node_group, "Thickness", subtype="DISTANCE",
)
assert modifier[thickness] == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0)
assert modifier_value(modifier, thickness) == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
assert np.ptp(local[:, 0]) > 0.0
@@ -70,7 +71,7 @@ def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
assert np.allclose(positive_x.matrix_world, source.matrix_world)
modifier = positive_x.modifiers[0]
axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis")
assert modifier[axis] == 0
assert modifier_value(modifier, axis) == 0
object_data.set_modifier_input(modifier, axis, 1)
positive_x.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
@@ -261,9 +262,9 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Mode")] == (1 if gesture == "POLYLINE" else 0)
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")] == 0.5
assert modifier[object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")] == 1.0
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == 0.5
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
@@ -274,14 +275,12 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")),
expected_cleanup,
)
assert np.isclose(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)
assert (
modifier[
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
]
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
== 0
)
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
@@ -290,7 +289,7 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
modifier.node_group,
"Reference Depth",
)
assert modifier[reference_id] == 8.0
assert modifier_value(modifier, reference_id) == 8.0
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
@@ -639,7 +638,7 @@ def test_depth_balloon_uses_metric_plane_inputs():
modifier.node_group,
"Depth Image",
)
assert isinstance(modifier[depth_image_id], bpy.types.Image)
assert isinstance(modifier_value(modifier, depth_image_id), bpy.types.Image)
expected_center = np.zeros(2, dtype=np.float64)
crop_center = Vector(
(
@@ -659,18 +658,18 @@ def test_depth_balloon_uses_metric_plane_inputs():
modifier.node_group,
"Reference Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
assert not np.isclose(modifier_value(modifier, depth_reference_id), 8.0)
depth_direction_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Direction",
)
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
direction = np.asarray(modifier_value(modifier, depth_direction_id), dtype=np.float64)
assert not np.allclose(direction, (0.0, 0.0, 1.0))
uniform_scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
assert modifier[uniform_scale_id] > 0.0
assert modifier_value(modifier, uniform_scale_id) > 0.0
thickness_id = object_data.modifier_input_identifier(
modifier.node_group,
"Thickness",
@@ -679,7 +678,7 @@ def test_depth_balloon_uses_metric_plane_inputs():
modifier.node_group,
"Smooth Weight",
)
assert modifier[smooth_id] == 1.0
assert modifier_value(modifier, smooth_id) == 1.0
object_data.set_modifier_input(modifier, smooth_id, 0.0)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
@@ -707,7 +706,7 @@ def test_depth_balloon_uses_metric_plane_inputs():
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.2,
modifier_value(modifier, depth_reference_id) + 0.2,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
@@ -729,7 +728,7 @@ def test_depth_balloon_uses_metric_plane_inputs():
object_data.set_modifier_input(
modifier,
depth_reference_id,
modifier[depth_reference_id] + 0.15,
modifier_value(modifier, depth_reference_id) + 0.15,
)
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
result.update_tag(refresh={"DATA"})
@@ -889,17 +888,15 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")),
expected_cleanup,
)
assert (
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")]
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"))
== 1.0
)
assert (
modifier[
object_data.modifier_input_identifier(modifier.node_group, "Smooth")
]
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
== 0
)
assert len(result.data.vertices) > 4
@@ -931,3 +928,15 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
def modifier_value(modifier, identifier):
"""Read modifier values through the runtime's socket API."""
inputs = object_data._modifier_input_slots(modifier)
if inputs is None:
return modifier[identifier]
slot = getattr(inputs, identifier)
prop = slot.bl_rna.properties["value"]
if prop.type == "ENUM":
return list(prop.enum_items.keys()).index(slot.value)
return slot.value

View File

@@ -73,12 +73,12 @@ def test_camera_blur_preserves_rays_interior_protection_and_disconnected_surface
def test_cutout_fine_outline_blurs_depth_without_moving_projection():
from tests.support.depth_surface import surface
from anyimage.operators.cutout_tool.fine_mesh import build_fine_mesh
from anyimage.operators.cutout_tool.mesh import build_mesh
obj, set_value = surface(step=0, resolution=8)
yy, xx = np.mgrid[:32, :64]
alpha = ((xx - 32)**2 / 27**2 + (yy - 16)**2 / 13**2 < 1).astype(float)
points, faces, _, _ = build_fine_mesh(alpha, 3, .9)
points, faces, _, _ = build_mesh(alpha, 3, .9)
obj.data.clear_geometry()
obj.data.from_pydata([(x / 32, y / 32, 0) for x, y in points], [], faces.tolist())
uv = obj.data.uv_layers.get("UVMap") or obj.data.uv_layers.new(name="UVMap")

View File

@@ -94,7 +94,9 @@ class ExtensionPackagingTest(unittest.TestCase):
"operators/cutout_tool/operators.py",
"operators/cutout_tool/interaction.py",
"operators/cutout_tool/geometry.py",
"operators/cutout_tool/fine_mesh.py",
"operators/cutout_tool/mesh.py",
"operators/cutout_tool/tangle.py",
"licenses/tangle.txt",
"operators/cutout_tool/object.py",
"operators/cutout_tool/shape.py",
"operators/convert_to_plane/__init__.py",

View File

@@ -278,6 +278,21 @@ def _build_depth_surface(group, geometry, controls):
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"], split,
)
projection = smooth_cut_boundary(group, projection)
# Clamped depth pixels can collapse adjacent outline samples after projection.
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
length = group.nodes.new("ShaderNodeVectorMath")
length.operation = "DISTANCE"
links.new(edge.outputs["Position 1"], length.inputs[0])
links.new(edge.outputs["Position 2"], length.inputs[1])
typical = group.nodes.new("GeometryNodeAttributeStatistic")
typical.data_type, typical.domain = "FLOAT", "EDGE"
links.new(projection, typical.inputs["Geometry"])
links.new(length.outputs["Value"], typical.inputs["Attribute"])
merge = group.nodes.new("GeometryNodeMergeByDistance")
merge.mode = "CONNECTED"
links.new(projection, merge.inputs["Geometry"])
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
projection = merge.outputs["Geometry"]
projection = taper_split_profile(group, original, projection)
# The mode field is shared by the geometry and thickness choices.
mode = group.nodes.new("GeometryNodeMenuSwitch")