diff --git a/.cursor/rules/cross-platform-cpp.mdc b/.cursor/rules/cross-platform-cpp.mdc index d6e5c02..5ace1f6 100644 --- a/.cursor/rules/cross-platform-cpp.mdc +++ b/.cursor/rules/cross-platform-cpp.mdc @@ -6,6 +6,30 @@ alwaysApply: false # C++ 跨平台编译规范 +本项目支持 Linux / macOS / Windows (MSVC) 三平台。所有 C++ 代码必须在 MSVC 上编译通过。 + +## MSVC 禁用项 + +- **禁止 `M_PI` / `M_E` / `M_SQRT2` 等 POSIX 数学常量**:MSVC 默认不定义。使用 C++20 `std::numbers::pi`、`std::numbers::e` 等替代,需 `#include `。 +- **禁止 `constexpr` 中调用 `std::pow`、`std::exp`、`std::log`、`std::sin` 等**:MSVC 不将这些视为 constexpr。需要编译期求值时使用运行时初始化的 `static` 变量或 LUT。 +- **禁止 VLA(变长数组)**:MSVC 不支持。使用 `std::vector` 或固定大小数组。 +- **禁止 GCC/Clang 扩展**:`__attribute__`、`__builtin_*`、`__typeof__`、`__PRETTY_FUNCTION__`。使用标准等价物(如 `[[nodiscard]]`、`__FUNCSIG__` + `#ifdef` 分支)。 + +## MSVC OpenMP 2.0 限制 + +MSVC 仅支持 OpenMP 2.0,以下功能不可用: +- `#pragma omp parallel for` 的循环变量必须是 `int`(不支持 `size_t`、`unsigned`、迭代器) +- 不支持 `collapse`、`task`、`taskloop` +- 不支持自定义类型的 `reduction`:使用 per-thread 数组 + 手动合并 +- OpenMP API 调用(`omp_get_thread_num()` 等)必须用 `#ifdef _OPENMP` 保护 + +## POSIX 函数兼容 + +- `popen`/`pclose` → MSVC 下用 `_popen`/`_pclose`,需 `#if defined(_WIN32)` 分支 +- `localtime_r` → MSVC 下用 `localtime_s`(参数顺序不同) +- `strdup` → MSVC 下用 `_strdup` +- `strcasecmp` → MSVC 下用 `_stricmp` + ## 文件系统路径 禁止硬编码 POSIX 路径,一律使用 `std::filesystem`(C++17)。 @@ -24,4 +48,4 @@ alwaysApply: false ## 变更检查 -每次新增系统调用或文件操作时,必须确认跨平台兼容性。 +每次新增系统调用、数学常量、OpenMP 代码或文件操作时,必须确认 MSVC 兼容性。 diff --git a/.cursor/rules/eval-usage.mdc b/.cursor/rules/eval-usage.mdc new file mode 100644 index 0000000..dc259a2 --- /dev/null +++ b/.cursor/rules/eval-usage.mdc @@ -0,0 +1,120 @@ +# 评估系统使用指南 + +当需要运行 eval 评估时,直接使用以下信息,无需重新查看文件。 + +## 测试图像 + +Manifest 路径:`test_data/images/manifest.json` + +共 15 张图,分两类: + +### simple(8 张) + +| 简称 | 文件 | 尺寸 | 特征 | +|------|------|------|------| +| tjls | `simple/tjls.jpg` | 3071×3071 | 只有 2 种主色 | +| varesa | `simple/varesa.JPG` | 1431×1431 | 需 16-18 色保持色彩完整 | +| bcba38 | `simple/BCBA38E4139B200BF7018202BD7071DA.jpg` | 1450×2048 | 中等复杂度 | +| s_c0315 | `simple/c03155ca4f21690e44fe38b5d2e94e4a_535808916776764142.png` | 188×253 | 小图 | +| s_b8489 | `simple/b848977173910bd7f1029f89003dff75_3952281026289371623.png` | 96×96 | 小图 | +| s_0ce86 | `simple/0ce86ee140a04fd833f948a637af2283_1513181873101011224.png` | 96×96 | 小图 | +| s_64c9a | `simple/64c9aca07027becd6143bbbdb47a323a_7089473772145220523.png` | 96×96 | 黑白二值小图 | +| s_39e69 | `simple/39e69fa36571347e9300cb88dedea782_5990935446758869519.png` | 96×96 | 小图 | + +### complex(6 张) + +| 简称 | 文件 | 尺寸 | 特征 | +|------|------|------|------| +| miku | `complex/miku.png` | 4680×2876 | 多色区域+渐变,大图 | +| reward | `complex/reward_1002.b096a174..png` | 200×200 | 中等 | +| c_193ce | `complex/193ce3eb96bce41fc84619164cd3aed5_748736223171964553.png` | 360×376 | 中等 | +| c_5bf9c | `complex/5bf9ca7c5b342d38c8b3fbf0964e00da_6402413799256732419.png` | 450×450 | 中等 | +| c_7eda8 | `complex/7eda813e089f1bdcec4e7c7e93b101d6_6078806162773208785.png` | 450×450 | 中等 | +| c_8e8d9 | `complex/8e8d9ba7da393866262621c946642090_7081326994847318235.png` | 450×450 | 中等 | + +## 常用命令 + +### 批量评估(14 张全跑) + +```bash +# V2 管线 +./build/apps/evaluate_svg \ + --manifest test_data/images/manifest.json \ + --svg-dir test_data/images/results/<输出目录名> \ + --json test_data/images/results/<输出目录名>/report.json \ + --pipeline v2 \ + --log-level info \ + --note "说明文字" + +# V1 管线 +./build/apps/evaluate_svg \ + --manifest test_data/images/manifest.json \ + --svg-dir test_data/images/results/<输出目录名> \ + --json test_data/images/results/<输出目录名>/report.json \ + --pipeline v1 \ + --log-level info +``` + +### 单图评估 + +```bash +./build/apps/evaluate_svg \ + --image test_data/images/simple/tjls.jpg \ + --svg-dir test_data/images/results/<输出目录名> \ + --json test_data/images/results/<输出目录名>/report.json \ + --pipeline v2 \ + --log-level debug +``` + +### 单图矢量化(不评估) + +```bash +./build/apps/raster_to_svg \ + --image test_data/images/complex/miku.png \ + --out /tmp/miku.svg \ + --pipeline v2 \ + --log-level debug +``` + +### 只跑某一类 + +```bash +./build/apps/evaluate_svg \ + --manifest test_data/images/manifest.json \ + --svg-dir test_data/images/results/<输出目录名> \ + --json test_data/images/results/<输出目录名>/report.json \ + --pipeline v2 \ + --category simple +``` + +## 关键参数覆盖 + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--colors N` | 颜色数,0=自动 | 0 | +| `--pipeline MODE` | v1 或 v2 | v1 | +| `--log-level LEVEL` | trace/debug/info/warn/error/off | info | +| `--min-region N` | 最小区域面积 | 50 | +| `--curve-fit-error F` | 曲线拟合误差 | 0.8 | +| `--smoothing-spatial F` | Mean Shift 空间半径 | 15 | +| `--smoothing-color F` | Mean Shift 颜色半径 | 25 | +| `--max-working-pixels N` | 自动缩放阈值 | 3000000 | + +## 已有评估结果目录 + +结果存储在 `test_data/images/results/` 下: +- `v1_eval/` — V1 管线基准 +- `v2_eval/` — V2 管线初始基准 +- `v2_eval_new/` — V2 修复 AutoDetectK 后(kTargetRemaining=0.02) +- `v2_eval_tuned/` — V2 调优 AutoDetectK 后(kTargetRemaining=0.005) + +## 输出格式 + +report.json 中每张图的 metrics 包含: +- `score` — 综合得分 +- `unique_colors` — 实际使用的颜色数 +- `delta_e_mean` — 平均色差 +- `ssim` — 结构相似度 +- `coverage` — 覆盖率 +- `total_shapes` — 形状总数 +- `vectorize_time_ms` — 矢量化耗时 diff --git a/.cursor/rules/post-algo-eval.mdc b/.cursor/rules/post-algo-eval.mdc new file mode 100644 index 0000000..44f4e8e --- /dev/null +++ b/.cursor/rules/post-algo-eval.mdc @@ -0,0 +1,34 @@ +--- +description: 算法改进后必须跑 eval 验证效果 +alwaysApply: true +--- + +# 算法改进后验证 + +当修改涉及以下模块的算法逻辑时,完成代码修改并确认编译通过后,必须跑一次 eval 验证效果: + +- 颜色量化 (`src/quantize/`) +- 深度排序 / 形状延伸 (`src/stacking/`) +- Potrace 追踪 / 覆盖率修补 (`src/trace/`) +- 曲线拟合 / 路径优化 (`src/curve/`) +- 管线编排 (`src/pipeline.cpp`, `src/pipeline_v2.cpp`) +- SVG 输出 (`src/output/`) +- 预处理 / 分割 (`src/preprocess/`, `src/segment/`) + +## 验证流程 + +1. 编译通过 + 单元测试通过 +2. 跑 V2 批量评估(15 张全跑),与上一次结果对比: + +```bash +./build/apps/evaluate_svg \ + --manifest test_data/images/manifest.json \ + --svg-dir test_data/images/results/<本次目录名> \ + --json test_data/images/results/<本次目录名>/report.json \ + --pipeline v2 \ + --log-level info \ + --note "<本次改动简述>" +``` + +3. 对比 report.json 中关键指标(score、delta_e_mean、ssim、coverage),确认无回归 +4. 若改动同时影响 V1,额外跑一次 `--pipeline v1` 验证 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0071e10..0686252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,7 +175,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, macos-14, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a61ab37..7a90ba7 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -8,7 +8,7 @@ on: jobs: # ── Build wheels on each platform ───────────────────────────────────────── build-wheels: - name: Wheels (${{ matrix.os }}, ${{ matrix.arch }}) + name: Wheels (${{ matrix.id }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -16,10 +16,25 @@ jobs: include: - os: ubuntu-24.04 arch: x86_64 + cibw_build: "cp310-* cp311-* cp312-* cp313-* cp314-*" + id: linux-x86_64 - os: macos-14 - arch: "x86_64 arm64" + arch: arm64 + cibw_build: "cp310-* cp311-* cp312-* cp313-* cp314-*" + id: macos-arm64 + # Split Windows into parallel groups to reduce wall time - os: windows-latest arch: AMD64 + cibw_build: "cp310-* cp311-*" + id: windows-py310-311 + - os: windows-latest + arch: AMD64 + cibw_build: "cp312-* cp313-*" + id: windows-py312-313 + - os: windows-latest + arch: AMD64 + cibw_build: "cp314-*" + id: windows-py314 steps: - uses: actions/checkout@v4 @@ -40,14 +55,15 @@ jobs: key: vcpkg-win-${{ hashFiles('ci/install-deps-windows.bat', 'ci/potrace-CMakeLists.txt') }} restore-keys: vcpkg-win- - - uses: pypa/cibuildwheel@v2.23 + - uses: pypa/cibuildwheel@v3.4.0 env: CIBW_ARCHS: ${{ matrix.arch }} + CIBW_BUILD: ${{ matrix.cibw_build }} SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.SETUPTOOLS_SCM_PRETEND_VERSION }} - uses: actions/upload-artifact@v4 with: - name: wheels-${{ matrix.os }}-${{ matrix.arch }} + name: wheels-${{ matrix.id }} path: wheelhouse/*.whl retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index fc102e3..7330ef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,12 +11,20 @@ ## 项目全景 -本库实现栅格到 SVG 矢量化的完整管线,按 7 个阶段组织: +本库实现栅格到 SVG 矢量化的完整管线,支持两种管线模式: +**V1(默认)**:边界图 + 剪切模型 ``` 输入图像 → [预处理] → [颜色分割] → [边界提取] → [轮廓装配] → [曲线拟合] → [轮廓追踪] → [SVG输出] ``` +**V2(层叠模型)**:深度排序 + 画家算法 +``` +输入图像 → [预处理] → [OKLab MMCQ量化] → [小区域合并] → [连通域提取] → [深度排序] → [形状延伸] → [逐层Potrace] → [路径优化] → [同色合并] → [覆盖率修补] → [SVG输出] +``` + +通过 `VectorizerConfig::pipeline_mode` 选择 `PipelineMode::V1` 或 `PipelineMode::V2`。 + ### 目录映射 - `include/neroued/vectorizer/`:公共 API 头文件(VectorizerConfig / VectorizerResult / Vectorize) @@ -24,10 +32,12 @@ - `src/segment/`:SLIC 超像素、K-Means 聚类、形态学清理、小区域合并 - `src/boundary/`:像素级边界图构建、亚像素细化、抗锯齿边缘检测 - `src/contour/`:链式轮廓装配、薄线矢量化 -- `src/curve/`:贝塞尔工具函数、Schneider 曲线拟合 +- `src/curve/`:贝塞尔工具函数、Schneider 曲线拟合、路径优化(V2) - `src/trace/`:Potrace 位图追踪、覆盖率修补、Clipper2 拓扑修复 +- `src/stacking/`:V2 层叠模型(深度排序、形状延伸) +- `src/quantize/`:V2 OKLab MMCQ 颜色量化 - `src/output/`:SVG 文档生成、同色形状合并 -- `src/detail/`:内部工具(OpenCV 辅助、ICC 色彩管理) +- `src/detail/`:内部工具(OpenCV 辅助、ICC 色彩管理、VectorizedShape 核心类型) - `python/`:Python 绑定(pybind11 绑定代码、Python 包、测试) - `eval/`:质量评估库(像素/边缘/路径指标、基线对比) - `apps/`:CLI 工具(raster_to_svg、evaluate_svg) @@ -47,7 +57,12 @@ | 修改 Potrace 追踪行为 | `src/trace/potrace.cpp` | | 修改覆盖率修补逻辑 | `src/trace/coverage.cpp` | | 修改拓扑修复策略 | `src/trace/topology.cpp` | -| 调整管线编排流程 | `src/pipeline.cpp` | +| 调整 V1 管线编排流程 | `src/pipeline.cpp` | +| 调整 V2 管线编排流程 | `src/pipeline_v2.cpp` | +| 修改 V2 深度排序逻辑 | `src/stacking/depth_order.cpp` | +| 修改 V2 形状延伸策略 | `src/stacking/shape_extend.cpp` | +| 修改 V2 OKLab 颜色量化 | `src/quantize/color_quantize.cpp`、`src/quantize/oklab.h` | +| 修改 V2 路径优化 | `src/curve/path_optimize.cpp` | | 新增/修改公共 API | `include/neroued/vectorizer/vectorizer.h`、`src/vectorizer.cpp` | | 新增/修改配置参数 | `include/neroued/vectorizer/config.h` | | 修改质量评估指标 | `eval/src/pixel_metrics.cpp`、`eval/src/edge_metrics.cpp`、`eval/src/path_metrics.cpp` | @@ -64,7 +79,7 @@ | 文件 | 内容 | |------|------| | `vectorizer.h` | 3 个 `Vectorize` 重载(文件路径 / 内存缓冲区 / cv::Mat) | -| `config.h` | `VectorizerConfig` — 管线完整配置 | +| `config.h` | `PipelineMode` 枚举、`VectorizerConfig` — 管线完整配置 | | `result.h` | `VectorizerResult` — SVG 内容、尺寸、形状数、调色板 | | `color.h` | `Rgb`、`Lab` 颜色类型及空间转换 | | `vec2.h` / `vec3.h` | 2D/3D 向量类型 | diff --git a/CMakeLists.txt b/CMakeLists.txt index 1656548..35463b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,7 +129,12 @@ add_library(neroued_vectorizer STATIC src/trace/topology.cpp src/output/svg_writer.cpp src/output/shape_merge.cpp - src/detail/icc_utils.cpp) + src/detail/icc_utils.cpp + src/pipeline_v2.cpp + src/stacking/depth_order.cpp + src/stacking/shape_extend.cpp + src/quantize/color_quantize.cpp + src/curve/path_optimize.cpp) add_library(neroued::vectorizer ALIAS neroued_vectorizer) diff --git a/README.md b/README.md index c283366..b475b4a 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ ## 特性 -- 7 阶段流水线:预处理 → 颜色分割 → 边界提取 → 轮廓装配 → 曲线拟合 → 轮廓追踪 → SVG 输出 -- 基于 Potrace 的位图追踪,Clipper2 拓扑修复 -- SLIC 超像素 + K-Means 自动调色板 -- Schneider 贝塞尔曲线拟合,亚像素边界细化 +- 双管线架构:V1(边界图 + 剪切模型)和 V2(层叠模型 + 深度排序) +- V2 管线:OKLab MMCQ 感知量化、深度排序画家算法、形状延伸消除缝隙、路径优化、同色合并、覆盖率修补 +- V1 管线:SLIC 超像素 + K-Means、Schneider 曲线拟合、Potrace + Clipper2 拓扑修复 +- 亚像素边界细化 - 薄线增强、抗锯齿边缘检测 - 可选 ICC 色彩管理(lcms2) - 质量评估模块(PSNR / SSIM / Delta E / Chamfer 距离) @@ -99,6 +99,7 @@ cmake --install build --prefix /usr/local | `--min-region` | 50 | 最小区域面积(像素²) | | `--upscale-short-edge` | 600 | 短边自动放大阈值 | | `--log-level` | info | 日志级别 | +| `--pipeline` | v1 | 管线模式:v1 或 v2 | 完整参数列表可通过 `--help` 查看。 @@ -168,6 +169,11 @@ config.num_colors = 8 config.curve_fit_error = 1.0 result = nv.vectorize("photo.png", config) +# 使用 V2 层叠管线 +config = nv.VectorizerConfig() +config.pipeline_mode = nv.PipelineMode.V2 +result = nv.vectorize("photo.png", config) + # 使用结果 print(result.svg_content) # SVG 文档字符串 print(result.width, result.height) @@ -227,46 +233,52 @@ std::ofstream("output.svg") << result.svg_content; ### VectorizerConfig 完整参数 -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| **颜色分割** | | | | -| `num_colors` | int | 0 | 调色板颜色数,0 = 自动检测 | -| `min_region_area` | int | 50 | 最小区域面积(像素²) | -| **曲线拟合** | | | | -| `curve_fit_error` | float | 0.8 | Schneider 曲线拟合误差阈值 | -| `corner_angle_threshold` | float | 135.0 | 角点检测角度阈值(度) | -| `smoothness` | float | 0.5 | 轮廓平滑度 [0,1] | -| **预处理** | | | | -| `smoothing_spatial` | float | 15.0 | Mean Shift 空间窗口半径 | -| `smoothing_color` | float | 25.0 | Mean Shift 颜色窗口半径 | -| `upscale_short_edge` | int | 600 | 短边自动放大阈值(0 = 禁用) | -| `max_working_pixels` | int | 3000000 | 自动缩小像素阈值(0 = 禁用) | -| **SLIC 分割** | | | | -| `slic_region_size` | int | 20 | SLIC 目标区域大小 | -| `slic_compactness` | float | 6.0 | SLIC 紧致度 | -| `edge_sensitivity` | float | 0.8 | 边缘感知空间权重衰减 [0,1] | -| `refine_passes` | int | 6 | 边界标签细化迭代次数 | -| `max_merge_color_dist` | float | 200.0 | 小区域合并最大 LAB ΔE² | -| **亚像素边界** | | | | -| `enable_subpixel_refine` | bool | true | 启用梯度引导亚像素细化 | -| `subpixel_max_displacement` | float | 0.7 | 亚像素最大法向位移 | -| **抗锯齿检测** | | | | -| `enable_antialias_detect` | bool | false | 启用 AA 混合边缘检测 | -| `aa_tolerance` | float | 10.0 | AA 混合像素最大 LAB ΔE | -| **薄线增强** | | | | -| `thin_line_max_radius` | float | 2.5 | 距离变换半径阈值 | -| **SVG 输出** | | | | -| `svg_enable_stroke` | bool | true | 启用描边输出 | -| `svg_stroke_width` | float | 0.5 | 描边宽度 | -| **细节控制** | | | | -| `detail_level` | float | -1.0 | 统一细节控制 [0,1](-1 = 禁用) | -| `merge_segment_tolerance` | float | 0.05 | 近线性贝塞尔段合并容差 | -| **Potrace 管线** | | | | -| `min_contour_area` | float | 10.0 | 最小轮廓面积 | -| `min_hole_area` | float | 4.0 | 最小孔洞面积 | -| `contour_simplify` | float | 0.45 | 轮廓简化强度 | -| `enable_coverage_fix` | bool | true | 启用覆盖率补全 | -| `min_coverage_ratio` | float | 0.998 | 触发补全的最低覆盖率 | +> **适用范围**:V1+V2 = 两条管线共用,V1 = 仅 V1 边界图管线,V2 = 仅 V2 层叠管线。 + +| 参数 | 类型 | 默认值 | 适用 | 说明 | +|------|------|--------|------|------| +| **管线选择** | | | | | +| `pipeline_mode` | PipelineMode | V1 | V1+V2 | 管线实现:V1(经典边界图)或 V2(层叠模型) | +| **颜色分割** | | | | | +| `num_colors` | int | 0 | V1+V2 | 调色板颜色数,0 = 自动检测 | +| `min_region_area` | int | 50 | V1+V2 | 最小区域面积(像素²) | +| **曲线拟合** | | | | | +| `curve_fit_error` | float | 0.8 | V1+V2 | 曲线拟合误差阈值(V1: Schneider 拟合, V2: 路径合并) | +| `corner_angle_threshold` | float | 135.0 | V1 | 角点检测角度阈值(度) | +| `smoothness` | float | 0.5 | V1 | 轮廓平滑度 [0,1] | +| **预处理** | | | | | +| `smoothing_spatial` | float | 15.0 | V1+V2 | Mean Shift 空间窗口半径 | +| `smoothing_color` | float | 25.0 | V1+V2 | Mean Shift 颜色窗口半径 | +| `upscale_short_edge` | int | 600 | V1+V2 | 短边自动放大阈值(0 = 禁用) | +| `max_working_pixels` | int | 3000000 | V1+V2 | 自动缩小像素阈值(0 = 禁用) | +| **SLIC 分割** | | | | | +| `slic_region_size` | int | 20 | V1 | SLIC 目标区域大小 | +| `slic_compactness` | float | 6.0 | V1 | SLIC 紧致度 | +| `edge_sensitivity` | float | 0.8 | V1 | 边缘感知空间权重衰减 [0,1] | +| `refine_passes` | int | 6 | V1 | 边界标签细化迭代次数 | +| `max_merge_color_dist` | float | 200.0 | V1+V2 | 小区域合并最大 LAB ΔE² | +| **亚像素边界** | | | | | +| `enable_subpixel_refine` | bool | true | V1 | 启用梯度引导亚像素细化 | +| `subpixel_max_displacement` | float | 0.7 | V1 | 亚像素最大法向位移 | +| **抗锯齿检测** | | | | | +| `enable_antialias_detect` | bool | false | V1 | 启用 AA 混合边缘检测 | +| `aa_tolerance` | float | 10.0 | V1 | AA 混合像素最大 LAB ΔE | +| **薄线增强** | | | | | +| `thin_line_max_radius` | float | 2.5 | V1 | 距离变换半径阈值 | +| **SVG 输出** | | | | | +| `svg_enable_stroke` | bool | true | V1+V2 | 启用描边输出 | +| `svg_stroke_width` | float | 0.5 | V1+V2 | 描边宽度 | +| **细节控制** | | | | | +| `detail_level` | float | -1.0 | V1 | 统一细节控制 [0,1](-1 = 禁用) | +| `merge_segment_tolerance` | float | 0.05 | V1+V2 | 近线性贝塞尔段合并容差 | +| **Potrace 管线** | | | | | +| `min_contour_area` | float | 10.0 | V1+V2 | 最小轮廓面积 | +| `min_hole_area` | float | 4.0 | V1+V2 | 最小孔洞面积 | +| `contour_simplify` | float | 0.45 | V1+V2 | 轮廓简化强度 | +| `enable_coverage_fix` | bool | true | V1+V2 | 启用覆盖率修补 | +| `min_coverage_ratio` | float | 0.998 | V1+V2 | 触发修补的最低覆盖率 | +| **诊断** | | | | | +| `enable_depth_validation` | bool | false | V2 | 启用深度排序诊断验证 | ### VectorizerResult @@ -297,8 +309,10 @@ neroued_vectorizer/ │ ├── segment/ # 颜色分割(SLIC、K-Means、形态学) │ ├── boundary/ # 边界提取(图构建、亚像素、AA检测) │ ├── contour/ # 轮廓装配(链式装配、薄线) -│ ├── curve/ # 曲线拟合(贝塞尔、Schneider) +│ ├── curve/ # 曲线拟合(贝塞尔、Schneider、路径优化) │ ├── trace/ # 追踪(Potrace、覆盖率、拓扑修复) +│ ├── stacking/ # V2 层叠模型(深度排序、形状延伸) +│ ├── quantize/ # V2 OKLab MMCQ 颜色量化 │ ├── output/ # 输出(SVG 写入、形状合并) │ └── detail/ # 内部工具(cv_utils、icc_utils) ├── python/ # Python 绑定 @@ -339,9 +353,9 @@ git push origin v0.2.0 | 平台 | 架构 | Python | |------|------|--------| -| Linux | x86_64, aarch64 | 3.10 – 3.13 | -| macOS | x86_64, arm64 | 3.10 – 3.13 | -| Windows | x86_64 | 3.10 – 3.13 | +| Linux | x86_64 | 3.10 – 3.14 | +| macOS | arm64 | 3.10 – 3.14 | +| Windows | x86_64 | 3.10 – 3.14 | ## 许可证 diff --git a/apps/evaluate_svg.cpp b/apps/evaluate_svg.cpp index 7baa5eb..3782313 100644 --- a/apps/evaluate_svg.cpp +++ b/apps/evaluate_svg.cpp @@ -60,6 +60,8 @@ void PrintUsage(const char* exe) { " --merge-tolerance F Near-linear segment merge tolerance\n" " --enable-antialias Enable AA mixed-edge detection\n" " --aa-tolerance F AA blend detection LAB tolerance\n" + " --enable-depth-validation V2: enable depth order validation diagnostic\n" + " --pipeline MODE Pipeline: v1 (default) or v2 (stacking model)\n" " --log-level LEVEL trace/debug/info/warn/error/off (default info)\n", exe, exe); } @@ -184,6 +186,22 @@ bool ParseArgs(int argc, char** argv, Options& opt) { opt.vec_overrides.enable_antialias_detect = true; continue; } + if (arg == "--enable-depth-validation") { + opt.vec_overrides.enable_depth_validation = true; + continue; + } + if (arg == "--pipeline" && i + 1 < argc) { + std::string mode = argv[++i]; + if (mode == "v1") + opt.vec_overrides.pipeline_mode = PipelineMode::V1; + else if (mode == "v2") + opt.vec_overrides.pipeline_mode = PipelineMode::V2; + else { + std::fprintf(stderr, "Invalid --pipeline: %s (use v1 or v2)\n", mode.c_str()); + return false; + } + continue; + } std::fprintf(stderr, "Unknown argument: %s\n", arg.c_str()); PrintUsage(argv[0]); diff --git a/apps/raster_to_svg.cpp b/apps/raster_to_svg.cpp index 0e1965e..3907a3c 100644 --- a/apps/raster_to_svg.cpp +++ b/apps/raster_to_svg.cpp @@ -42,7 +42,9 @@ struct Options { float aa_tolerance = 10.0f; bool svg_stroke = true; float svg_stroke_w = 0.5f; + bool enable_depth_validation = false; std::string log_level = "info"; + std::string pipeline = "v1"; }; void PrintUsage(const char* exe) { @@ -78,7 +80,9 @@ void PrintUsage(const char* exe) { " --aa-tolerance F AA blend detection LAB tolerance (default 10)\n" " --no-svg-stroke Disable SVG stroke output (default on)\n" " --svg-stroke-w F SVG stroke width when enabled (default 0.5)\n" - " --log-level LEVEL Log level: trace/debug/info/warn/error/off (default info)\n", + " --enable-depth-validation V2: enable depth order validation diagnostic\n" + " --log-level LEVEL Log level: trace/debug/info/warn/error/off (default info)\n" + " --pipeline MODE Pipeline: v1 (default) or v2 (stacking model)\n", exe); } @@ -297,6 +301,10 @@ bool ParseArgs(int argc, char** argv, Options& opt) { opt.svg_stroke = false; continue; } + if (arg == "--enable-depth-validation") { + opt.enable_depth_validation = true; + continue; + } if (arg == "--svg-stroke-w" && i + 1 < argc) { if (!ParseFloat(argv[++i], opt.svg_stroke_w) || opt.svg_stroke_w < 0.0f) { std::fprintf(stderr, "Invalid --svg-stroke-w\n"); @@ -308,6 +316,14 @@ bool ParseArgs(int argc, char** argv, Options& opt) { opt.log_level = argv[++i]; continue; } + if (arg == "--pipeline" && i + 1 < argc) { + opt.pipeline = argv[++i]; + if (opt.pipeline != "v1" && opt.pipeline != "v2") { + std::fprintf(stderr, "Invalid --pipeline (must be v1 or v2)\n"); + return false; + } + continue; + } std::fprintf(stderr, "Unknown argument: %s\n", arg.c_str()); PrintUsage(argv[0]); return false; @@ -365,8 +381,11 @@ int main(int argc, char** argv) { cfg.aa_tolerance = opt.aa_tolerance; cfg.svg_enable_stroke = opt.svg_stroke; cfg.svg_stroke_width = opt.svg_stroke_w; + cfg.enable_depth_validation = opt.enable_depth_validation; + cfg.pipeline_mode = (opt.pipeline == "v2") ? PipelineMode::V2 : PipelineMode::V1; - spdlog::info("Vectorizing {} -> {}", opt.image_path, opt.out_path); + spdlog::info("Vectorizing {} -> {} [pipeline={}]", opt.image_path, opt.out_path, + opt.pipeline); spdlog::info("Colors={}, contour_simplify={:.2f}, edge_sensitivity={:.2f}, " "refine_passes={}, max_merge_color_dist={:.1f}", cfg.num_colors, cfg.contour_simplify, cfg.edge_sensitivity, cfg.refine_passes, diff --git a/docs/research/00-executive-summary.md b/docs/research/00-executive-summary.md new file mode 100644 index 0000000..50b524d --- /dev/null +++ b/docs/research/00-executive-summary.md @@ -0,0 +1,115 @@ +# V2 矢量化管线调研报告 — 执行摘要 + +## 1. 问题定义 + +当前 V1 管线在视觉质量和文件尺寸上显著逊于 Adobe Illustrator Image Trace。核心痛点: + +- **视觉质量**:分割错误在纯前馈管线中不可逆传播,边界图拟合 + Potrace 回退 + CoverageGuard 补丁三重修补机制仍无法充分弥补 +- **文件尺寸偏大**:每形状含孔洞路径、覆盖率补丁增加形状数、形状合并过于保守、无全局路径优化 +- **架构脆弱性**:剪切模型(cutout)要求所有形状精确密铺(watertight),对上游分割精度要求极高 + +## 2. 调研中发现的关键技术方向 + +本调研覆盖了 11 篇核心文献和 5 个算法专题,关键发现如下: + +### 2.1 层叠模型 vs 剪切模型 + +| 维度 | 剪切模型(V1) | 层叠模型(V2 推荐) | +|------|----------------|---------------------| +| 形状关系 | 精确密铺,共享边界 | 自下而上叠放,允许重叠 | +| 孔洞 | 每形状需显式孔洞 | 上层自然遮盖,无需孔洞 | +| 缝隙处理 | 覆盖率检测 + 补丁 | 形状延伸,根本消除 | +| 错误隔离 | 差——一个边界错误影响两侧 | 好——各层独立追踪 | +| 路径紧凑度 | 差(孔洞增加路径复杂度) | 好(无孔洞,路径更简单) | + +**结论**:层叠模型在路径数量、文件尺寸、鲁棒性三个维度全面优于剪切模型。VTracer、color_trace、Image Vectorization with Depth 等工具/论文均验证了这一方向。 + +### 2.2 深度排序 + +推荐采用 **混合方案**: +1. 图像边界启发式确定背景层 +2. 相邻形状对使用覆盖面积能量 D(i,j) = A(i,j) - A(j,i) 判断遮挡关系 +3. 有向图 + 环破除(对称差 V(i,j))+ 拓扑排序获得全局深度序 + +该方案源自 Law & Kang (2024),经简化后适合工程实现。 + +### 2.3 形状延伸 + +推荐采用 **形态学膨胀**(首选方案): +- 对每个形状 mask,在被上层遮挡的区域内做 3x3 圆盘核膨胀(2-4 次迭代) +- 实现简单(OpenCV `cv::dilate`)、效果可靠 +- 备选方案:凸包延伸(大形状)、Euler's elastica(研究级,当前不考虑) + +### 2.4 感知色彩量化 + +推荐采用 **OKLab + Modified Median Cut (MMCQ)**: +- OKLab 比 CIELAB 感知一致性更好,转换更简单(矩阵乘 + 立方根) +- MMCQ 按加权方差分裂色彩盒,非 power-of-2 颜色数,质量优于经典 Median Cut +- 替换 V1 的 SLIC + KMeans 组合,解耦空间分割与颜色量化 + +### 2.5 路径优化 + +推荐采用 **两遍优化**: +1. **近线性段合并**:控制点距离弦不超过 ε 的三次段降级为直线 +2. **相邻段重拟合**:连续两段尝试合并为一段,最小二乘重拟合 + +结合 **z-order 安全的同色形状合并**(同深度层同色形状合并为单 ``)。 + +## 3. 推荐方案总结 + +### V2 管线流程 + +``` +输入图像 + → 预处理(缩放 + Mean Shift 平滑) [复用 V1] + → OKLab MMCQ 颜色量化 → labels + palette [新模块] + → 小区域合并清理 [复用 V1 MergeSmallComponents] + → 连通域分析 → 形状层提取 [新模块] + → 覆盖面积能量 → 深度排序 [新模块] + → 形状延伸(膨胀到遮挡区域) [新模块] + → 逐层 Potrace 追踪 [复用 V1 TraceMaskWithPotraceBezier] + → 路径优化(近线性合并 + 相邻段重拟合) [新模块] + → 同色形状合并(z-order 安全) [新模块] + → SVG 输出(按深度排序,画家算法) [复用 V1 WriteSvg] +``` + +### 预期收益 + +| 指标 | V1 | V2 预期 | 改善 | +|------|-----|---------|------| +| 形状数 | 基准 | 减少 30-50%(无孔洞 + 合并) | 显著 | +| 文件尺寸 | 基准 | 减少 20-40% | 显著 | +| 路径紧凑度 | 基准 | 改善(优化后更少控制点) | 中等 | +| 缝隙问题 | 需 CoverageGuard 修补 | 根本消除 | 显著 | +| 错误鲁棒性 | 差(串行传播) | 好(层间独立) | 显著 | + +## 4. 核心风险 + +| 风险 | 优先级 | 缓解 | +|------|--------|------| +| 深度排序不正确 | P1 | 边界启发式 + delta 阈值 + 环破除 | +| 深度排序性能 | P2 | 仅计算相邻对 | +| 形状延伸异常 | P2 | 限制膨胀量 + 后过滤 | +| 文件尺寸未达预期 | P2 | 激进合并 + 精度控制 | + +## 5. 推荐实施路线 + +**三个里程碑,预计总工时 6-9 周**: + +1. **M1(2-3 周)**:基础设施 + 层叠管线骨架 + - `PipelineMode` 枚举、dispatch、pipeline_v2、深度排序、形状延伸、逐层 Potrace + - 量化暂用 placeholder +2. **M2(2-3 周)**:OKLab MMCQ 颜色量化 +3. **M3(2-3 周)**:路径优化 + 同色形状合并 + +V1/V2 并行共存,通过 `config.pipeline_mode` 切换,V1 代码零修改。 + +## 6. 参考文献 + +详见 [02-literature/README.md](02-literature/README.md)。主要依据: + +- Law & Kang, "Image Vectorization with Depth" (2024) — 深度排序与形状凸化理论基础 +- VTracer (Vision Cortex) — 层叠模型的实践验证 +- "Forty Years of Color Quantization" (2023) — 量化算法综述 +- Raph Levien, "Simplifying Bézier paths" (2023) — 路径优化技术参考 +- Potrace (Peter Selinger) — 已验证的位图追踪引擎 diff --git a/docs/research/01-current-architecture-analysis.md b/docs/research/01-current-architecture-analysis.md new file mode 100644 index 0000000..b8514e2 --- /dev/null +++ b/docs/research/01-current-architecture-analysis.md @@ -0,0 +1,263 @@ +# V1 管线架构深度诊断 + +## 1. 管线总览 + +### 1.1 流程图 + +```mermaid +flowchart TD + Input[输入图像 BGR/BGRA] --> PrepInput["PrepareVectorizeInput\n提取 Alpha → opaque_mask\n转换为 BGR"] + PrepInput --> Preprocess["PreprocessForVectorize\n缩放 + Mean Shift 平滑\n→ working, unsmoothed, scale"] + Preprocess --> EdgeMap["ComputeEdgeMap\n(仅多色)Sobel 梯度\n→ edge_map"] + EdgeMap --> Segment["SegmentMultiColor / SegmentBinary\nSLIC 超像素 + K-Means 聚类\n→ labels, centers_lab"] + Segment --> Mask["透明掩码应用\nlabels[透明区] = -1"] + Mask --> Refine["RefineLabelsBoundary\n(多色,可选)\n迭代边界标签细化"] + Refine --> Merge["MergeSmallComponents\n小区域按色差合并"] + Merge --> Morph["MorphologicalCleanup\n(多色)闭运算平滑"] + Morph --> Compact["CompactLabels + ComputePalette\n→ num_labels, palette"] + Compact --> Branch{num_labels > 2\n且多色?} + Branch -->|是| BGraph["BuildBoundaryGraph\n裂缝网格 → 结点+边"] + BGraph --> SubPx["SubpixelRefine / AA\n(可选)法向梯度搜索"] + SubPx --> Assemble["AssembleContoursFromGraph\n逐边平滑 → Schneider 拟合\n→ VectorizedShape 列表"] + Assemble --> FallCheck["Per-label 覆盖率检查\n面积不足 → Potrace 回退"] + Branch -->|否| PerLabel["逐标签 Potrace 追踪\nTraceMaskWithPotraceBezier"] + FallCheck --> ThinLine["薄线检测 + 描边增强\n(可选)"] + PerLabel --> ThinLine + ThinLine --> Sort["排序(填充优先、按面积降序)\n+ MergeAdjacentSameColorShapes"] + Sort --> CovGuard["ApplyCoverageGuard\n(可选)像素覆盖率不足 → Potrace 补丁"] + CovGuard --> Rescale["逆缩放 + Clamp 到原图范围"] + Rescale --> WriteSVG["WriteSvg → VectorizerResult"] +``` + +### 1.2 代码规模 + +| 模块 | 目录 | 行数 | 职责 | +|------|------|------|------| +| 预处理 | `src/preprocess/` | 121 | 缩放、Mean Shift 平滑 | +| 颜色分割 | `src/segment/` | 1,258 | SLIC 超像素、K-Means 聚类、形态学清理、小区域合并 | +| 边界提取 | `src/boundary/` | 915 | 裂缝网格边界图、亚像素细化、AA 检测 | +| 轮廓装配 | `src/contour/` | 865 | 链式轮廓装配、薄线矢量化 | +| 曲线拟合 | `src/curve/` | 703 | 贝塞尔工具、Schneider 拟合 | +| Potrace 追踪 | `src/trace/` | 852 | Potrace 封装、覆盖率修补、拓扑修复 | +| SVG 输出 | `src/output/` | 369 | SVG 生成、同色形状合并 | +| 内部工具 | `src/detail/` | 459 | OpenCV 辅助、ICC 色彩管理 | +| 管线编排 | `src/pipeline.cpp` + `vectorizer.cpp` | 563 | 管线编排、公共 API 入口 | +| 公共头文件 | `include/neroued/vectorizer/` | 641 | 配置、结果、颜色类型、向量类型 | +| **总计** | | **6,782** | | + +**复杂度集中区域**:`segment/`(1,258 行)、`boundary/`(915 行)、`contour/`(865 行)三个模块合计 3,038 行,占总代码量 45%。这三个模块正是 V1 的核心——"分割 → 边界图 → 轮廓装配"管线,也是问题最多的部分。 + +--- + +## 2. 逐阶段分析 + +### 2.1 预处理(`src/preprocess/preprocess.cpp`,121 行) + +**算法**: +- 条件降采样:`max_working_pixels` 控制上限,`INTER_AREA` 缩放 +- 条件升采样:`upscale_short_edge` 控制小图放大,最多 4x +- Mean Shift 颜色平滑:`cv::pyrMeanShiftFiltering(bgr, filtered, sp, sr)` + +**已知问题**: +- Mean Shift 平滑是全局的,会模糊重要的细节边缘 +- 平滑后的图用于 SLIC,未平滑的图用于边缘/细化——两套图数据不一致可能导致分割与细化之间的语义偏差 +- 小图自适应逻辑硬编码阈值(`short_edge <= 128`) + +**评估**:该模块本身实现简洁合理,可在 V2 中完全复用。 + +### 2.2 颜色分割(`src/segment/`,1,258 行) + +#### SLIC 超像素(`slic.cpp`,471 行) + +**算法**: +- 网格初始化种子 → 梯度引导微扰 → 迭代距离更新 +- 距离 = `||color_diff||² + λ·||spatial_diff||²`,`λ = (compactness/step)²` +- 若有 edge_map,`spatial_lambda *= max(0.1, 1 - edge_sensitivity * edge)` +- `EnforceConnectivity`:四邻域直方图投票合并小块(与颜色无关) + +**已知问题**: +- SLIC 产出的超像素块可能跨越真实边界(特别是在弱梯度区域) +- `EnforceConnectivity` 按邻域投票而非颜色相似度合并,可能将异色小块合并到错误区域 +- 超像素数少于目标颜色数时,整个 SLIC 被跳过,退化为全局像素 K-Means——完全失去空间正则 + +#### K-Means 聚类(`color_segment.cpp`,621 行) + +**算法**: +- 正常路径:对 SLIC 超像素中心在 LAB 空间做 K-Means → 每个像素标签 = `km_labels[slic_label[pixel]]` +- 回退路径:超像素不足时对全图像素直接 K-Means + +**已知问题**: +- K-Means 只在颜色空间聚类,完全不考虑空间连续性 +- 色差用 LAB 欧氏距离而非 CIEDE2000,对蓝色系等区域感知不准确 +- `EstimateOptimalColors` 是启发式的(网格抽样 + proxy 图),在色彩连续变化的场景下表现不稳定 + +#### 后处理(`morphology.cpp`,80 行) + +- `MergeSmallComponents`:3 轮迭代,4-连通,按 LAB 距离合并小区域 +- `MorphologicalCleanup`:按面积从大到小对每标签做闭运算 +- `CompactLabels`:重映射标签 + +**已知问题**: +- 闭运算按面积从大到小进行,大区域可能吞并细结构 +- 合并判据 `max_merge_color_dist` 是 LAB ΔE²,阈值不够感知化 + +### 2.3 边界提取(`src/boundary/`,915 行) + +#### 裂缝网格(`boundary_graph.cpp`,384 行) + +**算法**: +- 扫描 labels 图,相邻像素标签不同处在 crack 网格上加边 +- 2×2 角点处多条裂缝汇聚的点为 junction(交汇点) +- 从 junction 出发沿同一标签对走边链 + +**已知问题**: +- **边界完全锁定在像素网格**(axis-aligned 阶梯),这是所有后续平滑和拟合的起点 +- **交汇点(junction)位置固定不可移动**,即使子像素细化也无法调整 +- 拓扑一旦建立就不可更改——如果分割有误,边界图无法补救 + +#### 子像素细化(`subpixel_refine.cpp`,281 行) + +**算法**: +- 对每条边的内部点(首尾 junction 固定),沿法向在未平滑 LAB 上采样 +- 找一维梯度剖面的最大峰,抛物插值细化位置 +- 平移量 clamp 到 `max_displacement` + +**已知问题**: +- **只做一维法向搜索**,在急转折处法向估计不准 +- 端点固定意味着 junction 附近的边界始终不准 +- 平坦区/纹理区可能产生伪峰 + +#### AA 检测(`aa_detector.cpp`,130 行) + +- 检测边界像素是否为两中心的线性混合 → 用 alpha 偏移调整 +- 假设过强:非线性混合、三色交界、渐变区域不适用 + +### 2.4 轮廓装配(`src/contour/assembly.cpp`,628 行) + +**算法**: +1. 逐边平滑:`DecimateNearCollinear` + `SmoothOpenChain` +2. 逐边 Schneider 拟合:`FitBezierToPolyline` → 贝塞尔段序列 +3. 按标签遍历环,拼接贝塞尔链 → 闭合轮廓 +4. 符号面积区分外轮廓 / 孔洞 → `VectorizedShape` + +**已知问题**: +- `ChainEdgeRefsIntoLoops` 在多出边节点取"第一条未用边"——贪心策略在复杂交汇处容易链错 +- 平滑和拟合共享同一份边数据(两侧标签看到相同几何),但如果某一侧的最优曲线与另一侧不一致,没有机制协调 +- 孔洞判定依赖 `original_signed_area` 与包含关系混合,边界数据异常时分类可能出错 +- 存在死代码:`ChainEdgesIntoLoops`、`PointsToBezierContour`、`ReverseBezierChain` 等函数在当前仓库无调用 + +### 2.5 曲线拟合(`src/curve/fitting.cpp`,523 行) + +**算法**:经典 Schneider 递归 +- 弦长参数化 → 固定端点和切向 → 最小二乘解 `p1/p2` +- 若最大偏离超阈值 → Newton 重参数化 → 仍超差则中点分裂递归 +- 深度达 `max_recursion_depth` 退化为线性段 + +**已知问题**: +- 递归深度限制导致高曲率或噪声区域产生大量线性退化段 +- 没有全局后优化(拟合完成后不再尝试减少控制点数) +- `FitBezierOnGraph` 与装配路径内联拟合功能重叠 + +### 2.6 Potrace 追踪(`src/trace/potrace.cpp`,430 行) + +**算法**: +- `TraceMaskWithPotraceBezier`:保留 Potrace 原生三次段 → `BezierContour` +- 嵌套处理:`sign == '+'` + `childlist` 组外+孔结构 + +**使用场景**: +- 多色标签 > 2 时:主路径覆盖不足的标签回退 +- 双色/少标签时:作为主追踪器 +- CoverageGuard 修补时:再次调用 + +**已知问题**: +- 与边界图 + Schneider 拟合**能力重叠**——三重矢量化机制共存 +- Potrace 回退追踪整个标签 mask,不知道边界图已经覆盖了哪些部分 → 可能产生重叠形状 + +### 2.7 覆盖率修补(`src/trace/coverage.cpp`,205 行) + +**算法**: +- 将现有 shapes 栅格化 → coverage 图 +- 若 `covered/source < min_ratio`:找 missing 区域连通块 → 每块用 Potrace 追踪 → 追加形状 +- 补丁与已有 coverage 重叠 > 50% 则丢弃 + +**已知问题**: +- 栅格化近似(展平容差 0.45、整像素 fillPoly)引入误差 +- 补丁标签用连通块内众数 label,边界混合区可能偏色 +- 补丁会**增加形状数量**和文件尺寸 + +### 2.8 SVG 输出(`src/output/svg_writer.cpp`,202 行) + +**算法**: +- 每个 `VectorizedShape` → 一个 `` +- 多轮廓(含孔)用 `fill-rule="evenodd"`,孔反向写 `C` 段 +- 数值精度:2 位小数去尾零 + +**已知问题**: +- 无 `` 分组、无 `` 复用、无结构优化 +- 孔洞反向写法增加路径复杂度 + +### 2.9 形状合并(`src/output/shape_merge.cpp`,83 行) + +**算法**: +- 连续同色(ΔE76 < 3)形状,若新形 bbox 与 run 内已有 bbox **不重叠**,则合并 + +**已知问题**: +- bbox 不重叠条件过于保守:空间上分离但绘制顺序安全的同色形状不会被合并 +- 合并后 `area` 字段只保留第一个,不累加 + +--- + +## 3. 与 Adobe Illustrator Image Trace 的能力差距 + +| 能力维度 | Adobe Illustrator | V1 管线 | 差距评估 | +|----------|-------------------|---------|----------| +| 矢量化模型 | 逐色层独立追踪 + 画家算法叠放 | 全局边界图共享边 + 三重修补 | **根本性差异** | +| 颜色量化 | 感知色彩模型 + 自适应 | SLIC + LAB KMeans | 明显劣势 | +| 缝隙处理 | Trapping 或层叠重叠 | 覆盖率检测 + Potrace 补丁(增加形状) | 明显劣势 | +| 路径紧凑度 | 全局优化,极少控制点 | Schneider 局部拟合,无全局优化 | 中度劣势 | +| 孔洞处理 | 上层形状自然遮盖(无需显式孔洞) | 每形状含孔洞列表(增加路径复杂度) | 明显劣势 | +| 文件尺寸 | 紧凑 | 显著偏大(更多形状、更多控制点、孔洞路径) | 明显劣势 | +| 鲁棒性 | 高(逐层独立,互不影响) | 低(分割错误传播到全链路,需三重修补) | **根本性差异** | +| 渐变支持 | 原生渐变/网格填充 | 无 | 暂不考虑 | + +--- + +## 4. 关键瓶颈定位 + +### 瓶颈 1:剪切模型的内在脆弱性 + +"分割 → 裂缝网格 → 共享边界 → 轮廓装配"链路是 V1 最复杂也最脆弱的部分(3,038 行代码,占 45%)。三重矢量化机制(边界图拟合 → Potrace 回退 → CoverageGuard 补丁)的存在本身就说明主路径不够可靠。 + +**根因**:剪切模型要求所有形状精确密铺(watertight),这对分割精度要求极高,而 SLIC + KMeans 难以达到。 + +### 瓶颈 2:错误不可逆的串行流水线 + +纯前馈链路,无任何反馈机制。Mean Shift 模糊的边缘、SLIC 跨越的边界、KMeans 合并的近似色——这些错误逐级放大,下游无法补救。 + +### 瓶颈 3:文件尺寸膨胀 + +- 孔洞路径增加 `` 复杂度 +- 覆盖率修补增加形状数量 +- 形状合并过于保守 +- 无全局路径优化 +- 三重矢量化机制可能产生冗余重叠形状 + +--- + +## 5. V2 可复用模块评估 + +| 模块 | 可复用性 | 说明 | +|------|----------|------| +| `src/preprocess/` | **完全复用** | 缩放和平滑逻辑通用 | +| `src/trace/potrace.cpp` | **完全复用** | `TraceMaskWithPotraceBezier` 接口清晰,V2 的主追踪器 | +| `src/curve/bezier.cpp` | **完全复用** | 贝塞尔工具函数通用 | +| `src/output/svg_writer.cpp` | **大部分复用** | 核心 SVG 生成逻辑可用,可能需微调以适应无孔洞模型 | +| `src/segment/morphology.cpp` | **部分复用** | `MergeSmallComponents` 可用于量化后清理 | +| `src/detail/` | **完全复用** | OpenCV 辅助和 ICC 工具通用 | +| `eval/` | **完全复用** | 评估框架独立于管线实现 | +| `src/segment/slic.cpp` | 不复用 | V2 不使用 SLIC | +| `src/segment/color_segment.cpp` | 不复用 | V2 使用新的量化模块 | +| `src/boundary/` | 不复用 | 层叠模型不需要裂缝网格 | +| `src/contour/assembly.cpp` | 不复用 | 层叠模型不需要轮廓装配 | +| `src/trace/coverage.cpp` | 不复用 | 形状延伸消除了覆盖率修补需求 | +| `src/trace/topology.cpp` | 不复用 | Potrace 输出拓扑通常正确 | diff --git a/docs/research/02-literature/README.md b/docs/research/02-literature/README.md new file mode 100644 index 0000000..8c5f63a --- /dev/null +++ b/docs/research/02-literature/README.md @@ -0,0 +1,33 @@ +# 参考文献索引 + +本目录收录调研过程中发现的重要文献,每篇一个 Markdown 文件。索引随检索进展动态更新。 + +## 文献列表 + +### 核心文献(深度分析) + +| 编号 | 文献 | 文件 | 来源 | 年份 | 关键技术 | +|------|------|------|------|------|----------| +| L01 | Image Vectorization with Depth | [vectorization-with-depth.md](vectorization-with-depth.md) | arXiv | 2024 | 深度排序能量、形状凸化、层叠 SVG | +| L02 | Perception-Driven Semi-Structured Boundary Vectorization | [perception-driven-vectorization.md](perception-driven-vectorization.md) | SIGGRAPH (ACM TOG) | 2018 | 感知角点检测、学习度量、曲线拟合 | +| L03 | VTracer | [vtracer-analysis.md](vtracer-analysis.md) | GitHub (开源) | 持续更新 | 层叠策略、分层聚类、O(n) 路径简化 | +| L04 | LIVE: Layer-wise Image Vectorization | [live-vectorization.md](live-vectorization.md) | CVPR 2022 (Oral) | 2022 | DiffVG 可微分栅格化、分层路径优化 | +| L05 | LIVSS: Layered Image Vectorization via Semantic Simplification | [livss-vectorization.md](livss-vectorization.md) | CVPR 2025 | 2024 | 渐进式语义简化、分层矢量化 | +| L06 | Depixelizing Pixel Art | [depixelizing-pixel-art.md](depixelizing-pixel-art.md) | SIGGRAPH (ACM TOG) | 2011 | 像素连通性、样条拟合、T-junction 处理 | + +### 工具与实现分析 + +| 编号 | 文献 | 文件 | 来源 | 关键技术 | +|------|------|------|------|----------| +| T01 | Potrace & color_trace | [potrace-and-color-trace.md](potrace-and-color-trace.md) | 开源 | 位图追踪、逐层着色、--stack 选项 | +| T02 | Adobe 相关专利 | [adobe-patents.md](adobe-patents.md) | USPTO | 内容感知矢量路径、位图多边形转换 | +| T03 | kurbo Bézier Path Simplification | [kurbo-bezier-simplify.md](kurbo-bezier-simplify.md) | Raph Levien blog / Rust crate | Green 定理面积度量、近最优贝塞尔拟合 | + +### 综述与参考 + +| 编号 | 文献 | 文件 | 来源 | 年份 | 关键技术 | +|------|------|------|------|------|----------| +| S01 | Forty Years of Color Quantization: A Modern Survey | [color-quantization-survey.md](color-quantization-survey.md) | Springer (AI Review) | 2023 | 量化算法综述、色彩空间、质量评估 | +| S02 | Printing Trapping Techniques | [printing-trapping.md](printing-trapping.md) | 行业标准 | — | 色彩重叠、密度方向规则 | + +> 后续检索发现的文献将持续添加到此列表。 diff --git a/docs/research/02-literature/adobe-patents.md b/docs/research/02-literature/adobe-patents.md new file mode 100644 index 0000000..fcc326c --- /dev/null +++ b/docs/research/02-literature/adobe-patents.md @@ -0,0 +1,184 @@ +# Adobe 矢量化相关专利分析 + +## 1. 基本信息 + +### 专利一:US6639593 + +| 项目 | 内容 | +|------|------| +| **标题** | Converting bitmap objects to polygons | +| **专利号** | US6639593 | +| **申请人** | Adobe Systems Incorporated | +| **类型** | 美国发明专利 | +| **状态** | 已授权 | + +### 专利二:AU2019201961B2 + +| 项目 | 内容 | +|------|------| +| **标题** | System for content-aware vector path creation from raster image data | +| **专利号** | AU2019201961B2 | +| **申请人** | Adobe Inc. | +| **类型** | 澳大利亚发明专利 | +| **状态** | 已授权 | + +## 2. 核心贡献摘要 + +### US6639593: 位图到多边形转换 + +本专利描述了一种使用 **8-连通方向码**(8-connected direction codes)追踪位图对象边界并将其转换为分辨率无关的多边形表示的方法。重点在于高效的边界追踪和紧凑的多边形表示。 + +### AU2019201961B2: 内容感知矢量路径创建 + +本专利描述了一种**内容感知**的矢量路径创建系统,通过边缘检测和像素变换识别边界,并根据图像内容特征(纹理、颜色渐变等)自适应地选择矢量化策略。 + +## 3. 算法/技术描述 + +### 3.1 US6639593: 方向码边界追踪 + +#### 8-连通方向码 + +``` + 3 2 1 + \ | / + 4 - * - 0 方向码 0-7,从右开始逆时针编号 + / | \ + 5 6 7 +``` + +#### 追踪算法 + +``` +输入:二值位图 +输出:边界多边形 + +1. 扫描找到边界起始像素 p_0 +2. 从 p_0 开始,按 8-连通方向追踪: + for each step: + a. 以当前方向为起点,顺时针扫描 8 个方向 + b. 找到第一个仍在对象内的相邻像素 + c. 记录方向码 d_i + d. 移动到该像素,继续追踪 +3. 回到 p_0 时停止 +4. 方向码序列 {d_0, d_1, ...} 即为边界描述 + +优化: +- 相同方向的连续步合并为一条边 +- 得到紧凑的多边形顶点序列 +``` + +#### 分辨率无关表示 + +``` +将像素坐标的多边形转换为归一化坐标: + vertex_normalized = vertex_pixel / image_size + +这使得多边形表示与原始分辨率无关,可以在任意分辨率下渲染。 +``` + +### 3.2 AU2019201961B2: 内容感知路径创建 + +#### 系统架构 + +``` +输入栅格图像 + ↓ +[边缘检测模块] + - 使用多种边缘检测算法(Canny、Sobel 等) + - 根据图像内容特征选择合适的检测器 + ↓ +[边界像素识别] + - 基于边缘检测结果进行像素级变换 + - 标记属于边界的像素 + ↓ +[内容感知决策] + - 分析局部图像特征(纹理复杂度、颜色渐变方向等) + - 决定路径的拟合策略: + ├── 平坦区域 → 直线段 + ├── 平滑曲线区域 → 贝塞尔曲线 + └── 复杂纹理区域 → 更多控制点 + ↓ +[路径生成] + - 根据决策生成矢量路径 + ↓ +输出矢量图 +``` + +#### 内容感知特征 + +| 特征 | 用途 | +|------|------| +| 局部梯度方向 | 判断边界走向 | +| 颜色对比度 | 判断边界显著性 | +| 纹理复杂度 | 决定采样密度 | +| 区域连通性 | 避免断开的路径 | + +## 4. 复杂度分析 + +### US6639593 + +| 方面 | 评估 | +|------|------| +| 边界追踪 | O(m),m 为边界像素数 | +| 多边形简化 | O(m),线性合并同向步 | +| 总计 | O(n + m),n 为图像像素数(扫描) | + +### AU2019201961B2 + +| 方面 | 评估 | +|------|------| +| 边缘检测 | O(n),标准卷积操作 | +| 特征分析 | O(n),逐像素或分块分析 | +| 路径生成 | O(m),m 为边界像素数 | + +## 5. 优缺点分析 + +### US6639593 优点 + +- **简单高效**:方向码追踪是最基本的边界追踪方法,实现简单 +- **确定性**:相同输入保证相同输出 +- **分辨率无关**:归一化坐标支持任意缩放 +- **内存友好**:方向码序列比全像素坐标更紧凑 + +### US6639593 缺点 + +- **像素级精度**:无亚像素精度 +- **锯齿明显**:直接输出的多边形有明显锯齿 +- **无曲线拟合**:只产生多边形,需要后续平滑 +- **仅适用于二值图像** + +### AU2019201961B2 优点 + +- **自适应**:根据内容特征调整矢量化策略 +- **多特征融合**:综合考虑多种图像特征 +- **灵活性高**:可以为不同区域使用不同策略 + +### AU2019201961B2 缺点 + +- **系统复杂**:多个特征分析模块增加实现复杂度 +- **专利限制**:商业使用受限 +- **细节不公开**:专利描述不如学术论文详细 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| 🔧 **概念可借鉴** | 方向码追踪(US6639593)——比我们当前的 crack grid 更简单,可作为轻量级替代方案的参考 | +| 🔧 **概念可借鉴** | 内容感知路径创建(AU2019201961B2)——根据区域特征调整拟合策略是一个有价值的思路 | +| ⚠️ **需注意** | 专利规避——确保我们的实现不侵犯这些专利的权利要求 | +| ❌ **不适用** | 直接使用方向码多边形(精度不足,我们已有更精细的边界提取) | + +**具体启发**: + +1. **方向码的简洁性**:方向码追踪是 O(m) 的简单算法,虽然精度有限但在某些快速预览场景可能有用 +2. **内容感知的拟合策略**:在我们的曲线拟合阶段,可以根据边界区域的特征(是否平坦、是否有渐变)调整拟合参数,如平坦区域用更少的控制点、复杂区域用更多控制点 +3. **归一化坐标**:输出 SVG 时使用 viewBox 归一化坐标,与图像实际分辨率解耦 + +## 7. 引用链线索 + +| 参考 | 相关性 | +|------|--------| +| Freeman 1961 (chain codes) | 方向码追踪的原始论文 | +| Suzuki & Abe 1985 (contour tracing) | OpenCV `findContours` 的基础,与方向码追踪相关 | +| Adobe Image Trace (产品) | AU2019201961B2 专利的实际产品实现 | +| Canny 1986 | 边缘检测经典方法 | diff --git a/docs/research/02-literature/color-quantization-survey.md b/docs/research/02-literature/color-quantization-survey.md new file mode 100644 index 0000000..c2f0274 --- /dev/null +++ b/docs/research/02-literature/color-quantization-survey.md @@ -0,0 +1,175 @@ +# 颜色量化综述 + +## 1. 基本信息 + +### 主要综述 + +| 项目 | 内容 | +|------|------| +| **标题** | Forty Years of Color Quantization: A Modern, Algorithmic Survey | +| **来源** | Springer AI Review, 2023 | +| **类型** | 综述论文 | + +### 补充文献 + +| 项目 | 内容 | +|------|------| +| **标题** | A comparative study of color quantization methods | +| **来源** | Multimedia Systems, 2023 | +| **类型** | 对比实验研究 | + +## 2. 核心贡献摘要 + +这两篇文献系统性地梳理了过去 40 年(1982–2023)颜色量化领域的算法演进,涵盖了从经典方法到现代方法的全面比较。主要发现: + +1. **感知均匀色彩空间显著优于 RGB**:在 CIELAB/CIELUV 空间中进行量化能获得更好的视觉质量 +2. **传统质量指标不充分**:MSE/PSNR 与人类视觉感知的相关性有限 +3. **HVS 感知指标更可靠**:考虑人类视觉系统(Human Visual System)特性的指标更能反映实际质量 +4. **Jancey k-means 可胜过 Lloyd k-means**:初始化和更新策略对聚类质量影响大 + +## 3. 算法/技术描述 + +### 3.1 主要算法分类 + +#### 分割型方法(Splitting Methods) + +| 算法 | 年份 | 原理 | 复杂度 | +|------|------|------|--------| +| **Median Cut** | 1982 | 沿最长轴在中位数处切分色彩盒 | O(n log k) | +| **Octree** | 1988 | 在 RGB 八叉树中自底向上合并 | O(n) | +| **Wu's** | 1991 | 方差最小化切分 | O(n + k × 2^(3b)) | + +#### 聚类型方法(Clustering Methods) + +| 算法 | 年份 | 原理 | 复杂度 | +|------|------|------|--------| +| **Lloyd K-Means** | 1982 | 迭代重分配 + 更新中心 | O(n × k × t) | +| **Jancey K-Means** | 1966/改进 | 中心更新使用反射策略 | O(n × k × t) | +| **K-Means++** | 2007 | 概率初始化 + Lloyd 迭代 | O(n × k × t) | +| **Fuzzy C-Means** | 1984 | 软聚类,每点隶属多个簇 | O(n × k² × t) | + +#### 神经网络方法 + +| 算法 | 年份 | 原理 | 复杂度 | +|------|------|------|--------| +| **NeuQuant** | 1994 | Kohonen SOM 学习调色板 | O(n × k) | +| **SOM** | 1982 | 自组织映射 | O(n × k × t) | + +### 3.2 色彩空间比较 + +``` +色彩空间选择对量化质量的影响(按效果排序): + +1. CIELAB / OKLab —— 最佳:感知均匀,欧氏距离 ≈ 感知差异 +2. CIELUV —— 良好:另一种感知均匀空间 +3. HSV / HSL —— 一般:色相均匀但亮度不均匀 +4. RGB —— 最差:非感知均匀,欧氏距离 ≠ 感知差异 +``` + +综述实验表明,在 CIELAB 空间中使用 k-means 的量化质量比 RGB 空间提升约 **15–25%**(PSNR 指标)。 + +### 3.3 质量度量 + +#### 传统像素级指标 + +| 指标 | 公式 | 问题 | +|------|------|------| +| MSE | Σ(p - q)² / n | 不考虑空间频率和掩蔽效应 | +| PSNR | 10 log₁₀(MAX² / MSE) | MSE 的变体,同样问题 | +| SSIM | 结构相似性 | 稍好,但对颜色不敏感 | + +#### HVS 感知指标 + +| 指标 | 特点 | 优势 | +|------|------|------| +| CID | 色彩图像差异 | 考虑色彩空间的非均匀性 | +| S-CIELAB | 空间 CIELAB | 在 CIELAB 前加入空间滤波 | +| CIEDE2000 | 色差公式 | 修正 CIELAB 在蓝色区域的偏差 | +| FLIP | 显示感知差异 | 考虑显示设备和观看距离 | + +综述结论:**使用 HVS 指标的排名与仅使用 MSE/PSNR 的排名有显著差异**。 + +### 3.4 Jancey K-Means vs Lloyd K-Means + +``` +Lloyd K-Means 中心更新: + c_new = (1/|S_i|) × Σ_{x ∈ S_i} x (简单均值) + +Jancey K-Means 中心更新: + c_new = 2 × mean(S_i) - c_old (反射策略) + 即:新中心 = 簇均值关于旧中心的对称点 + +效果: + - Jancey 更新加速收敛 + - 在某些数据集上质量优于 Lloyd + - 需要适当的衰减因子避免振荡 +``` + +### 3.5 关键发现总结 + +| 发现 | 意义 | +|------|------| +| Lab 空间 > RGB 空间 | 感知均匀空间的量化更接近人类感知 | +| K-Means++ 初始化重要 | 好的初始化显著减少迭代次数 | +| MSE/PSNR 不够 | 需要结合 HVS 指标评估 | +| 颜色数 < 32 时差异大 | 少颜色时算法选择更关键 | +| NeuQuant 质量好但慢 | 在质量敏感场景值得使用 | + +## 4. 复杂度分析 + +各算法在 n 像素、k 目标颜色数、t 迭代次数下的比较: + +| 算法 | 时间复杂度 | 空间复杂度 | 实际速度排名 | +|------|-----------|-----------|------------| +| Octree | O(n) | O(2^(3b)) | 1(最快) | +| Median Cut | O(n log k) | O(n) | 2 | +| Wu's | O(n + k × 2^(3b)) | O(2^(3b)) | 3 | +| K-Means (Lloyd) | O(n × k × t) | O(k) | 4 | +| NeuQuant | O(n × k) | O(k) | 5 | +| Fuzzy C-Means | O(n × k² × t) | O(n × k) | 6(最慢) | + +## 5. 优缺点分析 + +### 分割型方法 + +- **优点**:速度快、实现简单、无需迭代 +- **缺点**:质量上限较低、对色彩分布不均匀的图像效果差 + +### 聚类型方法 + +- **优点**:质量高、理论性强、可优化 +- **缺点**:速度依赖初始化和迭代次数、k 需要预设 + +### 神经网络方法 + +- **优点**:质量最高(尤其在少颜色数时) +- **缺点**:速度慢、参数敏感 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **已采用** | 在感知均匀空间中聚类——我们使用 SLIC(在 Lab 空间)+ K-Means,与综述推荐一致 | +| ✅ **可直接采用** | HVS 感知指标——在 eval 模块中增加 CIEDE2000 或 S-CIELAB 指标 | +| 🔧 **需要改造** | OKLab 迁移——综述确认 Lab 空间的优势,我们计划从 CIELAB 迁移到 OKLab(更快、更均匀) | +| 🔧 **需要改造** | K-Means++ 初始化——如果当前初始化不是 K-Means++,可以升级 | +| 🔧 **需要考虑** | Jancey K-Means——可以作为 Lloyd K-Means 的替代方案测试 | + +**具体启发**: + +1. **确认方向正确**:综述验证了我们在感知均匀空间中做颜色分割的选择是正确的 +2. **评估指标升级**:当前 eval 模块使用 MSE/PSNR,应增加 HVS 感知指标 +3. **少颜色场景优化**:当用户指定少量颜色时(< 32),算法选择对结果影响大,可能需要更精细的量化方法 +4. **K-Means 优化**:考虑 K-Means++ 初始化 + Jancey 更新策略的组合 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| Heckbert 1982 (Median Cut) | 经典基线方法 | +| Wu 1991 (variance-based) | 高效的分割方法 | +| Dekker 1994 (NeuQuant) | 神经网络量化 | +| Arthur & Vassilvitskii 2007 (K-Means++) | 改进的初始化方法 | +| Björnsson 2020 (OKLab) | 我们计划迁移到的色彩空间 | +| CIEDE2000 标准 | CIE 推荐的色差公式 | +| FLIP (Andersson et al. 2020) | NVIDIA 的显示感知差异指标 | diff --git a/docs/research/02-literature/depixelizing-pixel-art.md b/docs/research/02-literature/depixelizing-pixel-art.md new file mode 100644 index 0000000..80ea3f1 --- /dev/null +++ b/docs/research/02-literature/depixelizing-pixel-art.md @@ -0,0 +1,169 @@ +# Depixelizing Pixel Art + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | Depixelizing Pixel Art | +| **作者** | Johannes Kopf, Dani Lischinski | +| **单位** | Microsoft Research / Hebrew University of Jerusalem | +| **年份** | 2011 | +| **发表** | ACM SIGGRAPH 2011, ACM Transactions on Graphics (TOG) | +| **链接** | | + +## 2. 核心贡献摘要 + +本文提出了一种将像素画(pixel art)转换为平滑矢量图形的方法。与通用矢量化不同,像素画的特殊性在于每个像素都携带有意义的信息,分辨率极低(通常 16×16 到 256×256),且艺术家有意识地利用像素级特征表达形状。 + +核心创新: + +1. **像素单元重塑**:将方形像素网格重塑为适应特征连通性的不规则形状 +2. **T-junction 和 X-junction 处理**:精心处理像素交汇点的拓扑关系 +3. **相似性图上的启发式消歧**:解决对角像素连通性的二义性 +4. **B-样条优化**:在重塑后的轮廓上拟合光滑曲线 + +## 3. 算法/技术描述 + +### 3.1 总体管线 + +``` +像素画输入 + ↓ +[构建相似性图] + - 在相邻像素间建立连接 + - 对角像素使用颜色相似性判断是否连通 + ↓ +[消除交叉边] + - 解决对角连接的冲突(两对对角像素不能同时连通) + - 使用启发式规则(曲线连续性、稀疏度) + ↓ +[Voronoi 细分] + - 将每个像素的方形单元重塑为多边形 + - 适应连通性关系 + ↓ +[提取轮廓] + - 沿不同颜色区域的边界提取轮廓 + - 处理 T-junction 和 X-junction + ↓ +[B-样条拟合] + - 对轮廓进行 B-样条曲线拟合 + - 优化控制点位置 + ↓ +输出 SVG +``` + +### 3.2 相似性图与消歧 + +在像素网格中,每个像素与其 8 邻域像素的关系: + +``` +4-邻域(上下左右):总是连通 +对角邻域:仅当颜色相似时连通 + +问题:两对对角像素可能产生交叉 + A . B + . X . A-D 和 B-C 不能同时连通 + C . D + +消歧规则(按优先级): + 1. 曲线连续性:选择使轮廓更光滑的连接 + 2. 稀疏连接性:选择连接数更少的一方 + 3. 岛屿规则:保持小区域的连通性 +``` + +### 3.3 T-junction 和 X-junction 处理 + +这是本文的关键贡献之一: + +**T-junction(三色交汇)**: + +``` + A A B + A A B → A、B、C 三个区域在一点交汇 + C C C 轮廓线形成 T 形 + +处理:将 T-junction 点精确定位,确保三条轮廓线在此汇合 +``` + +**X-junction(四色交汇)**: + +``` + A B + C D → A、B、C、D 四个区域在一点交汇 + +处理: + - 判断哪两对颜色应该共享边界 + - 通常将 X-junction 分解为两个相邻的 T-junction +``` + +### 3.4 Voronoi 重塑 + +``` +对每个像素中心点 p_i: + 1. 根据连通性关系调整 p_i 的 Voronoi 单元形状 + 2. 连通的像素:边界向外扩展(共享更长的边界线) + 3. 不连通的像素:边界向内收缩(共享边界退化为一点或消失) +``` + +### 3.5 B-样条拟合与优化 + +``` +对每条轮廓: + 1. 在 junction 点处放置固定控制点 + 2. 在轮廓中间均匀采样额外控制点 + 3. 用最小二乘法优化控制点位置 + 4. 约束条件:junction 点不移动,切线连续性 +``` + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **相似性图构建** | O(n),n 为像素数 | +| **消歧** | O(n),每个交叉点常数时间 | +| **Voronoi 重塑** | O(n) | +| **轮廓提取** | O(n) | +| **B-样条优化** | O(m³),m 为控制点数(求解线性系统) | +| **适用规模** | 小图像(像素画通常 < 256×256),大图不实用 | + +## 5. 优缺点分析 + +### 优点 + +- **像素画专用**:深刻理解像素画的特征和约定 +- **T/X-junction 处理精确**:产生拓扑正确的矢量图 +- **视觉效果优秀**:在像素画场景中效果远超通用方法 +- **保真度高**:保留了艺术家意图的每个像素级细节 + +### 缺点 + +- **仅适用于像素画**:不适合照片或高分辨率图像 +- **计算不可扩展**:Voronoi 重塑对大图无意义 +- **启发式消歧**:规则可能在特殊情况下失败 +- **无颜色量化**:假设输入已经是量化的(像素画本身颜色数少) + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| 🔧 **概念可借鉴** | T-junction / X-junction 分析——理解共享边界处的拓扑关系,对我们的边界图构建和拓扑修复有参考价值 | +| 🔧 **概念可借鉴** | 连通性消歧——在我们的 crack grid 边界提取中,对角像素的连通性判断面临类似问题 | +| 🔧 **概念可借鉴** | 深度排序的连通性分析——判断哪些区域在前、哪些在后 | +| ❌ **不适用** | Voronoi 重塑——像素画专用技术,高分辨率图像无意义 | +| ❌ **不适用** | 相似性图的启发式规则——设计针对像素画的约定(如单像素宽线条) | + +**具体启发**: + +1. **边界拓扑理解**:T-junction 是三个区域在一点交汇的情况,在我们的边界图中同样存在。理解这些拓扑结构有助于正确处理共享边界 +2. **消歧策略**:在颜色分割边界存在二义性时(两个相邻区域颜色相似),可以参考本文的启发式规则(连续性优先、简洁性优先) +3. **角点处理**:junction 点是天然的角点(轮廓方向突变),我们在 Schneider 拟合时可以利用 junction 信息标记强制分割点 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| Selinger 2003 (Potrace) | 通用矢量化对比基线 | +| Rivers et al. 2010 (2.5D cartoon models) | 深度排序和层叠概念 | +| Orzan et al. 2008 (diffusion curves) | 另一种矢量表示——扩散曲线 | +| Lecot & Lévy 2006 | 边界矢量化 | +| Swaminarayan & Jagannathan 2006 | 像素级矢量化的另一种方法 | diff --git a/docs/research/02-literature/kurbo-bezier-simplify.md b/docs/research/02-literature/kurbo-bezier-simplify.md new file mode 100644 index 0000000..b4ec3aa --- /dev/null +++ b/docs/research/02-literature/kurbo-bezier-simplify.md @@ -0,0 +1,187 @@ +# kurbo: 贝塞尔路径简化 + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | Simplifying Bézier paths | +| **作者** | Raph Levien | +| **类型** | 技术博客 + 开源 Rust 库 | +| **年份** | 2023 | +| **博客** | | +| **库** | kurbo (Rust), | +| **所属项目** | Linebender (包含 kurbo, peniko, vello 等) | + +## 2. 核心贡献摘要 + +Raph Levien(Google Fonts 工程师、博士论文研究样条曲线)在 kurbo 库中实现了一种高效的贝塞尔路径简化算法。核心创新: + +1. **Green 定理的解析面积/矩计算**:利用 Green 定理精确计算贝塞尔曲线与目标之间的面积偏差,无需数值积分 +2. **ParamCurveFit trait**:通用的曲线拟合接口,支持任意源曲线类型 +3. **近最优拟合**:在保持高质量的前提下,实现接近最优的贝塞尔拟合 +4. **尖点检测**:处理平行曲线等场景中出现的尖点 +5. **幺半群 + 前缀和**:利用代数结构实现高效的区间查询 + +## 3. 算法/技术描述 + +### 3.1 核心思想:Green 定理 + +传统贝塞尔拟合使用逐点距离误差(如 Schneider 算法): + +``` +传统方法:E = Σ ||point_i - curve(t_i)||² (离散采样) + +Levien 方法:E = ∫ (area between source and fitted curve)² + = 利用 Green 定理解析计算 +``` + +Green 定理将二维区域积分转化为边界上的线积分: + +``` +∬_D (∂Q/∂x - ∂P/∂y) dA = ∮_C (P dx + Q dy) + +应用于面积计算: + Area = ½ ∮ (x dy - y dx) + +对贝塞尔曲线,这个线积分有解析表达式(多项式积分)。 +``` + +### 3.2 ParamCurveFit trait + +```rust +trait ParamCurveFit { + /// 在参数 t 处求值 + fn eval(&self, t: f64) -> Point; + + /// 计算面积矩(利用 Green 定理) + fn area_moment(&self, range: Range) -> AreaMoment; + + /// 检测尖点(曲率无穷大的点) + fn break_cusp(&self, range: Range) -> Option; +} +``` + +这个 trait 使得算法可以拟合任意类型的源曲线(直线段、二次/三次贝塞尔、弧线等)。 + +### 3.3 拟合算法 + +``` +输入:源曲线 C(实现 ParamCurveFit 的任意曲线) +输出:简化后的贝塞尔路径 + +算法: +1. [尖点检测] + 在源曲线上检测尖点位置 + 在尖点处强制分割 + +2. [自适应细分] + for 每段无尖点的子曲线: + a. 尝试用单条三次贝塞尔拟合整段 + b. 计算拟合误差(Green 定理面积偏差) + c. if 误差 < tolerance: + 接受拟合 + else: + 在误差最大点处二分 + 递归拟合左右两半 + +3. [优化控制点] + 对每条拟合出的贝塞尔曲线: + a. 端点固定(与相邻段共享) + b. 优化两个内部控制点 + c. 利用面积矩的解析梯度进行优化 +``` + +### 3.4 幺半群与前缀和 + +面积矩具有幺半群(monoid)结构: + +``` +AreaMoment 支持合并操作: + moment(a..c) = moment(a..b) ⊕ moment(b..c) + +这意味着可以: + 1. 预计算前缀和数组 + 2. 任意子区间的面积矩 = prefix[j] - prefix[i] + 3. 查询复杂度 O(1) + +实际应用: + - 在自适应细分时,快速计算任意子段的拟合误差 + - 无需重复积分,大幅加速 +``` + +### 3.5 尖点检测 + +``` +尖点出现条件: + - 曲率趋近无穷大 + - 典型场景:平行曲线(offset curve)的内侧 + +检测方法: + - 监测曲线的切线方向变化率 + - 当切线方向变化超过 π 时,标记为尖点 + - 在尖点处分割路径(强制角点) +``` + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **单次拟合** | O(1),解析计算面积矩 | +| **前缀和构建** | O(n),n 为采样点数 | +| **区间查询** | O(1),前缀和差分 | +| **自适应细分** | O(k log k),k 为输出段数 | +| **总计** | O(n + k log k),通常 k ≪ n | + +与 Schneider 算法的对比: + +| | Schneider | kurbo | +|---|-----------|-------| +| 误差度量 | 逐点最大距离 | 面积偏差(Green 定理) | +| 参数化 | 需要迭代重参数化 | 无需重参数化 | +| 分割策略 | 在最大误差点分割 | 在最大误差点分割 | +| 优化 | 无(直接拟合) | 控制点局部优化 | +| 精度 | 好 | 近最优 | + +## 5. 优缺点分析 + +### 优点 + +- **解析精确**:Green 定理避免了数值积分的误差和采样密度问题 +- **近最优拟合**:输出质量接近理论最优 +- **高效**:前缀和结构使得区间查询 O(1) +- **通用性强**:ParamCurveFit trait 支持任意源曲线 +- **尖点处理**:自动检测并处理尖点 + +### 缺点 + +- **实现复杂**:Green 定理的解析推导和实现需要较深的数学功底 +- **Rust 生态**:库是 Rust 实现,移植到 C++ 需要工作量 +- **面积误差 vs 距离误差**:面积偏差不完全等同于视觉上的最大距离偏差 +- **文档有限**:部分实现细节需要阅读源码理解 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **可直接采用** | Green 定理面积测量——可以作为我们路径质量评估的补充指标 | +| 🔧 **需要改造** | 拟合算法移植——将 kurbo 的核心拟合逻辑从 Rust 移植到 C++,替换或增强 Schneider | +| 🔧 **需要改造** | 前缀和加速——在我们的曲线拟合中引入前缀和结构,加速误差查询 | +| 🔧 **需要改造** | 尖点检测——用于处理 offset curve(形状延伸时产生的偏移曲线) | +| ❌ **不适用** | ParamCurveFit trait(Rust 特定抽象,C++ 中用模板或虚函数替代) | + +**具体启发**: + +1. **`merge_segment_tolerance` 的替代方案**:当前我们使用固定容差合并相邻线段,可以改用 Green 定理面积偏差作为更精确的合并判据 +2. **路径优化后处理**:在 Schneider 拟合后增加一步 Green 定理优化,微调控制点以最小化面积偏差 +3. **形状延伸的尖点处理**:当对形状边界做偏移(延伸到被遮挡区域)时,偏移曲线可能产生尖点,kurbo 的检测方法可直接使用 +4. **性能参考**:前缀和结构将误差查询从 O(n) 降到 O(1),对于大量控制点的路径有显著加速 + +## 7. 引用链线索 + +| 参考 | 相关性 | +|------|--------| +| Schneider 1990 (curve fitting) | 经典拟合方法,kurbo 的改进对象 | +| Levien 2009 (博士论文: spline curves) | Raph Levien 的理论基础 | +| Green's theorem (数学) | 核心数学工具 | +| vello (Linebender 项目) | kurbo 的上层用户——GPU 矢量渲染器 | +| peniko (Linebender 项目) | kurbo 的兄弟库——颜色和画笔抽象 | diff --git a/docs/research/02-literature/live-vectorization.md b/docs/research/02-literature/live-vectorization.md new file mode 100644 index 0000000..5fd30f3 --- /dev/null +++ b/docs/research/02-literature/live-vectorization.md @@ -0,0 +1,131 @@ +# LIVE: Towards Layer-wise Image Vectorization + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | LIVE: Towards Layer-wise Image Vectorization | +| **作者** | Xu Ma, Yuqian Zhou, Xingqian Xu, Bin Sun, Valerii Filev, Nikita Orlov, Yun Fu, Humphrey Shi | +| **年份** | 2022 | +| **发表** | CVPR 2022 (Oral Presentation) | +| **链接** | | +| **论文** | | + +## 2. 核心贡献摘要 + +LIVE 提出了一种**逐层渐进式**的图像矢量化方法,核心思想是将矢量化分解为多层贝塞尔路径的逐步添加与优化。与直接优化大量路径不同,LIVE 每次只添加少量路径并优化,大幅降低了优化难度。 + +核心创新: + +1. **逐层添加**:渐进式地向画布添加可优化的闭合贝塞尔路径 +2. **组件级初始化**:基于当前残差图像的连通域分析初始化新路径 +3. **DiffVG 可微分渲染**:利用可微分光栅化器计算梯度,反向传播优化路径参数 +4. **大幅减少路径数量**:仅需约 5 条路径即可达到 DiffVG 直接优化 256 条路径的效果 + +## 3. 算法/技术描述 + +### 3.1 总体框架 + +``` +初始化空画布 +for layer = 1, 2, ..., L: + 1. 计算残差 R = |target - current_render| + 2. 在 R 中检测最显著的连通域 + 3. 初始化一条新的闭合贝塞尔路径(基于连通域的形状) + 4. 将新路径加入路径集合 + 5. 联合优化所有路径参数(控制点 + 颜色 + 透明度) +输出最终 SVG +``` + +### 3.2 组件级路径初始化 + +对残差图像进行处理: + +``` +1. 将残差图 R 二值化(阈值化) +2. 提取连通域 +3. 选择面积最大的连通域 +4. 用该连通域的轮廓初始化一条闭合贝塞尔路径 + - 控制点沿轮廓均匀采样 + - 初始颜色取连通域内像素均值 +``` + +### 3.3 可微分优化 + +优化目标: + +``` +L = L_pixel + λ_xing * L_xing + +L_pixel = ||DiffVG(paths) - target||² (像素级均方误差) +L_xing = Σ xing_loss(path_i) (路径自交叉惩罚) +``` + +优化变量(每条路径): + +- 贝塞尔控制点坐标 +- 填充颜色 (RGB) +- 透明度 (alpha) + +使用 Adam 优化器,通过 DiffVG 的可微分光栅化计算梯度。 + +### 3.4 ECCV 2024 扩展 + +后续工作增加了**径向渐变**支持: + +- 每条路径不再是纯色填充,而是可以带有径向渐变 +- 进一步减少所需路径数量 +- 增强了表达能力 + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **空间** | O(L × P),L 为层数,P 为每条路径的控制点数 | +| **时间** | O(L × T × N),T 为每层优化迭代次数,N 为图像像素数(DiffVG 渲染) | +| **典型耗时** | 每层约 500 次迭代,总共约数分钟(GPU) | +| **硬件要求** | 需要 GPU(CUDA)+ PyTorch + DiffVG | + +## 5. 优缺点分析 + +### 优点 + +- **路径效率极高**:5 条路径即可达到良好重建质量,输出 SVG 极其简洁 +- **渐进式可控**:可以按需控制层数/路径数,权衡质量与复杂度 +- **理论优雅**:将矢量化转化为可微分优化问题 +- **质量上限高**:在艺术风格化矢量化任务中效果显著 + +### 缺点 + +- **速度慢**:依赖 DiffVG 逐像素可微渲染,每张图需数分钟 +- **需要 GPU**:不适合 CPU-only 环境 +- **依赖 DiffVG**:DiffVG 本身有编译和兼容性问题 +- **非实时**:不适合交互式/批量处理场景 +- **路径为闭合曲线**:不处理开放路径(如线条画) +- **无拓扑保证**:路径之间可能重叠或有缝隙 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **可直接采用** | 逐层渐进式思想——验证了我们堆叠模型(从底层到顶层逐步添加形状)的合理性 | +| 🔧 **需要改造** | 组件级初始化思路——我们可以在深度排序时参考"残差最大区域优先"的策略 | +| ❌ **不适用** | DiffVG 可微分优化——需要 GPU + PyTorch,与我们的 C++ CPU 管线不兼容 | +| ❌ **不适用** | 优化式曲线拟合——我们需要确定性的、可预测的拟合结果 | + +**具体启发**: + +1. **堆叠模型验证**:LIVE 从学术角度验证了"逐层堆叠构建"的有效性,与我们的 V2 堆叠方案一致 +2. **路径数量效率**:LIVE 证明少量路径即可高质量重建,这提示我们在合并同色形状时可以更激进 +3. **残差驱动的优先级**:优先处理视觉影响最大的区域,可以作为我们深度排序的辅助依据 +4. **质量上限参考**:LIVE 的输出可作为矢量化质量的理论上限参考 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| DiffVG (Li et al. 2020) | LIVE 的基础——可微分矢量图形渲染器 | +| CLIPDraw (Frans et al. 2022) | 基于 CLIP 引导的矢量绘图,类似的优化范式 | +| Im2Vec (Reddy et al. 2021) | 基于神经网络的图像到矢量转换 | +| LIVE++ (ECCV 2024) | 本文的扩展版本,增加渐变支持 | +| Potrace (Selinger 2003) | 传统方法的对比基线 | diff --git a/docs/research/02-literature/livss-vectorization.md b/docs/research/02-literature/livss-vectorization.md new file mode 100644 index 0000000..6be37c8 --- /dev/null +++ b/docs/research/02-literature/livss-vectorization.md @@ -0,0 +1,133 @@ +# LIVSS: Layered Image Vectorization via Semantic Simplification + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | LIVSS: Layered Image Vectorization via Semantic Simplification | +| **作者** | Wang et al. | +| **年份** | 2025 | +| **发表** | CVPR 2025 | +| **链接** | | +| **论文** | | + +## 2. 核心贡献摘要 + +LIVSS 提出了一种基于**语义简化**的分层图像矢量化方法。核心思想是利用生成式 AI(Score Distillation Sampling)渐进地简化图像,从复杂到简单逐步提取语义层次,然后对每一层进行矢量化。 + +核心创新: + +1. **渐进式图像简化**:利用 SDS 将图像逐步简化为更抽象的版本 +2. **语义分割驱动分层**:每一层的简化由语义分割引导,确保层与语义对齐 +3. **两阶段矢量化**:先进行结构性构建(粗糙轮廓),再进行视觉细化(细节调整) +4. **由粗到细的细节层次**:从简单几何形状逐步添加复杂细节 + +## 3. 算法/技术描述 + +### 3.1 总体管线 + +``` +输入图像 I + ↓ +[渐进式语义简化] + I → I_1 → I_2 → ... → I_K (从复杂到简单) + ↓ +[差分提取] + D_k = I_k - I_{k+1} (每层的新增细节) + ↓ +[两阶段矢量化] + Stage 1: 结构构建 (粗糙轮廓) + Stage 2: 视觉细化 (拟合优化) + ↓ +输出分层 SVG +``` + +### 3.2 渐进式语义简化 + +利用预训练的扩散模型进行 Score Distillation Sampling (SDS): + +``` +给定图像 I 和简化程度 t: + 1. 使用语义分割模型提取语义区域 + 2. 通过 SDS 引导,将 I 简化为 I_t: + - 保留主要语义结构 + - 移除细节纹理和次要元素 + 3. 控制 t 从小到大,获得一系列逐渐简化的图像 +``` + +### 3.3 语义对齐的分层 + +每一层代表一个语义细节级别: + +| 层级 | 内容 | 示例 | +|------|------|------| +| 底层 (L1) | 整体背景和主要色块 | 天空、地面 | +| 中层 (L2–L3) | 主要对象轮廓 | 建筑、树木 | +| 顶层 (L4+) | 细节元素 | 窗户、纹理 | + +### 3.4 两阶段矢量化 + +**阶段一:结构构建** + +- 对每层的差分图像提取轮廓 +- 使用简单几何形状(矩形、椭圆)近似轮廓 +- 确定形状的基本位置和大小 + +**阶段二:视觉细化** + +- 使用 DiffVG 可微分渲染优化形状参数 +- 调整贝塞尔控制点以精确匹配目标 +- 优化颜色和透明度 + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **SDS 简化** | 每层需要数百次扩散模型前向传播,非常耗时 | +| **语义分割** | 取决于所用模型(如 SAM),通常 O(N) | +| **矢量化** | 每层使用 DiffVG 优化,与 LIVE 类似 | +| **总耗时** | 每张图像数十分钟至数小时(GPU) | +| **硬件要求** | 需要高端 GPU(扩散模型 + DiffVG) | + +## 5. 优缺点分析 + +### 优点 + +- **语义对齐**:生成的层具有明确的语义含义,便于后续编辑 +- **由粗到细**:自然的细节层次结构 +- **高质量输出**:结合了生成模型的理解能力和可微分渲染的精度 +- **可编辑性好**:每层对应一个语义概念,用户可以独立编辑 + +### 缺点 + +- **极度依赖大模型**:需要预训练的扩散模型和语义分割模型 +- **速度极慢**:SDS 过程本身就很耗时 +- **不可控因素多**:扩散模型的随机性可能导致简化结果不稳定 +- **资源消耗巨大**:高端 GPU + 大量显存 +- **难以工程化**:依赖链复杂(扩散模型 + 分割模型 + DiffVG) + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| 🔧 **概念可借鉴** | 由粗到细的分层思想——虽然我们不使用 SDS,但"先绘制大区域、后添加细节"的策略与我们的堆叠方案一致 | +| 🔧 **概念可借鉴** | 语义对齐的层——我们的颜色分割在一定程度上已实现"语义"分层(同色区域 ≈ 语义区域) | +| ❌ **不适用** | SDS 简化过程——需要生成式 AI 模型,与我们的 C++ 管线完全不兼容 | +| ❌ **不适用** | DiffVG 优化——同 LIVE,需要 GPU | +| ❌ **不适用** | 语义分割依赖——需要预训练大模型 | + +**具体启发**: + +1. **分层粒度控制**:可以在配置中增加"细节层级"参数,控制矢量化输出的细节程度(类似 LIVSS 的层级概念,但通过颜色数量和小区域合并阈值实现) +2. **由粗到细的处理顺序**:在深度排序时,大面积区域优先(粗层次),小面积区域后处理(细层次),这与 LIVSS 的理念一致 +3. **可编辑性考量**:输出 SVG 的层次结构应当有意义(同色形状分组),方便用户后续编辑 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| LIVE (Ma et al. 2022) | 本文直接继承的前作,逐层矢量化 | +| DiffVG (Li et al. 2020) | 底层可微分渲染器 | +| Score Distillation Sampling (Poole et al. 2022, DreamFusion) | SDS 技术来源 | +| SAM (Kirillov et al. 2023) | 语义分割基础模型 | +| VectorFusion (Jain et al. 2023) | 另一种基于 SDS 的矢量生成方法 | diff --git a/docs/research/02-literature/perception-driven-vectorization.md b/docs/research/02-literature/perception-driven-vectorization.md new file mode 100644 index 0000000..1f362b5 --- /dev/null +++ b/docs/research/02-literature/perception-driven-vectorization.md @@ -0,0 +1,113 @@ +# Perception-Driven Semi-Structured Boundary Vectorization + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | Perception-Driven Semi-Structured Boundary Vectorization | +| **作者** | Shayan Hoshyari, Edoardo Dominici, Alla Sheffer, Nathan Carr, Ceylan, Zhili Chen, Wang, Shen | +| **单位** | University of British Columbia (UBC) / Adobe Research / National Taiwan University | +| **年份** | 2018 | +| **发表** | ACM SIGGRAPH 2018, ACM Transactions on Graphics (TOG), Vol. 37, No. 4 | +| **链接** | | + +## 2. 核心贡献摘要 + +本文针对**半结构化栅格图像**(如手绘插画、标志、漫画等颜色区域分明的图像)提出了一种新的矢量化方法。核心创新有三点: + +1. **同时进行样条拟合与角点检测**:不同于传统方法先检测角点再拟合曲线,本文将角点检测与贝塞尔曲线拟合统一到一个优化框架中。 +2. **学习的感知度量**:通过神经网络学习一种近似人类对边界不连续性感知的度量,用于指导角点判定。 +3. **局部线索与全局线索的平衡**:局部线索(学习的感知度量)判断边界上是否存在感知上的不连续;全局线索(简洁性/连续性偏好)确保输出矢量图整体简洁。 + +## 3. 算法/技术描述 + +### 3.1 输入与预处理 + +- 输入为半结构化栅格图像(预期颜色区域边界清晰) +- 提取区域边界像素序列 + +### 3.2 联合角点检测与样条拟合 + +传统流程是: + +``` +像素边界 → 角点检测 → 分段 → 逐段贝塞尔拟合 +``` + +本文的流程是: + +``` +像素边界 → 联合优化(角点位置 + 样条控制点)→ 最终矢量边界 +``` + +联合优化目标函数: + +``` +E_total = E_fit + λ_corner * E_corner + λ_smooth * E_smooth +``` + +- `E_fit`:样条曲线对像素边界的拟合误差 +- `E_corner`:角点处的感知不连续性度量(由学习模型提供) +- `E_smooth`:全局连续性/简洁性正则项 + +### 3.3 学习的感知度量 + +- 训练数据:通过用户研究收集人类对"边界上某点是角点还是光滑过渡"的判断 +- 输入特征:边界局部窗口内的像素模式 +- 输出:该位置是感知角点的概率 +- 模型架构:小型卷积网络 + +### 3.4 全局优化 + +采用交替优化策略: + +1. 固定角点位置,优化样条控制点(最小二乘拟合) +2. 固定样条,更新角点位置(基于感知度量 + 简洁性惩罚) +3. 重复直至收敛 + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **时间复杂度** | 未明确给出;交替优化收敛速度依赖边界复杂度,总体为多项式时间 | +| **训练开销** | 感知度量模型需要预训练(含用户研究数据收集),属一次性开销 | +| **推理开销** | 神经网络推理 + 交替优化,每条边界通常迭代 5–10 次 | + +## 5. 优缺点分析 + +### 优点 + +- 角点检测结果更符合人类感知,避免传统方法(固定阈值)的漏检/误检 +- 联合优化产生全局一致的矢量化结果 +- 用户研究验证效果优于 Adobe Image Trace、Vector Magic、Potrace + +### 缺点 + +- 需要预训练的感知模型,增加系统复杂度 +- 用户研究数据收集成本高 +- 仅适用于半结构化图像(清晰颜色分区),对照片类输入效果未验证 +- 交替优化可能陷入局部最优 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **可直接采用** | 角点检测与曲线拟合联合优化的思路——我们目前是先追踪轮廓再用 Schneider 拟合,可以考虑在拟合阶段引入角点质量评估 | +| 🔧 **需要改造** | 感知度量的概念——我们不需要训练神经网络,但可以设计基于曲率变化率的简化感知指标来判断角点 | +| ❌ **不适用** | 完整的学习框架(需要用户研究数据 + 神经网络训练),对我们的 C++ 管线而言过于复杂 | + +**具体启发**: + +1. **角点检测改进**:当前 Schneider 拟合使用固定角度阈值分割角点,可以参考本文的思路,引入曲率变化率的连续度量替代硬阈值 +2. **简洁性偏好**:输出矢量图应倾向更少的控制点(在拟合误差允许范围内),这与本文的全局简洁性正则化一致 +3. **评估方法**:本文的比较用户研究方法值得参考,用于验证我们的输出质量 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| Selinger 2003 (Potrace) | 已在我们代码库中使用 | +| Schneider 1990 (curve fitting) | 已在我们 `fitting.cpp` 中实现 | +| Lecot & Lévy 2006 (ardeco) | 边界矢量化的另一种方法,值得对比 | +| Favreau et al. 2016 (fidelity vs simplicity) | 保真度与简洁性权衡的形式化分析 | +| Noris et al. 2013 | 拓扑感知的矢量化,与我们的拓扑修复相关 | diff --git a/docs/research/02-literature/potrace-and-color-trace.md b/docs/research/02-literature/potrace-and-color-trace.md new file mode 100644 index 0000000..79490ad --- /dev/null +++ b/docs/research/02-literature/potrace-and-color-trace.md @@ -0,0 +1,194 @@ +# Potrace 与 color_trace 分析 + +## 1. 基本信息 + +### Potrace + +| 项目 | 内容 | +|------|------| +| **名称** | Potrace (Polygon Tracer) | +| **作者** | Peter Selinger | +| **单位** | Dalhousie University | +| **年份** | 2003 | +| **类型** | 开源工具 (GPL-2.0) | +| **链接** | | +| **论文** | Potrace: a polygon-based tracing algorithm (2003) | + +### color_trace + +| 项目 | 内容 | +|------|------| +| **名称** | color_trace | +| **作者** | migvel | +| **类型** | Potrace 的彩色封装脚本 | +| **链接** | | + +## 2. 核心贡献摘要 + +### Potrace + +Potrace 是最广泛使用的位图追踪算法之一,将二值位图转换为平滑矢量路径。其核心是两步流程: + +1. **最优多边形拟合**:在轮廓像素序列上求解最优多边形近似 +2. **贝塞尔曲线平滑**:将多边形边转换为三次贝塞尔曲线 + +### color_trace + +color_trace 将 Potrace 扩展到彩色图像,通过颜色量化 + 逐色 Potrace 追踪实现多色矢量化,并提供 `--stack` 模式支持堆叠输出。 + +## 3. 算法/技术描述 + +### 3.1 Potrace 核心算法 + +#### 步骤一:轮廓提取 + +``` +对二值位图: + 1. 扫描找到第一个黑色像素 + 2. 沿黑/白边界追踪闭合轮廓 + 3. 记录边界方向码序列 + 4. 标记已处理区域,继续扫描找到下一个轮廓 + 5. 建立轮廓的层次关系(外轮廓 / 内孔洞) +``` + +#### 步骤二:最优多边形拟合 + +``` +给定轮廓点序列 P = {p_0, p_1, ..., p_{n-1}}(闭合): + +1. 对每对点 (p_i, p_j),判断是否可以用直线段连接 + (所有中间点在直线的 ±0.5 像素容差内) +2. 构建可行线段的有向图 +3. 在此图上求解最短路径(最少线段数) + - 使用动态规划或最短路径算法 + - 惩罚函数考虑线段长度和方向变化 +``` + +这是 Potrace 的 **O(n²)** 瓶颈:对 n 个轮廓点,需要检查 O(n²) 对点的可行性。 + +#### 步骤三:贝塞尔平滑 + +``` +对最优多边形的每条边: + 1. 计算顶点处的切线方向(取相邻边方向的平均) + 2. 判断顶点是角点还是光滑点: + - 角度变化 > 阈值 → 角点(C0 连续) + - 否则 → 光滑点(G1 连续) + 3. 在光滑点处拟合三次贝塞尔曲线 + 4. 在角点处保持折角 +``` + +### 3.2 color_trace 管线 + +``` +输入彩色图像 + ↓ +[颜色量化] —— 可选算法: + ├── Median-Cut(中位切分) + ├── Adaptive Spatial Subdivision(自适应空间细分) + └── NeuQuant(神经网络量化) + ↓ +[逐色提取掩码] + 对每种量化后的颜色 c_i: + mask_i = (image == c_i) + ↓ +[逐掩码 Potrace 追踪] + 对每个 mask_i: + svg_i = potrace(mask_i) + ↓ +[合并输出] —— 两种模式: + ├── 普通模式:每个形状独立,精确但冗余 + └── --stack 模式:按面积排序堆叠,紧凑 + ↓ +输出 SVG +``` + +### 3.3 颜色量化选项 + +| 算法 | 颜色数 | 特点 | +|------|--------|------| +| Median-Cut | 最多 256 | 经典、快速、质量适中 | +| Adaptive Spatial Subdivision | 最多 256 | 空间自适应,保留边缘区域颜色 | +| NeuQuant | 最多 256 | 基于 Kohonen 网络,质量最好但最慢 | + +### 3.4 --stack 选项 + +``` +堆叠模式工作方式: + 1. 提取所有颜色区域 + 2. 按面积从大到小排序 + 3. 最大区域作为背景(可用 --background 指定) + 4. 逐层叠加较小区域 + 5. 无需在底层形状中镂空孔洞 + +--background 选项: + 指定背景色后,背景层可省略(浏览器/查看器用背景色绘制) + 进一步减小 SVG 文件大小 +``` + +## 4. 复杂度分析 + +| 阶段 | 复杂度 | 说明 | +|------|--------|------| +| 轮廓提取 | O(n) | n = 像素数 | +| 最优多边形 | **O(m²)** | m = 轮廓点数,每条轮廓独立 | +| 贝塞尔平滑 | O(m) | 线性遍历多边形顶点 | +| color_trace 量化 | O(n × k) | k = 目标颜色数 | +| **总计(单色)** | **O(m²)** | 瓶颈在最优多边形拟合 | +| **总计(彩色)** | **O(n × k + Σ m_i²)** | 每种颜色独立追踪 | + +## 5. 优缺点分析 + +### Potrace 优点 + +- **输出质量高**:最优多边形保证了拟合质量 +- **广泛验证**:20+ 年的工业使用 +- **实现成熟**:代码稳定、可靠 +- **参数少**:主要参数为容差和转角阈值 + +### Potrace 缺点 + +- **仅支持二值**:需要外部工具处理彩色 +- **O(n²) 复杂度**:大轮廓处理慢 +- **无亚像素精度**:边界限于像素网格 +- **无拓扑感知**:独立处理每条轮廓 + +### color_trace 优点 + +- **简单有效**:利用成熟的 Potrace 处理彩色 +- **堆叠模式**:`--stack` 选项产生紧凑 SVG +- **多种量化算法**:可按需选择 + +### color_trace 缺点 + +- **逐色独立追踪**:共享边界不对齐 +- **颜色量化在 RGB 空间**:不如感知均匀空间 +- **脚本封装**:性能和集成性有限 +- **无形状延伸**:堆叠模式下可能出现亚像素缝隙 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **已直接使用** | Potrace 算法——我们已在 `TraceMaskWithPotraceBezier` 中集成 Potrace 进行二值掩码追踪 | +| ✅ **可直接采用** | `--stack` 模式验证了堆叠策略——我们的 V2 堆叠方案与此一致 | +| 🔧 **需要改造** | Potrace 的 O(n²) 最优多边形——对于大轮廓可能需要近似算法或分段处理 | +| 🔧 **需要改造** | color_trace 的量化方法——我们已有更好的 SLIC + K-Means 方案 | +| 🔧 **需要改造** | 共享边界处理——Potrace 独立追踪每个掩码,我们需要在共享边界上保持一致性 | + +**具体启发**: + +1. **已采用的部分**:Potrace 的贝塞尔平滑已在我们的追踪模块中使用 +2. **堆叠模式参考**:color_trace 的 `--stack` 选项是一个简化版的堆叠实现,验证了概念可行性 +3. **背景优化**:`--background` 选项的思路(省略背景形状)可以减小输出 SVG 大小 +4. **量化算法对比**:NeuQuant、Median-Cut 等可以作为我们方案的对比基线 + +## 7. 引用链线索 + +| 参考文献 | 相关性 | +|----------|--------| +| Selinger 2003 原始论文 | Potrace 算法细节 | +| libpotrace API 文档 | 我们集成时的接口参考 | +| Heckbert 1982 (Median Cut) | 颜色量化经典方法 | +| Dekker 1994 (NeuQuant) | 神经网络颜色量化 | +| VTracer | 线性复杂度的替代方案 | diff --git a/docs/research/02-literature/printing-trapping.md b/docs/research/02-literature/printing-trapping.md new file mode 100644 index 0000000..03ea2c0 --- /dev/null +++ b/docs/research/02-literature/printing-trapping.md @@ -0,0 +1,165 @@ +# 印刷补漏白(Trapping)技术 + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **主题** | 印刷补漏白(Trapping) | +| **来源** | Wikipedia "Trapping (printing)" 条目、Adobe InDesign 用户文档、Prinect 技术文档 | +| **领域** | 印前处理 / 印刷工艺 | +| **类型** | 工程实践参考 | + +## 2. 核心贡献摘要 + +补漏白(Trapping)是印刷领域解决**套印偏差**(misregistration)问题的标准技术。当多色印刷时,由于机械精度限制,相邻颜色区域之间可能出现白色缝隙。补漏白通过在颜色边界处人为扩展一种颜色来掩盖这种缝隙。 + +这与矢量化中相邻形状之间的**亚像素缝隙**问题有直接类比关系。 + +## 3. 算法/技术描述 + +### 3.1 问题定义 + +``` +理想状态:相邻颜色区域 A 和 B 精确对齐,无缝隙 +实际状态:印刷机套印偏差导致 A 和 B 之间出现 0.003 英寸左右的白色缝隙 + +矢量化类比: +理想状态:相邻 SVG 形状边界精确对齐 +实际状态:浮点精度和路径拟合误差导致亚像素缝隙 +``` + +### 3.2 基本规则 + +| 规则 | 描述 | +|------|------| +| **明暗原则** | 较亮的颜色"展开"(spread)到较暗颜色区域中 | +| **原因** | 人眼对暗色边缘形状更敏感,暗色轮廓扩展会被注意到;亮色扩展不明显 | +| **补漏白宽度** | 典型值 0.003 英寸 = 0.25pt = 约 0.09mm | +| **补漏白颜色** | 通常取相邻两色的混合色(每个分色通道取较暗值) | + +### 3.3 两种基本操作 + +#### Spread(展开) + +``` +前景色较亮时:将前景色向外扩展到背景色区域 + + Before: After: + ┌────┬────┐ ┌─────┬───┐ + │ L │ D │ → │ L ▓▓│ D │ + └────┴────┘ └─────┴───┘ + ▓▓ = 补漏白区域(亮色扩展) +L = Light, D = Dark +``` + +#### Choke(收缩) + +``` +前景色较暗时:将背景色向内收缩(等价于亮色展开) + + Before: After: + ┌────┬────┐ ┌───┬─────┐ + │ D │ L │ → │ D │▓▓ L │ + └────┴────┘ └───┴─────┘ + ▓▓ = 补漏白区域(亮色扩展) +``` + +### 3.4 补漏白颜色计算 + +``` +给定相邻颜色 A = (C_a, M_a, Y_a, K_a) 和 B = (C_b, M_b, Y_b, K_b): + +trap_color = ( + max(C_a, C_b), # 每个分色通道取较大值(较暗) + max(M_a, M_b), + max(Y_a, Y_b), + max(K_a, K_b) +) + +在 RGB 空间类比: +trap_color = ( + min(R_a, R_b), # RGB 中较暗 = 值较小 + min(G_a, G_b), + min(B_a, B_b) +) +``` + +### 3.5 适用场景 + +| 印刷技术 | 需要补漏白 | 原因 | +|----------|-----------|------| +| 胶版印刷(Lithographic) | ✅ 是 | 多次过版,套印偏差不可避免 | +| 柔版印刷(Flexographic) | ✅ 是 | 套印精度较低 | +| 凹版印刷(Gravure) | ⚠️ 视情况 | 精度较高但大尺寸仍需要 | +| 喷墨印刷(Inkjet) | ❌ 否 | 单次喷印,无套印问题 | +| 屏幕显示 | ❌ 否 | 无机械对位问题 | + +## 4. 复杂度分析 + +| 方面 | 评估 | +|------|------| +| **检测** | O(m),m 为边界像素/路径点数(遍历所有边界) | +| **颜色计算** | O(1),每条边界上的颜色混合 | +| **路径偏移** | O(m),对边界路径做固定宽度偏移 | +| **总计** | O(m),线性于边界复杂度 | + +## 5. 优缺点分析 + +### 补漏白方法 + +**优点**: + +- **实现简单**:沿边界做固定宽度偏移即可 +- **行业标准**:成熟的工业实践,工具链完善 +- **局部处理**:只修改边界附近的窄带区域 + +**缺点**: + +- **引入新颜色**:补漏白区域的颜色是两个相邻色的混合,引入了原图中不存在的颜色 +- **需要明暗判定**:必须判断哪个颜色更暗,对于亮度相近的颜色较困难 +- **不完全隐蔽**:在高分辨率或放大查看时,补漏白区域可能可见 +- **CMYK 导向**:补漏白颜色计算在 CMYK 空间更自然,RGB/屏幕显示场景不直观 + +### 形状延伸方法(我们的方案) + +**优点**: + +- **完全隐蔽**:被遮挡区域的延伸完全被上层形状覆盖,不可见 +- **无新颜色**:不引入任何新颜色 +- **无需明暗判定**:延伸方向由层叠顺序决定,与颜色无关 +- **适用于所有显示技术** + +**缺点**: + +- **需要堆叠模型**:形状必须有明确的前后关系 +- **SVG 稍大**:每个形状的路径稍长(延伸区域) +- **实现较复杂**:需要计算延伸区域并修改路径 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| 🔧 **概念可借鉴** | 补漏白是解决缝隙问题的经典工业方案,验证了"缝隙问题需要主动处理"这一认知 | +| 🔧 **概念可借鉴** | "亮色展开到暗色"的规则——在无法使用堆叠模型的场景(如平铺模式)可以作为后备方案 | +| ✅ **间接已采用** | 形状延伸是补漏白思想的高级形式——将被遮挡的形状延伸到遮挡形状下方,比补漏白更优 | +| ❌ **不适用** | 补漏白的颜色混合逻辑——我们的形状延伸不需要计算混合颜色 | + +**具体启发**: + +1. **问题验证**:补漏白技术的存在证实了"相邻区域缝隙"是一个普遍问题,不仅出现在矢量化中,也出现在印刷领域 +2. **我们的方案更优**:形状延伸(extend into occluded region)比补漏白更优,因为: + - 不引入新颜色 + - 延伸区域完全不可见 + - 不需要明暗判定 +3. **退化场景参考**:当堆叠排序困难(如两个区域亮度接近且无明确前后关系)时,可以参考补漏白的"对等展开"策略作为降级方案 +4. **宽度参考**:补漏白的典型宽度 0.25pt(约 0.3 像素@72dpi)可以作为形状延伸最小宽度的参考 + +## 7. 引用链线索 + +| 参考 | 相关性 | +|------|--------| +| Adobe InDesign 补漏白文档 | 工业级补漏白实现的详细参数说明 | +| Prinect Signa Station | 自动补漏白工具 | +| PDF/X 标准 | 定义了补漏白在 PDF 中的标准表示 | +| Ghostscript trap 模块 | 开源的补漏白实现 | +| 我们的 `coverage.cpp` | 覆盖率修补——类似思路但在像素级操作 | diff --git a/docs/research/02-literature/vectorization-with-depth.md b/docs/research/02-literature/vectorization-with-depth.md new file mode 100644 index 0000000..c472ed4 --- /dev/null +++ b/docs/research/02-literature/vectorization-with-depth.md @@ -0,0 +1,223 @@ +# 文献笔记:Image Vectorization with Depth(Law & Kang, 2024) + +本文档对 Georgia Institute of Technology 的 Ho Law 与 Sung Ha Kang 于 2024 年发表在 arXiv 的工作进行结构化梳理,并对照本仓库 `neroued_vectorizer` 的管线做可迁移性分析。 + +--- + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **标题** | Image Vectorization with Depth: convexified shape layers with depth ordering | +| **作者** | Ho Law,Sung Ha Kang | +| **机构** | Georgia Institute of Technology(佐治亚理工学院) | +| **年份** | 2024 | +| **预印本** | arXiv:2409.06648(投稿日期:2024-09-10) | +| **链接** | [arXiv 摘要页](https://arxiv.org/abs/2409.06648) · [PDF](https://arxiv.org/pdf/2409.06648) · [DOI](https://doi.org/10.48550/arXiv.2409.06648) | +| **学科分类** | cs.CV(计算机视觉);cs.GR(图形学) | + +**说明**:该文目前以 arXiv 预印本形式公开;若后续有正式会议/期刊版本,应以最终录用信息为准更新本节的 venue 字段。 + +--- + +## 2. 核心贡献摘要 + +1. **带深度的分层矢量化**:在颜色量化后的栅格上,将每个同色连通域视为一层 **shape layer**,不仅输出曲线,还显式估计形状之间的 **深度顺序**,使 SVG 中图层的叠放顺序与遮挡关系一致,便于编辑与语义操作。 + +2. **深度排序能量 \(D(i,j)\)**:用 **凸包相交面积比** 定义成对关系,构造有向图表示全局“谁更可能在上层”;对图中可能出现的 **环** 提出基于 **凸包对称差型能量 \(V(i,j)\)** 的消解策略,再对无环图做 **拓扑排序** 得到线性深度序列。 + +3. **形状凸化(convexification)**:对被遮挡区域 \(O_i\) 采用 **Euler 弹性杆(Euler’s elastica)曲率正则** 的变分修补,并结合 **Modica–Mortola 双阱势** 以稳定地填充较大遮挡区域,使外推边界符合“光滑延伸”的视觉先验。 + +4. **曲线与输出**:对凸化后的形状边界进行 **Bézier 拟合**,按估计的深度顺序叠加导出 SVG;并讨论 **形状层分组** 以支持语义层面的矢量化。 + +--- + +## 3. 算法描述 + +整体流程可概括为:**颜色量化 → 形状层定义 → 深度图与环消除 → 各层遮挡区凸化 → 边界 Bézier 化 → 有序 SVG**。 + +### 3.1 形状层(Shape layer)定义 + +- 输入:经颜色量化后的标签图(每像素一个颜色标签)。 +- **定义**:同一标签下的每一个 **连通分量(connected component)** 对应 **一个** shape layer,记为 \(S_i\),其特征函数为 \(\chi_i\)(在 \(S_i\) 上为 1,否则为 0)。 + +该定义与许多“按区域矢量化”的方法一致,但本文强调层与层之间的 **遮挡与顺序**,而非仅平面并集。 + +### 3.2 成对深度能量与矩阵 \(A(i,j)\) + +对每一对形状 \(S_i\)、\(S_j\),用 **\(S_j\) 的凸包** 与 **\(S_i\)** 的相交程度,衡量“\(j\) 在 \(i\) 之上”的证据。 + +记 \(\chi_j^{\mathrm{conv}}\) 为 **\(S_j\) 的凸包** 所张成区域上的特征函数(凸包内为 1,外为 0)。定义: + +\[ +A(i,j) = \frac{\displaystyle\int \chi_j^{\mathrm{conv}}(x)\,\chi_i(x)\,\mathrm{d}x}{\displaystyle\int \chi_i(x)\,\mathrm{d}x} +\] + +**直观**:分子是 **\(S_j\) 的凸包与 \(S_i\) 的相交面积**;分母是 **\(S_i\) 的面积**。若 \(S_j\) 在视觉上“盖住”了 \(S_i\) 的一部分,则 \(S_j\) 的凸包往往会覆盖 \(S_i\) 的更多区域,从而 \(A(i,j)\) 较大。 + +成对 **深度排序能量** 定义为: + +\[ +D(i,j) = A(i,j) - A(j,i) +\] + +- 若 \(D(i,j) > 0\),倾向于认为 **\(j\) 在 \(i\) 之上**(或至少在该能量度量下更支持 \(j \succ i\) 的有向边)。 +- 该量对 \((i,j)\) **反对称**,便于构造有向图。 + +### 3.3 全局有向图、环消除与 \(V(i,j)\) + +- **构图**:根据 \(D(i,j)\) 的符号与阈值策略(具体阈值与平局处理以原文为准)在形状索引 \(\{1,\ldots,n\}\) 上建立 **有向图** \(G\),边表示估计的 **前后遮挡关系**。 +- **问题**:两两比较可能产生 **有向环**,与真实深度应为 **偏序/全序** 的假设矛盾。 +- **环消除能量**:对可能冲突的边 \((i,j)\),引入基于 **对称差思想** 的标量(积分形式与原文一致): + +\[ +V(i,j) = \int \Bigl(\chi_i(x) + \chi_j(x) - \chi_j^{\mathrm{conv}}(x)\,\chi_i(x)\Bigr)\,\mathrm{d}x +\] + +**直观**:\(\chi_i + \chi_j\) 对应 \(S_i \cup S_j\) 的指示(在并集上计数方式需与原文离散实现一致);减去 \(\chi_j^{\mathrm{conv}}\chi_i\) 与 **“用凸包覆盖关系”** 相关,用于度量 **凸包假设与真实形状不一致** 的程度。\(V(i,j)\) 较小的边对更可信,**优先保留**;在环上删除 **\(V(i,j)\) 较大** 的边以破环(具体选取规则:最小化总删除代价或迭代删边,见原文算法)。 + +### 3.4 拓扑排序得到线性顺序 + +- 对破环后的 **有向无环图(DAG)** 执行 **拓扑排序**,得到形状层的一个线性序列(可能存在多种合法拓扑序;原文可采用辅助规则固定唯一顺序)。 +- 该序列即 SVG 中 **从后到前** 或 **从前到后** 的绘制顺序(与实现约定一致即可)。 + +**伪代码(示意)**: + +```text +输入:量化标签图 L +1. 提取连通分量 {S_i},建立 χ_i,并计算各凸包 χ_i^conv +2. 对所有有序对 (i, j),计算 A(i,j)、D(i,j) +3. 根据 D 构造有向图 G +4. while G 含有向环 C: + 在环上找到使 V(i,j) 最大的边 e(或等价删边准则) + 从 G 中删除 e +5. 对 G 拓扑排序,得深度顺序 π +6. 按 π 依次对各层做凸化与矢量化(下一小节) +``` + +### 3.5 遮挡区域上的形状凸化:Euler’s elastica 与 \(O_i\) + +- 对每一形状 \(S_i\),**被遮挡部分**(或需外推以形成凸形边界的区域)记为 **\(O_i\)**(原文根据与其它层的关系定义;实现上为 \(S_i\) 上需 inpainting 的子区域或其闭包)。 +- 在 \(O_i\) 内求解 **Euler 弹性杆** 型能量,用闭合曲线 \(C_i\) 表示待恢复的 **边界**,能量形式为: + +\[ +E(C_i) = \int_{C_i} \bigl(a + b\,\kappa^2(s)\bigr)\,\mathrm{d}s +\] + +其中 \(\kappa\) 为曲率,\(a,b>0\) 为权重;并满足 **几何/区域约束**(原文表述): + +\[ +S_i \subseteq C_i \subseteq O_i +\] + +(注:严格写法依原文:\(C_i\) 为边界曲线,区域约束通常表述为 **可行曲线集合** 或 **水平集包含关系**;此处按用户给定纲要保留该约束形式,阅读 PDF 时应以文中集合定义为准。) + +- 为处理 **较大** 遮挡,引入 **Modica–Mortola 双阱势** 相关能量,使相变/区域划分更稳定(具体耦合方式见原文 PDE 离散化)。 + +**直观**:最小化曲率平方项使边界 **光滑**;在允许区域内外推,使形状趋向 **凸化** 或至少 **视觉连续**,符合“轮廓沿光滑曲线延伸”的先验。 + +### 3.6 Bézier 拟合与 SVG 输出 + +- 在凸化并得到各层 **分段光滑边界** 后,对每条边界执行 **Bézier 曲线拟合**(可配合自适应分段)。 +- 按 **拓扑排序** 得到的深度顺序将各层写入 SVG(例如后层先绘、前层后绘),实现 **可编辑的叠加结构**。 + +--- + +## 4. 计算复杂度分析(量级讨论) + +以下记 **\(n\)** 为形状层数量,**\(N\)** 为图像像素数,**\(m\)** 为单形状边界点数或网格规模的上界。 + +| 阶段 | 复杂度量级 | 说明 | +|------|------------|------| +| 连通域与凸包 | \(O(N)\) 或 \(O(N \log N)\) | 视实现而定;每区域凸包为顶点数的 \(O(k \log k)\)。 | +| 全体 \(A(i,j)\) | 粗估 **\(O(n^2 \cdot T_{\mathrm{overlap}})\)** | 需对 \(O(n^2)\) 对形状计算凸包与区域相交;\(T_{\mathrm{overlap}}\) 依赖多边形裁剪/栅格统计。 | +| 构图与环检测 | \(O(n^2)\)~**\(O(n^3)\)** | 建图 \(O(n^2)\);找环与迭代删边最坏可能多次遍历。 | +| 拓扑排序 | **\(O(n + |E|)\)** | DAG 上为线性时间。 | +| Euler elastica / MM 求解 | **每区域高** | 通常为迭代 PDE 或曲线演化,与网格分辨率、迭代次数呈近似线性或超线性关系;全文主要开销往往在此。 | +| Bézier 拟合 | 约 **\(O(m)\)** 每曲线 | 与边界采样点数及分段数有关。 | + +**小结**:该方法的 **渐近瓶颈** 通常在 **(1) \(O(n^2)\) 级别的成对几何计算** 与 **(2) 每形状变分修补的数值求解**。当量化后形状很多时,成对项会显著变慢。 + +--- + +## 5. 实验结果摘要(基于摘要与常见设定) + +- **对比对象**:文中报告与 **近年基于层的矢量化方法** 的比较(具体方法名与数据集以 PDF 为准),从 **视觉质量、编辑性、层顺序合理性** 等维度展示优势。 +- **现象**:利用 **深度顺序 + 凸化外推**,可在遮挡边界处得到 **更连贯** 的矢量轮廓,SVG **分层叠加** 更符合直觉,便于后续 **语义分组** 与编辑。 +- **局限(从方法本身推断)**:强依赖 **颜色量化** 与 **连通域 = 语义对象** 的假设;深度能量在 **复杂纹理、低对比、透明** 区域可能不稳定。 + +*注:定量指标(如 IoU、Chamfer、用户研究)请以原文表格为准。* + +--- + +## 6. 优缺点分析 + +**优点** + +- **显式深度**:将“绘制顺序”从后处理提升为 **模型输出**,利于 **非平面** 矢量编辑。 +- **几何可解释**:\(A(i,j)\)、\(D(i,j)\)、\(V(i,j)\) 均有 **面积/凸包** 层面的解释,便于调参与 Debug。 +- **边界先验**:Euler elastica + 双阱势 **贴合** “光滑延伸”的视觉习惯,对 **遮挡补全** 有针对性。 +- **标准输出**:Bézier + SVG 与业界工具链兼容。 + +**缺点** + +- **计算成本高**:成对形状 + PDE 式凸化,难以实时处理高分辨率、超多区域图。 +- **假设偏强**:**同色单连通 = 一层** 在文字、渐变、抗锯齿混合色下易 **过分割或合并错误**;**凸性/凸包** 先验对细长、凹形物体可能不适用。 +- **图环消解**:依赖 **\(V(i,j)\)** 的启发式删边,全局最优性保证有限,极端构图下顺序可能错。 +- **与栅格量化强绑定**:管线起点是 **标签图**,对 **照片级** 图像需强预处理,否则深度估计噪声大。 + +--- + +## 7. 与我们方案(`neroued_vectorizer`)的关联 + +本仓库为 **7 阶段** 栅格→SVG 管线:预处理 → 颜色分割 → 边界提取 → 轮廓装配 → 曲线拟合(Schneider)→ Potrace 追踪 → Clipper2 拓扑修复与 SVG 输出(见 `README.md`)。下表将 Law & Kang 的技术点映射到可迁移性(**可直接采用 / 需要改造 / 不适用**)。 + +| 技术点 | 判定 | 说明 | +|--------|------|------| +| 同色连通域作为一层 shape layer | **需要改造** | 我们已有 **SLIC + K-Means** 分割与连通域/轮廓,但 **未** 将每层作为带深度的独立 SVG 图层语义;若要支持,需要 **输出模型与 SVG 分组策略** 改造,且 Potrace 路径与“区域层”需对齐。 | +| 深度能量 \(A(i,j)\)、\(D(i,j)\) 与有向图 | **需要改造** | 核心思想 **可移植**:在分割后区域上算 **凸包面积比** 并构图。本库当前 **无** 深度排序模块,需在 **segment/contour 之后** 增加新阶段或可选分支;与 Potrace 的 **平面填充顺序** 需统一。 | +| 环消除与 \(V(i,j)\) | **需要改造** | 算法独立,但依赖上一步图结构;需 **单元测试** 覆盖环检测与删边稳定性。 | +| DAG 拓扑排序 | **可直接采用** | 标准算法;实现成本低,作为深度模块子步骤即可。 | +| Euler elastica + Modica–Mortola 凸化 \(O_i\) | **不适用**(默认管线) | 我库侧重 **Potrace + 几何拟合**,无 **PDE inpainting** 依赖;引入将显著增加 **依赖与耗时**。若未来做“研究分支”或可选插件,再评估 **需要改造** 为独立可选后处理。 | +| Bézier 边界拟合 | **可直接采用(已有同类能力)** | 我们已用 **Schneider** 等方法做曲线拟合;论文侧重点是 **凸化后的边界**,若引入凸化则需在同一几何上拟合,属 **参数与输入改造**,而非从零实现 Bézier。 | +| 有序 SVG 叠加导出 | **需要改造** | `svg_writer` 需支持 **按深度排序的 ``/层** 与绘制顺序;当前若以 **平面合成** 为主,要扩展 **元数据与导出选项**。 | +| 语义形状分组 | **需要改造** | 可与 **调色板/区域合并** 结合,但需额外 **用户语义或学习模块**,非现成特性。 | + +**一句话总结**:**深度图 + 凸包能量** 与我们的 **分割/轮廓** 模块 **概念兼容**,适合作为 **可选高层特性** 集成;**Euler elastica 凸化** 与当前 **轻量几何矢量化** 定位 **冲突**,默认 **不适用**。 + +--- + +## 8. 引用链线索(值得延伸阅读的文献方向) + +1. **Euler’s elastica 与图像修复/分割**:Mumford、Chan–Kang–Shen 等关于 **elastica 曲线与变分模型** 的经典工作,是理解 \( \int (a + b\kappa^2)\,\mathrm{d}s \) 数值实现与边界条件的入口。 + +2. **Modica–Mortola 近似与 Gamma 收敛**:用于 **双阱势** 与区域模型的大区域稳定求解;检索关键词:`Modica-Mortola` + `image segmentation` / `phase field`。 + +3. **Layer-based / semantic vectorization**:与文中对比的 **近年分层矢量化** 方法(PDF 参考文献表中 **Related work on vectorization**),用于横向对比 **深度/语义层** 设计。 + +4. **Potrace / 位图追踪**:本库已用 **Potrace**;论文路径更偏 **变分边界 + Bézier**,可对读 **Potrace 的 corner 与曲线简化** 与 **elastica 边界** 的差异。 + +5. **凸包与遮挡推理**:计算机视觉中 **深度序** 由 **T-junctions、遮挡轮廓** 推断的大量文献,可补充 **仅用凸包面积比** 之外的 **几何线索**(若未来改进 \(D(i,j)\))。 + +6. **SVG 与编辑友好矢量**:SVG 规范与 **图层编辑** 交互,便于产品化落地。 + +--- + +## 附录:文中关键公式汇总 + +\[ +A(i,j) = \frac{\displaystyle\int \chi_j^{\mathrm{conv}}(x)\,\chi_i(x)\,\mathrm{d}x}{\displaystyle\int \chi_i(x)\,\mathrm{d}x}, \qquad +D(i,j) = A(i,j) - A(j,i) +\] + +\[ +V(i,j) = \int \Bigl(\chi_i(x) + \chi_j(x) - \chi_j^{\mathrm{conv}}(x)\,\chi_i(x)\Bigr)\,\mathrm{d}x +\] + +\[ +E(C_i) = \int_{C_i} \bigl(a + b\,\kappa^2(s)\bigr)\,\mathrm{d}s, \quad \text{s.t. } S_i \subseteq C_i \subseteq O_i +\] + +--- + +*文档版本:基于 arXiv:2409.06648 摘要与公开信息整理;细节算法、离散化与超参数请以原文 PDF 为准校对。* diff --git a/docs/research/02-literature/vtracer-analysis.md b/docs/research/02-literature/vtracer-analysis.md new file mode 100644 index 0000000..d357329 --- /dev/null +++ b/docs/research/02-literature/vtracer-analysis.md @@ -0,0 +1,133 @@ +# VTracer 分析 + +## 1. 基本信息 + +| 项目 | 内容 | +|------|------| +| **名称** | VTracer | +| **开发者** | Vision Cortex | +| **许可证** | MIT | +| **语言** | Rust | +| **年份** | 2021– (持续维护) | +| **文档** | | +| **源码** | | + +## 2. 核心贡献摘要 + +VTracer 是一个开源的彩色栅格图像矢量化工具,设计目标是在保持高质量的前提下实现 **O(n) 线性时间复杂度**,支持高达千兆像素级的大尺寸扫描件。核心特点: + +- **双模式输出**:堆叠模式(stacked,默认)和镂空模式(cutout) +- **层叠策略**:形状按层堆叠、无需镂空孔洞,SVG 输出更紧凑 +- **三阶段路径处理**:路径行走 → 路径简化 → 曲线拟合 +- **线性复杂度**:避免 Potrace 的 O(n²) 最优多边形拟合 + +## 3. 算法/技术描述 + +### 3.1 颜色量化 + +- 使用**层次聚类**(hierarchical clustering)进行颜色量化 +- 可配置参数 `color_precision`(1–8,对应颜色数量 2–256) +- 在 RGB 空间中基于欧氏距离聚类 + +### 3.2 双模式 + +| 模式 | 描述 | SVG 特征 | +|------|------|----------| +| **Stacked** | 形状按从大到小堆叠,背景层在底、细节层在顶 | 无孔洞,紧凑 | +| **Cutout** | 每个形状独立,重叠区域镂空 | 有孔洞,精确 | + +堆叠模式的层序确定: + +``` +1. 按面积从大到小排序所有颜色区域 +2. 大区域先绘制(底层),小区域后绘制(顶层) +3. 顶层形状遮盖底层,无需在底层打孔 +``` + +### 3.3 三阶段路径处理 + +#### 阶段一:路径行走(Path Walking) + +- 沿颜色区域边界的像素进行顺时针/逆时针行走 +- 生成像素级的边界路径 + +#### 阶段二:路径简化(Path Simplification) + +两步简化: + +1. **阶梯消除**(Staircase Removal):平滑像素级的锯齿阶梯 +2. **惩罚制简化**(Penalty-based Simplification): + +``` +对每个路径点 p_i: + penalty(p_i) = 距离偏差(p_i) + 角度偏差(p_i) + if penalty(p_i) < threshold: + 移除 p_i +``` + +#### 阶段三:曲线拟合(Curve Fitting) + +- 将简化后的折线段拟合为二次/三次贝塞尔曲线 +- 基于角度阈值分割角点 +- 逐段拟合 + +### 3.4 深度排序(Stacked 模式) + +``` +对所有颜色区域: + 1. 计算每个区域的面积 + 2. 按面积降序排列 + 3. 输出 SVG 时按此顺序逐层写入 + 4. 最底层覆盖整个画布(背景色填充) +``` + +## 4. 复杂度分析 + +| 阶段 | 复杂度 | 说明 | +|------|--------|------| +| 颜色量化 | O(n × k) | n = 像素数,k = 目标颜色数 | +| 路径行走 | O(n) | 每个边界像素访问常数次 | +| 路径简化 | O(m) | m = 路径点数,线性扫描 | +| 曲线拟合 | O(m) | 逐段拟合,每段 O(1) | +| **总计** | **O(n)** | 对比 Potrace 的 O(n²) | + +## 5. 优缺点分析 + +### 优点 + +- **线性复杂度**:适合大尺寸图像(千兆像素级扫描件) +- **堆叠模式**:SVG 紧凑、无孔洞,渲染效率高 +- **开源 Rust 实现**:代码质量高,性能优秀 +- **参数简洁**:少量旋钮即可获得良好结果 + +### 缺点 + +- **颜色量化简单**:层次聚类在 RGB 空间中,不如感知均匀空间(Lab/OKLab)准确 +- **路径简化偏简陋**:惩罚制简化不如 Schneider 拟合精确 +- **无亚像素精度**:边界精度受限于像素网格 +- **无拓扑修复**:可能产生微小缝隙 +- **深度排序仅基于面积**:无法处理复杂的前后关系 + +## 6. 与我们方案的关联 + +| 关联程度 | 内容 | +|----------|------| +| ✅ **可直接采用** | 堆叠策略——这与我们 V2 设计的形状延伸 + 层叠输出思路高度一致 | +| ✅ **可直接采用** | 阶梯消除的预处理步骤——可在曲线拟合前加入类似的路径预简化 | +| 🔧 **需要改造** | 深度排序逻辑——VTracer 仅按面积排序,我们需要考虑颜色深浅和空间包含关系 | +| 🔧 **需要改造** | 颜色量化——我们已使用 SLIC + K-Means 在 Lab 空间,优于 VTracer 的 RGB 层次聚类 | +| ❌ **不适用** | 路径简化方法——惩罚制简化精度不足,我们保留 Schneider 拟合 | + +**具体启发**: + +1. **V2 堆叠模式设计**:VTracer 的堆叠策略验证了"形状按深度堆叠、无孔洞"的可行性,我们的形状延伸(extend into occluded region)比简单面积排序更优 +2. **路径预简化**:在 Schneider 拟合前增加阶梯消除步骤,可减少拟合点数、提升速度 +3. **性能参考**:VTracer 的 O(n) 复杂度是一个值得追求的目标 + +## 7. 引用链线索 + +| 参考 | 相关性 | +|------|--------| +| Potrace (Selinger 2003) | 直接对比对象,O(n²) vs O(n) | +| Vision Cortex 的 visioncortex crate | VTracer 底层的图像处理库,包含连通域分析等 | +| color_trace (migvel) | 另一个使用 Potrace 的彩色矢量化工具,可对比堆叠实现 | diff --git a/docs/research/03-algorithm-deep-dive/color-quantization.md b/docs/research/03-algorithm-deep-dive/color-quantization.md new file mode 100644 index 0000000..6bcb9eb --- /dev/null +++ b/docs/research/03-algorithm-deep-dive/color-quantization.md @@ -0,0 +1,196 @@ +# 颜色量化算法对比 + +## 1. 问题定义与约束 + +**问题**:将真彩色(通常每通道 8 bit)图像映射到至多 **K** 种颜色(调色板),在**感知失真**、**速度**、**内存**与**实现复杂度**之间取得平衡,并为后续**分割、矢量化**提供稳定的颜色标签。 + +**约束**: + +- **K** 可能由用户指定或由管线启发式给出;算法需支持**非 2 的幂**种颜色(若选用经典 Median Cut 需额外处理)。 +- **感知一致性**:在**近似感知均匀**的颜色空间中做聚类或划分,通常优于直接在 sRGB 欧氏距离下操作。 +- **与本项目 V1 的关系**:当前 V1 采用 **SLIC + 在 LAB 空间做 K-Means**(空间与颜色耦合);本专题讨论的是**纯颜色量化**或**可替换该段**的算法,**不必**沿用 SLIC 超像素管线。 + +--- + +## 2. 本专题检索记录 + +### 检索关键词(keywords searched) + +- `color quantization` +- `median cut` +- `Wu's optimal color quantization` +- `OKLab` +- `octree color quantization` +- `NeuQuant` +- `perceptual color quantization` + +### 检索到的材料与结论要点 + +| 方向 | 内容摘要 | +|------|----------| +| Median Cut(Heckbert 1979) | 递归划分颜色盒子,**中位**分裂;经典、快;原典常对应 **2 的幂** 调色板。 | +| 改进中位切分(MMCQ 等) | 按**加权方差**或最大范围选轴,分裂策略更灵活,**任意 K** 更易实现。 | +| Wu(1991) | 动态规划最小化 MSE,给定直方图与 K 为**全局较优**意义;直方图分辨率影响内存与时间。 | +| 八叉树 | 流式插入、合并叶子至 K 色;**快**,但划分与**感知簇**可能不一致。 | +| K-Means | 像素级 Lloyd,质量依赖初始化与迭代;大图慢。 | +| NeuQuant(Dekker 1994) | 神经网络自组织量化,适合大图与照片;**非确定性**与实现复杂度较高。 | +| OKLab(Ottosson 2020) | 比 CIELAB **更感知均匀**,且由线性 RGB 经**矩阵 + 立方根**即可到达,适合作为量化空间。 | + +--- + +## 3. 候选方案对比表 + +### 3.1 V1 当前:SLIC + LAB 空间 K-Means + +- **做法**:SLIC 产生超像素,在 **CIELAB** 上对超像素代表色做 K-Means。 +- **优点**:考虑**空间连贯性**,区域更成片。 +- **缺点**:SLIC 可能**跨真实边界**;在超像素中心上做 K-Means 会**损失细粒度颜色细节**。 + +### 3.2 Median Cut(Heckbert 1979) + +- 在 RGB(或选定空间)中维护轴对齐盒子,递归在**范围最大**的维度上按**中位数**切分,直到盒子数达到 K。 +- **复杂度**:约 **\(O(N \log K)\)**(与实现细节有关)。 +- **优点**:简单、快、颜色分布通常较均匀。 +- **缺点**:经典形式常导出 **2 的幂** 色数;可能**切开**重要稠密簇。 + +### 3.3 Modified Median Cut(MMCQ) + +- 在**方差最大**的轴上切分,或使用「**人口 × 方差**」最大的盒子优先分裂等启发式;可扩展到**任意 K**。 +- **优点**:簇质量通常优于纯 Median Cut。 +- **缺点**:实现略复杂,需维护每个盒子的统计量。 + +### 3.4 Wu 最优量化(1991) + +- 在粗分辨率 **3D 直方图**上用**动态规划**最小化总体平方误差(给定 K)。 +- **复杂度**:约 **\(O(K \cdot R^3)\)**,\(R\) 为每维直方图桶数(或论文中的离散化级别)。 +- **优点**:在 MSE 模型下**接近最优**。 +- **缺点**:细直方图下**内存与时间**压力大。 + +### 3.5 八叉树量化 + +- 在颜色空间建八叉树,叶节点合并直至 **K** 色。 +- **复杂度**:插入约 **\(O(N)\)**(均摊意义下,与树深相关)。 +- **优点**:快、可流式。 +- **缺点**:**轴对齐**划分不一定对应**感知簇**。 + +### 3.6 K-Means(直接对像素或抽样像素) + +- 标准 Lloyd 或带 **Jancey** 等变体的迭代。 +- **复杂度**:约 **\(O(N \cdot K \cdot T)\)**,\(T\) 为迭代次数。 +- **优点**:成熟、质量常较好。 +- **缺点**:大图慢;对**初始化敏感**。 + +### 3.7 NeuQuant(Dekker 1994) + +- 自组织网络学习调色板。 +- **复杂度**:约 **\(O(N)\)** 量级(与网络更新规则有关)。 +- **优点**:适合**大图像**、照片类。 +- **缺点**:**非确定性**;从零移植**复杂**。 + +### 3.8 颜色空间对比 + +| 空间 | 特点 | 量化适用性 | +|------|------|------------| +| **sRGB** | 设备相关,**非感知均匀** | 直接欧氏距离**差** | +| **CIELAB** | 感知较均匀,工业常用 | **好**,转换成本中等 | +| **OKLab** | 较 CIELAB **更均匀**,转换为线性矩阵 + **立方根** | **推荐**作为量化与距离度量空间 | + +--- + +## 4. 推荐方案详细描述 + +**推荐:在 OKLab 空间使用改进中位切分(MMCQ 类)** + +- **质量 / 速度 / 实现难度** 综合平衡较好。 +- **不依赖** SLIC;若仍需空间连贯性,可在量化后由**形态学 / 区域合并**(如现有 **MergeSmallComponents** 思路)清理小碎片。 + +### OKLab:sRGB → 线性 → LMS → 立方根 → OKLab + +**步骤 1:sRGB 归一化 \([0,1]\) 转线性 RGB** + +\[ +C_{\mathrm{lin}} = +\begin{cases} +\dfrac{C_{\mathrm{srgb}}}{12.92}, & C_{\mathrm{srgb}} \le 0.04045, \\[6pt] +\left(\dfrac{C_{\mathrm{srgb}} + 0.055}{1.055}\right)^{2.4}, & \text{否则}. +\end{cases} +\] + +**步骤 2:线性 RGB → LMS(\(M_1\))** + +\[ +\begin{pmatrix} l \\ m \\ s \end{pmatrix} += +M_1 +\begin{pmatrix} R \\ G \\ B \end{pmatrix}, +\quad +M_1 = +\begin{pmatrix} +0.4122214708 & 0.5363325363 & 0.0514459929 \\ +0.2119034982 & 0.6806995451 & 0.1073969566 \\ +0.0883024619 & 0.2817188376 & 0.6299787005 +\end{pmatrix}. +\] + +**步骤 3:立方根** + +\[ +l' = \sqrt[3]{l},\quad m' = \sqrt[3]{m},\quad s' = \sqrt[3]{s}. +\] + +**步骤 4:\(M_2\) 得到 OKLab 的 \(L,a,b\)** + +\[ +\begin{pmatrix} L \\ a \\ b \end{pmatrix} += +M_2 +\begin{pmatrix} l' \\ m' \\ s' \end{pmatrix}, +\quad +M_2 = +\begin{pmatrix} +0.2104542553 & 0.7936177850 & -0.0040720468 \\ +1.9779984951 & -2.4285922050 & 0.4505937099 \\ +0.0259040371 & 0.7827717662 & -0.8086757660 +\end{pmatrix}. +\] + +(矩阵系数来源:Björn Ottosson,**OKLab** 公开定义;实现时注意浮点精度与 **sRGB** 解码顺序。) + +### MMCQ in OKLab 伪代码(概念级) + +```text +输入:像素集 P,目标色数 K,每维直方图桶数 B(如 32 或 64) +输出:调色板 Q(K 个 OKLab 色),每像素标签 + +1. 对每个像素:sRGB → OKLab(L, a, b) +2. 将每个像素量化到 B×B×B 网格,统计每格权重 w(像素数或加权) +3. 初始化一个颜色盒子:包围所有非空格,记录 sum, sum_sq, weight 每轴 +4. while 当前盒子数 < K: + 选「加权方差 × 权重」最大(或范围最大轴上中位分裂)的盒子 + 在该盒沿方差最大轴按中位(或 MMCQ 准则)分裂为两个子盒 +5. 对每个最终盒子:质心(加权平均 OKLab)→ 作为调色板色 +6. 最近邻或 Dither 将每像素指派到 Q(距离在 OKLab 中欧氏) +``` + +--- + +## 5. 关键实现细节 + +- **OKLab 转换**:全程在**浮点**下完成;调色板若需写回 sRGB,应对最终 sRGB **钳位到 \([0,1]\)** 再编码为 8 bit。 +- **直方图分箱**:\(B\) 过小损失细节,过大则盒子统计接近 Wu 类方法的内存问题;**32~64** 为常见起点。 +- **分裂准则**:**最大加权方差轴** + **中位分裂** 是 MMCQ 常见组合;亦可采用「**population × variance** 最大的盒子优先分裂」以支持任意 **K**。 +- **空盒与退化**:若某盒无样本,跳过或合并(需防护)。 + +--- + +## 6. 与现有代码的复用关系 + +- **V1 segment 模块**:本推荐路径**不强制复用** SLIC+K-Means;新量化可作为**独立预处理**或**替换聚类阶段**。 +- **MergeSmallComponents**(及同类形态学/小区域合并):可用于量化标签图上的**后处理**,去除孤立小区域、平滑标签边界,与「空间连贯」形成**解耦**的第二条防线。 + +--- + +## 7. 开放问题 + +- **自动选 K**:基于失真—复杂度曲线、信息论准则或**边缘保持**指标自动定 K。 +- **空间感知量化**:在 OKLab 距离上加入**空间正则**(如引导滤波式标签平滑、或超像素仅作后约束)是否优于 SLIC 强耦合,有待 A/B。 diff --git a/docs/research/03-algorithm-deep-dive/depth-ordering.md b/docs/research/03-algorithm-deep-dive/depth-ordering.md new file mode 100644 index 0000000..dd0304d --- /dev/null +++ b/docs/research/03-algorithm-deep-dive/depth-ordering.md @@ -0,0 +1,159 @@ +# 深度排序算法 + +## 1. 问题定义与约束 + +**问题**:层叠矢量化(Stacking)需要一条(或多条)**线性深度序**,使得在按序自底向上绘制时,视觉上与栅格参考一致:被遮挡区域应显示**上层**颜色,下层形状可在遮挡区任意延伸,但**不得**在错误深度关系下露出。 + +**输入**:通常为分割/量化后的**形状层** —— 每个连通区域(或聚合后的色块)对应一种绘制实体,带有几何(掩膜、轮廓)与颜色。 + +**输出**:形状的一个**全序**(或兼容的偏序拓扑排序结果),必要时将**背景**固定在最底层。 + +**约束**: + +- 计算复杂度需适应**数十到数百**个形状层(具体上限由产品定义)。 +- 输入来自**栅格**,边界与抗锯齿会带来**邻接关系噪声**,算法需对阈值与平局规则稳健。 +- 若仅依赖**启发式**,需明确**失败时**的回退策略(如用户指定、交互式调整或保守默认值)。 + +--- + +## 2. 本专题检索记录 + +### 检索关键词(keywords searched) + +- `image vectorization depth ordering Law Kang 2024` +- `convex hull covered area energy adjacent shapes` +- `T-junction depth ordering Kopf Lischinski` +- `Nitzberg Mumford Shiota 2.1 sketch occlusion` +- `vectorization background border heuristic layer order` +- `directed graph cycle removal topological sort depth` + +### 检索到的材料与结论要点(materials found) + +| 来源 | 摘要 | +|------|------| +| Law & Kang 等(2024),*Image Vectorization with Depth* | 提出基于**相邻形状对**的覆盖面积能量 **D(i,j)**,用**有向图 + 拓扑排序**得到深度;环处理借助凸包对称差等指标。 | +| Kopf & Lischinski 等 | 基于**素描/曲线网络**的深度与 T-junction 分析(更偏矢量/线条输入)。 | +| Nitzberg–Mumford–Shiota 2.1 sketch | 遮挡与 T-junction 的经典讨论;对**纯栅格**直接检测 T-junction **不稳定**。 | +| 启发式工程实践 | **贴边最大区域为背景**、**大面积在底层**等规则在工具与论坛中常见,实现简单但存在反例。 | + +--- + +## 3. 候选方案对比表 + +| 方法 | 核心思想 | 复杂度(量级) | 优点 | 缺点 | +|------|----------|----------------|------|------| +| **A:简单面积序** | 面积最大 ≈ 最底层 | **O(N log N)**(排序) | 实现极简 | 小面积区域也可能是背景,**易错** | +| **B:贴边启发式** | 贴图像边界的形状更可能为背景 | **O(N)** 量级 | 改善“背景不一定最大块”的情况 | **中层**深度无法单靠贴边区分 | +| **C:Law & Kang 覆盖能量 D(i,j)** | 对相邻对计算 **A(i,j)**,定义 **D(i,j)=A(i,j)−A(j,i)**,建图、破环、拓扑序 | 若对**所有**形状对:**O(N²)**;仅相邻对:**O(相邻对数 × 单对代价)** | 原则性强,可处理**复杂遮挡** | 全对代价高;**破环**依赖启发式 | +| **D:T-junction 深度** | 从 T 型连接推断前后遮挡 | 与 junction 检测相关 | 局部几何上直观 | 栅格上 **T-junction 难稳定**检测 | +| **E:混合方案** | **背景**用贴边(及最大块)+ 其余用 **D(i,j)** 仅对**相邻**形状对 | 相邻对数 × 凸包等,通常远小于 N² | **平衡精度与成本** | 需调参 **δ**;极端图仍可能环多 | + +**符号说明(与方法 C 一致)**: + +- **S_i**、**S_j**:两相邻形状区域(可用像素掩膜或轮廓多边形近似)。 +- **A(i,j)**:**S_j 的凸包**与 **S_i** 的交集面积,再除以 **area(S_i)**(即相对 **S_i** 的“被 j 覆盖比例”类度量;具体定义与 Law & Kang 原文保持一致即可)。 +- **D(i,j) = A(i,j) − A(j,i)**:非对称,用于定向 **i 相对于 j** 的前后关系。 +- **V(i,j)**:用于破环的**凸包对称差**相关能量(原文定义用于消解不一致的环)。 + +--- + +## 4. 推荐方案详细描述(方法 E:混合) + +**思路**: + +1. 用**快速、可解释**的规则锁定**背景**(贴四边 + 面积等),置于**最底层**。 +2. 对其余形状,仅在**共享边界像素(邻接)**的形状对之间计算 **D(i,j)**,构建**有向图**。 +3. **δ**:仅当 **|D(i,j)|** 足够大时加边,抑制噪声。 +4. **环检测与破环**:DFS 或 Tarjan 等发现环;按 **V(i,j)** 最大的边删除(启发式)。 +5. **拓扑排序**得到线性序;将背景插入**底部**。 + +### 推荐伪代码(Method E) + +``` +算法:HybridDepthOrder(形状集合 S,图像边界 B,阈值 δ) + +1. 从栅格标签图提取形状层(每色或每连通分量)→ 形状集合 S = {s_1, …, s_N} + +2. 识别背景候选: + - 在「与图像四条边界均接触」的形状中, + 取面积最大者作为 background(若无则退化为「贴边面积最大」等规则) + +3. 构建邻接表: + - 对标签图做四邻/八邻扫描,若 label[i] ≠ label[j] 且二者均非忽略值, + 则记录无序对 (label[i], label[j]) 为相邻形状对 + +4. 有向图 G = (V, E),V = S \ {background 可选临时排除或仅后插入} + +5. 对每一对相邻形状 (i, j),i < j(去重): + a. 取形状像素集合 S_i, S_j(或代表多边形) + b. H_i ← ConvexHull(S_i),H_j ← ConvexHull(S_j) // 可用 cv::convexHull + c. A(i,j) ← Area( H_j ∩ S_i ) / Area(S_i) + A(j,i) ← Area( H_i ∩ S_j ) / Area(S_j) + d. D(i,j) ← A(i,j) − A(j,i) + e. 若 D(i,j) > δ:添加有向边 i → j(i 在 j 之下) + 若 D(i,j) < −δ:添加有向边 j → i + 若 |D(i,j)| ≤ δ:不加边(或记录为平局,后续用面积/ID 破平) + +6. 检测 G 中的环(DFS 着色或 Tarjan SCC) + +7. 对每个环: + - 在环上找边 (u,v) 使 V(u,v) 最大(凸包对称差等,按 Law & Kang 定义实现) + - 删除该边 + +8. 对无环图 G 做拓扑排序 → 序列为 L + +9. 输出深度序:[background] ++ L(background 在最前,即最底层) + +10. 若拓扑排序失败(仍有环):回退到「面积从大到小」或用户策略,并记录警告 +``` + +--- + +## 5. 关键实现细节 + +### 5.1 邻接检测(adjacency detection) + +- 在**标签图**上单次扫描:对每个像素查看邻居标签,用哈希集合存储规范化后的标签对 `(min(label), max(label))`,或排序去重收集**无序**相邻对。 +- 注意**八邻**会引入更多“对角接触”邻接;是否与四邻一致需与边界定义统一。 +- **小碎片噪声**:可先形态学或最小面积过滤,减少伪邻接。 + +### 5.2 凸包与面积交(convex hull computation) + +- 使用 **OpenCV**:`cv::convexHull` 输入点集(形状边界像素或抽稀轮廓点),再 `cv::intersectConvexConvex` 或使用多边形近似计算 **H_j ∩ S_i**(若直接用像素掩膜更稳,可对凸包多边形填充后与 **S_i** 掩膜求交)。 +- **数值**:面积用像素计数或 `cv::contourArea`;注意 **A(i,j)** 分母为 **area(S_i)**,避免除零(极小形状可跳过或合并)。 + +### 5.3 环检测(cycle detection via DFS) + +- **DFS 三色标记**检测后向边以报告环;或对整个图做**拓扑排序**(Kahn 算法),若无法输出 **|V|** 个顶点则存在环。 +- **破环**:按伪代码在环上选 **V(i,j)** 最大的边删除;需缓存每条无向对的 **V** 值。 + +### 5.4 阈值 δ 与平局 + +- **δ** 过小:噪声边增多,环增多。 +- **δ** 过大:缺边,排序不唯一,需 **tie-break**(如面积较大者更靠下、或按形状 ID)。 + +--- + +## 6. 与现有代码的复用关系 + +| 能力 | 现状 | +|------|------| +| **OpenCV 凸包 / 轮廓面积** | 项目已依赖 OpenCV;可在新模块中直接调用 `cv::convexHull` 等。 | +| **标签图与连通域** | 分割与轮廓管线已产生标签或区域,可接出邻接表。 | +| **独立深度排序实现** | **当前仓库无**专门深度排序模块;本专题为 **V2 新增算法设计** 的说明文档。 | + +--- + +## 7. 开放问题 + +1. **δ 调参**:不同分辨率与颜色数下 **δ** 的默认与自适应策略(如按 **D** 的分位数裁剪)。 +2. **性能**:形状层数量很大时,邻接对数量与凸包/交集次数的上界需 profiling;是否需 **R-tree** 或 **栅格稀疏**优化。 +3. **与 Law & Kang 全文一致**:**V(i,j)** 与 **A(i,j)** 的精确定义以实现级对齐原文,避免符号漂移。 +4. **失败可见性**:当图退化为“全相邻对平局”时,是否暴露 **确定性** tie-break(便于调试与回归测试)。 + +--- + +## 参考文献与内部链接 + +- Law & Kang 等(2024):*Image Vectorization with Depth* — `../02-literature/vectorization-with-depth.md` +- 层叠 vs 剪切总体方案:`stacking-vs-cutout.md` diff --git a/docs/research/03-algorithm-deep-dive/path-optimization.md b/docs/research/03-algorithm-deep-dive/path-optimization.md new file mode 100644 index 0000000..b23bbd8 --- /dev/null +++ b/docs/research/03-algorithm-deep-dive/path-optimization.md @@ -0,0 +1,203 @@ +# 路径优化与形状合并 + +## 1. 问题定义与约束 + +**问题 A(路径简化 / 优化)**:在曲线拟合阶段已得到分段三次贝塞尔(或折线)之后,希望在**可控几何误差**下减少段数、降低 SVG 路径数据量,并消除冗余控制点(例如近直线仍用完整三次表示)。 + +**问题 B(同色形状合并)**:在输出 SVG 前,将多个**填充色相同**且**在视觉与层叠语义上可合并**的 `VectorizedShape` 合并为更少的路径元素,减少 DOM 节点与重复属性,同时**不引入错误的遮挡关系**。 + +**约束**: + +- 路径优化必须在用户可配置的**误差预算**内工作;默认应**保守**,避免肉眼可见形变。 +- 合并策略须与**绘制顺序(z-order / 深度)**一致:合并后单一路径内的子路径顺序须与原视觉一致。 +- 实现复杂度需与管线其余阶段匹配;**两遍式**(轻量后处理 + 可选进阶合并)优于单次巨型全局优化。 +- 与现有 **Schneider 拟合**、**Potrace 追踪** 输出兼容:优化是**后处理**,不宜破坏闭合性、绕向与端点连续性(除非显式重参数化)。 + +--- + +## 2. 本专题检索记录 + +### 检索关键词(keywords searched) + +- `bezier simplification` +- `SVG path optimization` +- `SVGO mergePaths` +- `kurbo simplify` +- `shape merging z-order` + +### 检索到的材料与结论要点 + +| 方向 | 材料 / 工具 | 要点 | +|------|----------------|------| +| 工业 SVG 压缩 | SVGO 插件(`mergePaths`、`convertPathData` 等) | 舍入坐标、扁平三次改直线、同向线段合并;**工程实用**,误差模型不统一。 | +| 高质量贝塞尔简化 | Raph Levien,kurbo(2023 博客 + Rust 库) | **Green 定理** 面积/矩度量;`ParamCurveFit`;近最优三次拟合;可 **重采样 + 重拟合** 简化已有路径。 | +| 经典拟合 | Schneider 算法(本库 V1 已用) | 逐点误差、一次通过;**无**全局段间合并。 | +| 多边形简化 | Douglas–Peucker | 对**控制点序列**直接套用会**过度**简化曲线几何。 | +| 形状合并语义 | SVG 规范、常见编辑器导出 | `mergePaths` 通常要求 **DOM 相邻** 且属性一致;**z-order** 决定实际遮挡。 | + +--- + +## 3. 候选方案对比表(总览) + +| 层级 | 方案 | 作用 | 主要风险 | +|------|------|------|----------| +| **路径** | 近线性段合并 | 三次 → 直线或等价退化表示 | 容差过大时拐角变钝 | +| **路径** | 相邻段最小二乘重拟合 | 两段并一段,减段数 | 实现与误差度量需仔细设计 | +| **路径** | kurbo 式重采样 + Green 矩拟合 | 高质量、可解析误差 | 需移植或 FFI,与现有栈集成成本 | +| **路径** | SVGO 式启发式 | 体量下降明显 | 与「数学误差」不对齐 | +| **形状** | V1:仅相邻同色 + bbox 不重叠 | 安全 | **过于保守**,合并率低 | +| **形状** | z-order 安全合并(同色、同深度层) | 平衡体量与正确性 | 需稳定 **深度层** 定义 | +| **形状** | 激进 bbox 不重叠合并 | 合并率更高 | **跨深度** 验证复杂,易出错 | + +以下分专题展开路径侧与形状侧,并给出推荐与伪代码。 + +--- + +## 专题 A:路径简化 / 优化(Path Simplification / Optimization) + +### A.1 各方法说明 + +| 方法 | 摘要 | +|------|------| +| **V1 当前** | **Schneider 拟合** + `merge_segment_tolerance` 驱动的**近线性段合并**(相邻段在容差内视为可合并为更简洁表示)。**一次性**管线,**无**专门的全路径后优化Pass。 | +| **近线性段合并(后处理 Pass)** | 若控制点 **p1、p2** 到弦 **p0–p3** 的最大距离 < **ε_linear**,将三次贝塞尔**替换为直线**(或标准参数化下等价的三次退化)。实现简单、副作用面小。 | +| **相邻段合并** | 若**连续两段**三次曲线可用**单条三次**在误差界内逼近,则合并;内部控制点可用 **Schneider 类最小二乘** 在合并后的采样点集上重拟合。 | +| **kurbo 思路(Raph Levien,2023)** | 用 **Green 定理** 计算面积/矩偏差;**ParamCurveFit** 抽象源曲线;**近最优** 贝塞尔拟合。对**已有**贝塞尔路径可先 **重采样** 再 **重拟合**,达到简化效果。 | +| **SVGO 思路** | 坐标舍入、扁平三次改直线、**同方向**线段合并等;偏**工程压缩**,**非**严格一致的几何误差模型。 | +| **Douglas–Peucker 作用于控制点** | 把控制点当折线顶点删除;对真实曲线形状**过于激进**,一般不单独用于贝塞尔路径。 | + +### A.2 路径方法对比表 + +| 方法 | 压缩比 | 质量 | 复杂度 | 实现难度 | +|------|--------|------|--------|----------| +| V1(Schneider + 近线性合并) | 中 | 高(拟合阶段已控误差) | 中(管线内) | 已具备 | +| 近线性后处理 Pass | 中–高(图依赖) | 高(阈值可控) | **低** | **低** | +| 相邻段最小二乘合并 | 高 | 高(依赖误差与采样) | 中–高 | 中 | +| kurbo / Green 矩 | 高 | **很高** | 高(解析矩、优化) | 高(Rust 移植或新实现) | +| SVGO 式启发式 | **很高** | 中–高(舍入可见) | 低 | 低 | +| Douglas–Peucker(控制点) | 很高 | **低–中**(易损曲率) | 低 | 低 | + +### A.3 推荐:两遍路径优化 + +1. **Pass 1:近线性合并** — 简单、安全、与现有容差语义一致。 +2. **Pass 2:相邻段重拟合** — 中等复杂度,**压缩比**通常明显优于仅 Pass 1。 + +### A.4 伪代码:两遍路径优化 + +``` +Pass 1: Near-linear merge + for each contour: + for each segment s (cubic Bezier p0,p1,p2,p3): + if max_distance(p1, p2, to chord line(p0, p3)) < epsilon_linear: + mark as linear + // 可选:写回为标准退化三次,便于下游统一 + p1 = p0 + (1/3) * (p3 - p0) + p2 = p0 + (2/3) * (p3 - p0) + +Pass 2: Adjacent segment merge + for each contour: + i = 0 + while i < segments.size() - 1: + merged = try_merge_cubic_pair(segments[i], segments[i+1], error_threshold) + if merged is not empty: + replace segments[i..i+1] with merged // 新的一段三次 + // 不增加 i:继续尝试与下一段合并 + else: + i++ +``` + +**`try_merge_cubic_pair` 要点**:在 **p0→p3(第一段)** 与 **p3→p6(第二段)** 共享端点处,对合并区间上的曲线采样点集执行 **Schneider 式或最小二乘单三次拟合**;若最大误差或积分型误差(可选:面积偏差)小于阈值则接受。 + +--- + +## 专题 B:同色形状合并(Same-Color Shape Merging) + +### B.1 各方法说明 + +| 方法 | 摘要 | +|------|------| +| **V1 当前:`MergeAdjacentSameColorShapes`** | 在**已排序**的形状序列上,仅合并**连续同色**填充形状,且要求各形状 **bbox 互不重叠**;遇重叠即终止当前 run。**保守**,合并机会少。 | +| **SVGO `mergePaths`** | 合并 **DOM 相邻**、**填充等属性相同** 的 ``;**不**解决任意 z-order 下的全局合并。 | +| **Z-order 安全合并** | 在层叠模型中,**同色**且处于**同一深度层**的所有形状,若在该层内无其他颜色插入其间,可合并为**单一路径**,内含多个子路径(`M…Z M…Z`)。 | +| **激进合并** | 即使深度不同,若**不存在**异色形状与待合并集合的 **bbox 发生遮挡意义上的相交**,则尝试合并;验证与实现复杂度更高。 | + +### B.2 形状合并策略对比表 + +| 方法 | 合并率 | 正确性风险 | 实现难度 | +|------|--------|------------|----------| +| V1 相邻 + bbox 不重叠 | 低 | **很低** | 低 | +| SVGO 式(仅 DOM 相邻) | 中(视导出顺序) | 低(若顺序与绘制一致) | 低 | +| **Z-order 安全、同深度** | **中–高** | **低**(定义清晰时) | 中 | +| 激进 bbox / 遮挡检验 | 高 | **高** | 高 | + +### B.3 推荐:同深度层上的 z-order 安全合并 + +在已有**线性绘制序**与**深度层**(或等价分组)的前提下,按 **(颜色, 深度层)** 分组;组内形状在 z-order 上**连续且无其他颜色穿插**时,合并为单个 `VectorizedShape`,轮廓表拼接。 + +### B.4 伪代码:按颜色与深度分组合并 + +``` +group shapes by (color_key, depth_level) +for each group G: + if G.shapes.size() <= 1: + continue + // 可选:检查 G 内形状在全局序中是否「连续且无异色穿插」 + if not z_order_contiguous_same_color(G): + continue + merged = new VectorizedShape + merged.color = G.representative_color + merged.contours = concatenate(all contours from shapes in G in z-order) + merged.area = sum(shape.area for shape in G) // 或按需仅保留最大块面积等策略 + replace G with single merged shape in output list +``` + +--- + +## 4. 推荐方案详细描述(汇总) + +**路径侧**:采用 **Pass 1 近线性** + **Pass 2 相邻三次合并**(失败则保持两段)。与 V1 兼容:`merge_segment_tolerance` 可映射为 **ε_linear** 或与 Pass 2 的 **error_threshold** 建立比例关系(需调参)。 + +**形状侧**:采用 **(颜色, 深度层)** 下的 **z-order 安全合并**,替代或扩展当前仅「相邻 + bbox 不重叠」的策略;**不**默认启用激进跨层合并,除非后续有完整遮挡证明与测试。 + +--- + +## 5. 关键实现细节 + +### 5.1 合并判据与误差度量 + +- **近线性**:**p1、p2** 到线段 **p0p3** 的**有符号距离**的最大绝对值(或 Hausdorff 距离上界)与 **ε_linear** 比较。 +- **相邻段合并**:对采样点 **{q_k}** 到候选单三次 **B(t)** 的 **最大欧氏距离**,或 **均方根误差**;进阶可采用 **kurbo 式面积偏差** 作为一致目标(实现成本更高)。 +- **阈值**:`ε_linear` 可与像素半宽、视图缩放挂钩;Pass 2 通常取 **≤ Pass 1** 或同量级,避免「先合并再劣化」。 + +### 5.2 闭合与开放曲线 + +- **闭合轮廓**:合并与简化后须保持 **闭合标志**;最后一段与第一段衔接处若合并,需保证 **C⁰**(位置连续),**C¹** 为可选目标。 +- **开放曲线**:端点 **不得** 被相邻段合并错误吸收;仅在**内部**断点尝试 `try_merge`。 +- **薄线 / 描边**(`is_stroke`):V1 对描边形状**不参与**同色块合并;路径优化也应区分 **fill 与 stroke**,避免把描边几何当填充简化。 + +--- + +## 6. 与现有代码的复用关系 + +| 组件 | 路径 | +|------|------| +| **可复用** | `src/curve/bezier.cpp` 等贝塞尔工具(求值、细分、距离上界);Schneider 拟合相关实现可作为 **try_merge** 中重拟合内核。 | +| **需替换或扩展** | `src/output/shape_merge.cpp` 中 **`MergeAdjacentSameColorShapes`** 的核心策略(当前过于保守);若引入深度层,需与 **`pipeline.cpp`** 中排序/合并调用顺序一致。 | +| **配置** | `VectorizerConfig::merge_segment_tolerance`(`include/neroued/vectorizer/config.h`)可扩展为分 Pass 容差,或保持单参数比例缩放。 | + +--- + +## 7. 开放问题 + +- **最优误差阈值**:屏幕分辨率、导出 DPI、抗锯齿宽度与 **ε** 的解析关系仍依赖**数据集调参**。 +- **路径优化 vs 视觉质量**:Pass 2 合并可能在**高曲率拐点**附近产生控制点漂移;需 **A/B 评估**(`eval/` 指标)与典型艺术图、图标集回归。 +- **与 SVG 数值精度**:舍入策略(SVGO 类)与 **几何误差** 联合优化时,**谁先谁后**影响结果稳定性。 +- **形状合并与 Cutout**:若存在镂空/裁剪语义,合并前须确认 **winding / 子路径方向** 与渲染器一致。 +- **kurbo 级面积矩**:若长期目标是「近最优」简化,是否引入 **Rust FFI** 或 **独立 C++ 移植** 需权衡维护成本。 + +--- + +## 参考文献与延伸阅读(仓库内) + +- `docs/research/02-literature/kurbo-bezier-simplify.md` — kurbo / Green 定理与 ParamCurveFit 摘要。 +- `docs/research/01-current-architecture-analysis.md` — 管线中与 `MergeAdjacentSameColorShapes`、`merge_segment_tolerance` 的衔接说明。 diff --git a/docs/research/03-algorithm-deep-dive/shape-extension.md b/docs/research/03-algorithm-deep-dive/shape-extension.md new file mode 100644 index 0000000..7cb5e72 --- /dev/null +++ b/docs/research/03-algorithm-deep-dive/shape-extension.md @@ -0,0 +1,136 @@ +# 形状延伸 / 凸化方案 + +## 1. 问题定义与约束 + +**问题**:在**层叠(stacking)**矢量表达中,下层形状 \(S_i\) 在视觉上被更前景的形状遮挡;若下层掩膜在遮挡边界处**不延伸**,则抗锯齿、子像素对齐或追踪误差可能产生**缝隙(gap)**与**露底(show-through)**。需要在**不破坏深度语义**的前提下,将被遮挡区域用**几何上可接受**的方式填满,使最终 SVG 叠放后与栅格观感一致。 + +**约束**: + +- **深度一致性**:扩展只能发生在「被更前景覆盖」的像素集合内,不能侵入仍应显示为上层颜色的区域。 +- **计算与实现**:方案应能在典型分辨率下以可接受的复杂度运行;优先复用成熟图像形态学与 OpenCV 算子。 +- **视觉质量**:扩展边界应尽量平滑,避免在遮挡区产生明显**色渗(color bleeding)**或锯齿状伪影。 +- **与 trapping 的关系**:印刷语境下的 **trapping**(陷印)通过微量叠色消除露白;本专题的「延伸」在屏幕矢量管线中可与之类比,但几何目标仍以**消除缝隙**为主。 + +--- + +## 2. 本专题检索记录 + +### 检索关键词(keywords searched) + +- `amodal completion` +- `shape convexification` +- `morphological dilation occluded region` +- `gap elimination vectorization` +- `layered vectorization shape extension` +- `Euler elastica inpainting occlusion` + +### 检索到的材料与结论要点 + +| 方向 | 内容摘要 | +|------|----------| +| 无模态补全(amodal completion) | 心理学与计算机视觉中讨论「被遮挡物体的完整形状推断」;与本工程「向遮挡区合理延伸」在目标上部分同构,但本库更关注**工程可实现的填充**而非认知真实形状。 | +| 形状凸化 / 凸包 | 用凸包或近似凸化扩展区域,几何简单,但**非凸物体**易过度延伸。 | +| 形态学膨胀 | 对掩膜做膨胀后与**遮挡掩膜**相交,经典、易实现;边界可能呈**离散核**引起的锯齿。 | +| 缝隙与矢量化 | 多篇矢量化与分层文献强调**深度顺序 + 几何修补**;Law & Kang(2024)等讨论**曲率连续**的延伸与 PDE/相场表述。 | + +--- + +## 3. 候选方案对比表 + +### 3.1 方法概要 + +**方法 A:不延伸(仅靠 trapping / 叠印类策略处理缝隙)** + +- **做法**:不对下层掩膜做显式几何扩展,依赖陷印、边界容差或渲染侧叠色消除可见缝。 +- **优点**:实现最简单,无额外掩膜运算。 +- **缺点**:在高对比边缘处仍易出现**可见色渗**或细缝,对纯矢量预览路径不友好。 + +**方法 B:向遮挡区域做形态学膨胀** + +- 对深度为 \(d_i\) 的形状 \(S_i\),令 + \[ + O_i = \bigcup_{j \,:\, \mathrm{depth}(S_j) < d_i} \mathrm{mask}_j + \] + 即「所有比 \(S_i\) **更靠后**(更底层)的形状」的并——若深度定义是「数值越大越前景」,则需相应改为「所有深度大于 \(d_i\) 的形状」的并;**工程上应与本项目深度约定一致**。下文按常见约定:**\(O_i\)** = **覆盖在 \(S_i\) 之上的所有形状的并**(即遮挡 \(S_i\) 的前景并集)。 +- **步骤**:对 \(\mathrm{mask}_i\) 做膨胀;结果与 \(O_i\) 求交;再与原始 \(\mathrm{mask}_i\) 合并。 +- **优点**:实现简单,复杂度约 **\(O(N \cdot W \cdot H)\)**(\(N\) 为层数,与实现方式有关)。 +- **缺点**:不遵循物体真实轮廓,遮挡区内边界可能**锯齿化**。 +- **建议**:**3×3 圆盘核**,**2~4 次**迭代(可按分辨率调参)。 + +**方法 C:凸包延伸** + +- 计算 \(S_i\) 掩膜的**凸包**区域 \(H_i\),取 \(\mathrm{extension} = (H_i \cap O_i) \setminus \mathrm{mask}_i\),再与 \(\mathrm{mask}_i\) 并。 +- **优点**:整体形状更**规整**,比纯膨胀更贴合物体**外轮廓趋势**。 +- **缺点**:对**非凸**物体可能**过度延伸**。 + +**方法 D:Euler 弹性能量 / 曲率驱动修补(Law & Kang 2024 一类)** + +- 最小化形如 + \[ + E(C_i) = \int_{C_i} \bigl(a + b\,\kappa^2\bigr)\,\mathrm{d}s + \] + 的能量,约束 \(S_i \subseteq C_i \subseteq O_i\)(或与 \(O_i\) 一致的允许区域),常配合 **Modica–Mortola 双阱势**与**相场**离散化。 +- **优点**:边界可做到**平滑、曲率连续**。 +- **缺点**:需 **PDE/优化求解器**,**慢**、集成成本高。 + +**方法 E:混合** + +- **多数形状**用**方法 B(膨胀)**;对**面积或周长超过阈值**的大块形状再用**方法 C(凸包 ∩ 遮挡)**或加强平滑。 +- **权衡**:在质量与实现量之间折中。 + +### 3.2 多维度对比(comparison table) + +| 方法 | 复杂度 | 缝隙消除质量 | 边界平滑度 | 实现难度 | 是否适合本项目当前阶段 | +|------|--------|----------------|------------|----------|--------------------------| +| A:不延伸 + trapping | 低 | 依赖参数,高对比边易失败 | 不涉及扩展边界 | 极低 | 仅作基线,不推荐作为主策略 | +| B:形态学膨胀 ∩ 遮挡 | \(O(N \cdot W \cdot H)\) 量级 | 好,可调迭代 | 中(核与迭代决定锯齿) | 低 | **推荐默认** | +| C:凸包 ∩ 遮挡 | 取决于凸包算法 | 好,大块更稳 | 中偏高(非凸时可能过伸) | 中 | 可选增强 | +| D:弹性能量 / 相场 | 高 | 很好 | 高 | 高 | 研究向,非默认 | +| E:混合 | 中高 | 很好 | 中高 | 中 | 产品化时可评估 | + +--- + +## 4. 推荐方案详细描述 + +**推荐默认采用方法 B(形态学膨胀 + 与遮挡区域求交)**,理由: + +1. 与 OpenCV 管线一致,**可复现、易调参**(核形状、迭代次数)。 +2. 复杂度可接受,且**不依赖** PDE。 +3. 通过 **reverse depth order(自下而上,先处理最底层形状)** 与正确的 \(O_i\) 定义,可保证扩展不覆盖错误深度。 + +**注意**:\(O_i\) 必须定义为「**遮挡 \(S_i\) 的前景形状掩膜的并**」。若文档前文写成「depth 小于 \(S_i\)」,请按你方深度编码改为「所有 depth **大于** \(d_i\) 的形状」或等价表述,并与实现一致。 + +--- + +## 5. 关键实现细节 + +- **处理顺序**:按**深度从后往前**(bottom → top),先扩展最底层,再处理上层,避免上层未定型时误用 \(O_i\)。 +- **核**:**圆盘形** 3×3(`cv::MORPH_ELLIPSE`,ksize 3)或等价,减少各向异性拉伸。 +- **迭代次数**:**2~4** 次为常用起点;分辨率越高或抗锯齿带宽越宽,可略增。 +- **后处理**:可对 `extended_mask_i` 做一次**轻量开闭运算**抑制孤立噪点(慎用,避免吃掉细线)。 + +### 推荐伪代码(方法 B) + +```text +for each shape S_i in reverse depth order (bottom to top): + O_i = union of masks of all shapes strictly above S_i (occluders of S_i) + dilated = morphological_dilate(mask_i, disk_kernel_3x3, iterations=3) + extension = dilated AND O_i AND NOT mask_i + extended_mask_i = mask_i OR extension +``` + +--- + +## 6. 与现有代码的复用关系 + +- **`cv::dilate`**:直接用于灰度/二值掩膜的膨胀。 +- **`cv::getStructuringElement(cv::MORPH_ELLIPSE, Size(3,3))`**:生成推荐圆盘核。 +- 深度与掩膜若已由 **segment / trace / output** 管线产出,本步作为**层叠输出前**的掩膜修正步骤接入即可。 + +--- + +## 7. 开放问题 + +- **最优膨胀量**:与**图像分辨率、边界宽度、追踪容差**联合相关,是否需要**按边长或梯度强度自适应**迭代次数。 +- **极细遮挡区域**:狭窄缝隙内膨胀可能**填满或断裂**,是否需要**骨架或距离变换**辅助判断是否放弃延伸。 +- **与方法 C/E 的切换准则**:仅按面积阈值是否足够,或需结合**周长凸度、实心度**等形状描述子。 diff --git a/docs/research/03-algorithm-deep-dive/stacking-vs-cutout.md b/docs/research/03-algorithm-deep-dive/stacking-vs-cutout.md new file mode 100644 index 0000000..1b6afa5 --- /dev/null +++ b/docs/research/03-algorithm-deep-dive/stacking-vs-cutout.md @@ -0,0 +1,133 @@ +# 层叠模型(Stacking)与剪切模型(Cutout)对比 + +## 1. 问题定义与约束 + +**问题**:将栅格图像矢量化时,如何用一组闭合路径表示平面上的颜色区域,使得视觉上无裂缝、可编辑、且路径数量与文件体积可控? + +**剪切模型(Cutout)**假定:各形状**铺满**图像平面,相邻区域**共享边界**;每个形状由**外轮廓**与可选的**孔洞(holes)**描述,孔洞对应“被挖掉”的更前景区域。该模型等价于对平面做**分割**(partition),每个像素恰好属于一个顶层形状(或由其外轮廓—孔洞层次表达)。 + +**层叠模型(Stacking)**假定:形状按**自底向上**的深度顺序绘制,下层可被上层**覆盖**;在遮挡区域,**允许**下层形状延伸到被遮挡处(无需孔洞),视觉上只显示最上层颜色。该模型等价于**有序图层**的叠加,通常**不**要求每个形状无重叠地铺满全图。 + +**约束与工程权衡**: + +- **拓扑正确性**:剪切模型天然“水密”(watertight),共享边界处不易出现像素级缝隙;层叠模型依赖绘制顺序与扩展策略,需避免“露底”类视觉错误。 +- **路径复杂度**:孔洞与复杂共享边界会显著增加路径段数;层叠往往可用更少、更简单的闭合曲线表达同一视觉效果。 +- **管线依赖**:剪切模型强依赖**边界图 / 轮廓装配**与**孔洞传播**;层叠模型强依赖**深度排序**与**被遮挡区域的形状扩展**(或显式裁剪策略)。 + +--- + +## 2. 本专题检索记录 + +### 检索关键词(keywords searched) + +- `vectorization stacking vs layers`, `cutout vs layered SVG`, `vector magic segmentation` +- `VTracer stacked mode`, `color_trace --stack`, `potrace color layers` +- `image vectorization depth ordering`, `watertight vectorization holes` +- `Adobe Image Trace` early approaches, `shared boundary vectorization` +- `Stack Exchange` / `Graphic Design` vectorization points comparison(用于路径数量案例) + +### 检索到的材料与结论要点(materials found) + +| 来源类型 | 内容摘要 | +|----------|----------| +| 开源工具文档 | **VTracer** 提供层叠相关策略(如 `--stacked`),强调分层与简化路径;**color_trace** 提供 `--stack` 等选项,与逐层着色、Potrace 类追踪管线相关。 | +| 学术文献 | **Image Vectorization with Depth**(Law & Kang 等,2024,arXiv)讨论深度、形状凸化与层叠式 SVG 表达,与“有序层 + 遮挡处理”一致。 | +| 业界产品(公开资料) | **Vector Magic**、早期 **Adobe Trace** 类工具常采用“区域分割 + 共享边界 + 孔洞”的剪切思路,以保证区域互不重叠且边界一致。 | +| 社区讨论 | **Stack Exchange(Graphic Design 等)** 上存在同一图像分别用“布尔剪切”与“层叠绘制”矢量化时**路径控制点数量**差异的讨论(例如剪切路径点更多、层叠更少的一类案例)。 | + +> 注:具体产品内部算法以未公开细节为主,表中为文献与文档层面的归纳。 + +--- + +## 3. 候选方案对比表 + +### 3.1 剪切模型(Cutout) + +- **定义**:形状铺满平面,共享边界;每形状 = 外轮廓 + 孔洞。 +- **典型使用**:本项目 **V1** 管线;**Vector Magic**;早期 **Adobe Image Trace** 思路(区域分割 + 轮廓)。 +- **优点**:**水密**(watertight),相邻区域边界一致,不易出现缝隙类瑕疵。 +- **缺点**:**边界图复杂**;**孔洞**增加路径数量与装配难度;边界/分割误差易沿共享边**传播**。 + +### 3.2 层叠模型(Stacking) + +- **定义**:自下而上图层顺序;遮挡处下层可延伸,**通常无孔洞**(或孔洞仅作局部优化)。 +- **典型使用**:**VTracer**(`--stacked`);**color_trace**(`--stack`);**Image Vectorization with Depth**(深度 + 层叠表达)。 +- **优点**:单形状往往更简单;**路径数更少**;无“缝隙”类问题(由覆盖保证);**层间相对独立**,利于误差隔离。 +- **缺点**:需要可靠的**深度排序**;被遮挡区域需**扩展**或等价几何处理;编辑时需理解图层语义。 + +### 3.3 多维度对比(comparison table) + +| 准则 | 剪切模型(Cutout) | 层叠模型(Stacking) | +|------|-------------------|----------------------| +| **路径数量** | 通常较高(共享边 + 孔洞边界) | 通常较低(重叠覆盖替代孔洞) | +| **文件体积** | 随孔洞与细分边界上升 | 往往更紧凑(路径更少、结构更简单) | +| **缝隙(gap)处理** | 水密,缝隙风险低 | 依赖顺序与扩展;正确排序后无“露底缝” | +| **实现复杂度** | 边界图、孔洞、一致性高 | 深度排序 + 遮挡扩展,模块不同 | +| **误差隔离** | 共享边易连锁误差 | 层间相对独立,单点失败影响面更小 | +| **孔洞处理** | 一阶概念,必须正确处理 | 可用覆盖避免显式孔洞 | +| **可编辑性** | 区域语义清晰,但孔洞编辑负担大 | 图层语义强,需深度与叠放理解 | + +### 3.4 具体案例(StackExchange 类参考) + +社区中常见一类对比:**同一张图**分别用“布尔/剪切式”与“层叠绘制式”矢量化时,**剪切方案**因外轮廓与内孔、多段共享边界,**控制点总数**可达约 **124**;**层叠方案**因下层大块覆盖、上层小块遮挡,**控制点总数**可降至约 **72**(数值来自公开讨论中的代表性例子,实际随图与参数变化)。 + +**解读**:差异主要来自是否用**孔洞 + 多段边界**表达挖空,而非必须用更多颜色层。 + +### 3.5 推荐结论 + +**推荐在 V2 中采用层叠模型(Stacking)作为主表达**,以换取更简单形状、更少路径与更好的误差隔离;同时需配套**深度排序**与**遮挡区域几何策略**(见 `depth-ordering.md`)。 + +--- + +## 4. 推荐方案详细描述(V2:层叠优先) + +**目标表示**:输出 SVG(或等价路径集合)时,按深度从底到顶排序多个 ``(或分组),每个路径为**无孔洞闭合曲线**(或极少孔洞),下层在视觉上可被上层完全覆盖。 + +**管线要点**: + +1. **颜色/区域层**:从分割或量化结果得到若干连通区域(或聚类后的色块)。 +2. **深度序**:得到线性顺序(见深度排序专题);**背景**置于最底层。 +3. **几何生成**:对每一层生成外轮廓;对将被遮挡的边界,采用**扩展**(morphological / offset)或文献中的**凸化**等策略,使下层在遮挡区仍闭合但不露出错误颜色(实现细节与 V2 具体算法绑定)。 +4. **输出**:按深度序写入 SVG,避免依赖孔洞表达大面积“挖空”。 + +**与 V1 剪切模型的关系**:V1 可继续作为对照基线或局部回退(例如极简单图仍可用剪切保证严格水密)。 + +--- + +## 5. 关键实现细节与边界情况 + +| 主题 | 说明 | +|------|------| +| **扩展过量** | 下层扩展过大可能侵入非邻接区域视觉;需限制扩展半径或与深度一致的裁剪。 | +| **颜色数多** | 层数多时节流路径简化与合并,避免 N 层全量 Potrace。 | +| **抗锯齿边界** | 栅格边界模糊时,深度与邻接判断需与子像素/边界图一致,避免排序抖动。 | +| **薄线/发丝** | 层叠可能把细结构归并到单一上层;需保留与 V1 薄线模块的协调策略。 | +| **编辑场景** | 导出后用户移动单层时,需保证无孔洞假设仍成立或提供“转为剪切”选项(产品层决策)。 | + +--- + +## 6. 与现有代码的复用关系 + +| 模块 | 复用方式 | +|------|----------| +| `src/segment/` | 颜色分割、超像素、区域合并 — 为“形状层”提供输入标签图。 | +| `src/boundary/`、`src/contour/` | 边界与链式轮廓 — 可用于邻接检测与轮廓提取;剪切模型下的孔洞装配逻辑在 V2 中可能**降级为可选**或对照路径。 | +| `src/trace/`(Potrace 等) | 逐层位图追踪 — 层叠模式下对**每层二值掩膜**追踪,复用现有追踪与简化。 | +| `src/output/svg_writer.cpp` | 输出时增加**按深度排序**的组或路径顺序即可复用大部分写盘逻辑。 | +| **新增** | **深度排序模块**(本项目尚无独立实现,见 `depth-ordering.md`)。 | + +--- + +## 7. 开放问题与待验证假设 + +1. **假设**:对典型 Logo/插画,层叠模型在**路径数与文件大小**上相对 V1 剪切模型有稳定优势 —— 需在 `eval/` 指标与固定数据集上**量化**。 +2. **假设**:边界 + 邻接信息足以支撑工业界可用的深度序 —— 需在**复杂遮挡与多背景孔**场景下做失败案例分析。 +3. **开放**:层叠扩展与 **ICC/色彩量化误差** 联合时是否引入可见边 —— 需视觉与 ΔE/边缘指标双重评估。 +4. **开放**:是否提供 **V1/V2 双模式** CLI/API,以及默认模式选择策略。 + +--- + +## 参考文献与内部链接 + +- 文献索引:`../02-literature/README.md` +- 深度排序算法专题:`depth-ordering.md` diff --git a/docs/research/04-implementation-plan.md b/docs/research/04-implementation-plan.md new file mode 100644 index 0000000..b316814 --- /dev/null +++ b/docs/research/04-implementation-plan.md @@ -0,0 +1,136 @@ +# 初步实施计划 + +## 1. 总体策略 + +V2 管线与 V1 **并行共存**,通过 `VectorizerConfig::pipeline_mode` 枚举切换。V1 代码**零修改**(除最小化的 dispatch 逻辑),所有新功能在独立文件中实现。 + +## 2. 里程碑 + +### M1:基础设施 + 层叠管线骨架(预计 2-3 周) + +**目标**:端到端跑通 V2 管线,可用 eval 框架对比 V1。 + +**任务**: +1. `config.h` 添加 `PipelineMode` 枚举(`V1`/`V2`),默认 `V1` +2. `vectorizer.cpp` 添加 dispatch 逻辑 +3. `pipeline.h` 添加 `RunPipelineV2` 声明 +4. 实现 `pipeline_v2.cpp` 管线编排骨架 +5. 实现形状层提取(`cv::connectedComponents`) +6. 实现深度排序(覆盖面积能量 + 有向图 + 拓扑排序) +7. 实现形状延伸(形态学膨胀到遮挡区域) +8. 逐层 Potrace 追踪(复用 `TraceMaskWithPotraceBezier`) +9. 按深度排序输出 SVG(复用 `WriteSvg`) +10. CMakeLists.txt 添加新源文件 + +**量化颜色 placeholder**:M1 暂用 V1 的 K-Means 分割作为颜色量化(直接调用 `BgrToLab` + OpenCV `kmeans`),在 M2 替换。 + +**验收标准**: +- V2 管线端到端可运行 +- eval 框架可对比 V1/V2 结果 +- 无缝隙(形状延伸有效) +- 形状数 ≤ V1(层叠模型无孔洞) + +### M2:感知色彩量化(预计 2-3 周) + +**目标**:用 OKLab + MMCQ 替换 placeholder 量化,提升颜色还原质量。 + +**任务**: +1. 实现 `src/quantize/oklab.h`:sRGB ↔ OKLab 转换 +2. 实现 `src/quantize/color_quantize.cpp`:Modified Median Cut Quantization +3. 支持 `num_colors = 0` 自动选择 +4. 集成到 `pipeline_v2.cpp` 替换 placeholder +5. 用 MergeSmallComponents 做量化后清理 + +**验收标准**: +- MMCQ 在标准测试图上 ΔE 优于 V1 的 SLIC+KMeans +- 自动颜色数选择可用 +- 量化速度 ≤ V1 分割时间 + +### M3:路径优化 + 形状合并(预计 2-3 周) + +**目标**:降低文件尺寸,提升路径紧凑度。 + +**任务**: +1. 实现 `src/curve/path_optimize.cpp`:近线性段合并 +2. 实现相邻段重拟合合并 +3. 实现碎片过滤(小面积形状合并到同色最近形状) +4. 实现 z-order 安全的同色形状合并(同深度层同色合并为单 ``) +5. 集成到 `pipeline_v2.cpp` + +**验收标准**: +- SVG 文件尺寸 ≤ V1 的 80% +- 路径段数显著减少 +- 视觉质量无退化(eval 指标不下降) + +## 3. 代码变更范围 + +### 修改的现有文件(4 个) + +| 文件 | 变更内容 | 变更量 | +|------|----------|--------| +| `include/neroued/vectorizer/config.h` | 添加 `PipelineMode` 枚举 + `pipeline_mode` 字段 | ~10 行 | +| `src/vectorizer.cpp` | 添加 dispatch `if (config.pipeline_mode == V2)` | ~5 行 | +| `src/pipeline.h` | 添加 `RunPipelineV2` 声明 | ~3 行 | +| `CMakeLists.txt` | 添加新 .cpp 到 target | ~8 行 | + +### 新增文件 + +| 阶段 | 文件 | 职责 | +|------|------|------| +| M1 | `src/pipeline_v2.cpp` | V2 管线编排 | +| M1 | `src/stacking/depth_order.h` / `.cpp` | 深度排序 | +| M1 | `src/stacking/shape_extend.h` / `.cpp` | 形状延伸 | +| M2 | `src/quantize/oklab.h` | OKLab 转换 | +| M2 | `src/quantize/color_quantize.h` / `.cpp` | MMCQ 量化 | +| M3 | `src/curve/path_optimize.h` / `.cpp` | 路径优化 | + +### 复用的现有模块 + +| 模块 | 复用方式 | +|------|----------| +| `src/preprocess/preprocess.cpp` | 直接调用 `PreprocessForVectorize` | +| `src/trace/potrace.cpp` | 直接调用 `TraceMaskWithPotraceBezier` | +| `src/curve/bezier.cpp` | 工具函数直接复用 | +| `src/output/svg_writer.cpp` | 调用 `WriteSvg`(可能需小幅调整无孔洞模式) | +| `src/segment/morphology.cpp` | `MergeSmallComponents` 用于量化后清理 | +| `src/detail/` | 工具函数直接复用 | +| `eval/` | 评估框架直接复用 | + +## 4. V1/V2 并行共存策略 + +```cpp +// config.h +enum class PipelineMode { V1, V2 }; + +struct VectorizerConfig { + PipelineMode pipeline_mode = PipelineMode::V1; + // ... 其他字段不变 +}; +``` + +```cpp +// vectorizer.cpp (dispatch) +auto result = (config.pipeline_mode == PipelineMode::V2) + ? detail::RunPipelineV2(prepared.bgr, config, prepared.opaque_mask) + : detail::RunPipeline(prepared.bgr, config, prepared.opaque_mask); +``` + +- V1 用户完全不受影响(默认 `V1`) +- V2 通过 `config.pipeline_mode = PipelineMode::V2` 启用 +- 两个管线共享预处理和 SVG 输出,中间阶段完全独立 +- Python 绑定和 CLI 工具需同步更新以暴露 `pipeline_mode` 参数 + +## 5. 测试与评估方案 + +- 使用现有 eval 框架(`evaluate_svg` CLI 工具)做 V1/V2 对比 +- 关键指标:PSNR、SSIM、ΔE mean/p95、coverage、overlap、edge_f1、文件尺寸 +- 建立包含多类图像的测试集(扁平色插画、像素艺术、简单照片、复杂图案) +- 每个里程碑结束时做全量对比测试 + +## 6. 依赖分析 + +V2 不引入任何新的外部依赖: +- OpenCV:已有,用于连通域分析、凸包、形态学操作 +- Potrace:已有,逐层追踪 +- Clipper2:已有(但 V2 可能不需要,形状延伸用 OpenCV 形态学替代) +- spdlog:已有,日志 diff --git a/docs/research/05-risk-assessment.md b/docs/research/05-risk-assessment.md new file mode 100644 index 0000000..8b7daff --- /dev/null +++ b/docs/research/05-risk-assessment.md @@ -0,0 +1,134 @@ +# 风险评估与缓解 + +## 1. 技术风险 + +### R1:深度排序不正确导致渲染错误 + +**严重程度**:高 +**概率**:中 + +**描述**:覆盖面积能量 D(i,j) 在某些场景下可能给出错误的深度关系,特别是: +- 同色相邻区域无法区分遮挡方向 +- 极细长形状的凸包面积比不稳定 +- 有向图存在无法正确打破的环 + +**缓解策略**: +1. 结合图像边界启发式(触边形状 → 背景层)作为 bootstrap +2. 设置 D(i,j) 的 delta 阈值,小于阈值的对视为无偏好,允许任意排序 +3. 环破除时使用对称差 V(i,j) 选最弱边,若仍不稳定则允许"同层" +4. 输出时记录深度排序的置信度,辅助调试 + +### R2:形状延伸过度膨胀导致 Potrace 追踪异常 + +**严重程度**:中 +**概率**:中 + +**描述**:膨胀后的 mask 可能产生不规则毛刺或噪声像素,导致 Potrace 生成大量冗余路径。 + +**缓解策略**: +1. 膨胀后做一次 3x3 中值滤波平滑边缘 +2. 限制最大膨胀量(不超过 5 个像素迭代) +3. 对延伸区域单独做连通域分析,过滤掉过小碎片 + +### R3:OKLab MMCQ 在特定图像上色彩聚类质量差 + +**严重程度**:中 +**概率**:低 + +**描述**:MMCQ 按方差分裂 color box,可能在色彩连续渐变的图像上产生不自然的色彩跳变。 + +**缓解策略**: +1. 引入 `min_cluster_size` 阈值,避免产生像素极少的颜色 +2. 量化后做 `MergeSmallComponents` 合并碎片区域 +3. 保留 V1 的 KMeans 作为回退方案 +4. 将自动颜色数选择基于图像色彩分布(方差 + 直方图峰值检测) + +### R4:路径优化后视觉质量退化 + +**严重程度**:中 +**概率**:低 + +**描述**:相邻段合并或近线性简化可能在曲率变化剧烈处产生可见的拟合误差。 + +**缓解策略**: +1. 严格的误差阈值控制(最大偏离 < 0.5 像素) +2. 仅在 eval 指标不下降时接受合并 +3. 优先合并长直线段(高压缩收益、低风险) + +## 2. 性能风险 + +### R5:深度排序的 O(N²) 成对比较导致大图变慢 + +**严重程度**:中 +**概率**:中(取决于颜色数和连通域数量) + +**描述**:N 个形状层需要 O(N²) 次凸包计算和面积比对。典型场景 N < 100,可以接受;但极端场景(如像素艺术 + 高颜色数)N 可能达到数千。 + +**缓解策略**: +1. 只对**相邻形状对**计算 D(i,j),非相邻形状无需比较 +2. 相邻关系通过一次扫描 labels 图 O(W×H) 即可获取 +3. 凸包计算使用 OpenCV 的 `cv::convexHull`(Graham scan O(m log m)) +4. 必要时做形状层合并预处理(同色且位于同一区域的小连通域先合并) + +### R6:逐层 Potrace 追踪比 V1 慢(需要多次调用) + +**严重程度**:低 +**概率**:低 + +**描述**:V2 每个形状层独立调用一次 Potrace,而 V1 在多色路径中多个标签共享边界。调用次数增加。 + +**缓解策略**: +1. Potrace 每次只追踪一个二值 mask,输入尺寸较小 +2. 层叠模型无孔洞,Potrace 路径更简单 +3. 可并行化:每层 Potrace 独立,可用 OpenMP +4. V1 本身也对覆盖不足的标签单独调 Potrace,差异不大 + +## 3. 质量风险 + +### R7:层叠模型下半透明区域处理不当 + +**严重程度**:低 +**概率**:低 + +**描述**:V1 已有 alpha 通道处理(`opaque_mask`),但层叠模型的形状延伸可能不适用于半透明区域。 + +**缓解策略**: +1. 透明区域标签 = -1 的逻辑保持不变 +2. 形状延伸时排除透明区域(不延伸到透明区域) +3. 半透明像素按 V1 方式处理(预混合或过滤) + +### R8:文件尺寸未达预期优化目标 + +**严重程度**:中 +**概率**:中 + +**描述**:虽然层叠模型消除了孔洞路径,但如果颜色数过多或路径优化不够激进,文件尺寸可能仍然较大。 + +**缓解策略**: +1. 颜色数自动选择时偏保守(宁少勿多) +2. 同色合并激进执行(同深度层所有同色形状合并) +3. SVG 输出优化:数值精度降到 1 位小数(当缩放比例允许时) +4. 添加文件尺寸作为 eval 指标,每次回归测试 + +## 4. 需要原型验证的关键假设 + +| 假设 | 验证方法 | 预计用时 | +|------|----------|----------| +| 覆盖面积能量 D(i,j) 在真实图像上给出正确深度排序 | 选取 10 张典型图,手动标注期望深度,对比算法输出 | 2-3 天 | +| 3 像素膨胀足以消除所有可见缝隙 | 在 10 张测试图上对比有/无膨胀的渲染结果 | 1 天 | +| MMCQ 在 OKLab 空间优于 V1 的 LAB KMeans | 在标准色彩量化测试集上做 PSNR/SSIM/ΔE 对比 | 2 天 | +| 层叠模型的 SVG 文件尺寸小于 V1 | 在 10 张测试图上对比文件尺寸 | 1 天 | +| 逐层 Potrace 性能可接受(不比 V1 慢超过 50%) | 在 10 张测试图上做端到端性能对比 | 1 天 | + +## 5. 风险优先级矩阵 + +| 风险 | 影响 | 概率 | 优先级 | +|------|------|------|--------| +| R1 深度排序错误 | 高 | 中 | **P1** | +| R5 深度排序性能 | 中 | 中 | P2 | +| R2 形状延伸异常 | 中 | 中 | P2 | +| R8 文件尺寸 | 中 | 中 | P2 | +| R3 色彩聚类质量 | 中 | 低 | P3 | +| R4 路径优化退化 | 中 | 低 | P3 | +| R6 Potrace 性能 | 低 | 低 | P4 | +| R7 半透明处理 | 低 | 低 | P4 | diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 0000000..a5eef87 --- /dev/null +++ b/docs/research/README.md @@ -0,0 +1,32 @@ +# V2 矢量化管线调研报告 + +本目录包含 neroued_vectorizer V2 管线架构的系统化调研报告,作为团队内部技术决策参考。 + +## 目录 + +| 文件 | 内容 | +|------|------| +| [00-executive-summary.md](00-executive-summary.md) | 总报告(执行摘要):问题定义、核心结论、推荐方案 | +| [01-current-architecture-analysis.md](01-current-architecture-analysis.md) | 现有 V1 架构深度诊断 | +| [02-literature/](02-literature/) | 参考文献深度分析 | +| [03-algorithm-deep-dive/](03-algorithm-deep-dive/) | 核心算法实现调研 | +| [04-implementation-plan.md](04-implementation-plan.md) | 初步实施计划 | +| [05-risk-assessment.md](05-risk-assessment.md) | 风险评估与缓解 | + +## 文献索引 + +详见 [02-literature/README.md](02-literature/README.md)。 + +## 算法专题索引 + +| 专题 | 文件 | +|------|------| +| 层叠 vs 剪切模型 | [stacking-vs-cutout.md](03-algorithm-deep-dive/stacking-vs-cutout.md) | +| 深度排序算法 | [depth-ordering.md](03-algorithm-deep-dive/depth-ordering.md) | +| 形状延伸 / 凸化 | [shape-extension.md](03-algorithm-deep-dive/shape-extension.md) | +| 颜色量化对比 | [color-quantization.md](03-algorithm-deep-dive/color-quantization.md) | +| 路径优化与合并 | [path-optimization.md](03-algorithm-deep-dive/path-optimization.md) | + +## 调研方法论 + +本调研采用迭代式检索驱动:已知材料只是起点,每个专题撰写前做专项检索,发现新论文/专利/开源实现后纳入分析。每篇文献分析末尾标注引用链线索,用于持续扩展材料范围。 diff --git a/eval/include/neroued/vectorizer/eval.h b/eval/include/neroued/vectorizer/eval.h index 0f601f0..535d118 100644 --- a/eval/include/neroued/vectorizer/eval.h +++ b/eval/include/neroued/vectorizer/eval.h @@ -44,6 +44,8 @@ struct PartialVectorizerConfig { std::optional merge_segment_tolerance; std::optional enable_antialias_detect; std::optional aa_tolerance; + std::optional pipeline_mode; + std::optional enable_depth_validation; /// Apply set fields onto \p base, returning the merged config. VectorizerConfig MergeInto(const VectorizerConfig& base) const; @@ -74,8 +76,10 @@ struct VectorizeMetrics { double overlap = 0; double delta_e_mean = 0; double delta_e_p95 = 0; + double delta_e_p99 = 0; double delta_e_max = 0; double border_delta_e_mean = 0; + double hue_coverage = 1.0; // Edge fidelity double edge_f1 = 0; @@ -156,8 +160,11 @@ struct ScoreWeights { double edge = 15; double efficiency = 15; double delta_e_ceiling = 40; + double delta_e_p95_ceiling = 80; + double p95_weight = 0.3; double overlap_penalty_weight = 0.15; double border_delta_e_weight = 0.3; + double hue_coverage_weight = 0.2; }; double ComputeScore(const VectorizeMetrics& m, const ScoreWeights& w = {}); diff --git a/eval/src/benchmark.cpp b/eval/src/benchmark.cpp index c214263..87fe1c4 100644 --- a/eval/src/benchmark.cpp +++ b/eval/src/benchmark.cpp @@ -19,7 +19,10 @@ namespace neroued::vectorizer { // ── Scoring ────────────────────────────────────────────────────────────────── double ComputeScore(const VectorizeMetrics& m, const ScoreWeights& w) { - double base_fidelity = std::max(0.0, 1.0 - m.delta_e_mean / w.delta_e_ceiling); + double mean_fidelity = std::max(0.0, 1.0 - m.delta_e_mean / w.delta_e_ceiling); + double p95_fidelity = std::max(0.0, 1.0 - m.delta_e_p95 / w.delta_e_p95_ceiling); + double base_fidelity = (1.0 - w.p95_weight) * mean_fidelity + w.p95_weight * p95_fidelity; + double border_factor = std::max(0.0, 1.0 - m.border_delta_e_mean / (w.delta_e_ceiling * 1.5)); double fidelity = (1.0 - w.border_delta_e_weight) * base_fidelity + w.border_delta_e_weight * border_factor; @@ -27,6 +30,9 @@ double ComputeScore(const VectorizeMetrics& m, const ScoreWeights& w) { double overlap_penalty = std::clamp(m.overlap, 0.0, 1.0); fidelity *= (1.0 - w.overlap_penalty_weight * overlap_penalty); + double hue_penalty = w.hue_coverage_weight * (1.0 - std::clamp(m.hue_coverage, 0.0, 1.0)); + fidelity *= (1.0 - hue_penalty); + double structure = std::clamp(m.ssim, 0.0, 1.0); double edge = std::clamp(m.edge_f1, 0.0, 1.0); double efficiency = std::clamp((1.0 - m.tiny_fragment_rate) * m.coverage, 0.0, 1.0); diff --git a/eval/src/pixel_metrics.cpp b/eval/src/pixel_metrics.cpp index c611e14..c5d7e71 100644 --- a/eval/src/pixel_metrics.cpp +++ b/eval/src/pixel_metrics.cpp @@ -3,7 +3,9 @@ #include #include +#include #include +#include #include namespace neroued::vectorizer::eval { @@ -78,20 +80,22 @@ double ComputeSsim(const cv::Mat& a, const cv::Mat& b, const cv::Mat& mask) { struct DeltaEStats { double mean = 0; double p95 = 0; + double p99 = 0; double max_val = 0; }; -DeltaEStats ComputeDeltaE(const cv::Mat& orig_bgr, const cv::Mat& rend_bgr, const cv::Mat& mask) { +DeltaEStats ComputeDeltaE(const cv::Mat& orig_bgr, const cv::Mat& rend_bgr, const cv::Mat& mask, + cv::Mat* out_orig_lab = nullptr, cv::Mat* out_de_map = nullptr) { cv::Mat orig_f, rend_f; orig_bgr.convertTo(orig_f, CV_32FC3, 1.0 / 255.0); rend_bgr.convertTo(rend_f, CV_32FC3, 1.0 / 255.0); - cv::Mat orig32, rend32; - cv::cvtColor(orig_f, orig32, cv::COLOR_BGR2Lab); - cv::cvtColor(rend_f, rend32, cv::COLOR_BGR2Lab); + cv::Mat orig_lab, rend_lab; + cv::cvtColor(orig_f, orig_lab, cv::COLOR_BGR2Lab); + cv::cvtColor(rend_f, rend_lab, cv::COLOR_BGR2Lab); cv::Mat diff; - cv::subtract(orig32, rend32, diff); + cv::subtract(orig_lab, rend_lab, diff); cv::Mat diff_sq; cv::multiply(diff, diff, diff_sq); @@ -113,7 +117,6 @@ DeltaEStats ComputeDeltaE(const cv::Mat& orig_bgr, const cv::Mat& rend_bgr, cons stats.mean = cv::mean(de)[0]; } - // p95 via partial sort (only over masked pixels) std::vector vals; vals.reserve(de.rows * de.cols); const uchar* mask_data = mask.empty() ? nullptr : mask.ptr(); @@ -125,14 +128,68 @@ DeltaEStats ComputeDeltaE(const cv::Mat& orig_bgr, const cv::Mat& rend_bgr, cons } } if (!vals.empty()) { + size_t idx99 = static_cast(vals.size() * 0.99); + if (idx99 >= vals.size()) idx99 = vals.size() - 1; + std::nth_element(vals.begin(), vals.begin() + static_cast(idx99), vals.end()); + stats.p99 = vals[idx99]; + size_t idx95 = static_cast(vals.size() * 0.95); if (idx95 >= vals.size()) idx95 = vals.size() - 1; std::nth_element(vals.begin(), vals.begin() + static_cast(idx95), vals.end()); stats.p95 = vals[idx95]; } + + if (out_orig_lab) *out_orig_lab = std::move(orig_lab); + if (out_de_map) *out_de_map = std::move(de); + return stats; } +double ComputeHueCoverage(const cv::Mat& orig_lab, const cv::Mat& de_map, const cv::Mat& mask) { + constexpr int kBins = 36; + constexpr double kBinWidth = 2.0 * std::numbers::pi / kBins; + constexpr double kMinBinFraction = 0.003; + constexpr double kMaxBinDeltaE = 15.0; + constexpr double kMinChroma = 5.0; + + std::array bin_count{}; + std::array bin_de_sum{}; + int total_chromatic = 0; + + const uchar* mask_ptr = mask.empty() ? nullptr : mask.ptr(); + for (int r = 0; r < orig_lab.rows; ++r) { + const auto* lab_row = orig_lab.ptr(r); + const float* de_row = de_map.ptr(r); + const uchar* m_row = mask_ptr ? mask.ptr(r) : nullptr; + for (int c = 0; c < orig_lab.cols; ++c) { + if (m_row && !m_row[c]) continue; + float a_star = lab_row[c][1]; + float b_star = lab_row[c][2]; + double chroma = std::sqrt(a_star * a_star + b_star * b_star); + if (chroma < kMinChroma) continue; + + double hue = std::atan2(static_cast(b_star), static_cast(a_star)); + if (hue < 0) hue += 2.0 * std::numbers::pi; + int bin = std::min(kBins - 1, static_cast(hue / kBinWidth)); + + bin_count[bin]++; + bin_de_sum[bin] += de_row[c]; + total_chromatic++; + } + } + + if (total_chromatic == 0) return 1.0; + + int significant = 0, covered = 0; + double threshold = total_chromatic * kMinBinFraction; + for (int i = 0; i < kBins; ++i) { + if (bin_count[i] < threshold) continue; + significant++; + if (bin_de_sum[i] / bin_count[i] < kMaxBinDeltaE) covered++; + } + return significant > 0 ? static_cast(covered) / significant : 1.0; +} + double ComputeBorderDeltaE(const cv::Mat& orig_bgr, const cv::Mat& rend_bgr, const cv::Mat& coverage, const cv::Mat& alpha_mask) { cv::Mat gray; @@ -199,11 +256,15 @@ PixelMetricsResult ComputePixelMetrics(const cv::Mat& original, const cv::Mat& r r.overlap = static_cast(cv::countNonZero(overlap_mask)) / static_cast(total_valid); - auto de = ComputeDeltaE(original, rendered, alpha_mask); + cv::Mat orig_lab, de_map; + auto de = ComputeDeltaE(original, rendered, alpha_mask, &orig_lab, &de_map); r.delta_e_mean = de.mean; r.delta_e_p95 = de.p95; + r.delta_e_p99 = de.p99; r.delta_e_max = de.max_val; + r.hue_coverage = ComputeHueCoverage(orig_lab, de_map, alpha_mask); + r.border_delta_e_mean = ComputeBorderDeltaE(original, rendered, coverage, alpha_mask); return r; diff --git a/eval/src/pixel_metrics.h b/eval/src/pixel_metrics.h index 11d544c..fffcc22 100644 --- a/eval/src/pixel_metrics.h +++ b/eval/src/pixel_metrics.h @@ -11,8 +11,10 @@ struct PixelMetricsResult { double overlap = 0; double delta_e_mean = 0; double delta_e_p95 = 0; + double delta_e_p99 = 0; double delta_e_max = 0; double border_delta_e_mean = 0; + double hue_coverage = 1.0; }; /// Compute pixel-level fidelity metrics. diff --git a/eval/src/svg_geometry.cpp b/eval/src/svg_geometry.cpp index 220571f..d36ee52 100644 --- a/eval/src/svg_geometry.cpp +++ b/eval/src/svg_geometry.cpp @@ -2,7 +2,6 @@ #include -#include #include #include @@ -51,55 +50,13 @@ cv::Mat FillShapeWithHoles(const std::vector>& contours, if (contours.empty() || width <= 0 || height <= 0) return cv::Mat::zeros(height, width, CV_8UC1); - struct ContourInfo { - size_t index; - double abs_area; - cv::Rect bbox; - bool is_hole = false; - }; - - std::vector infos; - infos.reserve(contours.size()); - for (size_t i = 0; i < contours.size(); ++i) { - if (contours[i].size() < 3) continue; - ContourInfo ci; - ci.index = i; - ci.abs_area = std::abs(PolylineSignedArea(contours[i])); - ci.bbox = cv::boundingRect(contours[i]); - infos.push_back(ci); - } - - std::sort(infos.begin(), infos.end(), - [](const ContourInfo& a, const ContourInfo& b) { return a.abs_area > b.abs_area; }); - - for (size_t i = 0; i < infos.size(); ++i) { - if (i == 0) { - infos[i].is_hole = false; - continue; - } - cv::Point center(infos[i].bbox.x + infos[i].bbox.width / 2, - infos[i].bbox.y + infos[i].bbox.height / 2); - bool found = false; - for (size_t j = 0; j < i; ++j) { - if (!(infos[i].bbox.x >= infos[j].bbox.x && infos[i].bbox.y >= infos[j].bbox.y && - infos[i].bbox.br().x <= infos[j].bbox.br().x && - infos[i].bbox.br().y <= infos[j].bbox.br().y)) - continue; - if (PointInPolyline(contours[infos[j].index], center)) { - infos[i].is_hole = !infos[j].is_hole; - found = true; - break; - } - } - if (!found) infos[i].is_hole = false; - } - - // Fill outers with 255, erase holes with 0 — processed largest-first - // so nested structures (bullseye, etc.) resolve correctly. cv::Mat result = cv::Mat::zeros(height, width, CV_8UC1); - for (auto& ci : infos) { - std::vector> single = {contours[ci.index]}; - cv::fillPoly(result, single, ci.is_hole ? cv::Scalar(0) : cv::Scalar(255)); + for (auto& contour : contours) { + if (contour.size() < 3) continue; + cv::Mat single = cv::Mat::zeros(height, width, CV_8UC1); + std::vector> wrap = {contour}; + cv::fillPoly(single, wrap, cv::Scalar(255)); + cv::bitwise_xor(result, single, result); } return result; } diff --git a/eval/src/svg_geometry.h b/eval/src/svg_geometry.h index 34bf20d..cabf8b4 100644 --- a/eval/src/svg_geometry.h +++ b/eval/src/svg_geometry.h @@ -10,10 +10,8 @@ double PolylineSignedArea(const std::vector& pts); double PolylinePerimeter(const std::vector& pts); bool PointInPolyline(const std::vector& poly, cv::Point pt); -/// Fill contours from a single SVG shape onto a binary mask, respecting holes -/// detected via geometric containment (even-odd nesting). -/// Contours are sorted by |area| descending; each contour contained inside -/// the nearest larger contour alternates between outer and hole. +/// Fill contours from a single SVG shape onto a binary mask using the even-odd +/// rule: each sub-path toggles the fill state via XOR. cv::Mat FillShapeWithHoles(const std::vector>& contours, int width, int height); diff --git a/eval/src/vectorize_eval.cpp b/eval/src/vectorize_eval.cpp index 9a93675..4e7a03d 100644 --- a/eval/src/vectorize_eval.cpp +++ b/eval/src/vectorize_eval.cpp @@ -60,6 +60,8 @@ VectorizerConfig PartialVectorizerConfig::MergeInto(const VectorizerConfig& base MERGE_FIELD(merge_segment_tolerance); MERGE_FIELD(enable_antialias_detect); MERGE_FIELD(aa_tolerance); + MERGE_FIELD(pipeline_mode); + MERGE_FIELD(enable_depth_validation); #undef MERGE_FIELD return out; } @@ -77,9 +79,11 @@ std::string VectorizeMetrics::ToJson(int indent) const { ss << ind << "\"overlap\": " << std::setprecision(4) << overlap << ",\n"; ss << ind << "\"delta_e_mean\": " << std::setprecision(2) << delta_e_mean << ",\n"; ss << ind << "\"delta_e_p95\": " << std::setprecision(2) << delta_e_p95 << ",\n"; + ss << ind << "\"delta_e_p99\": " << std::setprecision(2) << delta_e_p99 << ",\n"; ss << ind << "\"delta_e_max\": " << std::setprecision(2) << delta_e_max << ",\n"; ss << ind << "\"border_delta_e_mean\": " << std::setprecision(2) << border_delta_e_mean << ",\n"; + ss << ind << "\"hue_coverage\": " << std::setprecision(4) << hue_coverage << ",\n"; ss << ind << "\"edge_f1\": " << std::setprecision(4) << edge_f1 << ",\n"; ss << ind << "\"chamfer_distance\": " << std::setprecision(2) << chamfer_distance << ",\n"; ss << ind << "\"total_shapes\": " << total_shapes << ",\n"; @@ -460,8 +464,10 @@ ImageResult EvaluateSingleEntry(const ImageEntry& entry, const EvalConfig& confi m.overlap = px.overlap; m.delta_e_mean = px.delta_e_mean; m.delta_e_p95 = px.delta_e_p95; + m.delta_e_p99 = px.delta_e_p99; m.delta_e_max = px.delta_e_max; m.border_delta_e_mean = px.border_delta_e_mean; + m.hue_coverage = px.hue_coverage; m.edge_f1 = em.edge_f1; m.chamfer_distance = em.chamfer_distance; m.total_shapes = pm.total_shapes; diff --git a/eval/tests/test_vectorize_eval.cpp b/eval/tests/test_vectorize_eval.cpp index 86c2e82..6ecda4e 100644 --- a/eval/tests/test_vectorize_eval.cpp +++ b/eval/tests/test_vectorize_eval.cpp @@ -125,39 +125,42 @@ TEST(Scoring, PerfectScore) { VectorizeMetrics m; m.coverage = 1.0; m.delta_e_mean = 0; + m.delta_e_p95 = 0; m.border_delta_e_mean = 0; m.overlap = 0; m.ssim = 1.0; m.edge_f1 = 1.0; m.tiny_fragment_rate = 0; + m.hue_coverage = 1.0; double score = ComputeScore(m); - // fidelity = (0.7*1.0 + 0.3*1.0) * (1 - 0.15*0) = 1.0 -> 40*1 = 40 - // structure = 1.0 -> 30, edge = 1.0 -> 15, efficiency = 1.0 -> 15 - // total = 100 + // mean_f=1.0, p95_f=1.0 -> base=1.0, border=1.0 + // fidelity = 1.0 * (1-0) * (1-0) = 1.0 -> 40 + // structure=30, edge=15, efficiency=15 -> total=100 EXPECT_NEAR(score, 100.0, 0.01); } TEST(Scoring, DegradedScore) { VectorizeMetrics m; m.delta_e_mean = 20.0; + m.delta_e_p95 = 50.0; m.border_delta_e_mean = 30.0; m.overlap = 0.4; m.ssim = 0.5; m.edge_f1 = 0.7; m.coverage = 0.95; m.tiny_fragment_rate = 0.8; + m.hue_coverage = 0.75; ScoreWeights w; double score = ComputeScore(m, w); - // base_fidelity = max(0, 1 - 20/40) = 0.5 - // border_factor = max(0, 1 - 30/60) = 0.5 - // fidelity = (0.7*0.5 + 0.3*0.5) * (1 - 0.15*0.4) = 0.5 * 0.94 = 0.47 -> 40*0.47 = 18.8 - // structure = 0.5 -> 30*0.5 = 15.0 - // edge = 0.7 -> 15*0.7 = 10.5 - // efficiency = (1-0.8)*0.95 = 0.19 -> 15*0.19 = 2.85 - // total = 47.15 - EXPECT_NEAR(score, 47.15, 0.1); + // mean_f = 1-20/40 = 0.5, p95_f = 1-50/80 = 0.375 + // base_fidelity = 0.7*0.5 + 0.3*0.375 = 0.4625 + // border_factor = 1-30/60 = 0.5 + // fidelity = (0.7*0.4625 + 0.3*0.5) * (1-0.15*0.4) * (1-0.2*0.25) + // = 0.47375 * 0.94 * 0.95 = 0.42306 -> 40*0.42306 = 16.92 + // structure=15.0, edge=10.5, efficiency=2.85 -> total=45.27 + EXPECT_NEAR(score, 45.27, 0.1); EXPECT_GT(score, 0.0); EXPECT_LT(score, 100.0); } diff --git a/include/neroued/vectorizer/config.h b/include/neroued/vectorizer/config.h index e13f4cf..b93ab5a 100644 --- a/include/neroued/vectorizer/config.h +++ b/include/neroued/vectorizer/config.h @@ -5,8 +5,16 @@ namespace neroued::vectorizer { +/// Pipeline implementation selector. +enum class PipelineMode { + V1, ///< Original boundary-graph + cutout pipeline. + V2, ///< Stacking model: per-layer Potrace with depth ordering. +}; + /// Configuration for the vectorization pipeline. struct VectorizerConfig { + PipelineMode pipeline_mode = PipelineMode::V1; ///< Which pipeline implementation to use. + // ── Color segmentation ────────────────────────────────────────────────── int num_colors = 0; ///< K-Means palette size. 0 = auto-detect optimal count. int min_region_area = 50; ///< Force-merge regions smaller than this (pixels²). @@ -65,6 +73,9 @@ struct VectorizerConfig { float contour_simplify = 0.45f; ///< Contour simplification strength (larger => fewer nodes). bool enable_coverage_fix = true; ///< Patch uncovered pixels after vectorization. float min_coverage_ratio = 0.998f; ///< Minimum coverage ratio before patching. + + // ── Diagnostics ────────────────────────────────────────────────────────── + bool enable_depth_validation = false; ///< V2 only: run depth order validation (diagnostic). }; } // namespace neroued::vectorizer diff --git a/pyproject.toml b/pyproject.toml index 0d0f423..1f86c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Multimedia :: Graphics :: Graphics Conversion", ] dependencies = ["numpy>=1.21"] @@ -55,7 +56,7 @@ local_scheme = "node-and-date" # ── cibuildwheel ────────────────────────────────────────────────────────────── [tool.cibuildwheel] -build = "cp310-* cp311-* cp312-* cp313-*" +build = "cp310-* cp311-* cp312-* cp313-* cp314-*" skip = "*-win32 *-manylinux_i686 *-musllinux*" test-requires = ["pytest", "numpy"] test-command = "pytest {project}/python/tests -v" diff --git a/python/bindings.cpp b/python/bindings.cpp index 5eb3734..5d448e0 100644 --- a/python/bindings.cpp +++ b/python/bindings.cpp @@ -89,6 +89,14 @@ PYBIND11_MODULE(_core, m) { return os.str(); }); + // ── PipelineMode ────────────────────────────────────────────────────── + py::enum_(m, "PipelineMode", + "Pipeline implementation selector.\n\n" + "V1: Original boundary-graph + cutout pipeline.\n" + "V2: Stacking model with per-layer Potrace and depth ordering.") + .value("V1", PipelineMode::V1, "Original boundary-graph + cutout pipeline.") + .value("V2", PipelineMode::V2, "Stacking model: per-layer Potrace with depth ordering."); + // ── VectorizerConfig ───────────────────────────────────────────────────── auto cfg = py::class_( m, "VectorizerConfig", @@ -102,6 +110,10 @@ PYBIND11_MODULE(_core, m) { cfg.def(py::init<>(), "Create a config with default values."); + // Pipeline mode + cfg.def_readwrite("pipeline_mode", &VectorizerConfig::pipeline_mode, + "Pipeline implementation: PipelineMode.V1 (default) or PipelineMode.V2."); + // Color segmentation cfg.def_readwrite("num_colors", &VectorizerConfig::num_colors, "K-Means palette size. 0 = auto-detect optimal count."); @@ -177,12 +189,15 @@ PYBIND11_MODULE(_core, m) { "Patch uncovered pixels after vectorization."); cfg.def_readwrite("min_coverage_ratio", &VectorizerConfig::min_coverage_ratio, "Minimum coverage ratio before patching kicks in."); + cfg.def_readwrite("enable_depth_validation", &VectorizerConfig::enable_depth_validation, + "V2 only: run depth order validation (diagnostic)."); cfg.def("__repr__", [](const VectorizerConfig& c) { std::ostringstream os; - os << "VectorizerConfig(num_colors=" << c.num_colors << ", smoothness=" << c.smoothness - << ", curve_fit_error=" << c.curve_fit_error << ", detail_level=" << c.detail_level - << ", ...)"; + os << "VectorizerConfig(pipeline_mode=" + << (c.pipeline_mode == PipelineMode::V2 ? "V2" : "V1") << ", num_colors=" << c.num_colors + << ", smoothness=" << c.smoothness << ", curve_fit_error=" << c.curve_fit_error + << ", detail_level=" << c.detail_level << ", ...)"; return os.str(); }); diff --git a/python/neroued_vectorizer/__init__.py b/python/neroued_vectorizer/__init__.py index 06c799c..1457ae3 100644 --- a/python/neroued_vectorizer/__init__.py +++ b/python/neroued_vectorizer/__init__.py @@ -30,6 +30,7 @@ from importlib.metadata import PackageNotFoundError, version from neroued_vectorizer._core import ( + PipelineMode, Rgb, VectorizerConfig, VectorizerResult, @@ -43,6 +44,7 @@ __version__ = "0.0.0+unknown" __all__ = [ + "PipelineMode", "Rgb", "VectorizerConfig", "VectorizerResult", diff --git a/python/neroued_vectorizer/_core.pyi b/python/neroued_vectorizer/_core.pyi index 99921b0..cb99a15 100644 --- a/python/neroued_vectorizer/_core.pyi +++ b/python/neroued_vectorizer/_core.pyi @@ -32,6 +32,14 @@ class Rgb: def __eq__(self, other: object) -> bool: ... def __repr__(self) -> str: ... +class PipelineMode: + """Pipeline implementation selector.""" + + V1: PipelineMode + """Original boundary-graph + cutout pipeline.""" + V2: PipelineMode + """Stacking model: per-layer Potrace with depth ordering.""" + class VectorizerConfig: """Configuration for the vectorization pipeline. @@ -44,6 +52,10 @@ class VectorizerConfig: result = vectorize("image.png", cfg) """ + # ── Pipeline mode ───────────────────────────────────────────────────── + pipeline_mode: PipelineMode + """Pipeline implementation: PipelineMode.V1 (default) or PipelineMode.V2.""" + # ── Color segmentation ─────────────────────────────────────────────── num_colors: int """K-Means palette size. 0 = auto-detect optimal count.""" @@ -119,6 +131,8 @@ class VectorizerConfig: """Patch uncovered pixels after vectorization.""" min_coverage_ratio: float """Minimum coverage ratio before patching kicks in.""" + enable_depth_validation: bool + """V2 only: run depth order validation (diagnostic).""" def __init__(self) -> None: """Create a config with default values.""" diff --git a/src/boundary/aa_detector.cpp b/src/boundary/aa_detector.cpp index f7bd618..32de058 100644 --- a/src/boundary/aa_detector.cpp +++ b/src/boundary/aa_detector.cpp @@ -1,5 +1,7 @@ #include "aa_detector.h" +#include "detail/cv_utils.h" + #include #include @@ -10,13 +12,6 @@ namespace neroued::vectorizer::detail { namespace { -float LabDist(const cv::Vec3f& a, const cv::Vec3f& b) { - float dL = a[0] - b[0]; - float da = a[1] - b[1]; - float db = a[2] - b[2]; - return std::sqrt(dL * dL + da * da + db * db); -} - bool IsBoundaryPixel(const cv::Mat& labels, int r, int c) { int lbl = labels.at(r, c); if (lbl < 0) return false; diff --git a/src/boundary/boundary_graph.cpp b/src/boundary/boundary_graph.cpp index 0d9fd96..91d0473 100644 --- a/src/boundary/boundary_graph.cpp +++ b/src/boundary/boundary_graph.cpp @@ -40,38 +40,6 @@ int GetLabel(const cv::Mat& labels, int r, int c) { return labels.at(r, c); } -std::set UniqueLabels2x2(const cv::Mat& labels, int r, int c) { - std::set s; - for (int dr = 0; dr < 2; ++dr) { - for (int dc = 0; dc < 2; ++dc) { - int l = GetLabel(labels, r + dr, c + dc); - if (l >= 0) s.insert(l); - } - } - return s; -} - -bool IsJunction(const cv::Mat& labels, int r, int c) { - return UniqueLabels2x2(labels, r, c).size() >= 3; -} - -bool IsBoundaryVertex(const cv::Mat& labels, int r, int c) { - return UniqueLabels2x2(labels, r, c).size() >= 2; -} - -// Boundary vertices live on the dual grid at half-pixel positions. -// Vertex (r,c) in the dual grid corresponds to the corner between pixels -// (r,c), (r,c+1), (r+1,c), (r+1,c+1), i.e. position (c+0.5, r+0.5). -// But for boundaries along the image border, vertices can be at edges. -// -// We define boundary vertices as points between pixel centers where the -// label changes. We work on the "crack" grid: horizontal cracks between -// rows r and r+1, vertical cracks between columns c and c+1. -// -// A simpler approach: scan all horizontal and vertical pixel edges, -// mark those where labels differ, then find junctions (edge-grid vertices -// with 3+ incident boundary cracks) and trace chains between junctions. - struct CrackEdge { IVec2 v0, v1; int label_a, label_b; diff --git a/src/boundary/subpixel_refine.cpp b/src/boundary/subpixel_refine.cpp index 6416d3b..66720b8 100644 --- a/src/boundary/subpixel_refine.cpp +++ b/src/boundary/subpixel_refine.cpp @@ -1,5 +1,7 @@ #include "subpixel_refine.h" +#include "detail/cv_utils.h" + #include #include @@ -38,13 +40,6 @@ cv::Vec3f BilinearSampleLab(const cv::Mat& lab, float fx, float fy) { return p00 * w00 + p01 * w01 + p10 * w10 + p11 * w11; } -float LabDeltaE(const cv::Vec3f& a, const cv::Vec3f& b) { - float dL = a[0] - b[0]; - float da = a[1] - b[1]; - float db = a[2] - b[2]; - return std::sqrt(dL * dL + da * da + db * db); -} - } // namespace void RefineEdgesSubpixel(BoundaryGraph& graph, const cv::Mat& lab, @@ -118,9 +113,9 @@ void RefineEdgesSubpixel(BoundaryGraph& graph, const cv::Mat& lab, int max_j = 0; float max_gv = 0.0f; for (int j = 0; j < num_grads; ++j) { - float g = LabDeltaE(samples[static_cast(j)], - samples[static_cast(j + 1)]) / - step; + float g = + LabDist(samples[static_cast(j)], samples[static_cast(j + 1)]) / + step; grads[static_cast(j)] = g; if (g > max_gv) { max_gv = g; @@ -238,9 +233,9 @@ void RefineEdgesSubpixelAA(BoundaryGraph& graph, const cv::Mat& lab, const cv::M int max_j = 0; float max_gv = 0.0f; for (int j = 0; j < num_grads; ++j) { - float g = LabDeltaE(samples[static_cast(j)], - samples[static_cast(j + 1)]) / - step; + float g = + LabDist(samples[static_cast(j)], samples[static_cast(j + 1)]) / + step; grads[static_cast(j)] = g; if (g > max_gv) { max_gv = g; diff --git a/src/contour/assembly.cpp b/src/contour/assembly.cpp index 333ba13..090e2d6 100644 --- a/src/contour/assembly.cpp +++ b/src/contour/assembly.cpp @@ -12,17 +12,6 @@ namespace neroued::vectorizer::detail { namespace { -double PolylineSignedArea(const std::vector& pts) { - if (pts.size() < 3) return 0.0; - double acc = 0.0; - for (size_t i = 0; i < pts.size(); ++i) { - const Vec2f& a = pts[i]; - const Vec2f& b = pts[(i + 1) % pts.size()]; - acc += static_cast(a.x) * b.y - static_cast(b.x) * a.y; - } - return 0.5 * acc; -} - BezierContour MakeDegenerateBezierContour(const std::vector& pts, bool closed) { BezierContour bc; bc.closed = closed; @@ -38,41 +27,6 @@ BezierContour MakeDegenerateBezierContour(const std::vector& pts, bool cl return bc; } -BezierContour PointsToBezierContour(const std::vector& pts, bool closed, - const CurveFitConfig* fit_cfg) { - BezierContour bc; - bc.closed = closed; - if (pts.size() < 2) return bc; - - if (fit_cfg && pts.size() >= 3) { - auto fitted = - closed ? FitBezierToClosedPolyline(pts, *fit_cfg) : FitBezierToPolyline(pts, *fit_cfg); - if (!fitted.empty()) { - // Area-ratio sanity check for closed contours - bool valid = true; - if (closed) { - BezierContour tmp; - tmp.closed = true; - tmp.segments = fitted; - double bez_area = std::abs(BezierContourSignedArea(tmp)); - double poly_area = std::abs(PolylineSignedArea(pts)); - if (poly_area > 1.0 && (bez_area < poly_area * 0.3 || bez_area > poly_area * 3.0)) { - valid = false; - spdlog::debug("PointsToBezierContour fallback: invalid area ratio, points={}, " - "poly_area={:.3f}, bezier_area={:.3f}", - pts.size(), poly_area, bez_area); - } - } - if (valid) { - bc.segments = std::move(fitted); - return bc; - } - } - } - - return MakeDegenerateBezierContour(pts, closed); -} - bool PointInPolygon(const Vec2f& p, const std::vector& poly) { int n = static_cast(poly.size()); bool inside = false; @@ -138,67 +92,6 @@ void AppendEdgePoints(const BoundaryGraph& graph, const OrientedEdgeRef& ref, } } -std::vector> ChainEdgesIntoLoops(const BoundaryGraph& graph, - const std::vector& refs) { - std::vector> loops; - if (refs.empty()) return loops; - - std::unordered_map> node_to_refs; - for (int i = 0; i < static_cast(refs.size()); ++i) { - int sn = EdgeStartNode(graph, refs[i]); - node_to_refs[sn].push_back(i); - } - - std::vector used(refs.size(), false); - - for (int seed = 0; seed < static_cast(refs.size()); ++seed) { - if (used[seed]) continue; - - std::vector loop; - int cur = seed; - bool ok = true; - - while (true) { - if (used[cur]) { - ok = (cur == seed && !loop.empty()); - break; - } - used[cur] = true; - AppendEdgePoints(graph, refs[cur], loop, !loop.empty()); - - int end_node = EdgeEndNode(graph, refs[cur]); - auto it = node_to_refs.find(end_node); - if (it == node_to_refs.end()) { - ok = false; - break; - } - - int next = -1; - for (int ri : it->second) { - if (!used[ri]) { - next = ri; - break; - } - } - if (next < 0) { - // Check if we've closed the loop back to seed - if (EdgeStartNode(graph, refs[seed]) == end_node && !loop.empty()) { ok = true; } - break; - } - cur = next; - } - - if (!ok || loop.size() < 3) continue; - - // Remove duplicate closing point if present - if (loop.size() > 1 && (loop.front() - loop.back()).LengthSquared() < 1e-6f) { - loop.pop_back(); - } - if (loop.size() >= 3) { loops.push_back(std::move(loop)); } - } - return loops; -} - void DecimateNearCollinear(std::vector& pts, float epsilon) { constexpr int kMinPoints = 6; constexpr int kMaxPasses = 3; @@ -244,18 +137,6 @@ void DecimateNearCollinear(std::vector& pts, float epsilon) { } } -float LocalCurvature(const std::vector& pts, int i, int n) { - int im1 = ((i - 1) % n + n) % n; - int ip1 = (i + 1) % n; - Vec2f v1 = pts[i] - pts[im1]; - Vec2f v2 = pts[ip1] - pts[i]; - float len1 = v1.Length(); - float len2 = v2.Length(); - if (len1 < 1e-6f || len2 < 1e-6f) return 0.0f; - float cross = std::abs(v1.x * v2.y - v1.y * v2.x); - return cross / (len1 * len2); -} - void SmoothOpenChain(std::vector& pts, float max_displacement, int iterations) { if (pts.size() < 5) return; const int n = static_cast(pts.size()); @@ -274,50 +155,10 @@ void SmoothOpenChain(std::vector& pts, float max_displacement, int iterat } } -void SmoothClosedLoop(std::vector& pts, float max_displacement, int iterations) { - if (pts.size() < 5) return; - const int n = static_cast(pts.size()); - - constexpr float kHighCurvature = 0.5f; - - for (int iter = 0; iter < iterations; ++iter) { - std::vector prev_pts = pts; - std::vector smoothed(n); - for (int i = 0; i < n; ++i) { - int im2 = ((i - 2) % n + n) % n; - int im1 = ((i - 1) % n + n) % n; - int ip1 = (i + 1) % n; - int ip2 = (i + 2) % n; - smoothed[i] = - (pts[im2] + pts[im1] * 4.0f + pts[i] * 6.0f + pts[ip1] * 4.0f + pts[ip2]) * - (1.0f / 16.0f); - } - for (int i = 0; i < n; ++i) { - float curv = LocalCurvature(prev_pts, i, n); - float attenuation = (curv > kHighCurvature) ? std::max(0.1f, 1.0f - curv) : 1.0f; - float local_max = max_displacement * attenuation; - - Vec2f delta = smoothed[i] - prev_pts[i]; - float dist = delta.Length(); - if (dist > local_max) { smoothed[i] = prev_pts[i] + delta * (local_max / dist); } - } - pts = std::move(smoothed); - } -} - CubicBezier ReverseBezierSegment(const CubicBezier& seg) { return {seg.p3, seg.p2, seg.p1, seg.p0}; } -std::vector ReverseBezierChain(const std::vector& chain) { - std::vector rev; - rev.reserve(chain.size()); - for (auto it = chain.rbegin(); it != chain.rend(); ++it) { - rev.push_back(ReverseBezierSegment(*it)); - } - return rev; -} - struct EdgeRefLoop { std::vector refs; }; @@ -378,18 +219,6 @@ std::vector ChainEdgeRefsIntoLoops(const BoundaryGraph& graph, return loops; } -double BezierChainSignedArea(const std::vector& segs) { - std::vector pts; - pts.reserve(segs.size() * 4); - for (const auto& s : segs) { - pts.push_back(s.p0); - pts.push_back(s.p1); - pts.push_back(s.p2); - } - if (!segs.empty()) pts.push_back(segs.back().p3); - return PolylineSignedArea(pts); -} - } // namespace ContourSmoothConfig ContourSmoothFromLevel(float smoothness) { diff --git a/src/contour/assembly.h b/src/contour/assembly.h index 76efa8a..50ed694 100644 --- a/src/contour/assembly.h +++ b/src/contour/assembly.h @@ -6,7 +6,7 @@ #include "curve/bezier.h" #include "boundary/boundary_graph.h" #include "curve/fitting.h" -#include "output/svg_writer.h" +#include "detail/vectorized_shape.h" #include diff --git a/src/contour/thin_line.cpp b/src/contour/thin_line.cpp index 43ce7f8..d2ffebf 100644 --- a/src/contour/thin_line.cpp +++ b/src/contour/thin_line.cpp @@ -64,6 +64,39 @@ std::vector TracePath(const cv::Mat& skel, cv::Mat& visited, int sr, int return path; } +bool MakeStrokeShape(const std::vector& path, const cv::Mat& dist_map, const Rgb& color, + VectorizedShape& out) { + float total_width = 0.0f; + int width_samples = 0; + for (const auto& p : path) { + int pr = static_cast(p.y); + int pc = static_cast(p.x); + if (pr >= 0 && pr < dist_map.rows && pc >= 0 && pc < dist_map.cols) { + total_width += dist_map.at(pr, pc) * 2.0f; + ++width_samples; + } + } + float avg_width = width_samples > 0 ? total_width / static_cast(width_samples) : 1.0f; + avg_width = std::max(0.5f, avg_width); + + CurveFitConfig fit_cfg; + fit_cfg.error_threshold = 1.0f; + auto beziers = FitBezierToPolyline(path, fit_cfg); + if (beziers.empty()) return false; + + BezierContour contour; + contour.closed = false; + contour.segments = std::move(beziers); + + out.color = color; + out.is_stroke = true; + out.stroke_width = avg_width; + out.area = 0.0; + out.contours.clear(); + out.contours.push_back(std::move(contour)); + return true; +} + } // namespace std::vector ExtractStrokePaths(const cv::Mat& skeleton, const cv::Mat& dist_map, @@ -73,7 +106,6 @@ std::vector ExtractStrokePaths(const cv::Mat& skeleton, const c cv::Mat visited = cv::Mat::zeros(skeleton.size(), CV_8UC1); - // Find endpoints (1 neighbor) and start tracing from them std::vector endpoints; for (int r = 0; r < skeleton.rows; ++r) { for (int c = 0; c < skeleton.cols; ++c) { @@ -82,82 +114,21 @@ std::vector ExtractStrokePaths(const cv::Mat& skeleton, const c } } - // Trace from endpoints first for (const auto& ep : endpoints) { if (visited.at(ep.y, ep.x) != 0) continue; auto path = TracePath(skeleton, visited, ep.y, ep.x); if (static_cast(path.size()) < min_length) continue; - - // Estimate stroke width from distance transform - float total_width = 0.0f; - int width_samples = 0; - for (const auto& p : path) { - int pr = static_cast(p.y); - int pc = static_cast(p.x); - if (pr >= 0 && pr < dist_map.rows && pc >= 0 && pc < dist_map.cols) { - total_width += dist_map.at(pr, pc) * 2.0f; - ++width_samples; - } - } - float avg_width = - width_samples > 0 ? total_width / static_cast(width_samples) : 1.0f; - avg_width = std::max(0.5f, avg_width); - - CurveFitConfig fit_cfg; - fit_cfg.error_threshold = 1.0f; - auto beziers = FitBezierToPolyline(path, fit_cfg); - if (beziers.empty()) continue; - - BezierContour contour; - contour.closed = false; - contour.segments = std::move(beziers); - VectorizedShape shape; - shape.color = color; - shape.is_stroke = true; - shape.stroke_width = avg_width; - shape.area = 0.0; - shape.contours.push_back(std::move(contour)); - shapes.push_back(std::move(shape)); + if (MakeStrokeShape(path, dist_map, color, shape)) shapes.push_back(std::move(shape)); } - // Trace remaining (closed loops in skeleton) for (int r = 0; r < skeleton.rows; ++r) { for (int c = 0; c < skeleton.cols; ++c) { if (skeleton.at(r, c) == 0 || visited.at(r, c) != 0) continue; auto path = TracePath(skeleton, visited, r, c); if (static_cast(path.size()) < min_length) continue; - - float total_width = 0.0f; - int width_samples = 0; - for (const auto& p : path) { - int pr = static_cast(p.y); - int pc = static_cast(p.x); - if (pr >= 0 && pr < dist_map.rows && pc >= 0 && pc < dist_map.cols) { - total_width += dist_map.at(pr, pc) * 2.0f; - ++width_samples; - } - } - float avg_width = - width_samples > 0 ? total_width / static_cast(width_samples) : 1.0f; - avg_width = std::max(0.5f, avg_width); - - CurveFitConfig fit_cfg; - fit_cfg.error_threshold = 1.0f; - auto beziers = FitBezierToPolyline(path, fit_cfg); - if (beziers.empty()) continue; - - BezierContour contour; - contour.closed = false; - contour.segments = std::move(beziers); - VectorizedShape shape; - shape.color = color; - shape.is_stroke = true; - shape.stroke_width = avg_width; - shape.area = 0.0; - shape.contours.push_back(std::move(contour)); - shapes.push_back(std::move(shape)); + if (MakeStrokeShape(path, dist_map, color, shape)) shapes.push_back(std::move(shape)); } } diff --git a/src/contour/thin_line.h b/src/contour/thin_line.h index ffe0f53..d90a526 100644 --- a/src/contour/thin_line.h +++ b/src/contour/thin_line.h @@ -4,8 +4,7 @@ /// \brief Skeleton-based thin-line vectorization via Zhang-Suen thinning. #include "curve/bezier.h" -#include "segment/morphology.h" -#include "output/svg_writer.h" +#include "detail/vectorized_shape.h" #include diff --git a/src/curve/bezier.h b/src/curve/bezier.h index 14528e9..2ad10a0 100644 --- a/src/curve/bezier.h +++ b/src/curve/bezier.h @@ -13,27 +13,39 @@ struct CubicBezier { Vec2f p0, p1, p2, p3; }; -struct CurveSegment { - enum Type { BEZIER, CORNER }; - - Type type; - Vec2f p0, p1, p2, p3; -}; - struct BezierContour { std::vector segments; - bool closed = true; + bool closed = true; + bool is_hole = false; }; /// Evaluate a cubic Bezier curve at parameter t in [0,1]. Vec2f EvalBezier(const CubicBezier& b, float t); +inline Vec2f EvalBezier(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { + return EvalBezier(CubicBezier{p0, p1, p2, p3}, t); +} + /// Evaluate the first derivative of a cubic Bezier at parameter t. Vec2f EvalBezierDeriv(const CubicBezier& b, float t); +inline Vec2f EvalBezierDeriv(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { + return EvalBezierDeriv(CubicBezier{p0, p1, p2, p3}, t); +} + /// Evaluate the second derivative of a cubic Bezier at parameter t. Vec2f EvalBezierSecondDeriv(const CubicBezier& b, float t); +inline Vec2f EvalBezierSecondDeriv(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { + return EvalBezierSecondDeriv(CubicBezier{p0, p1, p2, p3}, t); +} + +/// Construct a degenerate-linear cubic Bezier with 1/3, 2/3 control points. +inline CubicBezier MakeLinearBezier(Vec2f a, Vec2f b) { + Vec2f d = b - a; + return {a, a + d * (1.0f / 3.0f), a + d * (2.0f / 3.0f), b}; +} + /// Flatten a cubic Bezier into a polyline (does NOT add p0). void FlattenCubicBezier(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float tolerance, std::vector& out); diff --git a/src/curve/fitting.cpp b/src/curve/fitting.cpp index 9b4e187..32b29ec 100644 --- a/src/curve/fitting.cpp +++ b/src/curve/fitting.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include namespace neroued::vectorizer::detail { @@ -12,12 +11,6 @@ namespace { constexpr float kDegToRad = 3.14159265358979323846f / 180.0f; -float AngleBetween(Vec2f a, Vec2f b) { - float dot = a.Dot(b); - float cross = a.Cross(b); - return std::atan2(cross, dot); -} - Vec2f EstimateTangent(const std::vector& pts, int idx, bool forward) { int n = static_cast(pts.size()); if (n < 2) return {1.0f, 0.0f}; @@ -45,31 +38,6 @@ std::vector ChordLengthParameterize(const std::vector& pts) { return u; } -Vec2f BezierEval(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { - float s = 1.0f - t; - return p0 * (s * s * s) + p1 * (3.0f * s * s * t) + p2 * (3.0f * s * t * t) + p3 * (t * t * t); -} - -Vec2f BezierDerivEval(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { - float s = 1.0f - t; - Vec2f d1 = (p1 - p0) * 3.0f; - Vec2f d2 = (p2 - p1) * 3.0f; - Vec2f d3 = (p3 - p2) * 3.0f; - return d1 * (s * s) + d2 * (2.0f * s * t) + d3 * (t * t); -} - -Vec2f BezierDeriv2Eval(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) { - float s = 1.0f - t; - Vec2f d1 = (p2 - p1 * 2.0f + p0) * 6.0f; - Vec2f d2 = (p3 - p2 * 2.0f + p1) * 6.0f; - return d1 * s + d2 * t; -} - -CubicBezier MakeLinearBezier(Vec2f a, Vec2f b) { - Vec2f d = b - a; - return {a, a + d * (1.0f / 3.0f), a + d * (2.0f / 3.0f), b}; -} - CubicBezier FitSingleBezier(const std::vector& pts, const std::vector& u, Vec2f tan_left, Vec2f tan_right) { int n = static_cast(pts.size()); @@ -91,7 +59,7 @@ CubicBezier FitSingleBezier(const std::vector& pts, const std::vector& pts, const std::vector& u float max_err = 0.0f; split_idx = static_cast(pts.size()) / 2; for (int i = 1; i < static_cast(pts.size()) - 1; ++i) { - Vec2f p = BezierEval(bez.p0, bez.p1, bez.p2, bez.p3, u[i]); + Vec2f p = EvalBezier(bez.p0, bez.p1, bez.p2, bez.p3, u[i]); float err = (p - pts[i]).LengthSquared(); if (err > max_err) { max_err = err; @@ -140,9 +108,9 @@ float ComputeMaxError(const std::vector& pts, const std::vector& u void Reparameterize(const std::vector& pts, std::vector& u, const CubicBezier& bez) { for (int i = 0; i < static_cast(pts.size()); ++i) { float t = u[i]; - Vec2f b = BezierEval(bez.p0, bez.p1, bez.p2, bez.p3, t); - Vec2f d1 = BezierDerivEval(bez.p0, bez.p1, bez.p2, bez.p3, t); - Vec2f d2 = BezierDeriv2Eval(bez.p0, bez.p1, bez.p2, bez.p3, t); + Vec2f b = EvalBezier(bez.p0, bez.p1, bez.p2, bez.p3, t); + Vec2f d1 = EvalBezierDeriv(bez.p0, bez.p1, bez.p2, bez.p3, t); + Vec2f d2 = EvalBezierSecondDeriv(bez.p0, bez.p1, bez.p2, bez.p3, t); Vec2f diff = b - pts[i]; float num = diff.Dot(d1); @@ -495,29 +463,4 @@ void MergeNearLinearSegments(std::vector& segments, float tolerance segments = std::move(merged); } -int FitBezierOnGraph(BoundaryGraph& graph, const CurveFitConfig& cfg) { - int fallback_count = 0; - for (auto& edge : graph.edges) { - if (edge.points.size() < 3) { - ++fallback_count; - continue; - } - auto fitted = FitBezierToPolyline(edge.points, cfg); - bool has_real_curve = false; - for (const auto& b : fitted) { - Vec2f chord = b.p3 - b.p0; - float chord_len = chord.Length(); - if (chord_len < 1e-6f) continue; - float d1 = std::abs((b.p1 - b.p0).Cross(chord)) / chord_len; - float d2 = std::abs((b.p2 - b.p3).Cross(chord)) / chord_len; - if (d1 > 0.1f || d2 > 0.1f) { - has_real_curve = true; - break; - } - } - if (!has_real_curve && edge.points.size() >= 3) { ++fallback_count; } - } - return fallback_count; -} - } // namespace neroued::vectorizer::detail diff --git a/src/curve/fitting.h b/src/curve/fitting.h index 68415ad..631055c 100644 --- a/src/curve/fitting.h +++ b/src/curve/fitting.h @@ -4,7 +4,6 @@ /// \brief Schneider cubic Bezier fitting and corner detection for boundary edges. #include "bezier.h" -#include "boundary/boundary_graph.h" #include @@ -40,10 +39,6 @@ std::vector FitBezierToPolyline(const std::vector& pts, std::vector FitBezierToClosedPolyline(const std::vector& pts, const CurveFitConfig& cfg = {}); -/// Fit Bezier curves to all edges of a BoundaryGraph in-place. -/// Returns the total number of edges that fell back to polyline. -int FitBezierOnGraph(BoundaryGraph& graph, const CurveFitConfig& cfg = {}); - /// Merge consecutive near-linear Bezier segments into single curves. /// \param segments Bezier segment list (modified in-place). /// \param tolerance Max control-point deviation as fraction of chord length to consider linear. diff --git a/src/curve/path_optimize.cpp b/src/curve/path_optimize.cpp new file mode 100644 index 0000000..5919f35 --- /dev/null +++ b/src/curve/path_optimize.cpp @@ -0,0 +1,225 @@ +#include "path_optimize.h" + +#include + +#include +#include +#include + +namespace neroued::vectorizer::detail { + +namespace { + +float PointToSegmentDistance(const Vec2f& p, const Vec2f& a, const Vec2f& b) { + Vec2f ab = b - a; + float len2 = ab.LengthSquared(); + if (len2 < 1e-12f) return Vec2f::Distance(p, a); + float t = std::clamp((p - a).Dot(ab) / len2, 0.f, 1.f); + Vec2f proj = a + ab * t; + return Vec2f::Distance(p, proj); +} + +float MaxControlPointDeviation(const CubicBezier& seg) { + float d1 = PointToSegmentDistance(seg.p1, seg.p0, seg.p3); + float d2 = PointToSegmentDistance(seg.p2, seg.p0, seg.p3); + return std::max(d1, d2); +} + +bool IsNearLinear(const CubicBezier& seg, float eps) { return MaxControlPointDeviation(seg) < eps; } + +std::vector SampleBezier(const CubicBezier& b, int n) { + std::vector pts; + pts.reserve(n); + for (int i = 0; i < n; ++i) { + float t = static_cast(i) / (n - 1); + pts.push_back(EvalBezier(b, t)); + } + return pts; +} + +float MaxDeviationFromBezier(const CubicBezier& candidate, const std::vector& samples) { + float max_d = 0.f; + for (const auto& p : samples) { + float best = 1e30f; + constexpr int kProbes = 16; + for (int i = 0; i <= kProbes; ++i) { + float t = static_cast(i) / kProbes; + float d = Vec2f::Distance(p, EvalBezier(candidate, t)); + best = std::min(best, d); + } + max_d = std::max(max_d, best); + } + return max_d; +} + +CubicBezier FitCubicToPoints(const std::vector& pts) { + if (pts.size() < 2) return {pts[0], pts[0], pts[0], pts[0]}; + + Vec2f p0 = pts.front(); + Vec2f p3 = pts.back(); + + if (pts.size() == 2) return MakeLinearBezier(p0, p3); + + const int n = static_cast(pts.size()); + std::vector params(n); + params[0] = 0.f; + float total_len = 0.f; + for (int i = 1; i < n; ++i) { + total_len += Vec2f::Distance(pts[i], pts[i - 1]); + params[i] = total_len; + } + if (total_len > 1e-8f) { + for (auto& p : params) p /= total_len; + } else { + for (int i = 0; i < n; ++i) params[i] = static_cast(i) / (n - 1); + } + + float a11 = 0, a12 = 0, a22 = 0; + Vec2f c1{}, c2{}; + + for (int i = 0; i < n; ++i) { + float t = params[i]; + float u = 1.f - t; + float b1 = 3.f * u * u * t; + float b2 = 3.f * u * t * t; + + Vec2f rhs = pts[i] - p0 * (u * u * u) - p3 * (t * t * t); + + a11 += b1 * b1; + a12 += b1 * b2; + a22 += b2 * b2; + c1 += rhs * b1; + c2 += rhs * b2; + } + + float det = a11 * a22 - a12 * a12; + Vec2f cp1, cp2; + if (std::abs(det) < 1e-10f) { + cp1 = Vec2f::Lerp(p0, p3, 1.f / 3.f); + cp2 = Vec2f::Lerp(p0, p3, 2.f / 3.f); + } else { + float inv_det = 1.f / det; + cp1 = (c1 * a22 - c2 * a12) * inv_det; + cp2 = (c2 * a11 - c1 * a12) * inv_det; + } + + return {p0, cp1, cp2, p3}; +} + +bool TryMergeSegments(const CubicBezier& a, const CubicBezier& b, float merge_eps, + CubicBezier& result) { + constexpr int kSamplesPerSeg = 12; + auto sa = SampleBezier(a, kSamplesPerSeg); + auto sb = SampleBezier(b, kSamplesPerSeg); + + std::vector all_pts; + all_pts.reserve(sa.size() + sb.size()); + all_pts.insert(all_pts.end(), sa.begin(), sa.end()); + for (size_t i = 1; i < sb.size(); ++i) all_pts.push_back(sb[i]); + + CubicBezier candidate = FitCubicToPoints(all_pts); + float err = MaxDeviationFromBezier(candidate, all_pts); + + if (err <= merge_eps) { + result = candidate; + return true; + } + return false; +} + +} // namespace + +void OptimizeBezierContour(BezierContour& contour, float linear_eps, float merge_eps) { + if (contour.segments.size() <= 1) return; + + // Pass 1: Collapse near-linear segments to line segments. + // Consecutive collinear segments are merged; corners are preserved. + { + std::vector pass1; + pass1.reserve(contour.segments.size()); + size_t i = 0; + while (i < contour.segments.size()) { + if (IsNearLinear(contour.segments[i], linear_eps)) { + Vec2f start = contour.segments[i].p0; + Vec2f end = contour.segments[i].p3; + Vec2f dir = (end - start).Normalized(); + size_t j = i + 1; + while (j < contour.segments.size() && + IsNearLinear(contour.segments[j], linear_eps)) { + Vec2f next_end = contour.segments[j].p3; + Vec2f next_dir = (next_end - end).Normalized(); + // Only merge if roughly collinear (dot product > 0.95). + if (dir.Length() > 1e-6f && next_dir.Length() > 1e-6f && + dir.Dot(next_dir) > 0.95f) { + end = next_end; + dir = (end - start).Normalized(); + ++j; + } else { + break; + } + } + float chord = Vec2f::Distance(start, end); + if (chord < 1e-4f) { + // Degenerate: keep the individual segments as lines instead. + for (size_t k = i; k < j; ++k) { + pass1.push_back( + MakeLinearBezier(contour.segments[k].p0, contour.segments[k].p3)); + } + } else { + pass1.push_back(MakeLinearBezier(start, end)); + } + i = j; + } else { + pass1.push_back(contour.segments[i]); + ++i; + } + } + contour.segments = std::move(pass1); + } + + // Pass 2: Try to merge adjacent segments by re-fitting. + // Chain-merge is capped to avoid a single cubic representing too many + // original segments, which causes visible dents on smooth arcs. + if (contour.segments.size() > 2 && merge_eps > 0.f) { + constexpr int kMaxChainLength = 3; + std::vector pass2; + pass2.reserve(contour.segments.size()); + size_t i = 0; + while (i < contour.segments.size()) { + CubicBezier current = contour.segments[i]; + ++i; + int chain_count = 1; + while (i < contour.segments.size() && chain_count < kMaxChainLength) { + CubicBezier merged; + if (TryMergeSegments(current, contour.segments[i], merge_eps, merged)) { + current = merged; + ++i; + ++chain_count; + } else { + break; + } + } + pass2.push_back(current); + } + contour.segments = std::move(pass2); + } +} + +void OptimizeShapePaths(std::vector& shapes, float linear_eps, float merge_eps) { + int total_before = 0, total_after = 0; + const int n = static_cast(shapes.size()); + +#pragma omp parallel for schedule(dynamic) reduction(+ : total_before, total_after) + for (int i = 0; i < n; ++i) { + for (auto& contour : shapes[i].contours) { + total_before += static_cast(contour.segments.size()); + OptimizeBezierContour(contour, linear_eps, merge_eps); + total_after += static_cast(contour.segments.size()); + } + } + spdlog::info( + "OptimizeShapePaths: segments {} -> {} ({:.1f}% reduction)", total_before, total_after, + total_before > 0 ? 100.0 * (1.0 - static_cast(total_after) / total_before) : 0.0); +} + +} // namespace neroued::vectorizer::detail diff --git a/src/curve/path_optimize.h b/src/curve/path_optimize.h new file mode 100644 index 0000000..14d46df --- /dev/null +++ b/src/curve/path_optimize.h @@ -0,0 +1,28 @@ +#pragma once + +/// \file path_optimize.h +/// \brief Two-pass Bezier path optimization: near-linear merging and adjacent segment re-fitting. + +#include "bezier.h" +#include "detail/vectorized_shape.h" + +#include + +namespace neroued::vectorizer::detail { + +/// Optimize a single BezierContour in-place. +/// +/// Pass 1: Near-linear segments (where control points are close to the chord) +/// are merged/collapsed to reduce node count. +/// Pass 2: Adjacent cubic segments are tentatively merged by least-squares +/// re-fitting; if the error is below \p merge_eps the merge is kept. +/// +/// \param contour The contour to optimize (modified in-place). +/// \param linear_eps Max deviation from chord to qualify as near-linear (pixels). +/// \param merge_eps Max re-fit error to accept a two-segment merge (pixels). +void OptimizeBezierContour(BezierContour& contour, float linear_eps, float merge_eps); + +/// Optimize all paths in a set of shapes. +void OptimizeShapePaths(std::vector& shapes, float linear_eps, float merge_eps); + +} // namespace neroued::vectorizer::detail diff --git a/src/detail/cv_utils.h b/src/detail/cv_utils.h index 67dc2a8..93ffb63 100644 --- a/src/detail/cv_utils.h +++ b/src/detail/cv_utils.h @@ -89,6 +89,17 @@ inline cv::Mat ExtractOpaqueMask(const cv::Mat& src, uint8_t alpha_threshold = 0 return mask; } +/// Squared Euclidean distance between two CIE L*a*b* colors (cv::Vec3f). +inline float LabDistSq(const cv::Vec3f& a, const cv::Vec3f& b) { + float dL = a[0] - b[0]; + float da = a[1] - b[1]; + float db = a[2] - b[2]; + return dL * dL + da * da + db * db; +} + +/// Euclidean distance between two CIE L*a*b* colors (cv::Vec3f). +inline float LabDist(const cv::Vec3f& a, const cv::Vec3f& b) { return std::sqrt(LabDistSq(a, b)); } + /// Convert a BGR (uint8) image to CIE L*a*b* (float32). /// Returns an empty Mat if input is empty. inline cv::Mat BgrToLab(const cv::Mat& bgr) { diff --git a/src/detail/vectorized_shape.h b/src/detail/vectorized_shape.h new file mode 100644 index 0000000..b2087f9 --- /dev/null +++ b/src/detail/vectorized_shape.h @@ -0,0 +1,23 @@ +#pragma once + +/// \file vectorized_shape.h +/// \brief Core VectorizedShape type used across vectorization modules. + +#include "curve/bezier.h" +#include + +#include + +namespace neroued::vectorizer::detail { + +struct VectorizedShape { + std::vector contours; + Rgb color; + double area = 0.0; + bool is_stroke = false; + float stroke_width = 0.0f; + + bool operator==(const VectorizedShape& o) const = delete; +}; + +} // namespace neroued::vectorizer::detail diff --git a/src/output/shape_merge.cpp b/src/output/shape_merge.cpp index f469a44..2da8baa 100644 --- a/src/output/shape_merge.cpp +++ b/src/output/shape_merge.cpp @@ -1,4 +1,4 @@ -#include "output/shape_merge.h" +#include "shape_merge.h" #include diff --git a/src/output/shape_merge.h b/src/output/shape_merge.h index 7183540..579525c 100644 --- a/src/output/shape_merge.h +++ b/src/output/shape_merge.h @@ -3,7 +3,7 @@ /// \file shape_merge.h /// \brief Shape bounding-box computation and Z-order-preserving same-color merging. -#include "output/svg_writer.h" +#include "detail/vectorized_shape.h" #include diff --git a/src/output/svg_writer.cpp b/src/output/svg_writer.cpp index ea84aa4..53db655 100644 --- a/src/output/svg_writer.cpp +++ b/src/output/svg_writer.cpp @@ -1,4 +1,4 @@ -#include "output/svg_writer.h" +#include "svg_writer.h" #include @@ -94,7 +94,7 @@ std::string ContoursToSvgPath(const std::vector& contours) { auto& contour = contours[ci]; if (contour.segments.empty()) continue; - if (ci == 0) { + if (!contour.is_hole) { s += BezierToSvgPath(contour); } else { auto& segs = contour.segments; diff --git a/src/output/svg_writer.h b/src/output/svg_writer.h index ecc2f25..80755da 100644 --- a/src/output/svg_writer.h +++ b/src/output/svg_writer.h @@ -3,24 +3,13 @@ /// \file svg_writer.h /// \brief SVG document generation from vectorized shapes. -#include "curve/bezier.h" -#include +#include "detail/vectorized_shape.h" #include #include namespace neroued::vectorizer::detail { -struct VectorizedShape { - std::vector contours; - Rgb color; - double area = 0.0; - bool is_stroke = false; - float stroke_width = 0.0f; - - bool operator==(const VectorizedShape& o) const = delete; -}; - /// Generate a complete SVG document string from vectorized shapes. /// /// Shapes are rendered in the order given (first = bottom layer). diff --git a/src/pipeline.cpp b/src/pipeline.cpp index 20852ca..f67d447 100644 --- a/src/pipeline.cpp +++ b/src/pipeline.cpp @@ -19,14 +19,16 @@ #include #include -#include - #include #include #include #include #include +#ifdef _OPENMP +# include +#endif + namespace neroued::vectorizer::detail { VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, @@ -48,13 +50,14 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, adaptive_smoothing_color = std::min(cfg.smoothing_color, 15.0f); adaptive_min_region = std::max(cfg.min_region_area, short_edge * short_edge / 50); adaptive_upscale_short_edge = std::min(cfg.upscale_short_edge, 400); - spdlog::info("Small image adaptation: short_edge={}, colors={}, smoothing=({:.0f},{:.0f}), " - "min_region={}, upscale_target={}", - short_edge, resolved_colors, adaptive_smoothing_spatial, - adaptive_smoothing_color, adaptive_min_region, adaptive_upscale_short_edge); + spdlog::info( + "V1: small image adaptation: short_edge={}, colors={}, smoothing=({:.0f},{:.0f}), " + "min_region={}, upscale_target={}", + short_edge, resolved_colors, adaptive_smoothing_spatial, adaptive_smoothing_color, + adaptive_min_region, adaptive_upscale_short_edge); } - spdlog::info("RunPipeline start: input={}x{}, num_colors={}{}, " + spdlog::info("V1: start: input={}x{}, num_colors={}{}, " "min_region_area={}, curve_fit_error={:.2f}, contour_simplify={:.2f}, " "svg_stroke={}, coverage_fix={}, max_working_pixels={}", bgr.cols, bgr.rows, resolved_colors, cfg.num_colors == 0 ? " (auto)" : "", @@ -73,13 +76,13 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, if (scaled && !opaque_mask.empty()) { cv::resize(opaque_mask, working_mask, working.size(), 0, 0, cv::INTER_NEAREST); } - spdlog::debug("Vectorize preprocess done: working={}x{}, scale={:.3f}, mask_present={}", - working.cols, working.rows, scale, !working_mask.empty()); + spdlog::debug("V1: preprocess done: working={}x{}, scale={:.3f}, mask_present={}", working.cols, + working.rows, scale, !working_mask.empty()); cv::Mat edge_map; if (multicolor && !unsmoothed.empty()) { edge_map = ComputeEdgeMap(unsmoothed); - spdlog::debug("Vectorize edge map computed: size={}x{}", edge_map.cols, edge_map.rows); + spdlog::debug("V1: edge map computed: size={}x{}", edge_map.cols, edge_map.rows); } cv::Mat lab = BgrToLab(working); @@ -88,17 +91,17 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, cfg.slic_compactness, edge_map, cfg.edge_sensitivity) : SegmentBinary(working, lab); if (seg.labels.empty()) { - spdlog::error("Vectorize segmentation failed: empty labels"); + spdlog::error("V1: segmentation failed: empty labels"); throw std::runtime_error("RunPipeline: segmentation failed"); } - spdlog::info("Vectorize segmentation completed: mode={}, centers={}, label_map={}x{}", + spdlog::info("V1: segmentation completed: mode={}, centers={}, label_map={}x{}", multicolor ? "multicolor" : "binary", seg.centers_lab.size(), seg.labels.cols, seg.labels.rows); if (!working_mask.empty()) { if (working_mask.type() != CV_8UC1 || working_mask.size() != seg.labels.size()) { spdlog::error( - "Vectorize mask invalid: expected type=CV_8UC1 size={}x{}, got type={} size={}x{}", + "V1: mask invalid: expected type=CV_8UC1 size={}x{}, got type={} size={}x{}", seg.labels.cols, seg.labels.rows, working_mask.type(), working_mask.cols, working_mask.rows); throw std::runtime_error("RunPipeline: invalid opaque mask"); @@ -107,7 +110,7 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, cv::compare(working_mask, 0, transparent, cv::CMP_EQ); const int transparent_px = cv::countNonZero(transparent); seg.labels.setTo(cv::Scalar(-1), transparent); - spdlog::debug("Vectorize transparent mask applied: transparent_pixels={}", transparent_px); + spdlog::debug("V1: transparent mask applied: transparent_pixels={}", transparent_px); } cv::Mat unsmoothed_lab; @@ -116,13 +119,13 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, if (multicolor && cfg.refine_passes > 0 && !unsmoothed_lab.empty() && !seg.centers_lab.empty()) { RefineLabelsBoundary(seg.labels, unsmoothed_lab, seg.centers_lab, cfg.refine_passes); - spdlog::info("Vectorize label refinement applied: passes={}", cfg.refine_passes); + spdlog::info("V1: label refinement applied: passes={}", cfg.refine_passes); } int area_proportional_min = std::min(200, static_cast(working.rows * working.cols * 0.0005f)); int effective_min_region = std::max(adaptive_min_region, area_proportional_min); - spdlog::debug("MergeSmallComponents min_region: cfg={}, adaptive={}, proportional={}, " + spdlog::debug("V1: MergeSmallComponents min_region: cfg={}, adaptive={}, proportional={}, " "effective={}", cfg.min_region_area, adaptive_min_region, area_proportional_min, effective_min_region); @@ -133,9 +136,31 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, } int num_labels = CompactLabels(seg.labels, seg.centers_lab); auto palette = ComputePalette(working, seg.labels, num_labels); - spdlog::info("Vectorize labels compacted: num_labels={}, palette_size={}", num_labels, + spdlog::info("V1: labels compacted: num_labels={}, palette_size={}", num_labels, palette.size()); + struct LabelMask { + cv::Mat mask; + int pixel_count = 0; + }; + + std::vector label_masks(num_labels); + { + for (int rid = 0; rid < num_labels; ++rid) { + label_masks[rid].mask = cv::Mat::zeros(seg.labels.size(), CV_8UC1); + } + for (int r = 0; r < seg.labels.rows; ++r) { + const int* lrow = seg.labels.ptr(r); + for (int c = 0; c < seg.labels.cols; ++c) { + int lid = lrow[c]; + if (lid >= 0 && lid < num_labels) { + label_masks[lid].mask.at(r, c) = 255; + label_masks[lid].pixel_count++; + } + } + } + } + float effective_curve_fit_error = cfg.curve_fit_error; float effective_contour_simplify = cfg.contour_simplify; if (cfg.detail_level >= 0.0f) { @@ -145,24 +170,24 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, effective_curve_fit_error = 2.0f - 1.7f * dl; if (cfg.contour_simplify == kDefaults.contour_simplify) effective_contour_simplify = 0.8f - 0.6f * dl; - spdlog::info("detail_level={:.2f}: derived curve_fit_error={:.2f}, " + spdlog::info("V1: detail_level={:.2f}: derived curve_fit_error={:.2f}, " "contour_simplify={:.2f}", dl, effective_curve_fit_error, effective_contour_simplify); } - const float trace_eps = - std::max(0.2f, std::clamp(effective_contour_simplify * 0.45f + 0.2f, 0.2f, 2.0f)); - const int turdsize = std::max(0, static_cast(std::lround(trace_eps * 0.5f))); - const double opttolerance = std::clamp(static_cast(trace_eps), 0.2, 2.0); - spdlog::debug("Vectorize trace params: trace_eps={:.3f}, turdsize={}, opttolerance={:.3f}", - trace_eps, turdsize, opttolerance); + auto tp = DeriveTraceParams(effective_contour_simplify); + const float trace_eps = tp.trace_eps; + const int turdsize = tp.turdsize; + const double opttolerance = tp.opttolerance; + spdlog::debug("V1: trace params: trace_eps={:.3f}, turdsize={}, opttolerance={:.3f}", trace_eps, + turdsize, opttolerance); std::vector shapes; if (multicolor && num_labels > 2) { - spdlog::info("Vectorize contour mode: BoundaryGraph+CurveFit"); + spdlog::info("V1: contour mode: BoundaryGraph+CurveFit"); auto boundary_graph = BuildBoundaryGraph(seg.labels); - spdlog::debug("BoundaryGraph built: nodes={}, edges={}", boundary_graph.nodes.size(), + spdlog::debug("V1: BoundaryGraph built: nodes={}, edges={}", boundary_graph.nodes.size(), boundary_graph.edges.size()); if (cfg.enable_subpixel_refine) { @@ -189,7 +214,7 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, shapes = AssembleContoursFromGraph(boundary_graph, num_labels, palette, cfg.min_contour_area, cfg.min_hole_area, &fit_cfg, smooth_cfg, cfg.merge_segment_tolerance); - spdlog::info("BoundaryGraph contour assembly done: shapes={}", shapes.size()); + spdlog::info("V1: BoundaryGraph contour assembly done: shapes={}", shapes.size()); std::vector label_pixel_count(num_labels, 0.0); std::vector label_shape_area(num_labels, 0.0); @@ -226,18 +251,16 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, if (!label_covered[rid]) ++uncovered_labels; } if (uncovered_labels > 0) { - spdlog::warn( - "Vectorize fallback triggered: uncovered_labels={} (BoundaryGraph -> Potrace)", - uncovered_labels); + spdlog::warn("V1: fallback triggered: uncovered_labels={} (BoundaryGraph -> Potrace)", + uncovered_labels); } int fallback_labels = 0; int fallback_shapes_add = 0; for (int rid = 0; rid < num_labels; ++rid) { if (label_covered[rid]) continue; ++fallback_labels; - cv::Mat mask = (seg.labels == rid); - mask.convertTo(mask, CV_8UC1, 255); - if (cv::countNonZero(mask) <= 0) continue; + const cv::Mat& mask = label_masks[rid].mask; + if (label_masks[rid].pixel_count <= 0) continue; auto traced = TraceMaskWithPotraceBezier(mask, turdsize, opttolerance); for (auto& g : traced) { @@ -249,6 +272,7 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, for (auto& hole : g.holes) { double hole_area = std::abs(BezierContourSignedArea(hole)); if (hole_area < static_cast(cfg.min_hole_area)) continue; + hole.is_hole = true; shape.contours.push_back(std::move(hole)); } if (!shape.contours.empty()) { @@ -258,18 +282,20 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, } } if (fallback_labels > 0) { - spdlog::info("Vectorize fallback completed: labels={}, shapes_added={}", - fallback_labels, fallback_shapes_add); + spdlog::info("V1: fallback completed: labels={}, shapes_added={}", fallback_labels, + fallback_shapes_add); } } else { - spdlog::info("Vectorize contour mode: per-label Potrace"); + spdlog::info("V1: contour mode: per-label Potrace"); int labels_traced = 0; int dilate_retry_count = 0; int direct_shapes_added = 0; + std::vector> per_label_shapes(num_labels); + +#pragma omp parallel for schedule(dynamic) reduction(+ : labels_traced, dilate_retry_count) for (int rid = 0; rid < num_labels; ++rid) { - cv::Mat mask = (seg.labels == rid); - mask.convertTo(mask, CV_8UC1, 255); - int px = cv::countNonZero(mask); + const cv::Mat& mask = label_masks[rid].mask; + int px = label_masks[rid].pixel_count; if (px <= 0) continue; ++labels_traced; @@ -292,20 +318,23 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, for (auto& hole : g.holes) { double hole_area = std::abs(BezierContourSignedArea(hole)); if (hole_area < static_cast(cfg.min_hole_area)) continue; + hole.is_hole = true; shape.contours.push_back(std::move(hole)); } - if (!shape.contours.empty()) { - shapes.push_back(std::move(shape)); - ++direct_shapes_added; - } + if (!shape.contours.empty()) { per_label_shapes[rid].push_back(std::move(shape)); } + } + } + for (int rid = 0; rid < num_labels; ++rid) { + for (auto& s : per_label_shapes[rid]) { + shapes.push_back(std::move(s)); + ++direct_shapes_added; } } if (dilate_retry_count > 0) { - spdlog::warn("Vectorize Potrace retry with dilation: labels_retried={}", - dilate_retry_count); + spdlog::warn("V1: Potrace retry with dilation: labels_retried={}", dilate_retry_count); } - spdlog::info("Vectorize per-label Potrace done: labels_traced={}, shapes_added={}", - labels_traced, direct_shapes_added); + spdlog::info("V1: per-label Potrace done: labels_traced={}, shapes_added={}", labels_traced, + direct_shapes_added); } if (cfg.svg_enable_stroke && multicolor && num_labels > 1) { @@ -320,9 +349,8 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, int labels_with_thin = 0; int stroke_added = 0; for (int rid = 0; rid < num_labels && stroke_added < max_stroke_count; ++rid) { - cv::Mat mask = (seg.labels == rid); - mask.convertTo(mask, CV_8UC1, 255); - if (cv::countNonZero(mask) <= 0) continue; + const cv::Mat& mask = label_masks[rid].mask; + if (label_masks[rid].pixel_count <= 0) continue; cv::Mat thin = DetectThinRegion(mask, adaptive_thin_radius); if (cv::countNonZero(thin) < 3) continue; @@ -340,11 +368,11 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, ++stroke_added; } } - spdlog::info("Vectorize thin-line enhancement: labels={}, strokes_added={} (cap={})", + spdlog::info("V1: thin-line enhancement: labels={}, strokes_added={} (cap={})", labels_with_thin, stroke_added, max_stroke_count); } else if (cfg.svg_enable_stroke) { - spdlog::debug("Vectorize thin-line enhancement skipped: multicolor={}, num_labels={}", - multicolor, num_labels); + spdlog::debug("V1: thin-line enhancement skipped: multicolor={}, num_labels={}", multicolor, + num_labels); } std::sort(shapes.begin(), shapes.end(), [](const auto& a, const auto& b) { @@ -363,36 +391,14 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, ApplyCoverageGuard(shapes, seg.labels, palette, effective_coverage_ratio, trace_eps, std::max(1.0f, cfg.min_contour_area * 0.5f)); const auto added = shapes.size() >= before ? (shapes.size() - before) : 0; - spdlog::info("Vectorize coverage guard applied: added_shapes={}", added); + spdlog::info("V1: coverage guard applied: added_shapes={}", added); } if (scaled) { - const float inv = 1.0f / scale; - for (auto& shape : shapes) { - for (auto& contour : shape.contours) { - for (auto& s : contour.segments) { - s.p0 = s.p0 * inv; - s.p1 = s.p1 * inv; - s.p2 = s.p2 * inv; - s.p3 = s.p3 * inv; - } - } - } - spdlog::debug("Vectorize output rescaled by inverse factor={:.4f}", inv); - } - - const float fw = static_cast(bgr.cols); - const float fh = static_cast(bgr.rows); - for (auto& shape : shapes) { - for (auto& contour : shape.contours) { - for (auto& s : contour.segments) { - s.p0 = {std::clamp(s.p0.x, 0.f, fw), std::clamp(s.p0.y, 0.f, fh)}; - s.p1 = {std::clamp(s.p1.x, 0.f, fw), std::clamp(s.p1.y, 0.f, fh)}; - s.p2 = {std::clamp(s.p2.x, 0.f, fw), std::clamp(s.p2.y, 0.f, fh)}; - s.p3 = {std::clamp(s.p3.x, 0.f, fw), std::clamp(s.p3.y, 0.f, fh)}; - } - } + RescaleShapes(shapes, 1.0f / scale); + spdlog::debug("V1: output rescaled by inverse factor={:.4f}", 1.0f / scale); } + ClampShapesToBounds(shapes, static_cast(bgr.cols), static_cast(bgr.rows), true); VectorizerResult result; result.width = bgr.cols; @@ -405,7 +411,7 @@ VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, const auto elapsed_ms = std::chrono::duration(std::chrono::steady_clock::now() - pipeline_start) .count(); - spdlog::info("RunPipeline completed: elapsed_ms={:.2f}, width={}, height={}, " + spdlog::info("V1: completed: elapsed_ms={:.2f}, width={}, height={}, " "num_shapes={}, palette_size={}, svg_bytes={}", elapsed_ms, result.width, result.height, result.num_shapes, result.palette.size(), result.svg_content.size()); diff --git a/src/pipeline.h b/src/pipeline.h index 8d6dee2..23bcb5d 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -1,12 +1,64 @@ #pragma once +#include "detail/vectorized_shape.h" + #include #include +#include +#include +#include + namespace neroued::vectorizer::detail { VectorizerResult RunPipeline(const cv::Mat& bgr, const VectorizerConfig& cfg, const cv::Mat& opaque_mask = cv::Mat()); +VectorizerResult RunPipelineV2(const cv::Mat& bgr, const VectorizerConfig& cfg, + const cv::Mat& opaque_mask = cv::Mat()); + +struct TraceParams { + float trace_eps; + int turdsize; + double opttolerance; +}; + +inline TraceParams DeriveTraceParams(float contour_simplify) { + TraceParams tp; + tp.trace_eps = std::max(0.2f, std::clamp(contour_simplify * 0.45f + 0.2f, 0.2f, 2.0f)); + tp.turdsize = std::max(0, static_cast(std::lround(tp.trace_eps * 0.5f))); + tp.opttolerance = std::clamp(static_cast(tp.trace_eps), 0.2, 2.0); + return tp; +} + +inline void RescaleShapes(std::vector& shapes, float inv_scale) { + for (auto& shape : shapes) { + for (auto& contour : shape.contours) { + for (auto& s : contour.segments) { + s.p0 = s.p0 * inv_scale; + s.p1 = s.p1 * inv_scale; + s.p2 = s.p2 * inv_scale; + s.p3 = s.p3 * inv_scale; + } + } + } +} + +inline void ClampShapesToBounds(std::vector& shapes, float fw, float fh, + bool clamp_control_points) { + for (auto& shape : shapes) { + for (auto& contour : shape.contours) { + for (auto& s : contour.segments) { + s.p0 = {std::clamp(s.p0.x, 0.f, fw), std::clamp(s.p0.y, 0.f, fh)}; + s.p3 = {std::clamp(s.p3.x, 0.f, fw), std::clamp(s.p3.y, 0.f, fh)}; + if (clamp_control_points) { + s.p1 = {std::clamp(s.p1.x, 0.f, fw), std::clamp(s.p1.y, 0.f, fh)}; + s.p2 = {std::clamp(s.p2.x, 0.f, fw), std::clamp(s.p2.y, 0.f, fh)}; + } + } + } + } +} + } // namespace neroued::vectorizer::detail diff --git a/src/pipeline_v2.cpp b/src/pipeline_v2.cpp new file mode 100644 index 0000000..75b62dc --- /dev/null +++ b/src/pipeline_v2.cpp @@ -0,0 +1,314 @@ +#include "pipeline.h" + +#include "curve/bezier.h" +#include "curve/path_optimize.h" +#include "detail/cv_utils.h" +#include "output/svg_writer.h" +#include "preprocess/preprocess.h" +#include "quantize/color_quantize.h" +#include "segment/color_segment.h" +#include "stacking/depth_order.h" +#include "stacking/shape_extend.h" +#include "trace/coverage.h" +#include "trace/potrace.h" + +#include +#include + +#include +#include +#include +#include +#include + +#ifdef _OPENMP +# include +#endif + +namespace neroued::vectorizer::detail { + +namespace { + +/// z-order safe same-color shape merging. +/// Shapes that share the same color AND appear consecutively in the z-order +/// can be safely merged without altering the visual result. +void MergeSameColorShapesV2(std::vector& shapes, float min_area) { + if (shapes.size() <= 1) return; + + int before = static_cast(shapes.size()); + + // Filter out tiny fragments first. + if (min_area > 0.f) { + shapes.erase(std::remove_if(shapes.begin(), shapes.end(), + [min_area](const VectorizedShape& s) { + return s.area < static_cast(min_area); + }), + shapes.end()); + } + + // Merge consecutive same-color shapes. + std::vector merged; + merged.reserve(shapes.size()); + + for (size_t i = 0; i < shapes.size();) { + merged.push_back(std::move(shapes[i])); + auto& cur = merged.back(); + size_t j = i + 1; + + auto color_key = [](const Rgb& c) -> uint64_t { + uint8_t r8, g8, b8; + c.ToRgb255(r8, g8, b8); + return (static_cast(r8) << 16) | (static_cast(g8) << 8) | b8; + }; + + uint64_t cur_key = color_key(cur.color); + while (j < shapes.size() && color_key(shapes[j].color) == cur_key) { + for (auto& contour : shapes[j].contours) { cur.contours.push_back(std::move(contour)); } + cur.area += shapes[j].area; + ++j; + } + i = j; + } + + shapes = std::move(merged); + spdlog::info("MergeSameColorShapesV2: {} -> {} shapes (min_area={:.1f})", before, shapes.size(), + min_area); +} + +} // namespace + +VectorizerResult RunPipelineV2(const cv::Mat& bgr, const VectorizerConfig& cfg, + const cv::Mat& opaque_mask) { + const auto pipeline_start = std::chrono::steady_clock::now(); + + spdlog::info("RunPipelineV2 start: input={}x{}, num_colors={}{}", bgr.cols, bgr.rows, + cfg.num_colors, cfg.num_colors == 0 ? " (auto)" : ""); + + // ── 1. Preprocess ─────────────────────────────────────────────────────── + auto preproc = PreprocessForVectorize(bgr, true, cfg.smoothing_spatial, cfg.smoothing_color, + cfg.upscale_short_edge, cfg.max_working_pixels); + cv::Mat working = preproc.bgr; + const float scale = preproc.scale; + const bool scaled = std::abs(scale - 1.0f) > 1e-6f; + + cv::Mat working_mask = opaque_mask; + if (scaled && !opaque_mask.empty()) { + cv::resize(opaque_mask, working_mask, working.size(), 0, 0, cv::INTER_NEAREST); + } + + spdlog::debug("V2 preprocess: working={}x{}, scale={:.3f}", working.cols, working.rows, scale); + + // ── 2. OKLab MMCQ color quantization ──────────────────────────────────── + auto qr = QuantizeColors(working, cfg.num_colors); + cv::Mat labels = std::move(qr.labels); + std::vector centers_lab = std::move(qr.centers_lab); + int resolved_colors = static_cast(qr.palette.size()); + + cv::Mat lab = BgrToLab(working); + + spdlog::debug("V2 MMCQ quantization: {} colors", resolved_colors); + + // ── 3. Apply transparency mask ────────────────────────────────────────── + if (!working_mask.empty()) { + cv::Mat transparent; + cv::compare(working_mask, 0, transparent, cv::CMP_EQ); + labels.setTo(cv::Scalar(-1), transparent); + } + working_mask.release(); + + // ── 4. Small-region merge + compact labels ────────────────────────────── + const cv::Size working_size(working.cols, working.rows); + int effective_min_region = std::max( + cfg.min_region_area, std::min(200, static_cast(working_size.area() * 0.0005f))); + + MergeSmallComponents(labels, lab, centers_lab, std::max(2, effective_min_region), + cfg.max_merge_color_dist); + lab.release(); + + int num_labels = CompactLabels(labels, centers_lab); + auto palette = ComputePalette(working, labels, num_labels); + working.release(); + + spdlog::info("V2 labels compacted: num_labels={}, palette_size={}", num_labels, palette.size()); + + // ── 5. Extract shape layers (connected components per label) ──────────── + auto layers = ExtractShapeLayers(labels, num_labels, cfg.min_contour_area); + // labels kept alive for ApplyCoverageGuard; released after step 10b. + spdlog::info("V2 shape layers: {}", layers.size()); + + if (layers.empty()) { + spdlog::error("RunPipelineV2: no shape layers extracted"); + throw std::runtime_error("RunPipelineV2: no shape layers"); + } + + // ── 6. Depth ordering ─────────────────────────────────────────────────── + auto depth_order = ComputeDepthOrder(layers, working_size.height, working_size.width); + + // ── 7. Shape extension (dilate into occluded regions) ─────────────────── + cv::Mat gt_labels; + if (cfg.enable_depth_validation) { + gt_labels.create(working_size, CV_32SC1); + gt_labels.setTo(cv::Scalar(-1)); + for (const auto& layer : layers) { + const auto& bbox = layer.bbox; + const auto& mask = layer.mask; + for (int r = 0; r < bbox.height; ++r) { + const auto* mrow = mask.ptr(r); + auto* lrow = gt_labels.ptr(r + bbox.y); + for (int c = 0; c < bbox.width; ++c) { + if (mrow[c] > 0) lrow[c + bbox.x] = layer.label; + } + } + } + } + + ExtendShapeMasks(layers, depth_order, working_size, 3); + + // ── 7b. Depth order validation (diagnostic, opt-in) ───────────────────── + if (cfg.enable_depth_validation) { + cv::Mat rendered(working_size, CV_32SC1, cv::Scalar(-1)); + for (int idx : depth_order) { + const auto& layer = layers[idx]; + const auto& bbox = layer.bbox; + const auto& mask = layer.mask; + for (int r = 0; r < bbox.height; ++r) { + const auto* mrow = mask.ptr(r); + auto* lrow = rendered.ptr(r + bbox.y); + for (int c = 0; c < bbox.width; ++c) { + if (mrow[c] > 0) lrow[c + bbox.x] = layer.label; + } + } + } + + int total_opaque = 0, mismatch = 0; + const int val_rows = working_size.height; + const int val_cols = working_size.width; +#pragma omp parallel for reduction(+ : total_opaque, mismatch) schedule(static) + for (int r = 0; r < val_rows; ++r) { + const int* gt_row = gt_labels.ptr(r); + const int* rd_row = rendered.ptr(r); + for (int c = 0; c < val_cols; ++c) { + if (gt_row[c] < 0) continue; + ++total_opaque; + if (rd_row[c] != gt_row[c]) ++mismatch; + } + } + + float mismatch_rate = total_opaque > 0 ? static_cast(mismatch) / total_opaque : 0.0f; + if (mismatch_rate > 0.05f) { + spdlog::warn("V2 depth order validation: mismatch_rate={:.4f} ({}/{} opaque pixels)", + mismatch_rate, mismatch, total_opaque); + } else { + spdlog::debug("V2 depth order validation: mismatch_rate={:.4f} ({}/{} opaque pixels)", + mismatch_rate, mismatch, total_opaque); + } + gt_labels.release(); + } + + // ── 8. Per-layer Potrace tracing ──────────────────────────────────────── + auto tp = DeriveTraceParams(cfg.contour_simplify); + const float trace_eps = tp.trace_eps; + const int turdsize = tp.turdsize; + const double opttolerance = tp.opttolerance; + + const int depth_count = static_cast(depth_order.size()); + std::vector> per_rank_shapes(depth_count); + +#pragma omp parallel for schedule(dynamic) + for (int rank = 0; rank < depth_count; ++rank) { + int idx = depth_order[rank]; + const auto& layer = layers[idx]; + if (layer.area < cfg.min_contour_area) continue; + + auto traced = TraceMaskWithPotraceBezier(layer.mask, turdsize, opttolerance); + const Vec2f roi_offset(static_cast(layer.bbox.x), static_cast(layer.bbox.y)); + + for (auto& g : traced) { + if (g.area < static_cast(cfg.min_contour_area)) continue; + + for (auto& seg : g.outer.segments) { + seg.p0 = seg.p0 + roi_offset; + seg.p1 = seg.p1 + roi_offset; + seg.p2 = seg.p2 + roi_offset; + seg.p3 = seg.p3 + roi_offset; + } + + VectorizedShape shape; + shape.color = palette[layer.label]; + shape.area = g.area; + shape.contours.push_back(std::move(g.outer)); + + for (auto& hole : g.holes) { + double hole_area = std::abs(BezierContourSignedArea(hole)); + if (hole_area < static_cast(cfg.min_hole_area)) continue; + for (auto& seg : hole.segments) { + seg.p0 = seg.p0 + roi_offset; + seg.p1 = seg.p1 + roi_offset; + seg.p2 = seg.p2 + roi_offset; + seg.p3 = seg.p3 + roi_offset; + } + hole.is_hole = true; + shape.contours.push_back(std::move(hole)); + } + + if (!shape.contours.empty()) { per_rank_shapes[rank].push_back(std::move(shape)); } + } + } + + std::vector shapes; + shapes.reserve(layers.size()); + for (int rank = 0; rank < depth_count; ++rank) { + for (auto& s : per_rank_shapes[rank]) shapes.push_back(std::move(s)); + } + + spdlog::info("V2 tracing done: {} shapes", shapes.size()); + + // ── 9. Path optimization ─────────────────────────────────────────────── + { + float linear_eps = std::max(0.3f, cfg.merge_segment_tolerance * 5.f); + float merge_eps = std::max(0.3f, cfg.curve_fit_error * 0.5f); + OptimizeShapePaths(shapes, linear_eps, merge_eps); + } + + // ── 10. z-order safe same-color merge + fragment filtering ────────────── + MergeSameColorShapesV2(shapes, cfg.min_contour_area); + + // ── 10b. Coverage guard — patch uncovered pixels ──────────────────────── + if (cfg.enable_coverage_fix) { + float min_ratio = + (num_labels > 2) ? std::min(cfg.min_coverage_ratio, 0.995f) : cfg.min_coverage_ratio; + float min_patch_area = std::max(1.f, cfg.min_contour_area * 0.5f); + size_t pre_patch = shapes.size(); + ApplyCoverageGuard(shapes, labels, palette, min_ratio, trace_eps, min_patch_area); + size_t num_patches = shapes.size() - pre_patch; + if (num_patches > 0) { + // Rotate patches to bottom of z-order so they only show through + // uncovered gaps, never painting over correct existing content. + std::rotate(shapes.begin(), shapes.begin() + static_cast(pre_patch), + shapes.end()); + } + } + labels.release(); + + if (scaled) { RescaleShapes(shapes, 1.0f / scale); } + ClampShapesToBounds(shapes, static_cast(bgr.cols), static_cast(bgr.rows), false); + + // ── 12. Build result ──────────────────────────────────────────────────── + VectorizerResult result; + result.width = bgr.cols; + result.height = bgr.rows; + result.num_shapes = static_cast(shapes.size()); + result.resolved_num_colors = resolved_colors; + result.palette = std::move(palette); + result.svg_content = + WriteSvg(shapes, bgr.cols, bgr.rows, cfg.svg_enable_stroke, cfg.svg_stroke_width); + + const auto elapsed_ms = + std::chrono::duration(std::chrono::steady_clock::now() - pipeline_start) + .count(); + spdlog::info("RunPipelineV2 completed: elapsed_ms={:.2f}, shapes={}, svg_bytes={}", elapsed_ms, + result.num_shapes, result.svg_content.size()); + return result; +} + +} // namespace neroued::vectorizer::detail diff --git a/src/quantize/color_quantize.cpp b/src/quantize/color_quantize.cpp new file mode 100644 index 0000000..96cd908 --- /dev/null +++ b/src/quantize/color_quantize.cpp @@ -0,0 +1,703 @@ +#include "color_quantize.h" +#include "oklab.h" + +#include +#include + +#include +#include +#include +#include +#include + +#ifdef _OPENMP +# include +#endif + +namespace neroued::vectorizer::detail { + +namespace { + +constexpr int kGridDim = 32; +constexpr int kGridTotal = kGridDim * kGridDim * kGridDim; + +struct HistBin { + int count = 0; + double sum_l = 0; + double sum_a = 0; + double sum_b = 0; + double sum2_l = 0; + double sum2_a = 0; + double sum2_b = 0; +}; + +struct ColorGrid { + std::vector bins; + float L_min = 0, a_min = 0, b_min = 0; + float L_inv = 0, a_inv = 0, b_inv = 0; + + void Build(const cv::Mat& bgr, cv::Mat& oklab_cache) { + oklab_cache.create(bgr.rows, bgr.cols, CV_32FC3); + + float lo_L = 1e9f, lo_a = 1e9f, lo_b = 1e9f; + float hi_L = -1e9f, hi_a = -1e9f, hi_b = -1e9f; + for (int r = 0; r < bgr.rows; ++r) { + const auto* brow = bgr.ptr(r); + auto* orow = oklab_cache.ptr(r); + for (int c = 0; c < bgr.cols; ++c) { + auto ok = SrgbToOklab(brow[c][2], brow[c][1], brow[c][0]); + orow[c] = cv::Vec3f(ok.L, ok.a, ok.b); + lo_L = std::min(lo_L, ok.L); + hi_L = std::max(hi_L, ok.L); + lo_a = std::min(lo_a, ok.a); + hi_a = std::max(hi_a, ok.a); + lo_b = std::min(lo_b, ok.b); + hi_b = std::max(hi_b, ok.b); + } + } + constexpr float kEps = 1e-7f; + L_min = lo_L; + a_min = lo_a; + b_min = lo_b; + L_inv = static_cast(kGridDim) / std::max(kEps, hi_L - lo_L); + a_inv = static_cast(kGridDim) / std::max(kEps, hi_a - lo_a); + b_inv = static_cast(kGridDim) / std::max(kEps, hi_b - lo_b); + + bins.assign(kGridTotal, HistBin{}); +#ifdef _OPENMP + const int nt = omp_get_max_threads(); +#else + const int nt = 1; +#endif + std::vector> tl_bins(nt, std::vector(kGridTotal)); + +#pragma omp parallel + { +#ifdef _OPENMP + int tid = omp_get_thread_num(); +#else + int tid = 0; +#endif + auto& local = tl_bins[tid]; +#pragma omp for schedule(static) + for (int r = 0; r < bgr.rows; ++r) { + const auto* orow = oklab_cache.ptr(r); + for (int c = 0; c < bgr.cols; ++c) { + float L = orow[c][0], a = orow[c][1], b = orow[c][2]; + int bi = BinIndex(L, a, b); + auto& h = local[bi]; + h.count++; + h.sum_l += L; + h.sum_a += a; + h.sum_b += b; + h.sum2_l += static_cast(L) * L; + h.sum2_a += static_cast(a) * a; + h.sum2_b += static_cast(b) * b; + } + } + } + for (int t = 0; t < nt; ++t) { + for (int bi = 0; bi < kGridTotal; ++bi) { + bins[bi].count += tl_bins[t][bi].count; + bins[bi].sum_l += tl_bins[t][bi].sum_l; + bins[bi].sum_a += tl_bins[t][bi].sum_a; + bins[bi].sum_b += tl_bins[t][bi].sum_b; + bins[bi].sum2_l += tl_bins[t][bi].sum2_l; + bins[bi].sum2_a += tl_bins[t][bi].sum2_a; + bins[bi].sum2_b += tl_bins[t][bi].sum2_b; + } + } + } + + int BinIndex(float L, float a, float b) const { + int iL = std::clamp(static_cast((L - L_min) * L_inv), 0, kGridDim - 1); + int ia = std::clamp(static_cast((a - a_min) * a_inv), 0, kGridDim - 1); + int ib = std::clamp(static_cast((b - b_min) * b_inv), 0, kGridDim - 1); + return iL * kGridDim * kGridDim + ia * kGridDim + ib; + } + + float BinChannel(int bi, int axis) const { + const auto& h = bins[bi]; + if (h.count == 0) return 0.f; + double inv = 1.0 / h.count; + switch (axis) { + case 0: + return static_cast(h.sum_l * inv); + case 1: + return static_cast(h.sum_a * inv); + default: + return static_cast(h.sum_b * inv); + } + } +}; + +struct OkLabPixel { + float L, a, b; +}; + +struct ColorBox { + std::vector bin_ids; + int total_count = 0; + OkLabPixel min_corner{}, max_corner{}; + OkLabPixel mean{}; + double var_L = 0.0, var_a = 0.0, var_b = 0.0; + double priority = 0.0; + + bool operator<(const ColorBox& rhs) const { return priority < rhs.priority; } +}; + +void ComputeBoxStats(ColorBox& box, const ColorGrid& grid) { + if (box.bin_ids.empty()) return; + + box.min_corner = {1e9f, 1e9f, 1e9f}; + box.max_corner = {-1e9f, -1e9f, -1e9f}; + box.total_count = 0; + double tL = 0, tA = 0, tB = 0; + double t2L = 0, t2A = 0, t2B = 0; + + for (int bi : box.bin_ids) { + const auto& h = grid.bins[bi]; + if (h.count == 0) continue; + box.total_count += h.count; + tL += h.sum_l; + tA += h.sum_a; + tB += h.sum_b; + t2L += h.sum2_l; + t2A += h.sum2_a; + t2B += h.sum2_b; + + float mL = static_cast(h.sum_l / h.count); + float ma = static_cast(h.sum_a / h.count); + float mb = static_cast(h.sum_b / h.count); + box.min_corner.L = std::min(box.min_corner.L, mL); + box.min_corner.a = std::min(box.min_corner.a, ma); + box.min_corner.b = std::min(box.min_corner.b, mb); + box.max_corner.L = std::max(box.max_corner.L, mL); + box.max_corner.a = std::max(box.max_corner.a, ma); + box.max_corner.b = std::max(box.max_corner.b, mb); + } + + if (box.total_count == 0) return; + double n = static_cast(box.total_count); + box.mean = {static_cast(tL / n), static_cast(tA / n), static_cast(tB / n)}; + + box.var_L = std::max(0.0, t2L - tL * tL / n); + box.var_a = std::max(0.0, t2A - tA * tA / n); + box.var_b = std::max(0.0, t2B - tB * tB / n); + double total_var = box.var_L + box.var_a + box.var_b; + box.priority = total_var / std::max(1.0, std::sqrt(n)); +} + +int MaxVarianceAxis(const ColorBox& box) { + if (box.var_L >= box.var_a && box.var_L >= box.var_b) return 0; + if (box.var_a >= box.var_b) return 1; + return 2; +} + +std::pair MedianCutSplit(ColorBox& box, const ColorGrid& grid) { + int axis = MaxVarianceAxis(box); + + struct BinEntry { + int bi; + float val; + int count; + }; + + std::vector sorted; + sorted.reserve(box.bin_ids.size()); + for (int bi : box.bin_ids) { + if (grid.bins[bi].count == 0) continue; + sorted.push_back({bi, grid.BinChannel(bi, axis), grid.bins[bi].count}); + } + std::sort(sorted.begin(), sorted.end(), + [](const BinEntry& a, const BinEntry& b) { return a.val < b.val; }); + + int half_count = box.total_count / 2; + int cumulative = 0; + size_t median_idx = 0; + for (size_t i = 0; i < sorted.size(); ++i) { + cumulative += sorted[i].count; + if (cumulative >= half_count) { + median_idx = i; + break; + } + } + if (median_idx >= sorted.size() - 1 && sorted.size() > 1) median_idx = sorted.size() - 2; + + ColorBox left, right; + left.bin_ids.reserve(median_idx + 1); + right.bin_ids.reserve(sorted.size() - median_idx - 1); + for (size_t i = 0; i <= median_idx; ++i) left.bin_ids.push_back(sorted[i].bi); + for (size_t i = median_idx + 1; i < sorted.size(); ++i) right.bin_ids.push_back(sorted[i].bi); + + if (left.bin_ids.empty()) std::swap(left.bin_ids, right.bin_ids); + if (right.bin_ids.empty()) { + size_t half = left.bin_ids.size() / 2; + right.bin_ids.assign(left.bin_ids.begin() + static_cast(half), + left.bin_ids.end()); + left.bin_ids.resize(half); + } + + ComputeBoxStats(left, grid); + ComputeBoxStats(right, grid); + return {std::move(left), std::move(right)}; +} + +std::pair SplitBox(ColorBox& box, const ColorGrid& grid) { + OkLabPixel seed_a = box.mean; + OkLabPixel seed_b = seed_a; + float max_d2 = 0; + + for (int bi : box.bin_ids) { + const auto& h = grid.bins[bi]; + if (h.count == 0) continue; + float mL = static_cast(h.sum_l / h.count); + float ma = static_cast(h.sum_a / h.count); + float mb = static_cast(h.sum_b / h.count); + float dL = mL - seed_a.L, da = ma - seed_a.a, db = mb - seed_a.b; + float d2 = dL * dL + da * da + db * db; + if (d2 > max_d2) { + max_d2 = d2; + seed_b = {mL, ma, mb}; + } + } + + if (max_d2 < 1e-12f) return MedianCutSplit(box, grid); + + constexpr int kIters = 3; + std::vector assign(box.bin_ids.size(), 0); + + for (int iter = 0; iter < kIters; ++iter) { + double sL_a = 0, sA_a = 0, sB_a = 0; + double sL_b = 0, sA_b = 0, sB_b = 0; + int cnt_a = 0, cnt_b = 0; + + for (size_t idx = 0; idx < box.bin_ids.size(); ++idx) { + const auto& h = grid.bins[box.bin_ids[idx]]; + if (h.count == 0) continue; + float mL = static_cast(h.sum_l / h.count); + float ma = static_cast(h.sum_a / h.count); + float mb = static_cast(h.sum_b / h.count); + + float daL = mL - seed_a.L, daa = ma - seed_a.a, dab = mb - seed_a.b; + float dbL = mL - seed_b.L, dba = ma - seed_b.a, dbb = mb - seed_b.b; + float da2 = daL * daL + daa * daa + dab * dab; + float db2 = dbL * dbL + dba * dba + dbb * dbb; + + if (da2 <= db2) { + assign[idx] = 0; + sL_a += h.sum_l; + sA_a += h.sum_a; + sB_a += h.sum_b; + cnt_a += h.count; + } else { + assign[idx] = 1; + sL_b += h.sum_l; + sA_b += h.sum_a; + sB_b += h.sum_b; + cnt_b += h.count; + } + } + + if (cnt_a > 0) { + double inv = 1.0 / cnt_a; + seed_a = {static_cast(sL_a * inv), static_cast(sA_a * inv), + static_cast(sB_a * inv)}; + } + if (cnt_b > 0) { + double inv = 1.0 / cnt_b; + seed_b = {static_cast(sL_b * inv), static_cast(sA_b * inv), + static_cast(sB_b * inv)}; + } + } + + ColorBox left, right; + for (size_t idx = 0; idx < box.bin_ids.size(); ++idx) { + if (grid.bins[box.bin_ids[idx]].count == 0) continue; + if (assign[idx] == 0) + left.bin_ids.push_back(box.bin_ids[idx]); + else + right.bin_ids.push_back(box.bin_ids[idx]); + } + + if (left.bin_ids.empty() || right.bin_ids.empty()) return MedianCutSplit(box, grid); + + ComputeBoxStats(left, grid); + ComputeBoxStats(right, grid); + return {std::move(left), std::move(right)}; +} + +double BoxTotalVar(const ColorBox& b) { return b.var_L + b.var_a + b.var_b; } + +int FindNearestCentroid(float L, float a, float b, const std::vector& centroids) { + float best_d = 1e30f; + int best_j = 0; + for (int j = 0; j < static_cast(centroids.size()); ++j) { + float dL = L - centroids[j].L; + float da = a - centroids[j].a; + float db = b - centroids[j].b; + float d = dL * dL + da * da + db * db; + if (d < best_d) { + best_d = d; + best_j = j; + } + } + return best_j; +} + +struct TotalVarGreater { + bool operator()(const ColorBox& a, const ColorBox& b) const { + return BoxTotalVar(a) < BoxTotalVar(b); + } +}; + +int AutoDetectK(const ColorGrid& grid, const ColorBox& full_box) { + if (full_box.total_count < 100) return 2; + + constexpr int kMaxK = 64; + constexpr double kTargetRemaining = 0.005; + constexpr double kStallThresh = 0.0003; + constexpr int kStallLimit = 3; + constexpr double kElbowRatio = 0.05; + + std::vector pq; + pq.push_back(full_box); + + double total_variance = BoxTotalVar(full_box); + if (total_variance < 1e-12) return 2; + + double running_var = total_variance; + double prev_var = total_variance; + double prev_marginal = 0; + int k = 1; + int stall_count = 0; + bool elbow_detected = false; + + while (k < kMaxK && !pq.empty()) { + std::pop_heap(pq.begin(), pq.end(), TotalVarGreater{}); + auto top = std::move(pq.back()); + pq.pop_back(); + + if (top.bin_ids.size() < 2) { + pq.push_back(std::move(top)); + std::push_heap(pq.begin(), pq.end(), TotalVarGreater{}); + break; + } + + double removed_var = BoxTotalVar(top); + auto [left, right] = MedianCutSplit(top, grid); + double added_var = BoxTotalVar(left) + BoxTotalVar(right); + running_var += added_var - removed_var; + pq.push_back(std::move(left)); + std::push_heap(pq.begin(), pq.end(), TotalVarGreater{}); + pq.push_back(std::move(right)); + std::push_heap(pq.begin(), pq.end(), TotalVarGreater{}); + k++; + + double remaining = running_var / total_variance; + if (remaining <= kTargetRemaining && k >= 2) break; + + double marginal = (prev_var - running_var) / total_variance; + + if (k >= 3 && prev_marginal > 1e-12 && marginal / prev_marginal < kElbowRatio) { + elbow_detected = true; + spdlog::debug("AutoDetectK: elbow at k={}, marginal={:.6f}, prev={:.6f}", k, marginal, + prev_marginal); + break; + } + + if (marginal < kStallThresh) + stall_count++; + else + stall_count = 0; + if (stall_count >= kStallLimit && k >= 2) break; + + prev_marginal = marginal; + prev_var = running_var; + } + + int final_k = elbow_detected ? k - 1 : k; + spdlog::debug("AutoDetectK: k={}, remaining_var={:.4f}{}", final_k, + running_var / total_variance, elbow_detected ? " (elbow)" : ""); + return std::clamp(final_k, 2, kMaxK); +} + +std::vector RunMmcq(const ColorGrid& grid, int num_colors) { + ColorBox initial; + for (int i = 0; i < kGridTotal; ++i) { + if (grid.bins[i].count > 0) initial.bin_ids.push_back(i); + } + ComputeBoxStats(initial, grid); + + if (num_colors <= 0) { + num_colors = AutoDetectK(grid, initial); + spdlog::info("QuantizeColors: auto-detected K={}", num_colors); + } + num_colors = std::max(2, num_colors); + + std::vector heap; + heap.push_back(std::move(initial)); + + while (static_cast(heap.size()) < num_colors) { + std::pop_heap(heap.begin(), heap.end()); + auto top = std::move(heap.back()); + heap.pop_back(); + if (top.bin_ids.size() < 2) { + heap.push_back(std::move(top)); + std::push_heap(heap.begin(), heap.end()); + break; + } + auto [left, right] = SplitBox(top, grid); + heap.push_back(std::move(left)); + std::push_heap(heap.begin(), heap.end()); + heap.push_back(std::move(right)); + std::push_heap(heap.begin(), heap.end()); + } + + std::vector centroids; + centroids.reserve(heap.size()); + for (const auto& box : heap) { centroids.push_back(box.mean); } + spdlog::debug("QuantizeColors: MMCQ produced {} centroids from {} bins", centroids.size(), + kGridTotal); + return centroids; +} + +void RefineCentroidsKMeans(std::vector& centroids, const ColorGrid& grid, + const std::vector& active_bins) { + const int K = static_cast(centroids.size()); + constexpr int kRefineIters = 8; + constexpr float kConvergeEps2 = 1e-12f; + int actual_iters = 0; + + std::vector sL(K), sA(K), sB(K); + std::vector cnt(K); + + for (int iter = 0; iter < kRefineIters; ++iter) { + std::fill(sL.begin(), sL.end(), 0.0); + std::fill(sA.begin(), sA.end(), 0.0); + std::fill(sB.begin(), sB.end(), 0.0); + std::fill(cnt.begin(), cnt.end(), 0); + + for (int bi : active_bins) { + const auto& h = grid.bins[bi]; + float mL = static_cast(h.sum_l / h.count); + float ma = static_cast(h.sum_a / h.count); + float mb = static_cast(h.sum_b / h.count); + + int best_j = FindNearestCentroid(mL, ma, mb, centroids); + sL[best_j] += h.sum_l; + sA[best_j] += h.sum_a; + sB[best_j] += h.sum_b; + cnt[best_j] += h.count; + } + + float max_shift2 = 0; + for (int j = 0; j < K; ++j) { + if (cnt[j] == 0) continue; + double inv = 1.0 / cnt[j]; + float new_L = static_cast(sL[j] * inv); + float new_a = static_cast(sA[j] * inv); + float new_b = static_cast(sB[j] * inv); + float dL = new_L - centroids[j].L; + float da = new_a - centroids[j].a; + float db = new_b - centroids[j].b; + max_shift2 = std::max(max_shift2, dL * dL + da * da + db * db); + centroids[j].L = new_L; + centroids[j].a = new_a; + centroids[j].b = new_b; + } + ++actual_iters; + if (max_shift2 < kConvergeEps2) break; + } + spdlog::debug("QuantizeColors: K-Means refinement done ({}/{} iters on {} bins)", actual_iters, + kRefineIters, active_bins.size()); +} + +void ConsolidatePalette(std::vector& centroids, const ColorGrid& grid, + const std::vector& active_bins) { + constexpr float kMergeThreshold2 = 0.025f * 0.025f; + int K = static_cast(centroids.size()); + + std::vector pixel_count(K, 0); + for (int bi : active_bins) { + const auto& h = grid.bins[bi]; + float mL = static_cast(h.sum_l / h.count); + float ma = static_cast(h.sum_a / h.count); + float mb = static_cast(h.sum_b / h.count); + pixel_count[FindNearestCentroid(mL, ma, mb, centroids)] += h.count; + } + + struct PairDist { + int i, j; + float d2; + }; + + std::vector pairs; + pairs.reserve(K * (K - 1) / 2); + for (int i = 0; i < K; ++i) { + for (int j = i + 1; j < K; ++j) { + float dL = centroids[i].L - centroids[j].L; + float da = centroids[i].a - centroids[j].a; + float db = centroids[i].b - centroids[j].b; + float d2 = dL * dL + da * da + db * db; + if (d2 < kMergeThreshold2) pairs.push_back({i, j, d2}); + } + } + std::sort(pairs.begin(), pairs.end(), + [](const PairDist& a, const PairDist& b) { return a.d2 < b.d2; }); + + std::vector root(K); + std::iota(root.begin(), root.end(), 0); + auto find_root = [&](int x) { + while (root[x] != x) x = root[x] = root[root[x]]; + return x; + }; + + int merge_count = 0; + for (const auto& p : pairs) { + int ri = find_root(p.i); + int rj = find_root(p.j); + if (ri == rj) continue; + + float cur_dL = centroids[ri].L - centroids[rj].L; + float cur_da = centroids[ri].a - centroids[rj].a; + float cur_db = centroids[ri].b - centroids[rj].b; + if (cur_dL * cur_dL + cur_da * cur_da + cur_db * cur_db >= kMergeThreshold2) continue; + + double w_i = static_cast(pixel_count[ri]); + double w_j = static_cast(pixel_count[rj]); + double w_total = w_i + w_j; + if (w_total > 0) { + centroids[ri].L = + static_cast((w_i * centroids[ri].L + w_j * centroids[rj].L) / w_total); + centroids[ri].a = + static_cast((w_i * centroids[ri].a + w_j * centroids[rj].a) / w_total); + centroids[ri].b = + static_cast((w_i * centroids[ri].b + w_j * centroids[rj].b) / w_total); + } + pixel_count[ri] += pixel_count[rj]; + root[rj] = ri; + ++merge_count; + } + + if (merge_count > 0) { + std::vector compacted; + compacted.reserve(K - merge_count); + for (int i = 0; i < K; ++i) { + if (root[i] == i) compacted.push_back(centroids[i]); + } + centroids = std::move(compacted); + spdlog::info("QuantizeColors: palette consolidation merged {} pairs, K={}", merge_count, + centroids.size()); + } +} + +void SmoothLabels(cv::Mat& labels, const cv::Mat& bgr, const std::vector& centroids) { + constexpr int kRadius = 2; + constexpr float kMaxReassignDist2 = 0.04f * 0.04f; + const int K = static_cast(centroids.size()); + const int side = 2 * kRadius + 1; + const int half_window = side * side / 2; + const int rows = bgr.rows; + const int cols = bgr.cols; + + cv::Mat smoothed = labels.clone(); + int reassigned = 0; + +#pragma omp parallel reduction(+ : reassigned) + { + std::vector freq(K); +#pragma omp for schedule(static) + for (int r = kRadius; r < rows - kRadius; ++r) { + const auto* brow = bgr.ptr(r); + const int* lrow = labels.ptr(r); + int* srow = smoothed.ptr(r); + for (int c = kRadius; c < cols - kRadius; ++c) { + int cur_label = lrow[c]; + + std::fill(freq.begin(), freq.end(), 0); + for (int dr = -kRadius; dr <= kRadius; ++dr) { + const int* nr = labels.ptr(r + dr); + for (int dc = -kRadius; dc <= kRadius; ++dc) freq[nr[c + dc]]++; + } + + int majority = cur_label; + int majority_ct = freq[cur_label]; + for (int k = 0; k < K; ++k) { + if (freq[k] > majority_ct) { + majority_ct = freq[k]; + majority = k; + } + } + + if (majority == cur_label || majority_ct <= half_window) continue; + + auto ok = SrgbToOklab(brow[c][2], brow[c][1], brow[c][0]); + float dL = ok.L - centroids[majority].L; + float da = ok.a - centroids[majority].a; + float db = ok.b - centroids[majority].b; + if (dL * dL + da * da + db * db < kMaxReassignDist2) { + srow[c] = majority; + ++reassigned; + } + } + } + } + labels = smoothed; + spdlog::debug("QuantizeColors: spatial smoothing reassigned {} pixels", reassigned); +} + +} // namespace + +QuantizeResult QuantizeColors(const cv::Mat& bgr, int num_colors) { + ColorGrid grid; + cv::Mat oklab_cache; + grid.Build(bgr, oklab_cache); + + auto centroids = RunMmcq(grid, num_colors); + + std::vector active_bins; + active_bins.reserve(kGridTotal); + for (int i = 0; i < kGridTotal; ++i) { + if (grid.bins[i].count > 0) active_bins.push_back(i); + } + + RefineCentroidsKMeans(centroids, grid, active_bins); + ConsolidatePalette(centroids, grid, active_bins); + + const int K = static_cast(centroids.size()); + const int rows = bgr.rows; + const int cols = bgr.cols; + + QuantizeResult result; + result.labels = cv::Mat(rows, cols, CV_32SC1); + result.palette.resize(K); + result.centers_lab.resize(K); + +#pragma omp parallel for schedule(static) + for (int r = 0; r < rows; ++r) { + const auto* orow = oklab_cache.ptr(r); + auto* lrow = result.labels.ptr(r); + for (int c = 0; c < cols; ++c) { + lrow[c] = FindNearestCentroid(orow[c][0], orow[c][1], orow[c][2], centroids); + } + } + oklab_cache.release(); + + SmoothLabels(result.labels, bgr, centroids); + + for (int i = 0; i < K; ++i) { + uint8_t r8, g8, b8; + OkLab ok{centroids[i].L, centroids[i].a, centroids[i].b}; + OklabToSrgb(ok, r8, g8, b8); + result.palette[i] = Rgb::FromRgb255(r8, g8, b8); + + Lab cie_lab = result.palette[i].ToLab(); + result.centers_lab[i] = cv::Vec3f(cie_lab.l(), cie_lab.a(), cie_lab.b()); + } + + spdlog::info("QuantizeColors: {} colors, {} pixels", K, rows * cols); + return result; +} + +} // namespace neroued::vectorizer::detail diff --git a/src/quantize/color_quantize.h b/src/quantize/color_quantize.h new file mode 100644 index 0000000..50459e7 --- /dev/null +++ b/src/quantize/color_quantize.h @@ -0,0 +1,28 @@ +#pragma once + +/// \file color_quantize.h +/// \brief Modified Median Cut Quantization (MMCQ) in OKLab color space. + +#include + +#include + +#include + +namespace neroued::vectorizer::detail { + +struct QuantizeResult { + cv::Mat labels; ///< CV_32SC1 per-pixel label map. + std::vector palette; ///< Quantized palette (linear sRGB). + std::vector centers_lab; ///< Palette centers in CIE-Lab (for downstream merge). +}; + +/// Quantize image colors using Modified Median Cut in OKLab space. +/// +/// \param bgr Input BGR uint8 image. +/// \param num_colors Target palette size. If 0, automatically determine a good K +/// via variance-based elbow detection. +/// \return Labels map, palette, and Lab-space centers. +QuantizeResult QuantizeColors(const cv::Mat& bgr, int num_colors); + +} // namespace neroued::vectorizer::detail diff --git a/src/quantize/oklab.h b/src/quantize/oklab.h new file mode 100644 index 0000000..09708e5 --- /dev/null +++ b/src/quantize/oklab.h @@ -0,0 +1,100 @@ +#pragma once + +/// \file oklab.h +/// \brief sRGB <-> OKLab color space conversions (header-only). +/// +/// Based on Björn Ottosson's OKLab specification: +/// sRGB -> linear RGB -> M1 matrix -> cube root -> M2 matrix -> OKLab +/// +/// Reference: https://bottosson.github.io/posts/oklab/ + +#include +#include +#include + +namespace neroued::vectorizer::detail { + +struct OkLab { + float L = 0.f; ///< Lightness [0, 1]. + float a = 0.f; ///< Green–red axis. + float b = 0.f; ///< Blue–yellow axis. +}; + +namespace oklab_internal { + +inline const float* GetSrgbToLinearLUT() { + static float lut[256] = {}; + static bool ready = false; + if (!ready) { + for (int i = 0; i < 256; ++i) { + float s = static_cast(i) / 255.f; + lut[i] = (s <= 0.04045f) ? s / 12.92f : std::pow((s + 0.055f) / 1.055f, 2.4f); + } + ready = true; + } + return lut; +} + +inline float SrgbToLinearFast(uint8_t v) { return GetSrgbToLinearLUT()[v]; } + +inline float SrgbToLinear(float c) { + return (c <= 0.04045f) ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); +} + +inline float LinearToSrgb(float c) { + return (c <= 0.0031308f) ? 12.92f * c : 1.055f * std::pow(c, 1.f / 2.4f) - 0.055f; +} + +inline OkLab LinearRgbToOklab(float r, float g, float b) { + float l = 0.4122214708f * r + 0.5363325363f * g + 0.0514459929f * b; + float m = 0.2119034982f * r + 0.6806995451f * g + 0.1073969566f * b; + float s = 0.0883024619f * r + 0.2817188376f * g + 0.6299787005f * b; + + l = std::cbrt(l); + m = std::cbrt(m); + s = std::cbrt(s); + + OkLab lab; + lab.L = 0.2104542553f * l + 0.7936177850f * m - 0.0040720468f * s; + lab.a = 1.9779984951f * l - 2.4285922050f * m + 0.4505937099f * s; + lab.b = 0.0259040371f * l + 0.7827717662f * m - 0.8086757660f * s; + return lab; +} + +} // namespace oklab_internal + +inline OkLab SrgbToOklab(uint8_t r8, uint8_t g8, uint8_t b8) { + return oklab_internal::LinearRgbToOklab(oklab_internal::SrgbToLinearFast(r8), + oklab_internal::SrgbToLinearFast(g8), + oklab_internal::SrgbToLinearFast(b8)); +} + +inline OkLab SrgbToOklab(float r01, float g01, float b01) { + return oklab_internal::LinearRgbToOklab(oklab_internal::SrgbToLinear(r01), + oklab_internal::SrgbToLinear(g01), + oklab_internal::SrgbToLinear(b01)); +} + +inline void OklabToSrgb(const OkLab& lab, uint8_t& r8, uint8_t& g8, uint8_t& b8) { + float l = lab.L + 0.3963377774f * lab.a + 0.2158037573f * lab.b; + float m = lab.L - 0.1055613458f * lab.a - 0.0638541728f * lab.b; + float s = lab.L - 0.0894841775f * lab.a - 1.2914855480f * lab.b; + + l = l * l * l; + m = m * m * m; + s = s * s * s; + + float r = +4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s; + float g = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s; + float b = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s; + + r = std::clamp(oklab_internal::LinearToSrgb(std::clamp(r, 0.f, 1.f)), 0.f, 1.f); + g = std::clamp(oklab_internal::LinearToSrgb(std::clamp(g, 0.f, 1.f)), 0.f, 1.f); + b = std::clamp(oklab_internal::LinearToSrgb(std::clamp(b, 0.f, 1.f)), 0.f, 1.f); + + r8 = static_cast(std::lround(r * 255.f)); + g8 = static_cast(std::lround(g * 255.f)); + b8 = static_cast(std::lround(b * 255.f)); +} + +} // namespace neroued::vectorizer::detail diff --git a/src/segment/color_segment.cpp b/src/segment/color_segment.cpp index 7463b71..7f4c1e1 100644 --- a/src/segment/color_segment.cpp +++ b/src/segment/color_segment.cpp @@ -1,6 +1,7 @@ -#include "segment/color_segment.h" +#include "color_segment.h" -#include "segment/slic.h" +#include "detail/cv_utils.h" +#include "slic.h" #include #include @@ -202,18 +203,12 @@ void RefineLabelsBoundary(cv::Mat& labels, const cv::Mat& unsmoothed_lab, if (!has_different_neighbor) continue; const cv::Vec3f& pixel = lab_row[c]; - const cv::Vec3f& cur = centers_lab[lid]; - float d_current = (pixel[0] - cur[0]) * (pixel[0] - cur[0]) + - (pixel[1] - cur[1]) * (pixel[1] - cur[1]) + - (pixel[2] - cur[2]) * (pixel[2] - cur[2]); + float d_current = LabDistSq(pixel, centers_lab[lid]); int best_label = lid; float best_dist = d_current; for (int j = 0; j < neighbor_count; ++j) { - const cv::Vec3f& cand = centers_lab[neighbor_set[j]]; - float d = (pixel[0] - cand[0]) * (pixel[0] - cand[0]) + - (pixel[1] - cand[1]) * (pixel[1] - cand[1]) + - (pixel[2] - cand[2]) * (pixel[2] - cand[2]); + float d = LabDistSq(pixel, centers_lab[neighbor_set[j]]); if (d < best_dist) { best_dist = d; best_label = neighbor_set[j]; @@ -296,10 +291,7 @@ void MergeSmallComponents(cv::Mat& labels, const cv::Mat& lab, std::vector= static_cast(centers_lab.size())) continue; - float dl = mean_lab[0] - centers_lab[candidate][0]; - float da = mean_lab[1] - centers_lab[candidate][1]; - float db = mean_lab[2] - centers_lab[candidate][2]; - float d2 = dl * dl + da * da + db * db; + float d2 = LabDistSq(mean_lab, centers_lab[candidate]); if (d2 < best_dist || (d2 == best_dist && vote > best_border_vote)) { best_border_vote = vote; best_dist = d2; @@ -434,27 +426,25 @@ std::vector ComputePalette(const cv::Mat& bgr, const cv::Mat& labels, int n return palette; } -// ── Auto color count estimation ────────────────────────────────────────────── -// -// Selects K that minimizes: reconstruction_error + fragmentation_penalty + complexity_cost. -// Uses dual sampling (pixel sample for clustering, proxy image for spatial analysis). +namespace { -int EstimateOptimalColors(const cv::Mat& bgr) { +struct ColorSampleData { + cv::Mat km_samples; + cv::Mat proxy_cielab; + cv::Mat proxy; + int n_samples; + int ch; + bool achromatic; + int proxy_area; + float tiny_px_threshold; +}; + +ColorSampleData BuildColorSamples(const cv::Mat& bgr) { constexpr int kTargetSamples = 30000; constexpr int kProxyShortEdge = 300; constexpr float kAchromaticP90Threshold = 8.0f; - constexpr int kCandidates[] = {2, 3, 4, 6, 8, 12, 16, 24}; - constexpr int kNumCandidates = 8; constexpr float kTinyAreaFrac = 0.002f; - constexpr float kW_meanDE = 1.5f; - constexpr float kW_p95DE = 0.5f; - constexpr float kW_tinyRate = 20.0f; - constexpr float kW_compDens = 20.0f; - constexpr float kW_logK = 3.0f; - - // ── 1. Dual sampling ───────────────────────────────────────────────────── - const int total_px = bgr.rows * bgr.cols; const float grid_step = std::sqrt(static_cast(std::max(1, total_px)) / kTargetSamples); const int row_step = std::max(1, static_cast(grid_step)); @@ -488,8 +478,6 @@ int EstimateOptimalColors(const cv::Mat& bgr) { cv::cvtColor(proxy, proxy_lab8, cv::COLOR_BGR2Lab); const int proxy_area = proxy.rows * proxy.cols; - // ── 2. Achromatic detection (p90 chroma in LAB) ────────────────────────── - std::vector chromas(n_samples); for (int i = 0; i < n_samples; ++i) { const cv::Vec3b p = sample_lab8.at(i, 0); @@ -501,8 +489,6 @@ int EstimateOptimalColors(const cv::Mat& bgr) { std::nth_element(chromas.begin(), chromas.begin() + p90_idx, chromas.end()); const bool achromatic = (chromas[p90_idx] < kAchromaticP90Threshold); - // ── 3. Convert to CIELAB float (L: 0-100, a/b: -128..127) ─────────────── - const int ch = achromatic ? 1 : 3; cv::Mat km_samples(n_samples, ch, CV_32F); @@ -529,91 +515,117 @@ int EstimateOptimalColors(const cv::Mat& bgr) { } } - const float tiny_px_threshold = proxy_area * kTinyAreaFrac; - - // ── 4. Evaluate each candidate K ───────────────────────────────────────── - - int best_k = 16; - float best_score = std::numeric_limits::max(); + return {km_samples, proxy_cielab, proxy, n_samples, + ch, achromatic, proxy_area, proxy_area * kTinyAreaFrac}; +} - for (int ci = 0; ci < kNumCandidates; ++ci) { - const int K = kCandidates[ci]; - if (K > n_samples) break; +float ScoreCandidateK(int K, const ColorSampleData& data) { + constexpr float kW_meanDE = 1.5f; + constexpr float kW_p95DE = 0.5f; + constexpr float kW_tinyRate = 20.0f; + constexpr float kW_compDens = 20.0f; + constexpr float kW_logK = 3.0f; - cv::Mat km_labels, km_centers; - cv::kmeans(km_samples, K, km_labels, - cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT, 20, 0.5), 3, - cv::KMEANS_PP_CENTERS, km_centers); + cv::Mat km_labels, km_centers; + cv::kmeans(data.km_samples, K, km_labels, + cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT, 20, 0.5), 3, + cv::KMEANS_PP_CENTERS, km_centers); - cv::Mat proxy_labels(proxy.rows, proxy.cols, CV_32SC1); - std::vector dE_vec(proxy_area); - - for (int i = 0; i < proxy_area; ++i) { - float min_sq = std::numeric_limits::max(); - int lbl = 0; - for (int k = 0; k < km_centers.rows; ++k) { - float sq = 0.0f; - for (int d = 0; d < ch; ++d) { - const float diff = proxy_cielab.at(i, d) - km_centers.at(k, d); - sq += diff * diff; - } - if (sq < min_sq) { - min_sq = sq; - lbl = k; - } + cv::Mat proxy_labels(data.proxy.rows, data.proxy.cols, CV_32SC1); + std::vector dE_vec(data.proxy_area); + + for (int i = 0; i < data.proxy_area; ++i) { + float min_sq = std::numeric_limits::max(); + int lbl = 0; + for (int k = 0; k < km_centers.rows; ++k) { + float sq = 0.0f; + for (int d = 0; d < data.ch; ++d) { + const float diff = data.proxy_cielab.at(i, d) - km_centers.at(k, d); + sq += diff * diff; + } + if (sq < min_sq) { + min_sq = sq; + lbl = k; } - proxy_labels.at(i / proxy.cols, i % proxy.cols) = lbl; - dE_vec[i] = std::sqrt(min_sq); } + proxy_labels.at(i / data.proxy.cols, i % data.proxy.cols) = lbl; + dE_vec[i] = std::sqrt(min_sq); + } - float sum_dE = 0.0f; - for (float d : dE_vec) sum_dE += d; - const float mean_dE = sum_dE / std::max(1, proxy_area); - - const int p95 = std::max(0, static_cast(0.95f * (proxy_area - 1))); - std::nth_element(dE_vec.begin(), dE_vec.begin() + p95, dE_vec.end()); - const float p95_dE = dE_vec[p95]; - - int total_comp = 0; - int tiny_comp = 0; - for (int label = 0; label < km_centers.rows; ++label) { - cv::Mat mask; - cv::compare(proxy_labels, label, mask, cv::CMP_EQ); - if (cv::countNonZero(mask) == 0) continue; - - cv::Mat cc_labels; - int n_cc = cv::connectedComponents(mask, cc_labels, 8, CV_32S) - 1; - total_comp += n_cc; - - std::vector areas(n_cc + 1, 0); - for (int r = 0; r < cc_labels.rows; ++r) { - const int* row = cc_labels.ptr(r); - for (int c2 = 0; c2 < cc_labels.cols; ++c2) - if (row[c2] > 0) areas[row[c2]]++; - } - for (int cc_id = 1; cc_id <= n_cc; ++cc_id) - if (static_cast(areas[cc_id]) < tiny_px_threshold) ++tiny_comp; + float sum_dE = 0.0f; + for (float d : dE_vec) sum_dE += d; + const float mean_dE = sum_dE / std::max(1, data.proxy_area); + + const int p95 = std::max(0, static_cast(0.95f * (data.proxy_area - 1))); + std::nth_element(dE_vec.begin(), dE_vec.begin() + p95, dE_vec.end()); + const float p95_dE = dE_vec[p95]; + + int total_comp = 0; + int tiny_comp = 0; + for (int label = 0; label < km_centers.rows; ++label) { + cv::Mat mask; + cv::compare(proxy_labels, label, mask, cv::CMP_EQ); + if (cv::countNonZero(mask) == 0) continue; + + cv::Mat cc_labels; + int n_cc = cv::connectedComponents(mask, cc_labels, 8, CV_32S) - 1; + total_comp += n_cc; + + std::vector areas(n_cc + 1, 0); + for (int r = 0; r < cc_labels.rows; ++r) { + const int* row = cc_labels.ptr(r); + for (int c2 = 0; c2 < cc_labels.cols; ++c2) + if (row[c2] > 0) areas[row[c2]]++; } + for (int cc_id = 1; cc_id <= n_cc; ++cc_id) + if (static_cast(areas[cc_id]) < data.tiny_px_threshold) ++tiny_comp; + } - const float tiny_rate = - (total_comp > 0) ? static_cast(tiny_comp) / total_comp : 0.0f; - const float comp_density = static_cast(total_comp) / std::max(1, proxy_area); - const float log2K = std::log2(static_cast(K)); + const float tiny_rate = (total_comp > 0) ? static_cast(tiny_comp) / total_comp : 0.0f; + const float comp_density = static_cast(total_comp) / std::max(1, data.proxy_area); + const float log2K = std::log2(static_cast(K)); - const float score = kW_meanDE * mean_dE + kW_p95DE * p95_dE + kW_tinyRate * tiny_rate + - kW_compDens * comp_density + kW_logK * log2K; + const float score = kW_meanDE * mean_dE + kW_p95DE * p95_dE + kW_tinyRate * tiny_rate + + kW_compDens * comp_density + kW_logK * log2K; - spdlog::debug("AutoColor K={:2d}: dE_mean={:.2f} dE_p95={:.2f} tiny={:.3f} " - "comp_dens={:.5f} log2K={:.2f} => score={:.2f}", - K, mean_dE, p95_dE, tiny_rate, comp_density, log2K, score); + spdlog::debug("AutoColor K={:2d}: dE_mean={:.2f} dE_p95={:.2f} tiny={:.3f} " + "comp_dens={:.5f} log2K={:.2f} => score={:.2f}", + K, mean_dE, p95_dE, tiny_rate, comp_density, log2K, score); + return score; +} + +} // namespace - if (score < best_score) { - best_score = score; - best_k = K; +int EstimateOptimalColors(const cv::Mat& bgr) { + constexpr int kCandidates[] = {2, 3, 4, 6, 8, 12, 16, 24}; + constexpr int kNumCandidates = 8; + + auto data = BuildColorSamples(bgr); + + int actual_candidates = kNumCandidates; + for (int ci = 0; ci < kNumCandidates; ++ci) { + if (kCandidates[ci] > data.n_samples) { + actual_candidates = ci; + break; + } + } + + std::vector scores(actual_candidates, std::numeric_limits::max()); +#pragma omp parallel for schedule(dynamic) + for (int ci = 0; ci < actual_candidates; ++ci) { + scores[ci] = ScoreCandidateK(kCandidates[ci], data); + } + + int best_k = 16; + float best_score = std::numeric_limits::max(); + for (int ci = 0; ci < actual_candidates; ++ci) { + if (scores[ci] < best_score) { + best_score = scores[ci]; + best_k = kCandidates[ci]; } } - spdlog::info("AutoColor: achromatic={}, selected K={} (score={:.2f})", achromatic, best_k, + spdlog::info("AutoColor: achromatic={}, selected K={} (score={:.2f})", data.achromatic, best_k, best_score); return best_k; } diff --git a/src/segment/morphology.cpp b/src/segment/morphology.cpp index 93daa60..0c8aceb 100644 --- a/src/segment/morphology.cpp +++ b/src/segment/morphology.cpp @@ -4,70 +4,55 @@ namespace neroued::vectorizer::detail { -cv::Mat ZhangSuenThinning(const cv::Mat& binary_mask) { - cv::Mat img; - binary_mask.convertTo(img, CV_8UC1); - img /= 255; +namespace { - cv::Mat prev = cv::Mat::zeros(img.size(), CV_8UC1); - cv::Mat marker; +cv::Mat ApplySubiteration(cv::Mat& img, int phase) { + cv::Mat marker = cv::Mat::zeros(img.size(), CV_8UC1); + for (int r = 1; r < img.rows - 1; ++r) { + for (int c = 1; c < img.cols - 1; ++c) { + if (img.at(r, c) == 0) continue; + uint8_t p2 = img.at(r - 1, c); + uint8_t p3 = img.at(r - 1, c + 1); + uint8_t p4 = img.at(r, c + 1); + uint8_t p5 = img.at(r + 1, c + 1); + uint8_t p6 = img.at(r + 1, c); + uint8_t p7 = img.at(r + 1, c - 1); + uint8_t p8 = img.at(r, c - 1); + uint8_t p9 = img.at(r - 1, c - 1); - while (true) { - marker = cv::Mat::zeros(img.size(), CV_8UC1); - for (int r = 1; r < img.rows - 1; ++r) { - for (int c = 1; c < img.cols - 1; ++c) { - if (img.at(r, c) == 0) continue; - uint8_t p2 = img.at(r - 1, c); - uint8_t p3 = img.at(r - 1, c + 1); - uint8_t p4 = img.at(r, c + 1); - uint8_t p5 = img.at(r + 1, c + 1); - uint8_t p6 = img.at(r + 1, c); - uint8_t p7 = img.at(r + 1, c - 1); - uint8_t p8 = img.at(r, c - 1); - uint8_t p9 = img.at(r - 1, c - 1); + int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; + if (B < 2 || B > 6) continue; - int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; - if (B < 2 || B > 6) continue; - - int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) + (p4 == 0 && p5 == 1) + - (p5 == 0 && p6 == 1) + (p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) + - (p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1); - if (A != 1) continue; + int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) + (p4 == 0 && p5 == 1) + + (p5 == 0 && p6 == 1) + (p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) + + (p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1); + if (A != 1) continue; + if (phase == 0) { if (p2 * p4 * p6 != 0) continue; if (p4 * p6 * p8 != 0) continue; - marker.at(r, c) = 1; + } else { + if (p2 * p4 * p8 != 0) continue; + if (p2 * p6 * p8 != 0) continue; } + marker.at(r, c) = 1; } - img -= marker; + } + return marker; +} - marker = cv::Mat::zeros(img.size(), CV_8UC1); - for (int r = 1; r < img.rows - 1; ++r) { - for (int c = 1; c < img.cols - 1; ++c) { - if (img.at(r, c) == 0) continue; - uint8_t p2 = img.at(r - 1, c); - uint8_t p3 = img.at(r - 1, c + 1); - uint8_t p4 = img.at(r, c + 1); - uint8_t p5 = img.at(r + 1, c + 1); - uint8_t p6 = img.at(r + 1, c); - uint8_t p7 = img.at(r + 1, c - 1); - uint8_t p8 = img.at(r, c - 1); - uint8_t p9 = img.at(r - 1, c - 1); +} // namespace - int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; - if (B < 2 || B > 6) continue; +cv::Mat ZhangSuenThinning(const cv::Mat& binary_mask) { + cv::Mat img; + binary_mask.convertTo(img, CV_8UC1); + img /= 255; - int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) + (p4 == 0 && p5 == 1) + - (p5 == 0 && p6 == 1) + (p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) + - (p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1); - if (A != 1) continue; + cv::Mat prev = cv::Mat::zeros(img.size(), CV_8UC1); - if (p2 * p4 * p8 != 0) continue; - if (p2 * p6 * p8 != 0) continue; - marker.at(r, c) = 1; - } - } - img -= marker; + while (true) { + img -= ApplySubiteration(img, 0); + img -= ApplySubiteration(img, 1); if (cv::countNonZero(img != prev) == 0) break; img.copyTo(prev); diff --git a/src/stacking/depth_order.cpp b/src/stacking/depth_order.cpp new file mode 100644 index 0000000..5a54ed1 --- /dev/null +++ b/src/stacking/depth_order.cpp @@ -0,0 +1,645 @@ +#include "depth_order.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace neroued::vectorizer::detail { + +namespace { + +struct PairHash { + std::size_t operator()(const std::pair& p) const { + return std::hash()(static_cast(p.first) << 32 | p.second); + } +}; + +using AdjSet = std::unordered_set, PairHash>; + +cv::Mat BuildLayerMap(const std::vector& layers, int img_rows, int img_cols) { + cv::Mat layer_map(img_rows, img_cols, CV_32SC1, cv::Scalar(-1)); + for (int i = 0; i < static_cast(layers.size()); ++i) { + const auto& bbox = layers[i].bbox; + const auto& mask = layers[i].mask; + for (int r = 0; r < bbox.height; ++r) { + const auto* mrow = mask.ptr(r); + auto* lrow = layer_map.ptr(r + bbox.y); + for (int c = 0; c < bbox.width; ++c) { + if (mrow[c] > 0) lrow[c + bbox.x] = i; + } + } + } + return layer_map; +} + +AdjSet BuildAdjacency(const cv::Mat& layer_map, int img_rows, int img_cols) { + AdjSet adj; + for (int r = 0; r < img_rows; ++r) { + const auto* row = layer_map.ptr(r); + for (int c = 0; c < img_cols; ++c) { + int cur = row[c]; + if (cur < 0) continue; + if (c + 1 < img_cols) { + int right = row[c + 1]; + if (right >= 0 && right != cur) { + adj.emplace(std::min(cur, right), std::max(cur, right)); + } + } + if (r + 1 < img_rows) { + int down = layer_map.ptr(r + 1)[c]; + if (down >= 0 && down != cur) { + adj.emplace(std::min(cur, down), std::max(cur, down)); + } + } + } + } + return adj; +} + +struct BorderInfo { + std::vector touches_top, touches_bottom, touches_left, touches_right; + std::vector border_px; + int total_border_px = 0; +}; + +BorderInfo CollectBorderInfo(const std::vector& layers, int N, int img_rows, + int img_cols, const cv::Mat& layer_map) { + BorderInfo info; + info.touches_top.assign(N, false); + info.touches_bottom.assign(N, false); + info.touches_left.assign(N, false); + info.touches_right.assign(N, false); + + for (int i = 0; i < N; ++i) { + const auto& bbox = layers[i].bbox; + const auto& mask = layers[i].mask; + + if (bbox.y == 0) { + const auto* row = mask.ptr(0); + for (int c = 0; c < bbox.width && !info.touches_top[i]; ++c) + if (row[c] > 0) info.touches_top[i] = true; + } + if (bbox.y + bbox.height >= img_rows) { + const auto* row = mask.ptr(img_rows - 1 - bbox.y); + for (int c = 0; c < bbox.width && !info.touches_bottom[i]; ++c) + if (row[c] > 0) info.touches_bottom[i] = true; + } + if (bbox.x == 0) { + for (int r = 0; r < bbox.height && !info.touches_left[i]; ++r) + if (mask.at(r, 0) > 0) info.touches_left[i] = true; + } + if (bbox.x + bbox.width >= img_cols) { + int local_c = img_cols - 1 - bbox.x; + for (int r = 0; r < bbox.height && !info.touches_right[i]; ++r) + if (mask.at(r, local_c) > 0) info.touches_right[i] = true; + } + } + + constexpr int kBorderWidth = 3; + info.border_px.assign(N, 0); + for (int r = 0; r < img_rows; ++r) { + if (r >= kBorderWidth && r < img_rows - kBorderWidth) continue; + const auto* row = layer_map.ptr(r); + for (int c = 0; c < img_cols; ++c) { + if (r >= kBorderWidth && c >= kBorderWidth && c < img_cols - kBorderWidth) continue; + int idx = row[c]; + if (idx >= 0) { + info.border_px[idx]++; + info.total_border_px++; + } + } + } + + return info; +} + +int SelectBackground(const std::vector& layers, const BorderInfo& info) { + const int N = static_cast(layers.size()); + + int best = -1; + double best_area = -1.0; + for (int i = 0; i < N; ++i) { + if (info.touches_top[i] && info.touches_bottom[i] && info.touches_left[i] && + info.touches_right[i]) { + if (layers[i].area > best_area) { + best = i; + best_area = layers[i].area; + } + } + } + if (best >= 0) return best; + + double max_area = 0.0; + for (int i = 0; i < N; ++i) max_area = std::max(max_area, layers[i].area); + + double best_score = -1.0; + for (int i = 0; i < N; ++i) { + int sides = + static_cast(info.touches_top[i]) + static_cast(info.touches_bottom[i]) + + static_cast(info.touches_left[i]) + static_cast(info.touches_right[i]); + if (sides < 2) continue; + double side_ratio = sides / 4.0; + double area_ratio = (max_area > 0.0) ? layers[i].area / max_area : 0.0; + double border_ratio = (info.total_border_px > 0) + ? static_cast(info.border_px[i]) / info.total_border_px + : 0.0; + + double score = side_ratio * 0.3 + area_ratio * 0.3 + border_ratio * 0.4; + if (score > best_score) { + best_score = score; + best = i; + } + } + if (best >= 0) return best; + + best = 0; + for (int i = 1; i < N; ++i) { + if (layers[i].area > layers[best].area) best = i; + } + return best; +} + +int FindBackground(const std::vector& layers, int img_rows, int img_cols, + const cv::Mat& layer_map) { + const int N = static_cast(layers.size()); + auto info = CollectBorderInfo(layers, N, img_rows, img_cols, layer_map); + return SelectBackground(layers, info); +} + +struct RoiMask { + cv::Mat mask; + cv::Rect bbox; +}; + +double ComputeRoiIntersectionArea(const cv::Rect& a_bbox, const cv::Mat& a_mask, + const cv::Rect& b_bbox, const cv::Mat& b_mask) { + cv::Rect overlap = a_bbox & b_bbox; + if (overlap.area() <= 0) return 0.0; + + cv::Rect a_roi(overlap.x - a_bbox.x, overlap.y - a_bbox.y, overlap.width, overlap.height); + cv::Rect b_roi(overlap.x - b_bbox.x, overlap.y - b_bbox.y, overlap.width, overlap.height); + + cv::Mat inter; + cv::bitwise_and(a_mask(a_roi), b_mask(b_roi), inter); + return cv::countNonZero(inter); +} + +bool BboxStrictlyContains(const cv::Rect& outer, const cv::Rect& inner) { + return inner.x >= outer.x && inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height && outer.area() > inner.area(); +} + +RoiMask MakeDilatedMask(const ShapeLayer& layer, int img_rows, int img_cols, int radius) { + int x0 = std::max(0, layer.bbox.x - radius); + int y0 = std::max(0, layer.bbox.y - radius); + int x1 = std::min(img_cols, layer.bbox.x + layer.bbox.width + radius); + int y1 = std::min(img_rows, layer.bbox.y + layer.bbox.height + radius); + cv::Rect expanded(x0, y0, x1 - x0, y1 - y0); + + cv::Mat local = cv::Mat::zeros(expanded.size(), CV_8UC1); + cv::Rect src_roi(layer.bbox.x - x0, layer.bbox.y - y0, layer.bbox.width, layer.bbox.height); + layer.mask.copyTo(local(src_roi)); + + cv::Mat kernel = + cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(2 * radius + 1, 2 * radius + 1)); + cv::Mat dilated; + cv::dilate(local, dilated, kernel); + return {dilated, expanded}; +} + +struct TarjanSCC { + const std::vector>& graph; + const std::unordered_set& removed; + const std::unordered_set& active; + int N; + + std::vector disc, low; + std::vector on_stack; + std::vector stk; + std::vector> sccs; + int timer = 0; + + void Run() { + disc.assign(N, -1); + low.assign(N, -1); + on_stack.assign(N, false); + for (int u : active) { + if (disc[u] < 0) Dfs(u); + } + } + + void Dfs(int u) { + disc[u] = low[u] = timer++; + stk.push_back(u); + on_stack[u] = true; + + for (int v : graph[u]) { + long long key = static_cast(u) * N + v; + if (!active.count(v) || removed.count(key)) continue; + if (disc[v] < 0) { + Dfs(v); + low[u] = std::min(low[u], low[v]); + } else if (on_stack[v]) { + low[u] = std::min(low[u], disc[v]); + } + } + + if (low[u] == disc[u]) { + std::vector scc; + int w; + do { + w = stk.back(); + stk.pop_back(); + on_stack[w] = false; + scc.push_back(w); + } while (w != u); + if (scc.size() > 1) sccs.push_back(std::move(scc)); + } + } +}; + +long long DirEdgeKey(int from, int to, int N) { return static_cast(from) * N + to; } + +struct DepthGraph { + int N; + std::vector> graph; + std::unordered_map confidence; +}; + +DepthGraph BuildDepthGraph(const std::vector& layers, const AdjSet& adj, int bg_idx, + int img_rows, int img_cols) { + const int N = static_cast(layers.size()); + DepthGraph dg; + dg.N = N; + dg.graph.resize(N); + + constexpr double kDeltaFloor = 0.02; + const double significance_threshold = static_cast(img_rows) * img_cols * 0.0002; + const int dilate_radius = std::clamp( + static_cast(std::sqrt(static_cast(img_rows) * img_cols) * 0.008), 5, 20); + + std::unordered_map dilated_cache; + auto get_dilated = [&](int idx) -> const RoiMask& { + auto it = dilated_cache.find(idx); + if (it != dilated_cache.end()) return it->second; + return dilated_cache + .emplace(idx, MakeDilatedMask(layers[idx], img_rows, img_cols, dilate_radius)) + .first->second; + }; + + struct PairD { + int i, j; + double d_ij, area_i, area_j; + }; + + std::vector pair_data; + std::vector all_d_abs; + int skipped_small_pairs = 0; + + for (auto& [i, j] : adj) { + if (i == bg_idx || j == bg_idx) { + int other = (i == bg_idx) ? j : i; + dg.graph[bg_idx].push_back(other); + continue; + } + + double area_i = layers[i].area; + double area_j = layers[j].area; + if (area_i < 1.0 || area_j < 1.0) continue; + + if (area_i < significance_threshold && area_j < significance_threshold) { + ++skipped_small_pairs; + continue; + } + + const auto& ext_j = get_dilated(j); + const auto& ext_i = get_dilated(i); + + double inter_ij = + ComputeRoiIntersectionArea(layers[i].bbox, layers[i].mask, ext_j.bbox, ext_j.mask); + double inter_ji = + ComputeRoiIntersectionArea(layers[j].bbox, layers[j].mask, ext_i.bbox, ext_i.mask); + double a_ij = inter_ij / area_i; + double a_ji = inter_ji / area_j; + double d_ij_val = a_ij - a_ji; + + pair_data.push_back({i, j, d_ij_val, area_i, area_j}); + if (std::abs(d_ij_val) > 1e-6) all_d_abs.push_back(std::abs(d_ij_val)); + } + + double adaptive_delta = kDeltaFloor; + if (all_d_abs.size() > 10) { + std::sort(all_d_abs.begin(), all_d_abs.end()); + adaptive_delta = std::max(kDeltaFloor, all_d_abs[all_d_abs.size() / 4]); + } + + for (const auto& p : pair_data) { + double conf = std::abs(p.d_ij) * std::log2(std::max(p.area_i, p.area_j) + 1.0); + + if (p.d_ij > adaptive_delta) { + dg.graph[p.j].push_back(p.i); + dg.confidence[DirEdgeKey(p.j, p.i, N)] = conf; + } else if (p.d_ij < -adaptive_delta) { + dg.graph[p.i].push_back(p.j); + dg.confidence[DirEdgeKey(p.i, p.j, N)] = conf; + } else { + constexpr double kAreaRatioFallback = 3.0; + double ratio = std::max(p.area_i, p.area_j) / std::min(p.area_i, p.area_j); + if (ratio > kAreaRatioFallback) { + int big = (p.area_i > p.area_j) ? p.i : p.j; + int small = (p.area_i > p.area_j) ? p.j : p.i; + dg.graph[big].push_back(small); + dg.confidence[DirEdgeKey(big, small, N)] = kDeltaFloor * 0.1; + } + } + } + + spdlog::debug( + "ComputeDepthOrder: skipped_small_pairs={}, dilate_radius={}, adaptive_delta={:.4f}", + skipped_small_pairs, dilate_radius, adaptive_delta); + + return dg; +} + +void AddContainmentEdges(DepthGraph& dg, const std::vector& layers, int bg_idx) { + constexpr double kContainAreaRatio = 4.0; + constexpr int kMaxCandidates = 50; + constexpr double kDeltaFloor = 0.02; + const int N = dg.N; + + std::vector by_bbox_area(N); + std::iota(by_bbox_area.begin(), by_bbox_area.end(), 0); + std::sort(by_bbox_area.begin(), by_bbox_area.end(), + [&](int a, int b) { return layers[a].bbox.area() > layers[b].bbox.area(); }); + + std::unordered_set existing_edges; + for (int u = 0; u < N; ++u) + for (int v : dg.graph[u]) existing_edges.insert(DirEdgeKey(u, v, N)); + + int M = std::min(kMaxCandidates, N); + int containment_ct = 0; + for (int oi = 0; oi < M; ++oi) { + int outer = by_bbox_area[oi]; + if (outer == bg_idx) continue; + const auto& ob = layers[outer].bbox; + for (int ii = oi + 1; ii < N; ++ii) { + int inner = by_bbox_area[ii]; + if (inner == bg_idx) continue; + if (layers[outer].area < kContainAreaRatio * layers[inner].area) continue; + if (existing_edges.count(DirEdgeKey(outer, inner, N))) continue; + if (existing_edges.count(DirEdgeKey(inner, outer, N))) continue; + + if (!BboxStrictlyContains(ob, layers[inner].bbox)) continue; + + dg.graph[outer].push_back(inner); + dg.confidence[DirEdgeKey(outer, inner, N)] = kDeltaFloor * 0.05; + existing_edges.insert(DirEdgeKey(outer, inner, N)); + ++containment_ct; + } + } + if (containment_ct > 0) + spdlog::debug("ComputeDepthOrder: added {} containment edges", containment_ct); +} + +std::vector TopologicalSortWithCycleBreaking(const DepthGraph& dg, + const std::vector& layers) { + const int N = dg.N; + std::unordered_set removed_edges; + + auto area_cmp = [&](int a, int b) { return layers[a].area < layers[b].area; }; + using AreaPQ = std::priority_queue, decltype(area_cmp)>; + + auto run_kahn = [&]() -> std::vector { + std::vector deg(N, 0); + for (int u = 0; u < N; ++u) { + for (int v : dg.graph[u]) { + if (removed_edges.count(DirEdgeKey(u, v, N))) continue; + deg[v]++; + } + } + AreaPQ q(area_cmp); + for (int i = 0; i < N; ++i) { + if (deg[i] == 0) q.push(i); + } + std::vector order; + order.reserve(N); + while (!q.empty()) { + int u = q.top(); + q.pop(); + order.push_back(u); + for (int v : dg.graph[u]) { + if (removed_edges.count(DirEdgeKey(u, v, N))) continue; + if (--deg[v] == 0) q.push(v); + } + } + return order; + }; + + std::vector topo_order = run_kahn(); + int removed_count = 0; + int scc_rounds = 0; + + while (static_cast(topo_order.size()) < N) { + ++scc_rounds; + std::unordered_set placed(topo_order.begin(), topo_order.end()); + std::unordered_set active; + for (int i = 0; i < N; ++i) + if (!placed.count(i)) active.insert(i); + + TarjanSCC tarjan{dg.graph, removed_edges, active, N, {}, {}, {}, {}, {}, 0}; + tarjan.Run(); + + if (tarjan.sccs.empty()) break; + + int batch = 0; + for (const auto& scc : tarjan.sccs) { + std::unordered_set scc_set(scc.begin(), scc.end()); + long long weakest_key = -1; + double weakest_conf = std::numeric_limits::max(); + + for (int u : scc) { + for (int v : dg.graph[u]) { + if (!scc_set.count(v)) continue; + long long key = DirEdgeKey(u, v, N); + if (removed_edges.count(key)) continue; + double conf = std::numeric_limits::max(); + auto it = dg.confidence.find(key); + if (it != dg.confidence.end()) conf = it->second; + if (conf < weakest_conf) { + weakest_conf = conf; + weakest_key = key; + } + } + } + + if (weakest_key >= 0) { + removed_edges.insert(weakest_key); + ++batch; + } + } + + removed_count += batch; + spdlog::debug("ComputeDepthOrder: SCC round {}: {} SCCs, removed {} edges", scc_rounds, + tarjan.sccs.size(), batch); + + topo_order = run_kahn(); + } + + if (removed_count > 0) { + spdlog::warn("ComputeDepthOrder: removed {} edge(s) in {} SCC round(s)", removed_count, + scc_rounds); + } + + if (static_cast(topo_order.size()) < N) { + std::unordered_set in_topo(topo_order.begin(), topo_order.end()); + std::vector remaining; + for (int i = 0; i < N; ++i) { + if (!in_topo.count(i)) remaining.push_back(i); + } + std::sort(remaining.begin(), remaining.end(), + [&](int a, int b) { return layers[a].area > layers[b].area; }); + for (int idx : remaining) topo_order.push_back(idx); + } + + return topo_order; +} + +} // namespace + +std::vector ExtractShapeLayers(const cv::Mat& labels, int num_labels, double min_area) { + const int rows = labels.rows; + const int cols = labels.cols; + + cv::Mat cc_labels(rows, cols, CV_32SC1, cv::Scalar(0)); + int next_cc = 1; + + struct CCInfo { + int label; + int min_r, min_c, max_r, max_c; + int area; + }; + + std::vector cc_infos; + cc_infos.reserve(256); + + std::vector> stack; + stack.reserve(std::max(rows, cols) * 4); + + for (int r = 0; r < rows; ++r) { + const int* lrow = labels.ptr(r); + const int* ccrow = cc_labels.ptr(r); + for (int c = 0; c < cols; ++c) { + if (lrow[c] < 0 || ccrow[c] != 0) continue; + + int lid = lrow[c]; + int cc_id = next_cc++; + CCInfo info{lid, r, c, r, c, 0}; + + stack.clear(); + stack.push_back({r, c}); + cc_labels.at(r, c) = cc_id; + + while (!stack.empty()) { + auto [cr, cc_] = stack.back(); + stack.pop_back(); + info.area++; + info.min_r = std::min(info.min_r, cr); + info.max_r = std::max(info.max_r, cr); + info.min_c = std::min(info.min_c, cc_); + info.max_c = std::max(info.max_c, cc_); + + constexpr int dr[] = {-1, 1, 0, 0}; + constexpr int dc[] = {0, 0, -1, 1}; + for (int d = 0; d < 4; ++d) { + int nr = cr + dr[d], nc = cc_ + dc[d]; + if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; + if (cc_labels.at(nr, nc) != 0) continue; + if (labels.at(nr, nc) != lid) continue; + cc_labels.at(nr, nc) = cc_id; + stack.push_back({nr, nc}); + } + } + + cc_infos.push_back(info); + } + } + + std::vector layers; + int skipped = 0; + + for (int i = 0; i < static_cast(cc_infos.size()); ++i) { + const auto& info = cc_infos[i]; + if (info.area < min_area) { + ++skipped; + continue; + } + + int cc_id = i + 1; + cv::Rect bbox(info.min_c, info.min_r, info.max_c - info.min_c + 1, + info.max_r - info.min_r + 1); + cv::Mat mask(bbox.height, bbox.width, CV_8UC1, cv::Scalar(0)); + + for (int r = bbox.y; r < bbox.y + bbox.height; ++r) { + const int* ccrow = cc_labels.ptr(r); + auto* mrow = mask.ptr(r - bbox.y); + for (int c = bbox.x; c < bbox.x + bbox.width; ++c) { + if (ccrow[c] == cc_id) mrow[c - bbox.x] = 255; + } + } + + ShapeLayer layer; + layer.label = info.label; + layer.cc_id = cc_id; + layer.bbox = bbox; + layer.mask = std::move(mask); + layer.area = info.area; + layers.push_back(std::move(layer)); + } + + spdlog::info("ExtractShapeLayers: num_labels={}, shape_layers={}, skipped_small={}", num_labels, + layers.size(), skipped); + return layers; +} + +std::vector ComputeDepthOrder(const std::vector& layers, int img_rows, + int img_cols) { + const int N = static_cast(layers.size()); + if (N <= 1) { + std::vector order(N); + std::iota(order.begin(), order.end(), 0); + return order; + } + + cv::Mat layer_map = BuildLayerMap(layers, img_rows, img_cols); + int bg_idx = FindBackground(layers, img_rows, img_cols, layer_map); + spdlog::debug("ComputeDepthOrder: N={}, background_idx={}, background_area={:.0f}", N, bg_idx, + bg_idx >= 0 ? layers[bg_idx].area : 0.0); + + auto adj = BuildAdjacency(layer_map, img_rows, img_cols); + spdlog::debug("ComputeDepthOrder: adjacent_pairs={}", adj.size()); + + auto dg = BuildDepthGraph(layers, adj, bg_idx, img_rows, img_cols); + AddContainmentEdges(dg, layers, bg_idx); + auto topo_order = TopologicalSortWithCycleBreaking(dg, layers); + + if (bg_idx >= 0 && !topo_order.empty() && topo_order[0] != bg_idx) { + auto it = std::find(topo_order.begin(), topo_order.end(), bg_idx); + if (it != topo_order.end()) { + topo_order.erase(it); + topo_order.insert(topo_order.begin(), bg_idx); + } + } + + spdlog::info("ComputeDepthOrder: final ordering computed, {} layers", topo_order.size()); + return topo_order; +} + +} // namespace neroued::vectorizer::detail diff --git a/src/stacking/depth_order.h b/src/stacking/depth_order.h new file mode 100644 index 0000000..89f3dd8 --- /dev/null +++ b/src/stacking/depth_order.h @@ -0,0 +1,48 @@ +#pragma once + +/// \file depth_order.h +/// \brief Shape layer extraction and depth ordering for the stacking vectorization model. + +#include + +#include + +namespace neroued::vectorizer::detail { + +/// A single shape layer: one connected component of a single color label. +/// The mask is cropped to the bounding box to minimize memory usage. +struct ShapeLayer { + int label = -1; ///< Original quantized color label. + int cc_id = -1; ///< Connected-component id within that label. + cv::Rect bbox; ///< Bounding rectangle in full-image coordinates. + cv::Mat mask; ///< CV_8UC1 binary mask cropped to bbox (255 = shape, 0 = background). + double area = 0.0; ///< Pixel area. +}; + +/// Reconstruct a full-size mask from a ROI-cropped ShapeLayer. +inline cv::Mat FullSizeMask(const ShapeLayer& layer, cv::Size img_size) { + cv::Mat full = cv::Mat::zeros(img_size, CV_8UC1); + if (!layer.mask.empty() && layer.bbox.area() > 0) { layer.mask.copyTo(full(layer.bbox)); } + return full; +} + +/// Extract shape layers from a quantized label map. +/// Each connected component of each label becomes one ShapeLayer. +/// Labels with value < 0 (e.g. transparent) are skipped. +/// Connected components with area < \p min_area are discarded early to avoid +/// feeding thousands of tiny fragments into O(N²) depth ordering. +std::vector ExtractShapeLayers(const cv::Mat& labels, int num_labels, + double min_area = 1.0); + +/// Compute a bottom-to-top depth ordering of shape layers. +/// +/// Uses a hybrid approach: +/// 1. Background identification (border-touching + largest area) +/// 2. Covered-area energy D(i,j) for adjacent layer pairs +/// 3. Directed graph + cycle removal + topological sort +/// +/// \return Indices into \p layers, ordered from bottom (first) to top (last). +std::vector ComputeDepthOrder(const std::vector& layers, int img_rows, + int img_cols); + +} // namespace neroued::vectorizer::detail diff --git a/src/stacking/shape_extend.cpp b/src/stacking/shape_extend.cpp new file mode 100644 index 0000000..365d183 --- /dev/null +++ b/src/stacking/shape_extend.cpp @@ -0,0 +1,83 @@ +#include "shape_extend.h" + +#include +#include + +namespace neroued::vectorizer::detail { + +void ExtendShapeMasks(std::vector& layers, const std::vector& depth_order, + cv::Size img_size, int dilate_iterations) { + if (depth_order.size() <= 1 || dilate_iterations <= 0) return; + + cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); + const int img_rows = img_size.height; + const int img_cols = img_size.width; + + cv::Mat above_union = cv::Mat::zeros(img_size, CV_8UC1); + int above_union_count = 0; + for (int idx : depth_order) { + const auto& layer = layers[idx]; + cv::Mat roi = above_union(layer.bbox); + int before = cv::countNonZero(roi); + cv::bitwise_or(roi, layer.mask, roi); + above_union_count += cv::countNonZero(roi) - before; + } + + int total_extended_pixels = 0; + const int pad = dilate_iterations; + + for (int rank = 0; rank < static_cast(depth_order.size()); ++rank) { + int idx = depth_order[rank]; + + { + const auto& cur = layers[idx]; + cv::Mat roi = above_union(cur.bbox); + int before = cv::countNonZero(roi); + cv::Mat inv; + cv::bitwise_not(cur.mask, inv); + cv::bitwise_and(roi, inv, roi); + above_union_count -= before - cv::countNonZero(roi); + } + + if (above_union_count <= 0) continue; + + const auto& bbox = layers[idx].bbox; + int ex0 = std::max(0, bbox.x - pad); + int ey0 = std::max(0, bbox.y - pad); + int ex1 = std::min(img_cols, bbox.x + bbox.width + pad); + int ey1 = std::min(img_rows, bbox.y + bbox.height + pad); + cv::Rect exp_roi(ex0, ey0, ex1 - ex0, ey1 - ey0); + + cv::Mat local_mask = cv::Mat::zeros(exp_roi.size(), CV_8UC1); + { + cv::Rect src_in_exp(bbox.x - ex0, bbox.y - ey0, bbox.width, bbox.height); + layers[idx].mask.copyTo(local_mask(src_in_exp)); + } + + cv::Mat dilated; + cv::dilate(local_mask, dilated, kernel, cv::Point(-1, -1), dilate_iterations); + + cv::Mat au_roi = above_union(exp_roi); + cv::Mat extension; + cv::bitwise_and(dilated, au_roi, extension); + cv::Mat not_original; + cv::bitwise_not(local_mask, not_original); + cv::bitwise_and(extension, not_original, extension); + + int ext_pixels = cv::countNonZero(extension); + if (ext_pixels > 0) { + cv::bitwise_or(local_mask, extension, local_mask); + cv::Rect local_nz = cv::boundingRect(local_mask); + cv::Rect new_bbox(local_nz.x + ex0, local_nz.y + ey0, local_nz.width, local_nz.height); + layers[idx].bbox = new_bbox; + layers[idx].mask = local_mask(local_nz).clone(); + layers[idx].area = cv::countNonZero(layers[idx].mask); + total_extended_pixels += ext_pixels; + } + } + + spdlog::info("ExtendShapeMasks: dilate_iterations={}, total_extended_pixels={}", + dilate_iterations, total_extended_pixels); +} + +} // namespace neroued::vectorizer::detail diff --git a/src/stacking/shape_extend.h b/src/stacking/shape_extend.h new file mode 100644 index 0000000..59b1c9c --- /dev/null +++ b/src/stacking/shape_extend.h @@ -0,0 +1,26 @@ +#pragma once + +/// \file shape_extend.h +/// \brief Morphological shape extension into occluded regions for gap-free stacking SVG. + +#include "depth_order.h" + +#include + +namespace neroued::vectorizer::detail { + +/// Extend shape masks into regions occluded by higher layers. +/// +/// For each layer (processed bottom-to-top), the mask is dilated and the +/// extension is clipped to the union of all layers above it. This ensures +/// that lower shapes extend under upper shapes, eliminating gaps without +/// altering the visible result. +/// +/// \param layers Shape layers whose masks will be modified in-place. +/// \param depth_order Bottom-to-top index order (from ComputeDepthOrder). +/// \param img_size Full image dimensions (needed to reconstruct full-size masks). +/// \param dilate_iterations Number of 3x3 disk-kernel dilation iterations. +void ExtendShapeMasks(std::vector& layers, const std::vector& depth_order, + cv::Size img_size, int dilate_iterations = 3); + +} // namespace neroued::vectorizer::detail diff --git a/src/trace/coverage.cpp b/src/trace/coverage.cpp index 3627eaa..e0859ed 100644 --- a/src/trace/coverage.cpp +++ b/src/trace/coverage.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include @@ -18,22 +17,6 @@ namespace neroued::vectorizer::detail { namespace { -BezierContour RingToBezier(const std::vector& ring) { - BezierContour contour; - contour.closed = true; - if (ring.size() < 3) return contour; - contour.segments.reserve(ring.size()); - - for (size_t i = 0; i < ring.size(); ++i) { - const Vec2f& a = ring[i]; - const Vec2f& b = ring[(i + 1) % ring.size()]; - Vec2f d = b - a; - if (d.LengthSquared() < 1e-8f) continue; - contour.segments.push_back({a, a + d * (1.0f / 3.0f), a + d * (2.0f / 3.0f), b}); - } - return contour; -} - std::vector FlattenContour(const BezierContour& contour, int width, int height) { std::vector poly; if (contour.segments.empty()) return poly; @@ -65,76 +48,102 @@ cv::Mat RasterizeCoverage(const std::vector& shapes, int width, return coverage; } -} // namespace - -void ApplyCoverageGuard(std::vector& shapes, const cv::Mat& labels, - const std::vector& palette, float min_ratio, float tracing_epsilon, - float min_patch_area) { - if (labels.empty() || labels.type() != CV_32SC1) { - spdlog::warn("CoverageGuard skipped: invalid labels (empty={} type={})", labels.empty(), - labels.empty() ? -1 : labels.type()); - return; - } - const auto start = std::chrono::steady_clock::now(); - const int h = labels.rows; - const int w = labels.cols; - spdlog::debug("CoverageGuard start: labels={}x{}, min_ratio={:.4f}, tracing_eps={:.3f}", w, h, - min_ratio, tracing_epsilon); +struct GapInfo { + cv::Mat coverage; + cv::Mat cc_labels; + int ncc; + int source_px; + int covered_px; + float ratio; +}; +bool FindCoverageGaps(const std::vector& shapes, const cv::Mat& labels, + float min_ratio, int w, int h, GapInfo& out) { cv::Mat source_mask(h, w, CV_8UC1, cv::Scalar(0)); for (int r = 0; r < h; ++r) { const int* row = labels.ptr(r); - uint8_t* out = source_mask.ptr(r); - for (int c = 0; c < w; ++c) out[c] = (row[c] >= 0) ? 255 : 0; + uint8_t* mout = source_mask.ptr(r); + for (int c = 0; c < w; ++c) mout[c] = (row[c] >= 0) ? 255 : 0; } - cv::Mat coverage = RasterizeCoverage(shapes, w, h); + out.coverage = RasterizeCoverage(shapes, w, h); cv::Mat covered; - cv::bitwise_and(source_mask, coverage, covered); + cv::bitwise_and(source_mask, out.coverage, covered); - int source_px = cv::countNonZero(source_mask); - int covered_px = cv::countNonZero(covered); - if (source_px <= 0) { + out.source_px = cv::countNonZero(source_mask); + out.covered_px = cv::countNonZero(covered); + if (out.source_px <= 0) { spdlog::debug("CoverageGuard skipped: source pixels are zero"); - return; + return false; } - float ratio = static_cast(covered_px) / static_cast(source_px); - if (ratio >= min_ratio) { - spdlog::debug("CoverageGuard skipped: coverage_ratio={:.4f} >= min_ratio={:.4f}", ratio, + out.ratio = static_cast(out.covered_px) / static_cast(out.source_px); + if (out.ratio >= min_ratio) { + spdlog::debug("CoverageGuard skipped: coverage_ratio={:.4f} >= min_ratio={:.4f}", out.ratio, min_ratio); - return; + return false; } - spdlog::warn("CoverageGuard triggered: coverage_ratio={:.4f} < min_ratio={:.4f}", ratio, + spdlog::warn("CoverageGuard triggered: coverage_ratio={:.4f} < min_ratio={:.4f}", out.ratio, min_ratio); cv::Mat missing; - cv::bitwise_not(coverage, missing); + cv::bitwise_not(out.coverage, missing); cv::bitwise_and(missing, source_mask, missing); - cv::Mat cc_labels; - int ncc = cv::connectedComponents(missing, cc_labels, 8, CV_32S); - if (ncc <= 1) { + out.ncc = cv::connectedComponents(missing, out.cc_labels, 8, CV_32S); + if (out.ncc <= 1) { spdlog::debug("CoverageGuard no missing connected components"); - return; + return false; } + return true; +} + +struct PatchStats { + int eligible = 0; + int patched = 0; + int added = 0; + int bad_labels = 0; +}; + +PatchStats PatchMissingRegions(std::vector& shapes, const GapInfo& gaps, + const cv::Mat& labels, const std::vector& palette, + float tracing_epsilon, float min_patch_area, int w, int h) { + const int ncc = gaps.ncc; + std::vector> per_cid_patches(ncc); + std::vector per_cid_eligible(ncc, 0); + std::vector per_cid_patched(ncc, 0); + std::vector per_cid_bad(ncc, 0); - int eligible_components = 0; - int patched_components = 0; - int patch_shapes_added = 0; - int invalid_label_skips = 0; +#pragma omp parallel for schedule(dynamic) for (int cid = 1; cid < ncc; ++cid) { - cv::Mat comp_mask(h, w, CV_8UC1, cv::Scalar(0)); + cv::Rect roi; + { + int rmin = h, rmax = 0, cmin = w, cmax = 0; + for (int r = 0; r < h; ++r) { + const int* cc_row = gaps.cc_labels.ptr(r); + for (int c = 0; c < w; ++c) { + if (cc_row[c] != cid) continue; + rmin = std::min(rmin, r); + rmax = std::max(rmax, r); + cmin = std::min(cmin, c); + cmax = std::max(cmax, c); + } + } + if (rmin > rmax) continue; + roi = cv::Rect(cmin, rmin, cmax - cmin + 1, rmax - rmin + 1); + } + + cv::Mat comp_mask(roi.height, roi.width, CV_8UC1, cv::Scalar(0)); std::unordered_map label_hist; int area = 0; - for (int r = 0; r < h; ++r) { - const int* cc_row = cc_labels.ptr(r); + for (int r = roi.y; r < roi.y + roi.height; ++r) { + const int* cc_row = gaps.cc_labels.ptr(r); const int* lb_row = labels.ptr(r); - uint8_t* out = comp_mask.ptr(r); - for (int c = 0; c < w; ++c) { + uint8_t* out = comp_mask.ptr(r - roi.y); + for (int c = roi.x; c < roi.x + roi.width; ++c) { if (cc_row[c] != cid) continue; - out[c] = 255; + out[c - roi.x] = 255; ++area; label_hist[lb_row[c]]++; } @@ -142,7 +151,7 @@ void ApplyCoverageGuard(std::vector& shapes, const cv::Mat& lab if (area < static_cast(std::max(1.0f, min_patch_area))) continue; if (label_hist.empty()) continue; - ++eligible_components; + per_cid_eligible[cid] = 1; int best_label = -1; int best_count = -1; @@ -153,53 +162,109 @@ void ApplyCoverageGuard(std::vector& shapes, const cv::Mat& lab } } if (best_label < 0 || best_label >= static_cast(palette.size())) { - ++invalid_label_skips; + per_cid_bad[cid] = 1; continue; } auto traced = TraceMaskWithPotrace(comp_mask, tracing_epsilon * 0.8f); auto fixed = RepairTopology(traced, tracing_epsilon * 0.6f, min_patch_area, min_patch_area); - if (!fixed.empty()) ++patched_components; + if (!fixed.empty()) per_cid_patched[cid] = 1; for (auto& g : fixed) { VectorizedShape patch; patch.color = palette[best_label]; patch.area = g.area; - patch.contours.push_back(RingToBezier(g.outer)); - for (const auto& hole : g.holes) patch.contours.push_back(RingToBezier(hole)); + + auto shift_contour = [&](BezierContour& bc) { + Vec2f offset(static_cast(roi.x), static_cast(roi.y)); + for (auto& seg : bc.segments) { + seg.p0 = seg.p0 + offset; + seg.p1 = seg.p1 + offset; + seg.p2 = seg.p2 + offset; + seg.p3 = seg.p3 + offset; + } + }; + + auto outer_bc = RingToBezier(g.outer); + shift_contour(outer_bc); + patch.contours.push_back(std::move(outer_bc)); + for (const auto& hole : g.holes) { + auto hc = RingToBezier(hole); + shift_contour(hc); + hc.is_hole = true; + patch.contours.push_back(std::move(hc)); + } if (patch.contours.empty()) continue; - cv::Mat patch_raster(h, w, CV_8UC1, cv::Scalar(0)); + cv::Mat patch_raster(roi.height, roi.width, CV_8UC1, cv::Scalar(0)); { std::vector> polys; - for (const auto& c : patch.contours) { - auto poly = FlattenContour(c, w, h); - if (poly.size() >= 3) polys.push_back(std::move(poly)); + for (const auto& cnt : patch.contours) { + auto poly = FlattenContour(cnt, w, h); + std::vector local_poly; + local_poly.reserve(poly.size()); + for (const auto& pt : poly) { + local_poly.emplace_back(pt.x - roi.x, pt.y - roi.y); + } + if (local_poly.size() >= 3) polys.push_back(std::move(local_poly)); } if (!polys.empty()) cv::fillPoly(patch_raster, polys, cv::Scalar(255)); } cv::Mat overlap_mask; - cv::bitwise_and(patch_raster, coverage, overlap_mask); + cv::bitwise_and(patch_raster, gaps.coverage(roi), overlap_mask); int patch_px = cv::countNonZero(patch_raster); int overlap_px = cv::countNonZero(overlap_mask); if (patch_px > 0 && static_cast(overlap_px) / static_cast(patch_px) > 0.5f) { - spdlog::debug("CoverageGuard skip high-overlap patch: patch_px={}, overlap={}", - patch_px, overlap_px); continue; } - shapes.push_back(std::move(patch)); - ++patch_shapes_added; + per_cid_patches[cid].push_back(std::move(patch)); } } + + PatchStats stats; + for (int cid = 1; cid < ncc; ++cid) { + stats.eligible += per_cid_eligible[cid]; + stats.patched += per_cid_patched[cid]; + stats.bad_labels += per_cid_bad[cid]; + for (auto& p : per_cid_patches[cid]) { + shapes.push_back(std::move(p)); + ++stats.added; + } + } + return stats; +} + +} // namespace + +void ApplyCoverageGuard(std::vector& shapes, const cv::Mat& labels, + const std::vector& palette, float min_ratio, float tracing_epsilon, + float min_patch_area) { + if (labels.empty() || labels.type() != CV_32SC1) { + spdlog::warn("CoverageGuard skipped: invalid labels (empty={} type={})", labels.empty(), + labels.empty() ? -1 : labels.type()); + return; + } + const auto start = std::chrono::steady_clock::now(); + const int h = labels.rows; + const int w = labels.cols; + spdlog::debug("CoverageGuard start: labels={}x{}, min_ratio={:.4f}, tracing_eps={:.3f}", w, h, + min_ratio, tracing_epsilon); + + GapInfo gaps; + if (!FindCoverageGaps(shapes, labels, min_ratio, w, h, gaps)) return; + + auto stats = + PatchMissingRegions(shapes, gaps, labels, palette, tracing_epsilon, min_patch_area, w, h); + const auto elapsed_ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); spdlog::info( "CoverageGuard done: source_px={}, covered_px={}, ratio={:.4f}, ncc={}, eligible={}, " "patched_components={}, patch_shapes_added={}, invalid_label_skips={}, elapsed_ms={:.2f}", - source_px, covered_px, ratio, ncc, eligible_components, patched_components, - patch_shapes_added, invalid_label_skips, elapsed_ms); + gaps.source_px, gaps.covered_px, gaps.ratio, gaps.ncc, stats.eligible, stats.patched, + stats.added, stats.bad_labels, elapsed_ms); } } // namespace neroued::vectorizer::detail diff --git a/src/trace/coverage.h b/src/trace/coverage.h index 3cabcde..4a1f30b 100644 --- a/src/trace/coverage.h +++ b/src/trace/coverage.h @@ -3,7 +3,7 @@ /// \file coverage.h /// \brief Coverage validation and patching for vectorized output. -#include "output/svg_writer.h" +#include "detail/vectorized_shape.h" #include diff --git a/src/trace/potrace.cpp b/src/trace/potrace.cpp index d5e9c01..b948b76 100644 --- a/src/trace/potrace.cpp +++ b/src/trace/potrace.cpp @@ -57,19 +57,11 @@ std::vector SimplifyRing(const std::vector& ring, float eps) { } std::vector ConvertCvContour(const std::vector& contour, float eps) { - std::vector in; - in.reserve(contour.size()); - for (const auto& p : contour) in.push_back({static_cast(p.x), static_cast(p.y)}); - - std::vector approx; - cv::approxPolyDP(in, approx, std::max(0.2f, eps), true); - if (approx.size() < 3) return {}; - - std::vector out; - out.reserve(approx.size()); - for (const auto& p : approx) out.push_back({p.x, p.y}); - if (out.size() > 1 && (out.front() - out.back()).LengthSquared() < 1e-6f) out.pop_back(); - return out; + std::vector ring; + ring.reserve(contour.size()); + for (const auto& p : contour) + ring.push_back({static_cast(p.x), static_cast(p.y)}); + return SimplifyRing(ring, eps); } std::vector TraceMaskFallbackContours(const cv::Mat& mask, @@ -232,19 +224,10 @@ void CollectBezierGroupsFromTree(const potrace_path_t* path_list, } BezierContour PointsToBezierContour(const std::vector& pts) { - BezierContour bc; - bc.closed = true; - if (pts.size() < 3) return bc; - bc.segments.reserve(pts.size()); - for (size_t i = 0; i < pts.size(); ++i) { - Vec2f a(static_cast(pts[i].x), static_cast(pts[i].y)); - Vec2f b(static_cast(pts[(i + 1) % pts.size()].x), - static_cast(pts[(i + 1) % pts.size()].y)); - Vec2f d = b - a; - if (d.LengthSquared() < 1e-8f) continue; - bc.segments.push_back({a, a + d * (1.0f / 3.0f), a + d * (2.0f / 3.0f), b}); - } - return bc; + std::vector ring; + ring.reserve(pts.size()); + for (const auto& p : pts) ring.push_back({static_cast(p.x), static_cast(p.y)}); + return RingToBezier(ring); } std::vector FallbackBezierContours(const cv::Mat& mask) { @@ -284,8 +267,57 @@ std::vector FallbackBezierContours(const cv::Mat& mask) { return groups; } +struct PotraceTraceResult { + potrace_state_t* state = nullptr; + bool incomplete = false; + int path_count = 0; +}; + +PotraceTraceResult RunPotraceTrace(const cv::Mat& mask, int turdsize, double opttolerance, + std::vector& bitmap_storage) { + potrace_bitmap_t bm = BuildPotraceBitmap(mask, bitmap_storage); + + potrace_param_t* params = potrace_param_default(); + if (!params) throw std::runtime_error("potrace_param_default failed"); + params->turdsize = std::max(0, turdsize); + params->turnpolicy = POTRACE_TURNPOLICY_MAJORITY; + params->alphamax = 1.0; + params->opticurve = 1; + params->opttolerance = std::clamp(opttolerance, 0.2, 2.0); + + potrace_state_t* state = potrace_trace(params, &bm); + potrace_param_free(params); + if (!state) throw std::runtime_error("potrace_trace failed"); + + if (state->status != POTRACE_STATUS_OK && state->status != POTRACE_STATUS_INCOMPLETE) { + potrace_state_free(state); + throw std::runtime_error("potrace_trace returned invalid status"); + } + + PotraceTraceResult result; + result.state = state; + result.incomplete = (state->status == POTRACE_STATUS_INCOMPLETE); + for (const potrace_path_t* p = state->plist; p; p = p->next) ++result.path_count; + return result; +} + } // namespace +BezierContour RingToBezier(const std::vector& ring) { + BezierContour contour; + contour.closed = true; + if (ring.size() < 3) return contour; + contour.segments.reserve(ring.size()); + for (size_t i = 0; i < ring.size(); ++i) { + const Vec2f& a = ring[i]; + const Vec2f& b = ring[(i + 1) % ring.size()]; + Vec2f d = b - a; + if (d.LengthSquared() < 1e-8f) continue; + contour.segments.push_back({a, a + d * (1.0f / 3.0f), a + d * (2.0f / 3.0f), b}); + } + return contour; +} + double SignedArea(const std::vector& ring) { if (ring.size() < 3) return 0.0; double acc = 0.0; @@ -309,32 +341,16 @@ std::vector TraceMaskWithPotrace(const cv::Mat& mask, float spdlog::debug("TraceMaskWithPotrace start: mask={}x{}, nonzero={}, epsilon={:.3f}", mask.cols, mask.rows, mask_nonzero, simplify_epsilon); - std::vector bitmap_storage; - potrace_bitmap_t bm = BuildPotraceBitmap(mask, bitmap_storage); + int turdsize = std::max(0, static_cast(std::lround(simplify_epsilon * 0.5f))); + double opttolerance = static_cast(simplify_epsilon); - potrace_param_t* params = potrace_param_default(); - if (!params) throw std::runtime_error("potrace_param_default failed"); - params->turdsize = std::max(0, static_cast(std::lround(simplify_epsilon * 0.5f))); - params->turnpolicy = POTRACE_TURNPOLICY_MAJORITY; - params->alphamax = 1.0; - params->opticurve = 1; - params->opttolerance = std::clamp(static_cast(simplify_epsilon), 0.2, 2.0); - - potrace_state_t* state = potrace_trace(params, &bm); - potrace_param_free(params); - if (!state) throw std::runtime_error("potrace_trace failed"); - - if (state->status != POTRACE_STATUS_OK && state->status != POTRACE_STATUS_INCOMPLETE) { - potrace_state_free(state); - throw std::runtime_error("potrace_trace returned invalid status"); - } - if (state->status == POTRACE_STATUS_INCOMPLETE) { + std::vector bitmap_storage; + auto tr = RunPotraceTrace(mask, turdsize, opttolerance, bitmap_storage); + if (tr.incomplete) { spdlog::warn("TraceMaskWithPotrace status incomplete: mask={}x{}", mask.cols, mask.rows); } - int path_count = 0; - for (const potrace_path_t* p = state->plist; p != nullptr; p = p->next) { - ++path_count; + for (const potrace_path_t* p = tr.state->plist; p; p = p->next) { auto ring = ConvertPathToRing(p, simplify_epsilon); if (ring.size() < 3) continue; double area = SignedArea(ring); @@ -345,7 +361,7 @@ std::vector TraceMaskWithPotrace(const cv::Mat& mask, float g.area = std::abs(SignedArea(g.outer)); if (g.area > std::numeric_limits::epsilon()) groups.push_back(std::move(g)); } - potrace_state_free(state); + potrace_state_free(tr.state); bool fallback_used = false; if (groups.empty()) { @@ -361,7 +377,7 @@ std::vector TraceMaskWithPotrace(const cv::Mat& mask, float std::chrono::duration(std::chrono::steady_clock::now() - start).count(); spdlog::debug("TraceMaskWithPotrace done: mask={}x{}, paths={}, groups={}, fallback_used={}, " "elapsed_ms={:.2f}", - mask.cols, mask.rows, path_count, groups.size(), fallback_used, elapsed_ms); + mask.cols, mask.rows, tr.path_count, groups.size(), fallback_used, elapsed_ms); return groups; } @@ -380,33 +396,14 @@ std::vector TraceMaskWithPotraceBezier(const cv::Mat& mask, i mask.cols, mask.rows, mask_nonzero, turdsize, opttolerance); std::vector bitmap_storage; - potrace_bitmap_t bm = BuildPotraceBitmap(mask, bitmap_storage); - - potrace_param_t* params = potrace_param_default(); - if (!params) throw std::runtime_error("potrace_param_default failed"); - params->turdsize = std::max(0, turdsize); - params->turnpolicy = POTRACE_TURNPOLICY_MAJORITY; - params->alphamax = 1.0; - params->opticurve = 1; - params->opttolerance = std::clamp(opttolerance, 0.2, 2.0); - - potrace_state_t* state = potrace_trace(params, &bm); - potrace_param_free(params); - if (!state) throw std::runtime_error("potrace_trace failed"); - - if (state->status != POTRACE_STATUS_OK && state->status != POTRACE_STATUS_INCOMPLETE) { - potrace_state_free(state); - throw std::runtime_error("potrace_trace returned invalid status"); - } - if (state->status == POTRACE_STATUS_INCOMPLETE) { + auto tr = RunPotraceTrace(mask, turdsize, opttolerance, bitmap_storage); + if (tr.incomplete) { spdlog::warn("TraceMaskWithPotraceBezier status incomplete: mask={}x{}", mask.cols, mask.rows); } - int path_count = 0; - for (const potrace_path_t* p = state->plist; p != nullptr; p = p->next) ++path_count; - CollectBezierGroupsFromTree(state->plist, groups); - potrace_state_free(state); + CollectBezierGroupsFromTree(tr.state->plist, groups); + potrace_state_free(tr.state); bool fallback_used = false; if (groups.empty()) { @@ -423,7 +420,7 @@ std::vector TraceMaskWithPotraceBezier(const cv::Mat& mask, i spdlog::debug( "TraceMaskWithPotraceBezier done: mask={}x{}, paths={}, groups={}, fallback_used={}, " "elapsed_ms={:.2f}", - mask.cols, mask.rows, path_count, groups.size(), fallback_used, elapsed_ms); + mask.cols, mask.rows, tr.path_count, groups.size(), fallback_used, elapsed_ms); return groups; } diff --git a/src/trace/potrace.h b/src/trace/potrace.h index a696bc0..8bee3d7 100644 --- a/src/trace/potrace.h +++ b/src/trace/potrace.h @@ -33,4 +33,7 @@ struct TracedBezierGroup { std::vector TraceMaskWithPotraceBezier(const cv::Mat& mask, int turdsize = 2, double opttolerance = 0.2); +/// Convert a polygon ring to a degenerate linear BezierContour. +BezierContour RingToBezier(const std::vector& ring); + } // namespace neroued::vectorizer::detail diff --git a/src/vectorizer.cpp b/src/vectorizer.cpp index 2aa381b..4fe4fc3 100644 --- a/src/vectorizer.cpp +++ b/src/vectorizer.cpp @@ -42,6 +42,17 @@ PreparedVectorizeInput PrepareVectorizeInput(const cv::Mat& input) { return prepared; } +VectorizerResult VectorizeImpl(PreparedVectorizeInput& prepared, const VectorizerConfig& config, + const char* tag) { + if (prepared.bgr.empty()) { + spdlog::error("Vectorize({}) failed: prepared image empty", tag); + throw std::runtime_error(std::string("Vectorize(") + tag + "): empty input"); + } + return (config.pipeline_mode == PipelineMode::V2) + ? detail::RunPipelineV2(prepared.bgr, config, prepared.opaque_mask) + : detail::RunPipeline(prepared.bgr, config, prepared.opaque_mask); +} + } // namespace VectorizerResult Vectorize(const std::string& image_path, const VectorizerConfig& config) { @@ -50,11 +61,7 @@ VectorizerResult Vectorize(const std::string& image_path, const VectorizerConfig try { cv::Mat img = detail::LoadImageIcc(image_path); auto prepared = PrepareVectorizeInput(img); - if (prepared.bgr.empty()) { - spdlog::error("Vectorize(file) failed: prepared image empty, path='{}'", image_path); - throw std::runtime_error("Failed to load image: " + image_path); - } - auto result = detail::RunPipeline(prepared.bgr, config, prepared.opaque_mask); + auto result = VectorizeImpl(prepared, config, "file"); const auto elapsed_ms = std::chrono::duration(std::chrono::steady_clock::now() - start) .count(); @@ -81,11 +88,7 @@ VectorizerResult Vectorize(const uint8_t* image_data, size_t image_size, try { cv::Mat img = detail::LoadImageIcc(image_data, image_size); auto prepared = PrepareVectorizeInput(img); - if (prepared.bgr.empty()) { - spdlog::error("Vectorize(buffer) failed: prepared image empty, bytes={}", image_size); - throw std::runtime_error("Failed to decode image buffer"); - } - auto result = detail::RunPipeline(prepared.bgr, config, prepared.opaque_mask); + auto result = VectorizeImpl(prepared, config, "buffer"); const auto elapsed_ms = std::chrono::duration(std::chrono::steady_clock::now() - start) .count(); @@ -111,11 +114,7 @@ VectorizerResult Vectorize(const cv::Mat& bgr_image, const VectorizerConfig& con bgr_image.rows, bgr_image.channels()); try { auto prepared = PrepareVectorizeInput(bgr_image); - if (prepared.bgr.empty()) { - spdlog::error("Vectorize(mat) failed: empty input image"); - throw std::runtime_error("Empty input image"); - } - auto result = detail::RunPipeline(prepared.bgr, config, prepared.opaque_mask); + auto result = VectorizeImpl(prepared, config, "mat"); const auto elapsed_ms = std::chrono::duration(std::chrono::steady_clock::now() - start) .count(); diff --git a/test_data/images/manifest.json b/test_data/images/manifest.json index eec44fc..81b53d6 100644 --- a/test_data/images/manifest.json +++ b/test_data/images/manifest.json @@ -97,6 +97,13 @@ "category": "complex", "vectorizer_overrides": {}, "expectations": {} + }, + { + "path": "complex/v2-450addc6c052c09b3478440adf85ae5c_r.jpg", + "name": "c_450ad", + "category": "complex", + "vectorizer_overrides": {}, + "expectations": {} } ] }