From dcdb3e5a43b412045f99ee77d582b0dd856408e7 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Mon, 22 Sep 2025 16:57:55 -0400 Subject: [PATCH 01/14] docs: create single doc folder and migrate to rst files --- .../source/Data_analysis/Example.rst | 24 ++-- .../Graphical_User_Interface.rst | 128 ++++++++++-------- .../source/Data_analysis/Hatching_Point.rst | 11 +- docs/source/Data_analysis/Peak_Detection.rst | 59 ++++++++ docs/source/Data_analysis/index.rst | 11 ++ docs/source/Data_processing/Overview.rst | 34 +++++ .../Data_processing/Process_raw_data.rst | 30 ++++ docs/source/Data_processing/ROI_length.rst | 27 ++++ .../ROIs_and_signal_intensity.rst | 17 +++ docs/source/Data_processing/index.rst | 11 ++ docs/source/Getting_Started.rst | 57 ++++++++ docs/source/_static/gui-screenshot.png | Bin 0 -> 71931 bytes .../docs => docs}/source/conf.py | 0 docs/source/index.rst | 12 ++ snazzy_analysis/docs/peak_detection.md | 54 -------- .../docs/source/Getting_Started.rst | 68 ---------- snazzy_processing/docs/source/Overview.rst | 85 ------------ snazzy_processing/docs/source/index.rst | 14 -- 18 files changed, 348 insertions(+), 294 deletions(-) rename snazzy_analysis/docs/example.md => docs/source/Data_analysis/Example.rst (91%) rename snazzy_analysis/docs/graphical-user-interface.md => docs/source/Data_analysis/Graphical_User_Interface.rst (52%) rename snazzy_analysis/docs/hatching_point.md => docs/source/Data_analysis/Hatching_Point.rst (79%) create mode 100644 docs/source/Data_analysis/Peak_Detection.rst create mode 100644 docs/source/Data_analysis/index.rst create mode 100644 docs/source/Data_processing/Overview.rst create mode 100644 docs/source/Data_processing/Process_raw_data.rst create mode 100644 docs/source/Data_processing/ROI_length.rst create mode 100644 docs/source/Data_processing/ROIs_and_signal_intensity.rst create mode 100644 docs/source/Data_processing/index.rst create mode 100644 docs/source/Getting_Started.rst create mode 100644 docs/source/_static/gui-screenshot.png rename {snazzy_processing/docs => docs}/source/conf.py (100%) create mode 100644 docs/source/index.rst delete mode 100644 snazzy_analysis/docs/peak_detection.md delete mode 100644 snazzy_processing/docs/source/Getting_Started.rst delete mode 100644 snazzy_processing/docs/source/Overview.rst delete mode 100644 snazzy_processing/docs/source/index.rst diff --git a/snazzy_analysis/docs/example.md b/docs/source/Data_analysis/Example.rst similarity index 91% rename from snazzy_analysis/docs/example.md rename to docs/source/Data_analysis/Example.rst index d614b72..9428ddc 100644 --- a/snazzy_analysis/docs/example.md +++ b/docs/source/Data_analysis/Example.rst @@ -1,18 +1,21 @@ -# Example Analysis +Example Analysis +================ An example of how to use the GUI to analyze the data output from the raw image processing pipeline. -## Open the GUI +Open the GUI +------------ Open a terminal window and activate the conda environment: -``` -conda activate pscope_analysis -``` +.. code:: + + conda activate snazzy-env Refer to the Getting Started documentation if you haven't installed conda or haven't created an environment yet. -## Load data +Load data +--------- To load data in the GUI, select an entire folder that has pasnascope output. @@ -22,14 +25,16 @@ The parameters that change more often are presented as a dialog window as soon a For the example dataset, we are not going to change any of these parameters. For more details about these parameters, refer to the GUI guide item 'Config Parameters'. -## Visualizing data +Visualizing data +---------------- When the data is loaded the GUI presents a sidebar with accepted and removed embryos, and the currenlty selected embryo. The sidebar can be used to select other embryos. For the selected trace, we can see the identified peaks. The signal from each channel can be inspected by clicking the button to the right of the trace plot. -### Adjusting peaks +Adjusting peaks +--------------- The first option to change peaks is to change the frequency filter value. Higher frequency values will result in more denoising, which will help if the signal has many fast oscillations that should be ignored. @@ -46,7 +51,8 @@ Increasing the value in the slider will increase the peak width, while decreasin Once all peak data looks good, we can open other directories as another Group, to compare trace properties between them. -## Comparing with another Experiment +Comparing with another Experiment +--------------------------------- We can combine data from multiple experiments in two ways: either by adding more data from another experiment to the same Group, or by adding another Group and compare the different loaded Groups. In both modes, it's not possible to change the peak detection parameters, that's possbile only when a single experiment is loaded. diff --git a/snazzy_analysis/docs/graphical-user-interface.md b/docs/source/Data_analysis/Graphical_User_Interface.rst similarity index 52% rename from snazzy_analysis/docs/graphical-user-interface.md rename to docs/source/Data_analysis/Graphical_User_Interface.rst index debfe49..308e64e 100644 --- a/snazzy_analysis/docs/graphical-user-interface.md +++ b/docs/source/Data_analysis/Graphical_User_Interface.rst @@ -1,6 +1,7 @@ -# GUI +GUI +=== -The graphical user interface is written using `PyQt6` and `pyqtgraph`. +The graphical user interface is written using ``PyQt6`` and ``pyqtgraph``. The GUI's main functionalities are: @@ -8,24 +9,26 @@ The GUI's main functionalities are: 2. Combine multiple experiments as a group. 3. Compare multipe groups. 4. Inspect TIF movies in sync with the DFF signal. -5. Inspect all parameters used in the analysis and share them with other users. +5. Inspect all parameters used in the analysis. -## Loading the GUI +Loading the GUI +--------------- First step to use the GUI is to activate the conda environment. Refer to the Getting Started session if you haven't created an environment yet. -```sh -conda activate pscope_analysis -``` +.. code:: bash -Then, from the pasna_analysis directory, run the GUI code: + conda activate snazzy-env -```sh -python3 pasna_analysis/gui/gui.py -``` +Then, from the snazzy_analysis directory, run the GUI: -## Using the GUI +.. code:: bash + + python3 snazzy_analysis/gui/gui.py + +Using the GUI +------------- There are two primary modes to use the GUI: @@ -42,67 +45,69 @@ Therefore the general workflow is to first open each experiment separately and m The comparison plots in the Plot menu will show results by Group. In the upper left corner of the GUI, a dropdown menu can be used to change the Group that is currently being visualized. -## Loading an Experiment - -To load an Experiment select a directory that has `pasnascope` output. -The directory structure should be look like: - -``` -|-- project_folder -| -- data -| -- 20240501 -| -- activity -| emb1.csv -| .. -| -- lengths -| emb1.csv -| .. -| -- embs -| emb1-ch1.tif -| emb1-ch2.tif -| full-length.csv -| emb_numbers.png -``` - -The `activity` and `lengths` directories, and the `full-length.csv` file are required. +Loading an Experiment +--------------------- + +To load an Experiment select a directory that has ``snazzy_processing`` output. +The directory structure should look like: + +.. code:: bash + + |-- project_folder + |-- data + | -- 20240501 + | -- activity + | emb1.csv + | .. + | -- lengths + | emb1.csv + | .. + | -- embs + | emb1-ch1.tif + | emb1-ch2.tif + | full-length.csv + | emb_numbers.png + +The ``activity`` and ``lengths`` directories, and the ``full-length.csv`` file are required. If any of these is not found, the GUI will abort loading with an error message. -The `embs` directory will hold individual embryo movies in `.tif` format, and can be used to visualize embryo movies in sync with a DFF trace. -The `emb_numbers.png` file represents a snapshot of the microscope's field of view at the start of the imaging session, and also shows the embryo id of each embryo. +The ``embs`` directory will hold individual embryo movies in ``.tif`` format, and can be used to visualize embryo movies in sync with the DFF trace. +The ``emb_numbers.png`` file represents a snapshot of the microscope's field of view at the start of the imaging session, and also shows the embryo id of each embryo. -## Config parameters +Config parameters +----------------- -When loading an experiment the code will look for a config file named `peak_detection_params.json` inside the experiment directory and will use its data for the analysis. +When loading an experiment the code will look for a config file named ``peak_detection_params.json`` inside the experiment directory and will use its data for the analysis. If not found, a file with default parameters is created. -The default parameters can be found inside `config.py`. +The default parameters can be found inside ``config.py``. If you change any of the parameters, they will be recorded in this file. -To restore the original settings, simply delete `peak_detection_params.json` from the corresponding directory. -Sharing the `json` file allows someone else to reproduce your exact results in another machine. -Keep in mind that each directory should have its own `peak_detection_params.json` file. +To restore the original settings, simply delete ``peak_detection_params.json`` from the corresponding directory. +Sharing the config file allows someone else to reproduce your results in another machine. +Keep in mind that each directory should have its own ``peak_detection_params.json`` file. The parameters that are most frequently changed are presented in the GUI when an Experiment is loaded. From this window it's possible to set: * Group name: name of the group that contains this experiment dataset -* First peak threshold: minimum time in minutes that has to pass before any peak happens. -Used to make sure that the first peak caught at the imaging session is really the activiy onset. +* First peak threshold: minimum time in minutes that has to pass before any peak happens. Used to make sure that the first peak caught at the imaging session is really the activiy onset. * To_exclude: embryo numbers that will be excluded from the analysis. These embryos will be excluded from the analysis. * To_remove: embryo numbers that will be analyzed, but will show up in the 'Removed' group. * Embryos that have it's first peak before the first peak threshold or that were marked by the user as removed will also be at the to_remove category. * Has_transients: if selected the code will try to identify and skip the first peak if it's likely just a transient. * Has_dsna: if selected the code will try to determine dSNA and ignore all peaks that happen after dSNA start. -* Dff_strategy: Combo box with the baseline strategy methods. -`local_minima` will pick the bottom 11 points out of the `baseline_window_size` and use that average as the baseline. -`baseline` will split the DFF values into bins and use the average of the most frequent bin as the baseline. -This method assumes that the bursts of activity are sparse, so that for all windows the most frequent bin falls into the baseline values. +* Dff_strategy: Combo box with the baseline strategy methods. ``local_minima`` will pick the bottom 11 points out of the ``baseline_window_size`` and use that average as the baseline. ``baseline`` will split the DFF values into bins and use the average of the most frequent bin as the baseline. This method assumes that the bursts of activity are sparse, so that for all windows the most frequent bin falls into the baseline values. -Inside the File menu there is an option to open the `json` file and change any of its parameters. +Inside the File menu there is an option to open the ``json`` file and change any of its parameters. Updating the file causes the entire Experiment to be recreated with the new configuration data. -## Visualizing traces +Visualizing traces +------------------ Once the data is loaded, you should see something similar to this: +.. image:: /_static/gui-screenshot.png + :alt: GUI Initial screen + The top app bar has buttons to change the data presentation. Below the top app bar there are two sliders. The first is for the frequency cutoff, which controls how much the signal is smoothed for the finding peaks algorithm. @@ -111,19 +116,24 @@ The sidebar presents which embryos are currently considered for plots and analys You can toggle the embryo status between these two categories. In the main view you will see the DFF trace of the currently selected embryo. The pink dots represent the peak indices. -By pressing `shift` + `left mouse click` you can add a new peak to the plot. +By pressing ``shift`` + ``left mouse click`` you can add a new peak to the plot. Because we usually have many points over the X axis, it can be hard to click exactly where we want the peak index to land. To help with this, the actual peak index after clicking in the local maximum value for a small window around the point that was clicked. -By pressing `CTRL` + `left mouse click` you can remove a peak. +By pressing ``CTRL`` + ``left mouse click`` you can remove a peak. It also works on a small X axis range just like when adding new peaks. You can also visualize the signal from each channel, by clicking on the button in the right of the screen. This window will present the signal from each channel and also the hatching point, which can be changed manually by dragging the line. -## Plots menu +View embryo movies +------------------ + +If you haven't removed the individual movies that were cropped from raw data, you can visualize them in the GUI. + +.. NOTE:: When running the pipeline, set the variable `clean_up_data = False` to keep the cropped movies. + +The embryos must be placed inside the experiment directory, in a directory named ``embs``. + +If there are no files available to show, the GUI will simply display an error message. -### Field of View -### View embryo movies -### View phase boundaries -### View plots -### View comparison plots +If there are files, you can select one and see the movie in sync with the DFF trace. diff --git a/snazzy_analysis/docs/hatching_point.md b/docs/source/Data_analysis/Hatching_Point.rst similarity index 79% rename from snazzy_analysis/docs/hatching_point.md rename to docs/source/Data_analysis/Hatching_Point.rst index 082b994..c3da8d3 100644 --- a/snazzy_analysis/docs/hatching_point.md +++ b/docs/source/Data_analysis/Hatching_Point.rst @@ -1,14 +1,15 @@ -# Hathching point calculation +Hathching point calculation +=========================== Hatching happens when the fruit-fly embryo leaves its egg. Determining hatching time is somewhat straightforward, because when the embryo hatches it moves out of the field of view. -In terms of the signal recorded, this manifests as an abrupt drop in both active and structural channel signals. +In terms of the signal recorded, this is observed as an abrupt drop in both active and structural channel signals. To identify this signal drop we use the structural channel signal, because it's more stable than the active channel. -The structural channel is first smoothed using `scipy.signal.savgol_filter` and zscored. +The structural channel is first smoothed using ``scipy.signal.savgol_filter`` and zscored. Then we calculate the baseline of this signal, as the average of the most frequent bin in the signal's histogram. The signal used to calculate hatching is the structural channel signal minus the baseline. -As a default threshold we use `Z = 0.35`. +As a default threshold we use ``Z = 0.35``. The hatching point is then marked as the first point that reaches the Z score. Notice that all data after the hatching point should be ignored. @@ -22,4 +23,4 @@ On a few occasions, mostly due to very abrupt motion, the ROI is understimated a In these cases, you can drag the line that indicates the hatching to a more accurate positon or remove that embryo. If the default Z-score of 0.35 does not work in your case, you can adjust it to another value. -Inside the GUI, open the Config file `Menu... View pd_params` and change the value of the Z-score variable. \ No newline at end of file +Inside the GUI, open the Config file ``Menu... View pd_params`` and change the value of the Z-score variable. \ No newline at end of file diff --git a/docs/source/Data_analysis/Peak_Detection.rst b/docs/source/Data_analysis/Peak_Detection.rst new file mode 100644 index 0000000..c8af442 --- /dev/null +++ b/docs/source/Data_analysis/Peak_Detection.rst @@ -0,0 +1,59 @@ +Peak Detection +============== + +Peak detection is one of the core features of ``pasna_analysis``. +From the detected peaks, we derive most of the metrics used in this package: peak widths, amplitudes, rise times, decay times, and more. +The algorithm consists of several steps, each with parameters that can be fine-tuned for optimal detection. +To understand how to adjust these parameters effectively, it's important to first understand the entire peak detection algorithm. + +1. Peak Detection on a Low-Pass Filtered Signal +----------------------------------------------- + +The ΔF/F (DFF) trace is filtered in the frequency domain using a ``freq_cutoff`` parameter: all frequencies above this value are removed, and the remaining low-frequency components are used to reconstruct the filtered trace. +This acts as a smoothing step, which almost completely removes oscillations and short-duration peaks that do not correspond to actual activity bursts. + +The ``freq_cutoff`` can be adjusted in the GUI using a slider, and the reconstructed signal is updated in real time. +The default value of ``0.025 Hz`` works well for many traces, but traces with high-frequency noise may require a lower cutoff. + +Once we have the filtered trace, peaks are detected using the parameters ``fft_height`` and ``fft_prominence``. +The ``fft_height`` parameter is especially important because the reconstructed signal often contains minor ripples before the first real burst. +These are easy to identify, as they usually do not correspond to peaks in the original ΔF/F trace. +``fft_prominence`` complements ``fft_height`` by measuring how much a point must stand tall from its surrounding baseline in order to be marked as a peak. + +2. Align Peaks in the Original Signal +------------------------------------- + +After detecting peaks in the filtered signal, the peak indices are mapped back to the original ΔF/F trace. +This step is necessary because frequency-domain transformations can slightly shift peak positions. +The bursts of activity have a sharp rise and are followed closely by shorter oscillations. +To properly mark bursts, we use the leftmost peak in each burst as the peak index. + +The window size used to search for the leaftmost peak is given by ``port_peaks_window_size``. +Since the leaftmost peak can have an amplitude very different than the local maximum peak, we specify the percentage from the local maximum we accept using the parameter ``port_peaks_thres``. + +3. Filter peaks by local threshold +---------------------------------- + +As the embryos develop, there is a global trend of peak amplitude to rise and then stabilize before hatching. +We use this fact to perform an extra validation step for the calculated peaks. +Each peak is compared against its neighboring peaks, and peaks that are too high or too low are discarted. +For example, if a peak close to baseline level is misidentified between bursts, it will likely be discarded due to all other peaks having higher values nearby. + +The window size used to compare each peak with its neighbors is controlled using ``local_thres_window_size``. +The minimum value for accepting a peak is given by ``local_thres_value``. + +4. Optional Post-Processing +--------------------------- + +Some post-processing operations can further improve peak detection for specific types of traces. + +Certain traces may exhibit a large burst at the beginning of the imaging session. +This is an artifact that should be removed. +In such cases, the ``remove_transients`` function can be applied. +It detects and removes initial bursts if their interval is significantly longer than the average of subsequent bursts. + +Another post-processing step removes low-amplitude peaks that are likely false positives. +Peaks below a specified percentage of the maximum peak amplitude are discarted. + +Finally, in the GUI, you can manually add or remove peaks. +These manual edits are used to update the set of calculated peaks. diff --git a/docs/source/Data_analysis/index.rst b/docs/source/Data_analysis/index.rst new file mode 100644 index 0000000..29be391 --- /dev/null +++ b/docs/source/Data_analysis/index.rst @@ -0,0 +1,11 @@ +Data Analysis +============= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + Example + Graphical_User_Interface + Peak_Detection + Hatching_Point diff --git a/docs/source/Data_processing/Overview.rst b/docs/source/Data_processing/Overview.rst new file mode 100644 index 0000000..6cf2a31 --- /dev/null +++ b/docs/source/Data_processing/Overview.rst @@ -0,0 +1,34 @@ +Overview +======== + +``snazzy_processing`` is a Python package to automate the extraction of primary data (VNC length, ROI size, and activity) from fluorescence imaging. +The package is an image processing pipeline, that can be divided intro three main stages: + +* Crop movies for individual samples from raw data +* Calculate signal intensity inside ROIs +* Measure the VNC length + +Use the jupyter notebook ``snazzy-processing-pipeline.ipynb`` to run the pipeline. + +The data processing requires a ``.tif`` file. +There is built in support for formatting ``.nd2`` files to ``.tif``. +If your raw data is in another format, you must first convert if to ``.tif``. + +Before actually running the pipeline, which is the last cell of the jupyter notebook, we must determine from where to crop each movie in the raw data. + +The bounding boxes for each individual sample are determined via thresholding, so some manual adjustment might be necessary. +Inspect where the bounding boxes will be created in the jupyter notebook. +They should cover the entire sample. +If there is a bounding box that covers more than one sample, because maybe they were touching each other, those samples must be ignored or further manually processed. +We can't use it directly because the resulting ROI will not match a single sample and the length and activity data will be wrong. + +After the bounding boxes are determined, you can run the pipeline. +By default, the methods in the pipeline will not overwrite any data. +If data for a given sample is found in the output directory, it will simply skip that sample. +If you want to recalculate any data, first remove or rename the current files. + +Refer to the other documentation pages for a description of the pipeline steps: + +* `Process raw data `__ +* `ROI and signal intensity `__ +* `ROI length `__ \ No newline at end of file diff --git a/docs/source/Data_processing/Process_raw_data.rst b/docs/source/Data_processing/Process_raw_data.rst new file mode 100644 index 0000000..f4a4d04 --- /dev/null +++ b/docs/source/Data_processing/Process_raw_data.rst @@ -0,0 +1,30 @@ +Process raw images +================== + +Since the imaging is done with a large Field of View microscope, usually during 6 hours or more, the raw images tend to be in the range of 50 ~ 100 GiB. +The simplest way to handle the raw data is to crop it in individual movies. +There is a considerable amount of background pixels that can be ignored in the raw data, so after cropping the embryos, all individual movies combined take about 40% of the original memory space. +This already saves considerable ROM memory but most importantly, it means we can easily load individual movies in the RAM of a regular computer (8~16 GB RAM), without needing to use memory mapped files. + +The algorithm to process the raw image can be resumed as: +1. Get the maximum projection of each pixel for the first 10 frames +2. Automatic threshold (Triangle method) +3. Binarize the image +4. Mark connected regions +5. Select regions based on pixel count +6. Determine the bounding boxes for each movie based on each connected region +7. Open the image (mmap) and save the individual movies as tif + +To calculate the bounding boxes of each embryo, we first take the maximum projection of each pixel for the first 10 frames, and then use the Triangle threshold method to binarize the image. +The Triangle threshold is a good choice here because the image has a lot of background pixels, resulting in an unimodal histogram that is centered around the background pixels average value. + +Once we have the binary image, we traverse it to identify each embryo. +Whenever a foreground pixel is found, we mark all connecting foreground pixels, and also keep track of the amount of pixels marked and the extreme points (minimum and maximum coordinates in both dimensions). +The pixel count is used to determine if the marked area really corresponds to an embryo, or just a smaller artifact that was erroneously considered a foreground. +The minimum pixel count might change depending on the type of sample being processed, and can be adjusted in ``slice_img.get_bbox_boundaries``. +Regions with high singal intensity, for example corresponding to fly embryo's eyes or gut are examples of smaller artifacts that sometimes are included in the binary image, but can easily be removed due to its size. +The extreme points are then used to generate the bounding boxes, which will determine the positions where the image will be cropped. + +The raw image is opened as a memory map using ``numpy``, and the individual embryos are cropped and saved as tif files. +The movies are cropped using a ``ThreadPoolExecutor``. +Because of how Windows machines handle memory mapped file access, it is usually faster to use a single worker on Windows. \ No newline at end of file diff --git a/docs/source/Data_processing/ROI_length.rst b/docs/source/Data_processing/ROI_length.rst new file mode 100644 index 0000000..6154d25 --- /dev/null +++ b/docs/source/Data_processing/ROI_length.rst @@ -0,0 +1,27 @@ +ROI length +========== + +The ROI length is used as a way to characterize the different developmental stages of the embryo. +It is calculated by center line estimation. +The idea is to measure the line that will pass through the center of the ROI. + +To determine this line, we go over the following steps: + +1. Binarize the image +2. Apply a 'chessboard' distance transform +3. Determine the maxima points from the distance transform +4. RANSAC the points to eliminate outliers (usually resulting from areas that correspond to brain lobes) +5. Estimate ROI length with the line fitted with RANSAC + +The ``vnc-length.ipynb`` notebook has a more complete description and illustrates how the algorithm works. + +Full size +--------- + +The full sample size is calculated by approximating the entire sample shape as an ellipse, and measuring this ellipse's diameter. + +The steps to calculate the sample size are: +1. Equalize the image histogram +2. Automatic threshold (Triangle method) +3. Binarize the image +4. Calculate the corresponding ellipse's major axis \ No newline at end of file diff --git a/docs/source/Data_processing/ROIs_and_signal_intensity.rst b/docs/source/Data_processing/ROIs_and_signal_intensity.rst new file mode 100644 index 0000000..df10394 --- /dev/null +++ b/docs/source/Data_processing/ROIs_and_signal_intensity.rst @@ -0,0 +1,17 @@ +ROIs and signal intensity +========================= + +The ROIs are calculated for a given interval of frames. +By default, a single ROI is calculated for groups of 10 frames to speed up the process based on the fact that the sample signal won't change considerably within this interval. +This is a good approximation and the speed up justifies the eventual errors in readings caused by movement (see `activity.ipynb` for details about the error in activity caused by downsampling). + +The ROI algorithm can be resumed as: +1. Average the group of frames into a single 2D matrix +2. Automatic threshold (Otsu's method) +3. Binarize the image +4. Remove small holes inside the VNC +5. Select the largest group of connected foreground pixels +6. Return a mask that matches the largest label + +To calculate the signal intensity, we apply the mask to the embryo and calculate the mean pixel value. +The active and structural channel measurements are exported as a `.csv` file and further processed using the code from ``snazzy_analysis``. \ No newline at end of file diff --git a/docs/source/Data_processing/index.rst b/docs/source/Data_processing/index.rst new file mode 100644 index 0000000..bbf495c --- /dev/null +++ b/docs/source/Data_processing/index.rst @@ -0,0 +1,11 @@ +Data Processing +====================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + Overview + Process_raw_data + ROIs_and_signal_intensity + ROI_length \ No newline at end of file diff --git a/docs/source/Getting_Started.rst b/docs/source/Getting_Started.rst new file mode 100644 index 0000000..487a7df --- /dev/null +++ b/docs/source/Getting_Started.rst @@ -0,0 +1,57 @@ +Getting Started +=============== + +Installation +------------ + +The project uses `conda `__ to manage dependencies. +If you don’t already have conda, you can download and install it from the official website. + +Make a copy of this repo (e.g. with ``git clone``), then ``cd`` into the root folder of the repo. + +Recreate the conda environment: + +.. code:: + + conda env create -f=environment.yml + +Activate the environment: + +.. code:: + + conda activate snazzy-env + +Organization +------------ + +The code is split in two modules. + +Parsing raw data into csv files with the relevant ROI metrics is done using ``snazzy_processing``. +The data analysis and GUI access is done using ``snazzy_analysis``. + +Each one of the modules has the following structure: + +* ``snazzy_[pkg]``: core code +* ``tests``: contains tests for the code +* ``data``: contains the data for the analysis. This folder is kept out of github, and should be populated in your local copy +* ``results``: contains the results of the analyses. It is also kept out of github and will be populated by performing the analyses +* ``notebooks``: contains examples of some steps of the pipeline. Also used for more specfic visualizations. +* ``docs``: project documentation + +Running the code +---------------- + +Refer to the Getting Started session of each module for how to run the code. + +To process raw data, start with `Getting Started `__. +To analyze the output of the processing step, go to `Getting Started `__. + +The analyses can be executed using the provided jupyter notebooks, or running the files in the ``scripts`` directory. +The recommended way to analyze your data is to go through the notebooks in the following order: + +1. ``process-raw-data.ipynb`` +2. ``vnc-lengh.ipynb`` +3. ``activity.ipynb`` + +There are details on how to use the code in each one of the notebooks. + \ No newline at end of file diff --git a/docs/source/_static/gui-screenshot.png b/docs/source/_static/gui-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..da797059b6309624bfb3e949d55a729f915cfef3 GIT binary patch literal 71931 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 for details about the error in activity caused by downsampling). - -The ROI algorithm can be resumed as: -1. Average the group of frames into a single 2D matrix -2. Automatic threshold (Otsu's method) -3. Binarize the image -4. Remove small holes inside the VNC -5. Select the largest group of connected foreground pixels -6. Return a mask that matches the largest label - -To calculate the signal intensity, we apply the mask to the embryo and calculate the mean pixel value. -The active and structural channel measurements can be exported as a `.csv` file and further processed using the code from `pasna_fly `. - -TODO: Describe ratiometric activity calculation? - -VNC length ----------- - -The VNC length is used as a way to characterize the different developmental stages of the embryo. -The algorithm to calculate the VNC length is named Center Line Estimation. -The idea is to find the line that will pass through the center of the VNC. - -To determine this line, we go over the following steps: -1. Binarize the image -2. Apply a 'chessboard' distance transform -3. Determine the maxima points from the distance transform -4. RANSAC the points to eliminate outliers (usually resulting from areas that correspond to brain lobes) -5. Estimate VNC length with the line fitted with RANSAC - -The `vnc-length ` notebook has a more complete description and illustrates how the algorithm works. - -Embryo size ------------ - -The full embryo size is calculated by approximating the entire embryo shape as an ellipse, and measuring this ellipse's diameter. - -The steps to calculate the embryo size are: -1. Equalize the image histogram -2. Automatic threshold (Triangle method) -3. Binarize the image -4. Calculate the corresponding ellipse's major axis \ No newline at end of file diff --git a/snazzy_processing/docs/source/index.rst b/snazzy_processing/docs/source/index.rst deleted file mode 100644 index 81c164f..0000000 --- a/snazzy_processing/docs/source/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -.. pasnascope documentation master file, created by - sphinx-quickstart on Wed May 22 16:16:42 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to pasnascope's documentation! -====================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - Overview - Getting_Started From f80b6e796e1b25fc324984b5051edb291da14340 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Mon, 22 Sep 2025 16:58:44 -0400 Subject: [PATCH 02/14] docs: improve md text and comments --- .../notebooks/snazzy-processing-pipeline.ipynb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/snazzy_processing/notebooks/snazzy-processing-pipeline.ipynb b/snazzy_processing/notebooks/snazzy-processing-pipeline.ipynb index 50c75c3..7203d9a 100644 --- a/snazzy_processing/notebooks/snazzy-processing-pipeline.ipynb +++ b/snazzy_processing/notebooks/snazzy-processing-pipeline.ipynb @@ -71,10 +71,10 @@ "metadata": {}, "source": [ "Visualize the regions of the individual embryos that will be cropped.\n", - "If the bounding boxes don't fit the embryo, you can try changing the `thres_adjust` parameter at line 1.\n", + "If the bounding boxes don't fit the embryo, you can try changing the `thres_adjust` parameter in `slice_img.calculate_slice_coordinates`.\n", "The resulting image also shows the number that will be used for each embryo when saving the individual movies.\n", "\n", - "If after adjust `thres_adjust` the embryos are still not framed as expected, one can use `slice_img.increase_bbox()` to control the bbox dimensions." + "If after adjust `thres_adjust` the embryos are still not framed as expected, you can use `slice_img.increase_bbox()` to control the bbox dimensions." ] }, { @@ -86,11 +86,11 @@ "img = slice_img.get_first_image_from_mmap(first_frames_path)\n", "\n", "coords = slice_img.calculate_slice_coordinates(\n", - " first_frames_path, n_cols=4, thres_adjust=0\n", + " first_frames_path, n_cols=4, thres_adjust=-5\n", ")\n", "\n", "# If necessary, manually change the bboxes size by changing w and h:\n", - "boundaries = slice_img.increase_bbox(coords, w=190, h=40, shape=img.shape)\n", + "boundaries = slice_img.increase_bbox(coords, w=150, h=40, shape=img.shape)\n", "\n", "rect_coords = [slice_img.boundary_to_rect_coords(b) for b in boundaries.values()]\n", "recs = [Rectangle((y, x), w, h) for (x, y, w, h) in rect_coords]\n", @@ -140,10 +140,11 @@ "# Delete the individual movies created after the end of the analysis\n", "clean_up_data = False\n", "# List of the ids of the embryos that should be processed\n", + "# The ids are the bbox numbers from the previous cell output\n", "embryos = list(range(1, 4))\n", - "# Interval used to calculate VNC length\n", + "# Interval (number of frames) used to calculate VNC length\n", "vnc_length_interval = 10\n", - "# Window to calculate VNC ROI (which is then used to calculate activity)\n", + "# Window (number of frames) to calculate VNC ROI (which is then used to calculate activity)\n", "window = 1\n", "\n", "# directories and file paths\n", From 0f8d79b194539b012da1a60d99b4110f1fb30786 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Tue, 23 Sep 2025 16:23:34 -0400 Subject: [PATCH 03/14] docs: update README files --- README.md | 46 +++++++++++++++++++++++++++++++++++++ snazzy_analysis/README.md | 24 ++++--------------- snazzy_processing/README.md | 42 +++++++++------------------------ 3 files changed, 61 insertions(+), 51 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..962cb87 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# SNAzzy: an image processing pipeline for investigating global Synchronous Network Activity + +## Getting Started + +### Installation + +The project uses [conda](https://docs.conda.io) to manage dependencies. +If you don’t already have conda, you can download and install it from the official website. + +Make a copy of the repo (e.g. with `git clone`), then `cd` into the root folder of the repo. + +Recreate the conda environment with the dependencies listed in `environment.yml` in the repo's root: + +``` + conda env create -f=environment.yml +``` +Activate the environment: + +``` + conda activate snazzy-env +``` +## Contributing + +Thank you for being interested in `snazzy`! + +If you are interested in contributing, we accept contributions of all sorts: improving documentation, submitting bug reports, adding feature requests or writing code. +Feel free to create an issue or a pull request! + +If you are new to open souce and need help creating a pull request, we recommend taking a look at these tutorials: +Here are a couple of friendly tutorials you can include: http://makeapullrequest.com/ and http://www.firsttimersonly.com/ + +### How to report a bug + +Please open an issue for any bugs or request for help analyzing your data. + +When filing an issue, please add the following informatation: + +1. What operating system are you using? +2. What did you expect to see? +3. What did you see instead? + +> Did you have a problem analyzing data? If possible, please provide an example dataset. + +### How to suggest a feature or enhancement + +Please file an issue explaining the desired feature or enhancement. diff --git a/snazzy_analysis/README.md b/snazzy_analysis/README.md index dddaa04..b63530c 100644 --- a/snazzy_analysis/README.md +++ b/snazzy_analysis/README.md @@ -1,21 +1,6 @@ -# Pasna Analysis +# Snazzy Analysis -Data analysis for `pasnascope`'s pipeline output. - -### Installation - -Make a copy of this repo (e.g. with `git clone`), then `cd` into the root folder of the repo. -Recreate the conda environment: - -`conda env create --name pscope_analysis --file=environment.yml` - -Activate the environment: - -`conda activate pscope_analysis` - -Install the pasna_analysis package with `pip`: - -`pip install -e .` +Data analysis for `snazzy_processing`'s pipeline output. ### Organization @@ -29,16 +14,15 @@ Install the pasna_analysis package with `pip`: ### Analyses The analyses are primarily executed using the GUI. -After activating the environment, run `pasna_analysis/gui/gui.py` to start the GUI. +After activating the environment, run `python3 snazzy_analysis/gui/gui.py` to start the GUI. There are also jupyter notebooks available, which can be used alternatively and allows for image customization. ### Adding data Each experiment should have one corresponding folder inside `./data/`. -The expected data is generated using the `pasnascope` package. The file structure inside the `data` folder should look like: The `embs` directory is used if you want to inspect movies inside the GUI. -The files are generated with `pasnascope`, as long as the flag `clean_up_data` in there is set to `False`. +The files are generated with `snazzy_processing`, as long as the flag `clean_up_data` in there (inside snazzy_processing_pipeline.ipynb) is set to `False`. ``` |-- project_folder diff --git a/snazzy_processing/README.md b/snazzy_processing/README.md index 2cc9e4c..4152d14 100644 --- a/snazzy_processing/README.md +++ b/snazzy_processing/README.md @@ -1,51 +1,31 @@ -# GOWF - Processing +# SNAzzy Processing -Raw data processing for the GOWF pipeline. - -### Installation - -Make a copy of this repo (e.g. with `git clone`), then `cd` into the root folder of the repo. -Recreate the conda environment: - -`conda env create --name pscope --file=environment.yml` - -Activate the environment: - -`conda activate pscope` - -Install the pasnascope package with `pip`: - -`pip install -e .` +Raw data processing for the SNAzzy pipeline. ### Organization -* `pasnascope`: contains the core code used in all analysis -* `scripts`: Python code that combine the different modules in `pasnascope`, primarily for visualizing movies +* `snazzy_processing`: contains the core code used in all analysis +* `scripts`: Python code that combine the different modules in `snazzy_processing`, primarily for visualizing movies * `tests`: contains tests for the code * `data`: contains the data for the analysis. This folder is kept out of github, and should be populated in your local copy * `results`: contains the results of the analyses. It is also kept out of github and will be populated by performing the analyses -* `notebooks`: contains examples and the front-end for using the `pasnascope` main module +* `notebooks`: contains examples and the front-end for using the `snazzy_processing` main module * `docs`: project documentation ### Analyses -The analyses can be executed using the provided jupyter notebooks, or running the files in the `scripts` directory. -The recommended way to analyze your data is to go through the notebooks in the following order: +The analyses can be executed using the provided jupyter notebooks. +Use the notebook `snazzy_processing_pipeline.ipynb` to run the pipeline. +The other notebooks are used to understand in details the pipeline stages. -1. `process-raw-data.ipynb` -2. `vnc-lengh.ipynb` -3. `activity.ipynb` - -There are details on how to use the code in each one of the notebooks. - ### Adding data -Each experiment should have one corresponding folder inside `./data/`. -By running the code in `process-raw-data.ipynb`, the raw data will be parsed and saved inside `./data/experiment_name/embs`. +Each experiment will have one corresponding folder inside `./data/`. +By running the code in `snazzy_processing_pipeline.ipynb`, the raw data will be parsed and saved inside `./data/{experiment_name}/embs`. To compare the calculated VNC length against manual measurements, add an `annotated` folder inside the experiment directory. The measurements should be saved as a csv file. -Given the description about, the file structure inside the `data` folder should look like: +Given the description above, the file structure inside the `data` folder should look like: ``` |-- project_folder From 9cb9ee1cb7465d8f0ce19fa7c44ef80987bde683 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Wed, 24 Sep 2025 11:27:37 -0400 Subject: [PATCH 04/14] docs: only show TOC for items on first level for package pages --- docs/source/Data_analysis/index.rst | 2 +- docs/source/Data_processing/index.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/Data_analysis/index.rst b/docs/source/Data_analysis/index.rst index 29be391..7ff4997 100644 --- a/docs/source/Data_analysis/index.rst +++ b/docs/source/Data_analysis/index.rst @@ -2,7 +2,7 @@ Data Analysis ============= .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: Example diff --git a/docs/source/Data_processing/index.rst b/docs/source/Data_processing/index.rst index bbf495c..8f69929 100644 --- a/docs/source/Data_processing/index.rst +++ b/docs/source/Data_processing/index.rst @@ -2,7 +2,7 @@ Data Processing ====================================== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: Overview From 799a6620364840e747de362af9cd964ce49c5621 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Wed, 24 Sep 2025 11:28:03 -0400 Subject: [PATCH 05/14] docs: minor improvements --- docs/source/Data_analysis/Example.rst | 29 ++++++++++++------- .../Graphical_User_Interface.rst | 20 ++++++++++--- docs/source/Data_analysis/Peak_Detection.rst | 15 ++++++---- docs/source/Getting_Started.rst | 20 +++++-------- 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/docs/source/Data_analysis/Example.rst b/docs/source/Data_analysis/Example.rst index 9428ddc..83e18a3 100644 --- a/docs/source/Data_analysis/Example.rst +++ b/docs/source/Data_analysis/Example.rst @@ -1,43 +1,49 @@ Example Analysis ================ -An example of how to use the GUI to analyze the data output from the raw image processing pipeline. +An example of how to use the GUI to analyze the data from the ``snazzy_processing`` pipeline. Open the GUI ------------ -Open a terminal window and activate the conda environment: +Open a terminal window and activate the conda environment. .. code:: conda activate snazzy-env +Then ``cd`` into the snazzy_analysis folder, and run the following command to open the GUI: + +.. code:: + + python3 snazzy_analysis/gui/gui.py + Refer to the Getting Started documentation if you haven't installed conda or haven't created an environment yet. Load data --------- -To load data in the GUI, select an entire folder that has pasnascope output. +To load data in the GUI, select an entire folder that has snazzy_processing output. The data from a folder is inspected and loaded as an Experiment object. There are several configurable parameters that change how data is processed. The parameters that change more often are presented as a dialog window as soon as we select a directory. For the example dataset, we are not going to change any of these parameters. -For more details about these parameters, refer to the GUI guide item 'Config Parameters'. +For more details about these parameters, refer to the GUI guide, section `Config Parameters `__. Visualizing data ---------------- -When the data is loaded the GUI presents a sidebar with accepted and removed embryos, and the currenlty selected embryo. -The sidebar can be used to select other embryos. -For the selected trace, we can see the identified peaks. +Once the data is loaded the GUI presents a sidebar with accepted and removed embryos, and the currenlty selected embryo. The signal from each channel can be inspected by clicking the button to the right of the trace plot. +The sidebar can be used to select other embryos. +Only the selected embryos are considered in any of the plots generated in the GUI. Adjusting peaks --------------- The first option to change peaks is to change the frequency filter value. -Higher frequency values will result in more denoising, which will help if the signal has many fast oscillations that should be ignored. +Lower frequency values will result in more denoising, which will help if the signal has many fast oscillations that should be ignored. A recommended workflow is to change the frequency slider and see how the selected trace looks. Then click 'Apply Changes' and change the presentation mode to see All Embryos. Inspect the new peaks for every embryo and stop once peaks are precise enough. @@ -46,8 +52,11 @@ To solve this problem, it is also possible to manually add new peaks or remove e The peak width can be controlled with the peak width slider. The value of 0.98 works well for the majority of samples. -To evaluate the peak width values, click the 'View Widths' button. -Increasing the value in the slider will increase the peak width, while decreasing the slider makes the peaks more narrow. +To better understand this parameter, refer to the `scipy.signal.find_peaks documentation `__. +To inspect the peak widths, click the 'View Widths' button. +Increasing the value in the slider will increase the peak width, while decreasing the slider makes the peaks narrower. + +.. NOTE:: Changes in the slider values must be applied to all samples by clicking "Apply Changes", otherwise they will be discarted. Once all peak data looks good, we can open other directories as another Group, to compare trace properties between them. diff --git a/docs/source/Data_analysis/Graphical_User_Interface.rst b/docs/source/Data_analysis/Graphical_User_Interface.rst index 308e64e..14db480 100644 --- a/docs/source/Data_analysis/Graphical_User_Interface.rst +++ b/docs/source/Data_analysis/Graphical_User_Interface.rst @@ -15,7 +15,7 @@ Loading the GUI --------------- First step to use the GUI is to activate the conda environment. -Refer to the Getting Started session if you haven't created an environment yet. +Refer to the `Getting Started <../Getting_Started.html>`__ session if you haven't created an environment yet. .. code:: bash @@ -106,7 +106,7 @@ Visualizing traces Once the data is loaded, you should see something similar to this: .. image:: /_static/gui-screenshot.png - :alt: GUI Initial screen + :alt: GUI Screeshot with loaded data The top app bar has buttons to change the data presentation. Below the top app bar there are two sliders. @@ -116,14 +116,26 @@ The sidebar presents which embryos are currently considered for plots and analys You can toggle the embryo status between these two categories. In the main view you will see the DFF trace of the currently selected embryo. The pink dots represent the peak indices. + +You can also visualize the signal from each channel, by clicking on the button in the right of the screen. +This window will present the signal from each channel and also the hatching point, which can be changed manually by dragging the line. + +Manually changing peak data +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + By pressing ``shift`` + ``left mouse click`` you can add a new peak to the plot. Because we usually have many points over the X axis, it can be hard to click exactly where we want the peak index to land. To help with this, the actual peak index after clicking in the local maximum value for a small window around the point that was clicked. By pressing ``CTRL`` + ``left mouse click`` you can remove a peak. It also works on a small X axis range just like when adding new peaks. -You can also visualize the signal from each channel, by clicking on the button in the right of the screen. -This window will present the signal from each channel and also the hatching point, which can be changed manually by dragging the line. +The peak width can also be adjusted. +Click the button 'Adjust widths' to display handles on the peak boundaries. +To change the width, just drag the line to the desired position. + +The manual data is saved in ``peak_detection_params.json``, in a key named ``embryos``. +Click 'Clear manual data' to remove the manual data for the current sample of all samples at once. + View embryo movies ------------------ diff --git a/docs/source/Data_analysis/Peak_Detection.rst b/docs/source/Data_analysis/Peak_Detection.rst index c8af442..052247c 100644 --- a/docs/source/Data_analysis/Peak_Detection.rst +++ b/docs/source/Data_analysis/Peak_Detection.rst @@ -1,19 +1,24 @@ Peak Detection ============== -Peak detection is one of the core features of ``pasna_analysis``. +Peak detection is one of the core features of ``snazzy_analysis``. From the detected peaks, we derive most of the metrics used in this package: peak widths, amplitudes, rise times, decay times, and more. The algorithm consists of several steps, each with parameters that can be fine-tuned for optimal detection. -To understand how to adjust these parameters effectively, it's important to first understand the entire peak detection algorithm. -1. Peak Detection on a Low-Pass Filtered Signal +Each step is implemented as a single function that takes a params dictionary and changes the data in ``Trace._peak_idxes``. +If you want to change or extend the peak detection steps, just add another function that follows this interface as a new stage inside ``Trace.detect_peaks``. + +To understand how the different parameters influence the peak detection, it's important to first understand the entire peak detection algorithm. + +1. Peak Detection on Low-Passed Filtered Signal ----------------------------------------------- The ΔF/F (DFF) trace is filtered in the frequency domain using a ``freq_cutoff`` parameter: all frequencies above this value are removed, and the remaining low-frequency components are used to reconstruct the filtered trace. This acts as a smoothing step, which almost completely removes oscillations and short-duration peaks that do not correspond to actual activity bursts. The ``freq_cutoff`` can be adjusted in the GUI using a slider, and the reconstructed signal is updated in real time. -The default value of ``0.025 Hz`` works well for many traces, but traces with high-frequency noise may require a lower cutoff. +The default value of ``0.0025 Hz`` works well for many traces, but traces with high-frequency noise may require a lower cutoff. +Different types of samples will likely result in different traces and this value will have to be adjusted. Once we have the filtered trace, peaks are detected using the parameters ``fft_height`` and ``fft_prominence``. The ``fft_height`` parameter is especially important because the reconstructed signal often contains minor ripples before the first real burst. @@ -24,7 +29,7 @@ These are easy to identify, as they usually do not correspond to peaks in the or ------------------------------------- After detecting peaks in the filtered signal, the peak indices are mapped back to the original ΔF/F trace. -This step is necessary because frequency-domain transformations can slightly shift peak positions. +This step is necessary because the low-passed filter will result shift peak positions. The bursts of activity have a sharp rise and are followed closely by shorter oscillations. To properly mark bursts, we use the leftmost peak in each burst as the peak index. diff --git a/docs/source/Getting_Started.rst b/docs/source/Getting_Started.rst index 487a7df..ca92fad 100644 --- a/docs/source/Getting_Started.rst +++ b/docs/source/Getting_Started.rst @@ -7,9 +7,9 @@ Installation The project uses `conda `__ to manage dependencies. If you don’t already have conda, you can download and install it from the official website. -Make a copy of this repo (e.g. with ``git clone``), then ``cd`` into the root folder of the repo. +Make a copy of the repo (e.g. with ``git clone``), then ``cd`` into the root folder of the repo. -Recreate the conda environment: +Recreate the conda environment with the dependencies listed in ``environment.yml`` in the repo's root: .. code:: @@ -25,7 +25,6 @@ Organization ------------ The code is split in two modules. - Parsing raw data into csv files with the relevant ROI metrics is done using ``snazzy_processing``. The data analysis and GUI access is done using ``snazzy_analysis``. @@ -35,8 +34,7 @@ Each one of the modules has the following structure: * ``tests``: contains tests for the code * ``data``: contains the data for the analysis. This folder is kept out of github, and should be populated in your local copy * ``results``: contains the results of the analyses. It is also kept out of github and will be populated by performing the analyses -* ``notebooks``: contains examples of some steps of the pipeline. Also used for more specfic visualizations. -* ``docs``: project documentation +* ``notebooks``: contains examples of some steps of the pipeline. Also used for more specific visualizations. Running the code ---------------- @@ -46,12 +44,10 @@ Refer to the Getting Started session of each module for how to run the code. To process raw data, start with `Getting Started `__. To analyze the output of the processing step, go to `Getting Started `__. -The analyses can be executed using the provided jupyter notebooks, or running the files in the ``scripts`` directory. -The recommended way to analyze your data is to go through the notebooks in the following order: +The analyses can be executed using the provided jupyter notebooks, or using the GUI. -1. ``process-raw-data.ipynb`` -2. ``vnc-lengh.ipynb`` -3. ``activity.ipynb`` +Community Guidelines +-------------------- -There are details on how to use the code in each one of the notebooks. - \ No newline at end of file +Thank you for being interested in ``snazzy``! +Check more information about how to get involved in the Contributing section of the `Github repository's Readme `__. \ No newline at end of file From 35ca5c0f8d6218825389abdb74800608bff68af4 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Thu, 25 Sep 2025 16:10:04 -0400 Subject: [PATCH 06/14] docs: improve docstrings and type hints --- .../snazzy_processing/activity.py | 77 +++++++++++++---- .../snazzy_processing/centerline.py | 85 +++++++++++++++---- .../snazzy_processing/centerline_errors.py | 18 ++-- 3 files changed, 142 insertions(+), 38 deletions(-) diff --git a/snazzy_processing/snazzy_processing/activity.py b/snazzy_processing/snazzy_processing/activity.py index 3dba122..86f1627 100644 --- a/snazzy_processing/snazzy_processing/activity.py +++ b/snazzy_processing/snazzy_processing/activity.py @@ -1,15 +1,26 @@ import math +from pathlib import Path import numpy as np from snazzy_processing import csv_handler -def apply_mask(img, mask): - """Returns a np masked array, representing the masked image. +def apply_mask(img: np.ndarray, mask: np.ndarray): + """Apply a mask to an image. Accepts a few combinations of dimensions: 2D img and 2D mask, 3D img and - 2D mask, and 3D img and 3D mask.""" + 2D mask, and 3D img and 3D mask. + + Parameters: + img (np.ndarray): + A 2D or 3D np array representing an image. + mask (np.ndarray): + A 2D or 3D np array to mask the image. + + Returns: + masked_img (np.ma.MaskedArray) + """ if mask.ndim == 2 and img.ndim == 3: try: mask = np.broadcast_to(mask, img.shape).astype(np.bool_) @@ -29,28 +40,60 @@ def apply_mask(img, mask): return masked_img -def get_activity(masked_img): - return masked_img.mean(axis=(1, 2)) +def get_activity(masked_img: np.ma.MaskedArray) -> np.ma.MaskedArray: + """The median activity from a 3D MaskedArray. + Parameters: + masked_img (np.ma.MaskedArray): + A 3D masked array of shape (T, Y, X). -def get_output_data(signals, frame_interval=6): - if signals.ndim != 3: - raise ValueError("Expected a 3D array with shape (N, t, 3)") - N, t, _ = signals.shape - time = np.arange(t) * frame_interval - time = time[None, :, None] - time = np.repeat(time, N, axis=0) - - return np.concatenate([time, signals], axis=2) + Returns: + mean_activity (np.ma.MaskedArray): + Mean activity value over time. + """ + return masked_img.mean(axis=(1, 2)) -def export_csv(ids, signals, output_dir, frame_interval=6): +def export_csv(ids: list[int], signals: list, output_dir: Path, frame_interval=6): + """Write calculated activity as csv. + + Parameters: + ids (list[int]): + Embryo Ids used to name each csv file. + Must match the indices of the signals list. + signals (list): + Activity signals. + output_dir (Path): + Path to write csv files. + frame_interval (int): + The interval of acquistion of frames in seconds. + """ signals = np.asarray(signals) csv_paths = [output_dir.joinpath(f"emb{id}.csv") for id in ids] - data = get_output_data(signals, frame_interval) + data = add_timepoints(signals, frame_interval) csv_handler.write_files(csv_paths, data, ["time", "gcamp", "tomato"]) - return True + +def add_timepoints(signals: np.ndarray, frame_interval=6) -> np.ndarray: + """Add time information (in seconds) to signal data. + + Parameters: + signals (np.ndarray): + An array that represents act and struct channel signals. + frame_interval (int): + The interval of acquistion of frames in seconds. + + Returns: + A np.ndarray of shape (N, t, 3). + """ + if signals.ndim != 3: + raise ValueError("Expected a 3D array with shape (N, t, 2)") + N, t, _ = signals.shape + time = np.arange(t) * frame_interval + time = time[None, :, None] + time = np.repeat(time, N, axis=0) + + return np.concatenate([time, signals], axis=2) diff --git a/snazzy_processing/snazzy_processing/centerline.py b/snazzy_processing/snazzy_processing/centerline.py index be9ebab..df16fb6 100644 --- a/snazzy_processing/snazzy_processing/centerline.py +++ b/snazzy_processing/snazzy_processing/centerline.py @@ -1,5 +1,6 @@ import warnings +from matplotlib.axes import Axes import numpy as np from scipy import ndimage as ndi from skimage.draw import line @@ -8,10 +9,23 @@ from skimage.measure import label from skimage.morphology import binary_opening, disk, remove_small_holes from sklearn import linear_model +from sklearn.linear_model import RANSACRegressor -def binarize(image, threshold_method="multiotsu"): - """Create a binary image from the largest region after thresholding.""" +def binarize(image: np.ndarray, threshold_method="multiotsu"): + """Create a binary image from the largest region after thresholding. + + Parameters: + image (np.ndarray): + A 2 dimensional np array. + threshold_method ('multiotsu' | 'otsu'): + Threshold method used to binary the image. + For a higher threshold value, use 'otsu'. + + Returns: + binary_image (np.ndarray): + binary image with same dimensions as `image`. + """ if image.ndim != 2: raise ValueError("Image can only have 2 dimensions.") @@ -24,6 +38,7 @@ def binarize(image, threshold_method="multiotsu"): else: raise ValueError(f"Unsupported threshold method: {threshold_method}.") + # morphological operations to make the ROI better fit the VNC: remove_small_holes(bin_img, 200, out=bin_img) binary_opening(bin_img, footprint=disk(5), out=bin_img) @@ -33,8 +48,21 @@ def binarize(image, threshold_method="multiotsu"): return largest_label -def get_DT_maxima(image, thres_rel=0.6, min_dist=5): - """Calculates a distance transform and returns local maxima points.""" +def get_DT_maxima(image: np.ndarray, thres_rel=0.6, min_dist=5) -> np.ndarray: + """Points of local maxima from a distance transform image. + + Parameters: + image (np.ndarray): + A 2 dimensional np array. + thres_rel (float): + Minimum intensity of local maxima points relative to the maximum. + min_dist (int): + Minimum distance separating local maxima points. + + Returns: + np.ndarray: + Array of coordinate pairs. + """ distance_transform = ndi.distance_transform_cdt(image, metric="chessboard") return peak_local_max( distance_transform, @@ -44,13 +72,18 @@ def get_DT_maxima(image, thres_rel=0.6, min_dist=5): ) -def get_DT_image(binary_image, metric="chessboard"): +def get_DT_image(binary_image: np.ndarray, metric="chessboard") -> np.ndarray: """Returns the distance transform image for visualization.""" return ndi.distance_transform_cdt(binary_image, metric=metric) -def apply_ransac(coords): - """Returns the centerline estimated by applying a RANSAC linear model.""" +def apply_ransac(coords: np.ndarray): + """Returns the centerline estimated by applying a RANSAC linear model. + + Parameters: + coords (np.ndarray): + Array of coordinate points (y, x) + """ y = coords.T[0] x = coords.T[1].reshape(-1, 1) @@ -70,7 +103,19 @@ def apply_ransac(coords): return regressor -def centerline_mask(img_shape, predictor): +def centerline_mask(img_shape: tuple, predictor: RANSACRegressor.predict) -> np.ndarray: + """Create a mask from RANSAC predicted values. + + Parameters: + img_shape (tuple): + Shape of a 2D image, used to create an output mask of same shape. + predictor (RANSACRegressor.predict): + Fitted RANSACRegressor predictor. + + Returns: + mask (np.ndarray): + Centerline values as a mask with same shape as `img_shape`. + """ if len(img_shape) != 2: raise ValueError("Image can only have 2 dimensions.") @@ -90,7 +135,17 @@ def centerline_mask(img_shape, predictor): return mask -def measure_length(masked_image, pixel_width): +def measure_length(masked_image: np.ndarray, pixel_width: float) -> float: + """Length of the masked image. + + Calculated as the distance between ends of the image masked with the centerline. + + Parameters: + masked_image (np.ndarray): + 2D image where the centerline masked was applied + pixel_width (float): + Physical size of a pixel in the image. + """ if masked_image.ndim != 2: raise ValueError("Image can only have 2 dimensions.") @@ -104,7 +159,9 @@ def measure_length(masked_image, pixel_width): return distance * pixel_width -def centerline_dist(bin_image, pixel_width=1.62, thres_rel=0.6, min_dist=5): +def centerline_dist( + bin_image: np.ndarray, pixel_width=1.62, thres_rel=0.6, min_dist=5 +) -> float: """Returns the centerline length estimation based on EDT maxima points.""" if bin_image.ndim != 2: raise ValueError("Centerline distance can only be calculated on a 2D image.") @@ -124,13 +181,11 @@ def centerline_dist(bin_image, pixel_width=1.62, thres_rel=0.6, min_dist=5): mask = centerline_mask(bin_image.shape, estimator.predict) bin_image[~mask] = 0 - distance = measure_length(bin_image, pixel_width) + return measure_length(bin_image, pixel_width) - return distance - -def view_centerline_dist(binary_image, ax, thres_rel=0.6, min_dist=5): - """Returns the centerline length estimation based on EDT maxima points.""" +def view_centerline_dist(binary_image: np.ndarray, ax: Axes, thres_rel=0.6, min_dist=5): + """Plot the centerline length estimation based on EDT maxima points.""" coords = get_DT_maxima(binary_image, thres_rel, min_dist) if coords.shape[0] <= 2: diff --git a/snazzy_processing/snazzy_processing/centerline_errors.py b/snazzy_processing/snazzy_processing/centerline_errors.py index 6132e4c..20918ba 100644 --- a/snazzy_processing/snazzy_processing/centerline_errors.py +++ b/snazzy_processing/snazzy_processing/centerline_errors.py @@ -1,14 +1,16 @@ +from pathlib import Path + import numpy as np from snazzy_processing import utils -def percentual_err(measured, annotated): +def percentual_err(measured: np.ndarray, annotated: np.ndarray): err = np.abs((measured - annotated)) / annotated return np.average(err), np.max(err) -def compare(measured, annotated): +def compare(measured: np.ndarray, annotated: np.ndarray): # make sure both nparrays have the same size: min_len = min(measured.shape[0], annotated.shape[0]) annotated = annotated[:min_len] @@ -16,17 +18,21 @@ def compare(measured, annotated): return percentual_err(measured, annotated) -def point_wise_err(measured, annotated): +def point_wise_err(measured: np.ndarray, annotated: np.ndarray): min_len = min(measured.shape[0], annotated.shape[0]) annotated = annotated[:min_len] measured = measured[:min_len] return np.abs(measured - annotated) / annotated -def get_matching_embryos(embryos, annotated, LUT=None): +def get_matching_embryos( + embryos: list[Path], annotated: list[Path], LUT: None | dict[int, int] = None +): """Maps embryo files to corresponding annotated files, based on the LUT. - The look-up table is only composed of numbers, so this function ports those numbers to the filename convention used here. Also makes sure that the embryos in the LUT actually exist as files. + The look-up table is a mapping of emb number to emb number, so this function + ports those numbers to the filename convention used here. Also makes sure + that the embryos in the LUT actually exist as files. """ pairs = {} @@ -51,6 +57,6 @@ def get_matching_embryos(embryos, annotated, LUT=None): return pairs -def evaluate_CLE_global(measured, annotated): +def evaluate_CLE_global(measured: list[Path], annotated: list[Path]): """Compares measured values against manually annotated data.""" return {e: compare(measured[e], annotated[e]) for e in measured.keys()} From 8655d43f2045c0fa531200a08b4dc0eb7e617608 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Thu, 25 Sep 2025 16:10:26 -0400 Subject: [PATCH 07/14] tests: update function names --- snazzy_processing/tests/test_activity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/snazzy_processing/tests/test_activity.py b/snazzy_processing/tests/test_activity.py index 77d5d4b..dc2d9a0 100644 --- a/snazzy_processing/tests/test_activity.py +++ b/snazzy_processing/tests/test_activity.py @@ -82,7 +82,7 @@ def test_output_data_includes_time(): signals_shape = (2, 50, 2) signals = np.arange(0, 200).reshape(signals_shape) - output = activity.get_output_data(signals, frame_interval=5) + output = activity.add_timepoints(signals, frame_interval=5) N, t, _ = signals_shape assert output.shape == (N, t, 3) @@ -96,7 +96,7 @@ def test_output_data_uses_frame_interval(): frame_interval = 4 signals = np.arange(0, 200).reshape(signals_shape) - output = activity.get_output_data(signals, frame_interval) + output = activity.add_timepoints(signals, frame_interval) assert np.array_equal(np.arange(0, 50), output[0, :, 0] / frame_interval) @@ -125,6 +125,6 @@ def test_can_write_data_for_many_embryos(tmp_path): def test_export_csv_raises_when_no_embryos(tmp_path): - # will raise when `activity.get_output_data` is called + # will raise when `activity.add_timepoints` is called with pytest.raises(ValueError): activity.export_csv([], [], tmp_path) From 3267ccf7f795c42f738d8a66c192cefa5cf1cea9 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Thu, 25 Sep 2025 16:22:39 -0400 Subject: [PATCH 08/14] fix(gui): remove old references to pasnascope --- snazzy_analysis/snazzy_analysis/gui/gui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/snazzy_analysis/snazzy_analysis/gui/gui.py b/snazzy_analysis/snazzy_analysis/gui/gui.py index 7f48ad6..bcd4724 100644 --- a/snazzy_analysis/snazzy_analysis/gui/gui.py +++ b/snazzy_analysis/snazzy_analysis/gui/gui.py @@ -58,7 +58,7 @@ def __init__(self): self.filtered_dff = None self.display_filtered_dff = False - self.setWindowTitle("Pasna Analysis") + self.setWindowTitle("SNAzzy") self.setGeometry(100, 100, 1200, 600) self.paint_menu() @@ -69,7 +69,7 @@ def __init__(self): central_widget.setLayout(self.layout) self.placeholder = QLabel( - "To get started, open a directory with pasnascope output." + "To get started, open a directory with snazzy_processing output." ) self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self.layout.addWidget(self.placeholder) @@ -166,7 +166,7 @@ def on_dialog_accepted(dialog_values): def handle_open_err(self, err: Exception): try: self.placeholder.setText( - "To get started, open a directory with pasnascope output." + "To get started, open a directory with snazzy_processing output." ) except RuntimeError: pass From 9823923cbada1f1b771377e7680381c35255a301 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Thu, 25 Sep 2025 16:23:06 -0400 Subject: [PATCH 09/14] refactor: remove unused code --- snazzy_analysis/snazzy_analysis/__init__.py | 1 - .../snazzy_analysis/experiment_config.py | 12 -- snazzy_analysis/snazzy_analysis/mog_fit.py | 132 ------------------ 3 files changed, 145 deletions(-) delete mode 100644 snazzy_analysis/snazzy_analysis/experiment_config.py delete mode 100644 snazzy_analysis/snazzy_analysis/mog_fit.py diff --git a/snazzy_analysis/snazzy_analysis/__init__.py b/snazzy_analysis/snazzy_analysis/__init__.py index 231aaa3..c5c9231 100644 --- a/snazzy_analysis/snazzy_analysis/__init__.py +++ b/snazzy_analysis/snazzy_analysis/__init__.py @@ -5,5 +5,4 @@ from .trace import BaselineStrategies, Trace from .embryo import Embryo from .experiment import Experiment -from .experiment_config import ExperimentConfig from .group import Group diff --git a/snazzy_analysis/snazzy_analysis/experiment_config.py b/snazzy_analysis/snazzy_analysis/experiment_config.py deleted file mode 100644 index c2e0f16..0000000 --- a/snazzy_analysis/snazzy_analysis/experiment_config.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from typing import Literal - - -@dataclass -class ExperimentConfig: - """Provides configuration to create an Experiment.""" - - first_peak_threshold: int - to_exclude: list[int] - dff_strategy: Literal["baseline", "local_minima"] = "baseline" - has_transients: bool = False diff --git a/snazzy_analysis/snazzy_analysis/mog_fit.py b/snazzy_analysis/snazzy_analysis/mog_fit.py deleted file mode 100644 index df3a717..0000000 --- a/snazzy_analysis/snazzy_analysis/mog_fit.py +++ /dev/null @@ -1,132 +0,0 @@ -from pathlib import Path -import numpy as np -import matplotlib.pyplot as plt -from scipy.signal import find_peaks -from scipy.optimize import curve_fit -from scipy.special import erf - -from snazzy_analysis import Experiment - - -# Skewed Gaussian function -def skewed_gaussian(x, A, mu, sigma, skew): - norm_gauss = np.exp(-((x - mu) ** 2) / (2 * sigma**2)) - skew_factor = 1 + erf(skew * (x - mu) / (np.sqrt(2) * sigma)) - return A * norm_gauss * skew_factor - - -# Ripple function (damped sine wave) -def ripple(x, A_r, mu_r, tau_r, f_r): - ripple_wave = ( - A_r * np.exp(-(x - mu_r) / tau_r) * np.sin(2 * np.pi * f_r * (x - mu_r)) - ) - ripple_wave[x < mu_r] = 0 # Ensure ripples only happen after the peak - return ripple_wave - - -# Full model with multiple peaks and optional ripples -def multi_skewed_gaussian_with_ripples(x, *params): - num_peaks = len(params) // 4 # Each peak has 4 parameters: A, mu, sigma, skew - total_signal = np.zeros_like(x) - - for i in range(num_peaks): - A, mu, sigma, skew = params[i * 4 : (i + 1) * 4] - total_signal += skewed_gaussian(x, A, mu, sigma, skew) - - # # Add ripples if necessary - # if should_add_ripples(y, peaks, properties)[i]: - # total_signal += ripple(x, A_r=0.3 * A, mu_r=mu + 2, tau_r=5, f_r=2) - - return total_signal - - -# Automatically detect peaks -def detect_peaks(y, min_height=None, min_prominence=None): - """Automatically adjust peak detection parameters based on the signal's properties.""" - if min_height is None: - min_height = np.percentile( - y, 95 - ) # Adaptive threshold based on top 10% intensities - if min_prominence is None: - min_prominence = (np.max(y) - np.min(y)) * 0.05 # Adaptive prominence - - peaks, properties = find_peaks( - y, height=min_height, distance=60, prominence=min_prominence, width=5 - ) - return peaks, properties - - -# Automatically initialize fitting parameters based on peaks -def generate_initial_guesses(x, y): - peaks, properties = detect_peaks(y, min_height=0.05) - initial_guesses = [] - - for i, peak in enumerate(peaks): - A_guess = properties["peak_heights"][i] - mu_guess = x[peak] - sigma_guess = properties["widths"][i] / 2 - skew_guess = 0 # Assume symmetric initially - - initial_guesses.extend([A_guess, mu_guess, sigma_guess, skew_guess]) - - return initial_guesses - - -# Determine which peaks should have ripples -def should_add_ripples(y, peaks, properties): - ripple_flags = np.zeros(len(peaks), dtype=bool) - prominence_threshold = np.percentile(properties["prominences"], 75) # Top 25% peaks - for i, prom in enumerate(properties["prominences"]): - if prom > prominence_threshold: - ripple_flags[i] = True - return ripple_flags - - -# Fit a single trace -def fit_trace(x, y): - """Fully automated process for fitting a single trace""" - peaks, properties = detect_peaks(y, min_height=0.05) - print(len(peaks)) - print(properties) - if len(peaks) == 0: - return None # Skip if no peaks are found - - initial_guesses = generate_initial_guesses(x, y) - - try: - popt, _ = curve_fit( - multi_skewed_gaussian_with_ripples, x, y, p0=initial_guesses - ) - return popt, peaks - except RuntimeError: - return None, None # If fitting fails, return None - - -exp = Experiment( - Path("/home/cdp58/Documents/repos/pasna_analysis/data/20240514_testing"), - to_exclude=[1, 2], - # Path("/home/cdp58/Documents/repos/pasna_analysis/data/202409011-vglutdf") -) -tr = exp.embryos["emb3"].trace.dff[900:] -x = exp.embryos["emb3"].trace.time[900:] -# Fit the synthetic trace -fitted_params, peaks = fit_trace(x, tr) -print(fitted_params) - -# Generate fitted curve -if fitted_params is not None: - fitted_signal = multi_skewed_gaussian_with_ripples(x, *fitted_params) -else: - print("Fitting failed.") - -# Plot results -plt.figure(figsize=(8, 4)) -plt.plot(x, tr, label="Real Signal", color="blue", alpha=0.7) -if fitted_params is not None: - plt.plot(x, fitted_signal, label="Fitted Model", color="red", linestyle="dashed") - plt.plot(x[peaks], tr[peaks], "r.") -plt.xlabel("Time (or x-axis)") -plt.ylabel("Signal Intensity") -plt.legend() -plt.title("Fitted Signal with Multiple Asymmetric Gaussians and Ripples") -plt.show() From 35a5d16f5905edc7167d79c82e8d4cc273f1cb29 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Thu, 25 Sep 2025 16:38:43 -0400 Subject: [PATCH 10/14] refactor (ipynb): extract imports to first cell --- snazzy_analysis/notebooks/calculate_metrics.ipynb | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/snazzy_analysis/notebooks/calculate_metrics.ipynb b/snazzy_analysis/notebooks/calculate_metrics.ipynb index 384d172..30ea923 100644 --- a/snazzy_analysis/notebooks/calculate_metrics.ipynb +++ b/snazzy_analysis/notebooks/calculate_metrics.ipynb @@ -45,7 +45,11 @@ "from collections import Counter\n", "from pathlib import Path\n", "\n", + "import matplotlib.pyplot as plt\n", "import numpy as np\n", + "from scipy.stats import ttest_rel\n", + "import scipy.signal as spsig\n", + "import seaborn as sns\n", "import pandas as pd\n", "\n", "from snazzy_analysis import Experiment, Group, FrequencyAnalysis, utils, myplots, TracePhases" @@ -780,9 +784,6 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "import scipy.signal as spsig\n", - "\n", "emb = wt.experiments[\"20240919_25C\"].embryos[9]\n", "trace = emb.trace\n", "\n", @@ -816,10 +817,6 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "import seaborn as sns\n", - "from scipy.stats import ttest_rel\n", - "\n", "trace = wt.experiments[\"20240611_25C\"].embryos[4].trace\n", "\n", "\n", @@ -1074,7 +1071,7 @@ ], "metadata": { "kernelspec": { - "display_name": "golf-env", + "display_name": "snazzy-env", "language": "python", "name": "python3" }, From 22ed8a8bfbfe58228378b04b11c5e8951964d57d Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Fri, 26 Sep 2025 15:38:23 -0400 Subject: [PATCH 11/14] docs: update docstrings and type hints --- .../snazzy_processing/csv_handler.py | 48 +++- .../snazzy_processing/find_hatching.py | 6 +- .../snazzy_processing/full_embryo_length.py | 110 ++++++-- .../snazzy_processing/pipeline.py | 122 ++++++++- snazzy_processing/snazzy_processing/roi.py | 33 ++- .../snazzy_processing/slice_img.py | 238 ++++++++++++++---- snazzy_processing/snazzy_processing/utils.py | 2 +- .../snazzy_processing/vnc_length.py | 73 +++++- 8 files changed, 526 insertions(+), 106 deletions(-) diff --git a/snazzy_processing/snazzy_processing/csv_handler.py b/snazzy_processing/snazzy_processing/csv_handler.py index 614cbfe..be59c0e 100644 --- a/snazzy_processing/snazzy_processing/csv_handler.py +++ b/snazzy_processing/snazzy_processing/csv_handler.py @@ -1,13 +1,42 @@ +from pathlib import Path + import numpy as np -def read(csv_path, delimiter=",", skip_header=1, usecols=None): +def read(csv_path: Path, delimiter=",", skip_header=1, usecols=None) -> np.ndarray: + """Read csv file as a numpy array. + + Parameters: + csv_path (Path): + Path to csv file. + delimiter (str): + Delimiter used in the csv file. Defaults to ','. + skip_header (int): + Number of header lines, that will be skipped. + usecols: (list[int] | None): + List with column numbers to read. + """ return np.genfromtxt( csv_path, delimiter=delimiter, skip_header=skip_header, usecols=usecols ) -def write_files(csv_paths, signals, header, fmt="%.2f"): +def write_files( + csv_paths: list[Path], signals: np.ndarray, header: list[str], fmt="%.2f" +): + """Write data as csv. + + Parameters: + csv_paths (list[Path]): + List of csv paths for files that will be created. + signals (np.ndarray): + Array with data to be saved as csv. + header (list[str]): + Column names. + fmt (str): + Format string. Refer to `np.savetxt` docs for details. + Defaults to "%.2f". + """ for csv_path, signal in zip(csv_paths, signals): if csv_path.exists(): print(f"File {csv_path.stem} already exists. Skipping..") @@ -15,7 +44,20 @@ def write_files(csv_paths, signals, header, fmt="%.2f"): write_file(csv_path, signal, header, fmt) -def write_file(csv_path, signal, header, fmt="%.2f"): +def write_file(csv_path: Path, signal: np.ndarray, header: list[str], fmt="%.2f"): + """Write a csv file. + + Parameters: + csv_path (Path): + Path to save the csv file. + signal (np.ndarray): + Array with data to be saved as csv. + header (list[str]): + Column names. + fmt (str): + Format string. Refer to `np.savetxt` docs for details. + Defaults to "%.2f". + """ header_line = ", ".join(header) np.savetxt( csv_path, diff --git a/snazzy_processing/snazzy_processing/find_hatching.py b/snazzy_processing/snazzy_processing/find_hatching.py index 7e4c41e..8df2be9 100644 --- a/snazzy_processing/snazzy_processing/find_hatching.py +++ b/snazzy_processing/snazzy_processing/find_hatching.py @@ -2,8 +2,8 @@ import numpy as np -def find_hatching_point(img_path): - """First draft on how to calculate hatching points. +def find_hatching_point(img_path, fraction_threshold=0.95): + """Estimate hatching point by changes in the structural channel signal. Returns: hp (int): last slice before hatching. @@ -14,6 +14,6 @@ def find_hatching_point(img_path): # Assume that hatching occurs when the total pixel sum decreases by 5% # between 2 frames. for i, r in enumerate(ratios): - if r < 0.95: + if r < fraction_threshold: return i * 2 - 100 return i * 2 - 100 diff --git a/snazzy_processing/snazzy_processing/full_embryo_length.py b/snazzy_processing/snazzy_processing/full_embryo_length.py index 06f5ba5..26ec724 100644 --- a/snazzy_processing/snazzy_processing/full_embryo_length.py +++ b/snazzy_processing/snazzy_processing/full_embryo_length.py @@ -1,4 +1,5 @@ import math +from pathlib import Path import matplotlib.pyplot as plt import numpy as np @@ -13,8 +14,13 @@ from snazzy_processing import csv_handler -def binarize(image): - """Returns a binary image with a single label, using a low threshold.""" +def binarize(image: np.ndarray) -> np.ndarray: + """Returns a binary image with a single label, using a low threshold. + + Parameters: + image (np.ndarray): + 2D image + """ thr = threshold_triangle(image) thr -= 0.1 * thr bin_img = image > thr @@ -29,8 +35,15 @@ def binarize(image): return largest_label -def binarize_low_embryo_background(image): - """Returns a binary image with a single label, assuming that background values are _higher_ than non-VNC pixels in the embryo.""" +def binarize_low_embryo_background(image: np.ndarray) -> np.ndarray: + """Returns a binary image with a single label. + + Use when background values are _higher_ than non-VNC pixels in the embryo. + + Parameters: + image (np.ndarray): + 2D image + """ blurred_image = gaussian(image, sigma=2) thr = threshold_multiotsu(blurred_image, classes=3) img = np.digitize(blurred_image, thr) @@ -44,9 +57,17 @@ def binarize_low_embryo_background(image): return filled_label -def length_from_regions_props(img, pixel_width=1.62): - """Calculates length of a binary image containing a single label.""" - regions = regionprops(img.astype(np.uint8)) +def length_from_regions_props(binary_img: np.ndarray, pixel_width=1.62) -> float: + """Calculates length of a binary image containing a single label. + + Parameters: + binary_img (np.ndarray): + Binary image + pixel_width (float): + Length represented by a pixel. + Defaults to 1.62. + """ + regions = regionprops(binary_img.astype(np.uint8)) if len(regions) > 1: print( f"WARN: expected a single label to calculate length, but got {len(regions)}." @@ -54,7 +75,27 @@ def length_from_regions_props(img, pixel_width=1.62): return regions[0].axis_major_length * pixel_width -def read_and_preprocess_image(img_path, start=None, end=None, interval=100): +def read_and_preprocess_image( + img_path: Path, start=None, end=None, interval=100 +) -> np.ndarray: + """Preprocess the image to measure full embryo length. + + Enhance contrast by averaging and histogram equalization. + + Parameters: + img_path (Path): + Image path + start (int | None): + Starting image frame. Starts at first frame if None. + end (int | None): + Ending image frame. Ends at last frame if None. + interval (int): + How many frames to process if start and end are not provided. + + Returns: + equalized_img (np.ndarray): + The processed image with enhanced contrast. + """ if start is None and end is None: # try to sample frames from the start of the movie with tifffile.TiffFile(img_path) as tif: @@ -67,15 +108,7 @@ def read_and_preprocess_image(img_path, start=None, end=None, interval=100): return equalize_hist(img) -def label_and_get_len(img, low_non_VNC): - if low_non_VNC: - bin_img = binarize_low_embryo_background(img) - else: - bin_img = binarize(img) - return length_from_regions_props(bin_img) - - -def measure(img_path, low_non_VNC=False, start=None, end=None, interval=100): +def measure(img_path, low_non_VNC=False, start=None, end=None, interval=100) -> float: """Calculates the embryo length, based on a movie fragment. It's best to use an interval of 50 to 100 frames, and to pick frames @@ -84,15 +117,32 @@ def measure(img_path, low_non_VNC=False, start=None, end=None, interval=100): The length is estimated using the major axis of the ellipse that matches the binary image. This is a valid estimate because the embryo shape is fairly regular and resembles an ellipse. + + Parameters: + img_path (Path): + Image path + low_non_VNC (bool): + Flag to determine how to binarize the image. + Pick True if the VNC has lower signal than the rest of the embryo. + Defaults to `True`. + start (int | None): + Starting image frame. Starts at first frame if None. + end (int | None): + Ending image frame. Ends at last frame if None. + interval (int): + How many frames to process if start and end are not provided. """ img = read_and_preprocess_image(img_path, start, end, interval) - emb_length = label_and_get_len(img, low_non_VNC) + if low_non_VNC: + binary_img = binarize_low_embryo_background(img) + else: + binary_img = binarize(img) - return emb_length + return length_from_regions_props(binary_img) -def view_full_embryo_length(img, original_img): +def view_full_embryo_length(img: np.ndarray, original_img: np.ndarray): """Visualization of how the length is calculated for the full embryo. Expects a binary image containing a single label.""" @@ -133,21 +183,29 @@ def view_full_embryo_length(img, original_img): plt.show() -def get_output_data(ids, lengths): - return np.concatenate((ids[:, None], lengths[:, None]), axis=1) +def get_output_data(ids: list[int], lengths: list[float]): + """Format data to be saved as csv. + Parameters: + ids (list[int]): + List of Embryo Ids, that should match indices on the `lenghts` list. + lenghts (list[float]): + List of embryo lengths, that should match inidces of the `ids` list. -def export_csv(ids, lengths, output_path): + Returns: + A np.ndarray of shape (N, 2). + """ lengths = np.asarray(lengths) - ids = np.asarray(ids) + return np.concatenate((ids[:, None], lengths[:, None]), axis=1) + +def export_csv(ids: list[int], lengths: list[float], output_path: Path): + """Write Embryo lengths as a csv file.""" data = get_output_data(ids, lengths) csv_handler.write_file(output_path, data, ["ID", "full_length"]) - return True - def get_annotated_data(csv_path): """Reads annotated data from a csv file.""" diff --git a/snazzy_processing/snazzy_processing/pipeline.py b/snazzy_processing/snazzy_processing/pipeline.py index 4845f82..f218734 100644 --- a/snazzy_processing/snazzy_processing/pipeline.py +++ b/snazzy_processing/snazzy_processing/pipeline.py @@ -1,5 +1,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime +from pathlib import Path import shutil import numpy as np @@ -15,8 +16,23 @@ ) -def measure_vnc_length(embs_src, res_dir, downsampling, threshold_method="multiotsu"): - """Calculates VNC length for all embryos in a directory.""" +def measure_vnc_length( + embs_src: Path, res_dir: Path, downsampling: int, threshold_method="multiotsu" +): + """Calculates VNC length for all embryos in a directory and saves as CSV. + + Parameters: + embs_src (Path): + Directory with embryo files. + res_dir (Path): + Path to the results directory, where the csv files will be saved. + downsampling (int): + Step size to calculate ROI lengths. + threshold_method ('mulitotsu' | 'otsu'): + Threshold method used to calculate the ROI. + Refer to `centerline.binarize` for more details. + Defaults to 'multiotsu'. + """ embs = sorted(embs_src.glob("*ch2.tif"), key=utils.emb_number) output_dir = res_dir.joinpath("lengths") embs = [emb for emb in embs if not already_created(emb, output_dir)] @@ -42,7 +58,15 @@ def measure_vnc_length(embs_src, res_dir, downsampling, threshold_method="multio return len(ids) -def already_created(emb, output): +def already_created(emb: str, output: Path) -> bool: + """Check if the embryo was already processed based on tif file name. + + Parameters: + emb (str): + File name, for example emb2-ch2.tif. + output (Path): + Path where to search if csv file was created. + """ if not output.exists(): return False id = utils.emb_number(emb) @@ -50,7 +74,19 @@ def already_created(emb, output): return output_path.exists() -def calculate_length(emb, downsampling, threshold_method="multiotsu"): +def calculate_length(emb: Path, downsampling: int, threshold_method="multiotsu"): + """Calculates VNC length for an embryo. + + Parameters: + emb (Path): + Path to emb tif file. + downsampling (int): + Step size to calculate ROI lengths. + threshold_method ('mulitotsu' | 'otsu'): + Threshold method used to calculate the ROI. + Refer to `centerline.binarize` for more details. + Defaults to 'multiotsu'. + """ id = utils.emb_number(emb.stem) hp = find_hatching.find_hatching_point(emb) hp -= hp % downsampling @@ -61,7 +97,22 @@ def calculate_length(emb, downsampling, threshold_method="multiotsu"): return id, vnc_len -def measure_embryo_full_length(embs_src, res_dir, low_non_VNC=False): +def measure_embryo_full_length(embs_src: Path, res_dir: Path, low_non_VNC=False) -> int: + """Calculates full embryo length for all embryos in a directory. + + Parameters: + embs_src (Path): + Directory with embryo files. + res_dir (Path): + Path to the results directory, where the csv files will be saved. + low_non_VNC (bool): + Pass `False` if the signal in the vnc is lower than in the rest of the embryo. + Defaults to `True`. + + Returns: + count (int): + Number of embryos that were processed + """ embs = sorted(embs_src.glob("*ch2.tif"), key=utils.emb_number) output = res_dir.joinpath("full-length.csv") full_lengths = [] @@ -90,8 +141,21 @@ def measure_embryo_full_length(embs_src, res_dir, low_non_VNC=False): return len(full_lengths) -def calc_activities(embs_src, res_dir, window): - """Calculate activity for active and structural channels""" +def calc_activities(embs_src: Path, res_dir: Path, window: int): + """Calculates activity signal for all embryos in a directory. + + Parameters: + embs_src (Path): + Directory with embryo files. + res_dir (Path): + Path to the results directory, where the csv files will be saved. + window (int): + Step interval to take each measurement. + + Returns: + count (int): + Number of embryos that were processed + """ active = sorted(embs_src.glob("*ch1.tif"), key=utils.emb_number) struct = sorted(embs_src.glob("*ch2.tif"), key=utils.emb_number) @@ -124,7 +188,21 @@ def calc_activities(embs_src, res_dir, window): return len(ids) -def calc_activity(act, stct, window): +def calc_activity(act: Path, stct: Path, window: int) -> tuple[int, np.ma.MaskedArray]: + """Calculates activity signal for an embryo. + + Parameters: + act (Path): + Directory with embryo files. + stct (Path): + Path to the results directory, where the csv files will be saved. + window (int): + Step interval to take each measurement. + + Returns: + count (int): + Number of embryos that were processed + """ id = utils.emb_number(act) if id != utils.emb_number(stct): raise ValueError( @@ -145,7 +223,21 @@ def calc_activity(act, stct, window): return id, signals -def clean_up_files(embs_src, first_frames_path, tif_path): +def clean_up_files( + embs_src: Path | None, first_frames_path: Path | None, tif_path: Path | None +): + """Remove files generated by the pipeline. + + Passing None to any of the parameters if you want to keep them. + + Parameters: + embs_src (Path): + Path where the individual movies are saved. + first_frames_path (Path): + Path where the image with first frames is saved. + tif_path (Path): + Path to the converted tif file. + """ if embs_src: shutil.rmtree(embs_src) if first_frames_path.exists(): @@ -154,8 +246,16 @@ def clean_up_files(embs_src, first_frames_path, tif_path): tif_path.unlink(missing_ok=True) -def log_params(path, **kwargs): - with open(path, "+a") as f: +def log_params(output_path: Path, **kwargs): + """Write analysis parameters. + + Parameters: + output_path (Path): + Path to write. + **kwargs (dict): + All parameters to be saved. + """ + with open(output_path, "+a") as f: f.write("Starting a new analysis...\n") f.write(f"{datetime.now()}\n") for name, value in kwargs.items(): diff --git a/snazzy_processing/snazzy_processing/roi.py b/snazzy_processing/snazzy_processing/roi.py index a97c9ef..92170c5 100644 --- a/snazzy_processing/snazzy_processing/roi.py +++ b/snazzy_processing/snazzy_processing/roi.py @@ -6,10 +6,15 @@ from skimage.morphology import remove_small_objects -def get_single_roi(img): +def get_single_roi(img: np.ndarray) -> np.ndarray: """Calculates the ROI of a 2D grayscale image. - Values *outside* the ROI are marked as True, values inside are False.""" + Values *outside* the ROI are marked as True, values inside are False. + + Parameters: + img (np.ndarray): + A 2D numpy array representing an image. + """ if img.ndim != 2: raise ValueError("img should be a 2D matrix.") @@ -38,8 +43,16 @@ def get_single_roi(img): return np.logical_not(largest_label) -def get_roi(img, window=10): - """The ROI for an image, after downsampling the slices by `window`.""" +def get_roi(img: np.ndarray, window=10) -> np.ndarray: + """The ROIs for a 3D image, after downsampling the slices by `window`. + + Parameters: + img (np.ndarray): + A 3D np array representing an image. + window (int): + Interval used to calculate a new ROI. + Defaults to 10. + """ if img.ndim != 3: raise ValueError("img should be a 3D array.") @@ -55,8 +68,16 @@ def get_roi(img, window=10): return rois -def get_contours(img, window=10): - """Returns the contours of each image, based on their ROI.""" +def get_contours(img: np.ndarray, window=10) -> list[np.ndarray]: + """Returns the contours of each image, based on their ROI. + + Parameters: + img (np.ndarray): + A 3D np array representing an image. + window (int): + Interval used to calculate a new ROI. + Defaults to 10. + """ rois = get_roi(img, window=window) contours = [] diff --git a/snazzy_processing/snazzy_processing/slice_img.py b/snazzy_processing/snazzy_processing/slice_img.py index 71da8f4..1fcaebb 100644 --- a/snazzy_processing/snazzy_processing/slice_img.py +++ b/snazzy_processing/snazzy_processing/slice_img.py @@ -12,7 +12,7 @@ from snazzy_processing import utils -def get_metadata(img_path): +def get_metadata(img_path: Path): """Returns image metadata, used to open it as a memory map.""" with TiffFile(img_path) as tif: series = tif.series[0] @@ -23,8 +23,17 @@ def get_metadata(img_path): return offset, dtype, shape -def save_as_tiff(file, dest_path): - """Converts an nd2 image to tiff.""" +def save_as_tiff(file: Path, dest_path: Path): + """Converts an nd2 image to tiff. + + Does not overwrite the file if `dest_path` exists. + + Parameters: + file (Path): + Path to nd2 file. + dest_path (Path): + Path to save tiff file. + """ dest = Path(dest_path) if dest.exists(): print(f"File '{dest.name}' already exists.") @@ -33,8 +42,19 @@ def save_as_tiff(file, dest_path): f.write_tiff(dest_path, progress=True) -def save_first_frames_as_tiff(file, dest_path, n): - """Converts the first n slices of an nd2 image to tiff.""" +def save_first_frames_as_tiff(file: Path, dest_path: Path, n: int): + """Save the first frames of an nd2 file as tif. + + Does not overwrite the file if `dest_path` exists. + + Parameters: + file (Path): + Path to nd2 file. + dest_path (Path): + Path to save tiff file. + n (int): + Number of frames to save. + """ dest = Path(dest_path) if dest.exists(): print(f"File '{dest.name}' already exists.") @@ -45,19 +65,37 @@ def save_first_frames_as_tiff(file, dest_path, n): imwrite(dest_path, initial_frames) -def get_threshold(img, thres_adjust=0): - """Returns image threshold using the triangle method.""" +def get_threshold(img: np.ndarray, thres_adjust=0) -> float: + """Returns image threshold using the triangle method. + + Parameters: + img (np.ndarray): + 2D np array. + thres_adjust (int): + Increment the calculated threshold by this amount. + Makes it easy to manually adjust the threshold. + Defaults to 0. + """ return threshold_triangle(img) + thres_adjust -def within_boundaries(r, c, rows, cols): +def within_boundaries(r: int, c: int, rows: int, cols: int) -> bool: """Checks if `r` and `c` are valid coords in a `rows`x`cols` matrix.""" return r >= 0 and r < rows and c >= 0 and c < cols -def mark_neighbors(img, row, col, s): - """Expands from the search box and marks points connected to any point - within the search box. +def mark_neighbors(img: np.ndarray, row: int, col: int, s: int): + """Mark all points connected to (row, col) within the search box. + + Parameters: + img (np.ndarray): + 2D np array. + row (int): + Row coordinate. + col (int): + Columns coordinate. + s (int): + Search box size. Returns: counter: amount of pixels marked as visited @@ -90,8 +128,33 @@ def mark_neighbors(img, row, col, s): return dq.counter, dq.extremes -def get_bbox_boundaries(img, s=25, n_cols=3): - """Gets bbox boundaries (max and min values for both coordinates).""" +def get_bbox_boundaries( + img: np.ndarray, s=25, n_cols=3, min_pixel_count=6000 +) -> dict[int, list[int]]: + """Calculate bounding boxes. + + BBoxes are only calculated for connected components with more than + `min_pixel_count` points. + + Each bounding box is represented as the max and min values for both coordinates. + + Parameters: + img (np.ndarray): + 2D np array + s (int): + Iteration step when searching for connected components. + Defaults to 25. + n_cols (int): + Number of columns in the grid of embryos. + Used to numerate the embryos. + min_pixel_count (int): + Minimum number of items in a connected region to consider it an embryo. + Defaults to 6000. + + Returns: + extremes (dict[int, list[int]]): + List of bbox coordinates, sorted in F-order. + """ extremes = [] rows, cols = img.shape # iterate over slices of size sxs @@ -104,19 +167,28 @@ def get_bbox_boundaries(img, s=25, n_cols=3): if 1 in slc: counter, extreme = mark_neighbors(img, r, c, s) # minimum amount of pixels to be considered a VNC - if counter > 6000: + if counter > min_pixel_count: extremes.append(extreme) extremes = sort_by_grid_pos(extremes, n_cols) return extremes -def increase_bbox(coords, w, h, shape): +def increase_bbox(coords: dict[int, list[int]], w: int, h: int, shape: tuple): """Increases the bbox boundaries by w and h. - Args: - coords: Extremes (output from `slice_img.calculate_slice_coordinates`) - w: int Number of pixels to increment in the bbox width (half each side) - h: int Number of pixels to increment in the bbox height (half each side) + Parameters: + coords (dict[int, list[int]]): + Extremes (output from `slice_img.calculate_slice_coordinates`). + w (int): + Number of pixels to increment in the bbox width (half each side). + h (int): + Number of pixels to increment in the bbox height (half each side). + shape (tuple): + Shape of the original image where the bboxes were calculated. + + Returns: + new_coords (dict[int, list[int]]): + New dict with expanded bboxes """ new_coords = {} for emb_id, coord in coords.items(): @@ -130,10 +202,21 @@ def increase_bbox(coords, w, h, shape): return new_coords -def sort_by_grid_pos(extremes, n_cols): +def sort_by_grid_pos(extremes: list[list[int]], n_cols: int): """Sorts each boundary points list based on their position in the grid. - Sorts by F-order (column-wise).""" + Sorts by F-order (column-wise). + + Parameters: + extremes (list[list[int]]): + List of bbox coordinates + n_cols (int): + Number of columns to use to order embryos. + + Returns: + sorted_bboxes (dict[int, list[int]]): + Dict of bbox order to bbox coordinates. + """ centroids = [ ((r0 + r1) // 2, (c0 + c1) // 2, i) for i, (r0, r1, c0, c1) in enumerate(extremes) @@ -160,12 +243,26 @@ def sort_by_grid_pos(extremes, n_cols): return {k + 1: extremes[i] for k, i in enumerate(indices)} -def filter_by_embryos(extremes, selected_embryos): - """Filter the extremes dict, by only keeping the selected_embryos.""" +def filter_by_embryos( + extremes: dict[int, list[int]], selected_embryos: list[int] +) -> dict[int, list[int]]: + """Filter the extremes dict, by only keeping the selected_embryos. + + Parameters: + extremes (dict[int, list[int]]): + List of bbox coordinates + selected_embryos (int): + List with embryos to keep, that match `extremes` keys. + + Returns: + filtered_bboxes (dict[int, list[int]]): + Dict of bbox order to bbox coordinates. + """ return {k: extremes[k] for k in selected_embryos if k in extremes} -def read_mmap(mmap_path, num_frames=None): +def read_mmap(mmap_path: Path, num_frames=None): + """Read mmaped-file contents.""" offset, dtype, shape = get_metadata(mmap_path) if num_frames: shape = (num_frames, *shape[1:]) @@ -173,6 +270,24 @@ def read_mmap(mmap_path, num_frames=None): def create_tasks(extremes, channels, active_ch, dest, overwrite): + """Wraps args to be submitted with `save_movie` calls to ThreadPoolExecutor. + + Parameters: + extremes (dict[int, list[int]]): + Dict that maps emb ids to bbox coordinates. + channels (list[int]): + List of channel numbers. + active_ch (int): + What channel number represents the active channel. + dest (Path): + Output path. + overwrite (bool): + If saved movies should overwrite existing ones or not. + + Returns: + tasks: + List of arguments used by `save_movie`. + """ tasks = [] for id, extreme in extremes.items(): x0, x1, y0, y1 = extreme @@ -188,7 +303,8 @@ def create_tasks(extremes, channels, active_ch, dest, overwrite): return tasks -def submit_tasks(img, tasks): +def submit_tasks(img: np.ndarray, tasks: list): + """Run `save_movie` for all bbox coordinates args represented by tasks.""" num_threads = os.cpu_count() with ThreadPoolExecutor(max_workers=num_threads) as executor: futures = [executor.submit(save_movie, img, *task) for task in tasks] @@ -208,15 +324,23 @@ def cut_movies( """Extracts movies from ch1 and ch2, based on the boundaries passed for each item of `extremes`. - Args: - extremes: dict for `emb_number: [min_r, max_r, min_c, max_c]`. - img_path: path to the raw image that will be cut. - dest: directory where the movies will be saved. - embryos: list of embryo numbers. Used to select a subgroup of embryos. - active_ch: indicates the image active channel. Defaults to 1 and it - is expected to be equal to 1 or 2. - channels: (defaults to 2) number of channels imaged. - overwrite: boolean to determine if movies should be overwritten.""" + Parameters: + extremes dict[int, list[int]]: + `emb_number: [min_r, max_r, min_c, max_c]`. + img_path (int): + path to the raw image that will be cut. + dest (Path): + Directory where the movies will be saved. + embryos (list[int]): + List of embryo numbers. Used to select a subgroup of embryos. + active_ch (1 | 2): + Indicates the image active channel. + Defaults to 1. + channels (1 | 2): + Number of channels imaged. + Defaults to 2. + overwrite (bool): + Determine if movies should be overwritten.""" if embryos: extremes = filter_by_embryos(extremes, embryos) if active_ch not in [1, 2]: @@ -235,44 +359,64 @@ def cut_movies( submit_tasks(img, tasks) -def output_file_name(id, ch, active_ch): +def output_file_name(id: int, ch: int, active_ch: int) -> str: + """File name based on embryo id and ch number. + + Parameters: + id (int): + Embryo id. + ch (int): + Channel number + active_ch (1 | 2): + Specifies which is the active channel. + """ if active_ch != 1 and active_ch != 2: raise ValueError(f"Active channel should be 1 or 2, got {active_ch}.") ch_number = ch + 1 if active_ch == 1 else active_ch - ch return f"emb{id}-ch{ch_number}.tif" -def save_movie(img, ch, x0, x1, y0, y1, output): +def save_movie( + img: np.ndarray, ch: int, x0: int, x1: int, y0: int, y1: int, output: Path +): + """Write a slice of the movie based on the provided coordinates.""" movie = img[:, ch, x0:x1, y0:y1] imwrite(output, movie) -def boundary_to_rect_coords(boundary): +def boundary_to_rect_coords(boundary: list[int]) -> list[int]: """Converts from `(x0, x1, y0, y1)` to `(x, y, w, h)`.""" [x0, x1, y0, y1] = boundary return [x0, y0, y1 - y0, x1 - x0] -def calculate_slice_coordinates(img_path, n_cols=3, thres_adjust=0): +def calculate_slice_coordinates( + img_path: Path, n_cols=3, thres_adjust=0 +) -> dict[int, list[int]]: """Returns boundary points for all images in `img_path`. - Args: - img_path: absolute path to the raw data - n_cols: number of columns in the FOV grid, used to enforce the naming - convention of the extracted embryos. + Parameters: + img_path (Path): + Absolute path to the raw data + n_cols (int): + Number of columns in the FOV grid, used to enforce the naming + convention of the extracted embryos. + thres_adjust (int): + Increment the calculated threshold by this amount. + Makes it easy to manually adjust the threshold. + Defaults to 0. """ binary_img = get_initial_binary_image(img_path, thres_adjust=thres_adjust) - extremes = get_bbox_boundaries(binary_img, s=25, n_cols=n_cols) - return extremes + return get_bbox_boundaries(binary_img, s=25, n_cols=n_cols) -def get_initial_frames_from_mmap(img_path, n=10): +def get_initial_frames_from_mmap(img_path: Path, n=10): """Returns the first n frames from the file at `img_path`.""" return read_mmap(img_path, num_frames=n) -def get_first_image_from_mmap(img_path): +def get_first_image_from_mmap(img_path: Path): """Returns the first image from a mmap file, for plotting. The image is the average of the first 10 slices for channel 2. @@ -283,7 +427,7 @@ def get_first_image_from_mmap(img_path): return equalize_hist(first_frame) -def get_initial_binary_image(img_path, n=10, thres_adjust=0): +def get_initial_binary_image(img_path: Path, n=10, thres_adjust=0): """Binarizes the first `n` slices of the img, which is read as a mmap.""" img = get_initial_frames_from_mmap(img_path, n=n) diff --git a/snazzy_processing/snazzy_processing/utils.py b/snazzy_processing/snazzy_processing/utils.py index 99c46c3..376f7cf 100644 --- a/snazzy_processing/snazzy_processing/utils.py +++ b/snazzy_processing/snazzy_processing/utils.py @@ -45,7 +45,7 @@ def emb_name(number: int, ch: int, ext: str | None = None) -> str: return f"emb{number}-ch{ch}" -def format_seconds(seconds): +def format_seconds(seconds: int) -> str: """Returns HH:mm:ss, given an amount of seconds.""" hours, remainder = divmod(seconds, 3600) minutes, seconds = divmod(remainder, 60) diff --git a/snazzy_processing/snazzy_processing/vnc_length.py b/snazzy_processing/snazzy_processing/vnc_length.py index b92cf9a..f1efe12 100644 --- a/snazzy_processing/snazzy_processing/vnc_length.py +++ b/snazzy_processing/snazzy_processing/vnc_length.py @@ -1,3 +1,5 @@ +from pathlib import Path + import numpy as np from snazzy_processing import centerline, csv_handler @@ -10,8 +12,28 @@ def measure_VNC_centerline( min_dist=5, outlier_thres=0.09, threshold_method="multiotsu", -): - """Calculates the centerline distance for a 3D image.""" +) -> np.ndarray: + """Calculates the centerline distance for a 3D image. + + As RANSAC might give the wrong measurement sometimes, points with relative + difference above `outlier_thres` are corrected by lin regression. + + Parameters: + image (np.ndarray): + 3D matrix representing an image. + pixel_width (float): + Physica size of a pixel. + thres_rel (float): + Threshold value used to calculate centerline points. + min_dist (float): + Minimum distance used to find local points in the distance transform. + outlier_thres (float): + Maximum VNC length relative change between consecutive points. + + Returns: + vnc_lenghts (np.ndarray): + Array with VNC measurements. + """ vnc_lengths = np.zeros(image.shape[0]) for i, img in enumerate(image): bin_img = centerline.binarize(img, threshold_method=threshold_method) @@ -33,8 +55,13 @@ def measure_VNC_centerline( return vnc_lengths -def predict_next(previous): - """Estimates the next point by linear regression.""" +def predict_next(previous: np.ndarray) -> np.ndarray: + """Estimates the next point by linear regression. + + Parameters: + previous (np.ndarray): + Array with previous points, use to predict the next point. + """ y = np.array(previous) x = np.arange(y.shape[0]) A = np.vstack([x, np.ones(len(x))]).T @@ -42,14 +69,29 @@ def predict_next(previous): return m * len(x) + c -def get_length_from_csv(file_path, columns=(1,)): +def get_length_from_csv(file_path: Path, columns=(1,)): """Reads CSV data as a nparray. Expects the lengths to be in actual metric units, instead of pixels.""" return csv_handler.read(file_path, usecols=columns) -def get_output_data(length_data, downsampling, frame_interval): +def add_timepoints( + length_data: np.ndarray, downsampling: int, frame_interval: int +) -> np.ndarray: + """Add time information (in seconds) to VNC length data. + + Parameters: + lendth_data (np.ndarray): + An array of VNC length measurments. + downsampled (int): + Step interval used when calculating the VNC length. + frame_interval (int): + The interval of acquistion of frames in seconds. + + Returns: + A np.ndarray of shape (N, t, 2). + """ t = length_data.size time = np.arange(t) * frame_interval * downsampling @@ -57,10 +99,23 @@ def get_output_data(length_data, downsampling, frame_interval): def export_csv(ids, lengths, output_dir, downsampling, frame_interval=6): + """Write vnc lengths as csv. + + Parameters: + ids (list[int]): + Embryo Ids used to name each csv file. + Must match the indices of the signals list. + lengths (list): + VNC length measurements + output_dir (Path): + Path to write csv files. + downsampling (int): + Step interval used when calculating the VNC length. + frame_interval (int): + The interval of acquistion of frames in seconds. + """ csv_paths = [output_dir.joinpath(f"emb{id}.csv") for id in ids] for length_data, csv_path in zip(lengths, csv_paths): - data = get_output_data(length_data, downsampling, frame_interval) + data = add_timepoints(length_data, downsampling, frame_interval) csv_handler.write_file(csv_path, data, ["time", "length"]) - - return True From 4a069e361e88efc847db39fda0add6525ad89600 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Fri, 26 Sep 2025 15:39:27 -0400 Subject: [PATCH 12/14] fix(gui): avoid creating config file if selected directory is not valid --- snazzy_analysis/snazzy_analysis/gui/gui.py | 10 +++++----- snazzy_analysis/snazzy_analysis/gui/model.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/snazzy_analysis/snazzy_analysis/gui/gui.py b/snazzy_analysis/snazzy_analysis/gui/gui.py index bcd4724..905ee5f 100644 --- a/snazzy_analysis/snazzy_analysis/gui/gui.py +++ b/snazzy_analysis/snazzy_analysis/gui/gui.py @@ -108,16 +108,16 @@ def _show_experiment_dialog( self.exp_params_dialog.open() - # TODO: it should be easier to update Config from the GUI def _update_config(self, config: Config, dialog_values): exp_params = config.get_exp_params() new_exp_params = {k: v for k, v in dialog_values.items() if k in exp_params} - pd_params = {"dff_strategy": dialog_values["dff_strategy"]} - new_config = {"exp_params": new_exp_params, "pd_params": pd_params} + dff_strategy = dialog_values["dff_strategy"] + new_config = { + "exp_params": new_exp_params, + "pd_params": {"dff_strategy": dff_strategy}, + } config.update_params(new_config) - config.save_params() - def _start_experiment_worker(self, config: Config, group_name: str): worker = Worker( self.model.create_experiment, diff --git a/snazzy_analysis/snazzy_analysis/gui/model.py b/snazzy_analysis/snazzy_analysis/gui/model.py index 3ad6b1a..8dd1eea 100644 --- a/snazzy_analysis/snazzy_analysis/gui/model.py +++ b/snazzy_analysis/snazzy_analysis/gui/model.py @@ -110,6 +110,7 @@ def create_experiment(self, config: Config, group_name: str): f"Could not find any embryos with first peak after {first_peak_threshold} minutes." ) + config.save_params() return self.add_experiment(ExperimentModel(exp), group_name) def add_experiment(self, experiment: ExperimentModel, group_name: str): From 2c2356adba9f281f8db744c2b4c614ef534e4e44 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Tue, 30 Sep 2025 17:03:52 -0400 Subject: [PATCH 13/14] fix(gui): avoid duplicating entries in to_remove key --- snazzy_analysis/snazzy_analysis/config.py | 27 ++++++++++++++----- .../snazzy_analysis/gui/exp_params_dialog.py | 18 ++++++++----- .../snazzy_analysis/gui/json_viewer.py | 22 ++++++++++++--- snazzy_analysis/snazzy_analysis/gui/model.py | 8 +++--- snazzy_analysis/snazzy_analysis/trace.py | 3 --- snazzy_analysis/snazzy_analysis/utils.py | 3 ++- snazzy_analysis/tests/test_gui.py | 1 - 7 files changed, 55 insertions(+), 27 deletions(-) diff --git a/snazzy_analysis/snazzy_analysis/config.py b/snazzy_analysis/snazzy_analysis/config.py index 6366d89..a020731 100644 --- a/snazzy_analysis/snazzy_analysis/config.py +++ b/snazzy_analysis/snazzy_analysis/config.py @@ -13,6 +13,17 @@ def default(self, obj): return super().default(obj) +SET_KEYS = {"to_remove", "to_exclude"} + + +def set_decoder(obj): + """Convert specific list-valued keys back to sets.""" + for key in SET_KEYS: + if key in obj and isinstance(obj[key], list): + obj[key] = set(obj[key]) + return obj + + class ExpParams(BaseModel): """ Exp params. @@ -37,28 +48,30 @@ class ExpParams(BaseModel): """ first_peak_threshold: int = 30 - to_exclude: list[str] = Field(default_factory=list) - to_remove: list[str] = Field(default_factory=list) + to_exclude: set[str] = Field(default_factory=set) + to_remove: set[str] = Field(default_factory=set) has_transients: bool = True has_dsna: bool = False acquisition_period: int = 6 class PDParams(BaseModel): + """Parameters used in peak detection.""" + peak_width: float = 0.98 freq: float = 0.0025 dff_strategy: str = "local_minima" baseline_window_size: int = 81 trim_zscore: float = 0.35 - ISI_factor: float = 4 + ISI_factor: float = 4.0 low_amp_threshold: float = 0.1 fft_height: float = 0.04 fft_prominence: float = 0.03 local_thres_window_size: int = 300 - local_thres_value: float = 75 + local_thres_value: float = 75.0 local_thres_method: str = "percentile" port_peaks_window_size: int = 30 - port_peaks_thres: float = 70 + port_peaks_thres: float = 70.0 class EmbryoParams(BaseModel): @@ -130,7 +143,7 @@ def read_from_file(self): data it will be ignored and the default values will be used. """ with open(self.config_path, "r") as f: - data = json.load(f) + data = json.load(f, object_hook=set_decoder) try: return ConfigObj(**data).dict() except ValidationError as e: @@ -142,7 +155,7 @@ def read_from_file(self): def initialize_config_file(self): with open(self.config_path, "w") as f: - json.dump(self.default_params, f, indent=4) + json.dump(self.default_params, f, cls=PdParamsEncoder, indent=4) def get_pd_params(self): return self.data.get("pd_params", self.default_params["pd_params"]) diff --git a/snazzy_analysis/snazzy_analysis/gui/exp_params_dialog.py b/snazzy_analysis/snazzy_analysis/gui/exp_params_dialog.py index 252985b..fea4f93 100644 --- a/snazzy_analysis/snazzy_analysis/gui/exp_params_dialog.py +++ b/snazzy_analysis/snazzy_analysis/gui/exp_params_dialog.py @@ -17,7 +17,9 @@ def convert_value(value: str, field_name: str): if field_name == "first_peak_threshold" or field_name == "acquisition_period": return int(value) elif field_name == "to_exclude" or field_name == "to_remove": - return [f"emb{x.strip()}" for x in value.strip("[]").split(",") if x.strip()] + return set( + [f"emb{x.strip()}" for x in value.strip("[]").split(",") if x.strip()] + ) else: return value @@ -85,16 +87,18 @@ def adjust_emb_names(self, properties): for key in ("to_remove", "to_exclude"): if key in properties: if properties[key]: + emb_names = list(properties[key]) # keeping this check for compatibility: # previous versions of pd_params used to save ids instead of emb_names - if properties[key][0].isdigit(): - properties[key] = sorted( - [int(emb_id) for emb_id in properties[key]] - ) + if emb_names[0].isdigit(): + emb_names = sorted([int(emb_id) for emb_id in emb_names]) else: - properties[key] = sorted( - [utils.emb_id(emb_name) for emb_name in properties[key]] + emb_names = sorted( + [utils.emb_id(emb_name) for emb_name in emb_names] ) + properties[key] = emb_names + else: + properties[key] = [] def showEvent(self, event): super().showEvent(event) diff --git a/snazzy_analysis/snazzy_analysis/gui/json_viewer.py b/snazzy_analysis/snazzy_analysis/gui/json_viewer.py index a012a48..0709996 100644 --- a/snazzy_analysis/snazzy_analysis/gui/json_viewer.py +++ b/snazzy_analysis/snazzy_analysis/gui/json_viewer.py @@ -1,3 +1,5 @@ +import copy + from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( QHeaderView, @@ -12,8 +14,8 @@ "exp_path": str, "exp_params": dict, "first_peak_threshold": int, - "to_exclude": list, - "to_remove": list, + "to_exclude": set, + "to_remove": set, "has_transients": bool, "has_dsna": bool, "acquisition_period": int, @@ -60,7 +62,15 @@ def __init__(self, json_data): 1, QHeaderView.ResizeMode.ResizeToContents ) - self.populate_tree(self.tree, json_data) + config_data = copy.deepcopy(json_data) + exp_params = config_data.get("exp_params", {}) + to_remove = list(exp_params.get("to_remove", set())) + to_exclude = list(exp_params.get("to_exclude", set())) + if exp_params: + config_data["exp_params"]["to_remove"] = to_remove + config_data["exp_params"]["to_exclude"] = to_exclude + + self.populate_tree(self.tree, config_data) self.save_btn = QPushButton("Save changes") self.save_btn.clicked.connect(self.collect_data) @@ -87,7 +97,7 @@ def add_children(self, parent, value, editable=False): child.setFlags(child.flags() | Qt.ItemFlag.ItemIsEditable) parent.addChild(child) self.add_children(child, v, editable) - elif isinstance(value, list): + elif isinstance(value, list) or isinstance(value, set): for i, elem in enumerate(value): child = QTreeWidgetItem( [f"[{i}]", str(elem) if not isinstance(elem, (dict, list)) else ""] @@ -105,6 +115,10 @@ def collect_data(self): top_item = self.tree.topLevelItem(i) key = top_item.text(0) collected[key] = self.read_item(top_item) + collected["exp_params"]["to_exclude"] = set( + collected["exp_params"]["to_exclude"] + ) + collected["exp_params"]["to_remove"] = set(collected["exp_params"]["to_remove"]) self.update_config_signal.emit(collected) def read_item(self, item): diff --git a/snazzy_analysis/snazzy_analysis/gui/model.py b/snazzy_analysis/snazzy_analysis/gui/model.py index 8dd1eea..8a08d16 100644 --- a/snazzy_analysis/snazzy_analysis/gui/model.py +++ b/snazzy_analysis/snazzy_analysis/gui/model.py @@ -29,10 +29,10 @@ def all_embryos(self): return self.experiment.get_all_embryos() def get_removed_embryos(self): - manual_remove = self.experiment.exp_params.get("to_remove", []) + manual_remove = self.experiment.exp_params.get("to_remove", set()) if self.experiment.filtered_out is not None: - manual_remove.extend(self.experiment.filtered_out) - return set(manual_remove) + removed_embryos = manual_remove.union(self.experiment.filtered_out) + return removed_embryos def mark_as_accepted(self, emb_name): self.to_remove.remove(emb_name) @@ -70,7 +70,7 @@ def __init__(self): def __str__(self): group_names = [g.name for g in self.groups] to_remove_count = { - exp: len(exp.to_remove) + exp.name: len(exp.to_remove) for g in self.groups for exp in g.experiments.values() } diff --git a/snazzy_analysis/snazzy_analysis/trace.py b/snazzy_analysis/snazzy_analysis/trace.py index 07b42a2..69a7a51 100644 --- a/snazzy_analysis/snazzy_analysis/trace.py +++ b/snazzy_analysis/snazzy_analysis/trace.py @@ -444,9 +444,6 @@ def port_peaks(self, peaks, target_signal, search_window=30, peak_height_thres=7 local_peaks, peak_data = spsig.find_peaks(window, height=(None, None)) local_peak_heights = peak_data["peak_heights"] if not any(local_peak_heights): - print( - f"WARN: [{self.name}] could not port peaks at idx {idx}. Skipping this peak.." - ) continue max_peak = np.max(local_peak_heights) diff --git a/snazzy_analysis/snazzy_analysis/utils.py b/snazzy_analysis/snazzy_analysis/utils.py index 5a824bd..d60029c 100644 --- a/snazzy_analysis/snazzy_analysis/utils.py +++ b/snazzy_analysis/snazzy_analysis/utils.py @@ -2,7 +2,8 @@ from pathlib import Path -def split_in_bins(arr, bins): +def split_in_bins(arr: np.ndarray, bins: int): + """Return an array of bin indices for each arr element.""" return np.digitize(arr, bins) diff --git a/snazzy_analysis/tests/test_gui.py b/snazzy_analysis/tests/test_gui.py index 5e46af1..44a68a9 100644 --- a/snazzy_analysis/tests/test_gui.py +++ b/snazzy_analysis/tests/test_gui.py @@ -124,7 +124,6 @@ def test_can_render_FOV(qtbot): def test_can_render_json_config(qtbot, exp): config_data = exp.config.data - config_data["exp_params"]["to_remove"] = ["emb1", "emb2"] json_viewer = JsonViewer(config_data) qtbot.addWidget(json_viewer) From d4092917f2074c8450934023430bf5524f2c8772 Mon Sep 17 00:00:00 2001 From: cdpaiva Date: Tue, 30 Sep 2025 17:04:12 -0400 Subject: [PATCH 14/14] fix: update config file when creating new experiment --- snazzy_analysis/snazzy_analysis/experiment.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/snazzy_analysis/snazzy_analysis/experiment.py b/snazzy_analysis/snazzy_analysis/experiment.py index 997d714..10d26a2 100644 --- a/snazzy_analysis/snazzy_analysis/experiment.py +++ b/snazzy_analysis/snazzy_analysis/experiment.py @@ -124,4 +124,7 @@ def _create_embryos(self) -> dict[str, Embryo]: self.filtered_out.add(emb.name) embryos[emb.name] = emb + if self.filtered_out: + self.config.update_params({"exp_params": {"to_remove": self.filtered_out}}) + return embryos