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:
dvs
2026-09-26 14:37:12 +08:00
parent b6612f1df3
commit c2ec2e3267
8 changed files with 734 additions and 278 deletions
+5 -5
View File
@@ -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
+195 -42
View File
@@ -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
[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)]()
[![License](https://img.shields.io/badge/license-MIT-green.svg)]()
--- ---
## Table of Contents ## 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
@@ -102,6 +129,8 @@ 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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"""支持 `python -m lineartization` 调用。""" """Enable `python -m lineartization`."""
from . import main from . import main
if __name__ == "__main__": if __name__ == "__main__":
+217 -87
View File
@@ -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:
_log(f"[lineart] method=minimum true_black=True denoise={cfg.denoise}")
mask = _minimum_filter_lineart(bgr, cfg) 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
View File
@@ -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
View File
@@ -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)