15 KiB
lineartization
Convert color illustrations / handwritten 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
Table of Contents
- Overview
- Features
- Installation
- Quick Start
- The Two Extraction Modes
- Denoise Levels
- Command Line Interface
- Python API
- Technical Documentation
- Design Notes & Known Limits
- Project Structure
- Testing
- Contact
- License
Overview
lineartization turns a color picture (manga-style illustration, school 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.
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 |
| 🔗 Continuous lines | Lee skeletonization (shape-preserving) instead of naive thinning |
| 🎨 True-black criterion | Distinguishes black ink from dark colors using RGB + chroma |
| 🧹 Tunable denoise | Four levels: strong / normal / light / none |
| 🛡️ Protected regions | Keep complex textures (emblems, seals) from being cleaned away |
| 🐍 Zero model dependency | No GPU, no ONNX, no downloads — pip install and run |
Installation
pip install lineartization
From source:
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
# Skeletonization (clear / vector-like source)
lineartization poster.jpg lineart.png
# Minimum filter (handwritten / photographed source)
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("handwriting.jpg", "lineart.png", method="minimum")
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.
Denoise Levels
Available only in minimum mode (skeleton mode has its own built-in cleanup).
| 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 |
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]
[--min-mean MIN_MEAN] [--min-chroma MIN_CHROMA]
[--min-kernel MIN_KERNEL]
[-d {strong,normal,light,none}]
[--denoise-area DENOISE_AREA] [--no-green-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) |
-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 |
--min-kernel |
2 |
Minimum-filter radius (1–3) |
--no-green-smoothing |
off | Disable green-block smoothing |
--protect |
— | Protected rect x1,x2,y1,y2 (repeatable) |
-v, --verbose |
off | Print pipeline logs |
Python API
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"
denoise="strong", # strong | normal | light | none
min_mean=130, # true-black RGB mean threshold
min_chroma=45, # true-black chroma threshold
min_kernel=2, # minimum-filter radius
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"
──────────────────── ────────────────────
Region analysis True-black criterion
Pattern extraction Minimum filter
Lee skeletonization Otsu binarization
Denoise + spur pruning Denoise (tunable)
Uniform width → white bg / black lines
│ │
└───────────────┬────────────────┘
▼
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".Using the largest density blob (rather than a raw colour mask) reliably excludes scattered decorations such as fireworks or small figures.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
Step 2 — Line extraction
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) )
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).
Step 3 — Denoise & spur pruning
- Isolated noise removal — a connected component is removed when
skeleton_length < noise_sk_lenandbranch_count < noise_branchandarea < noise_area. - Spur pruning — walk from every skeleton endpoint; if a branch reaches a
junction within
spur_maxlenpx, 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
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.
The final mask is intersected with true_black, so coloured fills are never
reported as ink.
Denoise Algorithm
minimum mode 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.
Parameter Reference
| Parameter | Default | Meaning |
|---|---|---|
method |
"skeleton" |
"skeleton" or "minimum" |
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 |
text_pad |
40 | Padding around the detected text rectangle |
dark_v |
160 | Dark-pixel threshold (skeleton mode) |
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 |
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 |
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 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.
- Heavy JPEG artefacts in the source may survive as small debris; raise
--denoise-areato 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
Testing
pip install pytest
pytest tests/ -v
Contact
| Author | DVS |
| admin@dvscloud.net | |
| Backup | dvs6666@163.com |
| Repository | https://git.dvscloud.net/dvs/lineartization |
License
MIT License — see LICENSE for details.