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:
35
tools/images/README.md
Normal file
35
tools/images/README.md
Normal 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: ``
|
||||
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
97
tools/images/treat.py
Executable 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()
|
||||
Reference in New Issue
Block a user