diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/image-color-correction/warm_print.py b/image-color-correction/warm_print.py new file mode 100644 index 0000000..5e936f9 --- /dev/null +++ b/image-color-correction/warm_print.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "numpy==2.5.2", +# "pillow==12.3.0", +# ] +# /// +""" +Apply a warm print color correction (less blue, slightly yellower). + +Usage with uv (no venv, no uv pip): + uv run warm_print.py /path/to/image.jpg + +uv reads the dependencies in the header above and installs them +into an isolated cache on first run. + +Writes next to the source, same basename + "_warmprint", same extension. +Example: photo.jpg -> photo_warmprint.jpg +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageEnhance + + +def warm_correct(im: Image.Image) -> Image.Image: + """Shift RGB toward warm/yellow while keeping brightness similar. + + Matches the correction used for the preview print file: + lift red, lift green a little, cut blue, extra midtone warmth, + then a small contrast/saturation polish. + """ + # Work in linear-ish 0..1 float. Source may be RGB or RGBA; color + # math is on RGB only. Alpha is copied back unchanged if present. + rgba = im.mode == "RGBA" + if rgba: + alpha = np.asarray(im.getchannel("A")) + rgb = im.convert("RGB") + arr = np.asarray(rgb).astype(np.float32) / 255.0 + r, g, b = arr[..., 0], arr[..., 1], arr[..., 2] + + # Rec. 709 luma — used so we can warm the hue without crushing + # or blowing overall brightness (prints already look darker). + luma = 0.2126 * r + 0.7152 * g + 0.0722 * b + + # Global channel remap: more red, a bit more green, much less blue. + # That combination reads as yellow/warm rather than magenta or green. + r2 = r * 1.10 + 0.018 + g2 = g * 1.045 + 0.010 + b2 = b * 0.82 - 0.008 + + # Stronger warmth in midtones (skin, white fabric). Highlights and + # shadows get less so whites don't go mustard and blacks stay clean. + mid = np.clip(1.0 - np.abs(luma - 0.55) / 0.55, 0.0, 1.0) + r2 = r2 + 0.025 * mid + g2 = g2 + 0.018 * mid + b2 = b2 - 0.012 * mid + + new = np.stack([r2, g2, b2], axis=-1) + + # Re-match original luma, then add a tiny lift (prints look darker + # than screens). Clamp the scale so clipped/near-black pixels stay sane. + new_luma = 0.2126 * new[..., 0] + 0.7152 * new[..., 1] + 0.0722 * new[..., 2] + target = luma * 1.03 + 0.01 + scale = np.where(new_luma > 1e-4, target / new_luma, 1.0) + scale = np.clip(scale, 0.85, 1.18) + new = np.clip(new * scale[..., None], 0.0, 1.0) + + out_im = Image.fromarray((new * 255.0 + 0.5).astype(np.uint8), mode="RGB") + + # Small polish after the hue shift so it doesn't look flat. + out_im = ImageEnhance.Contrast(out_im).enhance(1.04) + out_im = ImageEnhance.Color(out_im).enhance(1.06) + + if rgba: + out_im = out_im.convert("RGBA") + out_im.putalpha(Image.fromarray(alpha)) + return out_im + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: python warm_print.py /path/to/image.jpg", file=sys.stderr) + return 2 + + src = Path(sys.argv[1]).expanduser().resolve() + if not src.is_file(): + print(f"Not a file: {src}", file=sys.stderr) + return 1 + + # Same folder, same name, short suffix before the original extension. + dest = src.with_name(f"{src.stem}_warmprint{src.suffix}") + + im = Image.open(src) + # Keep ICC if the source had one so color-managed printers stay consistent. + icc = im.info.get("icc_profile") + out_im = warm_correct(im) + + ext = src.suffix.lower() + save_kwargs: dict = {"optimize": True} + if icc: + save_kwargs["icc_profile"] = icc + if ext in {".jpg", ".jpeg"}: + # High-quality JPEG for print; 4:4:4 so chroma isn't smeared. + save_kwargs.update(quality=95, subsampling=0) + out_im = out_im.convert("RGB") + out_im.save(dest, "JPEG", **save_kwargs) + else: + out_im.save(dest, "PNG", **save_kwargs) + print(dest) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())