Add soft min and max constraints for frame resolution and cutout edge length properties
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-13
|
||||
64
openspec/changes/use-tangle-cutout-meshing/design.md
Normal file
64
openspec/changes/use-tangle-cutout-meshing/design.md
Normal 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
|
||||
|
||||
先固定后端与数值接口,再接入统一区域、中线及三角化,最后清理旧调用。检查源码和许可被现有打包规则收录;仅在实际改变依赖元数据时同步相关清单。运行相关测试,不构建扩展包或同步其他说明文档。若验收失败,在当前实现阶段修复;已集成变更的回退使用版本控制恢复整个变更,不保留运行时旧算法分支。
|
||||
28
openspec/changes/use-tangle-cutout-meshing/proposal.md
Normal file
28
openspec/changes/use-tangle-cutout-meshing/proposal.md
Normal 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 使用。
|
||||
@@ -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 设置及同一有效世界边长与网格校验规则
|
||||
52
openspec/changes/use-tangle-cutout-meshing/tasks.md
Normal file
52
openspec/changes/use-tangle-cutout-meshing/tasks.md
Normal 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.
21
src/anyimage/licenses/tangle.txt
Normal file
21
src/anyimage/licenses/tangle.txt
Normal 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.
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
426
src/anyimage/operators/cutout_tool/mesh.py
Normal file
426
src/anyimage/operators/cutout_tool/mesh.py
Normal 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
|
||||
2690
src/anyimage/operators/cutout_tool/tangle.py
Normal file
2690
src/anyimage/operators/cutout_tool/tangle.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
334
tests/anyimage/operators/test_cutout_mesh.py
Normal file
334
tests/anyimage/operators/test_cutout_mesh.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user