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 \
|
||||
libpq-dev gcc \
|
||||
tesseract-ocr tesseract-ocr-eng tesseract-ocr-nld \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ services:
|
|||
command: >
|
||||
sh -c "python manage.py migrate --noinput &&
|
||||
python manage.py collectstatic --noinput &&
|
||||
gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 2"
|
||||
gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 1"
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
|
|
|
|||
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).
|
||||
- search_species(query) → list of lightweight result dicts
|
||||
- scrape_plant(slug) → full detail dict ready to store as Species
|
||||
- search_species(query) -> list of lightweight result dicts
|
||||
- scrape_plant(slug) -> full detail dict ready to store as Species
|
||||
|
||||
Notes on VPC data:
|
||||
- Plant titles ARE the Latin names (no separate common name)
|
||||
|
|
@ -29,6 +29,14 @@ _ROMAN_MAP = {
|
|||
'IX': 9, 'IV': 4, 'III': 3, 'II': 2, 'X': 10, 'V': 5, 'I': 1,
|
||||
}
|
||||
|
||||
# VPC uses curly/smart quotes around cultivar names in product titles.
|
||||
# Straight apostrophe + left/right single quotation marks + modifier apostrophe.
|
||||
_QUOTE_CHARS = "'" + chr(0x2018) + chr(0x2019) + chr(0x02bc)
|
||||
_Q = '[' + re.escape(_QUOTE_CHARS) + ']'
|
||||
_NQ = '[^' + re.escape(_QUOTE_CHARS) + ']'
|
||||
_CULTIVAR_RE = re.compile(_Q + '(' + _NQ + '+)' + _Q)
|
||||
_CULTIVAR_STRIP_RE = re.compile(r'\s*' + _Q + _NQ + '+' + _Q)
|
||||
|
||||
|
||||
class VPCError(Exception):
|
||||
pass
|
||||
|
|
@ -90,6 +98,13 @@ def slug_from_query(query):
|
|||
return ''
|
||||
|
||||
|
||||
_STRIP_QUOTES_RE = re.compile('[' + re.escape(_QUOTE_CHARS) + ']')
|
||||
|
||||
|
||||
def _strip_quotes(text):
|
||||
return _STRIP_QUOTES_RE.sub('', text)
|
||||
|
||||
|
||||
def search_species(query):
|
||||
"""
|
||||
Search VPC and return up to 8 result dicts:
|
||||
|
|
@ -98,10 +113,17 @@ def search_species(query):
|
|||
On VPC, the title IS the Latin name so both name fields are set to it.
|
||||
Raises VPCError on network failure.
|
||||
"""
|
||||
# VPC's WordPress search breaks on multi-word and quoted queries.
|
||||
# Strategy: search with only the first word (genus), then filter
|
||||
# client-side so all query words appear in the result title.
|
||||
# Quote chars are stripped from both query and titles before matching.
|
||||
words = _strip_quotes(query).split()
|
||||
vpc_query = words[0] if words else query
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f'{_BASE}/',
|
||||
params={'s': query, 'post_type': 'product'},
|
||||
_BASE + '/',
|
||||
params={'s': vpc_query, 'post_type': 'product'},
|
||||
timeout=_TIMEOUT,
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
|
@ -109,7 +131,7 @@ def search_species(query):
|
|||
except requests.Timeout:
|
||||
raise VPCError('VPC timed out.')
|
||||
except Exception as e:
|
||||
raise VPCError(f'VPC unreachable: {e}')
|
||||
raise VPCError('VPC unreachable: ' + str(e))
|
||||
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
seen_slugs = set()
|
||||
|
|
@ -139,6 +161,15 @@ def search_species(query):
|
|||
if len(results) >= 8:
|
||||
break
|
||||
|
||||
if len(words) > 1:
|
||||
words_lower = [w.lower() for w in words]
|
||||
filtered = [
|
||||
r for r in results
|
||||
if all(w in _strip_quotes(r['common_name']).lower() for w in words_lower)
|
||||
]
|
||||
if filtered:
|
||||
return filtered
|
||||
|
||||
return results
|
||||
|
||||
|
||||
|
|
@ -150,14 +181,14 @@ def scrape_plant(slug):
|
|||
|
||||
Raises VPCError on failure.
|
||||
"""
|
||||
url = f'{_BASE}/soorten/{slug}/'
|
||||
url = _BASE + '/soorten/' + slug + '/'
|
||||
try:
|
||||
resp = requests.get(url, timeout=_TIMEOUT, headers=_HEADERS)
|
||||
resp.raise_for_status()
|
||||
except requests.Timeout:
|
||||
raise VPCError('VPC timed out while fetching plant page.')
|
||||
except Exception as e:
|
||||
raise VPCError(f'Could not fetch plant page: {e}')
|
||||
raise VPCError('Could not fetch plant page: ' + str(e))
|
||||
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
|
||||
|
|
@ -165,7 +196,7 @@ def scrape_plant(slug):
|
|||
h1 = soup.find('h1')
|
||||
name = h1.get_text(strip=True) if h1 else slug.replace('-', ' ').title()
|
||||
|
||||
# Hero image — VPC wraps the plant photo in a .wp-caption figure
|
||||
# Hero image -- VPC wraps the plant photo in a .wp-caption figure
|
||||
hero_img = ''
|
||||
for sel in ['.wp-caption img', 'figure img', '.elementor-widget-image img']:
|
||||
img = soup.select_one(sel)
|
||||
|
|
@ -204,6 +235,14 @@ def scrape_plant(slug):
|
|||
except (AttributeError, ValueError):
|
||||
pass
|
||||
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
|
||||
elif label in ('Groeisnelheid', 'Groeivorm'):
|
||||
result['growth_rate'] = value
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a>
|
||||
<div class="d-flex gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</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 %}
|
||||
{% if card_photos %}
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
|
|
@ -23,15 +23,15 @@
|
|||
{% if card_photos|length < 3 %}
|
||||
<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-encoding="multipart/form-data">
|
||||
hx-encoding="multipart/form-data" id="card-upload-form">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="file" name="image" accept="image/*" capture="environment"
|
||||
class="form-control form-control-sm" style="max-width:220px;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button>
|
||||
</div>
|
||||
<label class="btn btn-sm btn-outline-secondary">
|
||||
📷 Add card photo
|
||||
<input type="file" name="image" accept="image/*" class="d-none"
|
||||
onchange="document.getElementById('card-upload-form').requestSubmit()">
|
||||
</label>
|
||||
</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 %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
|
||||
<div class="card mb-2">
|
||||
<div class="card-body d-flex align-items-center gap-3 py-2">
|
||||
{% if plant.photo %}
|
||||
<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 %}
|
||||
{% if 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;">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||||
{% else %}
|
||||
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,19 @@
|
|||
<h5 class="mb-0">Plant card</h5>
|
||||
</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 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 %}
|
||||
|
|
|
|||
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>
|
||||
</div>
|
||||
|
||||
{% if plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
|
||||
{% elif plant.species and plant.species.api_image_url %}
|
||||
{% if 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 }}">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
|
||||
{% else %}
|
||||
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
|
||||
{% endif %}
|
||||
|
|
@ -43,6 +43,12 @@
|
|||
{% if plant.species.max_height_cm %}
|
||||
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
|
||||
{% 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 %}
|
||||
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
|
||||
{% endif %}
|
||||
|
|
@ -81,8 +87,9 @@
|
|||
{% include "plants/partials/photo_gallery.html" %}
|
||||
</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 %}
|
||||
<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 %}
|
||||
|
||||
<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('plants/', plants.plant_list, name='plant_list'),
|
||||
path('plants/add/', plants.plant_add, name='plant_add'),
|
||||
path('plants/scan/', plants.add_plant_from_scan, name='add_plant_from_scan'),
|
||||
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
|
||||
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>/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>/upload-photo/', plants.upload_photo, name='upload_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):
|
||||
qs = Plant.objects.select_related('species', 'location').all()
|
||||
qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all()
|
||||
q = request.GET.get('q', '')
|
||||
indoor_filter = request.GET.get('filter', '')
|
||||
|
||||
|
|
@ -131,6 +131,25 @@ def upload_card_photo(request, pk):
|
|||
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
|
||||
|
||||
|
||||
def _process_card_photo_async(card_photo):
|
||||
"""Apply card shape extraction + denoising to the uploaded image in-place."""
|
||||
try:
|
||||
from plants.services.card_processing import process_card_photo
|
||||
from PIL import Image
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
original = Image.open(card_photo.image.path).convert('RGB')
|
||||
processed = process_card_photo(original)
|
||||
|
||||
buf = io.BytesIO()
|
||||
processed.save(buf, format='JPEG', quality=88)
|
||||
filename = card_photo.image.name.split('/')[-1]
|
||||
card_photo.image.save(filename, ContentFile(buf.getvalue()), save=True)
|
||||
except Exception:
|
||||
pass # silently keep the original if processing fails
|
||||
|
||||
|
||||
@require_POST
|
||||
def delete_card_photo(request, card_photo_pk):
|
||||
card_photo = get_object_or_404(PlantCardPhoto, pk=card_photo_pk)
|
||||
|
|
@ -141,7 +160,56 @@ def delete_card_photo(request, card_photo_pk):
|
|||
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
|
||||
|
||||
|
||||
@require_POST
|
||||
def crop_thumbnail(request, pk):
|
||||
plant = get_object_or_404(Plant, pk=pk)
|
||||
card_photos = list(plant.card_photos.all())
|
||||
if not card_photos:
|
||||
return redirect('plant_detail', pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
from PIL import Image, ImageOps
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
try:
|
||||
x = int(request.POST['x'])
|
||||
y = int(request.POST['y'])
|
||||
w = int(request.POST['width'])
|
||||
h = int(request.POST['height'])
|
||||
except (KeyError, ValueError):
|
||||
return redirect('plant_detail', pk=pk)
|
||||
|
||||
source_url = request.POST.get('source_url', '')
|
||||
photo_obj = next(
|
||||
(cp for cp in card_photos if cp.image.url == source_url),
|
||||
card_photos[0]
|
||||
)
|
||||
img = Image.open(photo_obj.image.path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
cropped = img.crop((x, y, x + w, y + h))
|
||||
|
||||
max_side = 900
|
||||
if max(cropped.width, cropped.height) > max_side:
|
||||
scale = max_side / max(cropped.width, cropped.height)
|
||||
cropped = cropped.resize(
|
||||
(int(cropped.width * scale), int(cropped.height * scale)), Image.LANCZOS
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
cropped.save(buf, format='JPEG', quality=88)
|
||||
|
||||
PlantPhoto.objects.filter(plant=plant).update(is_thumbnail=False)
|
||||
photo = PlantPhoto(plant=plant, is_thumbnail=True)
|
||||
photo.image.save(f'thumb_{plant.pk}.jpg', ContentFile(buf.getvalue()), save=True)
|
||||
|
||||
return redirect('plant_detail', pk=pk)
|
||||
|
||||
return render(request, 'plants/crop_thumbnail.html', {
|
||||
'plant': plant,
|
||||
'card_photos': card_photos,
|
||||
})
|
||||
|
||||
|
||||
def upload_photo(request, pk):
|
||||
plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk)
|
||||
if request.FILES.get('image'):
|
||||
|
|
@ -168,3 +236,271 @@ def delete_photo(request, photo_pk):
|
|||
plant.refresh_from_db()
|
||||
plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk)
|
||||
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
|
||||
|
||||
|
||||
def extract_card_data(request, pk):
|
||||
plant = get_object_or_404(Plant.objects.prefetch_related('card_photos'), pk=pk)
|
||||
card_photos = list(plant.card_photos.all())
|
||||
|
||||
if not card_photos:
|
||||
return redirect('plant_card', pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
return _save_extracted_data(request, plant, card_photos)
|
||||
|
||||
# Run OCR on all card photos, merging results (later photos fill gaps)
|
||||
from plants.services.card_ocr import extract_from_card, extract_plant_photo
|
||||
merged = {
|
||||
'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
|
||||
'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
|
||||
}
|
||||
for cp in card_photos:
|
||||
data = extract_from_card(cp.image.path)
|
||||
for key, val in data.items():
|
||||
if val:
|
||||
merged[key] = val
|
||||
|
||||
# Extract plant photo from the first card photo (assumed front) and save as preview
|
||||
from PIL import ImageOps
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
plant_photo_preview_url = None
|
||||
front_img = extract_plant_photo(card_photos[0].image.path)
|
||||
if front_img:
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
front_img = ImageOps.exif_transpose(front_img)
|
||||
# Resize to max 900px on longest side
|
||||
max_side = 900
|
||||
if max(front_img.width, front_img.height) > max_side:
|
||||
scale = max_side / max(front_img.width, front_img.height)
|
||||
front_img = front_img.resize(
|
||||
(int(front_img.width * scale), int(front_img.height * scale)),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
# Enhance: auto-contrast, boost saturation slightly, sharpen
|
||||
front_img = ImageOps.autocontrast(front_img, cutoff=1)
|
||||
front_img = ImageEnhance.Color(front_img).enhance(1.2)
|
||||
front_img = front_img.filter(ImageFilter.SHARPEN)
|
||||
buf = io.BytesIO()
|
||||
front_img.save(buf, format='JPEG', quality=88)
|
||||
preview_name = f'plants/previews/preview_{plant.pk}.jpg'
|
||||
if default_storage.exists(preview_name):
|
||||
default_storage.delete(preview_name)
|
||||
default_storage.save(preview_name, ContentFile(buf.getvalue()))
|
||||
plant_photo_preview_url = default_storage.url(preview_name)
|
||||
|
||||
# Normalise bloom_months to strings so template `val in bloom_months` works
|
||||
merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
|
||||
|
||||
from plants.forms import MONTH_CHOICES
|
||||
from plants.models import SUNLIGHT_CHOICES
|
||||
return render(request, 'plants/plant_card_extract.html', {
|
||||
'plant': plant,
|
||||
'extracted': merged,
|
||||
'plant_photo_preview_url': plant_photo_preview_url,
|
||||
'month_choices': MONTH_CHOICES,
|
||||
'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
|
||||
})
|
||||
|
||||
|
||||
def _save_extracted_data(request, plant, card_photos):
|
||||
from plants.models import WATERING_CHOICES, SUNLIGHT_CHOICES
|
||||
import io
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
def _int_or_none(val):
|
||||
try:
|
||||
return int(val) if val not in (None, '') else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
bloom_raw = request.POST.getlist('bloom_months')
|
||||
bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
|
||||
|
||||
fields = {
|
||||
'common_name': request.POST.get('scientific_name', '').strip(),
|
||||
'scientific_name': request.POST.get('scientific_name', '').strip(),
|
||||
'bloom_months': bloom_months,
|
||||
'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
|
||||
'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
|
||||
'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
|
||||
'sunlight': request.POST.get('sunlight', ''),
|
||||
}
|
||||
|
||||
if plant.species:
|
||||
for attr, val in fields.items():
|
||||
if val not in (None, '', []):
|
||||
setattr(plant.species, attr, val)
|
||||
plant.species.save()
|
||||
else:
|
||||
species = Species.objects.create(**{k: v for k, v in fields.items() if v not in (None, '', [])})
|
||||
plant.species = species
|
||||
plant.save(update_fields=['species'])
|
||||
|
||||
preview_name = f'plants/previews/preview_{plant.pk}.jpg'
|
||||
if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
|
||||
with default_storage.open(preview_name, 'rb') as f:
|
||||
photo = PlantPhoto(plant=plant)
|
||||
photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
|
||||
|
||||
if default_storage.exists(preview_name):
|
||||
default_storage.delete(preview_name)
|
||||
|
||||
return redirect('plant_detail', pk=plant.pk)
|
||||
|
||||
|
||||
def add_plant_from_scan(request):
|
||||
from plants.forms import MONTH_CHOICES
|
||||
from plants.models import SUNLIGHT_CHOICES
|
||||
|
||||
if request.method == 'GET':
|
||||
return render(request, 'plants/plant_scan_new.html', {'step': 'upload'})
|
||||
|
||||
if request.POST.get('step') == 'confirm':
|
||||
return _create_plant_from_scan(request)
|
||||
|
||||
# Step 1 POST: save uploaded photos, run OCR, show confirmation
|
||||
import uuid, io
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
from plants.services.card_ocr import extract_from_card, extract_plant_photo
|
||||
from PIL import Image, ImageOps, ImageEnhance, ImageFilter
|
||||
|
||||
files = request.FILES.getlist('images')
|
||||
if not files:
|
||||
return render(request, 'plants/plant_scan_new.html', {
|
||||
'step': 'upload', 'error': 'Please select at least one photo.'
|
||||
})
|
||||
|
||||
scan_id = uuid.uuid4().hex
|
||||
temp_names = []
|
||||
for i, f in enumerate(files[:3]):
|
||||
temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
|
||||
default_storage.save(temp_name, ContentFile(f.read()))
|
||||
temp_names.append(temp_name)
|
||||
|
||||
merged = {
|
||||
'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
|
||||
'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
|
||||
}
|
||||
for temp_name in temp_names:
|
||||
data = extract_from_card(default_storage.path(temp_name))
|
||||
for key, val in data.items():
|
||||
if val:
|
||||
merged[key] = val
|
||||
|
||||
plant_photo_preview_url = None
|
||||
front_img = extract_plant_photo(default_storage.path(temp_names[0]))
|
||||
if front_img:
|
||||
front_img = ImageOps.exif_transpose(front_img)
|
||||
max_side = 900
|
||||
if max(front_img.width, front_img.height) > max_side:
|
||||
scale = max_side / max(front_img.width, front_img.height)
|
||||
front_img = front_img.resize(
|
||||
(int(front_img.width * scale), int(front_img.height * scale)), Image.LANCZOS
|
||||
)
|
||||
front_img = ImageOps.autocontrast(front_img, cutoff=1)
|
||||
front_img = ImageEnhance.Color(front_img).enhance(1.2)
|
||||
front_img = front_img.filter(ImageFilter.SHARPEN)
|
||||
buf = io.BytesIO()
|
||||
front_img.save(buf, format='JPEG', quality=88)
|
||||
preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
|
||||
default_storage.save(preview_name, ContentFile(buf.getvalue()))
|
||||
plant_photo_preview_url = default_storage.url(preview_name)
|
||||
|
||||
merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
|
||||
|
||||
vpc_results = []
|
||||
if merged.get('scientific_name'):
|
||||
try:
|
||||
from plants.services.vpc import search_species, VPCError
|
||||
vpc_results = search_species(merged['scientific_name'])[:3]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render(request, 'plants/plant_scan_new.html', {
|
||||
'step': 'confirm',
|
||||
'scan_id': scan_id,
|
||||
'extracted': merged,
|
||||
'suggested_name': merged.get('scientific_name') or '',
|
||||
'plant_photo_preview_url': plant_photo_preview_url,
|
||||
'month_choices': MONTH_CHOICES,
|
||||
'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
|
||||
'vpc_results': vpc_results,
|
||||
})
|
||||
|
||||
|
||||
def _create_plant_from_scan(request):
|
||||
import io
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
scan_id = request.POST.get('scan_id', '')
|
||||
if not scan_id.isalnum():
|
||||
return redirect('add_plant_from_scan')
|
||||
|
||||
def _int_or_none(val):
|
||||
try:
|
||||
return int(val) if val not in (None, '') else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
bloom_raw = request.POST.getlist('bloom_months')
|
||||
bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
|
||||
name = request.POST.get('name', '').strip() or request.POST.get('scientific_name', '').strip() or 'New plant'
|
||||
|
||||
species_fields = {
|
||||
'scientific_name': request.POST.get('scientific_name', '').strip(),
|
||||
'common_name': request.POST.get('scientific_name', '').strip(),
|
||||
'bloom_months': bloom_months,
|
||||
'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
|
||||
'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
|
||||
'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
|
||||
'sunlight': request.POST.get('sunlight', ''),
|
||||
}
|
||||
species = Species.objects.create(**{k: v for k, v in species_fields.items() if v not in (None, '', [])})
|
||||
plant = Plant.objects.create(name=name, species=species)
|
||||
|
||||
vpc_slug = request.POST.get('vpc_slug', '').strip()
|
||||
if vpc_slug:
|
||||
try:
|
||||
from plants.services.vpc import scrape_plant, VPCError
|
||||
data = scrape_plant(vpc_slug)
|
||||
species.scientific_name = data.get('scientific_name') or species.scientific_name
|
||||
species.common_name = data.get('common_name') or species.common_name
|
||||
species.bloom_months = data.get('bloom_months') or species.bloom_months
|
||||
species.max_height_cm = data.get('max_height_cm') or species.max_height_cm
|
||||
species.frost_hardiness_c = data.get('frost_hardiness_c') or species.frost_hardiness_c
|
||||
species.planting_density_m2 = data.get('planting_density_m2') or species.planting_density_m2
|
||||
species.sunlight = data.get('sunlight') or species.sunlight
|
||||
species.api_image_url = data.get('api_image_url', '')
|
||||
species.vpc_slug = vpc_slug
|
||||
species.vpc_raw = data
|
||||
species.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
i = 0
|
||||
while True:
|
||||
temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
|
||||
if not default_storage.exists(temp_name):
|
||||
break
|
||||
with default_storage.open(temp_name, 'rb') as f:
|
||||
card_photo = PlantCardPhoto(plant=plant)
|
||||
card_photo.image.save(f'card_{plant.pk}_{i}.jpg', ContentFile(f.read()), save=True)
|
||||
default_storage.delete(temp_name)
|
||||
i += 1
|
||||
|
||||
preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
|
||||
if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
|
||||
with default_storage.open(preview_name, 'rb') as f:
|
||||
photo = PlantPhoto(plant=plant, is_thumbnail=True)
|
||||
photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
|
||||
|
||||
if default_storage.exists(preview_name):
|
||||
default_storage.delete(preview_name)
|
||||
|
||||
return redirect('plant_detail', pk=plant.pk)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.http import require_POST
|
||||
|
|
@ -20,35 +19,31 @@ def species_search(request):
|
|||
vpc_results = []
|
||||
error = None
|
||||
|
||||
include_perenual = bool(request.GET.get('include_perenual'))
|
||||
|
||||
if len(q) >= 2:
|
||||
# If the query looks like a VPC URL or slug, skip the search and show
|
||||
# it directly as a single importable result.
|
||||
# If the query looks like a VPC URL or slug, skip search and show it
|
||||
# directly as a single importable result.
|
||||
vpc_slug = vpc_service.slug_from_query(q)
|
||||
if vpc_slug:
|
||||
vpc_results = [{'slug': vpc_slug, 'common_name': vpc_slug.replace('-', ' ').title(),
|
||||
'scientific_name': '', 'thumbnail_url': '', 'source': 'vpc'}]
|
||||
else:
|
||||
def _run_perenual():
|
||||
try:
|
||||
return perenual_search(q), None
|
||||
except PerenualError as e:
|
||||
return [], str(e)
|
||||
|
||||
def _run_vpc():
|
||||
try:
|
||||
return vpc_service.search_species(q)
|
||||
vpc_results = vpc_service.search_species(q)
|
||||
except VPCError:
|
||||
return []
|
||||
pass
|
||||
|
||||
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()
|
||||
if include_perenual:
|
||||
try:
|
||||
perenual_results, error = perenual_search(q), None
|
||||
except PerenualError as e:
|
||||
error = str(e)
|
||||
|
||||
return render(request, 'plants/partials/species_results.html', {
|
||||
'perenual_results': perenual_results,
|
||||
'vpc_results': vpc_results,
|
||||
'include_perenual': include_perenual,
|
||||
'q': q,
|
||||
'error': error,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
asgiref==3.11.1
|
||||
opencv-python-headless>=4.9
|
||||
pytesseract>=0.3.13
|
||||
beautifulsoup4==4.14.3
|
||||
certifi==2026.5.20
|
||||
charset-normalizer==3.4.7
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue