[RFC] Spatial feature detection for imaging MS (with optional ion mobility)
Maintainer thường phản hồi trong vòng 1 ngày
@patrick-bo đang làm issue này rồi.
Từ ngày 16/6/2026.
Đánh giá
Issue này chưa được đánh giá.
Mô tả
Summary
Add a feature detector for mass-spectrometry imaging (MSI) data that picks features across the spatial (x, y) pixel grid, with an optional ion-mobility (IM) dimension (timsTOF MALDI). The design generalizes the two existing 1‑D hill detectors in OpenMS — Biosaur2Algorithm (links peaks along the RT axis) and PASEFHillCentroider (links along the IM-scan axis within a frame) — to 2‑D space.
Proposed class: PeakPickerMSI (a DefaultParamHandler, MSImagingExperiment → FeatureMap).
Core concept
Biosaur2Algorithm builds "hills" by linking same‑m/z peaks across consecutive RT scans; PASEFHillCentroider does the same across IM scans inside one frame. Both link along a 1‑D, ordered separation axis, where an ion forms a roughly unimodal peak — which is why both split hills at intensity valleys to deconvolve co‑eluting/co‑drifting species.
The spatial axis pair (x, y) is not a separation axis — it is a map of where the molecule physically is. An ion image can be multimodal, fragmented, or diffuse; nothing forces a peak shape. Two consequences:
- Sequential tail‑linking → order‑independent union‑find connected components. In 2‑D a node can be adjacent to up to 8 neighbours and two already‑grown blobs can turn out to touch, which requires merging two existing groups — exactly what union‑find's
uniondoes and what Biosaur2's single rollingprev_peak_to_hillpointer cannot. - No intensity‑valley split in (x, y). The chromatography‑style "split co‑occurring isobars" operation belongs only on a real separation axis — i.e. the IM axis (Stage A), where
PASEFHillCentroideralready does it. A spatial watershed would be image segmentation (anatomy), not chemical deconvolution, so it is deliberately not part of this design.
A feature is therefore one connected component of the 8‑adjacency graph. The same molecule present in two non‑touching tissue regions yields two features (merging same‑m/z features across regions, if ever wanted, is a separate downstream consensus step).
1‑D concept (Biosaur2Algorithm / PASEFHillCentroider) |
Spatial picker | Status |
|---|---|---|
| RT / IM‑scan axis (ordered) | 2‑D pixel grid from MSImagingGeometry |
changed |
| Hill = path down the scan axis | Ion image = connected component (8‑adjacency) | changed |
Adjacent‑scan link (prev_fast_dict) |
8‑neighbour pixel adjacency | changed |
Sequential greedy + banned_prev_idx_set |
union‑find, union every qualifier | dropped (1‑D artifacts) |
mz_step = ppm·1e-6·max_mz, bins {fm-1,fm,fm+1} |
identical | faithful port |
IM bin floor(im/im_tol), gate ` |
Δbin | ≤1` |
min_hill_length (scans) |
min_area (connected pixels) |
faithful analog |
splitHills_ 1‑D valley split |
none (space is not a separation axis) | dropped |
detectIsotopePatterns_ RT‑profile cosine |
ion‑image cosine over shared pixels | extension / optional |
Pipeline
A per-pixel IM hill centroiding (PASEFHillCentroider::centroidFrame; skipped in no-IM mode)
B spatial union-find link (8-neighbour; gate m/z + IM only) -> connected components = features
B-guard m/z coherence guard (bound transitive m/z drift)
filter min_area (connected-pixel noise filter)
D collapse component -> Feature
E (optional) charge/isotopes by spatial co-localization
Data model
MSImagingExperiment=MSExperiment+MSImagingGeometry. OneMSSpectrumper pixel (Bruker TIMS imaging: 1 frame == 1 pixel == 1 spectrum).MSImagingGeometry: zero‑based(x,y) → spectrum_indexviagetSpectrumIndex/hasPixel/getPixels/getWidth/getHeight;Pixel{UInt x, UInt y, Size spectrum_index}.- Node = (pixel × centroid). After Stage A a pixel owns a list of centroids (one per m/z species), so one pixel contributes many nodes. Fields mirror
PASEFHillCentroider::Centroidplus pixel coords:
struct Node {
UInt x, y; // zero-based pixel coords (MSImagingGeometry::Pixel)
Size spectrum_index;
double mz; // Centroid.mz (intensity-weighted)
double intensity; // Centroid.intensity (summed)
double im_apex; // Centroid.im_apex (-1 in no-IM mode)
double im_lower, im_upper;// Centroid IM range (IM interval-overlap test)
double mz_lower, mz_upper;// Centroid m/z range
};
Stage A — per-pixel IM hill centroiding (faithful port; skipped in no-IM mode)
use_im is decided once per dataset via IMTypes::determineIMFormat(spec) == IM_PEAK.
for pixel in geometry.getPixels():
spec = experiment[pixel.spectrum_index]
if not use_im: // NO-IM: raw spectrum centroids are the nodes; IM gate disabled
nodes += {x,y,idx, peak.mz, peak.int, im=-1, ...} for each peak >= min_intensity
else: // IM: collapse the (m/z x IM) cloud to IM-centroids
build flat (mz[], int[], im[]) from spec + its IM FloatDataArray // cf. centroidPASEFData_
cents = PASEFHillCentroider::centroidFrame(mz, int, im, n, hill_params, /*scan_indices=*/nullptr)
nodes += {x,y,idx, c.mz, c.intensity, c.im_apex, c.im_lower, c.im_upper, c.mz_lower, c.mz_upper} for c in cents
Notes: the imaging MSSpectrum exposes no per‑peak TIMS scan index, so scan_indices = nullptr (⇒ max_scan_gap = 0). If the loader already ran hill‑centroiding (BrukerTimsFile HILL_BASED MS1), the peaks are the centroids and the bounds live in named float arrays.
Stage B — spatial union-find linking (the heart)
Edge predicate gates on m/z (ppm) + IM only — never intensity (an ion image legitimately varies in brightness across space; co‑localization is reserved for Stage E).
mz_step = mz_tol_ppm * 1e-6 * global_max_mz // == computeHillMzStep_
bin(mz) = floor(mz / mz_step); im_bin(im) = floor(im / im_tol)
fast_dict: per-pixel spectrum_index -> { mz_bin -> [node ids] } // per pixel, not 1 rolling dict
spatialEdge(A, B): // A, B in DIFFERENT adjacent pixels
if |A.mz - B.mz| / min(A.mz,B.mz) * 1e6 > mz_tol_ppm: return false // SYMMETRIC ppm
if use_im:
if |im_bin(A.im_apex) - im_bin(B.im_apex)| > 1: return false // cheap prefilter (port)
if A.im_upper < B.im_lower or B.im_upper < A.im_lower: return false// interval overlap (authoritative)
return true // NO intensity / cosine term
linkSpatial(nodes):
uf = UnionFind(|nodes|)
for A in nodes:
for (dx,dy) in 8-neighbourhood:
int qx = (int)A.x + dx, qy = (int)A.y + dy // signed FIRST: UInt underflow at x==0
if qx<0 or qy<0 or not geometry.hasPixel(qx,qy): continue
sq = geometry.getSpectrumIndex(qx,qy)
if sq == A.spectrum_index: continue // never union within a pixel
for fb in {bin(A.mz)-1, bin(A.mz), bin(A.mz)+1}:
for B in fast_dict[sq][fb]:
if spatialEdge(A,B): uf.union(A.id, B.id) // union ALL qualifiers; order-free, idempotent
return componentsOf(uf, nodes)
What changes vs. the 1‑D linker: drop descending‑intensity ordering, banned_prev_idx_set, and single‑winner tie‑breaks (sequential artifacts); symmetrize the ppm denominator; build one m/z‑bin index per pixel. What ports verbatim: the mz_step/binning, the 3‑bin probe, the IM bin gate.
B-guard — m/z coherence (mandatory under the component model)
A component is defined by transitive linking, so its m/z can drift across space (A~B~…~C with |A−C| ≫ tol). After union‑find, any component whose member‑m/z spread exceeds mz_cap_ppm (default 2·mz_tol_ppm) is re‑split by 1‑D single‑link clustering on member m/z. This is m/z‑identity control — distinct from the rejected intensity‑valley split. (Alternative: seed‑anchored linking that bounds drift at join time, at the cost of reintroducing a global intensity ordering.)
filter — min_area
Drop components with fewer than min_area connected pixels — the spatial analog of min_hill_length and the primary noise filter (real analyte = spatially coherent; noise = scattered).
Stage D — collapse component → Feature
Scalar summary in native Feature slots + scalar meta; footprint / ion image carried as parallel metavalue lists, not a ConvexHull2D (avoids overloading the conventional (RT, m/z) hull axes and the setHullPoints/addPoints recomputation footgun):
sort members row-major by (y, x) // deterministic, reproducible lists
IntList pixel_x, pixel_y; DoubleList pixel_intensity // index-aligned == sparse COO ion image
Feature f
f.setMZ(weighted_mean_mz) // native (dim 1)
f.setIntensity(apex_intensity) // or intensity_sum (intensity_type param)
f.setRT(0.0) // MSI: RT slot meaningless
f.setCharge(0) // set by Stage E if it runs
f.setOverallQuality(n_pixels) // area in the quality slot
f.setMetaValue("pixel_x", pixel_x) // IntList
f.setMetaValue("pixel_y", pixel_y) // IntList
f.setMetaValue("pixel_intensity", pixel_int) // DoubleList (optional: omit and regenerate for huge footprints)
f.setMetaValue("n_pixels", n_pixels)
f.setMetaValue("intensity_sum", intensity_sum)
f.setMetaValue("centroid_x", cx); f.setMetaValue("centroid_y", cy)
f.setMetaValue("apex_x", ax); f.setMetaValue("apex_y", ay)
f.setMetaValue("mz_min", mz_lo); f.setMetaValue("mz_max", mz_hi)
if use_im:
f.setMetaValue(Constants::UserParam::IM, im_apex)
f.setMetaValue("IM_mean", im_mean); f.setMetaValue("IM_min", im_lo); f.setMetaValue("IM_max", im_hi)
A dense IonImage (W×H grid + mask, already in OpenMS/IMAGING/IonImage.h) is reconstructed on demand from the three lists + geometry dims; it is not stored on the feature (keeps featureXML compact).
Stage E — optional charge / isotope assignment by spatial co-localization
The only place cosine correlation appears. Structurally mirrors detectIsotopePatterns_ (same bin lookup, IM gate, cos ≥ threshold), but RT‑profile overlap → pixel‑set overlap and RT‑profile cosine → ion‑image cosine over shared pixels.
ISO_DM = Constants::C13C12_MASSDIFF_U // = 1.0033548378
for mono in features by descending intensity, for z in cmin..cmax:
look for features near mono.mz + k*ISO_DM/z (k=1,2,...) within itol_ppm that
(a) share IM bin, (b) spatially overlap, (c) ion-image cosine >= cos_thresh
keep the longest consistent series -> assign charge z; isotopes -> subordinates / meta
Parameters
| Name | Stage | Default | Meaning |
|---|---|---|---|
mz_tol_ppm |
A, B | 8.0 | m/z link tolerance; sets mz_step |
im_tol |
A, B | dataset | IM bin width / gate (picker-level; not a PASEFHillCentroider::Params field) |
valley_factor |
A | 1.3 | IM-axis hill valley factor (Stage A only) |
min_hill_length |
A | 1 | min IM scans per hill |
min_intensity |
A | 0.0 | input peak floor |
min_area |
filter | 3–4 | min connected pixels per feature |
mz_cap_ppm |
B-guard | 2·mz_tol_ppm |
max component m/z spread before re-split |
itol_ppm, cmin, cmax, cos_thresh |
E | — | isotope/charge search (optional) |
Faithful port vs. extension
- Faithful ports: Stage A centroiding (
centroidFrame+Centroidcollapse); Stage Bmz_step/binning/3‑bin probe and the IM bin gate;min_area≈min_hill_length; Stage E isotope skeleton +cosineCorrelation_+ISO_DM. - Adapted (1‑D → 2‑D): union‑find replaces sequential linking; symmetric ppm; per‑pixel m/z‑bin index; IM interval‑overlap authority.
- Extensions (no code basis): per‑pixel geometry loop + unified
Node; the m/z‑coherence guard; spatial co‑localization in Stage E; the(x,y)metavalue footprint. - Deliberately omitted: intensity‑valley / watershed spatial split.
Hard invariant: Stage B gates on m/z + IM only. Intensity / cosine appear nowhere in spatialEdge; cosine lives only in Stage E.
Open decisions
im_tolunits — absolute IM units (matcheslinkScanToHills_im/paseftol) vs. relative (max_im·tol, matchescentroidPASEFData_). Pick and document.pixel_intensity— store inline (full sparse image) vs. store footprint only and regenerate intensities from the experiment for very large/diffuse features.- m/z‑drift bounding — post‑hoc spread cap (default, fully order‑independent) vs. seed‑anchored linking.
Feature::setIntensity— apex (mirrors Biosaur2) vs. summed; exposeintensity_type.
Implementation sketch / next steps
PeakPickerMSI(DefaultParamHandler) + a small reusableUnionFind.- Stage A delegates to existing
PASEFHillCentroider::centroidFrame. - Stages B + D first (no‑IM and IM paths); Stage E behind a flag.
- Synthetic fixture (a few pixels, two m/z species, one with two IM values) to test A+B+D before wiring E.
Design produced collaboratively; intended as an RFC for discussion before implementation.
- Ngôn ngữ chính
- C++
- Star
- 623
- Fork
- 441
- Merge trung bình
- 1 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 283
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của OpenMS/OpenMS
-
MSstatsFile: Condition written unquoted, so conditions containing commas produce malformed MSstats CSVCó thể đã có người làm @niharikavuchintala đã nhận 5 ngày trước. Đang mởnew contributor
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
OpenMS/OpenMS#10512 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[FEATUREFINDER] Biosaur2Algorithm::centroidProfileSpectra_: dead drift-time guard would drop every FAIMS CVCó thể làm lại được @kavyansh-1 đã nhận 36 ngày trước và không có pull request nào đang mở. Đang mởdefect
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
OpenMS/OpenMS#10005 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
IsobaricWorkflow: a spectrum with several identifications is quantified once per identificationCó thể đã có người làm @timosachsenberg đã nhận hôm nay. Đang mởnew contributor
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 48/100
OpenMS/OpenMS#10603 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
USI: msRun keeps the .mzML extension, and non-Thermo native IDs become scan numbersCó thể đã có người làm @kg290 đã nhận 1 ngày trước. Đang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
OpenMS/OpenMS#10591 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
ProSE: rescore all runs of a multi-file search with one joint Percolator modelCó thể đã có người làm @satyam102006 đã nhận hôm nay. Đang mở
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 8/100
OpenMS/OpenMS#10581 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của OpenMS/OpenMS
Issue tương tự
-
agent:WSL bug code-review linux LOW
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 88/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[raylib] update to 6.2Đang mởcategory:port-update
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
Maintainer thường phản hồi trong vòng 2 ngày
-
improvement ui
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
ihhub/fheroes2#11062 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement question
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
DarkStarSystems/pcons#224 ·
Maintainer thường phản hồi trong vòng 1 ngày