"""Tests for Unicode-safe cv2 image IO (issue #17).""" from __future__ import annotations import io from typing import TYPE_CHECKING import cv2 import numpy as np import pytest from remove_ai_watermarks import image_io if TYPE_CHECKING: from pathlib import Path # Non-ASCII filenames that break cv2.imread/imwrite on Windows (issue #17). _UNICODE_NAMES = [ "jimeng-2026-05-27-一面白色的墙.png", # Chinese "тест-изображение.png", # Cyrillic "café-señor.png", # accented Latin ] def _make_bgr() -> np.ndarray: img = np.zeros((8, 8, 3), dtype=np.uint8) img[2:6, 2:6] = (10, 120, 240) # a BGR block so the round-trip is checkable return img class TestUnicodeRoundTrip: def test_write_then_read_preserves_pixels(self, tmp_path: Path) -> None: for name in _UNICODE_NAMES: path = tmp_path / name src = _make_bgr() assert image_io.imwrite(path, src) is True assert path.exists() out = image_io.imread(path) assert out is not None # PNG is lossless: pixels must match exactly. assert np.array_equal(out, src) def test_alpha_round_trip_with_unchanged_flag(self, tmp_path: Path) -> None: path = tmp_path / "豆包-alpha.png" bgra = np.zeros((8, 8, 4), dtype=np.uint8) bgra[..., 3] = 128 assert image_io.imwrite(path, bgra) is True out = image_io.imread(path, cv2.IMREAD_UNCHANGED) assert out is not None assert out.shape[2] == 4 assert np.array_equal(out, bgra) def test_reads_file_written_by_raw_cv2(self, tmp_path: Path) -> None: # An ASCII file written by plain cv2 must read back identically through # the wrapper (decode path is byte-compatible with cv2.imread). path = tmp_path / "ascii.png" src = _make_bgr() cv2.imwrite(str(path), src) out = image_io.imread(path) assert out is not None assert np.array_equal(out, src) class TestToBgr: def test_grayscale_2d_promoted_to_bgr(self) -> None: gray = np.full((4, 5), 120, dtype=np.uint8) out = image_io.to_bgr(gray) assert out.shape == (4, 5, 3) # GRAY2BGR replicates the channel, so all three match the source. assert np.array_equal(out[..., 0], gray) assert np.array_equal(out[..., 0], out[..., 2]) def test_single_channel_3d_promoted(self) -> None: gray = np.full((4, 5, 1), 7, dtype=np.uint8) assert image_io.to_bgr(gray).shape == (4, 5, 3) def test_bgra_dropped_to_bgr(self) -> None: bgra = np.zeros((4, 5, 4), dtype=np.uint8) bgra[..., :3] = (10, 120, 240) out = image_io.to_bgr(bgra) assert out.shape == (4, 5, 3) assert np.array_equal(out, bgra[..., :3]) def test_bgr_returned_unchanged(self) -> None: bgr = _make_bgr() out = image_io.to_bgr(bgr) assert out is bgr # 3-channel: no copy class TestFailureSemantics: def test_missing_file_returns_none(self, tmp_path: Path) -> None: assert image_io.imread(tmp_path / "does-not-exist-不存在.png") is None def test_empty_file_returns_none(self, tmp_path: Path) -> None: path = tmp_path / "empty.png" path.write_bytes(b"") assert image_io.imread(path) is None def test_undecodable_file_returns_none(self, tmp_path: Path) -> None: path = tmp_path / "garbage.png" path.write_bytes(b"not an image") assert image_io.imread(path) is None def test_imwrite_to_missing_directory_returns_false(self, tmp_path: Path) -> None: # An unwritable path must return False (cv2.imwrite contract), not raise. path = tmp_path / "no-such-dir" / "out.png" assert image_io.imwrite(path, _make_bgr()) is False def _avif_writable() -> bool: """True when the installed Pillow can ENCODE AVIF (needed to synthesize a fixture); read support is what we test, but we need a writer to make the sample.""" from PIL import Image, features if not (hasattr(features, "check") and features.check("avif")): return False try: Image.fromarray(np.zeros((8, 8, 3), np.uint8)).save(io.BytesIO(), format="AVIF") return True except Exception: return False _HAS_AVIF_WRITE = _avif_writable() class TestPillowFallback: """OpenCV cannot decode HEIC/AVIF; :func:`imread` falls back to Pillow (with the pillow-heif plugin) so the pixel/removal path reads them. Uses a synthetic AVIF (no corpus files); HEIC is exercised the same way in production.""" @pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable") def test_avif_decodes_via_fallback(self, tmp_path: Path) -> None: from PIL import Image path = tmp_path / "x.avif" Image.fromarray(np.full((32, 48, 3), (10, 20, 200), np.uint8), "RGB").save(path) img = image_io.imread(path) # cv2 fails on AVIF -> Pillow fallback assert img is not None assert img.shape == (32, 48, 3) # Source RGB (R=10, G=20, B=200) -> BGR, so blue [...,0] is high, red [...,2] low # (thresholds loose for AVIF's lossy compression). assert int(img[0, 0, 0]) > 150 assert int(img[0, 0, 2]) < 70 @pytest.mark.skipif(not _HAS_AVIF_WRITE, reason="Pillow AVIF encoder unavailable") def test_avif_alpha_survives_read_bgr_and_alpha(self, tmp_path: Path) -> None: from PIL import Image path = tmp_path / "x.avif" rgba = np.dstack([np.full((20, 20, 3), 80, np.uint8), np.full((20, 20), 128, np.uint8)]) Image.fromarray(rgba, "RGBA").save(path) bgr, alpha = image_io.read_bgr_and_alpha(path) assert bgr is not None assert bgr.shape == (20, 20, 3) assert alpha is not None # the source alpha plane survives the fallback assert alpha.shape == (20, 20) def _heif_writable(fmt: str) -> bool: try: image_io._register_heif() from PIL import Image Image.fromarray(np.zeros((8, 8, 3), np.uint8), "RGB").save(io.BytesIO(), format=fmt, quality=100) return True except Exception: return False class TestQualityPreservingWrite: """The removal only touches the mark footprint, so the container re-encode must not degrade the untouched pixels: JPEG/WebP are written at max quality, HEIC/AVIF (which cv2 cannot encode) via Pillow instead of crashing.""" def test_jpeg_written_near_lossless(self, tmp_path: Path) -> None: # a smooth gradient -> JPEG at quality 100 / 4:4:4 is near-lossless g = np.tile(np.linspace(30, 210, 64, dtype=np.uint8), (48, 1)) img = np.dstack([g, g, g]) p = tmp_path / "x.jpg" assert image_io.imwrite(p, img) is True back = image_io.imread(p) assert back is not None assert float(np.abs(img.astype(int) - back.astype(int)).mean()) < 1.0 @pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env") def test_heic_write_roundtrips(self, tmp_path: Path) -> None: # cv2 cannot encode HEIC (used to raise); imwrite must route through Pillow. img = np.full((32, 48, 3), (30, 140, 200), np.uint8) p = tmp_path / "x.heic" assert image_io.imwrite(p, img) is True back = image_io.imread(p) assert back is not None assert back.shape == (32, 48, 3) def test_unencodable_ext_returns_false_not_raises(self, tmp_path: Path) -> None: # a bogus extension cv2 can't encode returns False (never raises the cv2.error). assert image_io.imwrite(tmp_path / "x.zzz", _make_bgr()) is False