- 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>
201 lines
7.5 KiB
Python
201 lines
7.5 KiB
Python
"""
|
||
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
|