diff --git a/catkit2/services/accufiz_interferometer/accufiz_interferometer.py b/catkit2/services/accufiz_interferometer/accufiz_interferometer.py new file mode 100644 index 00000000..c119c4f5 --- /dev/null +++ b/catkit2/services/accufiz_interferometer/accufiz_interferometer.py @@ -0,0 +1,320 @@ +import os +import h5py +import time +import requests +import uuid +import numpy as np +from scipy import ndimage +import math +from astropy.io import fits +from glob import glob +from catkit2.testbed.service import Service +import os +import threading + + +def rotate_and_flip_image(data, theta, flip): + """ + Rotate and/or flip the image data. + + Parameters + ---------- + data : numpy.ndarray + Numpy array of image data. + theta : int + Rotation angle in degrees. + flip : bool + If True, flip the image horizontally. + + Returns + ------- + numpy.ndarray + Modified image after rotation and/or flip. + """ + data_corr = np.rot90(data, int(theta / 90)) + + if flip: + data_corr = np.fliplr(data_corr) + + return data_corr + + +class AccufizInterferometer(Service): + """ + Service class for the 4D Technologies Accufiz Interferometer. + It handles image acquisition, processing, and data handling. + This requires 4D Insight Web Service to be running, and the 4Sight software to be set to listening. + """ + NUM_FRAMES_IN_BUFFER = 20 + instrument_lib = requests + + def __init__(self): + """ + Initialize the Accufiz Interferometer Simulator with configuration and set up data streams. + """ + super().__init__('accufiz_interferometer') + + # Essential configurations + self.mask = self.config['mask'] + self.server_path = self.config['server_path'] + self.local_path = self.config['local_path'] + + # Optional configurations + self.ip = self.config.get('ip_address', 'localhost:8080') + self.calibration_data_package = self.config.get('calibration_data_package', '') + self.timeout = self.config.get('timeout', 10000) + self.post_save_sleep = self.config.get('post_save_sleep', 1) + self.file_mode = self.config.get('file_mode', True) + self.image_height = self.config.get('height', 1967) + self.image_width = self.config.get('width', 1970) + self.config_id = self.config.get('config_id', 'accufiz') + self.save_h5 = self.config.get('save_h5', True) + self.save_fits = self.config.get('save_fits', False) + self.num_frames_avg = self.config.get('num_avg', 2) + self.fliplr = self.config.get('fliplr', True) + self.rotate = self.config.get('rotate', 0) + + # Set the 4D timeout. + self.html_prefix = f"http://{self.ip}/WebService4D/WebService4D.asmx" + set_timeout_string = f"{self.html_prefix}/SetTimeout?timeOut={self.timeout}" + self.get(set_timeout_string) + + # Set the mask + self.set_mask() + + # Create data streams. + self.detector_masks = self.make_data_stream('detector_masks', 'uint8', [self.image_height, self.image_width], self.NUM_FRAMES_IN_BUFFER) + self.images = self.make_data_stream('images', 'float32', [self.image_height, self.image_width], self.NUM_FRAMES_IN_BUFFER) + self.is_acquiring = self.make_data_stream('is_acquiring', 'int8', [1], 20) + self.is_acquiring.submit_data(np.array([0], dtype='int8')) + self.should_be_acquiring = threading.Event() + self.should_be_acquiring.clear() + + self.make_command('take_measurement', self.take_measurement) + self.make_command('start_acquisition', self.start_acquisition) + self.make_command('end_acquisition', self.end_acquisition) + + def set_mask(self): + """ + Set the mask for the simulator. The mask must be local to the 4D computer in a specified directory. + + Returns + ------- + bool + True if the mask is successfully set. + """ + filemask = self.mask + typeofmask = "Detector" + parammask = {"maskType": typeofmask, "fileName": filemask} + set_mask_string = f"{self.html_prefix}/SetMask" + + self.post(set_mask_string, data=parammask) + + return True + + def get(self, url, params=None, **kwargs): + """ + HTTP GET request. + + Parameters + ---------- + url : str + URL to send the GET request to. + params : dict, optional + Parameters for the request. Defaults to None. + + Returns + ------- + resp + Response object. + + Raises + ------ + RuntimeError + If the GET request fails. + """ + resp = self.instrument_lib.get(url, params=params, **kwargs) + if resp.status_code != 200: + raise RuntimeError(f"{self.config_id} GET error: {resp.status_code}: {resp.text}") + return resp + + def post(self, url, data=None, json=None, **kwargs): + """ + HTTP POST request. + + Parameters + ---------- + url : str + URL to send the POST request to. + data : dict, optional + Data to send in the request. Defaults to None. + json : dict, optional + JSON data to send in the request. Defaults to None. + + Returns + ------- + resp + Response object. + + Raises + ------ + RuntimeError + If the POST request fails. + """ + resp = self.instrument_lib.post(url, data=data, json=json, **kwargs) + if resp.status_code != 200: + raise RuntimeError(f"{self.config_id} POST error: {resp.status_code}: {resp.text}") + time.sleep(self.post_save_sleep) + return resp + + def take_measurement(self): + """ + Take a measurement, save the data, and return the processed image. + + Returns + ------- + numpy.ndarray + Processed image data after measurement. + + Raises + ------ + RuntimeError + If data acquisition or saving fails. + """ + # Send request to take data. + resp = self.post(f"{self.html_prefix}/AverageMeasure", data={"count": int(self.num_frames_avg)}) + + if "success" not in resp.text: + raise RuntimeError(f"{self.config_id}: Failed to take data - {resp.text}.") + + filename = str(uuid.uuid4()) + server_file_path = os.path.join(self.server_path, filename) + local_file_path = os.path.join(self.local_path, filename) + + # This line is here because when sent through webservice slashes tend + # to disappear. If we sent in parameter a path with only one slash, + # they disappear + server_file_path = server_file_path.replace('\\', '/') + server_file_path = server_file_path.replace('/', '\\\\') + + # Send request to save data. + self.post(f"{self.html_prefix}/SaveMeasurement", data={"fileName": server_file_path}) + + if not glob(f"{local_file_path}.h5"): + raise RuntimeError(f"{self.config_id}: Failed to save measurement data to '{local_file_path}'.") + + local_file_path = local_file_path if local_file_path.endswith(".h5") else f"{local_file_path}.h5" + self.log.info(f"{self.config_id}: Succeeded to save measurement data to '{local_file_path}'") + + mask = np.array(h5py.File(local_file_path, 'r').get('measurement0').get('Detectormask', 1)) + img = np.array(h5py.File(local_file_path, 'r').get('measurement0').get('genraw').get('data')) * mask + + self.detector_masks.submit_data(mask.astype(np.uint8)) + + image = self.convert_h5_to_fits(local_file_path, rotate=self.rotate, fliplr=self.fliplr, mask=mask, img=img, create_fits=self.save_fits) + + # Remove HDF5 file if not required + if (not self.save_h5) and os.path.exists(local_file_path): + os.remove(local_file_path) + + return image + + @staticmethod + def convert_h5_to_fits(filepath, rotate, fliplr, img, mask, wavelength=632.8, create_fits=False): + """ + Convert HDF5 data to FITS format and process image data. + + Parameters + ---------- + filepath : str + Filepath for the HDF5 data. + rotate : int + Rotation angle in degrees. + fliplr : bool + If True, flip the image horizontally. + img : numpy.ndarray + Image data to be processed. + mask : numpy.ndarray + Mask data to be applied. + wavelength : float, optional + Wavelength for scaling, default is 632.8 nm. + create_fits : bool, optional + If True, save the processed image as a FITS file. + + Returns + ------- + numpy.ndarray + Processed image data. + """ + filepath = filepath if filepath.endswith(".h5") else f"{filepath}.h5" + fits_filepath = f"{os.path.splitext(filepath)[0]}.fits" + + mask = np.array(h5py.File(filepath, 'r').get('measurement0').get('Detectormask', 1)) + img = np.array(h5py.File(filepath, 'r').get('measurement0').get('genraw').get('data')) * mask + + if create_fits: + fits.PrimaryHDU(mask).writeto(fits_filepath, overwrite=True) + + radiusmask = np.int64(np.sqrt(np.sum(mask) / math.pi)) + center = ndimage.measurements.center_of_mass(mask) + + image = np.clip(img, -10, +10)[ + np.int64(center[0]) - radiusmask:np.int64(center[0]) + radiusmask - 1, + np.int64(center[1]) - radiusmask:np.int64(center[1]) + radiusmask - 1 + ] + + # Apply the rotation and flips. + image = rotate_and_flip_image(image, rotate, fliplr) + + # Convert waves to nanometers. + image = image * wavelength + + if create_fits: + fits_hdu = fits.PrimaryHDU(image) + fits_hdu.writeto(fits_filepath, overwrite=True) + + return image + + def main(self): + """ + Main loop to manage data acquisition and processing. + """ + while not self.should_shut_down: + if self.should_be_acquiring.wait(0.05): + self.acquisition_loop() + + def acquisition_loop(self): + """ + Handle continuous data acquisition while the service is running. + """ + try: + self.is_acquiring.submit_data(np.array([1], dtype='int8')) + + while self.should_be_acquiring.is_set() and not self.should_shut_down: + img = self.take_measurement() + + has_correct_parameters = np.allclose(self.images.shape, img.shape) + + if not has_correct_parameters: + self.images.update_parameters('float32', img.shape, 20) + + self.images.submit_data(img.astype('float32')) + finally: + self.is_acquiring.submit_data(np.array([0], dtype='int8')) + + def start_acquisition(self): + """ + Start the data acquisition process. + """ + self.should_be_acquiring.set() + + def end_acquisition(self): + """ + End the data acquisition process. + """ + self.should_be_acquiring.clear() + + +if __name__ == '__main__': + service = AccufizInterferometer() + service.run() diff --git a/catkit2/services/accufiz_interferometer_sim/accufiz_interferometer_sim.py b/catkit2/services/accufiz_interferometer_sim/accufiz_interferometer_sim.py new file mode 100644 index 00000000..7cfa0925 --- /dev/null +++ b/catkit2/services/accufiz_interferometer_sim/accufiz_interferometer_sim.py @@ -0,0 +1,375 @@ +import os +import h5py +import time +import requests +import uuid +import numpy as np +from scipy import ndimage +import math +from astropy.io import fits +from glob import glob +from catkit2.testbed.service import Service +import tempfile +import os +import threading + +class sim_response: + """ + Simulated response for HTTP requests used in the Accufiz Interferometer Simulator. + + Attributes + ---------- + text : str + Simulated response text, defaults to 'success'. + """ + text = 'success' + + +def generate_circle_array(radius=1, h=256, w=256): + """ + Generate a dummy mask as a numpy ndarray of size (h, w). + + Parameters + ---------- + radius : int, optional + Proportion of the circle, default is 1. + h : int, optional + Height of the rectangle, default is 256. + w : int, optional + Width of the rectangle, default is 256. + + Returns + ------- + numpy.ndarray + Dummy mask as a uint8 array, representing a circle of specified radius. + """ + x = np.linspace(-1, 1, w) + y = np.linspace(-1, 1, h) + xx, yy = np.meshgrid(x, y) + distances = np.sqrt(xx**2 + yy**2) + circle_array = np.where(distances <= radius, 1, 0) + + return circle_array.astype('uint8') + + +def rotate_and_flip_image(data, theta, flip): + """ + Rotate and/or flip the image data. + + Parameters + ---------- + data : numpy.ndarray + Numpy array of image data. + theta : int + Rotation angle in degrees. + flip : bool + If True, flip the image horizontally. + + Returns + ------- + numpy.ndarray + Modified image after rotation and/or flip. + """ + data_corr = np.rot90(data, int(theta / 90)) + + if flip: + data_corr = np.fliplr(data_corr) + + return data_corr + + +class AccufizInterferometerSim(Service): + """ + Simulated Service class for the 4D Technologies Accufiz Interferometer. + It handles image acquisition, processing, and data handling. + This requires 4D Insight Web Service to be running, and the 4Sight software to be set to listening. + """ + + NUM_FRAMES_IN_BUFFER = 20 + instrument_lib = requests + + def __init__(self): + """ + Initialize the Accufiz Interferometer Simulator with configuration and set up data streams. + """ + super().__init__('accufiz_interferometer_sim') + + # Essential configurations + self.mask = self.config['mask'] + self.server_path = self.config['server_path'] + self.local_path = self.config['local_path'] + + # Optional configurations + self.ip = self.config.get('ip_address', 'localhost:8080') + self.calibration_data_package = self.config.get('calibration_data_package', '') + self.timeout = self.config.get('timeout', 10000) + self.post_save_sleep = self.config.get('post_save_sleep', 1) + self.file_mode = self.config.get('file_mode', True) + self.image_height = self.config.get('height', 1967) + self.image_width = self.config.get('width', 1970) + self.config_id = self.config.get('config_id', 'accufiz') + self.save_h5 = self.config.get('save_h5', True) + self.save_fits = self.config.get('save_fits', False) + self.num_frames_avg = self.config.get('num_avg', 2) + self.fliplr = self.config.get('fliplr', True) + self.rotate = self.config.get('rotate', 0) + self.sim_data = self.config.get('sim_data', None) + + # Set the 4D timeout. + self.html_prefix = f"http://{self.ip}/WebService4D/WebService4D.asmx" + set_timeout_string = f"{self.html_prefix}/SetTimeout?timeOut={self.timeout}" + self.get(set_timeout_string) + + # Set the mask + self.set_mask() + + # Create data streams. + self.detector_masks = self.make_data_stream('detector_masks', 'uint8', [self.image_height, self.image_width], self.NUM_FRAMES_IN_BUFFER) + self.images = self.make_data_stream('images', 'float32', [self.image_height, self.image_width], self.NUM_FRAMES_IN_BUFFER) + self.is_acquiring = self.make_data_stream('is_acquiring', 'int8', [1], 20) + self.is_acquiring.submit_data(np.array([0], dtype='int8')) + self.should_be_acquiring = threading.Event() + self.should_be_acquiring.clear() + + self.make_command('take_measurement', self.take_measurement) + self.make_command('start_acquisition', self.start_acquisition) + self.make_command('end_acquisition', self.end_acquisition) + + def set_mask(self): + """ + Set the mask for the simulator. The mask must be local to the 4D computer in a specified directory. + + Returns + ------- + bool + True if the mask is successfully set. + """ + filemask = self.mask + typeofmask = "Detector" + parammask = {"maskType": typeofmask, "fileName": filemask} + set_mask_string = f"{self.html_prefix}/SetMask" + + self.post(set_mask_string, data=parammask) + + return True + + def get(self, url, params=None, **kwargs): + """ + HTTP GET request. + + Parameters + ---------- + url : str + URL to send the GET request to. + params : dict, optional + Parameters for the request. Defaults to None. + + Returns + ------- + resp + Response object. + + Raises + ------ + RuntimeError + If the GET request fails. + """ + return sim_response() + + def post(self, url, data=None, json=None, **kwargs): + """ + HTTP POST request. + + Parameters + ---------- + url : str + URL to send the POST request to. + data : dict, optional + Data to send in the request. Defaults to None. + json : dict, optional + JSON data to send in the request. Defaults to None. + + Returns + ------- + resp + Response object. + + Raises + ------ + RuntimeError + If the POST request fails. + """ + + time.sleep(self.post_save_sleep) + return sim_response() + + def take_measurement(self): + """ + Take a measurement, save the data, and return the processed image. + + Returns + ------- + numpy.ndarray + Processed image data after measurement. + + Raises + ------ + RuntimeError + If data acquisition or saving fails. + """ + + # Send request to take data. + resp = self.post(f"{self.html_prefix}/AverageMeasure", data={"count": int(self.num_frames_avg)}) + + if "success" not in resp.text: + raise RuntimeError(f"{self.config_id}: Failed to take data - {resp.text}.") + + filename = str(uuid.uuid4()) + server_file_path = os.path.join(self.server_path, filename) + temp_file = None + + if self.sim_data is None: + # Create a temporary file with fake data + # this way we don't need to store data in the catkit repo + temp_file = tempfile.mkdtemp() + local_file_path = temp_file + fname = local_file_path + '.h5' + tmph5f = h5py.File(fname, 'w') + tmph5f['measurement0/Detectormask'] = generate_circle_array(radius=1, h=self.image_height, w=self.image_width) + tmph5f['measurement0/genraw/data'] = np.random.rand(self.image_height, self.image_width) + tmph5f.close() + + else: + local_file_path = self.sim_data.replace('.h5', '') + + # This line is here because when sent through webservice slashes tend + # to disappear. If we sent in parameter a path with only one slash, + # they disappear + server_file_path = server_file_path.replace('\\', '/') + server_file_path = server_file_path.replace('/', '\\\\') + + # Send request to save data. + self.post(f"{self.html_prefix}/SaveMeasurement", data={"fileName": server_file_path}) + + if not glob(f"{local_file_path}.h5"): + raise RuntimeError(f"{self.config_id}: Failed to save measurement data to '{local_file_path}'.") + + local_file_path = local_file_path if local_file_path.endswith(".h5") else f"{local_file_path}.h5" + self.log.info(f"{self.config_id}: Succeeded to save measurement data to '{local_file_path}'") + + mask = np.array(h5py.File(local_file_path, 'r').get('measurement0').get('Detectormask', 1)) + img = np.array(h5py.File(local_file_path, 'r').get('measurement0').get('genraw').get('data')) * mask + + self.detector_masks.submit_data(mask.astype(np.uint8)) + + image = self.convert_h5_to_fits(local_file_path, rotate=self.rotate, fliplr=self.fliplr, mask=mask, img=img, create_fits=self.save_fits) + + if temp_file: + fitsfile = local_file_path.replace('.h5', '.fits') + if os.path.exists(local_file_path): + os.remove(local_file_path) + if os.path.exists(fitsfile): + os.remove(fitsfile) + self.log.info('cleaning up temporary simulated files') + + return image + + @staticmethod + def convert_h5_to_fits(filepath, rotate, fliplr, img, mask, wavelength=632.8, create_fits=False): + """ + Convert HDF5 data to FITS format and process image data. + + Parameters + ---------- + filepath : str + Filepath for the HDF5 data. + rotate : int + Rotation angle in degrees. + fliplr : bool + If True, flip the image horizontally. + img : numpy.ndarray + Image data to be processed. + mask : numpy.ndarray + Mask data to be applied. + wavelength : float, optional + Wavelength for scaling, default is 632.8 nm. + create_fits : bool, optional + If True, save the processed image as a FITS file. + + Returns + ------- + numpy.ndarray + Processed image data. + """ + filepath = filepath if filepath.endswith(".h5") else f"{filepath}.h5" + fits_filepath = f"{os.path.splitext(filepath)[0]}.fits" + + mask = np.array(h5py.File(filepath, 'r').get('measurement0').get('Detectormask', 1)) + img = np.array(h5py.File(filepath, 'r').get('measurement0').get('genraw').get('data')) * mask + + if create_fits: + fits.PrimaryHDU(mask).writeto(fits_filepath, overwrite=True) + + radiusmask = np.int64(np.sqrt(np.sum(mask) / math.pi)) + center = ndimage.measurements.center_of_mass(mask) + + image = np.clip(img, -10, +10)[ + np.int64(center[0]) - radiusmask:np.int64(center[0]) + radiusmask - 1, + np.int64(center[1]) - radiusmask:np.int64(center[1]) + radiusmask - 1 + ] + + # Apply the rotation and flips. + image = rotate_and_flip_image(image, rotate, fliplr) + + # Convert waves to nanometers. + image = image * wavelength + + if create_fits: + fits_hdu = fits.PrimaryHDU(image) + fits_hdu.writeto(fits_filepath, overwrite=True) + + return image + + def main(self): + """ + Main loop to manage data acquisition and processing. + """ + while not self.should_shut_down: + if self.should_be_acquiring.wait(0.05): + self.acquisition_loop() + + def acquisition_loop(self): + """ + Handle continuous data acquisition while the service is running. + """ + try: + self.is_acquiring.submit_data(np.array([1], dtype='int8')) + + while self.should_be_acquiring.is_set() and not self.should_shut_down: + img = self.take_measurement() + + has_correct_parameters = np.allclose(self.images.shape, img.shape) + + if not has_correct_parameters: + self.images.update_parameters('float32', img.shape, 20) + + self.images.submit_data(img.astype('float32')) + finally: + self.is_acquiring.submit_data(np.array([0], dtype='int8')) + + def start_acquisition(self): + """ + Start the data acquisition process. + """ + self.should_be_acquiring.set() + + def end_acquisition(self): + """ + End the data acquisition process. + """ + self.should_be_acquiring.clear() + + +if __name__ == '__main__': + service = AccufizInterferometerSim() + service.run() diff --git a/docs/index.rst b/docs/index.rst index d0aa7c27..30fdedbe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Catkit2 :maxdepth: 1 :caption: Built-In Services + services/accufiz_interferometer services/aimtti_plp services/allied_vision_camera services/bmc_dm diff --git a/docs/services/accufiz_interferometer.rst b/docs/services/accufiz_interferometer.rst new file mode 100644 index 00000000..61afcfce --- /dev/null +++ b/docs/services/accufiz_interferometer.rst @@ -0,0 +1,84 @@ +AccuFiz Interferometer +========== + +This service operates a 4D Technologies AccuFiz Interferometer using the 4D InSight software and their Web Services API for communication. It handles image acquisition, processing, and data management. + +Configuration +------------- + +.. code-block:: YAML + + accufiz_interferometer: + service_type: accufiz_interferometer + simulated_service_type: accufiz_interferometer_sim + interface: camera + requires_safety: false + height: 1967 + width: 1970 + sim_data: C:/path/to/example.h5 + mask: C:/path/to/4d.mask + server_path: C:/path/to/data + local_path: C:/path/to/data + ip_address: localhost:8080 + save_h5: true + save_fits: false + num_avg: 2 + fliplr: true + rotate: 0 + +Properties +----------- +``mask``: Path to the mask file to be used by the interferometer. + +``server_path``: Directory path on the server where measurement data will be saved. + +``local_path``: Local directory path where measurement data will be saved. + +``ip_address``: IP address and port of the 4D InSight Web Services API. Default is 'localhost:8080'. + +``calibration_data_package``: Path to the calibration data package (optional). + +``timeout``: Timeout in milliseconds for the Web Services API requests. Default is 10000. + +``post_save_sleep``: Time in seconds to wait after saving data. Default is 1. + +``file_mode``: Whether to operate in file mode. Default is True. + +``height``: Height of the image frames captured by the interferometer. Default is 1967. + +``width``: Width of the image frames captured by the interferometer. Default is 1970. + +``config_id``: Configuration identifier for logging purposes. Default is 'accufiz'. + +``save_h5``: Whether to save measurement data in HDF5 format. Default is True. + +``save_fits``: Whether to save processed images in FITS format. Default is False. + +``num_avg``: Number of frames to average when taking a measurement. Default is 2. + +``fliplr``: Whether to flip the image horizontally. Default is True. + +``rotate``: Rotation angle in degrees for the image. Default is 0. + +Commands +----------- +``start_acquisition()``: Starts the continuous data acquisition process. + +``end_acquisition()``: Ends the continuous data acquisition process. + +``take_measurement()``: Takes a single measurement and processes the image. + +Datastreams +----------- +``images``: The processed images acquired from the interferometer. + +``detector_masks``: The detector masks used during measurements. + +``is_acquiring``: Indicates whether the service is currently acquiring data (1 for acquiring, 0 for not acquiring). + +Notes +The AccuFiz Interferometer requires the 4D InSight software to be in listening mode and WebServices4D to be installed and configured correctly. + +The service communicates with the interferometer using HTTP requests to the Web Services API provided by the 4D InSight software and Web Service. + +Ensure that the mask file specified in the configuration is accessible to the 4D computer. diff --git a/environment.yml b/environment.yml index c15841e3..6b752d70 100644 --- a/environment.yml +++ b/environment.yml @@ -37,6 +37,7 @@ dependencies: - networkx - pytest - flake8 + - h5py - pip: - dcps - zwoasi>=0.0.21