Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #277 +/- ##
==========================================
- Coverage 74.99% 73.77% -1.23%
==========================================
Files 103 105 +2
Lines 5419 5509 +90
==========================================
Hits 4064 4064
- Misses 1355 1445 +90
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…ize, array types and fallback access to acquisiton data
|
I really like this! We will try it out as soon as possible! |
|
I will give it a look and try it for subspace reconstruction for this package : https://github.com/CRMSB/PAPER_subspace_MESE Nice work ! |
|
Feel free to ping me here or in the Julia slack if you need help or stumble over something with the new interface. I think it would be really cool if not only existing recos work but if we can also find an example where we create a new parameter which results in functionality we didnt have before |
Hello everyone! This PR adds AbstractImageReconstruction (AIR) as a backend for MRIReco which I've mentioned in some MRIReco discussions and the Julia health slack. Unfortuantely, this is quite a large PR and while I've tried my best to make the migration transparent to users, it does change how reconstructions are implemented. So we should only go ahead if we see a clear benefit to this approach and people who are interested in developing new or extending existing algorithms feel capable of doing so. It would be great if some of you could spend some time playing around with this setup and give me some feedback @cncastillo, @aTrotier, @atsanda, @tknopp (or if you have questions to the wall of text that is about to follow and which I can hopefuly clarify in the comments)
I have not yet touched the Documenter docs, since that is an amount of effort I only want to do if we go ahead with the new backend. But I'll provide some context in the comments.
Why did MPIReco switch away from a dict based approach
Our reco MPIReco hit some limits of a dictionary/keyword argument based interface. To give some examples (transformed into MRIReco context):
We could dispatch to different types of arguments based on the input data, imagine acqusition data for multi-coil reconstructions had a type we could distinguish from a multi-echo file:
In MPI this approach to algorithm selection worked fairly well up to a point. However, when we have multiple algorithms for the same data, we can't dispatch on just the type anymore. So we need to look at either the content of a file or at a user supplied keyword. Either way we used some form of a switch/if statement:
This works but everytime we wanted to add a new variant, we need to update the if-statement. And if other researchers wanted to adapt a method, they'd have to fork the package and edit the source code. So extendabilty was always constrainted to either editing the code or having some arguments which you can overwrite with values such as
encodingOps.A second issue we faced is that our algorithms are inherently stateful. In particular, our encoding operator is not a matrix-free function, but instead we usuaslly construct it from large measurement files. That meant 50% of our reconstruction time is spent on building up the operator. This resulted in a split user-base. People who knew the internals of the package, could build the operator once and reuse it for quick reconstruction experiments, while people who relied on the most high-level user friendly interface always paid the cost. We also have a GUI for our measuring systems and since we are recording volumes multiple times per second, we also want to be able to do quick reconstructions in the GUI. In the end this meant that we hardcoded one algorithm for the quick path and had no access to all the other variants.
And lastly, we also passed
kwargs...down long function chains which also partially mutated the given set of parameters (either the kwargs or the dict). It was really difficult to reason about parameters, if they were set by a user or as a default from another function, if they should be passed on after being used or dropped. We even had cases where the same parameter needed to be used twice in an algorithm, but with different values. Which is not possible in a flat dict.Just to highlight this in MRIReco we currently have:
We also pass user-defined kwargs into function from MRIOperators and RegularizedLeastSquares with no context or validation and it's just not apparent what parameters an algorithm actually has, when they are set correctly and what effect they will have on the overall reconstruction. Overall the dicts we use for reconstruction parameters in MRIReco have "state" distributed across many functions and some state is even spread across different packages.
A great example of this is #263. The ADMM and SplitBregman solvers are a bit special in the way they handle transformation to other domain. While most solver want to get
reg = TransformedRegularization(L1Regularization, WaveletOp), those two solvers want to getreg = L1Regularization, regTrafo = WaveletOp. This means if a user uses those solvers and supplies the sparseTrafo as usualy, we currently generate the arguments to the solver incorrectly. If a user suppliessparseTrafo = "Wavelet", regTrafo = WaveletOp, the penalty suddenly is in the wavelet domain of the wavelet domain (might be interesting 🙈).AbstractImageReconstruction
To solve these issues, we switched from a pure-function based setup to a struct based setup and developed AbstractImageReconstruction (AIR). Note that AIR is not a finished package and the interface might change down the line, but so far we are happy with the features and would always strive to not lose any features. In the case of breaking interface changes, I would of course update MRIReco again.
AIR has three important concepts:
Parameters are implemented as callable structs (so parameters are the active part in defining processing), while algorithms are implemented as a FIFO queue (which is not super important for MRIReco, but is relevant for measurement systems). I've recently update AIRs documentation if you want more details.
The basic idea is that we generate templates for common algorithms:
which usually have a nested structure. Within this tree of parameters, all usable parameters are discoverable, we can validate them:
And we can implement small functions which are somewhat self-contained, testable and have more context than just a series of kwargs. The collective of all "parameter" functions of an specific algorithm implement the reconstruction.
For example, I've defined a parameter struct for regularization terms which contains a set of nested parameters for the sparsity transformations to be used (so our previous sparseTrafo parameter).
And this parameter implements a function:
(param::RegularizationParameters)(algo, solver)which returns eitherreg= ...orreg = ..., regTrafo = ...according to the given solver.If we ever want to create a new variant of an existing algorithm we can just swap out for example the SparsityParameters for something else. I've already prepared a CustomSparsityOperator where someone can just directly add Operators, but they could also define
MyDomainOperatorsin some other package with some other dependencies, which they can then plug into the existing components of MRIReco.Changelog
reconstruction(acqData, params)function now internally uses the pre-configured templates and does some parameter pre-processing to (hopefully) achieve transparent changesreconstruction_xfunctions are now marked as deprecated and are only reachable if a user explicitly calls them. Internally, they are unchangedImplementation details
I'll try to give a bottom up overview of the new interface. All iterative reconstruction shared a very similar structure of essentially:
At the very core of this loop is the linear solver provided by RegularizedLeastSquares (which we could also switch out now). This solver is essentially independant from most reco parameters. It just needs as inputs the
kdata, EFullandEFullᴴEFull. And while previously we constructed the regularization terms outside of the solver, I moved this inside the solver parameter.This solver core is parameterized by the solver arguments of RegularizedLeastSquares, which includes the regularization terms. The regularization terms are parameterized by the "usual" reg terms of
L1Regularizationand by sparsity transformations. Regularization terms need for their construction a solver.The next part of our loop are the encoding parameters. These are essentially just direct function calls to MRIOperators
encodingOps_xwhere x depends on the algorithm in question. So here we only need to have struct listing the common keyword arguments and dispatch to the correct call based on a given algorithm type. To finish constructing EFull we also need weighting.At the moment MRIReco supports two weighting strategies: density weighting or:
which I've represented by two weighting parameters
DensityWeightingParametersandUniformWeightingParameters.Both of these just depend on the acqData.
The multi coil algorithms additionally also depend on coil parameters, which in our case are the senseMaps and the noiseData.
With all these things in place, we have everything to construct
EFullandEFullᴴEFulland so the core of the loop is done.That means to fully describe our loop we need
encodingParams,weightingParams,solverParamsand (optionally)coilParams. These describe our existing algorithms.I went a step further and also seperated the loop-body from the loop-setup/context. While all iterative algorithms have a loop, the loop structure and the loop "inputs" differ per algorithm. For that I've created "Context" parameters, whose job it is to setup a loop and iterate over it (in parallel) and internally call our algorithm parameters with the loop index. This context is responsible for managing parallel computing. The context parameters essentially do:
And extra here contains things like the weights or any other information we need to prepare before we loop over everything.
There were some parameters we needed in basically every function inside this "context", which are the
reconSize, theacqDataitself, thearrayTypethat should be used (CPU vs GPU) and related thestorage_typefor the LinearOperators. Instead of passing these arguments to every function, I chose to use a ScopedValue to provide these to every function inside the "context core". For those who haven't used ScopedValue yet, you can imagine this working like every reconstruction having access to its very own "global" variable for those four values. All of the ScopedValue setup is also handled by the context parameters.Overall this gives us the following type hierarchy:
I know this is a lot, so feel free to ask any questions about this setup and if you have better ideas for structuring the parameters let me know. This is what I did based on the code, but there might be more intuitive ways based on the way MRI researchers think about these things.
Next Steps
If we approve these changes, the next steps would be:
encodingOps_xfrom MRIOperators withkwargs.... My gut feeling at the moment is to let MRIOperators just define the operator themselves and then have MRIReco actually construct them. I'm not sure what the most suitable type-hierarchy for this would be. We can leave it as is for now until someone has some need/benefit from thatHere we just need to see how and if people interact with reconstruction on the struct/plan level or on the highlevel dict approaches, either plan or
reconstructionbased.Third-party MRI reconstruction packages could also wrap their algorithms in very simple structs in a package extension to MRIReco and become available to KomaMRI this way.
Disclaimer
I've used some AI tools while implementing these features, however I had to manually change most of the output from that and the untouched things are mostly docstrings and some of the more boilerplate-y tests. Before switchout the backend of the dict-based reconstructions, I've compared results against the new recos and results where identially with
isapproxfor recos which had unchanged behaviour (see ADMM + sparseTrafos for changed behaviour). I did not visually check any examples from the docs yet