diff --git a/README.md b/README.md index 4ff7686..5c33e11 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) A Julia library for loading, representing, and analysing spatial transcriptomics data. -It provides a common data model for multi-modal spatial experiments — transcripts, cell boundaries, tissue images, segmentation masks, and expression matrices — alongside lazy spatial views, a multi-FOV coordinate system graph, and SpatialData OME-Zarr interoperability with Python tools. +It provides a common data model for multi-modal spatial experiments — transcripts, cell boundaries, tissue images, segmentation masks, and expression matrices — alongside provenance-aware FOV selection, lazy spatial views, a multi-FOV coordinate system graph, explicit persistence, and import support for SpatialData Zarr stores. ## Installation @@ -22,7 +22,7 @@ Pkg.add("SpatialOmics") using CairoMakie # load a Makie backend before plotting using SpatialOmics -# Load from SpatialData OME-Zarr (Xenium, CosMx, Visium, …) +# Load a native store or a supported SpatialData Zarr store ds = read(SpatialDataZarr(), "/path/to/experiment.zarr") ext = SpatialExtent(4000.0, 5000.0, 1000.0, 2000.0; coord_system="global") diff --git a/docs/Manifest.toml b/docs/Manifest.toml index 909e6fe..264d16c 100644 --- a/docs/Manifest.toml +++ b/docs/Manifest.toml @@ -1,17 +1,19 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.12.6" -manifest_format = "2.0" -project_hash = "d3bacc683624fa505250ad2aa50dcc149359ecf9" +julia_version = "1.13.0" +manifest_format = "2.1" +project_hash = "8fe7c6ddbe99bb1c9448997ccf03a4e91853bca6" [[deps.ANSIColoredPrinters]] git-tree-sha1 = "574baf8110975760d391c710b6341da1afa48d8c" +registries = "General" uuid = "a4c015fc-c6ff-483c-b24f-f7ea428134e9" version = "0.0.1" [[deps.AbstractFFTs]] deps = ["LinearAlgebra"] git-tree-sha1 = "d92ad398961a3ed262d8bf04a1a2b8340f915fef" +registries = "General" uuid = "621f4979-c628-5d54-868e-fcf4e3e8185c" version = "1.5.0" weakdeps = ["ChainRulesCore", "Test"] @@ -22,12 +24,14 @@ weakdeps = ["ChainRulesCore", "Test"] [[deps.AbstractTrees]] git-tree-sha1 = "2d9c9a55f9c93e8887ad391fbae72f8ef55e1177" +registries = "General" uuid = "1520ce14-60c1-5f80-bbc7-55ef81b5835c" version = "0.4.5" [[deps.Accessors]] deps = ["CompositionsBase", "ConstructionBase", "Dates", "InverseFunctions", "MacroTools"] git-tree-sha1 = "2eeb2c9bef11013efc6f8f97f32ee59b146b09fb" +registries = "General" uuid = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697" version = "0.1.44" @@ -52,6 +56,7 @@ version = "0.1.44" [[deps.Adapt]] deps = ["LinearAlgebra"] git-tree-sha1 = "28e1637322d4019ed2577cbec9268fab9b7da117" +registries = "General" uuid = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" version = "4.6.0" weakdeps = ["SparseArrays", "StaticArrays"] @@ -62,17 +67,27 @@ weakdeps = ["SparseArrays", "StaticArrays"] [[deps.AdaptivePredicates]] git-tree-sha1 = "7e651ea8d262d2d74ce75fdf47c4d63c07dba7a6" +registries = "General" uuid = "35492f91-a3bd-45ad-95db-fcad7dcfedb7" version = "1.2.0" [[deps.AliasTables]] deps = ["PtrArrays", "Random"] git-tree-sha1 = "9876e1e164b144ca45e9e3198d0b689cadfed9ff" +registries = "General" uuid = "66dad0bd-aa9a-41b7-9441-69ab47430ed8" version = "1.1.3" +[[deps.Animations]] +deps = ["Colors"] +git-tree-sha1 = "e092fa223bf66a3c41f9c022bd074d916dc303e7" +registries = "General" +uuid = "27a7e980-b3e6-11e9-2bcd-0b925532e340" +version = "0.4.2" + [[deps.ArgCheck]] git-tree-sha1 = "f9e9a66c9b7be1ad7372bbd9b062d9230c30c5ce" +registries = "General" uuid = "dce04be8-c92d-5529-be00-80e4d2c0e197" version = "2.5.0" @@ -83,12 +98,14 @@ version = "1.1.2" [[deps.ArnoldiMethod]] deps = ["LinearAlgebra", "Random", "StaticArrays"] git-tree-sha1 = "d57bd3762d308bded22c3b82d033bff85f6195c6" +registries = "General" uuid = "ec485272-7323-5ecc-a04f-4719b315124d" version = "0.4.0" [[deps.ArrayInterface]] deps = ["Adapt", "LinearAlgebra"] git-tree-sha1 = "3d0cabd25fab32390e3bcb82cd67e700aebd9816" +registries = "General" uuid = "4fba245c-0d91-5ea0-9b3e-6abc04ee57a9" version = "7.25.0" @@ -125,6 +142,7 @@ version = "7.25.0" [[deps.ArrayLayouts]] deps = ["FillArrays", "LinearAlgebra", "StaticArrays"] git-tree-sha1 = "e0b47732a192dd59b9d079a06d04235e2f833963" +registries = "General" uuid = "4c555306-a7a7-4459-81d9-ec55ddd5c99a" version = "1.12.2" weakdeps = ["SparseArrays"] @@ -132,25 +150,69 @@ weakdeps = ["SparseArrays"] [deps.ArrayLayouts.extensions] ArrayLayoutsSparseArraysExt = "SparseArrays" +[[deps.ArraysOfArrays]] +deps = ["Compat", "Statistics"] +git-tree-sha1 = "0cbc7e484da4f3772e56d77fbbdf295acd70b6da" +registries = "General" +uuid = "65a8f2f4-9b39-5baf-92e2-a9cc46fdf018" +version = "1.3.0" + + [deps.ArraysOfArrays.extensions] + ArraysOfArraysAdaptExt = "Adapt" + ArraysOfArraysChainRulesCoreExt = "ChainRulesCore" + ArraysOfArraysDiskArraysExt = "DiskArrays" + ArraysOfArraysFixedSizeArraysExt = "FixedSizeArrays" + ArraysOfArraysGPUArraysCoreExt = "GPUArraysCore" + ArraysOfArraysGPUKernelsExt = ["GPUArraysCore", "KernelAbstractions"] + ArraysOfArraysInverseFunctionsExt = "InverseFunctions" + ArraysOfArraysKernelAbstractionsExt = "KernelAbstractions" + ArraysOfArraysMooncakeExt = "Mooncake" + ArraysOfArraysReactantStaticArraysCoreExt = ["GPUArraysCore", "Reactant", "StaticArraysCore"] + ArraysOfArraysStaticArraysCoreExt = "StaticArraysCore" + ArraysOfArraysStructArraysExt = "StructArrays" + + [deps.ArraysOfArrays.weakdeps] + Adapt = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + DiskArrays = "3c3547ce-8d99-4f5e-a174-61eb10b00ae3" + FixedSizeArrays = "3821ddf9-e5b5-40d5-8e25-6813ab96b5e2" + GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" + Mooncake = "da2b9cff-9c12-43a0-ae48-6db2b0edb7d6" + Reactant = "3c362404-f566-11ee-1572-e11a4b42c853" + StaticArraysCore = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" + StructArrays = "09ab397b-f2b6-538f-b94a-2f83cf4a842a" + [[deps.Artifacts]] uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" version = "1.11.0" +[[deps.Automa]] +deps = ["PrecompileTools", "TranscodingStreams"] +git-tree-sha1 = "94eab0b3ccdcac361188cc661daf69d4433c1818" +registries = "General" +uuid = "67c07d97-cdcb-5c2c-af73-a7f9c32a568b" +version = "1.2.0" + [[deps.AxisAlgorithms]] deps = ["LinearAlgebra", "Random", "SparseArrays", "WoodburyMatrices"] git-tree-sha1 = "01b8ccb13d68535d73d2b0c23e39bd23155fb712" +registries = "General" uuid = "13072b0f-2c55-5437-9ae7-d433b7a33950" version = "1.1.0" [[deps.AxisArrays]] deps = ["Dates", "IntervalSets", "IterTools", "RangeArrays"] git-tree-sha1 = "4126b08903b777c88edf1754288144a0492c05ad" +registries = "General" uuid = "39de3d68-74b9-583c-8d2d-e117c070f3a9" version = "0.4.8" [[deps.BangBang]] deps = ["Accessors", "ConstructionBase", "InitialValues", "LinearAlgebra"] git-tree-sha1 = "cceb62468025be98d42a5dc581b163c20896b040" +registries = "General" uuid = "198e06fe-97b7-11e9-32a5-e1d131e6ad66" version = "0.4.9" @@ -174,54 +236,69 @@ version = "0.4.9" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" version = "1.11.0" +[[deps.BaseDirs]] +git-tree-sha1 = "8c290a1b223deaeea9aea44b235d24546da8eb98" +registries = "General" +uuid = "18cc8868-cbac-4acf-b575-c8ff214dc66f" +version = "1.4.0" + [[deps.Baselet]] git-tree-sha1 = "aebf55e6d7795e02ca500a689d326ac979aaf89e" +registries = "General" uuid = "9718e550-a3fa-408a-8086-8db961cd8217" version = "0.1.1" [[deps.BitFlags]] git-tree-sha1 = "0691e34b3bb8be9307330f88d1a3c3f25466c24d" +registries = "General" uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" version = "0.1.9" [[deps.BitIntegers]] deps = ["Random"] git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46" +registries = "General" uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" version = "0.3.7" [[deps.BitTwiddlingConvenienceFunctions]] deps = ["Static"] git-tree-sha1 = "f21cfd4950cb9f0587d5067e69405ad2acd27b87" +registries = "General" uuid = "62783981-4cbd-42fc-bca8-16325de8dc4b" version = "0.1.6" [[deps.Blosc]] deps = ["Blosc_jll"] git-tree-sha1 = "310b77648d38c223d947ff3f50f511d08690b8d5" +registries = "General" uuid = "a74b3585-a348-5f62-a45c-50e91977d574" version = "0.7.3" [[deps.Blosc_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Lz4_jll", "Zlib_jll", "Zstd_jll"] git-tree-sha1 = "535c80f1c0847a4c967ea945fca21becc9de1522" +registries = "General" uuid = "0b7ba130-8d10-5ba8-a3d6-c5182647fed9" version = "1.21.7+0" [[deps.Bzip2_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "1b96ea4a01afe0ea4090c5c8039690672dd13f2e" +registries = "General" uuid = "6e34b625-4abd-537c-b88f-471c36dfa7a0" version = "1.0.9+0" [[deps.CEnum]] git-tree-sha1 = "389ad5c84de1ae7cf0e28e381131c98ea87d54fc" +registries = "General" uuid = "fa961155-64e5-5f13-b03f-caf6b980ea82" version = "0.5.0" [[deps.CPUSummary]] deps = ["CpuId", "IfElse", "PrecompileTools", "Preferences", "Static"] git-tree-sha1 = "f3a21d7fc84ba618a779d1ed2fcca2e682865bab" +registries = "General" uuid = "2a0fbf3d-bb9c-48f3-b0a9-814d99fd7ab9" version = "0.2.7" @@ -232,30 +309,56 @@ version = "1.11.0" [[deps.CRlibm]] deps = ["CRlibm_jll"] git-tree-sha1 = "66188d9d103b92b6cd705214242e27f5737a1e5e" +registries = "General" uuid = "96374032-68de-5a5b-8d9e-752f78720389" version = "1.0.2" [[deps.CRlibm_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] git-tree-sha1 = "e329286945d0cfc04456972ea732551869af1cfc" +registries = "General" uuid = "4e9b3aee-d8a1-5a3d-ad8b-7d824db253f0" version = "1.0.1+0" [[deps.CSV]] deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] git-tree-sha1 = "8d8e0b0f350b8e1c91420b5e64e5de774c2f0f4d" +registries = "General" uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" version = "0.10.16" +[[deps.Cairo]] +deps = ["Cairo_jll", "Colors", "Glib_jll", "Graphics", "Libdl", "Pango_jll"] +git-tree-sha1 = "71aa551c5c33f1a4415867fe06b7844faadb0ae9" +registries = "General" +uuid = "159f3aea-2a34-519c-b102-8c37f9878175" +version = "1.1.1" + +[[deps.CairoMakie]] +deps = ["CRC32c", "Cairo", "Cairo_jll", "Colors", "FileIO", "FreeType", "GeometryBasics", "LinearAlgebra", "Makie", "PrecompileTools"] +git-tree-sha1 = "1cda0b7d5abfc95357dae18aca934d401f7869ad" +registries = "General" +uuid = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +version = "0.15.15" + +[[deps.Cairo_jll]] +deps = ["Artifacts", "Bzip2_jll", "CompilerSupportLibraries_jll", "Fontconfig_jll", "FreeType2_jll", "Glib_jll", "JLLWrappers", "Libdl", "Pixman_jll", "Xorg_libXext_jll", "Xorg_libXrender_jll", "Zlib_jll", "libpng_jll"] +git-tree-sha1 = "1fa950ebc3e37eccd51c6a8fe1f92f7d86263522" +registries = "General" +uuid = "83423d85-b0ee-5818-9007-b63ccbeb887a" +version = "1.18.7+0" + [[deps.CatIndices]] deps = ["CustomUnitRanges", "OffsetArrays"] git-tree-sha1 = "a0f80a09780eed9b1d106a1bf62041c2efc995bc" +registries = "General" uuid = "aafaddc9-749c-510e-ac4f-586e18779b91" version = "0.2.2" [[deps.ChainRulesCore]] deps = ["Compat", "LinearAlgebra"] git-tree-sha1 = "12177ad6b3cad7fd50c8b3825ce24a99ad61c18f" +registries = "General" uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" version = "1.26.1" weakdeps = ["SparseArrays"] @@ -265,78 +368,98 @@ weakdeps = ["SparseArrays"] [[deps.ChunkCodecCore]] git-tree-sha1 = "1a3ad7e16a321667698a19e77362b35a1e94c544" +registries = "General" uuid = "0b6fb165-00bc-4d37-ab8b-79f91016dbe1" version = "1.0.1" [[deps.ChunkCodecLibBrotli]] deps = ["ChunkCodecCore", "brotli_jll"] git-tree-sha1 = "45709ad3ba09bdff5e6481d2c1727b1499989997" +registries = "General" uuid = "653b0ff7-85b5-4442-93c1-dcc330d3ec7d" version = "1.0.0" [[deps.ChunkCodecLibLz4]] deps = ["ChunkCodecCore", "Lz4_jll"] git-tree-sha1 = "0a4d7695ef98ab714efe5aef26fc35c3b0b4c1ee" +registries = "General" uuid = "7e9cc85e-5614-42a3-ad86-b78f920b38a5" version = "1.0.0" [[deps.ChunkCodecLibSnappy]] deps = ["ChunkCodecCore", "snappy_jll"] git-tree-sha1 = "a9e98b8cc7ccdcfcb406773a6c58987daa6eda05" +registries = "General" uuid = "eac87354-86d5-4a5b-ab5f-a6ee56b239b3" version = "1.0.0" [[deps.ChunkCodecLibZlib]] deps = ["ChunkCodecCore", "Zlib_jll"] git-tree-sha1 = "cee8104904c53d39eb94fd06cbe60cb5acde7177" +registries = "General" uuid = "4c0bbee4-addc-4d73-81a0-b6caacae83c8" version = "1.0.0" [[deps.ChunkCodecLibZstd]] deps = ["ChunkCodecCore", "Zstd_jll"] git-tree-sha1 = "34d9873079e4cb3d0c62926a225136824677073f" +registries = "General" uuid = "55437552-ac27-4d47-9aa3-63184e8fd398" version = "1.0.0" [[deps.CloseOpenIntervals]] deps = ["Static", "StaticArrayInterface"] git-tree-sha1 = "05ba0d07cd4fd8b7a39541e31a7b0254704ea581" +registries = "General" uuid = "fb6a15b2-703c-40df-9091-08a04967cfa9" version = "0.1.13" [[deps.Clustering]] deps = ["Distances", "LinearAlgebra", "NearestNeighbors", "Printf", "Random", "SparseArrays", "Statistics", "StatsBase"] git-tree-sha1 = "3e22db924e2945282e70c33b75d4dde8bfa44c94" +registries = "General" uuid = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" version = "0.15.8" [[deps.CodecInflate64]] deps = ["TranscodingStreams"] git-tree-sha1 = "d981a6e8656b1e363a2731716f46851a2257deb7" +registries = "General" uuid = "6309b1aa-fc58-479c-8956-599a07234577" version = "0.1.3" [[deps.CodecZlib]] deps = ["TranscodingStreams", "Zlib_jll"] git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" +registries = "General" uuid = "944b1d66-785c-5afd-91f1-9de20f533193" version = "0.7.8" [[deps.CodecZstd]] deps = ["TranscodingStreams", "Zstd_jll"] git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56" +registries = "General" uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" version = "0.8.7" +[[deps.ColorBrewer]] +deps = ["Colors", "JSON"] +git-tree-sha1 = "07da79661b919001e6863b81fc572497daa58349" +registries = "General" +uuid = "a2cac450-b92f-5266-8821-25eda20663c8" +version = "0.4.2" + [[deps.ColorSchemes]] deps = ["ColorTypes", "ColorVectorSpace", "Colors", "FixedPointNumbers", "PrecompileTools", "Random"] git-tree-sha1 = "b0fd3f56fa442f81e0a47815c92245acfaaa4e34" +registries = "General" uuid = "35d6a980-a343-548e-a6ea-1d62b119f2f4" version = "3.31.0" [[deps.ColorTypes]] deps = ["FixedPointNumbers", "Random"] git-tree-sha1 = "67e11ee83a43eb71ddc950302c53bf33f0690dfe" +registries = "General" uuid = "3da002f7-5984-5a60-b8a6-cbb66c0b333f" version = "0.12.1" weakdeps = ["StyledStrings"] @@ -347,6 +470,7 @@ weakdeps = ["StyledStrings"] [[deps.ColorVectorSpace]] deps = ["ColorTypes", "FixedPointNumbers", "LinearAlgebra", "Requires", "Statistics", "TensorCore"] git-tree-sha1 = "8b3b6f87ce8f65a2b4f857528fd8d70086cd72b1" +registries = "General" uuid = "c3611d14-8923-5661-9e6a-0046d554d3a4" version = "0.11.0" weakdeps = ["SpecialFunctions"] @@ -357,17 +481,27 @@ weakdeps = ["SpecialFunctions"] [[deps.Colors]] deps = ["ColorTypes", "FixedPointNumbers", "Reexport"] git-tree-sha1 = "37ea44092930b1811e666c3bc38065d7d87fcc74" +registries = "General" uuid = "5ae59095-9a9b-59fe-a467-6f913c188581" version = "0.13.1" +[[deps.CommonSolve]] +deps = ["PrecompileTools"] +git-tree-sha1 = "6c389fa857f6ca5a95474b52a52023fd77f24cb7" +registries = "General" +uuid = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2" +version = "0.2.14" + [[deps.CommonWorldInvalidations]] git-tree-sha1 = "ae52d1c52048455e85a387fbee9be553ec2b68d0" +registries = "General" uuid = "f70d9fcc-98c5-4d4a-abd7-e4cdeebd8ca8" version = "1.0.0" [[deps.Compat]] deps = ["TOML", "UUIDs"] git-tree-sha1 = "9d8a54ce4b17aa5bdce0ea5c34bc5e7c340d16ad" +registries = "General" uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" version = "4.18.1" weakdeps = ["Dates", "LinearAlgebra"] @@ -378,10 +512,11 @@ weakdeps = ["Dates", "LinearAlgebra"] [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" -version = "1.3.0+1" +version = "1.5.5+2" [[deps.CompositionsBase]] git-tree-sha1 = "802bb88cd69dfd1509f6670416bd4434015693ad" +registries = "General" uuid = "a33af91c-f02d-484b-be07-31d278c5ca2b" version = "0.1.2" weakdeps = ["InverseFunctions"] @@ -391,17 +526,27 @@ weakdeps = ["InverseFunctions"] [[deps.ComputationalResources]] git-tree-sha1 = "52cb3ec90e8a8bea0e62e275ba577ad0f74821f7" +registries = "General" uuid = "ed09eef8-17a6-5b46-8889-db040fac31e3" version = "0.3.2" +[[deps.ComputePipeline]] +deps = ["Observables", "Preferences"] +git-tree-sha1 = "7bc84b769c1d384315e7b5c4ac03a6c303e6cf35" +registries = "General" +uuid = "95dc2771-c249-4cd0-9c9f-1f3b4330693c" +version = "0.1.8" + [[deps.ConcurrentUtilities]] deps = ["Serialization", "Sockets"] git-tree-sha1 = "21d088c496ea22914fe80906eb5bce65755e5ec8" +registries = "General" uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" version = "2.5.1" [[deps.ConstructionBase]] git-tree-sha1 = "b4b092499347b18a015186eae3042f72267106cb" +registries = "General" uuid = "187b0558-2788-49d3-abe0-74a17ed4e7c9" version = "1.6.0" weakdeps = ["IntervalSets", "LinearAlgebra", "StaticArrays"] @@ -411,54 +556,75 @@ weakdeps = ["IntervalSets", "LinearAlgebra", "StaticArrays"] ConstructionBaseLinearAlgebraExt = "LinearAlgebra" ConstructionBaseStaticArraysExt = "StaticArrays" +[[deps.Contour]] +git-tree-sha1 = "439e35b0b36e2e5881738abc8857bd92ad6ff9a8" +registries = "General" +uuid = "d38c429a-6771-53c6-b99e-75d170b6e991" +version = "0.6.3" + [[deps.CoordinateTransformations]] deps = ["LinearAlgebra", "StaticArrays"] git-tree-sha1 = "a692f5e257d332de1e554e4566a4e5a8a72de2b2" +registries = "General" uuid = "150eb455-5306-5404-9cee-2592286d6298" version = "0.6.4" [[deps.CoreMath]] deps = ["CoreMath_jll"] git-tree-sha1 = "8c0480f92b1b1796239156a1b9b1bfb1b39499b4" +registries = "General" uuid = "b7a15901-be09-4a0e-87d2-2e66b0e09b5a" version = "0.1.0" [[deps.CoreMath_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "a692a4c1dc59a4b8bc0b6403876eb3250fde2bc3" +registries = "General" uuid = "a38c48d9-6df1-5ac9-9223-b6ada3b5572b" version = "0.1.0+0" [[deps.CpuId]] deps = ["Markdown"] git-tree-sha1 = "fcbb72b032692610bfbdb15018ac16a36cf2e406" +registries = "General" uuid = "adafc99b-e345-5852-983c-f28acb93d879" version = "0.3.1" [[deps.CustomUnitRanges]] git-tree-sha1 = "1a3f97f907e6dd8983b744d2642651bb162a3f7a" +registries = "General" uuid = "dc8bdbbb-1ca9-579f-8c36-e416f6a65cce" version = "1.0.2" [[deps.DataAPI]] git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" +registries = "General" uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" version = "1.16.0" +[[deps.DataPipes]] +git-tree-sha1 = "3fb39158bc35c984cac5edb1ff55daa88a4b5074" +registries = "General" +uuid = "02685ad9-2d12-40c3-9f73-c6aeda6a7ff5" +version = "0.3.19" + [[deps.DataStructures]] deps = ["OrderedCollections"] git-tree-sha1 = "e86f4a2805f7f19bec5129bc9150c38208e5dc23" +registries = "General" uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" version = "0.19.4" [[deps.DataValueInterfaces]] git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" +registries = "General" uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" version = "1.0.0" [[deps.DateTimes64]] deps = ["Dates"] git-tree-sha1 = "1db3d38eecf7c197f5839d2afd6aedb15a8753b3" +registries = "General" uuid = "b342263e-b350-472a-b1a9-8dfd21b51589" version = "1.0.1" @@ -470,35 +636,41 @@ version = "1.11.0" [[deps.DecFP]] deps = ["DecFP_jll", "Printf", "Random", "SpecialFunctions"] git-tree-sha1 = "3b98337b5b709548754973b9d79f4d5f2038c7cf" +registries = "General" uuid = "55939f99-70c6-5e9b-8bb0-5071ed7d61fd" version = "1.4.2" [[deps.DecFP_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "40e4404a0a267a8e75f5c1ce2cc9b5e2ce1ba268" +registries = "General" uuid = "47200ebd-12ce-5be5-abb7-8e082af23329" version = "2.0.300+0" [[deps.DefineSingletons]] git-tree-sha1 = "0fba8b706d0178b4dc7fd44a96a92382c9065c2c" +registries = "General" uuid = "244e2a9f-e319-4986-a169-4d1fe445cd52" version = "0.1.2" [[deps.DelaunayTriangulation]] deps = ["AdaptivePredicates", "EnumX", "ExactPredicates", "Random"] git-tree-sha1 = "c55f5a9fd67bdbc8e089b5a3111fe4292986a8e8" +registries = "General" uuid = "927a84f5-c5f4-47a5-9785-b46e178433df" version = "1.6.6" [[deps.DiskArrays]] deps = ["ConstructionBase", "LRUCache", "Mmap", "OffsetArrays"] git-tree-sha1 = "e5d9ce1b751ddf9bcd9d36b51249dce8ea73cd55" +registries = "General" uuid = "3c3547ce-8d99-4f5e-a174-61eb10b00ae3" version = "0.4.19" [[deps.Distances]] deps = ["LinearAlgebra", "Statistics", "StatsAPI"] git-tree-sha1 = "c7e3a542b999843086e2f29dac96a618c105be1d" +registries = "General" uuid = "b4f34e82-e78d-54a5-968a-f98e89d6e8f7" version = "0.10.12" weakdeps = ["ChainRulesCore", "SparseArrays"] @@ -512,16 +684,37 @@ deps = ["Random", "Serialization", "Sockets"] uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" version = "1.11.0" +[[deps.Distributions]] +deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "Roots", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] +git-tree-sha1 = "a958ab3a40c755563f5e1405c0846cb0446bf19d" +registries = "General" +uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" +version = "0.25.131" + + [deps.Distributions.extensions] + DistributionsChainRulesCoreExt = "ChainRulesCore" + DistributionsDensityInterfaceExt = "DensityInterface" + DistributionsSparseConnectivityTracerExt = "SparseConnectivityTracer" + DistributionsTestExt = "Test" + + [deps.Distributions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + DensityInterface = "b429d917-457f-4dbc-8f4c-0cc954292b1d" + SparseConnectivityTracer = "9f842d2f-2579-4b1d-911e-f412cf18a3f5" + Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + [[deps.DocStringExtensions]] git-tree-sha1 = "7442a5dfe1ebb773c29cc2962a8980f47221d76c" +registries = "General" uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" version = "0.9.5" [[deps.Documenter]] deps = ["ANSIColoredPrinters", "AbstractTrees", "Base64", "CodecZlib", "Dates", "DocStringExtensions", "Downloads", "Git", "IOCapture", "InteractiveUtils", "JSON", "Logging", "Markdown", "MarkdownAST", "Pkg", "PrecompileTools", "REPL", "RegistryInstances", "SHA", "TOML", "Test", "Unicode"] -git-tree-sha1 = "56e9c37b5e7c3b4f080ab1da18d72d5c290e184a" +git-tree-sha1 = "191e6bef0cf32cac3a3913cd4787d851715254c2" +registries = "General" uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -version = "1.17.0" +version = "1.19.0" [[deps.Downloads]] deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] @@ -531,63 +724,88 @@ version = "1.7.0" [[deps.EarCut_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] git-tree-sha1 = "e3290f2d49e661fbd94046d7e3726ffcb2d41053" +registries = "General" uuid = "5ae413db-bbd1-5e63-b57d-d24a61df00f5" version = "2.2.4+0" [[deps.EnumX]] git-tree-sha1 = "c49898e8438c828577f04b92fc9368c388ac783c" +registries = "General" uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" version = "1.0.7" [[deps.ExactPredicates]] deps = ["IntervalArithmetic", "Random", "StaticArrays"] git-tree-sha1 = "83231673ea4d3d6008ac74dc5079e77ab2209d8f" +registries = "General" uuid = "429591f6-91af-11e9-00e2-59fbe8cec110" version = "2.2.9" [[deps.ExceptionUnwrapping]] deps = ["Test"] git-tree-sha1 = "d36f682e590a83d63d1c7dbd287573764682d12a" +registries = "General" uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" version = "0.1.11" [[deps.Expat_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "27af30de8b5445644e8ffe3bcb0d72049c089cf1" +registries = "General" uuid = "2e619515-83b5-522b-bb60-26c02a35a201" version = "2.7.3+0" [[deps.Extents]] git-tree-sha1 = "b309b36a9e02fe7be71270dd8c0fd873625332b4" +registries = "General" uuid = "411431e0-e8b7-467b-b5e0-f676ba4f2910" version = "0.1.6" +[[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", "libva_jll", "libvorbis_jll", "x264_jll", "x265_jll"] +git-tree-sha1 = "e3c081ec777297fb8fc433012d15a6eaf806b4d2" +registries = "General" +uuid = "b22a6f82-2f65-5046-a5b2-351ab43fb4e5" +version = "9.0.1+0" + +[[deps.FFTA]] +deps = ["AbstractFFTs", "DocStringExtensions", "LinearAlgebra", "MuladdMacro", "Primes", "Random", "Reexport"] +git-tree-sha1 = "65e55303b72f4a567a51b174dd2c47496efeb95a" +registries = "General" +uuid = "b86e33f2-c0db-4aa1-a6e0-ab43e668529e" +version = "0.3.1" + [[deps.FFTViews]] deps = ["CustomUnitRanges", "FFTW"] git-tree-sha1 = "cbdf14d1e8c7c8aacbe8b19862e0179fd08321c2" +registries = "General" uuid = "4f61f5a4-77b1-5117-aa51-3ab5ef4ef0cd" version = "0.3.2" [[deps.FFTW]] deps = ["AbstractFFTs", "FFTW_jll", "Libdl", "LinearAlgebra", "MKL_jll", "Preferences", "Reexport"] git-tree-sha1 = "97f08406df914023af55ade2f843c39e99c5d969" +registries = "General" uuid = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341" version = "1.10.0" [[deps.FFTW_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "6866aec60ef98e3164cd8d6855225684207e9dff" +registries = "General" uuid = "f5851436-0d7a-5f13-b9de-f02708fd171a" version = "3.3.12+0" [[deps.FNVHash]] git-tree-sha1 = "d6de2c735a8bffce9bc481942dfa453cc815357e" +registries = "General" uuid = "5207ad80-27db-4d23-8732-fa0bd339ea89" version = "0.1.0" [[deps.FileIO]] deps = ["Pkg", "Requires", "UUIDs"] git-tree-sha1 = "8e9c059d6857607253e837730dbf780b6b151acd" +registries = "General" uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" version = "1.19.0" weakdeps = ["HTTP"] @@ -595,9 +813,27 @@ weakdeps = ["HTTP"] [deps.FileIO.extensions] HTTPExt = "HTTP" +[[deps.FilePaths]] +deps = ["FilePathsBase", "MacroTools", "Reexport"] +git-tree-sha1 = "a1b2fbfe98503f15b665ed45b3d149e5d8895e4c" +registries = "General" +uuid = "8fc22ac5-c921-52a6-82fd-178b2807b824" +version = "0.9.0" + + [deps.FilePaths.extensions] + FilePathsGlobExt = "Glob" + FilePathsURIParserExt = "URIParser" + FilePathsURIsExt = "URIs" + + [deps.FilePaths.weakdeps] + Glob = "c27321d9-0574-5035-807b-f59d2c89b15c" + URIParser = "30578b45-9adc-5946-b283-645ec420af67" + URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" + [[deps.FilePathsBase]] deps = ["Compat", "Dates"] git-tree-sha1 = "3bab2c5aa25e7840a4b065805c0cdfc01f3068d2" +registries = "General" uuid = "48062228-2e41-5def-b9a4-89aafe57970f" version = "0.9.24" weakdeps = ["Mmap", "Test"] @@ -613,8 +849,10 @@ version = "1.11.0" [[deps.FillArrays]] deps = ["LinearAlgebra"] git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" +registries = "General" uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" version = "1.16.0" +weakdeps = ["PDMats", "SparseArrays", "StaticArrays", "Statistics"] [deps.FillArrays.extensions] FillArraysPDMatsExt = "PDMats" @@ -622,31 +860,111 @@ version = "1.16.0" FillArraysStaticArraysExt = "StaticArrays" FillArraysStatisticsExt = "Statistics" - [deps.FillArrays.weakdeps] - PDMats = "90014a1f-27ba-587c-ab20-58faa44d9150" - SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" - StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" - Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" - [[deps.FixedPointNumbers]] deps = ["Statistics"] git-tree-sha1 = "05882d6995ae5c12bb5f36dd2ed3f61c98cbb172" +registries = "General" uuid = "53c48c17-4a7d-5ca2-90c5-79b7896eea93" version = "0.8.5" +[[deps.FlexiJoins]] +deps = ["Accessors", "ArraysOfArrays", "DataAPI", "DataPipes", "FlexiMaps", "IntervalSets", "NearestNeighbors", "SentinelViews", "StaticArrays", "StructArrays"] +git-tree-sha1 = "584e4d67a8db1d442c2c42adfe94bf2a775bc49d" +registries = "General" +uuid = "e37f2e79-19fa-4eb7-8510-b63b51fe0a37" +version = "0.1.39" + + [deps.FlexiJoins.extensions] + DataFramesExt = "DataFrames" + SkyCoordsExt = "SkyCoords" + + [deps.FlexiJoins.weakdeps] + DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" + SkyCoords = "fc659fc5-75a3-5475-a2ea-3da92c065361" + +[[deps.FlexiMaps]] +deps = ["Accessors", "DataPipes", "InverseFunctions"] +git-tree-sha1 = "c2e79264c5e749d099d7ae854f64ec73f2f9e3e9" +registries = "General" +uuid = "6394faf6-06db-4fa8-b750-35ccc60383f7" +version = "0.1.29" + + [deps.FlexiMaps.extensions] + AxisKeysExt = "AxisKeys" + DictionariesExt = "Dictionaries" + IntervalSetsExt = "IntervalSets" + StructArraysExt = "StructArrays" + UnitfulExt = "Unitful" + + [deps.FlexiMaps.weakdeps] + AxisKeys = "94b1ba4f-4ee9-5380-92f1-94cde586c3c5" + Dictionaries = "85a47980-9c8c-11e8-2b9f-f7ca1fa99fb4" + IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" + StructArrays = "09ab397b-f2b6-538f-b94a-2f83cf4a842a" + Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" + +[[deps.Fontconfig_jll]] +deps = ["Artifacts", "Bzip2_jll", "Expat_jll", "FreeType2_jll", "JLLWrappers", "Libdl", "Libuuid_jll", "Zlib_jll"] +git-tree-sha1 = "f85dac9a96a01087df6e3a749840015a0ca3817d" +registries = "General" +uuid = "a3f928ae-7b40-5064-980b-68af3947d34b" +version = "2.17.1+0" + +[[deps.Format]] +git-tree-sha1 = "9c68794ef81b08086aeb32eeaf33531668d5f5fc" +registries = "General" +uuid = "1fa38f19-a742-5d3f-a2b9-30dd87b9d5f8" +version = "1.3.7" + +[[deps.FreeType]] +deps = ["CEnum", "FreeType2_jll"] +git-tree-sha1 = "907369da0f8e80728ab49c1c7e09327bf0d6d999" +registries = "General" +uuid = "b38be410-82b0-50bf-ab77-7b57e271db43" +version = "4.1.1" + +[[deps.FreeType2_jll]] +deps = ["Artifacts", "Bzip2_jll", "JLLWrappers", "Libdl", "Zlib_jll"] +git-tree-sha1 = "70329abc09b886fd2c5d94ad2d9527639c421e3e" +registries = "General" +uuid = "d7e528f0-a631-5988-bf34-fe36492bcfd7" +version = "2.14.3+1" + +[[deps.FreeTypeAbstraction]] +deps = ["BaseDirs", "ColorVectorSpace", "Colors", "FreeType", "GeometryBasics", "Mmap"] +git-tree-sha1 = "4ebb930ef4a43817991ba35db6317a05e59abd11" +registries = "General" +uuid = "663a7486-cb36-511b-a19d-713bb74d65c9" +version = "0.10.8" + +[[deps.FriBidi_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "7a214fdac5ed5f59a22c2d9a885a16da1c74bbc7" +registries = "General" +uuid = "559328eb-81f9-559d-9380-de523a88c83c" +version = "1.0.17+0" + [[deps.Future]] deps = ["Random"] uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" version = "1.11.0" +[[deps.Gamma]] +git-tree-sha1 = "86f86b6168a016ed88e4ae4e64577b98c3b59e8e" +registries = "General" +uuid = "a0844989-3bd2-4988-8bea-c9407ab0941b" +version = "1.1.0" + [[deps.GeoFormatTypes]] git-tree-sha1 = "7528a7956248c723d01a0a9b0447bf254bf4da52" +registries = "General" uuid = "68eda718-8dee-11e9-39e7-89f7f65f511f" version = "0.4.5" [[deps.GeoInterface]] deps = ["DataAPI", "Extents", "GeoFormatTypes"] git-tree-sha1 = "2b0312a0c06b4408773c6dc1829b472ea706f058" +registries = "General" uuid = "cf35fbd7-0cd7-5166-be24-54bfbe79505f" version = "1.6.1" @@ -662,6 +980,7 @@ version = "1.6.1" [[deps.GeometryBasics]] deps = ["EarCut_jll", "Extents", "IterTools", "LinearAlgebra", "PrecompileTools", "Random", "StaticArrays"] git-tree-sha1 = "1f5a80f4ed9f5a4aada88fc2db456e637676414b" +registries = "General" uuid = "5c1252a2-5f33-56bf-86c9-59e7332b4326" version = "0.5.10" weakdeps = ["GeoInterface"] @@ -672,6 +991,7 @@ weakdeps = ["GeoInterface"] [[deps.GeometryOps]] deps = ["AbstractTrees", "AdaptivePredicates", "CoordinateTransformations", "DataAPI", "DelaunayTriangulation", "ExactPredicates", "Extents", "GeoFormatTypes", "GeoInterface", "GeometryOpsCore", "LinearAlgebra", "Random", "SortTileRecursiveTree", "StaticArrays", "Statistics", "Tables"] git-tree-sha1 = "1a51219dd36002699e2618ad364b9cc5c97684ef" +registries = "General" uuid = "3251bfac-6a57-4b6d-aa61-ac1fef2975ab" version = "0.1.40" @@ -694,48 +1014,77 @@ version = "0.1.40" [[deps.GeometryOpsCore]] deps = ["DataAPI", "GeoInterface", "StableTasks", "Tables"] git-tree-sha1 = "3148a79daf82235877a8a49b5a6c35b6d8cf162d" +registries = "General" uuid = "05efe853-fabf-41c8-927e-7063c8b9f013" version = "0.1.10" +[[deps.GettextRuntime_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Libiconv_jll"] +git-tree-sha1 = "45288942190db7c5f760f59c04495064eedf9340" +registries = "General" +uuid = "b0724c58-0f36-5564-988d-3bb0596ebc4a" +version = "0.22.4+0" + [[deps.Ghostscript_jll]] deps = ["Artifacts", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Zlib_jll"] git-tree-sha1 = "38044a04637976140074d0b0621c1edf0eb531fd" +registries = "General" uuid = "61579ee1-b43e-5ca0-a5da-69d92c66a64b" version = "9.55.1+0" [[deps.Giflib_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "6570366d757b50fabae9f4315ad74d2e40c0560a" +registries = "General" uuid = "59f7168a-df46-5410-90c8-f2779963d0ec" version = "5.2.3+0" [[deps.Git]] deps = ["Git_LFS_jll", "Git_jll", "JLLWrappers", "OpenSSH_jll"] git-tree-sha1 = "824a1890086880696fc908fe12a17bcf61738bd8" +registries = "General" uuid = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2" version = "1.5.0" [[deps.Git_LFS_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "bb8471f313ed941f299aa53d32a94ab3bee08844" +registries = "General" uuid = "020c3dae-16b3-5ae5-87b3-4cb189e250b2" version = "3.7.0+0" [[deps.Git_jll]] deps = ["Artifacts", "Expat_jll", "JLLWrappers", "LibCURL_jll", "Libdl", "Libiconv_jll", "OpenSSL_jll", "PCRE2_jll", "Zlib_jll"] git-tree-sha1 = "dc34a3e3d96b4ed305b641e626dc14c12b7824b8" +registries = "General" uuid = "f8c6e375-362e-5223-8a59-34ff63f689eb" version = "2.53.0+0" +[[deps.Glib_jll]] +deps = ["Artifacts", "GettextRuntime_jll", "JLLWrappers", "Libdl", "Libffi_jll", "Libiconv_jll", "Libmount_jll", "PCRE2_jll", "Zlib_jll"] +git-tree-sha1 = "090526e65de8f69648ac156daae153de8b56df62" +registries = "General" +uuid = "7746bdde-850d-59dc-9ae8-88ece973131d" +version = "2.88.3+0" + [[deps.Graphics]] deps = ["Colors", "LinearAlgebra", "NaNMath"] git-tree-sha1 = "a641238db938fff9b2f60d08ed9030387daf428c" +registries = "General" uuid = "a2bd30eb-e257-5431-a919-1863eab51364" version = "1.1.3" +[[deps.Graphite2_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "69ffb934a5c5b7e086a0b4fee3427db2556fba6e" +registries = "General" +uuid = "3b182d85-2403-5c21-9c21-1e1f0cc25472" +version = "1.3.16+0" + [[deps.Graphs]] deps = ["ArnoldiMethod", "DataStructures", "Inflate", "LinearAlgebra", "Random", "SimpleTraits", "SparseArrays", "Statistics"] git-tree-sha1 = "7eb45fe833a5b7c51cf6d89c5a841d5967e44be3" +registries = "General" uuid = "86223c79-3864-5bf0-83f7-82e725a168b6" version = "1.14.0" weakdeps = ["Distributed", "SharedArrays"] @@ -743,171 +1092,221 @@ weakdeps = ["Distributed", "SharedArrays"] [deps.Graphs.extensions] GraphsSharedArraysExt = "SharedArrays" +[[deps.GridLayoutBase]] +deps = ["GeometryBasics", "InteractiveUtils", "Observables"] +git-tree-sha1 = "ef70da5e123a06a29e2d6ddff0f09985bc226491" +registries = "General" +uuid = "3955a311-db13-416c-9275-1d80ed98e5e9" +version = "0.11.3" + [[deps.HTTP]] deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] git-tree-sha1 = "51059d23c8bb67911a2e6fd5130229113735fc7e" +registries = "General" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" version = "1.11.0" +[[deps.HarfBuzz_jll]] +deps = ["Artifacts", "Cairo_jll", "Fontconfig_jll", "FreeType2_jll", "Glib_jll", "Graphite2_jll", "JLLWrappers", "Libdl", "Libffi_jll"] +git-tree-sha1 = "9d9531a9cb63a9edc33836414e82a07e81710de2" +registries = "General" +uuid = "2e76f6c2-a576-52d4-95c1-20adfe4de566" +version = "100.14004.0+0" + [[deps.HashArrayMappedTries]] git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" +registries = "General" uuid = "076d061b-32b6-4027-95e0-9a2c6f6d7e74" version = "0.2.0" [[deps.HistogramThresholding]] deps = ["ImageBase", "LinearAlgebra", "MappedArrays"] git-tree-sha1 = "7194dfbb2f8d945abdaf68fa9480a965d6661e69" +registries = "General" uuid = "2c695a8d-9458-5d45-9878-1b8a99cf7853" version = "0.3.1" [[deps.HostCPUFeatures]] deps = ["BitTwiddlingConvenienceFunctions", "IfElse", "Libdl", "Preferences", "Static"] git-tree-sha1 = "af9ab7d1f70739a47f03be78771ebda38c3c71bf" +registries = "General" uuid = "3e5b6fbb-0976-4d2c-9146-d79de83f2fb0" version = "0.1.18" +[[deps.HypergeometricFunctions]] +deps = ["Gamma", "LinearAlgebra"] +git-tree-sha1 = "31bb6c92405c084617facc1d7ed9eb6c402d061e" +registries = "General" +uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" +version = "0.3.30" + [[deps.IOCapture]] deps = ["Logging", "Random"] git-tree-sha1 = "0ee181ec08df7d7c911901ea38baf16f755114dc" +registries = "General" uuid = "b5f81e59-6552-4d32-b1f0-c071b021bf89" version = "1.0.0" [[deps.IfElse]] git-tree-sha1 = "debdd00ffef04665ccbb3e150747a77560e8fad1" +registries = "General" uuid = "615f187c-cbe4-4ef1-ba3b-2fcf58d6d173" version = "0.1.1" [[deps.ImageAxes]] deps = ["AxisArrays", "ImageBase", "ImageCore", "Reexport", "SimpleTraits"] git-tree-sha1 = "e12629406c6c4442539436581041d372d69c55ba" +registries = "General" uuid = "2803e5a7-5153-5ecf-9a86-9b4c37f5f5ac" version = "0.6.12" [[deps.ImageBase]] deps = ["ImageCore", "Reexport"] git-tree-sha1 = "eb49b82c172811fd2c86759fa0553a2221feb909" +registries = "General" uuid = "c817782e-172a-44cc-b673-b171935fbb9e" version = "0.1.7" [[deps.ImageBinarization]] deps = ["HistogramThresholding", "ImageCore", "LinearAlgebra", "Polynomials", "Reexport", "Statistics"] git-tree-sha1 = "33485b4e40d1df46c806498c73ea32dc17475c59" +registries = "General" uuid = "cbc4b850-ae4b-5111-9e64-df94c024a13d" version = "0.3.1" [[deps.ImageContrastAdjustment]] deps = ["ImageBase", "ImageCore", "ImageTransformations", "Parameters"] git-tree-sha1 = "4051d637a9536eb9f4aa731b8e2b31d1cd39ae64" +registries = "General" uuid = "f332f351-ec65-5f6a-b3d1-319c6670881a" version = "0.3.13" [[deps.ImageCore]] deps = ["ColorVectorSpace", "Colors", "FixedPointNumbers", "MappedArrays", "MosaicViews", "OffsetArrays", "PaddedViews", "PrecompileTools", "Reexport"] git-tree-sha1 = "8c193230235bbcee22c8066b0374f63b5683c2d3" +registries = "General" uuid = "a09fc81d-aa75-5fe9-8630-4744c3626534" version = "0.10.5" [[deps.ImageCorners]] deps = ["ImageCore", "ImageFiltering", "PrecompileTools", "StaticArrays", "StatsBase"] git-tree-sha1 = "24c52de051293745a9bad7d73497708954562b79" +registries = "General" uuid = "89d5987c-236e-4e32-acd0-25bd6bd87b70" version = "0.1.3" [[deps.ImageDistances]] deps = ["Distances", "ImageCore", "ImageMorphology", "LinearAlgebra", "Statistics"] git-tree-sha1 = "08b0e6354b21ef5dd5e49026028e41831401aca8" +registries = "General" uuid = "51556ac3-7006-55f5-8cb3-34580c88182d" version = "0.2.17" [[deps.ImageFiltering]] deps = ["CatIndices", "ComputationalResources", "DataStructures", "FFTViews", "FFTW", "ImageBase", "ImageCore", "LinearAlgebra", "OffsetArrays", "PrecompileTools", "Reexport", "SparseArrays", "StaticArrays", "Statistics", "TiledIteration"] git-tree-sha1 = "52116260a234af5f69969c5286e6a5f8dc3feab8" +registries = "General" uuid = "6a3955dd-da59-5b1f-98d4-e7296123deb5" version = "0.7.12" [[deps.ImageIO]] deps = ["FileIO", "IndirectArrays", "JpegTurbo", "LazyModules", "Netpbm", "OpenEXR", "PNGFiles", "QOI", "Sixel", "TiffImages", "UUIDs", "WebP"] git-tree-sha1 = "696144904b76e1ca433b886b4e7edd067d76cbf7" +registries = "General" uuid = "82e4d734-157c-48bb-816b-45c225c6df19" version = "0.6.9" [[deps.ImageMagick]] deps = ["FileIO", "ImageCore", "ImageMagick_jll", "InteractiveUtils"] git-tree-sha1 = "8e64ab2f0da7b928c8ae889c514a52741debc1c2" +registries = "General" uuid = "6218d12a-5da1-5696-b52f-db25d2ecc6d1" version = "1.4.2" [[deps.ImageMagick_jll]] deps = ["Artifacts", "Bzip2_jll", "FFTW_jll", "Ghostscript_jll", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Libtiff_jll", "OpenJpeg_jll", "Zlib_jll", "Zstd_jll", "libpng_jll", "libwebp_jll", "libzip_jll"] git-tree-sha1 = "61fb149224a297ea6c180d4f77c04fd77304faf0" +registries = "General" uuid = "c73af94c-d91f-53ed-93a7-00f77d67a9d7" version = "7.1.2023+0" [[deps.ImageMetadata]] deps = ["AxisArrays", "ImageAxes", "ImageBase", "ImageCore"] git-tree-sha1 = "2a81c3897be6fbcde0802a0ebe6796d0562f63ec" +registries = "General" uuid = "bc367c6b-8a6b-528e-b4bd-a4b897500b49" version = "0.9.10" [[deps.ImageMorphology]] deps = ["DataStructures", "ImageCore", "LinearAlgebra", "LoopVectorization", "OffsetArrays", "Requires", "TiledIteration"] git-tree-sha1 = "895205d762ae24a01689f8cc7ad584b55f1fd005" +registries = "General" uuid = "787d08f9-d448-5407-9aad-5290dd7ab264" version = "0.4.7" [[deps.ImageQualityIndexes]] deps = ["ImageContrastAdjustment", "ImageCore", "ImageDistances", "ImageFiltering", "LazyModules", "OffsetArrays", "PrecompileTools", "Statistics"] git-tree-sha1 = "783b70725ed326340adf225be4889906c96b8fd1" +registries = "General" uuid = "2996bd0c-7a13-11e9-2da2-2f5ce47296a9" version = "0.3.7" [[deps.ImageSegmentation]] deps = ["Clustering", "DataStructures", "Distances", "Graphs", "ImageCore", "ImageFiltering", "ImageMorphology", "LinearAlgebra", "MetaGraphs", "RegionTrees", "SimpleWeightedGraphs", "StaticArrays", "Statistics"] git-tree-sha1 = "8071ca812183ee9acb8e93e8d59c66a7d8742d5c" +registries = "General" uuid = "80713f31-8817-5129-9cf8-209ff8fb23e1" version = "1.10.0" [[deps.ImageShow]] deps = ["Base64", "ColorSchemes", "FileIO", "ImageBase", "ImageCore", "OffsetArrays", "StackViews"] git-tree-sha1 = "3b5344bcdbdc11ad58f3b1956709b5b9345355de" +registries = "General" uuid = "4e3cecfd-b093-5904-9786-8bbb286a6a31" version = "0.3.8" [[deps.ImageTransformations]] deps = ["AxisAlgorithms", "CoordinateTransformations", "ImageBase", "ImageCore", "Interpolations", "OffsetArrays", "Rotations", "StaticArrays"] git-tree-sha1 = "dfde81fafbe5d6516fb864dc79362c5c6b973c82" +registries = "General" uuid = "02fcd773-0e25-5acc-982a-7f6622650795" version = "0.10.2" [[deps.Images]] deps = ["Base64", "FileIO", "Graphics", "ImageAxes", "ImageBase", "ImageBinarization", "ImageContrastAdjustment", "ImageCore", "ImageCorners", "ImageDistances", "ImageFiltering", "ImageIO", "ImageMagick", "ImageMetadata", "ImageMorphology", "ImageQualityIndexes", "ImageSegmentation", "ImageShow", "ImageTransformations", "IndirectArrays", "IntegralArrays", "Random", "Reexport", "SparseArrays", "StaticArrays", "Statistics", "StatsBase", "TiledIteration"] git-tree-sha1 = "a49b96fd4a8d1a9a718dfd9cde34c154fc84fcd5" +registries = "General" uuid = "916415d5-f1e6-5110-898d-aaa5f9f070e0" version = "0.26.2" [[deps.Imath_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "dcc8d0cd653e55213df9b75ebc6fe4a8d3254c65" +registries = "General" uuid = "905a6f67-0a94-5f89-b386-d35d92009cd1" version = "3.2.2+0" [[deps.IndirectArrays]] git-tree-sha1 = "012e604e1c7458645cb8b436f8fba789a51b257f" +registries = "General" uuid = "9b13fd28-a010-5f03-acff-a1bbcff69959" version = "1.0.0" [[deps.Inflate]] git-tree-sha1 = "d1b1b796e47d94588b3757fe84fbf65a5ec4a80d" +registries = "General" uuid = "d25df0c9-e2be-5dd7-82c8-3ad0b3e990b9" version = "0.1.5" [[deps.InitialValues]] git-tree-sha1 = "4da0f88e9a39111c2fa3add390ab15f3a44f3ca3" +registries = "General" uuid = "22cec73e-a1b8-11e9-2c92-598750a2cf9c" version = "0.3.1" [[deps.InlineStrings]] git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d" +registries = "General" uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" version = "1.4.5" @@ -921,18 +1320,27 @@ version = "1.4.5" [[deps.InputBuffers]] git-tree-sha1 = "e5392ea00942566b631e991dd896942189937b2f" +registries = "General" uuid = "0c81fc1b-5583-44fc-8770-48be1e1cca08" version = "1.1.1" +[[deps.IntegerMathUtils]] +git-tree-sha1 = "c72458f1962faeb003bf23cbdb75164fe6280906" +registries = "General" +uuid = "18e54dd8-cb9d-406c-a71d-865a43cbb235" +version = "0.1.4" + [[deps.IntegralArrays]] deps = ["ColorTypes", "FixedPointNumbers", "IntervalSets"] git-tree-sha1 = "b842cbff3f44804a84fda409745cc8f04c029a20" +registries = "General" uuid = "1d092043-8f09-5a30-832f-7509e371ab51" version = "0.1.6" [[deps.IntelOpenMP_jll]] deps = ["Artifacts", "JLLWrappers", "LazyArtifacts", "Libdl"] git-tree-sha1 = "ec1debd61c300961f98064cfb21287613ad7f303" +registries = "General" uuid = "1d5cc7b8-4909-519e-a0f8-d0f5ad9712d0" version = "2025.2.0+0" @@ -944,6 +1352,7 @@ version = "1.11.0" [[deps.Interpolations]] deps = ["Adapt", "AxisAlgorithms", "ChainRulesCore", "LinearAlgebra", "OffsetArrays", "Random", "Ratios", "SharedArrays", "SparseArrays", "StaticArrays", "WoodburyMatrices"] git-tree-sha1 = "65d505fa4c0d7072990d659ef3fc086eb6da8208" +registries = "General" uuid = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" version = "0.16.2" @@ -958,6 +1367,7 @@ version = "0.16.2" [[deps.IntervalArithmetic]] deps = ["CRlibm", "CoreMath", "MacroTools", "OpenBLASConsistentFPCSR_jll", "Printf", "Random", "RoundingEmulator"] git-tree-sha1 = "921d7e91687e15a2c7c269c226960491fc041832" +registries = "General" uuid = "d1acc4aa-44c8-5952-acd4-ba5d80a2a253" version = "1.0.9" @@ -983,6 +1393,7 @@ version = "1.0.9" [[deps.IntervalSets]] git-tree-sha1 = "79d6bd28c8d9bccc2229784f1bd637689b256377" +registries = "General" uuid = "8197267c-284f-5f27-9208-e0e47529a953" version = "0.7.14" @@ -998,6 +1409,7 @@ version = "0.7.14" [[deps.InverseFunctions]] git-tree-sha1 = "a779299d77cd080bf77b97535acecd73e1c5e5cb" +registries = "General" uuid = "3587e190-3f89-42d0-90ee-14403ec27112" version = "0.1.17" weakdeps = ["Dates", "Test"] @@ -1008,22 +1420,33 @@ weakdeps = ["Dates", "Test"] [[deps.IrrationalConstants]] git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" +registries = "General" uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" version = "0.2.6" +[[deps.Isoband]] +deps = ["isoband_jll"] +git-tree-sha1 = "f9b6d97355599074dc867318950adaa6f9946137" +registries = "General" +uuid = "f1662d9f-8043-43de-a69a-05efc1cc6ff4" +version = "0.1.1" + [[deps.IterTools]] git-tree-sha1 = "42d5f897009e7ff2cf88db414a389e5ed1bdd023" +registries = "General" uuid = "c8e1da08-722c-5040-9ed9-7db0dc04731e" version = "1.10.0" [[deps.IteratorInterfaceExtensions]] git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" +registries = "General" uuid = "82899510-4779-5014-852e-03e436cf321d" version = "1.0.0" [[deps.JLD2]] deps = ["ChunkCodecLibZlib", "ChunkCodecLibZstd", "FileIO", "MacroTools", "Mmap", "OrderedCollections", "PrecompileTools", "ScopedValues"] git-tree-sha1 = "941f87a0ae1b14d1ac2fa57245425b23a9d7a516" +registries = "General" uuid = "033835bb-8acc-5ee8-8aae-3f567f8a3819" version = "0.6.4" weakdeps = ["UnPack"] @@ -1034,12 +1457,14 @@ weakdeps = ["UnPack"] [[deps.JLLWrappers]] deps = ["Artifacts", "Preferences"] git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" +registries = "General" uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" version = "1.7.1" [[deps.JSON]] deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] git-tree-sha1 = "67c6f1f085cb2671c93fe34244c9cccde30f7a26" +registries = "General" uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" version = "1.5.0" @@ -1052,6 +1477,7 @@ version = "1.5.0" [[deps.JSON3]] deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] git-tree-sha1 = "411eccfe8aba0814ffa0fdf4860913ed09c34975" +registries = "General" uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" version = "1.14.3" @@ -1064,12 +1490,14 @@ version = "1.14.3" [[deps.JpegTurbo]] deps = ["CEnum", "FileIO", "ImageCore", "JpegTurbo_jll", "TOML"] git-tree-sha1 = "9496de8fb52c224a2e3f9ff403947674517317d9" +registries = "General" uuid = "b835a17e-a41a-41e7-81f0-2f016b05efe0" version = "0.1.6" [[deps.JpegTurbo_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "c0c9b76f3520863909825cbecdef58cd63de705a" +registries = "General" uuid = "aacddb02-875f-59d6-b918-886e6ef4fbf8" version = "3.1.5+0" @@ -1078,14 +1506,37 @@ deps = ["StyledStrings"] uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" version = "1.12.0" +[[deps.KernelDensity]] +deps = ["Distributions", "DocStringExtensions", "FFTA", "Interpolations", "StatsBase"] +git-tree-sha1 = "9eda8292dd3268b3b7ec9df21bbfac24e177ec52" +registries = "General" +uuid = "5ab0869b-81aa-558d-bb23-cbf5423bbe9b" +version = "0.6.12" + +[[deps.LAME_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "059aabebaa7c82ccb853dd4a0ee9d17796f7e1bc" +registries = "General" +uuid = "c1c5ebd0-6772-5130-a774-d5fcae4a789d" +version = "3.100.3+0" + [[deps.LERC_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "17b94ecafcfa45e8360a4fc9ca6b583b049e4e37" +registries = "General" uuid = "88015f11-f218-50d7-93a8-a6af411a945d" version = "4.1.0+0" +[[deps.LLVMOpenMP_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "e5b100780d4d30d63b4618d7930d48af409c1772" +registries = "General" +uuid = "1d63c593-3942-5779-bab2-d838dc0a180e" +version = "23.1.1+0" + [[deps.LRUCache]] git-tree-sha1 = "5519b95a490ff5fe629c4a7aa3b3dfc9160498b3" +registries = "General" uuid = "8ac3fa9e-de4c-5943-b1dc-09c6b5f20637" version = "1.6.2" weakdeps = ["Serialization"] @@ -1093,20 +1544,29 @@ weakdeps = ["Serialization"] [deps.LRUCache.extensions] SerializationExt = ["Serialization"] +[[deps.LaTeXStrings]] +git-tree-sha1 = "f88f3ccef05a6a72a0cf0ed417c8fd68530f4ab2" +registries = "General" +uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" +version = "1.4.1" + [[deps.LayoutPointers]] deps = ["ArrayInterface", "LinearAlgebra", "ManualMemory", "SIMDTypes", "Static", "StaticArrayInterface"] git-tree-sha1 = "a9eaadb366f5493a5654e843864c13d8b107548c" +registries = "General" uuid = "10f19ff3-798f-405d-979b-55457f8fc047" version = "0.1.17" [[deps.LazilyInitializedFields]] git-tree-sha1 = "0f2da712350b020bc3957f269c9caad516383ee0" +registries = "General" uuid = "0e77f7df-68c5-4e49-93ce-4cd80f5598bf" version = "1.3.0" [[deps.LazyArrays]] deps = ["ArrayLayouts", "FillArrays", "LinearAlgebra", "MacroTools", "SparseArrays"] git-tree-sha1 = "33b5d8fafb7ab69eca907b359d00d0107feb2cbf" +registries = "General" uuid = "5078a376-72f3-5289-bfd5-ec5146d43c02" version = "2.9.7" @@ -1129,18 +1589,19 @@ version = "1.11.0" [[deps.LazyModules]] git-tree-sha1 = "a560dd966b386ac9ae60bdd3a3d3a326062d3c3e" +registries = "General" uuid = "8cdb02fc-e678-4876-92c5-9defec4f444e" version = "0.3.1" [[deps.LibCURL]] deps = ["LibCURL_jll", "MozillaCACerts_jll"] uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" -version = "0.6.4" +version = "1.0.0" [[deps.LibCURL_jll]] -deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "Zstd_jll", "nghttp2_jll"] uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" -version = "8.15.0+0" +version = "8.18.0+1" [[deps.LibGit2]] deps = ["LibGit2_jll", "NetworkOptions", "Printf", "SHA"] @@ -1148,40 +1609,65 @@ uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" version = "1.11.0" [[deps.LibGit2_jll]] -deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "PCRE2_jll", "Zlib_jll"] uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" -version = "1.9.0+0" +version = "1.9.1+0" [[deps.LibSSH2_jll]] -deps = ["Artifacts", "Libdl", "OpenSSL_jll"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl", "OpenSSL_jll", "Zlib_jll"] uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" -version = "1.11.3+1" +version = "1.11.103+0" [[deps.Libdl]] uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" version = "1.11.0" +[[deps.Libffi_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "c8da7e6a91781c41a863611c7e966098d783c57a" +registries = "General" +uuid = "e9f186c6-92d2-5b65-8a66-fee21dc1b490" +version = "3.4.7+0" + [[deps.Libglvnd_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll", "Xorg_libXext_jll"] git-tree-sha1 = "d36c21b9e7c172a44a10484125024495e2625ac0" +registries = "General" uuid = "7e76a0d4-f3c7-5321-8279-8d96eeed0f29" version = "1.7.1+1" [[deps.Libiconv_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "be484f5c92fad0bd8acfef35fe017900b0b73809" +registries = "General" uuid = "94ce4f54-9a6c-5748-9c1c-f9c7231a4531" version = "1.18.0+0" +[[deps.Libmount_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "cc3ad4faf30015a3e8094c9b5b7f19e85bdf2386" +registries = "General" +uuid = "4b2f31a3-9ecc-558c-b454-b3730dcb73e9" +version = "2.42.0+0" + [[deps.Libtiff_jll]] deps = ["Artifacts", "JLLWrappers", "JpegTurbo_jll", "LERC_jll", "Libdl", "XZ_jll", "Zlib_jll", "Zstd_jll"] git-tree-sha1 = "f04133fe05eff1667d2054c53d59f9122383fe05" +registries = "General" uuid = "89763e89-9b03-5906-acba-b20f662cd828" version = "4.7.2+0" +[[deps.Libuuid_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "d620582b1f0cbe2c72dd1d5bd195a9ce73370ab1" +registries = "General" +uuid = "38a345b3-de98-5d2b-a5d3-14cd9215e700" +version = "2.42.0+0" + [[deps.LightBSON]] deps = ["DataStructures", "Dates", "DecFP", "FNVHash", "JSON3", "Sockets", "StructTypes", "Transducers", "UUIDs", "UnsafeArrays", "WeakRefStrings"] git-tree-sha1 = "11219adfcf34c5e67f31fad4aed70c54f24380a0" +registries = "General" uuid = "a4a7f996-b3a6-4de6-b9db-2fa5f350df41" version = "1.5.0" @@ -1194,17 +1680,19 @@ version = "1.5.0" [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -version = "1.12.0" +version = "1.13.0" [[deps.LittleCMS_jll]] deps = ["Artifacts", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Libtiff_jll"] git-tree-sha1 = "70bd263e082a236c8c2661a474616d95ba59d2cf" +registries = "General" uuid = "d3a379c0-f9a3-5b72-a4c0-6bf4d2e8af0f" version = "2.19.0+0" [[deps.LogExpFunctions]] deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f" +registries = "General" uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" version = "0.3.29" @@ -1225,12 +1713,14 @@ version = "1.11.0" [[deps.LoggingExtras]] deps = ["Dates", "Logging"] git-tree-sha1 = "f00544d95982ea270145636c181ceda21c4e2575" +registries = "General" uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" version = "1.2.0" [[deps.LoopVectorization]] deps = ["ArrayInterface", "CPUSummary", "CloseOpenIntervals", "DocStringExtensions", "HostCPUFeatures", "IfElse", "LayoutPointers", "LinearAlgebra", "OffsetArrays", "PolyesterWeave", "PrecompileTools", "SIMDTypes", "SLEEFPirates", "Static", "StaticArrayInterface", "ThreadingUtilities", "UnPack", "VectorizationBase"] git-tree-sha1 = "a9fc7883eb9b5f04f46efb9a540833d1fad974b3" +registries = "General" uuid = "bdcacae8-1622-11e9-2a5c-532679323890" version = "0.12.173" @@ -1248,27 +1738,45 @@ version = "0.12.173" [[deps.Lz4_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" +registries = "General" uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" version = "1.10.1+0" [[deps.MKL_jll]] deps = ["Artifacts", "IntelOpenMP_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "oneTBB_jll"] git-tree-sha1 = "282cadc186e7b2ae0eeadbd7a4dffed4196ae2aa" +registries = "General" uuid = "856f044c-d86e-5d09-b602-aeab76dc8ba7" version = "2025.2.0+0" [[deps.MacroTools]] git-tree-sha1 = "1e0228a030642014fe5cfe68c2c0a818f9e3f522" +registries = "General" uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" version = "0.5.16" +[[deps.Makie]] +deps = ["Animations", "Base64", "CRC32c", "ColorBrewer", "ColorSchemes", "ColorTypes", "Colors", "ComputePipeline", "Contour", "Dates", "DelaunayTriangulation", "Distributions", "DocStringExtensions", "Downloads", "FFMPEG_jll", "FileIO", "FilePaths", "FixedPointNumbers", "Format", "FreeType", "FreeTypeAbstraction", "GeometryBasics", "GridLayoutBase", "ImageBase", "ImageIO", "InteractiveUtils", "Interpolations", "IntervalSets", "InverseFunctions", "Isoband", "KernelDensity", "LaTeXStrings", "LinearAlgebra", "MacroTools", "Markdown", "MathTeXEngine", "Observables", "OffsetArrays", "PNGFiles", "Packing", "Pkg", "PlotUtils", "PolygonOps", "PrecompileTools", "Printf", "REPL", "Random", "RelocatableFolders", "Scratch", "ShaderAbstractions", "SignedDistanceFields", "SparseArrays", "Statistics", "StatsBase", "StatsFuns", "StructArrays", "TriplotBase", "UnicodeFun", "Unitful"] +git-tree-sha1 = "5f6f5d1b1fb7ff98c9a083bbfd4c661a9808e758" +registries = "General" +uuid = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" +version = "0.24.15" + + [deps.Makie.extensions] + MakieDynamicQuantitiesExt = "DynamicQuantities" + + [deps.Makie.weakdeps] + DynamicQuantities = "06fc5a27-2a28-4c7c-a15d-362465fb6821" + [[deps.ManualMemory]] git-tree-sha1 = "bcaef4fc7a0cfe2cba636d84cda54b5e4e4ca3cd" +registries = "General" uuid = "d125e4d3-2237-4719-b19c-fa641b8a4667" version = "0.1.8" [[deps.MappedArrays]] git-tree-sha1 = "0ee4497a4e80dbd29c058fcee6493f5219556f40" +registries = "General" uuid = "dbb5928d-eab1-5f90-85c2-b9b0edb7c900" version = "0.4.3" @@ -1280,36 +1788,49 @@ version = "1.11.0" [[deps.MarkdownAST]] deps = ["AbstractTrees", "Markdown"] git-tree-sha1 = "93c718d892e73931841089cdc0e982d6dd9cc87b" +registries = "General" uuid = "d0879d2d-cac2-40c8-9cee-1863dc0c7391" version = "0.1.3" +[[deps.MathTeXEngine]] +deps = ["AbstractTrees", "Automa", "DataStructures", "FreeTypeAbstraction", "GeometryBasics", "LaTeXStrings", "REPL", "RelocatableFolders", "UnicodeFun"] +git-tree-sha1 = "aa1078778be5a8e5259ff04fbc3d258b3e78d464" +registries = "General" +uuid = "0a4f8689-d25c-4efe-a92b-7142dfc1aa53" +version = "0.6.9" + [[deps.MbedTLS]] deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] git-tree-sha1 = "8785729fa736197687541f7053f6d8ab7fc44f92" +registries = "General" uuid = "739be429-bea8-5141-9913-cc70e7f3736d" version = "1.1.10" [[deps.MbedTLS_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "ff69a2b1330bcb730b9ac1ab7dd680176f5896b8" +registries = "General" uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" version = "2.28.1010+0" [[deps.MetaGraphs]] deps = ["Graphs", "JLD2", "Random"] git-tree-sha1 = "3a8f462a180a9d735e340f4e8d5f364d411da3a4" +registries = "General" uuid = "626554b9-1ddb-594c-aa3c-2596fe9399a5" version = "0.8.1" [[deps.MicroCollections]] deps = ["Accessors", "BangBang", "InitialValues"] git-tree-sha1 = "44d32db644e84c75dab479f1bc15ee76a1a3618f" +registries = "General" uuid = "128add7d-3638-4c79-886c-908ea0c25c34" version = "0.2.0" [[deps.Missings]] deps = ["DataAPI"] git-tree-sha1 = "ec4f7fbeab05d7747bdf98eb74d130a2a2ed298d" +registries = "General" uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" version = "1.2.0" @@ -1320,28 +1841,39 @@ version = "1.11.0" [[deps.MosaicViews]] deps = ["MappedArrays", "OffsetArrays", "PaddedViews", "StackViews"] git-tree-sha1 = "7b86a5d4d70a9f5cdf2dacb3cbe6d251d1a61dbe" +registries = "General" uuid = "e94cdb99-869f-56ef-bcf0-1ae2bcbe0389" version = "0.3.4" [[deps.MozillaCACerts_jll]] uuid = "14a3606d-f60d-562e-9121-12d972cd8159" -version = "2025.11.4" +version = "2026.8.13" + +[[deps.MuladdMacro]] +deps = ["PrecompileTools"] +git-tree-sha1 = "283bf85d4a767481dd924dff0eee1735e95f449e" +registries = "General" +uuid = "46d2c3a1-f734-5fdb-9937-b9b9aeba4221" +version = "0.2.7" [[deps.NaNMath]] deps = ["OpenLibm_jll"] git-tree-sha1 = "9b8215b1ee9e78a293f99797cd31375471b2bcae" +registries = "General" uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" version = "1.1.3" [[deps.NearestNeighbors]] deps = ["AbstractTrees", "Distances", "StaticArrays"] git-tree-sha1 = "e2c3bba08dd6dedfe17a17889131b885b8c082f0" +registries = "General" uuid = "b8a86587-4115-5ab1-83bc-aa920d37bbce" version = "0.4.27" [[deps.Netpbm]] deps = ["FileIO", "ImageCore", "ImageMetadata"] git-tree-sha1 = "d92b107dbb887293622df7697a2223f9f8176fcd" +registries = "General" uuid = "f09324ee-3d7c-5217-9330-fc30815ba969" version = "1.1.1" @@ -1349,8 +1881,15 @@ version = "1.1.1" uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" version = "1.3.0" +[[deps.Observables]] +git-tree-sha1 = "7438a59546cf62428fc9d1bc94729146d37a7225" +registries = "General" +uuid = "510215fc-4207-5dde-b226-833fc4488ee2" +version = "0.5.5" + [[deps.OffsetArrays]] git-tree-sha1 = "117432e406b5c023f665fa73dc26e79ec3630151" +registries = "General" uuid = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" version = "1.17.0" weakdeps = ["Adapt"] @@ -1358,107 +1897,166 @@ weakdeps = ["Adapt"] [deps.OffsetArrays.extensions] OffsetArraysAdaptExt = "Adapt" +[[deps.Ogg_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "b6aa4566bb7ae78498a5e68943863fa8b5231b59" +registries = "General" +uuid = "e7412a2a-1a6e-54c0-be00-318e2571c051" +version = "1.3.6+0" + [[deps.OpenBLASConsistentFPCSR_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] git-tree-sha1 = "3287ec88df50429a934ebc6cf14606215e27b987" +registries = "General" uuid = "6cdc7f73-28fd-5e50-80fb-958a8875b1af" version = "0.3.33+0" [[deps.OpenBLAS_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" -version = "0.3.29+0" +version = "0.3.30+0" [[deps.OpenEXR]] deps = ["Colors", "FileIO", "OpenEXR_jll"] git-tree-sha1 = "97db9e07fe2091882c765380ef58ec553074e9c7" +registries = "General" uuid = "52e1d378-f018-4a11-a4be-720524705ac7" version = "0.3.3" [[deps.OpenEXR_jll]] deps = ["Artifacts", "Imath_jll", "JLLWrappers", "Libdl", "Zlib_jll"] git-tree-sha1 = "9ac7c730c53b3b5d9a73fb900ac4b4fc263774db" +registries = "General" uuid = "18a262bb-aa17-5467-a713-aee519bc75cb" version = "3.4.9+0" [[deps.OpenJpeg_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Libtiff_jll", "LittleCMS_jll", "libpng_jll"] git-tree-sha1 = "215a6666fee6d6b3a6e75f2cc22cb767e2dd393a" +registries = "General" uuid = "643b3616-a352-519d-856d-80112ee9badc" version = "2.5.5+0" [[deps.OpenLibm_jll]] -deps = ["Artifacts", "Libdl"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "05823500-19ac-5b8b-9628-191a04bc5112" version = "0.8.7+0" [[deps.OpenSSH_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "OpenSSL_jll", "Zlib_jll"] git-tree-sha1 = "57baa4b81a24c2910afbb6d853aa0685e4312bf7" +registries = "General" uuid = "9bd350c2-7e96-507f-8002-3f2e150b4e1b" version = "10.3.1+0" [[deps.OpenSSL]] deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "NetworkOptions", "OpenSSL_jll", "Sockets"] git-tree-sha1 = "1d1aaa7d449b58415f97d2839c318b70ffb525a0" +registries = "General" uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" version = "1.6.1" [[deps.OpenSSL_jll]] deps = ["Artifacts", "Libdl"] uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" -version = "3.5.4+0" +version = "3.5.6+0" [[deps.OpenSpecFun_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] git-tree-sha1 = "1346c9208249809840c91b26703912dff463d335" +registries = "General" uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" version = "0.5.6+0" +[[deps.Opus_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "e2bb57a313a74b8104064b7efd01406c0a50d2ff" +registries = "General" +uuid = "91d4177d-7536-5919-b921-800302f37372" +version = "1.6.1+0" + [[deps.OrderedCollections]] git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee" +registries = "General" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" version = "1.8.1" [[deps.PCRE2_jll]] deps = ["Artifacts", "Libdl"] uuid = "efcefdf7-47ab-520b-bdef-62a2eaa19f15" -version = "10.44.0+1" +version = "10.46.0+0" + +[[deps.PDMats]] +deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] +git-tree-sha1 = "123266c25174ef6c8d4718920abc206452cf8de6" +registries = "General" +uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" +version = "0.11.41" +weakdeps = ["StatsBase"] + + [deps.PDMats.extensions] + StatsBaseExt = "StatsBase" [[deps.PNGFiles]] deps = ["Base64", "CEnum", "ImageCore", "IndirectArrays", "OffsetArrays", "libpng_jll"] git-tree-sha1 = "cf181f0b1e6a18dfeb0ee8acc4a9d1672499626c" +registries = "General" uuid = "f57f5aa1-a3ce-4bc8-8ab9-96f992907883" version = "0.4.4" +[[deps.Packing]] +deps = ["GeometryBasics"] +git-tree-sha1 = "bc5bf2ea3d5351edf285a06b0016788a121ce92c" +registries = "General" +uuid = "19eb6ba3-879d-56ad-ad62-d5c202156566" +version = "0.5.1" + [[deps.PaddedViews]] deps = ["OffsetArrays"] git-tree-sha1 = "0fac6313486baae819364c52b4f483450a9d793f" +registries = "General" uuid = "5432bcbf-9aad-5242-b902-cca2824c8663" version = "0.5.12" +[[deps.Pango_jll]] +deps = ["Artifacts", "Cairo_jll", "Fontconfig_jll", "FreeType2_jll", "FriBidi_jll", "Glib_jll", "HarfBuzz_jll", "JLLWrappers", "Libdl"] +git-tree-sha1 = "1912a9f1b9ca55005b03ba075f8e19993583e237" +registries = "General" +uuid = "36c8627f-9965-5494-a995-c6b170f724f3" +version = "1.58.2+0" + [[deps.Parameters]] deps = ["OrderedCollections", "UnPack"] git-tree-sha1 = "34c0e9ad262e5f7fc75b10a9952ca7692cfc5fbe" +registries = "General" uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a" version = "0.12.3" [[deps.Parquet2]] deps = ["AbstractTrees", "BitIntegers", "ChunkCodecCore", "ChunkCodecLibBrotli", "ChunkCodecLibLz4", "ChunkCodecLibSnappy", "ChunkCodecLibZlib", "ChunkCodecLibZstd", "DataAPI", "Dates", "DecFP", "FilePathsBase", "FillArrays", "JSON3", "LazyArrays", "LightBSON", "Mmap", "OrderedCollections", "PooledArrays", "PrecompileTools", "SentinelArrays", "StaticArrays", "TableOperations", "Tables", "Thrift2", "Transducers", "UUIDs", "WeakRefStrings"] git-tree-sha1 = "b807642c695d78b1f2d6cc829ea505a7fc6fe81d" +registries = "General" uuid = "98572fba-bba0-415d-956f-fa77e587d26d" version = "0.2.33" [[deps.Parsers]] deps = ["Dates", "PrecompileTools", "UUIDs"] git-tree-sha1 = "7d2f8f21da5db6a806faf7b9b292296da42b2810" +registries = "General" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" version = "2.8.3" +[[deps.Pixman_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LLVMOpenMP_jll", "Libdl"] +git-tree-sha1 = "e4a6721aa89e62e5d4217c0b21bd714263779dda" +registries = "General" +uuid = "30392449-352a-5448-841d-b1acce4e97dc" +version = "0.46.4+0" + [[deps.Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "p7zip_jll"] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "Random", "SHA", "TOML", "Tar", "UUIDs", "Zstd_jll", "p7zip_jll"] uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" -version = "1.12.1" +version = "1.13.0" weakdeps = ["REPL"] [deps.Pkg.extensions] @@ -1467,18 +2065,34 @@ weakdeps = ["REPL"] [[deps.PkgVersion]] deps = ["Pkg"] git-tree-sha1 = "f9501cc0430a26bc3d156ae1b5b0c1b47af4d6da" +registries = "General" uuid = "eebad327-c553-4316-9ea0-9fa01ccd7688" version = "0.3.3" +[[deps.PlotUtils]] +deps = ["ColorSchemes", "Colors", "Dates", "PrecompileTools", "Printf", "Reexport", "Statistics"] +git-tree-sha1 = "f20e945b895d2009c6c28d8bbf40a5cd846f7c2f" +registries = "General" +uuid = "995b91a9-d308-5afd-9ec6-746e21dbc043" +version = "1.5.0" + [[deps.PolyesterWeave]] deps = ["BitTwiddlingConvenienceFunctions", "CPUSummary", "IfElse", "Static", "ThreadingUtilities"] git-tree-sha1 = "645bed98cd47f72f67316fd42fc47dee771aefcd" +registries = "General" uuid = "1d0040c9-8b98-4ee7-8388-3f51789ca0ad" version = "0.2.2" +[[deps.PolygonOps]] +git-tree-sha1 = "77b3d3605fc1cd0b42d95eba87dfcd2bf67d5ff6" +registries = "General" +uuid = "647866c9-e3ac-4575-94e7-e3d426903924" +version = "0.1.2" + [[deps.Polynomials]] deps = ["LinearAlgebra", "OrderedCollections", "Setfield", "SparseArrays"] git-tree-sha1 = "2d99b4c8a7845ab1342921733fa29366dae28b24" +registries = "General" uuid = "f27b6e38-b328-58d1-80ce-0feddd5e7a45" version = "4.1.1" @@ -1499,21 +2113,31 @@ version = "4.1.1" [[deps.PooledArrays]] deps = ["DataAPI", "Future"] git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" +registries = "General" uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" version = "1.4.3" [[deps.PrecompileTools]] deps = ["Preferences"] git-tree-sha1 = "07a921781cab75691315adc645096ed5e370cb77" +registries = "General" uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" version = "1.3.3" [[deps.Preferences]] deps = ["TOML"] git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +registries = "General" uuid = "21216c6a-2e73-6563-6e65-726566657250" version = "1.5.2" +[[deps.Primes]] +deps = ["IntegerMathUtils"] +git-tree-sha1 = "25cdd1d20cd005b52fc12cb6be3f75faaf59bb9b" +registries = "General" +uuid = "27ebfcd6-29c5-5fa9-bf4b-fb8fc14df3ae" +version = "0.5.7" + [[deps.Printf]] deps = ["Unicode"] uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" @@ -1522,28 +2146,45 @@ version = "1.11.0" [[deps.ProgressMeter]] deps = ["Distributed", "Printf"] git-tree-sha1 = "fbb92c6c56b34e1a2c4c36058f68f332bec840e7" +registries = "General" uuid = "92933f4c-e287-5a05-a399-4b506db050ca" version = "1.11.0" [[deps.PtrArrays]] git-tree-sha1 = "4fbbafbc6251b883f4d2705356f3641f3652a7fe" +registries = "General" uuid = "43287f4e-b6f4-7ad1-bb20-aadabca52c3d" version = "1.4.0" [[deps.QOI]] deps = ["ColorTypes", "FileIO", "FixedPointNumbers"] git-tree-sha1 = "472daaa816895cb7aee81658d4e7aec901fa1106" +registries = "General" uuid = "4b34888f-f399-49d4-9bb3-47ed5cae4e65" version = "1.0.2" +[[deps.QuadGK]] +deps = ["DataStructures", "LinearAlgebra"] +git-tree-sha1 = "5e8e8b0ab68215d7a2b14b9921a946fee794749e" +registries = "General" +uuid = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" +version = "2.11.3" + + [deps.QuadGK.extensions] + QuadGKEnzymeExt = "Enzyme" + + [deps.QuadGK.weakdeps] + Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9" + [[deps.Quaternions]] deps = ["LinearAlgebra", "Random", "RealDot"] git-tree-sha1 = "4d8c1b7c3329c1885b857abb50d08fa3f4d9e3c8" +registries = "General" uuid = "94ee1d12-ae83-5a48-8b1c-48b8ff168ae0" version = "0.7.7" [[deps.REPL]] -deps = ["InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] +deps = ["Base64", "Dates", "FileWatching", "InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" version = "1.11.0" @@ -1554,12 +2195,14 @@ version = "1.11.0" [[deps.RangeArrays]] git-tree-sha1 = "b9039e93773ddcfc828f12aadf7115b4b4d225f5" +registries = "General" uuid = "b3c3ace0-ae52-54e7-9d0b-2c1406fd6b9d" version = "0.3.2" [[deps.Ratios]] deps = ["Requires"] git-tree-sha1 = "1342a47bf3260ee108163042310d26f2be5ec90b" +registries = "General" uuid = "c84ed2f1-dad5-54f0-aa8e-dbefe2724439" version = "0.4.5" weakdeps = ["FixedPointNumbers"] @@ -1570,35 +2213,85 @@ weakdeps = ["FixedPointNumbers"] [[deps.RealDot]] deps = ["LinearAlgebra"] git-tree-sha1 = "9f0a1b71baaf7650f4fa8a1d168c7fb6ee41f0c9" +registries = "General" uuid = "c1ae055f-0cd5-4b69-90a6-9a35b1a98df9" version = "0.1.0" [[deps.Reexport]] git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +registries = "General" uuid = "189a3867-3050-52da-a836-e630ba90ab69" version = "1.2.2" [[deps.RegionTrees]] deps = ["IterTools", "LinearAlgebra", "StaticArrays"] git-tree-sha1 = "4618ed0da7a251c7f92e869ae1a19c74a7d2a7f9" +registries = "General" uuid = "dee08c22-ab7f-5625-9660-a9af2021b33f" version = "0.3.2" [[deps.RegistryInstances]] deps = ["LazilyInitializedFields", "Pkg", "TOML", "Tar"] git-tree-sha1 = "ffd19052caf598b8653b99404058fce14828be51" +registries = "General" uuid = "2792f1a3-b283-48e8-9a74-f99dce5104f3" version = "0.1.0" +[[deps.RelocatableFolders]] +deps = ["SHA", "Scratch"] +git-tree-sha1 = "ffdaf70d81cf6ff22c2b6e733c900c3321cab864" +registries = "General" +uuid = "05181044-ff0b-4ac5-8273-598c1e38db00" +version = "1.0.1" + [[deps.Requires]] deps = ["UUIDs"] git-tree-sha1 = "62389eeff14780bfe55195b7204c0d8738436d64" +registries = "General" uuid = "ae029012-a4dd-5104-9daa-d747884805df" version = "1.3.1" +[[deps.Rmath]] +deps = ["Random", "Rmath_jll"] +git-tree-sha1 = "5b3d50eb374cea306873b371d3f8d3915a018f0b" +registries = "General" +uuid = "79098fc4-a85e-5d69-aa6a-4863f24498fa" +version = "0.9.0" + +[[deps.Rmath_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "6d40b2fe70437b01397d2a4d5b020008da4e7019" +registries = "General" +uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" +version = "0.5.2+0" + +[[deps.Roots]] +deps = ["Accessors", "CommonSolve", "Printf"] +git-tree-sha1 = "4db094d5e079abbda658acfe1c4d098430417717" +registries = "General" +uuid = "f2b01f46-fcfa-551c-844a-d8ac1e96c665" +version = "3.0.8" + + [deps.Roots.extensions] + RootsChainRulesCoreExt = "ChainRulesCore" + RootsForwardDiffExt = "ForwardDiff" + RootsIntervalRootFindingExt = "IntervalRootFinding" + RootsSymPyExt = "SymPy" + RootsSymPyPythonCallExt = "SymPyPythonCall" + RootsUnitfulExt = "Unitful" + + [deps.Roots.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" + IntervalRootFinding = "d2bf35a9-74e0-55ec-b149-d360ff49b807" + SymPy = "24249f21-da20-56a4-8eb1-6a02cf4ae2e6" + SymPyPythonCall = "bc8888f7-b21e-4b7c-a06a-5d9c9496438c" + Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" + [[deps.Rotations]] deps = ["LinearAlgebra", "Quaternions", "Random", "StaticArrays"] git-tree-sha1 = "5680a9276685d392c87407df00d57c9924d9f11e" +registries = "General" uuid = "6038ab10-8711-5258-84ad-4b1120ba62dc" version = "1.7.1" @@ -1610,47 +2303,67 @@ version = "1.7.1" [[deps.RoundingEmulator]] git-tree-sha1 = "40b9edad2e5287e05bd413a38f61a8ff55b9557b" +registries = "General" uuid = "5eaf0fd0-dfba-4ccb-bf02-d820a40db705" version = "0.2.1" [[deps.SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" -version = "0.7.0" +version = "1.0.0" [[deps.SIMD]] deps = ["PrecompileTools"] git-tree-sha1 = "e24dc23107d426a096d3eae6c165b921e74c18e4" +registries = "General" uuid = "fdea26ae-647d-5447-a871-4b548cad5224" version = "3.7.2" [[deps.SIMDTypes]] git-tree-sha1 = "330289636fb8107c5f32088d2741e9fd7a061a5c" +registries = "General" uuid = "94e857df-77ce-4151-89e5-788b33177be4" version = "0.1.0" [[deps.SLEEFPirates]] deps = ["IfElse", "Static", "VectorizationBase"] git-tree-sha1 = "456f610ca2fbd1c14f5fcf31c6bfadc55e7d66e0" +registries = "General" uuid = "476501e8-09a2-5ece-8869-fb82de89a1fa" version = "0.6.43" [[deps.SciMLPublic]] git-tree-sha1 = "0ba076dbdce87ba230fff48ca9bca62e1f345c9b" +registries = "General" uuid = "431bcebd-1456-4ced-9d72-93c2757fff0b" version = "1.0.1" [[deps.ScopedValues]] deps = ["HashArrayMappedTries", "Logging"] git-tree-sha1 = "67a144433c4ce877ee6d1ada69a124d6b1ecf7be" +registries = "General" uuid = "7e506255-f358-4e82-b7e4-beb19740aa63" version = "1.6.2" +[[deps.Scratch]] +deps = ["Dates"] +git-tree-sha1 = "9b81b8393e50b7d4e6d0a9f14e192294d3b7c109" +registries = "General" +uuid = "6c6a2e73-6563-6170-7368-637461726353" +version = "1.3.0" + [[deps.SentinelArrays]] deps = ["Dates", "Random"] git-tree-sha1 = "084c47c7c5ce5cfecefa0a98dff69eb3646b5a80" +registries = "General" uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" version = "1.4.10" +[[deps.SentinelViews]] +git-tree-sha1 = "e1654cb20273458138262e24d5f5572179013913" +registries = "General" +uuid = "1c95a9c1-8e3f-460f-8963-106dcc440218" +version = "0.1.4" + [[deps.Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" version = "1.11.0" @@ -1658,34 +2371,53 @@ version = "1.11.0" [[deps.Setfield]] deps = ["ConstructionBase", "Future", "MacroTools", "StaticArraysCore"] git-tree-sha1 = "c5391c6ace3bc430ca630251d02ea9687169ca68" +registries = "General" uuid = "efcf1570-3423-57d1-acb7-fd33fddbac46" version = "1.1.2" +[[deps.ShaderAbstractions]] +deps = ["ColorTypes", "FixedPointNumbers", "GeometryBasics", "LinearAlgebra", "Observables", "StaticArrays"] +git-tree-sha1 = "57aa595158717ef165e6f5ab639fe2e3178c0a2b" +registries = "General" +uuid = "65257c39-d410-5151-9873-9b3e5be5013e" +version = "0.5.1" + [[deps.SharedArrays]] deps = ["Distributed", "Mmap", "Random", "Serialization"] uuid = "1a1011a3-84de-559e-8e89-a11a2f7dc383" version = "1.11.0" +[[deps.SignedDistanceFields]] +deps = ["Statistics"] +git-tree-sha1 = "3949ad92e1c9d2ff0cd4a1317d5ecbba682f4b92" +registries = "General" +uuid = "73760f76-fbc4-59ce-8f25-708e95d2df96" +version = "0.4.1" + [[deps.SimpleBufferStream]] git-tree-sha1 = "f305871d2f381d21527c770d4788c06c097c9bc1" +registries = "General" uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" version = "1.2.0" [[deps.SimpleTraits]] deps = ["InteractiveUtils", "MacroTools"] git-tree-sha1 = "7ddb0b49c109481b046972c0e4ab02b2127d6a75" +registries = "General" uuid = "699a6c99-e7fa-54fc-8d76-47d257e15c1d" version = "0.9.6" [[deps.SimpleWeightedGraphs]] deps = ["Graphs", "LinearAlgebra", "Markdown", "SparseArrays"] git-tree-sha1 = "749a2b719ec7f34f280c0d97ac3dab5c89818631" +registries = "General" uuid = "47aef6b3-ad0c-573a-a1e2-d07658019622" version = "1.5.1" [[deps.Sixel]] deps = ["Dates", "FileIO", "ImageCore", "IndirectArrays", "OffsetArrays", "REPL", "libsixel_jll"] git-tree-sha1 = "0494aed9501e7fb65daba895fb7fd57cc38bc743" +registries = "General" uuid = "45858cf5-a6b0-47a3-bbea-62219f50df47" version = "0.1.5" @@ -1696,37 +2428,36 @@ version = "1.11.0" [[deps.SortTileRecursiveTree]] deps = ["AbstractTrees", "Extents", "GeoInterface"] git-tree-sha1 = "f9aa6616a9b3bd01f93f27c010f1d25fc5a094a9" +registries = "General" uuid = "746ee33f-1797-42c2-866d-db2fce69d14d" version = "0.1.4" [[deps.SortingAlgorithms]] deps = ["DataStructures"] git-tree-sha1 = "64d974c2e6fdf07f8155b5b2ca2ffa9069b608d9" +registries = "General" uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" version = "1.2.2" [[deps.SparseArrays]] deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" -version = "1.12.0" +version = "1.13.0" [[deps.SpatialOmics]] -deps = ["CSV", "CodecZlib", "Colors", "FixedPointNumbers", "GeoInterface", "GeometryBasics", "GeometryOps", "ImageBase", "Images", "JSON", "Logging", "MappedArrays", "OrderedCollections", "Parquet2", "Random", "SparseArrays", "StaticArrays", "Tables", "TiffImages", "Zarr"] +deps = ["CSV", "CodecZlib", "CodecZstd", "Colors", "FixedPointNumbers", "FlexiJoins", "GeoInterface", "GeometryBasics", "GeometryOps", "ImageBase", "Images", "JSON", "LinearAlgebra", "Logging", "MappedArrays", "OrderedCollections", "Parquet2", "Random", "SparseArrays", "StaticArrays", "Tables", "TiffImages", "Zarr"] path = ".." uuid = "7c9a2e4e-8f1d-4a6b-b84c-3e8a9f2d1c5e" version = "0.2.0" +weakdeps = ["Makie"] [deps.SpatialOmics.extensions] MakieExt = "Makie" - NearestNeighborsExt = "NearestNeighbors" - - [deps.SpatialOmics.weakdeps] - Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" - NearestNeighbors = "b8a86587-4115-5ab1-83bc-aa920d37bbce" [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] git-tree-sha1 = "2700b235561b0335d5bef7097a111dc513b8655e" +registries = "General" uuid = "276daf66-3868-5448-9aa4-cd146d93841b" version = "2.7.2" weakdeps = ["ChainRulesCore"] @@ -1737,29 +2468,34 @@ weakdeps = ["ChainRulesCore"] [[deps.SplittablesBase]] deps = ["Setfield", "Test"] git-tree-sha1 = "e08a62abc517eb79667d0a29dc08a3b589516bb5" +registries = "General" uuid = "171d559e-b47b-412a-8079-5efa626c420e" version = "0.1.15" [[deps.StableTasks]] git-tree-sha1 = "c4f6610f85cb965bee5bfafa64cbeeda55a4e0b2" +registries = "General" uuid = "91464d47-22a1-43fe-8b7f-2d57ee82463f" version = "0.1.7" [[deps.StackViews]] deps = ["OffsetArrays"] git-tree-sha1 = "be1cf4eb0ac528d96f5115b4ed80c26a8d8ae621" +registries = "General" uuid = "cae243ae-269e-4f55-b966-ac2d0dc13c15" version = "0.1.2" [[deps.Static]] deps = ["CommonWorldInvalidations", "IfElse", "PrecompileTools", "SciMLPublic"] git-tree-sha1 = "bb072715f158b59ad8819ff80da5ffa90cce6ceb" +registries = "General" uuid = "aedffcd0-7271-4cad-89d0-dc628f76c6d3" version = "1.4.0" [[deps.StaticArrayInterface]] deps = ["ArrayInterface", "Compat", "IfElse", "LinearAlgebra", "PrecompileTools", "SciMLPublic", "Static"] git-tree-sha1 = "2a635e15d5035c53b345077c947f31ff91744078" +registries = "General" uuid = "0d7ed370-da01-4f52-bd93-41d350b8b718" version = "1.10.0" weakdeps = ["OffsetArrays", "StaticArrays"] @@ -1771,6 +2507,7 @@ weakdeps = ["OffsetArrays", "StaticArrays"] [[deps.StaticArrays]] deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] git-tree-sha1 = "246a8bb2e6667f832eea063c3a56aef96429a3db" +registries = "General" uuid = "90137ffa-7385-5640-81b9-e52037218182" version = "1.9.18" weakdeps = ["ChainRulesCore", "Statistics"] @@ -1781,12 +2518,14 @@ weakdeps = ["ChainRulesCore", "Statistics"] [[deps.StaticArraysCore]] git-tree-sha1 = "6ab403037779dae8c514bad259f32a447262455a" +registries = "General" uuid = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" version = "1.4.4" [[deps.Statistics]] deps = ["LinearAlgebra"] git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0" +registries = "General" uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" version = "1.11.1" weakdeps = ["SparseArrays"] @@ -1797,24 +2536,62 @@ weakdeps = ["SparseArrays"] [[deps.StatsAPI]] deps = ["LinearAlgebra"] git-tree-sha1 = "178ed29fd5b2a2cfc3bd31c13375ae925623ff36" +registries = "General" uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" version = "1.8.0" [[deps.StatsBase]] deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] git-tree-sha1 = "aceda6f4e598d331548e04cc6b2124a6148138e3" +registries = "General" uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" version = "0.34.10" +[[deps.StatsFuns]] +deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] +git-tree-sha1 = "91a5737baed20ee31f3faea0e51f57461f6a689e" +registries = "General" +uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" +version = "2.2.1" +weakdeps = ["ChainRulesCore", "InverseFunctions"] + + [deps.StatsFuns.extensions] + StatsFunsChainRulesCoreExt = "ChainRulesCore" + StatsFunsInverseFunctionsExt = "InverseFunctions" + +[[deps.StructArrays]] +deps = ["ConstructionBase", "DataAPI", "Tables"] +git-tree-sha1 = "ad8002667372439f2e3611cfd14097e03fa4bccd" +registries = "General" +uuid = "09ab397b-f2b6-538f-b94a-2f83cf4a842a" +version = "0.7.3" + + [deps.StructArrays.extensions] + StructArraysAdaptExt = "Adapt" + StructArraysGPUArraysCoreExt = ["GPUArraysCore", "KernelAbstractions"] + StructArraysLinearAlgebraExt = "LinearAlgebra" + StructArraysSparseArraysExt = "SparseArrays" + StructArraysStaticArraysExt = "StaticArrays" + + [deps.StructArrays.weakdeps] + Adapt = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" + GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527" + KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" + LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + [[deps.StructTypes]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" +registries = "General" uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" version = "1.11.0" [[deps.StructUtils]] deps = ["Dates", "UUIDs"] git-tree-sha1 = "aab80fbf866600f3299dd7f6656d80e7be177cfe" +registries = "General" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" version = "2.7.2" @@ -1832,10 +2609,14 @@ version = "2.7.2" uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" version = "1.11.0" +[[deps.SuiteSparse]] +deps = ["Libdl", "LinearAlgebra", "Serialization", "SparseArrays"] +uuid = "4607b0f0-06f3-5cda-b6b1-a6196a1729e9" + [[deps.SuiteSparse_jll]] -deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl", "libblastrampoline_jll"] uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" -version = "7.8.3+2" +version = "7.10.1+0" [[deps.TOML]] deps = ["Dates"] @@ -1845,18 +2626,21 @@ version = "1.0.3" [[deps.TableOperations]] deps = ["SentinelArrays", "Tables", "Test"] git-tree-sha1 = "e383c87cf2a1dc41fa30c093b2a19877c83e1bc1" +registries = "General" uuid = "ab02a1b2-a7df-11e8-156e-fb1833f50b87" version = "1.2.0" [[deps.TableTraits]] deps = ["IteratorInterfaceExtensions"] git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" +registries = "General" uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" version = "1.0.1" [[deps.Tables]] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"] git-tree-sha1 = "f2c1efbc8f3a609aadf318094f8fc5204bdaf344" +registries = "General" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" version = "1.12.1" @@ -1868,6 +2652,7 @@ version = "1.10.0" [[deps.TensorCore]] deps = ["LinearAlgebra"] git-tree-sha1 = "1feb45f88d133a655e001435632f019a9a1bcdb6" +registries = "General" uuid = "62fd8b95-f654-4bbd-a8a5-9c27f68ccd50" version = "0.1.1" @@ -1879,35 +2664,41 @@ version = "1.11.0" [[deps.ThreadingUtilities]] deps = ["ManualMemory"] git-tree-sha1 = "d969183d3d244b6c33796b5ed01ab97328f2db85" +registries = "General" uuid = "8290d209-cae3-49c0-8002-c8c24d57dab5" version = "0.5.5" [[deps.Thrift2]] deps = ["MacroTools", "OrderedCollections", "PrecompileTools"] git-tree-sha1 = "9610f626cf80cf28468edb20ec2dc007f72aacfa" +registries = "General" uuid = "9be31aac-5446-47db-bfeb-416acd2e4415" version = "0.2.1" [[deps.TiffImages]] deps = ["CodecZstd", "ColorTypes", "DataStructures", "DocStringExtensions", "FileIO", "FixedPointNumbers", "IndirectArrays", "Inflate", "Mmap", "OffsetArrays", "PkgVersion", "PrecompileTools", "ProgressMeter", "SIMD", "UUIDs"] git-tree-sha1 = "9ca5f1f2d42f80df4b8c9f6ab5a64f438bbd9976" +registries = "General" uuid = "731e570b-9d59-4bfa-96dc-6df516fadf69" version = "0.11.9" [[deps.TiledIteration]] deps = ["OffsetArrays", "StaticArrayInterface"] git-tree-sha1 = "1176cc31e867217b06928e2f140c90bd1bc88283" +registries = "General" uuid = "06e1c1a7-607b-532d-9fad-de7d9aa2abac" version = "0.5.0" [[deps.TranscodingStreams]] git-tree-sha1 = "0c45878dcfdcfa8480052b6ab162cdd138781742" +registries = "General" uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" version = "0.11.3" [[deps.Transducers]] deps = ["Accessors", "ArgCheck", "BangBang", "Baselet", "CompositionsBase", "ConstructionBase", "DefineSingletons", "Distributed", "InitialValues", "Logging", "Markdown", "MicroCollections", "SplittablesBase", "Tables"] git-tree-sha1 = "4aa1fdf6c1da74661f6f5d3edfd96648321dade9" +registries = "General" uuid = "28d57a85-8fef-5791-bfe6-a80928e7c999" version = "0.4.85" @@ -1927,8 +2718,15 @@ version = "0.4.85" OnlineStatsBase = "925886fa-5bf2-5e8e-b522-a9147a512338" Referenceables = "42d2dcc6-99eb-4e98-b66c-637b7d73030e" +[[deps.TriplotBase]] +git-tree-sha1 = "4d4ed7f294cda19382ff7de4c137d24d16adc89b" +registries = "General" +uuid = "981d1d27-644d-49a2-9326-4793e63143c3" +version = "0.1.0" + [[deps.URIs]] git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +registries = "General" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" version = "1.6.1" @@ -1939,6 +2737,7 @@ version = "1.11.0" [[deps.UnPack]] git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" +registries = "General" uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" version = "1.0.2" @@ -1946,85 +2745,151 @@ version = "1.0.2" uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" version = "1.11.0" +[[deps.UnicodeFun]] +deps = ["REPL"] +git-tree-sha1 = "53915e50200959667e78a92a418594b428dffddf" +registries = "General" +uuid = "1cfade01-22cf-5700-b092-accc4b62d6e1" +version = "0.4.1" + +[[deps.Unitful]] +deps = ["Dates", "LinearAlgebra", "Random"] +git-tree-sha1 = "1f0f9f401753701a7e4113b5056ca38d33875b55" +registries = "General" +uuid = "1986cc42-f94f-5a68-af5c-568840ba703d" +version = "1.29.0" + + [deps.Unitful.extensions] + ConstructionBaseUnitfulExt = "ConstructionBase" + ForwardDiffExt = "ForwardDiff" + InverseFunctionsUnitfulExt = "InverseFunctions" + LatexifyExt = ["Latexify", "LaTeXStrings"] + NaNMathExt = "NaNMath" + PrintfExt = "Printf" + + [deps.Unitful.weakdeps] + ConstructionBase = "187b0558-2788-49d3-abe0-74a17ed4e7c9" + ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" + Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316" + NaNMath = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" + Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" + [[deps.UnsafeArrays]] git-tree-sha1 = "c63023bd84f46f9df786c90180d4f79dbfdafa2a" +registries = "General" uuid = "c4a57d5a-5b31-53a6-b365-19f8c011fbd6" version = "1.0.9" [[deps.VectorizationBase]] deps = ["ArrayInterface", "CPUSummary", "HostCPUFeatures", "IfElse", "LayoutPointers", "Libdl", "LinearAlgebra", "SIMDTypes", "Static", "StaticArrayInterface"] git-tree-sha1 = "d1d9a935a26c475ebffd54e9c7ad11627c43ea85" +registries = "General" uuid = "3d5dd08c-fd9d-11e8-17fa-ed2836048c2f" version = "0.21.72" [[deps.WeakRefStrings]] deps = ["DataAPI", "InlineStrings", "Parsers"] git-tree-sha1 = "0716e01c3b40413de5dedbc9c5c69f27cddfddfc" +registries = "General" uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" version = "1.4.3" [[deps.WebP]] deps = ["CEnum", "ColorTypes", "FileIO", "FixedPointNumbers", "ImageCore", "libwebp_jll"] git-tree-sha1 = "aa1ca3c47f119fbdae8770c29820e5e6119b83f2" +registries = "General" uuid = "e3aaa7dc-3e4b-44e0-be63-ffb868ccd7c1" version = "0.1.3" [[deps.WoodburyMatrices]] deps = ["LinearAlgebra", "SparseArrays"] git-tree-sha1 = "248a7031b3da79a127f14e5dc5f417e26f9f6db7" +registries = "General" uuid = "efce3f68-66dc-5838-9240-27a6d6f5f9b6" version = "1.1.0" [[deps.WorkerUtilities]] git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" +registries = "General" uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" version = "1.6.1" [[deps.XZ_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "b29c22e245d092b8b4e8d3c09ad7baa586d9f573" +registries = "General" uuid = "ffd25f8a-64ca-5728-b0f7-c24cf3aae800" version = "5.8.3+0" [[deps.Xorg_libX11_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libxcb_jll", "Xorg_xtrans_jll"] git-tree-sha1 = "808090ede1d41644447dd5cbafced4731c56bd2f" +registries = "General" uuid = "4f6342f7-b3d2-589e-9d20-edeb45f2b2bc" version = "1.8.13+0" [[deps.Xorg_libXau_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "aa1261ebbac3ccc8d16558ae6799524c450ed16b" +registries = "General" uuid = "0c0b7dd1-d40b-584c-a123-a41640f87eec" version = "1.0.13+0" [[deps.Xorg_libXdmcp_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "52858d64353db33a56e13c341d7bf44cd0d7b309" +registries = "General" uuid = "a3789734-cfe1-5b06-b2d0-1dd0d9d62d05" version = "1.1.6+0" [[deps.Xorg_libXext_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll"] git-tree-sha1 = "1a4a26870bf1e5d26cd585e38038d399d7e65706" +registries = "General" uuid = "1082639a-0dae-5f34-9b06-72781eeb8cb3" version = "1.3.8+0" +[[deps.Xorg_libXfixes_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll"] +git-tree-sha1 = "75e00946e43621e09d431d9b95818ee751e6b2ef" +registries = "General" +uuid = "d091e8ba-531a-589c-9de9-94069b037ed8" +version = "6.0.2+0" + +[[deps.Xorg_libXrender_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll"] +git-tree-sha1 = "7ed9347888fac59a618302ee38216dd0379c480d" +registries = "General" +uuid = "ea2f1a96-1ddc-540d-b46f-429655e07cfa" +version = "0.9.12+0" + +[[deps.Xorg_libpciaccess_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Zlib_jll"] +git-tree-sha1 = "58972370b81423fc546c56a60ed1a009450177c3" +registries = "General" +uuid = "a65dc6b1-eb27-53a1-bb3e-dea574b5389e" +version = "0.19.0+0" + [[deps.Xorg_libxcb_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libXau_jll", "Xorg_libXdmcp_jll"] git-tree-sha1 = "bfcaf7ec088eaba362093393fe11aa141fa15422" +registries = "General" uuid = "c7cfdc94-dc32-55de-ac96-5a1b8d977c5b" version = "1.17.1+0" [[deps.Xorg_xtrans_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "a63799ff68005991f9d9491b6e95bd3478d783cb" +registries = "General" uuid = "c5fb5394-a638-5e4d-96e5-b29de1b5cf10" version = "1.6.0+0" [[deps.Zarr]] deps = ["Blosc", "CRC32c", "ChunkCodecCore", "ChunkCodecLibZlib", "ChunkCodecLibZstd", "DataStructures", "DateTimes64", "Dates", "DiskArrays", "HTTP", "JSON", "OffsetArrays", "OpenSSL", "Pkg", "URIs", "ZipArchives"] git-tree-sha1 = "e006bf49f81ae1f04af9e9ff405d02620a845405" +registries = "General" uuid = "0a941bbe-ad1d-11e8-39d9-ab76183a1d99" version = "0.10.0" @@ -2037,6 +2902,7 @@ version = "0.10.0" [[deps.ZipArchives]] deps = ["ArgCheck", "CodecInflate64", "CodecZlib", "InputBuffers", "PrecompileTools", "TranscodingStreams", "Zlib_jll"] git-tree-sha1 = "83f728ecb873c58b794964f8b4bed811814d4b0d" +registries = "General" uuid = "49080126-0e18-4c2a-b176-c102e4b3760c" version = "2.6.0" @@ -2046,64 +2912,137 @@ uuid = "83775a58-1f1d-513f-b197-d71354ab007a" version = "1.3.1+2" [[deps.Zstd_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl"] -git-tree-sha1 = "446b23e73536f84e8037f5dce465e92275f6a308" +deps = ["CompilerSupportLibraries_jll", "Libdl"] uuid = "3161d3a3-bdf6-5164-811a-617609db77b4" version = "1.5.7+1" [[deps.brotli_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "46fda47f4215c957bc92fd5fbb5ad04fee1e3743" +registries = "General" uuid = "4611771a-a7d2-5e23-8d00-b1becdba1aae" version = "1.2.0+0" +[[deps.isoband_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "51b5eeb3f98367157a7a12a1fb0aa5328946c03c" +registries = "General" +uuid = "9a68df92-36a6-505f-a73e-abb412b6bfb4" +version = "0.2.3+0" + +[[deps.libaom_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "ef17c47d22224aaecc76e597ab21a072e025cf7b" +registries = "General" +uuid = "a4ae2306-e953-59d6-aa16-d00cac43593b" +version = "3.14.1+0" + +[[deps.libass_jll]] +deps = ["Artifacts", "Bzip2_jll", "FreeType2_jll", "FriBidi_jll", "HarfBuzz_jll", "JLLWrappers", "Libdl", "Zlib_jll"] +git-tree-sha1 = "cb007192783c56d8249db4cf0e3495001edfe414" +registries = "General" +uuid = "0ac62f75-1d6f-5e53-bd7c-93b484bb37c0" +version = "0.17.5+0" + [[deps.libblastrampoline_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" version = "5.15.0+0" +[[deps.libdrm_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libpciaccess_jll"] +git-tree-sha1 = "28e57478e8a160d346a19c28b3fffb9273bcc9c2" +registries = "General" +uuid = "8e53e030-5e6c-5a89-a30b-be5b7263a166" +version = "2.4.134+0" + +[[deps.libfdk_aac_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "646634dd19587a56ee2f1199563ec056c5f228df" +registries = "General" +uuid = "f638f0a6-7fb0-5443-88ba-1cc74229b280" +version = "2.0.4+0" + [[deps.libpng_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl", "Zlib_jll"] git-tree-sha1 = "e51150d5ab85cee6fc36726850f0e627ad2e4aba" +registries = "General" uuid = "b53b4c65-9356-5827-b1ea-8c7a1a84506f" version = "1.6.58+0" [[deps.libsixel_jll]] deps = ["Artifacts", "JLLWrappers", "JpegTurbo_jll", "Libdl", "libpng_jll"] git-tree-sha1 = "c1733e347283df07689d71d61e14be986e49e47a" +registries = "General" uuid = "075b6546-f08a-558a-be8f-8157d0f608a5" version = "1.10.5+0" +[[deps.libva_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Xorg_libX11_jll", "Xorg_libXext_jll", "Xorg_libXfixes_jll", "libdrm_jll"] +git-tree-sha1 = "7dbf96baae3310fe2fa0df0ccbb3c6288d5816c9" +registries = "General" +uuid = "9a156e7d-b971-5f62-b2c9-67348b8fb97c" +version = "2.23.0+0" + +[[deps.libvorbis_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Ogg_jll"] +git-tree-sha1 = "11e1772e7f3cc987e9d3de991dd4f6b2602663a5" +registries = "General" +uuid = "f27f6e37-5d2b-51aa-960f-b287f2bc3b7a" +version = "1.3.8+0" + [[deps.libwebp_jll]] deps = ["Artifacts", "Giflib_jll", "JLLWrappers", "JpegTurbo_jll", "Libdl", "Libglvnd_jll", "Libtiff_jll", "libpng_jll"] git-tree-sha1 = "4e4282c4d846e11dce56d74fa8040130b7a95cb3" +registries = "General" uuid = "c5f90fcd-3b7e-5836-afba-fc50a0988cb2" version = "1.6.0+0" [[deps.libzip_jll]] deps = ["Artifacts", "Bzip2_jll", "JLLWrappers", "Libdl", "OpenSSL_jll", "XZ_jll", "Zlib_jll", "Zstd_jll"] git-tree-sha1 = "86addc139bca85fdf9e7741e10977c45785727b7" +registries = "General" uuid = "337d8026-41b4-5cde-a456-74a10e5b31d1" version = "1.11.3+0" [[deps.nghttp2_jll]] -deps = ["Artifacts", "Libdl"] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" -version = "1.64.0+1" +version = "1.67.1+0" [[deps.oneTBB_jll]] deps = ["Artifacts", "JLLWrappers", "LazyArtifacts", "Libdl"] git-tree-sha1 = "da8c1f6eee04831f14edcfa5dae611d309807e57" +registries = "General" uuid = "1317d2d5-d96f-522e-a858-c73665f53c3e" version = "2022.3.0+0" [[deps.p7zip_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" -version = "17.7.0+0" +version = "17.8.2+0" [[deps.snappy_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] git-tree-sha1 = "ca88363dd41d2547f52118287dd34dbbc14f3eb7" +registries = "General" uuid = "fe1e1685-f7be-5f59-ac9f-4ca204017dfd" version = "1.2.3+0" + +[[deps.x264_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "14cc7083fc6dff3cc44f2bc435ee96d06ed79aa7" +registries = "General" +uuid = "1270edf5-f2f9-52d2-97e9-ab00b5d0237a" +version = "10164.0.1+0" + +[[deps.x265_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "e7b67590c14d487e734dcb925924c5dc43ec85f3" +registries = "General" +uuid = "dfaa095f-4041-5dcd-9319-2fabd8486b76" +version = "4.1.0+0" + +[registries.General] +url = "https://github.com/JuliaRegistries/General.git" +uuid = "23338594-aafe-5451-b93e-139f81909106" diff --git a/docs/Project.toml b/docs/Project.toml index ba693d6..217ea84 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,3 +1,11 @@ [deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" SpatialOmics = "7c9a2e4e-8f1d-4a6b-b84c-3e8a9f2d1c5e" + +[sources] +SpatialOmics = {path = ".."} + +[compat] +CairoMakie = "0.15" +Documenter = "1.19" diff --git a/docs/heavy/Project.toml b/docs/heavy/Project.toml new file mode 100644 index 0000000..f5352d7 --- /dev/null +++ b/docs/heavy/Project.toml @@ -0,0 +1,11 @@ +[deps] +CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0" +SpatialOmics = "7c9a2e4e-8f1d-4a6b-b84c-3e8a9f2d1c5e" + +[sources] +SpatialOmics = {path = "../.."} + +[compat] +CairoMakie = "0.15" +SpatialOmics = "0.2" +julia = "1.12" diff --git a/docs/make.jl b/docs/make.jl index 6c94805..e81ce4a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -18,8 +18,15 @@ makedocs( "explanation/lazy_views.md", ], "Tutorials" => [ + "tutorials/index.md", + "tutorials/building_dataset.md", + "tutorials/coordinate_workflow.md", + "tutorials/source_roi_selection.md", + "tutorials/expression_summaries.md", + "tutorials/persistence.md", "tutorials/xenium.md", "tutorials/visium.md", + "tutorials/custom_starmap_reader.md", ], "Reference" => [ "reference/dataset.md", @@ -42,7 +49,7 @@ makedocs( if get(ENV, "CI", nothing) == "true" deploydocs( - repo = Documenter.Remotes.GitHub("BonhamLab", "SpatialOmics.jl"), + repo = "github.com/BonhamLab/SpatialOmics.jl.git", target = "build", push_preview = true, ) diff --git a/docs/src/explanation/coordinate_systems.md b/docs/src/explanation/coordinate_systems.md index dd7cee5..0bf9096 100644 --- a/docs/src/explanation/coordinate_systems.md +++ b/docs/src/explanation/coordinate_systems.md @@ -16,8 +16,8 @@ images are far too large to hold in memory. ## Named coordinate systems as a graph -SpatialOmics models coordinate spaces explicitly as a directed acyclic graph -(DAG). Each node is a [`CoordinateSystem`](@ref) with a name, axis labels, and units. +SpatialOmics models coordinate spaces explicitly as a named transform graph. +Each node is a [`CoordinateSystem`](@ref) with a name, axis labels, and units. Each edge is an [`AbstractTransformation`](@ref) carrying `src` and `dst` coordinate system names. @@ -39,7 +39,9 @@ homogeneous coordinates. This representation lets rotation, scaling, shear, and translation be encoded uniformly, and lets sequential transforms be fused by matrix multiplication. -The constructor helpers — [`translation`](@ref), [`scaling`](@ref), [`rotation`](@ref), [`flip_y`](@ref) — each +The constructor helpers — [`translation`](@ref SpatialOmics.translation), +[`scaling`](@ref SpatialOmics.scaling), [`rotation`](@ref SpatialOmics.rotation), +and [`flip_y`](@ref SpatialOmics.flip_y) — each produce an [`Affine`](@ref) with explicit `src` and `dst` names: ```julia @@ -53,7 +55,7 @@ push!(ds, CoordinateSystem("global")) push!(ds, t) ``` -[`compose`](@ref) fuses two [`Affine`](@ref) transforms into one (matrix product), or wraps +[`compose`](@ref SpatialOmics.compose) fuses two [`Affine`](@ref) transforms into one (matrix product), or wraps mixed types in a [`Sequence`](@ref). The `src`/`dst` chain must be consistent — `a.dst == b.src` is enforced. @@ -72,7 +74,8 @@ answer. `CoordinateTransformations.jl` is a general-purpose library for function-based transforms. SpatialOmics uses its own [`Affine`](@ref) type for two reasons: (1) the -augmented-matrix representation enables O(1) fusion via [`compose`](@ref), which matters +augmented-matrix representation enables O(1) fusion via +[`compose`](@ref SpatialOmics.compose), which matters when resolving paths through multi-hop graphs at load time; (2) every transformation carries explicit `src` and `dst` names, making the graph structure first-class rather than implicit in calling code. diff --git a/docs/src/explanation/data_model.md b/docs/src/explanation/data_model.md index 43857b6..49a08e2 100644 --- a/docs/src/explanation/data_model.md +++ b/docs/src/explanation/data_model.md @@ -24,6 +24,24 @@ and typed, without prejudicing any one modality as primary. All four types belong to one named coordinate system. A dataset can hold any number of elements of each type, keyed by name. +## Acquisition provenance + +An [`AcquisitionSource`](@ref) records the instrument-defined unit that +produced an observation, such as a CosMx field of view. Sources may be linked +to footprint polygons, but membership is stored independently as compact +origin IDs on points and shapes. This distinction prevents an observation in +the overlap between two FOV footprints from being silently assigned to both. + +```julia +sources(ds) # registered source names +view(ds, "fov_2_px") # observations acquired in FOV 2 +view(ds, user_drawn_polygon) # all observations inside the polygon +``` + +Use [`origins`](@ref) and [`origin_ids`](@ref) for bulk provenance, or +`source(element, i)` for one observation. See [Acquisition sources and +geometric regions](@ref) for view behavior and compatibility fallback rules. + ## Typed accessors Elements are stored internally in a flat `OrderedDict`. The typed accessor @@ -36,28 +54,56 @@ tx = points(ds, "transcripts") # SpatialPoints or error img = images(ds, "morphology") # SpatialImage or error ``` -Accessing [`elements`](@ref)`(ds)` directly returns the raw `OrderedDict` without type -checking. Use typed accessors in application code; `elements` is useful for -iteration or introspection. +[`elements`](@ref)`(ds)` returns a shallow dictionary snapshot. Changing that +dictionary does not change the dataset. Use typed accessors for retrieval and +[`edit!`](@ref) when mutating an attached element. ## The backing store -Every [`SpatialDataset`](@ref) is associated with a [`BackingStore`](@ref) — a Zarr directory -on disk. This is not optional. The design exists because: +Every [`SpatialDataset`](@ref) is associated with a [`BackingStore`](@ref) — a +Zarr directory on disk. This provides a durable target for large, lazily loaded +data without forcing every analysis step to perform I/O. 1. **Lazy loading** — images and large point clouds can exceed available RAM. Zarr arrays are read on demand through `DiskArrays.jl`. -2. **Persistence by default** — operations that produce new datasets (such as - `read`) always have a place to write without a separate "save" step. -3. **SpatialData compatibility** — the on-disk layout matches the - [SpatialData specification](https://spatialdata.scverse.org/), enabling - round-trip with Python tools without a conversion step. +2. **Explicit checkpoints** — supported mutations are staged and can be saved + together or element by element. +3. **Visible state** — [`isdirty`](@ref), [`dirty`](@ref), and dataset display + distinguish saved data from unsaved work. When `SpatialDataset()` is called without a `path`, a temporary directory is -created and owned by the dataset — it is deleted automatically when the dataset -is garbage collected or `close`d. Supply `path` to write directly to a -persistent location, or call [`write!`](@ref)`(ds, path, `[`SpatialDataZarr`](@ref)`())` to move a -temporary store to a permanent one. +created and owned by the dataset. Supply `path` to choose a persistent backing +location. In both cases, mutations remain staged until [`save!`](@ref) is +called: + +```julia +ds["transcripts"] = transcripts +isdirty(ds) # true +dirty(ds) # identifies the staged element +save!(ds, "transcripts") # save one element +save!(ds) # save everything else +``` + +Use `save!(ds; path="/data/experiment.zarr")` to atomically write a complete +snapshot and rebind a temporary dataset to a permanent location. `close(ds)` +rejects unsaved changes; `discard!(ds)` restores saved state, while +`close(ds; discard=true)` explicitly abandons it. + +Mutation through package operations is tracked automatically. For mutation +through an external API, use a scoped edit or mark the element afterward: + +```julia +edit!(ds, "transcripts") do points + points.feature_id[1] = 2 +end + +external_mutation!(points(ds, "transcripts")) +touch!(ds, "transcripts") +``` + +The native layout is a SpatialOmics format. Reading supported Python +SpatialData stores is an import operation; native stores should not be assumed +to round-trip through Python without an explicit exporter. ## Instance IDs and cross-element linkage diff --git a/docs/src/explanation/lazy_views.md b/docs/src/explanation/lazy_views.md index 1d755c1..615e686 100644 --- a/docs/src/explanation/lazy_views.md +++ b/docs/src/explanation/lazy_views.md @@ -31,8 +31,9 @@ for visualisation — `SpatialShapes(ext)` produces a rectangular polygon. ## SpatialElementView and SpatialDatasetView `view(el, roi)` returns a [`SpatialElementView`](@ref) — a struct holding a -reference to the parent element and the ROI. No data is read, no arrays are -allocated. The element's accessors — [`coords`](@ref), [`geometries`](@ref), [`feature_ids`](@ref), +reference to the parent element and the ROI. Constructing the wrapper does not +copy the spatial element; its selection mask is computed when filtered data are +requested. The element's accessors — [`coords`](@ref), [`geometries`](@ref), [`feature_ids`](@ref), [`instance_id`](@ref), [`count_per_instance`](@ref) — are all defined on [`SpatialElementView`](@ref) and apply the filter on each call. @@ -41,6 +42,39 @@ to every element in the dataset. Accessing a specific element via [`points`](@ref) or [`images`](@ref) returns a [`SpatialElementView`](@ref) for that element. +## Acquisition sources and geometric regions + +A registered [`AcquisitionSource`](@ref) represents where an observation was +acquired, independently of where its coordinates happen to fall. This matters +when fields of view overlap: a transcript recorded by `fov_1_px` can lie inside +the footprint of `fov_2_px` without becoming an observation from FOV 2. + +```julia +fov = view(ds, "fov_2_px") # provenance: observations acquired in FOV 2 +roi = view(ds, polygon) # geometry: every observation inside the polygon +``` + +Multiple sources use ordinary Julia selection semantics: selecting FOVs 1 and +5 returns those two sources, not the rectangular region bounded by them. + +```julia +selected = view(ds, ["fov_1_px", "fov_5_px"]) +tx = points(selected, "transcripts") +tiles = images(selected, "morphology") +``` + +`tiles` is a [`SpatialRasterTiles`](@ref) collection of positioned image crops. +Plotting it renders each crop in the shared coordinate system without allocating +pixels in the gap between disconnected FOVs. Creating a dense bounding canvas +is a separate, explicit operation. + +Points and shapes store compact per-observation origin IDs. Images and labels +are cropped to the registered source footprint. If a vector element predates +origin tracking, dataset-level source selection emits a warning and falls back +to footprint geometry; direct element-level source selection instead errors. +This makes the compatibility behavior visible without adding boilerplate to +the usual source-selection workflow. + This is the preferred way to build multi-layer plots: define the region once, then pass the view to each plot verb independently. Each verb applies the filter at materialisation time: @@ -75,10 +109,11 @@ you only need to plot or inspect the region once. Point containment is unambiguous — a point is either inside a region or not. Shape containment admits two interpretations: -- `:any` (default) — include shapes whose bounding box intersects the ROI. - This is a fast approximation: some included shapes may extend outside the ROI. -- `:full` — include only shapes whose bounding box lies entirely within the ROI. - More conservative; use when you need all included shapes to be completely visible. +- `:any` (default) — include shapes that intersect the ROI. Polygon ROIs use an + exact intersection test after a bounding-box prefilter; rectangular extents + use bounding-box intersection. +- `:full` — include only shapes contained by the ROI. Polygon ROIs use exact + containment; rectangular extents use bounding-box containment. The `overlap` keyword is passed to `view(el, roi; overlap=:any)`. It has no effect on point filtering. diff --git a/docs/src/guides/cosmx.md b/docs/src/guides/cosmx.md index eb6c0b9..18bddab 100644 --- a/docs/src/guides/cosmx.md +++ b/docs/src/guides/cosmx.md @@ -3,6 +3,13 @@ CosMx SMI exports a flat-file directory with per-FOV transcripts, cell segmentation polygons, and optional tissue images (Morphology2D TIF tiles). +For a reproducible public input, Bruker publishes a [CosMx Human Lymph Node +FFPE dataset](https://brukerspatialbiology.com/products/cosmx-spatial-molecular-imager/ffpe-dataset/cosmx-human-lymph-node-ffpe-dataset/) +with transcript coordinates, cell metadata, FOV positions, polygons, and +images. Full public releases are too large for routine documentation builds; +rendered examples should be generated from a documented subset and committed +in the same way as the Xenium and Visium tutorial figures. + ## Loading ```julia @@ -15,7 +22,7 @@ ds = read(CosMx(), "/path/to/cosmx_export/") ds = read(CosMx(morphology_dir="/path/to/Morphology2D"), "/path/to/cosmx_export/") # Cache to disk for faster subsequent loads -write!(ds, "/path/to/cache.zarr", SpatialDataZarr()) +save!(ds; path="/path/to/cache.zarr") ds2 = read(SpatialDataZarr(), "/path/to/cache.zarr") ``` @@ -25,7 +32,7 @@ ds2 = read(SpatialDataZarr(), "/path/to/cache.zarr") keys(elements(ds)) # list all loaded elements tx = points(ds, "transcripts") -bnd = shapes(ds, "cell_boundaries") +bnd = shapes(ds, "cells") # Top expressed genes top_features(tx, 20) @@ -42,13 +49,14 @@ transforms in the dataset's transform graph. ```julia # List all registered coordinate systems -coord_systems(ds) # ["fov_1", "fov_2", ..., "global"] +coord_systems(ds) # ["global_px", "fov_1_px", "fov_2_px", ...] # Resolve a transform from a FOV to global space -t = transform(ds, "fov_1", "global") +t = transform(ds, "fov_1_px", "global_px") # Apply to transform an element between spaces -tx_global = apply(t, points(ds, "transcripts_fov_1")) +local_points = SpatialPoints([Point2f(10, 20)]; coord_system="fov_1_px") +tx_global = apply(t, local_points) ``` ## Spatial filtering @@ -57,23 +65,37 @@ Use `SpatialExtent` or `SpatialROI` to define a region of interest. Views are lazy — no data is copied: ```julia -ext = SpatialExtent(5000.0, 7000.0, 3000.0, 5000.0; coord_system="global") +ext = SpatialExtent(5000.0, 7000.0, 3000.0, 5000.0; coord_system="global_px") roi = view(ds, ext) # Filter transcripts and shapes to the ROI tx_roi = points(roi, "transcripts") -bnd_roi = shapes(roi, "cell_boundaries") +bnd_roi = shapes(roi, "cells") # Subsampled scatter for quick overview scatter!(ax, subsample(collect(tx_roi), 50_000); markersize=1) ``` +Each CosMx FOV is also registered as an acquisition source. Source views use +the FOV recorded by the instrument rather than footprint geometry: + +```julia +sources(ds) # ["fov_1_px", "fov_2_px", ...] +fov2 = view(ds, "fov_2_px") +tx_fov2 = points(fov2, "transcripts") +``` + +If two FOV footprints overlap, `tx_fov2` contains only transcripts acquired in +FOV 2. A user-drawn `SpatialROI` over the same overlap contains transcripts +from both FOVs. The `z` and `CellComp` transcript annotations are available as +`features(tx, :z)` and `features(tx, :CellComp)`. + ## Visualisation ```julia using CairoMakie -ext = SpatialExtent(5000.0, 6000.0, 3000.0, 4000.0; coord_system="global") +ext = SpatialExtent(5000.0, 6000.0, 3000.0, 4000.0; coord_system="global_px") roi = view(ds, ext) fig = Figure(size=(600, 600)) @@ -82,7 +104,7 @@ ax = Axis(fig[1, 1]; aspect=DataAspect(), yreversed=true) # Tissue image — rescaled for display image!(ax, scaleminmax(channel(images(roi, "morphology"), 1))) # Cell boundaries -poly!(ax, shapes(roi, "cell_boundaries"); color=:transparent, strokecolor=:cyan, strokewidth=0.3) +poly!(ax, shapes(roi, "cells"); color=:transparent, strokecolor=:cyan, strokewidth=0.3) # Top gene transcripts for gene in top_features(points(roi, "transcripts"), 3) scatter!(ax, coords(points(roi, "transcripts"), gene); label=gene, markersize=2) @@ -92,11 +114,11 @@ tightlimits!(ax) fig ``` -## Export to SpatialData +## Native persistence -The resulting Zarr directory is compatible with Python's SpatialData library, -enabling handoff to Python-based downstream analysis: +Save the assembled dataset in the native SpatialOmics Zarr layout for later +Julia workflows. Python handoff requires an explicit SpatialData export path. ```julia -write!(ds, "/path/to/output.zarr", SpatialDataZarr()) +save!(ds; path="/path/to/output.zarr") ``` diff --git a/docs/src/guides/quickstart.md b/docs/src/guides/quickstart.md index c95ac5a..8e7cc02 100644 --- a/docs/src/guides/quickstart.md +++ b/docs/src/guides/quickstart.md @@ -9,7 +9,7 @@ first argument to `read`: using SpatialOmics import SpatialOmics as SO -# SpatialData OME-Zarr (Xenium, CosMx, Visium, MERFISH — any SpatialData-compatible store) +# Native Zarr or a supported SpatialData Zarr store ds = read(SpatialDataZarr(), "/path/to/experiment.zarr") # CosMx SMI raw flat-file export @@ -87,9 +87,9 @@ multi-channel display, and channel selection. ## Saving ```julia -write!(ds, "/path/to/output.zarr", SpatialDataZarr()) +save!(ds; path="/path/to/output.zarr") ``` -`write!` writes to disk and updates the dataset's backing store to the new -location — use this for persistent saves. The resulting Zarr directory is -compatible with Python's SpatialData library. +`save!` writes a complete native snapshot and updates the dataset's backing +location. Subsequent package-mediated mutations are visible through +`isdirty(ds)` and remain staged until the next `save!` call. diff --git a/docs/src/guides/visualization.md b/docs/src/guides/visualization.md index b5c4d79..2236951 100644 --- a/docs/src/guides/visualization.md +++ b/docs/src/guides/visualization.md @@ -33,8 +33,9 @@ arguments (`color`, `strokewidth`, `markersize`, etc.). `SpatialImage` objects loaded from OME-Zarr carry pre-computed pyramid levels as lazy `DiskArray`-backed arrays. `image!(ax, img)` selects the correct -resolution level on every zoom or pan event. No pixels are loaded until -a viewport is established. +resolution level as the visible region changes. Initial plot construction may +read a selected pyramid level; it does not automatically materialise the full +resolution image. ```julia img = images(ds, "morphology_focus") @@ -55,8 +56,9 @@ dapi = channel(img, 1) # or channel(img, "DAPI") image!(ax, scaleminmax(dapi)) ``` -`scaleminmax` samples the intensity range from the coarsest pyramid level and -attaches a min-max display transform applied at render time — no copy is made. +`scaleminmax` reads the coarsest pyramid level to estimate the intensity range, +then attaches a min-max display transform applied at render time. It does not +copy the full-resolution image. ## Lazy spatial views diff --git a/docs/src/index.md b/docs/src/index.md index e5752b9..dc90462 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -4,7 +4,8 @@ A Julia library for loading, representing, and analysing spatial transcriptomics data. It provides a common data model for multi-modal spatial experiments — transcripts, cell boundaries, tissue images, segmentation masks, and expression matrices — alongside lazy spatial views, a multi-FOV coordinate -system graph, and SpatialData OME-Zarr interoperability with Python tools. +system graph, explicit persistence, and import support for SpatialData Zarr +stores. ## Installation @@ -19,7 +20,7 @@ Pkg.add("SpatialOmics") using CairoMakie # load a Makie backend before plotting using SpatialOmics -# Load from SpatialData OME-Zarr (Xenium, CosMx, Visium, …) +# Load a native store or a supported SpatialData Zarr store ds = read(SpatialDataZarr(), "/path/to/experiment.zarr") # Inspect structure @@ -44,6 +45,8 @@ fig - **[Explanation](@ref "The data model")** — Why things are designed the way they are: the data model, coordinate system graph, and lazy view semantics. +- **[Tutorials](@ref "Tutorials")** — Executable core lessons plus + pre-rendered workflows using public technology datasets. - **[Reference](@ref "Dataset")** — Complete API documentation for all exported functions and types. - **[Guides](@ref "Quickstart")** — Task-oriented walkthroughs: loading data, diff --git a/docs/src/reference/coordinate_systems.md b/docs/src/reference/coordinate_systems.md index 2d1c85c..75c9874 100644 --- a/docs/src/reference/coordinate_systems.md +++ b/docs/src/reference/coordinate_systems.md @@ -20,16 +20,16 @@ Sequence ## Transformation constructors ```@docs -translation -scaling -rotation -flip_y +SpatialOmics.translation +SpatialOmics.scaling +SpatialOmics.rotation +SpatialOmics.flip_y ``` ## Operations ```@docs -compose +SpatialOmics.compose apply apply! resolve diff --git a/docs/src/reference/dataset.md b/docs/src/reference/dataset.md index 24ba45d..58c512b 100644 --- a/docs/src/reference/dataset.md +++ b/docs/src/reference/dataset.md @@ -8,6 +8,7 @@ See [The data model](@ref) for a conceptual overview. ```@docs SpatialDataset BackingStore +AcquisitionSource ``` ## Lifecycle @@ -15,6 +16,13 @@ BackingStore ```@docs with_dataset keep! +save! +discard! +edit! +touch! +isdirty +dirty +Base.close(::SpatialDataset) ``` ## Accessors @@ -23,6 +31,9 @@ keep! elements coord_systems transform +sources +source +source_attributes relations ``` diff --git a/docs/src/reference/elements.md b/docs/src/reference/elements.md index 55dbe4d..2dcff34 100644 --- a/docs/src/reference/elements.md +++ b/docs/src/reference/elements.md @@ -14,9 +14,12 @@ SpatialPoints coords features feature_ids +origins +origin_ids coord_system instance_id instance_ids +with_instance_ids ``` ## Shape collections @@ -42,5 +45,6 @@ count_per_instance ## Re-exported geometry types -`Polygon` and `Point2f` are re-exported from `GeometryBasics.jl`. -Use `Polygon(ring)` to construct cell boundary polygons for `SpatialShapes`. +`Polygon`, `MultiPolygon`, and `Point2f` are re-exported from +`GeometryBasics.jl`. Use a `MultiPolygon` when one biological object has +multiple disconnected components; it remains one row in `SpatialShapes`. diff --git a/docs/src/reference/io.md b/docs/src/reference/io.md index 667842f..65a6ff1 100644 --- a/docs/src/reference/io.md +++ b/docs/src/reference/io.md @@ -1,13 +1,14 @@ # I/O -SpatialOmics uses format tokens to select the read/write backend. Pass the -token as the first argument to `read` or `write!`. +SpatialOmics uses format tokens to select import and snapshot formats. Native +dataset changes are persisted with [`save!`](@ref). ## Format tokens ```@docs SpatialDataZarr CosMx +native_store_version ``` ## Reading @@ -15,7 +16,7 @@ CosMx `Base.read` is extended for spatial format tokens: ```julia -# SpatialData OME-Zarr (auto-detects Julia vs Python-written stores) +# Native SpatialOmics Zarr, or a supported Python-written SpatialData store ds = read(SpatialDataZarr(), "/path/to/experiment.zarr") # CosMx SMI raw flat-file export @@ -25,11 +26,18 @@ ds = read(CosMx(), "/path/to/cosmx_export/") ds = read(CosMx(morphology_dir="/path/to/Morphology2D"), "/path/to/export/") ``` +Native stores carry an explicit schema version. Stores written before versioning, +or with an unsupported version, are rejected with a rebuild instruction; reads never +silently migrate or reinterpret them. Use `native_store_version(path)` to inspect a +store before opening it. A future migration API can upgrade a store only when called +explicitly. + ## Writing ```@docs write! ``` -`Base.write` (without `!`) is also defined and writes to disk without updating -the dataset's backing store location. Prefer `write!` for persistent saves. +`write!` is retained as a compatibility spelling for a complete native save +and rebind. `Base.write` writes a snapshot without updating the active backing +location or clearing its dirty state. diff --git a/docs/src/reference/relations_analysis.md b/docs/src/reference/relations_analysis.md index 59654c9..2829a8f 100644 --- a/docs/src/reference/relations_analysis.md +++ b/docs/src/reference/relations_analysis.md @@ -9,8 +9,6 @@ by `analyze`. ```@docs RelationKind Membership -Proximity -KNN Expression ``` @@ -18,8 +16,11 @@ Expression ```@docs SpatialRelation +source_ids +destination_ids nobs nvar +obs_names var_names annotate ``` diff --git a/docs/src/reference/views.md b/docs/src/reference/views.md index 293bc1c..8aa5c77 100644 --- a/docs/src/reference/views.md +++ b/docs/src/reference/views.md @@ -16,11 +16,20 @@ SpatialDatasetView SpatialElementView ``` +Views accept geometric regions or registered acquisition sources: + +```julia +view(ds, roi) # geometric membership +view(ds, "fov_1_px") # acquisition provenance +view(ds, ["fov_1_px", "fov_5_px"]) # exact union of acquisition sources +``` + ## Accessors ```@docs geometry -select +roi +roi! ``` ## Example: constructing and intersecting extents diff --git a/docs/src/reference/visualization.md b/docs/src/reference/visualization.md index 93b8cbc..7fc65f0 100644 --- a/docs/src/reference/visualization.md +++ b/docs/src/reference/visualization.md @@ -10,6 +10,7 @@ These types and functions support image display. Makie plot verbs (`heatmap!`, ```@docs SpatialImage SpatialLabels +SpatialRasterTiles SpatialImageColorView ``` @@ -22,6 +23,7 @@ channel scaleminmax colorview build_pyramid! +ensure_pyramid! data ``` diff --git a/docs/src/tutorials/building_dataset.md b/docs/src/tutorials/building_dataset.md new file mode 100644 index 0000000..c013354 --- /dev/null +++ b/docs/src/tutorials/building_dataset.md @@ -0,0 +1,145 @@ +# Build a spatial dataset + +```@setup building-dataset +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +This tutorial builds a small transcript-and-cell dataset using only public +constructors and accessors. The same pattern is useful for an unsupported assay +format: parse the source tables at the boundary, then construct ordinary +SpatialOmics elements. + +## Construct transcript points + +`SpatialPoints` accepts any Tables.jl-compatible object. Identify the feature +column with `gene` and keep other per-transcript columns in `features`. +Acquisition origins are independent of spatial coordinates. + +```@example building-dataset +using SpatialOmics + +transcript_table = ( + x = Float32[1, 2, 7, 8], + y = Float32[1, 2, 1, 2], + gene = ["Actb", "Gapdh", "Actb", "Krt8"], + quality = Float32[0.98, 0.93, 0.96, 0.91], + origin = fill("fov_1", 4), +) + +transcripts = SpatialPoints( + transcript_table; + gene=:gene, + origin=:origin, + features=(quality=transcript_table.quality,), + coord_system="global_um", +) + +(length(transcripts), features(transcripts), features(transcripts, :quality)) +``` + +Feature labels use a compact codebook plus integer IDs. Use `features` and +`feature_ids` instead of depending on those storage fields directly. + +## Construct cell polygons + +`Polygon` and `MultiPolygon` are re-exported from GeometryBasics. One entry in +`SpatialShapes` represents one object, even when that object is a +`MultiPolygon` with disconnected components. + +```@example building-dataset +square(xmin, xmax, ymin, ymax) = Polygon(Point2f[ + (xmin, ymin), (xmax, ymin), (xmax, ymax), + (xmin, ymax), (xmin, ymin), +]) + +cells = SpatialShapes( + [square(0, 4, 0, 4), square(6, 10, 0, 4)]; + instance_id=Int32[101, 102], + origins=["fov_1", "fov_1"], + coord_system="global_um", +) + +(length(cells), instance_id(cells), coord_system(cells)) +``` + +## Assemble the dataset + +Register coordinate systems and acquisition sources explicitly. The source +footprint is a spatial object; the source name is the acquisition identity +stored on observations. + +```@example building-dataset +dataset = SpatialDataset(metadata=Dict("technology" => "synthetic example")) +push!(dataset, CoordinateSystem("global_um"; units=("µm", "µm"))) + +dataset["transcripts"] = transcripts +dataset["cells"] = cells +dataset["fov_footprints"] = SpatialShapes( + [square(0, 10, 0, 4)]; + instance_id=Int32[1], + origins=["fov_1"], + coord_system="global_um", +) +push!(dataset, AcquisitionSource( + "fov_1"; + region="fov_footprints", + instance_id=1, + attributes=Dict("vendor_fov" => 1), +)) + +( + element_names=collect(keys(elements(dataset))), + coordinate_systems=coord_systems(dataset), + acquisition_sources=sources(dataset), +) +``` + +The assembled layers share one coordinate system, so they can be inspected in +one spatial panel. Transcript color denotes the feature label; the dashed +outline is the acquisition footprint. + +```@eval building-dataset +figure = Figure(size=(720, 340)) +axis = Axis(figure[1, 1]; aspect=DataAspect(), xlabel="x (µm)", ylabel="y (µm)") +poly!(axis, geometries(cells); color=(:lightsteelblue, 0.45), + strokecolor=:steelblue, strokewidth=2) +poly!(axis, geometries(shapes(dataset, "fov_footprints")); color=:transparent, + strokecolor=:gray35, strokewidth=2, linestyle=:dash) +gene_palette = [:darkorange, :seagreen, :mediumpurple] +for gene_id in eachindex(features(transcripts)) + mask = feature_ids(transcripts) .== gene_id + scatter!(axis, coords(transcripts)[mask]; color=gene_palette[gene_id], + markersize=14, strokecolor=:white, strokewidth=1, + label=features(transcripts)[gene_id]) +end +text!(axis, 2, 3.4; text="cell 101", align=(:center, :center), color=:steelblue4) +text!(axis, 8, 3.4; text="cell 102", align=(:center, :center), color=:steelblue4) +axislegend(axis; position=:lt, orientation=:horizontal, framevisible=false) +xlims!(axis, -0.5, 10.5); ylims!(axis, -0.5, 4.5) +save("building-dataset.svg", figure) +Markdown.parse("![Transcript and cell layers](building-dataset.svg)") +``` + +Typed accessors fail early if a name refers to the wrong kind of element: + +```@example building-dataset +tx = points(dataset, "transcripts") +bounds = shapes(dataset, "cells") +(top_features(tx, 3), count_per_instance(tx)) +``` + +These transcript rows have not yet been assigned to cells, so their +`instance_id` values are zero and `count_per_instance` is empty. Spatial +assignment is covered in [Assign transcripts and summarise expression](@ref). + +```@example building-dataset +close(dataset; discard=true) # hide +nothing # hide +``` diff --git a/docs/src/tutorials/coordinate_workflow.md b/docs/src/tutorials/coordinate_workflow.md new file mode 100644 index 0000000..a164158 --- /dev/null +++ b/docs/src/tutorials/coordinate_workflow.md @@ -0,0 +1,102 @@ +# Place an FOV in slide coordinates + +```@setup coordinate-workflow +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +Imaging assays often report transcript positions in FOV-local pixels while +slide overlays use a physical global coordinate system. SpatialOmics keeps +those spaces named and resolves the transformation path explicitly. + +## Define the coordinate spaces + +This example has 0.5 µm pixels. The FOV origin lies at `(1000, 250)` µm in the +slide coordinate system. + +```@example coordinate-workflow +using SpatialOmics +import SpatialOmics as SO + +dataset = SpatialDataset() +push!(dataset, CoordinateSystem("fov_1_px"; units=("px", "px"))) +push!(dataset, CoordinateSystem("fov_1_um"; units=("µm", "µm"))) +push!(dataset, CoordinateSystem("slide_um"; units=("µm", "µm"))) + +pixel_size = SO.scaling(0.5, 0.5, "fov_1_px", "fov_1_um") +placement = SO.translation(1000.0, 250.0, "fov_1_um", "slide_um") +push!(dataset, pixel_size) +push!(dataset, placement) + +coord_systems(dataset) +``` + +## Resolve and apply the path + +`transform` resolves a path through the named graph. `apply` returns a new +element and leaves the local data unchanged. + +```@example coordinate-workflow +local_transcripts = SpatialPoints( + [Point2f(0, 0), Point2f(20, 40), Point2f(60, 20), + Point2f(80, 70), Point2f(100, 50)]; + coord_system="fov_1_px", +) + +to_slide = transform(dataset, "fov_1_px", "slide_um") +slide_transcripts = apply(to_slide, local_transcripts) + +( + local_coordinates=coords(local_transcripts), + slide=coords(slide_transcripts), + destination=coord_system(slide_transcripts), +) +``` + +The same observations retain their identity as the FOV is scaled from pixels +to micrometres and translated into its slide position. + +```@eval coordinate-workflow +point_colors = Makie.wong_colors()[1:length(local_transcripts)] +figure = Figure(size=(820, 400)) +local_axis = Axis(figure[1, 1]; title="FOV-local pixels", + xlabel="x (px)", ylabel="y (px)") +slide_axis = Axis(figure[1, 2]; title="Placed on slide", + xlabel="x (µm)", ylabel="y (µm)") +scatter!(local_axis, local_transcripts; color=point_colors, markersize=14) +scatter!(slide_axis, slide_transcripts; color=point_colors, markersize=14) +poly!(local_axis, [Rect2f(0, 0, 110, 80)]; color=(:steelblue, 0.08), + strokecolor=:steelblue, strokewidth=2) +poly!(slide_axis, [Rect2f(1000, 250, 55, 40)]; color=(:steelblue, 0.08), + strokecolor=:steelblue, strokewidth=2) +Label(figure[2, 1:2], "scale × 0.5, then translate + (1000, 250)", + tellheight=true, justification=:center) +xlims!(local_axis, -8, 118); ylims!(local_axis, -8, 88) +xlims!(slide_axis, 996, 1059); ylims!(slide_axis, 246, 294) +save("coordinate-workflow.svg", figure) +Markdown.parse("![FOV coordinates before and after transformation](coordinate-workflow.svg)") +``` + +Affine edges can also be resolved in reverse. Unsupported paths fail instead +of silently combining incompatible coordinates. + +```@example coordinate-workflow +to_pixels = transform(dataset, "slide_um", "fov_1_px") +apply(to_pixels, coords(slide_transcripts)) +``` + +For attached elements, prefer the pure `apply` form unless the dataset itself +should change. Package-mediated mutation with `apply!` marks an attached +element dirty; other direct field mutation must be wrapped in `edit!` or +followed by `touch!`. + +```@example coordinate-workflow +close(dataset; discard=true) # hide +nothing # hide +``` diff --git a/docs/src/tutorials/custom_starmap_reader.md b/docs/src/tutorials/custom_starmap_reader.md new file mode 100644 index 0000000..3a8a4a6 --- /dev/null +++ b/docs/src/tutorials/custom_starmap_reader.md @@ -0,0 +1,250 @@ +# Build a custom STARmap reader + +```@setup starmap-figure +using SpatialOmics +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +STARmap export layouts are still evolving. This tutorial shows how to adapt one observed +STARmap PLUS layout without making that layout part of SpatialOmics' stable API. The same +pattern applies to an internal assay or a new vendor format: use a small format token, +validate assumptions at the boundary, and return ordinary SpatialOmics elements. + +The example layout contains: + +- `Dapi/configurations.registered.txt`, with `(x, y, z)` offsets for each tile; +- `Dapi/*_Tile_NNN_405.tif`, with one DAPI z-stack per tile; +- `Decoded_spots/Raw/fused_goodSpots.csv`, in canvas-relative coordinates; +- a filtered fused transcript CSV; and +- optional per-tile transcript CSVs in tile-local coordinates. + +Check these names and coordinate conventions against each new export. A reader should fail +clearly when its assumptions do not hold. + +## Define a format token + +Format tokens keep dispatch at the I/O boundary and avoid technology checks throughout an +analysis workflow. + +```julia +using SpatialOmics +using CSV, DataFrames +import Images: load + +struct STARmap end + +function parse_registration(path::AbstractString) + entries = Pair{String,NTuple{3,Float64}}[] + for line in eachline(path) + match_result = match(r"^(Tile_\d+\.tif);\s*;\s*\(([^)]+)\)", line) + isnothing(match_result) && continue + values = parse.(Float64, split(match_result[2], ",")) + length(values) == 3 || error("Expected three offsets in: $line") + push!(entries, match_result[1] => (values[1], values[2], values[3])) + end + isempty(entries) && error("No tile registrations found in $path") + entries +end +``` + +Natural errors from `CSV.read`, `load`, and constructors are useful here. Avoid a broad +`try`/`catch` that turns a malformed export into a partially populated dataset. + +## Preserve raw columns while defining a working coordinate system + +In this export, fused transcript coordinates start at the stitched canvas origin while +registered tile offsets may be negative. Working coordinates are shifted into the +registration frame, but the original columns are retained so an exporter can reconstruct +the vendor table. + +```julia +function transcript_points(table, x_offset, y_offset) + canvas_x = copy(table.x) + canvas_y = copy(table.y) + table.x .+= x_offset + table.y .+= y_offset + SpatialPoints( + table; + gene=:gene, + features=(z=table.z, canvas_x, canvas_y), + coord_system="global_px", + ) +end +``` + +Keeping raw columns is different from keeping a reference to the raw files. For lossless +recovery, record both. + +## Construct elements and acquisition sources + +The fused CSV does not identify which tile produced a transcript. Do not infer a unique +origin from position: a transcript can lie in two overlapping tile footprints. Footprints +can still carry exact source provenance, and each source can retain paths and registration +values needed to reopen the raw data. + +```julia +function Base.read(::STARmap, root::AbstractString) + registration_file = joinpath(root, "Dapi", "configurations.registered.txt") + registration = parse_registration(registration_file) + + dapi_files = filter( + path -> endswith(basename(path), "_405.tif"), + readdir(joinpath(root, "Dapi"); join=true), + ) + isempty(dapi_files) && error("No DAPI TIFFs found under $root") + tile_height, tile_width = size(load(first(dapi_files)))[1:2] + + x_offset = floor(Int, minimum(value[1] for (_, value) in registration)) + y_offset = floor(Int, minimum(value[2] for (_, value) in registration)) + raw_path = joinpath(root, "Decoded_spots", "Raw", "fused_goodSpots.csv") + filtered_path = joinpath( + root, + "Decoded_spots", + "Cr2_Cxcl13_clusters_removed_fused_goodSpots.csv", + ) + + dataset = SpatialDataset(metadata=Dict( + "technology" => "STARmap PLUS", + "source_root" => abspath(root), + "raw_transcripts" => relpath(raw_path, root), + "filtered_transcripts" => relpath(filtered_path, root), + )) + push!(dataset, CoordinateSystem("global_px"; units=("px", "px"))) + dataset["transcripts_raw"] = transcript_points( + CSV.read(raw_path, DataFrame), x_offset, y_offset, + ) + dataset["transcripts_filtered"] = transcript_points( + CSV.read(filtered_path, DataFrame), x_offset, y_offset, + ) + + tile_names = [splitext(name)[1] for (name, _) in registration] + footprints = [ + Polygon(Point2f[ + (x, y), (x + tile_width, y), + (x + tile_width, y + tile_height), (x, y + tile_height), (x, y), + ]) + for (_, (x, y, _)) in registration + ] + dataset["tile_footprints"] = SpatialShapes( + footprints; + instance_id=Int32.(eachindex(footprints)), + origins=tile_names, + coord_system="global_px", + ) + + for (index, ((registered_name, offset), tile_name)) in + enumerate(zip(registration, tile_names)) + tile_number = match(r"Tile_(\d+)", registered_name)[1] + dapi_path = only(filter( + path -> occursin("Tile_$(tile_number)_", basename(path)), dapi_files, + )) + push!(dataset, AcquisitionSource( + tile_name; + region="tile_footprints", + instance_id=index, + attributes=Dict( + "technology" => "STARmap PLUS", + "registration_name" => registered_name, + "offset_xyz_px" => collect(offset), + "dapi_path" => relpath(dapi_path, root), + "raw_spots_path" => joinpath( + "Decoded_spots", "Raw", "$(tile_name)_goodSpots.csv", + ), + ), + )) + end + dataset +end +``` + +Inspect the result through public accessors: + +```julia +dataset = read(STARmap(), "/path/to/export") +sources(dataset) +source_attributes(dataset, first(sources(dataset))) +features(points(dataset, "transcripts_raw"), :canvas_x) +``` + +A source view of `tile_footprints` is provenance-exact. A source view of the fused +transcript element emits a warning and uses the source footprint because the CSV did not +provide transcript origins. Reading the per-tile CSVs is the right extension when exact +tile-level transcript provenance is required. + +The geometry explains why position cannot recover a unique acquisition source: +transcripts in the overlap are compatible with both registered tile footprints. + +```@eval starmap-figure +tile_1 = Polygon(Point2f[(0, 0), (8, 0), (8, 7), (0, 7), (0, 0)]) +tile_2 = Polygon(Point2f[(5, 2), (13, 2), (13, 9), (5, 9), (5, 2)]) +fused_transcripts = Point2f[(2, 2), (6, 3), (7, 5), (10, 7), (12, 4)] +figure = Figure(size=(720, 430)) +axis = Axis(figure[1, 1]; aspect=DataAspect(), title="Registered STARmap tiles", + xlabel="global x (px)", ylabel="global y (px)") +poly!(axis, [tile_1]; color=(:dodgerblue, 0.2), strokecolor=:dodgerblue3, + strokewidth=2, label="Tile 1 footprint") +poly!(axis, [tile_2]; color=(:darkorange, 0.2), strokecolor=:darkorange3, + strokewidth=2, label="Tile 2 footprint") +scatter!(axis, fused_transcripts; color=:black, markersize=13, + label="fused transcript") +text!(axis, 6.6, 4.1; text="origin ambiguous", color=:purple, + align=(:center, :bottom), fontsize=13) +axislegend(axis; position=:rt, framevisible=false) +xlims!(axis, -0.5, 13.5); ylims!(axis, -0.5, 9.5) +save("starmap-overlap.svg", figure) +Markdown.parse("![Overlapping registered STARmap tiles](starmap-overlap.svg)") +``` + +## Keep registered images tiled + +A full bounding canvas wastes memory in gaps and silently needs a policy for overlapping +pixels. Keep each tile positioned instead: + +```julia +import SpatialOmics as SO + +function load_dapi_tiles(dataset, root) + names = sources(dataset) + tiles = SpatialImage[] + for name in names + attributes = source_attributes(dataset, name) + raw = load(joinpath(root, attributes["dapi_path"])) + projection = ndims(raw) == 3 ? + dropdims(maximum(Float32.(raw); dims=3); dims=3) : Float32.(raw) + x, y, _ = attributes["offset_xyz_px"] + push!(tiles, SpatialImage( + projection; + coord_system="global_px", + pixel_to_cs=SO.translation(x, y, "pixel", "global_px"), + )) + end + SpatialRasterTiles(tiles, names) +end +``` + +`image!(axis, scaleminmax(tiles))` plots all pieces in the shared coordinate system. +The current native writer does not persist a `SpatialRasterTiles` collection. Until an +explicit tiled-raster export is implemented, keep the TIFF paths and offsets as the +recoverable representation rather than silently writing a dense, last-write-wins mosaic. + +## Version the local reader + +Treat the code above as a reader for a specific observed layout. In production, record a +reader version in dataset metadata and add small fixture tests for: + +- required filenames and columns; +- coordinate conversion at tile corners; +- preservation of canvas coordinates and z values; +- source attributes and footprint IDs; +- overlapping tile behavior; and +- reconstruction of an equivalent vendor transcript table. + +When the STARmap export changes, add a new method or explicit layout option. Do not +silently reinterpret an old cache; rebuild it with the appropriate reader version. diff --git a/docs/src/tutorials/expression_summaries.md b/docs/src/tutorials/expression_summaries.md new file mode 100644 index 0000000..ca97047 --- /dev/null +++ b/docs/src/tutorials/expression_summaries.md @@ -0,0 +1,129 @@ +# Assign transcripts and summarise expression + +```@setup expression-summaries +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +Spatial assignment is a many-to-many spatial join. Unmatched transcripts are +absent from a membership relation, while a point contained by overlapping cell +objects can appear more than once. This makes the cardinality explicit instead +of silently choosing an object or inventing a sentinel ID. + +## Construct transcripts and cells + +```@example expression-summaries +using SpatialOmics + +square(xmin, xmax, ymin, ymax) = Polygon(Point2f[ + (xmin, ymin), (xmax, ymin), (xmax, ymax), + (xmin, ymax), (xmin, ymin), +]) + +transcripts = SpatialPoints( + [Point2f(1, 1), Point2f(2, 2), Point2f(7, 1), Point2f(20, 20)]; + feature_id=Int32[1, 2, 1, 2], + feature_codebook=["Actb", "Gapdh"], + coord_system="global_um", +) +cells = SpatialShapes( + [square(0, 4, 0, 4), square(6, 10, 0, 4)]; + instance_id=Int32[101, 102], + coord_system="global_um", +) +nothing +``` + +## Inspect transcript-to-cell membership + +For point membership, `source_ids` are one-based rows of the point collection; +`destination_ids` are cell `instance_id` values. The fourth transcript is +outside both cells and therefore has no relation row. + +```@example expression-summaries +membership = analyze(Membership(), transcripts, cells) +( + transcript_rows=source_ids(membership), + cell_ids=destination_ids(membership), + matched_fraction=length(unique(source_ids(membership))) / length(transcripts), +) +``` + +Do not compute coverage as `nobs(membership) / length(transcripts)` when cell +objects may overlap: one transcript can contribute multiple membership rows. +Multipolygon components belonging to the same cell are deduplicated. + +## Build a cell-by-gene matrix + +`Expression` uses the same indexed point-in-polygon join and accumulates a +cell-by-gene count matrix. Rows are selected by cell instance ID and columns by +feature name. + +```@example expression-summaries +expression = analyze(Expression(), transcripts, cells) +( + cell_ids=source_ids(expression), + genes=var_names(expression), + cell_101=expression[101, :], + actb=expression[:, "Actb"], +) +``` + +The spatial join and its matrix summary are two views of the same result. The +unmatched transcript remains visible spatially but contributes to no matrix +row. + +```@eval expression-summaries +figure = Figure(size=(850, 370)) +spatial_axis = Axis(figure[1, 1]; aspect=DataAspect(), title="Spatial membership", + xlabel="x (µm)", ylabel="y (µm)") +poly!(spatial_axis, geometries(cells); color=(:lightsteelblue, 0.4), + strokecolor=:steelblue, strokewidth=2) +gene_palette = [:darkorange, :seagreen] +for gene_id in eachindex(features(transcripts)) + mask = feature_ids(transcripts) .== gene_id + scatter!(spatial_axis, coords(transcripts)[mask]; color=gene_palette[gene_id], + markersize=15, label=features(transcripts)[gene_id]) +end +text!(spatial_axis, 2, 3.45; text="cell 101", align=(:center, :center)) +text!(spatial_axis, 8, 3.45; text="cell 102", align=(:center, :center)) +text!(spatial_axis, 21, 20.5; text="unmatched", align=(:right, :bottom), color=:gray35) +axislegend(spatial_axis; position=:lt, framevisible=false) +xlims!(spatial_axis, -1, 22); ylims!(spatial_axis, -1, 22) +matrix_axis = Axis(figure[1, 2]; title="Cell-by-gene counts", + xticks=(1:2, var_names(expression)), + yticks=(1:2, string.(source_ids(expression))), + xlabel="gene", ylabel="cell instance ID", yreversed=true) +matrix_values = Matrix(expression[source_ids(expression), var_names(expression)]) +heatmap!(matrix_axis, matrix_values; colormap=:Blues, colorrange=(0, maximum(matrix_values))) +for row in axes(matrix_values, 1), column in axes(matrix_values, 2) + text!(matrix_axis, column, row; text=string(matrix_values[row, column]), + align=(:center, :center), color=:black) +end +save("expression-summaries.svg", figure) +Markdown.parse("![Spatial memberships and cell-by-gene counts](expression-summaries.svg)") +``` + +The result is intentionally a lightweight relation rather than a full +single-cell analysis object. Use `annotate` for row metadata, then hand the +matrix and metadata explicitly to clustering, normalization, or dimensionality +reduction packages. + +```@example expression-summaries +count_matrix = expression[source_ids(expression), var_names(expression)] +size(count_matrix) +``` + +```@example expression-summaries +annotated = annotate(expression, ["left", "right"]; key=:region) +(nobs(annotated), nvar(annotated), obs_names(annotated)) +``` + +`obs_names` falls back to the cell instance IDs because this annotation used +the key `:region`; use `key=:name` when string row indexing is desired. diff --git a/docs/src/tutorials/index.md b/docs/src/tutorials/index.md new file mode 100644 index 0000000..6c3cfca --- /dev/null +++ b/docs/src/tutorials/index.md @@ -0,0 +1,43 @@ +# Tutorials + +The tutorials are arranged around analysis decisions rather than package +implementation milestones. Start with the small, executable lessons and then +move to the technology-specific case studies. + +## Core lessons + +The following tutorials use synthetic data and run as part of the normal +Documenter build: + +1. [Build a spatial dataset](@ref) +2. [Place an FOV in slide coordinates](@ref) +3. [Select acquisition sources and geometric ROIs](@ref) +4. [Assign transcripts and summarise expression](@ref) +5. [Persist a dataset safely](@ref) + +These examples are deliberately small, but exercise the same public API used +for full experiments. + +## Technology case studies + +The Xenium and Visium tutorials use public datasets and committed rendered +figures. Their full inputs are several gigabytes, so routine documentation +builds display the curated outputs without downloading or recomputing them. +Each page also identifies the small native fixture used by tests and gives the +code used to regenerate it. + +- [Xenium spatial transcriptomics](@ref) +- [Visium HD spatial transcriptomics](@ref) +- [Build a custom STARmap reader](@ref) + +The [CosMx workflow](@ref) is a format guide rather than a collaborator-data +tutorial. Public-data figures can use the same pre-rendered approach when a +small redistributable fixture is selected. + +This split keeps ordinary CI fast while keeping expensive examples +reproducible. Small synthetic and fixture-backed figures are rendered with +CairoMakie during the normal documentation build. Full-dataset figures should +record their public source dataset and the code used to produce the committed +asset; collaborator datasets are not documentation inputs. Dependencies used +only for full-dataset asset regeneration live in the separate `docs/heavy` +environment. diff --git a/docs/src/tutorials/persistence.md b/docs/src/tutorials/persistence.md new file mode 100644 index 0000000..57d7789 --- /dev/null +++ b/docs/src/tutorials/persistence.md @@ -0,0 +1,124 @@ +# Persist a dataset safely + +```@setup persistence +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +Every `SpatialDataset` has a backing store, but in-memory changes are staged +until `save!` is called. Closing a dirty dataset refuses to discard those +changes implicitly. + +## Save and reopen + +Use a persistent path when constructing the dataset or provide one to `save!`. + +```@example persistence +using SpatialOmics + +root = mktempdir() +path = joinpath(root, "example.zarr") +dataset = SpatialDataset(path=path) +push!(dataset, CoordinateSystem("global_um"; units=("µm", "µm"))) +dataset["transcripts"] = SpatialPoints( + [Point2f(1, 2), Point2f(3, 4)]; + coord_system="global_um", +) +dataset.metadata["sample"] = "example" + +before_save = isdirty(dataset) +save!(dataset) +after_save = isdirty(dataset) +close(dataset) + +reopened = read(SpatialDataZarr(), path) +result = ( + before_save=before_save, + after_save=after_save, + sample=reopened.metadata["sample"], + transcripts=length(points(reopened, "transcripts")), +) +close(reopened) +result +``` + +## Track edits explicitly + +Package-mediated operations mark attached elements dirty. For a direct edit, +use `edit!` so the dataset knows that the element must be saved. + +```@example persistence +dataset = read(SpatialDataZarr(), path) +edit!(dataset, "transcripts") do transcripts + coords(transcripts)[1] = Point2f(9, 9) +end + +changes = [(change.kind, change.name, change.state) for change in dirty(dataset)] +save!(dataset, "transcripts") +saved_coordinates = copy(coords(points(dataset, "transcripts"))) +(changes=changes, dirty_after_save=isdirty(dataset)) +``` + +`save!(dataset, "transcripts")` persists one named element. Other staged +changes, if any, remain dirty. `save!(dataset)` saves the complete pending +change set. + +## Discard deliberately + +`discard!` reloads saved state. `close(dataset; discard=true)` abandons all +remaining changes and is most useful for temporary exploratory datasets. + +```@example persistence +edit!(dataset, "transcripts") do transcripts + coords(transcripts)[1] = Point2f(99, 99) +end +staged_coordinates = copy(coords(points(dataset, "transcripts"))) +discard!(dataset, "transcripts") + +restored_coordinates = copy(coords(points(dataset, "transcripts"))) +restored = restored_coordinates[1] +close(dataset) +rm(root; recursive=true) +restored +``` + +`discard!` restores the saved element rather than silently retaining the staged +edit. All three panels use the same limits so the discarded displacement is +apparent. + +```@eval persistence +figure = Figure(size=(900, 310)) +states = (("saved", saved_coordinates, :seagreen), + ("staged edit", staged_coordinates, :darkorange), + ("after discard!", restored_coordinates, :steelblue)) +for (column, (title, coordinates, color)) in enumerate(states) + axis = Axis(figure[1, column]; aspect=DataAspect(), title, + xlabel="x", ylabel=column == 1 ? "y" : "") + scatter!(axis, coordinates[2:end]; color=:gray60, markersize=12) + scatter!(axis, coordinates[1:1]; color, markersize=16) + point = first(coordinates) + near_edge = point[1] > 90 || point[2] > 90 + offset = near_edge ? -3 : 3 + alignment = near_edge ? (:right, :top) : (:left, :bottom) + text!(axis, point[1] + offset, point[2] + offset; + text="($(Int(point[1])), $(Int(point[2])))", + align=alignment, fontsize=12) + xlims!(axis, 0, 105); ylims!(axis, 0, 105) +end +save("persistence-states.svg", figure) +Markdown.parse("![Saved, staged, and restored coordinates](persistence-states.svg)") +``` + +For temporary work, `with_dataset() do dataset ... end` guarantees cleanup. +Call `keep!` inside the block when the result should become persistent. + +Native stores carry an explicit format version. An unversioned or unsupported +store is rejected with a rebuild instruction; SpatialOmics does not silently +upgrade it. Inspect a store with `native_store_version(path)` before opening it +when its origin is uncertain. diff --git a/docs/src/tutorials/source_roi_selection.md b/docs/src/tutorials/source_roi_selection.md new file mode 100644 index 0000000..16776d8 --- /dev/null +++ b/docs/src/tutorials/source_roi_selection.md @@ -0,0 +1,118 @@ +# Select acquisition sources and geometric ROIs + +```@setup source-roi-selection +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +Acquisition identity and geometric containment answer different questions. A +transcript acquired in one FOV does not become an observation from another FOV +merely because their footprints overlap. + +## Build two overlapping sources + +```@example source-roi-selection +using SpatialOmics + +square(xmin, xmax, ymin, ymax) = Polygon(Point2f[ + (xmin, ymin), (xmax, ymin), (xmax, ymax), + (xmin, ymax), (xmin, ymin), +]) + +dataset = SpatialDataset() +push!(dataset, CoordinateSystem("global_um"; units=("µm", "µm"))) +dataset["fov_footprints"] = SpatialShapes( + [square(0, 10, 0, 10), square(5, 15, 0, 10)]; + instance_id=Int32[1, 2], + origins=["fov_1", "fov_2"], + coord_system="global_um", +) +push!(dataset, AcquisitionSource("fov_1"; region="fov_footprints", instance_id=1)) +push!(dataset, AcquisitionSource("fov_2"; region="fov_footprints", instance_id=2)) + +dataset["transcripts"] = SpatialPoints( + [Point2f(7, 5), Point2f(7, 5), Point2f(12, 5)]; + origins=["fov_1", "fov_2", "fov_2"], + coord_system="global_um", +) +nothing +``` + +The first two transcripts have identical coordinates in the overlap but +different acquisition origins. + +## Select exact sources + +Index-like source selection returns exactly the requested sources. Selecting +FOVs 1 and 5 would not imply the rectangular region between them. + +```@example source-roi-selection +fov_2_transcripts = points(view(dataset, "fov_2"), "transcripts") +( + coordinates=coords(fov_2_transcripts), + sources=[source(fov_2_transcripts, i) for i in 1:length(fov_2_transcripts)], + parent_rows=only(parentindices(fov_2_transcripts)), +) +``` + +```@example source-roi-selection +both = points(view(dataset, ["fov_1", "fov_2"]), "transcripts") +(length(both), [source(both, i) for i in 1:length(both)]) +``` + +For images and labels, multi-source selection returns +[`SpatialRasterTiles`](@ref): positioned crops with no dense allocation for the +space between distant sources. + +## Select by geometry + +A geometric ROI ignores acquisition identity and includes every observation +whose current coordinates satisfy the query. + +```@example source-roi-selection +overlap = SpatialExtent(6, 8, 4, 6; coord_system="global_um") +overlap_transcripts = points(view(dataset, overlap), "transcripts") +( + length(overlap_transcripts), + [source(overlap_transcripts, i) for i in 1:length(overlap_transcripts)], +) +``` + +The plot makes the distinction visible: source membership is attached to each +observation, while the ROI is a geometric query over the overlap. + +```@eval source-roi-selection +figure = Figure(size=(760, 390)) +axis = Axis(figure[1, 1]; aspect=DataAspect(), xlabel="x (µm)", ylabel="y (µm)") +footprints = geometries(shapes(dataset, "fov_footprints")) +poly!(axis, [footprints[1]]; color=(:dodgerblue, 0.18), + strokecolor=:dodgerblue3, strokewidth=2, label="FOV 1 footprint") +poly!(axis, [footprints[2]]; color=(:darkorange, 0.18), + strokecolor=:darkorange3, strokewidth=2, label="FOV 2 footprint") +scatter!(axis, [Point2f(7, 5)]; color=:dodgerblue3, marker=:circle, + markersize=18, label="transcript from FOV 1") +scatter!(axis, [Point2f(7, 5), Point2f(12, 5)]; color=:darkorange3, + marker=:xcross, markersize=20, label="transcript from FOV 2") +poly!(axis, [Rect2f(6, 4, 2, 2)]; color=(:purple, 0.08), + strokecolor=:purple, strokewidth=3, linestyle=:dash, label="geometric ROI") +axislegend(axis; position=:rt, framevisible=false, labelsize=12) +xlims!(axis, -0.5, 15.5); ylims!(axis, -0.5, 10.5) +save("source-roi-selection.svg", figure) +Markdown.parse("![Acquisition footprints and geometric ROI](source-roi-selection.svg)") +``` + +Use source selection to answer “what did this acquisition produce?” and an ROI +to answer “what is currently located here?” If an older vector element has no +origin metadata, dataset-level source selection warns and falls back to the +registered footprint; it never silently claims exact provenance. + +```@example source-roi-selection +close(dataset; discard=true) # hide +nothing # hide +``` diff --git a/docs/src/tutorials/visium.md b/docs/src/tutorials/visium.md index 5a954f1..daaa77a 100644 --- a/docs/src/tutorials/visium.md +++ b/docs/src/tutorials/visium.md @@ -3,13 +3,16 @@ !!! note "Pre-rendered tutorial" This tutorial uses a full Visium HD dataset (~2.4 GB). The code is **not run automatically** — images below are pre-rendered and committed to the - repository. To reproduce them locally, download the dataset and run + repository. To reproduce them locally, download the Visium and Xenium + datasets and run `test/make_fixtures.jl` as described in the [Creating a subset](#creating-a-subset) section. + The final fixture panel is built during the normal documentation build. ## About the dataset -The example dataset is the **10x Genomics Visium HD Mouse Small Intestine**, -distributed by the [SpatialData project](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/datasets/) +The example dataset is the [**10x Genomics Visium HD Mouse Small +Intestine**](https://www.10xgenomics.com/datasets/visium-hd-cytassist-gene-expression-libraries-of-mouse-intestine), +distributed in a converted form by the [SpatialData project](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/datasets/) as a Python-compatible OME-Zarr store. It contains: - Square bin shapes at three resolutions: 2 µm, 8 µm, and 16 µm @@ -67,7 +70,7 @@ fig = Figure(size=(900, 900)) ax = Axis(fig[1, 1]; aspect=DataAspect(), yreversed=true, title="Visium HD — 16µm bins (subsampled)") poly!(ax, SpatialShapes(geometries(shp)[idx]; instance_id=instance_id(shp)[idx], - coord_system=shp.coord_system); + coord_system=coord_system(shp)); color=:steelblue, strokewidth=0) tightlimits!(ax) fig @@ -96,8 +99,14 @@ fig ## Creating a subset The fixture at `test/data/visium_small.zarr` covers a patch of the small -intestine at 16 µm bin resolution. Generate it with `test/make_fixtures.jl` -(two-pass, same workflow as the Xenium tutorial): +intestine at 16 µm bin resolution. Generate it with `test/make_fixtures.jl`; +inspect the overview and update the region constants before rerunning when a +different patch is needed: + +```bash +julia --project=docs/heavy -e 'using Pkg; Pkg.instantiate()' # first use +julia --project=docs/heavy test/make_fixtures.jl /path/to/xenium_ex.zarr /path/to/visium_ex.zarr +``` ```julia # Inspect visium_overview.png to pick a region, then fill in coordinates: @@ -110,17 +119,46 @@ sub["Visium_HD_Mouse_Small_Intestine_square_016um"] = sub["Visium_HD_Mouse_Small_Intestine_lowres_image"] = images(roi, "Visium_HD_Mouse_Small_Intestine_lowres_image") -write!(sub, "test/data/visium_small.zarr", SpatialDataZarr()) +save!(sub; path="test/data/visium_small.zarr") ``` ## Working with the committed fixture -```julia +```@setup visium-fixture +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +```@example visium-fixture +using SpatialOmics + ds = read(SpatialDataZarr(), joinpath(pkgdir(SpatialOmics), "test", "data", "visium_small.zarr")) shp = shapes(ds, "Visium_HD_Mouse_Small_Intestine_square_016um") img = images(ds, "Visium_HD_Mouse_Small_Intestine_lowres_image") -@show length(geometries(shp)) -@show size(data(img)) +(bins=length(shp), image_size=size(data(img))) +``` + +The low-resolution tissue image and 16 µm bins are positioned by their stored +coordinate transforms, so the overlay is rebuilt without hard-coded plot +extents. + +```@eval visium-fixture +figure = Figure(size=(650, 560)) +axis = Axis(figure[1, 1]; aspect=DataAspect(), yreversed=true, + title="Visium HD fixture", xlabel="x", ylabel="y") +image!(axis, scaleminmax(channel(img, 1))) +poly!(axis, shp; color=(:steelblue, 0.3), strokecolor=(:white, 0.5), + strokewidth=0.35) +tightlimits!(axis) +save("visium-fixture.svg", figure) +close(ds; discard=true) +Markdown.parse("![Visium tissue image and spatial bins](visium-fixture.svg)") ``` diff --git a/docs/src/tutorials/xenium.md b/docs/src/tutorials/xenium.md index a741e87..9761651 100644 --- a/docs/src/tutorials/xenium.md +++ b/docs/src/tutorials/xenium.md @@ -3,13 +3,16 @@ !!! note "Pre-rendered tutorial" This tutorial uses a full Xenium dataset (~5 GB). The code is **not run automatically** — images below are pre-rendered and committed to the - repository. To reproduce them locally, download the dataset and run + repository. To reproduce them locally, download the Xenium and Visium + datasets and run `test/make_fixtures.jl` as described in the [Creating a subset](#creating-a-subset) section. + The final fixture panel is built during the normal documentation build. ## About the dataset -The example dataset is the **10x Genomics Xenium Mouse Brain Coronal Section**, -distributed by the [SpatialData project](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/datasets/) +The example dataset is the [**10x Genomics Xenium FFPE Human Lung Cancer with +multimodal cell segmentation**](https://www.10xgenomics.com/datasets/preview-data-ffpe-human-lung-cancer-with-xenium-multimodal-cell-segmentation-1-standard), +distributed in a converted form by the [SpatialData project](https://spatialdata.scverse.org/en/stable/tutorials/notebooks/datasets/) as a Python-compatible OME-Zarr store. It contains: - ~5 million transcripts (`points/transcripts`) @@ -99,21 +102,24 @@ fig The committed test fixture at `test/data/xenium_small.zarr` was created from a 200 µm × 200 µm region of this dataset. The `test/make_fixtures.jl` script -automates this in two passes: +writes overview figures, native fixtures, and ROI figures. -**Pass 1 — generate overview figure and pick coordinates:** +Run it once, inspect the overview figures, and update the region constants in +the script if a different patch is needed: ```julia -# Run: julia --project=. test/make_fixtures.jl -# Inspect docs/src/assets/xenium_overview.png -# Fill in XEN_XMIN / XEN_XMAX / XEN_YMIN / XEN_YMAX in the script +# Run from the repository root: +# julia --project=docs/heavy -e 'using Pkg; Pkg.instantiate()' # first use +# julia --project=docs/heavy test/make_fixtures.jl /path/to/xenium_ex.zarr /path/to/visium_ex.zarr +# Inspect docs/src/assets/xenium_overview.png, then update +# XEN_XMIN / XEN_XMAX / XEN_YMIN / XEN_YMAX if needed. ``` -**Pass 2 — create the fixture:** +Rerun the same command after changing the region: ```julia # After filling in coordinates, re-run the script: -# julia --project=. test/make_fixtures.jl +# julia --project=docs/heavy test/make_fixtures.jl /path/to/xenium_ex.zarr /path/to/visium_ex.zarr # This writes test/data/xenium_small.zarr and docs/src/assets/xenium_roi.png ``` @@ -128,25 +134,56 @@ sub["transcripts"] = collect(points(roi, "transcripts")) sub["cell_boundaries"] = collect(shapes(roi, "cell_boundaries")) sub["morphology_focus"] = images(roi, "morphology_focus") -write!(sub, "test/data/xenium_small.zarr", SpatialDataZarr()) +save!(sub; path="test/data/xenium_small.zarr") ``` -`view` is lazy — no data is read until `collect` or the plot verb materialises -it. The resulting zarr is in SpatialOmics' native format and is loaded directly +`view` is lazy — constructing it does not copy its elements; accessors, +`collect`, and plot verbs materialise the selected data as needed. The +resulting zarr is in SpatialOmics' native format and is loaded directly by `read(SpatialDataZarr(), path)` without the full dataset. ## Working with the committed fixture The fixture is small enough to use in offline development and CI: -```julia +```@setup xenium-fixture +using CairoMakie +using Markdown +CairoMakie.activate!(type="svg") +set_theme!(Theme( + fontsize=15, + Figure=(; backgroundcolor=:white), + Axis=(; xgridvisible=false, ygridvisible=false), +)) +``` + +```@example xenium-fixture +using SpatialOmics + ds = read(SpatialDataZarr(), joinpath(pkgdir(SpatialOmics), "test", "data", "xenium_small.zarr")) tx = points(ds, "transcripts") shp = shapes(ds, "cell_boundaries") img = images(ds, "morphology_focus") -@show length(coords(tx)) -@show nchannels(img), channel_names(img) -@show top_features(tx, 5) +(transcripts=length(tx), cells=length(shp), + channels=channel_names(img), top_features=top_features(tx, 5)) +``` + +This executable panel verifies that image placement, segmentation boundaries, +and transcript coordinates remain registered in the committed fixture. + +```@eval xenium-fixture +figure = Figure(size=(650, 560)) +axis = Axis(figure[1, 1]; aspect=DataAspect(), yreversed=true, + title="Xenium fixture", xlabel="x", ylabel="y") +image!(axis, scaleminmax(channel(img, 1))) +reset_limits!(axis) +image_limits = axis.finallimits[] +poly!(axis, shp; color=:transparent, strokecolor=:cyan, strokewidth=1.2) +scatter!(axis, tx; color=(:red, 0.45), markersize=3) +limits!(axis, image_limits) +save("xenium-fixture.svg", figure) +close(ds; discard=true) +Markdown.parse("![Xenium image, cells, and transcripts](xenium-fixture.svg)") ``` diff --git a/ext/MakieExt.jl b/ext/MakieExt.jl index b8db6e1..8c61530 100644 --- a/ext/MakieExt.jl +++ b/ext/MakieExt.jl @@ -81,6 +81,15 @@ end Makie.convert_arguments(P::Type{<:Image}, img::SpatialImage) = convert_arguments(P, colorview(Gray, img)) +function Makie.image!(ax::Makie.Axis, tiles::SpatialRasterTiles{<:SpatialImage}; kw...) + [Makie.image!(ax, tile; kw...) for tile in tiles] +end + +function Makie.image!(ax::Makie.Axis, + tiles::SpatialRasterTiles{<:SpatialImageColorView}; kw...) + [Makie.image!(ax, tile; kw...) for tile in tiles] +end + # ── SpatialImage/SpatialImageColorView → Image (zoom-responsive) ───────────── # For images with pyramid levels, image!(ax, ...) pushes a new Observable value # when the axis zoom changes, selecting the pyramid level whose full-res/screen-px diff --git a/src/SpatialOmics.jl b/src/SpatialOmics.jl index 31a359b..c5fb594 100644 --- a/src/SpatialOmics.jl +++ b/src/SpatialOmics.jl @@ -1,6 +1,6 @@ module SpatialOmics -using GeometryBasics: Point, Point2f, Polygon, AbstractGeometry +using GeometryBasics: Point, Point2f, Polygon, MultiPolygon, AbstractGeometry using GeoInterface using Tables using StaticArrays @@ -31,34 +31,34 @@ export # Use SpatialOmics.translation(...) etc. when constructing pixel_to_cs transforms. apply, apply!, resolve, # Dataset - BackingStore, SpatialDataset, - elements, coord_systems, transform, - with_dataset, keep!, + BackingStore, SpatialDataset, AcquisitionSource, + elements, coord_systems, transform, sources, source, source_attributes, + with_dataset, keep!, save!, discard!, edit!, touch!, isdirty, dirty, # Elements SpatialPoints, SpatialShapes, SpatialShape, - Polygon, Point2f, + Polygon, MultiPolygon, Point2f, points, shapes, - coords, features, feature_ids, coord_system, - geometries, instance_id, instance_ids, + coords, features, feature_ids, origins, origin_ids, coord_system, + geometries, instance_id, instance_ids, with_instance_ids, subsample, top_features, count_per_instance, # Views SpatialExtent, SpatialROI, SpatialElementView, SpatialDatasetView, geometry, roi, roi!, # Images + Labels - SpatialImage, SpatialLabels, + SpatialImage, SpatialLabels, SpatialRasterTiles, data, nchannels, channel_names, build_pyramid!, ensure_pyramid!, images, labels, SpatialImageColorView, channel, scaleminmax, colorview, Gray, RGB, # Relations RelationKind, Membership, Expression, SpatialRelation, - relations, nobs, nvar, obs_names, var_names, + relations, source_ids, destination_ids, nobs, nvar, obs_names, var_names, annotate, # Analysis analyze, distances, PointDensity, density, ShapeColorView, # I/O - SpatialDataZarr, write!, + SpatialDataZarr, native_store_version, write!, CosMx include("coordsystems.jl") diff --git a/src/analysis.jl b/src/analysis.jl index af10321..5cb9eb9 100644 --- a/src/analysis.jl +++ b/src/analysis.jl @@ -4,6 +4,27 @@ _element_name(el::Union{SpatialPoints, SpatialShapes}) = (att = _dataset_ref(el); att === nothing ? "" : att[2]) _element_name(::Any) = "" +# FlexiJoins' spatial-tree mode needs both row indexing and direct column +# properties. A plain Tables.rowtable supplies only the former. +struct _SpatialJoinTable{C,T} <: AbstractVector{T} + columns::C +end + +function _SpatialJoinTable(columns::NamedTuple) + row_type = NamedTuple{keys(columns),Tuple{map(eltype, values(columns))...}} + _SpatialJoinTable{typeof(columns),row_type}(columns) +end + +Base.IndexStyle(::Type{<:_SpatialJoinTable}) = IndexLinear() +Base.size(table::_SpatialJoinTable) = (length(first(values(table.columns))),) +function Base.getindex(table::_SpatialJoinTable, index::Int) + NamedTuple{keys(table.columns)}(map(column -> column[index], values(table.columns))) +end +function Base.getproperty(table::_SpatialJoinTable, name::Symbol) + name === :columns && return getfield(table, :columns) + getproperty(getfield(table, :columns), name) +end + # ── Default dispatch — token inferred from argument types ───────────────────── """ @@ -16,9 +37,9 @@ Compute a spatial relation between elements. Dispatch on the `RelationKind` token selects the algorithm: - `analyze(Expression(), pts, cells)` — count transcripts per gene per cell. - Each transcript is assigned to the first containing cell (bounding-box - pre-filter, then exact point-in-polygon). Returns an n_cells × n_genes - count matrix. + Each transcript contributes to every containing cell (bounding-box + pre-filter, then exact point-in-polygon), so overlapping cell objects retain + many-to-many membership. Returns an n_cells × n_genes count matrix. - `analyze(Membership(), src, dst)` — assign each point or shape in `src` to the containing shape in `dst`. `strict=true` requires full containment; default uses centroid or point containment. @@ -62,9 +83,9 @@ function analyze(src::SpatialShapes, dst::SpatialShapes, obs; dst_pos = Dict{Int32,Int}(id => i for (i, id) in enumerate(dst.instance_id)) src_geoms = GeometryOps.centroid.(src.geometries) - src_tbl = Tables.rowtable((geom=src_geoms, label=labels, - src_instance_id=src.instance_id)) - dst_tbl = Tables.rowtable((poly=dst.geometries, dst_instance_id=dst.instance_id)) + src_tbl = _SpatialJoinTable((geom=src_geoms, label=labels, + src_instance_id=src.instance_id)) + dst_tbl = _SpatialJoinTable((poly=dst.geometries, dst_instance_id=dst.instance_id)) for row in innerjoin((src_tbl, dst_tbl), by_pred(:geom, predicate, :poly)) lpos = get(label_pos, row[1].label, 0) @@ -92,23 +113,49 @@ function analyze(::Expression, pts::SpatialPoints, cells::SpatialShapes; weights = zeros(Float32, n_cells, n_genes) cell_pos = Dict{Int32,Int}(id => i for (i, id) in enumerate(cells.instance_id)) - # FlexiJoins expects row-iterable tables; result rows are Tuple{src_row, dst_row}. - # Points go first (simpler geoms), cells second (tree-indexed by FlexiJoins). - pts_tbl = Tables.rowtable((pt=pts.coords, feature_id=pts.feature_id)) - cells_tbl = Tables.rowtable((poly=cells.geometries, instance_id=cells.instance_id)) + # Points go first (simpler geometries), cells second (tree-indexed). + pts_tbl = _SpatialJoinTable(( + pt=pts.coords, + feature_id=pts.feature_id, + point_index=Int32.(eachindex(pts.coords)), + )) + destination_geometries, destination_ids, deduplicate = _point_join_destinations(cells) + cells_tbl = _SpatialJoinTable((poly=destination_geometries, instance_id=destination_ids)) - for row in innerjoin((pts_tbl, cells_tbl), by_pred(:pt, predicate, :poly)) + joined = innerjoin((pts_tbl, cells_tbl), by_pred(:pt, predicate, :poly)) + _accumulate_expression!(weights, joined, cell_pos, deduplicate) + + var_nt = isempty(pts.feature_codebook) ? NamedTuple() : + NamedTuple{(:name,)}((pts.feature_codebook,)) + SpatialRelation(Expression(), _element_name(cells), + cells.instance_id, weights; var=var_nt) +end + +function _accumulate_expression!(weights, joined, cell_pos, ::Val{false}) + for row in joined gid = row[1].feature_id gid == 0 && continue cpos = get(cell_pos, row[2].instance_id, 0) cpos == 0 && continue weights[cpos, gid] += 1f0 end + weights +end - var_nt = isempty(pts.feature_codebook) ? NamedTuple() : - NamedTuple{(:name,)}((pts.feature_codebook,)) - SpatialRelation(Expression(), _element_name(cells), - cells.instance_id, weights; var=var_nt) +function _accumulate_expression!(weights, joined, cell_pos, ::Val{true}) + seen = Set{Tuple{Int32,Int32}}() + for row in joined + gid = row[1].feature_id + gid == 0 && continue + destination_id = row[2].instance_id + key = (row[1].point_index, destination_id) + key in seen && continue + push!(seen, key) + cpos = get(cell_pos, destination_id, 0) + cpos == 0 && continue + weights[cpos, gid] += 1f0 + end + weights end # ── Membership — assign each source point/shape to a containing destination ─── @@ -118,25 +165,59 @@ end function analyze(::Membership{strict}, pts::SpatialPoints, dst::SpatialShapes; predicate=GeometryOps.within) where strict - pts_tbl = Tables.rowtable((pt=pts.coords, pos=Int32.(eachindex(pts.coords)))) - dst_tbl = Tables.rowtable((poly=dst.geometries, dst_instance_id=dst.instance_id)) + positions = Int32.(eachindex(pts.coords)) + pts_tbl = _SpatialJoinTable((pt=pts.coords, pos=positions)) + destination_geometries, destination_ids, deduplicate = _point_join_destinations(dst) + dst_tbl = _SpatialJoinTable((poly=destination_geometries, dst_instance_id=destination_ids)) joined = collect(innerjoin((pts_tbl, dst_tbl), by_pred(:pt, predicate, :poly))) - src_ids = Int32[row[1].pos for row in joined] - dst_ids = Int32[row[2].dst_instance_id for row in joined] + src_ids, dst_ids = _membership_ids(joined, deduplicate) SpatialRelation(Membership{strict}(), _element_name(pts), _element_name(dst), src_ids, dst_ids, nothing) end +function _point_join_destinations(dst::SpatialShapes) + dst.geometries, dst.instance_id, Val(false) +end + +function _point_join_destinations(dst::SpatialShapes{<:MultiPolygon}) + components = [polygon for multi in dst.geometries for polygon in GeoInterface.getgeom(multi)] + ids = Int32[ + dst.instance_id[i] + for i in eachindex(dst.geometries) + for _ in 1:GeoInterface.ngeom(dst.geometries[i]) + ] + components, ids, Val(true) +end + +function _membership_ids(joined, ::Val{false}) + Int32[row[1].pos for row in joined], + Int32[row[2].dst_instance_id for row in joined] +end + +function _membership_ids(joined, ::Val{true}) + pairs = Set{Tuple{Int32,Int32}}() + src_ids = Int32[] + dst_ids = Int32[] + for row in joined + pair = (row[1].pos, row[2].dst_instance_id) + pair in pairs && continue + push!(pairs, pair) + push!(src_ids, pair[1]) + push!(dst_ids, pair[2]) + end + src_ids, dst_ids +end + function analyze(::Membership{strict}, src::SpatialShapes, dst::SpatialShapes; predicate=nothing) where strict # strict=false: centroid-in-polygon; strict=true: full shape containment. src_geoms = strict ? src.geometries : GeometryOps.centroid.(src.geometries) pred = isnothing(predicate) ? GeometryOps.within : predicate - src_tbl = Tables.rowtable((geom=src_geoms, src_instance_id=src.instance_id)) - dst_tbl = Tables.rowtable((poly=dst.geometries, dst_instance_id=dst.instance_id)) + src_tbl = _SpatialJoinTable((geom=src_geoms, src_instance_id=src.instance_id)) + dst_tbl = _SpatialJoinTable((poly=dst.geometries, dst_instance_id=dst.instance_id)) joined = collect(innerjoin((src_tbl, dst_tbl), by_pred(:geom, pred, :poly))) src_ids = Int32[row[1].src_instance_id for row in joined] @@ -159,7 +240,7 @@ signed distance. Returns a `Float32` vector of length `length(shapes_a)`. # See also -[`analyze`](@ref), [`Proximity`](@ref) +[`analyze`](@ref) """ function distances(shapes_a::SpatialShapes, shapes_b::SpatialShapes) :: Vector{Float32} [Float32(minimum(GeometryOps.distance(GeometryOps.centroid(g_a), g_b) diff --git a/src/dataset.jl b/src/dataset.jl index 9f346bd..bd8a711 100644 --- a/src/dataset.jl +++ b/src/dataset.jl @@ -18,6 +18,8 @@ mutable struct BackingStore path :: String owned :: Bool handles :: Dict{String, Any} # element name → open zarr group handle + changes :: OrderedDict{Tuple{Symbol, String}, Symbol} + closed :: Bool end function BackingStore(; path=nothing) @@ -30,7 +32,13 @@ function BackingStore(; path=nothing) owned = false end _init_zarr_root(p) - BackingStore(p, owned, Dict{String,Any}()) + BackingStore( + p, + owned, + Dict{String,Any}(), + OrderedDict{Tuple{Symbol,String},Symbol}(), + false, + ) end function _init_zarr_root(path::String) @@ -39,52 +47,103 @@ function _init_zarr_root(path::String) open(zarr_json, "w") do io write(io, """{"zarr_format":3,"node_type":"group","attributes":{"spatialdata_attrs":{"version":"0.2.0"}}}""") end - # Write spatialomics_meta.json so new stores are not mistaken for Python SpatialData format + # Native metadata distinguishes this layout from Python SpatialData stores. open(joinpath(path, "spatialomics_meta.json"), "w") do io - write(io, """{"coord_systems":[]}""") + JSON.print(io, Dict( + "format_version" => NATIVE_FORMAT_VERSION, + "coord_systems" => Any[], + "transforms" => Any[], + "sources" => Any[], + )) end end function _cleanup!(bs::BackingStore) bs.owned || return close.(values(bs.handles)) + empty!(bs.handles) rm(bs.path; recursive=true, force=true) end -# ── Dataset ─────────────────────────────────────────────────────────────────── +function _ensure_open(bs::BackingStore) + bs.closed && throw(ArgumentError("the dataset is closed")) + nothing +end -""" - SpatialDataset(; path=nothing, metadata=Dict()) +function _artifact_exists(bs::BackingStore, key::Tuple{Symbol,String}) + kind, name = key + kind === :element && return any( + isdir(joinpath(bs.path, group, name)) for group in ("points", "shapes", "images", "labels") + ) + kind === :relation && return isdir(joinpath(bs.path, "relations", name)) + kind === :metadata && return isdir(joinpath(bs.path, "metadata", name)) + kind === :dataset && return isfile(joinpath(bs.path, "spatialomics_meta.json")) + false +end -Root container for a spatial omics experiment. +function _mark_dirty!(bs::BackingStore, key::Tuple{Symbol,String}; deleted::Bool=false) + _ensure_open(bs) + current = get(bs.changes, key, nothing) + if deleted + if current === :new + delete!(bs.changes, key) + elseif current !== :deleted + bs.changes[key] = :deleted + end + elseif current !== :new + bs.changes[key] = _artifact_exists(bs, key) ? :modified : :new + end + nothing +end -Holds named collections of spatial elements (`SpatialPoints`, `SpatialShapes`, -`SpatialImage`, `SpatialLabels`), a graph of `CoordinateSystem` nodes connected -by `AbstractTransformation` edges, named `SpatialRelation` objects, and free-form -metadata. Follows the [SpatialData specification](https://spatialdata.scverse.org/). +# ── Dataset ─────────────────────────────────────────────────────────────────── -All data is backed by a `BackingStore` Zarr directory. When `path` is `nothing`, -a temporary directory is used and cleaned up automatically. Supply `path` to -write directly to a persistent location. Every `setindex!` call writes the -element to disk immediately — the dataset is always on disk. +""" + AcquisitionSource(name; region=nothing, instance_id=nothing, attributes=Dict()) -```julia -ds = SpatialDataset() # temp-backed -ds = SpatialDataset(path="/data/exp.zarr") # persistent-backed -``` +A named acquisition unit such as a field of view, imaging tile, or tissue +section. `region` and `instance_id` may identify its footprint in a +`SpatialShapes` element. Observations record the source name independently of +their coordinates, so source selection remains distinct from geometric ROI +selection in overlapping acquisitions. `attributes` retains structured +technology-specific identity needed for lossless export, such as a vendor FOV +number. # See also -[`BackingStore`](@ref), [`with_dataset`](@ref), [`keep!`](@ref), -[`elements`](@ref), [`coord_systems`](@ref), [`relations`](@ref) +[`sources`](@ref), [`source`](@ref), [`SpatialDatasetView`](@ref) """ +struct AcquisitionSource + name :: String + region_element :: Union{Nothing,String} + region_id :: Union{Nothing,Int32} + attributes :: Dict{String,Any} +end + +function AcquisitionSource(name::AbstractString; + region::Union{Nothing,AbstractString}=nothing, + instance_id::Union{Nothing,Integer}=nothing, + attributes::AbstractDict=Dict{String,Any}()) + (region === nothing) == (instance_id === nothing) || throw(ArgumentError( + "region and instance_id must either both be supplied or both be omitted", + )) + AcquisitionSource( + String(name), + region === nothing ? nothing : String(region), + instance_id === nothing ? nothing : Int32(instance_id), + deepcopy(Dict{String,Any}( + string(key) => value for (key, value) in pairs(attributes) + )), + ) +end + # ── Backed metadata dict ────────────────────────────────────────────────────── """ BackedMetadata -Dict-like container for dataset metadata that writes each entry to the backing -store immediately on assignment, keeping disk and memory in sync. Accessed -as `ds.metadata`. +Dict-like container that tracks metadata changes in its parent dataset. +Accessed as `ds.metadata`; changes become durable when [`save!`](@ref) is +called. String and integer keys are converted to `String` automatically. """ @@ -95,28 +154,59 @@ end function Base.setindex!(bm::BackedMetadata, val, key::String) bm.data[key] = val - _write_metadata_entry(bm.backing.path, key, val) # defined in zarr_io.jl + _mark_dirty!(bm.backing, (:metadata, key)) bm end +Base.setindex!(bm::BackedMetadata, val, key::Union{AbstractString,Integer}) = + setindex!(bm, val, string(key)) function Base.delete!(bm::BackedMetadata, key::String) + haskey(bm.data, key) || throw(KeyError(key)) delete!(bm.data, key) - p = joinpath(bm.backing.path, "metadata", key) - isdir(p) && rm(p; recursive=true, force=true) + _mark_dirty!(bm.backing, (:metadata, key); deleted=true) bm end +Base.delete!(bm::BackedMetadata, key::Union{AbstractString,Integer}) = delete!(bm, string(key)) Base.getindex(bm::BackedMetadata, key::String) = bm.data[key] +Base.getindex(bm::BackedMetadata, key::Union{AbstractString,Integer}) = bm[string(key)] +Base.haskey(bm::BackedMetadata, key::Union{AbstractString,Integer}) = haskey(bm.data, string(key)) Base.iterate(bm::BackedMetadata) = iterate(bm.data) Base.iterate(bm::BackedMetadata, state) = iterate(bm.data, state) Base.length(bm::BackedMetadata) = length(bm.data) # ── Dataset ─────────────────────────────────────────────────────────────────── +""" + SpatialDataset(; path=nothing, metadata=Dict()) + +Root container for a spatial omics experiment. + +Holds named collections of spatial elements (`SpatialPoints`, `SpatialShapes`, +`SpatialImage`, `SpatialLabels`), a graph of `CoordinateSystem` nodes connected +by `AbstractTransformation` edges, named `SpatialRelation` objects, and free-form +metadata. Its native Zarr layout is versioned by SpatialOmics; external +SpatialData stores are handled as an import boundary. + +Every dataset has a `BackingStore` Zarr directory. When `path` is `nothing`, a +temporary directory is used and cleaned up automatically. Mutations are staged +in memory and reported by [`dirty`](@ref); call [`save!`](@ref) to make them +durable. Closing a dirty dataset requires an explicit save or discard. + +```julia +ds = SpatialDataset() # temp-backed +ds = SpatialDataset(path="/data/exp.zarr") # persistent-backed +``` + +# See also +[`BackingStore`](@ref), [`with_dataset`](@ref), [`keep!`](@ref), +[`elements`](@ref), [`coord_systems`](@ref), [`relations`](@ref) +""" mutable struct SpatialDataset elements :: OrderedDict{String, Any} coord_systems :: OrderedDict{String, CoordinateSystem} transforms :: Vector{AbstractTransformation} + sources :: OrderedDict{String, AcquisitionSource} backing :: BackingStore relations :: Dict{String, Any} # name → SpatialRelation metadata :: BackedMetadata @@ -128,42 +218,121 @@ function SpatialDataset(; path=nothing, metadata=Dict{String,Any}()) OrderedDict{String,Any}(), OrderedDict{String,CoordinateSystem}(), AbstractTransformation[], + OrderedDict{String,AcquisitionSource}(), bs, Dict{String,Any}(), BackedMetadata(Dict{String,Any}(metadata), bs), ) finalizer(ds) do d - d.backing.owned && _cleanup!(d.backing) + d.backing.closed && return + !isempty(d.backing.changes) && @warn( + "SpatialDataset finalized with unsaved changes", + changes=dirty(d), + ) + _cleanup!(d.backing) + close.(values(d.backing.handles)) + empty!(d.backing.handles) + d.backing.closed = true + end + for key in keys(metadata) + _mark_dirty!(bs, (:metadata, string(key))) end ds end # ── Lifecycle ───────────────────────────────────────────────────────────────── -function Base.close(ds::SpatialDataset) +""" + close(ds; discard=false) + +Close a dataset and release its backing resources. A dirty dataset is rejected +unless `discard=true`; call [`save!`](@ref) or [`discard!`](@ref) first when the +changes should be kept or reviewed explicitly. +""" +function Base.close(ds::SpatialDataset; discard::Bool=false) + ds.backing.closed && return nothing + if isdirty(ds) && !discard + throw(ArgumentError( + "dataset has unsaved changes; call save!(ds), discard!(ds), or close(ds; discard=true)", + )) + end _cleanup!(ds.backing) ds.backing.owned = false # prevent double-free in finalizer + close.(values(ds.backing.handles)) + empty!(ds.backing.handles) + ds.backing.closed = true nothing end +Base.isopen(ds::SpatialDataset) = !ds.backing.closed + """ - keep!(ds, path=ds.backing.path) → ds + isdirty(ds) → Bool + +Return whether `ds` contains staged changes that have not been saved. +""" +isdirty(ds::SpatialDataset) = !isempty(ds.backing.changes) + +""" + dirty(ds) → Vector{NamedTuple} + +Return the staged changes in `ds`. Each entry has `kind`, `name`, and `state` +fields; `state` is `:new`, `:modified`, or `:deleted`. +""" +function dirty(ds::SpatialDataset) + [(kind=kind, name=name, state=state) for ((kind, name), state) in ds.backing.changes] +end + +""" + touch!(ds, name) → ds + +Mark a named element or relation as modified after mutation through an external +API. Prefer [`edit!`](@ref) for scoped mutation. +""" +function touch!(ds::SpatialDataset, name::String) + if haskey(ds.elements, name) + _mark_dirty!(ds.backing, (:element, name)) + elseif haskey(ds.relations, name) + _mark_dirty!(ds.backing, (:relation, name)) + else + throw(KeyError(name)) + end + ds +end + +""" + edit!(f, ds, name) + +Run `f` on a named mutable element and mark it modified before `f` is called. +The change remains dirty if `f` throws, because partial mutation may already +have occurred. + +```julia +edit!(ds, "transcripts") do points + points.feature_id[1] = 2 +end +save!(ds, "transcripts") +``` +""" +function edit!(f::Function, ds::SpatialDataset, name::String) + el = ds.elements[name] + _mark_dirty!(ds.backing, (:element, name)) + f(el) +end -Mark the dataset's backing store as permanent, preventing automatic cleanup. +""" + keep!(ds, path=ds.backing.path) → ds -If `path` differs from the current backing path, the store is copied there -first. After `keep!`, the dataset no longer owns its backing directory — it -will not be deleted when `ds` is garbage collected or `close`d. +Save the dataset and mark its backing store as permanent. When `path` differs +from the current backing path, a complete snapshot is written atomically and +the dataset is rebound to it. The resulting directory is not deleted when the +dataset is closed or garbage collected. # See also -[`with_dataset`](@ref), [`write!`](@ref) +[`with_dataset`](@ref), [`save!`](@ref) """ function keep!(ds::SpatialDataset, path::String=ds.backing.path) - if path != ds.backing.path - cp(ds.backing.path, path; force=true) - ds.backing.owned && rm(ds.backing.path; recursive=true, force=true) - ds.backing.path = abspath(path) - end + save!(ds; path) ds.backing.owned = false ds end @@ -171,10 +340,11 @@ end """ with_dataset(f; path=nothing, kw...) -Open a dataset, run `f(ds)`, then close and clean up the backing store. +Open a temporary dataset, run `f(ds)`, then discard it and clean up the backing +store. -The dataset is always closed in a `finally` block, making this safe for -temporary analysis workflows that should not leave stale Zarr directories on disk. +The dataset is always discarded in a `finally` block. Call [`keep!`](@ref) or +[`save!`](@ref) with a permanent path inside `f` when results should survive. ```julia result = with_dataset() do ds @@ -191,7 +361,7 @@ function with_dataset(f::Function; path=nothing, kw...) try f(ds) finally - close(ds) + close(ds; discard=true) end end @@ -199,27 +369,33 @@ end function Base.push!(ds::SpatialDataset, cs::CoordinateSystem) ds.coord_systems[cs.name] = cs - _write_spatialomics_meta(ds, ds.backing.path) # defined in zarr_io.jl + _mark_dirty!(ds.backing, (:dataset, "coordinate_systems")) ds end function Base.push!(ds::SpatialDataset, t::AbstractTransformation) push!(ds.transforms, t) - _write_spatialomics_meta(ds, ds.backing.path) # defined in zarr_io.jl + _mark_dirty!(ds.backing, (:dataset, "coordinate_systems")) + ds +end + +function Base.push!(ds::SpatialDataset, acquisition::AcquisitionSource) + ds.sources[acquisition.name] = acquisition + _mark_dirty!(ds.backing, (:dataset, "coordinate_systems")) ds end """ elements(ds) → OrderedDict{String, Any} -Return the ordered dictionary of all named spatial elements in `ds`. +Return a shallow snapshot of the named spatial elements in `ds`. Values are concrete element types (`SpatialPoints`, `SpatialShapes`, `SpatialImage`, `SpatialLabels`). Use the typed accessors [`points`](@ref), [`shapes`](@ref), [`images`](@ref), [`labels`](@ref) to retrieve a specific element with type checking. """ -elements(ds::SpatialDataset) = ds.elements +elements(ds::SpatialDataset) = copy(ds.elements) """ coord_systems(ds) → Vector{String} @@ -228,6 +404,38 @@ Return the names of all coordinate systems registered in `ds`. """ coord_systems(ds::SpatialDataset) = collect(keys(ds.coord_systems)) +""" + sources(ds) → Vector{String} + +Return the registered acquisition-source names in `ds`. +""" +sources(ds::SpatialDataset) = collect(keys(ds.sources)) + +""" + source(ds, name) → AcquisitionSource + +Return the named acquisition source. Source names can also be passed directly +to `view(ds, name)`. +""" +function source(ds::SpatialDataset, name::AbstractString) + key = String(name) + haskey(ds.sources, key) || throw(ArgumentError( + "unknown acquisition source $(repr(name)); available: $(join(keys(ds.sources), ", "))", + )) + ds.sources[key] +end + +""" + source_attributes(source) + source_attributes(ds, name) + +Return a copy of the structured technology-specific attributes registered for +an acquisition source. +""" +source_attributes(acquisition::AcquisitionSource) = deepcopy(acquisition.attributes) +source_attributes(ds::SpatialDataset, name::AbstractString) = + source_attributes(source(ds, name)) + """ transform(ds, src, dst) → AbstractTransformation @@ -241,21 +449,43 @@ end # ── Element attachment placeholder (implemented in elements.jl) ─────────────── function Base.setindex!(ds::SpatialDataset, rel::SpatialRelation, name::String) - _write_zarr_relation(ds.backing.path, name, rel) ds.relations[name] = rel + _mark_dirty!(ds.backing, (:relation, name)) ds end -function Base.setindex!(ds::SpatialDataset, el, name::String) +function _attach_element!(ds::SpatialDataset, el, name::String) existing = _owning_dataset(el) if existing !== nothing && existing !== ds error("Element already attached to a different dataset. " * "Use `ds[\"$name\"] = copy(el)` to attach a detached copy. " * "Note: relations involving this element in the original dataset will not transfer.") end - _write_zarr(ds.backing.path, name, el) + attachment = _dataset_ref(el) + if existing === ds && attachment[2] != name + error("Element is already attached to this dataset as \"$(attachment[2])\". " * + "Use copy(el) to attach it under another name.") + end + if haskey(ds.elements, name) && ds.elements[name] !== el + _clear_backref!(ds.elements[name]) + end _set_backref!(el, ds, name) ds.elements[name] = el + _mark_dirty!(ds.backing, (:element, name)) + ds +end + +function Base.delete!(ds::SpatialDataset, name::String) + if haskey(ds.elements, name) + el = pop!(ds.elements, name) + _clear_backref!(el) + _mark_dirty!(ds.backing, (:element, name); deleted=true) + elseif haskey(ds.relations, name) + pop!(ds.relations, name) + _mark_dirty!(ds.backing, (:relation, name); deleted=true) + else + throw(KeyError(name)) + end ds end @@ -273,7 +503,7 @@ Return the dictionary of all named relations, or a specific relation by name. [`SpatialRelation`](@ref), [`analyze`](@ref) """ function relations(ds::SpatialDataset) - ds.relations + copy(ds.relations) end function relations(ds::SpatialDataset, name::String) diff --git a/src/elements.jl b/src/elements.jl index 8aa23ca..294a1bd 100644 --- a/src/elements.jl +++ b/src/elements.jl @@ -1,6 +1,42 @@ # ── SpatialPoints ───────────────────────────────────────────────────────────── +function _origin_fields(n::Int, ::Nothing, ::Nothing, codebook) + isempty(codebook) || throw(ArgumentError("origin_codebook requires origin_id")) + nothing, String[] +end + +function _origin_fields(n::Int, labels::AbstractVector, ::Nothing, codebook) + isempty(codebook) || throw(ArgumentError( + "origin_codebook cannot be combined with origins", + )) + length(labels) == n || throw(DimensionMismatch( + "origins has length $(length(labels)); expected $n", + )) + names = unique(String.(labels)) + positions = Dict(name => Int32(i) for (i, name) in enumerate(names)) + Int32[positions[String(label)] for label in labels], names +end + +function _origin_fields(n::Int, ::Nothing, ids::AbstractVector{<:Integer}, codebook) + length(ids) == n || throw(DimensionMismatch( + "origin_id has length $(length(ids)); expected $n", + )) + names = String.(codebook) + encoded = Int32.(ids) + all(id -> 1 <= id <= length(names), encoded) || throw(ArgumentError( + "origin_id values must index origin_codebook", + )) + encoded, names +end + +function _origin_fields(::Int, ::AbstractVector, ::AbstractVector, _) + throw(ArgumentError("origins and origin_id are alternative inputs")) +end + +_subset_origin_ids(::Nothing, _) = nothing +_subset_origin_ids(ids::Vector{Int32}, idx) = ids[idx] + """ SpatialPoints{T<:AbstractFloat} @@ -13,14 +49,16 @@ for O(1) lookup by name via `coords(pts, feature)`. # Constructors - SpatialPoints(coords; feature_id, feature_codebook, instance_id, coord_system) + SpatialPoints(coords; feature_id, feature_codebook, instance_id, origins, coord_system) -Bare coordinates constructor. `coords` is a `Vector{Point{2,T}}`. +Bare coordinates constructor. `coords` is a `Vector{Point{2,T}}`. Pass source +names with `origins`, or an encoded `origin_id` vector and `origin_codebook`, +to retain acquisition provenance independently of position. - SpatialPoints(table; x=:x, y=:y, gene=nothing, coord_system="") + SpatialPoints(table; x=:x, y=:y, gene=nothing, origin=nothing, coord_system="") Tables.jl constructor. Reads x/y from columns named by `x` and `y`; optionally -encodes a gene/label column via `gene`. +encodes gene/label and acquisition-source columns via `gene` and `origin`. ```julia pts = SpatialPoints(df; x=:x_centroid, y=:y_centroid, gene=:target, coord_system="global") @@ -30,7 +68,7 @@ coords(pts, "Epcam") # coordinates of all Epcam transcripts ``` # See also -[`coords`](@ref), [`features`](@ref), [`feature_ids`](@ref), +[`coords`](@ref), [`features`](@ref), [`feature_ids`](@ref), [`origins`](@ref), [`instance_id`](@ref), [`subsample`](@ref), [`top_features`](@ref) """ mutable struct SpatialPoints{T<:AbstractFloat} @@ -39,6 +77,8 @@ mutable struct SpatialPoints{T<:AbstractFloat} feature_codebook :: Vector{String} instance_id :: Vector{Int32} feature_columns :: Union{Nothing, NamedTuple} + origin_id :: Union{Nothing, Vector{Int32}} + origin_codebook :: Vector{String} coord_system :: String _attachment :: Union{Nothing, Tuple{WeakRef, String}} end @@ -49,14 +89,24 @@ function SpatialPoints(coords::Vector{Point{2,T}}; feature_codebook::Vector{String}=String[], instance_id::Vector{Int32}=zeros(Int32, length(coords)), features::Union{Nothing, NamedTuple}=nothing, + origins::Union{Nothing,AbstractVector}=nothing, + origin_id::Union{Nothing,AbstractVector{<:Integer}}=nothing, + origin_codebook::AbstractVector{<:AbstractString}=String[], coord_system::String="") where T<:AbstractFloat - SpatialPoints{T}(coords, feature_id, feature_codebook, instance_id, features, coord_system, nothing) + encoded_origins, origin_names = _origin_fields( + length(coords), origins, origin_id, origin_codebook, + ) + SpatialPoints{T}( + coords, feature_id, feature_codebook, instance_id, features, + encoded_origins, origin_names, coord_system, nothing, + ) end # Tables.jl constructor — columns must have x and y; gene and features are optional function SpatialPoints(table; x::Symbol=:x, y::Symbol=:y, gene::Union{Symbol,Nothing}=nothing, + origin::Union{Symbol,Nothing}=nothing, features::Union{Nothing, NamedTuple}=nothing, coord_system::String="") cols = Tables.columntable(table) @@ -73,7 +123,16 @@ function SpatialPoints(table; codebook = String[] feature_id = zeros(Int32, n) end - SpatialPoints{Float32}(coords, feature_id, codebook, zeros(Int32, n), features, coord_system, nothing) + origin_values = origin === nothing ? nothing : cols[origin] + SpatialPoints( + coords; + feature_id, + feature_codebook=codebook, + instance_id=zeros(Int32, n), + features, + origins=origin_values, + coord_system, + ) end Base.length(pts::SpatialPoints) = length(pts.coords) @@ -119,6 +178,28 @@ _subset_feature_columns(::Nothing, _) = nothing _subset_feature_columns(nt::NamedTuple, idx) = NamedTuple{keys(nt)}(map(v -> v[idx], values(nt))) +""" + with_instance_ids(points, ids) -> SpatialPoints + +Return a detached copy of `points` with replacement instance assignments. +Coordinates, feature encodings, auxiliary feature columns, acquisition origins, +and the coordinate system are preserved. `ids` must contain one value per point. + +This is useful when importing assignments from an external segmentation tool +without rebuilding a point collection field by field. +""" +function with_instance_ids(pts::SpatialPoints{T}, ids::AbstractVector{<:Integer}) where T + length(ids) == length(pts) || throw(DimensionMismatch( + "instance IDs have length $(length(ids)); expected $(length(pts))", + )) + SpatialPoints{T}( + copy(pts.coords), copy(pts.feature_id), copy(pts.feature_codebook), + Int32.(ids), _subset_feature_columns(pts.feature_columns, :), + isnothing(pts.origin_id) ? nothing : copy(pts.origin_id), + copy(pts.origin_codebook), pts.coord_system, nothing, + ) +end + """ coord_system(el) → String @@ -180,29 +261,68 @@ Implements the GeoInterface `GeometryCollectionTrait`, making it compatible with GeometryOps operations directly. # Constructors - SpatialShapes(geometries; instance_id, coord_system) + SpatialShapes(geometries; instance_id, origins, coord_system) SpatialShapes(ext::SpatialExtent) # rectangular region SpatialShapes(roi::SpatialROI) # polygon region # See also -[`geometries`](@ref), [`instance_id`](@ref), [`SpatialROI`](@ref), [`SpatialPoints`](@ref) +[`geometries`](@ref), [`instance_id`](@ref), [`origins`](@ref), +[`SpatialROI`](@ref), [`SpatialPoints`](@ref) """ mutable struct SpatialShapes{G<:AbstractGeometry} geometries :: Vector{G} instance_id :: Vector{Int32} + origin_id :: Union{Nothing, Vector{Int32}} + origin_codebook :: Vector{String} coord_system :: String _attachment :: Union{Nothing, Tuple{WeakRef, String}} end function SpatialShapes(geometries::Vector{G}; instance_id::Vector{Int32}=zeros(Int32, length(geometries)), + origins::Union{Nothing,AbstractVector}=nothing, + origin_id::Union{Nothing,AbstractVector{<:Integer}}=nothing, + origin_codebook::AbstractVector{<:AbstractString}=String[], coord_system::String="") where G<:AbstractGeometry - SpatialShapes{G}(geometries, instance_id, coord_system, nothing) + encoded_origins, origin_names = _origin_fields( + length(geometries), origins, origin_id, origin_codebook, + ) + SpatialShapes{G}( + geometries, instance_id, encoded_origins, origin_names, coord_system, nothing, + ) end Base.length(shp::SpatialShapes) = length(shp.geometries) +""" + origins(el) → Vector{String} + +Return the acquisition-source codebook for a point or shape element. + +Source provenance is independent of geometry. Use `view(ds, source_name)` to +select observations acquired by one source, including when source footprints +overlap. +""" +origins(el::Union{SpatialPoints,SpatialShapes}) = el.origin_codebook + +""" + origin_ids(el) → Union{Nothing,Vector{Int32}} + +Return compact per-observation indices into [`origins`](@ref), or `nothing` +when the element has no acquisition provenance. +""" +origin_ids(el::Union{SpatialPoints,SpatialShapes}) = el.origin_id + +""" + source(el, i) → Union{Nothing,String} + +Return the acquisition-source name for observation `i`, or `nothing` when the +element has no acquisition provenance. +""" +source(el::Union{SpatialPoints,SpatialShapes}, i::Integer) = + el.origin_id === nothing ? nothing : el.origin_codebook[el.origin_id[i]] + # ── Row type ────────────────────────────────────────────────────────────────── """ @@ -210,9 +330,8 @@ Base.length(shp::SpatialShapes) = length(shp.geometries) Single-shape row accessor produced by indexing into a `SpatialShapes` collection. -Carries the geometry, its `instance_id`, and the coordinate system name. -Row-accessor and collection share the same field names (`geometry`, `instance_id`, -`coord_system`) so code generalises across both. +Carries the geometry, its `instance_id`, optional acquisition `origin`, and the +coordinate system name. # See also [`SpatialShapes`](@ref), [`geometry`](@ref) @@ -220,11 +339,12 @@ Row-accessor and collection share the same field names (`geometry`, `instance_id struct SpatialShape{G<:AbstractGeometry} geometry :: G instance_id :: Int32 + origin :: Union{Nothing,String} coord_system :: String end Base.getindex(shp::SpatialShapes{G}, i::Int) where G = - SpatialShape{G}(shp.geometries[i], shp.instance_id[i], shp.coord_system) + SpatialShape{G}(shp.geometries[i], shp.instance_id[i], source(shp, i), shp.coord_system) Base.iterate(shp::SpatialShapes, i=1) = i > length(shp) ? nothing : (shp[i], i+1) Base.eltype(::Type{SpatialShapes{G}}) where G = SpatialShape{G} @@ -233,6 +353,8 @@ function Base.filter(pred, shp::SpatialShapes{G}) where G keep = [i for i in eachindex(shp.geometries) if pred(shp[i])] SpatialShapes(shp.geometries[keep]; instance_id = shp.instance_id[keep], + origin_id = _subset_origin_ids(shp.origin_id, keep), + origin_codebook = copy(shp.origin_codebook), coord_system = shp.coord_system) end @@ -269,6 +391,10 @@ function _transform_geom(t::AbstractTransformation, poly::Polygon) Polygon(ext, holes) end +function _transform_geom(t::AbstractTransformation, multi::MultiPolygon) + MultiPolygon([_transform_geom(t, polygon) for polygon in GeoInterface.getgeom(multi)]) +end + # ── apply / apply! on SpatialPoints ────────────────────────────────────────── function apply(t::AbstractTransformation, pts::SpatialPoints{T}) where T @@ -276,8 +402,12 @@ function apply(t::AbstractTransformation, pts::SpatialPoints{T}) where T v = apply(t, p) Point{2,T}(v[1], v[2]) end - SpatialPoints{T}(new_coords, copy(pts.feature_id), copy(pts.feature_codebook), - copy(pts.instance_id), pts.feature_columns, t.dst, nothing) + SpatialPoints{T}( + new_coords, copy(pts.feature_id), copy(pts.feature_codebook), + copy(pts.instance_id), pts.feature_columns, + isnothing(pts.origin_id) ? nothing : copy(pts.origin_id), + copy(pts.origin_codebook), t.dst, nothing, + ) end """ @@ -293,6 +423,8 @@ when the element is attached to a dataset. [`apply`](@ref) """ function apply!(t::AbstractTransformation, pts::SpatialPoints{T}) where T + owner = _owning_dataset(pts) + owner === nothing || touch!(owner, _dataset_ref(pts)[2]) map!(pts.coords, pts.coords) do p v = apply(t, p) Point{2,T}(v[1], v[2]) @@ -305,10 +437,18 @@ end function apply(t::AbstractTransformation, shp::SpatialShapes{G}) where G new_geoms = G[_transform_geom(t, g) for g in shp.geometries] - SpatialShapes(new_geoms; instance_id=copy(shp.instance_id), coord_system=t.dst) + SpatialShapes( + new_geoms; + instance_id=copy(shp.instance_id), + origin_id=isnothing(shp.origin_id) ? nothing : copy(shp.origin_id), + origin_codebook=copy(shp.origin_codebook), + coord_system=t.dst, + ) end function apply!(t::AbstractTransformation, shp::SpatialShapes{G}) where G + owner = _owning_dataset(shp) + owner === nothing || touch!(owner, _dataset_ref(shp)[2]) for i in eachindex(shp.geometries) shp.geometries[i] = _transform_geom(t, shp.geometries[i]) end @@ -327,7 +467,7 @@ end _dataset_ref(el::SpatialPoints) = el._attachment _dataset_ref(el::SpatialShapes) = el._attachment -_dataset_ref(::Any) = nothing # images, labels, tables: no ref yet +_dataset_ref(::Any) = nothing # immutable labels and detached extension types function _owning_dataset(el) att = _dataset_ref(el) @@ -341,13 +481,30 @@ function _set_backref!(el::Union{SpatialPoints, SpatialShapes}, end _set_backref!(::Any, ::SpatialDataset, ::String) = nothing # no-op for other types +function _clear_backref!(el::Union{SpatialPoints, SpatialShapes}) + el._attachment = nothing + el +end +_clear_backref!(el) = el + +Base.setindex!(ds::SpatialDataset, el::Union{SpatialPoints,SpatialShapes}, name::String) = + _attach_element!(ds, el, name) + function Base.copy(pts::SpatialPoints{T}) where T - SpatialPoints{T}(copy(pts.coords), copy(pts.feature_id), copy(pts.feature_codebook), - copy(pts.instance_id), pts.feature_columns, pts.coord_system, nothing) + SpatialPoints{T}( + copy(pts.coords), copy(pts.feature_id), copy(pts.feature_codebook), + copy(pts.instance_id), pts.feature_columns, + isnothing(pts.origin_id) ? nothing : copy(pts.origin_id), + copy(pts.origin_codebook), pts.coord_system, nothing, + ) end Base.copy(shp::SpatialShapes{G}) where G = - SpatialShapes{G}(copy(shp.geometries), copy(shp.instance_id), shp.coord_system, nothing) + SpatialShapes{G}( + copy(shp.geometries), copy(shp.instance_id), + isnothing(shp.origin_id) ? nothing : copy(shp.origin_id), + copy(shp.origin_codebook), shp.coord_system, nothing, + ) """ instance_ids(pts) → Vector{Int32} @@ -375,15 +532,21 @@ The feature codebook is preserved; indices are rebuilt from the subset. function subsample(pts::SpatialPoints{T}, n::Int) where T n >= length(pts) && return pts idx = sort!(randperm(length(pts))[1:n]) - SpatialPoints{T}(pts.coords[idx], pts.feature_id[idx], copy(pts.feature_codebook), - pts.instance_id[idx], _subset_feature_columns(pts.feature_columns, idx), - pts.coord_system, nothing) + SpatialPoints{T}( + pts.coords[idx], pts.feature_id[idx], copy(pts.feature_codebook), + pts.instance_id[idx], _subset_feature_columns(pts.feature_columns, idx), + _subset_origin_ids(pts.origin_id, idx), copy(pts.origin_codebook), + pts.coord_system, nothing, + ) end function Base.getindex(pts::SpatialPoints{T}, mask::AbstractVector{Bool}) where T - SpatialPoints{T}(pts.coords[mask], pts.feature_id[mask], copy(pts.feature_codebook), - pts.instance_id[mask], _subset_feature_columns(pts.feature_columns, mask), - pts.coord_system, nothing) + SpatialPoints{T}( + pts.coords[mask], pts.feature_id[mask], copy(pts.feature_codebook), + pts.instance_id[mask], _subset_feature_columns(pts.feature_columns, mask), + _subset_origin_ids(pts.origin_id, mask), copy(pts.origin_codebook), + pts.coord_system, nothing, + ) end function Base.getindex(pts::SpatialPoints{T}, gene::String) where T @@ -392,6 +555,14 @@ function Base.getindex(pts::SpatialPoints{T}, gene::String) where T pts[mask] end +function Base.getindex(pts::SpatialPoints, genes::AbstractVector{<:AbstractString}) + selected = Set(String.(genes)) + selected_indices = Set( + Int32(index) for (index, gene) in pairs(pts.feature_codebook) if gene in selected + ) + pts[BitVector(id in selected_indices for id in pts.feature_id)] +end + # ── top_features ────────────────────────────────────────────────────────────── """ @@ -415,9 +586,10 @@ end # ── count_per_instance ──────────────────────────────────────────────────────── """ - count_per_instance(pts) → Dict{Int32, Int} + count_per_instance(pts; feature=nothing) → Dict{Int32, Int} Return a dictionary mapping each non-zero instance ID to its observation count. +When `feature` is supplied, count only observations with that feature label. Unassigned points (`instance_id == 0`) are excluded. Useful for computing transcript counts per cell or density metrics. @@ -425,9 +597,20 @@ transcript counts per cell or density metrics. # See also [`instance_id`](@ref), [`top_features`](@ref) """ -function count_per_instance(pts::SpatialPoints) +function count_per_instance(pts::SpatialPoints; feature::Union{Nothing,AbstractString}=nothing) + feature_index = if feature === nothing + nothing + else + index = findfirst(==(feature), pts.feature_codebook) + index === nothing && throw(ArgumentError( + "feature $(repr(feature)) not found; available: $(pts.feature_codebook)", + )) + Int32(index) + end counts = Dict{Int32, Int}() - for id in pts.instance_id + for index in eachindex(pts.instance_id) + feature_index === nothing || pts.feature_id[index] == feature_index || continue + id = pts.instance_id[index] id == Int32(0) && continue counts[id] = get(counts, id, 0) + 1 end diff --git a/src/images.jl b/src/images.jl index 2c14b49..211d48f 100644 --- a/src/images.jl +++ b/src/images.jl @@ -30,6 +30,7 @@ mutable struct SpatialImage{T, N} coord_system :: String pixel_to_cs :: AbstractTransformation # image pixel coords → coord_system display_transform :: Union{Nothing, Function} # applied post-materialization in display + _attachment :: Union{Nothing, Tuple{WeakRef, String}} end function _default_image_axes(N::Int) @@ -48,9 +49,21 @@ function SpatialImage(data::AbstractArray{T, N}; pyr = pyramid === nothing ? AbstractArray{T,N}[] : AbstractArray{T,N}[p for p in pyramid] SpatialImage{T, N}(data, pyr, NTuple{N, Symbol}(axes), channel_names, - coord_system, pixel_to_cs, display_transform) + coord_system, pixel_to_cs, display_transform, nothing) end +_dataset_ref(img::SpatialImage) = img._attachment +function _set_backref!(img::SpatialImage, ds::SpatialDataset, name::String) + img._attachment = (WeakRef(ds), name) +end +function _clear_backref!(img::SpatialImage) + img._attachment = nothing + img +end + +Base.setindex!(ds::SpatialDataset, img::SpatialImage, name::String) = + _attach_element!(ds, img, name) + # ── Accessors ────────────────────────────────────────────────────────────────── """ @@ -89,12 +102,56 @@ end Base.size(img::SpatialImage) = size(img.data) Base.length(img::SpatialImage) = length(img.data) +""" + SpatialRasterTiles + +Positioned raster pieces selected from non-contiguous acquisition sources. +Each tile retains its own pixel-to-coordinate-system transform. The collection +does not allocate or represent pixels in gaps between tiles; call `collect` on +individual tiles when dense arrays are required. +""" +struct SpatialRasterTiles{R} <: AbstractVector{R} + tiles :: Vector{R} + sources :: Vector{String} + + function SpatialRasterTiles(tiles::Vector{R}, sources::Vector{String}) where R + length(tiles) == length(sources) || throw(DimensionMismatch( + "raster tile count $(length(tiles)) does not match source count $(length(sources))", + )) + new{R}(tiles, sources) + end +end + +Base.size(tiles::SpatialRasterTiles) = (length(tiles.tiles),) +Base.length(tiles::SpatialRasterTiles) = length(tiles.tiles) +Base.getindex(tiles::SpatialRasterTiles, index::Int) = tiles.tiles[index] +Base.IndexStyle(::Type{<:SpatialRasterTiles}) = IndexLinear() + +""" + sources(tiles::SpatialRasterTiles) -> Vector{String} + +Return the acquisition source corresponding to each positioned raster tile. +""" +sources(tiles::SpatialRasterTiles) = copy(tiles.sources) + +function coord_system(tiles::SpatialRasterTiles) + isempty(tiles) && return "" + systems = unique(coord_system(tile) for tile in tiles) + length(systems) == 1 || throw(ArgumentError( + "raster tiles use multiple coordinate systems: $(collect(systems))", + )) + only(systems) +end + # ── Pyramid ──────────────────────────────────────────────────────────────────── function _spatial_dims(axes::NTuple{N, Symbol}) where N Tuple(i for (i, a) in enumerate(axes) if a in (:x, :y, :z)) end +_pyramid_storage(::Type{T}, level) where {T<:Integer} = round.(T, level) +_pyramid_storage(::Type{T}, level) where T = T.(level) + """ build_pyramid!(img, n_levels=3) → img @@ -103,17 +160,20 @@ coarser arrays in `img.pyramid`. Each level halves the spatial resolution along the `:x` and `:y` axes using `ImageBase.restrict`. The channel axis (`:c`) is not downsampled. Existing -pyramid levels are discarded before building. +pyramid levels are discarded before building. Levels preserve the image's +storage element type; filtered integer values are rounded to that type. # See also [`scaleminmax`](@ref), [`channel`](@ref) """ -function build_pyramid!(img::SpatialImage, n_levels::Int=3) +function build_pyramid!(img::SpatialImage{T}, n_levels::Int=3) where T + owner = _owning_dataset(img) + owner === nothing || touch!(owner, _dataset_ref(img)[2]) empty!(img.pyramid) sdims = _spatial_dims(img.axes) current = img.data for _ in 1:n_levels - current = restrict(current, sdims) + current = _pyramid_storage(T, restrict(current, sdims)) push!(img.pyramid, current) end img @@ -145,6 +205,9 @@ struct SpatialLabels{T<:Integer, N} pixel_to_cs :: AbstractTransformation end +Base.setindex!(ds::SpatialDataset, lbl::SpatialLabels, name::String) = + _attach_element!(ds, lbl, name) + function SpatialLabels(data::AbstractArray{T, N}; axes = _default_image_axes(N), instance_map = Dict{T, Int32}(), @@ -234,6 +297,10 @@ function channel(img::SpatialImage, ch::String) channel(img, i) end +channel(tiles::SpatialRasterTiles{<:SpatialImage}, ch) = SpatialRasterTiles( + [channel(tile, ch) for tile in tiles], copy(tiles.sources), +) + # ── scaleminmax — lazy display-time intensity rescaling ──────────────────────── """ @@ -259,6 +326,30 @@ function scaleminmax(img::SpatialImage) display_transform=scaleminmax(mn, mx)) end +function scaleminmax(tiles::SpatialRasterTiles{<:SpatialImage}) + isempty(tiles) && return tiles + ranges = map(tiles) do tile + source = isempty(tile.pyramid) ? tile.data : tile.pyramid[end] + extrema(Array(source)) + end + minimum_value = Float32(minimum(first, ranges)) + maximum_value = Float32(maximum(last, ranges)) + transform = scaleminmax(minimum_value, maximum_value) + scaled = [ + SpatialImage( + tile.data; + axes=tile.axes, + channel_names=tile.channel_names, + coord_system=tile.coord_system, + pixel_to_cs=tile.pixel_to_cs, + pyramid=tile.pyramid, + display_transform=transform, + ) + for tile in tiles + ] + SpatialRasterTiles(scaled, copy(tiles.sources)) +end + # ── pyramid_level — internal helper (not exported) ───────────────────────────── function _pyramid_level(img::SpatialImage, level::Int) @@ -352,6 +443,18 @@ function colorview(CT::Type{<:Colorant}, imgs::SpatialImage...) imgs[1].coord_system, imgs[1].pixel_to_cs, imgs[1].axes) end +function colorview(CT::Type{<:Colorant}, collections::SpatialRasterTiles...) + isempty(collections) && throw(ArgumentError("at least one raster collection is required")) + expected_sources = first(collections).sources + all(collection -> collection.sources == expected_sources, collections) || + throw(ArgumentError("raster collections must contain the same sources in the same order")) + tiles = [ + colorview(CT, (collection[index] for collection in collections)...) + for index in eachindex(first(collections)) + ] + SpatialRasterTiles(tiles, copy(expected_sources)) +end + function _spatial_colorview(CT::Type{<:Colorant}, img::SpatialImage{T,N}) where {T,N} SpatialImageColorView{CT, T, N}(img.data, img.pyramid, CT, img.display_transform, img.coord_system, img.pixel_to_cs, img.axes) @@ -385,6 +488,31 @@ function Base.view(img::SpatialImage, ext::SpatialExtent) pyramid=new_pyr, display_transform=img.display_transform) end +function Base.view(lbl::SpatialLabels, ext::SpatialExtent) + N = ndims(lbl.data) + xi = something(findfirst(==(:x), lbl.axes), 1) + yi = something(findfirst(==(:y), lbl.axes), 2) + lo = _global_to_pixel(lbl.pixel_to_cs, SVector(ext.xmin, ext.ymin)) + hi = _global_to_pixel(lbl.pixel_to_cs, SVector(ext.xmax, ext.ymax)) + xi_lo, xi_hi = _px_range(lo[1], hi[1], size(lbl.data, xi)) + yi_lo, yi_hi = _px_range(lo[2], hi[2], size(lbl.data, yi)) + slices = ntuple( + dimension -> dimension == xi ? (xi_lo:xi_hi) : + dimension == yi ? (yi_lo:yi_hi) : Colon(), + N, + ) + pixel_to_cs = _shift_pixel_origin( + lbl.pixel_to_cs, Float64(xi_lo - 1), Float64(yi_lo - 1), + ) + SpatialLabels( + view(lbl.data, slices...); + axes=lbl.axes, + instance_map=copy(lbl.instance_map), + coord_system=lbl.coord_system, + pixel_to_cs, + ) +end + _px_range(lo, hi, n) = (clamp(floor(Int, min(lo, hi)) + 1, 1, n), clamp(ceil(Int, max(lo, hi)), 1, n)) diff --git a/src/relations.jl b/src/relations.jl index 5a5e137..f01e426 100644 --- a/src/relations.jl +++ b/src/relations.jl @@ -5,8 +5,8 @@ Abstract supertype for relation-kind dispatch tokens. -Concrete subtypes — [`Membership`](@ref), [`Proximity`](@ref), [`KNN`](@ref), -[`Expression`](@ref) — are passed to `analyze` to select the algorithm, and +Concrete subtypes — [`Membership`](@ref) and [`Expression`](@ref) — are passed +to `analyze` to select the algorithm, and stored in the resulting `SpatialRelation` to enable re-dispatch. """ abstract type RelationKind end @@ -51,11 +51,12 @@ struct Expression <: RelationKind end Weighted relation between two named spatial elements. The relation kind `K` determines the semantics: `Expression` is a bipartite -cell × gene count matrix; `Membership` is a source-to-destination assignment; -`Proximity` and `KNN` are graph structures. +cell × gene count matrix; `Membership` is a source-to-destination assignment. - `src`, `dst`: element names in the parent dataset -- `src_ids`, `dst_ids`: `instance_id` vectors identifying the rows/nodes +- `src_ids`, `dst_ids`: identifiers for the related observations. Point + membership stores one-based point-row positions in `src_ids`; shape + membership and expression relations use shape `instance_id` values. - `weights`: the relation data (`Matrix{Float32}` or `nothing`) - `obs`: per-row metadata (Tables.jl-compatible) - `var`: per-column metadata (for `Expression`: gene names via `:name`) @@ -90,7 +91,7 @@ end # ── Convenience constructors ────────────────────────────────────────────────── -# Membership / Proximity / KNN: no var metadata +# Non-expression relations have no variable metadata. function SpatialRelation(kind::RelationKind, src::String, dst::String, src_ids, dst_ids, weights=nothing; obs=NamedTuple()) @@ -106,10 +107,32 @@ end # ── Accessors ───────────────────────────────────────────────────────────────── +""" + source_ids(rel) -> Vector{Int32} + +Return the source observation IDs stored by a relation. + +For point [`Membership`](@ref), these are one-based row positions in the source +`SpatialPoints`. For shape membership and [`Expression`](@ref), they are source +shape `instance_id` values. +""" +source_ids(rel::SpatialRelation) = rel.src_ids + +""" + destination_ids(rel) -> Vector{Int32} + +Return the destination IDs stored by a relation. Expression relations return +an empty vector because their columns are variables rather than destination +spatial objects. +""" +destination_ids(rel::SpatialRelation) = rel.dst_ids + """ nobs(rel) → Int -Return the number of source observations (rows) in a `SpatialRelation`. +Return the number of relation rows. For `Expression`, this is the number of +source observations. For `Membership`, it is the number of matched pairs and +can exceed the number of unique sources when destination shapes overlap. """ nobs(rel::SpatialRelation) = length(rel.src_ids) diff --git a/src/show.jl b/src/show.jl index 0aee05f..267a394 100644 --- a/src/show.jl +++ b/src/show.jl @@ -9,6 +9,12 @@ function Base.show(io::IO, cs::CoordinateSystem) print(io, "CoordinateSystem(\"$(cs.name)\", $(cs.axes[1])/$(cs.axes[2]), $(cs.units[1])/$(cs.units[2]))") end +function Base.show(io::IO, acquisition::AcquisitionSource) + footprint = acquisition.region_element === nothing ? "" : + " → $(repr(acquisition.region_element))[$(acquisition.region_id)]" + print(io, "AcquisitionSource($(repr(acquisition.name))$footprint)") +end + Base.show(io::IO, t::Identity) = print(io, "Identity: \"$(t.src)\" → \"$(t.dst)\"") Base.show(io::IO, t::Affine) = print(io, "Affine: \"$(t.src)\" → \"$(t.dst)\"") Base.show(io::IO, t::Sequence) = print(io, "Sequence($(length(t.steps)) steps): \"$(t.src)\" → \"$(t.dst)\"") @@ -80,7 +86,9 @@ function Base.show(io::IO, ds::SpatialDataset) cs = ncs == 0 ? "" : ncs <= 3 ? " [$(join(keys(ds.coord_systems), ", "))]" : " ($ncs coord systems)" - print(io, "SpatialDataset($n element$(n == 1 ? "" : "s")$cs)") + state = isdirty(ds) ? ", unsaved=$(length(ds.backing.changes))" : "" + source_state = isempty(ds.sources) ? "" : ", sources=$(length(ds.sources))" + print(io, "SpatialDataset($n element$(n == 1 ? "" : "s")$cs$source_state$state)") end # Full REPL form — used when ds is displayed at top level @@ -88,7 +96,9 @@ function Base.show(io::IO, ::MIME"text/plain", ds::SpatialDataset) n = length(ds.elements) nr = length(ds.relations) ncs = length(ds.coord_systems) - println(io, "SpatialDataset with $n element$(n == 1 ? "" : "s"), $nr relation$(nr == 1 ? "" : "s"), $ncs coord_system$(ncs == 1 ? "" : "s"):") + ns = length(ds.sources) + persistence = isdirty(ds) ? "$(length(ds.backing.changes)) unsaved change$(length(ds.backing.changes) == 1 ? "" : "s")" : "saved" + println(io, "SpatialDataset with $n element$(n == 1 ? "" : "s"), $nr relation$(nr == 1 ? "" : "s"), $ncs coord_system$(ncs == 1 ? "" : "s"), $ns acquisition source$(ns == 1 ? "" : "s") ($persistence):") for (name, el) in ds.elements print(io, " \"$name\" => ") show(io, el) @@ -109,4 +119,12 @@ function Base.show(io::IO, ::MIME"text/plain", ds::SpatialDataset) print(io, "\n transforms: $(length(ds.transforms))") end end + if !isempty(ds.sources) + println(io) + print(io, " sources: ", join(keys(ds.sources), ", ")) + end + if isdirty(ds) + println(io) + print(io, " unsaved: ", join(("$(c.kind):$(c.name) ($(c.state))" for c in dirty(ds)), ", ")) + end end diff --git a/src/views.jl b/src/views.jl index bf089f2..d559294 100644 --- a/src/views.jl +++ b/src/views.jl @@ -182,16 +182,29 @@ struct SpatialElementView{T, R} overlap :: Symbol # :any — shape intersects ROI; :full — shape fully inside ROI end +Base.parent(v::SpatialElementView) = v.parent +Base.parentindices(v::SpatialElementView) = + (findall(_mask(v.parent, v.roi, v.overlap)),) + # ── SpatialDatasetView ──────────────────────────────────────────────────────── +struct AcquisitionSelection + sources :: Vector{AcquisitionSource} +end + +struct SourceFootprintSelection + regions :: Vector{SpatialROI} +end + """ SpatialDatasetView Lazy view across all elements of a `SpatialDataset`, scoped to a spatial region. -Produced by `view(ds, extent)` or `view(ds, roi)`. Accessing an element via -the typed accessors (`points`, `shapes`, `images`, `labels`) returns a -`SpatialElementView` for that element — still lazy, no data copied. +Produced by `view(ds, extent)`, `view(ds, roi)`, or `view(ds, source_name)`. +Geometric regions select by location. Acquisition-source views select points +and shapes by recorded origin, so overlapping source footprints do not change +membership. ```julia roi = view(ds, SpatialExtent(1000.0, 2000.0, 500.0, 1500.0)) @@ -202,9 +215,9 @@ collect(tx) # materialise into a concrete SpatialPoints # See also [`SpatialElementView`](@ref), [`SpatialExtent`](@ref) """ -struct SpatialDatasetView +struct SpatialDatasetView{S} parent :: SpatialDataset - roi :: Union{SpatialExtent, SpatialROI} + roi :: S end # ── view constructors ───────────────────────────────────────────────────────── @@ -222,6 +235,32 @@ function Base.view(el::Union{SpatialPoints, SpatialShapes}, roi::_ROI; SpatialElementView(el, roi, overlap) end +function Base.view(el::Union{SpatialPoints,SpatialShapes}, + acquisition::AcquisitionSource; + overlap::Symbol=:any) + overlap in (:any, :full) || throw(ArgumentError( + "overlap must be :any or :full, got :$overlap", + )) + _has_origins(el) || throw(ArgumentError( + "element has no acquisition provenance; select the source through its parent dataset " * + "to permit an explicit, warned geometric fallback", + )) + SpatialElementView(el, acquisition, overlap) +end + +function Base.view(el::Union{SpatialPoints,SpatialShapes}, + selection::AcquisitionSelection; + overlap::Symbol=:any) + overlap in (:any, :full) || throw(ArgumentError( + "overlap must be :any or :full, got :$overlap", + )) + _has_origins(el) || throw(ArgumentError( + "element has no acquisition provenance; select sources through its parent " * + "dataset to permit an explicit, warned geometric fallback", + )) + SpatialElementView(el, selection, overlap) +end + function Base.view(el::Union{SpatialPoints, SpatialShapes, SpatialDataset}, shp::SpatialShapes; kw...) length(shp.geometries) == 1 || @@ -234,22 +273,103 @@ function Base.view(ds::SpatialDataset, roi::_ROI) SpatialDatasetView(ds, roi) end +Base.view(ds::SpatialDataset, acquisition::AcquisitionSource) = + SpatialDatasetView(ds, acquisition) + +""" + view(ds, source_name) + +Create a lazy acquisition-source view. Points and shapes with origin metadata +are selected by provenance, not by footprint geometry. This differs from +`view(ds, roi)`, which deliberately selects every observation geometrically +inside a user-defined region, including across acquisition boundaries. +""" +Base.view(ds::SpatialDataset, source_name::AbstractString) = + view(ds, source(ds, source_name)) + +""" + view(ds, source_names) + +Create a lazy union of acquisition sources. Vector elements include exactly the +observations recorded by the selected sources. Raster access returns a +[`SpatialRasterTiles`](@ref) collection with one positioned crop per source, +without materialising the bounding rectangle between disconnected sources. +""" +function Base.view(ds::SpatialDataset, source_names::AbstractVector{<:AbstractString}) + acquisitions = AcquisitionSource[] + seen = Set{String}() + for name in source_names + acquisition = source(ds, name) + acquisition.name in seen && continue + push!(seen, acquisition.name) + push!(acquisitions, acquisition) + end + SpatialDatasetView(ds, AcquisitionSelection(acquisitions)) +end + +function _source_roi(ds::SpatialDataset, acquisition::AcquisitionSource) + acquisition.region_element === nothing && throw(ArgumentError( + "acquisition source $(repr(acquisition.name)) has no registered spatial footprint", + )) + region = shapes(ds, acquisition.region_element) + index = findfirst(==(acquisition.region_id), region.instance_id) + index === nothing && throw(ArgumentError( + "source footprint $(repr(acquisition.region_element)) has no instance_id " * + "$(acquisition.region_id)", + )) + SpatialROI(region.geometries[index]; coord_system=region.coord_system) +end + +_has_origins(el::Union{SpatialPoints,SpatialShapes}) = el.origin_id !== nothing + +function _source_view(ds::SpatialDataset, + el::Union{SpatialPoints,SpatialShapes}, + acquisition::AcquisitionSource) + _has_origins(el) && return view(el, acquisition) + element_name = _element_name(el) + @warn "Element has no acquisition provenance; using source-footprint geometry" element=element_name source=acquisition.name _id=(:spatialomics_source_fallback, element_name, acquisition.name) maxlog=1 + view(el, _source_roi(ds, acquisition)) +end + +function _source_view(ds::SpatialDataset, + el::Union{SpatialPoints,SpatialShapes}, + selection::AcquisitionSelection) + _has_origins(el) && return view(el, selection) + element_name = _element_name(el) + source_names = [acquisition.name for acquisition in selection.sources] + @warn "Element has no acquisition provenance; using the union of source-footprint geometries" element=element_name sources=source_names _id=(:spatialomics_source_union_fallback, element_name, Tuple(source_names)) maxlog=1 + regions = [_source_roi(ds, acquisition) for acquisition in selection.sources] + SpatialElementView(el, SourceFootprintSelection(regions), :any) +end + +_dataset_view_element(::SpatialDataset, + el::Union{SpatialPoints,SpatialShapes}, roi::_ROI) = view(el, roi) +_dataset_view_element(ds::SpatialDataset, + el::Union{SpatialPoints,SpatialShapes}, + acquisition::AcquisitionSource) = _source_view(ds, el, acquisition) +_dataset_view_element(ds::SpatialDataset, + el::Union{SpatialPoints,SpatialShapes}, + selection::AcquisitionSelection) = _source_view(ds, el, selection) +_dataset_view_element(ds::SpatialDataset, el, selector) = + view(el, _view_extent(ds, selector)) + # ── SpatialDatasetView element access ───────────────────────────────────────── -Base.getindex(v::SpatialDatasetView, name::String) = view(v.parent[name], v.roi) +Base.getindex(v::SpatialDatasetView, name::String) = + _dataset_view_element(v.parent, v.parent[name], v.roi) Base.haskey(v::SpatialDatasetView, name::String) = haskey(v.parent, name) Base.keys(v::SpatialDatasetView) = keys(v.parent) function points(v::SpatialDatasetView, name::String) el = v.parent.elements[name] el isa SpatialPoints || error("Element \"$name\" is not SpatialPoints (got $(typeof(el)))") - view(el, v.roi) + _dataset_view_element(v.parent, el, v.roi) end function shapes(v::SpatialDatasetView, name::String) el = v.parent.elements[name] el isa SpatialShapes || error("Element \"$name\" is not SpatialShapes (got $(typeof(el)))") - view(el, v.roi) + _dataset_view_element(v.parent, el, v.roi) end # ── Mask computation ────────────────────────────────────────────────────────── @@ -268,6 +388,39 @@ function _mask(pts::SpatialPoints, roi::SpatialROI, ::Symbol=:any) mask end +function _origin_mask(el::Union{SpatialPoints,SpatialShapes}, + acquisition::AcquisitionSource) + index = findfirst(==(acquisition.name), el.origin_codebook) + index === nothing && return falses(length(el)) + el.origin_id .== Int32(index) +end + +_mask(pts::SpatialPoints, acquisition::AcquisitionSource, ::Symbol=:any) = + _origin_mask(pts, acquisition) + +_mask(shp::SpatialShapes, acquisition::AcquisitionSource, ::Symbol=:any) = + _origin_mask(shp, acquisition) + +function _mask(el::Union{SpatialPoints,SpatialShapes}, + selection::AcquisitionSelection, ::Symbol=:any) + selected_names = Set(acquisition.name for acquisition in selection.sources) + selected_codes = Set( + Int32(index) + for (index, name) in pairs(el.origin_codebook) + if name in selected_names + ) + BitVector(id in selected_codes for id in el.origin_id) +end + +function _mask(el::Union{SpatialPoints,SpatialShapes}, + selection::SourceFootprintSelection, overlap::Symbol=:any) + mask = falses(length(el)) + for region in selection.regions + mask .|= _mask(el, region, overlap) + end + mask +end + # Shapes × SpatialExtent: per-geometry extent check, exact for rectangular ROIs function _mask(shp::SpatialShapes, ext::SpatialExtent, overlap::Symbol=:any) if overlap == :any @@ -301,16 +454,22 @@ function Base.collect(v::SpatialElementView{<:SpatialPoints}) mask = _mask(v.parent, v.roi, v.overlap) p = v.parent T = eltype(eltype(p.coords)) - SpatialPoints{T}(p.coords[mask], p.feature_id[mask], copy(p.feature_codebook), - p.instance_id[mask], _subset_feature_columns(p.feature_columns, mask), - p.coord_system, nothing) + SpatialPoints{T}( + p.coords[mask], p.feature_id[mask], copy(p.feature_codebook), + p.instance_id[mask], _subset_feature_columns(p.feature_columns, mask), + _subset_origin_ids(p.origin_id, mask), copy(p.origin_codebook), + p.coord_system, nothing, + ) end function Base.collect(v::SpatialElementView{<:SpatialShapes}) mask = _mask(v.parent, v.roi, v.overlap) s = v.parent SpatialShapes(s.geometries[mask]; - instance_id=s.instance_id[mask], coord_system=s.coord_system) + instance_id=s.instance_id[mask], + origin_id=_subset_origin_ids(s.origin_id, mask), + origin_codebook=copy(s.origin_codebook), + coord_system=s.coord_system) end # ── length — count without allocating a copy ────────────────────────────────── @@ -320,7 +479,20 @@ Base.length(v::SpatialElementView) = count(_mask(v.parent, v.roi, v.overlap)) # ── passthrough accessors for SpatialElementView ────────────────────────────── coord_system(v::SpatialElementView) = coord_system(v.parent) -coord_system(v::SpatialDatasetView) = coord_system(v.roi) +coord_system(v::SpatialDatasetView) = _view_coord_system(v.parent, v.roi) +_view_coord_system(::SpatialDataset, roi::_ROI) = coord_system(roi) +_view_coord_system(ds::SpatialDataset, acquisition::AcquisitionSource) = + coord_system(_source_roi(ds, acquisition)) +function _view_coord_system(ds::SpatialDataset, selection::AcquisitionSelection) + isempty(selection.sources) && return "" + systems = unique( + coord_system(_source_roi(ds, acquisition)) for acquisition in selection.sources + ) + length(systems) == 1 || throw(ArgumentError( + "selected acquisition sources use multiple coordinate systems: $(collect(systems))", + )) + only(systems) +end features(v::SpatialElementView{<:SpatialPoints}) = v.parent.feature_codebook features(v::SpatialElementView{<:SpatialPoints}, col::Symbol) = @@ -343,17 +515,39 @@ end feature_ids(v::SpatialElementView{<:SpatialPoints}) = v.parent.feature_id[_mask(v.parent, v.roi, v.overlap)] +origins(v::SpatialElementView{<:Union{SpatialPoints,SpatialShapes}}) = + v.parent.origin_codebook + +origin_ids(v::SpatialElementView{<:Union{SpatialPoints,SpatialShapes}}) = + _subset_origin_ids(v.parent.origin_id, _mask(v.parent, v.roi, v.overlap)) + +function source(v::SpatialElementView{<:Union{SpatialPoints,SpatialShapes}}, i::Integer) + ids = origin_ids(v) + ids === nothing ? nothing : origins(v)[ids[i]] +end + instance_id(v::SpatialElementView{<:SpatialShapes}) = v.parent.instance_id[_mask(v.parent, v.roi, v.overlap)] instance_id(v::SpatialElementView{<:SpatialPoints}) = v.parent.instance_id[_mask(v.parent, v.roi, v.overlap)] -function count_per_instance(v::SpatialElementView{<:SpatialPoints}) +function count_per_instance(v::SpatialElementView{<:SpatialPoints}; + feature::Union{Nothing,AbstractString}=nothing) mask = _mask(v.parent, v.roi, v.overlap) + feature_index = if feature === nothing + nothing + else + index = findfirst(==(feature), v.parent.feature_codebook) + index === nothing && throw(ArgumentError( + "feature $(repr(feature)) not found; available: $(v.parent.feature_codebook)", + )) + Int32(index) + end counts = Dict{Int32, Int}() for (i, id) in enumerate(v.parent.instance_id) mask[i] || continue + feature_index === nothing || v.parent.feature_id[i] == feature_index || continue id == Int32(0) && continue counts[id] = get(counts, id, 0) + 1 end @@ -365,14 +559,42 @@ end function images(v::SpatialDatasetView, name::String) el = v.parent.elements[name] el isa SpatialImage || error("Element \"$name\" is not SpatialImage (got $(typeof(el)))") - ext = v.roi isa SpatialExtent ? v.roi : v.roi.extent - Base.view(el, ext) + Base.view(el, _view_extent(v.parent, v.roi)) +end + +function images(v::SpatialDatasetView{AcquisitionSelection}, name::String) + element = v.parent.elements[name] + element isa SpatialImage || error( + "Element \"$name\" is not SpatialImage (got $(typeof(element)))", + ) + tiles = [ + view(element, _source_roi(v.parent, acquisition).extent) + for acquisition in v.roi.sources + ] + SpatialRasterTiles(tiles, [acquisition.name for acquisition in v.roi.sources]) end +_view_extent(::SpatialDataset, ext::SpatialExtent) = ext +_view_extent(::SpatialDataset, roi::SpatialROI) = roi.extent +_view_extent(ds::SpatialDataset, acquisition::AcquisitionSource) = + _source_roi(ds, acquisition).extent + function labels(v::SpatialDatasetView, name::String) el = v.parent.elements[name] el isa SpatialLabels || error("Element \"$name\" is not SpatialLabels (got $(typeof(el)))") - el # labels are rasters — no spatial element view; return as-is + Base.view(el, _view_extent(v.parent, v.roi)) +end + +function labels(v::SpatialDatasetView{AcquisitionSelection}, name::String) + element = v.parent.elements[name] + element isa SpatialLabels || error( + "Element \"$name\" is not SpatialLabels (got $(typeof(element)))", + ) + tiles = [ + view(element, _source_roi(v.parent, acquisition).extent) + for acquisition in v.roi.sources + ] + SpatialRasterTiles(tiles, [acquisition.name for acquisition in v.roi.sources]) end function tables(v::SpatialDatasetView, name::String) diff --git a/src/zarr_io.jl b/src/zarr_io.jl index 10500f1..6c10510 100644 --- a/src/zarr_io.jl +++ b/src/zarr_io.jl @@ -3,7 +3,7 @@ """ SpatialDataZarr() -Format token for the native SpatialData OME-Zarr on-disk format. +Format token for the native SpatialOmics Zarr on-disk format. Pass to `read` or `write!` to select this backend: @@ -12,14 +12,47 @@ ds = read(SpatialDataZarr(), "/path/to/experiment.zarr") write!(ds, "/path/to/output.zarr", SpatialDataZarr()) ``` -`read` auto-detects whether the Zarr store was written by Python's SpatialData -library or by this package and dispatches accordingly. +`read` auto-detects native stores and supported Python SpatialData stores. +Native stores are not presented as Python-compatible SpatialData exports; +validated interchange is a separate conversion boundary. # See also [`CosMx`](@ref), [`write!`](@ref) """ struct SpatialDataZarr end +const NATIVE_FORMAT_VERSION = 1 + +""" + native_store_version(path) -> Union{Int,Nothing} + +Return the native SpatialOmics format version recorded at `path`. A native +store without a version returns `nothing`. Python SpatialData stores are not +native SpatialOmics stores and also return `nothing`. +""" +function native_store_version(path::AbstractString) + meta_path = joinpath(path, "spatialomics_meta.json") + isfile(meta_path) || return nothing + meta = JSON.parse(read(meta_path, String)) + version = get(meta, "format_version", nothing) + version === nothing ? nothing : Int(version) +end + +function _require_native_store_version(path::String, meta::AbstractDict) + version = get(meta, "format_version", nothing) + version === nothing && throw(ArgumentError( + "native SpatialOmics store $(repr(path)) predates format versioning and cannot " * + "be opened safely; rebuild it from the original input into a new cache path. " * + "SpatialOmics does not upgrade stores automatically", + )) + Int(version) == NATIVE_FORMAT_VERSION || throw(ArgumentError( + "native SpatialOmics store $(repr(path)) has format version $version; this " * + "SpatialOmics release supports version $NATIVE_FORMAT_VERSION. Rebuild the " * + "store from the original input or use an explicit compatible upgrade tool", + )) + nothing +end + # ── Low-level zarr helpers ───────────────────────────────────────────────────── function _write_group_meta(path::String, attrs::AbstractDict=Dict{String,Any}()) @@ -85,26 +118,47 @@ function _write_zarr(root::String, name::String, pts::SpatialPoints{T}) where T open(joinpath(grp, "feature_codebook.json"), "w") do io JSON.print(io, pts.feature_codebook) end + if pts.origin_id !== nothing + _write_zarr_array(grp, "origin_id", pts.origin_id) + open(joinpath(grp, "origin_codebook.json"), "w") do io + JSON.print(io, pts.origin_codebook) + end + end + if pts.feature_columns !== nothing + _write_named_tuple(joinpath(grp, "feature_columns"), pts.feature_columns) + end end # ── Write SpatialShapes ──────────────────────────────────────────────────────── +_geometry_storage_kind(::SpatialShapes{<:Polygon}) = "polygon" +_geometry_storage_kind(::SpatialShapes{<:MultiPolygon}) = "multipolygon" + function _write_zarr(root::String, name::String, shp::SpatialShapes) grp = joinpath(root, "shapes", name) mkpath(grp) _write_group_meta(grp, Dict( "_spatialdata_attrs" => Dict( "type" => "shapes", - "coord_system" => shp.coord_system))) + "coord_system" => shp.coord_system, + "geometry_type" => _geometry_storage_kind(shp)))) _write_zarr_array(grp, "instance_id", shp.instance_id) + if shp.origin_id !== nothing + _write_zarr_array(grp, "origin_id", shp.origin_id) + open(joinpath(grp, "origin_codebook.json"), "w") do io + JSON.print(io, shp.origin_codebook) + end + end + + _write_shape_geometries(grp, shp.geometries) +end - # Ragged CSR layout: polygons → rings → points - # poly_offsets[i] = 0-based index of first ring for polygon i (Julia 1-based) - # ring_offsets[r] = 0-based index of first point for ring r (Julia 1-based) +# Ragged CSR layout: shapes → rings → points. +function _write_shape_geometries(grp::String, geometries::Vector{<:Polygon}) total_pts = 0 total_rings = 0 - for g in shp.geometries + for g in geometries rings = GeoInterface.coordinates(g) total_rings += length(rings) for ring in rings @@ -114,12 +168,12 @@ function _write_zarr(root::String, name::String, shp::SpatialShapes) geom_data = Matrix{Float64}(undef, total_pts, 2) ring_offsets = Vector{Int64}(undef, total_rings + 1) - poly_offsets = Vector{Int64}(undef, length(shp) + 1) + poly_offsets = Vector{Int64}(undef, length(geometries) + 1) pt_idx = 0 ring_idx = 0 poly_offsets[1] = 0 - for (pi, g) in enumerate(shp.geometries) + for (pi, g) in enumerate(geometries) for ring in GeoInterface.coordinates(g) ring_offsets[ring_idx + 1] = pt_idx for pt in ring @@ -138,6 +192,50 @@ function _write_zarr(root::String, name::String, shp::SpatialShapes) _write_zarr_array(grp, "poly_offsets", poly_offsets) end +# Multipolygons add one offset level: shapes → polygon components → rings → points. +function _write_shape_geometries(grp::String, geometries::Vector{<:MultiPolygon}) + components = [polygon for multi in geometries for polygon in GeoInterface.getgeom(multi)] + component_offsets = Vector{Int64}(undef, length(geometries) + 1) + component_offsets[1] = 0 + component_index = 0 + for (shape_index, multi) in enumerate(geometries) + component_index += GeoInterface.ngeom(multi) + component_offsets[shape_index + 1] = component_index + end + + total_rings = sum(length(GeoInterface.coordinates(polygon)) for polygon in components) + total_points = sum( + length(ring) + for polygon in components + for ring in GeoInterface.coordinates(polygon) + ) + geometry_data = Matrix{Float64}(undef, total_points, 2) + ring_offsets = Vector{Int64}(undef, total_rings + 1) + polygon_offsets = Vector{Int64}(undef, length(components) + 1) + + point_index = 0 + ring_index = 0 + polygon_offsets[1] = 0 + for (polygon_index, polygon) in enumerate(components) + for ring in GeoInterface.coordinates(polygon) + ring_offsets[ring_index + 1] = point_index + for point in ring + point_index += 1 + geometry_data[point_index, 1] = Float64(point[1]) + geometry_data[point_index, 2] = Float64(point[2]) + end + ring_index += 1 + end + polygon_offsets[polygon_index + 1] = ring_index + end + ring_offsets[end] = point_index + + _write_zarr_array(grp, "geom_data", geometry_data) + _write_zarr_array(grp, "ring_offsets", ring_offsets) + _write_zarr_array(grp, "poly_offsets", polygon_offsets) + _write_zarr_array(grp, "component_offsets", component_offsets) +end + # ── Read SpatialPoints ───────────────────────────────────────────────────────── function _read_points_zarr(grp::String) :: SpatialPoints{Float32} @@ -155,7 +253,20 @@ function _read_points_zarr(grp::String) :: SpatialPoints{Float32} meta = JSON.parse(read(joinpath(grp, "zarr.json"), String)) cs = meta["attributes"]["_spatialdata_attrs"]["coord_system"] - SpatialPoints{Float32}(coords, feature_id, codebook, instance_id, nothing, cs, nothing) + columns_path = joinpath(grp, "feature_columns") + feature_columns = isdir(columns_path) ? _read_named_tuple(columns_path) : nothing + + origin_path = joinpath(grp, "origin_id") + origin_id = isdir(origin_path) ? + Vector{Int32}(zopen(origin_path, "r"; zarr_format=3)[:]) : nothing + origin_codebook_path = joinpath(grp, "origin_codebook.json") + origin_codebook = isfile(origin_codebook_path) ? + convert(Vector{String}, JSON.parse(read(origin_codebook_path, String))) : String[] + + SpatialPoints{Float32}( + coords, feature_id, codebook, instance_id, feature_columns, + origin_id, origin_codebook, cs, nothing, + ) end # ── Read SpatialShapes ───────────────────────────────────────────────────────── @@ -166,24 +277,68 @@ function _read_shapes_zarr(grp::String) :: SpatialShapes ring_offsets = Vector{Int64}(zopen(joinpath(grp, "ring_offsets"), "r"; zarr_format=3)[:]) poly_offsets = Vector{Int64}(zopen(joinpath(grp, "poly_offsets"), "r"; zarr_format=3)[:]) - n = length(instance_id) - geometries = Vector{Polygon}(undef, n) - for pi in 1:n - r_start = poly_offsets[pi] + 1 # 0-based offset → Julia 1-based start - r_end = poly_offsets[pi + 1] # 0-based exclusive = Julia 1-based end - rings = Vector{Vector{Point2f}}(undef, r_end - r_start + 1) - for (ri, r_idx) in enumerate(r_start:r_end) - pt_start = ring_offsets[r_idx] + 1 - pt_end = ring_offsets[r_idx + 1] - rings[ri] = [Point2f(geom_data[j, 1], geom_data[j, 2]) for j in pt_start:pt_end] - end - geometries[pi] = length(rings) == 1 ? Polygon(rings[1]) : Polygon(rings[1], rings[2:end]) + geometries = if isdir(joinpath(grp, "component_offsets")) + component_offsets = Vector{Int64}( + zopen(joinpath(grp, "component_offsets"), "r"; zarr_format=3)[:], + ) + _read_multipolygons(geom_data, ring_offsets, poly_offsets, component_offsets) + else + _read_polygons(geom_data, ring_offsets, poly_offsets) end meta = JSON.parse(read(joinpath(grp, "zarr.json"), String)) cs = meta["attributes"]["_spatialdata_attrs"]["coord_system"] - SpatialShapes(geometries; instance_id, coord_system=cs) + origin_path = joinpath(grp, "origin_id") + origin_id = isdir(origin_path) ? + Vector{Int32}(zopen(origin_path, "r"; zarr_format=3)[:]) : nothing + origin_codebook_path = joinpath(grp, "origin_codebook.json") + origin_codebook = isfile(origin_codebook_path) ? + convert(Vector{String}, JSON.parse(read(origin_codebook_path, String))) : String[] + + SpatialShapes(geometries; instance_id, origin_id, origin_codebook, coord_system=cs) +end + +function _read_polygon(geom_data, ring_offsets, first_ring::Int, last_ring::Int) + rings = Vector{Vector{Point2f}}(undef, last_ring - first_ring + 1) + for (output_index, ring_index) in enumerate(first_ring:last_ring) + point_start = ring_offsets[ring_index] + 1 + point_end = ring_offsets[ring_index + 1] + rings[output_index] = [ + Point2f(geom_data[j, 1], geom_data[j, 2]) for j in point_start:point_end + ] + end + length(rings) == 1 ? Polygon(rings[1]) : Polygon(rings[1], rings[2:end]) +end + +function _read_polygons(geom_data, ring_offsets, polygon_offsets) + polygon_count = length(polygon_offsets) - 1 + [ + _read_polygon( + geom_data, + ring_offsets, + polygon_offsets[index] + 1, + polygon_offsets[index + 1], + ) + for index in 1:polygon_count + ] +end + +function _read_multipolygons(geom_data, ring_offsets, polygon_offsets, component_offsets) + shape_count = length(component_offsets) - 1 + [ + MultiPolygon([ + _read_polygon( + geom_data, + ring_offsets, + polygon_offsets[component_index] + 1, + polygon_offsets[component_index + 1], + ) + for component_index in + (component_offsets[shape_index] + 1):component_offsets[shape_index + 1] + ]) + for shape_index in 1:shape_count + ] end # ── Transform serialization helpers ─────────────────────────────────────────── @@ -195,16 +350,25 @@ _transform_to_dict(t::Affine) = Dict("type" => "affine", "src" => t.src, "dst" => t.dst, "matrix" => [collect(t.matrix[i, :]) for i in 1:3]) -_transform_to_dict(::AbstractTransformation) = - Dict("type" => "identity", "src" => "", "dst" => "") +_transform_to_dict(t::Sequence) = Dict( + "type" => "sequence", + "src" => t.src, + "dst" => t.dst, + "steps" => [_transform_to_dict(step) for step in t.steps], +) function _transform_from_dict(d) if d["type"] == "affine" rows = d["matrix"] mat = SMatrix{3,3,Float64}(Float64(rows[i][j]) for i in 1:3, j in 1:3) Affine(mat, d["src"], d["dst"]) - else + elseif d["type"] == "sequence" + Sequence(AbstractTransformation[_transform_from_dict(step) for step in d["steps"]], + String(d["src"]), String(d["dst"])) + elseif d["type"] == "identity" Identity(d["src"], d["dst"]) + else + throw(ArgumentError("unsupported transformation type $(repr(d["type"]))")) end end @@ -235,7 +399,7 @@ function ensure_pyramid!(img::SpatialImage{T, N}, n_levels::Int=3) where {T, N} @info "Building $(n_levels)-level pyramid for $(basename(grp_path))…" current = Array{T}(img.data) for i in 1:n_levels - current = T.(restrict(current, sdims)) + current = _pyramid_storage(T, restrict(current, sdims)) _write_zarr_array(grp_path, "level$i", current) push!(img.pyramid, zopen(joinpath(grp_path, "level$i"), "r"; zarr_format=3)) end @@ -341,8 +505,6 @@ function _kind_meta(kind::Membership{strict}) where strict end _kind_meta(::Expression) = Dict("kind" => "Expression") -_write_zarr(::String, ::String, ::Any) = nothing # SpatialTable and future types not yet serialized - function _write_zarr_relation(root::String, name::String, rel::SpatialRelation) grp = joinpath(root, "relations", name) mkpath(grp) @@ -392,9 +554,12 @@ end # ── Dataset write ────────────────────────────────────────────────────────────── -function _write_metadata_entry(root::String, key::String, val::NamedTuple) - grp = joinpath(root, "metadata", key) +function _write_named_tuple(grp::String, val::NamedTuple) mkpath(grp) + lengths = Int[length(column) for column in values(val)] + isempty(lengths) || all(==(first(lengths)), lengths) || throw(ArgumentError( + "all columns in a named tuple must have the same length", + )) for (field, vec) in pairs(val) fname = string(field) if vec isa AbstractVector{<:Real} @@ -407,11 +572,42 @@ function _write_metadata_entry(root::String, key::String, val::NamedTuple) open(joinpath(grp, fname * "_codebook.json"), "w") do io JSON.print(io, codebook) end + else + throw(ArgumentError( + "cannot persist column $(repr(field)) with type $(typeof(vec)); " * + "expected a real-valued or string-valued vector", + )) end end open(joinpath(grp, "type.json"), "w") do io JSON.print(io, Dict("type" => "named_tuple")) end + nothing +end + +function _read_named_tuple(grp::String) + fields = Symbol[] + vecs = AbstractVector[] + for entry in readdir(grp) + entry == "type.json" && continue + endswith(entry, "_codebook.json") && continue + !isdir(joinpath(grp, entry)) && continue + codebook_path = joinpath(grp, entry * "_codebook.json") + ids = zopen(joinpath(grp, entry), "r"; zarr_format=3)[:] + if isfile(codebook_path) + codebook = convert(Vector{String}, JSON.parse(read(codebook_path, String))) + push!(fields, Symbol(entry)) + push!(vecs, [codebook[id + 1] for id in ids]) + else + push!(fields, Symbol(entry)) + push!(vecs, ids) + end + end + NamedTuple{Tuple(fields)}(vecs) +end + +function _write_metadata_entry(root::String, key::String, val::NamedTuple) + _write_named_tuple(joinpath(root, "metadata", key), val) end function _write_metadata_entry(root::String, key::String, val) @@ -437,26 +633,7 @@ function _read_user_metadata!(ds::SpatialDataset, path::String) isdir(grp) || continue type_path = joinpath(grp, "type.json") if isfile(type_path) && get(JSON.parse(read(type_path, String)), "type", "") == "named_tuple" - fields = Symbol[] - vecs = AbstractVector[] - for entry in readdir(grp) - entry == "type.json" && continue - endswith(entry, "_codebook.json") && continue - !isdir(joinpath(grp, entry)) && continue - codebook_path = joinpath(grp, entry * "_codebook.json") - ids = zopen(joinpath(grp, entry), "r"; zarr_format=3)[:] - if isfile(codebook_path) - codebook = convert(Vector{String}, - JSON.parse(read(codebook_path, String))) - push!(fields, Symbol(entry)) - push!(vecs, [codebook[id+1] for id in ids]) - else - push!(fields, Symbol(entry)) - push!(vecs, ids) - end - end - isempty(fields) && continue - ds.metadata.data[key] = NamedTuple{Tuple(fields)}(vecs) + ds.metadata.data[key] = _read_named_tuple(grp) elseif isfile(joinpath(grp, "value.json")) ds.metadata.data[key] = JSON.parse(read(joinpath(grp, "value.json"), String)) end @@ -466,12 +643,23 @@ end function _write_spatialomics_meta(ds::SpatialDataset, path::String) open(joinpath(path, "spatialomics_meta.json"), "w") do io JSON.print(io, Dict( + "format_version" => NATIVE_FORMAT_VERSION, "coord_systems" => [ Dict("name" => cs.name, "axes" => collect(string.(cs.axes)), "units" => collect(cs.units)) for cs in values(ds.coord_systems)], - "transforms" => [_transform_to_dict(t) for t in ds.transforms])) + "transforms" => [_transform_to_dict(t) for t in ds.transforms], + "sources" => [ + Dict( + "name" => acquisition.name, + "region_element" => acquisition.region_element, + "region_id" => acquisition.region_id, + "attributes" => acquisition.attributes, + ) + for acquisition in values(ds.sources) + ], + )) end end @@ -493,50 +681,306 @@ function _write_dataset_zarr(ds::SpatialDataset, path::String) path end +_element_group(::SpatialPoints) = "points" +_element_group(::SpatialShapes) = "shapes" +_element_group(::SpatialImage) = "images" +_element_group(::SpatialLabels) = "labels" + +function _atomic_replace(source::String, target::String) + mkpath(dirname(target)) + backup = ispath(target) ? tempname(dirname(target)) : nothing + backup === nothing || mv(target, backup) + try + mv(source, target) + catch + if backup !== nothing && ispath(backup) && !ispath(target) + mv(backup, target) + end + rethrow() + end + backup === nothing || rm(backup; recursive=true, force=true) + target +end + +function _write_dataset_atomic(ds::SpatialDataset, path::String) + target = abspath(path) + mkpath(dirname(target)) + staging = mktempdir(dirname(target); prefix=".spatialomics-save-") + try + _write_dataset_zarr(ds, staging) + _atomic_replace(staging, target) + finally + isdir(staging) && rm(staging; recursive=true, force=true) + end + target +end + +function _remove_element_paths!(root::String, name::String; except::Union{Nothing,String}=nothing) + for group in ("points", "shapes", "images", "labels") + group == except && continue + path = joinpath(root, group, name) + ispath(path) && rm(path; recursive=true, force=true) + end + nothing +end + +function _save_element!(ds::SpatialDataset, name::String) + element = ds.elements[name] + group = _element_group(element) + staging = mktempdir(dirname(ds.backing.path); prefix=".spatialomics-element-") + try + _init_zarr_root(staging) + _write_zarr(staging, name, element) + _atomic_replace(joinpath(staging, group, name), joinpath(ds.backing.path, group, name)) + _remove_element_paths!(ds.backing.path, name; except=group) + finally + isdir(staging) && rm(staging; recursive=true, force=true) + end + nothing +end + +function _save_relation!(ds::SpatialDataset, name::String) + staging = mktempdir(dirname(ds.backing.path); prefix=".spatialomics-relation-") + try + _init_zarr_root(staging) + _write_zarr_relation(staging, name, ds.relations[name]) + _atomic_replace( + joinpath(staging, "relations", name), + joinpath(ds.backing.path, "relations", name), + ) + finally + isdir(staging) && rm(staging; recursive=true, force=true) + end + nothing +end + +function _save_metadata!(ds::SpatialDataset, name::String) + staging = mktempdir(dirname(ds.backing.path); prefix=".spatialomics-metadata-") + try + _init_zarr_root(staging) + _write_metadata_entry(staging, name, ds.metadata[name]) + _atomic_replace( + joinpath(staging, "metadata", name), + joinpath(ds.backing.path, "metadata", name), + ) + finally + isdir(staging) && rm(staging; recursive=true, force=true) + end + nothing +end + +function _save_dataset_metadata!(ds::SpatialDataset) + staging = mktempdir(dirname(ds.backing.path); prefix=".spatialomics-metadata-") + try + _init_zarr_root(staging) + _write_spatialomics_meta(ds, staging) + _atomic_replace( + joinpath(staging, "spatialomics_meta.json"), + joinpath(ds.backing.path, "spatialomics_meta.json"), + ) + finally + isdir(staging) && rm(staging; recursive=true, force=true) + end + nothing +end + +function _save_change!(ds::SpatialDataset, key::Tuple{Symbol,String}, state::Symbol) + kind, name = key + if state === :deleted + if kind === :element + _remove_element_paths!(ds.backing.path, name) + elseif kind === :relation + rm(joinpath(ds.backing.path, "relations", name); recursive=true, force=true) + elseif kind === :metadata + rm(joinpath(ds.backing.path, "metadata", name); recursive=true, force=true) + end + elseif kind === :element + _save_element!(ds, name) + elseif kind === :relation + _save_relation!(ds, name) + elseif kind === :metadata + _save_metadata!(ds, name) + elseif kind === :dataset + _save_dataset_metadata!(ds) + end + nothing +end + function Base.write(ds::SpatialDataset, path::String, ::SpatialDataZarr) - ds.backing.owned && @warn "Backing store is still at temp path \"$(ds.backing.path)\". " * - "Call write!(ds, path, SpatialDataZarr()) to also update the dataset location." - _write_dataset_zarr(ds, path) + abspath(path) == ds.backing.path && throw(ArgumentError( + "cannot export over the active backing store; call save!(ds) instead", + )) + _write_dataset_atomic(ds, path) +end + +function _save_all!(ds::SpatialDataset) + _ensure_open(ds.backing) + selected = collect(ds.backing.changes) + for (key, state) in selected + _save_change!(ds, key, state) + end + for (key, _) in selected + delete!(ds.backing.changes, key) + end + ds end """ - write!(ds, path, SpatialDataZarr()) → ds + save!(ds) → ds + save!(ds, name) → ds + save!(ds; path) → ds -Write `ds` to the SpatialData OME-Zarr format at `path` and update the -dataset's backing store to point at the new location. +Persist staged changes. With no second argument, all changes are written to the +current backing store. When the positional argument names an element, relation, +or metadata entry, only matching changes are written. When `path` differs from +the current backing location, a complete snapshot is written atomically and the +dataset is rebound to that permanent location. -Unlike the non-mutating `write`, `write!` marks the backing store as permanent -(non-owned) so the directory is not deleted when `ds` is garbage collected. -Use this as the canonical "save" operation. +The dirty registry is cleared only after every selected write succeeds. # See also -[`SpatialDataZarr`](@ref), [`keep!`](@ref) +[`dirty`](@ref), [`discard!`](@ref), [`edit!`](@ref) """ -function write!(ds::SpatialDataset, path::String, ::SpatialDataZarr) - _write_dataset_zarr(ds, path) - ds.backing.path = abspath(path) +function save!(ds::SpatialDataset, name::String) + _ensure_open(ds.backing) + selected = [entry for entry in ds.backing.changes if entry[1][2] == name] + known = haskey(ds.elements, name) || haskey(ds.relations, name) || + haskey(ds.metadata, name) || name == "coordinate_systems" + isempty(selected) && !known && throw(KeyError(name)) + for (key, state) in selected + _save_change!(ds, key, state) + end + for (key, _) in selected + delete!(ds.backing.changes, key) + end + ds +end + +function save!(ds::SpatialDataset; path::Union{Nothing,String}=nothing) + _ensure_open(ds.backing) + path === nothing && return _save_all!(ds) + target = abspath(path) + target == ds.backing.path && return _save_all!(ds) + old_path = ds.backing.path + old_owned = ds.backing.owned + relative_target = relpath(target, old_path) + relative_parts = splitpath(relative_target) + if old_owned && !isempty(relative_parts) && first(relative_parts) != ".." + throw(ArgumentError("cannot save a temporary dataset inside its own backing directory")) + end + _write_dataset_atomic(ds, target) + close.(values(ds.backing.handles)) + empty!(ds.backing.handles) + ds.backing.path = target ds.backing.owned = false + empty!(ds.backing.changes) + old_owned && old_path != target && rm(old_path; recursive=true, force=true) + ds +end + +""" + discard!(ds) → ds + discard!(ds, name) → ds + +Replace staged changes with the corresponding values from the backing store. +With no name, all staged changes are discarded. A named call affects every +changed artifact with that name. + +# See also +[`save!`](@ref), [`dirty`](@ref) +""" +function discard!(ds::SpatialDataset, name::Union{Nothing,String}=nothing) + _ensure_open(ds.backing) + selected = [entry for entry in ds.backing.changes if name === nothing || entry[1][2] == name] + isempty(selected) && return ds + stored = read(SpatialDataZarr(), ds.backing.path) + try + for ((kind, artifact), _) in selected + if kind === :element + if haskey(stored.elements, artifact) + element = stored.elements[artifact] + haskey(ds.elements, artifact) && _clear_backref!(ds.elements[artifact]) + _set_backref!(element, ds, artifact) + ds.elements[artifact] = element + else + current = pop!(ds.elements, artifact, nothing) + current === nothing || _clear_backref!(current) + end + elseif kind === :relation + if haskey(stored.relations, artifact) + ds.relations[artifact] = stored.relations[artifact] + else + pop!(ds.relations, artifact, nothing) + end + elseif kind === :metadata + if haskey(stored.metadata, artifact) + ds.metadata.data[artifact] = stored.metadata[artifact] + else + pop!(ds.metadata.data, artifact, nothing) + end + elseif kind === :dataset + ds.coord_systems = copy(stored.coord_systems) + ds.transforms = copy(stored.transforms) + ds.sources = copy(stored.sources) + end + end + finally + close(stored) + end + for (key, _) in selected + delete!(ds.backing.changes, key) + end ds end +""" + write!(ds, path, SpatialDataZarr()) → ds + +Compatibility spelling for `save!(ds; path)`. Write a complete native snapshot +at `path` and update the dataset's backing store to point at the new location. + +New code should prefer [`save!`](@ref). The non-mutating `write` exports a +snapshot without changing the active backing location or dirty state. + +# See also +[`SpatialDataZarr`](@ref), [`save!`](@ref), [`keep!`](@ref) +""" +function write!(ds::SpatialDataset, path::String, ::SpatialDataZarr) + save!(ds; path) +end + # ── Dataset read ─────────────────────────────────────────────────────────────── function Base.read(::SpatialDataZarr, path::String) :: SpatialDataset isdir(path) || error("Path not found: $path") _is_python_spatialdata(path) && return _read_python_spatialdata(path) + meta_path = joinpath(path, "spatialomics_meta.json") + isfile(meta_path) || throw(ArgumentError( + "path $(repr(path)) is neither a supported Python SpatialData store nor a " * + "native SpatialOmics store", + )) + meta = JSON.parse(read(meta_path, String)) + _require_native_store_version(path, meta) ds = SpatialDataset(; path) - meta_path = joinpath(path, "spatialomics_meta.json") - if isfile(meta_path) - meta = JSON.parse(read(meta_path, String)) - for cs in get(meta, "coord_systems", []) - ds.coord_systems[cs["name"]] = CoordinateSystem(cs["name"]; - axes = Tuple(Symbol.(cs["axes"])), - units = Tuple(String.(cs["units"]))) - end - for t in get(meta, "transforms", []) - push!(ds.transforms, _transform_from_dict(t)) - end + for cs in get(meta, "coord_systems", []) + ds.coord_systems[cs["name"]] = CoordinateSystem(cs["name"]; + axes = Tuple(Symbol.(cs["axes"])), + units = Tuple(String.(cs["units"]))) + end + for t in get(meta, "transforms", []) + push!(ds.transforms, _transform_from_dict(t)) + end + for acquisition in get(meta, "sources", []) + region_element = get(acquisition, "region_element", nothing) + region_id = get(acquisition, "region_id", nothing) + registered = AcquisitionSource( + acquisition["name"]; + region=region_element, + instance_id=region_id, + attributes=get(acquisition, "attributes", Dict{String,Any}()), + ) + ds.sources[registered.name] = registered end for (subdir, reader) in (("points", _read_points_zarr), @@ -544,7 +988,9 @@ function Base.read(::SpatialDataZarr, path::String) :: SpatialDataset ("images", _read_image_zarr), ("labels", _read_labels_zarr)) for name in _zarr_element_names(path, subdir) - ds.elements[name] = reader(joinpath(path, subdir, name)) + element = reader(joinpath(path, subdir, name)) + _set_backref!(element, ds, name) + ds.elements[name] = element end end for name in _zarr_element_names(path, "relations") @@ -822,14 +1268,14 @@ end # ── Python SpatialData dataset reader ───────────────────────────────────────── function _read_python_spatialdata(path::String) - ds = SpatialDataset(; path) + ds = SpatialDataset() str_id_maps = Dict{String, Dict{Int32, String}}() for (kind, reader) in (("images", _read_ome_image_zarr_py), ("labels", _read_ome_labels_zarr_py)) for name in _zarr_element_names(path, kind) try - ds.elements[name] = reader(joinpath(path, kind, name)) + ds[name] = reader(joinpath(path, kind, name)) catch e @warn "Could not read $kind \"$name\": $e" end @@ -838,7 +1284,7 @@ function _read_python_spatialdata(path::String) for name in _zarr_element_names(path, "tables") try - ds.relations[name] = _read_anndata_table_zarr(joinpath(path, "tables", name)) + ds[name] = _read_anndata_table_zarr(joinpath(path, "tables", name)) catch e @warn "Could not read table \"$name\": $e" end @@ -847,7 +1293,7 @@ function _read_python_spatialdata(path::String) for name in _zarr_element_names(path, "shapes") try el, id_map = _read_shapes_parquet(joinpath(path, "shapes", name)) - ds.elements[name] = el + ds[name] = el !isempty(id_map) && (str_id_maps[name] = id_map) catch e @warn "Could not read shapes \"$name\": $e" @@ -856,7 +1302,7 @@ function _read_python_spatialdata(path::String) for name in _zarr_element_names(path, "points") try - ds.elements[name] = _read_points_parquet(joinpath(path, "points", name)) + ds[name] = _read_points_parquet(joinpath(path, "points", name)) catch e @warn "Could not read points \"$name\": $e" end @@ -864,12 +1310,13 @@ function _read_python_spatialdata(path::String) !isempty(str_id_maps) && (ds.metadata["_instance_id_str_map"] = str_id_maps) # Register coord systems inferred from element metadata (Python format lacks explicit registry). - # Bypass push!(ds, ...) to avoid writing spatialomics_meta.json into the Python SpatialData store. + # Insert them in a batch, then mark the dataset-level metadata once. for (_, el) in ds.elements cs = coord_system(el) isempty(cs) && continue haskey(ds.coord_systems, cs) || (ds.coord_systems[cs] = CoordinateSystem(cs)) end + _mark_dirty!(ds.backing, (:dataset, "coordinate_systems")) ds end @@ -890,9 +1337,10 @@ ds = read(CosMx(), "/path/to/cosmx_export/") ds = read(CosMx(morphology_dir="/path/to/Morphology2D"), "/path/to/cosmx_export/") ``` -Each field-of-view (FOV) is registered as a separate `CoordinateSystem`; -use `coord_systems(ds)` and `transform(ds, fov_cs, "global")` to navigate -between spaces. +Each field of view is registered both as a `CoordinateSystem` such as +`"fov_1_px"` and as an [`AcquisitionSource`](@ref) linked to its footprint. +Use `view(ds, "fov_1_px")` for provenance-aware selection and +`transform(ds, "fov_1_px", "global_px")` to navigate between spaces. # See also [`SpatialDataZarr`](@ref) @@ -1139,6 +1587,9 @@ function Base.read(fmt::CosMx, path::String; codebook = sort(unique(all_feat)) feat_to_id = Dict(g => Int32(i) for (i, g) in enumerate(codebook)) feat_ids = Int32[feat_to_id[f] for f in all_feat] + source_names = ["fov_$(f)_px" for f in fov_ids] + source_to_id = Dict(f => Int32(i) for (i, f) in enumerate(fov_ids)) + transcript_origin_ids = Int32[source_to_id[Int(f)] for f in ann_fov] # ── Cell polygons ───────────────────────────────────────────────────────── poly_tbl = _gz_csv(_cosmx_find(run_dir, "-polygons.csv.gz")) @@ -1169,7 +1620,14 @@ function Base.read(fmt::CosMx, path::String; push!(inst, Int32(i)) end - cells = SpatialShapes(polys; instance_id=inst, coord_system="global_px") + cell_origin_ids = Int32[source_to_id[first(key)] for key in cell_keys] + cells = SpatialShapes( + polys; + instance_id=inst, + origin_id=cell_origin_ids, + origin_codebook=source_names, + coord_system="global_px", + ) # Remap transcript instance_ids now that global_id map is available all_inst = Int32[ann_cell_id[i] == Int32(0) ? Int32(0) : @@ -1181,6 +1639,14 @@ function Base.read(fmt::CosMx, path::String; feature_id = feat_ids, feature_codebook = codebook, instance_id = all_inst, + features = ( + fov=ann_fov, + z=ann_z, + CellComp=ann_comp, + cell_ID=ann_cell_id, + ), + origin_id = transcript_origin_ids, + origin_codebook = source_names, coord_system = "global_px") # ── Assemble dataset ────────────────────────────────────────────────────── @@ -1210,12 +1676,19 @@ function Base.read(fmt::CosMx, path::String; Point2f(ox, oy), Point2f(ox, oy - fov_h)]) end for f in fov_ids]; - instance_id = Int32.(fov_ids), coord_system = "global_px") + instance_id = Int32.(fov_ids), + origin_id = Int32.(eachindex(fov_ids)), + origin_codebook = source_names, + coord_system = "global_px") - ds.metadata["transcripts_annotations"] = ( - fov = ann_fov, - z = ann_z, - CellComp = ann_comp) + for f in fov_ids + push!(ds, AcquisitionSource( + "fov_$(f)_px"; + region="fovs", + instance_id=f, + attributes=Dict("technology" => "CosMx", "native_id" => f), + )) + end if fmt.morphology_dir !== nothing morph2d = _find_morphology2d(fmt.morphology_dir) @@ -1229,7 +1702,7 @@ function Base.read(fmt::CosMx, path::String; end end - cache !== nothing && _write_spatialomics_meta(ds, ds.backing.path) + cache !== nothing && save!(ds) ds end diff --git a/test/data/visium_small.zarr/spatialomics_meta.json b/test/data/visium_small.zarr/spatialomics_meta.json index c0cb2d4..db2d97b 100644 --- a/test/data/visium_small.zarr/spatialomics_meta.json +++ b/test/data/visium_small.zarr/spatialomics_meta.json @@ -1 +1 @@ -{"coord_systems":[{"axes":["x","y"],"name":"Visium_HD_Mouse_Small_Intestine","units":["µm","µm"]},{"axes":["x","y"],"name":"Visium_HD_Mouse_Small_Intestine_downscaled_lowres","units":["µm","µm"]}]} \ No newline at end of file +{"format_version":1,"coord_systems":[{"axes":["x","y"],"name":"Visium_HD_Mouse_Small_Intestine","units":["µm","µm"]},{"axes":["x","y"],"name":"Visium_HD_Mouse_Small_Intestine_downscaled_lowres","units":["µm","µm"]}]} diff --git a/test/data/xenium_small.zarr/spatialomics_meta.json b/test/data/xenium_small.zarr/spatialomics_meta.json index 62a8d5e..f123ec5 100644 --- a/test/data/xenium_small.zarr/spatialomics_meta.json +++ b/test/data/xenium_small.zarr/spatialomics_meta.json @@ -1 +1 @@ -{"coord_systems":[{"axes":["x","y"],"name":"global","units":["µm","µm"]}]} \ No newline at end of file +{"format_version":1,"coord_systems":[{"axes":["x","y"],"name":"global","units":["µm","µm"]}]} diff --git a/test/make_fixtures.jl b/test/make_fixtures.jl index 702fdbc..1159dad 100644 --- a/test/make_fixtures.jl +++ b/test/make_fixtures.jl @@ -1,68 +1,31 @@ # make_fixtures.jl — developer script to create committable test fixtures # # Run from the SpatialOmics.jl repo root: -# julia --project=. test/make_fixtures.jl +# julia --project=docs/heavy test/make_fixtures.jl /path/to/xenium.zarr /path/to/visium.zarr # # Requires the full datasets downloaded from the SpatialData datasets page: -# XENIUM_SRC — Xenium Mouse Brain example (xenium_ex.zarr) +# XENIUM_SRC — Xenium FFPE Human Lung Cancer example (xenium_ex.zarr) # VISIUM_SRC — Visium HD Mouse Small Intestine (visium_ex.zarr) # -# Workflow: -# 1. Run the script once — it saves overview figures and exits. -# 2. Open the overview PNGs (written to docs/src/assets/), pick a region. -# 3. Fill in the coordinate constants below (STEP 2 blocks). -# 4. Re-run — fixture zarrs are written to test/data/. +# The script writes overview figures, native fixtures, and ROI figures. To +# select a different region, inspect the overview assets, update the coordinate +# constants below, and run it again. using SpatialOmics using CairoMakie -using StaticArrays -const XENIUM_SRC = "/home/kevin/Repos/stx_dev/test_data/experiments/xenium_ex.zarr" -const VISIUM_SRC = "/home/kevin/Repos/stx_dev/test_data/experiments/visium_ex.zarr" +length(ARGS) == 2 || error( + "usage: julia --project=docs/heavy test/make_fixtures.jl XENIUM_ZARR VISIUM_ZARR", +) + +const XENIUM_SRC = abspath(ARGS[1]) +const VISIUM_SRC = abspath(ARGS[2]) const XENIUM_OUT = joinpath(@__DIR__, "data", "xenium_small.zarr") const VISIUM_OUT = joinpath(@__DIR__, "data", "visium_small.zarr") const ASSETS = joinpath(@__DIR__, "..", "docs", "src", "assets") mkpath(ASSETS) -# ── Helpers ─────────────────────────────────────────────────────────────────── - -# Maps a physical-space point back to pixel coordinates using the image transform. -function _to_pixel(t::Affine, x::Real, y::Real) - m_inv = inv(Matrix(t.matrix)) - v = m_inv * [Float64(x), Float64(y), 1.0] - (v[1], v[2]) -end -_to_pixel(::Identity, x::Real, y::Real) = (Float64(x), Float64(y)) - -# Clamps a pixel range to valid array bounds (1-based, inclusive). -_px_clamp(lo, hi, n) = (clamp(floor(Int, min(lo, hi)) + 1, 1, n), - clamp(ceil(Int, max(lo, hi)), 1, n)) - -# Adjust pixel_to_cs for a crop: new pixel [1,1] = old pixel [xlo, ylo]. -function _shift_origin(t::Affine, xlo::Int, ylo::Int) - dx, dy = Float64(xlo - 1), Float64(ylo - 1) - S = SMatrix{3,3,Float64}(1, 0, 0, 0, 1, 0, dx, dy, 1) - Affine(t.matrix * S, t.src, t.dst) -end -_shift_origin(t::Identity, ::Int, ::Int) = t - -# Crop a SpatialLabels to a pixel rectangle and filter the instance_map. -<<<<<<< HEAD -# axes are (:x, :y), so dim1=x, dim2=y — index as [xlo:xhi, ylo:yhi]. -function crop_labels(lbl::SpatialLabels, ylo::Int, yhi::Int, xlo::Int, xhi::Int) - raw = Array(lbl.data[xlo:xhi, ylo:yhi]) -======= -function crop_labels(lbl::SpatialLabels, ylo::Int, yhi::Int, xlo::Int, xhi::Int) - raw = Array(lbl.data[ylo:yhi, xlo:xhi]) ->>>>>>> fix - present = Set(raw) - imap = Dict(k => v for (k, v) in lbl.instance_map if k in present) - p2cs = _shift_origin(lbl.pixel_to_cs, xlo, ylo) - SpatialLabels(raw; axes=lbl.axes, instance_map=imap, - coord_system=lbl.coord_system, pixel_to_cs=p2cs) -end - # ══════════════════════════════════════════════════════════════════════════════ # XENIUM # ══════════════════════════════════════════════════════════════════════════════ @@ -73,8 +36,7 @@ isdir(XENIUM_SRC) || error("Xenium source not found: $XENIUM_SRC") xen = read(SpatialDataZarr(), XENIUM_SRC) @info "Loaded" keys(elements(xen)) -# ── STEP 1: Overview figure ─────────────────────────────────────────────────── -# Run this block first, inspect xenium_overview.png, then fill in coordinates. +# ── Overview figure ─────────────────────────────────────────────────────────── let fig = Figure(size=(900, 900)) @@ -89,7 +51,7 @@ let @info "Saved overview → $path — inspect to pick XMIN/XMAX/YMIN/YMAX (coordinates are in µm)" end -# ── STEP 2: Fill in these values after viewing the overview ─────────────────── +# ── Fixture region ──────────────────────────────────────────────────────────── # Choose a ~200µm × 200µm region with good transcript density and visible cells. # coord_system must match points(xen,"transcripts").coord_system. @@ -116,19 +78,10 @@ else # Image: view() returns a lazily cropped SpatialImage with adjusted pixel_to_cs sub["morphology_focus"] = images(roi, "morphology_focus") - # Labels: manual crop (view on SpatialDatasetView returns labels as-is) - img_ref = images(xen, "morphology_focus") - lbl = labels(xen, "cell_labels") - ny, nx = size(lbl.data, findfirst(==(:y), lbl.axes)), - size(lbl.data, findfirst(==(:x), lbl.axes)) - px1 = _to_pixel(img_ref.pixel_to_cs, XEN_XMIN, XEN_YMIN) - px2 = _to_pixel(img_ref.pixel_to_cs, XEN_XMAX, XEN_YMAX) - xlo, xhi = _px_clamp(px1[1], px2[1], nx) - ylo, yhi = _px_clamp(px1[2], px2[2], ny) - sub["cell_labels"] = crop_labels(lbl, ylo, yhi, xlo, xhi) + sub["cell_labels"] = labels(roi, "cell_labels") rm(XENIUM_OUT; recursive=true, force=true) - write!(sub, XENIUM_OUT, SpatialDataZarr()) + save!(sub; path=XENIUM_OUT) @info "Xenium fixture written → $XENIUM_OUT" size=Base.format_bytes( sum(filesize(f) for (r,_,fs) in walkdir(XENIUM_OUT) for f in joinpath.(r,fs))) @@ -143,6 +96,7 @@ else tightlimits!(ax2) save(joinpath(ASSETS, "xenium_roi.png"), fig2) @info "Saved ROI figure → $(joinpath(ASSETS, "xenium_roi.png"))" + close(sub) end # ══════════════════════════════════════════════════════════════════════════════ @@ -167,7 +121,7 @@ let n = length(geometries(shp)) idx = n > 5_000 ? rand(1:n, 5_000) : 1:n poly!(ax, SpatialShapes(geometries(shp)[idx]; instance_id=instance_id(shp)[idx], - coord_system=shp.coord_system); + coord_system=coord_system(shp)); color=:steelblue, strokewidth=0) tightlimits!(ax) path = joinpath(ASSETS, "visium_overview.png") @@ -179,7 +133,7 @@ const VIS_XMIN = 3000.0 const VIS_XMAX = 3500.0 const VIS_YMIN = 2000.0 const VIS_YMAX = 2500.0 -const VIS_CS = shapes(vis, VIS_SHAPES).coord_system # auto-detected from element +const VIS_CS = coord_system(shapes(vis, VIS_SHAPES)) if VIS_XMAX == VIS_XMIN @info "Visium extent not set — fill in VIS_XMIN/XMAX/YMIN/YMAX and re-run" @@ -196,7 +150,7 @@ else sub[VIS_IMAGE] = images(roi, VIS_IMAGE) rm(VISIUM_OUT; recursive=true, force=true) - write!(sub, VISIUM_OUT, SpatialDataZarr()) + save!(sub; path=VISIUM_OUT) @info "Visium fixture written → $VISIUM_OUT" fig2 = Figure(size=(600, 600)) @@ -207,4 +161,8 @@ else tightlimits!(ax2) save(joinpath(ASSETS, "visium_roi.png"), fig2) @info "Saved ROI figure → $(joinpath(ASSETS, "visium_roi.png"))" + close(sub) end + +close(xen) +close(vis) diff --git a/test/persistence.jl b/test/persistence.jl new file mode 100644 index 0000000..d1b5c17 --- /dev/null +++ b/test/persistence.jl @@ -0,0 +1,189 @@ +using GeometryBasics: Point2f + +@testset "Explicit persistence" begin + @testset "changes are staged and selectively saved" begin + mktempdir() do path + ds = SpatialDataset(; path) + @test !isdirty(ds) + + push!(ds, CoordinateSystem("global")) + ds["transcripts"] = SpatialPoints( + [Point2f(1, 2), Point2f(3, 4)]; + features=(fov=Int32[1, 2], compartment=["nucleus", "cytoplasm"]), + coord_system="global", + ) + ds.metadata["sample"] = "A" + + @test isdirty(ds) + @test Set(c.kind for c in dirty(ds)) == Set((:dataset, :element, :metadata)) + @test !isdir(joinpath(path, "points", "transcripts")) + + save!(ds, "transcripts") + @test isdir(joinpath(path, "points", "transcripts")) + @test isdirty(ds) + @test all(c.name != "transcripts" for c in dirty(ds)) + + save!(ds) + @test !isdirty(ds) + + stored = read(SpatialDataZarr(), path) + @test length(points(stored, "transcripts")) == 2 + @test features(points(stored, "transcripts"), :fov) == Int32[1, 2] + @test features(points(stored, "transcripts"), :compartment) == ["nucleus", "cytoplasm"] + @test "global" in coord_systems(stored) + @test stored.metadata["sample"] == "A" + close(stored) + close(ds) + end + end + + @testset "close requires an explicit choice" begin + ds = SpatialDataset() + path = ds.backing.path + ds["points"] = SpatialPoints([Point2f(1, 1)]) + @test_throws ArgumentError close(ds) + @test isdir(path) + close(ds; discard=true) + @test !isdir(path) + end + + @testset "discard restores saved state" begin + mktempdir() do path + ds = SpatialDataset(; path) + ds["points"] = SpatialPoints([Point2f(1, 1), Point2f(2, 2)]) + save!(ds) + + edit!(ds, "points") do points + points.coords[1] = Point2f(9, 9) + end + abandoned = points(ds, "points") + @test isdirty(ds) + discard!(ds, "points") + @test !isdirty(ds) + @test coords(points(ds, "points"))[1] == Point2f(1, 1) + @test SpatialOmics._owning_dataset(abandoned) === nothing + close(ds) + end + end + + @testset "supported mutators mark attached elements dirty" begin + ds = SpatialDataset() + pts = SpatialPoints([Point2f(1, 1)]; coord_system="local") + ds["points"] = pts + save!(ds) + apply!(SpatialOmics.translation(1, 2, "local", "global"), pts) + @test isdirty(ds) + @test coord_system(pts) == "global" + close(ds; discard=true) + end + + @testset "sequence transformations round-trip" begin + mktempdir() do path + ds = SpatialDataset(; path) + push!(ds, CoordinateSystem("a")) + push!(ds, CoordinateSystem("b")) + push!(ds, CoordinateSystem("c")) + first_step = SpatialOmics.translation(1, 0, "a", "b") + second_step = SpatialOmics.translation(0, 2, "b", "c") + push!(ds, Sequence([first_step, second_step], "a", "c")) + save!(ds) + + stored = read(SpatialDataZarr(), path) + @test only(stored.transforms) isa Sequence + @test apply(only(stored.transforms), [0.0 0.0]) ≈ [1.0 2.0] + close(stored) + close(ds) + end + end + + @testset "deletion and cross-type replacement remove stale storage" begin + mktempdir() do path + ds = SpatialDataset(; path) + ds["object"] = SpatialPoints([Point2f(1, 1)]) + save!(ds) + @test isdir(joinpath(path, "points", "object")) + + ds["object"] = SpatialShapes( + [Polygon([Point2f(0, 0), Point2f(2, 0), Point2f(2, 2), Point2f(0, 0)])], + ) + save!(ds) + @test !isdir(joinpath(path, "points", "object")) + @test isdir(joinpath(path, "shapes", "object")) + + delete!(ds, "object") + @test any(c.state == :deleted for c in dirty(ds)) + save!(ds) + @test !isdir(joinpath(path, "shapes", "object")) + close(ds) + end + end + + @testset "write! remains a saving compatibility API" begin + mktempdir() do parent + ds = SpatialDataset() + old_path = ds.backing.path + ds["points"] = SpatialPoints([Point2f(1, 1)]) + target = joinpath(parent, "dataset.zarr") + write!(ds, target, SpatialDataZarr()) + + @test ds.backing.path == abspath(target) + @test !ds.backing.owned + @test !isdirty(ds) + @test !isdir(old_path) + @test isdir(joinpath(target, "points", "points")) + close(ds) + end + end + + @testset "temporary stores reject nested save targets" begin + ds = SpatialDataset() + ds["points"] = SpatialPoints([Point2f(1, 1)]) + nested = joinpath(ds.backing.path, "nested.zarr") + @test_throws ArgumentError save!(ds; path=nested) + @test isdirty(ds) + close(ds; discard=true) + end + + @testset "unsupported attachments fail immediately" begin + ds = SpatialDataset() + @test_throws MethodError setindex!(ds, (x=1,), "unsupported") + close(ds) + end + + @testset "unsupported metadata remains dirty after a failed save" begin + ds = SpatialDataset() + ds.metadata["bad"] = (objects=Any[Ref(1)],) + @test_throws ArgumentError save!(ds) + @test isdirty(ds) + close(ds; discard=true) + end + + @testset "display reports unsaved state" begin + ds = SpatialDataset() + ds["points"] = SpatialPoints([Point2f(1, 1)]) + @test occursin("unsaved", sprint(show, ds)) + @test occursin("unsaved", sprint(show, MIME("text/plain"), ds)) + close(ds; discard=true) + end + + @testset "native stores require an explicit format version" begin + mktempdir() do path + open(joinpath(path, "zarr.json"), "w") do io + write(io, """{"zarr_format":3,"node_type":"group","attributes":{}}""") + end + open(joinpath(path, "spatialomics_meta.json"), "w") do io + write(io, """{"coord_systems":[]}""") + end + @test native_store_version(path) === nothing + error = try + read(SpatialDataZarr(), path) + nothing + catch exception + exception + end + @test error isa ArgumentError + @test occursin("rebuild", sprint(showerror, error)) + @test occursin("does not upgrade stores automatically", sprint(showerror, error)) + end + end +end diff --git a/test/provenance.jl b/test/provenance.jl new file mode 100644 index 0000000..fd37c9a --- /dev/null +++ b/test/provenance.jl @@ -0,0 +1,167 @@ +using GeometryBasics: Point2f, Polygon + +@testset "Acquisition provenance" begin + square(xmin, xmax, ymin, ymax) = Polygon([ + Point2f(xmin, ymin), Point2f(xmax, ymin), Point2f(xmax, ymax), + Point2f(xmin, ymax), Point2f(xmin, ymin), + ]) + + function overlapping_dataset() + ds = SpatialDataset() + push!(ds, CoordinateSystem("global")) + ds["fovs"] = SpatialShapes( + [square(0, 10, 0, 10), square(5, 15, 0, 10)]; + instance_id=Int32[1, 2], + origins=["fov_a", "fov_b"], + coord_system="global", + ) + push!(ds, AcquisitionSource( + "fov_a"; region="fovs", instance_id=1, + attributes=Dict("native_id" => 1), + )) + push!(ds, AcquisitionSource("fov_b"; region="fovs", instance_id=2)) + ds + end + + @testset "source membership is distinct from geometric membership" begin + ds = overlapping_dataset() + try + ds["transcripts"] = SpatialPoints( + [Point2f(7, 5), Point2f(7, 5), Point2f(12, 5)]; + origins=["fov_a", "fov_b", "fov_b"], + coord_system="global", + ) + ds["cells"] = SpatialShapes( + [square(6, 8, 4, 6), square(6, 8, 4, 6)]; + origins=["fov_a", "fov_b"], + coord_system="global", + ) + + @test sources(ds) == ["fov_a", "fov_b"] + @test source_attributes(ds, "fov_a")["native_id"] == 1 + attributes = source_attributes(ds, "fov_a") + attributes["native_id"] = 2 + @test source_attributes(ds, "fov_a")["native_id"] == 1 + @test source(ds, SubString("xfov_a", 2)) == source(ds, "fov_a") + fov_a_points = points(view(ds, "fov_a"), "transcripts") + @test length(fov_a_points) == 1 + @test source(fov_a_points, 1) == "fov_a" + @test origin_ids(fov_a_points) == Int32[1] + @test length(points(view(ds, "fov_b"), "transcripts")) == 2 + @test length(shapes(view(ds, "fov_a"), "cells")) == 1 + @test length(shapes(view(ds, "fov_b"), "cells")) == 1 + + both = view(ds, ["fov_a", "fov_b"]) + @test length(points(both, "transcripts")) == 3 + @test length(shapes(both, "cells")) == 2 + @test only(parentindices(points(view(ds, ["fov_a"]), "transcripts"))) == [1] + + overlap = SpatialExtent(6, 8, 4, 6; coord_system="global") + @test length(points(view(ds, overlap), "transcripts")) == 2 + @test length(shapes(view(ds, overlap), "cells")) == 2 + + selected = points(view(ds, "fov_b"), "transcripts") + @test only(parentindices(selected)) == [2, 3] + reassigned = with_instance_ids(collect(selected), Int32[10, 11]) + @test instance_id(reassigned) == Int32[10, 11] + @test origins(reassigned) == origins(selected) + @test origin_ids(reassigned) == origin_ids(selected) + finally + close(ds; discard=true) + end + end + + @testset "missing provenance has an explicit dataset fallback" begin + ds = overlapping_dataset() + try + untracked = SpatialPoints( + [Point2f(2, 5), Point2f(12, 5)]; coord_system="global", + ) + ds["untracked"] = untracked + @test_logs (:warn, r"no acquisition provenance") begin + @test length(points(view(ds, "fov_a"), "untracked")) == 1 + end + @test_throws ArgumentError view(untracked, source(ds, "fov_a")) + finally + close(ds; discard=true) + end + end + + @testset "origin metadata survives materialisation and storage" begin + mktempdir() do path + ds = overlapping_dataset() + ds["transcripts"] = SpatialPoints( + [Point2f(2, 5), Point2f(12, 5)]; + features=(quality=Float32[0.8, 0.9],), + origins=["fov_a", "fov_b"], + coord_system="global", + ) + selected = collect(points(view(ds, "fov_b"), "transcripts")) + @test origins(selected) == ["fov_a", "fov_b"] + @test origin_ids(selected) == Int32[2] + @test source(selected, 1) == "fov_b" + @test features(selected, :quality) == Float32[0.9] + + save!(ds; path) + close(ds) + stored = read(SpatialDataZarr(), path) + try + @test sources(stored) == ["fov_a", "fov_b"] + @test source_attributes(stored, "fov_a")["native_id"] == 1 + @test source(points(stored, "transcripts"), 2) == "fov_b" + @test length(points(view(stored, "fov_b"), "transcripts")) == 1 + finally + close(stored) + end + end + end + + @testset "source footprints crop raster elements" begin + ds = overlapping_dataset() + try + ds["image"] = SpatialImage( + reshape(Float32.(1:150), 15, 10); + axes=(:x, :y), coord_system="global", + ) + ds["labels"] = SpatialLabels( + reshape(Int32.(1:150), 15, 10); + axes=(:x, :y), coord_system="global", + ) + @test size(images(view(ds, "fov_a"), "image")) == (10, 10) + @test size(labels(view(ds, "fov_b"), "labels")) == (10, 10) + + selected_images = images(view(ds, ["fov_a", "fov_b"]), "image") + @test selected_images isa SpatialRasterTiles + @test length(selected_images) == 2 + @test size(selected_images[1]) == (10, 10) + @test size(selected_images[2]) == (10, 10) + @test sources(selected_images) == ["fov_a", "fov_b"] + + scaled_images = scaleminmax(selected_images) + @test all(tile -> !isnothing(tile.display_transform), scaled_images) + composite = colorview(RGB, scaled_images, scaled_images, scaled_images) + @test composite isa SpatialRasterTiles + @test first(composite) isa SpatialImageColorView + @test sources(composite) == sources(selected_images) + + selected_labels = labels(view(ds, ["fov_a", "fov_b"]), "labels") + @test selected_labels isa SpatialRasterTiles + @test length(selected_labels) == 2 + finally + close(ds; discard=true) + end + end + + @testset "constructor validation" begin + @test_throws ArgumentError AcquisitionSource("bad"; region="fovs") + @test_throws DimensionMismatch SpatialRasterTiles( + SpatialImage[SpatialImage(zeros(Float32, 2, 2))], String[], + ) + @test_throws DimensionMismatch SpatialPoints( + [Point2f(1, 1), Point2f(2, 2)]; origins=["only_one"], + ) + @test_throws ArgumentError SpatialPoints( + [Point2f(1, 1)]; origin_id=Int32[2], origin_codebook=["one"], + ) + end +end diff --git a/test/regression.jl b/test/regression.jl new file mode 100644 index 0000000..dbb7bb1 --- /dev/null +++ b/test/regression.jl @@ -0,0 +1,1537 @@ +using SpatialOmics +using Test + +@testset "SpatialOmics M1" begin + + @testset "CoordinateSystem" begin + cs = CoordinateSystem("global"; axes=(:x, :y), units=("µm", "µm")) + @test cs.name == "global" + @test cs.axes == (:x, :y) + @test cs.units == ("µm", "µm") + end + + @testset "Transformations — construction" begin + t = SpatialOmics.translation(10.0, -5.0, "fov_1", "global") + @test t isa Affine + @test t.src == "fov_1" + @test t.dst == "global" + + s = SpatialOmics.scaling(2.0, 2.0, "px", "µm") + @test s isa Affine + + r = SpatialOmics.rotation(π/4, "a", "b") + @test r isa Affine + + f = SpatialOmics.flip_y("local", "global") + @test f isa Affine + end + + @testset "Transformations — apply" begin + # translation + pts = [1.0 2.0; 3.0 4.0] # 2×2 + t = SpatialOmics.translation(10.0, 20.0, "a", "b") + out = apply(t, pts) + @test out ≈ [11.0 22.0; 13.0 24.0] + + # flip_y + f = SpatialOmics.flip_y("a", "b") + out2 = apply(f, pts) + @test out2 ≈ [1.0 -2.0; 3.0 -4.0] + + # identity + id = Identity("a", "b") + @test apply(id, pts) === pts + + # compose two translations + t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") + t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") + tc = SpatialOmics.compose(t1, t2) + @test apply(tc, [0.0 0.0]) ≈ [1.0 1.0] + end + + @testset "Transformations — resolve / Dijkstra" begin + transforms = AbstractTransformation[ + SpatialOmics.translation(100.0, 200.0, "fov_1", "global"), + SpatialOmics.scaling(0.5, 0.5, "px", "µm"), + ] + t = resolve(transforms, "fov_1", "global") + @test t isa Affine + + # no path + @test_throws ErrorException resolve(transforms, "nowhere", "global") + + # identity (same src == dst) + t2 = resolve(transforms, "global", "global") + @test t2 isa Identity + end + + @testset "Sequence apply" begin + t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") + t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") + seq = Sequence([t1, t2], "a", "c") + out = apply(seq, [0.0 0.0]) + @test out ≈ [1.0 1.0] + end + + @testset "apply on SVector / Point2f" begin + using StaticArrays + t = SpatialOmics.translation(10.0, 20.0, "a", "b") + + # single SVector{2} + p = SVector(1.0, 2.0) + out = apply(t, p) + @test out isa SVector{2} + @test out ≈ SVector(11.0, 22.0) + + # Identity on SVector + id = Identity("a", "b") + @test apply(id, p) === p + + # vector of SVectors + pts = [SVector(0.0, 0.0), SVector(1.0, 1.0), SVector(2.0, 3.0)] + outs = apply(t, pts) + @test outs isa Vector + @test outs[1] ≈ SVector(10.0, 20.0) + @test outs[3] ≈ SVector(12.0, 23.0) + + # Identity on vector of SVectors + @test apply(id, pts) === pts + + # Sequence on vector of SVectors + t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") + t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") + seq = Sequence([t1, t2], "a", "c") + svec_pts = [SVector(0.0, 0.0)] + @test apply(seq, svec_pts)[1] ≈ SVector(1.0, 1.0) + + # flip_y on SVector + f = SpatialOmics.flip_y("a", "b") + @test apply(f, SVector(3.0, 4.0)) ≈ SVector(3.0, -4.0) + end + + @testset "BackingStore — tempdir" begin + bs = BackingStore() + @test isdir(bs.path) + @test startswith(basename(bs.path), "spatialomics_") + @test bs.owned == true + zarr_json = joinpath(bs.path, "zarr.json") + @test isfile(zarr_json) + p = bs.path + SpatialOmics._cleanup!(bs) + @test !isdir(p) + end + + @testset "BackingStore — user path" begin + mktempdir() do d + bs = BackingStore(; path=d) + @test bs.owned == false + @test isfile(joinpath(bs.path, "zarr.json")) + end + end + + @testset "SpatialDataset — construction + cleanup" begin + ds = SpatialDataset() + p = ds.backing.path + @test isdir(p) + @test ds.backing.owned == true + close(ds; discard=true) + @test !isdir(p) + end + + @testset "SpatialDataset — keep!" begin + mktempdir() do d + target = joinpath(d, "myds.zarr") + ds = SpatialDataset() + p = ds.backing.path + keep!(ds, target) + @test isdir(target) + @test ds.backing.owned == false + @test !isdir(p) # scratch removed after copy + close(ds; discard=true) # should be a no-op (owned=false) + @test isdir(target) + end + end + + @testset "SpatialDataset — with_dataset" begin + path_ref = Ref("") + with_dataset() do ds + path_ref[] = ds.backing.path + @test isdir(ds.backing.path) + end + @test !isdir(path_ref[]) + end + + @testset "SpatialDataset — coord systems and transforms" begin + ds = SpatialDataset() + try + push!(ds, CoordinateSystem("global"; units=("µm", "µm"))) + push!(ds, CoordinateSystem("fov_1"; axes=(:x, :y), units=("px", "px"))) + @test "global" in coord_systems(ds) + @test "fov_1" in coord_systems(ds) + + t = SpatialOmics.translation(500.0, 300.0, "fov_1", "global") + push!(ds, t) + resolved = transform(ds, "fov_1", "global") + @test resolved isa Affine + + pts = [0.0 0.0; 10.0 20.0] + out = apply(resolved, pts) + @test out ≈ [500.0 300.0; 510.0 320.0] + finally + close(ds; discard=true) + end + end + + @testset "SpatialDataset — element setindex/getindex" begin + ds = SpatialDataset() + try + @test_throws MethodError setindex!(ds, (x = 1, y = 2), "test") + finally + close(ds; discard=true) + end + end + +end + +@testset "SpatialOmics M2" begin + + using GeometryBasics + using GeoInterface + + @testset "SpatialPoints — bare constructor" begin + pts = SpatialPoints([Point2f(1, 2), Point2f(3, 4), Point2f(5, 6)]; coord_system="global") + @test pts isa SpatialPoints{Float32} + @test length(pts) == 3 + @test coord_system(pts) == "global" + @test coords(pts)[1] == Point2f(1, 2) + @test features(pts) == String[] + @test all(feature_ids(pts) .== 0) + @test all(pts.instance_id .== 0) # internal field + end + + @testset "SpatialPoints — Tables constructor" begin + pts = SpatialPoints( + (x = [1.0f0, 2.0f0, 3.0f0], + y = [4.0f0, 5.0f0, 6.0f0], + gene = ["Actb", "Gapdh", "Actb"]); + gene=:gene, coord_system="fov_1") + @test length(pts) == 3 + @test coord_system(pts) == "fov_1" + @test length(features(pts)) == 2 + @test "Actb" in features(pts) + @test "Gapdh" in features(pts) + # Actb entries share feature_id, Gapdh has a different one + @test features(pts)[feature_ids(pts)[1]] == "Actb" + @test feature_ids(pts)[1] == feature_ids(pts)[3] + @test feature_ids(pts)[2] != feature_ids(pts)[1] + + assigned = SpatialPoints( + [Point2f(1, 1), Point2f(2, 2), Point2f(3, 3)]; + feature_id=Int32[1, 2, 1], + feature_codebook=["Actb", "Gapdh"], + instance_id=Int32[1, 1, 2], + ) + @test count_per_instance(assigned) == Dict(Int32(1) => 2, Int32(2) => 1) + @test count_per_instance(assigned; feature="Actb") == + Dict(Int32(1) => 1, Int32(2) => 1) + @test features(assigned[["Gapdh"]]) == features(assigned) + @test coords(assigned[["Gapdh"]]) == [Point2f(2, 2)] + end + + @testset "SpatialPoints — GeoInterface" begin + coords = [Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)] + pts = SpatialPoints(coords) + @test GeoInterface.isgeometry(pts) + @test GeoInterface.geomtrait(pts) isa GeoInterface.MultiPointTrait + @test GeoInterface.ngeom(GeoInterface.geomtrait(pts), pts) == 3 + @test GeoInterface.getgeom(GeoInterface.geomtrait(pts), pts, 1) == Point2f(0, 0) + end + + @testset "SpatialPoints — apply (copy)" begin + pts = SpatialPoints([Point2f(0, 0), Point2f(1, 0)]; coord_system="fov_1") + t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") + pts2 = apply(t, pts) + @test pts2 isa SpatialPoints{Float32} + @test coord_system(pts2) == "global" + @test coords(pts2)[1] ≈ Point2f(10, 20) + @test coords(pts2)[2] ≈ Point2f(11, 20) + @test coords(pts)[1] == Point2f(0, 0) # original unchanged + @test coord_system(pts) == "fov_1" + end + + @testset "SpatialPoints — apply! (in-place)" begin + pts = SpatialPoints([Point2f(0, 0), Point2f(1, 0)]; coord_system="fov_1") + t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") + result = apply!(t, pts) + @test result === pts # same object + @test coord_system(pts) == "global" + @test coords(pts)[1] ≈ Point2f(10, 20) + @test coords(pts)[2] ≈ Point2f(11, 20) + end + + @testset "SpatialShapes — construction" begin + shp = SpatialShapes( + [Polygon([Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)]), + Polygon([Point2f(2,2), Point2f(3,2), Point2f(3,3), Point2f(2,3), Point2f(2,2)])]; + coord_system="global") + @test shp isa SpatialShapes{<:Polygon} + @test length(shp) == 2 + @test coord_system(shp) == "global" + @test length(geometries(shp)) == 2 + end + + @testset "SpatialShapes — GeoInterface" begin + ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] + shp = SpatialShapes([Polygon(ring)]) + @test GeoInterface.isgeometry(shp) + @test GeoInterface.geomtrait(shp) isa GeoInterface.GeometryCollectionTrait + @test GeoInterface.ngeom(GeoInterface.geomtrait(shp), shp) == 1 + @test GeoInterface.getgeom(GeoInterface.geomtrait(shp), shp, 1) isa Polygon + end + + @testset "SpatialShapes — apply (copy)" begin + ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] + shp = SpatialShapes([Polygon(ring)]; coord_system="fov_1") + t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") + shp2 = apply(t, shp) + @test coord_system(shp2) == "global" + @test GeoInterface.coordinates(geometries(shp2)[1])[1][1][1] ≈ 10.0 # x shifted + @test GeoInterface.coordinates(geometries(shp2)[1])[1][1][2] ≈ 20.0 # y shifted + @test coord_system(shp) == "fov_1" # original unchanged + end + + @testset "SpatialShapes — iteration and filter" begin + rings = [[Point2f(i,0), Point2f(i+1,0), Point2f(i+1,1), Point2f(i,1), Point2f(i,0)] + for i in 0:2] + shp = SpatialShapes(Polygon.(rings); + instance_id=Int32[10, 20, 30], coord_system="global") + + @test length(collect(shp)) == 3 + @test eltype(shp) <: SpatialShape + + row = shp[2] + @test row isa SpatialShape + @test row.instance_id == Int32(20) + @test row.coord_system == "global" + + kept = filter(s -> s.instance_id in [10, 30], shp) + @test length(kept) == 2 + @test kept.instance_id == Int32[10, 30] + @test coord_system(kept) == "global" + end + + @testset "SpatialShapes — apply! (in-place)" begin + ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] + shp = SpatialShapes([Polygon(ring)]; coord_system="fov_1") + t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") + result = apply!(t, shp) + @test result === shp + @test coord_system(shp) == "global" + @test GeoInterface.coordinates(geometries(shp)[1])[1][1][1] ≈ 10.0 + @test GeoInterface.coordinates(geometries(shp)[1])[1][1][2] ≈ 20.0 + end + + @testset "Typed dataset accessors" begin + ds = SpatialDataset() + try + coords = [Point2f(0, 0), Point2f(1, 1)] + pts = SpatialPoints(coords; coord_system="global") + ds["transcripts"] = pts + @test points(ds, "transcripts") === pts + + ring = [Point2f(0,0), Point2f(2,0), Point2f(2,2), Point2f(0,2), Point2f(0,0)] + shp = SpatialShapes([Polygon(ring)]; coord_system="global") + ds["cells"] = shp + @test shapes(ds, "cells") === shp + + @test_throws ErrorException points(ds, "cells") # wrong type + @test_throws ErrorException shapes(ds, "transcripts") + finally + close(ds; discard=true) + end + end + +end + +@testset "SpatialOmics M3" begin + + using GeometryBasics + using GeoInterface + + # ── shared fixtures ──────────────────────────────────────────────────────── + + pts = SpatialPoints( + [Point2f(x, y) for x in 0f0:1f0:4f0 for y in 0f0:1f0:4f0]; + coord_system="global") # 5×5 grid: 25 points + + shp = SpatialShapes( + [Polygon([Point2f(x,y), Point2f(x+1,y), Point2f(x+1,y+1), + Point2f(x,y+1), Point2f(x,y)]) + for x in 0f0:2f0:4f0 for y in 0f0:2f0:4f0]; + coord_system="global") # 3×3 = 9 unit squares + + # ── SpatialExtent ────────────────────────────────────────────────────────── + + @testset "SpatialExtent construction" begin + ext = SpatialExtent(0, 2, 0, 2; coord_system="global") + @test ext.xmin == 0.0 + @test ext.xmax == 2.0 + @test coord_system(ext) == "global" + end + + @testset "SpatialExtent from SpatialShapes" begin + rings = [[Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)], + [Point2f(2,2), Point2f(4,2), Point2f(4,5), Point2f(2,5), Point2f(2,2)]] + shp_ext = SpatialShapes(Polygon.(rings); instance_id=Int32[1,2], coord_system="global") + ext = SpatialExtent(shp_ext) + @test ext.xmin == 0.0 && ext.xmax == 4.0 + @test ext.ymin == 0.0 && ext.ymax == 5.0 + @test coord_system(ext) == "global" + + # filter then extent — the idiomatic pipeline + ext2 = SpatialExtent(filter(s -> s.instance_id == Int32(1), shp_ext)) + @test ext2.xmax == 1.0 + end + + @testset "SpatialExtent union" begin + a = SpatialExtent(0, 2, 0, 2; coord_system="g") + b = SpatialExtent(1, 4, 1, 3; coord_system="g") + u = a ∪ b + @test u.xmin == 0.0 && u.xmax == 4.0 + @test u.ymin == 0.0 && u.ymax == 3.0 + @test coord_system(u) == "g" + @test_throws ErrorException SpatialExtent(0,1,0,1;coord_system="a") ∪ + SpatialExtent(0,1,0,1;coord_system="b") + end + + @testset "SpatialExtent intersect" begin + a = SpatialExtent(0, 3, 0, 3; coord_system="g") + b = SpatialExtent(1, 4, 1, 4; coord_system="g") + i = a ∩ b + @test i isa SpatialExtent + @test i.xmin == 1.0 && i.xmax == 3.0 + @test i.ymin == 1.0 && i.ymax == 3.0 + # non-overlapping + c = SpatialExtent(5, 6, 5, 6; coord_system="g") + @test isnothing(a ∩ c) + # touching at edge — not an overlap + d = SpatialExtent(3, 5, 0, 3; coord_system="g") + @test isnothing(a ∩ d) + end + + # ── SpatialROI ───────────────────────────────────────────────────────────── + + @testset "SpatialROI construction" begin + ring = [Point2f(0,0), Point2f(2,0), Point2f(2,2), Point2f(0,2), Point2f(0,0)] + poly = Polygon(ring) + roi = SpatialROI(poly; coord_system="global") + @test geometry(roi) === poly + @test coord_system(roi) == "global" + @test roi.extent.xmin ≈ 0.0 + @test roi.extent.xmax ≈ 2.0 + end + + # ── view on SpatialPoints with SpatialExtent ─────────────────────────────── + + @testset "view(pts, SpatialExtent)" begin + ext = SpatialExtent(0, 2, 0, 2; coord_system="global") + v = view(pts, ext) + @test v isa SpatialElementView{<:SpatialPoints, SpatialExtent} + @test coord_system(v) == "global" + # x∈{0,1,2} × y∈{0,1,2} → 9 points + @test length(v) == 9 + end + + @testset "collect(view(pts, SpatialExtent))" begin + ext = SpatialExtent(0, 2, 0, 2; coord_system="global") + sub = collect(view(pts, ext)) + @test sub isa SpatialPoints + @test length(sub) == 9 + @test coord_system(sub) == "global" + @test all(p -> p[1] <= 2.0 && p[2] <= 2.0, coords(sub)) + end + + # ── view on SpatialShapes with SpatialExtent ─────────────────────────────── + + @testset "view(shp, SpatialExtent)" begin + ext = SpatialExtent(0, 3, 0, 3; coord_system="global") + sub = collect(view(shp, ext)) + @test sub isa SpatialShapes + @test coord_system(sub) == "global" + @test length(sub) >= 1 + end + + # ── overlap=:any vs :full ────────────────────────────────────────────────── + + @testset "view(shp, SpatialExtent) — overlap modes" begin + # ext straddles corners of several unit squares: only the (2,2) square + # sits fully inside 1..3 × 1..3; the three corner squares touch the boundary + ext = SpatialExtent(1, 3, 1, 3; coord_system="global") + n_any = length(view(shp, ext)) + n_full = length(view(shp, ext; overlap=:full)) + @test n_any > n_full + @test n_full >= 1 + sub = collect(view(shp, ext; overlap=:full)) + # all coords of fully-inside shapes lie within [1,3]×[1,3] + @test all(geometries(sub)) do g + all(Iterators.flatten(GeoInterface.coordinates(g))) do pt + 1.0 <= pt[1] <= 3.0 && 1.0 <= pt[2] <= 3.0 + end + end + end + + @testset "view — invalid overlap raises error" begin + ext = SpatialExtent(0, 2, 0, 2; coord_system="global") + @test_throws ErrorException view(pts, ext; overlap=:partial) + end + + # ── view on SpatialPoints with SpatialROI (polygon) ─────────────────────── + + @testset "view(pts, SpatialROI)" begin + tri = Polygon([Point2f(0,0), Point2f(3,0), Point2f(1.5,3), Point2f(0,0)]) + roi = SpatialROI(tri; coord_system="global") + sub = collect(view(pts, roi)) + @test sub isa SpatialPoints + @test length(sub) <= length(pts) + @test all(p -> p[1] <= 3.0 && p[2] <= 3.0, coords(sub)) + end + + # ── coord system mismatch ────────────────────────────────────────────────── + + @testset "coord system mismatch" begin + ext = SpatialExtent(0, 2, 0, 2; coord_system="other") + @test_throws ErrorException view(pts, ext) + end + + # ── SpatialDatasetView ───────────────────────────────────────────────────── + + @testset "SpatialDatasetView" begin + ds = SpatialDataset() + try + push!(ds, CoordinateSystem("global")) + ds["transcripts"] = pts + ds["cells"] = shp + + ext = SpatialExtent(0, 2, 0, 2; coord_system="global") + v_ds = view(ds, ext) + @test v_ds isa SpatialDatasetView + @test haskey(v_ds, "transcripts") + @test "cells" in collect(keys(v_ds)) + + @test v_ds["transcripts"] isa SpatialElementView{<:SpatialPoints} + @test points(v_ds, "transcripts") isa SpatialElementView{<:SpatialPoints} + @test shapes(v_ds, "cells") isa SpatialElementView{<:SpatialShapes} + @test length(collect(points(v_ds, "transcripts"))) == 9 + finally + close(ds; discard=true) + end + end + + # ── SpatialShapes(::SpatialExtent) and SpatialShapes(::SpatialROI) ────────── + + @testset "SpatialShapes(SpatialExtent)" begin + ext = SpatialExtent(1.0, 3.0, 2.0, 5.0; coord_system="global") + s = SpatialShapes(ext) + @test s isa SpatialShapes + @test length(s) == 1 + @test s.instance_id == Int32[1] + @test coord_system(s) == "global" + ring = GeoInterface.coordinates(s.geometries[1])[1] + @test length(ring) == 5 + @test ring[1] ≈ ring[end] + xs = [p[1] for p in ring]; ys = [p[2] for p in ring] + @test minimum(xs) ≈ 1.0 && maximum(xs) ≈ 3.0 + @test minimum(ys) ≈ 2.0 && maximum(ys) ≈ 5.0 + end + + @testset "SpatialShapes(SpatialROI)" begin + ring = [Point2f(0,0), Point2f(2,0), Point2f(1,2), Point2f(0,0)] + roi = SpatialROI(Polygon(ring); coord_system="global") + s = SpatialShapes(roi) + @test length(s) == 1 + @test coord_system(s) == "global" + r2 = GeoInterface.coordinates(s.geometries[1])[1] + @test r2[1] ≈ r2[end] + end + + @testset "SpatialShapes(SpatialExtent) zarr roundtrip" begin + ext = SpatialExtent(0.0, 10.0, 0.0, 10.0; coord_system="global") + path = mktempdir() + try + ds = SpatialDataset() + push!(ds, CoordinateSystem("global")) + ds["roi"] = SpatialShapes(ext) + write!(ds, path, SpatialDataZarr()) + close(ds; discard=true) + ds2 = read(SpatialDataZarr(), path) + s2 = shapes(ds2, "roi") + @test length(s2) == 1 + @test coord_system(s2) == "global" + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + +end + +@testset "SpatialOmics M4" begin + + using GeometryBasics + using Random + Random.seed!(7) + + genes = ["Actb", "Gapdh", "Vim"] + pts = SpatialPoints( + (x = rand(Float32, 200) .* 500f0, + y = rand(Float32, 200) .* 500f0, + g = [genes[rand(1:3)] for _ in 1:200]); + x=:x, y=:y, gene=:g, coord_system="px") + + cells = SpatialShapes( + [let cx = rand(Float32)*450f0+25f0, cy = rand(Float32)*450f0+25f0 + Polygon([Point2f(cx-10,cy-10), Point2f(cx+10,cy-10), + Point2f(cx+10,cy+10), Point2f(cx-10,cy+10), + Point2f(cx-10,cy-10)]) + end for _ in 1:15]; + instance_id=Int32.(1:15), coord_system="px") + + @testset "SpatialPoints zarr roundtrip" begin + mktempdir() do path + ds = SpatialDataset() + push!(ds, CoordinateSystem("px"; units=("px","px"))) + ds["pts"] = pts + write(ds, path, SpatialDataZarr()) + close(ds; discard=true) + + ds2 = read(SpatialDataZarr(), path) + pts2 = points(ds2, "pts") + @test length(pts2) == length(pts) + @test features(pts2) == features(pts) + @test coords(pts2)[1] ≈ coords(pts)[1] + @test coord_system(pts2) == coord_system(pts) + @test pts2.instance_id == pts.instance_id + close(ds2; discard=true) + end + end + + @testset "SpatialShapes zarr roundtrip" begin + mktempdir() do path + ds = SpatialDataset() + push!(ds, CoordinateSystem("px"; units=("px","px"))) + ds["cells"] = cells + write(ds, path, SpatialDataZarr()) + close(ds; discard=true) + + ds2 = read(SpatialDataZarr(), path) + cells2 = shapes(ds2, "cells") + @test length(cells2) == length(cells) + @test coord_system(cells2) == coord_system(cells) + @test cells2.instance_id == cells.instance_id + r1 = GeoInterface.coordinates(geometries(cells)[1])[1] + r2 = GeoInterface.coordinates(geometries(cells2)[1])[1] + @test length(r1) == length(r2) + @test all(r1[i][1] ≈ r2[i][1] && r1[i][2] ≈ r2[i][2] for i in eachindex(r1)) + close(ds2; discard=true) + end + end + + @testset "coord_systems preserved across roundtrip" begin + mktempdir() do path + ds = SpatialDataset(; path) + push!(ds, CoordinateSystem("px"; axes=(:x,:y), units=("px","px"))) + @test isdirty(ds) + save!(ds) + ds2 = read(SpatialDataZarr(), path) + @test "px" in coord_systems(ds2) + close(ds; discard=true); close(ds2; discard=true) + end + end + + @testset "transforms preserved across roundtrip" begin + mktempdir() do path + ds = SpatialDataset(; path) + push!(ds, CoordinateSystem("fov"; axes=(:x,:y), units=("µm","µm"))) + push!(ds, CoordinateSystem("global"; axes=(:x,:y), units=("µm","µm"))) + push!(ds, SpatialOmics.translation(100.0, 200.0, "fov", "global")) + save!(ds) + ds2 = read(SpatialDataZarr(), path) + @test length(ds2.transforms) == 1 + t = ds2.transforms[1] + @test t isa Affine + @test t.src == "fov" && t.dst == "global" + @test transform(ds2, "fov", "global") isa AbstractTransformation + close(ds; discard=true); close(ds2; discard=true) + end + end + + @testset "setindex! stages until save!" begin + mktempdir() do path + ds = SpatialDataset(; path) + ds["pts"] = copy(pts) + @test !isfile(joinpath(path, "points", "pts", "zarr.json")) + @test isdirty(ds) + save!(ds) + @test isfile(joinpath(path, "points", "pts", "zarr.json")) + @test isfile(joinpath(path, "points", "pts", "coords", "zarr.json")) + close(ds; discard=true) + end + end + + @testset "metadata NamedTuple-of-vectors roundtrip" begin + mktempdir() do path + ds = SpatialDataset(; path) + ds.metadata["ann"] = (fov=Int32[1, 1, 2], z=Float32[0.5, 1.0, 0.5], + comp=["Cytoplasm", "Nucleus", "Cytoplasm"]) + @test !isdir(joinpath(path, "metadata", "ann")) + save!(ds) + @test isdir(joinpath(path, "metadata", "ann")) + + ds2 = read(SpatialDataZarr(), path) + ann = ds2.metadata["ann"] + @test ann.fov == Int32[1, 1, 2] + @test ann.z ≈ Float32[0.5, 1.0, 0.5] + @test ann.comp == ["Cytoplasm", "Nucleus", "Cytoplasm"] + close(ds; discard=true) + close(ds2; discard=true) + end + end + + @testset "write produces valid zarr layout" begin + mktempdir() do path + ds = SpatialDataset() + ds["pts"] = copy(pts); ds["cells"] = copy(cells) + write(ds, path, SpatialDataZarr()) + close(ds; discard=true) + @test isfile(joinpath(path, "zarr.json")) + @test isfile(joinpath(path, "points", "pts", "coords", "zarr.json")) + @test isfile(joinpath(path, "shapes", "cells", "geom_data", "zarr.json")) + @test isfile(joinpath(path, "shapes", "cells", "poly_offsets","zarr.json")) + end + end + +end + +@testset "SpatialOmics M5" begin + + using Logging + + # ── construction ───────────────────────────────────────────────────────────── + + @testset "SpatialImage 2D construction" begin + arr = rand(Float32, 64, 64) + img = SpatialImage(arr; coord_system="px") + @test img.axes == (:y, :x) + @test nchannels(img) == 1 + @test size(img) == (64, 64) + @test isempty(img.pyramid) + @test coord_system(img) == "px" + end + + @testset "SpatialImage 3D construction — (c,y,x)" begin + arr = rand(Float32, 3, 128, 128) + img = SpatialImage(arr; + axes=(:c, :y, :x), + channel_names=["DAPI", "GFP", "RFP"], + coord_system="global") + @test img.axes == (:c, :y, :x) + @test nchannels(img) == 3 + @test channel_names(img) == ["DAPI", "GFP", "RFP"] + @test size(img) == (3, 128, 128) + @test coord_system(img) == "global" + end + + @testset "SpatialImage 3D construction — (y,x,c)" begin + arr = rand(Float32, 128, 128, 3) + img = SpatialImage(arr; + axes=(:y, :x, :c), + channel_names=["DAPI", "GFP", "RFP"], + coord_system="global") + @test img.axes == (:y, :x, :c) + @test nchannels(img) == 3 + @test size(img) == (128, 128, 3) + end + + @testset "SpatialImage pixel_to_cs default" begin + arr = rand(Float32, 64, 64) + img = SpatialImage(arr; coord_system="px") + @test img.pixel_to_cs isa Identity + @test img.pixel_to_cs.src == "pixel" + @test img.pixel_to_cs.dst == "px" + end + + @testset "SpatialImage pixel_to_cs custom" begin + t = SpatialOmics.translation(10.0, 20.0, "pixel", "global") + arr = rand(Float32, 64, 64) + img = SpatialImage(arr; coord_system="global", pixel_to_cs=t) + @test img.pixel_to_cs isa Affine + @test img.pixel_to_cs.src == "pixel" + @test img.pixel_to_cs.dst == "global" + end + + # ── pyramid ─────────────────────────────────────────────────────────────────── + + @testset "build_pyramid! level count" begin + arr = rand(Float32, 3, 128, 128) + img = SpatialImage(arr) + build_pyramid!(img, 3) + @test length(img.pyramid) == 3 + end + + @testset "build_pyramid! spatial dims shrink — (c,y,x)" begin + arr = rand(Float32, 3, 128, 128) + img = SpatialImage(arr; axes=(:c, :y, :x)) + build_pyramid!(img, 2) + @test size(img.pyramid[1], 1) == 3 # channel dim preserved + @test size(img.pyramid[1], 2) < 128 + @test size(img.pyramid[1], 3) < 128 + @test size(img.pyramid[2], 2) < size(img.pyramid[1], 2) + end + + @testset "build_pyramid! spatial dims shrink — (y,x,c)" begin + arr = rand(Float32, 128, 128, 3) + img = SpatialImage(arr; axes=(:y, :x, :c)) + build_pyramid!(img, 2) + @test size(img.pyramid[1], 1) < 128 + @test size(img.pyramid[1], 2) < 128 + @test size(img.pyramid[1], 3) == 3 # channel dim preserved + @test size(img.pyramid[2], 1) < size(img.pyramid[1], 1) + end + + @testset "build_pyramid! 2D (no channel dim)" begin + arr = rand(Float32, 64, 64) + img = SpatialImage(arr) + build_pyramid!(img, 2) + @test length(img.pyramid) == 2 + @test size(img.pyramid[1], 1) < 64 + @test size(img.pyramid[1], 2) < 64 + end + + @testset "build_pyramid! preserves integer storage type" begin + img = SpatialImage(rand(UInt8, 64, 64)) + build_pyramid!(img, 2) + @test length(img.pyramid) == 2 + @test all(level -> eltype(level) === UInt8, img.pyramid) + end + + @testset "build_pyramid! replace clears old levels" begin + arr = rand(Float32, 3, 64, 64) + img = SpatialImage(arr) + build_pyramid!(img, 3) + build_pyramid!(img, 1) + @test length(img.pyramid) == 1 + end + + # ── zarr roundtrip ──────────────────────────────────────────────────────────── + + @testset "SpatialImage zarr roundtrip — data" begin + arr = rand(Float32, 3, 64, 64) + img = SpatialImage(arr; coord_system="px") + path = mktempdir() + try + ds = SpatialDataset() + ds["img"] = img + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + img2 = images(ds2, "img") + @test size(img2.data) == (3, 64, 64) + @test img2.data ≈ arr + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + + @testset "SpatialImage zarr roundtrip — metadata preserved" begin + t = SpatialOmics.translation(5.0, 10.0, "pixel", "global") + arr = rand(Float32, 2, 32, 32) + img = SpatialImage(arr; + axes=(:c, :y, :x), + channel_names=["ch1", "ch2"], + coord_system="global", + pixel_to_cs=t) + path = mktempdir() + try + ds = SpatialDataset() + ds["img"] = img + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + img2 = images(ds2, "img") + @test img2.axes == (:c, :y, :x) + @test channel_names(img2) == ["ch1", "ch2"] + @test coord_system(img2) == "global" + @test img2.pixel_to_cs isa Affine + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + + @testset "SpatialImage zarr roundtrip — pyramid preserved" begin + arr = rand(Float32, 3, 64, 64) + img = SpatialImage(arr; coord_system="px") + build_pyramid!(img, 2) + path = mktempdir() + try + ds = SpatialDataset() + ds["img"] = img + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + img2 = images(ds2, "img") + @test length(img2.pyramid) == 2 + @test size(img2.pyramid[1]) == size(img.pyramid[1]) + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + + @testset "images accessor type error" begin + ds = SpatialDataset() + try + ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] + ds["cells"] = SpatialShapes([Polygon(ring)]; coord_system="global") + @test_throws ErrorException images(ds, "cells") + finally + close(ds; discard=true) + end + end + +end + +@testset "SpatialOmics M6" begin + + using Logging + + # ── shared fixtures ──────────────────────────────────────────────────────── + Random.seed!(42) + n_cells = 50 + n_genes = 4 + genes = ["Actb", "Gapdh", "Col1a1", "Vim"] + + cells = SpatialShapes( + [let cx = Float32(rand()*800+100), cy = Float32(rand()*800+100) + Polygon([Point2f(cx-30,cy-30), Point2f(cx+30,cy-30), + Point2f(cx+30,cy+30), Point2f(cx-30,cy+30), + Point2f(cx-30,cy-30)]) + end for _ in 1:n_cells]; + instance_id=Int32.(1:n_cells), coord_system="global_px") + + X = rand(Float32, n_cells, n_genes) + rel = SpatialRelation(Expression(), "cells", Int32.(1:n_cells), X; + obs=(instance_id=Int32.(1:n_cells),), var=(name=genes,)) + + # ── SpatialRelation construction ─────────────────────────────────────────── + + @testset "SpatialRelation construction" begin + @test nobs(rel) == n_cells + @test nvar(rel) == n_genes + @test var_names(rel) == genes + @test rel.src == "cells" + @test rel.kind isa Expression + end + + @testset "SpatialRelation show" begin + s = sprint(show, rel) + @test contains(s, "SpatialRelation") + @test contains(s, string(n_cells)) + @test contains(s, "cells") + end + + # ── expression weight lookup ─────────────────────────────────────────────── + + @testset "expression weight lookup" begin + actb_col = findfirst(==("Actb"), genes) + @test rel[:, "Actb"] ≈ X[:, actb_col] + @test rel[1, "Actb"] isa Float32 + @test rel[[1, 2], "Actb"] isa Vector{Float32} + end + + @testset "var_names lookup" begin + @test var_names(rel) == genes + @test length(var_names(rel)) == n_genes + end + + # ── passthrough accessors on SpatialElementView ──────────────────────────── + + @testset "geometries on SpatialElementView" begin + ext = SpatialExtent(0, 500, 0, 500; coord_system="global_px") + v = view(cells, ext) + geoms = geometries(v) + @test length(geoms) == length(v) + @test geoms isa Vector + end + + @testset "instance_id on SpatialElementView" begin + ext = SpatialExtent(0, 500, 0, 500; coord_system="global_px") + v = view(cells, ext) + ids = instance_id(v) + @test length(ids) == length(v) + @test ids isa Vector{Int32} + end + + # ── SpatialLabels ────────────────────────────────────────────────────────── + + @testset "SpatialLabels construction" begin + data = zeros(Int32, 64, 64) + data[10:30, 10:30] .= 1 + data[40:60, 40:60] .= 2 + lbl = SpatialLabels(data; + instance_map=Dict{Int32,Int32}(1=>1, 2=>2), + coord_system="global_px") + @test lbl.axes == (:y, :x) + @test coord_system(lbl) == "global_px" + @test length(instance_ids(lbl)) == 2 + @test size(lbl) == (64, 64) + end + + # ── Zarr round-trip ──────────────────────────────────────────────────────── + + @testset "SpatialRelation zarr roundtrip" begin + path = mktempdir() + try + ds = SpatialDataset() + ds["cells"] = cells + ds["expr"] = rel + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + rel2 = relations(ds2, "expr") + @test nobs(rel2) == n_cells + @test nvar(rel2) == n_genes + @test var_names(rel2) == genes + @test rel2.src == "cells" + @test rel2.weights ≈ X atol=1e-5 + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + + @testset "SpatialLabels zarr roundtrip" begin + path = mktempdir() + try + data = rand(Int32.(0:5), 32, 32) + lbl = SpatialLabels(data; + instance_map=Dict{Int32,Int32}(i=>i for i in 1:5), + coord_system="global_px") + ds = SpatialDataset(); ds["seg"] = lbl + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + lbl2 = labels(ds2, "seg") + @test size(lbl2.data) == (32, 32) + @test lbl2.data == data + @test coord_system(lbl2) == "global_px" + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + +end # M6 + +@testset "SpatialOmics M7" begin + + using Random, GeometryBasics, Logging + + @testset "Round-trip — all element kinds" begin + path = mktempdir(; prefix="so_m7_roundtrip_") + try + Random.seed!(1) + genes = ["Actb", "Gapdh", "Col1a1"] + n = 20 + + pts = SpatialPoints( + [Point2f(rand()*100, rand()*100) for _ in 1:n]; + feature_id = Int32.(rand(1:3, n)), + feature_codebook = genes, + instance_id = zeros(Int32, n), + coord_system = "global") + + polys = [let cx=rand()*80+10f0, cy=rand()*80+10f0 + Polygon([Point2f(cx-5,cy-5), Point2f(cx+5,cy-5), + Point2f(cx+5,cy+5), Point2f(cx-5,cy+5), + Point2f(cx-5,cy-5)]) + end for _ in 1:10] + shp = SpatialShapes(polys; instance_id=Int32.(1:10), coord_system="global") + + X = rand(Float32, 10, 3) + tbl = SpatialRelation(Expression(), "cells", Int32.(1:10), X; + obs=(instance_id=Int32.(1:10),), var=(name=genes,)) + + img = SpatialImage(rand(UInt16, 8, 8, 2); + axes=(:y,:x,:c), channel_names=["DAPI","GFP"], coord_system="global") + + data_lbl = Int32.(rand(0:5, 8, 8)) + lbl = SpatialLabels(data_lbl; + instance_map=Dict{Int32,Int32}(i=>i for i in 1:5), + coord_system="global") + + ds = SpatialDataset() + push!(ds, CoordinateSystem("global"; units=("µm","µm"))) + ds["transcripts"] = pts + ds["cells"] = shp + ds["expression"] = tbl + ds["dapi"] = img + ds["seg"] = lbl + + with_logger(SimpleLogger(stderr, Logging.Error)) do + write(ds, path, SpatialDataZarr()) + end + close(ds; discard=true) + + ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), path) + end + + @test haskey(ds2.elements, "transcripts") + @test haskey(ds2.elements, "cells") + @test haskey(ds2.relations, "expression") + @test haskey(ds2.elements, "dapi") + @test haskey(ds2.elements, "seg") + + pts2 = points(ds2, "transcripts") + @test length(pts2) == n + @test features(pts2) == genes + + shp2 = shapes(ds2, "cells") + @test length(shp2) == 10 + + tbl2 = relations(ds2, "expression") + @test nobs(tbl2) == 10 + @test nvar(tbl2) == 3 + @test var_names(tbl2) == genes + @test tbl2.weights ≈ X + + img2 = images(ds2, "dapi") + @test nchannels(img2) == 2 + @test channel_names(img2) == ["DAPI","GFP"] + + lbl2 = labels(ds2, "seg") + @test size(lbl2.data) == (8, 8) + @test lbl2.data == data_lbl + + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + + xenium_path = "/home/kevin/Repos/stx_dev/test_data/experiments/xenium_ex.zarr" + if isdir(xenium_path) + @testset "Python SpatialData read — Xenium smoke test" begin + ds = with_logger(SimpleLogger(stderr, Logging.Error)) do + read(SpatialDataZarr(), xenium_path) + end + + @test haskey(ds.elements, "morphology_focus") + @test haskey(ds.elements, "cell_labels") + @test haskey(ds.elements, "cell_boundaries") + @test haskey(ds.elements, "transcripts") + @test haskey(ds.relations, "table") + + img = images(ds, "morphology_focus") + @test img isa SpatialImage + @test nchannels(img) == 4 + @test length(img.pyramid) >= 1 + + shp = shapes(ds, "cell_boundaries") + @test length(shp) > 0 + + pts = points(ds, "transcripts") + @test length(pts) > 0 + @test length(features(pts)) > 0 + + tbl = relations(ds, "table") + @test nvar(tbl) == 377 + @test nobs(tbl) > 0 + @test length(var_names(tbl)) == 377 + @test all(!isempty, var_names(tbl)) + @test length(obs_names(tbl)) == nobs(tbl) + end + end + +end + +@testset "SpatialOmics M8" begin + + cosmx_path = "/home/kevin/Repos/stx_dev/test_data/experiments/cosmx_ex_raw/flatFiles/mw_mus_p1_11" + if isdir(cosmx_path) + @testset "CosMx reader — smoke test" begin + ds = read(CosMx(), cosmx_path) + + @test haskey(ds.elements, "transcripts") + @test haskey(ds.elements, "cells") + @test haskey(ds.elements, "fovs") + + pts = points(ds, "transcripts") + @test length(pts) > 0 + @test length(features(pts)) > 0 + @test coord_system(pts) == "global_px" + + shp = shapes(ds, "cells") + @test length(shp) > 0 + @test coord_system(shp) == "global_px" + + # per-FOV coord systems and transforms registered + @test haskey(ds.coord_systems, "global_px") + @test any(cs -> startswith(cs, "fov_"), keys(ds.coord_systems)) + @test any(t -> startswith(t.src, "fov_") && t.dst == "global_px", + ds.transforms) + + fovshp = shapes(ds, "fovs") + n_fovs = count(cs -> startswith(cs, "fov_"), keys(ds.coord_systems)) + @test length(fovshp) == n_fovs + @test coord_system(fovshp) == "global_px" + @test length(sources(ds)) == n_fovs + @test origin_ids(pts) !== nothing + @test origin_ids(shp) !== nothing + @test length(features(pts, :z)) == length(pts) + @test length(features(pts, :CellComp)) == length(pts) + @test length(features(pts, :fov)) == length(pts) + @test length(features(pts, :cell_ID)) == length(pts) + @test !haskey(ds.metadata, "transcripts_annotations") + first_source = first(sources(ds)) + @test length(points(view(ds, first_source), "transcripts")) > 0 + end + end + +end + +@testset "SpatialOmics M11" begin + + using GeometryBasics, Random + + # ── fixtures: 3 square cells, 6 known transcripts ───────────────────────── + cells = SpatialShapes( + [Polygon([Point2f(0,0), Point2f(10,0), Point2f(10,10), Point2f(0,10), Point2f(0,0)]), + Polygon([Point2f(20,0), Point2f(30,0), Point2f(30,10), Point2f(20,10), Point2f(20,0)]), + Polygon([Point2f(40,0), Point2f(50,0), Point2f(50,10), Point2f(40,10), Point2f(40,0)])]; + instance_id=Int32.([1, 2, 3])) + + pts = SpatialPoints( + [Point2f(5,5), Point2f(5,5), + Point2f(25,5), Point2f(25,5), + Point2f(45,5), Point2f(45,5)]; + feature_id=Int32.([1,2,1,2,1,2]), + feature_codebook=["GeneA","GeneB"]) + + # ── analyze(Expression()) ───────────────────────────────────────────────── + + @testset "analyze Expression" begin + rel = analyze(Expression(), pts, cells) + @test rel.kind isa Expression + @test nobs(rel) == 3 + @test nvar(rel) == 2 + @test size(rel.weights) == (3, 2) + @test all(rel.weights .== 1f0) + @test var_names(rel) == ["GeneA","GeneB"] + @test length(rel.src_ids) == 3 + end + + # ── analyze(Membership()) ───────────────────────────────────────────────── + + @testset "analyze Membership" begin + rel = analyze(Membership(), pts, cells) + @test rel.kind isa Membership + @test nobs(rel) == 6 + @test rel.dst_ids == Int32[1,1,2,2,3,3] + @test rel.weights === nothing + end + + @testset "multipolygon point joins preserve object identity" begin + left = Polygon([ + Point2f(0, 0), Point2f(2, 0), Point2f(2, 2), Point2f(0, 2), Point2f(0, 0), + ]) + right = Polygon([ + Point2f(10, 0), Point2f(12, 0), Point2f(12, 2), Point2f(10, 2), Point2f(10, 0), + ]) + multi_cells = SpatialShapes( + [MultiPolygon([left, right])]; instance_id=Int32[41], + ) + multi_points = SpatialPoints( + [Point2f(1, 1), Point2f(11, 1), Point2f(6, 1)]; + feature_id=Int32[1, 1, 1], feature_codebook=["GeneA"], + ) + + membership = analyze(Membership(), multi_points, multi_cells) + @test source_ids(membership) == Int32[1, 2] + @test destination_ids(membership) == Int32[41, 41] + @test analyze(Expression(), multi_points, multi_cells).weights == Float32[2;;] + + overlapping_parts = SpatialShapes( + [MultiPolygon([left, left])]; instance_id=Int32[42], + ) + duplicate_safe = analyze(Membership(), multi_points, overlapping_parts) + @test source_ids(duplicate_safe) == Int32[1] + @test destination_ids(duplicate_safe) == Int32[42] + end + + @testset "multipolygon storage roundtrip" begin + left = Polygon([ + Point2f(0, 0), Point2f(2, 0), Point2f(2, 2), Point2f(0, 2), Point2f(0, 0), + ]) + right = Polygon([ + Point2f(10, 0), Point2f(12, 0), Point2f(12, 2), Point2f(10, 2), Point2f(10, 0), + ]) + mktempdir() do path + ds = SpatialDataset() + ds["objects"] = SpatialShapes( + [MultiPolygon([left, right])]; instance_id=Int32[7], + ) + save!(ds; path) + close(ds) + + stored = read(SpatialDataZarr(), path) + object = only(geometries(shapes(stored, "objects"))) + @test object isa MultiPolygon + @test GeoInterface.ngeom(object) == 2 + @test instance_id(shapes(stored, "objects")) == Int32[7] + close(stored) + end + end + + # ── default dispatch ────────────────────────────────────────────────────── + + @testset "default dispatch pts+shapes → Expression" begin + rel = analyze(pts, cells) + @test rel.kind isa Expression + end + + @testset "default dispatch shapes+shapes → Membership" begin + rel = analyze(cells, cells) + @test rel.kind isa Membership + end + + # ── annotate — pure, shared weights ────────────────────────────────────── + + @testset "annotate" begin + rel = analyze(Expression(), pts, cells) + labels = ["T","B","M"] + rel2 = annotate(rel, labels; key=:cell_type) + @test hasproperty(rel2.obs, :cell_type) + @test rel2.obs.cell_type == labels + @test rel2.weights === rel.weights # no copy + end + + # ── getindex — Expression relation ─────────────────────────────────────── + + @testset "getindex — scalar and slices by instance_id" begin + rel = analyze(Expression(), pts, cells) + # scalar: one cell, one gene + @test rel[1, "GeneA"] isa Float32 + @test rel[1, "GeneA"] == 1f0 + @test rel[2, "GeneB"] == 1f0 + # all cells, one gene → Vector + v = rel[:, "GeneA"] + @test v isa Vector{Float32} + @test length(v) == 3 + @test all(v .== 1f0) + # one cell, all genes → Vector + r = rel[1, :] + @test r isa Vector{Float32} + @test length(r) == 2 + # multi-row by instance_ids, one gene → Vector + v2 = rel[[1, 3], "GeneA"] + @test v2 isa Vector{Float32} + @test length(v2) == 2 + # multi-row, multi-gene → Matrix + M = rel[[1, 3], ["GeneA", "GeneB"]] + @test M isa Matrix{Float32} + @test size(M) == (2, 2) + # all cells, multi-gene → Matrix + @test size(rel[:, ["GeneA", "GeneB"]]) == (3, 2) + # multi-row, all genes → Matrix + @test size(rel[[1, 2], :]) == (2, 2) + end + + @testset "getindex — string row via obs.name" begin + rel = analyze(Expression(), pts, cells) + rel_named = annotate(rel, ["cell_T", "cell_B", "cell_M"]; key=:name) + @test rel_named["cell_T", "GeneA"] isa Float32 + @test rel_named["cell_T", "GeneA"] == 1f0 + v = rel_named[["cell_T", "cell_M"], :] + @test size(v) == (2, 2) + @test all(v .== 1f0) + end + + @testset "getindex — error cases" begin + rel = analyze(Expression(), pts, cells) + @test_throws ErrorException rel[99, "GeneA"] # instance_id not found + @test_throws ErrorException rel[1, "NoGene"] # gene name not found + @test_throws ErrorException rel["cell_T", "GeneA"] # no obs.name column + end + + # ── obs_names ───────────────────────────────────────────────────────────── + + @testset "obs_names" begin + rel = analyze(Expression(), pts, cells) + # no obs.name → fallback to string.(src_ids) + @test obs_names(rel) == string.(rel.src_ids) + @test length(obs_names(rel)) == 3 + # with obs.name via annotate + rel_named = annotate(rel, ["T_cell", "B_cell", "Mac"]; key=:name) + @test obs_names(rel_named) == ["T_cell", "B_cell", "Mac"] + end + + # ── multi-level analyze ─────────────────────────────────────────────────── + + @testset "multi-level analyze: cells → ROIs by cell_type" begin + # roi1 covers cells 1+2 (centroids at (5,5) and (25,5)) + # roi2 covers cell 3 (centroid at (45,5)) + rois = SpatialShapes( + [Polygon([Point2f(-1,-1), Point2f(35,-1), Point2f(35,11), + Point2f(-1,11), Point2f(-1,-1)]), + Polygon([Point2f(35,-1), Point2f(55,-1), Point2f(55,11), + Point2f(35,11), Point2f(35,-1)])]; + instance_id=Int32.([10, 20])) + cell_obs = (cell_type = ["TypeA", "TypeA", "TypeB"],) + rel2 = analyze(cells, rois, cell_obs; by=:cell_type) + @test rel2.kind isa Expression + @test nobs(rel2) == 2 + @test nvar(rel2) == 2 + @test sort(var_names(rel2)) == ["TypeA", "TypeB"] + typeA_col = findfirst(==("TypeA"), var_names(rel2)) + typeB_col = findfirst(==("TypeB"), var_names(rel2)) + roi1_row = findfirst(==(Int32(10)), rel2.src_ids) + roi2_row = findfirst(==(Int32(20)), rel2.src_ids) + @test rel2.weights[roi1_row, typeA_col] == 2f0 + @test rel2.weights[roi1_row, typeB_col] == 0f0 + @test rel2.weights[roi2_row, typeA_col] == 0f0 + @test rel2.weights[roi2_row, typeB_col] == 1f0 + end + + # ── distances ───────────────────────────────────────────────────────────── + + @testset "distances shapes→shapes" begin + d = distances(cells, cells) + @test length(d) == length(cells) + @test all(d .>= 0f0) + end + + # ── PointDensity and ShapeColorView struct construction ─────────────────── + + @testset "PointDensity construction" begin + pd = density(pts; resolution=64, feature="GeneA") + @test pd isa PointDensity + @test pd.resolution == 64 + @test pd.feature == "GeneA" + end + + @testset "ShapeColorView construction" begin + rel = analyze(Expression(), pts, cells) + rel2 = annotate(rel, ["T","B","M"]; key=:cell_type) + scv = ShapeColorView(cells, rel2, :cell_type, :tab10) + @test scv isa ShapeColorView + @test scv.color_by == :cell_type + end + + # ── SpatialRelation zarr round-trip ─────────────────────────────────────── + + @testset "analyze + zarr round-trip" begin + path = mktempdir() + try + ds = SpatialDataset() + ds["cells"] = cells # attach before analyze so _element_name resolves + rel = analyze(Expression(), pts, cells) + rel = annotate(rel, ["T","B","M"]; key=:cell_type) + ds["expr"] = rel + write(ds, path, SpatialDataZarr()) + close(ds; discard=true) + ds2 = read(SpatialDataZarr(), path) + rel2 = relations(ds2, "expr") + @test nobs(rel2) == 3 + @test nvar(rel2) == 2 + @test rel2.weights ≈ rel.weights atol=1e-5 + @test rel2.src == "cells" + close(ds2; discard=true) + finally + rm(path; recursive=true, force=true) + end + end + +end # M11 + +# ── M12 — Real data fixtures ────────────────────────────────────────────────── +# These tests use committed zarr fixtures in test/data/ and run unconditionally +# in CI. Generate the fixtures locally with: julia --project=. test/make_fixtures.jl + +@testset "SpatialOmics M12 — Real data fixtures" begin + + xenium_path = joinpath(@__DIR__, "data", "xenium_small.zarr") + @testset "Xenium fixture" begin + if !isdir(xenium_path) + @warn "Xenium fixture not found at $xenium_path — run test/make_fixtures.jl to generate it" + else + ds = read(SpatialDataZarr(), xenium_path) + tx = points(ds, "transcripts") + shp = shapes(ds, "cell_boundaries") + img = images(ds, "morphology_focus") + lbl = labels(ds, "cell_labels") + + @test length(coords(tx)) > 500 + @test length(top_features(tx, 5)) == 5 + @test length(tx.instance_id) == length(coords(tx)) # structure check; values may be 0 for Python-source fixtures + + @test length(geometries(shp)) > 10 + + @test nchannels(img) == 4 + @test length(channel_names(img)) == 4 + + @test size(data(lbl), 1) > 0 + @test size(data(lbl), 2) > 0 + + @test !isempty(coord_systems(ds)) + + close(ds; discard=true) + end + end + + visium_path = joinpath(@__DIR__, "data", "visium_small.zarr") + @testset "Visium fixture" begin + if !isdir(visium_path) + @warn "Visium fixture not found at $visium_path — run test/make_fixtures.jl to generate it" + else + ds = read(SpatialDataZarr(), visium_path) + shp = shapes(ds, "Visium_HD_Mouse_Small_Intestine_square_016um") + img = images(ds, "Visium_HD_Mouse_Small_Intestine_lowres_image") + + @test length(geometries(shp)) > 50 + + @test ndims(data(img)) >= 2 + @test size(data(img), 1) > 0 + + close(ds; discard=true) + end + end + +end # M12 diff --git a/test/runtests.jl b/test/runtests.jl index 4ad2bda..8e7920a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,1456 +1,3 @@ -using SpatialOmics -using Test - -@testset "SpatialOmics M1" begin - - @testset "CoordinateSystem" begin - cs = CoordinateSystem("global"; axes=(:x, :y), units=("µm", "µm")) - @test cs.name == "global" - @test cs.axes == (:x, :y) - @test cs.units == ("µm", "µm") - end - - @testset "Transformations — construction" begin - t = SpatialOmics.translation(10.0, -5.0, "fov_1", "global") - @test t isa Affine - @test t.src == "fov_1" - @test t.dst == "global" - - s = SpatialOmics.scaling(2.0, 2.0, "px", "µm") - @test s isa Affine - - r = SpatialOmics.rotation(π/4, "a", "b") - @test r isa Affine - - f = SpatialOmics.flip_y("local", "global") - @test f isa Affine - end - - @testset "Transformations — apply" begin - # translation - pts = [1.0 2.0; 3.0 4.0] # 2×2 - t = SpatialOmics.translation(10.0, 20.0, "a", "b") - out = apply(t, pts) - @test out ≈ [11.0 22.0; 13.0 24.0] - - # flip_y - f = SpatialOmics.flip_y("a", "b") - out2 = apply(f, pts) - @test out2 ≈ [1.0 -2.0; 3.0 -4.0] - - # identity - id = Identity("a", "b") - @test apply(id, pts) === pts - - # compose two translations - t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") - t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") - tc = SpatialOmics.compose(t1, t2) - @test apply(tc, [0.0 0.0]) ≈ [1.0 1.0] - end - - @testset "Transformations — resolve / Dijkstra" begin - transforms = AbstractTransformation[ - SpatialOmics.translation(100.0, 200.0, "fov_1", "global"), - SpatialOmics.scaling(0.5, 0.5, "px", "µm"), - ] - t = resolve(transforms, "fov_1", "global") - @test t isa Affine - - # no path - @test_throws ErrorException resolve(transforms, "nowhere", "global") - - # identity (same src == dst) - t2 = resolve(transforms, "global", "global") - @test t2 isa Identity - end - - @testset "Sequence apply" begin - t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") - t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") - seq = Sequence([t1, t2], "a", "c") - out = apply(seq, [0.0 0.0]) - @test out ≈ [1.0 1.0] - end - - @testset "apply on SVector / Point2f" begin - using StaticArrays - t = SpatialOmics.translation(10.0, 20.0, "a", "b") - - # single SVector{2} - p = SVector(1.0, 2.0) - out = apply(t, p) - @test out isa SVector{2} - @test out ≈ SVector(11.0, 22.0) - - # Identity on SVector - id = Identity("a", "b") - @test apply(id, p) === p - - # vector of SVectors - pts = [SVector(0.0, 0.0), SVector(1.0, 1.0), SVector(2.0, 3.0)] - outs = apply(t, pts) - @test outs isa Vector - @test outs[1] ≈ SVector(10.0, 20.0) - @test outs[3] ≈ SVector(12.0, 23.0) - - # Identity on vector of SVectors - @test apply(id, pts) === pts - - # Sequence on vector of SVectors - t1 = SpatialOmics.translation(1.0, 0.0, "a", "b") - t2 = SpatialOmics.translation(0.0, 1.0, "b", "c") - seq = Sequence([t1, t2], "a", "c") - svec_pts = [SVector(0.0, 0.0)] - @test apply(seq, svec_pts)[1] ≈ SVector(1.0, 1.0) - - # flip_y on SVector - f = SpatialOmics.flip_y("a", "b") - @test apply(f, SVector(3.0, 4.0)) ≈ SVector(3.0, -4.0) - end - - @testset "BackingStore — tempdir" begin - bs = BackingStore() - @test isdir(bs.path) - @test startswith(basename(bs.path), "spatialomics_") - @test bs.owned == true - zarr_json = joinpath(bs.path, "zarr.json") - @test isfile(zarr_json) - p = bs.path - SpatialOmics._cleanup!(bs) - @test !isdir(p) - end - - @testset "BackingStore — user path" begin - mktempdir() do d - bs = BackingStore(; path=d) - @test bs.owned == false - @test isfile(joinpath(bs.path, "zarr.json")) - end - end - - @testset "SpatialDataset — construction + cleanup" begin - ds = SpatialDataset() - p = ds.backing.path - @test isdir(p) - @test ds.backing.owned == true - close(ds) - @test !isdir(p) - end - - @testset "SpatialDataset — keep!" begin - mktempdir() do d - target = joinpath(d, "myds.zarr") - ds = SpatialDataset() - p = ds.backing.path - keep!(ds, target) - @test isdir(target) - @test ds.backing.owned == false - @test !isdir(p) # scratch removed after copy - close(ds) # should be a no-op (owned=false) - @test isdir(target) - end - end - - @testset "SpatialDataset — with_dataset" begin - path_ref = Ref("") - with_dataset() do ds - path_ref[] = ds.backing.path - @test isdir(ds.backing.path) - end - @test !isdir(path_ref[]) - end - - @testset "SpatialDataset — coord systems and transforms" begin - ds = SpatialDataset() - try - push!(ds, CoordinateSystem("global"; units=("µm", "µm"))) - push!(ds, CoordinateSystem("fov_1"; axes=(:x, :y), units=("px", "px"))) - @test "global" in coord_systems(ds) - @test "fov_1" in coord_systems(ds) - - t = SpatialOmics.translation(500.0, 300.0, "fov_1", "global") - push!(ds, t) - resolved = transform(ds, "fov_1", "global") - @test resolved isa Affine - - pts = [0.0 0.0; 10.0 20.0] - out = apply(resolved, pts) - @test out ≈ [500.0 300.0; 510.0 320.0] - finally - close(ds) - end - end - - @testset "SpatialDataset — element setindex/getindex" begin - ds = SpatialDataset() - try - ds["test"] = (x = 1, y = 2) - @test haskey(ds, "test") - @test ds["test"] == (x = 1, y = 2) - @test "test" in collect(keys(ds)) - finally - close(ds) - end - end - -end - -@testset "SpatialOmics M2" begin - - using GeometryBasics - using GeoInterface - - @testset "SpatialPoints — bare constructor" begin - pts = SpatialPoints([Point2f(1, 2), Point2f(3, 4), Point2f(5, 6)]; coord_system="global") - @test pts isa SpatialPoints{Float32} - @test length(pts) == 3 - @test coord_system(pts) == "global" - @test coords(pts)[1] == Point2f(1, 2) - @test features(pts) == String[] - @test all(feature_ids(pts) .== 0) - @test all(pts.instance_id .== 0) # internal field - end - - @testset "SpatialPoints — Tables constructor" begin - pts = SpatialPoints( - (x = [1.0f0, 2.0f0, 3.0f0], - y = [4.0f0, 5.0f0, 6.0f0], - gene = ["Actb", "Gapdh", "Actb"]); - gene=:gene, coord_system="fov_1") - @test length(pts) == 3 - @test coord_system(pts) == "fov_1" - @test length(features(pts)) == 2 - @test "Actb" in features(pts) - @test "Gapdh" in features(pts) - # Actb entries share feature_id, Gapdh has a different one - @test features(pts)[feature_ids(pts)[1]] == "Actb" - @test feature_ids(pts)[1] == feature_ids(pts)[3] - @test feature_ids(pts)[2] != feature_ids(pts)[1] - end - - @testset "SpatialPoints — GeoInterface" begin - coords = [Point2f(0, 0), Point2f(1, 0), Point2f(0, 1)] - pts = SpatialPoints(coords) - @test GeoInterface.isgeometry(pts) - @test GeoInterface.geomtrait(pts) isa GeoInterface.MultiPointTrait - @test GeoInterface.ngeom(GeoInterface.geomtrait(pts), pts) == 3 - @test GeoInterface.getgeom(GeoInterface.geomtrait(pts), pts, 1) == Point2f(0, 0) - end - - @testset "SpatialPoints — apply (copy)" begin - pts = SpatialPoints([Point2f(0, 0), Point2f(1, 0)]; coord_system="fov_1") - t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") - pts2 = apply(t, pts) - @test pts2 isa SpatialPoints{Float32} - @test coord_system(pts2) == "global" - @test coords(pts2)[1] ≈ Point2f(10, 20) - @test coords(pts2)[2] ≈ Point2f(11, 20) - @test coords(pts)[1] == Point2f(0, 0) # original unchanged - @test coord_system(pts) == "fov_1" - end - - @testset "SpatialPoints — apply! (in-place)" begin - pts = SpatialPoints([Point2f(0, 0), Point2f(1, 0)]; coord_system="fov_1") - t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") - result = apply!(t, pts) - @test result === pts # same object - @test coord_system(pts) == "global" - @test coords(pts)[1] ≈ Point2f(10, 20) - @test coords(pts)[2] ≈ Point2f(11, 20) - end - - @testset "SpatialShapes — construction" begin - shp = SpatialShapes( - [Polygon([Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)]), - Polygon([Point2f(2,2), Point2f(3,2), Point2f(3,3), Point2f(2,3), Point2f(2,2)])]; - coord_system="global") - @test shp isa SpatialShapes{<:Polygon} - @test length(shp) == 2 - @test coord_system(shp) == "global" - @test length(geometries(shp)) == 2 - end - - @testset "SpatialShapes — GeoInterface" begin - ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] - shp = SpatialShapes([Polygon(ring)]) - @test GeoInterface.isgeometry(shp) - @test GeoInterface.geomtrait(shp) isa GeoInterface.GeometryCollectionTrait - @test GeoInterface.ngeom(GeoInterface.geomtrait(shp), shp) == 1 - @test GeoInterface.getgeom(GeoInterface.geomtrait(shp), shp, 1) isa Polygon - end - - @testset "SpatialShapes — apply (copy)" begin - ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] - shp = SpatialShapes([Polygon(ring)]; coord_system="fov_1") - t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") - shp2 = apply(t, shp) - @test coord_system(shp2) == "global" - @test GeoInterface.coordinates(geometries(shp2)[1])[1][1][1] ≈ 10.0 # x shifted - @test GeoInterface.coordinates(geometries(shp2)[1])[1][1][2] ≈ 20.0 # y shifted - @test coord_system(shp) == "fov_1" # original unchanged - end - - @testset "SpatialShapes — iteration and filter" begin - rings = [[Point2f(i,0), Point2f(i+1,0), Point2f(i+1,1), Point2f(i,1), Point2f(i,0)] - for i in 0:2] - shp = SpatialShapes(Polygon.(rings); - instance_id=Int32[10, 20, 30], coord_system="global") - - @test length(collect(shp)) == 3 - @test eltype(shp) <: SpatialShape - - row = shp[2] - @test row isa SpatialShape - @test row.instance_id == Int32(20) - @test row.coord_system == "global" - - kept = filter(s -> s.instance_id in [10, 30], shp) - @test length(kept) == 2 - @test kept.instance_id == Int32[10, 30] - @test coord_system(kept) == "global" - end - - @testset "SpatialShapes — apply! (in-place)" begin - ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] - shp = SpatialShapes([Polygon(ring)]; coord_system="fov_1") - t = SpatialOmics.translation(10.0, 20.0, "fov_1", "global") - result = apply!(t, shp) - @test result === shp - @test coord_system(shp) == "global" - @test GeoInterface.coordinates(geometries(shp)[1])[1][1][1] ≈ 10.0 - @test GeoInterface.coordinates(geometries(shp)[1])[1][1][2] ≈ 20.0 - end - - @testset "Typed dataset accessors" begin - ds = SpatialDataset() - try - coords = [Point2f(0, 0), Point2f(1, 1)] - pts = SpatialPoints(coords; coord_system="global") - ds["transcripts"] = pts - @test points(ds, "transcripts") === pts - - ring = [Point2f(0,0), Point2f(2,0), Point2f(2,2), Point2f(0,2), Point2f(0,0)] - shp = SpatialShapes([Polygon(ring)]; coord_system="global") - ds["cells"] = shp - @test shapes(ds, "cells") === shp - - @test_throws ErrorException points(ds, "cells") # wrong type - @test_throws ErrorException shapes(ds, "transcripts") - finally - close(ds) - end - end - -end - -@testset "SpatialOmics M3" begin - - using GeometryBasics - using GeoInterface - - # ── shared fixtures ──────────────────────────────────────────────────────── - - pts = SpatialPoints( - [Point2f(x, y) for x in 0f0:1f0:4f0 for y in 0f0:1f0:4f0]; - coord_system="global") # 5×5 grid: 25 points - - shp = SpatialShapes( - [Polygon([Point2f(x,y), Point2f(x+1,y), Point2f(x+1,y+1), - Point2f(x,y+1), Point2f(x,y)]) - for x in 0f0:2f0:4f0 for y in 0f0:2f0:4f0]; - coord_system="global") # 3×3 = 9 unit squares - - # ── SpatialExtent ────────────────────────────────────────────────────────── - - @testset "SpatialExtent construction" begin - ext = SpatialExtent(0, 2, 0, 2; coord_system="global") - @test ext.xmin == 0.0 - @test ext.xmax == 2.0 - @test coord_system(ext) == "global" - end - - @testset "SpatialExtent from SpatialShapes" begin - rings = [[Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)], - [Point2f(2,2), Point2f(4,2), Point2f(4,5), Point2f(2,5), Point2f(2,2)]] - shp_ext = SpatialShapes(Polygon.(rings); instance_id=Int32[1,2], coord_system="global") - ext = SpatialExtent(shp_ext) - @test ext.xmin == 0.0 && ext.xmax == 4.0 - @test ext.ymin == 0.0 && ext.ymax == 5.0 - @test coord_system(ext) == "global" - - # filter then extent — the idiomatic pipeline - ext2 = SpatialExtent(filter(s -> s.instance_id == Int32(1), shp_ext)) - @test ext2.xmax == 1.0 - end - - @testset "SpatialExtent union" begin - a = SpatialExtent(0, 2, 0, 2; coord_system="g") - b = SpatialExtent(1, 4, 1, 3; coord_system="g") - u = a ∪ b - @test u.xmin == 0.0 && u.xmax == 4.0 - @test u.ymin == 0.0 && u.ymax == 3.0 - @test coord_system(u) == "g" - @test_throws ErrorException SpatialExtent(0,1,0,1;coord_system="a") ∪ - SpatialExtent(0,1,0,1;coord_system="b") - end - - @testset "SpatialExtent intersect" begin - a = SpatialExtent(0, 3, 0, 3; coord_system="g") - b = SpatialExtent(1, 4, 1, 4; coord_system="g") - i = a ∩ b - @test i isa SpatialExtent - @test i.xmin == 1.0 && i.xmax == 3.0 - @test i.ymin == 1.0 && i.ymax == 3.0 - # non-overlapping - c = SpatialExtent(5, 6, 5, 6; coord_system="g") - @test isnothing(a ∩ c) - # touching at edge — not an overlap - d = SpatialExtent(3, 5, 0, 3; coord_system="g") - @test isnothing(a ∩ d) - end - - # ── SpatialROI ───────────────────────────────────────────────────────────── - - @testset "SpatialROI construction" begin - ring = [Point2f(0,0), Point2f(2,0), Point2f(2,2), Point2f(0,2), Point2f(0,0)] - poly = Polygon(ring) - roi = SpatialROI(poly; coord_system="global") - @test geometry(roi) === poly - @test coord_system(roi) == "global" - @test roi.extent.xmin ≈ 0.0 - @test roi.extent.xmax ≈ 2.0 - end - - # ── view on SpatialPoints with SpatialExtent ─────────────────────────────── - - @testset "view(pts, SpatialExtent)" begin - ext = SpatialExtent(0, 2, 0, 2; coord_system="global") - v = view(pts, ext) - @test v isa SpatialElementView{<:SpatialPoints, SpatialExtent} - @test coord_system(v) == "global" - # x∈{0,1,2} × y∈{0,1,2} → 9 points - @test length(v) == 9 - end - - @testset "collect(view(pts, SpatialExtent))" begin - ext = SpatialExtent(0, 2, 0, 2; coord_system="global") - sub = collect(view(pts, ext)) - @test sub isa SpatialPoints - @test length(sub) == 9 - @test coord_system(sub) == "global" - @test all(p -> p[1] <= 2.0 && p[2] <= 2.0, coords(sub)) - end - - # ── view on SpatialShapes with SpatialExtent ─────────────────────────────── - - @testset "view(shp, SpatialExtent)" begin - ext = SpatialExtent(0, 3, 0, 3; coord_system="global") - sub = collect(view(shp, ext)) - @test sub isa SpatialShapes - @test coord_system(sub) == "global" - @test length(sub) >= 1 - end - - # ── overlap=:any vs :full ────────────────────────────────────────────────── - - @testset "view(shp, SpatialExtent) — overlap modes" begin - # ext straddles corners of several unit squares: only the (2,2) square - # sits fully inside 1..3 × 1..3; the three corner squares touch the boundary - ext = SpatialExtent(1, 3, 1, 3; coord_system="global") - n_any = length(view(shp, ext)) - n_full = length(view(shp, ext; overlap=:full)) - @test n_any > n_full - @test n_full >= 1 - sub = collect(view(shp, ext; overlap=:full)) - # all coords of fully-inside shapes lie within [1,3]×[1,3] - @test all(geometries(sub)) do g - all(Iterators.flatten(GeoInterface.coordinates(g))) do pt - 1.0 <= pt[1] <= 3.0 && 1.0 <= pt[2] <= 3.0 - end - end - end - - @testset "view — invalid overlap raises error" begin - ext = SpatialExtent(0, 2, 0, 2; coord_system="global") - @test_throws ErrorException view(pts, ext; overlap=:partial) - end - - # ── view on SpatialPoints with SpatialROI (polygon) ─────────────────────── - - @testset "view(pts, SpatialROI)" begin - tri = Polygon([Point2f(0,0), Point2f(3,0), Point2f(1.5,3), Point2f(0,0)]) - roi = SpatialROI(tri; coord_system="global") - sub = collect(view(pts, roi)) - @test sub isa SpatialPoints - @test length(sub) <= length(pts) - @test all(p -> p[1] <= 3.0 && p[2] <= 3.0, coords(sub)) - end - - # ── coord system mismatch ────────────────────────────────────────────────── - - @testset "coord system mismatch" begin - ext = SpatialExtent(0, 2, 0, 2; coord_system="other") - @test_throws ErrorException view(pts, ext) - end - - # ── SpatialDatasetView ───────────────────────────────────────────────────── - - @testset "SpatialDatasetView" begin - ds = SpatialDataset() - try - push!(ds, CoordinateSystem("global")) - ds["transcripts"] = pts - ds["cells"] = shp - - ext = SpatialExtent(0, 2, 0, 2; coord_system="global") - v_ds = view(ds, ext) - @test v_ds isa SpatialDatasetView - @test haskey(v_ds, "transcripts") - @test "cells" in collect(keys(v_ds)) - - @test v_ds["transcripts"] isa SpatialElementView{<:SpatialPoints} - @test points(v_ds, "transcripts") isa SpatialElementView{<:SpatialPoints} - @test shapes(v_ds, "cells") isa SpatialElementView{<:SpatialShapes} - @test length(collect(points(v_ds, "transcripts"))) == 9 - finally - close(ds) - end - end - - # ── SpatialShapes(::SpatialExtent) and SpatialShapes(::SpatialROI) ────────── - - @testset "SpatialShapes(SpatialExtent)" begin - ext = SpatialExtent(1.0, 3.0, 2.0, 5.0; coord_system="global") - s = SpatialShapes(ext) - @test s isa SpatialShapes - @test length(s) == 1 - @test s.instance_id == Int32[1] - @test coord_system(s) == "global" - ring = GeoInterface.coordinates(s.geometries[1])[1] - @test length(ring) == 5 - @test ring[1] ≈ ring[end] - xs = [p[1] for p in ring]; ys = [p[2] for p in ring] - @test minimum(xs) ≈ 1.0 && maximum(xs) ≈ 3.0 - @test minimum(ys) ≈ 2.0 && maximum(ys) ≈ 5.0 - end - - @testset "SpatialShapes(SpatialROI)" begin - ring = [Point2f(0,0), Point2f(2,0), Point2f(1,2), Point2f(0,0)] - roi = SpatialROI(Polygon(ring); coord_system="global") - s = SpatialShapes(roi) - @test length(s) == 1 - @test coord_system(s) == "global" - r2 = GeoInterface.coordinates(s.geometries[1])[1] - @test r2[1] ≈ r2[end] - end - - @testset "SpatialShapes(SpatialExtent) zarr roundtrip" begin - ext = SpatialExtent(0.0, 10.0, 0.0, 10.0; coord_system="global") - path = mktempdir() - try - ds = SpatialDataset() - push!(ds, CoordinateSystem("global")) - ds["roi"] = SpatialShapes(ext) - write!(ds, path, SpatialDataZarr()) - close(ds) - ds2 = read(SpatialDataZarr(), path) - s2 = shapes(ds2, "roi") - @test length(s2) == 1 - @test coord_system(s2) == "global" - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - -end - -@testset "SpatialOmics M4" begin - - using GeometryBasics - using Random - Random.seed!(7) - - genes = ["Actb", "Gapdh", "Vim"] - pts = SpatialPoints( - (x = rand(Float32, 200) .* 500f0, - y = rand(Float32, 200) .* 500f0, - g = [genes[rand(1:3)] for _ in 1:200]); - x=:x, y=:y, gene=:g, coord_system="px") - - cells = SpatialShapes( - [let cx = rand(Float32)*450f0+25f0, cy = rand(Float32)*450f0+25f0 - Polygon([Point2f(cx-10,cy-10), Point2f(cx+10,cy-10), - Point2f(cx+10,cy+10), Point2f(cx-10,cy+10), - Point2f(cx-10,cy-10)]) - end for _ in 1:15]; - instance_id=Int32.(1:15), coord_system="px") - - @testset "SpatialPoints zarr roundtrip" begin - mktempdir() do path - ds = SpatialDataset() - push!(ds, CoordinateSystem("px"; units=("px","px"))) - ds["pts"] = pts - write(ds, path, SpatialDataZarr()) - close(ds) - - ds2 = read(SpatialDataZarr(), path) - pts2 = points(ds2, "pts") - @test length(pts2) == length(pts) - @test features(pts2) == features(pts) - @test coords(pts2)[1] ≈ coords(pts)[1] - @test coord_system(pts2) == coord_system(pts) - @test pts2.instance_id == pts.instance_id - close(ds2) - end - end - - @testset "SpatialShapes zarr roundtrip" begin - mktempdir() do path - ds = SpatialDataset() - push!(ds, CoordinateSystem("px"; units=("px","px"))) - ds["cells"] = cells - write(ds, path, SpatialDataZarr()) - close(ds) - - ds2 = read(SpatialDataZarr(), path) - cells2 = shapes(ds2, "cells") - @test length(cells2) == length(cells) - @test coord_system(cells2) == coord_system(cells) - @test cells2.instance_id == cells.instance_id - r1 = GeoInterface.coordinates(geometries(cells)[1])[1] - r2 = GeoInterface.coordinates(geometries(cells2)[1])[1] - @test length(r1) == length(r2) - @test all(r1[i][1] ≈ r2[i][1] && r1[i][2] ≈ r2[i][2] for i in eachindex(r1)) - close(ds2) - end - end - - @testset "coord_systems preserved across roundtrip" begin - mktempdir() do path - ds = SpatialDataset(; path) - push!(ds, CoordinateSystem("px"; axes=(:x,:y), units=("px","px"))) - # no explicit write — push! must persist immediately - ds2 = read(SpatialDataZarr(), path) - @test "px" in coord_systems(ds2) - close(ds); close(ds2) - end - end - - @testset "transforms preserved across roundtrip" begin - mktempdir() do path - ds = SpatialDataset(; path) - push!(ds, CoordinateSystem("fov"; axes=(:x,:y), units=("µm","µm"))) - push!(ds, CoordinateSystem("global"; axes=(:x,:y), units=("µm","µm"))) - push!(ds, SpatialOmics.translation(100.0, 200.0, "fov", "global")) - ds2 = read(SpatialDataZarr(), path) - @test length(ds2.transforms) == 1 - t = ds2.transforms[1] - @test t isa Affine - @test t.src == "fov" && t.dst == "global" - @test transform(ds2, "fov", "global") isa AbstractTransformation - close(ds); close(ds2) - end - end - - @testset "setindex! always writes to disk immediately" begin - mktempdir() do path - ds = SpatialDataset(; path) - ds["pts"] = copy(pts) - @test isfile(joinpath(path, "points", "pts", "zarr.json")) - @test isfile(joinpath(path, "points", "pts", "coords", "zarr.json")) - close(ds) - end - end - - @testset "metadata NamedTuple-of-vectors roundtrip" begin - mktempdir() do path - ds = SpatialDataset(; path) - ds.metadata["ann"] = (fov=Int32[1, 1, 2], z=Float32[0.5, 1.0, 0.5], - comp=["Cytoplasm", "Nucleus", "Cytoplasm"]) - @test isdir(joinpath(path, "metadata", "ann")) - - ds2 = read(SpatialDataZarr(), path) - ann = ds2.metadata["ann"] - @test ann.fov == Int32[1, 1, 2] - @test ann.z ≈ Float32[0.5, 1.0, 0.5] - @test ann.comp == ["Cytoplasm", "Nucleus", "Cytoplasm"] - close(ds) - close(ds2) - end - end - - @testset "write produces valid zarr layout" begin - mktempdir() do path - ds = SpatialDataset() - ds["pts"] = copy(pts); ds["cells"] = copy(cells) - write(ds, path, SpatialDataZarr()) - close(ds) - @test isfile(joinpath(path, "zarr.json")) - @test isfile(joinpath(path, "points", "pts", "coords", "zarr.json")) - @test isfile(joinpath(path, "shapes", "cells", "geom_data", "zarr.json")) - @test isfile(joinpath(path, "shapes", "cells", "poly_offsets","zarr.json")) - end - end - -end - -@testset "SpatialOmics M5" begin - - using Logging - - # ── construction ───────────────────────────────────────────────────────────── - - @testset "SpatialImage 2D construction" begin - arr = rand(Float32, 64, 64) - img = SpatialImage(arr; coord_system="px") - @test img.axes == (:y, :x) - @test nchannels(img) == 1 - @test size(img) == (64, 64) - @test isempty(img.pyramid) - @test coord_system(img) == "px" - end - - @testset "SpatialImage 3D construction — (c,y,x)" begin - arr = rand(Float32, 3, 128, 128) - img = SpatialImage(arr; - axes=(:c, :y, :x), - channel_names=["DAPI", "GFP", "RFP"], - coord_system="global") - @test img.axes == (:c, :y, :x) - @test nchannels(img) == 3 - @test channel_names(img) == ["DAPI", "GFP", "RFP"] - @test size(img) == (3, 128, 128) - @test coord_system(img) == "global" - end - - @testset "SpatialImage 3D construction — (y,x,c)" begin - arr = rand(Float32, 128, 128, 3) - img = SpatialImage(arr; - axes=(:y, :x, :c), - channel_names=["DAPI", "GFP", "RFP"], - coord_system="global") - @test img.axes == (:y, :x, :c) - @test nchannels(img) == 3 - @test size(img) == (128, 128, 3) - end - - @testset "SpatialImage pixel_to_cs default" begin - arr = rand(Float32, 64, 64) - img = SpatialImage(arr; coord_system="px") - @test img.pixel_to_cs isa Identity - @test img.pixel_to_cs.src == "pixel" - @test img.pixel_to_cs.dst == "px" - end - - @testset "SpatialImage pixel_to_cs custom" begin - t = SpatialOmics.translation(10.0, 20.0, "pixel", "global") - arr = rand(Float32, 64, 64) - img = SpatialImage(arr; coord_system="global", pixel_to_cs=t) - @test img.pixel_to_cs isa Affine - @test img.pixel_to_cs.src == "pixel" - @test img.pixel_to_cs.dst == "global" - end - - # ── pyramid ─────────────────────────────────────────────────────────────────── - - @testset "build_pyramid! level count" begin - arr = rand(Float32, 3, 128, 128) - img = SpatialImage(arr) - build_pyramid!(img, 3) - @test length(img.pyramid) == 3 - end - - @testset "build_pyramid! spatial dims shrink — (c,y,x)" begin - arr = rand(Float32, 3, 128, 128) - img = SpatialImage(arr; axes=(:c, :y, :x)) - build_pyramid!(img, 2) - @test size(img.pyramid[1], 1) == 3 # channel dim preserved - @test size(img.pyramid[1], 2) < 128 - @test size(img.pyramid[1], 3) < 128 - @test size(img.pyramid[2], 2) < size(img.pyramid[1], 2) - end - - @testset "build_pyramid! spatial dims shrink — (y,x,c)" begin - arr = rand(Float32, 128, 128, 3) - img = SpatialImage(arr; axes=(:y, :x, :c)) - build_pyramid!(img, 2) - @test size(img.pyramid[1], 1) < 128 - @test size(img.pyramid[1], 2) < 128 - @test size(img.pyramid[1], 3) == 3 # channel dim preserved - @test size(img.pyramid[2], 1) < size(img.pyramid[1], 1) - end - - @testset "build_pyramid! 2D (no channel dim)" begin - arr = rand(Float32, 64, 64) - img = SpatialImage(arr) - build_pyramid!(img, 2) - @test length(img.pyramid) == 2 - @test size(img.pyramid[1], 1) < 64 - @test size(img.pyramid[1], 2) < 64 - end - - @testset "build_pyramid! replace clears old levels" begin - arr = rand(Float32, 3, 64, 64) - img = SpatialImage(arr) - build_pyramid!(img, 3) - build_pyramid!(img, 1) - @test length(img.pyramid) == 1 - end - - # ── zarr roundtrip ──────────────────────────────────────────────────────────── - - @testset "SpatialImage zarr roundtrip — data" begin - arr = rand(Float32, 3, 64, 64) - img = SpatialImage(arr; coord_system="px") - path = mktempdir() - try - ds = SpatialDataset() - ds["img"] = img - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - img2 = images(ds2, "img") - @test size(img2.data) == (3, 64, 64) - @test img2.data ≈ arr - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - - @testset "SpatialImage zarr roundtrip — metadata preserved" begin - t = SpatialOmics.translation(5.0, 10.0, "pixel", "global") - arr = rand(Float32, 2, 32, 32) - img = SpatialImage(arr; - axes=(:c, :y, :x), - channel_names=["ch1", "ch2"], - coord_system="global", - pixel_to_cs=t) - path = mktempdir() - try - ds = SpatialDataset() - ds["img"] = img - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - img2 = images(ds2, "img") - @test img2.axes == (:c, :y, :x) - @test channel_names(img2) == ["ch1", "ch2"] - @test coord_system(img2) == "global" - @test img2.pixel_to_cs isa Affine - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - - @testset "SpatialImage zarr roundtrip — pyramid preserved" begin - arr = rand(Float32, 3, 64, 64) - img = SpatialImage(arr; coord_system="px") - build_pyramid!(img, 2) - path = mktempdir() - try - ds = SpatialDataset() - ds["img"] = img - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - img2 = images(ds2, "img") - @test length(img2.pyramid) == 2 - @test size(img2.pyramid[1]) == size(img.pyramid[1]) - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - - @testset "images accessor type error" begin - ds = SpatialDataset() - try - ring = [Point2f(0,0), Point2f(1,0), Point2f(1,1), Point2f(0,1), Point2f(0,0)] - ds["cells"] = SpatialShapes([Polygon(ring)]; coord_system="global") - @test_throws ErrorException images(ds, "cells") - finally - close(ds) - end - end - -end - -@testset "SpatialOmics M6" begin - - using Logging - - # ── shared fixtures ──────────────────────────────────────────────────────── - Random.seed!(42) - n_cells = 50 - n_genes = 4 - genes = ["Actb", "Gapdh", "Col1a1", "Vim"] - - cells = SpatialShapes( - [let cx = Float32(rand()*800+100), cy = Float32(rand()*800+100) - Polygon([Point2f(cx-30,cy-30), Point2f(cx+30,cy-30), - Point2f(cx+30,cy+30), Point2f(cx-30,cy+30), - Point2f(cx-30,cy-30)]) - end for _ in 1:n_cells]; - instance_id=Int32.(1:n_cells), coord_system="global_px") - - X = rand(Float32, n_cells, n_genes) - rel = SpatialRelation(Expression(), "cells", Int32.(1:n_cells), X; - obs=(instance_id=Int32.(1:n_cells),), var=(name=genes,)) - - # ── SpatialRelation construction ─────────────────────────────────────────── - - @testset "SpatialRelation construction" begin - @test nobs(rel) == n_cells - @test nvar(rel) == n_genes - @test var_names(rel) == genes - @test rel.src == "cells" - @test rel.kind isa Expression - end - - @testset "SpatialRelation show" begin - s = sprint(show, rel) - @test contains(s, "SpatialRelation") - @test contains(s, string(n_cells)) - @test contains(s, "cells") - end - - # ── expression weight lookup ─────────────────────────────────────────────── - - @testset "expression weight lookup" begin - actb_col = findfirst(==("Actb"), genes) - @test rel[:, "Actb"] ≈ X[:, actb_col] - @test rel[1, "Actb"] isa Float32 - @test rel[[1, 2], "Actb"] isa Vector{Float32} - end - - @testset "var_names lookup" begin - @test var_names(rel) == genes - @test length(var_names(rel)) == n_genes - end - - # ── passthrough accessors on SpatialElementView ──────────────────────────── - - @testset "geometries on SpatialElementView" begin - ext = SpatialExtent(0, 500, 0, 500; coord_system="global_px") - v = view(cells, ext) - geoms = geometries(v) - @test length(geoms) == length(v) - @test geoms isa Vector - end - - @testset "instance_id on SpatialElementView" begin - ext = SpatialExtent(0, 500, 0, 500; coord_system="global_px") - v = view(cells, ext) - ids = instance_id(v) - @test length(ids) == length(v) - @test ids isa Vector{Int32} - end - - # ── SpatialLabels ────────────────────────────────────────────────────────── - - @testset "SpatialLabels construction" begin - data = zeros(Int32, 64, 64) - data[10:30, 10:30] .= 1 - data[40:60, 40:60] .= 2 - lbl = SpatialLabels(data; - instance_map=Dict{Int32,Int32}(1=>1, 2=>2), - coord_system="global_px") - @test lbl.axes == (:y, :x) - @test coord_system(lbl) == "global_px" - @test length(instance_ids(lbl)) == 2 - @test size(lbl) == (64, 64) - end - - # ── Zarr round-trip ──────────────────────────────────────────────────────── - - @testset "SpatialRelation zarr roundtrip" begin - path = mktempdir() - try - ds = SpatialDataset() - ds["cells"] = cells - ds["expr"] = rel - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - rel2 = relations(ds2, "expr") - @test nobs(rel2) == n_cells - @test nvar(rel2) == n_genes - @test var_names(rel2) == genes - @test rel2.src == "cells" - @test rel2.weights ≈ X atol=1e-5 - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - - @testset "SpatialLabels zarr roundtrip" begin - path = mktempdir() - try - data = rand(Int32.(0:5), 32, 32) - lbl = SpatialLabels(data; - instance_map=Dict{Int32,Int32}(i=>i for i in 1:5), - coord_system="global_px") - ds = SpatialDataset(); ds["seg"] = lbl - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - lbl2 = labels(ds2, "seg") - @test size(lbl2.data) == (32, 32) - @test lbl2.data == data - @test coord_system(lbl2) == "global_px" - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - -end # M6 - -@testset "SpatialOmics M7" begin - - using Random, GeometryBasics, Logging - - @testset "Round-trip — all element kinds" begin - path = mktempdir(; prefix="so_m7_roundtrip_") - try - Random.seed!(1) - genes = ["Actb", "Gapdh", "Col1a1"] - n = 20 - - pts = SpatialPoints( - [Point2f(rand()*100, rand()*100) for _ in 1:n]; - feature_id = Int32.(rand(1:3, n)), - feature_codebook = genes, - instance_id = zeros(Int32, n), - coord_system = "global") - - polys = [let cx=rand()*80+10f0, cy=rand()*80+10f0 - Polygon([Point2f(cx-5,cy-5), Point2f(cx+5,cy-5), - Point2f(cx+5,cy+5), Point2f(cx-5,cy+5), - Point2f(cx-5,cy-5)]) - end for _ in 1:10] - shp = SpatialShapes(polys; instance_id=Int32.(1:10), coord_system="global") - - X = rand(Float32, 10, 3) - tbl = SpatialRelation(Expression(), "cells", Int32.(1:10), X; - obs=(instance_id=Int32.(1:10),), var=(name=genes,)) - - img = SpatialImage(rand(UInt16, 8, 8, 2); - axes=(:y,:x,:c), channel_names=["DAPI","GFP"], coord_system="global") - - data_lbl = Int32.(rand(0:5, 8, 8)) - lbl = SpatialLabels(data_lbl; - instance_map=Dict{Int32,Int32}(i=>i for i in 1:5), - coord_system="global") - - ds = SpatialDataset() - push!(ds, CoordinateSystem("global"; units=("µm","µm"))) - ds["transcripts"] = pts - ds["cells"] = shp - ds["expression"] = tbl - ds["dapi"] = img - ds["seg"] = lbl - - with_logger(SimpleLogger(stderr, Logging.Error)) do - write(ds, path, SpatialDataZarr()) - end - close(ds) - - ds2 = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), path) - end - - @test haskey(ds2.elements, "transcripts") - @test haskey(ds2.elements, "cells") - @test haskey(ds2.relations, "expression") - @test haskey(ds2.elements, "dapi") - @test haskey(ds2.elements, "seg") - - pts2 = points(ds2, "transcripts") - @test length(pts2) == n - @test features(pts2) == genes - - shp2 = shapes(ds2, "cells") - @test length(shp2) == 10 - - tbl2 = relations(ds2, "expression") - @test nobs(tbl2) == 10 - @test nvar(tbl2) == 3 - @test var_names(tbl2) == genes - @test tbl2.weights ≈ X - - img2 = images(ds2, "dapi") - @test nchannels(img2) == 2 - @test channel_names(img2) == ["DAPI","GFP"] - - lbl2 = labels(ds2, "seg") - @test size(lbl2.data) == (8, 8) - @test lbl2.data == data_lbl - - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - - xenium_path = "/home/kevin/Repos/stx_dev/test_data/experiments/xenium_ex.zarr" - if isdir(xenium_path) - @testset "Python SpatialData read — Xenium smoke test" begin - ds = with_logger(SimpleLogger(stderr, Logging.Error)) do - read(SpatialDataZarr(), xenium_path) - end - - @test haskey(ds.elements, "morphology_focus") - @test haskey(ds.elements, "cell_labels") - @test haskey(ds.elements, "cell_boundaries") - @test haskey(ds.elements, "transcripts") - @test haskey(ds.relations, "table") - - img = images(ds, "morphology_focus") - @test img isa SpatialImage - @test nchannels(img) == 4 - @test length(img.pyramid) >= 1 - - shp = shapes(ds, "cell_boundaries") - @test length(shp) > 0 - - pts = points(ds, "transcripts") - @test length(pts) > 0 - @test length(features(pts)) > 0 - - tbl = relations(ds, "table") - @test nvar(tbl) == 377 - @test nobs(tbl) > 0 - @test length(var_names(tbl)) == 377 - @test all(!isempty, var_names(tbl)) - @test length(obs_names(tbl)) == nobs(tbl) - end - end - -end - -@testset "SpatialOmics M8" begin - - cosmx_path = "/home/kevin/Repos/stx_dev/test_data/experiments/cosmx_ex_raw/flatFiles/mw_mus_p1_11" - if isdir(cosmx_path) - @testset "CosMx reader — smoke test" begin - ds = read(CosMx(), cosmx_path) - - @test haskey(ds.elements, "transcripts") - @test haskey(ds.elements, "cells") - @test haskey(ds.elements, "fovs") - - pts = points(ds, "transcripts") - @test length(pts) > 0 - @test length(features(pts)) > 0 - @test coord_system(pts) == "global_px" - - shp = shapes(ds, "cells") - @test length(shp) > 0 - @test coord_system(shp) == "global_px" - - # per-FOV coord systems and transforms registered - @test haskey(ds.coord_systems, "global_px") - @test any(cs -> startswith(cs, "fov_"), keys(ds.coord_systems)) - @test any(t -> startswith(t.src, "fov_") && t.dst == "global_px", - ds.transforms) - - fovshp = shapes(ds, "fovs") - n_fovs = count(cs -> startswith(cs, "fov_"), keys(ds.coord_systems)) - @test length(fovshp) == n_fovs - @test coord_system(fovshp) == "global_px" - - ann = ds.metadata["transcripts_annotations"] - @test length(ann.fov) == length(pts) - @test length(ann.z) == length(pts) - end - end - -end - -@testset "SpatialOmics M11" begin - - using GeometryBasics, Random - - # ── fixtures: 3 square cells, 6 known transcripts ───────────────────────── - cells = SpatialShapes( - [Polygon([Point2f(0,0), Point2f(10,0), Point2f(10,10), Point2f(0,10), Point2f(0,0)]), - Polygon([Point2f(20,0), Point2f(30,0), Point2f(30,10), Point2f(20,10), Point2f(20,0)]), - Polygon([Point2f(40,0), Point2f(50,0), Point2f(50,10), Point2f(40,10), Point2f(40,0)])]; - instance_id=Int32.([1, 2, 3])) - - pts = SpatialPoints( - [Point2f(5,5), Point2f(5,5), - Point2f(25,5), Point2f(25,5), - Point2f(45,5), Point2f(45,5)]; - feature_id=Int32.([1,2,1,2,1,2]), - feature_codebook=["GeneA","GeneB"]) - - # ── analyze(Expression()) ───────────────────────────────────────────────── - - @testset "analyze Expression" begin - rel = analyze(Expression(), pts, cells) - @test rel.kind isa Expression - @test nobs(rel) == 3 - @test nvar(rel) == 2 - @test size(rel.weights) == (3, 2) - @test all(rel.weights .== 1f0) - @test var_names(rel) == ["GeneA","GeneB"] - @test length(rel.src_ids) == 3 - end - - # ── analyze(Membership()) ───────────────────────────────────────────────── - - @testset "analyze Membership" begin - rel = analyze(Membership(), pts, cells) - @test rel.kind isa Membership - @test nobs(rel) == 6 - @test rel.dst_ids == Int32[1,1,2,2,3,3] - @test rel.weights === nothing - end - - # ── default dispatch ────────────────────────────────────────────────────── - - @testset "default dispatch pts+shapes → Expression" begin - rel = analyze(pts, cells) - @test rel.kind isa Expression - end - - @testset "default dispatch shapes+shapes → Membership" begin - rel = analyze(cells, cells) - @test rel.kind isa Membership - end - - # ── annotate — pure, shared weights ────────────────────────────────────── - - @testset "annotate" begin - rel = analyze(Expression(), pts, cells) - labels = ["T","B","M"] - rel2 = annotate(rel, labels; key=:cell_type) - @test hasproperty(rel2.obs, :cell_type) - @test rel2.obs.cell_type == labels - @test rel2.weights === rel.weights # no copy - end - - # ── getindex — Expression relation ─────────────────────────────────────── - - @testset "getindex — scalar and slices by instance_id" begin - rel = analyze(Expression(), pts, cells) - # scalar: one cell, one gene - @test rel[1, "GeneA"] isa Float32 - @test rel[1, "GeneA"] == 1f0 - @test rel[2, "GeneB"] == 1f0 - # all cells, one gene → Vector - v = rel[:, "GeneA"] - @test v isa Vector{Float32} - @test length(v) == 3 - @test all(v .== 1f0) - # one cell, all genes → Vector - r = rel[1, :] - @test r isa Vector{Float32} - @test length(r) == 2 - # multi-row by instance_ids, one gene → Vector - v2 = rel[[1, 3], "GeneA"] - @test v2 isa Vector{Float32} - @test length(v2) == 2 - # multi-row, multi-gene → Matrix - M = rel[[1, 3], ["GeneA", "GeneB"]] - @test M isa Matrix{Float32} - @test size(M) == (2, 2) - # all cells, multi-gene → Matrix - @test size(rel[:, ["GeneA", "GeneB"]]) == (3, 2) - # multi-row, all genes → Matrix - @test size(rel[[1, 2], :]) == (2, 2) - end - - @testset "getindex — string row via obs.name" begin - rel = analyze(Expression(), pts, cells) - rel_named = annotate(rel, ["cell_T", "cell_B", "cell_M"]; key=:name) - @test rel_named["cell_T", "GeneA"] isa Float32 - @test rel_named["cell_T", "GeneA"] == 1f0 - v = rel_named[["cell_T", "cell_M"], :] - @test size(v) == (2, 2) - @test all(v .== 1f0) - end - - @testset "getindex — error cases" begin - rel = analyze(Expression(), pts, cells) - @test_throws ErrorException rel[99, "GeneA"] # instance_id not found - @test_throws ErrorException rel[1, "NoGene"] # gene name not found - @test_throws ErrorException rel["cell_T", "GeneA"] # no obs.name column - end - - # ── obs_names ───────────────────────────────────────────────────────────── - - @testset "obs_names" begin - rel = analyze(Expression(), pts, cells) - # no obs.name → fallback to string.(src_ids) - @test obs_names(rel) == string.(rel.src_ids) - @test length(obs_names(rel)) == 3 - # with obs.name via annotate - rel_named = annotate(rel, ["T_cell", "B_cell", "Mac"]; key=:name) - @test obs_names(rel_named) == ["T_cell", "B_cell", "Mac"] - end - - # ── multi-level analyze ─────────────────────────────────────────────────── - - @testset "multi-level analyze: cells → ROIs by cell_type" begin - # roi1 covers cells 1+2 (centroids at (5,5) and (25,5)) - # roi2 covers cell 3 (centroid at (45,5)) - rois = SpatialShapes( - [Polygon([Point2f(-1,-1), Point2f(35,-1), Point2f(35,11), - Point2f(-1,11), Point2f(-1,-1)]), - Polygon([Point2f(35,-1), Point2f(55,-1), Point2f(55,11), - Point2f(35,11), Point2f(35,-1)])]; - instance_id=Int32.([10, 20])) - cell_obs = (cell_type = ["TypeA", "TypeA", "TypeB"],) - rel2 = analyze(cells, rois, cell_obs; by=:cell_type) - @test rel2.kind isa Expression - @test nobs(rel2) == 2 - @test nvar(rel2) == 2 - @test sort(var_names(rel2)) == ["TypeA", "TypeB"] - typeA_col = findfirst(==("TypeA"), var_names(rel2)) - typeB_col = findfirst(==("TypeB"), var_names(rel2)) - roi1_row = findfirst(==(Int32(10)), rel2.src_ids) - roi2_row = findfirst(==(Int32(20)), rel2.src_ids) - @test rel2.weights[roi1_row, typeA_col] == 2f0 - @test rel2.weights[roi1_row, typeB_col] == 0f0 - @test rel2.weights[roi2_row, typeA_col] == 0f0 - @test rel2.weights[roi2_row, typeB_col] == 1f0 - end - - # ── distances ───────────────────────────────────────────────────────────── - - @testset "distances shapes→shapes" begin - d = distances(cells, cells) - @test length(d) == length(cells) - @test all(d .>= 0f0) - end - - # ── PointDensity and ShapeColorView struct construction ─────────────────── - - @testset "PointDensity construction" begin - pd = density(pts; resolution=64, feature="GeneA") - @test pd isa PointDensity - @test pd.resolution == 64 - @test pd.feature == "GeneA" - end - - @testset "ShapeColorView construction" begin - rel = analyze(Expression(), pts, cells) - rel2 = annotate(rel, ["T","B","M"]; key=:cell_type) - scv = ShapeColorView(cells, rel2, :cell_type, :tab10) - @test scv isa ShapeColorView - @test scv.color_by == :cell_type - end - - # ── SpatialRelation zarr round-trip ─────────────────────────────────────── - - @testset "analyze + zarr round-trip" begin - path = mktempdir() - try - ds = SpatialDataset() - ds["cells"] = cells # attach before analyze so _element_name resolves - rel = analyze(Expression(), pts, cells) - rel = annotate(rel, ["T","B","M"]; key=:cell_type) - ds["expr"] = rel - write(ds, path, SpatialDataZarr()) - close(ds) - ds2 = read(SpatialDataZarr(), path) - rel2 = relations(ds2, "expr") - @test nobs(rel2) == 3 - @test nvar(rel2) == 2 - @test rel2.weights ≈ rel.weights atol=1e-5 - @test rel2.src == "cells" - close(ds2) - finally - rm(path; recursive=true, force=true) - end - end - -end # M11 - -# ── M12 — Real data fixtures ────────────────────────────────────────────────── -# These tests use committed zarr fixtures in test/data/ and run unconditionally -# in CI. Generate the fixtures locally with: julia --project=. test/make_fixtures.jl - -@testset "SpatialOmics M12 — Real data fixtures" begin - - xenium_path = joinpath(@__DIR__, "data", "xenium_small.zarr") - @testset "Xenium fixture" begin - if !isdir(xenium_path) - @warn "Xenium fixture not found at $xenium_path — run test/make_fixtures.jl to generate it" - else - ds = read(SpatialDataZarr(), xenium_path) - tx = points(ds, "transcripts") - shp = shapes(ds, "cell_boundaries") - img = images(ds, "morphology_focus") - lbl = labels(ds, "cell_labels") - - @test length(coords(tx)) > 500 - @test length(top_features(tx, 5)) == 5 - @test length(tx.instance_id) == length(coords(tx)) # structure check; values may be 0 for Python-source fixtures - - @test length(geometries(shp)) > 10 - - @test nchannels(img) == 4 - @test length(channel_names(img)) == 4 - - @test size(data(lbl), 1) > 0 - @test size(data(lbl), 2) > 0 - - @test !isempty(coord_systems(ds)) - - close(ds) - end - end - - visium_path = joinpath(@__DIR__, "data", "visium_small.zarr") - @testset "Visium fixture" begin - if !isdir(visium_path) - @warn "Visium fixture not found at $visium_path — run test/make_fixtures.jl to generate it" - else - ds = read(SpatialDataZarr(), visium_path) - shp = shapes(ds, "Visium_HD_Mouse_Small_Intestine_square_016um") - img = images(ds, "Visium_HD_Mouse_Small_Intestine_lowres_image") - - @test length(geometries(shp)) > 50 - - @test ndims(data(img)) >= 2 - @test size(data(img), 1) > 0 - - close(ds) - end - end - -end # M12 +include("regression.jl") +include("persistence.jl") +include("provenance.jl")