release: lineartization v1.6.1 - color/handwritten image to line-art (skeleton & minimum modes, tunable denoise)
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# 测试 / 缓存
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# 输出
|
||||||
|
output/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 系统
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 DVS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include pyproject.toml
|
||||||
|
recursive-include lineartization *.py *.typed
|
||||||
|
recursive-include tests *.py
|
||||||
|
recursive-include examples *.py
|
||||||
|
global-exclude __pycache__ *.py[cod] *.egg-info
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
lineart-extractor 使用示例
|
||||||
|
==========================
|
||||||
|
演示三种用法: 一行函数 / 自定义配置 / 直接处理 ndarray
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from lineartization import (
|
||||||
|
LineArtConfig,
|
||||||
|
extract_lineart,
|
||||||
|
extract_lineart_file,
|
||||||
|
load_image,
|
||||||
|
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():
|
||||||
|
"""① 一行搞定"""
|
||||||
|
print("=== 示例1: 一行调用 ===")
|
||||||
|
extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "simple.png"))
|
||||||
|
print(" 已生成 simple.png")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_config():
|
||||||
|
"""② 自定义配置 (线宽 + 保护华表区)"""
|
||||||
|
print("=== 示例2: 自定义配置 ===")
|
||||||
|
cfg = LineArtConfig(
|
||||||
|
method="minimum",
|
||||||
|
denoise="strong",
|
||||||
|
line_width=2, # 统一线宽 2px
|
||||||
|
enable_green_smoothing=True, # 手抄报绿块抹平
|
||||||
|
protect_areas=[(120, 220, 940, 1050)], # 保护"华表"区域
|
||||||
|
)
|
||||||
|
extract_lineart_file(DEMO_SRC, os.path.join(DEMO_OUT_DIR, "configured.png"),
|
||||||
|
cfg, verbose=True)
|
||||||
|
print(" 已生成 configured.png")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_ndarray():
|
||||||
|
"""③ 直接处理 ndarray (可嵌入你自己的流水线)"""
|
||||||
|
print("=== 示例3: ndarray 处理 ===")
|
||||||
|
img = load_image(DEMO_SRC)
|
||||||
|
print(f" 输入尺寸: {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")
|
||||||
|
|
||||||
|
|
||||||
|
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全部示例完成 ✔")
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
lineartization
|
||||||
|
=================
|
||||||
|
把彩色插图 / 手抄报 一键转换为黑白线稿。
|
||||||
|
|
||||||
|
两种模式
|
||||||
|
--------
|
||||||
|
- ``method="skeleton"`` (默认):**骨架化**。适合"原图线条清晰"的图片
|
||||||
|
(矢量插画、清晰手抄报),线条细而均匀、更美观。
|
||||||
|
- ``method="minimum"`` :**最小值滤波**。适合"手写 / 手机拍 / 像素不足"的图,
|
||||||
|
保留原笔触、不断线,属"基本可用"级别。
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
-----------
|
||||||
|
>>> from lineartization import extract_lineart_file
|
||||||
|
>>> extract_lineart_file("手抄报.jpg", "线稿.png") # 骨架化
|
||||||
|
>>> extract_lineart_file("手写.jpg", "线稿.png", method="minimum") # 最小值滤波
|
||||||
|
|
||||||
|
Python API:
|
||||||
|
|
||||||
|
>>> import cv2
|
||||||
|
>>> from lineartization import extract_lineart, LineArtConfig
|
||||||
|
>>> img = cv2.imread("手抄报.jpg")
|
||||||
|
>>> lineart = extract_lineart(img, LineArtConfig(method="skeleton"))
|
||||||
|
|
||||||
|
CLI
|
||||||
|
---
|
||||||
|
$ lineartization input.jpg output.png
|
||||||
|
$ lineartization input.jpg output.png --method minimum --verbose
|
||||||
|
"""
|
||||||
|
from .core import (
|
||||||
|
LineArtConfig,
|
||||||
|
extract_lineart,
|
||||||
|
extract_lineart_file,
|
||||||
|
load_image,
|
||||||
|
save_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__ = "1.6.1"
|
||||||
|
__author__ = "DVS"
|
||||||
|
__all__ = [
|
||||||
|
"LineArtConfig",
|
||||||
|
"extract_lineart",
|
||||||
|
"extract_lineart_file",
|
||||||
|
"load_image",
|
||||||
|
"save_image",
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
"""命令行入口。"""
|
||||||
|
import argparse
|
||||||
|
from .core import LineArtConfig, extract_lineart_file
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="lineartization",
|
||||||
|
description="彩色插图/手抄报 -> 黑白线稿 (支持 骨架化 / 最小值滤波 两种模式)",
|
||||||
|
)
|
||||||
|
parser.add_argument("input", help="输入图片路径")
|
||||||
|
parser.add_argument("output", help="输出线稿路径 (.png)")
|
||||||
|
parser.add_argument("-m", "--method", choices=["skeleton", "minimum"],
|
||||||
|
default="skeleton",
|
||||||
|
help="提取模式: skeleton=骨架化(清晰原图) / "
|
||||||
|
"minimum=最小值滤波(手写图)")
|
||||||
|
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)")
|
||||||
|
parser.add_argument("--min-kernel", type=int, default=2,
|
||||||
|
help="minimum 模式: 最小值滤波半径 (默认2)")
|
||||||
|
parser.add_argument("-d", "--denoise", choices=["strong", "normal", "light", "none"],
|
||||||
|
default="strong",
|
||||||
|
help="minimum 模式降噪档位: strong(默认,普通强降噪)/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="禁用'绿块局部抹平'(非手抄报场景可关闭)")
|
||||||
|
parser.add_argument("--protect", action="append", default=[],
|
||||||
|
metavar="x1,x2,y1,y2", help="保护区域(可多次)")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true", help="打印日志")
|
||||||
|
parser.add_argument("-V", "--version", action="version",
|
||||||
|
version=f"lineartization {__version__}")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
protect_areas = []
|
||||||
|
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)")
|
||||||
|
protect_areas.append(tuple(parts))
|
||||||
|
|
||||||
|
cfg = LineArtConfig(
|
||||||
|
method=args.method,
|
||||||
|
line_width=max(1, args.width),
|
||||||
|
min_mean=args.min_mean,
|
||||||
|
min_chroma=args.min_chroma,
|
||||||
|
min_kernel=args.min_kernel,
|
||||||
|
denoise=args.denoise,
|
||||||
|
denoise_area=args.denoise_area,
|
||||||
|
enable_green_smoothing=not args.no_green_smoothing,
|
||||||
|
protect_areas=protect_areas,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = extract_lineart_file(args.input, args.output, cfg, verbose=args.verbose)
|
||||||
|
print(f"✅ 线稿已生成 [{args.method}]: {out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""支持 `python -m lineartization` 调用。"""
|
||||||
|
from . import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""
|
||||||
|
lineartization.core
|
||||||
|
======================
|
||||||
|
彩色插图 / 手抄报 -> 黑白线稿 的核心算法。
|
||||||
|
|
||||||
|
支持两种提取模式(``LineArtConfig.method``):
|
||||||
|
|
||||||
|
1. ``"skeleton"`` —— **骨架化模式**(默认)
|
||||||
|
适用于"原图本身线条就清晰"的图片(矢量插画、清晰手抄报的放大版)。
|
||||||
|
流程: 区域分析 → 图案/文字提取 → Lee 骨架化 → 去噪/剪倒刺 → 统一线宽
|
||||||
|
特点: 线条细而均匀、美观;但骨架化对"手写粗笔触"会产生分叉/网状。
|
||||||
|
|
||||||
|
2. ``"minimum"`` —— **最小值滤波模式**
|
||||||
|
适用于"手写 / 像素不足 / 扫描件"类图片(手机拍的手抄报)。
|
||||||
|
流程: RGB 真黑判据 → 最小值滤波(PS 经典提线) → Otsu 纯黑白 → 降噪
|
||||||
|
降噪强度由 ``denoise`` 参数控制:
|
||||||
|
- ``"strong"`` (默认): 中值 → 开运算 → 连通域过滤(<30px) → 收尾中值 ← 普通强降噪
|
||||||
|
- ``"normal"`` : 中值 → 连通域过滤(<20px) → 收尾中值
|
||||||
|
- ``"light"`` : 中值 → 只删"极小且方正"噪点 → 收尾中值
|
||||||
|
- ``"none"`` : 仅中值滤波
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
from skimage.morphology import skeletonize as _skel_lee
|
||||||
|
_HAS_SKIMAGE = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_HAS_SKIMAGE = False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 配置
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass
|
||||||
|
class LineArtConfig:
|
||||||
|
"""提取线稿的参数配置。"""
|
||||||
|
|
||||||
|
# ---- 模式 ----
|
||||||
|
method: str = "skeleton" # "skeleton" | "minimum"
|
||||||
|
|
||||||
|
# ---- 通用: 纸面区(文字背景) ----
|
||||||
|
paper_v: int = 140
|
||||||
|
paper_s: int = 60
|
||||||
|
paper_erode: int = 31
|
||||||
|
|
||||||
|
# ---- 通用: 文字区(精确矩形) ----
|
||||||
|
ink_v: int = 140
|
||||||
|
ink_s: int = 60
|
||||||
|
density_close: int = 41
|
||||||
|
density_open: int = 61
|
||||||
|
text_pad: int = 40
|
||||||
|
|
||||||
|
# ---- skeleton 模式参数 ----
|
||||||
|
dark_v: int = 160
|
||||||
|
morph_open_k: int = 13
|
||||||
|
adaptive_bs: int = 25
|
||||||
|
adaptive_c: int = 19
|
||||||
|
noise_sk_len: int = 25
|
||||||
|
noise_branch: int = 8
|
||||||
|
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)
|
||||||
|
min_otsu: bool = True
|
||||||
|
# 降噪档位: "strong"(默认,普通强降噪) / "normal" / "light" / "none"
|
||||||
|
denoise: str = "strong"
|
||||||
|
denoise_area: int = 30 # strong/normal 模式: 连通域过滤阈值(<该值删除)
|
||||||
|
|
||||||
|
# ---- 输出 ----
|
||||||
|
line_width: int = 2
|
||||||
|
|
||||||
|
# ---- 保护区域 (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)
|
||||||
|
meanshift_sp: int = 30
|
||||||
|
meanshift_sr: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 工具函数
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _skel(bin01: np.ndarray) -> np.ndarray:
|
||||||
|
"""骨架化 (优先 Lee, 退化到 Zhang-Suen)。输入/输出均为 0/1。"""
|
||||||
|
b = (bin01 > 0).astype(np.uint8)
|
||||||
|
if _HAS_SKIMAGE:
|
||||||
|
return _skel_lee(b > 0).astype(np.uint8)
|
||||||
|
try:
|
||||||
|
import cv2.ximgproc as xi
|
||||||
|
return (xi.thinning(b * 255) > 128).astype(np.uint8)
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _to_width(mask01: np.ndarray, width: int) -> np.ndarray:
|
||||||
|
"""把 0/1 骨架增粗到目标宽度。"""
|
||||||
|
m = (mask01 > 0).astype(np.uint8)
|
||||||
|
if width <= 1:
|
||||||
|
return m
|
||||||
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * (width - 1) + 1,) * 2)
|
||||||
|
return cv2.dilate(m, k, iterations=1)
|
||||||
|
|
||||||
|
|
||||||
|
def load_image(path: str) -> np.ndarray:
|
||||||
|
"""读取图片 (兼容中文路径 / RGBA / 灰度)。返回 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}")
|
||||||
|
if im.ndim == 3 and im.shape[2] == 4:
|
||||||
|
bgr = im[:, :, :3].astype(np.float32)
|
||||||
|
a = im[:, :, 3:4].astype(np.float32) / 255.0
|
||||||
|
im = (bgr * a + 255 * (1 - a)).astype(np.uint8)
|
||||||
|
elif im.ndim == 3:
|
||||||
|
im = im[:, :, :3]
|
||||||
|
else:
|
||||||
|
im = cv2.cvtColor(im, cv2.COLOR_GRAY2BGR)
|
||||||
|
return im
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(path: str, img: np.ndarray) -> None:
|
||||||
|
"""保存图片 (兼容中文路径)。"""
|
||||||
|
ext = os.path.splitext(path)[1] or ".png"
|
||||||
|
ok, buf = cv2.imencode(ext, img)
|
||||||
|
if not ok:
|
||||||
|
raise IOError(f"编码失败: {path}")
|
||||||
|
buf.tofile(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# minimum 模式
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _true_black_mask(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
|
"""真黑/深灰判据: RGB 三通道都低、且互相接近(色度小)。"""
|
||||||
|
b = bgr[:, :, 0].astype(np.int32)
|
||||||
|
g = bgr[:, :, 1].astype(np.int32)
|
||||||
|
r = bgr[:, :, 2].astype(np.int32)
|
||||||
|
vmax = np.maximum(np.maximum(r, g), b)
|
||||||
|
vmin = np.minimum(np.minimum(r, g), b)
|
||||||
|
chroma = vmax - vmin
|
||||||
|
mean = (r + g + b) / 3.0
|
||||||
|
return (mean < cfg.min_mean) & (chroma < cfg.min_chroma)
|
||||||
|
|
||||||
|
|
||||||
|
def _denoise_minimum(mask_bool: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
|
"""minimum 模式降噪 (可调档位)。
|
||||||
|
|
||||||
|
strong (默认): 中值 → 开运算 → 连通域过滤 → 收尾中值 ← "普通强降噪"
|
||||||
|
normal : 中值 → 连通域过滤 → 收尾中值
|
||||||
|
light : 中值 → 只删"极小且方正"噪点 → 收尾中值
|
||||||
|
none : 仅中值
|
||||||
|
"""
|
||||||
|
lvl = (cfg.denoise or "strong").lower()
|
||||||
|
m = (mask_bool.astype(np.uint8)) * 255
|
||||||
|
|
||||||
|
if lvl == "none":
|
||||||
|
return cv2.medianBlur(m, 3) > 128
|
||||||
|
|
||||||
|
# ① 中值滤波
|
||||||
|
m = cv2.medianBlur(m, 3)
|
||||||
|
|
||||||
|
# ② strong: 开运算(去毛刺)
|
||||||
|
if lvl == "strong":
|
||||||
|
k2 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
|
||||||
|
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k2)
|
||||||
|
|
||||||
|
# ③ 连通域过滤
|
||||||
|
if lvl in ("strong", "normal"):
|
||||||
|
minA = cfg.denoise_area
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
||||||
|
keep = np.zeros_like(m)
|
||||||
|
for i in range(1, n):
|
||||||
|
if st[i, cv2.CC_STAT_AREA] >= minA:
|
||||||
|
keep[lab == i] = 255
|
||||||
|
m = keep
|
||||||
|
else: # light: 只删"极小且方正"噪点
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats((m > 0).astype(np.uint8), 8)
|
||||||
|
keep = np.zeros_like(m)
|
||||||
|
for i in range(1, n):
|
||||||
|
x, y, w, h, a = st[i]
|
||||||
|
elong = max(w, h) / max(1, min(w, h))
|
||||||
|
fill = a / max(1, w * h)
|
||||||
|
if a < 10 and fill >= 0.8 and elong < 1.8:
|
||||||
|
continue
|
||||||
|
keep[lab == i] = 255
|
||||||
|
m = keep
|
||||||
|
|
||||||
|
# ④ 收尾中值
|
||||||
|
m = cv2.medianBlur(m, 3)
|
||||||
|
return m > 128
|
||||||
|
|
||||||
|
|
||||||
|
def _minimum_filter_lineart(bgr: np.ndarray, cfg: LineArtConfig) -> np.ndarray:
|
||||||
|
"""最小值滤波提线 (PS 经典流程) + 真黑判据 + 可调降噪。"""
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||||
|
black_zone = _true_black_mask(bgr, cfg)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
mask = (line < 128) & black_zone
|
||||||
|
mask = _denoise_minimum(mask, cfg)
|
||||||
|
return mask.astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# skeleton 模式
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _paper_mask(hsv, cfg):
|
||||||
|
s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32)
|
||||||
|
paper = (v > cfg.paper_v) & (s < cfg.paper_s)
|
||||||
|
pb = cv2.morphologyEx(paper.astype(np.uint8) * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21)), 3)
|
||||||
|
er = cfg.paper_erode
|
||||||
|
return cv2.erode(pb, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (er, er)), 1) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _text_rect(bgr, cfg):
|
||||||
|
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||||
|
s = hsv[:, :, 1].astype(np.int32); v = hsv[:, :, 2].astype(np.int32)
|
||||||
|
ink = ((v < cfg.ink_v) & (s < cfg.ink_s)).astype(np.uint8)
|
||||||
|
dense = cv2.morphologyEx(ink * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.density_close,)*2))
|
||||||
|
dense = cv2.morphologyEx(dense, cv2.MORPH_OPEN,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.density_open,)*2))
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats((dense > 0).astype(np.uint8), 8)
|
||||||
|
if n <= 1:
|
||||||
|
return np.zeros(bgr.shape[:2], bool)
|
||||||
|
biggest = max(range(1, n), key=lambda i: st[i, cv2.CC_STAT_AREA])
|
||||||
|
x, y, w, h, _ = st[biggest]
|
||||||
|
p = cfg.text_pad
|
||||||
|
x1, y1 = max(0, x - p), max(0, y - p)
|
||||||
|
x2, y2 = min(bgr.shape[1], x + w + p), min(bgr.shape[0], y + h + p)
|
||||||
|
tz = np.zeros(bgr.shape[:2], bool); tz[y1:y2, x1:x2] = True
|
||||||
|
return tz
|
||||||
|
|
||||||
|
|
||||||
|
def _green_zones(bgr, cfg):
|
||||||
|
h, w = bgr.shape[:2]
|
||||||
|
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)
|
||||||
|
for i in range(1, n):
|
||||||
|
if amin <= st[i, cv2.CC_STAT_AREA] <= amax:
|
||||||
|
zones[lab == i] = 1
|
||||||
|
solid = cv2.morphologyEx(zones.astype(np.uint8) * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
|
||||||
|
k9 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
||||||
|
border = cv2.subtract(cv2.dilate(solid, k9), cv2.erode(solid, k9))
|
||||||
|
zones = cv2.dilate(zones.astype(np.uint8),
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))) > 0
|
||||||
|
return zones, border
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_lines(img, pz, border, cfg, use_border):
|
||||||
|
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
v = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)[:, :, 2].astype(np.int32)
|
||||||
|
at_text = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY_INV, 31, 14)
|
||||||
|
at_all = cv2.adaptiveThreshold(g, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
|
||||||
|
cv2.THRESH_BINARY_INV, cfg.adaptive_bs, cfg.adaptive_c)
|
||||||
|
dark = (v < cfg.dark_v).astype(np.uint8)
|
||||||
|
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cfg.morph_open_k,)*2)
|
||||||
|
big = cv2.morphologyEx(dark, cv2.MORPH_OPEN, k)
|
||||||
|
fine = dark.copy(); fine[big > 0] = 0
|
||||||
|
pattern = (~pz) & (fine > 0) & (at_all > 0)
|
||||||
|
if use_border:
|
||||||
|
edges = cv2.Canny(g, 40, 120)
|
||||||
|
pattern = pattern | ((border > 0) & (edges > 0))
|
||||||
|
text = (pz & (at_text > 0))
|
||||||
|
allline = (text | pattern).astype(np.uint8)
|
||||||
|
c = cv2.morphologyEx(allline * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1)
|
||||||
|
return _skel(c > 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_noise_domains(line, protect, cfg):
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats((line > 0).astype(np.uint8), 8)
|
||||||
|
k = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8)
|
||||||
|
big = np.zeros_like(line)
|
||||||
|
for i in range(1, n):
|
||||||
|
if st[i, cv2.CC_STAT_AREA] >= cfg.noise_area: big[lab == i] = 1
|
||||||
|
near = cv2.dilate(big, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13)))
|
||||||
|
keep = np.zeros_like(line)
|
||||||
|
for i in range(1, n):
|
||||||
|
x, y, w, h, a = st[i]
|
||||||
|
if a < 3: continue
|
||||||
|
comp = (lab == i)
|
||||||
|
s = _skel(comp.astype(np.uint8)); sk_len = int(s.sum())
|
||||||
|
nb = cv2.filter2D(s, cv2.CV_8U, k); brs = int(((s > 0) & (nb >= 3)).sum())
|
||||||
|
if sk_len < cfg.noise_sk_len and brs < cfg.noise_branch and a < cfg.noise_area:
|
||||||
|
continue
|
||||||
|
keep[comp] = 1
|
||||||
|
keep |= line & protect.astype(np.uint8)
|
||||||
|
keep |= line & (near > 0).astype(np.uint8)
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_spurs(line, protect, cfg):
|
||||||
|
s = _skel(line); sb = s.astype(bool); h, w = s.shape
|
||||||
|
k = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8)
|
||||||
|
nb = cv2.filter2D(s, cv2.CV_8U, k)
|
||||||
|
ends = ((s > 0) & (nb == 11)); brs = ((s > 0) & (nb >= 13))
|
||||||
|
def _nbs(y, x):
|
||||||
|
out = []
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
if dy == 0 and dx == 0: continue
|
||||||
|
ny, nx = y + dy, x + dx
|
||||||
|
if 0 <= ny < h and 0 <= nx < w and sb[ny, nx]: out.append((ny, nx))
|
||||||
|
return out
|
||||||
|
cut = np.zeros_like(s)
|
||||||
|
for (y0, x0) in [tuple(p) for p in np.argwhere(ends)]:
|
||||||
|
path = [(y0, x0)]; cur, prev = (y0, x0), None
|
||||||
|
for _ in range(cfg.spur_maxlen):
|
||||||
|
ns = [p for p in _nbs(*cur) if p != prev]
|
||||||
|
if not ns or len(ns) > 1: break
|
||||||
|
nxt = ns[0]
|
||||||
|
if brs[nxt[0], nxt[1]]:
|
||||||
|
for (yy, xx) in path:
|
||||||
|
if not protect[yy, xx]: cut[yy, xx] = 1
|
||||||
|
break
|
||||||
|
prev, cur = cur, nxt; path.append(cur)
|
||||||
|
cut_dil = cv2.dilate(cut, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))
|
||||||
|
return line & (1 - cut_dil)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_skeleton(bgr, cfg):
|
||||||
|
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||||
|
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||||
|
pz = _paper_mask(hsv, cfg)
|
||||||
|
tz = _text_rect(bgr, cfg)
|
||||||
|
protect = pz.copy()
|
||||||
|
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)
|
||||||
|
smoothed = (cv2.pyrMeanShiftFiltering(bgr, cfg.meanshift_sp, cfg.meanshift_sr, maxLevel=2)
|
||||||
|
if cfg.enable_green_smoothing else bgr)
|
||||||
|
|
||||||
|
lines_fine = _extract_lines(bgr, pz, border, cfg, False)
|
||||||
|
if cfg.enable_green_smoothing:
|
||||||
|
lines_smooth = _extract_lines(smoothed, pz, border, cfg, True)
|
||||||
|
lines = np.where(zones, lines_smooth, lines_fine).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
lines = lines_fine
|
||||||
|
|
||||||
|
c = cv2.morphologyEx(lines * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1)
|
||||||
|
lines = _skel(c > 0)
|
||||||
|
lines = _remove_noise_domains(lines, protect, cfg)
|
||||||
|
lines = _prune_spurs(lines, protect, cfg)
|
||||||
|
|
||||||
|
at_text = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY_INV, 31, 14)
|
||||||
|
text_raw = (tz & (at_text > 0)).astype(np.uint8)
|
||||||
|
text_closed = cv2.morphologyEx(text_raw * 255, cv2.MORPH_CLOSE,
|
||||||
|
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), 1)
|
||||||
|
text_skel = _skel(text_closed > 0)
|
||||||
|
|
||||||
|
text_w = _to_width(text_skel, cfg.line_width)
|
||||||
|
pat_w = _to_width(lines & (~tz), cfg.line_width)
|
||||||
|
out = np.where((text_w > 0) | (pat_w > 0), 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
line = (out < 128).astype(np.uint8)
|
||||||
|
n, lab, st, _ = cv2.connectedComponentsWithStats(line, 8)
|
||||||
|
keep = np.zeros_like(line)
|
||||||
|
for i in range(1, n):
|
||||||
|
x, y, w, h, a = st[i]
|
||||||
|
if a < 3: continue
|
||||||
|
comp = (lab == i)
|
||||||
|
if (comp & protect).sum() > a * 0.5: keep[comp] = 1; continue
|
||||||
|
L = max(w, h); fill = a / max(1, w * h)
|
||||||
|
if a < 40 and min(w, h) / max(1, w) >= 0.6 and fill >= 0.55: continue
|
||||||
|
if L <= 12 and a < 60: continue
|
||||||
|
keep[comp] = 1
|
||||||
|
return np.where(keep, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 主入口
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def extract_lineart(bgr: np.ndarray,
|
||||||
|
cfg: Optional[LineArtConfig] = None,
|
||||||
|
*, verbose: bool = False) -> np.ndarray:
|
||||||
|
"""从 BGR 图像提取线稿。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bgr: 输入图像 (OpenCV BGR, uint8)。
|
||||||
|
cfg: 参数配置。``method`` = "skeleton"|"minimum"。
|
||||||
|
verbose: 打印日志。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
白底黑线线稿 (uint8, 0/255)。
|
||||||
|
"""
|
||||||
|
cfg = cfg or LineArtConfig()
|
||||||
|
|
||||||
|
def _log(msg):
|
||||||
|
if verbose: print(msg, flush=True)
|
||||||
|
|
||||||
|
method = (cfg.method or "skeleton").lower()
|
||||||
|
if method not in ("skeleton", "minimum"):
|
||||||
|
raise ValueError(f"未知 method: {cfg.method!r}")
|
||||||
|
|
||||||
|
if method == "minimum":
|
||||||
|
_log(f"[lineart] method=minimum denoise={cfg.denoise}")
|
||||||
|
mask = _minimum_filter_lineart(bgr, cfg)
|
||||||
|
out = np.where(mask > 0, 0, 255).astype(np.uint8)
|
||||||
|
_log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%")
|
||||||
|
return out
|
||||||
|
|
||||||
|
_log("[lineart] method=skeleton")
|
||||||
|
out = _extract_skeleton(bgr, cfg)
|
||||||
|
_log(f"[lineart] 完成, 黑占比 {(out < 128).mean()*100:.2f}%")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def extract_lineart_file(src: str, dst: str,
|
||||||
|
cfg: Optional[LineArtConfig] = None,
|
||||||
|
*, method: Optional[str] = None,
|
||||||
|
verbose: bool = False) -> str:
|
||||||
|
"""从文件提取线稿并保存。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src: 输入图片路径。
|
||||||
|
dst: 输出线稿路径 (.png)。
|
||||||
|
cfg: 参数配置。None 使用默认。
|
||||||
|
method: 快捷覆盖模式 ("skeleton"/"minimum")。
|
||||||
|
verbose: 打印日志。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
输出文件路径。
|
||||||
|
"""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = LineArtConfig()
|
||||||
|
if method is not None:
|
||||||
|
cfg = LineArtConfig(**{**cfg.__dict__, "method": method})
|
||||||
|
bgr = load_image(src)
|
||||||
|
out = extract_lineart(bgr, cfg, verbose=verbose)
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(dst)) or ".", exist_ok=True)
|
||||||
|
save_image(dst, out)
|
||||||
|
return dst
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "lineartization"
|
||||||
|
version = "1.6.1"
|
||||||
|
description = "彩色插图/手抄报 一键转换为黑白线稿 (汉字清晰、线条连贯、粗细统一)"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [
|
||||||
|
{ name = "DVS" },
|
||||||
|
]
|
||||||
|
keywords = [
|
||||||
|
"lineart", "line-art", "sketch", "skeleton", "thinning",
|
||||||
|
"image-processing", "opencv", "手抄报", "线稿", "提取线稿",
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Multimedia :: Graphics",
|
||||||
|
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=1.21",
|
||||||
|
"opencv-python>=4.5",
|
||||||
|
"opencv-contrib-python>=4.5",
|
||||||
|
"scikit-image>=0.19",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
"build>=1.0",
|
||||||
|
"twine>=4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.dvscloud.net/dvs/lineartization"
|
||||||
|
Repository = "https://git.dvscloud.net/dvs/lineartization"
|
||||||
|
"Issue Tracker" = "https://git.dvscloud.net/dvs/lineartization/issues"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
lineartization = "lineartization:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["lineartization"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
lineartization = ["py.typed"]
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""
|
||||||
|
lineart-extractor 单元测试
|
||||||
|
==========================
|
||||||
|
运行: pytest tests/ -v
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from lineartization import (
|
||||||
|
LineArtConfig,
|
||||||
|
extract_lineart,
|
||||||
|
extract_lineart_file,
|
||||||
|
load_image,
|
||||||
|
save_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 测试用图: 合成"白底 + 黑字 + 彩色块"
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_image():
|
||||||
|
"""构造一张 400x600 的合成图: 白底 + 黑色矩形(模拟文字) + 彩色块。"""
|
||||||
|
img = np.full((400, 600, 3), 255, np.uint8)
|
||||||
|
|
||||||
|
# 中央"文字区": 密集小黑块
|
||||||
|
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) # 偏蓝
|
||||||
|
|
||||||
|
# 右侧一个红色圆(模拟灯笼)
|
||||||
|
import cv2
|
||||||
|
cv2.circle(img, (500, 120), 40, (40, 40, 200), 3)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 测试
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_load_save_roundtrip(tmp_path, sample_image):
|
||||||
|
"""读写往返一致。"""
|
||||||
|
p = tmp_path / "in.png"
|
||||||
|
save_image(str(p), sample_image)
|
||||||
|
loaded = load_image(str(p))
|
||||||
|
assert loaded.shape == sample_image.shape
|
||||||
|
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))
|
||||||
|
assert out.dtype == np.uint8
|
||||||
|
assert out.ndim == 2
|
||||||
|
uniq = np.unique(out)
|
||||||
|
assert set(uniq.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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
assert r3 > r1
|
||||||
|
|
||||||
|
|
||||||
|
def test_green_smoothing_toggle(sample_image):
|
||||||
|
"""绿块抹平开关都应能正常出图。"""
|
||||||
|
for flag in (True, False):
|
||||||
|
cfg = LineArtConfig(enable_green_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)],
|
||||||
|
)
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user