From ba0ca6b16941742c42dac79e9718462ba145b17d Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Fri, 29 May 2026 21:54:59 +0200 Subject: [PATCH] feat: card scanning, OCR improvements, crop thumbnail, VPC enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 1 + docker-compose.yml | 2 +- .../0010_add_frost_density_to_species.py | 23 ++ plants/services/card_ocr.py | 201 +++++++++++ plants/services/card_processing.py | 95 +++++ plants/services/vpc.py | 57 ++- plants/templates/plants/base.html | 1 + plants/templates/plants/crop_thumbnail.html | 86 +++++ .../plants/partials/card_gallery.html | 16 +- .../plants/partials/plant_list_results.html | 6 +- plants/templates/plants/plant_card.html | 15 +- .../templates/plants/plant_card_extract.html | 93 +++++ plants/templates/plants/plant_detail.html | 15 +- plants/templates/plants/plant_scan_new.html | 213 +++++++++++ plants/tests/test_card_ocr.py | 63 ++++ plants/urls.py | 3 + plants/views/plants.py | 340 +++++++++++++++++- plants/views/species.py | 31 +- requirements.txt | 2 + 19 files changed, 1216 insertions(+), 47 deletions(-) create mode 100644 plants/migrations/0010_add_frost_density_to_species.py create mode 100644 plants/services/card_ocr.py create mode 100644 plants/services/card_processing.py create mode 100644 plants/templates/plants/crop_thumbnail.html create mode 100644 plants/templates/plants/plant_card_extract.html create mode 100644 plants/templates/plants/plant_scan_new.html create mode 100644 plants/tests/test_card_ocr.py diff --git a/Dockerfile b/Dockerfile index 59ce1ec..3742042 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev gcc \ + tesseract-ocr tesseract-ocr-eng tesseract-ocr-nld \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/docker-compose.yml b/docker-compose.yml index 3f37498..4665f4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: command: > sh -c "python manage.py migrate --noinput && python manage.py collectstatic --noinput && - gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 2" + gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 1" restart: unless-stopped nginx: diff --git a/plants/migrations/0010_add_frost_density_to_species.py b/plants/migrations/0010_add_frost_density_to_species.py new file mode 100644 index 0000000..c0fabe5 --- /dev/null +++ b/plants/migrations/0010_add_frost_density_to_species.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.1 on 2026-05-29 12:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plants', '0009_update_watering_sunlight_choices'), + ] + + operations = [ + migrations.AddField( + model_name='species', + name='frost_hardiness_c', + field=models.SmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='species', + name='planting_density_m2', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/plants/services/card_ocr.py b/plants/services/card_ocr.py new file mode 100644 index 0000000..bc6ace7 --- /dev/null +++ b/plants/services/card_ocr.py @@ -0,0 +1,201 @@ +""" +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 diff --git a/plants/services/card_processing.py b/plants/services/card_processing.py new file mode 100644 index 0000000..696f2ec --- /dev/null +++ b/plants/services/card_processing.py @@ -0,0 +1,95 @@ +""" +Card image processing: extract card shape from background and reduce dirt/noise. + +Uses OpenCV Otsu thresholding + morphological cleanup to isolate the card shape +regardless of background colour. Works at reduced resolution for speed, then +upscales the mask to apply to the original image. +""" +import io +import numpy as np +import cv2 +from PIL import Image, ImageFilter + + +_PROCESS_SCALE = 0.25 # work at 25% resolution, upscale result +_MIN_COVERAGE = 0.05 # fail if detected card covers < 5% of image + + +def process_card_photo(pil_image: Image.Image) -> Image.Image: + """ + Return the card isolated on a white background, lightly denoised. + Falls back silently to the original image if extraction fails. + """ + try: + mask = _find_card_mask(pil_image) + if mask is None: + return pil_image + + arr = np.array(pil_image.convert('RGB')) + white = np.full_like(arr, 255) + # mask is (H, W) bool → broadcast over RGB channels + result_arr = np.where(mask[:, :, np.newaxis], arr, white).astype(np.uint8) + result = Image.fromarray(result_arr) + + # Mild median filter to soften dirt spots + result = result.filter(ImageFilter.MedianFilter(size=3)) + + # Crop to card bounding box + mask_img = Image.fromarray((mask * 255).astype(np.uint8)) + bbox = mask_img.getbbox() + if bbox: + result = result.crop(bbox) + + return result + except Exception: + return pil_image + + +def _find_card_mask(pil_image: Image.Image) -> np.ndarray | None: + """ + Return a boolean mask at original resolution covering the card, or None. + Uses Otsu threshold on the L channel of LAB colour space so it adapts + to any background brightness. + """ + w, h = pil_image.size + small_w = max(int(w * _PROCESS_SCALE), 80) + small_h = max(int(h * _PROCESS_SCALE), 80) + + # Resize for speed + small = pil_image.resize((small_w, small_h), Image.LANCZOS).convert('RGB') + bgr = cv2.cvtColor(np.array(small), cv2.COLOR_RGB2BGR) + + # L channel of LAB is perceptually uniform luminance + lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) + L = lab[:, :, 0] + + # Otsu threshold: automatically finds the best separation + _, mask = cv2.threshold(L, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + mask = mask.astype(bool) + + coverage = mask.mean() + if not (_MIN_COVERAGE < coverage < (1 - _MIN_COVERAGE)): + return None + + # Morphological cleanup: open removes isolated specks, close fills gaps + kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) + m = mask.astype(np.uint8) * 255 + m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel_open) + m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel_close) + + # Keep only the largest connected component (the card itself) + n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) + if n_labels <= 1: + return None + # stats[0] is background; find largest foreground component + largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) + m = ((labels == largest) * 255).astype(np.uint8) + + # Slight dilation to recover edges lost during thresholding + kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + m = cv2.dilate(m, kernel_dilate) + + # Upscale mask to original resolution + mask_full = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST) + return mask_full > 127 diff --git a/plants/services/vpc.py b/plants/services/vpc.py index 1ebd2c0..0aee83f 100644 --- a/plants/services/vpc.py +++ b/plants/services/vpc.py @@ -1,7 +1,7 @@ """ Scraper for vasteplantencatalogus.nl (VPC). -- search_species(query) → list of lightweight result dicts -- scrape_plant(slug) → full detail dict ready to store as Species +- search_species(query) -> list of lightweight result dicts +- scrape_plant(slug) -> full detail dict ready to store as Species Notes on VPC data: - Plant titles ARE the Latin names (no separate common name) @@ -29,6 +29,14 @@ _ROMAN_MAP = { 'IX': 9, 'IV': 4, 'III': 3, 'II': 2, 'X': 10, 'V': 5, 'I': 1, } +# VPC uses curly/smart quotes around cultivar names in product titles. +# Straight apostrophe + left/right single quotation marks + modifier apostrophe. +_QUOTE_CHARS = "'" + chr(0x2018) + chr(0x2019) + chr(0x02bc) +_Q = '[' + re.escape(_QUOTE_CHARS) + ']' +_NQ = '[^' + re.escape(_QUOTE_CHARS) + ']' +_CULTIVAR_RE = re.compile(_Q + '(' + _NQ + '+)' + _Q) +_CULTIVAR_STRIP_RE = re.compile(r'\s*' + _Q + _NQ + '+' + _Q) + class VPCError(Exception): pass @@ -90,6 +98,13 @@ def slug_from_query(query): return '' +_STRIP_QUOTES_RE = re.compile('[' + re.escape(_QUOTE_CHARS) + ']') + + +def _strip_quotes(text): + return _STRIP_QUOTES_RE.sub('', text) + + def search_species(query): """ Search VPC and return up to 8 result dicts: @@ -98,10 +113,17 @@ def search_species(query): On VPC, the title IS the Latin name so both name fields are set to it. Raises VPCError on network failure. """ + # VPC's WordPress search breaks on multi-word and quoted queries. + # Strategy: search with only the first word (genus), then filter + # client-side so all query words appear in the result title. + # Quote chars are stripped from both query and titles before matching. + words = _strip_quotes(query).split() + vpc_query = words[0] if words else query + try: resp = requests.get( - f'{_BASE}/', - params={'s': query, 'post_type': 'product'}, + _BASE + '/', + params={'s': vpc_query, 'post_type': 'product'}, timeout=_TIMEOUT, headers=_HEADERS, ) @@ -109,7 +131,7 @@ def search_species(query): except requests.Timeout: raise VPCError('VPC timed out.') except Exception as e: - raise VPCError(f'VPC unreachable: {e}') + raise VPCError('VPC unreachable: ' + str(e)) soup = BeautifulSoup(resp.text, 'html.parser') seen_slugs = set() @@ -139,6 +161,15 @@ def search_species(query): if len(results) >= 8: break + if len(words) > 1: + words_lower = [w.lower() for w in words] + filtered = [ + r for r in results + if all(w in _strip_quotes(r['common_name']).lower() for w in words_lower) + ] + if filtered: + return filtered + return results @@ -150,14 +181,14 @@ def scrape_plant(slug): Raises VPCError on failure. """ - url = f'{_BASE}/soorten/{slug}/' + url = _BASE + '/soorten/' + slug + '/' try: resp = requests.get(url, timeout=_TIMEOUT, headers=_HEADERS) resp.raise_for_status() except requests.Timeout: raise VPCError('VPC timed out while fetching plant page.') except Exception as e: - raise VPCError(f'Could not fetch plant page: {e}') + raise VPCError('Could not fetch plant page: ' + str(e)) soup = BeautifulSoup(resp.text, 'html.parser') @@ -165,7 +196,7 @@ def scrape_plant(slug): h1 = soup.find('h1') name = h1.get_text(strip=True) if h1 else slug.replace('-', ' ').title() - # Hero image — VPC wraps the plant photo in a .wp-caption figure + # Hero image -- VPC wraps the plant photo in a .wp-caption figure hero_img = '' for sel in ['.wp-caption img', 'figure img', '.elementor-widget-image img']: img = soup.select_one(sel) @@ -204,7 +235,15 @@ def scrape_plant(slug): except (AttributeError, ValueError): pass elif label == 'Standplaats': - result['sunlight'] = value + v = value.lower() + if 'halfschaduw' in v or 'half' in v or 'part' in v: + result['sunlight'] = 'part_sun' + elif 'schaduw' in v or 'shade' in v: + result['sunlight'] = 'full_shade' + elif 'zon' in v or 'sun' in v: + result['sunlight'] = 'full_sun' + else: + result['sunlight'] = value elif label in ('Groeisnelheid', 'Groeivorm'): result['growth_rate'] = value diff --git a/plants/templates/plants/base.html b/plants/templates/plants/base.html index 4d6608c..a425f10 100644 --- a/plants/templates/plants/base.html +++ b/plants/templates/plants/base.html @@ -30,6 +30,7 @@ 🌿 PlantDB diff --git a/plants/templates/plants/crop_thumbnail.html b/plants/templates/plants/crop_thumbnail.html new file mode 100644 index 0000000..9a2b0f4 --- /dev/null +++ b/plants/templates/plants/crop_thumbnail.html @@ -0,0 +1,86 @@ +{% extends "plants/base.html" %} +{% block title %}Crop thumbnail — {{ plant.name }}{% endblock %} +{% block content %} + +
+ +
Crop thumbnail — {{ plant.name }}
+
+ + + +{% if card_photos|length > 1 %} +
+ {% for photo in card_photos %} + + {% endfor %} +
+{% endif %} + +
+ +
+ +
+ {% csrf_token %} + + + + + + + + +
+ + + + +{% endblock %} diff --git a/plants/templates/plants/partials/card_gallery.html b/plants/templates/plants/partials/card_gallery.html index 940f912..92dbaa0 100644 --- a/plants/templates/plants/partials/card_gallery.html +++ b/plants/templates/plants/partials/card_gallery.html @@ -1,4 +1,4 @@ -
+