Skip to content

Commit 1b8405b

Browse files
committed
make outlier detection trend aware
1 parent 1f44d30 commit 1b8405b

2 files changed

Lines changed: 78 additions & 24 deletions

File tree

‎sharp_frames/selection_methods.py‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Canonical frame-selection algorithms shared by the CLI and TUI."""
22

33
from collections import OrderedDict
4-
from math import log1p
4+
from math import expm1, log1p
55
from statistics import median
66
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
77

@@ -14,8 +14,8 @@
1414
OUTLIER_DEFAULT_WINDOW_SIZE = 15
1515
OUTLIER_DEFAULT_SENSITIVITY = 60
1616
OUTLIER_MIN_WINDOW_SIZE = 5
17-
OUTLIER_MIN_RELATIVE_DROP = 0.05
18-
OUTLIER_MAX_RELATIVE_DROP = 0.30
17+
OUTLIER_MIN_RELATIVE_DROP = 0.005
18+
OUTLIER_MAX_RELATIVE_DROP = 0.08
1919
OUTLIER_MIN_ROBUST_THRESHOLD = 1.5
2020

2121

@@ -204,6 +204,20 @@ def _outlier_positions(
204204
for _, frame in group
205205
]
206206
scores = [log1p(score) for score in raw_scores]
207+
dip_magnitudes = [0.0] * len(scores)
208+
relative_drops = [0.0] * len(scores)
209+
for position in range(1, len(scores) - 1):
210+
# A local dip must sit below both adjacent frames. Using the lower
211+
# neighbor as the baseline prevents one unusually sharp neighbor from
212+
# making an otherwise ordinary frame look like an outlier.
213+
expected_score = min(scores[position - 1], scores[position + 1])
214+
dip_magnitudes[position] = max(0.0, expected_score - scores[position])
215+
expected_raw_score = expm1(expected_score)
216+
if expected_raw_score > 0:
217+
relative_drops[position] = max(
218+
0.0,
219+
(expected_raw_score - raw_scores[position]) / expected_raw_score,
220+
)
207221

208222
actual_window_size = max(OUTLIER_MIN_WINDOW_SIZE, window_size)
209223
if actual_window_size % 2 == 0:
@@ -221,38 +235,33 @@ def _outlier_positions(
221235
)
222236
outliers: Set[int] = set()
223237

224-
for position, current_score in enumerate(scores):
238+
for position, dip_magnitude in enumerate(dip_magnitudes):
239+
if position == 0 or position == len(scores) - 1:
240+
continue
225241
window_start = max(0, position - half_window)
226242
window_end = min(len(group), position + half_window + 1)
227-
neighbor_scores = scores[window_start:position] + scores[position + 1 : window_end]
243+
neighbor_dips = (
244+
dip_magnitudes[window_start:position]
245+
+ dip_magnitudes[position + 1 : window_end]
246+
)
228247
required_neighbors = max(2, min_neighbors)
229-
if len(neighbor_scores) < required_neighbors:
248+
if len(neighbor_dips) < required_neighbors:
230249
continue
231-
neighbor_median = median(neighbor_scores)
232-
raw_neighbor_scores = (
233-
raw_scores[window_start:position]
234-
+ raw_scores[position + 1 : window_end]
235-
)
236-
raw_neighbor_median = median(raw_neighbor_scores)
250+
neighbor_median = median(neighbor_dips)
237251
absolute_deviations = [
238-
abs(score - neighbor_median) for score in neighbor_scores
252+
abs(neighbor_dip - neighbor_median) for neighbor_dip in neighbor_dips
239253
]
240254
median_absolute_deviation = median(absolute_deviations)
241255
robust_scale = median_absolute_deviation * 1.4826
242256
if robust_scale == 0:
243257
robust_scale = max(abs(neighbor_median) * 0.01, 1e-9)
244258

245-
deficit = neighbor_median - current_score
246-
robust_deficit = deficit / robust_scale
247-
relative_drop = (
248-
(raw_neighbor_median - raw_scores[position]) / raw_neighbor_median
249-
if raw_neighbor_median > 0
250-
else 0.0
251-
)
259+
excess_dip = dip_magnitude - neighbor_median
260+
robust_excess = excess_dip / robust_scale
252261
if (
253-
deficit > 0
254-
and relative_drop >= minimum_relative_drop
255-
and robust_deficit > robust_threshold
262+
excess_dip > 0
263+
and relative_drops[position] >= minimum_relative_drop
264+
and robust_excess > robust_threshold
256265
):
257266
outliers.add(position)
258267
return outliers

‎tests/test_selection_quality.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def test_labeled_blur_probe_tracks_rejection_precision_and_recall():
154154

155155
def test_default_outlier_detection_requires_a_meaningful_relative_drop():
156156
scores = [100.0] * 15
157-
scores[5] = 90.0
157+
scores[5] = 98.0
158158
scores[9] = 75.0
159159
frames = [
160160
{"index": index, "sharpnessScore": score}
@@ -187,3 +187,48 @@ def rejected_dip(baseline, dip):
187187

188188
assert rejected_dip(10.0, 7.0) is True
189189
assert rejected_dip(1_000.0, 700.0) is True
190+
191+
192+
def test_smooth_trough_is_not_mistaken_for_an_outlier_cluster():
193+
scores = [
194+
162.32, 162.21, 161.43, 159.67, 160.00, 159.41, 159.17,
195+
157.90, 158.01, 160.00, 160.01, 160.47, 160.46, 160.53,
196+
160.82, 160.60, 160.10, 159.10, 158.01, 157.06, 155.20,
197+
152.87, 149.68, 148.97, 150.22, 153.09, 156.55, 158.64,
198+
159.78, 160.12, 160.68, 161.82, 162.06, 162.84, 162.69,
199+
163.60, 165.84, 165.63, 164.79, 163.05,
200+
]
201+
frames = [
202+
{"index": index, "sharpnessScore": score}
203+
for index, score in enumerate(scores)
204+
]
205+
206+
assessed = select_outlier_removal_frames_core(
207+
frames,
208+
window_size=31,
209+
sensitivity=100,
210+
)
211+
212+
assert all(assessed[position]["selected"] for position in (22, 23, 24))
213+
214+
215+
def test_abrupt_dip_in_a_broad_trend_is_detected_at_maximum_sensitivity():
216+
scores = [
217+
191.30, 190.19, 191.34, 189.59, 188.12, 185.22, 180.47,
218+
177.02, 173.26, 170.84, 168.32, 165.99, 163.30, 162.04,
219+
165.47, 166.67, 169.98, 173.32, 172.83, 169.09, 169.62,
220+
169.61, 168.71, 167.32, 164.46, 162.95, 162.07, 160.60,
221+
158.71, 157.39, 155.58,
222+
]
223+
frames = [
224+
{"index": index, "sharpnessScore": score}
225+
for index, score in enumerate(scores)
226+
]
227+
228+
assessed = select_outlier_removal_frames_core(
229+
frames,
230+
window_size=31,
231+
sensitivity=100,
232+
)
233+
234+
assert assessed[13]["selected"] is False

0 commit comments

Comments
 (0)