release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise)

This commit is contained in:
dvs
2026-09-26 13:37:03 +08:00
commit b6612f1df3
11 changed files with 1360 additions and 0 deletions
+443
View File
@@ -0,0 +1,443 @@
# 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.