mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-21 16:46:26 +03:00
Adds scripts/postprocess.py with 14 analog-print degradation effects (warm grade, CRT scanlines, film grain, Bayer dither, vignette, chromatic aberration, screen print texture, paper fiber, ink bleed, palette compression, xerox threshold, registration offset, plate wobble, print scuffs) and corresponding SKILL.md documentation. Adapted from the approach pioneered by plntrprotocol/nous-branding (MIT) — same goals, independent implementation. Supports three modes: imprint (full 14 effects), nous (9 base effects), standard (6 light effects). Intensity calibration from 0.45 to 0.8+. Closes #33 Signed-off-by: Magnus Hedemark <magnus919@pm.me>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
postprocess.py — Analog print effects for Nous-branded images.
|
|
|
|
Applies a sequence of analog-print degradation effects to AI-generated images,
|
|
transforming clean digital output into something that looks physically printed,
|
|
xeroxed, or risographed.
|
|
|
|
Inspired by plntrprotocol/nous-branding (MIT) — same goals, independent
|
|
implementation. See https://github.com/plntrprotocol/nous-branding
|
|
|
|
Modes:
|
|
--mode imprint Full 14-effect print degradation (default, for v9/v10/v11)
|
|
--mode nous Legacy: warm grade + grain + screen print (no xerox/registration)
|
|
--mode standard Light touch: grain + vignette + chroma aberration only
|
|
|
|
Usage:
|
|
python3 scripts/postprocess.py input.png output.png --mode imprint --intensity 0.7
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import numpy as np
|
|
from PIL import Image, ImageFilter, ImageEnhance, ImageOps
|
|
|
|
# ── Imprint palette (constrained 2-4 ink print colors for palette compression) ──
|
|
IMPRINT_PALETTE = [
|
|
"#F7EDE3", "#E8E0D4", "#C15811", "#F59E0B",
|
|
"#0E2723", "#1A3A32", "#00AEEF", "#8B5CF6",
|
|
"#6B7280", "#2D5016", "#0A0A1A", "#D946EF",
|
|
"#BFE8F2", "#071616", "#F04A23", "#D8D061",
|
|
]
|
|
|
|
|
|
def warm_grade(img, strength=0.15):
|
|
"""Warm color grade — push shadows toward amber/gold."""
|
|
arr = np.array(img).astype(np.float32)
|
|
lum = (arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114) / 255
|
|
shadow = 1.0 - lum
|
|
arr[:, :, 0] += shadow * strength * 12
|
|
arr[:, :, 2] -= shadow * strength * 6
|
|
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def crt_scanlines(img, opacity=0.06, every=2):
|
|
"""Faint horizontal CRT scanlines."""
|
|
w, h = img.size
|
|
overlay = Image.new("L", (w, h), 255)
|
|
px = overlay.load()
|
|
for y in range(h):
|
|
if y % every == 0:
|
|
for x in range(w):
|
|
px[x, y] = int(255 * (1 - opacity))
|
|
return Image.composite(img, Image.new("RGB", img.size, (0, 0, 0)), overlay)
|
|
|
|
|
|
def film_grain(img, intensity=0.04):
|
|
"""Fine Gaussian noise across entire image."""
|
|
w, h = img.size
|
|
noise = np.random.normal(0, 255 * intensity, (h, w, 3)).astype(np.float32)
|
|
return Image.fromarray(np.clip(np.array(img).astype(np.float32) + noise, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def bayer_dither(img, strength=0.3, levels=12):
|
|
"""4x4 ordered Bayer dither matrix — simulates risograph halftone."""
|
|
bayer = np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], dtype=np.float32)
|
|
bayer_n = (bayer / 16.0 - 0.5) * strength
|
|
w, h = img.size
|
|
tile = np.tile(bayer_n, ((h + 3) // 4, (w + 3) // 4))[:h, :w]
|
|
tile3 = np.stack([tile] * 3, axis=-1) * 255
|
|
arr = np.array(img).astype(np.float32)
|
|
q = np.round(arr / (256 / levels)) * (256 / levels)
|
|
d = q + tile3 * (256 / levels) / levels
|
|
return Image.fromarray(np.clip(d, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def vignette(img, strength=0.35):
|
|
"""Darken and warm edges toward corners."""
|
|
w, h = img.size
|
|
x = np.linspace(-1, 1, w)
|
|
y = np.linspace(-1, 1, h)
|
|
xx, yy = np.meshgrid(x, y)
|
|
dist = np.sqrt(xx**2 + yy**2)
|
|
mask = np.clip(1.0 - (dist / np.sqrt(2)) * strength, 0.3, 1.0)
|
|
arr = np.array(img).astype(np.float32)
|
|
m3 = np.stack([mask] * 3, axis=-1)
|
|
v = arr * m3
|
|
edge = (1 - mask) * 8
|
|
v[:, :, 0] += edge
|
|
v[:, :, 2] -= edge * 0.5
|
|
return Image.fromarray(np.clip(v, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def chromatic_aberration(img, shift=1.0):
|
|
"""Subtle RGB channel separation at edges."""
|
|
arr = np.array(img)
|
|
px = max(1, int(round(shift)))
|
|
r = np.roll(arr[:, :, 0], -px, axis=1)
|
|
b = np.roll(arr[:, :, 2], px, axis=1)
|
|
out = arr.copy()
|
|
out[:, :, 0] = r
|
|
out[:, :, 2] = b
|
|
return Image.fromarray(out)
|
|
|
|
|
|
def screen_print_texture(img, intensity=0.3):
|
|
"""Simulate risograph/screen-print halftone dot pattern."""
|
|
w, h = img.size
|
|
dot_size = 3
|
|
yy, xx = np.mgrid[0:h, 0:w]
|
|
offset = (np.arange(h) // dot_size % 2) * (dot_size // 2)
|
|
cx = (xx + offset[:, None]) % dot_size
|
|
cy = yy % dot_size
|
|
dist = np.sqrt((cx - dot_size / 2) ** 2 + (cy - dot_size / 2) ** 2)
|
|
lum = np.array(img.convert("L")).astype(float) / 255.0
|
|
dot_mask = np.clip(1.0 - dist / (dot_size * 0.7), 0, 1)
|
|
dot_mask = dot_mask * (1.0 - lum) * intensity
|
|
dot_3 = np.stack([dot_mask] * 3, axis=-1)
|
|
arr = np.array(img).astype(np.float32)
|
|
textured = arr * (1.0 - dot_3 * 0.15)
|
|
return Image.fromarray(np.clip(textured, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def paper_texture(img, intensity=0.15):
|
|
"""Subtle paper/canvas fiber substrate."""
|
|
w, h = img.size
|
|
noise = np.random.normal(0, 1, (max(1, h // 4), max(1, w // 4))).astype(np.float32)
|
|
denom = max(float(noise.max() - noise.min()), 1e-6)
|
|
n_img = Image.fromarray(((noise - noise.min()) / denom * 255).astype(np.uint8))
|
|
n_img = n_img.resize((w, h), Image.Resampling.BILINEAR)
|
|
n_arr = np.array(n_img).astype(float) / 255.0
|
|
n_3 = np.stack([n_arr] * 3, axis=-1)
|
|
arr = np.array(img).astype(float)
|
|
textured = arr * (1.0 + (n_3 - 0.5) * intensity)
|
|
return Image.fromarray(np.clip(textured, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def ink_bleed(img, intensity=0.2):
|
|
"""Slight blur + darken at dark edges to simulate ink spread on paper."""
|
|
blurred = img.filter(ImageFilter.GaussianBlur(radius=0.5))
|
|
arr_orig = np.array(img).astype(float)
|
|
arr_blur = np.array(blurred).astype(float)
|
|
edges_img = img.convert("L").filter(ImageFilter.FIND_EDGES).filter(ImageFilter.GaussianBlur(radius=0.6))
|
|
edges = np.array(edges_img).astype(float)
|
|
if edges.max() > 0:
|
|
edges = edges / edges.max()
|
|
edge_3 = np.stack([edges] * 3, axis=-1)
|
|
blended = arr_orig * (1 - edge_3 * intensity) + arr_blur * (edge_3 * intensity)
|
|
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def palette_compress(img, strength=0.45):
|
|
"""Pull clean RGB renders toward a limited 2-4 ink print palette."""
|
|
palette = Image.new("P", (1, 1))
|
|
colors = []
|
|
for hx in IMPRINT_PALETTE:
|
|
hx = hx.lstrip("#")
|
|
colors.extend([int(hx[i:i + 2], 16) for i in (0, 2, 4)])
|
|
colors.extend([0] * (768 - len(colors)))
|
|
palette.putpalette(colors)
|
|
quantized = img.quantize(palette=palette, dither=Image.Dither.FLOYDSTEINBERG).convert("RGB")
|
|
return Image.blend(img, quantized, float(np.clip(strength, 0, 1)))
|
|
|
|
|
|
def xerox_threshold(img, strength=0.25):
|
|
"""Degraded photocopy contrast with breakup."""
|
|
gray = ImageOps.grayscale(img)
|
|
gray = ImageEnhance.Contrast(gray).enhance(1.0 + 2.4 * strength)
|
|
arr = np.array(gray).astype(np.float32)
|
|
noise = np.random.normal(0, 24 * strength, arr.shape)
|
|
arr = np.clip(arr + noise, 0, 255)
|
|
poster = np.where(arr > (128 - 18 * strength), 235, 20).astype(np.uint8)
|
|
tint = ImageOps.colorize(Image.fromarray(poster), black="#071616", white="#DCEAF0")
|
|
return Image.blend(img, tint, float(np.clip(strength * 0.55, 0, 0.45)))
|
|
|
|
|
|
def registration_offset(img, shift=1.0, opacity=0.35):
|
|
"""Simulate misregistered cyan/orange ink plates."""
|
|
px = max(1, int(round(shift)))
|
|
op = float(np.clip(opacity, 0, 1))
|
|
arr = np.array(img).astype(np.float32)
|
|
cyan = np.roll(arr, -px, axis=1)
|
|
orange = np.roll(arr, px, axis=0)
|
|
out = arr.copy()
|
|
out[:, :, 1] = out[:, :, 1] * (1 - op * 0.16) + cyan[:, :, 1] * op * 0.16
|
|
out[:, :, 2] = out[:, :, 2] * (1 - op * 0.24) + cyan[:, :, 2] * op * 0.24
|
|
out[:, :, 0] = out[:, :, 0] * (1 - op * 0.18) + orange[:, :, 0] * op * 0.18
|
|
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def plate_wobble(img, strength=0.35):
|
|
"""Subtle row-wise print wobble so crisp lines stop feeling digital."""
|
|
arr = np.array(img)
|
|
h, w = arr.shape[:2]
|
|
rng = np.random.default_rng()
|
|
coarse = rng.normal(0, max(0.15, strength), max(4, h // 48))
|
|
offsets = np.interp(np.arange(h), np.linspace(0, h - 1, len(coarse)), coarse)
|
|
out = np.empty_like(arr)
|
|
for y in range(h):
|
|
out[y] = np.roll(arr[y], int(round(offsets[y])), axis=0)
|
|
return Image.fromarray(out)
|
|
|
|
|
|
def print_scuffs(img, intensity=0.25):
|
|
"""Sparse scratches and imperfect ink pickup."""
|
|
w, h = img.size
|
|
arr = np.array(img).astype(np.float32)
|
|
scuff = np.zeros((h, w), dtype=np.float32)
|
|
rng = np.random.default_rng()
|
|
for _ in range(int(24 * intensity) + 3):
|
|
y = int(rng.integers(0, h))
|
|
x0 = int(rng.integers(0, max(1, w - 1)))
|
|
length = int(rng.integers(max(8, w // 24), max(12, w // 5)))
|
|
thickness = int(rng.integers(1, 3))
|
|
x1 = min(w, x0 + length)
|
|
scuff[max(0, y - thickness):min(h, y + thickness + 1), x0:x1] = float(rng.uniform(0.25, 0.8))
|
|
scuff_img = Image.fromarray((scuff * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(radius=0.6))
|
|
mask = (np.array(scuff_img).astype(np.float32) / 255.0)[:, :, None]
|
|
paper = np.array(Image.new("RGB", img.size, "#F7EDE3")).astype(np.float32)
|
|
out = arr * (1 - mask * intensity * 0.35) + paper * (mask * intensity * 0.35)
|
|
return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
|
|
|
|
|
|
def process(input_path, output_path, intensity=0.5, mode="imprint"):
|
|
"""Run the full processing pipeline."""
|
|
print(f"Processing: {input_path} (mode={mode}, intensity={intensity})")
|
|
img = Image.open(input_path).convert("RGB")
|
|
w, h = img.size
|
|
print(f" Input: {w}x{h}")
|
|
s = intensity
|
|
|
|
steps = [
|
|
("Warm grade", lambda: warm_grade(img, 0.12 * s)),
|
|
("CRT scanlines", lambda: crt_scanlines(img, 0.04 * s, 2)),
|
|
("Film grain", lambda: film_grain(img, 0.035 * s)),
|
|
("Bayer dither", lambda: bayer_dither(img, 0.2 * s, 12)),
|
|
("Vignette", lambda: vignette(img, 0.3 * s)),
|
|
("Chromatic aberr.", lambda: chromatic_aberration(img, 0.8 * s)),
|
|
]
|
|
|
|
if mode in ("risograph", "nous", "imprint"):
|
|
steps.extend([
|
|
("Screen print", lambda: screen_print_texture(img, 0.25 * s)),
|
|
("Paper texture", lambda: paper_texture(img, 0.12 * s)),
|
|
("Ink bleed", lambda: ink_bleed(img, 0.15 * s)),
|
|
])
|
|
|
|
if mode == "imprint":
|
|
steps.extend([
|
|
("Palette compress", lambda: palette_compress(img, 0.55 * s)),
|
|
("Xerox threshold", lambda: xerox_threshold(img, 0.35 * s)),
|
|
("Registration", lambda: registration_offset(img, 1.4 * s, 0.45 * s)),
|
|
("Plate wobble", lambda: plate_wobble(img, 0.7 * s)),
|
|
("Print scuffs", lambda: print_scuffs(img, 0.35 * s)),
|
|
])
|
|
|
|
for i, (name, fn) in enumerate(steps):
|
|
print(f" [{i + 1}/{len(steps)}] {name}...")
|
|
img = fn()
|
|
|
|
img.save(output_path, "PNG")
|
|
sz = os.path.getsize(output_path)
|
|
print(f" Saved: {output_path} ({sz // 1024}KB)")
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Analog print effects for Nous-branded images."
|
|
)
|
|
parser.add_argument("input", help="Input image path")
|
|
parser.add_argument("output", nargs="?", default=None,
|
|
help="Output image path (default: input with -processed suffix)")
|
|
parser.add_argument("--intensity", "-i", type=float, default=0.5,
|
|
help="Effect intensity 0.0-1.0 (default: 0.5)")
|
|
parser.add_argument("--mode", "-m", choices=["standard", "risograph", "nous", "imprint"],
|
|
default="imprint",
|
|
help="Processing mode (default: imprint)")
|
|
args = parser.parse_args()
|
|
out = args.output or args.input.replace(".png", "-processed.png")
|
|
process(args.input, out, args.intensity, args.mode)
|
|
print("Done.")
|