From 7cc2ba1b58ed44afb8ecf7197887f18ad928b024 Mon Sep 17 00:00:00 2001 From: amletoflorio Date: Tue, 14 Apr 2026 10:52:00 +0200 Subject: [PATCH] fix: lower detection threshold and improve IR camera timing for Lenovo - Lower DEFAULT_DETECTION_THRESHOLD from 0.9 to 0.6 - Increase IR_TRIGGER_DELAY_MS from 200ms to 1500ms - Increase CAMERA_WARMUP_FRAMES (10->15) and CAMERA_WARMUP_DELAY_MS (100->200ms) - Always save failed enrollment frame with brightness and score info - Add YuNet face score logging to help diagnose threshold issues - Add docs/LENOVO_IR_DEBUG.md with full troubleshooting guide Tested on Lenovo with Ubuntu 26.04, /dev/video2 IR camera. --- LENOVO_IR_DEBUG.md | 163 ++++++++++++++++++++++++++++++++++++ config/config.ini | 10 ++- docs/LENOVO_IR_DEBUG.md | 163 ++++++++++++++++++++++++++++++++++++ include/constants.hpp | 18 ++-- src/service/auth_engine.cpp | 55 +++++++++--- src/service/config.hpp | 6 +- 6 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 LENOVO_IR_DEBUG.md create mode 100644 docs/LENOVO_IR_DEBUG.md diff --git a/LENOVO_IR_DEBUG.md b/LENOVO_IR_DEBUG.md new file mode 100644 index 0000000..27652ca --- /dev/null +++ b/LENOVO_IR_DEBUG.md @@ -0,0 +1,163 @@ +# Lenovo IR Camera — Debug Guide + +## Symptom + +``` +Response: ENROLL_FAIL Found 0 faces in ir. Expecting exactly 1. +``` + +## Root Cause + +The YuNet face detector assigns **significantly lower confidence scores** to faces +captured by IR cameras (grayscale image, low contrast, often overexposed). + +The default threshold of `0.9` was tuned for standard RGB webcams. On IR cameras +it is nearly impossible to reach. Lowering it to `0.5–0.6` resolves the issue +in most cases. + +--- + +## Quick Fix (no recompilation required) + +Edit `/etc/linuxcampam/config.ini`: + +```ini +[Auth] +detection_threshold = 0.5 + +[Capture] +enroll_averaging = on +enroll_average_frames = 7 +``` + +Then restart the daemon: +```bash +sudo systemctl restart linuxcampam +``` + +--- + +## Diagnosing with logs + +During enrollment, watch the logs in real time: + +```bash +journalctl -u linuxcampam -f +``` + +What to look for: + +| Log message | Meaning | +|-------------|---------| +| `0 faces found above threshold (0.9)` | Threshold too high → lower it | +| `Best score: 0.65 \| Threshold: 0.9` | Face found but below threshold → lower to 0.55 | +| `Brightness: 12` | Frame nearly black → IR emitter not activating | +| `Brightness: 240` | Frame overexposed → camera exposure issue | + +--- + +## Inspecting the failed frame + +On every failed enrollment the captured frame is automatically saved to: + +``` +/var/log/linuxcampam/failed_enroll_ir_.jpg +``` + +Open it to check what the camera actually captured: +```bash +xdg-open /var/log/linuxcampam/failed_enroll_ir_.jpg +``` + +- **Nearly black frame** → IR emitter did not activate (see section below) +- **Grainy / blurry frame** → increase `enroll_average_frames` +- **Face visible but not detected** → lower `detection_threshold` further +- **No face in frame** → positioning issue during enrollment + +--- + +## IR emitter not activating + +If the saved frame is nearly black: + +1. Verify `linux-enable-ir-emitter` is installed: + ```bash + ls /usr/local/bin/linux-enable-ir-emitter + ``` + +2. Test it manually: + ```bash + sudo linux-enable-ir-emitter run + ``` + +3. If not installed: + ```bash + sudo apt install linux-enable-ir-emitter + # or from source: https://github.com/EmixamPP/linux-enable-ir-emitter + ``` + +4. Configure for your specific hardware model: + ```bash + sudo linux-enable-ir-emitter configure + ``` + Follow the interactive procedure — move your head in front of the camera + while it tries different configurations. When the emitter blinks and you see + `The infrared emitter has been successfully enabled!` you are done. + +--- + +## Finding the correct IR camera device + +```bash +# List all webcams +v4l2-ctl --list-devices + +# Identify the IR camera by checking shape and brightness +python3 -c " +import cv2 +for i in range(4): + cap = cv2.VideoCapture(i) + if not cap.isOpened(): + print(f'video{i}: could not open') + continue + ret, frame = cap.read() + cap.release() + if ret: + print(f'video{i}: shape={frame.shape} brightness={frame.mean():.0f}') +" +``` + +The IR camera has **1 channel** (shape like `(360, 640)`) instead of 3 (RGB). +On Lenovo laptops it is typically `/dev/video2`. + +--- + +## Recommended settings for Lenovo ThinkPad / IdeaPad + +```ini +[Auth] +detection_threshold = 0.5 +timeout_ms = 5000 + +[Capture] +enroll_hdr = off ; IR cameras do not support HDR +enroll_averaging = on +enroll_average_frames = 7 + +[Hardware] +camera_path_ir = /dev/video2 ; verify with v4l2-ctl --list-devices +``` + +--- + +## Code changes (this fix) + +| File | Change | +|------|--------| +| `include/constants.hpp` | `IR_TRIGGER_DELAY_MS`: 200 → 1500 ms | +| `include/constants.hpp` | `CAMERA_WARMUP_FRAMES`: 10 → 15 | +| `include/constants.hpp` | `CAMERA_WARMUP_DELAY_MS`: 100 → 200 ms | +| `src/service/config.hpp` | `DEFAULT_DETECTION_THRESHOLD`: 0.9 → 0.6 | +| `src/service/auth_engine.cpp` | Log YuNet score + brightness in `generateEmbedding` | +| `src/service/auth_engine.cpp` | Always save failed enrollment frame with diagnostic info | +| `config/config.ini` | Updated default `detection_threshold` + averaging enabled | diff --git a/config/config.ini b/config/config.ini index 215ccdd..8ee9f5a 100644 --- a/config/config.ini +++ b/config/config.ini @@ -28,7 +28,10 @@ ir_emitter_path = /usr/local/bin/linux-enable-ir-emitter ; How confident the AI must be that it found a face. ; High values (0.9) are safe but fail in poor light/IR. ; Lower values (0.5-0.6) improve IR success but risk false detections. -detection_threshold = 0.9 +; Lowered from 0.9 to 0.6: IR cameras (e.g. Lenovo, Dell) produce grayscale, +; low-contrast images where YuNet rarely scores above 0.7. +; Use 0.5-0.6 for IR cameras, 0.8-0.9 for high-quality RGB cameras only. +detection_threshold = 0.6 ; Timeout in milliseconds to wait for a successful match. ; timeout_ms = 3000 @@ -59,8 +62,9 @@ detection_threshold = 0.9 ; Enhanced capture settings for enrollment quality. ; HDR uses multiple exposures if camera supports manual exposure control. ; enroll_hdr = auto ; auto | on | off -; enroll_averaging = on ; on | off -; enroll_average_frames = 5 +; For IR cameras (e.g. Lenovo, Dell), enable averaging to reduce noise: +enroll_averaging = on +enroll_average_frames = 7 ; Verification capture (speed-focused, defaults to fast single-frame). ; verify_averaging = off diff --git a/docs/LENOVO_IR_DEBUG.md b/docs/LENOVO_IR_DEBUG.md new file mode 100644 index 0000000..27652ca --- /dev/null +++ b/docs/LENOVO_IR_DEBUG.md @@ -0,0 +1,163 @@ +# Lenovo IR Camera — Debug Guide + +## Symptom + +``` +Response: ENROLL_FAIL Found 0 faces in ir. Expecting exactly 1. +``` + +## Root Cause + +The YuNet face detector assigns **significantly lower confidence scores** to faces +captured by IR cameras (grayscale image, low contrast, often overexposed). + +The default threshold of `0.9` was tuned for standard RGB webcams. On IR cameras +it is nearly impossible to reach. Lowering it to `0.5–0.6` resolves the issue +in most cases. + +--- + +## Quick Fix (no recompilation required) + +Edit `/etc/linuxcampam/config.ini`: + +```ini +[Auth] +detection_threshold = 0.5 + +[Capture] +enroll_averaging = on +enroll_average_frames = 7 +``` + +Then restart the daemon: +```bash +sudo systemctl restart linuxcampam +``` + +--- + +## Diagnosing with logs + +During enrollment, watch the logs in real time: + +```bash +journalctl -u linuxcampam -f +``` + +What to look for: + +| Log message | Meaning | +|-------------|---------| +| `0 faces found above threshold (0.9)` | Threshold too high → lower it | +| `Best score: 0.65 \| Threshold: 0.9` | Face found but below threshold → lower to 0.55 | +| `Brightness: 12` | Frame nearly black → IR emitter not activating | +| `Brightness: 240` | Frame overexposed → camera exposure issue | + +--- + +## Inspecting the failed frame + +On every failed enrollment the captured frame is automatically saved to: + +``` +/var/log/linuxcampam/failed_enroll_ir_.jpg +``` + +Open it to check what the camera actually captured: +```bash +xdg-open /var/log/linuxcampam/failed_enroll_ir_.jpg +``` + +- **Nearly black frame** → IR emitter did not activate (see section below) +- **Grainy / blurry frame** → increase `enroll_average_frames` +- **Face visible but not detected** → lower `detection_threshold` further +- **No face in frame** → positioning issue during enrollment + +--- + +## IR emitter not activating + +If the saved frame is nearly black: + +1. Verify `linux-enable-ir-emitter` is installed: + ```bash + ls /usr/local/bin/linux-enable-ir-emitter + ``` + +2. Test it manually: + ```bash + sudo linux-enable-ir-emitter run + ``` + +3. If not installed: + ```bash + sudo apt install linux-enable-ir-emitter + # or from source: https://github.com/EmixamPP/linux-enable-ir-emitter + ``` + +4. Configure for your specific hardware model: + ```bash + sudo linux-enable-ir-emitter configure + ``` + Follow the interactive procedure — move your head in front of the camera + while it tries different configurations. When the emitter blinks and you see + `The infrared emitter has been successfully enabled!` you are done. + +--- + +## Finding the correct IR camera device + +```bash +# List all webcams +v4l2-ctl --list-devices + +# Identify the IR camera by checking shape and brightness +python3 -c " +import cv2 +for i in range(4): + cap = cv2.VideoCapture(i) + if not cap.isOpened(): + print(f'video{i}: could not open') + continue + ret, frame = cap.read() + cap.release() + if ret: + print(f'video{i}: shape={frame.shape} brightness={frame.mean():.0f}') +" +``` + +The IR camera has **1 channel** (shape like `(360, 640)`) instead of 3 (RGB). +On Lenovo laptops it is typically `/dev/video2`. + +--- + +## Recommended settings for Lenovo ThinkPad / IdeaPad + +```ini +[Auth] +detection_threshold = 0.5 +timeout_ms = 5000 + +[Capture] +enroll_hdr = off ; IR cameras do not support HDR +enroll_averaging = on +enroll_average_frames = 7 + +[Hardware] +camera_path_ir = /dev/video2 ; verify with v4l2-ctl --list-devices +``` + +--- + +## Code changes (this fix) + +| File | Change | +|------|--------| +| `include/constants.hpp` | `IR_TRIGGER_DELAY_MS`: 200 → 1500 ms | +| `include/constants.hpp` | `CAMERA_WARMUP_FRAMES`: 10 → 15 | +| `include/constants.hpp` | `CAMERA_WARMUP_DELAY_MS`: 100 → 200 ms | +| `src/service/config.hpp` | `DEFAULT_DETECTION_THRESHOLD`: 0.9 → 0.6 | +| `src/service/auth_engine.cpp` | Log YuNet score + brightness in `generateEmbedding` | +| `src/service/auth_engine.cpp` | Always save failed enrollment frame with diagnostic info | +| `config/config.ini` | Updated default `detection_threshold` + averaging enabled | diff --git a/include/constants.hpp b/include/constants.hpp index ad9fbcb..b318946 100644 --- a/include/constants.hpp +++ b/include/constants.hpp @@ -18,10 +18,18 @@ inline constexpr double RGB_CHANNELS = 3.0; inline constexpr uid_t DEFAULT_MIN_UID = 1000; // Camera & Auth constants -inline constexpr int CAMERA_WARMUP_FRAMES = 10; -inline constexpr int CAMERA_WARMUP_DELAY_MS = 100; +// WARMUP_FRAMES and WARMUP_DELAY_MS increased from 10/100 to 15/200: +// IR cameras (e.g. Lenovo) need more frames discarded and longer settle time +// before the auto-exposure stabilizes and produces a usable image. +inline constexpr int CAMERA_WARMUP_FRAMES = 15; +inline constexpr int CAMERA_WARMUP_DELAY_MS = 200; inline constexpr int CAMERA_AVERAGE_FRAMES = 5; -inline constexpr int IR_TRIGGER_DELAY_MS = 200; + +// IR_TRIGGER_DELAY_MS increased from 200ms to 1500ms: +// On Lenovo laptops the IR emitter hardware needs significantly more time +// to activate after being triggered before the sensor receives usable IR light. +// 200ms was too short, resulting in near-black frames (brightness ~14/255). +inline constexpr int IR_TRIGGER_DELAY_MS = 1500; inline constexpr int CAPTURE_RETRY_DELAY_S = 1; // HDR Constants @@ -36,5 +44,5 @@ inline constexpr int CAPTURE_RETRY_ATTEMPTS = 3; inline constexpr float MIRROR_THRESHOLD_DEFAULT = 0.6f; // detection confidence inline constexpr int MIRROR_SIZE = 640; -inline constexpr int MIRROR_NMS = 5000; // keep top K bboxes before NMS . -} // namespace linuxcampam \ No newline at end of file +inline constexpr int MIRROR_NMS = 5000; // keep top K bboxes before NMS +} // namespace linuxcampam diff --git a/src/service/auth_engine.cpp b/src/service/auth_engine.cpp index 80da82a..9f9e2a5 100644 --- a/src/service/auth_engine.cpp +++ b/src/service/auth_engine.cpp @@ -250,11 +250,25 @@ int AuthEngine::generateEmbedding(const cv::Mat &frame, detector->setInputSize(frame.size()); detector->detect(frame, faces); - Logger::log(LogLevel::INFO, "Profiling: Detection complete. Faces: " + - std::to_string(faces.rows)); + // Log top face scores to help diagnose threshold issues (especially IR cameras) + int num_faces = faces.rows; + if (num_faces == 0) { + // YuNet found candidates but all were below detection_threshold. + // Log this explicitly: if you see this with IR, lower detection_threshold in config.ini. + Logger::log(LogLevel::WARN, + "Profiling: Detection complete. 0 faces found above threshold (" + + std::to_string(config.detection_threshold) + + "). If using IR camera, try lowering detection_threshold to 0.5 in config.ini."); + } else { + // Log confidence score of best face + float best_score = faces.at(0, 14); + Logger::log(LogLevel::INFO, + "Profiling: Detection complete. Faces: " + std::to_string(num_faces) + + " | Best score: " + std::to_string(best_score) + + " | Threshold: " + std::to_string(config.detection_threshold)); + } gpuSync(config.gpu_flush, config.gpu_throttle_ms); - int num_faces = faces.rows; if (num_faces < 1) return 0; @@ -634,15 +648,34 @@ AuthEngine::enrollUser(const std::string &username) { err += " faces in "; err += id; err += ". Expecting exactly 1."; - Logger::log(LogLevel::WARN, "Enroll failed: " + err); - if (config.save_fail) { - std::string fail_filename = config.log_dir + "failed_enroll_"; - fail_filename += id; - fail_filename += "_"; - fail_filename += username; - fail_filename += ".jpg"; - cv::imwrite(fail_filename, frame); + + // Always log frame brightness to help diagnose IR camera issues + double brightness = calculateBrightness(frame); + Logger::log(LogLevel::WARN, "Enroll failed: " + err + + " | Frame: " + + std::to_string(frame.cols) + "x" + + std::to_string(frame.rows) + + " | Brightness: " + + std::to_string(static_cast(brightness)) + + " | threshold: " + + std::to_string(config.detection_threshold)); + + // Always save the failed frame (regardless of save_fail setting) to help + // debug IR camera issues (e.g. Lenovo emitter not activated, underexposure). + fs::create_directories(config.log_dir); + std::string fail_filename = config.log_dir + "failed_enroll_"; + fail_filename += id; + fail_filename += "_"; + fail_filename += username; + fail_filename += ".jpg"; + if (cv::imwrite(fail_filename, frame)) { + Logger::log(LogLevel::INFO, + "Debug frame saved: " + fail_filename + + " - inspect to verify camera output."); + } else { + Logger::log(LogLevel::WARN, "Could not save debug frame: " + fail_filename); } + return {false, err}; } diff --git a/src/service/config.hpp b/src/service/config.hpp index 3c0364f..b2d5241 100644 --- a/src/service/config.hpp +++ b/src/service/config.hpp @@ -15,7 +15,11 @@ class Configuration { // TODO: Move defaults to cpp or keep here? Keeping here for easy reference // akin to previous struct static constexpr float DEFAULT_THRESHOLD = 0.363f; - static constexpr float DEFAULT_DETECTION_THRESHOLD = 0.9f; + // Lowered from 0.9 to 0.6: IR cameras (e.g. Lenovo, Dell) produce low-contrast + // grayscale images where YuNet confidence scores rarely exceed 0.7. + // A threshold of 0.9 works well for RGB webcams but effectively blocks + // all detection on IR streams. Override via detection_threshold in config.ini. + static constexpr float DEFAULT_DETECTION_THRESHOLD = 0.6f; static constexpr int DEFAULT_TIMEOUT_MS = 3000; static constexpr int DEFAULT_MAX_EMBEDDINGS = 5; static constexpr int DEFAULT_ENROLL_AVG_FRAMES = 5;