docs: image brief and treatment tooling

- docs/image-brief.md: one treatment for every image, keyed to the site palette
- tools/images/treat.py: cutout (rembg) and treat (Pillow) as separate, re-runnable steps
- tools/images/README.md: how to run it without polluting the NixOS python
This commit is contained in:
Sam Rolfe
2026-10-29 13:31:20 +11:00
parent 396eeb175e
commit 6900101506
3 changed files with 212 additions and 0 deletions

80
docs/image-brief.md Normal file
View File

@@ -0,0 +1,80 @@
# Image brief
Every image on this site gets the same treatment. Consistency is what makes a set look
designed; six images treated six ways look worse than six treated identically.
The palette is fixed by `internal/web/static/site.css`:
| Token | Value | Use |
|---|---|---|
| `--bg` | `#0b0d10` | canvas and fade colour. Never pure black |
| `--surface` | `#12161b` | screenshot backgrounds |
| `--line` | `#242c35` | screenshot borders |
| `--line-strong` | `#33404d` | diagram strokes |
| `--text-dim` | `#b3bcc8` | diagram labels |
| `--link` | `#8ecbff` | rim glow |
| `--accent` | `#6ee7c6` | sparing emphasis only |
## Why white-background product shots fail here
`#FFFFFF` on a `#0b0d10` page is roughly twenty times the brightness of everything around
it. The brightest thing in the frame becomes the backdrop rather than the object, and the
eye lands on the backdrop first. One reads as an accident; six read as a scrapbook.
## Photographs
- **Canvas** 1600×1000 for hero images, 1200×1200 for detail. Landscape unless the object demands otherwise.
- **Background** `#0b0d10`, the page colour. Not black — pure black always looks like a void.
- **Subject** cut out, placed right of centre, occupying 55–70% of the canvas height. Off-centre because the fade comes from the left.
- **Fade** a `#0b0d10` gradient, 100% opacity at the left edge to 0% at about 55% across. The object emerges from the page and dissolves to the right. This is the move that stops an image looking pasted on.
- **Rim glow** `#8ecbff` at 25–40% opacity, 8–14px blur, derived from the cut-out's own alpha channel so it follows real edges. Enough to lift the object off the background. Not enough to look like a gaming mouse.
- **Shadow lift** 5–8% on the subject itself. Cut-outs on dark backgrounds lose their dark detail and read as flat stickers otherwise.
- **Format** WebP, quality 82, plus a JPEG fallback. Target under 120 KB for a hero.
## Screenshots
Never cut out. Frame them like the site's own panels:
- corner radius `10px` (`--radius`)
- 1px border in `--line`
- edges darkened 8%
- soft drop shadow
- crop out browser chrome where possible
## Diagrams
Stay SVG and stay in the palette: `--line-strong` strokes, `--text-dim` labels, `--accent`
and `--link` for emphasis. No white fills, no default Mermaid blues.
## Logos
One colour, inheriting `currentColor` from the surrounding text. Forty-six client logos in
full colour would shriek. If one deserves emphasis, give it `--accent`; the rest stay muted.
## Prohibited
White boxes · stock photography · lens flares · 3D renders of objects not owned ·
glow strong enough to be the point · any image containing a family member, an address,
a credential, or a screen showing personal data.
## Capture guidance
Treatment rescues a product shot; it cannot rescue a photograph of a white wall in daylight.
- dark surface, one light source from one side
- shoot slightly above the object
- keep the object off-centre in frame
- no overhead lighting, no visible background clutter
- a phone is fine
Rack and cabinet shots are the exception: keep them whole and wide, framed like
screenshots rather than cut out.
## Output contract
Every image lands in `content/assets/images/`, named lowercase and hyphenated with no
spaces or dates. Every image used on a page carries a caption naming the object; a product
photograph additionally states that it is a product image and where it came from. The
original, if it is Sam's own photograph or a sourced product image, is also filed in the
repository of the project it belongs to, under `docs/images/`, with provenance recorded in
that folder's `README.md`.

35
tools/images/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Image treatment
Implements `docs/image-brief.md`. Two steps, in this order.
## 1. Cut the subject out
Needs `rembg`. It is not installed on the host, and installing Python packages on NixOS
into the system interpreter is a bad idea. Use a container:
```
docker run --rm -v "$PWD/raw:/in" -v "$PWD/build/cut:/out" \
-v "$PWD/tools/images/treat.py:/treat.py" \
python:3.12-slim sh -c "pip install -q rembg[cpu] pillow onnxruntime && \
python /treat.py cutout /in/*.jpg --out /out"
```
Or cut out by hand in GIMP and skip to step 2. For fewer than ten images, hand-cutting is
usually better — automatic segmentation struggles with reflective or thin edges.
## 2. Apply the treatment
```
pip install pillow # or the same container, without rembg
python3 tools/images/treat.py treat build/cut/*.png --size hero
```
Output lands in `content/assets/images/` as WebP at quality 82, canvas `#0b0d10`, subject
right of centre, left-to-right fade, rim glow from the alpha channel.
## Then
1. Add the page reference: `![caption](../../assets/images/name.webp)`
2. File the original in the project's own repo under `docs/images/`, with a note in that
folder's `README.md` saying where it came from.
3. Deploy and check it on the live site at 1440×900 and on a phone.

97
tools/images/treat.py Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Apply the site image treatment described in docs/image-brief.md.
Two jobs, both idempotent and re-runnable:
cutout remove the background from a product photograph (needs rembg)
treat compose onto the dark canvas with the fade and glow (needs Pillow)
Typical use:
python3 tools/images/treat.py cutout raw/*.jpg -> build/cut/*.png
python3 tools/images/treat.py treat build/cut/*.png -> content/assets/images/*.webp
If rembg is not available, cut the subject out by hand in GIMP, save as PNG with
transparency, and run only the `treat` step.
"""
import argparse, pathlib, sys
BG = (0x0b, 0x0d, 0x10) # --bg
GLOW = (0x8e, 0xcb, 0xff) # --link
SIZES = {"hero": (1600, 1000), "detail": (1200, 1200)}
FADE_END = 0.55 # gradient reaches 0 opacity here
GLOW_A = 0.32
GLOW_BLUR = 11
LIFT = 1.06 # shadow lift on the subject
def cutout(paths, outdir):
try:
from rembg import remove
except ImportError:
sys.exit("rembg not installed. See tools/images/README.md, or cut out by hand and skip this step.")
from PIL import Image
outdir.mkdir(parents=True, exist_ok=True)
for p in paths:
src = Image.open(p).convert("RGBA")
dst = remove(src)
target = outdir / (pathlib.Path(p).stem + ".png")
dst.save(target)
print(f"cut {p} -> {target}")
def treat(paths, outdir, size="hero"):
from PIL import Image, ImageFilter, ImageEnhance
W, H = SIZES[size]
outdir.mkdir(parents=True, exist_ok=True)
for p in paths:
subject = Image.open(p).convert("RGBA")
# fit to 68% of canvas height, keep aspect
scale = (H * 0.68) / subject.height
subject = subject.resize((max(1, int(subject.width * scale)), int(H * 0.68)), Image.LANCZOS)
# shadow lift
rgb = ImageEnhance.Brightness(subject.convert("RGB")).enhance(LIFT)
subject = Image.merge("RGBA", (*rgb.split(), subject.split()[3]))
canvas = Image.new("RGB", (W, H), BG)
# place right of centre
x = int(W * 0.52)
y = (H - subject.height) // 2
canvas.paste(subject, (x, y), subject)
# rim glow from the subject's own alpha
alpha = subject.split()[3].filter(ImageFilter.GaussianBlur(GLOW_BLUR))
glow = Image.new("RGB", subject.size, GLOW)
canvas.paste(glow, (x, y), alpha.point(lambda a: int(a * GLOW_A / 255)))
# left-to-right fade back to the page colour
fade = Image.new("L", (W, 1))
end = int(W * FADE_END)
for px in range(W):
fade.putpixel((px, 0), 255 if px < end else max(0, int(255 * (1 - (px - end) / max(1, W - end)))))
fade = fade.resize((W, H))
canvas = Image.composite(Image.new("RGB", (W, H), BG), canvas, fade)
target = outdir / (pathlib.Path(p).stem + ".webp")
canvas.save(target, "WEBP", quality=82, method=6)
kb = target.stat().st_size / 1024
print(f"treat {p} -> {target} ({kb:.0f} KB)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("step", choices=["cutout", "treat"])
ap.add_argument("paths", nargs="+", type=pathlib.Path)
ap.add_argument("--out", type=pathlib.Path, default=None)
ap.add_argument("--size", choices=list(SIZES), default="hero")
a = ap.parse_args()
out = a.out or pathlib.Path("build" if a.step == "cutout" else "content/assets/images")
(cutout if a.step == "cutout" else treat)(a.paths, out) if a.step == "cutout" else treat(a.paths, out, a.size)
if __name__ == "__main__":
main()