Spaces:
Sleeping
Sleeping
| <script setup> | |
| import { ref, computed, nextTick, watch } from 'vue' | |
| import { useRouter } from 'vue-router' | |
| import { useCalibrationStore } from '../stores/calibration' | |
| import { useUploadStore } from '../stores/upload' | |
| import api from '../services/api' | |
| import CalibrationArea from '@/components/CalibrationArea.vue' | |
| import FootballField from '@/components/FootballField.vue' | |
| import PlotlyChart from '@/components/PlotlyChart.vue' | |
| import fieldTestImage from '@/assets/field_test_thumb.jpg' | |
| const router = useRouter() | |
| const calibrationStore = useCalibrationStore() | |
| const uploadStore = useUploadStore() | |
| // Refs pour les sections | |
| const modeSection = ref(null) | |
| const uploadSection = ref(null) | |
| const videoTypeSection = ref(null) | |
| const parametersSection = ref(null) | |
| const processingSection = ref(null) | |
| const errorSection = ref(null) | |
| const resultsSection = ref(null) | |
| const manualSection = ref(null) | |
| // États locaux | |
| const dragActive = ref(false) | |
| const selectedTestImage = ref(null) | |
| // Image de test | |
| const testImage = ref({ | |
| id: 'field1', | |
| name: 'Terrain de test', | |
| description: 'Image d\'exemple pour tester l\'analyse', | |
| thumbnail: fieldTestImage, | |
| fullUrl: fieldTestImage | |
| }) | |
| // États pour la vue manuelle | |
| const thumbnail = ref(null) | |
| const calibrationPoints = ref({}) | |
| const selectedFieldPoint = ref(null) | |
| const calibrationLines = ref({}) | |
| const selectedFieldLine = ref(null) | |
| // Refs pour les composants manuels | |
| const calibrationArea = ref(null) | |
| const footballField = ref(null) | |
| // Computed | |
| const showUpload = computed(() => calibrationStore.mode !== null && !uploadStore.isFileSelected) | |
| const showVideoType = computed(() => uploadStore.isVideo && uploadStore.isFileSelected && uploadStore.videoType === null) | |
| const showParameters = computed(() => uploadStore.needsParameters && !showManualCalibration.value) | |
| const showProcessing = computed(() => calibrationStore.isProcessingStep) | |
| const showResults = computed(() => calibrationStore.isResultsStep) | |
| const showError = computed(() => calibrationStore.error !== null && !showManualCalibration.value) | |
| // Computed pour afficher la vue manuelle | |
| const showManualCalibration = computed(() => { | |
| return calibrationStore.isManualMode && | |
| uploadStore.isFileSelected && | |
| (uploadStore.isImage || (uploadStore.isVideo && uploadStore.videoType === 'static')) | |
| }) | |
| // Watchers pour le scroll automatique | |
| watch(() => calibrationStore.mode, async (newMode) => { | |
| if (newMode) { | |
| await nextTick() | |
| scrollToSection(uploadSection.value) | |
| } | |
| }) | |
| watch(() => uploadStore.isVideo, async (isVideo) => { | |
| if (isVideo && uploadStore.isFileSelected) { | |
| await nextTick() | |
| scrollToSection(videoTypeSection.value) | |
| } | |
| }) | |
| watch(() => uploadStore.videoType, async (videoType) => { | |
| if (calibrationStore.isManualMode) { | |
| if (videoType === 'static') { | |
| // Vidéo statique en mode manuel : charger la vue manuelle intégrée | |
| await loadThumbnail() | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } else if (videoType === 'dynamic') { | |
| // Vidéo dynamique en mode manuel : afficher message d'avertissement | |
| calibrationStore.setError("Le mode manuel pour les vidéos dynamiques n'est pas encore disponible. Cette fonctionnalité nécessite l'implémentation de nouvelles features. Veuillez utiliser le mode automatique ou sélectionner une vidéo statique.") | |
| await nextTick() | |
| scrollToSection(errorSection.value) | |
| } | |
| } else if (videoType === 'dynamic') { | |
| await nextTick() | |
| scrollToSection(parametersSection.value) | |
| } | |
| // Suppression du lancement automatique - sera géré par le watcher extractedFrame | |
| }) | |
| // Watcher spécifique pour l'extraction de frame terminée | |
| watch(() => uploadStore.extractedFrame, async (extractedFrame) => { | |
| // Lancement automatique quand la frame est extraite en mode auto + vidéo statique | |
| if (extractedFrame && | |
| calibrationStore.isAutoMode && | |
| uploadStore.isStaticVideo) { | |
| console.log('🔥 Frame extracted, starting automatic processing...') | |
| startProcessing() | |
| } | |
| }) | |
| watch(() => calibrationStore.isProcessingStep, async (isProcessing) => { | |
| if (isProcessing) { | |
| await nextTick() | |
| scrollToSection(processingSection.value) | |
| } | |
| }) | |
| watch(() => calibrationStore.isResultsStep, async (isResults) => { | |
| if (isResults) { | |
| await nextTick() | |
| scrollToSection(resultsSection.value) | |
| } | |
| }) | |
| // Watcher spécial pour les résultats en mode manuel | |
| watch(() => calibrationStore.results, async (results) => { | |
| if (results && calibrationStore.isManualMode) { | |
| await nextTick() | |
| scrollToSection(resultsSection.value) | |
| } | |
| }) | |
| watch(() => calibrationStore.error, async (error) => { | |
| if (error) { | |
| await nextTick() | |
| scrollToSection(errorSection.value) | |
| } | |
| }) | |
| // Méthodes | |
| const scrollToSection = (element) => { | |
| if (element) { | |
| element.scrollIntoView({ behavior: 'smooth', block: 'start' }) | |
| } | |
| } | |
| const selectMode = (mode) => { | |
| calibrationStore.setMode(mode) | |
| } | |
| const selectTestImage = async (testImage) => { | |
| try { | |
| selectedTestImage.value = testImage.id | |
| // Créer un objet File à partir de l'URL de l'image de test | |
| const response = await fetch(testImage.fullUrl) | |
| const blob = await response.blob() | |
| const file = new File([blob], `${testImage.name}.jpg`, { type: 'image/jpeg' }) | |
| uploadStore.setFile(file) | |
| if (calibrationStore.isManualMode) { | |
| // En mode manuel avec image, charger la vue manuelle intégrée | |
| await loadThumbnail() | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } else if (calibrationStore.isAutoMode) { | |
| // En mode auto, lancement automatique pour les images | |
| startProcessing() | |
| } | |
| } catch (error) { | |
| console.error('Erreur lors du chargement de l\'image de test:', error) | |
| // Fallback: utiliser l'URL directement pour la preview | |
| uploadStore.filePreview = testImage.fullUrl | |
| uploadStore.selectedFile = { name: `${testImage.name}.jpg`, size: 0 } | |
| uploadStore.fileType = 'image' | |
| } | |
| } | |
| const resetToStart = () => { | |
| // Réinitialiser tous les stores | |
| calibrationStore.reset() | |
| uploadStore.clearFile() | |
| // Scroll vers le haut | |
| scrollToSection(modeSection.value) | |
| } | |
| const handleFileSelect = async (event) => { | |
| const file = event.target.files[0] | |
| if (file) { | |
| uploadStore.setFile(file) | |
| if (calibrationStore.isManualMode) { | |
| if (uploadStore.isImage) { | |
| // En mode manuel avec image, charger la vue manuelle intégrée | |
| await loadThumbnail() | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } else if (uploadStore.isVideo) { | |
| // En mode manuel avec vidéo, aller au choix statique/dynamique | |
| await nextTick() | |
| scrollToSection(videoTypeSection.value) | |
| } | |
| } else if (calibrationStore.isAutoMode && uploadStore.isImage) { | |
| // En mode auto, lancement automatique pour les images | |
| startProcessing() | |
| } | |
| // Pour les vidéos en mode auto, on laisse le flow normal (choix statique/dynamique) | |
| } | |
| } | |
| const handleDrop = async (event) => { | |
| event.preventDefault() | |
| dragActive.value = false | |
| const files = event.dataTransfer.files | |
| if (files.length > 0) { | |
| uploadStore.setFile(files[0]) | |
| if (calibrationStore.isManualMode) { | |
| if (uploadStore.isImage) { | |
| // En mode manuel avec image, charger la vue manuelle intégrée | |
| await loadThumbnail() | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } else if (uploadStore.isVideo) { | |
| // En mode manuel avec vidéo, aller au choix statique/dynamique | |
| await nextTick() | |
| scrollToSection(videoTypeSection.value) | |
| } | |
| } else if (calibrationStore.isAutoMode && uploadStore.isImage) { | |
| // En mode auto, lancement automatique pour les images | |
| startProcessing() | |
| } | |
| // Pour les vidéos en mode auto, on laisse le flow normal (choix statique/dynamique) | |
| } | |
| } | |
| const handleDragOver = (event) => { | |
| event.preventDefault() | |
| dragActive.value = true | |
| } | |
| const handleDragLeave = () => { | |
| dragActive.value = false | |
| } | |
| const selectVideoType = async (type) => { | |
| await uploadStore.setVideoType(type) | |
| } | |
| const updateParameter = (param, value) => { | |
| calibrationStore.setVideoParams({ [param]: value }) | |
| } | |
| const startProcessing = async () => { | |
| calibrationStore.setProcessing(true, 'Initialisation...') | |
| try { | |
| let result | |
| if (uploadStore.isImage || uploadStore.isStaticVideo) { | |
| // Traitement d'image ou de frame extraite | |
| const fileToProcess = uploadStore.isImage ? | |
| uploadStore.selectedFile : | |
| uploadStore.extractedFrame | |
| // Vérification que le fichier existe | |
| if (!fileToProcess) { | |
| const errorMsg = uploadStore.isImage ? | |
| 'Aucun fichier image sélectionné' : | |
| 'Frame non extraite de la vidéo. Essayez de recharger la vidéo.' | |
| throw new Error(errorMsg) | |
| } | |
| // Logs détaillés pour debug | |
| console.log('🔥 File to process details:', { | |
| name: fileToProcess?.name, | |
| type: fileToProcess?.type, | |
| size: fileToProcess?.size, | |
| constructor: fileToProcess?.constructor?.name | |
| }) | |
| console.log('🔥 Processing params:', { | |
| kpThreshold: calibrationStore.videoProcessingParams.kpThreshold, | |
| lineThreshold: calibrationStore.videoProcessingParams.lineThreshold, | |
| typeOfKp: typeof calibrationStore.videoProcessingParams.kpThreshold, | |
| typeOfLine: typeof calibrationStore.videoProcessingParams.lineThreshold | |
| }) | |
| // Test de diagnostic si c'est une vidéo statique | |
| if (uploadStore.isStaticVideo && uploadStore.extractedFrame) { | |
| console.log('🔥 Testing extracted frame:', { | |
| isFile: fileToProcess instanceof File, | |
| isBlob: fileToProcess instanceof Blob, | |
| hasName: !!fileToProcess.name, | |
| hasType: !!fileToProcess.type | |
| }) | |
| } | |
| result = await processWithProgress(() => | |
| api.inferenceImage(fileToProcess, { | |
| kpThreshold: calibrationStore.videoProcessingParams.kpThreshold, | |
| lineThreshold: calibrationStore.videoProcessingParams.lineThreshold | |
| }) | |
| ) | |
| } else if (uploadStore.isDynamicVideo) { | |
| // Traitement de vidéo dynamique | |
| result = await processWithProgress(() => | |
| api.inferenceVideo(uploadStore.selectedFile, calibrationStore.videoProcessingParams) | |
| ) | |
| } | |
| // Console log de la réponse API | |
| console.log('🔥 Réponse API reçue:', result) | |
| if (result.status === 'success') { | |
| calibrationStore.setResults(result) | |
| } else if (result.status === 'failed') { | |
| throw new Error(result.message || "Échec de l'extraction des paramètres") | |
| } else { | |
| throw new Error(result.error || 'Erreur de traitement') | |
| } | |
| } catch (error) { | |
| console.error('❌ Erreur lors du traitement:', error) | |
| calibrationStore.setError(error.message) | |
| } | |
| } | |
| const processWithProgress = async (processFunction) => { | |
| // Activer le mode processing | |
| calibrationStore.setProcessing(true, 'Initialisation...') | |
| const tasks = [ | |
| 'Chargement du fichier...', | |
| 'Détection des lignes du terrain...', | |
| 'Analyse des points clés...', | |
| 'Calcul des paramètres de caméra...', | |
| 'Finalisation...' | |
| ] | |
| // Simulation du progrès | |
| for (let i = 0; i < tasks.length; i++) { | |
| calibrationStore.updateProgress((i / tasks.length) * 100, tasks[i]) | |
| await new Promise(resolve => setTimeout(resolve, 500)) | |
| } | |
| // Traitement réel | |
| const result = await processFunction() | |
| calibrationStore.updateProgress(100, 'Terminé !') | |
| return result | |
| } | |
| const goToManual = async () => { | |
| calibrationStore.switchToManual() | |
| await loadThumbnail() | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } | |
| const backToCalibration = async () => { | |
| // Réinitialiser les erreurs et résultats | |
| calibrationStore.setError(null) | |
| calibrationStore.setResults(null) | |
| calibrationStore.setProcessing(false) | |
| // Retourner à la section de calibration manuelle | |
| await nextTick() | |
| scrollToSection(manualSection.value) | |
| } | |
| const restart = () => { | |
| calibrationStore.reset() | |
| uploadStore.clearFile() | |
| scrollToSection(modeSection.value) | |
| } | |
| const exportResults = () => { | |
| // Encapsuler les données de calibration dans une clé "calibration" | |
| const data = { | |
| calibration: calibrationStore.results | |
| } | |
| // Générer le nom de fichier basé sur le fichier original | |
| let filename = 'football_vision_config.json' | |
| if (uploadStore.selectedFile && uploadStore.selectedFile.name) { | |
| // Enlever l'extension du fichier original et ajouter .json | |
| const originalName = uploadStore.selectedFile.name | |
| const nameWithoutExtension = originalName.substring(0, originalName.lastIndexOf('.')) || originalName | |
| filename = `${nameWithoutExtension}_config.json` | |
| } | |
| const content = JSON.stringify(data, null, 2) | |
| const blob = new Blob([content], { type: 'application/json' }) | |
| const url = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = url | |
| a.download = filename | |
| a.click() | |
| URL.revokeObjectURL(url) | |
| } | |
| // Méthodes pour la vue manuelle | |
| const loadThumbnail = async () => { | |
| try { | |
| thumbnail.value = null | |
| if (uploadStore.isImage) { | |
| // Pour les images, utiliser directement la preview | |
| thumbnail.value = uploadStore.filePreview | |
| } else if (uploadStore.isStaticVideo && uploadStore.extractedFrame) { | |
| // Pour les vidéos statiques, utiliser la frame déjà extraite | |
| console.log('Using extracted frame from static video:', uploadStore.selectedFile.name) | |
| thumbnail.value = URL.createObjectURL(uploadStore.extractedFrame) | |
| } else if (uploadStore.isVideo) { | |
| // Pour les autres vidéos, extraire la première frame | |
| console.log('Extracting first frame from video:', uploadStore.selectedFile.name) | |
| // Créer une URL pour le fichier vidéo | |
| const videoUrl = URL.createObjectURL(uploadStore.selectedFile) | |
| // Extraire la première frame avec un canvas | |
| const video = document.createElement('video') | |
| video.src = videoUrl | |
| video.muted = true // Important pour éviter les problèmes d'autoplay | |
| await new Promise((resolve, reject) => { | |
| video.addEventListener('loadedmetadata', () => { | |
| video.currentTime = 0 // Aller à la première frame | |
| }) | |
| video.addEventListener('seeked', () => { | |
| try { | |
| const canvas = document.createElement('canvas') | |
| canvas.width = video.videoWidth | |
| canvas.height = video.videoHeight | |
| const ctx = canvas.getContext('2d') | |
| ctx.drawImage(video, 0, 0) | |
| thumbnail.value = canvas.toDataURL('image/jpeg', 0.9) | |
| URL.revokeObjectURL(videoUrl) | |
| resolve() | |
| } catch (err) { | |
| URL.revokeObjectURL(videoUrl) | |
| reject(err) | |
| } | |
| }) | |
| video.addEventListener('error', (err) => { | |
| URL.revokeObjectURL(videoUrl) | |
| reject(err) | |
| }) | |
| }) | |
| } | |
| } catch (error) { | |
| console.error('Erreur lors du chargement du thumbnail:', error) | |
| thumbnail.value = null | |
| } | |
| } | |
| const handleFieldPointSelected = (pointData) => { | |
| selectedFieldLine.value = null | |
| selectedFieldPoint.value = pointData | |
| } | |
| const handleFieldLineSelected = (lineData) => { | |
| selectedFieldPoint.value = null | |
| selectedFieldLine.value = lineData | |
| } | |
| const updateThumbnail = (newThumbnail) => { | |
| thumbnail.value = newThumbnail | |
| } | |
| const updateCalibrationPoints = (newPoints) => { | |
| calibrationPoints.value = { ...newPoints } | |
| } | |
| const updateCalibrationLines = (newLines) => { | |
| calibrationLines.value = { ...newLines } | |
| } | |
| const updateSelectedFieldPoint = (newPoint) => { | |
| selectedFieldPoint.value = newPoint | |
| } | |
| const updateSelectedFieldLine = (newLine) => { | |
| selectedFieldLine.value = newLine | |
| if (footballField.value) { | |
| footballField.value.selectedLine = newLine ? newLine.id : null | |
| } | |
| } | |
| const clearCalibration = () => { | |
| calibrationPoints.value = {} | |
| calibrationLines.value = {} | |
| selectedFieldPoint.value = null | |
| selectedFieldLine.value = null | |
| } | |
| const processCalibration = async () => { | |
| if (!uploadStore.selectedFile || Object.keys(calibrationLines.value).length === 0) { | |
| alert('Veuillez créer au moins une ligne de calibration') | |
| return | |
| } | |
| // Scroll immédiat vers la section de processing | |
| scrollToSection(processingSection.value) | |
| await nextTick() | |
| try { | |
| // Utiliser le même système de progress que la version automatique | |
| const result = await processWithProgress(async () => { | |
| if (!calibrationArea.value) { | |
| throw new Error('CalibrationArea component is not mounted.') | |
| } | |
| const imageContainer = document.querySelector('.video-frame') | |
| const imageSize = calibrationArea.value.imageSize | |
| if (!imageSize) { | |
| throw new Error('Image size is not available.') | |
| } | |
| const containerWidth = imageContainer.clientWidth | |
| const containerHeight = imageContainer.clientHeight | |
| // Préparer les données des lignes pour l'API /calibrate | |
| const linesData = {} | |
| // Traitement des lignes - conversion des coordonnées container vers image | |
| for (const [lineName, line] of Object.entries(calibrationLines.value)) { | |
| linesData[lineName] = line.points.map(point => { | |
| return { | |
| x: point.x / containerWidth * imageSize.width, | |
| y: point.y / containerHeight * imageSize.height | |
| } | |
| }) | |
| } | |
| console.log('🔥 Données de lignes pour calibration:', linesData) | |
| // Déterminer quel fichier envoyer à l'API | |
| let fileToSend | |
| if (uploadStore.isImage) { | |
| // Pour les images, utiliser le fichier original | |
| fileToSend = uploadStore.selectedFile | |
| } else if (uploadStore.isStaticVideo && uploadStore.extractedFrame) { | |
| // Pour les vidéos statiques, utiliser la frame extraite | |
| fileToSend = uploadStore.extractedFrame | |
| } else { | |
| throw new Error('Aucune image disponible pour la calibration') | |
| } | |
| console.log('🔥 Fichier envoyé pour calibration:', fileToSend.name || 'extracted_frame.jpg', 'Type:', fileToSend.type) | |
| // Appeler l'API /calibrate avec l'image et les lignes | |
| return await api.calibrateCamera(fileToSend, linesData) | |
| }) | |
| // Console log de la réponse API | |
| console.log('🔥 Réponse API calibration reçue:', result) | |
| // Toujours afficher les résultats, même en cas d'erreur | |
| calibrationStore.setResults(result) | |
| } catch (error) { | |
| console.error('❌ Erreur lors du traitement manuel:', error) | |
| // En cas d'erreur de réseau ou autre, créer un objet de résultat d'erreur | |
| calibrationStore.setResults({ | |
| status: 'failed', | |
| message: error.message, | |
| error: error.message | |
| }) | |
| } | |
| } | |
| // Méthodes pour les événements du graphique Plotly | |
| const onChartDataLoaded = (data) => { | |
| if (data.rows) { | |
| // Pour les données CSV | |
| console.log('📊 Données CSV chargées:', data.rows.length, 'points') | |
| } else { | |
| // Pour le terrain de football ou autres | |
| console.log('📊 Graphique chargé:', data.traces.length, 'éléments') | |
| } | |
| } | |
| const onChartError = (error) => { | |
| console.error('❌ Erreur dans le graphique:', error) | |
| } | |
| </script> | |
| <template> | |
| <div class="home-container"> | |
| <!-- Bouton retour subtil (visible si on a déjà fait des actions) --> | |
| <button | |
| v-if="showUpload || showResults || showError" | |
| @click="resetToStart" | |
| class="btn-back-home" | |
| title="Recommencer" | |
| > | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/> | |
| <path d="M21 3v5h-5"/> | |
| <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/> | |
| <path d="M3 21v-5h5"/> | |
| </svg> | |
| </button> | |
| <!-- Section 1: Choix du mode --> | |
| <section ref="modeSection" class="section mode-section"> | |
| <div class="hero"> | |
| <h1>Football Vision</h1> | |
| <p class="hero-subtitle">Analysez automatiquement les paramètres de caméra de vos vidéos de football</p> | |
| </div> | |
| <div class="mode-selection"> | |
| <div class="mode-cards"> | |
| <div | |
| class="mode-card auto" | |
| :class="{ selected: calibrationStore.isAutoMode }" | |
| @click="selectMode('auto')" | |
| > | |
| <h3>Mode Automatique</h3> | |
| <p>Détection automatique des lignes du terrain</p> | |
| <ul> | |
| <li>Rapide et simple</li> | |
| <li>Précision élevée</li> | |
| <li>Recommandé</li> | |
| </ul> | |
| </div> | |
| <div | |
| class="mode-card manual" | |
| :class="{ selected: calibrationStore.isManualMode }" | |
| @click="selectMode('manual')" | |
| > | |
| <h3>Mode Manuel</h3> | |
| <p>Contrôle total sur la définition des lignes</p> | |
| <ul> | |
| <li>Contrôle précis</li> | |
| <li>Personnalisable</li> | |
| <li>Cas spéciaux</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 2: Upload de fichier --> | |
| <section v-if="showUpload" ref="uploadSection" class="section upload-section"> | |
| <div class="upload-grid"> | |
| <!-- Côté gauche: Upload de fichier --> | |
| <div class="upload-left"> | |
| <div | |
| class="drop-zone" | |
| :class="{ | |
| active: dragActive, | |
| 'has-file': uploadStore.isFileSelected, | |
| 'processing': uploadStore.isUploading | |
| }" | |
| @drop="handleDrop" | |
| @dragover="handleDragOver" | |
| @dragleave="handleDragLeave" | |
| > | |
| <div v-if="!uploadStore.isFileSelected" class="drop-content"> | |
| <div class="upload-icon">+</div> | |
| <h4>Glissez-déposez votre fichier ici</h4> | |
| <p class="or-text">ou</p> | |
| <label class="file-input-label"> | |
| <input | |
| type="file" | |
| accept="image/*,video/*" | |
| @change="handleFileSelect" | |
| hidden | |
| > | |
| Choisir un fichier | |
| </label> | |
| </div> | |
| <div v-else class="file-preview"> | |
| <div class="file-info"> | |
| <h4>Fichier sélectionné</h4> | |
| <p class="file-name">{{ uploadStore.selectedFile.name }}</p> | |
| <p class="file-details"> | |
| <span>Type: {{ uploadStore.fileType === 'image' ? 'Image' : 'Vidéo' }}</span> | |
| <span>Taille: {{ Math.round(uploadStore.selectedFile.size / 1024) }} KB</span> | |
| </p> | |
| </div> | |
| <div class="preview" v-if="uploadStore.filePreview"> | |
| <img | |
| v-if="uploadStore.isImage" | |
| :src="uploadStore.filePreview" | |
| alt="Preview" | |
| class="preview-media" | |
| > | |
| <video | |
| v-else | |
| :src="uploadStore.filePreview" | |
| controls | |
| class="preview-media" | |
| > | |
| </video> | |
| </div> | |
| <button @click="uploadStore.clearFile()" class="btn-secondary"> | |
| Changer de fichier | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Côté droit: Images de test --> | |
| <div class="upload-right"> | |
| <div class="test-image-container"> | |
| <div | |
| class="test-image-card single" | |
| @click="selectTestImage(testImage)" | |
| :class="{ selected: selectedTestImage === testImage.id }" | |
| > | |
| <img :src="testImage.thumbnail" :alt="testImage.name" class="test-image-thumb"> | |
| <div class="test-image-overlay"> | |
| <div class="overlay-content"> | |
| <p>Vue Drone</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 3: Type de vidéo (statique/dynamique) --> | |
| <section v-if="showVideoType" ref="videoTypeSection" class="section video-type-section"> | |
| <h2>Type de vidéo</h2> | |
| <div class="video-type-cards"> | |
| <div | |
| class="type-card static" | |
| :class="{ selected: uploadStore.videoType === 'static' }" | |
| @click="selectVideoType('static')" | |
| > | |
| <h3>Vidéo Statique</h3> | |
| <p>Caméra fixe, extraction de la première image</p> | |
| <ul> | |
| <li>Traitement rapide</li> | |
| <li>Analyse comme une image</li> | |
| <li>Recommandé pour caméra fixe</li> | |
| </ul> | |
| </div> | |
| <div | |
| class="type-card dynamic" | |
| :class="{ selected: uploadStore.videoType === 'dynamic' }" | |
| @click="selectVideoType('dynamic')" | |
| > | |
| <h3>Vidéo Dynamique</h3> | |
| <p>Caméra en mouvement, analyse de plusieurs frames</p> | |
| <ul> | |
| <li>Analyse complète</li> | |
| <li>Traitement avancé</li> | |
| <li>Plus de données collectées</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 4: Paramètres pour vidéo dynamique --> | |
| <section v-if="showParameters" ref="parametersSection" class="section parameters-section"> | |
| <h2>Paramètres de traitement</h2> | |
| <div class="parameters-form"> | |
| <div class="param-group"> | |
| <label>Seuil de détection des points clés</label> | |
| <input | |
| type="range" | |
| min="0.1" | |
| max="0.5" | |
| step="0.05" | |
| :value="calibrationStore.videoProcessingParams.kpThreshold" | |
| @input="updateParameter('kpThreshold', parseFloat($event.target.value))" | |
| > | |
| <span class="param-value">{{ calibrationStore.videoProcessingParams.kpThreshold }}</span> | |
| </div> | |
| <div class="param-group"> | |
| <label>Seuil de détection des lignes</label> | |
| <input | |
| type="range" | |
| min="0.1" | |
| max="0.5" | |
| step="0.05" | |
| :value="calibrationStore.videoProcessingParams.lineThreshold" | |
| @input="updateParameter('lineThreshold', parseFloat($event.target.value))" | |
| > | |
| <span class="param-value">{{ calibrationStore.videoProcessingParams.lineThreshold }}</span> | |
| </div> | |
| <div class="param-group"> | |
| <label>Pas entre les frames (traiter 1 frame sur X)</label> | |
| <input | |
| type="range" | |
| min="1" | |
| max="300" | |
| step="1" | |
| :value="calibrationStore.videoProcessingParams.frameStep" | |
| @input="updateParameter('frameStep', parseInt($event.target.value))" | |
| > | |
| <span class="param-value">{{ calibrationStore.videoProcessingParams.frameStep }}</span> | |
| </div> | |
| <button @click="startProcessing" class="btn-primary launch-btn"> | |
| Lancer le traitement | |
| </button> | |
| </div> | |
| </section> | |
| <!-- Section 5: Vue manuelle intégrée --> | |
| <section v-if="showManualCalibration" ref="manualSection" class="section manual-section"> | |
| <div class="manual-container"> | |
| <div class="manual-content"> | |
| <div class="calibration-container"> | |
| <CalibrationArea | |
| ref="calibrationArea" | |
| :thumbnail="thumbnail" | |
| :calibrationPoints="calibrationPoints" | |
| :calibrationLines="calibrationLines" | |
| :selectedFieldPoint="selectedFieldPoint" | |
| :selectedFieldLine="selectedFieldLine" | |
| @update:thumbnail="updateThumbnail" | |
| @update:calibrationPoints="updateCalibrationPoints" | |
| @update:calibrationLines="updateCalibrationLines" | |
| @update:selectedFieldPoint="updateSelectedFieldPoint" | |
| @update:selectedFieldLine="updateSelectedFieldLine" | |
| @clear-calibration="clearCalibration" | |
| @process-calibration="processCalibration" | |
| /> | |
| </div> | |
| <div class="field-container"> | |
| <FootballField | |
| ref="footballField" | |
| @point-selected="handleFieldPointSelected" | |
| @line-selected="handleFieldLineSelected" | |
| :positionedPoints="calibrationPoints" | |
| :positionedLines="calibrationLines" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 6: Traitement en cours --> | |
| <section v-if="showProcessing" ref="processingSection" class="section processing-section"> | |
| <h2>Traitement en cours</h2> | |
| <div class="processing-card"> | |
| <div class="processing-spinner"></div> | |
| <h3>{{ calibrationStore.processingTask }}</h3> | |
| <div class="progress-container"> | |
| <div class="progress-bar"> | |
| <div | |
| class="progress-fill" | |
| :style="{ width: calibrationStore.processingProgress + '%' }" | |
| ></div> | |
| </div> | |
| <span class="progress-text">{{ Math.round(calibrationStore.processingProgress) }}%</span> | |
| </div> | |
| <div class="processing-info"> | |
| <div class="info-item"> | |
| <span class="label">Fichier:</span> | |
| <span class="value">{{ uploadStore.selectedFile?.name }}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="label">Mode:</span> | |
| <span class="value">{{ calibrationStore.isAutoMode ? 'Automatique' : 'Manuel' }}</span> | |
| </div> | |
| <div class="info-item" v-if="uploadStore.isVideo"> | |
| <span class="label">Type:</span> | |
| <span class="value">{{ uploadStore.videoType === 'static' ? 'Vidéo Statique' : 'Vidéo Dynamique' }}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 7: Erreur --> | |
| <section v-if="showError" ref="errorSection" class="section error-section"> | |
| <h2 v-if="!calibrationStore.isManualMode">Mode manuel recommandé</h2> | |
| <h2 v-else>Échec de la calibration</h2> | |
| <div class="error-card"> | |
| <p class="error-message">{{ calibrationStore.error }}</p> | |
| <div v-if="calibrationStore.isManualMode" class="manual-error-actions"> | |
| <button @click="backToCalibration" class="btn-manual-primary"> | |
| Modifier la calibration | |
| </button> | |
| <button @click="restart" class="btn-restart-small"> | |
| Recommencer | |
| </button> | |
| </div> | |
| <div v-else class="auto-error-actions"> | |
| <button @click="goToManual" class="btn-manual-primary"> | |
| Passer en mode manuel | |
| </button> | |
| <button @click="restart" class="btn-restart-small"> | |
| Ou essayer une autre image | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Section 8: Résultats --> | |
| <section v-if="showResults" ref="resultsSection" class="section results-section"> | |
| <!-- Succès --> | |
| <div v-if="calibrationStore.results?.status === 'success'" class="results-container"> | |
| <div class="result-status"> | |
| <div class="status-success"> | |
| <h2>Analyse réussie</h2> | |
| </div> | |
| </div> | |
| <div class="result-message"> | |
| <p>{{ calibrationStore.results?.message || 'Paramètres de caméra extraits avec succès' }}</p> | |
| </div> | |
| <div class="result-actions"> | |
| <button @click="exportResults()" class="btn-primary"> | |
| Télécharger les résultats | |
| </button> | |
| <button v-if="calibrationStore.isManualMode" @click="backToCalibration" class="btn-secondary"> | |
| Modifier la calibration | |
| </button> | |
| <button @click="restart" class="btn-tertiary"> | |
| Nouvelle analyse | |
| </button> | |
| </div> | |
| <!-- Graphique 3D pour les images avec calibration réussie --> | |
| <div v-if="uploadStore.isImage" class="result-chart"> | |
| <h3>Terrain de Football - Vue 3D</h3> | |
| <PlotlyChart | |
| data-type="football-field" | |
| :height="550" | |
| :keypoints-data="calibrationStore.results?.detections?.keypoints || []" | |
| :camera-params="calibrationStore.results?.camera_parameters?.cam_params" | |
| :custom-layout="{ | |
| title: { | |
| text: 'Visualisation 3D du terrain de football', | |
| font: { size: 16, color: 'white' } | |
| }, | |
| paper_bgcolor: 'rgba(0,0,0,0)', | |
| plot_bgcolor: 'rgba(0,0,0,0)' | |
| }" | |
| @data-loaded="onChartDataLoaded" | |
| @error="onChartError" | |
| /> | |
| </div> | |
| <details class="result-details"> | |
| <summary>Données complètes</summary> | |
| <pre class="result-data">{{ JSON.stringify(calibrationStore.results, null, 2) }}</pre> | |
| </details> | |
| </div> | |
| <!-- Échec en mode manuel --> | |
| <div v-else-if="calibrationStore.isManualMode" class="error-card-simple"> | |
| <h2>Calibration échouée</h2> | |
| <p class="error-message">Les lignes définies ne permettent pas de calculer les paramètres de caméra.</p> | |
| <div class="error-actions"> | |
| <button | |
| @click="backToCalibration" | |
| class="btn-primary" | |
| title="Modifiez vos lignes de calibration : ajoutez plus de points, utilisez des lignes variées (droites, cercles), répartissez-les sur l'image" | |
| > | |
| Ajuster la calibration | |
| </button> | |
| <button | |
| @click="restart" | |
| class="btn-secondary" | |
| title="Essayez avec une image de meilleure qualité ou un angle de vue différent" | |
| > | |
| Changer d'image | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Échec en mode auto --> | |
| <div v-else class="results-container"> | |
| <div class="result-status"> | |
| <div class="status-error"> | |
| <h2>Analyse échouée</h2> | |
| </div> | |
| </div> | |
| <div class="result-message"> | |
| <p>L'analyse automatique n'a pas pu extraire les paramètres de caméra.</p> | |
| </div> | |
| <div class="result-actions"> | |
| <button @click="goToManual" class="btn-secondary"> | |
| Essayer en mode manuel | |
| </button> | |
| <button @click="restart" class="btn-tertiary"> | |
| Nouvelle analyse | |
| </button> | |
| </div> | |
| <details class="result-details"> | |
| <summary>Données complètes</summary> | |
| <pre class="result-data">{{ JSON.stringify(calibrationStore.results, null, 2) }}</pre> | |
| </details> | |
| </div> | |
| </section> | |
| </div> | |
| </template> | |
| <style scoped> | |
| .btn-back-home { | |
| position: fixed; | |
| top: 20px; | |
| left: 20px; | |
| background: rgba(255, 255, 255, 0.1); | |
| color: #888; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| border-radius: 8px; | |
| padding: 10px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| z-index: 1000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| backdrop-filter: blur(10px); | |
| } | |
| .btn-back-home:hover { | |
| background: rgba(255, 255, 255, 0.15); | |
| color: var(--color-primary); | |
| border-color: var(--color-primary); | |
| transform: scale(1.05); | |
| } | |
| .btn-back-home svg { | |
| transition: all 0.3s ease; | |
| } | |
| .home-container { | |
| margin: 0 auto; | |
| background: var(--color-secondary); | |
| min-height: 100vh; | |
| } | |
| .section { | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| } | |
| /* Section Mode */ | |
| .hero h1 { | |
| font-size: 3rem; | |
| font-weight: 700; | |
| margin-bottom: 1rem; | |
| color: white; | |
| letter-spacing: -0.025em; | |
| position: relative; | |
| } | |
| .hero h1::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: -10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 80px; | |
| height: 4px; | |
| background: var(--color-primary); | |
| border-radius: 2px; | |
| } | |
| .hero-subtitle { | |
| font-size: 1.1rem; | |
| color: #b0b0b0; | |
| margin-bottom: 4rem; | |
| max-width: 600px; | |
| line-height: 1.6; | |
| font-weight: 500; | |
| } | |
| .mode-selection h2 { | |
| font-size: 1.5rem; | |
| margin-bottom: 3rem; | |
| color: white; | |
| font-weight: 600; | |
| } | |
| .mode-cards, .video-type-cards { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); | |
| gap: 2rem; | |
| width: 100%; | |
| max-width: 800px; | |
| } | |
| .mode-card, .type-card { | |
| background: var(--color-secondary-soft); | |
| border-radius: 12px; | |
| padding: 2rem; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| border: 2px solid #333; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .mode-card::before, .type-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 4px; | |
| background: var(--color-primary); | |
| transform: translateY(-4px); | |
| transition: transform 0.3s ease; | |
| } | |
| .mode-card:hover::before, .type-card:hover::before { | |
| transform: translateY(0); | |
| } | |
| .mode-card:hover, .type-card:hover { | |
| transform: translateY(-8px); | |
| box-shadow: 0 8px 30px rgba(0,0,0,0.4); | |
| border-color: var(--color-primary); | |
| background: #2a2a2a; | |
| } | |
| .mode-card.selected, .type-card.selected { | |
| border-color: var(--color-primary); | |
| background: #2a2a2a; | |
| transform: translateY(-4px); | |
| box-shadow: 0 6px 25px rgba(255, 255, 255, 0.3); | |
| } | |
| .mode-card.selected::before, .type-card.selected::before { | |
| transform: translateY(0); | |
| } | |
| .mode-card h3, .type-card h3 { | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| color: white; | |
| margin-bottom: 1rem; | |
| } | |
| .mode-card p, .type-card p { | |
| color: #b0b0b0; | |
| margin-bottom: 1.5rem; | |
| line-height: 1.5; | |
| font-weight: 500; | |
| } | |
| .mode-card ul, .type-card ul { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| } | |
| .mode-card li, .type-card li { | |
| padding: 0.5rem 0; | |
| color: #d0d0d0; | |
| font-size: 0.9rem; | |
| position: relative; | |
| padding-left: 1.5rem; | |
| font-weight: 500; | |
| } | |
| .mode-card li::before, .type-card li::before { | |
| content: '●'; | |
| color: var(--color-primary); | |
| font-weight: bold; | |
| position: absolute; | |
| left: 0; | |
| } | |
| /* Section Upload */ | |
| .upload-section h2, .video-type-section h2, .parameters-section h2, .processing-section h2, .results-section h2 { | |
| font-size: 1.5rem; | |
| margin-bottom: 3rem; | |
| color: white; | |
| font-weight: 600; | |
| } | |
| /* Grid Upload */ | |
| .upload-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 3rem; | |
| width: 100%; | |
| max-width: 1200px; | |
| align-items: start; | |
| padding: 2rem; | |
| } | |
| .upload-left, .upload-right { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| } | |
| .upload-right { | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .upload-title { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| color: white; | |
| margin-bottom: 2rem; | |
| text-align: center; | |
| } | |
| .drop-zone { | |
| border: 3px dashed #555; | |
| border-radius: 12px; | |
| padding: 3rem; | |
| transition: all 0.3s ease; | |
| background: var(--color-secondary-soft); | |
| width: 100%; | |
| max-width: 600px; | |
| position: relative; | |
| } | |
| .drop-zone.active { | |
| border-color: var(--color-primary); | |
| background: #2a2a2a; | |
| box-shadow: 0 4px 20px rgba(217, 255, 4, 0.2); | |
| } | |
| .drop-zone.has-file { | |
| border-color: var(--color-primary); | |
| border-style: solid; | |
| background: #2a2a2a; | |
| } | |
| .upload-icon { | |
| font-size: 3rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| font-weight: 300; | |
| } | |
| .drop-content h4 { | |
| font-size: 1.25rem; | |
| color: white; | |
| margin-bottom: 1rem; | |
| font-weight: 600; | |
| } | |
| /* Image de test */ | |
| .test-image-container { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .test-image-card.single { | |
| background: var(--color-secondary-soft); | |
| border-radius: 12px; | |
| overflow: hidden; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| border: 2px solid transparent; | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| } | |
| .test-image-card:hover { | |
| transform: translateY(-4px); | |
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); | |
| border-color: rgba(255, 255, 255, 0.2); | |
| } | |
| .test-image-card.selected { | |
| border-color: var(--color-primary); | |
| box-shadow: 0 4px 20px rgba(217, 255, 4, 0.3); | |
| } | |
| .test-image-card.selected::after { | |
| content: '✓'; | |
| position: absolute; | |
| top: 8px; | |
| right: 8px; | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| width: 24px; | |
| height: 24px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: bold; | |
| font-size: 0.8rem; | |
| } | |
| .test-image-thumb { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .test-image-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: rgba(0, 0, 0, 0.7); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| opacity: 1; | |
| transition: opacity 0.3s ease; | |
| backdrop-filter: blur(2px); | |
| } | |
| .test-image-card.single:hover .test-image-overlay { | |
| opacity: 0; | |
| } | |
| .overlay-content { | |
| text-align: center; | |
| color: white; | |
| } | |
| .overlay-content h4 { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin: 0 0 0.5rem 0; | |
| color: var(--color-primary); | |
| } | |
| .overlay-content p { | |
| font-size: 0.9rem; | |
| margin: 0; | |
| color: #e0e0e0; | |
| font-weight: 500; | |
| } | |
| .or-text { | |
| color: #888; | |
| margin: 1.5rem 0; | |
| font-size: 0.9rem; | |
| font-weight: 500; | |
| } | |
| .file-input-label { | |
| display: inline-block; | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| padding: 0.875rem 2rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .file-input-label::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -100%; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); | |
| transition: left 0.5s ease; | |
| } | |
| .file-input-label:hover::before { | |
| left: 100%; | |
| } | |
| .file-input-label:hover { | |
| background: white; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(255, 255, 255, 0.4); | |
| } | |
| .format-info { | |
| color: #888; | |
| font-size: 0.85rem; | |
| margin-top: 1.5rem; | |
| font-weight: 500; | |
| } | |
| .file-preview { | |
| text-align: center; | |
| } | |
| .file-info h4 { | |
| color: var(--color-primary); | |
| margin-bottom: 1.5rem; | |
| font-weight: 600; | |
| } | |
| .file-name { | |
| font-weight: 700; | |
| color: white; | |
| margin-bottom: 1rem; | |
| } | |
| .file-details { | |
| color: #b0b0b0; | |
| font-size: 0.9rem; | |
| margin-bottom: 2rem; | |
| font-weight: 500; | |
| } | |
| .file-details span { | |
| margin-right: 1rem; | |
| } | |
| .preview-media { | |
| max-width: 100%; | |
| max-height: 300px; | |
| border-radius: 8px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| margin: 2rem 0; | |
| /* border: 2px solid var(--color-primary); */ | |
| } | |
| /* Section Paramètres */ | |
| .parameters-form { | |
| background: var(--color-secondary-soft); | |
| padding: 2.5rem; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| width: 100%; | |
| max-width: 500px; | |
| text-align: left; | |
| border: 1px solid #333; | |
| } | |
| .param-group { | |
| margin-bottom: 2rem; | |
| } | |
| .param-group label { | |
| display: block; | |
| margin-bottom: 0.75rem; | |
| font-weight: 600; | |
| color: white; | |
| font-size: 0.9rem; | |
| } | |
| .param-group input[type="range"] { | |
| width: 100%; | |
| height: 6px; | |
| background: #333; | |
| border-radius: 3px; | |
| outline: none; | |
| margin: 0.5rem 0; | |
| -webkit-appearance: none; | |
| } | |
| .param-group input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: var(--color-primary); | |
| cursor: pointer; | |
| border: 3px solid var(--color-secondary); | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.3); | |
| } | |
| .param-group input[type="range"]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: var(--color-primary); | |
| cursor: pointer; | |
| border: 3px solid var(--color-secondary); | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.3); | |
| } | |
| .param-value { | |
| font-weight: 700; | |
| float: right; | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| padding: 0.25rem 0.5rem; | |
| border-radius: 4px; | |
| font-size: 0.8rem; | |
| } | |
| .launch-btn { | |
| width: 100%; | |
| margin-top: 1rem; | |
| font-size: 1rem; | |
| padding: 1rem; | |
| font-weight: 700; | |
| } | |
| /* Section Erreur */ | |
| .error-section h2 { | |
| font-size: 1.5rem; | |
| margin-bottom: 3rem; | |
| color: white; | |
| font-weight: 600; | |
| } | |
| .error-card { | |
| background: var(--color-secondary-soft); | |
| border-radius: 12px; | |
| padding: 3rem; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| width: 100%; | |
| max-width: 400px; | |
| border: 1px solid #333; | |
| text-align: center; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2rem; | |
| } | |
| .error-message { | |
| color: #b0b0b0; | |
| font-size: 0.95rem; | |
| font-weight: 500; | |
| margin: 0; | |
| } | |
| .btn-manual-primary { | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| border: none; | |
| padding: 1rem 2rem; | |
| border-radius: 8px; | |
| font-weight: 700; | |
| font-size: 1rem; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .btn-manual-primary:hover { | |
| background: var(--color-primary-soft); | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4); | |
| } | |
| .btn-restart-small { | |
| background: transparent; | |
| color: #888; | |
| border: none; | |
| padding: 0.5rem; | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| transition: color 0.3s ease; | |
| text-decoration: underline; | |
| } | |
| .btn-restart-small:hover { | |
| color: white; | |
| } | |
| /* Section Traitement */ | |
| .processing-card { | |
| background: var(--color-secondary-soft); | |
| border-radius: 12px; | |
| padding: 3rem; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| width: 100%; | |
| max-width: 500px; | |
| border: 1px solid #333; | |
| } | |
| .processing-spinner { | |
| width: 48px; | |
| height: 48px; | |
| border: 4px solid #333; | |
| border-top: 4px solid var(--color-primary); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 2rem; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .processing-card h3 { | |
| font-size: 1.1rem; | |
| color: white; | |
| margin-bottom: 2rem; | |
| font-weight: 600; | |
| } | |
| .progress-container { | |
| margin: 2rem 0; | |
| } | |
| .progress-bar { | |
| width: 100%; | |
| height: 10px; | |
| background: #333; | |
| border-radius: 5px; | |
| overflow: hidden; | |
| margin-bottom: 0.75rem; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, var(--color-primary), var(--color-primary-soft)); | |
| transition: width 0.3s ease; | |
| border-radius: 5px; | |
| } | |
| .progress-text { | |
| font-weight: 700; | |
| color: white; | |
| font-size: 0.9rem; | |
| } | |
| .processing-info { | |
| margin-top: 2rem; | |
| text-align: left; | |
| } | |
| .info-item { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.9rem; | |
| } | |
| .info-item .label { | |
| color: #b0b0b0; | |
| font-weight: 500; | |
| } | |
| .info-item .value { | |
| color: white; | |
| font-weight: 600; | |
| } | |
| /* Section Résultats */ | |
| .results-summary { | |
| background: var(--color-secondary-soft); | |
| border-radius: 12px; | |
| margin-bottom: 3rem; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
| width: 100%; | |
| max-width: 600px; | |
| border: 1px solid #333; | |
| } | |
| /* Section Résultats */ | |
| .results-container { | |
| max-width: 800px; | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2rem; | |
| } | |
| .result-status h2 { | |
| margin: 0; | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| } | |
| .status-success h2 { | |
| color: var(--color-primary); | |
| } | |
| .status-error h2 { | |
| color: #dc3545; | |
| } | |
| .result-message { | |
| background: var(--color-secondary-soft); | |
| border-radius: 8px; | |
| padding: 1.5rem; | |
| border-left: 4px solid var(--color-primary); | |
| } | |
| .result-message p { | |
| margin: 0; | |
| color: #e0e0e0; | |
| line-height: 1.5; | |
| font-size: 0.95rem; | |
| } | |
| .result-actions { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| } | |
| .btn-primary, .btn-secondary, .btn-tertiary { | |
| padding: 0.875rem 1.5rem; | |
| border-radius: 8px; | |
| border: none; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| transition: all 0.3s ease; | |
| } | |
| .btn-primary { | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| } | |
| .btn-primary:hover { | |
| background: var(--color-primary-soft); | |
| transform: translateY(-1px); | |
| } | |
| .btn-secondary { | |
| background: #555; | |
| color: white; | |
| border: 1px solid #666; | |
| } | |
| .btn-secondary:hover { | |
| background: #666; | |
| transform: translateY(-1px); | |
| } | |
| .btn-tertiary { | |
| background: transparent; | |
| color: #888; | |
| border: 1px solid #555; | |
| } | |
| .btn-tertiary:hover { | |
| color: white; | |
| border-color: #777; | |
| background: #333; | |
| } | |
| .result-details { | |
| margin-top: 1rem; | |
| } | |
| .result-details summary { | |
| color: #888; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| padding: 0.5rem 0; | |
| transition: color 0.3s ease; | |
| } | |
| .result-details summary:hover { | |
| color: white; | |
| } | |
| .result-details[open] summary { | |
| color: var(--color-primary); | |
| margin-bottom: 1rem; | |
| } | |
| .result-data { | |
| background: #0d1117; | |
| color: #e6edf3; | |
| padding: 1.5rem; | |
| border-radius: 8px; | |
| font-family: 'Monaco', 'Consolas', 'Ubuntu Mono', monospace; | |
| font-size: 0.875rem; | |
| line-height: 1.6; | |
| overflow-x: auto; | |
| white-space: pre; | |
| border: 1px solid #30363d; | |
| max-height: 400px; | |
| overflow-y: auto; | |
| text-align: left; | |
| box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); | |
| /* Syntax highlighting simulation */ | |
| color: #c9d1d9; | |
| } | |
| .result-details { | |
| position: relative; | |
| } | |
| .confidence-score { | |
| font-size: 1rem; | |
| color: white; | |
| font-weight: 600; | |
| } | |
| .quick-info { | |
| margin-top: 2rem; | |
| padding-top: 1.5rem; | |
| border-top: 1px solid #333; | |
| } | |
| .quick-info .info-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.75rem; | |
| font-size: 0.9rem; | |
| } | |
| .quick-info .info-item span:first-child { | |
| color: #b0b0b0; | |
| font-weight: 500; | |
| } | |
| .quick-info .info-item span:last-child { | |
| color: white; | |
| font-weight: 600; | |
| font-family: monospace; | |
| } | |
| /* Error Card simple pour mode manuel */ | |
| .error-card-simple { | |
| max-width: 500px; | |
| width: 100%; | |
| text-align: center; | |
| } | |
| .error-card-simple h2 { | |
| color: #dc3545; | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| margin-bottom: 1rem; | |
| } | |
| .error-card-simple .error-message { | |
| color: #b0b0b0; | |
| font-size: 1rem; | |
| margin-bottom: 2rem; | |
| line-height: 1.5; | |
| } | |
| .error-actions { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| max-width: 300px; | |
| margin: 0 auto; | |
| } | |
| .manual-error-actions, .auto-error-actions { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| width: 100%; | |
| max-width: 300px; | |
| margin: 0 auto; | |
| } | |
| .btn-manual-primary { | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| border: none; | |
| padding: 1rem 2rem; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .btn-manual-primary:hover { | |
| background: var(--color-primary-soft); | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4); | |
| } | |
| .btn-restart-small { | |
| background: transparent; | |
| color: #888; | |
| border: 1px solid #555; | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 8px; | |
| font-weight: 500; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| } | |
| .btn-restart-small:hover { | |
| color: white; | |
| border-color: #777; | |
| background: #333; | |
| } | |
| /* Boutons */ | |
| .btn-primary, .btn-secondary, .btn-export { | |
| border: none; | |
| padding: 0.875rem 1.5rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| text-decoration: none; | |
| display: inline-block; | |
| font-size: 0.9rem; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .btn-primary { | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| } | |
| .btn-primary::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; | |
| left: -100%; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); | |
| transition: left 0.5s ease; | |
| } | |
| .btn-primary:hover::before { | |
| left: 100%; | |
| } | |
| .btn-primary:hover { | |
| background: var(--color-primary-soft); | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4); | |
| } | |
| .btn-secondary { | |
| background: #555; | |
| color: white; | |
| border: 1px solid #666; | |
| } | |
| .btn-secondary:hover { | |
| background: #666; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(255, 255, 255, 0.1); | |
| } | |
| .btn-export { | |
| background: var(--color-primary); | |
| color: var(--color-secondary); | |
| padding: 0.5rem 1rem; | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| } | |
| .btn-export:hover { | |
| background: var(--color-primary-soft); | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(217, 255, 4, 0.4); | |
| } | |
| /* Section manuelle */ | |
| .manual-section { | |
| height: 100svh; | |
| min-height: 100svh; | |
| padding: 0; | |
| } | |
| .manual-container { | |
| height: 100%; | |
| width: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .manual-content { | |
| flex: 1; | |
| display: flex; | |
| gap: 15px; | |
| padding: 15px; | |
| overflow: hidden; | |
| height: 100%; | |
| } | |
| .manual-section .calibration-container { | |
| flex: 1; | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| } | |
| .manual-section .field-container { | |
| flex: 1; | |
| min-width: 0; | |
| overflow: hidden; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| } | |
| .manual-section .field-container :deep(svg) { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| margin-top: -10px; | |
| } | |
| .manual-section .calibration-container :deep(.video-frame-container) { | |
| height: 100%; | |
| } | |
| .manual-section .calibration-container :deep(.video-frame) { | |
| height: calc(100% - 80px); | |
| } | |
| /* Section graphique dans les résultats */ | |
| .result-chart { | |
| width: 100%; | |
| max-width: 1000px; | |
| margin: 2rem 0; | |
| background: rgba(255, 255, 255, 0.02); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .result-chart h3 { | |
| font-size: 1.25rem; | |
| color: white; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| font-weight: 600; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .home-container { | |
| padding: 0 1rem; | |
| } | |
| .section { | |
| min-height: auto; | |
| } | |
| .mode-cards, .video-type-cards { | |
| grid-template-columns: 1fr; | |
| } | |
| .main-actions { | |
| grid-template-columns: 1fr; | |
| } | |
| .hero h1 { | |
| font-size: 2.5rem; | |
| } | |
| .manual-content { | |
| flex-direction: column; | |
| } | |
| .result-chart { | |
| padding: 1rem; | |
| margin: 1rem 0; | |
| } | |
| } | |
| </style> | |