Multi-Range Weighted Autofluorescence Subtraction
Experiment Overview
| Item |
Details |
| Date |
2026-02-14 |
| Goal |
Replace single global blank_scale_factor with per-intensity-range weights for AF subtraction |
| Problem |
Global scale factor either over-subtracts (destroys dim positive cells like FOXP3, CD163) or under-subtracts (leaves residual AF in collagen/lipofuscin) |
| Environment |
KINTSUGI pipeline, HiPerGator, 16-bit TIFF images (uint16), 9x7+ tile grids |
| Status |
Implemented and tested |
Algorithm
- Clip blank channel (same as global method)
- Segment signal histogram into N intensity ranges (default 5: background, very_dim, dim, medium, bright)
- For each range, compute signal-to-AF ratio → per-range weight:
blank_mean / signal_mean > 1.5 → weight 0.3-0.5 (AF dominates, protect signal)
- ratio 0.8-1.5 → weight 0.5-0.8 (mixed)
- ratio 0.3-0.8 → weight 0.8-1.0 (signal moderate)
- ratio < 0.3 → weight 1.0-1.15 (signal dominant)
- Correlation within range boosts weight slightly (+0.05 if corr > 0.3)
- Build smooth per-pixel weight map with cosine transitions at range boundaries
- Apply:
result = signal - min(signal, blank * base_scale * weight_map)
- Post-processing (smoothing, erosion — same as global method)
- Compute per-range quality metrics for learning
Key insight: In dim signal regions where blank > signal, the per-range weight drops to 0.3-0.5 (gentle subtraction), preserving real positive cells. In bright signal regions, weight rises to 1.0+ (aggressive AF removal).
Key Parameters
| Parameter |
Default |
Description |
n_ranges |
5 |
Number of intensity ranges |
range_method |
"percentile" |
Boundary computation: "percentile" or "otsu" |
base_scale_factor |
1.0 |
Global multiplier before per-range weighting |
transition_width |
0.1 |
Fraction of range width for cosine blending |
blank_clip_factor |
varies |
Inherited from global method analysis |
Failed Attempts (Critical)
| Attempt |
Why it Failed |
Lesson Learned |
| Test fixture with 100 dim pixels (10x10 region) in 256x256 image |
Percentile-based ranges couldn't separate 100 pixels (<0.2% of image) from dominant AF — dim pixels got lumped into same range as AF |
Percentile ranges require substantial pixel populations (~30%+) to form distinct ranges. Use realistic proportions in test fixtures. |
Using gpus=1 or gpu=1 in Snakemake resources |
SLURM_TRES_PER_TASK conflict on SLURM >= 24.11 |
Always use gres="gpu:1" for GPU resource in Snakemake |
| Otsu boundaries on near-uniform images |
threshold_multiotsu raises ValueError when variance is near-zero |
Catch ValueError and fall back to percentile method |
What Worked
Core Algorithm
- Cosine transitions between ranges prevent visible discontinuities in subtracted image
smooth_membership() function using (1 - cos(π*t))/2 for rising edges, (1 + cos(π*t))/2 for falling edges
- Weight map is float32 — pixel-local computation, no neighbor dependencies (trivially parallelizable with Dask
map_blocks)
- Near-uniform fallback: When
dynamic_range < 100, falls back to global method (avoids creating meaningless ranges)
- Sparse signal adaptation: When <1% nonzero pixels, reduces
n_ranges to min(n_ranges, 3)
Test Design
- Fixture with realistic proportions: 30% dim signal (rows 0:77), 20% bright signal (rows 180:230), 50% background
- Dim signal values: 3000-6000 with bright AF: 7000-9000 in same region
- Behavioral verification:
dim_global=0 (destroyed) vs dim_weighted=432 (preserved)
- Backwards compatibility: Uniform weights=1.0 produces
max_diff=0.0 vs global method
Learning Integration
algorithm_version column in parameter_learning.py via ALTER TABLE migration
- Bootstrap records with
user_approved=False (lower weight in recommendations)
- Range aggregation: average per-range weights across matching records with same
n_ranges
Architecture
IntensityRange and WeightedSubtractionParameters dataclasses with to_dict()/from_dict() for serialization
AutofluorescenceSubtractor.method parameter dispatches to _process_global() or _process_weighted()
- MCP
analyze_weighted_subtraction() tool previews ranges without performing subtraction
- CLI
kintsugi mcp pretrain scans EDF outputs for batch bootstrapping
Implementation Files
| File |
Key Functions/Classes |
src/kintsugi/signal/autofluorescence.py |
compute_intensity_ranges(), _compute_range_weight(), build_weight_map(), subtract_autofluorescence_weighted(), analyze_for_weighted_subtraction(), compute_weighted_subtraction_quality(), subtract_autofluorescence_weighted_dask() |
src/kintsugi/signal/utils.py |
adaptive_range_boundaries(), smooth_membership() |
src/kintsugi/signal/subtractor.py |
IntensityRange, WeightedSubtractionParameters, extended AutofluorescenceSubtractor |
src/kintsugi/signal/bootstrap.py |
bootstrap_from_project(), bootstrap_from_pairs() |
src/kintsugi/claude/parameter_learning.py |
algorithm_version column, _aggregate_ranges() |
src/kintsugi/mcp/tools/signal_isolation.py |
analyze_weighted_subtraction(), extended subtract_blank() |
src/kintsugi/mcp/tools/learning.py |
Extended _get_heuristic_suggestion(), record_successful_parameters() |
src/kintsugi/cli.py |
kintsugi mcp pretrain command |
tests/test_signal_autofluorescence.py |
TestWeightedSubtraction, TestComputeIntensityRanges, TestBuildWeightMap, etc. |
Data Structures
@dataclass
class IntensityRange:
lower_bound: float # Inclusive lower boundary
upper_bound: float # Exclusive upper boundary
weight: float # Scale multiplier (0.1-1.5) for blank in this range
label: str = "" # "background", "dim", "medium", "bright"
@dataclass
class WeightedSubtractionParameters:
blank_clip_factor: int = 0
base_scale_factor: float = 1.0
n_ranges: int = 5
range_method: str = "percentile" # "percentile" | "otsu" | "manual"
ranges: list[IntensityRange] = field(default_factory=list)
transition_width: float = 0.1 # Fraction of range width for blending
Learning Database Schema
ALTER TABLE parameter_records ADD COLUMN algorithm_version TEXT DEFAULT 'v1';
Weighted records store algorithm_version="weighted_v1" with per-range weights in parameters_json. Aggregation averages per-range weights across matching records with same n_ranges.
References
signal/autofluorescence.py — Core weighted subtraction implementation
signal/bootstrap.py — Batch pretraining from existing processed data
tests/test_signal_autofluorescence.py — Test classes for weighted subtraction
1---2name: weighted-autofluorescence-subtraction3description: Multi-range weighted autofluorescence subtraction for multiplex IF. Protects dim signal while aggressively removing bright AF. Trigger: autofluorescence removal, blank subtraction, dim marker preservation, weighted subtraction.4---5
6# Multi-Range Weighted Autofluorescence Subtraction
7
8## Experiment Overview
9| Item | Details |
10|------|---------|
11| **Date** | 2026-02-14 |
12| **Goal** | Replace single global `blank_scale_factor` with per-intensity-range weights for AF subtraction |
13| **Problem** | Global scale factor either over-subtracts (destroys dim positive cells like FOXP3, CD163) or under-subtracts (leaves residual AF in collagen/lipofuscin) |
14| **Environment** | KINTSUGI pipeline, HiPerGator, 16-bit TIFF images (uint16), 9x7+ tile grids |
15| **Status** | Implemented and tested |
16
17## Algorithm
18
191. Clip blank channel (same as global method)
202. Segment signal histogram into N intensity ranges (default 5: background, very_dim, dim, medium, bright)
213. For each range, compute signal-to-AF ratio → per-range weight:
22 - `blank_mean / signal_mean > 1.5` → weight 0.3-0.5 (AF dominates, protect signal)
23 - ratio 0.8-1.5 → weight 0.5-0.8 (mixed)
24 - ratio 0.3-0.8 → weight 0.8-1.0 (signal moderate)
25 - ratio < 0.3 → weight 1.0-1.15 (signal dominant)
26 - Correlation within range boosts weight slightly (+0.05 if corr > 0.3)
274. Build smooth per-pixel weight map with cosine transitions at range boundaries
285. Apply: `result = signal - min(signal, blank * base_scale * weight_map)`
296. Post-processing (smoothing, erosion — same as global method)
307. Compute per-range quality metrics for learning
31
32**Key insight**: In dim signal regions where blank > signal, the per-range weight drops to 0.3-0.5 (gentle subtraction), preserving real positive cells. In bright signal regions, weight rises to 1.0+ (aggressive AF removal).
33
34## Key Parameters
35
36| Parameter | Default | Description |
37|-----------|---------|-------------|
38| `n_ranges` | 5 | Number of intensity ranges |
39| `range_method` | `"percentile"` | Boundary computation: `"percentile"` or `"otsu"` |
40| `base_scale_factor` | 1.0 | Global multiplier before per-range weighting |
41| `transition_width` | 0.1 | Fraction of range width for cosine blending |
42| `blank_clip_factor` | varies | Inherited from global method analysis |
43
44## Failed Attempts (Critical)
45
46| Attempt | Why it Failed | Lesson Learned |
47|---------|---------------|----------------|
48| Test fixture with 100 dim pixels (10x10 region) in 256x256 image | Percentile-based ranges couldn't separate 100 pixels (<0.2% of image) from dominant AF — dim pixels got lumped into same range as AF | **Percentile ranges require substantial pixel populations (~30%+) to form distinct ranges. Use realistic proportions in test fixtures.** |
49| Using `gpus=1` or `gpu=1` in Snakemake resources | `SLURM_TRES_PER_TASK` conflict on SLURM >= 24.11 | Always use `gres="gpu:1"` for GPU resource in Snakemake |
50| Otsu boundaries on near-uniform images | `threshold_multiotsu` raises ValueError when variance is near-zero | Catch ValueError and fall back to percentile method |
51
52## What Worked
53
54### Core Algorithm
55- **Cosine transitions** between ranges prevent visible discontinuities in subtracted image
56- **`smooth_membership()`** function using `(1 - cos(π*t))/2` for rising edges, `(1 + cos(π*t))/2` for falling edges
57- **Weight map is float32** — pixel-local computation, no neighbor dependencies (trivially parallelizable with Dask `map_blocks`)
58- **Near-uniform fallback**: When `dynamic_range < 100`, falls back to global method (avoids creating meaningless ranges)
59- **Sparse signal adaptation**: When <1% nonzero pixels, reduces `n_ranges` to `min(n_ranges, 3)`
60
61### Test Design
62- Fixture with **realistic proportions**: 30% dim signal (rows 0:77), 20% bright signal (rows 180:230), 50% background
63- Dim signal values: 3000-6000 with bright AF: 7000-9000 in same region
64- **Behavioral verification**: `dim_global=0` (destroyed) vs `dim_weighted=432` (preserved)
65- **Backwards compatibility**: Uniform weights=1.0 produces `max_diff=0.0` vs global method
66
67### Learning Integration
68- `algorithm_version` column in parameter_learning.py via `ALTER TABLE` migration
69- Bootstrap records with `user_approved=False` (lower weight in recommendations)
70- Range aggregation: average per-range weights across matching records with same `n_ranges`
71
72### Architecture
73- `IntensityRange` and `WeightedSubtractionParameters` dataclasses with `to_dict()`/`from_dict()` for serialization
74- `AutofluorescenceSubtractor.method` parameter dispatches to `_process_global()` or `_process_weighted()`
75- MCP `analyze_weighted_subtraction()` tool previews ranges without performing subtraction
76- CLI `kintsugi mcp pretrain` scans EDF outputs for batch bootstrapping
77
78## Implementation Files
79
80| File | Key Functions/Classes |
81|------|----------------------|
82| `src/kintsugi/signal/autofluorescence.py` | `compute_intensity_ranges()`, `_compute_range_weight()`, `build_weight_map()`, `subtract_autofluorescence_weighted()`, `analyze_for_weighted_subtraction()`, `compute_weighted_subtraction_quality()`, `subtract_autofluorescence_weighted_dask()` |
83| `src/kintsugi/signal/utils.py` | `adaptive_range_boundaries()`, `smooth_membership()` |
84| `src/kintsugi/signal/subtractor.py` | `IntensityRange`, `WeightedSubtractionParameters`, extended `AutofluorescenceSubtractor` |
85| `src/kintsugi/signal/bootstrap.py` | `bootstrap_from_project()`, `bootstrap_from_pairs()` |
86| `src/kintsugi/claude/parameter_learning.py` | `algorithm_version` column, `_aggregate_ranges()` |
87| `src/kintsugi/mcp/tools/signal_isolation.py` | `analyze_weighted_subtraction()`, extended `subtract_blank()` |
88| `src/kintsugi/mcp/tools/learning.py` | Extended `_get_heuristic_suggestion()`, `record_successful_parameters()` |
89| `src/kintsugi/cli.py` | `kintsugi mcp pretrain` command |
90| `tests/test_signal_autofluorescence.py` | `TestWeightedSubtraction`, `TestComputeIntensityRanges`, `TestBuildWeightMap`, etc. |
91
92## Data Structures
93
94```python
95@dataclass
96class IntensityRange:
97 lower_bound: float # Inclusive lower boundary
98 upper_bound: float # Exclusive upper boundary
99 weight: float # Scale multiplier (0.1-1.5) for blank in this range
100 label: str = "" # "background", "dim", "medium", "bright"
101
102@dataclass
103class WeightedSubtractionParameters:
104 blank_clip_factor: int = 0
105 base_scale_factor: float = 1.0
106 n_ranges: int = 5
107 range_method: str = "percentile" # "percentile" | "otsu" | "manual"
108 ranges: list[IntensityRange] = field(default_factory=list)
109 transition_width: float = 0.1 # Fraction of range width for blending
110```
111
112## Learning Database Schema
113
114```sql
115ALTER TABLE parameter_records ADD COLUMN algorithm_version TEXT DEFAULT 'v1';
116```
117
118Weighted records store `algorithm_version="weighted_v1"` with per-range weights in `parameters_json`. Aggregation averages per-range weights across matching records with same `n_ranges`.
119
120## References
121
122- `signal/autofluorescence.py` — Core weighted subtraction implementation
123- `signal/bootstrap.py` — Batch pretraining from existing processed data
124- `tests/test_signal_autofluorescence.py` — Test classes for weighted subtraction