#!/usr/bin/env catnip
# Color grading et grain argentique avec pyvips
# Le pipeline reste entièrement dans libvips : aucune matérialisation NumPy.
#
# DEPS: pyvips[binary]
pyvips = import('pyvips')
math = import('math')
import('pathlib', 'Path')
script_dir = Path(META.file).parent
input_path = script_dir / 'data/catnip_test.jpg'
output_dir = script_dir / 'output'
output_dir.mkdir(exist_ok=True)
struct GradePreset {
name: str; contrast: float; shadow_tint: list[int]; highlight_tint: list[int]; grain_amount: float;
vignette_strength: float; seed: int;
}
# Le nom est volontairement séparé des paramètres : le match rend explicite
# la sélection d'une configuration complète et empêche les presets partiels.
preset_name = 'cinematic'
preset = match preset_name {
'cinematic' => {
GradePreset('cinematic', 6.0, list(-10, -2, 12), list(12, 5, -4), 7.0, 0.34, 1977)
}
'faded' => {
GradePreset('faded', 4.0, list(-4, 2, 10), list(10, 6, 0), 5.0, 0.20, 1968)
}
'noir' => {
GradePreset('noir', 7.0, list(-6, -6, -6), list(5, 5, 5), 9.0, 0.42, 1932)
}
_ => {
GradePreset('neutral', 3.0, list(0, 0, 0), list(0, 0, 0), 3.0, 0.10, 1)
}
}
clamp = (image, low: int, high: int) => {
above_low = (image < low).ifthenelse(low, image)
(above_low > high).ifthenelse(high, above_low)
}
# Construit une LUT 256 entrées. La normalisation conserve exactement les
# extrémités 0 et 255 malgré la courbe sigmoïde.
contrast_lut = (strength: float) => {
x = pyvips.Image.identity() / 255
sigmoid = 1 / (1 + ((x - 0.5) * -strength).exp())
low = 1 / (1 + math.exp(strength / 2))
high = 1 / (1 + math.exp(-strength / 2))
((sigmoid - low) * (255 / (high - low))).cast('uchar')
}
apply_contrast = (image, config: GradePreset) => {
image.maplut(contrast_lut(config.contrast))
}
apply_split_toning = (image, config: GradePreset) => {
luminance = image.colourspace('b-w')
shadows = clamp((128 - luminance) / 128, 0, 1)
highlights = clamp((luminance - 128) / 127, 0, 1)
image + shadows * config.shadow_tint + highlights * config.highlight_tint
}
apply_grain = (image, config: GradePreset) => {
luminance = image.colourspace('b-w') / 255
shadow_bias = 0.35 + 0.65 * (1 - luminance)
noise = pyvips.Image.gaussnoise(
image.width,
image.height,
sigma=config.grain_amount,
mean=0,
seed=config.seed,
)
image + noise * shadow_bias
}
apply_vignette = (image, config: GradePreset) => {
coordinates = pyvips.Image.xyz(image.width, image.height).bandsplit()
x = (coordinates[0] - (image.width - 1) / 2) / (image.width / 2)
y = (coordinates[1] - (image.height - 1) / 2) / (image.height / 2)
radius = (x ** 2 + y ** 2) / 2
image * (1 - config.vignette_strength * radius)
}
print("⇒ Chargement de l'en-tête")
source = pyvips.Image.new_from_file(str(input_path), access='sequential').autorot().colourspace('srgb')
print(f" {source.width}×{source.height}, {source.bands} bandes, format {source.format}")
print()
print(f"⇒ Construction du pipeline lazy « {preset.name} »")
graded = apply_contrast(source, preset)
graded = apply_split_toning(graded, preset)
graded = apply_grain(graded, preset)
graded = apply_vignette(graded, preset)
graded = clamp(graded, 0, 255).cast('uchar')
print(" LUT → split toning → grain → vignettage")
print(" aucun pixel n'est encore écrit")
graded_path = output_dir / f'pyvips_{preset.name}_graded.jpg'
print()
print("⇒ Évaluation du pipeline à l'écriture")
graded.write_to_file(str(graded_path), Q=92, strip=True, optimize_coding=True)
print(f" résultat → {graded_path.name}")
# Deux chargements indépendants permettent à chaque branche de la planche de
# rester séquentielle. Partager la source déjà évaluée imposerait des lectures
# arrière incompatibles avec access='sequential'.
original_preview = pyvips.Image.new_from_file(str(input_path), access='sequential').autorot().colourspace('srgb')
graded_preview = pyvips.Image.new_from_file(str(graded_path), access='sequential').colourspace('srgb')
comparison = pyvips.Image.arrayjoin(
list(original_preview, graded_preview),
across=2,
shim=12,
background=list(245, 245, 245),
)
comparison_path = output_dir / f'pyvips_{preset.name}_comparison.jpg'
comparison.write_to_file(str(comparison_path), Q=92, strip=True, optimize_coding=True)
print(f" avant/après → {comparison_path.name}")
print()
print(f"⇒ Sorties disponibles dans {output_dir}")