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