obico_smoothing.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """Temporal smoothing for Obico ML detection scores.
  2. A port of Obico's own decision (obico-server ``lib/prediction.py``,
  3. ``update_prediction_with_detections`` and ``is_failing``; parameters from
  4. ``FD_1ST_GEN_PARAMS``):
  5. - per-frame ``current_p`` = sum of detection confidences
  6. - ``ewm_mean`` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
  7. - ``rolling_mean_short`` = mean of the print's last 310 frames (~52 min at 10 s)
  8. - ``rolling_mean_long`` = mean of the printer's last 7200 frames (~20 h),
  9. kept across prints: whatever the camera always sees (plate texture,
  10. labels) is baseline, not failure (#3459)
  11. - the score is ``ewm_mean - rolling_mean_long``. Below LOW it is safe, above
  12. HIGH failing; in between it is failing only when it also beats
  13. ``(rolling_mean_short - rolling_mean_long) * 3.8``
  14. - "warning" is failing at the plain thresholds (Obico's alert); "failure"
  15. is failing with the score divided by 1.75 (Obico's pause), so pausing
  16. takes a much clearer signal than a notification
  17. - the first ``WARMUP_FRAMES`` frames of a print are always safe
  18. Obico multiplies the score by the printer's sensitivity; scaling the LOW/HIGH
  19. thresholds by its inverse is the same, and is what the status panel shows.
  20. """
  21. import math
  22. from collections import deque
  23. from dataclasses import dataclass, field
  24. EWM_SPAN = 12
  25. EWM_ALPHA = 2.0 / (EWM_SPAN + 1)
  26. ROLLING_SHORT = 310
  27. ROLLING_LONG = 7200
  28. WARMUP_FRAMES = 30
  29. ROLLING_MEAN_SHORT_MULTIPLE = 3.8
  30. ESCALATING_FACTOR = 1.75
  31. # Base thresholds; sensitivity multipliers adjust them
  32. BASE_LOW = 0.38
  33. BASE_HIGH = 0.78
  34. SENSITIVITY_MULT = {
  35. "low": 1.25, # harder to trigger — higher thresholds
  36. "medium": 1.0,
  37. "high": 0.75, # easier to trigger — lower thresholds
  38. }
  39. def thresholds(sensitivity: str) -> tuple[float, float]:
  40. mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
  41. return BASE_LOW * mult, BASE_HIGH * mult
  42. class _RollingMean:
  43. """Exact mean of the last ``size`` values."""
  44. def __init__(self, size: int):
  45. self._buf: deque = deque(maxlen=size)
  46. self._sum = 0.0
  47. def add(self, value: float) -> None:
  48. if len(self._buf) == self._buf.maxlen:
  49. self._sum -= self._buf[0]
  50. self._buf.append(value)
  51. self._sum += value
  52. @property
  53. def mean(self) -> float:
  54. return self._sum / len(self._buf) if self._buf else 0.0
  55. class Baseline(_RollingMean):
  56. """A printer's long-term mean, kept across its prints (in memory)."""
  57. def __init__(self):
  58. super().__init__(ROLLING_LONG)
  59. @dataclass
  60. class Reading:
  61. """One frame's smoothed state, as Obico's is_failing() sees it."""
  62. score: float = 0.0 # ewm_mean - rolling_mean_long
  63. short_rise: float = 0.0 # rolling_mean_short - rolling_mean_long
  64. warmup: bool = True
  65. @dataclass
  66. class PrintState:
  67. """Per-print smoothing state. Reset when a new print starts; the
  68. baseline it is given belongs to the printer and outlives it."""
  69. baseline: Baseline = field(default_factory=Baseline)
  70. frame_count: int = 0
  71. ewm_mean: float = 0.0
  72. short: _RollingMean = field(default_factory=lambda: _RollingMean(ROLLING_SHORT))
  73. last: Reading = field(default_factory=Reading)
  74. def update(self, current_p: float) -> Reading:
  75. """Feed a new per-frame score and return the smoothed reading."""
  76. self.frame_count += 1
  77. if self.frame_count == 1:
  78. self.ewm_mean = current_p
  79. else:
  80. self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
  81. self.short.add(current_p)
  82. self.baseline.add(current_p)
  83. long_mean = self.baseline.mean
  84. self.last = Reading(
  85. score=self.ewm_mean - long_mean,
  86. short_rise=self.short.mean - long_mean,
  87. warmup=self.frame_count <= WARMUP_FRAMES,
  88. )
  89. return self.last
  90. def _is_failing(reading: Reading, sensitivity: str, escalating_factor: float) -> bool:
  91. if reading.warmup:
  92. return False
  93. low, high = thresholds(sensitivity)
  94. score = reading.score / escalating_factor
  95. if score < low:
  96. return False
  97. if score > high:
  98. return True
  99. # Thresholds carry the sensitivity here; Obico scales the score instead,
  100. # so the short-window check gets the same factor to compare like with like.
  101. mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
  102. return score > reading.short_rise * ROLLING_MEAN_SHORT_MULTIPLE * mult
  103. def classify(reading: Reading, sensitivity: str) -> str:
  104. """'safe', 'warning' (Obico would alert) or 'failure' (Obico would pause)."""
  105. if _is_failing(reading, sensitivity, ESCALATING_FACTOR):
  106. return "failure"
  107. if _is_failing(reading, sensitivity, 1.0):
  108. return "warning"
  109. return "safe"
  110. def triggers_action(verdict: str, action: str) -> bool:
  111. """A notification goes out at Obico's alert level; pausing, with or
  112. without power-off, waits for its pause level."""
  113. if action == "notify":
  114. return verdict in ("warning", "failure")
  115. return verdict == "failure"
  116. def score_from_detections(detections: list) -> float:
  117. """Sum confidences from the ML API `detections` array.
  118. Each detection is `[label, confidence, [x, y, w, h]]`. We only care about
  119. the confidence column — label is always "failure" for the single-class model.
  120. """
  121. total = 0.0
  122. for det in detections or []:
  123. try:
  124. value = float(det[1])
  125. except (IndexError, TypeError, ValueError):
  126. continue
  127. if math.isnan(value) or math.isinf(value):
  128. continue
  129. total += value
  130. return total