Split group cycling photos into per-rider enhanced portraits. A peloton is the pack of riders — this finds each one and gives you a focused, sharpened picture of them, then helps you size, organize, and cull the results.
group photo → detect riders (YOLO person+bicycle) → crop each (person ∪ bike)
→ dehaze + auto-brighten → keep NATIVE detail (upscale only when a crop
is smaller than the target — a sharp high-res crop is left as-is;
Real-ESRGAN would over-smooth it) → [SAM cutout] → [face-restore]
→ a tight _single (+ optional wider _context that also captures the
surrounding riders) → out/<stem>_riderNN[.jpg | .tif]
Per-feature specs live in docs/ — one document per capability,
each grounded in the FFL docstrings, handlers, and _peloton_tools code.
| Spec | What it covers |
|---|---|
| portrait-pipeline | Flagship. Group photo → one enhanced portrait per rider (detect → crop → dehaze/brighten → upscale → face-restore); 16-bit TIFF path; per-photo fleet fan-out. |
| detection | YOLO person+bicycle detection, person↔bike pairing, require_bike, mocks. |
| cropping-framing-cutouts | Box geometry, fixed-aspect/print-size framing, SAM cutouts. |
| enhancement | Dehaze, auto-brighten, upscale (Real-ESRGAN → Lanczos), face-restore (GFPGAN/CodeFormer), 16-bit variants. |
| format-conversion | RAW/TIFF/JPEG → TIFF/JPEG at any resolution; adaptive multi-threaded engine; tiffs-to-jpegs. |
| copy-tree | Parallel, restart-safe recursive directory copy. |
| grouping-and-culling | Same-rider face grouping, quality scoring, blur culling. |
| ffl-workflows | The FFL domain layer — facets, workflows, handlers, entry point. |
| storage-and-cache | Local/S3(MinIO) storage backend + sidecar cache (present but not yet wired). |
Full index: docs/README.md.
Two enhancement fixes matter for high-resolution sources (e.g. 45 MP DSLR RAW):
- Detail-preserving. Each rider crop off a big frame is already high-res, so the
Real-ESRGAN 4× upscaler (a low-res super-resolver that denoises) is skipped
unless the crop is genuinely smaller than the target — running it then downscaling
visibly softens a sharp crop.
--upscale-mode auto(default) /never/always. - Dehaze. These frames come off the camera flat (lifted blacks, muted colour); a
per-image black/white-point stretch + mild contrast/colour removes the veil. On by
default (
--no-dehazeto disable).
Output: --out-format jpg (default, 8-bit) or tiff (lossless 16-bit —
decodes RAW at 16-bit and does the tonal math in 16-bit so a heavy brighten/dehaze
stretch stays banding-free; the archival master, large files). Keep the TIFFs as
masters and derive shareable JPEGs on the side with tiffs-to-jpegs (a separate
--in-dir/--out-dir step, no re-processing).
A Facetwork domain package following the tools/handlers pattern. This ships the
reusable library + CLI tools (src/peloton/tools/); the FFL handlers/workflow
are the next phase.
Input formats: JPEG/PNG/WebP/TIFF/BMP, iPhone HEIC/HEIF (pillow-heif,
core), and camera RAW — .nef (Nikon), .cr2/.cr3 (Canon), .arw (Sony),
.dng, .raf, .orf, .rw2, … (rawpy/LibRaw, .[raw] extra). A folder may
mix any of these — each file is decoded by its own format.
| Tool | Does |
|---|---|
detect-riders |
Detect each cyclist → JSON of person/bicycle boxes |
crop-riders |
Detect + crop each rider (no enhance) |
enhance-image |
Upscale + face-restore a single image |
process-photo |
The pipeline: one photo → per-rider portraits (+ optional fixed size / SAM cutout) |
batch-photos |
Run the pipeline over a whole directory (reuses models; running manifest.json) |
group-riders |
Cluster the crops by face → one rider_NNN/ folder per person, best-shot first |
cull-blurry |
Score focus; move out-of-focus photos to _unfocused/ |
tiffs-to-jpegs |
Derive shareable 8-bit JPEGs from a directory of 16-bit TIFF masters (separate step) |
convert-photos |
Convert RAW/TIFF/JPEG → TIFF or JPEG at any resolution (--format/--resize), recursive + adaptive-parallel. (nef-to-tif is a RAW→TIFF alias.) |
copy-tree |
Parallel recursive directory copy — mirror a tree, restart-safe (--workers) |
Every tool: JSON on stdout, logs on stderr, --use-mock (offline, no
models), --log-level. Full reference + the graceful-degradation backends,
--frame/--aspect/--size framing, and --segment cutouts:
src/peloton/tools/README.md.
pip install -e '.[detect,enhance]' # models; core alone runs degraded
# offline smoke (no models/network):
python src/peloton/tools/process_photo.py --image group.jpg --out-dir out/ --use-mock
# a folder → a tight _single + a wider _context per rider, cleaned up, native
# detail, lossless 16-bit TIFF (drop --out-format for 8-bit JPEG):
python src/peloton/tools/batch_photos.py --in-dir photos/ --out-dir out/ \
--require-bike --context --auto-brighten --out-format tiff
# or a tight portrait + a print-ready 4×6 (1200×1800) per rider:
python src/peloton/tools/batch_photos.py --in-dir photos/ --out-dir out/ \
--require-bike --frame both --size 1200x1800
# organize + cull:
python src/peloton/tools/group_riders.py --in-dir out/ --out-dir out/by_rider
python src/peloton/tools/cull_blurry.py --in-dir photos/ --min-sharpness 900
# derive shareable JPEGs from the 16-bit TIFF masters (separate, idempotent step):
python src/peloton/tools/tiffs_to_jpegs.py --in-dir out/ --out-dir out_jpg/
# convert RAW/TIFF/JPEG → TIFF or JPEG, any resolution (convert-photos):
python src/peloton/tools/convert_photos.py --image shot.NEF --out-dir out/ # RAW → 16-bit TIFF, full res
python src/peloton/tools/convert_photos.py --image master.tif --out-dir out/ --format jpeg --resize 2048 # TIF → JPEG, long edge 2048
python src/peloton/tools/convert_photos.py --in-dir jpgs/ --out-dir tifs/ --from jpg # JPEG → TIFF
# a whole tree, mirroring structure (RAW → JPEG @ 3000px), resumable + adaptive-parallel:
python src/peloton/tools/convert_photos.py --in-dir shoots/ --out-dir out/ --recursive --format jpeg --resize 3000 --resume
# --format tif|jpeg · --quality N · --resize N|WxH|50% · --from raw|any|<ext list>
# --workers auto (default): sizes to free CPUs, ramps up on headroom, backs off on saturationThe tools are also exposed as a Facetwork domain (facetwork.domains entry point +
handlers/ + ffl/peloton.ffl), so the pipeline runs on the runtime / fleet.
Event facets (image data flows by reference — file/MinIO paths):
peloton.Portraits.ProcessPhoto(image_path, out_dir, …)→(n_riders, outputs)peloton.Ingest.ConvertRaw(image_path, out_dir, highlight_mode)→(output)peloton.Ingest.ConvertTree(in_dir, out_dir, out_format, resize, from_sel, …)→(converted, skipped, failed)— multi-threaded whole-directory/tree convert (RAW/TIFF/JPEG → TIFF/JPEG)peloton.Ingest.CopyTree(src, dst, workers)→(copied, skipped, failed)— multi-threaded recursive copypeloton.Ingest.ListImages(in_dir)→(paths, count)
Workflows: ProcessOnePhoto, ProcessBatch(paths, out_dir) (fan out per photo),
ConvertBatch(paths, out_dir) (fleet fan-out, one task/file), ConvertDir(in_dir, out_dir, …)
(one step, multi-threaded), CopyDir(src, dst).
The multi-threaded conversion/copy engine (adaptive --workers auto — sizes to free
CPUs, ramps up on headroom, backs off on saturation) is shared by the CLIs
(convert-photos, copy-tree) and these handlers.
pip install -e '.[detect,enhance,raw,domain]' # domain = the facetwork runtime
facetwork compile src/peloton/ffl/peloton.ffl --check # validate the FFL
python -m facetwork.domains --seed peloton # register handlers + seed the flowsA step is name = Facet(args), later steps reference earlier ones as
step.field, and andThen foreach fans the per-photo work out across the fleet:
namespace my.peloton {
use peloton.Ingest
use peloton.Portraits
/** Enumerate a directory, then process every photo in parallel. */
workflow ProcessDir(in_dir: String, out_dir: String) => (count: Long) andThen {
listed = peloton.Ingest.ListImages(in_dir = $.in_dir) andThen foreach p in $.paths {
done = peloton.Portraits.ProcessPhoto(image_path = $.p, out_dir = $$.out_dir)
yield ProcessDir(count = 1)
}
}
}
fw ffl run --primary my.ffl --library src/peloton/ffl/peloton.ffl \
--workflow my.peloton.ProcessDir \
--inputs '{"in_dir": "/data/raw", "out_dir": "/data/out"}'📖 docs/ffl-examples.md — the full example gallery:
fan-out from a facet list vs a CLI list, two-stage convert-then-process, catch
so one corrupt file doesn't kill the batch, when guards, call-time mixins for
long conversions, and reusing the shipped workflows. Every snippet there is
compile-checked.
| Extra | Enables |
|---|---|
detect |
YOLO rider detection + SAM cutouts (ultralytics/torch) |
enhance |
Real-ESRGAN upscale + GFPGAN/CodeFormer face-restore (spandrel/gfpgan) |
recognize |
Same-rider grouping (insightface/onnxruntime) |
raw |
Camera RAW decode (rawpy/LibRaw) |
s3 |
S3/MinIO storage (boto3) |
domain |
Run as an FFL workflow on the Facetwork runtime (facetwork) |
Model weights auto-cache to ~/.cache/peloton/weights. Without a backend, upscale
falls back to Lanczos and face-restore to passthrough; each run records which
backend actually ran.
src/peloton/
tools/
detect_riders crop_riders enhance_image process_photo batch_photos
group_riders cull_blurry tiffs_to_jpegs convert_photos copy_tree (+ .sh wrappers)
_peloton_tools/ images crop detect segment enhance quality recognize
pipeline copytree peloton_mocks sidecar storage
ffl/peloton.ffl event facets + workflows
handlers/ ingest/ (list/convert/convert-tree/copy-tree) + portraits/ + shared/ shim
tests/ offline suite (59 tests, no network/models via --use-mock)
pip install pytest pillow && pytest tests/ -q # all offline- Face grouping is imperfect on helmeted/sunglassed cyclists (weak embeddings);
--thresholdtunes precision/recall. Bib-number OCR would identify riders better. - NEF/RAW decode is validated end-to-end on real Nikon
.NEF(8-bit and 16-bit), including a 1442-photo 45 MP run.