commit b6612f1df3f67c5ac704ba7d847392a6e994cd56 Author: DVS Date: Sat Sep 26 13:37:03 2026 +0800 release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3ef5d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# 虚拟环境 +.venv/ +venv/ +env/ +ENV/ + +# 测试 / 缓存 +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ + +# IDE +.idea/ +.vscode/ +*.swp + +# 输出 +output/ +*.log + +# 系统 +.DS_Store +Thumbs.db +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fead79f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DVS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e078042 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include README.md +include LICENSE +include pyproject.toml +recursive-include lineartization *.py *.typed +recursive-include tests *.py +recursive-include examples *.py +global-exclude __pycache__ *.py[cod] *.egg-info diff --git a/README.md b/README.md new file mode 100644 index 0000000..84cde43 --- /dev/null +++ b/README.md @@ -0,0 +1,443 @@ +# lineartization + +> **Convert color illustrations / handwritten posters into clean black-and-white line art.** +> Pure Python + OpenCV + scikit-image. No deep-learning models required. Runs offline. + +**Version:** 1.6.1 · **Author:** DVS · **License:** MIT + +[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)]() +[![License](https://img.shields.io/badge/license-MIT-green.svg)]() + +--- + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [The Two Extraction Modes](#the-two-extraction-modes) +- [Denoise Levels](#denoise-levels) +- [Command Line Interface](#command-line-interface) +- [Python API](#python-api) +- [Technical Documentation](#technical-documentation) + - [Pipeline Overview](#pipeline-overview) + - [Mode A: Skeletonization](#mode-a-skeletonization) + - [Mode B: Minimum Filter](#mode-b-minimum-filter) + - [True-Black Criterion](#true-black-criterion) + - [Denoise Algorithm](#denoise-algorithm) + - [Parameter Reference](#parameter-reference) +- [Design Notes & Known Limits](#design-notes--known-limits) +- [Project Structure](#project-structure) +- [Testing](#testing) +- [Contact](#contact) +- [License](#license) + +--- + +## Overview + +`lineartization` turns a **color picture** (manga-style illustration, school poster, +children's drawing) into a **black-on-white line drawing** suitable for: + +- Coloring books / templates +- Printing (high-contrast, ink-friendly) +- Vectorization / further editing +- OCR preprocessing + +Unlike dedicated edge-detection or upscaling tools (which are pure pixel math and +produce broken lines, hollow double edges, or heavy blur), this library works in +two well-defined strategies depending on the source quality. + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| 🈶 **Chinese-text aware** | Detects the text block and keeps character strokes complete | +| 📐 **Uniform stroke width** | Text and artwork lines unified to a configurable width | +| 🔗 **Continuous lines** | Lee skeletonization (shape-preserving) instead of naive thinning | +| 🎨 **True-black criterion** | Distinguishes *black ink* from *dark colors* using RGB + chroma | +| 🧹 **Tunable denoise** | Four levels: `strong` / `normal` / `light` / `none` | +| 🛡️ **Protected regions** | Keep complex textures (emblems, seals) from being cleaned away | +| 🐍 **Zero model dependency** | No GPU, no ONNX, no downloads — `pip install` and run | + +--- + +## Installation + +```bash +pip install lineartization +``` + +From source: + +```bash +git clone https://git.dvscloud.net/dvs/lineartization.git +cd lineartization +pip install -e . +``` + +**Dependencies:** `numpy`, `opencv-python`, `opencv-contrib-python`, `scikit-image` + +--- + +## Quick Start + +### Command line + +```bash +# Skeletonization (clear / vector-like source) +lineartization poster.jpg lineart.png + +# Minimum filter (handwritten / photographed source) +lineartization handwriting.jpg lineart.png --method minimum +``` + +### Python + +```python +from lineartization import extract_lineart_file + +extract_lineart_file("poster.jpg", "lineart.png") # skeleton +extract_lineart_file("handwriting.jpg", "lineart.png", method="minimum") +``` + +--- + +## The Two Extraction Modes + +Choosing the wrong mode is the most common cause of bad output. **Pick the mode +that matches how the source image was produced.** + +### `method="skeleton"` — for clear sources (default) + +Use when the original image **already has clean, well-separated lines**, e.g. a +vector illustration, a high-resolution redrawing, or a professionally scanned +black-ink drawing. + +* Strong point: thin, smooth, uniform lines — the most aesthetic result. +* Weak point: skeletonization on **thick handwritten strokes** produces spurs and + web-like artefacts, because thinning a wide non-uniform stroke inevitably + branches. + +### `method="minimum"` — for handwritten / low-resolution sources + +Use when the image is a **photo of a hand-drawn poster**, a phone snapshot, or +anything with thick, irregular, low-resolution strokes. + +* Strong point: preserves the original strokes, no line breakage. +* Weak point: strokes are a little thick; result is "usable" rather than refined. + +> **Rule of thumb:** if the lines in the source are one clean pixel wide → `skeleton`. +> If the lines are thick / wobbly / photographed → `minimum`. + +--- + +## Denoise Levels + +Available only in `minimum` mode (skeleton mode has its own built-in cleanup). + +| Level | Pipeline | Note | +|-------|----------|------| +| **`strong`** *(default)* | median → open → connected-component filter (<30 px) → final median | Standard aggressive cleanup | +| `normal` | median → connected-component filter → final median | Slightly gentler | +| `light` | median → remove only "tiny square" blobs → final median | Line-preserving | +| `none` | median only | Minimal | + +```bash +lineartization in.jpg out.png -m minimum -d light +lineartization in.jpg out.png -m minimum -d strong --denoise-area 40 +``` + +--- + +## Command Line Interface + +``` +usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH] + [--min-mean MIN_MEAN] [--min-chroma MIN_CHROMA] + [--min-kernel MIN_KERNEL] + [-d {strong,normal,light,none}] + [--denoise-area DENOISE_AREA] [--no-green-smoothing] + [--protect x1,x2,y1,y2] [-v] [-V] + input output +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-m, --method` | `skeleton` | Extraction mode | +| `-w, --width` | `2` | Stroke width (skeleton mode) | +| `-d, --denoise` | `strong` | Denoise level (minimum mode) | +| `--denoise-area` | `30` | Connected-component removal threshold | +| `--min-mean` | `130` | True-black criterion: max RGB mean | +| `--min-chroma` | `45` | True-black criterion: max chroma | +| `--min-kernel` | `2` | Minimum-filter radius (1–3) | +| `--no-green-smoothing` | off | Disable green-block smoothing | +| `--protect` | — | Protected rect `x1,x2,y1,y2` (repeatable) | +| `-v, --verbose` | off | Print pipeline logs | + +--- + +## Python API + +```python +import cv2 +from lineartization import LineArtConfig, extract_lineart, load_image, save_image + +img = load_image("poster.jpg") # BGR uint8, RGBA-safe + +cfg = LineArtConfig( + method="minimum", # "skeleton" | "minimum" + denoise="strong", # strong | normal | light | none + min_mean=130, # true-black RGB mean threshold + min_chroma=45, # true-black chroma threshold + min_kernel=2, # minimum-filter radius + protect_areas=[(120, 220, 940, 1050)], # x1,x2,y1,y2 +) + +lineart = extract_lineart(img, cfg, verbose=True) # 0/255, white bg, black lines +save_image("lineart.png", lineart) +``` + +`extract_lineart()` accepts a BGR image and returns a **single-channel `uint8` +image valued 0/255** (white background, black lines). + +--- + +# Technical Documentation + +## Pipeline Overview + +``` + ┌──────────────┐ + input image ─────► │ load_image() │ RGBA-safe, white-composited, BGR + └──────┬───────┘ + │ + ┌───────────────┴────────────────┐ + ▼ ▼ + method = "skeleton" method = "minimum" + ──────────────────── ──────────────────── + Region analysis True-black criterion + Pattern extraction Minimum filter + Lee skeletonization Otsu binarization + Denoise + spur pruning Denoise (tunable) + Uniform width → white bg / black lines + │ │ + └───────────────┬────────────────┘ + ▼ + 0/255 line-art PNG +``` + +--- + +## Mode A: Skeletonization + +**Goal:** reproduce a clear source as thin, uniform, aesthetically pleasing lines. + +### Step 1 — Region analysis + +Two spatial masks are derived from the HSV representation: + +* **Paper region** (`paper`) — bright, low-saturation background of the text block. + ``` + paper = (V > paper_v) AND (S < paper_s) + paper = morph_close(ELLIPSE 21×21, iterations=3) + paper = erode(ELLIPSE paper_erode×paper_erode) + ``` +* **Text rectangle** (`tz`) — the *largest connected blob* of "ink density". + ``` + ink = (V < ink_v) AND (S < ink_s) + dense = morph_close(ink, 41×41) + dense = morph_open(dense, 61×61) + tz = bounding_box(largest_blob(dense)) + text_pad + ``` + Using the largest density blob (rather than a raw colour mask) reliably + excludes scattered decorations such as fireworks or small figures. + +### Step 2 — Line extraction + +``` +at_text = adaptiveThreshold(gray, GAUSSIAN, INV, 31, 14) +at_all = adaptiveThreshold(gray, MEAN, INV, 25, 19) +dark = (V < dark_v) +fine = dark AND NOT morph_open(dark, 13×13) # drop large dark blocks +pattern = (NOT paper) AND fine AND at_all +text = paper AND at_text +lines = skel( morph_close(text OR pattern, 3×3) ) +``` + +Optionally, the four large green blocks (hills in a poster) are re-extracted +from a **mean-shift smoothed** copy to suppress colour-banding, and merged via +a Canny contour (see `enable_green_smoothing`). + +### Step 3 — Denoise & spur pruning + +* **Isolated noise removal** — a connected component is removed when + `skeleton_length < noise_sk_len` **and** `branch_count < noise_branch` + **and** `area < noise_area`. +* **Spur pruning** — walk from every skeleton endpoint; if a branch reaches a + junction within `spur_maxlen` px, it is erased (except inside protected areas). + +### Step 4 — Uniform width + +Text and artwork are separately re-skeletonised, then dilated to `line_width`. + +--- + +## Mode B: Minimum Filter + +**Goal:** faithfully keep the original strokes of a handwritten / low-res source, +avoiding the false-positive colour edges that naive thresholding produces. + +The pipeline mirrors the classic Photoshop "Minimum filter" line-art recipe, +derived mathematically: + +``` +L = grayscale(image) # line = dark, background = light +R = 255 − L # line = light, background = dark +M = erode(R, kernel) # minimum filter: dark background expands +result = L / (255 − M) · 255 # "Color Dodge" blend +line = Otsu(result) # pure black / white +``` + +### Why `L / (255 − M)` and not the inverse + +The Photoshop **Color Dodge** blend of a base `L` and a blend layer `B` is +`L / (255 − B)`. Feeding the eroded inverse `M` as the blend layer gives the +result **already in white-background / black-line polarity** — no extra +inversion is required (an extra `255 − result` produces an all-black image, +a classic pitfall). + +--- + +## True-Black Criterion + +A naive luminance threshold classifies **dark colours** (deep red, navy) as +"black", producing spurious blobs. `lineartization` instead requires a pixel to +be **both dark and achromatic**: + +``` +mean = (R + G + B) / 3 +chroma = max(R,G,B) − min(R,G,B) + +true_black = (mean < min_mean) AND (chroma < min_chroma) +``` + +* `mean < min_mean` ⇒ dark enough. +* `chroma < min_chroma` ⇒ R, G, B are close ⇒ grey/black, **not** a saturated colour. + +The final mask is intersected with `true_black`, so coloured fills are never +reported as ink. + +--- + +## Denoise Algorithm + +`minimum` mode exposes four levels. All levels end with a median pass to remove +salt-and-pepper residue. + +``` +strong : median(3) → open(2×2) → remove CC area<30 → median(3) +normal : median(3) → remove CC area<30 → median(3) +light : median(3) → remove blobs (area<10 & fill≥0.8 & elong<1.8) → median(3) +none : median(3) +``` + +`strong` is the default. Lower levels trade less noise suppression for fewer +false deletions of legitimate short strokes. + +--- + +## Parameter Reference + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| `method` | `"skeleton"` | `"skeleton"` or `"minimum"` | +| `paper_v` / `paper_s` | 140 / 60 | Paper-region brightness / saturation bounds | +| `paper_erode` | 31 | Erosion kernel to shrink the paper region | +| `ink_v` / `ink_s` | 140 / 60 | Ink criterion for text-block detection | +| `text_pad` | 40 | Padding around the detected text rectangle | +| `dark_v` | 160 | Dark-pixel threshold (skeleton mode) | +| `morph_open_k` | 13 | Kernel removing large dark blocks | +| `adaptive_bs` / `adaptive_c` | 25 / 19 | Artwork adaptive threshold | +| `noise_sk_len` / `noise_branch` / `noise_area` | 25 / 8 / 300 | Isolated-noise criterion | +| `spur_maxlen` | 25 | Max spur length pruned | +| `min_mean` / `min_chroma` | 130 / 45 | True-black criterion | +| `min_kernel` | 2 | Minimum-filter radius | +| `denoise` | `"strong"` | Denoise level | +| `denoise_area` | 30 | CC removal area for strong/normal | +| `line_width` | 2 | Stroke width (skeleton mode) | +| `protect_areas` | `[]` | List of `(x1,x2,y1,y2)` rectangles never cleaned | +| `enable_green_smoothing` | `True` | Mean-shift smoothing of green hill blocks | + +--- + +## Design Notes & Known Limits + +**Why skeletonization is not always the answer.** Morphological thinning peels +border pixels from a blob. For a *thick, non-uniform handwritten stroke*, the +remaining medial axis branches into spurs and webs. That is precisely what +`method="minimum"` avoids by keeping the original stroke instead of reducing it +to a 1-px skeleton. + +**Why edge detection is avoided.** Classical edge detectors (Sobel, Laplacian, +High-pass) respond to *gradients*; a rasterised line has **two** edges, so the +output is a hollow double line. Closing the gap yields either a thick smear or +requires a centre-line step — both inferior to the direct approaches above. + +**Known limits.** + +* Very low-resolution text (character strokes < 2 px) cannot be recovered by any + pure-algorithm method; a semantic/AI model is required. This library does not + include one by design. +* Heavy JPEG artefacts in the source may survive as small debris; raise + `--denoise-area` to suppress them. + +--- + +## Project Structure + +``` +lineartization/ +├── lineartization/ +│ ├── __init__.py # package entry + CLI +│ ├── __main__.py # `python -m lineartization` +│ ├── core.py # algorithm (skeleton / minimum) +│ └── py.typed +├── examples/ +│ └── demo.py +├── tests/ +│ └── test_core.py +├── pyproject.toml +├── MANIFEST.in +├── README.md +└── LICENSE +``` + +--- + +## Testing + +```bash +pip install pytest +pytest tests/ -v +``` + +--- + +## Contact + +| | | +|---|---| +| **Author** | DVS | +| **Email** | admin@dvscloud.net | +| **Backup** | dvs6666@163.com | +| **Repository** | https://git.dvscloud.net/dvs/lineartization | + +--- + +## License + +MIT License — see [LICENSE](LICENSE) for details. diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..1a91f97 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,68 @@ +""" +lineart-extractor 使用示例 +========================== +演示三种用法: 一行函数 / 自定义配置 / 直接处理 ndarray +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from lineartization import ( + LineArtConfig, + extract_lineart, + extract_lineart_file, + load_image, + save_image, +) + +DEMO_SRC = os.environ.get("LINEART_DEMO_SRC", "手抄报.jpg") +DEMO_OUT_DIR = os.environ.get("LINEART_DEMO_OUT", "./output") +os.makedirs(DEMO_OUT_DIR, exist_ok=True) + + +def demo_simple(): + """① 一行搞定""" + print("=== 示例1: 一行调用 ===") + extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "simple.png")) + print(" 已生成 simple.png") + + +def demo_config(): + """② 自定义配置 (线宽 + 保护华表区)""" + print("=== 示例2: 自定义配置 ===") + cfg = LineArtConfig( + method="minimum", + denoise="strong", + line_width=2, # 统一线宽 2px + enable_green_smoothing=True, # 手抄报绿块抹平 + protect_areas=[(120, 220, 940, 1050)], # 保护"华表"区域 + ) + extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "configured.png"), + cfg, verbose=True) + print(" 已生成 configured.png") + + +def demo_ndarray(): + """③ 直接处理 ndarray (可嵌入你自己的流水线)""" + print("=== 示例3: ndarray 处理 ===") + img = load_image(DEMO_SRC) + print(f" 输入尺寸: {img.shape[1]}x{img.shape[0]}") + lineart = extract_lineart(img, LineArtConfig(line_width=2)) + black_ratio = (lineart < 128).mean() * 100 + print(f" 黑占比: {black_ratio:.2f}%") + save_image(os.path.join(DEMO_OUT_DIR, "ndarray.png"), lineart) + print(" 已生成 ndarray.png") + + +if __name__ == "__main__": + if not os.path.exists(DEMO_SRC): + print(f"提示: 未找到示例图片 '{DEMO_SRC}'") + print("请设置环境变量 LINEART_DEMO_SRC 指向一张图片, 例如:") + print(" set LINEART_DEMO_SRC=D:\\pics\\手抄报.jpg") + sys.exit(0) + + demo_simple() + demo_config() + demo_ndarray() + print("\n全部示例完成 ✔") diff --git a/lineartization/__init__.py b/lineartization/__init__.py new file mode 100644 index 0000000..892d315 --- /dev/null +++ b/lineartization/__init__.py @@ -0,0 +1,114 @@ +""" +lineartization +================= +把彩色插图 / 手抄报 一键转换为黑白线稿。 + +两种模式 +-------- +- ``method="skeleton"`` (默认):**骨架化**。适合"原图线条清晰"的图片 + (矢量插画、清晰手抄报),线条细而均匀、更美观。 +- ``method="minimum"`` :**最小值滤波**。适合"手写 / 手机拍 / 像素不足"的图, + 保留原笔触、不断线,属"基本可用"级别。 + +Quick start +----------- +>>> from lineartization import extract_lineart_file +>>> extract_lineart_file("手抄报.jpg", "线稿.png") # 骨架化 +>>> extract_lineart_file("手写.jpg", "线稿.png", method="minimum") # 最小值滤波 + +Python API: + +>>> import cv2 +>>> from lineartization import extract_lineart, LineArtConfig +>>> img = cv2.imread("手抄报.jpg") +>>> lineart = extract_lineart(img, LineArtConfig(method="skeleton")) + +CLI +--- +$ lineartization input.jpg output.png +$ lineartization input.jpg output.png --method minimum --verbose +""" +from .core import ( + LineArtConfig, + extract_lineart, + extract_lineart_file, + load_image, + save_image, +) + +__version__ = "1.6.1" +__author__ = "DVS" +__all__ = [ + "LineArtConfig", + "extract_lineart", + "extract_lineart_file", + "load_image", + "save_image", + "__version__", +] + + +def main(argv=None): + """命令行入口。""" + import argparse + from .core import LineArtConfig, extract_lineart_file + + parser = argparse.ArgumentParser( + prog="lineartization", + description="彩色插图/手抄报 -> 黑白线稿 (支持 骨架化 / 最小值滤波 两种模式)", + ) + parser.add_argument("input", help="输入图片路径") + parser.add_argument("output", help="输出线稿路径 (.png)") + parser.add_argument("-m", "--method", choices=["skeleton", "minimum"], + default="skeleton", + help="提取模式: skeleton=骨架化(清晰原图) / " + "minimum=最小值滤波(手写图)") + parser.add_argument("-w", "--width", type=int, default=2, + help="线宽 px (仅 skeleton 模式, 默认2)") + parser.add_argument("--min-mean", type=int, default=130, + help="minimum 模式: 真黑判据 RGB 均值上限 (默认130)") + parser.add_argument("--min-chroma", type=int, default=45, + help="minimum 模式: 真黑判据 色度上限 (默认45)") + parser.add_argument("--min-kernel", type=int, default=2, + help="minimum 模式: 最小值滤波半径 (默认2)") + parser.add_argument("-d", "--denoise", choices=["strong", "normal", "light", "none"], + default="strong", + help="minimum 模式降噪档位: strong(默认,普通强降噪)/normal/light/none") + parser.add_argument("--denoise-area", type=int, default=30, + help="minimum 模式: 连通域过滤阈值 (默认30)") + parser.add_argument("--no-green-smoothing", action="store_true", + help="禁用'绿块局部抹平'(非手抄报场景可关闭)") + parser.add_argument("--protect", action="append", default=[], + metavar="x1,x2,y1,y2", help="保护区域(可多次)") + parser.add_argument("-v", "--verbose", action="store_true", help="打印日志") + parser.add_argument("-V", "--version", action="version", + version=f"lineartization {__version__}") + + args = parser.parse_args(argv) + + protect_areas = [] + for spec in args.protect: + parts = [int(v) for v in spec.replace(" ", "").split(",")] + if len(parts) != 4: + parser.error(f"--protect 格式错误: {spec} (应为 x1,x2,y1,y2)") + protect_areas.append(tuple(parts)) + + cfg = LineArtConfig( + method=args.method, + line_width=max(1, args.width), + min_mean=args.min_mean, + min_chroma=args.min_chroma, + min_kernel=args.min_kernel, + denoise=args.denoise, + denoise_area=args.denoise_area, + enable_green_smoothing=not args.no_green_smoothing, + protect_areas=protect_areas, + ) + + out = extract_lineart_file(args.input, args.output, cfg, verbose=args.verbose) + print(f"✅ 线稿已生成 [{args.method}]: {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lineartization/__main__.py b/lineartization/__main__.py new file mode 100644 index 0000000..664c5d4 --- /dev/null +++ b/lineartization/__main__.py @@ -0,0 +1,5 @@ +"""支持 `python -m lineartization` 调用。""" +from . import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lineartization/core.py b/lineartization/core.py new file mode 100644 index 0000000..da8b6f3 --- /dev/null +++ b/lineartization/core.py @@ -0,0 +1,475 @@ +""" +lineartization.core +====================== +彩色插图 / 手抄报 -> 黑白线稿 的核心算法。 + +支持两种提取模式(``LineArtConfig.method``): + +1. ``"skeleton"`` —— **骨架化模式**(默认) + 适用于"原图本身线条就清晰"的图片(矢量插画、清晰手抄报的放大版)。 + 流程: 区域分析 → 图案/文字提取 → Lee 骨架化 → 去噪/剪倒刺 → 统一线宽 + 特点: 线条细而均匀、美观;但骨架化对"手写粗笔触"会产生分叉/网状。 + +2. ``"minimum"`` —— **最小值滤波模式** + 适用于"手写 / 像素不足 / 扫描件"类图片(手机拍的手抄报)。 + 流程: RGB 真黑判据 → 最小值滤波(PS 经典提线) → Otsu 纯黑白 → 降噪 + 降噪强度由 ``denoise`` 参数控制: + - ``"strong"`` (默认): 中值 → 开运算 → 连通域过滤(<30px) → 收尾中值 ← 普通强降噪 + - ``"normal"`` : 中值 → 连通域过滤(<20px) → 收尾中值 + - ``"light"`` : 中值 → 只删"极小且方正"噪点 → 收尾中值 + - ``"none"`` : 仅中值滤波 +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +import cv2 +import numpy as np + +try: + from skimage.morphology import skeletonize as _skel_lee + _HAS_SKIMAGE = True +except ImportError: # pragma: no cover + _HAS_SKIMAGE = False + + +# --------------------------------------------------------------------------- # +# 配置 +# --------------------------------------------------------------------------- # +@dataclass +class LineArtConfig: + """提取线稿的参数配置。""" + + # ---- 模式 ---- + method: str = "skeleton" # "skeleton" | "minimum" + + # ---- 通用: 纸面区(文字背景) ---- + paper_v: int = 140 + paper_s: int = 60 + paper_erode: int = 31 + + # ---- 通用: 文字区(精确矩形) ---- + ink_v: int = 140 + ink_s: int = 60 + density_close: int = 41 + density_open: int = 61 + text_pad: int = 40 + + # ---- skeleton 模式参数 ---- + dark_v: int = 160 + morph_open_k: int = 13 + adaptive_bs: int = 25 + adaptive_c: int = 19 + noise_sk_len: int = 25 + noise_branch: int = 8 + noise_area: int = 300 + spur_maxlen: int = 25 + + # ---- minimum 模式参数 ---- + # 真黑判据: RGB 均值 < min_mean 且 色度(最大-最小通道) < min_chroma + min_mean: int = 130 + min_chroma: int = 45 + min_kernel: int = 2 # 最小值滤波半径(1-3) + min_otsu: bool = True + # 降噪档位: "strong"(默认,普通强降噪) / "normal" / "light" / "none" + denoise: str = "strong" + denoise_area: int = 30 # strong/normal 模式: 连通域过滤阈值(<该值删除) + + # ---- 输出 ---- + line_width: int = 2 + + # ---- 保护区域 (x1, x2, y1, y2) ---- + protect_areas: List[Tuple[int, int, int, int]] = field(default_factory=list) + + # ---- 绿块局部抹平(手抄报山体) ---- + enable_green_smoothing: bool = True + green_hue_range: Tuple[int, int] = (35, 85) + green_sat_min: int = 60 + green_area_range: Tuple[int, int] = (3000, 25000) + meanshift_sp: int = 30 + meanshift_sr: int = 60 + + +# --------------------------------------------------------------------------- # +# 工具函数 +# --------------------------------------------------------------------------- # +def _skel(bin01: np.ndarray) -> np.ndarray: + """骨架化 (优先 Lee, 退化到 Zhang-Suen)。输入/输出均为 0/1。""" + b = (bin01 > 0).astype(np.uint8) + if _HAS_SKIMAGE: + return _skel_lee(b > 0).astype(np.uint8) + try: + import cv2.ximgproc as xi + return (xi.thinning(b * 255) > 128).astype(np.uint8) + except Exception: # pragma: no cover + return b + + +def _to_width(mask01: np.ndarray, width: int) -> np.ndarray: + """把 0/1 骨架增粗到目标宽度。""" + m = (mask01 > 0).astype(np.uint8) + if width <= 1: + return m + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * (width - 1) + 1,) * 2) + return cv2.dilate(m, k, iterations=1) + + +def load_image(path: str) -> np.ndarray: + """读取图片 (兼容中文路径 / RGBA / 灰度)。返回 BGR uint8。""" + data = np.fromfile(path, dtype=np.uint8) + im = cv2.imdecode(data, cv2.IMREAD_UNCHANGED) + if im is None: + im = cv2.imread(path, cv2.IMREAD_UNCHANGED) + if im is None: + raise FileNotFoundError(f"无法读取图片: {path}") + if im.ndim == 3 and im.shape[2] == 4: + bgr = im[:, :, :3].astype(np.float32) + a = im[:, :, 3:4].astype(np.float32) / 255.0 + im = (bgr * a + 255 * (1 - a)).astype(np.uint8) + elif im.ndim == 3: + im = im[:, :, :3] + else: + im = cv2.cvtColor(im, cv2.COLOR_GRAY2BGR) + return im + + +def save_image(path: str, img: np.ndarray) -> None: + """保存图片 (兼容中文路径)。""" + ext = os.path.splitext(path)[1] or ".png" + ok, buf = cv2.imencode(ext, img) + if not ok: + raise IOError(f"编码失败: {path}") + buf.tofile(path) + + +# --------------------------------------------------------------------------- # +# minimum 模式 +# --------------------------------------------------------------------------- # +def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: + """真黑/深灰判据: RGB 三通道都低、且互相接近(色度小)。""" + b = bgr[:, :, 0].astype(np.int32) + g = bgr[:, :, 1].astype(np.int32) + r = bgr[:, :, 2].astype(np.int32) + vmax = np.maximum(np.maximum(r, g), b) + vmin = np.minimum(np.minimum(r, g), b) + chroma = vmax - vmin + mean = (r + g + b) / 3.0 + return (mean < cfg.min_mean) & (chroma < cfg.min_chroma) + + +def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray: + """minimum 模式降噪 (可调档位)。 + + strong (默认): 中值 → 开运算 → 连通域过滤 → 收尾中值 ← "普通强降噪" + normal : 中值 → 连通域过滤 → 收尾中值 + light : 中值 → 只删"极小且方正"噪点 → 收尾中值 + none : 仅中值 + """ + lvl = (cfg.denoise or "strong").lower() + m = (mask_bool.astype(np.uint8)) * 255 + + if lvl == "none": + return cv2.medianBlur(m, 3) > 128 + + # ① 中值滤波 + m = cv2.medianBlur(m, 3) + + # ② strong: 开运算(去毛刺) + if lvl == "strong": + k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) + m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k2) + + # ③ 连通域过滤 + if lvl in ("strong", "normal"): + minA = cfg.denoise_area + n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8) + keep = np.zeros_like(m) + for i in range(1, n): + if st[i, cv2.CC_STAT_AREA] >= minA: + keep[lab == i] = 255 + m = keep + else: # light: 只删"极小且方正"噪点 + n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8) + keep = np.zeros_like(m) + for i in range(1, n): + x, y, w, h, a = st[i] + elong = max(w, h) / max(1, min(w, h)) + fill = a / max(1, w * h) + if a < 10 and fill >= 0.8 and elong < 1.8: + continue + keep[lab == i] = 255 + m = keep + + # ④ 收尾中值 + m = cv2.medianBlur(m, 3) + return m > 128 + + +def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: + """最小值滤波提线 (PS 经典流程) + 真黑判据 + 可调降噪。""" + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) + black_zone = _true_black_mask(bgr, cfg) + + k = max(1, cfg.min_kernel) + ke = cv2.getStructuringElement(cv2.MORPH_RECT, (k * 2 + 1, k * 2 + 1)) + L = gray + R = 255.0 - L + M = cv2.erode(R.astype(np.uint8), ke).astype(np.float32) + result = np.clip(L / (255.0 - M + 1e-6) * 255.0, 0, 255).astype(np.uint8) + + if cfg.min_otsu: + _, line = cv2.threshold(result, 0, 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU) + else: + _, line = cv2.threshold(result, 128, 255, cv2.THRESH_BINARY) + + mask = (line < 128) & black_zone + mask = _denoise_minimum(mask, cfg) + return mask.astype(np.uint8) + + +# --------------------------------------------------------------------------- # +# skeleton 模式 +# --------------------------------------------------------------------------- # +def _paper_mask(hsv, cfg): + s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32) + paper = (v > cfg.paper_v) & (s < cfg.paper_s) + pb = cv2.morphologyEx(paper.astype(np.uint8) * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21)), 3) + er = cfg.paper_erode + return cv2.erode(pb, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (er, er)), 1) > 0 + + +def _text_rect(bgr, cfg): + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32) + ink = ((v < cfg.ink_v) & (s < cfg.ink_s)).astype(np.uint8) + dense = cv2.morphologyEx(ink * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.density_close,)*2)) + dense = cv2.morphologyEx(dense, cv2.MORPH_OPEN, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.density_open,)*2)) + n, lab, st, _ = cv2.connectedComponentsWithStats((dense > 0).astype(np.uint8), 8) + if n <= 1: + return np.zeros(bgr.shape[:2], bool) + biggest = max(range(1, n), key=lambda i: st[i, cv2.CC_STAT_AREA]) + x, y, w, h, _ = st[biggest] + p = cfg.text_pad + x1, y1 = max(0, x - p), max(0, y - p) + x2, y2 = min(bgr.shape[1], x + w + p), min(bgr.shape[0], y + h + p) + tz = np.zeros(bgr.shape[:2], bool); tz[y1:y2, x1:x2] = True + return tz + + +def _green_zones(bgr, cfg): + h, w = bgr.shape[:2] + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + hue = hsv[:, :, 0].astype(np.int32); sat = hsv[:, :, 1].astype(np.int32) + lo, hi = cfg.green_hue_range + green = (hue > lo) & (hue < hi) & (sat > cfg.green_sat_min) + green[:, w // 2:] = False + n, lab, st, _ = cv2.connectedComponentsWithStats(green.astype(np.uint8), 8) + amin, amax = cfg.green_area_range + zones = np.zeros_like(green) + for i in range(1, n): + if amin <= st[i, cv2.CC_STAT_AREA] <= amax: + zones[lab == i] = 1 + solid = cv2.morphologyEx(zones.astype(np.uint8) * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))) + k9 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) + border = cv2.subtract(cv2.dilate(solid, k9), cv2.erode(solid, k9)) + zones = cv2.dilate(zones.astype(np.uint8), + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))) > 0 + return zones, border + + +def _extract_lines(img, pz, border, cfg, use_border): + g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + v = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)[:, :, 2].astype(np.int32) + at_text = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 31, 14) + at_all = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_MEAN_C, + cv2.THRESH_BINARY_INV, cfg.adaptive_bs, cfg.adaptive_c) + dark = (v < cfg.dark_v).astype(np.uint8) + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.morph_open_k,)*2) + big = cv2.morphologyEx(dark, cv2.MORPH_OPEN, k) + fine = dark.copy(); fine[big > 0] = 0 + pattern = (~pz) & (fine > 0) & (at_all > 0) + if use_border: + edges = cv2.Canny(g, 40, 120) + pattern = pattern | ((border > 0) & (edges > 0)) + text = (pz & (at_text > 0)) + allline = (text | pattern).astype(np.uint8) + c = cv2.morphologyEx(allline * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1) + return _skel(c > 0) + + +def _remove_noise_domains(line, protect, cfg): + n, lab, st, _ = cv2.connectedComponentsWithStats((line > 0).astype(np.uint8), 8) + k = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8) + big = np.zeros_like(line) + for i in range(1, n): + if st[i, cv2.CC_STAT_AREA] >= cfg.noise_area: big[lab == i] = 1 + near = cv2.dilate(big, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))) + keep = np.zeros_like(line) + for i in range(1, n): + x, y, w, h, a = st[i] + if a < 3: continue + comp = (lab == i) + s = _skel(comp.astype(np.uint8)); sk_len = int(s.sum()) + nb = cv2.filter2D(s, cv2.CV_8U, k); brs = int(((s > 0) & (nb >= 3)).sum()) + if sk_len < cfg.noise_sk_len and brs < cfg.noise_branch and a < cfg.noise_area: + continue + keep[comp] = 1 + keep |= line & protect.astype(np.uint8) + keep |= line & (near > 0).astype(np.uint8) + return keep + + +def _prune_spurs(line, protect, cfg): + s = _skel(line); sb = s.astype(bool); h, w = s.shape + k = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8) + nb = cv2.filter2D(s, cv2.CV_8U, k) + ends = ((s > 0) & (nb == 11)); brs = ((s > 0) & (nb >= 13)) + def _nbs(y, x): + out = [] + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + if dy == 0 and dx == 0: continue + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w and sb[ny, nx]: out.append((ny, nx)) + return out + cut = np.zeros_like(s) + for (y0, x0) in [tuple(p) for p in np.argwhere(ends)]: + path = [(y0, x0)]; cur, prev = (y0, x0), None + for _ in range(cfg.spur_maxlen): + ns = [p for p in _nbs(*cur) if p != prev] + if not ns or len(ns) > 1: break + nxt = ns[0] + if brs[nxt[0], nxt[1]]: + for (yy, xx) in path: + if not protect[yy, xx]: cut[yy, xx] = 1 + break + prev, cur = cur, nxt; path.append(cur) + cut_dil = cv2.dilate(cut, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))) + return line & (1 - cut_dil) + + +def _extract_skeleton(bgr, cfg): + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) + pz = _paper_mask(hsv, cfg) + tz = _text_rect(bgr, cfg) + protect = pz.copy() + for (x1, x2, y1, y2) in cfg.protect_areas: protect[y1:y2, x1:x2] = True + + zones = border = np.zeros_like(pz) + if cfg.enable_green_smoothing: + zones, border = _green_zones(bgr, cfg) + smoothed = (cv2.pyrMeanShiftFiltering(bgr, cfg.meanshift_sp, cfg.meanshift_sr, maxLevel=2) + if cfg.enable_green_smoothing else bgr) + + lines_fine = _extract_lines(bgr, pz, border, cfg, False) + if cfg.enable_green_smoothing: + lines_smooth = _extract_lines(smoothed, pz, border, cfg, True) + lines = np.where(zones, lines_smooth, lines_fine).astype(np.uint8) + else: + lines = lines_fine + + c = cv2.morphologyEx(lines * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1) + lines = _skel(c > 0) + lines = _remove_noise_domains(lines, protect, cfg) + lines = _prune_spurs(lines, protect, cfg) + + at_text = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 31, 14) + text_raw = (tz & (at_text > 0)).astype(np.uint8) + text_closed = cv2.morphologyEx(text_raw * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1) + text_skel = _skel(text_closed > 0) + + text_w = _to_width(text_skel, cfg.line_width) + pat_w = _to_width(lines & (~tz), cfg.line_width) + out = np.where((text_w > 0) | (pat_w > 0), 0, 255).astype(np.uint8) + + line = (out < 128).astype(np.uint8) + n, lab, st, _ = cv2.connectedComponentsWithStats(line, 8) + keep = np.zeros_like(line) + for i in range(1, n): + x, y, w, h, a = st[i] + if a < 3: continue + comp = (lab == i) + if (comp & protect).sum() > a * 0.5: keep[comp] = 1; continue + L = max(w, h); fill = a / max(1, w * h) + if a < 40 and min(w, h) / max(1, w) >= 0.6 and fill >= 0.55: continue + if L <= 12 and a < 60: continue + keep[comp] = 1 + return np.where(keep, 0, 255).astype(np.uint8) + + +# --------------------------------------------------------------------------- # +# 主入口 +# --------------------------------------------------------------------------- # +def extract_lineart(bgr: np.ndarray, + cfg: Optional[LineArtConfig] = None, + *, verbose: bool = False) -> np.ndarray: + """从 BGR 图像提取线稿。 + + Args: + bgr: 输入图像 (OpenCV BGR, uint8)。 + cfg: 参数配置。``method`` = "skeleton"|"minimum"。 + verbose: 打印日志。 + + Returns: + 白底黑线线稿 (uint8, 0/255)。 + """ + cfg = cfg or LineArtConfig() + + def _log(msg): + if verbose: print(msg, flush=True) + + method = (cfg.method or "skeleton").lower() + if method not in ("skeleton", "minimum"): + raise ValueError(f"未知 method: {cfg.method!r}") + + if method == "minimum": + _log(f"[lineart] method=minimum denoise={cfg.denoise}") + mask = _minimum_filter_lineart(bgr, cfg) + out = np.where(mask > 0, 0, 255).astype(np.uint8) + _log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%") + return out + + _log("[lineart] method=skeleton") + out = _extract_skeleton(bgr, cfg) + _log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%") + return out + + +def extract_lineart_file(src: str, dst: str, + cfg: Optional[LineArtConfig] = None, + *, method: Optional[str] = None, + verbose: bool = False) -> str: + """从文件提取线稿并保存。 + + Args: + src: 输入图片路径。 + dst: 输出线稿路径 (.png)。 + cfg: 参数配置。None 使用默认。 + method: 快捷覆盖模式 ("skeleton"/"minimum")。 + verbose: 打印日志。 + + Returns: + 输出文件路径。 + """ + if cfg is None: + cfg = LineArtConfig() + if method is not None: + cfg = LineArtConfig(**{**cfg.__dict__, "method": method}) + bgr = load_image(src) + out = extract_lineart(bgr, cfg, verbose=verbose) + os.makedirs(os.path.dirname(os.path.abspath(dst)) or ".", exist_ok=True) + save_image(dst, out) + return dst diff --git a/lineartization/py.typed b/lineartization/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..35d9f6b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "lineartization" +version = "1.6.1" +description = "彩色插图/手抄报 一键转换为黑白线稿 (汉字清晰、线条连贯、粗细统一)" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [ + { name = "DVS" }, +] +keywords = [ + "lineart", "line-art", "sketch", "skeleton", "thinning", + "image-processing", "opencv", "手抄报", "线稿", "提取线稿", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", +] + +dependencies = [ + "numpy>=1.21", + "opencv-python>=4.5", + "opencv-contrib-python>=4.5", + "scikit-image>=0.19", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "build>=1.0", + "twine>=4.0", +] + +[project.urls] +Homepage = "https://git.dvscloud.net/dvs/lineartization" +Repository = "https://git.dvscloud.net/dvs/lineartization" +"Issue Tracker" = "https://git.dvscloud.net/dvs/lineartization/issues" + +[project.scripts] +lineartization = "lineartization:main" + +[tool.setuptools] +packages = ["lineartization"] + +[tool.setuptools.package-data] +lineartization = ["py.typed"] diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..b5a4793 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,129 @@ +""" +lineart-extractor 单元测试 +========================== +运行: pytest tests/ -v +""" +import os +import sys + +import numpy as np +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from lineartization import ( + LineArtConfig, + extract_lineart, + extract_lineart_file, + load_image, + save_image, +) + + +# --------------------------------------------------------------------------- # +# 测试用图: 合成"白底 + 黑字 + 彩色块" +# --------------------------------------------------------------------------- # +@pytest.fixture +def sample_image(): + """构造一张 400x600 的合成图: 白底 + 黑色矩形(模拟文字) + 彩色块。""" + img = np.full((400, 600, 3), 255, np.uint8) + + # 中央"文字区": 密集小黑块 + rng = np.random.default_rng(42) + for _ in range(120): + x = rng.integers(180, 420) + y = rng.integers(150, 250) + img[y:y + 4, x:x + 4] = 0 + + # 左侧彩色块(模拟山体) + img[60:160, 20:180] = (60, 160, 80) # 绿 + img[160:220, 20:180] = (80, 120, 200) # 偏蓝 + + # 右侧一个红色圆(模拟灯笼) + import cv2 + cv2.circle(img, (500, 120), 40, (40, 40, 200), 3) + + return img + + +# --------------------------------------------------------------------------- # +# 测试 +# --------------------------------------------------------------------------- # +def test_load_save_roundtrip(tmp_path, sample_image): + """读写往返一致。""" + p = tmp_path / "in.png" + save_image(str(p), sample_image) + loaded = load_image(str(p)) + assert loaded.shape == sample_image.shape + assert np.allclose(loaded, sample_image, atol=2) + + +def test_extract_returns_binary(sample_image): + """输出必须是二值(0/255)白底黑线。""" + out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False)) + assert out.dtype == np.uint8 + assert out.ndim == 2 + uniq = np.unique(out) + assert set(uniq.tolist()).issubset({0, 255}) + assert out.shape == sample_image.shape[:2] + + +def test_extract_has_content(sample_image): + """输出不能空白、也不能全黑。""" + out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False)) + black_ratio = (out < 128).mean() * 100 + assert 0.1 < black_ratio < 90.0 + + +def test_line_width_effect(sample_image): + """线宽参数应影响黑占比(越粗越多)。""" + cfg1 = LineArtConfig(line_width=1, enable_green_smoothing=False) + cfg3 = LineArtConfig(line_width=3, enable_green_smoothing=False) + r1 = (extract_lineart(sample_image, cfg1) < 128).mean() + r3 = (extract_lineart(sample_image, cfg3) < 128).mean() + assert r3 > r1 + + +def test_green_smoothing_toggle(sample_image): + """绿块抹平开关都应能正常出图。""" + for flag in (True, False): + cfg = LineArtConfig(enable_green_smoothing=flag) + out = extract_lineart(sample_image, cfg) + assert (out < 128).mean() > 0 + + +def test_protect_areas(sample_image): + """保护区域内的线条不应被删。""" + cfg = LineArtConfig( + enable_green_smoothing=False, + protect_areas=[(0, 200, 0, 400)], + ) + out = extract_lineart(sample_image, cfg) + assert (out < 128).sum() > 0 + + +def test_file_interface(tmp_path, sample_image): + """extract_lineart_file 接口正常。""" + src = tmp_path / "src.png" + dst = tmp_path / "dst.png" + save_image(str(src), sample_image) + result = extract_lineart_file(str(src), str(dst), + LineArtConfig(enable_green_smoothing=False)) + assert os.path.exists(result) + assert result == str(dst) + + +def test_load_missing_file(): + """读取不存在的文件应抛异常。""" + with pytest.raises((FileNotFoundError, Exception)): + load_image("___no_such_file___.png") + + +def test_chinese_path(tmp_path, sample_image): + """中文路径应正常工作。""" + src = tmp_path / "中文图片.png" + dst = tmp_path / "输出_láthair.png" + save_image(str(src), sample_image) + out = extract_lineart_file(str(src), str(dst), + LineArtConfig(enable_green_smoothing=False)) + assert os.path.exists(out)