""" 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