1369 lines
49 KiB
Julia
1369 lines
49 KiB
Julia
# src/imzML.jl
|
||
using Images, Statistics, CairoMakie, DataFrames, Printf, ColorSchemes, StatsBase
|
||
|
||
# --- Extracted from imzML.jl ---
|
||
|
||
"""
|
||
This file provides a library for parsing `.imzML` and `.ibd` files in pure Julia.
|
||
It is intended to be included by a parent script.
|
||
|
||
Core Functions:
|
||
- `load_imzml_lazy`: The main function that orchestrates the parsing.
|
||
- Helper functions for reading XML metadata and binary spectral data.
|
||
"""
|
||
|
||
|
||
# ============================================================================
|
||
#
|
||
#
|
||
# imzML Parser Implementation
|
||
#
|
||
# ============================================================================
|
||
|
||
"""
|
||
axes_config_img(stream)
|
||
|
||
Determines the storage order of the m/z and intensity arrays.
|
||
"""
|
||
function axes_config_img(stream)
|
||
param_groups = Dict{String, SpecDim}()
|
||
find_tag(stream, r"<referenceableParamGroupList")
|
||
|
||
while true
|
||
pos = position(stream)
|
||
line = readline(stream)
|
||
|
||
if eof(stream) || occursin("</referenceableParamGroupList>", line)
|
||
break
|
||
end
|
||
|
||
id_match = match(r"<referenceableParamGroup id=\"([^\"]+)\"", line)
|
||
if id_match !== nothing
|
||
id = id_match.captures[1]
|
||
spec_dim = configure_spec_dim(stream)
|
||
param_groups[id] = spec_dim
|
||
end
|
||
end
|
||
return param_groups
|
||
end
|
||
|
||
"""
|
||
get_img_dimensions(stream)
|
||
|
||
Reads the maximum X and Y dimensions and total spectrum count from the metadata.
|
||
"""
|
||
function get_img_dimensions(stream)
|
||
find_tag(stream, r"^\s*<(scanSettings )")
|
||
n = 2
|
||
dim = [0, 0, 0]
|
||
|
||
while n > 0 && !eof(stream)
|
||
currLine = readline(stream)
|
||
if occursin("<cvParam", currLine)
|
||
accession_match = get_attribute(currLine, "accession")
|
||
if accession_match !== nothing
|
||
accession = accession_match.captures[1]
|
||
if accession == "IMS:1000042" || accession == "IMS:1000043"
|
||
value_match = get_attribute(currLine, "value")
|
||
if value_match !== nothing
|
||
axis_idx = (accession == "IMS:1000042") ? 1 : 2
|
||
dim[axis_idx] = parse(Int32, value_match.captures[1])
|
||
n -= 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
count_tag = find_tag(stream, r"^\s*<spectrumList(.+)")
|
||
count_match = get_attribute(count_tag.captures[1], "count")
|
||
dim[3] = parse(Int32, count_match.captures[1])
|
||
return dim
|
||
end
|
||
|
||
"""
|
||
get_spectrum_tag_offset(stream)
|
||
|
||
Calculates the character offset within a `<spectrum>` tag, ignoring attribute values.
|
||
"""
|
||
function get_spectrum_tag_offset(stream)
|
||
offset = position(stream)
|
||
tag = find_tag(stream, r"^\s*<spectrum (.+)")
|
||
first = 1
|
||
|
||
while true
|
||
value = match(r"[^=]+\"([^\"]+)\"", tag.captures[1][first:end])
|
||
if value === nothing
|
||
break
|
||
end
|
||
first += value.offsets[1] + length(value.captures[1])
|
||
offset += length(value.captures[1])
|
||
end
|
||
return offset
|
||
end
|
||
|
||
"""
|
||
get_spectrum_attributes(stream, hIbd)
|
||
|
||
Reads metadata to determine the byte offsets and data types for reading spectra.
|
||
"""
|
||
function get_spectrum_attributes(stream, hIbd)
|
||
skip = Vector{UInt32}(undef, 8)
|
||
offset = get_spectrum_tag_offset(stream)
|
||
|
||
tag = find_tag(stream, r" accession=\"IMS:100005(\d)\"(.+)")
|
||
skip[1] = tag.captures[1][1] - '0' + 1
|
||
skip[2] = xor(skip[1], 3)
|
||
|
||
value = match(r" value=\"(\d+)\".+", tag.captures[2])
|
||
skip[5] = position(stream) - offset - length(value.match) - 2
|
||
value = match(r" value=\"\d+\".+", readline(stream))
|
||
skip[6] = value.offset
|
||
|
||
offset = position(stream)
|
||
tag = find_tag(stream, r"^\s*<referenceableParamGroupRef(.+)")
|
||
value = get_attribute(tag.captures[1], "ref")
|
||
skip[3] = (value.captures[1] == "intensityArray") + 3
|
||
skip[4] = xor(skip[3], 7)
|
||
|
||
k = 2
|
||
while k != 0
|
||
tag = find_tag(stream, r" accession=\"IMS:100010(\d)\"(.+)")
|
||
accession_val = tag.captures[1][1]
|
||
if accession_val == '2'
|
||
value = get_attribute(tag.match, "value")
|
||
seek(hIbd, parse(Int64, value.captures[1]))
|
||
k -= 1
|
||
elseif accession_val == '3'
|
||
skip[7] = position(stream) - offset - length(tag.match)
|
||
offset = position(stream)
|
||
k -= 1
|
||
end
|
||
end
|
||
|
||
find_tag(stream, r"^\s*</spectrum>")
|
||
skip[8] = position(stream) - offset
|
||
return skip
|
||
end
|
||
|
||
function determine_parser(stream, mz_is_compressed, int_is_compressed)
|
||
start_pos = position(stream)
|
||
spectrum_xml = ""
|
||
|
||
try
|
||
# Find the start of the first spectrum tag
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
if occursin("<spectrum ", line)
|
||
spectrum_buffer = IOBuffer()
|
||
write(spectrum_buffer, line)
|
||
# Read until the end of the spectrum tag
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
write(spectrum_buffer, line)
|
||
if occursin("</spectrum>", line)
|
||
break
|
||
end
|
||
end
|
||
spectrum_xml = String(take!(spectrum_buffer))
|
||
break # Found the first spectrum, so we can stop
|
||
end
|
||
end
|
||
finally
|
||
seek(stream, start_pos) # Always reset stream position
|
||
end
|
||
|
||
if isempty(spectrum_xml)
|
||
# Fallback based on compression flags if no spectrum tag found
|
||
return (mz_is_compressed || int_is_compressed) ? :compressed : :uncompressed
|
||
end
|
||
|
||
# Inspect the XML content
|
||
has_neofx_markers = occursin("encodedLength=\"0\"", spectrum_xml) &&
|
||
occursin("external encoded length", spectrum_xml)
|
||
|
||
has_external_data_markers = occursin("IMS:1000101", spectrum_xml) &&
|
||
occursin("IMS:1000102", spectrum_xml) &&
|
||
occursin("IMS:1000103", spectrum_xml)
|
||
|
||
if has_neofx_markers
|
||
return :neofx
|
||
end
|
||
|
||
if mz_is_compressed || int_is_compressed || has_external_data_markers
|
||
return :compressed
|
||
end
|
||
|
||
return :uncompressed
|
||
end
|
||
|
||
function load_imzml_lazy(file_path::String; cache_size=100)
|
||
println("DEBUG: Checking for .imzML file at $file_path")
|
||
if !isfile(file_path)
|
||
error("Provided path is not a file: $(file_path)")
|
||
end
|
||
|
||
ibd_path = replace(file_path, r"\.(imzML|mzML)"i => ".ibd")
|
||
println("DEBUG: Checking for .ibd file at $ibd_path")
|
||
if !isfile(ibd_path)
|
||
error("Corresponding .ibd file not found for: $(file_path)")
|
||
end
|
||
|
||
println("DEBUG: Opening file streams for .imzML and .ibd")
|
||
stream = open(file_path, "r")
|
||
hIbd = open(ibd_path, "r")
|
||
|
||
try
|
||
println("DEBUG: Configuring axes...")
|
||
param_groups = axes_config_img(stream)
|
||
println("DEBUG: Getting image dimensions...")
|
||
imgDim = get_img_dimensions(stream)
|
||
width, height, num_spectra = imgDim
|
||
println("DEBUG: Image dimensions: $(width)x$(height), $num_spectra spectra.")
|
||
|
||
# Extract default formats from the parsed param_groups
|
||
mz_group = nothing
|
||
int_group = nothing
|
||
|
||
for group in values(param_groups)
|
||
if group.Axis == 1
|
||
mz_group = group
|
||
elseif group.Axis == 2
|
||
int_group = group
|
||
end
|
||
end
|
||
|
||
if mz_group === nothing || int_group === nothing
|
||
@warn "Could not find global definitions for m/z and intensity arrays. Using hardcoded defaults (Float64)."
|
||
default_mz_format = Float64
|
||
default_intensity_format = Float64
|
||
mz_is_compressed = false
|
||
int_is_compressed = false
|
||
global_mode = UNKNOWN
|
||
else
|
||
default_mz_format = mz_group.Format
|
||
default_intensity_format = int_group.Format
|
||
mz_is_compressed = mz_group.Packed
|
||
int_is_compressed = int_group.Packed
|
||
global_mode = mz_group.Mode != UNKNOWN ? mz_group.Mode : int_group.Mode
|
||
end
|
||
|
||
println("DEBUG: m/z format: $default_mz_format, Intensity format: $default_intensity_format")
|
||
println("DEBUG: m/z compressed: $mz_is_compressed, Intensity compressed: $int_is_compressed")
|
||
println("DEBUG: Global mode: $global_mode")
|
||
|
||
# --- Parser Selection ---
|
||
parser_type = determine_parser(stream, mz_is_compressed, int_is_compressed)
|
||
println("DEBUG: Selected parser: $parser_type")
|
||
|
||
local spectra_metadata
|
||
if parser_type == :neofx
|
||
println("DEBUG: Using neofx parser.")
|
||
spectra_metadata = parse_neofx(stream, hIbd, param_groups, width, height, num_spectra,
|
||
default_mz_format, default_intensity_format,
|
||
mz_is_compressed, int_is_compressed, global_mode)
|
||
elseif parser_type == :compressed
|
||
println("DEBUG: Using compressed parser.")
|
||
spectra_metadata = parse_compressed(stream, hIbd, param_groups, width, height, num_spectra,
|
||
default_mz_format, default_intensity_format,
|
||
mz_is_compressed, int_is_compressed, global_mode)
|
||
else # :uncompressed
|
||
println("DEBUG: Using uncompressed parser.")
|
||
spectra_metadata = parse_uncompressed(stream, hIbd, param_groups, width, height, num_spectra,
|
||
default_mz_format, default_intensity_format,
|
||
mz_is_compressed, int_is_compressed, global_mode)
|
||
end
|
||
|
||
println("DEBUG: Metadata parsing complete.")
|
||
|
||
# Build coordinate map for imzML files
|
||
println("DEBUG: Building coordinate map...")
|
||
coordinate_map = zeros(Int, width, height)
|
||
for (idx, meta) in enumerate(spectra_metadata)
|
||
if idx == 1
|
||
println("DIAGNOSTIC_WRITE: For index 1, attempting to write to coordinate_map[$(meta.x), $(meta.y)]")
|
||
end
|
||
if 1 <= meta.x <= width && 1 <= meta.y <= height
|
||
coordinate_map[meta.x, meta.y] = idx
|
||
end
|
||
end
|
||
println("DEBUG: Coordinate map built.")
|
||
|
||
close(stream)
|
||
|
||
source = ImzMLSource(hIbd, default_mz_format, default_intensity_format)
|
||
println("DEBUG: Creating MSIData object.")
|
||
return MSIData(source, spectra_metadata, (width, height), coordinate_map, cache_size)
|
||
|
||
catch e
|
||
close(stream)
|
||
close(hIbd)
|
||
rethrow(e)
|
||
end
|
||
end
|
||
|
||
function parse_uncompressed(stream, hIbd, param_groups, width, height, num_spectra,
|
||
mz_format, intensity_format, mz_is_compressed, int_is_compressed, global_mode)
|
||
# Your existing working skip-based parser
|
||
println("DEBUG: Learning file structure from first spectrum...")
|
||
start_of_spectra_xml = position(stream)
|
||
attr = get_spectrum_attributes(stream, hIbd)
|
||
current_ibd_offset = position(hIbd)
|
||
seek(stream, start_of_spectra_xml)
|
||
println("DEBUG: Initial IBD offset: $current_ibd_offset")
|
||
|
||
spectra_metadata = Vector{SpectrumMetadata}(undef, num_spectra)
|
||
mz_is_first = attr[3] == 3
|
||
|
||
for k in 1:num_spectra
|
||
# Store the start position of this spectrum for mode detection
|
||
spectrum_start_pos = position(stream)
|
||
|
||
# Use skip values learned from the first spectrum
|
||
skip(stream, attr[5]) # Skip to X coordinate value
|
||
val_tag_x = find_tag(stream, r"value=\"(\d+)\"")
|
||
x = parse(Int32, val_tag_x.captures[1])
|
||
|
||
skip(stream, attr[6]) # Skip to Y coordinate value
|
||
val_tag_y = find_tag(stream, r"value=\"(\d+)\"")
|
||
y = parse(Int32, val_tag_y.captures[1])
|
||
|
||
skip(stream, attr[7]) # Skip to array length value
|
||
val_tag_len = find_tag(stream, r"value=\"(\d+)\"")
|
||
nPoints = parse(Int32, val_tag_len.captures[1])
|
||
|
||
# For uncompressed data, use simple calculation
|
||
mz_len_bytes = nPoints * sizeof(mz_format)
|
||
int_len_bytes = nPoints * sizeof(intensity_format)
|
||
|
||
local mz_offset, int_offset
|
||
if mz_is_first
|
||
mz_offset = current_ibd_offset
|
||
int_offset = mz_offset + mz_len_bytes
|
||
else
|
||
int_offset = current_ibd_offset
|
||
mz_offset = int_offset + int_len_bytes
|
||
end
|
||
|
||
# Mode detection from spectrum XML
|
||
current_pos = position(stream)
|
||
seek(stream, spectrum_start_pos)
|
||
spectrum_buffer = IOBuffer()
|
||
line = ""
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
write(spectrum_buffer, line)
|
||
if occursin("</spectrum>", line)
|
||
break
|
||
end
|
||
end
|
||
spectrum_xml = String(take!(spectrum_buffer))
|
||
|
||
spectrum_mode = global_mode
|
||
if occursin("MS:1000127", spectrum_xml)
|
||
spectrum_mode = CENTROID
|
||
elseif occursin("MS:1000128", spectrum_xml)
|
||
spectrum_mode = PROFILE
|
||
end
|
||
seek(stream, current_pos)
|
||
|
||
# Create SpectrumAsset objects
|
||
mz_asset = SpectrumAsset(mz_format, mz_is_compressed, mz_offset, nPoints, :mz)
|
||
int_asset = SpectrumAsset(intensity_format, int_is_compressed, int_offset, nPoints, :intensity)
|
||
|
||
spectra_metadata[k] = SpectrumMetadata(x, y, "", spectrum_mode, mz_asset, int_asset)
|
||
|
||
current_ibd_offset += mz_len_bytes + int_len_bytes
|
||
skip(stream, attr[8]) # Skip to the end of the spectrum tag
|
||
end
|
||
|
||
return spectra_metadata
|
||
end
|
||
|
||
function parse_compressed(stream, hIbd, param_groups, width, height, num_spectra,
|
||
default_mz_format, default_intensity_format,
|
||
mz_is_compressed, int_is_compressed, global_mode)
|
||
# New parser for compressed data
|
||
spectra_metadata = Vector{SpectrumMetadata}(undef, num_spectra)
|
||
|
||
for k in 1:num_spectra
|
||
# Read the full spectrum XML block
|
||
spectrum_buffer = IOBuffer()
|
||
line = ""
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
if occursin("<spectrum ", line)
|
||
write(spectrum_buffer, line)
|
||
break
|
||
end
|
||
end
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
write(spectrum_buffer, line)
|
||
if occursin("</spectrum>", line)
|
||
break
|
||
end
|
||
end
|
||
spectrum_xml = String(take!(spectrum_buffer))
|
||
|
||
# Parse coordinates
|
||
x_match = match(r"IMS:1000050.*?value=\"(\d+)\"", spectrum_xml)
|
||
y_match = match(r"IMS:1000051.*?value=\"(\d+)\"", spectrum_xml)
|
||
x = x_match !== nothing ? parse(Int32, x_match.captures[1]) : Int32(0)
|
||
y = y_match !== nothing ? parse(Int32, y_match.captures[1]) : Int32(0)
|
||
|
||
# Parse mode
|
||
spectrum_mode = global_mode
|
||
if occursin("MS:1000127", spectrum_xml)
|
||
spectrum_mode = CENTROID
|
||
elseif occursin("MS:1000128", spectrum_xml)
|
||
spectrum_mode = PROFILE
|
||
end
|
||
|
||
# Parse binary data arrays
|
||
array_data = []
|
||
|
||
# Find all binaryDataArray blocks
|
||
array_matches = eachmatch(r"<binaryDataArray.*?<\/binaryDataArray>"s, spectrum_xml)
|
||
for array_match in array_matches
|
||
array_xml = array_match.match
|
||
|
||
# Determine if this is m/z or intensity array
|
||
is_mz = occursin("MS:1000514", array_xml) || occursin("mzArray", array_xml)
|
||
|
||
# Parse external data parameters
|
||
# Get array_length (nPoints)
|
||
array_len_cv_match = match(r"IMS:1000103.*?value=\"(\d+)\"", array_xml)
|
||
array_length = 0
|
||
if array_len_cv_match !== nothing
|
||
array_length = parse(Int32, array_len_cv_match.captures[1])
|
||
end
|
||
if array_length == 0
|
||
nPoints_match = match(r"defaultArrayLength=\"(\d+)\"", spectrum_xml)
|
||
if nPoints_match !== nothing
|
||
array_length = parse(Int32, nPoints_match.captures[1])
|
||
end
|
||
end
|
||
|
||
# Get encoded_length
|
||
encoded_len_cv_match = match(r"IMS:1000104.*?value=\"(\d+)\"", array_xml)
|
||
encoded_length = 0
|
||
if encoded_len_cv_match !== nothing
|
||
encoded_length = parse(Int64, encoded_len_cv_match.captures[1])
|
||
else
|
||
encoded_len_attr_match = match(r"encodedLength=\"(\d+)\"", array_xml)
|
||
if encoded_len_attr_match !== nothing
|
||
encoded_length = parse(Int64, encoded_len_attr_match.captures[1])
|
||
end
|
||
end
|
||
|
||
# Get offset
|
||
offset_match = match(r"IMS:1000102.*?value=\"(\d+)\"", array_xml)
|
||
offset = 0
|
||
if offset_match !== nothing
|
||
offset = parse(Int64, offset_match.captures[1])
|
||
end
|
||
|
||
if array_length > 0 && offset > 0
|
||
push!(array_data, (
|
||
is_mz = is_mz,
|
||
array_length = array_length,
|
||
encoded_length = encoded_length,
|
||
offset = offset
|
||
))
|
||
end
|
||
end
|
||
|
||
# Separate m/z and intensity arrays
|
||
mz_data = filter(d -> d.is_mz, array_data)
|
||
int_data = filter(d -> !d.is_mz, array_data)
|
||
|
||
if length(mz_data) != 1 || length(int_data) != 1
|
||
error("Spectrum $k: Expected exactly one m/z and one intensity array")
|
||
end
|
||
|
||
mz_info = mz_data[1]
|
||
int_info = int_data[1]
|
||
|
||
# DEBUG: Print first spectrum details
|
||
if k == 1
|
||
println("DEBUG First spectrum parsed:")
|
||
println(" Coordinates: x=$x, y=$y")
|
||
println(" Mode: $spectrum_mode")
|
||
println(" m/z array: array_length=$(mz_info.array_length), encoded_length=$(mz_info.encoded_length), offset=$(mz_info.offset)")
|
||
println(" intensity array: array_length=$(int_info.array_length), encoded_length=$(int_info.encoded_length), offset=$(int_info.offset)")
|
||
println(" Expected m/z bytes: $(mz_info.array_length * sizeof(default_mz_format))")
|
||
println(" Expected intensity bytes: $(int_info.array_length * sizeof(default_intensity_format))")
|
||
end
|
||
|
||
# Create SpectrumAsset objects
|
||
mz_asset = SpectrumAsset(default_mz_format, mz_is_compressed, mz_info.offset,
|
||
mz_is_compressed ? mz_info.encoded_length : mz_info.array_length, :mz)
|
||
int_asset = SpectrumAsset(default_intensity_format, int_is_compressed, int_info.offset,
|
||
int_is_compressed ? int_info.encoded_length : int_info.array_length, :intensity)
|
||
|
||
spectra_metadata[k] = SpectrumMetadata(x, y, "", spectrum_mode, mz_asset, int_asset)
|
||
end
|
||
|
||
return spectra_metadata
|
||
end
|
||
|
||
function parse_neofx(stream, hIbd, param_groups, width, height, num_spectra,
|
||
default_mz_format, default_intensity_format,
|
||
mz_is_compressed, int_is_compressed, global_mode)
|
||
# New parser for compressed data
|
||
spectra_metadata = Vector{SpectrumMetadata}(undef, num_spectra)
|
||
|
||
for k in 1:num_spectra
|
||
# Read the full spectrum XML block
|
||
spectrum_buffer = IOBuffer()
|
||
line = ""
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
if occursin("<spectrum ", line)
|
||
write(spectrum_buffer, line)
|
||
break
|
||
end
|
||
end
|
||
while !eof(stream)
|
||
line = readline(stream)
|
||
write(spectrum_buffer, line)
|
||
if occursin("</spectrum>", line)
|
||
break
|
||
end
|
||
end
|
||
spectrum_xml = String(take!(spectrum_buffer))
|
||
|
||
# Parse coordinates
|
||
x_match = match(r"IMS:1000050.*?value=\"(\d+)\"", spectrum_xml)
|
||
y_match = match(r"IMS:1000051.*?value=\"(\d+)\"", spectrum_xml)
|
||
x = x_match !== nothing ? parse(Int32, x_match.captures[1]) : Int32(0)
|
||
y = y_match !== nothing ? parse(Int32, y_match.captures[1]) : Int32(0)
|
||
|
||
# Parse mode
|
||
spectrum_mode = global_mode
|
||
if occursin("MS:1000127", spectrum_xml)
|
||
spectrum_mode = CENTROID
|
||
elseif occursin("MS:1000128", spectrum_xml)
|
||
spectrum_mode = PROFILE
|
||
end
|
||
|
||
# Parse binary data arrays
|
||
array_data = []
|
||
|
||
# Find all binaryDataArray blocks
|
||
array_matches = eachmatch(r"<binaryDataArray.*?<\/binaryDataArray>"s, spectrum_xml)
|
||
for array_match in array_matches
|
||
array_xml = array_match.match
|
||
|
||
# Determine if this is m/z or intensity array
|
||
is_mz = occursin("MS:1000514", array_xml) || occursin("mzArray", array_xml)
|
||
|
||
# Parse external data parameters
|
||
# Get array_length (nPoints)
|
||
array_len_cv_match = match(r"IMS:1000103.*?value=\"(\d+)\"", array_xml)
|
||
array_length = 0
|
||
if array_len_cv_match !== nothing
|
||
array_length = parse(Int32, array_len_cv_match.captures[1])
|
||
end
|
||
if array_length == 0
|
||
nPoints_match = match(r"defaultArrayLength=\"(\d+)\"", spectrum_xml)
|
||
if nPoints_match !== nothing
|
||
array_length = parse(Int32, nPoints_match.captures[1])
|
||
end
|
||
end
|
||
|
||
# Get encoded_length
|
||
encoded_len_cv_match = match(r"IMS:1000104.*?value=\"(\d+)\"", array_xml)
|
||
encoded_length = 0
|
||
if encoded_len_cv_match !== nothing
|
||
encoded_length = parse(Int64, encoded_len_cv_match.captures[1])
|
||
else
|
||
encoded_len_attr_match = match(r"encodedLength=\"(\d+)\"", array_xml)
|
||
if encoded_len_attr_match !== nothing
|
||
encoded_length = parse(Int64, encoded_len_attr_match.captures[1])
|
||
end
|
||
end
|
||
|
||
# Get offset
|
||
offset_match = match(r"IMS:1000102.*?value=\"(\d+)\"", array_xml)
|
||
offset = 0
|
||
if offset_match !== nothing
|
||
offset = parse(Int64, offset_match.captures[1])
|
||
end
|
||
|
||
if array_length > 0 && offset > 0
|
||
push!(array_data, (
|
||
is_mz = is_mz,
|
||
array_length = array_length,
|
||
encoded_length = encoded_length,
|
||
offset = offset
|
||
))
|
||
end
|
||
end
|
||
|
||
# Separate m/z and intensity arrays
|
||
mz_data = filter(d -> d.is_mz, array_data)
|
||
int_data = filter(d -> !d.is_mz, array_data)
|
||
|
||
if length(mz_data) != 1 || length(int_data) != 1
|
||
error("Spectrum $k: Expected exactly one m/z and one intensity array")
|
||
end
|
||
|
||
mz_info = mz_data[1]
|
||
int_info = int_data[1]
|
||
|
||
# DEBUG: Print first spectrum details
|
||
if k == 1
|
||
println("DEBUG First spectrum parsed:")
|
||
println(" Coordinates: x=$x, y=$y")
|
||
println(" Mode: $spectrum_mode")
|
||
println(" m/z array: array_length=$(mz_info.array_length), encoded_length=$(mz_info.encoded_length), offset=$(mz_info.offset)")
|
||
println(" intensity array: array_length=$(int_info.array_length), encoded_length=$(int_info.encoded_length), offset=$(int_info.offset)")
|
||
println(" Expected m/z bytes: $(mz_info.array_length * sizeof(default_mz_format))")
|
||
println(" Expected intensity bytes: $(int_info.array_length * sizeof(default_intensity_format))")
|
||
end
|
||
|
||
# Create SpectrumAsset objects
|
||
mz_asset = SpectrumAsset(default_mz_format, mz_is_compressed, mz_info.offset,
|
||
mz_is_compressed ? mz_info.encoded_length : mz_info.array_length, :mz)
|
||
int_asset = SpectrumAsset(default_intensity_format, int_is_compressed, int_info.offset,
|
||
int_is_compressed ? int_info.encoded_length : int_info.array_length, :intensity)
|
||
|
||
spectra_metadata[k] = SpectrumMetadata(x, y, "", spectrum_mode, mz_asset, int_asset)
|
||
end
|
||
|
||
return spectra_metadata
|
||
end
|
||
|
||
# --- End of content from imzML.jl ---
|
||
|
||
# =============================================================================
|
||
#
|
||
# Image Slice Extraction
|
||
#
|
||
# =============================================================================
|
||
|
||
"""
|
||
find_mass(mz_array, intensity_array, target_mass, tolerance)
|
||
|
||
Finds the intensity of the most intense peak within a mass tolerance window.
|
||
This optimized version uses binary search for efficiency.
|
||
|
||
# Returns
|
||
- The intensity (`Float64`) of the peak if found, otherwise `0.0`.
|
||
"""
|
||
function find_mass(mz_array, intensity_array, target_mass, tolerance)
|
||
lower_bound = target_mass - tolerance
|
||
upper_bound = target_mass + tolerance
|
||
|
||
# Use binary search to find the start and end of the m/z window
|
||
start_idx = searchsortedfirst(mz_array, lower_bound)
|
||
end_idx = searchsortedlast(mz_array, upper_bound)
|
||
|
||
# If the window is empty, return 0.0
|
||
if start_idx > end_idx
|
||
return 0.0
|
||
end
|
||
|
||
# Find the maximum intensity within the identified window, optimized with @inbounds and @simd
|
||
max_intensity = intensity_array[start_idx]
|
||
@inbounds @simd for i in (start_idx + 1):end_idx
|
||
max_intensity = max(max_intensity, intensity_array[i])
|
||
end
|
||
|
||
return max_intensity
|
||
end
|
||
|
||
"""
|
||
load_slices(folder, masses, tolerance)
|
||
|
||
Loads image slices for multiple masses from all `.imzML` files in a directory.
|
||
This function is now refactored to use the new MSIData architecture and its
|
||
caching capabilities.
|
||
"""
|
||
function load_slices(folder, masses, tolerance)
|
||
files = filter(f -> endswith(f, ".imzML"), readdir(folder, join=true))
|
||
if isempty(files)
|
||
@warn "No .imzML files found in the specified directory: $folder"
|
||
return (Array{Any}(undef, 0, 0), String[])
|
||
end
|
||
n_files = length(files)
|
||
n_slices = length(masses)
|
||
|
||
img_list = Array{Any}(undef, n_files, n_slices)
|
||
names = String[]
|
||
|
||
for (i, file) in enumerate(files)
|
||
name = basename(file)
|
||
push!(names, name)
|
||
@info "Processing $(i)/$(n_files): $(name)"
|
||
|
||
# Load data using the new lazy loader, returning an MSIData object
|
||
msi_data = @time load_imzml_lazy(file)
|
||
|
||
# Create empty images for all slices for the current file
|
||
width, height = msi_data.image_dims
|
||
current_file_slices = [zeros(Float64, width, height) for _ in 1:n_slices]
|
||
|
||
# Use the high-performance iterator to process all spectra
|
||
_iterate_spectra_fast(msi_data) do spec_idx, mz_array, intensity_array
|
||
meta = msi_data.spectra_metadata[spec_idx]
|
||
|
||
# Now, check for all masses of interest in this single spectrum
|
||
for (j, mass) in enumerate(masses)
|
||
intensity = find_mass(mz_array, intensity_array, mass, tolerance)
|
||
if intensity > 0.0
|
||
if 1 <= meta.x <= width && 1 <= meta.y <= height
|
||
current_file_slices[j][meta.y, meta.x] = intensity
|
||
end
|
||
end
|
||
end
|
||
end # end of fast iterator
|
||
|
||
# Assign the generated images to the main list
|
||
for j in 1:n_slices
|
||
img_list[i, j] = current_file_slices[j]
|
||
end
|
||
|
||
end # end of files loop
|
||
|
||
return (img_list, names)
|
||
end
|
||
|
||
|
||
"""
|
||
get_mz_slice(data::MSIData, mass::Real, tolerance::Real)
|
||
|
||
Extracts an image slice for a given m/z value without plotting.
|
||
This is a performant function that iterates through spectra once.
|
||
|
||
# Returns
|
||
- A `Matrix{Float64}` representing the intensity slice.
|
||
"""
|
||
function get_mz_slice(data::MSIData, mass::Real, tolerance::Real)
|
||
width, height = data.image_dims
|
||
slice_matrix = zeros(Float64, height, width)
|
||
|
||
# INTELLIGENT LOADING: Ensure analytics are computed for filtering.
|
||
if data.spectrum_stats_df === nothing || !hasproperty(data.spectrum_stats_df, :MinMZ)
|
||
println("Per-spectrum metadata not found. Running one-time analytics computation...")
|
||
precompute_analytics(data)
|
||
end
|
||
|
||
println("Using high-performance sequential iterator...")
|
||
target_min = mass - tolerance
|
||
target_max = mass + tolerance
|
||
stats_df = data.spectrum_stats_df
|
||
|
||
# 1. Find all candidate spectra first for efficient filtering
|
||
candidate_indices = Set{Int}()
|
||
for i in 1:length(data.spectra_metadata)
|
||
spec_min_mz = stats_df.MinMZ[i]
|
||
spec_max_mz = stats_df.MaxMZ[i]
|
||
if target_max >= spec_min_mz && target_min <= spec_max_mz
|
||
push!(candidate_indices, i)
|
||
end
|
||
end
|
||
|
||
println("Found $(length(candidate_indices)) candidate spectra (filtered from $(length(data.spectra_metadata)))")
|
||
|
||
# 2. Iterate using the optimized, low-allocation iterator
|
||
results_count = 0
|
||
_iterate_spectra_fast(data) do idx, mz_array, intensity_array
|
||
# Process only the spectra that are candidates
|
||
if idx in candidate_indices
|
||
intensity = find_mass(mz_array, intensity_array, mass, tolerance)
|
||
if intensity > 0.0
|
||
meta = data.spectra_metadata[idx]
|
||
if 1 <= meta.x <= width && 1 <= meta.y <= height
|
||
slice_matrix[meta.y, meta.x] = intensity
|
||
results_count += 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
println("Populated $results_count pixels with intensity data")
|
||
replace!(slice_matrix, NaN => 0.0)
|
||
return slice_matrix
|
||
end
|
||
|
||
|
||
"""
|
||
plot_slice(msi_data::MSIData, mass::Float64, tolerance::Float64, output_dir::String; stage_name="slice", bins=256)
|
||
|
||
Generates and saves a plot of a single image slice for a given m/z value.
|
||
This function closely imitates the logic of the original `GetSlice` but uses
|
||
the modern `MSIData` access patterns and robust peak finding.
|
||
"""
|
||
function plot_slice(msi_data::MSIData, mass::Real, tolerance::Real, output_dir::String; stage_name="slice_mz_$(mass)", bins=256)
|
||
|
||
# 1. Create an empty image for the slice, with dimensions matching plotting expectations
|
||
width, height = msi_data.image_dims
|
||
slice_matrix = zeros(Float64, height, width)
|
||
|
||
# 2. Iterate through each spectrum to build the slice
|
||
println("Generating slice for m/z $mass...")
|
||
_iterate_spectra_fast(msi_data) do spec_idx, mz_array, intensity_array
|
||
meta = msi_data.spectra_metadata[spec_idx]
|
||
|
||
# Find the peak intensity using the modern, robust find_mass
|
||
intensity = find_mass(mz_array, intensity_array, mass, tolerance)
|
||
|
||
if intensity > 0.0
|
||
# Populate the matrix using (y, x) indexing
|
||
if 1 <= meta.x <= width && 1 <= meta.y <= height
|
||
slice_matrix[meta.y, meta.x] = intensity
|
||
end
|
||
end
|
||
end
|
||
println("Slice generation complete.")
|
||
|
||
# 3. Plot the resulting slice matrix using CairoMakie
|
||
println("Plotting slice...")
|
||
|
||
fig = Figure(size = (600, 500))
|
||
ax = CairoMakie.Axis(fig[1, 1],
|
||
aspect=DataAspect(),
|
||
title=@sprintf("Slice for m/z: %.2f", mass),
|
||
yreversed=true
|
||
)
|
||
hidedecorations!(ax)
|
||
|
||
# Use mass-specific bounds for colorrange, ensuring a valid range
|
||
min_val, max_val = extrema(slice_matrix)
|
||
if min_val == max_val
|
||
max_val = min_val + 1.0 # Ensure the color range has a non-zero width
|
||
end
|
||
hm = heatmap!(ax, slice_matrix,
|
||
colormap=cgrad(ColorSchemes.viridis, bins),
|
||
colorrange=(min_val, max_val)
|
||
)
|
||
|
||
Colorbar(fig[1, 2], hm, label="Intensity")
|
||
colgap!(fig.layout, 5)
|
||
|
||
# 4. Save the plot
|
||
mkpath(output_dir)
|
||
filename = "$(stage_name).png"
|
||
save_path = joinpath(output_dir, filename)
|
||
save(save_path, fig)
|
||
@info "Saved slice plot to $save_path"
|
||
|
||
return fig
|
||
end
|
||
|
||
# ============================================================================
|
||
#
|
||
#
|
||
# Image Processing and Normalization
|
||
#
|
||
# ============================================================================
|
||
|
||
"""
|
||
get_outlier_thres(img, prob=0.98)
|
||
|
||
Calculates dynamic intensity range bounds for an image based on a cumulative
|
||
probability histogram. This function replicates an R algorithm to find an
|
||
intensity threshold that corresponds to a given cumulative probability, which
|
||
is used to exclude outliers before normalization.
|
||
|
||
# Arguments
|
||
- `img`: The input image matrix.
|
||
- `prob`: The cumulative probability threshold (default: 0.98) for outlier detection.
|
||
|
||
# Returns
|
||
- A tuple `(low, high)` representing the calculated lower and upper intensity bounds.
|
||
"""
|
||
function get_outlier_thres(img, prob=0.98)
|
||
# DO NOT filter zeros. Use all pixel values like R does.
|
||
int_values = vec(img)
|
||
low = minimum(int_values)
|
||
upp = maximum(int_values)
|
||
|
||
# Create histogram bins
|
||
if upp - low + 1 >= 100
|
||
bins = 100
|
||
brk = range(low, stop=upp + (upp - low)/(bins - 1), length=bins + 1)
|
||
else
|
||
brk = collect(floor(low):(ceil(upp) + 1))
|
||
end
|
||
|
||
# Compute histogram with RIGHT-CLOSED = FALSE to mimic R's right=FALSE
|
||
# This makes intervals [a, b) instead of the default [a, b]
|
||
h = fit(Histogram, int_values, brk, closed=:left) # Key change: closed=:left
|
||
|
||
# Check if histogram is valid
|
||
if isempty(h.weights) || sum(h.weights) == 0
|
||
return (low, upp)
|
||
end
|
||
|
||
# Replicate R's algorithm exactly
|
||
cum_counts = cumsum(h.weights) / sum(h.weights)
|
||
# Calculate the difference from the target probability
|
||
top = prob .- cum_counts
|
||
delta = abs.(top)
|
||
# Find the index where the difference is minimized
|
||
min_delta_index = findfirst(x -> x == minimum(delta), delta)
|
||
|
||
# R adds 1 to this index: index <- 1 + which(...)[1]
|
||
target_bin_index = min_delta_index + 1
|
||
# Get the upper edge of the target bin
|
||
target_bin_upper_edge = h.edges[1][target_bin_index]
|
||
|
||
# Find the maximum data value that is strictly less than this edge
|
||
# This mimics: max( intMap[ intMap < h$breaks[index] ] )
|
||
values_below_edge = filter(x -> x < target_bin_upper_edge, int_values)
|
||
actual_threshold = isempty(values_below_edge) ? low : maximum(values_below_edge)
|
||
|
||
return (low, actual_threshold)
|
||
end
|
||
|
||
"""
|
||
set_pixel_depth(img, bounds, depth)
|
||
|
||
Quantizes the intensity values of an image into a specified number of bins
|
||
(`depth`) within a given intensity range (`bounds`). Pixels outside the bounds
|
||
are clipped.
|
||
|
||
# Arguments
|
||
- `img`: The input image matrix.
|
||
- `bounds`: A tuple `(min, max)` specifying the intensity range for quantization.
|
||
- `depth`: The number of quantization levels (bins).
|
||
|
||
# Returns
|
||
- A `Matrix{UInt8}` with pixel values quantized to the specified depth.
|
||
"""
|
||
function set_pixel_depth(img, bounds, depth)
|
||
min_val, max_val = bounds
|
||
bins = depth - 1
|
||
|
||
if min_val >= max_val
|
||
return zeros(UInt8, size(img))
|
||
end
|
||
|
||
# Create intensity bins
|
||
range_vals = range(min_val, stop=max_val, length=depth)[2:depth]
|
||
|
||
# Assign each pixel to a bin
|
||
result = similar(img, UInt8) # Use similar to create an array of the same type and size
|
||
for i in eachindex(img)
|
||
if img[i] <= min_val
|
||
result[i] = 0
|
||
else
|
||
bin_idx = findfirst(x -> img[i] <= x, range_vals)
|
||
result[i] = bin_idx === nothing ? bins : bin_idx - 1
|
||
end
|
||
end
|
||
|
||
return result
|
||
end
|
||
|
||
"""
|
||
TrIQ(pixMap, depth, prob=0.98)
|
||
|
||
Applies TrIQ (Treshold Intensity Quantization) normalization to an image.
|
||
This function first computes dynamic intensity range bounds by identifying outliers
|
||
based on a cumulative probability, then sets the pixel depth (quantizes intensities)
|
||
within these bounds.
|
||
|
||
# Arguments
|
||
- `pixMap`: The input image matrix (e.g., a slice from `GetMzSliceJl`).
|
||
- `depth`: The number of grey levels (bins) to quantize the intensities into.
|
||
- `prob`: The target cumulative probability (e.g., 0.98) used to determine outlier thresholds.
|
||
|
||
# Returns
|
||
- A new image matrix with intensities quantized to the specified depth within the TrIQ bounds.
|
||
"""
|
||
function TrIQ(pixMap, depth, prob=0.98)
|
||
# Compute new dynamic range
|
||
bounds = get_outlier_thres(pixMap, prob)
|
||
|
||
# Set intensity dynamic range
|
||
return set_pixel_depth(pixMap, bounds, depth)
|
||
end
|
||
|
||
"""
|
||
norm_slices_hist(slices, bins; prob=0.98)
|
||
|
||
Normalizes a set of image slices based on a shared histogram range.
|
||
"""
|
||
function norm_slices_hist(slices, bins; prob=0.98)
|
||
n_files, n_masses = size(slices)
|
||
norm_img = similar(slices)
|
||
mass_bounds = [] # This will store bounds for EACH mass
|
||
|
||
# Calculate bounds for each mass across all files
|
||
for mass_idx in 1:n_masses
|
||
# Get all slices for this specific mass across all files
|
||
mass_slices = [slices[i, mass_idx] for i in 1:n_files]
|
||
all_vals = reduce(vcat, [vec(s) for s in mass_slices])
|
||
|
||
# Calculate global bounds for this specific mass
|
||
mass_global_bounds = get_outlier_thres(all_vals, prob)
|
||
push!(mass_bounds, mass_global_bounds)
|
||
|
||
# Normalize each file's slice for this mass using its specific bounds
|
||
for file_idx in 1:n_files
|
||
norm_img[file_idx, mass_idx] = set_pixel_depth(slices[file_idx, mass_idx], mass_global_bounds, bins)
|
||
end
|
||
end
|
||
|
||
return (norm_img=norm_img, bounds=mass_bounds) # bounds is now a vector, one per mass
|
||
end
|
||
|
||
"""
|
||
quantize_intensity(slice::AbstractMatrix{<:Real}, levels::Integer=256)
|
||
|
||
Linearly scales the intensity values in a slice to a specified number of levels.
|
||
The output is an array of `UInt8` values.
|
||
This is a modernized version of `IntQuantCl`.
|
||
"""
|
||
function quantize_intensity(slice::AbstractMatrix{<:Real}, levels::Integer=256)
|
||
max_val = maximum(slice)
|
||
if max_val <= 0
|
||
return zeros(UInt8, size(slice))
|
||
end
|
||
|
||
# Scale relative to the absolute maximum value to preserve the zero point.
|
||
# The original logic used 'colorLevel' which was the max value (e.g., 255).
|
||
scale = (levels - 1) / max_val
|
||
|
||
# round is equivalent to floor(x+0.5) for positive numbers.
|
||
# clamp is used for robustness against floating point inaccuracies.
|
||
image = round.(UInt8, clamp.(slice .* scale, 0, levels - 1))
|
||
|
||
return image
|
||
end
|
||
|
||
"""
|
||
median_filter(img)
|
||
|
||
Applies a 3x3 median filter to the input image. This is a simple noise
|
||
reduction technique that replaces each pixel's value with the median value of
|
||
its 3x3 neighborhood.
|
||
|
||
# Arguments
|
||
- `img`: The input image matrix.
|
||
|
||
# Returns
|
||
- A new matrix containing the filtered image.
|
||
"""
|
||
function median_filter(img)
|
||
# 3x3 median filter implementation
|
||
return mapwindow(median, img, (3, 3))
|
||
end
|
||
|
||
|
||
|
||
"""
|
||
downsample_spectrum(mz, intensity, n_points=2000)
|
||
|
||
Reduces the number of points in a spectrum for faster plotting, while preserving peaks.
|
||
It divides the m/z range into `n_points` bins and keeps only the most intense point from each bin.
|
||
"""
|
||
function downsample_spectrum(mz::AbstractVector, intensity::AbstractVector, n_points::Integer=2000)
|
||
if isempty(mz) || length(mz) <= n_points
|
||
return mz, intensity
|
||
end
|
||
|
||
mz_min, mz_max = extrema(mz)
|
||
bin_width = (mz_max - mz_min) / n_points
|
||
|
||
# We use a vector of tuples to store the max intensity and its corresponding mz for each bin
|
||
# (max_intensity, mz_value)
|
||
bins = fill((0.0, 0.0), n_points)
|
||
|
||
for i in eachindex(mz)
|
||
# Determine the bin for the current point
|
||
# Bin indices are 1-based
|
||
bin_index = min(n_points, floor(Int, (mz[i] - mz_min) / bin_width) + 1)
|
||
|
||
# If the current point's intensity is higher than what's in the bin, replace it
|
||
if intensity[i] > bins[bin_index][1]
|
||
bins[bin_index] = (intensity[i], mz[i])
|
||
end
|
||
end
|
||
|
||
# Filter out empty bins and separate the mz and intensity values
|
||
final_mz = [b[2] for b in bins if b[1] > 0.0]
|
||
final_intensity = [b[1] for b in bins if b[1] > 0.0]
|
||
|
||
return final_mz, final_intensity
|
||
end
|
||
|
||
# ============================================================================
|
||
#
|
||
#
|
||
# Analysis and Visualization
|
||
#
|
||
# ============================================================================
|
||
|
||
"""
|
||
display_statistics(slices)
|
||
|
||
Calculates and prints key statistics for each slice.
|
||
"""
|
||
function display_statistics(slices, names, masses)
|
||
if isempty(slices)
|
||
@warn "Cannot display statistics for empty slice list."
|
||
return nothing
|
||
end
|
||
|
||
n_files, n_masses = size(slices)
|
||
#stats_to_calc = Dict("Mean" => mean, "Max" => maximum, "Min" => minimum, "Sum" => sum, "Std" => std)
|
||
stats_to_calc = Dict("Mean" => mean)
|
||
all_dfs = Dict{String, DataFrame}()
|
||
|
||
for (stat_name, stat_func) in stats_to_calc
|
||
# Create a matrix to hold the statistic for each slice
|
||
stat_matrix = zeros(Float64, n_files, n_masses)
|
||
for i in 1:n_files, j in 1:n_masses
|
||
flat_slice = vec(slices[i, j])
|
||
if !isempty(flat_slice)
|
||
stat_matrix[i, j] = stat_func(flat_slice)
|
||
end
|
||
end
|
||
|
||
# Create the DataFrame with masses as column headers
|
||
df = DataFrame(stat_matrix, Symbol.(masses))
|
||
# Insert the file names as the first column
|
||
insertcols!(df, 1, :Data => names)
|
||
mz_row = ["m/z"; masses...]
|
||
push!(df, mz_row)
|
||
|
||
println("\n--- Statistics: $(stat_name) ---")
|
||
println(df)
|
||
all_dfs[stat_name] = df
|
||
end
|
||
return all_dfs
|
||
end
|
||
|
||
"""
|
||
plot_slices(slices, names, masses, output_dir; stage_name, bins=256, dpi=150, global_bounds=nothing)
|
||
|
||
Generates and saves a grid of image slice plots. Each row corresponds to a
|
||
file and each column to a mass, creating a comprehensive overview.
|
||
|
||
# Arguments
|
||
- `slices`: A 2D array of image slice matrices (`n_files` x `n_masses`).
|
||
- `names`: A vector of file names, used for titling rows.
|
||
- `masses`: A vector of m/z values, used for titling columns.
|
||
- `output_dir`: The directory where the output plots will be saved.
|
||
|
||
# Keyword Arguments
|
||
- `stage_name`: A string used to name the output files (e.g., "raw", "normalized").
|
||
- `bins`: The number of color levels in the heatmap palette.
|
||
- `dpi`: The resolution for the saved image files.
|
||
- `global_bounds`: A vector of `(min, max)` tuples, one for each mass, to ensure a consistent
|
||
color scale across all files for a given mass. If `nothing`, bounds are calculated automatically.
|
||
|
||
# Returns
|
||
- The generated `Figure` object from Makie.
|
||
"""
|
||
function plot_slices(slices, names, masses, output_dir; stage_name, bins=256, dpi=150, global_bounds=nothing)
|
||
n_files, n_masses = size(slices)
|
||
|
||
mkpath(output_dir)
|
||
|
||
# If global_bounds is provided, it should now be a VECTOR of bounds (one per mass)
|
||
# If not provided, calculate per-mass bounds
|
||
if global_bounds === nothing
|
||
global_bounds = []
|
||
for mass_idx in 1:n_masses
|
||
mass_slices = [slices[i, mass_idx] for i in 1:n_files]
|
||
all_vals = reduce(vcat, [vec(s) for s in mass_slices])
|
||
filter!(isfinite, all_vals)
|
||
mass_bounds = isempty(all_vals) ? (0.0, 1.0) : extrema(all_vals)
|
||
push!(global_bounds, mass_bounds)
|
||
end
|
||
end
|
||
|
||
# With Makie, we define a Figure and a layout.
|
||
fig = Figure(size = (400 * n_masses, 330 * n_files)) # Adjusted size calculation
|
||
|
||
# Add a title for the entire figure.
|
||
Label(fig[0, 1:(2*n_masses)], "Image Slices - $(stage_name)", fontsize=24, font=:bold, tellwidth=false, padding=(0,0,10,0))
|
||
|
||
for file_idx in 1:n_files
|
||
for mass_idx in 1:n_masses
|
||
img = slices[file_idx, mass_idx]
|
||
mass = masses[mass_idx]
|
||
name = names[file_idx]
|
||
mass_global_bounds = global_bounds[mass_idx] # Get bounds for THIS specific mass
|
||
|
||
# --- Calculate tick properties for THIS mass ---
|
||
min_val, max_val = mass_global_bounds
|
||
levels = range(min_val, stop=max_val, length=bins + 1)
|
||
level_range = levels[end] - levels[1]
|
||
|
||
if level_range == 0
|
||
levels = range(min_val - 0.1, stop=max_val + 0.1, length=bins + 1)
|
||
level_range = 0.2
|
||
end
|
||
|
||
exponent = level_range > 0 ? floor(log10(level_range)) / 3 : 0
|
||
scale = 10^(3 * exponent)
|
||
scaled_levels = levels ./ scale
|
||
|
||
format_num = level_range > 0 ? floor(log10(level_range)) % 3 : 0
|
||
labels = if format_num == 0
|
||
[@sprintf("%3.2f", lvl) for lvl in scaled_levels]
|
||
elseif format_num == 1
|
||
[@sprintf("%3.2f", lvl) for lvl in scaled_levels]
|
||
else
|
||
[@sprintf("%3.2f", lvl) for lvl in scaled_levels]
|
||
end
|
||
|
||
divisors = 2:7
|
||
remainders = (bins - 1) .% divisors
|
||
best_divisor = divisors[findlast(x -> x == minimum(remainders), remainders)]
|
||
tick_indices = round.(Int, range(1, stop=bins + 1, length=best_divisor + 1))
|
||
|
||
if !(1 in tick_indices)
|
||
pushfirst!(tick_indices, 1)
|
||
end
|
||
if !((bins + 1) in tick_indices)
|
||
push!(tick_indices, bins + 1)
|
||
end
|
||
unique!(sort!(tick_indices))
|
||
|
||
tick_positions = levels[tick_indices]
|
||
tick_labels = labels[tick_indices]
|
||
# --- End of mass-specific tick calculation ---
|
||
|
||
# Create an Axis for the heatmap
|
||
ax = CairoMakie.Axis(fig[file_idx, 2*mass_idx-1],
|
||
aspect=DataAspect(),
|
||
title=@sprintf("%s\nm/z: %.2f", basename(name), mass),
|
||
titlesize=14
|
||
)
|
||
hidedecorations!(ax)
|
||
|
||
# Use mass-specific bounds for colorrange
|
||
hm = heatmap!(ax, transpose(img),
|
||
colormap=cgrad(ColorSchemes.viridis, bins),
|
||
colorrange=mass_global_bounds # ← This is mass-specific
|
||
)
|
||
|
||
# Add a colorbar with mass-specific scale
|
||
cb = Colorbar(fig[file_idx, 2*mass_idx], hm,
|
||
label=(scale == 1 ? "" : "×10^$(round(Int, 3 * exponent))"),
|
||
labelpadding=2,
|
||
labelsize=12,
|
||
ticks=(tick_positions, tick_labels),
|
||
ticklabelsize=10
|
||
)
|
||
colsize!(fig.layout, 2*mass_idx, 30)
|
||
end
|
||
end
|
||
|
||
colgap!(fig.layout, 5)
|
||
rowgap!(fig.layout, 10)
|
||
|
||
# Save in multiple formats.
|
||
formats = ["png", "pdf"]
|
||
|
||
for fmt in formats
|
||
filename = "$(stage_name)_overview.$(fmt)"
|
||
save_path = joinpath(output_dir, filename)
|
||
save(save_path, fig, px_per_unit = dpi / 96.0)
|
||
@info "Saved $(fmt) overview plot to $save_path"
|
||
end
|
||
return fig
|
||
end
|
||
|
||
"""
|
||
save_bitmap(name::String, pixMap::Matrix{UInt8}, colorTable::Vector{UInt32})
|
||
|
||
Saves an 8-bit indexed image as a BMP file.
|
||
This is a modernized version of `SaveBitmapCl`.
|
||
"""
|
||
function save_bitmap(name::String, pixMap::Matrix{UInt8}, colorTable::Vector{UInt32})
|
||
# Get image dimensions
|
||
height, width = size(pixMap)
|
||
|
||
# Normalize pixel values to stretch contrast, as in the original SaveBitmapCl
|
||
minVal, maxVal = extrema(pixMap)
|
||
if maxVal > minVal
|
||
pixMap = round.(UInt8, 255 * (pixMap .- minVal) ./ (maxVal - minVal))
|
||
end
|
||
|
||
# Compute row padding (each row must be a multiple of 4 bytes)
|
||
padding = (4 - (width % 4)) % 4
|
||
|
||
# BMP color table must have 256 entries for 8-bit images
|
||
fullColorTable = Vector{UInt32}(undef, 256)
|
||
if length(colorTable) <= 256
|
||
fullColorTable[1:length(colorTable)] .= colorTable
|
||
fullColorTable[length(colorTable)+1:end] .= 0
|
||
else
|
||
fullColorTable .= colorTable[1:256]
|
||
end
|
||
|
||
# Compute file dimensions
|
||
offset = 14 + 40 + (256 * 4) # 14(file) + 40(info) + 1024(palette) = 1078
|
||
imgBytes = height * (width + padding)
|
||
fileSize = offset + imgBytes
|
||
|
||
open(name, "w") do stream
|
||
# === File Header (14 bytes) ===
|
||
write(stream, UInt16(0x4D42)) # "BM"
|
||
write(stream, UInt32(fileSize))
|
||
write(stream, UInt16(0)) # Reserved
|
||
write(stream, UInt16(0)) # Reserved
|
||
write(stream, UInt32(offset))
|
||
|
||
# === Info Header (40 bytes) ===
|
||
write(stream, UInt32(40)) # Info header size
|
||
write(stream, Int32(width))
|
||
write(stream, Int32(height)) # Positive for bottom-up storage
|
||
write(stream, UInt16(1)) # Number of color planes
|
||
write(stream, UInt16(8)) # Bits per pixel
|
||
write(stream, UInt32(0)) # Compression (BI_RGB)
|
||
write(stream, UInt32(imgBytes))
|
||
write(stream, Int32(2835)) # Pels per meter X (~72 DPI)
|
||
write(stream, Int32(2835)) # Pels per meter Y (~72 DPI)
|
||
write(stream, UInt32(256)) # Colors in color table
|
||
write(stream, UInt32(0)) # Important colors (0 = all)
|
||
|
||
# === Color Table ===
|
||
write(stream, fullColorTable)
|
||
|
||
# === Image Pixels (written bottom-up) ===
|
||
row_buffer = Vector{UInt8}(undef, width + padding)
|
||
row_buffer[width+1:end] .= 0 # pre-fill padding bytes
|
||
|
||
for i in height:-1:1
|
||
row_data = @view pixMap[i, :]
|
||
row_buffer[1:width] = row_data
|
||
write(stream, row_buffer)
|
||
end
|
||
end
|
||
end
|
||
|
||
# ********************************************************************
|
||
# Viridis color palette (256 colors)
|
||
# ********************************************************************
|
||
|
||
"""
|
||
generate_palette(colorscheme, n_colors=256)
|
||
|
||
Generates a BMP-compatible UInt32 color palette from a ColorScheme.
|
||
"""
|
||
function generate_palette(colorscheme, n_colors=256)
|
||
palette = Vector{UInt32}(undef, n_colors)
|
||
colors = get(colorscheme, range(0, 1, length=n_colors))
|
||
for i in 1:n_colors
|
||
c = colors[i]
|
||
r = round(UInt8, c.r * 255)
|
||
g = round(UInt8, c.g * 255)
|
||
b = round(UInt8, c.b * 255)
|
||
# BMP color table format is 0x00RRGGBB, written little-endian becomes BB GG RR 00
|
||
palette[i] = (UInt32(r) << 16) | (UInt32(g) << 8) | UInt32(b)
|
||
end
|
||
return palette
|
||
end
|
||
|
||
const ViridisPalette = generate_palette(ColorSchemes.viridis) |