From 8b7386d1b5818726d3358f6c3767848598f42394 Mon Sep 17 00:00:00 2001 From: Ned Cutler Date: Thu, 12 Mar 2026 13:36:48 -0400 Subject: [PATCH 1/4] Harden pixel mask capture and review tooling --- README.md | 53 ++- ecl_analysis/analysis/__init__.py | 8 +- ecl_analysis/analysis/masking.py | 242 ++++++++++++ ecl_analysis/analysis/models.py | 67 +++- ecl_analysis/constants.py | 1 + ecl_analysis/export/csv_exporter.py | 34 +- ecl_analysis/video_analyzer.py | 286 ++++++++++---- ecl_analysis/workers.py | 212 +++++++---- tests/conftest.py | 3 + tests/integration/test_workers.py | 12 +- tests/ui/test_mask_hardening.py | 60 +++ tests/unit/test_analysis_masking.py | 85 +++++ tests/unit/test_export_csv_exporter.py | 7 +- tests/unit/test_real_video_review.py | 65 ++++ tools/mask_review_manifest.example.json | 24 ++ tools/real_video_review_manifest.example.json | 13 + tools/run_mask_review.py | 357 ++++++++++++++++++ tools/run_real_video_review.py | 246 ++++++++++++ 18 files changed, 1617 insertions(+), 158 deletions(-) create mode 100644 ecl_analysis/analysis/masking.py create mode 100644 tests/ui/test_mask_hardening.py create mode 100644 tests/unit/test_analysis_masking.py create mode 100644 tests/unit/test_real_video_review.py create mode 100644 tools/mask_review_manifest.example.json create mode 100644 tools/real_video_review_manifest.example.json create mode 100644 tools/run_mask_review.py create mode 100644 tools/run_real_video_review.py diff --git a/README.md b/README.md index 281df9f..c2323c5 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,39 @@ If `git pull` shows a conflict or error, reach out before trying to fix it. Each analysis produces: - **CSV files** — one per ROI with columns: `frame, brightness_mean, brightness_median, blue_mean, blue_median` - **Plot images** — dual-panel PNG (brightness trends + difference plot) with statistical annotations +- **Metadata sidecar** — one `*_analysis_metadata.json` file capturing mask mode, thresholds, source frames, and mask-quality warnings + +### Dark-Enclosure Review Workflow + +For lab-style review of electrode light inside a dark enclosure: + +1. Lock exposure, ISO, white balance, and focus before recording. +2. Draw tight electrode ROIs and place the background ROI close to the electrodes, but outside visible glow. +3. Capture a fixed mask, then enable **Show Pixel Mask** to inspect agreement between the fixed mask and the current adaptive mask. + Fixed-only pixels render in red, adaptive-only pixels in blue, and agreement in magenta. +4. Check the mask-quality summary: + - `high` / `medium` confidence means the consensus mask is stable enough to review. + - `low` confidence, `low_consensus`, `unstable_mask`, or `small_mask` means the mask needs operator review before trusting the run. +5. Export the analysis and confirm the `*_analysis_metadata.json` sidecar was written next to the CSV/plot files. +6. Package one or more exported runs into a repeatable review bundle: + +```bash +python tools/run_real_video_review.py \ + tools/real_video_review_manifest.example.json \ + --output-dir review_output +``` + +The manifest should point at already-exported analysis folders plus the original raw video paths. The review bundle copies metadata, CSVs, and plots into one folder and generates `review_report.md` with a per-run PASS/FAIL summary. + +For a direct rerun from raw videos and ROI manifests, use: + +```bash +python tools/run_mask_review.py \ + tools/mask_review_manifest.example.json \ + --output-dir mask_review_outputs +``` + +That runner performs auto-capture plus full analysis from the raw videos, writes overlay PNGs for source frames, exports fresh CSV/metadata artifacts, and generates a case-by-case review summary. ### Useful Shortcuts @@ -70,10 +103,11 @@ Arrow keys nudge a selected ROI instead of navigating frames. Shift+Arrow for 10 1. User draws ROIs on the video frame (one can be designated as a background reference). 2. For each frame in the selected range, the tool converts BGR pixels to **CIE LAB** color space and extracts the **L\* channel** (perceptually uniform brightness, 0–100 scale). -3. Pixels below a noise threshold (default 5 L\*) are filtered out. An optional morphological opening (erode then dilate) removes isolated bright pixels. -4. If a background ROI is set, its brightness (configurable percentile, default 90th) is subtracted per-frame to compensate for lighting drift. -5. Both mean and median brightness are computed per ROI per frame. -6. Results are exported to CSV and plotted. +3. Fixed-mask capture scores signal above the background ROI and absolute noise floor, then builds a deterministic consensus mask from the strongest source frames. +4. Pixels below the noise floor (default 5 L\*) are filtered out. Morphological opening plus connected-component filtering remove isolated bright artifacts. +5. If a background ROI is set, its brightness (configurable percentile, default 90th) is subtracted per-frame to compensate for lighting drift. +6. Both mean and median brightness are computed per ROI per frame. +7. Results are exported to CSV, plots, and metadata. ### Architecture @@ -123,10 +157,21 @@ Brightness Sorcerer reports **relative** L\* brightness values derived from smar ### Pipeline Notes - **Background subtraction** uses a configurable percentile (default 90th) from the background ROI. This adapts to gradual lighting drift but assumes the background ROI contains no glow signal. +- **Fixed-mask provenance** records the source frames, consensus score, warning flags, and threshold settings used to create each reusable mask. - **Morphological filtering** removes isolated bright pixels but may erode edges of very small glow regions. For ROIs smaller than ~50 px, use smaller kernel sizes (1–3). - **No temporal smoothing.** Each frame is analyzed independently. Raw traces may appear noisier than time-averaged instruments; post-hoc filtering (moving average, Savitzky-Golay) can be applied to the exported CSV data. - **Blue channel values** are on the raw 0–255 sensor scale without perceptual correction — useful for qualitative spectral trends, not calibrated spectral measurements. +### Mask-Quality Interpretation + +- `high` confidence: the fixed mask stayed stable across the strongest source frames and showed no blocking warnings. +- `medium` confidence: acceptable for review, but verify the overlay and source frames before using the run as a reference. +- `low` confidence: do not trust the run without manual inspection and likely recapturing the mask. +- `single_frame_capture`: only one usable source frame contributed to the fixed mask; repeatability is weaker. +- `low_consensus`: candidate frames disagreed about which pixels belonged to the glow region. +- `unstable_mask`: the consensus region was much smaller than the total detected union, suggesting drifting or noisy detections. +- `small_mask`: the retained signal region was near the minimum component-size floor and may be dominated by artifacts. + ### Reporting Recommendations When citing results in publications, note: diff --git a/ecl_analysis/analysis/__init__.py b/ecl_analysis/analysis/__init__.py index 395c6b3..e91658d 100644 --- a/ecl_analysis/analysis/__init__.py +++ b/ecl_analysis/analysis/__init__.py @@ -3,14 +3,20 @@ from .background import compute_background_brightness from .brightness import compute_brightness, compute_brightness_stats, compute_l_star_frame from .duration import validate_run_duration -from .models import AnalysisRequest, AnalysisResult +from .masking import MASK_TOP_CANDIDATES, build_consensus_mask, build_signal_mask, evaluate_mask_candidate +from .models import AnalysisRequest, AnalysisResult, MaskCaptureMetadata __all__ = [ "AnalysisRequest", "AnalysisResult", + "MaskCaptureMetadata", + "MASK_TOP_CANDIDATES", + "build_consensus_mask", + "build_signal_mask", "compute_background_brightness", "compute_brightness", "compute_brightness_stats", "compute_l_star_frame", + "evaluate_mask_candidate", "validate_run_duration", ] diff --git a/ecl_analysis/analysis/masking.py b/ecl_analysis/analysis/masking.py new file mode 100644 index 0000000..a322a09 --- /dev/null +++ b/ecl_analysis/analysis/masking.py @@ -0,0 +1,242 @@ +"""Mask scoring and consensus helpers for electrode-light analysis.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil +from typing import List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +from .models import MaskCaptureMetadata + +MASK_TOP_CANDIDATES = 3 + + +@dataclass(frozen=True) +class MaskCandidate: + """Single-frame mask candidate for one ROI.""" + + frame_idx: int + score: float + background_brightness: Optional[float] + mask: np.ndarray + pixel_count: int + signal_peak: float + threshold_value: float + min_component_area: int + + +def compute_min_component_area( + mask_shape: Tuple[int, int], + morphological_kernel_size: int, +) -> int: + """Return a conservative connected-component floor scaled to ROI area.""" + roi_area = max(1, int(mask_shape[0] * mask_shape[1])) + area_floor = int(round(roi_area * 0.002)) + return max(4, morphological_kernel_size, min(64, area_floor)) + + +def filter_connected_components(mask: np.ndarray, min_component_area: int) -> np.ndarray: + """Remove connected components smaller than the requested area.""" + if mask.size == 0 or not np.any(mask): + return np.zeros(mask.shape, dtype=bool) + + num_labels, labels, stats, _centroids = cv2.connectedComponentsWithStats( + mask.astype(np.uint8), + connectivity=8, + ) + filtered = np.zeros(mask.shape, dtype=bool) + for label_idx in range(1, num_labels): + area = int(stats[label_idx, cv2.CC_STAT_AREA]) + if area >= min_component_area: + filtered |= labels == label_idx + return filtered + + +def build_signal_mask( + roi_l_star: np.ndarray, + background_brightness: Optional[float], + noise_floor_threshold: float, + morphological_kernel_size: int, + min_component_area: Optional[int] = None, +) -> Tuple[np.ndarray, float, int]: + """Build a cleaned binary mask for pixels that plausibly belong to electrode light.""" + if roi_l_star.size == 0: + return np.zeros(roi_l_star.shape, dtype=bool), float(noise_floor_threshold), 0 + + threshold_value = float(noise_floor_threshold) + if background_brightness is not None: + threshold_value = max(threshold_value, float(background_brightness)) + + mask = roi_l_star > threshold_value + if np.any(mask): + kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, + (morphological_kernel_size, morphological_kernel_size), + ) + mask_uint8 = mask.astype(np.uint8) * 255 + cleaned = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel) + mask = cleaned > 0 + + min_area = ( + compute_min_component_area(mask.shape, morphological_kernel_size) + if min_component_area is None + else max(1, int(min_component_area)) + ) + mask = filter_connected_components(mask, min_area) + return mask, threshold_value, min_area + + +def evaluate_mask_candidate( + roi_l_star: np.ndarray, + background_brightness: Optional[float], + noise_floor_threshold: float, + morphological_kernel_size: int, + frame_idx: int, +) -> Optional[MaskCandidate]: + """Score a single-frame candidate using background-aware positive signal only.""" + mask, threshold_value, min_area = build_signal_mask( + roi_l_star=roi_l_star, + background_brightness=background_brightness, + noise_floor_threshold=noise_floor_threshold, + morphological_kernel_size=morphological_kernel_size, + ) + if not np.any(mask): + return None + + reference_value = float(background_brightness) if background_brightness is not None else threshold_value + signal_values = np.maximum(roi_l_star[mask] - reference_value, 0.0) + score = float(np.sum(signal_values)) + if score <= 0.0: + return None + + return MaskCandidate( + frame_idx=int(frame_idx), + score=score, + background_brightness=None if background_brightness is None else float(background_brightness), + mask=mask, + pixel_count=int(np.count_nonzero(mask)), + signal_peak=float(np.max(signal_values)), + threshold_value=float(threshold_value), + min_component_area=int(min_area), + ) + + +def update_top_candidates( + candidates: Sequence[MaskCandidate], + candidate: Optional[MaskCandidate], + limit: int = MASK_TOP_CANDIDATES, +) -> List[MaskCandidate]: + """Return the top scored candidates with deterministic ordering.""" + if candidate is None: + return list(candidates) + + ranked = list(candidates) + [candidate] + ranked.sort(key=lambda item: (-item.score, item.frame_idx)) + return ranked[: max(1, int(limit))] + + +def _confidence_label( + candidate_count: int, + consensus_ratio: float, + stability_ratio: float, + pixel_count: int, + min_component_area: int, +) -> str: + if pixel_count <= 0: + return "none" + if candidate_count == 1 or consensus_ratio < 0.6 or stability_ratio < 0.4: + return "low" + if pixel_count <= (min_component_area * 2) or consensus_ratio < 0.8 or stability_ratio < 0.6: + return "medium" + return "high" + + +def build_consensus_mask( + candidates: Sequence[MaskCandidate], + capture_mode: str, + noise_floor_threshold: float, + morphological_kernel_size: int, +) -> Tuple[Optional[np.ndarray], MaskCaptureMetadata]: + """Build a deterministic fixed mask from top candidates and summarize provenance.""" + ranked = sorted(candidates, key=lambda item: (-item.score, item.frame_idx)) + if not ranked: + return ( + None, + MaskCaptureMetadata( + capture_mode=capture_mode, + warnings=["no_signal"], + noise_floor_threshold=float(noise_floor_threshold), + morphological_kernel_size=int(morphological_kernel_size), + ), + ) + + source_frames = [candidate.frame_idx for candidate in ranked] + background_values = [ + 0.0 if candidate.background_brightness is None else float(candidate.background_brightness) + for candidate in ranked + ] + signal_scores = [float(candidate.score) for candidate in ranked] + threshold_values = [float(candidate.threshold_value) for candidate in ranked] + min_component_area = max(candidate.min_component_area for candidate in ranked) + + if len(ranked) == 1: + consensus_mask = ranked[0].mask.copy() + consensus_ratio = 1.0 + stability_ratio = 1.0 + else: + mask_stack = np.stack([candidate.mask.astype(np.uint8) for candidate in ranked], axis=0) + support_counts = np.sum(mask_stack, axis=0) + required_votes = max(1, ceil(len(ranked) / 2)) + consensus_mask = support_counts >= required_votes + consensus_mask = filter_connected_components(consensus_mask, min_component_area) + if np.any(consensus_mask): + consensus_ratio = float(np.mean(support_counts[consensus_mask] / len(ranked))) + else: + consensus_ratio = 0.0 + union_mask = np.any(mask_stack.astype(bool), axis=0) + overlap = np.count_nonzero(consensus_mask) + union = np.count_nonzero(union_mask) + stability_ratio = float(overlap / union) if union else 0.0 + + warnings: List[str] = [] + if len(ranked) == 1: + warnings.append("single_frame_capture") + if not np.any(consensus_mask): + warnings.append("low_consensus") + if np.count_nonzero(consensus_mask) <= min_component_area: + warnings.append("small_mask") + if consensus_ratio and consensus_ratio < 0.7: + warnings.append("low_consensus") + if stability_ratio and stability_ratio < 0.5: + warnings.append("unstable_mask") + + confidence_label = _confidence_label( + candidate_count=len(ranked), + consensus_ratio=consensus_ratio, + stability_ratio=stability_ratio, + pixel_count=int(np.count_nonzero(consensus_mask)), + min_component_area=min_component_area, + ) + + metadata = MaskCaptureMetadata( + capture_mode=capture_mode, + source_frames=source_frames, + primary_source_frame=ranked[0].frame_idx, + background_values=background_values, + signal_scores=signal_scores, + threshold_values=threshold_values, + pixel_count=int(np.count_nonzero(consensus_mask)), + consensus_ratio=float(consensus_ratio), + stability_ratio=float(stability_ratio), + confidence_label=confidence_label, + min_component_area=int(min_component_area), + warnings=sorted(set(warnings)), + noise_floor_threshold=float(noise_floor_threshold), + morphological_kernel_size=int(morphological_kernel_size), + ) + if not np.any(consensus_mask): + return None, metadata + return consensus_mask.astype(bool), metadata diff --git a/ecl_analysis/analysis/models.py b/ecl_analysis/analysis/models.py index 111f52c..a7faa5e 100644 --- a/ecl_analysis/analysis/models.py +++ b/ecl_analysis/analysis/models.py @@ -1,7 +1,7 @@ """Data contracts for analysis requests and results.""" -from dataclasses import dataclass -from typing import List, Optional, Sequence, Tuple +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np @@ -9,6 +9,64 @@ RoiRect = Tuple[Point, Point] +@dataclass +class MaskCaptureMetadata: + """Structured provenance for a captured fixed mask.""" + + capture_mode: str + source_frames: List[int] = field(default_factory=list) + primary_source_frame: Optional[int] = None + background_values: List[float] = field(default_factory=list) + signal_scores: List[float] = field(default_factory=list) + threshold_values: List[float] = field(default_factory=list) + pixel_count: int = 0 + consensus_ratio: float = 0.0 + stability_ratio: float = 0.0 + confidence_label: str = "none" + min_component_area: int = 0 + warnings: List[str] = field(default_factory=list) + noise_floor_threshold: float = 0.0 + morphological_kernel_size: int = 0 + + def clone(self) -> "MaskCaptureMetadata": + """Return a detached copy for history snapshots and worker payloads.""" + return MaskCaptureMetadata( + capture_mode=str(self.capture_mode), + source_frames=[int(frame) for frame in self.source_frames], + primary_source_frame=None if self.primary_source_frame is None else int(self.primary_source_frame), + background_values=[float(value) for value in self.background_values], + signal_scores=[float(value) for value in self.signal_scores], + threshold_values=[float(value) for value in self.threshold_values], + pixel_count=int(self.pixel_count), + consensus_ratio=float(self.consensus_ratio), + stability_ratio=float(self.stability_ratio), + confidence_label=str(self.confidence_label), + min_component_area=int(self.min_component_area), + warnings=[str(value) for value in self.warnings], + noise_floor_threshold=float(self.noise_floor_threshold), + morphological_kernel_size=int(self.morphological_kernel_size), + ) + + def to_dict(self) -> Dict[str, Any]: + """Serialize mask provenance for JSON export.""" + return { + "capture_mode": self.capture_mode, + "source_frames": list(self.source_frames), + "primary_source_frame": self.primary_source_frame, + "background_values": list(self.background_values), + "signal_scores": list(self.signal_scores), + "threshold_values": list(self.threshold_values), + "pixel_count": self.pixel_count, + "consensus_ratio": self.consensus_ratio, + "stability_ratio": self.stability_ratio, + "confidence_label": self.confidence_label, + "min_component_area": self.min_component_area, + "warnings": list(self.warnings), + "noise_floor_threshold": self.noise_floor_threshold, + "morphological_kernel_size": self.morphological_kernel_size, + } + + @dataclass(frozen=True) class AnalysisRequest: """Immutable snapshot of all inputs required for frame analysis.""" @@ -23,6 +81,8 @@ class AnalysisRequest: background_percentile: float morphological_kernel_size: int noise_floor_threshold: float + mask_metadata: Sequence[Optional[MaskCaptureMetadata]] = field(default_factory=list) + analysis_metadata: Dict[str, Any] = field(default_factory=dict) @dataclass @@ -40,3 +100,6 @@ class AnalysisResult: elapsed_seconds: float start_frame: int end_frame: int + use_fixed_mask: bool = False + mask_metadata: List[Optional[MaskCaptureMetadata]] = field(default_factory=list) + analysis_metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/ecl_analysis/constants.py b/ecl_analysis/constants.py index 1ae995a..f5bc633 100644 --- a/ecl_analysis/constants.py +++ b/ecl_analysis/constants.py @@ -33,6 +33,7 @@ AUTO_DETECT_BASELINE_PERCENTILE = 5 BRIGHTNESS_NOISE_FLOOR_PERCENTILE = 2 DEFAULT_MANUAL_THRESHOLD = 5.0 +DEFAULT_NOISE_FLOOR_THRESHOLD = 5.0 MORPHOLOGICAL_KERNEL_SIZE = 3 MOUSE_RESIZE_HANDLE_SENSITIVITY = 10 diff --git a/ecl_analysis/export/csv_exporter.py b/ecl_analysis/export/csv_exporter.py index 7dc7ad1..6d49fa5 100644 --- a/ecl_analysis/export/csv_exporter.py +++ b/ecl_analysis/export/csv_exporter.py @@ -2,10 +2,11 @@ from __future__ import annotations +import json import logging import os from dataclasses import dataclass -from typing import Callable, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple import numpy as np import pandas as pd @@ -120,6 +121,37 @@ def save_analysis_outputs( plot_failed = True summary_lines.append(f" - FAILED: ROI {actual_roi_idx + 1}") + metadata_payload: Dict[str, Any] = { + "analysis_name": clean_analysis_name, + "video_name": base_video_name, + "frames_processed": analysis_result.frames_processed, + "total_frames": analysis_result.total_frames, + "start_frame": analysis_result.start_frame, + "end_frame": analysis_result.end_frame, + "elapsed_seconds": analysis_result.elapsed_seconds, + "use_fixed_mask": analysis_result.use_fixed_mask, + "non_background_rois": list(analysis_result.non_background_rois), + "analysis_metadata": dict(analysis_result.analysis_metadata), + "mask_metadata": [ + metadata.to_dict() if metadata is not None else None + for metadata in analysis_result.mask_metadata + ], + } + metadata_filename = ( + f"{clean_analysis_name}_{base_video_name}_" + f"frames{analysis_result.start_frame + 1}-{analysis_result.end_frame + 1}_analysis_metadata.json" + ) + metadata_path = os.path.join(save_dir, metadata_filename) + try: + with open(metadata_path, "w", encoding="utf-8") as metadata_file: + json.dump(metadata_payload, metadata_file, indent=2) + out_paths.append(metadata_path) + summary_lines.append(f" - Saved Metadata: {metadata_filename}") + except Exception as exc: + logging.exception("Failed to export metadata to %s: %s", metadata_path, exc) + plot_failed = True + summary_lines.append(" - FAILED: analysis metadata export") + return ExportResult( summary_lines=summary_lines, avg_brightness_summary=avg_brightness_summary, diff --git a/ecl_analysis/video_analyzer.py b/ecl_analysis/video_analyzer.py index bbf2447..f8a2f4c 100644 --- a/ecl_analysis/video_analyzer.py +++ b/ecl_analysis/video_analyzer.py @@ -5,7 +5,7 @@ import os from dataclasses import dataclass from string import Template -from typing import List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import cv2 import numpy as np @@ -20,7 +20,8 @@ compute_l_star_frame as analysis_compute_l_star_frame, ) from .analysis.duration import validate_run_duration as analysis_validate_run_duration -from .analysis.models import AnalysisRequest, AnalysisResult +from .analysis.masking import MASK_TOP_CANDIDATES, build_consensus_mask, build_signal_mask, evaluate_mask_candidate +from .analysis.models import AnalysisRequest, AnalysisResult, MaskCaptureMetadata from .audio import AudioAnalyzer, AudioManager from .cache import FrameCache from .constants import ( @@ -37,6 +38,7 @@ COLOR_WARNING, DEFAULT_FONT_FAMILY, DEFAULT_MANUAL_THRESHOLD, + DEFAULT_NOISE_FLOOR_THRESHOLD, DEFAULT_SETTINGS_FILE, FRAME_CACHE_SIZE, JUMP_FRAMES, @@ -153,6 +155,7 @@ class EditorSnapshot: use_fixed_mask: bool fixed_roi_masks: List[Optional[np.ndarray]] mask_source_frames: List[Optional[int]] + mask_metadata: List[Optional[MaskCaptureMetadata]] @dataclass @@ -463,11 +466,12 @@ def _init_vars(self): self.use_fixed_mask = False self.fixed_roi_masks: List[Optional[np.ndarray]] = [] # aligned with self.rects self.mask_source_frames: List[Optional[int]] = [] # frame index each mask was captured from + self.fixed_mask_metadata: List[Optional[MaskCaptureMetadata]] = [] # aligned with self.rects # Noise filtering parameters (adjustable via UI) self.morphological_kernel_size = MORPHOLOGICAL_KERNEL_SIZE self.background_percentile = 90.0 # For background ROI threshold calculation - self.noise_floor_threshold = 0.0 # Additional noise floor filtering + self.noise_floor_threshold = DEFAULT_NOISE_FLOOR_THRESHOLD # Video playback self.is_playing = False @@ -494,6 +498,18 @@ def _load_settings(self): self.frame_cache_size = max(10, min(2000, configured_cache)) self.frame_cache = FrameCache(self.frame_cache_size) + # Load mask-analysis defaults + configured_kernel = int(self.settings.get("morphological_kernel_size", MORPHOLOGICAL_KERNEL_SIZE)) + if configured_kernel % 2 == 0: + configured_kernel = max(1, configured_kernel - 1) + self.morphological_kernel_size = max(1, min(15, configured_kernel)) + self.background_percentile = float(self.settings.get("background_percentile", 90.0)) + self.background_percentile = max(50.0, min(99.0, self.background_percentile)) + self.noise_floor_threshold = float( + self.settings.get("noise_floor_threshold", DEFAULT_NOISE_FLOOR_THRESHOLD) + ) + self.noise_floor_threshold = max(0.0, min(10.0, self.noise_floor_threshold)) + except Exception as e: logging.warning(f"Could not load settings: {e}") self.settings = {} @@ -506,6 +522,9 @@ def _save_settings(self): self.settings['audio_enabled'] = self.audio_manager.enabled self.settings['audio_volume'] = self.audio_manager.volume self.settings['frame_cache_size'] = int(self.frame_cache_size) + self.settings['morphological_kernel_size'] = int(self.morphological_kernel_size) + self.settings['background_percentile'] = float(self.background_percentile) + self.settings['noise_floor_threshold'] = float(self.noise_floor_threshold) with open(DEFAULT_SETTINGS_FILE, 'w') as f: json.dump(self.settings, f, indent=2) except Exception as e: @@ -751,6 +770,13 @@ def _clone_mask_list(self, masks: List[Optional[np.ndarray]]) -> List[Optional[n """Deep-copy masks for history snapshots.""" return [mask.copy() if isinstance(mask, np.ndarray) else None for mask in masks] + def _clone_mask_metadata_list( + self, + metadata_list: List[Optional[MaskCaptureMetadata]], + ) -> List[Optional[MaskCaptureMetadata]]: + """Deep-copy mask provenance for history snapshots.""" + return [metadata.clone() if isinstance(metadata, MaskCaptureMetadata) else None for metadata in metadata_list] + def _capture_editor_snapshot(self) -> EditorSnapshot: """Capture the mutable editor state for undo/redo.""" return EditorSnapshot( @@ -767,6 +793,7 @@ def _capture_editor_snapshot(self) -> EditorSnapshot: use_fixed_mask=bool(self.use_fixed_mask), fixed_roi_masks=self._clone_mask_list(self.fixed_roi_masks), mask_source_frames=[None if value is None else int(value) for value in self.mask_source_frames], + mask_metadata=self._clone_mask_metadata_list(self.fixed_mask_metadata), ) def _snapshots_equal(self, first: EditorSnapshot, second: EditorSnapshot) -> bool: @@ -785,7 +812,11 @@ def _snapshots_equal(self, first: EditorSnapshot, second: EditorSnapshot) -> boo and first.use_fixed_mask == second.use_fixed_mask and first.mask_source_frames == second.mask_source_frames ) - if not scalar_fields_match or len(first.fixed_roi_masks) != len(second.fixed_roi_masks): + if ( + not scalar_fields_match + or len(first.fixed_roi_masks) != len(second.fixed_roi_masks) + or len(first.mask_metadata) != len(second.mask_metadata) + ): return False for first_mask, second_mask in zip(first.fixed_roi_masks, second.fixed_roi_masks): @@ -795,6 +826,15 @@ def _snapshots_equal(self, first: EditorSnapshot, second: EditorSnapshot) -> boo return False if not np.array_equal(first_mask, second_mask): return False + + for first_metadata, second_metadata in zip(first.mask_metadata, second.mask_metadata): + if first_metadata is None and second_metadata is None: + continue + if (first_metadata is None) != (second_metadata is None): + return False + if first_metadata is not None and second_metadata is not None: + if first_metadata.to_dict() != second_metadata.to_dict(): + return False return True def _begin_history_action(self, label: str): @@ -884,6 +924,7 @@ def _restore_editor_snapshot(self, snapshot: EditorSnapshot): self.use_fixed_mask = snapshot.use_fixed_mask self.fixed_roi_masks = self._clone_mask_list(snapshot.fixed_roi_masks) self.mask_source_frames = list(snapshot.mask_source_frames) + self.fixed_mask_metadata = self._clone_mask_metadata_list(snapshot.mask_metadata) if hasattr(self, "threshold_spin"): self.threshold_spin.blockSignals(True) @@ -935,6 +976,8 @@ def _restore_editor_snapshot(self, snapshot: EditorSnapshot): self._sync_analysis_range_widgets() self._update_current_brightness_display() + self._update_mask_pixel_count_display() + self._update_mask_quality_display() self.show_frame() finally: self._history_restoring = False @@ -1975,7 +2018,7 @@ def _create_widgets(self): viz_layout = QtWidgets.QVBoxLayout() self.show_mask_checkbox = QtWidgets.QCheckBox("Show Pixel Mask") - self.show_mask_checkbox.setToolTip("Highlight analyzed pixels in red overlay") + self.show_mask_checkbox.setToolTip("Highlight adaptive pixels in blue, fixed pixels in red, and overlap in magenta") self.show_mask_checkbox.setChecked(self.show_pixel_mask) viz_layout.addWidget(self.show_mask_checkbox) @@ -2008,6 +2051,11 @@ def _create_widgets(self): self.mask_pixel_count_label.setObjectName("statusLabel") viz_layout.addWidget(self.mask_pixel_count_label) + self.mask_quality_label = QtWidgets.QLabel("Mask Quality: n/a") + self.mask_quality_label.setObjectName("statusLabel") + self.mask_quality_label.setWordWrap(True) + viz_layout.addWidget(self.mask_quality_label) + # Noise filtering controls noise_groupbox = QtWidgets.QGroupBox("Noise Filtering") noise_layout = QtWidgets.QVBoxLayout() @@ -2334,6 +2382,28 @@ def _update_mask_pixel_count_display(self): else: self.mask_pixel_count_label.setText("Mask Pixels: n/a") + def _update_mask_quality_display(self): + """Update sidebar label with per-ROI mask provenance and warnings.""" + if not hasattr(self, "mask_quality_label"): + return + + lines: List[str] = [] + for idx, metadata in enumerate(getattr(self, "fixed_mask_metadata", [])): + if idx == self.background_roi_idx: + continue + if not isinstance(metadata, MaskCaptureMetadata): + lines.append(f"ROI {idx + 1}: n/a") + continue + + source_text = ",".join(str(frame + 1) for frame in metadata.source_frames[:MASK_TOP_CANDIDATES]) or "n/a" + warnings = ", ".join(metadata.warnings) if metadata.warnings else "none" + lines.append( + f"ROI {idx + 1}: {metadata.confidence_label} | px {metadata.pixel_count} | " + f"src {source_text} | cons {metadata.consensus_ratio:.2f} | warn {warnings}" + ) + + self.mask_quality_label.setText("\n".join(lines) if lines else "Mask Quality: n/a") + def _invalidate_fixed_masks(self, reason: str = ""): """Clear captured fixed masks when ROIs change or become invalid. Optionally provide a reason for UI feedback. @@ -2341,11 +2411,13 @@ def _invalidate_fixed_masks(self, reason: str = ""): if self.fixed_roi_masks: self.fixed_roi_masks = [None for _ in self.rects] self.mask_source_frames = [None for _ in self.rects] + self.fixed_mask_metadata = [None for _ in self.rects] if reason: self.mask_status_label.setText(f"Mask: cleared ({reason})") else: self.mask_status_label.setText("Mask: cleared") self._update_mask_pixel_count_display() + self._update_mask_quality_display() def _update_video_info(self): """Update video information display.""" @@ -2542,9 +2614,11 @@ def load_video(self): # Reset fixed masks on new video load self.fixed_roi_masks = [None for _ in self.rects] self.mask_source_frames = [None for _ in self.rects] + self.fixed_mask_metadata = [None for _ in self.rects] self.use_fixed_mask_checkbox.setChecked(False) self.mask_status_label.setText("Mask: none") self._update_mask_pixel_count_display() + self._update_mask_quality_display() # Attempt auto-detection if ROIs already exist if self.rects: @@ -2869,6 +2943,7 @@ def update_rect_list(self, preferred_row: Optional[int] = None): # Resize/realign masks; default to None for new or mismatched entries new_masks: List[Optional[np.ndarray]] = [] new_sources: List[Optional[int]] = [] + new_metadata: List[Optional[MaskCaptureMetadata]] = [] for i in range(len(self.rects)): if i < len(self.fixed_roi_masks): new_masks.append(self.fixed_roi_masks[i]) @@ -2878,9 +2953,15 @@ def update_rect_list(self, preferred_row: Optional[int] = None): new_sources.append(self.mask_source_frames[i]) else: new_sources.append(None) + if i < len(self.fixed_mask_metadata): + new_metadata.append(self.fixed_mask_metadata[i]) + else: + new_metadata.append(None) self.fixed_roi_masks = new_masks self.mask_source_frames = new_sources + self.fixed_mask_metadata = new_metadata self._update_mask_pixel_count_display() + self._update_mask_quality_display() self._update_widget_states(video_loaded=bool(self.cap), rois_exist=bool(self.rects)) self._update_threshold_display() @@ -3088,6 +3169,8 @@ def delete_selected_rectangle(self): # Remove corresponding fixed mask and invalidate if self.selected_rect_idx is not None and self.selected_rect_idx < len(self.fixed_roi_masks): del self.fixed_roi_masks[self.selected_rect_idx] + if self.selected_rect_idx is not None and self.selected_rect_idx < len(self.fixed_mask_metadata): + del self.fixed_mask_metadata[self.selected_rect_idx] self._invalidate_fixed_masks("ROI deleted") # Handle background ROI index adjustment @@ -3121,9 +3204,11 @@ def clear_all_rectangles(self): self.background_roi_idx = None self.fixed_roi_masks = [] self.mask_source_frames = [] + self.fixed_mask_metadata = [] self.use_fixed_mask_checkbox.setChecked(False) self.mask_status_label.setText("Mask: none") self._update_mask_pixel_count_display() + self._update_mask_quality_display() self.update_rect_list() self.show_frame() self.results_label.setText("Cleared all ROIs.") @@ -3138,6 +3223,7 @@ def _set_background_roi(self): before = self._capture_editor_snapshot() self.background_roi_idx = self.selected_rect_idx + self._invalidate_fixed_masks("background ROI changed") # Calculate background threshold for display if self.frame is not None: @@ -3157,22 +3243,9 @@ def _set_background_roi(self): def _calculate_background_threshold(self) -> Optional[float]: """Calculate the current background threshold based on background ROI or manual setting.""" if self.background_roi_idx is not None and self.frame is not None: - # Calculate threshold from current frame's background ROI - if 0 <= self.background_roi_idx < len(self.rects): - pt1, pt2 = self.rects[self.background_roi_idx] - fh, fw = self.frame.shape[:2] - - # Ensure ROI coordinates are valid within the frame - x1 = max(0, min(pt1[0], fw - 1)) - y1 = max(0, min(pt1[1], fh - 1)) - x2 = max(0, min(pt2[0], fw - 1)) - y2 = max(0, min(pt2[1], fh - 1)) - - if x2 > x1 and y2 > y1: - roi = self.frame[y1:y2, x1:x2] - l_raw_mean, _, _, _, _, _, _, _ = self._compute_brightness_stats(roi) - return l_raw_mean - + frame_l_star = self._compute_l_star_frame(self.frame) + return self._compute_background_brightness(self.frame, frame_l_star=frame_l_star) + # If no background ROI or calculation failed, return manual threshold return None @@ -3200,13 +3273,13 @@ def _on_mask_checkbox_toggled(self, checked: bool): def _apply_pixel_mask_overlay(self, frame: np.ndarray) -> np.ndarray: """ - Apply red overlay to show which pixels are being analyzed in each ROI. + Apply adaptive/fixed overlay colors to show analyzed pixels in each ROI. Args: frame: BGR frame to apply overlay to Returns: - Frame with red mask overlay applied + Frame with mask overlay applied """ overlay = frame.copy() @@ -3230,27 +3303,34 @@ def _apply_pixel_mask_overlay(self, frame: np.ndarray) -> np.ndarray: roi = frame[y1:y2, x1:x2] roi_l_star = l_star_frame[y1:y2, x1:x2] try: - use_fixed = self.use_fixed_mask and roi_idx < len(self.fixed_roi_masks) and isinstance(self.fixed_roi_masks[roi_idx], np.ndarray) - mask = None + adaptive_mask, _threshold_value, _min_area = build_signal_mask( + roi_l_star=roi_l_star, + background_brightness=background_brightness, + noise_floor_threshold=self.noise_floor_threshold, + morphological_kernel_size=self.morphological_kernel_size, + ) + use_fixed = ( + self.use_fixed_mask + and roi_idx < len(self.fixed_roi_masks) + and isinstance(self.fixed_roi_masks[roi_idx], np.ndarray) + ) + fixed_mask = None if use_fixed: - fixed_mask = self.fixed_roi_masks[roi_idx] - if fixed_mask is not None and fixed_mask.shape[:2] == roi.shape[:2]: - mask = fixed_mask.astype(bool) - else: - # Shape mismatch - ignore fixed mask - mask = None - - if mask is None: - # Derive mask from current frame using cached L* channel - if background_brightness is not None: - mask = roi_l_star > background_brightness - else: - mask = np.ones_like(roi_l_star, dtype=bool) - - # Apply red overlay to analyzed pixels + candidate_mask = self.fixed_roi_masks[roi_idx] + if candidate_mask is not None and candidate_mask.shape[:2] == roi.shape[:2]: + fixed_mask = candidate_mask.astype(bool) + roi_overlay = roi.copy() - roi_overlay[mask] = roi_overlay[mask] * 0.7 + np.array([0, 0, 255]) * 0.3 # Red tint - + if fixed_mask is not None: + overlap_mask = fixed_mask & adaptive_mask + fixed_only_mask = fixed_mask & ~adaptive_mask + adaptive_only_mask = adaptive_mask & ~fixed_mask + roi_overlay[fixed_only_mask] = roi_overlay[fixed_only_mask] * 0.55 + np.array([0, 0, 255]) * 0.45 + roi_overlay[adaptive_only_mask] = roi_overlay[adaptive_only_mask] * 0.55 + np.array([255, 0, 0]) * 0.45 + roi_overlay[overlap_mask] = roi_overlay[overlap_mask] * 0.45 + np.array([255, 0, 255]) * 0.55 + else: + roi_overlay[adaptive_mask] = roi_overlay[adaptive_mask] * 0.7 + np.array([0, 0, 255]) * 0.3 + # Apply overlay back to main frame overlay[y1:y2, x1:x2] = roi_overlay @@ -3284,6 +3364,7 @@ def _on_use_fixed_mask_toggled(self, checked: bool): self.mask_status_label.setText("Mask: active") else: self.mask_status_label.setText("Mask: disabled") + self._update_mask_quality_display() if self.frame is not None: self._update_current_brightness_display() self.show_frame() @@ -3314,11 +3395,13 @@ def _capture_fixed_masks(self, source_frame_idx: Optional[int] = None): masks: List[Optional[np.ndarray]] = [] sources: List[Optional[int]] = [] + metadata_list: List[Optional[MaskCaptureMetadata]] = [] created_any = False for roi_idx, (pt1, pt2) in enumerate(self.rects): if roi_idx == self.background_roi_idx: masks.append(None) sources.append(None) + metadata_list.append(None) continue x1 = max(0, min(pt1[0], fw - 1)) y1 = max(0, min(pt1[1], fh - 1)) @@ -3327,30 +3410,44 @@ def _capture_fixed_masks(self, source_frame_idx: Optional[int] = None): if x2 > x1 and y2 > y1: roi_l_star = l_star_frame[y1:y2, x1:x2] try: - if background_brightness is not None: - mask = roi_l_star > background_brightness - # Morphological cleanup similar to analysis - if np.any(mask): - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (self.morphological_kernel_size, self.morphological_kernel_size)) - mask_uint8 = mask.astype(np.uint8) * 255 - cleaned = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel) - mask = cleaned > 0 - else: - # If no background brightness, default to full ROI - mask = np.ones(roi_l_star.shape, dtype=bool) + candidate = evaluate_mask_candidate( + roi_l_star=roi_l_star, + background_brightness=background_brightness, + noise_floor_threshold=self.noise_floor_threshold, + morphological_kernel_size=self.morphological_kernel_size, + frame_idx=source_frame_idx, + ) + candidates = [candidate] if candidate is not None else [] + mask, metadata = build_consensus_mask( + candidates=candidates, + capture_mode="manual", + noise_floor_threshold=self.noise_floor_threshold, + morphological_kernel_size=self.morphological_kernel_size, + ) masks.append(mask) - sources.append(source_frame_idx) - created_any = True + sources.append(metadata.primary_source_frame) + metadata_list.append(metadata) + created_any = created_any or mask is not None except Exception as e: logging.warning(f"Failed to capture mask for ROI {roi_idx+1}: {e}") masks.append(None) sources.append(None) + metadata_list.append( + MaskCaptureMetadata( + capture_mode="manual", + warnings=["capture_error"], + noise_floor_threshold=self.noise_floor_threshold, + morphological_kernel_size=self.morphological_kernel_size, + ) + ) else: masks.append(None) sources.append(None) + metadata_list.append(None) self.fixed_roi_masks = masks self.mask_source_frames = sources + self.fixed_mask_metadata = metadata_list if created_any: self.mask_status_label.setText(f"Mask: captured from frame {source_frame_idx}") if not self.use_fixed_mask: @@ -3362,6 +3459,7 @@ def _capture_fixed_masks(self, source_frame_idx: Optional[int] = None): else: self.mask_status_label.setText("Mask: none (could not capture)") self._update_mask_pixel_count_display() + self._update_mask_quality_display() if self.frame is not None: self._update_current_brightness_display() self.show_frame() @@ -3397,20 +3495,20 @@ def _auto_capture_brightest_frame_masks(self): "Invalid frame range for analysis.") return - step = max(1, (end_frame - start_frame) // 100) request = MaskScanRequest( video_path=self.video_path, rects=[((int(pt1[0]), int(pt1[1])), (int(pt2[0]), int(pt2[1]))) for pt1, pt2 in self.rects], background_roi_idx=self.background_roi_idx, start_frame=start_frame, end_frame=end_frame, - step=step, + step=1, background_percentile=self.background_percentile, morphological_kernel_size=self.morphological_kernel_size, + noise_floor_threshold=self.noise_floor_threshold, ) self._start_mask_worker( worker=BrightestFrameWorker(request), - label="Finding brightest frame...", + label="Scoring signal-rich frames...", title="Auto-Capture Masks", task_type="global", ) @@ -3451,16 +3549,16 @@ def _auto_capture_per_roi_brightest_masks(self): "Invalid frame range for analysis.") return - step = max(1, (end_frame - start_frame) // 100) request = MaskScanRequest( video_path=self.video_path, rects=[((int(pt1[0]), int(pt1[1])), (int(pt2[0]), int(pt2[1]))) for pt1, pt2 in self.rects], background_roi_idx=self.background_roi_idx, start_frame=start_frame, end_frame=end_frame, - step=step, + step=1, background_percentile=self.background_percentile, morphological_kernel_size=self.morphological_kernel_size, + noise_floor_threshold=self.noise_floor_threshold, ) self._start_mask_worker( worker=PerRoiMaskCaptureWorker(request), @@ -3517,12 +3615,36 @@ def _on_global_brightest_finished(self, result_obj: object): self.results_label.setText("Auto-capture failed: invalid worker result.") return - self.frame_slider.setValue(result.brightest_frame_idx) - self._capture_fixed_masks(source_frame_idx=result.brightest_frame_idx) + before = self._capture_editor_snapshot() + self.fixed_roi_masks = result.masks + self.mask_source_frames = result.sources + self.fixed_mask_metadata = result.metadata + created_count = sum(1 for mask in result.masks if mask is not None) + candidate_frame_text = ", ".join(str(frame + 1) for frame in result.candidate_frames) or "n/a" + if created_count > 0: + primary_frame = next((frame for frame in result.sources if frame is not None), None) + if primary_frame is not None: + self.frame_slider.setValue(primary_frame) + self.mask_status_label.setText(f"Mask: global consensus from [{candidate_frame_text}]") + if not self.use_fixed_mask: + self.use_fixed_mask = True + self.use_fixed_mask_checkbox.blockSignals(True) + self.use_fixed_mask_checkbox.setChecked(True) + self.use_fixed_mask_checkbox.blockSignals(False) + else: + self.mask_status_label.setText("Mask: none (could not capture)") + self._update_mask_pixel_count_display() + self._update_mask_quality_display() + if self.frame is not None: + self._update_current_brightness_display() + self.show_frame() + self._record_history_change("Capture Fixed Masks", before) QtWidgets.QMessageBox.information( self, "Auto-Capture Complete", - f"Captured masks from frame {result.brightest_frame_idx} (brightness: {result.max_brightness:.1f} L*)", + f"Captured {created_count} masks from global consensus frames " + f"[{candidate_frame_text}].\n\n" + f"Best shared signal score: {result.max_signal_score:.1f}", ) def _on_per_roi_mask_finished(self, result_obj: object): @@ -3536,10 +3658,11 @@ def _on_per_roi_mask_finished(self, result_obj: object): before = self._capture_editor_snapshot() self.fixed_roi_masks = result.masks self.mask_source_frames = result.sources + self.fixed_mask_metadata = result.metadata created_count = sum(1 for m in result.masks if m is not None) frame_info = [ - str(result.sources[i]) if result.sources[i] is not None else "n/a" + str(result.sources[i] + 1) if result.sources[i] is not None else "n/a" for i in range(len(self.rects)) if i != self.background_roi_idx ] @@ -3554,6 +3677,7 @@ def _on_per_roi_mask_finished(self, result_obj: object): self.mask_status_label.setText("Mask: none (could not capture)") self._update_mask_pixel_count_display() + self._update_mask_quality_display() if self.frame is not None: self._update_current_brightness_display() self.show_frame() @@ -3604,6 +3728,7 @@ def _on_kernel_size_changed(self, value: int): if self.frame is not None: self._update_current_brightness_display() self.show_frame() + self._save_settings() if before is not None: self._record_history_change("Adjust Mask Kernel", before) @@ -3612,11 +3737,13 @@ def _on_bg_percentile_changed(self, value: int): before = None if self._history_restoring else self._capture_editor_snapshot() self.background_percentile = float(value) self.bg_percentile_label.setText(f"{self.background_percentile:.0f}%") + self._invalidate_fixed_masks("background threshold changed") # Update display since background calculation changed if self.frame is not None: self._update_current_brightness_display() self._update_threshold_display() self.show_frame() + self._save_settings() if before is not None: self._record_history_change("Adjust Background Percentile", before) @@ -3630,6 +3757,7 @@ def _on_noise_floor_changed(self, value: int): if self.frame is not None: self._update_current_brightness_display() self.show_frame() + self._save_settings() if before is not None: self._record_history_change("Adjust Noise Floor", before) @@ -4189,6 +4317,8 @@ def _apply_analysis_range( or normalized_end != self.end_frame ) + if changed: + self._invalidate_fixed_masks("analysis range changed") self.start_frame = normalized_start self.end_frame = normalized_end self._sync_analysis_range_widgets() @@ -4421,6 +4551,31 @@ def _cleanup_audio_worker(self): self._pending_audio_expected_duration = 0.0 self._set_busy_state(False) + def _build_analysis_metadata_snapshot(self) -> Dict[str, Any]: + """Capture analysis settings and mask provenance for export/reporting.""" + return { + "background_roi_idx": self.background_roi_idx, + "background_percentile": float(self.background_percentile), + "noise_floor_threshold": float(self.noise_floor_threshold), + "morphological_kernel_size": int(self.morphological_kernel_size), + "start_frame": int(self.start_frame), + "end_frame": int(self.end_frame if self.end_frame is not None else self.start_frame), + "use_fixed_mask": bool(self.use_fixed_mask), + "roi_rects": [ + { + "index": idx, + "pt1": [int(pt1[0]), int(pt1[1])], + "pt2": [int(pt2[0]), int(pt2[1])], + "is_background": idx == self.background_roi_idx, + } + for idx, (pt1, pt2) in enumerate(self.rects) + ], + "mask_metadata": [ + metadata.to_dict() if isinstance(metadata, MaskCaptureMetadata) else None + for metadata in self.fixed_mask_metadata + ], + } + # --- Analysis and Plotting --- @@ -4459,6 +4614,7 @@ def analyze_video(self): fixed_masks_snapshot.append(mask.copy()) else: fixed_masks_snapshot.append(None) + fixed_mask_metadata_snapshot = self._clone_mask_metadata_list(self.fixed_mask_metadata) request = AnalysisRequest( video_path=self.video_path, @@ -4471,6 +4627,8 @@ def analyze_video(self): background_percentile=self.background_percentile, morphological_kernel_size=self.morphological_kernel_size, noise_floor_threshold=self.noise_floor_threshold, + mask_metadata=fixed_mask_metadata_snapshot, + analysis_metadata=self._build_analysis_metadata_snapshot(), ) self._analysis_save_dir = save_dir diff --git a/ecl_analysis/workers.py b/ecl_analysis/workers.py index 698d766..ba43764 100644 --- a/ecl_analysis/workers.py +++ b/ecl_analysis/workers.py @@ -12,7 +12,13 @@ from .analysis.background import compute_background_brightness from .analysis.brightness import compute_brightness_stats, compute_l_star_frame -from .analysis.models import AnalysisRequest, AnalysisResult, RoiRect +from .analysis.masking import ( + MASK_TOP_CANDIDATES, + build_consensus_mask, + evaluate_mask_candidate, + update_top_candidates, +) +from .analysis.models import AnalysisRequest, AnalysisResult, MaskCaptureMetadata, RoiRect from .audio import AudioAnalyzer @@ -46,14 +52,18 @@ class MaskScanRequest: step: int background_percentile: float morphological_kernel_size: int + noise_floor_threshold: float @dataclass class BrightestFrameResult: - """Result payload for global brightest frame detection.""" + """Result payload for global auto-capture.""" - brightest_frame_idx: int - max_brightness: float + masks: List[Optional[np.ndarray]] + sources: List[Optional[int]] + metadata: List[Optional[MaskCaptureMetadata]] + candidate_frames: List[int] + max_signal_score: float @dataclass @@ -63,6 +73,7 @@ class PerRoiMaskCaptureResult: masks: List[Optional[np.ndarray]] sources: List[Optional[int]] max_brightness: Dict[int, float] + metadata: List[Optional[MaskCaptureMetadata]] class AnalysisWorker(QtCore.QObject): @@ -209,6 +220,12 @@ def run(self) -> None: elapsed_seconds=elapsed_seconds, start_frame=req.start_frame, end_frame=req.end_frame, + use_fixed_mask=req.use_fixed_mask, + mask_metadata=[ + metadata.clone() if isinstance(metadata, MaskCaptureMetadata) else None + for metadata in req.mask_metadata + ], + analysis_metadata=dict(req.analysis_metadata), ) ) except cv2.error as exc: @@ -255,7 +272,7 @@ def cancel(self) -> None: class BrightestFrameWorker(QtCore.QObject): - """Find one brightest frame averaged across non-background ROIs.""" + """Capture fixed masks from the strongest shared signal frames.""" progress_changed = QtCore.pyqtSignal(int, int) progress_message = QtCore.pyqtSignal(str) @@ -271,7 +288,7 @@ def __init__(self, request: MaskScanRequest): @QtCore.pyqtSlot() def run(self) -> None: req = self._request - frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) + frame_indices = list(range(req.start_frame, req.end_frame + 1)) if not frame_indices: self.error.emit("No frames available for brightest-frame scan.") return @@ -286,8 +303,7 @@ def run(self) -> None: self.error.emit(f"Could not open video file: {req.video_path}") return - brightest_frame_idx = frame_indices[0] - max_brightness = float("-inf") + top_global_frames: List[tuple[float, int]] = [] try: total = len(frame_indices) @@ -302,24 +318,35 @@ def run(self) -> None: continue l_star_frame = compute_l_star_frame(frame) + background_value = compute_background_brightness( + frame=frame, + rects=req.rects, + background_roi_idx=req.background_roi_idx, + background_percentile=req.background_percentile, + frame_l_star=l_star_frame, + ) frame_height, frame_width = frame.shape[:2] - brightness_sum = 0.0 - roi_count = 0 + frame_signal_score = 0.0 for roi_idx in non_background_rois: pt1, pt2 = req.rects[roi_idx] x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) if x2 > x1 and y2 > y1: roi_l_star = l_star_frame[y1:y2, x1:x2] - if roi_l_star.size: - brightness_sum += float(np.mean(roi_l_star)) - roi_count += 1 + candidate = evaluate_mask_candidate( + roi_l_star=roi_l_star, + background_brightness=background_value, + noise_floor_threshold=req.noise_floor_threshold, + morphological_kernel_size=req.morphological_kernel_size, + frame_idx=frame_idx, + ) + if candidate is not None: + frame_signal_score += candidate.score - if roi_count > 0: - frame_brightness = brightness_sum / roi_count - if frame_brightness > max_brightness: - max_brightness = frame_brightness - brightest_frame_idx = frame_idx + if frame_signal_score > 0.0: + top_global_frames.append((float(frame_signal_score), int(frame_idx))) + top_global_frames.sort(key=lambda item: (-item[0], item[1])) + del top_global_frames[MASK_TOP_CANDIDATES:] self.progress_changed.emit(idx + 1, total) if (idx + 1) % 10 == 0 or idx + 1 == total: @@ -327,13 +354,62 @@ def run(self) -> None: f"Scanning frame {idx + 1}/{total} for global brightest mask source" ) - if max_brightness == float("-inf"): - max_brightness = 0.0 + candidate_frames = [frame_idx for _score, frame_idx in top_global_frames] + per_roi_candidates: Dict[int, List] = {roi_idx: [] for roi_idx in non_background_rois} + + for frame_idx in candidate_frames: + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + if not ret or frame is None: + continue + + l_star_frame = compute_l_star_frame(frame) + background_value = compute_background_brightness( + frame=frame, + rects=req.rects, + background_roi_idx=req.background_roi_idx, + background_percentile=req.background_percentile, + frame_l_star=l_star_frame, + ) + frame_height, frame_width = frame.shape[:2] + + for roi_idx in non_background_rois: + pt1, pt2 = req.rects[roi_idx] + x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) + if x2 <= x1 or y2 <= y1: + continue + + roi_l_star = l_star_frame[y1:y2, x1:x2] + candidate = evaluate_mask_candidate( + roi_l_star=roi_l_star, + background_brightness=background_value, + noise_floor_threshold=req.noise_floor_threshold, + morphological_kernel_size=req.morphological_kernel_size, + frame_idx=frame_idx, + ) + per_roi_candidates[roi_idx] = update_top_candidates(per_roi_candidates[roi_idx], candidate) + + masks: List[Optional[np.ndarray]] = [None] * len(req.rects) + sources: List[Optional[int]] = [None] * len(req.rects) + metadata: List[Optional[MaskCaptureMetadata]] = [None] * len(req.rects) + for roi_idx in non_background_rois: + mask, mask_metadata = build_consensus_mask( + candidates=per_roi_candidates[roi_idx], + capture_mode="global_auto", + noise_floor_threshold=req.noise_floor_threshold, + morphological_kernel_size=req.morphological_kernel_size, + ) + masks[roi_idx] = mask + sources[roi_idx] = mask_metadata.primary_source_frame + metadata[roi_idx] = mask_metadata self.finished.emit( BrightestFrameResult( - brightest_frame_idx=brightest_frame_idx, - max_brightness=max_brightness, + masks=masks, + sources=sources, + metadata=metadata, + candidate_frames=candidate_frames, + max_signal_score=float(top_global_frames[0][0]) if top_global_frames else 0.0, ) ) except cv2.error as exc: @@ -370,7 +446,7 @@ def run(self) -> None: self.error.emit("No non-background ROI available.") return - frame_indices = list(range(req.start_frame, req.end_frame + 1, max(1, req.step))) + frame_indices = list(range(req.start_frame, req.end_frame + 1)) if not frame_indices: self.error.emit("No frames available for per-ROI scan.") return @@ -380,11 +456,11 @@ def run(self) -> None: self.error.emit(f"Could not open video file: {req.video_path}") return - brightest_frames: Dict[int, int] = {idx: frame_indices[0] for idx in roi_indices} - max_brightness: Dict[int, float] = {idx: float("-inf") for idx in roi_indices} + top_candidates: Dict[int, List] = {idx: [] for idx in roi_indices} + max_brightness: Dict[int, float] = {idx: 0.0 for idx in roi_indices} scan_total = len(frame_indices) - total = scan_total + len(roi_indices) + total = scan_total try: for idx, frame_idx in enumerate(frame_indices): @@ -399,6 +475,13 @@ def run(self) -> None: continue l_star_frame = compute_l_star_frame(frame) + background_value = compute_background_brightness( + frame=frame, + rects=req.rects, + background_roi_idx=req.background_roi_idx, + background_percentile=req.background_percentile, + frame_l_star=l_star_frame, + ) frame_height, frame_width = frame.shape[:2] for roi_idx in roi_indices: @@ -406,11 +489,16 @@ def run(self) -> None: x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) if x2 > x1 and y2 > y1: roi_l_star = l_star_frame[y1:y2, x1:x2] - if roi_l_star.size: - roi_mean = float(np.mean(roi_l_star)) - if roi_mean > max_brightness[roi_idx]: - max_brightness[roi_idx] = roi_mean - brightest_frames[roi_idx] = frame_idx + candidate = evaluate_mask_candidate( + roi_l_star=roi_l_star, + background_brightness=background_value, + noise_floor_threshold=req.noise_floor_threshold, + morphological_kernel_size=req.morphological_kernel_size, + frame_idx=frame_idx, + ) + top_candidates[roi_idx] = update_top_candidates(top_candidates[roi_idx], candidate) + if candidate is not None: + max_brightness[roi_idx] = max(max_brightness[roi_idx], candidate.score) self.progress_changed.emit(idx + 1, total) if (idx + 1) % 10 == 0 or idx + 1 == scan_total: @@ -420,63 +508,25 @@ def run(self) -> None: masks: List[Optional[np.ndarray]] = [None] * len(req.rects) sources: List[Optional[int]] = [None] * len(req.rects) - - for idx, roi_idx in enumerate(roi_indices): - if self._cancelled: - self.cancelled.emit() - return - - frame_idx = brightest_frames[roi_idx] - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) - ret, frame = cap.read() - if not ret or frame is None: - self.progress_changed.emit(scan_total + idx + 1, total) - continue - - l_star_frame = compute_l_star_frame(frame) - background = compute_background_brightness( - frame=frame, - rects=req.rects, - background_roi_idx=req.background_roi_idx, - background_percentile=req.background_percentile, - frame_l_star=l_star_frame, + metadata: List[Optional[MaskCaptureMetadata]] = [None] * len(req.rects) + + for roi_idx in roi_indices: + mask, mask_metadata = build_consensus_mask( + candidates=top_candidates[roi_idx], + capture_mode="per_roi_auto", + noise_floor_threshold=req.noise_floor_threshold, + morphological_kernel_size=req.morphological_kernel_size, ) - - frame_height, frame_width = frame.shape[:2] - pt1, pt2 = req.rects[roi_idx] - x1, y1, x2, y2 = _normalized_slice_bounds(pt1, pt2, frame_width, frame_height) - - if x2 > x1 and y2 > y1: - roi_l_star = l_star_frame[y1:y2, x1:x2] - if background is not None: - mask = roi_l_star > background - if np.any(mask): - kernel = cv2.getStructuringElement( - cv2.MORPH_ELLIPSE, - (req.morphological_kernel_size, req.morphological_kernel_size), - ) - mask_uint8 = mask.astype(np.uint8) * 255 - cleaned = cv2.morphologyEx(mask_uint8, cv2.MORPH_OPEN, kernel) - mask = cleaned > 0 - else: - mask = np.ones(roi_l_star.shape, dtype=bool) - masks[roi_idx] = mask - sources[roi_idx] = frame_idx - - self.progress_changed.emit(scan_total + idx + 1, total) - self.progress_message.emit( - f"Capturing mask {idx + 1}/{len(roi_indices)} from frame {frame_idx}" - ) - - for roi_idx, value in max_brightness.items(): - if value == float("-inf"): - max_brightness[roi_idx] = 0.0 + masks[roi_idx] = mask + sources[roi_idx] = mask_metadata.primary_source_frame + metadata[roi_idx] = mask_metadata self.finished.emit( PerRoiMaskCaptureResult( masks=masks, sources=sources, max_brightness=max_brightness, + metadata=metadata, ) ) except cv2.error as exc: diff --git a/tests/conftest.py b/tests/conftest.py index ba56fb3..206f4bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,6 +43,9 @@ def _factory(**overrides: Dict[str, Any]) -> VideoAnalyzer: instance.background_percentile = overrides.get("background_percentile", 90.0) instance.background_roi_idx = overrides.get("background_roi_idx") instance.rects = overrides.get("rects", []) + instance.fixed_roi_masks = overrides.get("fixed_roi_masks", []) + instance.mask_source_frames = overrides.get("mask_source_frames", []) + instance.fixed_mask_metadata = overrides.get("fixed_mask_metadata", []) instance.frame_cache = overrides.get("frame_cache", FrameCache(FRAME_CACHE_SIZE)) instance.cap = overrides.get("cap") return instance diff --git a/tests/integration/test_workers.py b/tests/integration/test_workers.py index 8c9f643..b392ea6 100644 --- a/tests/integration/test_workers.py +++ b/tests/integration/test_workers.py @@ -96,6 +96,7 @@ def test_brightest_frame_worker_picks_max_frame(monkeypatch): step=1, background_percentile=90.0, morphological_kernel_size=3, + noise_floor_threshold=0.0, ) worker = BrightestFrameWorker(request) @@ -105,7 +106,9 @@ def test_brightest_frame_worker_picks_max_frame(monkeypatch): result = captured.get("result") assert isinstance(result, BrightestFrameResult) - assert result.brightest_frame_idx == 1 + assert result.candidate_frames[0] == 1 + assert result.sources[0] == 1 + assert result.masks[0] is not None def test_brightest_frame_worker_handles_edge_touching_roi(monkeypatch): @@ -124,6 +127,7 @@ def test_brightest_frame_worker_handles_edge_touching_roi(monkeypatch): step=1, background_percentile=90.0, morphological_kernel_size=3, + noise_floor_threshold=0.0, ) worker = BrightestFrameWorker(request) @@ -133,7 +137,8 @@ def test_brightest_frame_worker_handles_edge_touching_roi(monkeypatch): result = captured.get("result") assert isinstance(result, BrightestFrameResult) - assert result.brightest_frame_idx == 1 + assert result.candidate_frames[0] == 1 + assert result.sources[0] == 1 def test_per_roi_mask_capture_worker_returns_sources(monkeypatch): @@ -153,6 +158,7 @@ def test_per_roi_mask_capture_worker_returns_sources(monkeypatch): step=1, background_percentile=90.0, morphological_kernel_size=3, + noise_floor_threshold=0.0, ) worker = PerRoiMaskCaptureWorker(request) @@ -166,3 +172,5 @@ def test_per_roi_mask_capture_worker_returns_sources(monkeypatch): assert result.sources[1] == 1 assert result.masks[0] is not None assert result.masks[1] is not None + assert result.metadata[0] is not None + assert result.metadata[1] is not None diff --git a/tests/ui/test_mask_hardening.py b/tests/ui/test_mask_hardening.py new file mode 100644 index 0000000..a7e0a1e --- /dev/null +++ b/tests/ui/test_mask_hardening.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import numpy as np +from PyQt5 import QtWidgets + +from ecl_analysis.analysis.models import MaskCaptureMetadata +from ecl_analysis.video_analyzer import VideoAnalyzer + + +class _StubCapture: + def isOpened(self) -> bool: + return True + + def release(self) -> None: + return None + + +def _prepare_loaded_window(window: VideoAnalyzer, total_frames: int = 100) -> None: + window.cap = _StubCapture() + window.frame = np.zeros((120, 200, 3), dtype=np.uint8) + window.total_frames = total_frames + window.playback_fps = 25.0 + window.start_frame = 0 + window.end_frame = total_frames - 1 + window.current_frame_index = 0 + window.frame_slider.setRange(0, total_frames - 1) + window.frame_spinbox.setRange(0, total_frames - 1) + window._seek_to_frame = lambda frame_index: setattr(window, "current_frame_index", frame_index) + window._sync_analysis_range_widgets() + + +def test_background_percentile_change_invalidates_fixed_masks( + qt_application: QtWidgets.QApplication, +) -> None: + window = VideoAnalyzer() + _prepare_loaded_window(window) + + window.rects = [((0, 0), (20, 20))] + window.fixed_roi_masks = [np.ones((20, 20), dtype=bool)] + window.mask_source_frames = [5] + window.fixed_mask_metadata = [ + MaskCaptureMetadata( + capture_mode="manual", + source_frames=[5], + primary_source_frame=5, + pixel_count=400, + confidence_label="high", + noise_floor_threshold=5.0, + morphological_kernel_size=3, + ) + ] + + window._on_bg_percentile_changed(95) + + assert window.fixed_roi_masks == [None] + assert window.mask_source_frames == [None] + assert window.fixed_mask_metadata == [None] + assert "cleared" in window.mask_status_label.text().lower() + + window.close() diff --git a/tests/unit/test_analysis_masking.py b/tests/unit/test_analysis_masking.py new file mode 100644 index 0000000..44c7bd3 --- /dev/null +++ b/tests/unit/test_analysis_masking.py @@ -0,0 +1,85 @@ +import numpy as np + +from ecl_analysis.analysis.masking import ( + build_consensus_mask, + build_signal_mask, + evaluate_mask_candidate, +) + + +def test_build_signal_mask_removes_isolated_hot_pixel(): + roi_l_star = np.zeros((8, 8), dtype=np.float32) + roi_l_star[2, 2] = 80.0 + + mask, threshold_value, min_area = build_signal_mask( + roi_l_star=roi_l_star, + background_brightness=5.0, + noise_floor_threshold=5.0, + morphological_kernel_size=3, + ) + + assert threshold_value == 5.0 + assert min_area >= 4 + assert not np.any(mask) + + +def test_evaluate_mask_candidate_scores_small_bright_region(): + roi_l_star = np.full((10, 10), 2.0, dtype=np.float32) + roi_l_star[4:7, 4:7] = 25.0 + + candidate = evaluate_mask_candidate( + roi_l_star=roi_l_star, + background_brightness=2.0, + noise_floor_threshold=5.0, + morphological_kernel_size=3, + frame_idx=12, + ) + + assert candidate is not None + assert candidate.frame_idx == 12 + assert candidate.score > 0.0 + assert candidate.pixel_count >= 4 + + +def test_build_consensus_mask_marks_unstable_capture(): + base_mask = np.zeros((8, 8), dtype=bool) + base_mask[2:5, 2:5] = True + + shifted_mask = np.zeros((8, 8), dtype=bool) + shifted_mask[3:6, 3:6] = True + + candidates = [ + type("Candidate", (), { + "frame_idx": 10, + "score": 12.0, + "background_brightness": 1.0, + "mask": base_mask, + "pixel_count": int(np.count_nonzero(base_mask)), + "signal_peak": 10.0, + "threshold_value": 5.0, + "min_component_area": 4, + })(), + type("Candidate", (), { + "frame_idx": 12, + "score": 11.0, + "background_brightness": 1.0, + "mask": shifted_mask, + "pixel_count": int(np.count_nonzero(shifted_mask)), + "signal_peak": 9.0, + "threshold_value": 5.0, + "min_component_area": 4, + })(), + ] + + mask, metadata = build_consensus_mask( + candidates=candidates, + capture_mode="per_roi_auto", + noise_floor_threshold=5.0, + morphological_kernel_size=3, + ) + + assert mask is not None + assert metadata.capture_mode == "per_roi_auto" + assert metadata.primary_source_frame == 10 + assert metadata.consensus_ratio < 1.0 + assert "low_consensus" in metadata.warnings diff --git a/tests/unit/test_export_csv_exporter.py b/tests/unit/test_export_csv_exporter.py index e9f13ef..41c28c5 100644 --- a/tests/unit/test_export_csv_exporter.py +++ b/tests/unit/test_export_csv_exporter.py @@ -45,10 +45,11 @@ def test_save_analysis_outputs_writes_csv_and_summary(tmp_path: Path): assert export.cancelled is False assert export.plot_failed is False assert any("Saved CSV:" in line for line in export.summary_lines) - assert len(export.out_paths) == 1 - assert export.out_paths[0].endswith("_brightness.csv") + assert len(export.out_paths) == 2 + csv_path = next(Path(path) for path in export.out_paths if path.endswith("_brightness.csv")) + metadata_path = next(Path(path) for path in export.out_paths if path.endswith("_analysis_metadata.json")) + assert metadata_path.exists() - csv_path = Path(export.out_paths[0]) assert csv_path.exists() df = pd.read_csv(csv_path) assert list(df.columns) == ["frame", "brightness_mean", "brightness_median", "blue_mean", "blue_median"] diff --git a/tests/unit/test_real_video_review.py b/tests/unit/test_real_video_review.py new file mode 100644 index 0000000..767d5e2 --- /dev/null +++ b/tests/unit/test_real_video_review.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from tools.run_real_video_review import main + + +def test_real_video_review_bundle_generation(tmp_path: Path, monkeypatch): + analysis_dir = tmp_path / "analysis" + analysis_dir.mkdir() + metadata_path = analysis_dir / "Demo_input_frames1-3_analysis_metadata.json" + metadata_path.write_text( + json.dumps( + { + "use_fixed_mask": True, + "mask_metadata": [ + { + "confidence_label": "high", + "pixel_count": 24, + "warnings": [], + "source_frames": [3, 4, 5], + } + ], + } + ), + encoding="utf-8", + ) + (analysis_dir / "Demo_input_ROI1_frames1-3_brightness.csv").write_text( + "frame,brightness_mean\n0,1\n", + encoding="utf-8", + ) + (analysis_dir / "Demo_input_plot.png").write_bytes(b"png") + + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "runs": [ + { + "label": "demo-run", + "video_path": str(tmp_path / "raw.mp4"), + "analysis_dir": str(analysis_dir), + "require_fixed_mask": True, + "min_confidence": "medium", + } + ] + } + ), + encoding="utf-8", + ) + + output_dir = tmp_path / "review" + monkeypatch.setattr( + "sys.argv", + ["run_real_video_review.py", str(manifest_path), "--output-dir", str(output_dir)], + ) + + exit_code = main() + + assert exit_code == 0 + assert (output_dir / "review_report.md").exists() + assert (output_dir / "review_index.json").exists() + assert (output_dir / "demo-run" / metadata_path.name).exists() + assert (output_dir / "demo-run" / "review_summary.json").exists() diff --git a/tools/mask_review_manifest.example.json b/tools/mask_review_manifest.example.json new file mode 100644 index 0000000..9503dba --- /dev/null +++ b/tools/mask_review_manifest.example.json @@ -0,0 +1,24 @@ +{ + "output_dir": "mask_review_outputs", + "cases": [ + { + "name": "dark_enclosure_reference", + "video_path": "/absolute/path/to/recording.mp4", + "analysis_name": "LabReview", + "rects": [ + [[20, 40], [120, 180]], + [[140, 40], [240, 180]], + [[260, 40], [360, 180]] + ], + "background_roi_idx": 0, + "start_frame": 0, + "end_frame": 299, + "capture_mode": "per_roi_auto", + "background_percentile": 90.0, + "morphological_kernel_size": 3, + "noise_floor_threshold": 5.0, + "expected_nonempty_rois": [1, 2], + "expected_empty_rois": [] + } + ] +} diff --git a/tools/real_video_review_manifest.example.json b/tools/real_video_review_manifest.example.json new file mode 100644 index 0000000..4342918 --- /dev/null +++ b/tools/real_video_review_manifest.example.json @@ -0,0 +1,13 @@ +{ + "runs": [ + { + "label": "electrode-series-a", + "video_path": "/absolute/path/to/raw_videos/electrode-series-a.mp4", + "analysis_dir": "/absolute/path/to/exported_results/electrode-series-a", + "require_fixed_mask": true, + "min_confidence": "medium", + "max_warning_count": 2, + "expected_non_background_rois": 3 + } + ] +} diff --git a/tools/run_mask_review.py b/tools/run_mask_review.py new file mode 100644 index 0000000..a9891eb --- /dev/null +++ b/tools/run_mask_review.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Run repeatable mask-review cases from a local manifest.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from ecl_analysis.analysis.background import compute_background_brightness +from ecl_analysis.analysis.brightness import compute_l_star_frame +from ecl_analysis.analysis.masking import build_signal_mask +from ecl_analysis.analysis.models import AnalysisRequest, AnalysisResult, MaskCaptureMetadata +from ecl_analysis.export.csv_exporter import save_analysis_outputs +from ecl_analysis.workers import ( + AnalysisWorker, + BrightestFrameResult, + BrightestFrameWorker, + MaskScanRequest, + PerRoiMaskCaptureResult, + PerRoiMaskCaptureWorker, +) + + +def _load_manifest(path: Path) -> Dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _run_worker(worker: Any) -> Any: + captured: Dict[str, Any] = {} + worker.finished.connect(lambda payload: captured.setdefault("result", payload)) + worker.error.connect(lambda message: captured.setdefault("error", message)) + worker.cancelled.connect(lambda: captured.setdefault("cancelled", True)) + worker.run() + if captured.get("cancelled"): + raise RuntimeError("Worker cancelled unexpectedly.") + if "error" in captured: + raise RuntimeError(str(captured["error"])) + if "result" not in captured: + raise RuntimeError("Worker did not produce a result.") + return captured["result"] + + +def _noop_plot_builder(*_args: Any, **_kwargs: Any) -> Tuple[None, None]: + return None, None + + +def _case_output_dir(root: Path, case_name: str) -> Path: + safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in case_name).strip("_") + return root / (safe_name or "review_case") + + +def _overlay_masks( + frame: np.ndarray, + rects: Sequence[Tuple[Tuple[int, int], Tuple[int, int]]], + background_roi_idx: Optional[int], + fixed_masks: Sequence[Optional[np.ndarray]], + noise_floor_threshold: float, + morphological_kernel_size: int, +) -> np.ndarray: + overlay = frame.copy() + l_star_frame = compute_l_star_frame(frame) + background_value = compute_background_brightness( + frame=frame, + rects=rects, + background_roi_idx=background_roi_idx, + background_percentile=90.0, + frame_l_star=l_star_frame, + ) + fh, fw = frame.shape[:2] + for roi_idx, (pt1, pt2) in enumerate(rects): + if roi_idx == background_roi_idx: + continue + x1 = max(0, min(int(min(pt1[0], pt2[0])), fw)) + x2 = max(0, min(int(max(pt1[0], pt2[0])), fw)) + y1 = max(0, min(int(min(pt1[1], pt2[1])), fh)) + y2 = max(0, min(int(max(pt1[1], pt2[1])), fh)) + if x2 <= x1 or y2 <= y1: + continue + roi = overlay[y1:y2, x1:x2] + roi_l_star = l_star_frame[y1:y2, x1:x2] + adaptive_mask, _threshold, _min_area = build_signal_mask( + roi_l_star=roi_l_star, + background_brightness=background_value, + noise_floor_threshold=noise_floor_threshold, + morphological_kernel_size=morphological_kernel_size, + ) + fixed_mask = None + if roi_idx < len(fixed_masks): + candidate = fixed_masks[roi_idx] + if isinstance(candidate, np.ndarray) and candidate.shape[:2] == roi.shape[:2]: + fixed_mask = candidate.astype(bool) + + if fixed_mask is None: + roi[adaptive_mask] = roi[adaptive_mask] * 0.7 + np.array([0, 0, 255]) * 0.3 + continue + + overlap_mask = fixed_mask & adaptive_mask + fixed_only_mask = fixed_mask & ~adaptive_mask + adaptive_only_mask = adaptive_mask & ~fixed_mask + roi[fixed_only_mask] = roi[fixed_only_mask] * 0.55 + np.array([0, 0, 255]) * 0.45 + roi[adaptive_only_mask] = roi[adaptive_only_mask] * 0.55 + np.array([255, 0, 0]) * 0.45 + roi[overlap_mask] = roi[overlap_mask] * 0.45 + np.array([255, 0, 255]) * 0.55 + + return overlay + + +def _save_overlay_images( + video_path: str, + case_dir: Path, + rects: Sequence[Tuple[Tuple[int, int], Tuple[int, int]]], + background_roi_idx: Optional[int], + fixed_masks: Sequence[Optional[np.ndarray]], + sources: Sequence[Optional[int]], + noise_floor_threshold: float, + morphological_kernel_size: int, +) -> List[str]: + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"Could not open video file for overlays: {video_path}") + + overlay_paths: List[str] = [] + try: + for frame_idx in sorted({value for value in sources if value is not None}): + cap.set(cv2.CAP_PROP_POS_FRAMES, int(frame_idx)) + ret, frame = cap.read() + if not ret or frame is None: + continue + overlay = _overlay_masks( + frame=frame, + rects=rects, + background_roi_idx=background_roi_idx, + fixed_masks=fixed_masks, + noise_floor_threshold=noise_floor_threshold, + morphological_kernel_size=morphological_kernel_size, + ) + filename = f"mask_overlay_frame{int(frame_idx) + 1:05d}.png" + out_path = case_dir / filename + cv2.imwrite(str(out_path), overlay) + overlay_paths.append(str(out_path)) + finally: + cap.release() + return overlay_paths + + +def _mask_review_summary( + metadata: Sequence[Optional[MaskCaptureMetadata]], + expected_nonempty_rois: Sequence[int], + expected_empty_rois: Sequence[int], +) -> Tuple[bool, List[str]]: + lines: List[str] = [] + passed = True + + for roi_idx in expected_nonempty_rois: + metadata_entry = metadata[roi_idx] if roi_idx < len(metadata) else None + pixel_count = metadata_entry.pixel_count if isinstance(metadata_entry, MaskCaptureMetadata) else 0 + roi_pass = pixel_count > 0 + passed &= roi_pass + lines.append( + f"- ROI {roi_idx + 1} expected non-empty: {'PASS' if roi_pass else 'FAIL'} " + f"(pixels={pixel_count})" + ) + + for roi_idx in expected_empty_rois: + metadata_entry = metadata[roi_idx] if roi_idx < len(metadata) else None + pixel_count = metadata_entry.pixel_count if isinstance(metadata_entry, MaskCaptureMetadata) else 0 + roi_pass = pixel_count == 0 + passed &= roi_pass + lines.append( + f"- ROI {roi_idx + 1} expected empty: {'PASS' if roi_pass else 'FAIL'} " + f"(pixels={pixel_count})" + ) + + for roi_idx, metadata_entry in enumerate(metadata): + if not isinstance(metadata_entry, MaskCaptureMetadata): + continue + warning_text = ", ".join(metadata_entry.warnings) if metadata_entry.warnings else "none" + lines.append( + f"- ROI {roi_idx + 1} quality: {metadata_entry.confidence_label}, " + f"consensus={metadata_entry.consensus_ratio:.2f}, warnings={warning_text}" + ) + + return passed, lines + + +def _run_case(case: Dict[str, Any], root_output_dir: Path) -> Dict[str, Any]: + case_name = str(case["name"]) + case_dir = _case_output_dir(root_output_dir, case_name) + case_dir.mkdir(parents=True, exist_ok=True) + + rects = [ + ((int(pt1[0]), int(pt1[1])), (int(pt2[0]), int(pt2[1]))) + for pt1, pt2 in case["rects"] + ] + start_frame = int(case.get("start_frame", 0)) + end_frame = int(case["end_frame"]) + background_roi_idx = case.get("background_roi_idx") + capture_mode = str(case.get("capture_mode", "per_roi_auto")) + background_percentile = float(case.get("background_percentile", 90.0)) + morphological_kernel_size = int(case.get("morphological_kernel_size", 3)) + noise_floor_threshold = float(case.get("noise_floor_threshold", 5.0)) + + scan_request = MaskScanRequest( + video_path=str(case["video_path"]), + rects=rects, + background_roi_idx=background_roi_idx, + start_frame=start_frame, + end_frame=end_frame, + step=1, + background_percentile=background_percentile, + morphological_kernel_size=morphological_kernel_size, + noise_floor_threshold=noise_floor_threshold, + ) + + if capture_mode == "global_auto": + scan_result = _run_worker(BrightestFrameWorker(scan_request)) + if not isinstance(scan_result, BrightestFrameResult): + raise RuntimeError("Unexpected global auto-capture result type.") + fixed_masks = scan_result.masks + mask_sources = scan_result.sources + mask_metadata = scan_result.metadata + else: + scan_result = _run_worker(PerRoiMaskCaptureWorker(scan_request)) + if not isinstance(scan_result, PerRoiMaskCaptureResult): + raise RuntimeError("Unexpected per-ROI auto-capture result type.") + fixed_masks = scan_result.masks + mask_sources = scan_result.sources + mask_metadata = scan_result.metadata + + analysis_request = AnalysisRequest( + video_path=str(case["video_path"]), + rects=rects, + background_roi_idx=background_roi_idx, + start_frame=start_frame, + end_frame=end_frame, + use_fixed_mask=True, + fixed_roi_masks=fixed_masks, + background_percentile=background_percentile, + morphological_kernel_size=morphological_kernel_size, + noise_floor_threshold=noise_floor_threshold, + mask_metadata=[metadata.clone() if metadata is not None else None for metadata in mask_metadata], + analysis_metadata={ + "review_case": case_name, + "capture_mode": capture_mode, + "rects": case["rects"], + "background_roi_idx": background_roi_idx, + "background_percentile": background_percentile, + "morphological_kernel_size": morphological_kernel_size, + "noise_floor_threshold": noise_floor_threshold, + }, + ) + analysis_result = _run_worker(AnalysisWorker(analysis_request)) + if not isinstance(analysis_result, AnalysisResult): + raise RuntimeError("Unexpected analysis result type.") + + export_result = save_analysis_outputs( + analysis_result=analysis_result, + save_dir=str(case_dir), + video_path=str(case["video_path"]), + analysis_name=str(case.get("analysis_name", case_name)), + plot_builder=_noop_plot_builder, + ) + overlay_paths = _save_overlay_images( + video_path=str(case["video_path"]), + case_dir=case_dir, + rects=rects, + background_roi_idx=background_roi_idx, + fixed_masks=fixed_masks, + sources=mask_sources, + noise_floor_threshold=noise_floor_threshold, + morphological_kernel_size=morphological_kernel_size, + ) + passed, summary_lines = _mask_review_summary( + metadata=mask_metadata, + expected_nonempty_rois=[int(value) for value in case.get("expected_nonempty_rois", [])], + expected_empty_rois=[int(value) for value in case.get("expected_empty_rois", [])], + ) + + review_report_path = case_dir / "review_summary.md" + review_report_path.write_text( + "\n".join( + [ + f"# {case_name}", + "", + f"- Capture mode: `{capture_mode}`", + f"- Video: `{case['video_path']}`", + f"- Result: {'PASS' if passed else 'FAIL'}", + "", + "## Checks", + *summary_lines, + "", + "## Exported artifacts", + *[f"- `{Path(path).name}`" for path in export_result.out_paths + overlay_paths], + ] + ) + + "\n", + encoding="utf-8", + ) + + return { + "name": case_name, + "passed": passed, + "case_dir": str(case_dir), + "overlay_paths": overlay_paths, + "export_paths": export_result.out_paths, + "review_report": str(review_report_path), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path, help="Path to a local JSON review manifest.") + parser.add_argument( + "--output-dir", + type=Path, + default=None, + help="Directory for generated review artifacts. Defaults to manifest `output_dir` or `mask_review_outputs`.", + ) + args = parser.parse_args() + + manifest = _load_manifest(args.manifest) + output_dir = args.output_dir or Path(manifest.get("output_dir", "mask_review_outputs")) + output_dir.mkdir(parents=True, exist_ok=True) + + case_results = [_run_case(case, output_dir) for case in manifest.get("cases", [])] + passed_count = sum(1 for result in case_results if result["passed"]) + summary_path = output_dir / "manifest_review_summary.json" + summary_path.write_text( + json.dumps( + { + "manifest": str(args.manifest), + "passed_cases": passed_count, + "total_cases": len(case_results), + "results": case_results, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + print(f"Saved review summary to {summary_path}") + return 0 if passed_count == len(case_results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_real_video_review.py b/tools/run_real_video_review.py new file mode 100644 index 0000000..0f29994 --- /dev/null +++ b/tools/run_real_video_review.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Assemble a repeatable review bundle for exported real-video analyses.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + + +CONFIDENCE_RANK = { + "none": 0, + "low": 1, + "medium": 2, + "high": 3, +} + + +@dataclass +class RunEvaluation: + """Materialized review result for one manifest entry.""" + + label: str + status: str + video_path: str + analysis_dir: str + metadata_path: Optional[str] + copied_artifacts: List[str] + notes: List[str] + roi_summaries: List[Dict[str, Any]] + + +def _load_manifest(manifest_path: Path) -> Dict[str, Any]: + with manifest_path.open("r", encoding="utf-8") as handle: + manifest = json.load(handle) + runs = manifest.get("runs") + if not isinstance(runs, list) or not runs: + raise ValueError("Manifest must contain a non-empty 'runs' array.") + return manifest + + +def _find_metadata_file(run_entry: Dict[str, Any]) -> Path: + explicit_path = run_entry.get("analysis_metadata_path") + if explicit_path: + metadata_path = Path(explicit_path).expanduser() + if not metadata_path.exists(): + raise FileNotFoundError(f"Metadata file not found: {metadata_path}") + return metadata_path + + analysis_dir = Path(run_entry["analysis_dir"]).expanduser() + matches = sorted(analysis_dir.glob("*_analysis_metadata.json")) + if len(matches) != 1: + raise FileNotFoundError( + f"Expected exactly one *_analysis_metadata.json in {analysis_dir}, found {len(matches)}." + ) + return matches[0] + + +def _copy_artifacts(analysis_dir: Path, output_dir: Path) -> List[str]: + copied: List[str] = [] + patterns = [ + "*_analysis_metadata.json", + "*_brightness.csv", + "*.png", + "*.html", + ] + for pattern in patterns: + for src in sorted(analysis_dir.glob(pattern)): + dst = output_dir / src.name + shutil.copy2(src, dst) + copied.append(dst.name) + return copied + + +def _summarize_mask_metadata(mask_metadata: List[Any]) -> List[Dict[str, Any]]: + summaries: List[Dict[str, Any]] = [] + for idx, metadata in enumerate(mask_metadata): + if not isinstance(metadata, dict): + continue + summaries.append( + { + "roi_index": idx, + "confidence_label": metadata.get("confidence_label", "none"), + "pixel_count": int(metadata.get("pixel_count", 0)), + "warnings": list(metadata.get("warnings", [])), + "source_frames": list(metadata.get("source_frames", [])), + } + ) + return summaries + + +def _evaluate_run(run_entry: Dict[str, Any], bundle_dir: Path) -> RunEvaluation: + label = str(run_entry.get("label") or Path(run_entry.get("video_path", "run")).stem) + video_path = str(Path(run_entry["video_path"]).expanduser()) + analysis_dir = Path(run_entry["analysis_dir"]).expanduser() + metadata_path = _find_metadata_file(run_entry) + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + roi_summaries = _summarize_mask_metadata(metadata.get("mask_metadata", [])) + + require_fixed_mask = bool(run_entry.get("require_fixed_mask", True)) + min_confidence = str(run_entry.get("min_confidence", "medium")).lower() + max_warning_count = run_entry.get("max_warning_count") + expected_rois = run_entry.get("expected_non_background_rois") + + notes: List[str] = [] + status = "PASS" + + if require_fixed_mask and not bool(metadata.get("use_fixed_mask")): + status = "FAIL" + notes.append("Fixed mask was not enabled during export.") + + if expected_rois is not None and int(expected_rois) != len(roi_summaries): + status = "FAIL" + notes.append(f"Expected {expected_rois} ROI summaries, found {len(roi_summaries)}.") + + total_warning_count = 0 + for summary in roi_summaries: + confidence_label = str(summary["confidence_label"]).lower() + if CONFIDENCE_RANK.get(confidence_label, 0) < CONFIDENCE_RANK.get(min_confidence, 0): + status = "FAIL" + notes.append( + f"ROI {summary['roi_index'] + 1} confidence {confidence_label!r} is below {min_confidence!r}." + ) + warning_count = len(summary["warnings"]) + total_warning_count += warning_count + if warning_count: + notes.append( + f"ROI {summary['roi_index'] + 1} warnings: {', '.join(summary['warnings'])}." + ) + + if max_warning_count is not None and total_warning_count > int(max_warning_count): + status = "FAIL" + notes.append( + f"Total warning count {total_warning_count} exceeded max_warning_count={int(max_warning_count)}." + ) + + run_output_dir = bundle_dir / label + run_output_dir.mkdir(parents=True, exist_ok=True) + copied_artifacts = _copy_artifacts(analysis_dir, run_output_dir) + + run_summary = { + "label": label, + "status": status, + "video_path": video_path, + "analysis_dir": str(analysis_dir), + "metadata_path": str(metadata_path), + "notes": notes, + "roi_summaries": roi_summaries, + "copied_artifacts": copied_artifacts, + } + (run_output_dir / "review_summary.json").write_text( + json.dumps(run_summary, indent=2), + encoding="utf-8", + ) + + return RunEvaluation( + label=label, + status=status, + video_path=video_path, + analysis_dir=str(analysis_dir), + metadata_path=str(metadata_path), + copied_artifacts=copied_artifacts, + notes=notes, + roi_summaries=roi_summaries, + ) + + +def _render_report(evaluations: List[RunEvaluation], bundle_dir: Path) -> str: + lines = [ + "# Real-Video Pixel Mask Review", + "", + "| Run | Status | ROI Count | Notes |", + "|---|---|---:|---|", + ] + for evaluation in evaluations: + note_text = "; ".join(evaluation.notes) if evaluation.notes else "No blocking issues." + lines.append( + f"| {evaluation.label} | {evaluation.status} | {len(evaluation.roi_summaries)} | {note_text} |" + ) + + lines.extend(["", "## Run Details", ""]) + for evaluation in evaluations: + lines.append(f"### {evaluation.label}") + lines.append("") + lines.append(f"- Status: `{evaluation.status}`") + lines.append(f"- Video: `{evaluation.video_path}`") + lines.append(f"- Analysis dir: `{evaluation.analysis_dir}`") + lines.append(f"- Metadata: `{evaluation.metadata_path or 'n/a'}`") + lines.append(f"- Review bundle: `{bundle_dir / evaluation.label}`") + if evaluation.notes: + lines.append(f"- Notes: {'; '.join(evaluation.notes)}") + else: + lines.append("- Notes: No blocking issues.") + for summary in evaluation.roi_summaries: + lines.append( + f"- ROI {summary['roi_index'] + 1}: confidence `{summary['confidence_label']}`, " + f"pixels `{summary['pixel_count']}`, warnings `{', '.join(summary['warnings']) or 'none'}`, " + f"source frames `{summary['source_frames']}`" + ) + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", type=Path, help="JSON manifest describing exported real-video analyses") + parser.add_argument( + "--output-dir", + type=Path, + default=Path("review_output"), + help="Directory where the review bundle should be written", + ) + args = parser.parse_args() + + manifest = _load_manifest(args.manifest) + output_dir = args.output_dir.expanduser() + output_dir.mkdir(parents=True, exist_ok=True) + + evaluations = [_evaluate_run(run_entry, output_dir) for run_entry in manifest["runs"]] + report_path = output_dir / "review_report.md" + report_path.write_text(_render_report(evaluations, output_dir), encoding="utf-8") + + index_path = output_dir / "review_index.json" + index_payload = { + "runs": [ + { + "label": evaluation.label, + "status": evaluation.status, + "metadata_path": evaluation.metadata_path, + "copied_artifacts": evaluation.copied_artifacts, + } + for evaluation in evaluations + ] + } + index_path.write_text(json.dumps(index_payload, indent=2), encoding="utf-8") + print(f"Wrote review bundle to {output_dir}") + print(f"Report: {report_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a5c619643eb7112d1446f494d0a44f89cf34672d Mon Sep 17 00:00:00 2001 From: Ned Cutler Date: Thu, 2 Apr 2026 14:08:59 -0400 Subject: [PATCH 2/4] Add capture metadata validation and inbox ingest flow --- README.md | 37 ++- docs/capture_metadata_schema.md | 53 ++++ docs/iphone_capture_pipeline_review.md | 73 +++++ docs/metadata_ingest_execution_plan.md | 50 +++ ecl_analysis/ingest/__init__.py | 19 ++ ecl_analysis/ingest/metadata.py | 364 ++++++++++++++++++++++ ecl_analysis/video_analyzer.py | 46 ++- tests/unit/test_capture_inbox.py | 161 ++++++++++ tests/unit/test_export_csv_exporter.py | 29 ++ tests/unit/test_ingest_metadata.py | 152 +++++++++ tools/capture_inbox_manifest.example.json | 21 ++ tools/ingest_capture_inbox.py | 276 ++++++++++++++++ 12 files changed, 1276 insertions(+), 5 deletions(-) create mode 100644 docs/capture_metadata_schema.md create mode 100644 docs/iphone_capture_pipeline_review.md create mode 100644 docs/metadata_ingest_execution_plan.md create mode 100644 ecl_analysis/ingest/__init__.py create mode 100644 ecl_analysis/ingest/metadata.py create mode 100644 tests/unit/test_capture_inbox.py create mode 100644 tests/unit/test_ingest_metadata.py create mode 100644 tools/capture_inbox_manifest.example.json create mode 100644 tools/ingest_capture_inbox.py diff --git a/README.md b/README.md index c2323c5..ed7d48c 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,47 @@ If `git pull` shows a conflict or error, reach out before trying to fix it. 3. **Set frame range** — use "Set Start/End" buttons to find the active region automatically 4. **Run analysis** — click "Analyze Brightness" (or press F5), choose an output folder +### Capture Metadata Sidecar (new) + +When a video is loaded, the app now checks for an optional sidecar file named: + +`.capture.json` + +Example: +- `experiment_01.mov` +- `experiment_01.capture.json` + +The current schema authority is a lightweight versioned contract with `schema_version: "1.0"`. The validator checks required acquisition fields (`device_model`, exposure/white-balance lock flags, exposure duration, ISO, FPS, resolution, HDR flag), warns on legacy or invalid metadata, and shows a metadata status line in the UI after load. + +Reference: +- `docs/capture_metadata_schema.md` + +### Capture Inbox Workflow (new) + +For end-to-end testing, you can now point a local inbox at incoming iPhone captures and optionally auto-run analysis with a fixed manifest: + +```bash +python tools/ingest_capture_inbox.py tools/capture_inbox_manifest.example.json +``` + +That tool: +- scans `inbox_dir` for video + `*.capture.json` pairs +- validates capture metadata using the current schema contract +- creates deterministic per-capture output folders using `capture_id` when present +- writes `capture_ingest_summary.json` for each capture +- optionally runs the existing mask-review analysis path if `analysis_case` is configured +- optionally archives processed source files out of the inbox + +If you want to rerun ingest against the same capture and `output_dir`, pass `--force-reprocess` or set `"force_reprocess": true` in the manifest. Otherwise identical source signatures are skipped on purpose, and the run summary now includes the existing summary path that triggered the skip. + +Use `--watch-seconds 5` to keep rescanning during manual device-to-desktop testing. + ### Output Each analysis produces: - **CSV files** — one per ROI with columns: `frame, brightness_mean, brightness_median, blue_mean, blue_median` - **Plot images** — dual-panel PNG (brightness trends + difference plot) with statistical annotations -- **Metadata sidecar** — one `*_analysis_metadata.json` file capturing mask mode, thresholds, source frames, and mask-quality warnings +- **Metadata sidecar** — one `*_analysis_metadata.json` file capturing mask mode, thresholds, source frames, mask-quality warnings, and normalized capture provenance / validation results when a capture sidecar exists ### Dark-Enclosure Review Workflow diff --git a/docs/capture_metadata_schema.md b/docs/capture_metadata_schema.md new file mode 100644 index 0000000..f54842f --- /dev/null +++ b/docs/capture_metadata_schema.md @@ -0,0 +1,53 @@ +# Capture Metadata Sidecar Schema + +The analyzer accepts an optional sidecar JSON file next to each video: + +- Video: `experiment_01.mov` +- Sidecar: `experiment_01.capture.json` + +The current lightweight schema authority is: + +- `schema_version: "1.0"` +- Schema contract source of truth: `ecl_analysis/ingest/metadata.py` + +This is intentionally versioned but non-blocking during the transition from legacy videos to the dedicated iPhone capture app. Missing or invalid metadata should warn, not block analysis. + +## Required fields for schema `1.0` + +```json +{ + "schema_version": "1.0", + "device_model": "iPhone 15 Pro", + "capture_id": "8A0F0A5A-2A79-4D8C-9C2A-0CCF9F9368EA", + "recorded_at": "2026-04-01T10:15:30Z", + "app_version": "0.1.0", + "ios_version": "iOS 26.0", + "video_codec": "h264", + "color_space": "sdr", + "exposure_mode_locked": true, + "exposure_duration": 0.0333333333, + "iso": 80, + "white_balance_mode_locked": true, + "fps": 30, + "resolution": "1920x1080", + "hdr_disabled": true +} +``` + +## Validation behavior + +- Missing sidecar: warning-only in the UI; analysis still proceeds. +- Missing `schema_version`: warning; validator assumes compatibility with schema `1.0` and marks `schema_version_assumed: true` in exported provenance. +- Unknown `schema_version`: warning; validator performs best-effort validation against current fields. +- Missing required acquisition fields: warning-only at load time, but surfaced as validation errors in exported provenance. +- Unknown fields are retained in provenance as `unrecognized_fields` so schema drift is visible without blocking ingest. + +## Export behavior + +Analysis metadata exports now include: + +- `capture_metadata_validation`: whether the sidecar passed validation plus any warnings/errors +- `capture_metadata`: normalized capture provenance when a sidecar is present +- `capture_provenance`: grouped export view that carries both the normalized metadata and the validation record used for the run + +That contract is the boundary the iPhone capture app should target. diff --git a/docs/iphone_capture_pipeline_review.md b/docs/iphone_capture_pipeline_review.md new file mode 100644 index 0000000..bff1ae4 --- /dev/null +++ b/docs/iphone_capture_pipeline_review.md @@ -0,0 +1,73 @@ +# iPhone Capture Pipeline Feasibility Review + +## Context +The current app analyzes pre-recorded videos and assumes camera settings are stable enough for relative brightness trends. + +## What the project already does well +- Computes brightness using CIE L* from each frame and supports background subtraction and noise/morphological filtering. +- Exports reproducible frame-level CSV files and plots. +- Explicitly documents that manual exposure/ISO/white balance lock is required for valid results. + +## Current gap vs. requested workflow +Your proposed workflow is: +1. Record on iPhone with exposure lock and stable imaging pipeline. +2. Persist capture settings in metadata. +3. Automatically deliver video into ECL_Analysis for processing. + +The repository currently starts analysis from a local file picker / drag-drop and does not include: +- iPhone capture controls. +- In-app metadata ingestion/validation for camera settings. +- An automated watch/import service for incoming files. + +## Feasibility assessment +This is feasible and likely worth it if consistency is your top priority. + +### Why it is worth doing +- This codebase already depends on consistency of acquisition conditions for scientific validity. +- Most of your measurement error risk is upstream (capture variability), not downstream (analysis code). +- A capture-controlled iPhone flow should reduce false trends caused by auto-exposure, tone mapping, HDR, or AWB drift. + +### Practical constraints to account for +- iPhone camera APIs are iOS-native (AVFoundation). A robust capture app is best built as a separate iOS app, not inside this PyQt desktop app. +- iOS may not allow writing arbitrary custom metadata into the container exactly how you want for every codec/profile; often you should also create a sidecar JSON record. +- HEVC/HDR/Dolby Vision defaults can distort analysis unless explicitly disabled. + +## Recommended architecture (incremental) + +### Phase 1 (highest ROI, low risk): metadata-aware import in this repo +Add import-time validation in ECL_Analysis: +- Parse container metadata via ffprobe/exiftool (codec, fps, dimensions, capture date, color transfer/profile when available). +- Use a lightweight versioned sidecar JSON contract (`schema_version: "1.0"`) from `ecl_analysis/ingest/metadata.py` with fields like: + - device_model + - exposure_mode_locked + - exposure_duration + - iso + - white_balance_mode_locked + - fps + - resolution + - hdr_disabled +- Warn, rather than block, when required fields are missing or invalid so legacy videos remain analyzable during the transition. +- Normalize recognized sidecar fields before export so downstream analysis artifacts stay reproducible even when inputs vary in representation. + +### Phase 2: automatic ingest +- Add a watched inbox folder (`incoming/`). +- New files with valid sidecar metadata are queued for analysis automatically. +- Save outputs to deterministic folder names tied to capture IDs. + +### Phase 3: iPhone acquisition app +- Build a lightweight iOS capture app (Swift + AVFoundation): + - lock exposure/ISO/white balance/focus + - disable HDR/night mode/deep tone mapping where possible + - force fixed FPS and resolution + - export MOV + sidecar JSON + - upload directly to shared storage / API endpoint consumed by the desktop pipeline + +## Suggested acceptance criteria +- Repeated static-scene captures produce <= X% frame-level brightness variance across runs. +- Pipeline surfaces capture-provenance warnings for any run lacking lock-confirmed metadata. +- Analysis output includes capture settings provenance in summary artifacts, including schema version, validation status, and normalized sidecar fields. + +## Bottom line +Yes, this is feasible. It is also strategically aligned with the project’s own measurement assumptions. + +Best path: keep this Python analyzer as the analysis engine, and add (1) metadata-gated ingest now, then (2) iPhone capture app integration. That gives you immediate quality gains without a risky full rewrite. diff --git a/docs/metadata_ingest_execution_plan.md b/docs/metadata_ingest_execution_plan.md new file mode 100644 index 0000000..a6e064b --- /dev/null +++ b/docs/metadata_ingest_execution_plan.md @@ -0,0 +1,50 @@ +# Metadata Ingest Execution Plan + +## Goal +Improve acquisition consistency and provenance in the desktop analyzer while keeping legacy videos analyzable during the transition to a dedicated iPhone capture app. + +## Decisions +- Capture metadata validation is warning-first, not hard-blocking. +- The schema authority is a lightweight versioned sidecar contract with `schema_version: "1.0"`. +- The Python desktop app remains the analysis engine. +- The iPhone capture app should live in a separate repository and can start as a minimal AVFoundation MVP. + +## Phase Status + +### Phase 1: metadata-aware import in this repo +Status: in progress + +Implemented: +- Sidecar schema contract and validator in `ecl_analysis/ingest/metadata.py` +- UI load-time metadata status in `ecl_analysis/video_analyzer.py` +- Provenance export fields in analysis metadata outputs +- Tests covering validation behavior and metadata export wiring + +Remaining: +- Optional container-level metadata parsing (`ffprobe` / `exiftool`) to cross-check sidecar claims +- More explicit UI surfacing of validation warnings/details beyond the status line + +### Phase 2: automatic ingest +Status: in progress + +Implemented: +- Inbox ingest script in `tools/ingest_capture_inbox.py` +- Deterministic capture output folders using `capture_id` when present +- Per-capture ingest summaries and optional archive behavior +- Manifest-driven optional auto-analysis flow + +Remaining: +- Decide where the watched inbox should live in real deployments +- Add any daemon/service wrapper if continuous unattended ingest is needed + +### Phase 3: iPhone capture app +Status: not started in this repository + +Planned: +- Minimal Swift / AVFoundation capture app in a separate repository +- Fixed capture settings, sidecar JSON export, and transfer into the desktop ingest path + +## Near-Term Next Steps +1. Commit the Phase 1 and Phase 2 desktop-side ingest work. +2. Decide whether container metadata cross-checking is required before starting the iPhone app. +3. Create a separate repository for the iPhone capture MVP. diff --git a/ecl_analysis/ingest/__init__.py b/ecl_analysis/ingest/__init__.py new file mode 100644 index 0000000..f74a5ee --- /dev/null +++ b/ecl_analysis/ingest/__init__.py @@ -0,0 +1,19 @@ +"""Capture-ingest helpers.""" + +from .metadata import ( + CAPTURE_METADATA_SCHEMA_NAME, + CAPTURE_METADATA_SCHEMA_VERSION, + CURRENT_CAPTURE_SCHEMA_VERSION, + CaptureMetadataValidation, + get_capture_metadata_schema_contract, + validate_capture_metadata, +) + +__all__ = [ + "CAPTURE_METADATA_SCHEMA_NAME", + "CAPTURE_METADATA_SCHEMA_VERSION", + "CURRENT_CAPTURE_SCHEMA_VERSION", + "CaptureMetadataValidation", + "get_capture_metadata_schema_contract", + "validate_capture_metadata", +] diff --git a/ecl_analysis/ingest/metadata.py b/ecl_analysis/ingest/metadata.py new file mode 100644 index 0000000..9e9c28c --- /dev/null +++ b/ecl_analysis/ingest/metadata.py @@ -0,0 +1,364 @@ +"""Validation helpers for camera-capture sidecar metadata.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +CAPTURE_METADATA_SCHEMA_NAME = "ecl_capture_metadata" +CURRENT_CAPTURE_SCHEMA_VERSION = "1.0" +CAPTURE_METADATA_SCHEMA_VERSION = CURRENT_CAPTURE_SCHEMA_VERSION +SUPPORTED_CAPTURE_SCHEMA_VERSIONS = {CURRENT_CAPTURE_SCHEMA_VERSION} +REQUIRED_CAPTURE_FIELDS = ( + "device_model", + "exposure_mode_locked", + "exposure_duration", + "iso", + "white_balance_mode_locked", + "fps", + "resolution", + "hdr_disabled", +) +OPTIONAL_CAPTURE_FIELDS = ( + "capture_id", + "recorded_at", + "app_version", + "ios_version", + "color_space", + "video_codec", +) +KNOWN_CAPTURE_FIELDS = ("schema_version",) + REQUIRED_CAPTURE_FIELDS + OPTIONAL_CAPTURE_FIELDS + +CAPTURE_METADATA_FIELD_SPECS: Dict[str, Dict[str, str]] = { + "schema_version": { + "type": "string", + "required": "false during transition; assumed when omitted", + "description": "Version of the sidecar contract emitted by capture.", + }, + "device_model": { + "type": "string", + "required": "true", + "description": "Capture device model.", + }, + "exposure_mode_locked": { + "type": "boolean", + "required": "true", + "description": "Exposure lock state during capture.", + }, + "exposure_duration": { + "type": "number", + "required": "true", + "description": "Exposure duration in seconds.", + }, + "iso": { + "type": "number", + "required": "true", + "description": "Sensor ISO at capture time.", + }, + "white_balance_mode_locked": { + "type": "boolean", + "required": "true", + "description": "White-balance lock state during capture.", + }, + "fps": { + "type": "number", + "required": "true", + "description": "Configured frames per second.", + }, + "resolution": { + "type": "string|object", + "required": "true", + "description": "Capture resolution as WIDTHxHEIGHT or {width,height}.", + }, + "hdr_disabled": { + "type": "boolean", + "required": "true", + "description": "Whether HDR/tone mapping was disabled.", + }, + "capture_id": { + "type": "string", + "required": "false", + "description": "Stable capture identifier for downstream provenance.", + }, + "recorded_at": { + "type": "string", + "required": "false", + "description": "Capture timestamp in ISO-8601 format.", + }, + "app_version": { + "type": "string", + "required": "false", + "description": "Version of the capture app.", + }, + "ios_version": { + "type": "string", + "required": "false", + "description": "iOS version on the capture device.", + }, + "color_space": { + "type": "string", + "required": "false", + "description": "Recorded color space/profile label.", + }, + "video_codec": { + "type": "string", + "required": "false", + "description": "Recorded video codec label.", + }, +} + + +def get_capture_metadata_schema_contract() -> Dict[str, object]: + """Return the current lightweight sidecar schema contract.""" + return { + "schema_name": CAPTURE_METADATA_SCHEMA_NAME, + "schema_version": CAPTURE_METADATA_SCHEMA_VERSION, + "supported_schema_versions": sorted(SUPPORTED_CAPTURE_SCHEMA_VERSIONS), + "required_fields": { + field: dict(CAPTURE_METADATA_FIELD_SPECS[field]) for field in REQUIRED_CAPTURE_FIELDS + }, + "optional_fields": { + field: dict(CAPTURE_METADATA_FIELD_SPECS[field]) for field in OPTIONAL_CAPTURE_FIELDS + }, + "schema_version_field": dict(CAPTURE_METADATA_FIELD_SPECS["schema_version"]), + "resolution_formats": ["1920x1080", {"width": 1920, "height": 1080}], + } + + +@dataclass(frozen=True) +class CaptureMetadataValidation: + """Structured validation result for capture metadata sidecars.""" + + is_valid: bool + sidecar_path: str + errors: List[str] + warnings: List[str] + metadata: Optional[Dict[str, object]] = None + normalized_metadata: Optional[Dict[str, object]] = None + detected_schema_version: Optional[str] = None + schema_version_assumed: bool = False + unrecognized_fields: List[str] = field(default_factory=list) + + @property + def status(self) -> str: + if not self.is_valid: + return "invalid" + if self.warnings: + return "valid_with_warnings" + return "valid" + + def to_dict(self) -> Dict[str, Any]: + """Serialize the validation result for export/reporting.""" + return { + "is_valid": self.is_valid, + "status": self.status, + "sidecar_path": self.sidecar_path, + "errors": list(self.errors), + "warnings": list(self.warnings), + "schema_name": CAPTURE_METADATA_SCHEMA_NAME, + "expected_schema_version": CAPTURE_METADATA_SCHEMA_VERSION, + "detected_schema_version": self.detected_schema_version, + "schema_version": ( + None if self.normalized_metadata is None else self.normalized_metadata.get("schema_version") + ), + "schema_version_assumed": self.schema_version_assumed, + "unrecognized_fields": list(self.unrecognized_fields), + "schema_contract": get_capture_metadata_schema_contract(), + "metadata": dict(self.metadata) if isinstance(self.metadata, dict) else None, + "normalized_metadata": ( + dict(self.normalized_metadata) if isinstance(self.normalized_metadata, dict) else None + ), + } + + +def _sidecar_path_for_video(video_path: str) -> str: + base, _ = os.path.splitext(video_path) + return f"{base}.capture.json" + + +def _coerce_bool(value: object) -> Optional[bool]: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + return None + + +def _coerce_positive_float(value: object, field_name: str, errors: List[str]) -> Optional[float]: + if value is None: + return None + try: + numeric = float(value) + except (TypeError, ValueError): + errors.append(f"{field_name} must be numeric.") + return None + if numeric <= 0: + errors.append(f"{field_name} must be greater than 0.") + return None + return numeric + + +def _normalize_resolution(value: object, errors: List[str]) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + normalized = value.strip().lower().replace(" ", "") + if "x" in normalized: + width, height = normalized.split("x", 1) + if width.isdigit() and height.isdigit(): + return f"{int(width)}x{int(height)}" + if isinstance(value, dict): + width = value.get("width") + height = value.get("height") + if isinstance(width, (int, float)) and isinstance(height, (int, float)) and width > 0 and height > 0: + return f"{int(width)}x{int(height)}" + errors.append("resolution must be a string like '1920x1080' or an object with width/height.") + return None + + +def validate_capture_metadata(video_path: str) -> CaptureMetadataValidation: + """Load and validate `