Files
Halcyon/docs/pdf/photocopy.py
T
2026-09-20 14:16:41 +02:00

127 lines
5.5 KiB
Python

"""Make a page image look like a photocopy of a photocopy.
`intensity` scales the whole effect: 1.0 is a badly worn copy of a copy, 0.5 a
tired one, 0 leaves the image alone.
Each "generation" re-scans the previous copy: a little skew and shift, blur from
the optics, a steeper toner curve, uneven toner, speckle, dust streaks down the
page and dark shadows where the lid did not sit flat. Colour survives only as
faint warmth, so coffee stains stay recognisable but lose their punch.
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
def _lowfreq(rng, w, h, cells_x, cells_y, amp):
"""Smooth random field in [-amp, amp], roughly cells_x by cells_y blobs."""
small = rng.normal(0, 1, (cells_y + 2, cells_x + 2)).astype(np.float32)
small = (small - small.min()) / (np.ptp(small) + 1e-6) * 2 - 1
img = Image.fromarray(small, mode="F").resize((w, h), Image.BICUBIC)
return np.asarray(img, dtype=np.float32) * amp
def _speckles(rng, w, h, count, rmin, rmax):
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
count = max(0, count)
for _ in range(count):
x, y = rng.uniform(0, w), rng.uniform(0, h)
r = rng.uniform(rmin, rmax)
d.ellipse((x - r, y - r, x + r, y + r), fill=int(rng.uniform(120, 255)))
return np.asarray(mask.filter(ImageFilter.GaussianBlur(0.5)), dtype=np.float32) / 255.0
def _streaks(rng, arr, gen, t):
h, w = arr.shape
rows = np.arange(h)
for _ in range(int(rng.integers(2, 5 + gen))):
x0 = rng.uniform(0, w)
xs = x0 + np.cumsum(rng.normal(0, 0.12, h)) # the streak wanders a little
width = int(rng.integers(1, 4))
strength = rng.uniform(0.03, 0.13) * t
# dust comes and goes along the page
fade = 0.55 + 0.45 * np.sin(rows / rng.uniform(60, 400) + rng.uniform(0, 6.28))
fade *= np.clip(_1d(rng, h) + 0.6, 0, 1)
for dx in range(width):
cols = np.clip(xs.astype(int) + dx, 0, w - 1)
arr[rows, cols] -= strength * fade * (1.0 if dx == 0 else 0.55)
# one broad, faint band where the roller was tired
x0 = int(rng.uniform(0.1, 0.9) * w)
arr[:, x0:x0 + int(rng.uniform(20, 60))] *= 1 - (1 - rng.uniform(0.985, 0.997)) * t
def _1d(rng, n):
pts = rng.normal(0, 0.5, n // 40 + 3).astype(np.float32)
return np.interp(np.arange(n), np.linspace(0, n - 1, len(pts)), pts)
def _edge_shadows(rng, arr, t):
h, w = arr.shape
for edge in rng.permutation(4)[: int(rng.integers(1, 3))]:
depth = int(rng.uniform(8, 26))
strength = rng.uniform(0.18, 0.42) * t
ramp = np.linspace(strength, 0, depth, dtype=np.float32) ** 1.6
jag = 1 + 0.35 * _1d(rng, max(h, w))
if edge == 0:
arr[:, :depth] -= ramp[None, :] * jag[:h, None]
elif edge == 1:
arr[:, w - depth:] -= ramp[::-1][None, :] * jag[:h, None]
elif edge == 2:
arr[:depth, :] -= ramp[:, None] * jag[None, :w]
else:
arr[h - depth:, :] -= ramp[::-1][:, None] * jag[None, :w]
def _generation(rng, luma, gen, t):
"""One trip through the copier. luma is a float array in [0, 1]."""
h, w = luma.shape
im = Image.fromarray((np.clip(luma, 0, 1) * 255).astype(np.uint8), "L")
# placed slightly crooked on the glass; the lid backing shows in the wedges
im = im.rotate(rng.uniform(-0.28, 0.28) * t, resample=Image.BICUBIC, fillcolor=70,
translate=(rng.uniform(-4, 4) * t, rng.uniform(-4, 4) * t))
# toner spreads: thin strokes fatten a little on every pass
fat = np.asarray(im.filter(ImageFilter.MinFilter(3)), dtype=np.float32) / 255.0
thin = np.asarray(im, dtype=np.float32) / 255.0
im = Image.fromarray((np.clip((1 - 0.14 * t) * thin + 0.14 * t * fat, 0, 1) * 255).astype(np.uint8), "L")
if t > 0:
im = im.filter(ImageFilter.GaussianBlur(t * (0.55 + 0.05 * gen))) # optics
a = np.asarray(im, dtype=np.float32) / 255.0
# a faint ghost from the previous pass being a hair out of register
dy, dx = int(rng.integers(-2, 3)), int(rng.integers(-2, 3))
if dx or dy:
a = (1 - 0.12 * t) * a + 0.12 * t * np.roll(a, (dy, dx), axis=(0, 1))
# toner curve: blacker blacks, paler paper, but keep mid-tones for stains
k, c = 4.6 + 0.4 * gen, 0.50
s = lambda x: 1 / (1 + np.exp(-k * (x - c)))
a = (1 - t) * a + t * (s(a) - s(0.0)) / (s(1.0) - s(0.0))
# uneven toner and grain
a *= 1 + _lowfreq(rng, w, h, 5, 7, 0.022 * t) + _lowfreq(rng, w, h, w // 45, h // 45, 0.014 * t)
a += rng.normal(0, 0.018 * t, a.shape).astype(np.float32)
# dust and toner specks; a few white dropouts inside the ink
a = np.minimum(a, 1 - 0.65 * _speckles(rng, w, h, int((150 + 90 * gen) * t), 0.5, 1.5))
a = np.maximum(a, 0.85 * _speckles(rng, w, h, int(110 * t), 0.5, 1.3) * (a < 0.35))
_streaks(rng, a, gen, t)
_edge_shadows(rng, a, t)
return np.clip(a, 0, 1)
def photocopy(img, seed, copies=3, intensity=1.0):
"""Return a degraded RGB copy of `img` (an RGB PIL image)."""
rng = np.random.default_rng(seed)
ycc = img.convert("YCbCr")
luma = np.asarray(ycc.split()[0], dtype=np.float32) / 255.0
for g in range(copies):
luma = _generation(rng, luma, g, intensity)
# keep only a whisper of the original colour, smeared
_, cb, cr = ycc.split()
soften = lambda ch: ch.filter(ImageFilter.GaussianBlur(5)).point(lambda v: 128 + (v - 128) * (1 - 0.15 * intensity))
y = Image.fromarray((luma * 255).astype(np.uint8), "L")
return Image.merge("YCbCr", (y, soften(cb), soften(cr))).convert("RGB")