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.
- midas_distortion-0.2.0/PKG-INFO +64 -0
- midas_distortion-0.2.0/README.md +43 -0
- midas_distortion-0.2.0/midas_distortion/__init__.py +57 -0
- midas_distortion-0.2.0/midas_distortion/core.py +235 -0
- midas_distortion-0.2.0/midas_distortion/rhod.py +175 -0
- midas_distortion-0.2.0/midas_distortion.egg-info/PKG-INFO +64 -0
- midas_distortion-0.2.0/midas_distortion.egg-info/SOURCES.txt +12 -0
- midas_distortion-0.2.0/midas_distortion.egg-info/dependency_links.txt +1 -0
- midas_distortion-0.2.0/midas_distortion.egg-info/requires.txt +8 -0
- midas_distortion-0.2.0/midas_distortion.egg-info/top_level.txt +1 -0
- midas_distortion-0.2.0/pyproject.toml +44 -0
- midas_distortion-0.2.0/setup.cfg +4 -0
- midas_distortion-0.2.0/tests/test_core.py +156 -0
- midas_distortion-0.2.0/tests/test_rhod.py +60 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|