Server Push 0.2
parent
bf3f36d742
commit
7eb227d7b2
499
camera_stream.py
499
camera_stream.py
|
|
@ -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, 11): # Supports up to 10 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,237 @@ 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("person_detector_best.pt")
|
model = YOLO("person_detector_best.pt")
|
||||||
model.to(f"cuda:{INFERENCE_DEVICE}")
|
model.to(device)
|
||||||
|
|
||||||
# Group tracking
|
|
||||||
_group_trackers: dict = {}
|
|
||||||
_next_group_id = 0
|
|
||||||
_last_alert_time = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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:
|
with lock: state["cameras"][cam_id]["status"] = "error"
|
||||||
state["stream_status"] = "error"
|
time.sleep(5); cap = None; continue
|
||||||
print(f"[ERROR] Cannot open RTSP stream: {selected_camera['rtsp_url']}")
|
with lock: state["cameras"][cam_id]["status"] = "live"
|
||||||
time.sleep(2)
|
|
||||||
cap = None
|
|
||||||
continue
|
|
||||||
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:
|
|
||||||
state["frame"] = display
|
|
||||||
state["people_count"] = len(person_boxes)
|
|
||||||
state["groups"] = groups_json
|
|
||||||
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 == 1: grid = frames[0]
|
||||||
|
elif n == 2: grid = np.hstack(frames)
|
||||||
|
else:
|
||||||
|
row1 = np.hstack(frames[:2])
|
||||||
|
if n == 3: row2 = np.hstack([frames[2], np.zeros_like(frames[0])])
|
||||||
|
else: row2 = np.hstack(frames[2:4])
|
||||||
|
grid = np.vstack([row1, row2])
|
||||||
|
|
||||||
|
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")
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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,290 +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();
|
||||||
|
|
||||||
// Aggregated KPIs
|
document.getElementById('valPeople').textContent = d.total_people_count;
|
||||||
const totalPeople = d.total_people_count;
|
|
||||||
let totalGroups = 0;
|
|
||||||
let allGroups = [];
|
|
||||||
|
|
||||||
Object.keys(d.cameras).forEach(cid => {
|
|
||||||
const camData = d.cameras[cid];
|
|
||||||
totalGroups += camData.groups.length;
|
|
||||||
camData.groups.forEach(g => {
|
|
||||||
allGroups.push({...g, camName: camData.name});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('valPeople').textContent = totalPeople;
|
|
||||||
document.getElementById('valGroups').textContent = totalGroups;
|
|
||||||
document.getElementById('valAlerts').textContent = d.alerts.length;
|
document.getElementById('valAlerts').textContent = d.alerts.length;
|
||||||
document.getElementById('valFps').textContent = "GRID";
|
document.getElementById('overlayPeople').textContent = d.total_people_count + ' People Total';
|
||||||
document.getElementById('overlayPeople').textContent = totalPeople + ' People Total';
|
|
||||||
|
|
||||||
// Stream status badge (Aggregated)
|
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}));
|
||||||
const statuses = Object.values(d.cameras).map(c => c.status);
|
});
|
||||||
|
|
||||||
if (statuses.includes('error')) {
|
document.getElementById('valGroups').textContent = allGroups.length;
|
||||||
badge.className = 'status-badge error';
|
document.getElementById('kpiAlerts').classList.toggle('alert-glow', d.alert_active);
|
||||||
stext.textContent = 'SYSTEM ERROR';
|
|
||||||
} else if (statuses.includes('connecting') || statuses.includes('reconnecting')) {
|
|
||||||
badge.className = 'status-badge connecting';
|
|
||||||
stext.textContent = 'CONNECTING...';
|
|
||||||
} else {
|
|
||||||
badge.className = 'status-badge live';
|
|
||||||
stext.textContent = 'ALL SYSTEMS LIVE';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (Aggregated)
|
|
||||||
const gl = document.getElementById('groupList');
|
const gl = document.getElementById('groupList');
|
||||||
if (allGroups.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 = allGroups.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 - ${g.camName}</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} - ${a.camera || 'Unknown'}</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);
|
|
||||||
loadCameras().then(poll);
|
|
||||||
poll();
|
poll();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue