feat: implement traffic sign classifier with ONNX model support and updated OCR detection logic
This commit is contained in:
parent
814a04f1ac
commit
d931827c84
16 changed files with 2470 additions and 388 deletions
|
|
@ -1165,7 +1165,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAssetClick(feature) {
|
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);
|
openInspectionModal(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1356,7 +1363,6 @@
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
window.openEditInspectionFromMap = function() {
|
window.openEditInspectionFromMap = function() {
|
||||||
if (!window.currentMapInspectionId) return;
|
if (!window.currentMapInspectionId) return;
|
||||||
const mapModalEl = document.getElementById('mapInspectionDetailModal');
|
const mapModalEl = document.getElementById('mapInspectionDetailModal');
|
||||||
|
|
@ -1377,8 +1383,14 @@
|
||||||
if (!modalEl) return;
|
if (!modalEl) return;
|
||||||
|
|
||||||
let ctId = props.content_type_id || props.ct_id;
|
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')) {
|
if (!ctId) {
|
||||||
ctId = window.PARKING_SPOT_CT_ID;
|
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 || '';
|
const objId = props.object_id || props.id || props.pk || '';
|
||||||
document.getElementById('insp_content_type_id').value = ctId || '';
|
document.getElementById('insp_content_type_id').value = ctId || '';
|
||||||
|
|
@ -1414,16 +1426,43 @@
|
||||||
|
|
||||||
// Réinitialisation de l'accumulateur de photos/fichiers
|
// Réinitialisation de l'accumulateur de photos/fichiers
|
||||||
accumulatedInspectionFiles = [];
|
accumulatedInspectionFiles = [];
|
||||||
|
window.accumulatedInspectionFiles = accumulatedInspectionFiles;
|
||||||
const previewContainer = document.getElementById('insp_photos_preview_container');
|
const previewContainer = document.getElementById('insp_photos_preview_container');
|
||||||
if (previewContainer) previewContainer.innerHTML = '';
|
if (previewContainer) previewContainer.innerHTML = '';
|
||||||
|
notifyPhotosUpdated();
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(modalEl);
|
const modal = new bootstrap.Modal(modalEl);
|
||||||
modal.show();
|
modal.show();
|
||||||
};
|
};
|
||||||
|
|
||||||
let accumulatedInspectionFiles = [];
|
let accumulatedInspectionFiles = [];
|
||||||
|
window.accumulatedInspectionFiles = accumulatedInspectionFiles;
|
||||||
let inspCameraStream = null;
|
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() {
|
function initInspectionPhotoHandler() {
|
||||||
const btnCamera = document.getElementById('insp_btn_continuous_camera');
|
const btnCamera = document.getElementById('insp_btn_continuous_camera');
|
||||||
const btnGallery = document.getElementById('insp_btn_gallery_files');
|
const btnGallery = document.getElementById('insp_btn_gallery_files');
|
||||||
|
|
@ -1571,10 +1610,10 @@
|
||||||
|
|
||||||
function renderPhotoPreviews() {
|
function renderPhotoPreviews() {
|
||||||
const previewContainer = document.getElementById('insp_photos_preview_container');
|
const previewContainer = document.getElementById('insp_photos_preview_container');
|
||||||
if (!previewContainer) return;
|
if (previewContainer) previewContainer.innerHTML = '';
|
||||||
previewContainer.innerHTML = '';
|
|
||||||
|
|
||||||
accumulatedInspectionFiles.forEach((file, index) => {
|
accumulatedInspectionFiles.forEach((file, index) => {
|
||||||
|
if (!previewContainer) return;
|
||||||
const col = document.createElement('div');
|
const col = document.createElement('div');
|
||||||
col.className = 'col-4 col-sm-3 position-relative mt-2';
|
col.className = 'col-4 col-sm-3 position-relative mt-2';
|
||||||
|
|
||||||
|
|
@ -1614,7 +1653,11 @@
|
||||||
col.appendChild(card);
|
col.appendChild(card);
|
||||||
previewContainer.appendChild(col);
|
previewContainer.appendChild(col);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.renderInspPhotosPreview = renderPhotoPreviews;
|
||||||
|
notifyPhotosUpdated();
|
||||||
}
|
}
|
||||||
|
window.renderInspPhotosPreview = renderPhotoPreviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSignPhotos = [];
|
let newSignPhotos = [];
|
||||||
|
|
@ -1854,16 +1897,39 @@
|
||||||
body: formData
|
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) {
|
if (data.success) {
|
||||||
modal.hide();
|
modal.hide();
|
||||||
alert(data.message || 'Panneaux créés avec succès !');
|
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();
|
loadAssets();
|
||||||
|
loadInspectionsHistory();
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
|
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
alert('Erreur réseau : ' + err.message);
|
alert('Erreur lors de la création : ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
btnSubmit.disabled = false;
|
btnSubmit.disabled = false;
|
||||||
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
|
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,8 @@ def sign_assets_geojson(request):
|
||||||
"id": obj.id,
|
"id": obj.id,
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"id": obj.id,
|
||||||
|
"object_id": obj.id,
|
||||||
"code": obj.code,
|
"code": obj.code,
|
||||||
"name": obj.code,
|
"name": obj.code,
|
||||||
"category": obj.category.get_name() if obj.category else None,
|
"category": obj.category.get_name() if obj.category else None,
|
||||||
|
|
|
||||||
|
|
@ -15,35 +15,84 @@
|
||||||
<!-- 1. Assistance IA : Vérification instantanée par photo -->
|
<!-- 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 border border-primary-subtle bg-primary-subtle bg-opacity-10 mb-3 rounded-3">
|
||||||
<div class="card-body p-2.5">
|
<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>
|
<div>
|
||||||
<span class="fw-bold text-primary small d-flex align-items-center gap-1">
|
<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" %}
|
<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>
|
</span>
|
||||||
<div class="text-muted" style="font-size: 0.75rem;">
|
<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>
|
||||||
</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">
|
<div class="btn-group shadow-sm">
|
||||||
<i class="bi bi-camera-fill"></i>
|
<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">
|
||||||
<span>{% translate "Détecter par IA" %}</span>
|
<i class="bi bi-stars"></i>
|
||||||
</button>
|
<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>
|
</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">
|
<input type="file" id="sign_ai_file_input" accept="image/*" capture="environment" class="d-none">
|
||||||
|
|
||||||
<!-- Résultat de détection IA -->
|
<!-- Résultat de détection IA -->
|
||||||
<div id="sign_ai_result_container" class="mt-2 d-none">
|
<div id="sign_ai_result_container" class="mt-2 d-none">
|
||||||
<div id="sign_ai_spinner" class="text-center py-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>
|
<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>
|
||||||
<div id="sign_ai_badge_container"></div>
|
<div id="sign_ai_badge_container"></div>
|
||||||
</div>
|
</div>
|
||||||
</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_detected_code" id="insp_ai_detected_code" value="">
|
||||||
<input type="hidden" name="ai_confidence" id="insp_ai_confidence" 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 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 aiFileInput = document.getElementById('sign_ai_file_input');
|
||||||
const aiResultContainer = document.getElementById('sign_ai_result_container');
|
const aiResultContainer = document.getElementById('sign_ai_result_container');
|
||||||
const aiSpinner = document.getElementById('sign_ai_spinner');
|
const aiSpinner = document.getElementById('sign_ai_spinner');
|
||||||
|
const aiSpinnerText = document.getElementById('sign_ai_spinnerText');
|
||||||
const aiBadgeContainer = document.getElementById('sign_ai_badge_container');
|
const aiBadgeContainer = document.getElementById('sign_ai_badge_container');
|
||||||
|
|
||||||
if (btnAIDetect && aiFileInput) {
|
function getAvailablePhotos() {
|
||||||
btnAIDetect.addEventListener('click', function() {
|
if (typeof window.getInspectionPhotos === 'function') {
|
||||||
aiFileInput.click();
|
return window.getInspectionPhotos();
|
||||||
});
|
}
|
||||||
|
return (window.accumulatedInspectionFiles || []).filter(f => f && f.type && f.type.startsWith('image/'));
|
||||||
|
}
|
||||||
|
|
||||||
aiFileInput.addEventListener('change', async function() {
|
function updateAiPhotosIndicator() {
|
||||||
if (!this.files || !this.files.length) return;
|
const photos = getAvailablePhotos();
|
||||||
const file = this.files[0];
|
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 (dropCount) dropCount.textContent = photos.length;
|
||||||
if (aiSpinner) aiSpinner.classList.remove('d-none');
|
|
||||||
if (aiBadgeContainer) aiBadgeContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const formData = new FormData();
|
if (photos.length === 0) {
|
||||||
formData.append('image', file);
|
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 {
|
window.addEventListener('inspection-photos-updated', updateAiPhotosIndicator);
|
||||||
const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || '';
|
// Mise à jour immédiate à l'affichage
|
||||||
const response = await fetch('/sign/api/detect/', {
|
setTimeout(updateAiPhotosIndicator, 50);
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRFToken': csrfToken,
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (aiSpinner) aiSpinner.classList.add('d-none');
|
async function runSignAiDetection(file) {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (aiResultContainer) aiResultContainer.classList.remove('d-none');
|
||||||
throw new Error('Erreur réseau (' + response.status + ')');
|
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();
|
const formData = new FormData();
|
||||||
if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) {
|
formData.append('image', file);
|
||||||
// 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
|
try {
|
||||||
const inputAiCode = document.getElementById('insp_ai_detected_code');
|
const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || '';
|
||||||
const inputAiConf = document.getElementById('insp_ai_confidence');
|
const response = await fetch('/sign/api/detect/', {
|
||||||
if (inputAiCode) inputAiCode.value = detectedCode;
|
method: 'POST',
|
||||||
if (inputAiConf) inputAiConf.value = topPanel.confidence || 0;
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
// Comparaison avec le type actuel
|
if (aiSpinner) aiSpinner.classList.add('d-none');
|
||||||
const currentSelectedOpt = selectType?.options[selectType.selectedIndex];
|
|
||||||
const currentCode = currentSelectedOpt?.getAttribute('data-code') || '{{ asset.signpanel_type.code|default:"" }}';
|
|
||||||
const isMatch = (currentCode.toUpperCase() === detectedCode.toUpperCase());
|
|
||||||
|
|
||||||
let badgeHtml = '';
|
if (!response.ok) {
|
||||||
if (isMatch) {
|
throw new Error('Erreur réseau (' + response.status + ')');
|
||||||
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">
|
const data = await response.json();
|
||||||
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
|
if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) {
|
||||||
<div>
|
// Panneau principal détecté
|
||||||
<div class="fw-bold small text-success">✓ Conforme : ${detectedCode} (${confidence}%)</div>
|
const topPanel = data.panels[0];
|
||||||
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
|
const detectedCode = topPanel.code;
|
||||||
</div>
|
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>
|
</div>
|
||||||
<span class="badge bg-success">Validé</span>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
<span class="badge bg-success">Validé</span>
|
||||||
} else {
|
</div>
|
||||||
badgeHtml = `
|
`;
|
||||||
<div class="alert alert-warning d-flex align-items-center justify-content-between p-2 mb-1 rounded-3">
|
} else {
|
||||||
<div class="d-flex align-items-center gap-2">
|
badgeHtml = `
|
||||||
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
|
<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>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="fw-bold small text-dark">Détecté : <span class="text-primary">${detectedCode}</span> (${confidence}%)</div>
|
<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 style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
|
<div>
|
||||||
</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>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-primary py-1 px-2" id="btn_apply_ai_code" data-code="${detectedCode}">
|
</div>
|
||||||
Appliquer
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
}
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (detectedText) {
|
if (detectedText) {
|
||||||
badgeHtml += `
|
badgeHtml += `
|
||||||
<div class="d-flex align-items-center justify-content-between p-2 bg-light rounded border mt-1">
|
<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"></i>Texte OCR : <strong>« ${detectedText} »</strong></small>
|
<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}">
|
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2" id="btn_apply_ai_text" data-text="${detectedText}">
|
||||||
Insérer
|
Insérer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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 = `
|
aiBadgeContainer.innerHTML = `
|
||||||
<div class="alert alert-danger p-2 mb-0 small">
|
<div class="alert alert-secondary p-2 mb-0 small">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>Erreur lors de l'analyse : ${err.message}
|
<i class="bi bi-info-circle me-1"></i>Aucun panneau reconnu avec certitude sur cette image.
|
||||||
</div>
|
</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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,8 @@
|
||||||
<script>
|
<script>
|
||||||
window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";
|
window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";
|
||||||
window.PARKING_SPOT_CT_ID = {{ parking_spot_ct_id|default:"null" }};
|
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_CONTENT_TYPE_ID = {{ preselect_ct_id|safe }};
|
||||||
window.PRESELECT_OBJECT_ID = {{ preselect_obj_id|safe }};
|
window.PRESELECT_OBJECT_ID = {{ preselect_obj_id|safe }};
|
||||||
window.PRESELECT_LON = {{ preselect_lon|safe }};
|
window.PRESELECT_LON = {{ preselect_lon|safe }};
|
||||||
|
|
|
||||||
|
|
@ -207,8 +207,10 @@ def quick_inspection_map(request):
|
||||||
is_mobile = request.path.startswith('/mobile/')
|
is_mobile = request.path.startswith('/mobile/')
|
||||||
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
|
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
|
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_ct_id = request.GET.get('content_type_id')
|
||||||
preselect_obj_id = request.GET.get('object_id')
|
preselect_obj_id = request.GET.get('object_id')
|
||||||
|
|
@ -244,6 +246,8 @@ def quick_inspection_map(request):
|
||||||
'thematics': thematics,
|
'thematics': thematics,
|
||||||
'categories': categories,
|
'categories': categories,
|
||||||
'parking_spot_ct_id': parking_spot_ct_id,
|
'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_ct_id': preselect_ct_id or "null",
|
||||||
'preselect_obj_id': preselect_obj_id or "null",
|
'preselect_obj_id': preselect_obj_id or "null",
|
||||||
'preselect_mission_id': mission_id or "null",
|
'preselect_mission_id': mission_id or "null",
|
||||||
|
|
@ -359,11 +363,41 @@ def create_inspection_api(request):
|
||||||
inspection.save()
|
inspection.save()
|
||||||
elif is_signpanel:
|
elif is_signpanel:
|
||||||
from assets.models import SignPanelType
|
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_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_text = request.POST.get('verified_signpanel_text')
|
||||||
verified_cleanliness = request.POST.get('verified_cleanliness', 'clean')
|
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_raw = request.POST.get('ai_confidence')
|
||||||
ai_conf = float(ai_conf_raw) if ai_conf_raw else None
|
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
|
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(NatureRWIZ).id)
|
||||||
ct_ids.add(ContentType.objects.get_for_model(NatureRWIAsset).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:
|
except Thematic.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
from .detector import SignDetectionService
|
from .detector import SignDetectionService
|
||||||
from .catalog import SIGN_CATALOG, get_svg_url, match_sign_from_ocr, classify_sign_visual
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,51 @@ SIGN_CATALOG = {
|
||||||
"shape": "rectangle",
|
"shape": "rectangle",
|
||||||
"keywords": ["fin zone 30", "einde zone 30"],
|
"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": {
|
"F12A": {
|
||||||
"name_fr": "Début d'une zone résidentielle / zone de rencontre",
|
"name_fr": "Début d'une zone résidentielle / zone de rencontre",
|
||||||
"name_nl": "Begin van een woonerf of erf",
|
"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:
|
def get_svg_url(sign_code: str) -> str:
|
||||||
"""
|
"""
|
||||||
Retourne l'URL du fichier SVG statique pour un code de panneau donné.
|
Retourne l'URL du fichier SVG ou PNG 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).
|
Vérifie l'existence sous assets/road_signs/2025/ (.svg puis .png) et gère le fallback.
|
||||||
"""
|
"""
|
||||||
if not sign_code:
|
if not sign_code:
|
||||||
return ""
|
return ""
|
||||||
code_upper = sign_code.strip().upper()
|
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"
|
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é.
|
Analyse un texte OCR et tente d'associer un type de panneau normalisé.
|
||||||
Exemples:
|
Exemples:
|
||||||
- "STOP" -> B5
|
- "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
|
- "SAUF RIVERAINS" -> M2
|
||||||
- "300 m" -> M1 (avec valeur 300)
|
- "300 M" -> M1 (Distance)
|
||||||
- "50" (seul dans un cercle) -> C43 (avec valeur 50)
|
|
||||||
"""
|
"""
|
||||||
if not ocr_text:
|
if not ocr_text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cleaned = ocr_text.strip().upper()
|
cleaned = ocr_text.strip().upper()
|
||||||
|
cleaned_inline = re.sub(r"\s+", " ", cleaned)
|
||||||
|
|
||||||
# 1. STOP
|
# 1. STOP
|
||||||
if "STOP" in cleaned:
|
if "STOP" in cleaned_inline:
|
||||||
return {
|
return {
|
||||||
"code": "B5",
|
"code": "B5",
|
||||||
"confidence": 0.96,
|
"confidence": 0.96,
|
||||||
|
|
@ -301,96 +367,197 @@ def match_sign_from_ocr(ocr_text: str) -> Optional[Dict[str, Any]]:
|
||||||
"matched_by": "text_stop",
|
"matched_by": "text_stop",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Zone 30 / Zone 20 / Fin de zone
|
# 2. TOUS LES PANNEAUX DE ZONE ("ZONE ...")
|
||||||
if "ZONE" in cleaned:
|
if "ZONE" in cleaned_inline:
|
||||||
if "FIN" in cleaned or "EINDE" in cleaned:
|
if "FIN" in cleaned_inline or "EINDE" in cleaned_inline:
|
||||||
return {
|
return {
|
||||||
"code": "F4B",
|
"code": "F4B",
|
||||||
"confidence": 0.92,
|
"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"),
|
"svg_url": get_svg_url("F4B"),
|
||||||
"matched_by": "text_end_zone",
|
"matched_by": "text_end_zone",
|
||||||
}
|
}
|
||||||
# Détection vitesse de zone
|
# Zone Stationnement / Parking ("ZONE P", "ZONE ... CARTE DE STATIONNEMENT", "ZONE ... PARKEERKAART", "ZONE ... DISQUE")
|
||||||
speed_match = re.search(r"\b(20|30|50)\b", cleaned)
|
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:
|
if speed_match:
|
||||||
speed = int(speed_match.group(1))
|
speed = int(speed_match.group(1))
|
||||||
code = "F4A"
|
code = "F4A"
|
||||||
return {
|
return {
|
||||||
"code": code,
|
"code": code,
|
||||||
"confidence": 0.94,
|
"confidence": 0.95,
|
||||||
"data": SIGN_CATALOG["F4A"],
|
"data": SIGN_CATALOG.get("F4A", {"name_fr": f"Zone {speed}", "name_nl": f"Zone {speed}", "category": "indication"}),
|
||||||
"svg_url": get_svg_url(code),
|
"svg_url": get_svg_url(code),
|
||||||
"value": speed,
|
"value": speed,
|
||||||
"matched_by": "text_zone_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 ...")
|
# 3. VITESSE MAXIMALE AUTORISÉE (C43 : "50", "50 km", "50 km/h", "30 km", "70 km/h", "90", "120")
|
||||||
if re.search(r"\b(SAUF|EXCEPTE|EXCEPTE|UITGEZONDERD|RIVERAINS?|AANGELANDEN?|VELOS?|FIETS)\b", cleaned, re.IGNORECASE):
|
# 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 {
|
return {
|
||||||
"code": "M2",
|
"code": "M2",
|
||||||
"confidence": 0.90,
|
"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"),
|
"svg_url": get_svg_url("M2"),
|
||||||
"extracted_text": ocr_text.strip(),
|
"extracted_text": ocr_text.strip(),
|
||||||
"matched_by": "text_exception",
|
"matched_by": "text_exception",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Panonceaux de distance ("300 m", "50m", "1.5 km")
|
# 5. Panonceaux de distance ("300 m", "50 m", "1.5 km", "2.0 km")
|
||||||
dist_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*(M|KM|METRES?|METERS?)\b", cleaned)
|
dist_match = re.search(r"\b(\d+(?:[.,]\d+)?)\s*(M|METRES?|METERS?)\b|\b(\d+[.,]\d+)\s*(KM)\b", cleaned_inline)
|
||||||
if dist_match:
|
if dist_match:
|
||||||
val_str = dist_match.group(1).replace(",", ".")
|
val_str = (dist_match.group(1) or dist_match.group(3)).replace(",", ".")
|
||||||
unit = dist_match.group(2).lower()
|
unit = (dist_match.group(2) or dist_match.group(4) or "m").lower()
|
||||||
val = float(val_str)
|
val = float(val_str)
|
||||||
if unit == "km":
|
if unit == "km":
|
||||||
val *= 1000.0
|
val *= 1000.0
|
||||||
return {
|
return {
|
||||||
"code": "M1",
|
"code": "M1",
|
||||||
"confidence": 0.88,
|
"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"),
|
"svg_url": get_svg_url("M1"),
|
||||||
"value": val,
|
"value": val,
|
||||||
"extracted_text": ocr_text.strip(),
|
"extracted_text": ocr_text.strip(),
|
||||||
"matched_by": "text_distance",
|
"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")
|
# 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:
|
if ton_match:
|
||||||
val = float(ton_match.group(1).replace(",", "."))
|
val = float(ton_match.group(1).replace(",", "."))
|
||||||
return {
|
return {
|
||||||
"code": "C21",
|
"code": "C21",
|
||||||
"confidence": 0.88,
|
"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"),
|
"svg_url": get_svg_url("C21"),
|
||||||
"value": val,
|
"value": val,
|
||||||
"extracted_text": ocr_text.strip(),
|
"extracted_text": ocr_text.strip(),
|
||||||
"matched_by": "text_tonnage",
|
"matched_by": "text_tonnage",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 7. Parking PMR / Handicap
|
# 7. Parking P ("P", "PARKING", "PARKEREN" ou lettre "D" isolée due à l'OCR sur le P)
|
||||||
if re.search(r"\b(HANDICAP|PMR|HANDICAPE)\b", cleaned):
|
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 {
|
return {
|
||||||
"code": "E9B",
|
"code": "E9B",
|
||||||
"confidence": 0.91,
|
"confidence": 0.94,
|
||||||
"data": SIGN_CATALOG["E9B"],
|
"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"),
|
"svg_url": get_svg_url("E9B"),
|
||||||
"matched_by": "text_handicap",
|
"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
|
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.
|
Classifie un panneau par analyse de forme, couleur dominante (Bleu, Rouge, Jaune) et structure.
|
||||||
Permet de discriminer les panneaux sans texte :
|
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)
|
- Rond Bleu -> D7 (Piste cyclable / vélo) ou D5 (Rond-point)
|
||||||
- Triangle inversé -> B1 (Cédez le passage)
|
- Triangle inversé -> B1 (Cédez le passage)
|
||||||
- Triangle pointe en haut -> A15 (Passage piétons / Danger)
|
- Triangle pointe en haut -> A15 (Passage piétons / Danger)
|
||||||
- Octogone rouge -> B5 (STOP)
|
- Octogone rouge -> B5 (STOP)
|
||||||
- Losange jaune -> B3 (Route prioritaire)
|
- 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 cv2
|
||||||
import numpy as np
|
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
|
yellow_ratio = np.count_nonzero(yellow_mask) / total_px
|
||||||
aspect_ratio = w / float(h)
|
aspect_ratio = w / float(h)
|
||||||
|
|
||||||
# 1. PANNEAUX BLEUS (Famille D Obligation ou Famille F Indication)
|
# 1. PANNEAUX BLEUS (Famille E9 Stationnement, D Obligation ou F Indication)
|
||||||
if blue_ratio > 0.12:
|
if blue_ratio > 0.10:
|
||||||
if 0.75 <= aspect_ratio <= 1.35:
|
# Détection de texte ou lettre P / D dans l'OCR
|
||||||
# Extraction des éléments blancs centraux (pictogramme)
|
ocr_clean = ocr_text.strip().upper()
|
||||||
gray = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY)
|
if ocr_clean in ("P", "D", "🅿") or "PARKING" in ocr_clean or "PARKEREN" in ocr_clean:
|
||||||
center_crop = gray[int(h*0.18):int(h*0.82), int(w*0.18):int(w*0.82)]
|
code = "E9A"
|
||||||
white_mask = center_crop > 165
|
entry = SIGN_CATALOG.get("E9A", {})
|
||||||
white_ratio = np.count_nonzero(white_mask) / float(center_crop.size)
|
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():
|
# Panneau rectangulaire vertical (ratio largeur/hauteur < 0.88)
|
||||||
code = "E9A"
|
if aspect_ratio < 0.88:
|
||||||
entry = SIGN_CATALOG.get("E9A", {})
|
code = "E9A"
|
||||||
return {
|
entry = SIGN_CATALOG.get("E9A", {})
|
||||||
"code": code,
|
return {
|
||||||
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
|
"code": code,
|
||||||
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
|
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
|
||||||
"category": "parking",
|
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
|
||||||
"svg_url": get_svg_url(code),
|
"category": "parking",
|
||||||
"matched_by": "visual_blue_parking",
|
"svg_url": get_svg_url(code),
|
||||||
"confidence": 0.88,
|
"matched_by": "visual_blue_vertical_parking",
|
||||||
}
|
"confidence": 0.88,
|
||||||
|
}
|
||||||
|
|
||||||
# Si rond bleu : D7 (Piste cyclable / vélo) vs D5 (Rond-point) vs D1A
|
if 0.88 <= aspect_ratio <= 1.35:
|
||||||
if "VELO" in ocr_text.upper() or "FIETS" in ocr_text.upper() or white_ratio > 0.03:
|
# 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"
|
code = "D7"
|
||||||
entry = SIGN_CATALOG.get("D7", {})
|
entry = SIGN_CATALOG.get("D7", {})
|
||||||
return {
|
return {
|
||||||
|
|
@ -482,18 +671,20 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
|
||||||
"confidence": 0.90,
|
"confidence": 0.90,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
code = "D5"
|
# Carré bleu avec coins pleins -> E9A Parking
|
||||||
entry = SIGN_CATALOG.get("D5", {})
|
code = "E9A"
|
||||||
|
entry = SIGN_CATALOG.get("E9A", {})
|
||||||
return {
|
return {
|
||||||
"code": code,
|
"code": code,
|
||||||
"name_fr": entry.get("name_fr", "Sens giratoire obligatoire (Rond-point)"),
|
"name_fr": entry.get("name_fr", "Stationnement autorisé (Parking)"),
|
||||||
"name_nl": entry.get("name_nl", "Verplicht rond punt"),
|
"name_nl": entry.get("name_nl", "Parkeren toegelaten (Parking)"),
|
||||||
"category": "obligation",
|
"category": "parking",
|
||||||
"svg_url": get_svg_url(code),
|
"svg_url": get_svg_url(code),
|
||||||
"matched_by": "visual_blue_roundabout",
|
"matched_by": "visual_blue_square_parking",
|
||||||
"confidence": 0.86,
|
"confidence": 0.88,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
# Rectangle horizontal bleu
|
||||||
code = "F19"
|
code = "F19"
|
||||||
entry = SIGN_CATALOG.get("F19", {})
|
entry = SIGN_CATALOG.get("F19", {})
|
||||||
return {
|
return {
|
||||||
|
|
@ -503,7 +694,7 @@ def classify_sign_visual(crop_bgr: Any, ocr_text: str = "") -> Dict[str, Any]:
|
||||||
"category": "indication",
|
"category": "indication",
|
||||||
"svg_url": get_svg_url(code),
|
"svg_url": get_svg_url(code),
|
||||||
"matched_by": "visual_blue_rectangle",
|
"matched_by": "visual_blue_rectangle",
|
||||||
"confidence": 0.82,
|
"confidence": 0.80,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. PANNEAUX ROUGE & BLANC
|
# 2. PANNEAUX ROUGE & BLANC
|
||||||
|
|
|
||||||
547
loko/sign/ai/classifier.py
Normal file
547
loko/sign/ai/classifier.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""
|
"""
|
||||||
Service de détection et reconnaissance de panneaux de signalisation routière.
|
Service de détection et reconnaissance de panneaux de signalisation routière.
|
||||||
Combine YOLOv10-n (détection d'objets sans NMS sous ONNX Runtime)
|
Combine :
|
||||||
et PaddleOCR / RapidOCR (lecture de texte de panonceaux sous ONNX Runtime).
|
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 os
|
||||||
import io
|
import io
|
||||||
|
|
@ -18,6 +20,7 @@ from PIL import Image, ImageOps
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from .catalog import SIGN_CATALOG, get_svg_url, match_sign_from_ocr
|
from .catalog import SIGN_CATALOG, get_svg_url, match_sign_from_ocr
|
||||||
|
from .classifier import SignClassifierEngine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -68,7 +71,7 @@ def letterbox(
|
||||||
class SignDetectionService:
|
class SignDetectionService:
|
||||||
"""
|
"""
|
||||||
Service Singleton pour l'inférence IA des panneaux de signalisation.
|
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
|
_instance: Optional["SignDetectionService"] = None
|
||||||
|
|
||||||
|
|
@ -81,6 +84,7 @@ class SignDetectionService:
|
||||||
Path(settings.BASE_DIR) / "sign" / "ai" / "models"
|
Path(settings.BASE_DIR) / "sign" / "ai" / "models"
|
||||||
)
|
)
|
||||||
self.yolo_model_path = Path(self.model_dir) / "yolov10n.onnx"
|
self.yolo_model_path = Path(self.model_dir) / "yolov10n.onnx"
|
||||||
|
self.classifier_engine = SignClassifierEngine.get_instance()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls) -> "SignDetectionService":
|
def get_instance(cls) -> "SignDetectionService":
|
||||||
|
|
@ -315,8 +319,11 @@ class SignDetectionService:
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Pipeline complet d'analyse d'une image de signalisation.
|
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
|
Combine :
|
||||||
et l'image annotée en base64.
|
- 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()
|
total_start = time.perf_counter()
|
||||||
cv2_img, pil_img = self.load_image(image_input)
|
cv2_img, pil_img = self.load_image(image_input)
|
||||||
|
|
@ -325,14 +332,11 @@ class SignDetectionService:
|
||||||
# 1. Détection YOLO
|
# 1. Détection YOLO
|
||||||
yolo_boxes, yolo_ms = self.detect_yolo_boxes(cv2_img, confidence_threshold=confidence_threshold)
|
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 = []
|
candidate_boxes = []
|
||||||
if yolo_boxes:
|
if yolo_boxes:
|
||||||
candidate_boxes = yolo_boxes
|
candidate_boxes = yolo_boxes
|
||||||
else:
|
else:
|
||||||
# Fallback de cadrage intelligent : pleine image + zone centrale
|
# Fallback de cadrage intelligent : pleine image
|
||||||
candidate_boxes.append({
|
candidate_boxes.append({
|
||||||
"bbox": [0, 0, img_w, img_h],
|
"bbox": [0, 0, img_w, img_h],
|
||||||
"confidence": 0.50,
|
"confidence": 0.50,
|
||||||
|
|
@ -340,8 +344,9 @@ class SignDetectionService:
|
||||||
"class_name": "panneau_principal",
|
"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_ocr_ms = 0.0
|
||||||
|
total_classifier_ms = 0.0
|
||||||
detected_panels = []
|
detected_panels = []
|
||||||
|
|
||||||
for idx, det in enumerate(candidate_boxes):
|
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)
|
ocr_text, ocr_lines, ocr_ms = self.extract_ocr_from_crop(cv2_img, bbox)
|
||||||
total_ocr_ms += ocr_ms
|
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
|
# Tentative d'identification via l'OCR
|
||||||
matched = match_sign_from_ocr(ocr_text)
|
matched = match_sign_from_ocr(ocr_text)
|
||||||
|
|
||||||
# Heuristique basée sur la classe COCO si disponible
|
|
||||||
code = None
|
code = None
|
||||||
name_fr = ""
|
name_fr = ""
|
||||||
name_nl = ""
|
name_nl = ""
|
||||||
|
|
@ -360,22 +374,22 @@ class SignDetectionService:
|
||||||
svg_url = ""
|
svg_url = ""
|
||||||
matched_by = "detection_generic"
|
matched_by = "detection_generic"
|
||||||
val = None
|
val = None
|
||||||
|
final_confidence = det["confidence"]
|
||||||
|
top_matches = classifier_pred.get("top_matches", [])
|
||||||
|
|
||||||
if matched:
|
# --- MOTEUR DE FUSION MULTI-SIGNAUX (NEURONAL + OCR + FORME/COULEUR + YOLO) ---
|
||||||
code = matched["code"]
|
top_codes = [m["code"] for m in top_matches]
|
||||||
name_fr = matched["data"]["name_fr"]
|
primary_nn_code = classifier_pred.get("code")
|
||||||
name_nl = matched["data"]["name_nl"]
|
|
||||||
category = matched["data"]["category"]
|
# 1. Détections directes YOLO (Objets spécifiques)
|
||||||
svg_url = matched["svg_url"]
|
if det["class_name"] == "stop sign":
|
||||||
matched_by = matched["matched_by"]
|
|
||||||
val = matched.get("value")
|
|
||||||
elif det["class_name"] == "stop sign":
|
|
||||||
code = "B5"
|
code = "B5"
|
||||||
name_fr = "Arrêt obligatoire (STOP)"
|
name_fr = "Arrêt obligatoire (STOP)"
|
||||||
name_nl = "Verplichte stop (STOP)"
|
name_nl = "Verplichte stop (STOP)"
|
||||||
category = "priority"
|
category = "priority"
|
||||||
svg_url = get_svg_url("B5")
|
svg_url = get_svg_url("B5")
|
||||||
matched_by = "yolo_stop_sign"
|
matched_by = "yolo_stop_sign"
|
||||||
|
final_confidence = max(final_confidence, 0.96)
|
||||||
elif det["class_name"] == "traffic light":
|
elif det["class_name"] == "traffic light":
|
||||||
code = "SIGNALISATION_LUMINEUSE"
|
code = "SIGNALISATION_LUMINEUSE"
|
||||||
name_fr = "Feux de signalisation"
|
name_fr = "Feux de signalisation"
|
||||||
|
|
@ -383,44 +397,112 @@ class SignDetectionService:
|
||||||
category = "trafficlights"
|
category = "trafficlights"
|
||||||
svg_url = "/static/assets/traffic_light_icon.svg"
|
svg_url = "/static/assets/traffic_light_icon.svg"
|
||||||
matched_by = "yolo_traffic_light"
|
matched_by = "yolo_traffic_light"
|
||||||
else:
|
final_confidence = max(final_confidence, 0.95)
|
||||||
# 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"]
|
|
||||||
|
|
||||||
# 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
|
db_panel_type_id = None
|
||||||
try:
|
try:
|
||||||
from sign.models import SignPanelType
|
from sign.models import SignPanelType
|
||||||
db_type = SignPanelType.objects.filter(code__iexact=code).first()
|
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:
|
if db_type:
|
||||||
db_panel_type_id = db_type.id
|
db_panel_type_id = db_type.id
|
||||||
name_fr = db_type.name_fr or name_fr
|
name_fr = getattr(db_type, "name_fr", None) or name_fr
|
||||||
name_nl = db_type.name_nl or name_nl
|
name_nl = getattr(db_type, "name_nl", None) or name_nl
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
detected_panels.append({
|
detected_panels.append({
|
||||||
"id": idx + 1,
|
"id": idx + 1,
|
||||||
"bbox": bbox,
|
"bbox": bbox,
|
||||||
"confidence": det["confidence"],
|
"confidence": round(final_confidence, 3),
|
||||||
"yolo_class": det["class_name"],
|
"yolo_class": det["class_name"],
|
||||||
"code": code,
|
"code": code,
|
||||||
"name_fr": name_fr,
|
"name_fr": name_fr,
|
||||||
|
|
@ -428,10 +510,12 @@ class SignDetectionService:
|
||||||
"category": category,
|
"category": category,
|
||||||
"svg_url": svg_url,
|
"svg_url": svg_url,
|
||||||
"matched_by": matched_by,
|
"matched_by": matched_by,
|
||||||
|
"top_matches": top_matches,
|
||||||
"ocr_text": ocr_text,
|
"ocr_text": ocr_text,
|
||||||
"ocr_lines": ocr_lines,
|
"ocr_lines": ocr_lines,
|
||||||
"signpanel_text": ocr_text if ocr_text else None,
|
"signpanel_text": ocr_text if ocr_text else None,
|
||||||
"signpanel_value": val,
|
"signpanel_value": val,
|
||||||
|
"value": val,
|
||||||
"signpanel_type_id": db_panel_type_id,
|
"signpanel_type_id": db_panel_type_id,
|
||||||
"ymin": bbox[1],
|
"ymin": bbox[1],
|
||||||
})
|
})
|
||||||
|
|
@ -449,7 +533,7 @@ class SignDetectionService:
|
||||||
code = p["code"]
|
code = p["code"]
|
||||||
conf = int(p["confidence"] * 100)
|
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)
|
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)
|
cv2.rectangle(annotated_cv2, (x1, y1), (x2, y2), box_color, 3)
|
||||||
|
|
||||||
|
|
@ -458,7 +542,6 @@ class SignDetectionService:
|
||||||
if p["ocr_text"]:
|
if p["ocr_text"]:
|
||||||
label += f" - '{p['ocr_text'][:20]}'"
|
label += f" - '{p['ocr_text'][:20]}'"
|
||||||
|
|
||||||
# Fond du texte
|
|
||||||
(label_w, label_h), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
(label_w, label_h), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
||||||
cv2.rectangle(
|
cv2.rectangle(
|
||||||
annotated_cv2,
|
annotated_cv2,
|
||||||
|
|
@ -478,7 +561,7 @@ class SignDetectionService:
|
||||||
cv2.LINE_AA
|
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])
|
_, buffer = cv2.imencode(".jpg", annotated_cv2, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
|
||||||
annotated_base64 = "data:image/jpeg;base64," + base64.b64encode(buffer).decode("utf-8")
|
annotated_base64 = "data:image/jpeg;base64," + base64.b64encode(buffer).decode("utf-8")
|
||||||
|
|
||||||
|
|
@ -494,6 +577,7 @@ class SignDetectionService:
|
||||||
"performance": {
|
"performance": {
|
||||||
"yolo_inference_ms": round(yolo_ms, 1),
|
"yolo_inference_ms": round(yolo_ms, 1),
|
||||||
"ocr_inference_ms": round(total_ocr_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),
|
"total_processing_ms": round(total_elapsed_ms, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
loko/sign/ai/models/sign_classifier.onnx
Normal file
BIN
loko/sign/ai/models/sign_classifier.onnx
Normal file
Binary file not shown.
517
loko/sign/ai/models/sign_classifier_meta.json
Normal file
517
loko/sign/ai/models/sign_classifier_meta.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -2,52 +2,66 @@
|
||||||
Commande Django pour initialiser et vérifier l'environnement d'IA de signalisation :
|
Commande Django pour initialiser et vérifier l'environnement d'IA de signalisation :
|
||||||
- Téléchargement du modèle YOLOv10-n ONNX
|
- Téléchargement du modèle YOLOv10-n ONNX
|
||||||
- Initialisation des poids RapidOCR / PaddleOCR ONNX
|
- Initialisation des poids RapidOCR / PaddleOCR ONNX
|
||||||
|
- Vérification du classifieur neuronal de panneaux (SignClassifierEngine ONNX)
|
||||||
- Test de validation de l'inférence CPU
|
- Test de validation de l'inférence CPU
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import cv2
|
import cv2
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from sign.ai import SignDetectionService
|
from sign.ai import SignDetectionService, SignClassifierEngine
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write(self.style.NOTICE("=== Initialisation du module IA de Signalisation (StreetUp / Loko) ==="))
|
self.stdout.write(self.style.NOTICE("=== Initialisation du module IA de Signalisation (StreetUp / Loko) ==="))
|
||||||
|
|
||||||
service = SignDetectionService.get_instance()
|
service = SignDetectionService.get_instance()
|
||||||
self.stdout.write(f"1. Vérification du modèle YOLOv10-n dans : {service.yolo_model_path}")
|
self.stdout.write(f"1. Vérification du modèle YOLOv10-n dans : {service.yolo_model_path}")
|
||||||
|
|
||||||
start_dl = time.perf_counter()
|
start_dl = time.perf_counter()
|
||||||
model_path = service._ensure_yolo_model()
|
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(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()
|
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...")
|
self.stdout.write("3. Initialisation du moteur RapidOCR / PaddleOCR...")
|
||||||
ocr = service.get_ocr_engine()
|
ocr = service.get_ocr_engine()
|
||||||
self.stdout.write(self.style.SUCCESS(" ✓ Moteur OCR prêt."))
|
self.stdout.write(self.style.SUCCESS(" ✓ Moteur OCR prêt."))
|
||||||
|
|
||||||
self.stdout.write("4. Exécution du test de validation d'inférence CPU...")
|
self.stdout.write("4. Vérification du Classifieur Neuronal de Panneaux (ONNX)...")
|
||||||
# Image de test avec STOP et panonceau
|
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
|
test_img = np.ones((600, 600, 3), dtype=np.uint8) * 240
|
||||||
cv2.circle(test_img, (300, 200), 100, (0, 0, 200), -1)
|
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)
|
cv2.putText(test_img, "STOP", (240, 215), cv2.FONT_HERSHEY_SIMPLEX, 1.4, (255, 255, 255), 4)
|
||||||
|
|
||||||
res = service.analyze_image(test_img)
|
res = service.analyze_image(test_img)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f" ✓ Analyse réussie ! Panneaux détectés : {res['detected_count']}"))
|
self.stdout.write(self.style.SUCCESS(f" ✓ Analyse réussie ! Panneaux détectés : {res['detected_count']}"))
|
||||||
for p in res['panels']:
|
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']
|
perf = res['performance']
|
||||||
self.stdout.write(self.style.NOTICE(f"5. Métriques de performance CPU :"))
|
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 YOLO : {perf['yolo_inference_ms']} ms")
|
||||||
self.stdout.write(f" • Inférence OCR : {perf['ocr_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(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 ==="))
|
self.stdout.write(self.style.SUCCESS("=== Module IA opérationnel et prêt à l'emploi ==="))
|
||||||
|
|
|
||||||
111
loko/sign/management/commands/train_sign_classifier.py
Normal file
111
loko/sign/management/commands/train_sign_classifier.py
Normal 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))
|
||||||
|
|
@ -365,6 +365,27 @@
|
||||||
const confColor = confPct >= 75 ? 'bg-success' : (confPct >= 50 ? 'bg-warning' : 'bg-secondary');
|
const confColor = confPct >= 75 ? 'bg-success' : (confPct >= 50 ? 'bg-warning' : 'bg-secondary');
|
||||||
const svgSrc = p.svg_url || '';
|
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');
|
const card = document.createElement('div');
|
||||||
card.className = 'col-lg-6';
|
card.className = 'col-lg-6';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
|
|
@ -384,7 +405,10 @@
|
||||||
<h6 class="fw-bold text-dark mb-0">${p.name_fr || p.name_nl || 'Panneau détecté'}</h6>
|
<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>
|
<span class="badge ${confColor} px-2 py-1">${confPct}%</span>
|
||||||
</div>
|
</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 -->
|
<!-- Texte OCR / Panonceau -->
|
||||||
${p.ocr_text ? `
|
${p.ocr_text ? `
|
||||||
|
|
@ -409,6 +433,8 @@
|
||||||
<div class="small text-muted mt-2">
|
<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>
|
<i class="bi bi-arrows-move me-1"></i>Position verticale : <strong>#${p.vertical_order} sur le mât</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${topMatchesHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
"""
|
"""
|
||||||
Tests unitaires et d'intégration pour le module IA de reconnaissance de signalisation (sign.ai).
|
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 io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import cv2
|
import cv2
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
@ -11,11 +21,18 @@ from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework import status
|
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
|
from sign.models import SignPanelType
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -60,18 +77,58 @@ class SignCatalogMatcherTests(TestCase):
|
||||||
self.assertEqual(res["code"], "C43")
|
self.assertEqual(res["code"], "C43")
|
||||||
self.assertEqual(res["value"], 50)
|
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):
|
def test_match_tonnage_sign(self):
|
||||||
res = match_sign_from_ocr("3.5 t")
|
res = match_sign_from_ocr("3.5 t")
|
||||||
self.assertIsNotNone(res)
|
self.assertIsNotNone(res)
|
||||||
self.assertEqual(res["code"], "C21")
|
self.assertEqual(res["code"], "C21")
|
||||||
self.assertEqual(res["value"], 3.5)
|
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):
|
def test_visual_classification_blue_bike(self):
|
||||||
from sign.ai import classify_sign_visual
|
from sign.ai import classify_sign_visual
|
||||||
# Simuler un rond bleu
|
|
||||||
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
|
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), 45, (200, 50, 20), -1)
|
||||||
cv2.circle(img, (50, 50), 15, (255, 255, 255), -1) # Blanc centre
|
cv2.circle(img, (50, 50), 15, (255, 255, 255), -1)
|
||||||
res = classify_sign_visual(img)
|
res = classify_sign_visual(img)
|
||||||
self.assertEqual(res["code"], "D7")
|
self.assertEqual(res["code"], "D7")
|
||||||
self.assertEqual(res["category"], "obligation")
|
self.assertEqual(res["category"], "obligation")
|
||||||
|
|
@ -79,7 +136,7 @@ class SignCatalogMatcherTests(TestCase):
|
||||||
def test_visual_classification_yellow_priority(self):
|
def test_visual_classification_yellow_priority(self):
|
||||||
from sign.ai import classify_sign_visual
|
from sign.ai import classify_sign_visual
|
||||||
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
|
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)
|
res = classify_sign_visual(img)
|
||||||
self.assertEqual(res["code"], "B3")
|
self.assertEqual(res["code"], "B3")
|
||||||
self.assertEqual(res["category"], "priority")
|
self.assertEqual(res["category"], "priority")
|
||||||
|
|
@ -87,8 +144,8 @@ class SignCatalogMatcherTests(TestCase):
|
||||||
def test_visual_classification_sens_interdit_c1(self):
|
def test_visual_classification_sens_interdit_c1(self):
|
||||||
from sign.ai import classify_sign_visual
|
from sign.ai import classify_sign_visual
|
||||||
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
|
img = np.ones((100, 100, 3), dtype=np.uint8) * 180
|
||||||
cv2.circle(img, (50, 50), 45, (30, 30, 220), -1) # Cercle rouge
|
cv2.circle(img, (50, 50), 45, (30, 30, 220), -1)
|
||||||
cv2.rectangle(img, (15, 42), (85, 58), (255, 255, 255), -1) # Barre blanche
|
cv2.rectangle(img, (15, 42), (85, 58), (255, 255, 255), -1)
|
||||||
res = classify_sign_visual(img)
|
res = classify_sign_visual(img)
|
||||||
self.assertEqual(res["code"], "C1")
|
self.assertEqual(res["code"], "C1")
|
||||||
self.assertEqual(res["category"], "prohibition")
|
self.assertEqual(res["category"], "prohibition")
|
||||||
|
|
@ -98,6 +155,103 @@ class SignCatalogMatcherTests(TestCase):
|
||||||
self.assertEqual(get_svg_url(""), "")
|
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):
|
class SignDetectionServiceTests(TestCase):
|
||||||
"""Tests d'inférence du service SignDetectionService."""
|
"""Tests d'inférence du service SignDetectionService."""
|
||||||
|
|
||||||
|
|
@ -124,12 +278,16 @@ class SignDetectionServiceTests(TestCase):
|
||||||
self.assertIn("performance", result)
|
self.assertIn("performance", result)
|
||||||
self.assertIn("yolo_inference_ms", result["performance"])
|
self.assertIn("yolo_inference_ms", result["performance"])
|
||||||
self.assertIn("ocr_inference_ms", result["performance"])
|
self.assertIn("ocr_inference_ms", result["performance"])
|
||||||
|
self.assertIn("classifier_inference_ms", result["performance"])
|
||||||
self.assertIn("annotated_image", result)
|
self.assertIn("annotated_image", result)
|
||||||
self.assertTrue(result["annotated_image"].startswith("data:image/jpeg;base64,"))
|
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"]]
|
orders = [p["vertical_order"] for p in result["panels"]]
|
||||||
self.assertEqual(orders, list(range(1, len(orders) + 1)))
|
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):
|
class SignAIApiAndViewsTests(APITestCase):
|
||||||
|
|
@ -232,7 +390,6 @@ class SignAIApiAndViewsTests(APITestCase):
|
||||||
self.assertTrue(data["success"])
|
self.assertTrue(data["success"])
|
||||||
self.assertEqual(data["panels_created"], 2)
|
self.assertEqual(data["panels_created"], 2)
|
||||||
|
|
||||||
# Vérifier en BDD
|
|
||||||
pole = SignPole.objects.get(id=data["pole_id"])
|
pole = SignPole.objects.get(id=data["pole_id"])
|
||||||
self.assertIsNotNone(pole.geom)
|
self.assertIsNotNone(pole.geom)
|
||||||
self.assertEqual(pole.signpanels.count(), 2)
|
self.assertEqual(pole.signpanels.count(), 2)
|
||||||
|
|
@ -243,9 +400,7 @@ class SignAIApiAndViewsTests(APITestCase):
|
||||||
|
|
||||||
p2 = pole.signpanels.get(vertical_order=2)
|
p2 = pole.signpanels.get(vertical_order=2)
|
||||||
self.assertEqual(p2.signpanel_type.code, "M2")
|
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])
|
inspections = SignPanelInspection.objects.filter(asset_object_id__in=[p1.id, p2.id])
|
||||||
self.assertEqual(inspections.count(), 2)
|
self.assertEqual(inspections.count(), 2)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.permissions import IsAuthenticated
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -34,6 +34,9 @@ class SignAIDemoView(LoginRequiredMixin, TemplateView):
|
||||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["catalog_sample"] = list(SIGN_CATALOG.items())[:12]
|
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
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -139,110 +142,149 @@ class QuickCreateSignWithAIView(APIView):
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return Response({'success': False, 'message': str(_("Coordonnées GPS invalides."))}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'success': False, 'message': str(_("Coordonnées GPS invalides."))}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 1. Recherche de la rue la plus proche
|
try:
|
||||||
point_4326 = Point(lon, lat, srid=4326)
|
# 1. Recherche robuste de la rue la plus proche (SRID 3812)
|
||||||
closest_street = SignStreet.objects.annotate(
|
closest_street = None
|
||||||
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:
|
try:
|
||||||
panels_data = json.loads(panels_data)
|
point_4326 = Point(lon, lat, srid=4326)
|
||||||
except Exception:
|
point_3812 = point_4326.transform(3812, clone=True)
|
||||||
panels_data = []
|
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 = []
|
if not closest_street:
|
||||||
uploaded_photos = request.FILES.getlist('photos')
|
closest_street = SignStreet.objects.first()
|
||||||
|
|
||||||
for idx, p_info in enumerate(panels_data):
|
if not closest_street:
|
||||||
type_code = (p_info.get('signpanel_type_code') or p_info.get('code') or 'C1').strip().upper()
|
# Créer une rue par défaut si la base de données ne contient aucune rue
|
||||||
ptype = SignPanelType.objects.filter(code__iexact=type_code).first()
|
closest_street = SignStreet.objects.create(
|
||||||
if not ptype:
|
code="RUE-DEFAUT",
|
||||||
ptype = SignPanelType.objects.filter(code__iexact='C1').first()
|
name_fr="Rue non spécifiée",
|
||||||
|
name_nl="Niet gespecificeerde straat"
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
created_panels.append({
|
# Catégories d'assets pour la thématique signalisation
|
||||||
'id': panel.id,
|
from common.models import Thematic
|
||||||
'code': panel.code,
|
from assets.models import AssetCategory
|
||||||
'type_code': ptype.code if ptype else type_code,
|
from sign.ai.catalog import SIGN_CATALOG
|
||||||
'text': panel_text,
|
|
||||||
'vertical_order': vertical_order
|
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({
|
except Exception as exc:
|
||||||
'success': True,
|
logger.error("Erreur lors de la création rapide du panneau : %s", exc, exc_info=True)
|
||||||
'pole_id': pole.id,
|
return Response({
|
||||||
'pole_code': pole.code,
|
'success': False,
|
||||||
'panels': created_panels,
|
'message': f"Erreur lors de la création : {str(exc)}"
|
||||||
'panels_created': len(created_panels),
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
'message': str(_("%(count)d panneau(x) créé(s) avec succès.") % {'count': len(created_panels)})
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue