release: v1.7.0 - region-agnostic skeleton, selectable true-black, English code
Highlights:
- minimum mode: add min_true_black switch (default True = unchanged v1.6.1 behaviour).
True : true-black gate + CC denoise (best for black line work).
False : no gate, keeps colored regions, distance-transform thinning
(best for colorful posters).
- skeleton mode: replace hue-specific green-block smoothing with
saturation-based _color_zones; no hue or side-of-image assumptions.
- Move all comments, docstrings and messages to English.
- Remove built-in default paths; examples/demo.py now requires args.
- README: document both minimum variants, resolution guidance, parameters.
- tests: 18 cases covering I/O, both modes, both minimum variants.
This commit is contained in:
+5
-5
@@ -10,29 +10,29 @@ dist/
|
|||||||
.eggs/
|
.eggs/
|
||||||
pip-wheel-metadata/
|
pip-wheel-metadata/
|
||||||
|
|
||||||
# 虚拟环境
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
||||||
# 测试 / 缓存
|
# Tests / caches
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
# IDE
|
# IDEs
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
# 输出
|
# Outputs
|
||||||
output/
|
output/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# 系统
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
# lineartization
|
# lineartization
|
||||||
|
|
||||||
> **Convert color illustrations / handwritten posters into clean black-and-white line art.**
|
> **Convert color illustrations / posters into clean black-and-white line art.**
|
||||||
> Pure Python + OpenCV + scikit-image. No deep-learning models required. Runs offline.
|
> Pure Python + OpenCV + scikit-image. No deep-learning models required. Runs offline.
|
||||||
|
|
||||||
**Version:** 1.6.1 · **Author:** DVS · **License:** MIT
|
**Version:** 1.7.0 · **Author:** DVS · **License:** MIT
|
||||||
|
|
||||||
[]()
|
|
||||||
[]()
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
|
- [Resolution Matters](#resolution-matters)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [The Two Extraction Modes](#the-two-extraction-modes)
|
- [The Two Extraction Modes](#the-two-extraction-modes)
|
||||||
|
- [Minimum Mode: True-Black On/Off](#minimum-mode-true-black-onoff)
|
||||||
- [Denoise Levels](#denoise-levels)
|
- [Denoise Levels](#denoise-levels)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
- [Python API](#python-api)
|
- [Python API](#python-api)
|
||||||
@@ -37,7 +36,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`lineartization` turns a **color picture** (manga-style illustration, school poster,
|
`lineartization` turns a **color picture** (manga-style illustration, poster,
|
||||||
children's drawing) into a **black-on-white line drawing** suitable for:
|
children's drawing) into a **black-on-white line drawing** suitable for:
|
||||||
|
|
||||||
- Coloring books / templates
|
- Coloring books / templates
|
||||||
@@ -51,16 +50,41 @@ two well-defined strategies depending on the source quality.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Resolution Matters
|
||||||
|
|
||||||
|
**The higher the input resolution, the better the extraction.** Stroke recovery
|
||||||
|
is a purely geometric operation: at higher resolution a stroke covers more
|
||||||
|
pixels, survives binarization more reliably, and thins into a cleaner centre
|
||||||
|
line. Low-resolution inputs lose stroke detail before the algorithm even runs,
|
||||||
|
and no pure-algorithm method can invent it back.
|
||||||
|
|
||||||
|
Practical guidance:
|
||||||
|
|
||||||
|
- **High resolution (≥ 1500 px on the long edge)** → use `skeleton`. You get
|
||||||
|
thin, uniform, aesthetically pleasing lines.
|
||||||
|
- **Medium resolution** → try `skeleton` first; if strokes break up, fall back
|
||||||
|
to `minimum`.
|
||||||
|
- **Low resolution / phone snapshot / heavy compression** → use `minimum`. It
|
||||||
|
does not depend on thinning thin structures, so it degrades far more
|
||||||
|
gracefully.
|
||||||
|
- If you can, **upscale before extraction** rather than fighting a small input.
|
||||||
|
|
||||||
|
The CLI prints this hint after every run, and it is also available
|
||||||
|
programmatically as `lineartization.RESOLUTION_HINT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| 🈶 **Chinese-text aware** | Detects the text block and keeps character strokes complete |
|
| 📐 **Uniform stroke width** | Output lines unified to a configurable width |
|
||||||
| 📐 **Uniform stroke width** | Text and artwork lines unified to a configurable width |
|
|
||||||
| 🔗 **Continuous lines** | Lee skeletonization (shape-preserving) instead of naive thinning |
|
| 🔗 **Continuous lines** | Lee skeletonization (shape-preserving) instead of naive thinning |
|
||||||
| 🎨 **True-black criterion** | Distinguishes *black ink* from *dark colors* using RGB + chroma |
|
| 🎨 **True-black criterion** | Distinguishes *black ink* from *dark colors* using RGB + chroma |
|
||||||
|
| 🔀 **Two minimum variants** | Pick per image: with or without the true-black gate |
|
||||||
| 🧹 **Tunable denoise** | Four levels: `strong` / `normal` / `light` / `none` |
|
| 🧹 **Tunable denoise** | Four levels: `strong` / `normal` / `light` / `none` |
|
||||||
| 🛡️ **Protected regions** | Keep complex textures (emblems, seals) from being cleaned away |
|
| 🛡️ **Protected regions** | Keep chosen rectangles (emblems, seals) from being cleaned away |
|
||||||
|
| 🧩 **Region-agnostic** | No hue- or side-of-image assumptions; works on any layout |
|
||||||
| 🐍 **Zero model dependency** | No GPU, no ONNX, no downloads — `pip install` and run |
|
| 🐍 **Zero model dependency** | No GPU, no ONNX, no downloads — `pip install` and run |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -93,6 +117,9 @@ lineartization poster.jpg lineart.png
|
|||||||
|
|
||||||
# Minimum filter (handwritten / photographed source)
|
# Minimum filter (handwritten / photographed source)
|
||||||
lineartization handwriting.jpg lineart.png --method minimum
|
lineartization handwriting.jpg lineart.png --method minimum
|
||||||
|
|
||||||
|
# Minimum filter without the true-black gate (colorful posters)
|
||||||
|
lineartization colorful.jpg lineart.png --method minimum --no-true-black
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -100,8 +127,10 @@ lineartization handwriting.jpg lineart.png --method minimum
|
|||||||
```python
|
```python
|
||||||
from lineartization import extract_lineart_file
|
from lineartization import extract_lineart_file
|
||||||
|
|
||||||
extract_lineart_file("poster.jpg", "lineart.png") # skeleton
|
extract_lineart_file("poster.jpg", "lineart.png") # skeleton
|
||||||
extract_lineart_file("handwriting.jpg", "lineart.png", method="minimum")
|
extract_lineart_file("handwriting.jpg", "lineart.png", method="minimum")
|
||||||
|
extract_lineart_file("colorful.jpg", "lineart.png",
|
||||||
|
method="minimum", min_true_black=False)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -135,9 +164,45 @@ anything with thick, irregular, low-resolution strokes.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Minimum Mode: True-Black On/Off
|
||||||
|
|
||||||
|
`minimum` mode runs the classic Photoshop "minimum filter" (color-dodge blend)
|
||||||
|
followed by Otsu. The one thing you get to choose is whether the result is then
|
||||||
|
gated by the **true-black criterion**.
|
||||||
|
|
||||||
|
| | `min_true_black=True` (default) | `min_true_black=False` |
|
||||||
|
|---|---|---|
|
||||||
|
| True-black gate | ✅ intersects with the true-black mask | ❌ not applied |
|
||||||
|
| Colored regions | excluded (only dark, low-chroma pixels survive) | **kept as strokes** |
|
||||||
|
| Cleanup | median → open → CC filter → median | despeckle → drop short fragments |
|
||||||
|
| Thinning | none (original stroke weight kept) | distance transform to a thin even line |
|
||||||
|
| Best for | mostly-black line drawings, clean ink work | colorful posters, illustrations |
|
||||||
|
|
||||||
|
**Which to pick?** It depends on the picture, so try both when unsure:
|
||||||
|
|
||||||
|
- A **math worksheet / notebook page** — mostly black strokes on light paper —
|
||||||
|
looks better with the true-black gate **on**: the gate removes colored
|
||||||
|
scribbles and keeps the line work clean.
|
||||||
|
- A **colorful festival poster** — large red / gold areas — looks better with
|
||||||
|
the gate **off**: with the gate on, almost everything colorful is discarded
|
||||||
|
and the drawing comes out nearly empty.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lineartization in.jpg out.png -m minimum # true-black on
|
||||||
|
lineartization in.jpg out.png -m minimum --no-true-black # true-black off
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
extract_lineart(img, LineArtConfig(method="minimum", min_true_black=True))
|
||||||
|
extract_lineart(img, LineArtConfig(method="minimum", min_true_black=False))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Denoise Levels
|
## Denoise Levels
|
||||||
|
|
||||||
Available only in `minimum` mode (skeleton mode has its own built-in cleanup).
|
Available in `minimum` mode when `min_true_black=True`.
|
||||||
|
(`min_true_black=False` uses its own despeckle + fragment removal instead.)
|
||||||
|
|
||||||
| Level | Pipeline | Note |
|
| Level | Pipeline | Note |
|
||||||
|-------|----------|------|
|
|-------|----------|------|
|
||||||
@@ -157,10 +222,15 @@ lineartization in.jpg out.png -m minimum -d strong --denoise-area 40
|
|||||||
|
|
||||||
```
|
```
|
||||||
usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH]
|
usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH]
|
||||||
[--min-mean MIN_MEAN] [--min-chroma MIN_CHROMA]
|
[--no-true-black] [--min-mean MIN_MEAN]
|
||||||
|
[--min-chroma MIN_CHROMA] [--min-ratio MIN_RATIO]
|
||||||
[--min-kernel MIN_KERNEL]
|
[--min-kernel MIN_KERNEL]
|
||||||
[-d {strong,normal,light,none}]
|
[-d {strong,normal,light,none}]
|
||||||
[--denoise-area DENOISE_AREA] [--no-green-smoothing]
|
[--denoise-area DENOISE_AREA]
|
||||||
|
[--m2-noise-area M2_NOISE_AREA]
|
||||||
|
[--m2-short-area M2_SHORT_AREA]
|
||||||
|
[--m2-short-len M2_SHORT_LEN] [--m2-close-k M2_CLOSE_K]
|
||||||
|
[--m2-dist-min M2_DIST_MIN] [--no-color-smoothing]
|
||||||
[--protect x1,x2,y1,y2] [-v] [-V]
|
[--protect x1,x2,y1,y2] [-v] [-V]
|
||||||
input output
|
input output
|
||||||
```
|
```
|
||||||
@@ -169,12 +239,19 @@ usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH]
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `-m, --method` | `skeleton` | Extraction mode |
|
| `-m, --method` | `skeleton` | Extraction mode |
|
||||||
| `-w, --width` | `2` | Stroke width (skeleton mode) |
|
| `-w, --width` | `2` | Stroke width (skeleton mode) |
|
||||||
| `-d, --denoise` | `strong` | Denoise level (minimum mode) |
|
| `--no-true-black` | off | `minimum`: disable the true-black gate |
|
||||||
| `--denoise-area` | `30` | Connected-component removal threshold |
|
| `--min-mean` | `180` | True-black criterion: max RGB mean |
|
||||||
| `--min-mean` | `130` | True-black criterion: max RGB mean |
|
| `--min-chroma` | `60` | True-black criterion: max chroma |
|
||||||
| `--min-chroma` | `45` | True-black criterion: max chroma |
|
| `--min-ratio` | `1.5` | Otsu fallback threshold (%) |
|
||||||
| `--min-kernel` | `2` | Minimum-filter radius (1–3) |
|
| `--min-kernel` | `2` | Minimum-filter radius (1–3) |
|
||||||
| `--no-green-smoothing` | off | Disable green-block smoothing |
|
| `-d, --denoise` | `strong` | Denoise level (true-black on) |
|
||||||
|
| `--denoise-area` | `30` | Connected-component removal threshold |
|
||||||
|
| `--m2-noise-area` | `20` | No-true-black: despeckle threshold |
|
||||||
|
| `--m2-short-area` | `40` | No-true-black: short-fragment area |
|
||||||
|
| `--m2-short-len` | `25` | No-true-black: short-fragment length |
|
||||||
|
| `--m2-close-k` | `2` | No-true-black: close kernel before thinning |
|
||||||
|
| `--m2-dist-min` | `0.5` | No-true-black: distance threshold |
|
||||||
|
| `--no-color-smoothing` | off | Skeleton: disable flat-color smoothing |
|
||||||
| `--protect` | — | Protected rect `x1,x2,y1,y2` (repeatable) |
|
| `--protect` | — | Protected rect `x1,x2,y1,y2` (repeatable) |
|
||||||
| `-v, --verbose` | off | Print pipeline logs |
|
| `-v, --verbose` | off | Print pipeline logs |
|
||||||
|
|
||||||
@@ -190,10 +267,9 @@ img = load_image("poster.jpg") # BGR uint8, RGBA-safe
|
|||||||
|
|
||||||
cfg = LineArtConfig(
|
cfg = LineArtConfig(
|
||||||
method="minimum", # "skeleton" | "minimum"
|
method="minimum", # "skeleton" | "minimum"
|
||||||
denoise="strong", # strong | normal | light | none
|
min_true_black=False, # False -> keep colored regions, thin the strokes
|
||||||
min_mean=130, # true-black RGB mean threshold
|
|
||||||
min_chroma=45, # true-black chroma threshold
|
|
||||||
min_kernel=2, # minimum-filter radius
|
min_kernel=2, # minimum-filter radius
|
||||||
|
m2_dist_min=0.5, # thinning strength (no-true-black variant)
|
||||||
protect_areas=[(120, 220, 940, 1050)], # x1,x2,y1,y2
|
protect_areas=[(120, 220, 940, 1050)], # x1,x2,y1,y2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,12 +295,13 @@ image valued 0/255** (white background, black lines).
|
|||||||
▼ ▼
|
▼ ▼
|
||||||
method = "skeleton" method = "minimum"
|
method = "skeleton" method = "minimum"
|
||||||
──────────────────── ────────────────────
|
──────────────────── ────────────────────
|
||||||
Region analysis True-black criterion
|
Paper + text region True-black criterion (optional)
|
||||||
Pattern extraction Minimum filter
|
Pattern / text extraction Minimum filter
|
||||||
Lee skeletonization Otsu binarization
|
Lee skeletonization Otsu binarization
|
||||||
Denoise + spur pruning Denoise (tunable)
|
Denoise + spur pruning ├─ true-black ON : CC denoise
|
||||||
Uniform width → white bg / black lines
|
Uniform width └─ true-black OFF: despeckle,
|
||||||
│ │
|
│ fragment removal,
|
||||||
|
│ distance-transform thinning
|
||||||
└───────────────┬────────────────┘
|
└───────────────┬────────────────┘
|
||||||
▼
|
▼
|
||||||
0/255 line-art PNG
|
0/255 line-art PNG
|
||||||
@@ -258,7 +335,7 @@ Two spatial masks are derived from the HSV representation:
|
|||||||
|
|
||||||
### Step 2 — Line extraction
|
### Step 2 — Line extraction
|
||||||
|
|
||||||
```
|
```python
|
||||||
at_text = adaptiveThreshold(gray, GAUSSIAN, INV, 31, 14)
|
at_text = adaptiveThreshold(gray, GAUSSIAN, INV, 31, 14)
|
||||||
at_all = adaptiveThreshold(gray, MEAN, INV, 25, 19)
|
at_all = adaptiveThreshold(gray, MEAN, INV, 25, 19)
|
||||||
dark = (V < dark_v)
|
dark = (V < dark_v)
|
||||||
@@ -268,9 +345,12 @@ text = paper AND at_text
|
|||||||
lines = skel( morph_close(text OR pattern, 3×3) )
|
lines = skel( morph_close(text OR pattern, 3×3) )
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionally, the four large green blocks (hills in a poster) are re-extracted
|
**Flat color regions.** Broad saturated fills are located by saturation alone
|
||||||
from a **mean-shift smoothed** copy to suppress colour-banding, and merged via
|
(`S > color_sat_min`, area within `color_area_range`) — deliberately *not* by
|
||||||
a Canny contour (see `enable_green_smoothing`).
|
hue, so the step works for any palette rather than one specific image. Those
|
||||||
|
regions are re-extracted from a **mean-shift smoothed** copy, where a Canny
|
||||||
|
contour supplies the boundary, which suppresses colour banding inside the fill.
|
||||||
|
Tune or disable with `enable_color_smoothing`.
|
||||||
|
|
||||||
### Step 3 — Denoise & spur pruning
|
### Step 3 — Denoise & spur pruning
|
||||||
|
|
||||||
@@ -302,6 +382,35 @@ result = L / (255 − M) · 255 # "Color Dodge" blend
|
|||||||
line = Otsu(result) # pure black / white
|
line = Otsu(result) # pure black / white
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This common front end is followed by one of two back ends:
|
||||||
|
|
||||||
|
### Back end 1 — `min_true_black=True`
|
||||||
|
|
||||||
|
```
|
||||||
|
mask = (line < 128) AND true_black
|
||||||
|
if mask_ratio < min_ratio: # Otsu too sparse -> retry
|
||||||
|
mask = adaptiveThreshold(...) AND true_black
|
||||||
|
mask = denoise(mask, level) # median / open / CC filter / median
|
||||||
|
```
|
||||||
|
|
||||||
|
The `min_ratio` guard matters for white backgrounds with very thin lines, where
|
||||||
|
global Otsu can collapse to almost no ink; the adaptive threshold recovers it.
|
||||||
|
|
||||||
|
### Back end 2 — `min_true_black=False`
|
||||||
|
|
||||||
|
```
|
||||||
|
mask = (line < 128) # no true-black gate
|
||||||
|
mask = despeckle(mask, m2_noise_area)
|
||||||
|
mask = drop_short(mask, m2_short_area, m2_short_len)
|
||||||
|
mask = morph_close(mask, m2_close_k)
|
||||||
|
mask = distance_transform(mask) >= m2_dist_min
|
||||||
|
```
|
||||||
|
|
||||||
|
The last step is what makes the output a thin, even line. A distance transform
|
||||||
|
is used **instead of skeletonization**: skeletonization collapses a stroke to a
|
||||||
|
1-px medial axis, losing glyph detail and branching at thick crossings, whereas
|
||||||
|
the distance transform only shaves inward, preserving stroke topology.
|
||||||
|
|
||||||
### Why `L / (255 − M)` and not the inverse
|
### Why `L / (255 − M)` and not the inverse
|
||||||
|
|
||||||
The Photoshop **Color Dodge** blend of a base `L` and a blend layer `B` is
|
The Photoshop **Color Dodge** blend of a base `L` and a blend layer `B` is
|
||||||
@@ -328,15 +437,15 @@ true_black = (mean < min_mean) AND (chroma < min_chroma)
|
|||||||
* `mean < min_mean` ⇒ dark enough.
|
* `mean < min_mean` ⇒ dark enough.
|
||||||
* `chroma < min_chroma` ⇒ R, G, B are close ⇒ grey/black, **not** a saturated colour.
|
* `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
|
Defaults are `min_mean=180`, `min_chroma=60`. This gate is what the
|
||||||
reported as ink.
|
`min_true_black` switch turns on and off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Denoise Algorithm
|
## Denoise Algorithm
|
||||||
|
|
||||||
`minimum` mode exposes four levels. All levels end with a median pass to remove
|
`minimum` mode with `min_true_black=True` exposes four levels. All levels end
|
||||||
salt-and-pepper residue.
|
with a median pass to remove salt-and-pepper residue.
|
||||||
|
|
||||||
```
|
```
|
||||||
strong : median(3) → open(2×2) → remove CC area<30 → median(3)
|
strong : median(3) → open(2×2) → remove CC area<30 → median(3)
|
||||||
@@ -348,29 +457,56 @@ none : median(3)
|
|||||||
`strong` is the default. Lower levels trade less noise suppression for fewer
|
`strong` is the default. Lower levels trade less noise suppression for fewer
|
||||||
false deletions of legitimate short strokes.
|
false deletions of legitimate short strokes.
|
||||||
|
|
||||||
|
There is also a safety net: if denoising removes more than half of the strokes
|
||||||
|
(a sign that real lines were deleted), the pre-denoise result is used instead,
|
||||||
|
so the output never goes blank.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Parameter Reference
|
## Parameter Reference
|
||||||
|
|
||||||
|
### Shared
|
||||||
|
|
||||||
| Parameter | Default | Meaning |
|
| Parameter | Default | Meaning |
|
||||||
|-----------|---------|---------|
|
|-----------|---------|---------|
|
||||||
| `method` | `"skeleton"` | `"skeleton"` or `"minimum"` |
|
| `method` | `"skeleton"` | `"skeleton"` or `"minimum"` |
|
||||||
|
| `line_width` | `2` | Final stroke width (skeleton mode) |
|
||||||
|
| `protect_areas` | `[]` | List of `(x1,x2,y1,y2)` rectangles never cleaned |
|
||||||
|
|
||||||
|
### Skeleton mode
|
||||||
|
|
||||||
|
| Parameter | Default | Meaning |
|
||||||
|
|-----------|---------|---------|
|
||||||
| `paper_v` / `paper_s` | 140 / 60 | Paper-region brightness / saturation bounds |
|
| `paper_v` / `paper_s` | 140 / 60 | Paper-region brightness / saturation bounds |
|
||||||
| `paper_erode` | 31 | Erosion kernel to shrink the paper region |
|
| `paper_erode` | 31 | Erosion kernel to shrink the paper region |
|
||||||
| `ink_v` / `ink_s` | 140 / 60 | Ink criterion for text-block detection |
|
| `ink_v` / `ink_s` | 140 / 60 | Ink criterion for text-block detection |
|
||||||
|
| `density_close` / `density_open` | 41 / 61 | Density-blob morphology |
|
||||||
| `text_pad` | 40 | Padding around the detected text rectangle |
|
| `text_pad` | 40 | Padding around the detected text rectangle |
|
||||||
| `dark_v` | 160 | Dark-pixel threshold (skeleton mode) |
|
| `dark_v` | 160 | Dark-pixel threshold |
|
||||||
| `morph_open_k` | 13 | Kernel removing large dark blocks |
|
| `morph_open_k` | 13 | Kernel removing large dark blocks |
|
||||||
| `adaptive_bs` / `adaptive_c` | 25 / 19 | Artwork adaptive threshold |
|
| `adaptive_bs` / `adaptive_c` | 25 / 19 | Artwork adaptive threshold |
|
||||||
| `noise_sk_len` / `noise_branch` / `noise_area` | 25 / 8 / 300 | Isolated-noise criterion |
|
| `noise_sk_len` / `noise_branch` / `noise_area` | 25 / 8 / 300 | Isolated-noise criterion |
|
||||||
| `spur_maxlen` | 25 | Max spur length pruned |
|
| `spur_maxlen` | 25 | Max spur length pruned |
|
||||||
| `min_mean` / `min_chroma` | 130 / 45 | True-black criterion |
|
| `enable_color_smoothing` | `True` | Smooth broad flat colour regions |
|
||||||
|
| `color_sat_min` | 60 | Saturation floor for "flat colour region" |
|
||||||
|
| `color_area_range` | (3000, 25000) | Plausible area range for such a region |
|
||||||
|
| `meanshift_sp` / `meanshift_sr` | 30 / 60 | Mean-shift smoothing parameters |
|
||||||
|
|
||||||
|
### Minimum mode
|
||||||
|
|
||||||
|
| Parameter | Default | Meaning |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `min_true_black` | `True` | Apply the true-black gate |
|
||||||
|
| `min_mean` / `min_chroma` | 180 / 60 | True-black criterion bounds |
|
||||||
| `min_kernel` | 2 | Minimum-filter radius |
|
| `min_kernel` | 2 | Minimum-filter radius |
|
||||||
| `denoise` | `"strong"` | Denoise level |
|
| `min_otsu` | `True` | Use Otsu instead of a fixed 128 threshold |
|
||||||
| `denoise_area` | 30 | CC removal area for strong/normal |
|
| `min_ratio` | 1.5 | Fallback to adaptive threshold below this ink % |
|
||||||
| `line_width` | 2 | Stroke width (skeleton mode) |
|
| `denoise` | `"strong"` | Denoise level (true-black on) |
|
||||||
| `protect_areas` | `[]` | List of `(x1,x2,y1,y2)` rectangles never cleaned |
|
| `denoise_area` | 30 | CC removal area (true-black on) |
|
||||||
| `enable_green_smoothing` | `True` | Mean-shift smoothing of green hill blocks |
|
| `m2_noise_area` | 20 | Despeckle area (true-black off) |
|
||||||
|
| `m2_short_area` / `m2_short_len` | 40 / 25 | Short-fragment removal |
|
||||||
|
| `m2_close_k` | 2 | Close kernel before thinning |
|
||||||
|
| `m2_dist_min` | 0.5 | Distance threshold for thinning |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -382,6 +518,17 @@ remaining medial axis branches into spurs and webs. That is precisely what
|
|||||||
`method="minimum"` avoids by keeping the original stroke instead of reducing it
|
`method="minimum"` avoids by keeping the original stroke instead of reducing it
|
||||||
to a 1-px skeleton.
|
to a 1-px skeleton.
|
||||||
|
|
||||||
|
**Why the true-black gate is a switch, not a constant.** Some images are
|
||||||
|
genuinely black line work on light paper, where the gate is a pure win. Others
|
||||||
|
are dominated by saturated colours, where the gate discards most of the drawing.
|
||||||
|
Neither setting is universally right, so both are exposed and the default
|
||||||
|
(`True`) preserves the long-standing behaviour.
|
||||||
|
|
||||||
|
**Why the flat-color step keys on saturation, not hue.** An earlier revision
|
||||||
|
looked for a specific hue range and also assumed the region lay on the left half
|
||||||
|
of the image, which only worked for one particular poster. Saturation alone has
|
||||||
|
no such assumptions and generalises to any layout or palette.
|
||||||
|
|
||||||
**Why edge detection is avoided.** Classical edge detectors (Sobel, Laplacian,
|
**Why edge detection is avoided.** Classical edge detectors (Sobel, Laplacian,
|
||||||
High-pass) respond to *gradients*; a rasterised line has **two** edges, so the
|
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
|
output is a hollow double line. Closing the gap yields either a thick smear or
|
||||||
@@ -391,9 +538,12 @@ requires a centre-line step — both inferior to the direct approaches above.
|
|||||||
|
|
||||||
* Very low-resolution text (character strokes < 2 px) cannot be recovered by any
|
* 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
|
pure-algorithm method; a semantic/AI model is required. This library does not
|
||||||
include one by design.
|
include one by design. **Raise the resolution** or use `minimum` mode.
|
||||||
|
* `skeleton` mode on large images is slow (tens of seconds) because of the
|
||||||
|
full-frame mean-shift pass. Set `enable_color_smoothing=False` to skip it.
|
||||||
* Heavy JPEG artefacts in the source may survive as small debris; raise
|
* Heavy JPEG artefacts in the source may survive as small debris; raise
|
||||||
`--denoise-area` to suppress them.
|
`--denoise-area` (true-black) or `--m2-noise-area` / `--m2-short-area`
|
||||||
|
(no-true-black) to suppress them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -416,6 +566,9 @@ lineartization/
|
|||||||
└── LICENSE
|
└── LICENSE
|
||||||
```
|
```
|
||||||
|
|
||||||
|
No file in this project contains a built-in absolute path. Every entry point
|
||||||
|
takes its input and output paths from the caller.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
+64
-39
@@ -1,7 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
lineart-extractor 使用示例
|
lineartization usage examples
|
||||||
==========================
|
=============================
|
||||||
演示三种用法: 一行函数 / 自定义配置 / 直接处理 ndarray
|
Demonstrates the call styles: one-liner / custom config / raw ndarray.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python demo.py <input_image> <output_dir>
|
||||||
|
|
||||||
|
There are no built-in default paths: both arguments are required.
|
||||||
|
|
||||||
|
Tip: extraction quality depends on the source resolution -- the higher the
|
||||||
|
resolution, the cleaner the result. For low-resolution inputs use the
|
||||||
|
``minimum`` method.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -16,53 +25,69 @@ from lineartization import (
|
|||||||
save_image,
|
save_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
DEMO_SRC = os.environ.get("LINEART_DEMO_SRC", "手抄报.jpg")
|
|
||||||
DEMO_OUT_DIR = os.environ.get("LINEART_DEMO_OUT", "./output")
|
def demo_simple(src, out_dir):
|
||||||
os.makedirs(DEMO_OUT_DIR, exist_ok=True)
|
"""Example 1: one-liner (skeleton mode)."""
|
||||||
|
print("=== Example 1: one-liner ===")
|
||||||
|
extract_lineart_file(src, os.path.join(out_dir, "simple.png"))
|
||||||
|
print(" wrote simple.png")
|
||||||
|
|
||||||
|
|
||||||
def demo_simple():
|
def demo_config(src, out_dir):
|
||||||
"""① 一行搞定"""
|
"""Example 2: custom config (minimum mode + protected region)."""
|
||||||
print("=== 示例1: 一行调用 ===")
|
print("=== Example 2: custom config ===")
|
||||||
extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "simple.png"))
|
|
||||||
print(" 已生成 simple.png")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_config():
|
|
||||||
"""② 自定义配置 (线宽 + 保护华表区)"""
|
|
||||||
print("=== 示例2: 自定义配置 ===")
|
|
||||||
cfg = LineArtConfig(
|
cfg = LineArtConfig(
|
||||||
method="minimum",
|
method="minimum",
|
||||||
denoise="strong",
|
denoise="strong",
|
||||||
line_width=2, # 统一线宽 2px
|
line_width=2, # uniform 2 px stroke
|
||||||
enable_green_smoothing=True, # 手抄报绿块抹平
|
protect_areas=[(120, 220, 940, 1050)], # keep this region intact
|
||||||
protect_areas=[(120, 220, 940, 1050)], # 保护"华表"区域
|
|
||||||
)
|
)
|
||||||
extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "configured.png"),
|
extract_lineart_file(src, os.path.join(out_dir, "configured.png"),
|
||||||
cfg, verbose=True)
|
cfg, verbose=True)
|
||||||
print(" 已生成 configured.png")
|
print(" wrote configured.png")
|
||||||
|
|
||||||
|
|
||||||
def demo_ndarray():
|
def demo_two_variants(src, out_dir):
|
||||||
"""③ 直接处理 ndarray (可嵌入你自己的流水线)"""
|
"""Example 3: the two minimum variants (true-black on / off)."""
|
||||||
print("=== 示例3: ndarray 处理 ===")
|
print("=== Example 3: minimum variants ===")
|
||||||
img = load_image(DEMO_SRC)
|
for flag, name in ((True, "true-black"), (False, "no-true-black")):
|
||||||
print(f" 输入尺寸: {img.shape[1]}x{img.shape[0]}")
|
cfg = LineArtConfig(method="minimum", min_true_black=flag)
|
||||||
|
extract_lineart_file(src, os.path.join(out_dir, f"minimum_{name}.png"), cfg)
|
||||||
|
print(f" wrote minimum_{name}.png")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_ndarray(src, out_dir):
|
||||||
|
"""Example 4: work directly on an ndarray (embeddable in your pipeline)."""
|
||||||
|
print("=== Example 4: ndarray processing ===")
|
||||||
|
img = load_image(src)
|
||||||
|
print(f" input size: {img.shape[1]}x{img.shape[0]}")
|
||||||
lineart = extract_lineart(img, LineArtConfig(line_width=2))
|
lineart = extract_lineart(img, LineArtConfig(line_width=2))
|
||||||
black_ratio = (lineart < 128).mean() * 100
|
black_ratio = (lineart < 128).mean() * 100
|
||||||
print(f" 黑占比: {black_ratio:.2f}%")
|
print(f" black ratio: {black_ratio:.2f}%")
|
||||||
save_image(os.path.join(DEMO_OUT_DIR, "ndarray.png"), lineart)
|
save_image(os.path.join(out_dir, "ndarray.png"), lineart)
|
||||||
print(" 已生成 ndarray.png")
|
print(" wrote ndarray.png")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) != 3:
|
||||||
|
print(__doc__.strip())
|
||||||
|
print("\nError: expected exactly 2 arguments "
|
||||||
|
"(<input_image> <output_dir>).")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
src, out_dir = argv[1], argv[2]
|
||||||
|
if not os.path.exists(src):
|
||||||
|
print(f"Error: input image not found: {src}")
|
||||||
|
return 2
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
demo_simple(src, out_dir)
|
||||||
|
demo_config(src, out_dir)
|
||||||
|
demo_two_variants(src, out_dir)
|
||||||
|
demo_ndarray(src, out_dir)
|
||||||
|
print("\nAll examples finished.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if not os.path.exists(DEMO_SRC):
|
raise SystemExit(main(sys.argv))
|
||||||
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全部示例完成 ✔")
|
|
||||||
|
|||||||
+115
-36
@@ -1,32 +1,52 @@
|
|||||||
"""
|
"""
|
||||||
lineartization
|
lineartization
|
||||||
=================
|
==============
|
||||||
把彩色插图 / 手抄报 一键转换为黑白线稿。
|
Convert a color illustration / poster into black-and-white line art in one call.
|
||||||
|
|
||||||
两种模式
|
Two modes
|
||||||
--------
|
---------
|
||||||
- ``method="skeleton"`` (默认):**骨架化**。适合"原图线条清晰"的图片
|
- ``method="skeleton"`` (default): **skeletonization**. Best for sources whose
|
||||||
(矢量插画、清晰手抄报),线条细而均匀、更美观。
|
lines are already clean and separate (vector illustrations, clean posters).
|
||||||
- ``method="minimum"`` :**最小值滤波**。适合"手写 / 手机拍 / 像素不足"的图,
|
Produces thin, smooth, uniform strokes.
|
||||||
保留原笔触、不断线,属"基本可用"级别。
|
- ``method="minimum"``: **minimum filter**. Best for handwritten, photographed
|
||||||
|
or low-resolution sources. Keeps the original strokes without breaking them.
|
||||||
|
|
||||||
|
``minimum`` has two variants, chosen with ``min_true_black``:
|
||||||
|
|
||||||
|
* ``min_true_black=True`` (default) -- the *true-black* criterion is applied,
|
||||||
|
so only dark, low-chroma pixels count as ink and colored regions are
|
||||||
|
excluded. Best when the image really is black line work on light paper.
|
||||||
|
* ``min_true_black=False`` -- no true-black criterion; the minimum filter is
|
||||||
|
applied to every pixel and Otsu decides. Colored regions are kept as
|
||||||
|
strokes, then the result is thinned with a distance transform. Best for
|
||||||
|
colorful posters, where the true-black gate would drop most strokes.
|
||||||
|
|
||||||
|
Resolution note
|
||||||
|
---------------
|
||||||
|
Extraction quality depends directly on the input resolution: the higher the
|
||||||
|
resolution, the cleaner and more complete the result. For low-resolution
|
||||||
|
sources prefer ``method="minimum"``.
|
||||||
|
|
||||||
Quick start
|
Quick start
|
||||||
-----------
|
-----------
|
||||||
>>> from lineartization import extract_lineart_file
|
>>> from lineartization import extract_lineart_file
|
||||||
>>> extract_lineart_file("手抄报.jpg", "线稿.png") # 骨架化
|
>>> extract_lineart_file("poster.jpg", "lineart.png") # skeleton
|
||||||
>>> extract_lineart_file("手写.jpg", "线稿.png", method="minimum") # 最小值滤波
|
>>> extract_lineart_file("hand.jpg", "lineart.png", method="minimum")
|
||||||
|
>>> extract_lineart_file("color.jpg", "lineart.png",
|
||||||
|
... method="minimum", min_true_black=False)
|
||||||
|
|
||||||
Python API:
|
Python API:
|
||||||
|
|
||||||
>>> import cv2
|
>>> import cv2
|
||||||
>>> from lineartization import extract_lineart, LineArtConfig
|
>>> from lineartization import extract_lineart, LineArtConfig
|
||||||
>>> img = cv2.imread("手抄报.jpg")
|
>>> img = cv2.imread("poster.jpg")
|
||||||
>>> lineart = extract_lineart(img, LineArtConfig(method="skeleton"))
|
>>> lineart = extract_lineart(img, LineArtConfig(method="minimum"))
|
||||||
|
|
||||||
CLI
|
CLI
|
||||||
---
|
---
|
||||||
$ lineartization input.jpg output.png
|
$ lineartization input.jpg output.png
|
||||||
$ lineartization input.jpg output.png --method minimum --verbose
|
$ lineartization input.jpg output.png --method minimum
|
||||||
|
$ lineartization input.jpg output.png --method minimum --no-true-black
|
||||||
"""
|
"""
|
||||||
from .core import (
|
from .core import (
|
||||||
LineArtConfig,
|
LineArtConfig,
|
||||||
@@ -36,7 +56,7 @@ from .core import (
|
|||||||
save_image,
|
save_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.6.1"
|
__version__ = "1.7.0"
|
||||||
__author__ = "DVS"
|
__author__ = "DVS"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LineArtConfig",
|
"LineArtConfig",
|
||||||
@@ -47,40 +67,88 @@ __all__ = [
|
|||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
RESOLUTION_HINT = (
|
||||||
|
"Hint: extraction quality depends on the source resolution -- the higher "
|
||||||
|
"the resolution, the cleaner the result. For low-resolution inputs prefer "
|
||||||
|
"--method minimum."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
"""命令行入口。"""
|
"""Command-line entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
from .core import LineArtConfig, extract_lineart_file
|
from .core import LineArtConfig, extract_lineart_file
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="lineartization",
|
prog="lineartization",
|
||||||
description="彩色插图/手抄报 -> 黑白线稿 (支持 骨架化 / 最小值滤波 两种模式)",
|
description="Color illustration / poster -> black-and-white line art "
|
||||||
|
"(skeletonization or minimum-filter mode).",
|
||||||
|
epilog=RESOLUTION_HINT,
|
||||||
)
|
)
|
||||||
parser.add_argument("input", help="输入图片路径")
|
parser.add_argument("input", help="input image path")
|
||||||
parser.add_argument("output", help="输出线稿路径 (.png)")
|
parser.add_argument("output", help="output line-art path (.png)")
|
||||||
parser.add_argument("-m", "--method", choices=["skeleton", "minimum"],
|
parser.add_argument("-m", "--method", choices=["skeleton", "minimum"],
|
||||||
default="skeleton",
|
default="skeleton",
|
||||||
help="提取模式: skeleton=骨架化(清晰原图) / "
|
help="extraction mode: skeleton=skeletonization (clean "
|
||||||
"minimum=最小值滤波(手写图)")
|
"sources, default) / minimum=minimum filter "
|
||||||
|
"(handwritten, photographed, low-resolution)")
|
||||||
parser.add_argument("-w", "--width", type=int, default=2,
|
parser.add_argument("-w", "--width", type=int, default=2,
|
||||||
help="线宽 px (仅 skeleton 模式, 默认2)")
|
help="stroke width in px (skeleton mode only, default 2)")
|
||||||
parser.add_argument("--min-mean", type=int, default=130,
|
|
||||||
help="minimum 模式: 真黑判据 RGB 均值上限 (默认130)")
|
# ---- minimum mode: true-black variant (default) ----
|
||||||
parser.add_argument("--min-chroma", type=int, default=45,
|
parser.add_argument("--no-true-black", action="store_true",
|
||||||
help="minimum 模式: 真黑判据 色度上限 (默认45)")
|
help="minimum mode: disable the true-black criterion. "
|
||||||
|
"Colored regions are then kept as strokes and the "
|
||||||
|
"result is thinned with a distance transform. "
|
||||||
|
"Worth trying on colorful posters.")
|
||||||
|
parser.add_argument("--min-mean", type=int, default=180,
|
||||||
|
help="minimum mode (true-black): RGB mean upper bound "
|
||||||
|
"(default 180)")
|
||||||
|
parser.add_argument("--min-chroma", type=int, default=60,
|
||||||
|
help="minimum mode (true-black): chroma upper bound "
|
||||||
|
"(default 60)")
|
||||||
|
parser.add_argument("--min-ratio", type=float, default=1.5,
|
||||||
|
help="minimum mode (true-black): if the Otsu ink ratio "
|
||||||
|
"drops below this %%, fall back to adaptive "
|
||||||
|
"thresholding (default 1.5)")
|
||||||
parser.add_argument("--min-kernel", type=int, default=2,
|
parser.add_argument("--min-kernel", type=int, default=2,
|
||||||
help="minimum 模式: 最小值滤波半径 (默认2)")
|
help="minimum mode: minimum-filter radius "
|
||||||
parser.add_argument("-d", "--denoise", choices=["strong", "normal", "light", "none"],
|
"(1-3, default 2; larger = thicker)")
|
||||||
|
parser.add_argument("-d", "--denoise",
|
||||||
|
choices=["strong", "normal", "light", "none"],
|
||||||
default="strong",
|
default="strong",
|
||||||
help="minimum 模式降噪档位: strong(默认,普通强降噪)/normal/light/none")
|
help="minimum mode (true-black) denoise level: "
|
||||||
|
"strong (default) / normal / light / none")
|
||||||
parser.add_argument("--denoise-area", type=int, default=30,
|
parser.add_argument("--denoise-area", type=int, default=30,
|
||||||
help="minimum 模式: 连通域过滤阈值 (默认30)")
|
help="minimum mode (true-black): connected-component "
|
||||||
parser.add_argument("--no-green-smoothing", action="store_true",
|
"removal threshold (default 30)")
|
||||||
help="禁用'绿块局部抹平'(非手抄报场景可关闭)")
|
|
||||||
|
# ---- minimum mode: no-true-black variant ----
|
||||||
|
parser.add_argument("--m2-noise-area", type=int, default=20,
|
||||||
|
help="minimum mode (--no-true-black): drop specks "
|
||||||
|
"smaller than this (px, default 20)")
|
||||||
|
parser.add_argument("--m2-short-area", type=int, default=40,
|
||||||
|
help="minimum mode (--no-true-black): drop fragments "
|
||||||
|
"smaller than this (px^2, default 40)")
|
||||||
|
parser.add_argument("--m2-short-len", type=int, default=25,
|
||||||
|
help="minimum mode (--no-true-black): ...and shorter "
|
||||||
|
"than this (px, default 25)")
|
||||||
|
parser.add_argument("--m2-close-k", type=int, default=2,
|
||||||
|
help="minimum mode (--no-true-black): MORPH_CLOSE "
|
||||||
|
"kernel before thinning (default 2)")
|
||||||
|
parser.add_argument("--m2-dist-min", type=float, default=0.5,
|
||||||
|
help="minimum mode (--no-true-black): keep pixels with "
|
||||||
|
"distance >= this (default 0.5 -> ~2 px lines)")
|
||||||
|
|
||||||
|
# ---- shared ----
|
||||||
|
parser.add_argument("--no-color-smoothing", action="store_true",
|
||||||
|
help="skeleton mode: disable smoothing of large flat "
|
||||||
|
"color regions")
|
||||||
parser.add_argument("--protect", action="append", default=[],
|
parser.add_argument("--protect", action="append", default=[],
|
||||||
metavar="x1,x2,y1,y2", help="保护区域(可多次)")
|
metavar="x1,x2,y1,y2",
|
||||||
parser.add_argument("-v", "--verbose", action="store_true", help="打印日志")
|
help="protected rectangle, repeatable")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true",
|
||||||
|
help="print pipeline logs")
|
||||||
parser.add_argument("-V", "--version", action="version",
|
parser.add_argument("-V", "--version", action="version",
|
||||||
version=f"lineartization {__version__}")
|
version=f"lineartization {__version__}")
|
||||||
|
|
||||||
@@ -90,23 +158,34 @@ def main(argv=None):
|
|||||||
for spec in args.protect:
|
for spec in args.protect:
|
||||||
parts = [int(v) for v in spec.replace(" ", "").split(",")]
|
parts = [int(v) for v in spec.replace(" ", "").split(",")]
|
||||||
if len(parts) != 4:
|
if len(parts) != 4:
|
||||||
parser.error(f"--protect 格式错误: {spec} (应为 x1,x2,y1,y2)")
|
parser.error(f"bad --protect value: {spec} (expected x1,x2,y1,y2)")
|
||||||
protect_areas.append(tuple(parts))
|
protect_areas.append(tuple(parts))
|
||||||
|
|
||||||
cfg = LineArtConfig(
|
cfg = LineArtConfig(
|
||||||
method=args.method,
|
method=args.method,
|
||||||
line_width=max(1, args.width),
|
line_width=max(1, args.width),
|
||||||
|
min_true_black=not args.no_true_black,
|
||||||
min_mean=args.min_mean,
|
min_mean=args.min_mean,
|
||||||
min_chroma=args.min_chroma,
|
min_chroma=args.min_chroma,
|
||||||
|
min_ratio=args.min_ratio,
|
||||||
min_kernel=args.min_kernel,
|
min_kernel=args.min_kernel,
|
||||||
denoise=args.denoise,
|
denoise=args.denoise,
|
||||||
denoise_area=args.denoise_area,
|
denoise_area=args.denoise_area,
|
||||||
enable_green_smoothing=not args.no_green_smoothing,
|
m2_noise_area=args.m2_noise_area,
|
||||||
|
m2_short_area=args.m2_short_area,
|
||||||
|
m2_short_len=args.m2_short_len,
|
||||||
|
m2_close_k=args.m2_close_k,
|
||||||
|
m2_dist_min=args.m2_dist_min,
|
||||||
|
enable_color_smoothing=not args.no_color_smoothing,
|
||||||
protect_areas=protect_areas,
|
protect_areas=protect_areas,
|
||||||
)
|
)
|
||||||
|
|
||||||
out = extract_lineart_file(args.input, args.output, cfg, verbose=args.verbose)
|
out = extract_lineart_file(args.input, args.output, cfg, verbose=args.verbose)
|
||||||
print(f"✅ 线稿已生成 [{args.method}]: {out}")
|
variant = ""
|
||||||
|
if args.method == "minimum":
|
||||||
|
variant = " true-black" if cfg.min_true_black else " no-true-black"
|
||||||
|
print(f"line art written [{args.method}{variant}]: {out}")
|
||||||
|
print(RESOLUTION_HINT)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""支持 `python -m lineartization` 调用。"""
|
"""Enable `python -m lineartization`."""
|
||||||
from . import main
|
from . import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+218
-88
@@ -1,23 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
lineartization.core
|
lineartization.core
|
||||||
======================
|
======================
|
||||||
彩色插图 / 手抄报 -> 黑白线稿 的核心算法。
|
Core algorithms that convert a color illustration / poster into
|
||||||
|
black-and-white line art.
|
||||||
|
|
||||||
支持两种提取模式(``LineArtConfig.method``):
|
Two extraction modes are provided (``LineArtConfig.method``):
|
||||||
|
|
||||||
1. ``"skeleton"`` —— **骨架化模式**(默认)
|
1. ``"skeleton"`` -- skeletonization mode (default)
|
||||||
适用于"原图本身线条就清晰"的图片(矢量插画、清晰手抄报的放大版)。
|
For images whose lines are already clean and well separated
|
||||||
流程: 区域分析 → 图案/文字提取 → Lee 骨架化 → 去噪/剪倒刺 → 统一线宽
|
(vector illustrations, clean posters, high-resolution scans).
|
||||||
特点: 线条细而均匀、美观;但骨架化对"手写粗笔触"会产生分叉/网状。
|
Pipeline: region analysis -> pattern/text extraction -> Lee
|
||||||
|
skeletonization -> denoise / spur pruning -> uniform stroke width.
|
||||||
|
Result: thin, even, aesthetically pleasing lines. Note that
|
||||||
|
skeletonizing thick handwriting produces branching/webbing.
|
||||||
|
|
||||||
2. ``"minimum"`` —— **最小值滤波模式**
|
2. ``"minimum"`` -- minimum-filter mode
|
||||||
适用于"手写 / 像素不足 / 扫描件"类图片(手机拍的手抄报)。
|
For handwritten, low-resolution or scanned sources.
|
||||||
流程: RGB 真黑判据 → 最小值滤波(PS 经典提线) → Otsu 纯黑白 → 降噪
|
Pipeline: RGB true-black criterion -> minimum filter (the classic
|
||||||
降噪强度由 ``denoise`` 参数控制:
|
Photoshop line-extraction recipe) -> Otsu -> denoise.
|
||||||
- ``"strong"`` (默认): 中值 → 开运算 → 连通域过滤(<30px) → 收尾中值 ← 普通强降噪
|
Denoise strength is controlled by ``denoise``:
|
||||||
- ``"normal"`` : 中值 → 连通域过滤(<20px) → 收尾中值
|
- ``"strong"`` (default): median -> open -> CC filter (<30px) -> median
|
||||||
- ``"light"`` : 中值 → 只删"极小且方正"噪点 → 收尾中值
|
- ``"normal"`` : median -> CC filter (<20px) -> median
|
||||||
- ``"none"`` : 仅中值滤波
|
- ``"light"`` : median -> drop only tiny square specks -> median
|
||||||
|
- ``"none"`` : median only
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -36,28 +41,28 @@ except ImportError: # pragma: no cover
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 配置
|
# Configuration
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@dataclass
|
@dataclass
|
||||||
class LineArtConfig:
|
class LineArtConfig:
|
||||||
"""提取线稿的参数配置。"""
|
"""Parameter set for line-art extraction."""
|
||||||
|
|
||||||
# ---- 模式 ----
|
# ---- mode ----
|
||||||
method: str = "skeleton" # "skeleton" | "minimum"
|
method: str = "skeleton" # "skeleton" | "minimum"
|
||||||
|
|
||||||
# ---- 通用: 纸面区(文字背景) ----
|
# ---- shared: paper region (background of the text area) ----
|
||||||
paper_v: int = 140
|
paper_v: int = 140
|
||||||
paper_s: int = 60
|
paper_s: int = 60
|
||||||
paper_erode: int = 31
|
paper_erode: int = 31
|
||||||
|
|
||||||
# ---- 通用: 文字区(精确矩形) ----
|
# ---- shared: text region (exact rectangle) ----
|
||||||
ink_v: int = 140
|
ink_v: int = 140
|
||||||
ink_s: int = 60
|
ink_s: int = 60
|
||||||
density_close: int = 41
|
density_close: int = 41
|
||||||
density_open: int = 61
|
density_open: int = 61
|
||||||
text_pad: int = 40
|
text_pad: int = 40
|
||||||
|
|
||||||
# ---- skeleton 模式参数 ----
|
# ---- skeleton mode ----
|
||||||
dark_v: int = 160
|
dark_v: int = 160
|
||||||
morph_open_k: int = 13
|
morph_open_k: int = 13
|
||||||
adaptive_bs: int = 25
|
adaptive_bs: int = 25
|
||||||
@@ -67,36 +72,55 @@ class LineArtConfig:
|
|||||||
noise_area: int = 300
|
noise_area: int = 300
|
||||||
spur_maxlen: int = 25
|
spur_maxlen: int = 25
|
||||||
|
|
||||||
# ---- minimum 模式参数 ----
|
# ---- minimum mode ----
|
||||||
# 真黑判据: RGB 均值 < min_mean 且 色度(最大-最小通道) < min_chroma
|
# True-black test: mean(RGB) < min_mean AND chroma < min_chroma
|
||||||
min_mean: int = 130
|
min_mean: int = 180
|
||||||
min_chroma: int = 45
|
min_chroma: int = 60
|
||||||
min_kernel: int = 2 # 最小值滤波半径(1-3)
|
min_kernel: int = 2 # minimum-filter radius (1-3)
|
||||||
min_otsu: bool = True
|
min_otsu: bool = True
|
||||||
# 降噪档位: "strong"(默认,普通强降噪) / "normal" / "light" / "none"
|
min_ratio: float = 1.5 # fallback: if the Otsu ink ratio drops below this, retry with adaptive
|
||||||
|
# Denoise level: "strong" (default) / "normal" / "light" / "none"
|
||||||
denoise: str = "strong"
|
denoise: str = "strong"
|
||||||
denoise_area: int = 30 # strong/normal 模式: 连通域过滤阈值(<该值删除)
|
denoise_area: int = 30 # CC removal threshold < this value is deleted (strong/normal)
|
||||||
|
|
||||||
# ---- 输出 ----
|
# ---- minimum mode: enable the true-black criterion ----
|
||||||
|
# True = classic behaviour: intersect the Otsu result with the
|
||||||
|
# true-black mask, keeping only dark, low-chroma pixels.
|
||||||
|
# Denoise is controlled by the ``denoise`` level.
|
||||||
|
# False = alternative: skip the true-black mask and use the Otsu
|
||||||
|
# result directly (colored regions are kept as strokes).
|
||||||
|
# Cleanup becomes despeckle + short-fragment removal,
|
||||||
|
# followed by distance-transform thinning.
|
||||||
|
min_true_black: bool = True
|
||||||
|
|
||||||
|
# ---- minimum mode: used when min_true_black=False ----
|
||||||
|
m2_noise_area: int = 20 # despeckle: drop components smaller than this (px)
|
||||||
|
m2_short_area: int = 40 # drop fragments: area < this AND length < m2_short_len
|
||||||
|
m2_short_len: int = 25 # drop fragments: length < this (px)
|
||||||
|
m2_close_k: int = 2 # MORPH_CLOSE kernel applied before thinning (bridges 1px gaps)
|
||||||
|
m2_dist_min: float = 0.5 # distance threshold: keep pixels with dist >= this value
|
||||||
|
|
||||||
|
# ---- output ----
|
||||||
line_width: int = 2
|
line_width: int = 2
|
||||||
|
|
||||||
# ---- 保护区域 (x1, x2, y1, y2) ----
|
# ---- protected regions (x1, x2, y1, y2) ----
|
||||||
protect_areas: List[Tuple[int, int, int, int]] = field(default_factory=list)
|
protect_areas: List[Tuple[int, int, int, int]] = field(default_factory=list)
|
||||||
|
|
||||||
# ---- 绿块局部抹平(手抄报山体) ----
|
# ---- large flat color regions: local smoothing ----
|
||||||
enable_green_smoothing: bool = True
|
# Broad saturated fills are re-extracted from a mean-shift smoothed copy
|
||||||
green_hue_range: Tuple[int, int] = (35, 85)
|
# so gradients/banding inside them do not produce false edges.
|
||||||
green_sat_min: int = 60
|
enable_color_smoothing: bool = True
|
||||||
green_area_range: Tuple[int, int] = (3000, 25000)
|
color_sat_min: int = 60
|
||||||
|
color_area_range: Tuple[int, int] = (3000, 25000)
|
||||||
meanshift_sp: int = 30
|
meanshift_sp: int = 30
|
||||||
meanshift_sr: int = 60
|
meanshift_sr: int = 60
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 工具函数
|
# Helper functions
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _skel(bin01: np.ndarray) -> np.ndarray:
|
def _skel(bin01: np.ndarray) -> np.ndarray:
|
||||||
"""骨架化 (优先 Lee, 退化到 Zhang-Suen)。输入/输出均为 0/1。"""
|
"""Skeletonize a 0/1 mask (Lee first, Zhang-Suen as fallback). Input/output are 0/1."""
|
||||||
b = (bin01 > 0).astype(np.uint8)
|
b = (bin01 > 0).astype(np.uint8)
|
||||||
if _HAS_SKIMAGE:
|
if _HAS_SKIMAGE:
|
||||||
return _skel_lee(b > 0).astype(np.uint8)
|
return _skel_lee(b > 0).astype(np.uint8)
|
||||||
@@ -108,7 +132,7 @@ def _skel(bin01: np.ndarray) -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def _to_width(mask01: np.ndarray, width: int) -> np.ndarray:
|
def _to_width(mask01: np.ndarray, width: int) -> np.ndarray:
|
||||||
"""把 0/1 骨架增粗到目标宽度。"""
|
"""Thicken a 0/1 skeleton to the requested stroke width."""
|
||||||
m = (mask01 > 0).astype(np.uint8)
|
m = (mask01 > 0).astype(np.uint8)
|
||||||
if width <= 1:
|
if width <= 1:
|
||||||
return m
|
return m
|
||||||
@@ -117,13 +141,13 @@ def _to_width(mask01: np.ndarray, width: int) -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def load_image(path: str) -> np.ndarray:
|
def load_image(path: str) -> np.ndarray:
|
||||||
"""读取图片 (兼容中文路径 / RGBA / 灰度)。返回 BGR uint8。"""
|
"""Read an image (Unicode-path safe / RGBA / grayscale). Returns BGR uint8."""
|
||||||
data = np.fromfile(path, dtype=np.uint8)
|
data = np.fromfile(path, dtype=np.uint8)
|
||||||
im = cv2.imdecode(data, cv2.IMREAD_UNCHANGED)
|
im = cv2.imdecode(data, cv2.IMREAD_UNCHANGED)
|
||||||
if im is None:
|
if im is None:
|
||||||
im = cv2.imread(path, cv2.IMREAD_UNCHANGED)
|
im = cv2.imread(path, cv2.IMREAD_UNCHANGED)
|
||||||
if im is None:
|
if im is None:
|
||||||
raise FileNotFoundError(f"无法读取图片: {path}")
|
raise FileNotFoundError(f"cannot read image: {path}")
|
||||||
if im.ndim == 3 and im.shape[2] == 4:
|
if im.ndim == 3 and im.shape[2] == 4:
|
||||||
bgr = im[:, :, :3].astype(np.float32)
|
bgr = im[:, :, :3].astype(np.float32)
|
||||||
a = im[:, :, 3:4].astype(np.float32) / 255.0
|
a = im[:, :, 3:4].astype(np.float32) / 255.0
|
||||||
@@ -136,19 +160,19 @@ def load_image(path: str) -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def save_image(path: str, img: np.ndarray) -> None:
|
def save_image(path: str, img: np.ndarray) -> None:
|
||||||
"""保存图片 (兼容中文路径)。"""
|
"""Write an image (Unicode-path safe)."""
|
||||||
ext = os.path.splitext(path)[1] or ".png"
|
ext = os.path.splitext(path)[1] or ".png"
|
||||||
ok, buf = cv2.imencode(ext, img)
|
ok, buf = cv2.imencode(ext, img)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise IOError(f"编码失败: {path}")
|
raise IOError(f"failed to encode: {path}")
|
||||||
buf.tofile(path)
|
buf.tofile(path)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# minimum 模式
|
# minimum mode
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
"""真黑/深灰判据: RGB 三通道都低、且互相接近(色度小)。"""
|
"""True-black / dark-grey test: all three channels low and close to each other."""
|
||||||
b = bgr[:, :, 0].astype(np.int32)
|
b = bgr[:, :, 0].astype(np.int32)
|
||||||
g = bgr[:, :, 1].astype(np.int32)
|
g = bgr[:, :, 1].astype(np.int32)
|
||||||
r = bgr[:, :, 2].astype(np.int32)
|
r = bgr[:, :, 2].astype(np.int32)
|
||||||
@@ -160,12 +184,12 @@ def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
|||||||
|
|
||||||
|
|
||||||
def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
"""minimum 模式降噪 (可调档位)。
|
"""Denoise for minimum mode (adjustable level).
|
||||||
|
|
||||||
strong (默认): 中值 → 开运算 → 连通域过滤 → 收尾中值 ← "普通强降噪"
|
strong (default): median -> open -> CC filter -> final median
|
||||||
normal : 中值 → 连通域过滤 → 收尾中值
|
normal : median -> CC filter -> final median
|
||||||
light : 中值 → 只删"极小且方正"噪点 → 收尾中值
|
light : median -> drop only "tiny and square" specks -> final median
|
||||||
none : 仅中值
|
none : median only
|
||||||
"""
|
"""
|
||||||
lvl = (cfg.denoise or "strong").lower()
|
lvl = (cfg.denoise or "strong").lower()
|
||||||
m = (mask_bool.astype(np.uint8)) * 255
|
m = (mask_bool.astype(np.uint8)) * 255
|
||||||
@@ -173,15 +197,21 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
|||||||
if lvl == "none":
|
if lvl == "none":
|
||||||
return cv2.medianBlur(m, 3) > 128
|
return cv2.medianBlur(m, 3) > 128
|
||||||
|
|
||||||
# ① 中值滤波
|
# (1) median filter
|
||||||
m = cv2.medianBlur(m, 3)
|
m = cv2.medianBlur(m, 3)
|
||||||
|
|
||||||
# ② strong: 开运算(去毛刺)
|
# (2) strong: opening removes burrs
|
||||||
|
# NOTE: m here is "stroke = 255 (white)". Running opening (erode first)
|
||||||
|
# directly on white strokes erases 1-2px lines entirely, leaving all white.
|
||||||
|
# Correct approach: invert to "stroke = black", open away the small
|
||||||
|
# isolated specks, then invert back.
|
||||||
if lvl == "strong":
|
if lvl == "strong":
|
||||||
k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
|
k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
|
||||||
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k2)
|
inv = 255 - m # stroke=255,bg=0 -> stroke=0,bg=255
|
||||||
|
inv = cv2.morphologyEx(inv, cv2.MORPH_OPEN, k2) # drop isolated specks
|
||||||
|
m = 255 - inv
|
||||||
|
|
||||||
# ③ 连通域过滤
|
# (3) connected-component filtering
|
||||||
if lvl in ("strong", "normal"):
|
if lvl in ("strong", "normal"):
|
||||||
minA = cfg.denoise_area
|
minA = cfg.denoise_area
|
||||||
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
||||||
@@ -190,7 +220,7 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
|||||||
if st[i, cv2.CC_STAT_AREA] >= minA:
|
if st[i, cv2.CC_STAT_AREA] >= minA:
|
||||||
keep[lab == i] = 255
|
keep[lab == i] = 255
|
||||||
m = keep
|
m = keep
|
||||||
else: # light: 只删"极小且方正"噪点
|
else: # light: only drop "tiny and square" specks
|
||||||
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
||||||
keep = np.zeros_like(m)
|
keep = np.zeros_like(m)
|
||||||
for i in range(1, n):
|
for i in range(1, n):
|
||||||
@@ -202,13 +232,22 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
|||||||
keep[lab == i] = 255
|
keep[lab == i] = 255
|
||||||
m = keep
|
m = keep
|
||||||
|
|
||||||
# ④ 收尾中值
|
# (4) final median pass
|
||||||
m = cv2.medianBlur(m, 3)
|
m = cv2.medianBlur(m, 3)
|
||||||
return m > 128
|
result = m > 128
|
||||||
|
|
||||||
|
# (5) Safety net: if denoising removed more than half of the strokes
|
||||||
|
# (i.e. real lines were deleted by mistake), fall back to the
|
||||||
|
# pre-denoise result so the output never goes blank.
|
||||||
|
before_ratio = mask_bool.mean() * 100
|
||||||
|
after_ratio = result.mean() * 100
|
||||||
|
if after_ratio < before_ratio * 0.5 and before_ratio > 0.5:
|
||||||
|
return mask_bool.astype(np.uint8) if mask_bool.dtype != bool else mask_bool
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
"""最小值滤波提线 (PS 经典流程) + 真黑判据 + 可调降噪。"""
|
"""Minimum-filter line extraction (classic Photoshop recipe) + true-black + denoise."""
|
||||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||||
black_zone = _true_black_mask(bgr, cfg)
|
black_zone = _true_black_mask(bgr, cfg)
|
||||||
|
|
||||||
@@ -226,12 +265,93 @@ def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
|||||||
_, line = cv2.threshold(result, 128, 255, cv2.THRESH_BINARY)
|
_, line = cv2.threshold(result, 128, 255, cv2.THRESH_BINARY)
|
||||||
|
|
||||||
mask = (line < 128) & black_zone
|
mask = (line < 128) & black_zone
|
||||||
|
|
||||||
|
# Fallback: if the Otsu result is too sparse (too few strokes, typically
|
||||||
|
# because a white background with very thin lines defeats Otsu), re-extract
|
||||||
|
# with an adaptive threshold and keep whichever version has more strokes.
|
||||||
|
ratio = mask.mean() * 100
|
||||||
|
if ratio < cfg.min_ratio:
|
||||||
|
gray_u8 = np.clip(gray, 0, 255).astype(np.uint8)
|
||||||
|
at = cv2.adaptiveThreshold(gray_u8, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
|
||||||
|
cv2.THRESH_BINARY_INV, 25, 10)
|
||||||
|
fallback = (at > 0) & black_zone
|
||||||
|
if fallback.mean() * 100 > ratio:
|
||||||
|
mask = fallback
|
||||||
|
|
||||||
mask = _denoise_minimum(mask, cfg)
|
mask = _denoise_minimum(mask, cfg)
|
||||||
return mask.astype(np.uint8)
|
return mask.astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimum_filter_lineart_nb(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
|
"""minimum mode (min_true_black=False): no true-black mask + distance-transform thinning.
|
||||||
|
|
||||||
|
Differences from the classic minimum mode:
|
||||||
|
1. no true-black mask; the Otsu result is used directly, so colored
|
||||||
|
regions are kept as strokes
|
||||||
|
2. cleanup becomes: despeckle (< m2_noise_area) -> drop short fragments
|
||||||
|
(area < m2_short_area AND length < m2_short_len)
|
||||||
|
3. morphological close (bridges 1px gaps) -> distance-transform thinning,
|
||||||
|
keeping only pixels with dist >= m2_dist_min
|
||||||
|
|
||||||
|
Distance transform instead of skeletonization: skeletonization collapses
|
||||||
|
strokes to a 1px medial axis, which loses glyph detail and branches at
|
||||||
|
thick stroke crossings. A distance transform only shaves from the outside,
|
||||||
|
preserving stroke topology and glyph shape, so the lines stay thin and even.
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||||
|
|
||||||
|
# ---- minimum filter (same as the classic mode) ----
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ---- NOTE: deliberately NOT intersected with the true-black mask ----
|
||||||
|
mask = (line < 128).astype(np.uint8)
|
||||||
|
|
||||||
|
# ---- despeckle (< m2_noise_area) ----
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8)
|
||||||
|
keep = np.zeros_like(mask)
|
||||||
|
for i in range(1, n):
|
||||||
|
if st[i, cv2.CC_STAT_AREA] >= cfg.m2_noise_area:
|
||||||
|
keep[lab == i] = 1
|
||||||
|
mask = keep
|
||||||
|
|
||||||
|
# ---- drop short fragments (area < m2_short_area AND length < m2_short_len) ----
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8)
|
||||||
|
keep = np.zeros_like(mask)
|
||||||
|
for i in range(1, n):
|
||||||
|
x, y, w, h, a = st[i]
|
||||||
|
if a >= cfg.m2_short_area or max(w, h) >= cfg.m2_short_len:
|
||||||
|
keep[lab == i] = 1
|
||||||
|
mask = keep
|
||||||
|
|
||||||
|
# ---- morphological close + distance-transform thinning ----
|
||||||
|
if mask.any():
|
||||||
|
closed = cv2.morphologyEx(
|
||||||
|
mask * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
||||||
|
(max(1, cfg.m2_close_k),) * 2))
|
||||||
|
dist = cv2.distanceTransform((closed > 0).astype(np.uint8),
|
||||||
|
cv2.DIST_L2, 5)
|
||||||
|
thinned = (dist >= cfg.m2_dist_min).astype(np.uint8)
|
||||||
|
if not thinned.any(): # safety net: never thin every stroke away
|
||||||
|
thinned = mask
|
||||||
|
mask = thinned
|
||||||
|
|
||||||
|
return mask.astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# skeleton 模式
|
# skeleton mode
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _paper_mask(hsv, cfg):
|
def _paper_mask(hsv, cfg):
|
||||||
s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32)
|
s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32)
|
||||||
@@ -262,16 +382,21 @@ def _text_rect(bgr, cfg):
|
|||||||
return tz
|
return tz
|
||||||
|
|
||||||
|
|
||||||
def _green_zones(bgr, cfg):
|
def _color_zones(bgr, cfg):
|
||||||
h, w = bgr.shape[:2]
|
"""Locate large saturated color regions (region-agnostic).
|
||||||
|
|
||||||
|
Instead of keying on one specific hue (which only matched the green hills
|
||||||
|
of one particular poster), this selects *any* strongly saturated area of a
|
||||||
|
plausible size. The result decides where lines are re-extracted from a
|
||||||
|
mean-shift smoothed copy, so broad flat color fills do not bloom into
|
||||||
|
false edges.
|
||||||
|
"""
|
||||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||||
hue = hsv[:, :, 0].astype(np.int32); sat = hsv[:, :, 1].astype(np.int32)
|
sat = hsv[:, :, 1].astype(np.int32)
|
||||||
lo, hi = cfg.green_hue_range
|
solid_color = (sat > cfg.color_sat_min)
|
||||||
green = (hue > lo) & (hue < hi) & (sat > cfg.green_sat_min)
|
n, lab, st, _ = cv2.connectedComponentsWithStats(solid_color.astype(np.uint8), 8)
|
||||||
green[:, w // 2:] = False
|
amin, amax = cfg.color_area_range
|
||||||
n, lab, st, _ = cv2.connectedComponentsWithStats(green.astype(np.uint8), 8)
|
zones = np.zeros_like(solid_color)
|
||||||
amin, amax = cfg.green_area_range
|
|
||||||
zones = np.zeros_like(green)
|
|
||||||
for i in range(1, n):
|
for i in range(1, n):
|
||||||
if amin <= st[i, cv2.CC_STAT_AREA] <= amax:
|
if amin <= st[i, cv2.CC_STAT_AREA] <= amax:
|
||||||
zones[lab == i] = 1
|
zones[lab == i] = 1
|
||||||
@@ -366,13 +491,13 @@ def _extract_skeleton(bgr, cfg):
|
|||||||
for (x1, x2, y1, y2) in cfg.protect_areas: protect[y1:y2, x1:x2] = True
|
for (x1, x2, y1, y2) in cfg.protect_areas: protect[y1:y2, x1:x2] = True
|
||||||
|
|
||||||
zones = border = np.zeros_like(pz)
|
zones = border = np.zeros_like(pz)
|
||||||
if cfg.enable_green_smoothing:
|
if cfg.enable_color_smoothing:
|
||||||
zones, border = _green_zones(bgr, cfg)
|
zones, border = _color_zones(bgr, cfg)
|
||||||
smoothed = (cv2.pyrMeanShiftFiltering(bgr, cfg.meanshift_sp, cfg.meanshift_sr, maxLevel=2)
|
smoothed = (cv2.pyrMeanShiftFiltering(bgr, cfg.meanshift_sp, cfg.meanshift_sr, maxLevel=2)
|
||||||
if cfg.enable_green_smoothing else bgr)
|
if cfg.enable_color_smoothing else bgr)
|
||||||
|
|
||||||
lines_fine = _extract_lines(bgr, pz, border, cfg, False)
|
lines_fine = _extract_lines(bgr, pz, border, cfg, False)
|
||||||
if cfg.enable_green_smoothing:
|
if cfg.enable_color_smoothing:
|
||||||
lines_smooth = _extract_lines(smoothed, pz, border, cfg, True)
|
lines_smooth = _extract_lines(smoothed, pz, border, cfg, True)
|
||||||
lines = np.where(zones, lines_smooth, lines_fine).astype(np.uint8)
|
lines = np.where(zones, lines_smooth, lines_fine).astype(np.uint8)
|
||||||
else:
|
else:
|
||||||
@@ -411,20 +536,20 @@ def _extract_skeleton(bgr, cfg):
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 主入口
|
# Public entry points
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def extract_lineart(bgr: np.ndarray,
|
def extract_lineart(bgr: np.ndarray,
|
||||||
cfg: Optional[LineArtConfig] = None,
|
cfg: Optional[LineArtConfig] = None,
|
||||||
*, verbose: bool = False) -> np.ndarray:
|
*, verbose: bool = False) -> np.ndarray:
|
||||||
"""从 BGR 图像提取线稿。
|
"""Extract line art from a BGR image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bgr: 输入图像 (OpenCV BGR, uint8)。
|
bgr: input image (OpenCV BGR, uint8).
|
||||||
cfg: 参数配置。``method`` = "skeleton"|"minimum"。
|
cfg: parameter set. ``method`` = "skeleton" | "minimum".
|
||||||
verbose: 打印日志。
|
verbose: print pipeline logs.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
白底黑线线稿 (uint8, 0/255)。
|
White-background / black-line image (uint8, 0/255).
|
||||||
"""
|
"""
|
||||||
cfg = cfg or LineArtConfig()
|
cfg = cfg or LineArtConfig()
|
||||||
|
|
||||||
@@ -433,18 +558,23 @@ def extract_lineart(bgr: np.ndarray,
|
|||||||
|
|
||||||
method = (cfg.method or "skeleton").lower()
|
method = (cfg.method or "skeleton").lower()
|
||||||
if method not in ("skeleton", "minimum"):
|
if method not in ("skeleton", "minimum"):
|
||||||
raise ValueError(f"未知 method: {cfg.method!r}")
|
raise ValueError(f"unknown method: {cfg.method!r}")
|
||||||
|
|
||||||
if method == "minimum":
|
if method == "minimum":
|
||||||
_log(f"[lineart] method=minimum denoise={cfg.denoise}")
|
if cfg.min_true_black:
|
||||||
mask = _minimum_filter_lineart(bgr, cfg)
|
_log(f"[lineart] method=minimum true_black=True denoise={cfg.denoise}")
|
||||||
|
mask = _minimum_filter_lineart(bgr, cfg)
|
||||||
|
else:
|
||||||
|
_log(f"[lineart] method=minimum true_black=False "
|
||||||
|
f"dist_min={cfg.m2_dist_min}")
|
||||||
|
mask = _minimum_filter_lineart_nb(bgr, cfg)
|
||||||
out = np.where(mask > 0, 0, 255).astype(np.uint8)
|
out = np.where(mask > 0, 0, 255).astype(np.uint8)
|
||||||
_log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%")
|
_log(f"[lineart] done, black ratio {(out < 128).mean()*100:.2f}%")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
_log("[lineart] method=skeleton")
|
_log("[lineart] method=skeleton")
|
||||||
out = _extract_skeleton(bgr, cfg)
|
out = _extract_skeleton(bgr, cfg)
|
||||||
_log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%")
|
_log(f"[lineart] done, black ratio {(out < 128).mean()*100:.2f}%")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -452,17 +582,17 @@ def extract_lineart_file(src: str, dst: str,
|
|||||||
cfg: Optional[LineArtConfig] = None,
|
cfg: Optional[LineArtConfig] = None,
|
||||||
*, method: Optional[str] = None,
|
*, method: Optional[str] = None,
|
||||||
verbose: bool = False) -> str:
|
verbose: bool = False) -> str:
|
||||||
"""从文件提取线稿并保存。
|
"""Extract line art from a file and save it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
src: 输入图片路径。
|
src: input image path.
|
||||||
dst: 输出线稿路径 (.png)。
|
dst: output line-art path (.png).
|
||||||
cfg: 参数配置。None 使用默认。
|
cfg: parameter set. None uses the defaults.
|
||||||
method: 快捷覆盖模式 ("skeleton"/"minimum")。
|
method: convenience override for the mode ("skeleton" / "minimum").
|
||||||
verbose: 打印日志。
|
verbose: print pipeline logs.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
输出文件路径。
|
The output file path.
|
||||||
"""
|
"""
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
cfg = LineArtConfig()
|
cfg = LineArtConfig()
|
||||||
|
|||||||
+3
-3
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lineartization"
|
name = "lineartization"
|
||||||
version = "1.6.1"
|
version = "1.7.0"
|
||||||
description = "彩色插图/手抄报 一键转换为黑白线稿 (汉字清晰、线条连贯、粗细统一)"
|
description = "Convert color illustrations and posters into clean black-and-white line art."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
@@ -14,7 +14,7 @@ authors = [
|
|||||||
]
|
]
|
||||||
keywords = [
|
keywords = [
|
||||||
"lineart", "line-art", "sketch", "skeleton", "thinning",
|
"lineart", "line-art", "sketch", "skeleton", "thinning",
|
||||||
"image-processing", "opencv", "手抄报", "线稿", "提取线稿",
|
"image-processing", "opencv", "poster", "line-extraction",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
+132
-63
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
lineart-extractor 单元测试
|
lineartization unit tests
|
||||||
==========================
|
=========================
|
||||||
运行: pytest tests/ -v
|
Run: pytest tests/ -v
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -21,36 +21,37 @@ from lineartization import (
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 测试用图: 合成"白底 + 黑字 + 彩色块"
|
# Test fixture: synthetic "white background + black strokes + color blocks"
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_image():
|
def sample_image():
|
||||||
"""构造一张 400x600 的合成图: 白底 + 黑色矩形(模拟文字) + 彩色块。"""
|
"""Build a 400x600 synthetic image: white bg + black strokes + colors."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
img = np.full((400, 600, 3), 255, np.uint8)
|
img = np.full((400, 600, 3), 255, np.uint8)
|
||||||
|
|
||||||
# 中央"文字区": 密集小黑块
|
# Central "text area": dense small black blocks
|
||||||
rng = np.random.default_rng(42)
|
rng = np.random.default_rng(42)
|
||||||
for _ in range(120):
|
for _ in range(120):
|
||||||
x = rng.integers(180, 420)
|
x = rng.integers(180, 420)
|
||||||
y = rng.integers(150, 250)
|
y = rng.integers(150, 250)
|
||||||
img[y:y + 4, x:x + 4] = 0
|
img[y:y + 4, x:x + 4] = 0
|
||||||
|
|
||||||
# 左侧彩色块(模拟山体)
|
# Left color blocks
|
||||||
img[60:160, 20:180] = (60, 160, 80) # 绿
|
img[60:160, 20:180] = (60, 160, 80) # green
|
||||||
img[160:220, 20:180] = (80, 120, 200) # 偏蓝
|
img[160:220, 20:180] = (80, 120, 200) # bluish
|
||||||
|
|
||||||
# 右侧一个红色圆(模拟灯笼)
|
# A red circle on the right
|
||||||
import cv2
|
|
||||||
cv2.circle(img, (500, 120), 40, (40, 40, 200), 3)
|
cv2.circle(img, (500, 120), 40, (40, 40, 200), 3)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 测试
|
# I/O
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_load_save_roundtrip(tmp_path, sample_image):
|
def test_load_save_roundtrip(tmp_path, sample_image):
|
||||||
"""读写往返一致。"""
|
"""Save then load must round-trip."""
|
||||||
p = tmp_path / "in.png"
|
p = tmp_path / "in.png"
|
||||||
save_image(str(p), sample_image)
|
save_image(str(p), sample_image)
|
||||||
loaded = load_image(str(p))
|
loaded = load_image(str(p))
|
||||||
@@ -58,72 +59,140 @@ def test_load_save_roundtrip(tmp_path, sample_image):
|
|||||||
assert np.allclose(loaded, sample_image, atol=2)
|
assert np.allclose(loaded, sample_image, atol=2)
|
||||||
|
|
||||||
|
|
||||||
def test_extract_returns_binary(sample_image):
|
def test_load_missing_file():
|
||||||
"""输出必须是二值(0/255)白底黑线。"""
|
"""Loading a non-existent file must raise."""
|
||||||
out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False))
|
with pytest.raises((FileNotFoundError, Exception)):
|
||||||
|
load_image("___no_such_file___.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_path(tmp_path, sample_image):
|
||||||
|
"""Unicode file paths must work."""
|
||||||
|
src = tmp_path / "image_unicode.png"
|
||||||
|
dst = tmp_path / "output_unicode.png"
|
||||||
|
save_image(str(src), sample_image)
|
||||||
|
out = extract_lineart_file(str(src), str(dst))
|
||||||
|
assert os.path.exists(out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_interface(tmp_path, sample_image):
|
||||||
|
"""extract_lineart_file must work and return the output path."""
|
||||||
|
src = tmp_path / "src.png"
|
||||||
|
dst = tmp_path / "dst.png"
|
||||||
|
save_image(str(src), sample_image)
|
||||||
|
result = extract_lineart_file(str(src), str(dst))
|
||||||
|
assert os.path.exists(result)
|
||||||
|
assert result == str(dst)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Output contract
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.parametrize("method", ["skeleton", "minimum"])
|
||||||
|
def test_extract_returns_binary(sample_image, method):
|
||||||
|
"""Output must be binary (0/255), white background, black lines."""
|
||||||
|
out = extract_lineart(sample_image, LineArtConfig(method=method))
|
||||||
assert out.dtype == np.uint8
|
assert out.dtype == np.uint8
|
||||||
assert out.ndim == 2
|
assert out.ndim == 2
|
||||||
uniq = np.unique(out)
|
assert set(np.unique(out).tolist()).issubset({0, 255})
|
||||||
assert set(uniq.tolist()).issubset({0, 255})
|
|
||||||
assert out.shape == sample_image.shape[:2]
|
assert out.shape == sample_image.shape[:2]
|
||||||
|
|
||||||
|
|
||||||
def test_extract_has_content(sample_image):
|
@pytest.mark.parametrize("method", ["skeleton", "minimum"])
|
||||||
"""输出不能空白、也不能全黑。"""
|
def test_extract_has_content(sample_image, method):
|
||||||
out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False))
|
"""Output must be neither empty nor fully black."""
|
||||||
black_ratio = (out < 128).mean() * 100
|
out = extract_lineart(sample_image, LineArtConfig(method=method))
|
||||||
assert 0.1 < black_ratio < 90.0
|
ratio = (out < 128).mean() * 100
|
||||||
|
assert 0.1 < ratio < 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method_raises(sample_image):
|
||||||
|
"""An unknown method must raise ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
extract_lineart(sample_image, LineArtConfig(method="bogus"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# minimum mode: the two variants
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_minimum_true_black_default_is_on(sample_image):
|
||||||
|
"""min_true_black must default to True (the 1.6.1 behaviour)."""
|
||||||
|
assert LineArtConfig().min_true_black is True
|
||||||
|
assert LineArtConfig(method="minimum").min_true_black is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimum_both_variants_run(sample_image):
|
||||||
|
"""Both minimum variants must produce a valid binary image."""
|
||||||
|
for flag in (True, False):
|
||||||
|
cfg = LineArtConfig(method="minimum", min_true_black=flag)
|
||||||
|
out = extract_lineart(sample_image, cfg)
|
||||||
|
assert out.dtype == np.uint8
|
||||||
|
assert set(np.unique(out).tolist()).issubset({0, 255})
|
||||||
|
assert (out < 128).mean() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimum_variants_differ(sample_image):
|
||||||
|
"""The true-black and no-true-black variants must not be identical.
|
||||||
|
|
||||||
|
The fixture has saturated color blocks, which the true-black gate rejects,
|
||||||
|
so the two paths must produce measurably different masks.
|
||||||
|
"""
|
||||||
|
on = extract_lineart(sample_image,
|
||||||
|
LineArtConfig(method="minimum", min_true_black=True))
|
||||||
|
off = extract_lineart(sample_image,
|
||||||
|
LineArtConfig(method="minimum", min_true_black=False))
|
||||||
|
assert not np.array_equal(on, off)
|
||||||
|
|
||||||
|
|
||||||
|
def test_denoise_levels(sample_image):
|
||||||
|
"""Every denoise level must run (true-black variant)."""
|
||||||
|
for lvl in ("strong", "normal", "light", "none"):
|
||||||
|
cfg = LineArtConfig(method="minimum", denoise=lvl, min_true_black=True)
|
||||||
|
out = extract_lineart(sample_image, cfg)
|
||||||
|
assert (out < 128).mean() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_true_black_thresholds_apply(sample_image):
|
||||||
|
"""Tightening the true-black bounds must not increase the ink coverage."""
|
||||||
|
loose = extract_lineart(
|
||||||
|
sample_image,
|
||||||
|
LineArtConfig(method="minimum", min_true_black=True,
|
||||||
|
min_mean=255, min_chroma=255))
|
||||||
|
tight = extract_lineart(
|
||||||
|
sample_image,
|
||||||
|
LineArtConfig(method="minimum", min_true_black=True,
|
||||||
|
min_mean=10, min_chroma=5))
|
||||||
|
assert (tight < 128).mean() <= (loose < 128).mean()
|
||||||
|
|
||||||
|
|
||||||
|
def test_min2_options_are_accepted(sample_image):
|
||||||
|
"""The no-true-black tuning options must be accepted."""
|
||||||
|
cfg = LineArtConfig(method="minimum", min_true_black=False,
|
||||||
|
m2_noise_area=5, m2_short_area=10, m2_short_len=8,
|
||||||
|
m2_close_k=3, m2_dist_min=0.5)
|
||||||
|
out = extract_lineart(sample_image, cfg)
|
||||||
|
assert (out < 128).mean() > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# skeleton mode
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
def test_line_width_effect(sample_image):
|
def test_line_width_effect(sample_image):
|
||||||
"""线宽参数应影响黑占比(越粗越多)。"""
|
"""Stroke width must affect the black ratio (thicker = more black)."""
|
||||||
cfg1 = LineArtConfig(line_width=1, enable_green_smoothing=False)
|
r1 = (extract_lineart(sample_image, LineArtConfig(line_width=1)) < 128).mean()
|
||||||
cfg3 = LineArtConfig(line_width=3, enable_green_smoothing=False)
|
r3 = (extract_lineart(sample_image, LineArtConfig(line_width=3)) < 128).mean()
|
||||||
r1 = (extract_lineart(sample_image, cfg1) < 128).mean()
|
|
||||||
r3 = (extract_lineart(sample_image, cfg3) < 128).mean()
|
|
||||||
assert r3 > r1
|
assert r3 > r1
|
||||||
|
|
||||||
|
|
||||||
def test_green_smoothing_toggle(sample_image):
|
def test_color_smoothing_toggle(sample_image):
|
||||||
"""绿块抹平开关都应能正常出图。"""
|
"""Color-region smoothing must run both on and off."""
|
||||||
for flag in (True, False):
|
for flag in (True, False):
|
||||||
cfg = LineArtConfig(enable_green_smoothing=flag)
|
cfg = LineArtConfig(enable_color_smoothing=flag)
|
||||||
out = extract_lineart(sample_image, cfg)
|
out = extract_lineart(sample_image, cfg)
|
||||||
assert (out < 128).mean() > 0
|
assert (out < 128).mean() > 0
|
||||||
|
|
||||||
|
|
||||||
def test_protect_areas(sample_image):
|
def test_protect_areas(sample_image):
|
||||||
"""保护区域内的线条不应被删。"""
|
"""Lines inside a protected region must survive pruning."""
|
||||||
cfg = LineArtConfig(
|
cfg = LineArtConfig(protect_areas=[(0, 200, 0, 400)])
|
||||||
enable_green_smoothing=False,
|
|
||||||
protect_areas=[(0, 200, 0, 400)],
|
|
||||||
)
|
|
||||||
out = extract_lineart(sample_image, cfg)
|
out = extract_lineart(sample_image, cfg)
|
||||||
assert (out < 128).sum() > 0
|
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