mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat: add post-processing script for analog print effects
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>
This commit is contained in:
@@ -269,6 +269,49 @@ State what to **preserve** from the reference, then what to **add**:
|
||||
|
||||
---
|
||||
|
||||
## Post-Processing
|
||||
|
||||
Raw AI-generated images are too clean for the Nous aesthetic. **Post-processing is mandatory** after every generation. The `scripts/postprocess.py` script applies analog-print degradation effects locally using Pillow + numpy — no API calls needed.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
python3 scripts/postprocess.py input.png output.png --mode imprint --intensity 0.7
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Effects | When |
|
||||
|------|---------|------|
|
||||
| `imprint` | All 14 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 | **Default for v9/v10/v11 targets** — maximum analog print character |
|
||||
| `nous` | Base 9 effects (warm grade → ink bleed) — no xerox/registration/wobble/scuffs | Legacy luminous PNW/celestial requests |
|
||||
| `standard` | Base 6 effects (warm grade → chromatic aberration) only | When you want just a light texture touch |
|
||||
|
||||
### Intensity Calibration
|
||||
|
||||
| Intensity | Best for |
|
||||
|-----------|----------|
|
||||
| `0.45–0.55` | Fine manga linework, Future Halftone, Portal Minimal — keep detail visible |
|
||||
| `0.55–0.65` | Blueprint Scene, general use — avoid crushing midtones |
|
||||
| `0.65–0.8` | Xerox Poster, Acid Signal, heavy print effect — when the raw output is too clean |
|
||||
| `0.8+` | Aggressive degradation — typography may become hard to read |
|
||||
|
||||
### Integration
|
||||
|
||||
Run post-processing as the final step after any generation method (text-only, img2img, multi-pass, or any provider):
|
||||
|
||||
```bash
|
||||
# After any generation method:
|
||||
python3 scripts/postprocess.py output-raw.png output-final.png --mode imprint --intensity 0.7
|
||||
|
||||
# For legacy luminous targets:
|
||||
python3 scripts/postprocess.py output-raw.png output-final.png --mode nous --intensity 0.5
|
||||
```
|
||||
|
||||
**The raw generated image is never the final deliverable.**
|
||||
|
||||
---
|
||||
|
||||
## API Workflow Notes
|
||||
|
||||
| API | Approach |
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/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.")
|
||||
Reference in New Issue
Block a user