amayer5125 is savage

This commit is contained in:
Frank Schwenk
2026-05-30 11:33:07 +02:00
commit e7cdb8dd6f
55 changed files with 4339 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+56
View File
@@ -0,0 +1,56 @@
import struct
import zlib
from pathlib import Path
import pytest
import imagepipeline.modules # noqa: F401
def make_png(
path: Path,
width: int = 2,
height: int = 2,
rgb: tuple[int, int, int] = (200, 100, 50),
) -> None:
"""Write a minimal valid PNG without external dependencies."""
r, g, b = rgb
def chunk(tag: bytes, data: bytes) -> bytes:
crc = zlib.crc32(tag + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc)
raw = b"".join(
b"\x00" + bytes([r, g, b] * width)
for _ in range(height)
)
compressed = zlib.compress(raw, 9)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", compressed)
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
@pytest.fixture(autouse=True)
def _ensure_builtin_modules() -> None:
import imagepipeline.modules # noqa: F401
@pytest.fixture
def input_dir(tmp_path: Path) -> Path:
directory = tmp_path / "input"
directory.mkdir()
make_png(directory / "photo_a.png")
make_png(directory / "photo_b.png", rgb=(10, 20, 30))
return directory
@pytest.fixture
def output_base(tmp_path: Path) -> Path:
base = tmp_path / "output"
base.mkdir()
return base
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from imagepipeline.core.context import ModuleContext
from imagepipeline.core.exceptions import DependencyError
from imagepipeline.modules.ai_exposure import AIExposureModule
from imagepipeline.modules.ai_tone_map import AIToneMapModule
from imagepipeline.modules.comfy_flux_edit import ComfyFluxEditModule
from imagepipeline.modules.openrouter_edit import OpenRouterEditModule
from imagepipeline.modules.registry import get_module, list_modules
from tests.conftest import make_png
try:
import numpy # noqa: F401
has_numpy = True
except ImportError:
has_numpy = False
try:
import torch # noqa: F401
has_torch = True
except ImportError:
has_torch = False
has_magick = bool(shutil.which("magick") or shutil.which("convert"))
class TestAIModuleRegistration:
def test_ai_modules_registered(self) -> None:
names = list_modules()
for name in (
"ai_exposure",
"ai_tone_map",
"openrouter_edit",
"comfy_flux_edit",
):
assert name in names
def test_get_ai_modules(self) -> None:
assert get_module("ai_exposure") is AIExposureModule
assert get_module("ai_tone_map") is AIToneMapModule
assert get_module("openrouter_edit") is OpenRouterEditModule
assert get_module("comfy_flux_edit") is ComfyFluxEditModule
class TestAIParameters:
def test_ai_exposure_defaults(self) -> None:
params = AIExposureModule.validate_module_params({})
assert params["skip_existing"] is True
assert params["max_edge"] == 2048
assert params["device"] == "cpu"
assert params["strength"] == 1.0
def test_ai_tone_map_defaults(self) -> None:
params = AIToneMapModule.validate_module_params({})
assert params["checkpoint"] == ""
assert params["strength"] == 1.0
assert params["net_input_size"] == 256
def test_openrouter_requires_prompt(self) -> None:
with pytest.raises(ValueError, match="required"):
OpenRouterEditModule.validate_module_params({})
def test_openrouter_defaults(self) -> None:
params = OpenRouterEditModule.validate_module_params({"prompt": "brighten shadows"})
assert params["model"] == "black-forest-labs/flux.2-klein-4b"
assert params["strength"] == 0.3
assert params["api_key_env"] == "OPENROUTER_API_KEY"
def test_comfy_requires_prompt(self) -> None:
with pytest.raises(ValueError, match="required"):
ComfyFluxEditModule.validate_module_params({})
class TestAIToneMapFallback:
@pytest.mark.skipif(not has_numpy, reason="numpy not installed")
def test_clahe_fallback_writes_output(self, tmp_path: Path) -> None:
src = tmp_path / "photo.png"
make_png(src, width=8, height=8, rgb=(40, 80, 120))
output_dir = tmp_path / "out"
output_dir.mkdir()
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=AIToneMapModule.validate_module_params({"strength": 1.0}),
pipeline_output_root=tmp_path,
step_id="ai_tone_map_01",
logger=None,
)
AIToneMapModule().run(ctx)
dst = output_dir / "photo.png"
assert dst.is_file()
assert dst.stat().st_size > 0
class TestSkipExisting:
@pytest.mark.skipif(not has_numpy, reason="numpy not installed")
def test_second_run_skips_existing_outputs(self, tmp_path: Path, capsys) -> None:
src = tmp_path / "photo.png"
make_png(src, width=8, height=8)
output_dir = tmp_path / "out"
output_dir.mkdir()
params = AIToneMapModule.validate_module_params({"checkpoint": ""})
from imagepipeline.core.log import PipelineLogger
logger = PipelineLogger(verbose=True)
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=params,
pipeline_output_root=tmp_path,
step_id="ai_tone_map_01",
logger=logger,
)
module = AIToneMapModule()
module.run(ctx)
assert (output_dir / "photo.png").is_file()
module.run(ctx)
output = capsys.readouterr().out
assert "Skipped module ai_tone_map" in output
@pytest.mark.skipif(not has_torch, reason="torch not installed")
class TestAIExposure:
def test_ai_exposure_processes_image(self, tmp_path: Path) -> None:
src = tmp_path / "photo.png"
make_png(src, width=4, height=4, rgb=(30, 60, 90))
output_dir = tmp_path / "out"
output_dir.mkdir()
mock_model = MagicMock()
mock_device = MagicMock()
def fake_enhance(_model, image, *, device, strength):
return image
with (
patch.object(AIExposureModule, "_get_model", return_value=(mock_model, mock_device)),
patch(
"imagepipeline.ai.zero_dce.enhance_image",
side_effect=fake_enhance,
),
):
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=AIExposureModule.validate_module_params({"max_edge": 0}),
pipeline_output_root=tmp_path,
step_id="ai_exposure_01",
logger=None,
)
AIExposureModule().run(ctx)
assert (output_dir / "photo.png").is_file()
class TestComfyFluxEdit:
def test_server_unreachable_raises(self, tmp_path: Path) -> None:
src = tmp_path / "photo.png"
make_png(src)
output_dir = tmp_path / "out"
output_dir.mkdir()
workflow = tmp_path / "workflow.json"
workflow.write_text('{"1": {"class_type": "LoadImage", "inputs": {"image": "x"}}}')
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=ComfyFluxEditModule.validate_module_params(
{
"prompt": "test",
"server_url": "http://127.0.0.1:1",
"workflow_path": workflow,
}
),
pipeline_output_root=tmp_path,
step_id="comfy_flux_edit_01",
logger=None,
)
with pytest.raises(DependencyError, match="not reachable"):
ComfyFluxEditModule().run(ctx)
def test_missing_workflow_raises(self, tmp_path: Path) -> None:
src = tmp_path / "photo.png"
make_png(src)
output_dir = tmp_path / "out"
output_dir.mkdir()
ctx = ModuleContext(
input_paths=[src],
matched_groups=[],
output_dir=output_dir,
params=ComfyFluxEditModule.validate_module_params(
{
"prompt": "test",
"workflow_path": tmp_path / "missing.json",
}
),
pipeline_output_root=tmp_path,
step_id="comfy_flux_edit_01",
logger=None,
)
with pytest.raises(DependencyError, match="workflow not found"):
ComfyFluxEditModule().run(ctx)
class TestOpenRouterDependencies:
def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
with pytest.raises(DependencyError, match="OPENROUTER_API_KEY"):
OpenRouterEditModule.check_dependencies()
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from imagepipeline.core.exceptions import CycleError, ValidationError
from imagepipeline.core.manifest import PipelineManifest, write_manifest
from imagepipeline.core.params import Param, validate_params
from imagepipeline.core.pipeline import Pipeline
from imagepipeline.core.runner import PipelineRunner
from imagepipeline.core.step import StepDefinition
from imagepipeline.modules.base import BaseModule
from imagepipeline.modules.registry import get_module, register, unregister
from imagepipeline.utils.files import list_images, match_by_stem
from tests.conftest import make_png
class TestParams:
def test_defaults_applied(self) -> None:
schema = {"colorspace": Param("string", default="Gray")}
result = validate_params(schema, {})
assert result == {"colorspace": "Gray"}
def test_unknown_param_rejected(self) -> None:
schema = {"a": Param("int", default=1)}
with pytest.raises(ValueError, match="Unknown parameters"):
validate_params(schema, {"b": 2})
def test_choices_enforced(self) -> None:
schema = {"mode": Param("string", choices=("a", "b"))}
with pytest.raises(ValueError, match="must be one of"):
validate_params(schema, {"mode": "c"})
class TestFileMatching:
def test_match_by_stem_pairs_sources(self, tmp_path: Path) -> None:
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
make_png(a / "img1.png")
make_png(a / "img2.png")
make_png(b / "img1.png")
make_png(b / "img2.png")
groups = match_by_stem([list_images(a), list_images(b)])
assert len(groups) == 2
assert groups[0][0].parent == a
assert groups[0][1].parent == b
def test_missing_stem_raises(self, tmp_path: Path) -> None:
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
make_png(a / "only_a.png")
make_png(b / "only_b.png")
with pytest.raises(ValueError, match="Cannot match images by stem"):
match_by_stem([list_images(a), list_images(b)])
class TestPipelineRunner:
def test_cycle_detection(self, input_dir: Path, output_base: Path) -> None:
@register
class DummyModule(BaseModule):
name = "dummy_cycle"
def run(self, ctx) -> None:
pass
step_a = StepDefinition(
step_id="dummy_cycle_01",
module_name="dummy_cycle",
module=DummyModule,
input_refs=["dummy_cycle_02"],
params={},
output_dir_name="dummy_cycle_01",
)
step_b = StepDefinition(
step_id="dummy_cycle_02",
module_name="dummy_cycle",
module=DummyModule,
input_refs=["dummy_cycle_01"],
params={},
output_dir_name="dummy_cycle_02",
)
runner = PipelineRunner(
name="cycle_test",
input_dir=input_dir,
output_base=output_base,
steps=[step_a, step_b],
)
with pytest.raises(CycleError):
runner.run()
unregister("dummy_cycle")
def test_dag_execution_order(self, input_dir: Path, output_base: Path) -> None:
order: list[str] = []
@register
class OrderModule(BaseModule):
name = "order_tracker"
def run(self, ctx) -> None:
order.append(ctx.step_id)
ctx.output_dir.mkdir(parents=True, exist_ok=True)
for src in ctx.input_paths:
shutil.copy2(src, ctx.output_dir / src.name)
with Pipeline(name="order_test", input_dir=input_dir, output_base=output_base, verbose=False) as p:
step_b = p.step("order_tracker", inputs="input")
step_a = p.step("order_tracker", inputs=step_b)
p.run()
assert order == ["order_tracker_01", "order_tracker_02"]
unregister("order_tracker")
def test_duplicate_module_numbering(self, input_dir: Path, output_base: Path) -> None:
@register
class NumberModule(BaseModule):
name = "number_tracker"
def run(self, ctx) -> None:
ctx.output_dir.mkdir(parents=True, exist_ok=True)
for src in ctx.input_paths:
shutil.copy2(src, ctx.output_dir / src.name)
with Pipeline(name="dup_test", input_dir=input_dir, output_base=output_base, verbose=False) as p:
first = p.step("number_tracker", inputs="input")
p.step("number_tracker", inputs=first)
root = p.run()
assert (root / "number_tracker_01").is_dir()
assert (root / "number_tracker_02").is_dir()
unregister("number_tracker")
class TestManifest:
def test_write_manifest(self, tmp_path: Path) -> None:
manifest = PipelineManifest(
name="test",
output_root=str(tmp_path),
input_dir="/in",
started_at="2026-01-01T00:00:00+00:00",
finished_at="2026-01-01T00:00:01+00:00",
)
path = tmp_path / "pipeline_manifest.json"
write_manifest(path, manifest)
data = json.loads(path.read_text(encoding="utf-8"))
assert data["name"] == "test"
assert data["finished_at"] is not None
class TestPipelineValidation:
def test_empty_pipeline_rejected(self, input_dir: Path) -> None:
with Pipeline(name="empty", input_dir=input_dir, verbose=False) as p:
with pytest.raises(ValidationError, match="no steps"):
p.run()
def test_unknown_module_rejected(self, input_dir: Path) -> None:
with Pipeline(name="bad", input_dir=input_dir, verbose=False) as p:
with pytest.raises(KeyError, match="Unknown module"):
p.step("does_not_exist", inputs="input")
has_imagemagick = bool(shutil.which("magick") or shutil.which("convert"))
class TestPipelineLogging:
@pytest.mark.skipif(not has_imagemagick, reason="ImageMagick not installed")
def test_verbose_output(self, input_dir: Path, output_base: Path, capsys) -> None:
with Pipeline(
name="log_test",
input_dir=input_dir,
output_base=output_base,
verbose=True,
) as p:
p.step("imagemagick_grayscale", inputs="input")
p.run()
output = capsys.readouterr().out
assert "Found 2 photo(s)" in output
assert "Step 1/1: imagemagick_grayscale_01 (imagemagick_grayscale)" in output
assert "Applying module imagemagick_grayscale to image [1/2]" in output
assert "Pipeline finished." in output
@pytest.mark.skipif(not has_imagemagick, reason="ImageMagick not installed")
def test_quiet_output(self, input_dir: Path, output_base: Path, capsys) -> None:
with Pipeline(
name="quiet_test",
input_dir=input_dir,
output_base=output_base,
verbose=False,
) as p:
p.step("imagemagick_grayscale", inputs="input")
p.run()
assert capsys.readouterr().out == ""
@pytest.mark.skipif(not has_imagemagick, reason="ImageMagick not installed")
class TestImageMagickGrayscaleIntegration:
def test_grayscale_pipeline(self, input_dir: Path, output_base: Path) -> None:
with Pipeline(
name="gray_integration",
input_dir=input_dir,
output_base=output_base,
verbose=False,
) as p:
p.step("imagemagick_grayscale", inputs="input")
root = p.run()
manifest_path = root / "pipeline_manifest.json"
assert manifest_path.is_file()
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert len(manifest["steps"]) == 1
assert manifest["steps"][0]["module"] == "imagemagick_grayscale"
out_dir = root / "imagemagick_grayscale_01"
outputs = list_images(out_dir)
assert len(outputs) == 2
module = get_module("imagemagick_grayscale")
module.check_dependencies()
+298
View File
@@ -0,0 +1,298 @@
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())