Update app.py
Browse files
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
|
| 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 #
|
| 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 ""
|
| 248 |
df = df[df["run_id"] == run_id].copy()
|
| 249 |
-
if df.empty: return ""
|
| 250 |
out_path = f"/tmp/events_{run_id}.csv"
|
| 251 |
-
|
| 252 |
-
|
| 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 ""
|
| 258 |
df = df[df["run_id"] == run_id].copy()
|
| 259 |
-
if df.empty: return ""
|
| 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 |
-
|
| 273 |
-
|
| 274 |
-
return out_path, csv_bytes
|
| 275 |
|
| 276 |
-
def save_attendance_csv() ->
|
| 277 |
rows = st.session_state.attendance_rows
|
| 278 |
-
if not rows: return ""
|
| 279 |
df = pd.DataFrame(rows)
|
| 280 |
out_path = "/tmp/attendance_today.csv"
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
return out_path, csv_bytes
|
| 284 |
|
| 285 |
-
def save_metrics_csv() ->
|
| 286 |
rows = st.session_state.metric_rows
|
| 287 |
-
if not rows: return ""
|
| 288 |
df = pd.DataFrame(rows)
|
| 289 |
out_path = "/tmp/metrics_today.csv"
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
return out_path, csv_bytes
|
| 293 |
|
| 294 |
-
def save_anomalies_csv() ->
|
| 295 |
rows = st.session_state.anomalies
|
| 296 |
-
if not rows: return ""
|
| 297 |
df = pd.DataFrame(rows)
|
| 298 |
out_path = "/tmp/anomalies_today.csv"
|
| 299 |
-
|
| 300 |
-
|
| 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
|
| 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
|
| 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
|
| 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()
|
| 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 |
-
|
| 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] =
|
| 567 |
else:
|
| 568 |
-
|
|
|
|
| 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] =
|
| 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)) + (
|
| 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 |
-
#
|
| 648 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 687 |
-
ev_csv
|
| 688 |
-
sum_csv
|
| 689 |
-
att_csv
|
| 690 |
-
met_csv
|
| 691 |
-
an_csv
|
| 692 |
|
| 693 |
st.success(f"Processed frames: {frame_no}")
|
| 694 |
|
| 695 |
-
# PATCH
|
| 696 |
-
|
| 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 |
-
#
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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"),
|
|
|
|
| 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
|
| 843 |
-
met_csv
|
| 844 |
-
if
|
|
|
|
| 845 |
colA.download_button("Download Attendance CSV (today)", data=att_bytes, file_name=Path(att_csv).name, key="dl_att_today")
|
| 846 |
-
if
|
|
|
|
| 847 |
colB.download_button("Download Metrics CSV (today)", data=met_bytes, file_name=Path(met_csv).name, key="dl_met_today")
|
| 848 |
-
if not (
|
| 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
|
| 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 |
-
|
| 917 |
-
if
|
| 918 |
-
|
|
|
|
| 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
|