from __future__ import annotations import shutil from pathlib import Path import pytest from imagepipeline.modules.color_to_alpha import ( ColorToAlphaModule, build_color_to_alpha_args, ) from imagepipeline.modules.composite import CompositeModule from imagepipeline.modules.crop_square import CropSquareModule from imagepipeline.modules.darktable_style import DarktableStyleModule from imagepipeline.modules.gmic_grayscale import GmicGrayscale from imagepipeline.modules.imagemagick_fill import ( ImageMagickFillModule, build_fill_arguments, ) from imagepipeline.modules.imagemagick_resize import ( ImageMagickResizeModule, build_resize_arguments, ) from imagepipeline.modules.registry import get_module, list_modules from imagepipeline.modules.rembg import RembgModule from tests.integration_helpers import has_darktable_style from tests.integration_helpers import has_workflow_tools as check_workflow_tools 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", "color_to_alpha", "imagemagick_resize", "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 assert get_module("color_to_alpha") is ColorToAlphaModule class TestImageMagickResize: def test_build_resize_arguments(self) -> None: assert build_resize_arguments(max_edge=2000) == [ "-auto-orient", "-resize", "2000x2000>", ] def test_rejects_non_positive_max_edge(self) -> None: with pytest.raises(ValueError, match="positive"): build_resize_arguments(max_edge=0) def test_default_max_edge(self) -> None: params = ImageMagickResizeModule.validate_module_params({}) assert params["max_edge"] == 2000 class TestColorToAlpha: def test_build_args_exact(self) -> None: assert build_color_to_alpha_args(color="#00ff00", fuzz=0.0) == [ "-alpha", "on", "-transparent", "#00ff00", ] def test_build_args_with_fuzz(self) -> None: assert build_color_to_alpha_args(color="ffffff", fuzz=2.5) == [ "-alpha", "on", "-fuzz", "2.5%", "-transparent", "#ffffff", ] def test_requires_color(self) -> None: with pytest.raises(ValueError, match="required"): ColorToAlphaModule.validate_module_params({}) @pytest.mark.skipif(not has_magick, reason="ImageMagick not installed") def test_makes_matching_color_transparent(self, tmp_path: Path) -> None: from imagepipeline.core.context import ModuleContext from imagepipeline.utils.subprocess import run_command src = tmp_path / "green.png" output_dir = tmp_path / "out" output_dir.mkdir() magick = shutil.which("magick") or shutil.which("convert") run_command([magick, "-size", "8x8", "xc:#00ff00", str(src)]) ctx = ModuleContext( input_paths=[src], matched_groups=[], output_dir=output_dir, params=ColorToAlphaModule.validate_module_params({"color": "#00ff00"}), pipeline_output_root=tmp_path, step_id="color_to_alpha_01", logger=None, ) ColorToAlphaModule().run(ctx) dst = output_dir / "green.png" assert dst.is_file() result = run_command( [ magick, str(dst), "-alpha", "extract", "-format", "%[fx:mean]", "info:", ] ) assert float(result.stdout.strip()) == 0.0 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 = check_workflow_tools() @pytest.mark.integration @pytest.mark.slow @pytest.mark.skipif(not has_workflow_tools, reason="Workflow CLI tools not installed") @pytest.mark.skipif( not has_darktable_style("Watermark F12.rocks"), reason="darktable style 'Watermark F12.rocks' not available or darktable-cli export failed", ) 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())