diff --git a/.gitignore b/.gitignore
index fcda959..0bb3977 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
public/*
public/css/imgOver.png
!public/css/
+!public/css/masks/static.txt
+public/css/masks/*
log/*
R_original_scripts/
test/results/
+test/binarization_results/
diff --git a/Manifest.toml b/Manifest.toml
index 40c25fa..4dfa48d 100644
--- a/Manifest.toml
+++ b/Manifest.toml
@@ -2,7 +2,7 @@
julia_version = "1.11.7"
manifest_format = "2.0"
-project_hash = "b3d73d786a430f468e0f2f1b3b05da6a3addc7fe"
+project_hash = "9b36a3561cfc9927071202de68baf8c2b0f02a5c"
[[deps.ATK_jll]]
deps = ["Artifacts", "Glib_jll", "JLLWrappers", "Libdl"]
@@ -26,6 +26,30 @@ git-tree-sha1 = "2d9c9a55f9c93e8887ad391fbae72f8ef55e1177"
uuid = "1520ce14-60c1-5f80-bbc7-55ef81b5835c"
version = "0.4.5"
+[[deps.Accessors]]
+deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"]
+git-tree-sha1 = "3b86719127f50670efe356bc11073d84b4ed7a5d"
+uuid = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697"
+version = "0.1.42"
+
+ [deps.Accessors.extensions]
+ AxisKeysExt = "AxisKeys"
+ IntervalSetsExt = "IntervalSets"
+ LinearAlgebraExt = "LinearAlgebra"
+ StaticArraysExt = "StaticArrays"
+ StructArraysExt = "StructArrays"
+ TestExt = "Test"
+ UnitfulExt = "Unitful"
+
+ [deps.Accessors.weakdeps]
+ AxisKeys = "94b1ba4f-4ee9-5380-92f1-94cde586c3c5"
+ IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953"
+ LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+ StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+ StructArrays = "09ab397b-f2b6-538f-b94a-2f83cf4a842a"
+ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+ Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
+
[[deps.Adapt]]
deps = ["LinearAlgebra", "Requires"]
git-tree-sha1 = "7e35fca2bdfba44d797c53dfe63a51fabf39bfc0"
@@ -336,6 +360,15 @@ deps = ["Artifacts", "Libdl"]
uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
version = "1.1.1+0"
+[[deps.CompositionsBase]]
+git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad"
+uuid = "a33af91c-f02d-484b-be07-31d278c5ca2b"
+version = "0.1.2"
+weakdeps = ["InverseFunctions"]
+
+ [deps.CompositionsBase.extensions]
+ CompositionsBaseInverseFunctionsExt = "InverseFunctions"
+
[[deps.ComputationalResources]]
git-tree-sha1 = "52cb3ec90e8a8bea0e62e275ba577ad0f74821f7"
uuid = "ed09eef8-17a6-5b46-8889-db040fac31e3"
@@ -347,12 +380,6 @@ git-tree-sha1 = "d9d26935a0bcffc87d2613ce14c527c99fc543fd"
uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
version = "2.5.0"
-[[deps.Conda]]
-deps = ["Downloads", "JSON", "VersionParsing"]
-git-tree-sha1 = "b19db3927f0db4151cb86d073689f2428e524576"
-uuid = "8f4d0f93-b110-5947-807f-2305c1781a2d"
-version = "1.10.2"
-
[[deps.Configurations]]
deps = ["ExproniconLite", "OrderedCollections", "TOML"]
git-tree-sha1 = "4358750bb58a3caefd5f37a4a0c5bfdbbf075252"
@@ -375,6 +402,16 @@ git-tree-sha1 = "439e35b0b36e2e5881738abc8857bd92ad6ff9a8"
uuid = "d38c429a-6771-53c6-b99e-75d170b6e991"
version = "0.6.3"
+[[deps.ConvexHulls2d]]
+deps = ["LinearAlgebra"]
+git-tree-sha1 = "cd7759cfeaf855fed46ec26a26fb323ac3a5501e"
+uuid = "438b5665-92b0-42e6-bc36-e4ac8449fa2d"
+version = "0.1.1"
+weakdeps = ["Makie"]
+
+ [deps.ConvexHulls2d.extensions]
+ ConvexHulls2dMakieExt = "Makie"
+
[[deps.CoordinateTransformations]]
deps = ["LinearAlgebra", "StaticArrays"]
git-tree-sha1 = "a692f5e257d332de1e554e4566a4e5a8a72de2b2"
@@ -525,11 +562,6 @@ git-tree-sha1 = "7bb1361afdb33c7f2b085aa49ea8fe1b0fb14e58"
uuid = "2e619515-83b5-522b-bb60-26c02a35a201"
version = "2.7.1+0"
-[[deps.ExpressionExplorer]]
-git-tree-sha1 = "4a8c0a9eebf807ac42f0f6de758e60a20be25ffb"
-uuid = "21656369-7473-754a-2065-74616d696c43"
-version = "1.1.3"
-
[[deps.ExproniconLite]]
git-tree-sha1 = "c13f0b150373771b0fdc1713c97860f8df12e6c2"
uuid = "55351af7-c7e9-48d6-89ff-24e801d99491"
@@ -546,12 +578,6 @@ git-tree-sha1 = "680a39c9aadce7c721b68d979e66dc65d2021aa6"
uuid = "8f5d6c58-4d21-5cfd-889c-e3ad7ee6a615"
version = "1.2.2"
-[[deps.FFMPEG]]
-deps = ["FFMPEG_jll"]
-git-tree-sha1 = "83dc665d0312b41367b7263e8a4d172eac1897f4"
-uuid = "c87230d0-a227-11e9-1b43-d7ebe4e7570a"
-version = "0.4.4"
-
[[deps.FFMPEG_jll]]
deps = ["Artifacts", "Bzip2_jll", "FreeType2_jll", "FriBidi_jll", "JLLWrappers", "LAME_jll", "Libdl", "Ogg_jll", "OpenSSL_jll", "Opus_jll", "PCRE2_jll", "Zlib_jll", "libaom_jll", "libass_jll", "libfdk_aac_jll", "libvorbis_jll", "x264_jll", "x265_jll"]
git-tree-sha1 = "eaa040768ea663ca695d442be1bc97edfe6824f2"
@@ -578,9 +604,9 @@ version = "3.3.11+0"
[[deps.FileIO]]
deps = ["Pkg", "Requires", "UUIDs"]
-git-tree-sha1 = "b66970a70db13f45b7e57fbda1736e1cf72174ea"
+git-tree-sha1 = "d60eb76f37d7e5a40cc2e7c36974d864b82dc802"
uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
-version = "1.17.0"
+version = "1.17.1"
weakdeps = ["HTTP"]
[deps.FileIO.extensions]
@@ -665,29 +691,29 @@ deps = ["Random"]
uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820"
version = "1.11.0"
+[[deps.GLFW]]
+deps = ["GLFW_jll"]
+git-tree-sha1 = "40412e58ec374029de3d4ad7c13e1a52aa1e149f"
+uuid = "f7f18e0c-5ee9-5ccd-a5bf-e8befd85ed98"
+version = "3.4.5"
+
[[deps.GLFW_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl", "Libglvnd_jll", "Xorg_libXcursor_jll", "Xorg_libXi_jll", "Xorg_libXinerama_jll", "Xorg_libXrandr_jll", "libdecor_jll", "xkbcommon_jll"]
git-tree-sha1 = "fcb0584ff34e25155876418979d4c8971243bb89"
uuid = "0656b61e-2033-5cc2-a64a-77c0f6c09b89"
version = "3.4.0+2"
+[[deps.GLMakie]]
+deps = ["ColorTypes", "Colors", "FileIO", "FixedPointNumbers", "FreeTypeAbstraction", "GLFW", "GeometryBasics", "LinearAlgebra", "Makie", "Markdown", "MeshIO", "ModernGL", "Observables", "PrecompileTools", "Printf", "ShaderAbstractions", "StaticArrays"]
+git-tree-sha1 = "487ffeede54553565023a107529434ff585060ae"
+uuid = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a"
+version = "0.11.2"
+
[[deps.GMP_jll]]
deps = ["Artifacts", "Libdl"]
uuid = "781609d7-10c4-51f6-84f2-b8444358ff6d"
version = "6.3.0+0"
-[[deps.GR]]
-deps = ["Artifacts", "Base64", "DelimitedFiles", "Downloads", "GR_jll", "HTTP", "JSON", "Libdl", "LinearAlgebra", "Preferences", "Printf", "Qt6Wayland_jll", "Random", "Serialization", "Sockets", "TOML", "Tar", "Test", "p7zip_jll"]
-git-tree-sha1 = "1828eb7275491981fa5f1752a5e126e8f26f8741"
-uuid = "28b8d3ca-fb5f-59d9-8090-bfdbd6d07a71"
-version = "0.73.17"
-
-[[deps.GR_jll]]
-deps = ["Artifacts", "Bzip2_jll", "Cairo_jll", "FFMPEG_jll", "Fontconfig_jll", "FreeType2_jll", "GLFW_jll", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Libtiff_jll", "Pixman_jll", "Qt6Base_jll", "Zlib_jll", "libpng_jll"]
-git-tree-sha1 = "27299071cc29e409488ada41ec7643e0ab19091f"
-uuid = "d2c73de3-f751-5644-a686-071e5b155ba9"
-version = "0.73.17+0"
-
[[deps.GTK3_jll]]
deps = ["ATK_jll", "Artifacts", "Cairo_jll", "Fontconfig_jll", "FreeType2_jll", "FriBidi_jll", "Glib_jll", "HarfBuzz_jll", "JLLWrappers", "Libdl", "Libepoxy_jll", "Pango_jll", "Pkg", "Wayland_jll", "Xorg_libX11_jll", "Xorg_libXcomposite_jll", "Xorg_libXcursor_jll", "Xorg_libXdamage_jll", "Xorg_libXext_jll", "Xorg_libXfixes_jll", "Xorg_libXi_jll", "Xorg_libXinerama_jll", "Xorg_libXrandr_jll", "Xorg_libXrender_jll", "at_spi2_atk_jll", "gdk_pixbuf_jll", "iso_codes_jll", "xkbcommon_jll"]
git-tree-sha1 = "b080a592525632d287aee4637a62682576b7f5e4"
@@ -783,12 +809,6 @@ git-tree-sha1 = "97285bbd5230dd766e9ef6749b80fc617126d496"
uuid = "c27321d9-0574-5035-807b-f59d2c89b15c"
version = "1.3.1"
-[[deps.GracefulPkg]]
-deps = ["Compat", "Pkg", "TOML"]
-git-tree-sha1 = "698050b04f3cc0906d0817329d6e96484bf238eb"
-uuid = "828d9ff0-206c-6161-646e-6576656f7244"
-version = "2.3.0"
-
[[deps.Graphics]]
deps = ["Colors", "LinearAlgebra", "NaNMath"]
git-tree-sha1 = "a641238db938fff9b2f60d08ed9030387daf428c"
@@ -859,12 +879,6 @@ git-tree-sha1 = "68c173f4f449de5b438ee67ed0c9c748dc31a2ec"
uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a"
version = "0.3.28"
-[[deps.HypertextLiteral]]
-deps = ["Tricks"]
-git-tree-sha1 = "7134810b1afce04bbc1045ca1985fbe81ce17653"
-uuid = "ac1192a8-f4b3-4bfe-ba22-af5b92cd3ab2"
-version = "0.9.5"
-
[[deps.IfElse]]
git-tree-sha1 = "debdd00ffef04665ccbb3e150747a77560e8fad1"
uuid = "615f187c-cbe4-4ef1-ba3b-2fcf58d6d173"
@@ -888,6 +902,12 @@ git-tree-sha1 = "33485b4e40d1df46c806498c73ea32dc17475c59"
uuid = "cbc4b850-ae4b-5111-9e64-df94c024a13d"
version = "0.3.1"
+[[deps.ImageComponentAnalysis]]
+deps = ["AbstractTrees", "ConvexHulls2d", "DataFrames", "DataStructures", "ImageFiltering", "LeftChildRightSiblingTrees", "LinearAlgebra", "OffsetArrays", "Parameters", "StaticArrays"]
+git-tree-sha1 = "0f1e954d54bafec3df4aa9d1ce1f110e311a9043"
+uuid = "d9b9e9a0-1569-11e9-2cb5-bbca914b0e89"
+version = "0.2.2"
+
[[deps.ImageContrastAdjustment]]
deps = ["ImageBase", "ImageCore", "ImageTransformations", "Parameters"]
git-tree-sha1 = "eb3d4365a10e3f3ecb3b115e9d12db131d28a386"
@@ -1121,12 +1141,6 @@ weakdeps = ["UnPack"]
[deps.JLD2.extensions]
UnPackExt = "UnPack"
-[[deps.JLFzf]]
-deps = ["REPL", "Random", "fzf_jll"]
-git-tree-sha1 = "82f7acdc599b65e0f8ccd270ffa1467c21cb647b"
-uuid = "1019f520-868f-41f5-a6de-eb00f4b6a39c"
-version = "0.1.11"
-
[[deps.JLLWrappers]]
deps = ["Artifacts", "Preferences"]
git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2"
@@ -1199,15 +1213,6 @@ git-tree-sha1 = "eb62a3deb62fc6d8822c0c4bef73e4412419c5d8"
uuid = "1d63c593-3942-5779-bab2-d838dc0a180e"
version = "18.1.8+0"
-[[deps.LRUCache]]
-git-tree-sha1 = "5519b95a490ff5fe629c4a7aa3b3dfc9160498b3"
-uuid = "8ac3fa9e-de4c-5943-b1dc-09c6b5f20637"
-version = "1.6.2"
-weakdeps = ["Serialization"]
-
- [deps.LRUCache.extensions]
- SerializationExt = ["Serialization"]
-
[[deps.LZO_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "1c602b1127f4751facb671441ca72715cc95938a"
@@ -1219,35 +1224,12 @@ git-tree-sha1 = "dda21b8cbd6a6c40d9d02a73230f9d70fed6918c"
uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
version = "1.4.0"
-[[deps.Latexify]]
-deps = ["Format", "Ghostscript_jll", "InteractiveUtils", "LaTeXStrings", "MacroTools", "Markdown", "OrderedCollections", "Requires"]
-git-tree-sha1 = "44f93c47f9cd6c7e431f2f2091fcba8f01cd7e8f"
-uuid = "23fbe1c1-3f47-55db-b15f-69d7ec21a316"
-version = "0.16.10"
-
- [deps.Latexify.extensions]
- DataFramesExt = "DataFrames"
- SparseArraysExt = "SparseArrays"
- SymEngineExt = "SymEngine"
- TectonicExt = "tectonic_jll"
-
- [deps.Latexify.weakdeps]
- DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
- SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
- SymEngine = "123dc426-2d89-5057-bbad-38513e3affd8"
- tectonic_jll = "d7dd28d6-a5e6-559c-9131-7eb760cdacc5"
-
[[deps.LayoutPointers]]
deps = ["ArrayInterface", "LinearAlgebra", "ManualMemory", "SIMDTypes", "Static", "StaticArrayInterface"]
git-tree-sha1 = "a9eaadb366f5493a5654e843864c13d8b107548c"
uuid = "10f19ff3-798f-405d-979b-55457f8fc047"
version = "0.1.17"
-[[deps.LazilyInitializedFields]]
-git-tree-sha1 = "0f2da712350b020bc3957f269c9caad516383ee0"
-uuid = "0e77f7df-68c5-4e49-93ce-4cd80f5598bf"
-version = "1.3.0"
-
[[deps.LazyArtifacts]]
deps = ["Artifacts", "Pkg"]
uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
@@ -1258,6 +1240,12 @@ git-tree-sha1 = "a560dd966b386ac9ae60bdd3a3d3a326062d3c3e"
uuid = "8cdb02fc-e678-4876-92c5-9defec4f444e"
version = "0.3.1"
+[[deps.LeftChildRightSiblingTrees]]
+deps = ["AbstractTrees"]
+git-tree-sha1 = "95ba48564903b43b2462318aa243ee79d81135ff"
+uuid = "1d6d02ad-be62-4b6b-8a6d-2f90e265016e"
+version = "0.2.1"
+
[[deps.LibCURL]]
deps = ["LibCURL_jll", "MozillaCACerts_jll"]
uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
@@ -1346,6 +1334,12 @@ git-tree-sha1 = "8e6a74641caf3b84800f2ccd55dc7ab83893c10b"
uuid = "d3a379c0-f9a3-5b72-a4c0-6bf4d2e8af0f"
version = "2.17.0+0"
+[[deps.Loess]]
+deps = ["Distances", "LinearAlgebra", "Statistics", "StatsAPI"]
+git-tree-sha1 = "f749e7351f120b3566e5923fefdf8e52ba5ec7f9"
+uuid = "4345ca2d-374a-55d4-8d30-97f9976e7612"
+version = "0.6.4"
+
[[deps.LogExpFunctions]]
deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"]
git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f"
@@ -1421,12 +1415,6 @@ git-tree-sha1 = "c731269d5a2c85ffdc689127a9ba6d73e978a4b1"
uuid = "20f20a25-4f0e-4fdf-b5d1-57303727442b"
version = "0.9.0"
-[[deps.Malt]]
-deps = ["Distributed", "Logging", "RelocatableFolders", "Serialization", "Sockets"]
-git-tree-sha1 = "636abf4fb184be05888dacc0b636fe0911d6d746"
-uuid = "36869731-bdee-424d-aa32-cab38c994e3b"
-version = "1.2.0"
-
[[deps.ManualMemory]]
git-tree-sha1 = "bcaef4fc7a0cfe2cba636d84cda54b5e4e4ca3cd"
uuid = "d125e4d3-2237-4719-b19c-fa641b8a4667"
@@ -1459,10 +1447,11 @@ deps = ["Artifacts", "Libdl"]
uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1"
version = "2.28.6+0"
-[[deps.Measures]]
-git-tree-sha1 = "c13304c81eec1ed3af7fc20e75fb6b26092a1102"
-uuid = "442fdcdd-2543-5da2-b0f3-8c86c306513e"
-version = "0.3.2"
+[[deps.MeshIO]]
+deps = ["ColorTypes", "FileIO", "GeometryBasics", "Printf"]
+git-tree-sha1 = "c009236e222df68e554c7ce5c720e4a33cc0c23f"
+uuid = "7269a6da-0436-5bbc-96c2-40638cbb6118"
+version = "0.5.3"
[[deps.MetaGraphs]]
deps = ["Graphs", "JLD2", "Random"]
@@ -1491,6 +1480,12 @@ version = "0.1.2"
uuid = "a63ad114-7e13-5084-954f-fe012c677804"
version = "1.11.0"
+[[deps.ModernGL]]
+deps = ["Libdl"]
+git-tree-sha1 = "ac6cb1d8807a05cf1acc9680e09d2294f9d33956"
+uuid = "66fc600b-dfda-50eb-8b99-91cfa97b1301"
+version = "1.1.8"
+
[[deps.MosaicViews]]
deps = ["MappedArrays", "OffsetArrays", "PaddedViews", "StackViews"]
git-tree-sha1 = "7b86a5d4d70a9f5cdf2dacb3cbe6d251d1a61dbe"
@@ -1501,12 +1496,6 @@ version = "0.3.4"
uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
version = "2023.12.12"
-[[deps.MsgPack]]
-deps = ["Serialization"]
-git-tree-sha1 = "f5db02ae992c260e4826fe78c942954b48e1d9c2"
-uuid = "99f44e22-a591-53d1-9472-aa23ef4bd671"
-version = "1.2.1"
-
[[deps.NaNMath]]
deps = ["OpenLibm_jll"]
git-tree-sha1 = "9b8215b1ee9e78a293f99797cd31375471b2bcae"
@@ -1720,12 +1709,6 @@ git-tree-sha1 = "f9501cc0430a26bc3d156ae1b5b0c1b47af4d6da"
uuid = "eebad327-c553-4316-9ea0-9fa01ccd7688"
version = "0.3.3"
-[[deps.PlotThemes]]
-deps = ["PlotUtils", "Statistics"]
-git-tree-sha1 = "41031ef3a1be6f5bbbf3e8073f210556daeae5ca"
-uuid = "ccf2f8ad-2431-5c83-bf29-c5338b663b6a"
-version = "3.3.0"
-
[[deps.PlotUtils]]
deps = ["ColorSchemes", "Colors", "Dates", "PrecompileTools", "Printf", "Random", "Reexport", "StableRNGs", "Statistics"]
git-tree-sha1 = "3ca9a356cd2e113c420f2c13bea19f8d3fb1cb18"
@@ -1738,38 +1721,6 @@ git-tree-sha1 = "56baf69781fc5e61607c3e46227ab17f7040ffa2"
uuid = "a03496cd-edff-5a9b-9e67-9cda94a718b5"
version = "0.8.19"
-[[deps.Plots]]
-deps = ["Base64", "Contour", "Dates", "Downloads", "FFMPEG", "FixedPointNumbers", "GR", "JLFzf", "JSON", "LaTeXStrings", "Latexify", "LinearAlgebra", "Measures", "NaNMath", "Pkg", "PlotThemes", "PlotUtils", "PrecompileTools", "Printf", "REPL", "Random", "RecipesBase", "RecipesPipeline", "Reexport", "RelocatableFolders", "Requires", "Scratch", "Showoff", "SparseArrays", "Statistics", "StatsBase", "TOML", "UUIDs", "UnicodeFun", "Unzip"]
-git-tree-sha1 = "12ce661880f8e309569074a61d3767e5756a199f"
-uuid = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
-version = "1.41.1"
-
- [deps.Plots.extensions]
- FileIOExt = "FileIO"
- GeometryBasicsExt = "GeometryBasics"
- IJuliaExt = "IJulia"
- ImageInTerminalExt = "ImageInTerminal"
- UnitfulExt = "Unitful"
-
- [deps.Plots.weakdeps]
- FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
- GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326"
- IJulia = "7073ff75-c697-5162-941a-fcdaad2a7d2a"
- ImageInTerminal = "d8c32880-2388-543b-8c61-d9f865259254"
- Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d"
-
-[[deps.Pluto]]
-deps = ["Base64", "Configurations", "Dates", "Downloads", "ExpressionExplorer", "FileWatching", "GracefulPkg", "HTTP", "HypertextLiteral", "InteractiveUtils", "LRUCache", "Logging", "LoggingExtras", "MIMEs", "Malt", "Markdown", "MsgPack", "Pkg", "PlutoDependencyExplorer", "PrecompileSignatures", "PrecompileTools", "REPL", "RegistryInstances", "RelocatableFolders", "Scratch", "Sockets", "TOML", "Tables", "URIs", "UUIDs"]
-git-tree-sha1 = "64ff678257a6e59364ac2c094642348104c17443"
-uuid = "c3e4b0f8-55cb-11ea-2926-15256bba5781"
-version = "0.20.18"
-
-[[deps.PlutoDependencyExplorer]]
-deps = ["ExpressionExplorer", "InteractiveUtils", "Markdown"]
-git-tree-sha1 = "c3e5073a977b1c58b2d55c1ec187c3737e64e6af"
-uuid = "72656b73-756c-7461-726b-72656b6b696b"
-version = "1.2.2"
-
[[deps.PolyesterWeave]]
deps = ["BitTwiddlingConvenienceFunctions", "CPUSummary", "IfElse", "Static", "ThreadingUtilities"]
git-tree-sha1 = "645bed98cd47f72f67316fd42fc47dee771aefcd"
@@ -1805,11 +1756,6 @@ git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3"
uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720"
version = "1.4.3"
-[[deps.PrecompileSignatures]]
-git-tree-sha1 = "18ef344185f25ee9d51d80e179f8dad33dc48eb1"
-uuid = "91cefc8d-f054-46dc-8f8c-26e11d7c5411"
-version = "3.0.3"
-
[[deps.PrecompileTools]]
deps = ["Preferences"]
git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f"
@@ -1844,48 +1790,12 @@ git-tree-sha1 = "1d36ef11a9aaf1e8b74dacc6a731dd1de8fd493d"
uuid = "43287f4e-b6f4-7ad1-bb20-aadabca52c3d"
version = "1.3.0"
-[[deps.PyCall]]
-deps = ["Conda", "Dates", "Libdl", "LinearAlgebra", "MacroTools", "Serialization", "VersionParsing"]
-git-tree-sha1 = "9816a3826b0ebf49ab4926e2b18842ad8b5c8f04"
-uuid = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0"
-version = "1.96.4"
-
-[[deps.PyPlot]]
-deps = ["Colors", "LaTeXStrings", "PyCall", "Sockets", "Test", "VersionParsing"]
-git-tree-sha1 = "d2c2b8627bbada1ba00af2951946fb8ce6012c05"
-uuid = "d330b81b-6aea-500a-939a-2ce795aea3ee"
-version = "2.11.6"
-
[[deps.QOI]]
deps = ["ColorTypes", "FileIO", "FixedPointNumbers"]
git-tree-sha1 = "8b3fc30bc0390abdce15f8822c889f669baed73d"
uuid = "4b34888f-f399-49d4-9bb3-47ed5cae4e65"
version = "1.0.1"
-[[deps.Qt6Base_jll]]
-deps = ["Artifacts", "CompilerSupportLibraries_jll", "Fontconfig_jll", "Glib_jll", "JLLWrappers", "Libdl", "Libglvnd_jll", "OpenSSL_jll", "Vulkan_Loader_jll", "Xorg_libSM_jll", "Xorg_libXext_jll", "Xorg_libXrender_jll", "Xorg_libxcb_jll", "Xorg_xcb_util_cursor_jll", "Xorg_xcb_util_image_jll", "Xorg_xcb_util_keysyms_jll", "Xorg_xcb_util_renderutil_jll", "Xorg_xcb_util_wm_jll", "Zlib_jll", "libinput_jll", "xkbcommon_jll"]
-git-tree-sha1 = "eb38d376097f47316fe089fc62cb7c6d85383a52"
-uuid = "c0090381-4147-56d7-9ebc-da0b1113ec56"
-version = "6.8.2+1"
-
-[[deps.Qt6Declarative_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Qt6Base_jll", "Qt6ShaderTools_jll"]
-git-tree-sha1 = "da7adf145cce0d44e892626e647f9dcbe9cb3e10"
-uuid = "629bc702-f1f5-5709-abd5-49b8460ea067"
-version = "6.8.2+1"
-
-[[deps.Qt6ShaderTools_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Qt6Base_jll"]
-git-tree-sha1 = "9eca9fc3fe515d619ce004c83c31ffd3f85c7ccf"
-uuid = "ce943373-25bb-56aa-8eca-768745ed7b5a"
-version = "6.8.2+1"
-
-[[deps.Qt6Wayland_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Qt6Base_jll", "Qt6Declarative_jll"]
-git-tree-sha1 = "e1d5e16d0f65762396f9ca4644a5f4ddab8d452b"
-uuid = "e99dba38-086e-5de3-a5b1-6e4c66e897c3"
-version = "6.8.2+1"
-
[[deps.QuadGK]]
deps = ["DataStructures", "LinearAlgebra"]
git-tree-sha1 = "9da16da70037ba9d701192e27befedefb91ec284"
@@ -1941,12 +1851,6 @@ git-tree-sha1 = "5c3d09cc4f31f5fc6af001c250bf1278733100ff"
uuid = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
version = "1.3.4"
-[[deps.RecipesPipeline]]
-deps = ["Dates", "NaNMath", "PlotUtils", "PrecompileTools", "RecipesBase"]
-git-tree-sha1 = "45cf9fd0ca5839d06ef333c8201714e888486342"
-uuid = "01d81517-befc-4cb6-b9ec-a95719d0359c"
-version = "0.6.12"
-
[[deps.Reexport]]
git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b"
uuid = "189a3867-3050-52da-a836-e630ba90ab69"
@@ -1958,12 +1862,6 @@ git-tree-sha1 = "4618ed0da7a251c7f92e869ae1a19c74a7d2a7f9"
uuid = "dee08c22-ab7f-5625-9660-a9af2021b33f"
version = "0.3.2"
-[[deps.RegistryInstances]]
-deps = ["LazilyInitializedFields", "Pkg", "TOML", "Tar"]
-git-tree-sha1 = "ffd19052caf598b8653b99404058fce14828be51"
-uuid = "2792f1a3-b283-48e8-9a74-f99dce5104f3"
-version = "0.1.0"
-
[[deps.RelocatableFolders]]
deps = ["SHA", "Scratch"]
git-tree-sha1 = "ffdaf70d81cf6ff22c2b6e733c900c3321cab864"
@@ -2046,6 +1944,12 @@ git-tree-sha1 = "456f610ca2fbd1c14f5fcf31c6bfadc55e7d66e0"
uuid = "476501e8-09a2-5ece-8869-fb82de89a1fa"
version = "0.6.43"
+[[deps.SavitzkyGolay]]
+deps = ["LinearAlgebra"]
+git-tree-sha1 = "7bbc5949a42f53f4fca1a0157c72f9d3f78050d1"
+uuid = "c4bf5708-b6a6-4fbe-bcd0-6850ed671584"
+version = "0.9.1"
+
[[deps.SciMLPublic]]
git-tree-sha1 = "ed647f161e8b3f2973f24979ec074e8d084f1bee"
uuid = "431bcebd-1456-4ced-9d72-93c2757fff0b"
@@ -2396,11 +2300,6 @@ git-tree-sha1 = "0c45878dcfdcfa8480052b6ab162cdd138781742"
uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
version = "0.11.3"
-[[deps.Tricks]]
-git-tree-sha1 = "372b90fe551c019541fafc6ff034199dc19c8436"
-uuid = "410a4b4d-49e4-4fbc-ab6d-cb71b17b3775"
-version = "0.1.12"
-
[[deps.TriplotBase]]
git-tree-sha1 = "4d4ed7f294cda19382ff7de4c137d24d16adc89b"
uuid = "981d1d27-644d-49a2-9326-4793e63143c3"
@@ -2458,28 +2357,12 @@ version = "1.25.0"
Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316"
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
-[[deps.Unzip]]
-git-tree-sha1 = "ca0969166a028236229f63514992fc073799bb78"
-uuid = "41fe7b60-77ed-43a1-b4f0-825fd5a5650d"
-version = "0.2.0"
-
[[deps.VectorizationBase]]
deps = ["ArrayInterface", "CPUSummary", "HostCPUFeatures", "IfElse", "LayoutPointers", "Libdl", "LinearAlgebra", "SIMDTypes", "Static", "StaticArrayInterface"]
git-tree-sha1 = "d1d9a935a26c475ebffd54e9c7ad11627c43ea85"
uuid = "3d5dd08c-fd9d-11e8-17fa-ed2836048c2f"
version = "0.21.72"
-[[deps.VersionParsing]]
-git-tree-sha1 = "58d6e80b4ee071f5efd07fda82cb9fbe17200868"
-uuid = "81def892-9a0e-5fdd-b105-ffc91e053289"
-version = "1.3.0"
-
-[[deps.Vulkan_Loader_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Wayland_jll", "Xorg_libX11_jll", "Xorg_libXrandr_jll", "xkbcommon_jll"]
-git-tree-sha1 = "2f0486047a07670caad3a81a075d2e518acc5c59"
-uuid = "a44049a8-05dd-5a78-86c9-5fde0876e88c"
-version = "1.3.243+0"
-
[[deps.Wayland_jll]]
deps = ["Artifacts", "EpollShim_jll", "Expat_jll", "JLLWrappers", "Libdl", "Libffi_jll"]
git-tree-sha1 = "96478df35bbc2f3e1e791bc7a3d0eeee559e60e9"
@@ -2521,18 +2404,6 @@ git-tree-sha1 = "fee71455b0aaa3440dfdd54a9a36ccef829be7d4"
uuid = "ffd25f8a-64ca-5728-b0f7-c24cf3aae800"
version = "5.8.1+0"
-[[deps.Xorg_libICE_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "a3ea76ee3f4facd7a64684f9af25310825ee3668"
-uuid = "f67eecfb-183a-506d-b269-f58e52b52d7c"
-version = "1.1.2+0"
-
-[[deps.Xorg_libSM_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libICE_jll"]
-git-tree-sha1 = "9c7ad99c629a44f81e7799eb05ec2746abb5d588"
-uuid = "c834827a-8449-5923-a945-d239c165b7dd"
-version = "1.2.6+0"
-
[[deps.Xorg_libX11_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libxcb_jll", "Xorg_xtrans_jll"]
git-tree-sha1 = "b5899b25d17bf1889d25906fb9deed5da0c15b3b"
@@ -2623,42 +2494,6 @@ git-tree-sha1 = "e3150c7400c41e207012b41659591f083f3ef795"
uuid = "cc61e674-0454-545c-8b26-ed2c68acab7a"
version = "1.1.3+0"
-[[deps.Xorg_xcb_util_cursor_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_xcb_util_image_jll", "Xorg_xcb_util_jll", "Xorg_xcb_util_renderutil_jll"]
-git-tree-sha1 = "c5bf2dad6a03dfef57ea0a170a1fe493601603f2"
-uuid = "e920d4aa-a673-5f3a-b3d7-f755a4d47c43"
-version = "0.1.5+0"
-
-[[deps.Xorg_xcb_util_image_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_xcb_util_jll"]
-git-tree-sha1 = "f4fc02e384b74418679983a97385644b67e1263b"
-uuid = "12413925-8142-5f55-bb0e-6d7ca50bb09b"
-version = "0.4.1+0"
-
-[[deps.Xorg_xcb_util_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libxcb_jll"]
-git-tree-sha1 = "68da27247e7d8d8dafd1fcf0c3654ad6506f5f97"
-uuid = "2def613f-5ad1-5310-b15b-b15d46f528f5"
-version = "0.4.1+0"
-
-[[deps.Xorg_xcb_util_keysyms_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_xcb_util_jll"]
-git-tree-sha1 = "44ec54b0e2acd408b0fb361e1e9244c60c9c3dd4"
-uuid = "975044d2-76e6-5fbe-bf08-97ce7c6574c7"
-version = "0.4.1+0"
-
-[[deps.Xorg_xcb_util_renderutil_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_xcb_util_jll"]
-git-tree-sha1 = "5b0263b6d080716a02544c55fdff2c8d7f9a16a0"
-uuid = "0d47668e-0667-5a69-a72c-f761630bfb7e"
-version = "0.3.10+0"
-
-[[deps.Xorg_xcb_util_wm_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_xcb_util_jll"]
-git-tree-sha1 = "f233c83cad1fa0e70b7771e0e21b061a116f2763"
-uuid = "c22f9ab0-d5fe-5066-847c-f4bb1cd4e361"
-version = "0.4.2+0"
-
[[deps.Xorg_xkbcomp_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libxkbfile_jll"]
git-tree-sha1 = "801a858fc9fb90c11ffddee1801bb06a738bda9b"
@@ -2706,18 +2541,6 @@ git-tree-sha1 = "120f41bc9540b8f137e5e5dea65845ee4f089f9e"
uuid = "0fc3237b-ac94-5853-b45c-d43d59a06200"
version = "2.57.1+0"
-[[deps.eudev_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "c3b0e6196d50eab0c5ed34021aaa0bb463489510"
-uuid = "35ca27e7-8b34-5b7f-bca9-bdc33f59eb06"
-version = "3.2.14+0"
-
-[[deps.fzf_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "b6a34e0e0960190ac2a4363a1bd003504772d631"
-uuid = "214eeab7-80f7-51ab-84ad-2988db7cef09"
-version = "0.61.1+0"
-
[[deps.gdk_pixbuf_jll]]
deps = ["Artifacts", "Glib_jll", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Libtiff_jll", "Xorg_libX11_jll", "libpng_jll"]
git-tree-sha1 = "895f21b699121d1a57ecac57e65a852caf569254"
@@ -2736,14 +2559,6 @@ git-tree-sha1 = "51b5eeb3f98367157a7a12a1fb0aa5328946c03c"
uuid = "9a68df92-36a6-505f-a73e-abb412b6bfb4"
version = "0.2.3+0"
-[[deps.julia_mzML_imzML]]
-deps = ["Compat", "Libz", "Plots", "Pluto", "PyPlot"]
-git-tree-sha1 = "e1aceca0a4b5fad50f5947d3dd5b22a759ff86f3"
-repo-rev = "master"
-repo-url = "https://github.com/CINVESTAV-LABI/julia_mzML_imzML"
-uuid = "38eb50d3-2fb6-4afa-992a-964ed8562ed9"
-version = "0.0.1-DEV"
-
[[deps.libaom_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "4bba74fa59ab0755167ad24f98800fe5d727175b"
@@ -2767,24 +2582,12 @@ git-tree-sha1 = "9bf7903af251d2050b467f76bdbe57ce541f7f4f"
uuid = "1183f4f0-6f2a-5f1a-908b-139f9cdfea6f"
version = "0.2.2+0"
-[[deps.libevdev_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "56d643b57b188d30cccc25e331d416d3d358e557"
-uuid = "2db6ffa8-e38f-5e21-84af-90c45d0032cc"
-version = "1.13.4+0"
-
[[deps.libfdk_aac_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl"]
git-tree-sha1 = "646634dd19587a56ee2f1199563ec056c5f228df"
uuid = "f638f0a6-7fb0-5443-88ba-1cc74229b280"
version = "2.0.4+0"
-[[deps.libinput_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl", "eudev_jll", "libevdev_jll", "mtdev_jll"]
-git-tree-sha1 = "91d05d7f4a9f67205bd6cf395e488009fe85b499"
-uuid = "36db933b-70db-51c0-b978-0f229ee0e533"
-version = "1.28.1+0"
-
[[deps.libpng_jll]]
deps = ["Artifacts", "JLLWrappers", "Libdl", "Zlib_jll"]
git-tree-sha1 = "07b6a107d926093898e82b3b1db657ebe33134ec"
@@ -2815,19 +2618,13 @@ git-tree-sha1 = "86addc139bca85fdf9e7741e10977c45785727b7"
uuid = "337d8026-41b4-5cde-a456-74a10e5b31d1"
version = "1.11.3+0"
-[[deps.mtdev_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
-git-tree-sha1 = "b4d631fd51f2e9cdd93724ae25b2efc198b059b1"
-uuid = "009596ad-96f7-51b1-9f1b-5ce2d5e8a71e"
-version = "1.1.7+0"
-
[[deps.nghttp2_jll]]
deps = ["Artifacts", "Libdl"]
uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
version = "1.59.0+0"
[[deps.oneTBB_jll]]
-deps = ["Artifacts", "JLLWrappers", "Libdl"]
+deps = ["Artifacts", "JLLWrappers", "LazyArtifacts", "Libdl"]
git-tree-sha1 = "d5a767a3bb77135a99e433afe0eb14cd7f6914c3"
uuid = "1317d2d5-d96f-522e-a858-c73665f53c3e"
version = "2022.0.0+0"
diff --git a/Project.toml b/Project.toml
index eed2f9b..816068c 100644
--- a/Project.toml
+++ b/Project.toml
@@ -3,6 +3,7 @@ authors = ["JJSA"]
version = "0.1.0"
[deps]
+Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697"
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
@@ -10,19 +11,29 @@ ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4"
Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
+FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549"
+GLMakie = "e9467ef8-e4e7-5192-8a1a-b1aee30e663a"
Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e"
GenieFramework = "a59fdf5c-6bf0-4f5d-949c-a137c9e2f353"
+HistogramThresholding = "2c695a8d-9458-5d45-9878-1b8a99cf7853"
+ImageBinarization = "cbc4b850-ae4b-5111-9e64-df94c024a13d"
+ImageComponentAnalysis = "d9b9e9a0-1569-11e9-2cb5-bbca914b0e89"
+ImageContrastAdjustment = "f332f351-ec65-5f6a-b3d1-319c6670881a"
+ImageFiltering = "6a3955dd-da59-5b1f-98d4-e7296123deb5"
+ImageMorphology = "787d08f9-d448-5407-9aad-5290dd7ab264"
+ImageSegmentation = "80713f31-8817-5129-9cf8-209ff8fb23e1"
Images = "916415d5-f1e6-5110-898d-aaa5f9f070e0"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
Libz = "2ec943e9-cfe8-584d-b93d-64dcb6d567b7"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+Loess = "4345ca2d-374a-55d4-8d30-97f9976e7612"
Mmap = "a63ad114-7e13-5084-954f-fe012c677804"
NativeFileDialog = "e1fe445b-aa65-4df4-81c1-2041507f0fd4"
NaturalSort = "c020b1a1-e9b0-503a-9c33-f039bfc54a85"
PlotlyBase = "a03496cd-edff-5a9b-9e67-9cda94a718b5"
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+SavitzkyGolay = "c4bf5708-b6a6-4fbe-bcd0-6850ed671584"
Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91"
StipplePlotly = "ec984513-233d-481d-95b0-a3b58b97af2b"
-julia_mzML_imzML = "38eb50d3-2fb6-4afa-992a-964ed8562ed9"
diff --git a/README.md b/README.md
index df99a18..2affa30 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ https://codeberg.org/LabABI/JuliaMSI
~/Downloads/JuliaMSI-main/juliamsi
2. Without entering the Julia environment, launch the project in your terminal with the following command (which works for all operating systems):
```
- julia--project=. start_MSI_GUI.jl
+ julia --project=. start_MSI_GUI.jl
```
3. After the script has finished loading, you can open a [page](http://127.0.0.1:1481/) in your browser with the web app running.
diff --git a/app.jl b/app.jl
index 1737fa9..77f9cc9 100644
--- a/app.jl
+++ b/app.jl
@@ -24,153 +24,6 @@ using .MSI_src: MSIData, OpenMSIData, #=GetSpectrum,=# process_spectrum, Iterate
include("./julia_imzML_visual.jl")
-function load_registry(registry_path)
- if isfile(registry_path)
- try
- return JSON.parsefile(registry_path, dicttype=Dict{String, Any})
- catch e
- @error "Failed to parse registry.json: $e"
- return Dict{String, Any}()
- end
- end
- return Dict{String, Any}()
-end
-
-function extract_metadata(msi_data::MSIData, source_path::String)
- df = msi_data.spectrum_stats_df
- if df === nothing
- # This can happen if precompute_analytics hasn't been run
- # We can still return basic info
- return Dict(
- "summary" => [
- Dict("parameter" => "File Name", "value" => basename(source_path)),
- Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)),
- Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"),
- ],
- "global_min_mz" => nothing,
- "global_max_mz" => nothing
- )
- end
-
- summary_stats = [
- Dict("parameter" => "File Name", "value" => basename(source_path)),
- Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)),
- Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"),
- Dict("parameter" => "Global Min m/z", "value" => @sprintf("%.4f", msi_data.global_min_mz)),
- Dict("parameter" => "Global Max m/z", "value" => @sprintf("%.4f", msi_data.global_max_mz)),
- Dict("parameter" => "Mean TIC", "value" => @sprintf("%.2e", mean(df.TIC))),
- Dict("parameter" => "Mean BPI", "value" => @sprintf("%.2e", mean(df.BPI))),
- Dict("parameter" => "Mean # Points", "value" => @sprintf("%.1f", mean(df.NumPoints))),
- ]
-
- if hasproperty(df, :Mode)
- centroid_count = count(==(MSI_src.CENTROID), df.Mode)
- profile_count = count(==(MSI_src.PROFILE), df.Mode)
- unknown_count = count(==(MSI_src.UNKNOWN), df.Mode)
-
- push!(summary_stats, Dict("parameter" => "Centroid Spectra", "value" => string(centroid_count)))
- push!(summary_stats, Dict("parameter" => "Profile Spectra", "value" => string(profile_count)))
- if unknown_count > 0
- push!(summary_stats, Dict("parameter" => "Unknown Mode Spectra", "value" => string(unknown_count)))
- end
- end
-
- return Dict(
- "summary" => summary_stats,
- "global_min_mz" => msi_data.global_min_mz,
- "global_max_mz" => msi_data.global_max_mz
- )
-end
-
-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
- )
- if metadata !== nothing
- entry["metadata"] = metadata
- end
- registry[dataset_name] = entry
-
- try
- open(registry_path, "w") do f
- JSON.print(f, registry, 4)
- end
- catch e
- @error "Failed to write to registry.json: $e"
- end
-end
-
-function process_file_safely(file_path, masses, params, progress_message_ref, overall_progress_ref)
- local_msi_data = nothing
- dataset_name = replace(basename(file_path), r"\.imzML$"i => "")
- output_dir = joinpath("public", dataset_name)
- println("Processing: $dataset_name -> $output_dir")
-
- try
- # --- Load Data ---
- progress_message_ref = "Loading: $(basename(file_path))"
- local_msi_data = OpenMSIData(file_path)
- if !(local_msi_data.source isa ImzMLSource)
- @warn "Skipping non-imzML file: $(basename(file_path))"
- return (false, "Skipped: Not an imzML file")
- end
-
- # --- 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)
-
- # --- Extract metadata *after* it has been computed ---
- metadata = extract_metadata(local_msi_data, file_path)
-
- # --- Save Slices ---
- mkpath(output_dir) # Ensure output directory exists
-
- for (mass_idx, mass) in enumerate(masses)
- progress_message_ref = "File $(params.fileIdx)/$(params.nFiles): Saving slice for m/z=$mass"
-
- slice = slice_dict[mass]
- text_nmass = replace(string(mass), "." => "_")
- bitmap_filename = params.triqE ? "TrIQ_$(text_nmass).bmp" : "MSI_$(text_nmass).bmp"
- colorbar_filename = params.triqE ? "colorbar_TrIQ_$(text_nmass).png" : "colorbar_MSI_$(text_nmass).png"
-
- if all(iszero, slice)
- sliceQuant = zeros(UInt8, size(slice))
- @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)
- if params.medianF
- sliceQuant = round.(UInt8, median_filter(sliceQuant))
- end
- end
-
- 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)
- end
- end
-
- is_imzML = local_msi_data.source isa ImzMLSource
- update_registry(params.registry, dataset_name, file_path, metadata, is_imzML)
- return (true, "")
-
- catch e
- @error "File processing failed" file=file_path exception=(e, catch_backtrace())
- return (false, "File: $(basename(file_path)) - $(sprint(showerror, e))")
- finally
- if local_msi_data !== nothing
- # Cleanup
- end
- local_msi_data = nothing
- GC.gc(true)
- if Sys.islinux()
- ccall(:malloc_trim, Int32, (Int32,), 0)
- end
- end
-end
-
@genietools
# == Reactive code ==
@@ -316,7 +169,7 @@ end
@in selected_files = String[]
@out available_folders = String[]
@out image_available_folders = String[]
- @out registry_path = joinpath("public", "registry.json")
+ @out registry_path = abspath(joinpath(@__DIR__, "public", "registry.json"))
# Progress reporting
@out overall_progress = 0.0
@out progress_message = ""
@@ -502,7 +355,7 @@ end
# This handler correctly uses pick_file and loads the selected file
# as the active dataset for the UI.
- @onbutton btnSearch @time begin
+ @onbutton btnSearch begin
picked_route = pick_file(; filterlist="imzML,imzml,mzML,mzml")
if isempty(picked_route)
return
@@ -513,7 +366,7 @@ end
@async begin
try
- dataset_name = replace(basename(picked_route), r"(\.(imzML|imzml|mzML))$"i => "")
+ dataset_name = replace(basename(picked_route), r"(\.(imzML|imzml|mzML|mzml))$"i => "")
registry = load_registry(registry_path)
existing_entry = get(registry, dataset_name, nothing)
@@ -1864,7 +1717,7 @@ end
sleep(1.0) # Give frontend time to initialize
try
println("Synchronizing registry with filesystem on backend init...")
- reg_path = joinpath("public", "registry.json")
+ reg_path = abspath(joinpath(@__DIR__, "public", "registry.json"))
registry = isfile(reg_path) ? load_registry(reg_path) : Dict{String, Any}()
public_dirs = isdir("public") ? readdir("public") : []
diff --git a/app.jl.html b/app.jl.html
index ad97d23..e679cf7 100644
--- a/app.jl.html
+++ b/app.jl.html
@@ -138,6 +138,7 @@
label="Compare">
+
diff --git a/julia_imzML_visual.jl b/julia_imzML_visual.jl
index 439cd14..aff0afa 100644
--- a/julia_imzML_visual.jl
+++ b/julia_imzML_visual.jl
@@ -626,4 +626,151 @@ function warmup_init()
println("Pre-compilation finished.")
end
+end
+
+function load_registry(registry_path)
+ if isfile(registry_path)
+ try
+ return JSON.parsefile(registry_path, dicttype=Dict{String, Any})
+ catch e
+ @error "Failed to parse registry.json: $e"
+ return Dict{String, Any}()
+ end
+ end
+ return Dict{String, Any}()
+end
+
+function extract_metadata(msi_data::MSIData, source_path::String)
+ df = msi_data.spectrum_stats_df
+ if df === nothing
+ # This can happen if precompute_analytics hasn't been run
+ # We can still return basic info
+ return Dict(
+ "summary" => [
+ Dict("parameter" => "File Name", "value" => basename(source_path)),
+ Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)),
+ Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"),
+ ],
+ "global_min_mz" => nothing,
+ "global_max_mz" => nothing
+ )
+ end
+
+ summary_stats = [
+ Dict("parameter" => "File Name", "value" => basename(source_path)),
+ Dict("parameter" => "Number of Spectra", "value" => length(msi_data.spectra_metadata)),
+ Dict("parameter" => "Image Dimensions", "value" => "$(msi_data.image_dims[1]) x $(msi_data.image_dims[2])"),
+ Dict("parameter" => "Global Min m/z", "value" => @sprintf("%.4f", msi_data.global_min_mz)),
+ Dict("parameter" => "Global Max m/z", "value" => @sprintf("%.4f", msi_data.global_max_mz)),
+ Dict("parameter" => "Mean TIC", "value" => @sprintf("%.2e", mean(df.TIC))),
+ Dict("parameter" => "Mean BPI", "value" => @sprintf("%.2e", mean(df.BPI))),
+ Dict("parameter" => "Mean # Points", "value" => @sprintf("%.1f", mean(df.NumPoints))),
+ ]
+
+ if hasproperty(df, :Mode)
+ centroid_count = count(==(MSI_src.CENTROID), df.Mode)
+ profile_count = count(==(MSI_src.PROFILE), df.Mode)
+ unknown_count = count(==(MSI_src.UNKNOWN), df.Mode)
+
+ push!(summary_stats, Dict("parameter" => "Centroid Spectra", "value" => string(centroid_count)))
+ push!(summary_stats, Dict("parameter" => "Profile Spectra", "value" => string(profile_count)))
+ if unknown_count > 0
+ push!(summary_stats, Dict("parameter" => "Unknown Mode Spectra", "value" => string(unknown_count)))
+ end
+ end
+
+ return Dict(
+ "summary" => summary_stats,
+ "global_min_mz" => msi_data.global_min_mz,
+ "global_max_mz" => msi_data.global_max_mz
+ )
+end
+
+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
+ )
+ if metadata !== nothing
+ entry["metadata"] = metadata
+ end
+ registry[dataset_name] = entry
+
+ try
+ open(registry_path, "w") do f
+ JSON.print(f, registry, 4)
+ end
+ catch e
+ @error "Failed to write to registry.json: $e"
+ end
+end
+
+function process_file_safely(file_path, masses, params, progress_message_ref, overall_progress_ref)
+ local_msi_data = nothing
+ dataset_name = replace(basename(file_path), r"\.imzML$"i => "")
+ output_dir = joinpath("public", dataset_name)
+ println("Processing: $dataset_name -> $output_dir")
+
+ try
+ # --- Load Data ---
+ progress_message_ref = "Loading: $(basename(file_path))"
+ local_msi_data = OpenMSIData(file_path)
+ if !(local_msi_data.source isa ImzMLSource)
+ @warn "Skipping non-imzML file: $(basename(file_path))"
+ return (false, "Skipped: Not an imzML file")
+ end
+
+ # --- 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)
+
+ # --- Extract metadata *after* it has been computed ---
+ metadata = extract_metadata(local_msi_data, file_path)
+
+ # --- Save Slices ---
+ mkpath(output_dir) # Ensure output directory exists
+
+ for (mass_idx, mass) in enumerate(masses)
+ progress_message_ref = "File $(params.fileIdx)/$(params.nFiles): Saving slice for m/z=$mass"
+
+ slice = slice_dict[mass]
+ text_nmass = replace(string(mass), "." => "_")
+ bitmap_filename = params.triqE ? "TrIQ_$(text_nmass).bmp" : "MSI_$(text_nmass).bmp"
+ colorbar_filename = params.triqE ? "colorbar_TrIQ_$(text_nmass).png" : "colorbar_MSI_$(text_nmass).png"
+
+ if all(iszero, slice)
+ sliceQuant = zeros(UInt8, size(slice))
+ @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)
+ if params.medianF
+ sliceQuant = round.(UInt8, median_filter(sliceQuant))
+ end
+ end
+
+ 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)
+ end
+ end
+
+ is_imzML = local_msi_data.source isa ImzMLSource
+ update_registry(params.registry, dataset_name, file_path, metadata, is_imzML)
+ return (true, "")
+
+ catch e
+ @error "File processing failed" file=file_path exception=(e, catch_backtrace())
+ return (false, "File: $(basename(file_path)) - $(sprint(showerror, e))")
+ finally
+ if local_msi_data !== nothing
+ # Cleanup
+ end
+ local_msi_data = nothing
+ GC.gc(true)
+ if Sys.islinux()
+ ccall(:malloc_trim, Int32, (Int32,), 0)
+ end
+ end
end
\ No newline at end of file
diff --git a/mask.jl b/mask.jl
new file mode 100644
index 0000000..349a5f2
--- /dev/null
+++ b/mask.jl
@@ -0,0 +1,897 @@
+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
+
+# Plot Handling
+include("./julia_imzML_visual.jl")
+
+# Image Processing Pipeline
+include("src/ImageProcessing.jl")
+using .ImageProcessing
+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 =
+ ImageProcessing.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[]
+ @private registry_init_done = 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
+
+ # --- 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)
+ println("Selected folder: $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
+ 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_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}()
+
+ # 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]["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)",
+ "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
+
+ @onchange isready begin
+ if isready && !registry_init_done
+ @async begin # Run asynchronously to not block startup
+ 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
+ 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
diff --git a/mask.jl.html b/mask.jl.html
new file mode 100644
index 0000000..5d5492d
--- /dev/null
+++ b/mask.jl.html
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
Mask Generation Workflow
+
+
+
Step 1: Select Slice
+
+
+
+
+
+
+
Step 2: Create/Upload Mask
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
+ Manual Mask Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/css/genieapp.css b/public/css/genieapp.css
index f50c1f7..98ddaf2 100644
--- a/public/css/genieapp.css
+++ b/public/css/genieapp.css
@@ -90,6 +90,12 @@
overflow-y: auto; /* Add scroll if content overflows */
}
+.pixelated-plot svg image {
+ image-rendering: pixelated;
+ image-rendering: -moz-crisp-edges; /* Firefox */
+ image-rendering: crisp-edges;
+}
+
*{
font-family: 'Roboto', 'Lato', sans-serif;
}
\ No newline at end of file
diff --git a/routes.jl b/routes.jl
new file mode 100644
index 0000000..0d7c32c
--- /dev/null
+++ b/routes.jl
@@ -0,0 +1,3 @@
+# Include all your app modules
+include("app.jl")
+include("mask.jl")
diff --git a/src/ImageProcessing.jl b/src/ImageProcessing.jl
new file mode 100644
index 0000000..2f0f8a9
--- /dev/null
+++ b/src/ImageProcessing.jl
@@ -0,0 +1,47 @@
+module ImageProcessing
+
+using Images
+using ImageBinarization
+using ImageMorphology
+using ImageComponentAnalysis
+
+export process_image_pipeline
+
+# ===================================================================
+# CORE PROCESSING PIPELINE
+# ===================================================================
+
+function process_image_pipeline(gray_img;
+ otsu_scale=1.0,
+ noise_size_percent=0.1,
+ hole_size_percent=0.05,
+ smoothing=2)
+
+ # --- Step 1: Otsu Binarization ---
+ otsu_threshold = find_threshold(gray_img, Otsu())
+ adjusted_threshold = otsu_threshold * otsu_scale
+ binary_img = gray_img .>= adjusted_threshold
+
+ # --- Smart Parameter Scaling ---
+ image_area = length(gray_img)
+ noise_size_pixels = round(Int, image_area * noise_size_percent)
+ hole_size_pixels = round(Int, image_area * hole_size_percent)
+
+ # --- Step 2: Remove Small White Regions (Noise) ---
+ # area_opening is the correct morphological operation for this.
+ noise_removed_img = area_opening(binary_img, min_area=noise_size_pixels)
+
+ # --- Step 3: Fill Small Black Holes ---
+ # area_closing is the dual of area_opening and fills holes.
+ holes_filled_img = area_closing(noise_removed_img, min_area=hole_size_pixels)
+
+ # --- Step 4: Smooth Edges ---
+ # A morphological closing with a small disk smooths outlines.
+ smoothing_kernel = ones(Bool, (smoothing, smoothing))
+ smoothed_img = closing(holes_filled_img, smoothing_kernel)
+
+ # --- Return all intermediate steps for visualization ---
+ return binary_img, noise_removed_img, holes_filled_img, smoothed_img
+end
+
+end
diff --git a/src/MSI_src.jl b/src/MSI_src.jl
index 444dce5..19c0cf6 100644
--- a/src/MSI_src.jl
+++ b/src/MSI_src.jl
@@ -1,7 +1,40 @@
+# MSI_src.jl
+
module MSI_src
-# Export the public API
-export OpenMSIData, GetSpectrum, IterateSpectra, ImportMzmlFile, load_slices, plot_slices, plot_slice, get_total_spectrum, get_average_spectrum, LoadMzml, LoadSpectra, precompute_analytics
+# Export the public MSI API
+export OpenMSIData,
+ GetSpectrum,
+ IterateSpectra,
+ ImportMzmlFile,
+ load_slices,
+ plot_slices,
+ plot_slice,
+ get_total_spectrum,
+ get_average_spectrum,
+ LoadMzml,
+ precompute_analytics,
+ process_spectrum
+
+# Export the public Preprocessing API
+export FeatureMatrix,
+ run_preprocessing_pipeline,
+ qc_is_empty,
+ qc_is_regular,
+ transform_intensity,
+ smooth_spectrum,
+ snip_baseline,
+ tic_normalize,
+ pqn_normalize,
+ detect_peaks_profile,
+ align_peaks_lowess,
+ bin_peaks,
+ plot_stage_spectrum,
+ calculate_ppm_error,
+ calculate_resolution_fwhm,
+ analyze_mass_accuracy,
+ generate_qc_report,
+ get_common_calibration_standards
# Include all source files directly into the main module
include("MSIData.jl")
@@ -9,6 +42,7 @@ include("ParserHelpers.jl")
include("mzML.jl")
include("imzML.jl")
include("MzmlConverter.jl")
+include("Preprocessing.jl")
# --- Main Entry Point --- #
diff --git a/src/MzmlConverter.jl b/src/MzmlConverter.jl
index 4142c41..129cdf3 100644
--- a/src/MzmlConverter.jl
+++ b/src/MzmlConverter.jl
@@ -1,3 +1,4 @@
+# src/MzmlConverter.jl
"""
This file contains the workflow for converting .mzML files (with one spectrum per pixel)
into a proper .imzML/.ibd file pair, using a separate synchronization file.
diff --git a/src/Preprocessing.jl b/src/Preprocessing.jl
new file mode 100644
index 0000000..62e1e30
--- /dev/null
+++ b/src/Preprocessing.jl
@@ -0,0 +1,979 @@
+# src/Preprocessing.jl
+"""
+This module provides a comprehensive workflow for mass spectrometry imaging (MSI) data
+preprocessing, inspired by the functionality of the R package MALDIquant. It includes
+functions for quality control, intensity transformation, smoothing, baseline correction,
+normalization, peak picking, alignment, and feature matrix generation.
+"""
+
+# =============================================================================
+# Dependencies
+# =============================================================================
+
+using Statistics # For mean, median
+using StatsBase # For mad (Median Absolute Deviation)
+using SavitzkyGolay # For SavitzkyGolay filtering
+using Dates # For now()
+using CSV # For writing CSV files
+using DataFrames # For creating dataframes
+
+# =============================================================================
+# Data Structures
+# =============================================================================
+
+"""
+ FeatureMatrix
+
+Structure to hold the final feature matrix, including m/z bin boundaries and sample indices.
+
+# Fields
+- `matrix`: The numerical matrix where rows are samples and columns are features (m/z bins).
+- `mz_bins`: A vector of tuples `(low_mz, high_mz)` for each feature column.
+- `sample_ids`: A vector of indices corresponding to the original spectra.
+"""
+struct FeatureMatrix
+ matrix::Array{Float64,2} # samples × features
+ mz_bins::Vector{Tuple{Float64,Float64}} # [(low, high), ...]
+ sample_ids::Vector{Int}
+end
+
+# =============================================================================
+# 0) Quality Control (QC)
+# =============================================================================
+
+"""
+ qc_is_empty(mz, intensity) -> Bool
+
+Returns `true` if the spectrum is empty or contains no finite intensity data.
+"""
+qc_is_empty(mz::AbstractVector, intensity::AbstractVector)::Bool =
+ isempty(mz) || isempty(intensity) || all(!isfinite, intensity)
+
+"""
+ qc_is_regular(mz) -> Bool
+
+Checks that the m/z axis is monotonically non-decreasing, as expected in a profile spectrum.
+"""
+function qc_is_regular(mz::AbstractVector)
+ n = length(mz)
+ n < 2 && return true
+ @inbounds for i in 2:n
+ if mz[i] < mz[i-1]
+ return false
+ end
+ end
+ return true
+end
+
+# =============================================================================
+# 1) Intensity Transformation & Smoothing
+# =============================================================================
+
+"""
+ transform_intensity(intensity; method=:sqrt) -> Vector
+
+Applies a variance-stabilizing transformation to the intensity vector.
+Supported methods: `:sqrt` (default) and `:log1p`.
+"""
+function transform_intensity(intensity::AbstractVector{<:Real}; method::Symbol=:sqrt)
+ if method === :sqrt
+ return sqrt.(max.(zero(eltype(intensity)), intensity))
+ elseif method === :log1p
+ return log1p.(max.(zero(eltype(intensity)), intensity))
+ else
+ return collect(float.(intensity))
+ end
+end
+
+"""
+ smooth_spectrum(y; window=21, order=2) -> Vector
+
+Applies a Savitzky–Golay filter if `SavitzkyGolay.jl` is available.
+Otherwise, falls back to a simple (non-phase-correct) moving average.
+"""
+function smooth_spectrum(y::AbstractVector{<:Real}; window::Int=9, order::Int=2)
+ win = isodd(window) ? window : window + 1
+ res = SavitzkyGolay.savitzky_golay(collect(float.(y)), win, order)
+ return res.y
+end
+
+# =============================================================================
+# 2) Baseline Correction
+# =============================================================================
+
+"""
+ snip_baseline(y, iterations=100) -> Vector
+
+Estimates the baseline using a simple 1D SNIP (Statistics-sensitive Non-linear
+Iterative Peak-clipping) algorithm. `iterations` controls the aggressiveness.
+"""
+function snip_baseline(y::AbstractVector{<:Real}, iterations::Int=100)
+ n = length(y)
+ b = collect(float.(y)) # work copy
+ buf = similar(b)
+ for k in 1:iterations
+ copyto!(buf, b)
+ @inbounds for i in 2:n-1
+ buf[i] = min(b[i], 0.5 * (b[i-1] + b[i+1]))
+ end
+ # Handle endpoints
+ buf[1] = min(b[1], b[2])
+ buf[end] = min(b[end], b[end-1])
+ b, buf = buf, b # Swap buffers
+ end
+ return b
+end
+
+# =============================================================================
+# 3) Intensity Normalization
+# =============================================================================
+
+"""
+ tic_normalize(y) -> Vector
+
+Normalizes intensities to the Total Ion Current (TIC). If the sum is zero, returns a copy.
+"""
+function tic_normalize(y::AbstractVector{<:Real})
+ s = sum(y)
+ return s <= 0 ? collect(float.(y)) : collect(float.(y)) ./ s
+end
+
+"""
+ pqn_normalize(M) -> Matrix
+
+Performs Probabilistic Quotient Normalization on a matrix `M` where columns are spectra.
+"""
+function pqn_normalize(M::AbstractMatrix{<:Real})
+ M_float = collect(float.(M))
+ # Calculate reference spectrum (median across all spectra)
+ ref = mapslices(median, M_float; dims=2)[:,1]
+
+ # Calculate quotients for each spectrum relative to the reference
+ Q = similar(M_float)
+ @inbounds for j in axes(M_float, 2)
+ Q[:, j] = M_float[:, j] ./ (ref .+ eps(eltype(M_float)))
+ end
+
+ # Find the median quotient for each spectrum (scaling factor)
+ s = [median( @view Q[:, j]) for j in axes(Q, 2)]
+
+ # Normalize the original matrix
+ @inbounds for j in axes(M_float, 2)
+ M_float[:, j] ./= (s[j] + eps(eltype(M_float)))
+ end
+ return M_float
+end
+
+# =============================================================================
+# 4) Peak Detection (for Profile Data)
+# =============================================================================
+
+"""
+ detect_peaks_profile(mz, y; half_window=10, snr_threshold=2.0)
+
+Detects local maxima with a signal-to-noise threshold (using MAD for noise estimation).
+Assumes profile-mode data and a monotonic m/z axis.
+"""
+function detect_peaks_profile(mz::AbstractVector{<:Real},
+ y::AbstractVector{<:Real};
+ half_window::Int=10,
+ snr_threshold::Float64=2.0)
+ n = length(y)
+ n < 3 && return (Float64[], Float64[])
+
+ # Noise estimation using Median Absolute Deviation (robust to peaks)
+ noise_level = mad(y, normalize=true) + eps(Float64)
+
+ # Smooth the spectrum to make peak detection more robust
+ ys = smooth_spectrum(y; window=max(5, 2*half_window+1), order=2)
+
+ peak_idx = Int[]
+ @inbounds for i in 2:n-1
+ left = max(1, i - half_window)
+ right = min(n, i + half_window)
+
+ local_max = ys[i]
+ # A point is a peak if it's the maximum in its neighborhood and above the SNR threshold
+ if local_max >= maximum( @view ys[left:right]) && (local_max > snr_threshold * noise_level)
+ # Ensure we only record one point for flat-topped peaks
+ if isempty(peak_idx) || (i - last(peak_idx) > half_window)
+ push!(peak_idx, i)
+ end
+ end
+ end
+
+ # Return original intensities at peak locations
+ pk_mz = [float(mz[i]) for i in peak_idx]
+ pk_int = [float(y[i]) for i in peak_idx]
+
+ return (pk_mz, pk_int)
+end
+
+"""
+ detect_peaks_centroid(mz, y; intensity_threshold=0.0)
+
+Filters centroided data based on a minimum intensity threshold.
+"""
+function detect_peaks_centroid(mz::AbstractVector{<:Real},
+ y::AbstractVector{<:Real};
+ intensity_threshold::Float64=0.0)
+
+ keep_indices = findall(y .>= intensity_threshold)
+
+ return (mz[keep_indices], y[keep_indices])
+end
+
+# =============================================================================
+# 5) Peak Alignment
+# =============================================================================
+
+"""
+ align_peaks_lowess(ref_mz, tgt_mz; tolerance=0.002) -> warp::Function
+
+Generates a warping function `warp(x)` to map target m/z values to reference m/z values.
+Uses a lightweight LOWESS-like approach with linear interpolation.
+"""
+function align_peaks_lowess(ref_mz::Vector{<:Real},
+ tgt_mz::Vector{<:Real};
+ tolerance::Float64=0.002)
+ # Efficiently match peaks between sorted lists
+ pairs = Tuple{Float64,Float64}[] # (target_mz, reference_mz)
+ i = 1; j = 1
+ while i <= length(tgt_mz) && j <= length(ref_mz)
+ dt = tgt_mz[i] - ref_mz[j]
+ if abs(dt) <= tolerance
+ push!(pairs, (float(tgt_mz[i]), float(ref_mz[j])))
+ i += 1; j += 1
+ elseif dt < 0
+ i += 1
+ else
+ j += 1
+ end
+ end
+
+ if length(pairs) < 3
+ @warn "Too few matching peaks for alignment. Returning identity function."
+ return x -> float.(x)
+ end
+
+ t = [p[1] for p in pairs]
+ r = [p[2] for p in pairs]
+
+ # Lightly smooth the mapping to reduce noise
+ t_s = smooth_spectrum(t; window=5, order=2)
+ r_s = smooth_spectrum(r; window=5, order=2)
+
+ # Return a function that performs linear interpolation for warping
+ function warp(x::AbstractVector{<:Real})
+ out = similar(collect(float.(x)))
+ for (k, xv) in enumerate(x)
+ if xv <= t_s[1]
+ # Linear extrapolation at the start
+ m = (r_s[2]-r_s[1]) / (t_s[2]-t_s[1] + eps())
+ out[k] = r_s[1] + m*(xv - t_s[1])
+ elseif xv >= t_s[end]
+ # Linear extrapolation at the end
+ m = (r_s[end]-r_s[end-1]) / (t_s[end]-t_s[end-1] + eps())
+ out[k] = r_s[end-1] + m*(xv - t_s[end-1])
+ else
+ # Linear interpolation for points in the middle
+ lo = searchsortedlast(t_s, xv)
+ hi = lo + 1
+ α = (xv - t_s[lo]) / (t_s[hi] - t_s[lo] + eps())
+ out[k] = (1-α)*r_s[lo] + α*r_s[hi]
+ end
+ end
+ return out
+ end
+
+ return warp
+end
+
+# =============================================================================
+# 6) Peak Binning & Feature Matrix Generation
+# =============================================================================
+
+"""
+ _find_bin_index(x, bins) -> Int
+
+Efficiently finds the index of the bin `(low, high)` that contains `x` using binary search.
+Returns 0 if not found.
+"""
+function _find_bin_index(x::Float64, bins::Vector{Tuple{Float64,Float64}})
+ lo, hi = 1, length(bins)
+ while lo <= hi
+ mid = (lo + hi) >>> 1
+ b = bins[mid]
+ if x < b[1]
+ hi = mid - 1
+ elseif x > b[2]
+ lo = mid + 1
+ else
+ return mid
+ end
+ end
+ return 0
+end
+
+"""
+ bin_peaks(all_pk_mz, all_pk_int, tolerance; frequency_threshold=0.25)
+
+Groups peaks from all spectra into consensus m/z bins and creates a feature matrix.
+Filters out features that do not appear in a minimum fraction of spectra.
+"""
+function bin_peaks(all_pk_mz::Vector{<:AbstractVector{<:Real}},
+ all_pk_int::Vector{<:AbstractVector{<:Real}},
+ tolerance::Float64; frequency_threshold::Float64=0.25)
+ ns = length(all_pk_mz)
+ ns == 0 && return (zeros(0,0), Tuple{Float64,Float64}[])
+
+ # 1) Collect all unique peak m/z values and sort them
+ flat_mz = Float64[]
+ for v in all_pk_mz
+ append!(flat_mz, float.(v))
+ end
+ sort!(flat_mz)
+ isempty(flat_mz) && return (zeros(ns, 0), Tuple{Float64,Float64}[])
+
+ # 2) Create contiguous m/z bins based on tolerance
+ bins = Tuple{Float64,Float64}[]
+ cur_lo = flat_mz[1]
+ cur_hi = flat_mz[1]
+ for x in @view flat_mz[2:end]
+ if x - cur_hi <= tolerance
+ cur_hi = x # Extend the current bin
+ else
+ push!(bins, (cur_lo, cur_hi)) # Finalize old bin
+ cur_lo = x; cur_hi = x # Start a new one
+ end
+ end
+ push!(bins, (cur_lo, cur_hi))
+
+ # 3) Create the feature matrix (samples x features) using max intensity per bin
+ X = zeros(Float64, ns, length(bins))
+ for i in 1:ns
+ for (mzv, iv) in zip(all_pk_mz[i], all_pk_int[i])
+ bidx = _find_bin_index(float(mzv), bins)
+ if bidx > 0
+ X[i, bidx] = max(X[i, bidx], float(iv))
+ end
+ end
+ end
+
+ # 4) Filter features by minimum frequency
+ if frequency_threshold > 0
+ present_count = vec(sum(X .> 0, dims=1))
+ min_count = ceil(Int, frequency_threshold * ns)
+ keep_mask = findall(present_count .>= min_count)
+ X = X[:, keep_mask]
+ bins = bins[keep_mask]
+ end
+
+ return (X, bins)
+end
+
+# =============================================================================
+# 7) Plotting Helper
+# =============================================================================
+
+"""
+ plot_stage_spectrum(mz, intensity; title, ...)
+
+Returns a `CairoMakie.Figure` for a single spectrum trace. The caller is responsible for saving.
+"""
+function plot_stage_spectrum(mz::AbstractVector, intensity::AbstractVector;
+ title::AbstractString, xlabel::AbstractString="m/z",
+ ylabel::AbstractString="Intensity")
+ # This dynamic import is for script-like use; in a package, Makie would be a full dependency.
+ @eval begin
+ import CairoMakie
+ using CairoMakie
+ end
+ fig = CairoMakie.Figure(size = (1400, 500))
+ ax = CairoMakie.Axis(fig[1, 1], title=title, xlabel=xlabel, ylabel=ylabel)
+ CairoMakie.lines!(ax, mz, intensity)
+ return fig
+end
+
+# =============================================================================
+# 8) Pipeline Orchestrator
+# =============================================================================
+
+"""
+ run_preprocessing_pipeline(spectra; steps, params, on_stage)
+
+Executes a flexible preprocessing pipeline on a vector of spectra.
+
+# Arguments
+- `spectra`: A vector of `(mz, intensity)` tuples.
+- `steps`: A vector of symbols defining the pipeline order (e.g., `[:qc, :smooth, :baseline, :peaks, :bin]`).
+- `params`: A dictionary of parameters for each step.
+- `on_stage`: An optional callback function `on_stage(stage_symbol; idx, mz, intensity)` executed after each step for logging or visualization.
+
+# Returns
+- A `FeatureMatrix` if `:bin` is in the steps, otherwise the vector of processed spectra.
+"""
+function run_preprocessing_pipeline(spectra::Vector;
+ steps::Vector{Symbol},
+ params::Dict=Dict(),
+ on_stage::Function=(;kwargs...)->nothing)
+
+ processed = deepcopy(spectra) # Don't mutate the original input
+ reference_peaks = nothing # For alignment
+
+ _emit(stage::Symbol, idx::Int, mz, y) = on_stage(stage; idx=idx, mz=mz, intensity=y)
+
+ for step in steps
+ @info "Running step: $step"
+
+ if step === :qc
+ for (i, (mz, y)) in enumerate(processed)
+ (isempty(mz) || isempty(y)) && continue
+ _emit(:qc_raw, i, mz, y)
+ qc_is_empty(mz, y) && @warn "Spectrum at index $i is empty."
+ !qc_is_regular(mz) && @warn "m/z axis at index $i is not monotonic."
+ end
+
+ elseif step === :transform
+ meth = get(params, :transform_method, :sqrt)
+ for i in eachindex(processed)
+ mz, y = processed[i]
+ y_new = transform_intensity(y; method=meth)
+ processed[i] = (mz, y_new)
+ _emit(:transform, i, mz, y_new)
+ end
+
+ elseif step === :smooth
+ win = get(params, :sg_window, 21)
+ ord = get(params, :sg_order, 2)
+ for i in eachindex(processed)
+ mz, y = processed[i]
+ y_smooth = smooth_spectrum(y; window=win, order=ord)
+ processed[i] = (mz, y_smooth)
+ _emit(:smooth, i, mz, y_smooth)
+ end
+
+ elseif step === :baseline
+ iters = get(params, :snip_iterations, 100)
+ for i in eachindex(processed)
+ mz, y = processed[i]
+ baseline = snip_baseline(y, iters)
+ y_corrected = max.(0.0, y .- baseline)
+ processed[i] = (mz, y_corrected)
+ _emit(:baseline, i, mz, y_corrected)
+ end
+
+ elseif step === :normalize
+ mode = get(params, :normalize_method, :tic)
+ if mode === :tic
+ for i in eachindex(processed)
+ mz, y = processed[i]
+ y_norm = tic_normalize(y)
+ processed[i] = (mz, y_norm)
+ _emit(:normalize, i, mz, y_norm)
+ end
+ elseif mode === :pqn
+ # Note: PQN assumes spectra are on a common m/z grid.
+ matrix = hcat([float.(p[2]) for p in processed]...)
+ matrix_norm = pqn_normalize(matrix)
+ for i in eachindex(processed)
+ mz, _ = processed[i]
+ processed[i] = (mz, view(matrix_norm, :, i))
+ _emit(:normalize, i, mz, processed[i][2])
+ end
+ end
+
+ elseif step === :peaks
+ peak_results = Vector{Tuple{Vector{Float64},Vector{Float64}}}(undef, length(processed))
+ hw = get(params, :peak_half_window, 10)
+ snr = get(params, :peak_snr, 2.0)
+ for (i, (mz, y)) in enumerate(processed)
+ pk_mz, pk_int = detect_peaks_profile(mz, y; half_window=hw, snr_threshold=snr)
+ peak_results[i] = (pk_mz, pk_int)
+ # Emit with original mz axis but maybe stem plot of peaks?
+ _emit(:peaks, i, pk_mz, pk_int)
+ end
+ processed = peak_results
+ # Set reference for alignment
+ reference_peaks = isempty(processed) ? nothing : processed[1][1]
+
+ elseif step === :align
+ reference_peaks === nothing && (@error "Alignment requires a :peaks step first."; continue)
+ tol = get(params, :align_tolerance, 0.002)
+ for i in 2:length(processed)
+ tgt_peaks, intens = processed[i]
+ warp_func = align_peaks_lowess(reference_peaks, tgt_peaks; tolerance=tol)
+ processed[i] = (warp_func(tgt_peaks), intens)
+ _emit(:align, i, processed[i][1], processed[i][2])
+ end
+
+ elseif step === :bin
+ all_pks = [s[1] for s in processed]
+ all_ints = [s[2] for s in processed]
+ tol = get(params, :bin_tolerance, 0.002)
+ freq = get(params, :bin_min_frequency, 0.25)
+ mat, mz_bins = bin_peaks(all_pks, all_ints, tol; frequency_threshold=freq)
+ return FeatureMatrix(mat, mz_bins, collect(1:length(processed)))
+ end
+ end
+
+ @warn "Pipeline finished without a :bin step; returning processed spectra."
+ return processed
+end
+
+"""
+ run_preprocessing_pipeline(msi_data::MSIData, indices::Vector{Int}; steps, params, on_stage)
+
+Executes a flexible preprocessing pipeline on a subset of spectra from an MSIData object,
+with mode-aware logic for centroid and profile data.
+"""
+function run_preprocessing_pipeline(msi_data::MSIData, indices::Vector{Int};
+ steps::Vector{Symbol},
+ params::Dict=Dict(),
+ on_stage::Function=(;kwargs...)->nothing)
+
+ # This version of the pipeline is mode-aware.
+ # It processes spectra directly from the MSIData object.
+
+ processed_spectra = Vector{Tuple}(undef, length(indices))
+
+ # First, load all spectra and apply initial steps that run on individual spectra
+ for (i, spec_idx) in enumerate(indices)
+ mz, intensity = GetSpectrum(msi_data, spec_idx)
+ mode = msi_data.spectra_metadata[spec_idx].mode
+
+ on_stage(:qc_raw; idx=spec_idx, mz=mz, intensity=intensity)
+
+ for step in steps
+ if step === :transform
+ meth = get(params, :transform_method, :sqrt)
+ intensity = transform_intensity(intensity; method=meth)
+ on_stage(:transform; idx=spec_idx, mz=mz, intensity=intensity)
+ elseif step === :smooth && mode == PROFILE
+ win = get(params, :sg_window, 21)
+ ord = get(params, :sg_order, 2)
+ intensity = smooth_spectrum(intensity; window=win, order=ord)
+ on_stage(:smooth; idx=spec_idx, mz=mz, intensity=intensity)
+ elseif step === :baseline && mode == PROFILE
+ iters = get(params, :snip_iterations, 100)
+ baseline = snip_baseline(intensity, iters)
+ intensity = max.(0.0, intensity .- baseline)
+ on_stage(:baseline; idx=spec_idx, mz=mz, intensity=intensity)
+ elseif step === :normalize
+ norm_mode = get(params, :normalize_method, :tic)
+ if norm_mode === :tic
+ intensity = tic_normalize(intensity)
+ on_stage(:normalize; idx=spec_idx, mz=mz, intensity=intensity)
+ end
+ end
+ end
+ processed_spectra[i] = (mz, intensity, mode) # Store mode for peak detection
+ end
+
+ # Now, handle steps that require all spectra (like PQN) or are the final steps
+ final_result = nothing
+ for step in steps
+ if step === :normalize && get(params, :normalize_method, :tic) === :pqn
+ # Note: PQN assumes spectra are on a common m/z grid.
+ matrix = hcat([float.(p[2]) for p in processed_spectra]...)
+ matrix_norm = pqn_normalize(matrix)
+ for i in eachindex(processed_spectra)
+ mz, _, mode = processed_spectra[i]
+ processed_spectra[i] = (mz, view(matrix_norm, :, i), mode)
+ on_stage(:normalize; idx=indices[i], mz=mz, intensity=processed_spectra[i][2])
+ end
+ elseif step === :peaks
+ peak_results = Vector{Tuple{Vector{Float64},Vector{Float64}}}(undef, length(processed_spectra))
+ hw = get(params, :peak_half_window, 10)
+ snr = get(params, :peak_snr, 2.0)
+ intensity_thresh = get(params, :peak_intensity_threshold, 0.0)
+
+ for (i, (mz, y, mode)) in enumerate(processed_spectra)
+ spec_idx = indices[i]
+ if mode == PROFILE
+ pk_mz, pk_int = detect_peaks_profile(mz, y; half_window=hw, snr_threshold=snr)
+ else # CENTROID
+ pk_mz, pk_int = detect_peaks_centroid(mz, y; intensity_threshold=intensity_thresh)
+ end
+ peak_results[i] = (pk_mz, pk_int)
+ on_stage(:peaks; idx=spec_idx, mz=pk_mz, intensity=pk_int)
+ end
+ processed_spectra = peak_results # Now contains peak lists
+
+ elseif step === :align
+ # Alignment requires a reference peak list, typically from the first spectrum
+ reference_peaks = isempty(processed_spectra) ? nothing : processed_spectra[1][1]
+ if reference_peaks === nothing
+ @error "Alignment requires a :peaks step first."; continue
+ end
+ tol = get(params, :align_tolerance, 0.002)
+ for i in 2:length(processed_spectra)
+ tgt_peaks, intens = processed_spectra[i]
+ warp_func = align_peaks_lowess(reference_peaks, tgt_peaks; tolerance=tol)
+ processed_spectra[i] = (warp_func(tgt_peaks), intens)
+ on_stage(:align; idx=indices[i], mz=processed_spectra[i][1], intensity=processed_spectra[i][2])
+ end
+
+ elseif step === :bin
+ all_pks = [s[1] for s in processed_spectra]
+ all_ints = [s[2] for s in processed_spectra]
+ tol = get(params, :bin_tolerance, 0.002)
+ freq = get(params, :bin_min_frequency, 0.25)
+ mat, mz_bins = bin_peaks(all_pks, all_ints, tol; frequency_threshold=freq)
+ final_result = FeatureMatrix(mat, mz_bins, indices)
+ break # Binning is the last step
+ end
+ end
+
+ if final_result !== nothing
+ return final_result
+ else
+ @warn "Pipeline finished without a :bin step; returning processed spectra."
+ return processed_spectra
+ end
+end
+
+# =============================================================================
+# 9) Quality Control Metrics
+# =============================================================================
+
+"""
+ calculate_ppm_error(measured_mz::Float64, theoretical_mz::Float64) -> Float64
+
+Calculates mass accuracy in parts-per-million (PPM).
+
+# Formula
+PPM = 10⁶ × |measured_mz - theoretical_mz| / theoretical_mz
+"""
+function calculate_ppm_error(measured_mz::Real, theoretical_mz::Real)
+ if theoretical_mz == 0
+ return Inf
+ end
+ return 1e6 * abs(Float64(measured_mz) - Float64(theoretical_mz)) / Float64(theoretical_mz)
+end
+
+"""
+ calculate_ppm_error_bulk(measured_mz::Vector{Float64}, theoretical_mz::Vector{Float64}) -> Vector{Float64}
+
+Calculates PPM errors for multiple mass values.
+"""
+function calculate_ppm_error_bulk(measured_mz::Vector{Real}, theoretical_mz::Vector{Real})
+ return [calculate_ppm_error(m, t) for (m, t) in zip(measured_mz, theoretical_mz)]
+end
+
+"""
+ calculate_resolution_fwhm(mz::Float64, profile_mz::Vector{Float64},
+ profile_intensity::Vector{Float64}) -> Float64
+
+Calculates mass resolution using Full Width at Half Maximum (FWHM).
+
+# Formula
+Resolution = m / Δm, where Δm is FWHM
+
+# Arguments
+- `mz`: Peak centroid m/z
+- `profile_mz`: Full m/z array from profile data
+- `profile_intensity`: Full intensity array from profile data
+
+# Returns
+Resolution or NaN if cannot be calculated
+"""
+function calculate_resolution_fwhm(mz::Real, profile_mz::AbstractVector{<:Real},
+ profile_intensity::AbstractVector{<:Real})
+
+ # Find peak center index
+ peak_idx = argmin(abs.(profile_mz .- mz))
+ peak_height = Float64(profile_intensity[peak_idx])
+ half_max = peak_height / 2
+
+ # Find left half-maximum point (interpolate for accuracy)
+ left_idx = find_last_below(profile_intensity[1:peak_idx], half_max)
+ if left_idx == 0 || left_idx == length(profile_intensity[1:peak_idx])
+ return NaN
+ end
+
+ # Linear interpolation for left FWHM
+ x1, x2 = Float64(profile_mz[left_idx]), Float64(profile_mz[left_idx+1])
+ y1, y2 = Float64(profile_intensity[left_idx]), Float64(profile_intensity[left_idx+1])
+ left_fwhm = x1 + (x2 - x1) * (half_max - y1) / (y2 - y1)
+
+ # Find right half-maximum point
+ right_slice = profile_intensity[peak_idx:end]
+ right_offset = find_first_below(right_slice, half_max)
+ if right_offset == 0 || right_offset == length(right_slice)
+ return NaN
+ end
+
+ right_idx = peak_idx + right_offset - 1
+ x1, x2 = Float64(profile_mz[right_idx-1]), Float64(profile_mz[right_idx])
+ y1, y2 = Float64(profile_intensity[right_idx-1]), Float64(profile_intensity[right_idx])
+ right_fwhm = x1 + (x2 - x1) * (half_max - y1) / (y2 - y1)
+
+ fwhm = right_fwhm - left_fwhm
+ return fwhm > 0 ? Float64(mz) / fwhm : NaN
+end
+
+# Helper functions for FWHM calculation
+function find_last_below(v::AbstractVector{<:Real}, threshold::Real)
+ for i in length(v):-1:2
+ if v[i] >= threshold && v[i-1] < threshold
+ return i-1
+ end
+ end
+ return 0
+end
+
+function find_first_below(v::AbstractVector{<:Real}, threshold::Real)
+ for i in 1:(length(v)-1)
+ if v[i] >= threshold && v[i+1] < threshold
+ return i+1
+ end
+ end
+ return 0
+end
+
+"""
+ analyze_mass_accuracy(msi_data, reference_peaks; ppm_tolerance=20.0)
+
+Analyzes mass accuracy across the dataset using known reference peaks.
+
+# Arguments
+- `msi_data`: Your MSI dataset
+- `reference_peaks`: Dict of theoretical m/z values -> compound names
+- `ppm_tolerance`: Initial tolerance for peak matching
+
+# Returns
+Comprehensive mass accuracy report
+"""
+function analyze_mass_accuracy(msi_data, reference_peaks::Dict{Float64,String};
+ ppm_tolerance::Float64=5.0, sample_spectra=100)
+ println("\n[ MASS ACCURACY ANALYSIS ]")
+ println("PPM tolerance: ", ppm_tolerance, " ppm")
+ println("Spectra to sample: ", sample_spectra)
+
+ theoretical_mz = sort(collect(keys(reference_peaks)))
+ ppm_errors = Float64[]
+ matched_peaks = Tuple{Float64,Float64,String}[] # (theoretical, measured, compound)
+
+ # Sample spectra across the dataset
+ if sample_spectra >= length(msi_data.spectra_metadata)
+ spectrum_indices = 1:length(msi_data.spectra_metadata)
+ else
+ spectrum_indices = round.(Int, range(1, length(msi_data.spectra_metadata), length=sample_spectra))
+ end
+
+ for idx in spectrum_indices
+ mz, intensity = GetSpectrum(msi_data, idx)
+
+ # Detect peaks in this spectrum
+ detected_peaks, _ = detect_peaks_profile(mz, intensity, snr_threshold=3.0)
+
+ # Match detected peaks to reference peaks
+ for (i, theoretical) in enumerate(theoretical_mz)
+ # Find closest detected peak within tolerance
+ distances = abs.(detected_peaks .- theoretical)
+ if !isempty(distances)
+ min_idx = argmin(distances)
+ min_distance = distances[min_idx]
+
+ ppm_error = calculate_ppm_error(detected_peaks[min_idx], theoretical)
+
+ if ppm_error <= ppm_tolerance
+ push!(ppm_errors, ppm_error)
+ push!(matched_peaks, (theoretical, detected_peaks[min_idx], reference_peaks[theoretical]))
+ end
+ end
+ end
+ end
+
+ if isempty(ppm_errors)
+ @warn "No peaks matched within $ppm_tolerance ppm tolerance"
+ return (mean_ppm=NaN, std_ppm=NaN, min_ppm=NaN, max_ppm=NaN, optimal_ppm=NaN, n_matches=0, matched_peaks=[], all_ppm_errors=[])
+ end
+
+ # Calculate statistics
+ mean_ppm = mean(ppm_errors)
+ std_ppm = std(ppm_errors)
+ min_ppm = minimum(ppm_errors)
+ max_ppm = maximum(ppm_errors)
+
+ # Determine optimal ppm tolerance (mean + 3σ covers ~99.7% of peaks for normal distribution)
+ optimal_ppm = mean_ppm + 3 * std_ppm
+
+ return (
+ mean_ppm = mean_ppm,
+ std_ppm = std_ppm,
+ min_ppm = min_ppm,
+ max_ppm = max_ppm,
+ optimal_ppm = optimal_ppm,
+ n_matches = length(ppm_errors),
+ matched_peaks = matched_peaks,
+ all_ppm_errors = ppm_errors
+ )
+end
+
+"""
+ get_common_calibration_standards(standard_type::Symbol)
+
+Returns common calibration masses for different instrument types.
+
+# Supported standards
+- `:maldi_pos`: Common MALDI-TOF positive mode calibrants
+- `:maldi_neg`: Common MALDI-TOF negative mode calibrants
+- `:esi_pos`: ESI positive mode calibrants
+- `:lcms`: LC-MS commonly used standards
+"""
+function get_common_calibration_standards(standard_type::Symbol=:maldi_pos)
+ standards = Dict{Float64,String}()
+
+ if standard_type == :maldi_pos
+ standards = Dict(
+ 104.10754 => "C5H4N2 (Imidazole)",
+ 175.11995 => "C6H15O4P (Glycerophosphocholine fragment)",
+ 226.15687 => "C10H20NO4P (Phosphocholine)",
+ 322.04810 => "[Glu1]-Fibrinopeptide B fragment",
+ 379.09247 => "C12H22O11 (Sucrose)",
+ 515.32539 => "C26H52NO7P (PC(16:0/0:0))",
+ 622.02896 => "C20H12O5S2 (1-Hydroxypyrene-3,6,8-trisulfate)",
+ 757.39917 => "C37H74NO8P (PC(34:1))",
+ 1046.54198 => "Angiotensin I",
+ 1296.68477 => "ACTH clip 1-17",
+ 1570.67744 => "ACTH clip 18-39",
+ 2465.19829 => "ACTH clip 7-38"
+ )
+ elseif standard_type == :maldi_neg
+ standards = Dict(
+ 112.98563 => "C2F3O2 (Trifluoroacetate)",
+ 152.99568 => "C2F6S (Perfluoroethylsulfonate)",
+ 214.00166 => "C4F7O2 (Heptafluorobutyrate)",
+ 264.93278 => "C6F6 (Hexafluorobenzene)",
+ 362.96198 => "C8F15O2 (Perfluorooctanoate)",
+ 466.96714 => "C10F17O2S (Perfluorooctanesulfonate)"
+ )
+ elseif standard_type == :esi_pos
+ standards = Dict(
+ 118.08626 => "C5H12NO2 (Valine)",
+ 175.11900 => "C6H15O4P (Phosphocholine fragment)",
+ 524.26496 => "C23H48NO7P (LysoPC(16:0))",
+ 622.02896 => "C20H12O5S2 (Standard)",
+ 922.00980 => "C18H18O6N3S3 (Ultramark 1621)"
+ )
+ end
+
+ return standards
+end
+
+"""
+ generate_qc_report(msi_data; reference_peaks, output_dir)
+
+Generates a comprehensive QC report including mass accuracy and resolution.
+"""
+function generate_qc_report(msi_data, filename::String; reference_peaks=nothing, output_dir="qc_results", sample_spectra=100)
+ println("\n[ QC REPORT GENERATION ]")
+ println("Input file: ", filename)
+ println("Output directory: ", output_dir)
+ mkpath(output_dir)
+
+ # Use default calibrants if none provided
+ if reference_peaks === nothing
+ reference_peaks = get_common_calibration_standards(:maldi_pos)
+ end
+
+ println("Generating QC Report...")
+ println("Using $(length(reference_peaks)) reference masses")
+
+ # 1. Analyze mass accuracy
+ accuracy_report = analyze_mass_accuracy(msi_data, reference_peaks, sample_spectra=sample_spectra)
+
+ println("\n" * "="^50)
+ println("MASS ACCURACY REPORT")
+ println("="^50)
+ println("Mean PPM error: $(round(accuracy_report.mean_ppm, digits=2)) ppm")
+ println("Std PPM error: $(round(accuracy_report.std_ppm, digits=2)) ppm")
+ println("Min PPM error: $(round(accuracy_report.min_ppm, digits=2)) ppm")
+ println("Max PPM error: $(round(accuracy_report.max_ppm, digits=2)) ppm")
+ if haskey(accuracy_report, :optimal_ppm)
+ println("Optimal PPM tolerance: $(round(accuracy_report.optimal_ppm, digits=2)) ppm")
+ else
+ println("Optimal PPM tolerance: Not available")
+ end
+ println("Number of matches: $(accuracy_report.n_matches)")
+
+ # 2. Calculate resolution for a few representative peaks
+ println("\n" * "="^50)
+ println("RESOLUTION ANALYSIS")
+ println("="^50)
+
+ resolution_results = []
+ sample_spectra = min(10, length(msi_data.spectra_metadata))
+
+ for (i, idx) in enumerate(round.(Int, range(1, length(msi_data.spectra_metadata), length=sample_spectra)))
+ process_spectrum(msi_data, idx) do mz, intensity
+ if !qc_is_empty(mz, intensity)
+ # Test resolution on the most intense peak
+ max_intensity_idx = argmax(intensity)
+ test_mz = mz[max_intensity_idx]
+
+ resolution = calculate_resolution_fwhm(test_mz, mz, intensity)
+ if !isnan(resolution)
+ push!(resolution_results, resolution)
+ println("Spectrum $idx: Resolution = $(round(resolution))")
+ end
+ end
+ end
+ end
+
+ if !isempty(resolution_results)
+ avg_resolution = mean(resolution_results)
+ println("\nAverage resolution: $(round(avg_resolution))")
+ println("Resolution range: $(round(minimum(resolution_results))) - $(round(maximum(resolution_results)))")
+ end
+
+ # 3. Save detailed results
+
+ # Save PPM error distribution
+ ppm_df = DataFrame(
+ theoretical_mz = [p[1] for p in accuracy_report.matched_peaks],
+ measured_mz = [p[2] for p in accuracy_report.matched_peaks],
+ compound = [p[3] for p in accuracy_report.matched_peaks],
+ ppm_error = accuracy_report.all_ppm_errors
+ )
+
+ CSV.write(joinpath(output_dir, "mass_accuracy_results.csv"), ppm_df)
+
+ # Save resolution results
+ if !isempty(resolution_results)
+ res_df = DataFrame(resolution = resolution_results)
+ CSV.write(joinpath(output_dir, "resolution_results.csv"), res_df)
+ end
+
+ # 4. Create summary
+ summary = """
+ QC REPORT SUMMARY
+ =================
+ Date: $(now())
+ File: $(filename)
+ Spectra analyzed: $(length(msi_data.spectra_metadata))
+
+ MASS ACCURACY:
+ - Mean PPM: $(round(accuracy_report.mean_ppm, digits=2)) ppm
+ - Std PPM: $(round(accuracy_report.std_ppm, digits=2)) ppm
+ - Recommended tolerance: $(round(accuracy_report.optimal_ppm, digits=2)) ppm (mean + 3 * std)
+
+ RESOLUTION:
+ - Average: $(isempty(resolution_results) ? "N/A" : string(round(mean(resolution_results))))
+ - Range: $(isempty(resolution_results) ? "N/A" : "$(round(minimum(resolution_results))) - $(round(maximum(resolution_results)))")
+
+ RECOMMENDATIONS:
+ - Use $(round(accuracy_report.optimal_ppm, digits=2)) ppm for peak matching
+ - Instrument performance: $(accuracy_report.mean_ppm < 5 ? "Excellent" : accuracy_report.mean_ppm < 10 ? "Good" : "Needs calibration")
+ """
+
+ open(joinpath(output_dir, "qc_summary.txt"), "w") do f
+ write(f, summary)
+ end
+
+ println("\nQC report saved to: $output_dir")
+ return accuracy_report, resolution_results
+end
+
diff --git a/test/readme.md b/test/readme.md
index f80ee72..197af0d 100644
--- a/test/readme.md
+++ b/test/readme.md
@@ -37,21 +37,24 @@ Before running the tests, you must edit the `test/run_tests.jl` file to point to
cd /path/to/your/JuliaMSI
```
-2. **Execute the Test Script**:
+2. **Execute the Different Test Scripts**:
Run the following command from the project's root directory. This will install the necessary dependencies and run the tests.
```bash
julia --project=. test/run_tests.jl
```
+ ```bash
+ julia --project=. test/run_preprocessing.jl
+ ```
3. **Check the Results**:
The script will print its progress to the console. Any generated images (plots and image slices) will be saved in the `test/results/` directory.
## Test Case Configuration
-You can customize the test run by editing the variables in `test/run_tests.jl`.
+You can customize the test run by editing the variables in `test/run_tests.jl` or `test/run_preprocessing.jl` respectively.
### Enabling and Disabling Test Cases
-You can run or skip specific test cases by setting the corresponding boolean variables to `true` or `false`.
+You can run or skip specific test cases of run_tests by setting the corresponding boolean variables to `true` or `false`.
```julia
test1 = true # Runs Test Case 1
diff --git a/test/run_preprocessing.jl b/test/run_preprocessing.jl
new file mode 100644
index 0000000..4ea526a
--- /dev/null
+++ b/test/run_preprocessing.jl
@@ -0,0 +1,468 @@
+# test/run_preprocessing.jl
+
+# ===================================================================
+# Test Environment for the Preprocessing.jl Module
+# ===================================================================
+# This script tests the full preprocessing pipeline on single spectra
+# and total spectra from both .mzML and .imzML files.
+# It generates an overlay plot showing all preprocessing stages and
+# saves the resulting feature matrix to a CSV file.
+#
+# Instructions:
+# 1. Ensure the file paths in the "CONFIG" section are correct.
+# 2. Run the script from the project's root directory:
+# julia test/run_preprocessing.jl
+# 3. Check the `test/results/` folder for output plots and CSVs.
+# ===================================================================
+
+using Printf
+using CairoMakie
+import Pkg
+using DataFrames # For saving FeatureMatrix to CSV
+using CSV # For saving FeatureMatrix to CSV
+using Statistics # For mean()
+
+# --- Load Modules ---
+# Activate the project environment to access dependencies
+Pkg.activate(joinpath(@__DIR__, ".."))
+using MSI_src # This brings in Preprocessing.jl functions via export
+
+# ===================================================================
+# CONFIG: Test files and parameters
+# ===================================================================
+
+# --- Test Files ---
+# An mzML file for testing spectrum-based processing
+# const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/CE4_BF_R1/CE4_BF_R1.mzML"
+# const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/set de datos MS/Leaf_profile_LD_LTP_MS.mzML"
+const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/set de datos MS/Escopolamina_tuneo_fraq_20ev.mzML"
+#const TEST_MZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/set de datos MS/Atropina_tuneo_fraq_20ev.mzML"
+
+const MZML_SPECTRUM_ID = 1
+
+# An imzML file for testing
+# const TEST_IMZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/CE4_BF_R1/CE4_BF_R1.imzML"
+# const IMZML_COORDS = (50, 50)
+const TEST_IMZML_FILE = "/home/pixel/Documents/Cinvestav_2025/Analisis/salida/Stomach_DHB_uncompressed.imzML"
+const IMZML_COORDS = (1997, 639)
+
+# --- Output Directory ---
+const RESULTS_DIR = "test/results"
+
+# ===================================================================
+# HELPER FUNCTIONS FOR PLOTTING
+# ===================================================================
+
+"""
+ plot_overlay_stages(collected_data, output_path, title)
+
+Creates a single plot overlaying spectra from different preprocessing stages.
+"""
+function plot_overlay_stages(collected_data, output_path, title)
+ fig = Figure(size = (1400, 800))
+ ax = Axis(fig[1, 1], title=title, xlabel="m/z", ylabel="Intensity")
+
+ colors = Makie.wong_colors() # A good set of distinct colors
+
+ for (i, (stage, mz, intensity)) in enumerate(collected_data)
+ color = colors[mod1(i, length(colors))] # Cycle through colors
+
+ # Plot the spectrum as a line
+ lines!(ax, mz, intensity, color=color, label=string(stage))
+
+ # If it's the peaks stage, also mark the peak tops
+ if stage == :peaks
+ scatter!(ax, mz, intensity, color=color, marker=:circle, markersize=8, label="$(string(stage)) (tops)")
+ end
+ end
+ axislegend(ax, position=:rt) # Right top position
+ save(output_path, fig)
+ println("SUCCESS: Overlay plot saved to $output_path")
+end
+
+
+# ===================================================================
+# TEST DEFINITIONS
+# ===================================================================
+
+"""
+ test_full_pipeline(msi_data, spectrum_id; output_dir, file_type_prefix)
+
+Tests the full preprocessing pipeline on a single spectrum and saves a plot
+for each intermediate step using the `on_stage` callback.
+`spectrum_id` can be an `Int` (for mzML) or a `Tuple{Int, Int}` (for imzML).
+"""
+function test_full_pipeline(msi_data, spectrum_id; output_dir, file_type_prefix, mz_tolerance=0.002)
+ println("\n--- Testing Full Preprocessing Pipeline on Spectrum: $spectrum_id (File Type: $file_type_prefix) ---")
+
+ # 1. Determine the spectrum index
+ local spec_idx
+ if spectrum_id isa Int
+ spec_idx = spectrum_id
+ else # Tuple for imzML
+ spec_idx = msi_data.coordinate_map[spectrum_id...]
+ end
+
+ if spec_idx == 0
+ println("SKIPPED: No spectrum found at coordinates $spectrum_id.")
+ return
+ end
+
+ # 2. Define the pipeline steps in the desired order
+ pipeline_steps = [
+ :qc,
+ :transform,
+ :smooth,
+ :baseline,
+ :normalize,
+ :peaks,
+ :align, # Align requires multiple spectra, but we'll run it on a single one for now (will warn)
+ :bin
+ ]
+
+ # Define parameters for each step
+ params = Dict(
+ :transform_method => :sqrt,
+ :sg_window => 15,
+ :sg_order => 2,
+ :snip_iterations => 100,
+ :normalize_method => :tic,
+ :peak_half_window => 10,
+ :peak_snr => 3.0,
+ :peak_intensity_threshold => 0.0, # For centroid peak detection
+ :align_tolerance => mz_tolerance,
+ :bin_tolerance => mz_tolerance,
+ :bin_min_frequency => 0.0 # Keep all bins for a single spectrum
+ )
+
+ # 3. Define the on_stage callback to collect data for overlay plot and save separate plots
+ collected_stage_data = []
+ stage_counter = Ref(0) # Initialize counter for sequential naming
+ normalized_spectrum = nothing # Variable to hold the normalized spectrum
+
+ function stage_callback(stage; idx, mz, intensity)
+ stage_counter[] += 1 # Increment counter
+ println(" -> Generating plot for stage: $stage")
+
+ local fig # Make fig available in the whole function scope
+
+ if stage == :normalize
+ normalized_spectrum = (mz, intensity)
+ fig = plot_stage_spectrum(mz, intensity, title="Stage: $stage (Spectrum $spectrum_id)")
+ elseif stage == :peaks && normalized_spectrum !== nothing
+ # For the peaks stage, plot the normalized spectrum as a base layer
+ fig = Figure(size = (1400, 500))
+ ax = Axis(fig[1, 1], title="Stage: Peaks (Spectrum $spectrum_id)", xlabel="m/z", ylabel="Intensity")
+ lines!(ax, normalized_spectrum[1], normalized_spectrum[2], color=:gray, label="Normalized Spectrum")
+ scatter!(ax, mz, intensity, color=:red, marker=:circle, markersize=8, label="Detected Peaks")
+ axislegend(ax)
+ else
+ # Default plotting for all other stages
+ fig = plot_stage_spectrum(mz, intensity, title="Stage: $stage (Spectrum $spectrum_id)")
+ end
+
+ # Save the figure
+ stage_output_path = joinpath(output_dir, "$(file_type_prefix)_$(spectrum_id)_$(stage_counter[])_$(stage).png")
+ save(stage_output_path, fig)
+
+ # Collect data for overlay plot
+ push!(collected_stage_data, (stage, mz, intensity))
+ end
+
+ # 4. Run the pipeline on the single spectrum
+ println("Running pipeline with steps: $pipeline_steps")
+ processed_result = run_preprocessing_pipeline(
+ msi_data,
+ [spec_idx], # The pipeline expects a vector of indices
+ steps=pipeline_steps,
+ params=params,
+ on_stage=stage_callback
+ )
+
+ # 5. Generate and save the overlay plot
+ overlay_output_path = joinpath(output_dir, "$(file_type_prefix)_$(spectrum_id)_all_stages_overlay.png")
+ plot_overlay_stages(collected_stage_data, overlay_output_path, "Preprocessing Stages Overlay (Spectrum $spectrum_id)")
+
+ # 6. Save feature matrix if generated
+ if processed_result isa FeatureMatrix
+ feature_matrix_output_path = joinpath(output_dir, "$(file_type_prefix)_$(spectrum_id)_feature_matrix.csv")
+ # Convert mz_bins to a more readable format for CSV
+ mz_labels = ["$(round(b[1], digits=4))_$(round(b[2], digits=4))" for b in processed_result.mz_bins]
+ df = DataFrame(processed_result.matrix, Symbol.(mz_labels))
+ CSV.write(feature_matrix_output_path, df)
+ println("SUCCESS: Feature matrix saved to $feature_matrix_output_path")
+ else
+ @warn "Pipeline did not return a FeatureMatrix for Spectrum $spectrum_id."
+ processed_result
+ end
+
+ println("--- Pipeline test finished for Spectrum: $spectrum_id (File Type: $file_type_prefix) ---")
+ println("Check the '$(output_dir)' directory for output plots and CSVs.")
+end
+
+"""
+ test_full_pipeline_on_total_spectrum(msi_data; output_dir, file_type_prefix)
+
+Tests the full preprocessing pipeline on the *total spectrum* (sum of all spectra)
+and saves a plot for each intermediate step.
+"""
+function test_full_pipeline_on_total_spectrum(msi_data; output_dir, file_type_prefix, mz_tolerance=0.002)
+ println("\n--- Testing Full Preprocessing Pipeline on TOTAL Spectrum (File Type: $file_type_prefix) ---")
+
+ # 1. Get the total spectrum
+ total_mz, total_intensity = get_total_spectrum(msi_data)
+ total_spectrum = (total_mz, total_intensity)
+
+ if qc_is_empty(total_mz, total_intensity)
+ println("SKIPPED: Total spectrum is empty.")
+ return
+ end
+
+ # 2. Define the pipeline steps and parameters (same as for single spectrum)
+ pipeline_steps = [
+ :qc,
+ :transform,
+ :smooth,
+ :baseline,
+ :normalize,
+ :peaks,
+ :align, # Align requires multiple spectra, but we'll run it on a single one for now (will warn)
+ :bin
+ ]
+
+ params = Dict(
+ :transform_method => :sqrt,
+ :sg_window => 15,
+ :sg_order => 2,
+ :snip_iterations => 100,
+ :normalize_method => :tic,
+ :peak_half_window => 10,
+ :peak_snr => 3.0,
+ :align_tolerance => mz_tolerance,
+ :bin_tolerance => mz_tolerance,
+ :bin_min_frequency => 0.0 # Keep all bins for a single spectrum
+ )
+
+ # 3. Define the on_stage callback
+ collected_stage_data = []
+ stage_counter = Ref(0) # Initialize counter for sequential naming
+ normalized_spectrum_total = nothing # Variable to hold the normalized spectrum
+
+ function stage_callback_total(stage; idx, mz, intensity)
+ stage_counter[] += 1 # Increment counter
+ println(" -> Generating plot for stage: $stage (Total Spectrum)")
+
+ local fig
+
+ if stage == :normalize
+ normalized_spectrum_total = (mz, intensity)
+ fig = plot_stage_spectrum(mz, intensity, title="Stage: $stage (Total Spectrum)")
+ elseif stage == :peaks && normalized_spectrum_total !== nothing
+ fig = Figure(size = (1400, 500))
+ ax = Axis(fig[1, 1], title="Stage: Peaks (Total Spectrum)", xlabel="m/z", ylabel="Intensity")
+ lines!(ax, normalized_spectrum_total[1], normalized_spectrum_total[2], color=:gray, label="Normalized Spectrum")
+ scatter!(ax, mz, intensity, color=:red, marker=:circle, markersize=8, label="Detected Peaks")
+ axislegend(ax)
+ else
+ fig = plot_stage_spectrum(mz, intensity, title="Stage: $stage (Total Spectrum)")
+ end
+
+ # Save separate plot with sequential name
+ stage_output_path = joinpath(output_dir, "$(file_type_prefix)_total_$(stage_counter[])_$(stage).png")
+ save(stage_output_path, fig)
+
+ # Collect data for overlay plot
+ push!(collected_stage_data, (stage, mz, intensity))
+ end
+
+ # 4. Run the pipeline on the single total spectrum
+ println("Running pipeline with steps: $pipeline_steps")
+ processed_result = run_preprocessing_pipeline(
+ [total_spectrum], # Pass the total spectrum as a vector of one spectrum
+ steps=pipeline_steps,
+ params=params,
+ on_stage=stage_callback_total
+ )
+
+ # 5. Generate and save the overlay plot
+ overlay_output_path = joinpath(output_dir, "$(file_type_prefix)_total_all_stages_overlay.png")
+ plot_overlay_stages(collected_stage_data, overlay_output_path, "Preprocessing Stages Overlay (Total Spectrum)")
+
+ # 6. Save feature matrix if generated
+ if processed_result isa FeatureMatrix
+ feature_matrix_output_path = joinpath(output_dir, "$(file_type_prefix)_total_feature_matrix.csv")
+ # Convert mz_bins to a more readable format for CSV
+ mz_labels = ["$(round(b[1], digits=4))_$(round(b[2], digits=4))" for b in processed_result.mz_bins]
+ df = DataFrame(processed_result.matrix, Symbol.(mz_labels))
+ CSV.write(feature_matrix_output_path, df)
+ println("SUCCESS: Feature matrix saved to $feature_matrix_output_path")
+ else
+ @warn "Pipeline did not return a FeatureMatrix for Total Spectrum."
+ processed_result
+ end
+
+ println("--- Pipeline test finished for TOTAL Spectrum (File Type: $file_type_prefix) ---")
+ println("Check the '$(output_dir)' directory for output plots and CSVs.")
+end
+
+
+# ===================================================================
+# TEST RUNNER
+# ===================================================================
+
+function run_preprocessing_tests()
+ println("="^80)
+ println("STARTING PREPROCESSING TEST SUITE")
+ println("="^80)
+
+ # --- Test Case 1: Run full pipeline on a single mzML spectrum ---
+ println("\n" * "="^20 * " Test Case 1: Full Pipeline on .mzML Spectrum " * "="^20)
+ println("FILE: ", TEST_MZML_FILE)
+ if isfile(TEST_MZML_FILE)
+ try
+ msi_data_mzml = OpenMSIData(TEST_MZML_FILE)
+
+ # Dynamically determine tolerance
+ println("\n--- Calculating optimal tolerance for .mzML data ---")
+ report_mzml = analyze_mass_accuracy(msi_data_mzml, get_common_calibration_standards(:maldi_pos))
+ mz_tolerance_mzml = 0.002 # Default
+ if haskey(report_mzml, :optimal_ppm) && !isnan(report_mzml.optimal_ppm) && !isempty(report_mzml.matched_peaks)
+ avg_mz = mean([p[1] for p in report_mzml.matched_peaks])
+ mz_tolerance_mzml = avg_mz * report_mzml.optimal_ppm / 1e6
+ println("Optimal PPM: $(round(report_mzml.optimal_ppm, digits=2)), Average m/z: $(round(avg_mz, digits=2))")
+ println("Calculated m/z tolerance: $(round(mz_tolerance_mzml, digits=5))")
+ else
+ println("Could not determine optimal tolerance, using default: $mz_tolerance_mzml")
+ end
+
+ # Create a dedicated subdirectory for the output plots
+ mzml_output_dir = joinpath(RESULTS_DIR, "mzml_pipeline_stages")
+ mkpath(mzml_output_dir)
+
+ test_full_pipeline(msi_data_mzml, MZML_SPECTRUM_ID, output_dir=mzml_output_dir, file_type_prefix="mzml", mz_tolerance=mz_tolerance_mzml)
+ test_full_pipeline_on_total_spectrum(msi_data_mzml, output_dir=mzml_output_dir, file_type_prefix="mzml", mz_tolerance=mz_tolerance_mzml)
+ catch e
+ println("ERROR in .mzML pipeline test: $e")
+ showerror(stdout, e, catch_backtrace())
+ end
+ else
+ println("SKIPPED: File not found: $TEST_MZML_FILE")
+ end
+
+ # --- Test Case 2: Run full pipeline on a single imzML spectrum ---
+ println("\n" * "="^20 * " Test Case 2: Full Pipeline on .imzML Spectrum " * "="^20)
+ println("FILE: ", TEST_IMZML_FILE)
+ if isfile(TEST_IMZML_FILE)
+ try
+ msi_data_imzml = OpenMSIData(TEST_IMZML_FILE)
+
+ # Dynamically determine tolerance
+ println("\n--- Calculating optimal tolerance for .imzML data ---")
+ report_imzml = analyze_mass_accuracy(msi_data_imzml, get_common_calibration_standards(:maldi_pos))
+ mz_tolerance_imzml = 0.002 # Default
+ if haskey(report_imzml, :optimal_ppm) && !isnan(report_imzml.optimal_ppm) && !isempty(report_imzml.matched_peaks)
+ avg_mz = mean([p[1] for p in report_imzml.matched_peaks])
+ mz_tolerance_imzml = avg_mz * report_imzml.optimal_ppm / 1e6
+ println("Optimal PPM: $(round(report_imzml.optimal_ppm, digits=2)), Average m/z: $(round(avg_mz, digits=2))")
+ println("Calculated m/z tolerance: $(round(mz_tolerance_imzml, digits=5))")
+ else
+ println("Could not determine optimal tolerance, using default: $mz_tolerance_imzml")
+ end
+
+ # Create a dedicated subdirectory for the output plots
+ imzml_output_dir = joinpath(RESULTS_DIR, "imzml_pipeline_stages")
+ mkpath(imzml_output_dir)
+
+ test_full_pipeline(msi_data_imzml, IMZML_COORDS, output_dir=imzml_output_dir, file_type_prefix="imzml", mz_tolerance=mz_tolerance_imzml)
+ test_full_pipeline_on_total_spectrum(msi_data_imzml, output_dir=imzml_output_dir, file_type_prefix="imzml", mz_tolerance=mz_tolerance_imzml)
+ # generate_qc_report(msi_data_imzml, TEST_IMZML_FILE, output_dir=imzml_output_dir)
+ custom_reference_peaks = Dict(
+ 31.974 => "Red Phosphorus",
+ 432.6584 => "P13",
+ 464.6059 => "P15",
+ 526.5534 => "P17",
+ 650.4485 => "P21",
+ 774.3435 => "P25",
+ 898.2385 => "P29",
+ 950.1861 => "P31",
+ 1022.1336 => "P33",
+ 1146.0286 => "P37",
+ 1593.8187 => "P45",
+ 772.433 => "Unknown 1",
+ 772.5253 => "Unknown 2"
+ )
+ n_samples = length(msi_data_imzml.spectra_metadata)
+ generate_qc_report(msi_data_imzml, TEST_IMZML_FILE, reference_peaks=custom_reference_peaks, output_dir=imzml_output_dir, sample_spectra=n_samples)
+ catch e
+ println("ERROR in .imzML pipeline test: $e")
+ showerror(stdout, e, catch_backtrace())
+ end
+ else
+ println("SKIPPED: File not found: $TEST_IMZML_FILE")
+ end
+
+ # --- Test Case 3: Generate QC Report for .imzML data ---
+ println("\n" * "="^20 * " Test Case 3: QC Report Generation for .imzML " * "="^20)
+ println("FILE: ", TEST_IMZML_FILE)
+ if isfile(TEST_IMZML_FILE)
+ try
+ msi_data_imzml = OpenMSIData(TEST_IMZML_FILE)
+
+ # Create a dedicated subdirectory for the QC report
+ qc_output_dir = joinpath(RESULTS_DIR, "qc_report")
+ mkpath(qc_output_dir)
+
+ println("\n--- Generating comprehensive QC report ---")
+ custom_reference_peaks = Dict(
+ 31.974 => "Red Phosphorus",
+ 432.6584 => "P13",
+ 464.6059 => "P15",
+ 526.5534 => "P17",
+ 650.4485 => "P21",
+ 774.3435 => "P25",
+ 898.2385 => "P29",
+ 950.1861 => "P31",
+ 1022.1336 => "P33",
+ 1146.0286 => "P37",
+ 1593.8187 => "P45",
+ 772.433 => "Unknown 1",
+ 772.5253 => "Unknown 2"
+ )
+ # You can control the number of spectra sampled for the QC report.
+ # For the most accurate results, you can sample all spectra, but it will take longer.
+ # To sample all, use: n_samples = length(msi_data_imzml.spectra_metadata)
+ n_samples = length(msi_data_imzml.spectra_metadata)
+
+ #generate_qc_report(msi_data_imzml, TEST_IMZML_FILE, output_dir=qc_output_dir)
+ generate_qc_report(msi_data_imzml, TEST_IMZML_FILE, reference_peaks=custom_reference_peaks, output_dir=qc_output_dir, sample_spectra=n_samples)
+
+ println("\n--- Analyzing specific reference peaks ---")
+ #=
+ reference_peaks = Dict(
+ 104.10754 => "Imidazole",
+ 175.11995 => "GPC fragment",
+ 226.15687 => "Phosphocholine"
+ )
+ =#
+ report = analyze_mass_accuracy(msi_data_imzml, custom_reference_peaks)
+ if haskey(report, :optimal_ppm)
+ println("Optimal PPM tolerance with specific peaks: $(round(report.optimal_ppm, digits=2)) ppm")
+ else
+ println("Could not determine optimal PPM with specific peaks.")
+ end
+
+ catch e
+ println("ERROR in QC report generation test: $e")
+ showerror(stdout, e, catch_backtrace())
+ end
+ else
+ println("SKIPPED: File not found: $TEST_IMZML_FILE")
+ end
+
+ println("\nPreprocessing tests finished.")
+end
+
+# --- Execute ---
+# Ensure the results directory exists
+mkpath(RESULTS_DIR)
+@time run_preprocessing_tests()
\ No newline at end of file
diff --git a/test/run_tests.jl b/test/run_tests.jl
index c079ed9..212dc24 100644
--- a/test/run_tests.jl
+++ b/test/run_tests.jl
@@ -10,7 +10,7 @@
# Instructions:
# 1. Fill in the placeholder paths in the "CONFIG" section below.
# 2. Run the script from the project's root directory:
-# julia test/run_tests.jl
+# julia --project=. test/run_tests.jl
# 3. Check the `test/results/` folder for the output images.
# ===================================================================