updated to DNN for face detections
This commit is contained in:
parent
e3313dbc1b
commit
fb012de76b
92
.gitignore
vendored
92
.gitignore
vendored
|
|
@ -1,4 +1,10 @@
|
||||||
# Python
|
# ==========================================================================
|
||||||
|
# ICEYOU .gitignore
|
||||||
|
# Goal: never commit secrets, credentials, biometric/DNN data, captured
|
||||||
|
# media, logs, or local environment artifacts.
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
# --- Python ---------------------------------------------------------------
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
@ -20,32 +26,96 @@ wheels/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
# Virtualenv
|
# --- Virtual environments -------------------------------------------------
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
# Config & secrets
|
# ==========================================================================
|
||||||
|
# SECRETS & CONFIGURATION (NEVER COMMIT)
|
||||||
|
# ==========================================================================
|
||||||
|
# Real runtime config (contains email creds + unlock password).
|
||||||
config.json
|
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/
|
snapshots/
|
||||||
motion_clips/
|
motion_clips/
|
||||||
faces/
|
backup
|
||||||
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/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# OS
|
# --- OS -------------------------------------------------------------------
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# PyInstaller
|
# --- PyInstaller ----------------------------------------------------------
|
||||||
*.spec
|
*.spec
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
@ -144,7 +144,11 @@ Optional second unlock path next to the password.
|
||||||
- Hold a steady face ≥ 2 s — auto-capture
|
- Hold a steady face ≥ 2 s — auto-capture
|
||||||
- **R** restart, **Q / ESC** quit
|
- **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`**:
|
3. **Enable in `config.json`**:
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
13
backup
13
backup
|
|
@ -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
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"stop_monitoring": "<ctrl>+<alt>+s"
|
"stop_monitoring": "<ctrl>+<alt>+s"
|
||||||
},
|
},
|
||||||
"face_recognition_enabled": false,
|
"face_recognition_enabled": false,
|
||||||
|
"face_recognition_backend": "lbph", # "lbph" or "dnn" (requires face-recognition package)
|
||||||
"face_recognition": {
|
"face_recognition": {
|
||||||
"tolerance": 0.6,
|
"tolerance": 0.6,
|
||||||
"enrollment_images_dir": "faces/owner"
|
"enrollment_images_dir": "faces/owner"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""ICEYOU - Authorized User Face Enrollment
|
"""ICEYOU - Authorized User Face Enrollment
|
||||||
|
|
||||||
Captures multiple webcam photos of the authorized user from various angles,
|
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:
|
Usage:
|
||||||
python face_enrollment.py
|
python face_enrollment.py --name owner --backend lbph
|
||||||
(optionally append --name <label> to enroll secondary authorized users)
|
python face_enrollment.py --name owner --backend dnn # requires face-recognition package
|
||||||
|
|
||||||
Controls in the live preview window:
|
Controls in the live preview window:
|
||||||
SPACE - Capture the current frame (only if a face is detected)
|
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)")
|
help="Camera device index (default: from config.json or 0)")
|
||||||
parser.add_argument("--list-cameras", action="store_true",
|
parser.add_argument("--list-cameras", action="store_true",
|
||||||
help="Probe and list available camera indices, then exit")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.list_cameras:
|
if args.list_cameras:
|
||||||
|
|
@ -330,6 +333,8 @@ def main():
|
||||||
user_label = args.name.strip().replace(" ", "_") or "owner"
|
user_label = args.name.strip().replace(" ", "_") or "owner"
|
||||||
user_dir = FACES_DIR / user_label
|
user_dir = FACES_DIR / user_label
|
||||||
|
|
||||||
|
print(f"Using backend: {args.backend}")
|
||||||
|
|
||||||
if user_dir.exists() and any(user_dir.iterdir()):
|
if user_dir.exists() and any(user_dir.iterdir()):
|
||||||
resp = input(f"Existing enrollment for '{user_label}' found. Overwrite? [y/N]: ").strip().lower()
|
resp = input(f"Existing enrollment for '{user_label}' found. Overwrite? [y/N]: ").strip().lower()
|
||||||
if resp != "y":
|
if resp != "y":
|
||||||
|
|
@ -349,12 +354,33 @@ def main():
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\n{len(captures)} photo(s) saved to {user_dir}")
|
print(f"\n{len(captures)} photo(s) saved to {user_dir}")
|
||||||
print("Training recognition model...")
|
|
||||||
train_model(FACES_DIR)
|
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("\nEnrollment complete.")
|
||||||
print("To enable recognition, set in config.json:")
|
print("To enable recognition, set in config.json:")
|
||||||
print(' "face_recognition_enabled": true')
|
print(' "face_recognition_enabled": true')
|
||||||
|
if args.backend == "dnn":
|
||||||
|
print(' "face_recognition_backend": "dnn"')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
49
main.py
49
main.py
|
|
@ -1,7 +1,10 @@
|
||||||
"""ICEYOU entry point - launches the system tray application."""
|
"""ICEYOU entry point - launches the system tray application."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
# Add src to path for development
|
# Add src to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
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
|
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():
|
def main():
|
||||||
print("Starting ICEYOU Personal Monitor...")
|
print("Starting ICEYOU Personal Monitor...")
|
||||||
print("A system tray icon will appear. Right-click for options.")
|
print("A system tray icon will appear. Right-click for options.")
|
||||||
app = TrayApp()
|
try:
|
||||||
app.run()
|
app = TrayApp()
|
||||||
|
app.run()
|
||||||
|
except Exception as e:
|
||||||
|
log_crash_report(type(e), e, e.__traceback__)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ Pillow>=10.3.0
|
||||||
python-json-logger>=2.0.7
|
python-json-logger>=2.0.7
|
||||||
|
|
||||||
# Optional but recommended for email alerts (uses stdlib smtplib + ssl)
|
# Optional but recommended for email alerts (uses stdlib smtplib + ssl)
|
||||||
# For face recognition (advanced "not me" detection):
|
# 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
|
face-recognition>=1.3.0 # Requires dlib + build tools on Windows; see README
|
||||||
|
|
||||||
# Note: Run `pip install -r requirements.txt`
|
# Note: Run `pip install -r requirements.txt`
|
||||||
# For full face_recognition on Windows: Install Visual Studio Build Tools, CMake first.
|
# For full face_recognition on Windows: Install Visual Studio Build Tools, CMake first.
|
||||||
|
|
@ -127,6 +127,8 @@ class Camera:
|
||||||
if ii not in seen:
|
if ii not in seen:
|
||||||
seen.add(ii)
|
seen.add(ii)
|
||||||
ordered.append(ii)
|
ordered.append(ii)
|
||||||
|
self._requested_indices = ordered
|
||||||
|
self._probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5))
|
||||||
|
|
||||||
# Camera obstruction detection settings
|
# Camera obstruction detection settings
|
||||||
obs = config.get("camera_obscured_detection", {}) or {}
|
obs = config.get("camera_obscured_detection", {}) or {}
|
||||||
|
|
@ -134,20 +136,32 @@ class Camera:
|
||||||
self.brightness_threshold = float(obs.get("brightness_threshold", 35))
|
self.brightness_threshold = float(obs.get("brightness_threshold", 35))
|
||||||
self.variance_threshold = float(obs.get("variance_threshold", 10))
|
self.variance_threshold = float(obs.get("variance_threshold", 10))
|
||||||
|
|
||||||
probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5))
|
|
||||||
|
|
||||||
self.streams: List[CameraStream] = []
|
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)
|
stream = CameraStream(idx)
|
||||||
if stream.open(probe_timeout=probe_timeout):
|
if stream.open(probe_timeout=self._probe_timeout):
|
||||||
print(f"[Camera] index {idx} OPEN")
|
print(f"[Camera] index {idx} OPEN")
|
||||||
self.streams.append(stream)
|
self.streams.append(stream)
|
||||||
else:
|
else:
|
||||||
print(f"[Camera] index {idx} DEAD (skipped after {probe_timeout}s probe)")
|
print(f"[Camera] index {idx} DEAD (skipped after {self._probe_timeout}s probe)")
|
||||||
# keep the dead stream object out of the list entirely
|
|
||||||
if not self.alive_streams:
|
if not self.alive_streams:
|
||||||
print("[Camera] WARNING: no working cameras detected")
|
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 ----
|
# ---- discovery / accessors ----
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
323
src/iceyou/dnn_face_recognizer.py
Normal file
323
src/iceyou/dnn_face_recognizer.py
Normal 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
|
||||||
143
src/iceyou/dnn_verify_worker.py
Normal file
143
src/iceyou/dnn_verify_worker.py
Normal 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())
|
||||||
|
|
@ -192,6 +192,10 @@ class Monitor:
|
||||||
self.is_away = away
|
self.is_away = away
|
||||||
self._log_event("manual_state", f"Manually set away={away}")
|
self._log_event("manual_state", f"Manually set away={away}")
|
||||||
if 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
|
# Manual force: lock until explicit unlock
|
||||||
self.manual_lock = True
|
self.manual_lock = True
|
||||||
self.snooze_until = 0.0
|
self.snooze_until = 0.0
|
||||||
|
|
@ -226,12 +230,20 @@ class Monitor:
|
||||||
self.white_screen.hide()
|
self.white_screen.hide()
|
||||||
|
|
||||||
def resume_monitoring(self) -> None:
|
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:
|
if not self.paused:
|
||||||
return
|
return
|
||||||
self.paused = False
|
self.paused = False
|
||||||
self._log_event("monitoring_resumed", "Monitoring resumed")
|
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
|
# Restart hooks
|
||||||
self.input_hooks.start()
|
self.input_hooks.start()
|
||||||
if self.config.get("device_monitoring", True):
|
if self.config.get("device_monitoring", True):
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,43 @@ class TrayApp:
|
||||||
self.actions = Actions(self.config, self.monitor)
|
self.actions = Actions(self.config, self.monitor)
|
||||||
self.monitor.on_intrusion = self._on_intrusion
|
self.monitor.on_intrusion = self._on_intrusion
|
||||||
|
|
||||||
# Optional face recognition
|
# Optional face recognition (supports "lbph" or "dnn" backend)
|
||||||
self.face_recognizer: Optional[FaceRecognizer] = None
|
self.face_recognizer = None
|
||||||
face_cfg = self.config.get("face_unlock", {}) or {}
|
face_cfg = self.config.get("face_unlock", {}) or {}
|
||||||
face_enabled = bool(face_cfg.get("enabled", self.config.get("face_recognition_enabled", False)))
|
face_enabled = bool(face_cfg.get("enabled", self.config.get("face_recognition_enabled", False)))
|
||||||
if face_enabled:
|
if face_enabled:
|
||||||
|
backend = self.config.get("face_recognition_backend", "lbph").lower()
|
||||||
threshold = float(face_cfg.get("confidence_threshold", 70.0))
|
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))
|
min_matches = int(face_cfg.get("min_successful_matches", 2))
|
||||||
self.face_recognizer = FaceRecognizer(
|
|
||||||
confidence_threshold=threshold,
|
if backend == "dnn":
|
||||||
allowed_labels=allowed,
|
try:
|
||||||
min_successful_matches=min_matches,
|
from .dnn_face_recognizer import DNNFaceRecognizer
|
||||||
)
|
faces_dir = face_cfg.get("enrollment_images_dir", "faces")
|
||||||
if not self.face_recognizer.available:
|
# DNN tolerance is a face DISTANCE (~0.6, lower = stricter),
|
||||||
self.face_recognizer = None # Enrol first; fall back to password only
|
# 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 self.face_recognizer and not self.face_recognizer.available:
|
||||||
|
self.face_recognizer = None
|
||||||
|
|
||||||
# Wire WhiteScreen callbacks
|
# Wire WhiteScreen callbacks
|
||||||
ws = self.monitor.white_screen
|
ws = self.monitor.white_screen
|
||||||
|
|
@ -105,6 +127,7 @@ class TrayApp:
|
||||||
if self.face_recognizer is None or not self.face_recognizer.available:
|
if self.face_recognizer is None or not self.face_recognizer.available:
|
||||||
return False, None, None
|
return False, None, None
|
||||||
attempts = int(self.config.get("face_unlock", {}).get("frames_per_attempt", 4))
|
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)
|
return self.face_recognizer.verify_from_camera(self.monitor.camera, attempts=attempts)
|
||||||
|
|
||||||
def _on_motion_event(self, clip_path: str) -> None:
|
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:
|
def _toggle_white_screen(self, icon=None, item=None) -> None:
|
||||||
ws = self.monitor.white_screen
|
ws = self.monitor.white_screen
|
||||||
ws.enabled = not ws.enabled
|
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()
|
ws.hide()
|
||||||
self.icon.notify(f"White screen {'enabled' if ws.enabled else 'disabled'}", "ICEYOU")
|
self.icon.notify(f"White screen {'enabled' if ws.enabled else 'disabled'}", "ICEYOU")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -522,13 +522,16 @@ class WhiteScreen:
|
||||||
else:
|
else:
|
||||||
matched = bool(result)
|
matched = bool(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WhiteScreen] face_unlock error: {e}")
|
print(f"[WhiteScreen] face_unlock error: {type(e).__name__}: {e}")
|
||||||
# Hand result back to Tk thread
|
import traceback
|
||||||
if self._root:
|
traceback.print_exc(limit=2)
|
||||||
try:
|
# 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))
|
self._root.after(0, lambda: self._face_unlock_result(matched, label, conf, silent, manual))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
self._face_busy = False
|
print(f"[WhiteScreen] face result delivery error: {e}")
|
||||||
|
self._face_busy = False
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
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)
|
# Fire attempt log: always for manual clicks, only on success for auto (avoid flooding)
|
||||||
if manual or matched:
|
if manual or matched:
|
||||||
try:
|
try:
|
||||||
|
conf_value = None
|
||||||
|
if conf is not None:
|
||||||
|
try:
|
||||||
|
conf_value = float(conf)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
conf_value = None
|
||||||
|
|
||||||
self.on_attempt({
|
self.on_attempt({
|
||||||
"method": "face",
|
"method": "face",
|
||||||
"success": matched,
|
"success": matched,
|
||||||
"label": label,
|
"label": label,
|
||||||
"confidence": float(conf) if conf is not None else None,
|
"confidence": conf_value,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WhiteScreen] on_attempt error: {e}")
|
print(f"[WhiteScreen] on_attempt error: {e}")
|
||||||
|
|
@ -556,12 +566,15 @@ class WhiteScreen:
|
||||||
self.on_unlocked()
|
self.on_unlocked()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WhiteScreen] on_unlocked error: {e}")
|
print(f"[WhiteScreen] on_unlocked error: {e}")
|
||||||
self._hide_window()
|
try:
|
||||||
|
self._hide_window()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WhiteScreen] _hide_window error after face success: {e}")
|
||||||
return
|
return
|
||||||
if not silent and self._status_var:
|
if self._status_var:
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
self._status_var.set(
|
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.")
|
self._status_var.set("No face detected. Try the password.")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user