# -*- coding: utf-8 -*-
"""
Tests for ImageJ/Fiji ported components.

Verifies algorithmic correctness, edge cases, and parameter validation
for all 22 ported ImageJ/Fiji image processing components.

Author
------
Duane Smalley, PhD
duane.d.smalley@gmail.com

License
-------
MIT License
Copyright (c) 2024 geoint.org
See LICENSE file for full text.

Created
-------
2026-02-06

Modified
--------
2026-02-10
"""

import numpy as np
import pytest


# ============================================================================
# RollingBallBackground Tests
# ============================================================================

class TestRollingBallBackground:
    """Tests for Rolling Ball Background Subtraction."""

    def test_flat_image_low_variation(self):
        """A flat image should produce uniform output (constant everywhere)."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=10)
        flat = np.full((50, 50), 100.0)
        result = rb.apply(flat)
        assert result.shape == (50, 50)
        # Output should be spatially uniform (all same value)
        assert result.std() < 0.01

    def test_removes_gradient(self):
        """A linear gradient should be mostly removed."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=20)
        rows, cols = 100, 100
        gradient = np.tile(np.linspace(0, 200, cols), (rows, 1))
        result = rb.apply(gradient)
        # Result should have much less variation than input
        assert result.std() < gradient.std() * 0.5

    def test_preserves_small_features(self):
        """Small bright features on dark background should be preserved."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=30)
        image = np.zeros((100, 100))
        # Add a small bright spot
        image[45:55, 45:55] = 200.0
        result = rb.apply(image)
        # The bright spot should still be present
        assert result[50, 50] > 50.0

    def test_create_background(self):
        """create_background=True should return the background estimate."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=20, create_background=True)
        rows, cols = 80, 80
        gradient = np.tile(np.linspace(50, 200, cols), (rows, 1))
        bg = rb.apply(gradient)
        assert bg.shape == (rows, cols)
        # Background should track the gradient
        assert bg.mean() > 50.0

    def test_light_background(self):
        """light_background inverts the rolling direction."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=20, light_background=True)
        image = np.full((50, 50), 200.0)
        image[20:30, 20:30] = 50.0  # Dark spot on light background
        result = rb.apply(image)
        assert result.shape == (50, 50)

    def test_rejects_non_2d(self):
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground()
        with pytest.raises(ValueError, match="2D"):
            rb.apply(np.zeros((3, 10, 10)))

    def test_output_dtype(self):
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=5)
        result = rb.apply(np.ones((20, 20), dtype=np.uint8) * 128)
        assert result.dtype == np.float64

    def test_nonnegative_output(self):
        """Result should be non-negative (clipped at zero)."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=10)
        image = np.random.RandomState(42).rand(50, 50) * 255
        result = rb.apply(image)
        assert np.all(result >= 0.0)

    def test_small_radius(self):
        """Radius=1 should still work without error."""
        from grdl_imagej import RollingBallBackground
        rb = RollingBallBackground(radius=1)
        result = rb.apply(np.random.RandomState(0).rand(30, 30) * 100)
        assert result.shape == (30, 30)


# ============================================================================
# CLAHE Tests
# ============================================================================

class TestCLAHE:
    """Tests for Contrast Limited Adaptive Histogram Equalization."""

    def test_output_range(self):
        """Output should be in [0, 1]."""
        from grdl_imagej import CLAHE
        clahe = CLAHE(block_size=15, n_bins=64, max_slope=3.0)
        image = np.random.RandomState(42).rand(50, 50) * 255
        result = clahe.apply(image)
        assert result.min() >= -0.01  # Allow tiny numerical error
        assert result.max() <= 1.01

    def test_flat_image_stays_flat(self):
        """A constant image should map to all zeros (no variation)."""
        from grdl_imagej import CLAHE
        clahe = CLAHE(block_size=15, n_bins=64)
        flat = np.full((40, 40), 100.0)
        result = clahe.apply(flat)
        assert np.allclose(result, 0.0, atol=0.01)

    def test_enhances_contrast(self):
        """CLAHE should increase contrast in a low-contrast image."""
        from grdl_imagej import CLAHE
        clahe = CLAHE(block_size=20, n_bins=64, max_slope=3.0)
        # Low contrast image: values between 100 and 110
        rng = np.random.RandomState(42)
        image = rng.rand(60, 60) * 10 + 100
        result = clahe.apply(image)
        # Normalized result should span more of [0, 1] than input
        input_range = (image.max() - image.min()) / 255
        output_range = result.max() - result.min()
        assert output_range > input_range

    def test_output_shape(self):
        from grdl_imagej import CLAHE
        clahe = CLAHE(block_size=15)
        image = np.random.RandomState(0).rand(70, 90)
        result = clahe.apply(image)
        assert result.shape == (70, 90)

    def test_rejects_non_2d(self):
        from grdl_imagej import CLAHE
        clahe = CLAHE()
        with pytest.raises(ValueError, match="2D"):
            clahe.apply(np.zeros((3, 10, 10)))

    def test_invalid_params(self):
        from grdl_imagej import CLAHE
        with pytest.raises(ValueError):
            CLAHE(block_size=1)
        with pytest.raises(ValueError):
            CLAHE(n_bins=1)
        with pytest.raises(ValueError):
            CLAHE(max_slope=0.5)

    def test_max_slope_1_is_standard_ahe(self):
        """max_slope=1.0 should produce standard AHE (no clipping)."""
        from grdl_imagej import CLAHE
        clahe = CLAHE(block_size=20, max_slope=1.0)
        image = np.random.RandomState(7).rand(40, 40) * 200
        result = clahe.apply(image)
        assert result.shape == (40, 40)


# ============================================================================
# AutoLocalThreshold Tests
# ============================================================================

class TestAutoLocalThreshold:
    """Tests for Auto Local Threshold."""

    def test_all_methods_run(self):
        """Every method should execute without error."""
        from grdl_imagej import AutoLocalThreshold
        rng = np.random.RandomState(42)
        image = rng.rand(50, 50) * 255

        methods = [
            'bernsen', 'mean', 'median', 'midgrey',
            'niblack', 'sauvola', 'phansalkar', 'contrast',
        ]
        for method in methods:
            alt = AutoLocalThreshold(method=method, radius=7)
            result = alt.apply(image)
            assert result.shape == (50, 50), f"Failed for {method}"
            # Output should be binary
            unique = np.unique(result)
            assert all(v in [0.0, 1.0] for v in unique), (
                f"Non-binary output for {method}: {unique}"
            )

    def test_binary_output(self):
        """Output must only contain 0.0 and 1.0."""
        from grdl_imagej import AutoLocalThreshold
        alt = AutoLocalThreshold(method='sauvola', radius=10)
        image = np.random.RandomState(0).rand(40, 40) * 200
        result = alt.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_niblack_dark_image_mostly_background(self):
        """Very dark image with negative k should be mostly background."""
        from grdl_imagej import AutoLocalThreshold
        alt = AutoLocalThreshold(method='niblack', radius=10, k=-0.2)
        dark = np.full((40, 40), 10.0)
        result = alt.apply(dark)
        # Uniform image → all same threshold → all same class
        assert result.sum() == 0.0 or result.sum() == result.size

    def test_sauvola_params(self):
        """Sauvola with different k and r values should produce different results."""
        from grdl_imagej import AutoLocalThreshold
        rng = np.random.RandomState(5)
        image = rng.rand(50, 50) * 200 + 20

        alt1 = AutoLocalThreshold(method='sauvola', radius=10, k=0.2, r=128)
        alt2 = AutoLocalThreshold(method='sauvola', radius=10, k=0.8, r=128)
        r1 = alt1.apply(image)
        r2 = alt2.apply(image)
        # Different k should give different results
        assert not np.array_equal(r1, r2)

    def test_rejects_non_2d(self):
        from grdl_imagej import AutoLocalThreshold
        alt = AutoLocalThreshold()
        with pytest.raises(ValueError, match="2D"):
            alt.apply(np.zeros((3, 10, 10)))

    def test_invalid_method(self):
        from grdl_imagej import AutoLocalThreshold
        with pytest.raises(ValueError, match="not in allowed choices"):
            AutoLocalThreshold(method='nonexistent')

    def test_invalid_radius(self):
        from grdl_imagej import AutoLocalThreshold
        with pytest.raises(ValueError, match="radius"):
            AutoLocalThreshold(radius=0)

    def test_bernsen_low_contrast_region(self):
        """Bernsen should classify low-contrast regions as background."""
        from grdl_imagej import AutoLocalThreshold
        alt = AutoLocalThreshold(
            method='bernsen', radius=10, contrast_threshold=15
        )
        # Uniform region with contrast < threshold
        image = np.full((40, 40), 100.0)
        image += np.random.RandomState(0).rand(40, 40) * 5  # contrast ~5
        result = alt.apply(image)
        # Low contrast → mostly background
        assert result.mean() < 0.3

    def test_phansalkar_runs(self):
        """Phansalkar with default p, q parameters."""
        from grdl_imagej import AutoLocalThreshold
        alt = AutoLocalThreshold(
            method='phansalkar', radius=10, k=0.25, p=2.0, q=10.0, r=128.0
        )
        image = np.random.RandomState(3).rand(40, 40) * 200
        result = alt.apply(image)
        assert result.shape == (40, 40)


# ============================================================================
# UnsharpMask Tests
# ============================================================================

class TestUnsharpMask:
    """Tests for Unsharp Mask."""

    def test_weight_zero_is_identity(self):
        """weight=0 should return the original image."""
        from grdl_imagej import UnsharpMask
        usm = UnsharpMask(sigma=2.0, weight=0.0)
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = usm.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_increases_edge_contrast(self):
        """Sharpening should increase the gradient at edges."""
        from grdl_imagej import UnsharpMask
        usm = UnsharpMask(sigma=2.0, weight=0.6)
        # Step edge
        image = np.zeros((50, 100))
        image[:, 50:] = 200.0
        result = usm.apply(image)
        # Gradient at edge should be steeper in sharpened image
        orig_grad = np.abs(np.diff(image[25, :]))
        sharp_grad = np.abs(np.diff(result[25, :]))
        assert sharp_grad.max() >= orig_grad.max()

    def test_flat_image_unchanged(self):
        """A flat image has no edges to sharpen."""
        from grdl_imagej import UnsharpMask
        usm = UnsharpMask(sigma=2.0, weight=0.6)
        flat = np.full((40, 40), 128.0)
        result = usm.apply(flat)
        np.testing.assert_allclose(result, flat, atol=0.01)

    def test_output_shape_and_dtype(self):
        from grdl_imagej import UnsharpMask
        usm = UnsharpMask()
        result = usm.apply(np.ones((30, 40), dtype=np.uint8) * 100)
        assert result.shape == (30, 40)
        assert result.dtype == np.float64

    def test_rejects_non_2d(self):
        from grdl_imagej import UnsharpMask
        usm = UnsharpMask()
        with pytest.raises(ValueError, match="2D"):
            usm.apply(np.zeros((3, 10, 10)))

    def test_invalid_sigma(self):
        from grdl_imagej import UnsharpMask
        with pytest.raises(ValueError, match="sigma"):
            UnsharpMask(sigma=0)

    def test_invalid_weight(self):
        from grdl_imagej import UnsharpMask
        with pytest.raises(ValueError, match="weight"):
            UnsharpMask(weight=-1)

    def test_higher_weight_stronger_sharpening(self):
        """Higher weight should produce stronger sharpening effect."""
        from grdl_imagej import UnsharpMask
        image = np.zeros((50, 100))
        image[:, 50:] = 200.0

        usm_weak = UnsharpMask(sigma=2.0, weight=0.3)
        usm_strong = UnsharpMask(sigma=2.0, weight=1.5)
        r_weak = usm_weak.apply(image)
        r_strong = usm_strong.apply(image)

        # Stronger sharpening → larger overshoot at edges
        assert np.abs(np.diff(r_strong[25, :])).max() > np.abs(np.diff(r_weak[25, :])).max()


# ============================================================================
# FFTBandpassFilter Tests
# ============================================================================

class TestFFTBandpassFilter:
    """Tests for FFT Bandpass Filter."""

    def test_removes_dc_gradient(self):
        """Large-structure filter should remove smooth gradients."""
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(filter_large=20, filter_small=0, autoscale=False)
        rows, cols = 64, 64
        gradient = np.tile(np.linspace(0, 200, cols), (rows, 1))
        result = bp.apply(gradient)
        # Gradient (large structure) should be removed
        assert result.std() < gradient.std() * 0.5

    def test_preserves_mid_frequency(self):
        """Mid-frequency sinusoid should pass through bandpass filter."""
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(filter_large=100, filter_small=2)
        rows, cols = 128, 128
        x = np.arange(cols) / cols
        # Period ~16 pixels (mid-frequency)
        sine = np.sin(2 * np.pi * 8 * x)
        image = np.tile(sine, (rows, 1)) * 100
        result = bp.apply(image)
        # Mid-frequency should survive
        assert result.std() > 10.0

    def test_output_shape(self):
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter()
        image = np.random.RandomState(0).rand(60, 80)
        result = bp.apply(image)
        assert result.shape == (60, 80)

    def test_autoscale_matches_stats(self):
        """With autoscale=True, output should have similar stats to input."""
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(filter_large=30, filter_small=3, autoscale=True)
        image = np.random.RandomState(42).rand(64, 64) * 200 + 50
        result = bp.apply(image)
        # Mean should be approximately preserved
        assert abs(result.mean() - image.mean()) < image.std() * 0.5

    def test_no_autoscale(self):
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(filter_large=20, filter_small=3, autoscale=False)
        image = np.random.RandomState(0).rand(64, 64) * 100
        result = bp.apply(image)
        assert result.shape == (64, 64)

    def test_stripe_suppression_horizontal(self):
        """Horizontal stripe removal."""
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(
            filter_large=0, filter_small=0,
            suppress_stripes='horizontal', stripe_tolerance=5.0,
            autoscale=False,
        )
        rows, cols = 64, 64
        image = np.zeros((rows, cols))
        # Add horizontal stripes
        for r in range(0, rows, 4):
            image[r, :] = 100.0
        result = bp.apply(image)
        # Measure stripe strength: variance of row means
        orig_stripe = np.var(image.mean(axis=1))
        result_stripe = np.var(result.mean(axis=1))
        assert result_stripe < orig_stripe

    def test_stripe_suppression_vertical(self):
        """Vertical stripe removal."""
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter(
            filter_large=0, filter_small=0,
            suppress_stripes='vertical', stripe_tolerance=5.0,
            autoscale=False,
        )
        rows, cols = 64, 64
        image = np.zeros((rows, cols))
        for c in range(0, cols, 4):
            image[:, c] = 100.0
        result = bp.apply(image)
        # Measure stripe strength: variance of column means
        orig_stripe = np.var(image.mean(axis=0))
        result_stripe = np.var(result.mean(axis=0))
        assert result_stripe < orig_stripe

    def test_rejects_non_2d(self):
        from grdl_imagej import FFTBandpassFilter
        bp = FFTBandpassFilter()
        with pytest.raises(ValueError, match="2D"):
            bp.apply(np.zeros((3, 10, 10)))

    def test_invalid_params(self):
        from grdl_imagej import FFTBandpassFilter
        with pytest.raises(ValueError):
            FFTBandpassFilter(filter_large=-1)
        with pytest.raises(ValueError):
            FFTBandpassFilter(filter_small=-1)
        with pytest.raises(ValueError):
            FFTBandpassFilter(suppress_stripes='diagonal')


# ============================================================================
# ZProjection Tests
# ============================================================================

class TestZProjection:
    """Tests for Z-Projection."""

    def test_average_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='average')
        stack = np.array([[[1, 2], [3, 4]],
                          [[5, 6], [7, 8]],
                          [[3, 3], [3, 3]]], dtype=np.float64)
        result = zp.apply(stack)
        expected = np.array([[3, 11 / 3], [13 / 3, 5]])
        np.testing.assert_allclose(result, expected, atol=1e-10)

    def test_max_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='max')
        stack = np.array([[[1, 5], [3, 2]],
                          [[4, 2], [6, 8]]], dtype=np.float64)
        result = zp.apply(stack)
        expected = np.array([[4, 5], [6, 8]])
        np.testing.assert_array_equal(result, expected)

    def test_min_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='min')
        stack = np.array([[[1, 5], [3, 2]],
                          [[4, 2], [6, 8]]], dtype=np.float64)
        result = zp.apply(stack)
        expected = np.array([[1, 2], [3, 2]])
        np.testing.assert_array_equal(result, expected)

    def test_sum_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='sum')
        stack = np.ones((5, 10, 10), dtype=np.float64) * 3.0
        result = zp.apply(stack)
        np.testing.assert_allclose(result, 15.0)

    def test_std_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='std')
        stack = np.array([[[0], [0]],
                          [[10], [10]]], dtype=np.float64)
        result = zp.apply(stack)
        # std of [0, 10] = 5.0
        np.testing.assert_allclose(result, 5.0, atol=1e-10)

    def test_median_projection(self):
        from grdl_imagej import ZProjection
        zp = ZProjection(method='median')
        stack = np.array([[[1], [3]],
                          [[5], [1]],
                          [[3], [5]]], dtype=np.float64)
        result = zp.apply(stack)
        expected = np.array([[3], [3]])
        np.testing.assert_array_equal(result, expected)

    def test_slice_range(self):
        """start_slice and stop_slice should select subset of stack."""
        from grdl_imagej import ZProjection
        zp = ZProjection(method='sum', start_slice=1, stop_slice=3)
        stack = np.arange(40, dtype=np.float64).reshape(4, 2, 5)
        result = zp.apply(stack)
        expected = stack[1:3].sum(axis=0)
        np.testing.assert_array_equal(result, expected)

    def test_output_shape_2d(self):
        from grdl_imagej import ZProjection
        zp = ZProjection()
        stack = np.random.RandomState(0).rand(10, 50, 60)
        result = zp.apply(stack)
        assert result.shape == (50, 60)
        assert result.ndim == 2

    def test_rejects_non_3d(self):
        from grdl_imagej import ZProjection
        zp = ZProjection()
        with pytest.raises(ValueError, match="3D"):
            zp.apply(np.zeros((10, 10)))

    def test_invalid_method(self):
        from grdl_imagej import ZProjection
        with pytest.raises(ValueError, match="Unknown method"):
            ZProjection(method='mode')

    def test_single_slice_stack(self):
        """Stack with one slice should return that slice."""
        from grdl_imagej import ZProjection
        zp = ZProjection(method='max')
        stack = np.random.RandomState(0).rand(1, 20, 30)
        result = zp.apply(stack)
        np.testing.assert_array_equal(result, stack[0])

    def test_all_methods_consistent_on_uniform(self):
        """All methods on a uniform stack should return the same value."""
        from grdl_imagej import ZProjection
        stack = np.full((5, 10, 10), 42.0)
        for method in ['average', 'max', 'min', 'median']:
            zp = ZProjection(method=method)
            result = zp.apply(stack)
            np.testing.assert_allclose(result, 42.0, atol=1e-10,
                                       err_msg=f"Failed for {method}")


# ============================================================================
# RankFilters Tests
# ============================================================================

class TestRankFilters:
    """Tests for Rank Filters."""

    def test_median_removes_salt_pepper(self):
        """Median filter should remove impulse noise."""
        from grdl_imagej import RankFilters
        rng = np.random.RandomState(42)
        image = np.full((50, 50), 100.0)
        # Add salt-and-pepper noise
        noise_mask = rng.rand(50, 50) < 0.05
        image[noise_mask] = 255.0
        noise_mask2 = rng.rand(50, 50) < 0.05
        image[noise_mask2] = 0.0

        mf = RankFilters(method='median', radius=1)
        result = mf.apply(image)
        # After filtering, should be closer to 100
        assert abs(result.mean() - 100) < abs(image.mean() - 100)

    def test_min_shrinks_bright(self):
        """Min filter should shrink bright regions."""
        from grdl_imagej import RankFilters
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 200.0
        rf = RankFilters(method='min', radius=1)
        result = rf.apply(image)
        # Bright area should be smaller
        assert (result > 100).sum() < (image > 100).sum()

    def test_max_expands_bright(self):
        """Max filter should expand bright regions."""
        from grdl_imagej import RankFilters
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 200.0
        rf = RankFilters(method='max', radius=1)
        result = rf.apply(image)
        assert (result > 100).sum() > (image > 100).sum()

    def test_variance_detects_edges(self):
        """Variance filter should be high at edges."""
        from grdl_imagej import RankFilters
        image = np.zeros((40, 40))
        image[:, 20:] = 200.0
        vf = RankFilters(method='variance', radius=2)
        result = vf.apply(image)
        # Variance should be high near the edge (column 20)
        assert result[:, 18:22].mean() > result[:, 0:5].mean()

    def test_despeckle_is_3x3_median(self):
        """Despeckle should be equivalent to median with radius=1."""
        from grdl_imagej import RankFilters
        image = np.random.RandomState(7).rand(30, 30) * 200
        d = RankFilters(method='despeckle')
        m = RankFilters(method='median', radius=1)
        np.testing.assert_array_equal(d.apply(image), m.apply(image))

    def test_all_methods_run(self):
        from grdl_imagej import RankFilters
        image = np.random.RandomState(0).rand(30, 30) * 255
        for method in ('median', 'min', 'max', 'mean', 'variance', 'despeckle'):
            rf = RankFilters(method=method, radius=2)
            result = rf.apply(image)
            assert result.shape == (30, 30), f"Failed for {method}"

    def test_rejects_non_2d(self):
        from grdl_imagej import RankFilters
        rf = RankFilters()
        with pytest.raises(ValueError, match="2D"):
            rf.apply(np.zeros((3, 10, 10)))

    def test_invalid_method(self):
        from grdl_imagej import RankFilters
        with pytest.raises(ValueError):
            RankFilters(method='percentile')


# ============================================================================
# MorphologicalFilter Tests
# ============================================================================

class TestMorphologicalFilter:
    """Tests for Binary and Grayscale Morphological Operations."""

    def test_erode_shrinks_binary(self):
        from grdl_imagej import MorphologicalFilter
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        mf = MorphologicalFilter(operation='erode', radius=1)
        result = mf.apply(image)
        assert result.sum() < image.sum()

    def test_dilate_expands_binary(self):
        from grdl_imagej import MorphologicalFilter
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        mf = MorphologicalFilter(operation='dilate', radius=1)
        result = mf.apply(image)
        assert result.sum() > image.sum()

    def test_open_removes_small_noise(self):
        """Opening should remove small isolated pixels."""
        from grdl_imagej import MorphologicalFilter
        image = np.zeros((30, 30))
        image[15, 15] = 1.0  # Single isolated pixel
        image[5:10, 5:10] = 1.0  # Large region
        mf = MorphologicalFilter(operation='open', radius=1)
        result = mf.apply(image)
        # Isolated pixel should be removed, large region mostly kept
        assert result[15, 15] == 0.0
        assert result[7, 7] == 1.0

    def test_close_fills_small_holes(self):
        """Closing should fill small holes."""
        from grdl_imagej import MorphologicalFilter
        image = np.ones((30, 30))
        image[15, 15] = 0.0  # Small hole
        mf = MorphologicalFilter(operation='close', radius=1)
        result = mf.apply(image)
        assert result[15, 15] == 1.0

    def test_tophat_extracts_small_bright(self):
        """Top-hat should extract small bright features."""
        from grdl_imagej import MorphologicalFilter
        image = np.full((40, 40), 50.0)
        image[18:22, 18:22] = 200.0  # Small bright feature
        mf = MorphologicalFilter(operation='tophat', radius=3)
        result = mf.apply(image)
        # The bright spot should be in the top-hat result
        assert result[20, 20] > result[0, 0]

    def test_gradient_detects_boundaries(self):
        """Gradient should highlight region boundaries."""
        from grdl_imagej import MorphologicalFilter
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        mf = MorphologicalFilter(operation='gradient', radius=1)
        result = mf.apply(image)
        # Interior and exterior should be ~0, boundary should be > 0
        assert result[15, 15] < 0.5  # Interior
        assert result[10, 15] > 0.5 or result[9, 15] > 0.5  # Boundary

    def test_all_operations_run(self):
        from grdl_imagej import MorphologicalFilter
        image = np.random.RandomState(0).rand(30, 30)
        for op in ('erode', 'dilate', 'open', 'close', 'tophat', 'blackhat', 'gradient'):
            mf = MorphologicalFilter(operation=op, radius=1)
            result = mf.apply(image)
            assert result.shape == (30, 30), f"Failed for {op}"

    def test_kernel_shapes(self):
        from grdl_imagej import MorphologicalFilter
        image = np.random.RandomState(0).rand(20, 20)
        for shape in ('square', 'cross', 'disk'):
            mf = MorphologicalFilter(operation='erode', kernel_shape=shape)
            result = mf.apply(image)
            assert result.shape == (20, 20)

    def test_rejects_non_2d(self):
        from grdl_imagej import MorphologicalFilter
        mf = MorphologicalFilter()
        with pytest.raises(ValueError, match="2D"):
            mf.apply(np.zeros((3, 10, 10)))

    def test_invalid_operation(self):
        from grdl_imagej import MorphologicalFilter
        with pytest.raises(ValueError):
            MorphologicalFilter(operation='skeletonize')


# ============================================================================
# EdgeDetector Tests
# ============================================================================

class TestEdgeDetector:
    """Tests for Edge Detection filters."""

    def test_sobel_detects_step_edge(self):
        """Sobel should produce strong response at a step edge."""
        from grdl_imagej import EdgeDetector
        image = np.zeros((40, 80))
        image[:, 40:] = 200.0
        ed = EdgeDetector(method='sobel')
        result = ed.apply(image)
        # Edge response should be high near column 40
        assert result[:, 38:42].mean() > result[:, 0:5].mean() * 5

    def test_all_methods_run(self):
        from grdl_imagej import EdgeDetector
        image = np.random.RandomState(0).rand(40, 40) * 200
        for method in ('sobel', 'prewitt', 'roberts', 'log', 'scharr'):
            ed = EdgeDetector(method=method)
            result = ed.apply(image)
            assert result.shape == (40, 40), f"Failed for {method}"
            assert np.all(result >= 0), f"Negative values for {method}"

    def test_flat_image_zero_edges(self):
        """Flat image should have near-zero edge response."""
        from grdl_imagej import EdgeDetector
        flat = np.full((30, 30), 128.0)
        ed = EdgeDetector(method='sobel')
        result = ed.apply(flat)
        assert result.max() < 0.01

    def test_log_sigma_affects_scale(self):
        """Larger LoG sigma should smooth over finer detail."""
        from grdl_imagej import EdgeDetector
        image = np.zeros((60, 60))
        image[:, 30:] = 200.0
        ed_fine = EdgeDetector(method='log', sigma=0.5)
        ed_coarse = EdgeDetector(method='log', sigma=3.0)
        r_fine = ed_fine.apply(image)
        r_coarse = ed_coarse.apply(image)
        # Coarser sigma → wider but lower edge response
        assert r_fine.max() > r_coarse.max()

    def test_nonnegative_output(self):
        """Edge magnitude is always non-negative."""
        from grdl_imagej import EdgeDetector
        image = np.random.RandomState(5).rand(30, 30) * 200 - 50
        for method in ('sobel', 'prewitt', 'roberts', 'scharr'):
            ed = EdgeDetector(method=method)
            result = ed.apply(image)
            assert np.all(result >= -1e-10), f"Negative for {method}"

    def test_rejects_non_2d(self):
        from grdl_imagej import EdgeDetector
        ed = EdgeDetector()
        with pytest.raises(ValueError, match="2D"):
            ed.apply(np.zeros((3, 10, 10)))

    def test_invalid_method(self):
        from grdl_imagej import EdgeDetector
        with pytest.raises(ValueError):
            EdgeDetector(method='canny')


# ============================================================================
# GammaCorrection Tests
# ============================================================================

class TestGammaCorrection:
    """Tests for Gamma Correction."""

    def test_gamma_1_is_identity(self):
        """Gamma=1.0 should return the original image."""
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection(gamma=1.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = gc.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_gamma_less_than_1_brightens(self):
        """Gamma < 1 should increase mid-tone values."""
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection(gamma=0.5)
        image = np.linspace(0, 255, 100).reshape(10, 10)
        result = gc.apply(image)
        # Mid-tones should be brighter (higher)
        mid_idx = 5 * 10 + 5
        assert result.ravel()[mid_idx] > image.ravel()[mid_idx]

    def test_gamma_greater_than_1_darkens(self):
        """Gamma > 1 should decrease mid-tone values."""
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection(gamma=2.0)
        image = np.linspace(0, 255, 100).reshape(10, 10)
        result = gc.apply(image)
        mid_idx = 5 * 10 + 5
        assert result.ravel()[mid_idx] < image.ravel()[mid_idx]

    def test_preserves_range(self):
        """Output should have same min/max as input."""
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection(gamma=0.4)
        image = np.random.RandomState(3).rand(30, 30) * 200 + 10
        result = gc.apply(image)
        np.testing.assert_allclose(result.min(), image.min(), atol=1e-10)
        np.testing.assert_allclose(result.max(), image.max(), atol=1e-10)

    def test_flat_image_unchanged(self):
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection(gamma=0.5)
        flat = np.full((20, 20), 100.0)
        result = gc.apply(flat)
        np.testing.assert_allclose(result, 100.0, atol=1e-10)

    def test_rejects_non_2d(self):
        from grdl_imagej import GammaCorrection
        gc = GammaCorrection()
        with pytest.raises(ValueError, match="2D"):
            gc.apply(np.zeros((3, 10, 10)))

    def test_invalid_gamma(self):
        from grdl_imagej import GammaCorrection
        with pytest.raises(ValueError):
            GammaCorrection(gamma=0)
        with pytest.raises(ValueError):
            GammaCorrection(gamma=-1)


# ============================================================================
# FindMaxima Tests
# ============================================================================

class TestFindMaxima:
    """Tests for Find Maxima peak detection."""

    def test_detects_isolated_peak(self):
        """A single bright peak should be detected."""
        from grdl_imagej import FindMaxima
        image = np.zeros((50, 50))
        image[25, 25] = 100.0
        fm = FindMaxima(prominence=10.0)
        result = fm.apply(image)
        assert result[25, 25] == 1.0

    def test_rejects_low_prominence(self):
        """Peaks below prominence threshold should not be detected."""
        from grdl_imagej import FindMaxima
        image = np.full((30, 30), 100.0)
        image[15, 15] = 105.0  # Only 5 above background
        fm = FindMaxima(prominence=20.0)
        result = fm.apply(image)
        assert result.sum() == 0.0

    def test_multiple_peaks(self):
        """Multiple well-separated peaks should all be detected."""
        from grdl_imagej import FindMaxima
        image = np.zeros((50, 50))
        image[10, 10] = 100.0
        image[10, 40] = 100.0
        image[40, 10] = 100.0
        image[40, 40] = 100.0
        fm = FindMaxima(prominence=10.0)
        result = fm.apply(image)
        assert result.sum() >= 4.0

    def test_count_map_output(self):
        """count_map should label each peak region with a unique integer."""
        from grdl_imagej import FindMaxima
        image = np.zeros((50, 50))
        image[10, 10] = 100.0
        image[40, 40] = 100.0
        fm = FindMaxima(prominence=10.0, output='count_map')
        result = fm.apply(image)
        assert result.max() >= 2.0

    def test_find_peaks_method(self):
        """find_peaks() should return coordinate array."""
        from grdl_imagej import FindMaxima
        image = np.zeros((30, 30))
        image[15, 15] = 100.0
        fm = FindMaxima(prominence=10.0)
        coords = fm.find_peaks(image)
        assert coords.shape[1] == 2
        assert len(coords) >= 1

    def test_exclude_on_edges(self):
        """Edge maxima should be excluded when flag is set."""
        from grdl_imagej import FindMaxima
        image = np.zeros((30, 30))
        image[0, 15] = 100.0  # Edge peak
        image[15, 15] = 100.0  # Interior peak
        fm = FindMaxima(prominence=10.0, exclude_on_edges=True)
        result = fm.apply(image)
        assert result[0, 15] == 0.0
        assert result[15, 15] == 1.0

    def test_rejects_non_2d(self):
        from grdl_imagej import FindMaxima
        fm = FindMaxima()
        with pytest.raises(ValueError, match="2D"):
            fm.apply(np.zeros((3, 10, 10)))

    def test_no_peaks_returns_empty(self):
        """Flat image should return no peaks."""
        from grdl_imagej import FindMaxima
        fm = FindMaxima(prominence=10.0)
        coords = fm.find_peaks(np.full((20, 20), 50.0))
        assert coords.shape == (0, 2)


# ============================================================================
# StatisticalRegionMerging Tests
# ============================================================================

class TestStatisticalRegionMerging:
    """Tests for Statistical Region Merging segmentation."""

    def test_uniform_image_single_region(self):
        """A uniform image should produce a single region."""
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging(Q=25)
        flat = np.full((20, 20), 100.0)
        labels = srm.apply(flat)
        assert labels.max() == 1.0  # Single region

    def test_two_distinct_regions(self):
        """Two clearly separated intensity regions should be segmented."""
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging(Q=10)
        image = np.zeros((20, 40))
        image[:, 20:] = 200.0
        labels = srm.apply(image)
        # Should have at least 2 regions
        assert labels.max() >= 2.0
        # Left and right halves should have different labels
        assert labels[10, 5] != labels[10, 35]

    def test_mean_output(self):
        """Mean output should replace pixels with region means."""
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging(Q=10, output='mean')
        image = np.zeros((20, 40))
        image[:, 20:] = 200.0
        result = srm.apply(image)
        # Left half should be ~0, right half should be ~200
        assert abs(result[10, 5] - 0.0) < 50
        assert abs(result[10, 35] - 200.0) < 50

    def test_higher_q_more_regions(self):
        """Larger Q should produce more (finer) regions."""
        from grdl_imagej import StatisticalRegionMerging
        rng = np.random.RandomState(42)
        image = rng.rand(20, 20) * 100 + 50
        srm_coarse = StatisticalRegionMerging(Q=5)
        srm_fine = StatisticalRegionMerging(Q=200)
        labels_coarse = srm_coarse.apply(image)
        labels_fine = srm_fine.apply(image)
        assert labels_fine.max() >= labels_coarse.max()

    def test_output_shape(self):
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging(Q=25)
        image = np.random.RandomState(0).rand(15, 25) * 200
        result = srm.apply(image)
        assert result.shape == (15, 25)

    def test_labels_are_positive_integers(self):
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging(Q=25)
        image = np.random.RandomState(0).rand(15, 15) * 200
        labels = srm.apply(image)
        assert labels.min() >= 1.0

    def test_rejects_non_2d(self):
        from grdl_imagej import StatisticalRegionMerging
        srm = StatisticalRegionMerging()
        with pytest.raises(ValueError, match="2D"):
            srm.apply(np.zeros((3, 10, 10)))

    def test_invalid_q(self):
        from grdl_imagej import StatisticalRegionMerging
        with pytest.raises(ValueError):
            StatisticalRegionMerging(Q=0)
        with pytest.raises(ValueError):
            StatisticalRegionMerging(Q=-5)


# ============================================================================
# GaussianBlur Tests
# ============================================================================

class TestGaussianBlur:
    """Tests for Gaussian smoothing filter."""

    def test_sigma_zero_is_identity(self):
        """sigma=0 should return the original image unchanged."""
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=0.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = gb.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_sigma_zero_tuple_is_identity(self):
        """sigma=(0, 0) should return the original image unchanged."""
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=(0.0, 0.0))
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = gb.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_reduces_noise(self):
        """Gaussian blur should reduce noise standard deviation."""
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=3.0)
        rng = np.random.RandomState(42)
        clean = np.full((50, 50), 100.0)
        noisy = clean + rng.randn(50, 50) * 20
        result = gb.apply(noisy)
        assert np.std(result - clean) < np.std(noisy - clean)

    def test_flat_image_unchanged(self):
        """A flat image should remain unchanged after blurring."""
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=3.0)
        flat = np.full((40, 40), 128.0)
        result = gb.apply(flat)
        np.testing.assert_allclose(result, flat, atol=1e-10)

    def test_anisotropic_sigma(self):
        """Anisotropic sigma should blur differently in each direction."""
        from grdl_imagej import GaussianBlur
        image = np.zeros((50, 50))
        image[:, 25:] = 200.0
        gb_row = GaussianBlur(sigma=(5.0, 0.5))
        gb_col = GaussianBlur(sigma=(0.5, 5.0))
        r_row = gb_row.apply(image)
        r_col = gb_col.apply(image)
        # Column-heavy sigma should blur the vertical edge more,
        # producing a wider transition zone and lower column-mean variation
        col_var_row = np.var(r_row[25, :])
        col_var_col = np.var(r_col[25, :])
        assert col_var_col < col_var_row

    def test_output_shape_and_dtype(self):
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=2.0)
        result = gb.apply(np.ones((30, 40), dtype=np.uint8) * 100)
        assert result.shape == (30, 40)
        assert result.dtype == np.float64

    def test_complex_input(self):
        """Complex-valued input should preserve complex dtype."""
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur(sigma=1.0)
        rng = np.random.RandomState(42)
        cplx = rng.rand(20, 20) + 1j * rng.rand(20, 20)
        result = gb.apply(cplx)
        assert np.iscomplexobj(result)
        assert result.shape == (20, 20)

    def test_rejects_non_2d(self):
        from grdl_imagej import GaussianBlur
        gb = GaussianBlur()
        with pytest.raises(ValueError, match="2D"):
            gb.apply(np.zeros((3, 10, 10)))

    def test_invalid_sigma_negative(self):
        from grdl_imagej import GaussianBlur
        with pytest.raises(ValueError, match="sigma"):
            GaussianBlur(sigma=-1.0)

    def test_invalid_sigma_tuple_negative(self):
        from grdl_imagej import GaussianBlur
        with pytest.raises(ValueError, match="sigma"):
            GaussianBlur(sigma=(-1.0, 2.0))

    def test_invalid_sigma_tuple_length(self):
        from grdl_imagej import GaussianBlur
        with pytest.raises(ValueError, match="2 elements"):
            GaussianBlur(sigma=(1.0, 2.0, 3.0))

    def test_invalid_accuracy(self):
        from grdl_imagej import GaussianBlur
        with pytest.raises(ValueError, match="accuracy"):
            GaussianBlur(accuracy=0)


# ============================================================================
# Convolver Tests
# ============================================================================

class TestConvolver:
    """Tests for arbitrary 2D kernel convolution."""

    def test_identity_kernel(self):
        """A delta kernel should return the original image."""
        from grdl_imagej import Convolver
        kernel = np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float64)
        conv = Convolver(kernel=kernel, normalize=False)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = conv.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_averaging_kernel(self):
        """A 3x3 averaging kernel should smooth the image."""
        from grdl_imagej import Convolver
        kernel = np.ones((3, 3))
        conv = Convolver(kernel=kernel, normalize=True)
        image = np.zeros((20, 20))
        image[10, 10] = 100.0
        result = conv.apply(image)
        assert result[10, 10] < 100.0
        assert result[9, 10] > 0.0

    def test_normalize_preserves_brightness(self):
        """Normalized kernel should approximately preserve mean brightness."""
        from grdl_imagej import Convolver
        kernel = np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]])
        conv = Convolver(kernel=kernel, normalize=True)
        image = np.random.RandomState(7).rand(40, 40) * 200
        result = conv.apply(image)
        assert abs(result[5:-5, 5:-5].mean() - image[5:-5, 5:-5].mean()) < 5.0

    def test_derivative_kernel_no_normalize(self):
        """Derivative kernels (sum to 0) should not be normalized."""
        from grdl_imagej import Convolver
        kernel = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])
        conv = Convolver(kernel=kernel, normalize=False)
        image = np.zeros((30, 30))
        image[:, 15:] = 200.0
        result = conv.apply(image)
        assert abs(result[:, 14:16]).max() > 0

    def test_output_shape(self):
        from grdl_imagej import Convolver
        kernel = np.ones((3, 3))
        conv = Convolver(kernel=kernel)
        image = np.random.RandomState(0).rand(25, 35)
        result = conv.apply(image)
        assert result.shape == (25, 35)

    def test_complex_input(self):
        """Complex-valued input should preserve complex dtype."""
        from grdl_imagej import Convolver
        kernel = np.ones((3, 3))
        conv = Convolver(kernel=kernel)
        rng = np.random.RandomState(42)
        cplx = rng.rand(20, 20) + 1j * rng.rand(20, 20)
        result = conv.apply(cplx)
        assert np.iscomplexobj(result)

    def test_rejects_non_2d(self):
        from grdl_imagej import Convolver
        conv = Convolver(kernel=np.ones((3, 3)))
        with pytest.raises(ValueError, match="2D"):
            conv.apply(np.zeros((3, 10, 10)))

    def test_rejects_1d_kernel(self):
        from grdl_imagej import Convolver
        with pytest.raises(ValueError, match="2D"):
            Convolver(kernel=np.ones(5))

    def test_rejects_even_kernel(self):
        from grdl_imagej import Convolver
        with pytest.raises(ValueError, match="odd"):
            Convolver(kernel=np.ones((4, 4)))

    def test_rejects_empty_kernel(self):
        from grdl_imagej import Convolver
        with pytest.raises(ValueError):
            Convolver(kernel=np.empty((0, 0)))


# ============================================================================
# AutoThreshold Tests
# ============================================================================

class TestAutoThreshold:
    """Tests for global automatic thresholding (16 methods)."""

    def test_all_methods_run(self):
        """Every method should execute without error and produce binary output."""
        from grdl_imagej import AutoThreshold
        rng = np.random.RandomState(42)
        image = rng.rand(50, 50) * 255

        methods = [
            'default', 'huang', 'intermodes', 'isodata', 'li',
            'maxentropy', 'mean', 'minerror', 'minimum', 'moments',
            'otsu', 'percentile', 'renyientropy', 'shanbhag',
            'triangle', 'yen',
        ]
        for method in methods:
            at = AutoThreshold(method=method)
            result = at.apply(image)
            assert result.shape == (50, 50), f"Shape failed for {method}"
            unique = np.unique(result)
            assert all(v in [0.0, 1.0] for v in unique), (
                f"Non-binary output for {method}: {unique}"
            )

    def test_otsu_bimodal(self):
        """Otsu should separate a clearly bimodal image."""
        from grdl_imagej import AutoThreshold
        at = AutoThreshold(method='otsu')
        image = np.zeros((40, 40))
        image[:, 20:] = 200.0
        result = at.apply(image)
        assert result[:, :20].sum() == 0.0
        assert result[:, 20:].sum() == result[:, 20:].size

    def test_threshold_attribute(self):
        """threshold_ should be set after apply()."""
        from grdl_imagej import AutoThreshold
        at = AutoThreshold(method='otsu')
        image = np.random.RandomState(42).rand(30, 30) * 200
        at.apply(image)
        assert at.threshold_ is not None
        assert at.threshold_bin_ is not None
        assert 0 <= at.threshold_ <= 200

    def test_dark_background_false(self):
        """dark_background=False should invert the mask."""
        from grdl_imagej import AutoThreshold
        rng = np.random.RandomState(42)
        image = rng.rand(40, 40) * 200
        at_dark = AutoThreshold(method='otsu', dark_background=True)
        at_light = AutoThreshold(method='otsu', dark_background=False)
        r_dark = at_dark.apply(image)
        r_light = at_light.apply(image)
        np.testing.assert_array_equal(r_dark + r_light, np.ones_like(r_dark))

    def test_uniform_image_zero_output(self):
        """A uniform image should return all zeros."""
        from grdl_imagej import AutoThreshold
        at = AutoThreshold(method='otsu')
        flat = np.full((30, 30), 100.0)
        result = at.apply(flat)
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import AutoThreshold
        at = AutoThreshold()
        with pytest.raises(ValueError, match="2D"):
            at.apply(np.zeros((3, 10, 10)))

    def test_invalid_method(self):
        from grdl_imagej import AutoThreshold
        with pytest.raises(ValueError, match="not in allowed choices"):
            AutoThreshold(method='nonexistent')

    def test_invalid_nbins(self):
        from grdl_imagej import AutoThreshold
        with pytest.raises(ValueError, match="n_bins"):
            AutoThreshold(n_bins=1)


# ============================================================================
# Watershed Tests
# ============================================================================

class TestWatershed:
    """Tests for binary watershed segmentation."""

    def test_separates_touching_circles(self):
        """Two overlapping circles should be split by watershed."""
        from grdl_imagej import Watershed
        ws = Watershed(output_mode='labels')
        image = np.zeros((50, 80))
        yy, xx = np.ogrid[:50, :80]
        circle1 = ((yy - 25) ** 2 + (xx - 25) ** 2) < 15 ** 2
        circle2 = ((yy - 25) ** 2 + (xx - 55) ** 2) < 15 ** 2
        image[circle1 | circle2] = 1.0
        result = ws.apply(image)
        assert result.max() >= 2.0

    def test_single_object_no_split(self):
        """A single object should not be split."""
        from grdl_imagej import Watershed
        ws = Watershed(output_mode='labels')
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = ws.apply(image)
        assert result.max() >= 1.0
        assert (result > 0).sum() > 0

    def test_empty_image(self):
        """An empty image should return all zeros."""
        from grdl_imagej import Watershed
        ws = Watershed()
        result = ws.apply(np.zeros((20, 20)))
        assert result.sum() == 0.0

    def test_lines_output_mode(self):
        """lines output should produce binary watershed lines."""
        from grdl_imagej import Watershed
        ws = Watershed(output_mode='lines')
        image = np.zeros((50, 80))
        yy, xx = np.ogrid[:50, :80]
        circle1 = ((yy - 25) ** 2 + (xx - 25) ** 2) < 15 ** 2
        circle2 = ((yy - 25) ** 2 + (xx - 55) ** 2) < 15 ** 2
        image[circle1 | circle2] = 1.0
        result = ws.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_binary_output_mode(self):
        """binary output should produce separated binary objects."""
        from grdl_imagej import Watershed
        ws = Watershed(output_mode='binary')
        image = np.zeros((50, 80))
        yy, xx = np.ogrid[:50, :80]
        circle1 = ((yy - 25) ** 2 + (xx - 25) ** 2) < 15 ** 2
        circle2 = ((yy - 25) ** 2 + (xx - 55) ** 2) < 15 ** 2
        image[circle1 | circle2] = 1.0
        result = ws.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_output_shape(self):
        from grdl_imagej import Watershed
        ws = Watershed()
        image = np.zeros((25, 35))
        image[5:20, 5:30] = 1.0
        result = ws.apply(image)
        assert result.shape == (25, 35)

    def test_rejects_non_2d(self):
        from grdl_imagej import Watershed
        ws = Watershed()
        with pytest.raises(ValueError, match="2D"):
            ws.apply(np.zeros((3, 10, 10)))

    def test_invalid_min_seed_distance(self):
        from grdl_imagej import Watershed
        with pytest.raises(ValueError, match="min_seed_distance"):
            Watershed(min_seed_distance=0)

    def test_invalid_output_mode(self):
        from grdl_imagej import Watershed
        with pytest.raises(ValueError, match="not in allowed choices"):
            Watershed(output_mode='invalid')


# ============================================================================
# AnalyzeParticles Tests
# ============================================================================

class TestAnalyzeParticles:
    """Tests for connected component analysis with measurements."""

    def test_counts_distinct_particles(self):
        """Should detect the correct number of separated particles."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles()
        image = np.zeros((50, 50))
        image[5:10, 5:10] = 1.0
        image[20:25, 20:25] = 1.0
        image[35:40, 35:40] = 1.0
        ap.apply(image)
        assert ap.n_particles_ == 3

    def test_measurements_keys(self):
        """Results should include standard measurement keys."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        ap.apply(image)
        assert len(ap.results_) == 1
        expected_keys = {
            'area', 'centroid_row', 'centroid_col',
            'bbox_row', 'bbox_col', 'bbox_height', 'bbox_width',
            'perimeter', 'circularity', 'aspect_ratio', 'solidity',
        }
        assert expected_keys.issubset(set(ap.results_[0].keys()))

    def test_area_measurement(self):
        """Area should equal the number of foreground pixels."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        ap.apply(image)
        assert ap.results_[0]['area'] == 100.0

    def test_min_area_filter(self):
        """Particles smaller than min_area should be excluded."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles(min_area=50)
        image = np.zeros((50, 50))
        image[5:8, 5:8] = 1.0
        image[20:30, 20:30] = 1.0
        ap.apply(image)
        assert ap.n_particles_ == 1
        assert ap.results_[0]['area'] == 100.0

    def test_max_area_filter(self):
        """Particles larger than max_area should be excluded."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles(max_area=50)
        image = np.zeros((50, 50))
        image[5:8, 5:8] = 1.0
        image[20:30, 20:30] = 1.0
        ap.apply(image)
        assert ap.n_particles_ == 1
        assert ap.results_[0]['area'] == 9.0

    def test_mask_output_mode(self):
        """mask output should produce binary mask of accepted particles."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles(min_area=50, output_mode='mask')
        image = np.zeros((50, 50))
        image[5:8, 5:8] = 1.0
        image[20:30, 20:30] = 1.0
        result = ap.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})
        assert result[25, 25] == 1.0
        assert result[6, 6] == 0.0

    def test_outlines_output_mode(self):
        """outlines output should produce boundary pixels only."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles(output_mode='outlines')
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = ap.apply(image)
        assert result[15, 15] == 0.0
        assert result[10, 10] == 1.0

    def test_empty_image(self):
        """An empty image should return no particles."""
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles()
        result = ap.apply(np.zeros((20, 20)))
        assert ap.n_particles_ == 0
        assert len(ap.results_) == 0
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import AnalyzeParticles
        ap = AnalyzeParticles()
        with pytest.raises(ValueError, match="2D"):
            ap.apply(np.zeros((3, 10, 10)))

    def test_invalid_min_area(self):
        from grdl_imagej import AnalyzeParticles
        with pytest.raises(ValueError, match="min_area"):
            AnalyzeParticles(min_area=-1)

    def test_invalid_connectivity(self):
        from grdl_imagej import AnalyzeParticles
        with pytest.raises(ValueError, match="connectivity"):
            AnalyzeParticles(connectivity=6)

    def test_invalid_output_mode(self):
        from grdl_imagej import AnalyzeParticles
        with pytest.raises(ValueError, match="not in allowed choices"):
            AnalyzeParticles(output_mode='invalid')


# ============================================================================
# ImageCalculator Tests
# ============================================================================

class TestImageCalculator:
    """Tests for pixel-wise image arithmetic and logic."""

    def test_add(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='add')
        a = np.full((10, 10), 100.0)
        b = np.full((10, 10), 50.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 150.0)

    def test_subtract(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='subtract')
        a = np.full((10, 10), 100.0)
        b = np.full((10, 10), 30.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 70.0)

    def test_multiply(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='multiply')
        a = np.full((10, 10), 5.0)
        b = np.full((10, 10), 3.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 15.0)

    def test_divide(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='divide')
        a = np.full((10, 10), 100.0)
        b = np.full((10, 10), 4.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 25.0)

    def test_divide_by_zero(self):
        """Division by zero should produce 0."""
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='divide')
        a = np.full((10, 10), 100.0)
        b = np.zeros((10, 10))
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 0.0)

    def test_min_max(self):
        from grdl_imagej import ImageCalculator
        a = np.array([[1, 5], [3, 7]], dtype=np.float64)
        b = np.array([[4, 2], [6, 1]], dtype=np.float64)
        ic_min = ImageCalculator(operation='min')
        ic_max = ImageCalculator(operation='max')
        np.testing.assert_array_equal(
            ic_min.apply(a, image2=b), [[1, 2], [3, 1]]
        )
        np.testing.assert_array_equal(
            ic_max.apply(a, image2=b), [[4, 5], [6, 7]]
        )

    def test_average(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='average')
        a = np.full((10, 10), 100.0)
        b = np.full((10, 10), 200.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 150.0)

    def test_difference(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator(operation='difference')
        a = np.full((10, 10), 100.0)
        b = np.full((10, 10), 150.0)
        result = ic.apply(a, image2=b)
        np.testing.assert_allclose(result, 50.0)

    def test_and_or_xor(self):
        from grdl_imagej import ImageCalculator
        a = np.array([[255, 0]], dtype=np.float64)
        b = np.array([[255, 255]], dtype=np.float64)
        assert ImageCalculator(operation='and').apply(a, image2=b)[0, 0] == 255.0
        assert ImageCalculator(operation='or').apply(a, image2=b)[0, 1] == 255.0
        assert ImageCalculator(operation='xor').apply(a, image2=b)[0, 0] == 0.0

    def test_all_operations_run(self):
        """All operations should execute without error."""
        from grdl_imagej import ImageCalculator
        ops = [
            'add', 'subtract', 'multiply', 'divide',
            'and', 'or', 'xor', 'min', 'max',
            'average', 'difference', 'ratio',
        ]
        a = np.random.RandomState(0).rand(10, 10) * 200 + 1
        b = np.random.RandomState(1).rand(10, 10) * 200 + 1
        for op in ops:
            ic = ImageCalculator(operation=op)
            result = ic.apply(a, image2=b)
            assert result.shape == (10, 10), f"Failed for {op}"

    def test_complex_arithmetic(self):
        """Complex-valued inputs should work for arithmetic ops."""
        from grdl_imagej import ImageCalculator
        rng = np.random.RandomState(42)
        a = rng.rand(10, 10) + 1j * rng.rand(10, 10)
        b = rng.rand(10, 10) + 1j * rng.rand(10, 10)
        ic = ImageCalculator(operation='add')
        result = ic.apply(a, image2=b)
        assert np.iscomplexobj(result)

    def test_complex_real_only_ops_rejected(self):
        """Real-only operations should reject complex input."""
        from grdl_imagej import ImageCalculator
        a = np.ones((5, 5)) + 1j * np.ones((5, 5))
        b = np.ones((5, 5)) + 1j * np.ones((5, 5))
        for op in ('and', 'or', 'xor', 'min', 'max'):
            ic = ImageCalculator(operation=op)
            with pytest.raises(ValueError, match="not defined for complex"):
                ic.apply(a, image2=b)

    def test_rejects_non_2d(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator()
        with pytest.raises(ValueError, match="2D"):
            ic.apply(np.zeros((3, 10, 10)), image2=np.zeros((3, 10, 10)))

    def test_rejects_missing_image2(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator()
        with pytest.raises(ValueError, match="image2"):
            ic.apply(np.zeros((10, 10)))

    def test_rejects_shape_mismatch(self):
        from grdl_imagej import ImageCalculator
        ic = ImageCalculator()
        with pytest.raises(ValueError, match="Shape mismatch"):
            ic.apply(np.zeros((10, 10)), image2=np.zeros((10, 20)))

    def test_invalid_operation(self):
        from grdl_imagej import ImageCalculator
        with pytest.raises(ValueError, match="not in allowed choices"):
            ImageCalculator(operation='modulo')


# ============================================================================
# ContrastEnhancer Tests
# ============================================================================

class TestContrastEnhancer:
    """Tests for linear histogram stretching."""

    def test_stretches_narrow_range(self):
        """A narrow-range image should be stretched wider."""
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer(saturated=0.0, normalize=True)
        image = np.random.RandomState(42).rand(50, 50) * 10 + 100
        result = ce.apply(image)
        # Normalized output with 0% saturation should span full [0, 1]
        assert result.max() > 0.99
        assert result.min() < 0.01

    def test_normalize_output_01(self):
        """normalize=True should produce output in [0, 1]."""
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer(saturated=1.0, normalize=True)
        image = np.random.RandomState(42).rand(40, 40) * 200 + 50
        result = ce.apply(image)
        assert result.min() >= -0.01
        assert result.max() <= 1.01

    def test_flat_image_unchanged(self):
        """A flat image should be returned unchanged."""
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer()
        flat = np.full((30, 30), 100.0)
        result = ce.apply(flat)
        np.testing.assert_allclose(result, flat, atol=1e-10)

    def test_equalize_mode(self):
        """Equalize should produce a more uniform histogram."""
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer(equalize=True)
        rng = np.random.RandomState(42)
        image = rng.exponential(20, (50, 50))
        result = ce.apply(image)
        assert result.shape == (50, 50)

    def test_min_max_val_attributes(self):
        """min_val_ and max_val_ should be set after apply()."""
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer(saturated=2.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        ce.apply(image)
        assert ce.min_val_ < ce.max_val_

    def test_output_shape_and_dtype(self):
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer()
        result = ce.apply(np.ones((20, 30), dtype=np.uint8) * 100)
        assert result.shape == (20, 30)
        assert result.dtype == np.float64

    def test_rejects_non_2d(self):
        from grdl_imagej import ContrastEnhancer
        ce = ContrastEnhancer()
        with pytest.raises(ValueError, match="2D"):
            ce.apply(np.zeros((3, 10, 10)))

    def test_invalid_saturated(self):
        from grdl_imagej import ContrastEnhancer
        with pytest.raises(ValueError, match="saturated"):
            ContrastEnhancer(saturated=100.0)
        with pytest.raises(ValueError, match="saturated"):
            ContrastEnhancer(saturated=-1.0)


# ============================================================================
# DistanceTransform Tests
# ============================================================================

class TestDistanceTransform:
    """Tests for Euclidean Distance Map."""

    def test_background_is_zero(self):
        """Background pixels should have distance 0."""
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = dt.apply(image)
        assert result[0, 0] == 0.0
        assert result[5, 5] == 0.0

    def test_center_has_max_distance(self):
        """Center of a square object should have the maximum distance."""
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        image = np.zeros((50, 50))
        image[10:40, 10:40] = 1.0
        result = dt.apply(image)
        center_val = result[25, 25]
        assert center_val > result[11, 11]
        assert center_val == result.max()

    def test_single_pixel(self):
        """A single foreground pixel should have distance 1.0."""
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        image = np.zeros((20, 20))
        image[10, 10] = 1.0
        result = dt.apply(image)
        assert result[10, 10] == 1.0

    def test_normalize(self):
        """normalize=True should scale output to [0, 1]."""
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform(normalize=True)
        image = np.zeros((30, 30))
        image[5:25, 5:25] = 1.0
        result = dt.apply(image)
        assert result.max() <= 1.0 + 1e-10
        assert result.max() >= 0.99

    def test_anisotropic_pixel_size(self):
        """Different pixel_size should scale distances appropriately."""
        from grdl_imagej import DistanceTransform
        dt_iso = DistanceTransform(pixel_size=(1.0, 1.0))
        dt_aniso = DistanceTransform(pixel_size=(2.0, 1.0))
        # Wide rectangle: 6 rows x 20 cols so nearest boundary for
        # center pixel is row-direction (3px). With row_spacing=2,
        # anisotropic distance = 6 > isotropic distance = 3.
        image = np.zeros((30, 40))
        image[12:18, 10:30] = 1.0
        r_iso = dt_iso.apply(image)
        r_aniso = dt_aniso.apply(image)
        assert r_aniso[15, 20] > r_iso[15, 20]

    def test_empty_image(self):
        """An empty image should return all zeros."""
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        result = dt.apply(np.zeros((20, 20)))
        assert result.sum() == 0.0

    def test_output_shape(self):
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        image = np.zeros((25, 35))
        image[5:20, 5:30] = 1.0
        result = dt.apply(image)
        assert result.shape == (25, 35)
        assert result.dtype == np.float64

    def test_rejects_non_2d(self):
        from grdl_imagej import DistanceTransform
        dt = DistanceTransform()
        with pytest.raises(ValueError, match="2D"):
            dt.apply(np.zeros((3, 10, 10)))

    def test_invalid_pixel_size(self):
        from grdl_imagej import DistanceTransform
        with pytest.raises(ValueError, match="pixel_size"):
            DistanceTransform(pixel_size=(0, 1.0))
        with pytest.raises(ValueError, match="2 elements"):
            DistanceTransform(pixel_size=(1.0,))


# ============================================================================
# Skeletonize Tests
# ============================================================================

class TestSkeletonize:
    """Tests for Zhang-Suen binary thinning."""

    def test_reduces_to_thin_line(self):
        """A thick horizontal bar should be reduced to a thin line."""
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        image = np.zeros((30, 30))
        image[10:20, 5:25] = 1.0
        result = skel.apply(image)
        assert result.sum() < image.sum()
        assert result.sum() > 0

    def test_single_pixel_preserved(self):
        """An isolated single pixel should be preserved."""
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        image = np.zeros((20, 20))
        image[10, 10] = 1.0
        result = skel.apply(image)
        assert result[10, 10] == 1.0

    def test_already_thin_line_preserved(self):
        """A 1-pixel-wide line should remain unchanged."""
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        image = np.zeros((20, 20))
        image[10, 3:17] = 1.0
        result = skel.apply(image)
        assert result[10, 5:15].sum() >= 8.0

    def test_binary_output(self):
        """Output should be binary (0.0 and 1.0 only)."""
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        image = np.zeros((25, 25))
        image[5:20, 5:20] = 1.0
        result = skel.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_empty_image(self):
        """An empty image should return all zeros."""
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        result = skel.apply(np.zeros((20, 20)))
        assert result.sum() == 0.0

    def test_output_shape(self):
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        image = np.zeros((25, 35))
        image[5:20, 5:30] = 1.0
        result = skel.apply(image)
        assert result.shape == (25, 35)
        assert result.dtype == np.float64

    def test_rejects_non_2d(self):
        from grdl_imagej import Skeletonize
        skel = Skeletonize()
        with pytest.raises(ValueError, match="2D"):
            skel.apply(np.zeros((3, 10, 10)))


# ============================================================================
# AnisotropicDiffusion Tests
# ============================================================================

class TestAnisotropicDiffusion:
    """Tests for Perona-Malik anisotropic diffusion."""

    def test_reduces_noise(self):
        """Diffusion should reduce noise in smooth regions."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(n_iterations=20, kappa=30.0)
        rng = np.random.RandomState(42)
        clean = np.full((50, 50), 100.0)
        noisy = clean + rng.randn(50, 50) * 20
        result = ad.apply(noisy)
        assert np.std(result[10:40, 10:40]) < np.std(noisy[10:40, 10:40])

    def test_preserves_edges(self):
        """Strong edges should be preserved after diffusion."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(n_iterations=10, kappa=10.0)
        image = np.zeros((40, 40))
        image[:, 20:] = 200.0
        result = ad.apply(image)
        assert result[20, 19] < 50
        assert result[20, 21] > 150

    def test_flat_image_unchanged(self):
        """A flat image should remain flat (no gradients to diffuse)."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(n_iterations=10, kappa=20.0)
        flat = np.full((30, 30), 128.0)
        result = ad.apply(flat)
        np.testing.assert_allclose(result, flat, atol=1e-10)

    def test_exponential_conductance(self):
        """Exponential conductance should run without error."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(
            n_iterations=5, kappa=20.0, conductance='exponential'
        )
        image = np.random.RandomState(0).rand(30, 30) * 200
        result = ad.apply(image)
        assert result.shape == (30, 30)

    def test_quadratic_conductance(self):
        """Quadratic conductance should run without error."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(
            n_iterations=5, kappa=20.0, conductance='quadratic'
        )
        image = np.random.RandomState(0).rand(30, 30) * 200
        result = ad.apply(image)
        assert result.shape == (30, 30)

    def test_complex_input(self):
        """Complex-valued input should preserve complex dtype."""
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(n_iterations=5, kappa=20.0)
        rng = np.random.RandomState(42)
        cplx = rng.rand(20, 20) + 1j * rng.rand(20, 20)
        result = ad.apply(cplx)
        assert np.iscomplexobj(result)
        assert result.shape == (20, 20)

    def test_output_shape_and_dtype(self):
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion(n_iterations=3)
        result = ad.apply(np.ones((20, 25), dtype=np.uint8) * 100)
        assert result.shape == (20, 25)
        assert result.dtype == np.float64

    def test_rejects_non_2d(self):
        from grdl_imagej import AnisotropicDiffusion
        ad = AnisotropicDiffusion()
        with pytest.raises(ValueError, match="2D"):
            ad.apply(np.zeros((3, 10, 10)))

    def test_invalid_n_iterations(self):
        from grdl_imagej import AnisotropicDiffusion
        with pytest.raises(ValueError, match="n_iterations"):
            AnisotropicDiffusion(n_iterations=0)

    def test_invalid_kappa(self):
        from grdl_imagej import AnisotropicDiffusion
        with pytest.raises(ValueError, match="kappa"):
            AnisotropicDiffusion(kappa=0)
        with pytest.raises(ValueError, match="kappa"):
            AnisotropicDiffusion(kappa=-5)

    def test_invalid_gamma(self):
        from grdl_imagej import AnisotropicDiffusion
        with pytest.raises(ValueError, match="gamma"):
            AnisotropicDiffusion(gamma=0)
        with pytest.raises(ValueError, match="gamma"):
            AnisotropicDiffusion(gamma=0.3)

    def test_invalid_conductance(self):
        from grdl_imagej import AnisotropicDiffusion
        with pytest.raises(ValueError, match="not in allowed choices"):
            AnisotropicDiffusion(conductance='linear')


# ============================================================================
# Module-level integration tests
# ============================================================================

ALL_CLASSES = None

def _get_all_classes():
    global ALL_CLASSES
    if ALL_CLASSES is None:
        from grdl_imagej import (
            RollingBallBackground, CLAHE, AutoLocalThreshold,
            UnsharpMask, FFTBandpassFilter, ZProjection,
            RankFilters, MorphologicalFilter, EdgeDetector,
            GammaCorrection, FindMaxima, StatisticalRegionMerging,
            GaussianBlur, Convolver, AutoThreshold, Watershed,
            AnalyzeParticles, ImageCalculator, ContrastEnhancer,
            DistanceTransform, Skeletonize, AnisotropicDiffusion,
        )
        ALL_CLASSES = [
            RollingBallBackground, CLAHE, AutoLocalThreshold,
            UnsharpMask, FFTBandpassFilter, ZProjection,
            RankFilters, MorphologicalFilter, EdgeDetector,
            GammaCorrection, FindMaxima, StatisticalRegionMerging,
            GaussianBlur, Convolver, AutoThreshold, Watershed,
            AnalyzeParticles, ImageCalculator, ContrastEnhancer,
            DistanceTransform, Skeletonize, AnisotropicDiffusion,
        ]
    return ALL_CLASSES


class TestModuleExports:
    """Verify all 22 components are importable and properly configured."""

    def test_all_exports(self):
        classes = _get_all_classes()
        assert len(classes) == 22
        assert all(cls is not None for cls in classes)

    def test_all_inherit_from_image_transform(self):
        from grdl.image_processing.base import ImageTransform
        for cls in _get_all_classes():
            assert issubclass(cls, ImageTransform), (
                f"{cls.__name__} is not a subclass of ImageTransform"
            )

    def test_all_have_imagej_version(self):
        for cls in _get_all_classes():
            assert hasattr(cls, '__imagej_version__'), (
                f"{cls.__name__} missing __imagej_version__"
            )
            assert hasattr(cls, '__imagej_source__'), (
                f"{cls.__name__} missing __imagej_source__"
            )

    def test_all_have_processor_version(self):
        for cls in _get_all_classes():
            assert hasattr(cls, '__processor_version__'), (
                f"{cls.__name__} missing __processor_version__"
            )

    def test_all_list_matches_init(self):
        """__all__ in grdl_imagej/__init__.py should have 64 entries."""
        import grdl_imagej as ij_module
        assert len(ij_module.__all__) == 64


# ============================================================================
# Consolidated version attribute tests (replaces per-class test_version_attribute)
# ============================================================================

@pytest.mark.parametrize("class_name,expected_version", [
    ('RollingBallBackground', '1.54j'),
    ('CLAHE', '0.5.0'),
    ('AutoLocalThreshold', '1.10.1'),
    ('UnsharpMask', '1.54j'),
    ('FFTBandpassFilter', '1.54j'),
    ('ZProjection', '1.54j'),
    ('RankFilters', '1.54j'),
    ('MorphologicalFilter', '1.54j'),
    ('EdgeDetector', '1.54j'),
    ('GammaCorrection', '1.54j'),
    ('FindMaxima', '1.54j'),
    ('StatisticalRegionMerging', '1.0'),
    ('GaussianBlur', '1.54j'),
    ('Convolver', '1.54j'),
    ('AutoThreshold', '1.54j'),
    ('Watershed', '1.54j'),
    ('AnalyzeParticles', '1.54j'),
    ('ImageCalculator', '1.54j'),
    ('ContrastEnhancer', '1.54j'),
    ('DistanceTransform', '1.54j'),
    ('Skeletonize', '1.54j'),
    ('AnisotropicDiffusion', '2.0.0'),
    ('DifferenceOfGaussians', '1.0.0'),
    ('Shadows', '1.54j'),
    ('Smooth', '1.54j'),
    ('Sharpen', '1.54j'),
    ('VarianceFilter', '1.0.0'),
    ('EntropyFilter', '1.0.0'),
    ('KuwaharaFilter', '1.0.0'),
    ('LocalBinaryPatterns', '1.0.0'),
    ('GaborFilterBank', '1.0.0'),
    ('BinaryOutline', '1.54j'),
    ('BinaryFillHoles', '1.54j'),
    ('PseudoFlatField', '1.0.0'),
    ('NoiseGenerator', '1.54j'),
    ('BilateralFilter', '1.0.0'),
    ('MathOperations', '1.54j'),
    ('TypeConverter', '1.54j'),
    ('HarrisCornerDetector', '1.0.0'),
    ('PhaseCorrelation', '1.0.0'),
    ('ColorSpaceConverter', '1.54j'),
    ('WhiteBalance', '1.0.0'),
    ('GLCMHaralick', '0.40.0'),
    ('NonLocalMeans', '1.0.0'),
    ('StructureTensor', '2.0.0'),
    ('FrangiVesselness', '0.40.0'),
    ('MarkerControlledWatershed', '1.6.0'),
    ('MorphologicalReconstruction', '1.6.0'),
    ('RichardsonLucy', '0.40.0'),
    ('WienerFilter', '0.40.0'),
    ('HoughTransform', '1.0.0'),
    ('RidgeDetection', '1.4.0'),
    ('SlidingParaboloid', '1.54j'),
    ('ExtendedMinMax', '1.6.0'),
    ('FFTCustomFilter', '1.54j'),
    ('TemplateMatching', '0.40.0'),
    ('MorphologicalGradient', '1.6.0'),
    ('MorphologicalLaplacian', '1.6.0'),
    ('DirectionalFilter', '1.6.0'),
    ('KillBorders', '1.6.0'),
    ('Granulometry', '1.6.0'),
    ('ROFDenoise', '0.40.0'),
    ('ColorDeconvolution', '3.0.0'),
    ('TamuraTexture', '0.40.0'),
])
def test_imagej_version_attribute(class_name, expected_version):
    """All ImageJ components have correct __processor_version__."""
    import grdl_imagej as ij
    cls = getattr(ij, class_name)
    assert cls.__processor_version__ == expected_version


# ============================================================================
# DifferenceOfGaussians Tests
# ============================================================================

class TestDifferenceOfGaussians:
    """Tests for Difference of Gaussians."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import DifferenceOfGaussians
        dog = DifferenceOfGaussians(sigma1=1.0, sigma2=3.0)
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = dog.apply(image)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_flat_image_zero_response(self):
        """DoG of a flat image should be near zero everywhere."""
        from grdl_imagej import DifferenceOfGaussians
        dog = DifferenceOfGaussians(sigma1=1.0, sigma2=3.0)
        flat = np.full((40, 40), 100.0)
        result = dog.apply(flat)
        np.testing.assert_allclose(result, 0.0, atol=1e-10)

    def test_detects_blob(self):
        """DoG should have strong response at a bright blob."""
        from grdl_imagej import DifferenceOfGaussians
        dog = DifferenceOfGaussians(sigma1=1.0, sigma2=4.0)
        image = np.zeros((50, 50))
        image[23:28, 23:28] = 200.0
        result = dog.apply(image)
        # Strong positive response near center of blob
        assert result[25, 25] > 10.0

    def test_swaps_sigmas_if_needed(self):
        """sigma1 > sigma2 should still work (auto-swap)."""
        from grdl_imagej import DifferenceOfGaussians
        dog = DifferenceOfGaussians(sigma1=5.0, sigma2=1.0)
        image = np.random.RandomState(0).rand(30, 30) * 200
        result = dog.apply(image)
        assert result.shape == (30, 30)

    def test_rejects_non_2d(self):
        from grdl_imagej import DifferenceOfGaussians
        dog = DifferenceOfGaussians()
        with pytest.raises(ValueError, match="2D"):
            dog.apply(np.zeros((3, 10, 10)))


# ============================================================================
# Shadows Tests
# ============================================================================

class TestShadows:
    """Tests for Shadows (Emboss)."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import Shadows
        s = Shadows(direction='SE')
        image = np.random.RandomState(42).rand(40, 40) * 200
        result = s.apply(image)
        assert result.shape == (40, 40)
        assert result.dtype == np.float64

    def test_flat_image_constant(self):
        """Flat image should give pixel_value + offset (no gradient)."""
        from grdl_imagej import Shadows
        s = Shadows(direction='N', offset=128.0)
        flat = np.full((30, 30), 100.0)
        result = s.apply(flat)
        # Kernel sum=1, so convolution of flat=100 gives 100, plus offset=128
        np.testing.assert_allclose(result[5:-5, 5:-5], 228.0, atol=0.01)

    def test_all_directions_run(self):
        from grdl_imagej import Shadows
        image = np.random.RandomState(0).rand(30, 30) * 200
        for direction in ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']:
            s = Shadows(direction=direction)
            result = s.apply(image)
            assert result.shape == (30, 30), f"Failed for {direction}"

    def test_opposite_directions_differ(self):
        """N and S emboss should produce opposite gradients."""
        from grdl_imagej import Shadows
        image = np.zeros((40, 40))
        image[:20, :] = 200.0  # horizontal edge
        n = Shadows(direction='N', offset=0.0).apply(image)
        s = Shadows(direction='S', offset=0.0).apply(image)
        # Opposite directions → opposite signs at edge
        assert np.sign(n[20, 20]) != np.sign(s[20, 20]) or abs(n[20, 20]) < 0.01

    def test_rejects_non_2d(self):
        from grdl_imagej import Shadows
        s = Shadows()
        with pytest.raises(ValueError, match="2D"):
            s.apply(np.zeros((3, 10, 10)))


# ============================================================================
# Smooth Tests
# ============================================================================

class TestSmooth:
    """Tests for Smooth (Mean Filter)."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import Smooth
        s = Smooth()
        result = s.apply(np.ones((30, 30), dtype=np.uint8) * 100)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        from grdl_imagej import Smooth
        s = Smooth()
        flat = np.full((40, 40), 50.0)
        result = s.apply(flat)
        np.testing.assert_allclose(result, 50.0, atol=0.01)

    def test_reduces_noise(self):
        """Smoothing should reduce the standard deviation of noise."""
        from grdl_imagej import Smooth
        s = Smooth()
        rng = np.random.RandomState(42)
        noisy = np.full((50, 50), 100.0) + rng.randn(50, 50) * 20
        result = s.apply(noisy)
        assert result.std() < noisy.std()

    def test_rejects_non_2d(self):
        from grdl_imagej import Smooth
        s = Smooth()
        with pytest.raises(ValueError, match="2D"):
            s.apply(np.zeros((3, 10, 10)))


# ============================================================================
# Sharpen Tests
# ============================================================================

class TestSharpen:
    """Tests for Sharpen (Laplacian)."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import Sharpen
        s = Sharpen()
        result = s.apply(np.ones((30, 30), dtype=np.uint8) * 100)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        from grdl_imagej import Sharpen
        s = Sharpen()
        flat = np.full((40, 40), 128.0)
        result = s.apply(flat)
        np.testing.assert_allclose(result[5:-5, 5:-5], 128.0, atol=0.01)

    def test_sharpens_edge(self):
        """Should increase gradient at step edges."""
        from grdl_imagej import Sharpen
        s = Sharpen()
        image = np.zeros((40, 80))
        image[:, 40:] = 200.0
        result = s.apply(image)
        orig_grad = np.abs(np.diff(image[20, :]))
        sharp_grad = np.abs(np.diff(result[20, :]))
        assert sharp_grad.max() >= orig_grad.max()

    def test_rejects_non_2d(self):
        from grdl_imagej import Sharpen
        s = Sharpen()
        with pytest.raises(ValueError, match="2D"):
            s.apply(np.zeros((3, 10, 10)))


# ============================================================================
# VarianceFilter Tests
# ============================================================================

class TestVarianceFilter:
    """Tests for Variance / Std Dev Filter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import VarianceFilter
        vf = VarianceFilter(radius=3)
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = vf.apply(image)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_flat_image_zero_variance(self):
        from grdl_imagej import VarianceFilter
        vf = VarianceFilter(radius=3, output='variance')
        flat = np.full((40, 40), 100.0)
        result = vf.apply(flat)
        np.testing.assert_allclose(result, 0.0, atol=1e-10)

    def test_std_dev_nonnegative(self):
        from grdl_imagej import VarianceFilter
        vf = VarianceFilter(radius=3, output='std_dev')
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = vf.apply(image)
        assert np.all(result >= 0.0)

    def test_variance_vs_std_dev(self):
        """Std dev should be sqrt of variance."""
        from grdl_imagej import VarianceFilter
        image = np.random.RandomState(42).rand(30, 30) * 200
        var_result = VarianceFilter(radius=3, output='variance').apply(image)
        std_result = VarianceFilter(radius=3, output='std_dev').apply(image)
        np.testing.assert_allclose(std_result, np.sqrt(var_result), atol=1e-10)

    def test_rejects_non_2d(self):
        from grdl_imagej import VarianceFilter
        vf = VarianceFilter()
        with pytest.raises(ValueError, match="2D"):
            vf.apply(np.zeros((3, 10, 10)))


# ============================================================================
# BinaryOutline Tests
# ============================================================================

class TestBinaryOutline:
    """Tests for BinaryOutline."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = bo.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_binary_output(self):
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = bo.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_outline_of_square(self):
        """Outline should have fewer foreground pixels than original."""
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline(connectivity=4)
        image = np.zeros((30, 30))
        image[5:25, 5:25] = 1.0
        result = bo.apply(image)
        assert result.sum() < image.sum()
        assert result.sum() > 0  # Outline is not empty

    def test_interior_removed(self):
        """Interior of large solid object should be zero."""
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline(connectivity=4)
        image = np.zeros((40, 40))
        image[5:35, 5:35] = 1.0
        result = bo.apply(image)
        # Center should be zero (interior)
        assert result[20, 20] == 0.0

    def test_empty_image(self):
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline()
        result = bo.apply(np.zeros((20, 20)))
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import BinaryOutline
        bo = BinaryOutline()
        with pytest.raises(ValueError, match="2D"):
            bo.apply(np.zeros((3, 10, 10)))


# ============================================================================
# BinaryFillHoles Tests
# ============================================================================

class TestBinaryFillHoles:
    """Tests for BinaryFillHoles."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import BinaryFillHoles
        bfh = BinaryFillHoles()
        image = np.zeros((30, 30))
        image[5:25, 5:25] = 1.0
        result = bfh.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_fills_interior_hole(self):
        """A ring should become a filled disc."""
        from grdl_imagej import BinaryFillHoles
        bfh = BinaryFillHoles(connectivity=8)
        image = np.zeros((30, 30))
        image[5:25, 5:25] = 1.0
        image[10:20, 10:20] = 0.0  # Punch a hole
        result = bfh.apply(image)
        # Hole should now be filled
        assert result[15, 15] == 1.0
        assert result.sum() > image.sum()

    def test_no_hole_unchanged(self):
        """Image without holes should be unchanged."""
        from grdl_imagej import BinaryFillHoles
        bfh = BinaryFillHoles()
        image = np.zeros((30, 30))
        image[10:20, 10:20] = 1.0
        result = bfh.apply(image)
        np.testing.assert_array_equal(result, image)

    def test_empty_image(self):
        from grdl_imagej import BinaryFillHoles
        bfh = BinaryFillHoles()
        result = bfh.apply(np.zeros((20, 20)))
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import BinaryFillHoles
        bfh = BinaryFillHoles()
        with pytest.raises(ValueError, match="2D"):
            bfh.apply(np.zeros((3, 10, 10)))


# ============================================================================
# PseudoFlatField Tests
# ============================================================================

class TestPseudoFlatField:
    """Tests for Pseudo Flat-Field Correction."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import PseudoFlatField
        pff = PseudoFlatField(blur_radius=20.0)
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = pff.apply(image)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_normalized_output_range(self):
        from grdl_imagej import PseudoFlatField
        pff = PseudoFlatField(blur_radius=20.0, normalize_output=True)
        image = np.random.RandomState(42).rand(50, 50) * 200 + 10
        result = pff.apply(image)
        assert result.min() >= -0.01
        assert result.max() <= 1.01

    def test_corrects_gradient(self):
        """Should flatten a linear gradient illumination."""
        from grdl_imagej import PseudoFlatField
        pff = PseudoFlatField(blur_radius=30.0, normalize_output=False)
        rows, cols = 80, 80
        gradient = np.tile(np.linspace(50, 200, cols), (rows, 1))
        result = pff.apply(gradient)
        # Output should be more uniform than input
        assert result.std() < gradient.std()

    def test_rejects_non_2d(self):
        from grdl_imagej import PseudoFlatField
        pff = PseudoFlatField()
        with pytest.raises(ValueError, match="2D"):
            pff.apply(np.zeros((3, 10, 10)))


# ============================================================================
# NoiseGenerator Tests
# ============================================================================

class TestNoiseGenerator:
    """Tests for NoiseGenerator."""

    def test_gaussian_noise_changes_image(self):
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator(noise_type='gaussian', sigma=25.0, seed=42)
        image = np.full((50, 50), 100.0)
        result = ng.apply(image)
        assert result.shape == (50, 50)
        assert not np.allclose(result, 100.0)

    def test_gaussian_noise_zero_mean(self):
        """Gaussian noise should have approximately zero mean offset."""
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator(noise_type='gaussian', sigma=10.0, seed=42)
        image = np.full((200, 200), 100.0)
        result = ng.apply(image)
        diff = result - image
        assert abs(diff.mean()) < 2.0  # Roughly zero mean

    def test_poisson_noise(self):
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator(noise_type='poisson', seed=42)
        image = np.full((50, 50), 100.0)
        result = ng.apply(image)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_salt_pepper_noise(self):
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator(noise_type='salt_pepper', density=0.1, seed=42)
        image = np.full((50, 50), 100.0)
        result = ng.apply(image)
        # Some pixels should differ from 100
        assert not np.allclose(result, 100.0)

    def test_speckle_noise(self):
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator(noise_type='speckle', sigma=25.0, seed=42)
        image = np.full((50, 50), 100.0)
        result = ng.apply(image)
        assert result.shape == (50, 50)
        assert not np.allclose(result, 100.0)

    def test_seed_reproducibility(self):
        """Same seed should produce same noise."""
        from grdl_imagej import NoiseGenerator
        image = np.full((30, 30), 100.0)
        r1 = NoiseGenerator(noise_type='gaussian', sigma=10.0, seed=7).apply(image)
        r2 = NoiseGenerator(noise_type='gaussian', sigma=10.0, seed=7).apply(image)
        np.testing.assert_array_equal(r1, r2)

    def test_rejects_non_2d(self):
        from grdl_imagej import NoiseGenerator
        ng = NoiseGenerator()
        with pytest.raises(ValueError, match="2D"):
            ng.apply(np.zeros((3, 10, 10)))


# ============================================================================
# MathOperations Tests
# ============================================================================

class TestMathOperations:
    """Tests for MathOperations."""

    def test_add(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='add', value=50.0)
        image = np.full((20, 20), 100.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 150.0)

    def test_subtract(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='subtract', value=30.0)
        image = np.full((20, 20), 100.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 70.0)

    def test_multiply(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='multiply', value=2.0)
        image = np.full((20, 20), 50.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 100.0)

    def test_log(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='log')
        image = np.full((20, 20), np.e)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 1.0, atol=1e-10)

    def test_sqrt(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='sqrt')
        image = np.full((20, 20), 25.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 5.0)

    def test_square(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='square')
        image = np.full((20, 20), 5.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 25.0)

    def test_abs(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='abs')
        image = np.full((20, 20), -42.0)
        result = mo.apply(image)
        np.testing.assert_allclose(result, 42.0)

    def test_nan_to_num(self):
        from grdl_imagej import MathOperations
        mo = MathOperations(operation='nan_to_num', nan_replacement=-1.0)
        image = np.full((20, 20), 100.0)
        image[5, 5] = np.nan
        result = mo.apply(image)
        assert result[5, 5] == -1.0
        assert result[0, 0] == 100.0

    def test_min_max(self):
        from grdl_imagej import MathOperations
        image = np.array([[10.0, 200.0], [50.0, 300.0]])
        r_min = MathOperations(operation='min', value=100.0).apply(image)
        r_max = MathOperations(operation='max', value=100.0).apply(image)
        np.testing.assert_array_equal(r_min, [[10.0, 100.0], [50.0, 100.0]])
        np.testing.assert_array_equal(r_max, [[100.0, 200.0], [100.0, 300.0]])

    def test_rejects_non_2d(self):
        from grdl_imagej import MathOperations
        mo = MathOperations()
        with pytest.raises(ValueError, match="2D"):
            mo.apply(np.zeros((3, 10, 10)))


# ============================================================================
# TypeConverter Tests
# ============================================================================

class TestTypeConverter:
    """Tests for TypeConverter."""

    def test_float_to_uint8_scaled(self):
        from grdl_imagej import TypeConverter
        tc = TypeConverter(target_type='uint8', scale=True)
        image = np.array([[0.0, 127.5], [255.0, 64.0]])
        result = tc.apply(image)
        assert result.dtype == np.uint8
        assert result[0, 0] == 0
        assert result[0, 1] == 127  # (127.5/255)*255 = 127

    def test_uint8_to_float64(self):
        from grdl_imagej import TypeConverter
        tc = TypeConverter(target_type='float64')
        image = np.array([[0, 128, 255]], dtype=np.uint8)
        result = tc.apply(image)
        assert result.dtype == np.float64

    def test_normalize_to_01(self):
        from grdl_imagej import TypeConverter
        tc = TypeConverter(target_type='float32', normalize=True)
        image = np.array([[0.0, 100.0], [200.0, 50.0]])
        result = tc.apply(image)
        assert result.dtype == np.float32
        assert result.min() >= 0.0
        assert result.max() <= 1.0

    def test_identity_conversion(self):
        from grdl_imagej import TypeConverter
        tc = TypeConverter(target_type='float64', scale=False)
        image = np.array([[1.0, 2.0], [3.0, 4.0]])
        result = tc.apply(image)
        np.testing.assert_array_equal(result, image)

    def test_rejects_non_2d(self):
        from grdl_imagej import TypeConverter
        tc = TypeConverter()
        with pytest.raises(ValueError, match="2D"):
            tc.apply(np.zeros((3, 10, 10)))


# ============================================================================
# BilateralFilter Tests
# ============================================================================

class TestBilateralFilter:
    """Tests for BilateralFilter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import BilateralFilter
        bf = BilateralFilter(sigma_spatial=2.0, sigma_range=30.0, radius=3)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = bf.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        from grdl_imagej import BilateralFilter
        bf = BilateralFilter(sigma_spatial=2.0, sigma_range=30.0, radius=3)
        flat = np.full((20, 20), 100.0)
        result = bf.apply(flat)
        np.testing.assert_allclose(result, 100.0, atol=0.01)

    def test_preserves_edge(self):
        """Bilateral filter should preserve strong edges."""
        from grdl_imagej import BilateralFilter
        bf = BilateralFilter(sigma_spatial=3.0, sigma_range=10.0, radius=5)
        image = np.zeros((30, 60))
        image[:, 30:] = 200.0
        result = bf.apply(image)
        # Far from edge, values should stay close to original
        assert abs(result[15, 5] - 0.0) < 5.0
        assert abs(result[15, 55] - 200.0) < 5.0

    def test_rejects_non_2d(self):
        from grdl_imagej import BilateralFilter
        bf = BilateralFilter()
        with pytest.raises(ValueError, match="2D"):
            bf.apply(np.zeros((3, 10, 10)))


# ============================================================================
# EntropyFilter Tests
# ============================================================================

class TestEntropyFilter:
    """Tests for EntropyFilter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import EntropyFilter
        ef = EntropyFilter(radius=2, n_bins=64)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = ef.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_flat_image_zero_entropy(self):
        from grdl_imagej import EntropyFilter
        ef = EntropyFilter(radius=3, n_bins=256)
        flat = np.full((20, 20), 100.0)
        result = ef.apply(flat)
        np.testing.assert_allclose(result, 0.0, atol=0.01)

    def test_nonnegative(self):
        from grdl_imagej import EntropyFilter
        ef = EntropyFilter(radius=2, n_bins=64)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = ef.apply(image)
        assert np.all(result >= 0.0)

    def test_rejects_non_2d(self):
        from grdl_imagej import EntropyFilter
        ef = EntropyFilter()
        with pytest.raises(ValueError, match="2D"):
            ef.apply(np.zeros((3, 10, 10)))


# ============================================================================
# KuwaharaFilter Tests
# ============================================================================

class TestKuwaharaFilter:
    """Tests for KuwaharaFilter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import KuwaharaFilter
        kf = KuwaharaFilter(radius=2)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = kf.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        from grdl_imagej import KuwaharaFilter
        kf = KuwaharaFilter(radius=2)
        flat = np.full((20, 20), 100.0)
        result = kf.apply(flat)
        np.testing.assert_allclose(result, 100.0, atol=0.01)

    def test_reduces_noise(self):
        from grdl_imagej import KuwaharaFilter
        kf = KuwaharaFilter(radius=3)
        rng = np.random.RandomState(42)
        noisy = np.full((30, 30), 100.0) + rng.randn(30, 30) * 20
        result = kf.apply(noisy)
        assert result.std() < noisy.std()

    def test_rejects_non_2d(self):
        from grdl_imagej import KuwaharaFilter
        kf = KuwaharaFilter()
        with pytest.raises(ValueError, match="2D"):
            kf.apply(np.zeros((3, 10, 10)))


# ============================================================================
# LocalBinaryPatterns Tests
# ============================================================================

class TestLocalBinaryPatterns:
    """Tests for LocalBinaryPatterns."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import LocalBinaryPatterns
        lbp = LocalBinaryPatterns(radius=1, n_neighbors=8)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = lbp.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_nonnegative_integer_codes(self):
        """LBP codes should be non-negative integers."""
        from grdl_imagej import LocalBinaryPatterns
        lbp = LocalBinaryPatterns(radius=1, n_neighbors=8, method='default')
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = lbp.apply(image)
        assert np.all(result >= 0.0)
        assert np.all(result <= 255.0)
        # Codes should be integer-valued
        np.testing.assert_array_equal(result, np.round(result))

    def test_all_methods_run(self):
        from grdl_imagej import LocalBinaryPatterns
        image = np.random.RandomState(42).rand(20, 20) * 200
        for method in ['default', 'uniform', 'rotation_invariant']:
            lbp = LocalBinaryPatterns(radius=1, n_neighbors=8, method=method)
            result = lbp.apply(image)
            assert result.shape == (20, 20), f"Failed for {method}"

    def test_rejects_non_2d(self):
        from grdl_imagej import LocalBinaryPatterns
        lbp = LocalBinaryPatterns()
        with pytest.raises(ValueError, match="2D"):
            lbp.apply(np.zeros((3, 10, 10)))


# ============================================================================
# GaborFilterBank Tests
# ============================================================================

class TestGaborFilterBank:
    """Tests for GaborFilterBank."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import GaborFilterBank
        gfb = GaborFilterBank(sigma=2.0, n_orientations=4, lambda_=8.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = gfb.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_nonnegative_output(self):
        """Max-response output should be non-negative."""
        from grdl_imagej import GaborFilterBank
        gfb = GaborFilterBank(sigma=2.0, n_orientations=4, lambda_=8.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = gfb.apply(image)
        assert np.all(result >= 0.0)

    def test_flat_image_uniform_response(self):
        """Flat image should give spatially uniform Gabor response."""
        from grdl_imagej import GaborFilterBank
        gfb = GaborFilterBank(sigma=2.0, n_orientations=4, lambda_=8.0)
        flat = np.full((30, 30), 100.0)
        result = gfb.apply(flat)
        # Interior should be spatially uniform
        assert result[5:-5, 5:-5].std() < 0.01

    def test_rejects_non_2d(self):
        from grdl_imagej import GaborFilterBank
        gfb = GaborFilterBank()
        with pytest.raises(ValueError, match="2D"):
            gfb.apply(np.zeros((3, 10, 10)))


# ============================================================================
# HarrisCornerDetector Tests
# ============================================================================

class TestHarrisCornerDetector:
    """Tests for HarrisCornerDetector."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import HarrisCornerDetector
        hcd = HarrisCornerDetector(sigma=1.0, k=0.04, threshold=0.01)
        image = np.random.RandomState(42).rand(50, 50) * 200
        result = hcd.apply(image)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_flat_image_no_corners(self):
        from grdl_imagej import HarrisCornerDetector
        hcd = HarrisCornerDetector()
        flat = np.full((40, 40), 100.0)
        result = hcd.apply(flat)
        assert result.sum() == 0.0

    def test_detects_corner(self):
        """Should detect corners of a bright square."""
        from grdl_imagej import HarrisCornerDetector
        hcd = HarrisCornerDetector(sigma=1.0, k=0.04, threshold=0.001, nms_radius=3)
        image = np.zeros((60, 60))
        image[15:45, 15:45] = 200.0
        result = hcd.apply(image)
        # Should have non-zero responses near corners of the square
        assert result.sum() > 0.0

    def test_nonnegative_output(self):
        from grdl_imagej import HarrisCornerDetector
        hcd = HarrisCornerDetector()
        image = np.random.RandomState(42).rand(40, 40) * 200
        result = hcd.apply(image)
        assert np.all(result >= 0.0)

    def test_rejects_non_2d(self):
        from grdl_imagej import HarrisCornerDetector
        hcd = HarrisCornerDetector()
        with pytest.raises(ValueError, match="2D"):
            hcd.apply(np.zeros((3, 10, 10)))


# ============================================================================
# PhaseCorrelation Tests
# ============================================================================

class TestPhaseCorrelation:
    """Tests for PhaseCorrelation."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation()
        image = np.random.RandomState(42).rand(50, 50) * 200
        ref = image.copy()
        result = pc.apply(image, reference=ref)
        assert result.shape == (50, 50)
        assert result.dtype == np.float64

    def test_zero_shift_detection(self):
        """Identical images should produce ~zero shift."""
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation(upsample_factor=1)
        image = np.random.RandomState(42).rand(50, 50) * 200
        pc.apply(image, reference=image.copy())
        dy, dx = pc.last_shift
        assert abs(dy) < 1.0
        assert abs(dx) < 1.0

    def test_detects_known_shift(self):
        """Should detect a known integer translation."""
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation(upsample_factor=1, window='none')
        rng = np.random.RandomState(42)
        ref = rng.rand(64, 64) * 200
        # Shift by (3, 5)
        shifted = np.roll(np.roll(ref, 3, axis=0), 5, axis=1)
        pc.apply(shifted, reference=ref)
        dy, dx = pc.last_shift
        assert abs(dy - 3) < 1.5
        assert abs(dx - 5) < 1.5

    def test_no_reference_returns_zeros(self):
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation()
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = pc.apply(image)
        assert result.sum() == 0.0
        assert pc.last_shift == (0.0, 0.0)

    def test_rejects_non_2d(self):
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation()
        with pytest.raises(ValueError, match="2D"):
            pc.apply(np.zeros((3, 10, 10)))

    def test_rejects_shape_mismatch(self):
        from grdl_imagej import PhaseCorrelation
        pc = PhaseCorrelation()
        with pytest.raises(ValueError, match="shape"):
            pc.apply(np.zeros((30, 30)), reference=np.zeros((20, 20)))


# ============================================================================
# ColorSpaceConverter Tests
# ============================================================================

class TestColorSpaceConverter:
    """Tests for ColorSpaceConverter."""

    def test_rgb_to_lab_shape(self):
        from grdl_imagej import ColorSpaceConverter
        csc = ColorSpaceConverter(source_space='rgb', target_space='lab')
        image = np.random.RandomState(42).rand(20, 20, 3)
        result = csc.apply(image)
        assert result.shape == (20, 20, 3)
        assert result.dtype == np.float64

    def test_identity_conversion(self):
        from grdl_imagej import ColorSpaceConverter
        csc = ColorSpaceConverter(source_space='rgb', target_space='rgb')
        image = np.random.RandomState(42).rand(20, 20, 3)
        result = csc.apply(image)
        np.testing.assert_allclose(result, image, atol=1e-10)

    def test_rgb_hsb_roundtrip(self):
        from grdl_imagej import ColorSpaceConverter
        image = np.random.RandomState(42).rand(20, 20, 3)
        hsb = ColorSpaceConverter(source_space='rgb', target_space='hsb').apply(image)
        rgb = ColorSpaceConverter(source_space='hsb', target_space='rgb').apply(hsb)
        np.testing.assert_allclose(rgb, image, atol=1e-10)

    def test_rgb_lab_roundtrip(self):
        from grdl_imagej import ColorSpaceConverter
        image = np.random.RandomState(42).rand(20, 20, 3) * 0.9 + 0.05
        lab = ColorSpaceConverter(source_space='rgb', target_space='lab').apply(image)
        rgb = ColorSpaceConverter(source_space='lab', target_space='rgb').apply(lab)
        np.testing.assert_allclose(rgb, image, atol=0.01)

    def test_rgb_ycbcr_roundtrip(self):
        from grdl_imagej import ColorSpaceConverter
        image = np.random.RandomState(42).rand(20, 20, 3) * 0.8 + 0.1
        ycbcr = ColorSpaceConverter(source_space='rgb', target_space='ycbcr').apply(image)
        rgb = ColorSpaceConverter(source_space='ycbcr', target_space='rgb').apply(ycbcr)
        np.testing.assert_allclose(rgb, image, atol=0.01)

    def test_rejects_non_3channel(self):
        from grdl_imagej import ColorSpaceConverter
        csc = ColorSpaceConverter()
        with pytest.raises(ValueError, match="3-channel"):
            csc.apply(np.zeros((20, 20)))

    def test_lab_luminance_range(self):
        """L* should be in [0, 100] for valid RGB input."""
        from grdl_imagej import ColorSpaceConverter
        csc = ColorSpaceConverter(source_space='rgb', target_space='lab')
        image = np.random.RandomState(42).rand(20, 20, 3)
        lab = csc.apply(image)
        assert lab[..., 0].min() >= -1.0  # L* ≥ 0
        assert lab[..., 0].max() <= 101.0  # L* ≤ 100


# ============================================================================
# WhiteBalance Tests
# ============================================================================

class TestWhiteBalance:
    """Tests for WhiteBalance."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import WhiteBalance
        wb = WhiteBalance(method='gray_world')
        image = np.random.RandomState(42).rand(20, 20, 3)
        result = wb.apply(image)
        assert result.shape == (20, 20, 3)
        assert result.dtype == np.float64

    def test_output_clipped(self):
        from grdl_imagej import WhiteBalance
        wb = WhiteBalance(method='gray_world')
        image = np.random.RandomState(42).rand(20, 20, 3)
        result = wb.apply(image)
        assert result.min() >= 0.0
        assert result.max() <= 1.0

    def test_gray_world_equalizes_means(self):
        """Gray world should make channel means more similar."""
        from grdl_imagej import WhiteBalance
        wb = WhiteBalance(method='gray_world')
        image = np.random.RandomState(42).rand(30, 30, 3)
        image[..., 0] *= 0.5  # Create red channel imbalance
        result = wb.apply(image)
        means = [result[..., c].mean() for c in range(3)]
        # Means should be closer together after correction
        assert max(means) - min(means) < 0.2

    def test_all_methods_run(self):
        from grdl_imagej import WhiteBalance
        image = np.random.RandomState(42).rand(20, 20, 3)
        for method in ['gray_world', 'white_patch', 'percentile']:
            wb = WhiteBalance(method=method)
            result = wb.apply(image)
            assert result.shape == (20, 20, 3), f"Failed for {method}"

    def test_rejects_non_3channel(self):
        from grdl_imagej import WhiteBalance
        wb = WhiteBalance()
        with pytest.raises(ValueError, match="3-channel"):
            wb.apply(np.zeros((20, 20)))


# ============================================================================
# GLCMHaralick Tests
# ============================================================================

class TestGLCMHaralick:
    """Tests for GLCM / Haralick Texture Features."""

    def test_output_shape_all_features(self):
        """All features mode returns (H, W, 7) stack."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='all')
        image = np.random.RandomState(42).rand(30, 30) * 255
        result = glcm.apply(image)
        assert result.shape == (30, 30, 7)
        assert result.dtype == np.float64

    def test_output_shape_single_feature(self):
        """Single feature returns (H, W) image."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='contrast')
        image = np.random.RandomState(42).rand(30, 30) * 255
        result = glcm.apply(image)
        assert result.shape == (30, 30)

    def test_energy_of_uniform_image(self):
        """Uniform image should have high energy (all co-occurrences same pair)."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='energy', n_gray_levels=32)
        flat = np.full((30, 30), 100.0)
        result = glcm.apply(flat)
        assert result[0, 0] > 0.9  # Nearly 1.0 for uniform

    def test_contrast_of_uniform_is_zero(self):
        """Uniform image should have zero contrast."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='contrast', n_gray_levels=32)
        flat = np.full((30, 30), 100.0)
        result = glcm.apply(flat)
        assert abs(result[0, 0]) < 1e-10

    def test_entropy_of_uniform_is_zero(self):
        """Uniform image should have zero entropy."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='entropy', n_gray_levels=32)
        flat = np.full((20, 20), 100.0)
        result = glcm.apply(flat)
        assert abs(result[0, 0]) < 1e-10

    def test_all_angles(self):
        """All four angles should produce a valid result."""
        from grdl_imagej import GLCMHaralick
        image = np.random.RandomState(42).rand(20, 20) * 255
        for angle in ['0', '45', '90', '135', 'all']:
            glcm = GLCMHaralick(angle=angle, features='contrast')
            result = glcm.apply(image)
            assert result.shape == (20, 20), f"Failed for angle={angle}"

    def test_rejects_non_2d(self):
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick()
        with pytest.raises(ValueError, match="2D"):
            glcm.apply(np.zeros((3, 10, 10)))

    def test_unknown_feature_raises(self):
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='nonexistent')
        with pytest.raises(ValueError, match="Unknown feature"):
            glcm.apply(np.random.RandomState(0).rand(10, 10))

    def test_multiple_features(self):
        """Comma-separated features should work."""
        from grdl_imagej import GLCMHaralick
        glcm = GLCMHaralick(features='contrast,entropy')
        image = np.random.RandomState(42).rand(20, 20) * 255
        result = glcm.apply(image)
        assert result.shape == (20, 20, 2)


# ============================================================================
# NonLocalMeans Tests
# ============================================================================

class TestNonLocalMeans:
    """Tests for Non-Local Means Denoising."""

    def test_reduces_noise(self):
        """NLM should reduce noise in homogeneous regions."""
        from grdl_imagej import NonLocalMeans
        rng = np.random.RandomState(42)
        clean = np.full((30, 30), 100.0)
        noisy = clean + rng.randn(30, 30) * 20
        nlm = NonLocalMeans(sigma=20.0, patch_radius=2, search_radius=5)
        result = nlm.apply(noisy)
        # Interior should be smoother
        assert np.std(result[5:25, 5:25]) < np.std(noisy[5:25, 5:25])

    def test_output_shape_and_dtype(self):
        from grdl_imagej import NonLocalMeans
        nlm = NonLocalMeans(sigma=10.0, patch_radius=1, search_radius=5)
        image = np.random.RandomState(42).rand(20, 20) * 255
        result = nlm.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        """A flat image should remain flat."""
        from grdl_imagej import NonLocalMeans
        nlm = NonLocalMeans(sigma=10.0, patch_radius=1, search_radius=5)
        flat = np.full((20, 20), 100.0)
        result = nlm.apply(flat)
        assert np.allclose(result, 100.0, atol=0.1)

    def test_h_parameter(self):
        """Explicit h should override sigma for filtering strength."""
        from grdl_imagej import NonLocalMeans
        rng = np.random.RandomState(42)
        noisy = np.full((20, 20), 100.0) + rng.randn(20, 20) * 30
        # Large h = stronger smoothing
        nlm_strong = NonLocalMeans(sigma=30.0, h=100.0, patch_radius=1, search_radius=5)
        nlm_weak = NonLocalMeans(sigma=30.0, h=5.0, patch_radius=1, search_radius=5)
        strong = nlm_strong.apply(noisy)
        weak = nlm_weak.apply(noisy)
        assert np.std(strong[3:17, 3:17]) < np.std(weak[3:17, 3:17])

    def test_rejects_non_2d(self):
        from grdl_imagej import NonLocalMeans
        nlm = NonLocalMeans()
        with pytest.raises(ValueError, match="2D"):
            nlm.apply(np.zeros((3, 10, 10)))


# ============================================================================
# StructureTensor Tests
# ============================================================================

class TestStructureTensor:
    """Tests for Structure Tensor / Orientation Analysis."""

    def test_output_all_shape(self):
        """'all' mode returns (H, W, 3) stack."""
        from grdl_imagej import StructureTensor
        st = StructureTensor(sigma=2.0, output='all')
        image = np.random.RandomState(42).rand(30, 30)
        result = st.apply(image)
        assert result.shape == (30, 30, 3)

    def test_output_orientation_shape(self):
        from grdl_imagej import StructureTensor
        st = StructureTensor(output='orientation')
        image = np.random.RandomState(42).rand(30, 30)
        result = st.apply(image)
        assert result.shape == (30, 30)

    def test_output_coherence_shape(self):
        from grdl_imagej import StructureTensor
        st = StructureTensor(output='coherence')
        image = np.random.RandomState(42).rand(30, 30)
        result = st.apply(image)
        assert result.shape == (30, 30)

    def test_output_energy_shape(self):
        from grdl_imagej import StructureTensor
        st = StructureTensor(output='energy')
        image = np.random.RandomState(42).rand(30, 30)
        result = st.apply(image)
        assert result.shape == (30, 30)

    def test_coherence_high_for_edges(self):
        """Step edge should produce high coherence along edge."""
        from grdl_imagej import StructureTensor
        st = StructureTensor(sigma=2.0, output='coherence')
        image = np.zeros((40, 80))
        image[:, 40:] = 200.0
        result = st.apply(image)
        # High coherence near the edge
        assert np.max(result[:, 35:45]) > 0.5

    def test_orientation_of_vertical_edge(self):
        """Vertical step edge should produce ~0 orientation (horizontal gradient)."""
        from grdl_imagej import StructureTensor
        st = StructureTensor(sigma=2.0, output='orientation')
        image = np.zeros((40, 80))
        image[:, 40:] = 200.0
        result = st.apply(image)
        # Near the edge center, orientation should be close to 0
        edge_orientations = result[15:25, 38:42]
        assert np.max(np.abs(edge_orientations)) < 0.5  # radians, near 0

    def test_flat_image_low_energy(self):
        """Flat image should produce near-zero energy."""
        from grdl_imagej import StructureTensor
        st = StructureTensor(sigma=2.0, output='energy')
        flat = np.full((30, 30), 100.0)
        result = st.apply(flat)
        assert np.max(result) < 0.01

    def test_rejects_non_2d(self):
        from grdl_imagej import StructureTensor
        st = StructureTensor()
        with pytest.raises(ValueError, match="2D"):
            st.apply(np.zeros((3, 10, 10)))


# ============================================================================
# FrangiVesselness Tests
# ============================================================================

class TestFrangiVesselness:
    """Tests for Frangi Vesselness / Tubeness Filter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=1.0, scale_max=2.0)
        image = np.random.RandomState(42).rand(30, 30) * 255
        result = fv.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_detects_dark_line(self):
        """A dark line on bright background should produce high vesselness."""
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=1.0, scale_max=3.0, black_ridges=True)
        image = np.full((50, 50), 200.0)
        image[23:27, 10:40] = 50.0  # Dark horizontal line
        result = fv.apply(image)
        # Vesselness should be high along the line
        assert result[25, 25] > 0

    def test_detects_bright_line(self):
        """A bright line on dark background should be detected with black_ridges=False."""
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=1.0, scale_max=3.0, black_ridges=False)
        image = np.zeros((50, 50))
        image[23:27, 10:40] = 200.0  # Bright horizontal line
        result = fv.apply(image)
        assert result[25, 25] > 0

    def test_flat_image_zero_vesselness(self):
        """Flat image should produce zero vesselness."""
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=1.0, scale_max=2.0)
        flat = np.full((30, 30), 100.0)
        result = fv.apply(flat)
        assert np.allclose(result, 0.0, atol=1e-10)

    def test_non_negative(self):
        """Vesselness should be non-negative."""
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=1.0, scale_max=3.0)
        image = np.random.RandomState(42).rand(30, 30) * 255
        result = fv.apply(image)
        assert np.all(result >= 0)

    def test_scale_min_gt_max_raises(self):
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness(scale_min=10.0, scale_max=2.0)
        with pytest.raises(ValueError, match="scale_min"):
            fv.apply(np.random.RandomState(0).rand(20, 20))

    def test_rejects_non_2d(self):
        from grdl_imagej import FrangiVesselness
        fv = FrangiVesselness()
        with pytest.raises(ValueError, match="2D"):
            fv.apply(np.zeros((3, 10, 10)))


# ============================================================================
# MarkerControlledWatershed Tests
# ============================================================================

class TestMarkerControlledWatershed:
    """Tests for Marker-Controlled Watershed Segmentation."""

    def test_two_markers_two_regions(self):
        """Two markers on a two-region image should produce two labeled basins."""
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed(connectivity=4, use_gradient=False)
        image = np.zeros((20, 40))
        image[:, 20:] = 200.0
        markers = np.zeros((20, 40), dtype=np.int32)
        markers[10, 5] = 1   # Left region marker
        markers[10, 35] = 2  # Right region marker
        result = mcw.apply(image, markers=markers)
        assert result.dtype == np.int32
        assert 1 in result
        assert 2 in result

    def test_output_shape(self):
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed()
        image = np.random.RandomState(42).rand(20, 20)
        markers = np.zeros((20, 20), dtype=np.int32)
        markers[5, 5] = 1
        markers[15, 15] = 2
        result = mcw.apply(image, markers=markers)
        assert result.shape == (20, 20)

    def test_single_marker_fills_all(self):
        """A single marker should fill the entire image."""
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed(connectivity=8, use_gradient=False)
        image = np.ones((15, 15))
        markers = np.zeros((15, 15), dtype=np.int32)
        markers[7, 7] = 1
        result = mcw.apply(image, markers=markers)
        assert np.all(result == 1)

    def test_gradient_mode(self):
        """use_gradient=True should still produce valid labels."""
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed(connectivity=4, use_gradient=True)
        image = np.random.RandomState(42).rand(20, 20) * 255
        markers = np.zeros((20, 20), dtype=np.int32)
        markers[3, 3] = 1
        markers[17, 17] = 2
        result = mcw.apply(image, markers=markers)
        assert 1 in result
        assert 2 in result

    def test_connectivity_8(self):
        """8-connectivity should also work."""
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed(connectivity=8)
        image = np.random.RandomState(42).rand(15, 15)
        markers = np.zeros((15, 15), dtype=np.int32)
        markers[3, 3] = 1
        markers[12, 12] = 2
        result = mcw.apply(image, markers=markers)
        assert result.shape == (15, 15)

    def test_rejects_non_2d(self):
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed()
        with pytest.raises(ValueError, match="2D"):
            mcw.apply(np.zeros((3, 10, 10)), markers=np.zeros((3, 10, 10)))

    def test_missing_markers_raises(self):
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed()
        with pytest.raises(ValueError, match="markers"):
            mcw.apply(np.zeros((10, 10)))

    def test_shape_mismatch_raises(self):
        from grdl_imagej import MarkerControlledWatershed
        mcw = MarkerControlledWatershed()
        with pytest.raises(ValueError, match="shape"):
            mcw.apply(np.zeros((10, 10)), markers=np.zeros((5, 5), dtype=np.int32))


# ============================================================================
# MorphologicalReconstruction Tests
# ============================================================================

class TestMorphologicalReconstruction:
    """Tests for Morphological Reconstruction (geodesic)."""

    def test_dilation_output_shape_and_dtype(self):
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction(type='by_dilation')
        mask = np.random.RandomState(42).rand(20, 20) * 200
        marker = mask * 0.5
        result = mr.apply(mask, marker=marker)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_dilation_bounded_by_mask(self):
        """Reconstruction by dilation should never exceed the mask."""
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction(type='by_dilation', connectivity=8)
        mask = np.random.RandomState(42).rand(15, 15) * 200
        marker = mask * 0.3
        result = mr.apply(mask, marker=marker)
        assert np.all(result <= mask + 1e-10)

    def test_erosion_bounded_by_mask(self):
        """Reconstruction by erosion should never go below the mask."""
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction(type='by_erosion', connectivity=4)
        mask = np.random.RandomState(42).rand(15, 15) * 200
        marker = mask + 50
        result = mr.apply(mask, marker=marker)
        assert np.all(result >= mask - 1e-10)

    def test_marker_equals_mask_identity(self):
        """When marker == mask, reconstruction should return mask."""
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction(type='by_dilation')
        mask = np.random.RandomState(42).rand(15, 15) * 200
        result = mr.apply(mask, marker=mask.copy())
        np.testing.assert_allclose(result, mask, atol=1e-10)

    def test_missing_marker_raises(self):
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction()
        with pytest.raises(ValueError, match="marker"):
            mr.apply(np.zeros((10, 10)))

    def test_shape_mismatch_raises(self):
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction()
        with pytest.raises(ValueError, match="shape"):
            mr.apply(np.zeros((10, 10)), marker=np.zeros((5, 5)))

    def test_rejects_non_2d(self):
        from grdl_imagej import MorphologicalReconstruction
        mr = MorphologicalReconstruction()
        with pytest.raises(ValueError, match="2D"):
            mr.apply(np.zeros((3, 10, 10)), marker=np.zeros((3, 10, 10)))


# ============================================================================
# RichardsonLucy Tests
# ============================================================================

class TestRichardsonLucy:
    """Tests for Richardson-Lucy Deconvolution."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import RichardsonLucy
        rl = RichardsonLucy(n_iterations=5)
        image = np.random.RandomState(42).rand(30, 30) * 200
        psf = np.zeros((5, 5))
        psf[2, 2] = 1.0  # Delta PSF
        result = rl.apply(image, psf=psf)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_delta_psf_identity(self):
        """Delta PSF should approximately recover the input."""
        from grdl_imagej import RichardsonLucy
        rl = RichardsonLucy(n_iterations=5)
        image = np.random.RandomState(42).rand(30, 30) * 200
        psf = np.zeros((3, 3))
        psf[1, 1] = 1.0
        result = rl.apply(image, psf=psf)
        np.testing.assert_allclose(result, image, rtol=0.1, atol=5.0)

    def test_nonnegative_output(self):
        """Output should be non-negative."""
        from grdl_imagej import RichardsonLucy
        rl = RichardsonLucy(n_iterations=10)
        image = np.random.RandomState(42).rand(20, 20) * 200
        psf = np.ones((3, 3)) / 9.0
        result = rl.apply(image, psf=psf)
        assert np.all(result >= 0.0)

    def test_missing_psf_raises(self):
        from grdl_imagej import RichardsonLucy
        rl = RichardsonLucy()
        with pytest.raises(ValueError, match="psf"):
            rl.apply(np.zeros((10, 10)))

    def test_rejects_non_2d(self):
        from grdl_imagej import RichardsonLucy
        rl = RichardsonLucy()
        with pytest.raises(ValueError, match="2D"):
            rl.apply(np.zeros((3, 10, 10)))


# ============================================================================
# WienerFilter Tests
# ============================================================================

class TestWienerFilter:
    """Tests for Wiener Filter Deconvolution."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import WienerFilter
        wf = WienerFilter(snr=10.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        psf = np.zeros((3, 3))
        psf[1, 1] = 1.0
        result = wf.apply(image, psf=psf)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_delta_psf_identity(self):
        """Delta PSF with high SNR should recover input."""
        from grdl_imagej import WienerFilter
        wf = WienerFilter(snr=100.0, clip_negative=False)
        image = np.random.RandomState(42).rand(32, 32) * 200
        psf = np.zeros((3, 3))
        psf[1, 1] = 1.0
        result = wf.apply(image, psf=psf)
        np.testing.assert_allclose(result, image, rtol=0.05, atol=5.0)

    def test_clip_negative(self):
        """clip_negative=True should have no negative values."""
        from grdl_imagej import WienerFilter
        wf = WienerFilter(snr=5.0, clip_negative=True)
        image = np.random.RandomState(42).rand(20, 20) * 200
        psf = np.ones((3, 3)) / 9.0
        result = wf.apply(image, psf=psf)
        assert np.all(result >= 0.0)

    def test_missing_psf_raises(self):
        from grdl_imagej import WienerFilter
        wf = WienerFilter()
        with pytest.raises(ValueError, match="psf"):
            wf.apply(np.zeros((10, 10)))

    def test_rejects_non_2d(self):
        from grdl_imagej import WienerFilter
        wf = WienerFilter()
        with pytest.raises(ValueError, match="2D"):
            wf.apply(np.zeros((3, 10, 10)))


# ============================================================================
# HoughTransform Tests
# ============================================================================

class TestHoughTransform:
    """Tests for Hough Transform."""

    def test_lines_output_shape(self):
        from grdl_imagej import HoughTransform
        ht = HoughTransform(mode='lines', theta_resolution=1.0)
        edge = np.zeros((50, 50))
        edge[25, :] = 1.0  # Horizontal line
        result = ht.apply(edge)
        assert result.ndim == 2
        assert result.shape[1] == 180  # 180 theta bins at 1 deg resolution

    def test_lines_detects_horizontal_line(self):
        """Horizontal line should produce accumulator peak."""
        from grdl_imagej import HoughTransform
        ht = HoughTransform(mode='lines', threshold=0.3)
        edge = np.zeros((50, 50))
        edge[25, 5:45] = 1.0
        result = ht.apply(edge)
        assert result.max() > 0

    def test_circles_output_shape(self):
        from grdl_imagej import HoughTransform
        ht = HoughTransform(mode='circles', min_radius=5, max_radius=10)
        edge = np.zeros((30, 30))
        result = ht.apply(edge)
        assert result.shape == (30, 30)

    def test_circles_detects_circle(self):
        """Circle edge image should produce accumulator peak at center."""
        from grdl_imagej import HoughTransform
        ht = HoughTransform(mode='circles', min_radius=8, max_radius=12)
        edge = np.zeros((50, 50))
        # Draw a circle edge
        for angle in np.linspace(0, 2 * np.pi, 100):
            y = int(25 + 10 * np.sin(angle))
            x = int(25 + 10 * np.cos(angle))
            if 0 <= y < 50 and 0 <= x < 50:
                edge[y, x] = 1.0
        result = ht.apply(edge)
        # Peak should be near center (25, 25)
        assert result[23:28, 23:28].max() > 0

    def test_rejects_non_2d(self):
        from grdl_imagej import HoughTransform
        ht = HoughTransform()
        with pytest.raises(ValueError, match="2D"):
            ht.apply(np.zeros((3, 10, 10)))


# ============================================================================
# RidgeDetection Tests
# ============================================================================

class TestRidgeDetection:
    """Tests for Ridge Detection (Steger's algorithm)."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import RidgeDetection
        rd = RidgeDetection(sigma=2.0, lower_threshold=1.0, upper_threshold=3.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = rd.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_binary_output(self):
        """Output should be binary (0 or 1)."""
        from grdl_imagej import RidgeDetection
        rd = RidgeDetection(sigma=2.0, lower_threshold=1.0, upper_threshold=3.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = rd.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_detects_bright_line(self):
        """Should detect a bright thin line."""
        from grdl_imagej import RidgeDetection
        rd = RidgeDetection(sigma=1.5, lower_threshold=0.5, upper_threshold=1.0,
                            darkline=False)
        image = np.zeros((50, 50))
        image[25, 10:40] = 200.0  # Thin bright line
        result = rd.apply(image)
        assert result[25, 25] == 1.0 or result.sum() > 0

    def test_flat_image_no_ridges(self):
        """Flat image should have no ridges."""
        from grdl_imagej import RidgeDetection
        rd = RidgeDetection(sigma=2.0, lower_threshold=1.0, upper_threshold=3.0)
        flat = np.full((30, 30), 100.0)
        result = rd.apply(flat)
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import RidgeDetection
        rd = RidgeDetection()
        with pytest.raises(ValueError, match="2D"):
            rd.apply(np.zeros((3, 10, 10)))


# ============================================================================
# SlidingParaboloid Tests
# ============================================================================

class TestSlidingParaboloid:
    """Tests for Sliding Paraboloid Background Subtraction."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import SlidingParaboloid
        sp = SlidingParaboloid(radius=20.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = sp.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_nonnegative_output(self):
        """Background-subtracted result should be non-negative."""
        from grdl_imagej import SlidingParaboloid
        sp = SlidingParaboloid(radius=20.0)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = sp.apply(image)
        assert np.all(result >= -1e-10)

    def test_flat_image_near_zero(self):
        """Flat image after background subtraction should be near zero."""
        from grdl_imagej import SlidingParaboloid
        sp = SlidingParaboloid(radius=20.0)
        flat = np.full((30, 30), 100.0)
        result = sp.apply(flat)
        assert np.max(result) < 1.0

    def test_corrects_gradient(self):
        """Should flatten a linear gradient."""
        from grdl_imagej import SlidingParaboloid
        sp = SlidingParaboloid(radius=30.0)
        rows, cols = 60, 60
        gradient = np.tile(np.linspace(50, 200, cols), (rows, 1))
        result = sp.apply(gradient)
        assert result.std() < gradient.std()

    def test_rejects_non_2d(self):
        from grdl_imagej import SlidingParaboloid
        sp = SlidingParaboloid()
        with pytest.raises(ValueError, match="2D"):
            sp.apply(np.zeros((3, 10, 10)))


# ============================================================================
# ExtendedMinMax Tests
# ============================================================================

class TestExtendedMinMax:
    """Tests for Extended Min/Max and H-Minima/Maxima."""

    def test_h_minima_output_shape(self):
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax(type='h_minima', h=5.0)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = emm.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_h_maxima_output_shape(self):
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax(type='h_maxima', h=5.0)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = emm.apply(image)
        assert result.shape == (20, 20)

    def test_h_minima_suppresses_shallow_minima(self):
        """H-minima should fill in minima shallower than h."""
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax(type='h_minima', h=30.0, connectivity=8)
        image = np.full((20, 20), 100.0)
        image[10, 10] = 80.0  # Shallow minimum (depth=20 < h=30)
        result = emm.apply(image)
        # Shallow minimum should be filled
        assert result[10, 10] > 80.0

    def test_regional_minima_binary(self):
        """Regional minima output should be binary."""
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax(type='regional_minima')
        image = np.random.RandomState(42).rand(15, 15) * 200
        result = emm.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_regional_maxima_binary(self):
        """Regional maxima output should be binary."""
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax(type='regional_maxima')
        image = np.random.RandomState(42).rand(15, 15) * 200
        result = emm.apply(image)
        unique = set(np.unique(result))
        assert unique.issubset({0.0, 1.0})

    def test_rejects_non_2d(self):
        from grdl_imagej import ExtendedMinMax
        emm = ExtendedMinMax()
        with pytest.raises(ValueError, match="2D"):
            emm.apply(np.zeros((3, 10, 10)))


# ============================================================================
# FFTCustomFilter Tests
# ============================================================================

class TestFFTCustomFilter:
    """Tests for FFT Custom Filter."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import FFTCustomFilter
        filt = FFTCustomFilter(window='none', pad_to_power_of_2=False)
        image = np.random.RandomState(42).rand(30, 30) * 200
        mask = np.ones((30, 30))
        result = filt.apply(image, mask=mask)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_identity_mask(self):
        """All-ones mask should approximately preserve the image."""
        from grdl_imagej import FFTCustomFilter
        filt = FFTCustomFilter(window='none', pad_to_power_of_2=False)
        image = np.random.RandomState(42).rand(32, 32) * 200
        mask = np.ones((32, 32))
        result = filt.apply(image, mask=mask)
        np.testing.assert_allclose(result, image, atol=1e-8)

    def test_zero_mask_gives_zero(self):
        """All-zeros mask should give zero output."""
        from grdl_imagej import FFTCustomFilter
        filt = FFTCustomFilter(window='none', pad_to_power_of_2=False)
        image = np.random.RandomState(42).rand(32, 32) * 200
        mask = np.zeros((32, 32))
        result = filt.apply(image, mask=mask)
        np.testing.assert_allclose(result, 0.0, atol=1e-8)

    def test_window_types_run(self):
        """All window types should run without error."""
        from grdl_imagej import FFTCustomFilter
        image = np.random.RandomState(42).rand(16, 16) * 200
        mask = np.ones((16, 16))
        for win in ['none', 'hanning', 'hamming', 'blackman']:
            filt = FFTCustomFilter(window=win, pad_to_power_of_2=False)
            result = filt.apply(image, mask=mask)
            assert result.shape == (16, 16), f"Failed for window={win}"

    def test_missing_mask_raises(self):
        from grdl_imagej import FFTCustomFilter
        filt = FFTCustomFilter()
        with pytest.raises(ValueError, match="mask"):
            filt.apply(np.zeros((10, 10)))

    def test_rejects_non_2d(self):
        from grdl_imagej import FFTCustomFilter
        filt = FFTCustomFilter()
        with pytest.raises(ValueError, match="2D"):
            filt.apply(np.zeros((3, 10, 10)))


# ============================================================================
# TemplateMatching Tests
# ============================================================================

class TestTemplateMatching:
    """Tests for Template Matching."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching(method='zncc')
        image = np.random.RandomState(42).rand(30, 30) * 200
        template = image[10:15, 10:15].copy()
        result = tm.apply(image, template=template)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_self_match_peak(self):
        """Template from the image should produce a peak at the right location."""
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching(method='zncc')
        rng = np.random.RandomState(42)
        image = rng.rand(40, 40) * 200
        template = image[15:20, 15:20].copy()
        result = tm.apply(image, template=template)
        # Peak should be near (17, 17) - center of template region
        peak_pos = np.unravel_index(np.argmax(result), result.shape)
        assert abs(peak_pos[0] - 17) < 3
        assert abs(peak_pos[1] - 17) < 3

    def test_ssd_method_runs(self):
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching(method='ssd')
        image = np.random.RandomState(42).rand(30, 30) * 200
        template = np.random.RandomState(0).rand(5, 5) * 200
        result = tm.apply(image, template=template)
        assert result.shape == (30, 30)

    def test_ncc_method_runs(self):
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching(method='ncc')
        image = np.random.RandomState(42).rand(30, 30) * 200
        template = np.random.RandomState(0).rand(5, 5) * 200
        result = tm.apply(image, template=template)
        assert result.shape == (30, 30)

    def test_missing_template_raises(self):
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching()
        with pytest.raises(ValueError, match="template"):
            tm.apply(np.zeros((10, 10)))

    def test_rejects_non_2d(self):
        from grdl_imagej import TemplateMatching
        tm = TemplateMatching()
        with pytest.raises(ValueError, match="2D"):
            tm.apply(np.zeros((3, 10, 10)))


# ============================================================================
# MorphologicalGradient Tests
# ============================================================================

class TestMorphologicalGradient:
    """Tests for Morphological Gradient."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient(type='beucher', se_radius=1)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = mg.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_flat_image_zero_gradient(self):
        """Flat image should have zero morphological gradient."""
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient(type='beucher', se_radius=1)
        flat = np.full((20, 20), 100.0)
        result = mg.apply(flat)
        np.testing.assert_allclose(result, 0.0, atol=1e-10)

    def test_beucher_nonnegative(self):
        """Beucher gradient (dilate - erode) should be non-negative."""
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient(type='beucher', se_radius=1)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = mg.apply(image)
        assert np.all(result >= -1e-10)

    def test_internal_gradient(self):
        """Internal gradient = original - eroded, should be non-negative."""
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient(type='internal', se_radius=1)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = mg.apply(image)
        assert np.all(result >= -1e-10)

    def test_external_gradient(self):
        """External gradient = dilated - original, should be non-negative."""
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient(type='external', se_radius=1)
        image = np.random.RandomState(42).rand(20, 20) * 200
        result = mg.apply(image)
        assert np.all(result >= -1e-10)

    def test_beucher_equals_internal_plus_external(self):
        """Beucher = internal + external gradient."""
        from grdl_imagej import MorphologicalGradient
        image = np.random.RandomState(42).rand(20, 20) * 200
        b = MorphologicalGradient(type='beucher', se_radius=1).apply(image)
        i = MorphologicalGradient(type='internal', se_radius=1).apply(image)
        e = MorphologicalGradient(type='external', se_radius=1).apply(image)
        np.testing.assert_allclose(b, i + e, atol=1e-10)

    def test_rejects_non_2d(self):
        from grdl_imagej import MorphologicalGradient
        mg = MorphologicalGradient()
        with pytest.raises(ValueError, match="2D"):
            mg.apply(np.zeros((3, 10, 10)))


# ============================================================================
# MorphologicalLaplacian Tests
# ============================================================================

class TestMorphologicalLaplacian:
    """Tests for Morphological Laplacian."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import MorphologicalLaplacian
        ml = MorphologicalLaplacian(se_radius=1)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = ml.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_flat_image_zero(self):
        """Flat image should have zero Laplacian."""
        from grdl_imagej import MorphologicalLaplacian
        ml = MorphologicalLaplacian(se_radius=1)
        flat = np.full((20, 20), 100.0)
        result = ml.apply(flat)
        np.testing.assert_allclose(result, 0.0, atol=1e-10)

    def test_edge_response(self):
        """Should have non-zero response at step edges."""
        from grdl_imagej import MorphologicalLaplacian
        ml = MorphologicalLaplacian(se_radius=1)
        image = np.zeros((30, 60))
        image[:, 30:] = 200.0
        result = ml.apply(image)
        # Edge region should have non-zero Laplacian
        assert np.max(np.abs(result[:, 28:32])) > 0

    def test_rejects_non_2d(self):
        from grdl_imagej import MorphologicalLaplacian
        ml = MorphologicalLaplacian()
        with pytest.raises(ValueError, match="2D"):
            ml.apply(np.zeros((3, 10, 10)))


# ============================================================================
# DirectionalFilter Tests
# ============================================================================

class TestDirectionalFilter:
    """Tests for Directional Morphological Filtering."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import DirectionalFilter
        df = DirectionalFilter(n_directions=4, line_length=5, operation='opening')
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = df.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_flat_image_unchanged(self):
        """Flat image should be unchanged by opening."""
        from grdl_imagej import DirectionalFilter
        df = DirectionalFilter(n_directions=4, line_length=5, operation='opening')
        flat = np.full((20, 20), 100.0)
        result = df.apply(flat)
        np.testing.assert_allclose(result, 100.0, atol=0.01)

    def test_all_operations_run(self):
        from grdl_imagej import DirectionalFilter
        image = np.random.RandomState(42).rand(20, 20) * 200
        for op in ['opening', 'closing', 'erosion', 'dilation']:
            df = DirectionalFilter(n_directions=4, line_length=5, operation=op)
            result = df.apply(image)
            assert result.shape == (20, 20), f"Failed for {op}"

    def test_all_combinations_run(self):
        from grdl_imagej import DirectionalFilter
        image = np.random.RandomState(42).rand(20, 20) * 200
        for combo in ['max', 'mean', 'median']:
            df = DirectionalFilter(n_directions=4, line_length=5, combination=combo)
            result = df.apply(image)
            assert result.shape == (20, 20), f"Failed for {combo}"

    def test_rejects_non_2d(self):
        from grdl_imagej import DirectionalFilter
        df = DirectionalFilter()
        with pytest.raises(ValueError, match="2D"):
            df.apply(np.zeros((3, 10, 10)))


# ============================================================================
# KillBorders Tests
# ============================================================================

class TestKillBorders:
    """Tests for Kill Borders (remove border-touching components)."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import KillBorders
        kb = KillBorders(connectivity=8)
        image = np.zeros((20, 20))
        image[5:15, 5:15] = 1.0
        result = kb.apply(image)
        assert result.shape == (20, 20)
        assert result.dtype == np.float64

    def test_keeps_interior_object(self):
        """Object not touching border should be preserved."""
        from grdl_imagej import KillBorders
        kb = KillBorders(connectivity=8)
        image = np.zeros((20, 20))
        image[5:15, 5:15] = 200.0  # Interior object
        result = kb.apply(image)
        assert result[10, 10] > 0

    def test_removes_border_object(self):
        """Object touching the border should be removed."""
        from grdl_imagej import KillBorders
        kb = KillBorders(connectivity=8)
        image = np.zeros((20, 20))
        image[0:5, 0:5] = 200.0  # Touches top-left corner
        result = kb.apply(image)
        assert result[2, 2] == 0.0

    def test_empty_image_unchanged(self):
        from grdl_imagej import KillBorders
        kb = KillBorders()
        result = kb.apply(np.zeros((15, 15)))
        assert result.sum() == 0.0

    def test_rejects_non_2d(self):
        from grdl_imagej import KillBorders
        kb = KillBorders()
        with pytest.raises(ValueError, match="2D"):
            kb.apply(np.zeros((3, 10, 10)))


# ============================================================================
# Granulometry Tests
# ============================================================================

class TestGranulometry:
    """Tests for Granulometry (size distribution)."""

    def test_output_shape(self):
        from grdl_imagej import Granulometry
        g = Granulometry(max_radius=5, step=1)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = g.apply(image)
        assert result.ndim == 2
        assert result.shape[0] == 1
        assert result.shape[1] == 5  # radii 1,2,3,4,5

    def test_distribution_sums_to_one(self):
        """Size distribution should sum to approximately 1."""
        from grdl_imagej import Granulometry
        g = Granulometry(max_radius=5, step=1)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = g.apply(image)
        assert abs(result.sum() - 1.0) < 0.01

    def test_nonnegative_distribution(self):
        from grdl_imagej import Granulometry
        g = Granulometry(max_radius=5, step=1)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = g.apply(image)
        assert np.all(result >= -1e-10)

    def test_step_parameter(self):
        """Step=2 should produce half as many bins."""
        from grdl_imagej import Granulometry
        g1 = Granulometry(max_radius=10, step=1)
        g2 = Granulometry(max_radius=10, step=2)
        image = np.random.RandomState(42).rand(30, 30) * 200
        r1 = g1.apply(image)
        r2 = g2.apply(image)
        assert r1.shape[1] == 10
        assert r2.shape[1] == 5

    def test_rejects_non_2d(self):
        from grdl_imagej import Granulometry
        g = Granulometry()
        with pytest.raises(ValueError, match="2D"):
            g.apply(np.zeros((3, 10, 10)))


# ============================================================================
# ROFDenoise Tests
# ============================================================================

class TestROFDenoise:
    """Tests for ROF Total Variation Denoising."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import ROFDenoise
        rof = ROFDenoise(lambda_=0.1, n_iterations=20)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = rof.apply(image)
        assert result.shape == (30, 30)
        assert result.dtype == np.float64

    def test_reduces_noise(self):
        """ROF should reduce noise in flat regions."""
        from grdl_imagej import ROFDenoise
        rof = ROFDenoise(lambda_=0.05, n_iterations=50, dt=0.125)
        rng = np.random.RandomState(42)
        clean = np.full((40, 40), 100.0)
        noisy = clean + rng.randn(40, 40) * 20
        result = rof.apply(noisy)
        assert np.std(result[5:35, 5:35]) < np.std(noisy[5:35, 5:35])

    def test_flat_image_unchanged(self):
        """Flat image should remain flat."""
        from grdl_imagej import ROFDenoise
        rof = ROFDenoise(lambda_=0.1, n_iterations=50)
        flat = np.full((20, 20), 100.0)
        result = rof.apply(flat)
        np.testing.assert_allclose(result, 100.0, atol=1.0)

    def test_rejects_non_2d(self):
        from grdl_imagej import ROFDenoise
        rof = ROFDenoise()
        with pytest.raises(ValueError, match="2D"):
            rof.apply(np.zeros((3, 10, 10)))


# ============================================================================
# ColorDeconvolution Tests
# ============================================================================

class TestColorDeconvolution:
    """Tests for Color Deconvolution / Spectral Unmixing."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import ColorDeconvolution
        cd = ColorDeconvolution(stain_preset='H_E')
        image = np.random.RandomState(42).rand(20, 20, 3) * 200
        result = cd.apply(image)
        assert result.shape == (20, 20, 3)
        assert result.dtype == np.float64

    def test_custom_matrix(self):
        """Custom stain matrix should work."""
        from grdl_imagej import ColorDeconvolution
        cd = ColorDeconvolution(stain_preset='custom')
        matrix = np.eye(3)
        image = np.random.RandomState(42).rand(20, 20, 3) * 200
        result = cd.apply(image, stain_matrix=matrix)
        assert result.shape == (20, 20, 3)

    def test_all_presets_run(self):
        from grdl_imagej import ColorDeconvolution
        image = np.random.RandomState(42).rand(15, 15, 3) * 200
        for preset in ['H_E', 'H_DAB', 'FastRed_FastBlue']:
            cd = ColorDeconvolution(stain_preset=preset)
            result = cd.apply(image)
            assert result.shape == (15, 15, 3), f"Failed for {preset}"

    def test_missing_custom_matrix_raises(self):
        from grdl_imagej import ColorDeconvolution
        cd = ColorDeconvolution(stain_preset='custom')
        with pytest.raises(ValueError, match="stain_matrix"):
            cd.apply(np.random.RandomState(0).rand(10, 10, 3))

    def test_rejects_non_3channel(self):
        from grdl_imagej import ColorDeconvolution
        cd = ColorDeconvolution(stain_preset='H_E')
        with pytest.raises(ValueError, match="3-channel"):
            cd.apply(np.zeros((20, 20)))


# ============================================================================
# TamuraTexture Tests
# ============================================================================

class TestTamuraTexture:
    """Tests for Tamura Texture Features."""

    def test_output_shape_and_dtype(self):
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture(n_scales=3)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = tt.apply(image)
        assert result.shape == (30, 30, 3)
        assert result.dtype == np.float64

    def test_uniform_values(self):
        """Global features should be spatially uniform."""
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture(n_scales=3)
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = tt.apply(image)
        for c in range(3):
            assert result[:, :, c].std() < 1e-10

    def test_coarseness_nonnegative(self):
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture()
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = tt.apply(image)
        assert result[0, 0, 0] >= 0  # Coarseness

    def test_contrast_zero_for_uniform(self):
        """Uniform image should have zero contrast."""
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture()
        flat = np.full((30, 30), 100.0)
        result = tt.apply(flat)
        assert result[0, 0, 1] == 0.0  # Contrast

    def test_directionality_in_range(self):
        """Directionality should be in [0, 1]."""
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture()
        image = np.random.RandomState(42).rand(30, 30) * 200
        result = tt.apply(image)
        assert 0.0 <= result[0, 0, 2] <= 1.0

    def test_rejects_non_2d(self):
        from grdl_imagej import TamuraTexture
        tt = TamuraTexture()
        with pytest.raises(ValueError, match="2D"):
            tt.apply(np.zeros((3, 10, 10)))
