""" lineartization unit tests ========================= Run: 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, ) # --------------------------------------------------------------------------- # # Test fixture: synthetic "white background + black strokes + color blocks" # --------------------------------------------------------------------------- # @pytest.fixture def sample_image(): """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 # Left color blocks img[60:160, 20:180] = (60, 160, 80) # green img[160:220, 20:180] = (80, 120, 200) # bluish # 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)) assert loaded.shape == sample_image.shape assert np.allclose(loaded, sample_image, atol=2) 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 assert set(np.unique(out).tolist()).issubset({0, 255}) assert out.shape == sample_image.shape[:2] @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): """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_color_smoothing_toggle(sample_image): """Color-region smoothing must run both on and off.""" for flag in (True, False): cfg = LineArtConfig(enable_color_smoothing=flag) out = extract_lineart(sample_image, cfg) assert (out < 128).mean() > 0 def test_protect_areas(sample_image): """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