From 859a945ac49aa3d4ab3438a5fbb343f74ed5030a Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:26:40 +0100 Subject: [PATCH 01/25] Delete functions directory --- functions/Anime.py | 93 -- functions/ImageAlignment.py | 1015 ----------------- functions/align_refref.py | 60 - functions/bandpass.py | 113 -- functions/binning.py | 46 - functions/calculate_rotation.py | 40 - functions/calculate_translation.py | 37 - functions/create_logger.py | 37 - functions/data_raw_loader.py | 339 ------ functions/gauss_smear_individual.py | 168 --- functions/get_experiments.py | 19 - functions/get_parts.py | 18 - functions/get_torch_device.py | 17 - functions/get_trials.py | 19 - functions/load_config.py | 16 - functions/load_meta_data.py | 68 -- functions/perform_donor_volume_rotation.py | 207 ---- functions/perform_donor_volume_translation.py | 210 ---- functions/regression.py | 117 -- functions/regression_internal.py | 27 - 20 files changed, 2666 deletions(-) delete mode 100644 functions/Anime.py delete mode 100644 functions/ImageAlignment.py delete mode 100644 functions/align_refref.py delete mode 100644 functions/bandpass.py delete mode 100644 functions/binning.py delete mode 100644 functions/calculate_rotation.py delete mode 100644 functions/calculate_translation.py delete mode 100644 functions/create_logger.py delete mode 100644 functions/data_raw_loader.py delete mode 100644 functions/gauss_smear_individual.py delete mode 100644 functions/get_experiments.py delete mode 100644 functions/get_parts.py delete mode 100644 functions/get_torch_device.py delete mode 100644 functions/get_trials.py delete mode 100644 functions/load_config.py delete mode 100644 functions/load_meta_data.py delete mode 100644 functions/perform_donor_volume_rotation.py delete mode 100644 functions/perform_donor_volume_translation.py delete mode 100644 functions/regression.py delete mode 100644 functions/regression_internal.py diff --git a/functions/Anime.py b/functions/Anime.py deleted file mode 100644 index bfc4e46..0000000 --- a/functions/Anime.py +++ /dev/null @@ -1,93 +0,0 @@ -import numpy as np -import torch -import matplotlib.pyplot as plt -import matplotlib.animation - - -class Anime: - def __init__(self) -> None: - super().__init__() - - def show( - self, - input: torch.Tensor | np.ndarray, - mask: torch.Tensor | np.ndarray | None = None, - vmin: float | None = None, - vmax: float | None = None, - cmap: str = "hot", - axis_off: bool = True, - show_frame_count: bool = True, - interval: int = 100, - repeat: bool = False, - colorbar: bool = True, - vmin_scale: float | None = None, - vmax_scale: float | None = None, - movie_file: str | None = None, - ) -> None: - assert input.ndim == 3 - - if isinstance(input, torch.Tensor): - input_np: np.ndarray = input.cpu().numpy() - if mask is not None: - mask_np: np.ndarray | None = (mask == 0).cpu().numpy() - else: - mask_np = None - else: - input_np = input - if mask is not None: - mask_np = mask == 0 # type: ignore - else: - mask_np = None - - if vmin is None: - vmin = float(np.where(np.isfinite(input_np), input_np, 0.0).min()) - if vmax is None: - vmax = float(np.where(np.isfinite(input_np), input_np, 0.0).max()) - - if vmin_scale is not None: - vmin *= vmin_scale - - if vmax_scale is not None: - vmax *= vmax_scale - - fig = plt.figure() - image = np.nan_to_num(input_np[0, ...], copy=True, nan=0.0) - if mask_np is not None: - image[mask_np] = float("NaN") - image_handle = plt.imshow( - image, - cmap=cmap, - vmin=vmin, - vmax=vmax, - ) - - if colorbar: - plt.colorbar() - - if axis_off: - plt.axis("off") - - def next_frame(i: int) -> None: - image = np.nan_to_num(input_np[i, ...], copy=True, nan=0.0) - if mask_np is not None: - image[mask_np] = float("NaN") - - image_handle.set_data(image) - if show_frame_count: - bar_length: int = 10 - filled_length = int(round(bar_length * i / input_np.shape[0])) - bar = "\u25A0" * filled_length + "\u25A1" * (bar_length - filled_length) - plt.title(f"{bar} {i} of {int(input_np.shape[0]-1)}", loc="left") - return - - ani = matplotlib.animation.FuncAnimation( - fig, - next_frame, - frames=int(input.shape[0]), - interval=interval, - repeat=repeat, - ) - if movie_file is not None: - ani.save(movie_file) - else: - plt.show() diff --git a/functions/ImageAlignment.py b/functions/ImageAlignment.py deleted file mode 100644 index 6472d02..0000000 --- a/functions/ImageAlignment.py +++ /dev/null @@ -1,1015 +0,0 @@ -import torch -import torchvision as tv # type: ignore - -# The source code is based on: -# https://github.com/matejak/imreg_dft - -# The original LICENSE: -# Copyright (c) 2014, Matěj Týč -# Copyright (c) 2011-2014, Christoph Gohlke -# Copyright (c) 2011-2014, The Regents of the University of California -# Produced at the Laboratory for Fluorescence Dynamics - -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: - -# * Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. - -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. - -# * Neither the name of the {organization} nor the names of its -# contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -class ImageAlignment(torch.nn.Module): - device: torch.device - default_dtype: torch.dtype - excess_const: float = 1.1 - exponent: str = "inf" - success: torch.Tensor | None = None - - # The factor that detmines how many - # sub-pixel we will shift - scale_factor: int = 4 - - reference_image: torch.Tensor | None = None - - last_scale: torch.Tensor | None = None - last_angle: torch.Tensor | None = None - last_tvec: torch.Tensor | None = None - - # Cache - image_reference_dft: torch.Tensor | None = None - filt: torch.Tensor - pcorr_shape: torch.Tensor - log_base: torch.Tensor - image_reference_logp: torch.Tensor - - def __init__( - self, - device: torch.device | None = None, - default_dtype: torch.dtype | None = None, - ) -> None: - super().__init__() - - assert device is not None - assert default_dtype is not None - self.device = device - self.default_dtype = default_dtype - - def set_new_reference_image(self, new_reference_image: torch.Tensor | None = None): - assert new_reference_image is not None - assert new_reference_image.ndim == 2 - self.reference_image = ( - new_reference_image.detach() - .clone() - .to(device=self.device) - .type(dtype=self.default_dtype) - ) - self.image_reference_dft = None - - def forward( - self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None - ) -> torch.Tensor: - assert input.ndim == 3 - - if new_reference_image is not None: - self.set_new_reference_image(new_reference_image) - - assert self.reference_image is not None - assert self.reference_image.ndim == 2 - assert input.shape[-2] == self.reference_image.shape[-2] - assert input.shape[-1] == self.reference_image.shape[-1] - - self.last_scale, self.last_angle, self.last_tvec, output = self.similarity( - self.reference_image, - input.to(device=self.device).type(dtype=self.default_dtype), - ) - - return output - - def dry_run( - self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None - ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]: - assert input.ndim == 3 - - if new_reference_image is not None: - self.set_new_reference_image(new_reference_image) - - assert self.reference_image is not None - assert self.reference_image.ndim == 2 - assert input.shape[-2] == self.reference_image.shape[-2] - assert input.shape[-1] == self.reference_image.shape[-1] - - images_todo = input.to(device=self.device).type(dtype=self.default_dtype) - image_reference = self.reference_image - - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - - bgval: torch.Tensor = self.get_borderval(img=images_todo, radius=5) - - self.last_scale, self.last_angle, self.last_tvec = self._similarity( - image_reference, - images_todo, - bgval, - ) - - return self.last_scale, self.last_angle, self.last_tvec - - def dry_run_translation( - self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None - ) -> torch.Tensor: - assert input.ndim == 3 - - if new_reference_image is not None: - self.set_new_reference_image(new_reference_image) - - assert self.reference_image is not None - assert self.reference_image.ndim == 2 - assert input.shape[-2] == self.reference_image.shape[-2] - assert input.shape[-1] == self.reference_image.shape[-1] - - images_todo = input.to(device=self.device).type(dtype=self.default_dtype) - image_reference = self.reference_image - - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - - tvec, _ = self._translation(image_reference, images_todo) - - return tvec - - # --------------- - - def dry_run_angle( - self, - input: torch.Tensor, - new_reference_image: torch.Tensor | None = None, - ) -> torch.Tensor: - assert input.ndim == 3 - - if new_reference_image is not None: - self.set_new_reference_image(new_reference_image) - - constraints_dynamic_angle_0: torch.Tensor = torch.zeros( - (input.shape[0]), dtype=self.default_dtype, device=self.device - ) - constraints_dynamic_angle_1: torch.Tensor | None = None - constraints_dynamic_scale_0: torch.Tensor = torch.ones( - (input.shape[0]), dtype=self.default_dtype, device=self.device - ) - constraints_dynamic_scale_1: torch.Tensor | None = None - - assert self.reference_image is not None - assert self.reference_image.ndim == 2 - assert input.shape[-2] == self.reference_image.shape[-2] - assert input.shape[-1] == self.reference_image.shape[-1] - - images_todo = input.to(device=self.device).type(dtype=self.default_dtype) - image_reference = self.reference_image - - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - - _, newangle = self._get_ang_scale( - image_reference, - images_todo, - constraints_dynamic_scale_0, - constraints_dynamic_scale_1, - constraints_dynamic_angle_0, - constraints_dynamic_angle_1, - ) - - return newangle - - # --------------- - - def _get_pcorr_shape(self, shape: torch.Size) -> tuple[int, int]: - ret = (int(max(shape[-2:]) * 1.0),) * 2 - return ret - - def _get_log_base(self, shape: torch.Size, new_r: torch.Tensor) -> torch.Tensor: - old_r = torch.tensor( - (float(shape[-2]) * self.excess_const) / 2.0, - dtype=self.default_dtype, - device=self.device, - ) - log_base = torch.exp(torch.log(old_r) / new_r) - return log_base - - def wrap_angle( - self, angles: torch.Tensor, ceil: float = 2 * torch.pi - ) -> torch.Tensor: - angles += ceil / 2.0 - angles %= ceil - angles -= ceil / 2.0 - return angles - - def get_borderval( - self, img: torch.Tensor, radius: int | None = None - ) -> torch.Tensor: - assert img.ndim == 3 - if radius is None: - mindim = min([int(img.shape[-2]), int(img.shape[-1])]) - radius = max(1, mindim // 20) - mask = torch.zeros( - (int(img.shape[-2]), int(img.shape[-1])), - dtype=torch.bool, - device=self.device, - ) - mask[:, :radius] = True - mask[:, -radius:] = True - mask[:radius, :] = True - mask[-radius:, :] = True - - mean = torch.median(img[:, mask], dim=-1)[0] - return mean - - def get_apofield(self, shape: torch.Size, aporad: int) -> torch.Tensor: - if aporad == 0: - return torch.ones( - shape[-2:], - dtype=self.default_dtype, - device=self.device, - ) - - assert int(shape[-2]) > aporad * 2 - assert int(shape[-1]) > aporad * 2 - - apos = torch.hann_window( - aporad * 2, dtype=self.default_dtype, periodic=False, device=self.device - ) - - toapp_0 = torch.ones( - shape[-2], - dtype=self.default_dtype, - device=self.device, - ) - toapp_0[:aporad] = apos[:aporad] - toapp_0[-aporad:] = apos[-aporad:] - - toapp_1 = torch.ones( - shape[-1], - dtype=self.default_dtype, - device=self.device, - ) - toapp_1[:aporad] = apos[:aporad] - toapp_1[-aporad:] = apos[-aporad:] - - apofield = torch.outer(toapp_0, toapp_1) - - return apofield - - def _get_subarr( - self, array: torch.Tensor, center: torch.Tensor, rad: int - ) -> torch.Tensor: - assert array.ndim == 3 - assert center.ndim == 2 - assert array.shape[0] == center.shape[0] - assert center.shape[1] == 2 - - dim = 1 + 2 * rad - subarr = torch.zeros( - (array.shape[0], dim, dim), dtype=self.default_dtype, device=self.device - ) - - corner = center - rad - idx_p = range(0, corner.shape[0]) - for ii in range(0, dim): - yidx = corner[:, 0] + ii - yidx %= array.shape[-2] - for jj in range(0, dim): - xidx = corner[:, 1] + jj - xidx %= array.shape[-1] - subarr[:, ii, jj] = array[idx_p, yidx, xidx] - - return subarr - - def _argmax_2d(self, array: torch.Tensor) -> torch.Tensor: - assert array.ndim == 3 - - max_pos = array.reshape( - (array.shape[0], array.shape[1] * array.shape[2]) - ).argmax(dim=1) - pos_0 = max_pos // array.shape[2] - - max_pos -= pos_0 * array.shape[2] - ret = torch.zeros( - (array.shape[0], 2), dtype=self.default_dtype, device=self.device - ) - ret[:, 0] = pos_0 - ret[:, 1] = max_pos - - return ret.type(dtype=torch.int64) - - def _apodize(self, what: torch.Tensor) -> torch.Tensor: - mindim = min([int(what.shape[-2]), int(what.shape[-1])]) - aporad = int(mindim * 0.12) - - apofield = self.get_apofield(what.shape, aporad).unsqueeze(0) - - res = what * apofield - bg = self.get_borderval(what, aporad // 2).unsqueeze(-1).unsqueeze(-1) - res += bg * (1 - apofield) - return res - - def _logpolar_filter(self, shape: torch.Size) -> torch.Tensor: - yy = torch.linspace( - -torch.pi / 2.0, - torch.pi / 2.0, - shape[-2], - dtype=self.default_dtype, - device=self.device, - ).unsqueeze(1) - - xx = torch.linspace( - -torch.pi / 2.0, - torch.pi / 2.0, - shape[-1], - dtype=self.default_dtype, - device=self.device, - ).unsqueeze(0) - - rads = torch.sqrt(yy**2 + xx**2) - filt = 1.0 - torch.cos(rads) ** 2 - - filt[torch.abs(rads) > torch.pi / 2] = 1 - return filt - - def _get_angles(self, shape: torch.Tensor) -> torch.Tensor: - ret = torch.zeros( - (int(shape[-2]), int(shape[-1])), - dtype=self.default_dtype, - device=self.device, - ) - ret -= torch.linspace( - 0, - torch.pi, - int(shape[-2] + 1), - dtype=self.default_dtype, - device=self.device, - )[:-1].unsqueeze(-1) - - return ret - - def _get_lograd(self, shape: torch.Tensor, log_base: torch.Tensor) -> torch.Tensor: - ret = torch.zeros( - (int(shape[-2]), int(shape[-1])), - dtype=self.default_dtype, - device=self.device, - ) - ret += torch.pow( - log_base, - torch.arange( - 0, - int(shape[-1]), - dtype=self.default_dtype, - device=self.device, - ), - ).unsqueeze(0) - return ret - - def _logpolar( - self, image: torch.Tensor, shape: torch.Tensor, log_base: torch.Tensor - ) -> torch.Tensor: - assert image.ndim == 3 - - imshape: torch.Tensor = torch.tensor( - image.shape[-2:], - dtype=self.default_dtype, - device=self.device, - ) - - center: torch.Tensor = imshape.clone() / 2 - - theta: torch.Tensor = self._get_angles(shape) - radius_x: torch.Tensor = self._get_lograd(shape, log_base) - radius_y: torch.Tensor = radius_x.clone() - - ellipse_coef: torch.Tensor = imshape[0] / imshape[1] - radius_x /= ellipse_coef - - y = radius_y * torch.sin(theta) + center[0] - y /= float(image.shape[-2]) - y *= 2 - y -= 1 - - x = radius_x * torch.cos(theta) + center[1] - x /= float(image.shape[-1]) - x *= 2 - x -= 1 - - idx_x = torch.where(torch.abs(x) <= 1.0, 1.0, 0.0) - idx_y = torch.where(torch.abs(y) <= 1.0, 1.0, 0.0) - - normalized_coords = torch.cat( - ( - x.unsqueeze(-1), - y.unsqueeze(-1), - ), - dim=-1, - ).unsqueeze(0) - - output = torch.empty( - (int(image.shape[0]), int(y.shape[0]), int(y.shape[1])), - dtype=self.default_dtype, - device=self.device, - ) - - for id in range(0, int(image.shape[0])): - bgval: torch.Tensor = torch.quantile(image[id, :, :], q=1.0 / 100.0) - - temp = torch.nn.functional.grid_sample( - image[id, :, :].unsqueeze(0).unsqueeze(0), - normalized_coords, - mode="bilinear", - padding_mode="zeros", - align_corners=False, - ) - - output[id, :, :] = torch.where((idx_x * idx_y) == 0.0, bgval, temp) - - return output - - def _argmax_ext(self, array: torch.Tensor, exponent: float | str) -> torch.Tensor: - assert array.ndim == 3 - - if exponent == "inf": - ret = self._argmax_2d(array) - else: - assert isinstance(exponent, float) or isinstance(exponent, int) - - col = ( - torch.arange( - 0, array.shape[-2], dtype=self.default_dtype, device=self.device - ) - .unsqueeze(-1) - .unsqueeze(0) - ) - row = ( - torch.arange( - 0, array.shape[-1], dtype=self.default_dtype, device=self.device - ) - .unsqueeze(0) - .unsqueeze(0) - ) - - arr2 = torch.pow(array, float(exponent)) - arrsum = arr2.sum(dim=-2).sum(dim=-1) - - ret = torch.zeros( - (array.shape[0], 2), dtype=self.default_dtype, device=self.device - ) - - arrprody = (arr2 * col).sum(dim=-1).sum(dim=-1) / arrsum - arrprodx = (arr2 * row).sum(dim=-1).sum(dim=-1) / arrsum - - ret[:, 0] = arrprody.squeeze(-1).squeeze(-1) - ret[:, 1] = arrprodx.squeeze(-1).squeeze(-1) - - idx = torch.where(arrsum == 0.0)[0] - ret[idx, :] = 0.0 - return ret - - def _interpolate( - self, array: torch.Tensor, rough: torch.Tensor, rad: int = 2 - ) -> torch.Tensor: - assert array.ndim == 3 - assert rough.ndim == 2 - - rough = torch.round(rough).type(torch.int64) - - surroundings = self._get_subarr(array, rough, rad) - - com = self._argmax_ext(surroundings, 1.0) - - offset = com - rad - ret = rough + offset - - ret += 0.5 - ret %= ( - torch.tensor(array.shape[-2:], dtype=self.default_dtype, device=self.device) - .type(dtype=torch.int64) - .unsqueeze(0) - ) - ret -= 0.5 - return ret - - def _get_success( - self, array: torch.Tensor, coord: torch.Tensor, radius: int = 2 - ) -> torch.Tensor: - assert array.ndim == 3 - assert coord.ndim == 2 - assert array.shape[0] == coord.shape[0] - assert coord.shape[1] == 2 - - coord = torch.round(coord).type(dtype=torch.int64) - subarr = self._get_subarr( - array, coord, 2 - ) # Not my fault. They want a 2 there. Not radius - - theval = subarr.sum(dim=-1).sum(dim=-1) - - theval2 = array[range(0, coord.shape[0]), coord[:, 0], coord[:, 1]] - - success = torch.sqrt(theval * theval2) - return success - - def _get_constraint_mask( - self, - shape: torch.Size, - log_base: torch.Tensor, - constraints_scale_0: torch.Tensor, - constraints_scale_1: torch.Tensor | None, - constraints_angle_0: torch.Tensor, - constraints_angle_1: torch.Tensor | None, - ) -> torch.Tensor: - assert constraints_scale_0 is not None - assert constraints_angle_0 is not None - assert constraints_scale_0.ndim == 1 - assert constraints_angle_0.ndim == 1 - - assert constraints_scale_0.shape[0] == constraints_angle_0.shape[0] - - mask: torch.Tensor = torch.ones( - (constraints_scale_0.shape[0], int(shape[-2]), int(shape[-1])), - device=self.device, - dtype=self.default_dtype, - ) - - scale: torch.Tensor = constraints_scale_0.clone() - if constraints_scale_1 is not None: - sigma: torch.Tensor | None = constraints_scale_1.clone() - else: - sigma = None - - scales = torch.fft.ifftshift( - self._get_lograd( - torch.tensor(shape[-2:], device=self.device, dtype=self.default_dtype), - log_base, - ) - ) - - scales *= log_base ** (-shape[-1] / 2.0) - scales = scales.unsqueeze(0) - (1.0 / scale).unsqueeze(-1).unsqueeze(-1) - - if sigma is not None: - assert sigma.shape[0] == constraints_scale_0.shape[0] - - for p_id in range(0, sigma.shape[0]): - if sigma[p_id] == 0: - ascales = torch.abs(scales[p_id, ...]) - scale_min = ascales.min() - binary_mask = torch.where(ascales > scale_min, 0.0, 1.0) - mask[p_id, ...] *= binary_mask - else: - mask[p_id, ...] *= torch.exp( - -(torch.pow(scales[p_id, ...], 2)) / torch.pow(sigma[p_id], 2) - ) - - angle: torch.Tensor = constraints_angle_0.clone() - if constraints_angle_1 is not None: - sigma = constraints_angle_1.clone() - else: - sigma = None - - angles = self._get_angles( - torch.tensor(shape[-2:], device=self.device, dtype=self.default_dtype) - ) - - angles = angles.unsqueeze(0) + torch.deg2rad(angle).unsqueeze(-1).unsqueeze(-1) - - angles = torch.rad2deg(angles) - - if sigma is not None: - assert sigma.shape[0] == constraints_scale_0.shape[0] - - for p_id in range(0, sigma.shape[0]): - if sigma[p_id] == 0: - aangles = torch.abs(angles[p_id, ...]) - angle_min = aangles.min() - binary_mask = torch.where(aangles > angle_min, 0.0, 1.0) - mask[p_id, ...] *= binary_mask - else: - mask *= torch.exp( - -(torch.pow(angles[p_id, ...], 2)) / torch.pow(sigma[p_id], 2) - ) - - mask = torch.fft.fftshift(mask, dim=(-2, -1)) - - return mask - - def argmax_angscale( - self, - array: torch.Tensor, - log_base: torch.Tensor, - constraints_scale_0: torch.Tensor, - constraints_scale_1: torch.Tensor | None, - constraints_angle_0: torch.Tensor, - constraints_angle_1: torch.Tensor | None, - ) -> tuple[torch.Tensor, torch.Tensor]: - assert array.ndim == 3 - assert constraints_scale_0 is not None - assert constraints_angle_0 is not None - assert constraints_scale_0.ndim == 1 - assert constraints_angle_0.ndim == 1 - - mask = self._get_constraint_mask( - array.shape[-2:], - log_base, - constraints_scale_0, - constraints_scale_1, - constraints_angle_0, - constraints_angle_1, - ) - - array_orig = array.clone() - - array *= mask - ret = self._argmax_ext(array, self.exponent) - - ret_final = self._interpolate(array, ret) - - success = self._get_success(array_orig, ret_final, 0) - - return ret_final, success - - def argmax_translation( - self, array: torch.Tensor - ) -> tuple[torch.Tensor, torch.Tensor]: - assert array.ndim == 3 - - array_orig = array.clone() - - ashape = torch.tensor(array.shape[-2:], device=self.device).type( - dtype=torch.int64 - ) - - aporad = (ashape // 6).min() - mask2 = self.get_apofield(torch.Size(ashape), aporad).unsqueeze(0) - array *= mask2 - - tvec = self._argmax_ext(array, "inf") - - tvec = self._interpolate(array_orig, tvec) - - success = self._get_success(array_orig, tvec, 2) - - return tvec, success - - def transform_img( - self, - img: torch.Tensor, - scale: torch.Tensor | None = None, - angle: torch.Tensor | None = None, - tvec: torch.Tensor | None = None, - bgval: torch.Tensor | None = None, - ) -> torch.Tensor: - assert img.ndim == 3 - - if scale is None: - scale = torch.ones( - (img.shape[0],), dtype=self.default_dtype, device=self.device - ) - assert scale.ndim == 1 - assert scale.shape[0] == img.shape[0] - - if angle is None: - angle = torch.zeros( - (img.shape[0],), dtype=self.default_dtype, device=self.device - ) - assert angle.ndim == 1 - assert angle.shape[0] == img.shape[0] - - if tvec is None: - tvec = torch.zeros( - (img.shape[0], 2), dtype=self.default_dtype, device=self.device - ) - assert tvec.ndim == 2 - assert tvec.shape[0] == img.shape[0] - assert tvec.shape[1] == 2 - - if bgval is None: - bgval = self.get_borderval(img) - assert bgval.ndim == 1 - assert bgval.shape[0] == img.shape[0] - - # Otherwise we need to decompose it and put it back together - assert torch.is_complex(img) is False - - output = torch.zeros_like(img) - - for pos in range(0, img.shape[0]): - image_processed = img[pos, :, :].unsqueeze(0).clone() - - temp_shift = [ - int(round(tvec[pos, 1].item() * self.scale_factor)), - int(round(tvec[pos, 0].item() * self.scale_factor)), - ] - - image_processed = torch.nn.functional.interpolate( - image_processed.unsqueeze(0), - scale_factor=self.scale_factor, - mode="bilinear", - ).squeeze(0) - - image_processed = tv.transforms.functional.affine( - img=image_processed, - angle=-float(angle[pos]), - translate=temp_shift, - scale=float(scale[pos]), - shear=[0, 0], - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=float(bgval[pos]), - center=None, - ) - - image_processed = torch.nn.functional.interpolate( - image_processed.unsqueeze(0), - scale_factor=1.0 / self.scale_factor, - mode="bilinear", - ).squeeze(0) - - image_processed = tv.transforms.functional.center_crop( - image_processed, img.shape[-2:] - ) - - output[pos, ...] = image_processed.squeeze(0) - - return output - - def transform_img_dict( - self, - img: torch.Tensor, - scale: torch.Tensor | None = None, - angle: torch.Tensor | None = None, - tvec: torch.Tensor | None = None, - bgval: torch.Tensor | None = None, - invert=False, - ) -> torch.Tensor: - if invert: - if scale is not None: - scale = 1.0 / scale - if angle is not None: - angle *= -1 - if tvec is not None: - tvec *= -1 - - res = self.transform_img(img, scale, angle, tvec, bgval=bgval) - return res - - def _phase_correlation( - self, image_reference: torch.Tensor, images_todo: torch.Tensor, callback, *args - ) -> tuple[torch.Tensor, torch.Tensor]: - assert image_reference.ndim == 3 - assert image_reference.shape[0] == 1 - assert images_todo.ndim == 3 - - assert callback is not None - - image_reference_fft = torch.fft.fft2(image_reference, dim=(-2, -1)) - images_todo_fft = torch.fft.fft2(images_todo, dim=(-2, -1)) - - eps = torch.abs(images_todo_fft).max(dim=-1)[0].max(dim=-1)[0] * 1e-15 - - cps = abs( - torch.fft.ifft2( - (image_reference_fft * images_todo_fft.conj()) - / ( - torch.abs(image_reference_fft) * torch.abs(images_todo_fft) - + eps.unsqueeze(-1).unsqueeze(-1) - ), - dim=(-2, -1), - ) - ) - - scps = torch.fft.fftshift(cps, dim=(-2, -1)) - - ret, success = callback(scps, *args) - - ret[:, 0] -= image_reference_fft.shape[-2] // 2 - ret[:, 1] -= image_reference_fft.shape[-1] // 2 - - return ret, success - - def _translation( - self, im0: torch.Tensor, im1: torch.Tensor - ) -> tuple[torch.Tensor, torch.Tensor]: - assert im0.ndim == 2 - ret, succ = self._phase_correlation( - im0.unsqueeze(0), im1, self.argmax_translation - ) - - return ret, succ - - def _get_ang_scale( - self, - image_reference: torch.Tensor, - images_todo: torch.Tensor, - constraints_scale_0: torch.Tensor, - constraints_scale_1: torch.Tensor | None, - constraints_angle_0: torch.Tensor, - constraints_angle_1: torch.Tensor | None, - ) -> tuple[torch.Tensor, torch.Tensor]: - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - assert image_reference.shape[-1] == images_todo.shape[-1] - assert image_reference.shape[-2] == images_todo.shape[-2] - assert constraints_scale_0.shape[0] == images_todo.shape[0] - assert constraints_angle_0.shape[0] == images_todo.shape[0] - - if constraints_scale_1 is not None: - assert constraints_scale_1.shape[0] == images_todo.shape[0] - - if constraints_angle_1 is not None: - assert constraints_angle_1.shape[0] == images_todo.shape[0] - - if self.image_reference_dft is None: - image_reference_apod = self._apodize(image_reference.unsqueeze(0)) - self.image_reference_dft = torch.fft.fftshift( - torch.fft.fft2(image_reference_apod, dim=(-2, -1)), dim=(-2, -1) - ) - self.filt = self._logpolar_filter(image_reference.shape).unsqueeze(0) - self.image_reference_dft *= self.filt - self.pcorr_shape = torch.tensor( - self._get_pcorr_shape(image_reference.shape[-2:]), - dtype=self.default_dtype, - device=self.device, - ) - self.log_base = self._get_log_base( - image_reference.shape, - self.pcorr_shape[1], - ) - self.image_reference_logp = self._logpolar( - torch.abs(self.image_reference_dft), self.pcorr_shape, self.log_base - ) - - images_todo_apod = self._apodize(images_todo) - images_todo_dft = torch.fft.fftshift( - torch.fft.fft2(images_todo_apod, dim=(-2, -1)), dim=(-2, -1) - ) - - images_todo_dft *= self.filt - - images_todo_lopg = self._logpolar( - torch.abs(images_todo_dft), self.pcorr_shape, self.log_base - ) - - temp, _ = self._phase_correlation( - self.image_reference_logp, - images_todo_lopg, - self.argmax_angscale, - self.log_base, - constraints_scale_0, - constraints_scale_1, - constraints_angle_0, - constraints_angle_1, - ) - - arg_ang = temp[:, 0].clone() - arg_rad = temp[:, 1].clone() - - angle = -torch.pi * arg_ang / float(self.pcorr_shape[0]) - angle = torch.rad2deg(angle) - - angle = self.wrap_angle(angle, 360) - - scale = torch.pow(self.log_base, arg_rad) - - angle = -angle - scale = 1.0 / scale - - assert torch.where(scale < 2)[0].shape[0] == scale.shape[0] - assert torch.where(scale > 0.5)[0].shape[0] == scale.shape[0] - - return scale, angle - - def translation( - self, im0: torch.Tensor, im1: torch.Tensor - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - angle = torch.zeros( - (im1.shape[0]), dtype=self.default_dtype, device=self.device - ) - assert im1.ndim == 3 - assert im0.shape[-2] == im1.shape[-2] - assert im0.shape[-1] == im1.shape[-1] - - tvec, succ = self._translation(im0, im1) - tvec2, succ2 = self._translation(im0, torch.rot90(im1, k=2, dims=[-2, -1])) - - assert tvec.shape[0] == tvec2.shape[0] - assert tvec.ndim == 2 - assert tvec2.ndim == 2 - assert tvec.shape[1] == 2 - assert tvec2.shape[1] == 2 - assert succ.shape[0] == succ2.shape[0] - assert succ.ndim == 1 - assert succ2.ndim == 1 - assert tvec.shape[0] == succ.shape[0] - assert angle.shape[0] == tvec.shape[0] - assert angle.ndim == 1 - - for pos in range(0, angle.shape[0]): - pick_rotated = False - if succ2[pos] > succ[pos]: - pick_rotated = True - - if pick_rotated: - tvec[pos, :] = tvec2[pos, :] - succ[pos] = succ2[pos] - angle[pos] += 180 - - return tvec, succ, angle - - def _similarity( - self, - image_reference: torch.Tensor, - images_todo: torch.Tensor, - bgval: torch.Tensor, - ): - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - assert image_reference.shape[-1] == images_todo.shape[-1] - assert image_reference.shape[-2] == images_todo.shape[-2] - - # We are going to iterate and precise scale and angle estimates - scale: torch.Tensor = torch.ones( - (images_todo.shape[0]), dtype=self.default_dtype, device=self.device - ) - angle: torch.Tensor = torch.zeros( - (images_todo.shape[0]), dtype=self.default_dtype, device=self.device - ) - - constraints_dynamic_angle_0: torch.Tensor = torch.zeros( - (images_todo.shape[0]), dtype=self.default_dtype, device=self.device - ) - constraints_dynamic_angle_1: torch.Tensor | None = None - constraints_dynamic_scale_0: torch.Tensor = torch.ones( - (images_todo.shape[0]), dtype=self.default_dtype, device=self.device - ) - constraints_dynamic_scale_1: torch.Tensor | None = None - - newscale, newangle = self._get_ang_scale( - image_reference, - images_todo, - constraints_dynamic_scale_0, - constraints_dynamic_scale_1, - constraints_dynamic_angle_0, - constraints_dynamic_angle_1, - ) - scale *= newscale - angle += newangle - - im2 = self.transform_img(images_todo, scale, angle, bgval=bgval) - - tvec, self.success, res_angle = self.translation(image_reference, im2) - - angle += res_angle - - angle = self.wrap_angle(angle, 360) - - return scale, angle, tvec - - def similarity( - self, - image_reference: torch.Tensor, - images_todo: torch.Tensor, - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: - assert image_reference.ndim == 2 - assert images_todo.ndim == 3 - - bgval: torch.Tensor = self.get_borderval(img=images_todo, radius=5) - - scale, angle, tvec = self._similarity( - image_reference, - images_todo, - bgval, - ) - - im2 = self.transform_img_dict( - img=images_todo, - scale=scale, - angle=angle, - tvec=tvec, - bgval=bgval, - ) - - return scale, angle, tvec, im2 diff --git a/functions/align_refref.py b/functions/align_refref.py deleted file mode 100644 index 3208cf3..0000000 --- a/functions/align_refref.py +++ /dev/null @@ -1,60 +0,0 @@ -import torch -import torchvision as tv # type: ignore -import logging -from functions.ImageAlignment import ImageAlignment -from functions.calculate_translation import calculate_translation -from functions.calculate_rotation import calculate_rotation - - -@torch.no_grad() -def align_refref( - mylogger: logging.Logger, - ref_image_acceptor: torch.Tensor, - ref_image_donor: torch.Tensor, - batch_size: int, - fill_value: float = 0, -) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: - - image_alignment = ImageAlignment( - default_dtype=ref_image_acceptor.dtype, device=ref_image_acceptor.device - ) - - mylogger.info("Rotate ref image acceptor onto donor") - angle_refref = calculate_rotation( - image_alignment=image_alignment, - input=ref_image_acceptor.unsqueeze(0), - reference_image=ref_image_donor, - batch_size=batch_size, - ) - - ref_image_acceptor = tv.transforms.functional.affine( - img=ref_image_acceptor.unsqueeze(0), - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ) - - mylogger.info("Translate ref image acceptor onto donor") - tvec_refref = calculate_translation( - image_alignment=image_alignment, - input=ref_image_acceptor, - reference_image=ref_image_donor, - batch_size=batch_size, - ) - - tvec_refref = tvec_refref[0, :] - - ref_image_acceptor = tv.transforms.functional.affine( - img=ref_image_acceptor, - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - return angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor diff --git a/functions/bandpass.py b/functions/bandpass.py deleted file mode 100644 index 2659847..0000000 --- a/functions/bandpass.py +++ /dev/null @@ -1,113 +0,0 @@ -import torchaudio as ta # type: ignore -import torch - - -@torch.no_grad() -def filtfilt( - input: torch.Tensor, - butter_a: torch.Tensor, - butter_b: torch.Tensor, -) -> torch.Tensor: - assert butter_a.ndim == 1 - assert butter_b.ndim == 1 - assert butter_a.shape[0] == butter_b.shape[0] - - process_data: torch.Tensor = input.detach().clone() - - padding_length = 12 * int(butter_a.shape[0]) - left_padding = 2 * process_data[..., 0].unsqueeze(-1) - process_data[ - ..., 1 : padding_length + 1 - ].flip(-1) - right_padding = 2 * process_data[..., -1].unsqueeze(-1) - process_data[ - ..., -(padding_length + 1) : -1 - ].flip(-1) - process_data_padded = torch.cat((left_padding, process_data, right_padding), dim=-1) - - output = ta.functional.filtfilt( - process_data_padded.unsqueeze(0), butter_a, butter_b, clamp=False - ).squeeze(0) - - output = output[..., padding_length:-padding_length] - return output - - -@torch.no_grad() -def butter_bandpass( - device: torch.device, - low_frequency: float = 0.1, - high_frequency: float = 1.0, - fs: float = 30.0, -) -> tuple[torch.Tensor, torch.Tensor]: - import scipy # type: ignore - - butter_b_np, butter_a_np = scipy.signal.butter( - 4, [low_frequency, high_frequency], btype="bandpass", output="ba", fs=fs - ) - butter_a = torch.tensor(butter_a_np, device=device, dtype=torch.float32) - butter_b = torch.tensor(butter_b_np, device=device, dtype=torch.float32) - return butter_a, butter_b - - -@torch.no_grad() -def chunk_iterator(array: torch.Tensor, chunk_size: int): - for i in range(0, array.shape[0], chunk_size): - yield array[i : i + chunk_size] - - -@torch.no_grad() -def bandpass( - data: torch.Tensor, - low_frequency: float = 0.1, - high_frequency: float = 1.0, - fs=30.0, - filtfilt_chuck_size: int = 10, -) -> torch.Tensor: - - try: - return bandpass_internal( - data=data, - low_frequency=low_frequency, - high_frequency=high_frequency, - fs=fs, - filtfilt_chuck_size=filtfilt_chuck_size, - ) - - except torch.cuda.OutOfMemoryError: - - return bandpass_internal( - data=data.cpu(), - low_frequency=low_frequency, - high_frequency=high_frequency, - fs=fs, - filtfilt_chuck_size=filtfilt_chuck_size, - ).to(device=data.device) - - -@torch.no_grad() -def bandpass_internal( - data: torch.Tensor, - low_frequency: float = 0.1, - high_frequency: float = 1.0, - fs=30.0, - filtfilt_chuck_size: int = 10, -) -> torch.Tensor: - butter_a, butter_b = butter_bandpass( - device=data.device, - low_frequency=low_frequency, - high_frequency=high_frequency, - fs=fs, - ) - - index_full_dataset: torch.Tensor = torch.arange( - 0, data.shape[1], device=data.device, dtype=torch.int64 - ) - - for chunk in chunk_iterator(index_full_dataset, filtfilt_chuck_size): - temp_filtfilt = filtfilt( - data[:, chunk, :], - butter_a=butter_a, - butter_b=butter_b, - ) - data[:, chunk, :] = temp_filtfilt - - return data diff --git a/functions/binning.py b/functions/binning.py deleted file mode 100644 index f873433..0000000 --- a/functions/binning.py +++ /dev/null @@ -1,46 +0,0 @@ -import torch - - -@torch.no_grad() -def binning( - data: torch.Tensor, - kernel_size: int = 4, - stride: int = 4, - divisor_override: int | None = 1, -) -> torch.Tensor: - - try: - return binning_internal( - data=data, - kernel_size=kernel_size, - stride=stride, - divisor_override=divisor_override, - ) - except torch.cuda.OutOfMemoryError: - return binning_internal( - data=data.cpu(), - kernel_size=kernel_size, - stride=stride, - divisor_override=divisor_override, - ).to(device=data.device) - - -@torch.no_grad() -def binning_internal( - data: torch.Tensor, - kernel_size: int = 4, - stride: int = 4, - divisor_override: int | None = 1, -) -> torch.Tensor: - - assert data.ndim == 4 - return ( - torch.nn.functional.avg_pool2d( - input=data.movedim(0, -1).movedim(0, -1), - kernel_size=kernel_size, - stride=stride, - divisor_override=divisor_override, - ) - .movedim(-1, 0) - .movedim(-1, 0) - ) diff --git a/functions/calculate_rotation.py b/functions/calculate_rotation.py deleted file mode 100644 index 6a53afd..0000000 --- a/functions/calculate_rotation.py +++ /dev/null @@ -1,40 +0,0 @@ -import torch - -from functions.ImageAlignment import ImageAlignment - - -@torch.no_grad() -def calculate_rotation( - image_alignment: ImageAlignment, - input: torch.Tensor, - reference_image: torch.Tensor, - batch_size: int, -) -> torch.Tensor: - angle = torch.zeros((input.shape[0])) - - data_loader = torch.utils.data.DataLoader( - torch.utils.data.TensorDataset(input), - batch_size=batch_size, - shuffle=False, - ) - start_position: int = 0 - for input_batch in data_loader: - assert len(input_batch) == 1 - - end_position = start_position + input_batch[0].shape[0] - - angle_temp = image_alignment.dry_run_angle( - input=input_batch[0], - new_reference_image=reference_image, - ) - - assert angle_temp is not None - - angle[start_position:end_position] = angle_temp - - start_position += input_batch[0].shape[0] - - angle = torch.where(angle >= 180, 360.0 - angle, angle) - angle = torch.where(angle <= -180, 360.0 + angle, angle) - - return angle diff --git a/functions/calculate_translation.py b/functions/calculate_translation.py deleted file mode 100644 index 9eadf59..0000000 --- a/functions/calculate_translation.py +++ /dev/null @@ -1,37 +0,0 @@ -import torch - -from functions.ImageAlignment import ImageAlignment - - -@torch.no_grad() -def calculate_translation( - image_alignment: ImageAlignment, - input: torch.Tensor, - reference_image: torch.Tensor, - batch_size: int, -) -> torch.Tensor: - tvec = torch.zeros((input.shape[0], 2)) - - data_loader = torch.utils.data.DataLoader( - torch.utils.data.TensorDataset(input), - batch_size=batch_size, - shuffle=False, - ) - start_position: int = 0 - for input_batch in data_loader: - assert len(input_batch) == 1 - - end_position = start_position + input_batch[0].shape[0] - - tvec_temp = image_alignment.dry_run_translation( - input=input_batch[0], - new_reference_image=reference_image, - ) - - assert tvec_temp is not None - - tvec[start_position:end_position, :] = tvec_temp - - start_position += input_batch[0].shape[0] - - return tvec diff --git a/functions/create_logger.py b/functions/create_logger.py deleted file mode 100644 index b7e746f..0000000 --- a/functions/create_logger.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging -import datetime -import os - - -def create_logger( - save_logging_messages: bool, display_logging_messages: bool, log_stage_name: str -): - now = datetime.datetime.now() - dt_string_filename = now.strftime("%Y_%m_%d_%H_%M_%S") - - logger = logging.getLogger("MyLittleLogger") - logger.setLevel(logging.DEBUG) - - if save_logging_messages: - time_format = "%b %d %Y %H:%M:%S" - logformat = "%(asctime)s %(message)s" - file_formatter = logging.Formatter(fmt=logformat, datefmt=time_format) - os.makedirs("logs_" + log_stage_name, exist_ok=True) - file_handler = logging.FileHandler( - os.path.join("logs_" + log_stage_name, f"log_{dt_string_filename}.txt") - ) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - - if display_logging_messages: - time_format = "%H:%M:%S" - logformat = "%(asctime)s %(message)s" - stream_formatter = logging.Formatter(fmt=logformat, datefmt=time_format) - - stream_handler = logging.StreamHandler() - stream_handler.setLevel(logging.INFO) - stream_handler.setFormatter(stream_formatter) - logger.addHandler(stream_handler) - - return logger diff --git a/functions/data_raw_loader.py b/functions/data_raw_loader.py deleted file mode 100644 index 67e55cf..0000000 --- a/functions/data_raw_loader.py +++ /dev/null @@ -1,339 +0,0 @@ -import numpy as np -import torch -import os -import logging -import copy - -from functions.get_experiments import get_experiments -from functions.get_trials import get_trials -from functions.get_parts import get_parts -from functions.load_meta_data import load_meta_data - - -def data_raw_loader( - raw_data_path: str, - mylogger: logging.Logger, - experiment_id: int, - trial_id: int, - device: torch.device, - force_to_cpu_memory: bool, - config: dict, -) -> tuple[list[str], str, str, dict, dict, float, float, str, torch.Tensor]: - - meta_channels: list[str] = [] - meta_mouse_markings: str = "" - meta_recording_date: str = "" - meta_stimulation_times: dict = {} - meta_experiment_names: dict = {} - meta_trial_recording_duration: float = 0.0 - meta_frame_time: float = 0.0 - meta_mouse: str = "" - data: torch.Tensor = torch.zeros((1)) - - dtype_str = config["dtype"] - mylogger.info(f"Data precision will be {dtype_str}") - dtype: torch.dtype = getattr(torch, dtype_str) - dtype_np: np.dtype = getattr(np, dtype_str) - - if os.path.isdir(raw_data_path) is False: - mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") - assert os.path.isdir(raw_data_path) - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) - - if (torch.where(get_experiments(raw_data_path) == experiment_id)[0].shape[0]) != 1: - mylogger.info(f"ERROR: could not find experiment id {experiment_id}!!!!") - assert ( - torch.where(get_experiments(raw_data_path) == experiment_id)[0].shape[0] - ) == 1 - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) - - if ( - torch.where(get_trials(raw_data_path, experiment_id) == trial_id)[0].shape[0] - ) != 1: - mylogger.info(f"ERROR: could not find trial id {trial_id}!!!!") - assert ( - torch.where(get_trials(raw_data_path, experiment_id) == trial_id)[0].shape[ - 0 - ] - ) == 1 - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) - - available_parts: torch.Tensor = get_parts(raw_data_path, experiment_id, trial_id) - if available_parts.shape[0] < 1: - mylogger.info("ERROR: could not find any part files") - assert available_parts.shape[0] >= 1 - - experiment_name = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" - mylogger.info(f"Will work on: {experiment_name}") - - mylogger.info(f"We found {int(available_parts.shape[0])} parts.") - - first_run: bool = True - - mylogger.info("Compare meta data of all parts") - for id in range(0, available_parts.shape[0]): - part_id = available_parts[id] - - filename_meta: str = os.path.join( - raw_data_path, - f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}_meta.txt", - ) - - if os.path.isfile(filename_meta) is False: - mylogger.info(f"Could not load meta data... {filename_meta}") - assert os.path.isfile(filename_meta) - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) - - ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - ) = load_meta_data( - mylogger=mylogger, filename_meta=filename_meta, silent_mode=True - ) - - if first_run: - first_run = False - master_meta_channels: list[str] = copy.deepcopy(meta_channels) - master_meta_mouse_markings: str = meta_mouse_markings - master_meta_recording_date: str = meta_recording_date - master_meta_stimulation_times: dict = copy.deepcopy(meta_stimulation_times) - master_meta_experiment_names: dict = copy.deepcopy(meta_experiment_names) - master_meta_trial_recording_duration: float = meta_trial_recording_duration - master_meta_frame_time: float = meta_frame_time - master_meta_mouse: str = meta_mouse - - meta_channels_check = master_meta_channels == meta_channels - - # Check channel order - if meta_channels_check: - for channel_a, channel_b in zip(master_meta_channels, meta_channels): - if channel_a != channel_b: - meta_channels_check = False - - meta_mouse_markings_check = master_meta_mouse_markings == meta_mouse_markings - meta_recording_date_check = master_meta_recording_date == meta_recording_date - meta_stimulation_times_check = ( - master_meta_stimulation_times == meta_stimulation_times - ) - meta_experiment_names_check = ( - master_meta_experiment_names == meta_experiment_names - ) - meta_trial_recording_duration_check = ( - master_meta_trial_recording_duration == meta_trial_recording_duration - ) - meta_frame_time_check = master_meta_frame_time == meta_frame_time - meta_mouse_check = master_meta_mouse == meta_mouse - - if meta_channels_check is False: - mylogger.info(f"{filename_meta} failed: channels") - assert meta_channels_check - - if meta_mouse_markings_check is False: - mylogger.info(f"{filename_meta} failed: mouse_markings") - assert meta_mouse_markings_check - - if meta_recording_date_check is False: - mylogger.info(f"{filename_meta} failed: recording_date") - assert meta_recording_date_check - - if meta_stimulation_times_check is False: - mylogger.info(f"{filename_meta} failed: stimulation_times") - assert meta_stimulation_times_check - - if meta_experiment_names_check is False: - mylogger.info(f"{filename_meta} failed: experiment_names") - assert meta_experiment_names_check - - if meta_trial_recording_duration_check is False: - mylogger.info(f"{filename_meta} failed: trial_recording_duration") - assert meta_trial_recording_duration_check - - if meta_frame_time_check is False: - mylogger.info(f"{filename_meta} failed: frame_time_check") - assert meta_frame_time_check - - if meta_mouse_check is False: - mylogger.info(f"{filename_meta} failed: mouse") - assert meta_mouse_check - mylogger.info("-==- Done -==-") - - mylogger.info(f"Will use: {filename_meta} for meta data") - ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - ) = load_meta_data(mylogger=mylogger, filename_meta=filename_meta) - - ################# - # Meta data end # - ################# - - first_run = True - mylogger.info("Count the number of frames in the data of all parts") - frame_count: int = 0 - for id in range(0, available_parts.shape[0]): - part_id = available_parts[id] - - filename_data: str = os.path.join( - raw_data_path, - f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}.npy", - ) - - if os.path.isfile(filename_data) is False: - mylogger.info(f"Could not load data... {filename_data}") - assert os.path.isfile(filename_data) - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) - data_np: np.ndarray = np.load(filename_data, mmap_mode="r") - - if data_np.ndim != 4: - mylogger.info(f"ERROR: Data needs to have 4 dimensions {filename_data}") - assert data_np.ndim == 4 - - if first_run: - first_run = False - dim_0: int = int(data_np.shape[0]) - dim_1: int = int(data_np.shape[1]) - dim_3: int = int(data_np.shape[3]) - - frame_count += int(data_np.shape[2]) - - if int(data_np.shape[0]) != dim_0: - mylogger.info( - f"ERROR: Data dim 0 is broken {int(data_np.shape[0])} vs {dim_0} {filename_data}" - ) - assert int(data_np.shape[0]) == dim_0 - - if int(data_np.shape[1]) != dim_1: - mylogger.info( - f"ERROR: Data dim 1 is broken {int(data_np.shape[1])} vs {dim_1} {filename_data}" - ) - assert int(data_np.shape[1]) == dim_1 - - if int(data_np.shape[3]) != dim_3: - mylogger.info( - f"ERROR: Data dim 3 is broken {int(data_np.shape[3])} vs {dim_3} {filename_data}" - ) - assert int(data_np.shape[3]) == dim_3 - - mylogger.info( - f"{filename_data}: {int(data_np.shape[2])} frames -> {frame_count} frames total" - ) - - if force_to_cpu_memory: - mylogger.info("Using CPU memory for data") - data = torch.empty( - (dim_0, dim_1, frame_count, dim_3), dtype=dtype, device=torch.device("cpu") - ) - else: - mylogger.info("Using GPU memory for data") - data = torch.empty( - (dim_0, dim_1, frame_count, dim_3), dtype=dtype, device=device - ) - - start_position: int = 0 - end_position: int = 0 - for id in range(0, available_parts.shape[0]): - part_id = available_parts[id] - - filename_data = os.path.join( - raw_data_path, - f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}.npy", - ) - - mylogger.info(f"Will work on {filename_data}") - mylogger.info("Loading data file") - data_np = np.load(filename_data).astype(dtype_np) - - end_position = start_position + int(data_np.shape[2]) - - for i in range(0, len(config["required_order"])): - mylogger.info(f"Move raw data channel: {config['required_order'][i]}") - - idx = meta_channels.index(config["required_order"][i]) - data[..., start_position:end_position, i] = torch.tensor( - data_np[..., idx], dtype=dtype, device=data.device - ) - start_position = end_position - - if start_position != int(data.shape[2]): - mylogger.info("ERROR: data was not fulled fully!!!") - assert start_position == int(data.shape[2]) - - mylogger.info("-==- Done -==-") - - ################# - # Raw data end # - ################# - - return ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) diff --git a/functions/gauss_smear_individual.py b/functions/gauss_smear_individual.py deleted file mode 100644 index 73dba65..0000000 --- a/functions/gauss_smear_individual.py +++ /dev/null @@ -1,168 +0,0 @@ -import torch -import math - - -@torch.no_grad() -def gauss_smear_individual( - input: torch.Tensor, - spatial_width: float, - temporal_width: float, - overwrite_fft_gauss: None | torch.Tensor = None, - use_matlab_mask: bool = True, - epsilon: float = float(torch.finfo(torch.float64).eps), -) -> tuple[torch.Tensor, torch.Tensor]: - try: - return gauss_smear_individual_core( - input=input, - spatial_width=spatial_width, - temporal_width=temporal_width, - overwrite_fft_gauss=overwrite_fft_gauss, - use_matlab_mask=use_matlab_mask, - epsilon=epsilon, - ) - except torch.cuda.OutOfMemoryError: - - if overwrite_fft_gauss is None: - overwrite_fft_gauss_cpu: None | torch.Tensor = None - else: - overwrite_fft_gauss_cpu = overwrite_fft_gauss.cpu() - - input_cpu: torch.Tensor = input.cpu() - - output, overwrite_fft_gauss = gauss_smear_individual_core( - input=input_cpu, - spatial_width=spatial_width, - temporal_width=temporal_width, - overwrite_fft_gauss=overwrite_fft_gauss_cpu, - use_matlab_mask=use_matlab_mask, - epsilon=epsilon, - ) - return ( - output.to(device=input.device), - overwrite_fft_gauss.to(device=input.device), - ) - - -@torch.no_grad() -def gauss_smear_individual_core( - input: torch.Tensor, - spatial_width: float, - temporal_width: float, - overwrite_fft_gauss: None | torch.Tensor = None, - use_matlab_mask: bool = True, - epsilon: float = float(torch.finfo(torch.float64).eps), -) -> tuple[torch.Tensor, torch.Tensor]: - - dim_x: int = int(2 * math.ceil(2 * spatial_width) + 1) - dim_y: int = int(2 * math.ceil(2 * spatial_width) + 1) - dim_t: int = int(2 * math.ceil(2 * temporal_width) + 1) - dims_xyt: torch.Tensor = torch.tensor( - [dim_x, dim_y, dim_t], dtype=torch.int64, device=input.device - ) - - if input.ndim == 2: - input = input.unsqueeze(-1) - - input_padded = torch.nn.functional.pad( - input.unsqueeze(0), - pad=( - dim_t, - dim_t, - dim_y, - dim_y, - dim_x, - dim_x, - ), - mode="replicate", - ).squeeze(0) - - if overwrite_fft_gauss is None: - center_x: int = int(math.ceil(input_padded.shape[0] / 2)) - center_y: int = int(math.ceil(input_padded.shape[1] / 2)) - center_z: int = int(math.ceil(input_padded.shape[2] / 2)) - grid_x: torch.Tensor = ( - torch.arange(0, input_padded.shape[0], device=input.device) - center_x + 1 - ) - grid_y: torch.Tensor = ( - torch.arange(0, input_padded.shape[1], device=input.device) - center_y + 1 - ) - grid_z: torch.Tensor = ( - torch.arange(0, input_padded.shape[2], device=input.device) - center_z + 1 - ) - - grid_x = grid_x.unsqueeze(-1).unsqueeze(-1) ** 2 - grid_y = grid_y.unsqueeze(0).unsqueeze(-1) ** 2 - grid_z = grid_z.unsqueeze(0).unsqueeze(0) ** 2 - - gauss_kernel: torch.Tensor = ( - (grid_x / (spatial_width**2)) - + (grid_y / (spatial_width**2)) - + (grid_z / (temporal_width**2)) - ) - - if use_matlab_mask: - filter_radius: torch.Tensor = (dims_xyt - 1) // 2 - - border_lower: list[int] = [ - center_x - int(filter_radius[0]) - 1, - center_y - int(filter_radius[1]) - 1, - center_z - int(filter_radius[2]) - 1, - ] - - border_upper: list[int] = [ - center_x + int(filter_radius[0]), - center_y + int(filter_radius[1]), - center_z + int(filter_radius[2]), - ] - - matlab_mask: torch.Tensor = torch.zeros_like(gauss_kernel) - matlab_mask[ - border_lower[0] : border_upper[0], - border_lower[1] : border_upper[1], - border_lower[2] : border_upper[2], - ] = 1.0 - - gauss_kernel = torch.exp(-gauss_kernel / 2.0) - if use_matlab_mask: - gauss_kernel = gauss_kernel * matlab_mask - - gauss_kernel[gauss_kernel < (epsilon * gauss_kernel.max())] = 0 - - sum_gauss_kernel: float = float(gauss_kernel.sum()) - - if sum_gauss_kernel != 0.0: - gauss_kernel = gauss_kernel / sum_gauss_kernel - - # FFT Shift - gauss_kernel = torch.cat( - (gauss_kernel[center_x - 1 :, :, :], gauss_kernel[: center_x - 1, :, :]), - dim=0, - ) - gauss_kernel = torch.cat( - (gauss_kernel[:, center_y - 1 :, :], gauss_kernel[:, : center_y - 1, :]), - dim=1, - ) - gauss_kernel = torch.cat( - (gauss_kernel[:, :, center_z - 1 :], gauss_kernel[:, :, : center_z - 1]), - dim=2, - ) - overwrite_fft_gauss = torch.fft.fftn(gauss_kernel) - input_padded_gauss_filtered: torch.Tensor = torch.real( - torch.fft.ifftn(torch.fft.fftn(input_padded) * overwrite_fft_gauss) - ) - else: - input_padded_gauss_filtered = torch.real( - torch.fft.ifftn(torch.fft.fftn(input_padded) * overwrite_fft_gauss) - ) - - start = dims_xyt - stop = ( - torch.tensor(input_padded.shape, device=dims_xyt.device, dtype=dims_xyt.dtype) - - dims_xyt - ) - - output = input_padded_gauss_filtered[ - start[0] : stop[0], start[1] : stop[1], start[2] : stop[2] - ] - - return (output, overwrite_fft_gauss) diff --git a/functions/get_experiments.py b/functions/get_experiments.py deleted file mode 100644 index d92b936..0000000 --- a/functions/get_experiments.py +++ /dev/null @@ -1,19 +0,0 @@ -import torch -import os -import glob - - -@torch.no_grad() -def get_experiments(path: str) -> torch.Tensor: - filename_np: str = os.path.join( - path, - "Exp*_Part001.npy", - ) - - list_str = glob.glob(filename_np) - list_int: list[int] = [] - for i in range(0, len(list_str)): - list_int.append(int(list_str[i].split("Exp")[-1].split("_Trial")[0])) - list_int = sorted(list_int) - - return torch.tensor(list_int).unique() diff --git a/functions/get_parts.py b/functions/get_parts.py deleted file mode 100644 index d68e1ae..0000000 --- a/functions/get_parts.py +++ /dev/null @@ -1,18 +0,0 @@ -import torch -import os -import glob - - -@torch.no_grad() -def get_parts(path: str, experiment_id: int, trial_id: int) -> torch.Tensor: - filename_np: str = os.path.join( - path, - f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part*.npy", - ) - - list_str = glob.glob(filename_np) - list_int: list[int] = [] - for i in range(0, len(list_str)): - list_int.append(int(list_str[i].split("_Part")[-1].split(".npy")[0])) - list_int = sorted(list_int) - return torch.tensor(list_int).unique() diff --git a/functions/get_torch_device.py b/functions/get_torch_device.py deleted file mode 100644 index 9eec5e9..0000000 --- a/functions/get_torch_device.py +++ /dev/null @@ -1,17 +0,0 @@ -import torch -import logging - - -def get_torch_device(mylogger: logging.Logger, force_to_cpu: bool) -> torch.device: - - if torch.cuda.is_available(): - device_name: str = "cuda:0" - else: - device_name = "cpu" - - if force_to_cpu: - device_name = "cpu" - - mylogger.info(f"Using device: {device_name}") - device: torch.device = torch.device(device_name) - return device diff --git a/functions/get_trials.py b/functions/get_trials.py deleted file mode 100644 index abe33d2..0000000 --- a/functions/get_trials.py +++ /dev/null @@ -1,19 +0,0 @@ -import torch -import os -import glob - - -@torch.no_grad() -def get_trials(path: str, experiment_id: int) -> torch.Tensor: - filename_np: str = os.path.join( - path, - f"Exp{experiment_id:03d}_Trial*_Part001.npy", - ) - - list_str = glob.glob(filename_np) - list_int: list[int] = [] - for i in range(0, len(list_str)): - list_int.append(int(list_str[i].split("_Trial")[-1].split("_Part")[0])) - - list_int = sorted(list_int) - return torch.tensor(list_int).unique() diff --git a/functions/load_config.py b/functions/load_config.py deleted file mode 100644 index c17fa40..0000000 --- a/functions/load_config.py +++ /dev/null @@ -1,16 +0,0 @@ -import json -import os -import logging - -from jsmin import jsmin # type:ignore - - -def load_config(mylogger: logging.Logger, filename: str = "config.json") -> dict: - mylogger.info("loading config file") - if os.path.isfile(filename) is False: - mylogger.info(f"{filename} is missing") - - with open(filename, "r") as file: - config = json.loads(jsmin(file.read())) - - return config diff --git a/functions/load_meta_data.py b/functions/load_meta_data.py deleted file mode 100644 index 622473c..0000000 --- a/functions/load_meta_data.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import json - - -def load_meta_data( - mylogger: logging.Logger, filename_meta: str, silent_mode=False -) -> tuple[list[str], str, str, dict, dict, float, float, str]: - - if silent_mode is False: - mylogger.info("Loading meta data") - with open(filename_meta, "r") as file_handle: - metadata: dict = json.load(file_handle) - - channels: list[str] = metadata["channelKey"] - - if silent_mode is False: - mylogger.info(f"meta data: channel order: {channels}") - - if "mouseMarkings" in metadata["sessionMetaData"]: - mouse_markings: str = metadata["sessionMetaData"]["mouseMarkings"] - if silent_mode is False: - mylogger.info(f"meta data: mouse markings: {mouse_markings}") - else: - mouse_markings = "" - if silent_mode is False: - mylogger.info("meta data: no mouse markings") - - recording_date: str = metadata["sessionMetaData"]["date"] - if silent_mode is False: - mylogger.info(f"meta data: recording data: {recording_date}") - - stimulation_times: dict = metadata["sessionMetaData"]["stimulationTimes"] - if silent_mode is False: - mylogger.info(f"meta data: stimulation times: {stimulation_times}") - - experiment_names: dict = metadata["sessionMetaData"]["experimentNames"] - if silent_mode is False: - mylogger.info(f"meta data: experiment names: {experiment_names}") - - trial_recording_duration: float = float( - metadata["sessionMetaData"]["trialRecordingDuration"] - ) - if silent_mode is False: - mylogger.info( - f"meta data: trial recording duration: {trial_recording_duration} sec" - ) - - frame_time: float = float(metadata["sessionMetaData"]["frameTime"]) - if silent_mode is False: - mylogger.info( - f"meta data: frame time: {frame_time} sec ; frame rate: {1.0/frame_time}Hz" - ) - - mouse: str = metadata["sessionMetaData"]["mouse"] - if silent_mode is False: - mylogger.info(f"meta data: mouse: {mouse}") - mylogger.info("-==- Done -==-") - - return ( - channels, - mouse_markings, - recording_date, - stimulation_times, - experiment_names, - trial_recording_duration, - frame_time, - mouse, - ) diff --git a/functions/perform_donor_volume_rotation.py b/functions/perform_donor_volume_rotation.py deleted file mode 100644 index 1d2f55b..0000000 --- a/functions/perform_donor_volume_rotation.py +++ /dev/null @@ -1,207 +0,0 @@ -import torch -import torchvision as tv # type: ignore -import logging -from functions.calculate_rotation import calculate_rotation -from functions.ImageAlignment import ImageAlignment - - -@torch.no_grad() -def perform_donor_volume_rotation( - mylogger: logging.Logger, - acceptor: torch.Tensor, - donor: torch.Tensor, - oxygenation: torch.Tensor, - volume: torch.Tensor, - ref_image_donor: torch.Tensor, - ref_image_volume: torch.Tensor, - batch_size: int, - config: dict, - fill_value: float = 0, -) -> tuple[ - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, -]: - try: - - return perform_donor_volume_rotation_internal( - mylogger=mylogger, - acceptor=acceptor, - donor=donor, - oxygenation=oxygenation, - volume=volume, - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=batch_size, - config=config, - fill_value=fill_value, - ) - - except torch.cuda.OutOfMemoryError: - - ( - acceptor_cpu, - donor_cpu, - oxygenation_cpu, - volume_cpu, - angle_donor_volume_cpu, - ) = perform_donor_volume_rotation_internal( - mylogger=mylogger, - acceptor=acceptor.cpu(), - donor=donor.cpu(), - oxygenation=oxygenation.cpu(), - volume=volume.cpu(), - ref_image_donor=ref_image_donor.cpu(), - ref_image_volume=ref_image_volume.cpu(), - batch_size=batch_size, - config=config, - fill_value=fill_value, - ) - - return ( - acceptor_cpu.to(device=acceptor.device), - donor_cpu.to(device=acceptor.device), - oxygenation_cpu.to(device=acceptor.device), - volume_cpu.to(device=acceptor.device), - angle_donor_volume_cpu.to(device=acceptor.device), - ) - - -@torch.no_grad() -def perform_donor_volume_rotation_internal( - mylogger: logging.Logger, - acceptor: torch.Tensor, - donor: torch.Tensor, - oxygenation: torch.Tensor, - volume: torch.Tensor, - ref_image_donor: torch.Tensor, - ref_image_volume: torch.Tensor, - batch_size: int, - config: dict, - fill_value: float = 0, -) -> tuple[ - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, -]: - - image_alignment = ImageAlignment( - default_dtype=acceptor.dtype, device=acceptor.device - ) - - mylogger.info("Calculate rotation between donor data and donor ref image") - - angle_donor = calculate_rotation( - input=donor, - reference_image=ref_image_donor, - image_alignment=image_alignment, - batch_size=batch_size, - ) - - mylogger.info("Calculate rotation between volume data and volume ref image") - angle_volume = calculate_rotation( - input=volume, - reference_image=ref_image_volume, - image_alignment=image_alignment, - batch_size=batch_size, - ) - - mylogger.info("Average over both rotations") - - donor_threshold: torch.Tensor = torch.sort(torch.abs(angle_donor))[0] - donor_threshold = donor_threshold[ - int( - donor_threshold.shape[0] - * float(config["rotation_stabilization_threshold_border"]) - ) - ] * float(config["rotation_stabilization_threshold_factor"]) - - volume_threshold: torch.Tensor = torch.sort(torch.abs(angle_volume))[0] - volume_threshold = volume_threshold[ - int( - volume_threshold.shape[0] - * float(config["rotation_stabilization_threshold_border"]) - ) - ] * float(config["rotation_stabilization_threshold_factor"]) - - donor_idx = torch.where(torch.abs(angle_donor) > donor_threshold)[0] - volume_idx = torch.where(torch.abs(angle_volume) > volume_threshold)[0] - mylogger.info( - f"Border: {config['rotation_stabilization_threshold_border']}, " - f"factor {config['rotation_stabilization_threshold_factor']} " - ) - mylogger.info( - f"Donor threshold: {donor_threshold:.3e}, volume threshold: {volume_threshold:.3e}" - ) - mylogger.info( - f"Found broken rotation values: " - f"donor {int(donor_idx.shape[0])}, " - f"volume {int(volume_idx.shape[0])}" - ) - angle_donor[donor_idx] = angle_volume[donor_idx] - angle_volume[volume_idx] = angle_donor[volume_idx] - - donor_idx = torch.where(torch.abs(angle_donor) > donor_threshold)[0] - volume_idx = torch.where(torch.abs(angle_volume) > volume_threshold)[0] - mylogger.info( - f"After fill in these broken rotation values remain: " - f"donor {int(donor_idx.shape[0])}, " - f"volume {int(volume_idx.shape[0])}" - ) - angle_donor[donor_idx] = 0.0 - angle_volume[volume_idx] = 0.0 - angle_donor_volume = (angle_donor + angle_volume) / 2.0 - - mylogger.info("Rotate acceptor data based on the average rotation") - for frame_id in range(0, angle_donor_volume.shape[0]): - acceptor[frame_id, ...] = tv.transforms.functional.affine( - img=acceptor[frame_id, ...].unsqueeze(0), - angle=-float(angle_donor_volume[frame_id]), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Rotate donor data based on the average rotation") - for frame_id in range(0, angle_donor_volume.shape[0]): - donor[frame_id, ...] = tv.transforms.functional.affine( - img=donor[frame_id, ...].unsqueeze(0), - angle=-float(angle_donor_volume[frame_id]), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Rotate oxygenation data based on the average rotation") - for frame_id in range(0, angle_donor_volume.shape[0]): - oxygenation[frame_id, ...] = tv.transforms.functional.affine( - img=oxygenation[frame_id, ...].unsqueeze(0), - angle=-float(angle_donor_volume[frame_id]), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Rotate volume data based on the average rotation") - for frame_id in range(0, angle_donor_volume.shape[0]): - volume[frame_id, ...] = tv.transforms.functional.affine( - img=volume[frame_id, ...].unsqueeze(0), - angle=-float(angle_donor_volume[frame_id]), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - return (acceptor, donor, oxygenation, volume, angle_donor_volume) diff --git a/functions/perform_donor_volume_translation.py b/functions/perform_donor_volume_translation.py deleted file mode 100644 index 72e94fa..0000000 --- a/functions/perform_donor_volume_translation.py +++ /dev/null @@ -1,210 +0,0 @@ -import torch -import torchvision as tv # type: ignore -import logging - -from functions.calculate_translation import calculate_translation -from functions.ImageAlignment import ImageAlignment - - -@torch.no_grad() -def perform_donor_volume_translation( - mylogger: logging.Logger, - acceptor: torch.Tensor, - donor: torch.Tensor, - oxygenation: torch.Tensor, - volume: torch.Tensor, - ref_image_donor: torch.Tensor, - ref_image_volume: torch.Tensor, - batch_size: int, - config: dict, - fill_value: float = 0, -) -> tuple[ - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, -]: - try: - - return perform_donor_volume_translation_internal( - mylogger=mylogger, - acceptor=acceptor, - donor=donor, - oxygenation=oxygenation, - volume=volume, - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=batch_size, - config=config, - fill_value=fill_value, - ) - - except torch.cuda.OutOfMemoryError: - - ( - acceptor_cpu, - donor_cpu, - oxygenation_cpu, - volume_cpu, - tvec_donor_volume_cpu, - ) = perform_donor_volume_translation_internal( - mylogger=mylogger, - acceptor=acceptor.cpu(), - donor=donor.cpu(), - oxygenation=oxygenation.cpu(), - volume=volume.cpu(), - ref_image_donor=ref_image_donor.cpu(), - ref_image_volume=ref_image_volume.cpu(), - batch_size=batch_size, - config=config, - fill_value=fill_value, - ) - - return ( - acceptor_cpu.to(device=acceptor.device), - donor_cpu.to(device=acceptor.device), - oxygenation_cpu.to(device=acceptor.device), - volume_cpu.to(device=acceptor.device), - tvec_donor_volume_cpu.to(device=acceptor.device), - ) - - -@torch.no_grad() -def perform_donor_volume_translation_internal( - mylogger: logging.Logger, - acceptor: torch.Tensor, - donor: torch.Tensor, - oxygenation: torch.Tensor, - volume: torch.Tensor, - ref_image_donor: torch.Tensor, - ref_image_volume: torch.Tensor, - batch_size: int, - config: dict, - fill_value: float = 0, -) -> tuple[ - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, -]: - - image_alignment = ImageAlignment( - default_dtype=acceptor.dtype, device=acceptor.device - ) - - mylogger.info("Calculate translation between donor data and donor ref image") - tvec_donor = calculate_translation( - input=donor, - reference_image=ref_image_donor, - image_alignment=image_alignment, - batch_size=batch_size, - ) - - mylogger.info("Calculate translation between volume data and volume ref image") - tvec_volume = calculate_translation( - input=volume, - reference_image=ref_image_volume, - image_alignment=image_alignment, - batch_size=batch_size, - ) - - mylogger.info("Average over both translations") - - for i in range(0, 2): - mylogger.info(f"Processing dimension {i}") - donor_threshold: torch.Tensor = torch.sort(torch.abs(tvec_donor[:, i]))[0] - donor_threshold = donor_threshold[ - int( - donor_threshold.shape[0] - * float(config["rotation_stabilization_threshold_border"]) - ) - ] * float(config["rotation_stabilization_threshold_factor"]) - - volume_threshold: torch.Tensor = torch.sort(torch.abs(tvec_volume[:, i]))[0] - volume_threshold = volume_threshold[ - int( - volume_threshold.shape[0] - * float(config["rotation_stabilization_threshold_border"]) - ) - ] * float(config["rotation_stabilization_threshold_factor"]) - - donor_idx = torch.where(torch.abs(tvec_donor[:, i]) > donor_threshold)[0] - volume_idx = torch.where(torch.abs(tvec_volume[:, i]) > volume_threshold)[0] - mylogger.info( - f"Border: {config['rotation_stabilization_threshold_border']}, " - f"factor {config['rotation_stabilization_threshold_factor']} " - ) - mylogger.info( - f"Donor threshold: {donor_threshold:.3e}, volume threshold: {volume_threshold:.3e}" - ) - mylogger.info( - f"Found broken rotation values: " - f"donor {int(donor_idx.shape[0])}, " - f"volume {int(volume_idx.shape[0])}" - ) - tvec_donor[donor_idx, i] = tvec_volume[donor_idx, i] - tvec_volume[volume_idx, i] = tvec_donor[volume_idx, i] - - donor_idx = torch.where(torch.abs(tvec_donor[:, i]) > donor_threshold)[0] - volume_idx = torch.where(torch.abs(tvec_volume[:, i]) > volume_threshold)[0] - mylogger.info( - f"After fill in these broken rotation values remain: " - f"donor {int(donor_idx.shape[0])}, " - f"volume {int(volume_idx.shape[0])}" - ) - tvec_donor[donor_idx, i] = 0.0 - tvec_volume[volume_idx, i] = 0.0 - - tvec_donor_volume = (tvec_donor + tvec_volume) / 2.0 - - mylogger.info("Translate acceptor data based on the average translation vector") - for frame_id in range(0, tvec_donor_volume.shape[0]): - acceptor[frame_id, ...] = tv.transforms.functional.affine( - img=acceptor[frame_id, ...].unsqueeze(0), - angle=0, - translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Translate donor data based on the average translation vector") - for frame_id in range(0, tvec_donor_volume.shape[0]): - donor[frame_id, ...] = tv.transforms.functional.affine( - img=donor[frame_id, ...].unsqueeze(0), - angle=0, - translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Translate oxygenation data based on the average translation vector") - for frame_id in range(0, tvec_donor_volume.shape[0]): - oxygenation[frame_id, ...] = tv.transforms.functional.affine( - img=oxygenation[frame_id, ...].unsqueeze(0), - angle=0, - translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - mylogger.info("Translate volume data based on the average translation vector") - for frame_id in range(0, tvec_donor_volume.shape[0]): - volume[frame_id, ...] = tv.transforms.functional.affine( - img=volume[frame_id, ...].unsqueeze(0), - angle=0, - translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=fill_value, - ).squeeze(0) - - return (acceptor, donor, oxygenation, volume, tvec_donor_volume) diff --git a/functions/regression.py b/functions/regression.py deleted file mode 100644 index d4efac0..0000000 --- a/functions/regression.py +++ /dev/null @@ -1,117 +0,0 @@ -import torch -import logging -from functions.regression_internal import regression_internal - - -@torch.no_grad() -def regression( - mylogger: logging.Logger, - target_camera_id: int, - regressor_camera_ids: list[int], - mask: torch.Tensor, - data: torch.Tensor, - data_filtered: torch.Tensor, - first_none_ramp_frame: int, -) -> tuple[torch.Tensor, torch.Tensor]: - - assert len(regressor_camera_ids) > 0 - - mylogger.info("Prepare the target signal - 1.0 (from data_filtered)") - target_signals_train: torch.Tensor = ( - data_filtered[target_camera_id, ..., first_none_ramp_frame:].clone() - 1.0 - ) - target_signals_train[target_signals_train < -1] = 0.0 - - # Check if everything is happy - assert target_signals_train.ndim == 3 - assert target_signals_train.ndim == data[target_camera_id, ...].ndim - assert target_signals_train.shape[0] == data[target_camera_id, ...].shape[0] - assert target_signals_train.shape[1] == data[target_camera_id, ...].shape[1] - assert (target_signals_train.shape[2] + first_none_ramp_frame) == data[ - target_camera_id, ... - ].shape[2] - - mylogger.info("Prepare the regressor signals (linear plus from data_filtered)") - - regressor_signals_train: torch.Tensor = torch.zeros( - ( - data_filtered.shape[1], - data_filtered.shape[2], - data_filtered.shape[3], - len(regressor_camera_ids) + 1, - ), - device=data_filtered.device, - dtype=data_filtered.dtype, - ) - - mylogger.info("Copy the regressor signals - 1.0") - for matrix_id, id in enumerate(regressor_camera_ids): - regressor_signals_train[..., matrix_id] = data_filtered[id, ...] - 1.0 - - regressor_signals_train[regressor_signals_train < -1] = 0.0 - - mylogger.info("Create the linear regressor") - trend = torch.arange( - 0, regressor_signals_train.shape[-2], device=data_filtered.device - ) / float(regressor_signals_train.shape[-2] - 1) - trend -= trend.mean() - trend = trend.unsqueeze(0).unsqueeze(0) - trend = trend.tile( - (regressor_signals_train.shape[0], regressor_signals_train.shape[1], 1) - ) - regressor_signals_train[..., -1] = trend - - regressor_signals_train = regressor_signals_train[:, :, first_none_ramp_frame:, :] - - mylogger.info("Calculating the regression coefficients") - coefficients, intercept = regression_internal( - input_regressor=regressor_signals_train, input_target=target_signals_train - ) - del regressor_signals_train - del target_signals_train - - mylogger.info("Prepare the target signal - 1.0 (from data)") - target_signals_perform: torch.Tensor = data[target_camera_id, ...].clone() - 1.0 - - mylogger.info("Prepare the regressor signals (linear plus from data)") - regressor_signals_perform: torch.Tensor = torch.zeros( - ( - data.shape[1], - data.shape[2], - data.shape[3], - len(regressor_camera_ids) + 1, - ), - device=data.device, - dtype=data.dtype, - ) - - mylogger.info("Copy the regressor signals - 1.0 ") - for matrix_id, id in enumerate(regressor_camera_ids): - regressor_signals_perform[..., matrix_id] = data[id] - 1.0 - - mylogger.info("Create the linear regressor") - trend = torch.arange( - 0, regressor_signals_perform.shape[-2], device=data[0].device - ) / float(regressor_signals_perform.shape[-2] - 1) - trend -= trend.mean() - trend = trend.unsqueeze(0).unsqueeze(0) - trend = trend.tile( - (regressor_signals_perform.shape[0], regressor_signals_perform.shape[1], 1) - ) - regressor_signals_perform[..., -1] = trend - - mylogger.info("Remove regressors") - target_signals_perform -= ( - regressor_signals_perform * coefficients.unsqueeze(-2) - ).sum(dim=-1) - - mylogger.info("Remove offset") - target_signals_perform -= intercept.unsqueeze(-1) - - mylogger.info("Remove masked pixels") - target_signals_perform[mask, :] = 0.0 - - mylogger.info("Add an offset of 1.0") - target_signals_perform += 1.0 - - return target_signals_perform, coefficients diff --git a/functions/regression_internal.py b/functions/regression_internal.py deleted file mode 100644 index dd94d3c..0000000 --- a/functions/regression_internal.py +++ /dev/null @@ -1,27 +0,0 @@ -import torch - - -def regression_internal( - input_regressor: torch.Tensor, input_target: torch.Tensor -) -> tuple[torch.Tensor, torch.Tensor]: - - regressor_offset = input_regressor.mean(keepdim=True, dim=-2) - target_offset = input_target.mean(keepdim=True, dim=-1) - - regressor = input_regressor - regressor_offset - target = input_target - target_offset - - try: - coefficients, _, _, _ = torch.linalg.lstsq(regressor, target, rcond=None) - except torch.cuda.OutOfMemoryError: - coefficients_cpu, _, _, _ = torch.linalg.lstsq( - regressor.cpu(), target.cpu(), rcond=None - ) - coefficients = coefficients_cpu.to(regressor.device, copy=True) - del coefficients_cpu - - intercept = target_offset.squeeze(-1) - ( - coefficients * regressor_offset.squeeze(-2) - ).sum(dim=-1) - - return coefficients, intercept From a32fd9be39840ecd1bc90f29ae05e04b9b4b6a71 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:10 +0100 Subject: [PATCH 02/25] Delete callum_config_M0134M.json --- callum_config_M0134M.json | 66 --------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 callum_config_M0134M.json diff --git a/callum_config_M0134M.json b/callum_config_M0134M.json deleted file mode 100644 index f283745..0000000 --- a/callum_config_M0134M.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "basic_path": "/data_1/fatma/GEVI/", - "recoding_data": "2024-11-18", - "mouse_identifier": "M0134M_SessionA", - "raw_path": "raw", - "export_path": "output_M0134M_SessionA", - "ref_image_path": "ref_images_M0134M_SessionA", - "raw_path": "raw", - "heartbeat_remove": true, - "gevi": true, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - "target_camera_acceptor": "acceptor", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - "save_aligned_as_python": false, - "save_aligned_as_matlab": false, - "save_oxyvol_as_python": false, - "save_oxyvol_as_matlab": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From cfe688b0a3643f30aa5235dfbe8c035235d371f8 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:19 +0100 Subject: [PATCH 03/25] Delete callum_config_M3905F.json --- callum_config_M3905F.json | 66 --------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 callum_config_M3905F.json diff --git a/callum_config_M3905F.json b/callum_config_M3905F.json deleted file mode 100644 index 8517026..0000000 --- a/callum_config_M3905F.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "basic_path": "/data_1/fatma/GEVI_GECI_ES", - "recoding_data": "session_B", - "mouse_identifier": "M3905F", - "raw_path": "raw", - "export_path": "output_M3905F_session_B", - "ref_image_path": "ref_images_M3905F_session_B", - "raw_path": "raw", - "heartbeat_remove": true, - "gevi": true, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - "target_camera_acceptor": "acceptor", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - "save_aligned_as_python": false, - "save_aligned_as_matlab": false, - "save_oxyvol_as_python": true, - "save_oxyvol_as_matlab": true, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From 34eb1044d0a00b73e68d0a093193a56e5c84806d Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:33 +0100 Subject: [PATCH 04/25] Delete config_M3879M_2021-10-05.json --- config_M3879M_2021-10-05.json | 61 ----------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 config_M3879M_2021-10-05.json diff --git a/config_M3879M_2021-10-05.json b/config_M3879M_2021-10-05.json deleted file mode 100644 index da3980a..0000000 --- a/config_M3879M_2021-10-05.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "basic_path": "/data_1/robert", - "recoding_data": "2021-10-05", - "mouse_identifier": "M3879M", - "raw_path": "raw", - "export_path": "output_M3879M_2021-10-05", - "ref_image_path": "ref_images_M3879M_2021-10-05", - "heartbeat_remove": true, // if gevi must be true; geci: who knows... - "gevi": true, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - "target_camera_acceptor": "acceptor", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From f7f286689e4e93cbffe334d6285cee0143f74ce4 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:41 +0100 Subject: [PATCH 05/25] Delete config_M_Sert_Cre_41.json --- config_M_Sert_Cre_41.json | 62 --------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 config_M_Sert_Cre_41.json diff --git a/config_M_Sert_Cre_41.json b/config_M_Sert_Cre_41.json deleted file mode 100644 index 3675a3d..0000000 --- a/config_M_Sert_Cre_41.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "basic_path": "/data_1/hendrik", - "recoding_data": "2023-07-17", - "mouse_identifier": "M_Sert_Cre_41", - "raw_path": "raw", - "export_path": "output_M_Sert_Cre_41", - "ref_image_path": "ref_images_M_Sert_Cre_41", - "heartbeat_remove": false, - "gevi": false, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - //"target_camera_acceptor": "acceptor", - "target_camera_acceptor": "", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - // "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} \ No newline at end of file From 2e1906fd64e978a80a0e7c849ee8fd67ca5d80eb Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:49 +0100 Subject: [PATCH 06/25] Delete config_M_Sert_Cre_42.json --- config_M_Sert_Cre_42.json | 62 --------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 config_M_Sert_Cre_42.json diff --git a/config_M_Sert_Cre_42.json b/config_M_Sert_Cre_42.json deleted file mode 100644 index 47d0ab6..0000000 --- a/config_M_Sert_Cre_42.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "basic_path": "/data_1/hendrik", - "recoding_data": "2023-07-18", - "mouse_identifier": "M_Sert_Cre_42", - "raw_path": "raw", - "export_path": "output_M_Sert_Cre_42", - "ref_image_path": "ref_images_M_Sert_Cre_42", - "heartbeat_remove": false, - "gevi": false, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - //"target_camera_acceptor": "acceptor", - "target_camera_acceptor": "", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - // "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From 19027229015f181d0340344fe3f53f9864413012 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:27:57 +0100 Subject: [PATCH 07/25] Delete config_M_Sert_Cre_45.json --- config_M_Sert_Cre_45.json | 62 --------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 config_M_Sert_Cre_45.json diff --git a/config_M_Sert_Cre_45.json b/config_M_Sert_Cre_45.json deleted file mode 100644 index e28c337..0000000 --- a/config_M_Sert_Cre_45.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "basic_path": "/data_1/hendrik", - "recoding_data": "2023-07-18", - "mouse_identifier": "M_Sert_Cre_45", - "raw_path": "raw", - "export_path": "output_M_Sert_Cre_45", - "ref_image_path": "ref_images_M_Sert_Cre_45", - "heartbeat_remove": false, - "gevi": false, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - //"target_camera_acceptor": "acceptor", - "target_camera_acceptor": "", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - // "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From 0bb8964e1cfb24cdc179dbf880e1be4c7bdde891 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:06 +0100 Subject: [PATCH 08/25] Delete config_M_Sert_Cre_46.json --- config_M_Sert_Cre_46.json | 62 --------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 config_M_Sert_Cre_46.json diff --git a/config_M_Sert_Cre_46.json b/config_M_Sert_Cre_46.json deleted file mode 100644 index 21db1d5..0000000 --- a/config_M_Sert_Cre_46.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "basic_path": "/data_1/hendrik", - "recoding_data": "2023-03-16", - "mouse_identifier": "M_Sert_Cre_46", - "raw_path": "raw", - "export_path": "output_M_Sert_Cre_46", - "ref_image_path": "ref_images_M_Sert_Cre_46", - "heartbeat_remove": false, - "gevi": false, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - //"target_camera_acceptor": "acceptor", - "target_camera_acceptor": "", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - // "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From c8b9fadade30f5e8dfb7eca36e9e0bf4db8779d0 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:19 +0100 Subject: [PATCH 09/25] Delete config_M_Sert_Cre_49.json --- config_M_Sert_Cre_49.json | 62 --------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 config_M_Sert_Cre_49.json diff --git a/config_M_Sert_Cre_49.json b/config_M_Sert_Cre_49.json deleted file mode 100644 index 2621f5e..0000000 --- a/config_M_Sert_Cre_49.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "basic_path": "/data_1/hendrik", - "recoding_data": "2023-03-15", - "mouse_identifier": "M_Sert_Cre_49", - "raw_path": "raw", - "export_path": "output_M_Sert_Cre_49", - "ref_image_path": "ref_images_M_Sert_Cre_49", - "heartbeat_remove": false, - "gevi": false, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - //"target_camera_acceptor": "acceptor", - "target_camera_acceptor": "", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - // "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} \ No newline at end of file From 943a0a09a0f241611d3b71758fcb16b30c3148ea Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:29 +0100 Subject: [PATCH 10/25] Delete config_fatma_gevi_M0134M_SessionA2_Test.json --- config_fatma_gevi_M0134M_SessionA2_Test.json | 61 -------------------- 1 file changed, 61 deletions(-) delete mode 100644 config_fatma_gevi_M0134M_SessionA2_Test.json diff --git a/config_fatma_gevi_M0134M_SessionA2_Test.json b/config_fatma_gevi_M0134M_SessionA2_Test.json deleted file mode 100644 index b1d0f83..0000000 --- a/config_fatma_gevi_M0134M_SessionA2_Test.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "basic_path": "/data_1/fatma/GEVI", - "recoding_data": "2024-10-16", - "mouse_identifier": "M0134M_SessionA2_Test", - "export_path": "output_2024-10-16_M0134M_SessionA2_Test", - "ref_image_path": "ref_images_2024-10-16_M0134M_SessionA2_Test", - "raw_path": "raw", - "heartbeat_remove": true, // if gevi must be true; geci: who knows... - "gevi": true, // true => gevi, false => geci - // Ratio Sequence - "classical_ratio_mode": true, // true: a/d false: 1+a-d - // Regression - "target_camera_acceptor": "acceptor", - "regressor_cameras_acceptor": [ - "oxygenation", - "volume" - ], - "target_camera_donor": "donor", - "regressor_cameras_donor": [ - "oxygenation", - "volume" - ], - // binning - "binning_enable": true, - "binning_at_the_end": false, - "binning_kernel_size": 4, - "binning_stride": 4, - "binning_divisor_override": 1, - // alignment - "alignment_batch_size": 200, - "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 - "rotation_stabilization_threshold_border": 0.9, // <= 1.0 - // Heart beat detection - "lower_freqency_bandpass": 5.0, // Hz - "upper_freqency_bandpass": 14.0, // Hz - "heartbeat_filtfilt_chuck_size": 10, - // Gauss smear - "gauss_smear_spatial_width": 8, - "gauss_smear_temporal_width": 0.1, - "gauss_smear_use_matlab_mask": false, - // LED Ramp on - "skip_frames_in_the_beginning": 100, // Frames - // PyTorch - "dtype": "float32", - "force_to_cpu": false, - // Save - "save_as_python": true, // produces .npz files (compressed) - "save_as_matlab": false, // produces .hd5 file (compressed) - // Save extra information - "save_alignment": false, - "save_heartbeat": false, - "save_factors": false, - "save_regression_coefficients": false, - // Not important parameter - "required_order": [ - "acceptor", - "donor", - "oxygenation", - "volume" - ] -} From 1b12b32a7db767debfb5547c2735aadf146fad74 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:39 +0100 Subject: [PATCH 11/25] Delete geci_loader.py --- geci_loader.py | 168 ------------------------------------------------- 1 file changed, 168 deletions(-) delete mode 100644 geci_loader.py diff --git a/geci_loader.py b/geci_loader.py deleted file mode 100644 index a8f4da1..0000000 --- a/geci_loader.py +++ /dev/null @@ -1,168 +0,0 @@ -import numpy as np -import os -import json -from jsmin import jsmin # type:ignore -import argh -from functions.get_trials import get_trials -from functions.get_experiments import get_experiments -import scipy # type: ignore - - -def func_pow(x, a, b, c): - return -a * x**b + c - - -def func_exp(x, a, b, c): - return a * np.exp(-x / b) + c - - -def loader( - filename: str = "config_M_Sert_Cre_49.json", - fpath: str|None = None, - skip_timesteps: int = 100, - # If there is no special ROI... Get one! This is just a backup - roi_control_path_default: str = "roi_controlM_Sert_Cre_49.npy", - roi_sdarken_path_default: str = "roi_sdarkenM_Sert_Cre_49.npy", - remove_fit: bool = True, - fit_power: bool = False, # True => -ax^b ; False => exp(-b) -) -> None: - - if fpath is None: - fpath = os.getcwd() - - if os.path.isfile(filename) is False: - print(f"{filename} is missing") - exit() - - with open(filename, "r") as file: - config = json.loads(jsmin(file.read())) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if remove_fit: - roi_control_path: str = f"roi_control{config['mouse_identifier']}.npy" - roi_sdarken_path: str = f"roi_sdarken{config['mouse_identifier']}.npy" - - if os.path.isfile(roi_control_path) is False: - print(f"Using replacement RIO: {roi_control_path_default}") - roi_control_path = roi_control_path_default - - if os.path.isfile(roi_sdarken_path) is False: - print(f"Using replacement RIO: {roi_sdarken_path_default}") - roi_sdarken_path = roi_sdarken_path_default - - roi_control: np.ndarray = np.load(roi_control_path) - roi_darken: np.ndarray = np.load(roi_sdarken_path) - - experiments = get_experiments(raw_data_path).numpy() - n_exp = experiments.shape[0] - - first_run: bool = True - - for i_exp in range(0, n_exp): - trials = get_trials(raw_data_path, experiments[i_exp]).numpy() - n_tri = trials.shape[0] - - for i_tri in range(0, n_tri): - - experiment_name: str = ( - f"Exp{experiments[i_exp]:03d}_Trial{trials[i_tri]:03d}" - ) - tmp_fname = os.path.join( - fpath, - config["export_path"], - experiment_name + "_acceptor_donor.npz", - ) - print(f'Processing file "{tmp_fname}"...') - tmp = np.load(tmp_fname) - - tmp_data_sequence = tmp["data_donor"] - tmp_data_sequence = tmp_data_sequence[:, :, skip_timesteps:] - tmp_light_signal = tmp["data_acceptor"] - tmp_light_signal = tmp_light_signal[:, :, skip_timesteps:] - - if first_run: - mask = tmp["mask"] - new_shape = [n_exp, *tmp_data_sequence.shape] - data_sequence = np.zeros(new_shape) - light_signal = np.zeros(new_shape) - first_run = False - - if remove_fit: - roi_control *= mask - assert roi_control.sum() > 0, "ROI control empty" - roi_darken *= mask - assert roi_darken.sum() > 0, "ROI sDarken empty" - - if remove_fit: - combined_matrix = (roi_darken + roi_control) > 0 - idx = np.where(combined_matrix) - for idx_pos in range(0, idx[0].shape[0]): - - temp = tmp_data_sequence[idx[0][idx_pos], idx[1][idx_pos], :] - temp -= temp.mean() - - data_time = np.arange(0, temp.shape[0], dtype=np.float32) + skip_timesteps - data_time /= 100.0 - - data_min = temp.min() - data_max = temp.max() - data_delta = data_max - data_min - a_min = data_min - data_delta - b_min = 0.01 - a_max = data_max + data_delta - if fit_power: - b_max = 10.0 - else: - b_max = 100.0 - c_min = data_min - data_delta - c_max = data_max + data_delta - - try: - if fit_power: - popt, _ = scipy.optimize.curve_fit( - f=func_pow, - xdata=data_time, - ydata=np.nan_to_num(temp), - bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), - ) - pattern: np.ndarray | None = func_pow(data_time, *popt) - else: - popt, _ = scipy.optimize.curve_fit( - f=func_exp, - xdata=data_time, - ydata=np.nan_to_num(temp), - bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), - ) - pattern = func_exp(data_time, *popt) - - assert pattern is not None - pattern -= pattern.mean() - - scale = (temp * pattern).sum() / (pattern**2).sum() - pattern *= scale - - except ValueError: - print(f"Fit failed: Position ({idx[0][idx_pos]}, {idx[1][idx_pos]}") - pattern = None - - if pattern is not None: - temp -= pattern - tmp_data_sequence[idx[0][idx_pos], idx[1][idx_pos], :] = temp - - data_sequence[i_exp] += tmp_data_sequence - light_signal[i_exp] += tmp_light_signal - data_sequence[i_exp] /= n_tri - light_signal[i_exp] /= n_tri - np.save(os.path.join(fpath, config["export_path"], "dsq_" + config["mouse_identifier"]), data_sequence) - np.save(os.path.join(fpath, config["export_path"], "lsq_" + config["mouse_identifier"]), light_signal) - np.save(os.path.join(fpath, config["export_path"], "msq_" + config["mouse_identifier"]), mask) - - -if __name__ == "__main__": - argh.dispatch_command(loader) From d3d79c0607a73ce4629d3bb759f756cfa2ef8d39 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:46 +0100 Subject: [PATCH 12/25] Delete geci_plot.py --- geci_plot.py | 181 --------------------------------------------------- 1 file changed, 181 deletions(-) delete mode 100644 geci_plot.py diff --git a/geci_plot.py b/geci_plot.py deleted file mode 100644 index 8d32ab4..0000000 --- a/geci_plot.py +++ /dev/null @@ -1,181 +0,0 @@ -# %% - -import numpy as np -import matplotlib.pyplot as plt -import argh -import scipy # type: ignore -import json -import os -from jsmin import jsmin # type:ignore - - -def func_pow(x, a, b, c): - return -a * x**b + c - - -def func_exp(x, a, b, c): - return a * np.exp(-x / b) + c - - -# mouse: int = 0, 1, 2, 3, 4 -def plot( - filename: str = "config_M_Sert_Cre_49.json", - fpath: str | None = None, - experiment: int = 4, - skip_timesteps: int = 100, - remove_fit: bool = False, - fit_power: bool = False, # True => -ax^b ; False => exp(-b) -) -> None: - - if fpath is None: - fpath = os.getcwd() - - if os.path.isfile(filename) is False: - print(f"{filename} is missing") - exit() - - with open(filename, "r") as file: - config = json.loads(jsmin(file.read())) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if os.path.isdir(raw_data_path) is False: - print(f"ERROR: could not find raw directory {raw_data_path}!!!!") - exit() - - with open(f"meta_{config['mouse_identifier']}_exp{experiment:03d}.json", "r") as file: - metadata = json.loads(jsmin(file.read())) - - experiment_names = metadata['sessionMetaData']['experimentNames'][str(experiment)] - - roi_control_path: str = f"roi_control{config['mouse_identifier']}.npy" - roi_sdarken_path: str = f"roi_sdarken{config['mouse_identifier']}.npy" - - assert os.path.isfile(roi_control_path) - assert os.path.isfile(roi_sdarken_path) - - print("Load data...") - data = np.load(os.path.join(fpath, config["export_path"], "dsq_" + config["mouse_identifier"] + ".npy"), mmap_mode="r") - - print("Load light signal...") - light = np.load(os.path.join(fpath, config["export_path"], "lsq_" + config["mouse_identifier"] + ".npy"), mmap_mode="r") - - print("Load mask...") - mask = np.load(os.path.join(fpath, config["export_path"], "msq_" + config["mouse_identifier"] + ".npy")) - - roi_control = np.load(roi_control_path) - roi_control *= mask - assert roi_control.sum() > 0, "ROI control empty" - - roi_darken = np.load(roi_sdarken_path) - roi_darken *= mask - assert roi_darken.sum() > 0, "ROI sDarken empty" - - plt.figure(1) - a_show = data[experiment - 1, :, :, 1000].copy() - a_show[(roi_darken + roi_control) < 0.5] = np.nan - plt.imshow(a_show) - plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment}") - plt.show(block=False) - - plt.figure(2) - a_dontshow = data[experiment - 1, :, :, 1000].copy() - a_dontshow[(roi_darken + roi_control) > 0.5] = np.nan - plt.imshow(a_dontshow) - plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment}") - plt.show(block=False) - - plt.figure(3) - if remove_fit: - light_exp = light[experiment - 1, :, :, skip_timesteps:].copy() - else: - light_exp = light[experiment - 1, :, :, :].copy() - light_exp[(roi_darken + roi_control) < 0.5, :] = 0.0 - light_signal = light_exp.mean(axis=(0, 1)) - light_signal -= light_signal.min() - light_signal /= light_signal.max() - - if remove_fit: - a_exp = data[experiment - 1, :, :, skip_timesteps:].copy() - else: - a_exp = data[experiment - 1, :, :, :].copy() - - if remove_fit: - combined_matrix = (roi_darken + roi_control) > 0 - idx = np.where(combined_matrix) - for idx_pos in range(0, idx[0].shape[0]): - temp = a_exp[idx[0][idx_pos], idx[1][idx_pos], :] - temp -= temp.mean() - - data_time = np.arange(0, temp.shape[0], dtype=np.float32) + skip_timesteps - data_time /= 100.0 - - data_min = temp.min() - data_max = temp.max() - data_delta = data_max - data_min - a_min = data_min - data_delta - b_min = 0.01 - a_max = data_max + data_delta - if fit_power: - b_max = 10.0 - else: - b_max = 100.0 - c_min = data_min - data_delta - c_max = data_max + data_delta - - try: - if fit_power: - popt, _ = scipy.optimize.curve_fit( - f=func_pow, - xdata=data_time, - ydata=np.nan_to_num(temp), - bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), - ) - pattern: np.ndarray | None = func_pow(data_time, *popt) - else: - popt, _ = scipy.optimize.curve_fit( - f=func_exp, - xdata=data_time, - ydata=np.nan_to_num(temp), - bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), - ) - pattern = func_exp(data_time, *popt) - - assert pattern is not None - pattern -= pattern.mean() - - scale = (temp * pattern).sum() / (pattern**2).sum() - pattern *= scale - - except ValueError: - print(f"Fit failed: Position ({idx[0][idx_pos]}, {idx[1][idx_pos]}") - pattern = None - - if pattern is not None: - temp -= pattern - a_exp[idx[0][idx_pos], idx[1][idx_pos], :] = temp - - darken = a_exp[roi_darken > 0.5, :].sum(axis=0) / (roi_darken > 0.5).sum() - lighten = a_exp[roi_control > 0.5, :].sum(axis=0) / (roi_control > 0.5).sum() - - light_signal *= darken.max() - darken.min() - light_signal += darken.min() - - time_axis = np.arange(0, lighten.shape[-1], dtype=np.float32) + skip_timesteps - time_axis /= 100.0 - - plt.plot(time_axis, light_signal, c="k", label="light") - plt.plot(time_axis, darken, label="sDarken") - plt.plot(time_axis, lighten, label="control") - plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment} ({experiment_names})") - plt.legend() - plt.show() - - -if __name__ == "__main__": - argh.dispatch_command(plot) From d8688a156a174a574c6c4d2ba5250cc551f646e0 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:28:54 +0100 Subject: [PATCH 13/25] Delete stage_6_convert_roi.py --- stage_6_convert_roi.py | 55 ------------------------------------------ 1 file changed, 55 deletions(-) delete mode 100644 stage_6_convert_roi.py diff --git a/stage_6_convert_roi.py b/stage_6_convert_roi.py deleted file mode 100644 index 18176db..0000000 --- a/stage_6_convert_roi.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -import os -import argh -from jsmin import jsmin # type:ignore -import numpy as np -import h5py - - -def converter(config_filename: str = "config.json") -> None: - - filename: str = config_filename - - if os.path.isfile(filename) is False: - print(f"{filename} is missing") - exit() - - with open(filename, "r") as file: - config = json.loads(jsmin(file.read())) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if os.path.isdir(raw_data_path) is False: - print(f"ERROR: could not find raw directory {raw_data_path}!!!!") - exit() - - roi_path: str = os.path.join( - config["basic_path"], config["recoding_data"], config["mouse_identifier"] - ) - roi_control_mat: str = os.path.join(roi_path, "ROI_control.mat") - roi_sdarken_mat: str = os.path.join(roi_path, "ROI_sDarken.mat") - - if os.path.isfile(roi_control_mat): - hf = h5py.File(roi_control_mat, "r") - roi_control = np.array(hf["roi"]).T - filename_out: str = f"roi_control{config["mouse_identifier"]}.npy" - np.save(filename_out, roi_control) - else: - print("ROI Control not found") - - if os.path.isfile(roi_sdarken_mat): - hf = h5py.File(roi_sdarken_mat, "r") - roi_darken = np.array(hf["roi"]).T - filename_out = f"roi_sdarken{config["mouse_identifier"]}.npy" - np.save(filename_out, roi_darken) - else: - print("ROI sDarken not found") - - -if __name__ == "__main__": - argh.dispatch_command(converter) From 389251826d22b0d0648986db226cfbb34882304c Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:01 +0100 Subject: [PATCH 14/25] Delete stage_5_convert_metadata.py --- stage_5_convert_metadata.py | 54 ------------------------------------- 1 file changed, 54 deletions(-) delete mode 100644 stage_5_convert_metadata.py diff --git a/stage_5_convert_metadata.py b/stage_5_convert_metadata.py deleted file mode 100644 index 31e1ce2..0000000 --- a/stage_5_convert_metadata.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -import os -import argh -from jsmin import jsmin # type:ignore -from functions.get_trials import get_trials -from functions.get_experiments import get_experiments - - -def converter(config_filename: str = "config.json") -> None: - - filename: str = config_filename - - if os.path.isfile(filename) is False: - print(f"{filename} is missing") - exit() - - with open(filename, "r") as file: - config = json.loads(jsmin(file.read())) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if os.path.isdir(raw_data_path) is False: - print(f"ERROR: could not find raw directory {raw_data_path}!!!!") - exit() - - experiments = get_experiments(raw_data_path).numpy() - - for experiment in experiments: - - trials = get_trials(raw_data_path, experiment).numpy() - assert trials.shape[0] > 0 - - with open( - os.path.join( - raw_data_path, - f"Exp{experiment:03d}_Trial{trials[0]:03d}_Part001_meta.txt", - ), - "r", - ) as file: - metadata = json.loads(jsmin(file.read())) - - filename_out: str = f"meta_{config["mouse_identifier"]}_exp{experiment:03d}.json" - - with open(filename_out, 'w') as file: - json.dump(metadata, file) - - -if __name__ == "__main__": - argh.dispatch_command(converter) From 2d1772b9486cd1ba7c90837845eae063fc3ff200 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:09 +0100 Subject: [PATCH 15/25] Delete stage_1_get_ref_image.py --- stage_1_get_ref_image.py | 129 --------------------------------------- 1 file changed, 129 deletions(-) delete mode 100644 stage_1_get_ref_image.py diff --git a/stage_1_get_ref_image.py b/stage_1_get_ref_image.py deleted file mode 100644 index 0e5b6da..0000000 --- a/stage_1_get_ref_image.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -import torch -import numpy as np -import argh - -from functions.get_experiments import get_experiments -from functions.get_trials import get_trials -from functions.bandpass import bandpass -from functions.create_logger import create_logger -from functions.get_torch_device import get_torch_device -from functions.load_config import load_config -from functions.data_raw_loader import data_raw_loader - - -def main(*, config_filename: str = "config.json") -> None: - mylogger = create_logger( - save_logging_messages=True, - display_logging_messages=True, - log_stage_name="stage_1", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - device: torch.device = torch.device("cpu") - else: - device = get_torch_device(mylogger, config["force_to_cpu"]) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - mylogger.info(f"Using data path: {raw_data_path}") - - first_experiment_id: int = int(get_experiments(raw_data_path).min()) - first_trial_id: int = int(get_trials(raw_data_path, first_experiment_id).min()) - - meta_channels: list[str] - meta_mouse_markings: str - meta_recording_date: str - meta_stimulation_times: dict - meta_experiment_names: dict - meta_trial_recording_duration: float - meta_frame_time: float - meta_mouse: str - data: torch.Tensor - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - force_to_cpu_memory: bool = True - else: - force_to_cpu_memory = False - - mylogger.info("Loading data") - - ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) = data_raw_loader( - raw_data_path=raw_data_path, - mylogger=mylogger, - experiment_id=first_experiment_id, - trial_id=first_trial_id, - device=device, - force_to_cpu_memory=force_to_cpu_memory, - config=config, - ) - mylogger.info("-==- Done -==-") - - output_path = config["ref_image_path"] - mylogger.info(f"Create directory {output_path} in the case it does not exist") - os.makedirs(output_path, exist_ok=True) - - mylogger.info("Reference images") - for i in range(0, len(meta_channels)): - temp_path: str = os.path.join(output_path, meta_channels[i] + ".npy") - mylogger.info(f"Extract and save: {temp_path}") - frame_id: int = data.shape[-2] // 2 - mylogger.info(f"Will use frame id: {frame_id}") - ref_image: np.ndarray = ( - data[:, :, frame_id, meta_channels.index(meta_channels[i])] - .clone() - .cpu() - .numpy() - ) - np.save(temp_path, ref_image) - mylogger.info("-==- Done -==-") - - sample_frequency: float = 1.0 / meta_frame_time - mylogger.info( - ( - f"Heartbeat power {config['lower_freqency_bandpass']} Hz" - f" - {config['upper_freqency_bandpass']} Hz," - f" sample-rate: {sample_frequency}," - f" skipping the first {config['skip_frames_in_the_beginning']} frames" - ) - ) - - for i in range(0, len(meta_channels)): - temp_path = os.path.join(output_path, meta_channels[i] + "_var.npy") - mylogger.info(f"Extract and save: {temp_path}") - - heartbeat_ts: torch.Tensor = bandpass( - data=data[..., i], - low_frequency=config["lower_freqency_bandpass"], - high_frequency=config["upper_freqency_bandpass"], - fs=sample_frequency, - filtfilt_chuck_size=10, - ) - - heartbeat_power = heartbeat_ts[ - ..., config["skip_frames_in_the_beginning"] : - ].var(dim=-1) - np.save(temp_path, heartbeat_power) - - mylogger.info("-==- Done -==-") - - -if __name__ == "__main__": - argh.dispatch_command(main) From 580a85b964e13d829b5b61ae504c7f685a1c3150 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:19 +0100 Subject: [PATCH 16/25] Delete stage_2_make_heartbeat_mask.py --- stage_2_make_heartbeat_mask.py | 163 --------------------------------- 1 file changed, 163 deletions(-) delete mode 100644 stage_2_make_heartbeat_mask.py diff --git a/stage_2_make_heartbeat_mask.py b/stage_2_make_heartbeat_mask.py deleted file mode 100644 index dfa8c63..0000000 --- a/stage_2_make_heartbeat_mask.py +++ /dev/null @@ -1,163 +0,0 @@ -import matplotlib.pyplot as plt # type:ignore -import matplotlib -import numpy as np -import torch -import os -import argh - -from matplotlib.widgets import Slider, Button # type:ignore -from functools import partial -from functions.gauss_smear_individual import gauss_smear_individual -from functions.create_logger import create_logger -from functions.get_torch_device import get_torch_device -from functions.load_config import load_config - - -def main(*, config_filename: str = "config.json") -> None: - mylogger = create_logger( - save_logging_messages=True, - display_logging_messages=True, - log_stage_name="stage_2", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - path: str = config["ref_image_path"] - use_channel: str = "donor" - spatial_width: float = 4.0 - temporal_width: float = 0.1 - - threshold: float = 0.05 - - heartbeat_mask_threshold_file: str = os.path.join( - path, "heartbeat_mask_threshold.npy" - ) - if os.path.isfile(heartbeat_mask_threshold_file): - mylogger.info( - f"loading previous threshold file: {heartbeat_mask_threshold_file}" - ) - threshold = float(np.load(heartbeat_mask_threshold_file)[0]) - - mylogger.info(f"initial threshold is {threshold}") - - image_ref_file: str = os.path.join(path, use_channel + ".npy") - image_var_file: str = os.path.join(path, use_channel + "_var.npy") - heartbeat_mask_file: str = os.path.join(path, "heartbeat_mask.npy") - - device = get_torch_device(mylogger, config["force_to_cpu"]) - - mylogger.info(f"loading image reference file: {image_ref_file}") - image_ref: np.ndarray = np.load(image_ref_file) - image_ref /= image_ref.max() - - mylogger.info(f"loading image heartbeat power: {image_var_file}") - image_var: np.ndarray = np.load(image_var_file) - image_var /= image_var.max() - - mylogger.info("Smear the image heartbeat power patially") - temp, _ = gauss_smear_individual( - input=torch.tensor(image_var[..., np.newaxis], device=device), - spatial_width=spatial_width, - temporal_width=temporal_width, - use_matlab_mask=False, - ) - temp /= temp.max() - - mylogger.info("-==- DONE -==-") - - image_3color = np.concatenate( - ( - np.zeros_like(image_ref[..., np.newaxis]), - image_ref[..., np.newaxis], - temp.cpu().numpy(), - ), - axis=-1, - ) - - mylogger.info("Prepare image") - - display_image = image_3color.copy() - display_image[..., 2] = display_image[..., 0] - mask = np.where(image_3color[..., 2] >= threshold, 1.0, np.nan)[..., np.newaxis] - display_image *= mask - display_image = np.nan_to_num(display_image, nan=1.0) - - value_sort = np.sort(image_var.flatten()) - value_sort_max = value_sort[int(value_sort.shape[0] * 0.95)] * 3 - print(value_sort_max) - mylogger.info("-==- DONE -==-") - - mylogger.info("Create figure") - - fig: matplotlib.figure.Figure = plt.figure() - - image_handle = plt.imshow(display_image, vmin=0, vmax=1, cmap="hot") - - mylogger.info("Add controls") - - def next_frame( - i: float, images: np.ndarray, image_handle: matplotlib.image.AxesImage - ) -> None: - nonlocal threshold - threshold = i - - display_image: np.ndarray = images.copy() - display_image[..., 2] = display_image[..., 0] - mask: np.ndarray = np.where(images[..., 2] >= i, 1.0, np.nan)[..., np.newaxis] - display_image *= mask - display_image = np.nan_to_num(display_image, nan=1.0) - - image_handle.set_data(display_image) - return - - def on_clicked_accept(event: matplotlib.backend_bases.MouseEvent) -> None: - nonlocal threshold - nonlocal image_3color - nonlocal path - nonlocal mylogger - nonlocal heartbeat_mask_file - nonlocal heartbeat_mask_threshold_file - - mylogger.info(f"Threshold: {threshold}") - - mask: np.ndarray = image_3color[..., 2] >= threshold - mylogger.info(f"Save mask to: {heartbeat_mask_file}") - np.save(heartbeat_mask_file, mask) - mylogger.info(f"Save threshold to: {heartbeat_mask_threshold_file}") - np.save(heartbeat_mask_threshold_file, np.array([threshold])) - exit() - - def on_clicked_cancel(event: matplotlib.backend_bases.MouseEvent) -> None: - exit() - - axfreq = fig.add_axes(rect=(0.4, 0.9, 0.3, 0.03)) - slice_slider = Slider( - ax=axfreq, - label="Threshold", - valmin=0, - valmax=value_sort_max, - valinit=threshold, - valstep=value_sort_max / 1000.0, - ) - axbutton_accept = fig.add_axes(rect=(0.3, 0.85, 0.2, 0.04)) - button_accept = Button( - ax=axbutton_accept, label="Accept", image=None, color="0.85", hovercolor="0.95" - ) - button_accept.on_clicked(on_clicked_accept) # type: ignore - - axbutton_cancel = fig.add_axes(rect=(0.55, 0.85, 0.2, 0.04)) - button_cancel = Button( - ax=axbutton_cancel, label="Cancel", image=None, color="0.85", hovercolor="0.95" - ) - button_cancel.on_clicked(on_clicked_cancel) # type: ignore - - slice_slider.on_changed( - partial(next_frame, images=image_3color, image_handle=image_handle) - ) - - mylogger.info("Display") - plt.show() - - -if __name__ == "__main__": - argh.dispatch_command(main) From a3fe0e9d5fa784ed9c88514b459aa8011d1e69cc Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:29 +0100 Subject: [PATCH 17/25] Delete stage_3_refine_mask.py --- stage_3_refine_mask.py | 169 ----------------------------------------- 1 file changed, 169 deletions(-) delete mode 100644 stage_3_refine_mask.py diff --git a/stage_3_refine_mask.py b/stage_3_refine_mask.py deleted file mode 100644 index f96b3bd..0000000 --- a/stage_3_refine_mask.py +++ /dev/null @@ -1,169 +0,0 @@ -import os -import numpy as np - -import matplotlib.pyplot as plt # type:ignore -import matplotlib -from matplotlib.widgets import Button # type:ignore - -# pip install roipoly -from roipoly import RoiPoly # type:ignore - -from functions.create_logger import create_logger -from functions.load_config import load_config - -import argh - - -def compose_image(image_3color: np.ndarray, mask: np.ndarray) -> np.ndarray: - display_image = image_3color.copy() - display_image[..., 2] = display_image[..., 0] - display_image[mask == 0, :] = 1.0 - return display_image - - -def main(*, config_filename: str = "config.json") -> None: - mylogger = create_logger( - save_logging_messages=True, - display_logging_messages=True, - log_stage_name="stage_3", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - path: str = config["ref_image_path"] - use_channel: str = "donor" - padding: int = 20 - - image_ref_file: str = os.path.join(path, use_channel + ".npy") - heartbeat_mask_file: str = os.path.join(path, "heartbeat_mask.npy") - refined_mask_file: str = os.path.join(path, "mask_not_rotated.npy") - - mylogger.info(f"loading image reference file: {image_ref_file}") - image_ref: np.ndarray = np.load(image_ref_file) - image_ref /= image_ref.max() - image_ref = np.pad(image_ref, pad_width=padding) - - mylogger.info(f"loading heartbeat mask: {heartbeat_mask_file}") - mask: np.ndarray = np.load(heartbeat_mask_file) - mask = np.pad(mask, pad_width=padding) - - image_3color = np.concatenate( - ( - np.zeros_like(image_ref[..., np.newaxis]), - image_ref[..., np.newaxis], - np.zeros_like(image_ref[..., np.newaxis]), - ), - axis=-1, - ) - - mylogger.info("-==- DONE -==-") - - fig, ax_main = plt.subplots() - - display_image = compose_image(image_3color=image_3color, mask=mask) - image_handle = ax_main.imshow(display_image, vmin=0, vmax=1, cmap="hot") - - mylogger.info("Add controls") - - def on_clicked_accept(event: matplotlib.backend_bases.MouseEvent) -> None: - nonlocal mylogger - nonlocal refined_mask_file - nonlocal mask - - mylogger.info(f"Save mask to: {refined_mask_file}") - mask = mask[padding:-padding, padding:-padding] - np.save(refined_mask_file, mask) - - exit() - - def on_clicked_cancel(event: matplotlib.backend_bases.MouseEvent) -> None: - nonlocal mylogger - mylogger.info("Ended without saving the mask") - exit() - - def on_clicked_add(event: matplotlib.backend_bases.MouseEvent) -> None: - nonlocal new_roi # type: ignore - nonlocal mask - nonlocal image_3color - nonlocal display_image - nonlocal mylogger - if len(new_roi.x) > 0: - mylogger.info( - "A ROI with the following coordiantes has been added to the mask" - ) - for i in range(0, len(new_roi.x)): - mylogger.info(f"{round(new_roi.x[i], 1)} x {round(new_roi.y[i], 1)}") - mylogger.info("") - new_mask = new_roi.get_mask(display_image[:, :, 0]) - mask[new_mask] = 0.0 - display_image = compose_image(image_3color=image_3color, mask=mask) - image_handle.set_data(display_image) - for line in ax_main.lines: - line.remove() - plt.draw() - - new_roi = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) - - def on_clicked_remove(event: matplotlib.backend_bases.MouseEvent) -> None: - nonlocal new_roi # type: ignore - nonlocal mask - nonlocal image_3color - nonlocal display_image - if len(new_roi.x) > 0: - mylogger.info( - "A ROI with the following coordiantes has been removed from the mask" - ) - for i in range(0, len(new_roi.x)): - mylogger.info(f"{round(new_roi.x[i], 1)} x {round(new_roi.y[i], 1)}") - mylogger.info("") - new_mask = new_roi.get_mask(display_image[:, :, 0]) - mask[new_mask] = 1.0 - display_image = compose_image(image_3color=image_3color, mask=mask) - image_handle.set_data(display_image) - for line in ax_main.lines: - line.remove() - plt.draw() - new_roi = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) - - axbutton_accept = fig.add_axes(rect=(0.3, 0.85, 0.2, 0.04)) - button_accept = Button( - ax=axbutton_accept, label="Accept", image=None, color="0.85", hovercolor="0.95" - ) - button_accept.on_clicked(on_clicked_accept) # type: ignore - - axbutton_cancel = fig.add_axes(rect=(0.5, 0.85, 0.2, 0.04)) - button_cancel = Button( - ax=axbutton_cancel, label="Cancel", image=None, color="0.85", hovercolor="0.95" - ) - button_cancel.on_clicked(on_clicked_cancel) # type: ignore - - axbutton_addmask = fig.add_axes(rect=(0.3, 0.9, 0.2, 0.04)) - button_addmask = Button( - ax=axbutton_addmask, - label="Add mask", - image=None, - color="0.85", - hovercolor="0.95", - ) - button_addmask.on_clicked(on_clicked_add) # type: ignore - - axbutton_removemask = fig.add_axes(rect=(0.5, 0.9, 0.2, 0.04)) - button_removemask = Button( - ax=axbutton_removemask, - label="Remove mask", - image=None, - color="0.85", - hovercolor="0.95", - ) - button_removemask.on_clicked(on_clicked_remove) # type: ignore - - # ax_main.cla() - - mylogger.info("Display") - new_roi: RoiPoly = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) - - plt.show() - - -if __name__ == "__main__": - argh.dispatch_command(main) From c1caff162dfb40324c4f39dd74007b51108af3b4 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:39 +0100 Subject: [PATCH 18/25] Delete stage_4c_viewer.py --- stage_4c_viewer.py | 56 ---------------------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 stage_4c_viewer.py diff --git a/stage_4c_viewer.py b/stage_4c_viewer.py deleted file mode 100644 index 9c70616..0000000 --- a/stage_4c_viewer.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import numpy as np - -import matplotlib.pyplot as plt # type:ignore - -from functions.create_logger import create_logger -from functions.load_config import load_config - -import argh - - -def main( - *, config_filename: str = "config.json", experiment_id: int = 1, trial_id: int = 1 -) -> None: - - experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" - mylogger = create_logger( - save_logging_messages=False, - display_logging_messages=False, - log_stage_name="stage_4c", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - temp_path = os.path.join( - config["export_path"], experiment_name + "_inspect_images.npz" - ) - data = np.load(temp_path) - - acceptor = data["acceptor"][0, ...] - donor = data["donor"][0, ...] - oxygenation = data["oxygenation"][0, ...] - volume = data["volume"][0, ...] - - plt.figure(1) - plt.imshow(acceptor, cmap="hot") - plt.title(f"Acceptor Experiment: {experiment_id:03d} Trial:{trial_id:03d}") - plt.show(block=False) - plt.figure(2) - plt.imshow(donor, cmap="hot") - plt.title(f"Donor Experiment: {experiment_id:03d} Trial:{trial_id:03d}") - plt.show(block=False) - plt.figure(3) - plt.imshow(oxygenation, cmap="hot") - plt.title(f"Oxygenation Experiment: {experiment_id:03d} Trial:{trial_id:03d}") - plt.show(block=False) - plt.figure(4) - plt.imshow(volume, cmap="hot") - plt.title(f"Volume Experiment: {experiment_id:03d} Trial:{trial_id:03d}") - plt.show(block=True) - - return - - -if __name__ == "__main__": - argh.dispatch_command(main) From 2e36e9013ceb3f01152c563e559c524167dea2a8 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:46 +0100 Subject: [PATCH 19/25] Delete stage_4b_inspect.py --- stage_4b_inspect.py | 532 -------------------------------------------- 1 file changed, 532 deletions(-) delete mode 100644 stage_4b_inspect.py diff --git a/stage_4b_inspect.py b/stage_4b_inspect.py deleted file mode 100644 index f8884f5..0000000 --- a/stage_4b_inspect.py +++ /dev/null @@ -1,532 +0,0 @@ -# %% - -import numpy as np -import torch -import torchvision as tv # type: ignore - -import os -import logging - -from functions.create_logger import create_logger -from functions.get_torch_device import get_torch_device -from functions.load_config import load_config -from functions.get_experiments import get_experiments -from functions.get_trials import get_trials -from functions.binning import binning -from functions.align_refref import align_refref -from functions.perform_donor_volume_rotation import perform_donor_volume_rotation -from functions.perform_donor_volume_translation import perform_donor_volume_translation -from functions.data_raw_loader import data_raw_loader - -import argh - - -@torch.no_grad() -def process_trial( - config: dict, - mylogger: logging.Logger, - experiment_id: int, - trial_id: int, - device: torch.device, -): - - mylogger.info("") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("") - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - cuda_total_memory: int = torch.cuda.get_device_properties( - device.index - ).total_memory - else: - cuda_total_memory = 0 - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - force_to_cpu_memory: bool = True - else: - force_to_cpu_memory = False - - meta_channels: list[str] - meta_mouse_markings: str - meta_recording_date: str - meta_stimulation_times: dict - meta_experiment_names: dict - meta_trial_recording_duration: float - meta_frame_time: float - meta_mouse: str - data: torch.Tensor - - ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) = data_raw_loader( - raw_data_path=raw_data_path, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=device, - force_to_cpu_memory=force_to_cpu_memory, - config=config, - ) - experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" - - dtype_str = config["dtype"] - dtype_np: np.dtype = getattr(np, dtype_str) - - dtype: torch.dtype = data.dtype - - if device != torch.device("cpu"): - free_mem = cuda_total_memory - max( - [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("Finding limit values in the RAW data and mark them for masking") - limit: float = (2**16) - 1 - for i in range(0, data.shape[3]): - zero_pixel_mask: torch.Tensor = torch.any(data[..., i] >= limit, dim=-1) - data[zero_pixel_mask, :, i] = -100.0 - mylogger.info( - f"{meta_channels[i]}: " - f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " - f"with limit values " - ) - mylogger.info("-==- Done -==-") - - mylogger.info("Reference images and mask") - - ref_image_path: str = config["ref_image_path"] - - ref_image_path_acceptor: str = os.path.join(ref_image_path, "acceptor.npy") - if os.path.isfile(ref_image_path_acceptor) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_acceptor}") - assert os.path.isfile(ref_image_path_acceptor) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_acceptor}") - ref_image_acceptor: torch.Tensor = torch.tensor( - np.load(ref_image_path_acceptor).astype(dtype_np), - dtype=dtype, - device=data.device, - ) - - ref_image_path_donor: str = os.path.join(ref_image_path, "donor.npy") - if os.path.isfile(ref_image_path_donor) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_donor}") - assert os.path.isfile(ref_image_path_donor) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_donor}") - ref_image_donor: torch.Tensor = torch.tensor( - np.load(ref_image_path_donor).astype(dtype_np), dtype=dtype, device=data.device - ) - - ref_image_path_oxygenation: str = os.path.join(ref_image_path, "oxygenation.npy") - if os.path.isfile(ref_image_path_oxygenation) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_oxygenation}") - assert os.path.isfile(ref_image_path_oxygenation) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_oxygenation}") - ref_image_oxygenation: torch.Tensor = torch.tensor( - np.load(ref_image_path_oxygenation).astype(dtype_np), - dtype=dtype, - device=data.device, - ) - - ref_image_path_volume: str = os.path.join(ref_image_path, "volume.npy") - if os.path.isfile(ref_image_path_volume) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_volume}") - assert os.path.isfile(ref_image_path_volume) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_volume}") - ref_image_volume: torch.Tensor = torch.tensor( - np.load(ref_image_path_volume).astype(dtype_np), dtype=dtype, device=data.device - ) - - refined_mask_file: str = os.path.join(ref_image_path, "mask_not_rotated.npy") - if os.path.isfile(refined_mask_file) is False: - mylogger.info(f"Could not load mask file: {refined_mask_file}") - assert os.path.isfile(refined_mask_file) - return - - mylogger.info(f"Loading mask file data: {refined_mask_file}") - mask: torch.Tensor = torch.tensor( - np.load(refined_mask_file).astype(dtype_np), dtype=dtype, device=data.device - ) - mylogger.info("-==- Done -==-") - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - mylogger.info("Binning of data") - mylogger.info( - ( - f"kernel_size={int(config['binning_kernel_size'])}, " - f"stride={int(config['binning_stride'])}, " - f"divisor_override={int(config['binning_divisor_override'])}" - ) - ) - - data = binning( - data, - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ).to(device=data.device) - ref_image_acceptor = ( - binning( - ref_image_acceptor.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_donor = ( - binning( - ref_image_donor.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_oxygenation = ( - binning( - ref_image_oxygenation.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_volume = ( - binning( - ref_image_volume.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - mask = ( - binning( - mask.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("Preparing alignment") - mylogger.info("Re-order Raw data") - data = data.moveaxis(-2, 0).moveaxis(-1, 0) - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("Alignment of the ref images and the mask") - mylogger.info("Ref image of donor stays fixed.") - mylogger.info("Ref image of volume and the mask doesn't need to be touched") - mylogger.info("Calculate translation and rotation between the reference images") - angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor = align_refref( - mylogger=mylogger, - ref_image_acceptor=ref_image_acceptor, - ref_image_donor=ref_image_donor, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - ) - mylogger.info(f"Rotation: {round(float(angle_refref[0]), 2)} degree") - mylogger.info( - f"Translation: {round(float(tvec_refref[0]), 1)} x {round(float(tvec_refref[1]), 1)} pixel" - ) - - if config["save_alignment"]: - temp_path: str = os.path.join( - config["export_path"], experiment_name + "_angle_refref.npy" - ) - mylogger.info(f"Save angle to {temp_path}") - np.save(temp_path, angle_refref.cpu()) - - temp_path = os.path.join( - config["export_path"], experiment_name + "_tvec_refref.npy" - ) - mylogger.info(f"Save translation vector to {temp_path}") - np.save(temp_path, tvec_refref.cpu()) - - mylogger.info("Moving & rotating the oxygenation ref image") - ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore - img=ref_image_oxygenation.unsqueeze(0), - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore - img=ref_image_oxygenation, - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ).squeeze(0) - mylogger.info("-==- Done -==-") - - mylogger.info("Rotate and translate the acceptor and oxygenation data accordingly") - acceptor_index: int = config["required_order"].index("acceptor") - donor_index: int = config["required_order"].index("donor") - oxygenation_index: int = config["required_order"].index("oxygenation") - volume_index: int = config["required_order"].index("volume") - - mylogger.info("Rotate acceptor") - data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[acceptor_index, ...], # type: ignore - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Translate acceptor") - data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[acceptor_index, ...], - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Rotate oxygenation") - data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[oxygenation_index, ...], - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Translate oxygenation") - data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[oxygenation_index, ...], - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - mylogger.info("-==- Done -==-") - - mylogger.info("Perform rotation between donor and volume and its ref images") - mylogger.info("for all frames and then rotate all the data accordingly") - - ( - data[acceptor_index, ...], - data[donor_index, ...], - data[oxygenation_index, ...], - data[volume_index, ...], - angle_donor_volume, - ) = perform_donor_volume_rotation( - mylogger=mylogger, - acceptor=data[acceptor_index, ...], - donor=data[donor_index, ...], - oxygenation=data[oxygenation_index, ...], - volume=data[volume_index, ...], - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - config=config, - ) - - mylogger.info( - f"angles: " - f"min {round(float(angle_donor_volume.min()), 2)} " - f"max {round(float(angle_donor_volume.max()), 2)} " - f"mean {round(float(angle_donor_volume.mean()), 2)} " - ) - - if config["save_alignment"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_angle_donor_volume.npy" - ) - mylogger.info(f"Save angles to {temp_path}") - np.save(temp_path, angle_donor_volume.cpu()) - mylogger.info("-==- Done -==-") - - mylogger.info("Perform translation between donor and volume and its ref images") - mylogger.info("for all frames and then translate all the data accordingly") - - ( - data_acceptor, - data_donor, - data_oxygenation, - data_volume, - _, - ) = perform_donor_volume_translation( - mylogger=mylogger, - acceptor=data[acceptor_index, 0:1, ...], - donor=data[donor_index, 0:1, ...], - oxygenation=data[oxygenation_index, 0:1, ...], - volume=data[volume_index, 0:1, ...], - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - config=config, - ) - - # - - temp_path = os.path.join( - config["export_path"], experiment_name + "_inspect_images.npz" - ) - mylogger.info(f"Save images for inspection to {temp_path}") - np.savez( - temp_path, - acceptor=data_acceptor.cpu(), - donor=data_donor.cpu(), - oxygenation=data_oxygenation.cpu(), - volume=data_volume.cpu(), - ) - - mylogger.info("") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("") - - return - - -def main( - *, - config_filename: str = "config.json", - experiment_id_overwrite: int = -1, - trial_id_overwrite: int = -1, -) -> None: - mylogger = create_logger( - save_logging_messages=True, - display_logging_messages=True, - log_stage_name="stage_4b", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - if (config["save_as_python"] is False) and (config["save_as_matlab"] is False): - mylogger.info("No output will be created. ") - mylogger.info("Change save_as_python and/or save_as_matlab in the config file") - mylogger.info("ERROR: STOP!!!") - exit() - - if (len(config["target_camera_donor"]) == 0) and ( - len(config["target_camera_acceptor"]) == 0 - ): - mylogger.info( - "Configure at least target_camera_donor or target_camera_acceptor correctly." - ) - mylogger.info("ERROR: STOP!!!") - exit() - - device = get_torch_device(mylogger, config["force_to_cpu"]) - - mylogger.info( - f"Create directory {config['export_path']} in the case it does not exist" - ) - os.makedirs(config["export_path"], exist_ok=True) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if os.path.isdir(raw_data_path) is False: - mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") - exit() - - if experiment_id_overwrite == -1: - experiments = get_experiments(raw_data_path) - else: - assert experiment_id_overwrite >= 0 - experiments = torch.tensor([experiment_id_overwrite]) - - for experiment_counter in range(0, experiments.shape[0]): - experiment_id = int(experiments[experiment_counter]) - - if trial_id_overwrite == -1: - trials = get_trials(raw_data_path, experiment_id) - else: - assert trial_id_overwrite >= 0 - trials = torch.tensor([trial_id_overwrite]) - - for trial_counter in range(0, trials.shape[0]): - trial_id = int(trials[trial_counter]) - - mylogger.info("") - mylogger.info( - f"======= EXPERIMENT ID: {experiment_id} ==== TRIAL ID: {trial_id} =======" - ) - mylogger.info("") - - try: - process_trial( - config=config, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=device, - ) - except torch.cuda.OutOfMemoryError: - mylogger.info("WARNING: RUNNING IN FAILBACK MODE!!!!") - mylogger.info("Not enough GPU memory. Retry on CPU") - process_trial( - config=config, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=torch.device("cpu"), - ) - - -if __name__ == "__main__": - argh.dispatch_command(main) From d798311b0d8a2360522895fd679962d7c6de33ee Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:29:54 +0100 Subject: [PATCH 20/25] Delete stage_4_process.py --- stage_4_process.py | 1413 -------------------------------------------- 1 file changed, 1413 deletions(-) delete mode 100644 stage_4_process.py diff --git a/stage_4_process.py b/stage_4_process.py deleted file mode 100644 index 4a020e2..0000000 --- a/stage_4_process.py +++ /dev/null @@ -1,1413 +0,0 @@ -# %% - -import numpy as np -import torch -import torchvision as tv # type: ignore - -import os -import logging -import h5py # type: ignore - -from functions.create_logger import create_logger -from functions.get_torch_device import get_torch_device -from functions.load_config import load_config -from functions.get_experiments import get_experiments -from functions.get_trials import get_trials -from functions.binning import binning -from functions.align_refref import align_refref -from functions.perform_donor_volume_rotation import perform_donor_volume_rotation -from functions.perform_donor_volume_translation import perform_donor_volume_translation -from functions.bandpass import bandpass -from functions.gauss_smear_individual import gauss_smear_individual -from functions.regression import regression -from functions.data_raw_loader import data_raw_loader - -import argh - - -@torch.no_grad() -def process_trial( - config: dict, - mylogger: logging.Logger, - experiment_id: int, - trial_id: int, - device: torch.device, -): - - mylogger.info("") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - mylogger.info("") - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - cuda_total_memory: int = torch.cuda.get_device_properties( - device.index - ).total_memory - else: - cuda_total_memory = 0 - - mylogger.info("") - mylogger.info("(A) LOADING DATA, REFERENCE, AND MASK") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - force_to_cpu_memory: bool = True - else: - force_to_cpu_memory = False - - meta_channels: list[str] - meta_mouse_markings: str - meta_recording_date: str - meta_stimulation_times: dict - meta_experiment_names: dict - meta_trial_recording_duration: float - meta_frame_time: float - meta_mouse: str - data: torch.Tensor - - ( - meta_channels, - meta_mouse_markings, - meta_recording_date, - meta_stimulation_times, - meta_experiment_names, - meta_trial_recording_duration, - meta_frame_time, - meta_mouse, - data, - ) = data_raw_loader( - raw_data_path=raw_data_path, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=device, - force_to_cpu_memory=force_to_cpu_memory, - config=config, - ) - experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" - - dtype_str = config["dtype"] - dtype_np: np.dtype = getattr(np, dtype_str) - - dtype: torch.dtype = data.dtype - - if device != torch.device("cpu"): - free_mem = cuda_total_memory - max( - [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("Finding limit values in the RAW data and mark them for masking") - limit: float = (2**16) - 1 - for i in range(0, data.shape[3]): - zero_pixel_mask: torch.Tensor = torch.any(data[..., i] >= limit, dim=-1) - data[zero_pixel_mask, :, i] = -100.0 - mylogger.info( - f"{meta_channels[i]}: " - f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " - f"with limit values " - ) - mylogger.info("-==- Done -==-") - - mylogger.info("Reference images and mask") - - ref_image_path: str = config["ref_image_path"] - - ref_image_path_acceptor: str = os.path.join(ref_image_path, "acceptor.npy") - if os.path.isfile(ref_image_path_acceptor) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_acceptor}") - assert os.path.isfile(ref_image_path_acceptor) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_acceptor}") - ref_image_acceptor: torch.Tensor = torch.tensor( - np.load(ref_image_path_acceptor).astype(dtype_np), - dtype=dtype, - device=data.device, - ) - - ref_image_path_donor: str = os.path.join(ref_image_path, "donor.npy") - if os.path.isfile(ref_image_path_donor) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_donor}") - assert os.path.isfile(ref_image_path_donor) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_donor}") - ref_image_donor: torch.Tensor = torch.tensor( - np.load(ref_image_path_donor).astype(dtype_np), dtype=dtype, device=data.device - ) - - ref_image_path_oxygenation: str = os.path.join(ref_image_path, "oxygenation.npy") - if os.path.isfile(ref_image_path_oxygenation) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_oxygenation}") - assert os.path.isfile(ref_image_path_oxygenation) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_oxygenation}") - ref_image_oxygenation: torch.Tensor = torch.tensor( - np.load(ref_image_path_oxygenation).astype(dtype_np), - dtype=dtype, - device=data.device, - ) - - ref_image_path_volume: str = os.path.join(ref_image_path, "volume.npy") - if os.path.isfile(ref_image_path_volume) is False: - mylogger.info(f"Could not load ref file: {ref_image_path_volume}") - assert os.path.isfile(ref_image_path_volume) - return - - mylogger.info(f"Loading ref file data: {ref_image_path_volume}") - ref_image_volume: torch.Tensor = torch.tensor( - np.load(ref_image_path_volume).astype(dtype_np), dtype=dtype, device=data.device - ) - - refined_mask_file: str = os.path.join(ref_image_path, "mask_not_rotated.npy") - if os.path.isfile(refined_mask_file) is False: - mylogger.info(f"Could not load mask file: {refined_mask_file}") - assert os.path.isfile(refined_mask_file) - return - - mylogger.info(f"Loading mask file data: {refined_mask_file}") - mask: torch.Tensor = torch.tensor( - np.load(refined_mask_file).astype(dtype_np), dtype=dtype, device=data.device - ) - mylogger.info("-==- Done -==-") - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - - mylogger.info("") - mylogger.info("(B-OPTIONAL) BINNING") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Binning of data") - mylogger.info( - ( - f"kernel_size={int(config['binning_kernel_size'])}, " - f"stride={int(config['binning_stride'])}, " - f"divisor_override={int(config['binning_divisor_override'])}" - ) - ) - - data = binning( - data, - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ).to(device=data.device) - ref_image_acceptor = ( - binning( - ref_image_acceptor.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_donor = ( - binning( - ref_image_donor.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_oxygenation = ( - binning( - ref_image_oxygenation.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - ref_image_volume = ( - binning( - ref_image_volume.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - mask = ( - binning( - mask.unsqueeze(-1).unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=int(config["binning_divisor_override"]), - ) - .squeeze(-1) - .squeeze(-1) - ) - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("") - mylogger.info("(C) ALIGNMENT OF SECOND TO FIRST CAMERA") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Preparing alignment") - mylogger.info("Re-order Raw data") - data = data.moveaxis(-2, 0).moveaxis(-1, 0) - mylogger.info(f"Data shape: {data.shape}") - mylogger.info("-==- Done -==-") - - mylogger.info("Alignment of the ref images and the mask") - mylogger.info("Ref image of donor stays fixed.") - mylogger.info("Ref image of volume and the mask doesn't need to be touched") - mylogger.info("Calculate translation and rotation between the reference images") - angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor = align_refref( - mylogger=mylogger, - ref_image_acceptor=ref_image_acceptor, - ref_image_donor=ref_image_donor, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - ) - mylogger.info(f"Rotation: {round(float(angle_refref[0]), 2)} degree") - mylogger.info( - f"Translation: {round(float(tvec_refref[0]), 1)} x {round(float(tvec_refref[1]), 1)} pixel" - ) - - if config["save_alignment"]: - temp_path: str = os.path.join( - config["export_path"], experiment_name + "_angle_refref.npy" - ) - mylogger.info(f"Save angle to {temp_path}") - np.save(temp_path, angle_refref.cpu()) - - temp_path = os.path.join( - config["export_path"], experiment_name + "_tvec_refref.npy" - ) - mylogger.info(f"Save translation vector to {temp_path}") - np.save(temp_path, tvec_refref.cpu()) - - mylogger.info("Moving & rotating the oxygenation ref image") - ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore - img=ref_image_oxygenation.unsqueeze(0), - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore - img=ref_image_oxygenation, - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ).squeeze(0) - mylogger.info("-==- Done -==-") - - mylogger.info("Rotate and translate the acceptor and oxygenation data accordingly") - acceptor_index: int = config["required_order"].index("acceptor") - donor_index: int = config["required_order"].index("donor") - oxygenation_index: int = config["required_order"].index("oxygenation") - volume_index: int = config["required_order"].index("volume") - - mylogger.info("Rotate acceptor") - data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[acceptor_index, ...], # type: ignore - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Translate acceptor") - data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[acceptor_index, ...], - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Rotate oxygenation") - data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[oxygenation_index, ...], - angle=-float(angle_refref), - translate=[0, 0], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - - mylogger.info("Translate oxygenation") - data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore - img=data[oxygenation_index, ...], - angle=0, - translate=[tvec_refref[1], tvec_refref[0]], - scale=1.0, - shear=0, - interpolation=tv.transforms.InterpolationMode.BILINEAR, - fill=-100.0, - ) - mylogger.info("-==- Done -==-") - - mylogger.info("Perform rotation between donor and volume and its ref images") - mylogger.info("for all frames and then rotate all the data accordingly") - - ( - data[acceptor_index, ...], - data[donor_index, ...], - data[oxygenation_index, ...], - data[volume_index, ...], - angle_donor_volume, - ) = perform_donor_volume_rotation( - mylogger=mylogger, - acceptor=data[acceptor_index, ...], - donor=data[donor_index, ...], - oxygenation=data[oxygenation_index, ...], - volume=data[volume_index, ...], - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - config=config, - ) - - mylogger.info( - f"angles: " - f"min {round(float(angle_donor_volume.min()), 2)} " - f"max {round(float(angle_donor_volume.max()), 2)} " - f"mean {round(float(angle_donor_volume.mean()), 2)} " - ) - - if config["save_alignment"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_angle_donor_volume.npy" - ) - mylogger.info(f"Save angles to {temp_path}") - np.save(temp_path, angle_donor_volume.cpu()) - mylogger.info("-==- Done -==-") - - mylogger.info("Perform translation between donor and volume and its ref images") - mylogger.info("for all frames and then translate all the data accordingly") - ( - data[acceptor_index, ...], - data[donor_index, ...], - data[oxygenation_index, ...], - data[volume_index, ...], - tvec_donor_volume, - ) = perform_donor_volume_translation( - mylogger=mylogger, - acceptor=data[acceptor_index, ...], - donor=data[donor_index, ...], - oxygenation=data[oxygenation_index, ...], - volume=data[volume_index, ...], - ref_image_donor=ref_image_donor, - ref_image_volume=ref_image_volume, - batch_size=config["alignment_batch_size"], - fill_value=-100.0, - config=config, - ) - - mylogger.info( - f"translation dim 0: " - f"min {round(float(tvec_donor_volume[:, 0].min()), 1)} " - f"max {round(float(tvec_donor_volume[:, 0].max()), 1)} " - f"mean {round(float(tvec_donor_volume[:, 0].mean()), 1)} " - ) - mylogger.info( - f"translation dim 1: " - f"min {round(float(tvec_donor_volume[:, 1].min()), 1)} " - f"max {round(float(tvec_donor_volume[:, 1].max()), 1)} " - f"mean {round(float(tvec_donor_volume[:, 1].mean()), 1)} " - ) - - if config["save_alignment"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_tvec_donor_volume.npy" - ) - mylogger.info(f"Save translation vector to {temp_path}") - np.save(temp_path, tvec_donor_volume.cpu()) - mylogger.info("-==- Done -==-") - - mylogger.info("Finding zeros values in the RAW data and mark them for masking") - for i in range(0, data.shape[0]): - zero_pixel_mask = torch.any(data[i, ...] == 0, dim=0) - data[i, :, zero_pixel_mask] = -100.0 - mylogger.info( - f"{config['required_order'][i]}: " - f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " - f"with zeros " - ) - mylogger.info("-==- Done -==-") - - mylogger.info("Update mask with the new regions due to alignment") - - new_mask_area: torch.Tensor = torch.any(torch.any(data < -0.1, dim=0), dim=0).bool() - mask = (mask == 0).bool() - mask = torch.logical_or(mask, new_mask_area) - mask_negative: torch.Tensor = mask.clone() - mask_positve: torch.Tensor = torch.logical_not(mask) - del mask - - mylogger.info("Update the data with the new mask") - data *= mask_positve.unsqueeze(0).unsqueeze(0).type(dtype=dtype) - mylogger.info("-==- Done -==-") - - if config["save_aligned_as_python"]: - - temp_path = os.path.join( - config["export_path"], experiment_name + "_aligned.npz" - ) - mylogger.info(f"Save aligned data and mask to {temp_path}") - np.savez_compressed( - temp_path, - data=data.cpu(), - mask=mask_positve.cpu(), - acceptor_index=acceptor_index, - donor_index=donor_index, - oxygenation_index=oxygenation_index, - volume_index=volume_index, - ) - - if config["save_aligned_as_matlab"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_aligned.hd5" - ) - mylogger.info(f"Save aligned data and mask to {temp_path}") - file_handle = h5py.File(temp_path, "w") - - _ = file_handle.create_dataset( - "mask", - data=mask_positve.movedim(0, -1).type(torch.uint8).cpu(), - compression="gzip", - compression_opts=9, - ) - - _ = file_handle.create_dataset( - "data", - data=data.movedim(1, -1).movedim(0, -1).cpu(), - compression="gzip", - compression_opts=9, - ) - - _ = file_handle.create_dataset( - "acceptor_index", - data=torch.tensor((acceptor_index,)), - compression="gzip", - compression_opts=9, - ) - - _ = file_handle.create_dataset( - "donor_index", - data=torch.tensor((donor_index,)), - compression="gzip", - compression_opts=9, - ) - - _ = file_handle.create_dataset( - "oxygenation_index", - data=torch.tensor((oxygenation_index,)), - compression="gzip", - compression_opts=9, - ) - - _ = file_handle.create_dataset( - "volume_index", - data=torch.tensor((volume_index,)), - compression="gzip", - compression_opts=9, - ) - - mylogger.info("Reminder: How to read with matlab:") - mylogger.info(f"mask = h5read('{temp_path}','/mask');") - mylogger.info(f"data_acceptor = h5read('{temp_path}','/data');") - file_handle.close() - - mylogger.info("") - mylogger.info("(D) INTER-FRAME INTERPOLATION") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Interpolate the 'in-between' frames for oxygenation and volume") - data[oxygenation_index, 1:, ...] = ( - data[oxygenation_index, 1:, ...] + data[oxygenation_index, :-1, ...] - ) / 2.0 - data[volume_index, 1:, ...] = ( - data[volume_index, 1:, ...] + data[volume_index, :-1, ...] - ) / 2.0 - mylogger.info("-==- Done -==-") - - sample_frequency: float = 1.0 / meta_frame_time - - if config["gevi"]: - assert config["heartbeat_remove"] - - if config["heartbeat_remove"]: - - mylogger.info("") - mylogger.info("(E-OPTIONAL) HEARTBEAT REMOVAL") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Extract heartbeat from volume signal") - heartbeat_ts: torch.Tensor = bandpass( - data=data[volume_index, ...].movedim(0, -1).clone(), - low_frequency=config["lower_freqency_bandpass"], - high_frequency=config["upper_freqency_bandpass"], - fs=sample_frequency, - filtfilt_chuck_size=config["heartbeat_filtfilt_chuck_size"], - ) - heartbeat_ts = heartbeat_ts.flatten(start_dim=0, end_dim=-2) - mask_flatten: torch.Tensor = mask_positve.flatten(start_dim=0, end_dim=-1) - - heartbeat_ts = heartbeat_ts[mask_flatten, :] - - heartbeat_ts = heartbeat_ts.movedim(0, -1) - heartbeat_ts -= heartbeat_ts.mean(dim=0, keepdim=True) - - try: - volume_heartbeat, _, _ = torch.linalg.svd(heartbeat_ts, full_matrices=False) - except torch.cuda.OutOfMemoryError: - mylogger.info("torch.cuda.OutOfMemoryError: Fallback to cpu") - volume_heartbeat_cpu, _, _ = torch.linalg.svd( - heartbeat_ts.cpu(), full_matrices=False - ) - volume_heartbeat = volume_heartbeat_cpu.to(heartbeat_ts.device, copy=True) - del volume_heartbeat_cpu - - volume_heartbeat = volume_heartbeat[:, 0] - volume_heartbeat -= volume_heartbeat[ - config["skip_frames_in_the_beginning"] : - ].mean() - - del heartbeat_ts - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - free_mem = cuda_total_memory - max( - [ - torch.cuda.memory_reserved(device), - torch.cuda.memory_allocated(device), - ] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - if config["save_heartbeat"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_volume_heartbeat.npy" - ) - mylogger.info(f"Save volume heartbeat to {temp_path}") - np.save(temp_path, volume_heartbeat.cpu()) - mylogger.info("-==- Done -==-") - - volume_heartbeat = volume_heartbeat.unsqueeze(0).unsqueeze(0) - norm_volume_heartbeat = ( - volume_heartbeat[..., config["skip_frames_in_the_beginning"] :] ** 2 - ).sum(dim=-1) - - heartbeat_coefficients: torch.Tensor = torch.zeros( - (data.shape[0], data.shape[-2], data.shape[-1]), - dtype=data.dtype, - device=data.device, - ) - for i in range(0, data.shape[0]): - y = bandpass( - data=data[i, ...].movedim(0, -1).clone(), - low_frequency=config["lower_freqency_bandpass"], - high_frequency=config["upper_freqency_bandpass"], - fs=sample_frequency, - filtfilt_chuck_size=config["heartbeat_filtfilt_chuck_size"], - )[..., config["skip_frames_in_the_beginning"] :] - y -= y.mean(dim=-1, keepdim=True) - - heartbeat_coefficients[i, ...] = ( - volume_heartbeat[..., config["skip_frames_in_the_beginning"] :] * y - ).sum(dim=-1) / norm_volume_heartbeat - - heartbeat_coefficients[i, ...] *= mask_positve.type( - dtype=heartbeat_coefficients.dtype - ) - del y - - if config["save_heartbeat"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_heartbeat_coefficients.npy" - ) - mylogger.info(f"Save heartbeat coefficients to {temp_path}") - np.save(temp_path, heartbeat_coefficients.cpu()) - mylogger.info("-==- Done -==-") - - mylogger.info("Remove heart beat from data") - data -= heartbeat_coefficients.unsqueeze(1) * volume_heartbeat.unsqueeze( - 0 - ).movedim(-1, 1) - # data_herzlos = data.clone() - mylogger.info("-==- Done -==-") - - if config["gevi"]: # UDO scaling performed! - - mylogger.info("") - mylogger.info("(F-OPTIONAL) DONOR/ACCEPTOR SCALING") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - donor_heartbeat_factor = heartbeat_coefficients[donor_index, ...].clone() - acceptor_heartbeat_factor = heartbeat_coefficients[ - acceptor_index, ... - ].clone() - del heartbeat_coefficients - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - free_mem = cuda_total_memory - max( - [ - torch.cuda.memory_reserved(device), - torch.cuda.memory_allocated(device), - ] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - mylogger.info("Calculate scaling factor for donor and acceptor") - # donor_factor: torch.Tensor = ( - # donor_heartbeat_factor + acceptor_heartbeat_factor - # ) / (2 * donor_heartbeat_factor) - # acceptor_factor: torch.Tensor = ( - # donor_heartbeat_factor + acceptor_heartbeat_factor - # ) / (2 * acceptor_heartbeat_factor) - donor_factor = torch.sqrt( - acceptor_heartbeat_factor / donor_heartbeat_factor - ) - acceptor_factor = 1 / donor_factor - - # import matplotlib.pyplot as plt - # plt.pcolor(donor_factor, vmin=0.5, vmax=2.0) - # plt.colorbar() - # plt.show() - # plt.pcolor(acceptor_factor, vmin=0.5, vmax=2.0) - # plt.colorbar() - # plt.show() - # TODO remove - - del donor_heartbeat_factor - del acceptor_heartbeat_factor - - # import matplotlib.pyplot as plt - # plt.pcolor(torch.std(data[acceptor_index, config["skip_frames_in_the_beginning"] :, ...], axis=0), vmin=0, vmax=500) - # plt.colorbar() - # plt.show() - # plt.pcolor(torch.std(data[donor_index, config["skip_frames_in_the_beginning"] :, ...], axis=0), vmin=0, vmax=500) - # plt.colorbar() - # plt.show() - # TODO remove - - if config["save_factors"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_donor_factor.npy" - ) - mylogger.info(f"Save donor factor to {temp_path}") - np.save(temp_path, donor_factor.cpu()) - - temp_path = os.path.join( - config["export_path"], experiment_name + "_acceptor_factor.npy" - ) - mylogger.info(f"Save acceptor factor to {temp_path}") - np.save(temp_path, acceptor_factor.cpu()) - mylogger.info("-==- Done -==-") - - # TODO we have to calculate means first! - mylogger.info("Extract means for acceptor and donor first") - mean_values_acceptor = data[ - acceptor_index, config["skip_frames_in_the_beginning"] :, ... - ].nanmean(dim=0, keepdim=True) - mean_values_donor = data[ - donor_index, config["skip_frames_in_the_beginning"] :, ... - ].nanmean(dim=0, keepdim=True) - - mylogger.info("Scale acceptor to heart beat amplitude") - mylogger.info("Remove mean") - data[acceptor_index, ...] -= mean_values_acceptor - mylogger.info("Apply acceptor_factor and mask") - # data[acceptor_index, ...] *= acceptor_factor.unsqueeze( - # 0 - # ) * mask_positve.unsqueeze(0) - acceptor_factor_correction = np.sqrt( - mean_values_acceptor / mean_values_donor - ) - data[acceptor_index, ...] *= acceptor_factor.unsqueeze( - 0 - ) * acceptor_factor_correction * mask_positve.unsqueeze(0) - mylogger.info("Add mean") - data[acceptor_index, ...] += mean_values_acceptor - mylogger.info("-==- Done -==-") - - mylogger.info("Scale donor to heart beat amplitude") - mylogger.info("Remove mean") - data[donor_index, ...] -= mean_values_donor - mylogger.info("Apply donor_factor and mask") - # data[donor_index, ...] *= donor_factor.unsqueeze( - # 0 - # ) * mask_positve.unsqueeze(0) - donor_factor_correction = 1 / acceptor_factor_correction - data[donor_index, ...] *= donor_factor.unsqueeze( - 0 - ) * donor_factor_correction * mask_positve.unsqueeze(0) - mylogger.info("Add mean") - data[donor_index, ...] += mean_values_donor - mylogger.info("-==- Done -==-") - - # import matplotlib.pyplot as plt - # plt.pcolor(mean_values_acceptor[0]) - # plt.colorbar() - # plt.show() - # plt.pcolor(mean_values_donor[0]) - # plt.colorbar() - # plt.show() - # TODO remove - - # TODO SCHNUGGEL - else: - mylogger.info("GECI does not require acceptor/donor scaling, skipping!") - mylogger.info("-==- Done -==-") - - mylogger.info("") - mylogger.info("(G) CONVERSION TO RELATIVE SIGNAL CHANGES (DIV/MEAN)") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Divide by mean over time") - data /= data[:, config["skip_frames_in_the_beginning"] :, ...].nanmean( - dim=1, - keepdim=True, - ) - mylogger.info("-==- Done -==-") - - mylogger.info("") - mylogger.info("(H) CLEANING BY REGRESSION") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - data = data.nan_to_num(nan=0.0) - mylogger.info("Preparation for regression -- Gauss smear") - spatial_width = float(config["gauss_smear_spatial_width"]) - - if config["binning_enable"] and (config["binning_at_the_end"] is False): - spatial_width /= float(config["binning_kernel_size"]) - - mylogger.info( - f"Mask -- " - f"spatial width: {spatial_width}, " - f"temporal width: {float(config['gauss_smear_temporal_width'])}, " - f"use matlab mode: {bool(config['gauss_smear_use_matlab_mask'])} " - ) - - input_mask = mask_positve.type(dtype=dtype).clone() - - filtered_mask: torch.Tensor - filtered_mask, _ = gauss_smear_individual( - input=input_mask, - spatial_width=spatial_width, - temporal_width=float(config["gauss_smear_temporal_width"]), - use_matlab_mask=bool(config["gauss_smear_use_matlab_mask"]), - epsilon=float(torch.finfo(input_mask.dtype).eps), - ) - - mylogger.info("creating a copy of the data") - data_filtered = data.clone().movedim(1, -1) - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - free_mem = cuda_total_memory - max( - [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - overwrite_fft_gauss: None | torch.Tensor = None - for i in range(0, data_filtered.shape[0]): - mylogger.info( - f"{config['required_order'][i]} -- " - f"spatial width: {spatial_width}, " - f"temporal width: {float(config['gauss_smear_temporal_width'])}, " - f"use matlab mode: {bool(config['gauss_smear_use_matlab_mask'])} " - ) - data_filtered[i, ...] *= input_mask.unsqueeze(-1) - data_filtered[i, ...], overwrite_fft_gauss = gauss_smear_individual( - input=data_filtered[i, ...], - spatial_width=spatial_width, - temporal_width=float(config["gauss_smear_temporal_width"]), - overwrite_fft_gauss=overwrite_fft_gauss, - use_matlab_mask=bool(config["gauss_smear_use_matlab_mask"]), - epsilon=float(torch.finfo(input_mask.dtype).eps), - ) - - data_filtered[i, ...] /= filtered_mask + 1e-20 - data_filtered[i, ...] += 1.0 - input_mask.unsqueeze(-1) - - del filtered_mask - del overwrite_fft_gauss - del input_mask - mylogger.info("data_filtered is populated") - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - free_mem = cuda_total_memory - max( - [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - mylogger.info("-==- Done -==-") - - mylogger.info("Preperation for Regression") - mylogger.info("Move time dimensions of data to the last dimension") - data = data.movedim(1, -1) - - dual_signal_mode: bool = True - if len(config["target_camera_acceptor"]) > 0: - mylogger.info("Regression Acceptor") - mylogger.info(f"Target: {config['target_camera_acceptor']}") - mylogger.info( - f"Regressors: constant, linear and {config['regressor_cameras_acceptor']}" - ) - target_id: int = config["required_order"].index( - config["target_camera_acceptor"] - ) - regressor_id: list[int] = [] - for i in range(0, len(config["regressor_cameras_acceptor"])): - regressor_id.append( - config["required_order"].index(config["regressor_cameras_acceptor"][i]) - ) - - data_acceptor, coefficients_acceptor = regression( - mylogger=mylogger, - target_camera_id=target_id, - regressor_camera_ids=regressor_id, - mask=mask_negative, - data=data, - data_filtered=data_filtered, - first_none_ramp_frame=int(config["skip_frames_in_the_beginning"]), - ) - - if config["save_regression_coefficients"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_coefficients_acceptor.npy" - ) - mylogger.info(f"Save acceptor coefficients to {temp_path}") - np.save(temp_path, coefficients_acceptor.cpu()) - del coefficients_acceptor - - mylogger.info("-==- Done -==-") - else: - dual_signal_mode = False - target_id = config["required_order"].index("acceptor") - data_acceptor = data[target_id, ...].clone() - data_acceptor[mask_negative, :] = 0.0 - - if len(config["target_camera_donor"]) > 0: - mylogger.info("Regression Donor") - mylogger.info(f"Target: {config['target_camera_donor']}") - mylogger.info( - f"Regressors: constant, linear and {config['regressor_cameras_donor']}" - ) - target_id = config["required_order"].index(config["target_camera_donor"]) - regressor_id = [] - for i in range(0, len(config["regressor_cameras_donor"])): - regressor_id.append( - config["required_order"].index(config["regressor_cameras_donor"][i]) - ) - - data_donor, coefficients_donor = regression( - mylogger=mylogger, - target_camera_id=target_id, - regressor_camera_ids=regressor_id, - mask=mask_negative, - data=data, - data_filtered=data_filtered, - first_none_ramp_frame=int(config["skip_frames_in_the_beginning"]), - ) - - if config["save_regression_coefficients"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_coefficients_donor.npy" - ) - mylogger.info(f"Save acceptor donor to {temp_path}") - np.save(temp_path, coefficients_donor.cpu()) - del coefficients_donor - mylogger.info("-==- Done -==-") - else: - dual_signal_mode = False - target_id = config["required_order"].index("donor") - data_donor = data[target_id, ...].clone() - data_donor[mask_negative, :] = 0.0 - - # TODO clean up ---> - if config["save_oxyvol_as_python"] or config["save_oxyvol_as_matlab"]: - - mylogger.info("") - mylogger.info("(I-OPTIONAL) SAVE OXY/VOL/MASK") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - # extract oxy and vol - mylogger.info("Save Oxygenation/Volume/Mask") - data_oxygenation = data[oxygenation_index, ...].clone() - data_volume = data[volume_index, ...].clone() - data_mask = mask_positve.clone() - - # bin, if required... - if config["binning_enable"] and config["binning_at_the_end"]: - mylogger.info("Binning of data") - mylogger.info( - ( - f"kernel_size={int(config['binning_kernel_size'])}, " - f"stride={int(config['binning_stride'])}, " - "divisor_override=None" - ) - ) - - data_oxygenation = binning( - data_oxygenation.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - data_volume = binning( - data_volume.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - data_mask = ( - binning( - data_mask.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ) - .squeeze(-1) - .squeeze(-1) - ) - data_mask = (data_mask > 0).type(torch.bool) - - if config["save_oxyvol_as_python"]: - - # export it! - temp_path = os.path.join( - config["export_path"], experiment_name + "_oxygenation_volume.npz" - ) - mylogger.info(f"Save data oxygenation and volume to {temp_path}") - np.savez_compressed( - temp_path, - data_oxygenation=data_oxygenation.cpu(), - data_volume=data_volume.cpu(), - data_mask=data_mask.cpu(), - ) - - if config["save_oxyvol_as_matlab"]: - - temp_path = os.path.join( - config["export_path"], experiment_name + "_oxygenation_volume.hd5" - ) - mylogger.info(f"Save data oxygenation and volume to {temp_path}") - file_handle = h5py.File(temp_path, "w") - - data_mask = data_mask.movedim(0, -1) - data_oxygenation = data_oxygenation.movedim(1, -1).movedim(0, -1) - data_volume = data_volume.movedim(1, -1).movedim(0, -1) - _ = file_handle.create_dataset( - "data_mask", - data=data_mask.type(torch.uint8).cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "data_oxygenation", - data=data_oxygenation.cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "data_volume", - data=data_volume.cpu(), - compression="gzip", - compression_opts=9, - ) - mylogger.info("Reminder: How to read with matlab:") - mylogger.info(f"data_mask = h5read('{temp_path}','/data_mask');") - mylogger.info(f"data_oxygenation = h5read('{temp_path}','/data_oxygenation');") - mylogger.info(f"data_volume = h5read('{temp_path}','/data_volume');") - file_handle.close() - # TODO <------ clean up - - del data - del data_filtered - - if device != torch.device("cpu"): - torch.cuda.empty_cache() - mylogger.info("Empty CUDA cache") - free_mem = cuda_total_memory - max( - [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] - ) - mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") - - # ##################### - - if config["gevi"]: - assert dual_signal_mode - else: - assert dual_signal_mode is False - - if dual_signal_mode is False: - - mylogger.info("") - mylogger.info("(J1-OPTIONAL) SAVE ACC/DON/MASK (NO RATIO!+OPT BIN@END)") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("mono signal model") - - mylogger.info("Remove nan") - data_acceptor = torch.nan_to_num(data_acceptor, nan=0.0) - data_donor = torch.nan_to_num(data_donor, nan=0.0) - mylogger.info("-==- Done -==-") - - if config["binning_enable"] and config["binning_at_the_end"]: - mylogger.info("Binning of data") - mylogger.info( - ( - f"kernel_size={int(config['binning_kernel_size'])}, " - f"stride={int(config['binning_stride'])}, " - "divisor_override=None" - ) - ) - - data_acceptor = binning( - data_acceptor.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - data_donor = binning( - data_donor.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - mask_positve = ( - binning( - mask_positve.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ) - .squeeze(-1) - .squeeze(-1) - ) - mask_positve = (mask_positve > 0).type(torch.bool) - - if config["save_as_python"]: - - temp_path = os.path.join( - config["export_path"], experiment_name + "_acceptor_donor.npz" - ) - mylogger.info(f"Save data donor and acceptor and mask to {temp_path}") - np.savez_compressed( - temp_path, - data_acceptor=data_acceptor.cpu(), - data_donor=data_donor.cpu(), - mask=mask_positve.cpu(), - ) - - if config["save_as_matlab"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_acceptor_donor.hd5" - ) - mylogger.info(f"Save data donor and acceptor and mask to {temp_path}") - file_handle = h5py.File(temp_path, "w") - - mask_positve = mask_positve.movedim(0, -1) - data_acceptor = data_acceptor.movedim(1, -1).movedim(0, -1) - data_donor = data_donor.movedim(1, -1).movedim(0, -1) - _ = file_handle.create_dataset( - "mask", - data=mask_positve.type(torch.uint8).cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "data_acceptor", - data=data_acceptor.cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "data_donor", - data=data_donor.cpu(), - compression="gzip", - compression_opts=9, - ) - mylogger.info("Reminder: How to read with matlab:") - mylogger.info(f"mask = h5read('{temp_path}','/mask');") - mylogger.info(f"data_acceptor = h5read('{temp_path}','/data_acceptor');") - mylogger.info(f"data_donor = h5read('{temp_path}','/data_donor');") - file_handle.close() - return - # ##################### - - mylogger.info("") - mylogger.info("(J2-OPTIONAL) BUILD AND SAVE RATIO (+OPT BIN@END)") - mylogger.info("-----------------------------------------------") - mylogger.info("") - - mylogger.info("Calculate ratio sequence") - - if config["classical_ratio_mode"]: - mylogger.info("via acceptor / donor") - ratio_sequence: torch.Tensor = data_acceptor / data_donor - mylogger.info("via / mean over time") - ratio_sequence /= ratio_sequence.mean(dim=-1, keepdim=True) - else: - mylogger.info("via 1.0 + acceptor - donor") - ratio_sequence = 1.0 + data_acceptor - data_donor - - mylogger.info("Remove nan") - ratio_sequence = torch.nan_to_num(ratio_sequence, nan=0.0) - mylogger.info("-==- Done -==-") - - if config["binning_enable"] and config["binning_at_the_end"]: - mylogger.info("Binning of data") - mylogger.info( - ( - f"kernel_size={int(config['binning_kernel_size'])}, " - f"stride={int(config['binning_stride'])}, " - "divisor_override=None" - ) - ) - - ratio_sequence = binning( - ratio_sequence.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - if config["save_gevi_with_donor_acceptor"]: - data_acceptor = binning( - data_acceptor.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - data_donor = binning( - data_donor.unsqueeze(-1), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ).squeeze(-1) - - mask_positve = ( - binning( - mask_positve.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), - kernel_size=int(config["binning_kernel_size"]), - stride=int(config["binning_stride"]), - divisor_override=None, - ) - .squeeze(-1) - .squeeze(-1) - ) - mask_positve = (mask_positve > 0).type(torch.bool) - - if config["save_as_python"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_ratio_sequence.npz" - ) - mylogger.info(f"Save ratio_sequence and mask to {temp_path}") - if config["save_gevi_with_donor_acceptor"]: - np.savez_compressed( - temp_path, ratio_sequence=ratio_sequence.cpu(), mask=mask_positve.cpu(), data_acceptor=data_acceptor.cpu(), data_donor=data_donor.cpu() - ) - else: - np.savez_compressed( - temp_path, ratio_sequence=ratio_sequence.cpu(), mask=mask_positve.cpu() - ) - - if config["save_as_matlab"]: - temp_path = os.path.join( - config["export_path"], experiment_name + "_ratio_sequence.hd5" - ) - mylogger.info(f"Save ratio_sequence and mask to {temp_path}") - file_handle = h5py.File(temp_path, "w") - - mask_positve = mask_positve.movedim(0, -1) - ratio_sequence = ratio_sequence.movedim(1, -1).movedim(0, -1) - _ = file_handle.create_dataset( - "mask", - data=mask_positve.type(torch.uint8).cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "ratio_sequence", - data=ratio_sequence.cpu(), - compression="gzip", - compression_opts=9, - ) - if config["save_gevi_with_donor_acceptor"]: - _ = file_handle.create_dataset( - "data_acceptor", - data=data_acceptor.cpu(), - compression="gzip", - compression_opts=9, - ) - _ = file_handle.create_dataset( - "data_donor", - data=data_donor.cpu(), - compression="gzip", - compression_opts=9, - ) - mylogger.info("Reminder: How to read with matlab:") - mylogger.info(f"mask = h5read('{temp_path}','/mask');") - mylogger.info(f"ratio_sequence = h5read('{temp_path}','/ratio_sequence');") - if config["save_gevi_with_donor_acceptor"]: - mylogger.info(f"data_donor = h5read('{temp_path}','/data_donor');") - mylogger.info(f"data_acceptor = h5read('{temp_path}','/data_acceptor');") - file_handle.close() - - del ratio_sequence - del mask_positve - del mask_negative - - mylogger.info("") - mylogger.info("***********************************************") - mylogger.info("* TRIAL END ***********************************") - mylogger.info("***********************************************") - mylogger.info("") - - return - - -def main( - *, - config_filename: str = "config.json", - experiment_id_overwrite: int = -1, - trial_id_overwrite: int = -1, -) -> None: - mylogger = create_logger( - save_logging_messages=True, - display_logging_messages=True, - log_stage_name="stage_4", - ) - - config = load_config(mylogger=mylogger, filename=config_filename) - - if (config["save_as_python"] is False) and (config["save_as_matlab"] is False): - mylogger.info("No output will be created. ") - mylogger.info("Change save_as_python and/or save_as_matlab in the config file") - mylogger.info("ERROR: STOP!!!") - exit() - - if (len(config["target_camera_donor"]) == 0) and ( - len(config["target_camera_acceptor"]) == 0 - ): - mylogger.info( - "Configure at least target_camera_donor or target_camera_acceptor correctly." - ) - mylogger.info("ERROR: STOP!!!") - exit() - - device = get_torch_device(mylogger, config["force_to_cpu"]) - - mylogger.info( - f"Create directory {config['export_path']} in the case it does not exist" - ) - os.makedirs(config["export_path"], exist_ok=True) - - raw_data_path: str = os.path.join( - config["basic_path"], - config["recoding_data"], - config["mouse_identifier"], - config["raw_path"], - ) - - if os.path.isdir(raw_data_path) is False: - mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") - exit() - - if experiment_id_overwrite == -1: - experiments = get_experiments(raw_data_path) - else: - assert experiment_id_overwrite >= 0 - experiments = torch.tensor([experiment_id_overwrite]) - - for experiment_counter in range(0, experiments.shape[0]): - experiment_id = int(experiments[experiment_counter]) - - if trial_id_overwrite == -1: - trials = get_trials(raw_data_path, experiment_id) - else: - assert trial_id_overwrite >= 0 - trials = torch.tensor([trial_id_overwrite]) - - for trial_counter in range(0, trials.shape[0]): - trial_id = int(trials[trial_counter]) - - mylogger.info("") - mylogger.info( - f"======= EXPERIMENT ID: {experiment_id} ==== TRIAL ID: {trial_id} =======" - ) - mylogger.info("") - - try: - process_trial( - config=config, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=device, - ) - except torch.cuda.OutOfMemoryError: - mylogger.info("WARNING: RUNNING IN FAILBACK MODE!!!!") - mylogger.info("Not enough GPU memory. Retry on CPU") - process_trial( - config=config, - mylogger=mylogger, - experiment_id=experiment_id, - trial_id=trial_id, - device=torch.device("cpu"), - ) - - -if __name__ == "__main__": - argh.dispatch_command(main) - -# %% From 1946ecc3431e1858b3b191a7e295a59636c9e8c6 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:32:03 +0100 Subject: [PATCH 21/25] Delete README.md --- README.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index 8a52c17..0000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -This code is a reimagining of - -Robert Staadt - -Development of a system for high-volume multi-channel brain imaging of fluorescent voltage signals - -Dissertation - -Ruhr-Universität Bochum, Universitätsbibliothek - -08.02.2024 - -[https://doi.org/10.13154/294-11032](https://doi.org/10.13154/294-11032) From d0b8ff58d26b27c79f6e53629c2bf370f69ae72e Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:32:36 +0100 Subject: [PATCH 22/25] Add files via upload --- README.md | 59 + functions/Anime.py | 93 ++ functions/ImageAlignment.py | 1015 ++++++++++++ functions/align_refref.py | 60 + functions/bandpass.py | 113 ++ functions/binning.py | 46 + functions/calculate_rotation.py | 40 + functions/calculate_translation.py | 37 + functions/create_logger.py | 37 + functions/data_raw_loader.py | 339 ++++ functions/gauss_smear_individual.py | 168 ++ functions/get_experiments.py | 19 + functions/get_parts.py | 18 + functions/get_torch_device.py | 17 + functions/get_trials.py | 19 + functions/load_config.py | 16 + functions/load_meta_data.py | 68 + functions/perform_donor_volume_rotation.py | 207 +++ functions/perform_donor_volume_translation.py | 210 +++ functions/regression.py | 117 ++ functions/regression_internal.py | 27 + geci/config_M_Sert_Cre_41.json | 67 + geci/config_M_Sert_Cre_42.json | 67 + geci/config_M_Sert_Cre_45.json | 67 + geci/config_M_Sert_Cre_46.json | 67 + geci/config_M_Sert_Cre_49.json | 67 + geci/config_example_GECI.json | 67 + geci/geci_loader.py | 168 ++ geci/geci_plot.py | 181 +++ geci/stage_6_convert_roi.py | 53 + gevi/config_M0134M_2024-11-06_SessionA.json | 67 + gevi/config_M0134M_2024-11-06_SessionB.json | 67 + gevi/config_M0134M_2024-11-07_SessionA.json | 67 + gevi/config_M0134M_2024-11-07_SessionB.json | 67 + gevi/config_M0134M_2024-11-13_SessionA.json | 67 + gevi/config_M0134M_2024-11-13_SessionB.json | 67 + gevi/config_M0134M_2024-11-15_SessionA.json | 67 + gevi/config_M0134M_2024-11-15_SessionB.json | 67 + gevi/config_M0134M_2024-11-18_SessionA.json | 67 + gevi/config_M0134M_2024-11-18_SessionB.json | 67 + gevi/config_M0134M_2024-12-04_SessionA.json | 67 + gevi/config_M0134M_2024-12-04_SessionB.json | 67 + gevi/config_M3905F_SessionB.json | 67 + gevi/config_example_GEVI.json | 66 + gevi/example_load_gevi.py | 56 + other/stage_4b_inspect.py | 532 +++++++ other/stage_4c_viewer.py | 56 + stage_1_get_ref_image.py | 129 ++ stage_2_make_heartbeat_mask.py | 163 ++ stage_3_refine_mask.py | 169 ++ stage_4_process.py | 1413 +++++++++++++++++ stage_5_convert_metadata.py | 57 + 52 files changed, 7041 insertions(+) create mode 100644 README.md create mode 100644 functions/Anime.py create mode 100644 functions/ImageAlignment.py create mode 100644 functions/align_refref.py create mode 100644 functions/bandpass.py create mode 100644 functions/binning.py create mode 100644 functions/calculate_rotation.py create mode 100644 functions/calculate_translation.py create mode 100644 functions/create_logger.py create mode 100644 functions/data_raw_loader.py create mode 100644 functions/gauss_smear_individual.py create mode 100644 functions/get_experiments.py create mode 100644 functions/get_parts.py create mode 100644 functions/get_torch_device.py create mode 100644 functions/get_trials.py create mode 100644 functions/load_config.py create mode 100644 functions/load_meta_data.py create mode 100644 functions/perform_donor_volume_rotation.py create mode 100644 functions/perform_donor_volume_translation.py create mode 100644 functions/regression.py create mode 100644 functions/regression_internal.py create mode 100644 geci/config_M_Sert_Cre_41.json create mode 100644 geci/config_M_Sert_Cre_42.json create mode 100644 geci/config_M_Sert_Cre_45.json create mode 100644 geci/config_M_Sert_Cre_46.json create mode 100644 geci/config_M_Sert_Cre_49.json create mode 100644 geci/config_example_GECI.json create mode 100644 geci/geci_loader.py create mode 100644 geci/geci_plot.py create mode 100644 geci/stage_6_convert_roi.py create mode 100644 gevi/config_M0134M_2024-11-06_SessionA.json create mode 100644 gevi/config_M0134M_2024-11-06_SessionB.json create mode 100644 gevi/config_M0134M_2024-11-07_SessionA.json create mode 100644 gevi/config_M0134M_2024-11-07_SessionB.json create mode 100644 gevi/config_M0134M_2024-11-13_SessionA.json create mode 100644 gevi/config_M0134M_2024-11-13_SessionB.json create mode 100644 gevi/config_M0134M_2024-11-15_SessionA.json create mode 100644 gevi/config_M0134M_2024-11-15_SessionB.json create mode 100644 gevi/config_M0134M_2024-11-18_SessionA.json create mode 100644 gevi/config_M0134M_2024-11-18_SessionB.json create mode 100644 gevi/config_M0134M_2024-12-04_SessionA.json create mode 100644 gevi/config_M0134M_2024-12-04_SessionB.json create mode 100644 gevi/config_M3905F_SessionB.json create mode 100644 gevi/config_example_GEVI.json create mode 100644 gevi/example_load_gevi.py create mode 100644 other/stage_4b_inspect.py create mode 100644 other/stage_4c_viewer.py create mode 100644 stage_1_get_ref_image.py create mode 100644 stage_2_make_heartbeat_mask.py create mode 100644 stage_3_refine_mask.py create mode 100644 stage_4_process.py create mode 100644 stage_5_convert_metadata.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..79fa2ca --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +This code is a reimagining of + +Robert Staadt + +Development of a system for high-volume multi-channel brain imaging of fluorescent voltage signals + +Dissertation + +Ruhr-Universität Bochum, Universitätsbibliothek + +08.02.2024 + +[https://doi.org/10.13154/294-11032](https://doi.org/10.13154/294-11032) + +----------------------------------------------------------------------------------------------------- + +Updated: 19.03.2025 + +Files are now organized in subdirectories to distinguish better between code for GEVI or GECI analysis. + +gevi-geci/ + stage_1*, stage_2*, stage_3*, stage_4*, stage_5* + -> main stages for data preprocessing + -> use e.g.: python stage_1_get_ref_image.py -c config_example_GEVI.json + functions/ + -> functions used by the main stages + +gevi-geci/gevi/ + config_example_GEVI.json + -> typical config file for GEVI (compare to gevi-geci/geci/config_example_GECI.json) + config_M0134M*, config_M3905F* + -> config files for a few recordings (adjust directory names, if necessary!) + example_load_gevi.py + -> simple script demonstrating how to load data + +gevi-geci/geci/ + config_example_GECI.json + -> typical config file for GECI (compare to gevi-geci/gevi/config_example_GEVI.json) + config_M_Sert_Cre_4* + -> config files for a few recordings (adjust directory names, if necessary!) + stage_6_convert_roi.py + -> additional stage for the analysis of Hendrik's recordings + -> use e.g.: python stage_6_convert_roi.py -f config_M_Sert_Cre_41.json + geci_loader.py, geci_plot.py + -> additional code for summarizing the results and plotting with the ROIs + -> use e.g. python geci_loader.py --filename config_M_Sert_Cre_41.json + +gevi-geci/other/ + stage_4b_inspect.py, stage_4c_viewer.py + -> temporary code for assisting search for implantation electrode + + + + + + + + + diff --git a/functions/Anime.py b/functions/Anime.py new file mode 100644 index 0000000..bfc4e46 --- /dev/null +++ b/functions/Anime.py @@ -0,0 +1,93 @@ +import numpy as np +import torch +import matplotlib.pyplot as plt +import matplotlib.animation + + +class Anime: + def __init__(self) -> None: + super().__init__() + + def show( + self, + input: torch.Tensor | np.ndarray, + mask: torch.Tensor | np.ndarray | None = None, + vmin: float | None = None, + vmax: float | None = None, + cmap: str = "hot", + axis_off: bool = True, + show_frame_count: bool = True, + interval: int = 100, + repeat: bool = False, + colorbar: bool = True, + vmin_scale: float | None = None, + vmax_scale: float | None = None, + movie_file: str | None = None, + ) -> None: + assert input.ndim == 3 + + if isinstance(input, torch.Tensor): + input_np: np.ndarray = input.cpu().numpy() + if mask is not None: + mask_np: np.ndarray | None = (mask == 0).cpu().numpy() + else: + mask_np = None + else: + input_np = input + if mask is not None: + mask_np = mask == 0 # type: ignore + else: + mask_np = None + + if vmin is None: + vmin = float(np.where(np.isfinite(input_np), input_np, 0.0).min()) + if vmax is None: + vmax = float(np.where(np.isfinite(input_np), input_np, 0.0).max()) + + if vmin_scale is not None: + vmin *= vmin_scale + + if vmax_scale is not None: + vmax *= vmax_scale + + fig = plt.figure() + image = np.nan_to_num(input_np[0, ...], copy=True, nan=0.0) + if mask_np is not None: + image[mask_np] = float("NaN") + image_handle = plt.imshow( + image, + cmap=cmap, + vmin=vmin, + vmax=vmax, + ) + + if colorbar: + plt.colorbar() + + if axis_off: + plt.axis("off") + + def next_frame(i: int) -> None: + image = np.nan_to_num(input_np[i, ...], copy=True, nan=0.0) + if mask_np is not None: + image[mask_np] = float("NaN") + + image_handle.set_data(image) + if show_frame_count: + bar_length: int = 10 + filled_length = int(round(bar_length * i / input_np.shape[0])) + bar = "\u25A0" * filled_length + "\u25A1" * (bar_length - filled_length) + plt.title(f"{bar} {i} of {int(input_np.shape[0]-1)}", loc="left") + return + + ani = matplotlib.animation.FuncAnimation( + fig, + next_frame, + frames=int(input.shape[0]), + interval=interval, + repeat=repeat, + ) + if movie_file is not None: + ani.save(movie_file) + else: + plt.show() diff --git a/functions/ImageAlignment.py b/functions/ImageAlignment.py new file mode 100644 index 0000000..6472d02 --- /dev/null +++ b/functions/ImageAlignment.py @@ -0,0 +1,1015 @@ +import torch +import torchvision as tv # type: ignore + +# The source code is based on: +# https://github.com/matejak/imreg_dft + +# The original LICENSE: +# Copyright (c) 2014, Matěj Týč +# Copyright (c) 2011-2014, Christoph Gohlke +# Copyright (c) 2011-2014, The Regents of the University of California +# Produced at the Laboratory for Fluorescence Dynamics + +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the {organization} nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +class ImageAlignment(torch.nn.Module): + device: torch.device + default_dtype: torch.dtype + excess_const: float = 1.1 + exponent: str = "inf" + success: torch.Tensor | None = None + + # The factor that detmines how many + # sub-pixel we will shift + scale_factor: int = 4 + + reference_image: torch.Tensor | None = None + + last_scale: torch.Tensor | None = None + last_angle: torch.Tensor | None = None + last_tvec: torch.Tensor | None = None + + # Cache + image_reference_dft: torch.Tensor | None = None + filt: torch.Tensor + pcorr_shape: torch.Tensor + log_base: torch.Tensor + image_reference_logp: torch.Tensor + + def __init__( + self, + device: torch.device | None = None, + default_dtype: torch.dtype | None = None, + ) -> None: + super().__init__() + + assert device is not None + assert default_dtype is not None + self.device = device + self.default_dtype = default_dtype + + def set_new_reference_image(self, new_reference_image: torch.Tensor | None = None): + assert new_reference_image is not None + assert new_reference_image.ndim == 2 + self.reference_image = ( + new_reference_image.detach() + .clone() + .to(device=self.device) + .type(dtype=self.default_dtype) + ) + self.image_reference_dft = None + + def forward( + self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None + ) -> torch.Tensor: + assert input.ndim == 3 + + if new_reference_image is not None: + self.set_new_reference_image(new_reference_image) + + assert self.reference_image is not None + assert self.reference_image.ndim == 2 + assert input.shape[-2] == self.reference_image.shape[-2] + assert input.shape[-1] == self.reference_image.shape[-1] + + self.last_scale, self.last_angle, self.last_tvec, output = self.similarity( + self.reference_image, + input.to(device=self.device).type(dtype=self.default_dtype), + ) + + return output + + def dry_run( + self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None + ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]: + assert input.ndim == 3 + + if new_reference_image is not None: + self.set_new_reference_image(new_reference_image) + + assert self.reference_image is not None + assert self.reference_image.ndim == 2 + assert input.shape[-2] == self.reference_image.shape[-2] + assert input.shape[-1] == self.reference_image.shape[-1] + + images_todo = input.to(device=self.device).type(dtype=self.default_dtype) + image_reference = self.reference_image + + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + + bgval: torch.Tensor = self.get_borderval(img=images_todo, radius=5) + + self.last_scale, self.last_angle, self.last_tvec = self._similarity( + image_reference, + images_todo, + bgval, + ) + + return self.last_scale, self.last_angle, self.last_tvec + + def dry_run_translation( + self, input: torch.Tensor, new_reference_image: torch.Tensor | None = None + ) -> torch.Tensor: + assert input.ndim == 3 + + if new_reference_image is not None: + self.set_new_reference_image(new_reference_image) + + assert self.reference_image is not None + assert self.reference_image.ndim == 2 + assert input.shape[-2] == self.reference_image.shape[-2] + assert input.shape[-1] == self.reference_image.shape[-1] + + images_todo = input.to(device=self.device).type(dtype=self.default_dtype) + image_reference = self.reference_image + + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + + tvec, _ = self._translation(image_reference, images_todo) + + return tvec + + # --------------- + + def dry_run_angle( + self, + input: torch.Tensor, + new_reference_image: torch.Tensor | None = None, + ) -> torch.Tensor: + assert input.ndim == 3 + + if new_reference_image is not None: + self.set_new_reference_image(new_reference_image) + + constraints_dynamic_angle_0: torch.Tensor = torch.zeros( + (input.shape[0]), dtype=self.default_dtype, device=self.device + ) + constraints_dynamic_angle_1: torch.Tensor | None = None + constraints_dynamic_scale_0: torch.Tensor = torch.ones( + (input.shape[0]), dtype=self.default_dtype, device=self.device + ) + constraints_dynamic_scale_1: torch.Tensor | None = None + + assert self.reference_image is not None + assert self.reference_image.ndim == 2 + assert input.shape[-2] == self.reference_image.shape[-2] + assert input.shape[-1] == self.reference_image.shape[-1] + + images_todo = input.to(device=self.device).type(dtype=self.default_dtype) + image_reference = self.reference_image + + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + + _, newangle = self._get_ang_scale( + image_reference, + images_todo, + constraints_dynamic_scale_0, + constraints_dynamic_scale_1, + constraints_dynamic_angle_0, + constraints_dynamic_angle_1, + ) + + return newangle + + # --------------- + + def _get_pcorr_shape(self, shape: torch.Size) -> tuple[int, int]: + ret = (int(max(shape[-2:]) * 1.0),) * 2 + return ret + + def _get_log_base(self, shape: torch.Size, new_r: torch.Tensor) -> torch.Tensor: + old_r = torch.tensor( + (float(shape[-2]) * self.excess_const) / 2.0, + dtype=self.default_dtype, + device=self.device, + ) + log_base = torch.exp(torch.log(old_r) / new_r) + return log_base + + def wrap_angle( + self, angles: torch.Tensor, ceil: float = 2 * torch.pi + ) -> torch.Tensor: + angles += ceil / 2.0 + angles %= ceil + angles -= ceil / 2.0 + return angles + + def get_borderval( + self, img: torch.Tensor, radius: int | None = None + ) -> torch.Tensor: + assert img.ndim == 3 + if radius is None: + mindim = min([int(img.shape[-2]), int(img.shape[-1])]) + radius = max(1, mindim // 20) + mask = torch.zeros( + (int(img.shape[-2]), int(img.shape[-1])), + dtype=torch.bool, + device=self.device, + ) + mask[:, :radius] = True + mask[:, -radius:] = True + mask[:radius, :] = True + mask[-radius:, :] = True + + mean = torch.median(img[:, mask], dim=-1)[0] + return mean + + def get_apofield(self, shape: torch.Size, aporad: int) -> torch.Tensor: + if aporad == 0: + return torch.ones( + shape[-2:], + dtype=self.default_dtype, + device=self.device, + ) + + assert int(shape[-2]) > aporad * 2 + assert int(shape[-1]) > aporad * 2 + + apos = torch.hann_window( + aporad * 2, dtype=self.default_dtype, periodic=False, device=self.device + ) + + toapp_0 = torch.ones( + shape[-2], + dtype=self.default_dtype, + device=self.device, + ) + toapp_0[:aporad] = apos[:aporad] + toapp_0[-aporad:] = apos[-aporad:] + + toapp_1 = torch.ones( + shape[-1], + dtype=self.default_dtype, + device=self.device, + ) + toapp_1[:aporad] = apos[:aporad] + toapp_1[-aporad:] = apos[-aporad:] + + apofield = torch.outer(toapp_0, toapp_1) + + return apofield + + def _get_subarr( + self, array: torch.Tensor, center: torch.Tensor, rad: int + ) -> torch.Tensor: + assert array.ndim == 3 + assert center.ndim == 2 + assert array.shape[0] == center.shape[0] + assert center.shape[1] == 2 + + dim = 1 + 2 * rad + subarr = torch.zeros( + (array.shape[0], dim, dim), dtype=self.default_dtype, device=self.device + ) + + corner = center - rad + idx_p = range(0, corner.shape[0]) + for ii in range(0, dim): + yidx = corner[:, 0] + ii + yidx %= array.shape[-2] + for jj in range(0, dim): + xidx = corner[:, 1] + jj + xidx %= array.shape[-1] + subarr[:, ii, jj] = array[idx_p, yidx, xidx] + + return subarr + + def _argmax_2d(self, array: torch.Tensor) -> torch.Tensor: + assert array.ndim == 3 + + max_pos = array.reshape( + (array.shape[0], array.shape[1] * array.shape[2]) + ).argmax(dim=1) + pos_0 = max_pos // array.shape[2] + + max_pos -= pos_0 * array.shape[2] + ret = torch.zeros( + (array.shape[0], 2), dtype=self.default_dtype, device=self.device + ) + ret[:, 0] = pos_0 + ret[:, 1] = max_pos + + return ret.type(dtype=torch.int64) + + def _apodize(self, what: torch.Tensor) -> torch.Tensor: + mindim = min([int(what.shape[-2]), int(what.shape[-1])]) + aporad = int(mindim * 0.12) + + apofield = self.get_apofield(what.shape, aporad).unsqueeze(0) + + res = what * apofield + bg = self.get_borderval(what, aporad // 2).unsqueeze(-1).unsqueeze(-1) + res += bg * (1 - apofield) + return res + + def _logpolar_filter(self, shape: torch.Size) -> torch.Tensor: + yy = torch.linspace( + -torch.pi / 2.0, + torch.pi / 2.0, + shape[-2], + dtype=self.default_dtype, + device=self.device, + ).unsqueeze(1) + + xx = torch.linspace( + -torch.pi / 2.0, + torch.pi / 2.0, + shape[-1], + dtype=self.default_dtype, + device=self.device, + ).unsqueeze(0) + + rads = torch.sqrt(yy**2 + xx**2) + filt = 1.0 - torch.cos(rads) ** 2 + + filt[torch.abs(rads) > torch.pi / 2] = 1 + return filt + + def _get_angles(self, shape: torch.Tensor) -> torch.Tensor: + ret = torch.zeros( + (int(shape[-2]), int(shape[-1])), + dtype=self.default_dtype, + device=self.device, + ) + ret -= torch.linspace( + 0, + torch.pi, + int(shape[-2] + 1), + dtype=self.default_dtype, + device=self.device, + )[:-1].unsqueeze(-1) + + return ret + + def _get_lograd(self, shape: torch.Tensor, log_base: torch.Tensor) -> torch.Tensor: + ret = torch.zeros( + (int(shape[-2]), int(shape[-1])), + dtype=self.default_dtype, + device=self.device, + ) + ret += torch.pow( + log_base, + torch.arange( + 0, + int(shape[-1]), + dtype=self.default_dtype, + device=self.device, + ), + ).unsqueeze(0) + return ret + + def _logpolar( + self, image: torch.Tensor, shape: torch.Tensor, log_base: torch.Tensor + ) -> torch.Tensor: + assert image.ndim == 3 + + imshape: torch.Tensor = torch.tensor( + image.shape[-2:], + dtype=self.default_dtype, + device=self.device, + ) + + center: torch.Tensor = imshape.clone() / 2 + + theta: torch.Tensor = self._get_angles(shape) + radius_x: torch.Tensor = self._get_lograd(shape, log_base) + radius_y: torch.Tensor = radius_x.clone() + + ellipse_coef: torch.Tensor = imshape[0] / imshape[1] + radius_x /= ellipse_coef + + y = radius_y * torch.sin(theta) + center[0] + y /= float(image.shape[-2]) + y *= 2 + y -= 1 + + x = radius_x * torch.cos(theta) + center[1] + x /= float(image.shape[-1]) + x *= 2 + x -= 1 + + idx_x = torch.where(torch.abs(x) <= 1.0, 1.0, 0.0) + idx_y = torch.where(torch.abs(y) <= 1.0, 1.0, 0.0) + + normalized_coords = torch.cat( + ( + x.unsqueeze(-1), + y.unsqueeze(-1), + ), + dim=-1, + ).unsqueeze(0) + + output = torch.empty( + (int(image.shape[0]), int(y.shape[0]), int(y.shape[1])), + dtype=self.default_dtype, + device=self.device, + ) + + for id in range(0, int(image.shape[0])): + bgval: torch.Tensor = torch.quantile(image[id, :, :], q=1.0 / 100.0) + + temp = torch.nn.functional.grid_sample( + image[id, :, :].unsqueeze(0).unsqueeze(0), + normalized_coords, + mode="bilinear", + padding_mode="zeros", + align_corners=False, + ) + + output[id, :, :] = torch.where((idx_x * idx_y) == 0.0, bgval, temp) + + return output + + def _argmax_ext(self, array: torch.Tensor, exponent: float | str) -> torch.Tensor: + assert array.ndim == 3 + + if exponent == "inf": + ret = self._argmax_2d(array) + else: + assert isinstance(exponent, float) or isinstance(exponent, int) + + col = ( + torch.arange( + 0, array.shape[-2], dtype=self.default_dtype, device=self.device + ) + .unsqueeze(-1) + .unsqueeze(0) + ) + row = ( + torch.arange( + 0, array.shape[-1], dtype=self.default_dtype, device=self.device + ) + .unsqueeze(0) + .unsqueeze(0) + ) + + arr2 = torch.pow(array, float(exponent)) + arrsum = arr2.sum(dim=-2).sum(dim=-1) + + ret = torch.zeros( + (array.shape[0], 2), dtype=self.default_dtype, device=self.device + ) + + arrprody = (arr2 * col).sum(dim=-1).sum(dim=-1) / arrsum + arrprodx = (arr2 * row).sum(dim=-1).sum(dim=-1) / arrsum + + ret[:, 0] = arrprody.squeeze(-1).squeeze(-1) + ret[:, 1] = arrprodx.squeeze(-1).squeeze(-1) + + idx = torch.where(arrsum == 0.0)[0] + ret[idx, :] = 0.0 + return ret + + def _interpolate( + self, array: torch.Tensor, rough: torch.Tensor, rad: int = 2 + ) -> torch.Tensor: + assert array.ndim == 3 + assert rough.ndim == 2 + + rough = torch.round(rough).type(torch.int64) + + surroundings = self._get_subarr(array, rough, rad) + + com = self._argmax_ext(surroundings, 1.0) + + offset = com - rad + ret = rough + offset + + ret += 0.5 + ret %= ( + torch.tensor(array.shape[-2:], dtype=self.default_dtype, device=self.device) + .type(dtype=torch.int64) + .unsqueeze(0) + ) + ret -= 0.5 + return ret + + def _get_success( + self, array: torch.Tensor, coord: torch.Tensor, radius: int = 2 + ) -> torch.Tensor: + assert array.ndim == 3 + assert coord.ndim == 2 + assert array.shape[0] == coord.shape[0] + assert coord.shape[1] == 2 + + coord = torch.round(coord).type(dtype=torch.int64) + subarr = self._get_subarr( + array, coord, 2 + ) # Not my fault. They want a 2 there. Not radius + + theval = subarr.sum(dim=-1).sum(dim=-1) + + theval2 = array[range(0, coord.shape[0]), coord[:, 0], coord[:, 1]] + + success = torch.sqrt(theval * theval2) + return success + + def _get_constraint_mask( + self, + shape: torch.Size, + log_base: torch.Tensor, + constraints_scale_0: torch.Tensor, + constraints_scale_1: torch.Tensor | None, + constraints_angle_0: torch.Tensor, + constraints_angle_1: torch.Tensor | None, + ) -> torch.Tensor: + assert constraints_scale_0 is not None + assert constraints_angle_0 is not None + assert constraints_scale_0.ndim == 1 + assert constraints_angle_0.ndim == 1 + + assert constraints_scale_0.shape[0] == constraints_angle_0.shape[0] + + mask: torch.Tensor = torch.ones( + (constraints_scale_0.shape[0], int(shape[-2]), int(shape[-1])), + device=self.device, + dtype=self.default_dtype, + ) + + scale: torch.Tensor = constraints_scale_0.clone() + if constraints_scale_1 is not None: + sigma: torch.Tensor | None = constraints_scale_1.clone() + else: + sigma = None + + scales = torch.fft.ifftshift( + self._get_lograd( + torch.tensor(shape[-2:], device=self.device, dtype=self.default_dtype), + log_base, + ) + ) + + scales *= log_base ** (-shape[-1] / 2.0) + scales = scales.unsqueeze(0) - (1.0 / scale).unsqueeze(-1).unsqueeze(-1) + + if sigma is not None: + assert sigma.shape[0] == constraints_scale_0.shape[0] + + for p_id in range(0, sigma.shape[0]): + if sigma[p_id] == 0: + ascales = torch.abs(scales[p_id, ...]) + scale_min = ascales.min() + binary_mask = torch.where(ascales > scale_min, 0.0, 1.0) + mask[p_id, ...] *= binary_mask + else: + mask[p_id, ...] *= torch.exp( + -(torch.pow(scales[p_id, ...], 2)) / torch.pow(sigma[p_id], 2) + ) + + angle: torch.Tensor = constraints_angle_0.clone() + if constraints_angle_1 is not None: + sigma = constraints_angle_1.clone() + else: + sigma = None + + angles = self._get_angles( + torch.tensor(shape[-2:], device=self.device, dtype=self.default_dtype) + ) + + angles = angles.unsqueeze(0) + torch.deg2rad(angle).unsqueeze(-1).unsqueeze(-1) + + angles = torch.rad2deg(angles) + + if sigma is not None: + assert sigma.shape[0] == constraints_scale_0.shape[0] + + for p_id in range(0, sigma.shape[0]): + if sigma[p_id] == 0: + aangles = torch.abs(angles[p_id, ...]) + angle_min = aangles.min() + binary_mask = torch.where(aangles > angle_min, 0.0, 1.0) + mask[p_id, ...] *= binary_mask + else: + mask *= torch.exp( + -(torch.pow(angles[p_id, ...], 2)) / torch.pow(sigma[p_id], 2) + ) + + mask = torch.fft.fftshift(mask, dim=(-2, -1)) + + return mask + + def argmax_angscale( + self, + array: torch.Tensor, + log_base: torch.Tensor, + constraints_scale_0: torch.Tensor, + constraints_scale_1: torch.Tensor | None, + constraints_angle_0: torch.Tensor, + constraints_angle_1: torch.Tensor | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + assert array.ndim == 3 + assert constraints_scale_0 is not None + assert constraints_angle_0 is not None + assert constraints_scale_0.ndim == 1 + assert constraints_angle_0.ndim == 1 + + mask = self._get_constraint_mask( + array.shape[-2:], + log_base, + constraints_scale_0, + constraints_scale_1, + constraints_angle_0, + constraints_angle_1, + ) + + array_orig = array.clone() + + array *= mask + ret = self._argmax_ext(array, self.exponent) + + ret_final = self._interpolate(array, ret) + + success = self._get_success(array_orig, ret_final, 0) + + return ret_final, success + + def argmax_translation( + self, array: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + assert array.ndim == 3 + + array_orig = array.clone() + + ashape = torch.tensor(array.shape[-2:], device=self.device).type( + dtype=torch.int64 + ) + + aporad = (ashape // 6).min() + mask2 = self.get_apofield(torch.Size(ashape), aporad).unsqueeze(0) + array *= mask2 + + tvec = self._argmax_ext(array, "inf") + + tvec = self._interpolate(array_orig, tvec) + + success = self._get_success(array_orig, tvec, 2) + + return tvec, success + + def transform_img( + self, + img: torch.Tensor, + scale: torch.Tensor | None = None, + angle: torch.Tensor | None = None, + tvec: torch.Tensor | None = None, + bgval: torch.Tensor | None = None, + ) -> torch.Tensor: + assert img.ndim == 3 + + if scale is None: + scale = torch.ones( + (img.shape[0],), dtype=self.default_dtype, device=self.device + ) + assert scale.ndim == 1 + assert scale.shape[0] == img.shape[0] + + if angle is None: + angle = torch.zeros( + (img.shape[0],), dtype=self.default_dtype, device=self.device + ) + assert angle.ndim == 1 + assert angle.shape[0] == img.shape[0] + + if tvec is None: + tvec = torch.zeros( + (img.shape[0], 2), dtype=self.default_dtype, device=self.device + ) + assert tvec.ndim == 2 + assert tvec.shape[0] == img.shape[0] + assert tvec.shape[1] == 2 + + if bgval is None: + bgval = self.get_borderval(img) + assert bgval.ndim == 1 + assert bgval.shape[0] == img.shape[0] + + # Otherwise we need to decompose it and put it back together + assert torch.is_complex(img) is False + + output = torch.zeros_like(img) + + for pos in range(0, img.shape[0]): + image_processed = img[pos, :, :].unsqueeze(0).clone() + + temp_shift = [ + int(round(tvec[pos, 1].item() * self.scale_factor)), + int(round(tvec[pos, 0].item() * self.scale_factor)), + ] + + image_processed = torch.nn.functional.interpolate( + image_processed.unsqueeze(0), + scale_factor=self.scale_factor, + mode="bilinear", + ).squeeze(0) + + image_processed = tv.transforms.functional.affine( + img=image_processed, + angle=-float(angle[pos]), + translate=temp_shift, + scale=float(scale[pos]), + shear=[0, 0], + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=float(bgval[pos]), + center=None, + ) + + image_processed = torch.nn.functional.interpolate( + image_processed.unsqueeze(0), + scale_factor=1.0 / self.scale_factor, + mode="bilinear", + ).squeeze(0) + + image_processed = tv.transforms.functional.center_crop( + image_processed, img.shape[-2:] + ) + + output[pos, ...] = image_processed.squeeze(0) + + return output + + def transform_img_dict( + self, + img: torch.Tensor, + scale: torch.Tensor | None = None, + angle: torch.Tensor | None = None, + tvec: torch.Tensor | None = None, + bgval: torch.Tensor | None = None, + invert=False, + ) -> torch.Tensor: + if invert: + if scale is not None: + scale = 1.0 / scale + if angle is not None: + angle *= -1 + if tvec is not None: + tvec *= -1 + + res = self.transform_img(img, scale, angle, tvec, bgval=bgval) + return res + + def _phase_correlation( + self, image_reference: torch.Tensor, images_todo: torch.Tensor, callback, *args + ) -> tuple[torch.Tensor, torch.Tensor]: + assert image_reference.ndim == 3 + assert image_reference.shape[0] == 1 + assert images_todo.ndim == 3 + + assert callback is not None + + image_reference_fft = torch.fft.fft2(image_reference, dim=(-2, -1)) + images_todo_fft = torch.fft.fft2(images_todo, dim=(-2, -1)) + + eps = torch.abs(images_todo_fft).max(dim=-1)[0].max(dim=-1)[0] * 1e-15 + + cps = abs( + torch.fft.ifft2( + (image_reference_fft * images_todo_fft.conj()) + / ( + torch.abs(image_reference_fft) * torch.abs(images_todo_fft) + + eps.unsqueeze(-1).unsqueeze(-1) + ), + dim=(-2, -1), + ) + ) + + scps = torch.fft.fftshift(cps, dim=(-2, -1)) + + ret, success = callback(scps, *args) + + ret[:, 0] -= image_reference_fft.shape[-2] // 2 + ret[:, 1] -= image_reference_fft.shape[-1] // 2 + + return ret, success + + def _translation( + self, im0: torch.Tensor, im1: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + assert im0.ndim == 2 + ret, succ = self._phase_correlation( + im0.unsqueeze(0), im1, self.argmax_translation + ) + + return ret, succ + + def _get_ang_scale( + self, + image_reference: torch.Tensor, + images_todo: torch.Tensor, + constraints_scale_0: torch.Tensor, + constraints_scale_1: torch.Tensor | None, + constraints_angle_0: torch.Tensor, + constraints_angle_1: torch.Tensor | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + assert image_reference.shape[-1] == images_todo.shape[-1] + assert image_reference.shape[-2] == images_todo.shape[-2] + assert constraints_scale_0.shape[0] == images_todo.shape[0] + assert constraints_angle_0.shape[0] == images_todo.shape[0] + + if constraints_scale_1 is not None: + assert constraints_scale_1.shape[0] == images_todo.shape[0] + + if constraints_angle_1 is not None: + assert constraints_angle_1.shape[0] == images_todo.shape[0] + + if self.image_reference_dft is None: + image_reference_apod = self._apodize(image_reference.unsqueeze(0)) + self.image_reference_dft = torch.fft.fftshift( + torch.fft.fft2(image_reference_apod, dim=(-2, -1)), dim=(-2, -1) + ) + self.filt = self._logpolar_filter(image_reference.shape).unsqueeze(0) + self.image_reference_dft *= self.filt + self.pcorr_shape = torch.tensor( + self._get_pcorr_shape(image_reference.shape[-2:]), + dtype=self.default_dtype, + device=self.device, + ) + self.log_base = self._get_log_base( + image_reference.shape, + self.pcorr_shape[1], + ) + self.image_reference_logp = self._logpolar( + torch.abs(self.image_reference_dft), self.pcorr_shape, self.log_base + ) + + images_todo_apod = self._apodize(images_todo) + images_todo_dft = torch.fft.fftshift( + torch.fft.fft2(images_todo_apod, dim=(-2, -1)), dim=(-2, -1) + ) + + images_todo_dft *= self.filt + + images_todo_lopg = self._logpolar( + torch.abs(images_todo_dft), self.pcorr_shape, self.log_base + ) + + temp, _ = self._phase_correlation( + self.image_reference_logp, + images_todo_lopg, + self.argmax_angscale, + self.log_base, + constraints_scale_0, + constraints_scale_1, + constraints_angle_0, + constraints_angle_1, + ) + + arg_ang = temp[:, 0].clone() + arg_rad = temp[:, 1].clone() + + angle = -torch.pi * arg_ang / float(self.pcorr_shape[0]) + angle = torch.rad2deg(angle) + + angle = self.wrap_angle(angle, 360) + + scale = torch.pow(self.log_base, arg_rad) + + angle = -angle + scale = 1.0 / scale + + assert torch.where(scale < 2)[0].shape[0] == scale.shape[0] + assert torch.where(scale > 0.5)[0].shape[0] == scale.shape[0] + + return scale, angle + + def translation( + self, im0: torch.Tensor, im1: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + angle = torch.zeros( + (im1.shape[0]), dtype=self.default_dtype, device=self.device + ) + assert im1.ndim == 3 + assert im0.shape[-2] == im1.shape[-2] + assert im0.shape[-1] == im1.shape[-1] + + tvec, succ = self._translation(im0, im1) + tvec2, succ2 = self._translation(im0, torch.rot90(im1, k=2, dims=[-2, -1])) + + assert tvec.shape[0] == tvec2.shape[0] + assert tvec.ndim == 2 + assert tvec2.ndim == 2 + assert tvec.shape[1] == 2 + assert tvec2.shape[1] == 2 + assert succ.shape[0] == succ2.shape[0] + assert succ.ndim == 1 + assert succ2.ndim == 1 + assert tvec.shape[0] == succ.shape[0] + assert angle.shape[0] == tvec.shape[0] + assert angle.ndim == 1 + + for pos in range(0, angle.shape[0]): + pick_rotated = False + if succ2[pos] > succ[pos]: + pick_rotated = True + + if pick_rotated: + tvec[pos, :] = tvec2[pos, :] + succ[pos] = succ2[pos] + angle[pos] += 180 + + return tvec, succ, angle + + def _similarity( + self, + image_reference: torch.Tensor, + images_todo: torch.Tensor, + bgval: torch.Tensor, + ): + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + assert image_reference.shape[-1] == images_todo.shape[-1] + assert image_reference.shape[-2] == images_todo.shape[-2] + + # We are going to iterate and precise scale and angle estimates + scale: torch.Tensor = torch.ones( + (images_todo.shape[0]), dtype=self.default_dtype, device=self.device + ) + angle: torch.Tensor = torch.zeros( + (images_todo.shape[0]), dtype=self.default_dtype, device=self.device + ) + + constraints_dynamic_angle_0: torch.Tensor = torch.zeros( + (images_todo.shape[0]), dtype=self.default_dtype, device=self.device + ) + constraints_dynamic_angle_1: torch.Tensor | None = None + constraints_dynamic_scale_0: torch.Tensor = torch.ones( + (images_todo.shape[0]), dtype=self.default_dtype, device=self.device + ) + constraints_dynamic_scale_1: torch.Tensor | None = None + + newscale, newangle = self._get_ang_scale( + image_reference, + images_todo, + constraints_dynamic_scale_0, + constraints_dynamic_scale_1, + constraints_dynamic_angle_0, + constraints_dynamic_angle_1, + ) + scale *= newscale + angle += newangle + + im2 = self.transform_img(images_todo, scale, angle, bgval=bgval) + + tvec, self.success, res_angle = self.translation(image_reference, im2) + + angle += res_angle + + angle = self.wrap_angle(angle, 360) + + return scale, angle, tvec + + def similarity( + self, + image_reference: torch.Tensor, + images_todo: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + assert image_reference.ndim == 2 + assert images_todo.ndim == 3 + + bgval: torch.Tensor = self.get_borderval(img=images_todo, radius=5) + + scale, angle, tvec = self._similarity( + image_reference, + images_todo, + bgval, + ) + + im2 = self.transform_img_dict( + img=images_todo, + scale=scale, + angle=angle, + tvec=tvec, + bgval=bgval, + ) + + return scale, angle, tvec, im2 diff --git a/functions/align_refref.py b/functions/align_refref.py new file mode 100644 index 0000000..3208cf3 --- /dev/null +++ b/functions/align_refref.py @@ -0,0 +1,60 @@ +import torch +import torchvision as tv # type: ignore +import logging +from functions.ImageAlignment import ImageAlignment +from functions.calculate_translation import calculate_translation +from functions.calculate_rotation import calculate_rotation + + +@torch.no_grad() +def align_refref( + mylogger: logging.Logger, + ref_image_acceptor: torch.Tensor, + ref_image_donor: torch.Tensor, + batch_size: int, + fill_value: float = 0, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + + image_alignment = ImageAlignment( + default_dtype=ref_image_acceptor.dtype, device=ref_image_acceptor.device + ) + + mylogger.info("Rotate ref image acceptor onto donor") + angle_refref = calculate_rotation( + image_alignment=image_alignment, + input=ref_image_acceptor.unsqueeze(0), + reference_image=ref_image_donor, + batch_size=batch_size, + ) + + ref_image_acceptor = tv.transforms.functional.affine( + img=ref_image_acceptor.unsqueeze(0), + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ) + + mylogger.info("Translate ref image acceptor onto donor") + tvec_refref = calculate_translation( + image_alignment=image_alignment, + input=ref_image_acceptor, + reference_image=ref_image_donor, + batch_size=batch_size, + ) + + tvec_refref = tvec_refref[0, :] + + ref_image_acceptor = tv.transforms.functional.affine( + img=ref_image_acceptor, + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + return angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor diff --git a/functions/bandpass.py b/functions/bandpass.py new file mode 100644 index 0000000..2659847 --- /dev/null +++ b/functions/bandpass.py @@ -0,0 +1,113 @@ +import torchaudio as ta # type: ignore +import torch + + +@torch.no_grad() +def filtfilt( + input: torch.Tensor, + butter_a: torch.Tensor, + butter_b: torch.Tensor, +) -> torch.Tensor: + assert butter_a.ndim == 1 + assert butter_b.ndim == 1 + assert butter_a.shape[0] == butter_b.shape[0] + + process_data: torch.Tensor = input.detach().clone() + + padding_length = 12 * int(butter_a.shape[0]) + left_padding = 2 * process_data[..., 0].unsqueeze(-1) - process_data[ + ..., 1 : padding_length + 1 + ].flip(-1) + right_padding = 2 * process_data[..., -1].unsqueeze(-1) - process_data[ + ..., -(padding_length + 1) : -1 + ].flip(-1) + process_data_padded = torch.cat((left_padding, process_data, right_padding), dim=-1) + + output = ta.functional.filtfilt( + process_data_padded.unsqueeze(0), butter_a, butter_b, clamp=False + ).squeeze(0) + + output = output[..., padding_length:-padding_length] + return output + + +@torch.no_grad() +def butter_bandpass( + device: torch.device, + low_frequency: float = 0.1, + high_frequency: float = 1.0, + fs: float = 30.0, +) -> tuple[torch.Tensor, torch.Tensor]: + import scipy # type: ignore + + butter_b_np, butter_a_np = scipy.signal.butter( + 4, [low_frequency, high_frequency], btype="bandpass", output="ba", fs=fs + ) + butter_a = torch.tensor(butter_a_np, device=device, dtype=torch.float32) + butter_b = torch.tensor(butter_b_np, device=device, dtype=torch.float32) + return butter_a, butter_b + + +@torch.no_grad() +def chunk_iterator(array: torch.Tensor, chunk_size: int): + for i in range(0, array.shape[0], chunk_size): + yield array[i : i + chunk_size] + + +@torch.no_grad() +def bandpass( + data: torch.Tensor, + low_frequency: float = 0.1, + high_frequency: float = 1.0, + fs=30.0, + filtfilt_chuck_size: int = 10, +) -> torch.Tensor: + + try: + return bandpass_internal( + data=data, + low_frequency=low_frequency, + high_frequency=high_frequency, + fs=fs, + filtfilt_chuck_size=filtfilt_chuck_size, + ) + + except torch.cuda.OutOfMemoryError: + + return bandpass_internal( + data=data.cpu(), + low_frequency=low_frequency, + high_frequency=high_frequency, + fs=fs, + filtfilt_chuck_size=filtfilt_chuck_size, + ).to(device=data.device) + + +@torch.no_grad() +def bandpass_internal( + data: torch.Tensor, + low_frequency: float = 0.1, + high_frequency: float = 1.0, + fs=30.0, + filtfilt_chuck_size: int = 10, +) -> torch.Tensor: + butter_a, butter_b = butter_bandpass( + device=data.device, + low_frequency=low_frequency, + high_frequency=high_frequency, + fs=fs, + ) + + index_full_dataset: torch.Tensor = torch.arange( + 0, data.shape[1], device=data.device, dtype=torch.int64 + ) + + for chunk in chunk_iterator(index_full_dataset, filtfilt_chuck_size): + temp_filtfilt = filtfilt( + data[:, chunk, :], + butter_a=butter_a, + butter_b=butter_b, + ) + data[:, chunk, :] = temp_filtfilt + + return data diff --git a/functions/binning.py b/functions/binning.py new file mode 100644 index 0000000..f873433 --- /dev/null +++ b/functions/binning.py @@ -0,0 +1,46 @@ +import torch + + +@torch.no_grad() +def binning( + data: torch.Tensor, + kernel_size: int = 4, + stride: int = 4, + divisor_override: int | None = 1, +) -> torch.Tensor: + + try: + return binning_internal( + data=data, + kernel_size=kernel_size, + stride=stride, + divisor_override=divisor_override, + ) + except torch.cuda.OutOfMemoryError: + return binning_internal( + data=data.cpu(), + kernel_size=kernel_size, + stride=stride, + divisor_override=divisor_override, + ).to(device=data.device) + + +@torch.no_grad() +def binning_internal( + data: torch.Tensor, + kernel_size: int = 4, + stride: int = 4, + divisor_override: int | None = 1, +) -> torch.Tensor: + + assert data.ndim == 4 + return ( + torch.nn.functional.avg_pool2d( + input=data.movedim(0, -1).movedim(0, -1), + kernel_size=kernel_size, + stride=stride, + divisor_override=divisor_override, + ) + .movedim(-1, 0) + .movedim(-1, 0) + ) diff --git a/functions/calculate_rotation.py b/functions/calculate_rotation.py new file mode 100644 index 0000000..6a53afd --- /dev/null +++ b/functions/calculate_rotation.py @@ -0,0 +1,40 @@ +import torch + +from functions.ImageAlignment import ImageAlignment + + +@torch.no_grad() +def calculate_rotation( + image_alignment: ImageAlignment, + input: torch.Tensor, + reference_image: torch.Tensor, + batch_size: int, +) -> torch.Tensor: + angle = torch.zeros((input.shape[0])) + + data_loader = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(input), + batch_size=batch_size, + shuffle=False, + ) + start_position: int = 0 + for input_batch in data_loader: + assert len(input_batch) == 1 + + end_position = start_position + input_batch[0].shape[0] + + angle_temp = image_alignment.dry_run_angle( + input=input_batch[0], + new_reference_image=reference_image, + ) + + assert angle_temp is not None + + angle[start_position:end_position] = angle_temp + + start_position += input_batch[0].shape[0] + + angle = torch.where(angle >= 180, 360.0 - angle, angle) + angle = torch.where(angle <= -180, 360.0 + angle, angle) + + return angle diff --git a/functions/calculate_translation.py b/functions/calculate_translation.py new file mode 100644 index 0000000..9eadf59 --- /dev/null +++ b/functions/calculate_translation.py @@ -0,0 +1,37 @@ +import torch + +from functions.ImageAlignment import ImageAlignment + + +@torch.no_grad() +def calculate_translation( + image_alignment: ImageAlignment, + input: torch.Tensor, + reference_image: torch.Tensor, + batch_size: int, +) -> torch.Tensor: + tvec = torch.zeros((input.shape[0], 2)) + + data_loader = torch.utils.data.DataLoader( + torch.utils.data.TensorDataset(input), + batch_size=batch_size, + shuffle=False, + ) + start_position: int = 0 + for input_batch in data_loader: + assert len(input_batch) == 1 + + end_position = start_position + input_batch[0].shape[0] + + tvec_temp = image_alignment.dry_run_translation( + input=input_batch[0], + new_reference_image=reference_image, + ) + + assert tvec_temp is not None + + tvec[start_position:end_position, :] = tvec_temp + + start_position += input_batch[0].shape[0] + + return tvec diff --git a/functions/create_logger.py b/functions/create_logger.py new file mode 100644 index 0000000..b7e746f --- /dev/null +++ b/functions/create_logger.py @@ -0,0 +1,37 @@ +import logging +import datetime +import os + + +def create_logger( + save_logging_messages: bool, display_logging_messages: bool, log_stage_name: str +): + now = datetime.datetime.now() + dt_string_filename = now.strftime("%Y_%m_%d_%H_%M_%S") + + logger = logging.getLogger("MyLittleLogger") + logger.setLevel(logging.DEBUG) + + if save_logging_messages: + time_format = "%b %d %Y %H:%M:%S" + logformat = "%(asctime)s %(message)s" + file_formatter = logging.Formatter(fmt=logformat, datefmt=time_format) + os.makedirs("logs_" + log_stage_name, exist_ok=True) + file_handler = logging.FileHandler( + os.path.join("logs_" + log_stage_name, f"log_{dt_string_filename}.txt") + ) + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + if display_logging_messages: + time_format = "%H:%M:%S" + logformat = "%(asctime)s %(message)s" + stream_formatter = logging.Formatter(fmt=logformat, datefmt=time_format) + + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(stream_formatter) + logger.addHandler(stream_handler) + + return logger diff --git a/functions/data_raw_loader.py b/functions/data_raw_loader.py new file mode 100644 index 0000000..67e55cf --- /dev/null +++ b/functions/data_raw_loader.py @@ -0,0 +1,339 @@ +import numpy as np +import torch +import os +import logging +import copy + +from functions.get_experiments import get_experiments +from functions.get_trials import get_trials +from functions.get_parts import get_parts +from functions.load_meta_data import load_meta_data + + +def data_raw_loader( + raw_data_path: str, + mylogger: logging.Logger, + experiment_id: int, + trial_id: int, + device: torch.device, + force_to_cpu_memory: bool, + config: dict, +) -> tuple[list[str], str, str, dict, dict, float, float, str, torch.Tensor]: + + meta_channels: list[str] = [] + meta_mouse_markings: str = "" + meta_recording_date: str = "" + meta_stimulation_times: dict = {} + meta_experiment_names: dict = {} + meta_trial_recording_duration: float = 0.0 + meta_frame_time: float = 0.0 + meta_mouse: str = "" + data: torch.Tensor = torch.zeros((1)) + + dtype_str = config["dtype"] + mylogger.info(f"Data precision will be {dtype_str}") + dtype: torch.dtype = getattr(torch, dtype_str) + dtype_np: np.dtype = getattr(np, dtype_str) + + if os.path.isdir(raw_data_path) is False: + mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") + assert os.path.isdir(raw_data_path) + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) + + if (torch.where(get_experiments(raw_data_path) == experiment_id)[0].shape[0]) != 1: + mylogger.info(f"ERROR: could not find experiment id {experiment_id}!!!!") + assert ( + torch.where(get_experiments(raw_data_path) == experiment_id)[0].shape[0] + ) == 1 + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) + + if ( + torch.where(get_trials(raw_data_path, experiment_id) == trial_id)[0].shape[0] + ) != 1: + mylogger.info(f"ERROR: could not find trial id {trial_id}!!!!") + assert ( + torch.where(get_trials(raw_data_path, experiment_id) == trial_id)[0].shape[ + 0 + ] + ) == 1 + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) + + available_parts: torch.Tensor = get_parts(raw_data_path, experiment_id, trial_id) + if available_parts.shape[0] < 1: + mylogger.info("ERROR: could not find any part files") + assert available_parts.shape[0] >= 1 + + experiment_name = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" + mylogger.info(f"Will work on: {experiment_name}") + + mylogger.info(f"We found {int(available_parts.shape[0])} parts.") + + first_run: bool = True + + mylogger.info("Compare meta data of all parts") + for id in range(0, available_parts.shape[0]): + part_id = available_parts[id] + + filename_meta: str = os.path.join( + raw_data_path, + f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}_meta.txt", + ) + + if os.path.isfile(filename_meta) is False: + mylogger.info(f"Could not load meta data... {filename_meta}") + assert os.path.isfile(filename_meta) + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) + + ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + ) = load_meta_data( + mylogger=mylogger, filename_meta=filename_meta, silent_mode=True + ) + + if first_run: + first_run = False + master_meta_channels: list[str] = copy.deepcopy(meta_channels) + master_meta_mouse_markings: str = meta_mouse_markings + master_meta_recording_date: str = meta_recording_date + master_meta_stimulation_times: dict = copy.deepcopy(meta_stimulation_times) + master_meta_experiment_names: dict = copy.deepcopy(meta_experiment_names) + master_meta_trial_recording_duration: float = meta_trial_recording_duration + master_meta_frame_time: float = meta_frame_time + master_meta_mouse: str = meta_mouse + + meta_channels_check = master_meta_channels == meta_channels + + # Check channel order + if meta_channels_check: + for channel_a, channel_b in zip(master_meta_channels, meta_channels): + if channel_a != channel_b: + meta_channels_check = False + + meta_mouse_markings_check = master_meta_mouse_markings == meta_mouse_markings + meta_recording_date_check = master_meta_recording_date == meta_recording_date + meta_stimulation_times_check = ( + master_meta_stimulation_times == meta_stimulation_times + ) + meta_experiment_names_check = ( + master_meta_experiment_names == meta_experiment_names + ) + meta_trial_recording_duration_check = ( + master_meta_trial_recording_duration == meta_trial_recording_duration + ) + meta_frame_time_check = master_meta_frame_time == meta_frame_time + meta_mouse_check = master_meta_mouse == meta_mouse + + if meta_channels_check is False: + mylogger.info(f"{filename_meta} failed: channels") + assert meta_channels_check + + if meta_mouse_markings_check is False: + mylogger.info(f"{filename_meta} failed: mouse_markings") + assert meta_mouse_markings_check + + if meta_recording_date_check is False: + mylogger.info(f"{filename_meta} failed: recording_date") + assert meta_recording_date_check + + if meta_stimulation_times_check is False: + mylogger.info(f"{filename_meta} failed: stimulation_times") + assert meta_stimulation_times_check + + if meta_experiment_names_check is False: + mylogger.info(f"{filename_meta} failed: experiment_names") + assert meta_experiment_names_check + + if meta_trial_recording_duration_check is False: + mylogger.info(f"{filename_meta} failed: trial_recording_duration") + assert meta_trial_recording_duration_check + + if meta_frame_time_check is False: + mylogger.info(f"{filename_meta} failed: frame_time_check") + assert meta_frame_time_check + + if meta_mouse_check is False: + mylogger.info(f"{filename_meta} failed: mouse") + assert meta_mouse_check + mylogger.info("-==- Done -==-") + + mylogger.info(f"Will use: {filename_meta} for meta data") + ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + ) = load_meta_data(mylogger=mylogger, filename_meta=filename_meta) + + ################# + # Meta data end # + ################# + + first_run = True + mylogger.info("Count the number of frames in the data of all parts") + frame_count: int = 0 + for id in range(0, available_parts.shape[0]): + part_id = available_parts[id] + + filename_data: str = os.path.join( + raw_data_path, + f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}.npy", + ) + + if os.path.isfile(filename_data) is False: + mylogger.info(f"Could not load data... {filename_data}") + assert os.path.isfile(filename_data) + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) + data_np: np.ndarray = np.load(filename_data, mmap_mode="r") + + if data_np.ndim != 4: + mylogger.info(f"ERROR: Data needs to have 4 dimensions {filename_data}") + assert data_np.ndim == 4 + + if first_run: + first_run = False + dim_0: int = int(data_np.shape[0]) + dim_1: int = int(data_np.shape[1]) + dim_3: int = int(data_np.shape[3]) + + frame_count += int(data_np.shape[2]) + + if int(data_np.shape[0]) != dim_0: + mylogger.info( + f"ERROR: Data dim 0 is broken {int(data_np.shape[0])} vs {dim_0} {filename_data}" + ) + assert int(data_np.shape[0]) == dim_0 + + if int(data_np.shape[1]) != dim_1: + mylogger.info( + f"ERROR: Data dim 1 is broken {int(data_np.shape[1])} vs {dim_1} {filename_data}" + ) + assert int(data_np.shape[1]) == dim_1 + + if int(data_np.shape[3]) != dim_3: + mylogger.info( + f"ERROR: Data dim 3 is broken {int(data_np.shape[3])} vs {dim_3} {filename_data}" + ) + assert int(data_np.shape[3]) == dim_3 + + mylogger.info( + f"{filename_data}: {int(data_np.shape[2])} frames -> {frame_count} frames total" + ) + + if force_to_cpu_memory: + mylogger.info("Using CPU memory for data") + data = torch.empty( + (dim_0, dim_1, frame_count, dim_3), dtype=dtype, device=torch.device("cpu") + ) + else: + mylogger.info("Using GPU memory for data") + data = torch.empty( + (dim_0, dim_1, frame_count, dim_3), dtype=dtype, device=device + ) + + start_position: int = 0 + end_position: int = 0 + for id in range(0, available_parts.shape[0]): + part_id = available_parts[id] + + filename_data = os.path.join( + raw_data_path, + f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part{part_id:03d}.npy", + ) + + mylogger.info(f"Will work on {filename_data}") + mylogger.info("Loading data file") + data_np = np.load(filename_data).astype(dtype_np) + + end_position = start_position + int(data_np.shape[2]) + + for i in range(0, len(config["required_order"])): + mylogger.info(f"Move raw data channel: {config['required_order'][i]}") + + idx = meta_channels.index(config["required_order"][i]) + data[..., start_position:end_position, i] = torch.tensor( + data_np[..., idx], dtype=dtype, device=data.device + ) + start_position = end_position + + if start_position != int(data.shape[2]): + mylogger.info("ERROR: data was not fulled fully!!!") + assert start_position == int(data.shape[2]) + + mylogger.info("-==- Done -==-") + + ################# + # Raw data end # + ################# + + return ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) diff --git a/functions/gauss_smear_individual.py b/functions/gauss_smear_individual.py new file mode 100644 index 0000000..73dba65 --- /dev/null +++ b/functions/gauss_smear_individual.py @@ -0,0 +1,168 @@ +import torch +import math + + +@torch.no_grad() +def gauss_smear_individual( + input: torch.Tensor, + spatial_width: float, + temporal_width: float, + overwrite_fft_gauss: None | torch.Tensor = None, + use_matlab_mask: bool = True, + epsilon: float = float(torch.finfo(torch.float64).eps), +) -> tuple[torch.Tensor, torch.Tensor]: + try: + return gauss_smear_individual_core( + input=input, + spatial_width=spatial_width, + temporal_width=temporal_width, + overwrite_fft_gauss=overwrite_fft_gauss, + use_matlab_mask=use_matlab_mask, + epsilon=epsilon, + ) + except torch.cuda.OutOfMemoryError: + + if overwrite_fft_gauss is None: + overwrite_fft_gauss_cpu: None | torch.Tensor = None + else: + overwrite_fft_gauss_cpu = overwrite_fft_gauss.cpu() + + input_cpu: torch.Tensor = input.cpu() + + output, overwrite_fft_gauss = gauss_smear_individual_core( + input=input_cpu, + spatial_width=spatial_width, + temporal_width=temporal_width, + overwrite_fft_gauss=overwrite_fft_gauss_cpu, + use_matlab_mask=use_matlab_mask, + epsilon=epsilon, + ) + return ( + output.to(device=input.device), + overwrite_fft_gauss.to(device=input.device), + ) + + +@torch.no_grad() +def gauss_smear_individual_core( + input: torch.Tensor, + spatial_width: float, + temporal_width: float, + overwrite_fft_gauss: None | torch.Tensor = None, + use_matlab_mask: bool = True, + epsilon: float = float(torch.finfo(torch.float64).eps), +) -> tuple[torch.Tensor, torch.Tensor]: + + dim_x: int = int(2 * math.ceil(2 * spatial_width) + 1) + dim_y: int = int(2 * math.ceil(2 * spatial_width) + 1) + dim_t: int = int(2 * math.ceil(2 * temporal_width) + 1) + dims_xyt: torch.Tensor = torch.tensor( + [dim_x, dim_y, dim_t], dtype=torch.int64, device=input.device + ) + + if input.ndim == 2: + input = input.unsqueeze(-1) + + input_padded = torch.nn.functional.pad( + input.unsqueeze(0), + pad=( + dim_t, + dim_t, + dim_y, + dim_y, + dim_x, + dim_x, + ), + mode="replicate", + ).squeeze(0) + + if overwrite_fft_gauss is None: + center_x: int = int(math.ceil(input_padded.shape[0] / 2)) + center_y: int = int(math.ceil(input_padded.shape[1] / 2)) + center_z: int = int(math.ceil(input_padded.shape[2] / 2)) + grid_x: torch.Tensor = ( + torch.arange(0, input_padded.shape[0], device=input.device) - center_x + 1 + ) + grid_y: torch.Tensor = ( + torch.arange(0, input_padded.shape[1], device=input.device) - center_y + 1 + ) + grid_z: torch.Tensor = ( + torch.arange(0, input_padded.shape[2], device=input.device) - center_z + 1 + ) + + grid_x = grid_x.unsqueeze(-1).unsqueeze(-1) ** 2 + grid_y = grid_y.unsqueeze(0).unsqueeze(-1) ** 2 + grid_z = grid_z.unsqueeze(0).unsqueeze(0) ** 2 + + gauss_kernel: torch.Tensor = ( + (grid_x / (spatial_width**2)) + + (grid_y / (spatial_width**2)) + + (grid_z / (temporal_width**2)) + ) + + if use_matlab_mask: + filter_radius: torch.Tensor = (dims_xyt - 1) // 2 + + border_lower: list[int] = [ + center_x - int(filter_radius[0]) - 1, + center_y - int(filter_radius[1]) - 1, + center_z - int(filter_radius[2]) - 1, + ] + + border_upper: list[int] = [ + center_x + int(filter_radius[0]), + center_y + int(filter_radius[1]), + center_z + int(filter_radius[2]), + ] + + matlab_mask: torch.Tensor = torch.zeros_like(gauss_kernel) + matlab_mask[ + border_lower[0] : border_upper[0], + border_lower[1] : border_upper[1], + border_lower[2] : border_upper[2], + ] = 1.0 + + gauss_kernel = torch.exp(-gauss_kernel / 2.0) + if use_matlab_mask: + gauss_kernel = gauss_kernel * matlab_mask + + gauss_kernel[gauss_kernel < (epsilon * gauss_kernel.max())] = 0 + + sum_gauss_kernel: float = float(gauss_kernel.sum()) + + if sum_gauss_kernel != 0.0: + gauss_kernel = gauss_kernel / sum_gauss_kernel + + # FFT Shift + gauss_kernel = torch.cat( + (gauss_kernel[center_x - 1 :, :, :], gauss_kernel[: center_x - 1, :, :]), + dim=0, + ) + gauss_kernel = torch.cat( + (gauss_kernel[:, center_y - 1 :, :], gauss_kernel[:, : center_y - 1, :]), + dim=1, + ) + gauss_kernel = torch.cat( + (gauss_kernel[:, :, center_z - 1 :], gauss_kernel[:, :, : center_z - 1]), + dim=2, + ) + overwrite_fft_gauss = torch.fft.fftn(gauss_kernel) + input_padded_gauss_filtered: torch.Tensor = torch.real( + torch.fft.ifftn(torch.fft.fftn(input_padded) * overwrite_fft_gauss) + ) + else: + input_padded_gauss_filtered = torch.real( + torch.fft.ifftn(torch.fft.fftn(input_padded) * overwrite_fft_gauss) + ) + + start = dims_xyt + stop = ( + torch.tensor(input_padded.shape, device=dims_xyt.device, dtype=dims_xyt.dtype) + - dims_xyt + ) + + output = input_padded_gauss_filtered[ + start[0] : stop[0], start[1] : stop[1], start[2] : stop[2] + ] + + return (output, overwrite_fft_gauss) diff --git a/functions/get_experiments.py b/functions/get_experiments.py new file mode 100644 index 0000000..d92b936 --- /dev/null +++ b/functions/get_experiments.py @@ -0,0 +1,19 @@ +import torch +import os +import glob + + +@torch.no_grad() +def get_experiments(path: str) -> torch.Tensor: + filename_np: str = os.path.join( + path, + "Exp*_Part001.npy", + ) + + list_str = glob.glob(filename_np) + list_int: list[int] = [] + for i in range(0, len(list_str)): + list_int.append(int(list_str[i].split("Exp")[-1].split("_Trial")[0])) + list_int = sorted(list_int) + + return torch.tensor(list_int).unique() diff --git a/functions/get_parts.py b/functions/get_parts.py new file mode 100644 index 0000000..d68e1ae --- /dev/null +++ b/functions/get_parts.py @@ -0,0 +1,18 @@ +import torch +import os +import glob + + +@torch.no_grad() +def get_parts(path: str, experiment_id: int, trial_id: int) -> torch.Tensor: + filename_np: str = os.path.join( + path, + f"Exp{experiment_id:03d}_Trial{trial_id:03d}_Part*.npy", + ) + + list_str = glob.glob(filename_np) + list_int: list[int] = [] + for i in range(0, len(list_str)): + list_int.append(int(list_str[i].split("_Part")[-1].split(".npy")[0])) + list_int = sorted(list_int) + return torch.tensor(list_int).unique() diff --git a/functions/get_torch_device.py b/functions/get_torch_device.py new file mode 100644 index 0000000..9eec5e9 --- /dev/null +++ b/functions/get_torch_device.py @@ -0,0 +1,17 @@ +import torch +import logging + + +def get_torch_device(mylogger: logging.Logger, force_to_cpu: bool) -> torch.device: + + if torch.cuda.is_available(): + device_name: str = "cuda:0" + else: + device_name = "cpu" + + if force_to_cpu: + device_name = "cpu" + + mylogger.info(f"Using device: {device_name}") + device: torch.device = torch.device(device_name) + return device diff --git a/functions/get_trials.py b/functions/get_trials.py new file mode 100644 index 0000000..abe33d2 --- /dev/null +++ b/functions/get_trials.py @@ -0,0 +1,19 @@ +import torch +import os +import glob + + +@torch.no_grad() +def get_trials(path: str, experiment_id: int) -> torch.Tensor: + filename_np: str = os.path.join( + path, + f"Exp{experiment_id:03d}_Trial*_Part001.npy", + ) + + list_str = glob.glob(filename_np) + list_int: list[int] = [] + for i in range(0, len(list_str)): + list_int.append(int(list_str[i].split("_Trial")[-1].split("_Part")[0])) + + list_int = sorted(list_int) + return torch.tensor(list_int).unique() diff --git a/functions/load_config.py b/functions/load_config.py new file mode 100644 index 0000000..c17fa40 --- /dev/null +++ b/functions/load_config.py @@ -0,0 +1,16 @@ +import json +import os +import logging + +from jsmin import jsmin # type:ignore + + +def load_config(mylogger: logging.Logger, filename: str = "config.json") -> dict: + mylogger.info("loading config file") + if os.path.isfile(filename) is False: + mylogger.info(f"{filename} is missing") + + with open(filename, "r") as file: + config = json.loads(jsmin(file.read())) + + return config diff --git a/functions/load_meta_data.py b/functions/load_meta_data.py new file mode 100644 index 0000000..622473c --- /dev/null +++ b/functions/load_meta_data.py @@ -0,0 +1,68 @@ +import logging +import json + + +def load_meta_data( + mylogger: logging.Logger, filename_meta: str, silent_mode=False +) -> tuple[list[str], str, str, dict, dict, float, float, str]: + + if silent_mode is False: + mylogger.info("Loading meta data") + with open(filename_meta, "r") as file_handle: + metadata: dict = json.load(file_handle) + + channels: list[str] = metadata["channelKey"] + + if silent_mode is False: + mylogger.info(f"meta data: channel order: {channels}") + + if "mouseMarkings" in metadata["sessionMetaData"]: + mouse_markings: str = metadata["sessionMetaData"]["mouseMarkings"] + if silent_mode is False: + mylogger.info(f"meta data: mouse markings: {mouse_markings}") + else: + mouse_markings = "" + if silent_mode is False: + mylogger.info("meta data: no mouse markings") + + recording_date: str = metadata["sessionMetaData"]["date"] + if silent_mode is False: + mylogger.info(f"meta data: recording data: {recording_date}") + + stimulation_times: dict = metadata["sessionMetaData"]["stimulationTimes"] + if silent_mode is False: + mylogger.info(f"meta data: stimulation times: {stimulation_times}") + + experiment_names: dict = metadata["sessionMetaData"]["experimentNames"] + if silent_mode is False: + mylogger.info(f"meta data: experiment names: {experiment_names}") + + trial_recording_duration: float = float( + metadata["sessionMetaData"]["trialRecordingDuration"] + ) + if silent_mode is False: + mylogger.info( + f"meta data: trial recording duration: {trial_recording_duration} sec" + ) + + frame_time: float = float(metadata["sessionMetaData"]["frameTime"]) + if silent_mode is False: + mylogger.info( + f"meta data: frame time: {frame_time} sec ; frame rate: {1.0/frame_time}Hz" + ) + + mouse: str = metadata["sessionMetaData"]["mouse"] + if silent_mode is False: + mylogger.info(f"meta data: mouse: {mouse}") + mylogger.info("-==- Done -==-") + + return ( + channels, + mouse_markings, + recording_date, + stimulation_times, + experiment_names, + trial_recording_duration, + frame_time, + mouse, + ) diff --git a/functions/perform_donor_volume_rotation.py b/functions/perform_donor_volume_rotation.py new file mode 100644 index 0000000..1d2f55b --- /dev/null +++ b/functions/perform_donor_volume_rotation.py @@ -0,0 +1,207 @@ +import torch +import torchvision as tv # type: ignore +import logging +from functions.calculate_rotation import calculate_rotation +from functions.ImageAlignment import ImageAlignment + + +@torch.no_grad() +def perform_donor_volume_rotation( + mylogger: logging.Logger, + acceptor: torch.Tensor, + donor: torch.Tensor, + oxygenation: torch.Tensor, + volume: torch.Tensor, + ref_image_donor: torch.Tensor, + ref_image_volume: torch.Tensor, + batch_size: int, + config: dict, + fill_value: float = 0, +) -> tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, +]: + try: + + return perform_donor_volume_rotation_internal( + mylogger=mylogger, + acceptor=acceptor, + donor=donor, + oxygenation=oxygenation, + volume=volume, + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=batch_size, + config=config, + fill_value=fill_value, + ) + + except torch.cuda.OutOfMemoryError: + + ( + acceptor_cpu, + donor_cpu, + oxygenation_cpu, + volume_cpu, + angle_donor_volume_cpu, + ) = perform_donor_volume_rotation_internal( + mylogger=mylogger, + acceptor=acceptor.cpu(), + donor=donor.cpu(), + oxygenation=oxygenation.cpu(), + volume=volume.cpu(), + ref_image_donor=ref_image_donor.cpu(), + ref_image_volume=ref_image_volume.cpu(), + batch_size=batch_size, + config=config, + fill_value=fill_value, + ) + + return ( + acceptor_cpu.to(device=acceptor.device), + donor_cpu.to(device=acceptor.device), + oxygenation_cpu.to(device=acceptor.device), + volume_cpu.to(device=acceptor.device), + angle_donor_volume_cpu.to(device=acceptor.device), + ) + + +@torch.no_grad() +def perform_donor_volume_rotation_internal( + mylogger: logging.Logger, + acceptor: torch.Tensor, + donor: torch.Tensor, + oxygenation: torch.Tensor, + volume: torch.Tensor, + ref_image_donor: torch.Tensor, + ref_image_volume: torch.Tensor, + batch_size: int, + config: dict, + fill_value: float = 0, +) -> tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, +]: + + image_alignment = ImageAlignment( + default_dtype=acceptor.dtype, device=acceptor.device + ) + + mylogger.info("Calculate rotation between donor data and donor ref image") + + angle_donor = calculate_rotation( + input=donor, + reference_image=ref_image_donor, + image_alignment=image_alignment, + batch_size=batch_size, + ) + + mylogger.info("Calculate rotation between volume data and volume ref image") + angle_volume = calculate_rotation( + input=volume, + reference_image=ref_image_volume, + image_alignment=image_alignment, + batch_size=batch_size, + ) + + mylogger.info("Average over both rotations") + + donor_threshold: torch.Tensor = torch.sort(torch.abs(angle_donor))[0] + donor_threshold = donor_threshold[ + int( + donor_threshold.shape[0] + * float(config["rotation_stabilization_threshold_border"]) + ) + ] * float(config["rotation_stabilization_threshold_factor"]) + + volume_threshold: torch.Tensor = torch.sort(torch.abs(angle_volume))[0] + volume_threshold = volume_threshold[ + int( + volume_threshold.shape[0] + * float(config["rotation_stabilization_threshold_border"]) + ) + ] * float(config["rotation_stabilization_threshold_factor"]) + + donor_idx = torch.where(torch.abs(angle_donor) > donor_threshold)[0] + volume_idx = torch.where(torch.abs(angle_volume) > volume_threshold)[0] + mylogger.info( + f"Border: {config['rotation_stabilization_threshold_border']}, " + f"factor {config['rotation_stabilization_threshold_factor']} " + ) + mylogger.info( + f"Donor threshold: {donor_threshold:.3e}, volume threshold: {volume_threshold:.3e}" + ) + mylogger.info( + f"Found broken rotation values: " + f"donor {int(donor_idx.shape[0])}, " + f"volume {int(volume_idx.shape[0])}" + ) + angle_donor[donor_idx] = angle_volume[donor_idx] + angle_volume[volume_idx] = angle_donor[volume_idx] + + donor_idx = torch.where(torch.abs(angle_donor) > donor_threshold)[0] + volume_idx = torch.where(torch.abs(angle_volume) > volume_threshold)[0] + mylogger.info( + f"After fill in these broken rotation values remain: " + f"donor {int(donor_idx.shape[0])}, " + f"volume {int(volume_idx.shape[0])}" + ) + angle_donor[donor_idx] = 0.0 + angle_volume[volume_idx] = 0.0 + angle_donor_volume = (angle_donor + angle_volume) / 2.0 + + mylogger.info("Rotate acceptor data based on the average rotation") + for frame_id in range(0, angle_donor_volume.shape[0]): + acceptor[frame_id, ...] = tv.transforms.functional.affine( + img=acceptor[frame_id, ...].unsqueeze(0), + angle=-float(angle_donor_volume[frame_id]), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Rotate donor data based on the average rotation") + for frame_id in range(0, angle_donor_volume.shape[0]): + donor[frame_id, ...] = tv.transforms.functional.affine( + img=donor[frame_id, ...].unsqueeze(0), + angle=-float(angle_donor_volume[frame_id]), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Rotate oxygenation data based on the average rotation") + for frame_id in range(0, angle_donor_volume.shape[0]): + oxygenation[frame_id, ...] = tv.transforms.functional.affine( + img=oxygenation[frame_id, ...].unsqueeze(0), + angle=-float(angle_donor_volume[frame_id]), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Rotate volume data based on the average rotation") + for frame_id in range(0, angle_donor_volume.shape[0]): + volume[frame_id, ...] = tv.transforms.functional.affine( + img=volume[frame_id, ...].unsqueeze(0), + angle=-float(angle_donor_volume[frame_id]), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + return (acceptor, donor, oxygenation, volume, angle_donor_volume) diff --git a/functions/perform_donor_volume_translation.py b/functions/perform_donor_volume_translation.py new file mode 100644 index 0000000..72e94fa --- /dev/null +++ b/functions/perform_donor_volume_translation.py @@ -0,0 +1,210 @@ +import torch +import torchvision as tv # type: ignore +import logging + +from functions.calculate_translation import calculate_translation +from functions.ImageAlignment import ImageAlignment + + +@torch.no_grad() +def perform_donor_volume_translation( + mylogger: logging.Logger, + acceptor: torch.Tensor, + donor: torch.Tensor, + oxygenation: torch.Tensor, + volume: torch.Tensor, + ref_image_donor: torch.Tensor, + ref_image_volume: torch.Tensor, + batch_size: int, + config: dict, + fill_value: float = 0, +) -> tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, +]: + try: + + return perform_donor_volume_translation_internal( + mylogger=mylogger, + acceptor=acceptor, + donor=donor, + oxygenation=oxygenation, + volume=volume, + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=batch_size, + config=config, + fill_value=fill_value, + ) + + except torch.cuda.OutOfMemoryError: + + ( + acceptor_cpu, + donor_cpu, + oxygenation_cpu, + volume_cpu, + tvec_donor_volume_cpu, + ) = perform_donor_volume_translation_internal( + mylogger=mylogger, + acceptor=acceptor.cpu(), + donor=donor.cpu(), + oxygenation=oxygenation.cpu(), + volume=volume.cpu(), + ref_image_donor=ref_image_donor.cpu(), + ref_image_volume=ref_image_volume.cpu(), + batch_size=batch_size, + config=config, + fill_value=fill_value, + ) + + return ( + acceptor_cpu.to(device=acceptor.device), + donor_cpu.to(device=acceptor.device), + oxygenation_cpu.to(device=acceptor.device), + volume_cpu.to(device=acceptor.device), + tvec_donor_volume_cpu.to(device=acceptor.device), + ) + + +@torch.no_grad() +def perform_donor_volume_translation_internal( + mylogger: logging.Logger, + acceptor: torch.Tensor, + donor: torch.Tensor, + oxygenation: torch.Tensor, + volume: torch.Tensor, + ref_image_donor: torch.Tensor, + ref_image_volume: torch.Tensor, + batch_size: int, + config: dict, + fill_value: float = 0, +) -> tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, +]: + + image_alignment = ImageAlignment( + default_dtype=acceptor.dtype, device=acceptor.device + ) + + mylogger.info("Calculate translation between donor data and donor ref image") + tvec_donor = calculate_translation( + input=donor, + reference_image=ref_image_donor, + image_alignment=image_alignment, + batch_size=batch_size, + ) + + mylogger.info("Calculate translation between volume data and volume ref image") + tvec_volume = calculate_translation( + input=volume, + reference_image=ref_image_volume, + image_alignment=image_alignment, + batch_size=batch_size, + ) + + mylogger.info("Average over both translations") + + for i in range(0, 2): + mylogger.info(f"Processing dimension {i}") + donor_threshold: torch.Tensor = torch.sort(torch.abs(tvec_donor[:, i]))[0] + donor_threshold = donor_threshold[ + int( + donor_threshold.shape[0] + * float(config["rotation_stabilization_threshold_border"]) + ) + ] * float(config["rotation_stabilization_threshold_factor"]) + + volume_threshold: torch.Tensor = torch.sort(torch.abs(tvec_volume[:, i]))[0] + volume_threshold = volume_threshold[ + int( + volume_threshold.shape[0] + * float(config["rotation_stabilization_threshold_border"]) + ) + ] * float(config["rotation_stabilization_threshold_factor"]) + + donor_idx = torch.where(torch.abs(tvec_donor[:, i]) > donor_threshold)[0] + volume_idx = torch.where(torch.abs(tvec_volume[:, i]) > volume_threshold)[0] + mylogger.info( + f"Border: {config['rotation_stabilization_threshold_border']}, " + f"factor {config['rotation_stabilization_threshold_factor']} " + ) + mylogger.info( + f"Donor threshold: {donor_threshold:.3e}, volume threshold: {volume_threshold:.3e}" + ) + mylogger.info( + f"Found broken rotation values: " + f"donor {int(donor_idx.shape[0])}, " + f"volume {int(volume_idx.shape[0])}" + ) + tvec_donor[donor_idx, i] = tvec_volume[donor_idx, i] + tvec_volume[volume_idx, i] = tvec_donor[volume_idx, i] + + donor_idx = torch.where(torch.abs(tvec_donor[:, i]) > donor_threshold)[0] + volume_idx = torch.where(torch.abs(tvec_volume[:, i]) > volume_threshold)[0] + mylogger.info( + f"After fill in these broken rotation values remain: " + f"donor {int(donor_idx.shape[0])}, " + f"volume {int(volume_idx.shape[0])}" + ) + tvec_donor[donor_idx, i] = 0.0 + tvec_volume[volume_idx, i] = 0.0 + + tvec_donor_volume = (tvec_donor + tvec_volume) / 2.0 + + mylogger.info("Translate acceptor data based on the average translation vector") + for frame_id in range(0, tvec_donor_volume.shape[0]): + acceptor[frame_id, ...] = tv.transforms.functional.affine( + img=acceptor[frame_id, ...].unsqueeze(0), + angle=0, + translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Translate donor data based on the average translation vector") + for frame_id in range(0, tvec_donor_volume.shape[0]): + donor[frame_id, ...] = tv.transforms.functional.affine( + img=donor[frame_id, ...].unsqueeze(0), + angle=0, + translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Translate oxygenation data based on the average translation vector") + for frame_id in range(0, tvec_donor_volume.shape[0]): + oxygenation[frame_id, ...] = tv.transforms.functional.affine( + img=oxygenation[frame_id, ...].unsqueeze(0), + angle=0, + translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + mylogger.info("Translate volume data based on the average translation vector") + for frame_id in range(0, tvec_donor_volume.shape[0]): + volume[frame_id, ...] = tv.transforms.functional.affine( + img=volume[frame_id, ...].unsqueeze(0), + angle=0, + translate=[tvec_donor_volume[frame_id, 1], tvec_donor_volume[frame_id, 0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=fill_value, + ).squeeze(0) + + return (acceptor, donor, oxygenation, volume, tvec_donor_volume) diff --git a/functions/regression.py b/functions/regression.py new file mode 100644 index 0000000..d4efac0 --- /dev/null +++ b/functions/regression.py @@ -0,0 +1,117 @@ +import torch +import logging +from functions.regression_internal import regression_internal + + +@torch.no_grad() +def regression( + mylogger: logging.Logger, + target_camera_id: int, + regressor_camera_ids: list[int], + mask: torch.Tensor, + data: torch.Tensor, + data_filtered: torch.Tensor, + first_none_ramp_frame: int, +) -> tuple[torch.Tensor, torch.Tensor]: + + assert len(regressor_camera_ids) > 0 + + mylogger.info("Prepare the target signal - 1.0 (from data_filtered)") + target_signals_train: torch.Tensor = ( + data_filtered[target_camera_id, ..., first_none_ramp_frame:].clone() - 1.0 + ) + target_signals_train[target_signals_train < -1] = 0.0 + + # Check if everything is happy + assert target_signals_train.ndim == 3 + assert target_signals_train.ndim == data[target_camera_id, ...].ndim + assert target_signals_train.shape[0] == data[target_camera_id, ...].shape[0] + assert target_signals_train.shape[1] == data[target_camera_id, ...].shape[1] + assert (target_signals_train.shape[2] + first_none_ramp_frame) == data[ + target_camera_id, ... + ].shape[2] + + mylogger.info("Prepare the regressor signals (linear plus from data_filtered)") + + regressor_signals_train: torch.Tensor = torch.zeros( + ( + data_filtered.shape[1], + data_filtered.shape[2], + data_filtered.shape[3], + len(regressor_camera_ids) + 1, + ), + device=data_filtered.device, + dtype=data_filtered.dtype, + ) + + mylogger.info("Copy the regressor signals - 1.0") + for matrix_id, id in enumerate(regressor_camera_ids): + regressor_signals_train[..., matrix_id] = data_filtered[id, ...] - 1.0 + + regressor_signals_train[regressor_signals_train < -1] = 0.0 + + mylogger.info("Create the linear regressor") + trend = torch.arange( + 0, regressor_signals_train.shape[-2], device=data_filtered.device + ) / float(regressor_signals_train.shape[-2] - 1) + trend -= trend.mean() + trend = trend.unsqueeze(0).unsqueeze(0) + trend = trend.tile( + (regressor_signals_train.shape[0], regressor_signals_train.shape[1], 1) + ) + regressor_signals_train[..., -1] = trend + + regressor_signals_train = regressor_signals_train[:, :, first_none_ramp_frame:, :] + + mylogger.info("Calculating the regression coefficients") + coefficients, intercept = regression_internal( + input_regressor=regressor_signals_train, input_target=target_signals_train + ) + del regressor_signals_train + del target_signals_train + + mylogger.info("Prepare the target signal - 1.0 (from data)") + target_signals_perform: torch.Tensor = data[target_camera_id, ...].clone() - 1.0 + + mylogger.info("Prepare the regressor signals (linear plus from data)") + regressor_signals_perform: torch.Tensor = torch.zeros( + ( + data.shape[1], + data.shape[2], + data.shape[3], + len(regressor_camera_ids) + 1, + ), + device=data.device, + dtype=data.dtype, + ) + + mylogger.info("Copy the regressor signals - 1.0 ") + for matrix_id, id in enumerate(regressor_camera_ids): + regressor_signals_perform[..., matrix_id] = data[id] - 1.0 + + mylogger.info("Create the linear regressor") + trend = torch.arange( + 0, regressor_signals_perform.shape[-2], device=data[0].device + ) / float(regressor_signals_perform.shape[-2] - 1) + trend -= trend.mean() + trend = trend.unsqueeze(0).unsqueeze(0) + trend = trend.tile( + (regressor_signals_perform.shape[0], regressor_signals_perform.shape[1], 1) + ) + regressor_signals_perform[..., -1] = trend + + mylogger.info("Remove regressors") + target_signals_perform -= ( + regressor_signals_perform * coefficients.unsqueeze(-2) + ).sum(dim=-1) + + mylogger.info("Remove offset") + target_signals_perform -= intercept.unsqueeze(-1) + + mylogger.info("Remove masked pixels") + target_signals_perform[mask, :] = 0.0 + + mylogger.info("Add an offset of 1.0") + target_signals_perform += 1.0 + + return target_signals_perform, coefficients diff --git a/functions/regression_internal.py b/functions/regression_internal.py new file mode 100644 index 0000000..dd94d3c --- /dev/null +++ b/functions/regression_internal.py @@ -0,0 +1,27 @@ +import torch + + +def regression_internal( + input_regressor: torch.Tensor, input_target: torch.Tensor +) -> tuple[torch.Tensor, torch.Tensor]: + + regressor_offset = input_regressor.mean(keepdim=True, dim=-2) + target_offset = input_target.mean(keepdim=True, dim=-1) + + regressor = input_regressor - regressor_offset + target = input_target - target_offset + + try: + coefficients, _, _, _ = torch.linalg.lstsq(regressor, target, rcond=None) + except torch.cuda.OutOfMemoryError: + coefficients_cpu, _, _, _ = torch.linalg.lstsq( + regressor.cpu(), target.cpu(), rcond=None + ) + coefficients = coefficients_cpu.to(regressor.device, copy=True) + del coefficients_cpu + + intercept = target_offset.squeeze(-1) - ( + coefficients * regressor_offset.squeeze(-2) + ).sum(dim=-1) + + return coefficients, intercept diff --git a/geci/config_M_Sert_Cre_41.json b/geci/config_M_Sert_Cre_41.json new file mode 100644 index 0000000..48ede27 --- /dev/null +++ b/geci/config_M_Sert_Cre_41.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-07-17", + "mouse_identifier": "M_Sert_Cre_41", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_41", + "ref_image_path": "ref_images/M_Sert_Cre_41", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/config_M_Sert_Cre_42.json b/geci/config_M_Sert_Cre_42.json new file mode 100644 index 0000000..daf6c3e --- /dev/null +++ b/geci/config_M_Sert_Cre_42.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-07-18", + "mouse_identifier": "M_Sert_Cre_42", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_42", + "ref_image_path": "ref_images/M_Sert_Cre_42", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/config_M_Sert_Cre_45.json b/geci/config_M_Sert_Cre_45.json new file mode 100644 index 0000000..875faf5 --- /dev/null +++ b/geci/config_M_Sert_Cre_45.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-07-18", + "mouse_identifier": "M_Sert_Cre_45", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_45", + "ref_image_path": "ref_images/M_Sert_Cre_45", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/config_M_Sert_Cre_46.json b/geci/config_M_Sert_Cre_46.json new file mode 100644 index 0000000..085a80d --- /dev/null +++ b/geci/config_M_Sert_Cre_46.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-03-16", + "mouse_identifier": "M_Sert_Cre_46", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_46", + "ref_image_path": "ref_images/M_Sert_Cre_46", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/config_M_Sert_Cre_49.json b/geci/config_M_Sert_Cre_49.json new file mode 100644 index 0000000..11e6e8c --- /dev/null +++ b/geci/config_M_Sert_Cre_49.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-03-15", + "mouse_identifier": "M_Sert_Cre_49", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_49", + "ref_image_path": "ref_images/M_Sert_Cre_49", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/config_example_GECI.json b/geci/config_example_GECI.json new file mode 100644 index 0000000..48ede27 --- /dev/null +++ b/geci/config_example_GECI.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/hendrik", + "recoding_data": "2023-07-17", + "mouse_identifier": "M_Sert_Cre_41", + "raw_path": "raw", + "export_path": "output/M_Sert_Cre_41", + "ref_image_path": "ref_images/M_Sert_Cre_41", + "heartbeat_remove": true, + "gevi": false, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + // EMPTY FOR GECI "target_camera_acceptor": "acceptor", + "target_camera_acceptor": "", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + // REMOVED FOR GECI "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/geci/geci_loader.py b/geci/geci_loader.py new file mode 100644 index 0000000..a8f4da1 --- /dev/null +++ b/geci/geci_loader.py @@ -0,0 +1,168 @@ +import numpy as np +import os +import json +from jsmin import jsmin # type:ignore +import argh +from functions.get_trials import get_trials +from functions.get_experiments import get_experiments +import scipy # type: ignore + + +def func_pow(x, a, b, c): + return -a * x**b + c + + +def func_exp(x, a, b, c): + return a * np.exp(-x / b) + c + + +def loader( + filename: str = "config_M_Sert_Cre_49.json", + fpath: str|None = None, + skip_timesteps: int = 100, + # If there is no special ROI... Get one! This is just a backup + roi_control_path_default: str = "roi_controlM_Sert_Cre_49.npy", + roi_sdarken_path_default: str = "roi_sdarkenM_Sert_Cre_49.npy", + remove_fit: bool = True, + fit_power: bool = False, # True => -ax^b ; False => exp(-b) +) -> None: + + if fpath is None: + fpath = os.getcwd() + + if os.path.isfile(filename) is False: + print(f"{filename} is missing") + exit() + + with open(filename, "r") as file: + config = json.loads(jsmin(file.read())) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if remove_fit: + roi_control_path: str = f"roi_control{config['mouse_identifier']}.npy" + roi_sdarken_path: str = f"roi_sdarken{config['mouse_identifier']}.npy" + + if os.path.isfile(roi_control_path) is False: + print(f"Using replacement RIO: {roi_control_path_default}") + roi_control_path = roi_control_path_default + + if os.path.isfile(roi_sdarken_path) is False: + print(f"Using replacement RIO: {roi_sdarken_path_default}") + roi_sdarken_path = roi_sdarken_path_default + + roi_control: np.ndarray = np.load(roi_control_path) + roi_darken: np.ndarray = np.load(roi_sdarken_path) + + experiments = get_experiments(raw_data_path).numpy() + n_exp = experiments.shape[0] + + first_run: bool = True + + for i_exp in range(0, n_exp): + trials = get_trials(raw_data_path, experiments[i_exp]).numpy() + n_tri = trials.shape[0] + + for i_tri in range(0, n_tri): + + experiment_name: str = ( + f"Exp{experiments[i_exp]:03d}_Trial{trials[i_tri]:03d}" + ) + tmp_fname = os.path.join( + fpath, + config["export_path"], + experiment_name + "_acceptor_donor.npz", + ) + print(f'Processing file "{tmp_fname}"...') + tmp = np.load(tmp_fname) + + tmp_data_sequence = tmp["data_donor"] + tmp_data_sequence = tmp_data_sequence[:, :, skip_timesteps:] + tmp_light_signal = tmp["data_acceptor"] + tmp_light_signal = tmp_light_signal[:, :, skip_timesteps:] + + if first_run: + mask = tmp["mask"] + new_shape = [n_exp, *tmp_data_sequence.shape] + data_sequence = np.zeros(new_shape) + light_signal = np.zeros(new_shape) + first_run = False + + if remove_fit: + roi_control *= mask + assert roi_control.sum() > 0, "ROI control empty" + roi_darken *= mask + assert roi_darken.sum() > 0, "ROI sDarken empty" + + if remove_fit: + combined_matrix = (roi_darken + roi_control) > 0 + idx = np.where(combined_matrix) + for idx_pos in range(0, idx[0].shape[0]): + + temp = tmp_data_sequence[idx[0][idx_pos], idx[1][idx_pos], :] + temp -= temp.mean() + + data_time = np.arange(0, temp.shape[0], dtype=np.float32) + skip_timesteps + data_time /= 100.0 + + data_min = temp.min() + data_max = temp.max() + data_delta = data_max - data_min + a_min = data_min - data_delta + b_min = 0.01 + a_max = data_max + data_delta + if fit_power: + b_max = 10.0 + else: + b_max = 100.0 + c_min = data_min - data_delta + c_max = data_max + data_delta + + try: + if fit_power: + popt, _ = scipy.optimize.curve_fit( + f=func_pow, + xdata=data_time, + ydata=np.nan_to_num(temp), + bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), + ) + pattern: np.ndarray | None = func_pow(data_time, *popt) + else: + popt, _ = scipy.optimize.curve_fit( + f=func_exp, + xdata=data_time, + ydata=np.nan_to_num(temp), + bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), + ) + pattern = func_exp(data_time, *popt) + + assert pattern is not None + pattern -= pattern.mean() + + scale = (temp * pattern).sum() / (pattern**2).sum() + pattern *= scale + + except ValueError: + print(f"Fit failed: Position ({idx[0][idx_pos]}, {idx[1][idx_pos]}") + pattern = None + + if pattern is not None: + temp -= pattern + tmp_data_sequence[idx[0][idx_pos], idx[1][idx_pos], :] = temp + + data_sequence[i_exp] += tmp_data_sequence + light_signal[i_exp] += tmp_light_signal + data_sequence[i_exp] /= n_tri + light_signal[i_exp] /= n_tri + np.save(os.path.join(fpath, config["export_path"], "dsq_" + config["mouse_identifier"]), data_sequence) + np.save(os.path.join(fpath, config["export_path"], "lsq_" + config["mouse_identifier"]), light_signal) + np.save(os.path.join(fpath, config["export_path"], "msq_" + config["mouse_identifier"]), mask) + + +if __name__ == "__main__": + argh.dispatch_command(loader) diff --git a/geci/geci_plot.py b/geci/geci_plot.py new file mode 100644 index 0000000..8d32ab4 --- /dev/null +++ b/geci/geci_plot.py @@ -0,0 +1,181 @@ +# %% + +import numpy as np +import matplotlib.pyplot as plt +import argh +import scipy # type: ignore +import json +import os +from jsmin import jsmin # type:ignore + + +def func_pow(x, a, b, c): + return -a * x**b + c + + +def func_exp(x, a, b, c): + return a * np.exp(-x / b) + c + + +# mouse: int = 0, 1, 2, 3, 4 +def plot( + filename: str = "config_M_Sert_Cre_49.json", + fpath: str | None = None, + experiment: int = 4, + skip_timesteps: int = 100, + remove_fit: bool = False, + fit_power: bool = False, # True => -ax^b ; False => exp(-b) +) -> None: + + if fpath is None: + fpath = os.getcwd() + + if os.path.isfile(filename) is False: + print(f"{filename} is missing") + exit() + + with open(filename, "r") as file: + config = json.loads(jsmin(file.read())) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if os.path.isdir(raw_data_path) is False: + print(f"ERROR: could not find raw directory {raw_data_path}!!!!") + exit() + + with open(f"meta_{config['mouse_identifier']}_exp{experiment:03d}.json", "r") as file: + metadata = json.loads(jsmin(file.read())) + + experiment_names = metadata['sessionMetaData']['experimentNames'][str(experiment)] + + roi_control_path: str = f"roi_control{config['mouse_identifier']}.npy" + roi_sdarken_path: str = f"roi_sdarken{config['mouse_identifier']}.npy" + + assert os.path.isfile(roi_control_path) + assert os.path.isfile(roi_sdarken_path) + + print("Load data...") + data = np.load(os.path.join(fpath, config["export_path"], "dsq_" + config["mouse_identifier"] + ".npy"), mmap_mode="r") + + print("Load light signal...") + light = np.load(os.path.join(fpath, config["export_path"], "lsq_" + config["mouse_identifier"] + ".npy"), mmap_mode="r") + + print("Load mask...") + mask = np.load(os.path.join(fpath, config["export_path"], "msq_" + config["mouse_identifier"] + ".npy")) + + roi_control = np.load(roi_control_path) + roi_control *= mask + assert roi_control.sum() > 0, "ROI control empty" + + roi_darken = np.load(roi_sdarken_path) + roi_darken *= mask + assert roi_darken.sum() > 0, "ROI sDarken empty" + + plt.figure(1) + a_show = data[experiment - 1, :, :, 1000].copy() + a_show[(roi_darken + roi_control) < 0.5] = np.nan + plt.imshow(a_show) + plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment}") + plt.show(block=False) + + plt.figure(2) + a_dontshow = data[experiment - 1, :, :, 1000].copy() + a_dontshow[(roi_darken + roi_control) > 0.5] = np.nan + plt.imshow(a_dontshow) + plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment}") + plt.show(block=False) + + plt.figure(3) + if remove_fit: + light_exp = light[experiment - 1, :, :, skip_timesteps:].copy() + else: + light_exp = light[experiment - 1, :, :, :].copy() + light_exp[(roi_darken + roi_control) < 0.5, :] = 0.0 + light_signal = light_exp.mean(axis=(0, 1)) + light_signal -= light_signal.min() + light_signal /= light_signal.max() + + if remove_fit: + a_exp = data[experiment - 1, :, :, skip_timesteps:].copy() + else: + a_exp = data[experiment - 1, :, :, :].copy() + + if remove_fit: + combined_matrix = (roi_darken + roi_control) > 0 + idx = np.where(combined_matrix) + for idx_pos in range(0, idx[0].shape[0]): + temp = a_exp[idx[0][idx_pos], idx[1][idx_pos], :] + temp -= temp.mean() + + data_time = np.arange(0, temp.shape[0], dtype=np.float32) + skip_timesteps + data_time /= 100.0 + + data_min = temp.min() + data_max = temp.max() + data_delta = data_max - data_min + a_min = data_min - data_delta + b_min = 0.01 + a_max = data_max + data_delta + if fit_power: + b_max = 10.0 + else: + b_max = 100.0 + c_min = data_min - data_delta + c_max = data_max + data_delta + + try: + if fit_power: + popt, _ = scipy.optimize.curve_fit( + f=func_pow, + xdata=data_time, + ydata=np.nan_to_num(temp), + bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), + ) + pattern: np.ndarray | None = func_pow(data_time, *popt) + else: + popt, _ = scipy.optimize.curve_fit( + f=func_exp, + xdata=data_time, + ydata=np.nan_to_num(temp), + bounds=([a_min, b_min, c_min], [a_max, b_max, c_max]), + ) + pattern = func_exp(data_time, *popt) + + assert pattern is not None + pattern -= pattern.mean() + + scale = (temp * pattern).sum() / (pattern**2).sum() + pattern *= scale + + except ValueError: + print(f"Fit failed: Position ({idx[0][idx_pos]}, {idx[1][idx_pos]}") + pattern = None + + if pattern is not None: + temp -= pattern + a_exp[idx[0][idx_pos], idx[1][idx_pos], :] = temp + + darken = a_exp[roi_darken > 0.5, :].sum(axis=0) / (roi_darken > 0.5).sum() + lighten = a_exp[roi_control > 0.5, :].sum(axis=0) / (roi_control > 0.5).sum() + + light_signal *= darken.max() - darken.min() + light_signal += darken.min() + + time_axis = np.arange(0, lighten.shape[-1], dtype=np.float32) + skip_timesteps + time_axis /= 100.0 + + plt.plot(time_axis, light_signal, c="k", label="light") + plt.plot(time_axis, darken, label="sDarken") + plt.plot(time_axis, lighten, label="control") + plt.title(f"{config['mouse_identifier']} -- Experiment: {experiment} ({experiment_names})") + plt.legend() + plt.show() + + +if __name__ == "__main__": + argh.dispatch_command(plot) diff --git a/geci/stage_6_convert_roi.py b/geci/stage_6_convert_roi.py new file mode 100644 index 0000000..7bedc29 --- /dev/null +++ b/geci/stage_6_convert_roi.py @@ -0,0 +1,53 @@ +import json +import os +import argh +from jsmin import jsmin # type:ignore +import numpy as np +import h5py + + +def converter(filename: str = "config_M_Sert_Cre_49.json") -> None: + + if os.path.isfile(filename) is False: + print(f"{filename} is missing") + exit() + + with open(filename, "r") as file: + config = json.loads(jsmin(file.read())) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if os.path.isdir(raw_data_path) is False: + print(f"ERROR: could not find raw directory {raw_data_path}!!!!") + exit() + + roi_path: str = os.path.join( + config["basic_path"], config["recoding_data"], config["mouse_identifier"] + ) + roi_control_mat: str = os.path.join(roi_path, "ROI_control.mat") + roi_sdarken_mat: str = os.path.join(roi_path, "ROI_sDarken.mat") + + if os.path.isfile(roi_control_mat): + hf = h5py.File(roi_control_mat, "r") + roi_control = np.array(hf["roi"]).T + filename_out: str = f"roi_control{config['mouse_identifier']}.npy" + np.save(filename_out, roi_control) + else: + print("ROI Control not found") + + if os.path.isfile(roi_sdarken_mat): + hf = h5py.File(roi_sdarken_mat, "r") + roi_darken = np.array(hf["roi"]).T + filename_out: str = f"roi_sdarken{config['mouse_identifier']}.npy" + np.save(filename_out, roi_darken) + else: + print("ROI sDarken not found") + + +if __name__ == "__main__": + argh.dispatch_command(converter) diff --git a/gevi/config_M0134M_2024-11-06_SessionA.json b/gevi/config_M0134M_2024-11-06_SessionA.json new file mode 100644 index 0000000..b6f4da8 --- /dev/null +++ b/gevi/config_M0134M_2024-11-06_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-06", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-06_SessionA", + "ref_image_path": "ref_images/M0134M_2024-11-06_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-06_SessionB.json b/gevi/config_M0134M_2024-11-06_SessionB.json new file mode 100644 index 0000000..b620fbd --- /dev/null +++ b/gevi/config_M0134M_2024-11-06_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-06", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-06_SessionB", + "ref_image_path": "ref_images/M0134M_2024-11-06_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-07_SessionA.json b/gevi/config_M0134M_2024-11-07_SessionA.json new file mode 100644 index 0000000..01fb9b3 --- /dev/null +++ b/gevi/config_M0134M_2024-11-07_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-07", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-07_SessionA", + "ref_image_path": "ref_images/M0134M_2024-11-07_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-07_SessionB.json b/gevi/config_M0134M_2024-11-07_SessionB.json new file mode 100644 index 0000000..d92b34b --- /dev/null +++ b/gevi/config_M0134M_2024-11-07_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-07", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-07_SessionB", + "ref_image_path": "ref_images/M0134M_2024-11-07_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-13_SessionA.json b/gevi/config_M0134M_2024-11-13_SessionA.json new file mode 100644 index 0000000..eab7d1e --- /dev/null +++ b/gevi/config_M0134M_2024-11-13_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-13", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-13_SessionA", + "ref_image_path": "ref_images/M0134M_2024-11-13_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-13_SessionB.json b/gevi/config_M0134M_2024-11-13_SessionB.json new file mode 100644 index 0000000..0ae7eab --- /dev/null +++ b/gevi/config_M0134M_2024-11-13_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-13", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-13_SessionB", + "ref_image_path": "ref_images/M0134M_2024-11-13_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-15_SessionA.json b/gevi/config_M0134M_2024-11-15_SessionA.json new file mode 100644 index 0000000..c2aabf1 --- /dev/null +++ b/gevi/config_M0134M_2024-11-15_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-15", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-15_SessionA", + "ref_image_path": "ref_images/M0134M_2024-11-15_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-15_SessionB.json b/gevi/config_M0134M_2024-11-15_SessionB.json new file mode 100644 index 0000000..3827bc9 --- /dev/null +++ b/gevi/config_M0134M_2024-11-15_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-15", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-15_SessionB", + "ref_image_path": "ref_images/M0134M_2024-11-15_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-18_SessionA.json b/gevi/config_M0134M_2024-11-18_SessionA.json new file mode 100644 index 0000000..e9e0d00 --- /dev/null +++ b/gevi/config_M0134M_2024-11-18_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-18", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-18_SessionA", + "ref_image_path": "ref_images/M0134M_2024-11-18_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-11-18_SessionB.json b/gevi/config_M0134M_2024-11-18_SessionB.json new file mode 100644 index 0000000..143817b --- /dev/null +++ b/gevi/config_M0134M_2024-11-18_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-11-18", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-11-18_SessionB", + "ref_image_path": "ref_images/M0134M_2024-11-18_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-12-04_SessionA.json b/gevi/config_M0134M_2024-12-04_SessionA.json new file mode 100644 index 0000000..d77e531 --- /dev/null +++ b/gevi/config_M0134M_2024-12-04_SessionA.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-12-04", + "mouse_identifier": "M0134M_SessionA", + "raw_path": "raw", + "export_path": "output/M0134M_2024-12-04_SessionA", + "ref_image_path": "ref_images/M0134M_2024-12-04_SessionA", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M0134M_2024-12-04_SessionB.json b/gevi/config_M0134M_2024-12-04_SessionB.json new file mode 100644 index 0000000..36ad83f --- /dev/null +++ b/gevi/config_M0134M_2024-12-04_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI/", + "recoding_data": "2024-12-04", + "mouse_identifier": "M0134M_SessionB", + "raw_path": "raw", + "export_path": "output/M0134M_2024-12-04_SessionB", + "ref_image_path": "ref_images/M0134M_2024-12-04_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_M3905F_SessionB.json b/gevi/config_M3905F_SessionB.json new file mode 100644 index 0000000..f2a41bd --- /dev/null +++ b/gevi/config_M3905F_SessionB.json @@ -0,0 +1,67 @@ +{ + "basic_path": "/data_1/fatma/GEVI_GECI_ES", + "recoding_data": "session_B", + "mouse_identifier": "M3905F", + "raw_path": "raw", + "export_path": "output/M3905F_SessionB", + "ref_image_path": "ref_images/M3905F_SessionB", + "raw_path": "raw", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/config_example_GEVI.json b/gevi/config_example_GEVI.json new file mode 100644 index 0000000..e7d53ad --- /dev/null +++ b/gevi/config_example_GEVI.json @@ -0,0 +1,66 @@ +{ + "basic_path": "/data_1/fatma/GEVI_GECI_ES", + "recoding_data": "session_B", + "mouse_identifier": "M3905F", + "raw_path": "raw", + "export_path": "output/M3905F_SessionB", + "ref_image_path": "ref_images/M3905F_SessionB", + "heartbeat_remove": true, + "gevi": true, // true => gevi, false => geci + // Ratio Sequence + "classical_ratio_mode": true, // true: a/d false: 1+a-d + // Regression + "target_camera_acceptor": "acceptor", + "regressor_cameras_acceptor": [ + "oxygenation", + "volume" + ], + "target_camera_donor": "donor", + "regressor_cameras_donor": [ + "oxygenation", + "volume" + ], + // binning + "binning_enable": true, + "binning_at_the_end": false, + "binning_kernel_size": 4, + "binning_stride": 4, + "binning_divisor_override": 1, + // alignment + "alignment_batch_size": 200, + "rotation_stabilization_threshold_factor": 3.0, // >= 1.0 + "rotation_stabilization_threshold_border": 0.9, // <= 1.0 + // Heart beat detection + "lower_freqency_bandpass": 5.0, // Hz + "upper_freqency_bandpass": 14.0, // Hz + "heartbeat_filtfilt_chuck_size": 10, + // Gauss smear + "gauss_smear_spatial_width": 8, + "gauss_smear_temporal_width": 0.1, + "gauss_smear_use_matlab_mask": false, + // LED Ramp on + "skip_frames_in_the_beginning": 100, // Frames + // PyTorch + "dtype": "float32", + "force_to_cpu": false, + // Save + "save_as_python": true, // produces .npz files (compressed) + "save_as_matlab": false, // produces .hd5 file (compressed) + // Save extra information + "save_alignment": false, + "save_heartbeat": false, + "save_factors": false, + "save_regression_coefficients": false, + "save_aligned_as_python": false, + "save_aligned_as_matlab": false, + "save_oxyvol_as_python": false, + "save_oxyvol_as_matlab": false, + "save_gevi_with_donor_acceptor": true, + // Not important parameter + "required_order": [ + "acceptor", + "donor", + "oxygenation", + "volume" + ] +} diff --git a/gevi/example_load_gevi.py b/gevi/example_load_gevi.py new file mode 100644 index 0000000..ce8c2e5 --- /dev/null +++ b/gevi/example_load_gevi.py @@ -0,0 +1,56 @@ +# %% +import numpy as np +import matplotlib.pyplot as plt +import os + +output_path = 'output' + +recording_name = 'M0134M_2024-12-04_SessionA' +n_trials_per_experiment = [30, 0, 30, 30, 30, 30, 30, 30, 30,] +name_experiment = ['none', 'visual', '2 uA', '5 uA', '7 uA', '10 uA', '15 uA', '30 uA', '60 uA'] + +# recording_name = 'M0134M_2024-11-06_SessionB' +# n_trials_per_experiment = [15, 15,] +# name_experiment = ['none', 'visual',] + +i_experiment = 8 + +r_avg = None +ad_avg = None +for i_trial in range(n_trials_per_experiment[i_experiment]): + + folder = output_path + os.sep + recording_name + file = f"Exp{i_experiment + 1:03}_Trial{i_trial + 1:03}_ratio_sequence.npz" + fullpath = folder + os.sep + file + + print(f'Loading file "{fullpath}"...') + data = np.load(fullpath) + + print(f"FIle contents: {data.files}") + ratio_sequence = data["ratio_sequence"] + if 'data_acceptor' in data.files: + data_acceptor = data["data_acceptor"] + data_donor = data["data_donor"] + + mask = data["mask"][:, :, np.newaxis] + + if i_trial == 0: + r_avg = ratio_sequence + if 'data_acceptor' in data.files: + ad_avg = (data_acceptor / data_donor) * mask + 1 - mask + else: + r_avg += ratio_sequence + if 'data_acceptor' in data.files: + ad_avg += (data_acceptor / data_donor) * mask + 1 - mask + +if r_avg is not None: + r_avg /= n_trials_per_experiment[i_experiment] +if ad_avg is not None: + ad_avg /= n_trials_per_experiment[i_experiment] + +# %% +for t in range(200, 300, 5): + plt.imshow(r_avg[:, :, t], vmin=0.99, vmax=1.01, cmap='seismic') + plt.colorbar() + plt.show() + diff --git a/other/stage_4b_inspect.py b/other/stage_4b_inspect.py new file mode 100644 index 0000000..f8884f5 --- /dev/null +++ b/other/stage_4b_inspect.py @@ -0,0 +1,532 @@ +# %% + +import numpy as np +import torch +import torchvision as tv # type: ignore + +import os +import logging + +from functions.create_logger import create_logger +from functions.get_torch_device import get_torch_device +from functions.load_config import load_config +from functions.get_experiments import get_experiments +from functions.get_trials import get_trials +from functions.binning import binning +from functions.align_refref import align_refref +from functions.perform_donor_volume_rotation import perform_donor_volume_rotation +from functions.perform_donor_volume_translation import perform_donor_volume_translation +from functions.data_raw_loader import data_raw_loader + +import argh + + +@torch.no_grad() +def process_trial( + config: dict, + mylogger: logging.Logger, + experiment_id: int, + trial_id: int, + device: torch.device, +): + + mylogger.info("") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("") + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + cuda_total_memory: int = torch.cuda.get_device_properties( + device.index + ).total_memory + else: + cuda_total_memory = 0 + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + force_to_cpu_memory: bool = True + else: + force_to_cpu_memory = False + + meta_channels: list[str] + meta_mouse_markings: str + meta_recording_date: str + meta_stimulation_times: dict + meta_experiment_names: dict + meta_trial_recording_duration: float + meta_frame_time: float + meta_mouse: str + data: torch.Tensor + + ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) = data_raw_loader( + raw_data_path=raw_data_path, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=device, + force_to_cpu_memory=force_to_cpu_memory, + config=config, + ) + experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" + + dtype_str = config["dtype"] + dtype_np: np.dtype = getattr(np, dtype_str) + + dtype: torch.dtype = data.dtype + + if device != torch.device("cpu"): + free_mem = cuda_total_memory - max( + [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("Finding limit values in the RAW data and mark them for masking") + limit: float = (2**16) - 1 + for i in range(0, data.shape[3]): + zero_pixel_mask: torch.Tensor = torch.any(data[..., i] >= limit, dim=-1) + data[zero_pixel_mask, :, i] = -100.0 + mylogger.info( + f"{meta_channels[i]}: " + f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " + f"with limit values " + ) + mylogger.info("-==- Done -==-") + + mylogger.info("Reference images and mask") + + ref_image_path: str = config["ref_image_path"] + + ref_image_path_acceptor: str = os.path.join(ref_image_path, "acceptor.npy") + if os.path.isfile(ref_image_path_acceptor) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_acceptor}") + assert os.path.isfile(ref_image_path_acceptor) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_acceptor}") + ref_image_acceptor: torch.Tensor = torch.tensor( + np.load(ref_image_path_acceptor).astype(dtype_np), + dtype=dtype, + device=data.device, + ) + + ref_image_path_donor: str = os.path.join(ref_image_path, "donor.npy") + if os.path.isfile(ref_image_path_donor) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_donor}") + assert os.path.isfile(ref_image_path_donor) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_donor}") + ref_image_donor: torch.Tensor = torch.tensor( + np.load(ref_image_path_donor).astype(dtype_np), dtype=dtype, device=data.device + ) + + ref_image_path_oxygenation: str = os.path.join(ref_image_path, "oxygenation.npy") + if os.path.isfile(ref_image_path_oxygenation) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_oxygenation}") + assert os.path.isfile(ref_image_path_oxygenation) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_oxygenation}") + ref_image_oxygenation: torch.Tensor = torch.tensor( + np.load(ref_image_path_oxygenation).astype(dtype_np), + dtype=dtype, + device=data.device, + ) + + ref_image_path_volume: str = os.path.join(ref_image_path, "volume.npy") + if os.path.isfile(ref_image_path_volume) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_volume}") + assert os.path.isfile(ref_image_path_volume) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_volume}") + ref_image_volume: torch.Tensor = torch.tensor( + np.load(ref_image_path_volume).astype(dtype_np), dtype=dtype, device=data.device + ) + + refined_mask_file: str = os.path.join(ref_image_path, "mask_not_rotated.npy") + if os.path.isfile(refined_mask_file) is False: + mylogger.info(f"Could not load mask file: {refined_mask_file}") + assert os.path.isfile(refined_mask_file) + return + + mylogger.info(f"Loading mask file data: {refined_mask_file}") + mask: torch.Tensor = torch.tensor( + np.load(refined_mask_file).astype(dtype_np), dtype=dtype, device=data.device + ) + mylogger.info("-==- Done -==-") + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + mylogger.info("Binning of data") + mylogger.info( + ( + f"kernel_size={int(config['binning_kernel_size'])}, " + f"stride={int(config['binning_stride'])}, " + f"divisor_override={int(config['binning_divisor_override'])}" + ) + ) + + data = binning( + data, + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ).to(device=data.device) + ref_image_acceptor = ( + binning( + ref_image_acceptor.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_donor = ( + binning( + ref_image_donor.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_oxygenation = ( + binning( + ref_image_oxygenation.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_volume = ( + binning( + ref_image_volume.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + mask = ( + binning( + mask.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("Preparing alignment") + mylogger.info("Re-order Raw data") + data = data.moveaxis(-2, 0).moveaxis(-1, 0) + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("Alignment of the ref images and the mask") + mylogger.info("Ref image of donor stays fixed.") + mylogger.info("Ref image of volume and the mask doesn't need to be touched") + mylogger.info("Calculate translation and rotation between the reference images") + angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor = align_refref( + mylogger=mylogger, + ref_image_acceptor=ref_image_acceptor, + ref_image_donor=ref_image_donor, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + ) + mylogger.info(f"Rotation: {round(float(angle_refref[0]), 2)} degree") + mylogger.info( + f"Translation: {round(float(tvec_refref[0]), 1)} x {round(float(tvec_refref[1]), 1)} pixel" + ) + + if config["save_alignment"]: + temp_path: str = os.path.join( + config["export_path"], experiment_name + "_angle_refref.npy" + ) + mylogger.info(f"Save angle to {temp_path}") + np.save(temp_path, angle_refref.cpu()) + + temp_path = os.path.join( + config["export_path"], experiment_name + "_tvec_refref.npy" + ) + mylogger.info(f"Save translation vector to {temp_path}") + np.save(temp_path, tvec_refref.cpu()) + + mylogger.info("Moving & rotating the oxygenation ref image") + ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore + img=ref_image_oxygenation.unsqueeze(0), + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore + img=ref_image_oxygenation, + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ).squeeze(0) + mylogger.info("-==- Done -==-") + + mylogger.info("Rotate and translate the acceptor and oxygenation data accordingly") + acceptor_index: int = config["required_order"].index("acceptor") + donor_index: int = config["required_order"].index("donor") + oxygenation_index: int = config["required_order"].index("oxygenation") + volume_index: int = config["required_order"].index("volume") + + mylogger.info("Rotate acceptor") + data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[acceptor_index, ...], # type: ignore + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Translate acceptor") + data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[acceptor_index, ...], + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Rotate oxygenation") + data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[oxygenation_index, ...], + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Translate oxygenation") + data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[oxygenation_index, ...], + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + mylogger.info("-==- Done -==-") + + mylogger.info("Perform rotation between donor and volume and its ref images") + mylogger.info("for all frames and then rotate all the data accordingly") + + ( + data[acceptor_index, ...], + data[donor_index, ...], + data[oxygenation_index, ...], + data[volume_index, ...], + angle_donor_volume, + ) = perform_donor_volume_rotation( + mylogger=mylogger, + acceptor=data[acceptor_index, ...], + donor=data[donor_index, ...], + oxygenation=data[oxygenation_index, ...], + volume=data[volume_index, ...], + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + config=config, + ) + + mylogger.info( + f"angles: " + f"min {round(float(angle_donor_volume.min()), 2)} " + f"max {round(float(angle_donor_volume.max()), 2)} " + f"mean {round(float(angle_donor_volume.mean()), 2)} " + ) + + if config["save_alignment"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_angle_donor_volume.npy" + ) + mylogger.info(f"Save angles to {temp_path}") + np.save(temp_path, angle_donor_volume.cpu()) + mylogger.info("-==- Done -==-") + + mylogger.info("Perform translation between donor and volume and its ref images") + mylogger.info("for all frames and then translate all the data accordingly") + + ( + data_acceptor, + data_donor, + data_oxygenation, + data_volume, + _, + ) = perform_donor_volume_translation( + mylogger=mylogger, + acceptor=data[acceptor_index, 0:1, ...], + donor=data[donor_index, 0:1, ...], + oxygenation=data[oxygenation_index, 0:1, ...], + volume=data[volume_index, 0:1, ...], + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + config=config, + ) + + # + + temp_path = os.path.join( + config["export_path"], experiment_name + "_inspect_images.npz" + ) + mylogger.info(f"Save images for inspection to {temp_path}") + np.savez( + temp_path, + acceptor=data_acceptor.cpu(), + donor=data_donor.cpu(), + oxygenation=data_oxygenation.cpu(), + volume=data_volume.cpu(), + ) + + mylogger.info("") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("") + + return + + +def main( + *, + config_filename: str = "config.json", + experiment_id_overwrite: int = -1, + trial_id_overwrite: int = -1, +) -> None: + mylogger = create_logger( + save_logging_messages=True, + display_logging_messages=True, + log_stage_name="stage_4b", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + if (config["save_as_python"] is False) and (config["save_as_matlab"] is False): + mylogger.info("No output will be created. ") + mylogger.info("Change save_as_python and/or save_as_matlab in the config file") + mylogger.info("ERROR: STOP!!!") + exit() + + if (len(config["target_camera_donor"]) == 0) and ( + len(config["target_camera_acceptor"]) == 0 + ): + mylogger.info( + "Configure at least target_camera_donor or target_camera_acceptor correctly." + ) + mylogger.info("ERROR: STOP!!!") + exit() + + device = get_torch_device(mylogger, config["force_to_cpu"]) + + mylogger.info( + f"Create directory {config['export_path']} in the case it does not exist" + ) + os.makedirs(config["export_path"], exist_ok=True) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if os.path.isdir(raw_data_path) is False: + mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") + exit() + + if experiment_id_overwrite == -1: + experiments = get_experiments(raw_data_path) + else: + assert experiment_id_overwrite >= 0 + experiments = torch.tensor([experiment_id_overwrite]) + + for experiment_counter in range(0, experiments.shape[0]): + experiment_id = int(experiments[experiment_counter]) + + if trial_id_overwrite == -1: + trials = get_trials(raw_data_path, experiment_id) + else: + assert trial_id_overwrite >= 0 + trials = torch.tensor([trial_id_overwrite]) + + for trial_counter in range(0, trials.shape[0]): + trial_id = int(trials[trial_counter]) + + mylogger.info("") + mylogger.info( + f"======= EXPERIMENT ID: {experiment_id} ==== TRIAL ID: {trial_id} =======" + ) + mylogger.info("") + + try: + process_trial( + config=config, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=device, + ) + except torch.cuda.OutOfMemoryError: + mylogger.info("WARNING: RUNNING IN FAILBACK MODE!!!!") + mylogger.info("Not enough GPU memory. Retry on CPU") + process_trial( + config=config, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=torch.device("cpu"), + ) + + +if __name__ == "__main__": + argh.dispatch_command(main) diff --git a/other/stage_4c_viewer.py b/other/stage_4c_viewer.py new file mode 100644 index 0000000..9c70616 --- /dev/null +++ b/other/stage_4c_viewer.py @@ -0,0 +1,56 @@ +import os +import numpy as np + +import matplotlib.pyplot as plt # type:ignore + +from functions.create_logger import create_logger +from functions.load_config import load_config + +import argh + + +def main( + *, config_filename: str = "config.json", experiment_id: int = 1, trial_id: int = 1 +) -> None: + + experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" + mylogger = create_logger( + save_logging_messages=False, + display_logging_messages=False, + log_stage_name="stage_4c", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + temp_path = os.path.join( + config["export_path"], experiment_name + "_inspect_images.npz" + ) + data = np.load(temp_path) + + acceptor = data["acceptor"][0, ...] + donor = data["donor"][0, ...] + oxygenation = data["oxygenation"][0, ...] + volume = data["volume"][0, ...] + + plt.figure(1) + plt.imshow(acceptor, cmap="hot") + plt.title(f"Acceptor Experiment: {experiment_id:03d} Trial:{trial_id:03d}") + plt.show(block=False) + plt.figure(2) + plt.imshow(donor, cmap="hot") + plt.title(f"Donor Experiment: {experiment_id:03d} Trial:{trial_id:03d}") + plt.show(block=False) + plt.figure(3) + plt.imshow(oxygenation, cmap="hot") + plt.title(f"Oxygenation Experiment: {experiment_id:03d} Trial:{trial_id:03d}") + plt.show(block=False) + plt.figure(4) + plt.imshow(volume, cmap="hot") + plt.title(f"Volume Experiment: {experiment_id:03d} Trial:{trial_id:03d}") + plt.show(block=True) + + return + + +if __name__ == "__main__": + argh.dispatch_command(main) diff --git a/stage_1_get_ref_image.py b/stage_1_get_ref_image.py new file mode 100644 index 0000000..0e5b6da --- /dev/null +++ b/stage_1_get_ref_image.py @@ -0,0 +1,129 @@ +import os +import torch +import numpy as np +import argh + +from functions.get_experiments import get_experiments +from functions.get_trials import get_trials +from functions.bandpass import bandpass +from functions.create_logger import create_logger +from functions.get_torch_device import get_torch_device +from functions.load_config import load_config +from functions.data_raw_loader import data_raw_loader + + +def main(*, config_filename: str = "config.json") -> None: + mylogger = create_logger( + save_logging_messages=True, + display_logging_messages=True, + log_stage_name="stage_1", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + device: torch.device = torch.device("cpu") + else: + device = get_torch_device(mylogger, config["force_to_cpu"]) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + mylogger.info(f"Using data path: {raw_data_path}") + + first_experiment_id: int = int(get_experiments(raw_data_path).min()) + first_trial_id: int = int(get_trials(raw_data_path, first_experiment_id).min()) + + meta_channels: list[str] + meta_mouse_markings: str + meta_recording_date: str + meta_stimulation_times: dict + meta_experiment_names: dict + meta_trial_recording_duration: float + meta_frame_time: float + meta_mouse: str + data: torch.Tensor + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + force_to_cpu_memory: bool = True + else: + force_to_cpu_memory = False + + mylogger.info("Loading data") + + ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) = data_raw_loader( + raw_data_path=raw_data_path, + mylogger=mylogger, + experiment_id=first_experiment_id, + trial_id=first_trial_id, + device=device, + force_to_cpu_memory=force_to_cpu_memory, + config=config, + ) + mylogger.info("-==- Done -==-") + + output_path = config["ref_image_path"] + mylogger.info(f"Create directory {output_path} in the case it does not exist") + os.makedirs(output_path, exist_ok=True) + + mylogger.info("Reference images") + for i in range(0, len(meta_channels)): + temp_path: str = os.path.join(output_path, meta_channels[i] + ".npy") + mylogger.info(f"Extract and save: {temp_path}") + frame_id: int = data.shape[-2] // 2 + mylogger.info(f"Will use frame id: {frame_id}") + ref_image: np.ndarray = ( + data[:, :, frame_id, meta_channels.index(meta_channels[i])] + .clone() + .cpu() + .numpy() + ) + np.save(temp_path, ref_image) + mylogger.info("-==- Done -==-") + + sample_frequency: float = 1.0 / meta_frame_time + mylogger.info( + ( + f"Heartbeat power {config['lower_freqency_bandpass']} Hz" + f" - {config['upper_freqency_bandpass']} Hz," + f" sample-rate: {sample_frequency}," + f" skipping the first {config['skip_frames_in_the_beginning']} frames" + ) + ) + + for i in range(0, len(meta_channels)): + temp_path = os.path.join(output_path, meta_channels[i] + "_var.npy") + mylogger.info(f"Extract and save: {temp_path}") + + heartbeat_ts: torch.Tensor = bandpass( + data=data[..., i], + low_frequency=config["lower_freqency_bandpass"], + high_frequency=config["upper_freqency_bandpass"], + fs=sample_frequency, + filtfilt_chuck_size=10, + ) + + heartbeat_power = heartbeat_ts[ + ..., config["skip_frames_in_the_beginning"] : + ].var(dim=-1) + np.save(temp_path, heartbeat_power) + + mylogger.info("-==- Done -==-") + + +if __name__ == "__main__": + argh.dispatch_command(main) diff --git a/stage_2_make_heartbeat_mask.py b/stage_2_make_heartbeat_mask.py new file mode 100644 index 0000000..dfa8c63 --- /dev/null +++ b/stage_2_make_heartbeat_mask.py @@ -0,0 +1,163 @@ +import matplotlib.pyplot as plt # type:ignore +import matplotlib +import numpy as np +import torch +import os +import argh + +from matplotlib.widgets import Slider, Button # type:ignore +from functools import partial +from functions.gauss_smear_individual import gauss_smear_individual +from functions.create_logger import create_logger +from functions.get_torch_device import get_torch_device +from functions.load_config import load_config + + +def main(*, config_filename: str = "config.json") -> None: + mylogger = create_logger( + save_logging_messages=True, + display_logging_messages=True, + log_stage_name="stage_2", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + path: str = config["ref_image_path"] + use_channel: str = "donor" + spatial_width: float = 4.0 + temporal_width: float = 0.1 + + threshold: float = 0.05 + + heartbeat_mask_threshold_file: str = os.path.join( + path, "heartbeat_mask_threshold.npy" + ) + if os.path.isfile(heartbeat_mask_threshold_file): + mylogger.info( + f"loading previous threshold file: {heartbeat_mask_threshold_file}" + ) + threshold = float(np.load(heartbeat_mask_threshold_file)[0]) + + mylogger.info(f"initial threshold is {threshold}") + + image_ref_file: str = os.path.join(path, use_channel + ".npy") + image_var_file: str = os.path.join(path, use_channel + "_var.npy") + heartbeat_mask_file: str = os.path.join(path, "heartbeat_mask.npy") + + device = get_torch_device(mylogger, config["force_to_cpu"]) + + mylogger.info(f"loading image reference file: {image_ref_file}") + image_ref: np.ndarray = np.load(image_ref_file) + image_ref /= image_ref.max() + + mylogger.info(f"loading image heartbeat power: {image_var_file}") + image_var: np.ndarray = np.load(image_var_file) + image_var /= image_var.max() + + mylogger.info("Smear the image heartbeat power patially") + temp, _ = gauss_smear_individual( + input=torch.tensor(image_var[..., np.newaxis], device=device), + spatial_width=spatial_width, + temporal_width=temporal_width, + use_matlab_mask=False, + ) + temp /= temp.max() + + mylogger.info("-==- DONE -==-") + + image_3color = np.concatenate( + ( + np.zeros_like(image_ref[..., np.newaxis]), + image_ref[..., np.newaxis], + temp.cpu().numpy(), + ), + axis=-1, + ) + + mylogger.info("Prepare image") + + display_image = image_3color.copy() + display_image[..., 2] = display_image[..., 0] + mask = np.where(image_3color[..., 2] >= threshold, 1.0, np.nan)[..., np.newaxis] + display_image *= mask + display_image = np.nan_to_num(display_image, nan=1.0) + + value_sort = np.sort(image_var.flatten()) + value_sort_max = value_sort[int(value_sort.shape[0] * 0.95)] * 3 + print(value_sort_max) + mylogger.info("-==- DONE -==-") + + mylogger.info("Create figure") + + fig: matplotlib.figure.Figure = plt.figure() + + image_handle = plt.imshow(display_image, vmin=0, vmax=1, cmap="hot") + + mylogger.info("Add controls") + + def next_frame( + i: float, images: np.ndarray, image_handle: matplotlib.image.AxesImage + ) -> None: + nonlocal threshold + threshold = i + + display_image: np.ndarray = images.copy() + display_image[..., 2] = display_image[..., 0] + mask: np.ndarray = np.where(images[..., 2] >= i, 1.0, np.nan)[..., np.newaxis] + display_image *= mask + display_image = np.nan_to_num(display_image, nan=1.0) + + image_handle.set_data(display_image) + return + + def on_clicked_accept(event: matplotlib.backend_bases.MouseEvent) -> None: + nonlocal threshold + nonlocal image_3color + nonlocal path + nonlocal mylogger + nonlocal heartbeat_mask_file + nonlocal heartbeat_mask_threshold_file + + mylogger.info(f"Threshold: {threshold}") + + mask: np.ndarray = image_3color[..., 2] >= threshold + mylogger.info(f"Save mask to: {heartbeat_mask_file}") + np.save(heartbeat_mask_file, mask) + mylogger.info(f"Save threshold to: {heartbeat_mask_threshold_file}") + np.save(heartbeat_mask_threshold_file, np.array([threshold])) + exit() + + def on_clicked_cancel(event: matplotlib.backend_bases.MouseEvent) -> None: + exit() + + axfreq = fig.add_axes(rect=(0.4, 0.9, 0.3, 0.03)) + slice_slider = Slider( + ax=axfreq, + label="Threshold", + valmin=0, + valmax=value_sort_max, + valinit=threshold, + valstep=value_sort_max / 1000.0, + ) + axbutton_accept = fig.add_axes(rect=(0.3, 0.85, 0.2, 0.04)) + button_accept = Button( + ax=axbutton_accept, label="Accept", image=None, color="0.85", hovercolor="0.95" + ) + button_accept.on_clicked(on_clicked_accept) # type: ignore + + axbutton_cancel = fig.add_axes(rect=(0.55, 0.85, 0.2, 0.04)) + button_cancel = Button( + ax=axbutton_cancel, label="Cancel", image=None, color="0.85", hovercolor="0.95" + ) + button_cancel.on_clicked(on_clicked_cancel) # type: ignore + + slice_slider.on_changed( + partial(next_frame, images=image_3color, image_handle=image_handle) + ) + + mylogger.info("Display") + plt.show() + + +if __name__ == "__main__": + argh.dispatch_command(main) diff --git a/stage_3_refine_mask.py b/stage_3_refine_mask.py new file mode 100644 index 0000000..f96b3bd --- /dev/null +++ b/stage_3_refine_mask.py @@ -0,0 +1,169 @@ +import os +import numpy as np + +import matplotlib.pyplot as plt # type:ignore +import matplotlib +from matplotlib.widgets import Button # type:ignore + +# pip install roipoly +from roipoly import RoiPoly # type:ignore + +from functions.create_logger import create_logger +from functions.load_config import load_config + +import argh + + +def compose_image(image_3color: np.ndarray, mask: np.ndarray) -> np.ndarray: + display_image = image_3color.copy() + display_image[..., 2] = display_image[..., 0] + display_image[mask == 0, :] = 1.0 + return display_image + + +def main(*, config_filename: str = "config.json") -> None: + mylogger = create_logger( + save_logging_messages=True, + display_logging_messages=True, + log_stage_name="stage_3", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + path: str = config["ref_image_path"] + use_channel: str = "donor" + padding: int = 20 + + image_ref_file: str = os.path.join(path, use_channel + ".npy") + heartbeat_mask_file: str = os.path.join(path, "heartbeat_mask.npy") + refined_mask_file: str = os.path.join(path, "mask_not_rotated.npy") + + mylogger.info(f"loading image reference file: {image_ref_file}") + image_ref: np.ndarray = np.load(image_ref_file) + image_ref /= image_ref.max() + image_ref = np.pad(image_ref, pad_width=padding) + + mylogger.info(f"loading heartbeat mask: {heartbeat_mask_file}") + mask: np.ndarray = np.load(heartbeat_mask_file) + mask = np.pad(mask, pad_width=padding) + + image_3color = np.concatenate( + ( + np.zeros_like(image_ref[..., np.newaxis]), + image_ref[..., np.newaxis], + np.zeros_like(image_ref[..., np.newaxis]), + ), + axis=-1, + ) + + mylogger.info("-==- DONE -==-") + + fig, ax_main = plt.subplots() + + display_image = compose_image(image_3color=image_3color, mask=mask) + image_handle = ax_main.imshow(display_image, vmin=0, vmax=1, cmap="hot") + + mylogger.info("Add controls") + + def on_clicked_accept(event: matplotlib.backend_bases.MouseEvent) -> None: + nonlocal mylogger + nonlocal refined_mask_file + nonlocal mask + + mylogger.info(f"Save mask to: {refined_mask_file}") + mask = mask[padding:-padding, padding:-padding] + np.save(refined_mask_file, mask) + + exit() + + def on_clicked_cancel(event: matplotlib.backend_bases.MouseEvent) -> None: + nonlocal mylogger + mylogger.info("Ended without saving the mask") + exit() + + def on_clicked_add(event: matplotlib.backend_bases.MouseEvent) -> None: + nonlocal new_roi # type: ignore + nonlocal mask + nonlocal image_3color + nonlocal display_image + nonlocal mylogger + if len(new_roi.x) > 0: + mylogger.info( + "A ROI with the following coordiantes has been added to the mask" + ) + for i in range(0, len(new_roi.x)): + mylogger.info(f"{round(new_roi.x[i], 1)} x {round(new_roi.y[i], 1)}") + mylogger.info("") + new_mask = new_roi.get_mask(display_image[:, :, 0]) + mask[new_mask] = 0.0 + display_image = compose_image(image_3color=image_3color, mask=mask) + image_handle.set_data(display_image) + for line in ax_main.lines: + line.remove() + plt.draw() + + new_roi = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) + + def on_clicked_remove(event: matplotlib.backend_bases.MouseEvent) -> None: + nonlocal new_roi # type: ignore + nonlocal mask + nonlocal image_3color + nonlocal display_image + if len(new_roi.x) > 0: + mylogger.info( + "A ROI with the following coordiantes has been removed from the mask" + ) + for i in range(0, len(new_roi.x)): + mylogger.info(f"{round(new_roi.x[i], 1)} x {round(new_roi.y[i], 1)}") + mylogger.info("") + new_mask = new_roi.get_mask(display_image[:, :, 0]) + mask[new_mask] = 1.0 + display_image = compose_image(image_3color=image_3color, mask=mask) + image_handle.set_data(display_image) + for line in ax_main.lines: + line.remove() + plt.draw() + new_roi = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) + + axbutton_accept = fig.add_axes(rect=(0.3, 0.85, 0.2, 0.04)) + button_accept = Button( + ax=axbutton_accept, label="Accept", image=None, color="0.85", hovercolor="0.95" + ) + button_accept.on_clicked(on_clicked_accept) # type: ignore + + axbutton_cancel = fig.add_axes(rect=(0.5, 0.85, 0.2, 0.04)) + button_cancel = Button( + ax=axbutton_cancel, label="Cancel", image=None, color="0.85", hovercolor="0.95" + ) + button_cancel.on_clicked(on_clicked_cancel) # type: ignore + + axbutton_addmask = fig.add_axes(rect=(0.3, 0.9, 0.2, 0.04)) + button_addmask = Button( + ax=axbutton_addmask, + label="Add mask", + image=None, + color="0.85", + hovercolor="0.95", + ) + button_addmask.on_clicked(on_clicked_add) # type: ignore + + axbutton_removemask = fig.add_axes(rect=(0.5, 0.9, 0.2, 0.04)) + button_removemask = Button( + ax=axbutton_removemask, + label="Remove mask", + image=None, + color="0.85", + hovercolor="0.95", + ) + button_removemask.on_clicked(on_clicked_remove) # type: ignore + + # ax_main.cla() + + mylogger.info("Display") + new_roi: RoiPoly = RoiPoly(ax=ax_main, color="r", close_fig=False, show_fig=False) + + plt.show() + + +if __name__ == "__main__": + argh.dispatch_command(main) diff --git a/stage_4_process.py b/stage_4_process.py new file mode 100644 index 0000000..4a020e2 --- /dev/null +++ b/stage_4_process.py @@ -0,0 +1,1413 @@ +# %% + +import numpy as np +import torch +import torchvision as tv # type: ignore + +import os +import logging +import h5py # type: ignore + +from functions.create_logger import create_logger +from functions.get_torch_device import get_torch_device +from functions.load_config import load_config +from functions.get_experiments import get_experiments +from functions.get_trials import get_trials +from functions.binning import binning +from functions.align_refref import align_refref +from functions.perform_donor_volume_rotation import perform_donor_volume_rotation +from functions.perform_donor_volume_translation import perform_donor_volume_translation +from functions.bandpass import bandpass +from functions.gauss_smear_individual import gauss_smear_individual +from functions.regression import regression +from functions.data_raw_loader import data_raw_loader + +import argh + + +@torch.no_grad() +def process_trial( + config: dict, + mylogger: logging.Logger, + experiment_id: int, + trial_id: int, + device: torch.device, +): + + mylogger.info("") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~ TRIAL START ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") + mylogger.info("") + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + cuda_total_memory: int = torch.cuda.get_device_properties( + device.index + ).total_memory + else: + cuda_total_memory = 0 + + mylogger.info("") + mylogger.info("(A) LOADING DATA, REFERENCE, AND MASK") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + force_to_cpu_memory: bool = True + else: + force_to_cpu_memory = False + + meta_channels: list[str] + meta_mouse_markings: str + meta_recording_date: str + meta_stimulation_times: dict + meta_experiment_names: dict + meta_trial_recording_duration: float + meta_frame_time: float + meta_mouse: str + data: torch.Tensor + + ( + meta_channels, + meta_mouse_markings, + meta_recording_date, + meta_stimulation_times, + meta_experiment_names, + meta_trial_recording_duration, + meta_frame_time, + meta_mouse, + data, + ) = data_raw_loader( + raw_data_path=raw_data_path, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=device, + force_to_cpu_memory=force_to_cpu_memory, + config=config, + ) + experiment_name: str = f"Exp{experiment_id:03d}_Trial{trial_id:03d}" + + dtype_str = config["dtype"] + dtype_np: np.dtype = getattr(np, dtype_str) + + dtype: torch.dtype = data.dtype + + if device != torch.device("cpu"): + free_mem = cuda_total_memory - max( + [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("Finding limit values in the RAW data and mark them for masking") + limit: float = (2**16) - 1 + for i in range(0, data.shape[3]): + zero_pixel_mask: torch.Tensor = torch.any(data[..., i] >= limit, dim=-1) + data[zero_pixel_mask, :, i] = -100.0 + mylogger.info( + f"{meta_channels[i]}: " + f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " + f"with limit values " + ) + mylogger.info("-==- Done -==-") + + mylogger.info("Reference images and mask") + + ref_image_path: str = config["ref_image_path"] + + ref_image_path_acceptor: str = os.path.join(ref_image_path, "acceptor.npy") + if os.path.isfile(ref_image_path_acceptor) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_acceptor}") + assert os.path.isfile(ref_image_path_acceptor) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_acceptor}") + ref_image_acceptor: torch.Tensor = torch.tensor( + np.load(ref_image_path_acceptor).astype(dtype_np), + dtype=dtype, + device=data.device, + ) + + ref_image_path_donor: str = os.path.join(ref_image_path, "donor.npy") + if os.path.isfile(ref_image_path_donor) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_donor}") + assert os.path.isfile(ref_image_path_donor) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_donor}") + ref_image_donor: torch.Tensor = torch.tensor( + np.load(ref_image_path_donor).astype(dtype_np), dtype=dtype, device=data.device + ) + + ref_image_path_oxygenation: str = os.path.join(ref_image_path, "oxygenation.npy") + if os.path.isfile(ref_image_path_oxygenation) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_oxygenation}") + assert os.path.isfile(ref_image_path_oxygenation) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_oxygenation}") + ref_image_oxygenation: torch.Tensor = torch.tensor( + np.load(ref_image_path_oxygenation).astype(dtype_np), + dtype=dtype, + device=data.device, + ) + + ref_image_path_volume: str = os.path.join(ref_image_path, "volume.npy") + if os.path.isfile(ref_image_path_volume) is False: + mylogger.info(f"Could not load ref file: {ref_image_path_volume}") + assert os.path.isfile(ref_image_path_volume) + return + + mylogger.info(f"Loading ref file data: {ref_image_path_volume}") + ref_image_volume: torch.Tensor = torch.tensor( + np.load(ref_image_path_volume).astype(dtype_np), dtype=dtype, device=data.device + ) + + refined_mask_file: str = os.path.join(ref_image_path, "mask_not_rotated.npy") + if os.path.isfile(refined_mask_file) is False: + mylogger.info(f"Could not load mask file: {refined_mask_file}") + assert os.path.isfile(refined_mask_file) + return + + mylogger.info(f"Loading mask file data: {refined_mask_file}") + mask: torch.Tensor = torch.tensor( + np.load(refined_mask_file).astype(dtype_np), dtype=dtype, device=data.device + ) + mylogger.info("-==- Done -==-") + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + + mylogger.info("") + mylogger.info("(B-OPTIONAL) BINNING") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Binning of data") + mylogger.info( + ( + f"kernel_size={int(config['binning_kernel_size'])}, " + f"stride={int(config['binning_stride'])}, " + f"divisor_override={int(config['binning_divisor_override'])}" + ) + ) + + data = binning( + data, + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ).to(device=data.device) + ref_image_acceptor = ( + binning( + ref_image_acceptor.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_donor = ( + binning( + ref_image_donor.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_oxygenation = ( + binning( + ref_image_oxygenation.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + ref_image_volume = ( + binning( + ref_image_volume.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + mask = ( + binning( + mask.unsqueeze(-1).unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=int(config["binning_divisor_override"]), + ) + .squeeze(-1) + .squeeze(-1) + ) + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("") + mylogger.info("(C) ALIGNMENT OF SECOND TO FIRST CAMERA") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Preparing alignment") + mylogger.info("Re-order Raw data") + data = data.moveaxis(-2, 0).moveaxis(-1, 0) + mylogger.info(f"Data shape: {data.shape}") + mylogger.info("-==- Done -==-") + + mylogger.info("Alignment of the ref images and the mask") + mylogger.info("Ref image of donor stays fixed.") + mylogger.info("Ref image of volume and the mask doesn't need to be touched") + mylogger.info("Calculate translation and rotation between the reference images") + angle_refref, tvec_refref, ref_image_acceptor, ref_image_donor = align_refref( + mylogger=mylogger, + ref_image_acceptor=ref_image_acceptor, + ref_image_donor=ref_image_donor, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + ) + mylogger.info(f"Rotation: {round(float(angle_refref[0]), 2)} degree") + mylogger.info( + f"Translation: {round(float(tvec_refref[0]), 1)} x {round(float(tvec_refref[1]), 1)} pixel" + ) + + if config["save_alignment"]: + temp_path: str = os.path.join( + config["export_path"], experiment_name + "_angle_refref.npy" + ) + mylogger.info(f"Save angle to {temp_path}") + np.save(temp_path, angle_refref.cpu()) + + temp_path = os.path.join( + config["export_path"], experiment_name + "_tvec_refref.npy" + ) + mylogger.info(f"Save translation vector to {temp_path}") + np.save(temp_path, tvec_refref.cpu()) + + mylogger.info("Moving & rotating the oxygenation ref image") + ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore + img=ref_image_oxygenation.unsqueeze(0), + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + ref_image_oxygenation = tv.transforms.functional.affine( # type: ignore + img=ref_image_oxygenation, + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ).squeeze(0) + mylogger.info("-==- Done -==-") + + mylogger.info("Rotate and translate the acceptor and oxygenation data accordingly") + acceptor_index: int = config["required_order"].index("acceptor") + donor_index: int = config["required_order"].index("donor") + oxygenation_index: int = config["required_order"].index("oxygenation") + volume_index: int = config["required_order"].index("volume") + + mylogger.info("Rotate acceptor") + data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[acceptor_index, ...], # type: ignore + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Translate acceptor") + data[acceptor_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[acceptor_index, ...], + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Rotate oxygenation") + data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[oxygenation_index, ...], + angle=-float(angle_refref), + translate=[0, 0], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + + mylogger.info("Translate oxygenation") + data[oxygenation_index, ...] = tv.transforms.functional.affine( # type: ignore + img=data[oxygenation_index, ...], + angle=0, + translate=[tvec_refref[1], tvec_refref[0]], + scale=1.0, + shear=0, + interpolation=tv.transforms.InterpolationMode.BILINEAR, + fill=-100.0, + ) + mylogger.info("-==- Done -==-") + + mylogger.info("Perform rotation between donor and volume and its ref images") + mylogger.info("for all frames and then rotate all the data accordingly") + + ( + data[acceptor_index, ...], + data[donor_index, ...], + data[oxygenation_index, ...], + data[volume_index, ...], + angle_donor_volume, + ) = perform_donor_volume_rotation( + mylogger=mylogger, + acceptor=data[acceptor_index, ...], + donor=data[donor_index, ...], + oxygenation=data[oxygenation_index, ...], + volume=data[volume_index, ...], + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + config=config, + ) + + mylogger.info( + f"angles: " + f"min {round(float(angle_donor_volume.min()), 2)} " + f"max {round(float(angle_donor_volume.max()), 2)} " + f"mean {round(float(angle_donor_volume.mean()), 2)} " + ) + + if config["save_alignment"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_angle_donor_volume.npy" + ) + mylogger.info(f"Save angles to {temp_path}") + np.save(temp_path, angle_donor_volume.cpu()) + mylogger.info("-==- Done -==-") + + mylogger.info("Perform translation between donor and volume and its ref images") + mylogger.info("for all frames and then translate all the data accordingly") + ( + data[acceptor_index, ...], + data[donor_index, ...], + data[oxygenation_index, ...], + data[volume_index, ...], + tvec_donor_volume, + ) = perform_donor_volume_translation( + mylogger=mylogger, + acceptor=data[acceptor_index, ...], + donor=data[donor_index, ...], + oxygenation=data[oxygenation_index, ...], + volume=data[volume_index, ...], + ref_image_donor=ref_image_donor, + ref_image_volume=ref_image_volume, + batch_size=config["alignment_batch_size"], + fill_value=-100.0, + config=config, + ) + + mylogger.info( + f"translation dim 0: " + f"min {round(float(tvec_donor_volume[:, 0].min()), 1)} " + f"max {round(float(tvec_donor_volume[:, 0].max()), 1)} " + f"mean {round(float(tvec_donor_volume[:, 0].mean()), 1)} " + ) + mylogger.info( + f"translation dim 1: " + f"min {round(float(tvec_donor_volume[:, 1].min()), 1)} " + f"max {round(float(tvec_donor_volume[:, 1].max()), 1)} " + f"mean {round(float(tvec_donor_volume[:, 1].mean()), 1)} " + ) + + if config["save_alignment"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_tvec_donor_volume.npy" + ) + mylogger.info(f"Save translation vector to {temp_path}") + np.save(temp_path, tvec_donor_volume.cpu()) + mylogger.info("-==- Done -==-") + + mylogger.info("Finding zeros values in the RAW data and mark them for masking") + for i in range(0, data.shape[0]): + zero_pixel_mask = torch.any(data[i, ...] == 0, dim=0) + data[i, :, zero_pixel_mask] = -100.0 + mylogger.info( + f"{config['required_order'][i]}: " + f"found {int(zero_pixel_mask.type(dtype=dtype).sum())} pixel " + f"with zeros " + ) + mylogger.info("-==- Done -==-") + + mylogger.info("Update mask with the new regions due to alignment") + + new_mask_area: torch.Tensor = torch.any(torch.any(data < -0.1, dim=0), dim=0).bool() + mask = (mask == 0).bool() + mask = torch.logical_or(mask, new_mask_area) + mask_negative: torch.Tensor = mask.clone() + mask_positve: torch.Tensor = torch.logical_not(mask) + del mask + + mylogger.info("Update the data with the new mask") + data *= mask_positve.unsqueeze(0).unsqueeze(0).type(dtype=dtype) + mylogger.info("-==- Done -==-") + + if config["save_aligned_as_python"]: + + temp_path = os.path.join( + config["export_path"], experiment_name + "_aligned.npz" + ) + mylogger.info(f"Save aligned data and mask to {temp_path}") + np.savez_compressed( + temp_path, + data=data.cpu(), + mask=mask_positve.cpu(), + acceptor_index=acceptor_index, + donor_index=donor_index, + oxygenation_index=oxygenation_index, + volume_index=volume_index, + ) + + if config["save_aligned_as_matlab"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_aligned.hd5" + ) + mylogger.info(f"Save aligned data and mask to {temp_path}") + file_handle = h5py.File(temp_path, "w") + + _ = file_handle.create_dataset( + "mask", + data=mask_positve.movedim(0, -1).type(torch.uint8).cpu(), + compression="gzip", + compression_opts=9, + ) + + _ = file_handle.create_dataset( + "data", + data=data.movedim(1, -1).movedim(0, -1).cpu(), + compression="gzip", + compression_opts=9, + ) + + _ = file_handle.create_dataset( + "acceptor_index", + data=torch.tensor((acceptor_index,)), + compression="gzip", + compression_opts=9, + ) + + _ = file_handle.create_dataset( + "donor_index", + data=torch.tensor((donor_index,)), + compression="gzip", + compression_opts=9, + ) + + _ = file_handle.create_dataset( + "oxygenation_index", + data=torch.tensor((oxygenation_index,)), + compression="gzip", + compression_opts=9, + ) + + _ = file_handle.create_dataset( + "volume_index", + data=torch.tensor((volume_index,)), + compression="gzip", + compression_opts=9, + ) + + mylogger.info("Reminder: How to read with matlab:") + mylogger.info(f"mask = h5read('{temp_path}','/mask');") + mylogger.info(f"data_acceptor = h5read('{temp_path}','/data');") + file_handle.close() + + mylogger.info("") + mylogger.info("(D) INTER-FRAME INTERPOLATION") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Interpolate the 'in-between' frames for oxygenation and volume") + data[oxygenation_index, 1:, ...] = ( + data[oxygenation_index, 1:, ...] + data[oxygenation_index, :-1, ...] + ) / 2.0 + data[volume_index, 1:, ...] = ( + data[volume_index, 1:, ...] + data[volume_index, :-1, ...] + ) / 2.0 + mylogger.info("-==- Done -==-") + + sample_frequency: float = 1.0 / meta_frame_time + + if config["gevi"]: + assert config["heartbeat_remove"] + + if config["heartbeat_remove"]: + + mylogger.info("") + mylogger.info("(E-OPTIONAL) HEARTBEAT REMOVAL") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Extract heartbeat from volume signal") + heartbeat_ts: torch.Tensor = bandpass( + data=data[volume_index, ...].movedim(0, -1).clone(), + low_frequency=config["lower_freqency_bandpass"], + high_frequency=config["upper_freqency_bandpass"], + fs=sample_frequency, + filtfilt_chuck_size=config["heartbeat_filtfilt_chuck_size"], + ) + heartbeat_ts = heartbeat_ts.flatten(start_dim=0, end_dim=-2) + mask_flatten: torch.Tensor = mask_positve.flatten(start_dim=0, end_dim=-1) + + heartbeat_ts = heartbeat_ts[mask_flatten, :] + + heartbeat_ts = heartbeat_ts.movedim(0, -1) + heartbeat_ts -= heartbeat_ts.mean(dim=0, keepdim=True) + + try: + volume_heartbeat, _, _ = torch.linalg.svd(heartbeat_ts, full_matrices=False) + except torch.cuda.OutOfMemoryError: + mylogger.info("torch.cuda.OutOfMemoryError: Fallback to cpu") + volume_heartbeat_cpu, _, _ = torch.linalg.svd( + heartbeat_ts.cpu(), full_matrices=False + ) + volume_heartbeat = volume_heartbeat_cpu.to(heartbeat_ts.device, copy=True) + del volume_heartbeat_cpu + + volume_heartbeat = volume_heartbeat[:, 0] + volume_heartbeat -= volume_heartbeat[ + config["skip_frames_in_the_beginning"] : + ].mean() + + del heartbeat_ts + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + free_mem = cuda_total_memory - max( + [ + torch.cuda.memory_reserved(device), + torch.cuda.memory_allocated(device), + ] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + if config["save_heartbeat"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_volume_heartbeat.npy" + ) + mylogger.info(f"Save volume heartbeat to {temp_path}") + np.save(temp_path, volume_heartbeat.cpu()) + mylogger.info("-==- Done -==-") + + volume_heartbeat = volume_heartbeat.unsqueeze(0).unsqueeze(0) + norm_volume_heartbeat = ( + volume_heartbeat[..., config["skip_frames_in_the_beginning"] :] ** 2 + ).sum(dim=-1) + + heartbeat_coefficients: torch.Tensor = torch.zeros( + (data.shape[0], data.shape[-2], data.shape[-1]), + dtype=data.dtype, + device=data.device, + ) + for i in range(0, data.shape[0]): + y = bandpass( + data=data[i, ...].movedim(0, -1).clone(), + low_frequency=config["lower_freqency_bandpass"], + high_frequency=config["upper_freqency_bandpass"], + fs=sample_frequency, + filtfilt_chuck_size=config["heartbeat_filtfilt_chuck_size"], + )[..., config["skip_frames_in_the_beginning"] :] + y -= y.mean(dim=-1, keepdim=True) + + heartbeat_coefficients[i, ...] = ( + volume_heartbeat[..., config["skip_frames_in_the_beginning"] :] * y + ).sum(dim=-1) / norm_volume_heartbeat + + heartbeat_coefficients[i, ...] *= mask_positve.type( + dtype=heartbeat_coefficients.dtype + ) + del y + + if config["save_heartbeat"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_heartbeat_coefficients.npy" + ) + mylogger.info(f"Save heartbeat coefficients to {temp_path}") + np.save(temp_path, heartbeat_coefficients.cpu()) + mylogger.info("-==- Done -==-") + + mylogger.info("Remove heart beat from data") + data -= heartbeat_coefficients.unsqueeze(1) * volume_heartbeat.unsqueeze( + 0 + ).movedim(-1, 1) + # data_herzlos = data.clone() + mylogger.info("-==- Done -==-") + + if config["gevi"]: # UDO scaling performed! + + mylogger.info("") + mylogger.info("(F-OPTIONAL) DONOR/ACCEPTOR SCALING") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + donor_heartbeat_factor = heartbeat_coefficients[donor_index, ...].clone() + acceptor_heartbeat_factor = heartbeat_coefficients[ + acceptor_index, ... + ].clone() + del heartbeat_coefficients + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + free_mem = cuda_total_memory - max( + [ + torch.cuda.memory_reserved(device), + torch.cuda.memory_allocated(device), + ] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + mylogger.info("Calculate scaling factor for donor and acceptor") + # donor_factor: torch.Tensor = ( + # donor_heartbeat_factor + acceptor_heartbeat_factor + # ) / (2 * donor_heartbeat_factor) + # acceptor_factor: torch.Tensor = ( + # donor_heartbeat_factor + acceptor_heartbeat_factor + # ) / (2 * acceptor_heartbeat_factor) + donor_factor = torch.sqrt( + acceptor_heartbeat_factor / donor_heartbeat_factor + ) + acceptor_factor = 1 / donor_factor + + # import matplotlib.pyplot as plt + # plt.pcolor(donor_factor, vmin=0.5, vmax=2.0) + # plt.colorbar() + # plt.show() + # plt.pcolor(acceptor_factor, vmin=0.5, vmax=2.0) + # plt.colorbar() + # plt.show() + # TODO remove + + del donor_heartbeat_factor + del acceptor_heartbeat_factor + + # import matplotlib.pyplot as plt + # plt.pcolor(torch.std(data[acceptor_index, config["skip_frames_in_the_beginning"] :, ...], axis=0), vmin=0, vmax=500) + # plt.colorbar() + # plt.show() + # plt.pcolor(torch.std(data[donor_index, config["skip_frames_in_the_beginning"] :, ...], axis=0), vmin=0, vmax=500) + # plt.colorbar() + # plt.show() + # TODO remove + + if config["save_factors"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_donor_factor.npy" + ) + mylogger.info(f"Save donor factor to {temp_path}") + np.save(temp_path, donor_factor.cpu()) + + temp_path = os.path.join( + config["export_path"], experiment_name + "_acceptor_factor.npy" + ) + mylogger.info(f"Save acceptor factor to {temp_path}") + np.save(temp_path, acceptor_factor.cpu()) + mylogger.info("-==- Done -==-") + + # TODO we have to calculate means first! + mylogger.info("Extract means for acceptor and donor first") + mean_values_acceptor = data[ + acceptor_index, config["skip_frames_in_the_beginning"] :, ... + ].nanmean(dim=0, keepdim=True) + mean_values_donor = data[ + donor_index, config["skip_frames_in_the_beginning"] :, ... + ].nanmean(dim=0, keepdim=True) + + mylogger.info("Scale acceptor to heart beat amplitude") + mylogger.info("Remove mean") + data[acceptor_index, ...] -= mean_values_acceptor + mylogger.info("Apply acceptor_factor and mask") + # data[acceptor_index, ...] *= acceptor_factor.unsqueeze( + # 0 + # ) * mask_positve.unsqueeze(0) + acceptor_factor_correction = np.sqrt( + mean_values_acceptor / mean_values_donor + ) + data[acceptor_index, ...] *= acceptor_factor.unsqueeze( + 0 + ) * acceptor_factor_correction * mask_positve.unsqueeze(0) + mylogger.info("Add mean") + data[acceptor_index, ...] += mean_values_acceptor + mylogger.info("-==- Done -==-") + + mylogger.info("Scale donor to heart beat amplitude") + mylogger.info("Remove mean") + data[donor_index, ...] -= mean_values_donor + mylogger.info("Apply donor_factor and mask") + # data[donor_index, ...] *= donor_factor.unsqueeze( + # 0 + # ) * mask_positve.unsqueeze(0) + donor_factor_correction = 1 / acceptor_factor_correction + data[donor_index, ...] *= donor_factor.unsqueeze( + 0 + ) * donor_factor_correction * mask_positve.unsqueeze(0) + mylogger.info("Add mean") + data[donor_index, ...] += mean_values_donor + mylogger.info("-==- Done -==-") + + # import matplotlib.pyplot as plt + # plt.pcolor(mean_values_acceptor[0]) + # plt.colorbar() + # plt.show() + # plt.pcolor(mean_values_donor[0]) + # plt.colorbar() + # plt.show() + # TODO remove + + # TODO SCHNUGGEL + else: + mylogger.info("GECI does not require acceptor/donor scaling, skipping!") + mylogger.info("-==- Done -==-") + + mylogger.info("") + mylogger.info("(G) CONVERSION TO RELATIVE SIGNAL CHANGES (DIV/MEAN)") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Divide by mean over time") + data /= data[:, config["skip_frames_in_the_beginning"] :, ...].nanmean( + dim=1, + keepdim=True, + ) + mylogger.info("-==- Done -==-") + + mylogger.info("") + mylogger.info("(H) CLEANING BY REGRESSION") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + data = data.nan_to_num(nan=0.0) + mylogger.info("Preparation for regression -- Gauss smear") + spatial_width = float(config["gauss_smear_spatial_width"]) + + if config["binning_enable"] and (config["binning_at_the_end"] is False): + spatial_width /= float(config["binning_kernel_size"]) + + mylogger.info( + f"Mask -- " + f"spatial width: {spatial_width}, " + f"temporal width: {float(config['gauss_smear_temporal_width'])}, " + f"use matlab mode: {bool(config['gauss_smear_use_matlab_mask'])} " + ) + + input_mask = mask_positve.type(dtype=dtype).clone() + + filtered_mask: torch.Tensor + filtered_mask, _ = gauss_smear_individual( + input=input_mask, + spatial_width=spatial_width, + temporal_width=float(config["gauss_smear_temporal_width"]), + use_matlab_mask=bool(config["gauss_smear_use_matlab_mask"]), + epsilon=float(torch.finfo(input_mask.dtype).eps), + ) + + mylogger.info("creating a copy of the data") + data_filtered = data.clone().movedim(1, -1) + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + free_mem = cuda_total_memory - max( + [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + overwrite_fft_gauss: None | torch.Tensor = None + for i in range(0, data_filtered.shape[0]): + mylogger.info( + f"{config['required_order'][i]} -- " + f"spatial width: {spatial_width}, " + f"temporal width: {float(config['gauss_smear_temporal_width'])}, " + f"use matlab mode: {bool(config['gauss_smear_use_matlab_mask'])} " + ) + data_filtered[i, ...] *= input_mask.unsqueeze(-1) + data_filtered[i, ...], overwrite_fft_gauss = gauss_smear_individual( + input=data_filtered[i, ...], + spatial_width=spatial_width, + temporal_width=float(config["gauss_smear_temporal_width"]), + overwrite_fft_gauss=overwrite_fft_gauss, + use_matlab_mask=bool(config["gauss_smear_use_matlab_mask"]), + epsilon=float(torch.finfo(input_mask.dtype).eps), + ) + + data_filtered[i, ...] /= filtered_mask + 1e-20 + data_filtered[i, ...] += 1.0 - input_mask.unsqueeze(-1) + + del filtered_mask + del overwrite_fft_gauss + del input_mask + mylogger.info("data_filtered is populated") + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + free_mem = cuda_total_memory - max( + [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + mylogger.info("-==- Done -==-") + + mylogger.info("Preperation for Regression") + mylogger.info("Move time dimensions of data to the last dimension") + data = data.movedim(1, -1) + + dual_signal_mode: bool = True + if len(config["target_camera_acceptor"]) > 0: + mylogger.info("Regression Acceptor") + mylogger.info(f"Target: {config['target_camera_acceptor']}") + mylogger.info( + f"Regressors: constant, linear and {config['regressor_cameras_acceptor']}" + ) + target_id: int = config["required_order"].index( + config["target_camera_acceptor"] + ) + regressor_id: list[int] = [] + for i in range(0, len(config["regressor_cameras_acceptor"])): + regressor_id.append( + config["required_order"].index(config["regressor_cameras_acceptor"][i]) + ) + + data_acceptor, coefficients_acceptor = regression( + mylogger=mylogger, + target_camera_id=target_id, + regressor_camera_ids=regressor_id, + mask=mask_negative, + data=data, + data_filtered=data_filtered, + first_none_ramp_frame=int(config["skip_frames_in_the_beginning"]), + ) + + if config["save_regression_coefficients"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_coefficients_acceptor.npy" + ) + mylogger.info(f"Save acceptor coefficients to {temp_path}") + np.save(temp_path, coefficients_acceptor.cpu()) + del coefficients_acceptor + + mylogger.info("-==- Done -==-") + else: + dual_signal_mode = False + target_id = config["required_order"].index("acceptor") + data_acceptor = data[target_id, ...].clone() + data_acceptor[mask_negative, :] = 0.0 + + if len(config["target_camera_donor"]) > 0: + mylogger.info("Regression Donor") + mylogger.info(f"Target: {config['target_camera_donor']}") + mylogger.info( + f"Regressors: constant, linear and {config['regressor_cameras_donor']}" + ) + target_id = config["required_order"].index(config["target_camera_donor"]) + regressor_id = [] + for i in range(0, len(config["regressor_cameras_donor"])): + regressor_id.append( + config["required_order"].index(config["regressor_cameras_donor"][i]) + ) + + data_donor, coefficients_donor = regression( + mylogger=mylogger, + target_camera_id=target_id, + regressor_camera_ids=regressor_id, + mask=mask_negative, + data=data, + data_filtered=data_filtered, + first_none_ramp_frame=int(config["skip_frames_in_the_beginning"]), + ) + + if config["save_regression_coefficients"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_coefficients_donor.npy" + ) + mylogger.info(f"Save acceptor donor to {temp_path}") + np.save(temp_path, coefficients_donor.cpu()) + del coefficients_donor + mylogger.info("-==- Done -==-") + else: + dual_signal_mode = False + target_id = config["required_order"].index("donor") + data_donor = data[target_id, ...].clone() + data_donor[mask_negative, :] = 0.0 + + # TODO clean up ---> + if config["save_oxyvol_as_python"] or config["save_oxyvol_as_matlab"]: + + mylogger.info("") + mylogger.info("(I-OPTIONAL) SAVE OXY/VOL/MASK") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + # extract oxy and vol + mylogger.info("Save Oxygenation/Volume/Mask") + data_oxygenation = data[oxygenation_index, ...].clone() + data_volume = data[volume_index, ...].clone() + data_mask = mask_positve.clone() + + # bin, if required... + if config["binning_enable"] and config["binning_at_the_end"]: + mylogger.info("Binning of data") + mylogger.info( + ( + f"kernel_size={int(config['binning_kernel_size'])}, " + f"stride={int(config['binning_stride'])}, " + "divisor_override=None" + ) + ) + + data_oxygenation = binning( + data_oxygenation.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + data_volume = binning( + data_volume.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + data_mask = ( + binning( + data_mask.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ) + .squeeze(-1) + .squeeze(-1) + ) + data_mask = (data_mask > 0).type(torch.bool) + + if config["save_oxyvol_as_python"]: + + # export it! + temp_path = os.path.join( + config["export_path"], experiment_name + "_oxygenation_volume.npz" + ) + mylogger.info(f"Save data oxygenation and volume to {temp_path}") + np.savez_compressed( + temp_path, + data_oxygenation=data_oxygenation.cpu(), + data_volume=data_volume.cpu(), + data_mask=data_mask.cpu(), + ) + + if config["save_oxyvol_as_matlab"]: + + temp_path = os.path.join( + config["export_path"], experiment_name + "_oxygenation_volume.hd5" + ) + mylogger.info(f"Save data oxygenation and volume to {temp_path}") + file_handle = h5py.File(temp_path, "w") + + data_mask = data_mask.movedim(0, -1) + data_oxygenation = data_oxygenation.movedim(1, -1).movedim(0, -1) + data_volume = data_volume.movedim(1, -1).movedim(0, -1) + _ = file_handle.create_dataset( + "data_mask", + data=data_mask.type(torch.uint8).cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "data_oxygenation", + data=data_oxygenation.cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "data_volume", + data=data_volume.cpu(), + compression="gzip", + compression_opts=9, + ) + mylogger.info("Reminder: How to read with matlab:") + mylogger.info(f"data_mask = h5read('{temp_path}','/data_mask');") + mylogger.info(f"data_oxygenation = h5read('{temp_path}','/data_oxygenation');") + mylogger.info(f"data_volume = h5read('{temp_path}','/data_volume');") + file_handle.close() + # TODO <------ clean up + + del data + del data_filtered + + if device != torch.device("cpu"): + torch.cuda.empty_cache() + mylogger.info("Empty CUDA cache") + free_mem = cuda_total_memory - max( + [torch.cuda.memory_reserved(device), torch.cuda.memory_allocated(device)] + ) + mylogger.info(f"CUDA memory: {free_mem // 1024} MByte") + + # ##################### + + if config["gevi"]: + assert dual_signal_mode + else: + assert dual_signal_mode is False + + if dual_signal_mode is False: + + mylogger.info("") + mylogger.info("(J1-OPTIONAL) SAVE ACC/DON/MASK (NO RATIO!+OPT BIN@END)") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("mono signal model") + + mylogger.info("Remove nan") + data_acceptor = torch.nan_to_num(data_acceptor, nan=0.0) + data_donor = torch.nan_to_num(data_donor, nan=0.0) + mylogger.info("-==- Done -==-") + + if config["binning_enable"] and config["binning_at_the_end"]: + mylogger.info("Binning of data") + mylogger.info( + ( + f"kernel_size={int(config['binning_kernel_size'])}, " + f"stride={int(config['binning_stride'])}, " + "divisor_override=None" + ) + ) + + data_acceptor = binning( + data_acceptor.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + data_donor = binning( + data_donor.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + mask_positve = ( + binning( + mask_positve.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ) + .squeeze(-1) + .squeeze(-1) + ) + mask_positve = (mask_positve > 0).type(torch.bool) + + if config["save_as_python"]: + + temp_path = os.path.join( + config["export_path"], experiment_name + "_acceptor_donor.npz" + ) + mylogger.info(f"Save data donor and acceptor and mask to {temp_path}") + np.savez_compressed( + temp_path, + data_acceptor=data_acceptor.cpu(), + data_donor=data_donor.cpu(), + mask=mask_positve.cpu(), + ) + + if config["save_as_matlab"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_acceptor_donor.hd5" + ) + mylogger.info(f"Save data donor and acceptor and mask to {temp_path}") + file_handle = h5py.File(temp_path, "w") + + mask_positve = mask_positve.movedim(0, -1) + data_acceptor = data_acceptor.movedim(1, -1).movedim(0, -1) + data_donor = data_donor.movedim(1, -1).movedim(0, -1) + _ = file_handle.create_dataset( + "mask", + data=mask_positve.type(torch.uint8).cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "data_acceptor", + data=data_acceptor.cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "data_donor", + data=data_donor.cpu(), + compression="gzip", + compression_opts=9, + ) + mylogger.info("Reminder: How to read with matlab:") + mylogger.info(f"mask = h5read('{temp_path}','/mask');") + mylogger.info(f"data_acceptor = h5read('{temp_path}','/data_acceptor');") + mylogger.info(f"data_donor = h5read('{temp_path}','/data_donor');") + file_handle.close() + return + # ##################### + + mylogger.info("") + mylogger.info("(J2-OPTIONAL) BUILD AND SAVE RATIO (+OPT BIN@END)") + mylogger.info("-----------------------------------------------") + mylogger.info("") + + mylogger.info("Calculate ratio sequence") + + if config["classical_ratio_mode"]: + mylogger.info("via acceptor / donor") + ratio_sequence: torch.Tensor = data_acceptor / data_donor + mylogger.info("via / mean over time") + ratio_sequence /= ratio_sequence.mean(dim=-1, keepdim=True) + else: + mylogger.info("via 1.0 + acceptor - donor") + ratio_sequence = 1.0 + data_acceptor - data_donor + + mylogger.info("Remove nan") + ratio_sequence = torch.nan_to_num(ratio_sequence, nan=0.0) + mylogger.info("-==- Done -==-") + + if config["binning_enable"] and config["binning_at_the_end"]: + mylogger.info("Binning of data") + mylogger.info( + ( + f"kernel_size={int(config['binning_kernel_size'])}, " + f"stride={int(config['binning_stride'])}, " + "divisor_override=None" + ) + ) + + ratio_sequence = binning( + ratio_sequence.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + if config["save_gevi_with_donor_acceptor"]: + data_acceptor = binning( + data_acceptor.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + data_donor = binning( + data_donor.unsqueeze(-1), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ).squeeze(-1) + + mask_positve = ( + binning( + mask_positve.unsqueeze(-1).unsqueeze(-1).type(dtype=dtype), + kernel_size=int(config["binning_kernel_size"]), + stride=int(config["binning_stride"]), + divisor_override=None, + ) + .squeeze(-1) + .squeeze(-1) + ) + mask_positve = (mask_positve > 0).type(torch.bool) + + if config["save_as_python"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_ratio_sequence.npz" + ) + mylogger.info(f"Save ratio_sequence and mask to {temp_path}") + if config["save_gevi_with_donor_acceptor"]: + np.savez_compressed( + temp_path, ratio_sequence=ratio_sequence.cpu(), mask=mask_positve.cpu(), data_acceptor=data_acceptor.cpu(), data_donor=data_donor.cpu() + ) + else: + np.savez_compressed( + temp_path, ratio_sequence=ratio_sequence.cpu(), mask=mask_positve.cpu() + ) + + if config["save_as_matlab"]: + temp_path = os.path.join( + config["export_path"], experiment_name + "_ratio_sequence.hd5" + ) + mylogger.info(f"Save ratio_sequence and mask to {temp_path}") + file_handle = h5py.File(temp_path, "w") + + mask_positve = mask_positve.movedim(0, -1) + ratio_sequence = ratio_sequence.movedim(1, -1).movedim(0, -1) + _ = file_handle.create_dataset( + "mask", + data=mask_positve.type(torch.uint8).cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "ratio_sequence", + data=ratio_sequence.cpu(), + compression="gzip", + compression_opts=9, + ) + if config["save_gevi_with_donor_acceptor"]: + _ = file_handle.create_dataset( + "data_acceptor", + data=data_acceptor.cpu(), + compression="gzip", + compression_opts=9, + ) + _ = file_handle.create_dataset( + "data_donor", + data=data_donor.cpu(), + compression="gzip", + compression_opts=9, + ) + mylogger.info("Reminder: How to read with matlab:") + mylogger.info(f"mask = h5read('{temp_path}','/mask');") + mylogger.info(f"ratio_sequence = h5read('{temp_path}','/ratio_sequence');") + if config["save_gevi_with_donor_acceptor"]: + mylogger.info(f"data_donor = h5read('{temp_path}','/data_donor');") + mylogger.info(f"data_acceptor = h5read('{temp_path}','/data_acceptor');") + file_handle.close() + + del ratio_sequence + del mask_positve + del mask_negative + + mylogger.info("") + mylogger.info("***********************************************") + mylogger.info("* TRIAL END ***********************************") + mylogger.info("***********************************************") + mylogger.info("") + + return + + +def main( + *, + config_filename: str = "config.json", + experiment_id_overwrite: int = -1, + trial_id_overwrite: int = -1, +) -> None: + mylogger = create_logger( + save_logging_messages=True, + display_logging_messages=True, + log_stage_name="stage_4", + ) + + config = load_config(mylogger=mylogger, filename=config_filename) + + if (config["save_as_python"] is False) and (config["save_as_matlab"] is False): + mylogger.info("No output will be created. ") + mylogger.info("Change save_as_python and/or save_as_matlab in the config file") + mylogger.info("ERROR: STOP!!!") + exit() + + if (len(config["target_camera_donor"]) == 0) and ( + len(config["target_camera_acceptor"]) == 0 + ): + mylogger.info( + "Configure at least target_camera_donor or target_camera_acceptor correctly." + ) + mylogger.info("ERROR: STOP!!!") + exit() + + device = get_torch_device(mylogger, config["force_to_cpu"]) + + mylogger.info( + f"Create directory {config['export_path']} in the case it does not exist" + ) + os.makedirs(config["export_path"], exist_ok=True) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if os.path.isdir(raw_data_path) is False: + mylogger.info(f"ERROR: could not find raw directory {raw_data_path}!!!!") + exit() + + if experiment_id_overwrite == -1: + experiments = get_experiments(raw_data_path) + else: + assert experiment_id_overwrite >= 0 + experiments = torch.tensor([experiment_id_overwrite]) + + for experiment_counter in range(0, experiments.shape[0]): + experiment_id = int(experiments[experiment_counter]) + + if trial_id_overwrite == -1: + trials = get_trials(raw_data_path, experiment_id) + else: + assert trial_id_overwrite >= 0 + trials = torch.tensor([trial_id_overwrite]) + + for trial_counter in range(0, trials.shape[0]): + trial_id = int(trials[trial_counter]) + + mylogger.info("") + mylogger.info( + f"======= EXPERIMENT ID: {experiment_id} ==== TRIAL ID: {trial_id} =======" + ) + mylogger.info("") + + try: + process_trial( + config=config, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=device, + ) + except torch.cuda.OutOfMemoryError: + mylogger.info("WARNING: RUNNING IN FAILBACK MODE!!!!") + mylogger.info("Not enough GPU memory. Retry on CPU") + process_trial( + config=config, + mylogger=mylogger, + experiment_id=experiment_id, + trial_id=trial_id, + device=torch.device("cpu"), + ) + + +if __name__ == "__main__": + argh.dispatch_command(main) + +# %% diff --git a/stage_5_convert_metadata.py b/stage_5_convert_metadata.py new file mode 100644 index 0000000..ed4ef73 --- /dev/null +++ b/stage_5_convert_metadata.py @@ -0,0 +1,57 @@ +import json +import os +import argh +from jsmin import jsmin # type:ignore +from functions.get_trials import get_trials +from functions.get_experiments import get_experiments + + +def converter(filename: str = "config_M_Sert_Cre_49.json") -> None: + + if os.path.isfile(filename) is False: + print(f"{filename} is missing") + exit() + + with open(filename, "r") as file: + config = json.loads(jsmin(file.read())) + + raw_data_path: str = os.path.join( + config["basic_path"], + config["recoding_data"], + config["mouse_identifier"], + config["raw_path"], + ) + + if os.path.isdir(raw_data_path) is False: + print(f"ERROR: could not find raw directory {raw_data_path}!!!!") + exit() + + experiments = get_experiments(raw_data_path).numpy() + + os.makedirs(config["export_path"], exist_ok=True) + + for experiment in experiments: + + trials = get_trials(raw_data_path, experiment).numpy() + assert trials.shape[0] > 0 + + with open( + os.path.join( + raw_data_path, + f"Exp{experiment:03d}_Trial{trials[0]:03d}_Part001_meta.txt", + ), + "r", + ) as file: + metadata = json.loads(jsmin(file.read())) + + filename_out: str = os.path.join( + config["export_path"], + f"metadata_exp{experiment:03d}.json", + ) + + with open(filename_out, 'w') as file: + json.dump(metadata, file) + + +if __name__ == "__main__": + argh.dispatch_command(converter) From a33172f38bced310fbedc4f958b1b65586eb0d5f Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:34:08 +0100 Subject: [PATCH 23/25] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 79fa2ca..6fde740 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Ruhr-Universität Bochum, Universitätsbibliothek Updated: 19.03.2025 Files are now organized in subdirectories to distinguish better between code for GEVI or GECI analysis. - +``` gevi-geci/ stage_1*, stage_2*, stage_3*, stage_4*, stage_5* -> main stages for data preprocessing @@ -48,7 +48,7 @@ gevi-geci/geci/ gevi-geci/other/ stage_4b_inspect.py, stage_4c_viewer.py -> temporary code for assisting search for implantation electrode - +``` From 6d8a7fbbbcbc828980a816cb8e389df2c13b4938 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:35:14 +0100 Subject: [PATCH 24/25] Update stage_5_convert_metadata.py --- stage_5_convert_metadata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stage_5_convert_metadata.py b/stage_5_convert_metadata.py index ed4ef73..9f4073b 100644 --- a/stage_5_convert_metadata.py +++ b/stage_5_convert_metadata.py @@ -6,13 +6,13 @@ from functions.get_trials import get_trials from functions.get_experiments import get_experiments -def converter(filename: str = "config_M_Sert_Cre_49.json") -> None: +def converter(config_filename: str = "config_M_Sert_Cre_49.json") -> None: - if os.path.isfile(filename) is False: - print(f"{filename} is missing") + if os.path.isfile(config_filename) is False: + print(f"{config_filename} is missing") exit() - with open(filename, "r") as file: + with open(config_filename, "r") as file: config = json.loads(jsmin(file.read())) raw_data_path: str = os.path.join( From 6609a37a976500ffefb5c60e434e37751117deaf Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 19 Mar 2025 18:36:14 +0100 Subject: [PATCH 25/25] Add files via upload --- preprocessing.pdf | Bin 0 -> 279282 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 preprocessing.pdf diff --git a/preprocessing.pdf b/preprocessing.pdf new file mode 100644 index 0000000000000000000000000000000000000000..701c122096443560248f2a5f51af14b07a2bd493 GIT binary patch literal 279282 zcma&sLy#~$v;f$)ZQHhO+qP}nwr$(CZQJht+MIu;X7}FWrLxHCW|OL%3sMCUF|atdKptYa~BH&CN?Hkg8y$p(TiEyxR^Q-(2Lm^x|oWX z8rz$gLhBnfYJgmrB(TPD}Pmyf|^(nQyCqa0pad!clrvhFQp z)Mq<&LRO=1`=Yv8b$nQCT1V%(3{4kL6n(6s9uM5Jg!S2Jc)V~qs&-FW22JgU%&73<~ZN@tT6oMd>V z8nI1x+wT8BkDzNRwY<+Hx9tRp5ieoQxma~u+F~Tdm8{B4Rd$%V_?I256f54`=ul^V zcM(JC9M*%FJgF<))m_x?wB9)=tFTn*Dc@gxDzupPMpKHNtVOMtH&)soHrgAR01I?Z{YY2gQ0Ztd594)=iT%Y;qqFQfKD#4!ut5* zv69$jvRc_mAU_h|KyFUEPh|(LWfW&;)*GJy462GE>3b34_Of_K{WDcmbzfPJ_brVdXQ7f0Rz;wx7BAHKFoP`93clA~xN(REb zdU<&a5$MdQSfSsunR3Mj&10IwocG339r+0_-hjRV!`BuI;?3Ahe9lpeX}~jKY7AvEOX*YJ<~-)Xr-@%f zK*ms|EQsoNW?4>P{v*A(I*pW}!#&z0^qLNW+_pA6b#slrtBVy~(GcToT}^2ASBB~N z0X~4I@kH;1kg#6O0FLxXW6`?zVFq(}q+y*be}bwQgkwa)B+J<& z$vzrO#DltesMG7xmF@+Ed6dRPQLEQp7c`{Pyds{O;NQJ%>LN3m20g&L(X_uLMbbp@ zJGjjqcBHdAqs;l>P)&cVsJfpNWG(}E7c1rg2sDTw#UYZAj@Va9nq#y?i8NQm>oJ7D z8hZK!nAkH)7_5`l7Y-Z@ijJ#xbmrO+%$vl|HV5O>{btcQIbKF+2_T4TBa_Q5H^Zty z5HQVJa6S9N86_^jwlN^@ z$ajQ_6!z^0blZCOw#n7-B8Q$~WPBYaQ$RykZIMgBK6Bzmcf}*KJqTQ{v+d%wrw+g- zIG)}yzq_c)vaX6v7Y|%2v$}XX8drybk95=VOEPWZ*AFz>vGvazM_Z+CYy~jsKDt?k z%>BzrvPar6yJj+P0#E_k zkeHl<@@By|D*+|P`53NI%Q27#cXPICc;T+3!G$^MPdF4;4PdF@+Pj-|>I@d;OHSX_7l3#>!0nX1DS29NQ5SYLbP z=i{VA>%HMg&Mf)t(YhyY_^M(LMHkpHOz?BduB$b*nH)K(@y_C+6El zSe5^ev?SUPGp2pzImf=oiJc*6>#l9sGe(iNTi;a>nqSJ5uerRzP`qmRE*AQRN5%I$ zo@gnAD(F^VF%+c_VprngAzf7;2Ww&RH8dn4ar*@6=(*1#%PusqnCI#JLpff)rT!8M zTNfw1{M&QK^TE6mn%r9GvN^dOrWHgs-~Lh-E;OVH9xT}a0u1m12%;j;!K?{7@`-~) z3LmNY#P<>O*yh3)9K9Gs7l!B3h(NE8K8rp+K3={#S3aTpSu~qYAjpcq*HJKEuIS2$?) zg%0$)dPRH~*A%Zq29$);${Uw!^P{%E?b)3R3PD3r7Qf2pjBCD|Y*ckwu!4VWE+YYX zZI9fY+=@1%H1mGLCFS=fpUGK){IE?~A37=3y5I=w&(j5LuhVoJFw;PytS%_5%H5bM zb1jRg19VGF)5~KaH}Dsjjl%dw0T7$dv)$R?8ZRh#-dHj{+o|E|Ka-uGDq*io71u^6 z9Z&7|?_=m*SdywsN0GQlx5XFNF7)0z!f>rn+(N}7?L;=xjB`~<%&2F6c99?(eK4_E zet37y=Zw?DstPcREV0AT2@VY;v`B!w7RB9uqFC^s%cD(y04CHOXed)VlmFk6|DFC{ zhq5sLFCb-P<@mpXbXG$;d3y}8@4o&()?n3%MINR=1un_7I>)Idr6)By6*vvY>F}Tt zY+zUQbiUl&OMr(%+R0Wm1sP1Bxcuw-x64}qy&F{5r=#fi^X}~G1wM&3gDi)#vtyGE zNwh)&T?=}MRDZ_?tzXQj(9}0{0#R)XM)pEOMi}ug;AN$IXL={TA)`bx% zMx<#Fl;34ju4Y|_98u&^!>EJ~l!n#&l;pnl{idqeOt1-Wc>|c{hRG0sD4&?-Q-&C=7FtoTN&f(mVaXUfv|P0eoap= zHCbc|qt;I*)zuFQR~*I;vyRy+7`mN%p=%3cK4o(1^-Z2hr&45$-Q9Qkb{7RsLOdMhSHnB+#D^Js|6ld0~>aZB&&9ea(b|gob#KtW01`oaL>Y?{}H!?^( ze$BO8V7`D!HV?H!rur{%k4@8Dz1Fg>cKMQpK57CNa^u=p_nfWG8is|0x6ra?$s!kT zlN_b>?sYJ`!@d{%nnCfh1+3n2_nz8U<~V*oW9Z`txO@BSD!NYnb(6^7Vc7&Vuwq(_ zIKMgR89$F3r(Ns*%N4_nj^Ic$6UOmO8DM|1m(?=5Kxl;S?*_FRC8GDy1}X!TY_jpFj53n)W^yN#Eu1u01W)KniHL=p zUgU)+R`c+M*0fjBhHE!{BJAB5;a#cC4f3>%_j1OppKn6-Rgb7u4M!*~VR3vBdXpAX z>4;@DrDReIy5@T7>9XCvNqgvHs1_8eZ_`-@nlms>U7}AZC8J^g!4Dd@-mc+!&;{tS z=_ty^T>R$8ZUVJE^Z*!jI%$41H;6>NZFW}+^;Ifp zb67cp4UX18H-Ssg!bt*aIF==<`I^VLVaMfFbGM?b653nU9+>;F$Vj+G9MIeu;Cb&t zU4?z%JfNviq6kdco5CAlxP*{-9gYXW$>(4V!w1MINvv_c4-|j9zL!O#TE-mNQ!(gB zMin@2kGql&3~ICNFnWL0EZ0bk_83VVqnb|&6Z@=gzWJhQ*F7WoNWPlWJqfZpSpp0b6RVI z@caI6$JjbfueXAHMnQ7zo-njpf=g)qW4zUNfjD zl&%>l`~1RRDtJjla^QrbF%^Lee?DUI2a}E;0Mc)@n;jK+a{}WYul8r$!gJ6=0ol}P zNCjh@^3Ut9C5)zN@kn2jHg&>L#uDIU%0mMO{5<_xcH6rC9i@}WjKWI!GgpUd(fejUIY z{dFc%#6l6Z&tG#e0)n&lyvoxx+WUtc#+!Qqnm-gf@cuinfQ21UcTl@D$t(Zu5}8gk zq)b}`;tV2y@kAG0L00%RXeJqedtmHxk$)e3Jid1Nf+ZOCUt}%hBlr(z3a*|qCA(*# zqnK^EWw9XdD<1p(V71bavF0yU>Ud#yjq5;E2s!QQIV3tVx690Aime!up`HLnPZiLY zn>VvzHDOiA`KQbHlF6FhIpNpz5{n9W0ArRk@+Lzw`JGlEW-8C;=J1dJ7mqFzaTy#8 z>O!aplfUGp6n?@wD<4!FD z`BG5{lu%H3$K>MHFx}}Mt9oCR)mUJi4SI|-V)(&$Tbzbxg|lQ84d+*L_>N9?Gb)|8 zwmuF|nM*~s-Wh6X%p!^B&6&{Yw#1k7DodrOQ|3~^;OgGrM^FAUo}M!w=a}H+q;)GR zl%gMqk#P)X4ic-c=D0EaLb9;gTo7fECJqnVg0DPKjHE736sYI`1cg^K035^kk3}TN z`zVd+-A+uCH}qkV%289&Ni>8!YIr7`tupglTfg44i*mc;I1ja)bPv zR8t24ItQ2QKPeCXp7MAhflV=F%=P8UDncej!2RYY&Hd(G30RO1ajOl<69a@=pNiJc z|DLa54i_%;TQ{!sTf;xTRM3wCS~27_&~x(s?$4|wY9DlJ<>prJg$q|mTKE{k74@1=@;_R#@3NJ2m zHe~l`kg(<)bNlZT9xu*9#QSj!_s-kX&G)_MWg-$zKnRMbuM^KEu343!2&W7uUtZX{ zy+EU#eD9vd&qf!w8{0XAA!O0aOS0+P&X1E+A2e~`GgmcL|6}~7WlQZ%-Q=k!Yd<|2 z^Y7GCqi54sx%O%FYm7|?W?1eb5P*Qdj(e~m%i(@t3RHr;*3=1)PIr!djx-`?HLjn&h z41wZcScn`wk4;OLFygz4`FvSl;WWUopG(Yvpo8pL#tE5N;lYv|`vO9mHAX0J5{?Ha zzAm)v1368gqO=JiqM7BhROZDzuc5}xD~r{;y8r?6Oses=Xf2+JRf^|iBLHi_4MRi| z%pf1$g8z(js{Xi-X4UDQ&R&C{xiv1|OXO5YJ+cIb6M%1|+wOJIuy^XE2zg0a`g zr7xR|gg_FxW2H5-8wLO|>x?``a`7-{u_sRWL?(#kZZEW}VWz8Y-51SMtnt;J?g z7xd*XYa>NSHCn-R?MJ=}(W@M^oEqu-88q0}659sDUdAmk5uV8k;o|CDfIY5Ho>m~! z!fe6%I-QSH>+N~NnX#$d7(RW-g=0KwTe+wuk375vI6PcBX=KMb=E;~xzTD01OO-ilQQn2 zIP9`X{&yqiN2&*NMp%S9`q=48+i#GglRCsn>5cLA4A89}VdR`dj9=E_K_bt0BaJzO z@u*3@cL|AjDxeQgXIwPk{15m-gvXGQ>*n+%IkjjW4*U%w5n$V-YdXugM1nK^v{YiG zP4UI{IA7_RLy1M=VV+)8K$^J76p~LYh61B_1FcuC8cg*>p3)12^795{pIVd$jVK9; z$jEb~qehTw2f+6b$dG0`z5hiK68P-LRuT33@r5g2TY$<`Z3~>bgKX0&P5sm<5y%un z1m#Gz7|2=rjmt1VYdU+hM`Pn5(IKBEXxuI^wqFk&8J}4$y3+rtu*U9Zbq2Hy8$D*| z7U_NDyEJ(8wHXF^*U*XE-HtpQd6O8Rkt5v|4JtQ&(8!#Bb3NKM!us4UOD^ZyZjau4 z+f(3y&FqD-1^3}`S+>!^q+6oMeJX)yMOc;KlK8#H??G4<1v{;B6k`JNax{z(-1RZ_ z=S0)XH_8NBl72~f*)7`Wf*=5y6RkWL02@C@66jlh`prKX5lBpA%c2(7UEXR@*Gf^U zxxY-9P2+GfuQnfQL}uK)x|aL8{PUn`msyT%9Y*Q4;27pmA_p})cW#T5) zHAU194#PlKmy<@}B=yfucP9P*-zJ|z!Osmpjp0TH(AQ+m1p48k?P0zx z0Q*TZ!-H3E&eu3~dYqnG0EHBP?vx$0{tE|r<}U84p*zElzar}TN6A)zFuImW{m=td zN!yPGsvBwZk#TF-;V4TGXrPE8c}^Q2ejR(7fZecU-$&On7{y1=L%SH3MYzX#NW1>OXw zn>a2q11ef;^k^(S=D<32yI*HU3iUXn z$4XsKL$)m1mw;2ojrq_qPdm`<6_sIX8-q|q__Q5<-OA^?}8yq^0GRhY+in*sI6WkmU??ePC-@NmFHsHSJ&OL^-xOTDQpaR0aoz zfU0FtRA71y0)vDd$&}2)16QND--;Pt0&F+=1(V_U1cVz z2!_Me?NNIuG%z0xP`#RGgEoS-?6hnv-8gGLYlalwzYnWzFrCLX%=hLobRU@|NhqvE z%Ur%3gl`kB+GS8u&kU)a+!NnSa#I09Ney{3_ofM@oHS}}ZMqJtIvMB9q}-%KCR}{F z<=i^;$0;yIr>;=0JCPRmBHs=vz7&B-ZX7aNg^V1PI(#OKvq0D};tt4cD-YG0XcN^K zOOY5KzbCf|kd#8pfzDAQEOZ`pX?sk-8iFgO3*2drW(Z;v{hV`3^g>um#Hp&bP{XwP z?^xzZ$#CFC<84#NtYJp))ALgO&aPdsbs7g2yfC2?<5fD6SwAWEObMgXbf8TSoP-Ar zbrALjNNq%)IH4z)RIU#2x4`58Nus4WFGP1B0R+?P|#ElIf)O5aozCrhs)Kx|0|6tX5=k=#IZOLKWa)EV8% zjq7_t2?I3glSb8rL#qHMU$|W>0N$dK5`ody2y3bOumiNxc=&c05Ftr828aS}BOC*+ z{b?5kuI^doZkBty_1D!LhSvz1+bkdSvw!^0kFolega>Cbe;gsVw(%jl4SL=u z2F}mfT=qrHFCB830JL3{$z-%fvb#(E`;yTE$`E&1ED)*OmIxiu%LH>bu@C^jO6>Hj zZg;culXh58Db@^M+a+f%%wxk-vvN{#`+}YOq}StB+cW1YgKP1BP8}XTIC9)f21byn5m^cz(rYGQDseh0cM>!_G46U5Xxw`Nnb0E!j;+0lkW8*>V?E2yJ>y@Vw)^&?IE10$iv*yrWkW4|({deBX&fU%5S6(FWxJ-uvM_-x-t*Olna@ zA)2{PE{2Cbt+iM>U706?C12k5@#%58%eN&})1S?m^BbK_e=I{At{PWZfKQqq_q?IL zmj7Wr4^rF3bIUUIzbm1XS%+3O91;wg`Q=J48CtiMX9f0MtChPGz+T7Pd9>UK2adSQ zqM1GBH_YnHAPdL`36aJmCj>)ix!HpIYR;2#J43EJn{syp1OJh{kGIJ!!g;0N*#H%g z0Dfotu8M@D zKm%#l=^F2u_`Sa3;>jLP;|Esb0CmILUEoZ>6&2n_H!o&4`@ioWysU&`nve+C-err% zOW!njTgC8{Fg{~i!GH@TJzzKgp)YQKFh-9W*0g52(9bAANhZ)nV%!qYI&!R-qj4FW z0-zUvN@}F-E@)7@`Os5}3N4#!1aLBqKb~YP&ufSS?E)$yE8$Rye! zO>RPax@3jmzhiD^J6$0W@0bPfw*1;U8%k^P?3xINS}#H33g7v4JLYyVgx?bdM$<9m zqhv2Y1MAQHo3)o*9b7AB33_-d$q61*_!imi)SkhAd?@8VJIQ%>J1+Wdyfx~6D%Ya> zh6nV#u%%inBnGfSVsi9YwiPN1y8UXb1qd*7`J*vo80AqREQSfu^`qM|Vp1?t#Kfd& zOt8aDO}J^w(Hty6@Y=Q-9@AfowTg3SlQ6_5i$Zy^{+-G@+agC>>cl1}x;igC;`O>& zoC0pW<>kKiN`np#xn%6E%UD6Q)g^DmpzVY`ybzGRiS4w%wl0U=8L1B@doxBs8H1_#6cQYmF;X8ynC8vk$WetQD3 z?^^w!&A>D=_idOCKc%(SgF24Akt&sH8$2R}WF;v;NWiJ*>wKA)nE($*sO;7z3HcGj zG(kVINXmOW2#=y`{OFIe<$4@GF6pRV~J&Vfo0=I2)&Ee zYAt@^{;utms$VN5Q+ZeRWg+A)_KK`6S;lLZKG*6C+G*qS+%77-sqg*)avqzlV|dZe z(2H^dm2pfk3;F4&y}e(b&&wJ8x&N#2_%iFxCA9H<_E2;mtrNd}#_p=oq>R={Z9DP) zP|wzv>mtam5k)*xCM6knpeF_~a;~N<1)L&g=ojg1%c8*$F^h#C>{7o%ruoTU*9vR8k@^~Fwv;or71HFf_eozxEAF!*GWa0jE* zpj#qj;iv1vT#R2Iko|*USibN553VkV4xrwCVE#pmM(ZiCSg_&cTW~zLWVM&K`##!l z8MG|vHzYLOMLpEY5@)*+{1^&6(-_7XhW`D4nBFJ)( zK8ci6gHTDe?Phm;iKZ}jeo_#lArk!E30t5;`OLs+*{9c=dcZYtFy8+53j1X1sp%F@nC@7q34@6VA{bXNdqL zMm(T@x0M%ca^_a~SR<=0Wd3~AZH_^vzG}Xop7W>avLaACt859hk&H^ydeU{C{yS^ygWHr_K5Q9~5zxk-!IME>e=qm52z-1ebT(Q;L zb&U!2&?8?wnvXmitv8~9YUqbEIc+pfvS%w;a?y%wp#k8f&Huyx4|#SBB45YfW1kJa zcOHZZJNxL;lwH|zAA2J!gKSLJ2-Q1O1x<*mHU*HK;(%ka|cr$zc3v@ z79=CYEym2DAMCCWzxvs?8$KIA0D=#3b>``#Yalf35wSlF#+!`|`xLxq{qkNguXLOz z;$D1$XMf?4P0}i+Oc}bKyDxs!`zfoIoA2VPx<6VsR~uUNl!4y?QtR8TB5`FN1xl}* zHjS2KwZ;<>gzh0!D1C-IFbLUs0UJohwhB3amH`{s5^=sjuoga{vd{9rUg|qHP&db{ z0j+RL#Ifg)V*R_s+y&oggZU+{-qZw8Ot(2wmPi&oKKdtw?hycb9Bc_iL)9n*&qf}Fgy|N@1P5I6|;MQ5p%!mf04&M2vr{ArKr-6zcu%)5(9ZrsGF*`QCuaP zW6BqB{oJ+fq0G7mWL!5i4e=T-d2UiM+oyuN%@-M80WGsLLlE=cx5CAsU$0;s% zRv&NxByn5!1B%t|1RC{ZD5J4$@U2ys_`XKB;#6>|P5<{2MQP2=x-))NXk!M1mGM0A zlA~JlfBkwpA+BN3-&O%-#vxW5Smk$hRbXH@{s0LstnL;P)BkHYDBag682CTMfl5GZ zPmbL%6!{x-e}-4@{5=5v$cu4=UWj`re^TaRA{&L@Qn~}h zfr)fY{&OQtNZ3QgH+N@LZlKI4!rMaK;En2C5y-U^3*bxzPql!bXSqu*BM-V~lu&V3 zh@%A`a5o#HK>C$CV5rx?zYG)xl#UGsupK*>EjDc|G`d>Oj3#a|dzqxYE+%c{JozDy zdnZf`98xay#$eWHkz6vyq@i{v_n*m2dKEFDph4?Tn)WgzA-0+@-Dm;`$HG~jGp8rK zKPop{)6eR~N%=L?sORS;k~Lq6oBm_F`vxQMN+|bFTkjDIFL>;ONj~5O{~O}mat0Md zt75v@738TvSgJNgd50=o{G8c=3a^aQe+2sj4hxWtxOY8jPM{h&iZ|B4$2pZHmX8IX z^a$g*5J6-XDf#TPopMDSMzUjC#y{bF72=LZ%?7eq=NZy?KTjC!pAq_rsa`>;MUNll z{asV_2;Tl)bgc4c{G2IXAAc}wrhal2w+RBKo{*fq`yE5!0-|b2`{GAwG6~k^l=QAR zO?U-Z)4tISF+eG13?TahbHsJj_@DUYWc}ap%fa%0#qTNhrrWZ_E3f`xyV~4A(QE0~ zLqeE9IB*Cgrr9R|{E%9Z+_usdwDjBjJgusw>!ex)q~Y19?;l11%fZoN2GvN68^Jt3$wMl`qN$!hSoQbN?=@M@k!P37 zU-ixpeUj(zqJJ*F60YyBQ>Mvd)hEGR+1-&bpZ5IEglS+fxNGCYIbMZ!c-+w>NoNpc zMHHIbkK14f@m#3317Hd?j_kjgcr0hV!Cr9pERn}0*iRF2lIBcoWxm;cqoK6PseLs9 zbIY_5*ozPPFm^1h9 z1iK5-*{Zvv2(U%cZFF)(!qv}8)8Ht&paGbp4x!^kLJ7y`>BEcwsvbTZXuUn0kH2NF zkdQ;TA`1o)DG^dolLV$2L0_aS4MLlf3dX7|S7b5;L}_N2GN=I_sKFD6Wb6SR*x?(% z%C?{$m`Tuxf+bR80ZkMOB||liVgkHoO+y?$)V3+Utnz&IYthF%k$*i0%Pg6l>G?~m z;BRKRKZhva%7so1TEmGcxR2Q#_s9ztoRY~ls4B>X32LWE!ZFDb%6mi;EG48kmWxTD z?HUIQAvl}R{D(v!QUsBbwW&lEPP7bfLptO^`tZ#hF`10PjKh%39x}(;p1JT4%tMK)P!wlV>VY_^0$pB_-AgjY3WOr4!7-8$V2N_~L zdhUp}%8=C9$rha<@1CE|7IqJQP)DK+k1E82vY7aw#0oI`Kx#h3K(>kIF^I)RENc-K z%TVwd>DAj4VUEC3WpD$Yq;ItWEUTp%w9OD<=;9=v}a%mYi?25=iC3G~>BDNPE| zkpBy2zkjfRqR)?GI7+bdp$eO;ouwiA+PqjDLYc4N z_HQvywy&e{bYb5MV5TSJ+FRVstUUg)CfCp8?u<>UmN+sz&xvf{Pl`Q?xXiXWqz+;^ zMG828qQs(ys;I}_@-<=RxDhOyDrh_I32}T&^ zsVgB}rVhLh^BI^py{&fP8IVF&vl?`~S|4xX@PlQ; zo8aJ`s!El2HiK{$=%_leZ579ptO5$c?gHRKp6y&gcwp3`fH1|0K0%Mtt={#8nFzf|2fc=YyS87<5nSvh< z0jh`4W}7^VjS5!@7Rr`Sc$R?uu7V1p5*p2p0?=go4szv2tL%q+#I{?yhW43np4lbO z4|QfyoFWkdI|?PPX)Ck;zIn=T>+^$JX>;buzv`E%4+zvN^{&HCfBSaV4)N=1ETsVz zMUC{T?dxk8fu~I>&1Vg&7!)*JY1BQX0?*<_JST#>q?osSup5(2w97gIP+^30hhuF> zeyxn~>erbr4(u9M;1o*^x1rYXP3x&v-q!MN(Sz;9=5F{vqvpbHTIcmhkojD_s(c%Y1>0@CL;+zGkA!04nY02Oc!1o-O6OI&xtP!>-s8ana2 zqnpV=vsD$LBnXhE-D#`U+N!hU<8X5R?rAmC*|iBDUzNL>crbVO;U?JoGMqB%wVul4 z4YQu;()j@>O2pMNK|Jo>*4x9{QT&FCFO@?3pX)GMRvS0hyOYFblA_7;l6R?Qk6Pt-7(W7Qa*MC}ga zc$V5kKds|Wj+b+7lNKFP^=T}jd%(0L145Hh!J0ZYZk4A~D<4&wEmb2jwXkS90OiFm zmS%ISyYfw_l2V>2Ao?g~#)tbVE(b$I7j;WCx?$=EMpaBw50vq-B3IcvX9ySqg|`iG z06@YwpQa5Bmi~_GQ?EPeni%U53fugu%rCKs=hQ-@Wy6B%Mv1a(NmjOLW%WCy-n=vj z1uDw6Vgut1se3X{#mO!m`lGkzktr~!NI(r~<(q#v+sq78+~4IV_t8SKXmU|Cy;ucc zYU5d7wB!U^FN`GivO9sqy1U%E&85N%tSiJ-wWEu7M|$}?`E}FCy3U*yrVU>~j*RJIzSDnm#_5<)uqy_{){S%LsVU1+|i4z7noH)5q^e&pW=( zZZKSsB|dHyme1YZuMeA#i2JW>+TDBA$tb~ucR)Zr|JI;{({!QH}-7J!v_ce+>A5q zNT5<|(O$-Jwy*d>%u@&|d7u%5V5bS9okQg+zkgIO4G-R&x#)2x<1iwiN3MN{;1ZX* z=EmSW$ATFf11Ln@=AK;PL_Rp$H_>xmbMR7T3$C%4-Q*)Lby*$ZbPmUov+k2##r^^l z4~hgPu3=%eU-tw0*oV3Z4hgsHU%SFV&beiR0({`wvnW#RAq6y-HTA`jWKOTFiKV-ztgM&J~HQ%@+cBtHw>9s=m)F0!z2_#hYzxEdPE_|9`98 zlT2n1=2B+&>Y1t`Q0bV&5a~SL{WE@9KMJn>q|drLe&L+c?$Qal>oTM(qKT9m%^I?>_lo zZ>D=Gt95b0S%(QSR7M%0g+B@W%2Pt@l;OKLj8d(+d!=AbA1f-?tpsB zy5V=uk|7ZQ9?0se++fM2W@^Fe zz)MSsApO<>QXLtotiMy5&3tQxri!-h7mSacl|%QyOr8%x_{P!(-X2^9+tS{=#*tq< zeD~1{L!7w1vWKV*1fbGDYq7GLN_->N!(BQei7#^~r@A*;M+!9I^;O4qn9on+wArZP zSFqp71*AS-9rT1An>&yj~Xn7EX?aq`w$fhIlg1wB> zYZ-rS8Yo|67TA1FEwFA#PfTzGqn`wXgb-qprMU;r{UjL9nzwY>LqzPZ6#97?L(h92 zNO2#B+>u|E(lDY>2{(Wy_*+xD@3w2AnCU6@Z`n1J)$Z zPN2Ib1B6#ZJaXwlwubAcx}DS68@8?$ZM*I70D-D~vGi}(<5;M8zpJZ{4HQQT7BG4R zkIUl^w?zA)Wj@O4hnBhf=(%;^)^LeA06ZdvB9Q{Z*!8Ox)DT#;W`9&i(j?G-s*TkS z=4YbVZGr|fp&PTzpyowfqC#4adQpOzu#^&?5E+{c8jDa(+g15 ziQ!$6NVDC0)di!J0>})9Z9A@!n<0=Kis!Ax;)l1892gR8VZCw#fyj&hrZ71sm>n== zBVvXEpxyG4o^7;QuBW7(F+~*D4ne&PzCr7I_`Rfq)2-=S!XL`);8ySK{$c!{7K-9Qi8 zdNK)xc;F5l!VzDqoD>pfJNauZ?a8AO!|lYtA3SkU=wZ}|RWu$IN8L|fX?Qa@n!E{n z?rS|=0l*>up!`l6ha=7ldppQ4Fj_wF@3pxU20pWAKg>4OppSRK&B_Y2sh8Ma@Pfm^ z4=UYZf(gyB9U5fdKvZrY#g>F@N_6kzv&%x3v;n~1bjnz}Oa-;ceTV=?U?&*sys&e= z?-Pvm)WvW2{=xk&&k1F@?uDQwglDZq7tw~gnBov}%LeFahuRW@;;`olAWJJ}JvT0Z z9gb~B&FKfH1eLTCrxA*HdY^0h?C6?KM;QGp!Xws`k)Qcwf&>Yz9ZH^vGwq!h zmbxQSQu6oWg!k#j`@+0a(wDV*2pVFEV3q6W1tXtkk@FbC4ef1YW@sGB1C9#33Bnc+ zF++D;q9#0rLH?1HXjCU0-B@f1$}1r}j%UWTo|o+Q(7MtX9VLe;Ey=2V64myTICJmV zk2_bFyu?bb8P9M`2w!+_$@r6e>2)`+HaUdvSj>|ri)~S_^~}^`9=jVR{d$sInggim zN?+c7-hwUkWjz+IfV#W8Du_>TQLFV5($WJb{m2SfVN@M$@6)(g-4O3DrOu3ztGOJG(HZ1A!`lkF0j0G!9@kx=%*xvkJ)D#1&pjgXeZ{1cWD zpE2^&D9K+JXnwV=P>y7?hqv`J{w`v!6QM!)z5|FVjvC?UR@mBa2=MPnjLfpbugUR8 za_(Va6g@U;Au-6o%cFu#w65~si(6ethHrb+@<#Q0fxX1UbCJkP**q>`l;X1Ucp@lRZO&{xgGkCz5(qTtKwb7@(=?(z zLjUjGteuHAu~bZYpY8(-vJi^h0S*l5iueMsmp zLDcQJS%YpJ68jh^_Hz^87^Z$6Hmq@kB*vKq4CBe#iV}z-onacW;vi4cb@SYD-XF!o zJ7PP|H0Z(_8oq)7w!jw|f@w;Yxs-?M_a4rZXtD-fQ`1fS@3w4qpjT^u2mnJ$vU1#F zlAs$BJZG2O@0C=KDmmfMK!YJ3{We%P0N3pO5i8l@CTjk7sJjgZO7xWSE7E5IVv_s^ z#!PC<;MWRL;Z#S!}>%dSyIVPvQ(BVk!?m~N!houO@(Y(vM<9(BzudJ zbwaXJAv;MZ+hmFCTa0}$c4p=|qdlJM`+ctKdH#5=>*qh-?{m(5?)|*(`@B(m*^mhybGGb@EX*2)aiet%->{{ocK3?+vX=%tSFL%j%V`Yu4@yBNFQ zd0|+(?RxgDH$%rGkBEA)=x-GF9S=QHc!NdXiArCpY?<`*#cWka!s3RKqpUPPdu(4% z!=cP1gEPqs1hSW^56lil&mDJ(Ka@-6__9;|=E@iMB7-VZVG@l4`V{GBLGbfEtUDTc-ep$eS^)2Aric47CLXo_Yt;1v^0asIr0e!ysDH)lK4%oj zsPybwAlKZQ-hhr`;0IWzw`hOsLCd30ufL+@mM_yfdT4Loy+HoZffJBBfk*cqec#qJ zdElt_XO~a%R1N7IQB&6kN9ol*1bW;&{zQ}xdxkP2CHO=wxvk?JO|{-HzL^qwo&sNQ z(U5oC?39^t1h~lnAuZodhka_62VHYmU*f_!`m;Vp;>#?Ub5f_{#;;c#zyzXPM+u@H z?w(fMBUSy2W;zZAy_;U9xjjZ*eN8L9wiAiXSv<@KA6QXTVufGMI+2FRq-fBN9j<1$ z-Ac*oWOHJ;&!yuP--|<>%IBmNzdF6Pa&p|0^*FiaZD10a)n=kdtFFMg$mQ!X@5J&} zg5FRok&LS!vVoUBz0mN~aLlVHlZGZVGd$&N#z%whThCdx4u_4#s%TW_3ybWFq$3Fl zCKcJ99XB5uk;$@1})WhteQ$uJpq zp`jVat+_YykrZeZojW{Dv?n{{o=5DN(RlGGFl0P3`J#B*vh|HI%ggj?YV!2hnH+WM z$$OE#78LUo()Wi|Si73Y1;vdD-05bW7za2alCG)iR-{FZRpxOiEl=YTr2_WZL>^yP z{hD-NtewiUbFkmiM#bXYn-3AV zAEgooJrPpXYoA2Wy!1`#Da}D}(c1+v#0bk+~J#Eq4P6t(TEYhsIDhIg-s*fG9N|IGbK zt?>RKi*_CTI**SxDV|PG(Z*`^T{TE*^#8UZXr#7L7uFE{8q-9gY0O44CGl$Hpk>Ip zamVYy9ql&h%=g@&=+Vox%Co)Htsci(pE0-?;yxxHVK;bRfaamN?u4lfWQl2wwPE)*s4>0NMMh)&{*6JfGYc{RJV5w~96!)Z}F z%oo?sQMlC(yCu=v@?i-&mNXFYEYcU|uob3bC~Qmpr3H2y`RIKD`}0ldyv?fOHG^^k zdA!+ZJ~BER*S_LaX@X&+PLiHySJ+48P$0eHIe&Aw=u^(%OT=56UaCdfP?1pHBfWfM zdh1JS?H_!KOmg0zjvPLSyqpDNFRxScZAjM&UY`*Gj7GK5mqSn9GSZZvUD z`#(M87-gnrkw|n4Hz}>lzS}3?ahQDU4U3(-D$gX$FQyazanI@zw=*|Lx*Rn<#mz}A zuEZN1ln%W;A4`(QNP{^}Bh)5xbYD}L$vXoP&;2lT6)ALIf#Ok?=Q z$85fcLsu;-tf?0uwiM2`_CTCAWr?pqZ1=cS2lk=x)o@rc^vUp-eM&`lRcP!|3&r zXO@o2ub8nBTDdyv5$v|BjnZ@t7h3c_@+fqW7Tr^SQPLj3#d4cHGTWn?&M4$4le*>; zZz}aG!B<`*6%LYQojIi9*Z8&Zy|G4Ta(6y+0ST41&iR(HLq||k^>=OsvdXL(9I6%7 zxSr^8;q1Qg>hjUDnE5#WfR8FN#|s;IVCM1|yq?(o7|I$Z>%IraBY2Jb z_JF`Jr4>7DbPu2Y3yqH-vDy{y_IRDI)u7YcPzcjd*E@0bz+t63w}Uf=f(gr-3=#V; z$fBU(;@Z79`&{ND4B(fZK~gDs9-7^xGBEx5`)fzkGxjL5SK7dKBrq*?TIH*p#J z-`D#T%C|2XZJrU6_3(HUA@Df3ZKXqGIGC`ne=d{Damx^wm=P15m1&(8v%tXGFNwB3 z!AL6A9836`JHwrrH-kyk$DdM+N)ha=ldgh(l5T50A%CrdKyr)Qu5@QoBJxK{qU-5s z!6Bq^!$!x&j#5aS1Ke>Vek{t#*@ffW8IfO~k`fk|_+NiYD(9+^gN`JvFDh@It9;Gl zrc7n_sS|>yI0OzQ%goqM?v3(0^_4U=;{$#Z$0L?j5`V(JMq!p7i5e||n?-7TB1=>E zza+?QV4@b`KYAkK!)<|xsioWJpXC` z_&p|8(nsdetw+k=^NH3S_EF`^ydorb=IG(3h_FCgN7_q|D|wK)FG zBAr1vFJR(u@}(v&{>x-o&XCOeI!$?!l7X>%LNk)zSbHmUpZv2aPAB&N%y3> z_DAaD+|?%?17Dn?*5N*|^lmduk^jt*mh)C-e)*@~vt&lluBm9VTn-JWJ;`x2Oisry z|N3z@+Jmpp?W^9)QZ}C=_uojwJH_k}iQB@=BidnH{uhN6p#ObJmh|J`)IY$xcGUK) z&6Q)dXF|yNE;_~^G9!DXd9qvOyw#`94-_>?*XaBY6dmFV_;@UDsAVbiiig|>3gZrz zup43IqXAl6%#CEBPp!DF6ub*36|z3UmJnY&*W@achDpoy9%eI%6ha%c2dCA~eR|xQ zLi30ozZgXOKIW{M?RaP373}DO`u=i(;heMircE@qkQFm_?^c7(%Q-Puob8%}Xzwn= zHr(^S40E>Tvw3w_70<=nI#neO5xn1TD7`4P>_MDBpuJrBZ&Fu55LEa>>hdf!_1T^E_ zO%;uvzrLB5bg}Cgg^%?eN?wP;&k#Nv1sd`co<+Js*j?R@6Ob`n>6_#G(&ul?UyN-! z&K=p1M7BR`&Ce)?Dl7dByn4++^sX{Guh_# z7rt1@&)9wvxN-Y7_Mos_Mv;o7{Q&`mI~)bDTiWKdj|9mgy;Vc67#qV|%TG_b@$_6f zWOt$`*WCD`d0(}5Btx`!`2zOY2mb4#!r0qiF6TA}m+Y%h3sp^Y>N`M2_n@4Nx*Bqf z`WpA~wo(uO|$OPQRm0GP1WstXokKi97I!kcFXL=TpVQO zZp}6Na4H95RjN8ouKo1Ae!n>~drqTgZqi&N@SU^e;OEOJ;eiYo<^JyGwDplyq`_l{ zl(}w~6;Bx>epwS zvK`~;NSX7gzP*z=_N2-`*ex%EdyZU3ZvRupvR=1tk8?Dg=PE}V{5J0256bATpF*G z$;NR*WBL&THd8qNhmMTgPR9=)<76cWY7fK-=*BdU| zLH?rMbxR9MAp>i3@NGz8Nf8j}RV=J6tz0;S#l*lb^42cSnift94t9oo&G|;#g)1`5<}FL{M3SzUH=;A&2JDNJ{EytQ+!w3Ca5>|v1q z5ZB0^`^OabJQh=`Q6Qr`FUHh*L6eT0^gQ`DVVUu!%F)>I?CYq+>*uKAeej#utJ%56 z=QfSkiw2QZ1owpa;Nv9!=jZ=$;Qw&o|A!p#tI2u$)@=@%bLI628IgYKmXIT-LwqjT z+2#C4`7ygr`0IY~S3d=_xxNxgJ~;vv_)RXT9lCeJa{F~Moah6K)B4#~5Sr5)Wlx?8 znn*36mnI)ZcWE_JnCD8n%m z@(E+|M=2l_m3#M8laoRN<)@5DniMXbyrN3RPRdRTmz!@_AW+c`$P`5Rr*_=&8`?{p zm1zRG=Y3qzQ|(t#QnbXE_Ec-^#fi#!U_Wb9KGoh{8p5$={T#86!hy|DwOV^hj`$K- z;ouUAWVm^I1y%|Q@RHw9+8sP2N4clfNwP~B+hnlQZi#({?hTNj5lFpu(v^ew(q4b$ zz70#i`SGOG!~Ch#q(6uQeEI*LIF%<~vJ3{{*k%}EqYH|g)Nr5eDZCUdDC@P9o|{{_ z(qk)e>+3bJPvQ>QNn!Kn=TG0qGpa;7qhyP8rXg$9Gy|?(CPdPS%sM|0cw41CfxNSH z4!6|KY}xuynN8CCyNR(hbRj zkDXSlnW`pd7u)gFr5NlI6))-B^h}sbIU-)hb8%}OJ3Tc;jkn`fi|r80wl5yuN0cy8 zY#eJq22k7*>Vd`CvuE=yQI#`q8r7v|3+($#kEESPLVQ%cYb2e=1~-ptHrdM`H)JY^8{#v@5UsO^X^e0)2%OgzJ&&PrU`~x@`Q5Ru3QIS zY+OQ5M~ZSJU{Cfp#7y;Fa)}C54Gr|xFSZW??My@B8AYt#2kf_Fs3q@#GEfHQqabDL z(k!<>(6JNN>Ni(0gB9MZ<5h$^H1KPm9&vMTi3-6g%NJys2Zz6!MfT@gMiLc~s9oD8 zg!J98{P_F>z621#y@~<^*yHsXj2){>U8iI5>6&~^iLzzxn1Na-?rS~dtwrP0y)zx^uQeM+4{*Ldv!?`>bszzj5)a0=y@7 zyt%nb+N&d7O8};$qtjmGFgRkH3!0Tyst`$_RI2DLSO0|ciLArGn*AVd z&OI)1<2^&=E1ylb--7k0@rRvULdiXD+d#`FeFl3TxqIL!ztw2%-hEVjl}-&&CpUet zVA2DE#m^^~c@tc;fz$fUwrcX~JdO>^=NGMU*kr}f5RK*OuDn7Ts6>poo#8_5UUKR_ zOdMf|9BSpY5X_tSwFPzQyDy=H{BQ_Cct$I2Ljrt#ESdqyB;{h&D`Z+^Z?aH}@gCgl z!Ha~43RM>q0qy`il zgJNk}lx&0UeX9N43s$8}BJc2UFQI@|2|(IXFGasatX+FNToc5=zCN&8$*|deNvNV= zGSX{lLKuPHf)*sdd^zriw_%pBhbOTz)q5-;?Izn&jZSxOEKi3L>}|GCMt7+GaQgx# zrMd*Fs7`3r+DK@vaw$;JL}jo8132(AoUfsFcsn+`bE|#MG!uDG8^@$rp9j2uc@FCWqYw7qfY9CL{hQIT-gppv}bUlnofkRwb4ihcFDQo(H`u_ zjtrg9$arAGLFl}fh<#uxz60Q`TojasNo-<%>kAwNr^f$al;q!~936;3>{FdX(kyNw zQIhC*-2^!mXryZvd&t3VuJ8T@1cJkq84i1>l>noA3T;CpUBIrBlCD=pQn5qI*FJip z6D-*_-06P^g_H%36aD-m0Pl%zNdT@>5K?%(<{l0r;xh4hWIC^SgcAF?DvpHcrD@?D zLqw4ir4Y^py9&4Ieq*uegNv?QN*$R_(?HtqoVNhK9wyqt2RUokQ-G_ibQr9x?Sd|M z=!_>JB=!&;Gsl|TyXqh6Csa1`W<&>#MEC;bt@8E3VnPSr+8HqtD%ataUNAj9y$#Fn z+Q812Unz)E7JQKi)(%u7hrx<$l2y&_`57rCyckC&!u4SNMIrpj@F=#B$u!4z4mjq8ju9%qg_b5L{H^&*kuNK+& z|7~p?_8Z-=@#|xs2-(0GOg`bTFV*5o-DVBpZomB37B7vd>leMBejY@~Lw#;G*$7k5 zX;0uI-tdE4^X{8W(r&kIGcC<@W8DR^jZ5AXx|hwBPRAlV7Xukgi-FU62|aa~(s{gT zvcJrILBo4_O3%p1_P5=0RIBmUZlDJVGdFAO_%9s}Kt#wSUm|$YfBc6Dm}N)h13O^%h~TYhWM&Oy=$2@I*?a zX|ZF{Pd_I&f5-gb!Pa@R8#wHv$r|71=M|E;r?L*y|JHn3c2S$kmh|)$L@%D!;Q-kiHoaX9TKp;@0RwwiLFGc z6N*WYQUU222~PBfxkhyhAy}r7t4+s}!2*7G#_y>ldBaR42F%`{-$L9-JRAb6$ji(; z^?O{q2`qFORp#p%jAwj4^3AD4JqIMlKP-@OYi0=$zB=y+m{hy4Zu!n`JMFsD5u#ZJyt+qTs{dGXi4^ek_V1lDj;DcyJFaDn8=H_?+e!Us}&+E5L6Fx($@|A!V?)$Ax zYRw@aVp!(r+0!XHWjpX&L>N@Ksb7D}&tpGD=Q->@OvJ zcfIh2S2==>&)8N7&9SC^;o2T7P>~t6;W=os66Trcr zrZ8S57`%!S4Y~kyNAtHu-C|qw1tO3YBTgg#TguL>k_-5CiD;#NUVoVgn9X3!4Ct3n z`em+fVz!Ea&U|VnO7ie;Nygnqf<;gJviYC!M#ej65Bo{-uE(!KNFw@sh13L%w!PuN)92fAH- z+3WYpwU0i5?Y?~co}v=$VEYVNr1kyysdIYTEVj*Q?AzNM=Ni&ne=n|Jd*mv(eD~IOo~v>!h%s2ixBC?`Pul zV_D-MSMhcaBJSt=QQJP{`$72m4H;*HayT0{_!==U{l`~_Dc674L>u8D&vsdH)PQgl z=pEkc3^8%pK5`F<|2VCms%zo|?2U#}^6awF;^ioy42~|l4m^kq0SfezoPH;*BEV>! z`hk%*Rvp-7UW>2V^h6HXd2sGR?~~#1F^~&J6y4pwQ{s&JkYhr~pw;_KO#Zt)emcN} zK|06?zu3u#C&TU~9t4@?rU$z)@E&;!kas@*>}~_E_Y48g)O%WiauJK1XvBaF`>`sl+&~^OuhqdeFOUUkmx0^@QwJwP1#j~0@7SX>7u=QP1xC_XaO9ka0L=`#3km#zBM*Vk>LPa+tu0I&8P zs3{Krj`7@~oNr_YfvF6yacvh}k@M9oM}U7MLwaDhY z+|u#bxtBKkDMq08NsToJcF6RBn;8VS(r6ri`A#j$&@Vbm^n`{L9J@VzI~NmxFIich z-^utFJ=pw_G3-2>RuZl0PS)#sFKu`b;8+HKs+|Ss&_QfC$Ie9UMb9kZGziLY zy$3t@scq+i1Y%h#*ll1}MA0ZwEL}&*7F6fFgX zT~=+g#oq^eX=G41xN|S2t3JG7FPlP1`*vzdo)I~4WS%whyA0G0+*qxgmF$EF=oioa*M54#=)B(tF5vlAZAy8L^6rj66Ub_K%@5*$u|e*O0?}PY_R||AYhUrH1z) zjp>)zOTCj*N*b3#qETI|p-fEw?e>@^o*m>FNLII<+9Vdc=nnLIIP3i`1Esv(6>A{C zHjrf!GdpR@$j6riutSO$YG3W5Z7rgr-?bPwuC%<}PDTWnXm`xswzq1s z8xtvJ^%1Nh<{#4_%NYQ*lQ@{(W$NAh2dJ*V?dftEq*1M+J!PkLhz!nX14nuFi%Y1V z$Q}Es%f6bSLI}J~Cvjr!F)We*Z@(}#P_dbjmbmdS5`KYj>pOldH=ndPGeCC`$^%Y6 zmBO~$^EtJvNi@exV#lIk_EuI_TL^svgK6XsgqQ-No2Z99RJrBZpMRAYdQ?Pl`QK{> zd#Zy%mbR6()hAh=b9*vY`{k)GqLwWzqBom9KIG!nNR$CsVQFb;+komHL>%vz;vl-z zSt_DSy{fOHBkH{}({bM0zQZk$&6#nG;|Drr_|_;p2H+^GbNz7yJUL|k4)>*}1%!moQS2rLSdsJ$#9U`iFHxlJNW4pD_EiAVUoCdLZjA!~eOw&E zeJNcl#jx@v_}PX5jeVBC%)*`jn2{2@lC>XJxzJbfeM&1JtkVX${D&!<5&7Tn>ur~W z1Rx#_C)30V76-Xw0P3?~(O>G?Tkh%XJz)F?!`{nyUXmwl&BblcL&U`arvNal1$V6W zC$lT}c;~mL)<8W*BvlrZhj{zFM8vVCdHp3q(iAc%#gR|cJR%~_AYiBq&xBR^9<=j) zhzGI_5n?;%c+GnN_Q!IMI_$#lv9fMkRyUCF9iG%>R5Z-jb0H&1A?Ph`1aR~SY^!p$ z!eMh|cGCy($DUOe-_HaH4PIHje3KX)Vgde)^c;_=O?#<h}@AwK=0(fLSvjdtP0y z@nvTOWrKxf15?j2U)Y~)5jI7L3^z*P*W&nzv0Ch862i31&2HhLph>A%!D!Vb5%}8t z2-o1|z^Y;8wJ(`^>o+5y__db|gN`)^YX{@c+CKdW0TL@j8Q70sfHVIPl+rK#<|q8T zUdsUEhqQr8(~69UeP8him%=?N>vexPWY8MXLs;fe6SwbMlb05YSQsHNfJ$P&;it|# z7;MX3XB06&X-+IhNQ1)a^O-4LeSmAqwpTq}7!vx4>FqTylTmYb^;r|5MQx~Egs-6S9s`u)87&`!<_yAN)evk>^ z5@6Q%^DECDn~gcoPSCRwpOqjWA2A3BI^LNY1#4b~ZvH1YZ}(suuVm52R4&|Tq=9zH z${5f45QqhXa^P4-xJ*i2tgWoRVz_F8XoMh61D@#ADN^|j&uk-{=BIHt zR#9={c2l4fv(T-TqErZ113y@#jK>!}gBZ{c@S~t{YG4C>2vE7ER4vp7RS*~We+A@E z_ne%AXTwEG4xQ+2Z;R3AJG_sEt#7MVndSC{Dqozs)Z~kl2&v3< zOceo#ft7o_{%N!zERTG_1m|KHgg}l(S|y1SHP@+ML4^ZG+@oS_Tajk*S!zANAo#07 zP5}uRimW0kNWLi9_xFQVQEGFbfPlws7Qt6Oaz&O;B!>c9hLsxab6Y&2!AS+5te#A7B1mMEeTx%hCnx!s7Zp z4aH?|(yD_*DCYO+Ziu9O3AUfzH&McPz86$B|0dn#)z)*c`iw&BQ>JQ%xPRI5`C%tC zdVUyvb@0d*jtJHg`vHC-Y*KAANdWL`ZuhT({VNT*AWwG%v6%^Ae)V$I4m3MJznUiY z7%(%#IEAnG4xX`^t*%(P3$O@G@I_nmdj5l;O@bqC@!x{g{2=9W>xaicW#-vh%N!_uH-QuR z2WaJ-HhuOOYy*b7Fi$gc7+~op5`2wV*8dH_D|8;AY>g7)Swc-G9@%Q!F=OhL^=tC$GFT*8mHEH&ezzxU zkXdbG8lYV`XMdQ;zw#dP%XyZl2_cNc`6JHkAZKsBrKQ+PyTAr|t~CxpyLA_!{C_Jd z%QHoKqK&;6TH0D_&2Teuhk5SOQ_9@#TzdUVa*~%jlXzc9k))h5>1V~E$0TEY9G19=6 zd|kqZ+$sLu;&T&_oFxGq`;*nGTQ!Du2aE84y>@AUy^WZ|TM@+4(O+(4W)Oq9$cIeZ zGSN+E*zI}!+-&RDQTh$E0o#!+>K$%qroMa_XhRdH|KRH#l)t@Y$_fs1rVtwGjfz2r zEox#x>#yYtGLtw;oHUfkE}$TVy{^}e0x{WW%s{e30Y=+96Bo~;`C0^}0n zJ8EMT?b!%5AO@US$7hbUps1ZL_x(bB(4hwOuxxU378yYk1jj#OlmgF8c6c2d60kvB zX`bd+W8u$g$52%>?mS>4mLa*KjEr#RXRF{ZuR4(LyP;QFAEhqE4EbHBpl#pyq1Nlq4EDpk+a>c zoV9;G8b~V7_xM^+%)cJXyeAvO6NMVpxIgul5)Y1P|E$kgIgFwnjg#1=sAb;qR3cfc zg1%wf{493Z_*LEX$d`#Rnh=f5kd>LkTW|j5O%T7~IjJOg<+xCCY#LdW@ebmhJNbii zB~O7vkz>M8^o}TR-q_-vd>aXj40G(*7tE0@k$)pc5S6vnecy$gfJM)a+gh{J{xH*j z?cf1tBjz$6k{MBPMtQywv>g2Hv)|kpz0`U?5%BLW^ziRa(4XQBy8Km#K@Hyv7HESh zm%0DZj`}k+De!D9)`RgN>cpWA_^R#@A^0bB54Q?YmmKJ~mH>;oqfqFqRZ!jqCjGsW zAxR_~A;`2a9I&`P=bU_RU$q8MqkmoDi!Q*7{m4svNQbpXtIeC$8b^pPflA?(@2#Mx zw3BF?*U|u`phj`_5-HSf2fwdGnj(pm@Eq`^{5Lu&Jk!SsHn?g4W^bxrgKe^0-jV-s z%3-pDl~#c~cyX=;wsAvg#O8*-60?At8YV8&CjmPOE0T-wC)P4{IIiS7DFn_K_(ed!+F?#enS6?_jGZMmP=@c6gm3To*5 zOxs++R{~Fj%5S&d{>=s5x7-rE(Wq58*Qvv#RsK6Pg3NoeODoqK1n05P)?7P(*wueG znxtLvT861rlo+3a)7^504Emr|KR5fcbNN(TZ{CHjRiF|{jg5GrJia zL>|*!e&zcOqe-X1DC@f^z#S6H55GHXEatyOeOvTg9K7Ia#1aXJ{oSrXU4))f6GYat zvD#sT|GRAWQ!d(R_O>pY)_{YugZHOa zrINNwUONw0HJq=#y+MHdwkQeZ>(N06Y}cCpJuyf%0oc2s8qAiHR=!$d^`2Oe`p5&0 z+x*{qeS>NSOj=D~mjsc#i7a-5-va)qwEQ9|$yY-Ri+sF&QnTrjN*elIQ2Eg>AG8Qk z!hUyqr*)Z?_SW9Kb4h>HB7U~xTj=v$$PeO)MuEblomcmMixWFI#Fr-TBZhttG{I%i zsz#MJD&ZGo{)1BaYW{@e1uD;z&}t&nSf$)*0Cej~aI=_2pX4t%tH{2wL@F;w40!bH-w)u|A71*WHc&t5 zDQs240c{XQ-XGYyOMhr<{HF){wI!AE<+KSxzB{j@)|X8}{oY($gIF_nber*;Y_XhI zx3tW6dhwk%4sRO7&-AWB z-Rzw&J`1F~6XPk%4uLzyIM>C{U?I*PgYE8cHiu&P3L77#kSM1=f*#yDc~w5j$(u^T zn@UB>u|`4x`MmT-31Kh@?V#W|5Pc2WhUj~7qAyLfW%9tsGFKaJR)Lc34td99LHOuw#S^A`O3l-8q(73*1 zqmGxpeYw%7EY*k}Vh>RL^njvLc$XdcO=gDFX1)e*sjMnfyP0PL|!YqyC`IvuLr+H+6>)sk?bO`qq&JTrh=cg!SasQSzi)9tuEgZEPjza~#Q zND36@nrFR&y6PTvRYnQ`#sLWVwt5bO^PFulJdtq!quLGdn%FL{y}M#lct1AHxf)$t zbHSQN;rJR4$csrDrP-XcDIK*NfP(tBkeF^vRktFVM zTM0?3dDn`$FZKpp>blox*}RG(69GrT03f3RHgGrRt;;#XO1Nh~#`5M(0A%Z7ac}K< z$G?4ZBq}`6biiPu&C$OsV`sJ_Y{0;Y;0_9ZbqSmjvtrj z5uE~GIZa4NHERWE?DU`^=+p-7<;^*u#d(xy4BrJtiX2M0eTwo!gMrIalx`t>Ay ze|;DummTuSlT^grrl!k(@}7|*>Qa3NLj&>%RY`xOQU2YaseyMuD0 zXAm{`3%Du7?z=Y3>g5XRi}S-ZUUM&uitJ;X=PFiBO_(poh$xn3eg3TZ&)iOvevSw^ z<@LVvELT0uw5({idhg*GUx@>ZwgYxp;w?fyL$?QYFzRb}_wsD7JGg;CNfb_9 zU7ffGFIcusTtj9dAQ$nmy*X@HIFu~`oH>dsK88Ot|vzwJu{)W3)s zCwk3}X$IL_Xlv<2kOBC3sr&L51K8NNZz>WQx|#3Zy&I_Twsr8CuO@G7XqXzj2)I;G z4X%8pO|u)mF%K>XFDjDQHpXpN!?8w1y74iqHUj0ETQZDG|9H_{g|vzh8h$JE8lO`)T;+s}NiJ(!`nv6uwvh7-Xy2*w{EZ zy-`ig+*EtKi3azZE|!gg>hTw;&M-GAJN@>>?yhi)y@A83=CcaZo#&pIHYrvg+!(mFTqlx~e1H!)Q( z=k|)#fvjn^mzQIX&sHJ`zJIF-UhX7AHKDazrffi{<(4Jj>pB27XkV_NA|fJsDnL{H z3&d!AJj-mI`+%pV#aJ`pHEN4r{`aI3A!83Rq~a{m>c_7i zQ4q0>snug1713ue1%18m`eHsqf#b7rw{CwKj;CRdDlRUza$g4ye9Ga$qepESI=sy} z;^N{kpJlxZ7cRizTO+lh-oVMu7CH{g(dw=}c__)nroEQv7 zevOO*A~#QrSl9I$oCQxmtxC><>o-s9#K8Sd#-x``7GMi3M;Nk|M+Ck`fxbeWbmpxZp}Qr|MFjcQW4_E~W7U>Oggbo0n+1&(D^ zwa~dAdRjbgAs@TE49?EiPnLMai^- zg~1J$EYKT(^jV1!p1uSiF5-gx#+sjFj=-|w=i2okmO^k>OiNDFCw|gf{bR*9R?AGi z*^u3GXya8B%_a9ZOglg*uNe|pSXp!WQ^9>mu$tGh&8%Qv`J!sRtTZqj@Z(4JwanRe zTxiq<_?3=@nZ=7`1*gwG*Xw!f++|GYXD7Ttmq_lDLYE>2o)y)@kpNisJ6eKY50@CQ zdTUv~f#OG!y<$4C9WQG7o}?l51%35b{ZT%rQFE56IXYLbb}vNr!KhD2^M^B@;rg)F zb9EE_aMIhIG}d*_fHhB@jL`maSc8iG!b zvWZV>TTY@xbFU9NT%?kD;+j)l*LN2n)^g7}paup8y# zv2$)$MH4t+`;h@kr>T3!iY|R`pm0NubcN6HbS~ zCxaXKa}62dRx%LpQ}S10jHL-xWI@I&Jjy2g>A8A_=JVjxL7}sWzCBuJy-GtTZRVCfee+gF!;98CuBB3`-ZJiK?{NEM6zAErrjb*GXIbpIVbjq54>a@z?g!#ZaZ8n=C0!Oy(^L>J_PPxr zTRWrFWiq*6SVTk{RmY_i_St?CjjsQlUCPZbKy3=oO`VRHp5#XwgwdrkHf1oT>rQ=W z(#Gs%s27mAWr*&p7hW@|IyN}pXqnIKo{_J(4_qrII{wbJg?jcv=?JE?s$JZlhlhuI zntO^IU{|hu{A&f+7w#Ka@{P{Eud9zWJ4DiYRe21Qj6b^fdER_x^z2iX6u}moVG`V3 z`=pQ*22y*!;y`@Kt5>f~%G`Q4hdlt}Mi{)3@CQEdfudpfQ$tXqsF;b}2zXWDVqDM?!*w_iu7JI5^BE zJqJ$f`tk{0-sw^a8k)#bWZ&v?7YzL)sd@hV`F2t>+nsM|ZGG~0bRem&509jik%2Xp zXn@nwH!`iLhIsp+>_?e;%DI(AVs9f;P9|VSp%{VpgF4- zkx={sy5w)0sU1|K=%EZL2w!n78d@Uz<{$rvJ8y0Jj0?b?dX$RY!oC#_Fr8HrzYNEF&um z?)otZnX<642|oU0I6S(v)0juaPhF%xT{m$W>F&d2b2_FyCtZt=OX%?B-iJ#CjpylY z&`B-im4mUxn9KHx;r>Gs`vEk_b?)3+#-QSb3ukC;hrWG#f%b9$;bQwhE;rx$p@nRJ zq$!B>tK=VIW~n}zirSC7qHeyry6k6(G0{VPV-m9 z4pzY>tI8FRy1jig1R^2Rz`y{=6hKf0m%+`M^h1}C-=`ranx~67w(d=zMk-iRC<-{+ zL>XlyY@XXkaGh*@(rO?QnnmF&3KCf{&~3rDi~^Ug&A@nYJl;Nj(Za>J3k~BbXmk5r z#8KFd`+RQ#tHsjgsaa%S>l5teu2CZy48&@!QVJIW}jg4fr10CSWJ=N5y)|4RK^RN=9 zz&lrX|KPEhHdo+kic}n0l2x%oCP`a_7x$uPy{!3VN6X!K`KrN3#e`d?t~rl}iui%N z7DpqYsyHmTX8r7*E70bjI$NZw8O>@{m?p;v{Pa1$I*!6n}4qa#*bxe0gg-i?gZxOVNDhsWA=t}{xaBNz;HDspCa7KFT? zXO9#;V|wO+cK2><9xAvzziE;uqwCaBmU1yKUtKd>G%xy!;_NM9Vt6^Bjn1~fGWXWK z%Fj0oyZPbMr%CF5aD4wUQ;?P*E3GMR`3Y*%_t2uv_=_hxh7~iKQ)`N?JRL1MMaZ{_ zKM8&Dw!{~G%S^1fF03^S+$1Fdpvv|vML^T#eLK2jyk+?X+0E4-C5ObizP3h~cy$!? zNKW0jQ~0IjC_NmA^oDce1hScR`WNSE%*GPv0Iso`vX9_qW1o*Zq zFzHD{SFm`DIFb^IgaKKCIo|!(WK9{?n*CdPG&Q=ablApl6n)1R;&JDYzTvv^!Bcuu z&!XNFZb{=l%zRe*(z5sF%;t^R1=%#`Oq(O+o%~$Des@4aeE_t(v242`=xw@@TNY`|mW#^>7iz9!scU>(F!5Vpz2L`-iyWdb|e0j|YrE8h7!@jTCS#aH)*lHu^R z28%_zdQs>V&+JIEQ5*E_12mh8spdxw7DnEz5uJzM7%TCdd+wwK$S(or(rW(5{Q#;G z5Fo%Fl6fPGDcCq8r1$~UqCEo`tgY^>Oxx+U&3Cnp>tp5F*}S`PLTt~~7>f88d7f^V zlj^zonUPGFTT15MNUSq<-3`x72$EudcEn}HTq(Gx6#BGPikL)Bcq1QvN=``uUw(Pz zv2cTihNgKiHjkLvtV3#E%lx*FQA94wAxI=}XkF#LqoWi#3R;TIQ(Oix0(YraA=WQu zlgHtl=NZW={iG2xi$bCnPi19fT+P4P3=opu0&BD02hx~wEVu|yK|zt8lVf3gln8nL z@OOP2c}F*2PTD4#l-^INl($$;w6X)Ev(!ip5w}!rVoh2SF#Th2MA}sH-{tK=js- zTh2EoF999LTipM6Kfl8gmZCW){dhA5AzxLOoxkuEwFz9Y$6DLj)R3N-q3H|2Nr{Mv zfUge-+NJh^Z=Z}cMuQ)T;@gUff#aQtvfip=`Qw-}Or?=C4Y17D_+Ke;r%DEGP(^^V zC1BS8Mo}%$0xuY^BktYR&DlcER922%p}+d%ETt)gba37_49~dMT+fcku6VKj#K2UT zr)6wx3|vt7GPew{HxVVQFA5wre{!tna6Ch!GLSMFdR#E*>Htrq~~8gmsNQHL%i z(p3eRwD9MS1S-sj1H{mZ?{J7Wroy1~k>~tSwNuG!c2s7!c78>HSr9dAlqz?P80@!# z8quZ7vs~pcE(Hljkq>`nh`{8|-iPnK3I-w;AR?loA}t~yEw&(_ zNT*7JFeo|HC|pDZL`52A3_?m;nh}+bQM!lj9-4`F9Wc0`@9*U=|8UOPXYaLFJnLC& z@pu!hW#`MQ-AkKm184=9(S`c^n$OgdGUTV(A=3GN)@>=(abU=63zL|oVa-CD`}_Cr z*?rTU0GSFaM0x_V1R=J*5v25yfn zivHM!Y}-rmcN=N-pxEd3JdOCbZI4D+PN?Q&0rxUbh!?CE%x%;st`TRQG@TmcxfRHB zWo2bmA4xk;-`?wpnXTZC=@ec3da8yMH094YiA6DHPg(HRW*g8?j2Px-yK2hQ;HrLr zRk~J0V{dZupS{1F_W71}W0qSIb1}*Ve2P*4!D8mc5m2;G&h5s#R)dU?205!qpF80~ zfBXx9TQ?K5NO@gVSk8TMO%d}Z1j{T*;f%SsiEEq8Cd7*zJ!)m#Jcdi=-YFPxZKf>% zSL94@Z*L~uFKo|l6CuQUgWs40g^xLv5MK2Z$EO^%F4>f2wLrM_GKisrUU9x_dMM7w)-)p z)P5aV-&%Bu=d940G(ha4yc98lEO^EEaz#M9vYez9Zl;EmDC+|nfG6QYPow;F1S33u z88EA8t58u4begJfZEdA@s;H<)%g$~#Ad@GuiKj1be1Z3k%90~OJssMH%Lk`pPu83& zI+lP+HSzQ8IN!lbRhi7%E+kKsH|gkUtMrtoyeA>kx5fUtIDKBQ1e^@ zId3~JlNNDQILP*Fk#nTp{(gHOn?Khzf;0E}zYE{9Yrl{KI4Xziaj>S0LAHeC(k(>yJi@fG+9 zXMcjmzOQz3yWxaW+ekBw=VMNXa7Sw-2E;LA@S-y>!~Ao)m*1=3UnLAy2Jsr~-i!|d zC*0=2Y<~sPimOGs8THm@2it?u2A;>JY+AJ6_9;V6muG=;EK(A*OEc}#Ym>05y}Zc@ z`tJRsaNK?=Q4^hC3eF25_Wi-(rDQnt?{6R|AWxt$V_k*$N*B%$oosElGB6n32>$nv z*(2EmIW??c0cLw49IkW9O4*#}wOfmUUy2o)a-*0Swq}9*K~C84kf> zJ(i~LD18Ql{bpI0lr<>jS8{ZNR#7%Kxx1LX5^h$513zMx+(c6Y-zlO`qzeL0s^`!h zn>$@w1RoTO6r(7hHT-91XQPSC%*?{O5S#H!$Xjy&?qWi`!+F*rPaPWRD$hx$Hg}sB zw?53UN@LPbP(SBN)3HkLbrXS3VCMXWMSA(Ec5ZIco6?jwobjW|qiKB%n?hu!$Z;Qd zZE92iyrJVBkmL^#g{7Eves$ZaSIyhwY37MCg~c#JTFv6l5Fq6^3oU^^Izm?zf}t+{ z!FkZZ5I%&Q*Chp(^CT}w*#77es4j2IO);8|qi)+0NXqRFLwu{jDguE3ccH0iAtCb;;4-rP>41VsBhgGy1xGGM=moClw(?(v;e{=ts8 z6t@NU%f{cbK5Y>U}R;onoLJP(GFx#E(V)kG3ZZ)(cE&IUIB!mNFhPSr|kIm zCTKBuCRuc*Ct6sdekt*KyxVKDu|7!}iSLCB zSEak1Ip53a+WI*)goh@YG(IqBOkMMkb+WRupzfhaOc!~ZKPnq8{>MazwKKmeOF>^&nM?IfxI|`1;?=fOR2;T8COO3iF%6!!Rai|>*WcbIeUQ2slxw`(yF*sEjux0RhG+kSBO4?lI z0lE~eU2bsK$%2JEr!HrduKbRi#RD0NzYN_6+hNDOdy>CjAXg_{FTO3}HL0sk3uL$G zd2wJeZnzi*Z%0>bR>h(fm~Y;)MQ%R-$&;=0cccCY6kZ>wATdI0IpgTB zhLdlu0ikTNfbOi%bp5{tIBv(F&4%KZDx)Z`sX;N@Ne%c?juer0U zE5P|JZEZUbo_+Bj6&UeKN1&GO>2knu;drjs)-YaUfoZ)uU*4f{iN$Z@H@G5(@PaaV zL-x=0;(HyodJ>U-G^Nn8D~5`O60_@HE&rGZqOn39Dk<(Qrr6(v>*7L%sfQkTFy;z9 zLw}d8o<+j<({{v$i8z93$jt1fVsW^`|9|+B_}?*l2enV?<*#2rEj&B!YKx!ogMr|G z{UG3FM;DVaChy=c$L`Z?n*FkA4K#e1Gb6t#;gF2WRpvP`-v8M%nuf`@ZTKNXqL(+_ z4DO6%;-%%?1Md%zLBD%g;*4)=jVk-$B_%MSFOaT%sx)bn%6+kSHk_fjzQfZNQq-ft z)}j0Nk3>sws@*jmaf(ox#~r$ReIluKyj>|y+2r-K!lq)>dDx|B_%kD50mp-Oc-waD zFg~mS#Tmci(pw)Ik6J`bkEVc$w!>|WI%3HiKD3)hebgPY=>`2CUb0FvImn)po4ace z<_DeoJpw^OGxzM>o9{4YYGhP%(Rda1|adBB0 z_o-8-q@~HJyZ67>{u7ttcUvZ2%*n8w1h-{z#N)->;M*Y;?xzk2k=s@#=(_ zZ2f!tUQdXNG+qaqsL=Y4Z zK{nG4Cn(y~U$_z9aHnEZQVI z{dC|~31PI2aRo{=5B21`ooRC+Ls!^ZrKR`-7!_+Z)5=A*dhh}E`B`##bv0mx%hlEO zj{sgWdAl|I-W%{c6^|wD;T@P*S_DO8@Rs%QSp8`S!R=)Xl4*y^R39Jfd1Pn|bl^Og4M&bEZR zCJ2hAzao%b?B4YJyaW6>Iw~tF8u7DuZNNG(F9J_$_=$a&US(t*Nz3kIozCIAgxhj# ze{MtR)s%9u?LngavpYOd^w4^EMHwecS%l)qF4w6n%KIlzoex~Ahd zu8~^Ttr_y9oAyM4V_uXnDO2*Jkrpo-;S>oW;4 zc^bKbVu)A@XW8LLju34m9V9beH~+P@s8t54%+5hqJIkf%9#xWFHm1oV&V!b8<0O<~ zdW>j@i;J6@Bh`azIPF6RI=R*89y)Ornd7Ql;S1XP)EkxnGj7NUU_IEsZ*=zq+5F6K z9JHK;OwT0J4A#2+o|)MqIAUmM1$yMj5tomcrqDAMuk6R5-E8@eKQ}uyxLcFuX(w@e z+)juWnpYv}eEY%TOBp6B^}*%tuq&nq=4U*Gs{OUt++#d1VdW7Y!Z+27a47zAlXL&e zE45ge#KLQ<6#CVn?(W<1Z*1ygl^jHAK1po9l~FLKTafCpqd z5y%}D{Rfs|IYh2@PP_N-qd|1>-rn=6H^ghUkmJ=xHx12F(?KtD3N0-*&It2(TZ(cD zZPlukLfq4rI2>v{?()xMCwc@9zw8?gfxcJi}-cF$#j`G`q;V!Jm>U1=63 zFbtID`28Tg_k$dc3e&QOV(+rvPLqdpMSVyX2>Rx}{&=SdLQ_6IK2%=g?X}u;#rCC@ zmDATrzas|zQ%efoXFsVq&z-&@iy}vyEUG)klpdYlBu<_c`MAa30*edf7-fl!{94BF zPw?t<@EFaPmY1*5AvqNTnuZsJ1W0O(wY0SK#uW#%kTdu9EEYT(Tn^zbDWNFMPNyp* zK-!M|tQB6k1~I@@J7Ww&Dwfm!38t()6}W>}>1N?!V?@hT%pqy#)XbVM-V7U2ui(Ap zvD~|5tn?rlHoY%GUR2po9_g3oQ>O%;Qeu7f*GmWJ+d8j4P12!5wzp($d(-SkRO8Qi zjHi{2A`hNy%Sz0wfnx0cEO(b%jNUi?J(DtwuMrYf3|sb+bAzP=xv^J;ve%F7T)e1x z&^J%(Gi-v!{MvFi)6*-A$q}LTI0OXZoe6kMk=t^5W{RoThYu$=fL{+=x6l+`Ye`q1 zBaN?SUfq^oa^)Swb|;4LW2{EkCpu6r<*$|EYK?6UjJDvLq$NE{S2yhS&?vN^=?EwnL7X;T$MUZcGs#1+{f$;Y2TL3dMDV<1=xT6Lj zv-PO%)cbDDXdDZBm&kV{m46pgIP>)1SYUyK7mkes1q6(M%CyX$2PZV+d;94A?wX+i zm-zcLUtg>CPsyJ=ZJIxJLa;wIF_B#OW!Z<*BUBDU1riJWj{R>sualgBLy%%nSozn6 z7y5Q@nd)$BopR5zGut#ksuj9~##YdpmOI91ZEgv~xm`x#Ljf8kCDHp58qW~^npzSE zT{PMT{m|9qt;S477_8~^7p3Z0Iiwu@o;|y3&jRgQ*L}Urh3=3btRCfkbyf%{l?sNV zGEJG7^T`@z`aCPfN(G?D4a>SK*j2CA=@V>!&J;yaj!M$6rOUgsI^bBE>>14zq=M%}P zuQ(at6o6}R&Rrqq0kmgT zm5A=0EYf%jEEp0q?gBj4%hac~9rN~$UTFYbHwSS)$mF4tk`n0gjul6BL62+NG66#! zGo(FmaOh0Eb3s{o=Z198o5q`!D7S}i%Dm{qhnS5nHf@8)zq^buk{}ZbTMQ@?C=~#J z$y$GP%^+C)|F}xQhmY&=e-i&LP2fDdNBn6itt=O?5r&-xE)Qz|h6jK$6)Q7qIQg1< z9UkzT7>;`Xiw|8tB%ShWLc)<|wjJQnR?HS-dD@E~4M zxxEja-0HzvBN?@2>tB@phq(#yyLxKB#Q0KYB?x90FS*<`nvyE~PWb#g$ zE4GcSAx3o$4Gg7?^#=w|2Hvh1NU}7-B zpBr(KHvR2fhWfw0+HF&yz$c{92fIKf4!Vo!x=Aul$hXJ(h zaR5YB35h$95`Yc6aM|Q-3nkfh^!6LKon(dALu{OGSvnl<%i&*VwO||NmEQvILys?| zC+_d*=fwX1`wcrz4)nI{>G@H0j#S|Gy8QYn*(Kf}7a&Q9vvLZ4A?l;hno11Qzg` zPq^||eCp(rAP^CMgP@On`;F?J5!Y_@*8m*q(JZ<%zqjo;mzSYnpwLqV_fpa7&_T54 z)Juw)ojM2u@_tkBdjCfrlYJFMr1bu^RQ#n+p2Gk%c3bB6$B_#U19CK3?`cE(Dv<>GH&!^X&)W6H8QGCyG%I_KMl0 zNGzIKE!T6=MObb#ECJ!e{_hgb�#jT-a2TF-UkW^lkp2(d5*1ogyy+*5kP=V^gg= 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�KZOK=dH)f2L!O?{X%~ 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 UUcfQ3u`@F;!;z7R$ce)JKNNdBi~s-t literal 0 HcmV?d00001