release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise)
This commit is contained in:
+38
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
[]()
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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全部示例完成 ✔")
|
||||
@@ -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())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""支持 `python -m lineartization` 调用。"""
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user