Skip to content

Commit 5499360

Browse files
committed
improve outlier detection defaults
1 parent 86dd1c6 commit 5499360

13 files changed

Lines changed: 120 additions & 45 deletions

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ Divides content into batches and selects the sharpest frame from each batch.
6565
--selection-method batched --batch-size 5 --batch-buffer 2
6666
```
6767

68-
### Outlier Removal
68+
### Outlier Detection
6969
Removes unusually blurry frames by comparing each frame to its neighbors.
7070
```bash
71-
--selection-method outlier-removal --outlier-window-size 15 --outlier-sensitivity 50
71+
--selection-method outlier-removal --outlier-window-size 15 --outlier-sensitivity 60
7272
```
7373

7474
## Command Line Options
@@ -84,8 +84,8 @@ Removes unusually blurry frames by comparing each frame to its neighbors.
8484
- `--min-buffer <int>`: Minimum number of intervening frames between selected frames (best-n, default: 3)
8585
- `--batch-size <int>`: Frames per batch (batched, default: 5)
8686
- `--batch-buffer <int>`: Frames to skip between batches (batched, default: 2)
87-
- `--outlier-window-size <int>`: Neighbor comparison window (outlier-removal, default: 15)
88-
- `--outlier-sensitivity <int>`: Removal aggressiveness 0-100 (outlier-removal, default: 50)
87+
- `--outlier-window-size <int>`: Local comparison window, minimum 5 (outlier-removal, default: 15)
88+
- `--outlier-sensitivity <int>`: Detection sensitivity 0-100 (outlier-removal, default: 60)
8989

9090
## Examples
9191

‎sharp_frames/processing/frame_saver.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ def _get_method_params_for_metadata(self, config: Dict[str, Any]) -> Dict[str, A
382382
})
383383
elif selection_method == 'outlier_removal':
384384
params.update({
385-
"outlier_sensitivity": config.get('outlier_sensitivity', 50),
385+
"outlier_sensitivity": config.get('outlier_sensitivity', 60),
386386
"outlier_window_size": config.get('outlier_window_size', 15)
387387
})
388388

‎sharp_frames/processing/frame_selector.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from ..models.frame_data import FrameData
66
from ..selection_methods import (
7+
OUTLIER_DEFAULT_SENSITIVITY,
8+
OUTLIER_DEFAULT_WINDOW_SIZE,
79
select_batched_frames_core,
810
select_best_n_frames_core,
911
select_outlier_removal_frames_core,
@@ -47,8 +49,8 @@ def select_frames(
4749
if method in {"outlier_removal", "outlier-removal"}:
4850
return self._select_outlier_removal_frames(
4951
frames,
50-
params.get("outlier_sensitivity", 50),
51-
params.get("outlier_window_size", 15),
52+
params.get("outlier_sensitivity", OUTLIER_DEFAULT_SENSITIVITY),
53+
params.get("outlier_window_size", OUTLIER_DEFAULT_WINDOW_SIZE),
5254
)
5355
raise ValueError(f"Unsupported selection method: {method}")
5456

‎sharp_frames/processing/tui_processor.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,13 @@ def validate_selection_parameters(self, method: str, **params) -> tuple[bool, st
339339
return False, "Parameter 'batch_buffer' must be a non-negative integer"
340340

341341
elif method == 'outlier_removal':
342-
outlier_sensitivity = params.get('outlier_sensitivity', 50)
342+
outlier_sensitivity = params.get('outlier_sensitivity', 60)
343343
if not isinstance(outlier_sensitivity, int) or not (0 <= outlier_sensitivity <= 100):
344344
return False, "Parameter 'outlier_sensitivity' must be an integer between 0 and 100"
345345

346346
outlier_window_size = params.get('outlier_window_size', 15)
347-
if not isinstance(outlier_window_size, int) or outlier_window_size <= 0:
348-
return False, "Parameter 'outlier_window_size' must be a positive integer"
347+
if not isinstance(outlier_window_size, int) or outlier_window_size < 5:
348+
return False, "Parameter 'outlier_window_size' must be at least 5"
349349

350350
else:
351351
return False, f"Unknown selection method: {method}"

‎sharp_frames/selection_methods.py‎

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

33
from collections import OrderedDict
4+
from math import log1p
45
from statistics import median
56
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
67

@@ -10,6 +11,13 @@
1011
Frame = Dict[str, Any]
1112
PositionedFrame = Tuple[int, Frame]
1213

14+
OUTLIER_DEFAULT_WINDOW_SIZE = 15
15+
OUTLIER_DEFAULT_SENSITIVITY = 60
16+
OUTLIER_MIN_WINDOW_SIZE = 5
17+
OUTLIER_MIN_RELATIVE_DROP = 0.05
18+
OUTLIER_MAX_RELATIVE_DROP = 0.30
19+
OUTLIER_MIN_ROBUST_THRESHOLD = 1.5
20+
1321

1422
def _source_key(frame: Frame) -> Optional[str]:
1523
"""Return a stable source identifier, if the frame belongs to a video."""
@@ -191,35 +199,61 @@ def _outlier_positions(
191199
if sensitivity <= 0 or not group:
192200
return set()
193201

194-
scores = [float(frame.get("sharpnessScore", 0) or 0) for _, frame in group]
202+
raw_scores = [
203+
max(0.0, float(frame.get("sharpnessScore", 0) or 0))
204+
for _, frame in group
205+
]
206+
scores = [log1p(score) for score in raw_scores]
195207

196-
actual_window_size = max(1, window_size)
208+
actual_window_size = max(OUTLIER_MIN_WINDOW_SIZE, window_size)
197209
if actual_window_size % 2 == 0:
198210
actual_window_size += 1
199211
half_window = actual_window_size // 2
200212
maximum_threshold = threshold_divisor if threshold_divisor > 0 else 4.0
201213
sensitivity_ratio = min(100, sensitivity) / 100
202-
robust_threshold = 0.5 + ((maximum_threshold - 0.5) * (1 - sensitivity_ratio))
214+
robust_threshold = OUTLIER_MIN_ROBUST_THRESHOLD + (
215+
(maximum_threshold - OUTLIER_MIN_ROBUST_THRESHOLD)
216+
* (1 - sensitivity_ratio)
217+
)
218+
minimum_relative_drop = OUTLIER_MAX_RELATIVE_DROP - (
219+
(OUTLIER_MAX_RELATIVE_DROP - OUTLIER_MIN_RELATIVE_DROP)
220+
* sensitivity_ratio
221+
)
203222
outliers: Set[int] = set()
204223

205224
for position, current_score in enumerate(scores):
206225
window_start = max(0, position - half_window)
207226
window_end = min(len(group), position + half_window + 1)
208227
neighbor_scores = scores[window_start:position] + scores[position + 1 : window_end]
209-
if not neighbor_scores or len(neighbor_scores) < max(0, min_neighbors):
228+
required_neighbors = max(2, min_neighbors)
229+
if len(neighbor_scores) < required_neighbors:
210230
continue
211231
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)
212237
absolute_deviations = [
213238
abs(score - neighbor_median) for score in neighbor_scores
214239
]
215240
median_absolute_deviation = median(absolute_deviations)
216241
robust_scale = median_absolute_deviation * 1.4826
217242
if robust_scale == 0:
218-
robust_scale = max(abs(neighbor_median) * 0.05, 1e-9)
243+
robust_scale = max(abs(neighbor_median) * 0.01, 1e-9)
219244

220245
deficit = neighbor_median - current_score
221246
robust_deficit = deficit / robust_scale
222-
if deficit > 0 and robust_deficit > robust_threshold:
247+
relative_drop = (
248+
(raw_neighbor_median - raw_scores[position]) / raw_neighbor_median
249+
if raw_neighbor_median > 0
250+
else 0.0
251+
)
252+
if (
253+
deficit > 0
254+
and relative_drop >= minimum_relative_drop
255+
and robust_deficit > robust_threshold
256+
):
223257
outliers.add(position)
224258
return outliers
225259

@@ -284,7 +318,7 @@ def select_outlier_removal_frames(
284318
progress_bar.update(len(result))
285319
selected_count = sum(frame["selected"] for frame in result)
286320
print(
287-
f"Outlier removal: Marked {len(result) - selected_count} outliers. "
321+
f"Outlier detection: Marked {len(result) - selected_count} outliers. "
288322
f"Keeping {selected_count} frames."
289323
)
290324
return result

‎sharp_frames/selection_preview.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Any, Dict, List, Sequence, Tuple
55

66
from .selection_methods import (
7+
OUTLIER_DEFAULT_SENSITIVITY,
8+
OUTLIER_DEFAULT_WINDOW_SIZE,
79
select_batched_frames_core,
810
select_best_n_frames_core,
911
select_outlier_removal_frames_core,
@@ -33,8 +35,8 @@ def _select_for_preview(
3335
if method in {"outlier-removal", "outlier_removal"}:
3436
assessed = select_outlier_removal_frames_core(
3537
frames,
36-
params.get("outlier_window_size", 15),
37-
params.get("outlier_sensitivity", 50),
38+
params.get("outlier_window_size", OUTLIER_DEFAULT_WINDOW_SIZE),
39+
params.get("outlier_sensitivity", OUTLIER_DEFAULT_SENSITIVITY),
3840
params.get("min_neighbors", 3),
3941
params.get("threshold_divisor", 4.0),
4042
)

‎sharp_frames/sharp_frames.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ def main():
169169
parser.add_argument("--batch-buffer", type=int, default=2,
170170
help="Number of frames to skip between batches (default: 2)")
171171
parser.add_argument("--outlier-window-size", type=int, default=15,
172-
help="Number of neighboring frames to compare for outlier detection (default: 15)")
173-
parser.add_argument("--outlier-sensitivity", type=int, default=50,
174-
help="Sensitivity of outlier detection, 0-100 (default: 50)")
172+
help="Local comparison window for outlier detection, minimum 5 (default: 15)")
173+
parser.add_argument("--outlier-sensitivity", type=int, default=60,
174+
help="Sensitivity of outlier detection, 0-100 (default: 60)")
175175
parser.add_argument("--width", type=int, default=0,
176176
help="Width to resize output images (height will be adjusted proportionally, 0 for no resizing)")
177177
parser.add_argument("--interactive", action="store_true", help="Run in legacy terminal prompt mode")
@@ -323,7 +323,7 @@ def run_interactive_mode():
323323
batch_size = 5
324324
batch_buffer = 2
325325
outlier_window_size = 15
326-
outlier_sensitivity = 50
326+
outlier_sensitivity = 60
327327

328328
# Get method-specific parameters
329329
if selection_method == "best-n":
@@ -333,8 +333,8 @@ def run_interactive_mode():
333333
batch_size = get_valid_int("Enter batch size", min_value=1, default=5)
334334
batch_buffer = get_valid_int("Enter batch buffer (frames/images to skip between batches)", min_value=0, default=2)
335335
elif selection_method == "outlier-removal":
336-
outlier_window_size = get_valid_int("Enter window size for comparison", min_value=3, max_value=30, default=15)
337-
outlier_sensitivity = get_valid_int("Enter sensitivity (0-100, higher is more aggressive)", min_value=0, max_value=100, default=50)
336+
outlier_window_size = get_valid_int("Enter window size for comparison", min_value=5, max_value=31, default=15)
337+
outlier_sensitivity = get_valid_int("Enter sensitivity (0-100, higher is more aggressive)", min_value=0, max_value=100, default=60)
338338

339339
output_format = get_choice(
340340
"Choose output format for saved images (or first 3 letters)",
@@ -400,4 +400,4 @@ def run_interactive_mode():
400400
return 0 if success else 1
401401

402402
if __name__ == "__main__":
403-
sys.exit(main())
403+
sys.exit(main())

‎sharp_frames/sharp_frames_processor.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def __init__(self,
5656

5757
# --- Parameters for 'outlier-removal' selection ---
5858
outlier_window_size: int = 15,
59-
outlier_sensitivity: int = 50,
59+
outlier_sensitivity: int = 60,
6060

6161
# --- Parameters for image resizing ---
6262
width: int = 0):
@@ -198,7 +198,7 @@ def _analyze_and_select_frames(self, frame_paths: List[str]) -> List[Dict[str, A
198198
self.batch_buffer
199199
)
200200
elif self.selection_method == "outlier-removal":
201-
# Outlier removal returns all frames with a 'selected' flag
201+
# Outlier detection returns all frames with a 'selected' flag
202202
all_frames_data = select_outlier_removal_frames(
203203
frames_with_scores,
204204
self.outlier_window_size,

‎sharp_frames/ui/screens/configuration.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ def action_help(self) -> None:
351351
## Selection Process
352352
353353
After configuration, frames will be extracted and analyzed. You'll then see an interactive selection screen where you can:
354-
- Choose selection method (Best N, Batched, Outlier Removal)
354+
- Choose selection method (Best N, Batched, Outlier Detection)
355355
- Adjust parameters with real-time preview
356356
- See exactly how many frames will be selected
357357

‎sharp_frames/ui/screens/configuration_original.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def _create_selection_method_step(self, container) -> None:
246246
select_widget = Select([
247247
("Best N frames - Choose a specific number of frames", "best-n"),
248248
("Batched selection - Best frame from each batch", "batched"),
249-
("Outlier removal - Remove the blurriest frames", "outlier-removal")
249+
("Outlier detection - Reject local sharpness outliers", "outlier-removal")
250250
], value=self.config_data.get("selection_method", "best-n"), id="selection-method-field")
251251
container.mount(select_widget)
252252

@@ -306,17 +306,17 @@ def _create_method_params_step(self, container) -> None:
306306
input1.focus()
307307

308308
elif method == "outlier-removal":
309-
container.mount(Label("Outlier Removal Configuration:", classes="question"))
309+
container.mount(Label("Outlier Detection Configuration:", classes="question"))
310310
container.mount(Label("Window size for comparison:"))
311311
input1 = Input(
312312
value=str(self.config_data.get("outlier_window_size", 15)),
313-
validators=[IntRangeValidator(min_value=3, max_value=30)],
313+
validators=[IntRangeValidator(min_value=5, max_value=31)],
314314
id="param1"
315315
)
316316
container.mount(input1)
317317
container.mount(Label("Sensitivity (0-100, higher = more aggressive):"))
318318
input2 = Input(
319-
value=str(self.config_data.get("outlier_sensitivity", 50)),
319+
value=str(self.config_data.get("outlier_sensitivity", 60)),
320320
validators=[IntRangeValidator(min_value=0, max_value=100)],
321321
id="param2"
322322
)
@@ -387,7 +387,7 @@ def _build_config_summary(self) -> str:
387387
lines.append(f" Batch buffer: {self.config_data.get('batch_buffer', 2)}")
388388
elif method == "outlier-removal":
389389
lines.append(f" Window size: {self.config_data.get('outlier_window_size', 15)}")
390-
lines.append(f" Sensitivity: {self.config_data.get('outlier_sensitivity', 50)}")
390+
lines.append(f" Sensitivity: {self.config_data.get('outlier_sensitivity', 60)}")
391391

392392
# Only show output format and resize options for non-directory modes
393393
input_type = self.config_data.get("input_type", InputTypes.VIDEO)
@@ -559,8 +559,8 @@ def _save_current_step(self) -> bool:
559559
elif method == "outlier-removal":
560560
window_size = int(value1)
561561
sensitivity = int(value2)
562-
if window_size < 3:
563-
self._show_error(step_container, "Window size must be at least 3")
562+
if window_size < 5:
563+
self._show_error(step_container, "Window size must be at least 5")
564564
return False
565565
if sensitivity < 0 or sensitivity > 100:
566566
self._show_error(step_container, "Sensitivity must be between 0 and 100")
@@ -691,7 +691,7 @@ def _prepare_final_config(self) -> Dict[str, Any]:
691691
config["batch_size"] = 5
692692
config["batch_buffer"] = 2
693693
config["outlier_window_size"] = 15
694-
config["outlier_sensitivity"] = 50
694+
config["outlier_sensitivity"] = 60
695695

696696
# Override with method-specific values
697697
if selection_method == "best-n":
@@ -702,6 +702,6 @@ def _prepare_final_config(self) -> Dict[str, Any]:
702702
config["batch_buffer"] = self.config_data.get("batch_buffer", 2)
703703
elif selection_method == "outlier-removal":
704704
config["outlier_window_size"] = self.config_data.get("outlier_window_size", 15)
705-
config["outlier_sensitivity"] = self.config_data.get("outlier_sensitivity", 50)
705+
config["outlier_sensitivity"] = self.config_data.get("outlier_sensitivity", 60)
706706

707-
return config
707+
return config

0 commit comments

Comments
 (0)