updated to DNN for face detections

This commit is contained in:
Subinacls 2026-06-05 14:02:43 -04:00
parent e3313dbc1b
commit fb012de76b
13 changed files with 733 additions and 64 deletions

92
.gitignore vendored
View File

@ -1,4 +1,10 @@
# Python
# ==========================================================================
# ICEYOU .gitignore
# Goal: never commit secrets, credentials, biometric/DNN data, captured
# media, logs, or local environment artifacts.
# ==========================================================================
# --- Python ---------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
@ -20,32 +26,96 @@ wheels/
.installed.cfg
*.egg
# Virtualenv
# --- Virtual environments -------------------------------------------------
.venv/
venv/
ENV/
env/
# Config & secrets
# ==========================================================================
# SECRETS & CONFIGURATION (NEVER COMMIT)
# ==========================================================================
# Real runtime config (contains email creds + unlock password).
config.json
*.log
config.*.json
config.local.json
*.local.json
# ...but keep the safe placeholder template tracked.
!config.example.json
!config.sample.json
# Environment / credential files
.env
.env.*
*.env
secrets*
*secret*
credentials*
*credentials*
*.pem
*.key
*.pfx
*.p12
token*
*.token
# ==========================================================================
# BIOMETRIC / FACE-RECOGNITION & DNN MODEL DATA (NEVER COMMIT)
# ==========================================================================
# Enrolled face images + all per-identity folders
faces/
faces/**
# Model + encoding artifacts (DNN / LBPH) wherever they live
*.pkl
*.dat
*.h5
*.onnx
*.caffemodel
*.pb
*.tflite
*.npy
*.npz
model.yml
trainer*.yml
face_encodings.pkl
dnn_labels.json
labels.json
# Temp images written during face-unlock verification
iceyou_dnn_*.png
iceyou_verify_*.png
# ==========================================================================
# CAPTURED MEDIA, LOGS & RUNTIME ARTIFACTS (NEVER COMMIT)
# ==========================================================================
snapshots/
motion_clips/
faces/
events.log
backup
backup/
*.log
events.log
iceyou_crash.log
# IDE
# Loose captured media
*.avi
*.mp4
*.mkv
*.mov
*.jpg
*.jpeg
*.png
*.bmp
# --- IDE / editor ---------------------------------------------------------
.vscode/
.idea/
*.swp
*.swo
# OS
# --- OS -------------------------------------------------------------------
Thumbs.db
.DS_Store
# PyInstaller
# --- PyInstaller ----------------------------------------------------------
*.spec
build/
dist/

View File

@ -144,7 +144,11 @@ Optional second unlock path next to the password.
- Hold a steady face ≥ 2 s — auto-capture
- **R** restart, **Q / ESC** quit
For glasses / headphones / hats, **re-run with the same `--name`** so LBPH learns all your looks under one identity. Each session appends to `faces/<label>/capture_NN.png` and re-trains `faces/model.yml`.
**Backend selection**:
- Default (`--backend lbph`): Trains the lightweight LBPH model (`faces/model.yml`).
- DNN (`--backend dnn`): Trains a much more accurate FaceNet-style model using `face_recognition` (requires `pip install face-recognition` + build tools on Windows). Saves to `faces/face_encodings.pkl`.
For glasses / headphones / hats, **re-run with the same `--name`** so the model learns all your looks under one identity.
3. **Enable in `config.json`**:
```json

13
backup
View File

@ -1,13 +0,0 @@
#backup
#ICEYOU2026!
#6509 3423
#9962 7773
#9291 7449
#8860 9723
#4286 2375
#9428 4672
#2884 3483
#6359 1603
#7872 4290
#2926 2500

View File

@ -37,6 +37,7 @@
"stop_monitoring": "<ctrl>+<alt>+s"
},
"face_recognition_enabled": false,
"face_recognition_backend": "lbph", # "lbph" or "dnn" (requires face-recognition package)
"face_recognition": {
"tolerance": 0.6,
"enrollment_images_dir": "faces/owner"

View File

@ -1,11 +1,12 @@
"""ICEYOU - Authorized User Face Enrollment
Captures multiple webcam photos of the authorized user from various angles,
saves them under faces/owner/, and trains an OpenCV LBPH face-recognition model.
saves them under faces/<label>/, and trains either an LBPH or DNN (FaceNet-style)
face recognition model.
Usage:
python face_enrollment.py
(optionally append --name <label> to enroll secondary authorized users)
python face_enrollment.py --name owner --backend lbph
python face_enrollment.py --name owner --backend dnn # requires face-recognition package
Controls in the live preview window:
SPACE - Capture the current frame (only if a face is detected)
@ -316,6 +317,8 @@ def main():
help="Camera device index (default: from config.json or 0)")
parser.add_argument("--list-cameras", action="store_true",
help="Probe and list available camera indices, then exit")
parser.add_argument("--backend", choices=["lbph", "dnn"], default="lbph",
help="Face recognition backend to train (lbph or dnn)")
args = parser.parse_args()
if args.list_cameras:
@ -330,6 +333,8 @@ def main():
user_label = args.name.strip().replace(" ", "_") or "owner"
user_dir = FACES_DIR / user_label
print(f"Using backend: {args.backend}")
if user_dir.exists() and any(user_dir.iterdir()):
resp = input(f"Existing enrollment for '{user_label}' found. Overwrite? [y/N]: ").strip().lower()
if resp != "y":
@ -349,12 +354,33 @@ def main():
return
print(f"\n{len(captures)} photo(s) saved to {user_dir}")
print("Training recognition model...")
if args.backend == "dnn":
print("Training DNN (face_recognition) model...")
try:
# Robust import when running face_enrollment.py directly
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from src.iceyou.dnn_face_recognizer import train_dnn_model
success = train_dnn_model(FACES_DIR)
if success:
print("DNN model trained successfully.")
else:
print("DNN training failed.")
except ImportError as e:
print(f"face_recognition package not installed or import error: {e}")
print("Install with: pip install face-recognition")
print("On Windows you may also need Visual Studio Build Tools + CMake.")
else:
print("Training LBPH model...")
train_model(FACES_DIR)
print("\nEnrollment complete.")
print("To enable recognition, set in config.json:")
print(' "face_recognition_enabled": true')
if args.backend == "dnn":
print(' "face_recognition_backend": "dnn"')
if __name__ == "__main__":

45
main.py
View File

@ -1,7 +1,10 @@
"""ICEYOU entry point - launches the system tray application."""
import sys
import threading
import traceback
from pathlib import Path
from datetime import datetime
# Add src to path for development
sys.path.insert(0, str(Path(__file__).parent / "src"))
@ -9,11 +12,53 @@ sys.path.insert(0, str(Path(__file__).parent / "src"))
from iceyou.tray_app import TrayApp
def log_crash_report(exc_type, exc_value, exc_traceback, thread_name="MainThread"):
"""Write a detailed crash report to both console and a log file."""
timestamp = datetime.now().isoformat()
report = []
report.append("\n" + "=" * 80)
report.append(f"!!! ICEYOU CRASH REPORT !!!")
report.append(f"Time: {timestamp}")
report.append(f"Thread: {thread_name}")
report.append("=" * 80)
report.append("".join(traceback.format_exception(exc_type, exc_value, exc_traceback)))
report.append("=" * 80 + "\n")
crash_text = "\n".join(report)
print(crash_text)
# Also write to a persistent crash log
try:
with open("iceyou_crash.log", "a", encoding="utf-8") as f:
f.write(crash_text)
except Exception:
pass
def global_exception_handler(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
log_crash_report(exc_type, exc_value, exc_traceback)
def thread_exception_handler(args):
log_crash_report(args.exc_type, args.exc_value, args.exc_traceback, thread_name=args.thread.name)
# Install handlers
sys.excepthook = global_exception_handler
threading.excepthook = thread_exception_handler
def main():
print("Starting ICEYOU Personal Monitor...")
print("A system tray icon will appear. Right-click for options.")
try:
app = TrayApp()
app.run()
except Exception as e:
log_crash_report(type(e), e, e.__traceback__)
if __name__ == "__main__":

View File

@ -9,8 +9,8 @@ Pillow>=10.3.0
python-json-logger>=2.0.7
# Optional but recommended for email alerts (uses stdlib smtplib + ssl)
# For face recognition (advanced "not me" detection):
# face-recognition>=1.3.0 # Requires dlib + build tools on Windows; see README
# For DNN-based face recognition (FaceNet / dlib) - much more accurate than LBPH
face-recognition>=1.3.0 # Requires dlib + build tools on Windows; see README
# Note: Run `pip install -r requirements.txt`
# For full face_recognition on Windows: Install Visual Studio Build Tools, CMake first.

View File

@ -127,6 +127,8 @@ class Camera:
if ii not in seen:
seen.add(ii)
ordered.append(ii)
self._requested_indices = ordered
self._probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5))
# Camera obstruction detection settings
obs = config.get("camera_obscured_detection", {}) or {}
@ -134,20 +136,32 @@ class Camera:
self.brightness_threshold = float(obs.get("brightness_threshold", 35))
self.variance_threshold = float(obs.get("variance_threshold", 10))
probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5))
self.streams: List[CameraStream] = []
for idx in ordered:
self._open_streams()
def _open_streams(self) -> None:
"""Internal: (re)probe and open the originally requested camera indices."""
self.streams = []
for idx in self._requested_indices:
stream = CameraStream(idx)
if stream.open(probe_timeout=probe_timeout):
if stream.open(probe_timeout=self._probe_timeout):
print(f"[Camera] index {idx} OPEN")
self.streams.append(stream)
else:
print(f"[Camera] index {idx} DEAD (skipped after {probe_timeout}s probe)")
# keep the dead stream object out of the list entirely
print(f"[Camera] index {idx} DEAD (skipped after {self._probe_timeout}s probe)")
if not self.alive_streams:
print("[Camera] WARNING: no working cameras detected")
def reopen(self) -> None:
"""Re-acquire camera streams after a previous release(). Safe to call multiple times."""
# Close any lingering handles first
for s in self.streams:
try:
s.release()
except Exception:
pass
self._open_streams()
# ---- discovery / accessors ----
@property

View File

@ -0,0 +1,323 @@
"""DNN-based face recognition using the face_recognition library (dlib + FaceNet-style model).
This provides significantly higher accuracy than the LBPH backend, especially
against look-alikes. It requires the `face-recognition` package (which depends
on dlib).
Installation on Windows is non-trivial:
- Install Visual Studio Build Tools + CMake first.
- Then: pip install face-recognition
See README for detailed instructions.
"""
from pathlib import Path
from typing import Optional, Tuple, List
import pickle
import numpy as np
try:
import face_recognition
FACE_RECOGNITION_AVAILABLE = True
except ImportError:
FACE_RECOGNITION_AVAILABLE = False
class DNNFaceRecognizer:
"""Face recognition using deep neural network embeddings (FaceNet style).
Much more robust than LBPH for distinguishing similar-looking people.
"""
def __init__(self, tolerance: float = 0.6, allowed_labels: Optional[List[str]] = None, faces_dir: str = "faces"):
self.tolerance = float(tolerance)
self.allowed_labels = set(allowed_labels) if allowed_labels else None
self.faces_dir = Path(faces_dir)
self.encodings_file = self.faces_dir / "face_encodings.pkl"
self.labels_file = self.faces_dir / "dnn_labels.json"
self._known_encodings: list = []
self._known_labels: list = []
self._loaded = False
self._load()
@property
def available(self) -> bool:
# Model must be loaded. dlib (face_recognition) itself only needs to be
# importable in the isolated worker subprocess, not in this process.
return self._loaded
def _load(self) -> None:
if not self.encodings_file.exists() or not self.labels_file.exists():
print("[DNNFaceRecognizer] No trained DNN model found. Run enrollment with DNN backend.")
return
try:
with open(self.encodings_file, "rb") as f:
self._known_encodings = pickle.load(f)
import json
with open(self.labels_file, "r", encoding="utf-8") as f:
self._known_labels = json.load(f)
self._loaded = True
print(f"[DNNFaceRecognizer] Loaded DNN model with {len(self._known_labels)} identities")
except Exception as e:
print(f"[DNNFaceRecognizer] Failed to load model: {e}")
def verify_frame(self, frame) -> Tuple[bool, Optional[str], Optional[float]]:
"""Verify a frame using DNN embeddings.
Returns (matched, label, distance). Lower distance = better match.
Extremely defensive to avoid crashing the Tkinter white screen thread.
"""
if not self.available or frame is None:
return False, None, None
try:
# Convert BGR to RGB
rgb_frame = frame[:, :, ::-1]
# Multiple safety checks
if rgb_frame is None or rgb_frame.size == 0:
return False, None, None
if len(rgb_frame.shape) != 3 or rgb_frame.shape[2] != 3:
return False, None, None
if rgb_frame.dtype != np.uint8:
rgb_frame = rgb_frame.astype(np.uint8)
# Detect faces
face_locations = face_recognition.face_locations(rgb_frame)
if not face_locations:
return False, None, None
face_encodings = face_recognition.face_encodings(rgb_frame, face_locations)
if not face_encodings:
return False, None, None
best_label = None
best_distance = None
for encoding in face_encodings:
try:
distances = face_recognition.face_distance(self._known_encodings, encoding)
if len(distances) == 0:
continue
min_distance_idx = int(np.argmin(distances))
min_distance = float(distances[min_distance_idx])
label = self._known_labels[min_distance_idx]
if self.allowed_labels and label not in self.allowed_labels:
continue
if best_distance is None or min_distance < best_distance:
best_distance = min_distance
best_label = label
except Exception as inner_e:
print(f"[DNNFaceRecognizer] inner distance error: {inner_e}")
continue
if best_label is None or best_distance is None:
return False, None, None
matched = best_distance <= self.tolerance
return matched, best_label, best_distance
except Exception as e:
print(f"[DNNFaceRecognizer] verify_frame error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc(limit=3)
return False, None, None
def verify_from_camera(self, camera, attempts: int = 3) -> Tuple[bool, Optional[str], Optional[float]]:
"""Capture several frames from ALL alive cameras and verify them in ONE
isolated subprocess.
Frames are captured here in the (safe) calling process, then the dlib
matching is delegated to a single subprocess so that (a) a native dlib
crash can never take down the main ICEYOU app, and (b) the model is
loaded only once per attempt (fast -> button re-enables quickly).
IMPORTANT: we sample EVERY working camera (not just the primary). On
multi-cam rigs the user-facing camera is often not index 0, so reading
only the primary stream caused face unlock to never see the user.
"""
if not self.available:
return False, None, None
import cv2
import tempfile
import os
rounds = max(1, attempts)
tmp_paths: list = []
try:
captured = 0
def _save(frame) -> None:
nonlocal captured
if frame is None:
return
fd, tmp_path = tempfile.mkstemp(suffix=".png", prefix="iceyou_dnn_")
os.close(fd)
if cv2.imwrite(tmp_path, frame):
tmp_paths.append(tmp_path)
captured += 1
else:
try:
os.remove(tmp_path)
except Exception:
pass
# Prefer per-camera capture so we sample every working stream.
read_all = getattr(camera, "read_all_frames", None)
for _ in range(rounds):
if callable(read_all):
frames = read_all() or {}
for _idx, frame in frames.items():
_save(frame)
else:
_save(camera.read_frame())
cam_count = 0
try:
cam_count = len(getattr(camera, "alive_streams", []) or [])
except Exception:
cam_count = 0
print(f"[DNNFaceRecognizer] Captured {captured} frame(s) across {cam_count or 1} camera(s) x {rounds} round(s) for face unlock")
if not tmp_paths:
print("[DNNFaceRecognizer] No usable camera frames; face unlock will report no match")
return False, None, None
return self._verify_images_subprocess(tmp_paths)
finally:
for p in tmp_paths:
try:
os.remove(p)
except Exception:
pass
def _verify_images_subprocess(self, image_paths: list) -> Tuple[bool, Optional[str], Optional[float]]:
"""Run dlib matching over the given images in a single subprocess.
Returns (matched, label, distance). On ANY failure (including a native
subprocess crash / non-zero exit) returns a safe no-match instead of
propagating, so the parent process never dies.
"""
import subprocess
import os
import sys
try:
allowed_csv = ",".join(sorted(self.allowed_labels)) if self.allowed_labels else ""
print(f"[DNNFaceRecognizer] Launching isolated DNN worker for {len(image_paths)} frame(s) (face recognition in subprocess)...")
cmd = [
sys.executable,
"-m", "iceyou.dnn_verify_worker",
"--encodings", str(self.encodings_file),
"--labels", str(self.labels_file),
"--tolerance", str(self.tolerance),
]
if allowed_csv:
cmd += ["--allowed", allowed_csv]
cmd += list(image_paths)
# Ensure the worker can import the iceyou package
env = dict(os.environ)
src_dir = str(Path(__file__).resolve().parent.parent)
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = src_dir + (os.pathsep + existing if existing else "")
# Suppress the console window flash on Windows
creationflags = 0
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
env=env,
creationflags=creationflags,
)
if proc.returncode != 0:
# Native crash / segfault in dlib -> isolated, parent survives
print(f"[DNNFaceRecognizer] worker exited with code {proc.returncode} (isolated, app safe)")
if proc.stderr:
print(f"[DNNFaceRecognizer] worker stderr: {proc.stderr.strip()[:300]}")
return False, None, None
# Always surface worker diagnostics (face counts, closest distance)
if proc.stderr and proc.stderr.strip():
for line in proc.stderr.strip().splitlines():
print(f"[DNNFaceRecognizer] {line.strip()}")
out = (proc.stdout or "").strip().splitlines()
if not out:
return False, None, None
import json
result = json.loads(out[-1])
matched = bool(result.get("matched", False))
label = result.get("label")
distance = result.get("distance")
if matched:
print(f"[DNNFaceRecognizer] MATCH: label={label} distance={distance:.3f} (tolerance={self.tolerance:.3f})")
elif distance is not None:
print(f"[DNNFaceRecognizer] Worker result: best_distance={distance:.3f} (tolerance={self.tolerance:.3f}) — no match")
else:
print("[DNNFaceRecognizer] Worker result: no allowed face match (see diagnostics above)")
return matched, label, distance
except subprocess.TimeoutExpired:
print("[DNNFaceRecognizer] worker timed out (isolated, app safe)")
return False, None, None
except Exception as e:
print(f"[DNNFaceRecognizer] subprocess verify error: {type(e).__name__}: {e}")
return False, None, None
def train_dnn_model(faces_dir: str = "faces") -> bool:
"""Train a DNN model from images in faces/<label>/ directories.
This is a helper function for future enrollment script updates.
"""
if not FACE_RECOGNITION_AVAILABLE:
print("face_recognition not available. Cannot train DNN model.")
return False
faces_path = Path(faces_dir)
encodings = []
labels = []
for label_dir in faces_path.iterdir():
if not label_dir.is_dir():
continue
label = label_dir.name
for img_path in label_dir.glob("*.png"):
try:
image = face_recognition.load_image_file(str(img_path))
face_encodings = face_recognition.face_encodings(image)
if face_encodings:
encodings.append(face_encodings[0])
labels.append(label)
except Exception as e:
print(f"Failed to process {img_path}: {e}")
if not encodings:
print("No valid face encodings found.")
return False
encodings_file = faces_path / "face_encodings.pkl"
labels_file = faces_path / "dnn_labels.json"
with open(encodings_file, "wb") as f:
pickle.dump(encodings, f)
import json
with open(labels_file, "w", encoding="utf-8") as f:
json.dump(labels, f, indent=2)
print(f"Trained DNN model with {len(labels)} images across {len(set(labels))} identities.")
return True

View File

@ -0,0 +1,143 @@
"""Standalone DNN face-verification worker.
Run as a SUBPROCESS so that any native crash inside dlib / face_recognition
(which Python cannot catch and which would otherwise kill the whole ICEYOU
app) is contained here. If this process segfaults, the parent simply sees a
non-zero exit code and carries on.
Processes MULTIPLE candidate frames in a single invocation (one model load)
and returns as soon as one matches, so the parent stays responsive.
Usage:
python -m iceyou.dnn_verify_worker \
--encodings faces/face_encodings.pkl \
--labels faces/dnn_labels.json \
--tolerance 0.6 [--allowed owner,admin] \
img1.png img2.png img3.png
Output (stdout, single line JSON):
{"matched": bool, "label": str|null, "distance": float|null}
"""
import sys
import json
import pickle
import argparse
def main() -> int:
no_match = {"matched": False, "label": None, "distance": None}
try:
parser = argparse.ArgumentParser()
parser.add_argument("--encodings", required=True)
parser.add_argument("--labels", required=True)
parser.add_argument("--tolerance", type=float, default=0.6)
parser.add_argument("--allowed", default="")
parser.add_argument("images", nargs="+")
args = parser.parse_args()
allowed = None
if args.allowed.strip():
allowed = set(x for x in args.allowed.split(",") if x)
def _is_allowed(label: str) -> bool:
# Accept exact matches OR augmentation variants (e.g. an allowed
# label "owner" matches "owner", "owner_glasses", "owner_dnn", ...).
if allowed is None:
return True
for a in allowed:
if label == a or label.startswith(a + "_"):
return True
return False
import numpy as np
import face_recognition
with open(args.encodings, "rb") as f:
known_encodings = pickle.load(f)
with open(args.labels, "r", encoding="utf-8") as f:
known_labels = json.load(f)
best_label = None
best_distance = None
best_distance_any = None # closest match regardless of allowed filter
best_label_any = None
frames_with_faces = 0
for image_path in args.images:
try:
image = face_recognition.load_image_file(image_path)
except Exception:
continue
# Upsample once so smaller / farther / slightly-angled faces are
# still detected (default upsample=0 misses many webcam faces).
face_locations = face_recognition.face_locations(image, number_of_times_to_upsample=1)
if not face_locations:
continue
encodings = face_recognition.face_encodings(image, face_locations)
if not encodings:
continue
frames_with_faces += 1
for enc in encodings:
distances = face_recognition.face_distance(known_encodings, enc)
if len(distances) == 0:
continue
idx = int(np.argmin(distances))
dist = float(distances[idx])
label = known_labels[idx]
# Track the global closest match (for diagnostics)
if best_distance_any is None or dist < best_distance_any:
best_distance_any = dist
best_label_any = label
if not _is_allowed(label):
continue
if best_distance is None or dist < best_distance:
best_distance = dist
best_label = label
# Early exit once we have a confident match
if best_distance is not None and best_distance <= args.tolerance:
break
sys.stderr.write(
f"[dnn_verify_worker] frames={len(args.images)} frames_with_faces={frames_with_faces} "
f"closest_any={best_label_any}:{best_distance_any if best_distance_any is None else round(best_distance_any,3)}\n"
)
if best_label is None or best_distance is None:
# No ALLOWED match. If a face was seen but filtered out by allowed
# labels, surface that distance so the parent can explain it.
if best_distance_any is not None:
sys.stderr.write(
f"[dnn_verify_worker] a face was detected but no ALLOWED label matched "
f"(closest='{best_label_any}' dist={best_distance_any:.3f}); check allowed_labels\n"
)
elif frames_with_faces == 0:
sys.stderr.write("[dnn_verify_worker] NO face detected in any frame (camera angle/lighting?)\n")
print(json.dumps(no_match))
return 0
matched = best_distance <= args.tolerance
result = {
"matched": bool(matched),
"label": best_label,
"distance": best_distance,
}
if not matched:
# Helpful for debugging why DNN did not match
sys.stderr.write(f"[dnn_verify_worker] best_distance={best_distance:.3f} > tolerance={args.tolerance:.3f} (no match)\n")
print(json.dumps(result))
return 0
except Exception as e:
sys.stderr.write(f"[dnn_verify_worker] {type(e).__name__}: {e}\n")
print(json.dumps(no_match))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -192,6 +192,10 @@ class Monitor:
self.is_away = away
self._log_event("manual_state", f"Manually set away={away}")
if away:
# If we were paused (camera released), resume first so the camera
# reactivates for motion recording and face unlock on the white screen.
if self.paused:
self.resume_monitoring()
# Manual force: lock until explicit unlock
self.manual_lock = True
self.snooze_until = 0.0
@ -226,12 +230,20 @@ class Monitor:
self.white_screen.hide()
def resume_monitoring(self) -> None:
"""Resume monitoring after being paused."""
"""Resume monitoring after being paused. Re-opens the camera so face
unlock, motion recording, and obstruction detection work again.
"""
if not self.paused:
return
self.paused = False
self._log_event("monitoring_resumed", "Monitoring resumed")
# Re-acquire cameras (they were released in pause_monitoring)
try:
self.camera.reopen()
except Exception as e:
print(f"[Monitor] camera reopen failed: {e}")
# Restart hooks
self.input_hooks.start()
if self.config.get("device_monitoring", True):

View File

@ -37,21 +37,43 @@ class TrayApp:
self.actions = Actions(self.config, self.monitor)
self.monitor.on_intrusion = self._on_intrusion
# Optional face recognition
self.face_recognizer: Optional[FaceRecognizer] = None
# Optional face recognition (supports "lbph" or "dnn" backend)
self.face_recognizer = None
face_cfg = self.config.get("face_unlock", {}) or {}
face_enabled = bool(face_cfg.get("enabled", self.config.get("face_recognition_enabled", False)))
if face_enabled:
backend = self.config.get("face_recognition_backend", "lbph").lower()
threshold = float(face_cfg.get("confidence_threshold", 70.0))
allowed = face_cfg.get("allowed_labels") # None = any enrolled
allowed = face_cfg.get("allowed_labels")
min_matches = int(face_cfg.get("min_successful_matches", 2))
if backend == "dnn":
try:
from .dnn_face_recognizer import DNNFaceRecognizer
faces_dir = face_cfg.get("enrollment_images_dir", "faces")
# DNN tolerance is a face DISTANCE (~0.6, lower = stricter),
# NOT the LBPH confidence_threshold. Read it from the
# face_recognition config block.
dnn_cfg = self.config.get("face_recognition", {}) or {}
dnn_tolerance = float(dnn_cfg.get("tolerance", 0.6))
self.face_recognizer = DNNFaceRecognizer(
tolerance=dnn_tolerance,
allowed_labels=allowed,
faces_dir=faces_dir,
)
except Exception as e:
print(f"[TrayApp] Failed to load DNNFaceRecognizer: {e}")
self.face_recognizer = None
else:
# Default: LBPH
self.face_recognizer = FaceRecognizer(
confidence_threshold=threshold,
allowed_labels=allowed,
min_successful_matches=min_matches,
)
if not self.face_recognizer.available:
self.face_recognizer = None # Enrol first; fall back to password only
if self.face_recognizer and not self.face_recognizer.available:
self.face_recognizer = None
# Wire WhiteScreen callbacks
ws = self.monitor.white_screen
@ -105,6 +127,7 @@ class TrayApp:
if self.face_recognizer is None or not self.face_recognizer.available:
return False, None, None
attempts = int(self.config.get("face_unlock", {}).get("frames_per_attempt", 4))
print("[TrayApp] Face unlock attempt: reading camera frames for DNN verification...")
return self.face_recognizer.verify_from_camera(self.monitor.camera, attempts=attempts)
def _on_motion_event(self, clip_path: str) -> None:
@ -247,7 +270,15 @@ class TrayApp:
def _toggle_white_screen(self, icon=None, item=None) -> None:
ws = self.monitor.white_screen
ws.enabled = not ws.enabled
if not ws.enabled:
if ws.enabled:
# If the user is enabling the white screen while monitoring is paused
# (camera released), resume so the camera reactivates for face unlock
# and motion recording. Otherwise the white screen would have no camera.
if self.monitor.paused:
self.monitor.resume_monitoring()
if not ws.is_shown:
ws.show()
else:
ws.hide()
self.icon.notify(f"White screen {'enabled' if ws.enabled else 'disabled'}", "ICEYOU")

View File

@ -522,12 +522,15 @@ class WhiteScreen:
else:
matched = bool(result)
except Exception as e:
print(f"[WhiteScreen] face_unlock error: {e}")
# Hand result back to Tk thread
if self._root:
print(f"[WhiteScreen] face_unlock error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc(limit=2)
# Hand result back to Tk thread - never let this crash the UI thread
try:
if self._root:
self._root.after(0, lambda: self._face_unlock_result(matched, label, conf, silent, manual))
except Exception:
except Exception as e:
print(f"[WhiteScreen] face result delivery error: {e}")
self._face_busy = False
threading.Thread(target=worker, daemon=True).start()
@ -542,11 +545,18 @@ class WhiteScreen:
# Fire attempt log: always for manual clicks, only on success for auto (avoid flooding)
if manual or matched:
try:
conf_value = None
if conf is not None:
try:
conf_value = float(conf)
except (ValueError, TypeError):
conf_value = None
self.on_attempt({
"method": "face",
"success": matched,
"label": label,
"confidence": float(conf) if conf is not None else None,
"confidence": conf_value,
})
except Exception as e:
print(f"[WhiteScreen] on_attempt error: {e}")
@ -556,12 +566,15 @@ class WhiteScreen:
self.on_unlocked()
except Exception as e:
print(f"[WhiteScreen] on_unlocked error: {e}")
try:
self._hide_window()
except Exception as e:
print(f"[WhiteScreen] _hide_window error after face success: {e}")
return
if not silent and self._status_var:
if self._status_var:
if conf is not None:
self._status_var.set(
f"Face not recognised (best={label or '?'}, conf={conf:.1f}). Try the password."
f"Face not recognised (best={label or '?'}, dist={conf:.2f}). Try the password. (DNN: lower distance = better match)"
)
else:
elif not silent:
self._status_var.set("No face detected. Try the password.")