diff --git a/.gitignore b/.gitignore index 4417c1f..05e7fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,14 @@ __pycache__/ dist/ build/ .pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ .venv/ venv/ .env +*.log + +# Local pipeline run output (if created inside repo) +pipeline_output/ diff --git a/imagepipeline/ai/cache.py b/imagepipeline/ai/cache.py index 8669057..b32aaaa 100644 --- a/imagepipeline/ai/cache.py +++ b/imagepipeline/ai/cache.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import urllib.request from pathlib import Path diff --git a/imagepipeline/ai/classical_tone.py b/imagepipeline/ai/classical_tone.py index 3e2eeda..03752f8 100644 --- a/imagepipeline/ai/classical_tone.py +++ b/imagepipeline/ai/classical_tone.py @@ -49,7 +49,7 @@ def _lab_to_rgb(lab: np.ndarray) -> np.ndarray: fz = fy - lab[..., 2] / 200 def finv(t): - t3 = t ** 3 + t3 = t**3 return np.where(t3 > 216 / 24389, t3, (116 * t - 16) / kappa) kappa = 24389 / 27 diff --git a/imagepipeline/ai/hdrnet/model.py b/imagepipeline/ai/hdrnet/model.py index 6b3b3cb..7abaf1c 100644 --- a/imagepipeline/ai/hdrnet/model.py +++ b/imagepipeline/ai/hdrnet/model.py @@ -1,11 +1,8 @@ from __future__ import annotations -import math - import numpy as np import torch import torch.nn as nn -import torch.nn.functional as F from imagepipeline.ai.hdrnet.slice import batch_bilateral_slice @@ -70,22 +67,27 @@ class Slice(nn.Module): class ApplyCoeffs(nn.Module): def forward(self, coeff, full_res_input): - r = torch.sum(full_res_input * coeff[:, 0:3, :, :], dim=1, keepdim=True) + coeff[ - :, 9:10, :, : - ] - g = torch.sum(full_res_input * coeff[:, 3:6, :, :], dim=1, keepdim=True) + coeff[ - :, 10:11, :, : - ] - b = torch.sum(full_res_input * coeff[:, 6:9, :, :], dim=1, keepdim=True) + coeff[ - :, 11:12, :, : - ] + r = ( + torch.sum(full_res_input * coeff[:, 0:3, :, :], dim=1, keepdim=True) + + coeff[:, 9:10, :, :] + ) + g = ( + torch.sum(full_res_input * coeff[:, 3:6, :, :], dim=1, keepdim=True) + + coeff[:, 10:11, :, :] + ) + b = ( + torch.sum(full_res_input * coeff[:, 6:9, :, :], dim=1, keepdim=True) + + coeff[:, 11:12, :, :] + ) return torch.cat([r, g, b], dim=1) class GuideNN(nn.Module): def __init__(self, params) -> None: super().__init__() - self.conv1 = ConvBlock(3, params["guide_complexity"], kernel_size=1, padding=0, batch_norm=True) + self.conv1 = ConvBlock( + 3, params["guide_complexity"], kernel_size=1, padding=0, batch_norm=True + ) self.conv2 = ConvBlock( params["guide_complexity"], 1, kernel_size=1, padding=0, activation=nn.Sigmoid ) @@ -112,9 +114,7 @@ class Coeffs(nn.Module): for index in range(n_layers_splat): use_bn = bn if index > 0 else False out_ch = cm * (2**index) * lb - self.splat_features.append( - ConvBlock(prev_ch, out_ch, 3, stride=2, batch_norm=use_bn) - ) + self.splat_features.append(ConvBlock(prev_ch, out_ch, 3, stride=2, batch_norm=use_bn)) prev_ch = out_ch splat_ch = prev_ch @@ -131,7 +131,9 @@ class Coeffs(nn.Module): prev_ch = int(prev_ch * (nsize / 2**n_total) ** 2) self.global_features_fc.append(FC(prev_ch, 32 * cm * lb, batch_norm=bn)) self.global_features_fc.append(FC(32 * cm * lb, 16 * cm * lb, batch_norm=bn)) - self.global_features_fc.append(FC(16 * cm * lb, 8 * cm * lb, activation=None, batch_norm=bn)) + self.global_features_fc.append( + FC(16 * cm * lb, 8 * cm * lb, activation=None, batch_norm=bn) + ) self.local_features = nn.ModuleList( [ @@ -139,9 +141,7 @@ class Coeffs(nn.Module): ConvBlock(8 * cm * lb, 8 * cm * lb, 3, activation=None, use_bias=False), ] ) - self.conv_out = ConvBlock( - 8 * cm * lb, lb * nout * nin, 1, padding=0, activation=None - ) + self.conv_out = ConvBlock(8 * cm * lb, lb * nout * nin, 1, padding=0, activation=None) self.relu = nn.ReLU() def forward(self, lowres_input): diff --git a/imagepipeline/ai/hdrnet/slice.py b/imagepipeline/ai/hdrnet/slice.py index 6bc964f..0fb24a4 100644 --- a/imagepipeline/ai/hdrnet/slice.py +++ b/imagepipeline/ai/hdrnet/slice.py @@ -82,12 +82,8 @@ def _bilateral_slice(grid, guide): grid_val_110 = grid[gi1c, gj1c, gk0c, :] grid_val_111 = grid[gi1c, gj1c, gk1c, :] - w_000, w_001, w_010, w_011 = map( - torch.atleast_3d, (w_000, w_001, w_010, w_011) - ) - w_100, w_101, w_110, w_111 = map( - torch.atleast_3d, (w_100, w_101, w_110, w_111) - ) + w_000, w_001, w_010, w_011 = map(torch.atleast_3d, (w_000, w_001, w_010, w_011)) + w_100, w_101, w_110, w_111 = map(torch.atleast_3d, (w_100, w_101, w_110, w_111)) return ( torch.multiply(w_000, grid_val_000) diff --git a/imagepipeline/ai/zero_dce.py b/imagepipeline/ai/zero_dce.py index be1c69d..5700e57 100644 --- a/imagepipeline/ai/zero_dce.py +++ b/imagepipeline/ai/zero_dce.py @@ -60,9 +60,7 @@ class EnhanceNetNoPool(nn.Module): if self.scale_factor == 1: x_down = x else: - x_down = F.interpolate( - x, scale_factor=1 / self.scale_factor, mode="bilinear" - ) + x_down = F.interpolate(x, scale_factor=1 / self.scale_factor, mode="bilinear") x1 = self.relu(self.e_conv1(x_down)) x2 = self.relu(self.e_conv2(x1)) @@ -105,7 +103,5 @@ def enhance_image( if strength < 1.0: enhanced = tensor * (1.0 - strength) + enhanced * strength enhanced = torch.clamp(enhanced, 0.0, 1.0) - out = (enhanced.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0).astype( - np.uint8 - ) + out = (enhanced.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0).astype(np.uint8) return Image.fromarray(out) diff --git a/imagepipeline/cli.py b/imagepipeline/cli.py index 0a841e2..4e380cd 100644 --- a/imagepipeline/cli.py +++ b/imagepipeline/cli.py @@ -2,7 +2,6 @@ from __future__ import annotations import argparse import sys -from pathlib import Path from imagepipeline.modules.registry import list_modules diff --git a/imagepipeline/core/context.py b/imagepipeline/core/context.py index dcb7a0c..1f1f144 100644 --- a/imagepipeline/core/context.py +++ b/imagepipeline/core/context.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from imagepipeline.core.log import PipelineLogger diff --git a/imagepipeline/core/log.py b/imagepipeline/core/log.py index c950e48..6ddd318 100644 --- a/imagepipeline/core/log.py +++ b/imagepipeline/core/log.py @@ -36,23 +36,17 @@ class PipelineLogger: inputs: list[str], params: dict, ) -> None: - self.info( - f"Step {step_index}/{step_total}: {step_id} ({module_name})" - ) + self.info(f"Step {step_index}/{step_total}: {step_id} ({module_name})") self.info(f" inputs: {', '.join(inputs)}") if params: rendered = ", ".join(f"{key}={value!r}" for key, value in params.items()) self.info(f" params: {rendered}") def image(self, module_name: str, index: int, total: int, filename: str) -> None: - self.info( - f" Applying module {module_name} to image [{index}/{total}]: {filename}" - ) + self.info(f" Applying module {module_name} to image [{index}/{total}]: {filename}") def skipped(self, module_name: str, index: int, total: int, filename: str) -> None: - self.info( - f" Skipped module {module_name} [{index}/{total}] {filename} (output exists)" - ) + self.info(f" Skipped module {module_name} [{index}/{total}] {filename} (output exists)") def image_done( self, diff --git a/imagepipeline/core/manifest.py b/imagepipeline/core/manifest.py index d49b057..a18e573 100644 --- a/imagepipeline/core/manifest.py +++ b/imagepipeline/core/manifest.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -39,4 +39,4 @@ def write_manifest(path: Path, manifest: PipelineManifest) -> None: def utc_now_iso() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + return datetime.now(UTC).replace(microsecond=0).isoformat() diff --git a/imagepipeline/core/params.py b/imagepipeline/core/params.py index 4ba2e1c..93ccd78 100644 --- a/imagepipeline/core/params.py +++ b/imagepipeline/core/params.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any @@ -22,9 +22,7 @@ class Param: if self.choices is not None and value not in self.choices: allowed = ", ".join(repr(c) for c in self.choices) - raise ValueError( - f"Parameter '{name}' must be one of [{allowed}], got {value!r}" - ) + raise ValueError(f"Parameter '{name}' must be one of [{allowed}], got {value!r}") if self.type == "string": if not isinstance(value, str): @@ -56,9 +54,7 @@ class Param: raise ValueError(f"Unknown parameter type '{self.type}' for '{name}'") -def validate_params( - schema: dict[str, Param], raw: dict[str, Any] -) -> dict[str, Any]: +def validate_params(schema: dict[str, Param], raw: dict[str, Any]) -> dict[str, Any]: unknown = set(raw) - set(schema) if unknown: names = ", ".join(sorted(unknown)) diff --git a/imagepipeline/core/runner.py b/imagepipeline/core/runner.py index 7376be2..cf48b80 100644 --- a/imagepipeline/core/runner.py +++ b/imagepipeline/core/runner.py @@ -49,12 +49,9 @@ class PipelineRunner: self.symlink_input = symlink_input self.logger = PipelineLogger(verbose=verbose) self.existing_outputs = { - key: Path(value).resolve() - for key, value in (existing_outputs or {}).items() + key: Path(value).resolve() for key, value in (existing_outputs or {}).items() } - self.continue_from = ( - Path(continue_from).resolve() if continue_from is not None else None - ) + self.continue_from = Path(continue_from).resolve() if continue_from is not None else None self.skip_completed = skip_completed self.output_root = self._build_output_root() self._input_link_dir = self.output_root / "input" @@ -63,9 +60,7 @@ class PipelineRunner: def _build_output_root(self) -> Path: if self.continue_from is not None: if not self.continue_from.is_dir(): - raise ValidationError( - f"continue_from directory not found: {self.continue_from}" - ) + raise ValidationError(f"continue_from directory not found: {self.continue_from}") return self.continue_from timestamp = datetime.now().strftime("%y%m%d%H%M%S") @@ -249,8 +244,7 @@ class PipelineRunner: output_paths = step.module().list_output_images(ctx) if not output_paths: raise StepError( - f"Step '{step.output_dir_name}' ({step.module_name}) " - "produced no output images" + f"Step '{step.output_dir_name}' ({step.module_name}) produced no output images" ) self.logger.step_done(step.output_dir_name, step.output_dir_name, len(output_paths)) @@ -279,9 +273,7 @@ class PipelineRunner: raise ValidationError(f"Step '{step.step_id}' references unknown step '{dep}'") dependents[dep].append(step.step_id) - queue = deque( - step_id for step_id, degree in in_degree.items() if degree == 0 - ) + queue = deque(step_id for step_id, degree in in_degree.items() if degree == 0) ordered_ids: list[str] = [] while queue: diff --git a/imagepipeline/core/step.py b/imagepipeline/core/step.py index 356efc3..2efcab3 100644 --- a/imagepipeline/core/step.py +++ b/imagepipeline/core/step.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from imagepipeline.modules.base import BaseModule diff --git a/imagepipeline/modules/__init__.py b/imagepipeline/modules/__init__.py index fdb9aa5..a01cb2d 100644 --- a/imagepipeline/modules/__init__.py +++ b/imagepipeline/modules/__init__.py @@ -2,8 +2,8 @@ import imagepipeline.modules.ai_exposure # noqa: F401 import imagepipeline.modules.ai_tone_map # noqa: F401 -import imagepipeline.modules.comfy_flux_edit # noqa: F401 import imagepipeline.modules.color_to_alpha # noqa: F401 +import imagepipeline.modules.comfy_flux_edit # noqa: F401 import imagepipeline.modules.composite # noqa: F401 import imagepipeline.modules.crop_square # noqa: F401 import imagepipeline.modules.darktable_style # noqa: F401 diff --git a/imagepipeline/modules/ai_base.py b/imagepipeline/modules/ai_base.py index 885027f..c813d1f 100644 --- a/imagepipeline/modules/ai_base.py +++ b/imagepipeline/modules/ai_base.py @@ -115,9 +115,7 @@ class AIModule(BaseModule): avg = sum(elapsed_times) / len(elapsed_times) eta = avg * remaining if remaining else 0.0 if ctx.logger is not None: - ctx.logger.image_done( - self.name, index, total, dst.name, elapsed, eta_seconds=eta - ) + ctx.logger.image_done(self.name, index, total, dst.name, elapsed, eta_seconds=eta) @staticmethod def _image_size(path: Path) -> tuple[int, int]: diff --git a/imagepipeline/modules/color_to_alpha.py b/imagepipeline/modules/color_to_alpha.py index e696a43..f08dfa0 100644 --- a/imagepipeline/modules/color_to_alpha.py +++ b/imagepipeline/modules/color_to_alpha.py @@ -24,8 +24,7 @@ def build_color_to_alpha_args(*, color: str, fuzz: float) -> list[str]: class ColorToAlphaModule(SubprocessModule): name = "color_to_alpha" description = ( - "Make a solid color transparent (GIMP-style color to alpha). " - "Outputs PNG with alpha." + "Make a solid color transparent (GIMP-style color to alpha). Outputs PNG with alpha." ) command_candidates = ("magick", "convert") @@ -50,10 +49,7 @@ class ColorToAlphaModule(SubprocessModule): "fuzz": Param( "float", default=0.0, - help=( - "Match tolerance in percent (ImageMagick -fuzz); " - "0 = exact color only" - ), + help=("Match tolerance in percent (ImageMagick -fuzz); 0 = exact color only"), ), } diff --git a/imagepipeline/modules/comfy_flux_edit.py b/imagepipeline/modules/comfy_flux_edit.py index c391763..02b0a6f 100644 --- a/imagepipeline/modules/comfy_flux_edit.py +++ b/imagepipeline/modules/comfy_flux_edit.py @@ -76,9 +76,7 @@ class ComfyFluxEditModule(AIModule): server_url = ctx.params["server_url"].rstrip("/") self._ensure_server(server_url) if ctx.logger is not None: - ctx.logger.warning( - "ComfyUI on CPU: expect hours per full-resolution image" - ) + ctx.logger.warning("ComfyUI on CPU: expect hours per full-resolution image") workflow_template = json.loads(workflow_path.read_text(encoding="utf-8")) denoise = ctx.params["denoise"] @@ -98,9 +96,7 @@ class ComfyFluxEditModule(AIModule): seed=seed, ) prompt_id = self._queue_prompt(server_url, workflow) - output_info = self._wait_for_output( - server_url, prompt_id, poll_interval=poll_interval - ) + output_info = self._wait_for_output(server_url, prompt_id, poll_interval=poll_interval) image_bytes = self._download_view(server_url, output_info) dst.write_bytes(image_bytes) @@ -111,9 +107,7 @@ class ComfyFluxEditModule(AIModule): try: urllib.request.urlopen(f"{server_url}/system_stats", timeout=5) except urllib.error.URLError as exc: - raise DependencyError( - f"ComfyUI server not reachable at {server_url}: {exc}" - ) from exc + raise DependencyError(f"ComfyUI server not reachable at {server_url}: {exc}") from exc @staticmethod def _upload_image(server_url: str, src: Path) -> str: diff --git a/imagepipeline/modules/composite.py b/imagepipeline/modules/composite.py index 9a72b67..4e3ac96 100644 --- a/imagepipeline/modules/composite.py +++ b/imagepipeline/modules/composite.py @@ -63,9 +63,7 @@ class CompositeModule(SubprocessModule): for index, group in enumerate(ctx.matched_groups, start=1): if len(group) < 2: - raise ValueError( - "composite requires at least two input sources per image" - ) + raise ValueError("composite requires at least two input sources per image") background, foreground = group[0], group[1] self.log_image(ctx, index, total, foreground) diff --git a/imagepipeline/modules/imagemagick_fill.py b/imagepipeline/modules/imagemagick_fill.py index a237d2a..2b0f4a8 100644 --- a/imagepipeline/modules/imagemagick_fill.py +++ b/imagepipeline/modules/imagemagick_fill.py @@ -50,9 +50,7 @@ def build_fill_arguments( @register class ImageMagickFillModule(SubprocessModule): name = "imagemagick_fill" - description = ( - "Create solid-color or gradient images sized to match each input image" - ) + description = "Create solid-color or gradient images sized to match each input image" command_candidates = ("magick", "convert") @classmethod diff --git a/imagepipeline/modules/imagemagick_scale_crop.py b/imagepipeline/modules/imagemagick_scale_crop.py index 7982621..02a4c11 100644 --- a/imagepipeline/modules/imagemagick_scale_crop.py +++ b/imagepipeline/modules/imagemagick_scale_crop.py @@ -12,9 +12,7 @@ from imagepipeline.utils.subprocess import run_command @register class ImageMagickScaleCropModule(SubprocessModule): name = "imagemagick_scale_crop" - description = ( - "Scale an image then center-crop back to its original dimensions" - ) + description = "Scale an image then center-crop back to its original dimensions" command_candidates = ("magick", "convert") @classmethod diff --git a/imagepipeline/modules/openrouter_edit.py b/imagepipeline/modules/openrouter_edit.py index 22c9046..b73cd2f 100644 --- a/imagepipeline/modules/openrouter_edit.py +++ b/imagepipeline/modules/openrouter_edit.py @@ -70,9 +70,7 @@ class OpenRouterEditModule(AIModule): @classmethod def check_dependencies(cls) -> None: if not os.environ.get("OPENROUTER_API_KEY"): - raise DependencyError( - "OPENROUTER_API_KEY environment variable is not set" - ) + raise DependencyError("OPENROUTER_API_KEY environment variable is not set") def run(self, ctx: ModuleContext) -> None: api_key_env = ctx.params["api_key_env"] @@ -155,17 +153,11 @@ class OpenRouterEditModule(AIModule): *, template_data_url: str | None = None, ) -> dict: - full_prompt = ( - f"{TEMPLATE_PROMPT_PREFIX}{prompt}" if template_data_url else prompt - ) + full_prompt = f"{TEMPLATE_PROMPT_PREFIX}{prompt}" if template_data_url else prompt content: list[dict] = [{"type": "text", "text": full_prompt}] if template_data_url is not None: - content.append( - {"type": "image_url", "image_url": {"url": template_data_url}} - ) - content.append( - {"type": "image_url", "image_url": {"url": source_data_url}} - ) + content.append({"type": "image_url", "image_url": {"url": template_data_url}}) + content.append({"type": "image_url", "image_url": {"url": source_data_url}}) payload: dict = { "model": model, "modalities": cls._modalities_for_model(model), @@ -176,9 +168,7 @@ class OpenRouterEditModule(AIModule): return payload @classmethod - def _save_result_matching_source( - cls, source: Path, result_bytes: bytes, dest: Path - ) -> None: + def _save_result_matching_source(cls, source: Path, result_bytes: bytes, dest: Path) -> None: with Image.open(source) as original: orig_format = original.format orig_size = original.size @@ -229,9 +219,7 @@ class OpenRouterEditModule(AIModule): return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") - raise RuntimeError( - f"OpenRouter API error ({exc.code}): {detail}" - ) from exc + raise RuntimeError(f"OpenRouter API error ({exc.code}): {detail}") from exc @staticmethod def _extract_image_bytes(response: dict) -> bytes: diff --git a/imagepipeline/modules/xcf_stack.py b/imagepipeline/modules/xcf_stack.py index 4957ad1..dc6b454 100644 --- a/imagepipeline/modules/xcf_stack.py +++ b/imagepipeline/modules/xcf_stack.py @@ -18,9 +18,7 @@ def _layer_name(ref: str) -> str: @register class XcfStackModule(SubprocessModule): name = "xcf_stack" - description = ( - "Stack listed pipeline step outputs as GIMP layers into one XCF per image" - ) + description = "Stack listed pipeline step outputs as GIMP layers into one XCF per image" command_candidates = ("gimp-console", "gimp") @classmethod @@ -80,8 +78,7 @@ class XcfStackModule(SubprocessModule): if not layers: checked = ", ".join(name for name, _ in ctx.input_layer_dirs) raise ValueError( - f"xcf_stack: no layers found for stem '{stem}' " - f"(checked: {checked})" + f"xcf_stack: no layers found for stem '{stem}' (checked: {checked})" ) dst = ctx.output_dir / f"{stem}.xcf" diff --git a/imagepipeline/utils/files.py b/imagepipeline/utils/files.py index a866ac3..05cf8fe 100644 --- a/imagepipeline/utils/files.py +++ b/imagepipeline/utils/files.py @@ -45,8 +45,7 @@ def match_by_stem(sources: list[list[Path]]) -> list[list[Path]]: key = stem_key(path) if key in mapping: raise ValueError( - f"Duplicate stem '{key}' in {path.parent}: " - f"{mapping[key].name} and {path.name}" + f"Duplicate stem '{key}' in {path.parent}: {mapping[key].name} and {path.name}" ) mapping[key] = path key_maps.append(mapping) diff --git a/imagepipeline/utils/gimp.py b/imagepipeline/utils/gimp.py index 402d0ed..0d06ca7 100644 --- a/imagepipeline/utils/gimp.py +++ b/imagepipeline/utils/gimp.py @@ -91,9 +91,7 @@ def _run_gimp_batch( stdout=stdout or "", stderr=stderr or "", ) - raise RuntimeError( - f"Command timed out after {timeout}s: {' '.join(cmd)}" - ) from exc + raise RuntimeError(f"Command timed out after {timeout}s: {' '.join(cmd)}") from exc return subprocess.CompletedProcess( cmd, @@ -122,9 +120,7 @@ def stack_images_to_xcf( for layer_name, image_path in layers: resolved = image_path.resolve() if not resolved.is_file(): - raise FileNotFoundError( - f"Layer image not found for '{layer_name}': {resolved}" - ) + raise FileNotFoundError(f"Layer image not found for '{layer_name}': {resolved}") resolved_layers.append((layer_name, resolved)) outfile = outfile.resolve() @@ -159,8 +155,7 @@ def stack_images_to_xcf( detail = stderr or stdout or f"exit code {result.returncode}" layer_summary = ", ".join(name for name, _ in resolved_layers) raise RuntimeError( - f"GIMP failed to stack layers [{layer_summary}] into {outfile}: " - f"{detail}" + f"GIMP failed to stack layers [{layer_summary}] into {outfile}: {detail}" ) finally: script_path.unlink(missing_ok=True) diff --git a/imagepipeline/utils/gmic.py b/imagepipeline/utils/gmic.py index 9e7946f..09b17cd 100644 --- a/imagepipeline/utils/gmic.py +++ b/imagepipeline/utils/gmic.py @@ -38,6 +38,4 @@ def finalize_gmic_output(output_dir: Path, intended: Path) -> Path: frame_000000.rename(intended) return intended - raise FileNotFoundError( - f"G'MIC produced no output for {intended.name} in {output_dir}" - ) + raise FileNotFoundError(f"G'MIC produced no output for {intended.name} in {output_dir}") diff --git a/imagepipeline/utils/subprocess.py b/imagepipeline/utils/subprocess.py index bfeec50..6d581b8 100644 --- a/imagepipeline/utils/subprocess.py +++ b/imagepipeline/utils/subprocess.py @@ -32,13 +32,9 @@ def run_command( stderr = (exc.stderr or "").strip() stdout = (exc.stdout or "").strip() detail = stderr or stdout or str(exc) - raise RuntimeError( - f"Command failed ({exc.returncode}): {' '.join(cmd)}\n{detail}" - ) from exc + raise RuntimeError(f"Command failed ({exc.returncode}): {' '.join(cmd)}\n{detail}") from exc except subprocess.TimeoutExpired as exc: - raise RuntimeError( - f"Command timed out after {timeout}s: {' '.join(cmd)}" - ) from exc + raise RuntimeError(f"Command timed out after {timeout}s: {' '.join(cmd)}") from exc def require_command(*names: str) -> str: diff --git a/pipelines/example_grayscale.py b/pipelines/example_grayscale.py index edc0499..4f77c2a 100644 --- a/pipelines/example_grayscale.py +++ b/pipelines/example_grayscale.py @@ -18,8 +18,9 @@ def main() -> None: input_dir=INPUT, output_base=OUTPUT_BASE, ) as p: - gray = p.step("imagemagick_grayscale", inputs="input") + p.step("imagemagick_grayscale", inputs="input") # Chain another step on the result: + # gray = p.step("imagemagick_grayscale", inputs="input") # p.step("imagemagick_grayscale", inputs=gray, colorspace="Gray") output_root = p.run() diff --git a/pipelines/pipeline_aichelberg_indians.py b/pipelines/pipeline_aichelberg_indians.py index c1f5bc2..c879f42 100644 --- a/pipelines/pipeline_aichelberg_indians.py +++ b/pipelines/pipeline_aichelberg_indians.py @@ -37,8 +37,7 @@ GMIC_CRAYONGRAFFITI = "-fx_crayongraffiti2 300,50,1,0.4,12,1,2,2,0" GMIC_ANAGLYPH = "-fx_stereo_to_anaglyph 2,0" GMIC_DROP_SHADOW = "-fx_drop_shadow3d 0,0,0,10,1,1,2,0.5,194,37,24,200,0" GMIC_BOKEH = ( - f"-fx_bokeh 3,5,0,30,8,4,0.3,0.2,36,74,137,{ALPHA1},0.7,30,20,20,1,2," - f"194,37,24,{ALPHA2},0.15" + f"-fx_bokeh 3,5,0,30,8,4,0.3,0.2,36,74,137,{ALPHA1},0.7,30,20,20,1,2,194,37,24,{ALPHA2},0.15" ) @@ -71,9 +70,7 @@ def main() -> None: ) # recipe: gmic-edges-rembg - rembg_edges = p.step( - "gmic", inputs=rembg_out, command=GMIC_EDGES, step_id="rembg_edges" - ) + rembg_edges = p.step("gmic", inputs=rembg_out, command=GMIC_EDGES, step_id="rembg_edges") composite_edges = p.step( "composite", inputs=[rembg_edges, rembg_out], step_id="composite_edges" ) @@ -92,9 +89,7 @@ def main() -> None: ) # recipe: gmic-neon-rembg - rembg_neon = p.step( - "gmic", inputs=rembg_out, command=GMIC_NEON, step_id="rembg_neon" - ) + rembg_neon = p.step("gmic", inputs=rembg_out, command=GMIC_NEON, step_id="rembg_neon") composite_neon = p.step( "composite", inputs=[rembg_neon, rembg_out], step_id="composite_neon" ) @@ -248,15 +243,11 @@ def main() -> None: input_bokeh = p.step( "gmic", inputs=input_tone_map, command=GMIC_BOKEH, step_id="input_bokeh" ) - bokeh_mid = p.step( - "composite", inputs=[input_tone_map, input_bokeh], step_id="bokeh_mid" - ) + bokeh_mid = p.step("composite", inputs=[input_tone_map, input_bokeh], step_id="bokeh_mid") p.step("composite", inputs=[bokeh_mid, rembg_out], step_id="composite_bokeh") # recipe: color-bg-drop-shadow-rembg - color_bg = p.step( - "imagemagick_fill", inputs="input", color1=COLOR1, step_id="color_bg" - ) + color_bg = p.step("imagemagick_fill", inputs="input", color1=COLOR1, step_id="color_bg") rembg_shadow = p.step( "gmic", inputs=rembg_out, command=GMIC_DROP_SHADOW, step_id="rembg_shadow" ) diff --git a/pipelines/pipeline_baxxter_2.py b/pipelines/pipeline_baxxter_2.py index 9b479d0..f5fad13 100644 --- a/pipelines/pipeline_baxxter_2.py +++ b/pipelines/pipeline_baxxter_2.py @@ -40,14 +40,10 @@ def main() -> None: gmic_shadow = p.step("gmic", inputs=rembg, command=GMIC_DROP_SHADOW) gmic_smooth = p.step("gmic", inputs=rembg, command=GMIC_JPR_SMOOTH) - gmic_stereo_alpha = p.step( - "color_to_alpha", inputs=gmic_stereo, color="#000000" - ) + gmic_stereo_alpha = p.step("color_to_alpha", inputs=gmic_stereo, color="#000000") yellow_bg = p.step("imagemagick_fill", inputs="input", color1=YELLOW) - gmic_smooth_alpha = p.step( - "color_to_alpha", inputs=gmic_smooth, color="#7f7f7f" - ) + gmic_smooth_alpha = p.step("color_to_alpha", inputs=gmic_smooth, color="#7f7f7f") gmic_smooth_sized = p.step( "imagemagick_scale_crop", inputs=gmic_smooth_alpha, diff --git a/pipelines/pipeline_colorsplash_watermark_f12.py b/pipelines/pipeline_colorsplash_watermark_f12.py index d710750..e729d2e 100644 --- a/pipelines/pipeline_colorsplash_watermark_f12.py +++ b/pipelines/pipeline_colorsplash_watermark_f12.py @@ -6,7 +6,9 @@ from pathlib import Path from imagepipeline import Pipeline # Darktable export folder. -INPUT = Path("/home/frank/pics/20260517_Albershausen Crusaders - Biberach Beavers/darktable_exported/png") +INPUT = Path( + "/home/frank/pics/20260517_Albershausen Crusaders - Biberach Beavers/darktable_exported/png" +) # Where timestamped run folders are created. OUTPUT_BASE = Path.home() / "pipeline_output" diff --git a/pipelines/pipeline_crusaders.py b/pipelines/pipeline_crusaders.py index cd99773..2b37634 100644 --- a/pipelines/pipeline_crusaders.py +++ b/pipelines/pipeline_crusaders.py @@ -5,7 +5,9 @@ from pathlib import Path from imagepipeline import Pipeline -INPUT = Path("/home/frank/pics/20260620_Albershausen Crusaders - Montabaur Fighting Farmers/darktable_exported") +INPUT = Path( + "/home/frank/pics/20260620_Albershausen Crusaders - Montabaur Fighting Farmers/darktable_exported" +) OUTPUT_BASE = Path.home() / "pipeline_output" # Reuse outputs from a previous run or external folder (key = step id, e.g. rembg_01). @@ -80,13 +82,9 @@ def main() -> None: scale=1.05, ) - rembg_stereo_alpha = p.step( - "color_to_alpha", inputs=rembg_stereo, color="#000000" - ) + rembg_stereo_alpha = p.step("color_to_alpha", inputs=rembg_stereo, color="#000000") color_bg = p.step("imagemagick_fill", inputs="input", color1=COLOR1) - rembg_smooth_alpha = p.step( - "color_to_alpha", inputs=rembg_jpr_smooth, color="#7f7f7f" - ) + rembg_smooth_alpha = p.step("color_to_alpha", inputs=rembg_jpr_smooth, color="#7f7f7f") rembg_smooth_sized = p.step( "imagemagick_scale_crop", inputs=rembg_smooth_alpha, diff --git a/pipelines/pipeline_orange.py b/pipelines/pipeline_orange.py index 44687c7..26cebf4 100644 --- a/pipelines/pipeline_orange.py +++ b/pipelines/pipeline_orange.py @@ -35,6 +35,7 @@ GMIC_GRADIENT_B = ( "255,255,128,128,128,255,255,0,255,255,0,0,0,0" ) + def main() -> None: with Pipeline( name="orange", diff --git a/pipelines/pipeline_rezepttest.py b/pipelines/pipeline_rezepttest.py index 3f16e1e..6485708 100644 --- a/pipelines/pipeline_rezepttest.py +++ b/pipelines/pipeline_rezepttest.py @@ -22,8 +22,7 @@ ALPHA2 = 110 GMIC_DROP_SHADOW = "-fx_drop_shadow3d 0,0,0,10,1,1,2,0.5,240,16,0,200,0" GMIC_JPR_SMOOTH = "-jpr_gradient_smooth 0,1.5" GMIC_BOKEH = ( - f"-fx_bokeh 3,5,0,30,8,4,0.3,0.2,240,176,32,{ALPHA1},0.7,30,20,20,1,2," - f"240,16,0,{ALPHA2},0.15" + f"-fx_bokeh 3,5,0,30,8,4,0.3,0.2,240,176,32,{ALPHA1},0.7,30,20,20,1,2,240,16,0,{ALPHA2},0.15" ) @@ -59,9 +58,7 @@ def main() -> None: scale=1.05, step_id="rembg_jpr_smooth_sized", ) - input_bokeh = p.step( - "gmic", inputs="input", command=GMIC_BOKEH, step_id="input_bokeh" - ) + input_bokeh = p.step("gmic", inputs="input", command=GMIC_BOKEH, step_id="input_bokeh") # recipe: colorsplash composite_colorsplash = p.step( @@ -74,9 +71,7 @@ def main() -> None: ) # recipe: original-drop-shadow-rembg - shadow_mid = p.step( - "composite", inputs=["input", rembg_shadow], step_id="shadow_mid" - ) + shadow_mid = p.step("composite", inputs=["input", rembg_shadow], step_id="shadow_mid") composite_shadow = p.step( "composite", inputs=[shadow_mid, rembg_out], step_id="composite_shadow" ) diff --git a/pyproject.toml b/pyproject.toml index bbb0508..f77c4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,17 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "Frank" }] -dependencies = [] +dependencies = [ + "Pillow>=10.0", +] [project.optional-dependencies] -ai = ["numpy>=1.26", "Pillow>=10.0", "torch>=2.0"] -dev = ["pytest>=8.0"] +ai = ["numpy>=1.26", "torch>=2.0"] +dev = [ + "pytest>=8.0", + "numpy>=1.26", + "ruff>=0.8", +] [project.scripts] imagepipeline = "imagepipeline.cli:main" @@ -25,3 +31,28 @@ include = ["imagepipeline*"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "integration: tests that call external CLI tools (ImageMagick, G'MIC, rembg, darktable, GIMP)", + "slow: tests that take more than a few seconds", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 +src = ["imagepipeline", "tests", "pipelines"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear +] +ignore = [ + "E501", # line length handled by formatter + "B027", # optional empty hooks on BaseModule +] + +[tool.ruff.lint.isort] +known-first-party = ["imagepipeline"] diff --git a/tests/conftest.py b/tests/conftest.py index 8c8fa42..a59461d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,10 +20,7 @@ def make_png( 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) - ) + 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 = ( @@ -37,7 +34,7 @@ def make_png( @pytest.fixture(autouse=True) def _ensure_builtin_modules() -> None: - import imagepipeline.modules # noqa: F401 + pass # noqa: F401 @pytest.fixture diff --git a/tests/test_ai_modules.py b/tests/test_ai_modules.py index 4c3d522..2738fb0 100644 --- a/tests/test_ai_modules.py +++ b/tests/test_ai_modules.py @@ -110,9 +110,7 @@ class TestAIParameters: assert payload["modalities"] == ["image"] assert payload["image_config"] == {"strength": 0.25} - def test_save_result_matching_source_preserves_png_size( - self, tmp_path: Path - ) -> None: + def test_save_result_matching_source_preserves_png_size(self, tmp_path: Path) -> None: try: from PIL import Image except ImportError: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 715bd30..4c52dd9 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -113,9 +113,11 @@ class TestPipelineRunner: 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: + 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.step("order_tracker", inputs=step_b) p.run() assert order == ["order_tracker_01", "order_tracker_02"] @@ -132,7 +134,9 @@ class TestPipelineRunner: 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: + 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() @@ -170,7 +174,9 @@ class TestCustomStepId: for src in ctx.input_paths: shutil.copy2(src, ctx.output_dir / src.name) - with Pipeline(name="named_test", input_dir=input_dir, output_base=output_base, verbose=False) as p: + with Pipeline( + name="named_test", input_dir=input_dir, output_base=output_base, verbose=False + ) as p: ref = p.step("named_tracker", inputs="input", step_id="input_bokeh") root = p.run() @@ -193,7 +199,9 @@ class TestCustomStepId: for src in ctx.input_paths: shutil.copy2(src, ctx.output_dir / src.name) - with Pipeline(name="counter_test", input_dir=input_dir, output_base=output_base, verbose=False) as p: + with Pipeline( + name="counter_test", input_dir=input_dir, output_base=output_base, verbose=False + ) as p: p.step("counter_tracker", inputs="input") p.step("counter_tracker", inputs="input", step_id="custom_mid") p.step("counter_tracker", inputs="input") diff --git a/tests/test_resume.py b/tests/test_resume.py index 3132671..951ab60 100644 --- a/tests/test_resume.py +++ b/tests/test_resume.py @@ -13,7 +13,6 @@ from imagepipeline.core.resume import ( ) from imagepipeline.core.step import StepDefinition from imagepipeline.modules.imagemagick_grayscale import ImageMagickGrayscale -from imagepipeline.modules.registry import get_module from imagepipeline.modules.rembg import RembgModule from imagepipeline.utils.gmic import finalize_gmic_output, split_gmic_command from tests.conftest import make_png @@ -124,7 +123,9 @@ class TestExpectedOutputFilenames: class TestPipelineResume: @pytest.mark.skipif(not shutil.which("magick"), reason="ImageMagick not installed") - def test_continue_skips_completed_steps(self, input_dir: Path, output_base: Path, capsys) -> None: + def test_continue_skips_completed_steps( + self, input_dir: Path, output_base: Path, capsys + ) -> None: with Pipeline( name="resume_test", input_dir=input_dir, @@ -195,9 +196,7 @@ class TestPipelineResume: verbose=True, existing_outputs={"input_bokeh": external}, ) as p: - reused = p.step( - "imagemagick_grayscale", inputs="input", step_id="input_bokeh" - ) + reused = p.step("imagemagick_grayscale", inputs="input", step_id="input_bokeh") p.step("imagemagick_grayscale", inputs=reused) root = p.run() diff --git a/tests/test_xcf_stack.py b/tests/test_xcf_stack.py index 7ce41db..c4ccd7b 100644 --- a/tests/test_xcf_stack.py +++ b/tests/test_xcf_stack.py @@ -119,9 +119,7 @@ def _make_stack_fixture(tmp_path: Path) -> dict[str, Path]: class TestXcfStackRun: @patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf") - def test_collects_layers_from_explicit_inputs( - self, mock_stack: object, tmp_path: Path - ) -> None: + def test_collects_layers_from_explicit_inputs(self, mock_stack: object, tmp_path: Path) -> None: paths = _make_stack_fixture(tmp_path) input_path = paths["root"] / "refs" / "photo.jpg" input_path.parent.mkdir() @@ -152,9 +150,7 @@ class TestXcfStackRun: ] @patch("imagepipeline.modules.xcf_stack.stack_images_to_xcf") - def test_skip_missing_true_skips_missing_step( - self, mock_stack: object, tmp_path: Path - ) -> None: + def test_skip_missing_true_skips_missing_step(self, mock_stack: object, tmp_path: Path) -> None: paths = _make_stack_fixture(tmp_path) (paths["step_b"] / "photo.png").unlink() input_path = paths["root"] / "photo.jpg"