diff --git a/docs/image-brief.md b/docs/image-brief.md new file mode 100644 index 0000000..ac0ade4 --- /dev/null +++ b/docs/image-brief.md @@ -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`. diff --git a/tools/images/README.md b/tools/images/README.md new file mode 100644 index 0000000..b77a25b --- /dev/null +++ b/tools/images/README.md @@ -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. diff --git a/tools/images/treat.py b/tools/images/treat.py new file mode 100755 index 0000000..a0de0d1 --- /dev/null +++ b/tools/images/treat.py @@ -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()