Skip to main content

Camera → OCR

A photo that lands in Nextcloud gets its text saved beside it.

The text is a sibling file, indexed with the image:

receipts/2026-08-14_grocery.jpg
receipts/2026-08-14_grocery.jpg.ocr.txt ← created automatically

Getting the photo in​

Any upload to Nextcloud triggers it. Use one route or several.

RouteGood forSetup
Nextcloud auto-uploadThe default. Your phone's camera roll syncs, and every photo is OCR'd. No bot, no token.Turn on the Nextcloud mobile app's auto-upload
rclone / a scriptScanners, batch imports, anything already on diskrclone copyto file.jpg nextcloud:scans/file.jpg
Any WebDAV clientOther apps writing to Nextcloud directlyNothing — the upload is the trigger

How It Works​

1. The image lands in Nextcloud​

The flow starts when the upload finishes.

2. Nextcloud fires the webhook​

The upload raises a NodeWrittenEvent, which Nextcloud POSTs to the Decree webhook's /files endpoint. This is the same pipeline used for any file uploaded to Nextcloud.

3. file_router matches image extensions​

file_router scans the ollama-ocr processor and reads its pattern:

PATTERN='\.(jpg|jpeg|png|webp|gif|heic|heif|tiff?|bmp)$'
IS_PRE_SIGNED=false

A match enqueues a file_processor message with is_pre_signed: false — the file will be downloaded locally.

4. file_processor downloads the image​

file_processor runs rclone copyto to pull the image into a temp path and exports it as FILE_PATH. No share link is involved — the full image bytes are needed to encode as base64 for the vision API.

5. ollama-ocr extracts the text​

ollama-ocr.sh invokes ocr.ts, which:

  1. Reads the image from FILE_PATH and encodes it as base64
  2. POSTs it as an image_url to /chat/completions at the vision endpoint (EXIST_MODEL_URL_VISION, an OpenAI-compatible /v1 URL — ollama or llama.cpp) with the configured vision model and an extraction prompt
  3. Writes the returned text to stdout

The shell processor pipes that output via rclone rcat to Nextcloud at the same path with .ocr.txt appended:

scans/2026-03-02-receipt.jpg
scans/2026-03-02-receipt.jpg.ocr.txt ← created automatically

Prerequisites​

  • Nextcloud with file events on (see File Processor)
  • Ollama running with a vision-capable model pulled (e.g. llava, llava-phi3, moondream)
  • rclone configured with a nextcloud remote

Setup​

Step 1 — Pull a vision model in Ollama​

docker exec ollama ollama pull llava

Any Ollama-compatible vision model works. llava is a solid general-purpose choice; llava-phi3 is faster on CPU.

Step 2 — turn it on​

./existential.sh flow on image-ocr turns on Nextcloud's file events (two migrations: the nextcloud rclone remote and the webhooks, see File Processor → Setup) and copies the ollama-ocr processor into workspace/.decree/lib/file-processors/.

Step 3 — Point a camera at it​

Option A — Nextcloud camera auto-upload​

In the Nextcloud mobile app, turn on Auto upload.

Option B — rclone​

For scanners, batch imports, or anything already on disk:

rclone copyto /path/to/scan.jpg nextcloud:documents/scan.jpg \
--config automation/secrets/rclone/rclone.conf

Customization​

VariableDefaultDescription
FILE_SUFFIX.ocr.txtSuffix appended to the image path for the OCR output
OUTPUT_RCLONEnextcloudrclone remote where the OCR result is saved
OCR_MODELEXIST_MODEL_VISIONVision model used for text extraction. Chosen globally in .env.shared, not per-flow.
EXIST_MODEL_URL_VISION(blank → EXIST_MODEL_URL)OpenAI-compatible /v1 URL serving the vision model. Set in .env.shared.

Testing​

Upload a test image straight into Nextcloud to bypass whichever capture route you chose and verify the OCR pipeline on its own — the upload fires the event itself:

rclone copyto /path/to/test.jpg nextcloud:scans/test.jpg \
--config automation/secrets/rclone/rclone.conf

Watch the run complete:

docker logs -f automation
docker exec automation decree status
docker exec automation decree log <id-prefix>

Reusing the OCR function​

ocr.ts is a standalone library importable in any other TypeScript script:

import { ocr } from "/work/.decree/lib/ocr";

const text = await ocr("/tmp/some-file.png", {
model: "llava-phi3",
prompt: "What does this receipt total?",
});