127 lines
5.5 KiB
Python
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")
|