Spaces:
Running
Running
<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 testImageUrl = ref(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 () => { | |
try { | |
selectedTestImage.value = 'test' | |
// Créer un objet File à partir de l'URL de l'image de test | |
const response = await fetch(testImageUrl.value) | |
const blob = await response.blob() | |
const file = new File([blob], 'field_test_thumb.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 = testImageUrl.value | |
uploadStore.selectedFile = { name: 'test-image.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" | |
:class="{ selected: selectedTestImage === 'test' }" | |
> | |
<img :src="testImageUrl" :alt="'Test Image'" 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 et vidéos statiques avec calibration réussie --> | |
<div v-if="uploadStore.isImage || uploadStore.isStaticVideo" 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 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> | |