Update app.py
Browse files
app.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
# Smart Office Attendance β Patched + Tabs (
|
| 2 |
-
# - Live tab:
|
| 3 |
# per-employee check-in/out, event cooldown, batched events, metrics, CSV outputs.
|
| 4 |
# - Employees tab: table of live counters & status; search; CSV export.
|
| 5 |
# - Reports tab: charts + report builder from real session data; CSV export.
|
|
|
|
| 6 |
# - Face DB: loads ALL subfolders under ./employees (each subfolder = employee name).
|
| 7 |
-
# - Salesforce:
|
| 8 |
# ------------------------------------------------------------------------------------
|
| 9 |
|
| 10 |
import os, io, json, math, time, tempfile, traceback, uuid
|
|
@@ -20,10 +21,11 @@ from collections import deque
|
|
| 20 |
|
| 21 |
# ------------------------- Runtime switches -------------------------
|
| 22 |
ENABLE_SF = False # π keep SF code paths but do not call API
|
|
|
|
| 23 |
MAX_PREVIEW_W = 1280
|
| 24 |
FACE_UPDATE_EVERY_N = 4
|
| 25 |
EVENT_COOLDOWN_SEC = 1.0
|
| 26 |
-
PHONE_PERSIST_N = 3
|
| 27 |
SLEEP_IDLE_SECONDS = 60.0
|
| 28 |
CHECKOUT_MISS_FRAMES = 90
|
| 29 |
|
|
@@ -35,7 +37,7 @@ DEFAULT_CAMERA_ID = "CAM01"
|
|
| 35 |
# ------------------------- Page & Tabs -------------------------
|
| 36 |
st.set_page_config(page_title="Smart Office Attendance", page_icon="π₯", layout="wide")
|
| 37 |
|
| 38 |
-
tabs = st.tabs(["π’ Live", "π₯ Employees", "π Reports"])
|
| 39 |
|
| 40 |
# ------------------------- lazy model imports -------------------------
|
| 41 |
@st.cache_resource(show_spinner=False)
|
|
@@ -81,6 +83,8 @@ st.session_state.setdefault("logs", deque(maxlen=400))
|
|
| 81 |
st.session_state.setdefault("last_emit_map", {})
|
| 82 |
st.session_state.setdefault("attendance_rows", []) # local CSV for attendance
|
| 83 |
st.session_state.setdefault("metric_rows", []) # local CSV for metrics (session appends)
|
|
|
|
|
|
|
| 84 |
|
| 85 |
def log_ui(line, ok=True):
|
| 86 |
col = "π’" if ok else "π΄"
|
|
@@ -204,17 +208,20 @@ def center_speed(cur_box, prev_box):
|
|
| 204 |
return math.hypot(cx1 - cx0, cy1 - cy0)
|
| 205 |
|
| 206 |
def phone_near_head(person_box, phone_boxes, face_boxes):
|
|
|
|
| 207 |
px1, py1, px2, py2 = person_box
|
| 208 |
-
head_h = int(py1 + 0.
|
| 209 |
head_box = (px1, py1, px2, head_h)
|
| 210 |
exp_faces = []
|
| 211 |
for f in face_boxes:
|
| 212 |
fx1, fy1, fx2, fy2 = f
|
| 213 |
w = fx2 - fx1; h = fy2 - fy1
|
| 214 |
-
exp_faces.append((fx1 - w//
|
| 215 |
for ph in phone_boxes:
|
| 216 |
-
if any(iou(ph.box, f) > 0.
|
| 217 |
-
|
|
|
|
|
|
|
| 218 |
return False
|
| 219 |
|
| 220 |
def recognize_from_db(face_emb, db, threshold=0.70):
|
|
@@ -277,10 +284,17 @@ def save_metrics_csv() -> str:
|
|
| 277 |
df.to_csv(out_path, index=False)
|
| 278 |
return out_path
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
# ------------------------- βSalesforceβ shims (disabled) -------------------------
|
| 281 |
def push_events_batch(rows):
|
| 282 |
if not rows: return
|
| 283 |
-
# Just log locally
|
| 284 |
log_ui(f"(SF disabled) queued {len(rows)} Event rows to local CSV.", ok=True)
|
| 285 |
|
| 286 |
def push_metric_batch(rows):
|
|
@@ -289,7 +303,6 @@ def push_metric_batch(rows):
|
|
| 289 |
log_ui(f"(SF disabled) added {len(rows)} Metric rows to local CSV.", ok=True)
|
| 290 |
|
| 291 |
def push_attendance_once(kind: str, employee_name: str, ts_iso: str):
|
| 292 |
-
# Store to local list so we can export CSV later
|
| 293 |
st.session_state.attendance_rows.append({
|
| 294 |
"Type": kind, "EmployeeName": employee_name, "InTime": ts_iso
|
| 295 |
})
|
|
@@ -331,6 +344,39 @@ with tabs[0]:
|
|
| 331 |
if low == "working": return "Working"
|
| 332 |
return "Idle"
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
def process_video(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int):
|
| 335 |
st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
| 336 |
run_id = st.session_state.current_run_id
|
|
@@ -449,6 +495,8 @@ with tabs[0]:
|
|
| 449 |
else:
|
| 450 |
if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
|
| 451 |
act = "Sleep"
|
|
|
|
|
|
|
| 452 |
else:
|
| 453 |
st.session_state.idle_start_ts[name] = time.time()
|
| 454 |
|
|
@@ -457,11 +505,16 @@ with tabs[0]:
|
|
| 457 |
elif act == "Sleep":
|
| 458 |
c["sleep"] = c.get("sleep",0.0) + dt
|
| 459 |
|
| 460 |
-
# phone nanosecond timer
|
| 461 |
now_ns = time.time_ns()
|
| 462 |
if act == "On Phone":
|
| 463 |
if st.session_state.on_phone_start_ns.get(name) is None:
|
| 464 |
st.session_state.on_phone_start_ns[name] = now_ns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
else:
|
| 466 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 467 |
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]))
|
|
@@ -479,8 +532,8 @@ with tabs[0]:
|
|
| 479 |
key = f"{DEFAULT_CAMERA_ID}|{DEFAULT_ZONE}|{name}|{act}"
|
| 480 |
can_emit = True
|
| 481 |
last = st.session_state.last_emit_map
|
| 482 |
-
last_t = last.get(key);
|
| 483 |
-
if last_t and (
|
| 484 |
can_emit = False
|
| 485 |
if can_emit:
|
| 486 |
st.session_state.events = pd.concat([st.session_state.events, pd.DataFrame([{
|
|
@@ -505,9 +558,9 @@ with tabs[0]:
|
|
| 505 |
"IdleUs__c": int(c.get("idle", 0.0) * 1_000_000),
|
| 506 |
"Working_Seconds__c": int(c.get("working", 0.0)),
|
| 507 |
})
|
| 508 |
-
last[key] =
|
| 509 |
|
| 510 |
-
# draw overlay
|
| 511 |
draw_act = act if act in ALLOWED_ACT else "Idle"
|
| 512 |
color = ACT_COLORS.get(draw_act, (120,120,120))
|
| 513 |
x1,y1,x2,y2 = p.box
|
|
@@ -517,14 +570,12 @@ with tabs[0]:
|
|
| 517 |
iv = fmt_secs_short(c.get("idle",0.0))
|
| 518 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 519 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 520 |
-
|
|
|
|
| 521 |
pv = fmt_secs_short(pn_ns/1e9)
|
| 522 |
sv = fmt_secs_short(c.get("sleep",0.0))
|
| 523 |
-
top = max(0, y1-24)
|
| 524 |
tag = f"{name} [{draw_act}] W|I|P|S = {wv}|{iv}|{pv}|{sv}"
|
| 525 |
-
|
| 526 |
-
cv2.rectangle(vis, (x1, top), (x1+min(max(200,tw), x2-x1+120), top+20), (250,250,250), -1)
|
| 527 |
-
cv2.putText(vis, tag, (x1+5, top+14), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (30,30,30), 1, cv2.LINE_AA)
|
| 528 |
|
| 529 |
# checkouts (not seen recently)
|
| 530 |
gone = []
|
|
@@ -561,7 +612,7 @@ with tabs[0]:
|
|
| 561 |
for emp, c in st.session_state.emp_counters.items():
|
| 562 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
|
| 563 |
if st.session_state.on_phone_start_ns.get(emp) is not None:
|
| 564 |
-
pn_ns
|
| 565 |
metric_rows.append({
|
| 566 |
"As_Of_Date__c": date.today().isoformat(),
|
| 567 |
"Working_Sec__c": int(c.get("working",0.0)),
|
|
@@ -579,13 +630,15 @@ with tabs[0]:
|
|
| 579 |
sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
|
| 580 |
att_csv = save_attendance_csv()
|
| 581 |
met_csv = save_metrics_csv()
|
|
|
|
| 582 |
|
| 583 |
st.success(f"Processed frames: {frame_no}")
|
| 584 |
-
c1,c2,c3,c4 = st.columns(
|
| 585 |
if ev_csv: c1.download_button("Download Events CSV", data=open(ev_csv,"rb").read(), file_name=Path(ev_csv).name)
|
| 586 |
if sum_csv: c2.download_button("Download Summary CSV", data=open(sum_csv,"rb").read(), file_name=Path(sum_csv).name)
|
| 587 |
if att_csv: c3.download_button("Download Attendance CSV", data=open(att_csv,"rb").read(), file_name=Path(att_csv).name)
|
| 588 |
if met_csv: c4.download_button("Download Metrics CSV", data=open(met_csv,"rb").read(), file_name=Path(met_csv).name)
|
|
|
|
| 589 |
|
| 590 |
if process_btn:
|
| 591 |
try:
|
|
@@ -611,7 +664,7 @@ with tabs[1]:
|
|
| 611 |
for name, c in sorted(st.session_state.emp_counters.items()):
|
| 612 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 613 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 614 |
-
pn_ns
|
| 615 |
rows.append({
|
| 616 |
"Employee": name,
|
| 617 |
"Working (sec)": int(c.get("working",0.0)),
|
|
@@ -662,7 +715,7 @@ with tabs[2]:
|
|
| 662 |
|
| 663 |
st.divider()
|
| 664 |
|
| 665 |
-
# Activity by employee
|
| 666 |
if not ev.empty:
|
| 667 |
pivot = ev.groupby(["employee","activity"]).size().unstack(fill_value=0)
|
| 668 |
st.subheader("By Employee Γ Activity")
|
|
@@ -695,7 +748,6 @@ with tabs[2]:
|
|
| 695 |
all_emps = sorted(ev["employee"].unique().tolist()) if not ev.empty else []
|
| 696 |
sel_emp = ecol1.selectbox("Employee", options=["All"]+all_emps, index=0)
|
| 697 |
sel_act = ecol2.selectbox("Activity", options=["All"]+list(ALLOWED_ACT.keys()), index=0)
|
| 698 |
-
# zone placeholder
|
| 699 |
sel_zone = ecol3.selectbox("Zone", options=["All"]+sorted(ev["zone"].unique()) if not ev.empty else ["All"], index=0)
|
| 700 |
|
| 701 |
# Build filtered table
|
|
@@ -724,4 +776,85 @@ with tabs[2]:
|
|
| 724 |
if not (att_csv or met_csv):
|
| 725 |
st.info("Attendance/Metrics CSVs will show after a Live run.")
|
| 726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
# ------------------------- end of file -------------------------
|
|
|
|
| 1 |
+
# Smart Office Attendance β Patched + Tabs (+ Anomalies)
|
| 2 |
+
# - Live tab: original pipeline (YOLOv8 + MediaPipe) with W|I|P|S counters,
|
| 3 |
# per-employee check-in/out, event cooldown, batched events, metrics, CSV outputs.
|
| 4 |
# - Employees tab: table of live counters & status; search; CSV export.
|
| 5 |
# - Reports tab: charts + report builder from real session data; CSV export.
|
| 6 |
+
# - Anomalies tab: auto-surface Sleep cases -> Admin can raise, comment, resolve (local-only).
|
| 7 |
# - Face DB: loads ALL subfolders under ./employees (each subfolder = employee name).
|
| 8 |
+
# - Salesforce: disabled (code paths retained; everything stored locally to CSV lists).
|
| 9 |
# ------------------------------------------------------------------------------------
|
| 10 |
|
| 11 |
import os, io, json, math, time, tempfile, traceback, uuid
|
|
|
|
| 21 |
|
| 22 |
# ------------------------- Runtime switches -------------------------
|
| 23 |
ENABLE_SF = False # π keep SF code paths but do not call API
|
| 24 |
+
|
| 25 |
MAX_PREVIEW_W = 1280
|
| 26 |
FACE_UPDATE_EVERY_N = 4
|
| 27 |
EVENT_COOLDOWN_SEC = 1.0
|
| 28 |
+
PHONE_PERSIST_N = 2 # β from 3 to 2 so "On Phone" stabilizes quicker
|
| 29 |
SLEEP_IDLE_SECONDS = 60.0
|
| 30 |
CHECKOUT_MISS_FRAMES = 90
|
| 31 |
|
|
|
|
| 37 |
# ------------------------- Page & Tabs -------------------------
|
| 38 |
st.set_page_config(page_title="Smart Office Attendance", page_icon="π₯", layout="wide")
|
| 39 |
|
| 40 |
+
tabs = st.tabs(["π’ Live", "π₯ Employees", "π Reports", "π© Anomalies"])
|
| 41 |
|
| 42 |
# ------------------------- lazy model imports -------------------------
|
| 43 |
@st.cache_resource(show_spinner=False)
|
|
|
|
| 83 |
st.session_state.setdefault("last_emit_map", {})
|
| 84 |
st.session_state.setdefault("attendance_rows", []) # local CSV for attendance
|
| 85 |
st.session_state.setdefault("metric_rows", []) # local CSV for metrics (session appends)
|
| 86 |
+
st.session_state.setdefault("anomalies", []) # anomaly tickets (local workflow)
|
| 87 |
+
st.session_state.setdefault("anomaly_counter", 1) # incremental id
|
| 88 |
|
| 89 |
def log_ui(line, ok=True):
|
| 90 |
col = "π’" if ok else "π΄"
|
|
|
|
| 208 |
return math.hypot(cx1 - cx0, cy1 - cy0)
|
| 209 |
|
| 210 |
def phone_near_head(person_box, phone_boxes, face_boxes):
|
| 211 |
+
"""Phone considered 'near head' if overlaps any expanded face box or upper half of the person box."""
|
| 212 |
px1, py1, px2, py2 = person_box
|
| 213 |
+
head_h = int(py1 + 0.55 * (py2 - py1)) # a bit deeper into upper body for robustness
|
| 214 |
head_box = (px1, py1, px2, head_h)
|
| 215 |
exp_faces = []
|
| 216 |
for f in face_boxes:
|
| 217 |
fx1, fy1, fx2, fy2 = f
|
| 218 |
w = fx2 - fx1; h = fy2 - fy1
|
| 219 |
+
exp_faces.append((fx1 - w//5, fy1 - h//5, fx2 + w//5, fy2 + h//5))
|
| 220 |
for ph in phone_boxes:
|
| 221 |
+
if any(iou(ph.box, f) > 0.04 for f in exp_faces): # slightly looser
|
| 222 |
+
return True
|
| 223 |
+
if iou(ph.box, head_box) > 0.06:
|
| 224 |
+
return True
|
| 225 |
return False
|
| 226 |
|
| 227 |
def recognize_from_db(face_emb, db, threshold=0.70):
|
|
|
|
| 284 |
df.to_csv(out_path, index=False)
|
| 285 |
return out_path
|
| 286 |
|
| 287 |
+
def save_anomalies_csv() -> str:
|
| 288 |
+
rows = st.session_state.anomalies
|
| 289 |
+
if not rows: return ""
|
| 290 |
+
df = pd.DataFrame(rows)
|
| 291 |
+
out_path = "/tmp/anomalies_today.csv"
|
| 292 |
+
df.to_csv(out_path, index=False)
|
| 293 |
+
return out_path
|
| 294 |
+
|
| 295 |
# ------------------------- βSalesforceβ shims (disabled) -------------------------
|
| 296 |
def push_events_batch(rows):
|
| 297 |
if not rows: return
|
|
|
|
| 298 |
log_ui(f"(SF disabled) queued {len(rows)} Event rows to local CSV.", ok=True)
|
| 299 |
|
| 300 |
def push_metric_batch(rows):
|
|
|
|
| 303 |
log_ui(f"(SF disabled) added {len(rows)} Metric rows to local CSV.", ok=True)
|
| 304 |
|
| 305 |
def push_attendance_once(kind: str, employee_name: str, ts_iso: str):
|
|
|
|
| 306 |
st.session_state.attendance_rows.append({
|
| 307 |
"Type": kind, "EmployeeName": employee_name, "InTime": ts_iso
|
| 308 |
})
|
|
|
|
| 344 |
if low == "working": return "Working"
|
| 345 |
return "Idle"
|
| 346 |
|
| 347 |
+
def _overlay_label(img, x1, y1, text):
|
| 348 |
+
"""Draw a white box sized to text using cv2.getTextSize and put text on top."""
|
| 349 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 350 |
+
scale = 0.45
|
| 351 |
+
thickness = 1
|
| 352 |
+
(tw, th), baseline = cv2.getTextSize(text, font, scale, thickness)
|
| 353 |
+
pad_x, pad_y = 8, 6
|
| 354 |
+
box_w = tw + 2*pad_x
|
| 355 |
+
box_h = th + baseline + 2*pad_y
|
| 356 |
+
top = max(0, y1 - (box_h + 4))
|
| 357 |
+
cv2.rectangle(img, (x1, top), (x1 + box_w, top + box_h), (250,250,250), -1)
|
| 358 |
+
cv2.putText(img, text, (x1 + pad_x, top + pad_y + th), font, scale, (30,30,30), thickness, cv2.LINE_AA)
|
| 359 |
+
|
| 360 |
+
def _open_ticket_if_sleep(name: str, seconds_sleep: float):
|
| 361 |
+
# Auto-surface as an "observed" anomaly if Sleep exceeds threshold within the run
|
| 362 |
+
if seconds_sleep >= SLEEP_IDLE_SECONDS:
|
| 363 |
+
# avoid duplicates: only one open ticket per run+name
|
| 364 |
+
open_exists = any((t["employee"] == name and t["status"] == "Open") for t in st.session_state.anomalies)
|
| 365 |
+
if not open_exists:
|
| 366 |
+
ticket_id = st.session_state.anomaly_counter
|
| 367 |
+
st.session_state.anomaly_counter += 1
|
| 368 |
+
st.session_state.anomalies.append({
|
| 369 |
+
"ticket_id": ticket_id,
|
| 370 |
+
"employee": name,
|
| 371 |
+
"type": "Sleep",
|
| 372 |
+
"observed_sec": int(seconds_sleep),
|
| 373 |
+
"raised_by": "Admin",
|
| 374 |
+
"raised_at": datetime.utcnow().isoformat() + "Z",
|
| 375 |
+
"status": "Open",
|
| 376 |
+
"history": [{"at": datetime.utcnow().isoformat()+"Z", "by": "System", "msg": f"Auto-detected Sleep >= {SLEEP_IDLE_SECONDS}s"}],
|
| 377 |
+
})
|
| 378 |
+
log_ui(f"Anomaly opened for {name} (Sleep)", ok=False)
|
| 379 |
+
|
| 380 |
def process_video(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int):
|
| 381 |
st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
| 382 |
run_id = st.session_state.current_run_id
|
|
|
|
| 495 |
else:
|
| 496 |
if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
|
| 497 |
act = "Sleep"
|
| 498 |
+
# surface anomaly candidate
|
| 499 |
+
_open_ticket_if_sleep(name, c.get("sleep", 0.0))
|
| 500 |
else:
|
| 501 |
st.session_state.idle_start_ts[name] = time.time()
|
| 502 |
|
|
|
|
| 505 |
elif act == "Sleep":
|
| 506 |
c["sleep"] = c.get("sleep",0.0) + dt
|
| 507 |
|
| 508 |
+
# phone nanosecond timer (continuous accumulation)
|
| 509 |
now_ns = time.time_ns()
|
| 510 |
if act == "On Phone":
|
| 511 |
if st.session_state.on_phone_start_ns.get(name) is None:
|
| 512 |
st.session_state.on_phone_start_ns[name] = now_ns
|
| 513 |
+
else:
|
| 514 |
+
# accumulate while active so Employees/overlay keep increasing smoothly
|
| 515 |
+
delta = now_ns - int(st.session_state.on_phone_start_ns[name])
|
| 516 |
+
st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + delta
|
| 517 |
+
st.session_state.on_phone_start_ns[name] = now_ns
|
| 518 |
else:
|
| 519 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 520 |
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]))
|
|
|
|
| 532 |
key = f"{DEFAULT_CAMERA_ID}|{DEFAULT_ZONE}|{name}|{act}"
|
| 533 |
can_emit = True
|
| 534 |
last = st.session_state.last_emit_map
|
| 535 |
+
last_t = last.get(key); now_t2 = time.time()
|
| 536 |
+
if last_t and (now_t2 - last_t) < EVENT_COOLDOWN_SEC:
|
| 537 |
can_emit = False
|
| 538 |
if can_emit:
|
| 539 |
st.session_state.events = pd.concat([st.session_state.events, pd.DataFrame([{
|
|
|
|
| 558 |
"IdleUs__c": int(c.get("idle", 0.0) * 1_000_000),
|
| 559 |
"Working_Seconds__c": int(c.get("working", 0.0)),
|
| 560 |
})
|
| 561 |
+
last[key] = now_t2
|
| 562 |
|
| 563 |
+
# draw overlay (box width computed from text size)
|
| 564 |
draw_act = act if act in ALLOWED_ACT else "Idle"
|
| 565 |
color = ACT_COLORS.get(draw_act, (120,120,120))
|
| 566 |
x1,y1,x2,y2 = p.box
|
|
|
|
| 570 |
iv = fmt_secs_short(c.get("idle",0.0))
|
| 571 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 572 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 573 |
+
# the continuous accumulation path already bumps the counter each frame
|
| 574 |
+
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 575 |
pv = fmt_secs_short(pn_ns/1e9)
|
| 576 |
sv = fmt_secs_short(c.get("sleep",0.0))
|
|
|
|
| 577 |
tag = f"{name} [{draw_act}] W|I|P|S = {wv}|{iv}|{pv}|{sv}"
|
| 578 |
+
_overlay_label(vis, x1, y1, tag)
|
|
|
|
|
|
|
| 579 |
|
| 580 |
# checkouts (not seen recently)
|
| 581 |
gone = []
|
|
|
|
| 612 |
for emp, c in st.session_state.emp_counters.items():
|
| 613 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
|
| 614 |
if st.session_state.on_phone_start_ns.get(emp) is not None:
|
| 615 |
+
pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0)) # already accumulated
|
| 616 |
metric_rows.append({
|
| 617 |
"As_Of_Date__c": date.today().isoformat(),
|
| 618 |
"Working_Sec__c": int(c.get("working",0.0)),
|
|
|
|
| 630 |
sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
|
| 631 |
att_csv = save_attendance_csv()
|
| 632 |
met_csv = save_metrics_csv()
|
| 633 |
+
an_csv = save_anomalies_csv()
|
| 634 |
|
| 635 |
st.success(f"Processed frames: {frame_no}")
|
| 636 |
+
c1,c2,c3,c4,c5 = st.columns(5)
|
| 637 |
if ev_csv: c1.download_button("Download Events CSV", data=open(ev_csv,"rb").read(), file_name=Path(ev_csv).name)
|
| 638 |
if sum_csv: c2.download_button("Download Summary CSV", data=open(sum_csv,"rb").read(), file_name=Path(sum_csv).name)
|
| 639 |
if att_csv: c3.download_button("Download Attendance CSV", data=open(att_csv,"rb").read(), file_name=Path(att_csv).name)
|
| 640 |
if met_csv: c4.download_button("Download Metrics CSV", data=open(met_csv,"rb").read(), file_name=Path(met_csv).name)
|
| 641 |
+
if an_csv: c5.download_button("Download Anomalies CSV", data=open(an_csv,"rb").read(), file_name=Path(an_csv).name)
|
| 642 |
|
| 643 |
if process_btn:
|
| 644 |
try:
|
|
|
|
| 664 |
for name, c in sorted(st.session_state.emp_counters.items()):
|
| 665 |
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 666 |
if st.session_state.on_phone_start_ns.get(name) is not None:
|
| 667 |
+
pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
|
| 668 |
rows.append({
|
| 669 |
"Employee": name,
|
| 670 |
"Working (sec)": int(c.get("working",0.0)),
|
|
|
|
| 715 |
|
| 716 |
st.divider()
|
| 717 |
|
| 718 |
+
# Activity by employee
|
| 719 |
if not ev.empty:
|
| 720 |
pivot = ev.groupby(["employee","activity"]).size().unstack(fill_value=0)
|
| 721 |
st.subheader("By Employee Γ Activity")
|
|
|
|
| 748 |
all_emps = sorted(ev["employee"].unique().tolist()) if not ev.empty else []
|
| 749 |
sel_emp = ecol1.selectbox("Employee", options=["All"]+all_emps, index=0)
|
| 750 |
sel_act = ecol2.selectbox("Activity", options=["All"]+list(ALLOWED_ACT.keys()), index=0)
|
|
|
|
| 751 |
sel_zone = ecol3.selectbox("Zone", options=["All"]+sorted(ev["zone"].unique()) if not ev.empty else ["All"], index=0)
|
| 752 |
|
| 753 |
# Build filtered table
|
|
|
|
| 776 |
if not (att_csv or met_csv):
|
| 777 |
st.info("Attendance/Metrics CSVs will show after a Live run.")
|
| 778 |
|
| 779 |
+
# ------------------------- ANOMALIES TAB -------------------------
|
| 780 |
+
with tabs[3]:
|
| 781 |
+
st.title("π© Anomalies (Admin)")
|
| 782 |
+
st.caption("Sleep cases auto-surface here. Admin can raise to employee, add notes, and mark resolved.")
|
| 783 |
+
|
| 784 |
+
# Quick raise form (manual)
|
| 785 |
+
with st.expander("Raise manual anomaly", expanded=False):
|
| 786 |
+
name_opt = sorted(list(st.session_state.emp_counters.keys()))
|
| 787 |
+
emp = st.selectbox("Employee", options=name_opt if name_opt else ["β"], index=0 if name_opt else 0, key="an_raise_emp")
|
| 788 |
+
typ = st.selectbox("Type", options=["Sleep"], index=0, key="an_raise_type")
|
| 789 |
+
sec = st.number_input("Observed seconds", min_value=0, value=60, step=5, key="an_raise_sec")
|
| 790 |
+
note = st.text_input("Note (optional)", "", key="an_raise_note")
|
| 791 |
+
if st.button("Create Ticket", key="an_raise_btn", disabled=not name_opt):
|
| 792 |
+
ticket_id = st.session_state.anomaly_counter
|
| 793 |
+
st.session_state.anomaly_counter += 1
|
| 794 |
+
st.session_state.anomalies.append({
|
| 795 |
+
"ticket_id": ticket_id,
|
| 796 |
+
"employee": emp,
|
| 797 |
+
"type": typ,
|
| 798 |
+
"observed_sec": int(sec),
|
| 799 |
+
"raised_by": "Admin",
|
| 800 |
+
"raised_at": datetime.utcnow().isoformat() + "Z",
|
| 801 |
+
"status": "Open",
|
| 802 |
+
"history": [{"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": note or f"Manual {typ}"}],
|
| 803 |
+
})
|
| 804 |
+
st.success(f"Ticket #{ticket_id} created for {emp}")
|
| 805 |
+
|
| 806 |
+
# Tickets table
|
| 807 |
+
an_rows = st.session_state.anomalies
|
| 808 |
+
if an_rows:
|
| 809 |
+
df_an = pd.DataFrame([
|
| 810 |
+
{
|
| 811 |
+
"Ticket": r["ticket_id"],
|
| 812 |
+
"Employee": r["employee"],
|
| 813 |
+
"Type": r["type"],
|
| 814 |
+
"Observed (sec)": r.get("observed_sec", 0),
|
| 815 |
+
"Raised By": r["raised_by"],
|
| 816 |
+
"Raised At": r["raised_at"],
|
| 817 |
+
"Status": r["status"],
|
| 818 |
+
} for r in an_rows
|
| 819 |
+
])
|
| 820 |
+
st.dataframe(df_an, use_container_width=True, hide_index=True)
|
| 821 |
+
|
| 822 |
+
# actions
|
| 823 |
+
st.subheader("Ticket actions")
|
| 824 |
+
ids = [r["ticket_id"] for r in an_rows]
|
| 825 |
+
sel = st.selectbox("Select ticket", options=ids, index=0)
|
| 826 |
+
action_col1, action_col2 = st.columns(2)
|
| 827 |
+
with action_col1:
|
| 828 |
+
msg = st.text_input("Add note / employee response", "", key="an_msg")
|
| 829 |
+
if st.button("Add Note", key="an_add_note"):
|
| 830 |
+
for r in an_rows:
|
| 831 |
+
if r["ticket_id"] == sel:
|
| 832 |
+
r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": msg or "(no text)"})
|
| 833 |
+
st.success("Note added")
|
| 834 |
+
break
|
| 835 |
+
with action_col2:
|
| 836 |
+
if st.button("Mark Resolved", key="an_resolve"):
|
| 837 |
+
for r in an_rows:
|
| 838 |
+
if r["ticket_id"] == sel:
|
| 839 |
+
r["status"] = "Resolved"
|
| 840 |
+
r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg":"Resolved"})
|
| 841 |
+
st.success("Ticket resolved")
|
| 842 |
+
break
|
| 843 |
+
|
| 844 |
+
csv_an = save_anomalies_csv()
|
| 845 |
+
if csv_an:
|
| 846 |
+
st.download_button("Export Anomalies CSV", data=open(csv_an,"rb").read(), file_name=Path(csv_an).name)
|
| 847 |
+
st.divider()
|
| 848 |
+
|
| 849 |
+
# show history
|
| 850 |
+
st.subheader("Ticket history")
|
| 851 |
+
hsel = st.selectbox("Select ticket to view history", options=ids, index=0, key="an_hist_sel")
|
| 852 |
+
for r in an_rows:
|
| 853 |
+
if r["ticket_id"] == hsel:
|
| 854 |
+
for entry in r["history"]:
|
| 855 |
+
st.write(f"- **{entry['at']}** β’ {entry['by']}: {entry['msg']}")
|
| 856 |
+
break
|
| 857 |
+
else:
|
| 858 |
+
st.info("No anomalies yet. Sleep events will auto-create tickets once thresholds are crossed.")
|
| 859 |
+
|
| 860 |
# ------------------------- end of file -------------------------
|