Cleanup/quality pass #1
@@ -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/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from imagepipeline.modules.registry import list_modules
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
+34
-3
@@ -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"]
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+13
-5
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user