From 22c8d9637b0ac0cfb20f07b28c62e1aa584e85a5 Mon Sep 17 00:00:00 2001 From: Pixelguy14 Date: Thu, 2 Oct 2025 13:28:15 -0600 Subject: [PATCH] integrated MSI_src API into the UI, fixed interface elements, made some processes asynchronous, modified spectrum plots, added sum spectrum, removed use of obsolete julia_mzML_imzML library --- Manifest.toml | 2 +- Project.toml | 2 + app.jl | 805 +++++++++++++++++++++--------------------- app.jl.html | 5 + julia_imzML_visual.jl | 248 ++++++------- src/MSIData.jl | 97 ++++- src/MzmlConverter.jl | 304 +++++++++------- src/imzML.jl | 214 ++++++++++- test/readme.md | 83 ++++- test/run_tests.jl | 24 +- 10 files changed, 1076 insertions(+), 708 deletions(-) diff --git a/Manifest.toml b/Manifest.toml index ff5e2fb..e1ae255 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.11.7" manifest_format = "2.0" -project_hash = "896c16630591d2732da8e3315c4b64c618fd5ebb" +project_hash = "0f3959248d174e05d23b9e9267b00c6d2ead256d" [[deps.ATK_jll]] deps = ["Artifacts", "Glib_jll", "JLLWrappers", "Libdl"] diff --git a/Project.toml b/Project.toml index 653695f..0db27f1 100644 --- a/Project.toml +++ b/Project.toml @@ -19,6 +19,8 @@ NativeFileDialog = "e1fe445b-aa65-4df4-81c1-2041507f0fd4" NaturalSort = "c020b1a1-e9b0-503a-9c33-f039bfc54a85" PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" StipplePlotly = "ec984513-233d-481d-95b0-a3b58b97af2b" julia_mzML_imzML = "38eb50d3-2fb6-4afa-992a-964ed8562ed9" diff --git a/app.jl b/app.jl index b0fee09..b836bce 100644 --- a/app.jl +++ b/app.jl @@ -6,7 +6,8 @@ using Libz using PlotlyBase using CairoMakie using Colors -using julia_mzML_imzML +# using julia_mzML_imzML +using MSI_src # Import the new MSIData library using Statistics using NaturalSort using Images @@ -14,6 +15,10 @@ using LinearAlgebra using NativeFileDialog # Opens the file explorer depending on the OS using StipplePlotly using Base.Filesystem: mv # To rename files in the system + +# Bring MSIData into App module's scope +using .MSI_src: MSIData, OpenMSIData, GetSpectrum, IterateSpectra, ImzMLSource, _iterate_spectra_fast, MzMLSource, find_mass, ViridisPalette, get_mz_slice, quantize_intensity, save_bitmap, median_filter, save_bitmap, downsample_spectrum, TrIQ + include("./julia_imzML_visual.jl") @genietools @@ -57,6 +62,7 @@ include("./julia_imzML_visual.jl") @in compareBtn=false # To open dialog @in createMeanPlot=false # To generate mean spectrum plot @in createXYPlot=false # To generate an spectrum plot according to the xy values inputed + @in createSumPlot=false # To generate a sum of all the spectrum plots @in image3dPlot=false # To generate 3d plot based on current image @in triq3dPlot=false # To generate 3d plot based on current triq image @in imageCPlot=false # To generate contour plots of current image @@ -110,10 +116,12 @@ include("./julia_imzML_visual.jl") @out msgimgComp="" @out msgtriqComp="" + # Centralized MSIData object + @out msi_data::Union{MSIData, Nothing} = nothing + # Saves the route where imzML and mzML files are located @out full_route="" - @out full_routeMz="" - @out full_routeMz2="" + # For the creation of images with a more specific mass charge @out text_nmass="" @@ -145,10 +153,12 @@ include("./julia_imzML_visual.jl") layoutImg=PlotlyBase.Layout( xaxis=PlotlyBase.attr( visible=false, - scaleanchor="y" + scaleanchor="y", + range=[0, 0] ), yaxis=PlotlyBase.attr( - visible=false + visible=false, + range=[0, 0] ), margin=attr(l=0,r=0,t=0,b=0,pad=0) ) @@ -168,10 +178,10 @@ include("./julia_imzML_visual.jl") # Interface Plot Spectrum layoutSpectra=PlotlyBase.Layout( title="Spectrum plot", + hovermode="closest", xaxis=PlotlyBase.attr( title="m/z", - showgrid=true, - tickformat = ".3g" + showgrid=true ), yaxis=PlotlyBase.attr( title="Intensity", @@ -249,319 +259,305 @@ include("./julia_imzML_visual.jl") # Reactive handlers watch a variable and execute a block of code when its value changes # The onbutton handler will set the variable to false after the block is executed - @onbutton btnSearch begin - full_route=pick_file(; filterlist="imzML,imzml,mzML,mzml") - msg="" - if full_route != "" - if endswith(full_route, "imzml") - alt_route=replace(full_route, r"\.[^.]*$" => ".imzML") - mv(full_route, alt_route) - full_route=alt_route - # to detect if there's an mzml named wrong - alt_routeMz=replace(full_route, r"\.[^.]*$" => ".mzml") - if isfile(alt_routeMz) - alt_routeMz2=replace(alt_routeMz, r"\.[^.]*$" => ".mzML") - mv(alt_routeMz, alt_routeMz2) - alt_routeMz=alt_routeMz2 - end - end - if endswith(full_route, "mzml") - alt_route=replace(full_route, r"\.[^.]*$" => ".mzML") - mv(full_route, alt_route) - full_route = alt_route - # to detect if there's an imzml named wrong - alt_routeMz=replace(full_route, r"\.[^.]*$" => ".imzml") - if isfile(alt_routeMz) - alt_routeMz2=replace(alt_routeMz, r"\.[^.]*$" => ".imzML") - mv(alt_routeMz, alt_routeMz2) - alt_routeMz=alt_routeMz2 - end - end + @onbutton btnSearch @time begin + # This part is synchronous and blocking, which is unavoidable + picked_route = pick_file(; filterlist="imzML,imzml,mzML,mzml") + + if isempty(picked_route) + msg = "No file selected." + warning_msg = true + return end - if full_route=="" - msg="No file selected" - warning_msg=true - btnStartDisable=true - btnSpectraDisable=true - SpectraEnabled=false - else - if endswith(full_route, "imzML") # Case if the file loaded is imzML - btnStartDisable=false - btnPlotDisable=false - # Splitting the route with regex from imzml to mzml so the plotting can work - full_routeMz=replace(full_route, r"\.[^.]*$" => ".mzML") - if isfile(full_routeMz) - # Start the mean spectrum creation on loading - progressSpectraPlot=true - btnPlotDisable=true - btnStartDisable=true - msg="Loading mean spectrum plot..." - sTime=time() - plotdata, plotlayout, xSpectraMz, ySpectraMz=meanSpectrumPlot(full_routeMz) - selectedTab="tab2" - progressSpectraPlot=false - btnPlotDisable=false - # We enable coord search and spectra plot creation, also re-enable main process - btnSpectraDisable=false - SpectraEnabled=true - btnStartDisable=false - - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" + # UI updates immediately + progress = true + msg = "Opening file: $(basename(picked_route))..." + + @async begin + try + # --- Normalize file extension and path --- + if endswith(picked_route, "imzml") + full_route = replace(picked_route, r"\.imzml$"i => ".imzML") + mv(picked_route, full_route, force=true) + elseif endswith(picked_route, "mzml") + full_route = replace(picked_route, r"\.mzml$"i => ".mzML") + mv(picked_route, full_route, force=true) else - # If there's no mzML file, we deny access again - btnSpectraDisable=true - SpectraEnabled=false + full_route = picked_route end - else # Case if the file loaded is mzML - full_routeMz=full_route - btnSpectraDisable=false - SpectraEnabled=true - if isfile(full_routeMz) - # Start the mean spectrum creation on loading - progressSpectraPlot=true - btnPlotDisable=true - btnStartDisable=true - msg="Loading mean spectrum plot..." - sTime=time() - plotdata, plotlayout, xSpectraMz, ySpectraMz=meanSpectrumPlot(full_routeMz) - selectedTab="tab2" - progressSpectraPlot=false - btnPlotDisable=false - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + # --- Load data using the new MSIData library --- + sTime = time() + msi_data = OpenMSIData(full_route) + + w, h = msi_data.image_dims + imgWidth, imgHeight = w > 0 ? (w, h) : (500, 500) - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" - end - # Splitting the route the same way - full_route=replace(full_route, r"\.[^.]*$" => ".imzML") - if isfile(full_route) - btnStartDisable=false - else - btnStartDisable=true - full_route=full_routeMz - end + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "File loaded in $(eTime) seconds. Calculating total spectrum..." + + # Enable UI controls + btnStartDisable = !(msi_data.source isa ImzMLSource) + btnPlotDisable = false + btnSpectraDisable = false + SpectraEnabled = true + + # --- Automatically generate and display the sum spectrum plot --- + # This is still part of the same async task + progressSpectraPlot = true + sTime = time() + + plotdata, plotlayout, xSpectraMz, ySpectraMz = sumSpectrumPlot(msi_data) + + selectedTab = "tab2" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Total spectrum plot loaded in $(eTime) seconds." + + catch e + msi_data = nothing + msg = "Error loading file: $e" + warning_msg = true + btnStartDisable = true + btnSpectraDisable = true + SpectraEnabled = false + @error "File loading failed" exception=(e, catch_backtrace()) + finally + # This now correctly runs after everything is finished + progress = false + progressSpectraPlot = false end - xCoord=0 - yCoord=0 end end - @onbutton mainProcess begin - progress=true # Start progress button animation - btnStartDisable=true # We disable the button to avoid multiple requests - btnPlotDisable=true - btnSpectraDisable=true - text_nmass=replace(string(Nmass), "." => "_") - sTime=time() - if isfile(full_route) && Nmass > 0 && Tol > 0 && Tol <=1 && colorLevel > 1 && colorLevel < 257 - msg="File exists, Nmass=$(Nmass) Tol=$(Tol). Loading file will begin, please be patient." + @onbutton mainProcess @time begin + # UI updates immediately + progress = true + btnStartDisable = true + btnPlotDisable = true + btnSpectraDisable = true + + @async begin try - spectra=LoadImzml(full_route) - msg="File loaded. Creating spectra with the specific mass and tolerance, please be patient." - slice=GetMzSliceJl(spectra,Nmass,Tol) - fig=CairoMakie.Figure(size=(150, 250)) # Container - # Append a query string to force the image to refresh - timestamp=string(time_ns()) - if triqEnabled # If we have TrIQ - if triqProb < 0.8 || triqProb > 1 - msg="Incorrect TrIQ values, please adjust accordingly and try again." - warning_msg=true - else - image_path=joinpath("./public", "TrIQ_$(text_nmass).bmp") - valid_slice=false - while Tol <= 1.0 && !valid_slice - try - slice=GetMzSliceJl(spectra, Nmass, Tol) - sliceTriq=TrIQ(slice, colorLevel, triqProb) - if MFilterEnabled # If the Median filter is ON - sliceTriq=medianFilterjl(sliceTriq) - end - valid_slice=true - catch e - msg="Warning: insufficient tolerance, inputs modified to allow the creation of an image regardless=$Tol: $e" - Tol += 0.1 - end - end - sliceTriq=reverse(sliceTriq, dims=2) - SaveBitmapCl(joinpath("public", "TrIQ_$(text_nmass).bmp"),sliceTriq,ViridisPalette) - # Use timestamp to refresh image interface container - imgIntT="/TrIQ_$(text_nmass).bmp?t=$(timestamp)" - plotdataImgT, plotlayoutImgT, imgWidth, imgHeight=loadImgPlot(imgIntT) - # Get current image - current_triq="TrIQ_$(text_nmass).bmp" - msgtriq="TrIQ image with the Nmass of $(replace(text_nmass, "_" => "."))" - # Create colorbar - bound=julia_mzML_imzML.GetOutlierThres(slice, triqProb) - levels=range(bound[1],stop=bound[2], length=8) - levels=vcat(levels, 2*levels[end]-levels[end-1]) - Colorbar(fig[1, 1], colormap=cgrad(:viridis, colorLevel, categorical=true), limits=(0, bound[2]),ticks=levels,tickformat=log_tick_formatter, label="Intensity", size=25) - save("public/colorbar_TrIQ_$(text_nmass).png", fig) - colorbarT="/colorbar_TrIQ_$(text_nmass).png?t=$(timestamp)" - # Get current colorbar - current_col_triq="colorbar_TrIQ_$(text_nmass).png" - # We update the directory to include the new placed images. - triq_bmp=sort(filter(filename -> startswith(filename, "TrIQ_") && endswith(filename, ".bmp"), readdir("public")),lt=natural) - col_triq_png=sort(filter(filename -> startswith(filename, "colorbar_TrIQ_") && endswith(filename, ".png"), readdir("public")),lt=natural) - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="The file has been created in $(eTime) seconds successfully inside the 'public' folder of the app" - selectedTab="tab1" - end - else # If we don't use TrIQ - image_path=joinpath("./public", "MSI_$(text_nmass).bmp") + text_nmass = replace(string(Nmass), "." => "_") + sTime = time() + + if msi_data === nothing || !(msi_data.source isa ImzMLSource) + msg = "No .imzML file loaded or selected file is not an .imzML. Please select a valid file." + warning_msg = true + elseif Nmass > 0 && Tol > 0 && Tol <= 1 && colorLevel > 1 && colorLevel < 257 + msg = "Creating image for Nmass=$(Nmass) Tol=$(Tol). Please be patient." try - sliceQuant=IntQuantCl(slice,Int(colorLevel-1)) - if MFilterEnabled # If the Median filter is ON - sliceQuant=medianFilterjl(sliceQuant) + # Use the new get_mz_slice with the centralized MSIData object + slice = get_mz_slice(msi_data, Nmass, Tol) + fig = CairoMakie.Figure(size=(150, 250)) # Container + timestamp = string(time_ns()) + + if triqEnabled # If we have TrIQ + if triqProb < 0.8 || triqProb > 1 + msg = "Incorrect TrIQ values, please adjust accordingly and try again." + warning_msg = true + else + sliceTriq = TrIQ(slice, colorLevel, triqProb) + if MFilterEnabled + sliceTriq = round.(UInt8, median_filter(sliceTriq)) + end + sliceTriq = reverse(sliceTriq, dims=2) + save_bitmap(joinpath("public", "TrIQ_$(text_nmass).bmp"), sliceTriq, ViridisPalette) + + imgIntT = "/TrIQ_$(text_nmass).bmp?t=$(timestamp)" + plotdataImgT, plotlayoutImgT, imgWidth, imgHeight = loadImgPlot(imgIntT) + current_triq = "TrIQ_$(text_nmass).bmp" + msgtriq = "TrIQ image with the Nmass of $(replace(text_nmass, "_" => "."))" + + bound = MSI_src.get_outlier_thres(slice, triqProb) + levels = range(bound[1], stop=bound[2], length=8) + levels = vcat(levels, 2 * levels[end] - levels[end-1]) + Colorbar(fig[1, 1], colormap=cgrad(:viridis, colorLevel, categorical=true), limits=(0, bound[2]), ticks=levels, tickformat=log_tick_formatter, label="Intensity", size=25) + save("public/colorbar_TrIQ_$(text_nmass).png", fig) + colorbarT = "/colorbar_TrIQ_$(text_nmass).png?t=$(timestamp)" + current_col_triq = "colorbar_TrIQ_$(text_nmass).png" + + triq_bmp = sort(filter(filename -> startswith(filename, "TrIQ_") && endswith(filename, ".bmp"), readdir("public")), lt=natural) + col_triq_png = sort(filter(filename -> startswith(filename, "colorbar_TrIQ_") && endswith(filename, ".png"), readdir("public")), lt=natural) + + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "The TrIQ image has been created in $(eTime) seconds successfully inside the 'public' folder of the app" + selectedTab = "tab1" + end + else # If we don't use TrIQ + sliceQuant = quantize_intensity(slice, colorLevel) + if MFilterEnabled + sliceQuant = round.(UInt8, median_filter(sliceQuant)) + end + sliceQuant = reverse(sliceQuant, dims=2) + save_bitmap(joinpath("public", "MSI_$(text_nmass).bmp"), sliceQuant, ViridisPalette) + + imgInt = "/MSI_$(text_nmass).bmp?t=$(timestamp)" + plotdataImg, plotlayoutImg, imgWidth, imgHeight = loadImgPlot(imgInt) + current_msi = "MSI_$(text_nmass).bmp" + msgimg = "Image with the Nmass of $(replace(text_nmass, "_" => "."))" + + levels = range(0, maximum(slice), length=8) + Colorbar(fig[1, 1], colormap=cgrad(:viridis, colorLevel, categorical=true), limits=(0, maximum(slice)), ticks=levels, tickformat=log_tick_formatter, label="Intensity", size=25) + save("public/colorbar_MSI_$(text_nmass).png", fig) + colorbar = "/colorbar_MSI_$(text_nmass).png?t=$(timestamp)" + current_col_msi = "colorbar_MSI_$(text_nmass).png" + + msi_bmp = sort(filter(filename -> startswith(filename, "MSI_") && endswith(filename, ".bmp"), readdir("public")), lt=natural) + col_msi_png = sort(filter(filename -> startswith(filename, "colorbar_MSI_") && endswith(filename, ".png"), readdir("public")), lt=natural) + selectedTab = "tab0" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "The image has been created in $(eTime) seconds successfully inside the 'public' folder of the app" end catch e - msg="Warning: $e" + msg = "There was an error creating the image: $e" + warning_msg = true + @error "Image creation failed" exception=(e, catch_backtrace()) end - sliceQuant=reverse(sliceQuant, dims=2) - SaveBitmapCl(joinpath("public", "MSI_$(text_nmass).bmp"),sliceQuant,ViridisPalette) - # Use timestamp to refresh image interface container - imgInt="/MSI_$(text_nmass).bmp?t=$(timestamp)" - plotdataImg, plotlayoutImg, imgWidth, imgHeight=loadImgPlot(imgInt) - # Get current image - current_msi="MSI_$(text_nmass).bmp" - msgimg="Image with the Nmass of $(replace(text_nmass, "_" => "."))" - # Create colorbar - levels=range(0,maximum(slice),length=8) - Colorbar(fig[1, 1], colormap=cgrad(:viridis, colorLevel, categorical=true), limits=(0, maximum(slice)),ticks=levels,tickformat=log_tick_formatter, label="Intensity", size=25) - save("public/colorbar_MSI_$(text_nmass).png", fig) - colorbar="/colorbar_MSI_$(text_nmass).png?t=$(timestamp)" - # Get current colorbar - current_col_msi="colorbar_MSI_$(text_nmass).png" - # We update the directory to include the new placed images. - msi_bmp=sort(filter(filename -> startswith(filename, "MSI_") && endswith(filename, ".bmp"), readdir("public")),lt=natural) - col_msi_png=sort(filter(filename -> startswith(filename, "colorbar_MSI_") && endswith(filename, ".png"), readdir("public")),lt=natural) - selectedTab="tab0" - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="The file has been created in $(eTime) seconds successfully inside the 'public' folder of the app" + else + msg = "Invalid parameters. Nmass, Tol, or colorLevel are incorrect." + warning_msg = true + @error msg end - catch e - msg="There was an error loading the ImzML file, please verify the file accordingly and try again. $(e)" - warning_msg=true + finally + # This block will always run at the end of the async task + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end + btnStartDisable = false + btnPlotDisable = false + btnOpticalDisable = false + progress = false + btnSpectraDisable = false + SpectraEnabled = true end - else - msg="File does not exist or a parameter is incorrect, please try again." - warning_msg=true - end - GC.gc() # Trigger garbage collection - if Sys.islinux() - ccall(:malloc_trim, Int32, (Int32,), 0) # Ensure julia returns the freed memory to OS - end - btnStartDisable=false - btnPlotDisable=false - btnOpticalDisable=false - progress=false - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true end end @onbutton createMeanPlot begin - msg="Mean spectrum plot selected" - sTime=time() - if isfile(full_routeMz) # Check if the file exists - progressSpectraPlot=true - btnPlotDisable=true - btnStartDisable=true - msg="Loading plot..." - plotdata, plotlayout, xSpectraMz, ySpectraMz=meanSpectrumPlot(full_routeMz) - GC.gc() # Trigger garbage collection - if Sys.islinux() - ccall(:malloc_trim, Int32, (Int32,), 0) # Ensure julia returns the freed memory to OS + if msi_data === nothing + msg = "No data loaded. Please select a file first." + warning_msg = true + return + end + + # UI updates immediately + progressSpectraPlot = true + btnPlotDisable = true + btnStartDisable = true + msg = "Loading plot..." + + @async begin + try + sTime = time() + plotdata, plotlayout, xSpectraMz, ySpectraMz = meanSpectrumPlot(msi_data) + + selectedTab = "tab2" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Plot loaded in $(eTime) seconds" + catch e + msg = "Could not generate mean spectrum plot: $e" + warning_msg = true + @error "Mean spectrum plotting failed" exception=(e, catch_backtrace()) + finally + # This runs after the async task is finished + progressSpectraPlot = false + btnPlotDisable = false + btnSpectraDisable = false + if msi_data !== nothing && msi_data.source isa ImzMLSource + btnStartDisable = false + end end - selectedTab="tab2" - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" - else - msg="there was an error with the mzML, please try again" - warning_msg=true end - progressSpectraPlot=false - btnPlotDisable=false - if endswith(full_route, "imzML") - btnStartDisable=false + end + + @onbutton createSumPlot begin + if msi_data === nothing + msg = "No data loaded. Please select a file first." + warning_msg = true + return end - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + + # UI updates immediately + progressSpectraPlot = true + btnPlotDisable = true + btnStartDisable = true + msg = "Loading total spectrum plot..." + + @async begin + try + sTime = time() + plotdata, plotlayout, xSpectraMz, ySpectraMz = sumSpectrumPlot(msi_data) + + selectedTab = "tab2" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Total plot loaded in $(eTime) seconds" + catch e + msg = "Could not generate total spectrum plot: $e" + warning_msg = true + @error "Total spectrum plotting failed" exception=(e, catch_backtrace()) + finally + # This runs after the async task is finished + progressSpectraPlot = false + btnPlotDisable = false + btnSpectraDisable = false + if msi_data !== nothing && msi_data.source isa ImzMLSource + btnStartDisable = false + end + end end end @onbutton createXYPlot begin - msg="XY spectrum plot selected" - sTime=time() - if isfile(full_routeMz) # Check if the file exists - progressSpectraPlot=true - btnStartDisable=true - btnPlotDisable=true - btnSpectraDisable=true - msg="Loading plot..." - spectraMz=LoadMzml(full_routeMz) - layoutSpectra=PlotlyBase.Layout( - title="($xCoord, $yCoord) Specific spectrum plot", - xaxis=PlotlyBase.attr( - title="m/z", - showgrid=true - ), - yaxis=PlotlyBase.attr( - title="Intensity", - showgrid=true - ), - autosize=false, - margin=attr(l=0,r=0,t=120,b=0,pad=0) - ) - if xCoord < 1 - xCoord=1 - elseif xCoord > imgWidth - xCoord=imgWidth - end - if yCoord > -1 - yCoord=-1 - elseif yCoord < -imgHeight - yCoord=-imgHeight - end - xSpectraMz=spectraMz[1,abs(xCoord)] - ySpectraMz=spectraMz[2,abs(yCoord)] - traceSpectra=PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, mode="lines") - plotdata=[traceSpectra] # We add the data from spectra to the plot - plotlayout=layoutSpectra - GC.gc() # Trigger garbage collection - if Sys.islinux() - ccall(:malloc_trim, Int32, (Int32,), 0) # Ensure julia returns the freed memory to OS - end - selectedTab="tab2" - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" - else - msg="there was an error with the mzML or the coordenates, please try again" - warning_msg=true + if msi_data === nothing + msg = "No data loaded. Please select a file first." + warning_msg = true + return end - progressSpectraPlot=false - btnPlotDisable=false - if endswith(full_route, "imzML") - btnStartDisable=false - end - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + + # UI updates immediately + progressSpectraPlot = true + btnStartDisable = true + btnPlotDisable = true + btnSpectraDisable = true + msg = "Loading plot..." + + @async begin + try + sTime = time() + # The UI uses negative Y values, so we adjust before calling the plot function + y = yCoord < 0 ? abs(yCoord) : yCoord + plotdata, plotlayout, xSpectraMz, ySpectraMz = xySpectrumPlot(msi_data, xCoord, y, imgWidth, imgHeight) + + # Update UI coordinates + xCoord = plotlayout.title == "Spectrum #$(xCoord)" ? xCoord : clamp(xCoord, 1, imgWidth) + yCoord = plotlayout.title == "Spectrum #$(xCoord)" ? 0 : -clamp(y, 1, imgHeight) + + selectedTab = "tab2" + + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Plot loaded in $(eTime) seconds" + catch e + msg = "Could not retrieve spectrum: $e" + warning_msg = true + @error "Spectrum plotting failed" exception=(e, catch_backtrace()) + finally + # This runs after the async task is finished + progressSpectraPlot = false + btnPlotDisable = false + btnSpectraDisable = false + if msi_data !== nothing && msi_data.source isa ImzMLSource + btnStartDisable = false + end + end end end @@ -796,14 +792,21 @@ include("./julia_imzML_visual.jl") cleaned_imgInt=replace(imgInt, r"\?.*" => "") cleaned_imgInt=lstrip(cleaned_imgInt, '/') var=joinpath( "./public", cleaned_imgInt ) - sTime=time() - if isfile(var) - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true + if !isfile(var) + msg="Image could not be 3d plotted" + warning_msg=true + return + end + + progressPlot=true + btnPlotDisable=true + btnStartDisable=true + btnSpectraDisable=true + + @async begin try + sTime=time() plotdata3d, plotlayout3d=loadSurfacePlot(imgInt) GC.gc() # Trigger garbage collection if Sys.islinux() @@ -816,34 +819,39 @@ include("./julia_imzML_visual.jl") catch e msg="Failed to load and process image: $e" warning_msg=true + finally + progressPlot=false + btnPlotDisable=false + btnStartDisable=false + if msi_data !== nothing + # We enable coord search and spectra plot creation + btnSpectraDisable=false + SpectraEnabled=true + end end - else - msg="Image could not be 3d plotted" - warning_msg=true end - progressPlot=false - btnPlotDisable=false - btnStartDisable=false - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true - end - end - # 3d plot for TrIQ + end # 3d plot for TrIQ + @onbutton triq3dPlot begin msg="TrIQ 3D plot selected" cleaned_imgIntT=replace(imgIntT, r"\?.*" => "") cleaned_imgIntT=lstrip(cleaned_imgIntT, '/') var=joinpath( "./public", cleaned_imgIntT ) - sTime=time() - if isfile(var) - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true + if !isfile(var) + msg="Image could not be 3d plotted" + warning_msg=true + return + end + + progressPlot=true + btnPlotDisable=true + btnStartDisable=true + btnSpectraDisable=true + + @async begin try + sTime=time() plotdata3d, plotlayout3d=loadSurfacePlot(imgIntT) GC.gc() # Trigger garbage collection if Sys.islinux() @@ -856,18 +864,16 @@ include("./julia_imzML_visual.jl") catch e msg="Failed to load and process image: $e" warning_msg=true + finally + progressPlot=false + btnPlotDisable=false + btnStartDisable=false + if msi_data !== nothing + # We enable coord search and spectra plot creation + btnSpectraDisable=false + SpectraEnabled=true + end end - else - msg="Image could not be 3d plotted" - warning_msg=true - end - progressPlot=false - btnPlotDisable=false - btnStartDisable=false - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true end end @@ -877,15 +883,21 @@ include("./julia_imzML_visual.jl") cleaned_imgInt=replace(imgInt, r"\?.*" => "") cleaned_imgInt=lstrip(cleaned_imgInt, '/') var=joinpath("./public", cleaned_imgInt) - sTime=time() - - if isfile(var) - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true + + if !isfile(var) + msg="Image could not be 2D plotted" + warning_msg=true + return + end + + progressPlot=true + btnPlotDisable=true + btnStartDisable=true + btnSpectraDisable=true + + @async begin try - img=load(var) + sTime=time() plotdataC,plotlayoutC=loadContourPlot(imgInt) GC.gc() # Trigger garbage collection if Sys.islinux() @@ -898,18 +910,16 @@ include("./julia_imzML_visual.jl") catch e msg="Failed to load and process image: $e" warning_msg=true + finally + progressPlot=false + btnPlotDisable=false + btnStartDisable=false + if msi_data !== nothing + # We enable coord search and spectra plot creation + btnSpectraDisable=false + SpectraEnabled=true + end end - else - msg="Image could not be 2D plotted" - warning_msg=true - end - progressPlot=false - btnPlotDisable=false - btnStartDisable=false - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true end end # Contour 2d plot for TrIQ @@ -918,15 +928,21 @@ include("./julia_imzML_visual.jl") cleaned_imgIntT=replace(imgIntT, r"\?.*" => "") cleaned_imgIntT=lstrip(cleaned_imgIntT, '/') var=joinpath("./public", cleaned_imgIntT) - sTime=time() - - if isfile(var) - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true + + if !isfile(var) + msg="Image could not be 2D plotted" + warning_msg=true + return + end + + progressPlot=true + btnPlotDisable=true + btnStartDisable=true + btnSpectraDisable=true + + @async begin try - img=load(var) + sTime=time() plotdataC,plotlayoutC=loadContourPlot(imgIntT) GC.gc() # Trigger garbage collection if Sys.islinux() @@ -939,18 +955,16 @@ include("./julia_imzML_visual.jl") catch e msg="Failed to load and process image: $e" warning_msg=true + finally + progressPlot=false + btnPlotDisable=false + btnStartDisable=false + if msi_data !== nothing + # We enable coord search and spectra plot creation + btnSpectraDisable=false + SpectraEnabled=true + end end - else - msg="Image could not be 2D plotted" - warning_msg=true - end - progressPlot=false - btnPlotDisable=false - btnStartDisable=false - if isfile(full_routeMz) - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true end end @@ -961,48 +975,39 @@ include("./julia_imzML_visual.jl") # To include a visualization in the spectrum plot indicating where is the selected mass @onchange Nmass begin if !isempty(xSpectraMz) - traceSpectra=PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, mode="lines",name="Spectra",showlegend=false) - trace2=PlotlyBase.scatter(x=[Nmass, Nmass],y=[0, maximum(ySpectraMz)],mode="lines",line=attr(color="red", width=0.5),name="m/z selected",showlegend=false) - plotdata=[traceSpectra,trace2] # We add the data from spectra and the red line to the plot + # Use a stem plot for the main spectrum for consistency + traceSpectra = PlotlyBase.stem(x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), name="Spectrum", hoverinfo="x", hovertemplate="m/z: %{x:.4f}", showlegend=false) + + # Keep this as a scatter plot to draw the vertical line + trace2 = PlotlyBase.scatter(x=[Nmass, Nmass], y=[0, maximum(ySpectraMz)], mode="lines", line=attr(color="red", width=0.5), name="m/z selected", showlegend=false) + + plotdata = [traceSpectra, trace2] end end # Event detection for clicking on the images @onchange data_click begin + if selectedTab == "tab1" || selectedTab == "tab0" + # This is for the image heatmaps + cursor_data = data_click["cursor"] + x = Int32(round(cursor_data["x"])) + y = Int32(round(cursor_data["y"])) # y is negative in the UI + + # Update the reactive coordinates, which will trigger the crosshair update + xCoord = clamp(x, 1, imgWidth) + yCoord = clamp(y, -imgHeight, -1) + end + end + + @onchange xCoord, yCoord begin if selectedTab == "tab1" - cursor_data=data_click["cursor"] - xCoord=Int32(round(cursor_data["x"])) - yCoord=Int32(round(cursor_data["y"])) - if xCoord < 1 - xCoord=1 - elseif xCoord > imgWidth - xCoord=imgWidth - end - if yCoord > -1 - yCoord=-1 - elseif yCoord < -imgHeight - yCoord=-imgHeight - end # Get the x and y values from the click of the cursor and make sure they don't exceed image proportions - plotdataImgT=filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y"]), plotdataImgT) - trace1, trace2=crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) - plotdataImgT=append!(plotdataImgT, [trace1, trace2]) + plotdataImgT = filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y"]), plotdataImgT) + trace1, trace2 = crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) + plotdataImgT = append!(plotdataImgT, [trace1, trace2]) elseif selectedTab == "tab0" - cursor_data=data_click["cursor"] - xCoord=Int32(round(cursor_data["x"])) - yCoord=Int32(round(cursor_data["y"])) - if xCoord < 1 - xCoord=1 - elseif xCoord > imgWidth - xCoord=imgWidth - end - if yCoord > -1 - yCoord=-1 - elseif yCoord < -imgHeight - yCoord=-imgHeight - end # Get the x and y values from the click of the cursor and make sure they don't exceed image proportions - plotdataImg=filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y","Optical"]), plotdataImg) - trace1, trace2=crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) - plotdataImg=append!(plotdataImg, [trace1, trace2]) + plotdataImg = filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y", "Optical"]), plotdataImg) + trace1, trace2 = crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) + plotdataImg = append!(plotdataImg, [trace1, trace2]) end end diff --git a/app.jl.html b/app.jl.html index 77c2180..f9c9653 100644 --- a/app.jl.html +++ b/app.jl.html @@ -69,6 +69,11 @@ Mean spectrum plot + + + Sum Spectrum plot + + Spectrum plot (X,Y) diff --git a/julia_imzML_visual.jl b/julia_imzML_visual.jl index 604fcb1..e20abf7 100644 --- a/julia_imzML_visual.jl +++ b/julia_imzML_visual.jl @@ -1,84 +1,3 @@ -# IntQuantCl is originally a function of julia mzMl imzML with the adition -# of altering the scale of the colors according to colorlevel. -function IntQuantCl( slice , colorLevel) - # Compute scale factor for amplitude discretization - lower = minimum( slice ) - scale = colorLevel / maximum( slice ) - dim = size( slice ) - image = zeros( UInt8, dim[1], dim[2] ) - for i in 1:length( slice ) - image[i] = convert( UInt8, floor( slice[i] * scale + 0.5 ) ) - end - return image -end - -# SaveBitmap originally a function of the mzML imzML library in julia, -# This function dinamically adjust the color palete adjusting to the ammount of colors -# available in pixmap -function SaveBitmapCl( name, pixMap::Array{UInt8,2}, colorTable::Array{UInt32,1} ) - # Get image dimensions - dim = size( pixMap ) - if length( dim ) != 2 - return 0 - end - # Normalize pixel values to get a more accurate reading of the image - minVal = minimum(pixMap) - maxVal = maximum(pixMap) - pixMap = round.(UInt8, 255 * (pixMap .- minVal) ./ (maxVal - minVal)) - # Compute row padding - padding = ( 4 - dim[1] & 0x3 ) & 0x3 - # Compute file dimensions. Header = 14 + 40 + ( 256 * 4 ) = 1078 - offset = 1078 - imgBytes = dim[2] * ( dim[1] + padding ) - # Create file - stream = open( name, "w" ) - # Save file header - write( stream, UInt16( 0x4D42 ) ) - write( stream, UInt32[ offset + imgBytes, 0 , offset ] ) - # Save info header - write( stream, UInt32[ 40, dim[1], dim[2], 0x80001, 0 ] ) - write( stream, UInt32[ imgBytes, 0, 0, 256, 0 ] ) - # Save color table - write( stream, colorTable ) - if length( colorTable ) < 256 - fixTable = zeros( UInt32, 256 - length( colorTable ) ) - write( stream, fixTable ) - end - # Save image pixels - if padding == 0 - for i = 1:dim[2] - write( stream, pixMap[:,i] ) - end - else - zeroPad = zeros( UInt8, padding ) - for i in 1:dim[2] - write( stream, pixMap[:,i] ) - write( stream, zeroPad ) - end - end - # Close file - close( stream ) -end - -# SaveBitmap originally a function of the mzML imzML library in julia, -# now has an adjustment for NaN values in case they exist to mantain data integrity -function GetMzSliceJl(imzML, mass, tolerance) - # Alloc space for slice - width = maximum(imzML[1, :]) - height = maximum(imzML[2, :]) - image = fill(0.0, width, height) - - for i in 1:size(imzML)[2] - index = julia_mzML_imzML.FindMass(imzML[3, i], mass, tolerance) - if index != 0 - image[imzML[1, i], imzML[2, i]] = imzML[4, i][index] - end - end - # Adjustment for NaN values with 0 - replace!(image, NaN => 0.0) - return image -end - # == Search functions == # Functions that recieve a list to update, and the current direction both as string for # searching in the directory the position the list is going @@ -129,10 +48,12 @@ function loadImgPlot(interfaceImg::String) layout=PlotlyBase.Layout( xaxis=PlotlyBase.attr( visible=false, - scaleanchor="y" + scaleanchor="y", + range=[0, width] ), yaxis=PlotlyBase.attr( - visible=false + visible=false, + range=[-height, 0] ), margin=attr(l=0,r=0,t=0,b=0,pad=0) ) @@ -167,6 +88,7 @@ function loadImgPlot(interfaceImg::String) plotlayout=layout return plotdata, plotlayout, width, height end + # loadImgPlot recieves the local directory of the image as a string, the local directory o the overlay image # and the transparency its required to have. Returns the layout and data for the heatmap plotly plot # this function loads the image into a plot @@ -286,6 +208,7 @@ function loadContourPlot(interfaceImg::String) plotlayout=layout return plotdata, plotlayout end + # loadSurfacePlot recieves the local directory of the image as a string, # returns the layout and data for the surface plotly plot # this function loads the image and applies a gaussian filter @@ -363,6 +286,7 @@ function loadSurfacePlot(interfaceImg::String) plotlayout=layout3D return plotdata, plotlayout end + # This function recieves the x and y coords currently selected, and the dimentions of # the image to create two traces that will display in a cross section function crossLinesPlot(x, y, maxwidth, maxheight) @@ -404,77 +328,117 @@ function log_tick_formatter(values::Vector{Float64}) end -# Median filter: an adaptation of the R medianfilter, which averages the matrix -# with the close pixels just from the sides to reduce noise. -# This one in particular is a midpoint fiter from a 3x3 neighbour area -function medianFilterjl(pixMap) - height, width = size(pixMap) - padded_pixMap = padarray(pixMap, 1) - target = zeros(eltype(pixMap), height, width) - - for j in 1:width - for i in 1:height - neighbors = [] - for dj in j:j+2 - for di in i:i+2 - push!(neighbors, padded_pixMap[di, dj]) - end - end - target[i, j] = median(neighbors) - end - end - - return target -end - -function padarray(A, padsize) - h, w = size(A) - padded = zeros(eltype(A), h + 2*padsize, w + 2*padsize) - padded[padsize+1:end-padsize, padsize+1:end-padsize] .= A - padded[1:padsize, padsize+1:end-padsize] .= A[1:padsize, :] - padded[end-padsize+1:end, padsize+1:end-padsize] .= A[end-padsize+1:end, :] - padded[padsize+1:end-padsize, 1:padsize] .= A[:, 1:padsize] - padded[padsize+1:end-padsize, end-padsize+1:end] .= A[:, end-padsize+1:end] - padded[1:padsize, 1:padsize] .= A[1, 1] - padded[1:padsize, end-padsize+1:end] .= A[1, end] - padded[end-padsize+1:end, 1:padsize] .= A[end, 1] - padded[end-padsize+1:end, end-padsize+1:end] .= A[end, end] - return padded -end - # meanSpectrumPlot recieves the local directory of the image as a string, # returns the layout and data for the surface plotly plot # this function loads the spectra data and makes a mean to display # its values in the spectrum plot -function meanSpectrumPlot(mzmlRoute::String) - spectraMz=LoadMzml(mzmlRoute) - xSpectraMz=Float64[] - ySpectraMz=Float64[] - - layout=PlotlyBase.Layout( - title="Mean spectrum plot", +function meanSpectrumPlot(data::MSIData) + layout = PlotlyBase.Layout( + title="Average Spectrum Plot", + hovermode="closest", xaxis=PlotlyBase.attr( title="m/z", + showgrid=true + ), + yaxis=PlotlyBase.attr( + title="Average Intensity", showgrid=true, - tickformat = ".3g" + tickformat=".3g" + ), + margin=attr(l=0, r=0, t=120, b=0, pad=0) + ) + + # Use the new, efficient function from the backend + xSpectraMz, ySpectraMz = get_average_spectrum(data) + + if isempty(xSpectraMz) + @warn "Average spectrum is empty." + trace = PlotlyBase.stem(x=Float64[], y=Float64[]) + else + trace = PlotlyBase.stem(x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), name="Average", hoverinfo="x",hovertemplate="m/z: %{x:.4f}") + end + + + plotdata = [trace] + plotlayout = layout + return plotdata, plotlayout, xSpectraMz, ySpectraMz +end + +function xySpectrumPlot(data::MSIData, xCoord::Int, yCoord::Int, imgWidth::Int, imgHeight::Int) + local mz::AbstractVector, intensity::AbstractVector + local plot_title::String + + is_imaging = data.source isa ImzMLSource + + if is_imaging + # For imaging data, use (X, Y) coordinates + x = clamp(xCoord, 1, imgWidth) + y = clamp(yCoord, 1, imgHeight) + + mz, intensity = GetSpectrum(data, x, y) + plot_title = "Spectrum at ($x, $y)" + else + # For non-imaging data, treat xCoord as the spectrum index + index = clamp(xCoord, 1, length(data.spectra_metadata)) + + mz, intensity = GetSpectrum(data, index) + plot_title = "Spectrum #$index" + end + + layout = PlotlyBase.Layout( + title=plot_title, + hovermode="closest", + xaxis=PlotlyBase.attr( + title="m/z", + showgrid=true ), yaxis=PlotlyBase.attr( title="Intensity", showgrid=true, - tickformat = ".3g" + tickformat=".3g" ), - autosize=false, - margin=attr(l=0,r=0,t=120,b=0,pad=0) + margin=attr(l=0, r=0, t=120, b=0, pad=0) ) - try - xSpectraMz=mean(spectraMz[1,:]) - ySpectraMz=mean(spectraMz[2,:]) - catch e - xSpectraMz=spectraMz[1,1] - ySpectraMz=spectraMz[2,1] + + # Downsample for plotting performance + mz_down, int_down = MSI_src.downsample_spectrum(mz, intensity) + + trace = PlotlyBase.stem(x=mz_down, y=int_down, marker=attr(size=1, color="blue", opacity=0.5), name="Spectrum", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") + + plotdata = [trace] + plotlayout = layout + + # Return the full data for other uses, and the plot data + return plotdata, plotlayout, mz, intensity +end + +function sumSpectrumPlot(data::MSIData) + layout = PlotlyBase.Layout( + title="Total Spectrum Plot", + hovermode="closest", + xaxis=PlotlyBase.attr( + title="m/z", + showgrid=true + ), + yaxis=PlotlyBase.attr( + title="Total Intensity", + showgrid=true, + tickformat=".3g" + ), + margin=attr(l=0, r=0, t=120, b=0, pad=0) + ) + + # Use the get_total_spectrum function from the backend + xSpectraMz, ySpectraMz = get_total_spectrum(data) + + if isempty(xSpectraMz) + @warn "Total spectrum is empty." + trace = PlotlyBase.stem(x=Float64[], y=Float64[]) + else + trace = PlotlyBase.stem(x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), name="Total", hoverinfo="x",hovertemplate="m/z: %{x:.4f}") end - trace=PlotlyBase.stem(x=xSpectraMz, y=ySpectraMz,marker=attr(size=1, color="blue", opacity=0.1)) - plotdata=[trace] # We add the data from spectra to the plot - plotlayout=layout + + plotdata = [trace] + plotlayout = layout return plotdata, plotlayout, xSpectraMz, ySpectraMz -end \ No newline at end of file +end diff --git a/src/MSIData.jl b/src/MSIData.jl index 4ac7107..b570ee3 100644 --- a/src/MSIData.jl +++ b/src/MSIData.jl @@ -6,7 +6,7 @@ including caching and iteration logic, for handling large mzML and imzML dataset efficiently. """ -using Base64, Libz # For reading binary data +using Base64, Libz, Serialization # For reading binary data # Abstract type for different data sources (e.g., mzML, imzML) # This allows dispatching to the correct binary reading logic. @@ -179,17 +179,10 @@ function GetSpectrum(data::MSIData, x::Int, y::Int) return GetSpectrum(data, index) # Call the existing method end -""" - get_total_spectrum(msi_data::MSIData; num_bins::Int=20000) -> Tuple{Vector{Float64}, Vector{Float64}} +using Serialization -Calculates the sum of all spectra in the dataset by binning. -The m/z range is determined dynamically by finding the global min/max m/z values -across the entire dataset. - -Returns a tuple containing two vectors: the binned m/z axis and the summed intensities. -""" -function get_total_spectrum(msi_data::MSIData; num_bins::Int=20000) - println("Calculating total spectrum...") +function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) + println("Calculating total spectrum (2-pass method for imzML)...") # 1. First Pass: Find the global m/z range println(" Pass 1: Finding global m/z range...") @@ -222,7 +215,6 @@ function get_total_spectrum(msi_data::MSIData; num_bins::Int=20000) return # continue equivalent end for i in eachindex(mz) - # Find the correct bin for the current m/z value bin_index = clamp(round(Int, (mz[i] - global_min_mz) / bin_step) + 1, 1, num_bins) intensity_sum[bin_index] += intensity[i] end @@ -232,6 +224,83 @@ function get_total_spectrum(msi_data::MSIData; num_bins::Int=20000) return (collect(mz_bins), intensity_sum) end +function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) + println("Calculating total spectrum (single-pass optimization for mzML)...") + num_spectra = length(msi_data.spectra_metadata) + if num_spectra == 0 + return (Float64[], Float64[]) + end + + temp_path, temp_io = mktemp() + try + # --- Pass 1: Write spectra to temp file and find min/max m/z --- + println(" Pass 1: Caching spectra and finding global m/z range...") + global_min_mz = Inf + global_max_mz = -Inf + + _iterate_spectra_fast(msi_data) do idx, mz, intensity + Serialization.serialize(temp_io, (mz, intensity)) + if !isempty(mz) + local_min, local_max = extrema(mz) + global_min_mz = min(global_min_mz, local_min) + global_max_mz = max(global_max_mz, local_max) + end + end + + flush(temp_io) + + if !isfinite(global_min_mz) + @warn "Could not determine a valid m/z range. All spectra might be empty." + return (Float64[], Float64[]) + end + println(" Global m/z range found: [$(global_min_mz), $(global_max_mz)]") + + # --- Pass 2: Read from temp file and bin intensities --- + println(" Pass 2: Reading from cache and summing intensities into $num_bins bins...") + + seekstart(temp_io) + mz_bins = range(global_min_mz, stop=global_max_mz, length=num_bins) + intensity_sum = zeros(Float64, num_bins) + bin_step = step(mz_bins) + + while !eof(temp_io) + mz, intensity = Serialization.deserialize(temp_io)::Tuple{AbstractVector, AbstractVector} + + if isempty(mz) + continue + end + for i in eachindex(mz) + bin_index = clamp(round(Int, (mz[i] - global_min_mz) / bin_step) + 1, 1, num_bins) + intensity_sum[bin_index] += intensity[i] + end + end + + println("Total spectrum calculation complete.") + return (collect(mz_bins), intensity_sum) + + finally + close(temp_io) + rm(temp_path, force=true) + end +end + +""" + get_total_spectrum(msi_data::MSIData; num_bins::Int=20000) -> Tuple{Vector{Float64}, Vector{Float64}} + +Calculates the sum of all spectra in the dataset by binning. +This function dispatches to a specialized implementation based on the file type +(.imzML or .mzML) for optimal performance. + +Returns a tuple containing two vectors: the binned m/z axis and the summed intensities. +""" +function get_total_spectrum(msi_data::MSIData; num_bins::Int=2000) + if msi_data.source isa ImzMLSource + return get_total_spectrum_imzml(msi_data, num_bins=num_bins) + else # MzMLSource + return get_total_spectrum_mzml(msi_data, num_bins=num_bins) + end +end + """ get_average_spectrum(msi_data::MSIData; num_bins::Int=20000) -> Tuple{Vector{Float64}, Vector{Float64}} @@ -241,7 +310,7 @@ This is effectively the total ion chromatogram (TIC) divided by the number of sp Returns a tuple containing two vectors: the binned m/z axis and the averaged intensities. """ -function get_average_spectrum(msi_data::MSIData; num_bins::Int=20000) +function get_average_spectrum(msi_data::MSIData; num_bins::Int=2000) # This function uses the exact same logic as get_total_spectrum... mz_bins, intensity_sum = get_total_spectrum(msi_data, num_bins=num_bins) @@ -365,5 +434,3 @@ function _iterate_spectra_fast(f::Function, data::MSIData) # Dispatch to the correct implementation based on the source type _iterate_spectra_fast_impl(f, data, data.source) end - - diff --git a/src/MzmlConverter.jl b/src/MzmlConverter.jl index 69e10be..695eed4 100644 --- a/src/MzmlConverter.jl +++ b/src/MzmlConverter.jl @@ -26,26 +26,19 @@ struct BinaryMetadata end """ - GetMzmlScanTime(fileName::String) + GetMzmlScanTime_linebyline(fileName::String) -Parses a .mzML file to extract the scan start time for each spectrum. - -# Arguments -* `fileName`: Path to the .mzML file. - -# Returns -- A `Matrix{Int64}` where each row is `[spectrum_index, time_in_milliseconds]`. +(Internal) Slow, line-by-line parser for scan times. Used as a fallback if +the .mzML file index is missing or corrupt. """ -function GetMzmlScanTime(fileName::String) +function GetMzmlScanTime_linebyline(fileName::String) times = Tuple{Int64, Int64}[] - # Pre-allocate based on an estimate to reduce re-allocations try file_size = filesize(fileName) - estimated_spectra = max(1000, file_size ÷ 10000) # Heuristic + estimated_spectra = max(1000, file_size ÷ 10000) sizehint!(times, estimated_spectra) catch - # Ignore if filesize fails, sizehint! is just an optimization end open(fileName, "r") do stream @@ -60,27 +53,23 @@ function GetMzmlScanTime(fileName::String) current_index = 0 current_time = nothing - # Process index from the start tag itself idx_match = match(r"index=\"(\d+)\"", line) if idx_match !== nothing current_index = parse(Int, idx_match.captures[1]) + 1 end end elseif state == :in_spectrum - # Look for time cvParam if occursin("", line) state = :outside_spectrum if current_index > 0 && current_time !== nothing @@ -91,9 +80,100 @@ function GetMzmlScanTime(fileName::String) end end - # Sort by spectrum index to ensure correct order before normalization sort!(times, by = x -> x[1]) + if !isempty(times) + first_time = times[1][2] + for i in eachindex(times) + times[i] = (times[i][1], times[i][2] - first_time) + end + end + + if isempty(times) + return Matrix{Int64}(undef, 0, 2) + end + return permutedims(hcat(collect.(times)...)) +end + +""" + GetMzmlScanTime(fileName::String) + +Parses a .mzML file to extract the scan start time for each spectrum. +Uses the indexed part of the .mzML file for fast access, falling back to +a slower line-by-line parse if the index is not present. + +# Arguments +* `fileName`: Path to the .mzML file. + +# Returns +- A `Matrix{Int64}` where each row is `[spectrum_index, time_in_milliseconds]`. +""" +function GetMzmlScanTime(fileName::String) + times = Tuple{Int64, Int64}[] + + try + open(fileName, "r") do stream + # 1. Find and parse the spectrum index offsets + seekend(stream) + end_chunk_size = min(filesize(stream), 8192) + seek(stream, filesize(stream) - end_chunk_size) + footer = read(stream, String) + + index_offset_match = match(r"(\d+)", footer) + if index_offset_match === nothing + @warn "No found. Falling back to slow line-by-line parsing for scan times. This may be memory intensive." + return GetMzmlScanTime_linebyline(fileName) + end + + index_offset = parse(Int64, index_offset_match.captures[1]) + seek(stream, index_offset) + + # The find_tag function is defined in ParserHelpers.jl + if find_tag(stream, r"", line) || occursin("", line) + break + end + end + + if current_time !== nothing + push!(times, (idx, current_time)) + end + end + end + catch e + @error "Failed to parse scan times with indexed method. Falling back to line-by-line." exception=(e, catch_backtrace()) + return GetMzmlScanTime_linebyline(fileName) + end + + # The index is already sorted by spectrum index, so no need to sort `times`. + # Normalize times relative to the first scan if !isempty(times) first_time = times[1][2] @@ -102,7 +182,6 @@ function GetMzmlScanTime(fileName::String) end end - # Convert vector of tuples to a matrix if isempty(times) return Matrix{Int64}(undef, 0, 2) end @@ -329,131 +408,113 @@ function RenderPixel(pixel_info, scans, msi_data::MSIData, scan_time_deltas, pix return (mz_array, new_intensity) end -function ConvertMzmlToImzml(source_file::String, timing_matrix::Matrix{Int64}, scans::Matrix{Int64}) +function ConvertMzmlToImzml(source_file::String, target_ibd_file::String, timing_matrix::Matrix{Int64}, scans::Matrix{Int64}) if size(timing_matrix, 1) == 0 - return ProcessedPixel[], (0, 0) + # Create an empty .ibd file if there's nothing to process + open(target_ibd_file, "w") do ibd_stream + write(ibd_stream, zeros(UInt8, 16)) # UUID placeholder + end + return BinaryMetadata[], Tuple{Int, Int}[], (0, 0) end - # Get image dimensions from timing_matrix (assuming columns 1 and 2 are X and Y) width = maximum(timing_matrix[:, 1]) height = maximum(timing_matrix[:, 2]) - # Open the mzML file using the new memory-efficient API msi_data = OpenMSIData(source_file) - # Pre-calculate time deltas robustly scan_time_deltas = zeros(Int64, size(scans, 1)) if size(scans, 1) > 1 for i in 1:(size(scans, 1) - 1) delta = scans[i+1, 2] - scans[i, 2] - scan_time_deltas[i] = max(1, delta) # Ensure delta is at least 1 + scan_time_deltas[i] = max(1, delta) end - scan_time_deltas[end] = max(1, scan_time_deltas[end-1]) # Use previous delta for last scan, ensure at least 1 + scan_time_deltas[end] = max(1, scan_time_deltas[end-1]) end pixel_time_deltas = zeros(Int64, size(timing_matrix, 1)) if size(timing_matrix, 1) > 1 for i in 1:(size(timing_matrix, 1) - 1) - # Assumes time is in the 3rd column of the timing_matrix delta = timing_matrix[i+1, 3] - timing_matrix[i, 3] - pixel_time_deltas[i] = max(1, delta) # Ensure delta is at least 1 + pixel_time_deltas[i] = max(1, delta) end - pixel_time_deltas[end] = max(1, pixel_time_deltas[end-1]) # Use previous delta for last pixel, ensure at least 1 + pixel_time_deltas[end] = max(1, pixel_time_deltas[end-1]) end - processed_pixels = ProcessedPixel[] - sizehint!(processed_pixels, size(timing_matrix, 1)) + binary_meta_vec = BinaryMetadata[] + sizehint!(binary_meta_vec, size(timing_matrix, 1)) + coords_vec = Tuple{Int, Int}[] + sizehint!(coords_vec, size(timing_matrix, 1)) empty_pixel_count = 0 - # DEBUG: Scan coverage analysis - println("DEBUG: Scan coverage analysis:") - covered_pixels_count = 0 - for i in 1:size(timing_matrix, 1) - first_scan = timing_matrix[i, 4] - last_scan = timing_matrix[i, 5] - if first_scan <= last_scan && first_scan >= 1 && last_scan <= size(scans, 1) - covered_pixels_count += 1 + open(target_ibd_file, "w") do ibd_stream + write(ibd_stream, zeros(UInt8, 16)) # UUID placeholder + + for i in 1:size(timing_matrix, 1) + pixel_info = timing_matrix[i, :] + x, y = pixel_info[1], pixel_info[2] + push!(coords_vec, (x, y)) + + first_scan = pixel_info[4] + last_scan = pixel_info[5] + + if first_scan > last_scan || first_scan < 1 || last_scan > size(scans, 1) + empty_pixel_count += 1 + # For empty pixels, offsets point to the current end of file, with zero length + current_pos = position(ibd_stream) + push!(binary_meta_vec, BinaryMetadata(current_pos, 0, current_pos, 0)) + continue + end + + mz, intensity = RenderPixel(pixel_info, scans, msi_data, scan_time_deltas, pixel_time_deltas) + + # Write m/z array + mz_offset = position(ibd_stream) + for val in mz + write(ibd_stream, htol(Float64(val))) + end + mz_length = position(ibd_stream) - mz_offset + + # Write intensity array + int_offset = position(ibd_stream) + for val in intensity + write(ibd_stream, htol(Float32(val))) + end + int_length = position(ibd_stream) - int_offset + + push!(binary_meta_vec, BinaryMetadata(mz_offset, mz_length, int_offset, int_length)) end end - println(" Pixels with potential scans: $covered_pixels_count/$(size(timing_matrix, 1))") - println(" Total scans available: $(size(scans, 1))") - for i in 1:size(timing_matrix, 1) - pixel_info = timing_matrix[i, :] - x = pixel_info[1] - y = pixel_info[2] - first_scan = pixel_info[4] - last_scan = pixel_info[5] - - if first_scan > last_scan || first_scan < 1 || last_scan > size(scans, 1) - empty_pixel_count += 1 - push!(processed_pixels, ProcessedPixel((x, y), Float64[], Float32[])) - continue - end - - mz, intensity = RenderPixel(pixel_info, scans, msi_data, scan_time_deltas, pixel_time_deltas) - push!(processed_pixels, ProcessedPixel((x, y), mz, intensity)) - end - - @info "Found and created $empty_pixel_count empty pixels out of $(size(timing_matrix, 1)) total." - return processed_pixels, (width, height) + @info "Found and processed $empty_pixel_count empty pixels out of $(size(timing_matrix, 1)) total." + return binary_meta_vec, coords_vec, (width, height) end -function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims::Tuple{Int, Int}) +function ExportImzml(target_file::String, binary_meta::Vector{BinaryMetadata}, coords::Vector{Tuple{Int, Int}}, dims::Tuple{Int, Int}) ibd_file = replace(target_file, r"\.imzML$"i => ".ibd") - # Ensure we have valid pixels - if isempty(pixels) - @error "No pixels to export" - return false + if isempty(binary_meta) + @warn "No binary metadata to export; creating empty imzML file." + # Still create a valid, empty imzML file end - binary_meta = BinaryMetadata[] - sizehint!(binary_meta, length(pixels)) - try - # Step 1: Write .ibd file with proper binary format - open(ibd_file, "w") do ibd_stream - # Write UUID as first 16 bytes (placeholder) - write(ibd_stream, zeros(UInt8, 16)) + # The .ibd file is now written by ConvertMzmlToImzml. + # This function is only responsible for the .imzML XML metadata file. - # Write in little-endian format (standard for imzML) - for pixel in pixels - # Write m/z array as 64-bit little-endian floats - mz_offset = position(ibd_stream) - for val in pixel.mz - write(ibd_stream, htol(Float64(val))) # Host to little-endian - end - mz_length = position(ibd_stream) - mz_offset - - # Write intensity array as 32-bit little-endian floats - int_offset = position(ibd_stream) - for val in pixel.intensity - write(ibd_stream, htol(Float32(val))) # Host to little-endian - end - int_length = position(ibd_stream) - int_offset - - push!(binary_meta, BinaryMetadata(mz_offset, mz_length, int_offset, int_length)) - end - end - - # Step 2: Write proper .imzML XML file open(target_file, "w") do imzml_stream - # Use indexedmzML wrapper + # XML Header write(imzml_stream, """ """) - # CV List + # CV List, File Description, etc. (static parts) write(imzml_stream, """ """) - - # File Description write(imzml_stream, """ @@ -462,8 +523,6 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: """) - - # Referenceable Param Groups write(imzml_stream, """ @@ -479,24 +538,18 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: """) - - # Sample List write(imzml_stream, """ """) - - # Software List - OPEN SOURCE write(imzml_stream, """ """) - - # Scan Settings IMAGING write(imzml_stream, """ @@ -506,8 +559,6 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: """) - - # Instrument Configuration MALDI/TOF write(imzml_stream, """ @@ -525,8 +576,6 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: """) - - # Data Processing write(imzml_stream, """ @@ -539,18 +588,21 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: # Run and Spectrum List spectrum_offsets = UInt64[] write(imzml_stream, """ - + """) - # Write each spectrum - for (i, pixel) in enumerate(pixels) - meta = binary_meta[i] - x, y = pixel.coords + # Write each spectrum's metadata + for (i, meta) in enumerate(binary_meta) + x, y = coords[i] spectrum_start = position(imzml_stream) push!(spectrum_offsets, spectrum_start) - write(imzml_stream, """ + # Calculate number of points from byte length + mz_points = meta.mz_length ÷ sizeof(Float64) + int_points = meta.int_length ÷ sizeof(Float32) + + write(imzml_stream, """ @@ -563,14 +615,14 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: - + - + @@ -603,10 +655,10 @@ function ExportImzml(target_file::String, pixels::Vector{ProcessedPixel}, dims:: return true catch e - @error "Failed to export imzML files" exception=(e, catch_backtrace()) - # Clean up partial files - isfile(ibd_file) && rm(ibd_file, force=true) + @error "Failed to export imzML metadata file" exception=(e, catch_backtrace()) + # Clean up partial .imzML file isfile(target_file) && rm(target_file, force=true) + # Do not delete the .ibd file as it might be useful for debugging return false end end @@ -630,17 +682,15 @@ function ImportMzmlFile(source_file::String, sync_file::String, target_file::Str println("Step 2: Matching acquisition times...") timing_matrix = MatchAcquireTime(sync_file, scans; img_width=img_width, img_height=img_height) - println("Step 3: Converting spectra...") - processed_pixels, (width, height) = ConvertMzmlToImzml(source_file, timing_matrix, scans) + println("Step 3: Converting spectra and writing .ibd file...") + ibd_file = replace(target_file, r"\.imzML$"i => ".ibd") + binary_meta, coords, (width, height) = ConvertMzmlToImzml(source_file, ibd_file, timing_matrix, scans) # Flip image vertically to match R script output - for i in eachindex(processed_pixels) - x, y = processed_pixels[i].coords - processed_pixels[i] = ProcessedPixel((x, height - y + 1), processed_pixels[i].mz, processed_pixels[i].intensity) - end + flipped_coords = [(x, height - y + 1) for (x, y) in coords] - println("Step 4: Exporting to .imzML/.ibd format...") - success = ExportImzml(target_file, processed_pixels, (width, height)) + println("Step 4: Exporting .imzML metadata file...") + success = ExportImzml(target_file, binary_meta, flipped_coords, (width, height)) if success println("Conversion successful: $target_file") diff --git a/src/imzML.jl b/src/imzML.jl index 8b7ec9f..0426109 100644 --- a/src/imzML.jl +++ b/src/imzML.jl @@ -1,4 +1,4 @@ -using Images, Statistics, CairoMakie, DataFrames, Printf, ColorSchemes +using Images, Statistics, CairoMakie, DataFrames, Printf, ColorSchemes, StatsBase # --- Extracted from imzML.jl --- @@ -347,6 +347,38 @@ function load_slices(folder, masses, tolerance) 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) # Note: height, width for (y,x) indexing + + _iterate_spectra_fast(data) do spec_idx, mz_array, intensity_array + meta = data.spectra_metadata[spec_idx] + + 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 + + # The original function had a NaN replacement, which is good practice to keep. + 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) @@ -486,6 +518,30 @@ function set_pixel_depth(img, bounds, depth) 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) @@ -515,11 +571,73 @@ function norm_slices_hist(slices, bins; prob=0.98) 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 + 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 + # ============================================================================ # # @@ -678,3 +796,97 @@ function plot_slices(slices, names, masses, output_dir; stage_name, bins=256, dp 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) diff --git a/test/readme.md b/test/readme.md index bea2736..f80ee72 100644 --- a/test/readme.md +++ b/test/readme.md @@ -1,21 +1,78 @@ -to run the tests: +# JuliaMSI Test Suite -time julia --project=. test/run_tests.jl +This document provides instructions on how to set up and run the test suite for the `JuliaMSI` package. The tests validate the core functionality of the data processing workflows, including loading, converting, and visualizing mass spectrometry data. -to enable the different test cases, there are boolean variables +## Local Installation and Setup -test1 = true -test2 = true -test3 = true +### 1. Prerequisites +Make sure you have Julia installed (at least version 1.6). If not, we recommend using `juliaup` for installation. +- **juliaup**: [https://github.com/JuliaLang/juliaup](https://github.com/JuliaLang/juliaup) +- **Official binaries**: [https://julialang.org/downloads/](https://julialang.org/downloads/) -set them to true to perform that specific test. +### 2. Download the Project +Download and decompress the repository. You can get it from the official Codeberg page or clone it using Git. +- **Download ZIP**: [https://codeberg.org/LabABI/JuliaMSI/archive/main.zip](https://codeberg.org/LabABI/JuliaMSI/archive/main.zip) +- **Git URL**: `https://codeberg.org/LabABI/JuliaMSI.git` -test 1 consists in mzml validation and spectrum plotting +Ensure you know the directory where the `JuliaMSI` files are located. -test 2 consists in imzml conversion and validation +### 3. Download Test Data +The test script requires example mass spectrometry data files. You can download the required test data from Zenodo: +- **Example Data**: [https://doi.org/10.5281/zenodo.10084132](https://doi.org/10.5281/zenodo.10084132) -test 3 consists in imzml validation and spectrum and slice plotting +Download the data and place it in a known location on your computer. -use the global variables: -TEST_MZML_FILE, SPECTRUM_TO_PLOT, CONVERSION_SOURCE_MZML, CONVERSION_SYNC_FILE, CONVERSION_TARGET_IMZML, TEST_IMZML_FILE, MZ_VALUE_FOR_SLICE, MZ_TOLERANCE, COORDS_TO_PLOT, RESULTS_DIR -to modify input that the test script will recieve. +### 4. Configure the Test Script +Before running the tests, you must edit the `test/run_tests.jl` file to point to the test data you downloaded. + +1. Open `test/run_tests.jl` in a text editor. +2. Locate the `CONFIG: PLEASE FILL IN YOUR FILE PATHS HERE` section. +3. Update the constant variables (e.g., `TEST_MZML_FILE`, `CONVERSION_SOURCE_MZML`, `CONVERSION_SYNC_FILE`) with the absolute paths to the corresponding files on your system. + +## Running the Tests + +1. **Navigate to the Project Directory**: + Open your terminal and use the `cd` command to navigate to the root folder of the `JuliaMSI` project. + ```bash + cd /path/to/your/JuliaMSI + ``` + +2. **Execute the Test Script**: + Run the following command from the project's root directory. This will install the necessary dependencies and run the tests. + ```bash + julia --project=. test/run_tests.jl + ``` + +3. **Check the Results**: + The script will print its progress to the console. Any generated images (plots and image slices) will be saved in the `test/results/` directory. + +## Test Case Configuration + +You can customize the test run by editing the variables in `test/run_tests.jl`. + +### Enabling and Disabling Test Cases +You can run or skip specific test cases by setting the corresponding boolean variables to `true` or `false`. + +```julia +test1 = true # Runs Test Case 1 +test2 = true # Runs Test Case 2 +test3 = true # Runs Test Case 3 +``` + +- **Test Case 1**: Validates a standard `.mzML` file and generates plots for a single spectrum, the total spectrum, and the average spectrum. +- **Test Case 2**: Tests the conversion of a `.mzML` file (and its corresponding `.txt` sync file) into the `.imzML` format. It then validates the newly created file. +- **Test Case 3**: Validates an existing `.imzML` file. It generates plots for a spectrum at specific coordinates, the total spectrum, the average spectrum, and an ion image slice. + +### Input Configuration Variables +All configuration variables are located in the `CONFIG` section of `test/run_tests.jl`. + +- `TEST_MZML_FILE`: Path to the standard `.mzML` file for Test Case 1. +- `SPECTRUM_TO_PLOT`: The index of the spectrum to plot from the `.mzML` file in Test Case 1. +- `CONVERSION_SOURCE_MZML`: Path to the imaging `.mzML` file to be converted in Test Case 2. +- `CONVERSION_SYNC_FILE`: Path to the `.txt` synchronization file corresponding to `CONVERSION_SOURCE_MZML`. +- `CONVERSION_TARGET_IMZML`: The output path for the `.imzML` file generated in Test Case 2. This is also used as the default input for Test Case 3. +- `TEST_IMZML_FILE`: Path to the `.imzML` file to be tested in Test Case 3. +- `MZ_VALUE_FOR_SLICE`: The m/z value to use for creating an ion image slice in Test Case 3. +- `MZ_TOLERANCE`: The tolerance (+/-) for the `MZ_VALUE_FOR_SLICE` when generating the image. +- `COORDS_TO_PLOT`: An `(X, Y)` tuple specifying the coordinates of the spectrum to plot from the `.imzML` file in Test Case 3. +- `RESULTS_DIR`: The directory where output images will be saved. \ No newline at end of file diff --git a/test/run_tests.jl b/test/run_tests.jl index d96c39f..9b2efb2 100644 --- a/test/run_tests.jl +++ b/test/run_tests.jl @@ -32,7 +32,8 @@ using MSI_src # A regular, non-imaging mzML file. # const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/mzML/T9_A1.mzML" # const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/CE4_BF_R1/CE4_BF_R1.mzML" -const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/CE4_BF_R1/CE4_BF_R1.mzML" +# const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/CE4_BF_R1/CE4_BF_R1.mzML" +const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/Imaging_paper_spray/Imaging_paper_spray.mzML" const SPECTRUM_TO_PLOT = 1 # Which spectrum to plot from the file # --- Test Case 2: .mzML + Sync File for Conversion --- @@ -49,15 +50,18 @@ const CONVERSION_TARGET_IMZML = "test/results/converted_mzml.imzML" # --- Test Case 3: Standard .imzML file --- # An existing imzML file (can be the one generated from Case 2). -const TEST_IMZML_FILE = CONVERSION_TARGET_IMZML # The output from case 2 +# const TEST_IMZML_FILE = CONVERSION_TARGET_IMZML # The output from case 2 # const TEST_IMZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/imzML_AP_SMALDI/HR2MSImouseurinarybladderS096.imzML" # const TEST_IMZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/Imaging_paper_spray/Imaging_paper_spray.imzML" +const TEST_IMZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/Imaging prueba Roya 1/royaimg.imzML" # The m/z value to use for creating an image slice. -const MZ_VALUE_FOR_SLICE = 309.06 # BF +# const MZ_VALUE_FOR_SLICE = 309.06 # BF # const MZ_VALUE_FOR_SLICE = 896.0 # HR2MSI # const MZ_VALUE_FOR_SLICE = 76.03 # I PS +const MZ_VALUE_FOR_SLICE = 473 # ROYA # const MZ_TOLERANCE = 0.1 -const MZ_TOLERANCE = 1 +# const MZ_TOLERANCE = 1 +const MZ_TOLERANCE = 0.5 # Coordinates to plot a specific spectrum from imzML const COORDS_TO_PLOT = (50, 50) # Example coordinates (X, Y) @@ -65,8 +69,8 @@ const COORDS_TO_PLOT = (50, 50) # Example coordinates (X, Y) # --- Output Directory --- const RESULTS_DIR = "test/results" -test1 = true -test2 = true +test1 = false +test2 = false test3 = true # =================================================================== @@ -216,8 +220,7 @@ function run_test() end # --- Test Case 3: Process an existing .imzML file --- - println(" -" * "="^20 * " Test Case 3: Processing .imzML " * "="^20) + println("" * "="^20 * " Test Case 3: Processing .imzML " * "="^20) if test3 == true # Run new, stronger validation for imzML validate_msi_data(TEST_IMZML_FILE) @@ -226,6 +229,7 @@ function run_test() if isfile(TEST_IMZML_FILE) # Add spectrum plotting for imzML to match Test Case 1 try + """ # Get the msi data from the imzml println("Plotting a sample spectrum from $TEST_IMZML_FILE...") msi_data = OpenMSIData(TEST_IMZML_FILE) @@ -271,6 +275,8 @@ function run_test() output_path = joinpath(RESULTS_DIR, "test_imzml_average_spectrum.png") save(output_path, fig) println("SUCCESS: Total spectrum plot saved to $output_path") + """ + println("No spectrums tested on this try.") catch e println("ERROR during spectrum plotting in Test Case 3: $e") end @@ -279,7 +285,7 @@ function run_test() try println("Testing plot_slice function on $TEST_IMZML_FILE...") msi_data = OpenMSIData(TEST_IMZML_FILE) - plot_slice(msi_data, MZ_VALUE_FOR_SLICE, MZ_TOLERANCE, RESULTS_DIR, stage_name="test_imzml_single_slice") + @time plot_slice(msi_data, MZ_VALUE_FOR_SLICE, MZ_TOLERANCE, RESULTS_DIR, stage_name="test_imzml_single_slice") # The success message is now inside plot_slice catch e println("ERROR during plot_slice test in Test Case 3: $e")