bloombase/plants/templates/plants/plant_scan_new.html
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

213 lines
9 KiB
HTML

{% 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 %}