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. # ===================================================================