From afb6921d336e68f71b1c43ba3a56ce04dd04dbf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:08:17 +0000 Subject: [PATCH 01/10] Initial plan From 964ec0201b67cabef2462240634f3e5c21d03f89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:13:10 +0000 Subject: [PATCH 02/10] Add optional preprocessing step to marker detection pipeline Implement preprocess_marker_frame() helper and PREPROCESS_PRESETS in pyneon/video/marker.py, extend detect_markers() and Video.detect_markers() with preprocess/preprocess_params parameters, update docstring templates, and add unit tests. --- pyneon/utils/docstring_templating.py | 25 +++++ pyneon/video/__init__.py | 4 +- pyneon/video/marker.py | 153 +++++++++++++++++++++++++++ pyneon/video/video.py | 4 + tests/test_video.py | 74 +++++++++++++ 5 files changed, 259 insertions(+), 1 deletion(-) diff --git a/pyneon/utils/docstring_templating.py b/pyneon/utils/docstring_templating.py index 3dd1ab5..ff5114f 100644 --- a/pyneon/utils/docstring_templating.py +++ b/pyneon/utils/docstring_templating.py @@ -167,6 +167,31 @@ def render_doc( If True, undistorts frames before detection, which can improve detection performance, then redistorts detected points. Returned coordinates remain in the original (distorted) video frame. Defaults to ``False``. +preprocess : bool or str, optional + Enable a grayscale preprocessing stage applied after optional undistortion + and before marker detection. Preprocessing can improve robustness under + challenging conditions such as low contrast, uneven illumination, or + IR-emitter hotspots. + + Accepted values: + + - ``False`` (default): no preprocessing; current behaviour is preserved. + - ``True``: apply the ``"mild"`` preset (CLAHE + highlight clipping + + light blur + unsharp mask). + - ``"mild"``: balanced general-purpose preset. + - ``"ir"``: stronger highlight clipping for IR-emitter hotspots. + - ``"low_light"``: more aggressive CLAHE for dim scenes. + + Individual preprocessing parameters can be further overridden via + ``preprocess_params``. Defaults to ``False``. +preprocess_params : dict or None, optional + Keyword arguments forwarded to + :func:`~pyneon.video.marker.preprocess_marker_frame` that override the + values of the active preset (or set values when ``preprocess=False``). + Allowed keys are: ``clahe``, ``clahe_clip_limit``, + ``clahe_tile_grid_size``, ``clip_highlights``, ``highlight_percentile``, + ``gaussian_blur_sigma``, ``sharpen``, ``sharpen_amount``. + Defaults to ``None``. """ DOC["detect_contour_params"] = """ diff --git a/pyneon/video/__init__.py b/pyneon/video/__init__.py index a14ea2b..65badd5 100644 --- a/pyneon/video/__init__.py +++ b/pyneon/video/__init__.py @@ -15,7 +15,7 @@ from .detect_contour import detect_contour from .estimate_pose import estimate_camera_pose from .homography import find_homographies -from .marker import detect_markers +from .marker import PREPROCESS_PRESETS, detect_markers, preprocess_marker_frame from .scanpath import estimate_scanpath from .video import Video @@ -23,6 +23,8 @@ "Video", "estimate_scanpath", "detect_markers", + "preprocess_marker_frame", + "PREPROCESS_PRESETS", "detect_contour", "estimate_camera_pose", "find_homographies", diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index ace7c10..f7ccc2e 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -21,6 +21,139 @@ from .video import Video +#: Built-in preprocessing presets for :func:`detect_markers`. +#: +#: Each preset is a dict of keyword arguments forwarded to +#: :func:`preprocess_marker_frame`. Pass the preset name as +#: ``preprocess="mild"`` (etc.) to :func:`detect_markers`. +PREPROCESS_PRESETS: dict[str, dict] = { + "mild": { + "clahe": True, + "clahe_clip_limit": 2.0, + "clahe_tile_grid_size": (8, 8), + "clip_highlights": True, + "highlight_percentile": 99.5, + "gaussian_blur_sigma": 0.8, + "sharpen": True, + "sharpen_amount": 1.0, + }, + "ir": { + "clahe": True, + "clahe_clip_limit": 2.0, + "clahe_tile_grid_size": (8, 8), + "clip_highlights": True, + "highlight_percentile": 99.0, + "gaussian_blur_sigma": 1.0, + "sharpen": True, + "sharpen_amount": 0.6, + }, + "low_light": { + "clahe": True, + "clahe_clip_limit": 2.5, + "clahe_tile_grid_size": (8, 8), + "clip_highlights": False, + "highlight_percentile": 99.5, + "gaussian_blur_sigma": 0.6, + "sharpen": True, + "sharpen_amount": 1.0, + }, +} + + +def preprocess_marker_frame( + gray_frame: np.ndarray, + *, + clahe: bool = True, + clahe_clip_limit: float = 2.0, + clahe_tile_grid_size: tuple[int, int] = (8, 8), + clip_highlights: bool = True, + highlight_percentile: float = 99.5, + gaussian_blur_sigma: float = 0.8, + sharpen: bool = True, + sharpen_amount: float = 1.0, +) -> np.ndarray: + """Preprocess a grayscale frame to improve AprilTag / ArUco detection. + + Applies a lightweight pipeline intended to help with low-contrast, + unevenly illuminated, or IR-contaminated scenes. All operations use + OpenCV/NumPy only and return a ``uint8`` grayscale image compatible + with :func:`cv2.aruco.ArucoDetector.detectMarkers`. + + The processing order is: + + 1. Highlight clipping / compression (optional) + 2. Local contrast enhancement via CLAHE (optional) + 3. Mild Gaussian smoothing (optional, ``gaussian_blur_sigma > 0``) + 4. Unsharp-mask sharpening (optional) + + Parameters + ---------- + gray_frame : numpy.ndarray + Grayscale ``uint8`` input image. + clahe : bool, optional + Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) for + local contrast enhancement. Defaults to ``True``. + clahe_clip_limit : float, optional + Clip limit for CLAHE. Higher values give stronger enhancement but + more noise amplification. Defaults to ``2.0``. + clahe_tile_grid_size : tuple[int, int], optional + Tile grid size for CLAHE. Defaults to ``(8, 8)``. + clip_highlights : bool, optional + Compress very bright highlights (e.g., IR emitter hotspots) by + clipping pixel values above ``highlight_percentile`` and + re-normalising to the full 0–255 range. Defaults to ``True``. + highlight_percentile : float, optional + Percentile used as the upper clip boundary when + ``clip_highlights=True``. Defaults to ``99.5``. + gaussian_blur_sigma : float, optional + Standard deviation for mild Gaussian smoothing applied before + sharpening. Set to ``0`` to disable. Defaults to ``0.8``. + sharpen : bool, optional + Apply unsharp masking to recover edge contrast after smoothing. + Defaults to ``True``. + sharpen_amount : float, optional + Strength of the unsharp mask (higher = more sharpening). + Defaults to ``1.0``. + + Returns + ------- + numpy.ndarray + Preprocessed grayscale ``uint8`` image of the same spatial size + as ``gray_frame``. + """ + if gray_frame.dtype != np.uint8: + gray_frame = gray_frame.astype(np.uint8) + + img = gray_frame.copy() + + if clip_highlights: + high = np.percentile(img, highlight_percentile) + high = max(float(high), 1.0) + img = np.clip(img, 0, high) + img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) + + if clahe: + clahe_op = cv2.createCLAHE( + clipLimit=clahe_clip_limit, + tileGridSize=clahe_tile_grid_size, + ) + img = clahe_op.apply(img) + + blurred = img + if gaussian_blur_sigma and gaussian_blur_sigma > 0: + blurred = cv2.GaussianBlur(img, (0, 0), gaussian_blur_sigma) + + if sharpen: + img_f = img.astype(np.float32) + blurred_f = blurred.astype(np.float32) + sharp = img_f + sharpen_amount * (img_f - blurred_f) + img = np.clip(sharp, 0, 255).astype(np.uint8) + else: + img = blurred + + return img + + def marker_family_to_dict(marker_family: str) -> Tuple[str, cv2.aruco.Dictionary]: # AprilTags if marker_family in APRILTAG_FAMILIES: @@ -87,6 +220,8 @@ def detect_markers( processing_window_unit: Literal["frame", "time", "timestamp"] = "frame", detector_parameters: Optional[cv2.aruco.DetectorParameters] = None, undistort: bool = False, + preprocess: bool | str = False, + preprocess_params: Optional[dict] = None, ) -> Stream: """ Detect fiducial markers (AprilTag or ArUco) in a video and report their data for every processed frame. @@ -117,6 +252,22 @@ def detect_markers( if step < 1: raise ValueError("step must be >= 1") + # Resolve preprocessing configuration + _pp_kwargs: Optional[dict] = None + if preprocess is not False: + preset_name = "mild" if preprocess is True else preprocess + if preset_name not in PREPROCESS_PRESETS: + raise ValueError( + f"Unknown preprocess preset '{preset_name}'. " + f"Available presets: {list(PREPROCESS_PRESETS)}. " + "Pass preprocess=False to disable preprocessing." + ) + _pp_kwargs = dict(PREPROCESS_PRESETS[preset_name]) + if preprocess_params: + _pp_kwargs.update(preprocess_params) + elif preprocess_params: + _pp_kwargs = dict(preprocess_params) + start_frame_idx, end_frame_idx = resolve_processing_window( video, processing_window, @@ -176,6 +327,8 @@ def _process_frame(frame_idx: int, gray_frame: np.ndarray) -> list[dict]: gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if undistort: gray_frame = video.undistort_frame(gray_frame) + if _pp_kwargs is not None: + gray_frame = preprocess_marker_frame(gray_frame, **_pp_kwargs) records = _process_frame(frame_index, gray_frame) detected_markers.extend(records) diff --git a/pyneon/video/video.py b/pyneon/video/video.py index 6d8fbc8..d498caa 100644 --- a/pyneon/video/video.py +++ b/pyneon/video/video.py @@ -630,6 +630,8 @@ def detect_markers( processing_window_unit: Literal["frame", "time", "timestamp"] = "frame", detector_parameters: Optional[cv2.aruco.DetectorParameters] = None, undistort: bool = False, + preprocess: bool | str = False, + preprocess_params: Optional[dict] = None, ) -> Stream: """ Detect fiducial markers (AprilTag or ArUco) in the video frames. @@ -658,6 +660,8 @@ def detect_markers( processing_window_unit=processing_window_unit, detector_parameters=detector_parameters, undistort=undistort, + preprocess=preprocess, + preprocess_params=preprocess_params, ) @fill_doc diff --git a/tests/test_video.py b/tests/test_video.py index e8fbe99..197815d 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -2,6 +2,8 @@ import numpy as np import pytest +from pyneon.video.marker import PREPROCESS_PRESETS, preprocess_marker_frame + @pytest.mark.parametrize( "dataset_fixture", @@ -54,3 +56,75 @@ def test_video_basics(request, dataset_fixture): eye_video.close() if video is not None: video.close() + + +# --------------------------------------------------------------------------- +# Unit tests for preprocess_marker_frame +# --------------------------------------------------------------------------- + + +@pytest.fixture +def synthetic_gray(): + """A deterministic synthetic grayscale uint8 image.""" + rng = np.random.default_rng(42) + return rng.integers(0, 256, (480, 640), dtype=np.uint8) + + +def test_preprocess_returns_uint8_same_shape(synthetic_gray): + out = preprocess_marker_frame(synthetic_gray) + assert out.shape == synthetic_gray.shape + assert out.dtype == np.uint8 + + +def test_preprocess_float_input_accepted(synthetic_gray): + img_float = synthetic_gray.astype(np.float32) + out = preprocess_marker_frame(img_float) + assert out.dtype == np.uint8 + assert out.shape == synthetic_gray.shape + + +def test_preprocess_noop_preserves_image(synthetic_gray): + """With all stages disabled the output should equal the input.""" + out = preprocess_marker_frame( + synthetic_gray, + clahe=False, + clip_highlights=False, + gaussian_blur_sigma=0, + sharpen=False, + ) + np.testing.assert_array_equal(out, synthetic_gray) + + +@pytest.mark.parametrize("preset_name", list(PREPROCESS_PRESETS)) +def test_preprocess_all_presets(synthetic_gray, preset_name): + params = PREPROCESS_PRESETS[preset_name] + out = preprocess_marker_frame(synthetic_gray, **params) + assert out.shape == synthetic_gray.shape + assert out.dtype == np.uint8 + + +def test_preprocess_custom_params(synthetic_gray): + out = preprocess_marker_frame( + synthetic_gray, + clahe=True, + clahe_clip_limit=3.0, + clahe_tile_grid_size=(4, 4), + clip_highlights=True, + highlight_percentile=95.0, + gaussian_blur_sigma=1.5, + sharpen=True, + sharpen_amount=0.5, + ) + assert out.shape == synthetic_gray.shape + assert out.dtype == np.uint8 + + +def test_detect_markers_invalid_preprocess_preset(): + """detect_markers should raise for an unknown preset name.""" + from unittest.mock import MagicMock + + from pyneon.video.marker import detect_markers + + mock_video = MagicMock() + with pytest.raises(ValueError, match="Unknown preprocess preset"): + detect_markers(mock_video, "36h11", preprocess="nonexistent_preset") From c53136c1ed694d9e9d844e89ea2890325ed360fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:14:31 +0000 Subject: [PATCH 03/10] Address code review feedback: update docstring and add missing test --- pyneon/video/marker.py | 3 ++- tests/test_video.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index f7ccc2e..0a0d0b5 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -89,7 +89,8 @@ def preprocess_marker_frame( Parameters ---------- gray_frame : numpy.ndarray - Grayscale ``uint8`` input image. + Grayscale input image. ``uint8`` is expected; other numeric dtypes are + accepted and will be converted to ``uint8`` before processing. clahe : bool, optional Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) for local contrast enhancement. Defaults to ``True``. diff --git a/tests/test_video.py b/tests/test_video.py index 197815d..0558c23 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -128,3 +128,17 @@ def test_detect_markers_invalid_preprocess_preset(): mock_video = MagicMock() with pytest.raises(ValueError, match="Unknown preprocess preset"): detect_markers(mock_video, "36h11", preprocess="nonexistent_preset") + + +def test_preprocess_false_with_preprocess_params(synthetic_gray): + """preprocess_params alone (preprocess=False) should still apply the params.""" + from pyneon.video.marker import preprocess_marker_frame + + # When preprocess=False but preprocess_params is supplied to detect_markers, + # the params dict is used directly (no preset baseline). + # We can verify the plumbing by calling preprocess_marker_frame directly + # with the same kwargs that would be forwarded in that code path. + params = {"clahe": True, "clip_highlights": False, "sharpen": False} + out = preprocess_marker_frame(synthetic_gray, **params) + assert out.shape == synthetic_gray.shape + assert out.dtype == np.uint8 From 95cc1eba0923cb9661b99f4fb72141662835a2b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:16:04 +0000 Subject: [PATCH 04/10] Address second round code review: float input safety, comment clarity, remove duplicate import --- pyneon/video/marker.py | 10 +++++++++- tests/test_video.py | 2 -- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index 0a0d0b5..91a756f 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -90,7 +90,9 @@ def preprocess_marker_frame( ---------- gray_frame : numpy.ndarray Grayscale input image. ``uint8`` is expected; other numeric dtypes are - accepted and will be converted to ``uint8`` before processing. + accepted and will be converted to ``uint8`` before processing. Float + arrays are clipped to ``[0, 255]`` before conversion to prevent + wrap-around truncation for out-of-range values. clahe : bool, optional Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) for local contrast enhancement. Defaults to ``True``. @@ -123,12 +125,18 @@ def preprocess_marker_frame( as ``gray_frame``. """ if gray_frame.dtype != np.uint8: + # For float inputs, clip to [0, 255] before converting to avoid + # wrap-around truncation for out-of-range values. + if np.issubdtype(gray_frame.dtype, np.floating): + gray_frame = np.clip(gray_frame, 0, 255) gray_frame = gray_frame.astype(np.uint8) img = gray_frame.copy() if clip_highlights: high = np.percentile(img, highlight_percentile) + # Guard against a degenerate all-black image where the percentile + # would be 0, which would cause cv2.normalize to divide by zero. high = max(float(high), 1.0) img = np.clip(img, 0, high) img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) diff --git a/tests/test_video.py b/tests/test_video.py index 0558c23..f0d7a7e 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -132,8 +132,6 @@ def test_detect_markers_invalid_preprocess_preset(): def test_preprocess_false_with_preprocess_params(synthetic_gray): """preprocess_params alone (preprocess=False) should still apply the params.""" - from pyneon.video.marker import preprocess_marker_frame - # When preprocess=False but preprocess_params is supplied to detect_markers, # the params dict is used directly (no preset baseline). # We can verify the plumbing by calling preprocess_marker_frame directly From 723a9f6cee7730e4d5c2bd6014113cd537871bfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:07:46 +0000 Subject: [PATCH 05/10] refactor marker preprocessing defaults --- pyneon/utils/docstring_templating.py | 32 ++++++---- pyneon/video/marker.py | 88 +++++++++++----------------- pyneon/video/video.py | 4 +- tests/test_video.py | 70 ++++++++++++++++++---- 4 files changed, 116 insertions(+), 78 deletions(-) diff --git a/pyneon/utils/docstring_templating.py b/pyneon/utils/docstring_templating.py index ff5114f..8805f50 100644 --- a/pyneon/utils/docstring_templating.py +++ b/pyneon/utils/docstring_templating.py @@ -167,7 +167,7 @@ def render_doc( If True, undistorts frames before detection, which can improve detection performance, then redistorts detected points. Returned coordinates remain in the original (distorted) video frame. Defaults to ``False``. -preprocess : bool or str, optional +preprocess : str or None, optional Enable a grayscale preprocessing stage applied after optional undistortion and before marker detection. Preprocessing can improve robustness under challenging conditions such as low contrast, uneven illumination, or @@ -175,22 +175,30 @@ def render_doc( Accepted values: - - ``False`` (default): no preprocessing; current behaviour is preserved. - - ``True``: apply the ``"mild"`` preset (CLAHE + highlight clipping + - light blur + unsharp mask). - - ``"mild"``: balanced general-purpose preset. - - ``"ir"``: stronger highlight clipping for IR-emitter hotspots. - - ``"low_light"``: more aggressive CLAHE for dim scenes. + - ``"mild"`` (default): balanced general-purpose preset. + - ``"strong_highlight_clipping"``: use stronger highlight compression + before detection. + - ``"strong_local_contrast"``: use stronger local contrast enhancement. + - ``None``: disable preprocessing. Individual preprocessing parameters can be further overridden via - ``preprocess_params``. Defaults to ``False``. + ``preprocess_params``. Defaults to ``"mild"``. preprocess_params : dict or None, optional Keyword arguments forwarded to :func:`~pyneon.video.marker.preprocess_marker_frame` that override the - values of the active preset (or set values when ``preprocess=False``). - Allowed keys are: ``clahe``, ``clahe_clip_limit``, - ``clahe_tile_grid_size``, ``clip_highlights``, ``highlight_percentile``, - ``gaussian_blur_sigma``, ``sharpen``, ``sharpen_amount``. + values of the active preset (or define a fully custom configuration when + ``preprocess=None``). Allowed keys are: + + - ``clahe_clip_limit``: CLAHE strength; set to ``None`` to skip CLAHE. + - ``clahe_tile_grid_size``: CLAHE tile size. + - ``highlight_percentile``: percentile used for highlight clipping; set + to ``None`` to skip highlight clipping. + - ``gaussian_blur_sigma``: Gaussian smoothing strength; set to ``None`` + or ``0`` to skip smoothing. + - ``sharpen_amount``: unsharp-mask strength; set to ``None`` to skip + sharpening. + + Use these parameters to tune preprocessing explicitly for a given dataset. Defaults to ``None``. """ diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index 91a756f..e107f93 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -24,37 +24,28 @@ #: Built-in preprocessing presets for :func:`detect_markers`. #: #: Each preset is a dict of keyword arguments forwarded to -#: :func:`preprocess_marker_frame`. Pass the preset name as +#: :func:`preprocess_marker_frame`. Pass the preset name as #: ``preprocess="mild"`` (etc.) to :func:`detect_markers`. PREPROCESS_PRESETS: dict[str, dict] = { "mild": { - "clahe": True, "clahe_clip_limit": 2.0, "clahe_tile_grid_size": (8, 8), - "clip_highlights": True, "highlight_percentile": 99.5, "gaussian_blur_sigma": 0.8, - "sharpen": True, "sharpen_amount": 1.0, }, - "ir": { - "clahe": True, + "strong_highlight_clipping": { "clahe_clip_limit": 2.0, "clahe_tile_grid_size": (8, 8), - "clip_highlights": True, "highlight_percentile": 99.0, "gaussian_blur_sigma": 1.0, - "sharpen": True, "sharpen_amount": 0.6, }, - "low_light": { - "clahe": True, + "strong_local_contrast": { "clahe_clip_limit": 2.5, "clahe_tile_grid_size": (8, 8), - "clip_highlights": False, - "highlight_percentile": 99.5, + "highlight_percentile": None, "gaussian_blur_sigma": 0.6, - "sharpen": True, "sharpen_amount": 1.0, }, } @@ -63,14 +54,11 @@ def preprocess_marker_frame( gray_frame: np.ndarray, *, - clahe: bool = True, - clahe_clip_limit: float = 2.0, - clahe_tile_grid_size: tuple[int, int] = (8, 8), - clip_highlights: bool = True, - highlight_percentile: float = 99.5, - gaussian_blur_sigma: float = 0.8, - sharpen: bool = True, - sharpen_amount: float = 1.0, + clahe_clip_limit: float | None = 2.0, + clahe_tile_grid_size: tuple[int, int] | None = (8, 8), + highlight_percentile: float | None = 99.5, + gaussian_blur_sigma: float | None = 0.8, + sharpen_amount: float | None = 1.0, ) -> np.ndarray: """Preprocess a grayscale frame to improve AprilTag / ArUco detection. @@ -93,30 +81,24 @@ def preprocess_marker_frame( accepted and will be converted to ``uint8`` before processing. Float arrays are clipped to ``[0, 255]`` before conversion to prevent wrap-around truncation for out-of-range values. - clahe : bool, optional - Apply CLAHE (Contrast Limited Adaptive Histogram Equalization) for - local contrast enhancement. Defaults to ``True``. - clahe_clip_limit : float, optional + clahe_clip_limit : float or None, optional Clip limit for CLAHE. Higher values give stronger enhancement but - more noise amplification. Defaults to ``2.0``. - clahe_tile_grid_size : tuple[int, int], optional - Tile grid size for CLAHE. Defaults to ``(8, 8)``. - clip_highlights : bool, optional - Compress very bright highlights (e.g., IR emitter hotspots) by - clipping pixel values above ``highlight_percentile`` and - re-normalising to the full 0–255 range. Defaults to ``True``. - highlight_percentile : float, optional - Percentile used as the upper clip boundary when - ``clip_highlights=True``. Defaults to ``99.5``. - gaussian_blur_sigma : float, optional + more noise amplification. Set to ``None`` to skip the CLAHE step. + Defaults to ``2.0``. + clahe_tile_grid_size : tuple[int, int] or None, optional + Tile grid size for CLAHE. Set to ``None`` together with + ``clahe_clip_limit=None`` to skip the CLAHE step. Defaults to ``(8, 8)``. + highlight_percentile : float or None, optional + Upper percentile used to compress very bright highlights before + re-normalising to the full 0–255 range. Set to ``None`` to skip + highlight clipping. Defaults to ``99.5``. + gaussian_blur_sigma : float or None, optional Standard deviation for mild Gaussian smoothing applied before - sharpening. Set to ``0`` to disable. Defaults to ``0.8``. - sharpen : bool, optional - Apply unsharp masking to recover edge contrast after smoothing. - Defaults to ``True``. - sharpen_amount : float, optional - Strength of the unsharp mask (higher = more sharpening). - Defaults to ``1.0``. + sharpening. Set to ``None`` or ``0`` to skip smoothing. + Defaults to ``0.8``. + sharpen_amount : float or None, optional + Strength of the unsharp mask (higher = more sharpening). Set to + ``None`` to skip sharpening. Defaults to ``1.0``. Returns ------- @@ -133,7 +115,7 @@ def preprocess_marker_frame( img = gray_frame.copy() - if clip_highlights: + if highlight_percentile is not None: high = np.percentile(img, highlight_percentile) # Guard against a degenerate all-black image where the percentile # would be 0, which would cause cv2.normalize to divide by zero. @@ -141,7 +123,7 @@ def preprocess_marker_frame( img = np.clip(img, 0, high) img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) - if clahe: + if clahe_clip_limit is not None and clahe_tile_grid_size is not None: clahe_op = cv2.createCLAHE( clipLimit=clahe_clip_limit, tileGridSize=clahe_tile_grid_size, @@ -149,10 +131,10 @@ def preprocess_marker_frame( img = clahe_op.apply(img) blurred = img - if gaussian_blur_sigma and gaussian_blur_sigma > 0: + if gaussian_blur_sigma is not None and gaussian_blur_sigma > 0: blurred = cv2.GaussianBlur(img, (0, 0), gaussian_blur_sigma) - if sharpen: + if sharpen_amount is not None: img_f = img.astype(np.float32) blurred_f = blurred.astype(np.float32) sharp = img_f + sharpen_amount * (img_f - blurred_f) @@ -229,8 +211,8 @@ def detect_markers( processing_window_unit: Literal["frame", "time", "timestamp"] = "frame", detector_parameters: Optional[cv2.aruco.DetectorParameters] = None, undistort: bool = False, - preprocess: bool | str = False, - preprocess_params: Optional[dict] = None, + preprocess: str | None = "mild", + preprocess_params: dict | None = None, ) -> Stream: """ Detect fiducial markers (AprilTag or ArUco) in a video and report their data for every processed frame. @@ -262,14 +244,14 @@ def detect_markers( raise ValueError("step must be >= 1") # Resolve preprocessing configuration - _pp_kwargs: Optional[dict] = None - if preprocess is not False: - preset_name = "mild" if preprocess is True else preprocess + _pp_kwargs: dict | None = None + if preprocess is not None: + preset_name = preprocess if preset_name not in PREPROCESS_PRESETS: raise ValueError( f"Unknown preprocess preset '{preset_name}'. " f"Available presets: {list(PREPROCESS_PRESETS)}. " - "Pass preprocess=False to disable preprocessing." + "Pass preprocess=None to disable preprocessing." ) _pp_kwargs = dict(PREPROCESS_PRESETS[preset_name]) if preprocess_params: diff --git a/pyneon/video/video.py b/pyneon/video/video.py index d498caa..c7373be 100644 --- a/pyneon/video/video.py +++ b/pyneon/video/video.py @@ -630,8 +630,8 @@ def detect_markers( processing_window_unit: Literal["frame", "time", "timestamp"] = "frame", detector_parameters: Optional[cv2.aruco.DetectorParameters] = None, undistort: bool = False, - preprocess: bool | str = False, - preprocess_params: Optional[dict] = None, + preprocess: str | None = "mild", + preprocess_params: dict | None = None, ) -> Stream: """ Detect fiducial markers (AprilTag or ArUco) in the video frames. diff --git a/tests/test_video.py b/tests/test_video.py index f0d7a7e..88b67a5 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -87,10 +87,11 @@ def test_preprocess_noop_preserves_image(synthetic_gray): """With all stages disabled the output should equal the input.""" out = preprocess_marker_frame( synthetic_gray, - clahe=False, - clip_highlights=False, - gaussian_blur_sigma=0, - sharpen=False, + clahe_clip_limit=None, + clahe_tile_grid_size=None, + highlight_percentile=None, + gaussian_blur_sigma=None, + sharpen_amount=None, ) np.testing.assert_array_equal(out, synthetic_gray) @@ -106,13 +107,10 @@ def test_preprocess_all_presets(synthetic_gray, preset_name): def test_preprocess_custom_params(synthetic_gray): out = preprocess_marker_frame( synthetic_gray, - clahe=True, clahe_clip_limit=3.0, clahe_tile_grid_size=(4, 4), - clip_highlights=True, highlight_percentile=95.0, gaussian_blur_sigma=1.5, - sharpen=True, sharpen_amount=0.5, ) assert out.shape == synthetic_gray.shape @@ -130,13 +128,63 @@ def test_detect_markers_invalid_preprocess_preset(): detect_markers(mock_video, "36h11", preprocess="nonexistent_preset") -def test_preprocess_false_with_preprocess_params(synthetic_gray): - """preprocess_params alone (preprocess=False) should still apply the params.""" - # When preprocess=False but preprocess_params is supplied to detect_markers, +def test_preprocess_params_without_preset(synthetic_gray): + """preprocess_params alone (preprocess=None) should still apply the params.""" + # When preprocess=None but preprocess_params is supplied to detect_markers, # the params dict is used directly (no preset baseline). # We can verify the plumbing by calling preprocess_marker_frame directly # with the same kwargs that would be forwarded in that code path. - params = {"clahe": True, "clip_highlights": False, "sharpen": False} + params = { + "clahe_clip_limit": 2.0, + "clahe_tile_grid_size": (8, 8), + "highlight_percentile": None, + "sharpen_amount": None, + } out = preprocess_marker_frame(synthetic_gray, **params) assert out.shape == synthetic_gray.shape assert out.dtype == np.uint8 + + +def test_detect_markers_defaults_to_mild_preprocessing(monkeypatch): + from pyneon.video import marker + + class FakeDetector: + def detectMarkers(self, gray_frame): + corners = [ + np.array( + [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]], + dtype=np.float32, + ) + ] + ids = np.array([[1]], dtype=np.int32) + return corners, ids, None + + class FakeVideo: + ts = np.array([123], dtype=np.int64) + + def reset(self): + return None + + def read_frame_at(self, frame_index): + return np.zeros((4, 4, 3), dtype=np.uint8) + + captured_kwargs = {} + + def fake_preprocess(gray_frame, **kwargs): + captured_kwargs.update(kwargs) + return gray_frame + + monkeypatch.setattr( + marker, "marker_family_to_dict", lambda family: ("april", object()) + ) + monkeypatch.setattr(marker, "resolve_processing_window", lambda *args: (0, 0)) + monkeypatch.setattr(marker, "preprocess_marker_frame", fake_preprocess) + monkeypatch.setattr(cv2.aruco, "ArucoDetector", lambda *args: FakeDetector()) + + marker.detect_markers( + FakeVideo(), + "36h11", + detector_parameters=object(), + ) + + assert captured_kwargs == PREPROCESS_PRESETS["mild"] From 6414485ead3f183a8380230d76be5870bafd4149 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:09:24 +0000 Subject: [PATCH 06/10] chore: address final marker review notes --- pyneon/video/marker.py | 52 ++++++++++++++++++++++++------------------ tests/test_video.py | 4 +++- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index e107f93..faf50be 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -21,32 +21,40 @@ from .video import Video +DEFAULT_CLAHE_CLIP_LIMIT = 2.0 +DEFAULT_CLAHE_TILE_GRID_SIZE = (8, 8) +DEFAULT_HIGHLIGHT_PERCENTILE = 99.5 +DEFAULT_GAUSSIAN_BLUR_SIGMA = 0.8 +DEFAULT_SHARPEN_AMOUNT = 1.0 + +DEFAULT_PREPROCESS_PARAMS = { + "clahe_clip_limit": DEFAULT_CLAHE_CLIP_LIMIT, + "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, + "highlight_percentile": DEFAULT_HIGHLIGHT_PERCENTILE, + "gaussian_blur_sigma": DEFAULT_GAUSSIAN_BLUR_SIGMA, + "sharpen_amount": DEFAULT_SHARPEN_AMOUNT, +} + #: Built-in preprocessing presets for :func:`detect_markers`. #: #: Each preset is a dict of keyword arguments forwarded to #: :func:`preprocess_marker_frame`. Pass the preset name as #: ``preprocess="mild"`` (etc.) to :func:`detect_markers`. PREPROCESS_PRESETS: dict[str, dict] = { - "mild": { - "clahe_clip_limit": 2.0, - "clahe_tile_grid_size": (8, 8), - "highlight_percentile": 99.5, - "gaussian_blur_sigma": 0.8, - "sharpen_amount": 1.0, - }, + "mild": dict(DEFAULT_PREPROCESS_PARAMS), "strong_highlight_clipping": { - "clahe_clip_limit": 2.0, - "clahe_tile_grid_size": (8, 8), + "clahe_clip_limit": DEFAULT_CLAHE_CLIP_LIMIT, + "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, "highlight_percentile": 99.0, "gaussian_blur_sigma": 1.0, "sharpen_amount": 0.6, }, "strong_local_contrast": { "clahe_clip_limit": 2.5, - "clahe_tile_grid_size": (8, 8), + "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, "highlight_percentile": None, "gaussian_blur_sigma": 0.6, - "sharpen_amount": 1.0, + "sharpen_amount": DEFAULT_SHARPEN_AMOUNT, }, } @@ -54,11 +62,11 @@ def preprocess_marker_frame( gray_frame: np.ndarray, *, - clahe_clip_limit: float | None = 2.0, - clahe_tile_grid_size: tuple[int, int] | None = (8, 8), - highlight_percentile: float | None = 99.5, - gaussian_blur_sigma: float | None = 0.8, - sharpen_amount: float | None = 1.0, + clahe_clip_limit: float | None = DEFAULT_CLAHE_CLIP_LIMIT, + clahe_tile_grid_size: tuple[int, int] | None = DEFAULT_CLAHE_TILE_GRID_SIZE, + highlight_percentile: float | None = DEFAULT_HIGHLIGHT_PERCENTILE, + gaussian_blur_sigma: float | None = DEFAULT_GAUSSIAN_BLUR_SIGMA, + sharpen_amount: float | None = DEFAULT_SHARPEN_AMOUNT, ) -> np.ndarray: """Preprocess a grayscale frame to improve AprilTag / ArUco detection. @@ -244,7 +252,7 @@ def detect_markers( raise ValueError("step must be >= 1") # Resolve preprocessing configuration - _pp_kwargs: dict | None = None + preprocess_kwargs: dict | None = None if preprocess is not None: preset_name = preprocess if preset_name not in PREPROCESS_PRESETS: @@ -253,11 +261,11 @@ def detect_markers( f"Available presets: {list(PREPROCESS_PRESETS)}. " "Pass preprocess=None to disable preprocessing." ) - _pp_kwargs = dict(PREPROCESS_PRESETS[preset_name]) + preprocess_kwargs = dict(PREPROCESS_PRESETS[preset_name]) if preprocess_params: - _pp_kwargs.update(preprocess_params) + preprocess_kwargs.update(preprocess_params) elif preprocess_params: - _pp_kwargs = dict(preprocess_params) + preprocess_kwargs = dict(preprocess_params) start_frame_idx, end_frame_idx = resolve_processing_window( video, @@ -318,8 +326,8 @@ def _process_frame(frame_idx: int, gray_frame: np.ndarray) -> list[dict]: gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if undistort: gray_frame = video.undistort_frame(gray_frame) - if _pp_kwargs is not None: - gray_frame = preprocess_marker_frame(gray_frame, **_pp_kwargs) + if preprocess_kwargs is not None: + gray_frame = preprocess_marker_frame(gray_frame, **preprocess_kwargs) records = _process_frame(frame_index, gray_frame) detected_markers.extend(records) diff --git a/tests/test_video.py b/tests/test_video.py index 88b67a5..1f6eefd 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -145,7 +145,9 @@ def test_preprocess_params_without_preset(synthetic_gray): assert out.dtype == np.uint8 -def test_detect_markers_defaults_to_mild_preprocessing(monkeypatch): +def test_detect_markers_uses_mild_preprocessing_when_preset_not_specified( + monkeypatch, +): from pyneon.video import marker class FakeDetector: From 39e2edf76f8da8af6781bd1321e3d9b8b09304a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:10:15 +0000 Subject: [PATCH 07/10] chore: finalize preprocessing validation fixes --- pyneon/utils/docstring_templating.py | 3 ++- pyneon/video/marker.py | 4 ++-- tests/test_video.py | 8 +++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pyneon/utils/docstring_templating.py b/pyneon/utils/docstring_templating.py index 8805f50..bff7e1c 100644 --- a/pyneon/utils/docstring_templating.py +++ b/pyneon/utils/docstring_templating.py @@ -175,7 +175,8 @@ def render_doc( Accepted values: - - ``"mild"`` (default): balanced general-purpose preset. + - ``"mild"`` (default): moderate CLAHE, highlight clipping, light blur, + and sharpening for general indoor recordings. - ``"strong_highlight_clipping"``: use stronger highlight compression before detection. - ``"strong_local_contrast"``: use stronger local contrast enhancement. diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index faf50be..00b384a 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -80,7 +80,7 @@ def preprocess_marker_frame( 1. Highlight clipping / compression (optional) 2. Local contrast enhancement via CLAHE (optional) 3. Mild Gaussian smoothing (optional, ``gaussian_blur_sigma > 0``) - 4. Unsharp-mask sharpening (optional) + 4. Unsharp mask sharpening (optional) Parameters ---------- @@ -98,7 +98,7 @@ def preprocess_marker_frame( ``clahe_clip_limit=None`` to skip the CLAHE step. Defaults to ``(8, 8)``. highlight_percentile : float or None, optional Upper percentile used to compress very bright highlights before - re-normalising to the full 0–255 range. Set to ``None`` to skip + renormalizing to the full 0–255 range. Set to ``None`` to skip highlight clipping. Defaults to ``99.5``. gaussian_blur_sigma : float or None, optional Standard deviation for mild Gaussian smoothing applied before diff --git a/tests/test_video.py b/tests/test_video.py index 1f6eefd..9cae81e 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -124,7 +124,13 @@ def test_detect_markers_invalid_preprocess_preset(): from pyneon.video.marker import detect_markers mock_video = MagicMock() - with pytest.raises(ValueError, match="Unknown preprocess preset"): + with pytest.raises( + ValueError, + match=( + "Unknown preprocess preset.*Available presets:.*" + "Pass preprocess=None to disable preprocessing." + ), + ): detect_markers(mock_video, "36h11", preprocess="nonexistent_preset") From 0ffc616e8da3c5437b7f9e70709cc35d6fdf9c69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:11:01 +0000 Subject: [PATCH 08/10] chore: clean up final preprocessing review items --- pyneon/video/marker.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index 00b384a..1af734f 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -27,6 +27,7 @@ DEFAULT_GAUSSIAN_BLUR_SIGMA = 0.8 DEFAULT_SHARPEN_AMOUNT = 1.0 +# Keep this in sync with preprocess_marker_frame defaults. DEFAULT_PREPROCESS_PARAMS = { "clahe_clip_limit": DEFAULT_CLAHE_CLIP_LIMIT, "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, @@ -62,11 +63,11 @@ def preprocess_marker_frame( gray_frame: np.ndarray, *, - clahe_clip_limit: float | None = DEFAULT_CLAHE_CLIP_LIMIT, - clahe_tile_grid_size: tuple[int, int] | None = DEFAULT_CLAHE_TILE_GRID_SIZE, - highlight_percentile: float | None = DEFAULT_HIGHLIGHT_PERCENTILE, - gaussian_blur_sigma: float | None = DEFAULT_GAUSSIAN_BLUR_SIGMA, - sharpen_amount: float | None = DEFAULT_SHARPEN_AMOUNT, + clahe_clip_limit: float | None = 2.0, + clahe_tile_grid_size: tuple[int, int] | None = (8, 8), + highlight_percentile: float | None = 99.5, + gaussian_blur_sigma: float | None = 0.8, + sharpen_amount: float | None = 1.0, ) -> np.ndarray: """Preprocess a grayscale frame to improve AprilTag / ArUco detection. @@ -91,11 +92,15 @@ def preprocess_marker_frame( wrap-around truncation for out-of-range values. clahe_clip_limit : float or None, optional Clip limit for CLAHE. Higher values give stronger enhancement but - more noise amplification. Set to ``None`` to skip the CLAHE step. - Defaults to ``2.0``. + more noise amplification. CLAHE runs only when both + ``clahe_clip_limit`` and ``clahe_tile_grid_size`` are not ``None``. + Set either parameter to ``None`` to skip the CLAHE step. Defaults to + ``2.0``. clahe_tile_grid_size : tuple[int, int] or None, optional - Tile grid size for CLAHE. Set to ``None`` together with - ``clahe_clip_limit=None`` to skip the CLAHE step. Defaults to ``(8, 8)``. + Tile grid size for CLAHE. CLAHE runs only when both + ``clahe_clip_limit`` and ``clahe_tile_grid_size`` are not ``None``. + Set either parameter to ``None`` to skip the CLAHE step. Defaults to + ``(8, 8)``. highlight_percentile : float or None, optional Upper percentile used to compress very bright highlights before renormalizing to the full 0–255 range. Set to ``None`` to skip From 17ad6b4de1fcb708d913c243de9277cb2dbb3632 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:11:53 +0000 Subject: [PATCH 09/10] chore: polish preprocessing validation feedback --- pyneon/video/marker.py | 70 +++++++++++++++++++----------------------- tests/test_video.py | 4 +-- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index 1af734f..d1c81de 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -1,3 +1,4 @@ +import inspect import re from typing import TYPE_CHECKING, Literal, Optional, Tuple @@ -21,45 +22,6 @@ from .video import Video -DEFAULT_CLAHE_CLIP_LIMIT = 2.0 -DEFAULT_CLAHE_TILE_GRID_SIZE = (8, 8) -DEFAULT_HIGHLIGHT_PERCENTILE = 99.5 -DEFAULT_GAUSSIAN_BLUR_SIGMA = 0.8 -DEFAULT_SHARPEN_AMOUNT = 1.0 - -# Keep this in sync with preprocess_marker_frame defaults. -DEFAULT_PREPROCESS_PARAMS = { - "clahe_clip_limit": DEFAULT_CLAHE_CLIP_LIMIT, - "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, - "highlight_percentile": DEFAULT_HIGHLIGHT_PERCENTILE, - "gaussian_blur_sigma": DEFAULT_GAUSSIAN_BLUR_SIGMA, - "sharpen_amount": DEFAULT_SHARPEN_AMOUNT, -} - -#: Built-in preprocessing presets for :func:`detect_markers`. -#: -#: Each preset is a dict of keyword arguments forwarded to -#: :func:`preprocess_marker_frame`. Pass the preset name as -#: ``preprocess="mild"`` (etc.) to :func:`detect_markers`. -PREPROCESS_PRESETS: dict[str, dict] = { - "mild": dict(DEFAULT_PREPROCESS_PARAMS), - "strong_highlight_clipping": { - "clahe_clip_limit": DEFAULT_CLAHE_CLIP_LIMIT, - "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, - "highlight_percentile": 99.0, - "gaussian_blur_sigma": 1.0, - "sharpen_amount": 0.6, - }, - "strong_local_contrast": { - "clahe_clip_limit": 2.5, - "clahe_tile_grid_size": DEFAULT_CLAHE_TILE_GRID_SIZE, - "highlight_percentile": None, - "gaussian_blur_sigma": 0.6, - "sharpen_amount": DEFAULT_SHARPEN_AMOUNT, - }, -} - - def preprocess_marker_frame( gray_frame: np.ndarray, *, @@ -158,6 +120,36 @@ def preprocess_marker_frame( return img +DEFAULT_PREPROCESS_PARAMS = { + name: parameter.default + for name, parameter in inspect.signature(preprocess_marker_frame).parameters.items() + if name != "gray_frame" +} + +#: Built-in preprocessing presets for :func:`detect_markers`. +#: +#: Each preset is a dict of keyword arguments forwarded to +#: :func:`preprocess_marker_frame`. Pass the preset name as +#: ``preprocess="mild"`` (etc.) to :func:`detect_markers`. +PREPROCESS_PRESETS: dict[str, dict] = { + "mild": dict(DEFAULT_PREPROCESS_PARAMS), + "strong_highlight_clipping": { + "clahe_clip_limit": 2.0, + "clahe_tile_grid_size": (8, 8), + "highlight_percentile": 99.0, + "gaussian_blur_sigma": 1.0, + "sharpen_amount": 0.6, + }, + "strong_local_contrast": { + "clahe_clip_limit": 2.5, + "clahe_tile_grid_size": (8, 8), + "highlight_percentile": None, + "gaussian_blur_sigma": 0.6, + "sharpen_amount": 1.0, + }, +} + + def marker_family_to_dict(marker_family: str) -> Tuple[str, cv2.aruco.Dictionary]: # AprilTags if marker_family in APRILTAG_FAMILIES: diff --git a/tests/test_video.py b/tests/test_video.py index 9cae81e..7d9d1df 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -151,9 +151,7 @@ def test_preprocess_params_without_preset(synthetic_gray): assert out.dtype == np.uint8 -def test_detect_markers_uses_mild_preprocessing_when_preset_not_specified( - monkeypatch, -): +def test_detect_markers_defaults_to_mild_preset(monkeypatch): from pyneon.video import marker class FakeDetector: From 30a3a91e44727a1080dc5e8102e346cce7343431 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:12:41 +0000 Subject: [PATCH 10/10] Finalize marker preprocessing API simplification --- pyneon/video/marker.py | 14 ++++++++------ tests/test_video.py | 4 +++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pyneon/video/marker.py b/pyneon/video/marker.py index d1c81de..fce44a4 100644 --- a/pyneon/video/marker.py +++ b/pyneon/video/marker.py @@ -120,6 +120,8 @@ def preprocess_marker_frame( return img +# Capture preprocess_marker_frame's literal defaults so the "mild" preset +# stays aligned with the public API without duplicating those values manually. DEFAULT_PREPROCESS_PARAMS = { name: parameter.default for name, parameter in inspect.signature(preprocess_marker_frame).parameters.items() @@ -249,7 +251,7 @@ def detect_markers( raise ValueError("step must be >= 1") # Resolve preprocessing configuration - preprocess_kwargs: dict | None = None + preprocessing_config: dict | None = None if preprocess is not None: preset_name = preprocess if preset_name not in PREPROCESS_PRESETS: @@ -258,11 +260,11 @@ def detect_markers( f"Available presets: {list(PREPROCESS_PRESETS)}. " "Pass preprocess=None to disable preprocessing." ) - preprocess_kwargs = dict(PREPROCESS_PRESETS[preset_name]) + preprocessing_config = dict(PREPROCESS_PRESETS[preset_name]) if preprocess_params: - preprocess_kwargs.update(preprocess_params) + preprocessing_config.update(preprocess_params) elif preprocess_params: - preprocess_kwargs = dict(preprocess_params) + preprocessing_config = dict(preprocess_params) start_frame_idx, end_frame_idx = resolve_processing_window( video, @@ -323,8 +325,8 @@ def _process_frame(frame_idx: int, gray_frame: np.ndarray) -> list[dict]: gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if undistort: gray_frame = video.undistort_frame(gray_frame) - if preprocess_kwargs is not None: - gray_frame = preprocess_marker_frame(gray_frame, **preprocess_kwargs) + if preprocessing_config is not None: + gray_frame = preprocess_marker_frame(gray_frame, **preprocessing_config) records = _process_frame(frame_index, gray_frame) detected_markers.extend(records) diff --git a/tests/test_video.py b/tests/test_video.py index 7d9d1df..6952893 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -155,7 +155,7 @@ def test_detect_markers_defaults_to_mild_preset(monkeypatch): from pyneon.video import marker class FakeDetector: - def detectMarkers(self, gray_frame): + def detect_markers(self, gray_frame): corners = [ np.array( [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]], @@ -165,6 +165,8 @@ def detectMarkers(self, gray_frame): ids = np.array([[1]], dtype=np.int32) return corners, ids, None + detectMarkers = detect_markers + class FakeVideo: ts = np.array([123], dtype=np.int64)