From d931827c84f311bca1b5c09d8b35facfcfcf3e2e Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sun, 23 Aug 2026 10:28:22 +0200 Subject: [PATCH] feat: implement traffic sign classifier with ONNX model support and updated OCR detection logic --- loko/assets/static/assets/inspection_map.js | 82 ++- loko/assets/views/sign.py | 2 + .../components/asset_fields/_signpanel.html | 536 ++++++++++++----- .../inspections/quick_inspection_map.html | 2 + loko/inspections/views.py | 44 +- loko/sign/ai/__init__.py | 11 +- loko/sign/ai/catalog.py | 335 ++++++++--- loko/sign/ai/classifier.py | 547 ++++++++++++++++++ loko/sign/ai/detector.py | 180 ++++-- loko/sign/ai/models/sign_classifier.onnx | Bin 0 -> 8139262 bytes loko/sign/ai/models/sign_classifier_meta.json | 517 +++++++++++++++++ .../sign/management/commands/setup_sign_ai.py | 44 +- .../commands/train_sign_classifier.py | 111 ++++ loko/sign/templates/sign/ai_demo.html | 28 +- loko/sign/tests_ai.py | 179 +++++- loko/sign/views_ai.py | 240 ++++---- 16 files changed, 2470 insertions(+), 388 deletions(-) create mode 100644 loko/sign/ai/classifier.py create mode 100644 loko/sign/ai/models/sign_classifier.onnx create mode 100644 loko/sign/ai/models/sign_classifier_meta.json create mode 100644 loko/sign/management/commands/train_sign_classifier.py diff --git a/loko/assets/static/assets/inspection_map.js b/loko/assets/static/assets/inspection_map.js index 02c4a3d..bbb3f11 100644 --- a/loko/assets/static/assets/inspection_map.js +++ b/loko/assets/static/assets/inspection_map.js @@ -1165,7 +1165,14 @@ } function handleAssetClick(feature) { - const props = feature.properties || {}; + const props = Object.assign({}, feature.properties || {}); + if (!props.id && !props.object_id && feature.id) { + props.id = feature.id; + props.object_id = feature.id; + } + if (!props.object_id && props.id) { + props.object_id = props.id; + } openInspectionModal(props); } @@ -1356,7 +1363,6 @@ modal.show(); } - window.openEditInspectionFromMap = function() { if (!window.currentMapInspectionId) return; const mapModalEl = document.getElementById('mapInspectionDetailModal'); @@ -1377,8 +1383,14 @@ if (!modalEl) return; let ctId = props.content_type_id || props.ct_id; - if (!ctId && (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking')) { - ctId = window.PARKING_SPOT_CT_ID; + if (!ctId) { + if (props.model_name === 'signpanel' || props.asset_type === 'signpanel') { + ctId = window.SIGN_PANEL_CT_ID; + } else if (props.model_name === 'signpole' || props.asset_type === 'signpole') { + ctId = window.SIGN_POLE_CT_ID; + } else if (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking') { + ctId = window.PARKING_SPOT_CT_ID; + } } const objId = props.object_id || props.id || props.pk || ''; document.getElementById('insp_content_type_id').value = ctId || ''; @@ -1414,16 +1426,43 @@ // Réinitialisation de l'accumulateur de photos/fichiers accumulatedInspectionFiles = []; + window.accumulatedInspectionFiles = accumulatedInspectionFiles; const previewContainer = document.getElementById('insp_photos_preview_container'); if (previewContainer) previewContainer.innerHTML = ''; + notifyPhotosUpdated(); const modal = new bootstrap.Modal(modalEl); modal.show(); }; let accumulatedInspectionFiles = []; + window.accumulatedInspectionFiles = accumulatedInspectionFiles; let inspCameraStream = null; + function notifyPhotosUpdated() { + try { + window.dispatchEvent(new CustomEvent('inspection-photos-updated', { + detail: { + files: accumulatedInspectionFiles, + photos: accumulatedInspectionFiles.filter(f => f && f.type && f.type.startsWith('image/')) + } + })); + } catch (e) { + console.warn('Error dispatching inspection-photos-updated event:', e); + } + } + + window.getInspectionPhotos = function() { + return (accumulatedInspectionFiles || []).filter(f => f && f.type && f.type.startsWith('image/')); + }; + + window.addInspectionPhoto = function(file) { + if (file && !accumulatedInspectionFiles.includes(file)) { + accumulatedInspectionFiles.push(file); + renderPhotoPreviews(); + } + }; + function initInspectionPhotoHandler() { const btnCamera = document.getElementById('insp_btn_continuous_camera'); const btnGallery = document.getElementById('insp_btn_gallery_files'); @@ -1571,10 +1610,10 @@ function renderPhotoPreviews() { const previewContainer = document.getElementById('insp_photos_preview_container'); - if (!previewContainer) return; - previewContainer.innerHTML = ''; + if (previewContainer) previewContainer.innerHTML = ''; accumulatedInspectionFiles.forEach((file, index) => { + if (!previewContainer) return; const col = document.createElement('div'); col.className = 'col-4 col-sm-3 position-relative mt-2'; @@ -1614,7 +1653,11 @@ col.appendChild(card); previewContainer.appendChild(col); }); + + window.renderInspPhotosPreview = renderPhotoPreviews; + notifyPhotosUpdated(); } + window.renderInspPhotosPreview = renderPhotoPreviews; } let newSignPhotos = []; @@ -1854,16 +1897,39 @@ body: formData }); - const data = await res.json(); + let data; + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + data = await res.json(); + } else { + const errorText = await res.text(); + throw new Error(`Erreur serveur (${res.status}) : ${errorText.substring(0, 120)}`); + } + if (data.success) { modal.hide(); alert(data.message || 'Panneaux créés avec succès !'); + + // Basculer automatiquement sur la thématique Signalisation + localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign'); + localStorage.removeItem(STORAGE_CATEGORY_KEY); + updateUIElements('sign', ''); + + if (data.lat && data.lon && map) { + const lat = parseFloat(data.lat); + const lon = parseFloat(data.lon); + if (!isNaN(lat) && !isNaN(lon)) { + map.flyTo({ center: [lon, lat], zoom: 19 }); + } + } + loadAssets(); + loadInspectionsHistory(); } else { alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.')); } } catch(err) { - alert('Erreur réseau : ' + err.message); + alert('Erreur lors de la création : ' + err.message); } finally { btnSubmit.disabled = false; btnSubmit.innerHTML = ' Créer et Enregistrer'; diff --git a/loko/assets/views/sign.py b/loko/assets/views/sign.py index 2488c95..af9edbe 100644 --- a/loko/assets/views/sign.py +++ b/loko/assets/views/sign.py @@ -526,6 +526,8 @@ def sign_assets_geojson(request): "id": obj.id, "geometry": geometry, "properties": { + "id": obj.id, + "object_id": obj.id, "code": obj.code, "name": obj.code, "category": obj.category.get_name() if obj.category else None, diff --git a/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html b/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html index d66f380..5d230e4 100644 --- a/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html +++ b/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html @@ -15,35 +15,84 @@
-
+
{% translate "Assistant IA Signalisation" %} +
- {% translate "Photographiez le panneau pour valider ou corriger automatiquement le type & texte" %} + {% translate "Photographiez ou utilisez une photo déjà prise pour détecter automatiquement le type & texte" %}
- +
+ + + +
- +
- {% translate "Analyse visuelle & OCR en cours..." %} + {% translate "Analyse visuelle & OCR en cours..." %}
+ + + @@ -228,160 +277,389 @@ }); } - // 4. Assistance IA (Bouton Détecter par IA) + // 4. Assistance IA (Détection intelligente par photo) const btnAIDetect = document.getElementById('btn_sign_ai_detect'); + const btnPickExisting = document.getElementById('btn_ai_pick_existing'); + const btnTakeNew = document.getElementById('btn_ai_take_new'); const aiFileInput = document.getElementById('sign_ai_file_input'); const aiResultContainer = document.getElementById('sign_ai_result_container'); const aiSpinner = document.getElementById('sign_ai_spinner'); + const aiSpinnerText = document.getElementById('sign_ai_spinnerText'); const aiBadgeContainer = document.getElementById('sign_ai_badge_container'); - if (btnAIDetect && aiFileInput) { - btnAIDetect.addEventListener('click', function() { - aiFileInput.click(); - }); + function getAvailablePhotos() { + if (typeof window.getInspectionPhotos === 'function') { + return window.getInspectionPhotos(); + } + return (window.accumulatedInspectionFiles || []).filter(f => f && f.type && f.type.startsWith('image/')); + } - aiFileInput.addEventListener('change', async function() { - if (!this.files || !this.files.length) return; - const file = this.files[0]; + function updateAiPhotosIndicator() { + const photos = getAvailablePhotos(); + const badge = document.getElementById('sign_ai_photos_badge'); + const dropCount = document.getElementById('sign_ai_dropdown_count'); + const label = document.getElementById('btn_sign_ai_detect_label'); - if (aiResultContainer) aiResultContainer.classList.remove('d-none'); - if (aiSpinner) aiSpinner.classList.remove('d-none'); - if (aiBadgeContainer) aiBadgeContainer.innerHTML = ''; + if (dropCount) dropCount.textContent = photos.length; - const formData = new FormData(); - formData.append('image', file); + if (photos.length === 0) { + if (badge) badge.classList.add('d-none'); + if (label) label.textContent = '{% translate "Détecter par IA" %}'; + } else if (photos.length === 1) { + if (badge) { + badge.innerHTML = '1 photo prête'; + badge.classList.remove('d-none'); + } + if (label) label.textContent = '{% translate "Analyser la photo" %}'; + } else { + if (badge) { + badge.innerHTML = `${photos.length} photos`; + badge.classList.remove('d-none'); + } + if (label) label.textContent = '{% translate "Détecter par IA" %}'; + } + } - try { - const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || ''; - const response = await fetch('/sign/api/detect/', { - method: 'POST', - headers: { - 'X-CSRFToken': csrfToken, - }, - body: formData - }); + window.addEventListener('inspection-photos-updated', updateAiPhotosIndicator); + // Mise à jour immédiate à l'affichage + setTimeout(updateAiPhotosIndicator, 50); - if (aiSpinner) aiSpinner.classList.add('d-none'); + async function runSignAiDetection(file) { + if (!file) return; - if (!response.ok) { - throw new Error('Erreur réseau (' + response.status + ')'); - } + if (aiResultContainer) aiResultContainer.classList.remove('d-none'); + if (aiSpinner) aiSpinner.classList.remove('d-none'); + if (aiSpinnerText) aiSpinnerText.textContent = '{% translate "Analyse visuelle & OCR en cours..." %}'; + if (aiBadgeContainer) aiBadgeContainer.innerHTML = ''; - const data = await response.json(); - if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) { - // Panneau principal détecté - const topPanel = data.panels[0]; - const detectedCode = topPanel.code; - const confidence = Math.round((topPanel.confidence || 0) * 100); - const detectedText = topPanel.ocr_text || topPanel.signpanel_text || topPanel.text || ''; - const panelLabel = topPanel.name_fr || topPanel.label_fr || topPanel.name_nl || ''; + const formData = new FormData(); + formData.append('image', file); - // Remplissage des inputs cachés - const inputAiCode = document.getElementById('insp_ai_detected_code'); - const inputAiConf = document.getElementById('insp_ai_confidence'); - if (inputAiCode) inputAiCode.value = detectedCode; - if (inputAiConf) inputAiConf.value = topPanel.confidence || 0; + try { + const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || ''; + const response = await fetch('/sign/api/detect/', { + method: 'POST', + headers: { + 'X-CSRFToken': csrfToken, + }, + body: formData + }); - // Comparaison avec le type actuel - const currentSelectedOpt = selectType?.options[selectType.selectedIndex]; - const currentCode = currentSelectedOpt?.getAttribute('data-code') || '{{ asset.signpanel_type.code|default:"" }}'; - const isMatch = (currentCode.toUpperCase() === detectedCode.toUpperCase()); + if (aiSpinner) aiSpinner.classList.add('d-none'); - let badgeHtml = ''; - if (isMatch) { - badgeHtml = ` -
-
- -
-
✓ Conforme : ${detectedCode} (${confidence}%)
-
${panelLabel}
-
+ if (!response.ok) { + throw new Error('Erreur réseau (' + response.status + ')'); + } + + const data = await response.json(); + if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) { + // Panneau principal détecté + const topPanel = data.panels[0]; + const detectedCode = topPanel.code; + const confidence = Math.round((topPanel.confidence || 0) * 100); + const detectedText = topPanel.ocr_text || topPanel.signpanel_text || topPanel.text || ''; + const panelLabel = topPanel.name_fr || topPanel.label_fr || topPanel.name_nl || ''; + + // Remplissage des inputs cachés + const inputAiCode = document.getElementById('insp_ai_detected_code'); + const inputAiConf = document.getElementById('insp_ai_confidence'); + if (inputAiCode) inputAiCode.value = detectedCode; + if (inputAiConf) inputAiConf.value = topPanel.confidence || 0; + + // Comparaison avec le type actuel + const selectTypeEl = document.getElementById('insp_verified_signpanel_type'); + const currentSelectedOpt = selectTypeEl?.options[selectTypeEl.selectedIndex]; + const currentCode = currentSelectedOpt?.getAttribute('data-code') || '{{ asset.signpanel_type.code|default:"" }}'; + const isMatch = (currentCode.trim().toUpperCase() === detectedCode.trim().toUpperCase()); + + let badgeHtml = ''; + if (isMatch) { + badgeHtml = ` +
+
+ +
+
✓ Conforme : ${detectedCode} (${confidence}%)
+
${panelLabel}
- Validé
- `; - } else { - badgeHtml = ` -
-
- -
-
Détecté : ${detectedCode} (${confidence}%)
-
${panelLabel}
-
+ Validé +
+ `; + } else { + badgeHtml = ` +
+
+ +
+
Détecté : ${detectedCode} (${confidence}%)
+
${panelLabel}
-
+
+
- `; - } +
+ `; + } - if (detectedText) { - badgeHtml += ` -
- Texte OCR : « ${detectedText} » + if (detectedText) { + badgeHtml += ` +
+ Texte OCR : « ${detectedText} » +
- `; - } - - aiBadgeContainer.innerHTML = badgeHtml; - - // Bouton Appliquer le type détecté - const btnApplyCode = document.getElementById('btn_apply_ai_code'); - if (btnApplyCode) { - btnApplyCode.addEventListener('click', function() { - const codeToApply = this.getAttribute('data-code'); - if (selectType) { - for (let i = 0; i < selectType.options.length; i++) { - if ((selectType.options[i].getAttribute('data-code') || '').toUpperCase() === codeToApply.toUpperCase()) { - selectType.selectedIndex = i; - selectType.dispatchEvent(new Event('change')); - break; - } - } - } - }); - } - - // Bouton Insérer le texte - const btnApplyText = document.getElementById('btn_apply_ai_text'); - if (btnApplyText) { - btnApplyText.addEventListener('click', function() { - const textToApply = this.getAttribute('data-text'); - if (inputText) { - inputText.value = textToApply; - } - }); - } - - // Ajoute aussi automatiquement la photo à la liste des photos de l'inspection ! - if (window.inspAccumulatedFiles) { - window.inspAccumulatedFiles.push(file); - if (typeof window.renderInspPhotosPreview === 'function') { - window.renderInspPhotosPreview(); - } - } - - } else { - aiBadgeContainer.innerHTML = ` -
- Aucun panneau reconnu avec certitude sur cette image.
`; } - } catch (err) { - if (aiSpinner) aiSpinner.classList.add('d-none'); + + aiBadgeContainer.innerHTML = badgeHtml; + + // Bouton Appliquer le type détecté + const btnApplyCode = document.getElementById('btn_apply_ai_code'); + if (btnApplyCode) { + btnApplyCode.addEventListener('click', function(e) { + if (e) e.preventDefault(); + const codeToApply = (this.getAttribute('data-code') || '').trim(); + const nameToApply = this.getAttribute('data-name') || ''; + const upperCode = codeToApply.toUpperCase(); + + const select = document.getElementById('insp_verified_signpanel_type'); + const previewImg = document.getElementById('sign_preview_img'); + const previewCode = document.getElementById('sign_preview_code'); + const previewName = document.getElementById('sign_preview_name'); + + let matched = false; + if (select) { + for (let i = 0; i < select.options.length; i++) { + const optCode = (select.options[i].getAttribute('data-code') || '').trim().toUpperCase(); + if (optCode === upperCode) { + select.selectedIndex = i; + matched = true; + break; + } + } + + if (!matched) { + const newOpt = document.createElement('option'); + newOpt.value = upperCode; + newOpt.setAttribute('data-code', upperCode); + newOpt.setAttribute('data-name', nameToApply || upperCode); + newOpt.textContent = `${upperCode} - ${nameToApply || upperCode}`; + select.appendChild(newOpt); + select.selectedIndex = select.options.length - 1; + matched = true; + } + + select.dispatchEvent(new Event('change', { bubbles: true })); + } + + // Mise à jour visuelle garantie + if (previewImg) { + previewImg.src = `/static/assets/road_signs/2025/${upperCode}.svg`; + previewImg.alt = upperCode; + } + if (previewCode) { + previewCode.textContent = `${upperCode} - ${nameToApply || ''}`; + } + if (previewName) { + previewName.textContent = nameToApply || ''; + } + + // Mise à jour input caché + const inputAiCode = document.getElementById('insp_ai_detected_code'); + if (inputAiCode) inputAiCode.value = upperCode; + + // Feedback visuel sur le bouton et l'alerte + const btnContainer = document.getElementById('ai_apply_btn_container'); + if (btnContainer) { + btnContainer.innerHTML = ` + + Appliqué + + `; + } + + const alertBox = document.getElementById('ai_detect_alert_box'); + if (alertBox) { + alertBox.className = 'alert alert-success d-flex align-items-center justify-content-between p-2 mb-1 rounded-3'; + } + + // Animation de surbrillance du sélecteur + const previewBox = document.getElementById('sign_selected_preview'); + if (previewBox) { + previewBox.classList.add('border-primary', 'bg-primary-subtle', 'bg-opacity-25'); + setTimeout(() => { + previewBox.classList.remove('border-primary', 'bg-primary-subtle', 'bg-opacity-25'); + }, 1000); + } + }); + } + + // Bouton Insérer le texte + const btnApplyText = document.getElementById('btn_apply_ai_text'); + if (btnApplyText) { + btnApplyText.addEventListener('click', function(e) { + if (e) e.preventDefault(); + const textToApply = this.getAttribute('data-text') || ''; + const inputTextEl = document.getElementById('insp_verified_signpanel_text'); + if (inputTextEl) { + inputTextEl.value = textToApply; + inputTextEl.focus(); + inputTextEl.dispatchEvent(new Event('input', { bubbles: true })); + inputTextEl.classList.add('border-success'); + setTimeout(() => inputTextEl.classList.remove('border-success'), 1000); + } + + const textBtnContainer = document.getElementById('ai_apply_text_btn_container'); + if (textBtnContainer) { + textBtnContainer.innerHTML = ` + Inséré + `; + } + }); + } + + // Ajout automatique de la photo à la liste globale de l'inspection si absente + if (typeof window.addInspectionPhoto === 'function') { + window.addInspectionPhoto(file); + } + + } else { aiBadgeContainer.innerHTML = ` -
- Erreur lors de l'analyse : ${err.message} +
+ Aucun panneau reconnu avec certitude sur cette image.
`; } + } catch (err) { + if (aiSpinner) aiSpinner.classList.add('d-none'); + aiBadgeContainer.innerHTML = ` +
+ Erreur lors de l'analyse : ${err.message} +
+ `; + } + } + + function openPhotoPickerModal(photos) { + let modalEl = document.getElementById('signAiPhotoPickerModal'); + if (!modalEl) return; + if (modalEl.parentElement !== document.body) { + document.body.appendChild(modalEl); + } + + const grid = document.getElementById('signAiPhotoPickerGrid'); + if (grid) { + grid.innerHTML = ''; + photos.forEach((photo, idx) => { + const col = document.createElement('div'); + col.className = 'col-6 col-sm-4'; + + const card = document.createElement('div'); + card.className = 'card h-100 border shadow-xs text-center p-2 position-relative'; + card.style.cursor = 'pointer'; + card.style.transition = 'transform 0.15s ease, border-color 0.15s ease'; + card.onmouseover = () => { card.style.borderColor = '#0d6efd'; card.style.transform = 'scale(1.02)'; }; + card.onmouseout = () => { card.style.borderColor = '#dee2e6'; card.style.transform = 'scale(1)'; }; + + const img = document.createElement('img'); + img.src = URL.createObjectURL(photo); + img.className = 'rounded object-fit-cover w-100 mb-2'; + img.style.height = '100px'; + + const title = document.createElement('div'); + title.className = 'fw-bold small text-truncate'; + title.textContent = `Photo ${idx + 1}`; + + const size = document.createElement('small'); + size.className = 'text-muted extra-small d-block'; + size.textContent = (photo.size / 1024).toFixed(0) + ' Ko'; + + const btnSelect = document.createElement('button'); + btnSelect.type = 'button'; + btnSelect.className = 'btn btn-sm btn-primary w-100 mt-2 py-1'; + btnSelect.innerHTML = 'Analyser'; + + card.appendChild(img); + card.appendChild(title); + card.appendChild(size); + card.appendChild(btnSelect); + + card.addEventListener('click', function() { + const pickerInstance = bootstrap.Modal.getInstance(modalEl); + if (pickerInstance) pickerInstance.hide(); + runSignAiDetection(photo); + }); + + col.appendChild(card); + grid.appendChild(col); + }); + } + + const pickerBtnNew = document.getElementById('btn_picker_take_new'); + if (pickerBtnNew) { + pickerBtnNew.onclick = function() { + const pickerInstance = bootstrap.Modal.getInstance(modalEl); + if (pickerInstance) pickerInstance.hide(); + aiFileInput.value = ''; + aiFileInput.click(); + }; + } + + const pickerModal = bootstrap.Modal.getOrCreateInstance(modalEl); + pickerModal.show(); + } + + if (btnAIDetect) { + btnAIDetect.addEventListener('click', function() { + const photos = getAvailablePhotos(); + if (photos.length === 0) { + aiFileInput.value = ''; + aiFileInput.click(); + } else if (photos.length === 1) { + runSignAiDetection(photos[0]); + } else { + openPhotoPickerModal(photos); + } + }); + } + + if (btnPickExisting) { + btnPickExisting.addEventListener('click', function(e) { + e.preventDefault(); + const photos = getAvailablePhotos(); + if (photos.length === 0) { + aiFileInput.value = ''; + aiFileInput.click(); + } else { + openPhotoPickerModal(photos); + } + }); + } + + if (btnTakeNew) { + btnTakeNew.addEventListener('click', function(e) { + e.preventDefault(); + aiFileInput.value = ''; + aiFileInput.click(); + }); + } + + if (aiFileInput) { + aiFileInput.addEventListener('change', function() { + if (!this.files || !this.files.length) return; + const file = this.files[0]; + if (typeof window.addInspectionPhoto === 'function') { + window.addInspectionPhoto(file); + } + runSignAiDetection(file); }); } })(); diff --git a/loko/inspections/templates/inspections/quick_inspection_map.html b/loko/inspections/templates/inspections/quick_inspection_map.html index ef1ce5e..fad06f3 100644 --- a/loko/inspections/templates/inspections/quick_inspection_map.html +++ b/loko/inspections/templates/inspections/quick_inspection_map.html @@ -469,6 +469,8 @@