From cfc74cc3f27517cac0a1afad04f541ead45ec7a4 Mon Sep 17 00:00:00 2001 From: Bora Date: Thu, 1 Jan 2026 11:28:57 +0100 Subject: [PATCH] 4: debugging flag --- __pycache__/inference.cpython-311.pyc | Bin 13889 -> 14262 bytes app.py | 176 +++++++++++++++++--------- inference.py | 69 ++++++---- 3 files changed, 157 insertions(+), 88 deletions(-) diff --git a/__pycache__/inference.cpython-311.pyc b/__pycache__/inference.cpython-311.pyc index 6d782e69c580a0458befa4428a9981e7a7009ade..fab3036699ca5165ab327dc978c9348b4b00ef46 100644 GIT binary patch delta 4168 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 every_s: + _last_log[key] = now + logger.log(level, msg) + + +def log_debug(key, msg, every_s=0): + """Debug-only logging with optional rate limiting.""" + if not DEBUG_LOG: + return + if every_s and every_s > 0: + log_rl(logging.DEBUG, key, msg, every_s=every_s) + else: + logger.debug(msg) + + +def log_condition(camera_id: str, cond_key: str, msg: str, *, crucial=False, + debug_level=logging.DEBUG, debug_every=5, + nodebug_level=logging.WARNING, nodebug_every=60): + """Log conditions (skip reasons, degraded state) without spamming. + + - If DEBUG_LOG=True -> frequent detailed logs. + - If DEBUG_LOG=False -> only rate-limited warnings for crucial conditions. + """ + key = f"{camera_id}:{cond_key}" + if DEBUG_LOG: + log_rl(debug_level, key, msg, every_s=debug_every) + return + + if crucial: + log_rl(nodebug_level, key, msg, every_s=nodebug_every) + + def crop_image_for_ui(image, roi_list, scaleX, scaleY): """Helper for the /crop endpoint (UI preview only).""" cropped_images = [] @@ -89,6 +146,7 @@ def publish_detected_number(camera_id, detected_number, confidence=None): try: mqtt_client.publish(topic, payload) + # Keep this INFO even when debug is off: it's the primary business output. log_msg = f"Published to {topic}: {detected_number}" if confidence is not None: log_msg += f" (Conf: {confidence:.2f})" @@ -97,43 +155,31 @@ def publish_detected_number(camera_id, detected_number, confidence=None): logger.error("MQTT Publish failed: %s", e) -# --- Debug helpers --- -_last_log = {} - -def log_rl(level, key, msg, every_s=10): - now = time.time() - last = _last_log.get(key, 0.0) - if now - last >= every_s: - _last_log[key] = now - logger.log(level, msg) - - -# --- Main Processing Loop (Refactored) --- +# --- Main processing loop --- last_processed_time = {} def process_all_cameras(): - """Revised loop with rate limiting + debug instrumentation.""" - DETECTION_INTERVAL = int(_cfg("DETECTION_INTERVAL", default=10)) hb_last = 0.0 while True: try: - # Heartbeat (proves loop is alive even when no publishes happen) - now = time.time() - if now - hb_last >= 5.0: - hb_last = now - in_q = getattr(inference_worker, "input_queue", None) - out_q = getattr(inference_worker, "result_queue", None) - logger.info( - "HB mainloop alive; in_q=%s out_q=%s dropped=%s processed=%s last_invoke_s=%s", - (in_q.qsize() if in_q else "n/a"), - (out_q.qsize() if out_q else "n/a"), - getattr(inference_worker, "dropped_tasks", "n/a"), - getattr(inference_worker, "processed_tasks", "n/a"), - getattr(inference_worker, "last_invoke_secs", "n/a"), - ) + # Heartbeat only in debug mode + if DEBUG_LOG: + now = time.time() + if now - hb_last >= 5.0: + hb_last = now + in_q = getattr(inference_worker, "input_queue", None) + out_q = getattr(inference_worker, "result_queue", None) + logger.info( + "HB mainloop alive; in_q=%s out_q=%s dropped=%s processed=%s last_invoke_s=%s", + (in_q.qsize() if in_q else "n/a"), + (out_q.qsize() if out_q else "n/a"), + getattr(inference_worker, "dropped_tasks", "n/a"), + getattr(inference_worker, "processed_tasks", "n/a"), + getattr(inference_worker, "last_invoke_secs", "n/a"), + ) - # --- Part 1: Process Results --- + # --- Part 1: process results --- while True: result = inference_worker.get_result() if not result: @@ -141,11 +187,10 @@ def process_all_cameras(): cam_id = result.get('camera_id') - # End-to-end latency tracing - task_ts = result.get("task_ts") - if task_ts is not None: + # Debug-only latency trace + if DEBUG_LOG and result.get("task_ts") is not None: try: - age = time.time() - float(task_ts) + age = time.time() - float(result["task_ts"]) logger.info( "Result cam=%s type=%s task_id=%s age_s=%.3f timing=%s", cam_id, @@ -162,11 +207,20 @@ def process_all_cameras(): conf = result.get('confidence') camera_manager.results[cam_id] = val publish_detected_number(cam_id, val, conf) + elif result.get('type') == 'error': msg = result.get('message', 'Unknown error') - logger.warning("[%s] Detection skipped: %s", cam_id, msg) - # --- Part 2: Feed Frames --- + # When debug is off, avoid spamming "Low confidence" messages. + if DEBUG_LOG: + logger.warning("[%s] Detection skipped: %s", cam_id, msg) + else: + # Crucial errors: rate-limited warnings. + # Filter out "Low confidence" unless it's crucial for you. + if not str(msg).lower().startswith("low confidence"): + log_condition(cam_id, "detect_error", f"[{cam_id}] Detection skipped: {msg}", crucial=True) + + # --- Part 2: feed frames --- camera_manager.load_roi_config() for camera_id, camera_data in camera_manager.cameras.items(): @@ -177,45 +231,40 @@ def process_all_cameras(): last_time = last_processed_time.get(camera_id, 0.0) if current_time - last_time < DETECTION_INTERVAL: - log_rl( - logging.DEBUG, - f"{camera_id}:rate", - f"[{camera_id}] skip: rate limit ({current_time - last_time:.2f}s<{DETECTION_INTERVAL}s)", - every_s=30, - ) + log_debug(f"{camera_id}:rate", f"[{camera_id}] skip: rate limit", every_s=30) continue stream = camera_data.get("stream") if not stream: - log_rl(logging.WARNING, f"{camera_id}:nostream", f"[{camera_id}] skip: no stream", every_s=10) + log_condition(camera_id, "nostream", f"[{camera_id}] skip: no stream", crucial=True) continue - # Warmup check start_time = getattr(stream, "start_time", getattr(stream, "starttime", None)) if start_time is not None and (current_time - start_time) < 5: - log_rl(logging.DEBUG, f"{camera_id}:warmup", f"[{camera_id}] skip: warmup", every_s=10) + log_debug(f"{camera_id}:warmup", f"[{camera_id}] skip: warmup", every_s=10) continue frame = stream.read() if frame is None: - log_rl(logging.WARNING, f"{camera_id}:noframe", f"[{camera_id}] skip: frame is None", every_s=5) + log_condition(camera_id, "noframe", f"[{camera_id}] skip: frame is None", crucial=True) continue + # STD Check frame_std = float(np.std(frame)) - if frame_std < 5: - log_rl( - logging.INFO, - f"{camera_id}:lowstd", - f"[{camera_id}] skip: low frame std={frame_std:.2f} (<10) (disturbed/blank/frozen?)", - every_s=5, + if frame_std < FRAME_STD_THRESHOLD: + log_condition( + camera_id, + "lowstd", + f"[{camera_id}] skip: low frame std={frame_std:.2f} (<{FRAME_STD_THRESHOLD})", + crucial=True, + debug_every=5, + nodebug_every=60, ) - mqtt_client.publish(f"{Config.MQTT_TOPIC}/{camera_id}/status", "disturbed") - continue roi_list = camera_manager.rois.get(camera_id, []) if not roi_list: - log_rl(logging.WARNING, f"{camera_id}:norois", f"[{camera_id}] skip: no ROIs", every_s=30) + log_condition(camera_id, "norois", f"[{camera_id}] skip: no ROIs configured", crucial=True) continue inference_worker.add_task(camera_id, roi_list, frame, frame_std=frame_std) @@ -229,7 +278,7 @@ def process_all_cameras(): time.sleep(5) -# --- Flask Routes --- +# --- Flask routes --- @app.route('/') def index(): return render_template('index.html') @@ -405,7 +454,8 @@ def detect_digits(): if p['confidence'] < CONFIDENCE_THRESHOLD: msg = f"Digit {i} ('{p['digit']}') rejected: conf {p['confidence']:.2f} < {CONFIDENCE_THRESHOLD}" rejected_reasons.append(msg) - logger.warning("[Manual] %s", msg) + if DEBUG_LOG: + logger.warning("[Manual] %s", msg) else: valid_digits_str.append(p['digit']) confidences.append(p['confidence']) @@ -419,7 +469,8 @@ def detect_digits(): if not (MIN_VALUE <= final_number <= MAX_VALUE): msg = f"Value {final_number} out of range ({MIN_VALUE}-{MAX_VALUE})" - logger.warning("[Manual] %s", msg) + if DEBUG_LOG: + logger.warning("[Manual] %s", msg) return jsonify({'error': 'Value out of range', 'value': final_number}), 400 avg_conf = float(np.mean(confidences)) if confidences else None @@ -448,7 +499,6 @@ def update_camera_config(): return jsonify({"success": success}) -# --- Main --- if __name__ == '__main__': t = threading.Thread(target=process_all_cameras, daemon=True) t.start() diff --git a/inference.py b/inference.py index 1b31e69..e4d8c82 100644 --- a/inference.py +++ b/inference.py @@ -9,8 +9,22 @@ import tflite_runtime.interpreter as tflite from config import Config -logger = logging.getLogger(__name__) +# ------------------------------------------------------------------------------ +# 1. USER CONFIGURATION (Edit these values here) +# ------------------------------------------------------------------------------ +# Minimum confidence (0-1) to accept a digit. +# - Higher (0.85-0.90) reduces false positives like "1010" from noise. +# - Lower (0.70-0.75) helps with weak/dark digits. +CONFIDENCE_THRESHOLD = 0.1 + +# Minimum and Maximum expected values for the number. +MIN_VALUE = 5 +MAX_VALUE = 100 + +# ------------------------------------------------------------------------------ + +logger = logging.getLogger(__name__) def _cfg(*names, default=None): for n in names: @@ -20,7 +34,9 @@ def _cfg(*names, default=None): class InferenceWorker: - def __init__(self): + def __init__(self, debug_log: bool = False): + self.debug_log = bool(debug_log) + self.input_queue = queue.Queue(maxsize=10) self.result_queue = queue.Queue() self.running = False @@ -36,10 +52,10 @@ class InferenceWorker: self.processed_tasks = 0 self.last_invoke_secs = None - # Validation thresholds - self.CONFIDENCE_THRESHOLD = 0.10 - self.MIN_VALUE = 5 - self.MAX_VALUE = 100 + # Set thresholds from top-level variables + self.CONFIDENCE_THRESHOLD = CONFIDENCE_THRESHOLD + self.MIN_VALUE = MIN_VALUE + self.MAX_VALUE = MAX_VALUE self.load_model() @@ -55,7 +71,8 @@ class InferenceWorker: self.output_details = self.interpreter.get_output_details() self.original_input_shape = self.input_details[0]['shape'] - logger.info("Model loaded. Default input shape: %s", self.original_input_shape) + if self.debug_log: + logger.info("Model loaded. Default input shape: %s", self.original_input_shape) except Exception as e: logger.critical("Failed to load TFLite model: %s", e) @@ -101,11 +118,9 @@ class InferenceWorker: return None def _put_result(self, d): - """Best-effort put so failures never go silent.""" try: self.result_queue.put(d, block=False) except Exception: - # Should be extremely rare; log + drop logger.exception("Failed to enqueue result") def _worker_loop(self): @@ -121,18 +136,22 @@ class InferenceWorker: task_id = task.get('task_id') task_ts = task.get('timestamp') - try: - age_s = (time.time() - task_ts) if task_ts else None - logger.info( - "Worker got task cam=%s task_id=%s age_s=%s frame_std=%s rois=%d in_q=%d", - cam_id, - task_id, - (f"{age_s:.3f}" if age_s is not None else "n/a"), - task.get('frame_std'), - len(rois) if rois else 0, - self.input_queue.qsize(), - ) + if self.debug_log: + try: + age_s = (time.time() - task_ts) if task_ts else None + logger.info( + "Worker got task cam=%s task_id=%s age_s=%s frame_std=%s rois=%d in_q=%d", + cam_id, + task_id, + (f"{age_s:.3f}" if age_s is not None else "n/a"), + task.get('frame_std'), + len(rois) if rois else 0, + self.input_queue.qsize(), + ) + except Exception: + pass + try: t0 = time.time() crops = self._crop_rois(frame, rois) t_crop = time.time() @@ -213,6 +232,7 @@ class InferenceWorker: 'task_ts': task_ts, 'timing_s': {'crop': t_crop - t0, 'predict': t_pred - t_crop, 'total': t_pred - t0}, }) + self.processed_tasks += 1 else: self._put_result({ 'type': 'error', @@ -224,8 +244,6 @@ class InferenceWorker: 'timing_s': {'crop': t_crop - t0, 'predict': t_pred - t_crop, 'total': t_pred - t0}, }) - self.processed_tasks += 1 - except Exception: logger.exception("Inference error cam=%s task_id=%s", cam_id, task_id) self._put_result({ @@ -275,7 +293,7 @@ class InferenceWorker: input_tensor = np.array(batch_input) - # NOTE: Keeping original behavior (resize+allocate) but timing it. + # Keep current behavior (resize+allocate per batch). Debug timing is optional. self.interpreter.resize_tensor_input(input_index, [num_images, target_h, target_w, 3]) self.interpreter.allocate_tensors() @@ -284,7 +302,8 @@ class InferenceWorker: t0 = time.time() self.interpreter.invoke() self.last_invoke_secs = time.time() - t0 - if self.last_invoke_secs > 1.0: + + if self.debug_log and self.last_invoke_secs and self.last_invoke_secs > 1.0: logger.warning("Slow invoke: %.3fs (batch=%d)", self.last_invoke_secs, num_images) output_data = self.interpreter.get_tensor(output_index) @@ -293,7 +312,7 @@ class InferenceWorker: for i in range(num_images): logits = output_data[i] - # More stable softmax + # Numerically stable softmax logits = logits - np.max(logits) ex = np.exp(logits) denom = np.sum(ex)