From 4c515b55e339ad92b8b5ea9f86f996fc45448627 Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Thu, 17 Sep 2026 21:36:15 -0400 Subject: [PATCH 1/6] Initial edits to remedy problems identified by audit --- README.md | 4 +- docs/src/explanation/data_model.md | 56 +- docs/src/guides/cosmx.md | 10 +- docs/src/guides/quickstart.md | 10 +- docs/src/index.md | 5 +- docs/src/reference/dataset.md | 7 + docs/src/reference/io.md | 12 +- docs/src/tutorials/visium.md | 2 +- docs/src/tutorials/xenium.md | 2 +- src/SpatialOmics.jl | 2 +- src/analysis.jl | 42 +- src/dataset.jl | 221 ++++- src/elements.jl | 15 +- src/images.jl | 20 +- src/show.jl | 10 +- src/zarr_io.jl | 377 ++++++- test/make_fixtures.jl | 4 +- test/persistence.jl | 168 ++++ test/regression.jl | 1460 ++++++++++++++++++++++++++++ test/runtests.jl | 1458 +-------------------------- 20 files changed, 2287 insertions(+), 1598 deletions(-) create mode 100644 test/persistence.jl create mode 100644 test/regression.jl diff --git a/README.md b/README.md index 4ff7686..dd0d90f 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 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/src/explanation/data_model.md b/docs/src/explanation/data_model.md index 43857b6..02d4f4f 100644 --- a/docs/src/explanation/data_model.md +++ b/docs/src/explanation/data_model.md @@ -36,28 +36,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/guides/cosmx.md b/docs/src/guides/cosmx.md index eb6c0b9..154e62b 100644 --- a/docs/src/guides/cosmx.md +++ b/docs/src/guides/cosmx.md @@ -15,7 +15,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") ``` @@ -92,11 +92,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/index.md b/docs/src/index.md index e5752b9..ec33856 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 diff --git a/docs/src/reference/dataset.md b/docs/src/reference/dataset.md index 24ba45d..ad4ee9a 100644 --- a/docs/src/reference/dataset.md +++ b/docs/src/reference/dataset.md @@ -15,6 +15,13 @@ BackingStore ```@docs with_dataset keep! +save! +discard! +edit! +touch! +isdirty +dirty +Base.close(::SpatialDataset) ``` ## Accessors diff --git a/docs/src/reference/io.md b/docs/src/reference/io.md index 667842f..3f5a9e9 100644 --- a/docs/src/reference/io.md +++ b/docs/src/reference/io.md @@ -1,7 +1,7 @@ # 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 @@ -15,7 +15,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 @@ -28,8 +28,10 @@ ds = read(CosMx(morphology_dir="/path/to/Morphology2D"), "/path/to/export/") ## Writing ```@docs +save! 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/tutorials/visium.md b/docs/src/tutorials/visium.md index 5a954f1..b098a5f 100644 --- a/docs/src/tutorials/visium.md +++ b/docs/src/tutorials/visium.md @@ -110,7 +110,7 @@ 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 diff --git a/docs/src/tutorials/xenium.md b/docs/src/tutorials/xenium.md index a741e87..682cfc0 100644 --- a/docs/src/tutorials/xenium.md +++ b/docs/src/tutorials/xenium.md @@ -128,7 +128,7 @@ 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 diff --git a/src/SpatialOmics.jl b/src/SpatialOmics.jl index 31a359b..fe326ec 100644 --- a/src/SpatialOmics.jl +++ b/src/SpatialOmics.jl @@ -33,7 +33,7 @@ export # Dataset BackingStore, SpatialDataset, elements, coord_systems, transform, - with_dataset, keep!, + with_dataset, keep!, save!, discard!, edit!, touch!, isdirty, dirty, # Elements SpatialPoints, SpatialShapes, SpatialShape, Polygon, Point2f, diff --git a/src/analysis.jl b/src/analysis.jl index af10321..8b17504 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 ───────────────────── """ @@ -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,10 +113,9 @@ 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)) + cells_tbl = _SpatialJoinTable((poly=cells.geometries, instance_id=cells.instance_id)) for row in innerjoin((pts_tbl, cells_tbl), by_pred(:pt, predicate, :poly)) gid = row[1].feature_id @@ -118,8 +138,8 @@ 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)) + pts_tbl = _SpatialJoinTable((pt=pts.coords, pos=Int32.(eachindex(pts.coords)))) + dst_tbl = _SpatialJoinTable((poly=dst.geometries, dst_instance_id=dst.instance_id)) joined = collect(innerjoin((pts_tbl, dst_tbl), by_pred(:pt, predicate, :poly))) src_ids = Int32[row[1].pos for row in joined] @@ -135,8 +155,8 @@ function analyze(::Membership{strict}, src::SpatialShapes, dst::SpatialShapes; 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] diff --git a/src/dataset.jl b/src/dataset.jl index 9f346bd..4cf47cd 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) @@ -48,9 +56,41 @@ end function _cleanup!(bs::BackingStore) bs.owned || return close.(values(bs.handles)) + empty!(bs.handles) rm(bs.path; recursive=true, force=true) end +function _ensure_open(bs::BackingStore) + bs.closed && throw(ArgumentError("the dataset is closed")) + nothing +end + +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 + +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 + # ── Dataset ─────────────────────────────────────────────────────────────────── """ @@ -61,12 +101,13 @@ 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. Follows the [SpatialData specification](https://spatialdata.scverse.org/). +metadata. Its native Zarr layout is versioned by SpatialOmics; external +SpatialData stores are handled as an import boundary. -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. +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 @@ -82,9 +123,9 @@ ds = SpatialDataset(path="/data/exp.zarr") # persistent-backed """ 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,18 +136,23 @@ 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) @@ -133,37 +179,115 @@ function SpatialDataset(; path=nothing, metadata=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 -Mark the dataset's backing store as permanent, preventing automatic cleanup. +Return whether `ds` contains staged changes that have not been saved. +""" +isdirty(ds::SpatialDataset) = !isempty(ds.backing.changes) -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. +""" + 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 + +""" + keep!(ds, path=ds.backing.path) → ds + +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 +295,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 +316,7 @@ function with_dataset(f::Function; path=nothing, kw...) try f(ds) finally - close(ds) + close(ds; discard=true) end end @@ -199,27 +324,27 @@ 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 """ 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} @@ -241,21 +366,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 +420,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..d69d67f 100644 --- a/src/elements.jl +++ b/src/elements.jl @@ -293,6 +293,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]) @@ -309,6 +311,8 @@ function apply(t::AbstractTransformation, shp::SpatialShapes{G}) where G 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 +331,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,6 +345,15 @@ 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) diff --git a/src/images.jl b/src/images.jl index 2c14b49..0c39902 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 ────────────────────────────────────────────────────────────────── """ @@ -109,6 +122,8 @@ pyramid levels are discarded before building. [`scaleminmax`](@ref), [`channel`](@ref) """ function build_pyramid!(img::SpatialImage, n_levels::Int=3) + owner = _owning_dataset(img) + owner === nothing || touch!(owner, _dataset_ref(img)[2]) empty!(img.pyramid) sdims = _spatial_dims(img.axes) current = img.data @@ -145,6 +160,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}(), diff --git a/src/show.jl b/src/show.jl index 0aee05f..085fe00 100644 --- a/src/show.jl +++ b/src/show.jl @@ -80,7 +80,8 @@ 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))" : "" + print(io, "SpatialDataset($n element$(n == 1 ? "" : "s")$cs$state)") end # Full REPL form — used when ds is displayed at top level @@ -88,7 +89,8 @@ 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"):") + 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") ($persistence):") for (name, el) in ds.elements print(io, " \"$name\" => ") show(io, el) @@ -109,4 +111,8 @@ function Base.show(io::IO, ::MIME"text/plain", ds::SpatialDataset) print(io, "\n transforms: $(length(ds.transforms))") end 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/zarr_io.jl b/src/zarr_io.jl index 10500f1..ca8fd14 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,8 +12,9 @@ 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) @@ -85,6 +86,9 @@ 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.feature_columns !== nothing + _write_named_tuple(joinpath(grp, "feature_columns"), pts.feature_columns) + end end # ── Write SpatialShapes ──────────────────────────────────────────────────────── @@ -155,7 +159,10 @@ 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 + + SpatialPoints{Float32}(coords, feature_id, codebook, instance_id, feature_columns, cs, nothing) end # ── Read SpatialShapes ───────────────────────────────────────────────────────── @@ -195,16 +202,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 @@ -341,8 +357,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 +406,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 +424,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 +485,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 @@ -493,32 +522,273 @@ 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) + 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 @@ -544,7 +814,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 +1094,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 +1110,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 +1119,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 +1128,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 +1136,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 @@ -1229,7 +1502,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/make_fixtures.jl b/test/make_fixtures.jl index 702fdbc..da2b2f7 100644 --- a/test/make_fixtures.jl +++ b/test/make_fixtures.jl @@ -128,7 +128,7 @@ else sub["cell_labels"] = crop_labels(lbl, ylo, yhi, xlo, xhi) 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))) @@ -196,7 +196,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)) diff --git a/test/persistence.jl b/test/persistence.jl new file mode 100644 index 0000000..7ba5ed8 --- /dev/null +++ b/test/persistence.jl @@ -0,0 +1,168 @@ +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 +end diff --git a/test/regression.jl b/test/regression.jl new file mode 100644 index 0000000..9e2e31f --- /dev/null +++ b/test/regression.jl @@ -0,0 +1,1460 @@ +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] + 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! 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" + + 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; 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..dac88d7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,1456 +1,2 @@ -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") From da7fc3e3a1896e5813e566be53af8550e662821a Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Fri, 18 Sep 2026 09:44:44 -0400 Subject: [PATCH 2/6] Fix provenance --- README.md | 2 +- docs/src/explanation/data_model.md | 18 +++ docs/src/explanation/lazy_views.md | 28 ++++- docs/src/guides/cosmx.md | 31 +++-- docs/src/reference/dataset.md | 3 + docs/src/reference/elements.md | 2 + docs/src/reference/views.md | 7 ++ src/SpatialOmics.jl | 6 +- src/dataset.jl | 60 ++++++++++ src/elements.jl | 176 ++++++++++++++++++++++++----- src/images.jl | 25 ++++ src/show.jl | 16 ++- src/views.jl | 129 ++++++++++++++++++--- src/zarr_io.jl | 93 +++++++++++++-- test/provenance.jl | 126 +++++++++++++++++++++ test/regression.jl | 12 +- test/runtests.jl | 1 + 17 files changed, 660 insertions(+), 75 deletions(-) create mode 100644 test/provenance.jl diff --git a/README.md b/README.md index dd0d90f..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, explicit persistence, and import support for SpatialData Zarr stores. +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 diff --git a/docs/src/explanation/data_model.md b/docs/src/explanation/data_model.md index 02d4f4f..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 diff --git a/docs/src/explanation/lazy_views.md b/docs/src/explanation/lazy_views.md index 1d755c1..3da1b81 100644 --- a/docs/src/explanation/lazy_views.md +++ b/docs/src/explanation/lazy_views.md @@ -41,6 +41,25 @@ 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 +``` + +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 +94,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 154e62b..8aa2a78 100644 --- a/docs/src/guides/cosmx.md +++ b/docs/src/guides/cosmx.md @@ -25,7 +25,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 +42,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 +58,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 +97,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) diff --git a/docs/src/reference/dataset.md b/docs/src/reference/dataset.md index ad4ee9a..18fa614 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 @@ -30,6 +31,8 @@ Base.close(::SpatialDataset) elements coord_systems transform +sources +source relations ``` diff --git a/docs/src/reference/elements.md b/docs/src/reference/elements.md index 55dbe4d..b3a0323 100644 --- a/docs/src/reference/elements.md +++ b/docs/src/reference/elements.md @@ -14,6 +14,8 @@ SpatialPoints coords features feature_ids +origins +origin_ids coord_system instance_id instance_ids diff --git a/docs/src/reference/views.md b/docs/src/reference/views.md index 293bc1c..ecdf324 100644 --- a/docs/src/reference/views.md +++ b/docs/src/reference/views.md @@ -16,6 +16,13 @@ SpatialDatasetView SpatialElementView ``` +Views accept geometric regions or registered acquisition sources: + +```julia +view(ds, roi) # geometric membership +view(ds, "fov_1_px") # acquisition provenance +``` + ## Accessors ```@docs diff --git a/src/SpatialOmics.jl b/src/SpatialOmics.jl index fe326ec..87aa7dc 100644 --- a/src/SpatialOmics.jl +++ b/src/SpatialOmics.jl @@ -31,14 +31,14 @@ export # Use SpatialOmics.translation(...) etc. when constructing pixel_to_cs transforms. apply, apply!, resolve, # Dataset - BackingStore, SpatialDataset, - elements, coord_systems, transform, + BackingStore, SpatialDataset, AcquisitionSource, + elements, coord_systems, transform, sources, source, with_dataset, keep!, save!, discard!, edit!, touch!, isdirty, dirty, # Elements SpatialPoints, SpatialShapes, SpatialShape, Polygon, Point2f, points, shapes, - coords, features, feature_ids, coord_system, + coords, features, feature_ids, origins, origin_ids, coord_system, geometries, instance_id, instance_ids, subsample, top_features, count_per_instance, # Views diff --git a/src/dataset.jl b/src/dataset.jl index 4cf47cd..81f854d 100644 --- a/src/dataset.jl +++ b/src/dataset.jl @@ -93,6 +93,37 @@ end # ── Dataset ─────────────────────────────────────────────────────────────────── +""" + AcquisitionSource(name; region=nothing, instance_id=nothing) + +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. + +# See also +[`sources`](@ref), [`source`](@ref), [`SpatialDatasetView`](@ref) +""" +struct AcquisitionSource + name :: String + region_element :: Union{Nothing,String} + region_id :: Union{Nothing,Int32} +end + +function AcquisitionSource(name::AbstractString; + region::Union{Nothing,AbstractString}=nothing, + instance_id::Union{Nothing,Integer}=nothing) + (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), + ) +end + """ SpatialDataset(; path=nothing, metadata=Dict()) @@ -163,6 +194,7 @@ 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 @@ -174,6 +206,7 @@ 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), @@ -334,6 +367,12 @@ function Base.push!(ds::SpatialDataset, t::AbstractTransformation) 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} @@ -353,6 +392,27 @@ 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 + """ transform(ds, src, dst) → AbstractTransformation diff --git a/src/elements.jl b/src/elements.jl index d69d67f..6d529aa 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) @@ -180,29 +239,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 +308,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 +317,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 +331,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 @@ -276,8 +376,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 """ @@ -307,7 +411,13 @@ 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 @@ -355,12 +465,20 @@ Base.setindex!(ds::SpatialDataset, el::Union{SpatialPoints,SpatialShapes}, name: _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} @@ -388,15 +506,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 diff --git a/src/images.jl b/src/images.jl index 0c39902..114d114 100644 --- a/src/images.jl +++ b/src/images.jl @@ -403,6 +403,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/show.jl b/src/show.jl index 085fe00..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)\"") @@ -81,7 +87,8 @@ function Base.show(io::IO, ds::SpatialDataset) ncs <= 3 ? " [$(join(keys(ds.coord_systems), ", "))]" : " ($ncs coord systems)" state = isdirty(ds) ? ", unsaved=$(length(ds.backing.changes))" : "" - print(io, "SpatialDataset($n element$(n == 1 ? "" : "s")$cs$state)") + 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 @@ -89,8 +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) + 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") ($persistence):") + 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) @@ -111,6 +119,10 @@ 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)), ", ")) diff --git a/src/views.jl b/src/views.jl index bf089f2..378120e 100644 --- a/src/views.jl +++ b/src/views.jl @@ -189,9 +189,10 @@ end 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)) @@ -204,7 +205,7 @@ collect(tx) # materialise into a concrete SpatialPoints """ struct SpatialDatasetView parent :: SpatialDataset - roi :: Union{SpatialExtent, SpatialROI} + roi :: Union{SpatialExtent, SpatialROI, AcquisitionSource} end # ── view constructors ───────────────────────────────────────────────────────── @@ -222,6 +223,19 @@ 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, SpatialDataset}, shp::SpatialShapes; kw...) length(shp.geometries) == 1 || @@ -234,22 +248,69 @@ 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)) + +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 + +_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, 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 +329,19 @@ 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) + # Shapes × SpatialExtent: per-geometry extent check, exact for rectangular ROIs function _mask(shp::SpatialShapes, ext::SpatialExtent, overlap::Symbol=:any) if overlap == :any @@ -301,16 +375,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 +400,10 @@ 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)) features(v::SpatialElementView{<:SpatialPoints}) = v.parent.feature_codebook features(v::SpatialElementView{<:SpatialPoints}, col::Symbol) = @@ -343,6 +426,17 @@ 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)] @@ -365,14 +459,19 @@ 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 + +_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 tables(v::SpatialDatasetView, name::String) diff --git a/src/zarr_io.jl b/src/zarr_io.jl index ca8fd14..2e5931c 100644 --- a/src/zarr_io.jl +++ b/src/zarr_io.jl @@ -86,6 +86,12 @@ 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 @@ -102,6 +108,12 @@ function _write_zarr(root::String, name::String, shp::SpatialShapes) "coord_system" => shp.coord_system))) _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 # Ragged CSR layout: polygons → rings → points # poly_offsets[i] = 0-based index of first ring for polygon i (Julia 1-based) @@ -162,7 +174,17 @@ function _read_points_zarr(grp::String) :: SpatialPoints{Float32} columns_path = joinpath(grp, "feature_columns") feature_columns = isdir(columns_path) ? _read_named_tuple(columns_path) : nothing - SpatialPoints{Float32}(coords, feature_id, codebook, instance_id, feature_columns, cs, 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 ───────────────────────────────────────────────────────── @@ -190,7 +212,14 @@ function _read_shapes_zarr(grp::String) :: SpatialShapes 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 # ── Transform serialization helpers ─────────────────────────────────────────── @@ -500,7 +529,16 @@ function _write_spatialomics_meta(ds::SpatialDataset, path::String) "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, + ) + for acquisition in values(ds.sources) + ], + )) end end @@ -762,6 +800,7 @@ function discard!(ds::SpatialDataset, name::Union{Nothing,String}=nothing) elseif kind === :dataset ds.coord_systems = copy(stored.coord_systems) ds.transforms = copy(stored.transforms) + ds.sources = copy(stored.sources) end end finally @@ -807,6 +846,16 @@ function Base.read(::SpatialDataZarr, path::String) :: SpatialDataset 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, + ) + ds.sources[registered.name] = registered + end end for (subdir, reader) in (("points", _read_points_zarr), @@ -1163,9 +1212,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) @@ -1412,6 +1462,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")) @@ -1442,7 +1495,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) : @@ -1454,6 +1514,9 @@ function Base.read(fmt::CosMx, path::String; feature_id = feat_ids, feature_codebook = codebook, instance_id = all_inst, + features = (z=ann_z, CellComp=ann_comp), + origin_id = transcript_origin_ids, + origin_codebook = source_names, coord_system = "global_px") # ── Assemble dataset ────────────────────────────────────────────────────── @@ -1483,12 +1546,18 @@ 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, + )) + end if fmt.morphology_dir !== nothing morph2d = _find_morphology2d(fmt.morphology_dir) diff --git a/test/provenance.jl b/test/provenance.jl new file mode 100644 index 0000000..cb7f95c --- /dev/null +++ b/test/provenance.jl @@ -0,0 +1,126 @@ +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)) + 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(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 + + 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 + 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(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) + finally + close(ds; discard=true) + end + end + + @testset "constructor validation" begin + @test_throws ArgumentError AcquisitionSource("bad"; region="fovs") + @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 index 9e2e31f..720779a 100644 --- a/test/regression.jl +++ b/test/regression.jl @@ -1193,10 +1193,14 @@ end 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) + @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 !haskey(ds.metadata, "transcripts_annotations") + first_source = first(sources(ds)) + @test length(points(view(ds, first_source), "transcripts")) > 0 end end diff --git a/test/runtests.jl b/test/runtests.jl index dac88d7..8e7920a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,2 +1,3 @@ include("regression.jl") include("persistence.jl") +include("provenance.jl") From 1b002c0e031483b2992328f621064927df462393 Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Fri, 18 Sep 2026 20:03:59 -0400 Subject: [PATCH 3/6] Refine docs --- docs/Manifest.toml | 426 ++++++++++++++++-- docs/Project.toml | 3 + docs/heavy/Project.toml | 11 + docs/make.jl | 7 + docs/src/explanation/coordinate_systems.md | 13 +- docs/src/explanation/lazy_views.md | 19 +- docs/src/guides/cosmx.md | 7 + docs/src/guides/visualization.md | 10 +- docs/src/index.md | 2 + docs/src/reference/coordinate_systems.md | 10 +- docs/src/reference/dataset.md | 1 + docs/src/reference/elements.md | 6 +- docs/src/reference/io.md | 8 +- docs/src/reference/relations_analysis.md | 5 +- docs/src/reference/views.md | 8 +- docs/src/reference/visualization.md | 2 + docs/src/tutorials/building_dataset.md | 108 +++++ docs/src/tutorials/coordinate_workflow.md | 66 +++ docs/src/tutorials/custom_starmap_reader.md | 214 +++++++++ docs/src/tutorials/expression_summaries.md | 83 ++++ docs/src/tutorials/index.md | 41 ++ docs/src/tutorials/persistence.md | 83 ++++ docs/src/tutorials/source_roi_selection.md | 84 ++++ docs/src/tutorials/visium.md | 20 +- docs/src/tutorials/xenium.md | 30 +- ext/MakieExt.jl | 9 + src/SpatialOmics.jl | 14 +- src/analysis.jl | 91 +++- src/dataset.jl | 83 ++-- src/elements.jl | 52 ++- src/images.jl | 81 ++++ src/relations.jl | 37 +- src/views.jl | 129 +++++- src/zarr_io.jl | 213 +++++++-- .../visium_small.zarr/spatialomics_meta.json | 2 +- .../xenium_small.zarr/spatialomics_meta.json | 2 +- test/make_fixtures.jl | 84 +--- test/persistence.jl | 21 + test/provenance.jl | 43 +- test/regression.jl | 66 +++ 40 files changed, 1949 insertions(+), 245 deletions(-) create mode 100644 docs/heavy/Project.toml create mode 100644 docs/src/tutorials/building_dataset.md create mode 100644 docs/src/tutorials/coordinate_workflow.md create mode 100644 docs/src/tutorials/custom_starmap_reader.md create mode 100644 docs/src/tutorials/expression_summaries.md create mode 100644 docs/src/tutorials/index.md create mode 100644 docs/src/tutorials/persistence.md create mode 100644 docs/src/tutorials/source_roi_selection.md diff --git a/docs/Manifest.toml b/docs/Manifest.toml index 909e6fe..8d91186 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" +julia_version = "1.13.0" +manifest_format = "2.1" project_hash = "d3bacc683624fa505250ad2aa50dcc149359ecf9" [[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,20 @@ 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.ArgCheck]] git-tree-sha1 = "f9e9a66c9b7be1ad7372bbd9b062d9230c30c5ce" +registries = "General" uuid = "dce04be8-c92d-5529-be00-80e4d2c0e197" version = "2.5.0" @@ -83,12 +91,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 +135,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,6 +143,40 @@ 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" @@ -139,18 +184,21 @@ version = "1.11.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" @@ -176,52 +224,61 @@ version = "1.11.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 +289,35 @@ 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.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 +327,91 @@ 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.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 +422,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 +433,20 @@ 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.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 +457,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 +471,20 @@ weakdeps = ["InverseFunctions"] [[deps.ComputationalResources]] git-tree-sha1 = "52cb3ec90e8a8bea0e62e275ba577ad0f74821f7" +registries = "General" uuid = "ed09eef8-17a6-5b46-8889-db040fac31e3" version = "0.3.2" [[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"] @@ -414,51 +497,66 @@ weakdeps = ["IntervalSets", "LinearAlgebra", "StaticArrays"] [[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 +568,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"] @@ -514,12 +618,14 @@ version = "1.11.0" [[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" +registries = "General" uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4" version = "1.17.0" @@ -531,63 +637,74 @@ 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.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"] @@ -598,6 +715,7 @@ weakdeps = ["HTTP"] [[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,6 +731,7 @@ version = "1.11.0" [[deps.FillArrays]] deps = ["LinearAlgebra"] git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" +registries = "General" uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" version = "1.16.0" @@ -631,9 +750,46 @@ version = "1.16.0" [[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.Future]] deps = ["Random"] uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" @@ -641,12 +797,14 @@ version = "1.11.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 +820,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 +831,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 +854,56 @@ 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.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.Graphics]] deps = ["Colors", "LinearAlgebra", "NaNMath"] git-tree-sha1 = "a641238db938fff9b2f60d08ed9030387daf428c" +registries = "General" uuid = "a2bd30eb-e257-5431-a919-1863eab51364" version = "1.1.3" [[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"] @@ -746,168 +914,197 @@ weakdeps = ["Distributed", "SharedArrays"] [[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.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.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 +1118,21 @@ version = "1.4.5" [[deps.InputBuffers]] git-tree-sha1 = "e5392ea00942566b631e991dd896942189937b2f" +registries = "General" uuid = "0c81fc1b-5583-44fc-8770-48be1e1cca08" version = "1.1.1" [[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 +1144,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 +1159,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 +1185,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 +1201,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 +1212,26 @@ weakdeps = ["Dates", "Test"] [[deps.IrrationalConstants]] git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" +registries = "General" uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" version = "0.2.6" [[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 +1242,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 +1262,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 +1275,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" @@ -1081,11 +1294,13 @@ version = "1.12.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.LRUCache]] git-tree-sha1 = "5519b95a490ff5fe629c4a7aa3b3dfc9160498b3" +registries = "General" uuid = "8ac3fa9e-de4c-5943-b1dc-09c6b5f20637" version = "1.6.2" weakdeps = ["Serialization"] @@ -1096,17 +1311,20 @@ weakdeps = ["Serialization"] [[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 +1347,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,14 +1367,14 @@ 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" @@ -1164,24 +1383,28 @@ version = "1.11.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.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.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 +1417,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 +1450,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 +1475,32 @@ 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.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 +1512,42 @@ 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.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 +1558,32 @@ 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.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" @@ -1351,6 +1593,7 @@ version = "1.3.0" [[deps.OffsetArrays]] git-tree-sha1 = "117432e406b5c023f665fa73dc26e79ec3630151" +registries = "General" uuid = "6fe1bfb0-de20-5000-8ca7-80f57d26f881" version = "1.17.0" weakdeps = ["Adapt"] @@ -1361,104 +1604,117 @@ weakdeps = ["Adapt"] [[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.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.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.PaddedViews]] deps = ["OffsetArrays"] git-tree-sha1 = "0fac6313486baae819364c52b4f483450a9d793f" +registries = "General" uuid = "5432bcbf-9aad-5242-b902-cca2824c8663" version = "0.5.12" [[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.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 +1723,21 @@ weakdeps = ["REPL"] [[deps.PkgVersion]] deps = ["Pkg"] git-tree-sha1 = "f9501cc0430a26bc3d156ae1b5b0c1b47af4d6da" +registries = "General" uuid = "eebad327-c553-4316-9ea0-9fa01ccd7688" version = "0.3.3" [[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.Polynomials]] deps = ["LinearAlgebra", "OrderedCollections", "Setfield", "SparseArrays"] git-tree-sha1 = "2d99b4c8a7845ab1342921733fa29366dae28b24" +registries = "General" uuid = "f27b6e38-b328-58d1-80ce-0feddd5e7a45" version = "4.1.1" @@ -1499,18 +1758,21 @@ 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" @@ -1522,28 +1784,32 @@ 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.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 +1820,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 +1838,41 @@ 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.Requires]] deps = ["UUIDs"] git-tree-sha1 = "62389eeff14780bfe55195b7204c0d8738436d64" +registries = "General" uuid = "ae029012-a4dd-5104-9daa-d747884805df" version = "1.3.1" [[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 +1884,60 @@ 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.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,6 +1945,7 @@ 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" @@ -1668,24 +1956,28 @@ version = "1.11.0" [[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 +1988,38 @@ 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" [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 +2030,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 +2069,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 +2080,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 +2098,50 @@ 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.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" @@ -1833,9 +2160,9 @@ uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" version = "1.11.0" [[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 +2172,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 +2198,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 +2210,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" @@ -1929,6 +2266,7 @@ version = "0.4.85" [[deps.URIs]] git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +registries = "General" uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" version = "1.6.1" @@ -1939,6 +2277,7 @@ version = "1.11.0" [[deps.UnPack]] git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b" +registries = "General" uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed" version = "1.0.2" @@ -1948,83 +2287,97 @@ version = "1.11.0" [[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_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 +2390,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,14 +2400,14 @@ 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" @@ -2065,45 +2419,55 @@ version = "5.15.0+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.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" + +[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..b0d82c7 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,3 +1,6 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" SpatialOmics = "7c9a2e4e-8f1d-4a6b-b84c-3e8a9f2d1c5e" + +[sources] +SpatialOmics = {path = ".."} 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..c311fab 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", 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/lazy_views.md b/docs/src/explanation/lazy_views.md index 3da1b81..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. @@ -53,6 +54,20 @@ 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 diff --git a/docs/src/guides/cosmx.md b/docs/src/guides/cosmx.md index 8aa2a78..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 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 ec33856..dc90462 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -45,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 18fa614..58c512b 100644 --- a/docs/src/reference/dataset.md +++ b/docs/src/reference/dataset.md @@ -33,6 +33,7 @@ coord_systems transform sources source +source_attributes relations ``` diff --git a/docs/src/reference/elements.md b/docs/src/reference/elements.md index b3a0323..2dcff34 100644 --- a/docs/src/reference/elements.md +++ b/docs/src/reference/elements.md @@ -19,6 +19,7 @@ origin_ids coord_system instance_id instance_ids +with_instance_ids ``` ## Shape collections @@ -44,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 3f5a9e9..65a6ff1 100644 --- a/docs/src/reference/io.md +++ b/docs/src/reference/io.md @@ -8,6 +8,7 @@ dataset changes are persisted with [`save!`](@ref). ```@docs SpatialDataZarr CosMx +native_store_version ``` ## Reading @@ -25,10 +26,15 @@ 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 -save! write! ``` 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 ecdf324..8aa5c77 100644 --- a/docs/src/reference/views.md +++ b/docs/src/reference/views.md @@ -19,15 +19,17 @@ SpatialElementView Views accept geometric regions or registered acquisition sources: ```julia -view(ds, roi) # geometric membership -view(ds, "fov_1_px") # acquisition provenance +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..435fd99 --- /dev/null +++ b/docs/src/tutorials/building_dataset.md @@ -0,0 +1,108 @@ +# Build a spatial dataset + +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), +) +``` + +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..b8c0d78 --- /dev/null +++ b/docs/src/tutorials/coordinate_workflow.md @@ -0,0 +1,66 @@ +# Place an FOV in slide coordinates + +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)]; + 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), +) +``` + +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..c6523c3 --- /dev/null +++ b/docs/src/tutorials/custom_starmap_reader.md @@ -0,0 +1,214 @@ +# Build a custom STARmap reader + +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. + +## 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..d0959fd --- /dev/null +++ b/docs/src/tutorials/expression_summaries.md @@ -0,0 +1,83 @@ +# Assign transcripts and summarise expression + +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 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..96f4928 --- /dev/null +++ b/docs/src/tutorials/index.md @@ -0,0 +1,41 @@ +# 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. Generated figures should record their public source dataset and +the code used to produce the committed asset; collaborator datasets are not +documentation inputs. Plotting dependencies for asset regeneration live in the +separate `docs/heavy` environment and are not installed by a normal docs build. diff --git a/docs/src/tutorials/persistence.md b/docs/src/tutorials/persistence.md new file mode 100644 index 0000000..d953c91 --- /dev/null +++ b/docs/src/tutorials/persistence.md @@ -0,0 +1,83 @@ +# Persist a dataset safely + +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") +(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 +discard!(dataset, "transcripts") + +restored = coords(points(dataset, "transcripts"))[1] +close(dataset) +rm(root; recursive=true) +restored +``` + +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..a53eb5a --- /dev/null +++ b/docs/src/tutorials/source_roi_selection.md @@ -0,0 +1,84 @@ +# Select acquisition sources and geometric ROIs + +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)], +) +``` + +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 b098a5f..89d8b46 100644 --- a/docs/src/tutorials/visium.md +++ b/docs/src/tutorials/visium.md @@ -3,13 +3,15 @@ !!! 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. ## 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 +69,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 +98,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: diff --git a/docs/src/tutorials/xenium.md b/docs/src/tutorials/xenium.md index 682cfc0..b2a2706 100644 --- a/docs/src/tutorials/xenium.md +++ b/docs/src/tutorials/xenium.md @@ -3,13 +3,15 @@ !!! 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. ## 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 +101,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 ``` @@ -131,8 +136,9 @@ sub["morphology_focus"] = images(roi, "morphology_focus") 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 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 87aa7dc..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 @@ -32,33 +32,33 @@ export apply, apply!, resolve, # Dataset BackingStore, SpatialDataset, AcquisitionSource, - elements, coord_systems, transform, sources, source, + 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, origins, origin_ids, coord_system, - geometries, instance_id, instance_ids, + 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 8b17504..5cb9eb9 100644 --- a/src/analysis.jl +++ b/src/analysis.jl @@ -37,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. @@ -114,21 +114,48 @@ function analyze(::Expression, pts::SpatialPoints, cells::SpatialShapes; cell_pos = Dict{Int32,Int}(id => i for (i, id) in enumerate(cells.instance_id)) # Points go first (simpler geometries), cells second (tree-indexed). - pts_tbl = _SpatialJoinTable((pt=pts.coords, feature_id=pts.feature_id)) - cells_tbl = _SpatialJoinTable((poly=cells.geometries, instance_id=cells.instance_id)) + 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 ─── @@ -138,17 +165,51 @@ end function analyze(::Membership{strict}, pts::SpatialPoints, dst::SpatialShapes; predicate=GeometryOps.within) where strict - pts_tbl = _SpatialJoinTable((pt=pts.coords, pos=Int32.(eachindex(pts.coords)))) - dst_tbl = _SpatialJoinTable((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. @@ -179,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 81f854d..bd8a711 100644 --- a/src/dataset.jl +++ b/src/dataset.jl @@ -47,9 +47,14 @@ 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 @@ -94,13 +99,15 @@ end # ── Dataset ─────────────────────────────────────────────────────────────────── """ - AcquisitionSource(name; region=nothing, instance_id=nothing) + AcquisitionSource(name; region=nothing, instance_id=nothing, attributes=Dict()) 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. +selection in overlapping acquisitions. `attributes` retains structured +technology-specific identity needed for lossless export, such as a vendor FOV +number. # See also [`sources`](@ref), [`source`](@ref), [`SpatialDatasetView`](@ref) @@ -109,11 +116,13 @@ 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) + 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", )) @@ -121,34 +130,12 @@ function AcquisitionSource(name::AbstractString; 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 -""" - 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) -""" # ── Backed metadata dict ────────────────────────────────────────────────────── """ @@ -190,6 +177,31 @@ 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} @@ -413,6 +425,17 @@ function source(ds::SpatialDataset, name::AbstractString) 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 diff --git a/src/elements.jl b/src/elements.jl index 6d529aa..294a1bd 100644 --- a/src/elements.jl +++ b/src/elements.jl @@ -178,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 @@ -369,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 @@ -529,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 ────────────────────────────────────────────────────────────── """ @@ -552,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. @@ -562,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 114d114..c50bf68 100644 --- a/src/images.jl +++ b/src/images.jl @@ -102,6 +102,47 @@ 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 @@ -252,6 +293,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 ──────────────────────── """ @@ -277,6 +322,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) @@ -370,6 +439,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) 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/views.jl b/src/views.jl index 378120e..d559294 100644 --- a/src/views.jl +++ b/src/views.jl @@ -182,8 +182,20 @@ 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 @@ -203,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, AcquisitionSource} + roi :: S end # ── view constructors ───────────────────────────────────────────────────────── @@ -236,6 +248,19 @@ function Base.view(el::Union{SpatialPoints,SpatialShapes}, 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 || @@ -262,6 +287,26 @@ 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", @@ -286,11 +331,25 @@ function _source_view(ds::SpatialDataset, 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)) @@ -342,6 +401,26 @@ _mask(pts::SpatialPoints, acquisition::AcquisitionSource, ::Symbol=:any) = _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 @@ -404,6 +483,16 @@ 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) = @@ -443,11 +532,22 @@ instance_id(v::SpatialElementView{<:SpatialShapes}) = 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 @@ -462,6 +562,17 @@ function images(v::SpatialDatasetView, name::String) 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 @@ -474,6 +585,18 @@ function labels(v::SpatialDatasetView, name::String) 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) el = v.parent.elements[name] el isa SpatialTable || error("Element \"$name\" is not SpatialTable (got $(typeof(el)))") diff --git a/src/zarr_io.jl b/src/zarr_io.jl index 2e5931c..105eaff 100644 --- a/src/zarr_io.jl +++ b/src/zarr_io.jl @@ -21,6 +21,38 @@ validated interchange is a separate conversion boundary. """ 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}()) @@ -99,13 +131,17 @@ 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 @@ -115,12 +151,14 @@ function _write_zarr(root::String, name::String, shp::SpatialShapes) end 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) + _write_shape_geometries(grp, shp.geometries) +end + +# 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 @@ -130,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 @@ -154,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} @@ -195,18 +277,13 @@ 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)) @@ -222,6 +299,48 @@ function _read_shapes_zarr(grp::String) :: SpatialShapes 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 ─────────────────────────────────────────── _transform_to_dict(t::Identity) = @@ -524,6 +643,7 @@ 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)), @@ -535,6 +655,7 @@ function _write_spatialomics_meta(ds::SpatialDataset, path::String) "name" => acquisition.name, "region_element" => acquisition.region_element, "region_id" => acquisition.region_id, + "attributes" => acquisition.attributes, ) for acquisition in values(ds.sources) ], @@ -833,29 +954,33 @@ end 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 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, - ) - ds.sources[registered.name] = registered - 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), @@ -1514,7 +1639,12 @@ function Base.read(fmt::CosMx, path::String; feature_id = feat_ids, feature_codebook = codebook, instance_id = all_inst, - features = (z=ann_z, CellComp=ann_comp), + 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") @@ -1556,6 +1686,7 @@ function Base.read(fmt::CosMx, path::String; "fov_$(f)_px"; region="fovs", instance_id=f, + attributes=Dict("technology" => "CosMx", "native_id" => f), )) 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 da2b2f7..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,16 +78,7 @@ 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) save!(sub; path=XENIUM_OUT) @@ -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" @@ -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 index 7ba5ed8..d1b5c17 100644 --- a/test/persistence.jl +++ b/test/persistence.jl @@ -165,4 +165,25 @@ using GeometryBasics: Point2f @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 index cb7f95c..fd37c9a 100644 --- a/test/provenance.jl +++ b/test/provenance.jl @@ -15,7 +15,10 @@ using GeometryBasics: Point2f, Polygon origins=["fov_a", "fov_b"], coord_system="global", ) - push!(ds, AcquisitionSource("fov_a"; region="fovs", instance_id=1)) + 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 @@ -35,6 +38,10 @@ using GeometryBasics: Point2f, Polygon ) @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 @@ -44,9 +51,21 @@ using GeometryBasics: Point2f, Polygon @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 @@ -88,6 +107,7 @@ using GeometryBasics: Point2f, Polygon 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 @@ -109,6 +129,24 @@ using GeometryBasics: Point2f, Polygon ) @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 @@ -116,6 +154,9 @@ using GeometryBasics: Point2f, Polygon @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"], ) diff --git a/test/regression.jl b/test/regression.jl index 720779a..8667554 100644 --- a/test/regression.jl +++ b/test/regression.jl @@ -224,6 +224,18 @@ end @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 @@ -1198,6 +1210,8 @@ end @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 @@ -1247,6 +1261,58 @@ end @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 From e666bb2fd72f0185d124435db59b815471d42b2c Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Sat, 19 Sep 2026 19:47:06 -0400 Subject: [PATCH 4/6] fix url for deploydocs --- docs/make.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/make.jl b/docs/make.jl index c311fab..be8f91a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -49,7 +49,7 @@ makedocs( if get(ENV, "CI", nothing) == "true" deploydocs( - repo = Documenter.Remotes.GitHub("BonhamLab", "SpatialOmics.jl"), + repo = "githum.com/BonhamLab/SpatialOmics.jl.git", target = "build", push_preview = true, ) From c50de293b170b5078eb994c5c017fdd941fd4d82 Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Mon, 21 Sep 2026 15:52:40 -0400 Subject: [PATCH 5/6] Fix docs deployment repo URL typo --- docs/make.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/make.jl b/docs/make.jl index be8f91a..e81ce4a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -49,7 +49,7 @@ makedocs( if get(ENV, "CI", nothing) == "true" deploydocs( - repo = "githum.com/BonhamLab/SpatialOmics.jl.git", + repo = "github.com/BonhamLab/SpatialOmics.jl.git", target = "build", push_preview = true, ) From 80dc65ff08ec7476ff53ff0d828e166ab0d5dc8b Mon Sep 17 00:00:00 2001 From: Kevin Bonham Date: Tue, 22 Sep 2026 08:22:48 -0400 Subject: [PATCH 6/6] Update docs with figure examples --- docs/Manifest.toml | 599 +++++++++++++++++++- docs/Project.toml | 5 + docs/src/tutorials/building_dataset.md | 37 ++ docs/src/tutorials/coordinate_workflow.md | 38 +- docs/src/tutorials/custom_starmap_reader.md | 36 ++ docs/src/tutorials/expression_summaries.md | 46 ++ docs/src/tutorials/index.md | 10 +- docs/src/tutorials/persistence.md | 43 +- docs/src/tutorials/source_roi_selection.md | 34 ++ docs/src/tutorials/visium.md | 36 +- docs/src/tutorials/xenium.md | 39 +- src/images.jl | 10 +- src/zarr_io.jl | 2 +- test/regression.jl | 7 + 14 files changed, 913 insertions(+), 29 deletions(-) diff --git a/docs/Manifest.toml b/docs/Manifest.toml index 8d91186..264d16c 100644 --- a/docs/Manifest.toml +++ b/docs/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.13.0" manifest_format = "2.1" -project_hash = "d3bacc683624fa505250ad2aa50dcc149359ecf9" +project_hash = "8fe7c6ddbe99bb1c9448997ccf03a4e91853bca6" [[deps.ANSIColoredPrinters]] git-tree-sha1 = "574baf8110975760d391c710b6341da1afa48d8c" @@ -78,6 +78,13 @@ 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" @@ -181,6 +188,13 @@ version = "1.3.0" 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" @@ -222,6 +236,12 @@ 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" @@ -307,6 +327,27 @@ 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" @@ -401,6 +442,13 @@ 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" @@ -437,6 +485,13 @@ 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" @@ -475,6 +530,13 @@ 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" @@ -494,6 +556,12 @@ 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" @@ -616,6 +684,25 @@ 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" @@ -624,10 +711,10 @@ 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"] @@ -674,6 +761,20 @@ 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" @@ -712,6 +813,23 @@ 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" @@ -734,6 +852,7 @@ 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" @@ -741,12 +860,6 @@ 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" @@ -790,11 +903,58 @@ version = "0.1.29" 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" @@ -858,6 +1018,13 @@ 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" @@ -893,6 +1060,13 @@ 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" @@ -900,6 +1074,13 @@ 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" @@ -911,6 +1092,13 @@ 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" @@ -918,6 +1106,13 @@ 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" @@ -938,6 +1133,13 @@ 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" @@ -1122,6 +1324,12 @@ 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" @@ -1216,6 +1424,13 @@ 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" @@ -1291,6 +1506,20 @@ 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" @@ -1298,6 +1527,13 @@ 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" @@ -1308,6 +1544,12 @@ 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" @@ -1380,6 +1622,13 @@ version = "1.11.103+0" 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" @@ -1394,6 +1643,13 @@ 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" @@ -1401,6 +1657,13 @@ 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" @@ -1492,6 +1755,19 @@ 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" @@ -1516,6 +1792,13 @@ 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" @@ -1566,6 +1849,13 @@ version = "0.3.4" uuid = "14a3606d-f60d-562e-9121-12d972cd8159" 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" @@ -1591,6 +1881,12 @@ 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" @@ -1601,6 +1897,13 @@ 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" @@ -1665,6 +1968,13 @@ 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" @@ -1676,6 +1986,17 @@ deps = ["Artifacts", "Libdl"] uuid = "efcefdf7-47ab-520b-bdef-62a2eaa19f15" 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" @@ -1683,6 +2004,13 @@ 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" @@ -1690,6 +2018,13 @@ 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" @@ -1711,6 +2046,13 @@ 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", "Zstd_jll", "p7zip_jll"] uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" @@ -1727,6 +2069,13 @@ 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" @@ -1734,6 +2083,12 @@ 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" @@ -1776,6 +2131,13 @@ 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" @@ -1801,6 +2163,19 @@ 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" @@ -1862,6 +2237,13 @@ 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" @@ -1869,6 +2251,43 @@ 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" @@ -1925,6 +2344,13 @@ 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" @@ -1949,11 +2375,25 @@ 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" @@ -2009,13 +2449,11 @@ deps = ["CSV", "CodecZlib", "CodecZstd", "Colors", "FixedPointNumbers", "FlexiJo path = ".." uuid = "7c9a2e4e-8f1d-4a6b-b84c-3e8a9f2d1c5e" version = "0.2.0" +weakdeps = ["Makie"] [deps.SpatialOmics.extensions] MakieExt = "Makie" - [deps.SpatialOmics.weakdeps] - Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" - [[deps.SpecialFunctions]] deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] git-tree-sha1 = "2700b235561b0335d5bef7097a111dc513b8655e" @@ -2109,6 +2547,18 @@ 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" @@ -2159,6 +2609,10 @@ 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", "CompilerSupportLibraries_jll", "Libdl", "libblastrampoline_jll"] uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" @@ -2264,6 +2718,12 @@ 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" @@ -2285,6 +2745,37 @@ 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" @@ -2360,6 +2851,27 @@ 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" @@ -2411,11 +2923,46 @@ 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" @@ -2430,6 +2977,20 @@ 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" @@ -2468,6 +3029,20 @@ 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 b0d82c7..217ea84 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,6 +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/src/tutorials/building_dataset.md b/docs/src/tutorials/building_dataset.md index 435fd99..c013354 100644 --- a/docs/src/tutorials/building_dataset.md +++ b/docs/src/tutorials/building_dataset.md @@ -1,5 +1,16 @@ # 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 @@ -90,6 +101,32 @@ push!(dataset, AcquisitionSource( ) ``` +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 diff --git a/docs/src/tutorials/coordinate_workflow.md b/docs/src/tutorials/coordinate_workflow.md index b8c0d78..a164158 100644 --- a/docs/src/tutorials/coordinate_workflow.md +++ b/docs/src/tutorials/coordinate_workflow.md @@ -1,5 +1,16 @@ # 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. @@ -33,7 +44,8 @@ element and leaves the local data unchanged. ```@example coordinate-workflow local_transcripts = SpatialPoints( - [Point2f(0, 0), Point2f(20, 40)]; + [Point2f(0, 0), Point2f(20, 40), Point2f(60, 20), + Point2f(80, 70), Point2f(100, 50)]; coord_system="fov_1_px", ) @@ -47,6 +59,30 @@ slide_transcripts = apply(to_slide, local_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. diff --git a/docs/src/tutorials/custom_starmap_reader.md b/docs/src/tutorials/custom_starmap_reader.md index c6523c3..3a8a4a6 100644 --- a/docs/src/tutorials/custom_starmap_reader.md +++ b/docs/src/tutorials/custom_starmap_reader.md @@ -1,5 +1,17 @@ # 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, @@ -166,6 +178,30 @@ transcript element emits a warning and uses the source footprint because the CSV 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 diff --git a/docs/src/tutorials/expression_summaries.md b/docs/src/tutorials/expression_summaries.md index d0959fd..ca97047 100644 --- a/docs/src/tutorials/expression_summaries.md +++ b/docs/src/tutorials/expression_summaries.md @@ -1,5 +1,16 @@ # 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 @@ -64,6 +75,41 @@ expression = analyze(Expression(), transcripts, cells) ) ``` +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 diff --git a/docs/src/tutorials/index.md b/docs/src/tutorials/index.md index 96f4928..6c3cfca 100644 --- a/docs/src/tutorials/index.md +++ b/docs/src/tutorials/index.md @@ -35,7 +35,9 @@ 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. Generated figures should record their public source dataset and -the code used to produce the committed asset; collaborator datasets are not -documentation inputs. Plotting dependencies for asset regeneration live in the -separate `docs/heavy` environment and are not installed by a normal docs build. +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 index d953c91..57d7789 100644 --- a/docs/src/tutorials/persistence.md +++ b/docs/src/tutorials/persistence.md @@ -1,5 +1,16 @@ # 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. @@ -50,6 +61,7 @@ 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)) ``` @@ -66,14 +78,43 @@ remaining changes and is most useful for temporary exploratory datasets. edit!(dataset, "transcripts") do transcripts coords(transcripts)[1] = Point2f(99, 99) end +staged_coordinates = copy(coords(points(dataset, "transcripts"))) discard!(dataset, "transcripts") -restored = coords(points(dataset, "transcripts"))[1] +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. diff --git a/docs/src/tutorials/source_roi_selection.md b/docs/src/tutorials/source_roi_selection.md index a53eb5a..16776d8 100644 --- a/docs/src/tutorials/source_roi_selection.md +++ b/docs/src/tutorials/source_roi_selection.md @@ -1,5 +1,16 @@ # 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. @@ -73,6 +84,29 @@ overlap_transcripts = points(view(dataset, 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 diff --git a/docs/src/tutorials/visium.md b/docs/src/tutorials/visium.md index 89d8b46..daaa77a 100644 --- a/docs/src/tutorials/visium.md +++ b/docs/src/tutorials/visium.md @@ -6,6 +6,7 @@ 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 @@ -123,12 +124,41 @@ 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 b2a2706..9761651 100644 --- a/docs/src/tutorials/xenium.md +++ b/docs/src/tutorials/xenium.md @@ -6,6 +6,7 @@ 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 @@ -145,14 +146,44 @@ by `read(SpatialDataZarr(), path)` without the full dataset. 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/src/images.jl b/src/images.jl index c50bf68..211d48f 100644 --- a/src/images.jl +++ b/src/images.jl @@ -149,6 +149,9 @@ 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 @@ -157,19 +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 diff --git a/src/zarr_io.jl b/src/zarr_io.jl index 105eaff..6c10510 100644 --- a/src/zarr_io.jl +++ b/src/zarr_io.jl @@ -399,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 diff --git a/test/regression.jl b/test/regression.jl index 8667554..dbb7bb1 100644 --- a/test/regression.jl +++ b/test/regression.jl @@ -807,6 +807,13 @@ end @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)