feat: add image-color-correction
This commit is contained in:
2 files changed
+121
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user