2 Commits

Author SHA1 Message Date
Sam Rolfe
6900101506 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
2026-10-29 13:31:20 +11:00
Sam Rolfe
396eeb175e content: car tracker page with a real hardware image
- content/edge/l3/edge-car-tracker.md, written from the evidence notes
- product image of the SIM7080G Cat-M module, cropped to 640px, with a caption
  naming what it is and that it is a product image
- referenced from the edge overview
- original kept in sam/CAR_GPS_TRACKER so the project repo documents its own build
2026-10-29 11:24:26 +11:00
6 changed files with 264 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -8,7 +8,7 @@ confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Vehicle tracker on LTE Cat-M", url: "content/edge/l3/edge-car-tracker.md"}]
public: true
last_verified: 2026-10-26
tags: [edge, iot, esp32, lora, zigbee]

View File

@@ -0,0 +1,51 @@
---
id: edge-car-tracker
area: edge
level: 3
title: "Artefact — Vehicle tracker on LTE Cat-M"
claim: "A tracker that sleeps between reports · wakes on a schedule or when the car moves · and reports when it leaves a boundary set from the web interface."
confidence: in-progress
period: "2024–"
tech: [ESP32, LTE Cat-M, NB-IoT, SIM7080G, GPS, LiFePO4]
metrics: [{label: "battery packs", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-28
tags: [edge, tracker, lte, low-power, artefact]
---
The build is on the bench: two LiFePO batteries, a buck converter, an ESP32 with GPS, and a
cellular module for the uplink. The car keeps the packs charged while it is running.
![Waveshare Pico SIM7080G Cat-M and NB-IoT module — the cellular link in the tracker. Product image, used to identify the part.](../../assets/images/sim7080g-module.jpg)
## What it does
Two behaviours, chosen by the settings rather than hard-coded. When the car is parked the
ESP32 sits in deep sleep, wakes on a timer, checks its own configuration, and reports only
what the settings ask for. When the car is moving it stays awake and reports on a shorter
interval. Either way the interval and the boundary can be changed from the web interface,
and a change can force an immediate update rather than waiting for the next wake.
The geofence is the part that matters. A boundary is drawn from the web interface, and the
device is responsible for deciding whether it has left it — which is why the settings have
to travel to the device rather than live only in the server.
## Why LTE Cat-M rather than WiFi
The tracker lives in a car. WiFi coverage is a driveway, not a road. Cat-M and NB-IoT were
chosen because they are designed for exactly this shape of traffic — small payloads, long
sleeps, coverage beyond what 4G handsets need — and because the modules are cheap enough to
leave installed.
## What it proves
A complete path rather than a demo: constrained hardware, a power budget that dictates the
schedule, a device that holds enough state to act on its own, and a web application that
configures it and shows where it has been.
## Still open
Field testing across the full battery cycle. The sleep interval that is right for a parked
car and the interval that is right for a moving one are guesses until the discharge curve
has been measured over a week rather than a day.

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()