qoraphase 0.1.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.
- qoraphase-0.1.0/.gitignore +13 -0
- qoraphase-0.1.0/CITATION.cff +20 -0
- qoraphase-0.1.0/LICENSE +21 -0
- qoraphase-0.1.0/PKG-INFO +193 -0
- qoraphase-0.1.0/README.md +168 -0
- qoraphase-0.1.0/examples/diagnose.py +140 -0
- qoraphase-0.1.0/pyproject.toml +47 -0
- qoraphase-0.1.0/src/qoraphase/__init__.py +48 -0
- qoraphase-0.1.0/src/qoraphase/algorithms/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/algorithms/er.py +22 -0
- qoraphase-0.1.0/src/qoraphase/algorithms/hio.py +24 -0
- qoraphase-0.1.0/src/qoraphase/algorithms/raar.py +31 -0
- qoraphase-0.1.0/src/qoraphase/api.py +110 -0
- qoraphase-0.1.0/src/qoraphase/core/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/core/ensemble.py +77 -0
- qoraphase-0.1.0/src/qoraphase/core/problem.py +94 -0
- qoraphase-0.1.0/src/qoraphase/core/result.py +55 -0
- qoraphase-0.1.0/src/qoraphase/core/state.py +40 -0
- qoraphase-0.1.0/src/qoraphase/core/validate.py +47 -0
- qoraphase-0.1.0/src/qoraphase/diagnostics.py +200 -0
- qoraphase-0.1.0/src/qoraphase/metrics/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/metrics/errors.py +24 -0
- qoraphase-0.1.0/src/qoraphase/models/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/models/fft.py +24 -0
- qoraphase-0.1.0/src/qoraphase/operators/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/operators/modulus.py +34 -0
- qoraphase-0.1.0/src/qoraphase/operators/shrinkwrap.py +77 -0
- qoraphase-0.1.0/src/qoraphase/operators/support.py +51 -0
- qoraphase-0.1.0/src/qoraphase/postprocess.py +66 -0
- qoraphase-0.1.0/src/qoraphase/recipes/__init__.py +0 -0
- qoraphase-0.1.0/src/qoraphase/recipes/recipe.py +119 -0
- qoraphase-0.1.0/tests/conftest.py +102 -0
- qoraphase-0.1.0/tests/test_algorithms.py +137 -0
- qoraphase-0.1.0/tests/test_api.py +97 -0
- qoraphase-0.1.0/tests/test_detwin.py +178 -0
- qoraphase-0.1.0/tests/test_diagnostics.py +147 -0
- qoraphase-0.1.0/tests/test_ensemble.py +42 -0
- qoraphase-0.1.0/tests/test_fft.py +49 -0
- qoraphase-0.1.0/tests/test_forward_model.py +130 -0
- qoraphase-0.1.0/tests/test_jax_transforms.py +95 -0
- qoraphase-0.1.0/tests/test_metrics.py +36 -0
- qoraphase-0.1.0/tests/test_postprocess.py +121 -0
- qoraphase-0.1.0/tests/test_projections.py +82 -0
- qoraphase-0.1.0/tests/test_public_handles.py +52 -0
- qoraphase-0.1.0/tests/test_recipe.py +49 -0
- qoraphase-0.1.0/tests/test_shapes.py +36 -0
- qoraphase-0.1.0/tests/test_shrinkwrap.py +107 -0
- qoraphase-0.1.0/tests/test_validate.py +62 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
type: software
|
|
4
|
+
title: "QoraPhase: A JAX-native, composable phase-retrieval framework for coherent diffraction imaging"
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: Yong
|
|
7
|
+
given-names: Isaac
|
|
8
|
+
email: isaacyong@naver.com
|
|
9
|
+
affiliation: "Sogang University"
|
|
10
|
+
license: MIT
|
|
11
|
+
repository-code: "https://github.com/NaNIsaac/QoraPhase"
|
|
12
|
+
abstract: "A JAX-native, composable phase-retrieval framework for coherent diffraction imaging: an intensity array goes in, a reconstruction object comes out."
|
|
13
|
+
keywords:
|
|
14
|
+
- "phase retrieval"
|
|
15
|
+
- "coherent diffraction imaging"
|
|
16
|
+
- "CDI"
|
|
17
|
+
- "JAX"
|
|
18
|
+
- "X-ray"
|
|
19
|
+
version: "0.1.0"
|
|
20
|
+
date-released: "2026-08-28"
|
qoraphase-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Yong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
qoraphase-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: qoraphase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A JAX-native, composable phase-retrieval framework for coherent diffraction imaging
|
|
5
|
+
Project-URL: Homepage, https://github.com/NaNIsaac/QoraPhase
|
|
6
|
+
Project-URL: Repository, https://github.com/NaNIsaac/QoraPhase
|
|
7
|
+
Project-URL: Issues, https://github.com/NaNIsaac/QoraPhase/issues
|
|
8
|
+
Author-email: Isaac Yong <isaacyong@naver.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: CDI,coherent diffraction imaging,jax,phase retrieval,x-ray imaging
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: jax>=0.11.1
|
|
22
|
+
Requires-Dist: matplotlib>=3.11.1
|
|
23
|
+
Requires-Dist: numpy>=2.5.2
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# QoraPhase
|
|
27
|
+
|
|
28
|
+
A JAX-native, composable phase-retrieval framework for coherent diffraction imaging.
|
|
29
|
+
|
|
30
|
+
**Array in → reconstruction out.** QoraPhase does not read files. You bring a
|
|
31
|
+
`numpy` or `jax` array; it gives you back an object.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install qoraphase
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import qoraphase as qp
|
|
43
|
+
|
|
44
|
+
result = qp.reconstruct(intensity, support=support)
|
|
45
|
+
result.plot()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Array conventions (read this first)
|
|
49
|
+
|
|
50
|
+
These three are the source of nearly every phase-retrieval bug, so they are part
|
|
51
|
+
of the API contract, not an implementation detail.
|
|
52
|
+
|
|
53
|
+
| | |
|
|
54
|
+
|---|---|
|
|
55
|
+
| **Origin** | Every public array is **center-origin**: index `n // 2` is q=0. `ifftshift` is internal; never call it yourself. |
|
|
56
|
+
| **`intensity`** | A measured **intensity**, not an amplitude. Internally `sqrt(clip(intensity, 0))`. |
|
|
57
|
+
| **`mask`** | `True` marks an **unusable** pixel (beamstop, hot, saturated). Masked pixels are left unconstrained — never forced to zero. |
|
|
58
|
+
| **`support`** | `True` is inside the object. Omit it and an autocorrelation support is derived (loose; pass a real one when you have it). |
|
|
59
|
+
|
|
60
|
+
## Composing a reconstruction
|
|
61
|
+
|
|
62
|
+
Code order is execution order. There is no operator-overloading DSL.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
problem = qp.CDI(intensity=intensity, support=support, mask=mask)
|
|
66
|
+
|
|
67
|
+
recipe = qp.Recipe(
|
|
68
|
+
qp.repeat(10, qp.RAAR(50, beta=0.9), qp.Shrinkwrap()),
|
|
69
|
+
qp.ER(200),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result = problem.reconstruct(recipe)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The first positional argument of an algorithm is always its iteration count.
|
|
76
|
+
|
|
77
|
+
`qp.detwin(...)` runs its body against a support cut in half at the object's
|
|
78
|
+
centre of mass, then restores the full support. That cut is not centrosymmetric,
|
|
79
|
+
so it fits only one of the two conjugate solutions and biases the run into a
|
|
80
|
+
branch. The default recipe places it a quarter of the way through the RAAR
|
|
81
|
+
cycles, where PyNX puts it. It is measurably useful when the support is
|
|
82
|
+
symmetric and the run would otherwise stall, and harmless when it is not — but
|
|
83
|
+
a support with the right shape is worth far more than any detwinning.
|
|
84
|
+
|
|
85
|
+
`qp.Shrinkwrap(threshold, sigma, method)` takes `method="max"` (classic, relative
|
|
86
|
+
to the peak amplitude) or `method="rms"` (relative to the rms inside the current
|
|
87
|
+
support, less easily thrown off by a hot pixel). The thresholds are on different
|
|
88
|
+
scales: the classic `"max"` value is 0.2, PyNX uses 0.28 for `"rms"`.
|
|
89
|
+
|
|
90
|
+
The defaults are `0.3, 1.0` rather than the classic `0.2, 2.0`. Starting from an
|
|
91
|
+
autocorrelation support — the case that actually needs shrinkwrap — 0.2 / 2.0
|
|
92
|
+
never converged in testing and 0.3 / 1.0 did, on every object tried. That is a
|
|
93
|
+
measurement on synthetic data, not a law. **These two numbers are the first thing
|
|
94
|
+
to re-tune on a real dataset.**
|
|
95
|
+
|
|
96
|
+
## Multi-start
|
|
97
|
+
|
|
98
|
+
Multi-start is a core feature, not an add-on — for a support with awkward
|
|
99
|
+
symmetry it is the difference between converging and not.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
ensemble = problem.reconstruct(recipe, starts=32, batch=4)
|
|
103
|
+
best = ensemble.best
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`vmap` holds every start in memory at once. `batch` chunks that: lower it if a
|
|
107
|
+
large 3D volume runs out of GPU memory.
|
|
108
|
+
|
|
109
|
+
## Swapping the forward model
|
|
110
|
+
|
|
111
|
+
Algorithms never name an FFT, so the physics is replaceable without touching
|
|
112
|
+
them:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
qp.CDI(intensity, support=support, model=MyPropagator())
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A model needs `forward` / `adjoint` and should be unitary. A *wrong* model fails
|
|
119
|
+
silently — it can fit the data perfectly and return the wrong object — so this
|
|
120
|
+
is worth getting right. See `tests/test_forward_model.py`.
|
|
121
|
+
|
|
122
|
+
## Before you trust a reconstruction
|
|
123
|
+
|
|
124
|
+
Check the measurement against the assumptions the library makes about it:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
print(qp.diagnose(intensity, mask=mask, support=support))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
It reports the centring offset two ways (brightest pixel, and Friedel symmetry,
|
|
131
|
+
which resolves half a pixel and works on patterns with no dominant peak), how
|
|
132
|
+
much of the array the autocorrelation fills, the negative and masked fractions,
|
|
133
|
+
and warns about each. An off-centre pattern is the failure that costs the most
|
|
134
|
+
and shows the least.
|
|
135
|
+
|
|
136
|
+
`examples/diagnose.py` does the same from the command line, then reconstructs
|
|
137
|
+
and reports whether independent starts agree:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
python examples/diagnose.py scan.npz --key intensity --support sup.npy --starts 8
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A low Fourier error says a run fits the data. Agreement between starts, after
|
|
144
|
+
alignment, is what says the answer is determined — they are different claims and
|
|
145
|
+
only the second one is worth much.
|
|
146
|
+
|
|
147
|
+
## Combining runs
|
|
148
|
+
|
|
149
|
+
A reconstruction is determined only up to a global phase, a translation, and a
|
|
150
|
+
conjugate flip. No iteration can remove those — they are exact symmetries of the
|
|
151
|
+
constraints — so they are resolved afterwards:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
aligned = ensemble.align() # every run brought onto the best one
|
|
155
|
+
mean = ensemble.average() # coherent mean of the aligned runs
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Never average runs without aligning them.** Each carries its own global phase,
|
|
160
|
+
so a raw mean interferes destructively — in practice it throws away about two
|
|
161
|
+
thirds of the amplitude. The direction survives, so a similarity score will not
|
|
162
|
+
warn you; only the amplitude shows it.
|
|
163
|
+
|
|
164
|
+
`align()` picks a *convention*, not the truth. It adopts whichever conjugate
|
|
165
|
+
branch the best run happens to be in — enough to combine runs, and no claim
|
|
166
|
+
about which branch is real. Nothing in the data distinguishes them: `conj(obj(-r))`
|
|
167
|
+
reproduces the measured intensity exactly. What breaks the tie is the support
|
|
168
|
+
you supply. Hand the solver a reflected support and every start converges just
|
|
169
|
+
as cleanly on the mirrored object, with a flawless error metric and no warning.
|
|
170
|
+
The handedness of your support is an assumption, not a result.
|
|
171
|
+
|
|
172
|
+
Alignment works to whole pixels.
|
|
173
|
+
|
|
174
|
+
`result.phase` still carries whatever linear phase the measurement geometry put
|
|
175
|
+
there, and QoraPhase does not remove it. Doing that properly needs the
|
|
176
|
+
diffraction geometry, which arrives with BCDI in v0.3; short of that, any
|
|
177
|
+
"correction" is a guess.
|
|
178
|
+
|
|
179
|
+
If the twin branch matters to you, break the symmetry during the run rather than
|
|
180
|
+
after it — see `qp.detwin` below.
|
|
181
|
+
|
|
182
|
+
## What this does not do
|
|
183
|
+
|
|
184
|
+
- No phase-ramp removal, no sub-pixel alignment, no clustering, no PRTF.
|
|
185
|
+
- No positivity/reality constraint — the object is complex here.
|
|
186
|
+
- No file I/O, no BCDI geometry, no NUFFT, no strain, no autodiff optimisers.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
uv sync --all-groups
|
|
192
|
+
uv run pytest
|
|
193
|
+
```
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# QoraPhase
|
|
2
|
+
|
|
3
|
+
A JAX-native, composable phase-retrieval framework for coherent diffraction imaging.
|
|
4
|
+
|
|
5
|
+
**Array in → reconstruction out.** QoraPhase does not read files. You bring a
|
|
6
|
+
`numpy` or `jax` array; it gives you back an object.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install qoraphase
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import qoraphase as qp
|
|
18
|
+
|
|
19
|
+
result = qp.reconstruct(intensity, support=support)
|
|
20
|
+
result.plot()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Array conventions (read this first)
|
|
24
|
+
|
|
25
|
+
These three are the source of nearly every phase-retrieval bug, so they are part
|
|
26
|
+
of the API contract, not an implementation detail.
|
|
27
|
+
|
|
28
|
+
| | |
|
|
29
|
+
|---|---|
|
|
30
|
+
| **Origin** | Every public array is **center-origin**: index `n // 2` is q=0. `ifftshift` is internal; never call it yourself. |
|
|
31
|
+
| **`intensity`** | A measured **intensity**, not an amplitude. Internally `sqrt(clip(intensity, 0))`. |
|
|
32
|
+
| **`mask`** | `True` marks an **unusable** pixel (beamstop, hot, saturated). Masked pixels are left unconstrained — never forced to zero. |
|
|
33
|
+
| **`support`** | `True` is inside the object. Omit it and an autocorrelation support is derived (loose; pass a real one when you have it). |
|
|
34
|
+
|
|
35
|
+
## Composing a reconstruction
|
|
36
|
+
|
|
37
|
+
Code order is execution order. There is no operator-overloading DSL.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
problem = qp.CDI(intensity=intensity, support=support, mask=mask)
|
|
41
|
+
|
|
42
|
+
recipe = qp.Recipe(
|
|
43
|
+
qp.repeat(10, qp.RAAR(50, beta=0.9), qp.Shrinkwrap()),
|
|
44
|
+
qp.ER(200),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
result = problem.reconstruct(recipe)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The first positional argument of an algorithm is always its iteration count.
|
|
51
|
+
|
|
52
|
+
`qp.detwin(...)` runs its body against a support cut in half at the object's
|
|
53
|
+
centre of mass, then restores the full support. That cut is not centrosymmetric,
|
|
54
|
+
so it fits only one of the two conjugate solutions and biases the run into a
|
|
55
|
+
branch. The default recipe places it a quarter of the way through the RAAR
|
|
56
|
+
cycles, where PyNX puts it. It is measurably useful when the support is
|
|
57
|
+
symmetric and the run would otherwise stall, and harmless when it is not — but
|
|
58
|
+
a support with the right shape is worth far more than any detwinning.
|
|
59
|
+
|
|
60
|
+
`qp.Shrinkwrap(threshold, sigma, method)` takes `method="max"` (classic, relative
|
|
61
|
+
to the peak amplitude) or `method="rms"` (relative to the rms inside the current
|
|
62
|
+
support, less easily thrown off by a hot pixel). The thresholds are on different
|
|
63
|
+
scales: the classic `"max"` value is 0.2, PyNX uses 0.28 for `"rms"`.
|
|
64
|
+
|
|
65
|
+
The defaults are `0.3, 1.0` rather than the classic `0.2, 2.0`. Starting from an
|
|
66
|
+
autocorrelation support — the case that actually needs shrinkwrap — 0.2 / 2.0
|
|
67
|
+
never converged in testing and 0.3 / 1.0 did, on every object tried. That is a
|
|
68
|
+
measurement on synthetic data, not a law. **These two numbers are the first thing
|
|
69
|
+
to re-tune on a real dataset.**
|
|
70
|
+
|
|
71
|
+
## Multi-start
|
|
72
|
+
|
|
73
|
+
Multi-start is a core feature, not an add-on — for a support with awkward
|
|
74
|
+
symmetry it is the difference between converging and not.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
ensemble = problem.reconstruct(recipe, starts=32, batch=4)
|
|
78
|
+
best = ensemble.best
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`vmap` holds every start in memory at once. `batch` chunks that: lower it if a
|
|
82
|
+
large 3D volume runs out of GPU memory.
|
|
83
|
+
|
|
84
|
+
## Swapping the forward model
|
|
85
|
+
|
|
86
|
+
Algorithms never name an FFT, so the physics is replaceable without touching
|
|
87
|
+
them:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
qp.CDI(intensity, support=support, model=MyPropagator())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A model needs `forward` / `adjoint` and should be unitary. A *wrong* model fails
|
|
94
|
+
silently — it can fit the data perfectly and return the wrong object — so this
|
|
95
|
+
is worth getting right. See `tests/test_forward_model.py`.
|
|
96
|
+
|
|
97
|
+
## Before you trust a reconstruction
|
|
98
|
+
|
|
99
|
+
Check the measurement against the assumptions the library makes about it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
print(qp.diagnose(intensity, mask=mask, support=support))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It reports the centring offset two ways (brightest pixel, and Friedel symmetry,
|
|
106
|
+
which resolves half a pixel and works on patterns with no dominant peak), how
|
|
107
|
+
much of the array the autocorrelation fills, the negative and masked fractions,
|
|
108
|
+
and warns about each. An off-centre pattern is the failure that costs the most
|
|
109
|
+
and shows the least.
|
|
110
|
+
|
|
111
|
+
`examples/diagnose.py` does the same from the command line, then reconstructs
|
|
112
|
+
and reports whether independent starts agree:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
python examples/diagnose.py scan.npz --key intensity --support sup.npy --starts 8
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A low Fourier error says a run fits the data. Agreement between starts, after
|
|
119
|
+
alignment, is what says the answer is determined — they are different claims and
|
|
120
|
+
only the second one is worth much.
|
|
121
|
+
|
|
122
|
+
## Combining runs
|
|
123
|
+
|
|
124
|
+
A reconstruction is determined only up to a global phase, a translation, and a
|
|
125
|
+
conjugate flip. No iteration can remove those — they are exact symmetries of the
|
|
126
|
+
constraints — so they are resolved afterwards:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
aligned = ensemble.align() # every run brought onto the best one
|
|
130
|
+
mean = ensemble.average() # coherent mean of the aligned runs
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Never average runs without aligning them.** Each carries its own global phase,
|
|
135
|
+
so a raw mean interferes destructively — in practice it throws away about two
|
|
136
|
+
thirds of the amplitude. The direction survives, so a similarity score will not
|
|
137
|
+
warn you; only the amplitude shows it.
|
|
138
|
+
|
|
139
|
+
`align()` picks a *convention*, not the truth. It adopts whichever conjugate
|
|
140
|
+
branch the best run happens to be in — enough to combine runs, and no claim
|
|
141
|
+
about which branch is real. Nothing in the data distinguishes them: `conj(obj(-r))`
|
|
142
|
+
reproduces the measured intensity exactly. What breaks the tie is the support
|
|
143
|
+
you supply. Hand the solver a reflected support and every start converges just
|
|
144
|
+
as cleanly on the mirrored object, with a flawless error metric and no warning.
|
|
145
|
+
The handedness of your support is an assumption, not a result.
|
|
146
|
+
|
|
147
|
+
Alignment works to whole pixels.
|
|
148
|
+
|
|
149
|
+
`result.phase` still carries whatever linear phase the measurement geometry put
|
|
150
|
+
there, and QoraPhase does not remove it. Doing that properly needs the
|
|
151
|
+
diffraction geometry, which arrives with BCDI in v0.3; short of that, any
|
|
152
|
+
"correction" is a guess.
|
|
153
|
+
|
|
154
|
+
If the twin branch matters to you, break the symmetry during the run rather than
|
|
155
|
+
after it — see `qp.detwin` below.
|
|
156
|
+
|
|
157
|
+
## What this does not do
|
|
158
|
+
|
|
159
|
+
- No phase-ramp removal, no sub-pixel alignment, no clustering, no PRTF.
|
|
160
|
+
- No positivity/reality constraint — the object is complex here.
|
|
161
|
+
- No file I/O, no BCDI geometry, no NUFFT, no strain, no autodiff optimisers.
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
uv sync --all-groups
|
|
167
|
+
uv run pytest
|
|
168
|
+
```
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Run QoraPhase against a real measurement and report whether to believe it.
|
|
2
|
+
|
|
3
|
+
python examples/diagnose.py scan.npz --key intensity --mask-key mask
|
|
4
|
+
|
|
5
|
+
The library never touches files, so the loading lives here. What the
|
|
6
|
+
script adds on top of ``qp.diagnose`` is the check that synthetic data can never
|
|
7
|
+
provide: whether independent random starts, once aligned, actually agree with
|
|
8
|
+
each other. A low Fourier error only says a run fits the data; agreement across
|
|
9
|
+
starts is what says the answer is determined.
|
|
10
|
+
|
|
11
|
+
--no-run stop after the pre-flight report
|
|
12
|
+
--plot out.png save amplitude/phase panels and the convergence curves
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
import qoraphase as qp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(path: Path, key: str | None):
|
|
25
|
+
"""Read one array from .npy, .npz, .mat-style HDF5 or .h5/.cxi."""
|
|
26
|
+
suffix = path.suffix.lower()
|
|
27
|
+
if suffix == ".npy":
|
|
28
|
+
return np.load(path)
|
|
29
|
+
if suffix == ".npz":
|
|
30
|
+
with np.load(path) as archive:
|
|
31
|
+
names = list(archive.files)
|
|
32
|
+
if key is None:
|
|
33
|
+
if len(names) != 1:
|
|
34
|
+
raise SystemExit(f"{path} holds {names}; pick one with --key")
|
|
35
|
+
key = names[0]
|
|
36
|
+
return archive[key]
|
|
37
|
+
if suffix in (".h5", ".hdf5", ".cxi", ".nxs"):
|
|
38
|
+
try:
|
|
39
|
+
import h5py
|
|
40
|
+
except ImportError:
|
|
41
|
+
raise SystemExit("reading HDF5 needs h5py: uv pip install h5py") from None
|
|
42
|
+
if key is None:
|
|
43
|
+
raise SystemExit(f"--key is required for {suffix} files")
|
|
44
|
+
with h5py.File(path, "r") as handle:
|
|
45
|
+
return handle[key][()]
|
|
46
|
+
raise SystemExit(f"unsupported file type: {suffix}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def agreement(ensemble):
|
|
50
|
+
"""Pairwise similarity between runs, after resolving the ambiguities.
|
|
51
|
+
|
|
52
|
+
Each run is aligned onto the first, then compared by normalised overlap.
|
|
53
|
+
Runs that solved the same problem land on the same answer; runs that found
|
|
54
|
+
different local minima do not, however good their individual error looks.
|
|
55
|
+
"""
|
|
56
|
+
reference = np.asarray(ensemble[0].object)
|
|
57
|
+
aligned = [np.asarray(qp.align_to(run.object, ensemble[0].object)) for run in ensemble]
|
|
58
|
+
scores = []
|
|
59
|
+
for i, a in enumerate(aligned):
|
|
60
|
+
for b in aligned[i + 1 :]:
|
|
61
|
+
overlap = abs(np.vdot(a, b)) / (np.linalg.norm(a) * np.linalg.norm(b))
|
|
62
|
+
scores.append(float(overlap))
|
|
63
|
+
return np.array(scores) if scores else np.array([1.0])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def plot(result, ensemble, path):
|
|
67
|
+
import matplotlib
|
|
68
|
+
|
|
69
|
+
matplotlib.use("Agg")
|
|
70
|
+
import matplotlib.pyplot as plt
|
|
71
|
+
|
|
72
|
+
figure = plt.figure(figsize=(14, 4))
|
|
73
|
+
panels = figure.subplots(1, 3)
|
|
74
|
+
result.plot(axes=panels[:2] if result.object.ndim == 2 else None)
|
|
75
|
+
for run in ensemble:
|
|
76
|
+
panels[2].semilogy(np.asarray(run.metrics), lw=0.8)
|
|
77
|
+
panels[2].set_title("Fourier error")
|
|
78
|
+
panels[2].set_xlabel("iteration")
|
|
79
|
+
figure.tight_layout()
|
|
80
|
+
figure.savefig(path, dpi=120)
|
|
81
|
+
print(f"\nwrote {path}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv=None):
|
|
85
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
86
|
+
parser.add_argument("path", type=Path)
|
|
87
|
+
parser.add_argument("--key", help="dataset name inside .npz / .h5")
|
|
88
|
+
parser.add_argument("--mask", type=Path, help="mask file; True marks bad pixels")
|
|
89
|
+
parser.add_argument("--mask-key")
|
|
90
|
+
parser.add_argument("--support", type=Path)
|
|
91
|
+
parser.add_argument("--support-key")
|
|
92
|
+
parser.add_argument("--starts", type=int, default=8)
|
|
93
|
+
parser.add_argument("--batch", type=int)
|
|
94
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
95
|
+
parser.add_argument("--no-run", action="store_true")
|
|
96
|
+
parser.add_argument("--plot", type=Path)
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
|
|
99
|
+
intensity = load(args.path, args.key)
|
|
100
|
+
mask = None if args.mask is None else load(args.mask, args.mask_key).astype(bool)
|
|
101
|
+
support = (
|
|
102
|
+
None if args.support is None else load(args.support, args.support_key).astype(bool)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
report = qp.diagnose(intensity, mask=mask, support=support)
|
|
106
|
+
print(report)
|
|
107
|
+
if args.no_run:
|
|
108
|
+
return 0
|
|
109
|
+
if report.warnings:
|
|
110
|
+
print("\ncontinuing anyway -- read the warnings above before trusting this")
|
|
111
|
+
|
|
112
|
+
print(f"\nreconstructing, {args.starts} starts ...")
|
|
113
|
+
ensemble = qp.reconstruct(
|
|
114
|
+
intensity,
|
|
115
|
+
support=support,
|
|
116
|
+
mask=mask,
|
|
117
|
+
starts=args.starts,
|
|
118
|
+
batch=args.batch,
|
|
119
|
+
seed=args.seed,
|
|
120
|
+
)
|
|
121
|
+
if args.starts == 1:
|
|
122
|
+
ensemble = qp.Ensemble((ensemble,))
|
|
123
|
+
|
|
124
|
+
errors = np.array([run.error for run in ensemble])
|
|
125
|
+
scores = agreement(ensemble)
|
|
126
|
+
print(f"final error best {errors.min():.3e} worst {errors.max():.3e}")
|
|
127
|
+
print(f"run agreement min {scores.min():.3f} median {np.median(scores):.3f}")
|
|
128
|
+
if scores.min() < 0.9:
|
|
129
|
+
print(
|
|
130
|
+
"!! the starts did not converge on the same object. A low error on a "
|
|
131
|
+
"single run means it fits the data, not that the answer is determined."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if args.plot:
|
|
135
|
+
plot(ensemble.best, ensemble, args.plot)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qoraphase"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A JAX-native, composable phase-retrieval framework for coherent diffraction imaging"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE*"]
|
|
8
|
+
authors = [{ name = "Isaac Yong", email = "isaacyong@naver.com" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["phase retrieval", "coherent diffraction imaging", "CDI", "jax", "x-ray imaging"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"jax>=0.11.1",
|
|
23
|
+
"matplotlib>=3.11.1",
|
|
24
|
+
"numpy>=2.5.2",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/NaNIsaac/QoraPhase"
|
|
29
|
+
Repository = "https://github.com/NaNIsaac/QoraPhase"
|
|
30
|
+
Issues = "https://github.com/NaNIsaac/QoraPhase/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling>=1.27"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/qoraphase"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/qoraphase", "tests", "examples", "README.md", "LICENSE", "CITATION.cff"]
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = ["pytest>=8"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
pythonpath = ["src"]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""QoraPhase -- a JAX-native, composable phase-retrieval framework for CDI.
|
|
2
|
+
|
|
3
|
+
Array in, reconstruction out::
|
|
4
|
+
|
|
5
|
+
import qoraphase as qp
|
|
6
|
+
result = qp.reconstruct(intensity, support=support)
|
|
7
|
+
|
|
8
|
+
All public arrays are center-origin, ``mask=True`` means an unusable pixel, and
|
|
9
|
+
``intensity`` is a measured intensity (not an amplitude). See spec section 4.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .algorithms.er import ER
|
|
13
|
+
from .algorithms.hio import HIO
|
|
14
|
+
from .algorithms.raar import RAAR
|
|
15
|
+
from .api import default_recipe, reconstruct
|
|
16
|
+
from .core.ensemble import Ensemble
|
|
17
|
+
from .core.problem import CDI
|
|
18
|
+
from .core.result import Reconstruction
|
|
19
|
+
from .core.state import ReconState
|
|
20
|
+
from .diagnostics import Diagnosis, diagnose
|
|
21
|
+
from .models.fft import FFT
|
|
22
|
+
from .operators.modulus import ModulusProjection
|
|
23
|
+
from .operators.shrinkwrap import Shrinkwrap
|
|
24
|
+
from .operators.support import SupportProjection
|
|
25
|
+
from .postprocess import align_to
|
|
26
|
+
from .recipes.recipe import Recipe, detwin, repeat
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CDI",
|
|
30
|
+
"Diagnosis",
|
|
31
|
+
"ER",
|
|
32
|
+
"FFT",
|
|
33
|
+
"HIO",
|
|
34
|
+
"RAAR",
|
|
35
|
+
"Ensemble",
|
|
36
|
+
"ModulusProjection",
|
|
37
|
+
"ReconState",
|
|
38
|
+
"Reconstruction",
|
|
39
|
+
"Recipe",
|
|
40
|
+
"Shrinkwrap",
|
|
41
|
+
"SupportProjection",
|
|
42
|
+
"align_to",
|
|
43
|
+
"default_recipe",
|
|
44
|
+
"detwin",
|
|
45
|
+
"diagnose",
|
|
46
|
+
"reconstruct",
|
|
47
|
+
"repeat",
|
|
48
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Error Reduction."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import jax
|
|
6
|
+
|
|
7
|
+
from ..operators.modulus import modulus_project
|
|
8
|
+
from ..operators.support import support_project
|
|
9
|
+
from ..recipes.recipe import Iterative
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ER(Iterative):
|
|
14
|
+
"""``x <- P_S P_M x``. Monotone in the Fourier error, and slow."""
|
|
15
|
+
|
|
16
|
+
def step(self, state, problem):
|
|
17
|
+
projected, err = modulus_project(state.obj, problem)
|
|
18
|
+
obj = support_project(projected, state.support)
|
|
19
|
+
return state._replace(obj=obj, iteration=state.iteration + 1), err
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
jax.tree_util.register_dataclass(ER, data_fields=[], meta_fields=["n"])
|