phaserEM 0.2__tar.gz → 0.3.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.
Files changed (139) hide show
  1. {phaserem-0.2 → phaserem-0.3.0}/PKG-INFO +17 -4
  2. phaserem-0.3.0/phaser/__init__.py +1 -0
  3. {phaserem-0.2 → phaserem-0.3.0}/phaser/__main__.py +1 -1
  4. phaserem-0.3.0/phaser/cli/__init__.py +161 -0
  5. phaserem-0.3.0/phaser/cli/calc_drift.py +424 -0
  6. phaserem-0.3.0/phaser/cli/process_empad.py +308 -0
  7. phaserem-0.3.0/phaser/cli/validate.py +51 -0
  8. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/common/noise_models.py +14 -6
  9. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/common/output.py +4 -4
  10. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/common/position_correction.py +2 -2
  11. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/common/regularizers.py +122 -36
  12. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/common/simulation.py +73 -21
  13. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/conventional/run.py +53 -30
  14. {phaserem-0.2 → phaserem-0.3.0}/phaser/engines/conventional/solvers.py +79 -31
  15. phaserem-0.3.0/phaser/engines/gradient/run.py +617 -0
  16. phaserem-0.3.0/phaser/engines/gradient/solvers.py +307 -0
  17. phaserem-0.3.0/phaser/engines/gradient/synthetic.py +125 -0
  18. {phaserem-0.2 → phaserem-0.3.0}/phaser/execute.py +77 -40
  19. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/__init__.py +90 -48
  20. phaserem-0.3.0/phaser/hooks/_dependencies.py +46 -0
  21. phaserem-0.3.0/phaser/hooks/filter.py +70 -0
  22. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/hook.py +17 -6
  23. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/io/empad.py +2 -2
  24. phaserem-0.3.0/phaser/hooks/io/gatan.py +98 -0
  25. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/io/manual.py +1 -0
  26. phaserem-0.3.0/phaser/hooks/io/nion.py +116 -0
  27. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/object.py +4 -3
  28. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/preprocessing.py +98 -28
  29. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/probe.py +9 -5
  30. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/regularization.py +16 -6
  31. phaserem-0.3.0/phaser/hooks/scan.py +48 -0
  32. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/schedule.py +4 -2
  33. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/solver.py +15 -7
  34. {phaserem-0.2 → phaserem-0.3.0}/phaser/io/empad.py +80 -1
  35. phaserem-0.3.0/phaser/io/gatan.py +298 -0
  36. phaserem-0.3.0/phaser/io/nion.py +107 -0
  37. {phaserem-0.2 → phaserem-0.3.0}/phaser/main.py +29 -9
  38. {phaserem-0.2 → phaserem-0.3.0}/phaser/observer.py +40 -11
  39. {phaserem-0.2 → phaserem-0.3.0}/phaser/plan.py +59 -9
  40. {phaserem-0.2 → phaserem-0.3.0}/phaser/state.py +112 -74
  41. {phaserem-0.2 → phaserem-0.3.0}/phaser/types.py +106 -15
  42. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/_cuda_kernels.py +34 -0
  43. phaserem-0.3.0/phaser/utils/_jax_kernels.py +231 -0
  44. phaserem-0.3.0/phaser/utils/_torch_kernels.py +683 -0
  45. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/analysis.py +6 -1
  46. phaserem-0.3.0/phaser/utils/config.py +188 -0
  47. phaserem-0.3.0/phaser/utils/image.py +1581 -0
  48. phaserem-0.3.0/phaser/utils/io.py +611 -0
  49. phaserem-0.3.0/phaser/utils/misc.py +529 -0
  50. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/num.py +493 -154
  51. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/object.py +62 -22
  52. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/optics.py +35 -9
  53. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/physics.py +6 -1
  54. phaserem-0.3.0/phaser/utils/scan.py +64 -0
  55. phaserem-0.3.0/phaser/utils/tree.py +466 -0
  56. phaserem-0.3.0/phaser/version.py +67 -0
  57. phaserem-0.3.0/phaser/web/debug.py +140 -0
  58. phaserem-0.3.0/phaser/web/pubsub.py +320 -0
  59. phaserem-0.3.0/phaser/web/routes.py +381 -0
  60. phaserem-0.3.0/phaser/web/server.py +949 -0
  61. phaserem-0.3.0/phaser/web/static/apple-touch-icon.png +3 -0
  62. phaserem-0.3.0/phaser/web/static/dist/dashboard.js +3 -0
  63. phaserem-0.3.0/phaser/web/static/dist/dashboard.js.LICENSE.txt +6 -0
  64. phaserem-0.3.0/phaser/web/static/dist/dashboard.js.map +1 -0
  65. phaserem-0.3.0/phaser/web/static/dist/error.js +2 -0
  66. phaserem-0.3.0/phaser/web/static/dist/error.js.map +1 -0
  67. phaserem-0.3.0/phaser/web/static/dist/manager.js +3 -0
  68. phaserem-0.3.0/phaser/web/static/dist/manager.js.LICENSE.txt +6 -0
  69. phaserem-0.3.0/phaser/web/static/dist/manager.js.map +1 -0
  70. phaserem-0.3.0/phaser/web/static/dist/runtime.js +2 -0
  71. phaserem-0.3.0/phaser/web/static/dist/runtime.js.map +1 -0
  72. phaserem-0.3.0/phaser/web/static/dist/shared.js +3 -0
  73. phaserem-0.3.0/phaser/web/static/dist/shared.js.LICENSE.txt +58 -0
  74. phaserem-0.3.0/phaser/web/static/dist/shared.js.map +1 -0
  75. phaserem-0.3.0/phaser/web/static/favicon.ico +0 -0
  76. phaserem-0.3.0/phaser/web/static/icon.svg +27 -0
  77. phaserem-0.3.0/phaser/web/static/logo-text.svg +29 -0
  78. phaserem-0.3.0/phaser/web/static/logo.svg +21 -0
  79. phaserem-0.3.0/phaser/web/templates/base.html +19 -0
  80. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/templates/dashboard.html +2 -2
  81. phaserem-0.3.0/phaser/web/templates/error.html +19 -0
  82. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/templates/manager.html +2 -2
  83. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/types.py +115 -71
  84. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/util.py +50 -21
  85. phaserem-0.3.0/phaser/web/views.py +209 -0
  86. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/worker.py +65 -19
  87. {phaserem-0.2 → phaserem-0.3.0}/phaserEM.egg-info/PKG-INFO +17 -4
  88. {phaserem-0.2 → phaserem-0.3.0}/phaserEM.egg-info/SOURCES.txt +40 -5
  89. phaserem-0.3.0/phaserEM.egg-info/entry_points.txt +2 -0
  90. {phaserem-0.2 → phaserem-0.3.0}/phaserEM.egg-info/requires.txt +18 -3
  91. {phaserem-0.2 → phaserem-0.3.0}/pyproject.toml +56 -10
  92. phaserem-0.3.0/tests/test_engines.py +164 -0
  93. phaserem-0.3.0/tests/test_image.py +909 -0
  94. phaserem-0.3.0/tests/test_initialization.py +391 -0
  95. {phaserem-0.2 → phaserem-0.3.0}/tests/test_load.py +6 -6
  96. {phaserem-0.2 → phaserem-0.3.0}/tests/test_misc.py +5 -5
  97. phaserem-0.3.0/tests/test_num.py +366 -0
  98. {phaserem-0.2 → phaserem-0.3.0}/tests/test_object.py +39 -23
  99. phaserem-0.3.0/tests/test_optics.py +135 -0
  100. {phaserem-0.2 → phaserem-0.3.0}/tests/test_physics.py +8 -1
  101. phaserem-0.3.0/tests/test_state.py +255 -0
  102. phaserem-0.2/phaser/engines/gradient/run.py +0 -438
  103. phaserem-0.2/phaser/engines/gradient/solvers.py +0 -139
  104. phaserem-0.2/phaser/hooks/scan.py +0 -28
  105. phaserem-0.2/phaser/utils/_jax_kernels.py +0 -102
  106. phaserem-0.2/phaser/utils/image.py +0 -231
  107. phaserem-0.2/phaser/utils/io.py +0 -406
  108. phaserem-0.2/phaser/utils/misc.py +0 -295
  109. phaserem-0.2/phaser/utils/scan.py +0 -60
  110. phaserem-0.2/phaser/web/__init__.py +0 -0
  111. phaserem-0.2/phaser/web/dist/9573273f862f4f5d9644.module.wasm +0 -0
  112. phaserem-0.2/phaser/web/dist/bundle-dashboard.js +0 -3577
  113. phaserem-0.2/phaser/web/dist/bundle-manager.js +0 -3455
  114. phaserem-0.2/phaser/web/dist/bundle-vendors-node_modules_wasm-array_wasm_array_js.js +0 -106
  115. phaserem-0.2/phaser/web/routes.py +0 -228
  116. phaserem-0.2/phaser/web/server.py +0 -605
  117. phaserem-0.2/phaser/web/templates/base.html +0 -13
  118. phaserem-0.2/phaserEM.egg-info/entry_points.txt +0 -2
  119. phaserem-0.2/tests/test_image.py +0 -76
  120. phaserem-0.2/tests/test_initialization.py +0 -184
  121. phaserem-0.2/tests/test_num.py +0 -213
  122. phaserem-0.2/tests/test_optics.py +0 -58
  123. {phaserem-0.2 → phaserem-0.3.0}/LICENSE.txt +0 -0
  124. {phaserem-0.2 → phaserem-0.3.0}/README.md +0 -0
  125. {phaserem-0.2/phaser → phaserem-0.3.0/phaser/engines/common}/__init__.py +0 -0
  126. {phaserem-0.2/phaser/engines/common → phaserem-0.3.0/phaser/engines/conventional}/__init__.py +0 -0
  127. {phaserem-0.2 → phaserem-0.3.0}/phaser/hooks/tilt.py +0 -0
  128. {phaserem-0.2/phaser/engines/conventional → phaserem-0.3.0/phaser/io}/__init__.py +0 -0
  129. {phaserem-0.2 → phaserem-0.3.0}/phaser/py.typed +0 -0
  130. {phaserem-0.2/phaser/io → phaserem-0.3.0/phaser/utils}/__init__.py +0 -0
  131. {phaserem-0.2 → phaserem-0.3.0}/phaser/utils/plotting.py +0 -0
  132. {phaserem-0.2/phaser/utils → phaserem-0.3.0/phaser/web}/__init__.py +0 -0
  133. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/notebook.py +0 -0
  134. {phaserem-0.2 → phaserem-0.3.0}/phaser/web/slurm.py +0 -0
  135. {phaserem-0.2 → phaserem-0.3.0}/phaserEM.egg-info/dependency_links.txt +0 -0
  136. {phaserem-0.2 → phaserem-0.3.0}/phaserEM.egg-info/top_level.txt +0 -0
  137. {phaserem-0.2 → phaserem-0.3.0}/setup.cfg +0 -0
  138. {phaserem-0.2 → phaserem-0.3.0}/setup.py +0 -0
  139. {phaserem-0.2 → phaserem-0.3.0}/tests/test_empad.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phaserEM
3
- Version: 0.2
3
+ Version: 0.3.0
4
4
  Summary: Weapon of choice for electron ptychographic reconstructions
5
5
  Author-email: Colin Gilgenbach <hexane@mit.edu>
6
6
  License-Expression: MPL-2.0
@@ -20,16 +20,21 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE.txt
23
- Requires-Dist: numpy<2.6,>=1.22
23
+ Requires-Dist: numpy<2.7,>=2.0
24
24
  Requires-Dist: scipy<1.19,>=1.7.0
25
25
  Requires-Dist: matplotlib~=3.8
26
26
  Requires-Dist: h5py~=3.8
27
27
  Requires-Dist: pyyaml>=5.3.1
28
28
  Requires-Dist: click~=8.1.0
29
+ Requires-Dist: frozendict~=2.4.7
29
30
  Requires-Dist: rich<15,>=12.0.0
30
31
  Requires-Dist: tifffile>=2023.8.25
31
- Requires-Dist: py-pane==0.11.3
32
+ Requires-Dist: optree>=0.13.0
33
+ Requires-Dist: py-pane==0.11.7
32
34
  Requires-Dist: typing_extensions~=4.7
35
+ Requires-Dist: platformdirs>=4.8.0
36
+ Requires-Dist: PyYAML>=6.0
37
+ Requires-Dist: lxml>=5.0.0
33
38
  Provides-Extra: dev
34
39
  Requires-Dist: pytest>=6.2.4; extra == "dev"
35
40
  Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
@@ -40,13 +45,21 @@ Requires-Dist: pynvml>=11.0.0; extra == "cupy11"
40
45
  Provides-Extra: cupy12
41
46
  Requires-Dist: cupy-cuda12x>=12.0.0; extra == "cupy12"
42
47
  Requires-Dist: pynvml>=11.0.0; extra == "cupy12"
48
+ Provides-Extra: cupy13
49
+ Requires-Dist: cupy-cuda13x>=12.0.0; extra == "cupy13"
50
+ Requires-Dist: pynvml>=11.0.0; extra == "cupy13"
43
51
  Provides-Extra: jax
44
- Requires-Dist: jax<0.8,>=0.4.25; extra == "jax"
52
+ Requires-Dist: jax<0.11,>=0.4.25; extra == "jax"
45
53
  Requires-Dist: optax>=0.2.2; extra == "jax"
54
+ Provides-Extra: torch
55
+ Requires-Dist: torch>=2.8.0; extra == "torch"
46
56
  Provides-Extra: web
47
57
  Requires-Dist: Quart>=0.20.0; extra == "web"
48
58
  Requires-Dist: backoff==2.2.1; extra == "web"
49
59
  Requires-Dist: requests>=2.31.0; extra == "web"
60
+ Requires-Dist: anywidget>=0.11.0; extra == "web"
61
+ Requires-Dist: jupyter-ws-tunnel==0.1.3; extra == "web"
62
+ Requires-Dist: hypercorn==0.18.0; extra == "web"
50
63
  Provides-Extra: docs
51
64
  Requires-Dist: mkdocs==1.6.1; extra == "docs"
52
65
  Requires-Dist: mkdocs-material==9.6.11; extra == "docs"
@@ -0,0 +1 @@
1
+ __version__ = '0.3.0'
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  if __name__ == '__main__':
4
- from phaser.main import cli
4
+ from phaser.cli import cli
5
5
  cli()
@@ -0,0 +1,161 @@
1
+ import abc
2
+ import importlib
3
+ from pathlib import Path
4
+ import sys
5
+ import typing as t
6
+
7
+ import click
8
+
9
+ class Dependency(abc.ABC):
10
+ @abc.abstractmethod
11
+ def check(self):
12
+ ...
13
+
14
+ @abc.abstractmethod
15
+ def install_instructions(self) -> str:
16
+ ...
17
+
18
+
19
+ class ImportDependency(Dependency):
20
+ def __init__(self, ref: str, install: str) -> None:
21
+ self.ref = ref
22
+ self.install = install
23
+
24
+ def check(self):
25
+ importlib.import_module(self.ref)
26
+
27
+ def install_instructions(self) -> str:
28
+ return self.install
29
+
30
+
31
+ def check_dependencies(command: str, dependencies: t.Sequence[str]):
32
+ for dependency in dependencies:
33
+ if (dep := _DEPENDENCIES.get(dependency)) is None:
34
+ raise RuntimeError(f"Unknown dependency '{dependency}'. This is likely a bug in the hook declaration.")
35
+
36
+ try:
37
+ dep.check()
38
+ except Exception as e:
39
+ raise RuntimeError(
40
+ f"Missing dependency '{dependency}' required for subcommand '{command}'.\n"
41
+ f"To install: {dep.install_instructions()}"
42
+ ) from e
43
+
44
+
45
+ class MainCommand(click.MultiCommand):
46
+ def __init__(self, commands: t.Iterable[
47
+ t.Union[click.Command, t.Tuple[str, str], t.Tuple[str, str, t.Sequence[str]]]
48
+ ], **kwargs):
49
+ super().__init__(**kwargs)
50
+ # name: command or short_help
51
+ self.commands: t.Dict[str, t.Union[click.Command, str, None]] = {}
52
+ self.dependencies: t.Dict[str, t.Sequence[str]] = {}
53
+ for c in commands:
54
+ if isinstance(c, click.Command):
55
+ name = str(c.name)
56
+ self.commands[name] = c
57
+ self.dependencies[name] = ()
58
+ continue
59
+ self.commands[c[0]] = c[1]
60
+ if len(c) > 2:
61
+ self.dependencies[c[0]] = c[2]
62
+
63
+ def list_commands(self, ctx: click.Context):
64
+ return list(self.commands.keys())
65
+
66
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
67
+ from gettext import gettext
68
+
69
+ if len(self.commands):
70
+ limit = formatter.width - 6 - max(map(len, self.commands.keys()))
71
+
72
+ rows = []
73
+ for name, cmd in self.commands.items():
74
+ help = cmd.get_short_help_str(limit) if isinstance(cmd, click.Command) else cmd
75
+ rows.append((name, help))
76
+
77
+ if rows:
78
+ with formatter.section(gettext("Commands")):
79
+ formatter.write_dl(rows)
80
+
81
+ def get_command(self, ctx: click.Context, cmd_name: str) -> t.Optional[click.Command]:
82
+ name = cmd_name.lower()
83
+ val = (self.commands.get(name) or
84
+ self.commands.get(name.replace('-', '_')))
85
+ if val is None:
86
+ return None
87
+ if isinstance(val, click.BaseCommand):
88
+ return val
89
+
90
+ check_dependencies(name, self.dependencies.get(name, ()))
91
+
92
+ # for now, assume `validate` command is `cli.validate:validate`
93
+ module = func = name
94
+ mod = __import__(module, globals(), fromlist=[func], level=1)
95
+ return getattr(mod, func)
96
+
97
+
98
+ @click.command()
99
+ @click.argument('path', type=click.Path(exists=True, dir_okay=False))
100
+ def run(path: t.Union[str, Path]):
101
+ """Execute a reconstruction plan"""
102
+ from phaser.plan import ReconsPlan
103
+ from phaser.execute import execute_plan
104
+ plans = ReconsPlan.from_yaml_all(path)
105
+
106
+ for plan in plans:
107
+ execute_plan(plan)
108
+
109
+
110
+ @click.command()
111
+ @click.option('--host', type=str, default='localhost', help="Host to serve on")
112
+ @click.option('--port', type=int, help="Port to serve on")
113
+ @click.option('-v', '--verbose', count=True, help="Increase verbosity")
114
+ @click.option('--debug', is_flag=True, help="Enable debug routes (/debug/*), for testing the web interface")
115
+ def serve(host: str = 'localhost', port: t.Optional[int] = None, verbose: int = 0, debug: bool = False):
116
+ """Run phaser server"""
117
+ from phaser.web.server import server
118
+
119
+ if ':' in host:
120
+ (host, port_from_host) = host.rsplit(':', maxsplit=1)
121
+ try:
122
+ port_from_host = int(port_from_host)
123
+ except ValueError:
124
+ print(f"Invalid host '{host}:{port_from_host}'", file=sys.stderr)
125
+ sys.exit(1)
126
+
127
+ port = port or port_from_host
128
+
129
+ server.run(hostname=host, port=port, verbosity=verbose, debug=debug)
130
+
131
+
132
+ @click.command()
133
+ @click.argument('url', type=str, required=True)
134
+ @click.option('--quiet/--loud', default=False, help="Whether to print output to stdout")
135
+ def worker(url: str, quiet: bool = False):
136
+ """
137
+ Run phaser worker.
138
+
139
+ URL is the server URL to connect to.
140
+ """
141
+ from phaser.web.worker import run_worker
142
+
143
+ run_worker(url, quiet=quiet)
144
+
145
+
146
+ _DEPENDENCIES = {
147
+ 'rsciio': ImportDependency('rsciio', "'pip install rosettasciio' or 'conda install rosettasciio'"),
148
+ }
149
+
150
+ commands: t.List[t.Union[click.Command, t.Union[t.Tuple[str, str, t.Sequence[str]], t.Tuple[str, str]]]] = [
151
+ run, serve, worker,
152
+ # these will be looked up in the cli folder
153
+ ('validate', "Validate reconstruction plan file"),
154
+ ('process_empad', "Process EMPAD XML metadata"),
155
+ ('calc_drift', "Calculate and correct linear drift"),
156
+ ]
157
+
158
+
159
+ @click.command(cls=MainCommand, commands=commands)
160
+ def cli():
161
+ pass
@@ -0,0 +1,424 @@
1
+ from pathlib import Path
2
+ from queue import Queue, Empty
3
+ from threading import Thread, Event
4
+ import fnmatch
5
+ import typing as t
6
+
7
+ import click
8
+ import numpy
9
+ from numpy.typing import NDArray, ArrayLike
10
+ import scipy.ndimage
11
+ import json
12
+ import h5py
13
+ from matplotlib import pyplot
14
+ from matplotlib.patches import Circle, PathPatch
15
+ from matplotlib.path import Path as MplPath
16
+ from matplotlib.backend_bases import MouseEvent, MouseButton, PickEvent, KeyEvent
17
+ from rich.console import Console
18
+ from rich.prompt import Prompt, FloatPrompt, Confirm
19
+
20
+
21
+ # TODO: generalize to other detectors
22
+ from phaser.io.empad import EmpadMetadata, load_4d
23
+
24
+
25
+ def load_adf(path: t.Union[str, Path]) -> t.Tuple[numpy.ndarray, t.Any]:
26
+ f = h5py.File(path)
27
+
28
+ images = t.cast(h5py.Group, f['Data/Image'])
29
+ if len(images) == 0:
30
+ raise ValueError("No images found in dataset.")
31
+ if len(images) > 1:
32
+ raise ValueError("Multi-image files not currently supported.")
33
+ image = t.cast(h5py.Group, next(iter(images.values())))
34
+ raw_meta: numpy.ndarray = t.cast(h5py.Dataset, image['Metadata'])[:, 0][()]
35
+ meta_bytes = raw_meta.tobytes()
36
+ meta_bytes = meta_bytes[:meta_bytes.find(b"\0")]
37
+ meta = json.loads(meta_bytes)
38
+ data = t.cast(h5py.Dataset, image['Data'])[..., 0][()]
39
+
40
+ return (data, meta)
41
+
42
+
43
+ def normed_to_uint8(data: numpy.ndarray) -> NDArray[numpy.uint8]:
44
+ return numpy.floor(numpy.clip(data, 0, 1) * 255.999).astype(numpy.uint8)
45
+
46
+
47
+ def normed_to_color(data: numpy.ndarray, color: ArrayLike) -> NDArray[numpy.uint8]:
48
+ return numpy.floor(numpy.clip(data, 0, 1)[..., None] * color).astype(numpy.uint8)
49
+
50
+
51
+ def signed_angle(v1: numpy.ndarray, v2: numpy.ndarray) -> float:
52
+ return numpy.pi - numpy.mod(numpy.arctan2(v1[0] * v2[1] - v1[1] * v2[0], numpy.dot(v1, v2)), 2.*numpy.pi)
53
+
54
+
55
+ def load_files(paths: t.Iterable[t.Union[str, Path]], inner: float, outer: float) -> t.Iterable[t.Tuple[Path, EmpadMetadata, NDArray[numpy.float32]]]:
56
+ queue: Queue[t.Tuple[Path, EmpadMetadata, NDArray[numpy.float32]]] = Queue(1)
57
+ finished = Event()
58
+
59
+ # producer thread
60
+ # should hold one in queue, one waiting for queue, one processing
61
+ def producer():
62
+ try:
63
+ for path in paths:
64
+ # eagerly load and put on queue
65
+ path = Path(path).resolve()
66
+ meta = EmpadMetadata.from_json(path)
67
+
68
+ exp_path = meta.path or Path('.')
69
+ raw_path = (exp_path / (meta.raw_filename or "scan_x128_y128.raw")).resolve()
70
+ if not raw_path.exists():
71
+ raise ValueError(f"Can't find raw data at path '{raw_path}'")
72
+ scan_shape = t.cast(t.Tuple[int, int], meta.scan_shape[::-1]) if meta.scan_shape is not None else None
73
+ raw = load_4d(raw_path, scan_shape=scan_shape, flips=meta.det_flips)
74
+
75
+ kx = numpy.arange(raw.shape[-2], dtype=numpy.float32) - raw.shape[-2] / 2.
76
+ ky = numpy.arange(raw.shape[-1], dtype=numpy.float32) - raw.shape[-1] / 2.
77
+ kyy, kxx = numpy.meshgrid(kx, ky, indexing='ij')
78
+ k2 = kyy**2 + kxx**2
79
+ virtual_aperture = numpy.zeros(raw.shape[-2:], dtype=bool)
80
+ virtual_aperture[(k2 >= inner**2) & (k2 <= outer**2)] = 1
81
+ virtual_img = numpy.sum(raw * virtual_aperture, axis=(-1, -2))
82
+
83
+ queue.put((path, meta, virtual_img))
84
+ # finished all files
85
+ finished.set()
86
+ except BaseException:
87
+ import traceback
88
+ traceback.print_exc()
89
+
90
+ thread = Thread(target=producer, name='loader', daemon=True)
91
+ thread.start()
92
+
93
+ # periodically check that thread is still running,
94
+ # to prevent deadlock
95
+ while thread.is_alive():
96
+ try:
97
+ val = queue.get(timeout=1.)
98
+ except Empty:
99
+ continue
100
+ yield val
101
+
102
+ thread.join()
103
+
104
+ # drain queue once thread is finished
105
+ while True:
106
+ try:
107
+ val = queue.get(False)
108
+ except Empty:
109
+ break
110
+ yield val
111
+
112
+ if not finished.is_set():
113
+ raise ValueError("Error in file loading")
114
+
115
+ print("Finished processing files!")
116
+
117
+
118
+ def calibrated_meta_path(meta_path: Path):
119
+ meta_path_name = meta_path.stem
120
+ if meta_path_name.endswith('_orig'):
121
+ meta_path_name = meta_path_name[:-5]
122
+ new_meta_path = meta_path.with_stem(meta_path_name + "_calib")
123
+ return new_meta_path
124
+
125
+
126
+ @click.command()
127
+ @click.argument('path', type=click.Path(exists=True, dir_okay=True, file_okay=True))
128
+ @click.option('--include', type=str, multiple=True,
129
+ help="Glob of filenames to include. If not specified, include all '.json' files")
130
+ @click.option('--exclude', type=str, multiple=True,
131
+ help="Glob of filenames to exclude.")
132
+ @click.option('--skip-existing/--no-skip-existing', default=True,
133
+ help="Whether to skip datasets which have already been calibrated. Defaults to true.")
134
+ def calc_drift(path: t.Union[str, Path], include: t.Sequence[str] = (), exclude: t.Sequence[str] = (), skip_existing: bool = True):
135
+ """
136
+ Calculate the linear drift present in a ptychography dataset, or group of datasets.
137
+
138
+ PATH should be the path to a JSON metadata file, or to a directory
139
+ which will be searched for JSON metadata files.
140
+
141
+ Datasets can be included or excluded with the `--include` and `--exclude` options.
142
+ These can be repeated multiple times.
143
+
144
+ Calculated drift is stored in a new metadata file with the suffix `_calib`.
145
+ By default, datasets which already have this file are skipped. This behavior can be changed
146
+ using the `--no-skip-existing` option.
147
+ """
148
+
149
+ console = Console()
150
+ path = Path(path)
151
+
152
+ if path.is_file():
153
+ paths = [path]
154
+ else:
155
+ exclude = (*exclude, "*_calib.json", '._*')
156
+ paths = list(path.glob('**/*.json'))
157
+ if len(include):
158
+ paths = list(filter(lambda path: any(fnmatch.fnmatch(path.name, pat) for pat in include), paths))
159
+ paths = list(filter(lambda path: not any(fnmatch.fnmatch(path.name, pat) for pat in exclude), paths))
160
+
161
+ if skip_existing:
162
+ paths = list(filter(lambda path: not calibrated_meta_path(path).exists(), paths))
163
+
164
+ paths.sort()
165
+
166
+ console.print(f"{len(paths)} file(s) to process.")
167
+
168
+ params = {
169
+ 'det': 'bf',
170
+ 'inner': 0.0,
171
+ 'outer': 2.0,
172
+ 'd1': 6.,
173
+ 'd2': 6.
174
+ }
175
+
176
+ params['det'] = Prompt.ask("Detector type", choices=['bf', 'adf', 'af'], default=params['det'], console=console)
177
+ if params['det'] in ('af', 'adf'):
178
+ params['inner'] = float(FloatPrompt.ask(r"Inner radius \[mrad]", default=params['inner'], console=console))
179
+ inner = params['inner']
180
+ else:
181
+ inner = 0.
182
+
183
+ if params['det'] in ('af', 'bf'):
184
+ params['outer'] = float(FloatPrompt.ask(r"Outer radius \[mrad]", default=params['outer'], console=console))
185
+ outer = params['outer']
186
+ else:
187
+ outer = numpy.inf
188
+
189
+ params['scale1'] = FloatPrompt.ask("Distance 1 scale", default=1., console=console)
190
+ params['scale2'] = FloatPrompt.ask("Distance 2 scale", default=params['scale1'], console=console)
191
+ #params['angle'] = FloatPrompt.ask("Signed angle from distance 1 -> distance 2 (degree, CWW is +)", default=90., console=console)
192
+ #params['angle'] *= numpy.pi/180.
193
+
194
+ console.print("Loading files...")
195
+ for (meta_path, meta, virtual_img) in load_files(paths, inner, outer):
196
+ console.print(f"Loaded file '{meta.path}'")
197
+ if not bool(Confirm.ask("Process this file?", default=True)):
198
+ console.print("Skipping file...")
199
+ continue
200
+
201
+ while True:
202
+ correction_matrix = calc_drift_one(console, meta, virtual_img, params)
203
+ if correction_matrix is None:
204
+ continue
205
+
206
+ choice = Prompt.ask("Save calibration?", choices=['y', 'n', 'abort'], default='y', console=console)
207
+ if choice == 'abort':
208
+ break
209
+ if choice.lower() not in ('y', 'yes'):
210
+ continue
211
+
212
+ new_meta_path = calibrated_meta_path(meta_path)
213
+ new_meta = meta.__replace__()
214
+ new_meta.scan_correction = tuple(map(tuple, correction_matrix)) # type: ignore
215
+
216
+ new_meta.write_json(new_meta_path, indent=4)
217
+ console.print(f"New metadata written to '{new_meta_path}'!")
218
+ break
219
+
220
+
221
+ def calc_drift_one(
222
+ console: Console, meta: EmpadMetadata, virtual_img: NDArray[numpy.float32], params: t.Dict[str, t.Any]
223
+ ) -> t.Optional[NDArray[numpy.number]]:
224
+ print(meta)
225
+ scan_step_4d = numpy.array(meta.scan_step) * 1e10 # m to angstrom
226
+ scan_size_4d = scan_step_4d * meta.scan_shape
227
+
228
+ console.print(f" 4D pixel size: {scan_step_4d[0]:.3f} x {scan_step_4d[1]:.3f} A", style='logging.level.info')
229
+ console.print(f" 4D image size: {scan_size_4d[0]:.2f} x {scan_size_4d[1]:.2f} A", style='logging.level.info')
230
+
231
+ fig, ax = pyplot.subplots(constrained_layout=True)
232
+ canvas = fig.canvas
233
+ ax.set_xlabel('x [A]')
234
+ ax.set_ylabel('y [A]')
235
+
236
+ ax.set_xlim(0., scan_size_4d[0])
237
+ ax.set_ylim(scan_size_4d[1], 0.)
238
+
239
+ img = ax.imshow(virtual_img, extent=(-0.5 * scan_step_4d[0], (virtual_img.shape[1] + 0.5) * scan_step_4d[0], (virtual_img.shape[0] + 0.5) * scan_step_4d[1], -0.5 * scan_step_4d[1]))
240
+ img.set_picker(True)
241
+ img.set_animated(True)
242
+
243
+ path: t.Optional[PathPatch] = None
244
+ circles: t.List[Circle] = []
245
+
246
+ selected: t.Optional[Circle] = None
247
+ bg = canvas.copy_from_bbox(ax.bbox) # type: ignore
248
+
249
+ def draw_artists():
250
+ ax.draw_artist(img)
251
+ if path is not None:
252
+ ax.draw_artist(path)
253
+ for circle in circles:
254
+ ax.draw_artist(circle)
255
+ canvas.blit(ax.bbox) # type: ignore
256
+
257
+ def draw(event=None):
258
+ nonlocal path, bg
259
+
260
+ vertices = numpy.array([circle.center for circle in circles])
261
+ if len(vertices):
262
+ if path is None:
263
+ path = PathPatch(MplPath(vertices), fill=False, lw=3.)
264
+ ax.add_patch(path)
265
+ else:
266
+ path.set_path(MplPath(vertices))
267
+
268
+ bg = canvas.copy_from_bbox(ax.bbox) # type: ignore
269
+ draw_artists()
270
+
271
+ def on_release(event: MouseEvent):
272
+ nonlocal selected
273
+ if not event.button == MouseButton.LEFT:
274
+ return
275
+ selected = None
276
+
277
+ def on_pick(event: PickEvent):
278
+ nonlocal selected
279
+ nonlocal path
280
+ if event.mouseevent.button != MouseButton.LEFT:
281
+ return
282
+ if event.artist is not img:
283
+ return
284
+
285
+ for circle in circles:
286
+ if circle.contains_point((event.mouseevent.x, event.mouseevent.y)): # type: ignore
287
+ selected = circle
288
+ return
289
+
290
+ if len(circles) < 3:
291
+ pos = t.cast(
292
+ t.Tuple[float, float],
293
+ list(ax.transData.inverted().transform((event.mouseevent.x, event.mouseevent.y)))
294
+ )
295
+
296
+ node = Circle(pos, radius=0.5, fc='white', ec='black', transform=ax.transData)
297
+ node.set_animated(True)
298
+ ax.add_patch(node)
299
+ circles.append(node)
300
+ selected = node
301
+ draw()
302
+
303
+ def on_move(event: MouseEvent):
304
+ if selected is None or event.inaxes is None or event.button != MouseButton.LEFT:
305
+ return
306
+ if event.x is None or event.y is None:
307
+ return
308
+ new_pt = ax.transData.inverted().transform((event.x, event.y))
309
+ selected.center = new_pt
310
+
311
+ canvas.restore_region(bg) # type: ignore
312
+ draw()
313
+ #draw_artists()
314
+
315
+ warped_img = None
316
+ correction_matrix: t.Optional[NDArray[numpy.number]] = None
317
+
318
+ def on_press(event: KeyEvent):
319
+ if event.key != 'enter':
320
+ return
321
+ vertices = numpy.array([circle.center for circle in circles])
322
+
323
+ if len(vertices) != 3:
324
+ console.print("Need 3 points to compute drift")
325
+ return
326
+
327
+ vecs = numpy.diff(vertices, axis=0)
328
+ #vecs_next = numpy.roll(vecs, 1, axis=0)
329
+
330
+ print(f"angle: {signed_angle(vecs[:, 0], vecs[:, 1]) * 180./numpy.pi}")
331
+
332
+ while True:
333
+ dists = []
334
+ for (i, scale, default) in zip(range(len(vertices)-1), (params['scale1'], params['scale2']), (params['d1'], params['d2'])):
335
+ while True:
336
+ try:
337
+ s = Prompt.ask(f"Distance {i+1} (* {scale:.3f} A)", default=default, console=console)
338
+ val = float(eval(s))
339
+ dists.append(val * scale)
340
+ except Exception:
341
+ continue
342
+ break
343
+ console.print(f"Distances [A]: {dists[0]:.3f}, {dists[1]:.3f}")
344
+ if Confirm.ask("Distances correct?", default=True):
345
+ break
346
+
347
+ # first, we use some logic to determine which orthogonal basis the measurements are closest to.
348
+ horz_vec = numpy.argmax(numpy.abs(vecs[:, 0])) # vec with maximum x component is horizontal
349
+ horz_sign = numpy.sign(vecs[horz_vec, 0]) # whether to flip horizontal vector
350
+ vert_sign = numpy.sign(vecs[horz_vec-1, 1]) # whether to flip vertical vector
351
+
352
+ # make target_vecs using the determinations above
353
+ target_vecs = numpy.diag(numpy.array(dists))
354
+ if horz_vec != 0:
355
+ # v1 should be vertical
356
+ target_vecs = target_vecs[::-1, :]
357
+ # flip target_vecs based on desired signs
358
+ target_vecs = numpy.diag([horz_sign, vert_sign]) @ target_vecs
359
+ console.print(f"finding transformation\n{vecs.T}\nto\n{target_vecs}")
360
+
361
+ # we try to find A which transforms `vecs` into `target_vecs`.
362
+ a = target_vecs @ numpy.linalg.inv(vecs.T)
363
+
364
+ nonlocal warped_img, correction_matrix
365
+
366
+ # because we only know distances, `a`
367
+ q, r = numpy.linalg.qr(a)
368
+ r: NDArray[numpy.floating] = numpy.diag(numpy.sign(numpy.diagonal(r))) @ r # flip to ensure diagonals are positive. These are absorbed into `q`.
369
+ console.print(f"actual dists: {dists}")
370
+ console.print(f"measured dists: {numpy.linalg.norm(vecs, axis=-1)}")
371
+ console.print(f"distortion:\n{r[::-1, ::-1]}") # correct for ptychoshelves coordinate system
372
+ #print(f"distortion:\n{r}") # correct for ptychoshelves coordinate system
373
+ vecs_after = (r @ vecs.T).T
374
+ console.print(f"dists after correction: {numpy.linalg.norm(vecs_after, axis=-1)}")
375
+ console.print(f"vecs after correction: {vecs_after[0]}, {vecs_after[1]}")
376
+ angle_after = signed_angle(vecs_after[0], vecs_after[1])
377
+ console.print(f"angle after correction: {180./numpy.pi * angle_after:.2f}")
378
+ #vecs_after_a = (a @ vecs.T).T
379
+ #print(f"vecs after a correction: {vecs_after_a[:, 0]}, {vecs_after_a[:, 1]}")
380
+ #angle_after_a = signed_angle(vecs_after_a[:, 0], vecs_after_a[:, 1])
381
+ #print(f"angle after a correction: {180./numpy.pi * angle_after_a:.2f}")
382
+ correction_matrix = r
383
+
384
+ # warp image given corrections
385
+ warped_shape = tuple(numpy.ceil(numpy.array(virtual_img.shape) * numpy.max(numpy.abs(numpy.diagonal(r)))).astype(int))
386
+ affine = numpy.block([[numpy.linalg.inv(r)[::-1, ::-1], numpy.zeros((2, 1))], [numpy.zeros((1, 2)), numpy.ones((1, 1))]])
387
+ translation = numpy.eye(3)
388
+ translation[:2, -1] += numpy.array(virtual_img.shape[-2:]) / 2.
389
+ translation2 = numpy.eye(3)
390
+ translation2[:2, -1] -= numpy.array(warped_shape[-2:]) / 2.
391
+ affine = translation @ affine @ translation2
392
+ warped_img = scipy.ndimage.affine_transform(virtual_img, affine, output_shape=warped_shape)
393
+ pyplot.close(fig)
394
+
395
+ # TODO: error out for non-interactive backends
396
+ #canvas.mpl_connect('button_press_event', on_click)
397
+ canvas.mpl_connect('button_release_event', on_release) # type: ignore
398
+ canvas.mpl_connect('key_press_event', on_press) # type: ignore
399
+ canvas.mpl_connect('motion_notify_event', on_move) # type: ignore
400
+ canvas.mpl_connect('draw_event', draw) # type: ignore
401
+ canvas.mpl_connect('pick_event', on_pick) # type: ignore
402
+
403
+ pyplot.show()
404
+
405
+ if warped_img is None:
406
+ return
407
+
408
+ fig, ax = pyplot.subplots()
409
+ ax.imshow(warped_img, vmin=float(numpy.nanmin(virtual_img)), vmax=float(numpy.nanmax(virtual_img)),
410
+ extent=(-0.5 * scan_step_4d[0], (warped_img.shape[1] + 0.5) * scan_step_4d[0], (warped_img.shape[0] + 0.5) * scan_step_4d[1], -0.5 * scan_step_4d[1]))
411
+ ax.set_xlabel('x [A]')
412
+ ax.set_ylabel('y [A]')
413
+ ax.set_title("Warped image")
414
+
415
+ def on_press_2(event: KeyEvent):
416
+ if event.key != 'enter':
417
+ return
418
+ pyplot.close(fig)
419
+
420
+ fig.canvas.mpl_connect('key_press_event', on_press_2) # type: ignore
421
+
422
+ pyplot.show()
423
+
424
+ return correction_matrix