GeraldoRiberia commited on
Commit
679b5d0
·
1 Parent(s): 9f43980
Files changed (2) hide show
  1. .DS_Store +0 -0
  2. server.py +212 -0
.DS_Store ADDED
Binary file (6.15 kB). View file
 
server.py CHANGED
@@ -1,5 +1,6 @@
1
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
 
3
  import uvicorn
4
  import cv2
5
  import numpy as np
@@ -8,6 +9,9 @@ import json
8
  import logging
9
  import asyncio
10
  from concurrent.futures import ThreadPoolExecutor
 
 
 
11
 
12
  from services.single_tracker import SingleTracker
13
  from services.multi_tracker import MultiTracker
@@ -19,6 +23,24 @@ logger = logging.getLogger(__name__)
19
  # Executor for CPU-bound tasks
20
  executor = ThreadPoolExecutor(max_workers=1)
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  app = FastAPI(title="AFS Tracking Backend")
23
 
24
  app.add_middleware(
@@ -43,8 +65,120 @@ def decode_binary_image(img_data: bytes):
43
  logger.error(f"Failed to decode image: {e}")
44
  return None
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  @app.websocket("/ws")
47
  async def websocket_endpoint(websocket: WebSocket):
 
 
48
  await websocket.accept()
49
  logger.info("New WebSocket connection established.")
50
 
@@ -62,6 +196,40 @@ async def websocket_endpoint(websocket: WebSocket):
62
  logger.info(f"Switching mode from {current_mode} to {payload['mode']}")
63
  current_mode = payload["mode"]
64
  await websocket.send_json({"type": "mode_ack", "mode": current_mode})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  except json.JSONDecodeError:
66
  logger.error("Invalid JSON received.")
67
  continue
@@ -96,11 +264,55 @@ async def websocket_endpoint(websocket: WebSocket):
96
  # Send results back to client
97
  response_data["mode"] = current_mode
98
  await websocket.send_json(response_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  except WebSocketDisconnect:
101
  logger.info("WebSocket client disconnected.")
102
  except Exception as e:
103
  logger.error(f"WebSocket error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  if __name__ == "__main__":
106
  uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=True)
 
1
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.responses import StreamingResponse
4
  import uvicorn
5
  import cv2
6
  import numpy as np
 
9
  import logging
10
  import asyncio
11
  from concurrent.futures import ThreadPoolExecutor
12
+ from datetime import datetime
13
+ import threading
14
+ import pyvirtualcam
15
 
16
  from services.single_tracker import SingleTracker
17
  from services.multi_tracker import MultiTracker
 
23
  # Executor for CPU-bound tasks
24
  executor = ThreadPoolExecutor(max_workers=1)
25
 
26
+ # --- OBS and Recording State ---
27
+ latest_obs_frame = None # Store the latest JPEG encoded cropped frame for the OBS feed (deprecated by vcam)
28
+ obs_frame_lock = threading.Lock()
29
+ is_obs_active = False
30
+ vcam = None # Virtual Camera reference
31
+ is_recording = False
32
+ video_writer = None
33
+ recording_filename = ""
34
+
35
+ # --- Center Stage State (EMA Smoothing) ---
36
+ current_cx = 0.5
37
+ current_cy = 0.5
38
+ current_scale = 1.0
39
+
40
+ # Configurable parameters for smooth panning
41
+ SMOOTHING_FACTOR = 0.1 # Lower is smoother but slower (similar to Dart's TweenAnimation)
42
+ TARGET_ASPECT_RATIO = 16.0 / 9.0 # Assuming output is meant to be 16:9
43
+
44
  app = FastAPI(title="AFS Tracking Backend")
45
 
46
  app.add_middleware(
 
65
  logger.error(f"Failed to decode image: {e}")
66
  return None
67
 
68
+ def apply_center_stage_crop(frame, tracking_data):
69
+ """
70
+ Applies an exponential moving average (EMA) to smoothly pan and zoom
71
+ the frame based on the tracking target bounding box.
72
+ Returns the cropped frame.
73
+ """
74
+ global current_cx, current_cy, current_scale
75
+
76
+ h, w = frame.shape[:2]
77
+
78
+ # Defaults
79
+ target_cx = 0.5
80
+ target_cy = 0.5
81
+ target_scale = 1.0
82
+
83
+ # Calculate target state based on tracking data
84
+ boxes = tracking_data.get("boxes", [])
85
+ if tracking_data.get("mode") == "multi":
86
+ if "aggregate_box" in tracking_data:
87
+ ab = tracking_data["aggregate_box"]
88
+ box_cx = (ab["x1"] + ab["x2"]) / 2.0
89
+ box_cy = (ab["y1"] + ab["y2"]) / 2.0
90
+ box_w = ab["x2"] - ab["x1"]
91
+ box_h = ab["y2"] - ab["y1"]
92
+
93
+ target_cx = box_cx / w
94
+ target_cy = box_cy / h
95
+
96
+ # Target scale logic (from Dart): max dimension proportion * 1.5 margin
97
+ max_dim = max(box_w / w, box_h / h)
98
+ target_scale = 1.0 / (max_dim * 1.5)
99
+ # Clamp scale
100
+ target_scale = max(1.0, min(target_scale, 3.0))
101
+ else: # single
102
+ target_box = None
103
+ for b in boxes:
104
+ if b.get("is_target"):
105
+ target_box = b
106
+ break
107
+
108
+ if target_box:
109
+ box_cx = (target_box["x1"] + target_box["x2"]) / 2.0
110
+ box_cy = (target_box["y1"] + target_box["y2"]) / 2.0
111
+ box_w = target_box["x2"] - target_box["x1"]
112
+ box_h = target_box["y2"] - target_box["y1"]
113
+
114
+ target_cx = box_cx / w
115
+ target_cy = box_cy / h
116
+
117
+ max_dim = max(box_w / w, box_h / h)
118
+ target_scale = 1.0 / (max_dim * 2.0) # slightly tighter for single person
119
+ target_scale = max(1.0, min(target_scale, 3.0))
120
+
121
+ # Apply EMA smoothing
122
+ current_cx += (target_cx - current_cx) * SMOOTHING_FACTOR
123
+ current_cy += (target_cy - current_cy) * SMOOTHING_FACTOR
124
+ current_scale += (target_scale - current_scale) * SMOOTHING_FACTOR
125
+
126
+ # Calculate crop dimensions
127
+ # When scale is S, the crop width is w / S
128
+ crop_w = int(w / current_scale)
129
+ crop_h = int(h / current_scale)
130
+
131
+ # Enforce aspect ratio
132
+ # If crop_w / crop_h is not 16:9, adjust one to match
133
+ current_ar = crop_w / max(1, crop_h)
134
+ if current_ar > TARGET_ASPECT_RATIO:
135
+ # Too wide, shrink width
136
+ crop_w = int(crop_h * TARGET_ASPECT_RATIO)
137
+ else:
138
+ # Too tall, shrink height
139
+ crop_h = int(crop_w / TARGET_ASPECT_RATIO)
140
+
141
+ # Calculate top-left point of crop, clamping to frame boundaries
142
+ center_px_x = int(current_cx * w)
143
+ center_px_y = int(current_cy * h)
144
+
145
+ start_x = max(0, center_px_x - crop_w // 2)
146
+ start_y = max(0, center_px_y - crop_h // 2)
147
+
148
+ # Adjust if crop box goes out of bounds
149
+ if start_x + crop_w > w:
150
+ start_x = w - crop_w
151
+ if start_y + crop_h > h:
152
+ start_y = h - crop_h
153
+
154
+ # Crop
155
+ cropped = frame[start_y:start_y+crop_h, start_x:start_x+crop_w]
156
+ return cropped
157
+
158
+ async def generate_obs_stream():
159
+ """Generator for the MJPEG stream used by OBS."""
160
+ global latest_obs_frame
161
+ while True:
162
+ with obs_frame_lock:
163
+ if latest_obs_frame is not None:
164
+ yield (b'--frame\r\n'
165
+ b'Content-Type: image/jpeg\r\n\r\n' + latest_obs_frame + b'\r\n')
166
+ else:
167
+ # If no frame yet, yield a blank frame or sleep
168
+ await asyncio.sleep(0.1)
169
+ continue
170
+ # Use asyncio sleep to prevent blocking the event loop
171
+ await asyncio.sleep(0.033) # roughly 30 fps
172
+
173
+ @app.get("/obs_feed")
174
+ async def obs_feed():
175
+ """Endpoint for OBS Media Source to connect to."""
176
+ return StreamingResponse(generate_obs_stream(), media_type="multipart/x-mixed-replace; boundary=frame")
177
+
178
  @app.websocket("/ws")
179
  async def websocket_endpoint(websocket: WebSocket):
180
+ global is_recording, video_writer, recording_filename, latest_obs_frame, is_obs_active, vcam
181
+
182
  await websocket.accept()
183
  logger.info("New WebSocket connection established.")
184
 
 
196
  logger.info(f"Switching mode from {current_mode} to {payload['mode']}")
197
  current_mode = payload["mode"]
198
  await websocket.send_json({"type": "mode_ack", "mode": current_mode})
199
+ elif "command" in payload:
200
+ # Handle recording commands
201
+ command = payload["command"]
202
+ if command == "start_recording":
203
+ if not is_recording:
204
+ is_recording = True
205
+ recording_filename = f"capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}.mp4"
206
+ logger.info(f"Started recording to {recording_filename}")
207
+ await websocket.send_json({"type": "recording_ack", "status": "started"})
208
+ elif command == "stop_recording":
209
+ if is_recording:
210
+ is_recording = False
211
+ if video_writer is not None:
212
+ video_writer.release()
213
+ video_writer = None
214
+ logger.info(f"Stopped recording. File saved as {recording_filename}")
215
+ elif command == "start_obs":
216
+ if not is_obs_active:
217
+ is_obs_active = True
218
+ logger.info("Started OBS Virtual Camera stream")
219
+ try:
220
+ if vcam is None:
221
+ vcam = pyvirtualcam.Camera(width=1280, height=720, fps=30)
222
+ except Exception as e:
223
+ logger.error(f"Failed to start vcam: {e}")
224
+ await websocket.send_json({"type": "obs_ack", "status": "started"})
225
+ elif command == "stop_obs":
226
+ if is_obs_active:
227
+ is_obs_active = False
228
+ logger.info("Stopped OBS Virtual Camera stream")
229
+ if vcam is not None:
230
+ vcam.close()
231
+ vcam = None
232
+ await websocket.send_json({"type": "obs_ack", "status": "stopped"})
233
  except json.JSONDecodeError:
234
  logger.error("Invalid JSON received.")
235
  continue
 
264
  # Send results back to client
265
  response_data["mode"] = current_mode
266
  await websocket.send_json(response_data)
267
+
268
+ # Apply Crop and Handle OBS / Recording
269
+ try:
270
+ cropped_frame = apply_center_stage_crop(frame, response_data)
271
+
272
+ # 1. Update OBS Virtual Camera
273
+ if is_obs_active and vcam is not None:
274
+ try:
275
+ # Virtual cameras generally strict size requirements
276
+ cam_frame = cv2.resize(cropped_frame, (vcam.width, vcam.height))
277
+ cam_frame = cv2.cvtColor(cam_frame, cv2.COLOR_BGR2RGB)
278
+ vcam.send(cam_frame)
279
+ except Exception as e:
280
+ logger.error(f"Failed to push vcam frame: {e}")
281
+
282
+ # 2. Update Recording Output
283
+ if is_recording:
284
+ h, w = cropped_frame.shape[:2]
285
+ if video_writer is None:
286
+ # Initialize writer with the exact dimensions of the FIRST cropped frame
287
+ fourcc = cv2.VideoWriter_fourcc(*'avc1')
288
+ video_writer = cv2.VideoWriter(recording_filename, fourcc, 5.0, (w, h))
289
+
290
+ # Ensure we try to resize cleanly if aspect ratio forces slight off-by-one errors over time
291
+ if video_writer is not None:
292
+ target_w = int(video_writer.get(cv2.CAP_PROP_FRAME_WIDTH))
293
+ target_h = int(video_writer.get(cv2.CAP_PROP_FRAME_HEIGHT))
294
+ if (w, h) != (target_w, target_h):
295
+ cropped_frame = cv2.resize(cropped_frame, (target_w, target_h))
296
+ video_writer.write(cropped_frame)
297
+ except Exception as e:
298
+ logger.error(f"Error handling post-process crops: {e}")
299
 
300
  except WebSocketDisconnect:
301
  logger.info("WebSocket client disconnected.")
302
  except Exception as e:
303
  logger.error(f"WebSocket error: {e}")
304
+ finally:
305
+ # Cleanup Virtual Camera
306
+ if vcam is not None:
307
+ vcam.close()
308
+ vcam = None
309
+ is_obs_active = False
310
+
311
+ # Cleanup Recording
312
+ if video_writer is not None:
313
+ video_writer.release()
314
+ video_writer = None
315
+ is_recording = False
316
 
317
  if __name__ == "__main__":
318
  uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=True)