mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-03 19:54:16 -05:00
- 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.
298 lines
13 KiB
Python
Executable File
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()
|