bayes-mef 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bayes_mef-0.2.0/.gitignore +23 -0
- bayes_mef-0.2.0/LICENSE +28 -0
- bayes_mef-0.2.0/PKG-INFO +182 -0
- bayes_mef-0.2.0/README.md +155 -0
- bayes_mef-0.2.0/bayes_mef/__init__.py +54 -0
- bayes_mef-0.2.0/bayes_mef/_compat.py +132 -0
- bayes_mef-0.2.0/bayes_mef/algos.py +402 -0
- bayes_mef-0.2.0/bayes_mef/cli.py +83 -0
- bayes_mef-0.2.0/bayes_mef/em.py +182 -0
- bayes_mef-0.2.0/bayes_mef/kernels.py +106 -0
- bayes_mef-0.2.0/bayes_mef/mef_launcher.py +474 -0
- bayes_mef-0.2.0/bayes_mef/utils.py +176 -0
- bayes_mef-0.2.0/pyproject.toml +72 -0
- bayes_mef-0.2.0/tests/__init__.py +0 -0
- bayes_mef-0.2.0/tests/_reference_numpy.py +429 -0
- bayes_mef-0.2.0/tests/conftest.py +116 -0
- bayes_mef-0.2.0/tests/test_algos.py +385 -0
- bayes_mef-0.2.0/tests/test_censored_poisson.py +83 -0
- bayes_mef-0.2.0/tests/test_cli.py +24 -0
- bayes_mef-0.2.0/tests/test_launcher.py +277 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# some extensions
|
|
2
|
+
.vscode
|
|
3
|
+
.DS_Store
|
|
4
|
+
# jupyter notebook
|
|
5
|
+
.ipynb_checkpoints
|
|
6
|
+
*/.ipynb_checkpoints/*
|
|
7
|
+
|
|
8
|
+
# Byte-compiled / optimized / DLL files
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*$py.class
|
|
12
|
+
|
|
13
|
+
# folders
|
|
14
|
+
data
|
|
15
|
+
dist
|
|
16
|
+
*.zip
|
|
17
|
+
# Python / uv
|
|
18
|
+
.venv/
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.pyc
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
dist/
|
bayes_mef-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024, microscopic-image-analysis
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
bayes_mef-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bayes-mef
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Bayesian multi-exposure image fusion for robust HDR imaging
|
|
5
|
+
Project-URL: Homepage, https://github.com/microscopic-image-analysis/bayes-mef
|
|
6
|
+
Project-URL: Repository, https://github.com/microscopic-image-analysis/bayes-mef
|
|
7
|
+
Project-URL: Issues, https://github.com/microscopic-image-analysis/bayes-mef/issues
|
|
8
|
+
Author-email: Shantanu Kodgirwar <shantanu.kodgirwar@uni-jena.de>, Michael Habeck <michael.habeck@uni-jena.de>
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: HDR,expectation-maximization,image-fusion,jax,ptychography
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: h5py>=3.9
|
|
19
|
+
Requires-Dist: jax>=0.8
|
|
20
|
+
Requires-Dist: numpy>=1.24
|
|
21
|
+
Requires-Dist: scipy>=1.11
|
|
22
|
+
Provides-Extra: cuda12
|
|
23
|
+
Requires-Dist: jax[cuda12]; extra == 'cuda12'
|
|
24
|
+
Provides-Extra: cuda13
|
|
25
|
+
Requires-Dist: jax[cuda13]; extra == 'cuda13'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Bayesian MEF
|
|
29
|
+
[](https://pypi.org/project/bayes_mef/)
|
|
30
|
+

|
|
31
|
+
[](https://doi.org/10.1364/OE.524284)
|
|
32
|
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
|
33
|
+
|
|
34
|
+
Bayesian multi-exposure image fusion (MEF) is a general-purpose algorithm for robust
|
|
35
|
+
high dynamic range (HDR) imaging under low SNR or varying illumination — in particular
|
|
36
|
+
for phase retrieval in coherent diffractive imaging. The paper,
|
|
37
|
+
["Bayesian multi-exposure image fusion for robust high dynamic range ptychography"](https://doi.org/10.1364/OE.524284),
|
|
38
|
+
details the method and its benefits for ptychography ([reproducing the results](#reproducing-results)).
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
This small library is implemented in [JAX](https://docs.jax.dev), so the same code runs on CPU/GPU/TPU. Inputs may be NumPy arrays, Python lists or JAX arrays; results come back as NumPy arrays.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install bayes_mef
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optionally for GPU (CUDA 12 or 13)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install "bayes_mef[cuda13]" # GPU with CUDA 13; `cuda12` flag for older GPUs
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
After a GPU install, check the GPU is picked up with `bayes_mef check gpu`.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
A minimal example, simulating some data
|
|
59
|
+
([Colab](https://colab.research.google.com/github/microscopic-image-analysis/bayes-mef/blob/main/demo.ipynb)):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from bayes_mef import BayesianMEF, ConventionalMEF
|
|
63
|
+
from skimage.data import camera
|
|
64
|
+
import numpy as np
|
|
65
|
+
|
|
66
|
+
truth = camera()
|
|
67
|
+
background = 60
|
|
68
|
+
times = np.array([0.1, 1, 10]) # exposure times / flux factors
|
|
69
|
+
threshold = 1500 # detector limit
|
|
70
|
+
|
|
71
|
+
# overexposed Poisson data from the image-formation model
|
|
72
|
+
data = [np.random.poisson(t * truth + background) for t in times]
|
|
73
|
+
data_saturated = np.clip(data, None, threshold, dtype="float")
|
|
74
|
+
|
|
75
|
+
mef_em = BayesianMEF(data_saturated, threshold, times, background)
|
|
76
|
+
mef_em.run(n_iter=100)
|
|
77
|
+
fused_em = mef_em.fused_image.copy()
|
|
78
|
+
|
|
79
|
+
# ConventionalMEF is the paper's MLE baseline, with the same interface
|
|
80
|
+
mef_mle = ConventionalMEF(data_saturated, threshold, times, background)
|
|
81
|
+
mef_mle.mle()
|
|
82
|
+
fused_mle = mef_mle.fused_image.copy()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Precision
|
|
86
|
+
|
|
87
|
+
Single precision by default (accurate for censoring thresholds up to ~4096). For 16-bit detectors, switch to double precision *before* creating any array; a warning flags the risk otherwise:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import bayes_mef
|
|
91
|
+
bayes_mef.enable_x64() # or set JAX_ENABLE_X64=1
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### When the exposures are not known
|
|
95
|
+
|
|
96
|
+
Omit `times` (and `threshold`) and they are estimated from the data, initialised from the non-saturated pixels (`flux_init="matched"`). The estimate is only a starting point, so **pass `update_fluxes=True` with it** and let EM refine it:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
mef_em = BayesianMEF(data_saturated, background=background, update_fluxes=True)
|
|
100
|
+
mef_em.run(n_iter=200)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`update_fluxes` defaults to False — `times` are used exactly as given, estimated or not — and estimating them without it warns. On the simulation above, the unrefined estimate correlates 0.16 with the truth against 0.989 once refined. `flux_init="uncensored"` selects the v0.1.9 initialiser, whose ratios compress under heavy censoring.
|
|
104
|
+
|
|
105
|
+
Once the background dominates the signal, i.e., the weak, low-SNR regime the [paper](https://doi.org/10.1364/OE.524284) targets: summed counts carry almost no information about the exposures, and it is EM's iterative background handling that recovers the range; a warning is raised when the estimate comes out nearly flat. Estimated fluxes are relative, so the fused image is on a relative scale. **Supply the real exposure times whenever you know them.**
|
|
106
|
+
|
|
107
|
+
### Fusing a 4D ptychogram dataset
|
|
108
|
+
|
|
109
|
+
For ptychography, we record multiple exposures per scan position. `LaunchMEF` fuses every scan position with a single vectorised program (CPU or GPU):
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from bayes_mef import LaunchMEF
|
|
113
|
+
|
|
114
|
+
launch_mef = LaunchMEF(
|
|
115
|
+
ptychogram_stack, # (n_exposures, n_scans, dp_x, dp_y)
|
|
116
|
+
background, # a number, one dark frame, or one per exposure
|
|
117
|
+
times=None, # None -> estimated from the data
|
|
118
|
+
threshold=None, # None -> estimated automatically
|
|
119
|
+
update_fluxes=False, # True -> EM refines the fluxes; pair with estimated times
|
|
120
|
+
flux_init="matched",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# returns fused patterns (n_scans, dp_x, dp_y) and the flux factors
|
|
124
|
+
fused_ptyem_stack, em_flux_factors = launch_mef.run_em(n_iter=150)
|
|
125
|
+
|
|
126
|
+
# or the conventional MLE baseline over the whole stack (just the fused patterns)
|
|
127
|
+
fused_ptymle_stack = launch_mef.run_mle()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Backgrounds are taken however they were recorded, on `LaunchMEF` and on the single stack classes alike: a scalar, one offset per exposure `(n_exposures,)`, a single dark frame `(dp_x, dp_y)`, one frame per exposure `(n_exposures, dp_x, dp_y)`, or one per image (the full stack shape). Each is given its exposure axis explicitly, so a per-exposure vector is never spread along the image columns, and a mismatched shape raises instead of broadcasting into something that quietly means the wrong thing.
|
|
131
|
+
|
|
132
|
+
The old `n_cpus` argument is accepted but ignored (it warns), since there are no worker processes to size any more.
|
|
133
|
+
|
|
134
|
+
Scans are fused in chunks sized to the device's free memory, so a stack larger than device memory still works. Set `batch_size` yourself if you hit a `MemoryError` or fuse alongside other work (results do not depend on it):
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
fused, fluxes = launch_mef.run_em(n_iter, batch_size=8)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
See [synthetic_mef.py](scripts/synthetic_mef.py) for detailed usage on synthetic
|
|
141
|
+
ptychography data, and [benchmarks/FINDINGS.md](benchmarks/FINDINGS.md) for a study
|
|
142
|
+
of when each method helps and for performance benchmarks.
|
|
143
|
+
|
|
144
|
+
## Reproducing results
|
|
145
|
+
|
|
146
|
+
To reproduce the ptychographic reconstructions from the paper:
|
|
147
|
+
|
|
148
|
+
1. Clone the repo:
|
|
149
|
+
```bash
|
|
150
|
+
git clone https://github.com/microscopic-image-analysis/bayes-mef.git
|
|
151
|
+
cd bayes-mef
|
|
152
|
+
```
|
|
153
|
+
2. Install the pinned dependencies with [uv](https://docs.astral.sh/uv/), then prefix
|
|
154
|
+
commands with `uv run` (e.g. `uv run python scripts/synthetic_mef.py`):
|
|
155
|
+
```bash
|
|
156
|
+
uv sync --locked --group scripts
|
|
157
|
+
```
|
|
158
|
+
3. Download the data from [Zenodo](https://zenodo.org/doi/10.5281/zenodo.10964222):
|
|
159
|
+
```bash
|
|
160
|
+
./download_data.sh
|
|
161
|
+
```
|
|
162
|
+
4. Optional: install [`cupy`](https://docs.cupy.dev/en/stable/install.html) for faster
|
|
163
|
+
GPU reconstructions.
|
|
164
|
+
5. Run files from [scripts/](scripts) to plot the results.
|
|
165
|
+
|
|
166
|
+
## Citation
|
|
167
|
+
If this algorithm or the publication was useful, please cite:
|
|
168
|
+
```tex
|
|
169
|
+
@article{Kodgirwar:24,
|
|
170
|
+
author = {Shantanu Kodgirwar and Lars Loetgering and Chang Liu and Aleena Joseph and Leona Licht and Daniel S. Penagos Molina and Wilhelm Eschen and Jan Rothhardt and Michael Habeck},
|
|
171
|
+
journal = {Opt. Express},
|
|
172
|
+
number = {16},
|
|
173
|
+
pages = {28090--28099},
|
|
174
|
+
publisher = {Optica Publishing Group},
|
|
175
|
+
title = {Bayesian multi-exposure image fusion for robust high dynamic range ptychography},
|
|
176
|
+
volume = {32},
|
|
177
|
+
month = {Jul},
|
|
178
|
+
year = {2024},
|
|
179
|
+
url = {https://opg.optica.org/oe/abstract.cfm?URI=oe-32-16-28090},
|
|
180
|
+
doi = {10.1364/OE.524284},
|
|
181
|
+
}
|
|
182
|
+
```
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Bayesian MEF
|
|
2
|
+
[](https://pypi.org/project/bayes_mef/)
|
|
3
|
+

|
|
4
|
+
[](https://doi.org/10.1364/OE.524284)
|
|
5
|
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
|
6
|
+
|
|
7
|
+
Bayesian multi-exposure image fusion (MEF) is a general-purpose algorithm for robust
|
|
8
|
+
high dynamic range (HDR) imaging under low SNR or varying illumination — in particular
|
|
9
|
+
for phase retrieval in coherent diffractive imaging. The paper,
|
|
10
|
+
["Bayesian multi-exposure image fusion for robust high dynamic range ptychography"](https://doi.org/10.1364/OE.524284),
|
|
11
|
+
details the method and its benefits for ptychography ([reproducing the results](#reproducing-results)).
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
This small library is implemented in [JAX](https://docs.jax.dev), so the same code runs on CPU/GPU/TPU. Inputs may be NumPy arrays, Python lists or JAX arrays; results come back as NumPy arrays.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install bayes_mef
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Optionally for GPU (CUDA 12 or 13)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install "bayes_mef[cuda13]" # GPU with CUDA 13; `cuda12` flag for older GPUs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
After a GPU install, check the GPU is picked up with `bayes_mef check gpu`.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
A minimal example, simulating some data
|
|
32
|
+
([Colab](https://colab.research.google.com/github/microscopic-image-analysis/bayes-mef/blob/main/demo.ipynb)):
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from bayes_mef import BayesianMEF, ConventionalMEF
|
|
36
|
+
from skimage.data import camera
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
truth = camera()
|
|
40
|
+
background = 60
|
|
41
|
+
times = np.array([0.1, 1, 10]) # exposure times / flux factors
|
|
42
|
+
threshold = 1500 # detector limit
|
|
43
|
+
|
|
44
|
+
# overexposed Poisson data from the image-formation model
|
|
45
|
+
data = [np.random.poisson(t * truth + background) for t in times]
|
|
46
|
+
data_saturated = np.clip(data, None, threshold, dtype="float")
|
|
47
|
+
|
|
48
|
+
mef_em = BayesianMEF(data_saturated, threshold, times, background)
|
|
49
|
+
mef_em.run(n_iter=100)
|
|
50
|
+
fused_em = mef_em.fused_image.copy()
|
|
51
|
+
|
|
52
|
+
# ConventionalMEF is the paper's MLE baseline, with the same interface
|
|
53
|
+
mef_mle = ConventionalMEF(data_saturated, threshold, times, background)
|
|
54
|
+
mef_mle.mle()
|
|
55
|
+
fused_mle = mef_mle.fused_image.copy()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Precision
|
|
59
|
+
|
|
60
|
+
Single precision by default (accurate for censoring thresholds up to ~4096). For 16-bit detectors, switch to double precision *before* creating any array; a warning flags the risk otherwise:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import bayes_mef
|
|
64
|
+
bayes_mef.enable_x64() # or set JAX_ENABLE_X64=1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### When the exposures are not known
|
|
68
|
+
|
|
69
|
+
Omit `times` (and `threshold`) and they are estimated from the data, initialised from the non-saturated pixels (`flux_init="matched"`). The estimate is only a starting point, so **pass `update_fluxes=True` with it** and let EM refine it:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
mef_em = BayesianMEF(data_saturated, background=background, update_fluxes=True)
|
|
73
|
+
mef_em.run(n_iter=200)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`update_fluxes` defaults to False — `times` are used exactly as given, estimated or not — and estimating them without it warns. On the simulation above, the unrefined estimate correlates 0.16 with the truth against 0.989 once refined. `flux_init="uncensored"` selects the v0.1.9 initialiser, whose ratios compress under heavy censoring.
|
|
77
|
+
|
|
78
|
+
Once the background dominates the signal, i.e., the weak, low-SNR regime the [paper](https://doi.org/10.1364/OE.524284) targets: summed counts carry almost no information about the exposures, and it is EM's iterative background handling that recovers the range; a warning is raised when the estimate comes out nearly flat. Estimated fluxes are relative, so the fused image is on a relative scale. **Supply the real exposure times whenever you know them.**
|
|
79
|
+
|
|
80
|
+
### Fusing a 4D ptychogram dataset
|
|
81
|
+
|
|
82
|
+
For ptychography, we record multiple exposures per scan position. `LaunchMEF` fuses every scan position with a single vectorised program (CPU or GPU):
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from bayes_mef import LaunchMEF
|
|
86
|
+
|
|
87
|
+
launch_mef = LaunchMEF(
|
|
88
|
+
ptychogram_stack, # (n_exposures, n_scans, dp_x, dp_y)
|
|
89
|
+
background, # a number, one dark frame, or one per exposure
|
|
90
|
+
times=None, # None -> estimated from the data
|
|
91
|
+
threshold=None, # None -> estimated automatically
|
|
92
|
+
update_fluxes=False, # True -> EM refines the fluxes; pair with estimated times
|
|
93
|
+
flux_init="matched",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# returns fused patterns (n_scans, dp_x, dp_y) and the flux factors
|
|
97
|
+
fused_ptyem_stack, em_flux_factors = launch_mef.run_em(n_iter=150)
|
|
98
|
+
|
|
99
|
+
# or the conventional MLE baseline over the whole stack (just the fused patterns)
|
|
100
|
+
fused_ptymle_stack = launch_mef.run_mle()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Backgrounds are taken however they were recorded, on `LaunchMEF` and on the single stack classes alike: a scalar, one offset per exposure `(n_exposures,)`, a single dark frame `(dp_x, dp_y)`, one frame per exposure `(n_exposures, dp_x, dp_y)`, or one per image (the full stack shape). Each is given its exposure axis explicitly, so a per-exposure vector is never spread along the image columns, and a mismatched shape raises instead of broadcasting into something that quietly means the wrong thing.
|
|
104
|
+
|
|
105
|
+
The old `n_cpus` argument is accepted but ignored (it warns), since there are no worker processes to size any more.
|
|
106
|
+
|
|
107
|
+
Scans are fused in chunks sized to the device's free memory, so a stack larger than device memory still works. Set `batch_size` yourself if you hit a `MemoryError` or fuse alongside other work (results do not depend on it):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
fused, fluxes = launch_mef.run_em(n_iter, batch_size=8)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
See [synthetic_mef.py](scripts/synthetic_mef.py) for detailed usage on synthetic
|
|
114
|
+
ptychography data, and [benchmarks/FINDINGS.md](benchmarks/FINDINGS.md) for a study
|
|
115
|
+
of when each method helps and for performance benchmarks.
|
|
116
|
+
|
|
117
|
+
## Reproducing results
|
|
118
|
+
|
|
119
|
+
To reproduce the ptychographic reconstructions from the paper:
|
|
120
|
+
|
|
121
|
+
1. Clone the repo:
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/microscopic-image-analysis/bayes-mef.git
|
|
124
|
+
cd bayes-mef
|
|
125
|
+
```
|
|
126
|
+
2. Install the pinned dependencies with [uv](https://docs.astral.sh/uv/), then prefix
|
|
127
|
+
commands with `uv run` (e.g. `uv run python scripts/synthetic_mef.py`):
|
|
128
|
+
```bash
|
|
129
|
+
uv sync --locked --group scripts
|
|
130
|
+
```
|
|
131
|
+
3. Download the data from [Zenodo](https://zenodo.org/doi/10.5281/zenodo.10964222):
|
|
132
|
+
```bash
|
|
133
|
+
./download_data.sh
|
|
134
|
+
```
|
|
135
|
+
4. Optional: install [`cupy`](https://docs.cupy.dev/en/stable/install.html) for faster
|
|
136
|
+
GPU reconstructions.
|
|
137
|
+
5. Run files from [scripts/](scripts) to plot the results.
|
|
138
|
+
|
|
139
|
+
## Citation
|
|
140
|
+
If this algorithm or the publication was useful, please cite:
|
|
141
|
+
```tex
|
|
142
|
+
@article{Kodgirwar:24,
|
|
143
|
+
author = {Shantanu Kodgirwar and Lars Loetgering and Chang Liu and Aleena Joseph and Leona Licht and Daniel S. Penagos Molina and Wilhelm Eschen and Jan Rothhardt and Michael Habeck},
|
|
144
|
+
journal = {Opt. Express},
|
|
145
|
+
number = {16},
|
|
146
|
+
pages = {28090--28099},
|
|
147
|
+
publisher = {Optica Publishing Group},
|
|
148
|
+
title = {Bayesian multi-exposure image fusion for robust high dynamic range ptychography},
|
|
149
|
+
volume = {32},
|
|
150
|
+
month = {Jul},
|
|
151
|
+
year = {2024},
|
|
152
|
+
url = {https://opg.optica.org/oe/abstract.cfm?URI=oe-32-16-28090},
|
|
153
|
+
doi = {10.1364/OE.524284},
|
|
154
|
+
}
|
|
155
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Bayesian multi-exposure image fusion for robust HDR imaging."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
# Never executed. The public names are bound lazily below, which leaves type
|
|
9
|
+
# checkers and editors with nothing to resolve, so "go to definition" on
|
|
10
|
+
# `from bayes_mef import BayesianMEF` lands nowhere. These imports exist for
|
|
11
|
+
# them alone; at runtime the lazy path is still the only one.
|
|
12
|
+
from ._compat import enable_x64
|
|
13
|
+
from .algos import BayesianMEF, ConventionalMEF
|
|
14
|
+
from .mef_launcher import LaunchMEF
|
|
15
|
+
from .utils import set_fluxes, set_threshold
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BayesianMEF",
|
|
19
|
+
"ConventionalMEF",
|
|
20
|
+
"LaunchMEF",
|
|
21
|
+
"enable_x64",
|
|
22
|
+
"set_fluxes",
|
|
23
|
+
"set_threshold",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
__version__ = version("bayes-mef")
|
|
28
|
+
except PackageNotFoundError: # not installed, e.g. running from a source checkout
|
|
29
|
+
__version__ = "0.0.0"
|
|
30
|
+
|
|
31
|
+
_LAZY = {
|
|
32
|
+
"BayesianMEF": "algos",
|
|
33
|
+
"ConventionalMEF": "algos",
|
|
34
|
+
"LaunchMEF": "mef_launcher",
|
|
35
|
+
"set_fluxes": "utils",
|
|
36
|
+
"set_threshold": "utils",
|
|
37
|
+
"enable_x64": "_compat",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name):
|
|
42
|
+
"""Import submodules on first use, so `import bayes_mef` does not load JAX.
|
|
43
|
+
|
|
44
|
+
Keeps the import cheap, and leaves a window for callers to configure JAX
|
|
45
|
+
(precision, platform) before the backend initialises.
|
|
46
|
+
"""
|
|
47
|
+
if name in _LAZY:
|
|
48
|
+
module = importlib.import_module(f".{_LAZY[name]}", __name__)
|
|
49
|
+
return getattr(module, name)
|
|
50
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __dir__():
|
|
54
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""NumPy <-> JAX boundary: dtype policy, input coercion, precision control."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import jax
|
|
6
|
+
import jax.numpy as jnp
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
_X64_WARNED = False
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_dtype():
|
|
13
|
+
"""Working float dtype: float64 when x64 is enabled, float32 otherwise."""
|
|
14
|
+
return jnp.float64 if jax.config.jax_enable_x64 else jnp.float32
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def enable_x64():
|
|
18
|
+
"""Run bayes_mef in double precision.
|
|
19
|
+
|
|
20
|
+
Must be called before any JAX array is created. Equivalent to setting
|
|
21
|
+
``JAX_ENABLE_X64=1`` in the environment.
|
|
22
|
+
"""
|
|
23
|
+
jax.config.update("jax_enable_x64", True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def as_array(x, dtype=None):
|
|
27
|
+
"""Coerce numpy arrays, Python lists/scalars and JAX arrays to a JAX array."""
|
|
28
|
+
return jnp.asarray(x, dtype=default_dtype() if dtype is None else dtype)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def as_background(x):
|
|
32
|
+
"""Background may be None, a scalar, or a full array; all broadcast against data."""
|
|
33
|
+
return as_array(0.0 if x is None else x)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def align_background(background, stack_shape, *, scan_axis=False):
|
|
37
|
+
"""Lay a recorded background out so it broadcasts against the stack.
|
|
38
|
+
|
|
39
|
+
Backgrounds arrive in whatever form the experiment produced: a number, one
|
|
40
|
+
offset per exposure, a single dark frame, one frame per exposure, or one per
|
|
41
|
+
image. All of them describe the same thing, but NumPy lines shapes up from
|
|
42
|
+
the trailing axis, so only some happen to broadcast correctly against a
|
|
43
|
+
stack -- a per-exposure vector silently spreads along the image columns
|
|
44
|
+
instead. Each form is therefore given its exposure axis explicitly here,
|
|
45
|
+
once, so nothing downstream has to reason about the layout again.
|
|
46
|
+
|
|
47
|
+
`stack_shape` is the stack as the caller holds it, exposures first:
|
|
48
|
+
(n_exposures, *image), or (n_exposures, n_scans, *image) with
|
|
49
|
+
`scan_axis=True`. Accepted backgrounds, in that same layout:
|
|
50
|
+
|
|
51
|
+
None, scalar, (1,) one offset for the whole stack
|
|
52
|
+
(n_exposures,) one offset per exposure
|
|
53
|
+
image one dark frame, shared by every exposure
|
|
54
|
+
(n_exposures, *image) one dark frame per exposure
|
|
55
|
+
stack_shape one dark frame per image (scan_axis only)
|
|
56
|
+
|
|
57
|
+
Anything of the right rank whose axes are 1 or the stack's own length is
|
|
58
|
+
accepted too, so a background already written as (n_exposures, 1, 1) or
|
|
59
|
+
(1, X, Y) passes through untouched. Rank is what disambiguates the forms,
|
|
60
|
+
so a shape that is merely broadcastable by NumPy's trailing-axis rule is
|
|
61
|
+
rejected rather than guessed at.
|
|
62
|
+
|
|
63
|
+
Returns a NumPy array in the caller's layout. A per-image background that
|
|
64
|
+
does not actually vary across scan positions -- `np.broadcast_to(frame,
|
|
65
|
+
stack.shape)`, the usual way of saying "this frame everywhere" -- collapses
|
|
66
|
+
to the per-exposure form, so the launcher uploads it once rather than
|
|
67
|
+
re-sending a stack-sized copy with every chunk.
|
|
68
|
+
"""
|
|
69
|
+
bg = np.asarray(0.0 if background is None else background)
|
|
70
|
+
stack_shape = tuple(stack_shape)
|
|
71
|
+
n_exposures = stack_shape[0]
|
|
72
|
+
image = stack_shape[2:] if scan_axis else stack_shape[1:]
|
|
73
|
+
|
|
74
|
+
def fits(target):
|
|
75
|
+
return len(bg.shape) == len(target) and all(
|
|
76
|
+
b in (1, t) for b, t in zip(bg.shape, target, strict=True)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if bg.ndim == 0 or bg.shape == (1,):
|
|
80
|
+
return bg.reshape(())
|
|
81
|
+
|
|
82
|
+
if bg.shape == (n_exposures,):
|
|
83
|
+
return bg.reshape((n_exposures,) + (1,) * len(image))
|
|
84
|
+
|
|
85
|
+
if fits(image) or fits((n_exposures, *image)):
|
|
86
|
+
return bg
|
|
87
|
+
|
|
88
|
+
if scan_axis and fits(stack_shape):
|
|
89
|
+
# A singleton or zero-strided scan axis means the frame was broadcast,
|
|
90
|
+
# not measured per position, so the axis carries nothing worth keeping.
|
|
91
|
+
if bg.shape[1] == 1 or bg.strides[1] == 0:
|
|
92
|
+
return bg[:, 0]
|
|
93
|
+
return bg
|
|
94
|
+
|
|
95
|
+
accepted = [f"a scalar, {(n_exposures,)}, {image}, {(n_exposures, *image)}"]
|
|
96
|
+
if scan_axis:
|
|
97
|
+
accepted.append(f"or {stack_shape}")
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"background shape {bg.shape} does not fit a stack of shape "
|
|
100
|
+
f"{stack_shape}. Expected {' '.join(accepted)}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def to_numpy(x):
|
|
105
|
+
"""Public results are NumPy, so downstream `.flat`, h5py and in-place idioms work."""
|
|
106
|
+
return np.asarray(x)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Above this censoring threshold, XLA's float32 igamma loses accuracy near
|
|
110
|
+
# rate ~ threshold -- exactly where the EM operates on censored pixels. Measured
|
|
111
|
+
# relative error at rate = 0.9 * threshold: 1e-3 at 4096, 2e-2 at 16383, 1e-1 at
|
|
112
|
+
# 65279. Below the bound the error stays at or under 2e-3.
|
|
113
|
+
X32_SAFE_THRESHOLD = 4096.0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def warn_if_precision_risk(threshold):
|
|
117
|
+
"""Warn once when single precision is not accurate enough for this threshold."""
|
|
118
|
+
global _X64_WARNED
|
|
119
|
+
if _X64_WARNED or jax.config.jax_enable_x64:
|
|
120
|
+
return
|
|
121
|
+
if float(np.max(np.asarray(threshold))) <= X32_SAFE_THRESHOLD:
|
|
122
|
+
return
|
|
123
|
+
_X64_WARNED = True
|
|
124
|
+
warnings.warn(
|
|
125
|
+
f"censoring threshold exceeds {X32_SAFE_THRESHOLD:.0f}, where float32 "
|
|
126
|
+
"evaluation of the censored-Poisson expectation loses up to ~10% "
|
|
127
|
+
"accuracy for rates near the threshold. Call bayes_mef.enable_x64() "
|
|
128
|
+
"(or set JAX_ENABLE_X64=1) before creating any array to run in double "
|
|
129
|
+
"precision.",
|
|
130
|
+
UserWarning,
|
|
131
|
+
stacklevel=3,
|
|
132
|
+
)
|