prefab 0.5.1__py3-none-any.whl → 1.0.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.
prefab/geometry.py ADDED
@@ -0,0 +1,302 @@
1
+ """Provides functions for manipulating ndarrays of device geometries."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+
7
+ def normalize(device_array: np.ndarray) -> np.ndarray:
8
+ """
9
+ Normalize the input ndarray to have values between 0 and 1.
10
+
11
+ Parameters
12
+ ----------
13
+ device_array : np.ndarray
14
+ The input array to be normalized.
15
+
16
+ Returns
17
+ -------
18
+ np.ndarray
19
+ The normalized array with values scaled between 0 and 1.
20
+ """
21
+ return (device_array - np.min(device_array)) / (
22
+ np.max(device_array) - np.min(device_array)
23
+ )
24
+
25
+
26
+ def binarize(
27
+ device_array: np.ndarray, eta: float = 0.5, beta: float = np.inf
28
+ ) -> np.ndarray:
29
+ """
30
+ Binarize the input ndarray based on a threshold and a scaling factor.
31
+
32
+ Parameters
33
+ ----------
34
+ device_array : np.ndarray
35
+ The input array to be binarized.
36
+ eta : float, optional
37
+ The threshold value for binarization. Defaults to 0.5.
38
+ beta : float, optional
39
+ The scaling factor for the binarization process. A higher value makes the
40
+ transition sharper. Defaults to np.inf, which results in a hard threshold.
41
+
42
+ Returns
43
+ -------
44
+ np.ndarray
45
+ The binarized array with elements scaled to 0 or 1.
46
+ """
47
+ return (np.tanh(beta * eta) + np.tanh(beta * (device_array - eta))) / (
48
+ np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
49
+ )
50
+
51
+
52
+ def binarize_hard(device_array: np.ndarray, eta: float = 0.5) -> np.ndarray:
53
+ """
54
+ Apply a hard threshold to binarize the input ndarray. The `binarize` function is
55
+ generally preferred for most use cases, but it can create numerical artifacts for
56
+ large beta values.
57
+
58
+ Parameters
59
+ ----------
60
+ device_array : np.ndarray
61
+ The input array to be binarized.
62
+ eta : float, optional
63
+ The threshold value for binarization. Defaults to 0.5.
64
+
65
+ Returns
66
+ -------
67
+ np.ndarray
68
+ The binarized array with elements set to 0 or 1 based on the threshold.
69
+ """
70
+ return np.where(device_array < eta, 0.0, 1.0)
71
+
72
+
73
+ def binarize_sem(sem_array: np.ndarray) -> np.ndarray:
74
+ """
75
+ Binarize a grayscale scanning electron microscope (SEM) image.
76
+
77
+ This function applies Otsu's method to automatically determine the optimal threshold
78
+ value for binarization of a grayscale SEM image.
79
+
80
+ Parameters
81
+ ----------
82
+ sem_array : np.ndarray
83
+ The input SEM image array to be binarized.
84
+
85
+ Returns
86
+ -------
87
+ np.ndarray
88
+ The binarized SEM image array with elements scaled to 0 or 1.
89
+ """
90
+ return cv2.threshold(
91
+ sem_array.astype("uint8"), 0, 1, cv2.THRESH_BINARY + cv2.THRESH_OTSU
92
+ )[1]
93
+
94
+
95
+ def binarize_monte_carlo(
96
+ device_array: np.ndarray,
97
+ threshold_noise_std: float,
98
+ threshold_blur_std: float,
99
+ ) -> np.ndarray:
100
+ """
101
+ Binarize the input ndarray using a Monte Carlo approach with Gaussian blurring.
102
+
103
+ This function applies a dynamic thresholding technique where the threshold value is
104
+ determined by a base value perturbed by Gaussian-distributed random noise. The
105
+ threshold is then spatially varied across the array using Gaussian blurring,
106
+ simulating a potentially more realistic scenario where the threshold is not uniform
107
+ across the device.
108
+
109
+ Parameters
110
+ ----------
111
+ device_array : np.ndarray
112
+ The input array to be binarized.
113
+ threshold_noise_std : float
114
+ The standard deviation of the Gaussian distribution used to generate noise for
115
+ the threshold values. This controls the amount of randomness in the threshold.
116
+ threshold_blur_std : float
117
+ The standard deviation for the Gaussian kernel used in blurring the threshold
118
+ map. This controls the spatial variation of the threshold across the array.
119
+
120
+ Returns
121
+ -------
122
+ np.ndarray
123
+ The binarized array with elements set to 0 or 1 based on the dynamically
124
+ generated threshold.
125
+ """
126
+ device_array = np.squeeze(device_array)
127
+ base_threshold = np.clip(np.random.normal(loc=0.5, scale=0.5 / 2), 0.4, 0.6)
128
+ threshold_noise = np.random.normal(
129
+ loc=0, scale=threshold_noise_std, size=device_array.shape
130
+ )
131
+ spatial_threshold = cv2.GaussianBlur(
132
+ threshold_noise, ksize=(0, 0), sigmaX=threshold_blur_std
133
+ )
134
+ dynamic_threshold = base_threshold + spatial_threshold
135
+ binarized_array = np.where(device_array < dynamic_threshold, 0.0, 1.0)
136
+ binarized_array = np.expand_dims(binarized_array, axis=-1)
137
+ return binarized_array
138
+
139
+
140
+ def ternarize(
141
+ device_array: np.ndarray, eta1: float = 1 / 3, eta2: float = 2 / 3
142
+ ) -> np.ndarray:
143
+ """
144
+ Ternarize the input ndarray based on two thresholds. This function is useful for
145
+ flattened devices with angled sidewalls (i.e., three segments).
146
+
147
+ Parameters
148
+ ----------
149
+ device_array : np.ndarray
150
+ The input array to be ternarized.
151
+ eta1 : float, optional
152
+ The first threshold value for ternarization. Defaults to 1/3.
153
+ eta2 : float, optional
154
+ The second threshold value for ternarization. Defaults to 2/3.
155
+
156
+ Returns
157
+ -------
158
+ np.ndarray
159
+ The ternarized array with elements set to 0, 0.5, or 1 based on the thresholds.
160
+ """
161
+ return np.where(device_array < eta1, 0.0, np.where(device_array >= eta2, 1.0, 0.5))
162
+
163
+
164
+ def trim(device_array: np.ndarray, buffer_thickness: int = 0) -> np.ndarray:
165
+ """
166
+ Trim the input ndarray by removing rows and columns that are completely zero.
167
+
168
+ Parameters
169
+ ----------
170
+ device_array : np.ndarray
171
+ The input array to be trimmed.
172
+ buffer_thickness : int, optional
173
+ The thickness of the buffer to leave around the non-zero elements of the array.
174
+ Defaults to 0, which means no buffer is added.
175
+
176
+ Returns
177
+ -------
178
+ np.ndarray
179
+ The trimmed array, potentially with a buffer around the non-zero elements.
180
+ """
181
+ flattened_device_array = np.squeeze(flatten(device_array))
182
+ nonzero_rows, nonzero_cols = np.nonzero(flattened_device_array)
183
+ row_min = max(nonzero_rows.min() - buffer_thickness, 0)
184
+ row_max = min(
185
+ nonzero_rows.max() + buffer_thickness + 1,
186
+ device_array.shape[0],
187
+ )
188
+ col_min = max(nonzero_cols.min() - buffer_thickness, 0)
189
+ col_max = min(
190
+ nonzero_cols.max() + buffer_thickness + 1,
191
+ device_array.shape[1],
192
+ )
193
+ return device_array[
194
+ row_min:row_max,
195
+ col_min:col_max,
196
+ ]
197
+
198
+
199
+ def blur(device_array: np.ndarray, sigma: float = 1.0) -> np.ndarray:
200
+ """
201
+ Apply Gaussian blur to the input ndarray and normalize the result.
202
+
203
+ Parameters
204
+ ----------
205
+ device_array : np.ndarray
206
+ The input array to be blurred.
207
+ sigma : float, optional
208
+ The standard deviation for the Gaussian kernel. This controls the amount of
209
+ blurring. Defaults to 1.0.
210
+
211
+ Returns
212
+ -------
213
+ np.ndarray
214
+ The blurred and normalized array with values scaled between 0 and 1.
215
+ """
216
+ return np.expand_dims(
217
+ normalize(cv2.GaussianBlur(device_array, ksize=(0, 0), sigmaX=sigma)), axis=-1
218
+ )
219
+
220
+
221
+ def rotate(device_array: np.ndarray, angle: float) -> np.ndarray:
222
+ """
223
+ Rotate the input ndarray by a given angle.
224
+
225
+ Parameters
226
+ ----------
227
+ device_array : np.ndarray
228
+ The input array to be rotated.
229
+ angle : float
230
+ The angle of rotation in degrees. Positive values mean counter-clockwise
231
+ rotation.
232
+
233
+ Returns
234
+ -------
235
+ np.ndarray
236
+ The rotated array.
237
+ """
238
+ center = (device_array.shape[1] / 2, device_array.shape[0] / 2)
239
+ rotation_matrix = cv2.getRotationMatrix2D(center=center, angle=angle, scale=1)
240
+ rotated_device_array = cv2.warpAffine(
241
+ flatten(device_array),
242
+ M=rotation_matrix,
243
+ dsize=(device_array.shape[1], device_array.shape[0]),
244
+ )
245
+ return np.expand_dims(rotated_device_array, axis=-1)
246
+
247
+
248
+ def erode(device_array: np.ndarray, kernel_size: int) -> np.ndarray:
249
+ """
250
+ Erode the input ndarray using a specified kernel size and number of iterations.
251
+
252
+ Parameters
253
+ ----------
254
+ device_array : np.ndarray
255
+ The input array representing the device geometry to be eroded.
256
+ kernel_size : int
257
+ The size of the kernel used for erosion.
258
+
259
+ Returns
260
+ -------
261
+ np.ndarray
262
+ The eroded array.
263
+ """
264
+ kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
265
+ return np.expand_dims(cv2.erode(device_array, kernel=kernel), axis=-1)
266
+
267
+
268
+ def dilate(device_array: np.ndarray, kernel_size: int) -> np.ndarray:
269
+ """
270
+ Dilate the input ndarray using a specified kernel size.
271
+
272
+ Parameters
273
+ ----------
274
+ device_array : np.ndarray
275
+ The input array representing the device geometry to be dilated.
276
+ kernel_size : int
277
+ The size of the kernel used for dilation.
278
+
279
+ Returns
280
+ -------
281
+ np.ndarray
282
+ The dilated array.
283
+ """
284
+ kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
285
+ return np.expand_dims(cv2.dilate(device_array, kernel=kernel), axis=-1)
286
+
287
+
288
+ def flatten(device_array: np.ndarray) -> np.ndarray:
289
+ """
290
+ Flatten the input ndarray by summing the vertical layers and normalizing the result.
291
+
292
+ Parameters
293
+ ----------
294
+ device_array : np.ndarray
295
+ The input array to be flattened.
296
+
297
+ Returns
298
+ -------
299
+ np.ndarray
300
+ The flattened array with values scaled between 0 and 1.
301
+ """
302
+ return normalize(np.sum(device_array, axis=-1, keepdims=True))
prefab/models.py ADDED
@@ -0,0 +1,114 @@
1
+ """Models for the PreFab library."""
2
+
3
+ import json
4
+ from datetime import date
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Fab(BaseModel):
10
+ """
11
+ Represents a fabrication process in the PreFab model library.
12
+
13
+ Parameters
14
+ ----------
15
+ foundry : str
16
+ The name of the foundry where the fabrication process takes place.
17
+ process : str
18
+ The specific process used in the fabrication.
19
+ material : str
20
+ The material used in the fabrication process.
21
+ technology : str
22
+ The technology used in the fabrication process.
23
+ thickness : int
24
+ The thickness of the material used, measured in nanometers.
25
+ has_sidewall : bool
26
+ Indicates whether the fabrication has angled sidewalls.
27
+ """
28
+
29
+ foundry: str
30
+ process: str
31
+ material: str
32
+ technology: str
33
+ thickness: int
34
+ has_sidewall: bool
35
+
36
+
37
+ class Model(BaseModel):
38
+ """
39
+ Represents a model of a fabrication process including versioning and dataset detail.
40
+
41
+ Attributes
42
+ ----------
43
+ fab : Fab
44
+ An instance of the Fab class representing the fabrication details.
45
+ version : str
46
+ The version identifier of the model.
47
+ version_date : date
48
+ The release date of this version of the model.
49
+ dataset : str
50
+ The identifier for the dataset used in this model.
51
+ dataset_date : date
52
+ The date when the dataset was last updated or released.
53
+ tag : str
54
+ An optional tag for additional categorization or notes.
55
+
56
+ Methods
57
+ -------
58
+ to_json()
59
+ Serializes the model instance to a JSON formatted string.
60
+ """
61
+
62
+ fab: Fab
63
+ version: str
64
+ version_date: date
65
+ dataset: str
66
+ dataset_date: date
67
+ tag: str
68
+
69
+ def to_json(self):
70
+ return json.dumps(self.dict(), default=str)
71
+
72
+
73
+ ANT_NanoSOI = Fab(
74
+ foundry="ANT",
75
+ process="NanoSOI",
76
+ material="SOI",
77
+ technology="E-Beam",
78
+ thickness=220,
79
+ has_sidewall=False,
80
+ )
81
+
82
+ ANT_SiN = Fab(
83
+ foundry="ANT",
84
+ process="SiN",
85
+ material="SiN",
86
+ technology="E-Beam",
87
+ thickness=400,
88
+ has_sidewall=True,
89
+ )
90
+
91
+ ANT_NanoSOI_ANF1_d9 = Model(
92
+ fab=ANT_NanoSOI,
93
+ version="ANF1",
94
+ version_date=date(2024, 5, 6),
95
+ dataset="d9",
96
+ dataset_date=date(2024, 2, 6),
97
+ tag="",
98
+ )
99
+
100
+ ANT_SiN_ANF1_d1 = Model(
101
+ fab=ANT_SiN,
102
+ version="ANF1",
103
+ version_date=date(2024, 5, 6),
104
+ dataset="d1",
105
+ dataset_date=date(2024, 1, 31),
106
+ tag="",
107
+ )
108
+
109
+ models = dict(
110
+ ANT_NanoSOI=ANT_NanoSOI_ANF1_d9,
111
+ ANT_NanoSOI_ANF1_d9=ANT_NanoSOI_ANF1_d9,
112
+ ANT_SiN=ANT_SiN_ANF1_d1,
113
+ ANT_SiN_ANF1_d1=ANT_SiN_ANF1_d1,
114
+ )
prefab/read.py ADDED
@@ -0,0 +1,293 @@
1
+ """Provides functions to create Devices from various data sources."""
2
+
3
+ import re
4
+
5
+ import cv2
6
+ import gdstk
7
+ import numpy as np
8
+
9
+ from . import geometry
10
+ from .device import Device
11
+
12
+
13
+ def from_ndarray(
14
+ ndarray: np.ndarray, resolution: int = 1, binarize: bool = True, **kwargs
15
+ ) -> Device:
16
+ """
17
+ Create a Device from an ndarray.
18
+
19
+ Parameters
20
+ ----------
21
+ ndarray : np.ndarray
22
+ The input array representing the device layout.
23
+ resolution : int, optional
24
+ The resolution of the ndarray in nanometers per pixel, defaulting to 1 nm per
25
+ pixel. If specified, the input array will be resized based on this resolution to
26
+ match the desired physical size.
27
+ binarize : bool, optional
28
+ If True, the input array will be binarized (converted to binary values) before
29
+ conversion to a Device object. This is useful for processing grayscale images
30
+ into binary masks. Defaults to True.
31
+ **kwargs
32
+ Additional keyword arguments to be passed to the Device constructor.
33
+
34
+ Returns
35
+ -------
36
+ Device
37
+ A Device object representing the input array, after optional resizing and
38
+ binarization.
39
+ """
40
+ device_array = ndarray
41
+ device_array = cv2.resize(device_array, dsize=(0, 0), fx=resolution, fy=resolution)
42
+ if binarize:
43
+ device_array = geometry.binarize_hard(device_array)
44
+ return Device(device_array=device_array, **kwargs)
45
+
46
+
47
+ def from_img(
48
+ img_path: str, img_width_nm: int = None, binarize: bool = True, **kwargs
49
+ ) -> Device:
50
+ """
51
+ Create a Device from an image file.
52
+
53
+ Parameters
54
+ ----------
55
+ img_path : str
56
+ The path to the image file to be converted into a Device object.
57
+ img_width_nm : int, optional
58
+ The desired width of the device in nanometers. If specified, the image will be
59
+ resized to this width while maintaining aspect ratio. If None, no resizing is
60
+ performed.
61
+ binarize : bool, optional
62
+ If True, the image will be binarized (converted to binary values) before
63
+ conversion to a Device object. This is useful for converting grayscale images
64
+ into binary masks. Defaults to True.
65
+ **kwargs
66
+ Additional keyword arguments to be passed to the Device constructor.
67
+
68
+ Returns
69
+ -------
70
+ Device
71
+ A Device object representing the processed image, after optional resizing and
72
+ binarization.
73
+ """
74
+ device_array = cv2.imread(img_path, flags=cv2.IMREAD_GRAYSCALE) / 255
75
+ if img_width_nm is not None:
76
+ scale = img_width_nm / device_array.shape[1]
77
+ device_array = cv2.resize(device_array, dsize=(0, 0), fx=scale, fy=scale)
78
+ if binarize:
79
+ device_array = geometry.binarize_hard(device_array)
80
+ return Device(device_array=device_array, **kwargs)
81
+
82
+
83
+ def from_gds(
84
+ gds_path: str,
85
+ cell_name: str,
86
+ gds_layer: tuple[int, int] = (1, 0),
87
+ bounds: tuple[tuple[int, int], tuple[int, int]] = None,
88
+ **kwargs,
89
+ ):
90
+ """
91
+ Create a Device from a GDS cell.
92
+
93
+ Parameters
94
+ ----------
95
+ gds_path : str
96
+ The file path to the GDS file.
97
+ cell_name : str
98
+ The name of the cell within the GDS file to be converted into a Device object.
99
+ gds_layer : tuple[int, int], optional
100
+ A tuple specifying the layer and datatype to be used from the GDS file. Defaults
101
+ to (1, 0).
102
+ bounds : tuple[tuple[int, int], tuple[int, int]], optional
103
+ A tuple specifying the bounds for cropping the cell before conversion, formatted
104
+ as ((min_x, min_y), (max_x, max_y)), in units of the GDS file. If None, the
105
+ entire cell is used.
106
+ **kwargs
107
+ Additional keyword arguments to be passed to the Device constructor.
108
+
109
+ Returns
110
+ -------
111
+ Device
112
+ A Device object representing the specified cell from the GDS file, after
113
+ processing based on the specified layer.
114
+ """
115
+ gdstk_library = gdstk.read_gds(gds_path)
116
+ gdstk_cell = gdstk_library[cell_name]
117
+ device_array = _gdstk_to_device_array(
118
+ gdstk_cell=gdstk_cell, gds_layer=gds_layer, bounds=bounds
119
+ )
120
+ return Device(device_array=device_array, **kwargs)
121
+
122
+
123
+ def from_gdstk(
124
+ gdstk_cell: gdstk.Cell,
125
+ gds_layer: tuple[int, int] = (1, 0),
126
+ bounds: tuple[tuple[int, int], tuple[int, int]] = None,
127
+ **kwargs,
128
+ ):
129
+ """
130
+ Create a Device from a gdstk cell.
131
+
132
+ Parameters
133
+ ----------
134
+ gdstk_cell : gdstk.Cell
135
+ The gdstk.Cell object to be converted into a Device object.
136
+ gds_layer : tuple[int, int], optional
137
+ A tuple specifying the layer and datatype to be used. Defaults to (1, 0).
138
+ bounds : tuple[tuple[int, int], tuple[int, int]], optional
139
+ A tuple specifying the bounds for cropping the cell before conversion, formatted
140
+ as ((min_x, min_y), (max_x, max_y)), in units of the GDS file. If None, the
141
+ entire cell is used.
142
+ **kwargs
143
+ Additional keyword arguments to be passed to the Device constructor.
144
+
145
+ Returns
146
+ -------
147
+ Device
148
+ A Device object representing the gdstk.Cell, after processing based on the
149
+ specified layer.
150
+ """
151
+ device_array = _gdstk_to_device_array(
152
+ gdstk_cell=gdstk_cell, gds_layer=gds_layer, bounds=bounds
153
+ )
154
+ return Device(device_array=device_array, **kwargs)
155
+
156
+
157
+ def _gdstk_to_device_array(
158
+ gdstk_cell: gdstk.Cell,
159
+ gds_layer: tuple[int, int] = (1, 0),
160
+ bounds: tuple[tuple[int, int], tuple[int, int]] = None,
161
+ ) -> np.ndarray:
162
+ polygons = gdstk_cell.get_polygons(layer=gds_layer[0], datatype=gds_layer[1])
163
+ if bounds:
164
+ polygons = gdstk.slice(
165
+ polygons, position=(bounds[0][0], bounds[1][0]), axis="x"
166
+ )[1]
167
+ polygons = gdstk.slice(
168
+ polygons, position=(bounds[0][1], bounds[1][1]), axis="y"
169
+ )[1]
170
+ bounds = tuple(tuple(x * 1000 for x in sub_tuple) for sub_tuple in bounds)
171
+ else:
172
+ bounds = tuple(
173
+ tuple(1000 * x for x in sub_tuple)
174
+ for sub_tuple in gdstk_cell.bounding_box()
175
+ )
176
+ contours = [
177
+ np.array(
178
+ [
179
+ [
180
+ [
181
+ int(1000 * vertex[0] - bounds[0][0]),
182
+ int(1000 * vertex[1] - bounds[0][1]),
183
+ ]
184
+ ]
185
+ for vertex in polygon.points
186
+ ],
187
+ dtype=np.int32,
188
+ )
189
+ for polygon in polygons
190
+ ]
191
+ device_array = np.zeros(
192
+ (int(bounds[1][1] - bounds[0][1]), int(bounds[1][0] - bounds[0][0]))
193
+ )
194
+ cv2.fillPoly(img=device_array, pts=contours, color=(1, 1, 1))
195
+ device_array = np.flipud(device_array)
196
+ return device_array
197
+
198
+
199
+ def from_sem(
200
+ sem_path: str,
201
+ sem_resolution: float = None,
202
+ sem_resolution_key: str = None,
203
+ binarize: bool = True,
204
+ bounds: tuple[tuple[int, int], tuple[int, int]] = None,
205
+ **kwargs,
206
+ ) -> Device:
207
+ """
208
+ Create a Device from a scanning electron microscope (SEM) image file.
209
+
210
+ Parameters
211
+ ----------
212
+ sem_path : str
213
+ The file path to the SEM image.
214
+ sem_resolution : float, optional
215
+ The resolution of the SEM image in nanometers per pixel. If not provided, it
216
+ will be extracted from the image metadata using the `sem_resolution_key`.
217
+ sem_resolution_key : str, optional
218
+ The key to look for in the SEM image metadata to extract the resolution.
219
+ Required if `sem_resolution` is not provided.
220
+ binarize : bool, optional
221
+ If True, the SEM image will be binarized (converted to binary values) before
222
+ conversion to a Device object. This is needed for processing grayscale images
223
+ into binary masks. Defaults to True.
224
+ bounds : tuple[tuple[int, int], tuple[int, int]], optional
225
+ A tuple specifying the bounds for cropping the image before conversion,
226
+ formatted as ((min_x, min_y), (max_x, max_y)). If None, the entire image is
227
+ used.
228
+ **kwargs
229
+ Additional keyword arguments to be passed to the Device constructor.
230
+
231
+ Returns
232
+ -------
233
+ Device
234
+ A Device object representing the processed SEM image.
235
+
236
+ Raises
237
+ ------
238
+ ValueError
239
+ If neither `sem_resolution` nor `sem_resolution_key` is provided.
240
+ """
241
+ if sem_resolution is None and sem_resolution_key is not None:
242
+ sem_resolution = get_sem_resolution(sem_path, sem_resolution_key)
243
+ elif sem_resolution is None:
244
+ raise ValueError("Either sem_resolution or resolution_key must be provided.")
245
+
246
+ device_array = cv2.imread(sem_path, flags=cv2.IMREAD_GRAYSCALE)
247
+ if sem_resolution is not None:
248
+ device_array = cv2.resize(
249
+ device_array, dsize=(0, 0), fx=sem_resolution, fy=sem_resolution
250
+ )
251
+ if bounds is not None:
252
+ device_array = device_array[
253
+ -bounds[1][1] : -bounds[0][1], bounds[0][0] : bounds[1][0]
254
+ ]
255
+ if binarize:
256
+ device_array = geometry.binarize_sem(device_array)
257
+ return Device(device_array=device_array, **kwargs)
258
+
259
+
260
+ def get_sem_resolution(sem_path: str, sem_resolution_key: str) -> float:
261
+ """
262
+ Extracts the resolution of a scanning electron microscope (SEM) image from its
263
+ metadata.
264
+
265
+ Parameters
266
+ ----------
267
+ sem_path : str
268
+ The file path to the SEM image.
269
+ sem_resolution_key : str
270
+ The key to look for in the SEM image metadata to extract the resolution.
271
+
272
+ Returns
273
+ -------
274
+ float
275
+ The resolution of the SEM image in nanometers per pixel.
276
+
277
+ Raises
278
+ ------
279
+ ValueError
280
+ If the resolution key is not found in the SEM image metadata.
281
+ """
282
+ with open(sem_path, "rb") as file:
283
+ resolution_key_bytes = sem_resolution_key.encode("utf-8")
284
+ for line in file:
285
+ if resolution_key_bytes in line:
286
+ line_str = line.decode("utf-8")
287
+ match = re.search(r"-?\d+(\.\d+)?", line_str)
288
+ if match:
289
+ value = float(match.group())
290
+ if value > 100:
291
+ value /= 1000
292
+ return value
293
+ raise ValueError(f"Resolution key '{sem_resolution_key}' not found in {sem_path}.")