# julia_imzML_visual.jl const REGISTRY_LOCK = ReentrantLock() """ increment_image(current_image, image_list) Finds the next image in a list. # Arguments - `current_image`: The current image file name. - `image_list`: The list of available image file names. # Returns - The file name of the next image, or the last image if the current one is the last or not found. - `nothing` if `image_list` is empty. """ function increment_image(current_image, image_list) if isempty(image_list) return nothing end current_index = findfirst(isequal(current_image), image_list) if current_index === nothing || current_index == length(image_list) || current_image == "" return image_list[end] # Return the last image if current is not found or is the last else return image_list[current_index + 1] # Move to the next image end end """ decrement_image(current_image, image_list) Finds the previous image in a list. # Arguments - `current_image`: The current image file name. - `image_list`: The list of available image file names. # Returns - The file name of the previous image, or the first image if the current one is the first or not found. - `nothing` if `image_list` is empty. """ function decrement_image(current_image, image_list) if isempty(image_list) return nothing end current_index = findfirst(isequal(current_image), image_list) if current_index === nothing || current_index == 1 || current_image == "" return image_list[1] # Return the first image if current is not found or is the first else return image_list[current_index - 1] # Move to the previous image end end """ downsample_image(img_matrix, max_dim::Int) Downsamples an image matrix to a maximum dimension while preserving aspect ratio. # Arguments - `img_matrix`: The image matrix to downsample. - `max_dim`: The maximum dimension (width or height) for the downsampled image. # Returns - The downsampled image matrix. """ function downsample_image(img_matrix, max_dim::Int) h, w = size(img_matrix) if h <= max_dim && w <= max_dim return img_matrix # No downsampling needed end aspect_ratio = w / h if w > h new_w = max_dim new_h = round(Int, max_dim / aspect_ratio) else new_h = max_dim new_w = round(Int, max_dim * aspect_ratio) end # imresize from Images.jl is perfect for this return imresize(img_matrix, (new_h, new_w)) end """ loadImgPlot(interfaceImg::String) Loads an image and creates a Plotly heatmap. # Arguments - `interfaceImg`: The path to the image file relative to the "public" directory. # Returns - `plotdata`: A vector containing the Plotly trace. - `plotlayout`: The Plotly layout for the plot. - `width`: The width of the loaded image. - `height`: The height of the loaded image. """ function loadImgPlot(interfaceImg::String) # Load the image cleaned_img = replace(interfaceImg, r"\?.*" => "") cleaned_img = lstrip(cleaned_img, '/') var = joinpath("./public", cleaned_img) img = load(var) # Convert to grayscale img_gray = Gray.(img) img_array = Array(img_gray) elevation = Float32.(Array(img_array)) ./ 255.0 # Get the X, Y coordinates of the image height, width = size(img_array) X = 1:width Y = 1:height # Create the layout layout = PlotlyBase.Layout( title=PlotlyBase.attr( text="", font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=14, color="black" ) ), xaxis=PlotlyBase.attr( visible=false, scaleanchor="y", range=[0, width] ), yaxis=PlotlyBase.attr( visible=false, range=[-height, 0] ), margin=attr(l=0, r=0, t=0, b=0, pad=0) ) # Create the trace for the image trace = PlotlyBase.heatmap( z=elevation, x=X, y=-Y, name="", hoverinfo="x+y", showlegend=false, colorscale="Viridis", showscale=false, colorbar=attr( title=attr( text="Intensity", font=attr( size=14, color="black" ), side="right" ), ticks="outside", ticklen=2, tickwidth=0.5, nticks=5, tickformat=".2g" ) ) plotdata = [trace] plotlayout = layout return plotdata, plotlayout, width, height end """ loadImgPlot(interfaceImg::String, overlayImg::String, imgTrans::Float64) Loads a main image and overlays a second image on top, creating a Plotly heatmap. # Arguments - `interfaceImg`: Path to the main image file. - `overlayImg`: Path to the overlay image file. - `imgTrans`: Transparency level for the overlay image (0.0 to 1.0). # Returns - `plotdata`: A vector containing the Plotly trace for the main image. - `plotlayout`: The Plotly layout, including the overlay image. - `width`: The width of the main image. - `height`: The height of the main image. """ function loadImgPlot(interfaceImg::String, overlayImg::String, imgTrans::Float64) timestamp = string(time_ns()) # Load the main image cleaned_img = replace(interfaceImg, r"\?.*" => "") cleaned_img = lstrip(cleaned_img, '/') var = joinpath("./public", cleaned_img) img = load(var) # Convert to grayscale img_gray = Gray.(img) img_array = Array(img_gray) elevation = Float32.(Array(img_array)) ./ 255.0 # Get the X, Y coordinates of the image height, width = size(img_array) X = 1:width Y = 1:height # Create the layout with overlay image layoutImg = PlotlyBase.Layout( title=PlotlyBase.attr( text="", font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=14, color="black" ) ), images=[attr( source="$(overlayImg)?t=$(timestamp)", xref="x", yref="y", x=0, y=0, sizex=width, sizey=-height, sizing="stretch", opacity=imgTrans, layer="above" # Place the overlay image in the foreground )], xaxis=PlotlyBase.attr( visible=false, scaleanchor="y", range=[0, width] ), yaxis=PlotlyBase.attr( visible=false, range=[-height, 0] ), margin=attr(l=0, r=0, t=0, b=0, pad=0) ) # Create the trace for the main image trace = PlotlyBase.heatmap( z=elevation, x=X, y=-Y, name="", hoverinfo="x+y", showlegend=false, colorscale="Viridis", showscale=false ) plotdata = [trace] plotlayout = layoutImg return plotdata, plotlayout, width, height end """ loadContourPlot(interfaceImg::String) Loads an image, smooths it, and creates a Plotly contour plot. # Arguments - `interfaceImg`: Path to the image file. # Returns - `plotdata`: A vector containing the Plotly contour trace. - `plotlayout`: The Plotly layout for the plot. """ function loadContourPlot(interfaceImg::String) # Load the image cleaned_img=replace(interfaceImg, r"\?.*" => "") cleaned_img=lstrip(cleaned_img, '/') var=joinpath("./public", cleaned_img) img=load(var) img_gray=Gray.(img) img_array=Array(img_gray) elevation=Float32.(Array(img_array))./ 255.0 # Normalize between 0 and 1 # Smooth the image sigma=3.0 kernel=Kernel.gaussian(sigma) elevation_smoothed=imfilter(elevation, kernel) # --- DOWNSAMPLING FOR PERFORMANCE --- elevation_smoothed = downsample_image(elevation_smoothed, 512) # --- # Create the X, Y meshgrid coordinates x=1:size(elevation_smoothed, 2) y=1:size(elevation_smoothed, 1) X=repeat(reshape(x, 1, length(x)), length(y), 1) Y=repeat(reshape(y, length(y), 1), 1, length(x)) # Define tick values and text for colorbars min_val = minimum(elevation_smoothed) max_val = maximum(elevation_smoothed) tickV = range(min_val, stop=max_val, length=8) tickT = log_tick_formatter(collect(tickV)) layout=PlotlyBase.Layout( title=PlotlyBase.attr( text="2D topographic map of $cleaned_img (downsampled)", font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), xaxis=PlotlyBase.attr( visible=false, scaleanchor="y" ), yaxis=PlotlyBase.attr( visible=false ), margin=attr(l=0,r=0,t=100,b=0,pad=0) ) trace=PlotlyBase.contour( z=elevation_smoothed, x=X[1, :], # Use the first row y=-Y[:, 1], # Use the first column contours_coloring="Viridis", colorscale="Viridis", colorbar = attr( tickvals = tickV, ticktext = tickT, tickmode = "array" ) ) plotdata=[trace] plotlayout=layout return plotdata, plotlayout end """ loadSurfacePlot(interfaceImg::String) Loads an image, smooths it, and creates a 3D Plotly surface plot. # Arguments - `interfaceImg`: Path to the image file. # Returns - `plotdata`: A vector containing the Plotly surface trace. - `plotlayout`: The Plotly layout for the 3D plot. """ function loadSurfacePlot(interfaceImg::String) # Load the image cleaned_img=replace(interfaceImg, r"\?.*" => "") cleaned_img=lstrip(cleaned_img, '/') var=joinpath("./public", cleaned_img) img=load(var) img_gray=Gray.(img) # Convert to grayscale img_array=Array(img_gray) elevation=Float32.(Array(img_array)) ./ 255.0 # Normalize between 0 and 1 # Smooth the image sigma=3.0 kernel=Kernel.gaussian(sigma) elevation_smoothed=imfilter(elevation, kernel) # --- DOWNSAMPLING FOR PERFORMANCE --- elevation_smoothed = downsample_image(elevation_smoothed, 256) # --- # Create the X, Y meshgrid coordinates x=1:size(elevation_smoothed, 2) y=1:size(elevation_smoothed, 1) X=repeat(reshape(x, 1, length(x)), length(y), 1) Y=repeat(reshape(y, length(y), 1), 1, length(x)) # Define tick values and text for colorbars min_val = minimum(elevation_smoothed) max_val = maximum(elevation_smoothed) tickV = range(min_val, stop=max_val, length=8) tickT = log_tick_formatter(collect(tickV)) # Calculate the number of ticks and aspect ratio for the 3d plot x_nticks=min(20, length(x)) y_nticks=min(20, length(y)) z_nticks=5 aspect_ratio=attr(x=1, y=length(y) / length(x), z=0.5) # Define the layout for the 3D plot layout3D=PlotlyBase.Layout( title=PlotlyBase.attr( text="3D surface plot of $cleaned_img (downsampled)", font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), scene=attr( xaxis_nticks=x_nticks, yaxis_nticks=y_nticks, zaxis_nticks=z_nticks, camera=attr(eye=attr(x=0, y=1, z=0.5)), aspectratio=aspect_ratio ), margin=attr(l=0,r=0,t=120,b=0,pad=0) ) # Transpose the elevation_smoothed array if Y axis is longer than X axis to fix chopping elevation_smoothed=transpose(elevation_smoothed) if size(elevation_smoothed, 1) < size(elevation_smoothed, 2) Y=-Y else X=-X end trace3D=PlotlyBase.surface( x=X[1, :], y=Y[:, 1], z=elevation_smoothed, contours_z=attr( show=true, usecolormap=true, highlightcolor="limegreen", project_z=true ), colorscale="Viridis", colorbar = attr( tickvals = tickV, ticktext = tickT, nticks=8 ) ) plotdata=[trace3D] plotlayout=layout3D return plotdata, plotlayout end """ crossLinesPlot(x, y, maxwidth, maxheight) Creates two line traces for a crosshair indicator on a plot. # Arguments - `x`: The x-coordinate of the crosshair center. - `y`: The y-coordinate of the crosshair center. - `maxwidth`: The width of the plot area. - `maxheight`: The height of the plot area. # Returns - `trace1`: The horizontal line trace. - `trace2`: The vertical line trace. """ function crossLinesPlot(x, y, maxwidth, maxheight) # Define the coordinates for the two lines l1_x = [0, maxwidth] l1_y = [y, y] l2_x = [x, x] l2_y = [0, maxheight] # Create the line traces trace1 = PlotlyBase.scatter(x=l1_x, y=l1_y, mode="lines", line=attr(color="red", width=0.5), name="Line X", showlegend=false) trace2 = PlotlyBase.scatter(x=l2_x, y=l2_y, mode="lines", line=attr(color="red", width=0.5), name="Line Y", showlegend=false) return trace1, trace2 end """ log_tick_formatter(values::Vector{Float64}) Formats a vector of numbers into strings with a custom scientific notation for use as tick labels. For example, 1000 becomes "100x10¹" and 0.01 becomes "1.0x10⁻²". # Arguments - `values`: A vector of `Float64` values to format. # Returns - A vector of formatted strings. """ function log_tick_formatter(values::Vector{Float64}) # Initialize exponents dictionary exponents = zeros(Int, length(values)) formValues = zeros(Float64, length(values)) for i in 1:length(values) value = values[i] if value >= 1000 # positive formatting for notation while value >= 1000 value /= 10 exponents[i] += 1 end elseif value > 0 && value < 1 # negative formatting for notation while value < 1 value *= 10 exponents[i] -= 1 end end formValues[i] = value end return map((v, e) -> e == 0 ? "$(round(v, sigdigits=2))" : "$(round(v, sigdigits=2))x10" * Makie.UnicodeFun.to_superscript(e), formValues, exponents) end """ meanSpectrumPlot(data::MSIData, dataset_name::String="") Generates a plot of the mean spectrum from MSIData. # Arguments - `data`: The `MSIData` object. - `dataset_name`: Optional name of the dataset for the plot title. # Returns - `plotdata`: A vector containing the Plotly trace. - `plotlayout`: The Plotly layout for the plot. - `xSpectraMz`: The m/z values of the spectrum. - `ySpectraMz`: The intensity values of the spectrum. """ function meanSpectrumPlot(data::MSIData, dataset_name::String=""; mask_path::Union{String, Nothing}=nothing) # Determine base title based on mask usage base_title = if mask_path !== nothing "Masked Average Spectrum" else "Average Spectrum" end title_text = isempty(dataset_name) ? base_title : "$base_title for: $dataset_name" layout = PlotlyBase.Layout( title=PlotlyBase.attr( text=title_text, font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), hovermode="closest", xaxis=PlotlyBase.attr( title="m/z", showgrid=true ), yaxis=PlotlyBase.attr( title="Average Intensity", showgrid=true, tickformat=".3g" ), margin=attr(l=0, r=0, t=120, b=0, pad=0), legend=attr( x=1.0, y=1.0, xanchor="right", yanchor="top" ) ) # Use the new, efficient function from the backend xSpectraMz, ySpectraMz = get_average_spectrum(data, mask_path=mask_path) if isempty(xSpectraMz) || isempty(ySpectraMz) @warn "Average spectrum is empty." trace = PlotlyBase.stem(x=Float64[], y=Float64[]) # Update title to indicate empty spectrum layout.title.text = "Empty " * layout.title.text else df = data.spectrum_stats_df plot_as_lines = false # Default to stem for safety if no mode info if df !== nothing && hasproperty(df, :Mode) && !isempty(df.Mode) profile_count = count(==(MSI_src.PROFILE), df.Mode) plot_as_lines = profile_count > length(df.Mode) / 2 end if plot_as_lines trace = PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, mode="lines", marker=attr(size=1, color="blue", opacity=0.5), name="Average", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") 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 end plotdata = [trace] plotlayout = layout return plotdata, plotlayout, xSpectraMz, ySpectraMz end """ xySpectrumPlot(data::MSIData, xCoord::Int, yCoord::Int, imgWidth::Int, imgHeight::Int, dataset_name::String="") Generates a plot for the spectrum at a specific coordinate (for imaging data) or index (for non-imaging data). # Arguments - `data`: The `MSIData` object. - `xCoord`: The x-coordinate or spectrum index. - `yCoord`: The y-coordinate (used for imaging data). - `imgWidth`: The width of the MSI image. - `imgHeight`: The height of the MSI image. - `dataset_name`: Optional name of the dataset for the plot title. # Returns - `plotdata`: A vector containing the Plotly trace. - `plotlayout`: The Plotly layout for the plot. - `mz`: The m/z values of the spectrum. - `intensity`: The intensity values of the spectrum. """ function xySpectrumPlot(data::MSIData, xCoord::Int, yCoord::Int, imgWidth::Int, imgHeight::Int, dataset_name::String=""; mask_path::Union{String, Nothing}=nothing) local mz::AbstractVector, intensity::AbstractVector local spectrum_id::Int = -1 # Initialize spectrum_id mz, intensity = Float64[], Float64[] base_title = "" spectrum_mode = MSI_src.PROFILE if data.source isa ImzMLSource w, h = data.image_dims x = clamp(xCoord, 1, w) y = clamp(yCoord, 1, h) spectrum_id = (y - 1) * w + x # Calculate spectrum_id for imaging data # Check if spectrum stats are available and retrieve the mode if data.spectrum_stats_df !== nothing && hasproperty(data.spectrum_stats_df, :Mode) if spectrum_id <= length(data.spectrum_stats_df.Mode) try spectrum_mode = data.spectrum_stats_df.Mode[spectrum_id] catch e @warn "Could not retrieve spectrum mode for index $spectrum_id. Defaulting to PROFILE." spectrum_mode = MSI_src.PROFILE end end end if mask_path !== nothing mask_matrix = load_and_prepare_mask(mask_path, (imgWidth, imgHeight)) if !mask_matrix[y, x] @warn "Coordinate ($x, $y) is outside the specified mask." mz, intensity = Float64[], Float64[] base_title = "Spectrum Outside Mask at ($x, $y)" else process_spectrum(data, Int(x), Int(y)) do recieved_mz, recieved_intensity mz = recieved_mz intensity = recieved_intensity end base_title = "Masked Spectrum at ($x, $y)" end else process_spectrum(data, Int(x), Int(y)) do recieved_mz, recieved_intensity mz = recieved_mz intensity = recieved_intensity end base_title = "Spectrum at ($x, $y)" end else # For non-imaging data, treat xCoord as the spectrum index index = clamp(xCoord, 1, length(data.spectra_metadata)) spectrum_id = index # Assign index to spectrum_id for non-imaging data if data.spectrum_stats_df !== nothing && hasproperty(data.spectrum_stats_df, :Mode) if index <= length(data.spectrum_stats_df.Mode) spectrum_mode = data.spectrum_stats_df.Mode[index] end end process_spectrum(data, index) do recieved_mz, recieved_intensity mz = recieved_mz intensity = recieved_intensity end base_title = "Spectrum #$index" end plot_title = isempty(dataset_name) ? base_title : "$base_title for: $dataset_name" layout = PlotlyBase.Layout( title=PlotlyBase.attr( text=plot_title, font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), hovermode="closest", xaxis=PlotlyBase.attr( title="m/z", showgrid=true ), yaxis=PlotlyBase.attr( title="Intensity", showgrid=true, tickformat=".3g" ), margin=attr(l=0, r=0, t=120, b=0, pad=0), legend=attr( x=1.0, y=1.0, xanchor="right", yanchor="top" ) ) # Downsample for plotting performance mz_down, int_down = MSI_src.downsample_spectrum(mz, intensity) trace = if spectrum_mode == MSI_src.CENTROID 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}") else PlotlyBase.scatter(x=mz_down, y=int_down, mode="lines", marker=attr(size=1, color="blue", opacity=0.5), name="Spectrum", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") end plotdata = [trace] plotlayout = layout return plotdata, plotlayout, mz, intensity, spectrum_id end """ nSpectrumPlot(data::MSIData, id::Int, dataset_name::String=""; mask_path::Union{String, Nothing}=nothing) Generates a plot for a spectrum specified by its linear `id` for any type of MSIData. # Arguments - `data`: The `MSIData` object. - `id`: The linear index (ID) of the spectrum to plot. - `dataset_name`: Optional name of the dataset for the plot title. - `mask_path`: Optional path to a mask file (currently not used for plotting by ID, but kept for signature consistency). # Returns - `plotdata`: A vector containing the Plotly trace. - `plotlayout`: The Plotly layout for the plot. - `mz`: The m/z values of the spectrum. - `intensity`: The intensity values of the spectrum. - `spectrum_id`: The linear index of the spectrum (same as input `id`). """ function nSpectrumPlot(data::MSIData, id::Int, dataset_name::String=""; mask_path::Union{String, Nothing}=nothing) local mz::AbstractVector, intensity::AbstractVector local plot_title::String local spectrum_mode = MSI_src.PROFILE # Default to profile local spectrum_id::Int = id # Spectrum ID is the input id # Validate ID if id < 1 || id > length(data.spectra_metadata) @warn "Spectrum ID $id is out of bounds." mz, intensity = Float64[], Float64[] base_title = "Spectrum ID $id (Out of Bounds)" spectrum_id = 0 # Indicate invalid spectrum ID else # Get spectrum mode if data.spectrum_stats_df !== nothing && hasproperty(data.spectrum_stats_df, :Mode) if id <= length(data.spectrum_stats_df.Mode) spectrum_mode = data.spectrum_stats_df.Mode[id] end end # Retrieve spectrum data process_spectrum(data, id) do recieved_mz, recieved_intensity mz = recieved_mz intensity = recieved_intensity end base_title = "Spectrum #$id" end plot_title = isempty(dataset_name) ? base_title : "$base_title for: $dataset_name" layout = PlotlyBase.Layout( title=PlotlyBase.attr( text=plot_title, font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), hovermode="closest", xaxis=PlotlyBase.attr( title="m/z", showgrid=true ), yaxis=PlotlyBase.attr( title="Intensity", showgrid=true, tickformat=".3g" ), margin=attr(l=0, r=0, t=120, b=0, pad=0), legend=attr( x=1.0, y=1.0, xanchor="right", yanchor="top" ) ) # Downsample for plotting performance mz_down, int_down = MSI_src.downsample_spectrum(mz, intensity) trace = if spectrum_mode == MSI_src.CENTROID 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}") else PlotlyBase.scatter(x=mz_down, y=int_down, mode="lines", marker=attr(size=1, color="blue", opacity=0.5), name="Spectrum", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") end plotdata = [trace] plotlayout = layout return plotdata, plotlayout, mz, intensity, spectrum_id end """ sumSpectrumPlot(data::MSIData, dataset_name::String="") Generates a plot of the total (summed) spectrum from MSIData. # Arguments - `data`: The `MSIData` object. - `dataset_name`: Optional name of the dataset for the plot title. # Returns - `plotdata`: A vector containing the Plotly trace. - `plotlayout`: The Plotly layout for the plot. - `xSpectraMz`: The m/z values of the spectrum. - `ySpectraMz`: The intensity values of the spectrum. """ function sumSpectrumPlot(data::MSIData, dataset_name::String=""; mask_path::Union{String, Nothing}=nothing) # Determine base title based on mask usage base_title = if mask_path !== nothing "Masked Total Spectrum" else "Total Spectrum" end title_text = isempty(dataset_name) ? base_title : "$base_title for: $dataset_name" layout = PlotlyBase.Layout( title=PlotlyBase.attr( text=title_text, font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), 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), legend=attr( x=1.0, y=1.0, xanchor="right", yanchor="top" ) ) # Use the get_total_spectrum function from the backend xSpectraMz, ySpectraMz, num_spectra = get_total_spectrum(data, mask_path=mask_path) if isempty(xSpectraMz) || isempty(ySpectraMz) @warn "Total spectrum is empty." trace = PlotlyBase.stem(x=Float64[], y=Float64[]) # Update title to indicate empty spectrum layout.title.text = "Empty " * layout.title.text else df = data.spectrum_stats_df plot_as_lines = false # Default to stem for safety if df !== nothing && hasproperty(df, :Mode) && !isempty(df.Mode) profile_count = count(==(MSI_src.PROFILE), df.Mode) plot_as_lines = profile_count > length(df.Mode) / 2 end if plot_as_lines trace = PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, mode="lines", marker=attr(size=1, color="blue", opacity=0.5), name="Total", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") 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 end plotdata = [trace] plotlayout = layout return plotdata, plotlayout, xSpectraMz, ySpectraMz end """ warmup_init() Performs pre-compilation of key functions at application startup to reduce first-use latency. This is run asynchronously and should not block application startup. """ function warmup_init() @async begin println("Pre-compiling functions at startup...") # Pre-compile image processing and plotting functions try TrIQ(zeros(10, 10), 256, 0.98) catch end try quantize_intensity(zeros(10, 10), 256) catch end dummy_bmp_path = joinpath("public", "dummy.bmp") dummy_png_path = joinpath("public", "dummy.png") try save_bitmap(dummy_bmp_path, zeros(UInt8, 10, 10), ViridisPalette) loadImgPlot("/dummy.bmp") generate_colorbar_image(zeros(10, 10), 256, dummy_png_path, (0.0, 1.0)) catch e @warn "Pre-compilation step failed (this is expected if dummy files can't be created/read)" finally rm(dummy_bmp_path, force=true) rm(dummy_png_path, force=true) end println("Pre-compilation finished.") end end """ load_registry(registry_path) Loads the dataset registry from a JSON file. # Arguments - `registry_path`: Path to the `registry.json` file. # Returns - A dictionary containing the registry data. Returns an empty dictionary if the file doesn't exist or fails to parse. """ function load_registry(registry_path) return lock(REGISTRY_LOCK) do if isfile(registry_path) try JSON.parsefile(registry_path, dicttype=Dict{String,Any}) catch e @error "Failed to parse registry.json: $e" Dict{String,Any}() end else Dict{String,Any}() end end end """ extract_metadata(msi_data::MSIData, source_path::String) Extracts key metadata from an `MSIData` object for display. # Arguments - `msi_data`: The `MSIData` object. - `source_path`: The path to the source data file. # Returns - A dictionary containing summary statistics and metadata. """ function extract_metadata(msi_data::MSIData, source_path::String) df = msi_data.spectrum_stats_df if df === nothing # This can happen if precompute_analytics hasn't been run # We can still return basic info return Dict( "summary" => [ Dict("parameter" => "File Name", "value" => basename(source_path)), Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)), Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"), ], "global_min_mz" => nothing, "global_max_mz" => nothing ) end summary_stats = [ Dict("parameter" => "File Name", "value" => basename(source_path)), Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)), Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"), Dict("parameter" => "Global Min m/z", "value" => @sprintf("%.4f", Threads.atomic_add!(msi_data.global_min_mz, 0.0))), Dict("parameter" => "Global Max m/z", "value" => @sprintf("%.4f", Threads.atomic_add!(msi_data.global_max_mz, 0.0))), Dict("parameter" => "Mean TIC", "value" => @sprintf("%.2e", mean(df.TIC))), Dict("parameter" => "Mean BPI", "value" => @sprintf("%.2e", mean(df.BPI))), Dict("parameter" => "Mean # Points", "value" => @sprintf("%.1f", mean(df.NumPoints))), ] if hasproperty(df, :Mode) centroid_count = count(==(MSI_src.CENTROID), df.Mode) profile_count = count(==(MSI_src.PROFILE), df.Mode) unknown_count = count(==(MSI_src.UNKNOWN), df.Mode) push!(summary_stats, Dict("parameter" => "Centroid Spectra", "value" => string(centroid_count))) push!(summary_stats, Dict("parameter" => "Profile Spectra", "value" => string(profile_count))) if unknown_count > 0 push!(summary_stats, Dict("parameter" => "Unknown Mode Spectra", "value" => string(unknown_count))) end end return Dict( "summary" => summary_stats, "global_min_mz" => msi_data.global_min_mz, "global_max_mz" => msi_data.global_max_mz ) end """ update_registry(registry_path, dataset_name, source_path, metadata=nothing, is_imzML=false) Adds or updates an entry in the dataset registry JSON file. # Arguments - `registry_path`: Path to the `registry.json` file. - `dataset_name`: The name of the dataset. - `source_path`: The path to the source data file. - `metadata`: Optional dictionary of metadata to store. - `is_imzML`: Boolean indicating if the source is an imzML file. """ function update_registry(registry_path, dataset_name, source_path, metadata=nothing, is_imzML=false) lock(REGISTRY_LOCK) do registry = if isfile(registry_path) try JSON.parsefile(registry_path, dicttype=Dict{String,Any}) catch e @error "Failed to parse registry.json while updating: $e" Dict{String,Any}() # Start with empty if parsing fails end else Dict{String,Any}() end # Get existing entry if it exists, otherwise create new one existing_entry = get(registry, dataset_name, Dict{String,Any}()) # Start with existing data and update only the basic fields entry = copy(existing_entry) entry["source_path"] = source_path entry["processed_date"] = string(now()) entry["is_imzML"] = is_imzML # Only update metadata if provided if metadata !== nothing entry["metadata"] = metadata end # Note: has_mask and mask_path are preserved from existing_entry if they exist registry[dataset_name] = entry try open(registry_path, "w") do f JSON.print(f, registry, 4) end catch e @error "Failed to write to registry.json: $e" end end end """ save_registry(registry_path, registry_data) Saves the dataset registry to a JSON file, ensuring thread-safe access. # Arguments - `registry_path`: Path to the `registry.json` file. - `registry_data`: The dictionary containing the registry data to save. """ function save_registry(registry_path, registry_data) lock(REGISTRY_LOCK) do try open(registry_path, "w") do f JSON.print(f, registry_data, 4) end catch e @error "Failed to write to registry.json: $e" end end end """ process_file_safely(file_path, masses, params, progress_message_ref, overall_progress_ref) Safely processes a single MSI data file, generating and saving m/z slices. This function handles loading data, generating slices, saving results as bitmaps, and updating the data registry. It includes error handling and memory cleanup. # Arguments - `file_path`: Path to the `.imzML` file. - `masses`: A vector of m/z values to generate slices for. - `params`: A structure or dictionary containing processing parameters. - `progress_message_ref`: A reference to update with progress messages. - `overall_progress_ref`: A reference to update with overall progress. # Returns - A tuple `(success::Bool, message::String)`. """ function process_file_safely(file_path, masses, params, progress_message_ref, overall_progress_ref; use_mask::Bool=false) local_msi_data = nothing dataset_name = replace(basename(file_path), r"\.imzML$"i => "") output_dir = joinpath("public", dataset_name) println("Processing: $dataset_name -> $output_dir") try # --- Load Data --- progress_message_ref = "Loading: $(basename(file_path))" local_msi_data = OpenMSIData(file_path) if !(local_msi_data.source isa ImzMLSource) @warn "Skipping non-imzML file: $(basename(file_path))" return (false, "Skipped: Not an imzML file") end # --- Get mask path and load mask_matrix only if use_mask is true --- local mask_matrix_for_triq::Union{BitMatrix, Nothing} = nothing mask_path = nothing if use_mask registry = load_registry(params.registry) entry = get(registry, dataset_name, nothing) if entry !== nothing && get(entry, "has_mask", false) mask_path_candidate = get(entry, "mask_path", "") if isfile(mask_path_candidate) mask_path = mask_path_candidate # Load the mask matrix here mask_matrix_for_triq = load_and_prepare_mask(mask_path, (local_msi_data.image_dims[1], local_msi_data.image_dims[2])) println("DEBUG: Using mask: $(mask_path) with dimensions $(size(mask_matrix_for_triq))") else @warn "Mask enabled but file not found: $(mask_path_candidate). Clearing invalid mask entry." # Clear invalid mask entry update_registry_mask_fields(params.registry, dataset_name, false, "") mask_path = nothing end else @warn "Mask enabled but no valid mask entry found for: $(dataset_name)" end end # --- Generate Slices --- progress_message_ref = "Generating $(length(masses)) slices for $(dataset_name)..." slice_dict = get_multiple_mz_slices(local_msi_data, masses, params.tolerance, mask_path=mask_path) # --- Extract metadata --- metadata = extract_metadata(local_msi_data, file_path) # --- Save Slices --- mkpath(output_dir) for (mass_idx, mass) in enumerate(masses) progress_message_ref = "File $(params.fileIdx)/$(params.nFiles): Saving slice for m/z=$mass" slice = slice_dict[mass] text_nmass = replace(string(mass), "." => "_") bitmap_filename = params.triqE ? "TrIQ_$(text_nmass).bmp" : "MSI_$(text_nmass).bmp" colorbar_filename = params.triqE ? "colorbar_TrIQ_$(text_nmass).png" : "colorbar_MSI_$(text_nmass).png" if all(iszero, slice) sliceQuant = zeros(UInt8, size(slice)) bounds = (0.0, 1.0) # Default bounds for empty slice @warn "No intensity data for m/z = $mass in $(dataset_name)" else # Get both quantized data AND bounds in one call if params.triqE sliceQuant, bounds = TrIQ(slice, params.colorL, params.triqP, mask_matrix=mask_matrix_for_triq) else sliceQuant, bounds = quantize_intensity(slice, params.colorL, mask_matrix=mask_matrix_for_triq) end if params.medianF sliceQuant = round.(UInt8, median_filter(sliceQuant)) # Note: bounds remain the same after median filter end end save_bitmap(joinpath(output_dir, bitmap_filename), sliceQuant, ViridisPalette) if !all(iszero, slice) # Now pass the precomputed bounds to colorbar generation generate_colorbar_image(slice, params.colorL, joinpath(output_dir, colorbar_filename), bounds; use_triq=params.triqE, triq_prob=params.triqP, mask_path=mask_path) end end is_imzML = local_msi_data.source isa ImzMLSource update_registry(params.registry, dataset_name, file_path, metadata, is_imzML) return (true, "") catch e @error "File processing failed" file=file_path exception=(e, catch_backtrace()) return (false, "File: $(basename(file_path)) - $(sprint(showerror, e))") finally if local_msi_data !== nothing # Cleanup end local_msi_data = nothing GC.gc(true) if Sys.islinux() ccall(:malloc_trim, Int32, (Int32,), 0) end end end function update_registry_mask_fields(registry_path, dataset_name, has_mask, mask_path) lock(REGISTRY_LOCK) do registry = if isfile(registry_path) try JSON.parsefile(registry_path, dicttype=Dict{String,Any}) catch e @error "Failed to parse registry.json while updating mask fields: $e" Dict{String,Any}() end else Dict{String,Any}() end if haskey(registry, dataset_name) registry[dataset_name]["has_mask"] = has_mask registry[dataset_name]["mask_path"] = mask_path else # Create new entry if it doesn't exist registry[dataset_name] = Dict{String,Any}( "has_mask" => has_mask, "mask_path" => mask_path, "source_path" => "", "processed_date" => string(now()), "is_imzML" => false ) end try open(registry_path, "w") do f JSON.print(f, registry, 4) end catch e @error "Failed to write to registry.json: $e" end end end """ loadSurfacePlot(interfaceImg::String, mask_path::String) Loads an image, smooths it, applies a mask, crops the data to the masked region, and creates a 3D Plotly surface plot. # Arguments - `interfaceImg`: Path to the image file. - `mask_path`: Path to a mask file. Areas outside the mask will be removed and the plot axes will be cropped to the masked region. # Returns - `plotdata`: A vector containing the Plotly surface trace. - `plotlayout`: The Plotly layout for the 3D plot. """ function loadSurfacePlot(interfaceImg::String, mask_path::String) # Load the image cleaned_img = replace(interfaceImg, r"\?.*" => "") cleaned_img = lstrip(cleaned_img, '/') var = joinpath("./public", cleaned_img) img = load(var) img_gray = Gray.(img) # Convert to grayscale img_array = Array(img_gray) elevation = Float32.(Array(img_array)) ./ 255.0 # Normalize between 0 and 1 # Smooth the image sigma = 3.0 kernel = Kernel.gaussian(sigma) elevation_smoothed = imfilter(elevation, kernel) # --- APPLY MASK and CROP DATA --- mask_matrix = load_and_prepare_mask(mask_path, (size(elevation_smoothed, 2), size(elevation_smoothed, 1))) elevation_smoothed[.!mask_matrix] .= NaN32 non_nan_indices = findall(!isnan, elevation_smoothed) if isempty(non_nan_indices) # Return an empty plot if mask removes everything return [PlotlyBase.surface()], PlotlyBase.Layout(title="Empty plot: Mask covered all data") end row_indices = [idx[1] for idx in non_nan_indices] col_indices = [idx[2] for idx in non_nan_indices] y_range = minimum(row_indices):maximum(row_indices) x_range = minimum(col_indices):maximum(col_indices) cropped_elevation = elevation_smoothed[y_range, x_range] # --- # --- DOWNSAMPLE CROPPED DATA --- cropped_elevation = downsample_image(cropped_elevation, 256) # --- # Create the X, Y meshgrid coordinates for the CROPPED data x_coords = x_range y_coords = y_range X = repeat(reshape(x_coords, 1, length(x_coords)), length(y_coords), 1) Y = repeat(reshape(y_coords, length(y_coords), 1), 1, length(x_coords)) # Define tick values and text for colorbars, ignoring NaNs non_nan_values = filter(!isnan, cropped_elevation) min_val = isempty(non_nan_values) ? 0.0 : minimum(non_nan_values) max_val = isempty(non_nan_values) ? 1.0 : maximum(non_nan_values) tickV = range(min_val, stop=max_val, length=8) tickT = log_tick_formatter(collect(tickV)) # Transpose the elevation_smoothed array if Y axis is longer than X axis to fix chopping cropped_elevation = transpose(cropped_elevation) if size(cropped_elevation, 1) < size(cropped_elevation, 2) Y = -Y else X = -X end # Calculate the number of ticks and aspect ratio for the 3d plot x_nticks = min(20, length(x_coords)) y_nticks = min(20, length(y_coords)) z_nticks = 5 aspect_ratio = attr(x=1, y=length(y_coords) / length(x_coords), z=0.5) # Define the layout for the 3D plot layout3D = PlotlyBase.Layout( title=PlotlyBase.attr( text="3D surface plot of $cleaned_img (masked, cropped, downsampled)", font=PlotlyBase.attr( family="Roboto, Lato, sans-serif", size=18, color="black" ) ), scene=attr( xaxis_nticks=x_nticks, yaxis_nticks=y_nticks, zaxis_nticks=z_nticks, camera=attr(eye=attr(x=0, y=1, z=0.5)), aspectratio=aspect_ratio ), margin=attr(l=0, r=0, t=120, b=0, pad=0) ) trace3D = PlotlyBase.surface( x=X[1, :], y=Y[:, 1], z=cropped_elevation, contours_z=attr( show=true, usecolormap=true, highlightcolor="limegreen", project_z=true ), colorscale="Viridis", colorbar=attr( tickvals=tickV, ticktext=tickT, nticks=8 ) ) plotdata = [trace3D] plotlayout = layout3D return plotdata, plotlayout end