Skip to content

Commit 707399f

Browse files
committed
prepare v0.4.0 release
1 parent 1b94875 commit 707399f

22 files changed

Lines changed: 858 additions & 176 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to the Sharp Frames project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.4.0] - 2026-07-16
9+
10+
### Added
11+
- Scrollable frame timeline with keyboard navigation and full-result visualization
12+
- Resolution-normalized focus scoring for more consistent comparisons across source sizes
13+
- Cross-platform CI coverage for Python 3.10 through 3.13 on Linux, macOS, and Windows
14+
- Regression coverage for selection quality, output collisions, cancellation, and runtime cleanup
15+
16+
### Changed
17+
- Selection methods now balance sharpness and temporal distribution more consistently
18+
- Frame extraction, analysis, preview, and saving pipelines provide stricter validation and failure reporting
19+
- Image-directory exports preserve content while correctly handling format conversion and case-insensitive filename collisions
20+
- Configuration and selection screens provide clearer controls, status, and keyboard behavior
21+
22+
### Fixed
23+
- FFmpeg and FFprobe process cleanup, cancellation, timeout, and pipe handling
24+
- Partial or failed image reads being treated as valid zero-quality frames
25+
- Selection previews disagreeing with the frames produced during processing
26+
- Output metadata reporting frames that were not successfully saved
27+
- Platform-specific dependency guidance and Windows-safe process startup
28+
829
## [0.3.1] - 2025-01-29
930

1031
### Fixed
@@ -136,4 +157,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
136157
- outlier-removal: Remove outliers based on comparison with neighbors
137158
- Interactive mode with guided prompts
138159
- Proper Python package structure for pip and pipx installation
139-
- Command-line interface with `sharp-frames` command
160+
- Command-line interface with `sharp-frames` command

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,11 @@ sharp-frames photos selected --selection-method outlier-removal --outlier-sensit
136136

137137
1. **Validation**: Checks input paths, file formats, and system dependencies
138138
2. **Extraction**: Videos are extracted to frames at specified FPS using FFmpeg
139-
3. **Analysis**: Calculates sharpness scores using Laplacian variance in parallel
140-
4. **Selection**: Applies chosen algorithm to select the best frames/images
139+
3. **Analysis**: Normalizes analysis resolution, lightly denoises each image,
140+
and combines Laplacian variance with Tenengrad focus scoring in parallel.
141+
Unreadable inputs are excluded and reported.
142+
4. **Selection**: Applies the chosen source-aware algorithm to select the best
143+
naturally ordered frames/images.
141144
5. **Output**: Saves selected content with metadata including scores and parameters
142145

143146
## Output

‎RELEASE_NOTES.md‎

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,30 @@
1-
# Sharp Frames v0.2.0 Release Notes
1+
# Sharp Frames v0.4.0 Release Notes
22

3-
## New Features
3+
## Highlights
44

5-
### Textual Interface (Default)
6-
- Replaces command-line prompts with step-by-step wizard
7-
- Real-time input validation and error feedback
8-
- Enhanced error messages with specific guidance
5+
- Explore the complete analyzed frame set in a scrollable timeline with keyboard navigation.
6+
- Get more reliable selections from resolution-normalized focus scoring and improved temporal distribution.
7+
- Preview and execution now share the same selection rules, so displayed counts and chosen frames agree.
98

10-
### Video Directory Processing
11-
- Process all videos in a folder with a single command
12-
- Automatically detects and processes supported video formats
13-
- Maintains individual processing settings for each video
9+
## Reliability and Compatibility
1410

15-
### Usage Changes
16-
```bash
17-
# New default behavior
18-
sharp-frames # Launches textual interface
19-
20-
# Existing functionality unchanged
21-
sharp-frames input.mp4 output/ # Direct processing
22-
sharp-frames video_folder/ output/ # Process all videos in folder
23-
sharp-frames --interactive # Legacy prompts
24-
```
25-
26-
## Technical Changes
11+
- Hardened FFmpeg and FFprobe cancellation, timeouts, output draining, and temporary-directory cleanup.
12+
- Unreadable images are excluded and reported instead of silently receiving valid-looking scores.
13+
- Image exports handle format conversion, partial failures, metadata accuracy, and case-insensitive filename collisions.
14+
- CI covers Linux, macOS, and Windows on Python 3.10, 3.11, 3.12, and 3.13.
2715

28-
### Added
29-
- Path validation with existence/permission checking
30-
- Numeric field validation with range checking
31-
- Thread-safe subprocess management
32-
- UI component testing
33-
- Enhanced error analysis and reporting
16+
## Interface Improvements
3417

35-
### Fixed
36-
- Configuration parameter mapping
37-
- Resource cleanup (temp files, subprocesses)
38-
- Error message specificity
18+
- Clearer configuration controls and validation feedback.
19+
- Improved selection chart contrast, spacing, scrolling, and keyboard controls.
20+
- More actionable dependency and processing errors across supported platforms.
3921

40-
## Breaking Changes
41-
- `sharp-frames` without arguments now launches UI instead of showing help
42-
- Use `sharp-frames --help` for command-line help
22+
## Upgrade
4323

44-
## Installation
4524
```bash
4625
pip install --upgrade sharp-frames
4726
# or
4827
pipx upgrade sharp-frames
4928
```
5029

51-
## Dependencies
52-
- Added: `textual>=0.41.0`
53-
- All existing dependencies unchanged
30+
FFmpeg and FFprobe remain required for video processing and must be available on `PATH`.

‎docs/APPLICATION_AUDIT.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,22 @@ and CI across supported Python and operating-system versions.
205205
The following quality improvements are important but are deferred until the
206206
Critical and High correctness and safety defects above are closed.
207207

208+
### Implementation status (2026-07-15)
209+
210+
| Finding | Current status |
211+
| --- | --- |
212+
| Q1 | Baseline implemented. Modern and direct-CLI analysis now normalize the long edge to 512 pixels before scoring. The synthetic cross-resolution ratio fell from 4.0 to approximately 1.39, guarded by a `< 1.5` regression threshold. A fine-detail probe improved sharp/mild-blur separation from approximately 1.17× at 256 pixels to 1.56× at 512 pixels. |
213+
| Q2 | Baseline implemented. A 5×5 Gaussian denoise pass now feeds an equal-weight, log-normalized Laplacian/Tenengrad score. In the deterministic noise probe, the clean edge scores about 4× above the noisy blurred image. Real labeled-corpus validation remains under Q6. |
214+
| Q3 | Natural, case-insensitive numbered ordering is implemented for image directories, video directories, extracted frame discovery, and the direct CLI. Optional EXIF chronology remains a future enhancement. |
215+
| Q4 | Local outlier comparison now uses the neighbor median and median absolute deviation with a stable zero-MAD fallback. A single extreme high score no longer masks a real blurry frame. Scene-aware windows remain future work. |
216+
| Q5 | Resolved for the modern pipeline. Failed reads are excluded instead of receiving score zero; all-failed inputs stop safely; structured counts and paths are added to metadata; and the Textual selection screen reports exclusions. |
217+
| Q6 | In progress. Deterministic probes now track resolution invariance, noise resistance, chronology, unreadable exclusion, and synthetic outlier precision/recall. A representative real-media corpus, duplicate metric, and cross-camera baseline remain outstanding. |
218+
| Q7 | In progress. The chart now scrolls horizontally across the full timeline with guaranteed bar separation. Source-boundary markers and per-frame selection explanations remain outstanding. |
219+
220+
The focus-score metadata records
221+
`normalized_laplacian_tenengrad_v1` and the 512-pixel analysis scale so future
222+
quality comparisons can distinguish algorithm versions.
223+
208224
### Q1. Normalize analysis resolution
209225

210226
Laplacian variance is strongly resolution-dependent. The same synthetic edge

‎sharp_frames/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Sharp Frames - Extract, score, and select the best frames from a video, video directory, or image directory."""
22

3-
__version__ = "0.3.1"
3+
__version__ = "0.4.0"
44

55
import subprocess
66
import sys

‎sharp_frames/focus_scoring.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Resolution-normalized, noise-resistant focus scoring."""
2+
3+
import cv2
4+
import numpy as np
5+
6+
7+
ANALYSIS_LONG_EDGE = 512
8+
FOCUS_SCORE_METHOD = "normalized_laplacian_tenengrad_v1"
9+
DENOISE_KERNEL = (5, 5)
10+
DENOISE_SIGMA = 1.0
11+
LAPLACIAN_WEIGHT = 0.5
12+
TENENGRAD_WEIGHT = 0.5
13+
14+
15+
def normalize_analysis_image(image: np.ndarray) -> np.ndarray:
16+
"""Return a grayscale image at the canonical analysis resolution."""
17+
if image is None or image.size == 0:
18+
raise ValueError("Focus analysis requires a non-empty image")
19+
if image.ndim == 3:
20+
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
21+
22+
height, width = image.shape
23+
scale = ANALYSIS_LONG_EDGE / max(height, width)
24+
target_size = (
25+
max(1, round(width * scale)),
26+
max(1, round(height * scale)),
27+
)
28+
interpolation = cv2.INTER_AREA if scale < 1 else cv2.INTER_CUBIC
29+
return cv2.resize(image, target_size, interpolation=interpolation)
30+
31+
32+
def calculate_focus_score(image: np.ndarray) -> float:
33+
"""Combine Laplacian variance and Tenengrad after light denoising."""
34+
normalized = normalize_analysis_image(image)
35+
denoised = cv2.GaussianBlur(normalized, DENOISE_KERNEL, DENOISE_SIGMA)
36+
37+
laplacian_variance = cv2.Laplacian(denoised, cv2.CV_64F).var()
38+
gradient_x = cv2.Sobel(denoised, cv2.CV_64F, 1, 0, ksize=3)
39+
gradient_y = cv2.Sobel(denoised, cv2.CV_64F, 0, 1, ksize=3)
40+
tenengrad = np.mean((gradient_x * gradient_x) + (gradient_y * gradient_y))
41+
42+
combined_log_score = (
43+
np.log1p(laplacian_variance) * LAPLACIAN_WEIGHT
44+
+ np.log1p(tenengrad) * TENENGRAD_WEIGHT
45+
)
46+
return float(np.expm1(combined_log_score))

‎sharp_frames/processing/frame_extractor.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
SUPPORTED_IMAGE_EXTENSIONS,
1919
get_ffmpeg_installation_hint,
2020
get_video_files_in_directory,
21+
natural_path_key,
2122
)
2223

2324
if TYPE_CHECKING:
@@ -308,8 +309,7 @@ def _filter_image_files(self, directory: str) -> List[str]:
308309
except OSError as e:
309310
raise FileNotFoundError(f"Could not scan directory {directory}: {e}")
310311

311-
# Sort for consistent ordering
312-
image_files.sort()
312+
image_files.sort(key=natural_path_key)
313313
return image_files
314314

315315
def _get_image_output_name(self, image_path: str) -> str:
@@ -622,8 +622,7 @@ def _get_extracted_frame_files(self, temp_dir: str) -> List[str]:
622622
except OSError:
623623
return []
624624

625-
# Sort by filename to maintain frame order
626-
frame_files.sort()
625+
frame_files.sort(key=natural_path_key)
627626
return frame_files
628627

629628
def _get_supported_image_extensions(self) -> set:

‎sharp_frames/processing/frame_saver.py‎

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class ImageProcessingError(Exception):
2222

2323
class FrameSaver:
2424
"""Handles saving selected frames to disk with proper naming conventions."""
25+
26+
METADATA_FILENAME = "selected_metadata.json"
2527

2628
def __init__(self, show_progress: bool = True):
2729
"""Initialize FrameSaver.
@@ -79,13 +81,16 @@ def save_frames(self, selected_frames: List[FrameData], config: Dict[str, Any])
7981
print(f"Error creating output directory: {e}")
8082
return False
8183

82-
# Check for overwrite if needed
83-
if not force_overwrite and not self._check_output_directory_overwrite(output_dir):
84-
return False
85-
8684
output_filenames = self._plan_output_filenames(
8785
selected_frames, input_type, output_format
8886
)
87+
88+
# Check only paths this run intends to create. Unrelated files (for
89+
# example Finder's .DS_Store) are not overwrite risks.
90+
if not force_overwrite and not self._check_output_directory_overwrite(
91+
output_dir, output_filenames
92+
):
93+
return False
8994

9095
success_count = 0
9196
metadata_list = []
@@ -294,7 +299,7 @@ def _save_metadata(
294299
config: Configuration dictionary
295300
selected_frames: List of selected frames
296301
"""
297-
metadata_path = os.path.join(output_dir, "selected_metadata.json")
302+
metadata_path = os.path.join(output_dir, self.METADATA_FILENAME)
298303

299304
try:
300305
# Create comprehensive metadata
@@ -388,33 +393,45 @@ def _get_current_timestamp(self) -> str:
388393
import datetime
389394
return datetime.datetime.now().isoformat()
390395

391-
def _check_output_directory_overwrite(self, output_dir: str) -> bool:
392-
"""Return whether saving may proceed without unapproved overwrites."""
396+
def _check_output_directory_overwrite(
397+
self, output_dir: str, output_filenames: List[str]
398+
) -> bool:
399+
"""Return whether planned outputs can be written without overwriting."""
393400
if not os.path.exists(output_dir):
394401
return True
395402

396403
try:
397-
existing_files = [f for f in os.listdir(output_dir)
398-
if os.path.isfile(os.path.join(output_dir, f))]
404+
planned_names = {
405+
filename.casefold()
406+
for filename in [*output_filenames, self.METADATA_FILENAME]
407+
}
408+
conflicting_entries = [
409+
name
410+
for name in os.listdir(output_dir)
411+
if name.casefold() in planned_names
412+
]
399413

400-
if existing_files:
414+
if conflicting_entries:
401415
# In non-interactive mode (TUI/thread context), just warn without prompting
402416
if not self.show_progress: # show_progress=False indicates non-interactive context
403417
conflicting_paths = [
404418
os.path.join(output_dir, filename)
405-
for filename in sorted(existing_files)
419+
for filename in sorted(conflicting_entries)
406420
]
407421
print(
408-
f"Error: Output directory '{output_dir}' contains "
409-
f"{len(existing_files)} file(s). No files were written. "
410-
"Choose an empty directory or enable force overwrite."
422+
f"Error: {len(conflicting_entries)} planned output path(s) "
423+
f"already exist in '{output_dir}'. No files were written. "
424+
"Choose another directory or enable force overwrite."
411425
)
412426
print("Conflicting files: " + ", ".join(conflicting_paths))
413427
return False
414428

415429
# Interactive mode - prompt user
416-
print(f"Warning: Output directory '{output_dir}' contains {len(existing_files)} files.")
417-
print("Existing files may be overwritten.")
430+
print(
431+
f"Warning: {len(conflicting_entries)} planned output path(s) "
432+
f"already exist in '{output_dir}'."
433+
)
434+
print("Those existing outputs may be overwritten.")
418435

419436
while True:
420437
response = input("Continue anyway? (y/n): ").strip().lower()

0 commit comments

Comments
 (0)