module MaskApp # --- Dependencies --- using GenieFramework using Images, ImageBinarization, ImageMorphology, ImageComponentAnalysis using NativeFileDialog, FileIO, ImageCore, Printf, Dates, JSON using Pkg, Libz, PlotlyBase, CairoMakie, Colors, Base64 using Statistics, NaturalSort, LinearAlgebra, StipplePlotly using Base.Filesystem: mv using MSI_src using .MSI_src: MSIData, process_image_pipeline # Plot Handling include("./julia_imzML_visual.jl") # Image Processing Pipeline using ImageBinarization function load_and_binarize_mask(path) img = load(path) gray_img = ensure_grayscale(img) # Binarize to ensure only pure black and white values return binarize(gray_img, Otsu()) end function ensure_grayscale(img) if eltype(img) <: AbstractRGB; return Gray.(img); end if eltype(img) <: AbstractRGBA; return Gray.(RGB.(img)); end if eltype(img) <: Color; return Gray.(img); end return img end function alter_image(img_path, otsu_scale, noise_size_percent, hole_size_percent, smoothing_level) try # Strip query parameters from img_path clean_img_path = replace(img_path, r"\?.*" => "") full_image_path = joinpath("public", lstrip(clean_img_path, '/')) if !isfile(full_image_path) return (success=false, message="Image file not found: $(full_image_path)", path="") end original_img = load(full_image_path) gray_img = Float32.(ensure_grayscale(original_img)) binary, noise_removed, holes_filled, smoothed = process_image_pipeline(gray_img; otsu_scale=otsu_scale, noise_size_percent=noise_size_percent, hole_size_percent=hole_size_percent, smoothing=smoothing_level) output_dir = joinpath("public", "css", "masks") mkpath(output_dir) path_smooth = "/css/masks/smoothed.png" save(joinpath(output_dir, basename(path_smooth)), smoothed) return (success=true, message="Image processed successfully!", path=path_smooth) catch e @error "Mask editor processing failed" exception=(e, catch_backtrace()) return (success=false, message="Error processing image: $(sprint(showerror, e))", path="") end end # New helper function to display a single slice function display_slice(slice_path) if isempty(slice_path) return [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())], PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)), false end full_path = joinpath("public", lstrip(slice_path, '/')) if !isfile(full_path) return [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())], PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)), false end plotdata, plotlayout, _, _ = loadImgPlot(slice_path, "", 0.0) # No mask overlay return plotdata, plotlayout, true end function get_timestamped_path(base_path) clean_path = replace(base_path, r"\?.*" => "") return clean_path * "?t=" * string(time_ns()) end function refresh_editor_preview(imgInt, smoothed_mask_path, imgTrans) if !isempty(smoothed_mask_path) timestamped_path = get_timestamped_path(smoothed_mask_path) plotdata, plotlayout, _, _ = loadImgPlot(imgInt, timestamped_path, imgTrans) return plotdata, plotlayout else return [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())], PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)) end end function flood_fill!(img::AbstractMatrix, x::Int, y::Int, fill_color) h, w = size(img) if !(1 <= x <= h && 1 <= y <= w); return; end target_color = img[x, y] if target_color == fill_color; return; end q = [(x, y)] img[x, y] = fill_color while !isempty(q) cx, cy = popfirst!(q) for (dx, dy) in [(0, 1), (0, -1), (1, 0), (-1, 0)] nx, ny = cx + dx, cy + dy if 1 <= nx <= h && 1 <= ny <= w && img[nx, ny] == target_color img[nx, ny] = fill_color push!(q, (nx, ny)) end end end end function update_main_plot(imgInt::String, smoothed_mask_path::String, imgTrans::Float64) try if !isempty(imgInt) && !isempty(smoothed_mask_path) # Both slice and mask available - show overlay plotdata, plotlayout, _, _ = loadImgPlot(imgInt, smoothed_mask_path, imgTrans) return plotdata, plotlayout, true elseif !isempty(imgInt) # Only slice available - show slice alone plotdata, plotlayout, show = display_slice(imgInt) return plotdata, plotlayout, show elseif !isempty(smoothed_mask_path) # Only mask available - show mask alone plotdata, plotlayout, _, _ = loadImgPlot(smoothed_mask_path, "", 0.0) return plotdata, plotlayout, true else # Nothing to show return [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())], PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)), false end catch e @error "Failed to update main plot" exception=(e, catch_backtrace()) return [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())], PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)), false end end @genietools @app begin # --- State for Slice Selection (from app.jl) --- @in selected_folder_main = "" @out available_folders = String[] @out image_available_folders = String[] @out registry_path = abspath(joinpath(@__DIR__, "public", "registry.json")) @private registry_init_done = false @in refetch_folders = false @out imgInt = "" # Path to current slice @out current_msi = "" @out msgimg = "Please select a dataset." @in imgTrans = 0.5 # Default transparency # --- State for Mask Workflow --- @in otsu_scale = 1.0 @in noise_size_percent = 0.01 @in hole_size_percent = 0.005 @in smoothing_level = 3 @in is_editing_mask = false @in is_browsing_slices = true @in mask_input_path = "" # Reactive trigger for processing @out smoothed_mask_path = "" # Path to the final smoothed.png @out plotdata_verify = [PlotlyBase.heatmap(x=Vector{Float64}(), y=Vector{Float64}())] @out plotlayout_verify = PlotlyBase.Layout(margin=attr(l=0,r=0,t=0,b=0,pad=0)) @out show_verification_plot = false @out show_editor = false # To launch the custom editor # --- Messages and Warnings --- @out mask_editor_message = "" @out mask_editor_warning = false @out progress = false # --- Buttons --- @in btn_use_slice_as_mask = false @in btn_upload_mask = false @in btn_edit_manually = false @in btn_img_plus = false @in btn_img_minus = false @in btn_change_slice = false # New button @in btn_flip_mask = false @in btn_save_final_mask = false @in btn_move_mask = false @in btn_upload_premade_mask = false # --- Manual Editor State --- @in brush_size = 10 @in brush_color = "#ff0000" # Red brush @in current_tool = "brush" # "brush", "eraser", "bucket", "drag" @in editor_scale = 1.0 @in bucket_fill_trigger = Dict() # Canvas dimensions matching the smoothed mask @out canvas_width = 512 @out canvas_height = 512 # Mouse coordinates for drawing @in mouse_x = 0 @in mouse_y = 0 @in is_drawing = false # Editor operations @in rotate_degrees = 0 @in flip_direction = "horizontal" @in move_direction = "right" @in move_pixels = 10 @in move_mask_payload = Dict() # Final save @in final_mask_name = "" @in updated_mask_data = "" # New property for canvas data @in canvas_refresh_trigger = 0 # New property to trigger canvas refresh # --- Handlers --- @onchange updated_mask_data begin if !isempty(updated_mask_data) try # Data URL format: data:image/png;base64,iVBORw0KGgo... # Extract base64 part base64_data = split(updated_mask_data, ",")[2] decoded_img_bytes = base64decode(base64_data) # Load image from bytes, convert to binary, and re-save img_from_canvas = load(IOBuffer(decoded_img_bytes)) binary_mask = binarize(ensure_grayscale(img_from_canvas), Otsu()) mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) save(mask_path, binary_mask) smoothed_mask_path = get_timestamped_path(smoothed_mask_path) @info "Mask updated from canvas data and binarized." catch e @error "Failed to update mask from canvas data" exception=(e, catch_backtrace()) end end end @onchange selected_folder_main begin if !isempty(selected_folder_main) is_browsing_slices = true is_editing_mask = false folder_path = joinpath("public", selected_folder_main) if !isdir(folder_path) imgInt = "" msgimg = "Folder not found." show_verification_plot = false return end msi_bmp = sort(filter(f -> startswith(f, "MSI_") && endswith(f, ".bmp"), readdir(folder_path)), lt=natural) if !isempty(msi_bmp) current_msi = first(msi_bmp) imgInt = "/$(selected_folder_main)/$(current_msi)" text_nmass = replace(current_msi, r"MSI_|.bmp" => "") msgimg = "m/z: $(replace(text_nmass, "_" => "."))" # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) else imgInt = "" msgimg = "No MSI images found in this dataset." show_verification_plot = false end # --- Check for mask in registry and update message --- reg_path = abspath(joinpath(@__DIR__, "public", "registry.json")) if isfile(reg_path) registry = JSON.parsefile(reg_path) # Assuming JSON.jl is loaded if haskey(registry, selected_folder_main) dataset_entry = registry[selected_folder_main] if get(dataset_entry, "has_mask", false) && haskey(dataset_entry, "mask_path") mask_filepath = dataset_entry["mask_path"] mask_filename = basename(mask_filepath) mask_editor_message = "Dataset has an existing mask: $(mask_filename)" # Optionally, load this mask if the user is currently editing if is_editing_mask && isempty(smoothed_mask_path) # Only auto-load if not already editing a mask and smoothed_mask_path is empty smoothed_mask_path = get_timestamped_path("/css/masks/$(mask_filename)") plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) mask_editor_message *= " (loaded for editing)" end else mask_editor_message = "No existing mask found for this dataset." end else mask_editor_message = "Dataset entry not found in registry." end else mask_editor_message = "Registry file (registry.json) not found." end end end @onbutton btn_img_plus begin if isempty(selected_folder_main) || is_editing_mask return end folder_path = joinpath("public", selected_folder_main) if !isdir(folder_path) return end msi_bmp = sort(filter(f -> startswith(f, "MSI_") && endswith(f, ".bmp"), readdir(folder_path)), lt=natural) new_msi = increment_image(current_msi, msi_bmp) if new_msi !== nothing current_msi = new_msi imgInt = "/$(selected_folder_main)/$(current_msi)" text_nmass = replace(current_msi, r"MSI_|.bmp" => "") msgimg = "m/z: $(replace(text_nmass, "_" => "."))" # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) end end @onbutton btn_img_minus begin if isempty(selected_folder_main) || is_editing_mask return end folder_path = joinpath("public", selected_folder_main) if !isdir(folder_path) return end msi_bmp = sort(filter(f -> startswith(f, "MSI_") && endswith(f, ".bmp"), readdir(folder_path)), lt=natural) new_msi = decrement_image(current_msi, msi_bmp) if new_msi !== nothing current_msi = new_msi imgInt = "/$(selected_folder_main)/$(current_msi)" text_nmass = replace(current_msi, r"MSI_|.bmp" => "") msgimg = "m/z: $(replace(text_nmass, "_" => "."))" # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) end end @onbutton btn_use_slice_as_mask begin if !isempty(imgInt) is_browsing_slices = false is_editing_mask = true mask_input_path = imgInt mask_editor_message = "Using current slice as mask input..." mask_editor_warning = false # Trigger processing which will update the plot via mask_input_path handler else mask_editor_message = "No slice selected. Please select a dataset and slice first." mask_editor_warning = true end end @onbutton btn_upload_mask begin picked_path = pick_file(; filterlist="png,bmp,jpg,jpeg") if !isempty(picked_path) target_dir = joinpath("public", "css", "masks") mkpath(target_dir) uploaded_filename = "uploaded_mask_input.png" destination_path = joinpath(target_dir, uploaded_filename) cp(picked_path, destination_path; force=true) is_browsing_slices = false is_editing_mask = true mask_input_path = get_timestamped_path("/css/masks/$uploaded_filename") mask_editor_message = "Uploaded mask image for processing..." mask_editor_warning = false # Plot will be updated via mask_input_path handler after processing else mask_editor_message = "No file selected for upload." mask_editor_warning = true end end @onbutton btn_upload_premade_mask begin picked_path = pick_file(; filterlist="png,bmp,jpg,jpeg") if !isempty(picked_path) target_dir = joinpath("public", "css", "masks") mkpath(target_dir) uploaded_filename = "premade_mask.png" destination_path = joinpath(target_dir, uploaded_filename) local_uploaded_path = "" # Define here for finally block cleanup try cp(picked_path, destination_path; force=true) local_uploaded_path = destination_path # Full path to the copied image # --- Binary Check --- img = load(local_uploaded_path) gray_img = ensure_grayscale(img) # Check if image is effectively binary (only two distinct values, or close enough) # Convert to raw pixel values for robust checking pixel_values = Float64.(collect(Iterators.flatten(channelview(gray_img)))) unique_pixel_values = unique(pixel_values) # Heuristic: if more than 2 distinct values exist, it's not truly binary # Allow for floating point inaccuracies if length(unique_pixel_values) > 2 # Find min and max for range check min_val, max_val = extrema(unique_pixel_values) # If values are not clustered at 0 and 1, it's likely not binary if !(min_val < 0.1 && max_val > 0.9 && all(v -> v < 0.1 || v > 0.9, unique_pixel_values)) mask_editor_message = "Warning: Uploaded image is not purely binary (found $(length(unique_pixel_values)) distinct pixel values). Please use 'Upload to Create Mask' for processing or ensure your image is strictly black and white." mask_editor_warning = true rm(local_uploaded_path; force=true) # Clean up non-binary file return end end # If it has 2 values, or only one value, ensure it's a true BitMatrix # This handles cases like 0-255 images being converted to 0.0-1.0 and then to true/false binary_img = gray_img .>= 0.5 # Ensure pure binary (true/false) save(local_uploaded_path, binary_img) # --- Resizing to Slice (if imgInt is present) --- slice_path_cleaned = replace(imgInt, r"\?.*" => "") slice_full_path = joinpath("public", lstrip(slice_path_cleaned, '/')) if !isempty(imgInt) && isfile(slice_full_path) # Only resize if a slice is present slice_img = load(slice_full_path) mask_img = load(local_uploaded_path) # Reload potentially re-binarized image to get correct type/size if size(slice_img) != size(mask_img) @info "Resizing pre-made mask to match slice dimensions." resized_mask = imresize(mask_img, size(slice_img)) save(local_uploaded_path, resized_mask) end end is_browsing_slices = false is_editing_mask = true # Update smoothed_mask_path to the new file, with timestamp for cache busting relative_path_for_web = "/css/masks/$(uploaded_filename)" smoothed_mask_path = get_timestamped_path(relative_path_for_web) mask_editor_message = "Pre-made mask uploaded and loaded successfully!" mask_editor_warning = false # Trigger plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) catch e @error "Failed to upload pre-made mask" exception=(e, catch_backtrace()) mask_editor_message = "Error uploading pre-made mask: $(sprint(showerror, e))" mask_editor_warning = true if !isempty(local_uploaded_path) && isfile(local_uploaded_path) rm(local_uploaded_path; force=true) end # Clean up end else mask_editor_message = "No file selected for upload." mask_editor_warning = true end end @onbutton btn_change_slice begin is_browsing_slices = true is_editing_mask = false # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, "", imgTrans) smoothed_mask_path = "" # Clear the mask path mask_editor_message = "Slice browsing re-enabled." end @onchange mask_input_path begin if isempty(mask_input_path) return end result = alter_image(mask_input_path, otsu_scale, noise_size_percent, hole_size_percent, smoothing_level) if result.success local_smoothed_path = joinpath("public", lstrip(result.path, '/')) slice_path_cleaned = replace(imgInt, r"\?.*" => "") slice_full_path = joinpath("public", lstrip(slice_path_cleaned, '/')) if isfile(slice_full_path) && isfile(local_smoothed_path) slice_img = load(slice_full_path) mask_img = load(local_smoothed_path) if size(slice_img) != size(mask_img) @info "Resizing mask to match slice dimensions." resized_mask = imresize(mask_img, size(slice_img)) save(local_smoothed_path, resized_mask) end smoothed_mask_path = get_timestamped_path(result.path) # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) mask_editor_message = result.message mask_editor_warning = false elseif isfile(local_smoothed_path) smoothed_mask_path = get_timestamped_path(result.path) # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot("", smoothed_mask_path, imgTrans) mask_editor_message = "Displaying generated mask. Select a dataset to overlay a slice." mask_editor_warning = false else mask_editor_message = "Slice or mask image not found for verification plot." mask_editor_warning = true end else mask_editor_message = result.message mask_editor_warning = true end end @onchange otsu_scale, noise_size_percent, hole_size_percent, smoothing_level begin if is_editing_mask if isempty(mask_input_path) return end result = alter_image(mask_input_path, otsu_scale, noise_size_percent, hole_size_percent, smoothing_level) if result.success local_smoothed_path = joinpath("public", lstrip(result.path, '/')) slice_path_cleaned = replace(imgInt, r"\?.*" => "") slice_full_path = joinpath("public", lstrip(slice_path_cleaned, '/')) if isfile(slice_full_path) && isfile(local_smoothed_path) slice_img = load(slice_full_path) mask_img = load(local_smoothed_path) if size(slice_img) != size(mask_img) @info "Resizing mask to match slice dimensions." resized_mask = imresize(mask_img, size(slice_img)) save(local_smoothed_path, resized_mask) end smoothed_mask_path = get_timestamped_path(result.path) # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) mask_editor_message = "Mask updated." mask_editor_warning = false elseif isfile(local_smoothed_path) smoothed_mask_path = get_timestamped_path(result.path) # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot("", smoothed_mask_path, imgTrans) mask_editor_message = "Displaying generated mask. Select a dataset to overlay a slice." mask_editor_warning = false else mask_editor_message = "Slice or mask image not found for verification plot." mask_editor_warning = true end else mask_editor_message = result.message mask_editor_warning = true end end end @onchange imgTrans begin if is_editing_mask # Use centralized plot update plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) end end @onbutton btn_edit_manually begin if !isempty(smoothed_mask_path) mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if isfile(mask_path) mask_img = load(mask_path) h, w = size(mask_img) canvas_width = w canvas_height = h show_editor = true end else mask_editor_message = "No mask available for editing. Process a mask first." mask_editor_warning = true end end @onchange show_editor begin if !show_editor && is_editing_mask # When the editor dialog is closed, refresh the main plot using centralized function plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) is_drawing = false # Reset drawing state end end @onchange mouse_x, mouse_y, is_drawing begin if show_editor && is_drawing && mouse_x > 0 && mouse_y > 0 try mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if !isfile(mask_path) return end img = load(mask_path) actual_x = round(Int, mouse_x) actual_y = round(Int, mouse_y) if current_tool == "brush" || current_tool == "eraser" if brush_size <= 0 return end radius = brush_size / 2 # Treat as diameter for i in max(1, floor(Int, actual_y-radius)):min(size(img, 1), ceil(Int, actual_y+radius)) for j in max(1, floor(Int, actual_x-radius)):min(size(img, 2), ceil(Int, actual_x+radius)) if (i - actual_y)^2 + (j - actual_x)^2 <= radius^2 color = (current_tool == "brush") ? RGB(1, 1, 1) : RGB(0, 0, 0) img[i, j] = color end end end end save(mask_path, img) plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) smoothed_mask_path = get_timestamped_path(smoothed_mask_path) catch e @error "Editor action failed" exception=(e, catch_backtrace()) end end end @onchange bucket_fill_trigger begin if show_editor && !isempty(bucket_fill_trigger) try mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if !isfile(mask_path) return end img = load_and_binarize_mask(mask_path) actual_x = round(Int, bucket_fill_trigger["x"]) actual_y = round(Int, bucket_fill_trigger["y"]) if 1 <= actual_y <= size(img, 1) && 1 <= actual_x <= size(img, 2) fill_color = Gray(1) # White flood_fill!(img, actual_y, actual_x, fill_color) save(mask_path, img) plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) smoothed_mask_path = get_timestamped_path(smoothed_mask_path) canvas_refresh_trigger = canvas_refresh_trigger[] + 1 end catch e @error "Bucket fill failed" exception=(e, catch_backtrace()) end end end @onchange rotate_degrees begin if show_editor && rotate_degrees != 0 try mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if !isfile(mask_path) return end img = load_and_binarize_mask(mask_path) original_size = size(img) # Normalize angle to 0, 90, 180, 270 angle = mod(round(Int, rotate_degrees), 360) if angle == 0; rotate_degrees = 0; return; end rotated_img = if angle == 90 rot_r90(img) elseif angle == 180 rot180(img) elseif angle == 270 rot_l90(img) else img # No change for other angles end if rotated_img !== img # Create a new image with the original dimensions, filled with black new_img = similar(img, original_size) fill!(new_img, eltype(img)(0)) # Calculate padding/offset to center the rotated image rotated_size = size(rotated_img) offset_h = (original_size[1] - rotated_size[1]) ÷ 2 offset_w = (original_size[2] - rotated_size[2]) ÷ 2 # Define the region in the new image where the rotated image will be placed dest_region_h = (1:rotated_size[1]) .+ offset_h dest_region_w = (1:rotated_size[2]) .+ offset_w # Ensure the destination region is within the bounds of the new image clamped_dest_h = max(1, dest_region_h.start):min(original_size[1], dest_region_h.stop) clamped_dest_w = max(1, dest_region_w.start):min(original_size[2], dest_region_w.stop) # Define the source region from the rotated image src_h = (1:length(clamped_dest_h)) src_w = (1:length(clamped_dest_w)) if !isempty(clamped_dest_h) && !isempty(clamped_dest_w) view(new_img, clamped_dest_h, clamped_dest_w) .= view(rotated_img, src_h, src_w) end save(mask_path, new_img) plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) end # Reset the slider and update the path smoothed_mask_path = get_timestamped_path(smoothed_mask_path) rotate_degrees = 0 canvas_refresh_trigger = canvas_refresh_trigger[] + 1 catch e @error "Rotation failed" exception=(e, catch_backtrace()) end end end @onbutton btn_flip_mask begin if show_editor try mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if !isfile(mask_path) return end img = load_and_binarize_mask(mask_path) flipped = (flip_direction == "horizontal") ? reverse(img, dims=2) : reverse(img, dims=1) save(mask_path, flipped) plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) smoothed_mask_path = get_timestamped_path(smoothed_mask_path) canvas_refresh_trigger = canvas_refresh_trigger[] + 1 catch e @error "Flip failed" exception=(e, catch_backtrace()) end end end @onchange move_mask_payload begin if show_editor && move_pixels > 0 && !isempty(move_mask_payload) try mask_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) if !isfile(mask_path) return end img = load_and_binarize_mask(mask_path) h, w = size(img) new_img = similar(img) fill!(new_img, eltype(img)(0)) dx, dy = 0, 0 direction = move_mask_payload["direction"] if direction == "right"; dx = move_pixels; end if direction == "left"; dx = -move_pixels; end if direction == "down"; dy = move_pixels; end if direction == "up"; dy = -move_pixels; end src_y_range = max(1, 1-dy):min(h, h-dy) src_x_range = max(1, 1-dx):min(w, w-dx) dest_y_range = max(1, 1+dy):min(h, h+dy) dest_x_range = max(1, 1+dx):min(w, w+dx) if !isempty(src_y_range) && !isempty(src_x_range) && !isempty(dest_y_range) && !isempty(dest_x_range) view(new_img, dest_y_range, dest_x_range) .= view(img, src_y_range, src_x_range) end save(mask_path, new_img) plotdata_verify, plotlayout_verify, show_verification_plot = update_main_plot(imgInt, smoothed_mask_path, imgTrans) smoothed_mask_path = get_timestamped_path(smoothed_mask_path) canvas_refresh_trigger = canvas_refresh_trigger[] + 1 catch e @error "Move failed" exception=(e, catch_backtrace()) end end end @onbutton btn_save_final_mask begin try parent_folder = split(selected_folder_main, '/')[end] final_mask_name = "$(parent_folder).png" source_path = joinpath("public", lstrip(replace(smoothed_mask_path, r"\?.*" => ""), '/')) target_dir = joinpath("public", "css", "masks") mkpath(target_dir) final_path = joinpath(target_dir, final_mask_name) if isfile(source_path) # Copy the mask to final location cp(source_path, final_path; force=true) # 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"] = 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" => full_mask_path, "has_mask" => true, "is_imzML" => true, "processed_date" => string(Dates.now()) ) end # Save the updated registry open(reg_path, "w") do f JSON.print(f, registry, 4) end @info "Registry updated with mask: $(final_mask_name)" # Update UI state mask_editor_message = "Final mask saved as $(final_mask_name)" mask_editor_warning = false show_editor = false else mask_editor_message = "Source mask file not found: $(source_path)" mask_editor_warning = true end catch e @error "Save final mask failed" exception=(e, catch_backtrace()) mask_editor_message = "Error saving final mask: $(sprint(showerror, e))" mask_editor_warning = true end end @onbutton refetch_folders begin # First, re-run the logic to populate the folder list registry = load_registry(registry_path) all_folders = sort(collect(keys(registry)), lt=natural) img_folders = filter(folder -> get(get(registry, folder, Dict()), "is_imzML", false), all_folders) available_folders = deepcopy(all_folders) image_available_folders = deepcopy(img_folders) # Apply the logic to select the first item if none is selected if !isempty(image_available_folders) && isempty(selected_folder_main) # This ensures the list is up-to-date image_available_folders = deepcopy(img_folders) selected_folder_main = first(image_available_folders) end end @onchange isready begin if isready && !registry_init_done sleep(1.0) # Give frontend time to initialize try println("Synchronizing registry for mask editor...") reg_path = abspath(joinpath(@__DIR__, "public", "registry.json")) # Assuming load_registry is available from MSI_src or a similar utility file # For now, handle its absence gracefully if it's not explicitly defined here. registry = isfile(reg_path) ? load_registry(reg_path) : Dict{String, Any}() public_dirs = isdir("public") ? readdir("public") : [] ignored_dirs = ["css", "masks"] dataset_dirs = filter(d -> isdir(joinpath("public", d)) && !(d in ignored_dirs), public_dirs) registry_keys = Set(keys(registry)) folder_set = Set(dataset_dirs) new_folders = setdiff(folder_set, registry_keys) for folder in new_folders println("Found new folder: $folder") registry[folder] = Dict( "source_path" => "unknown (manually added)", "processed_date" => "unknown", "metadata" => Dict(), "is_imzML" => true # Assume folder contains images if found this way ) end removed_folders = setdiff(registry_keys, folder_set) for folder in removed_folders delete!(registry, folder) end if !isempty(new_folders) || !isempty(removed_folders) println("Registry changed, saving...") open(reg_path, "w") do f JSON.print(f, registry, 4) end end all_folders = sort(collect(keys(registry)), lt=natural) img_folders = filter(folder -> get(get(registry, folder, Dict()), "is_imzML", false), all_folders) available_folders = deepcopy(all_folders) image_available_folders = deepcopy(img_folders) println("Mask editor UI lists updated. All: $(length(available_folders)), Images: $(length(image_available_folders))") catch e @warn "Mask editor registry synchronization failed: $e" available_folders = [] image_available_folders = [] finally registry_init_done = true end end end @methods """ // Close the default methods object and define our own component structure }, created() { // Initialize non-reactive data properties here this.canvas = null; this.ctx = null; this.isDrawingOnCanvas = false; this.lastX = 0; this.lastY = 0; this.imgObj = null; }, watch: { show_editor(newValue) { if (newValue) { // Wait for the dialog to render before initializing canvas setTimeout(() => { this.initCanvas() }, 100); } }, canvas_refresh_trigger(newValue) { if (newValue > 0) { this.initCanvas(); } } }, methods: { // Re-opened methods object for all our functions initCanvas() { this.canvas = document.getElementById('maskCanvas'); if (!this.canvas) { console.error("Canvas element not found!"); return; } this.ctx = this.canvas.getContext('2d'); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.imgObj = new Image(); this.imgObj.onload = () => { this.ctx.drawImage(this.imgObj, 0, 0, this.canvas.width, this.canvas.height); }; this.imgObj.src = this.smoothed_mask_path.split('?')[0] + '?t=' + Date.now(); }, startDrawing(event) { if (this.current_tool === 'drag' || !this.ctx) return; this.isDrawingOnCanvas = true; const { x, y } = this.getCanvasMousePosition(event); this.lastX = x; this.lastY = y; if (this.current_tool === 'bucket') { this.bucket_fill_trigger = { x: x, y: y, t: Date.now() }; this.isDrawingOnCanvas = false; return; } this.ctx.beginPath(); this.ctx.moveTo(this.lastX, this.lastY); }, draw(event) { if (!this.isDrawingOnCanvas || this.current_tool === 'drag' || this.current_tool === 'bucket' || !this.ctx) return; const { x, y } = this.getCanvasMousePosition(event); this.ctx.lineWidth = this.brush_size; this.ctx.lineCap = 'round'; this.ctx.lineJoin = 'round'; if (this.current_tool === 'brush') { this.ctx.strokeStyle = 'white'; this.ctx.globalCompositeOperation = 'source-over'; } else if (this.current_tool === 'eraser') { this.ctx.strokeStyle = 'black'; this.ctx.globalCompositeOperation = 'source-over'; } this.ctx.lineTo(x, y); this.ctx.stroke(); }, stopDrawing() { if (this.isDrawingOnCanvas) { this.isDrawingOnCanvas = false; this.ctx.closePath(); this.syncMaskToServer(); } }, getCanvasMousePosition(event) { const rect = this.canvas.getBoundingClientRect(); const scaleX = this.canvas.width / rect.width; const scaleY = this.canvas.height / rect.height; const x = (event.clientX - rect.left) * scaleX; const y = (event.clientY - rect.top) * scaleY; return { x, y }; }, syncMaskToServer() { if (!this.canvas) return; const dataURL = this.canvas.toDataURL('image/png'); this.updated_mask_data = dataURL; }, // Kept for compatibility with bucket tool logic which uses original image coordinates updateMousePosition(event) { const rect = event.target.getBoundingClientRect(); const scale = this.editor_scale || 1; this.mouse_x = (event.clientX - rect.left) / scale; this.mouse_y = (event.clientY - rect.top) / scale; } // The closing brace for methods is intentionally omitted, as Genie/Stipple will add it. """ end @page("/mask", "mask.jl.html") end # module