- 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>
213 lines
9 KiB
HTML
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 %}
|