# Tuning the Threshold and Detection Parameters `panache`'s dynamic-threshold mode (`"dynamic_threshold": true`) re-derives the SPM flood-fill threshold for every scene and every plume from the scene's own gradient field, rather than applying one fixed number. That adaptivity is controlled by a small set of parameters, several of which are easy to misread from their names alone. This page works through the algorithm step by step with real numbers, gives symptom tables for setting each parameter too high or too low, and reports the results of running `panache` on real SEXTANT satellite data while sweeping each parameter through a range of values. It complements the {doc}`workflow ` and {doc}`masks ` vignettes: those show what a run produces, this one explains why the plume masks come out the shape they do, and what to change if they look wrong. --- ## 1. How one scene is thresholded, step by step For each plume, `panache` shoots a fan of transects outward from the configured `starting_points` pixel and reads SPM values step by step, moving away from the river mouth. The example below uses real-world values from the Gironde plume (`BAY_OF_BISCAY`, August 2000): `minimal_threshold ≈ 1.6 g m⁻³`, `maximal_threshold ≈ 37 g m⁻³`, `quantile_to_use = 0.2`. Suppose one westward transect reads: ```text pixel: 0 1 2 3 4 5 6 7 8 9 SPM: [180, 90, 45, 22, 12, 8, 5, 3, 2, 1.5] g m⁻³ ``` **Step 1: `maximal_threshold = 37` clips the first three pixels**, which are close enough to the mouth to still be at the ceiling: ```text [37, 37, 37, 22, 12, 8, 5, 3, 2, 1.5] ``` Clipped pixels also have their gradient forced to `+∞`, marking them "saturated near-mouth zone, not a plume edge candidate." **Step 2: gradients are computed** (successive differences, `+∞` excluded from ranking): ```text gradients: [+∞, +∞, 15, 10, 4, 3, 2, 1, 0.5] ``` **Step 3: only the steepest 90% of finite gradients survive** (`15 × 0.9 = 13.5`). Only the gradient of `15` clears that bar. The SPM value at that pixel, `22 g m⁻³`, becomes this transect's plume-edge candidate. **Step 4: edge candidates are pooled across every fan direction.** Suppose across 12 transect directions the collected edge values are: ```text [22, 19, 25, 18, 20, 23, 17, 21, 24, 19, 20, 22] g m⁻³ ``` **Step 5: `quantile_to_use = 0.2`** takes the p20 of those values, `≈ 19 g m⁻³`. **Step 6: `minimal_threshold = 1.6`** is applied as a floor: `max(19, 1.6) = 19`. The final scene threshold is **19 g m⁻³**. :::{note} Without `maximal_threshold` clipping the near-mouth pixels, the huge gradient between pixels 0 and 1 (`180 → 90`, Δ = 90) would dominate every transect, anchoring the detected threshold at roughly 90 g m⁻³. The flood fill would then include only the densest near-mouth pixels, and the diffuse offshore plume would be missed entirely. This is the single most common cause of under-sized plume masks; see [Test 1](#test-1-near-mouth-quantiles) below for a real example. ::: --- ## 2. What each parameter controls | Parameter | Role | |---|---| | `maximal_threshold` | Where along the transect the algorithm is *allowed* to look for a plume edge. Clips SPM above the ceiling before gradients are computed, forcing the search to look past the concentrated near-mouth turbidity. Lowering it pushes the search further offshore; raising it lets the algorithm settle for an edge closer to the mouth. | | `minimal_threshold` | A floor on the final threshold. If the gradient search returns something unrealistically low (e.g. `0.8 g m⁻³` on a very clear day), this overrides it, preventing the flood fill from spreading into ambient open-ocean water on days with a weak or diffuse signal. | | `quantile_to_use` | How inclusive the threshold is within the pool of edge candidates. Given the 12 candidates from the example above (17-25 g m⁻³): `0.2` → ≈19 g m⁻³ (diffuse outer plume captured); `0.5` → ≈21 g m⁻³ (core retained, fringe trimmed); `0.8` → ≈23 g m⁻³ (only the densest body). Lower quantiles suit plumes with a large, diffuse offshore component (Gironde, Seine); higher quantiles suit compact plumes with a sharp front. | --- ## 3. Diagnostic: symptoms of a mis-set parameter | Parameter | Set too high | Set too low | |---|---|---| | `maximal_threshold` | Effectively no clipping happens; the huge near-mouth gradient dominates every transect and anchors the threshold close to it. Only the densest near-mouth pixels survive. **Detected area is tiny and confined to the estuary.** | The clipping covers most of the plume body, not just the turbid mouth. The gradient field collapses and the algorithm falls back to `minimal_threshold`. The flood fill spreads across most of the visible ocean. **Plume masks are implausibly large.** | | `minimal_threshold` | On a typical high-turbidity day the computed threshold already exceeds this and the floor stays inactive; but on a low-turbidity day (e.g. after calm weather) it can force the threshold up past what the gradient search actually found. **The plume on that day is missed entirely**, a spurious gap in the time series. | The floor is always inactive. On scenes where the gradient search fails outright (e.g. cloud gaps break every transect), the algorithm falls back to this value as the detected threshold. **The flood fill spreads across the ocean.** | | `quantile_to_use` | Only the densest core near the mouth exceeds the threshold; the diffuse offshore plume body is invisible. **Area and centroid are systematically biased toward the estuary mouth.** | Some edge candidates may be artefacts (e.g. resuspension patches that produce a steep, but not plume-related, gradient). A very low quantile selects the lowest-SPM artefact as the threshold. **The flood fill leaks into resuspension areas or open ocean.** | --- ## 4. Where the auto-estimated bounds come from When `dynamic_threshold: true` and a plume's `minimal_threshold`/`maximal_threshold` are left as `null` (the default for all built-in zone presets), `panache` estimates them automatically rather than requiring hand-tuned values. This happens once per batch, before any scene is processed, from a *window of pixels around the river mouth*, pooled across every loaded scene ([`_compute_batch_thresholds`](https://github.com/RiOMar-projet/panache/blob/main/src/panache/runner.py), `runner.py`). The bounds stay fixed for the whole run rather than varying scene to scene. The quantiles used are configurable: ```json { "near_mouth_lower_quantile": 0.25, "near_mouth_upper_quantile": 0.75 } ``` (defaults shown; `minimal_threshold` uses the lower quantile, `maximal_threshold` the upper one). ### 4.1 The sampling window: shape and size Two further, less obvious settings control *which* pixels feed that estimate: ```json { "near_mouth_window_shape": "circle", "near_mouth_radius_pixels": 15 } ``` `"circle"` (the default) samples every water pixel within a Euclidean radius of `near_mouth_radius_pixels` around the starting point; `"square"` instead samples a square block using Chebyshev distance, where `near_mouth_radius_pixels` is the half-width (`1` → a 3×3 window, `2` → 5×5, `3` → 7×7). On the ~0.01° native SEXTANT grid, the default 15-pixel radius spans roughly 15 km in every direction from the mouth, comfortably reaching past the immediate turbid core into more ambient water. :::{warning} A tighter window is not a free upgrade. [Test 3](#test-3-near-mouth-sampling-window) below shows that shrinking to a 3×3 square does sharpen the near-mouth SPM distribution as expected, but the resulting bounds become dominated by whichever one or two pixels happen to be most turbid that day. On lower-turbidity days that can push `minimal_threshold` above every SPM value in the whole scene, killing detection outright, on 33% of days in one real test. A moderate window (5×5-7×7, or a smaller-radius circle) captures most of the benefit without that failure mode. ::: ### 4.2 The gradient-steepness cutoff Step 3 of the worked example above (only the steepest 90% of finite gradients survive) is controlled by a separate parameter: ```json { "gradient_steepness_fraction": 0.9 } ``` This is **not** a percentile of the gradient distribution across the transect, despite reading that way. It is the fraction of the *single steepest finite gradient found anywhere on that transect* that a point's own gradient must clear to be kept as an edge candidate. Concretely, for gradients `[15, 10, 4, 3, 2, 1, 0.5]`, a cutoff of `0.9` keeps only the `15` (`15 × 0.9 = 13.5`); a true 90th-percentile filter over the same values would instead keep the top decile of the whole distribution, a much larger and more forgiving set. In practice this means the default setting isolates a single sharp step per transect and discards the gentler "shoulder" of secondary gradients that a real SPM front usually also has, biasing the detected edge toward the single most abrupt near-mouth feature, the same direction of bias as `maximal_threshold` set too high. No published river-plume or ocean-front detection method was found that thresholds on a fraction of a single scene's own maximum gradient. The closest analogues in the literature both threshold on a statistic of the *pooled* gradient distribution instead: - **Fixed, empirically-tuned magnitude thresholds**, set once from many scenes, not re-derived per scene. Li et al. (2023), detecting the Yellow River Estuary turbid/clear-water demarcation with Canny edge detection on SPM imagery, report gradient-magnitude thresholds of 0.05-0.20 chosen by inspection across the whole dataset. [doi:10.3389/fmars.2023.1234631](https://doi.org/10.3389/fmars.2023.1234631) - **Percentile-of-distribution thresholds**, the convention in the Belkin-O'Reilly ocean-front detection literature that underlies most published front climatologies: a representative downstream application sets its regional gradient threshold at the 70th percentile computed over all suitable pixels in a multi-decadal pooled dataset, not a fraction of any single scene's maximum. [doi:10.1016/j.jmarsys.2008.11.018](https://doi.org/10.1016/j.jmarsys.2008.11.018) - **No gradient threshold at all.** Tavora et al. (2023)'s PLUMES algorithm classifies plume pixels by statistical similarity to user-placed control points at the river mouth and in background water, explicitly avoiding any fixed or scene-relative threshold. [doi:10.3389/fmars.2023.1215327](https://doi.org/10.3389/fmars.2023.1215327) --- ## 5. Evidence from real SEXTANT data The three sensitivity tests below were run on real SEXTANT SPM satellite data (not synthetic test fixtures) to check the reasoning above against actual plume detection outcomes. All used dynamic-threshold mode; nothing here changes any zone preset default; every value below is exactly what came out of a real `panache` run. (test-1-near-mouth-quantiles)= ### Test 1: `near_mouth_lower/upper_quantile` (Bay of Seine, full year 2009) The zone-default quantiles (p25/p75) systematically under-detect the Seine plume: visually, the flagged area misses much of the visibly high-SPM water on the daily maps. Lowering the upper quantile from 0.75 to 0.50 (and the lower from 0.25 to 0.10) roughly doubles the mean daily area and lowers the mean threshold from 9.65 to 5.99 g m⁻³: | | mean area (km²) | median area (km²) | mean threshold (g m⁻³) | |---|---|---|---| | Default (p25/p75) | 208.7 | 136.9 | 9.65 | | Loosened (p10/p50) | 418.9 | 265.4 | 5.99 | This does close roughly half the gap to an independent surface-area proxy for the Seine's freshwater influence for that year, but it also introduces 9 days out of 365 (2.5%) where the batch-pooled `minimal_threshold` drops low enough that the flood fill "leaks" across several thousand km² of open water unrelated to river discharge, confirmed by cross-checking against daily river-discharge records (these leak days sit at or below the year's median discharge, so they are not real high-flow events). A less extreme lower quantile (0.15-0.20) is likely to capture most of the area gain while avoiding that failure mode, but has not been tested. (test-2-gradient-steepness)= ### Test 2: `gradient_steepness_fraction` (Bay of Seine, June 2009) Sweeping the cutoff from the default `0.9` down to `0.1` in steps of `0.1`, on a 30-day sample: | `gradient_steepness_fraction` | mean area (km²) | mean threshold (g m⁻³) | area vs. default | |---|---|---|---| | 0.9 (default) | 262.6 | 3.49 | n/a | | 0.7 | 261.2 | 3.42 | +5% | | 0.5 | 300.3 | 3.22 | +28% | | 0.3 | 399.9 | 2.85 | +80% | | 0.1 | 417.5 | 2.80 | +88% | Unlike Test 1, no zero-pixel days and no runaway leak days appeared at any setting: `minimal_threshold` (a separate, independent mechanism, see §2) still guards against spillover regardless of how the steepness cutoff is set. Most of the area gain arrives by `0.4`-`0.5`; pushing further toward `0.1` adds comparatively little for a much larger departure from the percentile-of-distribution convention used in the published literature (§4.2), so a moderate value in that range looks like a more defensible middle ground than either extreme. (test-3-near-mouth-sampling-window)= ### Test 3: near-mouth sampling window (Gulf of Lion, June 2009) Comparing the default 15-pixel circle against square windows of decreasing size, holding `near_mouth_lower/upper_quantile` at the zone default: | Window | mean area (km²) | Grand Rhône threshold, mean (g m⁻³) | zero-pixel days (/30) | |---|---|---|---| | circle, radius 15 (default) | 511.8 | 2.07 | 0 | | square 7×7 | 262.8 | 2.97 | 1 | | square 5×5 | 137.5 | 3.59 | 3 | | square 3×3 | 32.4 | 5.09 | 10 | The hypothesis that a 15-pixel circle over-samples background water is confirmed: shrinking to a 3×3 square roughly quadruples the pooled near-mouth SPM bounds. But the outcome is not cleaner detection, it is a near-total loss of it. A 3×3 window is dominated by whichever single pixel is most turbid that specific day, so on lower-turbidity days the resulting `minimal_threshold` can exceed the SPM value everywhere in the scene. Ten of thirty days (33%) produced **zero detected plume pixels for both rivers simultaneously**, confirmed in the run log (`"No plume detected ... for any starting point"`) on days that were otherwise 100% cloud-free under the default circle. 5×5 and 7×7 recover substantially (3 and 1 zero-pixel days respectively), suggesting the useful range is a moderate window rather than the tightest one. --- ## 6. Every other zone-preset parameter `utils.py`'s `define_parameters()` returns several further keys per zone, each of which is a required field in the `parameters` block for a custom (non-`zone`) config. `maximal_threshold`, `minimal_threshold`, and `quantile_to_use` are covered above. `searching_strategies` : Maps each plume name to one of four named fan presets: `northward_fan`, `southward_fan`, `eastward_fan`, `westward_fan`. Resolved into pixel-direction vectors used by every step that walks outward from the river mouth (the flood-fill BFS, the gradient transects, the post-fill cleanup passes). **Wrong preset:** pointed back at the coastline instead of open water, the flood fill and gradient transects hit land or the domain edge within a pixel or two of the start point and detection collapses almost everywhere. `bathymetric_threshold` : A single zone-wide depth cutoff (metres) that strips any pixel shallower than it from the *final* plume mask. It does **not** affect land/flood-fill avoidance, which is always fixed at true sea level regardless of this setting. Three of the four built-in zones use `0`; `GULF_OF_LION` uses `20`, since the Rhône's resuspension signal sits closer to shore. **Too high:** strips genuinely shallow plume water, since river plumes are shallow-water phenomena by nature. **Too low:** shallow-water artefacts that survived the earlier resuspension filter are not caught by this final safety net. `starting_points` : One `(lat, lon)` coordinate per plume: the seed pixel for the flood fill and gradient transects. Relocated automatically to the nearest finite neighbour if cloud-covered on a given day (logged when it happens). **Misconfigured:** on land, or well outside the true turbid signal, the flood fill either never starts or starts below threshold and never grows. `core_of_the_plumes` : A second, trusted coordinate placed inside the expected plume body (not necessarily at the mouth), used *after* a raw mask exists: to pick the correct connected blob when the flood fill produces several disconnected shapes, as the estuary centre for the resuspension-distance filter, and during the dilate-and-merge step. The two coordinates are often near-identical, but can differ when the river mouth sits very close to the coastline: `starting_points` is then placed right at the coast, while `core_of_the_plumes` is offset slightly offshore so shape-selection reliably finds the right blob (see the README's `starting_points` and `core_of_the_plumes` section for a worked example from `BAY_OF_SEINE`). **Misconfigured:** if placed nearer a spurious blob than the true plume, the shape-selection fallback (nearest blob by centroid distance) keeps the wrong one outright. `lat_range_of_plume_area` / `lon_range_of_plume_area` : The bounding box used for data loading, polygon restriction, cloud-coverage screening, and plot extent. **Too wide:** more file I/O and RAM than needed, and irrelevant open-ocean turbidity can dilute the cloud-coverage/confidence statistics or (if wide enough) risk cross-contamination between two rivers' plumes in the same run. **Too narrow:** genuine offshore plume extent is truncated at the box edge, indistinguishable in the output from the plume genuinely ending there. `threshold_of_cloud_coverage_in_percentage` : The percentage of `NaN` water pixels inside the domain above which a scene is skipped entirely (no detection attempted). All four built-in zones use `25`. **Too high:** mostly-cloudy scenes still get processed, producing low-confidence guesses from a handful of visible pixels. **Too low:** routine, minor cloud cover trips the skip on most days, producing spurious gaps in the time series. `maximal_bathymetric_for_zone_with_resuspension` : A per-plume depth cutoff (metres) used, together with distance from the core and SPM level, to flag and remove coastal resuspension patches that are shallow and turbid but not river-plume water. **Too high:** legitimately shallow plume water becomes eligible for removal, undersizing the mask. **Too low:** genuine resuspension patches survive into the final mask, oversizing it with non-plume turbidity. `minimal_distance_from_estuary_for_zone_with_resuspension` : A per-plume distance (km) from `core_of_the_plumes` beyond which the criterion above applies. **Too high:** near-shore resuspension close to (but not at) the mouth survives and contaminates the mask. **Too low:** a genuine, nearby plume lobe can be mistaken for resuspension and stripped. `max_steps_for_the_directions` : A per-plume integer bounding how far each fan transect extends, in pixels, from the starting point, used both in the gradient search and in a post-fill edge-cleanup pass. Zone presets range from 35-40 (Seine, Rhône plumes) to 100 (Gironde, Loire), reflecting how far offshore each plume genuinely disperses. **Too high:** the transect reaches into unrelated open-ocean variability, adding noise for little benefit. **Too low:** the transect never reaches the true offshore edge of a widely-dispersing plume, biasing the derived threshold toward the turbid core. `fixed_threshold` : A per-plume static SPM value (g m⁻³), the lowest-priority option in the threshold resolution order (used only when `dynamic_threshold: false` and neither `spm_threshold` nor `global_threshold_quantile` is set). **Too high:** the flood fill never starts on days where peak SPM does not reach it, systematic false negatives. **Too low:** the flood fill spreads into ambient background water on every scene, since a static value cannot adapt to day-to-day or seasonal variability. `river_mouth_to_exclude` : A dict of named secondary-channel coordinates (e.g. a tidal canal) whose own turbid signal should be subtracted from the main plume mask. **Misconfigured:** omitting a real secondary outflow lets its plume merge into and contaminate the main one; a mis-placed or oversized entry can instead carve a real chunk out of the main plume. --- ## See also - {doc}`vignette_workflow`: a complete run from config file to output. - {doc}`vignette_masks`: recovering per-day SPM values from `PlumeMasks.nc`. - {doc}`api`: full parameter reference for `define_parameters`, `find_SPM_threshold`, and the rest of the detection pipeline.