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:
@@ -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
|
||||
|
||||
[]()
|
||||
[]()
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user