diff --git a/.gitignore b/.gitignore index c3ef5d9..4739319 100644 --- a/.gitignore +++ b/.gitignore @@ -10,29 +10,29 @@ dist/ .eggs/ pip-wheel-metadata/ -# 虚拟环境 +# Virtual environments .venv/ venv/ env/ ENV/ -# 测试 / 缓存 +# Tests / caches .pytest_cache/ .coverage htmlcov/ .tox/ .mypy_cache/ -# IDE +# IDEs .idea/ .vscode/ *.swp -# 输出 +# Outputs output/ *.log -# 系统 +# OS .DS_Store Thumbs.db desktop.ini diff --git a/README.md b/README.md index 84cde43..fda8acd 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,21 @@ # lineartization -> **Convert color illustrations / handwritten posters into clean black-and-white line art.** +> **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.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)]() +**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) @@ -37,7 +36,7 @@ ## Overview -`lineartization` turns a **color picture** (manga-style illustration, school poster, +`lineartization` turns a **color picture** (manga-style illustration, poster, children's drawing) into a **black-on-white line drawing** suitable for: - Coloring books / templates @@ -51,16 +50,41 @@ 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 | |---------|-------------| -| 🈶 **Chinese-text aware** | Detects the text block and keeps character strokes complete | -| 📐 **Uniform stroke width** | Text and artwork lines unified to a configurable width | +| 📐 **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 complex textures (emblems, seals) from being cleaned away | +| 🛡️ **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 | --- @@ -93,6 +117,9 @@ 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 @@ -100,8 +127,10 @@ lineartization handwriting.jpg lineart.png --method minimum ```python from lineartization import extract_lineart_file -extract_lineart_file("poster.jpg", "lineart.png") # skeleton +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) ``` --- @@ -135,9 +164,45 @@ anything with thick, irregular, low-resolution strokes. --- +## 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 only in `minimum` mode (skeleton mode has its own built-in cleanup). +Available in `minimum` mode when `min_true_black=True`. +(`min_true_black=False` uses its own despeckle + fragment removal instead.) | Level | Pipeline | Note | |-------|----------|------| @@ -157,10 +222,15 @@ lineartization in.jpg out.png -m minimum -d strong --denoise-area 40 ``` usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH] - [--min-mean MIN_MEAN] [--min-chroma MIN_CHROMA] + [--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] [--no-green-smoothing] + [--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 ``` @@ -169,12 +239,19 @@ usage: lineartization [-h] [-m {skeleton,minimum}] [-w WIDTH] |--------|---------|-------------| | `-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 | +| `--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) | -| `--no-green-smoothing` | off | Disable green-block smoothing | +| `-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 | @@ -190,10 +267,9 @@ 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_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 ) @@ -219,12 +295,13 @@ image valued 0/255** (white background, black lines). ▼ ▼ method = "skeleton" method = "minimum" ──────────────────── ──────────────────── - Region analysis True-black criterion - Pattern extraction Minimum filter + Paper + text region True-black criterion (optional) + Pattern / text extraction Minimum filter Lee skeletonization Otsu binarization - Denoise + spur pruning Denoise (tunable) - Uniform width → white bg / black lines - │ │ + Denoise + spur pruning ├─ true-black ON : CC denoise + Uniform width └─ true-black OFF: despeckle, + │ fragment removal, + │ distance-transform thinning └───────────────┬────────────────┘ ▼ 0/255 line-art PNG @@ -258,7 +335,7 @@ Two spatial masks are derived from the HSV representation: ### 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) @@ -268,9 +345,12 @@ 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`). +**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 @@ -302,6 +382,35 @@ 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 @@ -328,15 +437,15 @@ 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. +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 exposes four levels. All levels end with a median pass to remove -salt-and-pepper residue. +`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) @@ -348,29 +457,56 @@ 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 (skeleton mode) | +| `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 | -| `min_mean` / `min_chroma` | 130 / 45 | True-black criterion | +| `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 | -| `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 | +| `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 | --- @@ -382,6 +518,17 @@ 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 @@ -391,9 +538,12 @@ requires a centre-line step — both inferior to the direct approaches above. * 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. + 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` to suppress them. + `--denoise-area` (true-black) or `--m2-noise-area` / `--m2-short-area` + (no-true-black) to suppress them. --- @@ -416,6 +566,9 @@ lineartization/ └── 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 diff --git a/examples/demo.py b/examples/demo.py index 1a91f97..4d5bf19 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,7 +1,16 @@ """ -lineart-extractor 使用示例 -========================== -演示三种用法: 一行函数 / 自定义配置 / 直接处理 ndarray +lineartization usage examples +============================= +Demonstrates the call styles: one-liner / custom config / raw ndarray. + +Usage: + python demo.py + +There are no built-in default paths: both arguments are required. + +Tip: extraction quality depends on the source resolution -- the higher the +resolution, the cleaner the result. For low-resolution inputs use the +``minimum`` method. """ import os import sys @@ -16,53 +25,69 @@ from lineartization import ( save_image, ) -DEMO_SRC = os.environ.get("LINEART_DEMO_SRC", "手抄报.jpg") -DEMO_OUT_DIR = os.environ.get("LINEART_DEMO_OUT", "./output") -os.makedirs(DEMO_OUT_DIR, exist_ok=True) + +def demo_simple(src, out_dir): + """Example 1: one-liner (skeleton mode).""" + print("=== Example 1: one-liner ===") + extract_lineart_file(src, os.path.join(out_dir, "simple.png")) + print(" wrote simple.png") -def demo_simple(): - """① 一行搞定""" - print("=== 示例1: 一行调用 ===") - extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "simple.png")) - print(" 已生成 simple.png") - - -def demo_config(): - """② 自定义配置 (线宽 + 保护华表区)""" - print("=== 示例2: 自定义配置 ===") +def demo_config(src, out_dir): + """Example 2: custom config (minimum mode + protected region).""" + print("=== Example 2: custom config ===") cfg = LineArtConfig( method="minimum", denoise="strong", - line_width=2, # 统一线宽 2px - enable_green_smoothing=True, # 手抄报绿块抹平 - protect_areas=[(120, 220, 940, 1050)], # 保护"华表"区域 + line_width=2, # uniform 2 px stroke + protect_areas=[(120, 220, 940, 1050)], # keep this region intact ) - extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "configured.png"), + extract_lineart_file(src, os.path.join(out_dir, "configured.png"), cfg, verbose=True) - print(" 已生成 configured.png") + print(" wrote configured.png") -def demo_ndarray(): - """③ 直接处理 ndarray (可嵌入你自己的流水线)""" - print("=== 示例3: ndarray 处理 ===") - img = load_image(DEMO_SRC) - print(f" 输入尺寸: {img.shape[1]}x{img.shape[0]}") +def demo_two_variants(src, out_dir): + """Example 3: the two minimum variants (true-black on / off).""" + print("=== Example 3: minimum variants ===") + for flag, name in ((True, "true-black"), (False, "no-true-black")): + cfg = LineArtConfig(method="minimum", min_true_black=flag) + extract_lineart_file(src, os.path.join(out_dir, f"minimum_{name}.png"), cfg) + print(f" wrote minimum_{name}.png") + + +def demo_ndarray(src, out_dir): + """Example 4: work directly on an ndarray (embeddable in your pipeline).""" + print("=== Example 4: ndarray processing ===") + img = load_image(src) + print(f" input size: {img.shape[1]}x{img.shape[0]}") lineart = extract_lineart(img, LineArtConfig(line_width=2)) black_ratio = (lineart < 128).mean() * 100 - print(f" 黑占比: {black_ratio:.2f}%") - save_image(os.path.join(DEMO_OUT_DIR, "ndarray.png"), lineart) - print(" 已生成 ndarray.png") + print(f" black ratio: {black_ratio:.2f}%") + save_image(os.path.join(out_dir, "ndarray.png"), lineart) + print(" wrote ndarray.png") + + +def main(argv): + if len(argv) != 3: + print(__doc__.strip()) + print("\nError: expected exactly 2 arguments " + "( ).") + return 2 + + src, out_dir = argv[1], argv[2] + if not os.path.exists(src): + print(f"Error: input image not found: {src}") + return 2 + os.makedirs(out_dir, exist_ok=True) + + demo_simple(src, out_dir) + demo_config(src, out_dir) + demo_two_variants(src, out_dir) + demo_ndarray(src, out_dir) + print("\nAll examples finished.") + return 0 if __name__ == "__main__": - if not os.path.exists(DEMO_SRC): - print(f"提示: 未找到示例图片 '{DEMO_SRC}'") - print("请设置环境变量 LINEART_DEMO_SRC 指向一张图片, 例如:") - print(" set LINEART_DEMO_SRC=D:\\pics\\手抄报.jpg") - sys.exit(0) - - demo_simple() - demo_config() - demo_ndarray() - print("\n全部示例完成 ✔") + raise SystemExit(main(sys.argv)) diff --git a/lineartization/__init__.py b/lineartization/__init__.py index 892d315..28bbd8d 100644 --- a/lineartization/__init__.py +++ b/lineartization/__init__.py @@ -1,32 +1,52 @@ """ lineartization -================= -把彩色插图 / 手抄报 一键转换为黑白线稿。 +============== +Convert a color illustration / poster into black-and-white line art in one call. -两种模式 --------- -- ``method="skeleton"`` (默认):**骨架化**。适合"原图线条清晰"的图片 - (矢量插画、清晰手抄报),线条细而均匀、更美观。 -- ``method="minimum"`` :**最小值滤波**。适合"手写 / 手机拍 / 像素不足"的图, - 保留原笔触、不断线,属"基本可用"级别。 +Two modes +--------- +- ``method="skeleton"`` (default): **skeletonization**. Best for sources whose + lines are already clean and separate (vector illustrations, clean posters). + Produces thin, smooth, uniform strokes. +- ``method="minimum"``: **minimum filter**. Best for handwritten, photographed + or low-resolution sources. Keeps the original strokes without breaking them. + + ``minimum`` has two variants, chosen with ``min_true_black``: + + * ``min_true_black=True`` (default) -- the *true-black* criterion is applied, + so only dark, low-chroma pixels count as ink and colored regions are + excluded. Best when the image really is black line work on light paper. + * ``min_true_black=False`` -- no true-black criterion; the minimum filter is + applied to every pixel and Otsu decides. Colored regions are kept as + strokes, then the result is thinned with a distance transform. Best for + colorful posters, where the true-black gate would drop most strokes. + +Resolution note +--------------- +Extraction quality depends directly on the input resolution: the higher the +resolution, the cleaner and more complete the result. For low-resolution +sources prefer ``method="minimum"``. Quick start ----------- >>> from lineartization import extract_lineart_file ->>> extract_lineart_file("手抄报.jpg", "线稿.png") # 骨架化 ->>> extract_lineart_file("手写.jpg", "线稿.png", method="minimum") # 最小值滤波 +>>> extract_lineart_file("poster.jpg", "lineart.png") # skeleton +>>> extract_lineart_file("hand.jpg", "lineart.png", method="minimum") +>>> extract_lineart_file("color.jpg", "lineart.png", +... method="minimum", min_true_black=False) Python API: >>> import cv2 >>> from lineartization import extract_lineart, LineArtConfig ->>> img = cv2.imread("手抄报.jpg") ->>> lineart = extract_lineart(img, LineArtConfig(method="skeleton")) +>>> img = cv2.imread("poster.jpg") +>>> lineart = extract_lineart(img, LineArtConfig(method="minimum")) CLI --- $ lineartization input.jpg output.png -$ lineartization input.jpg output.png --method minimum --verbose +$ lineartization input.jpg output.png --method minimum +$ lineartization input.jpg output.png --method minimum --no-true-black """ from .core import ( LineArtConfig, @@ -36,7 +56,7 @@ from .core import ( save_image, ) -__version__ = "1.6.1" +__version__ = "1.7.0" __author__ = "DVS" __all__ = [ "LineArtConfig", @@ -47,40 +67,88 @@ __all__ = [ "__version__", ] +RESOLUTION_HINT = ( + "Hint: extraction quality depends on the source resolution -- the higher " + "the resolution, the cleaner the result. For low-resolution inputs prefer " + "--method minimum." +) + def main(argv=None): - """命令行入口。""" + """Command-line entry point.""" import argparse from .core import LineArtConfig, extract_lineart_file parser = argparse.ArgumentParser( prog="lineartization", - description="彩色插图/手抄报 -> 黑白线稿 (支持 骨架化 / 最小值滤波 两种模式)", + description="Color illustration / poster -> black-and-white line art " + "(skeletonization or minimum-filter mode).", + epilog=RESOLUTION_HINT, ) - parser.add_argument("input", help="输入图片路径") - parser.add_argument("output", help="输出线稿路径 (.png)") + parser.add_argument("input", help="input image path") + parser.add_argument("output", help="output line-art path (.png)") parser.add_argument("-m", "--method", choices=["skeleton", "minimum"], default="skeleton", - help="提取模式: skeleton=骨架化(清晰原图) / " - "minimum=最小值滤波(手写图)") + help="extraction mode: skeleton=skeletonization (clean " + "sources, default) / minimum=minimum filter " + "(handwritten, photographed, low-resolution)") parser.add_argument("-w", "--width", type=int, default=2, - help="线宽 px (仅 skeleton 模式, 默认2)") - parser.add_argument("--min-mean", type=int, default=130, - help="minimum 模式: 真黑判据 RGB 均值上限 (默认130)") - parser.add_argument("--min-chroma", type=int, default=45, - help="minimum 模式: 真黑判据 色度上限 (默认45)") + help="stroke width in px (skeleton mode only, default 2)") + + # ---- minimum mode: true-black variant (default) ---- + parser.add_argument("--no-true-black", action="store_true", + help="minimum mode: disable the true-black criterion. " + "Colored regions are then kept as strokes and the " + "result is thinned with a distance transform. " + "Worth trying on colorful posters.") + parser.add_argument("--min-mean", type=int, default=180, + help="minimum mode (true-black): RGB mean upper bound " + "(default 180)") + parser.add_argument("--min-chroma", type=int, default=60, + help="minimum mode (true-black): chroma upper bound " + "(default 60)") + parser.add_argument("--min-ratio", type=float, default=1.5, + help="minimum mode (true-black): if the Otsu ink ratio " + "drops below this %%, fall back to adaptive " + "thresholding (default 1.5)") parser.add_argument("--min-kernel", type=int, default=2, - help="minimum 模式: 最小值滤波半径 (默认2)") - parser.add_argument("-d", "--denoise", choices=["strong", "normal", "light", "none"], + help="minimum mode: minimum-filter radius " + "(1-3, default 2; larger = thicker)") + parser.add_argument("-d", "--denoise", + choices=["strong", "normal", "light", "none"], default="strong", - help="minimum 模式降噪档位: strong(默认,普通强降噪)/normal/light/none") + help="minimum mode (true-black) denoise level: " + "strong (default) / normal / light / none") parser.add_argument("--denoise-area", type=int, default=30, - help="minimum 模式: 连通域过滤阈值 (默认30)") - parser.add_argument("--no-green-smoothing", action="store_true", - help="禁用'绿块局部抹平'(非手抄报场景可关闭)") + help="minimum mode (true-black): connected-component " + "removal threshold (default 30)") + + # ---- minimum mode: no-true-black variant ---- + parser.add_argument("--m2-noise-area", type=int, default=20, + help="minimum mode (--no-true-black): drop specks " + "smaller than this (px, default 20)") + parser.add_argument("--m2-short-area", type=int, default=40, + help="minimum mode (--no-true-black): drop fragments " + "smaller than this (px^2, default 40)") + parser.add_argument("--m2-short-len", type=int, default=25, + help="minimum mode (--no-true-black): ...and shorter " + "than this (px, default 25)") + parser.add_argument("--m2-close-k", type=int, default=2, + help="minimum mode (--no-true-black): MORPH_CLOSE " + "kernel before thinning (default 2)") + parser.add_argument("--m2-dist-min", type=float, default=0.5, + help="minimum mode (--no-true-black): keep pixels with " + "distance >= this (default 0.5 -> ~2 px lines)") + + # ---- shared ---- + parser.add_argument("--no-color-smoothing", action="store_true", + help="skeleton mode: disable smoothing of large flat " + "color regions") parser.add_argument("--protect", action="append", default=[], - metavar="x1,x2,y1,y2", help="保护区域(可多次)") - parser.add_argument("-v", "--verbose", action="store_true", help="打印日志") + metavar="x1,x2,y1,y2", + help="protected rectangle, repeatable") + parser.add_argument("-v", "--verbose", action="store_true", + help="print pipeline logs") parser.add_argument("-V", "--version", action="version", version=f"lineartization {__version__}") @@ -90,23 +158,34 @@ def main(argv=None): for spec in args.protect: parts = [int(v) for v in spec.replace(" ", "").split(",")] if len(parts) != 4: - parser.error(f"--protect 格式错误: {spec} (应为 x1,x2,y1,y2)") + parser.error(f"bad --protect value: {spec} (expected x1,x2,y1,y2)") protect_areas.append(tuple(parts)) cfg = LineArtConfig( method=args.method, line_width=max(1, args.width), + min_true_black=not args.no_true_black, min_mean=args.min_mean, min_chroma=args.min_chroma, + min_ratio=args.min_ratio, min_kernel=args.min_kernel, denoise=args.denoise, denoise_area=args.denoise_area, - enable_green_smoothing=not args.no_green_smoothing, + m2_noise_area=args.m2_noise_area, + m2_short_area=args.m2_short_area, + m2_short_len=args.m2_short_len, + m2_close_k=args.m2_close_k, + m2_dist_min=args.m2_dist_min, + enable_color_smoothing=not args.no_color_smoothing, protect_areas=protect_areas, ) out = extract_lineart_file(args.input, args.output, cfg, verbose=args.verbose) - print(f"✅ 线稿已生成 [{args.method}]: {out}") + variant = "" + if args.method == "minimum": + variant = " true-black" if cfg.min_true_black else " no-true-black" + print(f"line art written [{args.method}{variant}]: {out}") + print(RESOLUTION_HINT) return 0 diff --git a/lineartization/__main__.py b/lineartization/__main__.py index 664c5d4..9ac0191 100644 --- a/lineartization/__main__.py +++ b/lineartization/__main__.py @@ -1,4 +1,4 @@ -"""支持 `python -m lineartization` 调用。""" +"""Enable `python -m lineartization`.""" from . import main if __name__ == "__main__": diff --git a/lineartization/core.py b/lineartization/core.py index da8b6f3..2883c69 100644 --- a/lineartization/core.py +++ b/lineartization/core.py @@ -1,23 +1,28 @@ """ lineartization.core ====================== -彩色插图 / 手抄报 -> 黑白线稿 的核心算法。 +Core algorithms that convert a color illustration / poster into +black-and-white line art. -支持两种提取模式(``LineArtConfig.method``): +Two extraction modes are provided (``LineArtConfig.method``): -1. ``"skeleton"`` —— **骨架化模式**(默认) - 适用于"原图本身线条就清晰"的图片(矢量插画、清晰手抄报的放大版)。 - 流程: 区域分析 → 图案/文字提取 → Lee 骨架化 → 去噪/剪倒刺 → 统一线宽 - 特点: 线条细而均匀、美观;但骨架化对"手写粗笔触"会产生分叉/网状。 +1. ``"skeleton"`` -- skeletonization mode (default) + For images whose lines are already clean and well separated + (vector illustrations, clean posters, high-resolution scans). + Pipeline: region analysis -> pattern/text extraction -> Lee + skeletonization -> denoise / spur pruning -> uniform stroke width. + Result: thin, even, aesthetically pleasing lines. Note that + skeletonizing thick handwriting produces branching/webbing. -2. ``"minimum"`` —— **最小值滤波模式** - 适用于"手写 / 像素不足 / 扫描件"类图片(手机拍的手抄报)。 - 流程: RGB 真黑判据 → 最小值滤波(PS 经典提线) → Otsu 纯黑白 → 降噪 - 降噪强度由 ``denoise`` 参数控制: - - ``"strong"`` (默认): 中值 → 开运算 → 连通域过滤(<30px) → 收尾中值 ← 普通强降噪 - - ``"normal"`` : 中值 → 连通域过滤(<20px) → 收尾中值 - - ``"light"`` : 中值 → 只删"极小且方正"噪点 → 收尾中值 - - ``"none"`` : 仅中值滤波 +2. ``"minimum"`` -- minimum-filter mode + For handwritten, low-resolution or scanned sources. + Pipeline: RGB true-black criterion -> minimum filter (the classic + Photoshop line-extraction recipe) -> Otsu -> denoise. + Denoise strength is controlled by ``denoise``: + - ``"strong"`` (default): median -> open -> CC filter (<30px) -> median + - ``"normal"`` : median -> CC filter (<20px) -> median + - ``"light"`` : median -> drop only tiny square specks -> median + - ``"none"`` : median only """ from __future__ import annotations @@ -36,28 +41,28 @@ except ImportError: # pragma: no cover # --------------------------------------------------------------------------- # -# 配置 +# Configuration # --------------------------------------------------------------------------- # @dataclass class LineArtConfig: - """提取线稿的参数配置。""" + """Parameter set for line-art extraction.""" - # ---- 模式 ---- + # ---- mode ---- method: str = "skeleton" # "skeleton" | "minimum" - # ---- 通用: 纸面区(文字背景) ---- + # ---- shared: paper region (background of the text area) ---- paper_v: int = 140 paper_s: int = 60 paper_erode: int = 31 - # ---- 通用: 文字区(精确矩形) ---- + # ---- shared: text region (exact rectangle) ---- ink_v: int = 140 ink_s: int = 60 density_close: int = 41 density_open: int = 61 text_pad: int = 40 - # ---- skeleton 模式参数 ---- + # ---- skeleton mode ---- dark_v: int = 160 morph_open_k: int = 13 adaptive_bs: int = 25 @@ -67,36 +72,55 @@ class LineArtConfig: noise_area: int = 300 spur_maxlen: int = 25 - # ---- minimum 模式参数 ---- - # 真黑判据: RGB 均值 < min_mean 且 色度(最大-最小通道) < min_chroma - min_mean: int = 130 - min_chroma: int = 45 - min_kernel: int = 2 # 最小值滤波半径(1-3) + # ---- minimum mode ---- + # True-black test: mean(RGB) < min_mean AND chroma < min_chroma + min_mean: int = 180 + min_chroma: int = 60 + min_kernel: int = 2 # minimum-filter radius (1-3) min_otsu: bool = True - # 降噪档位: "strong"(默认,普通强降噪) / "normal" / "light" / "none" + min_ratio: float = 1.5 # fallback: if the Otsu ink ratio drops below this, retry with adaptive + # Denoise level: "strong" (default) / "normal" / "light" / "none" denoise: str = "strong" - denoise_area: int = 30 # strong/normal 模式: 连通域过滤阈值(<该值删除) + denoise_area: int = 30 # CC removal threshold < this value is deleted (strong/normal) - # ---- 输出 ---- + # ---- minimum mode: enable the true-black criterion ---- + # True = classic behaviour: intersect the Otsu result with the + # true-black mask, keeping only dark, low-chroma pixels. + # Denoise is controlled by the ``denoise`` level. + # False = alternative: skip the true-black mask and use the Otsu + # result directly (colored regions are kept as strokes). + # Cleanup becomes despeckle + short-fragment removal, + # followed by distance-transform thinning. + min_true_black: bool = True + + # ---- minimum mode: used when min_true_black=False ---- + m2_noise_area: int = 20 # despeckle: drop components smaller than this (px) + m2_short_area: int = 40 # drop fragments: area < this AND length < m2_short_len + m2_short_len: int = 25 # drop fragments: length < this (px) + m2_close_k: int = 2 # MORPH_CLOSE kernel applied before thinning (bridges 1px gaps) + m2_dist_min: float = 0.5 # distance threshold: keep pixels with dist >= this value + + # ---- output ---- line_width: int = 2 - # ---- 保护区域 (x1, x2, y1, y2) ---- + # ---- protected regions (x1, x2, y1, y2) ---- protect_areas: List[Tuple[int, int, int, int]] = field(default_factory=list) - # ---- 绿块局部抹平(手抄报山体) ---- - enable_green_smoothing: bool = True - green_hue_range: Tuple[int, int] = (35, 85) - green_sat_min: int = 60 - green_area_range: Tuple[int, int] = (3000, 25000) + # ---- large flat color regions: local smoothing ---- + # Broad saturated fills are re-extracted from a mean-shift smoothed copy + # so gradients/banding inside them do not produce false edges. + enable_color_smoothing: bool = True + color_sat_min: int = 60 + color_area_range: Tuple[int, int] = (3000, 25000) meanshift_sp: int = 30 meanshift_sr: int = 60 # --------------------------------------------------------------------------- # -# 工具函数 +# Helper functions # --------------------------------------------------------------------------- # def _skel(bin01: np.ndarray) -> np.ndarray: - """骨架化 (优先 Lee, 退化到 Zhang-Suen)。输入/输出均为 0/1。""" + """Skeletonize a 0/1 mask (Lee first, Zhang-Suen as fallback). Input/output are 0/1.""" b = (bin01 > 0).astype(np.uint8) if _HAS_SKIMAGE: return _skel_lee(b > 0).astype(np.uint8) @@ -108,7 +132,7 @@ def _skel(bin01: np.ndarray) -> np.ndarray: def _to_width(mask01: np.ndarray, width: int) -> np.ndarray: - """把 0/1 骨架增粗到目标宽度。""" + """Thicken a 0/1 skeleton to the requested stroke width.""" m = (mask01 > 0).astype(np.uint8) if width <= 1: return m @@ -117,13 +141,13 @@ def _to_width(mask01: np.ndarray, width: int) -> np.ndarray: def load_image(path: str) -> np.ndarray: - """读取图片 (兼容中文路径 / RGBA / 灰度)。返回 BGR uint8。""" + """Read an image (Unicode-path safe / RGBA / grayscale). Returns BGR uint8.""" data = np.fromfile(path, dtype=np.uint8) im = cv2.imdecode(data, cv2.IMREAD_UNCHANGED) if im is None: im = cv2.imread(path, cv2.IMREAD_UNCHANGED) if im is None: - raise FileNotFoundError(f"无法读取图片: {path}") + raise FileNotFoundError(f"cannot read image: {path}") if im.ndim == 3 and im.shape[2] == 4: bgr = im[:, :, :3].astype(np.float32) a = im[:, :, 3:4].astype(np.float32) / 255.0 @@ -136,19 +160,19 @@ def load_image(path: str) -> np.ndarray: def save_image(path: str, img: np.ndarray) -> None: - """保存图片 (兼容中文路径)。""" + """Write an image (Unicode-path safe).""" ext = os.path.splitext(path)[1] or ".png" ok, buf = cv2.imencode(ext, img) if not ok: - raise IOError(f"编码失败: {path}") + raise IOError(f"failed to encode: {path}") buf.tofile(path) # --------------------------------------------------------------------------- # -# minimum 模式 +# minimum mode # --------------------------------------------------------------------------- # def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: - """真黑/深灰判据: RGB 三通道都低、且互相接近(色度小)。""" + """True-black / dark-grey test: all three channels low and close to each other.""" b = bgr[:, :, 0].astype(np.int32) g = bgr[:, :, 1].astype(np.int32) r = bgr[:, :, 2].astype(np.int32) @@ -160,12 +184,12 @@ def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray: - """minimum 模式降噪 (可调档位)。 + """Denoise for minimum mode (adjustable level). - strong (默认): 中值 → 开运算 → 连通域过滤 → 收尾中值 ← "普通强降噪" - normal : 中值 → 连通域过滤 → 收尾中值 - light : 中值 → 只删"极小且方正"噪点 → 收尾中值 - none : 仅中值 + strong (default): median -> open -> CC filter -> final median + normal : median -> CC filter -> final median + light : median -> drop only "tiny and square" specks -> final median + none : median only """ lvl = (cfg.denoise or "strong").lower() m = (mask_bool.astype(np.uint8)) * 255 @@ -173,15 +197,21 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray: if lvl == "none": return cv2.medianBlur(m, 3) > 128 - # ① 中值滤波 + # (1) median filter m = cv2.medianBlur(m, 3) - # ② strong: 开运算(去毛刺) + # (2) strong: opening removes burrs + # NOTE: m here is "stroke = 255 (white)". Running opening (erode first) + # directly on white strokes erases 1-2px lines entirely, leaving all white. + # Correct approach: invert to "stroke = black", open away the small + # isolated specks, then invert back. if lvl == "strong": k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) - m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k2) + inv = 255 - m # stroke=255,bg=0 -> stroke=0,bg=255 + inv = cv2.morphologyEx(inv, cv2.MORPH_OPEN, k2) # drop isolated specks + m = 255 - inv - # ③ 连通域过滤 + # (3) connected-component filtering if lvl in ("strong", "normal"): minA = cfg.denoise_area n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8) @@ -190,7 +220,7 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray: if st[i, cv2.CC_STAT_AREA] >= minA: keep[lab == i] = 255 m = keep - else: # light: 只删"极小且方正"噪点 + else: # light: only drop "tiny and square" specks n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8) keep = np.zeros_like(m) for i in range(1, n): @@ -202,13 +232,22 @@ def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray: keep[lab == i] = 255 m = keep - # ④ 收尾中值 + # (4) final median pass m = cv2.medianBlur(m, 3) - return m > 128 + result = m > 128 + + # (5) Safety net: if denoising removed more than half of the strokes + # (i.e. real lines were deleted by mistake), fall back to the + # pre-denoise result so the output never goes blank. + before_ratio = mask_bool.mean() * 100 + after_ratio = result.mean() * 100 + if after_ratio < before_ratio * 0.5 and before_ratio > 0.5: + return mask_bool.astype(np.uint8) if mask_bool.dtype != bool else mask_bool + return result def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: - """最小值滤波提线 (PS 经典流程) + 真黑判据 + 可调降噪。""" + """Minimum-filter line extraction (classic Photoshop recipe) + true-black + denoise.""" gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) black_zone = _true_black_mask(bgr, cfg) @@ -226,12 +265,93 @@ def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: _, line = cv2.threshold(result, 128, 255, cv2.THRESH_BINARY) mask = (line < 128) & black_zone + + # Fallback: if the Otsu result is too sparse (too few strokes, typically + # because a white background with very thin lines defeats Otsu), re-extract + # with an adaptive threshold and keep whichever version has more strokes. + ratio = mask.mean() * 100 + if ratio < cfg.min_ratio: + gray_u8 = np.clip(gray, 0, 255).astype(np.uint8) + at = cv2.adaptiveThreshold(gray_u8, 255, cv2.ADAPTIVE_THRESH_MEAN_C, + cv2.THRESH_BINARY_INV, 25, 10) + fallback = (at > 0) & black_zone + if fallback.mean() * 100 > ratio: + mask = fallback + mask = _denoise_minimum(mask, cfg) return mask.astype(np.uint8) +def _minimum_filter_lineart_nb(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray: + """minimum mode (min_true_black=False): no true-black mask + distance-transform thinning. + + Differences from the classic minimum mode: + 1. no true-black mask; the Otsu result is used directly, so colored + regions are kept as strokes + 2. cleanup becomes: despeckle (< m2_noise_area) -> drop short fragments + (area < m2_short_area AND length < m2_short_len) + 3. morphological close (bridges 1px gaps) -> distance-transform thinning, + keeping only pixels with dist >= m2_dist_min + + Distance transform instead of skeletonization: skeletonization collapses + strokes to a 1px medial axis, which loses glyph detail and branches at + thick stroke crossings. A distance transform only shaves from the outside, + preserving stroke topology and glyph shape, so the lines stay thin and even. + """ + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) + + # ---- minimum filter (same as the classic mode) ---- + k = max(1, cfg.min_kernel) + ke = cv2.getStructuringElement(cv2.MORPH_RECT, (k * 2 + 1, k * 2 + 1)) + L = gray + R = 255.0 - L + M = cv2.erode(R.astype(np.uint8), ke).astype(np.float32) + result = np.clip(L / (255.0 - M + 1e-6) * 255.0, 0, 255).astype(np.uint8) + + if cfg.min_otsu: + _, line = cv2.threshold(result, 0, 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU) + else: + _, line = cv2.threshold(result, 128, 255, cv2.THRESH_BINARY) + + # ---- NOTE: deliberately NOT intersected with the true-black mask ---- + mask = (line < 128).astype(np.uint8) + + # ---- despeckle (< m2_noise_area) ---- + n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8) + keep = np.zeros_like(mask) + for i in range(1, n): + if st[i, cv2.CC_STAT_AREA] >= cfg.m2_noise_area: + keep[lab == i] = 1 + mask = keep + + # ---- drop short fragments (area < m2_short_area AND length < m2_short_len) ---- + n, lab, st, _ = cv2.connectedComponentsWithStats(mask, 8) + keep = np.zeros_like(mask) + for i in range(1, n): + x, y, w, h, a = st[i] + if a >= cfg.m2_short_area or max(w, h) >= cfg.m2_short_len: + keep[lab == i] = 1 + mask = keep + + # ---- morphological close + distance-transform thinning ---- + if mask.any(): + closed = cv2.morphologyEx( + mask * 255, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, + (max(1, cfg.m2_close_k),) * 2)) + dist = cv2.distanceTransform((closed > 0).astype(np.uint8), + cv2.DIST_L2, 5) + thinned = (dist >= cfg.m2_dist_min).astype(np.uint8) + if not thinned.any(): # safety net: never thin every stroke away + thinned = mask + mask = thinned + + return mask.astype(np.uint8) + + # --------------------------------------------------------------------------- # -# skeleton 模式 +# skeleton mode # --------------------------------------------------------------------------- # def _paper_mask(hsv, cfg): s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32) @@ -262,16 +382,21 @@ def _text_rect(bgr, cfg): return tz -def _green_zones(bgr, cfg): - h, w = bgr.shape[:2] +def _color_zones(bgr, cfg): + """Locate large saturated color regions (region-agnostic). + + Instead of keying on one specific hue (which only matched the green hills + of one particular poster), this selects *any* strongly saturated area of a + plausible size. The result decides where lines are re-extracted from a + mean-shift smoothed copy, so broad flat color fills do not bloom into + false edges. + """ hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) - hue = hsv[:, :, 0].astype(np.int32); sat = hsv[:, :, 1].astype(np.int32) - lo, hi = cfg.green_hue_range - green = (hue > lo) & (hue < hi) & (sat > cfg.green_sat_min) - green[:, w // 2:] = False - n, lab, st, _ = cv2.connectedComponentsWithStats(green.astype(np.uint8), 8) - amin, amax = cfg.green_area_range - zones = np.zeros_like(green) + sat = hsv[:, :, 1].astype(np.int32) + solid_color = (sat > cfg.color_sat_min) + n, lab, st, _ = cv2.connectedComponentsWithStats(solid_color.astype(np.uint8), 8) + amin, amax = cfg.color_area_range + zones = np.zeros_like(solid_color) for i in range(1, n): if amin <= st[i, cv2.CC_STAT_AREA] <= amax: zones[lab == i] = 1 @@ -366,13 +491,13 @@ def _extract_skeleton(bgr, cfg): for (x1, x2, y1, y2) in cfg.protect_areas: protect[y1:y2, x1:x2] = True zones = border = np.zeros_like(pz) - if cfg.enable_green_smoothing: - zones, border = _green_zones(bgr, cfg) + if cfg.enable_color_smoothing: + zones, border = _color_zones(bgr, cfg) smoothed = (cv2.pyrMeanShiftFiltering(bgr, cfg.meanshift_sp, cfg.meanshift_sr, maxLevel=2) - if cfg.enable_green_smoothing else bgr) + if cfg.enable_color_smoothing else bgr) lines_fine = _extract_lines(bgr, pz, border, cfg, False) - if cfg.enable_green_smoothing: + if cfg.enable_color_smoothing: lines_smooth = _extract_lines(smoothed, pz, border, cfg, True) lines = np.where(zones, lines_smooth, lines_fine).astype(np.uint8) else: @@ -411,20 +536,20 @@ def _extract_skeleton(bgr, cfg): # --------------------------------------------------------------------------- # -# 主入口 +# Public entry points # --------------------------------------------------------------------------- # def extract_lineart(bgr: np.ndarray, cfg: Optional[LineArtConfig] = None, *, verbose: bool = False) -> np.ndarray: - """从 BGR 图像提取线稿。 + """Extract line art from a BGR image. Args: - bgr: 输入图像 (OpenCV BGR, uint8)。 - cfg: 参数配置。``method`` = "skeleton"|"minimum"。 - verbose: 打印日志。 + bgr: input image (OpenCV BGR, uint8). + cfg: parameter set. ``method`` = "skeleton" | "minimum". + verbose: print pipeline logs. Returns: - 白底黑线线稿 (uint8, 0/255)。 + White-background / black-line image (uint8, 0/255). """ cfg = cfg or LineArtConfig() @@ -433,18 +558,23 @@ def extract_lineart(bgr: np.ndarray, method = (cfg.method or "skeleton").lower() if method not in ("skeleton", "minimum"): - raise ValueError(f"未知 method: {cfg.method!r}") + raise ValueError(f"unknown method: {cfg.method!r}") if method == "minimum": - _log(f"[lineart] method=minimum denoise={cfg.denoise}") - mask = _minimum_filter_lineart(bgr, cfg) + if cfg.min_true_black: + _log(f"[lineart] method=minimum true_black=True denoise={cfg.denoise}") + mask = _minimum_filter_lineart(bgr, cfg) + else: + _log(f"[lineart] method=minimum true_black=False " + f"dist_min={cfg.m2_dist_min}") + mask = _minimum_filter_lineart_nb(bgr, cfg) out = np.where(mask > 0, 0, 255).astype(np.uint8) - _log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%") + _log(f"[lineart] done, black ratio {(out < 128).mean()*100:.2f}%") return out _log("[lineart] method=skeleton") out = _extract_skeleton(bgr, cfg) - _log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%") + _log(f"[lineart] done, black ratio {(out < 128).mean()*100:.2f}%") return out @@ -452,17 +582,17 @@ def extract_lineart_file(src: str, dst: str, cfg: Optional[LineArtConfig] = None, *, method: Optional[str] = None, verbose: bool = False) -> str: - """从文件提取线稿并保存。 + """Extract line art from a file and save it. Args: - src: 输入图片路径。 - dst: 输出线稿路径 (.png)。 - cfg: 参数配置。None 使用默认。 - method: 快捷覆盖模式 ("skeleton"/"minimum")。 - verbose: 打印日志。 + src: input image path. + dst: output line-art path (.png). + cfg: parameter set. None uses the defaults. + method: convenience override for the mode ("skeleton" / "minimum"). + verbose: print pipeline logs. Returns: - 输出文件路径。 + The output file path. """ if cfg is None: cfg = LineArtConfig() diff --git a/pyproject.toml b/pyproject.toml index 35d9f6b..aaaa0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "lineartization" -version = "1.6.1" -description = "彩色插图/手抄报 一键转换为黑白线稿 (汉字清晰、线条连贯、粗细统一)" +version = "1.7.0" +description = "Convert color illustrations and posters into clean black-and-white line art." readme = "README.md" requires-python = ">=3.8" license = { text = "MIT" } @@ -14,7 +14,7 @@ authors = [ ] keywords = [ "lineart", "line-art", "sketch", "skeleton", "thinning", - "image-processing", "opencv", "手抄报", "线稿", "提取线稿", + "image-processing", "opencv", "poster", "line-extraction", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_core.py b/tests/test_core.py index b5a4793..535a987 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,7 +1,7 @@ """ -lineart-extractor 单元测试 -========================== -运行: pytest tests/ -v +lineartization unit tests +========================= +Run: pytest tests/ -v """ import os import sys @@ -21,36 +21,37 @@ from lineartization import ( # --------------------------------------------------------------------------- # -# 测试用图: 合成"白底 + 黑字 + 彩色块" +# Test fixture: synthetic "white background + black strokes + color blocks" # --------------------------------------------------------------------------- # @pytest.fixture def sample_image(): - """构造一张 400x600 的合成图: 白底 + 黑色矩形(模拟文字) + 彩色块。""" + """Build a 400x600 synthetic image: white bg + black strokes + colors.""" + import cv2 + img = np.full((400, 600, 3), 255, np.uint8) - # 中央"文字区": 密集小黑块 + # Central "text area": dense small black blocks rng = np.random.default_rng(42) for _ in range(120): x = rng.integers(180, 420) y = rng.integers(150, 250) img[y:y + 4, x:x + 4] = 0 - # 左侧彩色块(模拟山体) - img[60:160, 20:180] = (60, 160, 80) # 绿 - img[160:220, 20:180] = (80, 120, 200) # 偏蓝 + # Left color blocks + img[60:160, 20:180] = (60, 160, 80) # green + img[160:220, 20:180] = (80, 120, 200) # bluish - # 右侧一个红色圆(模拟灯笼) - import cv2 + # A red circle on the right cv2.circle(img, (500, 120), 40, (40, 40, 200), 3) return img # --------------------------------------------------------------------------- # -# 测试 +# I/O # --------------------------------------------------------------------------- # def test_load_save_roundtrip(tmp_path, sample_image): - """读写往返一致。""" + """Save then load must round-trip.""" p = tmp_path / "in.png" save_image(str(p), sample_image) loaded = load_image(str(p)) @@ -58,72 +59,140 @@ def test_load_save_roundtrip(tmp_path, sample_image): assert np.allclose(loaded, sample_image, atol=2) -def test_extract_returns_binary(sample_image): - """输出必须是二值(0/255)白底黑线。""" - out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False)) +def test_load_missing_file(): + """Loading a non-existent file must raise.""" + with pytest.raises((FileNotFoundError, Exception)): + load_image("___no_such_file___.png") + + +def test_unicode_path(tmp_path, sample_image): + """Unicode file paths must work.""" + src = tmp_path / "image_unicode.png" + dst = tmp_path / "output_unicode.png" + save_image(str(src), sample_image) + out = extract_lineart_file(str(src), str(dst)) + assert os.path.exists(out) + + +def test_file_interface(tmp_path, sample_image): + """extract_lineart_file must work and return the output path.""" + src = tmp_path / "src.png" + dst = tmp_path / "dst.png" + save_image(str(src), sample_image) + result = extract_lineart_file(str(src), str(dst)) + assert os.path.exists(result) + assert result == str(dst) + + +# --------------------------------------------------------------------------- # +# Output contract +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("method", ["skeleton", "minimum"]) +def test_extract_returns_binary(sample_image, method): + """Output must be binary (0/255), white background, black lines.""" + out = extract_lineart(sample_image, LineArtConfig(method=method)) assert out.dtype == np.uint8 assert out.ndim == 2 - uniq = np.unique(out) - assert set(uniq.tolist()).issubset({0, 255}) + assert set(np.unique(out).tolist()).issubset({0, 255}) assert out.shape == sample_image.shape[:2] -def test_extract_has_content(sample_image): - """输出不能空白、也不能全黑。""" - out = extract_lineart(sample_image, LineArtConfig(enable_green_smoothing=False)) - black_ratio = (out < 128).mean() * 100 - assert 0.1 < black_ratio < 90.0 +@pytest.mark.parametrize("method", ["skeleton", "minimum"]) +def test_extract_has_content(sample_image, method): + """Output must be neither empty nor fully black.""" + out = extract_lineart(sample_image, LineArtConfig(method=method)) + ratio = (out < 128).mean() * 100 + assert 0.1 < ratio < 90.0 +def test_unknown_method_raises(sample_image): + """An unknown method must raise ValueError.""" + with pytest.raises(ValueError): + extract_lineart(sample_image, LineArtConfig(method="bogus")) + + +# --------------------------------------------------------------------------- # +# minimum mode: the two variants +# --------------------------------------------------------------------------- # +def test_minimum_true_black_default_is_on(sample_image): + """min_true_black must default to True (the 1.6.1 behaviour).""" + assert LineArtConfig().min_true_black is True + assert LineArtConfig(method="minimum").min_true_black is True + + +def test_minimum_both_variants_run(sample_image): + """Both minimum variants must produce a valid binary image.""" + for flag in (True, False): + cfg = LineArtConfig(method="minimum", min_true_black=flag) + out = extract_lineart(sample_image, cfg) + assert out.dtype == np.uint8 + assert set(np.unique(out).tolist()).issubset({0, 255}) + assert (out < 128).mean() > 0 + + +def test_minimum_variants_differ(sample_image): + """The true-black and no-true-black variants must not be identical. + + The fixture has saturated color blocks, which the true-black gate rejects, + so the two paths must produce measurably different masks. + """ + on = extract_lineart(sample_image, + LineArtConfig(method="minimum", min_true_black=True)) + off = extract_lineart(sample_image, + LineArtConfig(method="minimum", min_true_black=False)) + assert not np.array_equal(on, off) + + +def test_denoise_levels(sample_image): + """Every denoise level must run (true-black variant).""" + for lvl in ("strong", "normal", "light", "none"): + cfg = LineArtConfig(method="minimum", denoise=lvl, min_true_black=True) + out = extract_lineart(sample_image, cfg) + assert (out < 128).mean() > 0 + + +def test_min_true_black_thresholds_apply(sample_image): + """Tightening the true-black bounds must not increase the ink coverage.""" + loose = extract_lineart( + sample_image, + LineArtConfig(method="minimum", min_true_black=True, + min_mean=255, min_chroma=255)) + tight = extract_lineart( + sample_image, + LineArtConfig(method="minimum", min_true_black=True, + min_mean=10, min_chroma=5)) + assert (tight < 128).mean() <= (loose < 128).mean() + + +def test_min2_options_are_accepted(sample_image): + """The no-true-black tuning options must be accepted.""" + cfg = LineArtConfig(method="minimum", min_true_black=False, + m2_noise_area=5, m2_short_area=10, m2_short_len=8, + m2_close_k=3, m2_dist_min=0.5) + out = extract_lineart(sample_image, cfg) + assert (out < 128).mean() > 0 + + +# --------------------------------------------------------------------------- # +# skeleton mode +# --------------------------------------------------------------------------- # def test_line_width_effect(sample_image): - """线宽参数应影响黑占比(越粗越多)。""" - cfg1 = LineArtConfig(line_width=1, enable_green_smoothing=False) - cfg3 = LineArtConfig(line_width=3, enable_green_smoothing=False) - r1 = (extract_lineart(sample_image, cfg1) < 128).mean() - r3 = (extract_lineart(sample_image, cfg3) < 128).mean() + """Stroke width must affect the black ratio (thicker = more black).""" + r1 = (extract_lineart(sample_image, LineArtConfig(line_width=1)) < 128).mean() + r3 = (extract_lineart(sample_image, LineArtConfig(line_width=3)) < 128).mean() assert r3 > r1 -def test_green_smoothing_toggle(sample_image): - """绿块抹平开关都应能正常出图。""" +def test_color_smoothing_toggle(sample_image): + """Color-region smoothing must run both on and off.""" for flag in (True, False): - cfg = LineArtConfig(enable_green_smoothing=flag) + cfg = LineArtConfig(enable_color_smoothing=flag) out = extract_lineart(sample_image, cfg) assert (out < 128).mean() > 0 def test_protect_areas(sample_image): - """保护区域内的线条不应被删。""" - cfg = LineArtConfig( - enable_green_smoothing=False, - protect_areas=[(0, 200, 0, 400)], - ) + """Lines inside a protected region must survive pruning.""" + cfg = LineArtConfig(protect_areas=[(0, 200, 0, 400)]) out = extract_lineart(sample_image, cfg) assert (out < 128).sum() > 0 - - -def test_file_interface(tmp_path, sample_image): - """extract_lineart_file 接口正常。""" - src = tmp_path / "src.png" - dst = tmp_path / "dst.png" - save_image(str(src), sample_image) - result = extract_lineart_file(str(src), str(dst), - LineArtConfig(enable_green_smoothing=False)) - assert os.path.exists(result) - assert result == str(dst) - - -def test_load_missing_file(): - """读取不存在的文件应抛异常。""" - with pytest.raises((FileNotFoundError, Exception)): - load_image("___no_such_file___.png") - - -def test_chinese_path(tmp_path, sample_image): - """中文路径应正常工作。""" - src = tmp_path / "中文图片.png" - dst = tmp_path / "输出_láthair.png" - save_image(str(src), sample_image) - out = extract_lineart_file(str(src), str(dst), - LineArtConfig(enable_green_smoothing=False)) - assert os.path.exists(out)