Turn a folder of lab photos into a draft Tumbling Oysters notebook post, using open-weight models running locally on your own machine. No cloud inference, no API keys, works offline.
python3 scripts/p2n.py draft ~/Desktop/oyster-photos --note "35C trial, day 2. Counted morts at 10am."That writes drafts/2026-07-30-<slug>/index.qmd plus an images/ folder — a complete
post with the notebook's frontmatter, draft: true, and the AI-use badge already in
place. Review it, fix what's wrong, then promote it into the notebook.
Field and bench work already writes itself down — on whiteboards, tank labels, tube
racks, datasheets, and instrument displays. The local vision model reads that text back
out, and the drafting step arranges it into a logbook entry in the order the photos were
taken. Your --note says what actually happened; the photos supply the details you'd
otherwise have to retype.
- macOS — image conversion uses the built-in
sips. No Pillow, ImageMagick, or exiftool. - Python 3.8+, stdlib only. Nothing to install.
- ollama running locally, with a vision-capable model:
ollama pull gemma4
gemma4:latestis the default and, on a 24 GB M4, the best of the locally installed options for this job. The tool refuses to run on a text-only model rather than silently describing nothing.
# Draft from a folder, staged in this repo for review (default)
python3 scripts/p2n.py draft ~/Desktop/photos --note "what I did"
# Draft from specific files
python3 scripts/p2n.py draft IMG_0329.HEIC IMG_0335.HEIC --note-file today.txt
# Write a real numbered post into the notebook instead
python3 scripts/p2n.py draft ~/Desktop/photos --note "..." --repo ~/github/tumbling-oysters
# See what the model read off the photos, and stop there
python3 scripts/p2n.py draft ~/Desktop/photos --stage observations
# Produce the post JSON without writing any files
python3 scripts/p2n.py draft ~/Desktop/photos --dry-runUseful flags: --model (vision), --draft-model (prose), --date MM-DD-YYYY,
--no-cache, --json, --quiet, --workdir to keep the converted JPEGs.
With --repo, the post is scaffolded by scripts/new_post.py, which numbers it
max(prefix) + 1 — the notebook has repeated prefixes, so a count would be wrong.
Nothing is ever rendered or committed for you.
Three stages, each a script you can run on its own. Every one prints a single JSON blob
on stdout, and {"error": ...} with exit 1 on failure.
| Stage | Script | What it does |
|---|---|---|
| 1 | prep_images.py |
Converts photos to JPEG at 1568 px longest edge via sips, reads capture times, sorts chronologically |
| 2 | describe_photos.py |
One local vision call per photo → structured observations (legible_text, numbers, uncertain, …) |
| 3 | draft_post.py |
One local text call → title, description, slug, categories, body Markdown |
p2n.py runs all three and writes the post folder.
HEIC conversion isn't optional: ollama rejects HEIC bytes outright, and iPhone photos are
HEIC. Stage 2 caches by the original file's SHA-256 under
~/.cache/photo-2-notebook-post/, so re-drafting the same photos costs nothing — which
matters at roughly 10–30 s per photo.
The prompts live in prompts/observe.md and prompts/draft.md. They're the part most
worth editing; the voice rules in draft.md came from real posts.
What the model is not trusted with. Structure is enforced in Python, not requested in a prompt: categories are filtered against the notebook's fixed list, the slug and date are rebuilt (the date is always today), and every photo is guaranteed to appear in the body exactly once — invented image references are dropped and forgotten photos are appended.
Check the numbers against the photos. This is the one that matters. On handwritten
datasheets, an 8B vision model makes ordinary OCR mistakes — in testing it read a 6/1
as 4/1 and turned a "MANCHESTER" header into "MACKMASTER" — and it does not reliably
report its own uncertainty, even when the prompt asks it to. The drafting step copies
numbers through verbatim rather than inventing them, so an error you see in a draft came
from misreading the photo, and the original is right there in images/. Every post lands
as draft: true for exactly this reason. Printed text, screens, and typed labels are read
much more reliably than handwriting.
Other things to know:
- Photos are ordered by capture time. Photos with no capture metadata fall back to file modification time, which may not be the order you took them in; a warning says so.
- The model won't interpret results or propose mechanisms, by design. If a post needs a
conclusion, put it in
--noteor write it in yourself. gpt-oss:20bcurrently fails to load under ollama 0.30.10 on this machine (failed to load model) — a stale local blob, not a limitation of this tool.
python3 -m unittest discover tests60 tests, no network and no model required — the ollama calls are mocked and the image
fixtures are generated. sips is exercised for real, since it's fast and always present.
tumbling-oysters— the notebook itself. Its packagednotebook-postskill is wherescripts/new_post.py,assets/index_template.qmd, and the voice rules came from.LabNotebook-Summarizer— same house conventions for scripts and tests.ROADMAP.md— what's deliberately left for later, including voice-memo support.