from __future__ import annotations import shutil from pathlib import Path import pytest from imagepipeline.core.params import validate_params from imagepipeline.modules.composite import CompositeModule from imagepipeline.modules.crop_square import CropSquareModule from imagepipeline.modules.darktable_style import DarktableStyleModule from imagepipeline.modules.imagemagick_fill import ( ImageMagickFillModule, build_fill_arguments, ) from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale from imagepipeline.modules.gmic_grayscale import GmicGrayscale from imagepipeline.modules.registry import get_module, list_modules from imagepipeline.modules.rembg import RembgModule has_magick = bool(shutil.which("magick") or shutil.which("convert")) class TestModuleRegistration: def test_workflow_modules_registered(self) -> None: names = list_modules() for name in ( "rembg", "gmic_grayscale", "composite", "darktable_style", "imagemagick_grayscale", "imagemagick_fill", "crop_square", ): assert name in names def test_get_module_returns_class(self) -> None: assert get_module("rembg") is RembgModule assert get_module("gmic_grayscale") is GmicGrayscale assert get_module("composite") is CompositeModule assert get_module("darktable_style") is DarktableStyleModule assert get_module("crop_square") is CropSquareModule assert get_module("imagemagick_fill") is ImageMagickFillModule class TestImageMagickFill: def test_solid_fill_arguments(self) -> None: args = build_fill_arguments( 800, 600, color1="#d7fd00", color2=None, gradient=False, radial=False, angle=None, ) assert args == ["-size", "800x600", "xc:#d7fd00"] def test_linear_gradient_arguments(self) -> None: args = build_fill_arguments( 100, 50, color1="#d7fd00", color2="#fc0ade", gradient=True, radial=False, angle=45.0, ) assert args == [ "-size", "100x50", "-define", "gradient:angle=45.0", "gradient:#d7fd00-#fc0ade", ] def test_radial_gradient_arguments(self) -> None: args = build_fill_arguments( 100, 50, color1="d7fd00", color2="fc0ade", gradient=True, radial=True, angle=90.0, ) assert args == ["-size", "100x50", "radial-gradient:#d7fd00-#fc0ade"] def test_requires_color1(self) -> None: with pytest.raises(ValueError, match="required"): ImageMagickFillModule.validate_module_params({}) @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") def test_output_matches_input_size(self, tmp_path: Path) -> None: from imagepipeline.core.context import ModuleContext from imagepipeline.utils.subprocess import run_command from tests.conftest import make_png src = tmp_path / "ref.png" make_png(src, width=40, height=20) output_dir = tmp_path / "out" output_dir.mkdir() ctx = ModuleContext( input_paths=[src], matched_groups=[], output_dir=output_dir, params=ImageMagickFillModule.validate_module_params( { "color1": "#d7fd00", "color2": "#fc0ade", "gradient": True, "angle": 45, } ), pipeline_output_root=tmp_path, step_id="imagemagick_fill_01", logger=None, ) ImageMagickFillModule().run(ctx) dst = output_dir / "ref.png" assert dst.is_file() magick = shutil.which("magick") or shutil.which("convert") result = run_command([magick, "-format", "%w %h", str(dst), "info:"]) assert result.stdout.strip() == "40 20" class TestCropSquare: @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") def test_center_crops_to_square(self, tmp_path: Path) -> None: from imagepipeline.core.context import ModuleContext from imagepipeline.utils.subprocess import run_command from tests.conftest import make_png src = tmp_path / "wide.png" make_png(src, width=40, height=20) output_dir = tmp_path / "out" output_dir.mkdir() ctx = ModuleContext( input_paths=[src], matched_groups=[], output_dir=output_dir, params={}, pipeline_output_root=tmp_path, step_id="crop_square_01", logger=None, ) CropSquareModule().run(ctx) dst = output_dir / "wide.png" assert dst.is_file() magick = shutil.which("magick") or shutil.which("convert") result = run_command( [magick, "-format", "%w %h", str(dst), "info:"], ) width, height = map(int, result.stdout.strip().split()) assert width == height == 20 class TestModuleParameters: def test_darktable_style_requires_style(self) -> None: with pytest.raises(ValueError, match="required"): DarktableStyleModule.validate_module_params({}) def test_darktable_style_accepts_style(self) -> None: params = DarktableStyleModule.validate_module_params( {"style": "Watermark F12.rocks"} ) assert params["style"] == "Watermark F12.rocks" assert params["style_overwrite"] is True def test_composite_mode_choices(self) -> None: with pytest.raises(ValueError, match="must be one of"): CompositeModule.validate_module_params({"mode": "invalid"}) def test_rembg_defaults(self) -> None: params = RembgModule.validate_module_params({}) assert params["model"] == "birefnet-general" assert params["alpha_matting"] is True def test_darktable_export_conf_jpeg(self) -> None: from imagepipeline.modules.darktable_style import export_conf_options assert export_conf_options("jpeg") == [ "plugins/imageio/format/jpeg/quality=90" ] def test_darktable_export_conf_png(self) -> None: from imagepipeline.modules.darktable_style import export_conf_options assert export_conf_options("png") == [ "plugins/imageio/format/png/bpp=8", "plugins/imageio/format/png/compression=5", ] def test_darktable_resolve_format_from_input(self) -> None: from imagepipeline.modules.darktable_style import resolve_export_format format_name, conf = resolve_export_format(Path("photo.jpg"), "") assert format_name == "jpeg" assert conf == ["plugins/imageio/format/jpeg/quality=90"] format_name, conf = resolve_export_format(Path("photo.png"), "") assert format_name == "png" assert "plugins/imageio/format/png/compression=5" in conf def test_darktable_rejects_unsupported_format(self) -> None: from imagepipeline.modules.darktable_style import resolve_export_format with pytest.raises(ValueError, match="Unsupported output format"): resolve_export_format(Path("photo.tiff"), "tiff") @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") class TestCompositeColor: def test_preserves_color_over_grayscale_background(self, tmp_path: Path) -> None: from imagepipeline.core.context import ModuleContext from imagepipeline.utils.subprocess import run_command from tests.conftest import make_png src = tmp_path / "src.png" make_png(src, width=40, height=40, rgb=(200, 50, 50)) background = tmp_path / "bg.png" foreground = tmp_path / "fg.png" output_dir = tmp_path / "composite_out" output_dir.mkdir() magick = shutil.which("magick") or shutil.which("convert") run_command([magick, str(src), "-colorspace", "Gray", str(background)]) run_command( [ magick, "-size", "40x40", "xc:none", "-fill", "rgba(255,0,0,0.8)", "-draw", "circle 20,20 20,2", str(foreground), ] ) module = CompositeModule() ctx = ModuleContext( input_paths=[background, foreground], output_dir=output_dir, params=CompositeModule.validate_module_params({}), pipeline_output_root=tmp_path, step_id="composite_01", matched_groups=[[background, foreground]], ) module.run(ctx) output = output_dir / "fg.png" assert output.is_file() result = run_command( [magick, "identify", "-format", "%[type]", str(output)] ) assert result.stdout.strip() != "Grayscale" has_workflow_tools = all( shutil.which(name) for name in ("rembg", "gmic", "magick", "darktable-cli") ) or all(shutil.which(name) for name in ("rembg", "gmic", "convert", "darktable-cli")) @pytest.mark.skipif(not has_workflow_tools, reason="Workflow CLI tools not installed") class TestWorkflowIntegration: def test_watermark_pipeline(self, input_dir, output_base) -> None: from imagepipeline import Pipeline with Pipeline( name="workflow_test", input_dir=input_dir, output_base=output_base, verbose=False, ) as p: rembg_out = p.step("rembg", inputs="input") grayscale = p.step("gmic_grayscale", inputs="input") combined = p.step("composite", inputs=[grayscale, rembg_out]) p.step( "darktable_style", inputs=combined, style="Watermark F12.rocks", ) root = p.run() assert (root / "rembg_01").is_dir() assert (root / "gmic_grayscale_01").is_dir() assert (root / "composite_01").is_dir() assert (root / "darktable_style_01").is_dir() assert list((root / "darktable_style_01").iterdir())