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 928253a4c9
8 changed files with 734 additions and 278 deletions
+196 -43
View File
@@ -1,22 +1,21 @@
# 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.
**Version:** 1.6.1 · **Author:** DVS · **License:** MIT
[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)]()
[![License](https://img.shields.io/badge/license-MIT-green.svg)]()
**Version:** 1.7.0 · **Author:** DVS · **License:** MIT
---
## Table of Contents
- [Overview](#overview)
- [Resolution Matters](#resolution-matters)
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [The Two Extraction Modes](#the-two-extraction-modes)
- [Minimum Mode: True-Black On/Off](#minimum-mode-true-black-onoff)
- [Denoise Levels](#denoise-levels)
- [Command Line Interface](#command-line-interface)
- [Python API](#python-api)
@@ -37,7 +36,7 @@
## 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:
- 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
| Feature | Description |
|---------|-------------|
| 🈶 **Chinese-text aware** | Detects the text block and keeps character strokes complete |
| 📐 **Uniform stroke width** | Text and artwork lines unified to a configurable width |
| 📐 **Uniform stroke width** | Output lines unified to a configurable width |
| 🔗 **Continuous lines** | Lee skeletonization (shape-preserving) instead of naive thinning |
| 🎨 **True-black criterion** | Distinguishes *black ink* from *dark colors* using RGB + chroma |
| 🔀 **Two minimum variants** | Pick per image: with or without the true-black gate |
| 🧹 **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 |
---
@@ -93,6 +117,9 @@ lineartization poster.jpg lineart.png
# Minimum filter (handwritten / photographed source)
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
@@ -100,8 +127,10 @@ lineartization handwriting.jpg lineart.png --method minimum
```python
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("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
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 |
|-------|----------|------|
@@ -157,10 +222,15 @@ lineartization in.jpg out.png -m minimum -d strong --denoise-area 40
```
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]
[-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]
input output
```
@@ -169,12 +239,19 @@ usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH]
|--------|---------|-------------|
| `-m, --method` | `skeleton` | Extraction mode |
| `-w, --width` | `2` | Stroke width (skeleton mode) |
| `-d, --denoise` | `strong` | Denoise level (minimum mode) |
| `--denoise-area` | `30` | Connected-component removal threshold |
| `--min-mean` | `130` | True-black criterion: max RGB mean |
| `--min-chroma` | `45` | True-black criterion: max chroma |
| `--no-true-black` | off | `minimum`: disable the true-black gate |
| `--min-mean` | `180` | True-black criterion: max RGB mean |
| `--min-chroma` | `60` | True-black criterion: max chroma |
| `--min-ratio` | `1.5` | Otsu fallback threshold (%) |
| `--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) |
| `-v, --verbose` | off | Print pipeline logs |
@@ -190,10 +267,9 @@ img = load_image("poster.jpg") # BGR uint8, RGBA-safe
cfg = LineArtConfig(
method="minimum", # "skeleton" | "minimum"
denoise="strong", # strong | normal | light | none
min_mean=130, # true-black RGB mean threshold
min_chroma=45, # true-black chroma threshold
min_true_black=False, # False -> keep colored regions, thin the strokes
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
)
@@ -219,12 +295,13 @@ image valued 0/255** (white background, black lines).
▼ ▼
method = "skeleton" method = "minimum"
──────────────────── ────────────────────
Region analysis True-black criterion
Pattern extraction Minimum filter
Paper + text region True-black criterion (optional)
Pattern / text extraction Minimum filter
Lee skeletonization Otsu binarization
Denoise + spur pruning Denoise (tunable)
Uniform width → white bg / black lines
│ │
Denoise + spur pruning ├─ true-black ON : CC denoise
Uniform width └─ true-black OFF: despeckle,
│ fragment removal,
│ distance-transform thinning
└───────────────┬────────────────┘
▼
0/255 line-art PNG
@@ -258,7 +335,7 @@ Two spatial masks are derived from the HSV representation:
### Step 2 — Line extraction
```
```python
at_text = adaptiveThreshold(gray, GAUSSIAN, INV, 31, 14)
at_all = adaptiveThreshold(gray, MEAN, INV, 25, 19)
dark = (V < dark_v)
@@ -268,9 +345,12 @@ text = paper AND at_text
lines = skel( morph_close(text OR pattern, 3×3) )
```
Optionally, the four large green blocks (hills in a poster) are re-extracted
from a **mean-shift smoothed** copy to suppress colour-banding, and merged via
a Canny contour (see `enable_green_smoothing`).
**Flat color regions.** Broad saturated fills are located by saturation alone
(`S > color_sat_min`, area within `color_area_range`) — deliberately *not* by
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
@@ -302,6 +382,35 @@ result = L / (255 − M) · 255 # "Color Dodge" blend
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
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.
* `chroma < min_chroma` ⇒ R, G, B are close ⇒ grey/black, **not** a saturated colour.
The final mask is intersected with `true_black`, so coloured fills are never
reported as ink.
Defaults are `min_mean=180`, `min_chroma=60`. This gate is what the
`min_true_black` switch turns on and off.
---
## Denoise Algorithm
`minimum` mode exposes four levels. All levels end with a median pass to remove
salt-and-pepper residue.
`minimum` mode with `min_true_black=True` exposes four levels. All levels end
with a median pass to remove salt-and-pepper residue.
```
strong : median(3) → open(2×2) → remove CC area<30 → median(3)
@@ -348,29 +457,56 @@ none : median(3)
`strong` is the default. Lower levels trade less noise suppression for fewer
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
### Shared
| Parameter | Default | Meaning |
|-----------|---------|---------|
| `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_erode` | 31 | Erosion kernel to shrink the paper region |
| `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 |
| `dark_v` | 160 | Dark-pixel threshold (skeleton mode) |
| `dark_v` | 160 | Dark-pixel threshold |
| `morph_open_k` | 13 | Kernel removing large dark blocks |
| `adaptive_bs` / `adaptive_c` | 25 / 19 | Artwork adaptive threshold |
| `noise_sk_len` / `noise_branch` / `noise_area` | 25 / 8 / 300 | Isolated-noise criterion |
| `spur_maxlen` | 25 | Max spur length pruned |
| `min_mean` / `min_chroma` | 130 / 45 | True-black criterion |
| `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 |
| `denoise` | `"strong"` | Denoise level |
| `denoise_area` | 30 | CC removal area for strong/normal |
| `line_width` | 2 | Stroke width (skeleton mode) |
| `protect_areas` | `[]` | List of `(x1,x2,y1,y2)` rectangles never cleaned |
| `enable_green_smoothing` | `True` | Mean-shift smoothing of green hill blocks |
| `min_otsu` | `True` | Use Otsu instead of a fixed 128 threshold |
| `min_ratio` | 1.5 | Fallback to adaptive threshold below this ink % |
| `denoise` | `"strong"` | Denoise level (true-black on) |
| `denoise_area` | 30 | CC removal area (true-black on) |
| `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
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,
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
@@ -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
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
`--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
```
No file in this project contains a built-in absolute path. Every entry point
takes its input and output paths from the caller.
---
## Testing