Raman Preprocessing Methods¶
Implementation reference for focus/preprocessing/raman.py. RamanDataset.process_dataset() iterates
over samples and, for each one, calls load_source(), basic_correct(), remove_background(),
process_raw_tiles() and ashlar_stitch() in that order, skipping the whole sample when its final
OME-TIFF already exists and force_recomputing is false. Exceptions raised while processing a sample
are caught, printed as Error processing sample <id>: <error>, and the loop moves to the next sample.
1. Input and metadata extraction¶
load_source() scans {dataset}/{sample_id}/{modality}/ and loads the first entry whose name ends in
.lif (case-insensitive); with no such file it raises FileNotFoundError.
_parse_lif_metadata walks the LIF XML (readlif's xml_root) and returns one RamanMetadata per
image element. Elements are collected from the Children container of each top-level Element, with
fallbacks for LIF files whose header is a flat LMSDataContainerHeader; when nothing is found it
raises ValueError("No elements found in the LIF file or unexpected XML structure.").
Per element:
| Field | XML source |
|---|---|
scan_height, pixel_size[0] |
Data/Image/ImageDescription/Dimensions/DimensionDescription[@DimID=1]: NumberOfElements, and Length / NumberOfElements |
scan_width, pixel_size[1] |
same, @DimID=2 |
lambda_steps |
same, @DimID=9: NumberOfElements |
tile_number |
same, @DimID=10: NumberOfElements |
lambda_begin, lambda_end |
LambdaDefinition/LambdaExcitation: LambdaExcitationBeginDouble / LambdaExcitationEndDouble, falling back to the non-Double attribute names |
lambda_stokes, laser_type |
first LaserArray/Laser element carrying a PumpWavelength, plus its LaserName |
tiles_coordinates |
Data/Image/Attachment[@Name="TileScanInfo"]/Tile: PosX, PosY |
The wavelength and laser attributes are searched at
Data/Image/Attachment[@Name="HardwareSetting"]/ATLConfocalSettingDefinition and, if not found there,
at the same path under LDM_Block_Sequential/LDM_Block_Sequential_Master.
Units: each spatial DimensionDescription carries a Unit, m or um, giving a scaling factor of
\(10^6\) or \(1\) to micrometers; any other unit raises ValueError. The same factor converts the
tile PosX/PosY to micrometers, so tile coordinates and pixel sizes are both in µm. When the number
of Tile entries does not match DimID=10, a RuntimeWarning line is printed and the element is
dropped.
_load_lif then keeps only elements with tile_number >= 2. Single-field acquisitions and any
automatically stitched image are ignored. An element still missing tile_number,
lambda_steps, scan_width or scan_height is reported as a probably corrupted scan and skipped;
a missing tiles_coordinates or a non-positive pixel_size raises ValueError.
Pixel data is read plane by plane through readlif
(image.get_plane(display_dims=(1, 2), c=0, requested_dims={9: spectral_index, 10: tile_index})) into
a float32 array of shape (tile_number, lambda_steps, scan_width, scan_height). float32 is
required by the BaSiC step downstream.
2. Wavenumber axis¶
For each element, with \(\lambda_i\) the lambda_steps values of
linspace(lambda_begin, lambda_end, lambda_steps) and \(\lambda_{stokes}\) the laser PumpWavelength
(_compute_wavenumbers):
The factor \(10^7\) converts \(\text{nm}^{-1}\) to \(\text{cm}^{-1}\). The concatenated result is
stored as float32 on RamanImage.wavenumbers.
The axis is consumed only by §5, as the spectral axis handed to RamanSPy. It is never written to disk: neither the stitched OME-TIFF nor any sidecar file records it.
3. Merging scans, overlap trimming, intensity scaling¶
Elements that pass the filters above are merged into single arrays:
- tile data concatenated along the channel axis, in element order (requires identical tile count and tile size across elements);
- wavenumber vectors concatenated in the same order;
- tile coordinates stacked to
(tiles, scans, 2); - pixel sizes averaged over elements into one
(2,)vector.
Each element's channel range is recorded in _spectra_slices as an inclusive (start, end) pair.
These slices are the unit of work in §5 and the unit of output in §6, and they allow one sample to
carry several acquisitions with different spectral ranges.
Overlap trimming (_check_wavenumbers_overlaps). Within one scan the wavelength axis is a
linspace, so the wavenumber axis is monotonic; a concatenation of scans that re-cover part of the
spectrum is not. The first index \(b\) where monotonicity breaks is located, the index
\(c=\arg\min_{i<b}\lvert\tilde\nu_i-\tilde\nu_b\rvert\) marks where the overlap starts in the preceding
block, and channels \([c, b)\) are dropped from the wavenumber vector and from the tile array. The
slice boundaries are re-indexed by the shift \(b-c\): slices before the affected one are unchanged, the
affected one is truncated at \(c-1\), the one containing \(b\) restarts at \(c\), and later slices are
shifted down. A monotonic axis is returned untouched, and only one overlapping region is removed.
Intensity scaling. The merged stack is rescaled once, from the global maximum: values in
\((1, 255]\) are divided by 255, values in \((255, 65535]\) by 65535, a maximum above 65535 raises
ValueError, and a maximum at or below 1 is left as is.
The RamanImage.metadata object that results is a summary of the merged stack, not a copy of any
single element's metadata. Its tile_number and lambda_steps are the merged array's first two
dimensions, its scan_height and scan_width are its last two, and its pixel_size is the
cross-scan mean. The per-element arrays are allocated as
(tiles, channels, scan_width, scan_height), so scan_height and scan_width are swapped on this
summary object relative to the per-element metadata. Their only consumer, the
tile_size=(scan_height, scan_width) argument of _extract_tiles_segmentation_from_mosaic in §5,
reads them in the merged array's own axis order.
4. BaSiC correction¶
basic_correct() runs BaSiCpy channel-wise in the external conda environment FOCUS_BaSiCpy. Before
starting, it verifies that conda is on PATH, that a conda environment path containing
FOCUS_BaSiCpy is listed by conda env list --json, and that tools/BaSiCpy/main.py exists; each
failed check raises.
Per channel \(c\):
- Write
basic_input_{c}.npy, the(tiles, rows, columns)slice for that channel. conda run -n FOCUS_BaSiCpy python tools/BaSiCpy/main.py {output_path} {c}, withJAX_PLATFORM_NAME=cpuin the subprocess environment.main.pycallsBaSiC().fit(...)followed by.transform(...)on that stack and writesbasic_output_{c}.npy.- Poll for the output file for up to 10 s (0.2 s interval), then load it and delete both temporaries.
A missing file at the end of the timeout raises
TimeoutError.
Channels are dispatched concurrently with ThreadPoolExecutor(max_workers). When every channel has
returned, the assembled float32 tensor is min-max normalized globally (one minimum and one
maximum over all tiles and channels, applied only when the maximum exceeds the minimum) and cached to
basic_corrected_tiles.npy.
5. Background removal¶
remove_background() segments tissue once on a quick-stitched preview mosaic (_quick_stitch) and
back-projects the mask onto the tiles. It requires the BaSiC-corrected tiles to be in memory and
raises RuntimeError otherwise, even when its own cache exists.
- Distance-transform feathered blend. Tiles are placed at their stage coordinates divided by
pixel_size[0]. Each tile is weighted by the Euclidean distance transform of its footprint, normalized to its maximum, \(w(u,v)=\mathrm{EDT}(u,v)/\max \mathrm{EDT}\), so a pixel near the tile centre carries more weight than one near its border. The mosaic is the weighted sum divided by the accumulated weights, \(M = \bigl(\sum_t w_t\,T_t\bigr) \big/ \bigl(\sum_t w_t\bigr)\), per channel, with zeros wherever no tile contributed. - PCA to one component. Pixels that are zero in every channel are excluded; an all-zero mosaic
raises
RuntimeError("The mosaic is completely black."). The remaining \(C\)-channel vectors are projected onto their first principal component (sklearn.decomposition.PCA(n_components=1)). The scores are clipped to their 2nd and 98th percentiles and rescaled to \([0,1]\); excluded pixels are written back as 0. - CLAHE. The grayscale image is scaled to
uint8and equalized withcv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)). The result is the preview mosaic used below. - Otsu segmentation. The preview mosaic is clipped at its 95th percentile and Otsu's threshold is
computed on that copy (between-class-variance criterion, as in
microscopy). The threshold is
multiplied by
otsu_threshold_factor(default 0.7), truncated to an integer, and applied to the unclipped preview mosaic. - Morphological cleanup.
skimage.morphology.remove_small_objects(mask, max_size=min_object_size)removes connected components (4-connectivity) whose area is at mostmin_object_sizepixels (themax_sizeargument is inclusive), followed byscipy.ndimage.binary_fill_holes. - Contour area filter. External contours (
cv2.RETR_EXTERNAL) are kept when their area is at leastbg_min_area_fraction× (mosaic height × mosaic width) and filled to form the tissue mask. Whencv2.findContoursreturns nothing, a warning is printed and the mask from step 5 is kept unrefined. - Back-projection.
_extract_tiles_segmentation_from_mosaiccuts the mosaic mask at each tile's position into a boolean array shaped like the tile stack, using that tile's coordinates for the scan the channel belongs to. That array multiplies the BaSiC-corrected tiles. Background pixels become exactly 0 in every channel. The masked tiles are cached tosegmented_tiles.npyand replace the BaSiC-corrected tiles in memory, so §6 operates on them.
6. Spectral cleaning (RamanSPy)¶
process_raw_tiles() builds one work unit per (tile, spectra slice) pair, so each scan's channel
block is cleaned independently with its own wavenumber sub-range. Units are dispatched with
joblib.Parallel(n_jobs=max_workers, return_as="generator"); parallel=False runs the identical
per-unit function in a plain loop.
Inside _process_tile_parallel the block is reshaped to one spectrum per pixel and run through a fixed
rp.preprocessing.Pipeline, in order:
- Whitaker-Hayes despiking (
WhitakerHayes(), RamanSPy defaultskernel_size=3,threshold=8): points whose modified \(z\)-score \(\lvert 0.6745\,(d_i-\mathrm{med}(d))/\mathrm{MAD}(d)\rvert\) on the consecutive-difference series \(d=\mathrm{diff}(y)\) exceeds the threshold are flagged as cosmic-ray spikes; each flagged point is replaced by the mean of the non-flagged points within \(\pm\)kernel_size, iterating until no further point can be fixed. - Savitzky-Golay denoising (
SavGol(window_length=savgol_window, polyorder=savgol_polyorder)): least-squares fit of a degree-savgol_polyorderpolynomial (default 3) within a sliding window ofsavgol_windowchannels (default 7), evaluated at the window centre; smooths noise while preserving peak shape. SciPy raisesValueErrorwhen the polynomial order is not smaller than the window, or when the window is longer than the block being filtered. - IASLS baseline correction (
IASLS(), RamanSPy defaultslam=1e6,p=1e-2,lam_1=1e-4,max_iter=50,tol=1e-3,diff_order=2): Improved Asymmetric Least Squares estimates a smooth baseline \(z\) minimizing an asymmetrically weighted penalized least-squares objective \(\sum_i w_i (y_i-z_i)^2 + \lambda \lVert D^2 z\rVert^2\) with an additional first-derivative term, where the weights \(w_i\) penalize points above the baseline far less than those below, and subtracts \(z\). - Min-max normalization (
MinMax(), RamanSPy defaultpixelwise=True): each spectrum is rescaled to \([0,1]\) individually.
Only the Savitzky-Golay window and polynomial order are exposed as parameters.
Zero-variance spectra. _zero_variance_spectra computes the median absolute deviation of each
spectrum's consecutive-difference series and flags the spectra whose MAD is exactly 0. These are flat
spectra, including the background pixels zeroed in §5. That MAD is the denominator of the Whitaker-Hayes
modified \(z\)-score, so those spectra are dropped from the unit before the pipeline runs and their
pixels are written back as zeros. A unit in which every spectrum is flat returns an all-zero block
without invoking the pipeline.
The cleaned stack is cached to raman_corrected_tiles.npy.
7. ASHLAR stitching¶
_prepare_for_ashlar prepares the inputs:
- mirror the tile \(y\) coordinates within their own range per scan, \(y' = \max y + \min y - y\) (Leica → OME-TIFF convention);
np.nan_to_num, then scale by 255 and cast touint8;- choose the alignment channel from the first spectra slice: take each channel's per-tile mean over
the tile's pixels, reduce over tiles with a maximum, and select the
argmaxover channels; - write one
ashlar_input_cycle_{n}.ome.tiffper spectra slice, one OME image per tile, zlib compressed, carryingPhysicalSizeX/PhysicalSizeYin µm, per-planePositionX/PositionYin µm, and channel namesChannel_0 … Channel_{C-1}.
ashlar_stitch() then runs conda run -n FOCUS_ASHLAR python -u tools/ASHLAR/main.py {output_path}
{align_channel} with check=True, so a non-zero exit propagates as CalledProcessError. The script:
- sorts the
ashlar_input*files by name; - builds a Bio-Formats reader per file (hence the Java dependency) and calls
process_axis_flip(reader, False, False), which leaves the tile positions as written; - runs
reg.EdgeAligneron the first cycle withchannel=align_channelandmax_shift=15µm (do_make_thumbnail=Falsewhen there is only one cycle), then areg.LayerAlignerper further cycle against that result, with the samechannelandmax_shift; - writes every cycle's mosaic through
reg.PyramidWriter, withpeak_sizecomputed so the smallest pyramid level holds at most \(3000\times3000 = 9\times10^6\) pixels; - deletes the cycle input files.
ASHLAR reads the physical pixel size from the cycle files and refuses inputs whose PhysicalSizeX and
PhysicalSizeY differ by more than a relative tolerance of 1e-4 (Can't handle non-square pixels).
FOCUS renames ashlar_output.ome.tiff to the final preprocessing output path and loads it into
RamanImage.mosaic.
8. Caching and intermediates¶
Stage outputs are cached in the sample's preprocessing directory:
| File | Stage |
|---|---|
basic_corrected_tiles.npy |
§4 |
segmented_tiles.npy |
§5 |
raman_corrected_tiles.npy |
§6 |
Each stage loads its cache instead of recomputing whenever the file exists and force_recomputing is
false; the caches record no parameter values, so a changed parameter is only honoured with
force_recomputing: true or after the files are removed. §1 is never cached: the LIF file is re-read
on every run that does not already have a final OME-TIFF, because the tile coordinates, pixel size and
slice boundaries feed the later stages.
process_dataset() deletes all three files after the sample's final OME-TIFF has been produced, so
they persist only after an interrupted or failed run. In that case they let a rerun resume
mid-sample.
9. Parameters reflected by implementation¶
Defaults come from the RamanImage class constants and are applied both by the config settings
extractor (_extract_raman_settings) and by the RamanDataset.process_dataset() signature. They are
written below as the configuration values you would supply.
| Parameter | Default | Stage |
|---|---|---|
force_recomputing |
false |
all |
max_workers |
8 | §4 (threads), §6 (joblib workers) |
savgol_window |
7 | §6 |
savgol_polyorder |
3 | §6 |
bg_min_area_fraction |
0.05 | §5 |
otsu_threshold_factor |
0.7 | §5 |
min_object_size |
500 | §5 |
max_workers is also read by _create_raman_samples, which passes it to each RamanImage
constructor; process_dataset() re-assigns it on every sample before processing.
10. Outputs¶
Per sample:
uint8, one channel per surviving spectral channel in scan order, Adobe-Deflate compressed with a
horizontal predictor in 1024 × 1024 tiles, with 2× pyramid levels. The OME-XML holds the ASHLAR creator
string and PhysicalSizeX/PhysicalSizeY in µm, and no channel names. As a result,
raman_pixel_interpolation names the registered features Channel_0 … Channel_{C-1}, and the
wavenumber axis is not recoverable from the file. No merged file is produced for Raman.
The stitched OME-TIFF is the input to the alignment stage and to
raman_pixel_interpolation registration,
both of which work in its pixel coordinates.