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

22 KiB
Raw Permalink Blame History

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

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

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".
    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

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

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 for details.