Compare commits

..

6 Commits
main ... Server

Author SHA1 Message Date
ahmedmujtaba-gif a7b7a571c3 Server 0.6 2026-04-17 13:26:20 +05:00
ahmedmujtaba-gif dcac92d7e5 Server 0.5 2026-04-16 19:29:08 +05:00
ahmedmujtaba-gif 4468400a0e Server 0.4 2026-04-16 19:22:27 +05:00
ahmedmujtaba-gif a8276a1b25 Server Push 0.3 2026-04-16 18:47:34 +05:00
ahmedmujtaba-gif 7eb227d7b2 Server Push 0.2 2026-04-16 17:52:18 +05:00
ahmedmujtaba-gif bf3f36d742 commit for checking the desired change 2026-04-16 17:05:14 +05:00
2 changed files with 216 additions and 563 deletions

View File

@ -13,18 +13,19 @@ load_dotenv(override=True)
app = Flask(__name__) app = Flask(__name__)
# Load-time debug for camera config (safe to leave; only prints on startup)
print(
"[Surveillance] camera_ip_1=" + str(os.getenv("camera_ip_1")) +
" camera_ip_2=" + str(os.getenv("camera_ip_2"))
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
USERNAME = os.getenv("username") USERNAME = os.getenv("username")
PASSWORD = os.getenv("password") PASSWORD = os.getenv("password")
CAMERA_IPS = [os.getenv(f"camera_ip_{i}") for i in range(1, 3)]
# Dynamically find all camera_ip_N variables in environment
CAMERA_IPS = []
for i in range(1, 101): # Supports up to 100 cameras
ip = os.getenv(f"camera_ip_{i}")
if ip and ip.strip():
CAMERA_IPS.append(ip.strip())
CAMERAS = [ CAMERAS = [
{ {
"id": f"cam{i + 1}", "id": f"cam{i + 1}",
@ -34,7 +35,6 @@ CAMERAS = [
} }
for i, ip in enumerate(CAMERA_IPS) for i, ip in enumerate(CAMERA_IPS)
] ]
DEFAULT_CAMERA_ID = CAMERAS[0]["id"] if CAMERAS else "cam1"
PROXIMITY_PX = 200 # max pixel distance to consider two people "together" PROXIMITY_PX = 200 # max pixel distance to consider two people "together"
GROUP_TIME_THRESHOLD = 20 # seconds before an alert fires GROUP_TIME_THRESHOLD = 20 # seconds before an alert fires
@ -44,470 +44,264 @@ YOLO_CONF = 0.25
INFERENCE_DEVICE = 0 INFERENCE_DEVICE = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared state (protected by lock) # Shared state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
lock = threading.Lock() lock = threading.Lock()
state = { state = {
"cameras": {}, # camera_id -> dict with frame, metadata, status
"grid_frame": None,
"alerts": [],
"total_people_count": 0,
"alert_active": False
}
# Initialize camera states
for cam in CAMERAS:
state["cameras"][cam["id"]] = {
"frame": None, "frame": None,
"people_count": 0, "people_count": 0,
"groups": [], "groups": [],
"alert_active": False, "alert_active": False,
"alerts": [],
"fps": 0, "fps": 0,
"stream_status": "connecting", "status": "connecting",
"selected_camera_id": DEFAULT_CAMERA_ID, "name": cam["name"]
"active_camera_id": DEFAULT_CAMERA_ID, }
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# YOLO model (downloaded on first run) # YOLO model
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if not torch.cuda.is_available(): if not torch.cuda.is_available():
raise RuntimeError( print("[WARNING] CUDA not detected. Using CPU (this will be slow!).")
"CUDA GPU is required but not available. Install a CUDA-enabled PyTorch build " device = "cpu"
"and verify NVIDIA drivers." else:
) device = f"cuda:{INFERENCE_DEVICE}"
model = YOLO("yolo26m.pt")
model.to(f"cuda:{INFERENCE_DEVICE}")
# Group tracking
_group_trackers: dict = {}
_next_group_id = 0
_last_alert_time = 0.0
model = YOLO("person_detector_best.pt")
model.to(device)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Detection helpers # Detection Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _centroids(boxes): def _centroids(boxes):
"""Return list of (cx, cy) from xyxy boxes."""
return [((b[0] + b[2]) / 2, (b[1] + b[3]) / 2) for b in boxes] return [((b[0] + b[2]) / 2, (b[1] + b[3]) / 2) for b in boxes]
def _find_groups(centroids, threshold): def _find_groups(centroids, threshold):
"""BFS clustering — returns list of index-lists with >= MIN_GROUP_SIZE."""
n = len(centroids) n = len(centroids)
if n < MIN_GROUP_SIZE: if n < MIN_GROUP_SIZE: return []
return []
visited = set() visited = set()
groups = [] groups = []
for i in range(n): for i in range(n):
if i in visited: if i in visited: continue
continue cluster, queue = [i], [i]
cluster = [i]
visited.add(i) visited.add(i)
queue = [i]
while queue: while queue:
cur = queue.pop(0) cur = queue.pop(0)
for j in range(n): for j in range(n):
if j in visited: if j in visited: continue
continue dx, dy = centroids[cur][0] - centroids[j][0], centroids[cur][1] - centroids[j][1]
dx = centroids[cur][0] - centroids[j][0] if (dx*dx + dy*dy)**0.5 < threshold:
dy = centroids[cur][1] - centroids[j][1] cluster.append(j); visited.add(j); queue.append(j)
if (dx * dx + dy * dy) ** 0.5 < threshold: if len(cluster) >= MIN_GROUP_SIZE: groups.append(cluster)
cluster.append(j)
visited.add(j)
queue.append(j)
if len(cluster) >= MIN_GROUP_SIZE:
groups.append(cluster)
return groups return groups
def _group_centroid(centroids, indices): def _group_centroid(centroids, indices):
xs = [centroids[i][0] for i in indices] xs = [centroids[i][0] for i in indices]
ys = [centroids[i][1] for i in indices] ys = [centroids[i][1] for i in indices]
return (sum(xs) / len(xs), sum(ys) / len(ys)) return (sum(xs) / len(xs), sum(ys) / len(ys))
def _get_camera_by_id(camera_id):
for cam in CAMERAS:
if cam["id"] == camera_id:
return cam
return CAMERAS[0] if CAMERAS else None
def _reset_tracking():
global _group_trackers, _next_group_id, _last_alert_time
_group_trackers = {}
_next_group_id = 0
_last_alert_time = 0.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main processing loop (runs in background thread) # Stream Processing (One per camera)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _process_stream(camera):
def _process_stream(): cam_id = camera["id"]
global _next_group_id, _last_alert_time rtsp_url = camera["rtsp_url"]
group_trackers = {}
next_group_id = 0
last_alert_time = 0.0
cap = None cap = None
active_camera_id = None
prev_time = time.time() prev_time = time.time()
while True: print(f"[Surveillance] Starting thread for {camera['name']}")
with lock:
selected_camera_id = state["selected_camera_id"]
selected_camera = _get_camera_by_id(selected_camera_id)
if not selected_camera:
with lock:
state["stream_status"] = "error"
time.sleep(2)
continue
if cap is None or active_camera_id != selected_camera["id"]: while True:
if cap is not None: if cap is None:
cap.release() cap = cv2.VideoCapture(rtsp_url)
cap = cv2.VideoCapture(selected_camera["rtsp_url"])
active_camera_id = selected_camera["id"]
_reset_tracking()
with lock:
state["active_camera_id"] = active_camera_id
if not cap.isOpened(): if not cap.isOpened():
with lock: print(f"[ERROR] {camera['name']} (IP: {camera['ip']}) failed to connect. Check if 401 Unauthorized or 403 Forbidden.")
state["stream_status"] = "error" with lock: state["cameras"][cam_id]["status"] = "error"
print(f"[ERROR] Cannot open RTSP stream: {selected_camera['rtsp_url']}") time.sleep(10); cap = None; continue
time.sleep(2)
cap = None print(f"[SUCCESS] {camera['name']} (IP: {camera['ip']}) connected and streaming over TCP.")
continue with lock: state["cameras"][cam_id]["status"] = "live"
with lock:
state["stream_status"] = "live"
ret, frame = cap.read() ret, frame = cap.read()
if not ret: if not ret:
with lock: with lock: state["cameras"][cam_id]["status"] = "reconnecting"
state["stream_status"] = "reconnecting" cap.release(); time.sleep(2); cap = cv2.VideoCapture(rtsp_url); continue
cap.release()
time.sleep(2)
cap = cv2.VideoCapture(selected_camera["rtsp_url"])
if not cap.isOpened():
with lock:
state["stream_status"] = "error"
cap = None
else:
with lock:
state["stream_status"] = "live"
continue
now = time.time() now = time.time()
fps = 1.0 / max(now - prev_time, 1e-6) fps = 1.0 / max(now - prev_time, 1e-6)
prev_time = now prev_time = now
# --- YOLO inference (person = class 0) --- results = model(frame, classes=[0], verbose=False, conf=YOLO_CONF, device=device)
results = model(
frame,
classes=[0],
verbose=False,
conf=YOLO_CONF,
device=INFERENCE_DEVICE,
)
person_boxes = [] person_boxes = []
for r in results: for r in results:
for box in r.boxes: for box in r.boxes:
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
conf = float(box.conf[0]) person_boxes.append((float(x1), float(y1), float(x2), float(y2), float(box.conf[0])))
person_boxes.append((float(x1), float(y1), float(x2), float(y2), conf))
centroids = _centroids([(b[0], b[1], b[2], b[3]) for b in person_boxes]) centroids = _centroids([(b[0], b[1], b[2], b[3]) for b in person_boxes])
current_groups = _find_groups(centroids, PROXIMITY_PX) current_groups = _find_groups(centroids, PROXIMITY_PX)
# --- Match current groups to tracked groups --- # Match Groups
matched_ids: set = set() matched_ids = set()
frame_group_data = [] frame_group_data = []
for grp_indices in current_groups: for grp_indices in current_groups:
gc = _group_centroid(centroids, grp_indices) gc = _group_centroid(centroids, grp_indices)
best_id, best_dist = None, float("inf") best_id, best_dist = None, float("inf")
for gid, gdata in _group_trackers.items(): for gid, gdata in group_trackers.items():
if gid in matched_ids: if gid in matched_ids: continue
continue dist = ((gc[0]-gdata["centroid"][0])**2 + (gc[1]-gdata["centroid"][1])**2)**0.5
dx = gc[0] - gdata["centroid"][0]
dy = gc[1] - gdata["centroid"][1]
dist = (dx * dx + dy * dy) ** 0.5
if dist < PROXIMITY_PX * 2 and dist < best_dist: if dist < PROXIMITY_PX * 2 and dist < best_dist:
best_dist = dist best_dist, best_id = dist, gid
best_id = gid
if best_id is not None: if best_id is not None:
_group_trackers[best_id]["centroid"] = gc group_trackers[best_id].update({"centroid": gc, "last_seen": now, "member_count": len(grp_indices)})
_group_trackers[best_id]["last_seen"] = now
_group_trackers[best_id]["member_count"] = len(grp_indices)
matched_ids.add(best_id) matched_ids.add(best_id)
frame_group_data.append((best_id, grp_indices, gc)) frame_group_data.append((best_id, grp_indices, gc))
else: else:
gid = _next_group_id gid = next_group_id
_next_group_id += 1 next_group_id += 1
_group_trackers[gid] = { group_trackers[gid] = {"centroid": gc, "first_seen": now, "last_seen": now, "member_count": len(grp_indices), "alerted": False}
"centroid": gc,
"first_seen": now,
"last_seen": now,
"member_count": len(grp_indices),
"alerted": False,
}
frame_group_data.append((gid, grp_indices, gc)) frame_group_data.append((gid, grp_indices, gc))
# Remove stale groups (not seen for > 3 s) stale = [gid for gid, gd in group_trackers.items() if now - gd["last_seen"] > 3]
stale = [gid for gid, gd in _group_trackers.items() if now - gd["last_seen"] > 3] for gid in stale: del group_trackers[gid]
for gid in stale:
del _group_trackers[gid]
# --- Alert logic (mark + metadata; save after drawing overlays) --- # Alerting
alert_this_frame = False alert_this_frame = False
pending_alerts = [] # list of (gid, people_count, duration_seconds) for gid, gdata in group_trackers.items():
for gid, gdata in _group_trackers.items(): if (gdata["last_seen"] - gdata["first_seen"]) >= GROUP_TIME_THRESHOLD and not gdata["alerted"]:
duration = gdata["last_seen"] - gdata["first_seen"] if now - last_alert_time >= ALERT_COOLDOWN:
if duration >= GROUP_TIME_THRESHOLD and not gdata["alerted"]:
if now - _last_alert_time >= ALERT_COOLDOWN:
gdata["alerted"] = True gdata["alerted"] = True
alert_this_frame = True alert_this_frame = True
_last_alert_time = now last_alert_time = now
pending_alerts.append((gid, gdata["member_count"], duration))
# --- Draw overlays ---
display = frame.copy()
pending_gid_set = {gid for gid, _, _ in pending_alerts}
alert_person_indices = set()
for gid, grp_indices, _gc in frame_group_data:
if gid in pending_gid_set:
alert_person_indices.update(grp_indices)
# Live overlay: mark any person that belongs to any alerting group.
if pending_alerts:
max_people = max(people for _gid, people, _dur in pending_alerts)
max_dur = max(dur for _gid, _people, dur in pending_alerts)
cv2.putText(
display,
f"ALERT: {len(pending_alerts)} group(s) | {max_people} people | {int(max_dur)}s",
(12, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 0, 255),
3,
)
for idx, (x1, y1, x2, y2, conf) in enumerate(person_boxes):
is_alert_person = idx in alert_person_indices
box_color = (0, 0, 255) if is_alert_person else (0, 255, 0)
cv2.rectangle(display, (int(x1), int(y1)), (int(x2), int(y2)), box_color, 2)
cv2.putText(
display, f"{conf:.0%}",
(int(x1), int(y1) - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, box_color, 2,
)
for gid, grp_indices, gc in frame_group_data:
gdata = _group_trackers.get(gid)
if gdata is None:
continue
duration = gdata["last_seen"] - gdata["first_seen"]
radius = int(PROXIMITY_PX * 0.6)
is_alert = duration >= GROUP_TIME_THRESHOLD
color = (0, 0, 255) if is_alert else (0, 165, 255)
cv2.circle(display, (int(gc[0]), int(gc[1])), radius, color, 2)
label = f"Group: {len(grp_indices)} | {duration:.0f}s"
cv2.putText(
display, label,
(int(gc[0]) - 70, int(gc[1]) - radius - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2,
)
if is_alert:
cv2.putText(
display, "ALERT",
(int(gc[0]) - 35, int(gc[1]) + radius + 25),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 3,
)
# --- Save annotated alerts (separate per group) ---
if pending_alerts:
os.makedirs("alerts", exist_ok=True) os.makedirs("alerts", exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = f"alerts/alert_{ts}_{cam_id}_gid{gid}.jpg"
frame_group_by_id = {gid: (grp_indices, gc) for gid, grp_indices, gc in frame_group_data} cv2.imwrite(path, frame)
for gid, people, duration in pending_alerts:
grp_indices, gc = frame_group_by_id.get(gid, (None, None))
if grp_indices is None:
continue
alert_display = frame.copy()
alert_person_set = set(grp_indices)
# Header annotation for this specific alert group.
cv2.putText(
alert_display,
f"ALERT GROUP {gid} | {people} people | {int(duration)}s",
(12, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
(0, 0, 255),
3,
)
# Draw only the person boxes relevant to this group.
for idx, (x1, y1, x2, y2, conf) in enumerate(person_boxes):
is_alert_person = idx in alert_person_set
box_color = (0, 0, 255) if is_alert_person else (0, 255, 0)
cv2.rectangle(alert_display, (int(x1), int(y1)), (int(x2), int(y2)), box_color, 2)
cv2.putText(
alert_display,
f"{conf:.0%}",
(int(x1), int(y1) - 6),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
box_color,
2,
)
# Draw only the circle/label for this group.
radius = int(PROXIMITY_PX * 0.6)
cv2.circle(alert_display, (int(gc[0]), int(gc[1])), radius, (0, 0, 255), 2)
cv2.putText(
alert_display,
f"Group: {len(grp_indices)} | {duration:.0f}s",
(int(gc[0]) - 70, int(gc[1]) - radius - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 0, 255),
2,
)
cv2.putText(
alert_display,
"ALERT",
(int(gc[0]) - 35, int(gc[1]) + radius + 25),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 0, 255),
3,
)
# Include group id to avoid collisions when multiple groups alert in one second.
alert_path = f"alerts/alert_{ts}_gid{gid}.jpg"
cv2.imwrite(alert_path, alert_display)
alert_info = {
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"people": people,
"duration": round(duration, 1),
"image": alert_path,
}
with lock: with lock:
state["alerts"].insert(0, alert_info) state["alerts"].insert(0, {"time": datetime.now().strftime("%H:%M:%S"), "camera": camera["name"], "people": gdata["member_count"], "duration": round(gdata["last_seen"]-gdata["first_seen"],1), "image": path})
state["alerts"] = state["alerts"][:50] state["alerts"] = state["alerts"][:50]
# --- Update shared state --- # Overlays
groups_json = [] display = frame.copy()
for gid, gi, gc in frame_group_data: cv2.putText(display, camera["name"], (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
gdata = _group_trackers.get(gid) for x1, y1, x2, y2, conf in person_boxes:
if gdata: cv2.rectangle(display, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
groups_json.append({ for gid, grp_indices, gc in frame_group_data:
"id": gid, cv2.circle(display, (int(gc[0]), int(gc[1])), 50, (0, 165, 255), 2)
"count": len(gi),
"duration": round(gdata["last_seen"] - gdata["first_seen"], 1), with lock:
state["cameras"][cam_id].update({
"frame": display,
"people_count": len(person_boxes),
"groups": [{"id": gid, "count": len(gi), "duration": round(group_trackers[gid]["last_seen"]-group_trackers[gid]["first_seen"], 1)} for gid, gi, gc in frame_group_data],
"alert_active": alert_this_frame,
"fps": round(fps, 1)
}) })
with lock: # --- 1 FPS RATE LIMIT ---
state["frame"] = display # ensures this specific camera thread only loops once per second
state["people_count"] = len(person_boxes) elapsed = time.time() - now
state["groups"] = groups_json time.sleep(max(0, 1.0 - elapsed))
state["alert_active"] = alert_this_frame or any(
(gd["last_seen"] - gd["first_seen"]) >= GROUP_TIME_THRESHOLD
for gd in _group_trackers.values()
)
state["fps"] = round(fps, 1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MJPEG generator # Grid View Generator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _update_grid_frame():
def _generate_frames():
while True: while True:
frames = []
with lock: with lock:
frame = state["frame"] for cam_id in state["cameras"]:
if frame is not None: if state["cameras"][cam_id]["frame"] is not None:
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) frames.append(cv2.resize(state["cameras"][cam_id]["frame"], (640, 480)))
if ok:
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + buf.tobytes() + b"\r\n"
)
time.sleep(0.033)
if not frames:
time.sleep(0.1); continue
n = len(frames)
if n == 0:
time.sleep(0.1); continue
# Calculate grid dimensions (rows/cols)
cols = int(np.ceil(np.sqrt(n)))
rows = int(np.ceil(n / cols))
# Determine cell size
# Use 320x240 for large grids to prevent the output frame from being too massive
cell_w, cell_h = 320, 240
if n <= 1: cell_w, cell_h = 640, 480
elif n <= 4: cell_w, cell_h = 480, 360
grid_rows = []
for r in range(rows):
row_items = []
for c in range(cols):
idx = r * cols + c
if idx < n:
row_items.append(cv2.resize(frames[idx], (cell_w, cell_h)))
else:
row_items.append(np.zeros((cell_h, cell_w, 3), dtype=np.uint8))
grid_rows.append(np.hstack(row_items))
grid = np.vstack(grid_rows)
with lock:
state["grid_frame"] = grid
state["total_people_count"] = sum(c["people_count"] for c in state["cameras"].values())
state["alert_active"] = any(c["alert_active"] for c in state["cameras"].values())
time.sleep(0.04)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.route("/") @app.route("/")
def dashboard(): def dashboard(): return render_template("dashboard.html")
return render_template("dashboard.html")
@app.route("/video_feed") @app.route("/video_feed")
def video_feed(): def video_feed():
return Response( def gen():
_generate_frames(), while True:
mimetype="multipart/x-mixed-replace; boundary=frame", with lock:
) frame = state["grid_frame"]
if frame is not None:
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + buf.tobytes() + b"\r\n")
time.sleep(0.04)
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
with lock: with lock:
active_camera = _get_camera_by_id(state["active_camera_id"]) cams_info = {cid: {k:v for k,v in c.items() if k != "frame"} for cid, c in state["cameras"].items()}
return jsonify({ return jsonify({
"people_count": state["people_count"], "total_people_count": state["total_people_count"],
"groups": state["groups"], "cameras": cams_info,
"alert_active": state["alert_active"], "alert_active": state["alert_active"],
"alerts": state["alerts"][:20], "alerts": state["alerts"][:20]
"fps": state["fps"],
"stream_status": state["stream_status"],
"selected_camera_id": state["selected_camera_id"],
"active_camera_name": active_camera["name"] if active_camera else "Unknown",
}) })
@app.route("/api/cameras") @app.route("/api/cameras")
def api_cameras(): def api_cameras():
return jsonify({ return jsonify({"cameras": [{"id": c["id"], "name": c["name"], "ip": c["ip"]} for c in CAMERAS]})
"cameras": [{"id": c["id"], "name": c["name"], "ip": c["ip"]} for c in CAMERAS]
})
@app.route("/api/camera/select", methods=["POST"])
def api_camera_select():
data = request.get_json(silent=True) or {}
camera_id = data.get("camera_id")
camera = _get_camera_by_id(camera_id)
if camera is None:
return jsonify({"ok": False, "error": "Invalid camera id"}), 400
with lock:
state["selected_camera_id"] = camera["id"]
state["frame"] = None
state["groups"] = []
state["people_count"] = 0
state["alert_active"] = False
state["fps"] = 0
state["stream_status"] = "connecting"
return jsonify({"ok": True})
@app.route("/alerts/<path:filename>") @app.route("/alerts/<path:filename>")
def serve_alert_image(filename): def serve_alert_image(filename): return send_from_directory("alerts", filename)
return send_from_directory("alerts", filename)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
threading.Thread(target=_process_stream, daemon=True).start() for cam in CAMERAS: threading.Thread(target=_process_stream, args=(cam,), daemon=True).start()
print(f"\n Surveillance Dashboard → http://localhost:5000\n") time.sleep(1.5)
threading.Thread(target=_update_grid_frame, daemon=True).start()
print("\n Grid View Dashboard → http://localhost:5000\n")
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True) app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Surveillance Dashboard</title> <title>Surveillance Dashboard - Multi-Cam Grid</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@ -78,20 +78,11 @@
@keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.35 } } @keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.35 } }
.clock { font-size: .85rem; color: var(--text-dim); font-variant-numeric: tabular-nums; } .clock { font-size: .85rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.camera-select {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
font-size: .85rem;
min-width: 170px;
}
/* -------- Layout -------- */ /* -------- Layout -------- */
.container { .container {
display: grid; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
gap: 20px; gap: 20px;
padding: 20px 28px 28px; padding: 20px 28px 28px;
@ -132,6 +123,7 @@
overflow: hidden; overflow: hidden;
position: relative; position: relative;
min-height: 480px; min-height: 480px;
aspect-ratio: 16/9;
} }
.video-panel img { .video-panel img {
width: 100%; width: 100%;
@ -173,10 +165,8 @@
letter-spacing: .5px; letter-spacing: .5px;
box-shadow: 0 8px 30px rgba(239,68,68,.35); box-shadow: 0 8px 30px rgba(239,68,68,.35);
z-index: 200; z-index: 200;
animation: bannerIn .4s ease-out;
} }
.alert-banner.show { display: flex; align-items: center; gap: 10px; } .alert-banner.show { display: flex; align-items: center; gap: 10px; }
@keyframes bannerIn { from { opacity:0; transform:translateX(-50%) translateY(-12px); } }
/* -------- Sidebar -------- */ /* -------- Sidebar -------- */
.sidebar { .sidebar {
@ -201,7 +191,6 @@
} }
.panel-body { padding: 12px 16px; } .panel-body { padding: 12px 16px; }
/* Groups panel */
.group-item { .group-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -211,8 +200,8 @@
} }
.group-item:last-child { border-bottom: none; } .group-item:last-child { border-bottom: none; }
.group-meta { display: flex; flex-direction: column; gap: 2px; } .group-meta { display: flex; flex-direction: column; gap: 2px; }
.group-meta span:first-child { font-weight: 600; font-size: .9rem; } .group-meta span:first-child { font-weight: 600; font-size: .95rem; }
.group-meta span:last-child { font-size: .75rem; color: var(--text-dim); } .group-meta .cam-name { font-size: .75rem; color: var(--accent); }
.group-timer { .group-timer {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-weight: 700; font-weight: 700;
@ -221,282 +210,152 @@
border-radius: 6px; border-radius: 6px;
background: var(--surface-2); background: var(--surface-2);
} }
.group-timer.warning { color: var(--orange); background: rgba(245,158,11,.1); }
.group-timer.danger { color: var(--red); background: rgba(239,68,68,.1); } .group-timer.danger { color: var(--red); background: rgba(239,68,68,.1); }
.no-groups { color: var(--text-dim); font-size: .85rem; padding: 8px 0; }
/* Alert history */ .alert-log { max-height: 480px; overflow-y: auto; }
.alert-log {
max-height: 380px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.alert-entry { .alert-entry {
display: flex; display: flex;
gap: 12px; gap: 12px;
padding: 10px 0; padding: 12px 8px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: background .2s;
border-radius: 6px;
padding: 10px 8px;
} }
.alert-entry:hover { background: var(--surface-2); } .alert-entry:hover { background: var(--surface-2); }
.alert-entry:last-child { border-bottom: none; } .alert-thumb { width: 80px; height: 50px; border-radius: 4px; object-fit: cover; }
.alert-thumb {
width: 64px;
height: 44px;
border-radius: 6px;
object-fit: cover;
border: 1px solid var(--border);
flex-shrink: 0;
}
.alert-info { display: flex; flex-direction: column; gap: 2px; }
.alert-info .time { font-size: .75rem; color: var(--text-dim); } .alert-info .time { font-size: .75rem; color: var(--text-dim); }
.alert-info .desc { font-size: .82rem; font-weight: 600; } .alert-info .desc { font-size: .85rem; font-weight: 600; display: block; }
.alert-info .duration { font-size: .72rem; color: var(--orange); }
/* -------- Lightbox -------- */
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.85);
z-index: 300;
justify-content: center;
align-items: center;
cursor: pointer;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 85vh;
border-radius: 8px;
box-shadow: 0 12px 60px rgba(0,0,0,.6);
}
/* -------- Responsive -------- */
@media (max-width: 960px) {
.container {
grid-template-columns: 1fr;
}
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
}
</style> </style>
</head> </head>
<body> <body>
<!-- Alert banner -->
<div class="alert-banner" id="alertBanner"> <div class="alert-banner" id="alertBanner">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
GATHERING ALERT — People grouped for over 20 seconds GATHERING DETECTED — Multiple groups active
</div> </div>
<header> <header>
<div class="logo"> <div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
Surveillance Dashboard Surveillance Dash: Multi-Grid
</div> </div>
<div class="header-right"> <div class="header-right">
<select id="cameraSelect" class="camera-select"></select> <div class="status-badge live" id="statusBadge">
<div class="status-badge connecting" id="statusBadge">
<span class="dot"></span> <span class="dot"></span>
<span id="statusText">Connecting</span> <span id="statusText">System Live</span>
</div> </div>
<div class="clock" id="clock"></div> <div class="clock" id="clock"></div>
</div> </div>
</header> </header>
<div class="container"> <div class="container">
<!-- KPI strip -->
<div class="kpi-strip"> <div class="kpi-strip">
<div class="kpi" id="kpiPeople"> <div class="kpi">
<span class="kpi-label">People Detected</span> <span class="kpi-label">People (Total)</span>
<span class="kpi-value accent" id="valPeople">0</span> <span class="kpi-value accent" id="valPeople">0</span>
</div> </div>
<div class="kpi" id="kpiGroups"> <div class="kpi">
<span class="kpi-label">Active Groups</span> <span class="kpi-label">Active Groups</span>
<span class="kpi-value orange" id="valGroups">0</span> <span class="kpi-value orange" id="valGroups">0</span>
</div> </div>
<div class="kpi" id="kpiAlerts"> <div class="kpi" id="kpiAlerts">
<span class="kpi-label">Total Alerts</span> <span class="kpi-label">Global Alerts</span>
<span class="kpi-value red" id="valAlerts">0</span> <span class="kpi-value red" id="valAlerts">0</span>
</div> </div>
<div class="kpi"> <div class="kpi">
<span class="kpi-label">FPS</span> <span class="kpi-label">Node Status</span>
<span class="kpi-value green" id="valFps">0</span> <span class="kpi-value green" id="valNodes">Online</span>
</div> </div>
</div> </div>
<!-- Video feed -->
<div class="video-panel"> <div class="video-panel">
<img id="videoFeed" src="/video_feed" alt="Live Feed"> <img id="videoFeed" src="/video_feed" alt="Simultaneous Feed">
<div class="video-overlay"> <div class="video-overlay">
<span class="overlay-tag">LIVE</span> <span class="overlay-tag">GRID VIEW</span>
<span class="overlay-tag" id="overlayPeople">0 People</span> <span class="overlay-tag" id="overlayPeople">0 People Total</span>
</div> </div>
</div> </div>
<!-- Sidebar -->
<div class="sidebar"> <div class="sidebar">
<!-- Active groups -->
<div class="panel"> <div class="panel">
<div class="panel-header">Active Groups</div> <div class="panel-header">Active Groups (All Cams)</div>
<div class="panel-body" id="groupList"> <div class="panel-body" id="groupList">
<div class="no-groups">No groups detected</div> <div style="color:var(--text-dim); font-size:.8rem;">Monitoring...</div>
</div> </div>
</div> </div>
<!-- Alert history -->
<div class="panel" style="flex:1;"> <div class="panel" style="flex:1;">
<div class="panel-header">Alert History</div> <div class="panel-header">Global Alert History</div>
<div class="panel-body alert-log" id="alertLog"> <div class="panel-body alert-log" id="alertLog">
<div class="no-groups">No alerts yet</div> <div style="color:var(--text-dim); font-size:.8rem;">Scanning for events...</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Lightbox for alert images -->
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')">
<img id="lightboxImg" src="" alt="Alert snapshot">
</div>
<script> <script>
function updateClock() { function updateClock() {
const now = new Date(); const now = new Date();
document.getElementById('clock').textContent = now.toLocaleString('en-US', { document.getElementById('clock').textContent = now.toLocaleTimeString();
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
year: 'numeric', month: 'short', day: 'numeric'
});
} }
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
updateClock(); updateClock();
let prevAlertActive = false; let prevAlertActive = false;
let bannerTimeout = null;
let camerasLoaded = false;
async function loadCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
const select = document.getElementById('cameraSelect');
select.innerHTML = data.cameras
.map(c => `<option value="${c.id}">${c.name} (${c.ip})</option>`)
.join('');
camerasLoaded = true;
} catch (e) {
console.error('Failed to load cameras', e);
}
}
async function selectCamera(cameraId) {
try {
await fetch('/api/camera/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera_id: cameraId })
});
} catch (e) {
console.error('Failed to switch camera', e);
}
}
async function poll() { async function poll() {
try { try {
const res = await fetch('/api/status'); const res = await fetch('/api/status');
const d = await res.json(); const d = await res.json();
// KPIs document.getElementById('valPeople').textContent = d.total_people_count;
document.getElementById('valPeople').textContent = d.people_count;
document.getElementById('valGroups').textContent = d.groups.length;
document.getElementById('valAlerts').textContent = d.alerts.length; document.getElementById('valAlerts').textContent = d.alerts.length;
document.getElementById('valFps').textContent = d.fps; document.getElementById('overlayPeople').textContent = d.total_people_count + ' People Total';
document.getElementById('overlayPeople').textContent = d.people_count + ' People';
// Stream status badge let allGroups = [];
const badge = document.getElementById('statusBadge'); Object.keys(d.cameras).forEach(id => {
const stext = document.getElementById('statusText'); d.cameras[id].groups.forEach(g => allGroups.push({...g, camName: d.cameras[id].name}));
badge.className = 'status-badge ' + d.stream_status; });
stext.textContent = d.stream_status === 'live' ? 'Live' :
d.stream_status === 'error' ? 'Error' : 'Connecting';
if (d.active_camera_name) {
stext.textContent = `${stext.textContent} - ${d.active_camera_name}`;
}
if (camerasLoaded) { document.getElementById('valGroups').textContent = allGroups.length;
const cameraSelect = document.getElementById('cameraSelect'); document.getElementById('kpiAlerts').classList.toggle('alert-glow', d.alert_active);
if (cameraSelect.value !== d.selected_camera_id) {
cameraSelect.value = d.selected_camera_id;
}
}
// Alert KPI glow
const kpiAlerts = document.getElementById('kpiAlerts');
kpiAlerts.classList.toggle('alert-glow', d.alert_active);
// Alert banner
const banner = document.getElementById('alertBanner'); const banner = document.getElementById('alertBanner');
if (d.alert_active && !prevAlertActive) { if (d.alert_active && !prevAlertActive) banner.classList.add('show');
banner.classList.add('show'); else if (!d.alert_active) banner.classList.remove('show');
clearTimeout(bannerTimeout);
bannerTimeout = setTimeout(() => banner.classList.remove('show'), 8000);
}
prevAlertActive = d.alert_active; prevAlertActive = d.alert_active;
// Groups list
const gl = document.getElementById('groupList'); const gl = document.getElementById('groupList');
if (d.groups.length === 0) { if (allGroups.length === 0) gl.innerHTML = '<div style="color:var(--text-dim); font-size:.8rem;">No groups detected</div>';
gl.innerHTML = '<div class="no-groups">No groups detected</div>'; else {
} else { gl.innerHTML = allGroups.map(g => `
gl.innerHTML = d.groups.map(g => { <div class="group-item">
const cls = g.duration >= 20 ? 'danger' : g.duration >= 10 ? 'warning' : '';
return `<div class="group-item">
<div class="group-meta"> <div class="group-meta">
<span>${g.count} People</span> <span>${g.count} People</span>
<span>Group #${g.id}</span> <span class="cam-name">${g.camName}</span>
</div> </div>
<span class="group-timer ${cls}">${g.duration}s</span> <span class="group-timer ${g.duration > 20 ? 'danger' : ''}">${g.duration}s</span>
</div>`; </div>
}).join(''); `).join('');
} }
// Alert log
const al = document.getElementById('alertLog'); const al = document.getElementById('alertLog');
if (d.alerts.length === 0) { if (d.alerts.length === 0) al.innerHTML = '<div style="color:var(--text-dim); font-size:.8rem;">No alerts recorded</div>';
al.innerHTML = '<div class="no-groups">No alerts yet</div>'; else {
} else {
al.innerHTML = d.alerts.map(a => ` al.innerHTML = d.alerts.map(a => `
<div class="alert-entry" onclick="openLightbox('/${a.image}')"> <div class="alert-entry">
<img class="alert-thumb" src="/${a.image}" alt="Alert"> <img class="alert-thumb" src="/${a.image}" alt="Alert">
<div class="alert-info"> <div class="alert-info">
<span class="time">${a.time}</span> <span class="time">${a.time} - ${a.camera}</span>
<span class="desc">${a.people} people gathered</span> <span class="desc">${a.people} people gathered</span>
<span class="duration">Duration: ${a.duration}s</span>
</div> </div>
</div> </div>
`).join(''); `).join('');
} }
} catch (e) { } catch (e) { console.error('Poll error', e); }
console.error('Poll error', e);
}
} }
function openLightbox(src) { setInterval(poll, 1500);
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
setInterval(poll, 1000);
document.getElementById('cameraSelect').addEventListener('change', (e) => {
selectCamera(e.target.value);
});
loadCameras().then(poll);
poll(); poll();
</script> </script>
</body> </body>