diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e4843ad --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 -c \"import sys,json; d=json.load(sys.stdin); print(d['tool_input'].get('file_path',''))\" | { read -r f; [[ \"$f\" == *.jl ]] && runic -i \"$f\"; } 2>/dev/null || true", + "statusMessage": "Formatting Julia with Runic..." + } + ] + } + ] + } +} diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..508e15c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# runic formatting +a885a8b diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 70a4a34..1f7fc9e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -41,7 +41,24 @@ jobs: - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v5 with: file: lcov.info token: ${{ secrets.CODECOV_TOKEN }} + docs: + name: Documentation + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: '1' + - uses: julia-actions/cache@v2 + - name: registry_add + run: julia -e 'using Pkg; pkg"registry add General https://github.com/HolyLab/HolyLabRegistry.git"' + - uses: julia-actions/julia-docdeploy@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.gitignore b/.gitignore index a174b0a..f949448 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ deps/deps.jl Manifest.toml Manifest-v*.toml +docs/build/ diff --git a/Project.toml b/Project.toml index 1412d44..8e7c9ee 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "RegisterDriver" uuid = "935ac36e-2656-11e9-1e3b-cbaa636797af" +version = "1.0.0" authors = ["Tim Holy "] -version = "0.2.4" [deps] Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" @@ -16,6 +16,11 @@ SharedArrays = "1a1011a3-84de-559e-8e89-a11a2f7dc383" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" [compat] +Aqua = "0.8" +AxisArrays = "0.4" +Distributed = "1" +Documenter = "1" +ExplicitImports = "1" Formatting = "0.4" HDF5 = "0.12, 0.13, 0.14, 0.15, 0.16, 0.17" ImageCore = "0.8.1, 0.9, 0.10" @@ -23,12 +28,17 @@ ImageMetadata = "0.9" JLD = "0.9, 0.10, 0.11, 0.12, 0.13" RegisterCore = "0.2, 1" RegisterWorkerShell = "0.2, 1" +SharedArrays = "1" StaticArrays = "0.11, 0.12, 1" +Test = "1" julia = "1.10" [extras] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" AxisArrays = "39de3d68-74b9-583c-8d2d-e117c070f3a9" +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["AxisArrays", "Test"] +test = ["Aqua", "AxisArrays", "Documenter", "ExplicitImports", "Test"] diff --git a/README.md b/README.md index cd65a51..c60e205 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,95 @@ # RegisterDriver.jl -This package supports distributed computing to accelerate image registration. -It wraps the [BlockRegistration](https://github.com/HolyLab/BlockRegistration.jl) framework. +[![CI](https://github.com/HolyLab/RegisterDriver.jl/actions/workflows/CI.yml/badge.svg)](https://github.com/HolyLab/RegisterDriver.jl/actions/workflows/CI.yml) +[![codecov](https://codecov.io/gh/HolyLab/RegisterDriver.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/HolyLab/RegisterDriver.jl) +[![docs-stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://holylab.github.io/RegisterDriver.jl/stable) +[![docs-dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://holylab.github.io/RegisterDriver.jl/dev) +[![Aqua QA](https://raw.githubusercontent.com/JuliaTesting/Aqua.jl/master/badge.svg)](https://github.com/JuliaTesting/Aqua.jl) -For an introduction, see the documentation for [RegisterWorkerApertures](https://github.com/HolyLab/RegisterWorkerApertures.jl). +RegisterDriver.jl drives image registration workflows in the +[BlockRegistration](https://github.com/HolyLab/BlockRegistration.jl) ecosystem. +It runs `AbstractWorker` algorithms frame-by-frame across an image stack, +optionally in parallel across multiple threads, and saves results to disk in JLD +format. + +## Installation + +RegisterDriver.jl is distributed through the +[HolyLab registry](https://github.com/HolyLab/HolyLabRegistry). +Add that registry before installing: + +```julia +using Pkg +pkg"registry add https://github.com/HolyLab/HolyLabRegistry.git" +Pkg.add("RegisterDriver") +``` + +## Concepts + +### Workers and monitors + +A *worker* is an `AbstractWorker` instance (from a `RegisterWorker*` package +such as +[RegisterWorkerApertures](https://github.com/HolyLab/RegisterWorkerApertures.jl)) +that encapsulates a registration algorithm for a particular compute device. +Before running, create a *monitor* dict that names which computed quantities to +collect from each frame: + +```julia +algorithm = MyWorker(fixed, params...) # construct an AbstractWorker +mon = monitor(algorithm, (:tform, :mismatch)) # fields to record +``` + +### The driver + +`driver` iterates the worker over every frame of an image stack. It handles +initialisation, per-frame execution, and teardown, then either saves the +collected values to a JLD file or returns them in-memory for single-image use. + +## Usage + +### Single image (in-memory) + +```julia +result = driver(algorithm, img, mon) +tform = result[:tform] +``` + +### Image stack saved to a file + +```julia +driver("results.jld", algorithm, img, mon) +``` + +Results for each frame are stored inside the JLD file: scalars as plain +vectors, bit-type arrays as higher-dimensional HDF5 datasets, and other values +inside per-frame `"stack"` groups. + +### Parallel multi-threaded registration + +Start Julia with multiple threads (e.g. `julia --threads=4`), then assign one +worker per worker thread: + +```julia +tids = threadids() +algorithms = [MyWorker(fixed, params...; workertid=t) for t in tids] +monitors = [monitor(algorithms[1], (:tform, :mismatch)) for _ in tids] +driver("results.jld", algorithms, monitors) +``` + +`threadids()` returns the sorted list of thread IDs that Julia actually +schedules `@threads` tasks on (typically excluding thread 1, which drives the +writer). + +### Loading a device-specific backend + +Some workers require a device-specific mismatch package (e.g. a CUDA backend) +to be loaded on the driver process before registration starts: + +```julia +mm_package_loader(algorithm) +driver("results.jld", algorithm, img, mon) +``` + +For a full introduction see the +[RegisterWorkerApertures documentation](https://github.com/HolyLab/RegisterWorkerApertures.jl). diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..ae02535 --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,7 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +RegisterDriver = "935ac36e-2656-11e9-1e3b-cbaa636797af" + +[compat] +Documenter = "1" +julia = "1.10" diff --git a/docs/make.jl b/docs/make.jl new file mode 100644 index 0000000..7a361d3 --- /dev/null +++ b/docs/make.jl @@ -0,0 +1,22 @@ +using Documenter +using RegisterDriver + +DocMeta.setdocmeta!(RegisterDriver, :DocTestSetup, :(using RegisterDriver); recursive=true) + +makedocs(; + modules=[RegisterDriver], + sitename="RegisterDriver.jl", + checkdocs=:exports, + format=Documenter.HTML(; + canonical="https://holylab.github.io/RegisterDriver.jl", + ), + pages=[ + "Home" => "index.md", + "API Reference" => "api.md", + ], +) + +deploydocs(; + repo="github.com/HolyLab/RegisterDriver.jl", + devbranch="master", +) diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..db442f6 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,14 @@ +# API Reference + +## Running registration + +```@docs +driver +``` + +## Utilities + +```@docs +mm_package_loader +threadids +``` diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..e96701e --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,96 @@ +# RegisterDriver.jl + +```@docs +RegisterDriver +``` + +RegisterDriver.jl drives image registration workflows in the +[BlockRegistration](https://github.com/HolyLab/BlockRegistration.jl) ecosystem. +It runs `AbstractWorker` algorithms frame-by-frame across an image stack, +optionally in parallel across multiple threads, and saves results to disk in JLD +format. + +## Installation + +RegisterDriver.jl is distributed through the +[HolyLab registry](https://github.com/HolyLab/HolyLabRegistry). +Add that registry before installing: + +```julia +using Pkg +pkg"registry add https://github.com/HolyLab/HolyLabRegistry.git" +Pkg.add("RegisterDriver") +``` + +## Concepts + +### Workers and monitors + +A *worker* is an `AbstractWorker` instance (from a `RegisterWorker*` package +such as +[RegisterWorkerApertures](https://github.com/HolyLab/RegisterWorkerApertures.jl)) +that encapsulates a registration algorithm for a particular compute device. +Before running, create a *monitor* dict that names which computed quantities to +collect from each frame: + +```julia +algorithm = MyWorker(fixed, params...) # construct an AbstractWorker +mon = monitor(algorithm, (:tform, :mismatch)) # fields to record +``` + +The `monitor` function is provided by +[RegisterWorkerShell](https://github.com/HolyLab/RegisterWorkerShell.jl). + +### The driver + +[`driver`](@ref) iterates the worker over every frame of an image stack. It +handles initialisation, per-frame execution, and teardown, then either saves the +collected values to a JLD file or returns them in-memory for single-image use. + +## Usage + +### Single image (in-memory) + +```julia +result = driver(algorithm, img, mon) +tform = result[:tform] +``` + +### Image stack saved to a file + +```julia +driver("results.jld", algorithm, img, mon) +``` + +Results for each frame are stored inside the JLD file: scalars as plain +vectors, bit-type arrays as higher-dimensional HDF5 datasets, and other values +inside per-frame `"stack"` groups. + +### Parallel multi-threaded registration + +Start Julia with multiple threads (e.g. `julia --threads=4`), then assign one +worker per worker thread: + +```julia +tids = threadids() +algorithms = [MyWorker(fixed, params...; workertid=t) for t in tids] +monitors = [monitor(algorithms[1], (:tform, :mismatch)) for _ in tids] +driver("results.jld", algorithms, monitors) +``` + +[`threadids()`](@ref) returns the sorted list of thread IDs that Julia actually +schedules `@threads` tasks on (typically excluding thread 1, which drives the +writer task). + +### Loading a device-specific backend + +Some workers require a device-specific mismatch package (e.g. a CUDA backend) +to be loaded on the driver process before registration starts: + +```julia +mm_package_loader(algorithm) +driver("results.jld", algorithm, img, mon) +``` + +For a full introduction see the +[RegisterWorkerApertures documentation](https://github.com/HolyLab/RegisterWorkerApertures.jl). diff --git a/src/RegisterDriver.jl b/src/RegisterDriver.jl index 3c5ab23..604dbe7 100644 --- a/src/RegisterDriver.jl +++ b/src/RegisterDriver.jl @@ -1,54 +1,65 @@ +""" + RegisterDriver + +Drive image registration workflows: run `AbstractWorker` algorithms over +single- or multi-threaded execution and save results to disk. + +Primary entry point: [`driver`](@ref). See also [`mm_package_loader`](@ref) +and [`threadids`](@ref). +""" module RegisterDriver -using ImageCore, ImageMetadata, JLD, HDF5, StaticArrays, Formatting, SharedArrays, Distributed -using RegisterCore, RegisterWorkerShell -using Base.Threads +using Distributed: Distributed +using Formatting: Formatting, FormatSpec, fmt +using HDF5: HDF5, create_dataset, create_group, dataspace, datatype +using ImageCore: ImageCore, nimages +using ImageMetadata: ImageMetadata +using JLD: JLD, jldopen +using RegisterCore: RegisterCore, NumDenom +using RegisterWorkerShell: RegisterWorkerShell, AbstractWorker, ArrayDecl, + close!, init!, load_mm_package, worker +using SharedArrays: SharedArrays, SharedArray, sdata +using StaticArrays: StaticArrays, StaticArray +using Base.Threads: @threads, nthreads, threadid -if isdefined(HDF5, :BitsType) - const BitsType = HDF5.BitsType -else - const BitsType = HDF5.HDF5BitsKind -end -if !isdefined(HDF5, :create_dataset) - const create_dataset = d_create -end -if !isdefined(HDF5, :create_group) - const create_group = g_create -end +const BitsType = HDF5.BitsType export driver, mm_package_loader, threadids """ -`driver(outfile, algorithm, img, mon)` performs registration of the -image(s) in `img` according to the algorithm selected by -`algorithm`. `algorithm` is either a single instance, or for parallel -computation a vector of instances, of `AbstractWorker` types. See the -`RegisterWorkerShell` module for more details. - -Results are saved in `outfile` according to the information in `mon`. -`mon` is a `Dict`, or for parallel computation a vector of `Dicts` of -the same length as `algorithm`. The data saved correspond to the keys -(always `Symbol`s) in `mon`, and the values are used for communication -between the worker(s) and the driver. The usual way to set up `mon` -is like this: + driver(outfile, algorithm, img, mon) + driver(outfile, algorithms, img, mon) -``` - algorithm = RegisterRigid(fixed, params...) # An AbstractWorker algorithm - mon = monitor(algorithm, (:tform,:mismatch)) # List of variables to record -``` +Register the image(s) in `img` and save results to `outfile` in JLD format. -The list of symbols, taken from the field names of `RegisterRigid`, -specifies the pieces of information to be communicated back to the -driver process for saving and/or display to the user. It's also -possible to request local variables in the worker, as long as the -worker has been written to look for such settings: +`algorithm` is a single `AbstractWorker` instance; `algorithms` is a `Vector` +of such instances for parallel (multi-threaded) computation. See the +`RegisterWorkerShell` module for details on constructing workers. +`mon` is a `Dict` mapping `Symbol` keys to communication values, or for the +parallel form a `Vector` of such `Dict`s (one per worker). The keys specify +which computed quantities are communicated back from each worker. Set them up +with the worker's `monitor` function: + +```julia +algorithm = RegisterRigid(fixed, params...) # construct an AbstractWorker +mon = monitor(algorithm, (:tform, :mismatch)) # select fields to record +driver("results.jld", algorithm, img, mon) # register and save ``` - # - monitor_copy!(mon, :extra, extra) + +Scalars are stored as plain vectors indexed by image number; bit-type arrays are +stored as higher-dimensional HDF5 datasets; other values are stored per-image +inside `"stack"` groups. + +Additional local worker variables can be recorded by adding their keys to `mon` +and calling `monitor_copy!` inside the worker: + +```julia +# inside the worker algorithm: +monitor_copy!(mon, :extra, extra) # saved only if :extra is a key in mon ``` -which will save `extra` only if `:extra` is a key in `mon`. +Returns `nothing`. """ function driver(outfile::AbstractString, algorithms::Vector, img, mon::Vector) nalgs = length(algorithms) @@ -140,8 +151,22 @@ end driver(outfile::AbstractString, algorithm::AbstractWorker, img, mon::Dict) = driver(outfile, [algorithm], img, [mon]) """ -`mon = driver(algorithm, img, mon)` performs registration on a single -image, returning the results in `mon`. + driver(algorithm, img, mon) -> Dict + +Register the single image in `img` and return the populated result `Dict`. + +`img` must contain exactly one image; for multi-image stacks use the +file-saving form of `driver`. The returned `Dict` is the same object as `mon`, +with each key's value updated to the quantity computed by the worker. + +# Example + +```julia +algorithm = RegisterRigid(fixed, params...) +mon = monitor(algorithm, (:tform, :mismatch)) +mon = driver(algorithm, img, mon) +tform = mon[:tform] +``` """ function driver(algorithm::AbstractWorker, img, mon::Dict) nimages(img) == 1 || error("With multiple images, you must store results to a file") @@ -196,21 +221,43 @@ end nicehdf5(v::SharedArray) = sdata(v) nicehdf5(v) = v -function copy_all_but_shared!(dest, src) - for (k, v) in src - if !isa(v, SharedArray) - dest[k] = v - end - end - return dest -end +""" + mm_package_loader(algorithm::AbstractWorker) + mm_package_loader(algorithms::Vector{<:AbstractWorker}) + +Load the mismatch-computation package appropriate for `algorithm`'s compute device. + +Thin wrapper around `RegisterWorkerShell.load_mm_package` that accepts either a +single worker or a vector of workers (delegating to the first element). Call this +before `driver` when the algorithm requires a device-specific backend (e.g., a +CUDA mismatch package) to be loaded on the driver process. + +Returns `nothing`. +""" mm_package_loader(algorithms::Vector{W}) where {W <: AbstractWorker} = mm_package_loader(algorithms[1]) function mm_package_loader(algorithm::AbstractWorker) load_mm_package(algorithm.dev) return nothing end +""" + threadids() -> Vector{Int} + +Return the sorted list of thread IDs that Julia's scheduler actually assigns to +tasks spawned with `@threads` and `Threads.@spawn`. + +Julia's main thread (ID 1) typically does not execute worker tasks. The +returned IDs are useful for configuring `AbstractWorker` instances that pin +execution to a specific thread via the `workertid` field. + +# Example + +```julia +# On a Julia session started with 4 threads +threadids() # e.g. [2, 3, 4, 5] +``` +""" function threadids() nt = nthreads() ch = Channel{Int}(nt * 1001) diff --git a/test/WorkerDummy.jl b/test/WorkerDummy.jl index 87dae29..96ffd0f 100644 --- a/test/WorkerDummy.jl +++ b/test/WorkerDummy.jl @@ -4,7 +4,7 @@ module WorkerDummy using RegisterWorkerShell, Distributed import RegisterWorkerShell: worker -export Alg1, Alg2, Alg3 +export Alg1, Alg2, Alg3, Alg4 # Dispatch on the algorithm used to perform registration # Each algorithm has a container it uses for storage and communication @@ -60,4 +60,21 @@ function worker(algorithm::Alg3, moving, tindex, mon) return mon end +# Alg4: monitor contains a non-BitsType array (Vector{ComplexF32}) alongside +# an unpackable string, exercising the group-write paths in the driver and initialize_jld! +mutable struct Alg4 <: Alg + data::Vector{ComplexF32} + label::String + workertid::Int +end +function Alg4(; tid=1) + return Alg4(ComplexF32[ComplexF32(float(i), -float(i)) for i in 1:4], "frame", tid) +end + +function worker(algorithm::Alg4, moving, tindex, mon) + mon[:data] = algorithm.data .* tindex + mon[:label] = algorithm.label * string(tindex) + return mon +end + end # module diff --git a/test/runtests.jl b/test/runtests.jl index ac3fa86..9878774 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,12 +1,31 @@ using Test, Distributed, SharedArrays +using Aqua +using Documenter +using ExplicitImports using ImageCore, JLD using RegisterDriver, RegisterWorkerShell using AxisArrays: AxisArray +using RegisterCore: NumDenom +using StaticArrays: SVector using Base.Threads push!(LOAD_PATH, pwd()) using WorkerDummy +@testset "Doctests" begin + DocMeta.setdocmeta!(RegisterDriver, :DocTestSetup, :(using RegisterDriver); recursive = true) + doctest(RegisterDriver; manual = false) +end + +@testset "Aqua" begin + Aqua.test_all(RegisterDriver) +end + +@testset "ExplicitImports" begin + # BitsType is intentionally accessed as HDF5.BitsType (non-public alias) + ExplicitImports.test_explicit_imports(RegisterDriver; ignore=(:BitsType,)) +end + @testset "RegisterDriver" begin workdir = tempname() mkdir(workdir) @@ -68,4 +87,45 @@ using WorkerDummy indx = unique(indexin(tid, tids)) @test length(indx) == length(tids) && all(indx .> 0) rm(fn) + + # Non-BitsType array (ComplexF32) alongside an unpackable string: exercises + # the group-write path in the writer task (line 98) and initialize_jld! (line 169) + alg4 = Alg4() + mon4 = Dict{Symbol,Any}(:data => copy(alg4.data), :label => alg4.label) + fn = joinpath(workdir, "file5.jld") + driver(fn, alg4, img, mon4) + jldopen(fn, "r") do file + g2 = file["stack2"] + @test read(g2, "label") == "frame2" + @test read(g2, "data") == alg4.data .* 2 + end + rm(fn) +end + +@testset "In-memory single-image driver" begin + single_img = AxisArray(SharedArray{Float32}((100, 100, 1)), :y, :x, :time) + alg = Alg1(rand(3, 3), 3.2) + mon = monitor(alg, (:λ,)) + result = driver(alg, single_img, mon) + @test result[:λ] == 1.0 + + multi_img = AxisArray(SharedArray{Float32}((100, 100, 3)), :y, :x, :time) + @test_throws "With multiple images" driver(alg, multi_img, mon) +end + +@testset "nicehdf5 specializations" begin + # Plain SharedArray → sdata + sa = SharedArray{Float32}((3, 4)) + sa .= 2.0f0 + @test RegisterDriver.nicehdf5(sa) === sdata(sa) + + # Array of StaticArrays → reinterpreted matrix + arr_sv = [SVector(1.0, 2.0), SVector(3.0, 4.0)] + result_sv = RegisterDriver.nicehdf5(arr_sv) + @test result_sv == [1.0 3.0; 2.0 4.0] + + # Array of NumDenom → reinterpreted 2×n matrix + arr_nd = [NumDenom(1.0, 2.0), NumDenom(3.0, 4.0)] + result_nd = RegisterDriver.nicehdf5(arr_nd) + @test result_nd == [1.0 3.0; 2.0 4.0] end