"""
Constant False Alarm Rate (CFAR) detection algorithms.
CFAR algorithms maintain a constant probability of false alarm by adaptively
setting detection thresholds based on local noise estimates. These are
essential for radar signal processing where the noise environment varies.
Functions
---------
- cfar_ca: Cell-Averaging CFAR
- cfar_go: Greatest-Of CFAR
- cfar_so: Smallest-Of CFAR
- cfar_os: Order-Statistic CFAR
- cfar_2d: Two-dimensional CFAR
- threshold_factor: Compute CFAR threshold multiplier
- detection_probability: Compute detection probability
References
----------
- Richards, M. A. (2014). Fundamentals of Radar Signal Processing
(2nd ed.). McGraw-Hill.
- Rohling, H. (1983). Radar CFAR thresholding in clutter and multiple
target situations. IEEE Transactions on Aerospace and Electronic
Systems, 19(4), 608-621.
"""
from math import comb
from typing import Any, Callable, NamedTuple, Optional
import numpy as np
from numba import njit, prange
from numpy.typing import ArrayLike, NDArray
from scipy.optimize import brentq
# =============================================================================
# Result Types
# =============================================================================
[docs]
class CFARResult(NamedTuple):
"""
Result of 1D CFAR detection.
Attributes
----------
detections : ndarray
Boolean array indicating detections.
threshold : ndarray
Adaptive threshold values.
detection_indices : ndarray
Indices of detection points.
noise_estimate : ndarray
Estimated noise level at each cell.
"""
detections: NDArray[np.bool_]
threshold: NDArray[np.floating]
detection_indices: NDArray[np.intp]
noise_estimate: NDArray[np.floating]
[docs]
class CFARResult2D(NamedTuple):
"""
Result of 2D CFAR detection.
Attributes
----------
detections : ndarray
2D boolean array indicating detections.
threshold : ndarray
2D adaptive threshold values.
noise_estimate : ndarray
2D estimated noise level.
"""
detections: NDArray[np.bool_]
threshold: NDArray[np.floating]
noise_estimate: NDArray[np.floating]
# =============================================================================
# Threshold Factor Computation
# =============================================================================
def _pfa_ca(alpha: float, n: int) -> float:
"""Exact Pfa for CA-CFAR with n reference cells (exponential noise)."""
return float((1.0 + alpha / n) ** (-n))
def _pfa_so(alpha: float, n: int) -> float:
"""Exact Pfa for SO-CFAR with n cells per half window (Gandhi & Kassam 1988)."""
r = 2.0 + alpha / n
return float(2.0 * sum(comb(n + j - 1, j) * r ** (-(n + j)) for j in range(n)))
def _pfa_go(alpha: float, n: int) -> float:
"""Exact Pfa for GO-CFAR with n cells per half window (Gandhi & Kassam 1988)."""
return 2.0 * _pfa_ca(alpha, n) - _pfa_so(alpha, n)
def _pfa_os(alpha: float, n: int, k: int) -> float:
"""Exact Pfa for OS-CFAR using the k-th order statistic of n cells (Rohling 1983)."""
p = 1.0
for i in range(k):
p *= (n - i) / (n - i + alpha)
return p
def _solve_alpha(pfa_func: Callable[[float], float], pfa: float) -> float:
"""Solve pfa_func(alpha) = pfa for alpha (pfa_func monotone decreasing)."""
hi = 1.0
while pfa_func(hi) > pfa:
hi *= 2.0
if hi > 1e12:
break
return float(brentq(lambda a: pfa_func(a) - pfa, 0.0, hi))
[docs]
def threshold_factor(
pfa: float,
n_ref: int,
method: str = "ca",
k: Optional[int] = None,
) -> float:
"""
Compute the CFAR threshold multiplier for a given probability of false alarm.
Parameters
----------
pfa : float
Desired probability of false alarm (0 < pfa < 1).
n_ref : int
Total number of reference cells. For GO/SO-CFAR each half window
contains n_ref // 2 cells.
method : {'ca', 'go', 'so', 'os'}, optional
CFAR method. Default is 'ca'.
k : int, optional
Order statistic index for OS-CFAR (1 <= k <= n_ref).
Returns
-------
alpha : float
Threshold multiplier.
Examples
--------
>>> alpha = threshold_factor(1e-6, 32, method='ca')
>>> alpha > 1
True
Notes
-----
For CA-CFAR with n_ref reference cells, the relationship between
threshold factor alpha and Pfa is::
Pfa = (1 + alpha/n_ref)^(-n_ref)
Solving for alpha::
alpha = n_ref * (Pfa^(-1/n_ref) - 1)
For GO/SO-CFAR the exact expressions from Gandhi & Kassam (1988) are
solved numerically. For OS-CFAR the exact Rohling (1983) relation::
Pfa = prod_{i=0}^{k-1} (n_ref - i) / (n_ref - i + alpha)
is solved numerically.
"""
if pfa <= 0 or pfa >= 1:
raise ValueError("pfa must be between 0 and 1")
if n_ref < 1:
raise ValueError("n_ref must be at least 1")
if method == "ca":
# CA-CFAR threshold factor (exact closed form)
alpha = n_ref * (pfa ** (-1.0 / n_ref) - 1)
elif method == "go" or method == "so":
# Exact GO/SO-CFAR threshold with n_ref // 2 cells per half window
n_half = max(1, n_ref // 2)
pfa_func = _pfa_go if method == "go" else _pfa_so
alpha = _solve_alpha(lambda a: pfa_func(a, n_half), pfa)
elif method == "os":
if k is None:
k = int(0.75 * n_ref) # Default: 75th percentile
k = max(1, min(int(k), n_ref))
# Exact OS-CFAR threshold from Rohling (1983)
alpha = _solve_alpha(lambda a: _pfa_os(a, n_ref, k), pfa)
else:
raise ValueError(f"Unknown method: {method}")
return float(alpha)
[docs]
def detection_probability(
snr: float,
pfa: float,
n_ref: int,
method: str = "ca",
) -> float:
"""
Compute Swerling 1 detection probability for a given SNR and Pfa.
This is the CA-CFAR result for an exponentially fluctuating (Swerling 1)
target::
Pd = (1 + alpha/(n_ref*(1+snr)))^(-n_ref)
with ``alpha`` the threshold factor for the requested Pfa.
Parameters
----------
snr : float
Signal-to-noise ratio (linear, not dB).
pfa : float
Probability of false alarm.
n_ref : int
Number of reference cells.
method : {'ca'}, optional
CFAR method. Default is 'ca'.
Returns
-------
pd : float
Probability of detection for a Swerling 1 target.
Examples
--------
>>> pd = detection_probability(snr=10, pfa=1e-6, n_ref=32)
>>> 0 < pd < 1
True
Notes
-----
Only the Swerling 1 model is implemented. This function used to accept a
``swerling_case`` argument covering cases 0 through 4, but all of its
branches evaluated the same expression, so the argument selected nothing --
a caller asking for a non-fluctuating target got the Swerling 1 answer
(gh-20). The argument has been removed rather than left to imply a choice
that was never offered.
The difference is not small. At SNR 10, Pfa 1e-6 and 32 reference cells,
this returns about 0.62, while a genuinely non-fluctuating (Swerling 0 /
Marcum) target detects with probability about 0.90 -- the fluctuating model
understates a steady target substantially.
For a real choice of target model use ``swerling_detection_probability``,
which implements cases 0 through 4 as genuinely distinct expressions built
on the Marcum Q function. It is a pulse-integration model rather than a
CFAR one, so it answers a slightly different question: this function
accounts for threshold estimation from ``n_ref`` reference cells, that one
for coherent integration over ``n_pulses``.
See Also
--------
pytcl.mathematical_functions.special_functions.swerling_detection_probability :
Detection probability for Swerling cases 0-4.
"""
alpha = threshold_factor(pfa, n_ref, method=method)
pd = (1 + alpha / (n_ref * (1 + snr))) ** (-n_ref)
return float(pd)
# =============================================================================
# JIT-Compiled Kernels
# =============================================================================
@njit(cache=True, fastmath=True)
def _cfar_ca_kernel(
signal: np.ndarray[Any, Any],
guard_cells: int,
ref_cells: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled CA-CFAR kernel."""
n = len(signal)
half_window = guard_cells + ref_cells
for i in range(n):
left_start = max(0, i - half_window)
left_end = max(0, i - guard_cells)
right_start = min(n, i + guard_cells + 1)
right_end = min(n, i + half_window + 1)
ref_sum = 0.0
n_cells = 0
for j in range(left_start, left_end):
ref_sum += signal[j]
n_cells += 1
for j in range(right_start, right_end):
ref_sum += signal[j]
n_cells += 1
if n_cells > 0:
noise_estimate[i] = ref_sum / n_cells
else:
noise_estimate[i] = 0.0
threshold[i] = alpha * noise_estimate[i]
@njit(cache=True, fastmath=True)
def _cfar_go_kernel(
signal: np.ndarray[Any, Any],
guard_cells: int,
ref_cells: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled GO-CFAR kernel."""
n = len(signal)
half_window = guard_cells + ref_cells
for i in range(n):
left_start = max(0, i - half_window)
left_end = max(0, i - guard_cells)
right_start = min(n, i + guard_cells + 1)
right_end = min(n, i + half_window + 1)
left_sum = 0.0
left_count = 0
for j in range(left_start, left_end):
left_sum += signal[j]
left_count += 1
right_sum = 0.0
right_count = 0
for j in range(right_start, right_end):
right_sum += signal[j]
right_count += 1
left_avg = left_sum / left_count if left_count > 0 else 0.0
right_avg = right_sum / right_count if right_count > 0 else 0.0
noise_estimate[i] = max(left_avg, right_avg)
threshold[i] = alpha * noise_estimate[i]
@njit(cache=True, fastmath=True)
def _cfar_so_kernel(
signal: np.ndarray[Any, Any],
guard_cells: int,
ref_cells: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled SO-CFAR kernel."""
n = len(signal)
half_window = guard_cells + ref_cells
for i in range(n):
left_start = max(0, i - half_window)
left_end = max(0, i - guard_cells)
right_start = min(n, i + guard_cells + 1)
right_end = min(n, i + half_window + 1)
left_sum = 0.0
left_count = 0
for j in range(left_start, left_end):
left_sum += signal[j]
left_count += 1
right_sum = 0.0
right_count = 0
for j in range(right_start, right_end):
right_sum += signal[j]
right_count += 1
left_avg = left_sum / left_count if left_count > 0 else np.inf
right_avg = right_sum / right_count if right_count > 0 else np.inf
noise_est = min(left_avg, right_avg)
if noise_est == np.inf:
noise_est = 0.0
noise_estimate[i] = noise_est
threshold[i] = alpha * noise_estimate[i]
@njit(cache=True, fastmath=True)
def _cfar_os_kernel(
signal: np.ndarray[Any, Any],
guard_cells: int,
ref_cells: int,
k: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled OS-CFAR kernel."""
n = len(signal)
half_window = guard_cells + ref_cells
max_ref = 2 * ref_cells + 4 # Buffer for edge cells
for i in range(n):
left_start = max(0, i - half_window)
left_end = max(0, i - guard_cells)
right_start = min(n, i + guard_cells + 1)
right_end = min(n, i + half_window + 1)
# Collect reference cells into temporary array
ref_buffer = np.empty(max_ref, dtype=np.float64)
n_cells = 0
for j in range(left_start, left_end):
if n_cells < max_ref:
ref_buffer[n_cells] = signal[j]
n_cells += 1
for j in range(right_start, right_end):
if n_cells < max_ref:
ref_buffer[n_cells] = signal[j]
n_cells += 1
if n_cells > 0:
# Sort the reference cells
ref_values = ref_buffer[:n_cells]
ref_values.sort()
k_index = min(k - 1, n_cells - 1)
noise_estimate[i] = ref_values[k_index]
else:
noise_estimate[i] = 0.0
threshold[i] = alpha * noise_estimate[i]
@njit(cache=True, fastmath=True, parallel=True)
def _cfar_2d_ca_kernel(
image: np.ndarray[Any, Any],
guard_rows: int,
guard_cols: int,
ref_rows: int,
ref_cols: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled 2D CA-CFAR kernel with parallel execution."""
n_rows, n_cols = image.shape
half_row = guard_rows + ref_rows
half_col = guard_cols + ref_cols
for i in prange(n_rows):
for j in range(n_cols):
row_min = max(0, i - half_row)
row_max = min(n_rows, i + half_row + 1)
col_min = max(0, j - half_col)
col_max = min(n_cols, j + half_col + 1)
guard_row_min = max(0, i - guard_rows)
guard_row_max = min(n_rows, i + guard_rows + 1)
guard_col_min = max(0, j - guard_cols)
guard_col_max = min(n_cols, j + guard_cols + 1)
ref_sum = 0.0
n_cells = 0
for ri in range(row_min, row_max):
for ci in range(col_min, col_max):
if not (
guard_row_min <= ri < guard_row_max
and guard_col_min <= ci < guard_col_max
):
ref_sum += image[ri, ci]
n_cells += 1
if n_cells > 0:
noise_estimate[i, j] = ref_sum / n_cells
else:
noise_estimate[i, j] = 0.0
threshold[i, j] = alpha * noise_estimate[i, j]
@njit(cache=True, fastmath=True, parallel=True)
def _cfar_2d_go_kernel(
image: np.ndarray[Any, Any],
guard_rows: int,
guard_cols: int,
ref_rows: int,
ref_cols: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled 2D GO-CFAR kernel with parallel execution."""
n_rows, n_cols = image.shape
half_row = guard_rows + ref_rows
half_col = guard_cols + ref_cols
for i in prange(n_rows):
for j in range(n_cols):
row_min = max(0, i - half_row)
row_max = min(n_rows, i + half_row + 1)
col_min = max(0, j - half_col)
col_max = min(n_cols, j + half_col + 1)
guard_row_min = max(0, i - guard_rows)
guard_row_max = min(n_rows, i + guard_rows + 1)
guard_col_min = max(0, j - guard_cols)
guard_col_max = min(n_cols, j + guard_cols + 1)
top_sum = 0.0
top_count = 0
bottom_sum = 0.0
bottom_count = 0
for ri in range(row_min, row_max):
for ci in range(col_min, col_max):
if not (
guard_row_min <= ri < guard_row_max
and guard_col_min <= ci < guard_col_max
):
if ri < i:
top_sum += image[ri, ci]
top_count += 1
else:
bottom_sum += image[ri, ci]
bottom_count += 1
top_avg = top_sum / top_count if top_count > 0 else 0.0
bottom_avg = bottom_sum / bottom_count if bottom_count > 0 else 0.0
noise_estimate[i, j] = max(top_avg, bottom_avg)
threshold[i, j] = alpha * noise_estimate[i, j]
@njit(cache=True, fastmath=True, parallel=True)
def _cfar_2d_so_kernel(
image: np.ndarray[Any, Any],
guard_rows: int,
guard_cols: int,
ref_rows: int,
ref_cols: int,
alpha: float,
noise_estimate: np.ndarray[Any, Any],
threshold: np.ndarray[Any, Any],
) -> None:
"""JIT-compiled 2D SO-CFAR kernel with parallel execution."""
n_rows, n_cols = image.shape
half_row = guard_rows + ref_rows
half_col = guard_cols + ref_cols
for i in prange(n_rows):
for j in range(n_cols):
row_min = max(0, i - half_row)
row_max = min(n_rows, i + half_row + 1)
col_min = max(0, j - half_col)
col_max = min(n_cols, j + half_col + 1)
guard_row_min = max(0, i - guard_rows)
guard_row_max = min(n_rows, i + guard_rows + 1)
guard_col_min = max(0, j - guard_cols)
guard_col_max = min(n_cols, j + guard_cols + 1)
top_sum = 0.0
top_count = 0
bottom_sum = 0.0
bottom_count = 0
for ri in range(row_min, row_max):
for ci in range(col_min, col_max):
if not (
guard_row_min <= ri < guard_row_max
and guard_col_min <= ci < guard_col_max
):
if ri < i:
top_sum += image[ri, ci]
top_count += 1
else:
bottom_sum += image[ri, ci]
bottom_count += 1
top_avg = top_sum / top_count if top_count > 0 else np.inf
bottom_avg = bottom_sum / bottom_count if bottom_count > 0 else np.inf
noise_est = min(top_avg, bottom_avg)
if noise_est == np.inf:
noise_est = 0.0
noise_estimate[i, j] = noise_est
threshold[i, j] = alpha * noise_estimate[i, j]
# =============================================================================
# 1D CFAR Algorithms
# =============================================================================
[docs]
def cfar_ca(
signal: ArrayLike,
guard_cells: int,
ref_cells: int,
pfa: float = 1e-6,
alpha: Optional[float] = None,
) -> CFARResult:
"""
Cell-Averaging CFAR detector.
CA-CFAR estimates the noise level by averaging the cells in the reference
window (excluding guard cells around the cell under test).
Parameters
----------
signal : array_like
Input signal (typically power or magnitude).
guard_cells : int
Number of guard cells on each side of the cell under test.
ref_cells : int
Number of reference cells on each side.
pfa : float, optional
Probability of false alarm. Default is 1e-6.
alpha : float, optional
Threshold multiplier. If provided, overrides pfa calculation.
Returns
-------
result : CFARResult
Named tuple with detections, threshold, indices, and noise estimate.
Examples
--------
>>> import numpy as np
>>> np.random.seed(42)
>>> # Noise with a few targets
>>> signal = np.random.exponential(1.0, 1000)
>>> signal[250] = 50 # Target 1
>>> signal[500] = 100 # Target 2
>>> signal[750] = 30 # Target 3
>>> result = cfar_ca(signal, guard_cells=2, ref_cells=16, pfa=1e-4)
>>> 250 in result.detection_indices
True
Notes
-----
The CA-CFAR is optimal for homogeneous noise (noise power constant
across all cells). It suffers in heterogeneous environments and near
closely-spaced targets.
"""
signal = np.asarray(signal, dtype=np.float64)
n = len(signal)
if alpha is None:
alpha = threshold_factor(pfa, 2 * ref_cells, method="ca")
noise_estimate = np.zeros(n, dtype=np.float64)
threshold = np.zeros(n, dtype=np.float64)
# Use JIT-compiled kernel for performance
_cfar_ca_kernel(signal, guard_cells, ref_cells, alpha, noise_estimate, threshold)
detections = signal > threshold
detection_indices = np.where(detections)[0]
return CFARResult(
detections=detections,
threshold=threshold,
detection_indices=detection_indices,
noise_estimate=noise_estimate,
)
[docs]
def cfar_go(
signal: ArrayLike,
guard_cells: int,
ref_cells: int,
pfa: float = 1e-6,
alpha: Optional[float] = None,
) -> CFARResult:
"""
Greatest-Of CFAR detector.
GO-CFAR takes the maximum of the leading and lagging reference window
averages. This provides better performance at clutter edges but
increased loss against distributed targets.
Parameters
----------
signal : array_like
Input signal.
guard_cells : int
Number of guard cells on each side.
ref_cells : int
Number of reference cells on each side.
pfa : float, optional
Probability of false alarm. Default is 1e-6.
alpha : float, optional
Threshold multiplier.
Returns
-------
result : CFARResult
Named tuple with detection results.
Examples
--------
>>> import numpy as np
>>> signal = np.random.exponential(1.0, 500)
>>> signal[250] = 50
>>> result = cfar_go(signal, guard_cells=2, ref_cells=16, pfa=1e-4)
>>> len(result.detection_indices) >= 1
True
Notes
-----
GO-CFAR reduces false alarms at clutter edges (where noise level
changes abruptly) compared to CA-CFAR, at the cost of slightly
reduced detection probability in homogeneous noise.
"""
signal = np.asarray(signal, dtype=np.float64)
n = len(signal)
if alpha is None:
alpha = threshold_factor(pfa, 2 * ref_cells, method="go")
noise_estimate = np.zeros(n, dtype=np.float64)
threshold = np.zeros(n, dtype=np.float64)
# Use JIT-compiled kernel for performance
_cfar_go_kernel(signal, guard_cells, ref_cells, alpha, noise_estimate, threshold)
detections = signal > threshold
detection_indices = np.where(detections)[0]
return CFARResult(
detections=detections,
threshold=threshold,
detection_indices=detection_indices,
noise_estimate=noise_estimate,
)
[docs]
def cfar_so(
signal: ArrayLike,
guard_cells: int,
ref_cells: int,
pfa: float = 1e-6,
alpha: Optional[float] = None,
) -> CFARResult:
"""
Smallest-Of CFAR detector.
SO-CFAR takes the minimum of the leading and lagging reference window
averages. This provides better detection near clutter edges but
increased false alarms in some scenarios.
Parameters
----------
signal : array_like
Input signal.
guard_cells : int
Number of guard cells on each side.
ref_cells : int
Number of reference cells on each side.
pfa : float, optional
Probability of false alarm. Default is 1e-6.
alpha : float, optional
Threshold multiplier.
Returns
-------
result : CFARResult
Named tuple with detection results.
Examples
--------
>>> import numpy as np
>>> from pytcl.mathematical_functions.signal_processing import cfar_so
>>> # Create test signal with closely spaced targets in clutter
>>> np.random.seed(42)
>>> signal = np.random.exponential(1.0, 500)
>>> signal[200:205] = [30, 40, 35, 45, 38] # Target cluster
>>> signal[350] = 50 # Isolated target
>>> # Detect using SO-CFAR
>>> result = cfar_so(signal, guard_cells=2, ref_cells=16, pfa=1e-4)
>>> # SO-CFAR good for clutter edge detection
>>> len(result.detection_indices) >= 2 # Should find multiple targets
True
Notes
-----
SO-CFAR is complementary to GO-CFAR. It is more sensitive near
clutter edges but may produce more false alarms when interfering
targets are present in the reference window.
"""
signal = np.asarray(signal, dtype=np.float64)
n = len(signal)
if alpha is None:
alpha = threshold_factor(pfa, 2 * ref_cells, method="so")
noise_estimate = np.zeros(n, dtype=np.float64)
threshold = np.zeros(n, dtype=np.float64)
# Use JIT-compiled kernel for performance
_cfar_so_kernel(signal, guard_cells, ref_cells, alpha, noise_estimate, threshold)
detections = signal > threshold
detection_indices = np.where(detections)[0]
return CFARResult(
detections=detections,
threshold=threshold,
detection_indices=detection_indices,
noise_estimate=noise_estimate,
)
[docs]
def cfar_os(
signal: ArrayLike,
guard_cells: int,
ref_cells: int,
pfa: float = 1e-6,
k: Optional[int] = None,
alpha: Optional[float] = None,
) -> CFARResult:
"""
Order-Statistic CFAR detector.
OS-CFAR uses an order statistic (k-th smallest value) of the reference
cells instead of the mean. This makes it robust to interfering targets
in the reference window.
Parameters
----------
signal : array_like
Input signal.
guard_cells : int
Number of guard cells on each side.
ref_cells : int
Number of reference cells on each side.
pfa : float, optional
Probability of false alarm. Default is 1e-6.
k : int, optional
Order statistic to use (1 = minimum, n_ref = maximum).
Default is 0.75 * n_ref.
alpha : float, optional
Threshold multiplier.
Returns
-------
result : CFARResult
Named tuple with detection results.
Examples
--------
>>> import numpy as np
>>> np.random.seed(42)
>>> signal = np.random.exponential(1.0, 500)
>>> signal[250] = 50
>>> signal[260] = 40 # Closely spaced target
>>> result = cfar_os(signal, guard_cells=2, ref_cells=16, pfa=1e-4)
>>> len(result.detection_indices) >= 2
True
Notes
-----
OS-CFAR is robust to interfering targets in the reference window
because the order statistic ignores outliers. The choice of k trades
off between:
- Low k: Robust to multiple interferers, but sensitive to noise
- High k: Less robust to interferers, but better in homogeneous noise
"""
signal = np.asarray(signal, dtype=np.float64)
n = len(signal)
n_total_ref = 2 * ref_cells
if k is None:
k = int(0.75 * n_total_ref)
k = max(1, min(k, n_total_ref))
if alpha is None:
alpha = threshold_factor(pfa, n_total_ref, method="os", k=k)
noise_estimate = np.zeros(n, dtype=np.float64)
threshold = np.zeros(n, dtype=np.float64)
# Use JIT-compiled kernel for performance
_cfar_os_kernel(signal, guard_cells, ref_cells, k, alpha, noise_estimate, threshold)
detections = signal > threshold
detection_indices = np.where(detections)[0]
return CFARResult(
detections=detections,
threshold=threshold,
detection_indices=detection_indices,
noise_estimate=noise_estimate,
)
# =============================================================================
# 2D CFAR
# =============================================================================
[docs]
def cfar_2d(
image: ArrayLike,
guard_cells: tuple[int, int],
ref_cells: tuple[int, int],
pfa: float = 1e-6,
method: str = "ca",
alpha: Optional[float] = None,
) -> CFARResult2D:
"""
Two-dimensional CFAR detector.
2D CFAR is used for range-Doppler maps or image detection where the
reference window extends in both dimensions.
Parameters
----------
image : array_like
2D input (e.g., range-Doppler map).
guard_cells : tuple
(guard_rows, guard_cols) - guard cells in each direction.
ref_cells : tuple
(ref_rows, ref_cols) - reference cells in each direction.
pfa : float, optional
Probability of false alarm. Default is 1e-6.
method : {'ca', 'go', 'so'}, optional
CFAR method. Default is 'ca'.
alpha : float, optional
Threshold multiplier.
Returns
-------
result : CFARResult2D
Named tuple with 2D detections, threshold, and noise estimate.
Examples
--------
>>> import numpy as np
>>> np.random.seed(42)
>>> image = np.random.exponential(1.0, (100, 100))
>>> image[50, 50] = 100 # Target
>>> result = cfar_2d(image, guard_cells=(2, 2), ref_cells=(8, 8), pfa=1e-4)
>>> result.detections[50, 50]
True
Notes
-----
The 2D reference window forms a rectangular annulus around the cell
under test. The total number of reference cells is::
(2*guard_rows + 2*ref_rows + 1) * (2*guard_cols + 2*ref_cols + 1)
- (2*guard_rows + 1) * (2*guard_cols + 1)
"""
image = np.asarray(image, dtype=np.float64)
guard_rows, guard_cols = guard_cells
ref_rows, ref_cols = ref_cells
# Count reference cells
outer_rows = 2 * (guard_rows + ref_rows) + 1
outer_cols = 2 * (guard_cols + ref_cols) + 1
inner_rows = 2 * guard_rows + 1
inner_cols = 2 * guard_cols + 1
n_ref = outer_rows * outer_cols - inner_rows * inner_cols
if alpha is None:
alpha = threshold_factor(pfa, n_ref, method=method)
noise_estimate = np.zeros_like(image)
threshold = np.zeros_like(image)
# Use JIT-compiled kernel for performance (with parallel execution)
if method == "ca":
_cfar_2d_ca_kernel(
image,
guard_rows,
guard_cols,
ref_rows,
ref_cols,
alpha,
noise_estimate,
threshold,
)
elif method == "go":
_cfar_2d_go_kernel(
image,
guard_rows,
guard_cols,
ref_rows,
ref_cols,
alpha,
noise_estimate,
threshold,
)
elif method == "so":
_cfar_2d_so_kernel(
image,
guard_rows,
guard_cols,
ref_rows,
ref_cols,
alpha,
noise_estimate,
threshold,
)
else:
raise ValueError(f"Unknown method: {method}")
detections = image > threshold
return CFARResult2D(
detections=detections,
threshold=threshold,
noise_estimate=noise_estimate,
)
# =============================================================================
# Utility Functions
# =============================================================================
[docs]
def cluster_detections(
detections: ArrayLike,
min_separation: int = 1,
) -> NDArray[np.intp]:
"""
Cluster nearby detections and return peak indices.
Parameters
----------
detections : array_like
Boolean detection array or signal values at detection points.
min_separation : int, optional
Minimum separation between distinct detections. Default is 1.
Returns
-------
peak_indices : ndarray
Indices of detection peaks after clustering.
Examples
--------
>>> import numpy as np
>>> from pytcl.mathematical_functions.signal_processing import cluster_detections
>>> # CFAR detection result with closely spaced detections
>>> detections = np.zeros(100, dtype=bool)
>>> detections[20:24] = True # Cluster 1 (4 adjacent detections)
>>> detections[60] = True # Cluster 2 (single detection)
>>> detections[62] = True # Close to cluster 2
>>> # Gaps <= min_separation merge into the same cluster
>>> peaks = cluster_detections(detections, min_separation=2)
>>> len(peaks) # Should find 2 clusters
2
>>> int(peaks[0]) # Center of first cluster (indices 20-23)
21
"""
detections = np.asarray(detections)
if detections.dtype == bool:
det_indices = np.where(detections)[0]
else:
det_indices = np.arange(len(detections))
if len(det_indices) == 0:
return np.array([], dtype=np.intp)
# Cluster nearby indices
clusters = []
current_cluster = [det_indices[0]]
for i in range(1, len(det_indices)):
if det_indices[i] - det_indices[i - 1] <= min_separation:
current_cluster.append(det_indices[i])
else:
clusters.append(current_cluster)
current_cluster = [det_indices[i]]
clusters.append(current_cluster)
# Take center of each cluster
peak_indices = [int(np.mean(cluster)) for cluster in clusters]
return np.array(peak_indices, dtype=np.intp)
[docs]
def snr_loss(
n_ref: int,
pfa: float,
pd: float = 0.5,
method: str = "ca",
) -> float:
"""
CA-CFAR loss: the extra SNR needed because the threshold is estimated.
An ideal detector knows the noise power. A CFAR detector estimates it from
``n_ref`` reference cells, and needs more signal to reach the same
detection probability at the same false-alarm rate. The loss is the
difference between the two required SNRs, for a Swerling 1 target::
S_ideal = ln(Pfa)/ln(Pd) - 1
S_ca = (Pfa^(-1/n) - 1) / (Pd^(-1/n) - 1) - 1
L_dB = 10 log10( (1 + S_ca) / (1 + S_ideal) )
Parameters
----------
n_ref : int
Number of reference cells.
pfa : float
Design probability of false alarm.
pd : float, optional
Detection probability at which the loss is evaluated. Default 0.5.
CFAR loss is only defined against an operating point.
method : {'ca'}, optional
CFAR method. Only ``'ca'`` is implemented. Default is 'ca'.
Returns
-------
loss : float
SNR loss in dB, non-negative and decreasing in ``n_ref``.
Raises
------
NotImplementedError
For ``'go'``, ``'so'`` and ``'os'``. See the notes.
Examples
--------
>>> round(snr_loss(16, pfa=1e-6), 3)
1.915
>>> snr_loss(8, pfa=1e-6) > snr_loss(64, pfa=1e-6) # fewer cells, more loss
True
Notes
-----
This used to be computed from ad-hoc expressions -- ``1 + 1/n_ref`` for CA,
``1 + 2/n_ref`` for GO and SO, ``1 + 3/n_ref`` for OS -- taking neither
``pfa`` nor ``pd`` (gh-20). CFAR loss depends on both, so a function of
``n_ref`` alone cannot express it. The heuristics understated it by roughly
a factor of four: at 8 reference cells and Pfa 1e-6 they returned 0.51 dB
against a true 4.09 dB, and returned that same figure for every operating
point.
Only CA is implemented, because the loss is defined through the detection
probability and this library has a closed form for Pd under CA-CFAR alone.
GO, SO and OS raise rather than return a number: a plausible wrong loss is
worse than an absent one, and the earlier heuristics for those three were
not derived from anything.
Comparing threshold factors directly would be the obvious shortcut and does
not work. ``threshold_factor`` returns a multiplier on each method's own
noise statistic -- the mean for CA, the larger or smaller half-window mean
for GO and SO, an order statistic for OS -- and those statistics have
different expectations, so the multipliers are not comparable. Doing it
that way gives OS-CFAR a *negative* loss at 64 reference cells.
See Also
--------
detection_probability : The Swerling 1 CA-CFAR Pd this is derived from.
"""
if not 0.0 < pfa < 1.0:
raise ValueError(f"pfa must be in (0, 1), got {pfa}")
if not 0.0 < pd < 1.0:
raise ValueError(f"pd must be in (0, 1), got {pd}")
if n_ref < 1:
raise ValueError(f"n_ref must be at least 1, got {n_ref}")
if method != "ca":
raise NotImplementedError(
f"snr_loss is only implemented for method='ca', got {method!r}. "
f"The loss is defined through the detection probability, and no "
f"closed-form Pd is available here for GO, SO or OS-CFAR."
)
snr_ideal = np.log(pfa) / np.log(pd) - 1.0
snr_cfar = (pfa ** (-1.0 / n_ref) - 1.0) / (pd ** (-1.0 / n_ref) - 1.0) - 1.0
return float(10 * np.log10((1.0 + snr_cfar) / (1.0 + snr_ideal)))
__all__ = [
# Result Types
"CFARResult",
"CFARResult2D",
# Threshold and Detection Probability
"threshold_factor",
"detection_probability",
# CFAR Detectors
"cfar_ca",
"cfar_go",
"cfar_so",
"cfar_os",
"cfar_2d",
# Utilities
"cluster_detections",
"snr_loss",
]