Files
growstuff/script/ocr/read_packets.py
T
Brenda Wallace f70b85e60d Add OCR functionality for seed packet photos
- Introduced ocr_text, ocr_engine, and ocr_at attributes to Photo model.
- Implemented awaiting_ocr scope to fetch photos needing OCR processing.
- Updated PhotoAssociation model to remove awaiting_text scope.
- Modified Seed model to include packet_texts method for retrieving OCR results.
- Enhanced PhotoResource and SeedResource to handle new OCR attributes.
- Created migration to add OCR fields to photos and remove packet_text from seeds.
- Updated read_packets.py script to save OCR results directly to photos.
- Added tests for new OCR functionality and removed outdated packet_text tests.
2026-10-31 23:21:01 +13:00

298 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read the text on photos of seed packets that haven't been read yet, and save it.
Asks a Growstuff server's API for the photos of seed packets (labelled front or
back) that have not been read yet, oldest first. For each photo it downloads the
image, runs an OCR command on it and, with --upload, saves the text on the photo,
with the name of the engine that read it and when. Without --upload nothing is
saved: it only shows what it would save.
GROWSTUFF_URL=http://localhost:3000 script/ocr/read_packets.py --limit 5
GROWSTUFF_API_TOKEN=... script/ocr/read_packets.py --limit 5 --upload
One JSON object per photo goes to stdout, with what happened to it:
{"photo_id": "118", "label": "back", "words": 40, "action": "would save", "text": "..."}
Progress and errors go to stderr.
GROWSTUFF_URL the server (default https://www.growstuff.org)
GROWSTUFF_API_TOKEN the API token of an admin (Settings > Apps). Only
--upload needs it; reading is public.
--limit N read only the first N photos (the oldest that need it)
--upload save the text on each photo (otherwise only show it)
--list only list the photos that need reading, without reading them
--min-words N don't save less than N real-looking words (default 3). Photos
with less are remembered as tried, and skipped next time
--retry-tried read the photos remembered as tried too (use after changing the
OCR tool or settings)
--state FILE where tried photos are remembered (default ~/.growstuff-packet-ocr.json)
--engine NAME the name saved with the text, such as "tesseract-5.3". Default:
the first line of "tesseract --version", for the default command
--ocr-command the OCR tool to run on each image, {image} being its path.
Default "tesseract {image} stdout"; any command that prints
the text works, so other engines can be tried the same way.
--keep-images D keep the downloaded images in directory D, to try tools on
--clean tidy each image before reading it: for faint, tilted or dark
packets that read as nothing (needs Pillow and numpy)
Needs only the python3 standard library, and whatever OCR command is used.
"""
import argparse
import datetime
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_URL = "https://www.growstuff.org"
DEFAULT_STATE = os.path.join(os.path.expanduser("~"), ".growstuff-packet-ocr.json")
PAGE_SIZE = 100 # the most the API gives in a page
JSON_API = "application/vnd.api+json"
def fetch(url, token=None, accept=None):
request = urllib.request.Request(url)
if token:
request.add_header("Authorization", f"Bearer {token}")
if accept:
request.add_header("Accept", accept)
return urllib.request.urlopen(request, timeout=60)
def get_json(url, token=None):
with fetch(url, token, JSON_API) as response:
return json.load(response)
def photos_needing_ocr(base_url, token, limit=None):
"""Yield the photos of seed packets with no text yet, oldest first.
Pages are asked for by number, not by following the API's own links, which
point at the address the API is configured with and not necessarily at this
server."""
size = min(limit or PAGE_SIZE, PAGE_SIZE)
count = 0
for number in range(1, 10_000):
query = urllib.parse.urlencode(
{"filter[needs_ocr]": "true", "page[size]": size, "page[number]": number}
)
page = get_json(f"{base_url}/api/v1/photos?{query}", token)
for photo in page["data"]:
if limit is not None and count >= limit:
return
count += 1
yield {
"photo_id": photo["id"],
"label": photo["attributes"]["label"],
"image_url": photo["attributes"]["fullsize-url"],
}
if len(page["data"]) < size:
return
def larger(url):
"""Flickr's `_z` (640px) is too small for packet fine print; `_b` is 1024px."""
return re.sub(r"_z(\.\w+)$", r"_b\1", url)
def download(url, directory, name):
for candidate in dict.fromkeys([larger(url), url]):
try:
with fetch(candidate) as response:
image = response.read()
except (urllib.error.URLError, TimeoutError): # HTTP errors are URLErrors too
continue
path = os.path.join(directory, name + os.path.splitext(candidate)[1])
with open(path, "wb") as f:
f.write(image)
return path
return None
def clean(path):
"""Use only the red channel, where dark ink on orange or brown paper has the
most contrast, divide out the uneven lighting, and double the size."""
try:
import numpy
from PIL import Image, ImageFilter
except ImportError:
sys.exit("--clean needs Pillow and numpy: sudo apt install python3-pil python3-numpy")
red = Image.open(path).convert("RGB").split()[0]
big = red.resize((red.width * 2, red.height * 2), Image.LANCZOS)
pixels = numpy.asarray(big).astype("float32")
lighting = numpy.asarray(big.filter(ImageFilter.GaussianBlur(30))).astype("float32")
tidy = numpy.clip(pixels / numpy.maximum(lighting, 1) * 200, 0, 255).astype("uint8")
cleaned = os.path.splitext(path)[0] + "-clean.png"
Image.fromarray(tidy).save(cleaned)
return cleaned
def ocr(command, path):
arguments = [part.replace("{image}", path) for part in shlex.split(command)]
return subprocess.run(arguments, capture_output=True, text=True, check=True).stdout
def read_photo(photo, args):
"""The text of one photo, or None if it could not be downloaded."""
with tempfile.TemporaryDirectory() as scratch:
path = download(photo["image_url"], args.keep_images or scratch, f"photo-{photo['photo_id']}")
if path is None:
return None
try:
return ocr(args.ocr_command, clean(path) if args.clean else path)
except (OSError, subprocess.CalledProcessError) as error:
sys.exit(f"The OCR command failed on photo {photo['photo_id']}: {error}")
def real_words(text):
"""Words with at least three letters: what a stray mark or a speck is not."""
return sum(1 for word in text.split() if sum(char.isalpha() for char in word) >= 3)
def tidy(text):
"""The lines worth keeping: those with at least two letters or digits. The
edges of a photo read as columns of single marks, which are not text."""
lines = [line.strip() for line in text.splitlines()]
return "\n".join(line for line in lines if sum(char.isalnum() for char in line) >= 2)
def engine_name(args):
"""What to save as the engine: --engine, or the version of tesseract, which is the default command."""
if args.engine:
return args.engine
if shlex.split(args.ocr_command)[0] != "tesseract":
sys.exit("--engine is needed with an --ocr-command that is not tesseract, so the text says what read it.")
try:
first = subprocess.run(["tesseract", "--version"], capture_output=True, text=True, check=True)
version = (first.stdout or first.stderr).split()[1]
except (OSError, subprocess.CalledProcessError, IndexError):
return "tesseract"
return f"tesseract-{version}"
def save_text(base_url, token, photo_id, text, engine):
read_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
attributes = {"ocr-text": text, "ocr-engine": engine, "ocr-at": read_at}
body = {"data": {"type": "photos", "id": str(photo_id), "attributes": attributes}}
request = urllib.request.Request(
f"{base_url}/api/v1/photos/{photo_id}", data=json.dumps(body).encode(), method="PATCH"
)
request.add_header("Content-Type", JSON_API)
request.add_header("Accept", JSON_API)
request.add_header("Authorization", f"Bearer {token}")
urllib.request.urlopen(request, timeout=60).close()
def load_tried(path, base_url):
try:
with open(path) as f:
return set(json.load(f).get(base_url, {}).get("tried_photos", []))
except (OSError, ValueError):
return set()
def remember_tried(path, base_url, photo_ids):
try:
with open(path) as f:
state = json.load(f)
except (OSError, ValueError):
state = {}
state.setdefault(base_url, {})["tried_photos"] = sorted(photo_ids, key=int)
with open(path, "w") as f:
json.dump(state, f, indent=2)
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--base-url", default=os.environ.get("GROWSTUFF_URL", DEFAULT_URL))
parser.add_argument("--limit", "-n", type=int, help="read only the first N photos (the oldest)")
parser.add_argument("--upload", action="store_true", help="save the text on each photo")
parser.add_argument("--list", action="store_true", help="list the photos that need reading, without reading them")
parser.add_argument("--min-words", type=int, default=3, help="the least real-looking words worth saving")
parser.add_argument("--retry-tried", action="store_true", help="read the photos remembered as tried too")
parser.add_argument("--state", default=DEFAULT_STATE, help="where tried photos are remembered")
parser.add_argument("--ocr-command", default="tesseract {image} stdout")
parser.add_argument("--engine", help="the name saved with the text, such as tesseract-5.3")
parser.add_argument("--keep-images", metavar="DIR", help="keep the downloaded images in this directory")
parser.add_argument("--clean", action="store_true", help="tidy each image before reading it")
args = parser.parse_args()
if args.limit is not None and args.limit < 1:
sys.exit("--limit must be at least 1.")
base_url = args.base_url.rstrip("/")
token = os.environ.get("GROWSTUFF_API_TOKEN")
if args.upload and not token:
sys.exit("--upload needs GROWSTUFF_API_TOKEN: the API token of an admin (Settings > Apps).")
if args.keep_images:
os.makedirs(args.keep_images, exist_ok=True)
print(f"Server: {base_url}", file=sys.stderr)
try:
if args.list:
count = 0
for photo in photos_needing_ocr(base_url, token, args.limit):
count += 1
print(json.dumps(photo), flush=True)
print(f"{count} photo(s) listed.", file=sys.stderr)
return
run(base_url, token, args)
except urllib.error.HTTPError as error:
sys.exit(f"The server said {error.code} {error.reason} for {error.url}")
except (urllib.error.URLError, TimeoutError) as error:
sys.exit(f"Could not reach {base_url}: {getattr(error, 'reason', error)}")
def photos_to_read(base_url, token, args):
"""The photos waiting to be read, oldest first, less any tried before. The whole
waiting list is fetched first: photos saved partway through would drop off it,
and shift the pages not yet asked for."""
tried = set() if args.retry_tried else load_tried(args.state, base_url)
photos = [photo for photo in photos_needing_ocr(base_url, token) if photo["photo_id"] not in tried]
return photos[: args.limit]
def run(base_url, token, args):
engine = engine_name(args)
remembered = load_tried(args.state, base_url)
photos = photos_to_read(base_url, token, args)
saved = 0
for photo in photos:
text = read_photo(photo, args)
result = {"photo_id": photo["photo_id"], "label": photo["label"]}
if text is None:
print(f"photo {photo['photo_id']}: could not download {photo['image_url']}", file=sys.stderr)
result["action"] = "could not download the photo"
elif real_words(tidy(text)) < args.min_words:
result.update(words=real_words(tidy(text)), action="too little text, not saved")
remembered.add(photo["photo_id"])
else:
text = tidy(text)
result.update(words=real_words(text), text=text, action="would save")
if args.upload:
try:
save_text(base_url, token, photo["photo_id"], text, engine)
result["action"] = "saved"
saved += 1
except urllib.error.HTTPError as error:
if error.code in (401, 403):
sys.exit(f"The server refused to save (HTTP {error.code}): is the token an admin's?")
result["action"] = f"not saved: HTTP {error.code}"
print(json.dumps(result), flush=True)
if args.upload:
remember_tried(args.state, base_url, remembered)
outcome = f"{saved} saved" if args.upload else "nothing saved without --upload"
print(f"{len(photos)} photo(s) read, {outcome}.", file=sys.stderr)
if __name__ == "__main__":
main()