diff --git a/beltmap/phase.py b/beltmap/phase.py index f91a2d6..22f45d5 100644 --- a/beltmap/phase.py +++ b/beltmap/phase.py @@ -45,19 +45,44 @@ class PhaseRegistrationConfig: robust_normalization: bool = False def candidate_offsets(self) -> FloatArray: - if self.search_radius_px < 0: + cfg = self.normalized() + radius = cfg.search_radius_px + if radius < 0: raise ValueError("search_radius_px must be non-negative") - if self.search_step_px <= 0: + step = cfg.search_step_px + if step <= 0: raise ValueError("search_step_px must be positive") - radius = float(self.search_radius_px) if radius == 0.0: return np.asarray([0.0], dtype=np.float64) - step = float(self.search_step_px) positive = step * np.arange(int(np.floor(radius / step)) + 1, dtype=np.float64) if not np.any(np.isclose(positive, radius)): positive = np.append(positive, radius) return np.r_[-positive[:0:-1], positive].astype(np.float64, copy=False) + def normalized(self) -> PhaseRegistrationConfig: + search_radius_px = _finite_float_value( + self.search_radius_px, + "search_radius_px", + ) + search_step_px = _finite_float_value( + self.search_step_px, + "search_step_px", + ) + trim_fraction = _finite_float_value(self.trim_fraction, "trim_fraction") + highpass_radius_px = _nonnegative_integer_value( + self.highpass_radius_px, + "highpass_radius_px", + ) + if not 0 <= trim_fraction < 1: + raise ValueError("trim_fraction must be in [0, 1)") + return replace( + self, + search_radius_px=search_radius_px, + search_step_px=search_step_px, + trim_fraction=trim_fraction, + highpass_radius_px=highpass_radius_px, + ) + @dataclass(frozen=True) class PhaseTrajectorySmoothingConfig: @@ -70,15 +95,22 @@ class PhaseTrajectorySmoothingConfig: min_support: int = 3 def validate(self) -> None: - if self.window_radius_frames < 0: - raise ValueError("window_radius_frames must be non-negative") - if self.min_support < 1: - raise ValueError("min_support must be positive") - if self.robust_sigma <= 0: + _nonnegative_integer_value( + self.window_radius_frames, + "window_radius_frames", + ) + _positive_integer_value(self.min_support, "min_support") + robust_sigma = _finite_float_value(self.robust_sigma, "robust_sigma") + if robust_sigma <= 0: raise ValueError("robust_sigma must be positive") - if self.min_score is not None and self.min_score < 0: + min_score = _optional_finite_float_value(self.min_score, "min_score") + if min_score is not None and min_score < 0: raise ValueError("min_score must be non-negative when set") - if self.max_abs_correction_px is not None and self.max_abs_correction_px < 0: + max_abs_correction_px = _optional_finite_float_value( + self.max_abs_correction_px, + "max_abs_correction_px", + ) + if max_abs_correction_px is not None and max_abs_correction_px < 0: raise ValueError("max_abs_correction_px must be non-negative when set") @@ -169,11 +201,13 @@ def _accepts(self, estimate: PhaseEstimate) -> bool: def wrap_phase(phase_px: float, period_px: float | None) -> float: + phase = _finite_float_value(phase_px, "phase_px") if period_px is None: - return float(phase_px) - if period_px <= 0: + return phase + period = _finite_float_value(period_px, "period_px") + if period <= 0: raise ValueError("period_px must be positive") - return float(phase_px % period_px) + return float(phase % period) def render_belt_view( @@ -193,13 +227,14 @@ def render_belt_view( belt = _as_float_image(belt_map, name="belt_map") if belt.ndim != 2: raise ValueError("belt_map must be a 2-D array") + phase = _finite_float_value(phase_px, "phase_px") if height <= 0: raise ValueError("height must be positive") if x_slice is not None: belt = belt[:, x_slice] map_height = belt.shape[0] - rows = np.arange(height, dtype=np.float64) + float(phase_px) + rows = np.arange(height, dtype=np.float64) + phase if periodic: rows = rows % map_height row0 = np.floor(rows).astype(np.int64) @@ -254,7 +289,7 @@ def refine_phase_by_registration( config: PhaseRegistrationConfig | None = None, mask: ArrayLike | None = None, ) -> PhaseEstimate: - cfg = config or PhaseRegistrationConfig() + cfg = (config or PhaseRegistrationConfig()).normalized() observed = _as_float_image(frame, name="frame") belt = _as_float_image(belt_map, name="belt_map") if observed.ndim != 2 or belt.ndim != 2: @@ -518,6 +553,33 @@ def _as_float_image(image: ArrayLike, *, name: str) -> FloatArray: return arr +def _finite_float_value(value: float, name: str) -> float: + parsed = float(value) + if not np.isfinite(parsed): + raise ValueError(f"{name} must be finite") + return parsed + + +def _optional_finite_float_value(value: float | None, name: str) -> float | None: + if value is None: + return None + return _finite_float_value(value, name) + + +def _nonnegative_integer_value(value: int, name: str) -> int: + parsed = _finite_float_value(value, name) + if parsed < 0 or not parsed.is_integer(): + raise ValueError(f"{name} must be a finite non-negative integer") + return int(parsed) + + +def _positive_integer_value(value: int, name: str) -> int: + parsed = _finite_float_value(value, name) + if parsed < 1 or not parsed.is_integer(): + raise ValueError(f"{name} must be a finite positive integer") + return int(parsed) + + def _prepare_mask(mask: ArrayLike | None, shape: tuple[int, int]) -> NDArray[np.bool_] | None: if mask is None: return None diff --git a/beltmap/rendering.py b/beltmap/rendering.py index 493685c..2661ff4 100644 --- a/beltmap/rendering.py +++ b/beltmap/rendering.py @@ -96,7 +96,7 @@ def render_expected_clean_belt( observed = _as_float_image(observed_frame, name="observed_frame") if observed.ndim != 2: raise ValueError("observed_frame must be a 2-D array") - if output_shape is not None and tuple(int(value) for value in output_shape) != observed.shape: + if output_shape is not None and _resolve_shape(output_shape) != observed.shape: raise ValueError( "output_shape must match observed_frame.shape when observed_frame " "is supplied" @@ -180,11 +180,21 @@ def _resolve_belt_region( height, width = output_shape if output_shape is not None else fallback_shape return BeltRegion(top=0, left=0, height=height, width=width) if isinstance(belt_region, BeltRegion): - return belt_region + return BeltRegion( + top=_integer_config_value(belt_region.top, "belt_region top"), + left=_integer_config_value(belt_region.left, "belt_region left"), + height=_integer_config_value(belt_region.height, "belt_region height"), + width=_integer_config_value(belt_region.width, "belt_region width"), + ) if len(belt_region) != 4: raise ValueError("belt_region tuple must be (top, left, height, width)") top, left, height, width = belt_region - region = BeltRegion(int(top), int(left), int(height), int(width)) + region = BeltRegion( + _integer_config_value(top, "belt_region top"), + _integer_config_value(left, "belt_region left"), + _integer_config_value(height, "belt_region height"), + _integer_config_value(width, "belt_region width"), + ) if output_shape is None and region.width != belt_width: raise ValueError("belt_region width must match belt_map width") return region @@ -196,12 +206,28 @@ def _resolve_output_shape( region: BeltRegion, ) -> tuple[int, int]: if output_shape is not None: - return tuple(int(value) for value in output_shape) + return _resolve_shape(output_shape) if observed is not None: return observed.shape return (region.top + region.height, region.left + region.width) +def _resolve_shape(output_shape: tuple[int, int]) -> tuple[int, int]: + if len(output_shape) != 2: + raise ValueError("output_shape must be (height, width)") + return ( + _integer_config_value(output_shape[0], "output_shape height"), + _integer_config_value(output_shape[1], "output_shape width"), + ) + + +def _integer_config_value(value: int, name: str) -> int: + parsed = float(value) + if not np.isfinite(parsed) or not parsed.is_integer(): + raise ValueError(f"{name} must be a finite integer") + return int(parsed) + + def _validate_region(region: BeltRegion, output_shape: tuple[int, int]) -> None: if region.top < 0 or region.left < 0: raise ValueError("belt_region top and left must be non-negative") diff --git a/beltmap/residual.py b/beltmap/residual.py index 6615374..ea356ca 100644 --- a/beltmap/residual.py +++ b/beltmap/residual.py @@ -139,16 +139,20 @@ def estimate_local_noise( """Estimate robust local noise scale for a residual image.""" cfg = config or ResidualConfig() - if cfg.noise_radius_px < 0: - raise ValueError("noise_radius_px must be non-negative") - if cfg.min_noise <= 0: - raise ValueError("min_noise must be positive") - if cfg.clip_sigma is not None and cfg.clip_sigma <= 0: - raise ValueError("clip_sigma must be positive when set") - if cfg.noise_exclusion_sigma is not None and cfg.noise_exclusion_sigma <= 0: - raise ValueError("noise_exclusion_sigma must be positive when set") - if cfg.noise_exclusion_radius_px < 0: - raise ValueError("noise_exclusion_radius_px must be non-negative") + noise_radius_px = _nonnegative_integer_config_value( + cfg.noise_radius_px, + "noise_radius_px", + ) + min_noise = _positive_config_value(cfg.min_noise, "min_noise") + clip_sigma = _optional_positive_config_value(cfg.clip_sigma, "clip_sigma") + noise_exclusion_sigma = _optional_positive_config_value( + cfg.noise_exclusion_sigma, + "noise_exclusion_sigma", + ) + noise_exclusion_radius_px = _nonnegative_integer_config_value( + cfg.noise_exclusion_radius_px, + "noise_exclusion_radius_px", + ) noise_exclusion_mode = _validate_noise_exclusion_mode(cfg.noise_exclusion_mode) values = _as_float_image(residual, name="residual") @@ -163,14 +167,15 @@ def estimate_local_noise( sample = values[valid] center = float(np.median(sample)) - global_sigma = _robust_sigma(sample, center=center, min_noise=cfg.min_noise) + global_sigma = _robust_sigma(sample, center=center, min_noise=min_noise) noise_valid = valid.copy() particle_noise_mask = _particle_noise_exclusion_mask( values, valid=valid, center=center, global_sigma=global_sigma, - config=cfg, + noise_exclusion_sigma=noise_exclusion_sigma, + noise_exclusion_radius_px=noise_exclusion_radius_px, mode=noise_exclusion_mode, ) if particle_noise_mask.any(): @@ -179,24 +184,24 @@ def estimate_local_noise( noise_valid = valid.copy() centered = np.zeros(values.shape, dtype=np.float64) centered[valid] = values[valid] - center - if cfg.clip_sigma is not None: + if clip_sigma is not None: centered = np.clip( centered, - -cfg.clip_sigma * global_sigma, - cfg.clip_sigma * global_sigma, + -clip_sigma * global_sigma, + clip_sigma * global_sigma, ) local_var = _masked_box_mean( np.square(centered), noise_valid, - radius=cfg.noise_radius_px, + radius=noise_radius_px, ) local_var = np.where( np.isfinite(local_var), local_var, global_sigma * global_sigma, ) - local_noise = np.sqrt(np.maximum(local_var, cfg.min_noise * cfg.min_noise)) + local_noise = np.sqrt(np.maximum(local_var, min_noise * min_noise)) local_noise[~valid] = cfg.fill_value return local_noise @@ -227,18 +232,39 @@ def _validate_noise_exclusion_mode(mode: str) -> str: ) +def _positive_config_value(value: float, name: str) -> float: + parsed = float(value) + if not np.isfinite(parsed) or parsed <= 0: + raise ValueError(f"{name} must be finite and positive") + return parsed + + +def _optional_positive_config_value(value: float | None, name: str) -> float | None: + if value is None: + return None + return _positive_config_value(value, name) + + +def _nonnegative_integer_config_value(value: int, name: str) -> int: + parsed = float(value) + if not np.isfinite(parsed) or parsed < 0 or not parsed.is_integer(): + raise ValueError(f"{name} must be a finite non-negative integer") + return int(parsed) + + def _particle_noise_exclusion_mask( values: FloatArray, *, valid: NDArray[np.bool_], center: float, global_sigma: float, - config: ResidualConfig, + noise_exclusion_sigma: float | None, + noise_exclusion_radius_px: int, mode: str, ) -> NDArray[np.bool_]: - if config.noise_exclusion_sigma is None: + if noise_exclusion_sigma is None: return np.zeros(values.shape, dtype=bool) - threshold = config.noise_exclusion_sigma * global_sigma + threshold = noise_exclusion_sigma * global_sigma centered = values - center if mode == "positive": particle_like = valid & (centered > threshold) @@ -248,9 +274,9 @@ def _particle_noise_exclusion_mask( particle_like = valid & (np.abs(centered) > threshold) if not particle_like.any(): return particle_like - if config.noise_exclusion_radius_px > 0: + if noise_exclusion_radius_px > 0: particle_like = ( - _dilate_mask(particle_like, radius=config.noise_exclusion_radius_px) + _dilate_mask(particle_like, radius=noise_exclusion_radius_px) & valid ) return particle_like diff --git a/beltmap/tracking.py b/beltmap/tracking.py index 8145999..dec4d74 100644 --- a/beltmap/tracking.py +++ b/beltmap/tracking.py @@ -277,6 +277,16 @@ def track_particle_detections( ) if len(effective_frame_indices) != len(detections_by_frame): raise ValueError("frame_indices must have the same length as detections_by_frame") + if not all(np.isfinite(index) for index in effective_frame_indices): + raise ValueError("frame_indices must be finite") + if any( + current <= previous + for previous, current in zip( + effective_frame_indices, + effective_frame_indices[1:], + ) + ): + raise ValueError("frame_indices must be strictly increasing") tracks: list[list[ParticleDetection]] = [] active_track_ids: list[int] = [] @@ -642,13 +652,18 @@ def estimate_particle_velocities_vs_belt( raise ValueError("belt_image_velocity_px_per_frame must be finite") if belt_image_velocity_px_per_frame == 0: raise ValueError("belt_image_velocity_px_per_frame must be non-zero") - if min_track_length < 2: + min_track_length_value = _finite_config_value( + min_track_length, + "min_track_length", + ) + if min_track_length_value < 2 or not min_track_length_value.is_integer(): raise ValueError("min_track_length must be at least 2") + min_track_length_int = int(min_track_length_value) fit_method = _validate_velocity_fit_method(fit_method) velocities: list[ParticleVelocity] = [] for track in tracks: - if track.n_detections < min_track_length: + if track.n_detections < min_track_length_int: continue frames = np.asarray([d.frame_index for d in track.detections], dtype=np.float64) if np.unique(frames).size < 2: @@ -919,32 +934,71 @@ def extract_particle_velocities_vs_belt( def _validate_component_config(config: ParticleComponentConfig) -> None: - if config.min_area_px < 1: + min_area_px = _finite_config_value(config.min_area_px, "min_area_px") + if min_area_px < 1: raise ValueError("min_area_px must be positive") - if config.max_area_px is not None and config.max_area_px < config.min_area_px: + max_area_px = _optional_finite_config_value(config.max_area_px, "max_area_px") + if max_area_px is not None and max_area_px < min_area_px: raise ValueError("max_area_px must be greater than or equal to min_area_px") - if config.min_bbox_width_px is not None and config.min_bbox_width_px < 1: + min_bbox_width_px = _optional_finite_config_value( + config.min_bbox_width_px, + "min_bbox_width_px", + ) + if min_bbox_width_px is not None and min_bbox_width_px < 1: raise ValueError("min_bbox_width_px must be positive when set") - if config.min_bbox_height_px is not None and config.min_bbox_height_px < 1: + min_bbox_height_px = _optional_finite_config_value( + config.min_bbox_height_px, + "min_bbox_height_px", + ) + if min_bbox_height_px is not None and min_bbox_height_px < 1: raise ValueError("min_bbox_height_px must be positive when set") + max_bbox_aspect_ratio = _optional_finite_config_value( + config.max_bbox_aspect_ratio, + "max_bbox_aspect_ratio", + ) if ( - config.max_bbox_aspect_ratio is not None - and config.max_bbox_aspect_ratio < 1.0 + max_bbox_aspect_ratio is not None + and max_bbox_aspect_ratio < 1.0 ): raise ValueError("max_bbox_aspect_ratio must be at least 1 when set") - if config.min_bbox_extent is not None and not (0.0 <= config.min_bbox_extent <= 1.0): + min_bbox_extent = _optional_finite_config_value( + config.min_bbox_extent, + "min_bbox_extent", + ) + if min_bbox_extent is not None and not (0.0 <= min_bbox_extent <= 1.0): raise ValueError("min_bbox_extent must be in [0, 1] when set") if config.connectivity not in (4, 8): raise ValueError("connectivity must be 4 or 8") - if config.split_min_projection_gap_px < 1: + split_min_projection_gap_px = _finite_config_value( + config.split_min_projection_gap_px, + "split_min_projection_gap_px", + ) + if split_min_projection_gap_px < 1: raise ValueError("split_min_projection_gap_px must be positive") + split_min_component_area_px = _optional_finite_config_value( + config.split_min_component_area_px, + "split_min_component_area_px", + ) if ( - config.split_min_component_area_px is not None - and config.split_min_component_area_px < 1 + split_min_component_area_px is not None + and split_min_component_area_px < 1 ): raise ValueError("split_min_component_area_px must be positive when set") +def _finite_config_value(value: float, name: str) -> float: + parsed = float(value) + if not np.isfinite(parsed): + raise ValueError(f"{name} must be finite") + return parsed + + +def _optional_finite_config_value(value: float | None, name: str) -> float | None: + if value is None: + return None + return _finite_config_value(value, name) + + def _validate_velocity_fit_method(method: str) -> str: normalized = str(method).strip().lower() if normalized not in _VELOCITY_FIT_METHODS: @@ -988,7 +1042,11 @@ def _component_shape_passes( def _validate_track_filter_config(config: TrackFilterConfig) -> None: - if config.min_track_length < 1: + min_track_length = _finite_config_value( + config.min_track_length, + "min_track_length", + ) + if min_track_length < 1 or not min_track_length.is_integer(): raise ValueError("min_track_length must be positive") if not np.isfinite(config.min_velocity_ratio_y): raise ValueError("min_velocity_ratio_y must be finite") @@ -996,19 +1054,31 @@ def _validate_track_filter_config(config: TrackFilterConfig) -> None: raise ValueError("max_velocity_ratio_y must be finite") if config.max_velocity_ratio_y < config.min_velocity_ratio_y: raise ValueError("max_velocity_ratio_y must be greater than or equal to min_velocity_ratio_y") + max_abs_x_velocity_px_per_frame = _optional_finite_config_value( + config.max_abs_x_velocity_px_per_frame, + "max_abs_x_velocity_px_per_frame", + ) if ( - config.max_abs_x_velocity_px_per_frame is not None - and config.max_abs_x_velocity_px_per_frame <= 0 + max_abs_x_velocity_px_per_frame is not None + and max_abs_x_velocity_px_per_frame <= 0 ): raise ValueError("max_abs_x_velocity_px_per_frame must be positive when set") - if config.max_recurrent_artifact_track_score is not None and not ( - 0.0 <= config.max_recurrent_artifact_track_score <= 1.0 + max_recurrent_artifact_track_score = _optional_finite_config_value( + config.max_recurrent_artifact_track_score, + "max_recurrent_artifact_track_score", + ) + if max_recurrent_artifact_track_score is not None and not ( + 0.0 <= max_recurrent_artifact_track_score <= 1.0 ): raise ValueError( "max_recurrent_artifact_track_score must be in [0, 1] when set" ) + recurrent_artifact_detection_threshold = _finite_config_value( + config.recurrent_artifact_detection_threshold, + "recurrent_artifact_detection_threshold", + ) if not ( - 0.0 <= config.recurrent_artifact_detection_threshold <= 1.0 + 0.0 <= recurrent_artifact_detection_threshold <= 1.0 ): raise ValueError( "recurrent_artifact_detection_threshold must be in [0, 1]" diff --git a/tests/test_phase.py b/tests/test_phase.py index 101c87b..98b9d89 100644 --- a/tests/test_phase.py +++ b/tests/test_phase.py @@ -7,6 +7,7 @@ PhaseRegistrationConfig, PhaseTrajectorySmoothingConfig, estimate_phase, + refine_phase_by_registration, render_belt_view, smooth_phase_estimates, ) @@ -16,6 +17,7 @@ _refine_quadratic_offset, _registration_loss_diagnostics, _uniform_filter_axis, + wrap_phase, ) @@ -102,6 +104,25 @@ def test_render_belt_view_can_mark_nonperiodic_out_of_support_rows(): assert np.isnan(after_end[2, 0]) +@pytest.mark.parametrize( + ("phase_px", "period_px", "message"), + [ + (float("nan"), None, "phase_px"), + (0.0, float("nan"), "period_px"), + ], +) +def test_wrap_phase_rejects_nonfinite_values(phase_px, period_px, message): + with pytest.raises(ValueError, match=message): + wrap_phase(phase_px, period_px) + + +def test_render_belt_view_rejects_nonfinite_phase(): + belt = np.arange(5, dtype=float)[:, None] + + with pytest.raises(ValueError, match="phase_px"): + render_belt_view(belt, phase_px=float("nan"), height=3) + + def test_quadratic_refinement_returns_consistent_loss_offset_pair(): losses = [(1.25, -1.0), (1.0, 0.0), (1.25, 1.0)] @@ -139,6 +160,34 @@ def test_registration_candidate_offsets_are_symmetric_for_non_divisible_step(): np.testing.assert_allclose(offsets, [-1.0, -0.6, 0.0, 0.6, 1.0]) +@pytest.mark.parametrize( + ("config", "message"), + [ + (PhaseRegistrationConfig(search_radius_px=float("nan")), "search_radius_px"), + (PhaseRegistrationConfig(search_step_px=float("nan")), "search_step_px"), + (PhaseRegistrationConfig(trim_fraction=float("nan")), "trim_fraction"), + (PhaseRegistrationConfig(highpass_radius_px=float("nan")), "highpass_radius_px"), + (PhaseRegistrationConfig(highpass_radius_px=1.5), "highpass_radius_px"), + ], +) +def test_registration_config_rejects_invalid_numeric_settings(config, message): + with pytest.raises(ValueError, match=message): + config.normalized() + + +def test_registration_refinement_validates_full_config_before_search(): + frame = np.zeros((4, 4), dtype=float) + belt = np.zeros((8, 4), dtype=float) + + with pytest.raises(ValueError, match="trim_fraction"): + refine_phase_by_registration( + frame=frame, + belt_map=belt, + predicted_phase_px=0.0, + config=PhaseRegistrationConfig(trim_fraction=float("nan")), + ) + + def test_uniform_filter_axis_matches_edge_padded_reference(): image = np.array( [ @@ -267,6 +316,23 @@ def test_smooth_phase_estimates_rejects_registration_outlier(): assert smoothed[4].method == "registration_smoothed" +@pytest.mark.parametrize( + ("config", "message"), + [ + (PhaseTrajectorySmoothingConfig(window_radius_frames=float("nan")), "window_radius_frames"), + (PhaseTrajectorySmoothingConfig(window_radius_frames=1.5), "window_radius_frames"), + (PhaseTrajectorySmoothingConfig(min_support=float("nan")), "min_support"), + (PhaseTrajectorySmoothingConfig(min_support=1.5), "min_support"), + (PhaseTrajectorySmoothingConfig(robust_sigma=float("nan")), "robust_sigma"), + (PhaseTrajectorySmoothingConfig(min_score=float("nan")), "min_score"), + (PhaseTrajectorySmoothingConfig(max_abs_correction_px=float("nan")), "max_abs_correction_px"), + ], +) +def test_phase_smoothing_config_rejects_invalid_numeric_settings(config, message): + with pytest.raises(ValueError, match=message): + config.validate() + + def test_smooth_phase_estimates_uses_cyclic_corrections(): estimates = [ PhaseEstimate( diff --git a/tests/test_rendering.py b/tests/test_rendering.py index e286ae0..7e01320 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from beltmap import ( BeltMotionModel, @@ -60,6 +61,27 @@ def test_render_expected_clean_belt_accepts_explicit_phase_estimate(): assert render.phase_estimate is phase +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"belt_region": (0.5, 0, 3, 4), "output_shape": (3, 4)}, "belt_region top"), + ({"belt_region": (0, 0, 3, 4), "output_shape": (3.5, 4)}, "output_shape height"), + ({"belt_region": BeltRegion(top=0, left=0, height=3.5, width=4), "output_shape": (4, 4)}, "belt_region height"), + ], +) +def test_render_expected_clean_belt_rejects_fractional_geometry(kwargs, message): + belt = np.zeros((8, 4), dtype=float) + phase = PhaseEstimate(phase_px=0.0, frame_index=0, predicted_phase_px=0.0) + + with pytest.raises(ValueError, match=message): + render_expected_clean_belt( + belt_map=belt, + frame_index=0, + phase_estimate=phase, + **kwargs, + ) + + def test_render_expected_clean_belt_can_refine_phase_from_observed_frame(): belt = make_belt_map(period=80, width=18) true_model = BeltMotionModel( diff --git a/tests/test_residual.py b/tests/test_residual.py index da3ba72..791e0d9 100644 --- a/tests/test_residual.py +++ b/tests/test_residual.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from beltmap import ( BeltMotionModel, @@ -115,6 +116,25 @@ def test_estimate_local_noise_excludes_negative_particle_pixels_when_requested() assert float(with_exclusion[25, 25]) < float(without_exclusion[25, 25]) * 0.7 +@pytest.mark.parametrize( + ("config", "message"), + [ + (ResidualConfig(noise_radius_px=float("nan")), "noise_radius_px"), + (ResidualConfig(noise_radius_px=1.5), "noise_radius_px"), + (ResidualConfig(clip_sigma=float("nan")), "clip_sigma"), + (ResidualConfig(noise_exclusion_sigma=float("nan")), "noise_exclusion_sigma"), + (ResidualConfig(noise_exclusion_radius_px=float("nan")), "noise_exclusion_radius_px"), + (ResidualConfig(noise_exclusion_radius_px=1.5), "noise_exclusion_radius_px"), + (ResidualConfig(min_noise=float("nan")), "min_noise"), + ], +) +def test_estimate_local_noise_rejects_invalid_numeric_config(config, message): + residual = np.ones((5, 5), dtype=float) + + with pytest.raises(ValueError, match=message): + estimate_local_noise(residual, config=config) + + def test_render_clean_belt_residual_returns_standardized_particle_signal(): period = 64 width = 16 diff --git a/tests/test_tracking.py b/tests/test_tracking.py index 9a47e54..d0a60b5 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -87,6 +87,26 @@ def test_extract_particle_detections_applies_shape_gates(): assert detections[0].area_px == 16 +@pytest.mark.parametrize( + ("config", "message"), + [ + (ParticleComponentConfig(min_area_px=float("nan")), "min_area_px"), + (ParticleComponentConfig(max_area_px=float("nan")), "max_area_px"), + (ParticleComponentConfig(min_bbox_width_px=float("nan")), "min_bbox_width_px"), + (ParticleComponentConfig(min_bbox_height_px=float("nan")), "min_bbox_height_px"), + (ParticleComponentConfig(max_bbox_aspect_ratio=float("nan")), "max_bbox_aspect_ratio"), + (ParticleComponentConfig(min_bbox_extent=float("nan")), "min_bbox_extent"), + (ParticleComponentConfig(split_min_projection_gap_px=float("nan")), "split_min_projection_gap_px"), + (ParticleComponentConfig(split_min_component_area_px=float("nan")), "split_min_component_area_px"), + ], +) +def test_extract_particle_detections_rejects_nonfinite_component_config(config, message): + mask = np.ones((2, 2), dtype=bool) + + with pytest.raises(ValueError, match=message): + extract_particle_detections(mask, config=config) + + def test_connected_components_prefers_accelerated_scipy_labeler(monkeypatch): mask = np.array([[True]]) expected = [(np.array([0]), np.array([0]))] @@ -221,6 +241,33 @@ def test_velocity_extraction_skips_tracks_without_finite_fit_points(): assert velocities[0].velocity_ratio_y == 0.5 +def test_track_particle_detections_rejects_nonfinite_frame_indices(): + with pytest.raises(ValueError, match="frame_indices must be finite"): + track_particle_detections( + [[]], + frame_indices=[float("nan")], + ) + + +@pytest.mark.parametrize("frame_indices", [[0, 0], [1, 0]]) +def test_track_particle_detections_rejects_nonincreasing_frame_indices(frame_indices): + with pytest.raises(ValueError, match="strictly increasing"): + track_particle_detections( + [[], []], + frame_indices=frame_indices, + ) + + +@pytest.mark.parametrize("min_track_length", [float("nan"), 2.5]) +def test_estimate_particle_velocities_rejects_invalid_min_track_length(min_track_length): + with pytest.raises(ValueError, match="min_track_length"): + estimate_particle_velocities_vs_belt( + [], + belt_image_velocity_px_per_frame=1.0, + min_track_length=min_track_length, + ) + + def test_track_particle_detections_predicts_from_recent_track_velocity(): detections_by_frame = [ [ @@ -1110,6 +1157,20 @@ def test_score_particle_velocities_requires_tracks_for_enabled_recurrent_gate(): ) +@pytest.mark.parametrize( + ("config", "message"), + [ + (TrackFilterConfig(min_track_length=float("nan")), "min_track_length"), + (TrackFilterConfig(max_abs_x_velocity_px_per_frame=float("nan")), "max_abs_x_velocity_px_per_frame"), + (TrackFilterConfig(max_recurrent_artifact_track_score=float("nan")), "max_recurrent_artifact_track_score"), + (TrackFilterConfig(recurrent_artifact_detection_threshold=float("nan")), "recurrent_artifact_detection_threshold"), + ], +) +def test_score_particle_velocities_rejects_nonfinite_filter_config(config, message): + with pytest.raises(ValueError, match=message): + score_particle_velocities([], config=config, tracks=[]) + + def test_track_particle_detections_drops_tracks_across_explicit_empty_frame_gap(): detections_by_frame = [ [ParticleDetection(0, 1, y=10.0, x=5.0, area_px=4, bbox_top=9, bbox_left=4, bbox_bottom=11, bbox_right=6)],