release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise)
This commit is contained in:
@@ -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
|
||||
|
||||
[]()
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user