Files
lineartization/README.md
T
dvs 928253a4c9 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.
2026-09-26 14:37:12 +08:00

597 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# lineartization
> **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.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)
- [Technical Documentation](#technical-documentation)
- [Pipeline Overview](#pipeline-overview)
- [Mode A: Skeletonization](#mode-a-skeletonization)
- [Mode B: Minimum Filter](#mode-b-minimum-filter)
- [True-Black Criterion](#true-black-criterion)
- [Denoise Algorithm](#denoise-algorithm)
- [Parameter Reference](#parameter-reference)
- [Design Notes & Known Limits](#design-notes--known-limits)
- [Project Structure](#project-structure)
- [Testing](#testing)
- [Contact](#contact)
- [License](#license)
---
## Overview
`lineartization` turns a **color picture** (manga-style illustration, poster,
children's drawing) into a **black-on-white line drawing** suitable for:
- Coloring books / templates
- Printing (high-contrast, ink-friendly)
- Vectorization / further editing
- OCR preprocessing
Unlike dedicated edge-detection or upscaling tools (which are pure pixel math and
produce broken lines, hollow double edges, or heavy blur), this library works in
two well-defined strategies depending on the source quality.
---
## 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 |
|---------|-------------|
| 📐 **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 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 |
---
## Installation
```bash
pip install lineartization
```
From source:
```bash
git clone https://git.dvscloud.net/dvs/lineartization.git
cd lineartization
pip install -e .
```
**Dependencies:** `numpy`, `opencv-python`, `opencv-contrib-python`, `scikit-image`
---
## Quick Start
### Command line
```bash
# Skeletonization (clear / vector-like source)
lineartization poster.jpg lineart.png
# Minimum filter (handwritten / photographed source)
lineartization handwriting.jpg lineart.png --method minimum
# Minimum filter without the true-black gate (colorful posters)
lineartization colorful.jpg lineart.png --method minimum --no-true-black
```
### Python
```python
from lineartization import extract_lineart_file
extract_lineart_file("poster.jpg", "lineart.png") # skeleton
extract_lineart_file("handwriting.jpg", "lineart.png", method="minimum")
extract_lineart_file("colorful.jpg", "lineart.png",
method="minimum", min_true_black=False)
```
---
## The Two Extraction Modes
Choosing the wrong mode is the most common cause of bad output. **Pick the mode
that matches how the source image was produced.**
### `method="skeleton"` — for clear sources (default)
Use when the original image **already has clean, well-separated lines**, e.g. a
vector illustration, a high-resolution redrawing, or a professionally scanned
black-ink drawing.
* Strong point: thin, smooth, uniform lines — the most aesthetic result.
* Weak point: skeletonization on **thick handwritten strokes** produces spurs and
web-like artefacts, because thinning a wide non-uniform stroke inevitably
branches.
### `method="minimum"` — for handwritten / low-resolution sources
Use when the image is a **photo of a hand-drawn poster**, a phone snapshot, or
anything with thick, irregular, low-resolution strokes.
* Strong point: preserves the original strokes, no line breakage.
* Weak point: strokes are a little thick; result is "usable" rather than refined.
> **Rule of thumb:** if the lines in the source are one clean pixel wide → `skeleton`.
> If the lines are thick / wobbly / photographed → `minimum`.
---
## 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 in `minimum` mode when `min_true_black=True`.
(`min_true_black=False` uses its own despeckle + fragment removal instead.)
| Level | Pipeline | Note |
|-------|----------|------|
| **`strong`** *(default)* | median → open → connected-component filter (<30 px) → final median | Standard aggressive cleanup |
| `normal` | median → connected-component filter → final median | Slightly gentler |
| `light` | median → remove only "tiny square" blobs → final median | Line-preserving |
| `none` | median only | Minimal |
```bash
lineartization in.jpg out.png -m minimum -d light
lineartization in.jpg out.png -m minimum -d strong --denoise-area 40
```
---
## Command Line Interface
```
usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH]
[--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]
[--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
```
| Option | Default | Description |
|--------|---------|-------------|
| `-m, --method` | `skeleton` | Extraction mode |
| `-w, --width` | `2` | Stroke width (skeleton mode) |
| `--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) |
| `-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 |
---
## Python API
```python
import cv2
from lineartization import LineArtConfig, extract_lineart, load_image, save_image
img = load_image("poster.jpg") # BGR uint8, RGBA-safe
cfg = LineArtConfig(
method="minimum", # "skeleton" | "minimum"
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
)
lineart = extract_lineart(img, cfg, verbose=True) # 0/255, white bg, black lines
save_image("lineart.png", lineart)
```
`extract_lineart()` accepts a BGR image and returns a **single-channel `uint8`
image valued 0/255** (white background, black lines).
---
# Technical Documentation
## Pipeline Overview
```
┌──────────────┐
input image ─────► │ load_image() │ RGBA-safe, white-composited, BGR
└──────┬───────┘
│
┌───────────────┴────────────────┐
▼ ▼
method = "skeleton" method = "minimum"
──────────────────── ────────────────────
Paper + text region True-black criterion (optional)
Pattern / text extraction Minimum filter
Lee skeletonization Otsu binarization
Denoise + spur pruning ├─ true-black ON : CC denoise
Uniform width └─ true-black OFF: despeckle,
│ fragment removal,
│ distance-transform thinning
└───────────────┬────────────────┘
▼
0/255 line-art PNG
```
---
## Mode A: Skeletonization
**Goal:** reproduce a clear source as thin, uniform, aesthetically pleasing lines.
### Step 1 — Region analysis
Two spatial masks are derived from the HSV representation:
* **Paper region** (`paper`) — bright, low-saturation background of the text block.
```
paper = (V > paper_v) AND (S < paper_s)
paper = morph_close(ELLIPSE 21×21, iterations=3)
paper = erode(ELLIPSE paper_erode×paper_erode)
```
* **Text rectangle** (`tz`) — the *largest connected blob* of "ink density".
```
ink = (V < ink_v) AND (S < ink_s)
dense = morph_close(ink, 41×41)
dense = morph_open(dense, 61×61)
tz = bounding_box(largest_blob(dense)) + text_pad
```
Using the largest density blob (rather than a raw colour mask) reliably
excludes scattered decorations such as fireworks or small figures.
### Step 2 — Line extraction
```python
at_text = adaptiveThreshold(gray, GAUSSIAN, INV, 31, 14)
at_all = adaptiveThreshold(gray, MEAN, INV, 25, 19)
dark = (V < dark_v)
fine = dark AND NOT morph_open(dark, 13×13) # drop large dark blocks
pattern = (NOT paper) AND fine AND at_all
text = paper AND at_text
lines = skel( morph_close(text OR pattern, 3×3) )
```
**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
* **Isolated noise removal** — a connected component is removed when
`skeleton_length < noise_sk_len` **and** `branch_count < noise_branch`
**and** `area < noise_area`.
* **Spur pruning** — walk from every skeleton endpoint; if a branch reaches a
junction within `spur_maxlen` px, it is erased (except inside protected areas).
### Step 4 — Uniform width
Text and artwork are separately re-skeletonised, then dilated to `line_width`.
---
## Mode B: Minimum Filter
**Goal:** faithfully keep the original strokes of a handwritten / low-res source,
avoiding the false-positive colour edges that naive thresholding produces.
The pipeline mirrors the classic Photoshop "Minimum filter" line-art recipe,
derived mathematically:
```
L = grayscale(image) # line = dark, background = light
R = 255 − L # line = light, background = dark
M = erode(R, kernel) # minimum filter: dark background expands
result = L / (255 − M) · 255 # "Color Dodge" blend
line = Otsu(result) # pure black / white
```
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
`L / (255 − B)`. Feeding the eroded inverse `M` as the blend layer gives the
result **already in white-background / black-line polarity** — no extra
inversion is required (an extra `255 − result` produces an all-black image,
a classic pitfall).
---
## True-Black Criterion
A naive luminance threshold classifies **dark colours** (deep red, navy) as
"black", producing spurious blobs. `lineartization` instead requires a pixel to
be **both dark and achromatic**:
```
mean = (R + G + B) / 3
chroma = max(R,G,B) − min(R,G,B)
true_black = (mean < min_mean) AND (chroma < min_chroma)
```
* `mean < min_mean` ⇒ dark enough.
* `chroma < min_chroma` ⇒ R, G, B are close ⇒ grey/black, **not** a saturated colour.
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 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)
normal : median(3) → remove CC area<30 → median(3)
light : median(3) → remove blobs (area<10 & fill≥0.8 & elong<1.8) → median(3)
none : median(3)
```
`strong` is the default. Lower levels trade less noise suppression for fewer
false deletions of legitimate short strokes.
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 |
| `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 |
| `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_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 |
---
## Design Notes & Known Limits
**Why skeletonization is not always the answer.** Morphological thinning peels
border pixels from a blob. For a *thick, non-uniform handwritten stroke*, the
remaining medial axis branches into spurs and webs. That is precisely what
`method="minimum"` avoids by keeping the original stroke instead of reducing it
to a 1-px skeleton.
**Why 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
requires a centre-line step — both inferior to the direct approaches above.
**Known limits.**
* Very low-resolution text (character strokes < 2 px) cannot be recovered by any
pure-algorithm method; a semantic/AI model is required. This library does not
include one by design. **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` (true-black) or `--m2-noise-area` / `--m2-short-area`
(no-true-black) to suppress them.
---
## Project Structure
```
lineartization/
├── lineartization/
│ ├── __init__.py # package entry + CLI
│ ├── __main__.py # `python -m lineartization`
│ ├── core.py # algorithm (skeleton / minimum)
│ └── py.typed
├── examples/
│ └── demo.py
├── tests/
│ └── test_core.py
├── pyproject.toml
├── MANIFEST.in
├── README.md
└── LICENSE
```
No file in this project contains a built-in absolute path. Every entry point
takes its input and output paths from the caller.
---
## Testing
```bash
pip install pytest
pytest tests/ -v
```
---
## Contact
| | |
|---|---|
| **Author** | DVS |
| **Email** | admin@dvscloud.net |
| **Backup** | dvs6666@163.com |
| **Repository** | https://git.dvscloud.net/dvs/lineartization |
---
## License
MIT License — see [LICENSE](LICENSE) for details.