Samfredoly commited on
Commit
5a560c5
·
verified ·
1 Parent(s): 0addbfc

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +45 -0
  2. download_api.py +407 -0
  3. requirements.txt +11 -0
Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim-bullseye
2
+
3
+ # Install system dependencies
4
+ RUN sed -i 's/main/main contrib non-free/' /etc/apt/sources.list && \
5
+ apt-get update && \
6
+ apt-get install -y --no-install-recommends \
7
+ unrar \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+
14
+ # Upgrade pip and install core dependencies first
15
+ RUN pip install --no-cache-dir --upgrade pip setuptools wheel packaging
16
+
17
+ # Install CPU-only PyTorch first
18
+
19
+ # Copy requirements and install with special handling for flash_attn
20
+ COPY requirements.txt .
21
+ RUN pip install --no-cache-dir \
22
+ -r requirements.txt \
23
+ --find-links https://download.pytorch.org/whl/cpu \
24
+ --extra-index-url https://pypi.org/simple && \
25
+ # Install remaining packages that might have been skipped
26
+ pip install --no-cache-dir \
27
+ accelerate \
28
+ transformers==4.36.2 \
29
+ timm==0.9.12 \
30
+ einops==0.7.0
31
+
32
+ # Copy application code
33
+ COPY . .
34
+
35
+ # Create non-root user
36
+ RUN useradd -m -u 1000 user && \
37
+ chown -R user:user /app
38
+
39
+ USER user
40
+
41
+ # Environment variables to suppress warnings
42
+ ENV HF_HUB_DISABLE_PROGRESS=1
43
+ ENV TF_CPP_MIN_LOG_LEVEL=3
44
+
45
+ CMD ["uvicorn", "download_api:app", "--host", "0.0.0.0", "--port", "7860"]
download_api.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import threading
5
+ import asyncio
6
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import JSONResponse, FileResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ import uvicorn
11
+ from typing import Dict
12
+ from pathlib import Path
13
+ import subprocess
14
+ from datetime import datetime
15
+
16
+ import torch
17
+
18
+ # Import core functionality
19
+ from vision_analyzer import (
20
+ main_processing_loop,
21
+ processing_status,
22
+ log_message,
23
+ FRAMES_OUTPUT_FOLDER
24
+ )
25
+
26
+ # FastAPI App Definition
27
+ app = FastAPI(title="Video Analysis API",
28
+ description="API to access video frame analysis results and extracted images",
29
+ version="1.0.0")
30
+
31
+ # Add CORS middleware to allow cross-origin requests
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=["*"], # Allows all origins
35
+ allow_credentials=True,
36
+ allow_methods=["*"], # Allows all methods
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ # Global variables for processing and frame tracking
41
+ processing_thread = None
42
+ frame_locks = {} # Dict to track frame locks: {course: {frame: {"locked_by": id, "locked_at": timestamp}}}
43
+ processed_frames = {} # Dict to track processed frames: {course: {frame: {"processed_by": id, "processed_at": timestamp}}}
44
+ LOCK_TIMEOUT = 300 # 5 minutes timeout for locks
45
+ TRACKING_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frame_tracking.json")
46
+
47
+ def save_tracking_state():
48
+ """Save frame tracking state to disk"""
49
+ state = {
50
+ "frame_locks": frame_locks,
51
+ "processed_frames": processed_frames
52
+ }
53
+ try:
54
+ with open(TRACKING_FILE, "w") as f:
55
+ json.dump(state, f, indent=2)
56
+ except Exception as e:
57
+ log_message(f"Error saving tracking state: {e}")
58
+
59
+ def load_tracking_state():
60
+ """Load frame tracking state from disk"""
61
+ global frame_locks, processed_frames
62
+ try:
63
+ with open(TRACKING_FILE, "r") as f:
64
+ state = json.load(f)
65
+ frame_locks = state.get("frame_locks", {})
66
+ processed_frames = state.get("processed_frames", {})
67
+ except FileNotFoundError:
68
+ log_message("No previous tracking state found")
69
+ except Exception as e:
70
+ log_message(f"Error loading tracking state: {e}")
71
+
72
+ def check_frame_lock(course: str, frame: str) -> bool:
73
+ """Check if frame is locked and lock hasn't expired"""
74
+ if course in frame_locks and frame in frame_locks[course]:
75
+ lock = frame_locks[course][frame]
76
+ if time.time() - lock["locked_at"] < LOCK_TIMEOUT:
77
+ return True
78
+ # Lock expired, remove it
79
+ del frame_locks[course][frame]
80
+ save_tracking_state()
81
+ return False
82
+
83
+ def lock_frame(course: str, frame: str, requester_id: str) -> bool:
84
+ """Attempt to lock a frame for processing"""
85
+ if check_frame_lock(course, frame):
86
+ return False
87
+
88
+ if course not in frame_locks:
89
+ frame_locks[course] = {}
90
+
91
+ frame_locks[course][frame] = {
92
+ "locked_by": requester_id,
93
+ "locked_at": time.time()
94
+ }
95
+ save_tracking_state()
96
+ return True
97
+
98
+ def mark_frame_processed(course: str, frame: str, requester_id: str):
99
+ """Mark a frame as successfully processed"""
100
+ if course not in processed_frames:
101
+ processed_frames[course] = {}
102
+
103
+ processed_frames[course][frame] = {
104
+ "processed_by": requester_id,
105
+ "processed_at": time.time()
106
+ }
107
+
108
+ # Remove the lock if it exists
109
+ if course in frame_locks and frame in frame_locks[course]:
110
+ del frame_locks[course][frame]
111
+
112
+ save_tracking_state()
113
+
114
+ def log_message(message):
115
+ """Add a log message with timestamp"""
116
+ timestamp = datetime.now().strftime("%H:%M:%S")
117
+ log_entry = f"[{timestamp}] {message}"
118
+ processing_status["logs"].append(log_entry)
119
+
120
+ # Keep only the last 100 logs
121
+ if len(processing_status["logs"]) > 100:
122
+ processing_status["logs"] = processing_status["logs"][-100:]
123
+
124
+ print(log_entry)
125
+
126
+ @app.on_event("startup")
127
+ async def startup_event():
128
+ """Initialize frame tracking and start processing loop"""
129
+ # Load frame tracking state
130
+ load_tracking_state()
131
+ log_message("✓ Loaded frame tracking state")
132
+
133
+ # Start processing thread
134
+ global processing_thread
135
+ if not (processing_thread and processing_thread.is_alive()):
136
+ log_message("🚀 Starting RAR extraction, frame extraction, and vision analysis pipeline in background...")
137
+ processing_thread = threading.Thread(target=main_processing_loop)
138
+ processing_thread.daemon = True
139
+ processing_thread.start()
140
+
141
+ @app.get("/")
142
+ async def root():
143
+ """Root endpoint that returns basic info"""
144
+ return {
145
+ "message": "Video Analysis API",
146
+ "status": "running",
147
+ "endpoints": {
148
+ "/status": "Get processing status",
149
+ "/courses": "List all available course folders",
150
+ "/images/{course_folder}": "List images in a course folder",
151
+ "/images/{course_folder}/{frame_filename}": "Get specific frame image",
152
+ "/start-processing": "Start processing pipeline",
153
+ "/stop-processing": "Stop processing pipeline"
154
+ }
155
+ }
156
+
157
+ @app.get("/status")
158
+ async def get_status():
159
+ """Get current processing status"""
160
+ return {
161
+ "processing_status": processing_status,
162
+ "frames_folder": FRAMES_OUTPUT_FOLDER,
163
+ "frames_folder_exists": os.path.exists(FRAMES_OUTPUT_FOLDER)
164
+ }
165
+
166
+ # ===== NEW IMAGE SERVING ENDPOINTS =====
167
+
168
+ @app.get("/middleware/next/course")
169
+ async def get_next_course(requester_id: str):
170
+ """Get next available course for processing"""
171
+ if not os.path.exists(FRAMES_OUTPUT_FOLDER):
172
+ raise HTTPException(status_code=404, detail="No courses available")
173
+
174
+ # Load latest state
175
+ load_tracking_state()
176
+
177
+ # Find a course with unprocessed frames
178
+ for folder in os.listdir(FRAMES_OUTPUT_FOLDER):
179
+ folder_path = os.path.join(FRAMES_OUTPUT_FOLDER, folder)
180
+ if not os.path.isdir(folder_path):
181
+ continue
182
+
183
+ # Check if course has any unprocessed frames
184
+ image_files = [f for f in os.listdir(folder_path)
185
+ if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
186
+
187
+ for image in image_files:
188
+ if (folder not in processed_frames or
189
+ image not in processed_frames[folder]):
190
+ return {"course": folder}
191
+
192
+ raise HTTPException(status_code=404, detail="No courses with unprocessed frames")
193
+
194
+ @app.get("/middleware/next/image/{course_folder}")
195
+ async def get_next_image(course_folder: str, requester_id: str):
196
+ """Get next available image from a course"""
197
+ folder_path = os.path.join(FRAMES_OUTPUT_FOLDER, course_folder)
198
+
199
+ if not os.path.exists(folder_path):
200
+ raise HTTPException(status_code=404, detail=f"Course not found: {course_folder}")
201
+
202
+ # Load latest state
203
+ load_tracking_state()
204
+
205
+ # Find first unprocessed and unlocked frame
206
+ for file in sorted(os.listdir(folder_path)):
207
+ if not file.lower().endswith(('.png', '.jpg', '.jpeg')):
208
+ continue
209
+
210
+ # Skip if processed
211
+ if (course_folder in processed_frames and
212
+ file in processed_frames[course_folder]):
213
+ continue
214
+
215
+ # Skip if locked by another requester
216
+ if check_frame_lock(course_folder, file):
217
+ continue
218
+
219
+ # Try to lock the frame
220
+ if lock_frame(course_folder, file, requester_id):
221
+ file_path = os.path.join(folder_path, file)
222
+ file_stats = os.stat(file_path)
223
+ return {
224
+ "file_id": f"frame:{course_folder}/{file}",
225
+ "frame": file,
226
+ "video": os.path.splitext(file)[0],
227
+ "size_bytes": file_stats.st_size,
228
+ "modified_time": time.ctime(file_stats.st_mtime),
229
+ "url": f"/images/{course_folder}/{file}"
230
+ }
231
+
232
+ raise HTTPException(status_code=404, detail="No available frames in course")
233
+
234
+ @app.post("/middleware/release/frame/{course_folder}/{video}/{frame}")
235
+ async def release_frame(course_folder: str, video: str, frame: str, requester_id: str):
236
+ """Release a frame lock"""
237
+ if course_folder in frame_locks and frame in frame_locks[course_folder]:
238
+ lock = frame_locks[course_folder][frame]
239
+ if lock["locked_by"] == requester_id:
240
+ del frame_locks[course_folder][frame]
241
+ save_tracking_state()
242
+ return {"status": "released"}
243
+ return {"status": "not_found"}
244
+
245
+ @app.post("/middleware/release/course/{course_folder}")
246
+ async def release_course(course_folder: str, requester_id: str):
247
+ """Release all frame locks for a course"""
248
+ if course_folder in frame_locks:
249
+ # Only release frames locked by this requester
250
+ frames_to_release = [
251
+ frame for frame, lock in frame_locks[course_folder].items()
252
+ if lock["locked_by"] == requester_id
253
+ ]
254
+ for frame in frames_to_release:
255
+ del frame_locks[course_folder][frame]
256
+ save_tracking_state()
257
+ return {"status": "released"}
258
+
259
+ @app.get("/images/{course_folder}/{frame_filename}")
260
+ async def get_frame_image(course_folder: str, frame_filename: str, requester_id: str = None):
261
+ """
262
+ Serve extracted frame images from course folders with locking
263
+
264
+ Args:
265
+ course_folder: The course folder name (e.g., "course1_video1_mp4_frames")
266
+ frame_filename: The frame file name (e.g., "0001.png")
267
+ requester_id: Optional requester ID for frame locking
268
+ """
269
+ # Load latest state
270
+ load_tracking_state()
271
+
272
+ # Construct the full path to the image
273
+ image_path = os.path.join(FRAMES_OUTPUT_FOLDER, course_folder, frame_filename)
274
+
275
+ # Check if file exists
276
+ if not os.path.exists(image_path):
277
+ raise HTTPException(status_code=404, detail=f"Image not found: {course_folder}/{frame_filename}")
278
+
279
+ # Verify it's an image file
280
+ if not frame_filename.lower().endswith(('.png', '.jpg', '.jpeg')):
281
+ raise HTTPException(status_code=400, detail="File must be an image (PNG, JPG, JPEG)")
282
+
283
+ # If requester_id provided, verify frame lock
284
+ if requester_id:
285
+ if check_frame_lock(course_folder, frame_filename):
286
+ lock = frame_locks[course_folder][frame_filename]
287
+ if lock["locked_by"] != requester_id:
288
+ raise HTTPException(status_code=423, detail="Frame is locked by another requester")
289
+
290
+ # Return the image file
291
+ return FileResponse(image_path)
292
+
293
+ @app.get("/images/{course_folder}")
294
+ async def list_course_images(course_folder: str):
295
+ """
296
+ List all available images in a specific course folder
297
+
298
+ Args:
299
+ course_folder: The course folder name
300
+ """
301
+ folder_path = os.path.join(FRAMES_OUTPUT_FOLDER, course_folder)
302
+
303
+ if not os.path.exists(folder_path):
304
+ raise HTTPException(status_code=404, detail=f"Course folder not found: {course_folder}")
305
+
306
+ # Get all image files
307
+ image_files = []
308
+ for file in os.listdir(folder_path):
309
+ if file.lower().endswith(('.png', '.jpg', '.jpeg')):
310
+ file_path = os.path.join(folder_path, file)
311
+ file_stats = os.stat(file_path)
312
+ image_files.append({
313
+ "filename": file,
314
+ "size_bytes": file_stats.st_size,
315
+ "modified_time": time.ctime(file_stats.st_mtime),
316
+ "url": f"/images/{course_folder}/{file}"
317
+ })
318
+
319
+ return {
320
+ "course_folder": course_folder,
321
+ "total_images": len(image_files),
322
+ "images": image_files
323
+ }
324
+
325
+ @app.get("/courses")
326
+ async def list_all_courses():
327
+ """
328
+ List all available course folders with their image counts
329
+ """
330
+ if not os.path.exists(FRAMES_OUTPUT_FOLDER):
331
+ return {"courses": [], "message": "Frames output folder does not exist yet"}
332
+
333
+ courses = []
334
+ for folder in os.listdir(FRAMES_OUTPUT_FOLDER):
335
+ folder_path = os.path.join(FRAMES_OUTPUT_FOLDER, folder)
336
+ if os.path.isdir(folder_path):
337
+ # Count image files
338
+ image_count = len([f for f in os.listdir(folder_path)
339
+ if f.lower().endswith(('.png', '.jpg', '.jpeg'))])
340
+ courses.append({
341
+ "course_folder": folder,
342
+ "image_count": image_count,
343
+ "images_url": f"/images/{folder}",
344
+ "sample_image_url": f"/images/{folder}/0001.png" if image_count > 0 else None
345
+ })
346
+
347
+ return {
348
+ "total_courses": len(courses),
349
+ "courses": courses
350
+ }
351
+
352
+
353
+ # Signal handlers to prevent accidental shutdown
354
+ def handle_shutdown(signum, frame):
355
+ """Prevent shutdown on SIGTERM/SIGINT"""
356
+ print(f"\n⚠️ Received signal {signum}. Server will continue running.")
357
+ print("Use Ctrl+Break or kill -9 to force stop.")
358
+
359
+ # Setup signal handlers for graceful shutdown prevention
360
+ import signal
361
+ signal.signal(signal.SIGINT, handle_shutdown)
362
+ signal.signal(signal.SIGTERM, handle_shutdown)
363
+
364
+ # Server lifecycle events
365
+ @app.on_event("shutdown")
366
+ async def shutdown_event():
367
+ """Save state on shutdown attempt"""
368
+ save_tracking_state()
369
+ print("💾 Saved tracking state")
370
+ print("⚠️ Server shutdown prevented - use Ctrl+Break or kill -9 to force stop")
371
+ # Prevent shutdown by not returning
372
+ while True:
373
+ await asyncio.sleep(1)
374
+
375
+ if __name__ == "__main__":
376
+ # Start the FastAPI server
377
+ print("🚀 Starting Video Analysis FastAPI Server (Persistent Mode)...")
378
+ print("API Documentation will be available at: http://localhost:8000/docs")
379
+ print("API Root endpoint: http://localhost:8000/")
380
+ print("⚠️ Server will continue running even after processing completes")
381
+ print("Use Ctrl+Break or kill -9 to force stop")
382
+
383
+ # Ensure the analysis output folder exists
384
+ os.makedirs(FRAMES_OUTPUT_FOLDER, exist_ok=True)
385
+
386
+ # Start processing in thread instead of blocking
387
+ processing_thread = threading.Thread(target=main_processing_loop)
388
+ processing_thread.daemon = False # Make non-daemon so it doesn't exit
389
+ processing_thread.start()
390
+
391
+ # Configure uvicorn for persistent running
392
+ config = uvicorn.Config(
393
+ app=app,
394
+ host="0.0.0.0",
395
+ port=8000,
396
+ log_level="info",
397
+ reload=False,
398
+ workers=1,
399
+ loop="asyncio",
400
+ timeout_keep_alive=600, # Keep connections alive longer
401
+ access_log=True
402
+ )
403
+
404
+ # Run server with persistent config
405
+ server = uvicorn.Server(config)
406
+ server.run()
407
+
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ accelerate
3
+ fastapi
4
+ uvicorn
5
+ opencv-python-headless
6
+ numpy
7
+ pathlib
8
+ huggingface_hub
9
+ pillow
10
+ rarfile
11
+ python-multipart