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 %}
+
+
+
+
+
+
+
+
+
+
+{% 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 @@
-
+
{% with card_photos=plant.card_photos.all %}
{% if card_photos %}
@@ -23,15 +23,15 @@
{% if card_photos|length < 3 %}
-
Up to 3 photos — front & back of the label
+
Up to 3 photos — front & back of the label
{% endif %}
{% endwith %}
diff --git a/plants/templates/plants/partials/plant_list_results.html b/plants/templates/plants/partials/plant_list_results.html
index 4caa2a9..f6c84d2 100644
--- a/plants/templates/plants/partials/plant_list_results.html
+++ b/plants/templates/plants/partials/plant_list_results.html
@@ -2,10 +2,10 @@
- {% if plant.photo %}
-
- {% elif plant.species and plant.species.api_image_url %}
+ {% if plant.species and plant.species.api_image_url %}
+ {% elif plant.thumbnail_url %}
+
{% else %}
🌿
{% endif %}
diff --git a/plants/templates/plants/plant_card.html b/plants/templates/plants/plant_card.html
index 3a97e4e..b1e973c 100644
--- a/plants/templates/plants/plant_card.html
+++ b/plants/templates/plants/plant_card.html
@@ -7,8 +7,19 @@
Plant card
-
- {% include "plants/partials/card_gallery.html" %}
+{% include "plants/partials/card_gallery.html" %}
+
+{% if plant.card_photos.all %}
+
+
+
Loading…
+
Scanning card…
+
+{% endif %}
{% endblock %}
diff --git a/plants/templates/plants/plant_card_extract.html b/plants/templates/plants/plant_card_extract.html
new file mode 100644
index 0000000..0b74955
--- /dev/null
+++ b/plants/templates/plants/plant_card_extract.html
@@ -0,0 +1,93 @@
+{% extends "plants/base.html" %}
+{% load plant_extras %}
+{% block title %}Extract card data — {{ plant.name }} — PlantDB{% endblock %}
+{% block content %}
+
+
+
← Card
+
Extract from card
+
+
+
Review the data extracted from your card photos. Correct anything that looks off, then save.
+
+
+{% endblock %}
diff --git a/plants/templates/plants/plant_detail.html b/plants/templates/plants/plant_detail.html
index d60277c..68dc3c9 100644
--- a/plants/templates/plants/plant_detail.html
+++ b/plants/templates/plants/plant_detail.html
@@ -8,10 +8,10 @@
✏️
-{% if plant.thumbnail_url %}
-
-{% elif plant.species and plant.species.api_image_url %}
+{% if plant.species and plant.species.api_image_url %}
+{% elif plant.thumbnail_url %}
+
{% else %}
🌿
{% endif %}
@@ -43,6 +43,12 @@
{% if plant.species.max_height_cm %}
📏 up to {{ plant.species.max_height_cm }} cm
{% endif %}
+ {% if plant.species.frost_hardiness_c is not None %}
+
❄️ {{ plant.species.frost_hardiness_c }}°C
+ {% endif %}
+ {% if plant.species.planting_density_m2 %}
+
🌿 {{ plant.species.planting_density_m2 }}/m²
+ {% endif %}
{% if plant.species.growth_rate %}
🌱 {{ plant.species.growth_rate }}
{% endif %}
@@ -81,8 +87,9 @@
{% include "plants/partials/photo_gallery.html" %}
+ 🪧 Plant card
{% if plant.card_photos.all %}
-
🪧 View plant card
+
✂️ Crop thumbnail
{% endif %}
Delete plant
diff --git a/plants/templates/plants/plant_scan_new.html b/plants/templates/plants/plant_scan_new.html
new file mode 100644
index 0000000..c62cf44
--- /dev/null
+++ b/plants/templates/plants/plant_scan_new.html
@@ -0,0 +1,213 @@
+{% extends "plants/base.html" %}
+{% block title %}Add plant from card — PlantDB{% endblock %}
+{% block content %}
+
+
+
←
+
Add plant from card
+
+
+{% if step == 'upload' %}
+
+
+ Take a photo of the front and back of the plant card, then tap Scan.
+
+
+{% if error %}
+
{{ error }}
+{% endif %}
+
+
+
+
+
Loading…
+
Scanning card…
+
+
+
+
+{% else %}
+
+
Review the scanned data, fill in the plant name, then save.
+
+
+
+{% endif %}
+
+{% endblock %}
diff --git a/plants/tests/test_card_ocr.py b/plants/tests/test_card_ocr.py
new file mode 100644
index 0000000..f0a5e37
--- /dev/null
+++ b/plants/tests/test_card_ocr.py
@@ -0,0 +1,63 @@
+from plants.services.card_ocr import parse_card_text
+
+# Simulated OCR output matching the Veronicastrum card back
+_SAMPLE_TEXT = """
+ELBURG SMIT
+G1L-901126
+
+Veronicastrum
+virg. 'Red Arrows'
+
+Virginische Ereprijs, Kandelaber-Ehrenpreis,
+Culver's root, Grande veronique, Kransveronika
+
+VII-VIII
+
+7/m2 120 cm
+
+-20 oC
+
+Bijenlokker
+Plant that attracts bees
+"""
+
+
+def test_scientific_name():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['scientific_name'] is not None
+ assert 'Veronicastrum' in result['scientific_name']
+
+
+def test_bloom_months():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['bloom_months'] == [7, 8]
+
+
+def test_height():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['max_height_cm'] == 120
+
+
+def test_frost_hardiness():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['frost_hardiness_c'] == -20
+
+
+def test_planting_density():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['planting_density_m2'] == 7
+
+
+def test_bee_attractant():
+ result = parse_card_text(_SAMPLE_TEXT)
+ assert result['bee_attractant'] is True
+
+
+def test_empty_text():
+ result = parse_card_text('')
+ assert result['scientific_name'] is None
+ assert result['bloom_months'] == []
+ assert result['max_height_cm'] is None
+ assert result['frost_hardiness_c'] is None
+ assert result['planting_density_m2'] is None
+ assert result['bee_attractant'] is False
diff --git a/plants/urls.py b/plants/urls.py
index b9b0020..2dfdcc1 100644
--- a/plants/urls.py
+++ b/plants/urls.py
@@ -5,10 +5,13 @@ urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
path('plants/', plants.plant_list, name='plant_list'),
path('plants/add/', plants.plant_add, name='plant_add'),
+ path('plants/scan/', plants.add_plant_from_scan, name='add_plant_from_scan'),
path('plants/
/', plants.plant_detail, name='plant_detail'),
path('plants//edit/', plants.plant_edit, name='plant_edit'),
path('plants//delete/', plants.plant_delete, name='plant_delete'),
path('plants//card/', plants.plant_card, name='plant_card'),
+ path('plants//crop-thumbnail/', plants.crop_thumbnail, name='crop_thumbnail'),
+ path('plants//card/extract/', plants.extract_card_data, name='extract_card_data'),
path('plants//log-pruning/', plants.log_pruning, name='log_pruning'),
path('plants//upload-photo/', plants.upload_photo, name='upload_photo'),
path('plants//card-photos/upload/', plants.upload_card_photo, name='upload_card_photo'),
diff --git a/plants/views/plants.py b/plants/views/plants.py
index c5d0131..d07d3ac 100644
--- a/plants/views/plants.py
+++ b/plants/views/plants.py
@@ -8,7 +8,7 @@ from plants.utils.pruning import pruning_status
def plant_list(request):
- qs = Plant.objects.select_related('species', 'location').all()
+ qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all()
q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '')
@@ -131,6 +131,25 @@ def upload_card_photo(request, pk):
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
+def _process_card_photo_async(card_photo):
+ """Apply card shape extraction + denoising to the uploaded image in-place."""
+ try:
+ from plants.services.card_processing import process_card_photo
+ from PIL import Image
+ import io
+ from django.core.files.base import ContentFile
+
+ original = Image.open(card_photo.image.path).convert('RGB')
+ processed = process_card_photo(original)
+
+ buf = io.BytesIO()
+ processed.save(buf, format='JPEG', quality=88)
+ filename = card_photo.image.name.split('/')[-1]
+ card_photo.image.save(filename, ContentFile(buf.getvalue()), save=True)
+ except Exception:
+ pass # silently keep the original if processing fails
+
+
@require_POST
def delete_card_photo(request, card_photo_pk):
card_photo = get_object_or_404(PlantCardPhoto, pk=card_photo_pk)
@@ -141,7 +160,56 @@ def delete_card_photo(request, card_photo_pk):
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
-@require_POST
+def crop_thumbnail(request, pk):
+ plant = get_object_or_404(Plant, pk=pk)
+ card_photos = list(plant.card_photos.all())
+ if not card_photos:
+ return redirect('plant_detail', pk=pk)
+
+ if request.method == 'POST':
+ from PIL import Image, ImageOps
+ import io
+ from django.core.files.base import ContentFile
+
+ try:
+ x = int(request.POST['x'])
+ y = int(request.POST['y'])
+ w = int(request.POST['width'])
+ h = int(request.POST['height'])
+ except (KeyError, ValueError):
+ return redirect('plant_detail', pk=pk)
+
+ source_url = request.POST.get('source_url', '')
+ photo_obj = next(
+ (cp for cp in card_photos if cp.image.url == source_url),
+ card_photos[0]
+ )
+ img = Image.open(photo_obj.image.path)
+ img = ImageOps.exif_transpose(img)
+ cropped = img.crop((x, y, x + w, y + h))
+
+ max_side = 900
+ if max(cropped.width, cropped.height) > max_side:
+ scale = max_side / max(cropped.width, cropped.height)
+ cropped = cropped.resize(
+ (int(cropped.width * scale), int(cropped.height * scale)), Image.LANCZOS
+ )
+
+ buf = io.BytesIO()
+ cropped.save(buf, format='JPEG', quality=88)
+
+ PlantPhoto.objects.filter(plant=plant).update(is_thumbnail=False)
+ photo = PlantPhoto(plant=plant, is_thumbnail=True)
+ photo.image.save(f'thumb_{plant.pk}.jpg', ContentFile(buf.getvalue()), save=True)
+
+ return redirect('plant_detail', pk=pk)
+
+ return render(request, 'plants/crop_thumbnail.html', {
+ 'plant': plant,
+ 'card_photos': card_photos,
+ })
+
+
def upload_photo(request, pk):
plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk)
if request.FILES.get('image'):
@@ -168,3 +236,271 @@ def delete_photo(request, photo_pk):
plant.refresh_from_db()
plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk)
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
+
+
+def extract_card_data(request, pk):
+ plant = get_object_or_404(Plant.objects.prefetch_related('card_photos'), pk=pk)
+ card_photos = list(plant.card_photos.all())
+
+ if not card_photos:
+ return redirect('plant_card', pk=pk)
+
+ if request.method == 'POST':
+ return _save_extracted_data(request, plant, card_photos)
+
+ # Run OCR on all card photos, merging results (later photos fill gaps)
+ from plants.services.card_ocr import extract_from_card, extract_plant_photo
+ merged = {
+ 'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
+ 'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
+ }
+ for cp in card_photos:
+ data = extract_from_card(cp.image.path)
+ for key, val in data.items():
+ if val:
+ merged[key] = val
+
+ # Extract plant photo from the first card photo (assumed front) and save as preview
+ from PIL import ImageOps
+ import io
+ from django.core.files.base import ContentFile
+ from django.core.files.storage import default_storage
+
+ plant_photo_preview_url = None
+ front_img = extract_plant_photo(card_photos[0].image.path)
+ if front_img:
+ from PIL import Image, ImageEnhance, ImageFilter
+ front_img = ImageOps.exif_transpose(front_img)
+ # Resize to max 900px on longest side
+ max_side = 900
+ if max(front_img.width, front_img.height) > max_side:
+ scale = max_side / max(front_img.width, front_img.height)
+ front_img = front_img.resize(
+ (int(front_img.width * scale), int(front_img.height * scale)),
+ Image.LANCZOS,
+ )
+ # Enhance: auto-contrast, boost saturation slightly, sharpen
+ front_img = ImageOps.autocontrast(front_img, cutoff=1)
+ front_img = ImageEnhance.Color(front_img).enhance(1.2)
+ front_img = front_img.filter(ImageFilter.SHARPEN)
+ buf = io.BytesIO()
+ front_img.save(buf, format='JPEG', quality=88)
+ preview_name = f'plants/previews/preview_{plant.pk}.jpg'
+ if default_storage.exists(preview_name):
+ default_storage.delete(preview_name)
+ default_storage.save(preview_name, ContentFile(buf.getvalue()))
+ plant_photo_preview_url = default_storage.url(preview_name)
+
+ # Normalise bloom_months to strings so template `val in bloom_months` works
+ merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
+
+ from plants.forms import MONTH_CHOICES
+ from plants.models import SUNLIGHT_CHOICES
+ return render(request, 'plants/plant_card_extract.html', {
+ 'plant': plant,
+ 'extracted': merged,
+ 'plant_photo_preview_url': plant_photo_preview_url,
+ 'month_choices': MONTH_CHOICES,
+ 'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
+ })
+
+
+def _save_extracted_data(request, plant, card_photos):
+ from plants.models import WATERING_CHOICES, SUNLIGHT_CHOICES
+ import io
+ from django.core.files.base import ContentFile
+ from django.core.files.storage import default_storage
+
+ def _int_or_none(val):
+ try:
+ return int(val) if val not in (None, '') else None
+ except (ValueError, TypeError):
+ return None
+
+ bloom_raw = request.POST.getlist('bloom_months')
+ bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
+
+ fields = {
+ 'common_name': request.POST.get('scientific_name', '').strip(),
+ 'scientific_name': request.POST.get('scientific_name', '').strip(),
+ 'bloom_months': bloom_months,
+ 'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
+ 'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
+ 'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
+ 'sunlight': request.POST.get('sunlight', ''),
+ }
+
+ if plant.species:
+ for attr, val in fields.items():
+ if val not in (None, '', []):
+ setattr(plant.species, attr, val)
+ plant.species.save()
+ else:
+ species = Species.objects.create(**{k: v for k, v in fields.items() if v not in (None, '', [])})
+ plant.species = species
+ plant.save(update_fields=['species'])
+
+ preview_name = f'plants/previews/preview_{plant.pk}.jpg'
+ if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
+ with default_storage.open(preview_name, 'rb') as f:
+ photo = PlantPhoto(plant=plant)
+ photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
+
+ if default_storage.exists(preview_name):
+ default_storage.delete(preview_name)
+
+ return redirect('plant_detail', pk=plant.pk)
+
+
+def add_plant_from_scan(request):
+ from plants.forms import MONTH_CHOICES
+ from plants.models import SUNLIGHT_CHOICES
+
+ if request.method == 'GET':
+ return render(request, 'plants/plant_scan_new.html', {'step': 'upload'})
+
+ if request.POST.get('step') == 'confirm':
+ return _create_plant_from_scan(request)
+
+ # Step 1 POST: save uploaded photos, run OCR, show confirmation
+ import uuid, io
+ from django.core.files.storage import default_storage
+ from django.core.files.base import ContentFile
+ from plants.services.card_ocr import extract_from_card, extract_plant_photo
+ from PIL import Image, ImageOps, ImageEnhance, ImageFilter
+
+ files = request.FILES.getlist('images')
+ if not files:
+ return render(request, 'plants/plant_scan_new.html', {
+ 'step': 'upload', 'error': 'Please select at least one photo.'
+ })
+
+ scan_id = uuid.uuid4().hex
+ temp_names = []
+ for i, f in enumerate(files[:3]):
+ temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
+ default_storage.save(temp_name, ContentFile(f.read()))
+ temp_names.append(temp_name)
+
+ merged = {
+ 'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
+ 'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
+ }
+ for temp_name in temp_names:
+ data = extract_from_card(default_storage.path(temp_name))
+ for key, val in data.items():
+ if val:
+ merged[key] = val
+
+ plant_photo_preview_url = None
+ front_img = extract_plant_photo(default_storage.path(temp_names[0]))
+ if front_img:
+ front_img = ImageOps.exif_transpose(front_img)
+ max_side = 900
+ if max(front_img.width, front_img.height) > max_side:
+ scale = max_side / max(front_img.width, front_img.height)
+ front_img = front_img.resize(
+ (int(front_img.width * scale), int(front_img.height * scale)), Image.LANCZOS
+ )
+ front_img = ImageOps.autocontrast(front_img, cutoff=1)
+ front_img = ImageEnhance.Color(front_img).enhance(1.2)
+ front_img = front_img.filter(ImageFilter.SHARPEN)
+ buf = io.BytesIO()
+ front_img.save(buf, format='JPEG', quality=88)
+ preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
+ default_storage.save(preview_name, ContentFile(buf.getvalue()))
+ plant_photo_preview_url = default_storage.url(preview_name)
+
+ merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
+
+ vpc_results = []
+ if merged.get('scientific_name'):
+ try:
+ from plants.services.vpc import search_species, VPCError
+ vpc_results = search_species(merged['scientific_name'])[:3]
+ except Exception:
+ pass
+
+ return render(request, 'plants/plant_scan_new.html', {
+ 'step': 'confirm',
+ 'scan_id': scan_id,
+ 'extracted': merged,
+ 'suggested_name': merged.get('scientific_name') or '',
+ 'plant_photo_preview_url': plant_photo_preview_url,
+ 'month_choices': MONTH_CHOICES,
+ 'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
+ 'vpc_results': vpc_results,
+ })
+
+
+def _create_plant_from_scan(request):
+ import io
+ from django.core.files.storage import default_storage
+ from django.core.files.base import ContentFile
+
+ scan_id = request.POST.get('scan_id', '')
+ if not scan_id.isalnum():
+ return redirect('add_plant_from_scan')
+
+ def _int_or_none(val):
+ try:
+ return int(val) if val not in (None, '') else None
+ except (ValueError, TypeError):
+ return None
+
+ bloom_raw = request.POST.getlist('bloom_months')
+ bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
+ name = request.POST.get('name', '').strip() or request.POST.get('scientific_name', '').strip() or 'New plant'
+
+ species_fields = {
+ 'scientific_name': request.POST.get('scientific_name', '').strip(),
+ 'common_name': request.POST.get('scientific_name', '').strip(),
+ 'bloom_months': bloom_months,
+ 'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
+ 'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
+ 'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
+ 'sunlight': request.POST.get('sunlight', ''),
+ }
+ species = Species.objects.create(**{k: v for k, v in species_fields.items() if v not in (None, '', [])})
+ plant = Plant.objects.create(name=name, species=species)
+
+ vpc_slug = request.POST.get('vpc_slug', '').strip()
+ if vpc_slug:
+ try:
+ from plants.services.vpc import scrape_plant, VPCError
+ data = scrape_plant(vpc_slug)
+ species.scientific_name = data.get('scientific_name') or species.scientific_name
+ species.common_name = data.get('common_name') or species.common_name
+ species.bloom_months = data.get('bloom_months') or species.bloom_months
+ species.max_height_cm = data.get('max_height_cm') or species.max_height_cm
+ species.frost_hardiness_c = data.get('frost_hardiness_c') or species.frost_hardiness_c
+ species.planting_density_m2 = data.get('planting_density_m2') or species.planting_density_m2
+ species.sunlight = data.get('sunlight') or species.sunlight
+ species.api_image_url = data.get('api_image_url', '')
+ species.vpc_slug = vpc_slug
+ species.vpc_raw = data
+ species.save()
+ except Exception:
+ pass
+
+ i = 0
+ while True:
+ temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
+ if not default_storage.exists(temp_name):
+ break
+ with default_storage.open(temp_name, 'rb') as f:
+ card_photo = PlantCardPhoto(plant=plant)
+ card_photo.image.save(f'card_{plant.pk}_{i}.jpg', ContentFile(f.read()), save=True)
+ default_storage.delete(temp_name)
+ i += 1
+
+ preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
+ if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
+ with default_storage.open(preview_name, 'rb') as f:
+ photo = PlantPhoto(plant=plant, is_thumbnail=True)
+ photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
+
+ if default_storage.exists(preview_name):
+ default_storage.delete(preview_name)
+
+ return redirect('plant_detail', pk=plant.pk)
diff --git a/plants/views/species.py b/plants/views/species.py
index 86253b2..8b1f5b0 100644
--- a/plants/views/species.py
+++ b/plants/views/species.py
@@ -1,4 +1,3 @@
-from concurrent.futures import ThreadPoolExecutor
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from django.views.decorators.http import require_POST
@@ -20,35 +19,31 @@ def species_search(request):
vpc_results = []
error = None
+ include_perenual = bool(request.GET.get('include_perenual'))
+
if len(q) >= 2:
- # If the query looks like a VPC URL or slug, skip the search and show
- # it directly as a single importable result.
+ # If the query looks like a VPC URL or slug, skip search and show it
+ # directly as a single importable result.
vpc_slug = vpc_service.slug_from_query(q)
if vpc_slug:
vpc_results = [{'slug': vpc_slug, 'common_name': vpc_slug.replace('-', ' ').title(),
'scientific_name': '', 'thumbnail_url': '', 'source': 'vpc'}]
else:
- def _run_perenual():
+ try:
+ vpc_results = vpc_service.search_species(q)
+ except VPCError:
+ pass
+
+ if include_perenual:
try:
- return perenual_search(q), None
+ perenual_results, error = perenual_search(q), None
except PerenualError as e:
- return [], str(e)
-
- def _run_vpc():
- try:
- return vpc_service.search_species(q)
- except VPCError:
- return []
-
- with ThreadPoolExecutor(max_workers=2) as ex:
- f_p = ex.submit(_run_perenual)
- f_v = ex.submit(_run_vpc)
- perenual_results, error = f_p.result()
- vpc_results = f_v.result()
+ error = str(e)
return render(request, 'plants/partials/species_results.html', {
'perenual_results': perenual_results,
'vpc_results': vpc_results,
+ 'include_perenual': include_perenual,
'q': q,
'error': error,
})
diff --git a/requirements.txt b/requirements.txt
index e818999..094fbaf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,6 @@
asgiref==3.11.1
+opencv-python-headless>=4.9
+pytesseract>=0.3.13
beautifulsoup4==4.14.3
certifi==2026.5.20
charset-normalizer==3.4.7