change flask to fastapi

This commit is contained in:
awe
2025-11-20 15:35:36 +03:00
parent 0e1b3a2916
commit a85368fdfd
9 changed files with 1452 additions and 121 deletions

View File

@ -1,31 +1,41 @@
"""
Flask Web Application for Beacon Tracker Video Streaming
FastAPI Web Application for Beacon Tracker Video Streaming
This application reads JPEG frames from shared memory and streams them
to web browsers via Server-Sent Events (SSE).
to web browsers via Server-Sent Events (SSE) or WebSocket (H.264 stream).
"""
from flask import Flask, render_template, Response, jsonify
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse, StreamingResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from shared_memory_reader import SharedMemoryFrameReader
import asyncio
import time
import base64
import json
from threading import Lock
app = Flask(__name__)
import os
from typing import Set
from contextlib import asynccontextmanager
# Global state
reader = None
reader_lock = Lock()
reader_lock = asyncio.Lock()
last_frame_data = None
last_frame_header = None
frame_lock = Lock()
frame_lock = asyncio.Lock()
# Video streaming state
active_websocket_clients: Set[WebSocket] = set()
streaming_task = None
streaming_active = False
PIPE_PATH = '/tmp/beacon_video_stream'
def init_reader():
async def init_reader():
"""Initialize the shared memory reader"""
global reader
with reader_lock:
async with reader_lock:
if reader is None:
try:
reader = SharedMemoryFrameReader()
@ -35,27 +45,63 @@ def init_reader():
reader = None
@app.route('/')
def index():
async def cleanup_reader():
"""Clean up shared memory reader"""
global reader
async with reader_lock:
if reader:
reader.close()
reader = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events"""
# Startup
await init_reader()
print("FastAPI application started")
yield
# Shutdown
await cleanup_reader()
print("FastAPI application shutdown")
# Create FastAPI app
app = FastAPI(
title="Beacon Tracker Video Stream",
description="High-performance video streaming with WebSocket and SSE support",
version="2.0",
lifespan=lifespan
)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Templates
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Render the main page"""
return render_template('index.html')
return templates.TemplateResponse("index.html", {"request": request})
@app.route('/stream')
def stream():
@app.get("/stream")
async def stream_sse():
"""
Server-Sent Events stream endpoint
Continuously sends JPEG frames to the client as they become available.
"""
def generate():
async def generate():
global reader
# Initialize reader for this stream
init_reader()
await init_reader()
# Check again after init
with reader_lock:
async with reader_lock:
local_reader = reader
if local_reader is None:
@ -67,11 +113,13 @@ def stream():
while True:
try:
with reader_lock:
async with reader_lock:
if reader is None:
yield f"data: {json.dumps({'error': 'Reader is None'})}\n\n"
return
result = reader.read_frame()
# Run blocking read in thread pool
result = await asyncio.to_thread(reader.read_frame)
if result:
header, jpeg_data = result
@ -94,7 +142,7 @@ def stream():
yield f"data: {json.dumps(event_data)}\n\n"
# Update global state
with frame_lock:
async with frame_lock:
global last_frame_data, last_frame_header
last_frame_data = jpeg_data
last_frame_header = header
@ -106,64 +154,202 @@ def stream():
consecutive_failures = 0
# Small delay to prevent busy waiting
time.sleep(0.001) # 1ms - fast updates
await asyncio.sleep(0.001) # 1ms - fast updates
except GeneratorExit:
# Client disconnected
break
except Exception as e:
print(f"Error in stream: {e}")
yield f"data: {json.dumps({'error': str(e)})}\n\n"
time.sleep(1)
await asyncio.sleep(1)
return Response(generate(), mimetype='text/event-stream')
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@app.route('/status')
def status():
@app.get("/status")
async def status():
"""Get the current status of the stream"""
init_reader()
await init_reader()
with frame_lock:
async with frame_lock:
if last_frame_header:
return jsonify({
return {
'connected': True,
'last_frame': last_frame_header.frame_number,
'timestamp_us': last_frame_header.timestamp_us,
'resolution': f"{last_frame_header.width}x{last_frame_header.height}"
})
}
else:
return jsonify({
return {
'connected': reader is not None,
'last_frame': None,
'message': 'Waiting for frames...'
})
}
@app.route('/latest_frame')
def latest_frame():
@app.get("/latest_frame")
async def latest_frame():
"""Get the latest frame as a JPEG image"""
with frame_lock:
async with frame_lock:
if last_frame_data:
return Response(last_frame_data, mimetype='image/jpeg')
return Response(content=last_frame_data, media_type="image/jpeg")
else:
return "No frame available", 404
return Response(content="No frame available", status_code=404)
@app.teardown_appcontext
def cleanup(exception=None):
"""Clean up resources on shutdown"""
global reader
with reader_lock:
if reader:
reader.close()
reader = None
async def stream_video_from_pipe():
"""
Read MPEG-TS video stream from named pipe and broadcast via WebSocket.
This runs as a background task.
"""
global streaming_active
print(f"Starting video stream reader from: {PIPE_PATH}")
# Wait for pipe to be created by C++ application
max_wait_time = 30 # seconds
start_time = time.time()
while not os.path.exists(PIPE_PATH):
if time.time() - start_time > max_wait_time:
print(f"ERROR: Pipe {PIPE_PATH} not found after {max_wait_time}s")
return
print(f"Waiting for pipe {PIPE_PATH}...")
await asyncio.sleep(1)
print(f"Pipe found: {PIPE_PATH}")
try:
# Open the named pipe in binary read mode
print("Opening pipe for reading...")
# Use asyncio to read from pipe
streaming_active = True
# Open pipe in non-blocking mode
import fcntl
pipe_fd = os.open(PIPE_PATH, os.O_RDONLY | os.O_NONBLOCK)
print("Pipe opened successfully, starting stream...")
chunk_size = 32768 # 32KB chunks for MPEG-TS
while streaming_active and active_websocket_clients:
try:
# Read chunk from pipe
try:
data = os.read(pipe_fd, chunk_size)
except BlockingIOError:
# No data available, wait a bit
await asyncio.sleep(0.001)
continue
if not data:
print("No data from pipe, stream may have ended")
break
# Broadcast binary data to all connected WebSocket clients
disconnected_clients = set()
for client in active_websocket_clients.copy():
try:
await client.send_bytes(data)
except Exception as e:
print(f"Error sending to client: {e}")
disconnected_clients.add(client)
# Remove disconnected clients
active_websocket_clients.difference_update(disconnected_clients)
# Small delay to prevent overwhelming clients
await asyncio.sleep(0.001)
except Exception as e:
print(f"Error reading from pipe: {e}")
break
os.close(pipe_fd)
except FileNotFoundError:
print(f"ERROR: Pipe {PIPE_PATH} not found")
except Exception as e:
print(f"ERROR in video streaming: {e}")
import traceback
traceback.print_exc()
finally:
streaming_active = False
print("Video streaming stopped")
@app.websocket("/ws/video")
async def websocket_video_endpoint(websocket: WebSocket):
"""WebSocket endpoint for video streaming"""
global streaming_task, streaming_active
await websocket.accept()
print(f"Client connected to video stream: {id(websocket)}")
# Add client to active set
active_websocket_clients.add(websocket)
# Start streaming task if not already running
if not streaming_active:
streaming_task = asyncio.create_task(stream_video_from_pipe())
print("Started video streaming task")
try:
# Keep connection alive and handle client messages
while True:
# Wait for client messages (like ping/pong or control messages)
try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
# Handle client messages if needed
if data == "ping":
await websocket.send_text("pong")
except asyncio.TimeoutError:
# No message received, continue
continue
except WebSocketDisconnect:
print(f"Client disconnected from video stream: {id(websocket)}")
except Exception as e:
print(f"WebSocket error: {e}")
finally:
# Remove client from active set
active_websocket_clients.discard(websocket)
# Stop streaming if no more clients
if not active_websocket_clients:
streaming_active = False
print("No more clients, stopping stream")
@app.get("/health")
async def health():
"""Health check endpoint"""
return {
"status": "healthy",
"active_websocket_clients": len(active_websocket_clients),
"streaming_active": streaming_active,
"pipe_exists": os.path.exists(PIPE_PATH)
}
if __name__ == '__main__':
# Initialize reader on startup
init_reader()
import uvicorn
# Run the Flask app
# Use 0.0.0.0 to make it accessible from other machines on the network
app.run(host='0.0.0.0', port=5000, debug=True, threaded=True)
print("Starting FastAPI server on http://0.0.0.0:5000")
print(f"Video stream will be available at ws://0.0.0.0:5000/ws/video")
uvicorn.run(
app,
host='0.0.0.0',
port=5000,
log_level='info',
access_log=True
)

View File

@ -1,2 +1,5 @@
Flask>=3.0.0
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
posix_ipc>=1.1.0
websockets>=12.0

View File

@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beacon Tracker - Live Stream</title>
<title>Beacon Tracker - Live Stream (FastAPI)</title>
<!-- JSMpeg for H.264/MPEG-TS decoding -->
<script src="https://cdn.jsdelivr.net/npm/jsmpeg@0.1.0/jsmpeg.min.js"></script>
<style>
* {
margin: 0;
@ -90,24 +92,29 @@
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
#videoFrame {
#videoFrame, #videoCanvas {
width: 100%;
height: auto;
display: block;
}
.hidden {
display: none !important;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
color: white;
z-index: 10;
}
.loading-overlay.hidden {
@ -115,17 +122,18 @@
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
border-top: 4px solid white;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
@keyframes spin {
to { transform: rotate(360deg); }
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.info-grid {
@ -137,15 +145,16 @@
.info-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px;
padding: 20px;
border-radius: 10px;
color: white;
text-align: center;
}
.info-label {
font-size: 0.85rem;
opacity: 0.9;
margin-bottom: 5px;
margin-bottom: 8px;
}
.info-value {
@ -153,12 +162,61 @@
font-weight: 700;
}
.controls {
margin-top: 20px;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.controls button, .stream-selector button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.controls button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stream-selector {
margin-top: 20px;
padding: 15px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
gap: 10px;
align-items: center;
}
.stream-selector button {
background: white;
color: #333;
border: 2px solid #ddd;
}
.stream-selector button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: #667eea;
}
.controls button:hover, .stream-selector button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.error-message {
margin-top: 15px;
padding: 15px;
background: #e74c3c;
color: white;
padding: 15px;
border-radius: 10px;
margin-top: 15px;
border-radius: 8px;
display: none;
}
@ -166,43 +224,40 @@
display: block;
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
.perf-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
margin-left: 10px;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
.perf-badge.sse {
background: #f39c12;
color: white;
border: none;
padding: 12px 24px;
}
.perf-badge.websocket {
background: #27ae60;
color: white;
}
.tech-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
font-size: 0.75rem;
background: #3498db;
color: white;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎯 Beacon Tracker - Live Stream</h1>
<h1>🎯 Beacon Tracker - Live Stream <span class="tech-badge">FastAPI</span></h1>
<div class="status-bar">
<div class="status-item">
<div class="status-indicator" id="connectionStatus"></div>
@ -214,12 +269,28 @@
<div class="status-item">
<span class="status-text">Кадр: <strong id="frameCounter">0</strong></span>
</div>
<div class="status-item">
<span class="status-text">Метод: <strong id="streamMethod">-</strong></span>
</div>
</div>
</header>
<div class="main-content">
<div class="stream-selector">
<span style="font-weight: 600;">Выбрать метод передачи:</span>
<button id="btnWebSocket" onclick="switchToWebSocket()" class="active">
WebSocket (H.264) <span class="perf-badge websocket">Быстрее</span>
</button>
<button id="btnSSE" onclick="switchToSSE()">
SSE (JPEG) <span class="perf-badge sse">Совместимость</span>
</button>
</div>
<div class="video-container">
<img id="videoFrame" alt="Video stream">
<!-- Canvas for WebSocket/JSMpeg streaming -->
<canvas id="videoCanvas"></canvas>
<!-- Image for SSE/JPEG streaming -->
<img id="videoFrame" class="hidden" alt="Video stream">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Ожидание данных...</p>
@ -232,7 +303,7 @@
<div class="info-value" id="resolution">-</div>
</div>
<div class="info-card">
<div class="info-label">Размер кадра</div>
<div class="info-label">Размер/Битрейт</div>
<div class="info-value" id="frameSize">-</div>
</div>
<div class="info-card">
@ -240,8 +311,8 @@
<div class="info-value" id="latency">-</div>
</div>
<div class="info-card">
<div class="info-label">Битрейт</div>
<div class="info-value" id="bitrate">-</div>
<div class="info-label">Потребление памяти</div>
<div class="info-value" id="memoryUsage">-</div>
</div>
</div>
@ -255,14 +326,193 @@
</div>
<script>
let eventSource = null;
let frameCount = 0;
let lastFrameTime = Date.now();
let fps = 0;
let totalBytes = 0;
let lastBitrateCalc = Date.now();
// Stream mode: 'websocket' or 'sse'
let streamMode = 'websocket';
// SSE variables
let eventSource = null;
let sseFrameCount = 0;
let sseLastFrameTime = Date.now();
let sseTotalBytes = 0;
let sseLastBitrateCalc = Date.now();
// WebSocket variables
let websocket = null;
let player = null;
let wsFrameCount = 0;
let wsLastFrameTime = Date.now();
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
// Common variables
let fps = 0;
let statsInterval = null;
function updateMemoryUsage() {
if (performance.memory) {
const memMB = (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(1);
document.getElementById('memoryUsage').textContent = `${memMB} MB`;
} else {
document.getElementById('memoryUsage').textContent = 'N/A';
}
}
function switchToWebSocket() {
if (streamMode === 'websocket') return;
streamMode = 'websocket';
document.getElementById('btnWebSocket').classList.add('active');
document.getElementById('btnSSE').classList.remove('active');
disconnectSSE();
connectWebSocket();
}
function switchToSSE() {
if (streamMode === 'sse') return;
streamMode = 'sse';
document.getElementById('btnSSE').classList.add('active');
document.getElementById('btnWebSocket').classList.remove('active');
disconnectWebSocket();
connectSSE();
}
function connectWebSocket() {
document.getElementById('streamMethod').textContent = 'WebSocket (H.264)';
document.getElementById('videoCanvas').classList.remove('hidden');
document.getElementById('videoFrame').classList.add('hidden');
console.log('Connecting to WebSocket video stream...');
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/video`;
// Connect to native WebSocket
websocket = new WebSocket(wsUrl);
websocket.binaryType = 'arraybuffer';
websocket.onopen = () => {
console.log('WebSocket connected');
updateConnectionStatus(true);
hideError();
reconnectAttempts = 0;
// Initialize JSMpeg player
const canvas = document.getElementById('videoCanvas');
player = new JSMpeg.Player(null, {
canvas: canvas,
disableGl: false,
disableWebAssembly: false,
preserveDrawingBuffer: false,
progressive: true,
throttled: true,
chunkSize: 1024 * 32,
onVideoDecode: (decoder, time) => {
// Update FPS counter
const now = Date.now();
const timeDiff = (now - wsLastFrameTime) / 1000;
if (timeDiff > 0.1) { // Update every 100ms
fps = Math.round(1 / timeDiff);
document.getElementById('fpsCounter').textContent = fps;
wsFrameCount++;
document.getElementById('frameCounter').textContent = wsFrameCount;
}
wsLastFrameTime = now;
// Update resolution
if (decoder.width && decoder.height) {
document.getElementById('resolution').textContent =
`${decoder.width}×${decoder.height}`;
}
// Hide loading overlay on first frame
document.getElementById('loadingOverlay').classList.add('hidden');
}
});
};
websocket.onmessage = (event) => {
if (player && event.data instanceof ArrayBuffer) {
const uint8Array = new Uint8Array(event.data);
if (!player.source) {
// Create source buffer if needed
player.source = {
write: function(data) {
if (player.demuxer && player.demuxer.write) {
player.demuxer.write(data);
}
}
};
}
// Feed data to JSMpeg
if (player.demuxer && player.demuxer.write) {
player.demuxer.write(uint8Array);
}
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
showError('Ошибка WebSocket соединения');
};
websocket.onclose = () => {
console.log('WebSocket disconnected');
updateConnectionStatus(false);
// Auto-reconnect
if (streamMode === 'websocket' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
console.log(`Attempting to reconnect (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
setTimeout(connectWebSocket, 2000);
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showError('Потеряно соединение. Нажмите "Переподключиться"');
}
};
// Start memory monitoring
if (!statsInterval) {
statsInterval = setInterval(() => {
updateMemoryUsage();
}, 2000);
}
// Send periodic ping to keep connection alive
const pingInterval = setInterval(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send('ping');
} else {
clearInterval(pingInterval);
}
}, 30000); // Every 30 seconds
}
function disconnectWebSocket() {
if (websocket) {
websocket.close();
websocket = null;
}
if (player) {
player.destroy();
player = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
function connectSSE() {
document.getElementById('streamMethod').textContent = 'SSE (JPEG)';
document.getElementById('videoCanvas').classList.add('hidden');
document.getElementById('videoFrame').classList.remove('hidden');
function connect() {
if (eventSource) {
eventSource.close();
}
@ -291,17 +541,17 @@
document.getElementById('loadingOverlay').classList.add('hidden');
// Update counters
frameCount++;
sseFrameCount++;
document.getElementById('frameCounter').textContent = data.frame_number;
// Calculate FPS
const now = Date.now();
const timeDiff = (now - lastFrameTime) / 1000;
const timeDiff = (now - sseLastFrameTime) / 1000;
if (timeDiff > 0) {
fps = Math.round(1 / timeDiff);
document.getElementById('fpsCounter').textContent = fps;
}
lastFrameTime = now;
sseLastFrameTime = now;
// Update resolution
document.getElementById('resolution').textContent = `${data.width}×${data.height}`;
@ -316,15 +566,17 @@
document.getElementById('latency').textContent = `${latencyMs} ms`;
// Calculate bitrate
totalBytes += data.data_size;
const bitrateTime = (now - lastBitrateCalc) / 1000;
sseTotalBytes += data.data_size;
const bitrateTime = (now - sseLastBitrateCalc) / 1000;
if (bitrateTime >= 1.0) {
const bitrate = (totalBytes * 8 / bitrateTime / 1000000).toFixed(2);
document.getElementById('bitrate').textContent = `${bitrate} Mbps`;
totalBytes = 0;
lastBitrateCalc = now;
const bitrate = (sseTotalBytes * 8 / bitrateTime / 1000000).toFixed(2);
document.getElementById('frameSize').textContent = `${bitrate} Mbps`;
sseTotalBytes = 0;
sseLastBitrateCalc = now;
}
updateMemoryUsage();
} catch (error) {
console.error('Error processing frame:', error);
showError('Ошибка обработки кадра: ' + error.message);
@ -336,6 +588,24 @@
updateConnectionStatus(false);
showError('Потеряно соединение с сервером');
};
// Start memory monitoring
if (!statsInterval) {
statsInterval = setInterval(() => {
updateMemoryUsage();
}, 2000);
}
}
function disconnectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
}
function updateConnectionStatus(connected) {
@ -364,19 +634,45 @@
function reconnect() {
document.getElementById('loadingOverlay').classList.remove('hidden');
connect();
reconnectAttempts = 0;
if (streamMode === 'websocket') {
disconnectWebSocket();
connectWebSocket();
} else {
disconnectSSE();
connectSSE();
}
}
function takeScreenshot() {
const img = document.getElementById('videoFrame');
let dataUrl;
if (streamMode === 'websocket') {
const canvas = document.getElementById('videoCanvas');
dataUrl = canvas.toDataURL('image/jpeg', 0.95);
} else {
const img = document.getElementById('videoFrame');
dataUrl = img.src;
}
const link = document.createElement('a');
link.download = `beacon_tracker_${Date.now()}.jpg`;
link.href = img.src;
link.href = dataUrl;
link.click();
}
// Connect on page load
connect();
// Connect on page load with default WebSocket mode
if (streamMode === 'websocket') {
connectWebSocket();
} else {
connectSSE();
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
disconnectWebSocket();
disconnectSSE();
});
</script>
</body>
</html>