diff --git a/app.jl b/app.jl index 77f9cc9..1d6ff84 100644 --- a/app.jl +++ b/app.jl @@ -1,3 +1,5 @@ +# app.jl + module App # ==Packages == using GenieFramework # Set up Genie development environment. @@ -6,7 +8,6 @@ using Libz using PlotlyBase using CairoMakie using Colors -# using julia_mzML_imzML using MSI_src # Import the new MSIData library using Statistics using NaturalSort @@ -20,9 +21,50 @@ using JSON using Dates # Bring MSIData into App module's scope -using .MSI_src: MSIData, OpenMSIData, #=GetSpectrum,=# process_spectrum, IterateSpectra, ImzMLSource, _iterate_spectra_fast, MzMLSource, find_mass, ViridisPalette, get_mz_slice, get_multiple_mz_slices, quantize_intensity, save_bitmap, median_filter, save_bitmap, downsample_spectrum, TrIQ, precompute_analytics, ImportMzmlFile +using .MSI_src: MSIData, OpenMSIData, process_spectrum, IterateSpectra, ImzMLSource, _iterate_spectra_fast, MzMLSource, find_mass, ViridisPalette, get_mz_slice, get_multiple_mz_slices, quantize_intensity, save_bitmap, median_filter, save_bitmap, downsample_spectrum, TrIQ, precompute_analytics, ImportMzmlFile, generate_colorbar_image, load_and_prepare_mask -include("./julia_imzML_visual.jl") +if !@isdefined(increment_image) + include("./julia_imzML_visual.jl") +end + +# --- Memory Validation Logging --- +if get(ENV, "GENIE_ENV", "dev") != "prod" + function get_rss_mb() + if !Sys.islinux() + return 0.0 + end + try + pid = getpid() + cmd = `ps -p $pid -o rss=` + rss_kb_str = read(cmd, String) + rss_kb = parse(Int, strip(rss_kb_str)) + return round(rss_kb / 1024, digits=2) + catch e + @warn "Could not get RSS via `ps` command. Error: $e" + return 0.0 + end + end + + function log_memory_usage(context::String, msi_data_val) + rss_mb = get_rss_mb() + + msi_data_size_mb = 0 + if msi_data_val !== nothing + msi_data_size_mb = round(Base.summarysize(msi_data_val) / (1024^2), digits=2) + end + + gc_time_s = round(GC.time(), digits=3) + + println("--- MEMORY LOG [$(context)] ---") + println(" Timestamp: $(now())") + println(" Process RSS: $(rss_mb) MB") + println(" msi_data size: $(msi_data_size_mb) MB") + println(" Cumulative GC time: $(gc_time_s) s") + println("--------------------------") + end +else + log_memory_usage(context::String, msi_data_val) = nothing # No-op for production +end @genietools @@ -47,6 +89,7 @@ include("./julia_imzML_visual.jl") @in triqEnabled=false @in SpectraEnabled=false @in MFilterEnabled=false + @in maskEnabled=false # Dialogs @in warning_msg=false @in CompareDialog=false @@ -160,6 +203,9 @@ include("./julia_imzML_visual.jl") @out msg_conversion = "" @out btnConvertDisable = true + # == Pre Processing Variables == + @in pre_tab = "stabilization" + # == Batch Summary Dialog == @in showBatchSummary = false @out batch_summary = "" @@ -271,7 +317,7 @@ include("./julia_imzML_visual.jl") margin=attr(l=0,r=0,t=120,b=0,pad=0) ) # Dummy 2D scatter plot - traceSpectra=PlotlyBase.scatter(x=Vector{Float64}(), y=Vector{Float64}(),marker=attr(size=1, color="blue", opacity=0.1)) + traceSpectra=PlotlyBase.stem(x=Vector{Float64}(), y=Vector{Float64}(),marker=attr(size=1, color="blue", opacity=0.1)) # Create conection to frontend @out plotdata=[traceSpectra] @out plotlayout=layoutSpectra @@ -384,6 +430,7 @@ include("./julia_imzML_visual.jl") imgWidth, imgHeight = dims[1], dims[2] msi_data = nothing # Ensure data is not held in memory + log_memory_usage("Fast Load (msi_data cleared)", msi_data) btnMetadataDisable = false btnStartDisable = false btnPlotDisable = false @@ -443,6 +490,7 @@ include("./julia_imzML_visual.jl") selected_folder_main = dataset_name msi_data = loaded_data + log_memory_usage("Full Load", msi_data) eTime = round(time() - sTime, digits=3) msg = "Active file loaded in $(eTime) seconds. Dataset '$(dataset_name)' is ready for analysis." @@ -612,7 +660,7 @@ include("./julia_imzML_visual.jl") overall_progress = 0.0 progress_message = "Preparing batch process..." - # --- CAPTURE CURRENT VALUES HERE (NO []) --- + # --- CAPTURE CURRENT VALUES HERE --- current_selected_files = selected_files current_nmass = Nmass current_tol = Tol @@ -620,6 +668,7 @@ include("./julia_imzML_visual.jl") current_triq_enabled = triqEnabled current_triq_prob = triqProb current_mfilter_enabled = MFilterEnabled + current_mask_enabled = maskEnabled current_registry_path = registry_path println("starting main process with $(length(current_selected_files)) files") @@ -635,62 +684,33 @@ include("./julia_imzML_visual.jl") return end - println("Nmass value: '$current_nmass'") - println("Type of current_nmass: $(typeof(current_nmass))") - - masses_str = split(current_nmass, ',', keepempty=false) - println("Parsed masses strings: $masses_str") - - masses = Float64[] + masses = Float64[] try - masses = [parse(Float64, strip(m)) for m in masses_str] - println("Parsed masses: $masses") + masses = [parse(Float64, strip(m)) for m in split(current_nmass, ',', keepempty=false)] catch e progress_message = "Invalid m/z value(s). Please provide a comma-separated list of numbers. Error: $e" warning_msg = true - println(progress_message) return end - println("Masses array: $masses, type: $(typeof(masses))") - - if !(masses isa AbstractArray) || isempty(masses) + if isempty(masses) progress_message = "No valid m/z values found. Please provide comma-separated positive numbers." warning_msg = true - println(progress_message) return end - # Check other parameters - if !(0 < current_tol <= 1) - progress_message = "Tolerance must be between 0 and 1." - warning_msg = true - println(progress_message) - return - end - - if !(1 < current_color_level < 257) - progress_message = "Color levels must be between 2 and 256." - warning_msg = true - println(progress_message) - return - end - - println("entering batch processing loop with masses: $masses") - # --- 2. Batch Processing Loop --- num_files = length(current_selected_files) total_steps = num_files current_step = 0 errors = Dict("load_errors" => String[], "slice_errors" => String[], "io_errors" => String[]) newly_created_folders = String[] + files_without_mask = 0 for (file_idx, file_path) in enumerate(current_selected_files) - # Update progress for current file progress_message = "Processing file $file_idx/$num_files: $(basename(file_path))" overall_progress = current_step / total_steps - # Create parameters for this specific file all_params = ( tolerance = current_tol, colorL = current_color_level, @@ -702,14 +722,12 @@ include("./julia_imzML_visual.jl") nFiles = num_files ) - # Process the file - success, error_msg = process_file_safely(file_path, masses, all_params, progress_message, overall_progress) + success, error_msg = process_file_safely(file_path, masses, all_params, progress_message, overall_progress, use_mask=current_mask_enabled) if !success push!(errors["load_errors"], error_msg) else - dataset_name = replace(basename(file_path), r"\.imzML$"i => "") - push!(newly_created_folders, dataset_name) + push!(newly_created_folders, replace(basename(file_path), r"\.imzML$"i => "")) end current_step += 1 end @@ -717,7 +735,6 @@ include("./julia_imzML_visual.jl") # --- 3. Final Report --- total_time_end = round(time() - total_time_start, digits=3) - # Update folder lists in UI registry = load_registry(current_registry_path) all_folders = sort(collect(keys(registry)), lt=natural) img_folders = filter(folder -> get(get(registry, folder, Dict()), "is_imzML", false), all_folders) @@ -738,8 +755,11 @@ include("./julia_imzML_visual.jl") warning_msg = true end + mask_summary = current_mask_enabled ? "\nFiles processed without a mask: $(files_without_mask)" : "" + batch_summary = """ Processed $(successful_files)/$(num_files) files successfully. + $(mask_summary) Errors by category: • Load failures: $(length(errors["load_errors"])) @@ -751,6 +771,56 @@ include("./julia_imzML_visual.jl") """ showBatchSummary = true + # Update UI to display the last generated image + if !isempty(newly_created_folders) + timestamp = string(time_ns()) + folder_path = joinpath("public", selected_folder_main) + + if current_triq_enabled + triq_files = filter(filename -> startswith(filename, "TrIQ_") && endswith(filename, ".bmp"), readdir(folder_path)) + col_triq_files = filter(filename -> startswith(filename, "colorbar_TrIQ_") && endswith(filename, ".png"), readdir(folder_path)) + + if !isempty(triq_files) + latest_triq = triq_files[argmax([mtime(joinpath(folder_path, f)) for f in triq_files])] + current_triq = latest_triq + imgIntT = "/$(selected_folder_main)/$(current_triq)?t=$(timestamp)" + plotdataImgT, plotlayoutImgT, _, _ = loadImgPlot(imgIntT) + text_nmass = replace(current_triq, r"TrIQ_|.bmp" => "") + msgtriq = "TrIQ m/z: $(replace(text_nmass, "_" => "."))" + + if !isempty(col_triq_files) + latest_col_triq = col_triq_files[argmax([mtime(joinpath(folder_path, f)) for f in col_triq_files])] + current_col_triq = latest_col_triq + colorbarT = "/$(selected_folder_main)/$(current_col_triq)?t=$(timestamp)" + else + colorbarT = "" + end + selectedTab = "tab1" + end + else # Not TrIQ enabled, display regular MSI image + msi_files = filter(filename -> startswith(filename, "MSI_") && endswith(filename, ".bmp"), readdir(folder_path)) + col_msi_files = filter(filename -> startswith(filename, "colorbar_MSI_") && endswith(filename, ".png"), readdir(folder_path)) + + if !isempty(msi_files) + latest_msi = msi_files[argmax([mtime(joinpath(folder_path, f)) for f in msi_files])] + current_msi = latest_msi + imgInt = "/$(selected_folder_main)/$(current_msi)?t=$(timestamp)" + plotdataImg, plotlayoutImg, _, _ = loadImgPlot(imgInt) + text_nmass = replace(current_msi, r"MSI_|.bmp" => "") + msgimg = "m/z: $(replace(text_nmass, "_" => "."))" + + if !isempty(col_msi_files) + latest_col_msi = col_msi_files[argmax([mtime(joinpath(folder_path, f)) for f in col_msi_files])] + current_col_msi = latest_col_msi + colorbar = "/$(selected_folder_main)/$(current_col_msi)?t=$(timestamp)" + else + colorbar = "" + end + selectedTab = "tab0" + end + end + end + catch e println("Error in main process: $e") msg = "Batch processing failed: $e" @@ -766,6 +836,10 @@ include("./julia_imzML_visual.jl") SpectraEnabled = true overall_progress = 0.0 println("Done") + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end end end end @@ -786,7 +860,9 @@ include("./julia_imzML_visual.jl") try sTime = time() registry = load_registry(registry_path) - target_path = registry[selected_folder_main]["source_path"] + entry = registry[selected_folder_main] + target_path = entry["source_path"] + if target_path == "unknown (manually added)" msg = "Dataset selected contained no route." warning_msg = true @@ -797,22 +873,29 @@ include("./julia_imzML_visual.jl") msg = "Reloading $(basename(target_path)) for analysis..." full_route = target_path msi_data = OpenMSIData(target_path) - - existing_entry = get(registry, selected_folder_main, nothing) - if existing_entry !== nothing && haskey(get(existing_entry, "metadata", Dict()), "global_min_mz") && existing_entry["metadata"]["global_min_mz"] !== nothing - println("Injecting cached m/z range to skip Pass 1...") - msi_data.global_min_mz = existing_entry["metadata"]["global_min_mz"] - msi_data.global_max_mz = existing_entry["metadata"]["global_max_mz"] + if haskey(get(entry, "metadata", Dict()), "global_min_mz") && entry["metadata"]["global_min_mz"] !== nothing + msi_data.global_min_mz = entry["metadata"]["global_min_mz"] + msi_data.global_max_mz = entry["metadata"]["global_max_mz"] else precompute_analytics(msi_data) end end - plotdata, plotlayout, xSpectraMz, ySpectraMz = meanSpectrumPlot(msi_data, selected_folder_main) + local mask_path_for_plot::Union{String, Nothing} = nothing + if maskEnabled && get(entry, "has_mask", false) + mask_path_for_plot = get(entry, "mask_path", "") + if !isfile(mask_path_for_plot) + @warn "Mask not found for plotting: $(mask_path_for_plot). Plotting without mask." + mask_path_for_plot = nothing + end + end + + plotdata, plotlayout, xSpectraMz, ySpectraMz = meanSpectrumPlot(msi_data, selected_folder_main, mask_path=mask_path_for_plot) selectedTab = "tab2" fTime = time() eTime = round(fTime - sTime, digits=3) msg = "Plot loaded in $(eTime) seconds" + log_memory_usage("Mean Plot Generated", msi_data) catch e msg = "Could not generate mean spectrum plot: $e" warning_msg = true @@ -822,6 +905,10 @@ include("./julia_imzML_visual.jl") btnPlotDisable = false btnSpectraDisable = false btnStartDisable = false + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end end end end @@ -842,7 +929,9 @@ include("./julia_imzML_visual.jl") try sTime = time() registry = load_registry(registry_path) - target_path = registry[selected_folder_main]["source_path"] + entry = registry[selected_folder_main] + target_path = entry["source_path"] + if target_path == "unknown (manually added)" msg = "Dataset selected contained no route." warning_msg = true @@ -853,22 +942,29 @@ include("./julia_imzML_visual.jl") msg = "Reloading $(basename(target_path)) for analysis..." full_route = target_path msi_data = OpenMSIData(target_path) - - existing_entry = get(registry, selected_folder_main, nothing) - if existing_entry !== nothing && haskey(get(existing_entry, "metadata", Dict()), "global_min_mz") && existing_entry["metadata"]["global_min_mz"] !== nothing - println("Injecting cached m/z range to skip Pass 1...") - msi_data.global_min_mz = existing_entry["metadata"]["global_min_mz"] - msi_data.global_max_mz = existing_entry["metadata"]["global_max_mz"] + if haskey(get(entry, "metadata", Dict()), "global_min_mz") && entry["metadata"]["global_min_mz"] !== nothing + msi_data.global_min_mz = entry["metadata"]["global_min_mz"] + msi_data.global_max_mz = entry["metadata"]["global_max_mz"] else precompute_analytics(msi_data) end end - plotdata, plotlayout, xSpectraMz, ySpectraMz = sumSpectrumPlot(msi_data, selected_folder_main) + local mask_path_for_plot::Union{String, Nothing} = nothing + if maskEnabled && get(entry, "has_mask", false) + mask_path_for_plot = get(entry, "mask_path", "") + if !isfile(mask_path_for_plot) + @warn "Mask not found for plotting: $(mask_path_for_plot). Plotting without mask." + mask_path_for_plot = nothing + end + end + + plotdata, plotlayout, xSpectraMz, ySpectraMz = sumSpectrumPlot(msi_data, selected_folder_main, mask_path=mask_path_for_plot) selectedTab = "tab2" fTime = time() eTime = round(fTime - sTime, digits=3) msg = "Total plot loaded in $(eTime) seconds" + log_memory_usage("Sum Plot Generated", msi_data) catch e msg = "Could not generate total spectrum plot: $e" warning_msg = true @@ -878,6 +974,10 @@ include("./julia_imzML_visual.jl") btnPlotDisable = false btnSpectraDisable = false btnStartDisable = false + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end end end end @@ -899,7 +999,17 @@ include("./julia_imzML_visual.jl") try sTime = time() registry = load_registry(registry_path) - target_path = registry[selected_folder_main]["source_path"] + + # Add error handling for registry access + if !haskey(registry, selected_folder_main) + msg = "Dataset '$selected_folder_main' not found in registry." + warning_msg = true + return + end + + entry = registry[selected_folder_main] + target_path = entry["source_path"] + if target_path == "unknown (manually added)" msg = "Dataset selected contained no route." warning_msg = true @@ -910,25 +1020,62 @@ include("./julia_imzML_visual.jl") msg = "Reloading $(basename(target_path)) for analysis..." full_route = target_path msi_data = OpenMSIData(target_path) - - existing_entry = get(registry, selected_folder_main, nothing) - if existing_entry !== nothing && haskey(get(existing_entry, "metadata", Dict()), "global_min_mz") && existing_entry["metadata"]["global_min_mz"] !== nothing - println("Injecting cached m/z range to skip Pass 1...") - msi_data.global_min_mz = existing_entry["metadata"]["global_min_mz"] - msi_data.global_max_mz = existing_entry["metadata"]["global_max_mz"] + if haskey(get(entry, "metadata", Dict()), "global_min_mz") && entry["metadata"]["global_min_mz"] !== nothing + msi_data.global_min_mz = entry["metadata"]["global_min_mz"] + msi_data.global_max_mz = entry["metadata"]["global_max_mz"] else precompute_analytics(msi_data) end end - y = yCoord < 0 ? abs(yCoord) : yCoord - plotdata, plotlayout, xSpectraMz, ySpectraMz = xySpectrumPlot(msi_data, xCoord, y, imgWidth, imgHeight, selected_folder_main) - xCoord = plotlayout.title == "Spectrum #$(xCoord)" ? xCoord : clamp(xCoord, 1, imgWidth) - yCoord = plotlayout.title == "Spectrum #$(xCoord)" ? 0 : -clamp(y, 1, imgHeight) + local mask_path_for_plot::Union{String, Nothing} = nothing + if maskEnabled && get(entry, "has_mask", false) + mask_path_for_plot = get(entry, "mask_path", "") + if !isfile(mask_path_for_plot) + @warn "Mask not found for plotting: $(mask_path_for_plot). Plotting without mask." + mask_path_for_plot = nothing + end + end + + # Convert to positive coordinates for processing + y_positive = yCoord < 0 ? abs(yCoord) : yCoord + plotdata, plotlayout, xSpectraMz, ySpectraMz = xySpectrumPlot(msi_data, xCoord, y_positive, imgWidth, imgHeight, selected_folder_main, mask_path=mask_path_for_plot) + + # Update coordinates based on actual plot title + # Extract title text from the Dict safely + actual_title = if plotlayout.title isa Dict && haskey(plotlayout.title, :text) + plotlayout.title[:text] + elseif plotlayout.title isa Dict && haskey(plotlayout.title, "text") + plotlayout.title["text"] + else + string(plotlayout.title) # Fallback + end + + if occursin("Masked Spectrum at", actual_title) + # Extract coordinates from masked spectrum title + coords_match = match(r"Masked Spectrum at \((\d+), (\d+)\)", actual_title) + if coords_match !== nothing + xCoord = parse(Int, coords_match.captures[1]) + yCoord = -parse(Int, coords_match.captures[2]) # Negative for display + end + elseif occursin("Spectrum at", actual_title) + # Extract coordinates from regular spectrum title + coords_match = match(r"Spectrum at \((\d+), (\d+)\)", actual_title) + if coords_match !== nothing + xCoord = parse(Int, coords_match.captures[1]) + yCoord = -parse(Int, coords_match.captures[2]) # Negative for display + end + else + # For non-imaging data or fallback, just clamp the coordinates + xCoord = clamp(xCoord, 1, imgWidth) + yCoord = yCoord < 0 ? yCoord : -clamp(yCoord, 1, imgHeight) + end + selectedTab = "tab2" fTime = time() eTime = round(fTime - sTime, digits=3) msg = "Plot loaded in $(eTime) seconds" + log_memory_usage("XY Plot Generated", msi_data) catch e msg = "Could not retrieve spectrum: $e" warning_msg = true @@ -938,6 +1085,10 @@ include("./julia_imzML_visual.jl") btnPlotDisable = false btnSpectraDisable = false btnStartDisable = false + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end end end end @@ -1213,6 +1364,13 @@ include("./julia_imzML_visual.jl") # This handler will now correctly load the first image from the newly selected folder. @onchange selected_folder_main begin + msi_data = nothing + log_memory_usage("Folder Changed (msi_data cleared)", msi_data) + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) + end + if !isempty(selected_folder_main) folder_path = joinpath("public", selected_folder_main) if !isdir(folder_path) @@ -1226,6 +1384,7 @@ include("./julia_imzML_visual.jl") plotlayoutImg = layoutImg plotdataImgT = [traceImg] plotlayoutImgT = layoutImg + imgWidth, imgHeight = 0, 0 return end @@ -1236,7 +1395,8 @@ include("./julia_imzML_visual.jl") if !isempty(msi_bmp) current_msi = first(msi_bmp) imgInt = "/$(selected_folder_main)/$(current_msi)" - plotdataImg, plotlayoutImg, _, _ = loadImgPlot(imgInt) + plotdataImg, plotlayoutImg, w, h = loadImgPlot(imgInt) + imgWidth, imgHeight = w, h text_nmass = replace(current_msi, r"MSI_|.bmp" => "") msgimg = "m/z: $(replace(text_nmass, "_" => "."))" if !isempty(col_msi_png) @@ -1260,7 +1420,11 @@ include("./julia_imzML_visual.jl") if !isempty(triq_bmp) current_triq = first(triq_bmp) imgIntT = "/$(selected_folder_main)/$(current_triq)" - plotdataImgT, plotlayoutImgT, _, _ = loadImgPlot(imgIntT) + plotdataImgT, plotlayoutImgT, w, h = loadImgPlot(imgIntT) + # If no MSI image was loaded, dimensions from TrIQ image are used. + if isempty(msi_bmp) + imgWidth, imgHeight = w, h + end text_nmass = replace(current_triq, r"TrIQ_|.bmp" => "") msgtriq = "TrIQ m/z: $(replace(text_nmass, "_" => "."))" if !isempty(col_triq_png) @@ -1276,6 +1440,10 @@ include("./julia_imzML_visual.jl") plotdataImgT = [traceImg] plotlayoutImgT = layoutImg end + + if isempty(msi_bmp) && isempty(triq_bmp) + imgWidth, imgHeight = 0, 0 + end end end @@ -1391,90 +1559,130 @@ include("./julia_imzML_visual.jl") # 3d plot @onbutton image3dPlot begin - msg="Image 3D plot selected" - cleaned_imgInt=replace(imgInt, r"\?.*" => "") - cleaned_imgInt=lstrip(cleaned_imgInt, '/') - var=joinpath( "./public", cleaned_imgInt ) + msg = "Image 3D plot selected" + cleaned_imgInt = replace(imgInt, r"\?.*" => "") + cleaned_imgInt = lstrip(cleaned_imgInt, '/') + var = joinpath("./public", cleaned_imgInt) if !isfile(var) - msg="Image could not be 3d plotted" - warning_msg=true + msg = "Image could not be 3d plotted" + warning_msg = true return end - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true - + 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() - ccall(:malloc_trim, Int32, (Int32,), 0) # Ensure julia returns the freed memory to OS + # --- Get Mask Path --- + local mask_path_for_plot::Union{String, Nothing} = nothing + if maskEnabled && !isempty(selected_folder_main) + registry = load_registry(registry_path) + entry = get(registry, selected_folder_main, nothing) + if entry !== nothing && get(entry, "has_mask", false) + mask_path_candidate = get(entry, "mask_path", "") + if isfile(mask_path_candidate) + mask_path_for_plot = mask_path_candidate + else + @warn "Mask enabled but file not found: $(mask_path_candidate). Plotting without mask." + end + end end - selectedTab="tab4" - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" - catch e - msg="Failed to load and process image: $e" - warning_msg=true + # --- + + sTime = time() + if mask_path_for_plot !== nothing + plotdata3d, plotlayout3d = loadSurfacePlot(imgInt, mask_path_for_plot) + else + plotdata3d, plotlayout3d = loadSurfacePlot(imgInt) + end + + selectedTab = "tab4" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Plot loaded in $(eTime) seconds" + log_memory_usage("Mean Plot Generated", msi_data) + catch e + msg = "Failed to load and process image: $e" + warning_msg = true + @error "3D plot generation failed" exception=(e, catch_backtrace()) finally progressPlot=false btnPlotDisable=false btnStartDisable=false - if msi_data !== nothing - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + btnSpectraDisable=false + SpectraEnabled=true + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) end end end - end # 3d plot for TrIQ + end @onbutton triq3dPlot begin - msg="TrIQ 3D plot selected" - cleaned_imgIntT=replace(imgIntT, r"\?.*" => "") - cleaned_imgIntT=lstrip(cleaned_imgIntT, '/') - var=joinpath( "./public", cleaned_imgIntT ) + msg = "TrIQ 3D plot selected" + cleaned_imgIntT = replace(imgIntT, r"\?.*" => "") + cleaned_imgIntT = lstrip(cleaned_imgIntT, '/') + var = joinpath("./public", cleaned_imgIntT) if !isfile(var) - msg="Image could not be 3d plotted" - warning_msg=true + msg = "Image could not be 3d plotted" + warning_msg = true return end - progressPlot=true - btnPlotDisable=true - btnStartDisable=true - btnSpectraDisable=true - + 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() - ccall(:malloc_trim, Int32, (Int32,), 0) # Ensure julia returns the freed memory to OS + # --- Get Mask Path --- + local mask_path_for_plot::Union{String, Nothing} = nothing + if maskEnabled[] && !isempty(selected_folder_main) + registry = load_registry(registry_path) + entry = get(registry, selected_folder_main, nothing) + if entry !== nothing && get(entry, "has_mask", false) + mask_path_candidate = get(entry, "mask_path", "") + if isfile(mask_path_candidate) + mask_path_for_plot = mask_path_candidate + else + @warn "Mask enabled but file not found: $(mask_path_candidate). Plotting without mask." + end + end end - selectedTab="tab4" - fTime=time() - eTime=round(fTime-sTime,digits=3) - msg="Plot loaded in $(eTime) seconds" - catch e - msg="Failed to load and process image: $e" - warning_msg=true + # --- + + sTime = time() + if mask_path_for_plot !== nothing + plotdata3d, plotlayout3d = loadSurfacePlot(imgIntT, mask_path_for_plot) + else + plotdata3d, plotlayout3d = loadSurfacePlot(imgIntT) + end + + selectedTab = "tab4" + fTime = time() + eTime = round(fTime - sTime, digits=3) + msg = "Plot loaded in $(eTime) seconds" + log_memory_usage("Mean Plot Generated", msi_data) + catch e + msg = "Failed to load and process image: $e" + warning_msg = true + @error "3D TrIQ plot generation failed" exception=(e, catch_backtrace()) finally progressPlot=false btnPlotDisable=false btnStartDisable=false - if msi_data !== nothing - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + btnSpectraDisable=false + SpectraEnabled=true + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) end end end @@ -1517,10 +1725,11 @@ include("./julia_imzML_visual.jl") progressPlot=false btnPlotDisable=false btnStartDisable=false - if msi_data !== nothing - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + btnSpectraDisable=false + SpectraEnabled=true + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) end end end @@ -1562,10 +1771,11 @@ include("./julia_imzML_visual.jl") progressPlot=false btnPlotDisable=false btnStartDisable=false - if msi_data !== nothing - # We enable coord search and spectra plot creation - btnSpectraDisable=false - SpectraEnabled=true + btnSpectraDisable=false + SpectraEnabled=true + GC.gc() + if Sys.islinux() + ccall(:malloc_trim, Int32, (Int32,), 0) end end end @@ -1579,7 +1789,7 @@ include("./julia_imzML_visual.jl") @onchange Nmass begin if !isempty(xSpectraMz) # Main spectrum trace - traceSpectra = PlotlyBase.scatter( + traceSpectra = PlotlyBase.stem( x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), @@ -1651,13 +1861,13 @@ include("./julia_imzML_visual.jl") @onchange xCoord, yCoord begin if selectedTab == "tab1" - plotdataImgT = filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y"]), plotdataImgT) + main_trace = plotdataImgT[1] # The heatmap/image trace trace1, trace2 = crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) - plotdataImgT = append!(plotdataImgT, [trace1, trace2]) + plotdataImgT = [main_trace, trace1, trace2] # Fresh array every time elseif selectedTab == "tab0" - plotdataImg = filter(trace -> !(get(trace, :name, "") in ["Line X", "Line Y", "Optical"]), plotdataImg) + main_trace = plotdataImg[1] # The heatmap/image trace trace1, trace2 = crossLinesPlot(xCoord, yCoord, imgWidth, -imgHeight) - plotdataImg = append!(plotdataImg, [trace1, trace2]) + plotdataImg = [main_trace, trace1, trace2] end end @@ -1769,6 +1979,7 @@ include("./julia_imzML_visual.jl") end end end + log_memory_usage("App Ready", msi_data) warmup_init() end diff --git a/app.jl.html b/app.jl.html index e679cf7..1aea080 100644 --- a/app.jl.html +++ b/app.jl.html @@ -1,290 +1,133 @@ -
-
- -
- - - - - - - -
imzML & mzML Data Processor
-

Please make sure the ibd and imzML file are located in the same directory and have the same name. -
It may take a while to generate the slice / spectrum, please be patient. -
To generate the contour or surface plots, you have to select the desired slice first using the interface.

-
- - + + + +
+
+
+ + + + + + + + + + + + +

HI!

+
+
+
+ +
imzML & mzML Data Processor
+

Please make sure the ibd and imzML file are located in the same directory and have the same name. +
It may take a while to generate the slice / spectrum, please be patient. +
To generate the contour or surface plots, you have to select the desired slice first using the + interface. +

+
+ + + + + +
+ + + + {{ file }} + + + + + + - -
-
- +
+ - -
-
- -
-
- -
+
-
- -
-
- - -
-
-
- + +
+
+ +
+
+
+ +
+
+ + + +
+
+
+ -
-
-
- -
-
- - - - - - - Mean spectrum plot - - - - - Sum Spectrum plot - - - - - Spectrum plot (X,Y) - - - - -
-
-
- -
-
- -
-
- - - - - - - -
- -
{{ progress_message }}
-
-
-

{{msg}}

-
- - - - - - - Image topography Plot - - - - - - TrIQ topography Plot - - - - - - Image surface Plot - - - - - - TrIQ Surface Plot - - - - - - - - Over normal image - - - - - Over TrIQ image - - - -
- - Transparency: {{ imgTrans }} -
-
- - -
mzML to imzML Converter
-

Select the .mzML file and the corresponding .txt synchronization file to convert them into an .imzML/.ibd pair.

- - - - - - - - - - - - -

{{msg_conversion}}

-
- -
-
-
- -
- - - - -
Image visualizer
-
- - - - -
- -
-
- -
-
- -
-
-

-
- - - -
TrIQ visualizer
-
- - - - -
- -
-
- -
-
- -
-
-

-
- - -
- + +
+
- + @@ -303,237 +146,444 @@ +
+
+
+ +
+
+ +
+
- - +
+
+ + + + + + + +
+ +
{{ progress_message }}
+
+
+

{{msg}}

+
+ + - - - - - - - - - -
+ + + + Image topography Plot + + + + + + TrIQ topography Plot + + + + + + Image surface Plot + + + + + + TrIQ Surface Plot + + + + + + + + Over normal image + + + + + Over TrIQ image + + + +
+ + Transparency: {{ imgTrans }} +
+
+ + +
mzML to imzML Converter
+

Select the .mzML file and the corresponding .txt synchronization file to convert them into an .imzML/.ibd + pair.

+ + + + + + + + + + + + +

{{msg_conversion}}

+
+
- - - - -
Warning
-
- - - {{msg}} - - - - - -
-
- - - - -
Compare two different images or plots
-
- - Transparency: {{ imgTrans }} -
- -
- - -
-
-
- - - -
- - - - -
- - -
- -
-
- - -
-
- -
-
-

-
- - - - -
- - -
- -
-
- - -
-
- -
-
-

-
- - - - - - - - - - - - -
+
+ +
+ + + + +
Image visualizer
+
+ + + +
-
-
- - - + +
+
+ +
+
+
- - - - -
- - -
- -
-
- - -
-
- -
-
-

-
- - - - -
- - -
- -
-
- - -
-
- -
-
-

-
- - - - - - - - - - - - -
-
- +

+ - - - - - + + +
TrIQ visualizer
+
+ + + + +
+ +
+
+ +
+
+ +
+
+

+
- - - -
Batch Process Summary
-
+ +
+ + + - -
{{ batch_summary }}
-
- - - - - - - - - - -
Dataset Summary
- - -
- - - - - - {{ row.parameter }} - - - {{ row.value }} - + + + + Mean spectrum plot + - -
- -

No metadata found in registry for this dataset.

-

You may need to process the file first.

-
-
+ + + Sum Spectrum plot + + + + + Spectrum plot (X,Y) + + + +
+
+ +
- - - -
-
\ No newline at end of file + + + + + + + + + +
+
+
+ + + + +
Warning
+
+ + + {{msg}} + + + + + +
+
+ + + + +
Compare two different images or plots
+
+ + Transparency: {{ imgTrans }} +
+ +
+ + +
+
+
+ + + +
+ + + + +
+ + +
+ +
+
+ + +
+
+ +
+
+

+
+ + + + +
+ + +
+ +
+
+ + +
+
+ +
+
+

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + +
+ + + + +
+ + +
+ +
+
+ + +
+
+ +
+
+

+
+ + + + +
+ + +
+ +
+
+ + +
+
+ +
+
+

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + +
+
+ + + + +
Batch Process Summary
+
+ + +
{{ batch_summary }}
+
+ + + + +
+
+ + + + +
Dataset Summary
+ + +
+ + + + + + {{ row.parameter }} + + + {{ row.value }} + + + +
+ +

No metadata found in registry for this dataset.

+

You may need to process the file first.

+
+
+ + + + +
+
\ No newline at end of file diff --git a/julia_imzML_visual.jl b/julia_imzML_visual.jl index aff0afa..803af8d 100644 --- a/julia_imzML_visual.jl +++ b/julia_imzML_visual.jl @@ -1,32 +1,67 @@ -# == 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 +# julia_imzML_visual.jl + +""" + 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[length(image_list)] # Return the current image if it's the last one or not found + 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 current image if it's the first one or not found + 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 -## Plot Image functions -# Downsample an image matrix to a maximum dimension while preserving aspect ratio +""" + 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 @@ -46,26 +81,37 @@ function downsample_image(img_matrix, max_dim::Int) return imresize(img_matrix, (new_h, new_w)) end -# loadImgPlot recieves the local directory of the image as a string, -# returns the layout and data for the heatmap plotly plot -# this function loads the image into a plot +""" + 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) + 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 + 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=collect(1:width) - Y=collect(1:height) + height, width = size(img_array) + X = 1:width + Y = 1:height # Create the layout - layout=PlotlyBase.Layout( + layout = PlotlyBase.Layout( title=PlotlyBase.attr( text="", font=PlotlyBase.attr( @@ -83,11 +129,11 @@ function loadImgPlot(interfaceImg::String) visible=false, range=[-height, 0] ), - margin=attr(l=0,r=0,t=0,b=0,pad=0) + margin=attr(l=0, r=0, t=0, b=0, pad=0) ) # Create the trace for the image - trace=PlotlyBase.heatmap( + trace = PlotlyBase.heatmap( z=elevation, x=X, y=-Y, @@ -113,16 +159,29 @@ function loadImgPlot(interfaceImg::String) ) ) - plotdata=[trace] - plotlayout=layout + plotdata = [trace] + 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 +""" + 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()) + timestamp = string(time_ns()) # Load the main image cleaned_img = replace(interfaceImg, r"\?.*" => "") cleaned_img = lstrip(cleaned_img, '/') @@ -134,8 +193,8 @@ function loadImgPlot(interfaceImg::String, overlayImg::String, imgTrans::Float64 elevation = Float32.(Array(img_array)) ./ 255.0 # Get the X, Y coordinates of the image height, width = size(img_array) - X = collect(1:width) - Y = collect(1:height) + X = 1:width + Y = 1:height # Create the layout with overlay image layoutImg = PlotlyBase.Layout( @@ -147,40 +206,40 @@ function loadImgPlot(interfaceImg::String, overlayImg::String, imgTrans::Float64 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 + 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] + xaxis=PlotlyBase.attr( + visible=false, + scaleanchor="y", + range=[0, width] ), - yaxis = PlotlyBase.attr( - visible = false, - range = [-height,0] + yaxis=PlotlyBase.attr( + visible=false, + range=[-height, 0] ), - margin = attr(l = 0, r = 0, t = 0, b = 0, pad = 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 + z=elevation, + x=X, + y=-Y, + name="", + hoverinfo="x+y", + showlegend=false, + colorscale="Viridis", + showscale=false ) plotdata = [trace] @@ -188,10 +247,18 @@ function loadImgPlot(interfaceImg::String, overlayImg::String, imgTrans::Float64 return plotdata, plotlayout, width, height end -# loadContourPlot recieves the local directory of the image as a string, -# returns the layout and data for the contour plotly plot -# this function loads the image and applies a gaussian filter -# to smoothen it and loads it into a plot +""" + 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"\?.*" => "") @@ -258,10 +325,18 @@ function loadContourPlot(interfaceImg::String) 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 -# to smoothen it and loads it into a 3D plot +""" + 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"\?.*" => "") @@ -347,28 +422,51 @@ function loadSurfacePlot(interfaceImg::String) 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 +""" + 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] + 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) + 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 -# This function is used for giving colorbar values a visual format -# that shortens long values giving them scientific notation +""" + 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)) + 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 @@ -382,78 +480,36 @@ function log_tick_formatter(values::Vector{Float64}) exponents[i] -= 1 end end - formValues[i]=value + 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 -function generate_colorbar_image(slice_data::AbstractMatrix, color_levels::Int, output_path::String; use_triq::Bool=false, triq_prob::Float64=0.98) - # 1. Determine bounds based on whether TrIQ is used - min_val, max_val = if use_triq - MSI_src.get_outlier_thres(slice_data, triq_prob) +""" + 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 - extrema(slice_data) - end - - # 2. Replicate the tick calculation logic from plot_slices - bins = color_levels - levels = range(min_val, stop=max_val, length=bins + 1) - level_range = levels[end] - levels[1] - - if level_range == 0 - levels = range(min_val - 0.1, stop=max_val + 0.1, length=bins + 1) - level_range = 0.2 + "Average Spectrum" end - exponent = level_range > 0 ? floor(log10(level_range)) / 3 : 0 - scale = 10^(3 * exponent) - scaled_levels = levels ./ scale + title_text = isempty(dataset_name) ? base_title : "$base_title for: $dataset_name" - format_num = level_range > 0 ? floor(log10(level_range)) % 3 : 0 - labels = if format_num == 0 - [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] - elseif format_num == 1 - [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] - else - [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] - end - - divisors = 2:7 - remainders = (bins - 1) .% divisors - best_divisor = divisors[findlast(x -> x == minimum(remainders), remainders)] - tick_indices = round.(Int, range(1, stop=bins + 1, length=best_divisor + 1)) - - if !(1 in tick_indices) - pushfirst!(tick_indices, 1) - end - if !((bins + 1) in tick_indices) - push!(tick_indices, bins + 1) - end - unique!(sort!(tick_indices)) - - tick_positions = levels[tick_indices] - tick_labels = labels[tick_indices] - - # 3. Create and save the colorbar image - fig = Figure(size=(150, 250)) - Colorbar(fig[1, 1], - colormap=cgrad(:viridis, bins, categorical=true), - # limits=(min_val, max_val), - limits=(levels[1], levels[end]), - label=(scale == 1 ? "Intensity" : "Intensity ×10^$(round(Int, 3 * exponent))"), - ticks=(tick_positions, tick_labels), - labelsize=20, - ticklabelsize=16 - ) - save(output_path, fig) -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(data::MSIData, dataset_name::String="") - title_text = isempty(dataset_name) ? "Average Spectrum Plot" : "Average Spectrum for: $dataset_name" layout = PlotlyBase.Layout( title=PlotlyBase.attr( text=title_text, @@ -477,42 +533,76 @@ function meanSpectrumPlot(data::MSIData, dataset_name::String="") ) # Use the new, efficient function from the backend - xSpectraMz, ySpectraMz = get_average_spectrum(data) + xSpectraMz, ySpectraMz = get_average_spectrum(data, mask_path=mask_path) - if isempty(xSpectraMz) + if isempty(xSpectraMz) || isempty(ySpectraMz) @warn "Average spectrum is empty." - trace = PlotlyBase.scatter(x=Float64[], y=Float64[]) + trace = PlotlyBase.stem(x=Float64[], y=Float64[]) + # Update title to indicate empty spectrum + layout.title.text = "Empty " * layout.title.text else - trace = PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), name="Average", hoverinfo="x",hovertemplate="m/z: %{x:.4f}") + 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, dataset_name::String="") +""" + 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 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) - - process_spectrum(data, Int(x), Int(y)) do recieved_mz, recieved_intensity - mz = recieved_mz - intensity = recieved_intensity + + 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 - base_title = "Spectrum at ($x, $y)" else # For non-imaging data, treat xCoord as the spectrum index index = clamp(xCoord, 1, length(data.spectra_metadata)) - - process_spectrum(data, index) do recieved_mz, recieved_intensity + + process_spectrum(data, index) do recieved_mz, recieved_intensity mz = recieved_mz intensity = recieved_intensity end @@ -542,20 +632,43 @@ function xySpectrumPlot(data::MSIData, xCoord::Int, yCoord::Int, imgWidth::Int, ), margin=attr(l=0, r=0, t=120, b=0, pad=0) ) - + # Downsample for plotting performance mz_down, int_down = MSI_src.downsample_spectrum(mz, intensity) - trace = PlotlyBase.scatter(x=mz_down, y=int_down, marker=attr(size=1, color="blue", opacity=0.5), name="Spectrum", hoverinfo="x", hovertemplate="m/z: %{x:.4f}") - + 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 plotdata, plotlayout, mz, intensity end -function sumSpectrumPlot(data::MSIData, dataset_name::String="") - title_text = isempty(dataset_name) ? "Total Spectrum Plot" : "Total Spectrum for: $dataset_name" +""" + 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, @@ -579,13 +692,15 @@ function sumSpectrumPlot(data::MSIData, dataset_name::String="") ) # Use the get_total_spectrum function from the backend - xSpectraMz, ySpectraMz = get_total_spectrum(data) + xSpectraMz, ySpectraMz, num_spectra = get_total_spectrum(data, mask_path=mask_path) - if isempty(xSpectraMz) + if isempty(xSpectraMz) || isempty(ySpectraMz) @warn "Total spectrum is empty." - trace = PlotlyBase.scatter(x=Float64[], y=Float64[]) + trace = PlotlyBase.stem(x=Float64[], y=Float64[]) + # Update title to indicate empty spectrum + layout.title.text = "Empty " * layout.title.text else - trace = PlotlyBase.scatter(x=xSpectraMz, y=ySpectraMz, marker=attr(size=1, color="blue", opacity=0.5), name="Total", hoverinfo="x",hovertemplate="m/z: %{x:.4f}") + 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 plotdata = [trace] @@ -593,30 +708,32 @@ function sumSpectrumPlot(data::MSIData, dataset_name::String="") 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...") - - # Create a dummy MSIData object to be used for pre-compilation - # dummy_source = ImzMLSource("dummy.ibd", Float32, Float32) - # dummy_meta = MSI_src.SpectrumMetadata(0,0,"",MSI_src.UNKNOWN, MSI_src.SpectrumAsset(Float32,false,0,0,:mz), MSI_src.SpectrumAsset(Float32,false,0,0,:intensity)) - # dummy_msi_data = MSIData(dummy_source, [dummy_meta], (1,1), zeros(Int,1,1), 0) - # Pre-compile functions from btnSearch - # try OpenMSIData("dummy.imzML") catch end - # try precompute_analytics(dummy_msi_data) catch end + # 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 - # Pre-compile functions from mainProcess - # try get_mz_slice(dummy_msi_data, 1.0, 1.0) catch end - 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 + try save_bitmap(dummy_bmp_path, zeros(UInt8, 10, 10), ViridisPalette) loadImgPlot("/dummy.bmp") - generate_colorbar_image(zeros(10,10), 256, dummy_png_path) + generate_colorbar_image(zeros(10, 10), 256, dummy_png_path) catch e @warn "Pre-compilation step failed (this is expected if dummy files can't be created/read)" finally @@ -628,18 +745,41 @@ function warmup_init() 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) if isfile(registry_path) try - return JSON.parsefile(registry_path, dicttype=Dict{String, Any}) + return JSON.parsefile(registry_path, dicttype=Dict{String,Any}) catch e @error "Failed to parse registry.json: $e" - return Dict{String, Any}() + return Dict{String,Any}() end end - return Dict{String, Any}() + return Dict{String,Any}() 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 @@ -671,7 +811,7 @@ function extract_metadata(msi_data::MSIData, source_path::String) 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 @@ -686,18 +826,39 @@ function extract_metadata(msi_data::MSIData, source_path::String) ) 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) registry = load_registry(registry_path) - entry = Dict{String, Any}( # Explicitly type the dictionary to allow mixed value types - "source_path" => source_path, - "processed_date" => string(now()), - "is_imzML" => is_imzML - ) + + # 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 - registry[dataset_name] = entry + # 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) @@ -707,12 +868,30 @@ function update_registry(registry_path, dataset_name, source_path, metadata=noth end end -function process_file_safely(file_path, masses, params, progress_message_ref, overall_progress_ref) +""" + 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))" @@ -722,19 +901,43 @@ function process_file_safely(file_path, masses, params, progress_message_ref, ov return (false, "Skipped: Not an imzML file") end - # --- Generate Slices (this will call precompute_analytics if needed) --- - progress_message_ref = "Generating $(length(masses)) slices for $(dataset_name)..." - slice_dict = get_multiple_mz_slices(local_msi_data, masses, params.tolerance) + # --- 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 - # --- Extract metadata *after* it has been computed --- + # --- 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) # Ensure output directory exists - + 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" @@ -744,7 +947,7 @@ function process_file_safely(file_path, masses, params, progress_message_ref, ov sliceQuant = zeros(UInt8, size(slice)) @warn "No intensity data for m/z = $mass in $(dataset_name)" else - sliceQuant = params.triqE ? TrIQ(slice, params.colorL, params.triqP) : quantize_intensity(slice, params.colorL) + sliceQuant = params.triqE ? TrIQ(slice, params.colorL, params.triqP, mask_matrix=mask_matrix_for_triq) : quantize_intensity(slice, params.colorL, mask_matrix=mask_matrix_for_triq) if params.medianF sliceQuant = round.(UInt8, median_filter(sliceQuant)) end @@ -752,10 +955,11 @@ function process_file_safely(file_path, masses, params, progress_message_ref, ov save_bitmap(joinpath(output_dir, bitmap_filename), sliceQuant, ViridisPalette) if !all(iszero, slice) - generate_colorbar_image(slice, params.colorL, joinpath(output_dir, colorbar_filename); use_triq=params.triqE, triq_prob=params.triqP) + generate_colorbar_image(slice, params.colorL, joinpath(output_dir, colorbar_filename); + 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, "") @@ -773,4 +977,150 @@ function process_file_safely(file_path, masses, params, progress_message_ref, ov ccall(:malloc_trim, Int32, (Int32,), 0) end end +end + +function update_registry_mask_fields(registry_path, dataset_name, has_mask, mask_path) + registry = load_registry(registry_path) + + 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 + +""" + 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 \ No newline at end of file diff --git a/mask.jl b/mask.jl index 349a5f2..1682805 100644 --- a/mask.jl +++ b/mask.jl @@ -9,14 +9,12 @@ using Statistics, NaturalSort, LinearAlgebra, StipplePlotly using Base.Filesystem: mv using MSI_src -using .MSI_src: MSIData +using .MSI_src: MSIData, process_image_pipeline # Plot Handling include("./julia_imzML_visual.jl") # Image Processing Pipeline -include("src/ImageProcessing.jl") -using .ImageProcessing using ImageBinarization function load_and_binarize_mask(path) @@ -46,7 +44,7 @@ function alter_image(img_path, otsu_scale, noise_size_percent, hole_size_percent gray_img = Float32.(ensure_grayscale(original_img)) binary, noise_removed, holes_filled, smoothed = - ImageProcessing.process_image_pipeline(gray_img; + process_image_pipeline(gray_img; otsu_scale=otsu_scale, noise_size_percent=noise_size_percent, hole_size_percent=hole_size_percent, smoothing=smoothing_level) @@ -250,7 +248,6 @@ end is_browsing_slices = true is_editing_mask = false folder_path = joinpath("public", selected_folder_main) - println("Selected folder: $selected_folder_main") if !isdir(folder_path) imgInt = "" msgimg = "Folder not found." @@ -674,15 +671,17 @@ end # Update registry directly in the handler reg_path = abspath(joinpath(@__DIR__, "public", "registry.json")) registry = isfile(reg_path) ? JSON.parsefile(reg_path) : Dict{String, Any}() + + full_mask_path = abspath(final_path) # Update the registry entry if haskey(registry, selected_folder_main) - registry[selected_folder_main]["mask_path"] = "/css/masks/$(final_mask_name)" + registry[selected_folder_main]["mask_path"] = full_mask_path registry[selected_folder_main]["has_mask"] = true else # Create a new entry if folder doesn't exist in registry registry[selected_folder_main] = Dict( - "mask_path" => "/css/masks/$(final_mask_name)", + "mask_path" => full_mask_path, "has_mask" => true, "is_imzML" => true, "processed_date" => string(Dates.now()) diff --git a/mask.jl.html b/mask.jl.html index 5d5492d..60d7011 100644 --- a/mask.jl.html +++ b/mask.jl.html @@ -1,72 +1,91 @@
-
- -
-
Mask Generation Workflow
- - -
Step 1: Select Slice
-
- -
-

+
+ +
+
Mask Generation Workflow
- -
Step 2: Create/Upload Mask
-
- - - -
+ +
Step 1: Select Slice
+
+ +
+

- -
Step 3: Adjust & Verify
-
-

Adjust automated processing for the initial mask:

- - - - -
-
-

Adjust slice overlay transparency:

- -
+ +
Step 2: Create/Upload Mask
+
+ + + +
-

{{ mask_editor_message }}

-
- - + +
Step 3: Adjust & Verify
+
+

Adjust automated processing for the initial mask:

+ + + + +
+
+

Adjust slice overlay transparency:

+ +
+ +

{{ mask_editor_message }}

+
+ + +
-
Mask Preview
- - + +
- +

Please select a dataset and provide a mask input to begin.

Use the controls on the left to load a slice or upload a mask image.

- +
- + +
- +

@@ -87,54 +106,59 @@
- - - - + + + + - + - - - + + + - - - + + +
-
- +
+
- +
-
- + + - -
+ }" v-on:mousedown.prevent="startDrawing" v-on:mousemove.prevent="draw" + v-on:mouseup.prevent="stopDrawing()" v-on:mouseleave.prevent="stopDrawing()"> + +
@@ -142,4 +166,4 @@ --> - + \ No newline at end of file diff --git a/src/ImageProcessing.jl b/src/ImageProcessing.jl index 2f0f8a9..d47ea6a 100644 --- a/src/ImageProcessing.jl +++ b/src/ImageProcessing.jl @@ -1,16 +1,80 @@ -module ImageProcessing +# src/ImageProcessing using Images using ImageBinarization using ImageMorphology using ImageComponentAnalysis +using Colors # For converting to grayscale export process_image_pipeline +export load_and_prepare_mask # Export the new function + +""" + load_and_prepare_mask(mask_path::String, target_dims::Tuple{Int, Int}) + +Loads a PNG image mask, converts it to a binary (Boolean) matrix, +and resizes it to the specified `target_dims`. White pixels in the mask +are considered `true` (part of the ROI), and black pixels are `false`. + +# Arguments +- `mask_path`: Absolute path to the PNG mask file. +- `target_dims`: A tuple `(width, height)` representing the desired output dimensions. + +# Returns +- A `BitMatrix` of `target_dims` where `true` indicates the ROI. +""" +function load_and_prepare_mask(mask_path::String, target_dims::Tuple{Int, Int}) + if !isfile(mask_path) + error("Mask file not found: $(mask_path)") + end + + # Load the image + img = Images.load(mask_path) + + # Convert to grayscale if it's a color image + gray_img = Gray.(img) + + # Binarize using Otsu's method - white regions become 'true' + binary_img = binarize(gray_img, Otsu()) + + # Resize to target dimensions + resized_img = imresize(binary_img, (target_dims[2], target_dims[1])) + + # Ensure the output is a BitMatrix, as imresize can change the type + return resized_img .> 0.5 +end # =================================================================== # CORE PROCESSING PIPELINE # =================================================================== +""" + process_image_pipeline(gray_img; otsu_scale=1.0, noise_size_percent=0.1, hole_size_percent=0.05, smoothing=2) + +Applies a multi-step image processing pipeline to a grayscale image to segment regions of interest. + +The pipeline consists of: +1. **Binarization**: An adjusted Otsu's threshold is used to create a binary image. +2. **Noise Removal**: Small white regions (noise) are removed using an area opening operation. +3. **Hole Filling**: Small black regions (holes) within larger objects are filled. +4. **Edge Smoothing**: The edges of the final regions are smoothed using a morphological closing operation. + +# Arguments +- `gray_img`: The input grayscale image (`Matrix{<:Gray}`). + +# Keyword Arguments +- `otsu_scale`: A factor to scale the automatically determined Otsu threshold. Values > 1.0 make the threshold stricter (less white), < 1.0 make it more lenient (more white). Default: `1.0`. +- `noise_size_percent`: The percentage of the total image area used as a threshold to remove small white noise components. Default: `0.1`. +- `hole_size_percent`: The percentage of the total image area used as a threshold to fill black holes in white components. Default: `0.05`. +- `smoothing`: The size of the kernel for the final edge smoothing (closing) operation. Default: `2`. + +# Returns +- A tuple containing four images representing the intermediate steps of the pipeline: + 1. `binary_img`: The result of the initial binarization. + 2. `noise_removed_img`: The image after noise removal. + 3. `holes_filled_img`: The image after filling holes. + 4. `smoothed_img`: The final smoothed image. +""" function process_image_pipeline(gray_img; otsu_scale=1.0, noise_size_percent=0.1, @@ -43,5 +107,3 @@ function process_image_pipeline(gray_img; # --- Return all intermediate steps for visualization --- return binary_img, noise_removed_img, holes_filled_img, smoothed_img end - -end diff --git a/src/MSIData.jl b/src/MSIData.jl index 709b00f..b5bc5a7 100644 --- a/src/MSIData.jl +++ b/src/MSIData.jl @@ -143,6 +143,45 @@ mutable struct MSIData end end +""" + get_masked_spectrum_indices(data::MSIData, mask_matrix::BitMatrix) -> Set{Int} + +Converts a 2D boolean mask matrix (e.g., loaded from a PNG) into a `Set` of linear +spectrum indices that fall within the `true` regions of the mask. + +# Arguments +- `data`: The `MSIData` object containing the `coordinate_map`. +- `mask_matrix`: A `BitMatrix` where `true` indicates a pixel is part of the ROI. + +# Returns +- A `Set{Int}` containing the linear indices of spectra within the mask. +""" +function get_masked_spectrum_indices(data::MSIData, mask_matrix::BitMatrix) + if data.coordinate_map === nothing + error("Coordinate map not available. Cannot apply mask to non-imaging data.") + end + + width, height = data.image_dims + mask_height, mask_width = size(mask_matrix) + + if mask_width != width || mask_height != height + error("Mask dimensions ($(mask_width)x$(mask_height)) do not match image dimensions ($(width)x$(height)).") + end + + masked_indices = Set{Int}() + for y in 1:height + for x in 1:width + if mask_matrix[y, x] # If this pixel is part of the mask + idx = data.coordinate_map[x, y] + if idx != 0 # Ensure there's an actual spectrum at this coordinate + push!(masked_indices, idx) + end + end + end + end + return masked_indices +end + # --- Internal function for reading binary data --- # @@ -513,7 +552,7 @@ It uses a fast, two-pass approach: This function is highly optimized for `.imzML` by leveraging direct binary reading and optimized binning logic. It is called by `get_total_spectrum`. """ -function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) +function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000, masked_indices::Union{Set{Int}, Nothing}=nothing) println("Calculating total spectrum for imzML (2-pass method)...") total_start_time = time_ns() @@ -529,7 +568,7 @@ function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) println(" Pass 1: Finding global m/z range...") g_min_mz = Inf g_max_mz = -Inf - _iterate_spectra_fast(msi_data) do idx, mz, _ + _iterate_spectra_fast(msi_data, masked_indices === nothing ? nothing : collect(masked_indices)) do idx, mz, _ if !isempty(mz) local_min, local_max = extrema(mz) g_min_mz = min(g_min_mz, local_min) @@ -539,7 +578,7 @@ function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) pass1_duration = (time_ns() - pass1_start_time) / 1e9 if !isfinite(g_min_mz) @warn "Could not determine a valid m/z range for imzML. All spectra might be empty." - return (Float64[], Float64[]) + return (Float64[], Float64[], 0) end # Use and cache the result @@ -560,7 +599,9 @@ function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) # 3. Second Pass: Optimized binning pass2_start_time = time_ns() println(" Pass 2: Summing intensities into $num_bins bins...") - _iterate_spectra_fast(msi_data) do idx, mz, intensity + num_spectra_processed = 0 + _iterate_spectra_fast(msi_data, masked_indices === nothing ? nothing : collect(masked_indices)) do idx, mz, intensity + num_spectra_processed += 1 if isempty(mz) return end @@ -594,7 +635,7 @@ function get_total_spectrum_imzml(msi_data::MSIData; num_bins::Int=2000) println("----------------------------------------\n") println("Total spectrum calculation complete for imzML.") - return (collect(mz_bins), intensity_sum) + return (collect(mz_bins), intensity_sum, num_spectra_processed) end """ @@ -608,7 +649,7 @@ It uses a two-pass approach analogous to the `imzML` implementation: This function is called by `get_total_spectrum`. """ -function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) +function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000, masked_indices::Union{Set{Int}, Nothing}=nothing) println("Calculating total spectrum for mzML (2-pass method)...") total_start_time = time_ns() @@ -624,7 +665,7 @@ function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) println(" Pass 1: Finding global m/z range...") g_min_mz = Inf g_max_mz = -Inf - _iterate_spectra_fast(msi_data) do idx, mz, intensity + _iterate_spectra_fast(msi_data, masked_indices === nothing ? nothing : collect(masked_indices)) do idx, mz, intensity if !isempty(mz) local_min, local_max = extrema(mz) g_min_mz = min(g_min_mz, local_min) @@ -635,7 +676,7 @@ function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) if !isfinite(g_min_mz) @warn "Could not determine a valid m/z range for mzML. All spectra might be empty." - return (Float64[], Float64[]) + return (Float64[], Float64[], 0) end # Use and cache the result @@ -658,7 +699,9 @@ function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) inv_bin_step = 1.0 / bin_step # Precompute reciprocal min_mz = global_min_mz - _iterate_spectra_fast(msi_data) do idx, mz, intensity + num_spectra_processed = 0 + _iterate_spectra_fast(msi_data, masked_indices === nothing ? nothing : collect(masked_indices)) do idx, mz, intensity + num_spectra_processed += 1 if isempty(mz) return end @@ -687,7 +730,7 @@ function get_total_spectrum_mzml(msi_data::MSIData; num_bins::Int=2000) println("----------------------------------------\n") println("Total spectrum calculation complete for mzML.") - return (collect(mz_bins), intensity_sum) + return (collect(mz_bins), intensity_sum, num_spectra_processed) end """ @@ -699,11 +742,18 @@ This function dispatches to a specialized implementation based on the file type 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) +function get_total_spectrum(msi_data::MSIData; num_bins::Int=2000, mask_path::Union{String, Nothing}=nothing)::Tuple{Vector{Float64}, Vector{Float64}, Int} + local masked_indices::Union{Set{Int}, Nothing} = nothing + if mask_path !== nothing + mask_matrix = load_and_prepare_mask(mask_path, msi_data.image_dims) + masked_indices = get_masked_spectrum_indices(msi_data, mask_matrix) + println("Calculating total spectrum for masked region (mask from: $(mask_path))") + end + if msi_data.source isa ImzMLSource - return get_total_spectrum_imzml(msi_data, num_bins=num_bins) + return get_total_spectrum_imzml(msi_data, num_bins=num_bins, masked_indices=masked_indices) else # MzMLSource - return get_total_spectrum_mzml(msi_data, num_bins=num_bins) + return get_total_spectrum_mzml(msi_data, num_bins=num_bins, masked_indices=masked_indices) end end @@ -715,14 +765,15 @@ This is effectively the total spectrum divided by the number of spectra. 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=2000) - mz_bins, intensity_sum = get_total_spectrum(msi_data, num_bins=num_bins) +function get_average_spectrum(msi_data::MSIData; num_bins::Int=2000, mask_path::Union{String, Nothing}=nothing)::Tuple{Vector{Float64}, Vector{Float64}} + mz_bins, intensity_sum, num_spectra_processed = get_total_spectrum(msi_data, num_bins=num_bins, mask_path=mask_path) - if isempty(intensity_sum) - return (mz_bins, intensity_sum) + if isempty(intensity_sum) || num_spectra_processed == 0 + @warn "Cannot calculate average spectrum: no spectra were processed." + return (mz_bins, Float64[]) end - num_spectra = length(msi_data.spectra_metadata) - average_intensity = intensity_sum ./ num_spectra + + average_intensity = intensity_sum ./ num_spectra_processed return (mz_bins, average_intensity) end @@ -855,13 +906,16 @@ is significantly faster for bulk processing than reading spectra one by one. - `data`: The `MSIData` object. - `source`: The `ImzMLSource`. """ -function _iterate_uncompressed_fast(f::Function, data::MSIData, source::ImzMLSource) +function _iterate_uncompressed_fast(f::Function, data::MSIData, source::ImzMLSource, indices_to_iterate::Union{AbstractVector{Int}, Nothing}) # Optimized path for uncompressed data using buffer reuse max_points = maximum(meta -> meta.mz_asset.encoded_length, data.spectra_metadata) mz_buffer = Vector{source.mz_format}(undef, max_points) int_buffer = Vector{source.intensity_format}(undef, max_points) - for i in 1:length(data.spectra_metadata) + # Determine which indices to iterate over + spectrum_indices = (indices_to_iterate === nothing) ? (1:length(data.spectra_metadata)) : indices_to_iterate + + for i in spectrum_indices meta = data.spectra_metadata[i] nPoints = meta.mz_asset.encoded_length @@ -904,10 +958,14 @@ and will be slower and allocate more memory than `_iterate_uncompressed_fast`. - `data`: The `MSIData` object. - `source`: The `ImzMLSource`. """ -function _iterate_compressed_fast(f::Function, data::MSIData, source::ImzMLSource) +function _iterate_compressed_fast(f::Function, data::MSIData, source::ImzMLSource, indices_to_iterate::Union{AbstractVector{Int}, Nothing}) # Path for datasets containing at least one compressed spectrum. # This path reads and decompresses each spectrum individually. - for i in 1:length(data.spectra_metadata) + + # Determine which indices to iterate over + spectrum_indices = (indices_to_iterate === nothing) ? (1:length(data.spectra_metadata)) : indices_to_iterate + + for i in spectrum_indices meta = data.spectra_metadata[i] if meta.mz_asset.encoded_length == 0 && meta.int_asset.encoded_length == 0 @@ -937,7 +995,7 @@ This function acts as a dispatcher: - If all spectra are uncompressed, it uses a highly optimized path that reuses pre-allocated buffers to minimize memory allocations and overhead. """ -function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::ImzMLSource) +function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::ImzMLSource, indices_to_iterate::Union{AbstractVector{Int}, Nothing}) if isempty(data.spectra_metadata) return end @@ -947,9 +1005,9 @@ function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::ImzMLSou data.spectra_metadata) if any_compressed - _iterate_compressed_fast(f, data, source) + _iterate_compressed_fast(f, data, source, indices_to_iterate) else - _iterate_uncompressed_fast(f, data, source) + _iterate_uncompressed_fast(f, data, source, indices_to_iterate) end end @@ -961,12 +1019,16 @@ through each spectrum, decodes the Base64 data on the fly, and calls the provided function. It bypasses the `GetSpectrum` cache to avoid storing all decoded spectra in memory. """ -function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::MzMLSource) +function _iterate_spectra_fast_impl(f::Function, data::MSIData, source::MzMLSource, indices_to_iterate::Union{AbstractVector{Int}, Nothing}) # This implementation is for mzML. It iterates through each spectrum, # decodes it, and then calls the function `f`. It's less performant than # the imzML version because we cannot pre-allocate buffers of a known size, # but it is correct and still faster than using GetSpectrum due to bypassing the cache. - for i in 1:length(data.spectra_metadata) + + # Determine which indices to iterate over + spectrum_indices = (indices_to_iterate === nothing) ? (1:length(data.spectra_metadata)) : indices_to_iterate + + for i in spectrum_indices meta = data.spectra_metadata[i] mz = read_binary_vector(source.file_handle, meta.mz_asset) @@ -986,7 +1048,7 @@ It dispatches to a specialized implementation based on the data source type - `f`: A function to call for each spectrum, with signature `f(index, mz, intensity)`. - `data`: The `MSIData` object. """ -function _iterate_spectra_fast(f::Function, data::MSIData) +function _iterate_spectra_fast(f::Function, data::MSIData, indices_to_iterate::Union{AbstractVector{Int}, Nothing}=nothing) # Dispatch to the correct implementation based on the source type - _iterate_spectra_fast_impl(f, data, data.source) + _iterate_spectra_fast_impl(f, data, data.source, indices_to_iterate) end \ No newline at end of file diff --git a/src/MSI_src.jl b/src/MSI_src.jl index 19c0cf6..72adaf3 100644 --- a/src/MSI_src.jl +++ b/src/MSI_src.jl @@ -14,7 +14,10 @@ export OpenMSIData, get_average_spectrum, LoadMzml, precompute_analytics, - process_spectrum + process_spectrum, + generate_colorbar_image, + process_image_pipeline, + load_and_prepare_mask # Export the public Preprocessing API export FeatureMatrix, @@ -43,6 +46,7 @@ include("mzML.jl") include("imzML.jl") include("MzmlConverter.jl") include("Preprocessing.jl") +include("ImageProcessing.jl") # --- Main Entry Point --- # diff --git a/src/imzML.jl b/src/imzML.jl index 6c3b48a..635eefa 100644 --- a/src/imzML.jl +++ b/src/imzML.jl @@ -744,10 +744,17 @@ 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) +function get_mz_slice(data::MSIData, mass::Real, tolerance::Real; mask_path::Union{String, Nothing}=nothing) width, height = data.image_dims slice_matrix = zeros(Float64, height, width) + local masked_indices::Union{Set{Int}, Nothing} = nothing + if mask_path !== nothing + mask_matrix = load_and_prepare_mask(mask_path, (width, height)) + masked_indices = get_masked_spectrum_indices(data, mask_matrix) + println("Applying mask from: $(mask_path), found $(length(masked_indices)) spectra in ROI.") + end + # INTELLIGENT LOADING: Ensure analytics are computed for filtering. if data.spectrum_stats_df === nothing || !hasproperty(data.spectrum_stats_df, :MinMZ) println("Per-spectrum metadata not found. Running one-time analytics computation...") @@ -761,7 +768,9 @@ function get_mz_slice(data::MSIData, mass::Real, tolerance::Real) # 1. Find all candidate spectra first for efficient filtering candidate_indices = Set{Int}() - for i in 1:length(data.spectra_metadata) + indices_to_check = masked_indices === nothing ? (1:length(data.spectra_metadata)) : masked_indices + + for i in indices_to_check spec_min_mz = stats_df.MinMZ[i] spec_max_mz = stats_df.MaxMZ[i] if target_max >= spec_min_mz && target_min <= spec_max_mz @@ -769,20 +778,17 @@ function get_mz_slice(data::MSIData, mass::Real, tolerance::Real) end end - println("Found $(length(candidate_indices)) candidate spectra (filtered from $(length(data.spectra_metadata)))") + println("Found $(length(candidate_indices)) candidate spectra (filtered from $(length(indices_to_check)) initial spectra)") # 2. Iterate using the optimized, low-allocation iterator results_count = 0 - _iterate_spectra_fast(data) do idx, mz_array, intensity_array - # Process only the spectra that are candidates - if idx in candidate_indices - intensity = find_mass(mz_array, intensity_array, mass, tolerance) - if intensity > 0.0 - meta = data.spectra_metadata[idx] - if 1 <= meta.x <= width && 1 <= meta.y <= height - slice_matrix[meta.y, meta.x] = intensity - results_count += 1 - end + _iterate_spectra_fast(data, collect(candidate_indices)) do idx, mz_array, intensity_array + meta = data.spectra_metadata[idx] + intensity = find_mass(mz_array, intensity_array, mass, tolerance) + if intensity > 0.0 + if 1 <= meta.x <= width && 1 <= meta.y <= height + slice_matrix[meta.y, meta.x] = intensity + results_count += 1 end end end @@ -802,7 +808,7 @@ This is a highly performant function that iterates through the full dataset only # Returns - A `Dict{Real, Matrix{Float64}}` mapping each mass to its intensity slice matrix. """ -function get_multiple_mz_slices(data::MSIData, masses::AbstractVector{<:Real}, tolerance::Real) +function get_multiple_mz_slices(data::MSIData, masses::AbstractVector{<:Real}, tolerance::Real; mask_path::Union{String, Nothing}=nothing) width, height = data.image_dims # 1. Initialize a dictionary to hold the output slice matrices @@ -811,6 +817,13 @@ function get_multiple_mz_slices(data::MSIData, masses::AbstractVector{<:Real}, t slice_dict[mass] = zeros(Float64, height, width) end + local masked_indices::Union{Set{Int}, Nothing} = nothing + if mask_path !== nothing + mask_matrix = load_and_prepare_mask(mask_path, (width, height)) + masked_indices = get_masked_spectrum_indices(data, mask_matrix) + println("Applying mask from: $(mask_path), found $(length(masked_indices)) spectra in ROI.") + end + # 2. Ensure analytics are computed for filtering. if data.spectrum_stats_df === nothing || !hasproperty(data.spectrum_stats_df, :MinMZ) println("Per-spectrum metadata not found. Running one-time analytics computation...") @@ -820,12 +833,13 @@ function get_multiple_mz_slices(data::MSIData, masses::AbstractVector{<:Real}, t println("Filtering candidate spectra for $(length(masses)) m/z values...") stats_df = data.spectrum_stats_df candidate_indices = Set{Int}() + indices_to_check = masked_indices === nothing ? (1:length(data.spectra_metadata)) : masked_indices # 3. Find all spectra that could contain *any* of the requested masses. for mass in masses target_min = mass - tolerance target_max = mass + tolerance - for i in 1:length(data.spectra_metadata) + for i in indices_to_check # If already a candidate, no need to check again if i in candidate_indices continue @@ -841,20 +855,17 @@ function get_multiple_mz_slices(data::MSIData, masses::AbstractVector{<:Real}, t println("Found $(length(candidate_indices)) total candidate spectra.") # 4. Iterate through the data a single time using the optimized iterator. - _iterate_spectra_fast(data) do idx, mz_array, intensity_array - # Process only the spectra that are candidates - if idx in candidate_indices - meta = data.spectra_metadata[idx] - # For this single spectrum, check all masses of interest - for mass in masses - # Check if this spectrum's range actually covers the current mass - # This is a finer-grained check than the initial filtering - if !isempty(mz_array) && (mass + tolerance) >= first(mz_array) && (mass - tolerance) <= last(mz_array) - intensity = find_mass(mz_array, intensity_array, mass, tolerance) - if intensity > 0.0 - if 1 <= meta.x <= width && 1 <= meta.y <= height - slice_dict[mass][meta.y, meta.x] = intensity - end + _iterate_spectra_fast(data, collect(candidate_indices)) do idx, mz_array, intensity_array + meta = data.spectra_metadata[idx] + # For this single spectrum, check all masses of interest + for mass in masses + # Check if this spectrum's range actually covers the current mass + # This is a finer-grained check than the initial filtering + if !isempty(mz_array) && (mass + tolerance) >= first(mz_array) && (mass - tolerance) <= last(mz_array) + intensity = find_mass(mz_array, intensity_array, mass, tolerance) + if intensity > 0.0 + if 1 <= meta.x <= width && 1 <= meta.y <= height + slice_dict[mass][meta.y, meta.x] = intensity end end end @@ -878,30 +889,15 @@ Generates and saves a plot of a single image slice for a given m/z value. This function closely imitates the logic of the original `GetSlice` but uses the modern `MSIData` access patterns and robust peak finding. """ -function plot_slice(msi_data::MSIData, mass::Real, tolerance::Real, output_dir::String; stage_name="slice_mz_$(mass)", bins=256) +function plot_slice(msi_data::MSIData, mass::Real, tolerance::Real, output_dir::String; + stage_name="slice_mz_$(mass)", bins=256, mask_path::Union{String, Nothing}=nothing) - # 1. Create an empty image for the slice, with dimensions matching plotting expectations - width, height = msi_data.image_dims - slice_matrix = zeros(Float64, height, width) - - # 2. Iterate through each spectrum to build the slice + # 1. Generate the slice, applying the mask if provided. println("Generating slice for m/z $mass...") - _iterate_spectra_fast(msi_data) do spec_idx, mz_array, intensity_array - meta = msi_data.spectra_metadata[spec_idx] - - # Find the peak intensity using the modern, robust find_mass - intensity = find_mass(mz_array, intensity_array, mass, tolerance) - - if intensity > 0.0 - # Populate the matrix using (y, x) indexing - if 1 <= meta.x <= width && 1 <= meta.y <= height - slice_matrix[meta.y, meta.x] = intensity - end - end - end + slice_matrix = get_mz_slice(msi_data, mass, tolerance, mask_path=mask_path) println("Slice generation complete.") - # 3. Plot the resulting slice matrix using CairoMakie + # 2. Plot the resulting slice matrix using CairoMakie println("Plotting slice...") fig = Figure(size = (600, 500)) @@ -925,7 +921,7 @@ function plot_slice(msi_data::MSIData, mass::Real, tolerance::Real, output_dir:: Colorbar(fig[1, 2], hm, label="Intensity") colgap!(fig.layout, 5) - # 4. Save the plot + # 3. Save the plot mkpath(output_dir) filename = "$(stage_name).png" save_path = joinpath(output_dir, filename) @@ -957,9 +953,9 @@ is used to exclude outliers before normalization. # Returns - A tuple `(low, high)` representing the calculated lower and upper intensity bounds. """ -function get_outlier_thres(img::AbstractMatrix{<:Real}, prob::Real=0.98) +function get_outlier_thres(img::AbstractVector{<:Real}, prob::Real=0.98) # DO NOT filter zeros. Use all pixel values like R does. - int_values = vec(img) + int_values = img low = minimum(int_values) upp = maximum(int_values) @@ -1001,6 +997,10 @@ function get_outlier_thres(img::AbstractMatrix{<:Real}, prob::Real=0.98) return (low, actual_threshold) end +function get_outlier_thres(img::AbstractMatrix{<:Real}, prob::Real=0.98) + return get_outlier_thres(vec(img), prob) +end + """ set_pixel_depth(img, bounds, depth) @@ -1063,11 +1063,26 @@ within these bounds. # Returns - A new image matrix with intensities quantized to the specified depth within the TrIQ bounds. """ -function TrIQ(pixMap::AbstractMatrix{<:Real}, depth::Integer, prob::Real=0.98) - # Compute new dynamic range - bounds = get_outlier_thres(pixMap, prob) +function TrIQ(pixMap::AbstractMatrix{<:Real}, depth::Integer, prob::Real=0.98; mask_matrix::Union{BitMatrix, Nothing}=nothing) + local values_for_thres + if mask_matrix !== nothing + # Extract values only from the masked region for threshold calculation + values_for_thres = pixMap[mask_matrix] + # Filter out only zeros created by the mask. + filter!(x -> x != 0, values_for_thres) + else + values_for_thres = pixMap + end + + # If the relevant values are empty or all zero, return a zero matrix + if isempty(values_for_thres) || all(iszero, values_for_thres) + bounds = (0.0, 0.0) + else + # Compute new dynamic range from the (potentially filtered) values + bounds = get_outlier_thres(values_for_thres, prob) + end - # Set intensity dynamic range + # Set intensity dynamic range for the *entire* pixMap, using bounds derived from masked data return set_pixel_depth(pixMap, bounds, depth) end @@ -1107,8 +1122,17 @@ 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) +function quantize_intensity(slice::AbstractMatrix{<:Real}, levels::Integer=256; mask_matrix::Union{BitMatrix, Nothing}=nothing) + local max_val + if mask_matrix !== nothing + masked_slice = slice[mask_matrix] + # Filter out zeros that might have been introduced by masking, if any + filter!(x -> x != 0, masked_slice) + max_val = isempty(masked_slice) ? 0.0 : maximum(masked_slice) + else + max_val = maximum(slice) + end + if max_val <= 0 return zeros(UInt8, size(slice)) end @@ -1208,7 +1232,7 @@ function display_statistics(slices::AbstractArray{<:AbstractMatrix{<:Real}, 2}, stats_to_calc = Dict{String, Function}("Mean" => mean) all_dfs = Dict{String, DataFrame}() - for (stat_name, stat_func) in stats_to_to_calc + for (stat_name, stat_func) in stats_to_calc # Pre-allocate matrix for better performance stat_matrix = zeros(Float64, n_files, n_masses) @@ -1497,4 +1521,81 @@ function process_columns_first!(A::AbstractMatrix) end # Viridis color palette (256 colors) - defined as constant -const ViridisPalette = generate_palette(ColorSchemes.viridis) \ No newline at end of file +const ViridisPalette = generate_palette(ColorSchemes.viridis) + +function generate_colorbar_image(slice_data::AbstractMatrix, color_levels::Int, output_path::String; use_triq::Bool=false, triq_prob::Float64=0.98, mask_path::Union{String, Nothing}=nothing) + # 1. Determine bounds based on whether TrIQ is used and if a mask is applied + local data_for_bounds + if mask_path !== nothing + height, width = size(slice_data) + mask_matrix = load_and_prepare_mask(mask_path, (width, height)) + # Extract only the non-zero values within the mask to accurately calculate the range + data_for_bounds = slice_data[mask_matrix] + filter!(x -> x > 0, data_for_bounds) + else + data_for_bounds = vec(slice_data) + end + + # Handle cases where data_for_bounds might be empty after filtering + if isempty(data_for_bounds) + min_val, max_val = 0.0, 1.0 # Default range if no data in ROI + else + min_val, max_val = if use_triq + get_outlier_thres(data_for_bounds, triq_prob) + else + extrema(data_for_bounds) + end + end + + # 2. Replicate the tick calculation logic from plot_slices + bins = color_levels + levels = range(min_val, stop=max_val, length=bins + 1) + level_range = levels[end] - levels[1] + + if level_range == 0 + levels = range(min_val - 0.1, stop=max_val + 0.1, length=bins + 1) + level_range = 0.2 + end + + exponent = level_range > 0 ? floor(log10(level_range)) / 3 : 0 + scale = 10^(3 * exponent) + scaled_levels = levels ./ scale + + format_num = level_range > 0 ? floor(log10(level_range)) % 3 : 0 + labels = if format_num == 0 + [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] + elseif format_num == 1 + [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] + else + [ @sprintf("%3.2f", lvl) for lvl in scaled_levels] + end + + divisors = 2:7 + remainders = (bins - 1) .% divisors + best_divisor = divisors[findlast(x -> x == minimum(remainders), remainders)] + tick_indices = round.(Int, range(1, stop=bins + 1, length=best_divisor + 1)) + + if !(1 in tick_indices) + pushfirst!(tick_indices, 1) + end + if !((bins + 1) in tick_indices) + push!(tick_indices, bins + 1) + end + unique!(sort!(tick_indices)) + + tick_positions = levels[tick_indices] + tick_labels = labels[tick_indices] + + # 3. Create and save the colorbar image + fig = Figure(size=(150, 250)) + Colorbar(fig[1, 1], + colormap=cgrad(:viridis, bins, categorical=true), + # limits=(min_val, max_val), + limits=(levels[1], levels[end]), + label=(scale == 1 ? "Intensity" : "Intensity ×10^$(round(Int, 3 * exponent))"), + ticks=(tick_positions, tick_labels), + labelsize=20, + ticklabelsize=16 + ) + save(output_path, fig) +end