feat: implement traffic sign classifier with ONNX model support and updated OCR detection logic

This commit is contained in:
kdeterme 2026-08-23 10:28:22 +02:00
parent 814a04f1ac
commit d931827c84
16 changed files with 2470 additions and 388 deletions

View file

@ -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 = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';

View file

@ -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,

View file

@ -15,35 +15,84 @@
<!-- 1. Assistance IA : Vérification instantanée par photo -->
<div class="card border border-primary-subtle bg-primary-subtle bg-opacity-10 mb-3 rounded-3">
<div class="card-body p-2.5">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<span class="fw-bold text-primary small d-flex align-items-center gap-1">
<i class="bi bi-stars text-warning"></i>{% translate "Assistant IA Signalisation" %}
<span id="sign_ai_photos_badge" class="badge bg-white text-primary border ms-1 d-none" style="font-size: 0.7rem; font-weight: 500;"></span>
</span>
<div class="text-muted" style="font-size: 0.75rem;">
{% 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" %}
</div>
</div>
<button type="button" id="btn_sign_ai_detect" class="btn btn-sm btn-primary d-flex align-items-center gap-1 px-2.5 py-1.5 shadow-sm">
<i class="bi bi-camera-fill"></i>
<span>{% translate "Détecter par IA" %}</span>
</button>
<div class="btn-group shadow-sm">
<button type="button" id="btn_sign_ai_detect" class="btn btn-sm btn-primary d-flex align-items-center gap-1 px-2.5 py-1.5">
<i class="bi bi-stars"></i>
<span id="btn_sign_ai_detect_label">{% translate "Détecter par IA" %}</span>
</button>
<button type="button" class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split px-2" data-bs-toggle="dropdown" aria-expanded="false" id="btn_sign_ai_dropdown" title="{% translate 'Options de photo' %}">
<span class="visually-hidden">Options</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm small py-1" style="font-size: 0.8rem;">
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 py-1.5" id="btn_ai_pick_existing">
<i class="bi bi-images text-primary"></i>
<span>{% translate "Choisir parmi les photos" %}</span>
<span class="badge bg-light text-dark border ms-auto" id="sign_ai_dropdown_count">0</span>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center gap-2 py-1.5" id="btn_ai_take_new">
<i class="bi bi-camera text-primary"></i>
<span>{% translate "Prendre une nouvelle photo" %}</span>
</button>
</li>
</ul>
</div>
</div>
<!-- Input caché pour la caméra IA -->
<!-- Input caché pour la caméra / upload IA -->
<input type="file" id="sign_ai_file_input" accept="image/*" capture="environment" class="d-none">
<!-- Résultat de détection IA -->
<div id="sign_ai_result_container" class="mt-2 d-none">
<div id="sign_ai_spinner" class="text-center py-2 d-none">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="small text-muted ms-2">{% translate "Analyse visuelle & OCR en cours..." %}</span>
<span class="small text-muted ms-2" id="sign_ai_spinnerText">{% translate "Analyse visuelle & OCR en cours..." %}</span>
</div>
<div id="sign_ai_badge_container"></div>
</div>
</div>
</div>
<!-- Modale de sélection de la photo pour l'analyse IA -->
<div class="modal fade" id="signAiPhotoPickerModal" tabindex="-1" aria-labelledby="signAiPhotoPickerModalLabel" aria-hidden="true" style="z-index: 1065;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow border-0">
<div class="modal-header bg-light py-2 px-3">
<h6 class="modal-title fw-bold" id="signAiPhotoPickerModalLabel">
<i class="bi bi-stars text-warning me-1"></i>{% translate "Choisir la photo à analyser avec l'IA" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-3">
<p class="small text-muted mb-2">
{% translate "Plusieurs photos sont disponibles. Sélectionnez celle qui cadre le mieux le panneau de signalisation :" %}
</p>
<div class="row g-2" id="signAiPhotoPickerGrid"></div>
</div>
<div class="modal-footer bg-light py-2 px-3 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-primary" id="btn_picker_take_new">
<i class="bi bi-camera me-1"></i>{% translate "Prendre une nouvelle photo" %}
</button>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">
{% translate "Fermer" %}
</button>
</div>
</div>
</div>
</div>
<input type="hidden" name="ai_detected_code" id="insp_ai_detected_code" value="">
<input type="hidden" name="ai_confidence" id="insp_ai_confidence" value="">
@ -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 = '<i class="bi bi-image text-primary me-1"></i>1 photo prête';
badge.classList.remove('d-none');
}
if (label) label.textContent = '{% translate "Analyser la photo" %}';
} else {
if (badge) {
badge.innerHTML = `<i class="bi bi-images text-primary me-1"></i>${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 = `
<div class="alert alert-success d-flex align-items-center justify-content-between p-2 mb-1 rounded-3">
<div class="d-flex align-items-center gap-2">
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
<div>
<div class="fw-bold small text-success">✓ Conforme : ${detectedCode} (${confidence}%)</div>
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
</div>
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 = `
<div class="alert alert-success d-flex align-items-center justify-content-between p-2 mb-1 rounded-3" id="ai_detect_alert_box">
<div class="d-flex align-items-center gap-2">
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 32px; height: 32px; object-fit: contain;" onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + (this.alt || 'default') + '.png';">
<div>
<div class="fw-bold small text-success">✓ Conforme : ${detectedCode} (${confidence}%)</div>
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
</div>
<span class="badge bg-success">Validé</span>
</div>
`;
} else {
badgeHtml = `
<div class="alert alert-warning d-flex align-items-center justify-content-between p-2 mb-1 rounded-3">
<div class="d-flex align-items-center gap-2">
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
<div>
<div class="fw-bold small text-dark">Détecté : <span class="text-primary">${detectedCode}</span> (${confidence}%)</div>
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
</div>
<span class="badge bg-success">Validé</span>
</div>
`;
} else {
badgeHtml = `
<div class="alert alert-warning d-flex align-items-center justify-content-between p-2 mb-1 rounded-3" id="ai_detect_alert_box">
<div class="d-flex align-items-center gap-2">
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 32px; height: 32px; object-fit: contain;" onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + (this.alt || 'default') + '.png';">
<div>
<div class="fw-bold small text-dark">Détecté : <span class="text-primary fw-bold">${detectedCode}</span> (${confidence}%)</div>
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
</div>
<button type="button" class="btn btn-sm btn-primary py-1 px-2" id="btn_apply_ai_code" data-code="${detectedCode}">
Appliquer
</div>
<div id="ai_apply_btn_container">
<button type="button" class="btn btn-sm btn-primary py-1 px-2.5 shadow-sm d-flex align-items-center gap-1" id="btn_apply_ai_code" data-code="${detectedCode}" data-name="${panelLabel}">
<i class="bi bi-check-lg"></i>
<span>Appliquer</span>
</button>
</div>
`;
}
</div>
`;
}
if (detectedText) {
badgeHtml += `
<div class="d-flex align-items-center justify-content-between p-2 bg-light rounded border mt-1">
<small class="text-muted"><i class="bi bi-fonts me-1"></i>Texte OCR : <strong>« ${detectedText} »</strong></small>
if (detectedText) {
badgeHtml += `
<div class="d-flex align-items-center justify-content-between p-2 bg-light rounded border mt-1" id="ai_apply_text_box">
<small class="text-muted"><i class="bi bi-fonts me-1 text-primary"></i>Texte OCR : <strong class="text-dark">« ${detectedText} »</strong></small>
<div id="ai_apply_text_btn_container">
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2" id="btn_apply_ai_text" data-text="${detectedText}">
Insérer
</button>
</div>
`;
}
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 = `
<div class="alert alert-secondary p-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>Aucun panneau reconnu avec certitude sur cette image.
</div>
`;
}
} 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 = `
<span class="badge bg-success text-white py-1.5 px-2.5 d-inline-flex align-items-center gap-1 shadow-sm">
<i class="bi bi-check2-circle fs-6"></i> <span>Appliqué</span>
</span>
`;
}
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 = `
<span class="badge bg-success py-1 px-2"><i class="bi bi-check2 me-1"></i>Inséré</span>
`;
}
});
}
// Ajout automatique de la photo à la liste globale de l'inspection si absente
if (typeof window.addInspectionPhoto === 'function') {
window.addInspectionPhoto(file);
}
} else {
aiBadgeContainer.innerHTML = `
<div class="alert alert-danger p-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>Erreur lors de l'analyse : ${err.message}
<div class="alert alert-secondary p-2 mb-0 small">
<i class="bi bi-info-circle me-1"></i>Aucun panneau reconnu avec certitude sur cette image.
</div>
`;
}
} catch (err) {
if (aiSpinner) aiSpinner.classList.add('d-none');
aiBadgeContainer.innerHTML = `
<div class="alert alert-danger p-2 mb-0 small">
<i class="bi bi-exclamation-triangle me-1"></i>Erreur lors de l'analyse : ${err.message}
</div>
`;
}
}
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 = '<i class="bi bi-stars me-1"></i>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);
});
}
})();

View file

@ -469,6 +469,8 @@
<script>
window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";
window.PARKING_SPOT_CT_ID = {{ parking_spot_ct_id|default:"null" }};
window.SIGN_PANEL_CT_ID = {{ sign_panel_ct_id|default:"null" }};
window.SIGN_POLE_CT_ID = {{ sign_pole_ct_id|default:"null" }};
window.PRESELECT_CONTENT_TYPE_ID = {{ preselect_ct_id|safe }};
window.PRESELECT_OBJECT_ID = {{ preselect_obj_id|safe }};
window.PRESELECT_LON = {{ preselect_lon|safe }};

View file

@ -207,8 +207,10 @@ def quick_inspection_map(request):
is_mobile = request.path.startswith('/mobile/')
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
from assets.models import ParkingSpot
from assets.models import ParkingSpot, SignPanel, SignPole
parking_spot_ct_id = ContentType.objects.get_for_model(ParkingSpot).id
sign_panel_ct_id = ContentType.objects.get_for_model(SignPanel).id
sign_pole_ct_id = ContentType.objects.get_for_model(SignPole).id
preselect_ct_id = request.GET.get('content_type_id')
preselect_obj_id = request.GET.get('object_id')
@ -244,6 +246,8 @@ def quick_inspection_map(request):
'thematics': thematics,
'categories': categories,
'parking_spot_ct_id': parking_spot_ct_id,
'sign_panel_ct_id': sign_panel_ct_id,
'sign_pole_ct_id': sign_pole_ct_id,
'preselect_ct_id': preselect_ct_id or "null",
'preselect_obj_id': preselect_obj_id or "null",
'preselect_mission_id': mission_id or "null",
@ -359,11 +363,41 @@ def create_inspection_api(request):
inspection.save()
elif is_signpanel:
from assets.models import SignPanelType
try:
from sign.ai.catalog import SIGN_CATALOG
except ImportError:
SIGN_CATALOG = {}
verified_type_id = request.POST.get('verified_signpanel_type_id') or None
verified_type = SignPanelType.objects.filter(pk=verified_type_id).first() if verified_type_id else None
ai_code = request.POST.get('ai_detected_code')
verified_type = None
if verified_type_id:
if str(verified_type_id).isdigit():
verified_type = SignPanelType.objects.filter(pk=int(verified_type_id)).first()
elif str(verified_type_id).startswith('code:'):
code = str(verified_type_id).split(':', 1)[1].strip()
verified_type = SignPanelType.objects.filter(code__iexact=code).first()
else:
verified_type = SignPanelType.objects.filter(code__iexact=str(verified_type_id).strip()).first()
if not verified_type and ai_code:
verified_type = SignPanelType.objects.filter(code__iexact=ai_code.strip()).first()
if not verified_type and (verified_type_id or ai_code):
target_code = str(verified_type_id).replace('code:', '').strip() if verified_type_id and not str(verified_type_id).isdigit() else (ai_code or '').strip()
if target_code:
cat_info = SIGN_CATALOG.get(target_code.upper(), {})
verified_type, _created = SignPanelType.objects.get_or_create(
code=target_code.upper(),
defaults={
'name_fr': cat_info.get('name_fr', f'Panneau {target_code.upper()}'),
'name_nl': cat_info.get('name_nl', f'Verkeersbord {target_code.upper()}'),
}
)
verified_text = request.POST.get('verified_signpanel_text')
verified_cleanliness = request.POST.get('verified_cleanliness', 'clean')
ai_code = request.POST.get('ai_detected_code')
ai_conf_raw = request.POST.get('ai_confidence')
ai_conf = float(ai_conf_raw) if ai_conf_raw else None
@ -760,6 +794,10 @@ def inspections_geojson(request):
from assets.models.nature import NatureRWIZ, NatureRWIAsset
ct_ids.add(ContentType.objects.get_for_model(NatureRWIZ).id)
ct_ids.add(ContentType.objects.get_for_model(NatureRWIAsset).id)
elif thematic_code == 'sign':
from assets.models import SignPanel, SignPole
ct_ids.add(ContentType.objects.get_for_model(SignPanel).id)
ct_ids.add(ContentType.objects.get_for_model(SignPole).id)
except Thematic.DoesNotExist:
pass

View file

@ -1,4 +1,13 @@
from .detector import SignDetectionService
from .catalog import SIGN_CATALOG, get_svg_url, match_sign_from_ocr, classify_sign_visual
from .classifier import SignClassifierEngine, SyntheticSignAugmentor
__all__ = ["SignDetectionService", "SIGN_CATALOG", "get_svg_url", "match_sign_from_ocr", "classify_sign_visual"]
__all__ = [
"SignDetectionService",
"SIGN_CATALOG",
"get_svg_url",
"match_sign_from_ocr",
"classify_sign_visual",
"SignClassifierEngine",
"SyntheticSignAugmentor",
]

View file

@ -206,6 +206,51 @@ SIGN_CATALOG = {
"shape": "rectangle",
"keywords": ["fin zone 30", "einde zone 30"],
},
"ZE9A": {
"name_fr": "Zone de stationnement réglementé",
"name_nl": "Zone voor gereglementeerd parkeren",
"category": "parking",
"shape": "rectangle",
"keywords": ["zone p", "zone stationnement", "parkeerzone"],
},
"ZE9A_DISK": {
"name_fr": "Zone bleue (Disque obligatoire)",
"name_nl": "Blauwe zone (Parkeerschijf verplicht)",
"category": "parking",
"shape": "rectangle",
"keywords": ["zone bleue", "blauwe zone", "disque"],
},
"ZC43": {
"name_fr": "Zone à vitesse limitée",
"name_nl": "Zone met snelheidsbeperking",
"category": "zone",
"shape": "rectangle",
"keywords": ["zone vitesse", "snelheidszone"],
},
"GVII_BETALEND": {
"name_fr": "Stationnement payant",
"name_nl": "Betalend parkeren",
"category": "parking",
"shape": "rectangle",
},
"GVIID_ELEKTRISCHE_WAGENS": {
"name_fr": "Stationnement réservé aux véhicules électriques en charge",
"name_nl": "Voorbehouden voor opladende elektrische voertuigen",
"category": "parking",
"shape": "rectangle",
},
"E9A_PARKEERSCHIJF": {
"name_fr": "Stationnement avec disque obligatoire",
"name_nl": "Parkeren met parkeerschijf",
"category": "parking",
"shape": "rectangle",
},
"F103": {
"name_fr": "Zone piétonne",
"name_nl": "Voetgangerszone",
"category": "zone",
"shape": "rectangle",
},
"F12A": {
"name_fr": "Début d'une zone résidentielle / zone de rencontre",
"name_nl": "Begin van een woonerf of erf",
@ -267,12 +312,31 @@ def get_catalog_entry(code: str) -> Optional[Dict[str, Any]]:
def get_svg_url(sign_code: str) -> str:
"""
Retourne l'URL du fichier SVG statique pour un code de panneau donné.
Garantit l'utilisation des majuscules car les fichiers sous assets/road_signs/2025/ sont nommés en majuscules (ex: F4A.svg).
Retourne l'URL du fichier SVG ou PNG statique pour un code de panneau donné.
Vérifie l'existence sous assets/road_signs/2025/ (.svg puis .png) et gère le fallback.
"""
if not sign_code:
return ""
code_upper = sign_code.strip().upper()
try:
from pathlib import Path
from django.conf import settings
base_dir = getattr(settings, "BASE_DIR", None)
if base_dir:
signs_dir = Path(base_dir) / "assets" / "static" / "assets" / "road_signs" / "2025"
if (signs_dir / f"{code_upper}.svg").exists():
return f"{DEFAULT_SVG_BASE_PATH}{code_upper}.svg"
if (signs_dir / f"{code_upper}.png").exists():
return f"{DEFAULT_SVG_BASE_PATH}{code_upper}.png"
# Fallback code racine (ex: C43_50KM -> C43_50.svg -> C43.svg)
if "_" in code_upper:
root_code = code_upper.split("_")[0]
if (signs_dir / f"{root_code}.svg").exists():
return f"{DEFAULT_SVG_BASE_PATH}{root_code}.svg"
except Exception:
pass
return f"{DEFAULT_SVG_BASE_PATH}{code_upper}.svg"
@ -281,18 +345,20 @@ def match_sign_from_ocr(ocr_text: str) -> Optional[Dict[str, Any]]:
Analyse un texte OCR et tente d'associer un type de panneau normalisé.
Exemples:
- "STOP" -> B5
- "ZONE 30" -> F4A (avec valeur 30)
- "50 km" ou "50" -> C43 (Limitation de vitesse 50 km/h)
- "ZONE P" ou "ZONE ... EXCEPTE CARTE" -> ZE9A (Zone de stationnement)
- "ZONE 30" -> F4A (Zone 30)
- "SAUF RIVERAINS" -> M2
- "300 m" -> M1 (avec valeur 300)
- "50" (seul dans un cercle) -> C43 (avec valeur 50)
- "300 M" -> M1 (Distance)
"""
if not ocr_text:
return None
cleaned = ocr_text.strip().upper()
cleaned_inline = re.sub(r"\s+", " ", cleaned)
# 1. STOP
if "STOP" in cleaned:
if "STOP" in cleaned_inline:
return {
"code": "B5",
"confidence": 0.96,
@ -301,96 +367,197 @@ def match_sign_from_ocr(ocr_text: str) -> Optional[Dict[str, Any]]:
"matched_by": "text_stop",
}
# 2. Zone 30 / Zone 20 / Fin de zone
if "ZONE" in cleaned:
if "FIN" in cleaned or "EINDE" in cleaned:
# 2. TOUS LES PANNEAUX DE ZONE ("ZONE ...")
if "ZONE" in cleaned_inline:
if "FIN" in cleaned_inline or "EINDE" in cleaned_inline:
return {
"code": "F4B",
"confidence": 0.92,
"data": SIGN_CATALOG["F4B"],
"data": SIGN_CATALOG.get("F4B", {"name_fr": "Fin de zone", "name_nl": "Einde zone", "category": "indication"}),
"svg_url": get_svg_url("F4B"),
"matched_by": "text_end_zone",
}
# Détection vitesse de zone
speed_match = re.search(r"\b(20|30|50)\b", cleaned)
# Zone Stationnement / Parking ("ZONE P", "ZONE ... CARTE DE STATIONNEMENT", "ZONE ... PARKEERKAART", "ZONE ... DISQUE")
if re.search(r"\b(ZONE\s+P\b|PARKING|PARKEREN|STATIONNEMENT|PARKEER|DISQUE|PARKEERSCHIJF|CARTE|KAART)\b", cleaned_inline):
return {
"code": "ZE9A",
"confidence": 0.95,
"data": SIGN_CATALOG.get("ZE9A", {
"name_fr": "Zone de stationnement réglementé",
"name_nl": "Zone voor gereglementeerd parkeren",
"category": "parking",
}),
"svg_url": get_svg_url("ZE9A"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_zone_parking",
}
# Zone de vitesse ("ZONE 30", "ZONE 20", "ZONE 50")
speed_match = re.search(r"\b(20|30|50|70)\b", cleaned_inline)
if speed_match:
speed = int(speed_match.group(1))
code = "F4A"
return {
"code": code,
"confidence": 0.94,
"data": SIGN_CATALOG["F4A"],
"confidence": 0.95,
"data": SIGN_CATALOG.get("F4A", {"name_fr": f"Zone {speed}", "name_nl": f"Zone {speed}", "category": "indication"}),
"svg_url": get_svg_url(code),
"value": speed,
"matched_by": "text_zone_speed",
}
# Zone piétonne
if re.search(r"\b(PIETON|VOETGANGER|PIETONS|VOETGANGERS)\b", cleaned_inline):
return {
"code": "F103",
"confidence": 0.94,
"data": SIGN_CATALOG.get("F103", {
"name_fr": "Zone piétonne",
"name_nl": "Voetgangerszone",
"category": "indication",
}),
"svg_url": get_svg_url("F103"),
"matched_by": "text_zone_pedestrian",
}
# Zone générique
return {
"code": "F4A",
"confidence": 0.88,
"data": SIGN_CATALOG.get("F4A", {"name_fr": "Zone réglementée", "name_nl": "Gereglementeerde zone", "category": "indication"}),
"svg_url": get_svg_url("F4A"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_zone",
}
# 3. Panonceaux d'exception ("Sauf ...", "Excepté ...", "Uitgezonderd ...")
if re.search(r"\b(SAUF|EXCEPTE|EXCEPTE|UITGEZONDERD|RIVERAINS?|AANGELANDEN?|VELOS?|FIETS)\b", cleaned, re.IGNORECASE):
# 3. VITESSE MAXIMALE AUTORISÉE (C43 : "50", "50 km", "50 km/h", "30 km", "70 km/h", "90", "120")
# Note : "50 km" ou "50 km/h" sur un panneau de limitation est une vitesse C43 et NON une distance M1 !
speed_match = re.search(r"\b(10|20|30|40|50|60|70|80|90|100|110|120|130)\s*(?:KM(?:/H|/U)?|KPH)?\b", cleaned_inline)
if speed_match:
# Exclure si le texte est explicitement une distance comme "50 m", "300 m", "1.5 km" (avec décimale ou mètres)
is_explicit_distance = bool(re.search(r"\b(\d+\s*M|\d+[,.]\d+\s*KM)\b", cleaned_inline))
if not is_explicit_distance:
speed = int(speed_match.group(1))
code_speed = f"C43_{speed}"
return {
"code": "C43",
"specific_code": code_speed,
"confidence": 0.95,
"data": SIGN_CATALOG.get("C43", {
"name_fr": f"Vitesse maximale autorisée ({speed} km/h)",
"name_nl": f"Maximumsnelheid ({speed} km/u)",
"category": "prohibition",
}),
"svg_url": get_svg_url(code_speed) or get_svg_url("C43"),
"value": speed,
"matched_by": "text_speed_limit",
}
# 4. Panonceaux d'exception ("Sauf ...", "Excepté ...", "Uitgezonderd ...")
if re.search(r"\b(SAUF|EXCEPTE|EXCEPTE|UITGEZONDERD|RIVERAINS?|AANGELANDEN?|VELOS?|FIETS)\b", cleaned_inline, re.IGNORECASE):
return {
"code": "M2",
"confidence": 0.90,
"data": SIGN_CATALOG["M2"],
"data": SIGN_CATALOG.get("M2", {"name_fr": "Panonceau d'application ou d'exception", "name_nl": "Onderbord: uitzondering", "category": "panonceau"}),
"svg_url": get_svg_url("M2"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_exception",
}
# 4. Panonceaux de distance ("300 m", "50m", "1.5 km")
dist_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*(M|KM|METRES?|METERS?)\b", cleaned)
# 5. Panonceaux de distance ("300 m", "50 m", "1.5 km", "2.0 km")
dist_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*(M|METRES?|METERS?)\b|\b(\d+[.,]\d+)\s*(KM)\b", cleaned_inline)
if dist_match:
val_str = dist_match.group(1).replace(",", ".")
unit = dist_match.group(2).lower()
val_str = (dist_match.group(1) or dist_match.group(3)).replace(",", ".")
unit = (dist_match.group(2) or dist_match.group(4) or "m").lower()
val = float(val_str)
if unit == "km":
val *= 1000.0
return {
"code": "M1",
"confidence": 0.88,
"data": SIGN_CATALOG["M1"],
"data": SIGN_CATALOG.get("M1", {"name_fr": "Panonceau de distance", "name_nl": "Onderbord: afstand", "category": "panonceau"}),
"svg_url": get_svg_url("M1"),
"value": val,
"extracted_text": ocr_text.strip(),
"matched_by": "text_distance",
}
# 5. Limitation de vitesse pure ("30", "50", "70", "90", "110", "120")
speed_alone_match = re.search(r"^\D*(\b(?:20|30|50|70|90|110|120)\b)\D*$", cleaned)
if speed_alone_match:
speed = int(speed_alone_match.group(1))
return {
"code": "C43",
"confidence": 0.90,
"data": SIGN_CATALOG["C43"],
"svg_url": get_svg_url("C43"),
"value": speed,
"matched_by": "text_speed_limit",
}
# 6. Tonnage ("3.5 t", "7.5t")
ton_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*T\b", cleaned)
ton_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*T\b", cleaned_inline)
if ton_match:
val = float(ton_match.group(1).replace(",", "."))
return {
"code": "C21",
"confidence": 0.88,
"data": SIGN_CATALOG["C21"],
"data": SIGN_CATALOG.get("C21", {"name_fr": "Accès interdit aux véhicules dont la masse en charge dépasse le tonnage indiqué", "name_nl": "Verboden toegang voor voertuigen met een hogere massa dan aangeduid", "category": "prohibition"}),
"svg_url": get_svg_url("C21"),
"value": val,
"extracted_text": ocr_text.strip(),
"matched_by": "text_tonnage",
}
# 7. Parking PMR / Handicap
if re.search(r"\b(HANDICAP|PMR|HANDICAPE)\b", cleaned):
# 7. Parking P ("P", "PARKING", "PARKEREN" ou lettre "D" isolée due à l'OCR sur le P)
if re.search(r"^\s*([PD])\s*$", cleaned_inline) or re.search(r"\b(PARKING|PARKEREN)\b", cleaned_inline):
return {
"code": "E9A",
"confidence": 0.96,
"data": SIGN_CATALOG.get("E9A", {"name_fr": "Stationnement autorisé (Parking)", "name_nl": "Parkeren toegelaten (Parking)", "category": "parking"}),
"svg_url": get_svg_url("E9A"),
"matched_by": "text_parking",
}
# 8. Parking PMR / Handicap
if re.search(r"\b(HANDICAP|PMR|HANDICAPE|GEHANDICAPT)\b", cleaned_inline):
return {
"code": "E9B",
"confidence": 0.91,
"data": SIGN_CATALOG["E9B"],
"confidence": 0.94,
"data": SIGN_CATALOG.get("E9B", {"name_fr": "Stationnement réservé aux personnes handicapées", "name_nl": "Parkeren voorbehouden voor personen met een handicap", "category": "parking"}),
"svg_url": get_svg_url("E9B"),
"matched_by": "text_handicap",
}
# 9. Stationnement Payant / Betalend
if re.search(r"\b(PAYANT|BETALEND|HORODATEUR|TICKET)\b", cleaned_inline):
return {
"code": "GVII_BETALEND",
"confidence": 0.95,
"data": SIGN_CATALOG.get("GVII_BETALEND", {
"name_fr": "Stationnement payant",
"name_nl": "Betalend parkeren",
"category": "parking",
}),
"svg_url": get_svg_url("GVII_BETALEND"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_parking_payant",
}
# 10. Véhicules électriques en charge
if re.search(r"\b(OPLADEND|OPLADEN|ELEKTRISCH|ELECTRIQUE|RECHARGE|CHARGE)\b", cleaned_inline) and re.search(r"\b(VEHICULE|VOERTUIG|WAGEN|AUTO)\b", cleaned_inline):
return {
"code": "GVIID_ELEKTRISCHE_WAGENS",
"confidence": 0.94,
"data": SIGN_CATALOG.get("GVIID_ELEKTRISCHE_WAGENS", {
"name_fr": "Stationnement réservé aux véhicules électriques en charge",
"name_nl": "Voorbehouden voor opladende elektrische voertuigen",
"category": "parking",
}),
"svg_url": get_svg_url("GVIID_ELEKTRISCHE_WAGENS"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_electric_vehicle",
}
# 11. Disque de stationnement / Zone bleue
if re.search(r"\b(DISQUE|PARKEERSCHIJF)\b", cleaned_inline):
return {
"code": "E9A_PARKEERSCHIJF",
"confidence": 0.93,
"data": SIGN_CATALOG.get("E9A_PARKEERSCHIJF", {
"name_fr": "Stationnement avec disque obligatoire",
"name_nl": "Parkeren met parkeerschijf",
"category": "parking",
}),
"svg_url": get_svg_url("E9A_PARKEERSCHIJF"),
"extracted_text": ocr_text.strip(),
"matched_by": "text_parking_disc",
}
return None
@ -398,12 +565,13 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
"""
Classifie un panneau par analyse de forme, couleur dominante (Bleu, Rouge, Jaune) et structure.
Permet de discriminer les panneaux sans texte :
- Rectangle Bleu vertical / lettre P -> E9A (Parking)
- Rond Bleu -> D7 (Piste cyclable / vélo) ou D5 (Rond-point)
- Triangle inversé -> B1 (Cédez le passage)
- Triangle pointe en haut -> A15 (Passage piétons / Danger)
- Octogone rouge -> B5 (STOP)
- Losange jaune -> B3 (Route prioritaire)
- Cercle bord rouge -> C3 (Accès interdit)
- Cercle bord rouge -> C3 (Accès interdit) / C1 (Sens interdit)
"""
import cv2
import numpy as np
@ -446,30 +614,51 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
yellow_ratio = np.count_nonzero(yellow_mask) / total_px
aspect_ratio = w / float(h)
# 1. PANNEAUX BLEUS (Famille D Obligation ou Famille F Indication)
if blue_ratio > 0.12:
if 0.75 <= aspect_ratio <= 1.35:
# Extraction des éléments blancs centraux (pictogramme)
gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
center_crop = gray[int(h*0.18):int(h*0.82), int(w*0.18):int(w*0.82)]
white_mask = center_crop > 165
white_ratio = np.count_nonzero(white_mask) / float(center_crop.size)
# 1. PANNEAUX BLEUS (Famille E9 Stationnement, D Obligation ou F Indication)
if blue_ratio > 0.10:
# Détection de texte ou lettre P / D dans l'OCR
ocr_clean = ocr_text.strip().upper()
if ocr_clean in ("P", "D", "🅿") or "PARKING" in ocr_clean or "PARKEREN" in ocr_clean:
code = "E9A"
entry = SIGN_CATALOG.get("E9A", {})
return {
"code": code,
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
"category": "parking",
"svg_url": get_svg_url(code),
"matched_by": "visual_blue_parking_letter",
"confidence": 0.92,
}
if "P" in ocr_text.upper():
code = "E9A"
entry = SIGN_CATALOG.get("E9A", {})
return {
"code": code,
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
"category": "parking",
"svg_url": get_svg_url(code),
"matched_by": "visual_blue_parking",
"confidence": 0.88,
}
# Panneau rectangulaire vertical (ratio largeur/hauteur < 0.88)
if aspect_ratio < 0.88:
code = "E9A"
entry = SIGN_CATALOG.get("E9A", {})
return {
"code": code,
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
"category": "parking",
"svg_url": get_svg_url(code),
"matched_by": "visual_blue_vertical_parking",
"confidence": 0.88,
}
# Si rond bleu : D7 (Piste cyclable / vélo) vs D5 (Rond-point) vs D1A
if "VELO" in ocr_text.upper() or "FIETS" in ocr_text.upper() or white_ratio > 0.03:
if 0.88 <= aspect_ratio <= 1.35:
# Vérifier si le panneau est rond (les 4 coins du crop ne sont pas bleus) ou carré (coins bleus)
corner_h = max(2, int(h * 0.15))
corner_w = max(2, int(w * 0.15))
corner_tl = np.count_nonzero(blue_mask[:corner_h, :corner_w])
corner_tr = np.count_nonzero(blue_mask[:corner_h, -corner_w:])
corner_bl = np.count_nonzero(blue_mask[-corner_h:, :corner_w])
corner_br = np.count_nonzero(blue_mask[-corner_h:, -corner_w:])
corner_blue_ratio = (corner_tl + corner_tr + corner_bl + corner_br) / float(4 * corner_h * corner_w)
# Si les coins ne sont pas bleus (corner_blue_ratio < 0.25) -> C'est un ROND BLEU (Famille D Obligation)
is_circle = corner_blue_ratio < 0.25
if is_circle:
code = "D7"
entry = SIGN_CATALOG.get("D7", {})
return {
@ -482,18 +671,20 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
"confidence": 0.90,
}
else:
code = "D5"
entry = SIGN_CATALOG.get("D5", {})
# Carré bleu avec coins pleins -> E9A Parking
code = "E9A"
entry = SIGN_CATALOG.get("E9A", {})
return {
"code": code,
"name_fr": entry.get("name_fr", "Sens giratoire obligatoire (Rond-point)"),
"name_nl": entry.get("name_nl", "Verplicht rond punt"),
"category": "obligation",
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
"category": "parking",
"svg_url": get_svg_url(code),
"matched_by": "visual_blue_roundabout",
"confidence": 0.86,
"matched_by": "visual_blue_square_parking",
"confidence": 0.88,
}
else:
# Rectangle horizontal bleu
code = "F19"
entry = SIGN_CATALOG.get("F19", {})
return {
@ -503,7 +694,7 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
"category": "indication",
"svg_url": get_svg_url(code),
"matched_by": "visual_blue_rectangle",
"confidence": 0.82,
"confidence": 0.80,
}
# 2. PANNEAUX ROUGE & BLANC

547
loko/sign/ai/classifier.py Normal file
View file

@ -0,0 +1,547 @@
"""
Module de classification et d'auto-entraînement sur la signalisation routière (StreetUp / Loko).
Génère des variations synthétiques réalistes à partir des SVGs/PNGs officiels,
entraîne un modèle neuronal léger (MobileNetV3) et exporte en ONNX pour une inférence ultra-rapide (< 3ms).
"""
import io
import json
import logging
import math
import os
import random
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
import cv2
import numpy as np
from PIL import Image
logger = logging.getLogger(__name__)
MODELS_DIR = Path(__file__).resolve().parent / "models"
DEFAULT_SIGNS_DIR = Path(__file__).resolve().parents[2] / "assets" / "static" / "assets" / "road_signs" / "2025"
CLASSIFIER_ONNX_PATH = MODELS_DIR / "sign_classifier.onnx"
INDEX_METADATA_PATH = MODELS_DIR / "sign_classifier_meta.json"
class SyntheticSignAugmentor:
"""
Générateur de variations réalistes à partir d'un pictogramme vectoriel ou d'un template PNG :
- Préservation du ratio d'aspect (letterboxing).
- Déformation perspective 3D (angles de prise de vue sur le terrain / caméra embarquée).
- Variations d'éclairage (ombres franches, sur-exposition, sous-exposition, soleil rasant).
- Teinte & décoloration (peinture vieillie, balance des blancs, météo).
- Flou de mouvement, flou optique et compression.
- Incrustation sur fonds naturels synthétiques (ciel, végétation, asphalte, urbain).
"""
@staticmethod
def letterbox_rgba(img_rgba: np.ndarray, target_size: int = 224) -> np.ndarray:
"""Redimensionne une image RGBA en préservant son ratio d'aspect avec padding transparent."""
h, w = img_rgba.shape[:2]
if h == 0 or w == 0:
return np.zeros((target_size, target_size, 4), dtype=np.uint8)
scale = min(target_size / h, target_size / w)
new_w, new_h = max(1, int(round(w * scale))), max(1, int(round(h * scale)))
resized = cv2.resize(img_rgba, (new_w, new_h), interpolation=cv2.INTER_AREA)
canvas = np.zeros((target_size, target_size, 4), dtype=np.uint8)
x_offset = (target_size - new_w) // 2
y_offset = (target_size - new_h) // 2
canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized
return canvas
@classmethod
def render_svg_to_numpy(cls, svg_path: Path, size: int = 224) -> Optional[np.ndarray]:
"""Convertit un fichier SVG en tableau NumPy RGBA (size, size) avec canal alpha."""
try:
import cairosvg
png_bytes = cairosvg.svg2png(url=str(svg_path), output_width=size, output_height=size)
nparr = np.frombuffer(png_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED)
if img is not None:
if len(img.shape) == 3 and img.shape[2] == 4:
return cls.letterbox_rgba(img, size)
elif len(img.shape) == 3 and img.shape[2] == 3:
alpha = np.ones((img.shape[0], img.shape[1], 1), dtype=np.uint8) * 255
img = np.concatenate([img, alpha], axis=-1)
return cls.letterbox_rgba(img, size)
except Exception as e:
logger.debug("CairoSVG rendering failed for %s: %s", svg_path.name, e)
# Fallback de secours via PIL si CairoSVG rencontre une anomalie
try:
from PIL import Image
pil_img = Image.open(str(svg_path)).convert("RGBA")
nparr = np.array(pil_img)
# PIL RGBA -> OpenCV BGRA
bgra = cv2.cvtColor(nparr, cv2.COLOR_RGBA2BGRA)
return cls.letterbox_rgba(bgra, size)
except Exception:
pass
return None
@classmethod
def load_png_to_numpy(cls, png_path: Path, size: int = 224) -> Optional[np.ndarray]:
"""Charge un template PNG et retourne un tableau NumPy RGBA (size, size)."""
try:
img = cv2.imread(str(png_path), cv2.IMREAD_UNCHANGED)
if img is None:
return None
if len(img.shape) == 2:
# Niveaux de gris
bgr = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
alpha = np.ones((img.shape[0], img.shape[1], 1), dtype=np.uint8) * 255
img = np.concatenate([bgr, alpha], axis=-1)
elif len(img.shape) == 3 and img.shape[2] == 3:
alpha = np.ones((img.shape[0], img.shape[1], 1), dtype=np.uint8) * 255
img = np.concatenate([img, alpha], axis=-1)
return cls.letterbox_rgba(img, size)
except Exception as e:
logger.debug("Failed to load PNG %s: %s", png_path.name, e)
return None
@classmethod
def generate_random_background(cls, size: int = 224) -> np.ndarray:
"""Génère un fond d'environnement synthétique (ciel, végétation, bitume, rue, jour couvert)."""
bg_type = random.choice(["sky", "greenery", "urban", "asphalt", "neutral", "cloudy"])
bg = np.zeros((size, size, 3), dtype=np.uint8)
if bg_type == "sky":
# Dégradé ciel bleu
top_color = np.array([random.randint(180, 240), random.randint(140, 190), random.randint(70, 120)])
bot_color = np.array([random.randint(220, 255), random.randint(210, 240), random.randint(170, 210)])
for y in range(size):
t = y / float(size)
bg[y, :] = (1 - t) * top_color + t * bot_color
elif bg_type == "greenery":
# Fond végétal (nuances de vert / arbres)
base_green = random.randint(30, 80)
noise = np.random.randint(-25, 25, (size, size, 3), dtype=np.int16)
bg[:] = [base_green // 2, base_green, base_green // 3]
bg = np.clip(bg.astype(np.int16) + noise, 0, 255).astype(np.uint8)
bg = cv2.GaussianBlur(bg, (15, 15), 0)
elif bg_type in ("asphalt", "urban"):
# Fond gris bitume / façade de bâtiment
base_gray = random.randint(80, 160)
noise = np.random.randint(-30, 30, (size, size, 3), dtype=np.int16)
bg[:] = [base_gray, base_gray, base_gray]
bg = np.clip(bg.astype(np.int16) + noise, 0, 255).astype(np.uint8)
bg = cv2.GaussianBlur(bg, (9, 9), 0)
elif bg_type == "cloudy":
# Ciel gris nuageux
base_gray = random.randint(160, 210)
noise = np.random.randint(-15, 15, (size, size, 3), dtype=np.int16)
bg[:] = [base_gray, base_gray, base_gray]
bg = np.clip(bg.astype(np.int16) + noise, 0, 255).astype(np.uint8)
bg = cv2.GaussianBlur(bg, (21, 21), 0)
else:
# Fond neutre gris clair
g = random.randint(210, 245)
bg[:] = [g, g, g]
return bg
@classmethod
def augment_sign(cls, rgba_sign: np.ndarray, size: int = 224) -> np.ndarray:
"""
Applique une suite de déformations physiques et colorimétriques réalistes
sur le panneau RGBA et l'incruste sur un fond synthétique.
Retourne une image BGR 3 canaux de taille (size, size).
"""
h, w = rgba_sign.shape[:2]
bgr = rgba_sign[:, :, :3].copy()
alpha = rgba_sign[:, :, 3].copy()
# 1. Déformation Perspective 3D (Angle de vue caméra smartphone / véhicule)
scale = random.uniform(0.72, 0.96)
# Points sources
src_pts = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
# Décalages de perspective aléatoires
max_shift = 0.12
dx1 = random.uniform(-w * max_shift, w * max_shift)
dy1 = random.uniform(-h * max_shift, h * max_shift)
dx2 = random.uniform(-w * max_shift, w * max_shift)
dy2 = random.uniform(-h * max_shift, h * max_shift)
margin_w = w * (1.0 - scale) / 2.0
margin_h = h * (1.0 - scale) / 2.0
dst_pts = np.float32([
[margin_w + max(0, dx1), margin_h + max(0, dy1)],
[w - margin_w - max(0, -dx2), margin_h + max(0, dy2)],
[w - margin_w - max(0, dx2), h - margin_h - max(0, -dy2)],
[margin_w + max(0, -dx1), h - margin_h - max(0, dy1)]
])
M_persp = cv2.getPerspectiveTransform(src_pts, dst_pts)
warped_bgr = cv2.warpPerspective(bgr, M_persp, (size, size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
warped_alpha = cv2.warpPerspective(alpha, M_persp, (size, size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# 2. Rotation légère (-10° à +10°)
rot_angle = random.uniform(-10, 10)
M_rot = cv2.getRotationMatrix2D((size / 2.0, size / 2.0), rot_angle, 1.0)
warped_bgr = cv2.warpAffine(warped_bgr, M_rot, (size, size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
warped_alpha = cv2.warpAffine(warped_alpha, M_rot, (size, size), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# 3. Éclairage & Ombrage réaliste (Gradient de soleil rasant ou ombre)
alpha_norm = (warped_alpha.astype(np.float32) / 255.0)[:, :, np.newaxis]
bgr_float = warped_bgr.astype(np.float32)
grad_angle = random.uniform(0, 2 * math.pi)
gx, gy = math.cos(grad_angle), math.sin(grad_angle)
y_coords, x_coords = np.mgrid[0:size, 0:size]
light_grad = 1.0 + random.uniform(-0.35, 0.35) * (gx * (x_coords / float(size) - 0.5) + gy * (y_coords / float(size) - 0.5))
light_grad = np.clip(light_grad, 0.55, 1.45)[:, :, np.newaxis]
bgr_float = bgr_float * light_grad
# Luminosité & Contraste globaux
brightness = random.uniform(0.75, 1.25)
contrast = random.uniform(0.80, 1.25)
bgr_float = np.clip((bgr_float - 128.0) * contrast + 128.0 * brightness, 0, 255)
# Teinte / vieillissement / saturation
b_shift = random.uniform(0.92, 1.08)
g_shift = random.uniform(0.92, 1.08)
r_shift = random.uniform(0.92, 1.08)
bgr_float[:, :, 0] *= b_shift
bgr_float[:, :, 1] *= g_shift
bgr_float[:, :, 2] *= r_shift
bgr_float = np.clip(bgr_float, 0, 255).astype(np.uint8)
# 4. Composition sur fond d'environnement
bg = cls.generate_random_background(size)
composite = (bgr_float * alpha_norm + bg * (1.0 - alpha_norm)).astype(np.uint8)
# 5. Flou optique & Bruit de capteur
if random.random() < 0.35:
ksize = random.choice([3, 5])
composite = cv2.GaussianBlur(composite, (ksize, ksize), 0)
if random.random() < 0.30:
noise = np.random.normal(0, random.uniform(2, 8), composite.shape).astype(np.int16)
composite = np.clip(composite.astype(np.int16) + noise, 0, 255).astype(np.uint8)
return composite
class SignClassifierEngine:
"""
Moteur de classification par réseau neuronal convolutif (MobileNetV3)
auto-entraîné sur l'ensemble des templates SVG et PNG de signalisation,
et exporté en ONNX optimisé pour une inférence CPU instantanée.
"""
_instance: Optional["SignClassifierEngine"] = None
@classmethod
def get_instance(cls) -> "SignClassifierEngine":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self, models_dir: Union[str, Path] = MODELS_DIR):
self.models_dir = Path(models_dir)
self.models_dir.mkdir(parents=True, exist_ok=True)
self.onnx_path = self.models_dir / "sign_classifier.onnx"
self.meta_path = self.models_dir / "sign_classifier_meta.json"
self._session = None
self._classes: List[str] = []
self._meta: Dict[str, Any] = {}
def is_trained(self) -> bool:
"""Indique si un modèle ONNX valide et ses métadonnées sont présents."""
return self.onnx_path.exists() and self.meta_path.exists() and self.onnx_path.stat().st_size > 1000
def get_metadata(self) -> Dict[str, Any]:
"""Retourne les métadonnées du modèle (classes, date, précision, etc.)."""
if not self._meta and self.meta_path.exists():
try:
with open(self.meta_path, "r", encoding="utf-8") as f:
self._meta = json.load(f)
self._classes = self._meta.get("classes", [])
except Exception as e:
logger.warning("Erreur de lecture des métadonnées ONNX : %s", e)
return self._meta
def discover_templates(self, signs_dir: Union[str, Path] = DEFAULT_SIGNS_DIR) -> Dict[str, Path]:
"""
Découvre récursivement tous les fichiers SVG et PNG de panneaux officiels.
Retourne un dictionnaire {code_normalisé: chemin_fichier}.
"""
signs_path = Path(signs_dir)
if not signs_path.exists():
raise FileNotFoundError(f"Dossier des panneaux introuvable : {signs_path}")
template_files: Dict[str, Path] = {}
for f in sorted(signs_path.rglob("*")):
if f.is_file() and f.suffix.lower() in ('.svg', '.png'):
code = f.stem.upper().strip()
# Priorité aux SVGs s'il existe à la fois SVG et PNG
if code not in template_files or f.suffix.lower() == '.svg':
template_files[code] = f
return template_files
def train_from_svgs(
self,
signs_dir: Union[str, Path] = DEFAULT_SIGNS_DIR,
samples_per_class: int = 15,
epochs: int = 10,
batch_size: int = 32,
learning_rate: float = 0.001,
progress_callback: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Scanne le dossier des SVGs et PNGs, génère un dataset synthétique équilibré,
entraîne MobileNetV3-Small et exporte vers ONNX.
"""
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader
import torchvision.models as models
start_time = time.perf_counter()
template_files = self.discover_templates(signs_dir)
classes = sorted(list(template_files.keys()))
num_classes = len(classes)
if num_classes < 2:
raise ValueError(f"Pas assez de templates trouvés ({num_classes}) pour entraîner le modèle.")
logger.info("🔍 %d types de panneaux officiels découverts pour l'entraînement.", num_classes)
if progress_callback:
progress_callback(f"Chargement de {num_classes} types de panneaux...")
# 1. Génération du dataset synthétique
x_list = []
y_list = []
for class_idx, code in enumerate(classes):
file_path = template_files[code]
if file_path.suffix.lower() == '.svg':
rgba = SyntheticSignAugmentor.render_svg_to_numpy(file_path, size=224)
else:
rgba = SyntheticSignAugmentor.load_png_to_numpy(file_path, size=224)
if rgba is None:
continue
# Image canonique sur fond blanc
raw_bgr = rgba[:, :, :3]
raw_alpha = (rgba[:, :, 3] / 255.0)[:, :, np.newaxis]
white_bg = np.ones((224, 224, 3), dtype=np.uint8) * 255
canonical = (raw_bgr * raw_alpha + white_bg * (1.0 - raw_alpha)).astype(np.uint8)
def to_tensor_norm(bgr_img: np.ndarray) -> np.ndarray:
rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
norm = (rgb - mean) / std
return norm.transpose(2, 0, 1)
x_list.append(to_tensor_norm(canonical))
y_list.append(class_idx)
# Échantillons synthétiques avec variations réalistes
for _ in range(samples_per_class):
aug_bgr = SyntheticSignAugmentor.augment_sign(rgba, size=224)
x_list.append(to_tensor_norm(aug_bgr))
y_list.append(class_idx)
total_samples = len(x_list)
logger.info("📦 Dataset synthétique généré : %d images pour %d classes.", total_samples, num_classes)
if progress_callback:
progress_callback(f"Entraînement MobileNetV3 sur {total_samples} images ({epochs} époques)...")
# 2. Préparation des Tensors PyTorch
x_tensor = torch.tensor(np.array(x_list, dtype=np.float32))
y_tensor = torch.tensor(np.array(y_list, dtype=np.int64))
dataset = TensorDataset(x_tensor, y_tensor)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
# 3. Modèle MobileNetV3-Small pré-entraîné
device = torch.device("cuda" if torch.cuda.is_available() else ("mps" if hasattr(torch.backends, "mps") and torch.backends.mps.is_available() else "cpu"))
model = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT)
# Remplacement de la couche finale de classification
in_features = model.classifier[3].in_features
model.classifier[3] = nn.Linear(in_features, num_classes)
model.to(device)
# 4. Entraînement avec AdamW et Label Smoothing
criterion = nn.CrossEntropyLoss(label_smoothing=0.05)
optimizer = optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
model.train()
for epoch in range(1, epochs + 1):
epoch_loss = 0.0
correct = 0
total = 0
for batch_x, batch_y in loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
optimizer.zero_grad()
outputs = model(batch_x)
loss = criterion(outputs, batch_y)
loss.backward()
optimizer.step()
epoch_loss += loss.item() * batch_x.size(0)
_, predicted = outputs.max(1)
total += batch_y.size(0)
correct += predicted.eq(batch_y).sum().item()
scheduler.step()
acc = 100.0 * correct / max(1, total)
avg_loss = epoch_loss / max(1, total)
logger.info("Époque %d/%d - Loss: %.4f - Précision: %.1f%%", epoch, epochs, avg_loss, acc)
if progress_callback:
progress_callback(f"Époque {epoch}/{epochs} : Précision {acc:.1f}% (Loss: {avg_loss:.4f})")
# 5. Exportation vers ONNX
model.eval()
dummy_input = torch.randn(1, 3, 224, 224, device=device)
self.onnx_path.parent.mkdir(parents=True, exist_ok=True)
try:
torch.onnx.export(
model,
dummy_input,
str(self.onnx_path),
export_params=True,
opset_version=14,
do_constant_folding=True,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}},
dynamo=False
)
except TypeError:
torch.onnx.export(
model,
dummy_input,
str(self.onnx_path),
export_params=True,
opset_version=14,
do_constant_folding=True,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}
)
# 6. Sauvegarde des métadonnées
meta_data = {
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"num_classes": num_classes,
"classes": classes,
"samples_per_class": samples_per_class,
"epochs": epochs,
"final_accuracy": round(acc, 2),
"input_size": [224, 224],
"framework": "MobileNetV3-Small / ONNX",
"signs_dir": str(signs_dir)
}
with open(self.meta_path, "w", encoding="utf-8") as f:
json.dump(meta_data, f, indent=2, ensure_ascii=False)
total_time = time.perf_counter() - start_time
logger.info("🎉 Modèle ONNX généré avec succès en %.1f secondes : %s", total_time, self.onnx_path)
# Réinitialisation de la session en cache
self._session = None
self._classes = classes
self._meta = meta_data
return {
"status": "success",
"num_classes": num_classes,
"accuracy": round(acc, 2),
"training_time_seconds": round(total_time, 1),
"onnx_path": str(self.onnx_path),
"size_mb": round(self.onnx_path.stat().st_size / (1024 * 1024), 2)
}
def _get_session(self):
"""Initialise la session ONNX Runtime de manière paresseuse."""
if self._session is None:
import onnxruntime as ort
if not self.onnx_path.exists() or not self.meta_path.exists():
raise FileNotFoundError(f"Modèle ONNX non trouvé dans {self.onnx_path}. Entraînez le modèle d'abord.")
opts = ort.SessionOptions()
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.intra_op_num_threads = 2
self._session = ort.InferenceSession(str(self.onnx_path), opts, providers=["CPUExecutionProvider"])
with open(self.meta_path, "r", encoding="utf-8") as f:
self._meta = json.load(f)
self._classes = self._meta.get("classes", [])
return self._session
def predict(self, crop_bgr: np.ndarray, top_k: int = 5) -> Dict[str, Any]:
"""
Prédit le type officiel d'un panneau découpé (crop BGR).
Retourne le code officiel, la confiance et le top-K des alternatives.
"""
if not isinstance(crop_bgr, np.ndarray) or crop_bgr.size == 0 or crop_bgr.shape[0] < 5 or crop_bgr.shape[1] < 5:
return {"status": "error", "code": None, "confidence": 0.0, "top_matches": []}
try:
session = self._get_session()
except Exception as e:
logger.debug("Classifieur ONNX non disponible : %s", e)
return {"status": "unavailable", "code": None, "confidence": 0.0, "top_matches": []}
# 1. Prétraitement avec letterboxing (préservation du ratio)
h, w = crop_bgr.shape[:2]
scale = min(224.0 / h, 224.0 / w)
nw, nh = max(1, int(round(w * scale))), max(1, int(round(h * scale)))
resized = cv2.resize(crop_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
canvas = np.ones((224, 224, 3), dtype=np.uint8) * 128
xo = (224 - nw) // 2
yo = (224 - nh) // 2
canvas[yo:yo + nh, xo:xo + nw] = resized
rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
norm = (rgb - mean) / std
input_tensor = np.expand_dims(norm.transpose(2, 0, 1), axis=0).astype(np.float32)
# 2. Inférence ONNX
outputs = session.run(["output"], {"input": input_tensor})[0]
logits = outputs[0]
# 3. Softmax
exp_logits = np.exp(logits - np.max(logits))
probs = exp_logits / np.sum(exp_logits)
# 4. Top-K
k = min(top_k, len(self._classes))
top_indices = np.argsort(probs)[::-1][:k]
top_matches = []
for idx in top_indices:
code = self._classes[idx]
conf = float(probs[idx])
top_matches.append({
"code": code,
"confidence": round(conf, 4),
"svg_url": f"/static/assets/road_signs/2025/{code}.svg"
})
best_match = top_matches[0] if top_matches else {"code": None, "confidence": 0.0, "svg_url": ""}
return {
"status": "success",
"code": best_match["code"],
"confidence": best_match["confidence"],
"svg_url": best_match["svg_url"],
"top_matches": top_matches,
}

View file

@ -1,7 +1,9 @@
"""
Service de détection et reconnaissance de panneaux de signalisation routière.
Combine YOLOv10-n (détection d'objets sans NMS sous ONNX Runtime)
et PaddleOCR / RapidOCR (lecture de texte de panonceaux sous ONNX Runtime).
Combine :
1. YOLOv10-n (détection & localisation de boîtes de panneaux et panonceaux sans NMS sous ONNX Runtime).
2. RapidOCR / PaddleOCR (lecture de texte de panonceaux et limitations sous ONNX Runtime).
3. SignClassifierEngine (modèle MobileNetV3 ONNX auto-entraîné sur les 500+ templates vectoriels).
"""
import os
import io
@ -18,6 +20,7 @@ from PIL import Image, ImageOps
from django.conf import settings
from .catalog import SIGN_CATALOG, get_svg_url, match_sign_from_ocr
from .classifier import SignClassifierEngine
logger = logging.getLogger(__name__)
@ -68,7 +71,7 @@ def letterbox(
class SignDetectionService:
"""
Service Singleton pour l'inférence IA des panneaux de signalisation.
Initialise paresseusement les modèles ONNX Runtime pour économiser les ressources.
Initialise paresseusement les modèles ONNX Runtime (YOLO, OCR, Classifieur) pour économiser les ressources.
"""
_instance: Optional["SignDetectionService"] = None
@ -81,6 +84,7 @@ class SignDetectionService:
Path(settings.BASE_DIR) / "sign" / "ai" / "models"
)
self.yolo_model_path = Path(self.model_dir) / "yolov10n.onnx"
self.classifier_engine = SignClassifierEngine.get_instance()
@classmethod
def get_instance(cls) -> "SignDetectionService":
@ -315,8 +319,11 @@ class SignDetectionService:
) -> Dict[str, Any]:
"""
Pipeline complet d'analyse d'une image de signalisation.
Retourne les panneaux détectés, le texte OCR, l'ordre vertical, les SVGs associés
et l'image annotée en base64.
Combine :
- Détection des boîtes candidates (YOLOv10-n)
- Lecture OCR (RapidOCR)
- Classification fine par réseau neuronal (SignClassifierEngine ONNX)
- Ordonnancement vertical et génération de l'image annotée.
"""
total_start = time.perf_counter()
cv2_img, pil_img = self.load_image(image_input)
@ -325,14 +332,11 @@ class SignDetectionService:
# 1. Détection YOLO
yolo_boxes, yolo_ms = self.detect_yolo_boxes(cv2_img, confidence_threshold=confidence_threshold)
# Si aucun objet YOLO n'est détecté avec un modèle pré-entraîné COCO générique,
# ou si un seul panneau occupe toute l'image (photo cadrée de près),
# nous ajoutons l'image entière comme boîte candidate principale pour l'OCR et l'analyse.
candidate_boxes = []
if yolo_boxes:
candidate_boxes = yolo_boxes
else:
# Fallback de cadrage intelligent : pleine image + zone centrale
# Fallback de cadrage intelligent : pleine image
candidate_boxes.append({
"bbox": [0, 0, img_w, img_h],
"confidence": 0.50,
@ -340,8 +344,9 @@ class SignDetectionService:
"class_name": "panneau_principal",
})
# 2. Analyse OCR & Correspondance Catalogue pour chaque boîte
# 2. Analyse OCR & Classification fine pour chaque boîte
total_ocr_ms = 0.0
total_classifier_ms = 0.0
detected_panels = []
for idx, det in enumerate(candidate_boxes):
@ -349,10 +354,19 @@ class SignDetectionService:
ocr_text, ocr_lines, ocr_ms = self.extract_ocr_from_crop(cv2_img, bbox)
total_ocr_ms += ocr_ms
# Crop pour le classifieur neuronal
crop_bgr = cv2_img[bbox[1]:bbox[3], bbox[0]:bbox[2]]
c_start = time.perf_counter()
classifier_pred = (
self.classifier_engine.predict(crop_bgr, top_k=5)
if self.classifier_engine.is_trained()
else {"status": "unavailable", "code": None, "confidence": 0.0, "top_matches": []}
)
total_classifier_ms += (time.perf_counter() - c_start) * 1000.0
# Tentative d'identification via l'OCR
matched = match_sign_from_ocr(ocr_text)
# Heuristique basée sur la classe COCO si disponible
code = None
name_fr = ""
name_nl = ""
@ -360,22 +374,22 @@ class SignDetectionService:
svg_url = ""
matched_by = "detection_generic"
val = None
final_confidence = det["confidence"]
top_matches = classifier_pred.get("top_matches", [])
if matched:
code = matched["code"]
name_fr = matched["data"]["name_fr"]
name_nl = matched["data"]["name_nl"]
category = matched["data"]["category"]
svg_url = matched["svg_url"]
matched_by = matched["matched_by"]
val = matched.get("value")
elif det["class_name"] == "stop sign":
# --- MOTEUR DE FUSION MULTI-SIGNAUX (NEURONAL + OCR + FORME/COULEUR + YOLO) ---
top_codes = [m["code"] for m in top_matches]
primary_nn_code = classifier_pred.get("code")
# 1. Détections directes YOLO (Objets spécifiques)
if det["class_name"] == "stop sign":
code = "B5"
name_fr = "Arrêt obligatoire (STOP)"
name_nl = "Verplichte stop (STOP)"
category = "priority"
svg_url = get_svg_url("B5")
matched_by = "yolo_stop_sign"
final_confidence = max(final_confidence, 0.96)
elif det["class_name"] == "traffic light":
code = "SIGNALISATION_LUMINEUSE"
name_fr = "Feux de signalisation"
@ -383,44 +397,112 @@ class SignDetectionService:
category = "trafficlights"
svg_url = "/static/assets/traffic_light_icon.svg"
matched_by = "yolo_traffic_light"
else:
# Classification visuelle basée sur la classe YOLO et l'image découpée (forme & couleur)
if det["class_name"] == "sub_plate":
code = "M2"
entry = SIGN_CATALOG.get("M2", {})
name_fr = entry.get("name_fr", "Panonceau additionnel")
name_nl = entry.get("name_nl", "Onderbord")
category = "panonceau"
svg_url = get_svg_url("M2")
matched_by = "yolo_sub_plate"
else:
# Découpage du panneau pour classification par forme et couleur
crop_bgr = cv2_img[bbox[1]:bbox[3], bbox[0]:bbox[2]]
from .catalog import classify_sign_visual
vis_res = classify_sign_visual(crop_bgr, ocr_text=ocr_text)
code = vis_res["code"]
name_fr = vis_res["name_fr"]
name_nl = vis_res["name_nl"]
category = vis_res["category"]
svg_url = vis_res["svg_url"]
matched_by = vis_res["matched_by"]
final_confidence = max(final_confidence, 0.95)
# Recherche en base de données pour associer le SignPanelType officiel si disponible
# 2. Vitesse maximale autorisée (C43 / C43_XX / ZC43) :
# Fusion : Si OCR extrait une vitesse OU que le classifieur neuronal a prédit C43
elif (matched and matched["code"] == "C43") or (primary_nn_code and "C43" in primary_nn_code):
val = matched.get("value") if matched else None
# Si pas de valeur extraite de l'OCR, tenter d'extraire depuis le code neuronal (ex: C43_50 -> 50)
if val is None and primary_nn_code:
import re as reg_sub
speed_sub = reg_sub.search(r"C43_(\d+)", primary_nn_code)
if speed_sub:
val = int(speed_sub.group(1))
specific_code = matched.get("specific_code") if matched else None
if not specific_code:
specific_code = primary_nn_code if (primary_nn_code and "C43" in primary_nn_code) else (f"C43_{val}" if val else "C43")
code = "C43"
name_fr = f"Vitesse maximale autorisée ({val} km/h)" if val else "Vitesse maximale autorisée"
name_nl = f"Maximumsnelheid ({val} km/u)" if val else "Maximumsnelheid"
category = "prohibition"
svg_url = get_svg_url(specific_code) or get_svg_url("C43")
nn_confirms_c43 = any("C43" in c for c in top_codes[:3])
matched_by = "ai_neural_classifier" if nn_confirms_c43 else (matched["matched_by"] if matched else "ai_neural_classifier")
final_confidence = max(final_confidence, 0.96 if nn_confirms_c43 else (matched.get("confidence", 0.90) if matched else 0.85))
# 3. Panneaux de Zone (Zone 30, Zone Parking ZE9A, Fin de zone) :
elif (matched and matched["code"] in ("ZE9A", "ZE9A_DISK", "F4A", "F4B", "F103", "ZC43")) or (primary_nn_code and primary_nn_code.startswith(("ZE9", "F4", "ZC"))):
if matched and matched["code"] in ("ZE9A", "ZE9A_DISK", "F4A", "F4B", "F103", "ZC43"):
code = matched["code"]
name_fr = matched["data"]["name_fr"]
name_nl = matched["data"]["name_nl"]
category = matched["data"]["category"]
svg_url = matched["svg_url"]
val = matched.get("value")
matched_by = matched["matched_by"]
final_confidence = max(final_confidence, matched.get("confidence", 0.92))
else:
code = primary_nn_code
entry = SIGN_CATALOG.get(code, {})
name_fr = entry.get("name_fr", f"Zone {code}")
name_nl = entry.get("name_nl", f"Zone {code}")
category = entry.get("category", "zone")
svg_url = classifier_pred.get("svg_url") or get_svg_url(code)
matched_by = "ai_neural_classifier"
final_confidence = max(final_confidence, float(classifier_pred.get("confidence", 0.85)))
# 4. Matching OCR fort (STOP, Parking P, PMR, Payant, Recharge électrique, Tonnage, etc.)
elif matched and (
matched["code"] in ("B5", "E9A", "E9B", "GVII_BETALEND", "GVIID_ELEKTRISCHE_WAGENS", "E9A_PARKEERSCHIJF", "C21")
or det["class_name"] == "sub_plate"
):
code = matched["code"]
name_fr = matched["data"]["name_fr"]
name_nl = matched["data"]["name_nl"]
category = matched["data"]["category"]
svg_url = matched["svg_url"]
matched_by = matched["matched_by"]
val = matched.get("value")
final_confidence = max(final_confidence, matched.get("confidence", 0.90))
# 5. Réseau Neuronal MobileNetV3 (Classification visuelle fine des pictogrammes)
elif primary_nn_code and classifier_pred.get("confidence", 0.0) >= 0.02:
code = primary_nn_code
svg_url = classifier_pred.get("svg_url") or get_svg_url(code)
matched_by = "ai_neural_classifier"
final_confidence = float(classifier_pred.get("confidence", det["confidence"]))
# Recherche dans le catalogue pour nom & catégorie
entry = SIGN_CATALOG.get(code, {})
name_fr = entry.get("name_fr", f"Panneau {code}")
name_nl = entry.get("name_nl", f"Verkeersbord {code}")
category = entry.get("category", "indication")
# 6. Fallback Analyse Visuelle Géométrique
else:
from .catalog import classify_sign_visual
vis_res = classify_sign_visual(crop_bgr, ocr_text=ocr_text)
code = vis_res["code"]
name_fr = vis_res["name_fr"]
name_nl = vis_res["name_nl"]
category = vis_res["category"]
svg_url = vis_res["svg_url"]
matched_by = vis_res["matched_by"]
final_confidence = vis_res.get("confidence", 0.50)
# Recherche en base de données pour associer le SignPanelType officiel
db_panel_type_id = None
try:
from sign.models import SignPanelType
db_type = SignPanelType.objects.filter(code__iexact=code).first()
if not db_type:
from assets.models import SignPanelType as AssetSignPanelType
db_type = AssetSignPanelType.objects.filter(code__iexact=code).first()
if db_type:
db_panel_type_id = db_type.id
name_fr = db_type.name_fr or name_fr
name_nl = db_type.name_nl or name_nl
name_fr = getattr(db_type, "name_fr", None) or name_fr
name_nl = getattr(db_type, "name_nl", None) or name_nl
except Exception:
pass
detected_panels.append({
"id": idx + 1,
"bbox": bbox,
"confidence": det["confidence"],
"confidence": round(final_confidence, 3),
"yolo_class": det["class_name"],
"code": code,
"name_fr": name_fr,
@ -428,10 +510,12 @@ class SignDetectionService:
"category": category,
"svg_url": svg_url,
"matched_by": matched_by,
"top_matches": top_matches,
"ocr_text": ocr_text,
"ocr_lines": ocr_lines,
"signpanel_text": ocr_text if ocr_text else None,
"signpanel_value": val,
"value": val,
"signpanel_type_id": db_panel_type_id,
"ymin": bbox[1],
})
@ -449,7 +533,7 @@ class SignDetectionService:
code = p["code"]
conf = int(p["confidence"] * 100)
# Couleur du rectangle (Vert pour haute confiance, Orange pour moyenne)
# Couleur du rectangle
box_color = (46, 204, 113) if p["confidence"] >= 0.7 else (52, 152, 219)
cv2.rectangle(annotated_cv2, (x1, y1), (x2, y2), box_color, 3)
@ -458,7 +542,6 @@ class SignDetectionService:
if p["ocr_text"]:
label += f" - '{p['ocr_text'][:20]}'"
# Fond du texte
(label_w, label_h), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
cv2.rectangle(
annotated_cv2,
@ -478,7 +561,7 @@ class SignDetectionService:
cv2.LINE_AA
)
# Encodage de l'image annotée en base64 pour affichage immédiat
# Encodage de l'image annotée en base64
_, buffer = cv2.imencode(".jpg", annotated_cv2, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
annotated_base64 = "data:image/jpeg;base64," + base64.b64encode(buffer).decode("utf-8")
@ -494,6 +577,7 @@ class SignDetectionService:
"performance": {
"yolo_inference_ms": round(yolo_ms, 1),
"ocr_inference_ms": round(total_ocr_ms, 1),
"classifier_inference_ms": round(total_classifier_ms, 1),
"total_processing_ms": round(total_elapsed_ms, 1),
}
}

Binary file not shown.

View file

@ -0,0 +1,517 @@
{
"created_at": "2026-08-22 18:42:48",
"num_classes": 502,
"classes": [
"A11",
"A13",
"A14",
"A15",
"A17",
"A19",
"A1A",
"A1B",
"A1C",
"A1D",
"A21",
"A23",
"A25",
"A27",
"A29",
"A3",
"A31",
"A33",
"A35",
"A37",
"A39",
"A41",
"A43",
"A45",
"A47",
"A49",
"A5",
"A50",
"A51",
"A7A",
"A7B",
"A7C",
"A9",
"B1",
"B11",
"B13",
"B15A",
"B15B",
"B15C",
"B15D",
"B15E",
"B15F",
"B15G",
"B17",
"B19",
"B21",
"B22",
"B23",
"B3",
"B5",
"B7",
"B9",
"C1",
"C11",
"C13",
"C15",
"C17",
"C19",
"C21",
"C21_5.5",
"C22",
"C23",
"C24A",
"C24B",
"C24C",
"C25",
"C27",
"C29",
"C3",
"C31A",
"C31B",
"C33",
"C35",
"C37",
"C39",
"C41",
"C43",
"C43_100",
"C43_20",
"C43_30",
"C43_50",
"C43_50KM",
"C43_60",
"C43_70",
"C43_80",
"C43_90",
"C45",
"C45_50",
"C45_50KM",
"C46",
"C47",
"C48",
"C49",
"C5",
"C5-C7",
"C6",
"C7",
"C9",
"C9-C11",
"CAMERA",
"D10",
"D11",
"D13",
"D1A",
"D1B",
"D1B_LEFT",
"D1B_LINKS",
"D1B_RECHTS",
"D1B_RIGHT",
"D1C",
"D1D",
"D1E",
"D1F",
"D1G",
"D3A",
"D3B",
"D4",
"D4_LEFT",
"D4_LINKS",
"D4_RECHTDOOR",
"D4_RECHTS",
"D4_RIGHT",
"D4_STRAIGHT",
"D5",
"D7",
"D9",
"D9A",
"D9B",
"E1",
"E11",
"E3",
"E5",
"E7",
"E9A",
"E9A_DISK",
"E9A_DISK_35TMAX",
"E9A_HANDI",
"E9A_HANDICAP",
"E9A_PARKEERSCHIJF",
"E9B",
"E9C",
"E9D",
"E9E",
"E9F",
"E9G",
"E9H",
"E9I",
"F101A",
"F101B",
"F101C",
"F103",
"F105",
"F11",
"F111",
"F113",
"F117",
"F118",
"F119",
"F120",
"F12A",
"F12B",
"F13",
"F14",
"F15",
"F15_AFRIT",
"F17",
"F18",
"F19",
"F1A",
"F1A_HORIZONTAAL",
"F1A_VERTICAAL",
"F1B",
"F1B_HORIZONTAAL",
"F1B_VERTICAAL",
"F21",
"F23A",
"F23B",
"F23C",
"F23D",
"F25",
"F25M",
"F25R",
"F25V",
"F25VM",
"F25VR",
"F27",
"F27V",
"F29",
"F31",
"F33A",
"F33B",
"F33C",
"F34A",
"F34B1",
"F34B2",
"F34C1",
"F34C2",
"F35",
"F37",
"F39",
"F3A",
"F3A_HORIZONTAAL",
"F3A_VERTICAAL",
"F3B",
"F3B_HORIZONTAAL",
"F3B_VERTICAAL",
"F41",
"F43",
"F45",
"F45B",
"F45_LINKS",
"F45_RECHTS",
"F47",
"F49",
"F4A",
"F4B",
"F5",
"F50",
"F50BIS",
"F50BIS_A21",
"F50BIS_A21-A25",
"F50BIS_A25",
"F51A",
"F51B",
"F52A",
"F52B",
"F52BISA",
"F52BISB",
"F53",
"F55",
"F56",
"F57",
"F59",
"F59A",
"F59B",
"F60",
"F61",
"F62",
"F63",
"F63_CNG",
"F63_ELEKTRISCH",
"F63_H2",
"F63_LNG",
"F63_LPG",
"F65",
"F67",
"F69",
"F7",
"F71",
"F73",
"F75",
"F77",
"F79",
"F8",
"F81",
"F83",
"F85",
"F87",
"F89",
"F9",
"F91",
"F93",
"F95",
"F97",
"F97A",
"F97_3-2",
"F98",
"F99A",
"F99B",
"F99C",
"FXX",
"GF98",
"GIA_150",
"GIB_150",
"GIII_AQUAPLANING",
"GIII_BIJ_NAT_WEGDEK",
"GIII_FABRIEK",
"GIII_IJZEL",
"GIII_LANGSGROEVEN",
"GIII_MIST",
"GIII_SPOORVORMING",
"GIII_STEENGROEVE",
"GIII_UITRIT_VRACHTWAGENS",
"GIII_VERVORMD_WEGDEK",
"GIII_ZACHTE_BERM",
"GII_10",
"GIV_UITGEZONDERD_-5T",
"GIV_UITGEZONDERD_BUS",
"GIV_UITGEZONDERD_FIETSERS",
"GIV_UITGEZONDERD_PLAATSELIJK_VERKEER",
"GIX",
"GIX_2-VAKS",
"GIX_3-2",
"GIX_3-VAKS",
"GVI",
"GVIIA_+2T",
"GVIIB",
"GVIIB_UITGEZONDERD_BEWONERS",
"GVIIC_30MIN",
"GVIIC_30_MIN",
"GVIID_CARPOOL",
"GVIID_ELEKTRISCHE_PERSONENWAGEN",
"GVIID_ELEKTRISCHE_PERSONENWAGENS",
"GVIID_ELEKTRISCHE_WAGENS",
"GVIID_ELEKTRISCH_VOERTUIG",
"GVIID_GEHANDICAPT",
"GVIID_HANDICAP",
"GVIID_P+R",
"GVIID_TAXIS",
"GVIII",
"GVII_BETALEND",
"GVII_BETALEND_UITGEZONDERD_BEWONERS",
"GVI_HERHALING",
"GV_VAN_7_TOT_19U",
"GV_VAN_7_TOT_19_U",
"GV_VAN_MAANDAG_TOT_VRIJDAG",
"GXA",
"GXB",
"GXC",
"GXD",
"GXI",
"GXII_B",
"GXII_C",
"GXII_D",
"GXII_E",
"ICC",
"ICR",
"IF2",
"IR10B",
"IR11",
"IVMS1",
"KM",
"M1",
"M10",
"M11",
"M12",
"M13",
"M14",
"M15",
"M16",
"M17",
"M18",
"M19",
"M2",
"M20",
"M24",
"M3",
"M3BIS",
"M4",
"M5",
"M5BIS",
"M6",
"M7",
"M8",
"M9",
"MARK1",
"MARK10",
"MARK12",
"MARK13",
"MARK14",
"MARK3",
"MARK5",
"MARK6",
"MARK8",
"MARK9",
"MARKCYCLOM",
"MARKM1",
"MARKM21",
"MARKM22",
"MARKM23",
"MARKM24",
"OTHERS",
"S1",
"S10",
"S11",
"S12",
"S13",
"S14",
"S15",
"S16",
"S17",
"S18",
"S19",
"S2",
"S20",
"S21",
"S3",
"S30",
"S31",
"S32",
"S33",
"S34",
"S35",
"S36",
"S4",
"S5",
"TYPE0",
"TYPE0B",
"TYPE1A1",
"TYPE1A2",
"TYPE1B1",
"TYPE1B2",
"TYPE1C1",
"TYPE5",
"TYPEIA_200M",
"TYPEIA_300M",
"TYPEIA_50M",
"TYPEIA_GEN",
"TYPEIB",
"TYPEIIIA",
"TYPEIIIAC",
"TYPEIIIB",
"TYPEII_GEN",
"TYPEIV",
"TYPEIVA",
"TYPEIVD",
"TYPEIVE",
"TYPEIVF",
"TYPEIXB",
"TYPEVA",
"TYPEVB",
"TYPEVC",
"TYPEVD",
"TYPEVE",
"TYPEVF",
"TYPEVG",
"TYPEVI",
"TYPEVIIA_(+)2,5T",
"TYPEVIIA_(+)2T",
"TYPEVIIA_(+)3,5T",
"TYPEVIIA_(+)7,5T",
"TYPEVIIA_(-)3,5T",
"TYPEVIIA_3,5TMAX",
"TYPEVIIA_5TMAX",
"TYPEVIIBD",
"TYPEVIIBDA",
"TYPEVIIBDB",
"TYPEVIIBDD",
"TYPEVIIBDE",
"TYPEVIIBE",
"TYPEVIIBEA",
"TYPEVIIC_15",
"TYPEVIIC_20",
"TYPEVIIC_30",
"TYPEVIIC_5",
"TYPEVIID",
"TYPEVIIDA",
"TYPEVIIDB",
"TYPEVIIDC",
"TYPEVIIDD",
"TYPEVIIDE",
"TYPEVIIDF",
"TYPEVIIDG",
"TYPEVIII",
"TYPEXI",
"TYPEXIIB",
"TYPEXIIC",
"TYPEXIIE",
"TYPEXIIIA",
"TYPEXIIIB",
"TYPEXIIIC",
"TYPEXIVA",
"TYPEXIVB",
"TYPEXIVC",
"TYPEXIVD",
"TYPEXIVE",
"TYPEXIVF",
"TYPEXVA",
"TYPEXVC",
"XA",
"XB",
"XC",
"XC12",
"XC18",
"XC6",
"XD",
"XE",
"XF",
"ZC43",
"ZC45",
"ZE1",
"ZE3",
"ZE9A",
"ZE9A3T5MAXPB",
"ZE9A3T5MAXPB'",
"ZE9A3T5MAX_DISK",
"ZE9A3T5MAX_DISK'",
"ZE9AE",
"ZE9AE'",
"ZE9AG",
"ZE9AG'",
"ZE9A_DISK",
"ZE9A_DISK'",
"ZE9B",
"ZE9BE",
"ZE9BE'",
"ZE9BG",
"ZE9BG'",
"ZE9B_DISK",
"ZE9B_DISK'",
"ZF111",
"ZF113"
],
"samples_per_class": 12,
"epochs": 10,
"final_accuracy": 99.79,
"input_size": [
224,
224
],
"framework": "MobileNetV3-Small / ONNX",
"signs_dir": "/home/kdt/StreetUp/Antigravity/loko/loko/assets/static/assets/road_signs/2025"
}

View file

@ -2,52 +2,66 @@
Commande Django pour initialiser et vérifier l'environnement d'IA de signalisation :
- Téléchargement du modèle YOLOv10-n ONNX
- Initialisation des poids RapidOCR / PaddleOCR ONNX
- Vérification du classifieur neuronal de panneaux (SignClassifierEngine ONNX)
- Test de validation de l'inférence CPU
"""
import time
import numpy as np
import cv2
from django.core.management.base import BaseCommand
from sign.ai import SignDetectionService
from sign.ai import SignDetectionService, SignClassifierEngine
class Command(BaseCommand):
help = "Télécharge les modèles IA (YOLOv10-n ONNX & OCR) et valide l'inférence CPU."
help = "Télécharge les modèles IA (YOLOv10-n ONNX, OCR & Classifieur) et valide l'inférence CPU."
def handle(self, *args, **options):
self.stdout.write(self.style.NOTICE("=== Initialisation du module IA de Signalisation (StreetUp / Loko) ==="))
service = SignDetectionService.get_instance()
self.stdout.write(f"1. Vérification du modèle YOLOv10-n dans : {service.yolo_model_path}")
start_dl = time.perf_counter()
model_path = service._ensure_yolo_model()
self.stdout.write(self.style.SUCCESS(f" ✓ Modèle YOLOv10-n présent ({model_path.stat().st_size / (1024*1024):.2f} Mo)"))
self.stdout.write("2. Chargement de la session ONNX Runtime...")
self.stdout.write("2. Chargement de la session YOLO ONNX Runtime...")
session = service.get_yolo_session()
self.stdout.write(self.style.SUCCESS(f" ✓ Session ONNX prête (Providers: {session.get_providers()})"))
self.stdout.write(self.style.SUCCESS(f" ✓ Session YOLO ONNX prête (Providers: {session.get_providers()})"))
self.stdout.write("3. Initialisation du moteur RapidOCR / PaddleOCR...")
ocr = service.get_ocr_engine()
self.stdout.write(self.style.SUCCESS(" ✓ Moteur OCR prêt."))
self.stdout.write("4. Exécution du test de validation d'inférence CPU...")
# Image de test avec STOP et panonceau
self.stdout.write("4. Vérification du Classifieur Neuronal de Panneaux (ONNX)...")
classifier = SignClassifierEngine.get_instance()
if classifier.is_trained():
meta = classifier.get_metadata()
self.stdout.write(self.style.SUCCESS(
f" ✓ Classifieur ONNX actif ({meta.get('num_classes', '?')} classes, "
f"précision : {meta.get('final_accuracy', '?')}%)"
))
else:
self.stdout.write(self.style.WARNING(
" ⚠️ Classifieur ONNX non encore entraîné. Lancez 'python manage.py train_sign_classifier' pour l'auto-entraînement."
))
self.stdout.write("5. Exécution du test de validation d'inférence CPU...")
test_img = np.ones((600, 600, 3), dtype=np.uint8) * 240
cv2.circle(test_img, (300, 200), 100, (0, 0, 200), -1)
cv2.putText(test_img, "STOP", (240, 215), cv2.FONT_HERSHEY_SIMPLEX, 1.4, (255, 255, 255), 4)
res = service.analyze_image(test_img)
self.stdout.write(self.style.SUCCESS(f" ✓ Analyse réussie ! Panneaux détectés : {res['detected_count']}"))
for p in res['panels']:
self.stdout.write(f" - #{p['vertical_order']} [{p['code']}] {p['name_fr']} (OCR: '{p['ocr_text']}')")
self.stdout.write(f" - #{p['vertical_order']} [{p['code']}] {p['name_fr']} (Match: {p.get('matched_by', '?')}, OCR: '{p['ocr_text']}')")
perf = res['performance']
self.stdout.write(self.style.NOTICE(f"5. Métriques de performance CPU :"))
self.stdout.write(f" • Inférence YOLO : {perf['yolo_inference_ms']} ms")
self.stdout.write(f" • Inférence OCR : {perf['ocr_inference_ms']} ms")
self.stdout.write(f" • Temps total : {perf['total_processing_ms']} ms")
self.stdout.write(self.style.NOTICE("6. Métriques de performance CPU :"))
self.stdout.write(f" • Inférence YOLO : {perf['yolo_inference_ms']} ms")
self.stdout.write(f" • Inférence OCR : {perf['ocr_inference_ms']} ms")
self.stdout.write(f" • Inférence Classifier : {perf.get('classifier_inference_ms', 0)} ms")
self.stdout.write(f" • Temps total : {perf['total_processing_ms']} ms")
self.stdout.write(self.style.SUCCESS("=== Module IA opérationnel et prêt à l'emploi ==="))

View file

@ -0,0 +1,111 @@
"""
Commande Django pour entraîner le modèle neuronal de classification de panneaux
à partir des fichiers SVG et PNG officiels du projet, et exporter vers ONNX.
Usage:
python manage.py train_sign_classifier
python manage.py train_sign_classifier --epochs 15 --samples-per-class 20
python manage.py train_sign_classifier --force
"""
import time
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from sign.ai.classifier import SignClassifierEngine, DEFAULT_SIGNS_DIR
class Command(BaseCommand):
help = "Auto-entraîne le classifieur MobileNetV3 sur les images SVG/PNG de panneaux et exporte en ONNX."
def add_arguments(self, parser):
parser.add_argument(
"--epochs",
type=int,
default=12,
help="Nombre d'époques d'entraînement (défaut : 12)."
)
parser.add_argument(
"--samples-per-class",
type=int,
default=15,
help="Nombre d'échantillons synthétiques générés par classe de panneau (défaut : 15)."
)
parser.add_argument(
"--batch-size",
type=int,
default=32,
help="Taille des batchs pour l'entraînement PyTorch (défaut : 32)."
)
parser.add_argument(
"--lr",
type=float,
default=0.001,
help="Taux d'apprentissage initial (défaut : 0.001)."
)
parser.add_argument(
"--signs-dir",
type=str,
default=str(DEFAULT_SIGNS_DIR),
help=f"Répertoire source des SVGs et PNGs (défaut : {DEFAULT_SIGNS_DIR})."
)
parser.add_argument(
"--force",
action="store_true",
help="Force le réentraînement même si un modèle ONNX existe déjà."
)
def handle(self, *args, **options):
epochs = options["epochs"]
samples_per_class = options["samples_per_class"]
batch_size = options["batch_size"]
lr = options["lr"]
signs_dir = Path(options["signs_dir"])
force = options["force"]
self.stdout.write(self.style.NOTICE("=================================================================="))
self.stdout.write(self.style.NOTICE(" Auto-Entraînement du Modèle de Classification de Signalisation"))
self.stdout.write(self.style.NOTICE("=================================================================="))
self.stdout.write(f"📁 Dossier source : {signs_dir}")
if not signs_dir.exists():
raise CommandError(f"Le dossier spécifié n'existe pas : {signs_dir}")
engine = SignClassifierEngine.get_instance()
if engine.is_trained() and not force:
meta = engine.get_metadata()
self.stdout.write(self.style.WARNING(
f"ℹ️ Un modèle entraîné existe déjà ({meta.get('num_classes', '?')} classes, "
f"créé le {meta.get('created_at', '?')}, précision : {meta.get('final_accuracy', '?')}%)."
))
self.stdout.write(" Utilisez --force pour forcer le ré-entraînement complet.")
return
def progress_cb(msg: str):
self.stdout.write(f" ➜ {msg}")
try:
self.stdout.write("\n🚀 Démarrage de l'analyse des templates et de la génération synthétique...")
start_time = time.time()
result = engine.train_from_svgs(
signs_dir=signs_dir,
samples_per_class=samples_per_class,
epochs=epochs,
batch_size=batch_size,
learning_rate=lr,
progress_callback=progress_cb
)
elapsed = time.time() - start_time
self.stdout.write(self.style.SUCCESS("\n=================================================================="))
self.stdout.write(self.style.SUCCESS(" 🎉 ENTRAÎNEMENT & EXPORT ONNX TERMINÉS AVEC SUCCÈS !"))
self.stdout.write(self.style.SUCCESS("=================================================================="))
self.stdout.write(f" • Classes de panneaux entraînées : {result['num_classes']}")
self.stdout.write(f" • Précision finale : {result['accuracy']:.2f}%")
self.stdout.write(f" • Fichier ONNX actif : {result['onnx_path']}")
self.stdout.write(f" • Taille du modèle : {result['size_mb']} Mo")
self.stdout.write(f" • Temps total : {elapsed:.1f} secondes")
self.stdout.write(self.style.SUCCESS("==================================================================\n"))
except Exception as exc:
self.stderr.write(self.style.ERROR(f"❌ Échec de l'entraînement : {exc}"))
raise CommandError(str(exc))

View file

@ -365,6 +365,27 @@
const confColor = confPct >= 75 ? 'bg-success' : (confPct >= 50 ? 'bg-warning' : 'bg-secondary');
const svgSrc = p.svg_url || '';
const matchBadge = p.matched_by === 'ai_neural_classifier'
? '<span class="badge bg-primary-subtle text-primary border border-primary-subtle small"><i class="bi bi-cpu me-1"></i>Modèle IA Neuronal</span>'
: (p.matched_by.startsWith('text_') || p.matched_by === 'ocr_text'
? '<span class="badge bg-success-subtle text-success border border-success-subtle small"><i class="bi bi-fonts me-1"></i>Reconnaissance OCR</span>'
: '<span class="badge bg-secondary-subtle text-secondary border small"><i class="bi bi-eye me-1"></i>Analyse Visuelle</span>');
let topMatchesHtml = '';
if (p.top_matches && p.top_matches.length > 1) {
const altList = p.top_matches.slice(1, 4).map(m => `
<span class="badge bg-light text-dark border me-1" title="Confiance : ${(m.confidence * 100).toFixed(1)}%">
${m.code} (${Math.round(m.confidence * 100)}%)
</span>
`).join('');
topMatchesHtml = `
<div class="mt-2 pt-2 border-top small">
<span class="text-muted fw-semibold me-1">Alternatives candidates :</span>
<div class="d-inline-flex flex-wrap gap-1 mt-1">${altList}</div>
</div>
`;
}
const card = document.createElement('div');
card.className = 'col-lg-6';
card.innerHTML = `
@ -384,7 +405,10 @@
<h6 class="fw-bold text-dark mb-0">${p.name_fr || p.name_nl || 'Panneau détecté'}</h6>
<span class="badge ${confColor} px-2 py-1">${confPct}%</span>
</div>
<p class="text-muted small mb-2">${p.name_nl ? `<span class="fst-italic">${p.name_nl}</span>` : ''}</p>
<div class="mb-2 d-flex flex-wrap gap-1 align-items-center">
${matchBadge}
${p.name_nl ? `<span class="text-muted small fst-italic ms-1">${p.name_nl}</span>` : ''}
</div>
<!-- Texte OCR / Panonceau -->
${p.ocr_text ? `
@ -409,6 +433,8 @@
<div class="small text-muted mt-2">
<i class="bi bi-arrows-move me-1"></i>Position verticale : <strong>#${p.vertical_order} sur le mât</strong>
</div>
${topMatchesHtml}
</div>
</div>
</div>

View file

@ -1,8 +1,18 @@
"""
Tests unitaires et d'intégration pour le module IA de reconnaissance de signalisation (sign.ai).
Couvre :
- Matcher OCR et catalogue
- SyntheticSignAugmentor et déformations réalistes
- SignClassifierEngine (auto-entraînement, export ONNX, inférence top-k)
- SignDetectionService (détection YOLO + OCR + Classifieur neuronal)
- Commande Django train_sign_classifier
- Endpoints API REST et vue de démonstration
"""
import io
import json
import os
import tempfile
from pathlib import Path
import numpy as np
import cv2
from PIL import Image
@ -11,11 +21,18 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from rest_framework.test import APITestCase
from rest_framework import status
from sign.ai import SignDetectionService, match_sign_from_ocr, get_svg_url
from sign.ai import (
SignDetectionService,
SignClassifierEngine,
SyntheticSignAugmentor,
match_sign_from_ocr,
get_svg_url,
)
from sign.models import SignPanelType
User = get_user_model()
@ -60,18 +77,58 @@ class SignCatalogMatcherTests(TestCase):
self.assertEqual(res["code"], "C43")
self.assertEqual(res["value"], 50)
def test_match_speed_limit_with_km_and_kmh(self):
res_km = match_sign_from_ocr("50 km")
self.assertIsNotNone(res_km)
self.assertEqual(res_km["code"], "C43")
self.assertEqual(res_km["value"], 50)
res_kmh = match_sign_from_ocr("70 km/h")
self.assertIsNotNone(res_kmh)
self.assertEqual(res_kmh["code"], "C43")
self.assertEqual(res_kmh["value"], 70)
def test_match_zone_parking_with_exemptions(self):
res = match_sign_from_ocr("ZONE P Excepte carte de stationnement Uitgezonderd parkeerkaart Rappel")
self.assertIsNotNone(res)
self.assertEqual(res["code"], "ZE9A")
self.assertEqual(res["data"]["category"], "parking")
def test_match_tonnage_sign(self):
res = match_sign_from_ocr("3.5 t")
self.assertIsNotNone(res)
self.assertEqual(res["code"], "C21")
self.assertEqual(res["value"], 3.5)
def test_match_parking_p_and_d(self):
res_p = match_sign_from_ocr("P")
self.assertIsNotNone(res_p)
self.assertEqual(res_p["code"], "E9A")
res_d = match_sign_from_ocr("D")
self.assertIsNotNone(res_d)
self.assertEqual(res_d["code"], "E9A")
def test_match_parking_payant(self):
res = match_sign_from_ocr("PAYANT BETALEND")
self.assertIsNotNone(res)
self.assertEqual(res["code"], "GVII_BETALEND")
self.assertEqual(res["data"]["category"], "parking")
def test_visual_classification_blue_parking_vertical(self):
from sign.ai import classify_sign_visual
# Simuler un rectangle bleu vertical (ex: 200 de haut x 130 de large)
img = np.zeros((200, 130, 3), dtype=np.uint8)
img[:, :] = (200, 50, 20) # BGR Bleu
res = classify_sign_visual(img, ocr_text="")
self.assertEqual(res["code"], "E9A")
self.assertEqual(res["category"], "parking")
def test_visual_classification_blue_bike(self):
from sign.ai import classify_sign_visual
# Simuler un rond bleu
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
cv2.circle(img, (50, 50), 45, (200, 50, 20), -1) # BGR Bleu
cv2.circle(img, (50, 50), 15, (255, 255, 255), -1) # Blanc centre
cv2.circle(img, (50, 50), 45, (200, 50, 20), -1)
cv2.circle(img, (50, 50), 15, (255, 255, 255), -1)
res = classify_sign_visual(img)
self.assertEqual(res["code"], "D7")
self.assertEqual(res["category"], "obligation")
@ -79,7 +136,7 @@ class SignCatalogMatcherTests(TestCase):
def test_visual_classification_yellow_priority(self):
from sign.ai import classify_sign_visual
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
cv2.circle(img, (50, 50), 45, (0, 220, 220), -1) # BGR Jaune
cv2.circle(img, (50, 50), 45, (0, 220, 220), -1)
res = classify_sign_visual(img)
self.assertEqual(res["code"], "B3")
self.assertEqual(res["category"], "priority")
@ -87,8 +144,8 @@ class SignCatalogMatcherTests(TestCase):
def test_visual_classification_sens_interdit_c1(self):
from sign.ai import classify_sign_visual
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
cv2.circle(img, (50, 50), 45, (30, 30, 220), -1) # Cercle rouge
cv2.rectangle(img, (15, 42), (85, 58), (255, 255, 255), -1) # Barre blanche
cv2.circle(img, (50, 50), 45, (30, 30, 220), -1)
cv2.rectangle(img, (15, 42), (85, 58), (255, 255, 255), -1)
res = classify_sign_visual(img)
self.assertEqual(res["code"], "C1")
self.assertEqual(res["category"], "prohibition")
@ -98,6 +155,103 @@ class SignCatalogMatcherTests(TestCase):
self.assertEqual(get_svg_url(""), "")
class SyntheticSignAugmentorTests(TestCase):
"""Tests pour le générateur de variations synthétiques."""
def test_letterbox_rgba(self):
# Image non carrée (100x200)
img = np.ones((100, 200, 4), dtype=np.uint8) * 255
letterboxed = SyntheticSignAugmentor.letterbox_rgba(img, target_size=224)
self.assertEqual(letterboxed.shape, (224, 224, 4))
def test_generate_random_background(self):
bg = SyntheticSignAugmentor.generate_random_background(size=224)
self.assertEqual(bg.shape, (224, 224, 3))
self.assertEqual(bg.dtype, np.uint8)
def test_augment_sign(self):
# Créer une image RGBA synthétique (disque rouge sur fond transparent)
rgba = np.zeros((224, 224, 4), dtype=np.uint8)
cv2.circle(rgba, (112, 112), 90, (0, 0, 220, 255), -1)
augmented = SyntheticSignAugmentor.augment_sign(rgba, size=224)
self.assertEqual(augmented.shape, (224, 224, 3))
self.assertEqual(augmented.dtype, np.uint8)
# Vérifier qu'il y a du contenu non noir
self.assertGreater(np.mean(augmented), 5.0)
class SignClassifierEngineTests(TestCase):
"""Tests du moteur d'auto-entraînement et d'inférence ONNX."""
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.models_dir = Path(self.temp_dir.name) / "models"
self.signs_dir = Path(self.temp_dir.name) / "signs"
self.signs_dir.mkdir(parents=True, exist_ok=True)
# Créer au moins 2 templates PNG de test
# 1. Panneau C1 (Sens interdit - rouge)
c1_img = np.zeros((200, 200, 4), dtype=np.uint8)
cv2.circle(c1_img, (100, 100), 90, (0, 0, 220, 255), -1)
cv2.rectangle(c1_img, (30, 85), (170, 115), (255, 255, 255, 255), -1)
cv2.imwrite(str(self.signs_dir / "C1.png"), c1_img)
# 2. Panneau D7 (Piste cyclable - bleu)
d7_img = np.zeros((200, 200, 4), dtype=np.uint8)
cv2.circle(d7_img, (100, 100), 90, (220, 100, 0, 255), -1)
cv2.circle(d7_img, (100, 100), 30, (255, 255, 255, 255), -1)
cv2.imwrite(str(self.signs_dir / "D7.png"), d7_img)
self.engine = SignClassifierEngine(models_dir=self.models_dir)
def tearDown(self):
self.temp_dir.cleanup()
def test_discover_templates(self):
templates = self.engine.discover_templates(signs_dir=self.signs_dir)
self.assertEqual(len(templates), 2)
self.assertIn("C1", templates)
self.assertIn("D7", templates)
def test_train_from_svgs_and_predict(self):
self.assertFalse(self.engine.is_trained())
# Entraîner un modèle miniature (2 époques, 4 samples par classe)
result = self.engine.train_from_svgs(
signs_dir=self.signs_dir,
samples_per_class=4,
epochs=2,
batch_size=8,
learning_rate=0.005,
)
self.assertEqual(result["status"], "success")
self.assertEqual(result["num_classes"], 2)
self.assertTrue(self.engine.is_trained())
self.assertTrue(Path(result["onnx_path"]).exists())
meta = self.engine.get_metadata()
self.assertEqual(meta["num_classes"], 2)
self.assertIn("C1", meta["classes"])
self.assertIn("D7", meta["classes"])
# Tester l'inférence sur un crop rouge (devrait être C1)
red_crop = np.ones((150, 150, 3), dtype=np.uint8) * 30
cv2.circle(red_crop, (75, 75), 60, (0, 0, 220), -1)
pred = self.engine.predict(red_crop, top_k=2)
self.assertEqual(pred["status"], "success")
self.assertIsNotNone(pred["code"])
self.assertGreaterEqual(len(pred["top_matches"]), 2)
self.assertGreaterEqual(pred["confidence"], 0.0)
def test_predict_invalid_crop(self):
empty_crop = np.array([])
pred = self.engine.predict(empty_crop)
self.assertEqual(pred["status"], "error")
class SignDetectionServiceTests(TestCase):
"""Tests d'inférence du service SignDetectionService."""
@ -124,12 +278,16 @@ class SignDetectionServiceTests(TestCase):
self.assertIn("performance", result)
self.assertIn("yolo_inference_ms", result["performance"])
self.assertIn("ocr_inference_ms", result["performance"])
self.assertIn("classifier_inference_ms", result["performance"])
self.assertIn("annotated_image", result)
self.assertTrue(result["annotated_image"].startswith("data:image/jpeg;base64,"))
# Vérifier l'ordonnancement vertical
# Vérifier l'ordonnancement vertical et la présence des top_matches
orders = [p["vertical_order"] for p in result["panels"]]
self.assertEqual(orders, list(range(1, len(orders) + 1)))
for p in result["panels"]:
self.assertIn("top_matches", p)
self.assertIn("matched_by", p)
class SignAIApiAndViewsTests(APITestCase):
@ -232,7 +390,6 @@ class SignAIApiAndViewsTests(APITestCase):
self.assertTrue(data["success"])
self.assertEqual(data["panels_created"], 2)
# Vérifier en BDD
pole = SignPole.objects.get(id=data["pole_id"])
self.assertIsNotNone(pole.geom)
self.assertEqual(pole.signpanels.count(), 2)
@ -243,9 +400,7 @@ class SignAIApiAndViewsTests(APITestCase):
p2 = pole.signpanels.get(vertical_order=2)
self.assertEqual(p2.signpanel_type.code, "M2")
self.assertFalse(p2.is_compliant) # Dirty -> non-conforme
self.assertFalse(p2.is_compliant)
# Vérifier l'inspection générée
inspections = SignPanelInspection.objects.filter(asset_object_id__in=[p1.id, p2.id])
self.assertEqual(inspections.count(), 2)

View file

@ -18,7 +18,7 @@ from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .ai import SignDetectionService, SIGN_CATALOG, get_svg_url
from .ai import SignDetectionService, SIGN_CATALOG, get_svg_url, SignClassifierEngine
logger = logging.getLogger(__name__)
@ -34,6 +34,9 @@ class SignAIDemoView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["catalog_sample"] = list(SIGN_CATALOG.items())[:12]
engine = SignClassifierEngine.get_instance()
context["classifier_is_trained"] = engine.is_trained()
context["classifier_meta"] = engine.get_metadata()
return context
def post(self, request, *args, **kwargs):
@ -139,110 +142,149 @@ class QuickCreateSignWithAIView(APIView):
except (ValueError, TypeError):
return Response({'success': False, 'message': str(_("Coordonnées GPS invalides."))}, status=status.HTTP_400_BAD_REQUEST)
# 1. Recherche de la rue la plus proche
point_4326 = Point(lon, lat, srid=4326)
closest_street = SignStreet.objects.annotate(
distance=Distance('geom', point_4326)
).order_by('distance').first()
# 2. Création du Poteau / Support (SignPole)
pole_geom = Point(lon, lat, srid=4326)
pole = SignPole.objects.create(
lat=lat,
lon=lon,
geom=pole_geom,
geojson=pole_geom.geojson,
street=closest_street,
code=f"P-{int(timezone.now().timestamp())}",
status='active'
)
panels_data = request.data.get('panels', '[]')
if isinstance(panels_data, str):
try:
# 1. Recherche robuste de la rue la plus proche (SRID 3812)
closest_street = None
try:
panels_data = json.loads(panels_data)
except Exception:
panels_data = []
point_4326 = Point(lon, lat, srid=4326)
point_3812 = point_4326.transform(3812, clone=True)
closest_street = SignStreet.objects.filter(geom__isnull=False).annotate(
distance=Distance('geom', point_3812)
).order_by('distance').first()
except Exception as street_exc:
logger.warning("Impossible de calculer la rue la plus proche : %s", street_exc)
created_panels = []
uploaded_photos = request.FILES.getlist('photos')
if not closest_street:
closest_street = SignStreet.objects.first()
for idx, p_info in enumerate(panels_data):
type_code = (p_info.get('signpanel_type_code') or p_info.get('code') or 'C1').strip().upper()
ptype = SignPanelType.objects.filter(code__iexact=type_code).first()
if not ptype:
ptype = SignPanelType.objects.filter(code__iexact='C1').first()
panel_text = p_info.get('signpanel_text') or p_info.get('text') or ''
cleanliness = p_info.get('cleanliness') or 'clean'
vertical_order = p_info.get('vertical_order') or (idx + 1)
ai_detected_code = p_info.get('ai_detected_code') or type_code
ai_conf_val = p_info.get('ai_confidence', 0.95)
try:
ai_confidence = float(ai_conf_val)
except (ValueError, TypeError):
ai_confidence = 0.95
is_compliant = (cleanliness == 'clean')
panel_status = 'inactive' if cleanliness == 'missing' else 'active'
panel = SignPanel.objects.create(
pole=pole,
street=closest_street,
signpanel_type=ptype,
signpanel_text=panel_text,
vertical_order=vertical_order,
lat=lat,
lon=lon,
geom=pole_geom,
geojson=pole_geom.geojson,
code=f"PAN-{pole.id}-{vertical_order}",
status=panel_status,
inspection_status='compliant' if is_compliant else 'non_compliant',
last_inspection_date=timezone.now()
)
# Création de l'inspection initiale
result_status = 'compliant' if is_compliant else ('missing' if cleanliness == 'missing' else 'damaged')
ct = ContentType.objects.get_for_model(SignPanel)
inspection = SignPanelInspection.objects.create(
asset_content_type=ct,
asset_object_id=panel.pk,
inspector=request.user,
inspection_date=timezone.now(),
result_status=result_status,
nature='visual',
inspector_role='observer',
verified_signpanel_type=ptype,
verified_signpanel_text=panel_text,
verified_cleanliness=cleanliness,
ai_detected_code=ai_detected_code,
ai_confidence=ai_confidence,
apply_sign_updates_to_asset=True,
notes="Création initiale assistée par IA sur le terrain."
)
for photo in uploaded_photos:
InspectionDocument.objects.create(
inspection=inspection,
file=photo
if not closest_street:
# Créer une rue par défaut si la base de données ne contient aucune rue
closest_street = SignStreet.objects.create(
code="RUE-DEFAUT",
name_fr="Rue non spécifiée",
name_nl="Niet gespecificeerde straat"
)
created_panels.append({
'id': panel.id,
'code': panel.code,
'type_code': ptype.code if ptype else type_code,
'text': panel_text,
'vertical_order': vertical_order
# Catégories d'assets pour la thématique signalisation
from common.models import Thematic
from assets.models import AssetCategory
from sign.ai.catalog import SIGN_CATALOG
sign_thematic = Thematic.objects.filter(code='sign').first()
cat_poles = AssetCategory.objects.filter(thematic=sign_thematic, code='sign_poles').first() if sign_thematic else None
cat_panels = AssetCategory.objects.filter(thematic=sign_thematic, code='sign_panels').first() if sign_thematic else None
# 2. Création du Poteau / Support (SignPole)
pole = SignPole.objects.create(
lat=lat,
lon=lon,
street=closest_street,
category=cat_poles,
code=f"P-{int(timezone.now().timestamp())}",
status='active'
)
panels_data = request.data.get('panels', '[]')
if isinstance(panels_data, str):
try:
panels_data = json.loads(panels_data)
except Exception:
panels_data = []
created_panels = []
uploaded_photos = request.FILES.getlist('photos')
for idx, p_info in enumerate(panels_data):
type_code = (p_info.get('signpanel_type_code') or p_info.get('code') or 'C1').strip().upper()
ptype = SignPanelType.objects.filter(code__iexact=type_code).first()
if not ptype:
cat_info = SIGN_CATALOG.get(type_code, {})
p_name_fr = cat_info.get('name_fr', type_code)
p_name_nl = cat_info.get('name_nl', type_code)
ptype, _created = SignPanelType.objects.get_or_create(
code=type_code,
defaults={'name_fr': p_name_fr, 'name_nl': p_name_nl}
)
panel_text = p_info.get('signpanel_text') or p_info.get('text') or ''
cleanliness = p_info.get('cleanliness') or 'clean'
vertical_order = p_info.get('vertical_order') or (idx + 1)
ai_detected_code = p_info.get('ai_detected_code') or type_code
ai_conf_val = p_info.get('ai_confidence', 0.95)
try:
ai_confidence = float(ai_conf_val)
except (ValueError, TypeError):
ai_confidence = 0.95
is_compliant = (cleanliness == 'clean')
panel_status = 'inactive' if cleanliness == 'missing' else 'active'
panel = SignPanel.objects.create(
pole=pole,
street=closest_street,
category=cat_panels,
signpanel_type=ptype,
signpanel_text=panel_text,
vertical_order=vertical_order,
lat=lat,
lon=lon,
code=f"PAN-{pole.id}-{vertical_order}",
status=panel_status,
inspection_status='compliant' if is_compliant else 'non_compliant',
last_inspection_date=timezone.now()
)
# Création de l'inspection initiale
result_status = 'compliant' if is_compliant else ('missing' if cleanliness == 'missing' else 'damaged')
ct = ContentType.objects.get_for_model(SignPanel)
inspector = request.user if (hasattr(request, 'user') and request.user.is_authenticated) else None
inspection = SignPanelInspection.objects.create(
asset_content_type=ct,
asset_object_id=panel.pk,
inspector=inspector,
inspection_date=timezone.now(),
result_status=result_status,
nature='visual',
inspector_role='observer',
verified_signpanel_type=ptype,
verified_signpanel_text=panel_text,
verified_cleanliness=cleanliness,
ai_detected_code=ai_detected_code,
ai_confidence=ai_confidence,
apply_sign_updates_to_asset=True,
notes="Création initiale assistée par IA sur le terrain."
)
for photo in uploaded_photos:
InspectionDocument.objects.create(
inspection=inspection,
file=photo
)
created_panels.append({
'id': panel.id,
'code': panel.code,
'type_code': ptype.code if ptype else type_code,
'text': panel_text,
'vertical_order': vertical_order
})
return Response({
'success': True,
'pole_id': pole.id,
'pole_code': pole.code,
'lat': lat,
'lon': lon,
'panels': created_panels,
'panels_created': len(created_panels),
'message': str(_("%(count)d panneau(x) créé(s) avec succès.") % {'count': len(created_panels)})
})
return Response({
'success': True,
'pole_id': pole.id,
'pole_code': pole.code,
'panels': created_panels,
'panels_created': len(created_panels),
'message': str(_("%(count)d panneau(x) créé(s) avec succès.") % {'count': len(created_panels)})
})
except Exception as exc:
logger.error("Erreur lors de la création rapide du panneau : %s", exc, exc_info=True)
return Response({
'success': False,
'message': f"Erreur lors de la création : {str(exc)}"
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)