|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import os, io, json, math, time, tempfile, traceback, uuid |
|
|
from pathlib import Path |
|
|
from dataclasses import dataclass |
|
|
from datetime import datetime, date |
|
|
|
|
|
import cv2 |
|
|
import numpy as np |
|
|
import pandas as pd |
|
|
import streamlit as st |
|
|
from collections import deque |
|
|
|
|
|
|
|
|
ENABLE_SF = False |
|
|
|
|
|
MAX_PREVIEW_W = 1280 |
|
|
FACE_UPDATE_EVERY_N = 4 |
|
|
EVENT_COOLDOWN_SEC = 1.0 |
|
|
PHONE_PERSIST_N = 2 |
|
|
SLEEP_IDLE_SECONDS = 60.0 |
|
|
CHECKOUT_MISS_FRAMES = 90 |
|
|
|
|
|
DEFAULT_INSTANCE = "HQ-Instance-01" |
|
|
DEFAULT_FLOOR = "1" |
|
|
DEFAULT_ZONE = "Work Area" |
|
|
DEFAULT_CAMERA_ID = "CAM01" |
|
|
|
|
|
SPLIT_MAX_BYTES = 200 * 1024 * 1024 |
|
|
|
|
|
|
|
|
st.set_page_config(page_title="Smart Office Attendance", page_icon="π₯", layout="wide") |
|
|
tabs = st.tabs(["π’ Live", "π₯ Employees", "π Reports", "π© Anomalies"]) |
|
|
|
|
|
|
|
|
@st.cache_resource(show_spinner=False) |
|
|
def _lazy_ultralytics(): |
|
|
from ultralytics import YOLO |
|
|
return YOLO |
|
|
|
|
|
@st.cache_resource(show_spinner=False) |
|
|
def _lazy_mediapipe(): |
|
|
import mediapipe as mp |
|
|
return mp |
|
|
|
|
|
YOLO = _lazy_ultralytics() |
|
|
mp = _lazy_mediapipe() |
|
|
|
|
|
@st.cache_resource(show_spinner=False) |
|
|
def load_yolo_model(): |
|
|
return YOLO("yolov8n.pt") |
|
|
|
|
|
@st.cache_resource(show_spinner=False) |
|
|
def load_mp_face(): |
|
|
mpfd = mp.solutions.face_detection |
|
|
det = mpfd.FaceDetection(model_selection=1, min_detection_confidence=0.5) |
|
|
return det |
|
|
|
|
|
model = load_yolo_model() |
|
|
mp_face = load_mp_face() |
|
|
|
|
|
|
|
|
st.session_state.setdefault("events", pd.DataFrame( |
|
|
columns=["id","ts","camera","employee","activity","zone","confidence","run_id"])) |
|
|
st.session_state.setdefault("current_run_id", None) |
|
|
st.session_state.setdefault("prev_boxes_by_name", {}) |
|
|
st.session_state.setdefault("act_votes", {}) |
|
|
st.session_state.setdefault("on_phone_start_ns", {}) |
|
|
st.session_state.setdefault("on_phone_accum_ns", {}) |
|
|
st.session_state.setdefault("emp_counters", {}) |
|
|
st.session_state.setdefault("emp_first_seen", set()) |
|
|
st.session_state.setdefault("last_seen_frame", {}) |
|
|
st.session_state.setdefault("idle_start_ts", {}) |
|
|
st.session_state.setdefault("did_checkout", set()) |
|
|
st.session_state.setdefault("last_emit_map", {}) |
|
|
st.session_state.setdefault("attendance_rows", []) |
|
|
st.session_state.setdefault("metric_rows", []) |
|
|
st.session_state.setdefault("anomalies", []) |
|
|
st.session_state.setdefault("anomaly_counter", 1) |
|
|
|
|
|
st.session_state.setdefault("last_run_assets", { |
|
|
"video_parts": [], |
|
|
"summary": "", |
|
|
}) |
|
|
st.session_state.setdefault("csv_data", { |
|
|
"events": None, |
|
|
"summary": None, |
|
|
"attendance": None, |
|
|
"metrics": None, |
|
|
"anomalies": None, |
|
|
}) |
|
|
|
|
|
def log_ui(line, ok=True): |
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
def _list_employee_images(root="employees"): |
|
|
"""Return {employee_name: [image paths...]} for ALL subfolders under root.""" |
|
|
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} |
|
|
rootp = Path(root) |
|
|
mapping = {} |
|
|
if not rootp.exists(): |
|
|
return mapping |
|
|
for dirpath, _, filenames in os.walk(rootp): |
|
|
rel = Path(dirpath).relative_to(rootp) |
|
|
if rel == Path("."): |
|
|
continue |
|
|
emp_name = rel.parts[0] |
|
|
for fn in filenames: |
|
|
if Path(fn).suffix.lower() in exts: |
|
|
mapping.setdefault(emp_name, []).append(str(Path(dirpath) / fn)) |
|
|
print("β
Loaded face DB for:", list(mapping.keys())) |
|
|
return mapping |
|
|
|
|
|
def _face_embed_gray(frame_bgr, bbox_xyxy): |
|
|
"""Crop face to 112x112 gray & L2-normalize as simple embedding.""" |
|
|
x1,y1,x2,y2 = [max(0,int(v)) for v in bbox_xyxy] |
|
|
crop = frame_bgr[y1:y2, x1:x2] |
|
|
if crop.size == 0: return None |
|
|
g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) |
|
|
g = cv2.resize(g, (112,112), interpolation=cv2.INTER_AREA) |
|
|
vec = g.astype(np.float32).reshape(-1) |
|
|
n = np.linalg.norm(vec) + 1e-6 |
|
|
return (vec / n) |
|
|
|
|
|
def _detect_faces_mediapipe(frame_bgr): |
|
|
h, w = frame_bgr.shape[:2] |
|
|
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) |
|
|
res = mp_face.process(rgb) |
|
|
out = [] |
|
|
if res.detections: |
|
|
for d in res.detections: |
|
|
bb = d.location_data.relative_bounding_box |
|
|
x1 = int(bb.xmin * w) |
|
|
y1 = int(bb.ymin * h) |
|
|
x2 = int((bb.xmin + bb.width) * w) |
|
|
y2 = int((bb.ymin + bb.height) * h) |
|
|
out.append((x1,y1,x2,y2, float(d.score[0] if d.score else 0.0))) |
|
|
return out |
|
|
|
|
|
@st.cache_resource(show_spinner=False) |
|
|
def build_employee_db(): |
|
|
img_map = _list_employee_images("employees") |
|
|
db = {} |
|
|
for name, paths in img_map.items(): |
|
|
vecs = [] |
|
|
for path in paths[:12]: |
|
|
try: |
|
|
bgr = cv2.imread(path) |
|
|
if bgr is None: |
|
|
continue |
|
|
faces = _detect_faces_mediapipe(bgr) |
|
|
if faces: |
|
|
|
|
|
faces.sort(key=lambda t: (t[2]-t[0])*(t[3]-t[1]), reverse=True) |
|
|
emb = _face_embed_gray(bgr, faces[0][:4]) |
|
|
if emb is not None: |
|
|
vecs.append(emb) |
|
|
else: |
|
|
|
|
|
h, w = bgr.shape[:2] |
|
|
cx1 = max(0, int(w*0.25)); cy1 = max(0, int(h*0.20)) |
|
|
cx2 = min(w, int(w*0.75)); cy2 = min(h, int(h*0.80)) |
|
|
emb = _face_embed_gray(bgr, (cx1, cy1, cx2, cy2)) |
|
|
if emb is not None: |
|
|
vecs.append(emb) |
|
|
except Exception: |
|
|
pass |
|
|
if vecs: |
|
|
db[name] = np.stack(vecs, axis=0) |
|
|
return db |
|
|
|
|
|
face_db = build_employee_db() |
|
|
|
|
|
|
|
|
ALLOWED_ACT = {"Working":"Working","Idle":"Idle","On Phone":"On Phone","Sleep":"Sleep","Away":"Away"} |
|
|
ACT_COLORS = {"Working":(40,180,70), "Idle":(210,160,40), "On Phone":(60,120,240), "Sleep":(150,80,180), "Away":(120,120,120)} |
|
|
|
|
|
@dataclass |
|
|
class DetBox: |
|
|
cls: str |
|
|
conf: float |
|
|
box: tuple |
|
|
|
|
|
def run_yolo(model, frame_bgr, conf_thres=0.28): |
|
|
res = model.predict(frame_bgr, verbose=False, conf=conf_thres)[0] |
|
|
names = res.names |
|
|
out = [] |
|
|
if res.boxes is None: |
|
|
return out |
|
|
for b in res.boxes: |
|
|
c = int(b.cls.item()) |
|
|
conf = float(b.conf.item() if b.conf is not None else 0.0) |
|
|
xyxy = tuple(map(int, b.xyxy[0].tolist())) |
|
|
out.append(DetBox(names[c], conf, xyxy)) |
|
|
return out |
|
|
|
|
|
def iou(a, b): |
|
|
xA, yA = max(a[0], b[0]), max(a[1], b[1]) |
|
|
xB, yB = min(a[2], b[2]), min(a[3], b[3]) |
|
|
inter = max(0, xB-xA) * max(0, yB-yA) |
|
|
if inter == 0: return 0.0 |
|
|
areaA = (a[2]-a[0])*(a[3]-a[1]); areaB = (b[2]-b[0])*(b[3]-b[1]) |
|
|
return inter / float(areaA + areaB - inter + 1e-6) |
|
|
|
|
|
def center_speed(cur_box, prev_box): |
|
|
if prev_box is None or cur_box is None: |
|
|
return 0.0 |
|
|
cx0 = (prev_box[0]+prev_box[2]) * 0.5; cy0 = (prev_box[1]+prev_box[3]) * 0.5 |
|
|
cx1 = (cur_box[0]+cur_box[2]) * 0.5; cy1 = (cur_box[1]+cur_box[3]) * 0.5 |
|
|
return math.hypot(cx1 - cx0, cy1 - cy0) |
|
|
|
|
|
def phone_near_head(person_box, phone_boxes, face_boxes): |
|
|
"""Phone considered 'near head' if overlaps any expanded face box or upper half of the person box.""" |
|
|
px1, py1, px2, py2 = person_box |
|
|
head_h = int(py1 + 0.55 * (py2 - py1)) |
|
|
head_box = (px1, py1, px2, head_h) |
|
|
exp_faces = [] |
|
|
for f in face_boxes: |
|
|
fx1, fy1, fx2, fy2 = f |
|
|
w = fx2 - fx1; h = fy2 - fy1 |
|
|
exp_faces.append((fx1 - w//5, fy1 - h//5, fx2 + w//5, fy2 + h//5)) |
|
|
for ph in phone_boxes: |
|
|
if any(iou(ph.box, f) > 0.04 for f in exp_faces): |
|
|
return True |
|
|
if iou(ph.box, head_box) > 0.06: |
|
|
return True |
|
|
return False |
|
|
|
|
|
def recognize_from_db(face_emb, db, threshold=0.70): |
|
|
best_name, best = None, -1.0 |
|
|
for nm, arr in db.items(): |
|
|
sims = np.dot(arr, face_emb) / (np.linalg.norm(arr, axis=1) * (np.linalg.norm(face_emb)+1e-8)) |
|
|
m = float(np.mean(sims)) |
|
|
if m > best: |
|
|
best, best_name = m, nm |
|
|
if best >= threshold: |
|
|
return best_name, best |
|
|
return None, best |
|
|
|
|
|
def fmt_secs_short(secs_float): |
|
|
return str(int(round(max(0.0, secs_float)))) |
|
|
|
|
|
|
|
|
def save_events_csv(run_id: str): |
|
|
df = st.session_state.events |
|
|
if df.empty: return "" |
|
|
df = df[df["run_id"] == run_id].copy() |
|
|
if df.empty: return "" |
|
|
out_path = f"/tmp/events_{run_id}.csv" |
|
|
df.to_csv(out_path, index=False) |
|
|
st.session_state.csv_data["events"] = df.to_csv(index=False).encode("utf-8") |
|
|
return out_path |
|
|
|
|
|
def save_run_summary_csv(run_id: str, site_floor: str, camera_name: str): |
|
|
df = st.session_state.events |
|
|
if df.empty: return "" |
|
|
df = df[df["run_id"] == run_id].copy() |
|
|
if df.empty: return "" |
|
|
counts = df.groupby(["employee","activity"]).size().unstack(fill_value=0) |
|
|
for col in ["Working","On Phone","Idle","Away","Sleep"]: |
|
|
if col not in counts.columns: counts[col] = 0 |
|
|
counts["total"] = counts.sum(axis=1) |
|
|
pct = counts[["Working","On Phone","Idle","Away","Sleep"]].div(counts["total"].replace(0,1), axis=0) * 100.0 |
|
|
pct = pct.round(2) |
|
|
out = counts.join(pct.add_suffix(" %")).reset_index() |
|
|
out.insert(0,"camera", camera_name) |
|
|
out.insert(0,"site_floor", site_floor) |
|
|
out.insert(0,"run_id", run_id) |
|
|
out.insert(0,"date", date.today().isoformat()) |
|
|
out_path = f"/tmp/run_summary_{run_id}.csv" |
|
|
out.to_csv(out_path, index=False) |
|
|
st.session_state.csv_data["summary"] = out.to_csv(index=False).encode("utf-8") |
|
|
return out_path |
|
|
|
|
|
def save_attendance_csv() -> str: |
|
|
rows = st.session_state.attendance_rows |
|
|
if not rows: return "" |
|
|
df = pd.DataFrame(rows) |
|
|
out_path = "/tmp/attendance_today.csv" |
|
|
df.to_csv(out_path, index=False) |
|
|
st.session_state.csv_data["attendance"] = df.to_csv(index=False).encode("utf-8") |
|
|
return out_path |
|
|
|
|
|
def save_metrics_csv() -> str: |
|
|
rows = st.session_state.metric_rows |
|
|
if not rows: return "" |
|
|
df = pd.DataFrame(rows) |
|
|
out_path = "/tmp/metrics_today.csv" |
|
|
df.to_csv(out_path, index=False) |
|
|
st.session_state.csv_data["metrics"] = df.to_csv(index=False).encode("utf-8") |
|
|
return out_path |
|
|
|
|
|
def save_anomalies_csv() -> str: |
|
|
rows = st.session_state.anomalies |
|
|
if not rows: return "" |
|
|
df = pd.DataFrame(rows) |
|
|
out_path = "/tmp/anomalies_today.csv" |
|
|
df.to_csv(out_path, index=False) |
|
|
st.session_state.csv_data["anomalies"] = df.to_csv(index=False).encode("utf-8") |
|
|
return out_path |
|
|
|
|
|
|
|
|
def push_events_batch(rows): |
|
|
|
|
|
pass |
|
|
|
|
|
def push_metric_batch(rows): |
|
|
if not rows: return |
|
|
st.session_state.metric_rows.extend(rows) |
|
|
|
|
|
def push_attendance_once(kind: str, employee_name: str, ts_iso: str): |
|
|
st.session_state.attendance_rows.append({ |
|
|
"Type": kind, "EmployeeName": employee_name, "InTime": ts_iso |
|
|
}) |
|
|
|
|
|
|
|
|
def _overlay_label(img, x1, y1, text): |
|
|
"""Draw a white box sized to text using cv2.getTextSize and put text on top.""" |
|
|
font = cv2.FONT_HERSHEY_SIMPLEX |
|
|
scale = 0.45 |
|
|
thickness = 1 |
|
|
(tw, th), baseline = cv2.getTextSize(text, font, scale, thickness) |
|
|
pad_x, pad_y = 8, 6 |
|
|
box_w = tw + 2*pad_x |
|
|
box_h = th + baseline + 2*pad_y |
|
|
top = max(0, y1 - (box_h + 4)) |
|
|
cv2.rectangle(img, (x1, top), (x1 + box_w, top + box_h), (250,250,250), -1) |
|
|
cv2.putText(img, text, (x1 + pad_x, top + pad_y + th), font, scale, (30,30,30), thickness, cv2.LINE_AA) |
|
|
|
|
|
def ensure_counter(name: str): |
|
|
if name not in st.session_state.emp_counters: |
|
|
st.session_state.emp_counters[name] = {"state": None, "working":0.0, "idle":0.0, "sleep":0.0} |
|
|
|
|
|
def map_activity(act_raw: str) -> str: |
|
|
if act_raw in ALLOWED_ACT: return act_raw |
|
|
low = act_raw.lower() |
|
|
if low == "on phone": return "On Phone" |
|
|
if low == "sleeping": return "Sleep" |
|
|
if low == "away": return "Away" |
|
|
if low == "working": return "Working" |
|
|
return "Idle" |
|
|
|
|
|
def _open_ticket_if_sleep(name: str, seconds_sleep: float): |
|
|
if seconds_sleep >= SLEEP_IDLE_SECONDS: |
|
|
open_exists = any((t["employee"] == name and t["status"] == "Open") for t in st.session_state.anomalies) |
|
|
if not open_exists: |
|
|
ticket_id = st.session_state.anomaly_counter |
|
|
st.session_state.anomaly_counter += 1 |
|
|
st.session_state.anomalies.append({ |
|
|
"ticket_id": ticket_id, |
|
|
"employee": name, |
|
|
"type": "Sleep", |
|
|
"observed_sec": int(seconds_sleep), |
|
|
"raised_by": "Admin", |
|
|
"raised_at": datetime.utcnow().isoformat() + "Z", |
|
|
"status": "Open", |
|
|
"history": [{"at": datetime.utcnow().isoformat()+"Z", "by": "System", "msg": f"Auto-detected Sleep >= {SLEEP_IDLE_SECONDS}s"}], |
|
|
}) |
|
|
|
|
|
|
|
|
def _init_videowriter(out_path: str, fps: float, frame_size): |
|
|
|
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v') |
|
|
return cv2.VideoWriter(out_path, fourcc, max(1.0, fps), frame_size) |
|
|
|
|
|
def _split_file_if_needed(full_path: str) -> list: |
|
|
"""If file size exceeds SPLIT_MAX_BYTES, split into parts and return the list of part paths. |
|
|
Splitting is done on raw bytes (not re-encoding). Many players can handle split sequences. |
|
|
We still prefer re-encode-per-part for safe playback, so we implement chunked copy and keep .mp4 parts. |
|
|
""" |
|
|
parts = [] |
|
|
size = os.path.getsize(full_path) |
|
|
if size <= SPLIT_MAX_BYTES: |
|
|
return [full_path] |
|
|
|
|
|
|
|
|
base = Path(full_path).with_suffix("") |
|
|
with open(full_path, "rb") as fin: |
|
|
idx = 1 |
|
|
while True: |
|
|
chunk = fin.read(SPLIT_MAX_BYTES) |
|
|
if not chunk: |
|
|
break |
|
|
part_path = f"{base}_part{idx}.mp4" |
|
|
with open(part_path, "wb") as fout: |
|
|
fout.write(chunk) |
|
|
parts.append(part_path) |
|
|
idx += 1 |
|
|
|
|
|
return parts |
|
|
|
|
|
|
|
|
with tabs[0]: |
|
|
st.title("π’ Live") |
|
|
qp = st.query_params |
|
|
INSTANCE_NAME = str(qp.get("instance", [DEFAULT_INSTANCE])[0]) |
|
|
FLOOR_TEXT = str(qp.get("floor", [DEFAULT_FLOOR])[0]) |
|
|
ZONE_TEXT = str(qp.get("zone", [DEFAULT_ZONE])[0]) |
|
|
ZONE_TYPE = "Work Area" if ZONE_TEXT.lower().strip() in ("work area","workarea","desk") else "Corridor" |
|
|
CAMERA_ID = str(qp.get("camera", [DEFAULT_CAMERA_ID])[0]) |
|
|
|
|
|
st.caption(f"Instance **{INSTANCE_NAME}** β’ Floor **{FLOOR_TEXT}** β’ Zone **{ZONE_TEXT}** β’ Camera **{CAMERA_ID}**") |
|
|
st.caption("Loaded face DB for: " + (", ".join(sorted(face_db.keys())) if face_db else "β none β")) |
|
|
|
|
|
with st.expander("Advanced (ML)", expanded=False): |
|
|
conf_thres = st.slider("YOLO confidence", 0.1, 0.7, 0.28, 0.02, key="adv_conf") |
|
|
target_fps_fraction = st.slider("Process fraction of FPS (0.3β1.0)", 0.3, 1.0, 0.8, 0.05, key="adv_fps") |
|
|
idle_motion_px = st.slider("Idle motion threshold (px)", 2, 25, 8, key="adv_idle") |
|
|
st.caption("Frames are paced to video FPS; UI shows results only after processing.") |
|
|
|
|
|
up = st.file_uploader("Upload a video (MP4/AVI/MOV/MKV)", type=["mp4","avi","mov","mkv"], key="video_upl") |
|
|
process_btn = st.button("Process Video", disabled=False, key="process_btn") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_video_silent(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int): |
|
|
st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S") |
|
|
run_id = st.session_state.current_run_id |
|
|
|
|
|
|
|
|
st.session_state.prev_boxes_by_name = {} |
|
|
st.session_state.act_votes = {} |
|
|
st.session_state.on_phone_start_ns = {} |
|
|
st.session_state.on_phone_accum_ns = {} |
|
|
st.session_state.emp_first_seen = set() |
|
|
st.session_state.last_seen_frame = {} |
|
|
st.session_state.idle_start_ts = {} |
|
|
st.session_state.did_checkout = set() |
|
|
st.session_state.last_run_assets = {"video_parts": [], "summary": ""} |
|
|
|
|
|
|
|
|
event_write_buffer = [] |
|
|
|
|
|
|
|
|
if isinstance(file_or_path, (str, Path)): |
|
|
cap = cv2.VideoCapture(str(file_or_path)) |
|
|
else: |
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file_or_path.name).suffix) as t: |
|
|
t.write(file_or_path.read()) |
|
|
tmp_path = t.name |
|
|
cap = cv2.VideoCapture(tmp_path) |
|
|
|
|
|
src_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 |
|
|
|
|
|
dt_target = 1.0 / (src_fps if src_fps>0 else 25.0) |
|
|
|
|
|
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) |
|
|
prog = st.progress(0.0) |
|
|
|
|
|
|
|
|
ret, first_frame = cap.read() |
|
|
if not ret: |
|
|
st.error("Could not read video.") |
|
|
return |
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, 0) |
|
|
|
|
|
H, W = first_frame.shape[:2] |
|
|
|
|
|
out_name = f"/tmp/processed_output_{run_id}.mp4" |
|
|
vw = _init_videowriter(out_name, src_fps, (W, H)) |
|
|
if not vw.isOpened(): |
|
|
st.error("Failed to initialize video writer.") |
|
|
return |
|
|
|
|
|
frame_no = 0 |
|
|
last_face_refresh = -999 |
|
|
t_last = time.time() |
|
|
|
|
|
|
|
|
while cap.isOpened(): |
|
|
ret, frame = cap.read() |
|
|
if not ret: break |
|
|
frame_no += 1 |
|
|
|
|
|
|
|
|
now = time.time() |
|
|
slip = dt_target - (now - t_last) |
|
|
if slip > 0: time.sleep(slip) |
|
|
t_last = time.time() |
|
|
|
|
|
infer = frame |
|
|
scale = 1.0 |
|
|
if W > MAX_PREVIEW_W: |
|
|
|
|
|
|
|
|
pass |
|
|
|
|
|
def inv_box(b): |
|
|
return b |
|
|
|
|
|
|
|
|
dets = run_yolo(model, infer, conf_thres=conf_thres) |
|
|
persons = [DetBox(d.cls, d.conf, inv_box(d.box)) for d in dets if d.cls == "person"] |
|
|
phones = [DetBox(d.cls, d.conf, inv_box(d.box)) for d in dets if d.cls in ("cell phone","mobile phone","phone","cellphone")] |
|
|
|
|
|
|
|
|
face_boxes = [] |
|
|
if (frame_no - last_face_refresh) >= FACE_UPDATE_EVERY_N: |
|
|
faces_rel = _detect_faces_mediapipe(infer) |
|
|
face_boxes = [inv_box(b[:4]) for b in faces_rel] |
|
|
last_face_refresh = frame_no |
|
|
|
|
|
vis = frame.copy() |
|
|
|
|
|
|
|
|
for idx, p in enumerate(persons): |
|
|
|
|
|
name = None |
|
|
for fb in face_boxes: |
|
|
fx1,fy1,fx2,fy2 = fb |
|
|
cx = (fx1+fx2)/2.0; cy=(fy1+fy2)/2.0 |
|
|
if p.box[0] <= cx <= p.box[2] and p.box[1] <= cy <= p.box[3]: |
|
|
emb = _face_embed_gray(frame, fb) |
|
|
if emb is not None: |
|
|
nm, score = recognize_from_db(emb, face_db, threshold=0.70) |
|
|
if nm: name = nm; break |
|
|
if not name: |
|
|
name = f"Unknown #{idx+1}" |
|
|
|
|
|
|
|
|
prev_box = st.session_state.prev_boxes_by_name.get(name) |
|
|
spd = center_speed(p.box, prev_box) |
|
|
st.session_state.prev_boxes_by_name[name] = p.box |
|
|
base_act = "Idle" if spd < float(idle_motion_px) else "Working" |
|
|
|
|
|
|
|
|
on_head = phone_near_head(p.box, phones, face_boxes) |
|
|
dq = st.session_state.act_votes.get(name) |
|
|
if dq is None: |
|
|
dq = deque(maxlen=PHONE_PERSIST_N) |
|
|
st.session_state.act_votes[name] = dq |
|
|
dq.append("phone" if on_head else "not") |
|
|
on_phone_stable = (dq.count("phone") >= PHONE_PERSIST_N) |
|
|
act = "On Phone" if on_phone_stable else base_act |
|
|
|
|
|
|
|
|
ensure_counter(name) |
|
|
c = st.session_state.emp_counters[name] |
|
|
dt = dt_target |
|
|
if base_act == "Idle": |
|
|
c["idle"] = c.get("idle",0.0) + dt |
|
|
if name not in st.session_state.idle_start_ts: |
|
|
st.session_state.idle_start_ts[name] = time.time() |
|
|
else: |
|
|
if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS: |
|
|
act = "Sleep" |
|
|
_open_ticket_if_sleep(name, c.get("sleep", 0.0)) |
|
|
else: |
|
|
st.session_state.idle_start_ts[name] = time.time() |
|
|
|
|
|
if act == "Working": |
|
|
c["working"] = c.get("working",0.0) + dt |
|
|
elif act == "Sleep": |
|
|
c["sleep"] = c.get("sleep",0.0) + dt |
|
|
|
|
|
|
|
|
now_ns = time.time_ns() |
|
|
if act == "On Phone": |
|
|
if st.session_state.on_phone_start_ns.get(name) is None: |
|
|
st.session_state.on_phone_start_ns[name] = now_ns |
|
|
else: |
|
|
delta = now_ns - int(st.session_state.on_phone_start_ns[name]) |
|
|
st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + delta |
|
|
st.session_state.on_phone_start_ns[name] = now_ns |
|
|
else: |
|
|
if st.session_state.on_phone_start_ns.get(name) is not None: |
|
|
st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + (now_ns - int(st.session_state.on_phone_start_ns[name])) |
|
|
st.session_state.on_phone_start_ns[name] = None |
|
|
|
|
|
|
|
|
if ("Unknown" not in name) and (name not in st.session_state.emp_first_seen): |
|
|
st.session_state.emp_first_seen.add(name) |
|
|
push_attendance_once("CheckIn", name, datetime.utcnow().isoformat()+"Z") |
|
|
|
|
|
|
|
|
st.session_state.last_seen_frame[name] = frame_no |
|
|
|
|
|
|
|
|
key = f"{DEFAULT_CAMERA_ID}|{DEFAULT_ZONE}|{name}|{act}" |
|
|
can_emit = True |
|
|
last = st.session_state.last_emit_map |
|
|
last_t = last.get(key); now_t2 = time.time() |
|
|
if last_t and (now_t2 - last_t) < EVENT_COOLDOWN_SEC: |
|
|
can_emit = False |
|
|
if can_emit: |
|
|
st.session_state.events = pd.concat([st.session_state.events, pd.DataFrame([{ |
|
|
"id": f"evt-{len(st.session_state.events)+1}", |
|
|
"ts": datetime.utcnow().isoformat()+"Z", |
|
|
"camera": DEFAULT_CAMERA_ID, |
|
|
"employee": name, |
|
|
"activity": act if act in ALLOWED_ACT else "Idle", |
|
|
"zone": DEFAULT_ZONE, |
|
|
"confidence": int(p.conf*100), |
|
|
"run_id": run_id |
|
|
}])], ignore_index=True) |
|
|
|
|
|
event_write_buffer.append({ |
|
|
"Activity__c": act if act in ALLOWED_ACT else "Idle", |
|
|
"Timestamp__c": datetime.utcnow().isoformat()+"Z", |
|
|
"Confidence__c": int(p.conf*100), |
|
|
"Edge_Event_ID__c": str(uuid.uuid4()), |
|
|
"EmployeeNameText__c": name if ("Unknown" not in name) else None, |
|
|
"OnPhoneNs__c": int(st.session_state.on_phone_accum_ns.get(name, 0)), |
|
|
"IdleUs__c": int(c.get("idle", 0.0) * 1_000_000), |
|
|
"Working_Seconds__c": int(c.get("working", 0.0)), |
|
|
}) |
|
|
last[key] = now_t2 |
|
|
|
|
|
|
|
|
draw_act = act if act in ALLOWED_ACT else "Idle" |
|
|
color = ACT_COLORS.get(draw_act, (120,120,120)) |
|
|
x1,y1,x2,y2 = p.box |
|
|
cv2.rectangle(vis,(x1,y1),(x2,y2), color, 2) |
|
|
|
|
|
wv = fmt_secs_short(c.get("working",0.0)) |
|
|
iv = fmt_secs_short(c.get("idle",0.0)) |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0)) |
|
|
if st.session_state.on_phone_start_ns.get(name) is not None: |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0)) |
|
|
pv = fmt_secs_short(pn_ns/1e9) |
|
|
sv = fmt_secs_short(c.get("sleep",0.0)) |
|
|
tag = f"{name} [{draw_act}] W|I|P|S = {wv}|{iv}|{pv}|{sv}" |
|
|
_overlay_label(vis, x1, y1, tag) |
|
|
|
|
|
|
|
|
gone = [] |
|
|
for nm, lastf in list(st.session_state.last_seen_frame.items()): |
|
|
if (frame_no - lastf) >= CHECKOUT_MISS_FRAMES and nm in st.session_state.emp_first_seen and nm not in st.session_state.did_checkout: |
|
|
st.session_state.did_checkout.add(nm) |
|
|
push_attendance_once("CheckOut", nm, datetime.utcnow().isoformat()+"Z") |
|
|
gone.append(nm) |
|
|
for nm in gone: |
|
|
st.session_state.last_seen_frame.pop(nm, None) |
|
|
|
|
|
|
|
|
if len(event_write_buffer) >= 30: |
|
|
push_events_batch(event_write_buffer[:30]) |
|
|
del event_write_buffer[:30] |
|
|
|
|
|
|
|
|
vw.write(vis) |
|
|
|
|
|
|
|
|
if total > 0 and frame_no % 10 == 0: |
|
|
prog.progress(min(1.0, frame_no/total)) |
|
|
|
|
|
cap.release() |
|
|
vw.release() |
|
|
prog.progress(1.0) |
|
|
|
|
|
|
|
|
if event_write_buffer: |
|
|
push_events_batch(event_write_buffer) |
|
|
|
|
|
|
|
|
metric_rows = [] |
|
|
for emp, c in st.session_state.emp_counters.items(): |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0)) |
|
|
if st.session_state.on_phone_start_ns.get(emp) is not None: |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0)) |
|
|
metric_rows.append({ |
|
|
"As_Of_Date__c": date.today().isoformat(), |
|
|
"Working_Sec__c": int(c.get("working",0.0)), |
|
|
"Idle_Us__c": int(c.get("idle",0.0) * 1_000_000), |
|
|
"On_Phones__c": int(pn_ns/1e9), |
|
|
"Total_Events__c": int((st.session_state.events["employee"]==emp).sum()), |
|
|
"Window__c": "Session", |
|
|
"Unique_Key__c": f"{run_id}_{emp}", |
|
|
"Notes__c": json.dumps({"instance":DEFAULT_INSTANCE, "floor":DEFAULT_FLOOR, "zone":DEFAULT_ZONE, "camera":DEFAULT_CAMERA_ID, "run_id": run_id})[:131000] |
|
|
}) |
|
|
push_metric_batch(metric_rows) |
|
|
|
|
|
|
|
|
ev_csv = save_events_csv(run_id) |
|
|
sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID) |
|
|
att_csv = save_attendance_csv() |
|
|
met_csv = save_metrics_csv() |
|
|
an_csv = save_anomalies_csv() |
|
|
|
|
|
|
|
|
parts = _split_file_if_needed(out_name) |
|
|
st.session_state.last_run_assets["video_parts"] = parts |
|
|
st.session_state.last_run_assets["summary"] = sum_csv |
|
|
|
|
|
st.success(f"Processed frames: {frame_no}") |
|
|
|
|
|
|
|
|
if process_btn: |
|
|
try: |
|
|
if up is not None: |
|
|
process_video_silent(up, conf_thres=conf_thres, fps_fraction=target_fps_fraction, idle_motion_px=idle_motion_px) |
|
|
else: |
|
|
fallback = "VID_2.mp4" |
|
|
if not Path(fallback).exists(): |
|
|
st.error("No video uploaded and VID_2.mp4 not found in app root.") |
|
|
else: |
|
|
process_video_silent(fallback, conf_thres=conf_thres, fps_fraction=target_fps_fraction, idle_motion_px=idle_motion_px) |
|
|
except Exception as e: |
|
|
st.error(f"Run failed: {e}") |
|
|
st.code(traceback.format_exc()) |
|
|
|
|
|
|
|
|
if st.session_state.last_run_assets["video_parts"]: |
|
|
st.subheader("Processed Video") |
|
|
|
|
|
for i, vp in enumerate(st.session_state.last_run_assets["video_parts"], start=1): |
|
|
with st.container(): |
|
|
if st.button(f"Play Processed Video (Part {i})", key=f"play_part_{i}"): |
|
|
with open(vp, "rb") as vf: |
|
|
st.video(vf.read()) |
|
|
|
|
|
|
|
|
c1,c2,c3,c4,c5 = st.columns(5) |
|
|
if st.session_state.csv_data["events"]: |
|
|
c1.download_button("Download Events CSV", data=st.session_state.csv_data["events"], file_name="events.csv") |
|
|
if st.session_state.csv_data["summary"]: |
|
|
c2.download_button("Download Summary CSV", data=st.session_state.csv_data["summary"], file_name="summary.csv") |
|
|
if st.session_state.csv_data["attendance"]: |
|
|
c3.download_button("Download Attendance CSV", data=st.session_state.csv_data["attendance"], file_name="attendance.csv") |
|
|
if st.session_state.csv_data["metrics"]: |
|
|
c4.download_button("Download Metrics CSV", data=st.session_state.csv_data["metrics"], file_name="metrics.csv") |
|
|
if st.session_state.csv_data["anomalies"]: |
|
|
c5.download_button("Download Anomalies CSV", data=st.session_state.csv_data["anomalies"], file_name="anomalies.csv") |
|
|
|
|
|
|
|
|
with tabs[1]: |
|
|
st.title("π₯ Employees") |
|
|
st.caption("Live counters built from the current session (W | I | P | S). Search & export supported.") |
|
|
|
|
|
rows = [] |
|
|
for name, c in sorted(st.session_state.emp_counters.items()): |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0)) |
|
|
if st.session_state.on_phone_start_ns.get(name) is not None: |
|
|
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0)) |
|
|
rows.append({ |
|
|
"Employee": name, |
|
|
"Working (sec)": int(c.get("working",0.0)), |
|
|
"Idle (sec)": int(c.get("idle",0.0)), |
|
|
"On Phone (sec)": int(pn_ns/1e9), |
|
|
"Sleep (sec)": int(c.get("sleep",0.0)), |
|
|
"At Desk?": "Yes" if name in st.session_state.last_seen_frame else "No", |
|
|
"Last Seen Frame": int(st.session_state.last_seen_frame.get(name, -1)), |
|
|
}) |
|
|
emp_df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Employee","Working (sec)","Idle (sec)","On Phone (sec)","Sleep (sec)","At Desk?","Last Seen Frame"]) |
|
|
|
|
|
q = st.text_input("Search employees", "", key="emp_search") |
|
|
if q: |
|
|
emp_df = emp_df[emp_df["Employee"].str.contains(q, case=False, na=False)] |
|
|
st.dataframe(emp_df, use_container_width=True, hide_index=True) |
|
|
|
|
|
col1, _ = st.columns(2) |
|
|
if not emp_df.empty: |
|
|
csv_bytes = emp_df.to_csv(index=False).encode("utf-8") |
|
|
col1.download_button("Export Employees CSV", data=csv_bytes, file_name="employees_live.csv") |
|
|
|
|
|
st.markdown("**Face DB:** " + (", ".join(sorted(face_db.keys())) if face_db else "β none β")) |
|
|
|
|
|
|
|
|
with tabs[2]: |
|
|
st.title("π Reports") |
|
|
st.caption("Charts & exports created from real session events and attendance/metrics CSV queues.") |
|
|
|
|
|
ev = st.session_state.events.copy() |
|
|
|
|
|
c1,c2,c3,c4,c5 = st.columns(5) |
|
|
c1.metric("Present (unique people)", value=ev['employee'].nunique() if not ev.empty else 0) |
|
|
c2.metric("Events", value=len(ev)) |
|
|
|
|
|
met_df = pd.DataFrame(st.session_state.metric_rows) if st.session_state.metric_rows else pd.DataFrame(columns=["Working_Sec__c","Idle_Us__c","On_Phones__c","Unique_Key__c"]) |
|
|
if not met_df.empty: |
|
|
c3.metric("Avg Working (sec)", int(met_df["Working_Sec__c"].mean())) |
|
|
c4.metric("Avg Idle (sec)", int((met_df["Idle_Us__c"]/1_000_000).mean())) |
|
|
c5.metric("On Phone (sec avg)", int(met_df["On_Phones__c"].mean())) |
|
|
else: |
|
|
c3.metric("Avg Working (sec)", 0) |
|
|
c4.metric("Avg Idle (sec)", 0) |
|
|
c5.metric("On Phone (sec avg)", 0) |
|
|
|
|
|
st.divider() |
|
|
|
|
|
if not ev.empty: |
|
|
pivot = ev.groupby(["employee","activity"]).size().unstack(fill_value=0) |
|
|
st.subheader("By Employee Γ Activity") |
|
|
st.dataframe(pivot, use_container_width=True) |
|
|
st.bar_chart(pivot, use_container_width=True) |
|
|
else: |
|
|
st.info("No events yet for this session.") |
|
|
|
|
|
st.divider() |
|
|
|
|
|
if not ev.empty: |
|
|
ev["minute"] = ev["ts"].str.slice(0,16) |
|
|
trend = ev.groupby("minute").size() |
|
|
st.subheader("Events β Trend") |
|
|
st.line_chart(trend, use_container_width=True) |
|
|
else: |
|
|
st.info("Trend will appear after you run a session.") |
|
|
|
|
|
st.divider() |
|
|
st.subheader("Report Builder") |
|
|
left, right = st.columns(2) |
|
|
start_date = left.date_input("Date range start", value=date.today()) |
|
|
end_date = right.date_input("Date range end", value=date.today()) |
|
|
|
|
|
ecol1, ecol2, ecol3 = st.columns(3) |
|
|
all_emps = sorted(ev["employee"].unique().tolist()) if not ev.empty else [] |
|
|
sel_emp = ecol1.selectbox("Employee", options=["All"]+all_emps, index=0) |
|
|
sel_act = ecol2.selectbox("Activity", options=["All"]+list(ALLOWED_ACT.keys()), index=0) |
|
|
sel_zone = ecol3.selectbox("Zone", options=["All"]+sorted(ev["zone"].unique()) if not ev.empty else ["All"], index=0) |
|
|
|
|
|
if not ev.empty: |
|
|
dfb = ev.copy() |
|
|
dfb["d"] = dfb["ts"].str.slice(0,10) |
|
|
dfb = dfb[(dfb["d"] >= start_date.isoformat()) & (dfb["d"] <= end_date.isoformat())] |
|
|
if sel_emp != "All": dfb = dfb[dfb["employee"] == sel_emp] |
|
|
if sel_act != "All": dfb = dfb[dfb["activity"] == sel_act] |
|
|
if sel_zone != "All": dfb = dfb[dfb["zone"] == sel_zone] |
|
|
dfb = dfb.drop(columns=["d"]) |
|
|
st.dataframe(dfb, use_container_width=True, hide_index=True, height=280) |
|
|
st.download_button("Export CSV", data=dfb.to_csv(index=False).encode("utf-8"), file_name="report_builder.csv") |
|
|
else: |
|
|
st.info("Run a session to populate the report builder table.") |
|
|
|
|
|
st.divider() |
|
|
colA, colB, colC = st.columns(3) |
|
|
att_csv = save_attendance_csv() |
|
|
met_csv = save_metrics_csv() |
|
|
if st.session_state.csv_data["attendance"]: |
|
|
colA.download_button("Download Attendance CSV (today)", data=st.session_state.csv_data["attendance"], file_name="attendance_today.csv") |
|
|
if st.session_state.csv_data["metrics"]: |
|
|
colB.download_button("Download Metrics CSV (today)", data=st.session_state.csv_data["metrics"], file_name="metrics_today.csv") |
|
|
if not (st.session_state.csv_data["attendance"] or st.session_state.csv_data["metrics"]): |
|
|
st.info("Attendance/Metrics CSVs will show after a Live run.") |
|
|
|
|
|
|
|
|
with tabs[3]: |
|
|
st.title("π© Anomalies (Admin)") |
|
|
st.caption("Sleep cases auto-surface here. Admin can raise to employee, add notes, and mark resolved.") |
|
|
|
|
|
with st.expander("Raise manual anomaly", expanded=False): |
|
|
name_opt = sorted(list(st.session_state.emp_counters.keys())) |
|
|
emp = st.selectbox("Employee", options=name_opt if name_opt else ["β"], index=0 if name_opt else 0, key="an_raise_emp") |
|
|
typ = st.selectbox("Type", options=["Sleep"], index=0, key="an_raise_type") |
|
|
sec = st.number_input("Observed seconds", min_value=0, value=60, step=5, key="an_raise_sec") |
|
|
note = st.text_input("Note (optional)", "", key="an_raise_note") |
|
|
if st.button("Create Ticket", key="an_raise_btn", disabled=not name_opt): |
|
|
ticket_id = st.session_state.anomaly_counter |
|
|
st.session_state.anomaly_counter += 1 |
|
|
st.session_state.anomalies.append({ |
|
|
"ticket_id": ticket_id, |
|
|
"employee": emp, |
|
|
"type": typ, |
|
|
"observed_sec": int(sec), |
|
|
"raised_by": "Admin", |
|
|
"raised_at": datetime.utcnow().isoformat() + "Z", |
|
|
"status": "Open", |
|
|
"history": [{"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": note or f"Manual {typ}"}], |
|
|
}) |
|
|
st.success(f"Ticket #{ticket_id} created for {emp}") |
|
|
|
|
|
an_rows = st.session_state.anomalies |
|
|
if an_rows: |
|
|
df_an = pd.DataFrame([ |
|
|
{ |
|
|
"Ticket": r["ticket_id"], |
|
|
"Employee": r["employee"], |
|
|
"Type": r["type"], |
|
|
"Observed (sec)": r.get("observed_sec", 0), |
|
|
"Raised By": r["raised_by"], |
|
|
"Raised At": r["raised_at"], |
|
|
"Status": r["status"], |
|
|
} for r in an_rows |
|
|
]) |
|
|
st.dataframe(df_an, use_container_width=True, hide_index=True) |
|
|
|
|
|
st.subheader("Ticket actions") |
|
|
ids = [r["ticket_id"] for r in an_rows] |
|
|
sel = st.selectbox("Select ticket", options=ids, index=0) |
|
|
action_col1, action_col2 = st.columns(2) |
|
|
with action_col1: |
|
|
msg = st.text_input("Add note / employee response", "", key="an_msg") |
|
|
if st.button("Add Note", key="an_add_note"): |
|
|
for r in an_rows: |
|
|
if r["ticket_id"] == sel: |
|
|
r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": msg or "(no text)"}) |
|
|
st.success("Note added") |
|
|
break |
|
|
with action_col2: |
|
|
if st.button("Mark Resolved", key="an_resolve"): |
|
|
for r in an_rows: |
|
|
if r["ticket_id"] == sel: |
|
|
r["status"] = "Resolved" |
|
|
r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg":"Resolved"}) |
|
|
st.success("Ticket resolved") |
|
|
break |
|
|
|
|
|
|
|
|
if st.session_state.csv_data["anomalies"]: |
|
|
st.download_button("Export Anomalies CSV", data=st.session_state.csv_data["anomalies"], file_name="anomalies_today.csv") |
|
|
|
|
|
st.divider() |
|
|
st.subheader("Ticket history") |
|
|
hsel = st.selectbox("Select ticket to view history", options=ids, index=0, key="an_hist_sel") |
|
|
for r in an_rows: |
|
|
if r["ticket_id"] == hsel: |
|
|
for entry in r["history"]: |
|
|
st.write(f"- **{entry['at']}** β’ {entry['by']}: {entry['msg']}") |
|
|
break |
|
|
else: |
|
|
st.info("No anomalies yet. Sleep events will auto-create tickets once thresholds are crossed.") |
|
|
|