bloombase/plants/views/plants.py
Stephan Kerkman ba0ca6b169 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>
2026-05-29 21:54:59 +02:00

506 lines
19 KiB
Python

from datetime import date
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST, require_GET
from plants.models import Plant, Species, PruningLog, PlantPhoto, PlantCardPhoto
from plants.forms import PlantForm, PruningLogForm
from plants.utils.pruning import pruning_status
def plant_list(request):
qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all()
q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '')
if q:
qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q)
if indoor_filter == 'indoor':
qs = qs.filter(is_indoor=True)
elif indoor_filter == 'outdoor':
qs = qs.filter(is_indoor=False)
template = (
'plants/partials/plant_list_results.html'
if request.htmx
else 'plants/plant_list.html'
)
return render(request, template, {'plants': qs, 'q': q, 'filter': indoor_filter})
def plant_detail(request, pk):
plant = get_object_or_404(
Plant.objects.select_related('species', 'location').prefetch_related('pruning_logs', 'photos', 'card_photos'),
pk=pk,
)
today = date.today()
return render(request, 'plants/plant_detail.html', {
'plant': plant,
'pruning_status': pruning_status(plant, today),
'log_form': PruningLogForm(initial={'pruned_on': today}),
'today': today,
})
@require_POST
def log_pruning(request, pk):
plant = get_object_or_404(
Plant.objects.prefetch_related('pruning_logs'), pk=pk
)
form = PruningLogForm(request.POST)
if form.is_valid():
log = form.save(commit=False)
log.plant = plant
log.save()
today = date.today()
return render(request, 'plants/partials/pruning_strip.html', {
'plant': plant,
'pruning_status': pruning_status(plant, today),
'log_form': PruningLogForm(initial={'pruned_on': today}),
})
@ensure_csrf_cookie
def plant_add(request):
species_id = request.GET.get('species_id') or request.POST.get('species_id')
species = None
if species_id and species_id != 'skip':
species = get_object_or_404(Species, pk=species_id)
if species_id is None and request.method == 'GET':
return render(request, 'plants/species_search.html')
if request.method == 'POST':
form = PlantForm(request.POST, request.FILES)
if form.is_valid():
plant = form.save(commit=False)
plant.species = species
plant.save()
return redirect('plant_detail', pk=plant.pk)
else:
initial = {'bloom_months': species.bloom_months} if species else {}
form = PlantForm(initial=initial)
return render(request, 'plants/plant_form.html', {
'form': form,
'species': species,
'species_id': species_id,
'title': 'Add Plant',
})
def plant_edit(request, pk):
plant = get_object_or_404(Plant, pk=pk)
if request.method == 'POST':
form = PlantForm(request.POST, request.FILES, instance=plant)
if form.is_valid():
form.save()
return redirect('plant_detail', pk=plant.pk)
else:
form = PlantForm(instance=plant)
return render(request, 'plants/plant_form.html', {
'form': form,
'plant': plant,
'species': plant.species,
'title': 'Edit Plant',
})
def plant_delete(request, pk):
plant = get_object_or_404(Plant, pk=pk)
if request.method == 'POST':
plant.delete()
return redirect('plant_list')
return render(request, 'plants/plant_confirm_delete.html', {'plant': plant})
@require_GET
def plant_card(request, pk):
plant = get_object_or_404(
Plant.objects.prefetch_related('card_photos'), pk=pk
)
return render(request, 'plants/plant_card.html', {'plant': plant})
@require_POST
def upload_card_photo(request, pk):
plant = get_object_or_404(Plant, pk=pk)
if request.FILES.get('image') and plant.card_photos.count() < 3:
PlantCardPhoto.objects.create(plant=plant, image=request.FILES['image'])
plant = Plant.objects.prefetch_related('card_photos').get(pk=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)
plant_pk = card_photo.plant_id
card_photo.image.delete(save=False)
card_photo.delete()
plant = Plant.objects.prefetch_related('card_photos').get(pk=plant_pk)
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
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'):
PlantPhoto.objects.create(plant=plant, image=request.FILES['image'])
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
@require_POST
def set_thumbnail(request, photo_pk):
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
PlantPhoto.objects.filter(plant=photo.plant).update(is_thumbnail=False)
photo.is_thumbnail = True
photo.save(update_fields=['is_thumbnail'])
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
@require_POST
def delete_photo(request, photo_pk):
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
photo.image.delete(save=False)
photo.delete()
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)