SuriRaja commited on
Commit
ce1bfe9
·
verified ·
1 Parent(s): 9e3254e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +117 -135
app.py CHANGED
@@ -6,11 +6,9 @@
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
- # - PATCH 1: Do NOT show frames live; record all overlays into mp4 at source FPS and autoplay after processing.
10
- # - PATCH 2: CSV download buttons use in-memory bytes to avoid refresh side-effects.
11
  # ------------------------------------------------------------------------------------
12
 
13
- import os, io, json, math, time, tempfile, traceback, uuid, base64
14
  from pathlib import Path
15
  from dataclasses import dataclass
16
  from datetime import datetime, date
@@ -27,7 +25,7 @@ ENABLE_SF = False # 🔕 keep SF code paths but do not call API
27
  MAX_PREVIEW_W = 1280
28
  FACE_UPDATE_EVERY_N = 4
29
  EVENT_COOLDOWN_SEC = 1.0
30
- PHONE_PERSIST_N = 2 # quicker "On Phone" stabilization
31
  SLEEP_IDLE_SECONDS = 60.0
32
  CHECKOUT_MISS_FRAMES = 90
33
 
@@ -87,7 +85,6 @@ st.session_state.setdefault("attendance_rows", []) # local CSV for atten
87
  st.session_state.setdefault("metric_rows", []) # local CSV for metrics (session appends)
88
  st.session_state.setdefault("anomalies", []) # anomaly tickets (local workflow)
89
  st.session_state.setdefault("anomaly_counter", 1) # incremental id
90
- st.session_state.setdefault("last_processed_video", None) # PATCH 1: path to processed mp4
91
 
92
  def log_ui(line, ok=True):
93
  col = "🟢" if ok else "🔴"
@@ -244,19 +241,18 @@ def fmt_secs_short(secs_float):
244
  # ------------------------- CSV helpers -------------------------
245
  def save_events_csv(run_id: str):
246
  df = st.session_state.events
247
- if df.empty: return "", b""
248
  df = df[df["run_id"] == run_id].copy()
249
- if df.empty: return "", b""
250
  out_path = f"/tmp/events_{run_id}.csv"
251
- csv_bytes = df.to_csv(index=False).encode("utf-8")
252
- Path(out_path).write_bytes(csv_bytes)
253
- return out_path, csv_bytes
254
 
255
  def save_run_summary_csv(run_id: str, site_floor: str, camera_name: str):
256
  df = st.session_state.events
257
- if df.empty: return "", b""
258
  df = df[df["run_id"] == run_id].copy()
259
- if df.empty: return "", b""
260
  counts = df.groupby(["employee","activity"]).size().unstack(fill_value=0)
261
  for col in ["Working","On Phone","Idle","Away","Sleep"]:
262
  if col not in counts.columns: counts[col] = 0
@@ -269,36 +265,32 @@ def save_run_summary_csv(run_id: str, site_floor: str, camera_name: str):
269
  out.insert(0,"run_id", run_id)
270
  out.insert(0,"date", date.today().isoformat())
271
  out_path = f"/tmp/run_summary_{run_id}.csv"
272
- csv_bytes = out.to_csv(index=False).encode("utf-8")
273
- Path(out_path).write_bytes(csv_bytes)
274
- return out_path, csv_bytes
275
 
276
- def save_attendance_csv() -> tuple[str, bytes]:
277
  rows = st.session_state.attendance_rows
278
- if not rows: return "", b""
279
  df = pd.DataFrame(rows)
280
  out_path = "/tmp/attendance_today.csv"
281
- csv_bytes = df.to_csv(index=False).encode("utf-8")
282
- Path(out_path).write_bytes(csv_bytes)
283
- return out_path, csv_bytes
284
 
285
- def save_metrics_csv() -> tuple[str, bytes]:
286
  rows = st.session_state.metric_rows
287
- if not rows: return "", b""
288
  df = pd.DataFrame(rows)
289
  out_path = "/tmp/metrics_today.csv"
290
- csv_bytes = df.to_csv(index=False).encode("utf-8")
291
- Path(out_path).write_bytes(csv_bytes)
292
- return out_path, csv_bytes
293
 
294
- def save_anomalies_csv() -> tuple[str, bytes]:
295
  rows = st.session_state.anomalies
296
- if not rows: return "", b""
297
  df = pd.DataFrame(rows)
298
  out_path = "/tmp/anomalies_today.csv"
299
- csv_bytes = df.to_csv(index=False).encode("utf-8")
300
- Path(out_path).write_bytes(csv_bytes)
301
- return out_path, csv_bytes
302
 
303
  # ------------------------- “Salesforce” shims (disabled) -------------------------
304
  def push_events_batch(rows):
@@ -385,35 +377,6 @@ with tabs[0]:
385
  })
386
  log_ui(f"Anomaly opened for {name} (Sleep)", ok=False)
387
 
388
- def _pick_video_writer(path: str, fps: float, size_wh: tuple[int,int]):
389
- """Try mp4v, then avc1, then XVID (avi fallback)."""
390
- w, h = size_wh
391
- # Primary: mp4
392
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
393
- vw = cv2.VideoWriter(path, fourcc, max(fps, 1.0), (w, h))
394
- if vw.isOpened():
395
- return vw, path
396
- # Try avc1
397
- fourcc = cv2.VideoWriter_fourcc(*"avc1")
398
- vw = cv2.VideoWriter(path, fourcc, max(fps, 1.0), (w, h))
399
- if vw.isOpened():
400
- return vw, path
401
- # Fallback to avi
402
- avi_path = Path(path).with_suffix(".avi").as_posix()
403
- fourcc = cv2.VideoWriter_fourcc(*"XVID")
404
- vw = cv2.VideoWriter(avi_path, fourcc, max(fps, 1.0), (w, h))
405
- return vw, avi_path
406
-
407
- def _autoplay_video_bytes(video_bytes: bytes):
408
- """Autoplay via HTML5 video tag (muted + playsinline)."""
409
- b64 = base64.b64encode(video_bytes).decode("utf-8")
410
- html = f"""
411
- <video controls autoplay muted playsinline style="width:100%;outline:none;border-radius:12px;">
412
- <source src="data:video/mp4;base64,{b64}" type="video/mp4">
413
- </video>
414
- """
415
- st.markdown(html, unsafe_allow_html=True)
416
-
417
  def process_video(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int):
418
  st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
419
  run_id = st.session_state.current_run_id
@@ -442,54 +405,56 @@ with tabs[0]:
442
 
443
  src_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
444
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  prog = st.progress(0.0)
 
446
  log_box = st.empty()
447
 
448
- # PATCH 1: set up VideoWriter to record annotated frames for later autoplay
449
- ret_first, first_frame = cap.read()
450
- if not ret_first:
451
- st.error("Could not read video.")
452
- return
453
- H0, W0 = first_frame.shape[:2]
454
- # compute processing scale (preview downscale still used for inference only)
455
- scale = 1.0
456
- if W0 > MAX_PREVIEW_W:
457
- scale = MAX_PREVIEW_W / W0
458
- # rewind to frame 0
459
- cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
460
- # processed video path
461
- out_vid_path = f"/tmp/processed_{run_id}.mp4"
462
- vw, out_vid_path = _pick_video_writer(out_vid_path, src_fps, (W0, H0))
463
- if not vw or not vw.isOpened():
464
- st.error("Failed to open VideoWriter for processed output.")
465
- return
466
-
467
  frame_no = 0
468
  last_face_refresh = -999
469
  t_last = time.time()
470
 
471
- # while loop
472
  while cap.isOpened():
473
  ret, frame = cap.read()
474
  if not ret: break
475
  frame_no += 1
476
 
477
- # pace to FPS (real-time)
478
  dt_target = 1.0 / (src_fps if src_fps>0 else 25.0)
479
  now = time.time()
480
  slip = dt_target - (now - t_last)
481
  if slip > 0: time.sleep(slip)
482
  t_last = time.time()
483
 
484
- # resize for inference if needed (recording uses original full-res frame)
485
  H, W = frame.shape[:2]
486
  infer = frame
 
487
  if W > MAX_PREVIEW_W:
 
488
  infer = cv2.resize(frame, (int(W*scale), int(H*scale)), interpolation=cv2.INTER_AREA)
489
 
490
  def inv_box(b):
491
- if infer is frame: # no scaling
492
- return b
493
  x1,y1,x2,y2 = b
494
  inv = 1.0 / scale
495
  return (int(x1*inv), int(y1*inv), int(x2*inv), int(y2*inv))
@@ -506,7 +471,7 @@ with tabs[0]:
506
  face_boxes = [inv_box(b[:4]) for b in faces_rel]
507
  last_face_refresh = frame_no
508
 
509
- vis = frame.copy() # annotated frame to be recorded
510
 
511
  # per person
512
  for idx, p in enumerate(persons):
@@ -550,6 +515,7 @@ with tabs[0]:
550
  else:
551
  if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
552
  act = "Sleep"
 
553
  _open_ticket_if_sleep(name, c.get("sleep", 0.0))
554
  else:
555
  st.session_state.idle_start_ts[name] = time.time()
@@ -560,17 +526,18 @@ with tabs[0]:
560
  c["sleep"] = c.get("sleep",0.0) + dt
561
 
562
  # phone nanosecond timer (continuous accumulation)
563
- now_ns = time.time_ns()
564
  if act == "On Phone":
565
  if st.session_state.on_phone_start_ns.get(name) is None:
566
- st.session_state.on_phone_start_ns[name] = now_ns
567
  else:
568
- delta = now_ns - int(st.session_state.on_phone_start_ns[name])
 
569
  st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + delta
570
- st.session_state.on_phone_start_ns[name] = now_ns
571
  else:
572
  if st.session_state.on_phone_start_ns.get(name) is not None:
573
- 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]))
574
  st.session_state.on_phone_start_ns[name] = None
575
 
576
  # attendance once per run for known names
@@ -644,23 +611,27 @@ with tabs[0]:
644
  push_events_batch(event_write_buffer[:30])
645
  del event_write_buffer[:30]
646
 
647
- # PATCH 1: record the annotated frame instead of showing live
648
- vw.write(vis)
649
-
650
- # progress + logs
651
  if total>0 and frame_no % 10 == 0:
652
  prog.progress(min(1.0, frame_no/total))
653
- if st.session_state.logs and (frame_no % 15 == 0):
654
  log_text = "\n".join(list(st.session_state.logs)[-8:])
655
  log_box.text_area("Run log", log_text, height=160, key=f"run_log_{int(time.time()*1000)}")
656
 
 
 
 
 
 
 
657
  cap.release()
658
- vw.release()
 
 
 
659
  prog.progress(1.0)
660
 
661
- # store path for later (if needed)
662
- st.session_state.last_processed_video = out_vid_path
663
-
664
  # flush remaining events (SF disabled – just log)
665
  if event_write_buffer:
666
  push_events_batch(event_write_buffer)
@@ -670,7 +641,7 @@ with tabs[0]:
670
  for emp, c in st.session_state.emp_counters.items():
671
  pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
672
  if st.session_state.on_phone_start_ns.get(emp) is not None:
673
- pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
674
  metric_rows.append({
675
  "As_Of_Date__c": date.today().isoformat(),
676
  "Working_Sec__c": int(c.get("working",0.0)),
@@ -683,35 +654,41 @@ with tabs[0]:
683
  })
684
  push_metric_batch(metric_rows)
685
 
686
- # CSV artifacts (PATCH 2: return bytes too)
687
- ev_csv, ev_bytes = save_events_csv(run_id)
688
- sum_csv, sum_bytes = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
689
- att_csv, att_bytes = save_attendance_csv()
690
- met_csv, met_bytes = save_metrics_csv()
691
- an_csv, an_bytes = save_anomalies_csv()
692
 
693
  st.success(f"Processed frames: {frame_no}")
694
 
695
- # PATCH 1: autoplay processed video
696
- try:
697
- video_bytes = Path(out_vid_path).read_bytes()
698
- _autoplay_video_bytes(video_bytes)
699
- st.download_button("Download Processed Video",
700
- data=video_bytes,
701
- file_name=Path(out_vid_path).name,
702
- mime="video/mp4",
703
- key=f"dl_vid_{run_id}")
704
- except Exception as e:
705
- st.warning(f"Video playback fallback. ({e})")
706
- st.video(out_vid_path)
707
 
708
- # Stable CSV downloads (in-memory bytes)
709
- c1,c2,c3,c4,c5 = st.columns(5)
710
- if ev_bytes: c1.download_button("Download Events CSV", data=ev_bytes, file_name=Path(ev_csv).name, key=f"dl_ev_{run_id}")
711
- if sum_bytes: c2.download_button("Download Summary CSV", data=sum_bytes, file_name=Path(sum_csv).name, key=f"dl_sum_{run_id}")
712
- if att_bytes: c3.download_button("Download Attendance CSV",data=att_bytes, file_name=Path(att_csv).name, key=f"dl_att_{run_id}")
713
- if met_bytes: c4.download_button("Download Metrics CSV", data=met_bytes, file_name=Path(met_csv).name, key=f"dl_met_{run_id}")
714
- if an_bytes: c5.download_button("Download Anomalies CSV", data=an_bytes, file_name=Path(an_csv).name, key=f"dl_an_{run_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
 
716
  if process_btn:
717
  try:
@@ -759,7 +736,7 @@ with tabs[1]:
759
  col1, col2 = st.columns(2)
760
  if not emp_df.empty:
761
  csv_bytes = emp_df.to_csv(index=False).encode("utf-8")
762
- col1.download_button("Export Employees CSV", data=csv_bytes, file_name="employees_live.csv", key="emp_export_csv")
763
 
764
  # Show who the face DB knows
765
  st.markdown("**Face DB:** " + (", ".join(sorted(face_db.keys())) if face_db else "— none —"))
@@ -775,6 +752,7 @@ with tabs[2]:
775
  c1,c2,c3,c4,c5 = st.columns(5)
776
  c1.metric("Present (unique people)", value=ev['employee'].nunique() if not ev.empty else 0)
777
  c2.metric("Events", value=len(ev))
 
778
  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"])
779
  if not met_df.empty:
780
  c3.metric("Avg Working (sec)", int(met_df["Working_Sec__c"].mean()))
@@ -832,20 +810,23 @@ with tabs[2]:
832
  if sel_zone != "All": dfb = dfb[dfb["zone"] == sel_zone]
833
  dfb = dfb.drop(columns=["d"])
834
  st.dataframe(dfb, use_container_width=True, hide_index=True, height=280)
835
- st.download_button("Export CSV", data=dfb.to_csv(index=False).encode("utf-8"), file_name="report_builder.csv", key="rb_export")
 
836
  else:
837
  st.info("Run a session to populate the report builder table.")
838
 
839
  st.divider()
840
  # One-click CSVs of today’s local stores
841
  colA, colB, colC = st.columns(3)
842
- att_csv, att_bytes = save_attendance_csv()
843
- met_csv, met_bytes = save_metrics_csv()
844
- if att_bytes:
 
845
  colA.download_button("Download Attendance CSV (today)", data=att_bytes, file_name=Path(att_csv).name, key="dl_att_today")
846
- if met_bytes:
 
847
  colB.download_button("Download Metrics CSV (today)", data=met_bytes, file_name=Path(met_csv).name, key="dl_met_today")
848
- if not (att_bytes or met_bytes):
849
  st.info("Attendance/Metrics CSVs will show after a Live run.")
850
 
851
  # ------------------------- ANOMALIES TAB -------------------------
@@ -894,7 +875,7 @@ with tabs[3]:
894
  # actions
895
  st.subheader("Ticket actions")
896
  ids = [r["ticket_id"] for r in an_rows]
897
- sel = st.selectbox("Select ticket", options=ids, index=0, key="an_sel_ticket")
898
  action_col1, action_col2 = st.columns(2)
899
  with action_col1:
900
  msg = st.text_input("Add note / employee response", "", key="an_msg")
@@ -913,9 +894,10 @@ with tabs[3]:
913
  st.success("Ticket resolved")
914
  break
915
 
916
- an_csv, an_bytes = save_anomalies_csv()
917
- if an_bytes:
918
- st.download_button("Export Anomalies CSV", data=an_bytes, file_name=Path(an_csv).name, key="dl_an_today")
 
919
  st.divider()
920
 
921
  # show history
 
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
12
  from pathlib import Path
13
  from dataclasses import dataclass
14
  from datetime import datetime, date
 
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
 
 
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 "🔴"
 
241
  # ------------------------- CSV helpers -------------------------
242
  def save_events_csv(run_id: str):
243
  df = st.session_state.events
244
+ if df.empty: return ""
245
  df = df[df["run_id"] == run_id].copy()
246
+ if df.empty: return ""
247
  out_path = f"/tmp/events_{run_id}.csv"
248
+ df.to_csv(out_path, index=False)
249
+ return out_path
 
250
 
251
  def save_run_summary_csv(run_id: str, site_floor: str, camera_name: str):
252
  df = st.session_state.events
253
+ if df.empty: return ""
254
  df = df[df["run_id"] == run_id].copy()
255
+ if df.empty: return ""
256
  counts = df.groupby(["employee","activity"]).size().unstack(fill_value=0)
257
  for col in ["Working","On Phone","Idle","Away","Sleep"]:
258
  if col not in counts.columns: counts[col] = 0
 
265
  out.insert(0,"run_id", run_id)
266
  out.insert(0,"date", date.today().isoformat())
267
  out_path = f"/tmp/run_summary_{run_id}.csv"
268
+ out.to_csv(out_path, index=False)
269
+ return out_path
 
270
 
271
+ def save_attendance_csv() -> str:
272
  rows = st.session_state.attendance_rows
273
+ if not rows: return ""
274
  df = pd.DataFrame(rows)
275
  out_path = "/tmp/attendance_today.csv"
276
+ df.to_csv(out_path, index=False)
277
+ return out_path
 
278
 
279
+ def save_metrics_csv() -> str:
280
  rows = st.session_state.metric_rows
281
+ if not rows: return ""
282
  df = pd.DataFrame(rows)
283
  out_path = "/tmp/metrics_today.csv"
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):
 
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
 
405
 
406
  src_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
407
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
408
+ # --- PATCH 1: Prepare processed video writer (real-time based on source FPS)
409
+ out_video_path = "processed_output.mp4"
410
+ try:
411
+ if os.path.exists(out_video_path):
412
+ os.remove(out_video_path)
413
+ except Exception:
414
+ pass
415
+ W0 = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 0
416
+ H0 = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 0
417
+ if W0 <= 0 or H0 <= 0:
418
+ # Fallback: probe one frame to get size
419
+ ret_probe, frame_probe = cap.read()
420
+ if not ret_probe:
421
+ st.error("Failed to read video.")
422
+ return
423
+ H0, W0 = frame_probe.shape[:2]
424
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # rewind
425
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
426
+ writer = cv2.VideoWriter(out_video_path, fourcc, (src_fps if src_fps>0 else 25.0), (W0, H0))
427
+
428
  prog = st.progress(0.0)
429
+ out_canvas = st.empty()
430
  log_box = st.empty()
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  frame_no = 0
433
  last_face_refresh = -999
434
  t_last = time.time()
435
 
 
436
  while cap.isOpened():
437
  ret, frame = cap.read()
438
  if not ret: break
439
  frame_no += 1
440
 
441
+ # pace to FPS
442
  dt_target = 1.0 / (src_fps if src_fps>0 else 25.0)
443
  now = time.time()
444
  slip = dt_target - (now - t_last)
445
  if slip > 0: time.sleep(slip)
446
  t_last = time.time()
447
 
448
+ # resize preview if needed
449
  H, W = frame.shape[:2]
450
  infer = frame
451
+ scale = 1.0
452
  if W > MAX_PREVIEW_W:
453
+ scale = MAX_PREVIEW_W / W
454
  infer = cv2.resize(frame, (int(W*scale), int(H*scale)), interpolation=cv2.INTER_AREA)
455
 
456
  def inv_box(b):
457
+ if scale == 1.0: return b
 
458
  x1,y1,x2,y2 = b
459
  inv = 1.0 / scale
460
  return (int(x1*inv), int(y1*inv), int(x2*inv), int(y2*inv))
 
471
  face_boxes = [inv_box(b[:4]) for b in faces_rel]
472
  last_face_refresh = frame_no
473
 
474
+ vis = frame.copy()
475
 
476
  # per person
477
  for idx, p in enumerate(persons):
 
515
  else:
516
  if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
517
  act = "Sleep"
518
+ # surface anomaly candidate
519
  _open_ticket_if_sleep(name, c.get("sleep", 0.0))
520
  else:
521
  st.session_state.idle_start_ts[name] = time.time()
 
526
  c["sleep"] = c.get("sleep",0.0) + dt
527
 
528
  # phone nanosecond timer (continuous accumulation)
529
+ now_ns2 = time.time_ns()
530
  if act == "On Phone":
531
  if st.session_state.on_phone_start_ns.get(name) is None:
532
+ st.session_state.on_phone_start_ns[name] = now_ns2
533
  else:
534
+ # accumulate while active so Employees/overlay keep increasing smoothly
535
+ delta = now_ns2 - int(st.session_state.on_phone_start_ns[name])
536
  st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + delta
537
+ st.session_state.on_phone_start_ns[name] = now_ns2
538
  else:
539
  if st.session_state.on_phone_start_ns.get(name) is not None:
540
+ st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + (now_ns2 - int(st.session_state.on_phone_start_ns[name]))
541
  st.session_state.on_phone_start_ns[name] = None
542
 
543
  # attendance once per run for known names
 
611
  push_events_batch(event_write_buffer[:30])
612
  del event_write_buffer[:30]
613
 
614
+ # UI live preview (unchanged)
615
+ out_canvas.image(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB), channels="RGB", use_column_width=True)
 
 
616
  if total>0 and frame_no % 10 == 0:
617
  prog.progress(min(1.0, frame_no/total))
618
+ if st.session_state.logs:
619
  log_text = "\n".join(list(st.session_state.logs)[-8:])
620
  log_box.text_area("Run log", log_text, height=160, key=f"run_log_{int(time.time()*1000)}")
621
 
622
+ # --- PATCH 1: Write processed frame to output video
623
+ try:
624
+ writer.write(vis)
625
+ except Exception:
626
+ pass
627
+
628
  cap.release()
629
+ try:
630
+ writer.release()
631
+ except Exception:
632
+ pass
633
  prog.progress(1.0)
634
 
 
 
 
635
  # flush remaining events (SF disabled – just log)
636
  if event_write_buffer:
637
  push_events_batch(event_write_buffer)
 
641
  for emp, c in st.session_state.emp_counters.items():
642
  pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
643
  if st.session_state.on_phone_start_ns.get(emp) is not None:
644
+ pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0)) # already accumulated
645
  metric_rows.append({
646
  "As_Of_Date__c": date.today().isoformat(),
647
  "Working_Sec__c": int(c.get("working",0.0)),
 
654
  })
655
  push_metric_batch(metric_rows)
656
 
657
+ # CSV artifacts
658
+ ev_csv = save_events_csv(run_id)
659
+ sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
660
+ att_csv = save_attendance_csv()
661
+ met_csv = save_metrics_csv()
662
+ an_csv = save_anomalies_csv()
663
 
664
  st.success(f"Processed frames: {frame_no}")
665
 
666
+ # --- PATCH 2: In-memory bytes for stable CSV downloads + Play button
667
+ c_play, c1,c2,c3,c4,c5 = st.columns(6)
 
 
 
 
 
 
 
 
 
 
668
 
669
+ # Play processed video button
670
+ if c_play.button("Play Processed Video", key=f"play_processed_{run_id}"):
671
+ if os.path.exists(out_video_path):
672
+ st.video(out_video_path)
673
+ else:
674
+ st.warning("processed_output.mp4 not found yet.")
675
+
676
+ # Prepare bytes once to avoid re-open jitter and rerun side-effects
677
+ if ev_csv:
678
+ with open(ev_csv, "rb") as f: ev_bytes = f.read()
679
+ c1.download_button("Download Events CSV", data=ev_bytes, file_name=Path(ev_csv).name, mime="text/csv", key=f"dl_evt_{run_id}")
680
+ if sum_csv:
681
+ with open(sum_csv, "rb") as f: sum_bytes = f.read()
682
+ c2.download_button("Download Summary CSV", data=sum_bytes, file_name=Path(sum_csv).name, mime="text/csv", key=f"dl_sum_{run_id}")
683
+ if att_csv:
684
+ with open(att_csv, "rb") as f: att_bytes = f.read()
685
+ c3.download_button("Download Attendance CSV", data=att_bytes, file_name=Path(att_csv).name, mime="text/csv", key=f"dl_att_{run_id}")
686
+ if met_csv:
687
+ with open(met_csv, "rb") as f: met_bytes = f.read()
688
+ c4.download_button("Download Metrics CSV", data=met_bytes, file_name=Path(met_csv).name, mime="text/csv", key=f"dl_met_{run_id}")
689
+ if an_csv:
690
+ with open(an_csv, "rb") as f: an_bytes = f.read()
691
+ c5.download_button("Download Anomalies CSV", data=an_bytes, file_name=Path(an_csv).name, mime="text/csv", key=f"dl_an_{run_id}")
692
 
693
  if process_btn:
694
  try:
 
736
  col1, col2 = st.columns(2)
737
  if not emp_df.empty:
738
  csv_bytes = emp_df.to_csv(index=False).encode("utf-8")
739
+ col1.download_button("Export Employees CSV", data=csv_bytes, file_name="employees_live.csv", key="emp_export_btn")
740
 
741
  # Show who the face DB knows
742
  st.markdown("**Face DB:** " + (", ".join(sorted(face_db.keys())) if face_db else "— none —"))
 
752
  c1,c2,c3,c4,c5 = st.columns(5)
753
  c1.metric("Present (unique people)", value=ev['employee'].nunique() if not ev.empty else 0)
754
  c2.metric("Events", value=len(ev))
755
+ # derive avg working/idle from metrics if present
756
  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"])
757
  if not met_df.empty:
758
  c3.metric("Avg Working (sec)", int(met_df["Working_Sec__c"].mean()))
 
810
  if sel_zone != "All": dfb = dfb[dfb["zone"] == sel_zone]
811
  dfb = dfb.drop(columns=["d"])
812
  st.dataframe(dfb, use_container_width=True, hide_index=True, height=280)
813
+ st.download_button("Export CSV", data=dfb.to_csv(index=False).encode("utf-8"),
814
+ file_name="report_builder.csv", key="report_builder_dl")
815
  else:
816
  st.info("Run a session to populate the report builder table.")
817
 
818
  st.divider()
819
  # One-click CSVs of today’s local stores
820
  colA, colB, colC = st.columns(3)
821
+ att_csv = save_attendance_csv()
822
+ met_csv = save_metrics_csv()
823
+ if att_csv:
824
+ with open(att_csv, "rb") as f: att_bytes = f.read()
825
  colA.download_button("Download Attendance CSV (today)", data=att_bytes, file_name=Path(att_csv).name, key="dl_att_today")
826
+ if met_csv:
827
+ with open(met_csv, "rb") as f: met_bytes = f.read()
828
  colB.download_button("Download Metrics CSV (today)", data=met_bytes, file_name=Path(met_csv).name, key="dl_met_today")
829
+ if not (att_csv or met_csv):
830
  st.info("Attendance/Metrics CSVs will show after a Live run.")
831
 
832
  # ------------------------- ANOMALIES TAB -------------------------
 
875
  # actions
876
  st.subheader("Ticket actions")
877
  ids = [r["ticket_id"] for r in an_rows]
878
+ sel = st.selectbox("Select ticket", options=ids, index=0)
879
  action_col1, action_col2 = st.columns(2)
880
  with action_col1:
881
  msg = st.text_input("Add note / employee response", "", key="an_msg")
 
894
  st.success("Ticket resolved")
895
  break
896
 
897
+ csv_an = save_anomalies_csv()
898
+ if csv_an:
899
+ with open(csv_an, "rb") as f: an_bytes = f.read()
900
+ st.download_button("Export Anomalies CSV", data=an_bytes, file_name=Path(csv_an).name, key="dl_an_today")
901
  st.divider()
902
 
903
  # show history