# 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 [![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)]() [![License](https://img.shields.io/badge/license-MIT-green.svg)]() --- ## Table of Contents - [Overview](#overview) - [Features](#features) - [Installation](#installation) - [Quick Start](#quick-start) - [The Two Extraction Modes](#the-two-extraction-modes) - [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, 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 ```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 ``` ### 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") ``` --- ## 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 | ```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] [--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 ```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" 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". ``` 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) ) ``` 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_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 ``` ### 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-area` 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 ``` --- ## 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.