bloombase/plants/services/card_ocr.py
Stephan Kerkman ba0ca6b169 feat: card scanning, OCR improvements, crop thumbnail, VPC enrichment
- Add scan-to-plant flow: upload card photos, OCR, VPC match on confirm form
- OpenCV CLAHE + adaptive threshold preprocessing; dual psm 6/11 OCR pass
- Regex fixes for OCR misreads: height (s→5), frost (=,„,i,l,G), density, bloom months (E→I)
- Scientific name extraction: passport section + cultivar quote strategy
- Last-wins merge so back card data overrides front card garbage
- Crop thumbnail tool with Cropper.js; cropped image shown in list and detail
- VPC image always supersedes user thumbnail on detail page
- VPC sunlight mapping: zon→full_sun, halfschaduw→part_sun, schaduw→full_shade
- Auto-set thumbnail on plant created from scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:54:59 +02:00

201 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Extract structured plant data from card photos using Tesseract OCR.
Designed for hellogarden.nl / Elburg & Smit style cards but the regex patterns
are generic enough to handle most Dutch/international plant labels.
"""
import re
from PIL import Image, ImageEnhance, ImageFilter
from plants.services.vpc import _parse_months
# Tesseract config: treat as a single block of text, best for card layouts
_TESS_CONFIG = '--psm 6'
_TESS_LANGS = 'nld+eng'
def extract_from_card(image) -> dict:
"""
Run OCR on a card image (path or PIL Image) and return extracted fields.
Returned dict keys: scientific_name, bloom_months, max_height_cm,
frost_hardiness_c, planting_density_m2, bee_attractant.
Values are None / [] / False when not found.
"""
from PIL import ImageOps
if not isinstance(image, Image.Image):
image = Image.open(image)
image = ImageOps.exif_transpose(image)
text = _ocr_image(image)
return parse_card_text(text)
def extract_plant_photo(image) -> Image.Image | None:
"""
Crop just the plant photo from the front of a card.
Scans rows from the bottom upward to find where the dark banner ends,
then returns everything above that boundary.
Returns None if detection fails.
"""
from PIL import ImageOps
if not isinstance(image, Image.Image):
image = Image.open(image)
image = ImageOps.exif_transpose(image)
w, h = image.size
gray = image.convert('L')
# Scan upward from the bottom half to find the first bright row
# (transition from dark banner to colourful plant photo)
boundary = None
for y in range(h - 1, h // 3, -1):
samples = [
gray.getpixel((x, y))
for x in range(w // 4, 3 * w // 4, max(1, w // 20))
]
if samples and sum(samples) / len(samples) > 100:
boundary = y
break
if boundary is None or boundary < h // 4:
return None
return image.crop((0, 0, w, min(boundary + 20, h)))
_OCR_MAX_WIDTH = 1200 # cap before upscaling — phone photos are 4K+
def _ocr_image(img: Image.Image) -> str:
"""Downscale, auto-rotate, upscale 2×, enhance contrast, run OCR."""
import pytesseract
max_side = max(img.width, img.height)
if max_side > _OCR_MAX_WIDTH:
scale = _OCR_MAX_WIDTH / max_side
img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
# Auto-detect orientation and rotate to upright
try:
osd = pytesseract.image_to_osd(img, config='--psm 0')
angle = int(re.search(r'Rotate: (\d+)', osd).group(1))
if angle:
img = img.rotate(angle, expand=True)
except Exception:
pass
img = img.resize((img.width * 2, img.height * 2), Image.LANCZOS)
def _run(image, cfg):
try:
return pytesseract.image_to_string(image, lang=_TESS_LANGS, config=cfg)
except Exception:
return pytesseract.image_to_string(image, config=cfg)
# Pass 1: color image, block mode — best for scientific name / structured text
color = ImageEnhance.Contrast(img).enhance(1.5)
# Pass 2: OpenCV CLAHE + adaptive threshold, sparse mode — best for data-strip values
import cv2
import numpy as np
arr = np.array(img.convert('L'))
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
arr = clahe.apply(arr)
arr = cv2.adaptiveThreshold(arr, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 31, 10)
binarized = Image.fromarray(arr)
return _run(color, '--psm 6') + '\n' + _run(binarized, '--psm 11')
def parse_card_text(text: str) -> dict:
"""
Parse raw OCR text into structured fields.
Exposed as a standalone function so it can be unit-tested without Tesseract.
"""
result = {
'scientific_name': None,
'bloom_months': [],
'max_height_cm': None,
'frost_hardiness_c': None,
'planting_density_m2': None,
'bee_attractant': False,
}
# Scientific name strategy:
# 1. Look for genus in the plant passport section ("A Lupinus", "B NL-..." pattern).
# This is the most reliable signal on Elburg & Smit cards.
# 2. Look for a cultivar in quotes anywhere in the text.
# 3. Fall back to scanning for a genus+species pair in sequence.
passport_match = re.search(r'(?:^|\s)A\s+([A-Z][a-z]{3,})(?:\s|$)', text, re.MULTILINE)
cultivar_match = re.search(r"['ʼ]([A-Z][^'ʼ\n]{2,30})['ʼ]", text)
if passport_match:
genus = passport_match.group(1)
if cultivar_match:
result['scientific_name'] = f"{genus} '{cultivar_match.group(1).strip()}'"
else:
# Try to find species epithet on the line after the genus in the text body
genus_line_match = re.search(rf'^{genus}\s*$', text, re.MULTILINE)
if genus_line_match:
after = text[genus_line_match.end():].lstrip('\n')
epithet_line = after.splitlines()[0].strip() if after.splitlines() else ''
epithet_clean = re.sub(r'[^\w\s\'\-]', '', epithet_line).strip()
first_epithet_word = epithet_clean.split()[0] if epithet_clean.split() else ''
if re.match(r'^[a-z]{3,}$', first_epithet_word):
result['scientific_name'] = f"{genus} {epithet_clean}"
else:
result['scientific_name'] = genus
else:
result['scientific_name'] = genus
else:
# Fallback: scan lines for a strictly title-case genus word
name_lines = []
for line in text.splitlines():
line = re.sub(r'[^\w\s\'\-]', '', line).strip()
if not line:
continue
if not name_lines:
first_word = line.split()[0] if line.split() else ''
if re.match(r'^[A-Z][a-z]{4,}$', first_word) and not re.search(r'\d', line):
name_lines.append(line)
else:
if re.match(r'^[A-Za-z]', line) and not re.search(r'\d', line) and len(line) < 40:
name_lines.append(line)
break
if name_lines:
result['scientific_name'] = ' '.join(name_lines)
# Bloom months: Roman numeral range — OCR misreads 'I' as 'E'/'e'
roman = re.search(r'\b([IVXEe]{1,4}\s*[-]\s*[IVXEe]{1,4})\b', text)
if roman:
normalized_roman = roman.group(1).replace(' ', '').upper().replace('E', 'I')
result['bloom_months'] = _parse_months(normalized_roman)
# Height: "120 cm" — OCR misreads: '0'→'o', '5'→'s'/'S'
height = re.search(r'([0-9soSO]{2,3})\s*cm', text, re.I)
if height:
h_str = height.group(1).lower().replace('s', '5').replace('o', '0')
if h_str.isdigit():
result['max_height_cm'] = int(h_str)
# Frost hardiness: "-20 °C" — OCR misreads: '-'→'„'/'=', '1'→'i'/'l'/'|', 'C'→'G'
frost = re.search(r'[-−„=]\s*([0-9il|]{1,3})\s*[o°]?\s*[CcGg]', text)
if frost:
f_str = frost.group(1).replace('i', '1').replace('l', '1').replace('|', '1')
if f_str.isdigit():
result['frost_hardiness_c'] = -int(f_str)
# Planting density: "7/m²" or "7/m2" or "7/m" (OCR often reads ² as ? or 2 or drops it)
density = re.search(r'(\d+)\s*/\s*m[²2?]?(?!\w)', text)
if density:
result['planting_density_m2'] = int(density.group(1))
# Bee attractant: Dutch, English, German keywords
if re.search(r'bijenlokker|attracts bees|bienen', text, re.I):
result['bee_attractant'] = True
return result