midas-distortion 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.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: midas-distortion
3
+ Version: 0.2.0
4
+ Summary: Canonical MIDAS radial-distortion model: layout tables, v1<->v2 coefficient mapping, and a backend-agnostic (numpy/torch) distortion kernel.
5
+ Author-email: Hemant Sharma <hsharma@anl.gov>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/marinerhemant/MIDAS
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering :: Physics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: numpy>=1.22
16
+ Provides-Extra: torch
17
+ Requires-Dist: torch>=2.0; extra == "torch"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Requires-Dist: torch>=2.0; extra == "dev"
21
+
22
+ # midas-distortion
23
+
24
+ Canonical MIDAS radial-distortion model — the single source of truth for the
25
+ detector distortion layout and the v1↔v2 coefficient mapping, shared by:
26
+
27
+ - **midas-calibrate-v2** — fits the distortion (v2 canonical names).
28
+ - **midas-peakfit** — applies it to spot geometry (numpy).
29
+ - **midas-transforms** — applies it to spot geometry (torch).
30
+
31
+ ## Model
32
+
33
+ Multiplicative factor on the projected radius `R` (with `ρ = R / RhoD`,
34
+ `η' = 90° − η`):
35
+
36
+ ```
37
+ D(ρ, η) = 1
38
+ + iso_R2·ρ² + iso_R4·ρ⁴ + iso_R6·ρ⁶ (isotropic)
39
+ + a1·ρ⁴·cos( η' + phi1) (1-fold; ρ⁴ is a v1 quirk)
40
+ + a2·ρ²·cos(2η' + phi2)
41
+ + a3·ρ³·cos(3η' + phi3)
42
+ + a4·ρ⁴·cos(4η' + phi4)
43
+ + a5·ρ⁵·cos(5η' + phi5)
44
+ + a6·ρ⁶·cos(6η' + phi6)
45
+ ```
46
+
47
+ The legacy v1 ordering (`p0..p14`, phases scattered) is `v1_term_layout()`;
48
+ v1↔v2 reindexing is exact (`v1_to_v2_coeffs` / `v2_to_v1_coeffs`).
49
+
50
+ ## Backend-agnostic kernel
51
+
52
+ `distortion_factor(R_norm, eta_deg, p_coeffs, terms=...)` dispatches `cos` /
53
+ `ones_like` on the input's own array library, so numpy and torch consumers
54
+ evaluate bit-for-bit the same model (up to floating-point reassociation).
55
+
56
+ ```python
57
+ import numpy as np
58
+ from midas_distortion import distortion_factor, v1_term_layout, v1_to_v2_coeffs
59
+
60
+ p_v1 = np.zeros(15) # legacy paramstest p0..p14
61
+ D = distortion_factor(R/RhoD, eta_deg, p_v1, terms=v1_term_layout())
62
+ # …or convert once and use the v2 layout (default):
63
+ D = distortion_factor(R/RhoD, eta_deg, v1_to_v2_coeffs(p_v1))
64
+ ```
@@ -0,0 +1,43 @@
1
+ # midas-distortion
2
+
3
+ Canonical MIDAS radial-distortion model — the single source of truth for the
4
+ detector distortion layout and the v1↔v2 coefficient mapping, shared by:
5
+
6
+ - **midas-calibrate-v2** — fits the distortion (v2 canonical names).
7
+ - **midas-peakfit** — applies it to spot geometry (numpy).
8
+ - **midas-transforms** — applies it to spot geometry (torch).
9
+
10
+ ## Model
11
+
12
+ Multiplicative factor on the projected radius `R` (with `ρ = R / RhoD`,
13
+ `η' = 90° − η`):
14
+
15
+ ```
16
+ D(ρ, η) = 1
17
+ + iso_R2·ρ² + iso_R4·ρ⁴ + iso_R6·ρ⁶ (isotropic)
18
+ + a1·ρ⁴·cos( η' + phi1) (1-fold; ρ⁴ is a v1 quirk)
19
+ + a2·ρ²·cos(2η' + phi2)
20
+ + a3·ρ³·cos(3η' + phi3)
21
+ + a4·ρ⁴·cos(4η' + phi4)
22
+ + a5·ρ⁵·cos(5η' + phi5)
23
+ + a6·ρ⁶·cos(6η' + phi6)
24
+ ```
25
+
26
+ The legacy v1 ordering (`p0..p14`, phases scattered) is `v1_term_layout()`;
27
+ v1↔v2 reindexing is exact (`v1_to_v2_coeffs` / `v2_to_v1_coeffs`).
28
+
29
+ ## Backend-agnostic kernel
30
+
31
+ `distortion_factor(R_norm, eta_deg, p_coeffs, terms=...)` dispatches `cos` /
32
+ `ones_like` on the input's own array library, so numpy and torch consumers
33
+ evaluate bit-for-bit the same model (up to floating-point reassociation).
34
+
35
+ ```python
36
+ import numpy as np
37
+ from midas_distortion import distortion_factor, v1_term_layout, v1_to_v2_coeffs
38
+
39
+ p_v1 = np.zeros(15) # legacy paramstest p0..p14
40
+ D = distortion_factor(R/RhoD, eta_deg, p_v1, terms=v1_term_layout())
41
+ # …or convert once and use the v2 layout (default):
42
+ D = distortion_factor(R/RhoD, eta_deg, v1_to_v2_coeffs(p_v1))
43
+ ```
@@ -0,0 +1,57 @@
1
+ """midas_distortion — canonical MIDAS radial-distortion model (single source).
2
+
3
+ Shared by midas_calibrate_v2 (calibration), midas_peakfit (numpy spot geometry)
4
+ and midas_transforms (torch spot geometry) so all three evaluate one definition
5
+ of the distortion layout + the v1↔v2 coefficient mapping.
6
+ """
7
+ from .core import (
8
+ HarmonicTerm,
9
+ P_COEF_NAMES,
10
+ PHASE_NAMES,
11
+ ISO_NAMES,
12
+ AMP_NAMES,
13
+ v1_term_layout,
14
+ v2_term_layout,
15
+ extended_term_layout,
16
+ extended_p_coef_names,
17
+ V1_TO_V2_DISTORTION,
18
+ V2_TO_V1_DISTORTION,
19
+ V2_TO_V1_PNAME,
20
+ v1_to_v2_coeffs,
21
+ v2_to_v1_coeffs,
22
+ v2_coeffs_from_named,
23
+ distortion_factor,
24
+ apply_distortion,
25
+ )
26
+ from .rhod import (
27
+ detector_max_corner_dist_um,
28
+ check_rho_d_um,
29
+ resolve_rho_d_um,
30
+ resolve_rho_d_um_warn,
31
+ )
32
+
33
+ __version__ = "0.2.0"
34
+
35
+ __all__ = [
36
+ "HarmonicTerm",
37
+ "P_COEF_NAMES",
38
+ "PHASE_NAMES",
39
+ "ISO_NAMES",
40
+ "AMP_NAMES",
41
+ "v1_term_layout",
42
+ "v2_term_layout",
43
+ "extended_term_layout",
44
+ "extended_p_coef_names",
45
+ "V1_TO_V2_DISTORTION",
46
+ "V2_TO_V1_DISTORTION",
47
+ "V2_TO_V1_PNAME",
48
+ "v1_to_v2_coeffs",
49
+ "v2_to_v1_coeffs",
50
+ "v2_coeffs_from_named",
51
+ "distortion_factor",
52
+ "apply_distortion",
53
+ "detector_max_corner_dist_um",
54
+ "check_rho_d_um",
55
+ "resolve_rho_d_um",
56
+ "resolve_rho_d_um_warn",
57
+ ]
@@ -0,0 +1,235 @@
1
+ """Canonical MIDAS radial-distortion model — the single source of truth.
2
+
3
+ This leaf holds the distortion *layout* (which coefficient drives which η-fold
4
+ at which radial power) and the v1↔v2 coefficient mapping. Bugs in a distortion
5
+ model live in the layout/mapping — a wrong index, swapped phase, wrong fold —
6
+ not in the trivial arithmetic. Centralising the layout here lets
7
+ ``midas_calibrate_v2`` (calibration), ``midas_peakfit`` (numpy) and
8
+ ``midas_transforms`` (torch) all evaluate the *same* model from one definition.
9
+
10
+ Model (multiplicative factor on the projected radius)::
11
+
12
+ D(ρ, η) = 1
13
+ + iso_R2·ρ² + iso_R4·ρ⁴ + iso_R6·ρ⁶ (isotropic)
14
+ + a1·ρ⁴·cos( η' + phi1) (1-fold; ρ⁴ is a
15
+ + a2·ρ²·cos(2η' + phi2) v1-physics quirk)
16
+ + a3·ρ³·cos(3η' + phi3)
17
+ + a4·ρ⁴·cos(4η' + phi4)
18
+ + a5·ρ⁵·cos(5η' + phi5)
19
+ + a6·ρ⁶·cos(6η' + phi6)
20
+
21
+ with η' = 90° − η (degrees in, converted internally). ``distortion_factor`` is
22
+ backend-agnostic: pass numpy arrays or torch tensors and it dispatches cos /
23
+ ones_like / zeros_like on the input's own library, so the math is identical
24
+ across consumers (down to floating-point reassociation).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from typing import Dict, Iterable, List
30
+
31
+ _DEG2RAD = 0.017453292519943295
32
+
33
+
34
+ # ───────────────────────────────────────────────────────────── canonical names
35
+ # 15 names, in the SAME order as the v2 p_coeffs vector slots.
36
+ P_COEF_NAMES: List[str] = [
37
+ "iso_R2", "iso_R4", "iso_R6", # 0,1,2 isotropic radial (no η)
38
+ "a1", "phi1", # 3,4 1-fold (ρ⁴)
39
+ "a2", "phi2", # 5,6 2-fold (ρ²)
40
+ "a3", "phi3", # 7,8 3-fold (ρ³)
41
+ "a4", "phi4", # 9,10 4-fold (ρ⁴)
42
+ "a5", "phi5", # 11,12 5-fold (ρ⁵)
43
+ "a6", "phi6", # 13,14 6-fold (ρ⁶)
44
+ ]
45
+ PHASE_NAMES = ("phi1", "phi2", "phi3", "phi4", "phi5", "phi6")
46
+ ISO_NAMES = ("iso_R2", "iso_R4", "iso_R6")
47
+ AMP_NAMES = ("a1", "a2", "a3", "a4", "a5", "a6")
48
+ _NAME_TO_V2IDX = {nm: i for i, nm in enumerate(P_COEF_NAMES)}
49
+
50
+
51
+ # ─────────────────────────────────────────────────────────────── harmonic term
52
+ @dataclass
53
+ class HarmonicTerm:
54
+ """One additive contribution to the multiplicative distortion D.
55
+
56
+ Isotropic radial term ``c·ρⁿ`` → ``fold=0, phase_idx=-1``; polar harmonic
57
+ ``c·ρⁿ·cos(k η' + φ)`` → ``fold=k``.
58
+ """
59
+
60
+ coef_idx: int # index into p_coeffs for the amplitude
61
+ phase_idx: int # index into p_coeffs for the phase (-1 if isotropic)
62
+ radial_power: int # n in ρⁿ
63
+ fold: int # k in cos(k η' + φ); 0 = isotropic
64
+
65
+
66
+ def v2_term_layout() -> List[HarmonicTerm]:
67
+ """v2 canonical ordering — fold-monotonic, isotropic terms grouped first."""
68
+ return [
69
+ HarmonicTerm(0, -1, 2, 0), # iso_R2
70
+ HarmonicTerm(1, -1, 4, 0), # iso_R4
71
+ HarmonicTerm(2, -1, 6, 0), # iso_R6
72
+ HarmonicTerm(3, 4, 4, 1), # a1, phi1 (1-fold uses ρ⁴)
73
+ HarmonicTerm(5, 6, 2, 2), # a2, phi2
74
+ HarmonicTerm(7, 8, 3, 3), # a3, phi3
75
+ HarmonicTerm(9, 10, 4, 4), # a4, phi4
76
+ HarmonicTerm(11, 12, 5, 5), # a5, phi5
77
+ HarmonicTerm(13, 14, 6, 6), # a6, phi6
78
+ ]
79
+
80
+
81
+ def v1_term_layout() -> List[HarmonicTerm]:
82
+ """Legacy v1 ordering on the v1 p₀..p₁₄ vector (kept for paramstest parity).
83
+
84
+ p₀ amp ρ²fold2(φ=p₆) | p₁ amp ρ⁴fold4(φ=p₃) | p₂ iso ρ² | p₄ iso ρ⁶ |
85
+ p₅ iso ρ⁴ | p₇ amp ρ⁴fold1(φ=p₈) | p₉ amp ρ³fold3(φ=p₁₀) |
86
+ p₁₁ amp ρ⁵fold5(φ=p₁₂) | p₁₃ amp ρ⁶fold6(φ=p₁₄).
87
+ """
88
+ return [
89
+ HarmonicTerm(0, 6, 2, 2),
90
+ HarmonicTerm(1, 3, 4, 4),
91
+ HarmonicTerm(2, -1, 2, 0),
92
+ HarmonicTerm(4, -1, 6, 0),
93
+ HarmonicTerm(5, -1, 4, 0),
94
+ HarmonicTerm(7, 8, 4, 1),
95
+ HarmonicTerm(9, 10, 3, 3),
96
+ HarmonicTerm(11, 12, 5, 5),
97
+ HarmonicTerm(13, 14, 6, 6),
98
+ ]
99
+
100
+
101
+ def extended_term_layout(max_fold: int = 8) -> List[HarmonicTerm]:
102
+ """v2 base layout plus (a_k, phi_k) for k = 7..max_fold, each at radial ρᵏ.
103
+
104
+ Caller must size the p_coeffs vector to ``15 + 2·(max_fold − 6)``.
105
+ """
106
+ if max_fold <= 6:
107
+ return v2_term_layout()
108
+ base = v2_term_layout()
109
+ nxt = 15
110
+ for k in range(7, max_fold + 1):
111
+ base.append(HarmonicTerm(nxt, nxt + 1, k, k))
112
+ nxt += 2
113
+ return base
114
+
115
+
116
+ def extended_p_coef_names(max_fold: int = 8) -> List[str]:
117
+ if max_fold <= 6:
118
+ return list(P_COEF_NAMES)
119
+ out = list(P_COEF_NAMES)
120
+ for k in range(7, max_fold + 1):
121
+ out.extend([f"a{k}", f"phi{k}"])
122
+ return out
123
+
124
+
125
+ # ───────────────────────────────────────────────────────────────── v1 ↔ v2 map
126
+ # v1 p-index → v2 canonical name.
127
+ V1_TO_V2_DISTORTION: Dict[int, str] = {
128
+ 0: "a2", 1: "a4", 2: "iso_R2", 3: "phi4", 4: "iso_R6", 5: "iso_R4",
129
+ 6: "phi2", 7: "a1", 8: "phi1", 9: "a3", 10: "phi3", 11: "a5",
130
+ 12: "phi5", 13: "a6", 14: "phi6",
131
+ }
132
+ # v2 canonical name → v1 p-index.
133
+ V2_TO_V1_DISTORTION: Dict[str, int] = {v: k for k, v in V1_TO_V2_DISTORTION.items()}
134
+ # v2 canonical name → v1 p-name ("p0".."p14"), for paramstest field setting.
135
+ V2_TO_V1_PNAME: Dict[str, str] = {v: f"p{k}" for k, v in V1_TO_V2_DISTORTION.items()}
136
+
137
+
138
+ # Fixed gather permutations (pure indexing → differentiable + device-portable,
139
+ # unlike an in-place assignment loop). ``_PERM_V1_TO_V2[slot]`` is the v1 index
140
+ # feeding v2 slot ``slot``; ``_PERM_V2_TO_V1[v1_idx]`` is the v2 slot feeding it.
141
+ _PERM_V1_TO_V2 = [V2_TO_V1_DISTORTION[name] for name in P_COEF_NAMES]
142
+ _PERM_V2_TO_V1 = [_NAME_TO_V2IDX[V1_TO_V2_DISTORTION[i]] for i in range(15)]
143
+
144
+
145
+ def v1_to_v2_coeffs(p_v1):
146
+ """Reindex a v1 p₀..p₁₄ coefficient vector into v2 canonical order.
147
+
148
+ A pure gather (``p_v1[perm]``) — works for numpy arrays and torch tensors,
149
+ returns the same type, and stays differentiable for autograd.
150
+ """
151
+ return p_v1[_PERM_V1_TO_V2]
152
+
153
+
154
+ def v2_to_v1_coeffs(p_v2):
155
+ """Reindex a v2 canonical coefficient vector into v1 p₀..p₁₄ order."""
156
+ return p_v2[_PERM_V2_TO_V1]
157
+
158
+
159
+ def v2_coeffs_from_named(named, *, default: float = 0.0):
160
+ """Build the canonical 15-vector (v2 :data:`P_COEF_NAMES` order) from a
161
+ mapping that uses **v2 names** (``iso_R2``, ``a1``, ``phi1``, …) — with
162
+ legacy ``p0``..``p14`` accepted only as a fallback for old inputs.
163
+
164
+ v2 names win over any same-slot ``pN`` (so a v2-named source is honoured
165
+ exactly; a pure-v1 source still works). Returns a numpy float64[15].
166
+ Missing entries default to ``default`` (0.0). ``None`` values are skipped.
167
+ """
168
+ import numpy as np
169
+ out = np.full(15, float(default), dtype=np.float64)
170
+ # legacy p-names first, so v2 names override on any collision
171
+ for k, v in named.items():
172
+ if v is None:
173
+ continue
174
+ if isinstance(k, str) and len(k) > 1 and k[0] == "p" and k[1:].isdigit():
175
+ i = int(k[1:])
176
+ if 0 <= i < 15:
177
+ out[_NAME_TO_V2IDX[V1_TO_V2_DISTORTION[i]]] = float(v)
178
+ for k, v in named.items():
179
+ if v is None:
180
+ continue
181
+ if k in _NAME_TO_V2IDX:
182
+ out[_NAME_TO_V2IDX[k]] = float(v)
183
+ return out
184
+
185
+
186
+ # ─────────────────────────────────────────────────────────────────── evaluation
187
+ def _is_torch(x) -> bool:
188
+ return type(x).__module__.split(".")[0] == "torch"
189
+
190
+
191
+ def _backend(x):
192
+ """Return (cos, ones_like, zeros_like) bound to x's array library."""
193
+ if _is_torch(x):
194
+ import torch
195
+ return torch.cos, torch.ones_like, torch.zeros_like
196
+ import numpy as np
197
+ return np.cos, np.ones_like, np.zeros_like
198
+
199
+
200
+ def _zeros_like(x):
201
+ return _backend(x)[2](x)
202
+
203
+
204
+ def distortion_factor(R_norm, eta_deg, p_coeffs, *, terms: Iterable[HarmonicTerm] = None):
205
+ """Multiplicative distortion factor D(ρ, η).
206
+
207
+ Parameters
208
+ ----------
209
+ R_norm : ρ = R / RhoD, numpy array or torch tensor (broadcastable).
210
+ eta_deg : azimuthal angle in degrees (atan2(-y, z) convention).
211
+ p_coeffs : coefficient vector; indexing is defined by ``terms``
212
+ (v2 order by default, v1 order if ``terms=v1_term_layout()``).
213
+ terms : layout; defaults to :func:`v2_term_layout`.
214
+ """
215
+ if terms is None:
216
+ terms = v2_term_layout()
217
+ cos, ones_like, _ = _backend(R_norm)
218
+ eta_T = (90.0 - eta_deg) * _DEG2RAD
219
+ D = ones_like(R_norm)
220
+ for t in terms:
221
+ amp = p_coeffs[t.coef_idx]
222
+ rad = R_norm ** t.radial_power
223
+ if t.fold == 0:
224
+ D = D + amp * rad
225
+ else:
226
+ phase = p_coeffs[t.phase_idx] * _DEG2RAD
227
+ D = D + amp * rad * cos(t.fold * eta_T + phase)
228
+ return D
229
+
230
+
231
+ def apply_distortion(R, eta_deg, p_coeffs, rho_d, *, terms: Iterable[HarmonicTerm] = None):
232
+ """Multiplicatively apply the distortion to a projected radius ``R`` (same
233
+ units as ``rho_d``)."""
234
+ R_norm = R / rho_d
235
+ return R * distortion_factor(R_norm, eta_deg, p_coeffs, terms=terms)
@@ -0,0 +1,175 @@
1
+ """RhoD unit self-consistency — the single source of truth for all MIDAS packages.
2
+
3
+ ``RhoD`` (the radial-distortion normalisation radius) appears only inside
4
+ ``ρ = R / RhoD`` (see :func:`midas_distortion.core.apply_distortion`). It MUST
5
+ therefore be in the **same units as R**, i.e. **micrometres**. Different MIDAS
6
+ workflows have historically stored it in µm *or* in pixels, and a wrong-unit
7
+ value silently corrupts the distortion stage:
8
+
9
+ * RhoD too small (pixels passed as µm) → ρ ≈ pixel-pitch× too large → the
10
+ distortion polynomial explodes → pixels scatter into the wrong radial bins →
11
+ powder rings wash out (the failure mode that cost real debugging time).
12
+ * RhoD too large → ρ ≈ 0 → distortion silently nulled.
13
+
14
+ These helpers remove the ambiguity once and for all. ``resolve_rho_d_um``
15
+ auto-detects the units and returns micrometres; ``check_rho_d_um`` validates a
16
+ value that is *supposed* to already be in µm. Both take detector context
17
+ (pixel count, beam centre, pixel pitch) because the sane scale for RhoD is the
18
+ beam-centre-to-farthest-corner distance in µm.
19
+
20
+ This module is intentionally dependency-free (pure ``math``) so every package
21
+ — ``midas_calibrate_v2``, ``midas_integrate_v2``, ``midas_integrate`` — can
22
+ call exactly the same logic.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import warnings
28
+ from typing import Optional
29
+
30
+
31
+ def detector_max_corner_dist_um(
32
+ NrPixelsY: int, NrPixelsZ: int,
33
+ BC_y: float, BC_z: float,
34
+ pxY: float, pxZ: Optional[float] = None,
35
+ ) -> float:
36
+ """Max distance from the beam centre to any detector corner, in µm —
37
+ the natural upper-bound scale for ``RhoD``.
38
+ """
39
+ if pxZ is None:
40
+ pxZ = pxY
41
+ if NrPixelsY <= 0 or NrPixelsZ <= 0:
42
+ raise ValueError(
43
+ f"NrPixelsY/NrPixelsZ must be positive; got {NrPixelsY}x{NrPixelsZ}"
44
+ )
45
+ corners_px = [
46
+ (0.0, 0.0),
47
+ (NrPixelsY - 1.0, 0.0),
48
+ (0.0, NrPixelsZ - 1.0),
49
+ (NrPixelsY - 1.0, NrPixelsZ - 1.0),
50
+ ]
51
+ return max(
52
+ math.sqrt(((y - BC_y) * pxY) ** 2 + ((z - BC_z) * pxZ) ** 2)
53
+ for (y, z) in corners_px
54
+ )
55
+
56
+
57
+ def check_rho_d_um(
58
+ RhoD_um: float,
59
+ NrPixelsY: int, NrPixelsZ: int,
60
+ BC_y: float, BC_z: float,
61
+ pxY: float, pxZ: Optional[float] = None,
62
+ *,
63
+ low_factor: float = 0.5,
64
+ high_factor: float = 5.0,
65
+ strict: bool = True,
66
+ ) -> Optional[str]:
67
+ """Raise (or return a warning) if ``RhoD_um`` looks like a unit mistake.
68
+
69
+ The healthy range is roughly the detector-corner distance from the beam
70
+ centre (in µm). Outside ``[low_factor·dmax, high_factor·dmax]`` we treat
71
+ the value as a likely unit mix-up — most often RhoD passed in **pixels**,
72
+ which is ``dmax / px`` ≈ 1/px-pitch of the expected µm magnitude.
73
+
74
+ Returns ``None`` if healthy. With ``strict=False`` and an out-of-range
75
+ value, returns the diagnostic string; with ``strict=True`` raises
76
+ ``ValueError``.
77
+ """
78
+ if RhoD_um <= 0:
79
+ msg = f"RhoD must be positive; got {RhoD_um}"
80
+ if strict:
81
+ raise ValueError(msg)
82
+ return msg
83
+ dmax = detector_max_corner_dist_um(NrPixelsY, NrPixelsZ, BC_y, BC_z, pxY, pxZ)
84
+ lo, hi = low_factor * dmax, high_factor * dmax
85
+ if RhoD_um < lo or RhoD_um > hi:
86
+ pxY_eff = float(pxY)
87
+ as_px_hint = ""
88
+ if RhoD_um < lo and pxY_eff > 0 and 0.3 * dmax < RhoD_um * pxY_eff < 3 * dmax:
89
+ as_px_hint = (f" HINT: RhoD * px = {RhoD_um * pxY_eff:.1f} µm "
90
+ f"looks reasonable — did you pass RhoD in pixels?")
91
+ msg = (
92
+ f"RhoD = {RhoD_um:.4g} µm is outside the sane range "
93
+ f"[{lo:.1f}, {hi:.1f}] µm for a {NrPixelsY}×{NrPixelsZ} detector at "
94
+ f"px=({pxY},{pxZ if pxZ is not None else pxY}), BC=({BC_y},{BC_z}) "
95
+ f"(max corner distance = {dmax:.1f} µm).{as_px_hint}"
96
+ )
97
+ if strict:
98
+ raise ValueError(msg)
99
+ return msg
100
+ return None
101
+
102
+
103
+ def resolve_rho_d_um(
104
+ rho_d,
105
+ NrPixelsY: int, NrPixelsZ: int,
106
+ BC_y: float, BC_z: float,
107
+ pxY: float, pxZ: Optional[float] = None,
108
+ *,
109
+ low_factor: float = 0.5,
110
+ high_factor: float = 5.0,
111
+ ) -> "tuple[float, str]":
112
+ """Resolve a ``RhoD`` of ambiguous units to micrometres.
113
+
114
+ Tries ``{rho_d, rho_d·px, rho_d/px}`` and returns the first that falls in
115
+ the sane window ``[low_factor·dmax, high_factor·dmax]`` (preferring the
116
+ as-is interpretation), where ``dmax`` is the beam-centre-to-farthest-corner
117
+ distance in µm. When no candidate is sane (or ``rho_d`` is missing /
118
+ non-positive) it falls back to ``dmax`` — the natural default RhoD.
119
+
120
+ Returns ``(rho_d_um, how)`` where ``how`` documents the chosen branch.
121
+ """
122
+ dmax = detector_max_corner_dist_um(NrPixelsY, NrPixelsZ, BC_y, BC_z, pxY, pxZ)
123
+ lo, hi = low_factor * dmax, high_factor * dmax
124
+ px = float(pxY) if (pxZ is None or pxZ <= 0) else 0.5 * (float(pxY) + float(pxZ))
125
+ try:
126
+ val = float(rho_d)
127
+ except (TypeError, ValueError):
128
+ val = 0.0
129
+ if val > 0 and px > 0:
130
+ for how, cand in (("as-is (µm)", val),
131
+ ("×px (was pixels)", val * px),
132
+ ("÷px", val / px)):
133
+ if lo <= cand <= hi:
134
+ return cand, how
135
+ return dmax, "default = BC-to-farthest-edge"
136
+
137
+
138
+ def resolve_rho_d_um_warn(
139
+ rho_d,
140
+ NrPixelsY: int, NrPixelsZ: int,
141
+ BC_y: float, BC_z: float,
142
+ pxY: float, pxZ: Optional[float] = None,
143
+ *,
144
+ where: str = "",
145
+ rel_tol: float = 1e-3,
146
+ **kwargs,
147
+ ) -> float:
148
+ """Like :func:`resolve_rho_d_um` but emit a ``RuntimeWarning`` when the
149
+ value had to be corrected (units were not already µm), and return just the
150
+ resolved µm value. Intended for ``validate()``/build hooks so a wrong-unit
151
+ RhoD is fixed *and* surfaced rather than silently mangling the distortion.
152
+ """
153
+ resolved, how = resolve_rho_d_um(
154
+ rho_d, NrPixelsY, NrPixelsZ, BC_y, BC_z, pxY, pxZ, **kwargs)
155
+ try:
156
+ orig = float(rho_d)
157
+ except (TypeError, ValueError):
158
+ orig = 0.0
159
+ if orig <= 0 or abs(resolved - orig) > rel_tol * max(resolved, 1.0):
160
+ loc = f" in {where}" if where else ""
161
+ warnings.warn(
162
+ f"RhoD{loc} resolved to {resolved:.1f} µm ({how}); supplied value "
163
+ f"was {orig:.4g}. RhoD must be in micrometres (ρ = R_µm / RhoD); a "
164
+ f"pixel-valued RhoD silently corrupts the distortion. Auto-corrected.",
165
+ RuntimeWarning, stacklevel=3,
166
+ )
167
+ return resolved
168
+
169
+
170
+ __all__ = [
171
+ "detector_max_corner_dist_um",
172
+ "check_rho_d_um",
173
+ "resolve_rho_d_um",
174
+ "resolve_rho_d_um_warn",
175
+ ]
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: midas-distortion
3
+ Version: 0.2.0
4
+ Summary: Canonical MIDAS radial-distortion model: layout tables, v1<->v2 coefficient mapping, and a backend-agnostic (numpy/torch) distortion kernel.
5
+ Author-email: Hemant Sharma <hsharma@anl.gov>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/marinerhemant/MIDAS
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering :: Physics
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: numpy>=1.22
16
+ Provides-Extra: torch
17
+ Requires-Dist: torch>=2.0; extra == "torch"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Requires-Dist: torch>=2.0; extra == "dev"
21
+
22
+ # midas-distortion
23
+
24
+ Canonical MIDAS radial-distortion model — the single source of truth for the
25
+ detector distortion layout and the v1↔v2 coefficient mapping, shared by:
26
+
27
+ - **midas-calibrate-v2** — fits the distortion (v2 canonical names).
28
+ - **midas-peakfit** — applies it to spot geometry (numpy).
29
+ - **midas-transforms** — applies it to spot geometry (torch).
30
+
31
+ ## Model
32
+
33
+ Multiplicative factor on the projected radius `R` (with `ρ = R / RhoD`,
34
+ `η' = 90° − η`):
35
+
36
+ ```
37
+ D(ρ, η) = 1
38
+ + iso_R2·ρ² + iso_R4·ρ⁴ + iso_R6·ρ⁶ (isotropic)
39
+ + a1·ρ⁴·cos( η' + phi1) (1-fold; ρ⁴ is a v1 quirk)
40
+ + a2·ρ²·cos(2η' + phi2)
41
+ + a3·ρ³·cos(3η' + phi3)
42
+ + a4·ρ⁴·cos(4η' + phi4)
43
+ + a5·ρ⁵·cos(5η' + phi5)
44
+ + a6·ρ⁶·cos(6η' + phi6)
45
+ ```
46
+
47
+ The legacy v1 ordering (`p0..p14`, phases scattered) is `v1_term_layout()`;
48
+ v1↔v2 reindexing is exact (`v1_to_v2_coeffs` / `v2_to_v1_coeffs`).
49
+
50
+ ## Backend-agnostic kernel
51
+
52
+ `distortion_factor(R_norm, eta_deg, p_coeffs, terms=...)` dispatches `cos` /
53
+ `ones_like` on the input's own array library, so numpy and torch consumers
54
+ evaluate bit-for-bit the same model (up to floating-point reassociation).
55
+
56
+ ```python
57
+ import numpy as np
58
+ from midas_distortion import distortion_factor, v1_term_layout, v1_to_v2_coeffs
59
+
60
+ p_v1 = np.zeros(15) # legacy paramstest p0..p14
61
+ D = distortion_factor(R/RhoD, eta_deg, p_v1, terms=v1_term_layout())
62
+ # …or convert once and use the v2 layout (default):
63
+ D = distortion_factor(R/RhoD, eta_deg, v1_to_v2_coeffs(p_v1))
64
+ ```
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ midas_distortion/__init__.py
4
+ midas_distortion/core.py
5
+ midas_distortion/rhod.py
6
+ midas_distortion.egg-info/PKG-INFO
7
+ midas_distortion.egg-info/SOURCES.txt
8
+ midas_distortion.egg-info/dependency_links.txt
9
+ midas_distortion.egg-info/requires.txt
10
+ midas_distortion.egg-info/top_level.txt
11
+ tests/test_core.py
12
+ tests/test_rhod.py
@@ -0,0 +1,8 @@
1
+ numpy>=1.22
2
+
3
+ [dev]
4
+ pytest>=7
5
+ torch>=2.0
6
+
7
+ [torch]
8
+ torch>=2.0
@@ -0,0 +1 @@
1
+ midas_distortion
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "midas-distortion"
7
+ version = "0.2.0"
8
+ description = "Canonical MIDAS radial-distortion model: layout tables, v1<->v2 coefficient mapping, and a backend-agnostic (numpy/torch) distortion kernel."
9
+ readme = "README.md"
10
+ license = { text = "BSD-3-Clause" }
11
+ authors = [
12
+ {name = "Hemant Sharma", email = "hsharma@anl.gov"},
13
+ ]
14
+ requires-python = ">=3.9"
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Scientific/Engineering :: Physics",
21
+ ]
22
+ dependencies = [
23
+ "numpy>=1.22",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ torch = [
28
+ "torch>=2.0",
29
+ ]
30
+ dev = [
31
+ "pytest>=7",
32
+ "torch>=2.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/marinerhemant/MIDAS"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["."]
40
+ include = ["midas_distortion*"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ addopts = "-ra -q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,156 @@
1
+ """midas_distortion parity + cross-backend gates.
2
+
3
+ The distortion model is correct iff its layout/mapping is correct; the
4
+ arithmetic is trivial. So we pin:
5
+
6
+ HARD GATE — every additive term is BIT-IDENTICAL across the legacy v1 inline
7
+ polynomial, the v1-layout kernel, and the v2-shim kernel (Δ=0). A mapping
8
+ bug (wrong index/fold/phase/radial-power) surfaces here, not behind rounding.
9
+ ASSEMBLED — the summed factor agrees to ≤ 8 ULP (pure IEEE-754 reassociation).
10
+ CROSS-BACKEND — numpy and torch evaluate the same factor to ≤ 8 ULP.
11
+ """
12
+ import numpy as np
13
+ import pytest
14
+
15
+ from midas_distortion import (
16
+ distortion_factor, v1_term_layout, v2_term_layout,
17
+ v1_to_v2_coeffs, v2_to_v1_coeffs, v2_coeffs_from_named,
18
+ P_COEF_NAMES, V1_TO_V2_DISTORTION, V2_TO_V1_DISTORTION,
19
+ )
20
+
21
+ _DEG2RAD = 0.017453292519943295
22
+ _EPS = float(np.finfo(np.float64).eps)
23
+ _ULP_TOL = 8 * _EPS
24
+
25
+
26
+ def _grid_np():
27
+ R = np.linspace(0.0, 1.3, 41)
28
+ eta = np.linspace(-180.0, 180.0, 73)
29
+ return np.meshgrid(R, eta, indexing="ij")
30
+
31
+
32
+ def _inline_terms(Rg, Eg, p):
33
+ """The 9 additive terms of the legacy v1 polynomial, keyed by p-index."""
34
+ eT = (90.0 - Eg) * _DEG2RAD
35
+ return {
36
+ 0: p[0] * Rg**2 * np.cos(2 * eT + _DEG2RAD * p[6]),
37
+ 1: p[1] * Rg**4 * np.cos(4 * eT + _DEG2RAD * p[3]),
38
+ 2: p[2] * Rg**2,
39
+ 4: p[4] * Rg**6,
40
+ 5: p[5] * Rg**4,
41
+ 7: p[7] * Rg**4 * np.cos(eT + _DEG2RAD * p[8]),
42
+ 9: p[9] * Rg**3 * np.cos(3 * eT + _DEG2RAD * p[10]),
43
+ 11: p[11] * Rg**5 * np.cos(5 * eT + _DEG2RAD * p[12]),
44
+ 13: p[13] * Rg**6 * np.cos(6 * eT + _DEG2RAD * p[14]),
45
+ }
46
+
47
+
48
+ def _random_p(rng):
49
+ p = rng.uniform(-5e-3, 5e-3, size=15)
50
+ for ph in (3, 6, 8, 10, 12, 14):
51
+ p[ph] = rng.uniform(-180.0, 180.0)
52
+ return p
53
+
54
+
55
+ def test_v1_v2_maps_are_inverses():
56
+ assert {v: k for k, v in V1_TO_V2_DISTORTION.items()} == V2_TO_V1_DISTORTION
57
+ assert set(V1_TO_V2_DISTORTION.values()) == set(P_COEF_NAMES)
58
+ assert sorted(V1_TO_V2_DISTORTION) == list(range(15))
59
+
60
+
61
+ def test_coeff_reindex_roundtrip_and_mapping():
62
+ """v1→v2→v1 is identity, and each v2 slot pulls the right v1 index."""
63
+ p = np.arange(15.0)
64
+ v2 = v1_to_v2_coeffs(p)
65
+ assert np.array_equal(v2_to_v1_coeffs(v2), p)
66
+ for v1_idx, name in V1_TO_V2_DISTORTION.items():
67
+ assert v2[P_COEF_NAMES.index(name)] == p[v1_idx]
68
+
69
+
70
+ def test_v2_coeffs_from_named():
71
+ """v2 names populate the canonical vector; legacy p0..p14 is a fallback
72
+ equal to v1_to_v2_coeffs; v2 names win on collision."""
73
+ # pure v2 names
74
+ d = {"iso_R2": -1.1e-3, "a2": 4.6e-4, "phi2": 33.0, "a4": -5.2e-4, "phi4": -6.87}
75
+ v = v2_coeffs_from_named(d)
76
+ for nm, val in d.items():
77
+ assert v[P_COEF_NAMES.index(nm)] == val
78
+ # legacy p path == v1_to_v2_coeffs
79
+ pv = np.arange(15.0)
80
+ assert np.array_equal(v2_coeffs_from_named({f"p{i}": pv[i] for i in range(15)}),
81
+ v1_to_v2_coeffs(pv))
82
+ # v2 name wins over the same-slot legacy p
83
+ mixed = {"p2": 99.0, "iso_R2": -1.1e-3} # p2 and iso_R2 are the same slot
84
+ assert v2_coeffs_from_named(mixed)[P_COEF_NAMES.index("iso_R2")] == -1.1e-3
85
+ # None values skipped
86
+ assert np.array_equal(v2_coeffs_from_named({"a2": None}), np.zeros(15))
87
+
88
+
89
+ def test_coeff_reindex_is_differentiable():
90
+ """The v1→v2 gather must preserve autograd (transforms needs grads)."""
91
+ torch = pytest.importorskip("torch")
92
+ t = torch.arange(15.0, dtype=torch.float64, requires_grad=True)
93
+ v1_to_v2_coeffs(t).sum().backward()
94
+ assert t.grad is not None and torch.all(t.grad == 1.0)
95
+
96
+
97
+ def test_terms_bit_identical():
98
+ """HARD GATE: per-term Δ=0 across inline / v1-kernel / v2-shim."""
99
+ rng = np.random.default_rng(0)
100
+ Rg, Eg = _grid_np()
101
+ v1L, v2L = v1_term_layout(), v2_term_layout()
102
+ name2v2 = {nm: i for i, nm in enumerate(P_COEF_NAMES)}
103
+ v2_key = {k: name2v2[v] for k, v in V1_TO_V2_DISTORTION.items()} # v1 amp idx → v2 amp idx
104
+ eT = (90.0 - Eg) * _DEG2RAD
105
+ for _ in range(300):
106
+ p = _random_p(rng)
107
+ p2 = v1_to_v2_coeffs(p)
108
+ inl = _inline_terms(Rg, Eg, p)
109
+ # kernel per-term, v1 layout
110
+ kv1 = {}
111
+ for t in v1L:
112
+ rad = Rg ** t.radial_power
113
+ kv1[t.coef_idx] = (p[t.coef_idx] * rad if t.fold == 0
114
+ else p[t.coef_idx] * rad * np.cos(t.fold * eT + p[t.phase_idx] * _DEG2RAD))
115
+ # kernel per-term, v2 layout on shimmed coeffs
116
+ kv2 = {}
117
+ for t in v2L:
118
+ rad = Rg ** t.radial_power
119
+ kv2[t.coef_idx] = (p2[t.coef_idx] * rad if t.fold == 0
120
+ else p2[t.coef_idx] * rad * np.cos(t.fold * eT + p2[t.phase_idx] * _DEG2RAD))
121
+ for k, term in inl.items():
122
+ assert np.array_equal(term, kv1[k]), f"v1 kernel term p{k} != inline"
123
+ assert np.array_equal(term, kv2[v2_key[k]]), f"v2 shim term p{k} != inline"
124
+
125
+
126
+ def test_assembled_within_ulp():
127
+ rng = np.random.default_rng(1)
128
+ Rg, Eg = _grid_np()
129
+ for _ in range(300):
130
+ p = _random_p(rng)
131
+ p2 = v1_to_v2_coeffs(p)
132
+ D_inline = sum(_inline_terms(Rg, Eg, p).values()) + 1.0
133
+ D_v1 = distortion_factor(Rg, Eg, p, terms=v1_term_layout())
134
+ D_v2 = distortion_factor(Rg, Eg, p2, terms=v2_term_layout())
135
+ assert np.abs(D_inline - D_v1).max() <= _ULP_TOL
136
+ assert np.abs(D_v1 - D_v2).max() <= _ULP_TOL
137
+
138
+
139
+ def test_zero_is_unity():
140
+ Rg, Eg = _grid_np()
141
+ z = np.zeros(15)
142
+ for layout in (v1_term_layout(), v2_term_layout()):
143
+ assert np.array_equal(distortion_factor(Rg, Eg, z, terms=layout), np.ones_like(Rg))
144
+
145
+
146
+ def test_cross_backend_numpy_torch():
147
+ """numpy and torch evaluate the same factor to ≤ 8 ULP."""
148
+ torch = pytest.importorskip("torch")
149
+ rng = np.random.default_rng(2)
150
+ Rg, Eg = _grid_np()
151
+ Rt, Et = torch.from_numpy(Rg), torch.from_numpy(Eg)
152
+ for _ in range(50):
153
+ p = _random_p(rng)
154
+ D_np = distortion_factor(Rg, Eg, p, terms=v1_term_layout())
155
+ D_t = distortion_factor(Rt, Et, torch.from_numpy(p), terms=v1_term_layout())
156
+ assert np.abs(D_np - D_t.numpy()).max() <= _ULP_TOL
@@ -0,0 +1,60 @@
1
+ """Tests for the shared RhoD unit self-consistency guard."""
2
+ import warnings
3
+
4
+ import pytest
5
+
6
+ from midas_distortion.rhod import (
7
+ detector_max_corner_dist_um,
8
+ check_rho_d_um,
9
+ resolve_rho_d_um,
10
+ resolve_rho_d_um_warn,
11
+ )
12
+
13
+ # A leighanne-like 2880^2 Varex: px=150 µm, BC near centre.
14
+ GEO = dict(NrPixelsY=2880, NrPixelsZ=2880, BC_y=1431.7, BC_z=1472.5, pxY=150.0)
15
+ DMAX = detector_max_corner_dist_um(**GEO) # ~310000 µm
16
+ CORNER_PX = DMAX / 150.0 # ~2065 px
17
+
18
+
19
+ def test_dmax_is_um_scale():
20
+ assert 250_000 < DMAX < 360_000 # micrometres, not pixels
21
+
22
+
23
+ def test_resolve_passes_through_correct_um():
24
+ val, how = resolve_rho_d_um(DMAX, **GEO)
25
+ assert abs(val - DMAX) < 1.0
26
+ assert how.startswith("as-is")
27
+
28
+
29
+ def test_resolve_corrects_pixels_to_um():
30
+ # RhoD given in pixels (~2065) must be detected and scaled by px.
31
+ val, how = resolve_rho_d_um(CORNER_PX, **GEO)
32
+ assert abs(val - DMAX) < 1.0
33
+ assert "pixels" in how
34
+
35
+
36
+ def test_resolve_defaults_when_missing():
37
+ val, how = resolve_rho_d_um(0.0, **GEO)
38
+ assert abs(val - DMAX) < 1.0
39
+ assert "default" in how
40
+
41
+
42
+ def test_check_raises_on_pixel_value():
43
+ with pytest.raises(ValueError):
44
+ check_rho_d_um(CORNER_PX, **GEO) # px value, strict
45
+ assert check_rho_d_um(DMAX, **GEO) is None # µm value, healthy
46
+
47
+
48
+ def test_warn_helper_warns_on_correction_only():
49
+ # pixel value -> corrected + warns
50
+ with warnings.catch_warnings(record=True) as w:
51
+ warnings.simplefilter("always")
52
+ out = resolve_rho_d_um_warn(CORNER_PX, **GEO)
53
+ assert abs(out - DMAX) < 1.0
54
+ assert any("RhoD" in str(x.message) for x in w)
55
+ # already-µm value -> no warning
56
+ with warnings.catch_warnings(record=True) as w:
57
+ warnings.simplefilter("always")
58
+ out = resolve_rho_d_um_warn(DMAX, **GEO)
59
+ assert abs(out - DMAX) < 1.0
60
+ assert not w