2nzi commited on
Commit
b4f9490
·
verified ·
1 Parent(s): 10c8127

first commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log*
3
+ yarn-debug.log*
4
+ yarn-error.log*
5
+ .git
6
+ .gitignore
7
+ README.md
8
+ .env
9
+ .nyc_output
10
+ coverage
11
+ .vscode
12
+ .DS_Store
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ src/assets/football.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ src/assets/imgFoot.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ node_modules
3
+ /dist
4
+
5
+
6
+ # local env files
7
+ .env.local
8
+ .env.*.local
9
+
10
+ # Log files
11
+ npm-debug.log*
12
+ yarn-debug.log*
13
+ yarn-error.log*
14
+ pnpm-debug.log*
15
+
16
+ # Editor directories and files
17
+ .idea
18
+ .vscode
19
+ *.suo
20
+ *.ntvs*
21
+ *.njsproj
22
+ *.sln
23
+ *.sw?
24
+
25
+ #Electron-builder output
26
+ /dist_electron
27
+
28
+ *.exe
29
+ *.blockmap
30
+ *.dmg
31
+ *.deb
32
+ *.rpm
33
+ *.AppImage
34
+ *.snap
35
+ *.tar.gz
36
+ */bundled
37
+ */mac
38
+ */mac_arm64
39
+ */mac_x64
40
+ */mac_arm64_x64
41
+ */mac_arm64_x64_x64
42
+ */mac_arm64_x64_x64_x64
43
+ */win-unpacked
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build
2
+ FROM node:20.18.0-alpine AS build-stage
3
+ WORKDIR /app
4
+
5
+ # Copier les fichiers de dépendances
6
+ COPY package*.json ./
7
+
8
+ # Installer les dépendances (sans --only=production=false qui est obsolète)
9
+ RUN npm ci
10
+
11
+ # Copier le code source
12
+ COPY . .
13
+
14
+ # Construire l'application
15
+ RUN npm run build
16
+
17
+ # Stage 2: Production
18
+ FROM node:20.18.0-alpine AS production-stage
19
+
20
+ # Installer serve globalement
21
+ RUN npm install -g serve
22
+
23
+ # Créer un utilisateur non-root pour la sécurité
24
+ USER node
25
+ WORKDIR /home/node
26
+
27
+ # Copier les fichiers buildés depuis le stage précédent
28
+ COPY --chown=node:node --from=build-stage /app/dist ./dist
29
+
30
+ # Exposer le port 7860 (requis par Hugging Face Spaces)
31
+ EXPOSE 7860
32
+
33
+ # Commande pour servir l'application
34
+ CMD ["serve", "-s", "dist", "-l", "7860"]
README.md CHANGED
@@ -1,10 +1,20 @@
1
  ---
2
- title: PointTrackApp
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Point Tracker App
3
+ emoji:
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Point Tracker App
11
+
12
+ Application Vue.js pour l'annotation et le suivi de points dans des vidéos de football.
13
+
14
+ ## Fonctionnalités
15
+
16
+ - 🎯 Annotation de points sur vidéo
17
+ - 🏷️ Système de labellisation (Player Team 1, Player Team 2, Ball)
18
+ - ⌨️ Raccourcis clavier pour une utilisation rapide
19
+ - 📊 Export des annotations au format JSON
20
+ - 🔍 Vue zoom et timeline
babel.config.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ module.exports = {
2
+ presets: [
3
+ '@vue/cli-plugin-babel/preset'
4
+ ]
5
+ }
index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <title>Vite App</title>
9
+ </head>
10
+ <body>
11
+ <div id="app"></div>
12
+ <script type="module" src="/src/main.js"></script>
13
+ </body>
14
+ </html>
jsconfig.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "module": "esnext",
5
+ "baseUrl": "./",
6
+ "moduleResolution": "node",
7
+ "paths": {
8
+ "@/*": [
9
+ "src/*"
10
+ ]
11
+ },
12
+ "lib": [
13
+ "esnext",
14
+ "dom",
15
+ "dom.iterable",
16
+ "scripthost"
17
+ ]
18
+ }
19
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "point-tracker-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "serve": "vue-cli-service serve",
7
+ "build": "vue-cli-service build",
8
+ "lint": "vue-cli-service lint"
9
+ },
10
+ "dependencies": {
11
+ "core-js": "^3.8.3",
12
+ "js-yaml": "^4.1.0",
13
+ "konva": "^9.3.20",
14
+ "pinia": "^3.0.2",
15
+ "vue": "^3.2.13",
16
+ "vue-konva": "^3.2.1",
17
+ "vue-router": "^4.5.0"
18
+ },
19
+ "devDependencies": {
20
+ "@babel/core": "^7.12.16",
21
+ "@babel/eslint-parser": "^7.12.16",
22
+ "@vue/cli-plugin-babel": "~5.0.0",
23
+ "@vue/cli-plugin-eslint": "~5.0.0",
24
+ "@vue/cli-service": "~5.0.0",
25
+ "eslint": "^7.32.0",
26
+ "eslint-plugin-vue": "^8.0.3"
27
+ },
28
+ "eslintConfig": {
29
+ "root": true,
30
+ "env": {
31
+ "node": true
32
+ },
33
+ "extends": [
34
+ "plugin:vue/vue3-essential",
35
+ "eslint:recommended"
36
+ ],
37
+ "parserOptions": {
38
+ "parser": "@babel/eslint-parser"
39
+ },
40
+ "rules": {}
41
+ },
42
+ "browserslist": [
43
+ "> 1%",
44
+ "last 2 versions",
45
+ "not dead",
46
+ "not ie 11"
47
+ ]
48
+ }
public/favicon.ico ADDED
public/index.html ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
7
+ <link rel="icon" href="<%= BASE_URL %>favicon.ico">
8
+ <title><%= htmlWebpackPlugin.options.title %></title>
9
+ </head>
10
+ <body>
11
+ <noscript>
12
+ <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
13
+ </noscript>
14
+ <div id="app"></div>
15
+ <!-- built files will be auto injected -->
16
+ </body>
17
+ </html>
src/App.vue ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div id="app">
3
+ <router-view />
4
+ </div>
5
+ </template>
6
+
7
+ <script>
8
+ export default {
9
+ name: 'App'
10
+ }
11
+ </script>
12
+
13
+ <style>
14
+ #app {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
16
+ -webkit-font-smoothing: antialiased;
17
+ -moz-osx-font-smoothing: grayscale;
18
+ height: 100vh;
19
+ overflow: hidden;
20
+ }
21
+
22
+ * {
23
+ margin: 0;
24
+ padding: 0;
25
+ box-sizing: border-box;
26
+ }
27
+ </style>
28
+
29
+
30
+ <!-- npm run electron:serve -->
31
+ <!-- uvicorn main:app --reload -->
src/assets/football.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f9a5a4d58029c92484c0ca7ed7abeae32e710115386f078f4da4236b2bc551f6
3
+ size 144517071
src/assets/imgFoot.jpg ADDED

Git LFS Details

  • SHA256: a681562c393f5fcd34f36f21adf6614a9fb993dd9fedfd0b267cb1877d2da250
  • Pointer size: 131 Bytes
  • Size of remote file: 773 kB
src/assets/logo.jpg ADDED
src/components/AnnotationsRawView.vue ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="annotations-raw-view">
3
+ <div class="raw-header">
4
+ <h4>Annotations Brutes</h4>
5
+ <div class="frame-info">
6
+ <span>Frame {{ currentFrameNumber }}</span>
7
+ <span v-if="selectedObject">{{ selectedObject.name }}</span>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="raw-content" v-if="selectedAnnotations.length > 0">
12
+ <div
13
+ v-for="annotation in selectedAnnotations"
14
+ :key="annotation.id"
15
+ class="annotation-item"
16
+ :class="`annotation-${annotation.type}`"
17
+ >
18
+ <div class="annotation-header">
19
+ <span class="annotation-type">{{ getAnnotationTypeLabel(annotation.type) }}</span>
20
+ <span class="annotation-id">ID: {{ annotation.id.slice(0, 8) }}...</span>
21
+ </div>
22
+
23
+ <div class="annotation-data">
24
+ <div v-if="annotation.type === 'rectangle'" class="rectangle-data">
25
+ <div class="data-row">
26
+ <span class="data-label">Position:</span>
27
+ <span class="data-value">{{ Math.round(annotation.x) }}, {{ Math.round(annotation.y) }}</span>
28
+ </div>
29
+ <div class="data-row">
30
+ <span class="data-label">Dimensions:</span>
31
+ <span class="data-value">{{ Math.round(annotation.width) }} × {{ Math.round(annotation.height) }}</span>
32
+ </div>
33
+ </div>
34
+
35
+ <div v-else-if="annotation.type === 'point'" class="point-data">
36
+ <div class="data-row">
37
+ <span class="data-label">Position:</span>
38
+ <span class="data-value">{{ Math.round(annotation.x) }}, {{ Math.round(annotation.y) }}</span>
39
+ </div>
40
+ <div class="data-row">
41
+ <span class="data-label">Type:</span>
42
+ <span class="data-value" :class="`point-${annotation.pointType}`">
43
+ {{ annotation.pointType === 'positive' ? 'Positif (+)' : 'Négatif (-)' }}
44
+ </span>
45
+ </div>
46
+ </div>
47
+
48
+ <div v-else-if="annotation.type === 'mask'" class="mask-data">
49
+ <div class="data-row">
50
+ <span class="data-label">Score:</span>
51
+ <span class="data-value">{{ (annotation.maskScore * 100).toFixed(1) }}%</span>
52
+ </div>
53
+ <div class="data-row">
54
+ <span class="data-label">Taille image:</span>
55
+ <span class="data-value">{{ annotation.maskImageSize?.width }} × {{ annotation.maskImageSize?.height }}</span>
56
+ </div>
57
+ <div class="data-row">
58
+ <span class="data-label">Points:</span>
59
+ <span class="data-value">{{ annotation.points?.length || 0 }} points</span>
60
+ </div>
61
+ <div v-if="annotation.points && annotation.points.length > 0" class="points-details">
62
+ <div v-for="(point, pointIndex) in annotation.points" :key="pointIndex" class="point-detail">
63
+ <span class="point-coords">{{ Math.round(point.x) }}, {{ Math.round(point.y) }}</span>
64
+ <span class="point-type" :class="`point-${point.type}`">{{ point.type }}</span>
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ <div v-else class="generic-data">
70
+ <div class="data-row">
71
+ <span class="data-label">Données:</span>
72
+ <span class="data-value">{{ JSON.stringify(annotation, null, 2) }}</span>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div v-else class="no-annotations">
80
+ <div class="no-annotations-content">
81
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
82
+ <path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M11,16.5L6.5,12L7.91,10.59L11,13.67L16.59,8.09L18,9.5L11,16.5Z" opacity="0.3"/>
83
+ </svg>
84
+ <p v-if="!selectedObject">Aucun objet sélectionné</p>
85
+ <p v-else>Aucune annotation pour cet objet sur cette frame</p>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </template>
90
+
91
+ <script>
92
+ import { useAnnotationStore } from '@/stores/annotationStore'
93
+ import { useVideoStore } from '@/stores/videoStore'
94
+ import { computed } from 'vue'
95
+
96
+ export default {
97
+ name: 'AnnotationsRawView',
98
+
99
+ setup() {
100
+ const annotationStore = useAnnotationStore()
101
+ const videoStore = useVideoStore()
102
+
103
+ const getCurrentFrameNumber = () => {
104
+ const frameRate = annotationStore.currentSession?.frameRate || 30
105
+ return Math.round(videoStore.currentTime * frameRate)
106
+ }
107
+
108
+ const currentFrameNumber = computed(() => getCurrentFrameNumber())
109
+
110
+ const selectedObject = computed(() => {
111
+ if (!annotationStore.selectedObjectId) return null
112
+ return annotationStore.objects[annotationStore.selectedObjectId]
113
+ })
114
+
115
+ const selectedAnnotations = computed(() => {
116
+ const currentFrame = getCurrentFrameNumber()
117
+ const frameAnnotations = annotationStore.getAnnotationsForFrame(currentFrame) || []
118
+ return frameAnnotations.filter(
119
+ annotation => annotation && annotation.objectId === annotationStore.selectedObjectId
120
+ )
121
+ })
122
+
123
+ return {
124
+ currentFrameNumber,
125
+ selectedObject,
126
+ selectedAnnotations
127
+ }
128
+ },
129
+
130
+ methods: {
131
+ getAnnotationTypeLabel(type) {
132
+ const labels = {
133
+ 'rectangle': 'Rectangle',
134
+ 'point': 'Point',
135
+ 'mask': 'Masque',
136
+ 'polygon': 'Polygone'
137
+ }
138
+ return labels[type] || type.charAt(0).toUpperCase() + type.slice(1)
139
+ }
140
+ }
141
+ }
142
+ </script>
143
+
144
+ <style scoped>
145
+ .annotations-raw-view {
146
+ height: 100%;
147
+ display: flex;
148
+ flex-direction: column;
149
+ color: white;
150
+ }
151
+
152
+ .raw-header {
153
+ padding: 12px;
154
+ border-bottom: 1px solid #4a4a4a;
155
+ }
156
+
157
+ .raw-header h4 {
158
+ margin: 0 0 4px 0;
159
+ font-size: 0.9rem;
160
+ color: #fff;
161
+ }
162
+
163
+ .frame-info {
164
+ display: flex;
165
+ gap: 12px;
166
+ font-size: 0.8rem;
167
+ color: #ccc;
168
+ }
169
+
170
+ .raw-content {
171
+ flex: 1;
172
+ padding: 8px;
173
+ overflow-y: auto;
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: 8px;
177
+ }
178
+
179
+ .annotation-item {
180
+ background: #3c3c3c;
181
+ border-radius: 6px;
182
+ padding: 8px;
183
+ border-left: 3px solid #4ecdc4;
184
+ }
185
+
186
+ .annotation-item.annotation-rectangle {
187
+ border-left-color: #00ff00;
188
+ }
189
+
190
+ .annotation-item.annotation-point {
191
+ border-left-color: #ff6b35;
192
+ }
193
+
194
+ .annotation-item.annotation-mask {
195
+ border-left-color: #a55eea;
196
+ }
197
+
198
+ .annotation-header {
199
+ display: flex;
200
+ justify-content: space-between;
201
+ align-items: center;
202
+ margin-bottom: 6px;
203
+ }
204
+
205
+ .annotation-type {
206
+ font-weight: 500;
207
+ font-size: 0.8rem;
208
+ color: #fff;
209
+ }
210
+
211
+ .annotation-id {
212
+ font-size: 0.7rem;
213
+ color: #999;
214
+ font-family: monospace;
215
+ }
216
+
217
+ .annotation-data {
218
+ font-size: 0.8rem;
219
+ }
220
+
221
+ .data-row {
222
+ display: flex;
223
+ justify-content: space-between;
224
+ align-items: center;
225
+ margin-bottom: 4px;
226
+ }
227
+
228
+ .data-label {
229
+ color: #ccc;
230
+ font-weight: 500;
231
+ }
232
+
233
+ .data-value {
234
+ color: #fff;
235
+ font-family: monospace;
236
+ }
237
+
238
+ .point-positive {
239
+ color: #00ff00;
240
+ }
241
+
242
+ .point-negative {
243
+ color: #ff0000;
244
+ }
245
+
246
+ .points-details {
247
+ margin-top: 6px;
248
+ padding-left: 8px;
249
+ border-left: 1px solid #555;
250
+ }
251
+
252
+ .point-detail {
253
+ display: flex;
254
+ justify-content: space-between;
255
+ font-size: 0.7rem;
256
+ margin-bottom: 2px;
257
+ }
258
+
259
+ .point-coords {
260
+ color: #ccc;
261
+ font-family: monospace;
262
+ }
263
+
264
+ .point-type {
265
+ font-weight: 500;
266
+ }
267
+
268
+ .no-annotations {
269
+ flex: 1;
270
+ display: flex;
271
+ align-items: center;
272
+ justify-content: center;
273
+ }
274
+
275
+ .no-annotations-content {
276
+ text-align: center;
277
+ color: #666;
278
+ }
279
+
280
+ .no-annotations-content svg {
281
+ margin-bottom: 16px;
282
+ }
283
+
284
+ .no-annotations-content p {
285
+ margin: 0;
286
+ font-style: italic;
287
+ }
288
+ </style>
src/components/EmptyView.vue ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="export-view">
3
+ <div class="export-content">
4
+ <div class="export-header">
5
+ <h3>Export</h3>
6
+ </div>
7
+
8
+ <div class="export-info">
9
+ <div class="info-row">
10
+ <span>{{ objectCount }} objets, {{ annotationCount }} annotations</span>
11
+ </div>
12
+ <div class="info-row filename">
13
+ <span>{{ fileName }}</span>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="export-actions">
18
+ <button
19
+ class="export-button"
20
+ @click="exportAnnotations"
21
+ :disabled="!hasAnnotations"
22
+ >
23
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
24
+ <path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/>
25
+ </svg>
26
+ Télécharger
27
+ </button>
28
+
29
+ <p v-if="!hasAnnotations" class="no-data-message">
30
+ Aucune donnée à exporter
31
+ </p>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </template>
36
+
37
+ <script>
38
+ import { useAnnotationStore } from '@/stores/annotationStore'
39
+ import { useVideoStore } from '@/stores/videoStore'
40
+ import { computed } from 'vue'
41
+
42
+ export default {
43
+ name: 'EmptyView',
44
+
45
+ setup() {
46
+ const annotationStore = useAnnotationStore()
47
+ const videoStore = useVideoStore()
48
+
49
+ const videoName = computed(() => {
50
+ if (videoStore.selectedVideo?.name) {
51
+ return videoStore.selectedVideo.name.replace(/\.[^/.]+$/, '') // Enlever l'extension
52
+ }
53
+ return 'default_video'
54
+ })
55
+
56
+ const fileName = computed(() => {
57
+ return `${videoName.value}_config.json`
58
+ })
59
+
60
+ const objectCount = computed(() => {
61
+ return Object.keys(annotationStore.objects).length
62
+ })
63
+
64
+ const annotationCount = computed(() => {
65
+ let count = 0
66
+ Object.values(annotationStore.frameAnnotations).forEach(frameAnnotations => {
67
+ count += frameAnnotations.length
68
+ })
69
+ return count
70
+ })
71
+
72
+ const hasAnnotations = computed(() => {
73
+ return annotationCount.value > 0
74
+ })
75
+
76
+ const exportAnnotations = () => {
77
+ // Créer la structure objects selon le format demandé
78
+ const objects = Object.values(annotationStore.objects).map(obj => {
79
+ const objData = {
80
+ obj_id: parseInt(obj.id)
81
+ }
82
+
83
+ // Analyser le label pour extraire le type et l'équipe
84
+ if (obj.label) {
85
+ const label = obj.label.toLowerCase()
86
+
87
+ if (label.includes('ball')) {
88
+ objData.obj_type = 'ball'
89
+ objData.team = null
90
+ } else if (label.includes('player')) {
91
+ objData.obj_type = 'player'
92
+
93
+ // Extraire le numéro d'équipe
94
+ if (label.includes('team 1') || label.includes('team1')) {
95
+ objData.team = 1
96
+ } else if (label.includes('team 2') || label.includes('team2')) {
97
+ objData.team = 2
98
+ } else {
99
+ objData.team = null
100
+ }
101
+ } else {
102
+ objData.obj_type = null
103
+ objData.team = null
104
+ }
105
+ } else {
106
+ objData.obj_type = null
107
+ objData.team = null
108
+ }
109
+
110
+ return objData
111
+ })
112
+
113
+ // Créer la structure initial_annotations selon le format demandé
114
+ const initial_annotations = []
115
+
116
+ // Parcourir toutes les frames qui ont des annotations
117
+ Object.keys(annotationStore.frameAnnotations).forEach(frameNumber => {
118
+ const frameAnnotations = annotationStore.frameAnnotations[frameNumber]
119
+ const frameData = {
120
+ frame: parseInt(frameNumber),
121
+ annotations: []
122
+ }
123
+
124
+ // Grouper les annotations par objet pour cette frame
125
+ const annotationsByObject = {}
126
+ frameAnnotations.forEach(annotation => {
127
+ if (!annotationsByObject[annotation.objectId]) {
128
+ annotationsByObject[annotation.objectId] = []
129
+ }
130
+
131
+ // Extraire les points selon le type d'annotation
132
+ if (annotation.type === 'point') {
133
+ annotationsByObject[annotation.objectId].push({
134
+ x: Math.round(annotation.x),
135
+ y: Math.round(annotation.y),
136
+ label: annotation.pointType === 'positive' ? 1 : 0
137
+ })
138
+ } else if (annotation.type === 'mask' && annotation.points) {
139
+ // Ajouter tous les points du masque
140
+ annotation.points.forEach(point => {
141
+ annotationsByObject[annotation.objectId].push({
142
+ x: Math.round(point.x),
143
+ y: Math.round(point.y),
144
+ label: point.type === 'positive' ? 1 : 0
145
+ })
146
+ })
147
+ }
148
+ })
149
+
150
+ // Créer les annotations pour cette frame
151
+ Object.keys(annotationsByObject).forEach(objectId => {
152
+ const points = annotationsByObject[objectId]
153
+ if (points.length > 0) {
154
+ frameData.annotations.push({
155
+ obj_id: parseInt(objectId),
156
+ points: points
157
+ })
158
+ }
159
+ })
160
+
161
+ // Ajouter la frame seulement si elle a des annotations
162
+ if (frameData.annotations.length > 0) {
163
+ initial_annotations.push(frameData)
164
+ }
165
+ })
166
+
167
+ // Structure finale selon le format demandé
168
+ const exportData = {
169
+ objects: objects,
170
+ initial_annotations: initial_annotations
171
+ }
172
+
173
+ // Créer le blob et le télécharger
174
+ const jsonString = JSON.stringify(exportData, null, 2)
175
+ const blob = new Blob([jsonString], { type: 'application/json' })
176
+ const url = URL.createObjectURL(blob)
177
+
178
+ const link = document.createElement('a')
179
+ link.href = url
180
+ link.download = fileName.value
181
+ document.body.appendChild(link)
182
+ link.click()
183
+ document.body.removeChild(link)
184
+ URL.revokeObjectURL(url)
185
+
186
+ console.log('Annotations exportées:', fileName.value)
187
+ }
188
+
189
+ return {
190
+ videoName,
191
+ fileName,
192
+ objectCount,
193
+ annotationCount,
194
+ hasAnnotations,
195
+ exportAnnotations
196
+ }
197
+ }
198
+ }
199
+ </script>
200
+
201
+ <style scoped>
202
+ .export-view {
203
+ height: 100%;
204
+ display: flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ color: white;
208
+ padding: 20px;
209
+ }
210
+
211
+ .export-content {
212
+ text-align: center;
213
+ width: 100%;
214
+ }
215
+
216
+ .export-header {
217
+ margin-bottom: 20px;
218
+ }
219
+
220
+ .export-header h3 {
221
+ margin: 0;
222
+ font-size: 1rem;
223
+ color: #fff;
224
+ font-weight: 500;
225
+ }
226
+
227
+ .export-info {
228
+ margin-bottom: 20px;
229
+ }
230
+
231
+ .info-row {
232
+ margin-bottom: 8px;
233
+ color: #ccc;
234
+ font-size: 0.9rem;
235
+ }
236
+
237
+ .info-row.filename {
238
+ color: #fff;
239
+ font-family: monospace;
240
+ font-size: 0.8rem;
241
+ }
242
+
243
+ .export-actions {
244
+ text-align: center;
245
+ }
246
+
247
+ .export-button {
248
+ display: inline-flex;
249
+ align-items: center;
250
+ gap: 6px;
251
+ padding: 8px 16px;
252
+ background: #4a4a4a;
253
+ color: white;
254
+ border: none;
255
+ border-radius: 4px;
256
+ font-size: 0.8rem;
257
+ cursor: pointer;
258
+ transition: background 0.2s;
259
+ }
260
+
261
+ .export-button:hover:not(:disabled) {
262
+ background: #5a5a5a;
263
+ }
264
+
265
+ .export-button:disabled {
266
+ background: #3c3c3c;
267
+ color: #666;
268
+ cursor: not-allowed;
269
+ }
270
+
271
+ .no-data-message {
272
+ margin: 12px 0 0 0;
273
+ color: #666;
274
+ font-size: 0.8rem;
275
+ font-style: italic;
276
+ }
277
+ </style>
src/components/EnrichedSection.vue ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="enriched-section">
3
+ <div class="enriched-header">
4
+ <div class="view-navigation">
5
+ <button
6
+ class="nav-button"
7
+ @click="previousView"
8
+ >
9
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
10
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
11
+ </svg>
12
+ </button>
13
+
14
+ <span class="view-indicator">
15
+ {{ currentViewIndex + 1 }} / {{ views.length }}
16
+ </span>
17
+
18
+ <button
19
+ class="nav-button"
20
+ @click="nextView"
21
+ >
22
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
23
+ <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
24
+ </svg>
25
+ </button>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="enriched-content">
30
+ <transition :name="transitionName" mode="out-in">
31
+ <component
32
+ :is="currentView.component"
33
+ :key="currentViewIndex"
34
+ class="view-content"
35
+ />
36
+ </transition>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script>
42
+ import LabelingView from './LabelingView.vue'
43
+ import EmptyView from './EmptyView.vue'
44
+ import ShortcutsView from './ShortcutsView.vue'
45
+
46
+ export default {
47
+ name: 'EnrichedSection',
48
+
49
+ components: {
50
+ LabelingView,
51
+ EmptyView,
52
+ ShortcutsView
53
+ },
54
+
55
+ data() {
56
+ return {
57
+ currentViewIndex: 0,
58
+ transitionName: 'slide-right',
59
+ views: [
60
+ {
61
+ name: 'Labeling',
62
+ component: 'LabelingView'
63
+ },
64
+ {
65
+ name: 'Export',
66
+ component: 'EmptyView'
67
+ },
68
+ {
69
+ name: 'Shortcuts',
70
+ component: 'ShortcutsView'
71
+ }
72
+ ]
73
+ }
74
+ },
75
+
76
+ computed: {
77
+ currentView() {
78
+ return this.views[this.currentViewIndex]
79
+ }
80
+ },
81
+
82
+ methods: {
83
+ nextView() {
84
+ this.transitionName = 'slide-right'
85
+ this.currentViewIndex = (this.currentViewIndex + 1) % this.views.length
86
+ },
87
+
88
+ previousView() {
89
+ this.transitionName = 'slide-left'
90
+ this.currentViewIndex = this.currentViewIndex === 0
91
+ ? this.views.length - 1
92
+ : this.currentViewIndex - 1
93
+ }
94
+ }
95
+ }
96
+ </script>
97
+
98
+ <style scoped>
99
+ .enriched-section {
100
+ width: 100%;
101
+ height: 100%;
102
+ background: #2c2c2c;
103
+ border-radius: 8px;
104
+ display: flex;
105
+ flex-direction: column;
106
+ overflow: hidden;
107
+ }
108
+
109
+ .enriched-header {
110
+ padding: 8px 12px;
111
+ background: #3c3c3c;
112
+ border-bottom: 1px solid #4a4a4a;
113
+ display: flex;
114
+ justify-content: center;
115
+ align-items: center;
116
+ }
117
+
118
+ .view-navigation {
119
+ display: flex;
120
+ align-items: center;
121
+ gap: 8px;
122
+ }
123
+
124
+ .nav-button {
125
+ background: #4a4a4a;
126
+ border: none;
127
+ border-radius: 4px;
128
+ color: white;
129
+ width: 24px;
130
+ height: 24px;
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ cursor: pointer;
135
+ transition: all 0.2s;
136
+ }
137
+
138
+ .nav-button:hover:not(:disabled) {
139
+ background: #5a5a5a;
140
+ }
141
+
142
+ .view-indicator {
143
+ color: #ccc;
144
+ font-size: 0.8rem;
145
+ min-width: 40px;
146
+ text-align: center;
147
+ }
148
+
149
+ .enriched-content {
150
+ flex: 1;
151
+ position: relative;
152
+ overflow: hidden;
153
+ }
154
+
155
+ .view-content {
156
+ position: absolute;
157
+ top: 0;
158
+ left: 0;
159
+ right: 0;
160
+ bottom: 0;
161
+ }
162
+
163
+ /* Animations de transition */
164
+ .slide-right-enter-active, .slide-right-leave-active,
165
+ .slide-left-enter-active, .slide-left-leave-active {
166
+ transition: transform 0.3s ease;
167
+ }
168
+
169
+ /* Animation vers la droite */
170
+ .slide-right-enter-from {
171
+ transform: translateX(100%);
172
+ }
173
+
174
+ .slide-right-leave-to {
175
+ transform: translateX(-100%);
176
+ }
177
+
178
+ /* Animation vers la gauche */
179
+ .slide-left-enter-from {
180
+ transform: translateX(-100%);
181
+ }
182
+
183
+ .slide-left-leave-to {
184
+ transform: translateX(100%);
185
+ }
186
+ </style>
src/components/LabelingView.vue ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="labeling-view">
3
+ <div class="labels-container" v-if="selectedObject">
4
+ <div class="label-options">
5
+ <div class="label-list">
6
+ <div
7
+ v-for="label in predefinedLabels"
8
+ :key="label.name"
9
+ class="label-item"
10
+ >
11
+ <button
12
+ class="label-btn"
13
+ :class="{ active: selectedObject.label === label.name }"
14
+ :style="{
15
+ backgroundColor: label.color,
16
+ border: selectedObject.label === label.name ? '3px solid #fff' : '3px solid transparent'
17
+ }"
18
+ @click="assignLabel(label.name)"
19
+ >
20
+ <span class="label-text">{{ label.name }}</span>
21
+ <span class="shortcut-key">{{ getShortcutKey(label.name) }}</span>
22
+ </button>
23
+ <input
24
+ type="color"
25
+ :value="label.color"
26
+ @input="updateLabelColor(label.name, $event.target.value)"
27
+ class="color-picker"
28
+ :title="`Changer la couleur de ${label.name}`"
29
+ />
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+
35
+ <div v-else class="no-object-message">
36
+ <p>Sélectionnez un objet dans la timeline pour lui attribuer un label</p>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script>
42
+ import { useAnnotationStore } from '@/stores/annotationStore'
43
+
44
+ export default {
45
+ name: 'LabelingView',
46
+
47
+ data() {
48
+ return {
49
+ predefinedLabels: [
50
+ { name: 'Player Team 1', color: '#dc3545' },
51
+ { name: 'Player Team 2', color: '#000000' },
52
+ { name: 'Ball', color: '#28a745' }
53
+ ]
54
+ }
55
+ },
56
+
57
+ setup() {
58
+ const annotationStore = useAnnotationStore()
59
+ return {
60
+ annotationStore
61
+ }
62
+ },
63
+
64
+ mounted() {
65
+ // Ajouter l'écouteur d'événement clavier
66
+ window.addEventListener('keydown', this.handleKeyPress)
67
+ },
68
+
69
+ beforeUnmount() {
70
+ // Supprimer l'écouteur d'événement clavier
71
+ window.removeEventListener('keydown', this.handleKeyPress)
72
+ },
73
+
74
+ computed: {
75
+ selectedObject() {
76
+ if (!this.annotationStore.selectedObjectId) return null
77
+ return this.annotationStore.objects[this.annotationStore.selectedObjectId]
78
+ }
79
+ },
80
+
81
+ methods: {
82
+ assignLabel(labelName) {
83
+ if (!this.selectedObject) return
84
+
85
+ // Mettre à jour l'objet avec le nouveau label
86
+ this.annotationStore.objects[this.selectedObject.id] = {
87
+ ...this.selectedObject,
88
+ label: labelName
89
+ }
90
+
91
+ console.log(`Label "${labelName}" assigné à l'objet ${this.selectedObject.name}`)
92
+ },
93
+
94
+ updateLabelColor(labelName, newColor) {
95
+ const labelIndex = this.predefinedLabels.findIndex(label => label.name === labelName)
96
+ if (labelIndex !== -1) {
97
+ this.predefinedLabels[labelIndex].color = newColor
98
+ }
99
+ },
100
+
101
+ removeLabel() {
102
+ if (!this.selectedObject) return
103
+
104
+ // Supprimer le label de l'objet
105
+ const updatedObject = { ...this.selectedObject }
106
+ delete updatedObject.label
107
+
108
+ this.annotationStore.objects[this.selectedObject.id] = updatedObject
109
+
110
+ console.log(`Label supprimé de l'objet ${this.selectedObject.name}`)
111
+ },
112
+
113
+ getLabelColor(labelName) {
114
+ const predefinedLabel = this.predefinedLabels.find(label => label.name === labelName)
115
+ return predefinedLabel ? predefinedLabel.color : '#6c757d'
116
+ },
117
+
118
+ getShortcutKey(labelName) {
119
+ const shortcuts = {
120
+ 'Player Team 1': '1',
121
+ 'Player Team 2': '2',
122
+ 'Ball': '3'
123
+ }
124
+ return shortcuts[labelName] || ''
125
+ },
126
+
127
+ handleKeyPress(event) {
128
+ // Vérifier que nous ne sommes pas dans un champ de saisie
129
+ if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
130
+ return
131
+ }
132
+
133
+ // Vérifier qu'un objet est sélectionné
134
+ if (!this.selectedObject) {
135
+ return
136
+ }
137
+
138
+ // Gérer les raccourcis clavier
139
+ switch(event.key) {
140
+ case '1':
141
+ event.preventDefault()
142
+ this.assignLabel('Player Team 1')
143
+ break
144
+ case '2':
145
+ event.preventDefault()
146
+ this.assignLabel('Player Team 2')
147
+ break
148
+ case '3':
149
+ event.preventDefault()
150
+ this.assignLabel('Ball')
151
+ break
152
+ }
153
+ }
154
+ }
155
+ }
156
+ </script>
157
+
158
+ <style scoped>
159
+ .labeling-view {
160
+ height: 100%;
161
+ padding: 16px;
162
+ color: white;
163
+ overflow-y: auto;
164
+ }
165
+
166
+ .labels-container {
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 20px;
170
+ }
171
+
172
+ .label-list {
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: 8px;
176
+ }
177
+
178
+ .label-item {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 8px;
182
+ }
183
+
184
+ .label-btn {
185
+ flex: 1;
186
+ padding: 8px 12px;
187
+ border-radius: 4px;
188
+ color: white;
189
+ font-size: 0.8rem;
190
+ font-weight: 500;
191
+ cursor: pointer;
192
+ transition: all 0.2s;
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: space-between;
196
+ gap: 8px;
197
+ }
198
+
199
+ .label-btn:hover {
200
+ opacity: 0.8;
201
+ transform: translateY(-1px);
202
+ }
203
+
204
+ .label-btn.active {
205
+ box-shadow: 0 0 0 1px #fff;
206
+ }
207
+
208
+ .color-picker {
209
+ width: 32px;
210
+ height: 32px;
211
+ border: none;
212
+ border-radius: 4px;
213
+ cursor: pointer;
214
+ background: none;
215
+ padding: 0;
216
+ }
217
+
218
+ .color-picker::-webkit-color-swatch-wrapper {
219
+ padding: 0;
220
+ border-radius: 4px;
221
+ }
222
+
223
+ .color-picker::-webkit-color-swatch {
224
+ border: none;
225
+ border-radius: 4px;
226
+ }
227
+
228
+ .label-text {
229
+ flex: 1;
230
+ text-align: left;
231
+ }
232
+
233
+ .shortcut-key {
234
+ background-color: rgba(255, 255, 255, 0.2);
235
+ border-radius: 3px;
236
+ padding: 2px 6px;
237
+ font-size: 11px;
238
+ font-weight: bold;
239
+ min-width: 16px;
240
+ text-align: center;
241
+ }
242
+
243
+ .no-object-message {
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ height: 100%;
248
+ text-align: center;
249
+ }
250
+
251
+ .no-object-message p {
252
+ color: #999;
253
+ font-style: italic;
254
+ }
255
+ </style>
src/components/ObjectItem.vue ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div
3
+ class="object-item"
4
+ :class="{ 'selected': isSelected }"
5
+ @click="toggleSelection"
6
+ >
7
+ <div class="object-id">
8
+ <span>{{ objectId }}</span>
9
+ </div>
10
+ <div class="object-timeline" ref="timelineRef">
11
+ <div class="timeline-line"></div>
12
+ <!-- Points pour chaque annotation -->
13
+ <div
14
+ v-for="(frameKey, index) in annotatedFrames"
15
+ :key="index"
16
+ class="annotation-point"
17
+ :style="{
18
+ left: `${calculatePositionExact(parseInt(frameKey))}%`,
19
+ backgroundColor: getObjectColor
20
+ }"
21
+ :title="`Frame ${frameKey}`"
22
+ @click.stop="goToFrame(parseInt(frameKey))"
23
+ ></div>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- Popup de confirmation simplifiée -->
28
+ <div v-if="showDeleteConfirm" class="delete-overlay" @click="cancelDelete">
29
+ <div class="delete-modal" @click.stop>
30
+ <h3>Supprimer {{ objectId }} ?</h3>
31
+ <p>Cette action est irréversible.</p>
32
+
33
+ <div class="delete-actions">
34
+ <button class="btn-cancel" @click="cancelDelete">Annuler</button>
35
+ <button class="btn-delete" @click="confirmDelete">Supprimer</button>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script>
42
+ import { useAnnotationStore } from '@/stores/annotationStore'
43
+ import { useVideoStore } from '@/stores/videoStore'
44
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
45
+
46
+ export default {
47
+ name: 'ObjectItem',
48
+ props: {
49
+ objectId: {
50
+ type: String,
51
+ default: 'object1'
52
+ },
53
+ colorIndex: {
54
+ type: Number,
55
+ default: 0
56
+ }
57
+ },
58
+ setup(props) {
59
+ const annotationStore = useAnnotationStore()
60
+ const videoStore = useVideoStore()
61
+ const timelineRef = ref(null)
62
+ const showDeleteConfirm = ref(false)
63
+
64
+ // Keyboard shortcut handler
65
+ const handleKeyDown = (event) => {
66
+ // Add new object when pressing 'N' key
67
+ if (event.key === 'n' || event.key === 'N') {
68
+ // Prevent default behavior (like typing 'n' in an input field)
69
+ event.preventDefault()
70
+
71
+ // Only process the event if this is the first object item
72
+ // This prevents multiple objects from being created when multiple ObjectItems exist
73
+ if (props.objectId !== Object.keys(annotationStore.objects)[0]) {
74
+ return;
75
+ }
76
+
77
+ // Check available methods and use the correct one
78
+ if (typeof annotationStore.addObject === 'function') {
79
+ annotationStore.addObject();
80
+ } else if (typeof annotationStore.createNewObject === 'function') {
81
+ annotationStore.createNewObject();
82
+ } else {
83
+ // Fallback: Create a new object ID based on the last object ID + 1
84
+ const objectIds = Object.keys(annotationStore.objects);
85
+ let lastId = 0;
86
+
87
+ // Find the highest numeric ID
88
+ objectIds.forEach(id => {
89
+ // Extract numeric part from objectX format
90
+ const numericPart = parseInt(id.replace('object', ''));
91
+ if (!isNaN(numericPart) && numericPart > lastId) {
92
+ lastId = numericPart;
93
+ }
94
+ });
95
+
96
+ // Create new object with ID = last ID + 1
97
+ const newObjectId = `object${lastId + 1}`;
98
+ annotationStore.objects[newObjectId] = {
99
+ id: newObjectId,
100
+ color: annotationStore.getNextColor(),
101
+ // Add any other required properties
102
+ };
103
+ console.log(`Created new object: ${newObjectId}`);
104
+ }
105
+ }
106
+
107
+ // Delete selected object when pressing Ctrl+Delete key
108
+ if (event.key === 'Delete' && event.ctrlKey && annotationStore.selectedObjectId === props.objectId) {
109
+ event.preventDefault()
110
+ showDeleteConfirm.value = true
111
+ }
112
+
113
+ // Fermer la popup avec Escape
114
+ if (event.key === 'Escape' && showDeleteConfirm.value) {
115
+ event.preventDefault()
116
+ showDeleteConfirm.value = false
117
+ }
118
+ }
119
+
120
+ // Add and remove event listeners
121
+ onMounted(() => {
122
+ window.addEventListener('keydown', handleKeyDown)
123
+ })
124
+
125
+ onUnmounted(() => {
126
+ window.removeEventListener('keydown', handleKeyDown)
127
+ })
128
+
129
+ // Vérifier si cet objet est actuellement sélectionné
130
+ const isSelected = computed(() => {
131
+ return annotationStore.selectedObjectId === props.objectId
132
+ })
133
+
134
+ // Fonction pour basculer la sélection de l'objet
135
+ const toggleSelection = () => {
136
+ if (isSelected.value) {
137
+ annotationStore.deselectObject()
138
+ } else {
139
+ annotationStore.selectObject(props.objectId)
140
+ }
141
+ }
142
+
143
+ // Fonctions pour la popup de confirmation
144
+ const confirmDelete = () => {
145
+ annotationStore.deleteObject(props.objectId)
146
+ showDeleteConfirm.value = false
147
+ }
148
+
149
+ const cancelDelete = () => {
150
+ showDeleteConfirm.value = false
151
+ }
152
+
153
+ // Récupérer toutes les frames où cet objet a des annotations
154
+ const annotatedFrames = computed(() => {
155
+ const frames = []
156
+ Object.keys(annotationStore.frameAnnotations).forEach(frameKey => {
157
+ const hasObjectAnnotation = annotationStore.frameAnnotations[frameKey].some(
158
+ annotation => annotation.objectId === props.objectId
159
+ )
160
+ if (hasObjectAnnotation) {
161
+ frames.push(frameKey)
162
+ }
163
+ })
164
+ return frames
165
+ })
166
+
167
+ // Obtenir la couleur de l'objet
168
+ const getObjectColor = computed(() => {
169
+ return annotationStore.objects[props.objectId]?.color || '#4CAF50'
170
+ })
171
+
172
+ // Calculer la position en pourcentage pour une frame donnée
173
+ const calculatePositionExact = (frameNumber) => {
174
+ const frameRate = annotationStore.currentSession.frameRate || 30
175
+ const timeInSeconds = frameNumber / frameRate
176
+ const videoDuration = videoStore.duration || videoStore.selectedVideo?.duration || 0
177
+
178
+ if (!videoDuration || videoDuration <= 0) {
179
+ console.warn('Attention: Durée de vidéo non disponible, utilisation d\'une valeur par défaut')
180
+ return 0 // Ou retourner une position par défaut
181
+ }
182
+
183
+ return (timeInSeconds / videoDuration) * 100
184
+ }
185
+
186
+ // Fonction pour naviguer vers une frame spécifique
187
+ const goToFrame = (frameNumber) => {
188
+ // Convertir le numéro de frame en temps (secondes)
189
+ const frameRate = annotationStore.currentSession.frameRate || 30
190
+
191
+ // Utiliser une valeur exacte pour le temps en secondes
192
+ // Ajouter un petit décalage (0.001) pour éviter les problèmes d'arrondi
193
+ const timeInSeconds = frameNumber / frameRate + 0.001
194
+
195
+ // Mettre à jour le temps courant dans le videoStore
196
+ videoStore.setCurrentTime(timeInSeconds)
197
+
198
+ // Utiliser la méthode seek si disponible
199
+ if (videoStore.seek) {
200
+ videoStore.seek(timeInSeconds)
201
+ } else {
202
+ // Fallback: essayer d'accéder directement à l'élément vidéo
203
+ const videoElement = document.querySelector('video')
204
+ if (videoElement) {
205
+ videoElement.currentTime = timeInSeconds
206
+ }
207
+ }
208
+
209
+ // Forcer la mise à jour de l'interface
210
+ videoStore.updateProgressBar(timeInSeconds)
211
+ }
212
+
213
+ return {
214
+ annotatedFrames,
215
+ calculatePositionExact,
216
+ getObjectColor,
217
+ timelineRef,
218
+ isSelected,
219
+ toggleSelection,
220
+ goToFrame,
221
+ showDeleteConfirm,
222
+ confirmDelete,
223
+ cancelDelete
224
+ }
225
+ }
226
+ }
227
+ </script>
228
+
229
+ <style scoped>
230
+ .object-item {
231
+ display: flex;
232
+ height: 24px;
233
+ margin-bottom: 18px;
234
+ align-items: center;
235
+ gap: 14px;
236
+ cursor: pointer;
237
+ transition: background-color 0.2s ease;
238
+ border-radius: 4px;
239
+ padding: 2px 4px;
240
+ position: relative;
241
+ }
242
+
243
+ .object-item:hover {
244
+ background-color: rgba(255, 255, 255, 0.1);
245
+ }
246
+
247
+ .object-item.selected {
248
+ background-color: rgba(255, 255, 255, 0.2);
249
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5);
250
+ }
251
+
252
+ .object-id {
253
+ width: 35px;
254
+ font-weight: bold;
255
+ font-size: 0.9rem;
256
+ overflow: hidden;
257
+ text-overflow: ellipsis;
258
+ white-space: nowrap;
259
+ color: white;
260
+ }
261
+
262
+ .object-timeline {
263
+ flex-grow: 1;
264
+ height: 100%;
265
+ position: relative;
266
+ border-radius: 4px;
267
+ display: flex;
268
+ align-items: center;
269
+ border-color: white;
270
+ }
271
+
272
+ .timeline-line {
273
+ height: 1px;
274
+ width: 100%;
275
+ background-color: white;
276
+ }
277
+
278
+ .annotation-point {
279
+ position: absolute;
280
+ width: 8px;
281
+ height: 8px;
282
+ background-color: #4CAF50;
283
+ border-radius: 50%;
284
+ transform: translateX(-50%);
285
+ z-index: 2;
286
+ }
287
+
288
+ /* Popup de confirmation simplifiée */
289
+ .delete-overlay {
290
+ position: fixed;
291
+ top: 0;
292
+ left: 0;
293
+ right: 0;
294
+ bottom: 0;
295
+ background-color: rgba(0, 0, 0, 0.6);
296
+ display: flex;
297
+ align-items: center;
298
+ justify-content: center;
299
+ z-index: 1000;
300
+ }
301
+
302
+ .delete-modal {
303
+ background: #2c2c2c;
304
+ border-radius: 8px;
305
+ padding: 20px;
306
+ max-width: 300px;
307
+ width: 90%;
308
+ text-align: center;
309
+ color: white;
310
+ }
311
+
312
+ .delete-modal h3 {
313
+ margin: 0 0 12px 0;
314
+ font-size: 1.1rem;
315
+ color: #fff;
316
+ }
317
+
318
+ .delete-modal p {
319
+ margin: 0 0 20px 0;
320
+ color: #ccc;
321
+ font-size: 0.9rem;
322
+ }
323
+
324
+ .delete-actions {
325
+ display: flex;
326
+ gap: 12px;
327
+ justify-content: center;
328
+ }
329
+
330
+ .btn-cancel,
331
+ .btn-delete {
332
+ padding: 8px 16px;
333
+ border: none;
334
+ border-radius: 4px;
335
+ font-size: 0.9rem;
336
+ cursor: pointer;
337
+ transition: all 0.2s ease;
338
+ }
339
+
340
+ .btn-cancel {
341
+ background: #555;
342
+ color: white;
343
+ }
344
+
345
+ .btn-cancel:hover {
346
+ background: #666;
347
+ }
348
+
349
+ .btn-delete {
350
+ background: #dc3545;
351
+ color: white;
352
+ }
353
+
354
+ .btn-delete:hover {
355
+ background: #c82333;
356
+ }
357
+ </style>
src/components/ObjectTimeline.vue ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="object-timeline">
3
+ <div class="object-info">
4
+ <span class="object-name">{{ object.name }}</span>
5
+ </div>
6
+ <div class="timeline-segments">
7
+ <div
8
+ v-for="segment in object.segments"
9
+ :key="segment.id"
10
+ class="segment"
11
+ :style="{
12
+ left: `${(segment.start / videoDuration) * 100}%`,
13
+ width: `${((segment.end - segment.start) / videoDuration) * 100}%`
14
+ }"
15
+ ></div>
16
+ </div>
17
+ </div>
18
+ </template>
19
+
20
+ <script>
21
+ export default {
22
+ name: 'ObjectTimeline',
23
+
24
+ props: {
25
+ object: {
26
+ type: Object,
27
+ required: true
28
+ },
29
+ videoDuration: {
30
+ type: Number,
31
+ required: true
32
+ }
33
+ }
34
+ }
35
+ </script>
36
+
37
+ <style scoped>
38
+ .object-timeline {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 12px;
42
+ padding: 8px 0;
43
+ }
44
+
45
+ .object-info {
46
+ width: 120px;
47
+ flex-shrink: 0;
48
+ }
49
+
50
+ .object-name {
51
+ color: #ffffff;
52
+ font-size: 0.875rem;
53
+ }
54
+
55
+ .timeline-segments {
56
+ flex-grow: 1;
57
+ height: 24px;
58
+ background: #2c2c2c;
59
+ border-radius: 4px;
60
+ position: relative;
61
+ }
62
+
63
+ .segment {
64
+ position: absolute;
65
+ height: 100%;
66
+ background: #4CAF50;
67
+ opacity: 0.7;
68
+ border-radius: 2px;
69
+ }
70
+ </style>
src/components/ObjectsTimelineSection.vue ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="objects-timeline-section">
3
+ <div class="timeline-container">
4
+ <div class="objects-container">
5
+ <object-item
6
+ v-for="object in objects"
7
+ :key="object.id"
8
+ :object-id="object.id"
9
+ :object-name="object.name"
10
+ :color="object.color"
11
+ :is-selected="annotationStore.selectedObjectId === object.id"
12
+ @click="selectObject(object.id)"
13
+ />
14
+ <div class="add-object" v-if="objects.length === 0 || showAddButton">
15
+ <button class="add-button" @click="addNewObject">+</button>
16
+ </div>
17
+ <div class="empty-state" v-if="objects.length === 0">
18
+ <p>Aucun objet créé</p>
19
+ <p>Cliquez sur + pour ajouter un objet</p>
20
+ </div>
21
+ </div>
22
+ <div class="timeline-tools"></div>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script>
28
+ import ObjectItem from './ObjectItem.vue'
29
+ import { useAnnotationStore } from '@/stores/annotationStore'
30
+ import { storeToRefs } from 'pinia'
31
+
32
+ export default {
33
+ name: 'ObjectsTimelineSection',
34
+ components: {
35
+ ObjectItem
36
+ },
37
+
38
+ setup() {
39
+ const annotationStore = useAnnotationStore()
40
+ const { objects } = storeToRefs(annotationStore)
41
+
42
+ return {
43
+ annotationStore,
44
+ objects
45
+ }
46
+ },
47
+
48
+ data() {
49
+ return {
50
+ showAddButton: true
51
+ }
52
+ },
53
+
54
+ methods: {
55
+ selectObject(objectId) {
56
+ this.annotationStore.selectObject(objectId)
57
+ this.$emit('object-selected', objectId)
58
+ },
59
+
60
+ addNewObject() {
61
+ const newObjectId = this.annotationStore.addObject()
62
+ this.$emit('object-selected', newObjectId)
63
+ }
64
+ }
65
+ }
66
+ </script>
67
+
68
+ <style scoped>
69
+ .objects-timeline-section {
70
+ background: #363636;
71
+ border-radius: 8px;
72
+ height: 100%;
73
+ display: flex;
74
+ flex-direction: column;
75
+ overflow: hidden; /* Contenir tout le contenu */
76
+ }
77
+
78
+ .timeline-container {
79
+ display: flex;
80
+ gap: 10px;
81
+ flex: 1;
82
+ min-height: 0; /* Crucial pour que le scroll fonctionne dans un conteneur flex */
83
+ overflow: hidden; /* Contenir les enfants */
84
+ }
85
+
86
+ .objects-container {
87
+ flex-grow: 1;
88
+ overflow-y: auto; /* Activer le défilement vertical */
89
+ padding: 8px;
90
+ background: #2a2a2a;
91
+ border-radius: 4px;
92
+ /* Stylisation de la scrollbar */
93
+ scrollbar-width: thin;
94
+ scrollbar-color: #555 #2c2c2c;
95
+ direction: rtl; /* Déplace la scrollbar à gauche */
96
+ }
97
+
98
+ .objects-container > * {
99
+ direction: ltr; /* Rétablit la direction normale pour le contenu */
100
+ }
101
+
102
+ /* Stylisation de la scrollbar pour Chrome/Safari/Edge */
103
+ .objects-container::-webkit-scrollbar {
104
+ width: 8px;
105
+ }
106
+
107
+ .objects-container::-webkit-scrollbar-track {
108
+ background: #2c2c2c;
109
+ border-radius: 4px;
110
+ }
111
+
112
+ .objects-container::-webkit-scrollbar-thumb {
113
+ background-color: #555;
114
+ border-radius: 4px;
115
+ }
116
+
117
+ .timeline-tools {
118
+ width: 50px;
119
+ background: #2c2c2c;
120
+ border-radius: 4px;
121
+ height: 100%;
122
+ }
123
+
124
+ h3 {
125
+ color: #ffffff;
126
+ margin: 0 0 12px 0;
127
+ font-size: 1rem;
128
+ font-weight: 500;
129
+ }
130
+
131
+ .add-object {
132
+ display: flex;
133
+ justify-content: center;
134
+ margin-top: 8px;
135
+ }
136
+
137
+ .add-button {
138
+ width: 30px;
139
+ height: 30px;
140
+ border-radius: 50%;
141
+ background: #2c2c2c;
142
+ border: none;
143
+ color: white;
144
+ font-size: 20px;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ cursor: pointer;
149
+ transition: background-color 0.2s;
150
+ }
151
+
152
+ .add-button:hover {
153
+ background: #3c3c3c;
154
+ }
155
+
156
+ .empty-state {
157
+ text-align: center;
158
+ color: #888;
159
+ padding: 20px 0;
160
+ font-size: 0.9rem;
161
+ }
162
+
163
+ .empty-state p {
164
+ margin: 5px 0;
165
+ }
166
+ </style>
src/components/SegmentationSidebar.vue ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="segmentation-sidebar">
3
+ <button class="upload-video-btn" @click="uploadVideo">
4
+ <span>Upload Video</span>
5
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
6
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
7
+ <path d="m17 8-5-5-5 5"/>
8
+ <path d="M12 3v12"/>
9
+ </svg>
10
+ </button>
11
+
12
+ <!-- Input file caché -->
13
+ <input
14
+ ref="fileInput"
15
+ type="file"
16
+ accept="video/*"
17
+ @change="handleVideoUpload"
18
+ style="display: none;"
19
+ />
20
+
21
+ <div class="video-list" v-if="videoStore.videos.length">
22
+ <div
23
+ v-for="video in videoStore.videos"
24
+ :key="video.path"
25
+ class="video-item"
26
+ :class="{ active: videoStore.selectedVideo?.path === video.path }"
27
+ @click="selectVideo(video)"
28
+ >
29
+ {{ video.name }}
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </template>
34
+
35
+ <script>
36
+ import { useVideoStore } from '../stores/videoStore'
37
+
38
+ export default {
39
+ name: 'SegmentationSidebar',
40
+
41
+ data() {
42
+ const videoStore = useVideoStore()
43
+ return {
44
+ videoStore
45
+ }
46
+ },
47
+
48
+ watch: {
49
+ 'videoStore.selectedVideo': {
50
+ handler(newVideo) {
51
+ console.log('Vidéo sélectionnée:', newVideo)
52
+ },
53
+ deep: true
54
+ }
55
+ },
56
+
57
+ mounted() {
58
+ // Plus besoin de charger un dossier par défaut - l'utilisateur uploadera une vidéo
59
+ console.log('SegmentationSidebar mounted - prêt pour l\'upload de vidéo')
60
+ },
61
+
62
+ methods: {
63
+ uploadVideo() {
64
+ // Déclencher le clic sur l'input file caché
65
+ this.$refs.fileInput.click()
66
+ },
67
+
68
+ handleVideoUpload(event) {
69
+ const file = event.target.files[0]
70
+ if (file) {
71
+ console.log('Vidéo sélectionnée:', file.name)
72
+
73
+ // Créer un URL blob pour la vidéo
74
+ const videoUrl = URL.createObjectURL(file)
75
+
76
+ // Créer un objet vidéo pour le store
77
+ const videoObject = {
78
+ name: file.name,
79
+ path: videoUrl,
80
+ file: file,
81
+ size: file.size,
82
+ type: file.type
83
+ }
84
+
85
+ // Mettre à jour le store avec la nouvelle vidéo
86
+ this.videoStore.setVideos([videoObject])
87
+ this.videoStore.setSelectedVideo(videoObject)
88
+
89
+ // Émettre l'événement pour informer les autres composants
90
+ this.$emit('video-selected', videoObject)
91
+
92
+ console.log('Vidéo uploadée et sélectionnée:', videoObject)
93
+ }
94
+ },
95
+
96
+ selectVideo(video) {
97
+ this.videoStore.setSelectedVideo(video)
98
+ this.$emit('video-selected', video)
99
+ }
100
+ }
101
+ }
102
+ </script>
103
+
104
+ <style scoped>
105
+ .segmentation-sidebar {
106
+ background: #363636;
107
+ height: 100%;
108
+ width: 200px;
109
+ padding: 16px;
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 16px;
113
+ }
114
+
115
+ .navigation-menu {
116
+ display: flex;
117
+ flex-direction: column;
118
+ gap: 8px;
119
+ margin-bottom: 8px;
120
+ }
121
+
122
+ .nav-item {
123
+ display: flex;
124
+ align-items: center;
125
+ padding: 12px;
126
+ color: white;
127
+ text-decoration: none;
128
+ border-radius: 8px;
129
+ transition: all 0.2s ease;
130
+ background: #424242;
131
+ }
132
+
133
+ .nav-item:hover {
134
+ background: #4a4a4a;
135
+ }
136
+
137
+ .nav-item.active {
138
+ background: #3a3a3a;
139
+ color: #4CAF50;
140
+ }
141
+
142
+ .nav-icon {
143
+ margin-right: 12px;
144
+ font-size: 1.2rem;
145
+ }
146
+
147
+ .nav-text {
148
+ font-size: 0.9rem;
149
+ }
150
+
151
+ .upload-video-btn {
152
+ background: #424242;
153
+ border: none;
154
+ border-radius: 8px;
155
+ color: white;
156
+ padding: 12px 16px;
157
+ cursor: pointer;
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: space-between;
161
+ width: 100%;
162
+ font-size: 1rem;
163
+ flex-shrink: 0;
164
+ transition: background-color 0.2s ease;
165
+ }
166
+
167
+ .upload-video-btn:hover {
168
+ background: #4a4a4a;
169
+ }
170
+
171
+ .video-list {
172
+ height: 20vh;
173
+ background: #424242;
174
+ border-radius: 8px;
175
+ overflow-y: auto;
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 4px;
179
+ padding: 4px;
180
+ }
181
+
182
+ /* Styles pour Firefox */
183
+ .video-list {
184
+ scrollbar-width: thin;
185
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
186
+ }
187
+
188
+ /* Styles pour Chrome/Safari/Edge */
189
+ .video-list::-webkit-scrollbar {
190
+ width: 4px;
191
+ }
192
+
193
+ .video-list::-webkit-scrollbar-track {
194
+ background: transparent;
195
+ }
196
+
197
+ .video-list::-webkit-scrollbar-thumb {
198
+ background: rgba(255, 255, 255, 0.3);
199
+ border-radius: 2px;
200
+ }
201
+
202
+ .video-item {
203
+ padding: 8px 12px;
204
+ border-radius: 4px;
205
+ cursor: pointer;
206
+ color: white;
207
+ transition: background-color 0.2s;
208
+ }
209
+
210
+ .video-item:hover {
211
+ background: #4a4a4a;
212
+ }
213
+
214
+ .video-item.active {
215
+ background: #3a3a3a;
216
+ }
217
+ </style>
src/components/ShortcutsView.vue ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="shortcuts-view">
3
+ <div class="shortcuts-header">
4
+ <h3>Raccourcis Clavier</h3>
5
+ </div>
6
+
7
+ <div class="shortcuts-content">
8
+ <div class="shortcuts-section">
9
+ <h4>Labellisation</h4>
10
+ <div class="shortcut-list">
11
+ <div class="shortcut-item">
12
+ <div class="shortcut-key">1</div>
13
+ <div class="shortcut-description">Player Team 1</div>
14
+ </div>
15
+ <div class="shortcut-item">
16
+ <div class="shortcut-key">2</div>
17
+ <div class="shortcut-description">Player Team 2</div>
18
+ </div>
19
+ <div class="shortcut-item">
20
+ <div class="shortcut-key">3</div>
21
+ <div class="shortcut-description">Ball</div>
22
+ </div>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="shortcuts-section">
27
+ <h4>Objets</h4>
28
+ <div class="shortcut-list">
29
+ <div class="shortcut-item">
30
+ <div class="shortcut-key">N</div>
31
+ <div class="shortcut-description">Créer un nouvel objet</div>
32
+ </div>
33
+ <div class="shortcut-item">
34
+ <div class="shortcut-key">Ctrl + Suppr</div>
35
+ <div class="shortcut-description">Supprimer l'objet sélectionné</div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="shortcuts-section">
41
+ <h4>Navigation</h4>
42
+ <div class="shortcut-list">
43
+ <div class="shortcut-item">
44
+ <div class="shortcut-key">Espace</div>
45
+ <div class="shortcut-description">Lecture/Pause vidéo</div>
46
+ </div>
47
+ <div class="shortcut-item">
48
+ <div class="shortcut-key">←/→</div>
49
+ <div class="shortcut-description">Avancer/Reculer dans la vidéo</div>
50
+ </div>
51
+ <div class="shortcut-item">
52
+ <div class="shortcut-key">Échap</div>
53
+ <div class="shortcut-description">Fermer les popups</div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </template>
60
+
61
+ <script>
62
+ export default {
63
+ name: 'ShortcutsView'
64
+ }
65
+ </script>
66
+
67
+ <style scoped>
68
+ .shortcuts-view {
69
+ height: 100%;
70
+ padding: 16px;
71
+ color: white;
72
+ overflow-y: auto;
73
+ }
74
+
75
+ .shortcuts-header {
76
+ margin-bottom: 20px;
77
+ text-align: center;
78
+ }
79
+
80
+ .shortcuts-header h3 {
81
+ margin: 0;
82
+ font-size: 1.2rem;
83
+ color: #fff;
84
+ }
85
+
86
+ .shortcuts-content {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 24px;
90
+ }
91
+
92
+ .shortcuts-section h4 {
93
+ margin: 0 0 12px 0;
94
+ font-size: 1rem;
95
+ border-bottom: 1px solid #4a4a4a;
96
+ padding-bottom: 4px;
97
+ }
98
+
99
+ .shortcut-list {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 8px;
103
+ }
104
+
105
+ .shortcut-item {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 12px;
109
+ padding: 8px 12px;
110
+ background: #3c3c3c;
111
+ border-radius: 6px;
112
+ transition: background 0.2s;
113
+ }
114
+
115
+ .shortcut-item:hover {
116
+ background: #4a4a4a;
117
+ }
118
+
119
+ .shortcut-key {
120
+ background: #555;
121
+ color: white;
122
+ padding: 4px 8px;
123
+ border-radius: 4px;
124
+ font-family: 'Courier New', monospace;
125
+ font-size: 0.8rem;
126
+ font-weight: bold;
127
+ min-width: 60px;
128
+ text-align: center;
129
+ border: 1px solid #666;
130
+ }
131
+
132
+ .shortcut-description {
133
+ flex: 1;
134
+ color: #e2e8f0;
135
+ font-size: 0.9rem;
136
+ }
137
+ </style>
src/components/TimelineSection.vue ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="timeline-section">
3
+ <div class="timeline-container">
4
+ <div class="controls">
5
+ <button class="play-pause-btn" @click="togglePlayPause">
6
+ <!-- Triangle creux pour le bouton play -->
7
+ <svg v-if="!isPlaying" viewBox="0 0 24 24" width="20" height="20">
8
+ <polygon points="5,3 19,12 5,21" fill="none" stroke="white" stroke-width="1.5"/>
9
+ </svg>
10
+ <!-- Icône pause -->
11
+ <svg v-else viewBox="0 0 24 24" width="20" height="20">
12
+ <rect x="6" y="4" width="4" height="16" fill="white"/>
13
+ <rect x="14" y="4" width="4" height="16" fill="white"/>
14
+ </svg>
15
+ </button>
16
+ </div>
17
+ <div class="video-timeline">
18
+ <div class="timeline-track">
19
+ <div class="time-marker" :style="{ left: progressPercentage + '%' }">
20
+ <div class="time-indicator">{{ formatTimeSimple(preciseTime) }}</div>
21
+ <div class="marker-head"></div>
22
+ <div class="marker-line"></div>
23
+ </div>
24
+
25
+ <!-- Suppression des marqueurs d'annotation -->
26
+
27
+ <div class="frames-container">
28
+ <div
29
+ class="frame"
30
+ v-for="(thumbnail, index) in thumbnails"
31
+ :key="index"
32
+ :style="{ backgroundImage: `url(${thumbnail})` }"
33
+ ></div>
34
+ </div>
35
+ <input
36
+ type="range"
37
+ min="0"
38
+ :max="duration"
39
+ step="0.01"
40
+ v-model="currentTime"
41
+ @input="seekVideo"
42
+ class="timeline-slider"
43
+ />
44
+ </div>
45
+ </div>
46
+ <div class="timeline-tools"></div>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <script>
52
+ import { useVideoStore } from '@/stores/videoStore'
53
+ import { useAnnotationStore } from '@/stores/annotationStore'
54
+
55
+ export default {
56
+ name: 'TimelineSection',
57
+
58
+ data() {
59
+ return {
60
+ videoStore: useVideoStore(),
61
+ annotationStore: useAnnotationStore(),
62
+ isPlaying: false,
63
+ currentTime: 0,
64
+ duration: 100, // Valeur par défaut, sera mise à jour quand la vidéo sera chargée
65
+ videoElement: null,
66
+ thumbnails: [],
67
+ thumbnailCount: 6,
68
+ timeUpdateInterval: null,
69
+ keyboardListener: null,
70
+ unsubscribeTimeUpdate: null,
71
+ frameRate: 30, // Taux d'images par défaut, à mettre à jour lors du chargement de la vidéo
72
+ currentFrame: 0 // Numéro de frame actuel
73
+ }
74
+ },
75
+
76
+ computed: {
77
+ progressPercentage() {
78
+ // Utiliser directement la valeur du store pour s'assurer que les mises à jour sont reflétées
79
+ const storeTime = this.videoStore.currentTime || this.currentTime
80
+ return (storeTime / this.duration) * 100 || 0
81
+ },
82
+
83
+ // Calculer le temps exact basé sur le numéro de frame
84
+ preciseTime() {
85
+ return this.currentFrame / this.frameRate
86
+ },
87
+
88
+ // Récupérer toutes les frames qui ont des annotations
89
+ annotationFrames() {
90
+ return this.annotationStore.frameAnnotations || {}
91
+ }
92
+ },
93
+
94
+ mounted() {
95
+ // Ajouter un écouteur d'événement pour les touches Entrée et Espace
96
+ this.keyboardListener = (event) => {
97
+ if (event.key === ' ' || event.code === 'Space') {
98
+ // Empêcher le comportement par défaut (comme le défilement de la page avec la barre d'espace)
99
+ event.preventDefault();
100
+ this.togglePlayPause();
101
+ }
102
+ };
103
+ document.addEventListener('keydown', this.keyboardListener);
104
+
105
+ // S'abonner aux changements de temps dans le store
106
+ this.unsubscribeTimeUpdate = this.videoStore.$subscribe((mutation, state) => {
107
+ if (state.currentTime !== this.currentTime) {
108
+ this.currentTime = state.currentTime
109
+ // Mettre à jour également le numéro de frame
110
+ this.currentFrame = this.getCurrentFrame()
111
+ }
112
+ })
113
+ },
114
+
115
+ watch: {
116
+ 'videoStore.selectedVideo': {
117
+ handler(newVideo) {
118
+ if (newVideo) {
119
+ console.log('Nouvelle vidéo sélectionnée dans Timeline:', newVideo)
120
+ this.resetPlayer()
121
+ this.loadVideo(newVideo.path)
122
+
123
+ // Mettre à jour le frameRate dans le store d'annotation
124
+ if (this.annotationStore.currentSession) {
125
+ this.annotationStore.currentSession.videoId = newVideo.id || newVideo.path
126
+ this.annotationStore.currentSession.frameRate = this.frameRate
127
+ }
128
+ }
129
+ },
130
+ immediate: true
131
+ }
132
+ },
133
+
134
+ methods: {
135
+ async loadVideo(videoPath) {
136
+ try {
137
+ // Utiliser la méthode du store pour charger la vidéo
138
+ const { duration, videoElement, frameRate } = await this.videoStore.loadVideoMetadata(videoPath)
139
+
140
+ // Mettre à jour les propriétés locales
141
+ this.duration = duration
142
+ this.videoElement = videoElement
143
+ this.frameRate = frameRate || 30 // Utiliser 30 fps par défaut si non spécifié
144
+
145
+ // Mettre à jour le frameRate dans le store d'annotation
146
+ if (this.annotationStore.currentSession) {
147
+ this.annotationStore.currentSession.frameRate = this.frameRate
148
+ }
149
+
150
+ // Générer les vignettes
151
+ this.generateThumbnails(videoElement)
152
+ } catch (error) {
153
+ console.error('Erreur lors du chargement de la vidéo:', error)
154
+ }
155
+ },
156
+
157
+ // Calculer la position d'une frame sur la timeline (en pourcentage)
158
+ getFramePosition(frameNumber) {
159
+ const timeInSeconds = frameNumber / this.frameRate
160
+ return (timeInSeconds / this.duration) * 100
161
+ },
162
+
163
+ // Obtenir la couleur pour un marqueur d'annotation
164
+ getAnnotationColor(frameNumber) {
165
+ const annotations = this.annotationStore.getAnnotationsForFrame(frameNumber)
166
+ if (annotations.length > 0) {
167
+ // Utiliser la couleur du premier objet annoté
168
+ const objectId = annotations[0].objectId
169
+ const object = this.annotationStore.objects[objectId]
170
+ return object ? object.color : '#FFFFFF'
171
+ }
172
+ return '#FFFFFF' // Couleur par défaut
173
+ },
174
+
175
+ async generateThumbnails(videoEl) {
176
+ this.thumbnails = []
177
+
178
+ // Créer un canvas pour dessiner les vignettes
179
+ const canvas = document.createElement('canvas')
180
+ const ctx = canvas.getContext('2d')
181
+
182
+ // Définir la taille du canvas
183
+ canvas.width = 160 // Largeur de la vignette
184
+ canvas.height = 90 // Hauteur de la vignette (ratio 16:9)
185
+
186
+ // Générer les vignettes à intervalles réguliers
187
+ for (let i = 0; i < this.thumbnailCount; i++) {
188
+ const timePoint = (i / (this.thumbnailCount - 1)) * this.duration
189
+
190
+ // Positionner la vidéo au point temporel
191
+ videoEl.currentTime = timePoint
192
+
193
+ // Attendre que la vidéo soit positionnée
194
+ await new Promise(resolve => {
195
+ videoEl.addEventListener('seeked', resolve, { once: true })
196
+ })
197
+
198
+ // Dessiner l'image sur le canvas
199
+ ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height)
200
+
201
+ // Convertir le canvas en URL de données
202
+ const thumbnailUrl = canvas.toDataURL('image/jpeg')
203
+ this.thumbnails.push(thumbnailUrl)
204
+ }
205
+ },
206
+
207
+ // Méthode pour aller à une frame spécifique
208
+ goToFrame(frameNumber) {
209
+ this.currentFrame = frameNumber
210
+ const preciseTime = frameNumber / this.frameRate
211
+
212
+ // Mettre à jour le temps avec une précision à l'image près
213
+ this.currentTime = preciseTime
214
+ this.videoStore.currentTime = preciseTime
215
+
216
+ if (this.videoElement) {
217
+ // Utiliser requestAnimationFrame pour s'assurer que le DOM est prêt
218
+ requestAnimationFrame(() => {
219
+ this.videoElement.currentTime = preciseTime
220
+ })
221
+ }
222
+
223
+ // Mettre à jour l'interface
224
+ this.seekVideo()
225
+ },
226
+
227
+ // Méthode pour obtenir le numéro de frame actuel à partir du temps
228
+ getCurrentFrame() {
229
+ // Utiliser Math.round au lieu de Math.floor pour une meilleure précision
230
+ return Math.round(this.currentTime * this.frameRate)
231
+ },
232
+
233
+ togglePlayPause() {
234
+ this.isPlaying = !this.isPlaying
235
+
236
+ // Mettre à jour le store pour que VideoSection soit informé
237
+ this.videoStore.isPlaying = this.isPlaying
238
+
239
+ if (this.videoElement) {
240
+ if (this.isPlaying) {
241
+ // S'assurer que la vidéo commence à la position exacte de la frame actuelle
242
+ const preciseTime = this.currentFrame / this.frameRate
243
+ this.videoElement.currentTime = preciseTime
244
+ this.videoElement.play()
245
+ this.startTimeUpdate()
246
+ } else {
247
+ this.videoElement.pause()
248
+ this.stopTimeUpdate()
249
+ // Capturer le numéro de frame exact lors de la pause
250
+ this.currentFrame = this.getCurrentFrame()
251
+ }
252
+ } else {
253
+ // Mode simulation
254
+ if (this.isPlaying) {
255
+ // Commencer la simulation à partir de la frame actuelle
256
+ this.startTimeUpdate()
257
+ } else {
258
+ this.stopTimeUpdate()
259
+ // Capturer le numéro de frame exact lors de la pause
260
+ this.currentFrame = this.getCurrentFrame()
261
+ }
262
+ }
263
+ },
264
+
265
+ seekVideo() {
266
+ // Calculer le numéro de frame exact
267
+ this.currentFrame = this.getCurrentFrame()
268
+
269
+ // Calculer le temps précis basé sur le numéro de frame
270
+ const preciseTime = this.currentFrame / this.frameRate
271
+
272
+ if (this.videoElement) {
273
+ this.videoElement.currentTime = preciseTime
274
+ }
275
+
276
+ // Mettre à jour le store avec le temps précis
277
+ this.videoStore.currentTime = preciseTime
278
+ },
279
+
280
+ startTimeUpdate() {
281
+ // Utiliser requestAnimationFrame pour une meilleure fluidité
282
+ const updateTime = () => {
283
+ if (this.videoElement) {
284
+ this.currentTime = this.videoElement.currentTime
285
+ // Mettre à jour le numéro de frame actuel
286
+ this.currentFrame = this.getCurrentFrame()
287
+ // Mettre à jour le store à chaque frame
288
+ this.videoStore.currentTime = this.currentTime
289
+ } else {
290
+ // Simulation pour test - avancer d'une frame à la fois
291
+ this.currentFrame += 1
292
+ this.currentTime = this.currentFrame / this.frameRate
293
+
294
+ // Vérifier si on a atteint la fin
295
+ if (this.currentTime >= this.duration) {
296
+ this.isPlaying = false
297
+ this.videoStore.isPlaying = false
298
+ this.stopTimeUpdate()
299
+ return
300
+ }
301
+
302
+ // Mettre à jour le store même en mode simulation
303
+ this.videoStore.currentTime = this.currentTime
304
+ }
305
+
306
+ if (this.isPlaying) {
307
+ this.animationFrameId = requestAnimationFrame(updateTime)
308
+ }
309
+ }
310
+
311
+ this.animationFrameId = requestAnimationFrame(updateTime)
312
+ },
313
+
314
+ stopTimeUpdate() {
315
+ if (this.animationFrameId) {
316
+ cancelAnimationFrame(this.animationFrameId)
317
+ this.animationFrameId = null
318
+ }
319
+ },
320
+
321
+ resetPlayer() {
322
+ this.isPlaying = false
323
+ this.currentTime = 0
324
+ this.stopTimeUpdate()
325
+ this.thumbnails = []
326
+ },
327
+
328
+ formatTimeSimple(seconds) {
329
+ const secs = Math.floor(seconds)
330
+ const cs = Math.floor((seconds - secs) * 100)
331
+ // Ajouter le numéro de frame pour plus de précision
332
+ const frame = this.getCurrentFrame()
333
+ return `${secs}:${cs < 10 ? '0' : ''}${cs} (f:${frame})`
334
+ }
335
+ },
336
+
337
+ beforeUnmount() {
338
+ this.stopTimeUpdate()
339
+ if (this.videoElement) {
340
+ this.videoElement.pause()
341
+ this.videoElement.src = ''
342
+ this.videoElement = null
343
+ }
344
+
345
+ // Supprimer l'écouteur d'événement lors du démontage du composant
346
+ if (this.keyboardListener) {
347
+ document.removeEventListener('keydown', this.keyboardListener);
348
+ }
349
+
350
+ // Désabonner de l'écoute des changements dans le store
351
+ if (this.unsubscribeTimeUpdate) {
352
+ this.unsubscribeTimeUpdate()
353
+ }
354
+ }
355
+ }
356
+ </script>
357
+
358
+ <style scoped>
359
+ .timeline-section {
360
+ padding-top: 25px;
361
+ width: 100%;
362
+ }
363
+
364
+ .timeline-container {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 20px;
368
+ height: 100%;
369
+ }
370
+
371
+ .controls {
372
+ display: flex;
373
+ align-items: center;
374
+ }
375
+
376
+ .play-pause-btn {
377
+ width: 40px;
378
+ height: 40px;
379
+ background: transparent;
380
+ border: none;
381
+ color: white;
382
+ cursor: pointer;
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ padding: 0;
387
+ }
388
+
389
+ .video-timeline {
390
+ flex-grow: 1;
391
+ position: relative;
392
+ }
393
+
394
+ .timeline-track {
395
+ position: relative;
396
+ height: 50px;
397
+ }
398
+
399
+ .frames-container {
400
+ display: flex;
401
+ width: 100%;
402
+ height: 100%;
403
+ border-radius: 8px;
404
+ overflow: hidden;
405
+ }
406
+
407
+ .frame {
408
+ flex: 1;
409
+ height: 100%;
410
+ background-size: cover;
411
+ background-position: center;
412
+ border-right: 2px solid black;
413
+ }
414
+
415
+ .frame:last-child {
416
+ border-right: none;
417
+ }
418
+
419
+ .time-marker {
420
+ position: absolute;
421
+ bottom: 0;
422
+ transform: translateX(-50%);
423
+ z-index: 10;
424
+ display: flex;
425
+ flex-direction: column;
426
+ align-items: center;
427
+ }
428
+
429
+ .time-indicator {
430
+ color: white;
431
+ padding: 2px 6px;
432
+ border-radius: 4px;
433
+ font-size: 12px;
434
+ margin-bottom: 4px;
435
+ white-space: nowrap;
436
+ }
437
+
438
+ .marker-head {
439
+ width: 6px;
440
+ height: 3px;
441
+ background: white;
442
+ }
443
+
444
+ .marker-line {
445
+ width: 2px;
446
+ height: 50px;
447
+ background: white;
448
+ }
449
+
450
+ .timeline-slider {
451
+ position: absolute;
452
+ top: 0;
453
+ left: 0;
454
+ width: 100%;
455
+ height: 100%;
456
+ opacity: 0;
457
+ cursor: pointer;
458
+ z-index: 5;
459
+ }
460
+
461
+ .timeline-tools {
462
+ width: 50px;
463
+ height: 50px;
464
+ }
465
+
466
+ .annotation-marker {
467
+ position: absolute;
468
+ bottom: 0;
469
+ transform: translateX(-50%);
470
+ z-index: 8;
471
+ cursor: pointer;
472
+ }
473
+
474
+ .annotation-dot {
475
+ width: 8px;
476
+ height: 8px;
477
+ border-radius: 50%;
478
+ background-color: white;
479
+ margin-bottom: 2px;
480
+ }
481
+ </style>
src/components/VideoProgressTimeline.vue ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="video-progress">
3
+ <div
4
+ class="timeline-bar"
5
+ ref="timelineBar"
6
+ @click="handleTimelineClick"
7
+ >
8
+ <div
9
+ class="progress-indicator"
10
+ :style="{ left: `${(currentTime / duration) * 100}%` }"
11
+ ></div>
12
+ </div>
13
+ <div class="time-display">
14
+ {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
15
+ </div>
16
+ </div>
17
+ </template>
18
+
19
+ <script>
20
+ export default {
21
+ name: 'VideoProgressTimeline',
22
+
23
+ props: {
24
+ duration: {
25
+ type: Number,
26
+ required: true
27
+ },
28
+ currentTime: {
29
+ type: Number,
30
+ required: true
31
+ }
32
+ },
33
+
34
+ methods: {
35
+ handleTimelineClick(event) {
36
+ const rect = this.$refs.timelineBar.getBoundingClientRect()
37
+ const clickPosition = event.clientX - rect.left
38
+ const percentage = clickPosition / rect.width
39
+ const newTime = this.duration * percentage
40
+ this.$emit('seek', newTime)
41
+ },
42
+
43
+ formatTime(seconds) {
44
+ const minutes = Math.floor(seconds / 60)
45
+ const remainingSeconds = Math.floor(seconds % 60)
46
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
47
+ }
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <style scoped>
53
+ .video-progress {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 8px;
57
+ }
58
+
59
+ .timeline-bar {
60
+ height: 8px;
61
+ background: #666666;
62
+ border-radius: 4px;
63
+ position: relative;
64
+ cursor: pointer;
65
+ }
66
+
67
+ .progress-indicator {
68
+ position: absolute;
69
+ width: 12px;
70
+ height: 12px;
71
+ background: #4CAF50;
72
+ border-radius: 50%;
73
+ top: 50%;
74
+ transform: translate(-50%, -50%);
75
+ }
76
+
77
+ .time-display {
78
+ color: #ffffff;
79
+ font-size: 0.875rem;
80
+ text-align: center;
81
+ }
82
+ </style>
src/components/VideoSection.vue ADDED
@@ -0,0 +1,1852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="video-section">
3
+ <tool-bar
4
+ :current-tool="currentTool"
5
+ @tool-selected="selectTool"
6
+ />
7
+
8
+ <div class="video-container" ref="container">
9
+ <div class="video-wrapper">
10
+ <video
11
+ ref="videoRef"
12
+ class="video-element"
13
+ crossorigin="anonymous"
14
+ muted
15
+ ></video>
16
+ </div>
17
+
18
+ <div class="canvas-wrapper">
19
+ <v-stage
20
+ ref="stage"
21
+ :config="stageConfig"
22
+ @mousedown="handleMouseDown"
23
+ @mousemove="handleMouseMove"
24
+ @mouseup="handleMouseUp"
25
+ class="canvas-overlay"
26
+ >
27
+ <v-layer ref="layer">
28
+ <!-- Fond transparent explicite -->
29
+ <v-rect
30
+ :config="{
31
+ x: position.x,
32
+ y: position.y,
33
+ width: imageWidth,
34
+ height: imageHeight,
35
+ fill: 'transparent'
36
+ }"
37
+ />
38
+
39
+ <!-- Lignes de guidage -->
40
+ <v-line
41
+ v-if="mousePosition.x !== null && isInsideImage(mousePosition)"
42
+ :config="{
43
+ points: [
44
+ position.x, mousePosition.y,
45
+ position.x + imageWidth, mousePosition.y
46
+ ],
47
+ stroke: '#ffffff',
48
+ strokeWidth: 1,
49
+ dash: [5, 5],
50
+ opacity: 0.5
51
+ }"
52
+ />
53
+ <v-line
54
+ v-if="mousePosition.y !== null && isInsideImage(mousePosition)"
55
+ :config="{
56
+ points: [
57
+ mousePosition.x, position.y,
58
+ mousePosition.x, position.y + imageHeight
59
+ ],
60
+ stroke: '#ffffff',
61
+ strokeWidth: 1,
62
+ dash: [5, 5],
63
+ opacity: 0.5
64
+ }"
65
+ />
66
+
67
+ <!-- Rectangle en cours de dessin -->
68
+ <v-rect
69
+ v-if="isDrawing && currentTool === 'rectangle'"
70
+ :config="{
71
+ x: rectangleStart.x,
72
+ y: rectangleStart.y,
73
+ width: rectangleSize.width,
74
+ height: rectangleSize.height,
75
+ stroke: getObjectColor(annotationStore.selectedObjectId),
76
+ strokeWidth: 1,
77
+ fill: null,
78
+ dash: []
79
+ }"
80
+ />
81
+ <!-- Rectangles sauvegardés -->
82
+ <v-rect
83
+ v-for="rect in rectangles"
84
+ :key="rect.id"
85
+ :config="{
86
+ x: rect.x,
87
+ y: rect.y,
88
+ width: rect.width,
89
+ height: rect.height,
90
+ stroke: selectedId === rect.id ? '#FFD700' : rect.color,
91
+ strokeWidth: selectedId === rect.id ? 3 : (rect.objectId === annotationStore.selectedObjectId ? 2 : 1),
92
+ fill: null,
93
+ dash: rect.type === 'proxy' ? [5, 5] : [],
94
+ id: rect.id,
95
+ objectId: rect.objectId
96
+ }"
97
+ @mousedown="handleShapeMouseDown($event, rect.id)"
98
+ />
99
+
100
+ <!-- Halo de sélection pour le rectangle sélectionné -->
101
+ <v-rect
102
+ v-if="selectedId && rectangles.find(r => r.id === selectedId)"
103
+ :key="`halo-${selectedId}`"
104
+ :config="{
105
+ x: rectangles.find(r => r.id === selectedId).x - 3,
106
+ y: rectangles.find(r => r.id === selectedId).y - 3,
107
+ width: rectangles.find(r => r.id === selectedId).width + 6,
108
+ height: rectangles.find(r => r.id === selectedId).height + 6,
109
+ stroke: '#FFD700',
110
+ strokeWidth: 1,
111
+ fill: null,
112
+ dash: [6, 6],
113
+ opacity: 0.6,
114
+ listening: false
115
+ }"
116
+ />
117
+ <!-- Poignées de redimensionnement pour le rectangle sélectionné -->
118
+ <template v-if="selectedId && currentTool === 'arrow'">
119
+ <v-circle
120
+ v-for="handle in getResizeHandles()"
121
+ :key="handle.position"
122
+ :config="{
123
+ x: handle.x,
124
+ y: handle.y,
125
+ radius: 4,
126
+ fill: 'white',
127
+ stroke: '#4CAF50',
128
+ strokeWidth: 1,
129
+ draggable: true
130
+ }"
131
+ @dragmove="handleResize($event, handle.position)"
132
+ />
133
+ </template>
134
+ <!-- Points existants -->
135
+ <v-group
136
+ v-for="point in points"
137
+ :key="point.id"
138
+ :config="{
139
+ x: point.x,
140
+ y: point.y,
141
+ objectId: point.objectId,
142
+ listening: true,
143
+ id: point.id
144
+ }"
145
+ @mousedown="handlePointClick(point.id, $event)"
146
+ >
147
+ <!-- Halo de sélection pour le point sélectionné -->
148
+ <v-circle
149
+ v-if="selectedId === point.id"
150
+ :config="{
151
+ radius: 12,
152
+ fill: 'transparent',
153
+ stroke: '#FFD700',
154
+ strokeWidth: 2,
155
+ dash: [4, 4],
156
+ opacity: 0.8
157
+ }"
158
+ />
159
+
160
+ <v-circle
161
+ :config="{
162
+ radius: selectedId === point.id ? 7 : (point.objectId === annotationStore.selectedObjectId ? 6 : 5),
163
+ fill: point.color,
164
+ stroke: selectedId === point.id ? '#FFD700' : 'white',
165
+ strokeWidth: selectedId === point.id ? 3 : (point.objectId === annotationStore.selectedObjectId ? 2 : 1)
166
+ }"
167
+ />
168
+ <v-line
169
+ :config="{
170
+ points: [-2, 0, 2, 0],
171
+ stroke: selectedId === point.id ? '#FFD700' : 'white',
172
+ strokeWidth: selectedId === point.id ? 2 : 1
173
+ }"
174
+ />
175
+ <v-line
176
+ v-if="point.type === 'positive'"
177
+ :config="{
178
+ points: [0, -2, 0, 2],
179
+ stroke: selectedId === point.id ? '#FFD700' : 'white',
180
+ strokeWidth: selectedId === point.id ? 2 : 1
181
+ }"
182
+ />
183
+ </v-group>
184
+
185
+ <!-- Masques de segmentation -->
186
+ <v-shape
187
+ v-for="annotation in maskedAnnotations"
188
+ :key="`mask-${annotation.id}`"
189
+ :config="{
190
+ sceneFunc: (context, shape) => drawMask(context, shape, annotation),
191
+ fill: annotation.objectId === annotationStore.selectedObjectId ?
192
+ `${getObjectColor(annotation.objectId)}88` :
193
+ `${getObjectColor(annotation.objectId)}44`,
194
+ stroke: getObjectColor(annotation.objectId),
195
+ strokeWidth: annotation.objectId === annotationStore.selectedObjectId ? 2 : 1,
196
+ opacity: 0.8,
197
+ listening: true,
198
+ id: annotation.id
199
+ }"
200
+ @mousedown="handleMaskClick(annotation.id)"
201
+ />
202
+
203
+ <!-- Bounding boxes de tous les objets -->
204
+ <v-rect
205
+ v-for="bbox in allBoundingBoxes"
206
+ v-show="showAllBoundingBoxes && bbox"
207
+ :key="bbox.id"
208
+ :config="{
209
+ x: bbox.x,
210
+ y: bbox.y,
211
+ width: bbox.width,
212
+ height: bbox.height,
213
+ stroke: bbox.color,
214
+ strokeWidth: bbox.objectId === annotationStore.selectedObjectId ? 2 : 1,
215
+ fill: null,
216
+ dash: [4, 2], // Trait pointillé uniforme pour tous les rectangles
217
+ opacity: 0.8,
218
+ listening: false // Ne pas intercepter les clics sur les bbox d'affichage
219
+ }"
220
+ />
221
+ </v-layer>
222
+ </v-stage>
223
+ </div>
224
+ </div>
225
+
226
+
227
+ </div>
228
+ </template>
229
+
230
+ <script>
231
+ import { useVideoStore } from '@/stores/videoStore'
232
+ import { useAnnotationStore } from '@/stores/annotationStore'
233
+ import ToolBar from './tools/ToolBar.vue'
234
+
235
+ /*
236
+ FONCTIONNALITÉS DES BOUNDING BOXES :
237
+
238
+ 1. Affichage automatique des bounding boxes pour tous les objets sur chaque frame :
239
+ - Annotations en cours : rectangles et masques créés dans cette session
240
+ - Objet sélectionné : trait plus épais pour mise en évidence
241
+
242
+ 2. Différenciation visuelle :
243
+ - Annotations rectangles : trait continu []
244
+ - Annotations masques : trait pointillé court [- - -]
245
+ - Couleurs : selon la couleur de l'objet définie
246
+
247
+ 3. Contrôles clavier :
248
+ - Touche 'b' : Basculer l'affichage des bounding boxes (on/off)
249
+ - Touche 'd' : Afficher les informations de débogage dans la console
250
+ - Par défaut : affichage activé
251
+
252
+ 4. Sources de données :
253
+ - Store d'annotations (this.annotationStore) : annotations créées en temps réel
254
+
255
+ 5. Stockage des bounding boxes :
256
+ - Rectangles : coordonnées directes (x, y, width, height)
257
+ - Masques de segmentation : bbox calculée à partir des points ou du masque
258
+
259
+ 6. Débogage :
260
+ - Appel automatique de debugBoundingBoxes() à chaque changement de frame
261
+ - Informations détaillées sur les annotations trouvées
262
+ */
263
+
264
+ export default {
265
+ name: 'VideoSection',
266
+
267
+ components: {
268
+ ToolBar
269
+ },
270
+
271
+ props: {
272
+ selectedObjectId: {
273
+ type: String,
274
+ default: null
275
+ }
276
+ },
277
+
278
+ setup() {
279
+ const videoStore = useVideoStore()
280
+ const annotationStore = useAnnotationStore()
281
+
282
+ return { videoStore, annotationStore }
283
+ },
284
+
285
+ data() {
286
+ return {
287
+ videoElement: null,
288
+ imageWidth: 0,
289
+ imageHeight: 0,
290
+ position: { x: 0, y: 0 },
291
+ stageConfig: {
292
+ width: 0,
293
+ height: 0
294
+ },
295
+ currentTool: 'arrow',
296
+ isDrawing: false,
297
+ rectangleStart: { x: 0, y: 0 },
298
+ rectangleSize: { width: 0, height: 0 },
299
+ mousePosition: { x: null, y: null },
300
+ selectedId: null,
301
+ isDragging: false,
302
+ dragStartPos: { x: 0, y: 0 },
303
+ resizing: false,
304
+ resizeTimeout: null,
305
+ animationId: null,
306
+ currentFrameNumber: 0,
307
+ originalVideoPath: null,
308
+ proxyVideoPath: null,
309
+ isUsingProxy: true,
310
+ originalVideoDimensions: { width: 0, height: 0 },
311
+ maskCache: {},
312
+ showAllBoundingBoxes: true, // Nouvelle propriété pour contrôler l'affichage des bbox
313
+ }
314
+ },
315
+
316
+ mounted() {
317
+ // Réactiver l'élément vidéo
318
+ this.videoElement = this.$refs.videoRef
319
+ this.videoElement.muted = true
320
+ this.videoElement.addEventListener('loadedmetadata', this.handleVideoLoaded)
321
+ this.videoElement.addEventListener('timeupdate', this.updateCurrentFrame)
322
+
323
+ // S'abonner aux changements de vidéo dans le store
324
+ this.subscribeToVideoStore()
325
+
326
+ window.addEventListener('resize', this.handleWindowResize)
327
+ window.addEventListener('keydown', this.handleKeyDown)
328
+ },
329
+
330
+ beforeUnmount() {
331
+ window.removeEventListener('resize', this.handleWindowResize)
332
+ window.removeEventListener('keydown', this.handleKeyDown)
333
+
334
+ if (this.videoElement) {
335
+ this.videoElement.removeEventListener('loadedmetadata', this.handleVideoLoaded)
336
+ this.videoElement.pause()
337
+ }
338
+
339
+ this.stopAnimation()
340
+
341
+ // Supprimer l'écouteur d'événement
342
+ this.videoElement.removeEventListener('timeupdate', this.updateCurrentFrame)
343
+ },
344
+
345
+ computed: {
346
+
347
+ availableObjects() {
348
+ return Object.values(this.annotationStore.objects)
349
+ },
350
+ hasSelectedObject() {
351
+ return !!this.annotationStore.selectedObjectId
352
+ },
353
+ rectangles() {
354
+ const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || []
355
+
356
+ // Convertir les annotations en rectangles pour l'affichage
357
+ return frameAnnotations
358
+ .filter(annotation => annotation && annotation.type === 'rectangle')
359
+ .map(annotation => {
360
+ const object = this.annotationStore.objects[annotation.objectId]
361
+ const color = object ? object.color : '#4CAF50'
362
+
363
+ // Convertir les coordonnées originales en coordonnées d'affichage
364
+ const displayX = this.position.x + (annotation.x / this.scaleX)
365
+ const displayY = this.position.y + (annotation.y / this.scaleY)
366
+ const displayWidth = annotation.width / this.scaleX
367
+ const displayHeight = annotation.height / this.scaleY
368
+
369
+ return {
370
+ id: annotation.id,
371
+ objectId: annotation.objectId,
372
+ x: displayX,
373
+ y: displayY,
374
+ width: displayWidth,
375
+ height: displayHeight,
376
+ color: color
377
+ }
378
+ })
379
+ },
380
+ points() {
381
+ const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || []
382
+
383
+ // Points directs de type "point" (nouveau système)
384
+ const directPoints = frameAnnotations
385
+ .filter(annotation => annotation && annotation.type === 'point')
386
+ .map(annotation => ({
387
+ id: annotation.id,
388
+ objectId: annotation.objectId,
389
+ x: this.position.x + (annotation.x / this.scaleX),
390
+ y: this.position.y + (annotation.y / this.scaleY),
391
+ type: annotation.pointType,
392
+ color: this.getObjectColor(annotation.objectId),
393
+ isDirect: true
394
+ }))
395
+
396
+ // Points des annotations de type "mask" qui contiennent des points (ancien système)
397
+ const annotationPoints = frameAnnotations
398
+ .filter(annotation => annotation && annotation.type === 'mask' && annotation.points)
399
+ .flatMap(annotation => annotation.points.map(point => ({
400
+ id: `${annotation.id}-point-${point.x}-${point.y}`,
401
+ objectId: annotation.objectId,
402
+ x: this.position.x + (point.x / this.scaleX),
403
+ y: this.position.y + (point.y / this.scaleY),
404
+ type: point.type,
405
+ color: this.getObjectColor(annotation.objectId),
406
+ fromAnnotation: annotation.id
407
+ })))
408
+
409
+ // Points temporaires (ne devrait plus être utilisé mais gardé pour sécurité)
410
+ const tempPoints = (this.annotationStore.temporaryPoints || []).map(point => ({
411
+ id: `temp-point-${point.id}`,
412
+ objectId: point.objectId,
413
+ x: this.position.x + (point.x / this.scaleX),
414
+ y: this.position.y + (point.y / this.scaleY),
415
+ type: point.pointType,
416
+ color: this.getObjectColor(point.objectId),
417
+ isTemporary: true
418
+ }))
419
+
420
+ return [...directPoints, ...annotationPoints, ...tempPoints]
421
+ },
422
+ scaleX() {
423
+ if (this.originalVideoDimensions.width && this.imageWidth) {
424
+ return this.originalVideoDimensions.width / this.imageWidth
425
+ }
426
+ if (!this.videoElement || !this.imageWidth) return 1
427
+ return this.videoElement.videoWidth / this.imageWidth
428
+ },
429
+ scaleY() {
430
+ if (this.originalVideoDimensions.height && this.imageHeight) {
431
+ return this.originalVideoDimensions.height / this.imageHeight
432
+ }
433
+ if (!this.videoElement || !this.imageHeight) return 1
434
+ return this.videoElement.videoHeight / this.imageHeight
435
+ },
436
+ maskedAnnotations() {
437
+ const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || [];
438
+ return frameAnnotations.filter(annotation => annotation && annotation.mask && annotation.maskImageSize);
439
+ },
440
+ // Nouvelle computed property pour toutes les bounding boxes
441
+ allBoundingBoxes() {
442
+ // Temporairement désactivé pour isoler l'erreur
443
+ return []
444
+
445
+ // Le code original sera restauré une fois l'erreur identifiée
446
+ /*
447
+ const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || []
448
+ const boundingBoxes = []
449
+
450
+ // 1. BOUNDING BOXES DES ANNOTATIONS EN COURS (STORE D'ANNOTATIONS)
451
+ frameAnnotations.forEach(annotation => {
452
+ if (!annotation) return // Vérifier que l'annotation existe
453
+
454
+ const object = this.annotationStore.objects[annotation.objectId]
455
+ const color = object ? object.color : '#CCCCCC'
456
+ const objectName = object ? object.name : `Objet ${annotation.objectId}`
457
+
458
+ if (annotation.type === 'rectangle') {
459
+ // Pour les rectangles, utiliser directement leurs coordonnées
460
+ const displayX = this.position.x + (annotation.x / this.scaleX)
461
+ const displayY = this.position.y + (annotation.y / this.scaleY)
462
+ const displayWidth = annotation.width / this.scaleX
463
+ const displayHeight = annotation.height / this.scaleY
464
+
465
+ boundingBoxes.push({
466
+ id: `bbox-${annotation.id}`,
467
+ objectId: annotation.objectId,
468
+ x: displayX,
469
+ y: displayY,
470
+ width: displayWidth,
471
+ height: displayHeight,
472
+ color: color,
473
+ name: objectName,
474
+ type: 'rectangle',
475
+ annotationId: annotation.id,
476
+ source: 'current' // Annotations en cours
477
+ })
478
+ } else if (annotation.type === 'mask' && annotation.mask) {
479
+ // Pour les masques, calculer la bounding box ou utiliser celle stockée
480
+ let bbox = null
481
+
482
+ if (annotation.bbox && annotation.bbox.output) {
483
+ // Si la bbox est déjà stockée, l'utiliser
484
+ bbox = annotation.bbox.output
485
+ } else if (annotation.bbox) {
486
+ // Si c'est juste bbox sans output
487
+ bbox = annotation.bbox
488
+ } else {
489
+ // Sinon, essayer de calculer une bbox approximative à partir des dimensions du masque
490
+ // Cette méthode n'est pas parfaite mais donne une approximation
491
+ const maskSize = annotation.maskImageSize || { width: this.originalVideoDimensions.width, height: this.originalVideoDimensions.height }
492
+
493
+ // Pour une approximation, on peut supposer que le masque couvre une région significative
494
+ // En attendant une vraie bbox calculée côté serveur
495
+ bbox = {
496
+ x: Math.round(maskSize.width * 0.1), // 10% du bord gauche
497
+ y: Math.round(maskSize.height * 0.1), // 10% du bord haut
498
+ width: Math.round(maskSize.width * 0.8), // 80% de la largeur
499
+ height: Math.round(maskSize.height * 0.8) // 80% de la hauteur
500
+ }
501
+ }
502
+
503
+ if (bbox) {
504
+ // Convertir les coordonnées originales en coordonnées d'affichage
505
+ const displayX = this.position.x + (bbox.x / this.scaleX)
506
+ const displayY = this.position.y + (bbox.y / this.scaleY)
507
+ const displayWidth = bbox.width / this.scaleX
508
+ const displayHeight = bbox.height / this.scaleY
509
+
510
+ boundingBoxes.push({
511
+ id: `bbox-${annotation.id}`,
512
+ objectId: annotation.objectId,
513
+ x: displayX,
514
+ y: displayY,
515
+ width: displayWidth,
516
+ height: displayHeight,
517
+ color: color,
518
+ name: objectName,
519
+ type: 'mask',
520
+ annotationId: annotation.id,
521
+ source: 'current' // Annotations en cours
522
+ })
523
+ }
524
+ }
525
+ })
526
+
527
+ // Filtrer les bounding boxes invalides avant de retourner
528
+ return boundingBoxes.filter(bbox => bbox && bbox.type && bbox.id)
529
+ */
530
+ },
531
+ },
532
+
533
+ methods: {
534
+ subscribeToVideoStore() {
535
+ const videoStore = useVideoStore()
536
+
537
+ // Observer les changements dans le store
538
+ this.$watch(
539
+ () => videoStore.selectedVideo,
540
+ (newVideo) => {
541
+ if (newVideo) {
542
+ console.log('Nouvelle vidéo sélectionnée dans VideoSection:', newVideo)
543
+ this.loadVideo(newVideo.path)
544
+ }
545
+ },
546
+ { immediate: true }
547
+ )
548
+
549
+ // Observer les changements de temps dans la timeline
550
+ this.$watch(
551
+ () => videoStore.currentTime,
552
+ (newTime) => {
553
+ if (this.videoElement && newTime !== undefined) {
554
+ // Seulement mettre à jour si la différence est significative
555
+ if (Math.abs(this.videoElement.currentTime - newTime) > 0.05) {
556
+ this.videoElement.currentTime = newTime
557
+ }
558
+ }
559
+ }
560
+ )
561
+
562
+ // Observer l'état de lecture (play/pause)
563
+ this.$watch(
564
+ () => videoStore.isPlaying,
565
+ (isPlaying) => {
566
+ // console.log('État de lecture changé dans VideoSection:', isPlaying)
567
+ if (isPlaying) {
568
+ this.playVideo()
569
+ } else {
570
+ this.pauseVideo()
571
+ }
572
+ }
573
+ )
574
+ },
575
+
576
+ loadVideo(videoPath) {
577
+ if (!videoPath) return
578
+
579
+ // Arrêter toute animation en cours
580
+ this.stopAnimation()
581
+
582
+ this.originalVideoPath = videoPath
583
+
584
+ // Si l'élément vidéo est commenté, ne pas essayer de charger la vidéo
585
+ if (!this.videoElement) {
586
+ console.log("Mode test: élément vidéo non disponible, simulation uniquement")
587
+ // Simuler des dimensions pour le test
588
+ this.imageWidth = 640
589
+ this.imageHeight = 360
590
+ this.updateDimensions()
591
+ return
592
+ }
593
+
594
+ // Si c'est une URL blob (vidéo uploadée), charger directement sans proxy
595
+ if (videoPath.startsWith('blob:')) {
596
+ console.log("Chargement direct d'une vidéo uploadée (blob URL)")
597
+ this.videoElement.src = videoPath
598
+ this.videoElement.load()
599
+ return
600
+ }
601
+
602
+ // Si c'est un fichier média par défaut depuis les assets, le charger directement
603
+ if (videoPath.includes('/assets/') || videoPath.includes('assets/') ||
604
+ videoPath.includes('.jpg') || videoPath.includes('.png') ||
605
+ videoPath.includes('.jpeg') || videoPath.includes('.mp4') ||
606
+ videoPath.includes('.webm') || videoPath.includes('.mov')) {
607
+ console.log("Chargement direct d'un fichier média par défaut")
608
+ this.videoElement.src = videoPath
609
+ this.videoElement.load()
610
+ return
611
+ }
612
+
613
+ // Pour les fichiers locaux, utiliser le système de proxy existant
614
+ this.getOriginalVideoDimensions(videoPath)
615
+ .then(dimensions => {
616
+ console.log("Dimensions de la vidéo originale:", dimensions.width, "x", dimensions.height)
617
+ // Stocker les dimensions originales pour les utiliser dans les calculs de coordonnées
618
+ this.originalVideoDimensions = dimensions
619
+
620
+ // Vérifier si un proxy existe déjà ou en créer un
621
+ this.createOrLoadProxy(videoPath)
622
+ .then(proxyPath => {
623
+ this.proxyVideoPath = proxyPath
624
+
625
+ // Charger le proxy si l'option est activée, sinon charger l'original
626
+ const sourceToLoad = this.isUsingProxy ? proxyPath : videoPath
627
+ this.videoElement.src = sourceToLoad
628
+ this.videoElement.load()
629
+ })
630
+ .catch(err => {
631
+ console.error("Erreur lors de la création du proxy:", err)
632
+ // En cas d'erreur, charger la vidéo originale
633
+ this.videoElement.src = videoPath
634
+ this.videoElement.load()
635
+ })
636
+ })
637
+ .catch(err => {
638
+ console.error("Erreur lors de l'obtention des dimensions de la vidéo originale:", err)
639
+ // Continuer avec le chargement normal
640
+ this.videoElement.src = videoPath
641
+ this.videoElement.load()
642
+ })
643
+ },
644
+
645
+ async getOriginalVideoDimensions(videoPath) {
646
+ return new Promise((resolve, reject) => {
647
+ // Créer un élément vidéo temporaire pour obtenir les dimensions
648
+ const tempVideo = document.createElement('video')
649
+ tempVideo.style.display = 'none'
650
+
651
+ // Configurer les gestionnaires d'événements
652
+ tempVideo.onloadedmetadata = () => {
653
+ const dimensions = {
654
+ width: tempVideo.videoWidth,
655
+ height: tempVideo.videoHeight
656
+ }
657
+
658
+ // Nettoyer
659
+ document.body.removeChild(tempVideo)
660
+
661
+ resolve(dimensions)
662
+ }
663
+
664
+ tempVideo.onerror = (error) => {
665
+ // Nettoyer
666
+ if (document.body.contains(tempVideo)) {
667
+ document.body.removeChild(tempVideo)
668
+ }
669
+
670
+ reject(error)
671
+ }
672
+
673
+ // Ajouter l'élément au DOM et charger la vidéo
674
+ document.body.appendChild(tempVideo)
675
+ tempVideo.src = videoPath
676
+ })
677
+ },
678
+
679
+ async createOrLoadProxy(originalPath) {
680
+ // Générer un nom de fichier pour le proxy
681
+ const proxyPath = this.generateProxyPath(originalPath)
682
+
683
+ // Vérifier si le proxy existe déjà
684
+ const proxyExists = await this.checkIfFileExists(proxyPath)
685
+
686
+ if (proxyExists) {
687
+ console.log("Proxy vidéo existant trouvé:", proxyPath)
688
+ return proxyPath
689
+ }
690
+
691
+ // Créer un nouveau proxy
692
+ console.log("Création d'un nouveau proxy vidéo...")
693
+ return this.createVideoProxy(originalPath, proxyPath)
694
+ },
695
+
696
+ generateProxyPath(originalPath) {
697
+ // Exemple: transformer "/videos/original.mp4" en "/videos/original_proxy.mp4"
698
+ const pathParts = originalPath.split('.')
699
+ const extension = pathParts.pop()
700
+ return `${pathParts.join('.')}_proxy.${extension}`
701
+ },
702
+
703
+ async checkIfFileExists() {
704
+ // DÉSACTIVÉ - utilisation de vidéo statique dans assets
705
+ console.log("Vérification de fichier désactivée - utilisation de vidéo statique");
706
+ return true; // Toujours vrai pour la vidéo statique
707
+ },
708
+
709
+ async createVideoProxy(originalPath) {
710
+ // DÉSACTIVÉ - utilisation de vidéo statique dans assets
711
+ console.log("Création de proxy désactivée - utilisation de vidéo statique");
712
+ return originalPath; // Retourner le chemin original
713
+ },
714
+
715
+ toggleProxyMode() {
716
+ this.isUsingProxy = !this.isUsingProxy
717
+
718
+ // Sauvegarder la position actuelle
719
+ const currentTime = this.videoElement.currentTime
720
+
721
+ // Charger la vidéo appropriée
722
+ this.videoElement.src = this.isUsingProxy ? this.proxyVideoPath : this.originalVideoPath
723
+ this.videoElement.load()
724
+
725
+ // Restaurer la position après le chargement
726
+ this.videoElement.addEventListener('loadedmetadata', () => {
727
+ this.videoElement.currentTime = currentTime
728
+ }, { once: true })
729
+ },
730
+
731
+ handleVideoLoaded() {
732
+ console.log('Vidéo chargée, dimensions:', this.videoElement.videoWidth, 'x', this.videoElement.videoHeight)
733
+
734
+ // Vérifier que les dimensions sont valides
735
+ if (!this.videoElement.videoWidth || !this.videoElement.videoHeight) {
736
+ console.error('Dimensions de vidéo invalides après chargement')
737
+ return
738
+ }
739
+
740
+ // Stocker les dimensions si elles ne sont pas déjà définies
741
+ if (!this.originalVideoDimensions.width || !this.originalVideoDimensions.height) {
742
+ this.originalVideoDimensions = {
743
+ width: this.videoElement.videoWidth,
744
+ height: this.videoElement.videoHeight
745
+ }
746
+ }
747
+
748
+ // Ajouter un log pour indiquer si c'est le proxy ou l'original
749
+ const sourceType = this.isUsingProxy ? "proxy" : "originale"
750
+ console.log(`Vidéo ${sourceType} chargée. Dimensions d'affichage:`,
751
+ this.videoElement.videoWidth, 'x', this.videoElement.videoHeight)
752
+
753
+ this.initializeView()
754
+
755
+ // Démarrer l'animation pour le rendu fluide
756
+ this.startAnimation()
757
+ },
758
+
759
+ playVideo() {
760
+ if (!this.videoElement) {
761
+ console.log("Mode test: élément vidéo non disponible")
762
+ return
763
+ }
764
+
765
+ this.videoElement.play()
766
+ this.startAnimation()
767
+ },
768
+
769
+ pauseVideo() {
770
+ if (!this.videoElement) {
771
+ console.log("Mode test: élément vidéo non disponible")
772
+ return
773
+ }
774
+
775
+ this.videoElement.pause()
776
+ },
777
+
778
+ startAnimation() {
779
+ // Arrêter l'animation existante si elle existe
780
+ this.stopAnimation()
781
+
782
+ // Démarrer l'animation
783
+ this.animationId = requestAnimationFrame(this.animate)
784
+ },
785
+
786
+ stopAnimation() {
787
+ if (this.animationId) {
788
+ cancelAnimationFrame(this.animationId)
789
+ this.animationId = null
790
+ }
791
+ },
792
+
793
+ initializeView() {
794
+ this.$nextTick(() => {
795
+ this.updateDimensions()
796
+ })
797
+ },
798
+
799
+ handleWindowResize() {
800
+ if (this.resizeTimeout) {
801
+ clearTimeout(this.resizeTimeout)
802
+ }
803
+ this.resizeTimeout = setTimeout(() => {
804
+ this.updateDimensions()
805
+ }, 100)
806
+ },
807
+
808
+ updateDimensions() {
809
+ const container = this.$refs.container
810
+ if (!container) return
811
+
812
+ const containerWidth = container.clientWidth
813
+ const containerHeight = container.clientHeight
814
+
815
+ // Obtenir les dimensions de la vidéo avec des valeurs par défaut
816
+ let videoWidth = 0
817
+ let videoHeight = 0
818
+
819
+ if (this.videoElement && this.videoElement.videoWidth && this.videoElement.videoHeight) {
820
+ videoWidth = this.videoElement.videoWidth
821
+ videoHeight = this.videoElement.videoHeight
822
+ } else if (this.originalVideoDimensions.width && this.originalVideoDimensions.height) {
823
+ videoWidth = this.originalVideoDimensions.width
824
+ videoHeight = this.originalVideoDimensions.height
825
+ } else if (this.imageWidth && this.imageHeight) {
826
+ videoWidth = this.imageWidth
827
+ videoHeight = this.imageHeight
828
+ } else {
829
+ // Valeurs par défaut si aucune dimension n'est disponible
830
+ videoWidth = 640
831
+ videoHeight = 480
832
+ }
833
+
834
+ // Vérifier que les dimensions sont valides
835
+ if (!videoWidth || !videoHeight || videoWidth <= 0 || videoHeight <= 0) {
836
+ console.warn('Dimensions vidéo invalides, utilisation de valeurs par défaut')
837
+ videoWidth = 640
838
+ videoHeight = 480
839
+ }
840
+
841
+ const videoRatio = videoWidth / videoHeight
842
+
843
+ let width = containerWidth
844
+ let height = width / videoRatio
845
+
846
+ if (height > containerHeight) {
847
+ height = containerHeight
848
+ width = height * videoRatio
849
+ }
850
+
851
+ // S'assurer que les dimensions finales sont valides
852
+ if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) {
853
+ console.error('Dimensions calculées invalides, utilisation des dimensions du conteneur')
854
+ width = containerWidth || 640
855
+ height = containerHeight || 480
856
+ }
857
+
858
+ this.stageConfig.width = containerWidth
859
+ this.stageConfig.height = containerHeight
860
+ this.imageWidth = width
861
+ this.imageHeight = height
862
+
863
+ this.position = {
864
+ x: Math.floor((containerWidth - width) / 2),
865
+ y: Math.floor((containerHeight - height) / 2)
866
+ }
867
+
868
+ console.log('Dimensions mises à jour:', { videoWidth, videoHeight, displayWidth: width, displayHeight: height })
869
+
870
+ // Forcer une mise à jour du canvas
871
+ if (this.$refs.layer) {
872
+ const layer = this.$refs.layer.getNode();
873
+ layer.batchDraw();
874
+ }
875
+ },
876
+
877
+ selectTool(tool) {
878
+ this.currentTool = tool
879
+ },
880
+
881
+ handleMouseDown(e) {
882
+ if (e.evt.button !== 0) return
883
+
884
+ const stage = this.$refs.stage.getStage()
885
+ const pointerPos = stage.getPointerPosition()
886
+
887
+ if (!this.isInsideImage(pointerPos)) return
888
+
889
+ if (this.currentTool === 'arrow' && this.selectedId) {
890
+ const handles = this.getResizeHandles()
891
+ const clickedHandle = handles.find(handle => {
892
+ const dx = handle.x - pointerPos.x
893
+ const dy = handle.y - pointerPos.y
894
+ return Math.sqrt(dx * dx + dy * dy) <= 5
895
+ })
896
+
897
+ if (clickedHandle) {
898
+ this.resizing = true
899
+ return
900
+ }
901
+ }
902
+
903
+ // Sélection des rectangles (fonctionne avec tous les outils)
904
+ const clickedRect = this.rectangles.find(rect =>
905
+ pointerPos.x >= rect.x &&
906
+ pointerPos.x <= rect.x + rect.width &&
907
+ pointerPos.y >= rect.y &&
908
+ pointerPos.y <= rect.y + rect.height
909
+ )
910
+
911
+ if (clickedRect) {
912
+ this.selectedId = clickedRect.id
913
+ if (this.currentTool === 'arrow') {
914
+ this.isDragging = true
915
+ this.dragStartPos = pointerPos
916
+ }
917
+ console.log('Selected rectangle:', this.selectedId)
918
+ return
919
+ }
920
+
921
+ // Sélection des points (fonctionne avec tous les outils)
922
+ const clickedPoint = this.points.find(point => {
923
+ const dx = point.x - pointerPos.x
924
+ const dy = point.y - pointerPos.y
925
+ return Math.sqrt(dx * dx + dy * dy) <= 8 // Augmenter la zone de détection
926
+ })
927
+
928
+ if (clickedPoint) {
929
+ this.selectedId = clickedPoint.id
930
+ if (this.currentTool === 'arrow') {
931
+ this.isDragging = true
932
+ this.dragStartPos = pointerPos
933
+ }
934
+ console.log('Selected point:', this.selectedId)
935
+ return
936
+ }
937
+
938
+ // Déselectionner si aucun élément n'est cliqué
939
+ this.selectedId = null
940
+
941
+ switch(this.currentTool) {
942
+ case 'rectangle':
943
+ if (!this.annotationStore.selectedObjectId) {
944
+ this.annotationStore.addObject()
945
+ }
946
+
947
+ this.isDrawing = true
948
+ this.rectangleStart = {
949
+ x: pointerPos.x,
950
+ y: pointerPos.y
951
+ }
952
+ this.rectangleSize = { width: 0, height: 0 }
953
+ break
954
+ case 'positive':
955
+ this.addPoint(pointerPos, 'positive')
956
+ break
957
+ case 'negative':
958
+ this.addPoint(pointerPos, 'negative')
959
+ break
960
+ }
961
+ },
962
+
963
+ handleMouseMove() {
964
+ const stage = this.$refs.stage.getStage();
965
+ const pointerPos = stage.getPointerPosition();
966
+ this.mousePosition = pointerPos;
967
+
968
+ if (this.isDragging && this.selectedId && this.currentTool === 'arrow') {
969
+ const dx = pointerPos.x - this.dragStartPos.x
970
+ const dy = pointerPos.y - this.dragStartPos.y
971
+
972
+ const selectedRect = this.rectangles.find(r => r.id === this.selectedId)
973
+ if (selectedRect) {
974
+ selectedRect.x += dx
975
+ selectedRect.y += dy
976
+ }
977
+
978
+ const selectedPoint = this.points.find(p => p.id === this.selectedId)
979
+ if (selectedPoint) {
980
+ selectedPoint.x += dx
981
+ selectedPoint.y += dy
982
+ }
983
+
984
+ this.dragStartPos = pointerPos
985
+ return
986
+ }
987
+
988
+ if (!this.isDrawing || this.currentTool !== 'rectangle') return;
989
+
990
+ this.rectangleSize = {
991
+ width: pointerPos.x - this.rectangleStart.x,
992
+ height: pointerPos.y - this.rectangleStart.y
993
+ };
994
+ },
995
+
996
+ async handleMouseUp() {
997
+ if (this.resizing) {
998
+ this.resizing = false;
999
+ return;
1000
+ }
1001
+
1002
+ if (this.isDragging) {
1003
+ this.isDragging = false;
1004
+
1005
+ // Mettre à jour la position dans le store après le drag
1006
+ if (this.selectedId) {
1007
+ const selectedRect = this.rectangles.find(r => r.id === this.selectedId)
1008
+ if (selectedRect) {
1009
+ // Convertir les coordonnées d'affichage en coordonnées réelles
1010
+ const realX = Math.round((selectedRect.x - this.position.x) * this.scaleX)
1011
+ const realY = Math.round((selectedRect.y - this.position.y) * this.scaleY)
1012
+ const realWidth = Math.round(selectedRect.width * this.scaleX)
1013
+ const realHeight = Math.round(selectedRect.height * this.scaleY)
1014
+
1015
+ // Mettre à jour l'annotation dans le store
1016
+ this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, {
1017
+ x: realX,
1018
+ y: realY,
1019
+ width: realWidth,
1020
+ height: realHeight
1021
+ })
1022
+ }
1023
+
1024
+ const selectedPoint = this.points.find(p => p.id === this.selectedId)
1025
+ if (selectedPoint) {
1026
+ // Convertir les coordonnées d'affichage en coordonnées réelles
1027
+ const realX = Math.round((selectedPoint.x - this.position.x) * this.scaleX)
1028
+ const realY = Math.round((selectedPoint.y - this.position.y) * this.scaleY)
1029
+
1030
+ // Mettre à jour l'annotation dans le store
1031
+ this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, {
1032
+ x: realX,
1033
+ y: realY
1034
+ })
1035
+ }
1036
+ }
1037
+ return;
1038
+ }
1039
+
1040
+ if (!this.isDrawing || this.currentTool !== 'rectangle') return;
1041
+
1042
+ // Normaliser les coordonnées du rectangle pour s'assurer que x, y est le coin supérieur gauche
1043
+ // et que width, height sont positifs
1044
+ let normalizedRect = this.normalizeRectangle(
1045
+ this.rectangleStart.x,
1046
+ this.rectangleStart.y,
1047
+ this.rectangleSize.width,
1048
+ this.rectangleSize.height
1049
+ );
1050
+
1051
+ const relativeStart = {
1052
+ x: normalizedRect.x - this.position.x,
1053
+ y: normalizedRect.y - this.position.y
1054
+ };
1055
+
1056
+ // Utiliser les dimensions réelles de la vidéo originale, pas du proxy
1057
+ const originalRect = {
1058
+ x: Math.round(relativeStart.x * this.scaleX),
1059
+ y: Math.round(relativeStart.y * this.scaleY),
1060
+ width: Math.round(normalizedRect.width * this.scaleX),
1061
+ height: Math.round(normalizedRect.height * this.scaleY)
1062
+ };
1063
+
1064
+ // Vérifier s'il existe déjà des annotations pour cet objet sur cette frame
1065
+ const existingAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber)
1066
+ .filter(annotation => annotation.objectId === this.annotationStore.selectedObjectId);
1067
+
1068
+ // Si des annotations existent déjà, les supprimer avant d'ajouter la nouvelle
1069
+ if (existingAnnotations.length > 0) {
1070
+ console.log(`Suppression de ${existingAnnotations.length} annotations existantes pour l'objet ${this.annotationStore.selectedObjectId}`);
1071
+ existingAnnotations.forEach(annotation => {
1072
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, annotation.id);
1073
+ });
1074
+ }
1075
+
1076
+ // Créer l'annotation avec les coordonnées réelles
1077
+ const annotation = {
1078
+ objectId: this.annotationStore.selectedObjectId,
1079
+ type: 'rectangle',
1080
+ x: originalRect.x,
1081
+ y: originalRect.y,
1082
+ width: originalRect.width,
1083
+ height: originalRect.height
1084
+ };
1085
+
1086
+ // Ajouter l'annotation au store
1087
+ const annotationId = this.annotationStore.addAnnotation(this.currentFrameNumber, annotation);
1088
+
1089
+ // Log détaillé pour le mode annotation uniquement
1090
+ console.log('Rectangle ajouté à la frame', this.currentFrameNumber, 'avec ID:', annotationId, ':', annotation);
1091
+ console.log('État actuel des annotations:', JSON.parse(JSON.stringify(this.annotationStore.frameAnnotations)));
1092
+
1093
+ this.isDrawing = false;
1094
+ this.rectangleSize = { width: 0, height: 0 };
1095
+ },
1096
+
1097
+ // Ajouter cette nouvelle méthode pour normaliser les coordonnées du rectangle
1098
+ normalizeRectangle(x, y, width, height) {
1099
+ // Si la largeur est négative, ajuster x et width
1100
+ let newX = x;
1101
+ let newWidth = width;
1102
+ if (width < 0) {
1103
+ newX = x + width;
1104
+ newWidth = Math.abs(width);
1105
+ }
1106
+
1107
+ // Si la hauteur est négative, ajuster y et height
1108
+ let newY = y;
1109
+ let newHeight = height;
1110
+ if (height < 0) {
1111
+ newY = y + height;
1112
+ newHeight = Math.abs(height);
1113
+ }
1114
+
1115
+ return {
1116
+ x: newX,
1117
+ y: newY,
1118
+ width: newWidth,
1119
+ height: newHeight
1120
+ };
1121
+ },
1122
+
1123
+ isInsideImage(point) {
1124
+ return point.x >= this.position.x &&
1125
+ point.x <= this.position.x + this.imageWidth &&
1126
+ point.y >= this.position.y &&
1127
+ point.y <= this.position.y + this.imageHeight
1128
+ },
1129
+
1130
+ addPoint(pos, type) {
1131
+ if (!this.annotationStore.selectedObjectId) {
1132
+ this.annotationStore.addObject()
1133
+ }
1134
+
1135
+ const relativeX = pos.x - this.position.x
1136
+ const relativeY = pos.y - this.position.y
1137
+
1138
+ // Utiliser les dimensions réelles de la vidéo originale, pas du proxy
1139
+ const imageX = Math.round(relativeX * this.scaleX)
1140
+ const imageY = Math.round(relativeY * this.scaleY)
1141
+
1142
+ // Vérifier s'il existe des rectangles pour cet objet sur cette frame
1143
+ const existingRectangles = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber)
1144
+ .filter(annotation =>
1145
+ annotation.objectId === this.annotationStore.selectedObjectId &&
1146
+ annotation.type === 'rectangle'
1147
+ );
1148
+
1149
+ // Si des rectangles existent, les supprimer avant d'ajouter le point
1150
+ if (existingRectangles.length > 0) {
1151
+ console.log(`Suppression de ${existingRectangles.length} rectangles existants pour l'objet ${this.annotationStore.selectedObjectId}`);
1152
+ existingRectangles.forEach(rectangle => {
1153
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, rectangle.id);
1154
+ });
1155
+
1156
+ // Supprimer également les masques associés à ces rectangles
1157
+ const associatedMasks = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber)
1158
+ .filter(annotation =>
1159
+ annotation.objectId === this.annotationStore.selectedObjectId &&
1160
+ annotation.type === 'mask' &&
1161
+ (!annotation.points || annotation.points.length === 0)
1162
+ );
1163
+
1164
+ associatedMasks.forEach(mask => {
1165
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, mask.id);
1166
+ });
1167
+ }
1168
+
1169
+ // Créer directement une annotation pour le point (comme pour les rectangles)
1170
+ const annotation = {
1171
+ objectId: this.annotationStore.selectedObjectId,
1172
+ type: 'point',
1173
+ x: imageX,
1174
+ y: imageY,
1175
+ pointType: type
1176
+ };
1177
+
1178
+ // Ajouter l'annotation au store
1179
+ const annotationId = this.annotationStore.addAnnotation(this.currentFrameNumber, annotation);
1180
+
1181
+ // Log détaillé
1182
+ console.log('Point ajouté à la frame', this.currentFrameNumber, 'avec ID:', annotationId, ':', annotation);
1183
+ console.log('État des frameAnnotations après ajout:', JSON.parse(JSON.stringify(this.annotationStore.frameAnnotations)));
1184
+ },
1185
+
1186
+ handleKeyDown(e) {
1187
+ // Raccourci pour supprimer un élément sélectionné (annotations uniquement)
1188
+ // Note: Pour supprimer un objet, utiliser Ctrl+Suppr dans la liste des objets
1189
+ if (e.key === 'Delete' && this.selectedId) {
1190
+ console.log('Tentative de suppression de l\'élément:', this.selectedId)
1191
+
1192
+ // Déterminer le type d'élément sélectionné
1193
+ const selectedRect = this.rectangles.find(r => r.id === this.selectedId);
1194
+ const selectedPoint = this.points.find(p => p.id === this.selectedId);
1195
+
1196
+ if (selectedRect) {
1197
+ console.log('Suppression du rectangle:', this.selectedId)
1198
+ // Supprimer le rectangle
1199
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, this.selectedId);
1200
+
1201
+ // Vérifier s'il reste des annotations pour cet objet sur cette frame
1202
+ this.checkAndCleanupMasks(selectedRect.objectId);
1203
+ }
1204
+ else if (selectedPoint) {
1205
+ console.log('Suppression du point:', this.selectedId, 'Type:', selectedPoint.isDirect ? 'direct' : 'depuis annotation')
1206
+
1207
+ if (selectedPoint.isTemporary) {
1208
+ // Supprimer un point temporaire
1209
+ this.annotationStore.removeTemporaryPoint(selectedPoint.id.replace('temp-point-', ''));
1210
+ } else if (selectedPoint.isDirect) {
1211
+ // Nouveau système : point direct - supprimer directement l'annotation
1212
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, this.selectedId);
1213
+ console.log('Point direct supprimé')
1214
+ } else {
1215
+ // Ancien système : point d'une annotation existante
1216
+ const annotationId = selectedPoint.fromAnnotation;
1217
+ const annotation = this.annotationStore.getAnnotation(this.currentFrameNumber, annotationId);
1218
+
1219
+ if (annotation && annotation.points) {
1220
+ // Filtrer les points pour retirer celui qui est sélectionné
1221
+ const pointKey = selectedPoint.id.split('-point-')[1]; // Récupérer les coordonnées du point
1222
+ const [pointX, pointY] = pointKey.split('-').map(Number);
1223
+
1224
+ const updatedPoints = annotation.points.filter(p =>
1225
+ !(p.x === pointX && p.y === pointY)
1226
+ );
1227
+
1228
+ if (updatedPoints.length > 0) {
1229
+ // Mettre à jour l'annotation avec les points restants
1230
+ this.annotationStore.updateAnnotation(this.currentFrameNumber, annotationId, {
1231
+ points: updatedPoints
1232
+ });
1233
+ console.log('Point retiré de l\'annotation, points restants:', updatedPoints.length)
1234
+ } else {
1235
+ // Si plus aucun point, supprimer l'annotation complètement
1236
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, annotationId);
1237
+ console.log('Annotation complètement supprimée (plus de points)')
1238
+ }
1239
+
1240
+ // Vérifier s'il reste des annotations pour cet objet sur cette frame
1241
+ this.checkAndCleanupMasks(selectedPoint.objectId);
1242
+ }
1243
+ }
1244
+ } else {
1245
+ console.log('Aucun élément trouvé avec l\'ID:', this.selectedId)
1246
+ }
1247
+
1248
+ this.selectedId = null;
1249
+ console.log('Element deleted');
1250
+ }
1251
+
1252
+ // Raccourci "v" supprimé - Les points sont maintenant sauvegardés directement
1253
+
1254
+ // Raccourci Escape supprimé - Plus de points temporaires à annuler
1255
+
1256
+ // Raccourci pour basculer l'affichage des bounding boxes (touche 'b')
1257
+ if (e.key === 'b' || e.key === 'B') {
1258
+ e.preventDefault();
1259
+ this.toggleBoundingBoxes();
1260
+ }
1261
+
1262
+ // Raccourci pour déboguer les bounding boxes (touche 'd')
1263
+ if (e.key === 'd' || e.key === 'D') {
1264
+ e.preventDefault();
1265
+ this.debugBoundingBoxes();
1266
+ }
1267
+
1268
+ // Navigation frame par frame avec les flèches
1269
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1270
+ e.preventDefault() // Empêcher le défilement de la page
1271
+
1272
+ // Calculer la nouvelle frame
1273
+ const frameRate = this.annotationStore.currentSession.frameRate || 30
1274
+ const currentFrame = this.currentFrameNumber
1275
+ const newFrame = e.key === 'ArrowLeft' ? Math.max(0, currentFrame - 1) : currentFrame + 1
1276
+
1277
+ // Calculer le nouveau temps basé sur la frame
1278
+ const newTime = newFrame / frameRate
1279
+
1280
+ // Mettre à jour le temps dans le store et la vidéo
1281
+ this.videoStore.setCurrentTime(newTime)
1282
+ if (this.videoElement) {
1283
+ this.videoElement.currentTime = newTime
1284
+ }
1285
+
1286
+ // console.log(`Navigation: Frame ${currentFrame} -> ${newFrame}, Temps: ${newTime.toFixed(3)}s`)
1287
+ }
1288
+ },
1289
+
1290
+ // Nouvelle méthode pour vérifier et nettoyer les masques orphelins
1291
+ checkAndCleanupMasks(objectId) {
1292
+ const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || [];
1293
+
1294
+ // Vérifier s'il reste des annotations (rectangles ou points) pour cet objet sur cette frame
1295
+ const hasRemainingElements = frameAnnotations.some(annotation =>
1296
+ annotation.objectId === objectId &&
1297
+ (annotation.type === 'rectangle' ||
1298
+ (annotation.type === 'mask' && annotation.points && annotation.points.length > 0))
1299
+ );
1300
+
1301
+ if (!hasRemainingElements) {
1302
+ // Si aucun élément ne reste, supprimer tous les masques associés à cet objet sur cette frame
1303
+ const masksToRemove = frameAnnotations
1304
+ .filter(annotation =>
1305
+ annotation.objectId === objectId &&
1306
+ annotation.type === 'mask' &&
1307
+ (!annotation.points || annotation.points.length === 0)
1308
+ )
1309
+ .map(annotation => annotation.id);
1310
+
1311
+ masksToRemove.forEach(maskId => {
1312
+ this.annotationStore.removeAnnotation(this.currentFrameNumber, maskId);
1313
+ });
1314
+
1315
+ if (masksToRemove.length > 0) {
1316
+ console.log(`Suppression de ${masksToRemove.length} masques orphelins pour l'objet ${objectId}`);
1317
+ }
1318
+ }
1319
+ },
1320
+
1321
+ getResizeHandles() {
1322
+ const rect = this.rectangles.find(r => r.id === this.selectedId)
1323
+ if (!rect) return []
1324
+
1325
+ return [
1326
+ { position: 'nw', x: rect.x, y: rect.y },
1327
+ { position: 'ne', x: rect.x + rect.width, y: rect.y },
1328
+ { position: 'se', x: rect.x + rect.width, y: rect.y + rect.height },
1329
+ { position: 'sw', x: rect.x, y: rect.y + rect.height },
1330
+
1331
+ { position: 'n', x: rect.x + rect.width/2, y: rect.y },
1332
+ { position: 'e', x: rect.x + rect.width, y: rect.y + rect.height/2 },
1333
+ { position: 's', x: rect.x + rect.width/2, y: rect.y + rect.height },
1334
+ { position: 'w', x: rect.x, y: rect.y + rect.height/2 }
1335
+ ]
1336
+ },
1337
+
1338
+ handleResize(e, position) {
1339
+ const rect = this.rectangles.find(r => r.id === this.selectedId)
1340
+ if (!rect) return
1341
+
1342
+ const stage = this.$refs.stage.getStage()
1343
+ const pos = stage.getPointerPosition()
1344
+
1345
+ const originalX = rect.x
1346
+ const originalY = rect.y
1347
+ const originalWidth = rect.width
1348
+ const originalHeight = rect.height
1349
+ let newWidth, newHeight
1350
+
1351
+ // Appliquer le redimensionnement selon la poignée utilisée
1352
+ switch (position) {
1353
+ case 'e':
1354
+ rect.width = Math.max(10, pos.x - rect.x)
1355
+ break
1356
+ case 'w':
1357
+ newWidth = originalWidth + (originalX - pos.x)
1358
+ if (newWidth >= 10) {
1359
+ rect.x = pos.x
1360
+ rect.width = newWidth
1361
+ }
1362
+ break
1363
+ case 'n':
1364
+ newHeight = originalHeight + (originalY - pos.y)
1365
+ if (newHeight >= 10) {
1366
+ rect.y = pos.y
1367
+ rect.height = newHeight
1368
+ }
1369
+ break
1370
+ case 's':
1371
+ rect.height = Math.max(10, pos.y - rect.y)
1372
+ break
1373
+ case 'nw':
1374
+ if (originalWidth + (originalX - pos.x) >= 10) {
1375
+ rect.x = pos.x
1376
+ rect.width = originalWidth + (originalX - pos.x)
1377
+ }
1378
+ if (originalHeight + (originalY - pos.y) >= 10) {
1379
+ rect.y = pos.y
1380
+ rect.height = originalHeight + (originalY - pos.y)
1381
+ }
1382
+ break
1383
+ case 'ne':
1384
+ rect.width = Math.max(10, pos.x - rect.x)
1385
+ if (originalHeight + (originalY - pos.y) >= 10) {
1386
+ rect.y = pos.y
1387
+ rect.height = originalHeight + (originalY - pos.y)
1388
+ }
1389
+ break
1390
+ case 'se':
1391
+ rect.width = Math.max(10, pos.x - rect.x)
1392
+ rect.height = Math.max(10, pos.y - rect.y)
1393
+ break
1394
+ case 'sw':
1395
+ if (originalWidth + (originalX - pos.x) >= 10) {
1396
+ rect.x = pos.x
1397
+ rect.width = originalWidth + (originalX - pos.x)
1398
+ }
1399
+ rect.height = Math.max(10, pos.y - rect.y)
1400
+ break
1401
+ }
1402
+
1403
+ // Convertir les coordonnées d'affichage en coordonnées réelles
1404
+ const realX = Math.round((rect.x - this.position.x) * this.scaleX)
1405
+ const realY = Math.round((rect.y - this.position.y) * this.scaleY)
1406
+ const realWidth = Math.round(rect.width * this.scaleX)
1407
+ const realHeight = Math.round(rect.height * this.scaleY)
1408
+
1409
+ // Mettre à jour l'annotation dans le store avec les coordonnées réelles
1410
+ this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, {
1411
+ x: realX,
1412
+ y: realY,
1413
+ width: realWidth,
1414
+ height: realHeight
1415
+ })
1416
+ },
1417
+
1418
+ updateCurrentFrame() {
1419
+ if (!this.videoElement) return
1420
+
1421
+ const frameRate = this.annotationStore.currentSession.frameRate || 30
1422
+
1423
+ // Utiliser Math.round au lieu de Math.floor pour une meilleure précision
1424
+ const newFrameNumber = Math.round(this.videoElement.currentTime * frameRate)
1425
+
1426
+ // Ne mettre à jour que si la frame a changé
1427
+ if (newFrameNumber !== this.currentFrameNumber) {
1428
+ this.currentFrameNumber = newFrameNumber
1429
+
1430
+ // Forcer le rafraîchissement du canvas pour s'assurer que seules les annotations
1431
+ // de la frame actuelle sont affichées
1432
+ if (this.$refs.layer) {
1433
+ const layer = this.$refs.layer.getNode()
1434
+ layer.batchDraw()
1435
+ }
1436
+
1437
+ // Debug des bounding boxes pour la nouvelle frame
1438
+ this.$nextTick(() => {
1439
+ this.debugBoundingBoxes()
1440
+ })
1441
+
1442
+ // Log pour débogage
1443
+ // console.log(`Temps: ${this.videoElement.currentTime.toFixed(3)}s, Frame: ${this.currentFrameNumber}`)
1444
+ }
1445
+ },
1446
+
1447
+ selectObject(objectId) {
1448
+ this.annotationStore.selectObject(objectId)
1449
+ this.$emit('object-selected', objectId)
1450
+ },
1451
+
1452
+ createNewObject() {
1453
+ this.annotationStore.addObject()
1454
+ },
1455
+
1456
+ animate() {
1457
+ // Récupérer les éléments sélectionnés
1458
+ if (this.$refs.layer && this.annotationStore.selectedObjectId) {
1459
+ const layer = this.$refs.layer.getNode();
1460
+
1461
+ // Trouver tous les éléments de l'objet sélectionné
1462
+ const selectedRects = layer.find('Rect').filter(rect => {
1463
+ return rect.attrs.objectId === this.annotationStore.selectedObjectId;
1464
+ });
1465
+
1466
+ const selectedPoints = layer.find('Group').filter(group => {
1467
+ return group.attrs.objectId === this.annotationStore.selectedObjectId;
1468
+ });
1469
+
1470
+ // Appliquer l'animation
1471
+ [...selectedRects, ...selectedPoints].forEach(shape => {
1472
+ // Animation de pulsation
1473
+ const scale = 1 + Math.sin(Date.now() / 300) * 0.05; // Pulsation subtile
1474
+ shape.scale({ x: scale, y: scale });
1475
+ });
1476
+
1477
+ layer.batchDraw();
1478
+ }
1479
+
1480
+ // Continuer l'animation
1481
+ this.animationId = requestAnimationFrame(this.animate);
1482
+ },
1483
+
1484
+ getObjectColor(objectId) {
1485
+ const object = this.annotationStore.objects[objectId];
1486
+ return object ? object.color : '#CCCCCC';
1487
+ },
1488
+
1489
+ handleMaskClick(maskId, e) {
1490
+ // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé
1491
+ if (e && e.evt) {
1492
+ e.evt.stopPropagation(); // Remplacer cancelBubble par stopPropagation
1493
+ }
1494
+
1495
+ // Sélectionner le masque
1496
+ this.selectedId = maskId;
1497
+
1498
+ console.log('Selected mask:', maskId);
1499
+ },
1500
+
1501
+ drawMask(context, shape, annotation) {
1502
+ if (!annotation.mask || !annotation.maskImageSize) {
1503
+ console.warn('Annotation sans masque ou dimensions:', annotation);
1504
+ return;
1505
+ }
1506
+
1507
+ // Vérifier si le masque est déjà dans le cache
1508
+ const cacheKey = `${annotation.id}-${annotation.mask.substring(0, 20)}`;
1509
+ let maskImage = this.maskCache[cacheKey];
1510
+
1511
+ if (!maskImage) {
1512
+ try {
1513
+ console.log(`Décodage du masque pour l'annotation ${annotation.id}`);
1514
+ console.log('Début du masque:', annotation.mask.substring(0, 50) + '...');
1515
+
1516
+ // Créer une nouvelle image pour charger le masque base64
1517
+ maskImage = new Image();
1518
+
1519
+ // Attendre que l'image soit chargée avant de continuer
1520
+ const loadPromise = new Promise((resolve, reject) => {
1521
+ maskImage.onload = () => resolve();
1522
+ maskImage.onerror = (e) => reject(new Error(`Erreur de chargement de l'image: ${e}`));
1523
+ });
1524
+
1525
+ // Définir la source de l'image (base64)
1526
+ if (annotation.mask.startsWith('data:')) {
1527
+ // Si c'est déjà un data URL
1528
+ maskImage.src = annotation.mask;
1529
+ } else {
1530
+ // Sinon, supposer que c'est un base64 brut et créer un data URL
1531
+ maskImage.src = `data:image/png;base64,${annotation.mask}`;
1532
+ }
1533
+
1534
+ // Attendre que l'image soit chargée
1535
+ loadPromise.then(() => {
1536
+ console.log(`Image du masque chargée: ${maskImage.width}x${maskImage.height}`);
1537
+
1538
+ // Créer un canvas temporaire pour traiter l'image
1539
+ const tempCanvas = document.createElement('canvas');
1540
+ tempCanvas.width = maskImage.width;
1541
+ tempCanvas.height = maskImage.height;
1542
+ const tempCtx = tempCanvas.getContext('2d');
1543
+
1544
+ // Dessiner l'image sur le canvas temporaire
1545
+ tempCtx.drawImage(maskImage, 0, 0);
1546
+
1547
+ // Obtenir les données de l'image
1548
+ const imageData = tempCtx.getImageData(0, 0, maskImage.width, maskImage.height);
1549
+ const data = imageData.data;
1550
+
1551
+ // Obtenir la couleur de l'objet
1552
+ const objectColor = this.getObjectColor(annotation.objectId);
1553
+ const r = parseInt(objectColor.slice(1, 3), 16);
1554
+ const g = parseInt(objectColor.slice(3, 5), 16);
1555
+ const b = parseInt(objectColor.slice(5, 7), 16);
1556
+
1557
+ // Parcourir tous les pixels
1558
+ for (let i = 0; i < data.length; i += 4) {
1559
+ // Si le pixel est blanc (ou presque blanc)
1560
+ if (data[i] > 200 && data[i+1] > 200 && data[i+2] > 200) {
1561
+ // Remplacer par la couleur de l'objet avec une transparence
1562
+ data[i] = r;
1563
+ data[i+1] = g;
1564
+ data[i+2] = b;
1565
+ data[i+3] = 180; // Semi-transparent
1566
+ } else {
1567
+ // Rendre le pixel complètement transparent
1568
+ data[i+3] = 0;
1569
+ }
1570
+ }
1571
+
1572
+ // Remettre les données modifiées dans le canvas
1573
+ tempCtx.putImageData(imageData, 0, 0);
1574
+
1575
+ // Créer une nouvelle image à partir du canvas modifié
1576
+ const coloredMaskImage = new Image();
1577
+ coloredMaskImage.src = tempCanvas.toDataURL();
1578
+
1579
+ // Mettre en cache l'image colorée
1580
+ this.maskCache[cacheKey] = coloredMaskImage;
1581
+
1582
+ // Forcer un nouveau rendu
1583
+ this.$nextTick(() => {
1584
+ if (this.$refs.layer) {
1585
+ this.$refs.layer.getNode().batchDraw();
1586
+ }
1587
+ });
1588
+ }).catch(error => {
1589
+ console.error('Erreur lors du traitement de l\'image du masque:', error);
1590
+ });
1591
+
1592
+ // Retourner tôt car l'image n'est pas encore chargée
1593
+ return;
1594
+ } catch (error) {
1595
+ console.error('Erreur lors de la création de l\'image du masque:', error);
1596
+ return;
1597
+ }
1598
+ }
1599
+
1600
+ // Si l'image n'est pas encore complètement chargée, retourner
1601
+ if (!maskImage.complete) {
1602
+ return;
1603
+ }
1604
+
1605
+ // Calculer l'échelle pour adapter le masque à la taille d'affichage
1606
+ const scaleX = this.imageWidth / annotation.maskImageSize.width;
1607
+ const scaleY = this.imageHeight / annotation.maskImageSize.height;
1608
+
1609
+ // Dessiner le masque sur le canvas principal
1610
+ const ctx = context._context;
1611
+ ctx.save();
1612
+
1613
+ // Appliquer la transformation pour positionner correctement le masque
1614
+ ctx.translate(this.position.x, this.position.y);
1615
+ ctx.scale(scaleX, scaleY);
1616
+
1617
+ // Dessiner l'image du masque coloré
1618
+ ctx.drawImage(maskImage, 0, 0);
1619
+
1620
+ // Restaurer le contexte
1621
+ ctx.restore();
1622
+
1623
+ // Indiquer à Konva que le dessin est terminé
1624
+ shape.strokeEnabled(false); // Désactiver le contour automatique
1625
+ },
1626
+
1627
+ handleShapeMouseDown(e, shapeId) {
1628
+ // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé
1629
+ e.evt.stopPropagation(); // Remplacer cancelBubble par stopPropagation
1630
+
1631
+ // Sélectionner la forme
1632
+ this.selectedId = shapeId;
1633
+
1634
+ // Si l'outil actuel est la flèche, activer le mode de déplacement
1635
+ if (this.currentTool === 'arrow') {
1636
+ this.isDragging = true;
1637
+
1638
+ const stage = this.$refs.stage.getStage();
1639
+ this.dragStartPos = stage.getPointerPosition();
1640
+
1641
+ console.log('Selected shape:', shapeId);
1642
+ }
1643
+ },
1644
+
1645
+ // Ajouter cette méthode pour gérer l'ajout de points à un masque existant
1646
+ addPointToExistingMask(pos, type) {
1647
+ if (!this.annotationStore.selectedObjectId) {
1648
+ this.annotationStore.addObject();
1649
+ }
1650
+
1651
+ const relativeX = pos.x - this.position.x;
1652
+ const relativeY = pos.y - this.position.y;
1653
+
1654
+ // Utiliser les dimensions réelles de la vidéo originale
1655
+ const imageX = Math.round(relativeX * this.scaleX);
1656
+ const imageY = Math.round(relativeY * this.scaleY);
1657
+
1658
+ // Ajouter le point à la collection temporaire
1659
+ this.annotationStore.addTemporaryPoint({
1660
+ objectId: this.annotationStore.selectedObjectId,
1661
+ x: imageX,
1662
+ y: imageY,
1663
+ pointType: type
1664
+ });
1665
+
1666
+ console.log('Point temporaire ajouté:', { x: imageX, y: imageY, type });
1667
+ },
1668
+
1669
+ // Nouvelle méthode pour basculer l'affichage des bounding boxes
1670
+ toggleBoundingBoxes() {
1671
+ this.showAllBoundingBoxes = !this.showAllBoundingBoxes;
1672
+ console.log('Affichage des bounding boxes:', this.showAllBoundingBoxes ? 'activé' : 'désactivé');
1673
+ },
1674
+
1675
+ // Méthode de débogage pour afficher les informations des bounding boxes
1676
+ debugBoundingBoxes() {
1677
+ const boxes = this.allBoundingBoxes;
1678
+ if (boxes.length > 0) {
1679
+ console.log(`🔍 Frame ${this.currentFrameNumber}: ${boxes.length} bounding box(es) trouvée(s)`);
1680
+
1681
+ const currentBoxes = boxes.filter(b => b.source === 'current');
1682
+
1683
+ if (currentBoxes.length > 0) {
1684
+ console.log(` ✏️ Annotations en cours: ${currentBoxes.length}`);
1685
+ currentBoxes.forEach(box => {
1686
+ console.log(` - ${box.name} (${box.type}): x=${Math.round(box.x)}, y=${Math.round(box.y)}, w=${Math.round(box.width)}, h=${Math.round(box.height)}`);
1687
+ });
1688
+ }
1689
+ }
1690
+ },
1691
+
1692
+ // Nouvelle méthode pour gérer les clics sur les points
1693
+ handlePointClick(pointId, e) {
1694
+ // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé
1695
+ e.evt.stopPropagation()
1696
+
1697
+ // Sélectionner le point
1698
+ this.selectedId = pointId
1699
+
1700
+ // Si l'outil actuel est la flèche, activer le mode de déplacement
1701
+ if (this.currentTool === 'arrow') {
1702
+ this.isDragging = true
1703
+ const stage = this.$refs.stage.getStage()
1704
+ this.dragStartPos = stage.getPointerPosition()
1705
+ }
1706
+
1707
+ console.log('Selected point via direct click:', pointId)
1708
+ },
1709
+ },
1710
+
1711
+ watch: {
1712
+ 'stageConfig.width'() {
1713
+ this.$nextTick(() => {
1714
+ this.updateDimensions()
1715
+ })
1716
+ },
1717
+ 'stageConfig.height'() {
1718
+ this.$nextTick(() => {
1719
+ this.updateDimensions()
1720
+ })
1721
+ },
1722
+ 'videoStore.currentTime'() {
1723
+ this.updateCurrentFrame()
1724
+ },
1725
+ 'annotationStore.selectedObjectId'(newId) {
1726
+ console.log('Objet sélectionné changé:', newId)
1727
+ // Redémarrer l'animation quand l'objet sélectionné change
1728
+ this.startAnimation()
1729
+ },
1730
+ currentFrameNumber() {
1731
+ // Forcer le rafraîchissement du canvas quand la frame change
1732
+ this.$nextTick(() => {
1733
+ if (this.$refs.layer) {
1734
+ const layer = this.$refs.layer.getNode()
1735
+ layer.batchDraw()
1736
+ }
1737
+ })
1738
+ }
1739
+ },
1740
+ }
1741
+ </script>
1742
+
1743
+ <style scoped>
1744
+ .video-section {
1745
+ width: 100%;
1746
+ height: 100%;
1747
+ display: flex;
1748
+ gap: 8px;
1749
+ }
1750
+
1751
+ .video-container {
1752
+ flex: 1;
1753
+ position: relative;
1754
+ display: flex;
1755
+ align-items: center;
1756
+ justify-content: center;
1757
+ overflow: hidden;
1758
+ }
1759
+
1760
+ .video-wrapper, .canvas-wrapper {
1761
+ position: absolute;
1762
+ top: 0;
1763
+ left: 0;
1764
+ width: 100%;
1765
+ height: 100%;
1766
+ }
1767
+
1768
+ .video-wrapper {
1769
+ z-index: 1;
1770
+ }
1771
+
1772
+ .canvas-wrapper {
1773
+ z-index: 999;
1774
+ }
1775
+
1776
+ .video-element {
1777
+ position: absolute;
1778
+ top: 0;
1779
+ left: 0;
1780
+ width: 100%;
1781
+ height: 100%;
1782
+ object-fit: contain;
1783
+ z-index: 1;
1784
+ }
1785
+
1786
+ .canvas-overlay {
1787
+ position: absolute;
1788
+ top: 0;
1789
+ left: 0;
1790
+ width: 100%;
1791
+ height: 100%;
1792
+ z-index: 10;
1793
+ background: transparent;
1794
+ pointer-events: auto;
1795
+ }
1796
+
1797
+ .tool-btn {
1798
+ width: 34px;
1799
+ height: 34px;
1800
+ border: none;
1801
+ border-radius: 4px;
1802
+ background: transparent;
1803
+ color: #fff;
1804
+ cursor: pointer;
1805
+ display: flex;
1806
+ align-items: center;
1807
+ justify-content: center;
1808
+ transition: all 0.2s ease;
1809
+ }
1810
+
1811
+ .tool-btn:hover {
1812
+ background: #4a4a4a;
1813
+ }
1814
+
1815
+ .tool-btn.active {
1816
+ background: #3a3a3a;
1817
+ color: white;
1818
+ }
1819
+
1820
+ .tool-btn svg {
1821
+ width: 20px;
1822
+ height: 20px;
1823
+ stroke-width: 2;
1824
+ }
1825
+
1826
+ .tool-btn:disabled {
1827
+ opacity: 0.5;
1828
+ cursor: not-allowed;
1829
+ background: transparent;
1830
+ }
1831
+
1832
+ .tool-btn:not(:disabled):hover {
1833
+ background: #4a4a4a;
1834
+ }
1835
+
1836
+ .pulse-animation {
1837
+ animation: pulse 1.5s infinite ease-in-out;
1838
+ }
1839
+
1840
+ .spinner {
1841
+ width: 20px;
1842
+ height: 20px;
1843
+ border: 3px solid rgba(255, 255, 255, 0.3);
1844
+ border-radius: 50%;
1845
+ border-top-color: white;
1846
+ animation: spin 1s ease-in-out infinite;
1847
+ }
1848
+
1849
+ @keyframes spin {
1850
+ to { transform: rotate(360deg); }
1851
+ }
1852
+ </style>
src/components/VideoTimeline.vue ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="video-timeline">
3
+ <timeline-section />
4
+ <objects-timeline-section />
5
+ </div>
6
+ </template>
7
+
8
+ <script>
9
+ import TimelineSection from './TimelineSection.vue'
10
+ import ObjectsTimelineSection from './ObjectsTimelineSection.vue'
11
+
12
+ export default {
13
+ name: 'VideoTimeline',
14
+ components: {
15
+ TimelineSection,
16
+ ObjectsTimelineSection
17
+ }
18
+ }
19
+ </script>
20
+
21
+ <style scoped>
22
+ .video-timeline {
23
+ width: 100%;
24
+ border-radius: 8px;
25
+ padding: 0;
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 16px;
29
+ }
30
+ </style>
src/components/ZoomSection.vue ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="zoom-section">
3
+ <div class="zoom-header">
4
+ <div class="view-navigation">
5
+ <button
6
+ class="nav-button"
7
+ @click="previousView"
8
+ >
9
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
10
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
11
+ </svg>
12
+ </button>
13
+
14
+ <span class="view-indicator">
15
+ {{ currentViewIndex + 1 }} / {{ views.length }}
16
+ </span>
17
+
18
+ <button
19
+ class="nav-button"
20
+ @click="nextView"
21
+ >
22
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
23
+ <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
24
+ </svg>
25
+ </button>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="zoom-content">
30
+ <transition :name="transitionName" mode="out-in">
31
+ <component
32
+ :is="currentView.component"
33
+ :key="currentViewIndex"
34
+ class="view-content"
35
+ />
36
+ </transition>
37
+ </div>
38
+ </div>
39
+ </template>
40
+
41
+ <script>
42
+ import { useAnnotationStore } from '@/stores/annotationStore'
43
+ import { computed } from 'vue'
44
+ import ZoomView from './ZoomView.vue'
45
+ import AnnotationsRawView from './AnnotationsRawView.vue'
46
+
47
+ export default {
48
+ name: 'ZoomSection',
49
+
50
+ components: {
51
+ ZoomView,
52
+ AnnotationsRawView
53
+ },
54
+
55
+ setup() {
56
+ const annotationStore = useAnnotationStore()
57
+
58
+ const objectName = computed(() => {
59
+ if (!annotationStore.selectedObjectId) return 'Aucun objet sélectionné'
60
+ return annotationStore.objects[annotationStore.selectedObjectId]?.name ||
61
+ annotationStore.selectedObjectId
62
+ })
63
+
64
+ return {
65
+ objectName
66
+ }
67
+ },
68
+
69
+ data() {
70
+ return {
71
+ currentViewIndex: 0,
72
+ transitionName: 'slide-right',
73
+ views: [
74
+ {
75
+ name: 'Zoom',
76
+ component: 'ZoomView'
77
+ },
78
+ {
79
+ name: 'Raw Annotations',
80
+ component: 'AnnotationsRawView'
81
+ }
82
+ ]
83
+ }
84
+ },
85
+
86
+ computed: {
87
+ currentView() {
88
+ return this.views[this.currentViewIndex]
89
+ }
90
+ },
91
+
92
+ methods: {
93
+ nextView() {
94
+ this.transitionName = 'slide-right'
95
+ this.currentViewIndex = (this.currentViewIndex + 1) % this.views.length
96
+ },
97
+
98
+ previousView() {
99
+ this.transitionName = 'slide-left'
100
+ this.currentViewIndex = this.currentViewIndex === 0
101
+ ? this.views.length - 1
102
+ : this.currentViewIndex - 1
103
+ }
104
+ }
105
+ }
106
+ </script>
107
+
108
+ <style scoped>
109
+ .zoom-section {
110
+ width: 100%;
111
+ height: 100%;
112
+ background: #2c2c2c;
113
+ border-radius: 8px;
114
+ display: flex;
115
+ flex-direction: column;
116
+ overflow: hidden;
117
+ }
118
+
119
+ .zoom-header {
120
+ padding: 8px 12px;
121
+ background: #3c3c3c;
122
+ border-bottom: 1px solid #4a4a4a;
123
+ display: flex;
124
+ justify-content: center;
125
+ align-items: center;
126
+ }
127
+
128
+ .view-navigation {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 8px;
132
+ }
133
+
134
+ .nav-button {
135
+ background: #4a4a4a;
136
+ border: none;
137
+ border-radius: 4px;
138
+ color: white;
139
+ width: 24px;
140
+ height: 24px;
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ cursor: pointer;
145
+ transition: all 0.2s;
146
+ }
147
+
148
+ .nav-button:hover:not(:disabled) {
149
+ background: #5a5a5a;
150
+ }
151
+
152
+
153
+
154
+ .view-indicator {
155
+ color: #ccc;
156
+ font-size: 0.8rem;
157
+ min-width: 40px;
158
+ text-align: center;
159
+ }
160
+
161
+ .zoom-content {
162
+ flex: 1;
163
+ position: relative;
164
+ overflow: hidden;
165
+ }
166
+
167
+ .view-content {
168
+ position: absolute;
169
+ top: 0;
170
+ left: 0;
171
+ right: 0;
172
+ bottom: 0;
173
+ }
174
+
175
+ /* Animations de transition */
176
+ .slide-right-enter-active, .slide-right-leave-active,
177
+ .slide-left-enter-active, .slide-left-leave-active {
178
+ transition: transform 0.3s ease;
179
+ }
180
+
181
+ /* Animation vers la droite */
182
+ .slide-right-enter-from {
183
+ transform: translateX(100%);
184
+ }
185
+
186
+ .slide-right-leave-to {
187
+ transform: translateX(-100%);
188
+ }
189
+
190
+ /* Animation vers la gauche */
191
+ .slide-left-enter-from {
192
+ transform: translateX(-100%);
193
+ }
194
+
195
+ .slide-left-leave-to {
196
+ transform: translateX(100%);
197
+ }
198
+ </style>
src/components/ZoomView.vue ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="zoom-view">
3
+ <div v-if="hasAnnotations" class="zoom-image-container">
4
+ <canvas
5
+ ref="zoomCanvas"
6
+ class="zoom-canvas"
7
+ :width="zoomWidth"
8
+ :height="zoomHeight"
9
+ ></canvas>
10
+ <div class="zoom-info">
11
+ <span>Frame {{ currentFrameNumber }}</span>
12
+ <span v-if="zoomRegion">{{ Math.round(zoomRegion.width) }}x{{ Math.round(zoomRegion.height) }}px</span>
13
+ <span v-if="zoomRegion?.type === 'points' && selectedAnnotations.length > 1">
14
+ {{ selectedAnnotations.length }} points
15
+ </span>
16
+ </div>
17
+ </div>
18
+ <div v-else class="no-annotations">
19
+ <p>Aucune annotation sur cette frame</p>
20
+ </div>
21
+ </div>
22
+ </template>
23
+
24
+ <script>
25
+ import { useAnnotationStore } from '@/stores/annotationStore'
26
+ import { useVideoStore } from '@/stores/videoStore'
27
+ import { computed, ref, watch, nextTick, onMounted } from 'vue'
28
+
29
+ export default {
30
+ name: 'ZoomView',
31
+
32
+ mounted() {
33
+ // Forcer le rafraîchissement quand le composant est monté
34
+ this.$nextTick(() => {
35
+ setTimeout(() => {
36
+ this.updateZoomImage()
37
+ }, 100) // Petit délai pour s'assurer que la vidéo est prête
38
+ })
39
+ },
40
+
41
+ setup() {
42
+ const annotationStore = useAnnotationStore()
43
+ const videoStore = useVideoStore()
44
+ const zoomCanvas = ref(null)
45
+
46
+ // Dimensions du canvas de zoom
47
+ const zoomWidth = 200
48
+ const zoomHeight = 300
49
+
50
+ const getCurrentFrameNumber = () => {
51
+ const frameRate = annotationStore.currentSession?.frameRate || 30
52
+ return Math.round(videoStore.currentTime * frameRate)
53
+ }
54
+
55
+ const currentFrameNumber = computed(() => getCurrentFrameNumber())
56
+
57
+ const selectedAnnotations = computed(() => {
58
+ const currentFrame = getCurrentFrameNumber()
59
+ const frameAnnotations = annotationStore.getAnnotationsForFrame(currentFrame) || []
60
+ return frameAnnotations.filter(
61
+ annotation => annotation && annotation.objectId === annotationStore.selectedObjectId
62
+ )
63
+ })
64
+
65
+ const hasAnnotations = computed(() => {
66
+ return selectedAnnotations.value.length > 0
67
+ })
68
+
69
+ const zoomRegion = computed(() => {
70
+ const annotations = selectedAnnotations.value
71
+ if (!annotations.length) return null
72
+
73
+ // Séparer rectangles et points
74
+ const rectangles = annotations.filter(a => a.type === 'rectangle')
75
+ const points = annotations.filter(a => a.type === 'point')
76
+
77
+ if (rectangles.length > 0) {
78
+ // Utiliser le premier rectangle trouvé
79
+ const rect = rectangles[0]
80
+ return {
81
+ x: rect.x,
82
+ y: rect.y,
83
+ width: rect.width,
84
+ height: rect.height,
85
+ type: 'rectangle'
86
+ }
87
+ } else if (points.length > 0) {
88
+ // Calculer le centre moyen des points
89
+ const avgX = points.reduce((sum, p) => sum + p.x, 0) / points.length
90
+ const avgY = points.reduce((sum, p) => sum + p.y, 0) / points.length
91
+
92
+ if (points.length === 1) {
93
+ // Pour un seul point, utiliser une taille fixe raisonnable
94
+ const fixedSize = 120
95
+ return {
96
+ x: avgX - fixedSize,
97
+ y: avgY - fixedSize,
98
+ width: fixedSize * 2,
99
+ height: fixedSize * 2,
100
+ type: 'points',
101
+ centerX: avgX,
102
+ centerY: avgY
103
+ }
104
+ } else {
105
+ // Pour plusieurs points, calculer la bounding box englobante
106
+ const minX = Math.min(...points.map(p => p.x))
107
+ const maxX = Math.max(...points.map(p => p.x))
108
+ const minY = Math.min(...points.map(p => p.y))
109
+ const maxY = Math.max(...points.map(p => p.y))
110
+
111
+ // Calculer les dimensions nécessaires
112
+ const pointsWidth = maxX - minX
113
+ const pointsHeight = maxY - minY
114
+
115
+ // Ajouter une marge (minimum 60px de chaque côté)
116
+ const marginX = Math.max(60, pointsWidth * 0.3)
117
+ const marginY = Math.max(60, pointsHeight * 0.3)
118
+
119
+ // Calculer les dimensions finales
120
+ const finalWidth = pointsWidth + marginX * 2
121
+ const finalHeight = pointsHeight + marginY * 2
122
+
123
+ return {
124
+ x: minX - marginX,
125
+ y: minY - marginY,
126
+ width: finalWidth,
127
+ height: finalHeight,
128
+ type: 'points',
129
+ centerX: avgX,
130
+ centerY: avgY,
131
+ pointsBounds: {
132
+ minX, maxX, minY, maxY,
133
+ pointsWidth, pointsHeight
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ return null
140
+ })
141
+
142
+ const drawAnnotationsOnZoom = (ctx, sourceX, sourceY, sourceWidth, sourceHeight) => {
143
+ const annotations = selectedAnnotations.value
144
+ if (!annotations.length) return
145
+
146
+ // Calculer le facteur d'échelle entre la source et le canvas
147
+ const scaleX = zoomWidth / sourceWidth
148
+ const scaleY = zoomHeight / sourceHeight
149
+
150
+ annotations.forEach(annotation => {
151
+ if (annotation.type === 'rectangle') {
152
+ // Calculer la position du rectangle dans le canvas zoomé
153
+ const rectX = (annotation.x - sourceX) * scaleX
154
+ const rectY = (annotation.y - sourceY) * scaleY
155
+ const rectWidth = annotation.width * scaleX
156
+ const rectHeight = annotation.height * scaleY
157
+
158
+ // Ne dessiner que si le rectangle est visible dans la zone
159
+ if (rectX < zoomWidth && rectY < zoomHeight &&
160
+ rectX + rectWidth > 0 && rectY + rectHeight > 0) {
161
+
162
+ // Dessiner le rectangle
163
+ ctx.strokeStyle = '#00ff00' // Vert pour les rectangles
164
+ ctx.lineWidth = 2
165
+ ctx.setLineDash([5, 3]) // Trait pointillé
166
+ ctx.strokeRect(rectX, rectY, rectWidth, rectHeight)
167
+ ctx.setLineDash([]) // Remettre trait plein
168
+ }
169
+
170
+ } else if (annotation.type === 'point') {
171
+ // Calculer la position du point dans le canvas zoomé
172
+ const pointX = (annotation.x - sourceX) * scaleX
173
+ const pointY = (annotation.y - sourceY) * scaleY
174
+
175
+ // Ne dessiner que si le point est visible dans la zone
176
+ if (pointX >= 0 && pointX <= zoomWidth && pointY >= 0 && pointY <= zoomHeight) {
177
+
178
+ // Couleur selon le type de point
179
+ const pointColor = annotation.pointType === 'positive' ? '#00ff00' : '#ff0000'
180
+
181
+ // Dessiner le cercle du point
182
+ ctx.fillStyle = pointColor
183
+ ctx.strokeStyle = '#ffffff'
184
+ ctx.lineWidth = 2
185
+ ctx.beginPath()
186
+ ctx.arc(pointX, pointY, 6, 0, 2 * Math.PI)
187
+ ctx.fill()
188
+ ctx.stroke()
189
+
190
+ // Dessiner le symbole + ou -
191
+ ctx.strokeStyle = '#ffffff'
192
+ ctx.lineWidth = 2
193
+ ctx.beginPath()
194
+ if (annotation.pointType === 'positive') {
195
+ // Dessiner +
196
+ ctx.moveTo(pointX - 3, pointY)
197
+ ctx.lineTo(pointX + 3, pointY)
198
+ ctx.moveTo(pointX, pointY - 3)
199
+ ctx.lineTo(pointX, pointY + 3)
200
+ } else {
201
+ // Dessiner -
202
+ ctx.moveTo(pointX - 3, pointY)
203
+ ctx.lineTo(pointX + 3, pointY)
204
+ }
205
+ ctx.stroke()
206
+ }
207
+ }
208
+ })
209
+
210
+ // Si c'est une vue centrée sur des points, dessiner une croix de repère au centre
211
+ if (zoomRegion.value?.type === 'points') {
212
+ ctx.strokeStyle = '#ffff00' // Jaune pour le centre
213
+ ctx.lineWidth = 1
214
+ ctx.setLineDash([3, 3])
215
+ ctx.beginPath()
216
+ const centerX = zoomWidth / 2
217
+ const centerY = zoomHeight / 2
218
+ ctx.moveTo(centerX - 15, centerY)
219
+ ctx.lineTo(centerX + 15, centerY)
220
+ ctx.moveTo(centerX, centerY - 15)
221
+ ctx.lineTo(centerX, centerY + 15)
222
+ ctx.stroke()
223
+ ctx.setLineDash([])
224
+ }
225
+ }
226
+
227
+ const updateZoomImage = async () => {
228
+ if (!zoomCanvas.value || !zoomRegion.value) return
229
+
230
+ // Trouver l'élément vidéo
231
+ const videoElement = document.querySelector('video')
232
+ if (!videoElement) return
233
+
234
+ const canvas = zoomCanvas.value
235
+ const ctx = canvas.getContext('2d')
236
+
237
+ // Effacer le canvas
238
+ ctx.clearRect(0, 0, zoomWidth, zoomHeight)
239
+
240
+ try {
241
+ // Calculer les coordonnées source dans la vidéo
242
+ const sourceX = Math.max(0, zoomRegion.value.x)
243
+ const sourceY = Math.max(0, zoomRegion.value.y)
244
+ const sourceWidth = Math.min(zoomRegion.value.width, videoElement.videoWidth - sourceX)
245
+ const sourceHeight = Math.min(zoomRegion.value.height, videoElement.videoHeight - sourceY)
246
+
247
+ // S'assurer que les dimensions sont valides
248
+ if (sourceWidth <= 0 || sourceHeight <= 0) return
249
+
250
+ // Dessiner la région zoomée sur le canvas
251
+ ctx.drawImage(
252
+ videoElement,
253
+ sourceX, sourceY, sourceWidth, sourceHeight, // Source (région de la vidéo)
254
+ 0, 0, zoomWidth, zoomHeight // Destination (canvas)
255
+ )
256
+
257
+ // Dessiner les annotations sur l'image zoomée
258
+ drawAnnotationsOnZoom(ctx, sourceX, sourceY, sourceWidth, sourceHeight)
259
+
260
+ } catch (error) {
261
+ console.error('Erreur lors de la capture du zoom:', error)
262
+ // Afficher un message d'erreur sur le canvas
263
+ ctx.fillStyle = '#666'
264
+ ctx.fillRect(0, 0, zoomWidth, zoomHeight)
265
+ ctx.fillStyle = '#fff'
266
+ ctx.font = '14px Arial'
267
+ ctx.textAlign = 'center'
268
+ ctx.fillText('Erreur de capture', zoomWidth / 2, zoomHeight / 2)
269
+ }
270
+ }
271
+
272
+ // Watcher pour mettre à jour l'image quand les annotations changent
273
+ watch([selectedAnnotations, currentFrameNumber], () => {
274
+ nextTick(() => {
275
+ updateZoomImage()
276
+ })
277
+ }, { deep: true })
278
+
279
+ // Watcher pour mettre à jour quand le temps de la vidéo change
280
+ watch(() => videoStore.currentTime, () => {
281
+ nextTick(() => {
282
+ updateZoomImage()
283
+ })
284
+ })
285
+
286
+ // Hook onMounted pour forcer le rafraîchissement au montage
287
+ onMounted(() => {
288
+ nextTick(() => {
289
+ setTimeout(() => {
290
+ updateZoomImage()
291
+ }, 200) // Délai plus long pour s'assurer que tout est prêt
292
+ })
293
+ })
294
+
295
+ return {
296
+ selectedAnnotations,
297
+ hasAnnotations,
298
+ zoomRegion,
299
+ currentFrameNumber,
300
+ zoomCanvas,
301
+ zoomWidth,
302
+ zoomHeight,
303
+ updateZoomImage
304
+ }
305
+ }
306
+ }
307
+ </script>
308
+
309
+ <style scoped>
310
+ .zoom-view {
311
+ height: 100%;
312
+ padding: 10px;
313
+ color: white;
314
+ overflow: auto;
315
+ display: flex;
316
+ flex-direction: column;
317
+ align-items: center;
318
+ justify-content: center;
319
+ }
320
+
321
+ .zoom-image-container {
322
+ display: flex;
323
+ flex-direction: column;
324
+ align-items: center;
325
+ gap: 8px;
326
+ }
327
+
328
+ .zoom-canvas {
329
+ border: 1px solid #555;
330
+ border-radius: 4px;
331
+ background: #000;
332
+ }
333
+
334
+ .zoom-info {
335
+ display: flex;
336
+ gap: 12px;
337
+ font-size: 0.8rem;
338
+ color: #ccc;
339
+ }
340
+
341
+ .no-annotations {
342
+ text-align: center;
343
+ color: #888;
344
+ font-style: italic;
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ height: 100%;
349
+ }
350
+
351
+ .no-annotations p {
352
+ margin: 0;
353
+ }
354
+ </style>
src/components/tools/ToolBar.vue ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="video-tools">
3
+ <tool-button
4
+ :is-active="currentTool === 'arrow'"
5
+ @click="selectTool('arrow')"
6
+ title="Selection Tool"
7
+ >
8
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
9
+ <path d="M3 3l7 19 2.051-7.179L19 13 3 3z"/>
10
+ </svg>
11
+ </tool-button>
12
+
13
+ <tool-button
14
+ :is-active="currentTool === 'rectangle'"
15
+ @click="selectTool('rectangle')"
16
+ title="Rectangle Tool"
17
+ >
18
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor">
19
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
20
+ </svg>
21
+ </tool-button>
22
+
23
+ <tool-button
24
+ :is-active="currentTool === 'positive'"
25
+ @click="selectTool('positive')"
26
+ title="Positive Point"
27
+ >
28
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="currentColor">
29
+ <circle cx="12" cy="12" r="10" fill="#4CAF50"/>
30
+ <line x1="7" y1="12" x2="17" y2="12" stroke="white" stroke-width="2"/>
31
+ <line x1="12" y1="7" x2="12" y2="17" stroke="white" stroke-width="2"/>
32
+ </svg>
33
+ </tool-button>
34
+
35
+ <tool-button
36
+ :is-active="currentTool === 'negative'"
37
+ @click="selectTool('negative')"
38
+ title="Negative Point"
39
+ >
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="currentColor">
41
+ <circle cx="12" cy="12" r="10" fill="#f44336"/>
42
+ <line x1="7" y1="12" x2="17" y2="12" stroke="white" stroke-width="2"/>
43
+ </svg>
44
+ </tool-button>
45
+ </div>
46
+ </template>
47
+
48
+ <script>
49
+ import ToolButton from './ToolButton.vue'
50
+
51
+ export default {
52
+ name: 'ToolBar',
53
+
54
+ components: {
55
+ ToolButton
56
+ },
57
+
58
+ props: {
59
+ currentTool: {
60
+ type: String,
61
+ required: true
62
+ }
63
+ },
64
+
65
+ emits: ['tool-selected'],
66
+
67
+ methods: {
68
+ selectTool(tool) {
69
+ this.$emit('tool-selected', tool)
70
+ }
71
+ }
72
+ }
73
+ </script>
74
+
75
+ <style scoped>
76
+ .video-tools {
77
+ width: 50px;
78
+ height: 100%;
79
+ padding: 8px;
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 8px;
83
+ justify-content: center;
84
+ align-items: center;
85
+ }
86
+ </style>
src/components/tools/ToolButton.vue ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <button
3
+ class="tool-btn"
4
+ :class="{ active: isActive }"
5
+ @click="$emit('click')"
6
+ :title="title"
7
+ >
8
+ <slot></slot>
9
+ </button>
10
+ </template>
11
+
12
+ <script>
13
+ export default {
14
+ name: 'ToolButton',
15
+
16
+ props: {
17
+ isActive: {
18
+ type: Boolean,
19
+ default: false
20
+ },
21
+ title: {
22
+ type: String,
23
+ default: ''
24
+ }
25
+ },
26
+
27
+ emits: ['click']
28
+ }
29
+ </script>
30
+
31
+ <style scoped>
32
+ .tool-btn {
33
+ width: 34px;
34
+ height: 34px;
35
+ border: none;
36
+ border-radius: 4px;
37
+ background: transparent;
38
+ color: #fff;
39
+ cursor: pointer;
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ transition: all 0.2s ease;
44
+ }
45
+
46
+ .tool-btn:hover {
47
+ background: #4a4a4a;
48
+ }
49
+
50
+ .tool-btn.active {
51
+ background: #3a3a3a;
52
+ color: white;
53
+ }
54
+
55
+ .tool-btn svg {
56
+ width: 20px;
57
+ height: 20px;
58
+ stroke-width: 2;
59
+ }
60
+
61
+ .tool-btn:disabled {
62
+ opacity: 0.5;
63
+ cursor: not-allowed;
64
+ background: transparent;
65
+ }
66
+
67
+ .tool-btn:not(:disabled):hover {
68
+ background: #4a4a4a;
69
+ }
70
+ </style>
src/main.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import App from './App.vue'
4
+ import router from './router'
5
+ import VueKonva from 'vue-konva'
6
+
7
+ const app = createApp(App)
8
+ app.use(router)
9
+ app.use(VueKonva)
10
+ app.use(createPinia())
11
+ app.mount('#app')
src/router/index.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRouter, createWebHashHistory } from 'vue-router'
2
+ import SegmentationView from '../views/SegmentationView.vue'
3
+
4
+ const routes = [
5
+ {
6
+ path: '/',
7
+ redirect: '/segmentation'
8
+ },
9
+ {
10
+ path: '/segmentation',
11
+ name: 'segmentation',
12
+ component: SegmentationView
13
+ }
14
+ ]
15
+
16
+ const router = createRouter({
17
+ history: createWebHashHistory(),
18
+ routes
19
+ })
20
+
21
+ export default router
src/stores/annotationStore.js ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Dans un store Pinia (stores/annotationStore.js)
2
+ import { defineStore } from 'pinia'
3
+ import { v4 as uuidv4 } from 'uuid'
4
+
5
+ export const useAnnotationStore = defineStore('annotations', {
6
+ state: () => ({
7
+ // Session courante avec métadonnées vidéo
8
+ currentSession: {
9
+ id: 'session-1',
10
+ name: 'Session d\'annotation',
11
+ videoId: null,
12
+ video: null, // Nom du fichier vidéo
13
+ metadata: {
14
+ duration: 0,
15
+ width: 0,
16
+ height: 0,
17
+ fps: 30,
18
+ date: new Date().toISOString()
19
+ }
20
+ },
21
+
22
+ // Dictionnaire des objets
23
+ objects: {
24
+ // Format: "1": { id: "1", name: "Objet 1", color: "#4f056f" }
25
+ },
26
+
27
+ // Compteur pour les IDs d'objets
28
+ objectIdCounter: 1,
29
+
30
+ // Annotations par frame
31
+ frameAnnotations: {}, // Format: {
32
+ // "0": [
33
+ // {
34
+ // id: "uuid",
35
+ // objectId: "1",
36
+ // type: "mask",
37
+ // mask: "base64...",
38
+ // maskScore: 0.91,
39
+ // maskImageSize: { width: 1920, height: 1080 },
40
+ // points: [{ x: 1270, y: 405, type: "positive" }]
41
+ // },
42
+ // {
43
+ // id: "uuid",
44
+ // objectId: "2",
45
+ // type: "rectangle",
46
+ // x: 100,
47
+ // y: 100,
48
+ // width: 200,
49
+ // height: 150
50
+ // }
51
+ // ]
52
+ // }
53
+ selectedObjectId: null,
54
+ temporaryPoints: []
55
+ }),
56
+
57
+ getters: {
58
+ getTemporaryPointsForObject: (state) => (objectId) => {
59
+ return state.temporaryPoints.filter(point => point.objectId === objectId)
60
+ }
61
+ },
62
+
63
+ actions: {
64
+ // Sélectionner un objet
65
+ selectObject(objectId) {
66
+ this.selectedObjectId = objectId
67
+ console.log(`Objet sélectionné: ${objectId}`)
68
+ },
69
+
70
+ // Désélectionner l'objet actuel
71
+ deselectObject() {
72
+ this.selectedObjectId = null
73
+ },
74
+
75
+ // Mettre à jour les métadonnées de la vidéo
76
+ updateVideoMetadata(metadata) {
77
+ this.currentSession.metadata = {
78
+ ...this.currentSession.metadata,
79
+ ...metadata
80
+ }
81
+ },
82
+
83
+ // Ajouter une annotation pour l'objet sélectionné à la frame actuelle
84
+ addAnnotation(frameNumber, annotation) {
85
+ const id = uuidv4()
86
+ let newAnnotation
87
+
88
+ if (annotation.type === 'mask') {
89
+ newAnnotation = {
90
+ id,
91
+ objectId: this.selectedObjectId,
92
+ type: 'mask',
93
+ mask: annotation.mask || '',
94
+ maskScore: annotation.maskScore || 0,
95
+ maskImageSize: annotation.maskImageSize || {
96
+ width: this.currentSession.metadata.width,
97
+ height: this.currentSession.metadata.height
98
+ },
99
+ points: annotation.points || []
100
+ }
101
+ } else if (annotation.type === 'rectangle') {
102
+ newAnnotation = {
103
+ id,
104
+ objectId: this.selectedObjectId,
105
+ type: 'rectangle',
106
+ x: annotation.x,
107
+ y: annotation.y,
108
+ width: annotation.width,
109
+ height: annotation.height
110
+ }
111
+ } else if (annotation.type === 'point') {
112
+ newAnnotation = {
113
+ id,
114
+ objectId: this.selectedObjectId,
115
+ type: 'point',
116
+ x: annotation.x,
117
+ y: annotation.y,
118
+ pointType: annotation.pointType // 'positive' ou 'negative'
119
+ }
120
+ } else {
121
+ // Gestion par défaut pour les autres types
122
+ newAnnotation = {
123
+ id,
124
+ objectId: this.selectedObjectId,
125
+ ...annotation
126
+ }
127
+ }
128
+
129
+ // Vérifier que newAnnotation a été créée
130
+ if (!newAnnotation) {
131
+ console.error('Erreur: impossible de créer l\'annotation', annotation)
132
+ return null
133
+ }
134
+
135
+ if (!this.frameAnnotations[frameNumber]) {
136
+ this.frameAnnotations[frameNumber] = []
137
+ }
138
+
139
+ this.frameAnnotations[frameNumber].push(newAnnotation)
140
+ console.log('Annotation ajoutée:', newAnnotation)
141
+ return id
142
+ },
143
+
144
+ updateAnnotation(frameNumber, annotationId, updates) {
145
+ if (!this.frameAnnotations[frameNumber]) return
146
+
147
+ const annotationIndex = this.frameAnnotations[frameNumber].findIndex(
148
+ a => a.id === annotationId
149
+ )
150
+
151
+ if (annotationIndex === -1) return
152
+
153
+ // Mettre à jour l'annotation avec les nouvelles propriétés
154
+ this.frameAnnotations[frameNumber][annotationIndex] = {
155
+ ...this.frameAnnotations[frameNumber][annotationIndex],
156
+ ...updates
157
+ }
158
+ },
159
+
160
+ // Ajouter un nouvel objet
161
+ addObject(objectData = {}) {
162
+ const objectId = `${this.objectIdCounter++}`
163
+
164
+ this.objects[objectId] = {
165
+ id: objectId,
166
+ name: objectData.name || `Objet ${this.objectIdCounter - 1}`,
167
+ color: objectData.color || this.getRandomColor(),
168
+ // Autres propriétés selon vos besoins
169
+ }
170
+
171
+ // Sélectionner automatiquement le nouvel objet
172
+ this.selectObject(objectId)
173
+
174
+ return objectId
175
+ },
176
+
177
+ // Récupérer les annotations pour une frame
178
+ getAnnotationsForFrame(frameNumber) {
179
+ return this.frameAnnotations[frameNumber.toString()] || []
180
+ },
181
+
182
+ // Supprimer une annotation
183
+ removeAnnotation(frameNumber, annotationId) {
184
+ const frameKey = frameNumber.toString()
185
+ if (this.frameAnnotations[frameKey]) {
186
+ this.frameAnnotations[frameKey] = this.frameAnnotations[frameKey]
187
+ .filter(a => a.id !== annotationId)
188
+ }
189
+ },
190
+
191
+ // Nouvelle méthode pour supprimer tous les masques d'un objet sur une frame
192
+ removeMasksForObject(frameNumber, objectId) {
193
+ const frameKey = frameNumber.toString()
194
+ if (this.frameAnnotations[frameKey]) {
195
+ this.frameAnnotations[frameKey] = this.frameAnnotations[frameKey]
196
+ .filter(a => !(a.objectId === objectId && a.type === 'mask'))
197
+ }
198
+ },
199
+
200
+ // Vérifier si un objet a encore des annotations sur une frame
201
+ hasAnnotationsForObject(frameNumber, objectId) {
202
+ const frameKey = frameNumber.toString()
203
+ if (!this.frameAnnotations[frameKey]) return false
204
+
205
+ return this.frameAnnotations[frameKey]
206
+ .some(a => a.objectId === objectId)
207
+ },
208
+
209
+ // Générer une couleur aléatoire
210
+ getRandomColor() {
211
+ return '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')
212
+ },
213
+
214
+ // Sauvegarder les annotations dans le format demandé
215
+ saveAnnotations() {
216
+ const data = {
217
+ video: this.currentSession.video,
218
+ metadata: this.currentSession.metadata,
219
+ objects: this.objects,
220
+ annotations: this.frameAnnotations
221
+ }
222
+
223
+ localStorage.setItem('annotations', JSON.stringify(data))
224
+ },
225
+
226
+ // Charger les annotations depuis le format demandé
227
+ loadAnnotations() {
228
+ const saved = localStorage.getItem('annotations')
229
+ if (saved) {
230
+ const data = JSON.parse(saved)
231
+ this.currentSession.video = data.video
232
+ this.currentSession.metadata = data.metadata
233
+ this.objects = data.objects
234
+ this.frameAnnotations = data.annotations
235
+ }
236
+ },
237
+
238
+ getAnnotation(frameNumber, annotationId) {
239
+ if (!this.frameAnnotations[frameNumber]) return null
240
+
241
+ return this.frameAnnotations[frameNumber].find(a => a.id === annotationId) || null
242
+ },
243
+
244
+ addTemporaryPoint(point) {
245
+ // Ajouter un ID unique au point
246
+ const pointWithId = {
247
+ ...point,
248
+ id: uuidv4()
249
+ }
250
+ this.temporaryPoints.push(pointWithId)
251
+ return pointWithId.id
252
+ },
253
+
254
+ removeTemporaryPoint(pointId) {
255
+ const index = this.temporaryPoints.findIndex(p => p.id === pointId)
256
+ if (index !== -1) {
257
+ this.temporaryPoints.splice(index, 1)
258
+ }
259
+ },
260
+
261
+ clearTemporaryPoints() {
262
+ this.temporaryPoints = []
263
+ },
264
+
265
+ // Supprimer un objet et toutes ses annotations
266
+ deleteObject(objectId) {
267
+ // Supprimer l'objet du dictionnaire
268
+ delete this.objects[objectId]
269
+
270
+ // Supprimer toutes les annotations associées à cet objet
271
+ Object.keys(this.frameAnnotations).forEach(frameKey => {
272
+ this.frameAnnotations[frameKey] = this.frameAnnotations[frameKey]
273
+ .filter(annotation => annotation.objectId !== objectId)
274
+ })
275
+
276
+ // Si l'objet supprimé était sélectionné, désélectionner
277
+ if (this.selectedObjectId === objectId) {
278
+ this.selectedObjectId = null
279
+ }
280
+
281
+ // Supprimer les points temporaires associés à cet objet
282
+ this.temporaryPoints = this.temporaryPoints.filter(point => point.objectId !== objectId)
283
+ }
284
+ }
285
+ })
src/stores/videoStore.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from 'pinia'
2
+
3
+ export const useVideoStore = defineStore('video', {
4
+ state: () => ({
5
+ videos: [],
6
+ selectedVideo: null,
7
+ defaultPath: 'C:\\Users\\antoi\\Documents\\Work_Learn\\Stage-Rennes\\RepositoryFootballVision\\SportDETR\\data\\football\\raw',
8
+ // defaultPath: '\\\\10.35.51.152\\Biomeca\\Projets\\25_EVA2PERF_M2_AntoineVerdon\\RepositoryFootballVision\\SportDETR\\data\\football\\raw',
9
+ currentTime: 0,
10
+ isPlaying: false,
11
+ duration: 0,
12
+ // Vidéo par défaut depuis les assets
13
+ defaultVideo: null,
14
+ isInitialized: false
15
+ }),
16
+
17
+ actions: {
18
+ // Initialiser avec une vidéo par défaut si aucune n'est sélectionnée
19
+ async initialize() {
20
+ if (!this.isInitialized && !this.selectedVideo) {
21
+ // Essayer de charger une vidéo par défaut depuis les assets
22
+ try {
23
+ const defaultVideoPath = require('@/assets/football.mp4')
24
+ this.defaultVideo = {
25
+ name: 'football',
26
+ path: defaultVideoPath,
27
+ type: 'video/mp4',
28
+ isDefault: true
29
+ }
30
+ // Ne pas définir automatiquement comme selectedVideo, laisser l'utilisateur choisir
31
+ } catch (error) {
32
+ console.log('Aucune vidéo par défaut disponible dans les assets')
33
+ }
34
+ this.isInitialized = true
35
+ }
36
+ },
37
+
38
+ setVideos(videos) {
39
+ this.videos = videos
40
+ },
41
+
42
+ async setSelectedVideo(video) {
43
+ this.selectedVideo = video
44
+ },
45
+
46
+ setCurrentTime(time) {
47
+ this.currentTime = time
48
+ },
49
+
50
+ setIsPlaying(isPlaying) {
51
+ this.isPlaying = isPlaying
52
+ },
53
+
54
+ setDuration(duration) {
55
+ this.duration = duration
56
+ },
57
+
58
+
59
+
60
+ async loadVideosFromFolder() {
61
+ // DÉSACTIVÉ - pas de chargement de dossier externe
62
+ this.videos = []
63
+ return []
64
+ },
65
+
66
+ async loadVideoMetadata(videoPath) {
67
+ return new Promise((resolve, reject) => {
68
+ const tempVideo = document.createElement('video')
69
+ tempVideo.src = videoPath
70
+ tempVideo.crossOrigin = 'anonymous'
71
+
72
+ tempVideo.addEventListener('loadedmetadata', () => {
73
+ this.duration = tempVideo.duration
74
+ resolve({
75
+ duration: tempVideo.duration,
76
+ videoElement: tempVideo
77
+ })
78
+ })
79
+
80
+ tempVideo.addEventListener('error', (error) => {
81
+ console.error('Erreur lors du chargement de la vidéo:', error)
82
+ reject(error)
83
+ })
84
+ })
85
+ },
86
+
87
+ updateProgressBar(time) {
88
+ this.currentTime = time
89
+ this.emitTimeUpdate(time)
90
+ },
91
+
92
+ emitTimeUpdate(time) {
93
+ this.currentTime = time
94
+ }
95
+ }
96
+ })
src/views/SegmentationView.vue ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="segmentation-view">
3
+ <!-- Sidebar de segmentation -->
4
+ <SegmentationSidebar
5
+ class="sidebar"
6
+ @video-selected="selectVideo"
7
+ />
8
+
9
+ <div class="main-content">
10
+ <!-- Grille 2x2 avec les 4 sections -->
11
+ <div class="grid-container">
12
+ <!-- Ligne du haut -->
13
+ <VideoSection class="video-section grid-item" />
14
+ <ZoomSection class="zoom-section grid-item" />
15
+
16
+ <!-- Ligne du bas -->
17
+ <VideoTimeline class="video-timeline grid-item" />
18
+ <EnrichedSection class="enriched-section grid-item" />
19
+ </div>
20
+ </div>
21
+ </div>
22
+ </template>
23
+
24
+ <script>
25
+ import SegmentationSidebar from '@/components/SegmentationSidebar.vue'
26
+ import VideoSection from '@/components/VideoSection.vue'
27
+ import VideoTimeline from '@/components/VideoTimeline.vue'
28
+ import ZoomSection from '@/components/ZoomSection.vue'
29
+ import EnrichedSection from '@/components/EnrichedSection.vue'
30
+ import { useVideoStore } from '@/stores/videoStore'
31
+
32
+ export default {
33
+ name: 'SegmentationView',
34
+
35
+ components: {
36
+ SegmentationSidebar,
37
+ VideoSection,
38
+ VideoTimeline,
39
+ ZoomSection,
40
+ EnrichedSection
41
+ },
42
+
43
+ setup() {
44
+ const videoStore = useVideoStore()
45
+ return {
46
+ videoStore
47
+ }
48
+ },
49
+
50
+ async mounted() {
51
+ // Charger une vidéo par défaut si aucune n'est sélectionnée
52
+ if (!this.videoStore.selectedVideo) {
53
+ await this.loadDefaultVideo()
54
+ }
55
+ },
56
+
57
+ methods: {
58
+ async selectVideo(video) {
59
+ await this.videoStore.setSelectedVideo(video)
60
+ },
61
+
62
+ async loadDefaultVideo() {
63
+ // Créer une vidéo par défaut avec la vidéo de football
64
+ try {
65
+ const defaultVideo = {
66
+ name: 'football.mp4',
67
+ path: require('@/assets/football.mp4'),
68
+ type: 'video/mp4',
69
+ isDefault: true,
70
+ size: 0
71
+ }
72
+ await this.videoStore.setSelectedVideo(defaultVideo)
73
+ console.log('Vidéo par défaut chargée:', defaultVideo.name)
74
+ } catch (error) {
75
+ console.error('Erreur lors du chargement de la vidéo par défaut:', error)
76
+ }
77
+ }
78
+ }
79
+ }
80
+ </script>
81
+
82
+ <style scoped>
83
+ .segmentation-view {
84
+ display: flex;
85
+ height: 100vh;
86
+ background: #1a1a1a;
87
+ }
88
+
89
+ .sidebar {
90
+ width: 200px;
91
+ border-right: 1px solid #333;
92
+ }
93
+
94
+ .main-content {
95
+ flex: 1;
96
+ display: flex;
97
+ flex-direction: column;
98
+ padding: 8px;
99
+ background: #2A2A2A;
100
+ }
101
+
102
+ .grid-container {
103
+ flex: 1;
104
+ display: grid;
105
+ grid-template-columns: 3fr 1fr;
106
+ grid-template-rows: 1fr 1fr;
107
+ gap: 8px;
108
+ min-height: 0;
109
+ }
110
+
111
+ .grid-item {
112
+ background: #2a2a2a;
113
+ border-radius: 8px;
114
+ overflow: hidden;
115
+ }
116
+
117
+ .video-section {
118
+ /* Section vidéo principale - haut gauche */
119
+ }
120
+
121
+ .zoom-section {
122
+ /* Section zoom - haut droite */
123
+ }
124
+
125
+ .enriched-section {
126
+ /* Section enriched - bas droite */
127
+ }
128
+
129
+ .video-timeline {
130
+ /* Timeline vidéo - bas gauche */
131
+ }
132
+ </style>
vue.config.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const { defineConfig } = require('@vue/cli-service')
2
+
3
+ module.exports = defineConfig({
4
+ transpileDependencies: true
5
+ })