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>
This commit is contained in:
Stephan Kerkman 2026-05-29 21:54:59 +02:00
parent fad4642075
commit ba0ca6b169
19 changed files with 1216 additions and 47 deletions

View file

@ -4,6 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \ libpq-dev gcc \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-nld \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .

View file

@ -11,7 +11,7 @@ services:
command: > command: >
sh -c "python manage.py migrate --noinput && sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --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 restart: unless-stopped
nginx: nginx:

View file

@ -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),
),
]

201
plants/services/card_ocr.py Normal file
View file

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

View file

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

View file

@ -1,7 +1,7 @@
""" """
Scraper for vasteplantencatalogus.nl (VPC). Scraper for vasteplantencatalogus.nl (VPC).
- search_species(query) list of lightweight result dicts - search_species(query) -> list of lightweight result dicts
- scrape_plant(slug) full detail dict ready to store as Species - scrape_plant(slug) -> full detail dict ready to store as Species
Notes on VPC data: Notes on VPC data:
- Plant titles ARE the Latin names (no separate common name) - 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, '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): class VPCError(Exception):
pass pass
@ -90,6 +98,13 @@ def slug_from_query(query):
return '' return ''
_STRIP_QUOTES_RE = re.compile('[' + re.escape(_QUOTE_CHARS) + ']')
def _strip_quotes(text):
return _STRIP_QUOTES_RE.sub('', text)
def search_species(query): def search_species(query):
""" """
Search VPC and return up to 8 result dicts: 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. On VPC, the title IS the Latin name so both name fields are set to it.
Raises VPCError on network failure. 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: try:
resp = requests.get( resp = requests.get(
f'{_BASE}/', _BASE + '/',
params={'s': query, 'post_type': 'product'}, params={'s': vpc_query, 'post_type': 'product'},
timeout=_TIMEOUT, timeout=_TIMEOUT,
headers=_HEADERS, headers=_HEADERS,
) )
@ -109,7 +131,7 @@ def search_species(query):
except requests.Timeout: except requests.Timeout:
raise VPCError('VPC timed out.') raise VPCError('VPC timed out.')
except Exception as e: except Exception as e:
raise VPCError(f'VPC unreachable: {e}') raise VPCError('VPC unreachable: ' + str(e))
soup = BeautifulSoup(resp.text, 'html.parser') soup = BeautifulSoup(resp.text, 'html.parser')
seen_slugs = set() seen_slugs = set()
@ -139,6 +161,15 @@ def search_species(query):
if len(results) >= 8: if len(results) >= 8:
break 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 return results
@ -150,14 +181,14 @@ def scrape_plant(slug):
Raises VPCError on failure. Raises VPCError on failure.
""" """
url = f'{_BASE}/soorten/{slug}/' url = _BASE + '/soorten/' + slug + '/'
try: try:
resp = requests.get(url, timeout=_TIMEOUT, headers=_HEADERS) resp = requests.get(url, timeout=_TIMEOUT, headers=_HEADERS)
resp.raise_for_status() resp.raise_for_status()
except requests.Timeout: except requests.Timeout:
raise VPCError('VPC timed out while fetching plant page.') raise VPCError('VPC timed out while fetching plant page.')
except Exception as e: 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') soup = BeautifulSoup(resp.text, 'html.parser')
@ -165,7 +196,7 @@ def scrape_plant(slug):
h1 = soup.find('h1') h1 = soup.find('h1')
name = h1.get_text(strip=True) if h1 else slug.replace('-', ' ').title() 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 = '' hero_img = ''
for sel in ['.wp-caption img', 'figure img', '.elementor-widget-image img']: for sel in ['.wp-caption img', 'figure img', '.elementor-widget-image img']:
img = soup.select_one(sel) img = soup.select_one(sel)
@ -204,7 +235,15 @@ def scrape_plant(slug):
except (AttributeError, ValueError): except (AttributeError, ValueError):
pass pass
elif label == 'Standplaats': 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'): elif label in ('Groeisnelheid', 'Groeivorm'):
result['growth_rate'] = value result['growth_rate'] = value

View file

@ -30,6 +30,7 @@
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a> <a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a> <a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-sm btn-outline-light" title="Add plant by card scan">📷</a>
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a> <a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
</div> </div>
</nav> </nav>

View file

@ -0,0 +1,86 @@
{% extends "plants/base.html" %}
{% block title %}Crop thumbnail — {{ plant.name }}{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_detail' plant.pk %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Crop thumbnail — {{ plant.name }}</h5>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
{% if card_photos|length > 1 %}
<div class="d-flex gap-2 mb-3">
{% for photo in card_photos %}
<img src="{{ photo.image.url }}" width="60" height="60"
class="rounded card-source-thumb {% if forloop.first %}border border-success border-2{% endif %}"
style="object-fit:cover;cursor:pointer;"
data-url="{{ photo.image.url }}"
onclick="switchSource(this)">
{% endfor %}
</div>
{% endif %}
<div style="max-height:60vh;overflow:hidden;background:#111;border-radius:8px;" class="mb-3">
<img id="crop-image" src="{{ card_photos.0.image.url }}"
style="display:block;max-width:100%;max-height:60vh;">
</div>
<form method="post" id="crop-form">
{% csrf_token %}
<input type="hidden" name="x" id="f-x">
<input type="hidden" name="y" id="f-y">
<input type="hidden" name="width" id="f-w">
<input type="hidden" name="height" id="f-h">
<input type="hidden" name="natural_width" id="f-nw">
<input type="hidden" name="natural_height" id="f-nh">
<input type="hidden" name="source_url" id="f-src" value="{{ card_photos.0.image.url }}">
<button type="submit" class="btn btn-success w-100">Save as thumbnail</button>
</form>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
<script>
let cropper;
function initCropper(img) {
if (cropper) cropper.destroy();
cropper = new Cropper(img, {
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.7,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
});
}
initCropper(document.getElementById('crop-image'));
function switchSource(thumb) {
document.querySelectorAll('.card-source-thumb').forEach(t => {
t.classList.remove('border', 'border-success', 'border-2');
});
thumb.classList.add('border', 'border-success', 'border-2');
const img = document.getElementById('crop-image');
img.src = thumb.dataset.url;
document.getElementById('f-src').value = thumb.dataset.url;
img.onload = () => initCropper(img);
}
document.getElementById('crop-form').addEventListener('submit', function(e) {
const data = cropper.getData(true);
const imgData = cropper.getImageData();
document.getElementById('f-x').value = data.x;
document.getElementById('f-y').value = data.y;
document.getElementById('f-w').value = data.width;
document.getElementById('f-h').value = data.height;
document.getElementById('f-nw').value = imgData.naturalWidth;
document.getElementById('f-nh').value = imgData.naturalHeight;
});
</script>
{% endblock %}

View file

@ -1,4 +1,4 @@
<div class="mb-3"> <div id="card-gallery" class="mb-3">
{% with card_photos=plant.card_photos.all %} {% with card_photos=plant.card_photos.all %}
{% if card_photos %} {% if card_photos %}
<div class="d-flex flex-wrap gap-2 mb-2"> <div class="d-flex flex-wrap gap-2 mb-2">
@ -23,15 +23,15 @@
{% if card_photos|length < 3 %} {% if card_photos|length < 3 %}
<form method="post" action="{% url 'upload_card_photo' plant.pk %}" enctype="multipart/form-data" <form method="post" action="{% url 'upload_card_photo' plant.pk %}" enctype="multipart/form-data"
hx-post="{% url 'upload_card_photo' plant.pk %}" hx-target="#card-gallery" hx-swap="outerHTML" hx-post="{% url 'upload_card_photo' plant.pk %}" hx-target="#card-gallery" hx-swap="outerHTML"
hx-encoding="multipart/form-data"> hx-encoding="multipart/form-data" id="card-upload-form">
{% csrf_token %} {% csrf_token %}
<div class="d-flex gap-2 align-items-center"> <label class="btn btn-sm btn-outline-secondary">
<input type="file" name="image" accept="image/*" capture="environment" 📷 Add card photo
class="form-control form-control-sm" style="max-width:220px;"> <input type="file" name="image" accept="image/*" class="d-none"
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button> onchange="document.getElementById('card-upload-form').requestSubmit()">
</div> </label>
</form> </form>
<small class="text-muted">Up to 3 photos — front &amp; back of the label</small> <small class="text-muted d-block mt-1">Up to 3 photos — front &amp; back of the label</small>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>

View file

@ -2,10 +2,10 @@
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none"> <a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body d-flex align-items-center gap-3 py-2"> <div class="card-body d-flex align-items-center gap-3 py-2">
{% if plant.photo %} {% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.photo.url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% elif plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;"> <img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% else %} {% else %}
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div> <div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
{% endif %} {% endif %}

View file

@ -7,8 +7,19 @@
<h5 class="mb-0">Plant card</h5> <h5 class="mb-0">Plant card</h5>
</div> </div>
<div id="card-gallery"> {% include "plants/partials/card_gallery.html" %}
{% include "plants/partials/card_gallery.html" %}
{% if plant.card_photos.all %}
<div class="mt-3">
<a href="{% url 'extract_card_data' plant.pk %}" class="btn btn-sm btn-outline-success"
onclick="document.getElementById('scan-spinner').style.display='flex'">
🔍 Extract data from card
</a>
</div> </div>
<div id="scan-spinner" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.88); z-index:9999; align-items:center; justify-content:center; flex-direction:column; gap:12px;">
<div class="spinner-border text-success" role="status"><span class="visually-hidden">Loading…</span></div>
<p class="text-muted small mb-0">Scanning card…</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,93 @@
{% extends "plants/base.html" %}
{% load plant_extras %}
{% block title %}Extract card data — {{ plant.name }} — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary">← Card</a>
<h5 class="mb-0">Extract from card</h5>
</div>
<p class="text-muted small mb-3">Review the data extracted from your card photos. Correct anything that looks off, then save.</p>
<form method="post" action="{% url 'extract_card_data' plant.pk %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label fw-semibold">Scientific name</label>
<input type="text" name="scientific_name" class="form-control"
value="{{ extracted.scientific_name|default:'' }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Bloom months</label>
<div class="month-toggle-group">
{% for val, label in month_choices %}
<div class="month-toggle">
<input type="checkbox" id="month_{{ val }}" name="bloom_months" value="{{ val }}"
{% if val in extracted.bloom_months %}checked{% endif %}>
<label for="month_{{ val }}">{{ label|slice:":1" }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Height (cm)</label>
<input type="number" name="max_height_cm" class="form-control"
value="{{ extracted.max_height_cm|default:'' }}">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Frost hardiness (°C)</label>
<input type="number" name="frost_hardiness_c" class="form-control"
value="{{ extracted.frost_hardiness_c|default:'' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Planting density (per m²)</label>
<input type="number" name="planting_density_m2" class="form-control" style="max-width:140px;"
value="{{ extracted.planting_density_m2|default:'' }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Sunlight <small class="text-muted fw-normal">(select manually — icons can't be read)</small></label>
<div class="d-flex flex-wrap gap-2">
{% for val, label in sunlight_choices %}
<div>
<input type="radio" class="btn-check" name="sunlight"
id="sunlight_{{ val|default:'none' }}" value="{{ val }}"
autocomplete="off"
{% if plant.species.sunlight == val %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sunlight_{{ val|default:'none' }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="bee_attractant" name="bee_attractant"
{% if extracted.bee_attractant %}checked{% endif %} disabled>
<label class="form-check-label" for="bee_attractant">🐝 Bee attractant (detected from card)</label>
</div>
{% if plant_photo_preview_url %}
<div class="mb-3">
<img src="{{ plant_photo_preview_url }}" class="rounded mb-2"
style="max-width:100%; max-height:260px; object-fit:contain; display:block;">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="save_plant_photo" name="save_plant_photo" value="1" checked>
<label class="form-check-label" for="save_plant_photo">
📷 Add this photo to gallery
</label>
</div>
</div>
{% endif %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success">Save to species</button>
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
{% endblock %}

View file

@ -8,10 +8,10 @@
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a> <a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
</div> </div>
{% if plant.thumbnail_url %} {% if plant.species and plant.species.api_image_url %}
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% elif plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}"> <img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
{% elif plant.thumbnail_url %}
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% else %} {% else %}
<div class="plant-hero-placeholder rounded mb-3">🌿</div> <div class="plant-hero-placeholder rounded mb-3">🌿</div>
{% endif %} {% endif %}
@ -43,6 +43,12 @@
{% if plant.species.max_height_cm %} {% if plant.species.max_height_cm %}
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span> <span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
{% endif %} {% endif %}
{% if plant.species.frost_hardiness_c is not None %}
<span class="care-chip" style="background:#e3f2fd; color:#0d47a1;">❄️ {{ plant.species.frost_hardiness_c }}°C</span>
{% endif %}
{% if plant.species.planting_density_m2 %}
<span class="care-chip" style="background:#f9fbe7; color:#558b2f;">🌿 {{ plant.species.planting_density_m2 }}/m²</span>
{% endif %}
{% if plant.species.growth_rate %} {% if plant.species.growth_rate %}
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span> <span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
{% endif %} {% endif %}
@ -81,8 +87,9 @@
{% include "plants/partials/photo_gallery.html" %} {% include "plants/partials/photo_gallery.html" %}
</div> </div>
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 Plant card</a>
{% if plant.card_photos.all %} {% if plant.card_photos.all %}
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 View plant card</a> <a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">✂️ Crop thumbnail</a>
{% endif %} {% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a> <a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a>

View file

@ -0,0 +1,213 @@
{% extends "plants/base.html" %}
{% block title %}Add plant from card — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Add plant from card</h5>
</div>
{% if step == 'upload' %}
<p class="text-muted small mb-3">
Take a photo of the front and back of the plant card, then tap Scan.
</p>
{% if error %}
<div class="alert alert-warning py-2 small">{{ error }}</div>
{% endif %}
<form method="post" enctype="multipart/form-data" action="{% url 'add_plant_from_scan' %}"
id="scan-form">
{% csrf_token %}
<input type="file" name="images" accept="image/*" class="d-none" id="file-picker">
<div class="mb-3">
<div id="preview-thumbs" class="d-flex flex-wrap gap-2 mb-2"></div>
<label id="add-photo-btn" class="btn btn-outline-secondary w-100 py-3" for="file-picker">
📷 Add card photo <span id="photo-count" class="text-muted small"></span>
</label>
</div>
<button type="submit" class="btn btn-success w-100" id="scan-btn" disabled>
🔍 Scan card
</button>
</form>
<div id="scan-spinner" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.88); z-index:9999; align-items:center; justify-content:center; flex-direction:column; gap:12px;">
<div class="spinner-border text-success" role="status"><span class="visually-hidden">Loading…</span></div>
<p class="text-muted small mb-0">Scanning card…</p>
</div>
<script>
const selected = [];
const picker = document.getElementById('file-picker');
picker.addEventListener('change', function () {
if (!this.files[0] || selected.length >= 3) return;
selected.push(this.files[0]);
this.value = '';
render();
});
function render() {
const thumbs = document.getElementById('preview-thumbs');
thumbs.innerHTML = '';
selected.forEach((file, i) => {
const wrap = document.createElement('div');
wrap.style.position = 'relative';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;';
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '✕';
btn.style.cssText = 'position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;';
btn.onclick = () => { selected.splice(i, 1); render(); };
wrap.appendChild(img); wrap.appendChild(btn);
thumbs.appendChild(wrap);
});
document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/3)` : '';
document.getElementById('add-photo-btn').style.display = selected.length >= 3 ? 'none' : '';
document.getElementById('scan-btn').disabled = selected.length === 0;
}
document.getElementById('scan-form').addEventListener('submit', function (e) {
if (selected.length === 0) { e.preventDefault(); return; }
const dt = new DataTransfer();
selected.forEach(f => dt.items.add(f));
picker.files = dt.files;
document.getElementById('scan-spinner').style.display = 'flex';
});
</script>
{% else %}
<p class="text-muted small mb-3">Review the scanned data, fill in the plant name, then save.</p>
<form method="post" action="{% url 'add_plant_from_scan' %}">
{% csrf_token %}
<input type="hidden" name="step" value="confirm">
<input type="hidden" name="scan_id" value="{{ scan_id }}">
<input type="hidden" name="vpc_slug" id="vpc_slug_input" value="">
{% if vpc_results %}
<div class="mb-3">
<label class="form-label fw-semibold">VPC match <small class="text-muted fw-normal">tap to use enriched data</small></label>
<div class="d-flex flex-column gap-2" id="vpc-results">
{% for r in vpc_results %}
<div class="vpc-option d-flex align-items-center gap-2 p-2 border rounded" style="cursor:pointer;"
data-slug="{{ r.slug }}" onclick="selectVpc(this)">
{% if r.thumbnail_url %}
<img src="{{ r.thumbnail_url }}" width="48" height="48" style="object-fit:cover;border-radius:4px;" alt="">
{% endif %}
<div class="flex-grow-1 small">
<div class="fw-semibold">{{ r.common_name }}</div>
</div>
<span class="vpc-check text-success fw-bold" style="display:none;"></span>
</div>
{% endfor %}
<div class="vpc-option d-flex align-items-center gap-2 p-2 border rounded text-muted small"
style="cursor:pointer;" data-slug="" onclick="selectVpc(this)">
<div class="flex-grow-1">Skip — use scan data only</div>
<span class="vpc-check text-success fw-bold" style="display:none;"></span>
</div>
</div>
</div>
<script>
function selectVpc(el) {
document.querySelectorAll('.vpc-option').forEach(o => {
o.classList.remove('border-success');
o.querySelector('.vpc-check').style.display = 'none';
});
el.classList.add('border-success');
el.querySelector('.vpc-check').style.display = '';
document.getElementById('vpc_slug_input').value = el.dataset.slug;
}
// Pre-select first result
const first = document.querySelector('.vpc-option');
if (first) selectVpc(first);
</script>
{% endif %}
<div class="mb-3">
<label class="form-label fw-semibold">Plant name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ suggested_name }}"
placeholder="e.g. Liatris" required autofocus>
<small class="text-muted">How you want to refer to this plant in your collection</small>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Scientific name</label>
<input type="text" name="scientific_name" class="form-control"
value="{{ extracted.scientific_name|default:'' }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Bloom months</label>
<div class="month-toggle-group">
{% for val, label in month_choices %}
<div class="month-toggle">
<input type="checkbox" id="month_{{ val }}" name="bloom_months" value="{{ val }}"
{% if val in extracted.bloom_months %}checked{% endif %}>
<label for="month_{{ val }}">{{ label|slice:":1" }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Height (cm)</label>
<input type="number" name="max_height_cm" class="form-control"
value="{{ extracted.max_height_cm|default:'' }}">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Frost hardiness (°C)</label>
<input type="number" name="frost_hardiness_c" class="form-control"
value="{{ extracted.frost_hardiness_c|default:'' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Planting density (per m²)</label>
<input type="number" name="planting_density_m2" class="form-control" style="max-width:140px;"
value="{{ extracted.planting_density_m2|default:'' }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Sunlight <small class="text-muted fw-normal">(select manually)</small></label>
<div class="d-flex flex-wrap gap-2">
{% for val, label in sunlight_choices %}
<div>
<input type="radio" class="btn-check" name="sunlight"
id="sunlight_{{ val|default:'none' }}" value="{{ val }}"
autocomplete="off">
<label class="btn btn-outline-secondary btn-sm" for="sunlight_{{ val|default:'none' }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</div>
{% if plant_photo_preview_url %}
<div class="mb-3">
<img src="{{ plant_photo_preview_url }}" class="rounded mb-2"
style="max-width:100%; max-height:260px; object-fit:contain; display:block;">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="save_plant_photo"
name="save_plant_photo" value="1" checked>
<label class="form-check-label" for="save_plant_photo">
📷 Add this photo to gallery
</label>
</div>
</div>
{% endif %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success">Save plant</button>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-outline-secondary">Start over</a>
</div>
</form>
{% endif %}
{% endblock %}

View file

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

View file

@ -5,10 +5,13 @@ urlpatterns = [
path('', dashboard.dashboard, name='dashboard'), path('', dashboard.dashboard, name='dashboard'),
path('plants/', plants.plant_list, name='plant_list'), path('plants/', plants.plant_list, name='plant_list'),
path('plants/add/', plants.plant_add, name='plant_add'), path('plants/add/', plants.plant_add, name='plant_add'),
path('plants/scan/', plants.add_plant_from_scan, name='add_plant_from_scan'),
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'), path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'), path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'), path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
path('plants/<int:pk>/card/', plants.plant_card, name='plant_card'), path('plants/<int:pk>/card/', plants.plant_card, name='plant_card'),
path('plants/<int:pk>/crop-thumbnail/', plants.crop_thumbnail, name='crop_thumbnail'),
path('plants/<int:pk>/card/extract/', plants.extract_card_data, name='extract_card_data'),
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'), path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
path('plants/<int:pk>/upload-photo/', plants.upload_photo, name='upload_photo'), path('plants/<int:pk>/upload-photo/', plants.upload_photo, name='upload_photo'),
path('plants/<int:pk>/card-photos/upload/', plants.upload_card_photo, name='upload_card_photo'), path('plants/<int:pk>/card-photos/upload/', plants.upload_card_photo, name='upload_card_photo'),

View file

@ -8,7 +8,7 @@ from plants.utils.pruning import pruning_status
def plant_list(request): 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', '') q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '') 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}) 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 @require_POST
def delete_card_photo(request, card_photo_pk): def delete_card_photo(request, card_photo_pk):
card_photo = get_object_or_404(PlantCardPhoto, pk=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}) 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): def upload_photo(request, pk):
plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk) plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk)
if request.FILES.get('image'): if request.FILES.get('image'):
@ -168,3 +236,271 @@ def delete_photo(request, photo_pk):
plant.refresh_from_db() plant.refresh_from_db()
plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk) plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk)
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant}) 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)

View file

@ -1,4 +1,3 @@
from concurrent.futures import ThreadPoolExecutor
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -20,35 +19,31 @@ def species_search(request):
vpc_results = [] vpc_results = []
error = None error = None
include_perenual = bool(request.GET.get('include_perenual'))
if len(q) >= 2: if len(q) >= 2:
# If the query looks like a VPC URL or slug, skip the search and show # If the query looks like a VPC URL or slug, skip search and show it
# it directly as a single importable result. # directly as a single importable result.
vpc_slug = vpc_service.slug_from_query(q) vpc_slug = vpc_service.slug_from_query(q)
if vpc_slug: if vpc_slug:
vpc_results = [{'slug': vpc_slug, 'common_name': vpc_slug.replace('-', ' ').title(), vpc_results = [{'slug': vpc_slug, 'common_name': vpc_slug.replace('-', ' ').title(),
'scientific_name': '', 'thumbnail_url': '', 'source': 'vpc'}] 'scientific_name': '', 'thumbnail_url': '', 'source': 'vpc'}]
else: else:
def _run_perenual(): try:
vpc_results = vpc_service.search_species(q)
except VPCError:
pass
if include_perenual:
try: try:
return perenual_search(q), None perenual_results, error = perenual_search(q), None
except PerenualError as e: except PerenualError as e:
return [], str(e) error = 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()
return render(request, 'plants/partials/species_results.html', { return render(request, 'plants/partials/species_results.html', {
'perenual_results': perenual_results, 'perenual_results': perenual_results,
'vpc_results': vpc_results, 'vpc_results': vpc_results,
'include_perenual': include_perenual,
'q': q, 'q': q,
'error': error, 'error': error,
}) })

View file

@ -1,4 +1,6 @@
asgiref==3.11.1 asgiref==3.11.1
opencv-python-headless>=4.9
pytesseract>=0.3.13
beautifulsoup4==4.14.3 beautifulsoup4==4.14.3
certifi==2026.5.20 certifi==2026.5.20
charset-normalizer==3.4.7 charset-normalizer==3.4.7