release: v1.7.0 - region-agnostic skeleton, selectable true-black, English code
Highlights:
- minimum mode: add min_true_black switch (default True = unchanged v1.6.1 behaviour).
True : true-black gate + CC denoise (best for black line work).
False : no gate, keeps colored regions, distance-transform thinning
(best for colorful posters).
- skeleton mode: replace hue-specific green-block smoothing with
saturation-based _color_zones; no hue or side-of-image assumptions.
- Move all comments, docstrings and messages to English.
- Remove built-in default paths; examples/demo.py now requires args.
- README: document both minimum variants, resolution guidance, parameters.
- tests: 18 cases covering I/O, both modes, both minimum variants.
This commit is contained in:
+132
-63
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user