mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-29 10:18:29 -04:00
- Implemented Dockerfile for OCR processing with necessary dependencies. - Enhanced read_packets.py to read and save text from seed packet photos. - Created requirements.txt for Python dependencies. - Updated show.html.haml to display OCR results when available. - Added tests for displaying packet text on seed pages, including handling of markup.
311 lines
13 KiB
Python
Executable File
311 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 seeds whose packet photos (labelled front
|
|
or back) have not been read yet, oldest first. For each seed it downloads those
|
|
photos, runs an OCR command on each, joins the text (front, then back) and, with
|
|
--upload, saves it on the seed. 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 seed goes to stdout, with what happened to it:
|
|
|
|
{"seed_id": "10", "photo_ids": ["118"], "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 seeds (the oldest that need it)
|
|
--upload save the text on each seed (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). Seeds
|
|
with less are remembered as tried, and skipped next time
|
|
--retry-tried read the seeds remembered as tried too (use after changing the
|
|
OCR tool or settings)
|
|
--state FILE where tried seeds are remembered (default ~/.growstuff-packet-ocr.json)
|
|
--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 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", "include": "seeds", "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
|
|
seeds = (photo.get("relationships", {}).get("seeds") or {}).get("data") or []
|
|
yield {
|
|
"photo_id": photo["id"],
|
|
"seed_ids": [seed["id"] for seed in seeds],
|
|
"label": photo["attributes"]["label"],
|
|
"image_url": photo["attributes"]["fullsize-url"],
|
|
}
|
|
if len(page["data"]) < size:
|
|
return
|
|
|
|
|
|
def packet_photos_of(base_url, token, seed_id):
|
|
"""All of a seed's photos of its packet, front first, so the text reads in order."""
|
|
query = urllib.parse.urlencode({"filter[seed_id]": seed_id, "page[size]": PAGE_SIZE})
|
|
photos = [
|
|
{"photo_id": photo["id"], "label": photo["attributes"]["label"], "image_url": photo["attributes"]["fullsize-url"]}
|
|
for photo in get_json(f"{base_url}/api/v1/photos?{query}", token)["data"]
|
|
if photo["attributes"]["label"]
|
|
]
|
|
return sorted(photos, key=lambda photo: (photo["label"] != "front", int(photo["photo_id"])))
|
|
|
|
|
|
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 combine(parts):
|
|
"""The text of a seed's photos as one, each marked with its side."""
|
|
return "\n\n".join(f"[{label}]\n{tidy(text)}" for label, text in parts if tidy(text))
|
|
|
|
|
|
def save_text(base_url, token, seed_id, text):
|
|
body = {"data": {"type": "seeds", "id": str(seed_id), "attributes": {"packet-text": text}}}
|
|
request = urllib.request.Request(
|
|
f"{base_url}/api/v1/seeds/{seed_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_seeds", []))
|
|
except (OSError, ValueError):
|
|
return set()
|
|
|
|
|
|
def remember_tried(path, base_url, seed_ids):
|
|
try:
|
|
with open(path) as f:
|
|
state = json.load(f)
|
|
except (OSError, ValueError):
|
|
state = {}
|
|
state.setdefault(base_url, {})["tried_seeds"] = sorted(seed_ids, key=int)
|
|
with open(path, "w") as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
|
|
def read_seed(base_url, token, seed_id, args):
|
|
"""Read all of a seed's packet photos. Returns (photo ids, text) or (photo ids, None)
|
|
if one could not be downloaded, as saving only some of a seed's text would leave
|
|
the rest to be forgotten."""
|
|
photos = packet_photos_of(base_url, token, seed_id)
|
|
parts = []
|
|
for photo in photos:
|
|
text = read_photo(photo, args)
|
|
if text is None:
|
|
print(f"photo {photo['photo_id']}: could not download {photo['image_url']}", file=sys.stderr)
|
|
return [p["photo_id"] for p in photos], None
|
|
print(f"photo {photo['photo_id']} ({photo['label']}): {real_words(text)} words", file=sys.stderr)
|
|
parts.append((photo["label"], text))
|
|
return [p["photo_id"] for p in photos], combine(parts)
|
|
|
|
|
|
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 seeds (the oldest)")
|
|
parser.add_argument("--upload", action="store_true", help="save the text on each seed")
|
|
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 seeds remembered as tried too")
|
|
parser.add_argument("--state", default=DEFAULT_STATE, help="where tried seeds are remembered")
|
|
parser.add_argument("--ocr-command", default="tesseract {image} stdout")
|
|
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 seeds_to_read(base_url, token, args):
|
|
"""The seeds with unread packet photos, oldest first, less any tried before. The
|
|
whole waiting list is fetched first: seeds 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)
|
|
seed_ids = []
|
|
for photo in photos_needing_ocr(base_url, token):
|
|
seed_ids += [seed_id for seed_id in photo["seed_ids"] if seed_id not in seed_ids and seed_id not in tried]
|
|
return seed_ids[: args.limit]
|
|
|
|
|
|
def run(base_url, token, args):
|
|
remembered = load_tried(args.state, base_url)
|
|
seed_ids = seeds_to_read(base_url, token, args)
|
|
saved = 0
|
|
for seed_id in seed_ids:
|
|
photo_ids, text = read_seed(base_url, token, seed_id, args)
|
|
result = {"seed_id": seed_id, "photo_ids": photo_ids}
|
|
if text is None:
|
|
result["action"] = "could not download a photo"
|
|
elif real_words(text) < args.min_words:
|
|
result.update(words=real_words(text), action="too little text, not saved")
|
|
remembered.add(seed_id)
|
|
else:
|
|
result.update(words=real_words(text), text=text, action="would save")
|
|
if args.upload:
|
|
try:
|
|
save_text(base_url, token, seed_id, text)
|
|
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(seed_ids)} seed(s) read, {outcome}.", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|