micat 0.1.0__py3-none-any.whl

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.
micat/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Micat is for Microscopy Calibration and Testing using USAF resolution targets.
2
+
3
+ Micat is planned to become a larger library of calibration tools, but for now the only
4
+ calibration it does is calibrate the size of pixels in a micrograph. The internal API
5
+ for these features are in `usafcal`.
6
+
7
+
8
+
9
+ """
10
+
11
+ from . import usafcal
12
+
13
+ __all__ = ["usafcal"]
micat/__main__.py ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python
2
+ """The module used for CLI operation."""
3
+
4
+ import argparse
5
+ import os
6
+ from datetime import datetime
7
+
8
+ import openflexure_metadata
9
+ import piexif
10
+
11
+ import micat
12
+ from micat.usafcal_report import export_results
13
+
14
+ METADATA_UNREAD_VALUE = "unknown"
15
+
16
+
17
+ def _create_cli_parsers() -> tuple[argparse.ArgumentParser, argparse.ArgumentParser]:
18
+ """Create the command line argument parser.
19
+
20
+ :return: The main parser and the subparser for the usafcal command.
21
+ """
22
+ parser = argparse.ArgumentParser(
23
+ description="Micat Microscopy Calibration and Testing",
24
+ formatter_class=argparse.RawTextHelpFormatter,
25
+ )
26
+ subparsers = parser.add_subparsers(
27
+ help="Available commands listed below", metavar="<command>", dest="command"
28
+ )
29
+ parser_usafcal = subparsers.add_parser(
30
+ "usafcal", help="Analyse an image of a USAF target"
31
+ )
32
+ parser_usafcal.add_argument(
33
+ "imagefiles", type=str, nargs="+", help="the image file to analyse"
34
+ )
35
+ parser_usafcal.add_argument("--verbose", "-v", action="count")
36
+ parser_usafcal.add_argument("--outdir", "-O", help="Directory for saved data")
37
+ parser_usafcal.add_argument(
38
+ "--group",
39
+ "-g",
40
+ type=int,
41
+ default=7,
42
+ help="Set smallest group found, see PDF output! (default: 7)",
43
+ )
44
+ parser_usafcal.add_argument(
45
+ "--element",
46
+ "-e",
47
+ type=int,
48
+ default=6,
49
+ help="Set smallest element found, see PDF output! (default: 6)",
50
+ )
51
+ parser_usafcal.add_argument(
52
+ "--largest",
53
+ "-L",
54
+ action="count",
55
+ help="Makes --group and --element to refer to largest not smallest",
56
+ )
57
+ parser_help = subparsers.add_parser(
58
+ "help", help="Run 'help <command>' for detailed help"
59
+ )
60
+ parser_help.add_argument(
61
+ "h_command",
62
+ metavar="<command>",
63
+ nargs="?",
64
+ type=str,
65
+ help="Command to show help for",
66
+ )
67
+
68
+ return parser, parser_usafcal
69
+
70
+
71
+ def main() -> None:
72
+ """Run Micat.
73
+
74
+ This is the main function when Micat is run as a CLI tool.
75
+ """
76
+ parser, parser_usafcal = _create_cli_parsers()
77
+ args = parser.parse_args()
78
+
79
+ if args.command == "usafcal":
80
+ _run_usafcal(args)
81
+
82
+ elif args.command == "help":
83
+ if args.h_command is None:
84
+ parser.print_help()
85
+ elif args.h_command == "usafcal":
86
+ print(
87
+ "\n"
88
+ "Analyse images of USAF 1951 Resolution Targets to calibrate the "
89
+ "field of view and the numper of microns per pixel.\n"
90
+ )
91
+ print(parser_usafcal.format_help())
92
+ else:
93
+ print(f"Invalid micat command {args.h_command}\n\n")
94
+ parser.print_help()
95
+ else:
96
+ print(f"Invalid micat command {args.command}")
97
+ return
98
+
99
+
100
+ def _run_usafcal(args: argparse.Namespace) -> None:
101
+ """Run the USAF Target calibration based on the specified command line arguments."""
102
+ if not (1 <= args.group <= 7 and 1 <= args.element <= 6):
103
+ raise ValueError(
104
+ "Group must be between 1 and 7. Element must be between 1 and 6."
105
+ )
106
+
107
+ verbose = args.verbose is not None
108
+
109
+ largest = args.largest is not None
110
+
111
+ for filepath in args.imagefiles:
112
+ if verbose:
113
+ print("Analysing %s" % filepath)
114
+
115
+ directory, filename = os.path.split(filepath)
116
+ basefile, ext = os.path.splitext(filename)
117
+
118
+ target = micat.usafcal.analyse_file(
119
+ filepath,
120
+ group=args.group,
121
+ element=args.element,
122
+ smallest=not largest,
123
+ )
124
+
125
+ if args.outdir is not None:
126
+ directory = args.outdir
127
+
128
+ os.makedirs(directory, exist_ok=True)
129
+
130
+ if verbose:
131
+ print("Exporting results...")
132
+ metadata = read_metadata(filepath)
133
+ export_results(
134
+ target, os.path.join(directory, basefile + "_USAF_calibration"), metadata
135
+ )
136
+ if verbose:
137
+ print("Done!")
138
+
139
+
140
+ def read_metadata(filepath: str) -> dict:
141
+ """Read selected image metadata from the given filepath."""
142
+ exif = piexif.load(filepath)
143
+
144
+ user_comments = openflexure_metadata.get_exif_usercomment_json(exif)
145
+ capture_time = openflexure_metadata.get_capture_time(exif)
146
+
147
+ # Fully structured default schema
148
+ metadata: dict = {
149
+ "camera": METADATA_UNREAD_VALUE,
150
+ "system": {
151
+ "hostname": METADATA_UNREAD_VALUE,
152
+ "microscope-uuid": METADATA_UNREAD_VALUE,
153
+ "version": METADATA_UNREAD_VALUE,
154
+ "version_source": METADATA_UNREAD_VALUE,
155
+ },
156
+ "capture_time": METADATA_UNREAD_VALUE,
157
+ "capture_time_unix": METADATA_UNREAD_VALUE,
158
+ }
159
+
160
+ if isinstance(user_comments, dict):
161
+ metadata["camera"] = user_comments.get("camera", METADATA_UNREAD_VALUE)
162
+
163
+ system = user_comments.get("system")
164
+ if isinstance(system, dict):
165
+ metadata["system"]["hostname"] = system.get(
166
+ "hostname", METADATA_UNREAD_VALUE
167
+ )
168
+ metadata["system"]["microscope-uuid"] = system.get(
169
+ "microscope-uuid", METADATA_UNREAD_VALUE
170
+ )
171
+ metadata["system"]["version"] = system.get("version", METADATA_UNREAD_VALUE)
172
+ metadata["system"]["version_source"] = system.get(
173
+ "version_source", METADATA_UNREAD_VALUE
174
+ )
175
+
176
+ # Capture time
177
+ if capture_time is not None:
178
+ metadata["capture_time"] = datetime.fromtimestamp(capture_time).strftime(
179
+ "%d %B %Y, %H:%M"
180
+ )
181
+ metadata["capture_time_unix"] = capture_time
182
+ return metadata
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3
+ <svg width="100%" height="100%" viewBox="0 0 263 163" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
4
+ <g id="path3773" transform="matrix(0.376342,0,0,0.376342,-8.65757,-85.6939)">
5
+ <path d="M212.85,344.652C234.052,276.913 297.347,227.702 372.047,227.702C446.747,227.702 510.042,276.913 531.245,344.652L531.246,344.656C531.789,346.391 532.304,348.138 532.791,349.896C556.452,421.618 655.593,393.868 655.593,393.868L721.09,563.056L472.356,659.347L430.306,550.728L430.129,550.794L406.853,490.142C408.621,489.464 410.361,488.736 412.072,487.962C419.736,484.232 426.404,480.109 432.198,475.703C447.995,463.209 459.586,446.041 465.257,426.69C473.38,395.32 463.384,366.691 463.101,365.891C449.309,328.959 413.54,302.366 372.047,302.366C330.554,302.366 294.785,328.959 280.994,365.891C280.711,366.689 270.714,395.319 278.838,426.69C284.509,446.041 296.099,463.209 311.897,475.703C317.69,480.109 324.359,484.232 332.023,487.962C333.734,488.736 335.474,489.464 337.242,490.142L337.166,490.34L313.965,550.794L313.788,550.728L271.739,659.347L23.004,563.056L88.501,393.868C88.501,393.868 187.642,421.618 211.303,349.896C211.79,348.138 212.306,346.391 212.849,344.656L212.85,344.652Z" style="fill:rgb(197,36,127);fill-rule:nonzero;"/>
6
+ </g>
7
+ </svg>
@@ -0,0 +1,154 @@
1
+ #let report-title = "Micat Calibration Report"
2
+ #let um = $upright(#sym.mu m)$
3
+ #let um2 = $upright(#sym.mu m^2)$
4
+ #let report-date = datetime.today().display("[day] [month repr:long] [year]")
5
+
6
+ #let calibration_data = json(bytes(sys.inputs.calibration_data))
7
+
8
+ #set document(
9
+ title: report-title,
10
+ )
11
+
12
+ #set page(
13
+ paper: "a4",
14
+ margin: (x: 2.5cm, y: 2cm),
15
+ numbering: "1",
16
+ )
17
+
18
+ #set text(
19
+ font: "Open Sans",
20
+ size: 12pt,
21
+ )
22
+
23
+ #set heading(numbering: "1.")
24
+ #show heading: set block(below: 2em)
25
+
26
+ #set page(
27
+ paper: "a4",
28
+ margin: (x: 2.5cm, y: 2cm),
29
+ footer: context [
30
+ #align(center)[
31
+ #text(size: 9pt, fill: gray)[
32
+ Micat Calibration Report · Micat Version #calibration_data.software_version
33
+ ]
34
+ #linebreak()
35
+ #text(size: 9pt, fill: gray)[
36
+ Page #counter(page).display("1")
37
+ of #counter(page).final().at(0)
38
+ · #report-date
39
+ ]
40
+ ]
41
+ ]
42
+ )
43
+ #grid(
44
+ columns: (auto, auto),
45
+ gutter: 20pt,
46
+ align: horizon+center,
47
+ image("emblem_pink.svg", height: 40pt),
48
+ text(size: 22pt, weight: 700)[#report-title]
49
+ )
50
+
51
+ Microscope name: *#calibration_data.metadata.system.hostname*
52
+
53
+ = USAF Resolution Target Analysis Output
54
+
55
+ Effective size of pixel = #calibration_data.um_per_px #sym.plus.minus #calibration_data.um_per_px_uncert #um
56
+
57
+ Field of view = (#calibration_data.fov0 #sym.plus.minus #calibration_data.fov0_uncert #sym.times #calibration_data.fov1 #sym.plus.minus #calibration_data.fov1_uncert) #um2
58
+
59
+ These uncertainties are from the regression only and may be an under estimation.
60
+
61
+ This is based on an image size of #calibration_data.fov_px.at(0) #sym.times #calibration_data.fov_px.at(1) pixels, and will need scaling for other images.
62
+
63
+ Greyscale input image has hashlib md5 hash #calibration_data.md5_hash
64
+
65
+ Report produced on #report-date
66
+
67
+ Micat analysis software version: #calibration_data.software_version
68
+
69
+ #text(blue)[NOTE: These results are only valid if all elements in the following image are correctly labelled.]
70
+
71
+ #pagebreak()
72
+
73
+ = USAF Resolution Labelled Image
74
+
75
+ #text(blue)[Ensure that the elements have the correct group and element number before using these results.]
76
+
77
+ Smallest labelled element is group #calibration_data.smallest_group, element #calibration_data.smallest_element.
78
+
79
+ #figure(
80
+ caption: [Labelled USAF resolution target used for calibration. Red boxes mark a set of 3 lines, each of which will be surrounded by a dark blue box. Light blue highlights the squares, and green highlights other features including numbers.],
81
+ [
82
+ #align(center)[
83
+ #image("file.jpg", width: 80%, fit:"contain")
84
+ ]
85
+ ]
86
+ )
87
+
88
+ #pagebreak()
89
+
90
+ = USAF Resolution Plots
91
+
92
+
93
+ The spacing plot should be an increasing, linear fit. The residuals should be distributed around y = 0 with no overall trends.
94
+
95
+ #image("regression.png", width:100%)
96
+
97
+ = System Information
98
+
99
+ // Produce the rows from given labels and variables, handling either dicts or strings
100
+ #let render-metadata(label, value) = {
101
+ if type(value) == dictionary {
102
+ let rows = value.pairs().map(((k, v)) => (k, str(v)))
103
+ (table.cell(colspan: 2)[*#label*],) + rows.flatten()
104
+ } else {
105
+ (table.cell(colspan: 2)[*#label*], table.cell(colspan: 2)[#str(value)])
106
+ }
107
+ }
108
+
109
+ #table(
110
+ columns: (auto, 1fr),
111
+ stroke: 0.5pt,
112
+ fill: (_, row) => if calc.odd(row) { luma(245) } else { white },
113
+ table.header(table.cell(colspan: 2, align: center)[*Metadata*]),
114
+ ..render-metadata("Camera", calibration_data.metadata.camera),
115
+ ..render-metadata("Microscope", calibration_data.metadata.system),
116
+ table.cell(colspan: 2)[*Capture Time*],
117
+ [capture_time], [#calibration_data.metadata.capture_time],
118
+ [capture_time_unix], [#str(calibration_data.metadata.capture_time_unix)],
119
+ )
120
+
121
+ #pagebreak()
122
+
123
+
124
+ = Methodology
125
+
126
+ Calibration was performed using an automated analysis of a USAF 1951 resolution target image.
127
+
128
+ The input image is first converted to greyscale and thresholded to identify contours.
129
+ Detected features are classified based on shape: rectangular features with an aspect
130
+ ratio consistent with USAF bars (approximately 5:1) are retained, while other features
131
+ are discarded or labelled separately.
132
+
133
+ Bars are grouped into sets of three based on their relative spacing and alignment,
134
+ forming individual USAF elements. The spacing between bars is measured in pixels, and
135
+ each element is assigned a group and element number based on the known geometric
136
+ progression of the USAF target.
137
+
138
+ The physical spacing corresponding to each element is calculated using the standard
139
+ relationship for line pairs per millimetre:
140
+
141
+ $ 2^{(g + (e - 1)/6)} $
142
+
143
+ where $g$ is the group number and $e$ is the element number.
144
+
145
+ A linear fit is performed between the measured pixel spacing and the known physical
146
+ spacing. The gradient of this fit is used to determine the calibration factor
147
+ (micrometres per pixel), and the associated uncertainty is estimated from the
148
+ covariance of the fit.
149
+
150
+ The field of view is then calculated from the image dimensions and the calibrated
151
+ pixel size.
152
+
153
+ Further implementation details and source code are available at:
154
+ #link("https://gitlab.com/openflexure/micat")[https://gitlab.com/openflexure/micat]