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
- Resolution Matters
- Features
- Installation
- Quick Start
- The Two Extraction Modes
- Minimum Mode: True-Black On/Off
- 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, 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
skeletonfirst; if strokes break up, fall back tominimum. - 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
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
# Minimum filter without the true-black gate (colorful posters)
lineartization colorful.jpg lineart.png --method minimum --no-true-black
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.
lineartization in.jpg out.png -m minimum # true-black on
lineartization in.jpg out.png -m minimum --no-true-black # true-black off
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 |
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
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".
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) )
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_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
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
minimummode. skeletonmode on large images is slow (tens of seconds) because of the full-frame mean-shift pass. Setenable_color_smoothing=Falseto 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
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.
Release files for lineartization 1.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lineartization-1.7.0.tar.gz | 32.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lineartization-1.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 54.2 kB
Release files / lineartization-1.7.0.tar.gz
| Download URL | lineartization-1.7.0.tar.gz |
|---|---|
| Size | 32.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ee2706e7ccea491af5882d35e441aed3d1a2a606b952624283d0bf0b30529137
|
|
BLAKE2b-256 checksum How to use checksums |
1862f5d367fd00345db115a8e80fef577e0bd7124cf6255460d1dc58922647e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.4
|
Release files / lineartization-1.7.0-py3-none-any.whl
| Download URL | lineartization-1.7.0-py3-none-any.whl |
|---|---|
| Size | 21.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f11aa1d3a3ca1c4cb94fc2a05bbc50c9e544a1225583cf05f73d5c65493745f1
|
|
BLAKE2b-256 checksum How to use checksums |
50418b8e69ef05ed8810ec33fd9e7bb626d306ed0f790cbae4063665230445f7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.4
|