release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise)

This commit is contained in:
dvs
2026-09-26 13:37:03 +08:00
commit b6612f1df3
11 changed files with 1360 additions and 0 deletions
+38
View File
@@ -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
+21
View File
@@ -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.
+7
View File
@@ -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
+443
View File
@@ -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.
+68
View File
@@ -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全部示例完成 ✔")
+114
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
"""支持 `python -m lineartization` 调用。"""
from . import main
if __name__ == "__main__":
raise SystemExit(main())
+475
View File
@@ -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
View File
+60
View File
@@ -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"]
+129
View File
@@ -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)