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:
parent
fad4642075
commit
ba0ca6b169
19 changed files with 1216 additions and 47 deletions
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
23
plants/migrations/0010_add_frost_density_to_species.py
Normal file
23
plants/migrations/0010_add_frost_density_to_species.py
Normal 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
201
plants/services/card_ocr.py
Normal 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
|
||||||
95
plants/services/card_processing.py
Normal file
95
plants/services/card_processing.py
Normal 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
|
||||||
|
|
@ -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,6 +235,14 @@ def scrape_plant(slug):
|
||||||
except (AttributeError, ValueError):
|
except (AttributeError, ValueError):
|
||||||
pass
|
pass
|
||||||
elif label == 'Standplaats':
|
elif label == 'Standplaats':
|
||||||
|
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
|
result['sunlight'] = value
|
||||||
elif label in ('Groeisnelheid', 'Groeivorm'):
|
elif label in ('Groeisnelheid', 'Groeivorm'):
|
||||||
result['growth_rate'] = value
|
result['growth_rate'] = value
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
86
plants/templates/plants/crop_thumbnail.html
Normal file
86
plants/templates/plants/crop_thumbnail.html
Normal 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 %}
|
||||||
|
|
@ -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 & back of the label</small>
|
<small class="text-muted d-block mt-1">Up to 3 photos — front & back of the label</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
93
plants/templates/plants/plant_card_extract.html
Normal file
93
plants/templates/plants/plant_card_extract.html
Normal 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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
213
plants/templates/plants/plant_scan_new.html
Normal file
213
plants/templates/plants/plant_scan_new.html
Normal 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 %}
|
||||||
63
plants/tests/test_card_ocr.py
Normal file
63
plants/tests/test_card_ocr.py
Normal 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
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
try:
|
||||||
return perenual_search(q), None
|
vpc_results = vpc_service.search_species(q)
|
||||||
except PerenualError as e:
|
|
||||||
return [], str(e)
|
|
||||||
|
|
||||||
def _run_vpc():
|
|
||||||
try:
|
|
||||||
return vpc_service.search_species(q)
|
|
||||||
except VPCError:
|
except VPCError:
|
||||||
return []
|
pass
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
if include_perenual:
|
||||||
f_p = ex.submit(_run_perenual)
|
try:
|
||||||
f_v = ex.submit(_run_vpc)
|
perenual_results, error = perenual_search(q), None
|
||||||
perenual_results, error = f_p.result()
|
except PerenualError as e:
|
||||||
vpc_results = f_v.result()
|
error = str(e)
|
||||||
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue