| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- """Temporal smoothing for Obico ML detection scores.
- A port of Obico's own decision (obico-server ``lib/prediction.py``,
- ``update_prediction_with_detections`` and ``is_failing``; parameters from
- ``FD_1ST_GEN_PARAMS``):
- - per-frame ``current_p`` = sum of detection confidences
- - ``ewm_mean`` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)
- - ``rolling_mean_short`` = mean of the print's last 310 frames (~52 min at 10 s)
- - ``rolling_mean_long`` = mean of the printer's last 7200 frames (~20 h),
- kept across prints: whatever the camera always sees (plate texture,
- labels) is baseline, not failure (#3459)
- - the score is ``ewm_mean - rolling_mean_long``. Below LOW it is safe, above
- HIGH failing; in between it is failing only when it also beats
- ``(rolling_mean_short - rolling_mean_long) * 3.8``
- - "warning" is failing at the plain thresholds (Obico's alert); "failure"
- is failing with the score divided by 1.75 (Obico's pause), so pausing
- takes a much clearer signal than a notification
- - the first ``WARMUP_FRAMES`` frames of a print are always safe
- Obico multiplies the score by the printer's sensitivity; scaling the LOW/HIGH
- thresholds by its inverse is the same, and is what the status panel shows.
- """
- import math
- from collections import deque
- from dataclasses import dataclass, field
- EWM_SPAN = 12
- EWM_ALPHA = 2.0 / (EWM_SPAN + 1)
- ROLLING_SHORT = 310
- ROLLING_LONG = 7200
- WARMUP_FRAMES = 30
- ROLLING_MEAN_SHORT_MULTIPLE = 3.8
- ESCALATING_FACTOR = 1.75
- # Base thresholds; sensitivity multipliers adjust them
- BASE_LOW = 0.38
- BASE_HIGH = 0.78
- SENSITIVITY_MULT = {
- "low": 1.25, # harder to trigger — higher thresholds
- "medium": 1.0,
- "high": 0.75, # easier to trigger — lower thresholds
- }
- def thresholds(sensitivity: str) -> tuple[float, float]:
- mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
- return BASE_LOW * mult, BASE_HIGH * mult
- class _RollingMean:
- """Exact mean of the last ``size`` values."""
- def __init__(self, size: int):
- self._buf: deque = deque(maxlen=size)
- self._sum = 0.0
- def add(self, value: float) -> None:
- if len(self._buf) == self._buf.maxlen:
- self._sum -= self._buf[0]
- self._buf.append(value)
- self._sum += value
- @property
- def mean(self) -> float:
- return self._sum / len(self._buf) if self._buf else 0.0
- class Baseline(_RollingMean):
- """A printer's long-term mean, kept across its prints (in memory)."""
- def __init__(self):
- super().__init__(ROLLING_LONG)
- @dataclass
- class Reading:
- """One frame's smoothed state, as Obico's is_failing() sees it."""
- score: float = 0.0 # ewm_mean - rolling_mean_long
- short_rise: float = 0.0 # rolling_mean_short - rolling_mean_long
- warmup: bool = True
- @dataclass
- class PrintState:
- """Per-print smoothing state. Reset when a new print starts; the
- baseline it is given belongs to the printer and outlives it."""
- baseline: Baseline = field(default_factory=Baseline)
- frame_count: int = 0
- ewm_mean: float = 0.0
- short: _RollingMean = field(default_factory=lambda: _RollingMean(ROLLING_SHORT))
- last: Reading = field(default_factory=Reading)
- def update(self, current_p: float) -> Reading:
- """Feed a new per-frame score and return the smoothed reading."""
- self.frame_count += 1
- if self.frame_count == 1:
- self.ewm_mean = current_p
- else:
- self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean
- self.short.add(current_p)
- self.baseline.add(current_p)
- long_mean = self.baseline.mean
- self.last = Reading(
- score=self.ewm_mean - long_mean,
- short_rise=self.short.mean - long_mean,
- warmup=self.frame_count <= WARMUP_FRAMES,
- )
- return self.last
- def _is_failing(reading: Reading, sensitivity: str, escalating_factor: float) -> bool:
- if reading.warmup:
- return False
- low, high = thresholds(sensitivity)
- score = reading.score / escalating_factor
- if score < low:
- return False
- if score > high:
- return True
- # Thresholds carry the sensitivity here; Obico scales the score instead,
- # so the short-window check gets the same factor to compare like with like.
- mult = SENSITIVITY_MULT.get(sensitivity, 1.0)
- return score > reading.short_rise * ROLLING_MEAN_SHORT_MULTIPLE * mult
- def classify(reading: Reading, sensitivity: str) -> str:
- """'safe', 'warning' (Obico would alert) or 'failure' (Obico would pause)."""
- if _is_failing(reading, sensitivity, ESCALATING_FACTOR):
- return "failure"
- if _is_failing(reading, sensitivity, 1.0):
- return "warning"
- return "safe"
- def triggers_action(verdict: str, action: str) -> bool:
- """A notification goes out at Obico's alert level; pausing, with or
- without power-off, waits for its pause level."""
- if action == "notify":
- return verdict in ("warning", "failure")
- return verdict == "failure"
- def score_from_detections(detections: list) -> float:
- """Sum confidences from the ML API `detections` array.
- Each detection is `[label, confidence, [x, y, w, h]]`. We only care about
- the confidence column — label is always "failure" for the single-class model.
- """
- total = 0.0
- for det in detections or []:
- try:
- value = float(det[1])
- except (IndexError, TypeError, ValueError):
- continue
- if math.isnan(value) or math.isinf(value):
- continue
- total += value
- return total
|