diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..f812ff3 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index ec4cfbf..2a012a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ docs/build dist/ -Deep_ACSA/__pycache__ -Deep_ACSA/gui_helpers/__pycache__ -build/ +DeepACSA/__pycache__ +DeepACSA/gui_helpers/__pycache__ +build/doctrees .vscode/ +.sync* +DeepACSA/dist/ +DeepACSA/build/ +DeepACSA/installer/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a52b772..ad107fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,22 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.5.0 # Use the latest stable version hooks: - - id: check-toml - - id: check-yaml + - id: trailing-whitespace - id: end-of-file-fixer - - id: mixed-line-ending + #- id: check-yaml + - id: check-json + - id: check-added-large-files - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 25.1.0 # Use latest stable Black version hooks: - id: black - - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + - repo: https://github.com/asottile/blacken-docs + rev: 1.19.1 + hooks: + - id: blacken-docs + - repo: https://github.com/timothycrosley/isort + rev: 6.0.1 hooks: - id: isort - args: ["--profile", "black"] + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 711a809..3d58292 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,4 +1,37 @@ +2026-03-22 +========== + +Removed +------- + +- GUI advanced modules from main GUI file. +- Loss function declaration in the main GUI file. + +Added +----- + +- GUI advanced modules subdir. +- Support for more models (swinunet, 3Dunet) +- No Scaling analysis option, + +Changed +------- + +- ALoading of models now based on names +- GUI graphics and layout. +- Create masks not has full customtkiner support. + +- Updated the documentation and inserted a documentation tree for modules. +- Provisional version of documentation for functions following the numpy style guide in gui_helpers modules.. + +- Updated News section for patellar tendon ACSA model including reliability comparison. + +Fixed +----- + +- Truncated cone formula for volume calculation. + 2023-10-04 ========== diff --git a/DeepACSA/.DS_Store b/DeepACSA/.DS_Store new file mode 100644 index 0000000..c83bd11 Binary files /dev/null and b/DeepACSA/.DS_Store differ diff --git a/DeepACSA/DeepACSA.iss b/DeepACSA/DeepACSA.iss new file mode 100644 index 0000000..994c415 --- /dev/null +++ b/DeepACSA/DeepACSA.iss @@ -0,0 +1,20 @@ +[Setup] +AppName=DeepACSA0.3.2 +AppVersion=0.3.2 +DefaultDirName={pf}\DeepACSA +DefaultGroupName=DeepASA +OutputDir=installer +OutputBaseFilename=DeepACSA0.3.2_Installer +Compression=lzma +SolidCompression=yes +SetupIconFile=gui_helpers\icon.ico + +[Files] +Source: "dist\DeepACSA\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\DeepACSA0.3.2"; Filename: "{app}\deep_acsa_gui.exe" +Name: "{commondesktop}\DeepACSA0.3.2"; Filename: "{app}\deep_acsa_gui.exe"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:" \ No newline at end of file diff --git a/DeepACSA/DeepACSA.spec b/DeepACSA/DeepACSA.spec new file mode 100644 index 0000000..2ae2d92 --- /dev/null +++ b/DeepACSA/DeepACSA.spec @@ -0,0 +1,46 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['deep_acsa_gui.py'], + pathex=[], + binaries=[], + datas=[('gui_helpers/deep_acsa.jpg', 'gui_helpers/gui_files'), ('gui_helpers/gui_files/ui_color_theme.json', 'gui_helpers/gui_files'), ('gui_helpers/icon.ico', 'gui_helpers/gui_files'), ('gui_helpers/gui_files/gear.png', 'gui_helpers/gui_files'), ('gui_helpers/gui_files/Cite.png', 'gui_helpers/gui_files'), ('gui_helpers/gui_files/Info.png', 'gui_helpers/gui_files')], + hiddenimports=['customtkinter'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='deep_acsa_gui', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['gui_helpers\\icon.ico'], + onefile=True, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='DeepACSA', +) \ No newline at end of file diff --git a/DeepACSA/__init__.py b/DeepACSA/__init__.py new file mode 100644 index 0000000..01af311 --- /dev/null +++ b/DeepACSA/__init__.py @@ -0,0 +1,9 @@ +"""Container for all our package modules.""" + +__version__ = "0.4.0" + +_author__ = ["Paul Ritsche"] + +__all__ = ["deep_acsa_gui"] + +from DeepACSA.deep_acsa_gui import * diff --git a/Deep_ACSA/__main__.py b/DeepACSA/__main__.py similarity index 73% rename from Deep_ACSA/__main__.py rename to DeepACSA/__main__.py index aa28b62..671597c 100644 --- a/Deep_ACSA/__main__.py +++ b/DeepACSA/__main__.py @@ -1,6 +1,6 @@ """This modules contains code to allow usage of the GUI from the command line""" -from Deep_ACSA.deep_acsa_gui import runMain +from DeepACSA.deep_acsa_gui import runMain if __name__ == "__main__": runMain() diff --git a/DeepACSA/deep_acsa_gui.py b/DeepACSA/deep_acsa_gui.py new file mode 100644 index 0000000..437178a --- /dev/null +++ b/DeepACSA/deep_acsa_gui.py @@ -0,0 +1,774 @@ +""" +Description +----------- +This module contains a class with methods to automatically and manually annotate +transversal ultrasonography images. When the class is initiated, +a graphical user interface is opened. This is the main GUI of the DeepACSA package. +From here, the user is able to navigate all functionalities of the package. +These extend the methods in this class. The main functionalities of the GUI contained +in this module are automatic and manual evalution of muscle ultrasonography images. +Inputted images are analyzed and the parameter muscle anatomical cross-sectional area is +returned for each image. +The parameters are analyzed using convolutional neural networks (U-net, VGG16). +This module and all submodules contained in the /gui_helpers modality are extensions +and improvements of the work presented in Ritsche et al. (2022). There, the core functionalities +of this code are already outlined and the comparability of the model segmentations to +manual analysis (current gold standard) is described. Here, we have improved the code. + +References +---------- +| VGG16: Simonyan, K., and Zisserman, A. “Very deep convolutional networks for large-scale image recognition.” arXiv preprint arXiv:1409.1556 (2014) +| U-net: Ronneberger, O., Fischer, P. and Brox, T. "U-Net: Convolutional Networks for Biomedical Image Segmentation." arXiv preprint arXiv:1505.04597 (2015) +| DeepACSA: Ritsche, P., Wirth, P., Cronin, N., Sarto, F., Narici, M., Faude, O., Franchi, M. "DeepACSA: Automatic Segmentation of Cross-Sectional Area in Ultrasound Images of Lower Limb Muscles Using Deep Learning" (2022) +""" + +import os +import sys +import webbrowser +import customtkinter as ctk +import tkinter as tk +from threading import Lock, Thread +from tkinter import E, N, S, StringVar, Tk, W, filedialog, ttk + +import matplotlib +from PIL import Image, ImageTk + +from DeepACSA import gui_helpers +from DeepACSA.gui_modules import AdvancedAnalysis + +matplotlib.use("TkAgg") + +class DeepACSA(ctk.CTk): + """ + Python class to automatically or manually annotate tranversal muscle + ultrasonography images of human lower limb muscles. + An analysis tkinter GUI is opened upon initialization of the class. + By clicking the buttons, the user can switch between image analysis and model training. + The GUI consistsof the following elements. + - Input Directory: + By pressing the "Input" button, the user is asked to select + an input directory containing all images to be + analyzed. This can also be entered directly in the entry field. + - Apo Model Path: + By pressing the "Apo Model" button, the user is asked to select + the aponeurosis model used for aponeurosis segmentation. The absolute + model path can be entered directly in the enty field as well. + - Break: + By pressing the "break" button, the user is able to stop the analysis process + after each finished image or image frame analysis. + - Run: + By pressing the "run" button, the user can start the analysis process. + - Model training: + By pressing the "train model" button, a new window opens and the + user can train an own neural network based on existing/own + training data. Furthermore, the new window allows to augment input images through + data augmentation to generate new training images. + + Attributes + ---------- + self._lock : _thread.lock + Thread object to lock the self._lock variable for access by another thread. + self._is_running : bool, default = False + Boolen variable determining the active state + of the GUI. If False, the is not running. If True + the GUI is running. + self._should_stop : bool, default = False + Boolen variable determining the active state + of the GUI. If False, the is allowed to continue running. If True + the GUI is stopped and background processes deactivated. + self.main : tk.TK + tk.TK instance which is the base frame for the GUI. + self.input : tk.Stringvar + tk.Stringvariable containing the path to the input directory. + self.model : tk.Stringvar + tk.Stringvariable containing the path to the aponeurosis + model. + self.scaling : tk.Stringvar + tk.Stringvariable containing the selected scaling type. + This can be "bar", "manual" or "no scaling". + self.volume_calc_wanted : tk.Stringvar + tk.Stringvariale defining whether the muscle volume + should be calculated. + This can be "yes" or "no". + self.filetype : tk.Stringvar + tk.Stringvariabel containing the selected filetype for + the images to be analyzed. The user can select from the + dopdown list or enter an own filetype. The formatting + should be kept constant. + self.depth : tk.Stringvar + tk.Stringvariable containing the selected image depth. + This must be according to the image depth used during + image acquisition. + self.spacing : tk.Stringvar + tk.Stringvariable containing the selected spacing distance + used for computation of pixel / cm ratio. This must only be + specified when the analysis type "bar" or "manual" is selected. + self.distance : tk.Stringvar + tk.Stringvariable containing the space between acquired images + along one muscle. The space must be given in mm. + self.train_image_dir : tk.Stringvar + tk.Straingvar containing the path to the directory of the training images. + Image must be in RGB format. + self.mask_dir : tk.Stringvar + tk.Stringvariable containing the path to the directory of the mask images. + Masks must be binary. + self.out_dir : tk.Stringvar + tk.Stringvariable containing the path to the directory where the trained model + should be saved. + self.batch_size : tk.Stringvar + tk.Stringvariable containing the batch size per iteration through the + network during model training. Must be non-negative and non-zero. + self.learning_rate : tk.Stringvariable + tk.Stringvariable the learning rate used during model training. + Must be non-negative and non-zero. + self.epochs : tk.Stringvar + tk.Straingvariable containing the amount of epochs that the model + is trained befor training is aborted. Must be non-negative and non-zero. + self.loss : tk.Stringvar + tk.Stringvariable containing the loss function that is used during training. + + Methods + ------- + get_root_dir + Instance method to ask the user to select the input directory. + get_model_path + Instance method to ask the user to select the apo model path. + run_code + Instance method to execute the analysis process when the + "run" button is pressed. + do_break + Instance method to break the analysis process when the + button "break" is pressed. + train_model_window + Instance method to open new window for model training. + get_train_dir + Instance method to ask the user to select the training image + directory path. + get_mask_dir + Instance method to ask the user to select the training mask + directory path. + get_output_dir + Instance method to ask the user to select the output + directory path. + train_model + Instance method to execute the model training when the + "start training" button is pressed. + augment_images + Instance method to augment input images and masks, + when the "Augment Images" button is pressed. + + Notes + ----- + This class contains only instance attributes. + The instance methods contained in this class are solely purposed for + support of the main GUI instance method. They cannot be used + independantly or seperately. + + For more detailed documentation of the functions employed + in this GUI upon running the analysis or starting model training + see the respective modules in the /gui_helpers subfolder. + + See Also + -------- + model_training.py, predict_muscle_area.py + """ + + def __init__(self, *args, **kwargs): + """Initialize the DeepACSA GUI application. + + Parameters + ---------- + root (Tk): The Tkinter root window. + + Returns + ------- + None + """ + super().__init__(*args, **kwargs) + + # set up threading + self._lock = Lock() + self._is_running = False + self._should_stop = False + + # set up gui + self.title("DeepACSA") + master_path = os.path.dirname(os.path.abspath(__file__)) + ctk.set_default_color_theme( + master_path + "/gui_helpers/gui_files/ui_color_theme.json" + ) + iconpath = master_path + "/gui_helpers/icon.ico" + # self.iconbitmap(iconpath) + + self.main = ctk.CTkFrame(self) + self.main.grid(column=0, row=0, sticky=(N, S, W, E)) + + # Ensure the root window/resizable container gives space to `self.main` + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + + + for row in range(21): + self.main.rowconfigure(row, weight=1) + for column in range(4): + self.main.columnconfigure(column, weight=1) + + # Docs reference button + info_path = self.resource_path("gui_helpers/gui_files/Info.png") + # Get info button path + self.info = ctk.CTkImage( + light_image=Image.open(info_path), + size=(20, 20), + ) + info_button = ctk.CTkButton( + self.main, + image=self.info, + text="Docs", + width=20, + height=20, + bg_color="#2A484E", + fg_color="#2A484E", + border_width=0, + command=lambda: ( + (webbrowser.open("https://deepacsa.readthedocs.io/")) + ), + ) + info_button.grid( + row=1, + column=5, + sticky=( + W, + E, + ), + ) + + # citation button + cite_path = self.resource_path("gui_helpers/gui_files/Cite.png") + # Get info button path + self.info = ctk.CTkImage( + light_image=Image.open(cite_path), + size=(20, 20), + ) + cite_button = ctk.CTkButton( + self.main, + image=self.info, + text="Cite us", + width=20, + height=20, + bg_color="#2A484E", + fg_color="#2A484E", + border_width=0, + command=lambda: ( + (webbrowser.open("https://journals.lww.com/acsm-msse/Fulltext/2022/12000/DeepACSA__Automatic_Segmentation_of.21.aspx")) + ), + ) + cite_button.grid( + row=1, + column=3, + sticky=( + W, + E, + ), + ) + + # Paths + # Input directory + self.input = ctk.StringVar() + input_entry = ctk.CTkEntry(self.main, width=30, textvariable=self.input) + input_entry.grid(column=2, row=2, columnspan=3, sticky=(W, E)) + # self.input.set( + # "C:/Users/admin/Desktop/DeepACSA_example_v0.3.1/DeepACSA_example_v0.3.1/images_test" + # ) + + # Model path + self.model = ctk.StringVar() + model_entry = ctk.CTkEntry(self.main, width=30, textvariable=self.model) + model_entry.grid(column=2, row=3, columnspan=3, sticky=(W, E)) + # self.model.set("C:/Users/admin/Documents/DeepACSA/notebooks/VGG16pre-VL-256.h5") + + self.scaling = ctk.StringVar(value="Bar") + self.scaling_menu = ctk.CTkComboBox( + self.main, + variable=self.scaling, + values=["Line", "Bar", "Manual", "No Scaling"], + command=self.on_scaling_change, + state="readonly", + ) + self.scaling_menu.grid(column=2, row=7, sticky=(W, E)) + self.scaling_menu.set("No Scaling") + + self.volume_calc_wanted = ctk.StringVar(value="No") + self.volume_menu = ctk.CTkComboBox( + self.main, + variable=self.volume_calc_wanted, + values=["Yes", "No"], + command=self.on_volume_change, + state="readonly", + ) + self.volume_menu.grid(column=3, row=14, sticky=(W, E)) + + # Structure to segment + self.muscle = StringVar() + muscle_entry = ctk.CTkComboBox( + self.main, + values=[ + "Vastus Lateralis", + "Rectus Femoris", + "Gastrocnemius Medialis", + "Gastrocnemius Lateralis", + "Biceps Femoris", + "Vastus Medialis", + "Patellar Tendon", + ], + state="readonly", + variable=self.muscle, + ) + muscle_entry.grid(column=2, row=8, sticky=(W, E)) + self.muscle.set("Recus Femoris") + + # Buttons + # Input directory + input_button = ctk.CTkButton(self.main, text="Input", command=self.get_root_dir) + input_button.grid(column=5, row=2, sticky=E) + + # Model path button + model_button = ctk.CTkButton( + self.main, text="Model", command=self.get_model_path + ) + model_button.grid(column=5, row=3, sticky=E) + + # Break Button + break_button = ctk.CTkButton(self.main, text="Break", command=self.do_break) + break_button.grid(column=1, row=17, sticky=W) + + # Run Button + run_button = ctk.CTkButton(self.main, text="Run", command=self.run_code) + run_button.grid(column=2, row=17, sticky=(W, E)) + + advanced_button = ctk.CTkButton( + self.main, + text="Advanced Methods", + command=lambda: (AdvancedAnalysis(self),), + fg_color="#000000", + text_color="#FFFFFF", + border_color="yellow3", + ) + advanced_button.grid(column=5, row=17, sticky=E) + + # Labels + ctk.CTkLabel(self.main, text="Directories", font=("Verdana", 14)).grid( + column=1, row=1, sticky=W + ) + ctk.CTkLabel(self.main, text="Root Directory").grid(column=1, row=2) + ctk.CTkLabel(self.main, text="Model Path").grid(column=1, row=3) + ctk.CTkLabel(self.main, text="Image Properties", font=("Verdana", 14)).grid( + column=1, row=6, sticky=W + ) + ctk.CTkLabel(self.main, text="Scaling Type").grid(column=1, row=7) + ctk.CTkLabel(self.main, text="Structure").grid(column=1, row=8) + ctk.CTkLabel(self.main, text="Muscle Volume", font=("Verdana", 14)).grid( + column=1, row=13, sticky=W + ) + ctk.CTkLabel(self.main, text="Volume Calculation").grid(column=1, row=14) + + # Separators + ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( + column=0, row=5, columnspan=9, sticky=(W, E) + ) + ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( + column=0, row=12, columnspan=9, sticky=(W, E) + ) + ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( + column=0, row=16, columnspan=9, sticky=(W, E) + ) + + for child in self.main.winfo_children(): + child.grid_configure(padx=5, pady=5) + + # Label above progress bar + self.progress_label = ctk.CTkLabel( + self.main, text="Predicting images...", font=("Verdana", 8) + ) + self.progress_label.grid(column=1, row=18, columnspan=4, sticky="w", padx=10) + self.after(100, self.progress_label.grid_remove) + + self.progress_var = ctk.DoubleVar(value=0) + self.progress_bar = ctk.CTkProgressBar(self.main, variable=self.progress_var) + self.progress_bar.grid( + column=1, row=19, columnspan=4, sticky="ew", padx=10, pady=2 + ) + self.progress_bar.set(0) + self.progress_bar.configure(mode="determinate") + self.after(100, self.progress_bar.grid_remove) + + self.bind("", self.run_code) # execute by pressing return + + # -------------------------------------------------------------------------------------------------- + # Functionalities used in GUI + + def get_root_dir(self): + """ + Instance method to ask the user to select the input directory. + All image files (of the same specified filetype) in + the input directory are analysed. + """ + root_dir = filedialog.askdirectory() + self.input.set(root_dir) + return root_dir + + def get_model_path(self): + """ + Instance method to ask the user to select the apo model path. + This must be an absolute path and the model must be a .h5 file. + """ + model_dir = filedialog.askopenfilename() + self.model.set(model_dir) + return model_dir + + def resource_path(self, relative_path): + """Get absolute path to resource (for dev and PyInstaller)""" + try: + base_path = sys._MEIPASS + except AttributeError: + base_path = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base_path, relative_path) + + def on_volume_change(self, *args): + """ + Instance method to adpat GUI layout based on volume + calculation selection. + """ + if self.volume_calc_wanted.get() == "Yes": + # Distance between ACSA for Volume Calculation + self.volume_label = ctk.CTkLabel(self.main, text="Slice Distance (cm)") + self.volume_label.grid(column=1, row=15) + + self.distance = StringVar() + self.distance_entry = ctk.CTkEntry( + self.main, width=10, textvariable=self.distance + ) + self.distance_entry.grid(column=3, row=15, sticky=(W, E)) + self.distance.set(7) + + if self.volume_calc_wanted.get() == "No": + # Destroy widget on selection + if hasattr(self, "distance"): + self.distance_entry.grid_remove() + self.volume_label.grid_remove() + + def on_scaling_change(self, *args): + """ + Instance method to adpat GUI layout based on scaling + calculation selection. + """ + if self.scaling.get() == "Bar": + # Spacing + self.spacing_label = ctk.CTkLabel(self.main, text="Spacing (mm)") + self.spacing_label.grid(column=1, row=10) + self.spacing = StringVar() + spacing = [str(s) for s in [5, 10, 15, 20]] + self.spacing_entry = ctk.CTkComboBox( + self.main, + width=10, + variable=self.spacing, + values=spacing, + state="readonly", + ) + self.spacing_entry.grid(column=2, row=10, sticky=(W, E)) + self.spacing.set(10) + + if hasattr(self, "depth"): + self.depth_label.grid_remove() + self.depth_entry.grid_remove() + + elif self.scaling.get() == "Line": + # Image Depth + self.depth_label = ctk.CTkLabel(self.main, text="Depth (cm)") + self.depth_label.grid(column=1, row=9) + self.depth = StringVar() + depth = [str(d) for d in [2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8]] + self.depth_entry = ctk.CTkComboBox( + self.main, width=10, variable=self.depth, values=depth + ) + self.depth_entry.grid(column=2, row=9, sticky=(W, E)) + self.depth.set(5.5) + + if hasattr(self, "spacing"): + self.spacing_label.grid_remove() + self.spacing_entry.grid_remove() + + elif self.scaling.get() == "Manual": + + if hasattr(self, "spacing"): + self.spacing_label.grid_remove() + self.spacing_entry.grid_remove() + + if hasattr(self, "depth"): + self.depth_label.grid_remove() + self.depth_entry.grid_remove() + + elif self.scaling.get() == "No Scaling": + if hasattr(self, "spacing"): + self.spacing_label.grid_remove() + self.spacing_entry.grid_remove() + + if hasattr(self, "depth"): + self.depth_label.grid_remove() + self.depth_entry.grid_remove() + + # --------------------------------------------------------------------------------------------------- + # Methods to run the code + + def run_code(self): + """ + Instance method to execute the analysis process when the + "run" button is pressed. + + Which analysis process is executed depends on the user + selection. By pressing the button, a seperate thread is started + in which the analysis is run. This allows the user to break any + analysis process. Moreover, the threading allows interaction + with the main GUI during ongoing analysis process. This function + handles most of the errors occuring during specification of + file and analysis parameters. All other exeptions are raised in + other function of this package. + + Raises + ------ + AttributeError + The execption is raised when the user didn't specify the + file or training parameters correctly. A tk.messagebox + is openend containing hints how to solve the issue. + FileNotFoundError + The execption is raised when the user didn't specify the + file or training parameters correctly. A tk.messagebox + is openend containing hints how to solve the issue. + PermissionError + The execption is raised when the user didn't specify the + file or training parameters correctly. A tk.messagebox + is openend containing hints how to solve the issue. + """ + try: + if self.is_running: + # don't run again if it is already running + return + self.is_running = True + + selected_input_dir = self.input.get() + selected_model_path = self.model.get() + # selected_loss = self.loss_function.get() + selected_muscle = self.muscle.get() + selected_scaling = self.scaling.get() + selected_volume_calculation = self.volume_calc_wanted.get() + + # Use distance for volumne only if selected + if selected_volume_calculation == "Yes": + distance_acsa = float(self.distance.get()) + else: + distance_acsa = 1 + + if len(selected_input_dir) == 0: + tk.messagebox.showerror( + "Information", + "Check input parameters." + + "\nPotential error source: Invalid specified input directory", + ) + self.should_stop = False + self.is_running = False + self.do_break() + return + + elif len(selected_model_path) == 0: + tk.messagebox.showerror( + "Information", + "Check input parameters." + + "\nPotential error source: Invalid specified model path", + ) + self.should_stop = False + self.is_running = False + self.do_break() + return + + if selected_scaling == "Line": + selected_depth = float(self.depth.get()) + + # Catch depth error. + if selected_depth <= 0: + tk.messagebox.showerror( + "Information", + "Check input parameters." + + "\nPotential error source: Invalid specified depth.", + ) + self.should_stop = False + self.is_running = False + self.do_break() + return + + thread = Thread( + target=gui_helpers.calculate_batch_efov, + args=( + selected_input_dir, + selected_model_path, + selected_depth, + selected_muscle, + selected_volume_calculation, + distance_acsa, + self, + ), + ) + else: + if selected_scaling == "Bar": + selected_spacing = float(self.spacing.get()) + else: + selected_spacing = 0 + thread = Thread( + target=gui_helpers.calculate_batch, + args=( + selected_input_dir, + selected_model_path, + selected_spacing, + selected_muscle, + selected_scaling, + selected_volume_calculation, + distance_acsa, + self, + ), + ) + + thread.start() + + except ValueError: + tk.messagebox.showerror( + "Information", + "Check input parameters." + + "\nPotential error source: Invalid specified depth, distance or volume", + ) + self.should_stop = False + self.is_running = False + self.do_break() + + except AttributeError: + tk.messagebox.showerror( + "Information", + "Check input parameters." + + "\nPotential error source: No scaling type or spacing value specified", + ) + self.should_stop = False + self.is_running = False + self.do_break() + + @property + def should_stop(self): + """ + Instance method to define the should_stop + property getter method. By defining this as a property, + should_stop is treated like a public attribute even + though it is private. + + This is used to stop the analysis process running + in a seperate thread. + + Returns + ------- + should_stop : bool + Boolean variable to decide whether the analysis process + started from the GUI should be stopped. The process is + stopped when should_stop = True. + """ + self._lock.acquire() + should_stop = self._should_stop + self._lock.release() + return should_stop + + @property + def is_running(self): + """ + Instance method to define the is_running + property getter method. By defining this as a property, + is_running is treated like a public attribute even + though it is private. + + This is used to stop the analysis process running + in a seperate thread. + + Returns + ------- + is_running : bool + Boolean variable to check whether the analysis process + started from the GUI is running. The process is only + stopped when is_running = True. + """ + self._lock.acquire() + is_running = self._is_running + self._lock.release() + return is_running + + @should_stop.setter + def should_stop(self, flag: bool): + """ + Instance method to define the should_stop + property setter method. The setter method is used + to set the self._should_stop attribute as if it was + a public attribute. The argument "flag" is thereby + validated to ensure proper input (boolean) + """ + self._lock.acquire() + self._should_stop = flag + self._lock.release() + + @is_running.setter + def is_running(self, flag: bool): + """ + Instance method to define the is_running + property setter method. The setter method is used + to set the self._is_running attribute as if it was + a public attribute. The argument "flag" is thereby + validated to ensure proper input (boolean) + """ + self._lock.acquire() + self._is_running = flag + self._lock.release() + + def do_break(self): + """ + Instance method to break the analysis process when the + button "break" is pressed. + + This changes the instance attribute self.should_stop + to True, given that the analysis is already running. + The attribute is checked befor every iteration + of the analysis process. + """ + if self.is_running: + self.should_stop = True + + +# --------------------------------------------------------------------------------------------------- +# Function required to run the GUI frm the prompt + + +def runMain() -> None: + """ + Function that enables usage of the gui from command promt + as pip package. + + Notes + ----- + The GUI can be executed by typing 'python -m deep_acsa_gui.py' in the command + subsequtently to installing the pip package and activating the + respective library. + + It is not necessary to download any files from the repository when the pip + package is installed. + + For documentation of DL_Track see top of this module. + """ + app = DeepACSA() + app.mainloop() + + +# This statement is required to execute the GUI by typing 'python deep_acsa_gui.py' in the prompt +# when navigated to the folder containing the file and all dependencies. +if __name__ == "__main__": + app = DeepACSA() + app.mainloop() diff --git a/DeepACSA/gui_helpers/.DS_Store b/DeepACSA/gui_helpers/.DS_Store new file mode 100644 index 0000000..3ce7d01 Binary files /dev/null and b/DeepACSA/gui_helpers/.DS_Store differ diff --git a/DeepACSA/gui_helpers/__init__.py b/DeepACSA/gui_helpers/__init__.py new file mode 100644 index 0000000..b439670 --- /dev/null +++ b/DeepACSA/gui_helpers/__init__.py @@ -0,0 +1,24 @@ +__all__ = [ + "apo_model", + "calculate_muscle_volume", + "calibrate", + "echo_int", + "predict_muscle_area", + "model_training", + "file_analysis", + "create_manual_masks", + "image_processing", +] + +__author__ = ["Paul Ritsche"] + +from DeepACSA.gui_helpers.apo_model import * +from DeepACSA.gui_helpers.calculate_muscle_volume import * +from DeepACSA.gui_helpers.calibrate import * +from DeepACSA.gui_helpers.create_manual_masks import * +from DeepACSA.gui_helpers.data_augmentation import * +from DeepACSA.gui_helpers.echo_int import * +from DeepACSA.gui_helpers.file_analysis import * +from DeepACSA.gui_helpers.image_processing import * +from DeepACSA.gui_helpers.model_training import * +from DeepACSA.gui_helpers.predict_muscle_area import * diff --git a/Deep_ACSA/gui_helpers/apo_model.py b/DeepACSA/gui_helpers/apo_model.py similarity index 80% rename from Deep_ACSA/gui_helpers/apo_model.py rename to DeepACSA/gui_helpers/apo_model.py index 121f9e6..9808130 100644 --- a/Deep_ACSA/gui_helpers/apo_model.py +++ b/DeepACSA/gui_helpers/apo_model.py @@ -1,22 +1,22 @@ -""" Python class to predict muscle area - +""" Description ----------- -This module provides a Python class called "ApoModel" for predicting muscle areas -in ultrasound (US) images. It uses a pre-trained segmentation model to predict the -probability of each pixel belonging to the foreground (aponeurosis). -The class supports various image types, such as those with scaling lines, -scaling bars, or manually scaled images. It also offers post-processing functions -to remove unnecessary areas, fill holes, and calculate the circumference of the largest contour. -The module allows users to return the thresholded bit-mask and optionally plot the input -image with the predicted muscle area overlay. Its purpose is to automate muscle area analysis -in US images for research and medical purposes. - -Function scopes ---------------- -_resize - Resizes an input image to the specified height and width. -For scope of the functions used in the class ApoModel see class documentation. +Aponeurosis segmentation model interface for ultrasound images. + +This module provides the ``ApoModel`` class for predicting muscle areas +in ultrasound (US) images using a pre-trained segmentation model. The +model predicts per-pixel foreground probabilities (aponeurosis), which +can be thresholded to obtain a binary mask. + +The class supports different image types, including images with scaling +lines, scaling bars, or manual scaling. It also includes post-processing +utilities to remove unwanted regions, fill holes, and compute the +circumference of the largest detected contour. Optionally, prediction +results can be visualized by overlaying the predicted mask on the input +image. + +The purpose of this module is to automate muscle area analysis in US +images for research and medical purposes. """ import tkinter as tk @@ -29,7 +29,20 @@ from skimage import measure, morphology from skimage.transform import resize -from Deep_ACSA.gui_helpers.model_training import IoU, dice_score, focal_loss +from keras_unet_collection.transformer_layers import ( + patch_extract, + patch_embedding, + SwinTransformerBlock, + patch_merging, + patch_expanding, +) + +from DeepACSA.gui_helpers.model_training import ( + IoU, + dice_score, + dice_bce_loss, + deep_supervised_loss, +) matplotlib.use("Agg") @@ -67,6 +80,7 @@ def _resize(img, width: int, height: int): [3, 4, 3]]) """ + img = resize(img, (1, height, width, 1)) img = np.reshape(img, (height, width)) return img @@ -111,11 +125,10 @@ class ApoModel: >>> # get predictions and plot (the following two are identical) >>> pred_apo_t, fig = apo_model.predict_t(img, width, height) >>> pred_apo_t, fig = apo_model.predict_t(img, width, height, True) + """ - def __init__( - self, gui, model_path: str, loss_function: str, apo_threshold: float = 0.5 - ): + def __init__(self, gui, model_path: str, apo_threshold: float = 0.5): """ Initialize the ApoModel class. @@ -155,36 +168,56 @@ def __init__( >>> instance = ApoModel(gui, model_path, loss_function, apo_threshold) """ + matplotlib.use("Agg") + self.apo_threshold = apo_threshold + try: - self.model_path = model_path - self.apo_threshold = apo_threshold + model_path_lower = model_path.lower() + + # Default custom objects + custom_objects = {"IoU": IoU, "dice_score": dice_score} - # Check for used loss function - if loss_function == "IoU": - self.model_apo = load_model( - self.model_path, custom_objects={"IoU": IoU} + # Add model-specific custom objects + if "vgg16" in model_path_lower: + custom_objects.update( + { + "dice_bce_loss": dice_bce_loss, + } ) - if loss_function == "Dice Loss": - self.model_apo = load_model( - self.model_path, - custom_objects={"dice_score": dice_score, "IoU": IoU}, + elif "unet3plus" in model_path_lower: + custom_objects.update( + { + "deep_supervised_loss": deep_supervised_loss, + } ) - if loss_function == "Focal Loss": - self.model_apo = load_model( - self.model_path, - custom_objects={"focal_loss": focal_loss, "IoU": IoU}, + elif "swinunet" in model_path_lower: + custom_objects.update( + { + "dice_bce_loss": dice_bce_loss, + "patch_extract": patch_extract, + "patch_embedding": patch_embedding, + "SwinTransformerBlock": SwinTransformerBlock, + "patch_merging": patch_merging, + "patch_expanding": patch_expanding, + } ) - # Check if model directory is correct - except OSError: + elif "unet" in model_path_lower: + pass # Nothing to add, default custom_objects are sufficient + + else: + raise ValueError("Unrecognized model type from path.") + + self.model_apo = load_model(model_path, custom_objects=custom_objects) + + except (OSError, ValueError) as e: tk.messagebox.showerror( - "Information", - "Invalid model path." - + "\nPotential error source: Wrong (model) file selected", + "Model Load Error", + f"Failed to load model from path:\n{model_path}\n\nError: {str(e)}", ) def predict(self, gui, img): @@ -220,8 +253,11 @@ def predict(self, gui, img): [0.7, 0.6]]) """ + try: pred_apo = self.model_apo.predict(img) + if isinstance(pred_apo, list): + pred_apo = pred_apo[0] return pred_apo # Model path was specified incorrectly @@ -232,8 +268,7 @@ def predict(self, gui, img): return def postprocess_image(self, img): - """Deletes unnecessary areas, fills holes and calculates the length - of the detected largest contour. + """Deletes unnecessary areas, fills holes, and calculates the length of the detected largest contour. Parameters ---------- @@ -251,30 +286,37 @@ def postprocess_image(self, img): This method takes an input image and performs the following steps: 1. Finds pixel regions and labels them using `measure.label`. 2. Sorts the regions by area in descending order. - 3. Removes all regions except the one with the largest area, effectively - keeping only the largest area in the image. + 3. Removes all regions except the one with the largest area, effectively keeping only the largest area in the image. 4. Fills holes in the largest area using `morphology.remove_small_holes`. 5. Smooths the edges of the predicted area. 6. Calculates the circumference of the largest contour in the image. Example ------- - >>> img = np.array([[0, 0, 1, 0, 0], - [0, 1, 1, 1, 0], - [0, 1, 1, 1, 0], - [0, 0, 1, 0, 0]]) - >>> model = ApoModel() - >>> circumference, processed_img = model.postprocess_image(img) - >>> print(circumference) - 16.0 - >>> print(processed_img) - array([[0, 0, 1, 0, 0], - [0, 1, 1, 1, 0], - [0, 1, 1, 1, 0], - [0, 0, 1, 0, 0]]) - + :: + + >>> img = np.array([[0, 0, 1, 0, 0], + ... [0, 1, 1, 1, 0], + ... [0, 1, 1, 1, 0], + ... [0, 0, 1, 0, 0]]) + >>> model = ApoModel() + >>> circumference, processed_img = model.postprocess_image(img) + >>> print(circumference) + 16.0 + >>> print(processed_img) + array([[0, 0, 1, 0, 0], + [0, 1, 1, 1, 0], + [0, 1, 1, 1, 0], + [0, 0, 1, 0, 0]]) """ + if np.sum(img) == 0: + return 0.0, img.astype(np.uint8) + + # Pre erosion to break thin connections + erosion_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (4, 4)) + img = cv2.erode(img.astype(np.uint8), erosion_kernel, iterations=1) + # Find pixel regions and label them label_img = measure.label(img) regions = measure.regionprops(label_img) @@ -296,8 +338,8 @@ def postprocess_image(self, img): ).astype(int) # Smooth the edges - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (10, 10)) - pred_apo_th = cv2.dilate(pred_apo_th.astype(np.uint8), kernel, iterations=2) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + pred_apo_th = cv2.dilate(pred_apo_th.astype(np.uint8), kernel, iterations=3) pred_apo_th = cv2.erode(pred_apo_th, kernel, iterations=2) # Calculate circumference @@ -377,7 +419,9 @@ def predict_e( [False, False]]) >>> print(fig) + """ + pred_apo = self.predict(gui, img) pred_apo_t = pred_apo > self.apo_threshold @@ -478,7 +522,9 @@ def predict_s( [False, False]]) >>> print(fig) + """ + pred_apo = self.predict(gui, img) pred_apo_t = pred_apo > self.apo_threshold @@ -518,7 +564,7 @@ def predict_m( """Runs a segmentation model on the input image and thresholds the result. - The input image here was scaled manualy. + The input image here was scaled manualy or not at all. Parameters ---------- @@ -568,7 +614,9 @@ def predict_m( [False, False]]) >>> print(fig) + """ + pred_apo = self.predict(gui, img) pred_apo_t = pred_apo > self.apo_threshold @@ -591,7 +639,7 @@ def predict_m( ) ax2.grid(False) ax2.set_title( - "Normalized and resized image with predicted muscle area (overlay)" + f"Normalized and resized image {filename} with predicted muscle area (overlay)" ) plt.close(fig) return circum, pred_apo_th, fig diff --git a/DeepACSA/gui_helpers/calculate_muscle_volume.py b/DeepACSA/gui_helpers/calculate_muscle_volume.py new file mode 100644 index 0000000..e8e44c3 --- /dev/null +++ b/DeepACSA/gui_helpers/calculate_muscle_volume.py @@ -0,0 +1,77 @@ +""" +Description +----------- +Utilities for estimating muscle volume from ultrasound data. + +Muscle volume is computed from predicted anatomical cross-sectional +areas (ACSA) using the truncated cone formula between consecutive +scans with constant spacing. At least three images of the same muscle +are required, and the inter-scan distance must be known and provided +in centimeters. +""" + +import math + + +def muscle_volume_calculation(scan_area: list, dist: float): + """ + Estimate total muscle volume from predicted cross-sectional areas. + + The function calculates muscle volume by applying the truncated cone + formula between consecutive anatomical cross-sectional areas (ACSA) + measured from ultrasound images. The individual segment volumes are + summed to obtain the total muscle volume. + + Parameters + ---------- + scan_area : list of floats + List variable containing the predicted muscle areas as floats. + dist : float + Float variable containing the distance between the images + in cm. + + Returns + ------- + total_volume : float + Float variable containing the total predicted muscle + volume in cubic centimeters (cm^3). + + Notes + ----- + The truncated cone formula is given by: + + .. math:: + + V = \\frac{h}{3} (A1 + A2 + \\sqrt{A1 A2}) + + where `h` is the distance between scans. `A1` and `A2` are the predicted areas + of two adjacent scans. + + The function makes a few assumptions without checking for them. + + - The `scan_area` list should contain at least three elements representing + the predicted muscle areas from 3 different scans or images of the same + muscle. + - The `dist` parameter should be the known distance between the images in + centimeters (cm). It must be constant and positive for accurate volume + calculation. + + Examples + -------- + >>> muscle_volume_calculation([2, 3, 5, 4, 2, 2], 3.75) + 59.52879521092045 + """ + total_volume = 0 + + for scan_nr in range(len(scan_area)-1): + + partial_volume = 0 + + # a and b are ACSA of 2 scans + a = scan_area[scan_nr] + b = scan_area[scan_nr + 1] + + partial_volume = 1/3 * (a + b + math.sqrt((a * b))) * dist + total_volume = total_volume + partial_volume + + return total_volume diff --git a/Deep_ACSA/gui_helpers/calibrate.py b/DeepACSA/gui_helpers/calibrate.py similarity index 50% rename from Deep_ACSA/gui_helpers/calibrate.py rename to DeepACSA/gui_helpers/calibrate.py index 62e45cc..f902e1c 100644 --- a/Deep_ACSA/gui_helpers/calibrate.py +++ b/DeepACSA/gui_helpers/calibrate.py @@ -1,37 +1,23 @@ """ Description ----------- -This module contains functions to automatically or manually -scale images. -The scope of the automatic method is limited to scaling bars being -present in the right side of the image. The scope of the manual method -is not limited to specific scaling types in images. However, the distance -between two selected points in the image required for the scaling must be known. - -Functions scope ---------------- -region_of_interest - Function to crop the images according to a specified - region of interest. -mclick - Function to detect mouse click coordinates in image. -draw_the_lines - Function to mark the detected lines. -calibrate_distance_efov - Function to calibrate EFOV ultrasonography images automatically. -calibrate_distance_manually - Function to manually calibrate an image to convert measurements - in pixel units to centimeters. -calibrate_distance_static - Function to calibrate an image to convert measurements - in pixel units to centimeters. +Image scaling utilities. + +This module contains functions to automatically or manually scale +ultrasound images. The automatic method requires scaling bars to be +present on the right side of the image. The manual method allows +scaling based on user-selected points, provided that the real-world +distance between those points is known. """ import math import tkinter as tk -import cv2 +from tkinter import messagebox +from PIL import Image, ImageTk import numpy as np +import cv2 + mlocs = [] @@ -68,8 +54,8 @@ def region_of_interest(img: np.ndarray, vertices: np.ndarray): - The function does not modify the original image; instead, it returns a cropped version containing the region of interest. - Example - ------- + Examples + -------- >>> region_of_interest(preprocessed_image, ([(0,1), (0,2), (4,2), (4,7)], np.int32),) """ @@ -265,7 +251,7 @@ def calibrate_distance_efov(path_to_image: str, arg_muscle: str): image_with_lines = draw_the_lines(image, lines[0]) # For VL - if muscle == "VL": + elif muscle == "VL": lines = cv2.HoughLinesP( cropped_image, rho=1, # Distance of pixels in accumulator @@ -281,7 +267,7 @@ def calibrate_distance_efov(path_to_image: str, arg_muscle: str): image_with_lines = draw_the_lines(image, lines[0]) # For GM / GL - if muscle == "GL": + elif muscle == "GL": lines = cv2.HoughLinesP( cropped_image, rho=1, # Distance of pixels in accumulator @@ -297,7 +283,7 @@ def calibrate_distance_efov(path_to_image: str, arg_muscle: str): image_with_lines = draw_the_lines(image, lines[0]) # For BF - if muscle == "BF": + elif muscle == "BF": lines = cv2.HoughLinesP( cropped_image, rho=1, @@ -412,95 +398,394 @@ def calibrate_distance_static(nonflipped_img: np.ndarray, spacing: int): return calib_dist, imgscale, scale_statement +# def calibrate_distance_manually(nonflipped_img: np.ndarray, spacing: str): +# """ +# Function to manually calibrate an image to convert measurements +# in pixel units to centimeters. + +# The function calculates the distance in pixel units between two +# points on the input image. The points are determined by clicks of +# the user; only two points may be selected and clicks outside the +# visible image are ignored to ensure both calibration points remain +# on the picture. A right mouse click can be used to undo the last +# point in case of a mistake. The distance (in milimeters) is +# determined by the value contained in the spacing variable. Then the +# ratio of pixel / centimeter is calculated. To get the distance, the +# euclidean distance between the two points is calculated. + +# Parameters +# ---------- +# nonflipped_img : np.ndarray +# Input image to be analysed as a numpy array. The image must +# be loaded prior to calibration, specifying a path +# is not valid. +# spacing : int +# Integer variable containing the known distance in milimeter +# between the two placed points by the user. This can be 5, 10, +# 15 or 20 milimeter. + +# Returns +# ------- +# calib_dist : int +# Integer variable containing the distance between the two +# specified point in pixel units. + +# Notes +# ----- +# - The function displays the image and waits for the user to click on two points to specify the distance. +# - The spacing parameter should be provided as a string, representing the numeric value of the known distance. +# - After calibration, the function will return the calibration distance in pixel units. + +# Examples +# -------- +# >>> calibrateDistanceManually(img=([[[[0.22414216 0.19730392 0.22414216] ... [0.2509804 0.2509804 0.2509804 ]]]), 5) +# 99, 5 mm corresponds to 99 pixels +# """ +# global mlocs +# mlocs = [] + +# # keep an unmodified copy of the original for zooming calculations +# orig_img = np.uint8(nonflipped_img) +# zoom_factor = 1.0 + +# def refresh(): +# """Redraw the image window according to the current zoom and points.""" +# nonlocal zoom_factor +# h, w = orig_img.shape[:2] +# disp_w = max(1, int(w * zoom_factor)) +# disp_h = max(1, int(h * zoom_factor)) +# display_img = cv2.resize( +# orig_img, (disp_w, disp_h), interpolation=cv2.INTER_LINEAR +# ) +# for px, py in mlocs: +# cv2.circle( +# display_img, +# (int(px * zoom_factor), int(py * zoom_factor)), +# 3, +# (255, 255, 255), +# 2, +# ) +# cv2.imshow("image", display_img) + +# def mclick(event, x_val, y_val, flags, param): +# nonlocal zoom_factor +# global mlocs + +# # mouse wheel event: adjust zoom +# if event == cv2.EVENT_MOUSEWHEEL or event == cv2.EVENT_MOUSEHWHEEL: +# if flags > 0: +# zoom_factor *= 1.1 +# else: +# zoom_factor /= 1.1 +# zoom_factor = max(0.2, min(5.0, zoom_factor)) +# refresh() +# return + +# # left click: add a point (only if fewer than 2 points) +# if event == cv2.EVENT_LBUTTONDOWN: +# h, w = orig_img.shape[:2] +# # map to original coordinates +# ox = int(x_val / zoom_factor) +# oy = int(y_val / zoom_factor) +# if not (0 <= ox < w and 0 <= oy < h): +# return +# if len(mlocs) < 2: +# mlocs.append((ox, oy)) +# else: +# return + +# # right click: remove the last point if present +# elif event == cv2.EVENT_RBUTTONDOWN and mlocs: +# mlocs.pop() + +# refresh() + +# # give information for scaling +# tk.messagebox.showinfo( +# "Information", +# "Scale the image before creating a mask." +# + "\nClick on two scaling bars that are EXACTLY 1 CM APART." +# + "\nUse the mouse wheel to zoom in/out if necessary." +# + "\nRight‑click on the last point to remove the last point if you make a mistake." +# + "\nClick 'q' to continue.", +# ) +# # Edit image +# img2 = np.uint8(nonflipped_img) + +# # display the image and wait for a keypress +# # cv2.imshow("image", img2) +# import matplotlib.pyplot as plt + +# plt.imshow(cv2.cvtColor(img2, cv2.COLOR_BGR2RGB)) +# plt.title("image") +# plt.axis("off") +# plt.show() +# cv2.setMouseCallback("image", mclick) +# key = cv2.waitKey(0) + +# # if the 'q' key is pressed, break from the loop +# if key == ord("q"): +# cv2.destroyAllWindows() + +# calib_dist = 0 +# if len(mlocs) == 2: +# calib_dist = np.abs( +# math.sqrt( +# (mlocs[1][0] - mlocs[0][0]) ** 2 + (mlocs[1][1] - mlocs[0][1]) ** 2 +# ) +# ) + +# mlocs = [] + +# if calib_dist == 0: +# tk.messagebox.showerror( +# "Error", +# "Calibration failed. Please click on two points to specify the distance.", +# ) +# return None + +# tk.messagebox.showinfo("Information", f"1 cm corresponds to {calib_dist} pixels") + +# return calib_dist + + def calibrate_distance_manually(nonflipped_img: np.ndarray, spacing: str): """ - Function to manually calibrate an image to convert measurements - in pixel units to centimeters. + Manually calibrate pixel-to-centimeter scaling using an interactive GUI. - The function calculates the distance in pixel units between two - points on the input image. The points are determined by clicks of - the user. The distance (in milimeters) is determined by the value - contained in the spacing variable. Then the ratio of pixel / centimeter - is calculated. To get the distance, the euclidean distance between the - two points is calculated. + This function opens a Tkinter-based calibration window that allows the user + to select two points on an image corresponding to a known physical distance + (typically 1 cm). The Euclidean distance between the selected points is + computed in pixel units and returned as the calibration factor. + + The interface supports zooming, point selection, and undo functionality, + enabling precise placement of calibration points directly within the GUI. Parameters ---------- nonflipped_img : np.ndarray - Input image to be analysed as a numpy array. The image must - be loaded prior to calibration, specifying a path - is not valid. - spacing : int - Integer variable containing the known distance in milimeter - between the two placed points by the user. This can be 5, 10, - 15 or 20 milimeter. + Input image as a NumPy array. The image must be preloaded and is expected + to be in either grayscale or BGR format. The image is internally converted + to RGB for display purposes. + + spacing : str + String representing the known physical distance between the two selected + points in millimeters. Typically values such as "5", "10", "15", or "20". + Note that the current implementation assumes a 1 cm (10 mm) reference for + interpretation of the returned pixel distance. Returns ------- - calib_dist : int - Integer variable containing the distance between the two - specified point in pixel units. + float or None + The Euclidean distance between the two selected points in pixel units, + corresponding to 1 cm. Returns None if calibration is aborted or invalid. Notes ----- - - The function displays the image and waits for the user to click on two points to specify the distance. - - The spacing parameter should be provided as a string, representing the numeric value of the known distance. - - After calibration, the function will return the calibration distance in pixel units. + **User interaction:** + - Left-click: Place up to two calibration points. + - Right-click: Remove the last placed point. + - Mouse wheel: Zoom in/out centered around the cursor. + - Enter: Confirm selection and compute calibration. + - Escape: Cancel calibration. + + **Behavior:** + - The function operates within a Tkinter `Toplevel` window and blocks execution + until the window is closed. + - The returned value represents pixels per centimeter and can be used to convert + pixel-based measurements to real-world units. + + **Important:** + - This implementation replaces OpenCV GUI (`cv2.imshow`) functionality to ensure + compatibility with macOS and integration with Tkinter/CustomTkinter-based GUIs. + - The `spacing` parameter is currently not used in scaling and serves as metadata. + If variable scaling is required (e.g., non-1 cm references), the returned value + should be adjusted accordingly. Examples -------- - >>> calibrateDistanceManually(img=([[[[0.22414216 0.19730392 0.22414216] ... [0.2509804 0.2509804 0.2509804 ]]]), 5) - 99, 5 mm corresponds to 99 pixels + >>> import cv2 + >>> img = cv2.imread("ultrasound.png") + >>> px_per_cm = calibrate_distance_manually(img, spacing="10") + >>> print(px_per_cm) + 98.7 + + >>> # Convert pixel measurement to centimeters + >>> pixel_length = 250 + >>> length_cm = pixel_length / px_per_cm + >>> print(length_cm) + 2.53 + + See Also + -------- + area_cm2_from_mask : Convert segmented pixel areas to cm² using calibration. + fill_contour_mask : Utility for generating binary masks from contours. """ - global mlocs - mlocs = [] + if nonflipped_img is None: + return None + + # Ensure uint8 RGB for display + img = np.uint8(nonflipped_img) + if img.ndim == 2: + img_rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) + else: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - def mclick(event, x_val, y_val, flags, param): - global mlocs - img_cop = img2.copy() - # if the left mouse button was clicked, record the (x, y) coordinates - if event == cv2.EVENT_LBUTTONDOWN: - mlocs.append((x_val, y_val)) - # Draw a red dot on the image at the clicked position - cv2.circle(img_cop, (x_val, y_val), 3, (255, 255, 255), 2) - cv2.imshow("image", img_cop) - - # give information for scaling - tk.messagebox.showinfo( + orig_img = img_rgb + orig_h, orig_w = orig_img.shape[:2] + + messagebox.showinfo( "Information", "Scale the image before creating a mask." - + "\nClick on two scaling bars that are EXACTLY 1 CM APART." - + "\nClick 'q' to continue.", + "\nClick on two scaling bars that are EXACTLY 1 CM APART." + "\nUse the mouse wheel to zoom in/out if necessary." + "\nRight-click to remove the last point." + "\nPress Enter to confirm, or Esc to cancel.", ) - # Edit image - img2 = np.uint8(nonflipped_img) - # display the image and wait for a keypress - cv2.imshow("image", img2) - cv2.setMouseCallback("image", mclick) - key = cv2.waitKey(0) + root = tk._default_root + if root is None: + root = tk.Tk() + root.withdraw() - # if the 'q' key is pressed, break from the loop - if key == ord("q"): - cv2.destroyAllWindows() + top = tk.Toplevel(root) + top.title("Calibration") + top.geometry("1000x800") + top.grab_set() - calib_dist = 0 - if len(mlocs) == 2: - calib_dist = np.abs( - math.sqrt( - (mlocs[1][0] - mlocs[0][0]) ** 2 + (mlocs[1][1] - mlocs[0][1]) ** 2 - ) + canvas = tk.Canvas(top, bg="black", highlightthickness=0) + canvas.pack(fill="both", expand=True) + + points = [] + zoom_factor = 1.0 + photo_ref = {"img": None} + result = {"calib_dist": None} + canvas_img_id = {"id": None} + + def redraw(): + canvas.delete("point") + + disp_w = max(1, int(orig_w * zoom_factor)) + disp_h = max(1, int(orig_h * zoom_factor)) + + pil_img = Image.fromarray(orig_img).resize( + (disp_w, disp_h), Image.Resampling.BILINEAR ) + photo = ImageTk.PhotoImage(pil_img) + photo_ref["img"] = photo + + if canvas_img_id["id"] is None: + canvas_img_id["id"] = canvas.create_image(0, 0, anchor="nw", image=photo) + else: + canvas.itemconfig(canvas_img_id["id"], image=photo) + + canvas.config(scrollregion=(0, 0, disp_w, disp_h)) + + for px, py in points: + x = px * zoom_factor + y = py * zoom_factor + r = 4 + canvas.create_oval( + x - r, y - r, x + r, y + r, outline="white", width=2, tags="point" + ) - mlocs = [] + def canvas_to_image_coords(x, y): + cx = canvas.canvasx(x) + cy = canvas.canvasy(y) + ox = int(cx / zoom_factor) + oy = int(cy / zoom_factor) + return ox, oy + + def on_left_click(event): + if len(points) >= 2: + return + ox, oy = canvas_to_image_coords(event.x, event.y) + if 0 <= ox < orig_w and 0 <= oy < orig_h: + points.append((ox, oy)) + redraw() + + def on_right_click(event): + if points: + points.pop() + redraw() + + def on_mousewheel(event): + nonlocal zoom_factor + + old_zoom = zoom_factor + + # Windows / macOS + if event.delta > 0: + zoom_factor *= 1.1 + else: + zoom_factor /= 1.1 + + zoom_factor = max(0.2, min(8.0, zoom_factor)) + + # zoom around cursor + mouse_x = canvas.canvasx(event.x) + mouse_y = canvas.canvasy(event.y) + + redraw() + + if old_zoom != 0: + scale = zoom_factor / old_zoom + new_x = mouse_x * scale + new_y = mouse_y * scale - if calib_dist == 0: - tk.messagebox.showerror( - "Error", - "Calibration failed. Please click on two points to specify the distance.", + canvas_w = canvas.winfo_width() + canvas_h = canvas.winfo_height() + + xview = max(0, new_x - event.x) + yview = max(0, new_y - event.y) + + total_w = max(1, int(orig_w * zoom_factor)) + total_h = max(1, int(orig_h * zoom_factor)) + + canvas.xview_moveto(xview / total_w) + canvas.yview_moveto(yview / total_h) + + def on_confirm(event=None): + if len(points) != 2: + messagebox.showerror( + "Error", + "Calibration failed. Please click on two points to specify the distance.", + ) + return + + dist = math.sqrt( + (points[1][0] - points[0][0]) ** 2 + (points[1][1] - points[0][1]) ** 2 ) - return None - tk.messagebox.showinfo("Information", f"1 cm corresponds to {calib_dist} pixels") + result["calib_dist"] = dist + top.destroy() + + def on_cancel(event=None): + result["calib_dist"] = None + top.destroy() + + # Optional scrollbars + xbar = tk.Scrollbar(top, orient="horizontal", command=canvas.xview) + ybar = tk.Scrollbar(top, orient="vertical", command=canvas.yview) + canvas.configure(xscrollcommand=xbar.set, yscrollcommand=ybar.set) + + xbar.pack(side="bottom", fill="x") + ybar.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + + canvas.bind("", on_left_click) + canvas.bind("", on_right_click) + canvas.bind("", on_mousewheel) # macOS / Windows + top.bind("", on_confirm) + top.bind("", on_cancel) + + redraw() + top.wait_window() + + calib_dist = result["calib_dist"] + + if calib_dist is None or calib_dist == 0: + return None + messagebox.showinfo("Information", f"1 cm corresponds to {calib_dist:.1f} pixels") return calib_dist diff --git a/Deep_ACSA/gui_helpers/create_manual_masks.py b/DeepACSA/gui_helpers/create_manual_masks.py similarity index 88% rename from Deep_ACSA/gui_helpers/create_manual_masks.py rename to DeepACSA/gui_helpers/create_manual_masks.py index bf24e81..d3bf92f 100644 --- a/Deep_ACSA/gui_helpers/create_manual_masks.py +++ b/DeepACSA/gui_helpers/create_manual_masks.py @@ -1,3 +1,24 @@ +""" +Description +----------- +Interactive manual mask creation utilities. + +This module provides functionality for manually generating anatomical +cross-sectional area (ACSA) masks from ultrasound images. Users can +interactively select regions of interest using a Matplotlib-based GUI, +after optional distance calibration. The selected regions are converted +into binary masks and the corresponding muscle area is computed. + +The module also supports batch processing of image directories for +training data generation. Selected images and masks are saved to +separate output directories, and analysis results are exported in +tabular form. + +The functions in this module are primarily intended for manual +annotation and dataset preparation for model training. +""" + + import os from tkinter.messagebox import WARNING, askokcancel, showerror, showinfo @@ -7,8 +28,8 @@ import numpy as np import pandas as pd -from Deep_ACSA.gui_helpers.calibrate import calibrate_distance_manually -from Deep_ACSA.gui_helpers.predict_muscle_area import compile_save_results +from DeepACSA.gui_helpers.calibrate import calibrate_distance_manually +from DeepACSA.gui_helpers.predict_muscle_area import compile_save_results def select_area(image): @@ -35,7 +56,7 @@ def select_area(image): # Plot image fig, ax = plt.subplots() fig.set_size_inches(20 / 2.45, 15 / 2.54) - fig.set_facecolor("#7ABAA1") + fig.set_facecolor("#2A484E") ax.imshow(image, cmap="gray") ax.set_title( @@ -138,7 +159,7 @@ def zoom(event): def create_acsa_masks( input_dir, - muscle_name: str = "bf_img_", + muscle_name: str = "rf_img_", output_imgs_dir: str = None, output_masks_dir: str = None, ): @@ -151,7 +172,7 @@ def create_acsa_masks( input_dir : str Directory containing the input images. muscle_name : str, optional - Prefix for the output filenames. Default is "rf_img_". + Prefix for the output filenames. Default is ``"rf_img_"``. output_imgs_dir: str, optional Directory to save the output images. If not provided, a 'train_images' folder will be created inside input_dir. output_masks_dir: str, optional diff --git a/Deep_ACSA/gui_helpers/data_augmentation.py b/DeepACSA/gui_helpers/data_augmentation.py similarity index 96% rename from Deep_ACSA/gui_helpers/data_augmentation.py rename to DeepACSA/gui_helpers/data_augmentation.py index 109879d..0e28dc9 100644 --- a/Deep_ACSA/gui_helpers/data_augmentation.py +++ b/DeepACSA/gui_helpers/data_augmentation.py @@ -9,11 +9,6 @@ input images and their corresponding masks to generate new training data for machine learning models. Data augmentation is a common technique used to artificially increase the diversity of the training dataset by applying various transformations to the original images. - -Functions scope ---------------- -image_augmentation - Function, which generates new training data from the input images through data augmentation. """ import os @@ -21,7 +16,7 @@ import matplotlib.pyplot as plt import numpy as np -from keras.preprocessing.image import ImageDataGenerator +from tensorflow.keras.preprocessing.image import ImageDataGenerator def image_augmentation(input_img_folder, input_mask_folder, gui): diff --git a/Deep_ACSA/gui_helpers/deep_acsa.jpg b/DeepACSA/gui_helpers/deep_acsa.jpg similarity index 100% rename from Deep_ACSA/gui_helpers/deep_acsa.jpg rename to DeepACSA/gui_helpers/deep_acsa.jpg diff --git a/Deep_ACSA/gui_helpers/echo_int.py b/DeepACSA/gui_helpers/echo_int.py similarity index 82% rename from Deep_ACSA/gui_helpers/echo_int.py rename to DeepACSA/gui_helpers/echo_int.py index 3271095..eccb8a5 100644 --- a/Deep_ACSA/gui_helpers/echo_int.py +++ b/DeepACSA/gui_helpers/echo_int.py @@ -1,15 +1,18 @@ """ Description ----------- -This module contains a function to automatically calculate the echo intensity -(mean grayscale value) of the predicted mask. The correctness of the calculated -echo intensity depends on the correctess of the predicton. - -Functions scope ---------------- -calculate_echo_int - Function to calculate the echo intensity of muscle area. +Echo intensity computation utilities. + +This module provides functionality to calculate the echo intensity +(mean grayscale value) within a segmented muscle region. The echo +intensity is computed from the original ultrasound image using a +binary mask representing the predicted anatomical cross-sectional +area (ACSA). + +The accuracy of the calculated echo intensity depends directly on +the correctness of the provided segmentation mask. """ + import cv2 import numpy as np diff --git a/Deep_ACSA/gui_helpers/file_analysis.py b/DeepACSA/gui_helpers/file_analysis.py similarity index 73% rename from Deep_ACSA/gui_helpers/file_analysis.py rename to DeepACSA/gui_helpers/file_analysis.py index 9a837b1..9a800f2 100644 --- a/Deep_ACSA/gui_helpers/file_analysis.py +++ b/DeepACSA/gui_helpers/file_analysis.py @@ -1,10 +1,28 @@ -"""Module to perform analysis of image files used for training model""" - +""" +Description +----------- +Training image file analysis and management utilities. + +This module provides interactive and automated tools for managing +image datasets used for model training. It includes functionality for +detecting mismatches between image and mask directories, visualizing +image–mask overlays, collecting images from nested directory +structures, redacting image regions, and reviewing potential outliers. + +Several functions use graphical user interfaces (Tkinter and +Matplotlib) for interactive inspection, navigation, and deletion of +image–mask pairs. The module is primarily intended to support dataset +quality control and preparation prior to model training. +""" import os import tkinter as tk from tkinter import ttk +import shutil +from pathlib import Path +from PIL import Image, ImageDraw import cv2 +import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -31,8 +49,8 @@ def show_outliers_popup(df, dir1, dir2): popup = tk.Toplevel() popup.title("Outliers") master_path = os.path.dirname(os.path.abspath(__file__)) - iconpath = master_path + "/icon.ico" - popup.iconbitmap(iconpath) + iconpath = master_path + "/home_im.ico" + # popup.iconbitmap(iconpath) label = tk.Label(popup, text=f"Comparing images in {dir1} \nand {dir2}") label.pack(pady=10) @@ -244,9 +262,12 @@ def overlay_image(image_path, mask_path, alpha=0.5): ultrasound = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) - # Create a colored mask with green color + # Ensure both images have the same dimensions by resizing the mask + ultrasound = cv2.resize(ultrasound, (mask.shape[1], mask.shape[0])) + + # Create a colored mask with green color for the white regions in the mask colored_mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) - colored_mask[mask == 255] = [0, 255, 0] # Green color for mask + colored_mask[mask > 0] = [0, 255, 0] # Green color for mask regions # Convert the ultrasound image to color ultrasound_colored = cv2.cvtColor(ultrasound, cv2.COLOR_GRAY2BGR) @@ -256,10 +277,12 @@ def overlay_image(image_path, mask_path, alpha=0.5): return overlaid_image + matplotlib.use("TkAgg") + # Create an interactive plot fig, ax = plt.subplots(1, 1) fig.set_size_inches(20 / 2.45, 15 / 2.54) - fig.set_facecolor("#7ABAA1") + fig.set_facecolor("#2A484E") current_idx = start_index # Function to handle delete button click @@ -319,7 +342,8 @@ def display_current_image(): ax.set_title( f"Image: {image_files[current_idx]}" + "\nClick right/left arrow to navigate through images" - + "\nClick Delete to delete the image-mask pair." + + "\nClick Delete to delete the image-mask pair.", + color="white", ) # Turn off axis display @@ -372,3 +396,78 @@ def on_key(event): display_current_image() plt.show() + + +def collect_images(root_dir, target_dir, image_type): + """ + Searches through the root directory and its subdirectories for images of the + specified type and copies them to the target directory with modified names to + avoid overwriting. The modified name format is original_filename_n.extension, where + n starts from 0. + + Parameters + ---------- + root_dir : str + The root directory to search for images. + target_dir : str + The directory where found images will be stored. + image_type : str + The type of the images to search for (e.g., 'jpg', 'png', 'tiff'). + + Notes + ----- + The function creates the target directory if it does not already exist. + """ + # Ensure target directory exists + Path(target_dir).mkdir(parents=True, exist_ok=True) + file_counter = {} + + for subdir, dirs, files in os.walk(root_dir): + for file in files: + if file.endswith("." + image_type): + # Check if the file name is already in our counter dictionary + if file in file_counter: + # Increment the counter for that file name + file_counter[file] += 1 + else: + # Initialize counter for this file name + file_counter[file] = 0 + + src_path = os.path.join(subdir, file) + # Modify the target file name to include the counter + file_name, file_ext = os.path.splitext(file) + target_file_name = f"{file_name}_{file_counter[file]}{file_ext}" + target_path = os.path.join(target_dir, target_file_name) + + shutil.copy(src_path, target_path) + + +def redact_images_in_directory(directory): + """ + Redacts the upper 50 pixels of every image in the specified directory by + drawing a black rectangle over them. The images are saved under the same + names in the same directory. + + Parameters + ---------- + directory : str + The path to the directory containing the images to be redacted. + """ + for filename in os.listdir(directory): + # Check if the file is an image based on its extension. + # You might want to adjust the list of extensions based on your needs. + if filename.lower().endswith( + (".png", ".jpg", ".jpeg", ".tiff", ".bmp", ".gif", ".tif") + ): + try: + filepath = os.path.join(directory, filename) + with Image.open(filepath) as img: + # Use ImageDraw to draw a black rectangle over the top 50 pixels + draw = ImageDraw.Draw(img) + draw.rectangle([0, 0, img.width, 60], fill="black") + + # Save the image, overwriting the original file + img.save(filepath) + print(f"Processed {filename}") + except Exception as e: + print(f"Failed to process {filename}: {e}") diff --git a/DeepACSA/gui_helpers/gui_files/Cite.png b/DeepACSA/gui_helpers/gui_files/Cite.png new file mode 100644 index 0000000..2c6448b Binary files /dev/null and b/DeepACSA/gui_helpers/gui_files/Cite.png differ diff --git a/DeepACSA/gui_helpers/gui_files/Info.png b/DeepACSA/gui_helpers/gui_files/Info.png new file mode 100644 index 0000000..88b62eb Binary files /dev/null and b/DeepACSA/gui_helpers/gui_files/Info.png differ diff --git a/DeepACSA/gui_helpers/gui_files/gear.png b/DeepACSA/gui_helpers/gui_files/gear.png new file mode 100644 index 0000000..cea51cb Binary files /dev/null and b/DeepACSA/gui_helpers/gui_files/gear.png differ diff --git a/DeepACSA/gui_helpers/gui_files/ui_color_theme.json b/DeepACSA/gui_helpers/gui_files/ui_color_theme.json new file mode 100644 index 0000000..64767da --- /dev/null +++ b/DeepACSA/gui_helpers/gui_files/ui_color_theme.json @@ -0,0 +1,359 @@ +{ + "CTk": { + "fg_color": [ + "#2A484E", + "#2A484E" + ] + }, + "CTkToplevel": { + "fg_color": [ + "LightBlue4", + "LightBlue4" + ] + }, + "CTkFrame": { + "corner_radius": 0, + "border_width": 0, + "fg_color": [ + "#2A484E", + "#2A484E" + ], + "top_fg_color": [ + "#2A484E", + "#2A484E" + ], + "border_color": [ + "#2A484E", + "#2A484E" + ] + }, + "CTkButton": { + "corner_radius": 6, + "border_width": 1, + "fg_color": [ + "#DDEFE3", + "#DDEFE3" + ], + "hover_color": [ + "#A8D8CD", + "#A8D8CD" + ], + "border_color": [ + "#000000", + "#000000" + ], + "text_color": [ + "#000000", + "#000000" + ], + "text_color_disabled": [ + "gray74", + "gray60" + ] + }, + "CTkLabel": { + "corner_radius": 0, + "fg_color": "transparent", + "text_color": [ + "#000000", + "#000000" + ] + }, + "CTkEntry": { + "corner_radius": 6, + "border_width": 2, + "fg_color": [ + "#F9F9FA", + "#343638" + ], + "border_color": [ + "#979DA2", + "#565B5E" + ], + "text_color": [ + "black", + "black" + ], + "placeholder_text_color": [ + "gray52", + "gray62" + ] + }, + "CTkCheckBox": { + "corner_radius": 6, + "border_width": 3, + "fg_color": [ + "#3a7ebf", + "#1f538d" + ], + "border_color": [ + "#3E454A", + "#949A9F" + ], + "hover_color": [ + "#325882", + "#14375e" + ], + "checkmark_color": [ + "#DCE4EE", + "gray90" + ], + "text_color": [ + "gray14", + "gray84" + ], + "text_color_disabled": [ + "gray60", + "gray45" + ] + }, + "CTkSwitch": { + "corner_radius": 1000, + "border_width": 3, + "button_length": 0, + "fg_color": [ + "#939BA2", + "#4A4D50" + ], + "progress_color": [ + "#3a7ebf", + "#1f538d" + ], + "button_color": [ + "gray36", + "#D5D9DE" + ], + "button_hover_color": [ + "gray20", + "gray100" + ], + "text_color": [ + "gray14", + "gray84" + ], + "text_color_disabled": [ + "gray60", + "gray45" + ] + }, + "CTkRadioButton": { + "corner_radius": 1000, + "border_width_checked": 6, + "border_width_unchecked": 3, + "fg_color": [ + "#3a7ebf", + "#1f538d" + ], + "border_color": [ + "#3E454A", + "#949A9F" + ], + "hover_color": [ + "#325882", + "#14375e" + ], + "text_color": [ + "gray14", + "gray84" + ], + "text_color_disabled": [ + "gray60", + "gray45" + ] + }, + "CTkProgressBar": { + "corner_radius": 1000, + "border_width": 0, + "fg_color": [ + "#585858", + "#585858" + ], + "progress_color": [ + "#FFBF00", + "#FFBF00" + ], + "border_color": [ + "black", + "black" + ] + }, + "CTkSlider": { + "corner_radius": 1000, + "button_corner_radius": 1000, + "border_width": 6, + "button_length": 0, + "fg_color": [ + "#939BA2", + "#4A4D50" + ], + "progress_color": [ + "gray40", + "#AAB0B5" + ], + "button_color": [ + "#3a7ebf", + "#1f538d" + ], + "button_hover_color": [ + "#325882", + "#14375e" + ] + }, + "CTkOptionMenu": { + "corner_radius": 6, + "fg_color": [ + "#3a7ebf", + "#1f538d" + ], + "button_color": [ + "#325882", + "#14375e" + ], + "button_hover_color": [ + "#234567", + "#1e2c40" + ], + "text_color": [ + "#DCE4EE", + "#DCE4EE" + ], + "text_color_disabled": [ + "gray74", + "gray60" + ] + }, + "CTkComboBox": { + "corner_radius": 6, + "border_width": 2, + "fg_color": [ + "#F9F9FA", + "#343638" + ], + "border_color": [ + "#979DA2", + "#565B5E" + ], + "button_color": [ + "#979DA2", + "#565B5E" + ], + "button_hover_color": [ + "#6E7174", + "#7A848D" + ], + "text_color": [ + "gray14", + "gray84" + ], + "text_color_disabled": [ + "gray50", + "gray45" + ] + }, + "CTkScrollbar": { + "corner_radius": 1000, + "border_spacing": 4, + "fg_color": "transparent", + "button_color": [ + "gray55", + "gray41" + ], + "button_hover_color": [ + "gray40", + "gray53" + ] + }, + "CTkSegmentedButton": { + "corner_radius": 6, + "border_width": 2, + "fg_color": [ + "#979DA2", + "gray29" + ], + "selected_color": [ + "#3a7ebf", + "#1f538d" + ], + "selected_hover_color": [ + "#325882", + "#14375e" + ], + "unselected_color": [ + "#979DA2", + "gray29" + ], + "unselected_hover_color": [ + "gray70", + "gray41" + ], + "text_color": [ + "#DCE4EE", + "#DCE4EE" + ], + "text_color_disabled": [ + "gray74", + "gray60" + ] + }, + "CTkTextbox": { + "corner_radius": 6, + "border_width": 0, + "fg_color": [ + "gray100", + "gray20" + ], + "border_color": [ + "#979DA2", + "#565B5E" + ], + "text_color": [ + "gray14", + "gray84" + ], + "scrollbar_button_color": [ + "gray55", + "gray41" + ], + "scrollbar_button_hover_color": [ + "gray40", + "gray53" + ] + }, + "CTkScrollableFrame": { + "label_fg_color": [ + "gray80", + "gray21" + ] + }, + "DropdownMenu": { + "fg_color": [ + "gray90", + "gray20" + ], + "hover_color": [ + "gray75", + "gray28" + ], + "text_color": [ + "gray14", + "gray84" + ] + }, + "CTkFont": { + "macOS": { + "family": "SF Display", + "size": 18, + "weight": "normal" + }, + "Windows": { + "family": "Roboto", + "size": 18, + "weight": "normal" + }, + "Linux": { + "family": "Roboto", + "size": 18, + "weight": "normal" + } + } +} \ No newline at end of file diff --git a/Deep_ACSA/gui_helpers/icon.ico b/DeepACSA/gui_helpers/icon.ico similarity index 100% rename from Deep_ACSA/gui_helpers/icon.ico rename to DeepACSA/gui_helpers/icon.ico diff --git a/DeepACSA/gui_helpers/image_processing.py b/DeepACSA/gui_helpers/image_processing.py new file mode 100644 index 0000000..0d13375 --- /dev/null +++ b/DeepACSA/gui_helpers/image_processing.py @@ -0,0 +1,1094 @@ +""" +Description +----------- +Ultrasound image preprocessing and area measurement pipeline. + +This module provides functions for preprocessing muscle ultrasound images, +detecting anatomical contours, and estimating cross-sectional area. +The processing workflow includes contrast enhancement, denoising, +edge detection, contour filtering, and mask-based area computation. + +Both automatic and interactive components are supported. Users may +manually remove unwanted structures, select starting points for +outline detection, and process entire directories of images. +Measurement results can be exported to Excel files. + +The functions in this module form the core image analysis pipeline +used for muscle cross-sectional area estimation. +""" + +import os +import time + +import cv2 +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from scipy.ndimage import gaussian_filter, median_filter +from skimage.feature import canny, hessian_matrix, hessian_matrix_eigvals +from skimage.filters import threshold_otsu + + +def preprocess_image( + image, + muscle_type, + flip_horizontal, + flip_vertical, + tubeness_sigma, + gaussian_sigma, + min_length_fac=0.1, +): + """ + Preprocess an ultrasound image and return a binary region mask. + + The preprocessing pipeline includes optional image flipping, + contrast enhancement (CLAHE), denoising, smoothing, edge detection, + and contour filtering. Small contours are removed based on a + minimum length threshold relative to image width. The output is a + binary mask highlighting the retained regions. + + Parameters + ---------- + image : np.ndarray + Input ultrasound image in BGR or grayscale format. + muscle_type : str + Name of the muscle being analyzed. Currently not used to alter + preprocessing logic but retained for API compatibility. + flip_horizontal : bool + If True, flip the image horizontally before processing. + flip_vertical : bool + If True, flip the image vertically before processing. + tubeness_sigma : float + Standard deviation used for Gaussian-based tubeness smoothing. + If 0 or less, this step is skipped. + gaussian_sigma : float + Standard deviation for Gaussian blur applied prior to tubeness + filtering. If 0 or less, this step is skipped. + min_length_fac : float, optional + Minimum contour length threshold expressed as a fraction of + image width. Contours with arc length below this threshold + are removed. Default is 0.1. + + Returns + ------- + np.ndarray + Binary mask (uint8) where retained regions are 255 and + background pixels are 0. + + Notes + ----- + The function assumes the input image is a valid OpenCV-readable image. + The returned mask can be used for subsequent contour detection or + area measurement steps. + """ + + start = time.time() + + # Flip the image if necessary + if flip_horizontal: + image = cv2.flip(image, 1) + if flip_vertical: + image = cv2.flip(image, 0) + + # Convert to grayscale + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # Apply CLAHE for contrast enhancement + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + equalized = clahe.apply(gray) + + # Apply Non-Local Means Denoising + denoised = cv2.fastNlMeansDenoising( + equalized, None, h=10, templateWindowSize=7, searchWindowSize=21 + ) + + # Apply Bilateral Filtering for edge-preserving smoothing + bilateral = cv2.bilateralFilter(denoised, d=9, sigmaColor=75, sigmaSpace=75) + + # Apply Gaussian blur using scipy (small sigma) + if gaussian_sigma > 0: + blurred = gaussian_filter( + bilateral, sigma=gaussian_sigma + ) # Adjust sigma as needed + + # Tubeness filtering using OpenCV (using GaussianBlur for demonstration) + if tubeness_sigma > 0: + tubeness = cv2.GaussianBlur( + blurred, (0, 0), tubeness_sigma + ) # Adjust sigma as needed + + # Apply Canny edge detection using skimage with adjusted thresholds + edges = canny(tubeness, sigma=1) # Adjust sigma as needed for edge detection + + # Convert edges from boolean to uint8 for OpenCV compatibility + edges_uint8 = (edges * 255).astype(np.uint8) + + # Detect contours in the edge-detected image + contours, _ = cv2.findContours( + edges_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + # Filter out small objects based on min_length_fac + image_width = image.shape[1] + min_length_threshold = min_length_fac * image_width + filtered_contours = [ + cnt for cnt in contours if cv2.arcLength(cnt, True) > min_length_threshold + ] + + print(f"Number of contours after filtering: {len(filtered_contours)}") + + # Create a mask for the filtered contours and apply it + mask = np.zeros_like(edges_uint8) + cv2.drawContours(mask, filtered_contours, -1, 255, thickness=cv2.FILLED) + + print(f"Time taken for preprocessing: {time.time() - start} seconds") + + return mask + + +import cv2 +import numpy as np + +def remove_border_scale_bar(gray_u8: np.ndarray, *, thr=240, min_area_frac=0.0005) -> np.ndarray: + """ + Remove bright scale bars or overlays touching image borders. + + The function detects very bright connected components in a grayscale + image, identifies those that touch the image borders, and removes them + by setting the corresponding pixels to zero. This is primarily intended + to eliminate scale bars or overlay artifacts commonly present near + image edges. + + Parameters + ---------- + gray_u8 : np.ndarray + Input 2D grayscale image of dtype ``uint8``. + thr : int, optional + Intensity threshold used to classify pixels as "bright". + Pixels with values greater than or equal to this threshold + are considered candidate overlay regions. Default is 240. + min_area_frac : float, optional + Minimum area of detected bright regions expressed as a fraction + of total image area. Regions smaller than this threshold are + ignored. Default is 0.0005. + + Returns + ------- + np.ndarray + Grayscale image with detected border-touching bright regions + removed (set to 0). + + Raises + ------ + ValueError + If the input image is not a 2D array. + + Notes + ----- + The function performs morphological closing to connect fragmented + bright regions before contour detection. Only contours exceeding + the specified minimum area and touching the image border are removed. + """ + if gray_u8.ndim != 2: + raise ValueError("remove_border_scale_bar expects 2D grayscale uint8") + if gray_u8.dtype != np.uint8: + gray_u8 = gray_u8.astype(np.uint8) + + h, w = gray_u8.shape[:2] + min_area = int(min_area_frac * h * w) + + # detect very bright pixels + bin_img = (gray_u8 >= thr).astype(np.uint8) * 255 + + # connect fragments + k = max(3, (min(h, w) // 300) * 2 + 1) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)) + bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_CLOSE, kernel, iterations=2) + + # contours (OpenCV 3/4 compatible) + res = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours = res[0] if len(res) == 2 else res[1] + + # build mask of border-touching bright rectangles + remove = np.zeros((h, w), dtype=np.uint8) + + def touches_border(x, y, ww, hh, pad=2): + return (x <= pad) or (y <= pad) or (x + ww >= w - pad) or (y + hh >= h - pad) + + for c in contours: + area = cv2.contourArea(c) + if area < min_area: + continue + x, y, ww, hh = cv2.boundingRect(c) + if touches_border(x, y, ww, hh): + cv2.rectangle(remove, (x, y), (x + ww, y + hh), 255, thickness=-1) + + # apply: set removed pixels to 0 (or median background if you prefer) + out = gray_u8.copy() + out[remove > 0] = 0 + return out + +def detect_candidate_contours(bin_img: np.ndarray, *, min_area_px=300) -> list[np.ndarray]: + """ + Detect and rank external contours by area. + + Parameters + ---------- + bin_img : np.ndarray + 2D binary image (uint8) where foreground pixels are 255 + and background pixels are 0. + min_area_px : int, optional + Minimum contour area in pixels. Contours with an area + smaller than this threshold are discarded. Default is 300. + + Returns + ------- + list of np.ndarray + List of external contours sorted in descending order + by contour area. + + Raises + ------ + ValueError + If the input image is not 2D. + + Notes + ----- + Contours are extracted using ``cv2.findContours`` with + ``RETR_EXTERNAL`` and ``CHAIN_APPROX_SIMPLE``. + """ + if bin_img.ndim != 2: + raise ValueError("detect_candidate_contours expects 2D binary") + res = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + contours = res[0] if len(res) == 2 else res[1] + contours = [c for c in contours if cv2.contourArea(c) >= float(min_area_px)] + contours.sort(key=cv2.contourArea, reverse=True) + return contours + + +def _find_contours_compat(bin_img: np.ndarray): + """ + OpenCV-compatible contour extraction. + + Parameters + ---------- + bin_img : np.ndarray + 2D binary image (uint8). + + Returns + ------- + contours : list of np.ndarray + Detected external contours. + hierarchy : np.ndarray + Contour hierarchy information returned by OpenCV. + + Raises + ------ + RuntimeError + If ``cv2.findContours`` returns an unexpected number of values. + + Notes + ----- + Handles differences between OpenCV 3 and OpenCV 4 return + signatures of ``cv2.findContours``. + """ + res = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if len(res) == 2: + contours, hier = res + elif len(res) == 3: + _, contours, hier = res + else: + raise RuntimeError(f"Unexpected findContours return length: {len(res)}") + return contours, hier + +import cv2 +import numpy as np + + +def contours_external(bin_img: np.ndarray): + """ + Extract external contours from a binary image. + + Parameters + ---------- + bin_img : np.ndarray + 2D binary image (uint8) from which contours should be + extracted. + + Returns + ------- + contours : list of np.ndarray + List of detected external contours. + hierarchy : np.ndarray + Contour hierarchy information returned by OpenCV. + + Notes + ----- + Uses ``cv2.findContours`` with ``RETR_EXTERNAL`` and + ``CHAIN_APPROX_SIMPLE``. Handles differences in return + format between OpenCV versions. + """ + res = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if len(res) == 2: + contours, hier = res + else: + _, contours, hier = res + return contours, hier + + +def largest_contour(bin_img: np.ndarray, *, min_area_px: int = 50): + """ + Return the largest external contour above a minimum area threshold. + + Parameters + ---------- + bin_img : np.ndarray + 2D binary image (uint8) from which contours are extracted. + min_area_px : int, optional + Minimum contour area in pixels. Contours smaller than this + threshold are ignored. Default is 50. + + Returns + ------- + np.ndarray or None + The largest contour satisfying the area constraint, + or ``None`` if no valid contour is found. + + Raises + ------ + ValueError + If the input image is not 2D. + + Notes + ----- + The image is converted to ``uint8`` if necessary before + contour extraction. + """ + if bin_img.ndim != 2: + raise ValueError("largest_contour expects a 2D binary image") + if bin_img.dtype != np.uint8: + bin_img = bin_img.astype(np.uint8) + + contours, _ = contours_external(bin_img) + if not contours: + return None + + cnt = max(contours, key=cv2.contourArea) + if cv2.contourArea(cnt) < float(min_area_px): + return None + return cnt + + +def fill_contour_mask(shape_hw: tuple[int, int], contour: np.ndarray) -> np.ndarray: + """ + Create a filled binary mask from a contour. + + Parameters + ---------- + shape_hw : tuple of int + Tuple ``(height, width)`` specifying the mask dimensions. + contour : np.ndarray + Contour points defining the region to fill. + + Returns + ------- + np.ndarray + Binary mask (uint8) with the contour region filled + (255 inside, 0 outside). + + Notes + ----- + The contour is cast to ``int32`` before filling to ensure + OpenCV compatibility. + """ + h, w = shape_hw + m = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(m, [contour.astype(np.int32)], 255) + return m + + +def area_cm2_from_mask(mask_u8: np.ndarray, *, px_per_cm: float | None = None) -> float: + """ + Compute area in square centimeters from a filled mask. + + Parameters + ---------- + mask_u8 : np.ndarray + 2D binary mask (uint8) where foreground pixels are 255. + px_per_cm : float or None, optional + Pixel-to-centimeter scaling factor (pixels per centimeter). + If provided and positive, area is converted using this value. + If ``None`` or non-positive, pixel units are treated as + centimeters (no scaling applied). + + Returns + ------- + float + Estimated area in square centimeters. + + Raises + ------ + ValueError + If the input mask is not 2D. + + Notes + ----- + Area is computed as the number of non-zero pixels multiplied by + ``(cm_per_px)^2``. If no scaling factor is provided, the function + assumes one pixel equals one centimeter. + """ + if mask_u8.ndim != 2: + raise ValueError("area_cm2_from_mask expects 2D mask") + area_px = int(np.count_nonzero(mask_u8)) + if area_px <= 0: + return 0.0 + + h = int(mask_u8.shape[0]) + + if px_per_cm is not None and px_per_cm > 0: + cm_per_px = 1.0 / float(px_per_cm) + else: + # None + cm_per_px = 1 + + return float(area_px) * (cm_per_px ** 2) + + +def detect_contour_from_preprocessed_mask(pre_mask_u8: np.ndarray) -> tuple[np.ndarray | None, np.ndarray]: + """ + Extract the largest contour from a preprocessed mask. + + The function converts the input mask to grayscale (if necessary), + ensures a binary representation using Otsu thresholding, performs + morphological cleanup, and returns the largest external contour. + + Parameters + ---------- + pre_mask_u8 : np.ndarray + Preprocessed mask image (typically the output of + ``preprocess_image``). Can be grayscale or BGR. + + Returns + ------- + tuple of (np.ndarray or None, np.ndarray) + - Largest contour satisfying the area threshold, or ``None`` if + no valid contour is found. + - Cleaned binary mask used for contour detection. + + Notes + ----- + Morphological closing is applied to reduce small gaps before + contour extraction. The minimum area threshold for contour + selection is fixed internally. + """ + if pre_mask_u8.ndim == 3: + pre_mask_u8 = cv2.cvtColor(pre_mask_u8, cv2.COLOR_BGR2GRAY) + if pre_mask_u8.dtype != np.uint8: + pre_mask_u8 = pre_mask_u8.astype(np.uint8) + + # Ensure binary + _, bin_img = cv2.threshold(pre_mask_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # Cleanup (small gaps) + k = max(3, (min(bin_img.shape[:2]) // 200) * 2 + 1) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_CLOSE, kernel, iterations=2) + + cnt = largest_contour(bin_img, min_area_px=200) + return cnt, bin_img + +def measure_area(image, starting_points=None, scan_depth=5, num_rays=360, *, return_mask=False): + """ + Estimate cross-sectional area from a segmented image. + + The function converts the input image to grayscale, applies Otsu + thresholding and morphological cleanup to obtain a binary mask, + selects the largest external contour, fills it, and computes the + area in pixel units. The result is scaled using ``scan_depth``. + + Parameters + ---------- + image : np.ndarray + Input image (grayscale or BGR) representing a segmented or + preprocessed muscle region. + starting_points : optional + Present for API compatibility. Not used in the current + implementation. + scan_depth : float or int, optional + Scaling factor used to convert pixel area. The value is + rounded to an integer and used as ``area_px / scan_depth**2``. + num_rays : int, optional + Retained for API compatibility. Not used in the current + implementation. + return_mask : bool, optional + If True, returns a tuple ``(area_cm2, filled_mask, contour)``. + Otherwise, returns only the computed area. + + Returns + ------- + float or tuple + If ``return_mask`` is False: + Estimated area value. + If ``return_mask`` is True: + Tuple containing: + - Area value (float) + - Filled binary mask (np.ndarray) + - Selected contour (np.ndarray) + + Notes + ----- + The scaling behavior follows the original implementation, + where area is computed as:: + + area_px / scan_depth**2 + + This assumes a specific pixel-to-centimeter relationship and + does not use physical calibration directly. + """ + scan_depth_px = int(round(float(scan_depth))) + if scan_depth_px <= 0: + return (0.0, None, None) if return_mask else 0.0 + + # ---- ensure grayscale uint8 ---- + if image is None: + return (0.0, None, None) if return_mask else 0.0 + + if image.ndim == 3: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + else: + gray = image + + if gray.dtype != np.uint8: + gmin, gmax = float(np.min(gray)), float(np.max(gray)) + if gmax > gmin: + gray = ((gray - gmin) * (255.0 / (gmax - gmin))).astype(np.uint8) + else: + gray = np.zeros_like(gray, dtype=np.uint8) + + # ---- binarize (Otsu) ---- + # Otsu chooses foreground vs background automatically. + _, bin_img = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # If Otsu picked the wrong polarity (common), flip so "muscle region" is white + # Heuristic: prefer fewer white pixels than black pixels? actually muscle often occupies mid/large. + white = int(np.count_nonzero(bin_img)) + total = int(bin_img.size) + if white < total * 0.05 or white > total * 0.95: + bin_img = cv2.bitwise_not(bin_img) + + # ---- cleanup ---- + k = max(3, (min(gray.shape[:2]) // 200) * 2 + 1) # odd kernel, scale with image size + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) + bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_OPEN, kernel, iterations=1) + bin_img = cv2.morphologyEx(bin_img, cv2.MORPH_CLOSE, kernel, iterations=2) + + # ---- contours ---- + contours, _ = _find_contours_compat(bin_img) + if not contours: + return (0.0, bin_img if return_mask else None, None) if return_mask else 0.0 + + # pick largest contour by area + contour = max(contours, key=cv2.contourArea) + if cv2.contourArea(contour) <= 1: + return (0.0, bin_img if return_mask else None, None) if return_mask else 0.0 + + # ---- fill contour ---- + filled = np.zeros_like(gray, dtype=np.uint8) + cv2.fillPoly(filled, [contour.astype(np.int32)], 255) + + area_px = int(np.count_nonzero(filled)) + cm_area = area_px / float(scan_depth_px ** 2) + + if return_mask: + return float(cm_area), filled, contour + return float(cm_area) + +def _ensure_binary_u8(img: np.ndarray) -> np.ndarray: + """ + Convert an image to a binary uint8 representation. + + Parameters + ---------- + img : np.ndarray + Input image (grayscale, BGR, or numeric array). + + Returns + ------- + np.ndarray + Single-channel binary image (uint8) obtained using + Otsu thresholding. + + Raises + ------ + ValueError + If the input image is None. + + Notes + ----- + The function converts multi-channel images to grayscale, + normalizes non-uint8 arrays to uint8, and applies Otsu + thresholding to produce a contour-ready binary image. + """ + if img is None: + raise ValueError("find_starting_points: image is None") + + if img.ndim == 3: + # treat as BGR + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + if img.dtype != np.uint8: + # robust conversion to uint8 + img_min = float(np.min(img)) + img_max = float(np.max(img)) + if img_max > img_min: + img = ((img - img_min) * (255.0 / (img_max - img_min))).astype(np.uint8) + else: + img = np.zeros_like(img, dtype=np.uint8) + + # ensure we have a binary image for contours + # (works even if already binary) + _, bin_img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + return bin_img + + +def _find_contours_compat(bin_img: np.ndarray): + """ + Extract contours with OpenCV version compatibility. + + Parameters + ---------- + bin_img : np.ndarray + 2D binary image (uint8). + + Returns + ------- + contours : list of np.ndarray + Detected external contours. + hierarchy : np.ndarray + Contour hierarchy information returned by OpenCV. + + Raises + ------ + RuntimeError + If ``cv2.findContours`` returns an unexpected number of values. + + Notes + ----- + Handles differences between OpenCV 3 and OpenCV 4 return + signatures of ``cv2.findContours``. + """ + res = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if len(res) == 2: + contours, hierarchy = res + elif len(res) == 3: + _, contours, hierarchy = res + else: + raise RuntimeError(f"Unexpected findContours return length: {len(res)}") + return contours, hierarchy + + +def find_starting_points(image: np.ndarray, method: str = "Automatic"): + """ + Find starting points for outline detection. + + Depending on the selected method, starting points are determined from + the centroids of detected contours, a fixed pixel location, or manual + mouse clicks in an OpenCV window. An RGB overlay image is returned with + the selected points drawn for visualization. + + Parameters + ---------- + image : np.ndarray + Input image (grayscale or BGR). The image is used for contour + detection and for generating an overlay for display. + method : {'Automatic', 'Fixed Pixels', 'Manual'}, optional + Strategy used to generate starting points: + + - ``'Automatic'``: compute contour centroids from a binary mask. + - ``'Fixed Pixels'``: use the image center as a single point. + - ``'Manual'``: collect points via mouse clicks in an OpenCV window. + + Returns + ------- + starting_points : list of tuple[int, int] + List of ``(x, y)`` starting point coordinates in pixel units. + overlay : np.ndarray + RGB overlay image with the selected points drawn. The input + ``image`` is not modified. + + Raises + ------ + ValueError + If `method` is not one of the supported options. + + Notes + ----- + When ``method='Manual'``, this function opens an OpenCV GUI window and + blocks until a key is pressed. This may not work in headless + environments. + """ + starting_points: list[tuple[int, int]] = [] + + # For contour detection: use a single-channel binary uint8 image + bin_img = _ensure_binary_u8(image) + contours, _ = _find_contours_compat(bin_img) + + # Create an RGB overlay for drawing (never draw on analysis input) + if image.ndim == 2: + overlay = cv2.cvtColor(image.astype(np.uint8, copy=False), cv2.COLOR_GRAY2RGB) + else: + # assume BGR if 3ch + overlay = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + if method == "Automatic": + for contour in contours: + M = cv2.moments(contour) + if M.get("m00", 0) != 0: + cX = int(M["m10"] / M["m00"]) + cY = int(M["m01"] / M["m00"]) + starting_points.append((cX, cY)) + cv2.circle(overlay, (cX, cY), 5, (255, 0, 255), -1) + + elif method == "Fixed Pixels": + # Example fallback: center point (adjust if you have a defined rule) + h, w = bin_img.shape[:2] + cX, cY = w // 2, h // 2 + starting_points.append((cX, cY)) + cv2.circle(overlay, (cX, cY), 5, (255, 0, 255), -1) + + elif method == "Manual": + # NOTE: OpenCV GUI; if you want full CustomTkinter integration, don’t use this. + def click_event(event, x, y, flags, params): + if event == cv2.EVENT_LBUTTONDOWN: + starting_points.append((x, y)) + cv2.circle(overlay_bgr, (x, y), 5, (255, 0, 255), -1) + cv2.imshow("Select Starting Points", overlay_bgr) + + print("Click to select starting points. Press ESC when done.") + overlay_bgr = cv2.cvtColor(overlay, cv2.COLOR_RGB2BGR) + cv2.imshow("Select Starting Points", overlay_bgr) + cv2.setMouseCallback("Select Starting Points", click_event) + cv2.waitKey(0) + cv2.destroyAllWindows() + + else: + raise ValueError(f"Unknown method: {method}") + + return starting_points, overlay + + +def draw_outline(image, contours): + """ + Draw contours on an image. + + Parameters + ---------- + image : np.ndarray + Image on which to draw. This array is modified in place. + contours : list of np.ndarray + Contours to draw (as returned by ``cv2.findContours``). + + Returns + ------- + np.ndarray + The same image array with contours drawn. + + Notes + ----- + This function uses ``cv2.drawContours`` and therefore draws directly + into the provided `image` array. + """ + cv2.drawContours(image, contours, -1, (255, 0, 0), 2) + return image + + +def flip_image(image, axis): + """ + Flip an image along a specified axis. + + Parameters + ---------- + image : np.ndarray + Input image to flip. + axis : int + Flip code passed to ``cv2.flip``: + + - ``0``: vertical flip (top/bottom) + - ``1``: horizontal flip (left/right) + - ``-1``: both axes + + Returns + ------- + np.ndarray + Flipped image. + """ + return cv2.flip(image, axis) + + +def clear_display(): + """ + Clear interactive displays (placeholder). + + This function currently acts as a placeholder and only prints a message. + It is intended to mimic clearing UI elements such as ROI managers or + display windows. + """ + print("Clearing display...") + + +def remove_structures(image): + """ + Interactively remove unwanted structures by masking user-drawn circles. + + An OpenCV window is opened where the user can draw circles over regions + to remove. The selected regions are excluded from the image by applying + a binary mask. The last circle can be removed with the Delete key, and + the interaction ends when the user presses ESC. + + Parameters + ---------- + image : np.ndarray + Input image from which structures should be removed. This function + does not modify `image` in place. + + Returns + ------- + np.ndarray + Image with the user-selected circular regions removed (masked out). + + Notes + ----- + This function requires an interactive OpenCV GUI environment. It will + block execution until the user exits the window (ESC). + """ + + # Create a copy of the image for drawing and a mask for removal + output_image = image.copy() + temp_image = image.copy() + mask = np.ones_like(image, dtype=np.uint8) * 255 + + circles = [] + + def draw_circle(event, x, y, flags, param): + nonlocal temp_image, circles + + if event == cv2.EVENT_LBUTTONDOWN: + # Start a new circle with radius 0 + circles.append((x, y, 0)) + + elif event == cv2.EVENT_MOUSEMOVE: + if len(circles) > 0 and flags == cv2.EVENT_FLAG_LBUTTON: + # Update the radius of the last circle + cx, cy, _ = circles[-1] + radius = int(np.sqrt((x - cx) ** 2 + (y - cy) ** 2)) + circles[-1] = (cx, cy, radius) + + # Redraw the image with the new circle + temp_image = output_image.copy() + cv2.circle( + temp_image, (cx, cy), radius, (255, 0, 255), 2 + ) # Violet circle + cv2.imshow("Draw Circles", temp_image) + + elif event == cv2.EVENT_LBUTTONUP: + # Finalize the last circle by drawing it on the output image + if len(circles) > 0: + cx, cy, radius = circles[-1] + cv2.circle( + output_image, (cx, cy), radius, (255, 0, 255), 2 + ) # Violet circle + cv2.circle( + mask, (cx, cy), radius, (0, 0, 0), thickness=-1 + ) # Draw filled circle on mask + temp_image = ( + output_image.copy() + ) # Update the temporary image for further drawing + cv2.imshow("Draw Circles", output_image) + + def remove_last_circle(): + if circles: + circles.pop() # Remove the last circle + redraw_circles() + + def redraw_circles(): + nonlocal temp_image, mask, output_image + output_image = image.copy() + mask = np.ones_like(image, dtype=np.uint8) * 255 + for cx, cy, radius in circles: + cv2.circle( + output_image, (cx, cy), radius, (255, 0, 255), 2 + ) # Violet circle + cv2.circle(mask, (cx, cy), radius, (0, 0, 0), thickness=-1) # Update mask + temp_image = output_image.copy() + cv2.imshow("Draw Circles", output_image) + + # Display the image and set up the mouse callback + cv2.imshow("Draw Circles", output_image) + cv2.setMouseCallback("Draw Circles", draw_circle) + print( + "Draw violet circles around structures to remove them. Press Delete to remove the last circle. Press ESC when done." + ) + + # Wait until ESC is pressed + while True: + key = cv2.waitKey(1) & 0xFF + if key == 27: # ESC key + break + elif key == 8: # Delete key + remove_last_circle() + + cv2.destroyAllWindows() + + # Apply the mask to remove the selected structures + cleaned_image = cv2.bitwise_and(image, mask) + + return cleaned_image + + +def excel_expo(results, output_path): + """ + Export results to an Excel file. + + Parameters + ---------- + results : dict or pandas.DataFrame + Results to export. If a dict is provided, it must be compatible with + ``pandas.DataFrame(results)`` (e.g., keys as column names and values + as equal-length lists). + output_path : str or os.PathLike + File path where the Excel file will be written. + + Returns + ------- + None + + Notes + ----- + The file is written using ``pandas.DataFrame.to_excel`` with + ``index=False``. + """ + df = pd.DataFrame(results) + df.to_excel(output_path, index=False) + print(f"Results exported to {output_path}") + + +def process_images( + input_dir, + output_dir, + settings, + muscle_type, + flip_horizontal, + flip_vertical, + outline_strategy, + scan_depth, +): + """ + Process a directory of images and compute cross-sectional area estimates. + + Images with extensions ``.jpg``, ``.png``, and ``.tif`` are loaded from + `input_dir`, preprocessed into a mask, optionally cleaned via an + interactive removal step, and analyzed to estimate area. Intermediate + results are displayed using OpenCV windows. The resulting mask images are + written to `output_dir`, and a summary dictionary of filenames and area + values is returned. + + Parameters + ---------- + input_dir : str or os.PathLike + Directory containing input images. + output_dir : str or os.PathLike + Directory where processed output images will be saved. + settings : dict + Preprocessing settings. Must contain keys ``'tubeness_sigma'`` and + ``'gaussian_sigma'``. + muscle_type : str + Muscle type label passed through to preprocessing. + flip_horizontal : bool + If True, flip images horizontally before preprocessing. + flip_vertical : bool + If True, flip images vertically before preprocessing. + outline_strategy : {'Automatic', 'Fixed Pixels', 'Manual'} + Strategy passed to ``find_starting_points``. + scan_depth : float or int + Scan depth passed to ``measure_area`` for scaling. + + Returns + ------- + dict + Dictionary with keys ``'Filename'`` and ``'Area (cm²)'`` containing + per-image results. + + Notes + ----- + This function uses multiple OpenCV GUI windows and may block execution + awaiting user input. It also calls ``remove_structures``, which is + interactive and requires a display environment. + """ + file_list = [ + f for f in os.listdir(input_dir) if f.endswith((".jpg", ".png", ".tif")) + ] + results = {"Filename": [], "Area (cm²)": []} + + for file_name in file_list: + image_path = os.path.join(input_dir, file_name) + image = cv2.imread(image_path) + + cv2.imshow("Processed Image", image) + cv2.waitKey(0) + cv2.destroyAllWindows() + + if image is None: + print(f"Failed to load image: {image_path}") + continue + + preprocessed_image = preprocess_image( + image, + muscle_type, + flip_horizontal, + flip_vertical, + settings["tubeness_sigma"], + settings["gaussian_sigma"], + ) + # preprocessed_image = preprocess_image2(image=image) + + if preprocessed_image is None or preprocessed_image.size == 0: + print(f"Preprocessing failed for image: {image_path}") + continue + + # Print debug information + print(f"Processing image: {file_name}") + + # Final check before saving + if preprocessed_image is None or preprocessed_image.size == 0: + print( + f"Image processing resulted in an empty or invalid image: {file_name}" + ) + continue + cv2.imshow("mask", preprocessed_image) + cv2.waitKey(0) + cv2.destroyAllWindows() + + cleaned_image = remove_structures(preprocessed_image) + + starting_points, preprocessed_image = find_starting_points( + cleaned_image, method=outline_strategy + ) + + # Display the processed image with contours + cv2.imshow("Processed Image - Final", cleaned_image) + cv2.waitKey(0) + cv2.destroyAllWindows() + # draw_outline(image, starting_points) + area = measure_area(preprocessed_image, starting_points) + + results["Filename"].append(file_name) + results["Area (cm²)"].append(area) + + output_image_path = os.path.join(output_dir, file_name) + try: + cv2.imwrite(output_image_path, preprocessed_image) + print(f"Saved processed image to: {output_image_path}") + except Exception as e: + print(f"Failed to save image {file_name}: {e}") + + return results diff --git a/Deep_ACSA/gui_helpers/model_training.py b/DeepACSA/gui_helpers/model_training.py similarity index 92% rename from Deep_ACSA/gui_helpers/model_training.py rename to DeepACSA/gui_helpers/model_training.py index 1a0c1f8..28a36b5 100644 --- a/Deep_ACSA/gui_helpers/model_training.py +++ b/DeepACSA/gui_helpers/model_training.py @@ -1,45 +1,22 @@ """ Description ----------- -This module contains functions to train a VGG16 encoder U-net decoder CNN. -The module was specifically designed to be executed from a GUI. -When used from the GUI, the module saves the trained model and weights to -a given directory. The user needs to provide paths to the image and label/ -mask directories. Instructions for correct image labelling can be found -in the Labelling directory. - - -Functions scope ---------------- -conv_block - Function to build a convolutional block for the U-net decoder path of the network. - The block is built using several keras.layers functionalities. -decoder_block - Function to build a decoder block for the U-net decoder path of the network. - The block is built using several keras.layers functionalities. -build_vgg16_model - Function that builds a convolutional network consisting of an VGG16 encoder path - and a U-net decoder path. -IoU - Function to compute the intersection over union score (IoU), - a measure of prediction accuracy. This is sometimes also called Jaccard score. -dice_score - Function to compute the Dice score, a measure of prediction accuracy. -focal_loss - Function to compute the focal loss, a measure of prediction accuracy. -load_images - Function to load images and manually labeled masks from a specified - directory. -train_model - Function to train a convolutional neural network with VGG16 encoder and - U-net decoder. All the steps necessary to properly train a neural - network are included in this function. +Model training utilities for VGG16 U-Net segmentation. -Notes ------ -Additional information and usage examples can be found at the respective -functions documentations. +This module provides functions to train a convolutional neural +network using a VGG16 encoder and U-Net decoder architecture. +It is primarily designed to be executed through the graphical +user interface (GUI). + +When used via the GUI, the trained model and corresponding +weights are saved to a user-specified directory. The user must +provide paths to the training image directory and the +corresponding label (mask) directory. + +Instructions for correct image labeling can be found in the +labelling directory of the project. """ + import os import tkinter as tk @@ -60,6 +37,7 @@ from keras.optimizers import Adam from skimage.transform import resize from sklearn.model_selection import train_test_split +import tensorflow as tf from tensorflow.keras.applications import VGG16 # from keras.preprocessing.image import img_to_array, load_img @@ -116,7 +94,7 @@ def conv_block(inputs, num_filters: int): x = BatchNormalization()(x) x = Activation("relu")(x) # Define second Conv2D layer witch Batchnor and Activation relu - x = Conv2D(filters=num_filters, kernel_size=3, padding="same")(inputs) + x = Conv2D(filters=num_filters, kernel_size=3, padding="same")(x) x = BatchNormalization()(x) x = Activation("relu")(x) @@ -293,14 +271,24 @@ def IoU(y_true, y_pred, smooth: int = 1) -> float: smooth=1) Tensor("truediv:0", shape=(1, 512, 512), dtype=float32) """ - # Caclulate Intersection - intersection = K.sum(K.abs(y_true * y_pred), axis=-1) - # Calculate Union - union = K.sum(y_true, -1) + K.sum(y_pred, -1) - intersection - # Calculate IoU - iou = (intersection + smooth) / (union + smooth) + dtype = tf.float32 + y_pred = tf.cast(y_pred, dtype) + y_true = tf.cast(y_true, y_pred.dtype) + + y_pred = tf.squeeze(y_pred) + y_true = tf.squeeze(y_true) + + y_true_pos = tf.reshape(y_true, [-1]) + y_pred_pos = tf.reshape(y_pred, [-1]) + + area_intersect = tf.reduce_sum(tf.multiply(y_true_pos, y_pred_pos)) - return iou + area_true = tf.reduce_sum(y_true_pos) + area_pred = tf.reduce_sum(y_pred_pos) + area_union = area_true + area_pred - area_intersect + + # Return the IoU score + return tf.math.divide_no_nan(area_intersect, area_union) def dice_score(y_true, y_pred, smooth=1e-6) -> float: @@ -339,11 +327,36 @@ def dice_score(y_true, y_pred, smooth=1e-6) -> float: smooth=1) Tensor("dice_score/truediv:0", shape=(1, 512, 512), dtype=float32) """ - intersection = K.sum(K.abs(y_true * y_pred), axis=-1) - dice = (2 * intersection + smooth) / ( - K.sum(y_pred, -1) + K.sum(y_true, -1) + smooth + y_true_f = tf.reshape(y_true, [-1]) + y_pred_f = tf.reshape(y_pred, [-1]) + intersection = tf.reduce_sum(y_true_f * y_pred_f) + score = (2.0 * intersection + smooth) / ( + tf.reduce_sum(y_true_f) + tf.reduce_sum(y_pred_f) + smooth + ) + return score + + +def dice_bce_loss(y_true, y_pred, smooth=1): + + Dice_BCE = 0.5 * K.binary_crossentropy(y_true, y_pred) + 0.5 * dice_score( + y_true, y_pred ) - return 1 - dice + + return Dice_BCE + + +def deep_supervised_loss(y_true, y_preds): + # y_preds is a list of tensors from deep supervision outputs + if not isinstance(y_preds, (list, tuple)): + y_preds = [y_preds] + + # Broadcast y_true to each prediction and compute the loss + losses = [] + for y_pred in y_preds: + loss = dice_bce_loss(y_true, y_pred) + losses.append(loss) + + return tf.reduce_mean(losses) def focal_loss(y_true, y_pred, alpha: float = 0.8, gamma: float = 2) -> float: diff --git a/Deep_ACSA/gui_helpers/predict_muscle_area.py b/DeepACSA/gui_helpers/predict_muscle_area.py similarity index 52% rename from Deep_ACSA/gui_helpers/predict_muscle_area.py rename to DeepACSA/gui_helpers/predict_muscle_area.py index ddc4509..24b0b17 100644 --- a/Deep_ACSA/gui_helpers/predict_muscle_area.py +++ b/DeepACSA/gui_helpers/predict_muscle_area.py @@ -1,37 +1,18 @@ -"""Python module to automatically calcuate muscle area in US images - +""" Description ----------- -This Python module provides a collection of functions to automatically calculate muscle area in -ultrasound (US) images. It includes functions for importing and preprocessing images, predicting -aponeurosis areas, calculating echo intensity, and optionally estimating muscle volume based on the predicted areas. -The module also offers various calibration methods, including continuous scaling lines (EFOV images) -and scaling bars or manual calibration for regular ultrasound images. The results are saved in a -Pandas DataFrame and exported to an Excel file. The module is designed to streamline the analysis -of muscle area in large batches of ultrasound images for research and medical purposes. - -Functions scope ---------------- -get_list_of_files - Gets a list of all files in the directory that match the specified pattern. -import_image_efov - Imports and preprocesses an EFOV image, returning the filename, preprocessed image, - original image, height, and width. -import_image - Imports and preprocesses an image, returning the filename, preprocessed image, - original image, height, and width. -calc_area_efov - Calculates the predicted muscle area in the region of interest (ROI) of an EFOV ultrasound image. -calc_area_efov - Calculates the predicted muscle area in an ultrasound image using a known calibration distance. -compile_save_results - Saves analysis results to an Excel file. -calculate_batch_efov - Calculates area predictions for batches of EFOV US images containing a continuous scaling line, - including echo intensity and optional muscle volume calculations. -calculate_batch - Calculates area predictions for batches of (EFOV) US images not containing a continuous scaling line, - including echo intensity and optional muscle volume calculations. +Automatic muscle area analysis for ultrasound images. + +This module provides functions to import and preprocess ultrasound (US) +images, predict anatomical regions using a trained model, and compute +derived metrics such as muscle area and echo intensity. Optional muscle +volume estimation is supported using the predicted areas. + +Multiple calibration methods are available, including continuous scaling +lines for EFOV images as well as scaling bars, manual calibration, or no +scaling for standard ultrasound images. Results are collected in a +Pandas DataFrame and exported to an Excel file. The module is intended +to support batch analysis of ultrasound images. """ import glob @@ -48,14 +29,14 @@ from skimage.transform import resize from tensorflow.keras.utils import img_to_array -from Deep_ACSA.gui_helpers.apo_model import ApoModel -from Deep_ACSA.gui_helpers.calculate_muscle_volume import muscle_volume_calculation -from Deep_ACSA.gui_helpers.calibrate import ( +from DeepACSA.gui_helpers.apo_model import ApoModel +from DeepACSA.gui_helpers.calculate_muscle_volume import muscle_volume_calculation +from DeepACSA.gui_helpers.calibrate import ( calibrate_distance_efov, calibrate_distance_manually, calibrate_distance_static, ) -from Deep_ACSA.gui_helpers.echo_int import calculate_echo_int +from DeepACSA.gui_helpers.echo_int import calculate_echo_int # from keras.preprocessing.image import img_to_array, load_img @@ -65,60 +46,62 @@ def get_list_of_files(pathname: str): - """Get a list of all files in the directory. - + """Get a list of files matching a pathname pattern. Parameters ---------- pathname : str - The pathname pattern to match against file paths. - + Glob-style pathname pattern to match against file paths. Returns ------- - List : list - A list of file paths that match the specified pathname pattern. - - Example - ------- - >>> get_list_of_files(C:/Desktop/Test) - ["C:/Desktop/Test/Img1.tif", "C:/Desktop/Test/Img2.tif", - "C:/Desktop/Test/Flip.txt"] + list of str + File paths that match `pathname`. + Examples + -------- + >>> get_list_of_files("C:/Desktop/Test/*") + ['C:/Desktop/Test/Img1.tif', 'C:/Desktop/Test/Img2.tif', + 'C:/Desktop/Test/Flip.txt'] """ return glob.glob(pathname) def import_image_efov(path_to_image: str): - """Define the image to analyse, import and reshape the image. + """Import and preprocess an eFOV ultrasound image for model inference. + + The function reads an image, removes fixed margins, keeps an unmodified + copy of the cropped image, and creates a normalized model input tensor. Parameters ---------- path_to_image : str - The file path to the EFOV image. + Path to the eFOV ultrasound image. Returns ------- filename : str - The filename of the image without the extension. - img : np.ndarray - The preprocessed image as a NumPy array with shape (1, 256, 256, 3). - img_copy : np.ndarray - A copy of the original image as a NumPy array. + Image filename without extension. + img_copy : numpy.ndarray + Cropped image copy (after margin removal), shape (H, W, 3). + img : numpy.ndarray + Normalized model input tensor, shape (1, 256, 256, 3), dtype float32. height : int - The height of the original image. + Height of the cropped image (`img_copy.shape[0]`). width : int - The width of the original image. + Width of the cropped image (`img_copy.shape[1]`). Notes ----- - - The function reads the EFOV image using OpenCV (cv2) library. - - The function crops the image to remove unwanted margins (75 pixels from the top, 20 pixels from the left, - and 10 pixels from the right). - - The function then reshapes the image and resizes it to a fixed size of (256, 256). - - The final preprocessed image is normalized to have values in the range [0, 1]. + The image is read using OpenCV and cropped as follows: - Example - ------- - >>> import_image(C:/Desktop/Test/Img1.tif) - (Img1.tif, array[[[[...]]]], 864, 1152) + - 75 pixels removed from the top + - 20 pixels removed from the left + - 10 pixels removed from the right + + The cropped image is resized to 256x256, converted to float32, and + normalized to [0, 1]. + + Examples + -------- + >>> filename, img_copy, img, height, width = import_image_efov("C:/Desktop/Test/Img1.tif") """ image_add = path_to_image filename = os.path.splitext(os.path.basename(image_add))[0] @@ -127,81 +110,118 @@ def import_image_efov(path_to_image: str): img = img[75:rows, 20 : cols - 10] img_copy = img.copy() - img = img_to_array(img) height = img.shape[0] - weight = img.shape[1] - img = np.reshape(img, [-1, height, weight, 3]) - img = resize(img, (1, 256, 256, 3), mode="constant", preserve_range=True) - img = img / 255.0 + width = img.shape[1] + + img = cv2.resize(img, (256, 256), interpolation=cv2.INTER_AREA) + img = img.astype(np.float32) / 255.0 + img = np.expand_dims(img, axis=0) + + return filename, img_copy, img, height, width - return filename, img_copy, img, height, weight +def import_image(path_to_image: str, modelpath: str): + """Import and preprocess an ultrasound image for model inference. -def import_image(path_to_image: str): - """Define the image to analyse, import and reshape the image. + The image is loaded in color, preserved in its original orientation, and + resized/normalized to match the expected input size of the selected model. Parameters ---------- path_to_image : str - The file path to the image that should be analyzed. + Path to the ultrasound image. + modelpath : str + Path to the model file. The path string is used to infer the expected + input size (e.g., SwinUNet vs. other models). Returns ------- filename : str - The filename of the image without the extension. - img : np.ndarray - The preprocessed image as a NumPy array with shape (1, 256, 256, 3). - img_copy : np.ndarray - A copy of the original image as a NumPy array. - height : int - The height of the original image. - width : int - The width of the original image. + Image filename without extension. + img_resized : numpy.ndarray + Normalized model input tensor, shape (1, H, W, 3), dtype float32. + The spatial size depends on `modelpath`: + - (224, 224) if `"swinunet"` is found in `modelpath` (case-insensitive) + - (256, 256) otherwise + nonflipped_img : numpy.ndarray + Original loaded image (unmodified), shape (H0, W0, 3). + original_height : int + Height of the original image (`nonflipped_img.shape[0]`). + original_width : int + Width of the original image (`nonflipped_img.shape[1]`). + + Raises + ------ + FileNotFoundError + If OpenCV fails to load the image (i.e., `cv2.imread` returns None). - Example - ------- - >>> import_image(C:/Desktop/Test/Img1.tif) - (Img1.tif, array[[[[...]]]], - , - , - 864, 1152) + Notes + ----- + The resized image is converted to float32, normalized to [0, 1], and + expanded to include a batch dimension. + + Examples + -------- + >>> filename, x, img0, h, w = import_image("C:/Desktop/Test/Img1.tif", "C:/Desktop/Test/model.h5") """ - image_add = path_to_image - filename = os.path.splitext(os.path.basename(image_add))[0] - img = cv2.imread(path_to_image, 1) + filename = os.path.splitext(os.path.basename(path_to_image))[0] + img = cv2.imread(path_to_image, cv2.IMREAD_COLOR) + + if img is None: + raise FileNotFoundError(f"[ERROR] Failed to load image: {path_to_image}") + nonflipped_img = img.copy() + original_height, original_width = img.shape[:2] - img = img_to_array(img) - height = img.shape[0] - weight = img.shape[1] - img = np.reshape(img, [-1, height, weight, 3]) - img = resize(img, (1, 256, 256, 3), mode="constant", preserve_range=True) - img = img / 255.0 + # Determine input size based on model type + model_path_lower = modelpath.lower() + if "swinunet" in model_path_lower: + target_size = (224, 224) + else: # fallback to VGG/UNet + target_size = (256, 256) + + # Resize for model input + img_resized = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA) + img_resized = img_resized.astype(np.float32) / 255.0 + img_resized = np.expand_dims(img_resized, axis=0) + + return filename, img_resized, nonflipped_img, original_height, original_width - return filename, img, nonflipped_img, height, weight + # return filename, img, nonflipped_img, height, weight def calc_area_efov(depth: float, scalingline_length: int, img: np.ndarray): - """Calculates predicted muscle aread. + """Compute predicted muscle area for an eFOV mask using a continuous scale line. Parameters ---------- depth : float - The depth of the region of interest (ROI) in centimeters. + Depth represented by the scaling line, in centimeters. scalingline_length : int - The length of the scaling line in pixels. - img : np.ndarray - The eFOV ultrasound image as a NumPy array. + Length of the scaling line, in pixels. + img : numpy.ndarray + Binary (or non-zero) mask image of the predicted region of interest. + Non-zero pixels are treated as belonging to the region. Returns ------- - pred_muscle_area : float - The predicted muscle area in the ROI based on the eFOV ultrasound image. + float + Predicted muscle area in square centimeters. - Example - ------- - >>> calc_area(float(5), int(254), Image1.tif) - 3.813 + Notes + ----- + Pixels-per-centimeter is computed as:: + + pix_per_cm = scalingline_length / depth + + Area is then computed from the count of non-zero mask pixels:: + + area_cm2 = count_nonzero / (pix_per_cm ** 2) + + Examples + -------- + >>> calc_area_efov(5.0, 254, pred_mask) + 3.813 """ pix_per_cm = scalingline_length / depth # Counts pixels with values != 0 @@ -210,23 +230,30 @@ def calc_area_efov(depth: float, scalingline_length: int, img: np.ndarray): def calc_area(calib_dist: float, img: np.ndarray): - """Calculates predicted muscle aread. + """Compute predicted muscle area for a mask using a calibration factor. Parameters ---------- calib_dist : float - The calibration distance in centimeters, representing the known distance in the image. - img : np.ndarray - The ultrasound image as a NumPy array. + Calibration factor in pixels per centimeter. + img : numpy.ndarray + Binary (or non-zero) mask image of the predicted region of interest. + Non-zero pixels are treated as belonging to the region. Returns ------- - pred_muscle_area : float - The predicted muscle area in square centimeters based on the calibration distance. + float + Predicted muscle area in square centimeters. + + Notes + ----- + Area is computed from the count of non-zero pixels as:: + + area_cm2 = count_nonzero / (calib_dist ** 2) Examples -------- - >>> calc_area(int(54), Image1.tif) + >>> calc_area(54, pred_mask) 3.813 """ pix_per_cm = calib_dist @@ -237,22 +264,27 @@ def calc_area(calib_dist: float, img: np.ndarray): def compile_save_results(rootpath: str, dataframe: pd.DataFrame): - """Saves analysis results to excel and pdf files. + """Save analysis results to an Excel file. Parameters ---------- rootpath : str - The root path where the Excel file will be saved. - dataframe : pd.DataFrame - The Pandas DataFrame containing the results. + Directory where the results file is written. + dataframe : pandas.DataFrame + Table of results to write. Returns ------- - Excel file containing filename, muscle and predicted area. + None - Example - ------- - >>> compile_save_results(C:/Desktop/Test, dataframe) + Notes + ----- + The output file is written to ``{rootpath}/Results.xlsx`` and the + DataFrame is saved to the ``Results`` sheet. + + Examples + -------- + >>> compile_save_results("C:/Desktop/Test", df) """ excelpath = rootpath + "/Results.xlsx" with pd.ExcelWriter(excelpath, mode="w") as writer: @@ -263,37 +295,39 @@ def compile_save_results(rootpath: str, dataframe: pd.DataFrame): def calculate_batch_efov( rootpath: str, modelpath: str, - loss_function: str, depth: float, muscle: str, volume_wanted: str, distance_acsa: float, gui, ): - """Calculates area predictions for batches of EFOV US images - containing continous scaling line. - This function takes a batch of eFOV ultrasound images, predicts the aponeurosis area, - calculates echo intensity, and optionally calculates muscle volume based on the predicted areas. - The results are compiled into a Pandas DataFrame and saved to an Excel file. + """Run batch muscle area analysis for eFOV images with a continuous scale line. + + This function searches `rootpath` for supported image types, performs scale + calibration via a continuous scaling line, predicts the region of interest + using a trained model, computes muscle area and echo intensity, and writes + results to disk. Optionally, it estimates muscle volume from the sequence of + predicted areas. Parameters ---------- rootpath : str - The root path where the eFOV images are located. + Directory containing the eFOV ultrasound images. modelpath : str - The path to the pre-trained aponeurosis detection model. - loss_function : str - The loss function used during model training (e.g., "BCE" for binary cross-entropy). + Path to the trained model used for prediction. depth : float - The depth (in centimeters) of the eFOV ultrasound image. + Depth represented by the eFOV scaling line, in centimeters. muscle : str - The name or type of muscle being analyzed. + Muscle name/type used for analysis and calibration. volume_wanted : str - Whether to calculate muscle volume based on the predicted aponeurosis areas ("Yes" or "No"). + Whether to compute volume. Expected values are `"Yes"` or `"No"`. distance_acsa : float - The distance (in centimeters) between adjacent aponeurosis areas for volume calculation. - gui : tkinter.Tk - The Tkinter root window to interact with the graphical user interface. + Distance between adjacent ACSA locations, in centimeters, used for + volume estimation. + gui : object + GUI controller used for progress updates and stop handling. Expected to + expose attributes/methods used in this function (e.g., `should_stop`, + `progress_var`, `do_break`). Returns ------- @@ -301,27 +335,31 @@ def calculate_batch_efov( Notes ----- - - The function uses a pre-trained aponeurosis detection model to predict aponeurosis areas in each eFOV image. - - The function calculates the echo intensity (mean grey value) of the predicted muscle area in each image. - - If volume_wanted is set to "Yes", the function calculates the muscle volume based on the predicted aponeurosis areas. - - The function saves the results in a Pandas DataFrame and exports them to an Excel file in the rootpath. - - The function also saves the analyzed images as a multi-page PDF file named "Analyzed_images.pdf" in the rootpath. - - If any image processing or calculation error occurs, the function will record the failed files in "failed_images.txt". - - Example - ------- - >>> rootpath = "/path/to/directory" - >>> modelpath = "/path/to/pretrained_model.h5" - >>> loss_function = "BCE" - >>> depth = 4.5 - >>> muscle = "Quadriceps" - >>> volume_wanted = "Yes" - >>> distance_acsa = 2.0 - >>> gui = ... # Your Tkinter root window - >>> calculate_batch_efov(rootpath, filetype, modelpath, loss_function, depth, muscle, volume_wanted, distance_acsa, gui) - # The function will process the eFOV images in the specified directory, predict the aponeurosis areas, - # calculate echo intensity, and optionally calculate muscle volume based on the predicted areas. - # The results will be compiled into a DataFrame and saved to an Excel file. + - Supported image extensions: ``.tif``, ``.tiff``, ``.jpeg``, ``.jpg``, + ``.png``, ``.bmp``. + - Results are saved as: + - ``{rootpath}/Results.xlsx`` + - ``{rootpath}/Analyzed_images.pdf`` + - Failures are recorded in ``{rootpath}/failed_images.txt``. + - Echo intensity is computed from the original image and the predicted mask. + + Warns + ----- + UserWarning + If scale line detection fails (ScalinglineError) or echo intensity + calculation fails (EchoIntensityError), the image is skipped. + + Examples + -------- + >>> calculate_batch_efov( + ... rootpath="/path/to/images", + ... modelpath="/path/to/model.h5", + ... depth=4.5, + ... muscle="Quadriceps", + ... volume_wanted="Yes", + ... distance_acsa=2.0, + ... gui=gui, + ... ) """ # loop through acceptable image files filetypes = ["*.tif", "*.jpeg", "*.tiff", "*.jpg", "*.png", "*.bmp"] @@ -343,7 +381,7 @@ def calculate_batch_efov( gui.do_break() return - apo_model = ApoModel(gui, model_path=modelpath, loss_function=loss_function) + apo_model = ApoModel(gui, model_path=modelpath) dataframe = pd.DataFrame( columns=[ @@ -365,7 +403,13 @@ def calculate_batch_efov( try: start_time = time.time() - for imagepath in list_of_files: + # Show progress bar and reset value + gui.progress_label.grid() + gui.progress_bar.grid() + gui.progress_var.set(0) + gui.progress_bar.update_idletasks() + + for i, imagepath in enumerate(list_of_files): if gui.should_stop: # there was an input to stop the calculations @@ -422,6 +466,14 @@ def calculate_batch_efov( duration = time.time() - start_time print(f"duration: {duration}") + # Update progress + gui.progress_label.configure( + text=f"Predicting image {i+1}/{len(list_of_files)}" + ) + progress = (i + 1) / len(list_of_files) + gui.progress_var.set(progress) + gui.progress_bar.update_idletasks() + # musclevolume calculation with the calculated areas if volume_wanted == "Yes": @@ -467,12 +519,13 @@ def calculate_batch_efov( # clean up gui.should_stop = False gui.is_running = False + gui.progress_bar.grid_remove() + gui.progress_label.grid_remove() def calculate_batch( rootpath: str, modelpath: str, - loss_function: str, spacing: str, muscle: str, scaling: str, @@ -480,32 +533,36 @@ def calculate_batch( distance_acsa: float, gui, ): - """Calculates area predictions for batches of (EFOV) US images - not containing a continous scaling line. - This function takes a batch of ultrasound images, predicts the aponeurosis area, - calculates echo intensity, and optionally calculates muscle volume based on the predicted areas. - The results are compiled into a Pandas DataFrame and saved to an Excel file. + """Run batch muscle area analysis for images without a continuous scale line. + + This function searches `rootpath` for supported image types, performs scale + calibration using a scaling bar, manual calibration, or no scaling, predicts + the region of interest using a trained model, computes muscle area and echo + intensity, and writes results to disk. Optionally, it estimates muscle volume + from the sequence of predicted areas. Parameters ---------- rootpath : str - The root path where the ultrasound images are located. + Directory containing the ultrasound images. modelpath : str - The path to the pre-trained aponeurosis detection model. - loss_function : str - The loss function used during model training (e.g., "BCE" for binary cross-entropy). + Path to the trained model used for prediction. spacing : str - The spacing between reference markers on the image (used for scaling calibration). + Spacing between reference markers on the image (used for scaling). + Passed through to the calibration routines. muscle : str - The name or type of muscle being analyzed. + Muscle name/type used for analysis output. scaling : str - The type of scaling used for calibration ("Bar" for scaling bars, "Manual" for manual calibration). + Scaling mode. Expected values are `"Bar"`, `"Manual"`, or `"No Scaling"`. volume_wanted : str - Whether to calculate muscle volume based on the predicted aponeurosis areas ("Yes" or "No"). + Whether to compute volume. Expected values are `"Yes"` or `"No"`. distance_acsa : float - The distance (in centimeters) between adjacent aponeurosis areas for volume calculation. - gui : tkinter.Tk - The Tkinter root window to interact with the graphical user interface. + Distance between adjacent ACSA locations, in centimeters, used for + volume estimation. + gui : object + GUI controller used for progress updates and stop handling. Expected to + expose attributes/methods used in this function (e.g., `should_stop`, + `progress_var`, `do_break`). Returns ------- @@ -513,29 +570,31 @@ def calculate_batch( Notes ----- - - The function uses a pre-trained aponeurosis detection model to predict aponeurosis areas in each ultrasound image. - - The function calculates the echo intensity (mean grey value) of the predicted muscle area in each image. - - The scaling type ("Bar" or "Manual") is used to determine the calibration method for scaling. - - If volume_wanted is set to "Yes", the function calculates the muscle volume based on the predicted aponeurosis areas. - - The function saves the results in a Pandas DataFrame and exports them to an Excel file in the rootpath. - - The function also saves the analyzed images as a multi-page PDF file named "Analyzed_images.pdf" in the rootpath. - - If any image processing or calculation error occurs, the function will record the failed files in "failed_images.txt". - - Example - ------- - >>> rootpath = "/path/to/directory" - >>> modelpath = "/path/to/pretrained_model.h5" - >>> loss_function = "BCE" - >>> spacing = "5 cm" - >>> muscle = "Quadriceps" - >>> scaling = "Bar" - >>> volume_wanted = "Yes" - >>> distance_acsa = 2.0 - >>> gui = ... # Your Tkinter root window - >>> calculate_batch(rootpath, filetype, modelpath, loss_function, spacing, muscle, scaling, volume_wanted, distance_acsa, gui) - # The function will process the ultrasound images in the specified directory, predict the aponeurosis areas, - # calculate echo intensity, and optionally calculate muscle volume based on the predicted areas. - # The results will be compiled into a DataFrame and saved to an Excel file. + - Supported image extensions: ``.tif``, ``.tiff``, ``.jpeg``, ``.jpg``, + ``.png``, ``.bmp``. + - Results are saved as: + - ``{rootpath}/Results.xlsx`` + - ``{rootpath}/Analyzed_images.pdf`` + - Failures are recorded in ``{rootpath}/failed_images.txt``. + + Warns + ----- + UserWarning + If static scaling bar detection fails (StaticScalingError) or echo + intensity calculation fails (EchoIntensityError), the image is skipped. + + Examples + -------- + >>> calculate_batch( + ... rootpath="/path/to/images", + ... modelpath="/path/to/model.h5", + ... spacing="5 cm", + ... muscle="Quadriceps", + ... scaling="Bar", + ... volume_wanted="Yes", + ... distance_acsa=2.0, + ... gui=gui, + ... ) """ # loop through acceptable image files filetypes = ["*.tif", "*.jpeg", "*.tiff", "*.jpg", "*.png", "*.bmp"] @@ -557,7 +616,7 @@ def calculate_batch( gui.do_break() return - apo_model = ApoModel(gui, model_path=modelpath, loss_function=loss_function) + apo_model = ApoModel(gui, model_path=modelpath) dataframe = pd.DataFrame( columns=[ @@ -579,13 +638,19 @@ def calculate_batch( try: start_time = time.time() - for imagepath in list_of_files: + # Show progress bar and reset value + gui.progress_label.grid() + gui.progress_bar.grid() + gui.progress_var.set(0) + gui.progress_bar.update_idletasks() + + for i, imagepath in enumerate(list_of_files): if gui.should_stop: # there was an input to stop the calculations break # load image - imported = import_image(imagepath) + imported = import_image(imagepath, modelpath) filename, img, nonflipped_img, height, width = imported if scaling == "Bar": @@ -606,7 +671,7 @@ def calculate_batch( gui, img, imgscale, filename, scale_statement, width, height ) - else: + elif scaling == "Manual": calibrate_fn = calibrate_distance_manually calib_dist = calibrate_fn(nonflipped_img, spacing) @@ -615,6 +680,12 @@ def calculate_batch( gui, img, width, filename, height ) + elif scaling == "No Scaling": + calib_dist = 1 + circum, pred_apo_t, fig = apo_model.predict_m( + gui, img, width, filename, height + ) + # calculate echo intensity and area echo = calculate_echo_int(nonflipped_img, pred_apo_t) if echo is None: @@ -647,6 +718,14 @@ def calculate_batch( duration = time.time() - start_time print(f"duration: {duration}") + # Update progress + gui.progress_label.configure( + text=f"Predicting image {i+1}/{len(list_of_files)}" + ) + progress = (i + 1) / len(list_of_files) + gui.progress_var.set(progress) + gui.progress_bar.update_idletasks() + if volume_wanted == "Yes": # musclevolume calculation with the calculated areas @@ -681,6 +760,7 @@ def calculate_batch( gui.is_running = False finally: + # save predicted area results compile_save_results(rootpath, dataframe) # write failed images in file @@ -692,3 +772,5 @@ def calculate_batch( # clean up gui.should_stop = False gui.is_running = False + gui.progress_bar.grid_remove() + gui.progress_label.grid_remove() diff --git a/DeepACSA/gui_modules/ACSAuto.py b/DeepACSA/gui_modules/ACSAuto.py new file mode 100644 index 0000000..42cbc01 --- /dev/null +++ b/DeepACSA/gui_modules/ACSAuto.py @@ -0,0 +1,939 @@ +from __future__ import annotations + +import os +import tkinter as tk +from tkinter import filedialog, messagebox + +import customtkinter as ctk +import cv2 +import numpy as np +from PIL import Image, ImageTk + +from DeepACSA.gui_helpers.image_processing import ( + excel_expo, + preprocess_image, + fill_contour_mask, + area_cm2_from_mask, + remove_border_scale_bar, + detect_contour_from_preprocessed_mask, + find_starting_points, + measure_area, +) + +SUPPORTED_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") + +EDGE_SETTINGS = { + # Muscle CSA: weaker boundaries -> more smoothing, lower Canny + "Rectus femoris": dict(blur="gauss", blur_k=5, blur_sig=1.4, canny_lo=25, canny_hi=80, dilate=1), + "Vastus lateralis": dict(blur="gauss", blur_k=5, blur_sig=1.6, canny_lo=20, canny_hi=75, dilate=1), + "Quad RF": dict(blur="gauss", blur_k=5, blur_sig=1.4, canny_lo=25, canny_hi=80, dilate=1), + "Quad VL": dict(blur="gauss", blur_k=5, blur_sig=1.6, canny_lo=20, canny_hi=75, dilate=1), + "Quadriceps": dict(blur="gauss", blur_k=7, blur_sig=1.8, canny_lo=20, canny_hi=70, dilate=1), + "Gastro MED": dict(blur="median", blur_k=5, blur_sig=None, canny_lo=20, canny_hi=70, dilate=1), + + # Tendon: sharper, brighter lines -> less smoothing, higher Canny + "Patellar tendon": dict(blur="gauss", blur_k=3, blur_sig=1.0, canny_lo=35, canny_hi=120, dilate=1), +} + + +# ============================================================================= +# Helpers +# ============================================================================= + +def load_bgr(path: str) -> np.ndarray: + img = cv2.imread(path, cv2.IMREAD_COLOR) + if img is None: + raise FileNotFoundError(f"Failed to read image: {path}") + return img + + +def safe_gray(img: np.ndarray) -> np.ndarray: + if img is None: + raise ValueError("safe_gray: img is None") + if img.ndim == 2: + return img.astype(np.uint8, copy=False) + if img.ndim == 3: + c = img.shape[2] + if c == 1: + return img[:, :, 0].astype(np.uint8, copy=False) + if c == 3: + return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + if c == 4: + return cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY) + raise ValueError(f"safe_gray: unsupported shape {img.shape}") + + +def ensure_gray2d(img: np.ndarray) -> np.ndarray: + g = safe_gray(img) + if g.ndim != 2: + raise ValueError(f"ensure_gray2d failed, got shape {g.shape}") + return g + + +def as_int(x, *, name="value") -> int: + try: + return int(round(float(x))) + except Exception as e: + raise ValueError(f"{name} must be numeric, got {x!r}") from e + + +def to_tk_photo_rgb(rgb: np.ndarray, size: tuple[int, int]) -> ImageTk.PhotoImage: + if rgb.ndim != 3 or rgb.shape[2] != 3: + raise ValueError(f"to_tk_photo_rgb expects RGB image, got {rgb.shape}") + pil = Image.fromarray(rgb) + pil.thumbnail(size) + return ImageTk.PhotoImage(pil) + + +def mask_outname(original_name: str) -> str: + stem, _ = os.path.splitext(original_name) + return f"{stem}_mask.png" + +def _blur_u8(gray: np.ndarray, kind: str, k: int, sig: float | None) -> np.ndarray: + k = int(k) | 1 + if kind == "median": + return cv2.medianBlur(gray, k) + if kind == "gauss": + return cv2.GaussianBlur(gray, (k, k), float(sig or 0)) + return gray + +def build_edge_map(gray_u8: np.ndarray, muscle_type: str) -> np.ndarray: + s = EDGE_SETTINGS.get(muscle_type, EDGE_SETTINGS["Rectus femoris"]) + + g = gray_u8 + g = _blur_u8(g, s["blur"], s["blur_k"], s.get("blur_sig", None)) + + edges = cv2.Canny(g, threshold1=int(s["canny_lo"]), threshold2=int(s["canny_hi"])) + # optional small dilation to connect broken edges + if int(s.get("dilate", 0)) > 0: + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + edges = cv2.dilate(edges, k, iterations=int(s["dilate"])) + + # enforce binary 0/255 + edges = (edges > 0).astype(np.uint8) * 255 + return edges + +def rays_hit_edges(edge_u8: np.ndarray, + start_points: list[tuple[int, int]], + *, + max_range_px: int | None = None, + n_rays: int = 360) -> list[tuple[int, int]]: + h, w = edge_u8.shape[:2] + is_edge = (edge_u8 > 0) + + if max_range_px is None: + max_range_px = int(np.hypot(w, h)) + + hits: list[tuple[int, int]] = [] + + for (x0, y0) in start_points: + x0 = int(np.clip(x0, 0, w - 1)) + y0 = int(np.clip(y0, 0, h - 1)) + + for ang in np.linspace(0, 2 * np.pi, int(n_rays), endpoint=False): + dx, dy = np.cos(ang), np.sin(ang) + + for d in range(1, max_range_px): + x = int(round(x0 + d * dx)) + y = int(round(y0 + d * dy)) + if x < 0 or y < 0 or x >= w or y >= h: + break + if is_edge[y, x]: + hits.append((x, y)) + break + + return hits + +def poly_from_hits(hits: list[tuple[int, int]]) -> list[tuple[int, int]]: + if len(hits) < 3: + return [] + pts = np.array(hits, dtype=np.int32).reshape(-1, 1, 2) + hull = cv2.convexHull(pts) + return [(int(p[0][0]), int(p[0][1])) for p in hull] + +def ray_outline_from_mask(mask_u8: np.ndarray, + start_points: list[tuple[int, int]], + *, + n_rays: int = 360) -> list[tuple[int, int]]: + """ + Rays start inside foreground (mask>0) and march outward until they leave it. + Returns a polygon-like point set (convex hull of boundary hits). + """ + h, w = mask_u8.shape[:2] + fg = (mask_u8 > 0) + + hits: list[tuple[int, int]] = [] + + for (x0, y0) in start_points: + x0 = int(np.clip(x0, 0, w - 1)) + y0 = int(np.clip(y0, 0, h - 1)) + if not fg[y0, x0]: + # If user clicked outside, skip this point (or you could project to nearest fg) + continue + + max_d = int(np.hypot(w, h)) # safe upper bound + + for ang in np.linspace(0, 2 * np.pi, int(n_rays), endpoint=False): + dx, dy = np.cos(ang), np.sin(ang) + last_in = (x0, y0) + + for d in range(1, max_d): + x = int(round(x0 + d * dx)) + y = int(round(y0 + d * dy)) + if x < 0 or y < 0 or x >= w or y >= h: + hits.append(last_in) + break + if fg[y, x]: + last_in = (x, y) + else: + # just left foreground + hits.append(last_in) + break + + if len(hits) < 3: + return [] + + pts = np.array(hits, dtype=np.int32).reshape(-1, 1, 2) + hull = cv2.convexHull(pts) + return [(int(p[0][0]), int(p[0][1])) for p in hull] +# ============================================================================= +# App +# ============================================================================= + +class ACSAutoApp(ctk.CTk): + """ + - Removed the contour deletion stage completely (per request). + - Always shows final overlay automatically at the end (on original image). + - Toggle to hide overlay completely. + - Slightly nicer UI layout (grouped sections), background/theme unchanged. + """ + def __init__(self): + super().__init__() + + self.title("ACSAuto - Anatomical Cross-Sectional Area Analysis") + self.geometry("1180x700") + self.configure(padx=0, pady=0) + + master_path = os.path.dirname(os.path.abspath(__file__)) + theme_path = os.path.join(master_path, "..", "gui_helpers", "gui_files", "ui_color_theme.json") + ctk.set_default_color_theme(theme_path) + + # close safely + self._stop_requested = False + self.protocol("WM_DELETE_WINDOW", self._on_close_request) + + # UI vars + self.analysis_type = ctk.StringVar(value="Folder") + self.export_excel = ctk.BooleanVar(value=True) + self.input_dir = ctk.StringVar() + self.output_dir = ctk.StringVar() + + self.muscle = ctk.StringVar(value="Rectus femoris") + self.starting_point_mode = ctk.StringVar(value="Automatic") # Automatic | Manual + self.scaling = ctk.StringVar(value="None") # Automatic | Manual + self.scan_depth = ctk.DoubleVar(value=5.0) + + self.flip_horizontal = ctk.BooleanVar(value=False) + self.flip_vertical = ctk.BooleanVar(value=False) + + # overlay option (OFF = completely remove overlay) + self.show_final_overlay = ctk.BooleanVar(value=True) + + # file state + self.image_list: list[str] = [] + self.selected_image_paths: list[str] = [] + self.cur_path: str | None = None + + # image shown on canvas + self.img_gray: np.ndarray | None = None + self._orig_gray_current: np.ndarray | None = None + self._bin_gray_current: np.ndarray | None = None + + # mapping + self._disp_w = 1 + self._disp_h = 1 + self._scale = 1.0 + self._canvas_w = 1 + self._canvas_h = 1 + + # calibration + self.px_per_cm: float | None = None + self.calib_points: list[tuple[int, int]] = [] + self._calib_mode = False + + # modes: VIEW | START_POINTS | EDIT_CONTOUR + self.mode = "VIEW" + + # starting points stage + self.sp_points: list[tuple[int, int]] = [] + self._sp_done_var = tk.BooleanVar(value=False) + self._sp_accept = False + + # edit stage (optional; we keep it so you can still add/move vertices if you want) + self.edit_poly: list[tuple[int, int]] = [] + self._edit_drag_idx: int | None = None + self._edit_done_var = tk.BooleanVar(value=False) + self._edit_accept = False + + # last final contour for automatic overlay + self._last_final_poly: list[tuple[int, int]] = [] + + # detection ray seed activstion + self.seed_from_rays = ctk.BooleanVar(value=True) + + self.create_ui() + + # ------------------------------------------------------------------------- + # Close handling + # ------------------------------------------------------------------------- + def _on_close_request(self): + self._stop_requested = True + try: + self._sp_done_var.set(True) + self._edit_done_var.set(True) + except Exception: + pass + self.quit() + + # ------------------------------------------------------------------------- + # UI + # ------------------------------------------------------------------------- + def create_ui(self): + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=0) + self.grid_columnconfigure(1, weight=1) + + main = ctk.CTkFrame(self) + main.grid(row=0, column=0, sticky="nsew", padx=0, pady=0) + + right = ctk.CTkFrame(self) + right.grid(row=0, column=1, sticky="nsew", padx=0, pady=0) + right.grid_rowconfigure(0, weight=1) + right.grid_columnconfigure(0, weight=1) + + # --- nicer grouping (no background changes) --- + io = ctk.CTkFrame(main) + io.grid(row=0, column=0, columnspan=3, sticky="ew", padx=10, pady=(10, 6)) + io.grid_columnconfigure(1, weight=1) + + opts = ctk.CTkFrame(main) + opts.grid(row=1, column=0, columnspan=3, sticky="ew", padx=10, pady=6) + opts.grid_columnconfigure(1, weight=1) + + run = ctk.CTkFrame(main) + run.grid(row=2, column=0, columnspan=3, sticky="ew", padx=10, pady=(6, 10)) + run.grid_columnconfigure(0, weight=1) + + # --- I/O --- + ctk.CTkLabel(io, text="Input").grid(row=0, column=0, sticky="w") + ctk.CTkRadioButton(io, text="Folder", variable=self.analysis_type, value="Folder", + command=self._on_analysis_type_changed).grid(row=0, column=1, sticky="w") + ctk.CTkRadioButton(io, text="Image", variable=self.analysis_type, value="Image", + command=self._on_analysis_type_changed).grid(row=0, column=2, sticky="w") + + ctk.CTkButton(io, text="Browse…", command=self.select_input_source).grid(row=1, column=0, sticky="w", pady=(6, 0)) + self.lbl_in = ctk.CTkLabel(io, text="", anchor="w") + self.lbl_in.grid(row=1, column=1, columnspan=2, sticky="ew", padx=(8, 0), pady=(6, 0)) + + ctk.CTkButton(io, text="Output…", command=self.select_output_dir).grid(row=2, column=0, sticky="w", pady=(6, 0)) + self.lbl_out = ctk.CTkLabel(io, text="", anchor="w") + self.lbl_out.grid(row=2, column=1, columnspan=2, sticky="ew", padx=(8, 0), pady=(6, 0)) + + ctk.CTkCheckBox(io, text="Export to Excel", variable=self.export_excel).grid(row=3, column=0, sticky="w", pady=(6, 0)) + + # --- Options --- + ctk.CTkLabel(opts, text="Muscle Type").grid(row=0, column=0, sticky="w") + ctk.CTkOptionMenu( + opts, + variable=self.muscle, + values=[ + "Rectus femoris", + "Vastus lateralis", + "Quad RF", + "Quad VL", + "Quadriceps", + "Gastro MED", + "Patellar tendon", + ], + ).grid(row=0, column=1, sticky="ew") + + ctk.CTkLabel(opts, text="Starting Points").grid(row=1, column=0, sticky="w", pady=(6, 0)) + ctk.CTkOptionMenu(opts, variable=self.starting_point_mode, + values=["Automatic", "Manual"]).grid(row=1, column=1, sticky="ew", pady=(6, 0)) + + ctk.CTkLabel(opts, text="Scaling").grid(row=2, column=0, sticky="w", pady=(6, 0)) + ctk.CTkOptionMenu(opts, variable=self.scaling, + values=["None", "Manual"]).grid(row=2, column=1, sticky="ew", pady=(6, 0)) + ctk.CTkButton(opts, text="Calibrate…", command=self.calibrate_scaling).grid(row=2, column=2, sticky="w", padx=(8, 0), pady=(6, 0)) + + ctk.CTkLabel(opts, text="Scan Depth (cm)").grid(row=3, column=0, sticky="w", pady=(6, 0)) + ctk.CTkEntry(opts, textvariable=self.scan_depth).grid(row=3, column=1, sticky="ew", pady=(6, 0)) + + ctk.CTkCheckBox(opts, text="Flip Horizontally", variable=self.flip_horizontal).grid(row=4, column=0, sticky="w", pady=(6, 0)) + ctk.CTkCheckBox(opts, text="Flip Vertically", variable=self.flip_vertical).grid(row=4, column=1, sticky="w", pady=(6, 0)) + + ctk.CTkCheckBox( + opts, + text="Show final overlay", + variable=self.show_final_overlay, + command=self._render, + ).grid(row=5, column=0, sticky="w", pady=(6, 0)) + + ctk.CTkCheckBox( + opts, + text="Seed contour from rays (starting points affect overlay)", + variable=self.seed_from_rays, + ).grid(row=6, column=0, columnspan=3, sticky="w", pady=(6, 0)) + + # --- Run + status --- + ctk.CTkButton(run, text="Start Analysis", command=self.start_analysis).grid(row=0, column=0, sticky="ew") + btn_row = ctk.CTkFrame(run) + btn_row.grid(row=2, column=0, sticky="ew", pady=(6, 0)) + btn_row.grid_columnconfigure(0, weight=1) + btn_row.grid_columnconfigure(1, weight=1) + + ctk.CTkButton(btn_row, text="Clear vertices", command=self.clear_vertices).grid(row=0, column=0, sticky="w", padx=(0, 6)) + self.status = ctk.CTkLabel(run, text="Ready.", justify="left", font=("Verdana", 10), text_color="#FFFFFF") + self.status.grid(row=3, column=0, sticky="w", pady=(6, 0)) + + # Canvas + self.canvas = tk.Canvas(right, bg="white", highlightthickness=0) + self.canvas.grid(row=0, column=0, sticky="nsew") + self.canvas.bind("", lambda e: self._render()) + self.canvas.bind("", self.on_mouse_down) + self.canvas.bind("", self.on_mouse_move) + self.canvas.bind("", self.on_mouse_up) + self.canvas.bind("", self.on_right_click) + + self.bind("", self.on_enter_key) + self.bind("", self.on_escape_key) + + self._on_analysis_type_changed() + + def _set_status(self, text: str): + try: + self.status.configure(text=text) + except Exception: + pass + + def _on_analysis_type_changed(self): + self.input_dir.set("") + self.selected_image_paths = [] + self.image_list = [] + self.cur_path = None + self.img_gray = None + self._orig_gray_current = None + self._bin_gray_current = None + self.mode = "VIEW" + self._last_final_poly = [] + self.canvas.delete("all") + self._set_status("Ready.") + self.lbl_in.configure(text="") + self.lbl_out.configure(text="") + + # ------------------------------------------------------------------------- + # Input + # ------------------------------------------------------------------------- + def select_input_source(self): + if self.analysis_type.get() == "Folder": + directory = filedialog.askdirectory() + if directory: + self.input_dir.set(directory) + self.lbl_in.configure(text=directory) + self._load_images_from_folder(directory) + else: + file_paths = filedialog.askopenfilenames( + filetypes=[("Image Files", "*.png;*.jpg;*.jpeg;*.bmp;*.tif;*.tiff")] + ) + if file_paths: + self.selected_image_paths = list(file_paths) + self.image_list = self.selected_image_paths[:] + self.lbl_in.configure(text=f"{len(self.image_list)} image(s) selected") + self._load_image(self.image_list[0]) + + def select_output_dir(self): + directory = filedialog.askdirectory() + if directory: + self.output_dir.set(directory) + self.lbl_out.configure(text=directory) + + def _load_images_from_folder(self, folder_path: str): + files = [ + os.path.join(folder_path, f) + for f in sorted(os.listdir(folder_path)) + if f.lower().endswith(SUPPORTED_EXT) + ] + if not files: + messagebox.showwarning("No images", "No supported image files found in that folder.") + return + self.image_list = files + self._load_image(self.image_list[0]) + + def _load_image(self, image_path: str): + self.cur_path = os.path.abspath(image_path) + bgr = load_bgr(image_path) + self.img_gray = ensure_gray2d(bgr) + self._orig_gray_current = self.img_gray.copy() + self.mode = "VIEW" + self._render() + + # ------------------------------------------------------------------------- + # Calibration + # ------------------------------------------------------------------------- + def calibrate_scaling(self): + if self.img_gray is None: + messagebox.showerror("Error", "Load an image first (so you can click on it).") + return + self.calib_points = [] + self._calib_mode = True + messagebox.showinfo( + "Manual scaling", + "Click TWO points with known distance.\nAfter second click you will be asked for cm." + ) + + # ------------------------------------------------------------------------- + # Mapping + Render + # ------------------------------------------------------------------------- + def _compute_display_mapping(self): + assert self.img_gray is not None + h, w = self.img_gray.shape[:2] + cw = max(1, int(self.canvas.winfo_width())) + ch = max(1, int(self.canvas.winfo_height())) + self._scale = min(cw / w, ch / h, 1.0) + self._disp_w = max(1, as_int(w * self._scale)) + self._disp_h = max(1, as_int(h * self._scale)) + self._canvas_w = cw + self._canvas_h = ch + + def _canvas_to_image_xy(self, x_canvas: int, y_canvas: int) -> tuple[int, int] | None: + if self.img_gray is None: + return None + if x_canvas < 0 or y_canvas < 0 or x_canvas >= self._disp_w or y_canvas >= self._disp_h: + return None + h, w = self.img_gray.shape[:2] + x_img = int(round(x_canvas / max(self._scale, 1e-9))) + y_img = int(round(y_canvas / max(self._scale, 1e-9))) + return int(np.clip(x_img, 0, w - 1)), int(np.clip(y_img, 0, h - 1)) + + def _nearest_point_idx(self, pts: list[tuple[int, int]], ix: int, iy: int, max_dist_px: int = 15) -> int | None: + if not pts: + return None + best_i, best_d2 = None, None + for i, (x, y) in enumerate(pts): + d2 = (x - ix) ** 2 + (y - iy) ** 2 + if best_d2 is None or d2 < best_d2: + best_d2, best_i = d2, i + if best_d2 is not None and best_d2 <= max_dist_px ** 2: + return best_i + return None + + def _render(self): + if self.img_gray is None: + self.canvas.delete("all") + return + + self._compute_display_mapping() + rgb = cv2.cvtColor(self.img_gray, cv2.COLOR_GRAY2RGB) + + # scale-aware overlay thickness (prevents vanishing when scaled down) + t = max(2, int(round(2.0 / max(self._scale, 1e-6)))) + rad = max(3, int(round(4.0 / max(self._scale, 1e-6)))) + + # START_POINTS overlay (cyan points) + if self.mode == "START_POINTS": + for (x, y) in self.sp_points: + cv2.circle(rgb, (int(x), int(y)), rad, (0, 255, 255), -1) + cv2.circle(rgb, (int(x), int(y)), rad + 2, (0, 0, 0), 1) + + # EDIT overlay polygon (live editing) + if self.mode == "EDIT_CONTOUR" and len(self.edit_poly) >= 2: + pts = np.array(self.edit_poly, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(rgb, [pts], True, (0, 255, 0), t) + for (x, y) in self.edit_poly: + cv2.circle(rgb, (int(x), int(y)), rad, (255, 0, 255), -1) + + # FINAL OVERLAY (automatic at end, on original image view) + if self.show_final_overlay.get() and self._last_final_poly and self.mode != "EDIT_CONTOUR": + if len(self._last_final_poly) >= 2: + pts = np.array(self._last_final_poly, dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(rgb, [pts], True, (0, 255, 0), t) + for (x, y) in self._last_final_poly: + cv2.circle(rgb, (int(x), int(y)), rad, (255, 0, 255), -1) + + disp = cv2.resize(rgb, (self._disp_w, self._disp_h), interpolation=cv2.INTER_AREA) + canvas_rgb = np.full((self._canvas_h, self._canvas_w, 3), 255, dtype=np.uint8) + canvas_rgb[: self._disp_h, : self._disp_w] = disp + + tk_img = to_tk_photo_rgb(canvas_rgb, size=(self._canvas_w, self._canvas_h)) + self.canvas.delete("all") + self.canvas.create_image(0, 0, anchor="nw", image=tk_img) + self.canvas.image = tk_img + + def clear_vertices(self): + """Remove all vertices from the editable polygon (only meaningful in EDIT_CONTOUR).""" + if self.mode != "EDIT_CONTOUR": + self._set_status("Clear vertices is only available while editing the contour.") + return + self.edit_poly = [] + self._edit_drag_idx = None + self._render() + self._set_status("All vertices cleared. Click to add new vertices, Enter to accept, Esc to use auto.") + + def toggle_overlay(self): + self.show_final_overlay.set(not self.show_final_overlay.get()) + self._render() + + def _poly_from_rays(self, bin_img_u8: np.ndarray, sp: list[tuple[int, int]], scan_depth_cm: float) -> list[tuple[int, int]]: + """ + Build a polygon seed using the ray method: + - uses measure_area() which internally shoots rays and collects contour points + - we re-run a *lightweight* version: ray endpoints -> convex hull + """ + # We replicate ray endpoints here (fast) so we can build a hull for overlay. + h, w = bin_img_u8.shape[:2] + depth_px = max(5, int(round(scan_depth_cm * 50))) # fallback heuristic if scan_depth isn't pixels + # NOTE: if you have px/cm, use it: + if self.scaling.get() == "Manual" and self.px_per_cm: + depth_px = max(5, int(round(scan_depth_cm * self.px_per_cm))) + + contour_pts: list[tuple[int, int]] = [] + num_rays = 360 + + for (x0, y0) in sp: + for ang in np.linspace(0, 2 * np.pi, num_rays, endpoint=False): + dx, dy = np.cos(ang), np.sin(ang) + hit = None + for d in range(depth_px): + x = int(round(x0 + d * dx)) + y = int(round(y0 + d * dy)) + if x < 0 or y < 0 or x >= w or y >= h: + hit = (int(np.clip(x, 0, w - 1)), int(np.clip(y, 0, h - 1))) + break + if bin_img_u8[y, x] > 0: # edge hit + hit = (x, y) + break + if hit is not None: + contour_pts.append(hit) + + if len(contour_pts) < 3: + return [] + + pts = np.array(contour_pts, dtype=np.int32).reshape(-1, 1, 2) + hull = cv2.convexHull(pts) # stable polygon seed + return [(int(p[0][0]), int(p[0][1])) for p in hull] + + # ------------------------------------------------------------------------- + # Keys + # ------------------------------------------------------------------------- + def on_enter_key(self, event=None): + if self.mode == "START_POINTS": + self._sp_accept = True + self._sp_done_var.set(True) + return + if self.mode == "EDIT_CONTOUR": + self._edit_accept = True + self._edit_done_var.set(True) + return + + def on_escape_key(self, event=None): + if self.mode == "START_POINTS": + self._sp_accept = False + self._sp_done_var.set(True) + return + if self.mode == "EDIT_CONTOUR": + self._edit_accept = False + self._edit_done_var.set(True) + return + if self._calib_mode: + self._calib_mode = False + self.calib_points = [] + self._set_status("Calibration cancelled.") + return + + # ------------------------------------------------------------------------- + # Mouse + # ------------------------------------------------------------------------- + def on_right_click(self, event): + # START_POINTS: right-click removes nearest point + if self.mode == "START_POINTS": + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is None: + return + ix, iy = xy + j = self._nearest_point_idx(self.sp_points, ix, iy) + if j is not None: + self.sp_points.pop(j) + self._render() + return + + # EDIT_CONTOUR: right-click deletes nearest vertex + if self.mode == "EDIT_CONTOUR": + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is None: + return + ix, iy = xy + j = self._nearest_point_idx(self.edit_poly, ix, iy) + if j is not None: + self.edit_poly.pop(j) + self._render() + return + + def on_mouse_down(self, event): + if self.img_gray is None: + return + + # calibration first + if self._calib_mode: + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is None: + return + self.calib_points.append(xy) + if len(self.calib_points) == 2: + (x1, y1), (x2, y2) = self.calib_points + px = float(np.hypot(x2 - x1, y2 - y1)) + print(f"Calibration points: {self.calib_points}, pixel distance: {px:.2f}") + self._calib_mode = False + + win = ctk.CTkInputDialog(text="Distance between points (cm):", title="Manual scaling") + cm_str = win.get_input() + try: + cm = float(cm_str) + if cm <= 0: + raise ValueError + except Exception: + messagebox.showerror("Error", "Invalid cm value.") + self.calib_points = [] + return + + self.px_per_cm = px / cm + self.calib_points = [] + self._set_status(f"Manual scaling set: {self.px_per_cm:.2f} px/cm") + return + + # START_POINTS: add point + if self.mode == "START_POINTS": + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is not None: + self.sp_points.append(xy) + self._render() + return + + # EDIT_CONTOUR: add/move vertex + if self.mode == "EDIT_CONTOUR": + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is None: + return + ix, iy = xy + j = self._nearest_point_idx(self.edit_poly, ix, iy) + if j is not None: + self._edit_drag_idx = j + else: + self.edit_poly.append((ix, iy)) + self._render() + return + + def on_mouse_move(self, event): + if self.img_gray is None: + return + if self.mode == "EDIT_CONTOUR" and self._edit_drag_idx is not None: + xy = self._canvas_to_image_xy(event.x, event.y) + if xy is None: + return + self.edit_poly[self._edit_drag_idx] = xy + self._render() + + def on_mouse_up(self, event): + if self.mode == "EDIT_CONTOUR": + self._edit_drag_idx = None + + # ------------------------------------------------------------------------- + # Analysis + # ------------------------------------------------------------------------- + def _resolve_paths(self) -> list[str]: + if self.analysis_type.get() == "Folder": + indir = self.input_dir.get().strip() + if not indir or not os.path.isdir(indir): + raise ValueError("Please select a valid input folder.") + return [ + os.path.join(indir, f) + for f in sorted(os.listdir(indir)) + if f.lower().endswith(SUPPORTED_EXT) + ] + return self.selected_image_paths or self.image_list + + def start_analysis(self): + if self._stop_requested: + return + + outdir = self.output_dir.get().strip() + if not outdir: + messagebox.showerror("Error", "Please select an output directory.") + return + os.makedirs(outdir, exist_ok=True) + + if self.scaling.get() == "Manual" and (self.px_per_cm is None or self.px_per_cm <= 0): + messagebox.showerror("Error", "Manual scaling selected but not calibrated (px/cm missing).") + return + + try: + paths = self._resolve_paths() + except Exception as e: + messagebox.showerror("Error", str(e)) + return + + if not paths: + messagebox.showerror("Error", "No images selected/found.") + return + + muscle_type = self.muscle.get() + scan_depth_cm = float(self.scan_depth.get()) + settings = {"tubeness_sigma": 5, "gaussian_sigma": 2} + + results_rows: list[dict] = [] + + for idx, p in enumerate(paths, start=1): + if self._stop_requested: + return + + # Clear vertices and reset state for each new image to avoid confusion (especially since we removed the deletion stage). + self.clear_vertices() + + self._set_status(f"Processing {idx}/{len(paths)}: {os.path.basename(p)}") + + bgr = load_bgr(p) + orig_gray = ensure_gray2d(bgr) + self._orig_gray_current = orig_gray + + # Build edge map for ray hits (structure-dependent) + edge_u8 = build_edge_map(orig_gray, muscle_type) + # also remove scale bar/border artifacts on edge image (optional but helps) + edge_u8 = remove_border_scale_bar(edge_u8, thr=240) + + # for manual start-point placement show the EDGE image (not the old binary mask) + self._bin_gray_current = edge_u8 + + # preprocess -> mask + pre_mask = preprocess_image( + bgr, + muscle_type, + self.flip_horizontal.get(), + self.flip_vertical.get(), + settings["tubeness_sigma"], + settings["gaussian_sigma"], + ) + pre_mask = ensure_gray2d(pre_mask) + + if pre_mask.shape[:2] != orig_gray.shape[:2]: + pre_mask = cv2.resize(pre_mask, (orig_gray.shape[1], orig_gray.shape[0]), interpolation=cv2.INTER_NEAREST) + + # pre_for_contours = remove_border_scale_bar(pre_mask, thr=240) + # _, bin_img = cv2.threshold(pre_for_contours, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + # bin_img_u8 = bin_img.astype(np.uint8, copy=False) + # self._bin_gray_current = bin_img_u8 + + # ---------------- STARTING POINTS stage (binary display) ---------------- + if self.starting_point_mode.get() == "Manual": + self.mode = "START_POINTS" + self.img_gray = self._bin_gray_current + self.sp_points = [] + self._sp_accept = False + self._sp_done_var.set(False) + self._render() + self._set_status("Starting points: Left=add, Right=remove, Enter=accept, Esc=skip(auto).") + self.wait_variable(self._sp_done_var) + + if self._stop_requested: + return + + if self._sp_accept and len(self.sp_points) > 0: + sp = self.sp_points[:] + else: + sp, _ = find_starting_points(self._bin_gray_current.copy(), method="Automatic") + else: + sp, _ = find_starting_points(self._bin_gray_current.copy(), method="Automatic") + + area_rays = measure_area(self._bin_gray_current.copy(), sp, scan_depth=as_int(scan_depth_cm, name="scan_depth_cm")) + + # ---------------- Seed polygon (automatic) ---------------- + # Use the DeepACSA helper to get a contour directly from preprocessed mask. + # This avoids the broken deletion step and gives you a stable auto contour. + cnt_auto, _ = detect_contour_from_preprocessed_mask(pre_mask.copy()) + poly_seed: list[tuple[int, int]] = [] + + if self.seed_from_rays.get() and len(sp) > 0: + #poly_seed = self._poly_from_rays(bin_img_u8, sp, scan_depth_cm) + #poly_seed = ray_outline_from_mask(self._bin_gray_current.copy(), sp, n_rays=360) + hits = rays_hit_edges(edge_u8, sp, n_rays=360) + poly_seed = poly_from_hits(hits) + + if poly_seed: + self._set_status("Seeded contour from rays (starting points affect overlay).") + else: + self._set_status("Ray seeding produced no polygon, falling back to auto contour.") + + if not poly_seed: + cnt_auto, _ = detect_contour_from_preprocessed_mask(pre_mask.copy()) + if cnt_auto is not None and len(cnt_auto) >= 3: + cnt_auto = np.asarray(cnt_auto).astype(np.int32) + if cnt_auto.ndim == 2 and cnt_auto.shape[1] == 2: + cnt_auto = cnt_auto.reshape(-1, 1, 2) + poly_seed = [(int(pt[0][0]), int(pt[0][1])) for pt in cnt_auto] + + # ---------------- Optional edit stage on ORIGINAL ---------------- + self.img_gray = self._orig_gray_current + self.mode = "EDIT_CONTOUR" + self.edit_poly = poly_seed[:] + self._edit_drag_idx = None + self._edit_accept = False + self._edit_done_var.set(False) + self._render() + self._set_status("Edit contour: click add/move, Right=delete vertex, Enter=accept, Esc=use auto.") + self.wait_variable(self._edit_done_var) + + if self._stop_requested: + return + + final_poly = self.edit_poly[:] if (self._edit_accept and len(self.edit_poly) >= 3) else poly_seed[:] + self._last_final_poly = final_poly[:] # stored for automatic overlay + + # compute filled mask + area + if len(final_poly) >= 3: + final_cnt = np.array(final_poly, dtype=np.int32).reshape(-1, 1, 2) + filled = fill_contour_mask(orig_gray.shape[:2], final_cnt) + + area = area_cm2_from_mask( + filled, + px_per_cm=self.px_per_cm if self.scaling.get() == "Manual" else None, + ) + else: + filled = np.zeros_like(orig_gray, dtype=np.uint8) + area = 0.0 + + out_name = os.path.basename(p) + cv2.imwrite(os.path.join(outdir, mask_outname(out_name)), filled) + + results_rows.append( + { + "Filename": out_name, + "Area (cm²)": float(area), + "Area_rays (a.u.)": float(area_rays), + "N_start_points": int(len(sp)), + } + ) + + # --- FINAL: show original + overlay automatically --- + self.mode = "VIEW" + self.img_gray = self._orig_gray_current + self._set_status(f"Done: {out_name} | Area={area:.2f} cm² (overlay={'on' if self.show_final_overlay.get() else 'off'})") + self._render() + + if self.export_excel.get(): + excel_expo(results_rows, os.path.join(outdir, "results.xlsx")) + + self._set_status("All done.") + messagebox.showinfo("Success", "Analysis completed successfully.") + + +if __name__ == "__main__": + app = ACSAutoApp() + app.mainloop() \ No newline at end of file diff --git a/DeepACSA/gui_modules/__init__.py b/DeepACSA/gui_modules/__init__.py new file mode 100644 index 0000000..593cee0 --- /dev/null +++ b/DeepACSA/gui_modules/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["advanced_analysis"] + +from DeepACSA.gui_modules.advanced_modules import * diff --git a/DeepACSA/gui_modules/__pycache__/__init__.cpython-310.pyc b/DeepACSA/gui_modules/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..cea3775 Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/__init__.cpython-310.pyc differ diff --git a/DeepACSA/gui_modules/__pycache__/__init__.cpython-312.pyc b/DeepACSA/gui_modules/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..64302c0 Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/__init__.cpython-312.pyc differ diff --git a/DeepACSA/gui_modules/__pycache__/__init__.cpython-39.pyc b/DeepACSA/gui_modules/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..9ad2f7d Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/__init__.cpython-39.pyc differ diff --git a/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-310.pyc b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-310.pyc new file mode 100644 index 0000000..a4a46a1 Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-310.pyc differ diff --git a/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-312.pyc b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-312.pyc new file mode 100644 index 0000000..cbe1208 Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-312.pyc differ diff --git a/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-39.pyc b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-39.pyc new file mode 100644 index 0000000..c51123e Binary files /dev/null and b/DeepACSA/gui_modules/__pycache__/advanced_modules.cpython-39.pyc differ diff --git a/DeepACSA/gui_modules/advanced_modules.py b/DeepACSA/gui_modules/advanced_modules.py new file mode 100644 index 0000000..c1530eb --- /dev/null +++ b/DeepACSA/gui_modules/advanced_modules.py @@ -0,0 +1,1139 @@ +""" """ + +import os +import customtkinter as ctk +from CTkToolTip import * +import math +import numpy as np +import pandas as pd +from tkinter import ttk, W, E, N, S, StringVar, BooleanVar, filedialog, Canvas +from DeepACSA import gui_helpers +import tkinter as tk +import cv2 +from PIL import Image, ImageTk +from threading import Lock, Thread + + +class AdvancedAnalysis: + """ + Function to open a toplevel where masks + can either be created for training purposes or + can be inspected subsequent to labelling. + """ + + def __init__(self, parent): + + self.parent = parent + # self.parent.load_settings() + + # set up threading + self._lock = Lock() + self._is_running = False + self._should_stop = False + + # Open Window + self.advanced_window = ctk.CTkToplevel(fg_color="#2A484E") + self.advanced_window.title("Advanced Methods Window") + + head_path = os.path.dirname(os.path.abspath(__file__)) + iconpath = head_path + "/gui_helpers/home_im.ico" + # self.advanced_window.iconbitmap(iconpath) + + # if platform.startswith("win"): + # self.a_window.after(200, lambda: self.a_window.iconbitmap(iconpath)) + + # Configure resizing of user interface + for row in range(21): + self.advanced_window.rowconfigure(row, weight=1) + # for column in range(4): + # self.advanced_window.rowconfigure(column, weight=1) + + self.advanced_window.columnconfigure(0, weight=1) + self.advanced_window.columnconfigure(1, weight=5) + self.advanced_window.minsize(width=400, height=100) + + self.advanced_window.grab_set() + + ctk.CTkLabel(self.advanced_window, text="Select Method").grid(column=1, row=0) + + # Mask Option + self.advanced_option = StringVar() + advanced_entry = [ + "Train Model", + "Inspect Masks", + "Remove Image Parts", + "Create Masks", + ] + advanced_entry = ctk.CTkComboBox( + self.advanced_window, + width=20, + variable=self.advanced_option, + state="readonly", + values=advanced_entry, + ) + advanced_entry.grid(column=1, row=1, sticky=(W, E)) + self.advanced_option.set("...") + self.advanced_option.trace_add("write", self.on_mask_change) + + # Add padding + for child in self.advanced_window.winfo_children(): + child.grid_configure(padx=5, pady=5) + + # Initialize video-related attributes + self.video_path = None + self.processed_frames = [] + self.current_frame_index = 0 + + def on_mask_change(self, *args): + """ + Depending on which mask opration is selected, + this function adapts the GUI. + + Instance method to open new window for model training. + The window is opened upon pressing of the "analysis parameters" + button. + + Several parameters are displayed. + - Image Directory: + The user must select or input the image directory. This + path must to the directory containing the training images. + Images must be in RGB format. + - Mask Directory: + The user must select or input the mask directory. This + path must to the directory containing the training images. + Masks must be binary. + - Output Directory: + The user must select or input the mask directory. This + path must lead to the directory where the trained model + and the model weights should be saved. + - Batch Size: + The user must input the batch size used during model training by + selecting from the dropdown list or entering a value. + Although a larger batch size has advantages during model trainig, + the images used here are large. Thus, the larger the batch size, + the more compute power is needed or the longer the training duration. + Integer, must be non-negative and non-zero. + - Learning Rate: + The user must enter the learning rate used for model training by + selecting from the dropdown list or entering a value. + Float, must be non-negative and non-zero. + - Epochs: + The user must enter the number of Epochs used during model training by + selecting from the dropdown list or entering a value. + The total amount of epochs will only be used if early stopping does not happen. + Integer, must be non-negative and non-zero. + - Loss Function: + The user must enter the loss function used for model training by + selecting from the dropdown list. These can be "BCE" (binary + cross-entropy), "Dice" (Dice coefficient) or "FL"(Focal loss). + + Model training is started by pressing the "start training" button. Although + all parameters relevant for model training can be adapted, we advise users with + limited experience to keep the pre-defined settings. These settings are best + practice and devised from the original papers that proposed the models used + here. Singularly the batch size should be adapted to 1 if comupte power is limited + (no GPU or GPU with RAM lower than 8 gigabyte). + + There is an "Augment Images" button, which allows to generate new training images. + The images and masks for the data augmentation are taken from the chosen image directory + and mask directory. The new images are saved under the same directories. + """ + try: + + # Clear the existing frame if it exists + if hasattr(self, "advanced_window_frame"): + for widget in self.advanced_window_frame.winfo_children(): + widget.destroy() + + self.advanced_window_frame = ctk.CTkFrame(self.advanced_window) + self.advanced_window_frame.grid(column=1, row=2, sticky=(N, S, W, E)) + self.advanced_window_frame.rowconfigure(0, weight=1) # or appropriate row + self.advanced_window_frame.columnconfigure(0, weight=1) # or appropriate column + + # Set the desired window size + self.desired_width = 1200 + self.desired_height = 800 + + if self.advanced_option.get() == "Inspect Masks": + + if hasattr(self, "mask_button"): + self.mask_button.destroy() + + # Train image directory + self.raw_image_dir = StringVar() + dir1_button = ctk.CTkButton( + self.advanced_window_frame, + text="Image Dir", + command=lambda: (self.raw_image_dir.set(filedialog.askdirectory())), + ) + dir1_button.grid(column=0, row=2, sticky=(W, E)) + + # Mask directory + self.mask_image_dir = StringVar() + self.dir2_button = ctk.CTkButton( + self.advanced_window_frame, + text="Mask Dir", + command=lambda: ( + self.mask_image_dir.set(filedialog.askdirectory()) + ), + ) + self.dir2_button.grid(column=1, row=2, sticky=(W, E)) + + # Start index + self.start_label = ctk.CTkLabel( + self.advanced_window_frame, text="Start at Image:" + ) + self.start_label.grid(column=0, row=4, sticky=W) + start_idx = StringVar() + self.idx = ctk.CTkEntry( + self.advanced_window_frame, width=10, textvariable=start_idx + ) + self.idx.grid(column=1, row=4, sticky=W) + start_idx.set("0") + + ttk.Separator(self.advanced_window_frame, orient="horizontal").grid( + column=1, row=5, columnspan=4 + ) + + # Inspect button + self.inspect_button = ctk.CTkButton( + self.advanced_window_frame, + text="Inspect Masks", + command=lambda: ( + self.advanced_window.destroy(), + gui_helpers.find_outliers( + dir1=self.raw_image_dir.get(), + dir2=self.mask_image_dir.get(), + ), + gui_helpers.overlay_directory_images( + image_dir=self.raw_image_dir.get(), + mask_dir=self.mask_image_dir.get(), + start_index=int(start_idx.get()), + ), + ), + ) + self.inspect_button.grid(column=1, row=6, sticky=(W, E)) + + elif self.advanced_option.get() == "Create Masks": + + # Forget widgets + if hasattr(self, "train_image_dir"): + for widget in self.advanced_window_frame.winfo_children(): + widget.destroy() + + if hasattr(self, "dir2_button"): + self.dir2_button.destroy() + #self.mask_image_entry.destroy() + self.inspect_button.destroy() + self.idx.destroy() + self.start_label.destroy() + + # Train image directory + self.raw_image_dir = StringVar() + # keep references so we can remove them later + self.image_entry = ctk.CTkEntry( + self.advanced_window_frame, + width=100, + textvariable=self.raw_image_dir, + ) + self.image_entry.grid(column=0, row=0, columnspan=2, sticky=(W, E)) + self.raw_image_dir.set("Select Input Image Directory") + + self.dir1_button = ctk.CTkButton( + self.advanced_window_frame, + text="Image Dir", + command=lambda: (self.raw_image_dir.set(filedialog.askdirectory())), + ) + self.dir1_button.grid(column=3, row=0, sticky=(W, E)) + + self.mask_button = ctk.CTkButton( + self.advanced_window_frame, + text="Create Masks", + command=self._start_mask_interaction, + ) + self.mask_button.grid(column=3, row=1, sticky=(W, E)) + # tooltip_remove = CTkToolTip( + # self.mask_button, + # message="Click to start manual analysis/labelling. \nThis creates binary masks of the selected area. \nResulting masks and images can be used for model training.", + # delay=0.5, + # bg_color="#A8D8CD", + # text_color="#000000", + # alpha=0.7, + # ) + + elif self.advanced_option.get() == "Train Model": + # Labels + ctk.CTkLabel( + self.advanced_window_frame, + text="Training Directories", + font=("Verdana", 20), + ).grid(column=1, row=0, padx=10) + + # Train image button + train_img_button = ctk.CTkButton( + self.advanced_window_frame, + text="Images", + command=self.get_train_dir, + ) + train_img_button.grid(column=0, row=2, sticky=E) + + # Mask button + mask_button = ctk.CTkButton( + self.advanced_window_frame, text="Masks", command=self.get_mask_dir + ) + mask_button.grid(column=1, row=2, sticky=(W, E)) + + # Input directory + out_button = ctk.CTkButton( + self.advanced_window_frame, + text="Output", + command=self.get_output_dir, + ) + out_button.grid(column=2, row=2, sticky=W) + + ttk.Separator( + self.advanced_window_frame, orient="horizontal", style="TSeparator" + ).grid(column=0, row=5, columnspan=9, sticky=(W, E)) + + # Hyperparameters + ctk.CTkLabel( + self.advanced_window_frame, + text="Hyperparameters", + font=("Verdana", 20), + ).grid(column=0, row=6, padx=10) + + # Batch Size Label + ctk.CTkLabel(self.advanced_window_frame, text="Batch Size").grid( + column=0, row=7 + ) + # Batch size Combobox + self.batch_size = StringVar() + size = ["1", "2", "3", "4", "5", "6"] + size_entry = ctk.CTkComboBox( + self.advanced_window_frame, + width=10, + variable=self.batch_size, + values=size, + ) + size_entry.grid(column=1, row=7, sticky=(W, E)) + self.batch_size.set("1") + + # Learning Rate Label + ctk.CTkLabel(self.advanced_window_frame, text="Learning Rate").grid( + column=0, row=8 + ) + # Learning Rate Combobox + self.learn_rate = StringVar() + learn = ["0.005", "0.001", "0.0005", "0.0001", "0.00005", "0.00001"] + learn_entry = ctk.CTkComboBox( + self.advanced_window_frame, + width=10, + variable=self.learn_rate, + values=learn, + ) + learn_entry.grid(column=1, row=8, sticky=(W, E)) + self.learn_rate.set("0.00001") + + # Epochs Label + ctk.CTkLabel(self.advanced_window_frame, text="Epochs").grid( + column=0, row=9 + ) + # Number of training epochs + self.epochs = StringVar() + epoch = ["30", "40", "50", "60", "70", "80"] + epoch_entry = ctk.CTkComboBox( + self.advanced_window_frame, + width=10, + variable=self.epochs, + values=epoch, + ) + epoch_entry.grid(column=1, row=9, sticky=(W, E)) + self.epochs.set("3") + + # Loss function Label + ctk.CTkLabel(self.advanced_window_frame, text="Loss Function").grid( + column=0, row=10 + ) + # Loss function Combobox + self.loss_function = StringVar() + loss_entry = ctk.CTkComboBox( + self.advanced_window_frame, + width=10, + variable=self.loss_function, + values=["BCE"], + ) + loss_entry["state"] = "readonly" + loss_entry.grid(column=1, row=10, sticky=(W, E)) + self.loss_function.set("BCE") + + ttk.Separator( + self.advanced_window_frame, orient="horizontal", style="TSeparator" + ).grid(column=0, row=11, columnspan=9, sticky=(W, E)) + + # Data augmentation button + data_augmentation_button = ctk.CTkButton( + self.advanced_window_frame, + text="Augment Images", + command=self.augment_images, + ) + data_augmentation_button.grid(column=1, row=12, sticky=(W, E)) + + # Augmentation Warning + ctk.CTkLabel( + self.advanced_window_frame, + text="*Use augmentation only with \nbacked-up original images*", + font=("Segue UI", 8, "bold"), + text_color="#000000", + ).grid(column=0, row=12, sticky=E) + + # Model train button + self.model_button = ctk.CTkButton( + self.advanced_window_frame, + text="Start Training", + command=self.train_model, + ) + self.model_button.grid(column=2, row=12, sticky=E) + + elif self.advanced_option.get() == "Remove Image Parts": + + ctk.CTkButton( + self.advanced_window_frame, + text="Load Image", + command=self.load_image, + ).grid(column=1, row=0, columnspan=3, sticky=W) + + # Add padding + for child in self.advanced_window_frame.winfo_children(): + child.grid_configure(padx=5, pady=5) + + except FileNotFoundError: + tk.messagebox.showerror("Information", "Enter the correct folder path!") + + # Add padding + for child in self.advanced_window_frame.winfo_children(): + child.grid_configure(padx=5, pady=5) + + def load_image(self): + """ + Loads all valid image files from a folder and displays the first one. + """ + folder_path = filedialog.askdirectory(title="Select Image Folder") + if not folder_path: + return + + self.image_input_dir = folder_path + valid_ext = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") + image_files = sorted( + [f for f in os.listdir(folder_path) if f.lower().endswith(valid_ext)] + ) + + if not image_files: + print("No valid image files found in folder.") + return + + self.processed_frames = [] + for filename in image_files: + img_path = os.path.join(folder_path, filename) + img = cv2.imread(img_path) + if img is not None: + self.processed_frames.append(img) + + if self.processed_frames: + print(f"Loaded {len(self.processed_frames)} images.") + self.display_frame(0) + # self.update_slider_range() + else: + print("No images could be read.") + + def display_frame(self, frame_index): + """ + Displays a specific frame on the canvas. + """ + frame = self.processed_frames[frame_index] + # keep original for zooming + self.current_image = frame.copy() + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Resize the frame to the desired dimensions + frame_resized = cv2.resize(frame_rgb, (self.desired_width, self.desired_height)) + + frame_image = Image.fromarray(frame_resized) + frame_tk = ImageTk.PhotoImage(image=frame_image) + + if not hasattr(self, "video_canvas"): + self.video_canvas = ctk.CTkLabel(self.advanced_window_frame, text="") + self.video_canvas.grid(column=0, row=0, columnspan=2, sticky=(W, E)) + + self.video_canvas.imgtk = frame_tk + self.video_canvas.configure(image=frame_tk) + + # Allow user to make a selection on the first frame + if frame_index == 0: + self.selection = None + # reset zoom state for new sequence + self.zoom_factor = 1.0 + self.min_zoom = 0.2 + self.max_zoom = 5.0 + + self.canvas = Canvas( + self.advanced_window_frame, + width=self.desired_width, + height=self.desired_height, + ) + self.canvas.create_image(0, 0, anchor=tk.NW, image=frame_tk) + self.canvas.grid(column=0, row=0, columnspan=2, sticky=(W, E)) + self.canvas.bind("", self.on_button_press) + self.canvas.bind("", self.on_mouse_drag) + self.canvas.bind("", self.on_button_release) + self.canvas.bind("", self.on_mouse_wheel) + else: + self.canvas.create_image(0, 0, anchor=tk.NW, image=frame_tk) + self.canvas.imgtk = frame_tk # Keep a reference to avoid garbage collection + + remove_button = ctk.CTkButton( + self.advanced_window_frame, + text="Remove Parts", + command=self.remove_image_parts, + ) + remove_button.grid(column=0, row=6, sticky=W, padx=5, pady=5) + + ctk.CTkLabel( + self.advanced_window_frame, + text="*Select region to be cropped with left mouse drag. \nSelect new region to remove old region. \nPress button to save the image. \nParts are removed from all images in the selected folder", + font=("Verdana", 10, "bold"), + text_color="#000000", + ).grid(column=1, row=6, columnspan=2, sticky=W) + + def on_button_press(self, event): + self.selection = (event.x, event.y, event.x, event.y) + + def on_mouse_drag(self, event): + x0, y0, _, _ = self.selection + self.selection = (x0, y0, event.x, event.y) + self.canvas.delete("selection") + self.canvas.create_rectangle( + x0, y0, event.x, event.y, outline="red", tag="selection" + ) + + def on_button_release(self, event): + x0, y0, x1, y1 = self.selection + self.selection = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + def on_mouse_wheel(self, event): + """Zoom in/out when the user scrolls the mouse wheel. + + The handler works for both mask-creation and remove-parts canvases. It + resizes the displayed image according to ``self.zoom_factor`` and scales + any existing selection or calibration/polygon points so that they stay + at the correct relative positions. A small range limit is applied to + avoid excessive zooming. + """ + + # determine zoom direction (Windows sends delta multiples of 120) + if event.delta > 0: + factor = 1.1 + else: + factor = 1.0 / 1.1 + + new_zoom = self.zoom_factor * factor + # clamp zoom level + new_zoom = max(self.min_zoom, min(self.max_zoom, new_zoom)) + # recalculated factor actually applied + factor = new_zoom / self.zoom_factor + self.zoom_factor = new_zoom + + # resize the underlying image + display_w = int(self.desired_width * self.zoom_factor) + display_h = int(self.desired_height * self.zoom_factor) + frame_rgb = cv2.cvtColor(self.current_image, cv2.COLOR_BGR2RGB) + frame_resized = cv2.resize(frame_rgb, (display_w, display_h)) + self.frame_image = ImageTk.PhotoImage(Image.fromarray(frame_resized)) + + # update canvas dimensions and image + self.canvas.config(width=display_w, height=display_h) + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.frame_image) + self.canvas.imgtk = self.frame_image + + # scale any existing annotations so they remain in place + # selection rectangle (used by remove-parts workflow) + if hasattr(self, "selection") and self.selection is not None: + x0, y0, x1, y1 = self.selection + self.selection = ( + x0 * factor, + y0 * factor, + x1 * factor, + y1 * factor, + ) + # calibration/polygon points (used during mask creation) + if hasattr(self, "calibration_points"): + self.calibration_points = [ + (x * factor, y * factor) for x, y in self.calibration_points + ] + if hasattr(self, "mask_polygon_points"): + self.mask_polygon_points = [ + (x * factor, y * factor) for x, y in self.mask_polygon_points + ] + + # redraw overlays (points, rectangle) after scaling + self.canvas.delete("all") + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.frame_image) + # redraw calibration points if present + if hasattr(self, "calibration_points"): + for x, y in self.calibration_points: + self.canvas.create_oval( + x - 4, + y - 4, + x + 4, + y + 4, + outline="red", + fill="white", + ) + # redraw polygon points if present + if hasattr(self, "mask_polygon_points"): + for x, y in self.mask_polygon_points: + self.canvas.create_oval( + x - 4, + y - 4, + x + 4, + y + 4, + outline="yellow", + fill="green", + ) + # redraw selection rectangle if present + if hasattr(self, "selection") and self.selection is not None: + x0, y0, x1, y1 = self.selection + self.canvas.create_rectangle( + x0, y0, x1, y1, outline="red", tag="selection" + ) + def remove_image_parts(self): + """ + Removes the selected rectangular region from all images in a folder + and saves them with the same filenames in the selected output path. + """ + input_path = self.image_input_dir + output_path = input_path + "/processed" + os.makedirs(output_path, exist_ok=True) + + if not input_path or not output_path: + print("No input or output path specified.") + return + + if not hasattr(self, "selection") or self.selection is None: + print("No selection made.") + return + + try: + x0, y0, x1, y1 = self.selection + + # Supported image extensions + valid_ext = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff") + image_files = [ + f for f in os.listdir(input_path) if f.lower().endswith(valid_ext) + ] + + if not image_files: + print("No images found in the folder.") + return + + for filename in image_files: + image_path = os.path.join(input_path, filename) + img = cv2.imread(image_path) + + if img is None: + print(f"Warning: Failed to load {filename}. Skipping.") + continue + + # Resize to expected size (optional, if needed) + if hasattr(self, "desired_width") and hasattr(self, "desired_height"): + img = cv2.resize(img, (self.desired_width, self.desired_height)) + + # Black out the selected region + img[y0:y1, x0:x1] = 0 + + # Save result + save_path = os.path.join(output_path, filename) + cv2.imwrite(save_path, img) + + ctk.CTkLabel( + self.advanced_window_frame, + text=f"Masked image saved to\n{save_path}", + font=("Verdana", 10, "italic"), + ).grid(column=0, row=7, columnspan=2, sticky=(W, E)) + + print(f"Masked images saved to: {output_path}") + + except Exception as e: + print(f"Error during image processing: {str(e)}") + + def select_area_tk(self, image, callback): + """ + Interactive area and calibration selection directly in the Tkinter canvas. + First 2 clicks define the calibration (1 cm), remaining clicks define a polygon. + The area mask is passed to the callback once complete. + """ + + self.calibration_points = [] + self.mask_polygon_points = [] + self.calib_dist = None + self.current_image = image.copy() + self.drawing_polygon = True + self.callback = callback + + frame_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + frame_resized = cv2.resize(frame_rgb, (self.desired_width, self.desired_height)) + self.frame_image = ImageTk.PhotoImage(Image.fromarray(frame_resized)) + + self.instruction_label = ctk.CTkLabel( + self.advanced_window_frame, + text="Click 2 points that are exactly 1 cm apart (for calibration).\nRight-click = undo last point & Scroll = zoom.", + bg_color="#aac3c3", + font=("Segoe UI", 14, "bold"), + ) + self.instruction_label.grid( + column=0, row=2, columnspan=4, sticky=(W, E), pady=(5, 2) + ) + + self.canvas = Canvas( + self.advanced_window_frame, + width=self.desired_width, + height=self.desired_height, + cursor="crosshair", + ) + self.canvas.grid(column=0, row=1, columnspan=2, sticky=(W,E)) + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.frame_image) + + # zoom support + self.zoom_factor = 1.0 + self.min_zoom = 0.2 + self.max_zoom = 5.0 + + self.canvas.bind("", self._on_area_click) + self.canvas.bind("", self._on_right_click) + self.canvas.bind("", self.on_mouse_wheel) + self.advanced_window_frame.master.bind("", self._on_confirm_polygon) + + def _on_area_click(self, event): + """ + Handle left mouse click to select points for calibration or polygon. + """ + x, y = event.x, event.y + if len(self.calibration_points) < 2: + + self.calibration_points.append((x, y)) + self.canvas.create_oval( + x - 4, y - 4, x + 4, y + 4, outline="red", fill="white" + ) + if len(self.calibration_points) == 2: + # dx = self.calibration_points[1][0] - self.calibration_points[0][0] + # dy = self.calibration_points[1][1] - self.calibration_points[0][1] + # self.calib_dist = math.sqrt(dx**2 + dy**2) + (x1, y1), (x2, y2) = self.calibration_points + + display_w = self.frame_image.width() + display_h = self.frame_image.height() + h, w = self.current_image.shape[:2] + + scale_x = w / display_w + scale_y = h / display_h + + x1o, y1o = x1 * scale_x, y1 * scale_y + x2o, y2o = x2 * scale_x, y2 * scale_y + + self.calib_dist = float(np.hypot(x2o - x1o, y2o - y1o)) + self.instruction_label.configure( + text=f"Calibrated: 1 cm = {self.calib_dist:.1f} px. Now click to outline the area with left-click. \nRight-click = undo last point, Scroll = zoom, Enter = confirm selection." + ) + print(f"Calibrated: 1 cm = {self.calib_dist:.1f} px") + else: + self.mask_polygon_points.append((x, y)) + self.canvas.create_oval( + x - 4, y - 4, x + 4, y + 4, outline="yellow", fill="green" + ) + + def _on_right_click(self, event): + """ + Undo the last point clicked (either calibration or polygon point). + """ + if len(self.calibration_points) < 2: + self.instruction_label.configure( + text="Click 2 points that are exactly 1 cm apart (for calibration).\nRight-click = undo last point, Scroll = zoom, ENTER = confirm." + ) + self.calibration_points.pop() + self.canvas.delete("all") + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.frame_image) + for x, y in self.calibration_points: + self.canvas.create_oval( + x - 4, y - 4, x + 4, y + 4, outline="red", fill="white" + ) + else: + self.instruction_label.configure( + text="Click to outline area.\nRight-click = undo last point, Scroll = zoom, ENTER = confirm." + ) + + if self.mask_polygon_points: + self.mask_polygon_points.pop() + self.canvas.delete("all") + self.canvas.create_image(0, 0, anchor=tk.NW, image=self.frame_image) + # Redraw calibration points + for x, y in self.calibration_points: + self.canvas.create_oval( + x - 4, y - 4, x + 4, y + 4, outline="red", fill="white" + ) + # Redraw polygon points + for x, y in self.mask_polygon_points: + self.canvas.create_oval( + x - 4, y - 4, x + 4, y + 4, outline="yellow", fill="green" + ) + + def _on_confirm_polygon(self, event): + """ + Confirm the polygon selection and calculate the area. + """ + if self.calib_dist is None or len(self.mask_polygon_points) < 3: + self.instruction_label.configure( + text="Need 2 calibration points and 3+ polygon points to save mask.\nSelect more points.\nRight-click = undo last point, Scroll = zoom, ENTER = confirm." + ) + return + + h, w = self.current_image.shape[:2] + display_w = self.frame_image.width() + display_h = self.frame_image.height() + mask = np.zeros((h, w), dtype=np.uint8) + scale_x = self.current_image.shape[1] / display_w + scale_y = self.current_image.shape[0] / display_h + + points_scaled = [ + (int(x * scale_x), int(y * scale_y)) for x, y in self.mask_polygon_points + ] + cv2.fillPoly(mask, [np.array(points_scaled, dtype=np.int32)], 255) + mask = gui_helpers.fill_contour_mask(self.current_image.shape[:2], np.array(points_scaled)) + muscle_area = gui_helpers.area_cm2_from_mask(mask_u8=mask, px_per_cm=self.calib_dist) + + self.instruction_label.configure(text="Mask confirmed.") + self.canvas.destroy() + self.callback(mask, muscle_area) + + def _start_mask_interaction(self): + """ + Start the mask interaction process. + """ + # clean up the UI: hide controls no longer needed once canvas appears + if hasattr(self, "mask_button"): + self.mask_button.destroy() + if hasattr(self, "image_entry"): + self.image_entry.destroy() + if hasattr(self, "dir1_button"): + self.dir1_button.destroy() + + input_dir = self.raw_image_dir.get() + self.image_files = sorted( + [ + f + for f in os.listdir(input_dir) + if f.lower().endswith((".jpg", ".png", ".tif", ".jpeg")) + ] + ) + + if not self.image_files: + print("No images found.") + return + + # Create subfolders for output + self.image_output_dir = os.path.join(input_dir, "_images") + self.mask_output_dir = os.path.join(input_dir, "_masks") + os.makedirs(self.image_output_dir, exist_ok=True) + os.makedirs(self.mask_output_dir, exist_ok=True) + + self.area_results = [] # list of dicts: {"Filename": ..., "Area_cm2": ...} + self.results_csv_path = os.path.join(self.image_output_dir, "areas.csv") + + self.current_image_index = 0 + self._load_next_image() + + def _load_next_image(self): + """ + Load the next image from the list and display it. + """ + if self.current_image_index >= len(self.image_files): + print("All images processed.") + self.instruction_label.configure(text="All images processed.") + return + + filename = self.image_files[self.current_image_index] + image_path = os.path.join(self.raw_image_dir.get(), filename) + image = cv2.imread(image_path) + if image is None: + print(f"Skipping unreadable file: {filename}") + self.current_image_index += 1 + self._load_next_image() + return + + self.current_filename = filename + self.select_area_tk(image, callback=self._handle_mask_result) + + def _handle_mask_result(self, mask, area): + """ + Handle the mask result and save the image and mask. + """ + mask_path = os.path.join(self.mask_output_dir, self.current_filename) + image_path = os.path.join(self.image_output_dir, self.current_filename) + + # Save image and mask with the same name + full_image = self.current_image # Original image saved as well + cv2.imwrite(mask_path, mask) + cv2.imwrite(image_path, full_image) + + print(f"Saved: {self.current_filename} | Area: {area:.2f} cm²") + self.area_results.append( + {"Filename": self.current_filename, "Area_cm2": float(area)} + ) + + # write/update csv after each image so nothing is lost if user closes the window + pd.DataFrame(self.area_results).to_csv(self.results_csv_path, index=False) + self.current_image_index += 1 + self._load_next_image() + + def get_train_dir(self): + """ + Instance method to ask the user to select the training image + directory path. All image files (of the same specified filetype) in + the directory are analysed. This must be an absolute path. + """ + self.train_image_dir = filedialog.askdirectory() + ctk.CTkLabel( + self.advanced_window_frame, + text=f"Folder: {os.path.basename(self.train_image_dir)}", + font=("Segue UI", 8, "bold"), + ).grid(column=0, row=3) + + def get_mask_dir(self): + """ + Instance method to ask the user to select the training mask + directory path. All mask files (of the same specified filetype) in + the directory are analysed.The mask files and the corresponding + image must have the exact same name. This must be an absolute path. + """ + self.mask_dir = filedialog.askdirectory() + ctk.CTkLabel( + self.advanced_window_frame, + text=f"Folder: {os.path.basename(self.mask_dir)}", + font=("Segue UI", 8, "bold"), + ).grid(column=1, row=3) + + def get_output_dir(self): + """ + Instance method to ask the user to select the output + directory path. Here, all file created during model + training (model file, weight file, graphs) are saved. + This must be an absolute path. + """ + self.out_dir = filedialog.askdirectory() + ctk.CTkLabel( + self.advanced_window_frame, + text=f"Folder: {os.path.basename(self.out_dir)}", + font=("Segue UI", 8, "bold"), + ).grid(column=2, row=3) + + def train_model(self): + """ + Instance method to execute the model training when the + "start training" button is pressed. + + By pressing the button, a seperate thread is started + in which the model training is run. This allows the user to break any + training process at certain stages. When the analysis can be + interrupted, a tk.messagebox opens asking the user to either + continue or terminate the analysis. Moreover, the threading allows interaction + with the GUI during ongoing analysis process. + """ + try: + # See if GUI is already running + if self.is_running: + # don't run again if it is already running + return + self.is_running = True + + # configure stop button + self.model_button.configure(text="Stop Training", command=self.do_break) + + # Get input paremeter + selected_images = self.train_image_dir + selected_masks = self.mask_dir + selected_outpath = self.out_dir + + # Make sure some kind of filetype is specified. + if ( + len(selected_images) < 3 + or len(selected_masks) < 3 + or len(selected_outpath) < 3 + ): + tk.messagebox.showerror("Information", "Specified directories invalid.") + self.should_stop = False + self.is_running = False + self.do_break() + return + + selected_batch_size = int(self.batch_size.get()) + selected_learning_rate = float(self.learn_rate.get()) + selected_epochs = int(self.epochs.get()) + selected_loss_function = self.loss_function.get() + + # Start thread + thread = Thread( + target=gui_helpers.trainModel, + args=( + selected_images, + selected_masks, + selected_outpath, + selected_batch_size, + selected_learning_rate, + selected_epochs, + selected_loss_function, + self, + ), + ) + + thread.start() + + # Error handling + except ValueError: + tk.messagebox.showerror( + "Information", "Analysis parameter entry fields" + " must not be empty." + ) + self.do_break() + self.should_stop = False + self.is_running = False + + # Method used for data augmentation + def augment_images(self): + """ + Instance method to augment input images, when the "Augment Images" button is pressed. + Input parameters for the gui_helpers.image_augmentation function are taken from the chosen + image and mask directories. The newly generated data will be saved under the same + directories. + """ + try: + # See if GUI is already running + if self.is_running: + # don't run again if it is already running + return + self.is_running = True + + # Get input paremeters + selected_images = self.train_image_dir + selected_masks = self.mask_dir + + # Make sure some kind of filetype is specified. + if len(selected_images) < 3 or len(selected_masks) < 3: + tk.messagebox.showerror("Information", "Specified directories invalid.") + self.should_stop = False + self.is_running = False + self.do_break() + return + + gui_helpers.image_augmentation(selected_images, selected_masks, self) + + # Inform user in GUI + tk.messagebox.showinfo( + "Information", + "Data augmentation successful." + + "\nResults are saved to specified input paths.", + ) + + # Error handling + except ValueError: + tk.messagebox.showerror( + "Information", + "Check input parameters" + + "\nPotential error source: Invalid directories", + ) + self.do_break() + self.should_stop = False + self.is_running = False + + # ----------------------------------------------------------- + # --- Threading properties --- + + @property + def should_stop(self) -> bool: + """Instance method to define the should_stop + property getter method. By defining this as a property, + should_stop is treated like a public attribute even + though it is private. + + This is used to stop the analysis process running + in a seperate thread. + + Returns + ------- + should_stop : bool + Boolean variable to decide whether the analysis process + started from the GUI should be stopped. The process is + stopped when should_stop = True. + """ + self._lock.acquire() + # Get private variable _should_stop + should_stop = self._should_stop + self._lock.release() + return should_stop + + @property + def is_running(self) -> bool: + """Instance method to define the is_running + property getter method. By defining this as a property, + is_running is treated like a public attribute even + though it is private. + + This is used to stop the analysis process running + in a seperate thread. + + Returns + ------- + is_running : bool + Boolean variable to check whether the analysis process + started from the GUI is running. The process is only + stopped when is_running = True. + """ + self._lock.acquire() + is_running = self._is_running + self._lock.release() + return is_running + + @should_stop.setter + def should_stop(self, flag: bool): + """Instance method to define the should_stop + property setter method. The setter method is used + to set the self._should_stop attribute as if it was + a public attribute. The argument "flag" is thereby + validated to ensure proper input (boolean) + """ + self._lock.acquire() + self._should_stop = flag + self._lock.release() + + @is_running.setter + def is_running(self, flag: bool): + """Instance method to define the is_running + property setter method. The setter method is used + to set the self._is_running attribute as if it was + a public attribute. The argument "flag" is thereby + validated to ensure proper input (boolean) + """ + self._lock.acquire() + self._is_running = flag + self._lock.release() + + def do_break(self): + """Instance method to break the analysis process when the + button "break" is pressed. + + This changes the instance attribute self.should_stop + to True, given that the analysis is already running. + The attribute is checked befor every iteration + of the analysis process. + """ + + self.should_stop = True + self.is_running = False + + self.model_button.configure(text="Start Training", command=self.train_model) diff --git a/Deep_ACSA/__init__.py b/Deep_ACSA/__init__.py deleted file mode 100644 index b7c7008..0000000 --- a/Deep_ACSA/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" Container for all our package modules.""" - -__version__ = "0.3.1" - -_author__ = ["Paul Ritsche"] - -__all__ = ["deep_acsa_gui"] - -from Deep_ACSA.deep_acsa_gui import * diff --git a/Deep_ACSA/deep_acsa_gui.py b/Deep_ACSA/deep_acsa_gui.py deleted file mode 100644 index 06ad3ff..0000000 --- a/Deep_ACSA/deep_acsa_gui.py +++ /dev/null @@ -1,1254 +0,0 @@ -""" -Description ------------ -This module contains a class with methods to automatically and manually annotate -transversal ultrasonography images. When the class is initiated, -a graphical user interface is opened. This is the main GUI of the DeepACSA package. -From here, the user is able to navigate all functionalities of the package. -These extend the methods in this class. The main functionalities of the GUI contained -in this module are automatic and manual evalution of muscle ultrasonography images. -Inputted images are analyzed and the parameter muscle anatomical cross-sectional area is -returned for each image. -The parameters are analyzed using convolutional neural networks (U-net, VGG16). -This module and all submodules contained in the /gui_helpers modality are extensions -and improvements of the work presented in Ritsche et al. (2022). There, the core functionalities -of this code are already outlined and the comparability of the model segmentations to -manual analysis (current gold standard) is described. Here, we have improved the code. - -Functions scope ---------------- -For scope of the functions see class documentation. - -Notes ------ -Additional information and usage exaples can be found in the video -tutorials provided for this package. - -References ----------- -VGG16: Simonyan, K., and Zisserman, A. “Very deep convolutional networks for large-scale image recognition.” arXiv preprint arXiv:1409.1556 (2014) -U-net: Ronneberger, O., Fischer, P. and Brox, T. "U-Net: Convolutional Networks for Biomedical Image Segmentation." arXiv preprint arXiv:1505.04597 (2015) -DeepACSA: Ritsche, P., Wirth, P., Cronin, N., Sarto, F., Narici, M., Faude, O., Franchi, M. "DeepACSA: Automatic Segmentation of Cross-Sectional Area in Ultrasound Images of Lower Limb Muscles Using Deep Learning" (2022) -""" - -import os -import tkinter as tk -from threading import Lock, Thread -from tkinter import E, N, S, StringVar, Tk, W, filedialog, ttk - -import matplotlib - -from Deep_ACSA import gui_helpers - -matplotlib.use("TkAgg") - - -class DeepACSA: - """ - Python class to automatically or manually annotate tranversal muscle - ultrasonography images of human lower limb muscles. - An analysis tkinter GUI is opened upon initialization of the class. - By clicking the buttons, the user can switch between image analysis and model training. - The GUI consistsof the following elements. - - Input Directory: - By pressing the "Input" button, the user is asked to select - an input directory containing all images to be - analyzed. This can also be entered directly in the entry field. - - Apo Model Path: - By pressing the "Apo Model" button, the user is asked to select - the aponeurosis model used for aponeurosis segmentation. The absolute - model path can be entered directly in the enty field as well. - - Break: - By pressing the "break" button, the user is able to stop the analysis process - after each finished image or image frame analysis. - - Run: - By pressing the "run" button, the user can start the analysis process. - - Model training: - By pressing the "train model" button, a new window opens and the - user can train an own neural network based on existing/own - training data. Furthermore, the new window allows to augment input images through - data augmentation to generate new training images. - - Attributes - ---------- - self._lock : _thread.lock - Thread object to lock the self._lock variable for access by another thread. - self._is_running : bool, default = False - Boolen variable determining the active state - of the GUI. If False, the is not running. If True - the GUI is running. - self._should_stop : bool, default = False - Boolen variable determining the active state - of the GUI. If False, the is allowed to continue running. If True - the GUI is stopped and background processes deactivated. - self.main : tk.TK - tk.TK instance which is the base frame for the GUI. - self.input : tk.Stringvar - tk.Stringvariable containing the path to the input directory. - self.model : tk.Stringvar - tk.Stringvariable containing the path to the aponeurosis - model. - self.scaling : tk.Stringvar - tk.Stringvariable containing the selected scaling type. - This can be "bar", "manual" or "no scaling". - self.muscle_volume_calculation_wanted : tk.Stringvar - tk.Stringvariale defining whether the muscle volume - should be calculated. - This can be "yes" or "no". - self.filetype : tk.Stringvar - tk.Stringvariabel containing the selected filetype for - the images to be analyzed. The user can select from the - dopdown list or enter an own filetype. The formatting - should be kept constant. - self.depth : tk.Stringvar - tk.Stringvariable containing the selected image depth. - This must be according to the image depth used during - image acquisition. - self.spacing : tk.Stringvar - tk.Stringvariable containing the selected spacing distance - used for computation of pixel / cm ratio. This must only be - specified when the analysis type "bar" or "manual" is selected. - self.distance : tk.Stringvar - tk.Stringvariable containing the space between acquired images - along one muscle. The space must be given in mm. - self.train_image_dir : tk.Stringvar - tk.Straingvar containing the path to the directory of the training images. - Image must be in RGB format. - self.mask_dir : tk.Stringvar - tk.Stringvariable containing the path to the directory of the mask images. - Masks must be binary. - self.out_dir : tk.Stringvar - tk.Stringvariable containing the path to the directory where the trained model - should be saved. - self.batch_size : tk.Stringvar - tk.Stringvariable containing the batch size per iteration through the - network during model training. Must be non-negative and non-zero. - self.learning_rate : tk.Stringvariable - tk.Stringvariable the learning rate used during model training. - Must be non-negative and non-zero. - self.epochs : tk.Stringvar - tk.Straingvariable containing the amount of epochs that the model - is trained befor training is aborted. Must be non-negative and non-zero. - self.loss : tk.Stringvar - tk.Stringvariable containing the loss function that is used during training. - - Methods - ------- - get_root_dir - Instance method to ask the user to select the input directory. - get_model_path - Instance method to ask the user to select the apo model path. - run_code - Instance method to execute the analysis process when the - "run" button is pressed. - do_break - Instance method to break the analysis process when the - button "break" is pressed. - train_model_window - Instance method to open new window for model training. - get_train_dir - Instance method to ask the user to select the training image - directory path. - get_mask_dir - Instance method to ask the user to select the training mask - directory path. - get_output_dir - Instance method to ask the user to select the output - directory path. - train_model - Instance method to execute the model training when the - "start training" button is pressed. - augment_images - Instance method to augment input images and masks, - when the "Augment Images" button is pressed. - - Notes - ----- - This class contains only instance attributes. - The instance methods contained in this class are solely purposed for - support of the main GUI instance method. They cannot be used - independantly or seperately. - - For more detailed documentation of the functions employed - in this GUI upon running the analysis or starting model training - see the respective modules in the /gui_helpers subfolder. - - See Also - -------- - model_training.py, predict_muscle_area.py - """ - - def __init__(self, root): - """Initialize the DeepACSA GUI application. - - Parameters - ---------- - root (Tk): The Tkinter root window. - - Returns - ------- - None - """ - - # set up threading - self._lock = Lock() - self._is_running = False - self._should_stop = False - - # set up gui - root.title("DeepACSA") - master_path = os.path.dirname(os.path.abspath(__file__)) - iconpath = master_path + "/gui_helpers/icon.ico" - root.iconbitmap(iconpath) - - self.main = ttk.Frame(root, padding="10 10 12 12") - self.main.grid(column=0, row=0, sticky=(N, S, W, E)) - # Configure resizing of user interface - self.main.columnconfigure(0, weight=1) - self.main.columnconfigure(1, weight=1) - self.main.columnconfigure(2, weight=1) - self.main.columnconfigure(3, weight=1) - self.main.columnconfigure(4, weight=1) - self.main.columnconfigure(5, weight=1) - root.columnconfigure(0, weight=1) - root.rowconfigure(0, weight=1) - - # Style - style = ttk.Style() - style.theme_use("clam") - style.configure("TFrame", background="#7ABAA1") - style.configure( - "TLabel", - font=("Lucida Sans", 12), - foreground="black", - background="#7ABAA1", - ) - style.configure( - "TRadiobutton", - background="#7ABAA1", - foreground="black", - font=("Lucida Sans", 12), - ) - style.configure( - "TButton", - background="#EADCC3", - foreground="black", - font=("Lucida Sans", 11), - ) - style.configure( - "TEntry", font=("Lucida Sans", 12), background="linen", foregrund="black" - ) - style.configure("TSeparator", background="linen", foreground="linen") - style.configure("TCombobox", background="#7ABAA1", foreground="black") - - # Paths - # Input directory - self.input = StringVar() - input_entry = ttk.Entry(self.main, width=30, textvariable=self.input) - input_entry.grid(column=2, row=2, columnspan=3, sticky=(W, E)) - # self.input.set("Desktop/DeepACSA/") - # Model path - self.model = StringVar() - model_entry = ttk.Entry(self.main, width=30, textvariable=self.model) - model_entry.grid(column=2, row=3, columnspan=3, sticky=(W, E)) - # self.model.set("C:/Users/admin/Documents/DeepACSA/notebooks/VGG16pre-VL-256.h5") - - # Radiobuttons - # Image Type - self.scaling = StringVar() - efov = ttk.Radiobutton( - self.main, text="Line", variable=self.scaling, value="Line" - ) - efov.grid(column=2, row=7, sticky=W) - static = ttk.Radiobutton( - self.main, text="Bar", variable=self.scaling, value="Bar" - ) - static.grid(column=3, row=7, sticky=(W, E)) - manual = ttk.Radiobutton( - self.main, text="Manual", variable=self.scaling, value="Manual" - ) - manual.grid(column=4, row=7, sticky=E) - self.scaling.set("Bar") - self.scaling.trace("w", self.on_scaling_change) - - # Volume Calculation - self.muscle_volume_calculation_wanted = StringVar() - yes_volume = ttk.Radiobutton( - self.main, - text="Yes", - variable=self.muscle_volume_calculation_wanted, - value="Yes", - ) - yes_volume.grid(column=2, row=14, sticky=W) - no_volume = ttk.Radiobutton( - self.main, - text="No", - variable=self.muscle_volume_calculation_wanted, - value="No", - ) - no_volume.grid(column=3, row=14, sticky=(W, E)) - self.muscle_volume_calculation_wanted.set("No") - self.muscle_volume_calculation_wanted.trace("w", self.on_volume_change) - - # Comboboxes - # Loss Function - self.loss_function = StringVar() - loss = ("IoU", "Dice Loss", "Focal Loss") - loss_entry = ttk.Combobox(self.main, width=15, textvariable=self.loss_function) - loss_entry["values"] = loss - loss_entry["state"] = "readonly" - loss_entry.grid(column=4, row=4, sticky=E) - self.loss_function.set("Loss Function") - - # Muscles - self.muscle = StringVar() - muscle = ("VL", "RF", "GM", "GL", "BF") - muscle_entry = ttk.Combobox(self.main, width=10, textvariable=self.muscle) - muscle_entry["values"] = muscle - muscle_entry["state"] = "readonly" - muscle_entry.grid(column=2, row=8, sticky=(W, E)) - self.muscle.set("RF") - - # Buttons - # Input directory - input_button = ttk.Button(self.main, text="Input", command=self.get_root_dir) - input_button.grid(column=5, row=2, sticky=E) - - # Model path button - model_button = ttk.Button(self.main, text="Model", command=self.get_model_path) - model_button.grid(column=5, row=3, sticky=E) - - # Break Button - break_button = ttk.Button(self.main, text="Break", command=self.do_break) - break_button.grid(column=1, row=17, sticky=W) - - # Run Button - run_button = ttk.Button(self.main, text="Run", command=self.run_code) - run_button.grid(column=2, row=17, sticky=(W, E)) - - # Advanced button with style - style.configure( - "B.TButton", - background="black", - foreground="white", - font=("Lucida Sans", 11), - ) - advanced_button = ttk.Button( - self.main, - text="Advanced Methods", - command=self.advanced_methods, - style="B.TButton", - ) - advanced_button.grid(column=5, row=17, sticky=E) - - # Labels - ttk.Label(self.main, text="Directories", font=("Verdana", 14)).grid( - column=1, row=1, sticky=W - ) - ttk.Label(self.main, text="Root Directory").grid(column=1, row=2) - ttk.Label(self.main, text="Model Path").grid(column=1, row=3) - ttk.Label(self.main, text="Loss Function").grid(column=1, row=4) - ttk.Label(self.main, text="Image Properties", font=("Verdana", 14)).grid( - column=1, row=6, sticky=W - ) - ttk.Label(self.main, text="Scaling Type").grid(column=1, row=7) - ttk.Label(self.main, text="Muscle").grid(column=1, row=8) - ttk.Label(self.main, text="Muscle Volume", font=("Verdana", 14)).grid( - column=1, row=13, sticky=W - ) - ttk.Label(self.main, text="Volume Calculation").grid(column=1, row=14) - - # Separators - ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( - column=0, row=5, columnspan=9, sticky=(W, E) - ) - ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( - column=0, row=12, columnspan=9, sticky=(W, E) - ) - ttk.Separator(self.main, orient="horizontal", style="TSeparator").grid( - column=0, row=16, columnspan=9, sticky=(W, E) - ) - - for child in self.main.winfo_children(): - child.grid_configure(padx=5, pady=5) - - root.bind("", self.run_code) # execute by pressing return - - # -------------------------------------------------------------------------------------------------- - # Functionalities used in GUI - - def get_root_dir(self): - """ - Instance method to ask the user to select the input directory. - All image files (of the same specified filetype) in - the input directory are analysed. - """ - root_dir = filedialog.askdirectory() - self.input.set(root_dir) - return root_dir - - def get_model_path(self): - """ - Instance method to ask the user to select the apo model path. - This must be an absolute path and the model must be a .h5 file. - """ - model_dir = filedialog.askopenfilename() - self.model.set(model_dir) - return model_dir - - def on_volume_change(self, *args): - """ - Instance method to adpat GUI layout based on volume - calculation selection. - """ - if self.muscle_volume_calculation_wanted.get() == "Yes": - # Distance between ACSA for Volume Calculation - self.volume_label = ttk.Label(self.main, text="Slice Distance (cm)") - self.volume_label.grid(column=1, row=15) - - self.distance = StringVar() - self.distance_entry = ttk.Entry( - self.main, width=10, textvariable=self.distance - ) - self.distance_entry.grid(column=2, row=15, sticky=(W, E)) - self.distance.set(7) - - if self.muscle_volume_calculation_wanted.get() == "No": - # Destroy widget on selection - if hasattr(self, "distance"): - self.distance_entry.grid_remove() - self.volume_label.grid_remove() - - def on_scaling_change(self, *args): - """ - Instance method to adpat GUI layout based on scaling - calculation selection. - """ - if self.scaling.get() == "Bar": - # Spacing - self.spacing_label = ttk.Label(self.main, text="Spacing (mm)") - self.spacing_label.grid(column=1, row=10) - self.spacing = StringVar() - spacing = (5, 10, 15, 20) - self.spacing_entry = ttk.Combobox( - self.main, width=10, textvariable=self.spacing - ) - self.spacing_entry["values"] = spacing - self.spacing_entry["state"] = "readonly" - self.spacing_entry.grid(column=2, row=10, sticky=(W, E)) - self.spacing.set(10) - - if hasattr(self, "depth"): - self.depth_label.grid_remove() - self.depth_entry.grid_remove() - - elif self.scaling.get() == "Line": - # Image Depth - self.depth_label = ttk.Label(self.main, text="Depth (cm)") - self.depth_label.grid(column=1, row=9) - self.depth = StringVar() - depth = (2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8) - self.depth_entry = ttk.Combobox( - self.main, width=10, textvariable=self.depth - ) - self.depth_entry["values"] = depth - # depth_entry["state"] = "readonly" - self.depth_entry.grid(column=2, row=9, sticky=(W, E)) - self.depth.set(5.5) - - if hasattr(self, "spacing"): - self.spacing_label.grid_remove() - self.spacing_entry.grid_remove() - - elif self.scaling.get() == "Manual": - - if hasattr(self, "spacing"): - self.spacing_label.grid_remove() - self.spacing_entry.grid_remove() - - if hasattr(self, "depth"): - self.depth_label.grid_remove() - self.depth_entry.grid_remove() - - # --------------------------------------------------------------------------------------------------- - # Open new toplevel instance for advanced methods - def advanced_methods(self): - """ - Function to open a toplevel where masks - can either be created for training purposes or - can be inspected subsequent to labelling. - """ - # Open Window - self.advanced_window = tk.Toplevel(bg="#7ABAA1") - self.advanced_window.title("Advanced Methods Window") - master_path = os.path.dirname(os.path.abspath(__file__)) - iconpath = master_path + "/gui_helpers/icon.ico" - self.advanced_window.iconbitmap(iconpath) - self.advanced_window.grab_set() - - ttk.Label(self.advanced_window, text="Select Method").grid(column=1, row=0) - - # Mask Option - self.advanced_option = StringVar() - advanced_entry = ttk.Combobox( - self.advanced_window, width=20, textvariable=self.advanced_option - ) - advanced_entry["values"] = ("Train Model", "Create Masks", "Inspect Masks") - advanced_entry["state"] = "readonly" - advanced_entry.grid(column=1, row=1, sticky=(W, E)) - self.advanced_option.set(" ") - self.advanced_option.trace("w", self.on_mask_change) - - # Add padding - for child in self.advanced_window.winfo_children(): - child.grid_configure(padx=5, pady=5) - - def on_mask_change(self, *args): - """ - Depending on which mask opration is selected, - this function adapts the GUI. - - Instance method to open new window for model training. - The window is opened upon pressing of the "analysis parameters" - button. - - Several parameters are displayed. - - Image Directory: - The user must select or input the image directory. This - path must to the directory containing the training images. - Images must be in RGB format. - - Mask Directory: - The user must select or input the mask directory. This - path must to the directory containing the training images. - Masks must be binary. - - Output Directory: - The user must select or input the mask directory. This - path must lead to the directory where the trained model - and the model weights should be saved. - - Batch Size: - The user must input the batch size used during model training by - selecting from the dropdown list or entering a value. - Although a larger batch size has advantages during model trainig, - the images used here are large. Thus, the larger the batch size, - the more compute power is needed or the longer the training duration. - Integer, must be non-negative and non-zero. - - Learning Rate: - The user must enter the learning rate used for model training by - selecting from the dropdown list or entering a value. - Float, must be non-negative and non-zero. - - Epochs: - The user must enter the number of Epochs used during model training by - selecting from the dropdown list or entering a value. - The total amount of epochs will only be used if early stopping does not happen. - Integer, must be non-negative and non-zero. - - Loss Function: - The user must enter the loss function used for model training by - selecting from the dropdown list. These can be "BCE" (binary - cross-entropy), "Dice" (Dice coefficient) or "FL"(Focal loss). - - Model training is started by pressing the "start training" button. Although - all parameters relevant for model training can be adapted, we advise users with - limited experience to keep the pre-defined settings. These settings are best - practice and devised from the original papers that proposed the models used - here. Singularly the batch size should be adapted to 1 if comupte power is limited - (no GPU or GPU with RAM lower than 8 gigabyte). - - There is an "Augment Images" button, which allows to generate new training images. - The images and masks for the data augmentation are taken from the chosen image directory - and mask directory. The new images are saved under the same directories. - """ - # make new frame - self.advanced_window_frame = ttk.Frame( - self.advanced_window, padding="10 10 12 12" - ) - self.advanced_window_frame.grid(column=1, row=2, sticky=(N, S, W, E)) - - try: - - if self.advanced_option.get() == "Inspect Masks": - - if hasattr(self, "mask_button"): - self.mask_button.destroy() - - # Train image directory - self.raw_image_dir = StringVar() - image_entry = ttk.Entry( - self.advanced_window_frame, - width=30, - textvariable=self.raw_image_dir, - ) - image_entry.grid(column=1, row=2, columnspan=2, sticky=(W, E)) - self.raw_image_dir.set("Select Raw Image Directory") - - dir1_button = ttk.Button( - self.advanced_window_frame, - text="Image Dir", - command=lambda: (self.raw_image_dir.set(filedialog.askdirectory())), - ) - dir1_button.grid(column=3, row=2, sticky=(W, E)) - - # Mask directory - self.mask_image_dir = StringVar() - self.mask_image_entry = ttk.Entry( - self.advanced_window_frame, - width=30, - textvariable=self.mask_image_dir, - ) - self.mask_image_entry.grid(column=1, row=3, columnspan=2, sticky=(W, E)) - self.mask_image_dir.set("Select Mask Image Directory") - - self.dir2_button = ttk.Button( - self.advanced_window_frame, - text="Mask Dir", - command=lambda: ( - self.mask_image_dir.set(filedialog.askdirectory()) - ), - ) - self.dir2_button.grid(column=3, row=3, sticky=(W, E)) - - # Start index - self.start_label = ttk.Label( - self.advanced_window_frame, text="Start at Image:" - ) - self.start_label.grid(column=1, row=4, sticky=W) - start_idx = StringVar() - self.idx = ttk.Entry( - self.advanced_window_frame, width=10, textvariable=start_idx - ) - self.idx.grid(column=2, row=4, sticky=W) - start_idx.set("0") - - # Inspect button - self.inspect_button = ttk.Button( - self.advanced_window_frame, - text="Inspect Masks", - command=lambda: ( - self.advanced_window.destroy(), - gui_helpers.find_outliers( - dir1=self.raw_image_dir.get(), - dir2=self.mask_image_dir.get(), - ), - gui_helpers.overlay_directory_images( - image_dir=self.raw_image_dir.get(), - mask_dir=self.mask_image_dir.get(), - start_index=int(start_idx.get()), - ), - ), - ) - self.inspect_button.grid(column=2, row=5, sticky=(W, E)) - - elif self.advanced_option.get() == "Create Masks": - - # Forget widgets - if hasattr(self, "train_image_dir"): - for widget in self.advanced_window_frame.winfo_children(): - widget.destroy() - - if hasattr(self, "dir2_button"): - self.dir2_button.destroy() - self.mask_image_entry.destroy() - self.inspect_button.destroy() - self.idx.destroy() - self.start_label.destroy() - - # Train image directory - self.raw_image_dir = StringVar() - image_entry = ttk.Entry( - self.advanced_window_frame, - width=30, - textvariable=self.raw_image_dir, - ) - image_entry.grid(column=1, row=2, columnspan=2, sticky=(W, E)) - self.raw_image_dir.set("Select Raw Image Directory") - - dir1_button = ttk.Button( - self.advanced_window_frame, - text="Image Dir", - command=lambda: (self.raw_image_dir.set(filedialog.askdirectory())), - ) - dir1_button.grid(column=3, row=2, sticky=(W, E)) - - # Train image directory - self.raw_image_dir = StringVar() - mask_entry = ttk.Entry( - self.advanced_window_frame, - width=30, - textvariable=self.raw_image_dir, - ) - mask_entry.grid(column=1, row=2, columnspan=2, sticky=(W, E)) - self.raw_image_dir.set("Select Raw Image Directory") - - # Mask button - self.mask_button = ttk.Button( - self.advanced_window_frame, - text="Create Masks", - command=lambda: ( - self.advanced_window.destroy(), - gui_helpers.create_acsa_masks( - input_dir=self.raw_image_dir.get(), muscle_name="image" - ), - ), - ) - self.mask_button.grid(column=2, row=3, sticky=(W, E)) - - elif self.advanced_option.get() == "Train Model": - # Labels - ttk.Label( - self.advanced_window_frame, - text="Training Directories", - font=("Verdana", 14), - ).grid(column=1, row=0, padx=10) - ttk.Label(self.advanced_window_frame, text="Image Directory").grid( - column=1, row=2 - ) - ttk.Label(self.advanced_window_frame, text="Mask Directory").grid( - column=1, row=3 - ) - ttk.Label(self.advanced_window_frame, text="Output Directory").grid( - column=1, row=4 - ) - - ttk.Label( - self.advanced_window_frame, - text="Hyperparameters", - font=("Verdana", 14), - ).grid(column=1, row=6, padx=10) - ttk.Label(self.advanced_window_frame, text="Batch Size").grid( - column=1, row=7 - ) - ttk.Label(self.advanced_window_frame, text="Learning Rate").grid( - column=1, row=8 - ) - ttk.Label(self.advanced_window_frame, text="Epochs").grid( - column=1, row=9 - ) - ttk.Label(self.advanced_window_frame, text="Loss Function").grid( - column=1, row=10 - ) - - # Entryboxes - # Train image directory - self.train_image_dir = StringVar() - train_image_entry = ttk.Entry( - self.advanced_window_frame, - width=30, - textvariable=self.train_image_dir, - ) - train_image_entry.grid(column=2, row=2, columnspan=3, sticky=(W, E)) - self.train_image_dir.set("C:/Users/admin/Documents/DeepACSA") - - # Mask directory - self.mask_dir = StringVar() - mask_entry = ttk.Entry( - self.advanced_window_frame, width=30, textvariable=self.mask_dir - ) - mask_entry.grid(column=2, row=3, columnspan=3, sticky=(W, E)) - self.mask_dir.set("C:/Users/admin/Documents/DeepACSA") - - # Output path - self.out_dir = StringVar() - out_entry = ttk.Entry( - self.advanced_window_frame, width=30, textvariable=self.out_dir - ) - out_entry.grid(column=2, row=4, columnspan=3, sticky=(W, E)) - self.out_dir.set("C:/Users/admin/Documents/DeepACSA") - - # Buttons - # Train image button - train_img_button = ttk.Button( - self.advanced_window_frame, - text="Images", - command=self.get_train_dir, - ) - train_img_button.grid(column=5, row=2, sticky=E) - - # Mask button - mask_button = ttk.Button( - self.advanced_window_frame, text="Masks", command=self.get_mask_dir - ) - mask_button.grid(column=5, row=3, sticky=E) - - # Data augmentation button - data_augmentation_button = ttk.Button( - self.advanced_window_frame, - text="Augment Images", - command=self.augment_images, - ) - data_augmentation_button.grid(column=4, row=12, sticky=E) - - # Input directory - out_button = ttk.Button( - self.advanced_window_frame, - text="Output", - command=self.get_output_dir, - ) - out_button.grid(column=5, row=4, sticky=E) - - # Model train button - model_button = ttk.Button( - self.advanced_window_frame, - text="Start Training", - command=self.train_model, - ) - model_button.grid(column=5, row=12, sticky=E) - - # Comboboxes - # Batch size - self.batch_size = StringVar() - size = ("1", "2", "3", "4", "5", "6") - size_entry = ttk.Combobox( - self.advanced_window_frame, width=10, textvariable=self.batch_size - ) - size_entry["values"] = size - size_entry.grid(column=2, row=7, sticky=(W, E)) - self.batch_size.set("1") - - # Learning rate - self.learn_rate = StringVar() - learn = ("0.005", "0.001", "0.0005", "0.0001", "0.00005", "0.00001") - learn_entry = ttk.Combobox( - self.advanced_window_frame, width=10, textvariable=self.learn_rate - ) - learn_entry["values"] = learn - learn_entry.grid(column=2, row=8, sticky=(W, E)) - self.learn_rate.set("0.00001") - - # Number of training epochs - self.epochs = StringVar() - epoch = ("30", "40", "50", "60", "70", "80") - epoch_entry = ttk.Combobox( - self.advanced_window_frame, width=10, textvariable=self.epochs - ) - epoch_entry["values"] = epoch - epoch_entry.grid(column=2, row=9, sticky=(W, E)) - self.epochs.set("3") - - # Loss function - self.loss_function = StringVar() - loss = ("BCE", "Dice", "FL") - loss_entry = ttk.Combobox( - self.advanced_window_frame, - width=10, - textvariable=self.loss_function, - ) - loss_entry["values"] = loss - loss_entry["state"] = "readonly" - loss_entry.grid(column=2, row=10, sticky=(W, E)) - self.loss_function.set("BCE") - - # Seperators - ttk.Separator( - self.advanced_window_frame, orient="horizontal", style="TSeparator" - ).grid(column=0, row=5, columnspan=9, sticky=(W, E)) - ttk.Separator( - self.advanced_window_frame, orient="horizontal", style="TSeparator" - ).grid(column=0, row=11, columnspan=9, sticky=(W, E)) - - except FileNotFoundError: - tk.messagebox.showerror("Information", "Enter the coorect folder path!") - - # Add padding - for child in self.advanced_window_frame.winfo_children(): - child.grid_configure(padx=5, pady=5) - - # --------------------------------------------------------------------------------------------------- - ## Methods used for model training - - def get_train_dir(self): - """ - Instance method to ask the user to select the training image - directory path. All image files (of the same specified filetype) in - the directory are analysed. This must be an absolute path. - """ - train_image_dir = filedialog.askdirectory() - self.train_image_dir.set(train_image_dir) - - def get_mask_dir(self): - """ - Instance method to ask the user to select the training mask - directory path. All mask files (of the same specified filetype) in - the directory are analysed.The mask files and the corresponding - image must have the exact same name. This must be an absolute path. - """ - mask_dir = filedialog.askdirectory() - self.mask_dir.set(mask_dir) - - def get_output_dir(self): - """ - Instance method to ask the user to select the output - directory path. Here, all file created during model - training (model file, weight file, graphs) are saved. - This must be an absolute path. - """ - out_dir = filedialog.askdirectory() - self.out_dir.set(out_dir) - - def train_model(self): - """ - Instance method to execute the model training when the - "start training" button is pressed. - - By pressing the button, a seperate thread is started - in which the model training is run. This allows the user to break any - training process at certain stages. When the analysis can be - interrupted, a tk.messagebox opens asking the user to either - continue or terminate the analysis. Moreover, the threading allows interaction - with the GUI during ongoing analysis process. - """ - try: - # See if GUI is already running - if self.is_running: - # don't run again if it is already running - return - self.is_running = True - - # Get input paremeter - selected_images = self.train_image_dir.get() - selected_masks = self.mask_dir.get() - selected_outpath = self.out_dir.get() - - # Make sure some kind of filetype is specified. - if ( - len(selected_images) < 3 - or len(selected_masks) < 3 - or len(selected_outpath) < 3 - ): - tk.messagebox.showerror("Information", "Specified directories invalid.") - self.should_stop = False - self.is_running = False - self.do_break() - return - - selected_batch_size = int(self.batch_size.get()) - selected_learning_rate = float(self.learn_rate.get()) - selected_epochs = int(self.epochs.get()) - selected_loss_function = self.loss_function.get() - - # Start thread - thread = Thread( - target=gui_helpers.trainModel, - args=( - selected_images, - selected_masks, - selected_outpath, - selected_batch_size, - selected_learning_rate, - selected_epochs, - selected_loss_function, - self, - ), - ) - - thread.start() - - # Error handling - except ValueError: - tk.messagebox.showerror( - "Information", "Analysis parameter entry fields" + " must not be empty." - ) - self.do_break() - self.should_stop = False - self.is_running = False - - ## Method used for data augmentation - - def augment_images(self): - """ - Instance method to augment input images, when the "Augment Images" button is pressed. - Input parameters for the gui_helpers.image_augmentation function are taken from the chosen - image and mask directories. The newly generated data will be saved under the same - directories. - """ - try: - # See if GUI is already running - if self.is_running: - # don't run again if it is already running - return - self.is_running = True - - # Get input paremeters - selected_images = self.train_image_dir.get() - selected_masks = self.mask_dir.get() - - # Make sure some kind of filetype is specified. - if len(selected_images) < 3 or len(selected_masks) < 3: - tk.messagebox.showerror("Information", "Specified directories invalid.") - self.should_stop = False - self.is_running = False - self.do_break() - return - - gui_helpers.image_augmentation(selected_images, selected_masks, self) - - # Error handling - except ValueError: - tk.messagebox.showerror( - "Information", - "Check input parameters" - + "\nPotential error source: Invalid directories", - ) - self.do_break() - self.should_stop = False - self.is_running = False - - # --------------------------------------------------------------------------------------------------- - # Methods to run the code - - def run_code(self): - """ - Instance method to execute the analysis process when the - "run" button is pressed. - - Which analysis process is executed depends on the user - selection. By pressing the button, a seperate thread is started - in which the analysis is run. This allows the user to break any - analysis process. Moreover, the threading allows interaction - with the main GUI during ongoing analysis process. This function - handles most of the errors occuring during specification of - file and analysis parameters. All other exeptions are raised in - other function of this package. - - Raises - ------ - AttributeError - The execption is raised when the user didn't specify the - file or training parameters correctly. A tk.messagebox - is openend containing hints how to solve the issue. - FileNotFoundError - The execption is raised when the user didn't specify the - file or training parameters correctly. A tk.messagebox - is openend containing hints how to solve the issue. - PermissionError - The execption is raised when the user didn't specify the - file or training parameters correctly. A tk.messagebox - is openend containing hints how to solve the issue. - """ - try: - if self.is_running: - # don't run again if it is already running - return - self.is_running = True - - selected_input_dir = self.input.get() - selected_model_path = self.model.get() - selected_loss = self.loss_function.get() - selected_muscle = self.muscle.get() - selected_scaling = self.scaling.get() - selected_volume_calculation = self.muscle_volume_calculation_wanted.get() - - # Use distance for volumne only if selected - if selected_volume_calculation == "Yes": - distance_acsa = float(self.distance.get()) - else: - distance_acsa = 1 - - if len(selected_input_dir) == 0: - tk.messagebox.showerror( - "Information", - "Check input parameters." - + "\nPotential error source: Invalid specified input directory", - ) - self.should_stop = False - self.is_running = False - self.do_break() - return - - elif len(selected_model_path) == 0: - tk.messagebox.showerror( - "Information", - "Check input parameters." - + "\nPotential error source: Invalid specified model path", - ) - self.should_stop = False - self.is_running = False - self.do_break() - return - - elif selected_loss == "Loss Function": - tk.messagebox.showerror( - "Information", - "Check model loss function." - + "\nPotential error source: Invalid specified loss", - ) - self.should_stop = False - self.is_running = False - self.do_break() - return - - if selected_scaling == "Line": - selected_depth = float(self.depth.get()) - - # Catch depth error. - if selected_depth <= 0: - tk.messagebox.showerror( - "Information", - "Check input parameters." - + "\nPotential error source: Invalid specified depth.", - ) - self.should_stop = False - self.is_running = False - self.do_break() - return - - thread = Thread( - target=gui_helpers.calculate_batch_efov, - args=( - selected_input_dir, - selected_model_path, - selected_loss, - selected_depth, - selected_muscle, - selected_volume_calculation, - distance_acsa, - self, - ), - ) - else: - if selected_scaling == "Bar": - selected_spacing = float(self.spacing.get()) - else: - selected_spacing = 0 - thread = Thread( - target=gui_helpers.calculate_batch, - args=( - selected_input_dir, - selected_model_path, - selected_loss, - selected_spacing, - selected_muscle, - selected_scaling, - selected_volume_calculation, - distance_acsa, - self, - ), - ) - - thread.start() - - except ValueError: - tk.messagebox.showerror( - "Information", - "Check input parameters." - + "\nPotential error source: Invalid specified depth, distance or volume", - ) - self.should_stop = False - self.is_running = False - self.do_break() - - @property - def should_stop(self): - """ - Instance method to define the should_stop - property getter method. By defining this as a property, - should_stop is treated like a public attribute even - though it is private. - - This is used to stop the analysis process running - in a seperate thread. - - Returns - ------- - should_stop : bool - Boolean variable to decide whether the analysis process - started from the GUI should be stopped. The process is - stopped when should_stop = True. - """ - self._lock.acquire() - should_stop = self._should_stop - self._lock.release() - return should_stop - - @property - def is_running(self): - """ - Instance method to define the is_running - property getter method. By defining this as a property, - is_running is treated like a public attribute even - though it is private. - - This is used to stop the analysis process running - in a seperate thread. - - Returns - ------- - is_running : bool - Boolean variable to check whether the analysis process - started from the GUI is running. The process is only - stopped when is_running = True. - """ - self._lock.acquire() - is_running = self._is_running - self._lock.release() - return is_running - - @should_stop.setter - def should_stop(self, flag: bool): - """ - Instance method to define the should_stop - property setter method. The setter method is used - to set the self._should_stop attribute as if it was - a public attribute. The argument "flag" is thereby - validated to ensure proper input (boolean) - """ - self._lock.acquire() - self._should_stop = flag - self._lock.release() - - @is_running.setter - def is_running(self, flag: bool): - """ - Instance method to define the is_running - property setter method. The setter method is used - to set the self._is_running attribute as if it was - a public attribute. The argument "flag" is thereby - validated to ensure proper input (boolean) - """ - self._lock.acquire() - self._is_running = flag - self._lock.release() - - def do_break(self): - """ - Instance method to break the analysis process when the - button "break" is pressed. - - This changes the instance attribute self.should_stop - to True, given that the analysis is already running. - The attribute is checked befor every iteration - of the analysis process. - """ - if self.is_running: - self.should_stop = True - - -# --------------------------------------------------------------------------------------------------- -# Function required to run the GUI frm the prompt - - -def runMain() -> None: - """ - Function that enables usage of the gui from command promt - as pip package. - - Notes - ----- - The GUI can be executed by typing 'python -m deep_acsa_gui.py' in the command - subsequtently to installing the pip package and activating the - respective library. - - It is not necessary to download any files from the repository when the pip - package is installed. - - For documentation of DL_Track see top of this module. - """ - root = Tk() - DeepACSA(root) - root.mainloop() - - -# This statement is required to execute the GUI by typing 'python deep_acsa_gui.py' in the prompt -# when navigated to the folder containing the file and all dependencies. -if __name__ == "__main__": - root = Tk() - DeepACSA(root) - root.mainloop() diff --git a/Deep_ACSA/gui_helpers/__init__.py b/Deep_ACSA/gui_helpers/__init__.py deleted file mode 100644 index c82e78b..0000000 --- a/Deep_ACSA/gui_helpers/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -__all__ = [ - "apo_model", - "calculate_muscle_volume", - "calibrate", - "echo_int", - "predict_muscle_area", - "model_training", - "file_analysis", - "create_manual_masks", -] - -__author__ = ["Paul Ritsche"] - -from Deep_ACSA.gui_helpers.apo_model import * -from Deep_ACSA.gui_helpers.calculate_muscle_volume import * -from Deep_ACSA.gui_helpers.calibrate import * -from Deep_ACSA.gui_helpers.create_manual_masks import * -from Deep_ACSA.gui_helpers.data_augmentation import * -from Deep_ACSA.gui_helpers.echo_int import * -from Deep_ACSA.gui_helpers.file_analysis import * -from Deep_ACSA.gui_helpers.model_training import * -from Deep_ACSA.gui_helpers.predict_muscle_area import * diff --git a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 9ed6f9b..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 210f389..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-39.pyc b/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 211e202..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/__init__.cpython-39.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-310.pyc deleted file mode 100644 index d10bdf6..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-38.pyc deleted file mode 100644 index 9fe8a6a..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-39.pyc b/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-39.pyc deleted file mode 100644 index 79d6687..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/apo_model.cpython-39.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-310.pyc deleted file mode 100644 index e033f07..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-38.pyc deleted file mode 100644 index ba9375e..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/calculate_muscle_volume.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-310.pyc deleted file mode 100644 index 11d3c7e..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-38.pyc deleted file mode 100644 index fd9c779..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/calibrate.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-310.pyc deleted file mode 100644 index ae111b6..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-38.pyc deleted file mode 100644 index daf1308..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/echo_int.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/model_training.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/model_training.cpython-38.pyc deleted file mode 100644 index 1423973..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/model_training.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-310.pyc b/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-310.pyc deleted file mode 100644 index 07118eb..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-310.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-38.pyc deleted file mode 100644 index ac88429..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/predict_muscle_area.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/__pycache__/prepare_quad_imgs.cpython-38.pyc b/Deep_ACSA/gui_helpers/__pycache__/prepare_quad_imgs.cpython-38.pyc deleted file mode 100644 index 6d386e7..0000000 Binary files a/Deep_ACSA/gui_helpers/__pycache__/prepare_quad_imgs.cpython-38.pyc and /dev/null differ diff --git a/Deep_ACSA/gui_helpers/calculate_muscle_volume.py b/Deep_ACSA/gui_helpers/calculate_muscle_volume.py deleted file mode 100644 index 7215a2b..0000000 --- a/Deep_ACSA/gui_helpers/calculate_muscle_volume.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Description ------------ -This module contains a function to automatically calculate the volume -of the analyzed muscle. Note that this can only be done across several -images of the same muscle, at least 3. The truncated cone formula is used, -modelling the muscle as a cone. The higher the number of images per muscle, -the higher the accuracy of the volume calculation, as distances between images -are extrapolated. Thus, the distance must be known and must be equal. - -Functions scope ---------------- -muscle_volume_calculation - Function to calcualte the echo intensity of muscle area. -""" - -import math - -def muscle_volume_calculation(scan_area: list, dist: float): - """ - Fuction to calculate muscle volume on the basis of the predicted - muscle areas. Several images of the same muscle are needed to - calculate the muscle muscle volume with known distances between - the images. - - The truncated cone formula is used to calculate the volume, - where the muscle is modelled as a cone. - - Parameters - ---------- - scan_area : list - List variable containing the predicted muscle areas. - Should be at least 3 predicted areas. - dist : float - Float variable contianing the distance between the images - in cm. Needs to be constant. - - Returns - ------- - total_volume : float - Float variable containing the total predicted muscle - volume in cm^3. - - Notes - ----- - - The `scan_area` list should contain at least three elements representing - the predicted muscle areas from different scans or images of the same - muscle. - - The `dist` parameter should be the known distance between the images in - centimeters (cm). It must be constant for accurate volume calculation. - - The returned volume is in cubic centimeters (cm^3). - - Example - ------- - >>>muscle_volume = muscle_volume_calculation([2, 3, 5, 4, 2, 2], 3.75) - 11.3276276145058 - """ - total_volume = 0 - - for scan_nr in range(len(scan_area)-1): - - partial_volume = 0 - - # a and b are ACSA of 2 scans - a = scan_area[scan_nr] - b = scan_area[scan_nr + 1] - - partial_volume = 1/3 * (a + math.sqrt((a * b) + b)) * dist - total_volume = total_volume + partial_volume - - return total_volume diff --git a/MANIFEST.in b/MANIFEST.in index f541dc5..a5df8de 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,5 +3,5 @@ include README.md include LICENSE include requirements.txt -# Add all the non .py files in openhdemg -include Deep_ACSA/gui_files/Icon.ico +# Add all the non .py files in DeepACSA +include DeepACSA/gui_files/Icon.ico diff --git a/README.md b/README.md index 0d589d9..1b53d60 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ DeepACSA is an open-source tool to evaluate the anatomical cross-sectional area of muscles in ultrasound images using deep learning. More information about the installtion and usage of DeepACSA can be found in the online [documentation](https://deepacsa.readthedocs.io/en/latest/index.html). You can find information about contributing, issues and bug reports there as well. -Our trained models, training data, an executable as well as example files can be accessed at [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.8419487.svg)](https://doi.org/10.5281/zenodo.8419487). +Our trained models, training data, an executable as well as example files can be accessed at [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19130694.svg)](https://doi.org/10.5281/zenodo.19130694). If you find this work useful, please remember to cite the corresponding [paper](https://journals.lww.com/acsm-msse/Abstract/9900/DeepACSA__Automatic_Segmentation_of.87.aspx), where more information about the model architecture and performance can be found as well. ## Quickstart @@ -28,8 +28,49 @@ when the DeepACSA environment is activated Irrespective of the way the software was started, the GUI should open and is ready to be used. -## Whats new? -With version 0.3.1, we included new models for the m. vastus lateralis (VL) and m. rectus femoris (RF) and added manual image labelling and mask inspection to the GUI. Take a look at our [documentation](https://deepacsa.readthedocs.io/en/latest/index.html) to see more details and the result of the model comparisons. Moreover, we have included models for the automatic segmentation of biceps femoris long head panorama and single muscle images which are described below. +⚠️ v0.3.2 is not yet available as a python package. The newest release can be run by cloning the github repository + +``git clone https://github.com/PaulRitsche/DeepACSA.git`` + +then creating the DeepACSA0.3.2 environment, i.e. with + +``conda env create -f environment.yml`` + +in the root directory with the `environment.yml` file. +Subsequently, install the package locally with + +``python -m pip instell -e .`` + +then run the module or the UI by + +``python -m DeepACSA`` + +or + +``cd DeepACSA`` +``python deep_acsa_gui.py`` + +# Whats new? + +## V0.3.2 + +With version 0.3.2, we included new models for the + +- patella tendon (taken from [Guzzi et al. 2026](https://link.springer.com/article/10.1007/s10278-026-01846-x) +- vastus medialis (taken from [Tayfur et al. 2025](https://www.sciencedirect.com/science/article/abs/pii/S0301562924004319)) + +Below you can find some overview tables. All models and the newest installer can + +## Patella Tendon Models (Unet3+ best performing) +We provide a model for the automatic segmentation of the patellar tendon anatomical cross-sectional area (ACSA) at 25%, 50%, and 75% of tendon length in healthy subjects (**UNet 3+**). +We evaluated two model architectures for patellar tendon segmentation: *UNet-VGG16* and *UNet 3+*. Their performance was assessed by comparing automated predictions with manual segmentations. Overall, both models demonstrated good agreement with manual analysis, with **UNet 3+** showing the most consistent performance. Detailed methodology and results are reported in our publication [Guzzi et al. 2026](https://link.springer.com/article/10.1007/s10278-026-01846-x). + +## Vatus medialis model (only VGG16Unet trained) +We provide a model for the automatic segmentation of the vastus medialis cross-sectional area (ACSA) in healthy participants as well participants with ACL injuries. +A *UNet-VGG16* model was evaluated and compared to manual analysis. Comparability calculations and detailled methodology can be found at [Tayfur et al. 2025](https://www.sciencedirect.com/science/article/abs/pii/S0301562924004319) + + +## V0.3.1 ### Hamstring models In collaboration with the [ORB Michigan](https://www.kines.umich.edu/research/labs-centers/orthopedic-rehabilitation-biomechanics-laboratory), we developed models for the automatic segmentation of the biceps femoris. The dataset consisted of approximately 900 images from around 150 participants. Participants included were youth and adult soccer players, adult endurance runners, adult track and field athletes as well as adults with a recent ACL tear (in total 30% women). Images were captured across different muscle regions including 33%, 50% and 66% of muscle length. We compared the performance of different models to manual analysis of the images. We used similar training procedures as decribed in our DeepACSA [paper](https://journals.lww.com/acsm-msse/Abstract/9900/DeepACSA__Automatic_Segmentation_of.87.aspx), however, we evaluated the models unsing 5-fold cross-validation to check for overfitting. We provide the model with the highest IoU scores for ACSA segmentation. We compared the different model architectures [VGG16-Unet](https://journals.lww.com/acsm-msse/Abstract/9900/DeepACSA__Automatic_Segmentation_of.87.aspx), [Unet2+](https://arxiv.org/abs/1912.05074) and [Unet3+](https://arxiv.org/abs/2004.08790). Below we have outlined the analysis results and the trained models can be found [here](https://osf.io/a3u4v/). @@ -48,9 +89,3 @@ In collaboration with the [ORB Michigan](https://www.kines.umich.edu/research/la ![Figure2_VGG16Unet](https://user-images.githubusercontent.com/71383228/182554020-2c8bad75-7f08-4194-8f5f-0a521a70781c.jpg) DeepACSA workflow. a) Original ultrasound image of the m. rectus femoris (RF) at 50% of femur length that serves as input for the model. b) Detailed U-net CNN architecture with a VGG16 encoder (left path). c) Model prediction of muscle area following post-processing (shown as a binary image). - -## Results of comparing DeepACSA analysis to manual analysis - -![Figure3_BAP](https://user-images.githubusercontent.com/71383228/182554096-c5fde7cd-a137-4cc4-ad73-a819368d13ec.jpg) - -Bland-Altman plots of all muscles plotting the difference between manual and DeepACSA with incorrect predictions removed (rm), manual and DeepACSA as well as manual and ACSAuto area segmentation measurements against the mean of both measures. Dotted and solid lines illustrate 95% limits of agreement and bias. M. rectus femoris (RF) and m. vastus lateralis (VL), mm. gastrocnemius medialis (GM), and lateralis (GL). diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..125470d Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/source/Deep_ACSA.gui_helpers.rst b/docs/source/DeepACSA.gui_helpers.rst similarity index 56% rename from docs/source/Deep_ACSA.gui_helpers.rst rename to docs/source/DeepACSA.gui_helpers.rst index 2008c60..73b7d97 100644 --- a/docs/source/Deep_ACSA.gui_helpers.rst +++ b/docs/source/DeepACSA.gui_helpers.rst @@ -1,13 +1,10 @@ Deep\_ACSA.gui\_helpers package =============================== -Submodules ----------- - Deep\_ACSA.gui\_helpers.apo\_model module ----------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.apo_model +.. automodule:: DeepACSA.gui_helpers.apo_model :members: :undoc-members: :show-inheritance: @@ -15,7 +12,7 @@ Deep\_ACSA.gui\_helpers.apo\_model module Deep\_ACSA.gui\_helpers.calculate\_muscle\_volume module -------------------------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.calculate_muscle_volume +.. automodule:: DeepACSA.gui_helpers.calculate_muscle_volume :members: :undoc-members: :show-inheritance: @@ -23,55 +20,63 @@ Deep\_ACSA.gui\_helpers.calculate\_muscle\_volume module Deep\_ACSA.gui\_helpers.calibrate module ---------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.calibrate +.. automodule:: DeepACSA.gui_helpers.calibrate :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.echo\_int module ----------------------------------------- +Deep\_ACSA.gui\_helpers.create\_manual\_masks module +---------------------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.echo_int +.. automodule:: DeepACSA.gui_helpers.create_manual_masks :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.model\_training module ----------------------------------------------- +Deep\_ACSA.gui\_helpers.data\_augmentation module +------------------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.model_training +.. automodule:: DeepACSA.gui_helpers.data_augmentation :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.predict\_muscle\_area module ----------------------------------------------------- +Deep\_ACSA.gui\_helpers.echo\_int module +---------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.predict_muscle_area +.. automodule:: DeepACSA.gui_helpers.echo_int :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.model\_training ----------------------------------------------------- +Deep\_ACSA.gui\_helpers.file\_analysis module +--------------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.model_training +.. automodule:: DeepACSA.gui_helpers.file_analysis :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.file\_analysis ----------------------------------------------------- +Deep\_ACSA.gui\_helpers.image\_processing module +------------------------------------------------ + +.. automodule:: DeepACSA.gui_helpers.image_processing + :members: + :undoc-members: + :show-inheritance: -.. automodule:: Deep_ACSA.gui_helpers.file_analysis +Deep\_ACSA.gui\_helpers.model\_training module +---------------------------------------------- + +.. automodule:: DeepACSA.gui_helpers.model_training :members: :undoc-members: :show-inheritance: -Deep\_ACSA.gui\_helpers.create\_manual\_masks +Deep\_ACSA.gui\_helpers.predict\_muscle\_area module ---------------------------------------------------- -.. automodule:: Deep_ACSA.gui_helpers.create_manual_masks +.. automodule:: DeepACSA.gui_helpers.predict_muscle_area :members: :undoc-members: :show-inheritance: diff --git a/docs/source/GUI_augmentation.png b/docs/source/GUI_augmentation.png new file mode 100644 index 0000000..fa62c5d Binary files /dev/null and b/docs/source/GUI_augmentation.png differ diff --git a/docs/source/GUI_test_images.png b/docs/source/GUI_test_images.png new file mode 100644 index 0000000..9e4accf Binary files /dev/null and b/docs/source/GUI_test_images.png differ diff --git a/docs/source/GUI_test_training.png b/docs/source/GUI_test_training.png new file mode 100644 index 0000000..1201f80 Binary files /dev/null and b/docs/source/GUI_test_training.png differ diff --git a/docs/source/Inspect_incorrect.png b/docs/source/Inspect_incorrect.png index a24a3d8..c30a5e7 100644 Binary files a/docs/source/Inspect_incorrect.png and b/docs/source/Inspect_incorrect.png differ diff --git a/docs/source/PT_reliability.png b/docs/source/PT_reliability.png new file mode 100644 index 0000000..f099417 Binary files /dev/null and b/docs/source/PT_reliability.png differ diff --git a/docs/source/RF_bar_scale.png b/docs/source/RF_bar_scale.png new file mode 100644 index 0000000..bfa16a4 Binary files /dev/null and b/docs/source/RF_bar_scale.png differ diff --git a/docs/source/RF_line_scale.png b/docs/source/RF_line_scale.png new file mode 100644 index 0000000..ca987ee Binary files /dev/null and b/docs/source/RF_line_scale.png differ diff --git a/docs/source/_static/github_bar.css b/docs/source/_static/github_bar.css new file mode 100644 index 0000000..1033be2 --- /dev/null +++ b/docs/source/_static/github_bar.css @@ -0,0 +1,28 @@ +# GitHub repo bar styles +# Compact, theme-friendly styles + +#gh-bar { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--color-background-secondary, #005b4c); + border: 1px solid rgba(0,0,0,0.06); + padding: 0.35rem 0.6rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +#gh-bar a { + color: inherit; + text-decoration: none; + font-weight: 600; +} + +#gh-stars { + background: rgba(1, 76, 58, 0.481); + border: 1px solid rgba(0,0,0,0.06); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-weight: 700; + font-size: 0.95rem; +} diff --git a/docs/source/_static/github_bar.js b/docs/source/_static/github_bar.js new file mode 100644 index 0000000..ae1a7c7 --- /dev/null +++ b/docs/source/_static/github_bar.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', function () { + const repoOwner = 'PaulRitsche'; + const repoName = 'DeepACSA'; + const starsEl = document.getElementById('gh-stars'); + const linkEl = document.getElementById('gh-link'); + if (!starsEl) return; + + // Use GitHub public API to get repository info (stargazers_count) + fetch(`https://api.github.com/repos/${repoOwner}/${repoName}`) + .then((res) => { + if (!res.ok) throw new Error('network'); + return res.json(); + }) + .then((data) => { + const stars = data && data.stargazers_count ? data.stargazers_count : '0'; + starsEl.textContent = `${stars} ★`; + if (linkEl) linkEl.href = `https://github.com/${repoOwner}/${repoName}`; + }) + .catch(() => { + // Graceful fallback + starsEl.textContent = '★'; + }); +}); diff --git a/docs/source/check_bar.png b/docs/source/check_bar.png new file mode 100644 index 0000000..6f786a7 Binary files /dev/null and b/docs/source/check_bar.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index 1076ee3..6d62771 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,20 +13,22 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "DeepACSA" -copyright = "2023, Paul Ritsche" +copyright = "2023-2026, Paul Ritsche" author = "Paul Ritsche" -release = "0.3.1" +release = "0.3.2" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc"] +extensions = ["sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.mathjax", + ] pygments_style = "sphinx" - # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output @@ -42,3 +44,15 @@ html_logo = "deep_acsa.jpg" html_favicon = "icon.ico" + +# -- Static files (such as style sheets and scripts) ------------------------ +html_static_path = ["_static"] + +# Include our custom CSS/JS for the GitHub repo bar on the landing page +html_css_files = [ + "github_bar.css", +] + +html_js_files = [ + "github_bar.js", +] diff --git a/docs/source/contribute.rst b/docs/source/contribute.rst index ca3ff85..55ddf1d 100644 --- a/docs/source/contribute.rst +++ b/docs/source/contribute.rst @@ -3,17 +3,22 @@ Contributing ============ -We are happy whenever you decide to contribute to DeepACSA. However, when contributing to DeepACSA, please first file an issue in our Github `issue section `_. Label the issue with "improvement" and describe your suggestion. Please formulate the title of the issue similar to this: Contribution - Your contribution idea. Please also state in the issue wheter you want to implement it yourself / already implemented it in your code or if you would like us to implement it. We will be in touch with you. +We are happy whenever you decide to contribute to DeepACSA. However, when contributing to DeepACSA, please first file an issue in our Github `issue section `_. + +Label the issue with *"improvement"* and provide a clear description of your suggestion. Please formulate the title of the issue similar to this: *Contribution - Your contribution idea*. + +In the issue, also indicate whether you plan to implement the contribution yourself, have already implemented it locally, or would prefer that we implement it. We will review your proposal and get back to you as soon as possible. + Please note we have a code of conduct, please follow it in all your interactions with the project. In case you have decided to implement your suggestion yourself and we agreed that you should file a pull request, take a look at the steps listed below. -Pull Request Process for contributing own material +Pull request process for contributing own material -------------------------------------------------- 1. Describe the changes you made as detailled as possible in the pull request. 2. Add a changelog to the changelog.d folder describing exactly what you changed in the project and the environment. -3. When adding new functions, please follow the code and docstring styles used throughout the code. FYI, we used the `Numpy styleguide `_. (However, code documentation is not completed for all our functions yet.) +3. When adding new functions, please follow the code and docstring styles used throughout the code. FYI, we used the `Numpy style guide `_. (However, code documentation is not completed for all our functions yet.) 4. Create a new branch (named yourchange_yourinitials) and a Pull Request to merge your work on the **main** branch of the project. Report a bug @@ -35,10 +40,10 @@ Getting Support If you have any questions about the project, encountered problems / need help during the installation procedure or encountered problems / need help during the usage of DeepACSA not related to bugs, don't hesitate to report this in the Github `Q&A section `_. This is the space to have conversations, ask questions and post answers without opening issues. Code of Conduct -=============== +--------------- Our Pledge ----------- +"""""""""" In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and @@ -48,7 +53,7 @@ level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. Our Standards -------------- +""""""""""""" Examples of behavior that contributes to creating a positive environment include: @@ -71,7 +76,7 @@ Examples of unacceptable behavior by participants include: professional setting Our Responsibilities --------------------- +"""""""""""""""""""" Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in @@ -84,7 +89,7 @@ permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. Scope ------ +""""" This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. @@ -94,7 +99,7 @@ as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. Enforcement ------------ +""""""""""" Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at paul.ritsche@unibas.ch. All @@ -108,7 +113,7 @@ faith may face temporary or permanent repercussions as determined by other members of the project's leadership. Attribution ------------ +""""""""""" This Code of Conduct is adapted from the Contributor Covenant homepage, version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. diff --git a/docs/source/create_masks.png b/docs/source/create_masks.png index 2e7b320..a84b4f4 100644 Binary files a/docs/source/create_masks.png and b/docs/source/create_masks.png differ diff --git a/docs/source/Documentation.rst b/docs/source/deep_acsa_package.rst similarity index 60% rename from docs/source/Documentation.rst rename to docs/source/deep_acsa_package.rst index 54a62d9..92f7397 100644 --- a/docs/source/Documentation.rst +++ b/docs/source/deep_acsa_package.rst @@ -7,15 +7,13 @@ Subpackages .. toctree:: :maxdepth: 4 - Deep_ACSA.gui_helpers - -Submodules ----------- + DeepACSA.gui_helpers Deep_ACSA.deep_acsa_gui module -"""""""""""""""""""""""""""""" +------------------------------ -.. automodule:: Deep_ACSA.deep_acsa_gui +.. automodule:: DeepACSA.deep_acsa_gui :members: :undoc-members: :show-inheritance: + :no-index: diff --git a/docs/source/image_overlay.png b/docs/source/image_overlay.png new file mode 100644 index 0000000..bc1b89a Binary files /dev/null and b/docs/source/image_overlay.png differ diff --git a/docs/source/index.rst b/docs/source/index.rst index c40cec5..a9e81c0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,21 +3,30 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +.. raw:: html + +
+
+ GitHub + Loading… +
+ + Welcome to DeepACSA's documentation! ==================================== *DeepACSA: Automated analysis of human lower limb ultrasonography images* -So, what is DeepACSA all about? The DeepACSA algorithm was developed in 2022 by `Paul Ritsche `_ & `Philipp Wirth `_. Moreover, `Neil Cronin `_, `Fabio Sarto `_, Marco Narici, `Oliver Faude `_ and `Martino Franchi `_ supported the project during development and provided images, materials and insight. The algorithm makes extensive use of fully convolutional neural networks trained on a fair amount of ultrasonography images of the human lower limb. Specifically, the dataset included transversal ultrasonography images from the human gastrocnemius medialis and lateralis, vastus lateralis and rectus femoris. The algorithm is able to analyse muscle anatomical cross-sectional area, echo intensity and muscle volume. +So, what is DeepACSA all about? The DeepACSA algorithm was developed in 2022 by `Paul Ritsche `_ & `Philipp Wirth `_. Moreover, `Neil Cronin `_, `Fabio Sarto `_, Marco Narici, `Oliver Faude `_ and `Martino Franchi `_ supported the project during development and provided images, materials and insight. The algorithm makes extensive use of fully convolutional neural networks trained on a fair amount of ultrasonography images of the human lower limb. Specifically, the dataset included transversal ultrasonography images from the human gastrocnemius medialis and lateralis, vastus lateralis and rectus femoris. The current version provides dedicated segmentation models for the gastrocnemius medialis and lateralis, vastus lateralis, rectus femoris, biceps femoris long head, and the patellar tendon. These models enable the analysis of muscle and tendon anatomical cross-sectional area, echo intensity and muscle volume. Why use DeepACSA? ================= -Using the DeepACSA algorithm to analyze muscle morphological parameters in human lower limb muscle ultrasonography images hase two main advantages. The analysis is objectified when using the automated analysis types for images because no user input is required during the analysis process. Secondly, the required analysis time for image analysis is drastically reduced compared to manual analysis. Whereas an image manual analysis takes about one minute, DeepACSA analyzes images in less than one second. This allows users to analyze large amounts of images without supervision during the analysis process in relatively short amounts of time. +Using the DeepACSA algorithm to analyse muscle and tendon morphological parameters in human lower limb ultrasonography images has two main advantages. The analysis is objectified when using the automated analysis types for images because no user input is required during the analysis process. Secondly, the required analysis time for image analysis is drastically reduced compared to manual analysis. Whereas an image manual analysis takes about one minute, DeepACSA analyzes images in less than one second. This allows users to analyze large amounts of images without supervision during the analysis process in relatively short amounts of time. Limitations =========== -Currently, we have not provided unit testing for the functions and modules included in the DeepACSA algorithm. Moreover, the muscles included in the training data set are limited to the lower extremities. Although we included images from as many ultrasonography devices as possible, we were only able to include images from four different devices. Therefore, users aiming to analyze images from different muscles or different ultrasonography devices might be required to train their own models because the provided pre-trained models result in bad segmentations. Lastly, even though DeepACSA objectifies the analysis of ultrasonography images when using the automated analysis types, we labeled the images manually. Therefore, we introduced some subjectivity into the datasets. +Currently, we have not provided unit testing for the functions and modules included in the DeepACSA algorithm. Moreover, the muscles included in the training data set are limited to the lower extremities. Although we included images from as many ultrasonography devices as possible, we were only able to include images from four different devices. Therefore, users aiming to analyze images from different muscles or different ultrasonography devices might be required to train their own models because the provided pre-trained models result in bad segmentations. Lastly, although DeepACSA objectifies the analysis of ultrasonography images through automated segmentation, the training data were manually annotated. Consequently, a degree of subjectivity was introduced into the dataset. .. toctree:: :caption: Contents @@ -28,7 +37,7 @@ Currently, we have not provided unit testing for the functions and modules inclu usage contribute tests - documentation + deep_acsa_package diff --git a/docs/source/inspect.png b/docs/source/inspect.png deleted file mode 100644 index bbdfede..0000000 Binary files a/docs/source/inspect.png and /dev/null differ diff --git a/docs/source/inspect_correct.png b/docs/source/inspect_correct.png index 3b5ace1..433aa06 100644 Binary files a/docs/source/inspect_correct.png and b/docs/source/inspect_correct.png differ diff --git a/docs/source/inspect_gui.png b/docs/source/inspect_gui.png new file mode 100644 index 0000000..14eafde Binary files /dev/null and b/docs/source/inspect_gui.png differ diff --git a/docs/source/inspect_info.png b/docs/source/inspect_info.png deleted file mode 100644 index 8eaeecb..0000000 Binary files a/docs/source/inspect_info.png and /dev/null differ diff --git a/docs/source/inspect_masks.png b/docs/source/inspect_masks.png new file mode 100644 index 0000000..a469a59 Binary files /dev/null and b/docs/source/inspect_masks.png differ diff --git a/docs/source/installation.rst b/docs/source/installation.rst index f48c82e..5d54285 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,110 +1,166 @@ +.. _installation: + Installation ============ -We offer two possible installation approaches for our DeepACSA software. The first option is to download the DeepACSA executable file. The second option we describe is DeepACSA package installation via Github. We want to inform you that there are more ways to install the package. However, we do not aim to be complete and rather demonstrate an (in our opinion) user friendly way for the installation of DeepACSA. Moreover, we advise users with less programming experience to make use of the first option and download the executable file. +We offer two possible installation approaches for our DeepACSA software. The first option is to download the DeepACSA Installer. The second option we describe is DeepACSA package installation via Github. We want to inform you that there are more ways to install the package. However, we do not aim to be complete and rather demonstrate an (in our opinion) user friendly way for the installation of DeepACSA. Moreover, we advise users with less programming experience to make use of the first option and download the Installer. -Download the DeepACSA executable +Download the DeepACSA Installer --------------------------------- -1. Got to the Zenodo webpage containing the DeepACSA executable, the pre-trained models and the example files using this `link `_. -2. Download the DeepACSA_example.7z -3. Find the DeepACSA.exe Executable in the DeepACSA_example/executable folder. -4. Create a specified DeepACSA directory and put the deep_acsa_gui.exe, the model files and the example file in seperate subfolders (for example "Executable", "Models" and "Example"). Moreover, unpack the DeepACSAv0.3.1_example.7z file. -5. Open the DeepACSA GUI by double clicking the DeepACSA.exe file and start with the testing procedure to check that everything works properly. +1. Got to the Zenodo webpage containing the DeepACSA executable, the pre-trained models and the example files using this `link `_. +2. Download the ``DeepACSAv0.3.2_Installer`` folder and extract its content on your Desktop. If your system does not support .7z files natively, use a program such as 7-Zip to extract the contents. +3. Navigate to ``C:/Users/your_user/Desktop/Downloads`` and install DeepACSA v0.3.2 by double clicking the ``DeepACSAv0.3.2_Installer.exe``. +4. Navigate to ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.2/``. Start with the :ref:`testing procedure ` to check that everything works properly. .. _installlabel: -Install DeepACSA via Github ---------------------------- +DeepACSA Installation Guide (v0.3.2) +------------------------------------ + +.. warning:: + + Version **0.3.2 is NOT YET available via pip**. + The newest release must be installed from GitHub. + +Overview +-------- + +DeepACSA can be installed using a Conda environment and a local editable installation. +This is the recommended approach for both users and developers. + +Step 1: Install Anaconda (required) +----------------------------------- + +If you do not have Anaconda or Miniconda installed: + +- Download: https://www.anaconda.com/distribution/ +- Choose a Python 3.X version +- Ensure that *"Add Anaconda to PATH"* is enabled during installation + +Step 2: Install Git (recommended) +--------------------------------- + +Git is required to clone the repository: + +- https://git-scm.com/download + +Step 3: Clone the DeepACSA Repository +------------------------------------- + +Open a terminal (or Git Bash) and run: + +.. code-block:: bash -In case you want to use this way to install and run DeepACSA, we advise you to setup conda (see step 1) and download the environment.yml file from the repo (see steps 5-8). If you want to actively contribute to the project or customize the code, it might be usefull to you to do all of the following steps (for more information see :ref:`contributelabel`). + git clone https://github.com/PaulRitsche/DeepACSA.git + cd DeepACSA -1. *Step 1.* Anaconda setup (only before first usage and if Anaconda/minicoda is not already installed). +Step 4: Create the Conda Environment +------------------------------------ - Install `Anaconda `_ (click 'Download' and be sure to choose 'Python 3.X Version' (where the X represents the latest version being offered. IMPORTANT: Make sure you tick the 'Add Anaconda to my PATH environment variable' box). +DeepACSA provides an environment file with all dependencies: -2. *Step 2.* **(Only required for MacOS users, contributing or development)** Git setup (only before first usage and if Git is not already installed). This is optional and only required when you want to clone the whole DeepACSA Github repository. +.. code-block:: bash - In case you have never used Git before on you computer, please install it using the instructions provided `here `_. + conda env create -f environment.yml -3. *Step 3.* **(Only required for MacOS users, contributing or development)** Create a directory for DeepACSA. +This will create the environment (typically named ``DeepACSA0.3.2``). - On your computer create a specific directory for DeepACSA (for example "DeepACA") and navigate there. You can use Git as a version control system. Once there open a git bash with right click and then "Git Bash Here". In the bash terminal, type the following: +Step 5: Activate the Environment +-------------------------------- - ``git init`` +.. code-block:: bash - This will initialize a git repository and allows you to continue. If run into problems, check this `website `_. + conda activate DeepACSA0.3.2 -4. *Step 4.* **(Only required for MacOS users, contributing or development)** Clone the DeepACSA Github repository into a pre-specified folder (for example "DeepACSA") by typing the following code in your bash window: +Step 6: Install DeepACSA Locally +-------------------------------- - ``git clone https://github.com/PaulRitsche/DeepACSA.git`` +Install the package in editable mode: - This will clone the entire repository to your local computer. To make sure that everything worked, see if the files in your local directory match the ones you can find in the Github DeepACSA repository. If you run into problem, check this `website `_. +.. code-block:: bash - Alternatively, you can only download the environment.yml file from the `DeepACSA repo `_ and continue to the next step. + python -m pip install -e . -5. *Step 5.* Create the virtual environment required for DeepACSA. +This allows local development and updates. - DeepACSA is bound to a specific python version (3.9.18). To create an environment for DeepACSA, type the following command in your Git bash terminal: +Step 7: Verify Installation +--------------------------- + +.. code-block:: bash + + conda list + +Ensure that ``deepacsa`` appears in the list of installed packages. + +Step 8: Run DeepACSA +--------------------- + +Option 1: Run as module (recommended) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: bash + + python -m DeepACSA - ``conda create -n DeepACSA python=3.9`` +This works from any directory as long as the environment is active. -6. *Step 6.* Activate the environment for usage of DeepACSA. +Option 2: Run GUI script directly +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - You can now activate the virtual environment by typing: - - ``conda activate DeepACSA`` - - An active conda environment is visible in () brackets befor your current path in the bash terminal. In this case, this should look something like (DeepACSA) C:/user/.../DeepACSA.Then, download the DeepACSA package by typing: +.. code-block:: bash -7. *Step 7.* Install the DeepACSA package **on Windows**. + cd DeepACSA + python deep_acsa_gui.py - ``pip install DeepACSA==0.3.1`` +Alternative: Older Versions (not recommended) +--------------------------------------------- -7. *Step 7.* Install the DeepACSA package **on MacOS**. +If you explicitly need an older version (e.g. 0.3.1): - Navigate into the folder that you cloned from Github (DeepACSA) with the bash terminal. You can do that by typing "cd" followed by the path to the folder containing the requirements.txt file. This should look something like: - - ``cd /.../.../DeepACSA/DeepACSA`` - - Then you can install the requirements of DeepACSA with: - - ``pip install -r requirements.txt`` - - Install the DeepACSA package locally to make use of its functionalities with: - - ``python -m pip install -e .`` +.. code-block:: bash -8. *Step 8.* The First option of running DeepACSA is using the installed DeepACSA package (either by pip or locally installed). You do not need the whole cloned repository for this, only the active DeepACSA environment. You do moreover not need be any specific directory. Type in your bash terminal: + pip install DeepACSA==0.3.1 - ``python -m Deep_ACSA`` - - The main GUI should now open. If you run into problems, open a discussion in the Q&A section of `DeepACSA discussions `_ and assign the label "Problem". For usage of DeepACSA please take a look at :ref:`usagelabel`. - -9. *Step 9.* The second option of running DeepACSA is using the deep_acsa_gui python script. This requires you to clone the whole directory and navigate to the directory where the deep_acsa_gui.py file is located. Moreover, you need the active DeepACSA environment. +.. warning:: - The deep_acsa_gui.py file is located at the `DeepACSA/Deep_ACSA` folder. To execute the module type the following command in your bash terminal. - - ``python deep_acsa_gui.py`` + Older versions may not include recent updates and fixes. + The older version runs in python 3.9.18. Please consider this when installing the older version. Whichever option you used, the main GUI should now open. .. figure:: main.png - :scale: 50 % + :scale: 75 % :alt: main_gui_figure Main GUI Window -If you run into problems, open a discussion in the Q&A section of `DeepACSA discussions `_ and assign the label "Problem". You can find an example discussion there. For usage of DeepACSA please take a look at :ref:`usagelabel`. +*If you run into problems, open a discussion in the Q&A section of* `DeepACSA discussions `_ *and assign the label "Problem". You can find an example discussion there. For usage of DeepACSA please take a look at* :ref:`usagelabel`. + +.. _gui_setup_ref: GPU setup --------- -**Attention: The next section is only relevant for windows users!** +The processing speed of a single image or video frame analyzed with DeepACSA is highly dependent on computing power. While possible, model inference and model training using a CPU only will decrese processing speed and prolong the model training process. Therefore, we advise to use a GPU whenever possible. + +**The following instructions are relevant only for Windows users!** + +Before using a GPU it needs to be properly configured. First, install the appropriate GPU drivers on your system. The correct drivers for your GPU can be found on the `official NVIDIA website `_. + +After installing the drivers, you must install the required CUDA and cuDNN software packages. To use DeepACSA with TensorFlow version 2.10, install: + +* CUDA version 11.2 from the `CUDA 11.2 download archive `_. +* cuDNN version 8.5 for CUDA version 11.x from the `cuDNN archive `_. + +You may need to create an NVIDIA account to access these downloads. + +As a next step, you need to be your own installation wizard. We refer to this `video `_ (up to date, watch from minute 9 to minute 13) or this `older video `_ (watch the entire video, but substitute CUDA and cuDNN versions accordingly). There are procedures at the end of each video testing whether a GPU is detected by tensorflow or not. + +**The following instructions are relevant only for MacOS users!** -The processing speed of a single image or video frame analyzed with DeepACSA is highly dependent on computing power. While possible, model inference and model training using a CPU only will decrese processing speed and prolong the model training process. Therefore, we advise to use a GPU whenever possible. Prior to using a GPU it needs to be set up. Firstly the GPU drivers must be locally installed on your computer. You can find out which drivers are right for your GPU `here `_. Subsequent to installing the drivers, you need to install the interdependant CUDA and cuDNN software packages. To use DeepACSA with tensorflow version 2.10 you need to install CUDA version 11.2 from `here `_ and cuDNN version 8.5 for CUDA version 11.x from `here `_ (you may need to create an nvidia account). As a next step, you need to be your own installation wizard. We refer to this `video `_ (up to date, minute 9 to minute 13) or this `video `_ (older, entire video but replace CUDA and cuDNN versions). There are procedures at the end of each video testing whether a GPU is detected by tensorflow or not. If you run into problems with the GPU/CUDA setup, please open a discussion in the Q&A section of `DeepACSA discussions `_ and assign the label "Problem". +If you are using an Apple Silicon device (M1 or M2) and would like to enable GPU acceleration for model training and/or inference, please refer to this `tutorial `_. It provides a detailed guide on enabling GPU support for TensorFlow on Apple Silicon devices. -**Attention : The next section is only relevant for MacOS users!** -In case you want to make use of you M1 / M2 chips for model training and / or inference, we refer you to this `tutorial `_. There you will find a detailed description of how to enable GPU support for tensorflow. It is not strictly necessary to do that for model training or inference, but will speed up the process. +*If you run into problems with the GPU/CUDA setup, please open a discussion in the Q&A section of* `DeepACSA discussions `_ *and assign the label "Problem".* diff --git a/docs/source/line_check.png b/docs/source/line_check.png new file mode 100644 index 0000000..955968a Binary files /dev/null and b/docs/source/line_check.png differ diff --git a/docs/source/main.png b/docs/source/main.png index 8bcfe88..10abf88 100644 Binary files a/docs/source/main.png and b/docs/source/main.png differ diff --git a/docs/source/make_mask.png b/docs/source/make_mask.png deleted file mode 100644 index b772384..0000000 Binary files a/docs/source/make_mask.png and /dev/null differ diff --git a/docs/source/manual_scale.png b/docs/source/manual_scale.png new file mode 100644 index 0000000..8131880 Binary files /dev/null and b/docs/source/manual_scale.png differ diff --git a/docs/source/manual_scale_info.png b/docs/source/manual_scale_info.png new file mode 100644 index 0000000..59645d6 Binary files /dev/null and b/docs/source/manual_scale_info.png differ diff --git a/docs/source/mask_info.png b/docs/source/mask_info.png deleted file mode 100644 index 417e8ad..0000000 Binary files a/docs/source/mask_info.png and /dev/null differ diff --git a/docs/source/masked_image.png b/docs/source/masked_image.png new file mode 100644 index 0000000..d20d3a9 Binary files /dev/null and b/docs/source/masked_image.png differ diff --git a/docs/source/news.rst b/docs/source/news.rst index 5dee21c..2f56832 100644 --- a/docs/source/news.rst +++ b/docs/source/news.rst @@ -1,10 +1,33 @@ News ========== -Hamstring models ----------------- +v0.3.2: +------- +We introduced a model for the automatic segmentation of the anatomical cross-sectional area of the patellar tendon and updated the documentation and usage examples accordingly. +All models, the installer and examples can be downloaded from the `DeepACSA repository `_. + +Patellar tendon models +---------------------- 1. Data: - We provide, together with `ORBlab Michigan `_, models for the automatic segmentation of the biceps femoris long head at several muscle length in male, female young and adult people. More information can be found in the `DeepACSA repo `_. The models can be downloaded from `OSF `_. + We provide a model for the automatic segmentation of the patellar tendon anatomical cross-sectional area (ACSA) at 25%, 50%, and 75% of tendon length in healthy subjects (**UNet 3+**). Further information is available in the `DeepACSA repository `_, and the model together with reliability data can be downloaded from `OSF `_. + + We evaluated two model architectures for patellar tendon segmentation: *UNet-VGG16* and *UNet 3+*. Their performance was assessed by comparing automated predictions with manual segmentations. Reliability was evaluated and is reported in the figure below. + + Overall, both models demonstrated good agreement with manual analysis, with **UNet 3+** showing the most consistent performance. Detailed methodology and results are reported in our publication (`Guzzi et al., 2026 `_). + + + .. figure:: PT_reliability.png + :width: 100% + :alt: inspect_figure + + Figure 1: Comparison of manual segmentation versus VGG16 UNet and UNet 3+ predictions (with and without removal of erroneous predictions). + +Vastus medialis model +--------------------- +1. Data: + We provide a model for the automatic segmentation of the vastus medialis cross-sectional area (ACSA) in healthy participants as well participants with ACL injuries. + + A *UNet-VGG16* model was evaluated and compared to manual analysis. Comparability calculations and detailled methodology can be found at `Tayfur et al. 2025 `_ v0.3.1: ------- @@ -20,32 +43,34 @@ v0.3.1: We included a new model architecture as pre-trained models in the DeepACSA dataset for the RF and VL muscles. The new dataset can be accessed `here `_ These model architectures were: - - **UNet3+** `(Huang et al., 2020) `_ for RF and VL: It uses full-scale skip connections, causing increased skip connections. These additional skip connections in between layers help to preserve data that is needed for further training. Full-scale skip connections incorporate low-level details with high-level semantics from feature maps in different scales, which is beneficial for medical image segmentation. + - We provide, together with `ORBlab Michigan `_, models for the automatic segmentation of the biceps femoris long head at several muscle length in male, female young and adult people. More information can be found in the `DeepACSA repo `_. The models and further informations on them can be downloaded from the `respective OSF `_. + + - **UNet3+** (`Huang et al., 2020 `_) for RF and VL: It uses full-scale skip connections, causing increased skip connections. These additional skip connections in between layers help to preserve data that is needed for further training. Full-scale skip connections incorporate low-level details with high-level semantics from feature maps in different scales, which is beneficial for medical image segmentation. To summarise the results for the rectus femoris, for the visually inspected (segmented masks were visually checked for plausibility) rectus femoris images, the original architecture was slightly exceeded by the UNet 3+. Moving towards the vastus lateralis, the UNet 3+ closely followed by TransUNet outperformed the original architecture on the uninspected data. For the visually inspected data of the vastus lateralis, the UNet 3+ outperformed the other models. - We provide two more models and compared them to manual analysis (Fig. 1-4). Users are adivsed to select the performing models for VL and RF. However, we still advise to test the selected model prior to usage. + We provide two more models and compared them to manual analysis (Fig. 2-5). Users are adivsed to select the performing models for VL and RF. However, we still advise to test the selected model prior to usage. All models were trained using K-fold (5 folds used) cross-validation to ensure that overfitting is reduced. .. figure:: RF_without_inspection.png - :scale: 50 % + :scale: 70 % :alt: inspect_figure - Figure 1: Comparison of RF images to manual analysis with uninspected results. + Figure 2: Comparison of RF images to manual analysis with uninspected results. .. figure:: RF_with_inspection.png - :scale: 50 % + :scale: 70 % :alt: inspect_figure - Figure 2: Comparison of RF images to manual analysis with inspected results. + Figure 3: Comparison of RF images to manual analysis with inspected results. .. figure:: VL_without_inspection.png - :scale: 50 % + :scale: 70 % :alt: inspect_figure - Figure 2: Comparison of VL images to manual analysis with inspected results. + Figure 4: Comparison of VL images to manual analysis with inspected results. .. figure:: VL_with_inspection.png - :scale: 50 % + :scale: 70 % :alt: inspect_figure - Figure 2: Comparison of VL images to manual analysis with inspected results. + Figure 5: Comparison of VL images to manual analysis with inspected results. diff --git a/docs/source/outliers.png b/docs/source/outliers.png new file mode 100644 index 0000000..bebcca4 Binary files /dev/null and b/docs/source/outliers.png differ diff --git a/docs/source/remove_gui.png b/docs/source/remove_gui.png new file mode 100644 index 0000000..d3c48d4 Binary files /dev/null and b/docs/source/remove_gui.png differ diff --git a/docs/source/removed_parts.png b/docs/source/removed_parts.png new file mode 100644 index 0000000..5d0c611 Binary files /dev/null and b/docs/source/removed_parts.png differ diff --git a/docs/source/scale_check.png b/docs/source/scale_check.png new file mode 100644 index 0000000..5f1e849 Binary files /dev/null and b/docs/source/scale_check.png differ diff --git a/docs/source/scale_result.png b/docs/source/scale_result.png new file mode 100644 index 0000000..2ea5827 Binary files /dev/null and b/docs/source/scale_result.png differ diff --git a/docs/source/tests.rst b/docs/source/tests.rst index 9b7b974..6889781 100644 --- a/docs/source/tests.rst +++ b/docs/source/tests.rst @@ -5,16 +5,36 @@ Tests Image analaysis """"""""""""""" -So far, we have not included unit testing in DeepACSA. However, in the DeepACAS_example that you can download `here `_ you will find an *images_test* folder. -There, we have included three rectus femoris images as well as an *Original_Results.xlsx* file. Analyse these images using the ``RF`` muscle option, ``Line`` scaling option and an ``Imagedepth`` of **4.5**. -Tick the ``Calculate Volume`` checkbox to ``YES``. Compare your results with the one in the *Original_Results.xlsx*. Should the results be identical, DeepACSA works fine. In theory, every pre-trained model should produce similar -results. However, for the sake of accuracy, we used the VGG16-Unet for the test results. +DeepACSA currently does not include formal unit testing. +However, the ``DeepACSA_example_v0.3.2.zip`` folder available for download `here `_ contains an ``images_test`` folder with three rectus femoris images and an ``Original_Results.xlsx`` file. +These files can be used to verify that your setup is functioning correctly. To do so, follow the steps below after downloading the files as described in the :ref:`Installation ` section: + +1. Double click the installed ``DeepACSA_v0.3.2`` icon to open the GUI. +2. Click the **Input** button and select the root directory: ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.2/images_test/``. +3. Click the **Model** button and select the model you downloaded from the Zenodo repository: ``unet3plus_RectFem_v0.3.1.h5`` +4. Select the Scaling type **Line**, structure **Rectus Femoris**, and an image depth (**Depth (cm)**) of **4.5**. +5. Set the **Volume Calculation** to **Yes** and keep the **Slice Distance (cm)** at **7**. +6. Click **Run** +7. After the analysis (which takes only a few seconds), two new files should appear in the ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.2/images_test/`` folder: ``Analyzed_images.pdf`` and ``Results.xlsx``. +8. Compare your results with those in ``Original_Results_v0.3.2.xlsx``. If the results are identical, DeepACSA is working correctly. In theory, each pre-trained model should produce similar results. + +.. figure:: GUI_test_images.png + :scale: 75 % + :alt: inspect_figure Model training """""""""""""" -In order to test wheter the model training option included in DeepACSA is functional, open the ``Train Model`` model -by clicking the ``Advanced Analysis`` button and selecting **Train model**. Then select the **image** folder in the "DeepACSA_example/training_test" folder clicking the ``Images`` -button and the **mask** folder in the "DeepACSA_example/training_test" folder clicking the ``Mask`` button. Subsequently, select the "DeepACSA_example/training_test" as -output directory by clicking the ``Output`` button. Leave all training parameters as specified and click ``Start Training``. -Once the training process is completed you should have a results plot, a .h5 model file and a .xlsx weights file in the output directory. -If this is the case, the training process worked! +To test wheter the model training option included in DeepACSA is functional, follow these steps: + +1. Double click the installed ``DeepACSA_v0.3.2`` icon to open the GUI.. +2. Click on **Advanced Methods** and select **Train Model** from the drop-down menu in the newly opened window. +3. Click the **Images** button and select the image directory: ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.1/training_test/images``. +4. Click the **Masks** button and select the mask directory: ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.1/training_test/masks``. +5. Click the **Output** button and select the output directory: ``C:/Users/your_user/Desktop/DeepACSA_example_v0.3.1/training_test/``. +6. Leave all training hyperparameters as specified (**Batch Size** = 1, **Learning Rate** = 0.00001, **Epochs** = 3, **Loss Function** = BCE) and click the **Start Training** button. +7. A first pop-up window will inform you whether the images and masks have been properly loaded. A second pop-up window will inform you that the model has been compiled. Once you click "OK", the training will start. +8. Once the training process is completed, you should find a ``Training_Results.tif`` plot, a ``Test_Apo.h5`` model file and a ``Test_apo.csv`` file containing the per-epoch training and validation metrics (i.e, IoU, accuracy, loss, and learning rate) in the output directory. If this is the case, the training process was succesful. + +.. figure:: GUI_test_training.png + :scale: 75 % + :alt: inspect_figure diff --git a/docs/source/train_masks.png b/docs/source/train_masks.png deleted file mode 100644 index 4d89767..0000000 Binary files a/docs/source/train_masks.png and /dev/null differ diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 464b5c1..d864cca 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -5,228 +5,371 @@ Usage Welcome to the DeepACSA tutorial. Here you will learn how to automatically analyse ultrasonography images of human lower limb muscles. -You will do so by making extensive use of the graphical user interface provided by DeepACSA. -The basics on how to use the GUI are demonstrated in this `video `_. -You can find the details on how to start the graphical user interface, how to train your own neural networks and how to calculate muscle volume below. -Have fun! +You will do so by making extensive use of the graphical user interface (GUI) provided by DeepACSA. -Not included in the video: --------------------------- +| The basic functionality of the GUI is demonstrated in the following sections. We guide you through a logical, practice-oriented example that illustrates how each feature can be implemented within a typical laboratory workflow. +| You will find detailed instructions below on how to analyze ultrasound images manually, train your own neural networks, perform automated analysis, and calculate muscle volume as part of this structured example. +| Have fun! -- Loss Function: In the new version of the GUI, you can find a ``Loss Function`` dropdown under the ``Model Path`` textentry. Here you need to specify the loss function used during model training. So far, we implemented *IoU*, *Focal Loss* and *Dice Loss*. When using our pre-trained models, simply select *IoU*. -- Model Training (see :ref:`trainlabel`) -- Image Labelling (see :ref:`datalabel`) -- Mask Inspection (see :ref:`masklabel`) -- Image Augmentation (see :ref:`augmentlabel`) -- Volume Calculation (see :ref:`volumelabel`) +What is covered on this page: +----------------------------- -**Please note that the video is using an older version of DeepACSA. The GUI has been updated since then, but the overall workflow remains the same.** +* :ref:`Image preprocessing and anonymization ` +* :ref:`Manual data labeling and mask creation ` +* :ref:`Image augmentation ` +* :ref:`Training your own model ` +* :ref:`Automatic image analysis with models ` +* :ref:`Mask inspection and quality control ` +* :ref:`Muscle volume calculation ` -**Attention MacOS users:** -The DeepACSA package is only fully functional on windows OS and was not properly tested on MacOS. However, with restricted functionality macOS users can employ the DeepACSA as well. With macOS, the manual scaling option for image analysis is not functional. Therefore, images cannot be scaled this way in the GUI. A possible solution is to scale the analysis results subsequent to completion of the analysis. Therefore, the pixel per centimeter must be calculated elsewhere. One option is to use `FIJI `_. By drawing a line on the image, it is possible to see the length of the line in pixel units. Open the respective image in FIJI by drag and drop. Draw a line on the image with a known distance of one centimetre, click `cmd + m` and get the length of the line in pixel unit from the result window. Do that for every image with varying scanning depth. Divide the analysis results for muscle thickness and fascicle length by the linelength in pixel units. The result will be the muscle thickness and fascicle length in centimeter units. +**Attention macOS users:** + +| The DeepACSA package is only fully functional on Windows OS and was not properly tested on macOS. However, with restricted functionality macOS users can use DeepACSA as well. +| With macOS, the manual scaling option for image analysis is not functional. Therefore, images cannot be scaled this way in the GUI. A possible solution is to scale the analysis results subsequent to completion of the analysis. +| Therefore, the pixels per centimeter must be calculated elsewhere. One option is to use `FIJI `_. By drawing a line on the image, it is possible to see the length of the line in pixel units. Open the respective image in FIJI by drag and drop. Draw a line on the image with a known distance of one centimetre, click ``cmd + m`` and get the length of the line in pixel units from the result window. +| Repeat this procedure for images acquired at different scanning depths. Determine the pixel-to-centimeter conversion factor (pixels per centimeter) for each image. To convert ACSA values from pixels to cm², divide the predicted area (in pixels) by the square of the pixel-per-centimeter value. Good to know ------------ -All relevant instructions and guidelines for the installation of the DeepACSA software are described in the installation section, so please take a look there if anything is unclear. We have also provided information on what to do when you encounter problems during the installation process, encounter errors during the analysis process that are not caught by the GUI (no error message pop ups and advises you what to do), if you want to contribute to the DeepACSA software package, and how you can reach us. +All relevant instructions and guidelines for the installation of the DeepACSA software are described in the :ref:`installation section `, so please take a look there if anything is unclear. We have also provided information on what to do when you encounter problems during the installation process; encounter errors during the analysis process that are not caught by the GUI (no error message pop ups and advises you what to do); want to :ref:`contribute ` to the DeepACSA software package, and how you can reach us. Before we start with this tutorial, here are some important tips: -- In case you plan an analysis on images taken from different muscles, we strongly advise to - test the algorithm first and in case of bad performance, train your own models. We have provided - extensive documentation on how to do so in the section “Training your own network” in this tutorial. +* In case you plan an analysis on images taken from different muscles, we strongly advise to test the algorithm first and in case of bad performance, train your own models. We have provided extensive documentation on how to do so in the :ref:`Training your own network section ` in this tutorial. + +* Bad model performance can be detected. The first and easiest step to take is to :ref:`visually inspect ` the output of the models. If the segmentation results and the actual aponeuroses overlap on most of the analysed images, model performance is good. Secondly, you should manually analyse a few of your images and compare the model results to your manual results. If both results are similar, model performance is good, if not, train a separate model. + +* Although we used extensive data augmentation during the model training process, we caution users about the generalizability of our models. Deep learning is no magic! Even though our model demonstrated good performance on unseen images during testing, we cannot confidently claim that they will work fine on all lower limb ultrasonography images. Model performance may be affected by variations in device type, anatomical region, or ultrasound acquisition settings; even when analyzing muscles that were included in the training dataset. -- Although we used extensive data augmentation during the model training process, we must - caution you about the generalizability of our models. Deep learning is no magic! Even though our model demonstrated good performance on unseen images during testing, we cannot confidently claim that they will work fine on all lower limb ultrasonography images. - It is possible that even for images of muscles represented in our training data set, different device types, different muscle regions and even different settings of ultrasonography devices during image acquisition might offset model performance. +* Quality matters! Ensure that the images you intend to analyse with DeepACSA are of high quality. High quality implies good image contrast, appropriate brightness, clearly visible aponeuroses, and proper probe alignment perpendicular to the muscle plane. If the quality of the images you want to analyse is bad, the results will be as well. -- Quality matters! Please pay attention that the images you want to analyse with DeepACSA are - of high quality. High quality means good image contrast, appropriate image brightness, clearly visible fascicles and aponeurosis and clear alignment of the probe with the fascicle plain. If the quality of the images you want to analyse is bad, the results will be as well. +Using the GUI +------------- + +This section assumes that you have completed the steps in the :ref:`installation section ` and can launch the GUI using one of the two methods described there. After launching, the following screen should appear: + +.. figure:: main.png + :scale: 75 % + :alt: main_gui_figure -- Bad model performance can be detected. The first and easiest step to take is to visually - inspect the output of the models. If the segmentation results and the actual aponeuroses overlap on most of the analysed images, model performance is good. If not, adapt the analysis parameters (how to do so is covered in the tutorials) or train a separate model. Secondly, you should manually analyse a few of your images and compare the model results to your manual results. If both results are similar, model performance is good. If not, train a separate model. + Main GUI Window -Starting the interface ----------------------- +In the following sections, the functionalities of DeepACSA are presented using a single, consistent example. This example serves as a common thread and is referenced throughout the sections. -In the very first step of this tutorial, we will take a look at how to start the graphical user interface (GUI) once it was installed. We have provided three different installation procedures: +.. _removeparts: -1. downloading the DeepACSA.exe `file `_ +Removing image parts +"""""""""""""""""""" +Assume that, in your laboratory, you have acquired transverse ultrasound images of the rectus femoris muscle at different percentages of femur length in 60 subjects. Before proceeding with further analysis, it may be useful to crop or mask additional information displayed by the ultrasound machine that could mislead automated processing. This step is also recommended for data anonymization, particularly when filenames or patient information are consistently displayed in the same image location. -2. installing the DeepACSA python package with pip, Pypi or locally. +| If you plan to delegate the analysis or prepare the data for publication, working in a blinded manner with anonymized images is strongly advised to reduce potential bias. +| DeepACSA provides a dedicated functionality to remove such regions, allowing you to work with cleaner and anonymized data. The example below illustrates the use of this feature: -Let's begin with 1., how to start the GUI when you downloaded the DeepACSA.exe: -It doesn't get any easier than this. Navigate to the downloads folder and place the DeepACSA.exe file somewhere you can easily find it again. Done so, you just have to double click the DeepACSA.exe file with your left mouse button to start the GUI. Once you’ve done that, the GUI should open and you are ready to start an analysis. +.. figure:: removed_parts.png + :scale: 20 % + :alt: removed_parts -Now to 2., how to start the GUI when you installed the DeepACSA python package via Pip, Github and Pypi: -There are essentially two ways you can start the GUI. But first lets make sure that DeepACSA was correctly installed. The package should be included in the conda virtual environment, as you probably installed it locally in your machine. Therefore, open a prompt and activate the environment by typing + Left: original image containing non-relevant and personal overlay information. Right: cleaned image with superfluous information removed. + +1. Before starting, ensure that the region you intend to remove is consistently located across all images to be edited. Only one selection can be made and it will be applied to every image in the directory. +2. Launch the GUI and select ``Remove Image Parts`` from the ``Advanced Methods`` menu. +3. In the newly opened window, click ``Load Image`` to select the directory containing the images you wish to clean or anonymize. +4. After selecting the directory, a new window will open displaying the first image. +5. Left-click and drag to select the area you wish to cover with a black rectangle. If you misclick, simply click and drag again; the previously selected area will be replaced. -``conda activate DeepACSA`` +.. figure:: remove_gui.png + :scale: 40 % + :alt: remove_gui -If this does not work, go back to the Installation section (see :ref:`installlabel`). -You should see the activated environment now in the left round brackets. Next type + GUI for removing image parts, with the selected region highlighted in red. -``conda list`` +6. After selecting the desired area, click ``Remove Parts`` to remove the selected pixel region **from all images in the directory**. +7. Once the process is complete, close the ``Remove Image Parts`` window by clicking the **X** in the upper-right corner. +8. A new folder named ``Processed`` will appear in the input directory, containing the edited images. +9. Multiple regions cannot be selected within a single run. To remove an additional area, close the ``Remove Image Parts`` window, reopen it, select the newly created ``Processed`` folder as the input directory, and repeat the procedure. -to see all packages installed in the DeepACSA environment. When DeepACSA is included, you are good to go. If this is not the case, navigate to the source folder of DeepACSA (with the pyproject.toml file) in your prompt. Type +.. _datalabel: -``python -m pip install -e .`` +Data labelling +"""""""""""""" -to install the package locally (for more information, see section Installation (see :ref:`installlabel`)) -Check again if the package is listed inside the environment now. If you still encounter a problem, ask a question in the Q&A discussion section of DLTrack on Github and add the Label “Problem”. +After preprocessing and anonymizing your images, the next step is to prepare your dataset for model development. +If no trained model is available, the first step is to manually identify and segment the anatomical cross-sectional area (ACSA) in each image. This process allows you to determine the ACSA of the subject's rectus femoris, with the output reported in cm². -However, if DeepACSA is included in the conda environment, type +This step is called data labelling and, together with data preparation, is the most important stage of model training. -``python -m Deep_ACSA`` +DeepACSA provides a dedicated functionality that enables manual labeling of images and the creation of corresponding segmentation masks. -to start the GUI. The location of you prompt is irrelevant, as long as the DeepACSA conda environment is activated. The main GUI window should open now. +1. After launching the GUI click the ``Advanced Methods`` button. +2. Select the ``Create Masks`` option. The ``Create Masks Winow`` will open. -.. figure:: main.png - :scale: 50 % - :alt: main_gui_figure +.. figure:: create_masks.png + :scale: 100 % + :alt: create_masks_figure + + Create Masks Window. + +3. Click the ``Image Dir`` button to select the folder containing the images you want to label. All images must be stored in a single folder without subfolders. +4. Start the mask creation process by clicking the ``Create Masks`` button. Two folders will be created in the directory specified under ``Image Dir``: **_images** and **_masks**. The original images are copied to *_images*, and the corresponding binary masks are saved in *_masks* using the same filename with a *.tif* extension. +5. The first image will open automatically. After completing the analysis of one image, the next image in the directory will open sequentially. +6. The first thing to do is scaling the image. Left-click on two points that are exactly 1 cm apart **(red points)**. If you misclick, remove the most recent point by right-clicking on the image. After selecting the two scaling points, the scaling factor will be displayed in the terminal. +7. You will then be prompted to segment the muscle ACSA by repeatedly left-clicking around the muscle boundary **(blue points)**. Incorrect points can be removed by right-clicking. + +.. figure:: masked_image.png + :scale: 50 % + :alt: masked_image + + Scaled image (red dots) being segmented (blue dots). + +8. Once segmentation is complete, press ``Enter`` to finalize the labeling and proceed to the next image. Image filenames are incremented automatically based on the number of files already present in these folders. The calculated ACSA expressed in cm² will be displayed in the terminal. To interrupt the analysis at any time, press ``Esc``. +9. After each successfully analyzed image, the renamed image and corresponding mask are saved in their respective folders. In addition, the filename and calculated ACSA are appended to the **Areas.csv** file. Because the results are reported in cm², accurate scaling is essential. +10. If you stop the analysis before processing all images in the selected ``Image Dir``, move any already analyzed images (**_images** folder), masks (**_masks** folder), and the generated **Areas.csv** file before restarting the procedure. +11. Do not delete the **_images**, **_masks** or **Areas.csv** files, as they contain the labeled images and corresponding masks. Moving them is only necessary to avoid labeling duplicates. + +**If the GUI becomes unresponsive after the labeling process, restart the application before continuing.** + +.. _augmentlabel: + +Image Augmentation +"""""""""""""""""" +Assume that you have completed the manual labeling process for 30 participants. With an additional 30 participants remaining to be analyzed, you decide to train a custom model to make the analysis faster and more objective. +Training a model at this stage using data from only 30 participants may result in limited generalization performance, as the dataset size is relatively small. + +To address this challenge, you can apply **image augmentation**. This process artificially increases the size of the training dataset by generating additional image–mask pairs through operations such as **flipping, rotation, and translation** of original images. + +The purpose of image augmentation is to improve model robustness and generalization by exposing the model to a greater variety of training examples. - Main GUI Window +For more detailed information about the augmentation procedure, please refer to our `paper `_ or to the corresponding functions described in the documentation. + +1. After launching the GUI click the ``Advanced Methods`` button and select the ``Train Model`` option. +2. Before starting the augmentation process, make sure to create a backup copy of your original images and masks. + +.. figure:: GUI_augmentation.png + :scale: 75 % + :alt: GUI_augmentation + + GUI seetings to augment the dataset. + +3. Click the ``Images`` button to select the directory containing the images you want to augment. +4. Click the ``Masks`` button to select the corresponding masks directory. +5. Click ``Augment Images``. The process may take a couple of minutes, depending on the number of image–mask pairs being augmented. +6. Once the augmentation process is complete, a pop-up window will indicate successful execution. The augmented images and masks (augmented threefold) are saved in the same directories that were selected as input. .. _trainlabel: Train your own networks ------------------------ +""""""""""""""""""""""" -It is advantageous to have a working GPU setup, otherwise model training will take much longer. -How to setup you GUI for DeepACSA is described above and in the installation section. -Although you can adapt a number of parameters during training, you cannot change the neural network architecture from the GUI (of course you could modify source code to do so). -To explain the parameters used during model that are adaptable from the GUI is out of the scope of this tutorial. However, we would like to refer you to `this excellent introductory course `_ in case you are a deep learning beginner. -Training your own networks for muscle architecture analysis requires pairs of original images and manually labelled masks. Examples are provided for you in the “DeepACSA_example/model_training” folder. If you haven't downloaded this folder, please do so now (`link `_). Unzip the folder and put it somewhere accessible, for example on your desktop. +| *At this stage, having a functional GPU setup is advantageous, otherwise model training will take much longer. as model training can otherwise take considerably longer. Instructions for setting up the DeepACSA GUI are provided in the* :ref:`installation section `. +| *While several training parameters can be adjusted from the GUI, the neural network architecture itself cannot be modified without editing the source code.* -.. _datalabel: +*Explaining the adjustable training parameters in detail is beyond the scope of this tutorial. If you are new to deep learning, we recommend* `this excellent introductory course `_. +*Training a custom network for muscle ACSA analysis requires paired original images and manually labeled binary masks. Example data are available in the* ``DeepACSA_example_v0.3.2/model_training`` *folder. If you have not yet downloaded this folder, please do so from this* `link `_, *extract the archive, and place it on your desktop.* -Data labelling -"""""""""""""" +Continuing with our example, assume that you have augmented your dataset and are now ready to train your own model. This will allow the images from the remaining 30 subjects to be analyzed objectively and more efficiently. -The most important part for model training is data preparation and labelling. -We have provided a functionality inside DeepACSA that allows you to label your images and create the masks. -1. Start the GUI and click the ``Advanced Methods`` button. -2. Select the ``Create Masks`` option. The ``Create Masks Winow`` will open. +1. Organize your data so that all training images are stored in one folder and the corresponding binary masks *(with identical filenames and a* **.tif** *extension)* are stored in a separate folder. +2. Launch the GUI and select ``Train Model`` in the ``Advanced Methods`` menu. +3. Click ``Images`` to specify the directory containing your training images. +4. Click ``Masks`` to specify the directory containing the corresponding training masks. +5. Click ``Output`` to specify the directory where all training outputs will be saved. +6. Specify the ``Batch Size`` manually or using the drop-down menu. Keep in mind that the batch size should be proportional to your available computational resources (e.g., limited RAM or no GPU requires a smaller batch size). +7. Specify the ``Learning Rate`` manually or using the drop-down menu if you wish to modify the default value. +8. Choose the number of ``Epochs`` manually or using the drop-down menu. For actual model training, **use more than 3 epochs**. The default value of 3 is intended for testing purposes only. +9. Select a ``Loss Function`` from the drop-down menu. Currently, the only implemented option is **binary cross-entropy (BCE)**. +10. Click ``Start Training`` and follow the instructions provided in the pop-up messages. Once training has started, a live preview of the training metrics will be displayed in the terminal. +11. Once the training is complete, three files will appear in the specified ``Output Directory``: -.. figure:: create_masks.png - :scale: 50 % - :alt: create_masks_figure + * ``Test_apo.csv`` - contains the following metrics recorded per epoch: IoU, accuracy, loss, learning rate *(lr)*, validation IoU *(val_IoU)*, validation accuracy *(val_accuracy)*, and validation loss *(val_loss)*. + * ``Test_Apo.h5`` – the trained model file. + * ``Training_Results.tif`` – a plot of the training and validation curves across epochs. - Create Masks Window. +**Restart the GUI when model training is completed to successfully use the trained models.** + +.. _automatic_analysis_label: + +Analyzing images with a model +""""""""""""""""""""""""""""" + +**Disclaimer:** Before applying your own trained model, its validity and reliability should be evaluated. +Now that you have trained your own model, you are ready to apply it to analyze the images from the remaining 30 participants. + +1. After launching the GUI, the main window will open. This is the window used for image analysis. +2. First, click ``Input`` to select the root directory containing the images to be analyzed. +3. Then, click ``Model`` to select the trained model file that will be used for analysis. +4. Select the anatomical ``Structure`` to be analyzed from the drop-down menu. The currently implemented structures are rectus femoris, vastus lateralis, vastus medialis, gastrocnemius lateralis, gastrocnemius medialis, biceps femoris, and patellar tendon. +5. DeepACSA provides four ``Scaling Types``, each designed for a specific image type. Select the most appropriate option based on your image characteristics: -2. In the ``Image Dir``, specify the path to the images you want to label by clicking the ``Image Dir`` button. These images should be contained in a single folder with no subfolders. -3. Start the mask creation by clicking the ``Create Masks`` button. Two folders will be created in the ``Image Dir`` folder: *train_images* and *train_masks*. The original images will be copied to *train_images* and the masks will be saved in *train_masks* with the same filename but a ".tif" extension. -4. An information window opens telling you to scale the images. Click ``OK`` to continue. + **A.** ``Line Scaling``: + + You should use this scaling type when the image contains a continuous straight reference line with markers at regular intervals, as shown in the example below. + + .. figure:: RF_line_scale.png + :scale: 60 % + :alt: RF_line_scale + + Rectus femoris image with line scaling and highlighted intervals. + + To ensure proper scaling, you will be prompted to enter the ``Depth (cm)`` at which the image was acquired. This information is required to convert pixel values to cm² and it should always be recorded during image acquisition. + + All images analyzed within a single run must have been acquired at the same depth. If your dataset includes images acquired at different depths, organize them into separate folders and repeat the analysis process for each folder using the corresponding depth value. + + **B.** ``Bar Scaling``: + + This scaling type is used when multiple scaling bars are present at regular intervals instead of a continuous reference line, as shown in the image below. + + .. figure:: RF_bar_scale.png + :scale: 47 % + :alt: RF_line_scale + + Rectus femoris image with bar scaling. + + For this analysis type, you will be prompted to specify the ``Spacing (mm)``, i.e. the distance in millimiters, between two horizontal scaling bars. As in the previous example, this information is required to convert pixel values to cm² and should always be recorded during image acquisition. + + As described above, images with different spacing values must be analyzed in separate runs, since the spacing can only be specified once per run. + + **C.** ``Manual Scaling``: + + For the manual scaling type, the scale must be defined individually for each image to be analyzed. This option is useful when the automatic scaling methods fail to correctly detect the scale, or when certain images cannot be processed using the other scaling types. + Whenever possible, we recommend using one of the fully automatic scaling types instead. + + Once the analysis is started, you will be prompted to left-click on two points in the image that are exactly 1 cm apart. After placing the two points, press the **q key** to continue. + If you misclick, simply click again to reset the scaling points. The scaling factor is always calculated based on the last two points placed. Although only one point is visible at a time, the scale is still computed correctly. + + .. figure:: manual_scale.png + :scale: 50 % + :alt: manual_scale + + Rectus femoris image with one white manual scaling point on the scaling line. + + After placing two points and pressing the **q key**, a pop-up window will appear displaying the scaling factor used to convert pixels to cm, as shown in the example below. + + .. figure:: scale_result.png + :scale: 100 % + :alt: scale_result + + Pop-up window displaying the scaling factor used to convert pixels in cm. -.. figure:: mask_info.png - :scale: 50 % - :alt: mask_info_figure + After clicking ``OK``, the process will repeat for each image in the selected root directory. + If you interrupt the analysis before processing all images, move the already analyzed images, the ``Analyzed_images.pdf`` file, and the ``Results.xlsx`` file to a different directory before restarting the procedure. This prevents duplicate processing and unintended overwriting of data. - Mask Scaling Info. + **D.** ``No scaling``: + + The final option is to run the analysis without applying any scaling. In this case, the ACSA values will be returned in pixels rather than in cm. -5. Another GUI will open. Create the mask by clicking on the image. Follow the instructions in the GUI. Repeat this process for all images in the selected ``Image Dir``. + This approach is useful when the scale factor is already known and consistent across multiple images but cannot be automatically detected by the other scaling methods. It may also be suitable for quick exploratory comparisons where absolute area values are not required, or when images have already been calibrated or preprocessed externally prior to being loaded into DeepACSA. -.. figure:: make_mask.png - :scale: 50 % - :alt: make_mask_figure +6. Click ``Run``. +7. After the analysis is complete, two files will appear in the directory containing your images: + +* ``Analyzed_images.pdf`` contains the normalized and resized images with the predicted muscle area overlaid, as shown in the example below. This file allows you to visually assess the quality and validity of the model predictions. + +.. figure:: image_overlay.png + :scale: 100 % + :alt: image_overlay + + Original image with the predicted ACSA boundary overlaid in light blue. - Mask Creation GUI. +* | ``Results.xlsx`` provides, for each image, the filename, selected anatomical structure, predicted area (in cm² or pixels if no scaling was applied), echo intensity, area-to-circumference ratio, circumference, and, if selected, the calculated muscle volume in cm³. + | It is important to note that the current models have been validated only for ACSA measurements. All other reported metrics are provided for additional information and should be interpreted with caution. -All images in the selected folder will be used during mask creation. Please remeber to remove already labelled images from the seleccted ``Image Dir`` to not label them twice. *DO NOT* delete the ``train_images`` and ``train_masks`` folder as those contain your labelled images and leave the masks and renamed images in those folders as well as the image names will be incrementally increased based on the number of images contained in the folders. -In addtion to the renamed images, the analysis results (ACSA) are saved to an excel file which is why the scaling step is necessary. Thus, the *mask creation can also be used for manual image analysis*. +8. For the line and bar scaling types, the ``Analyzed_images.pdf`` file also includes a visual representation of the applied scaling. This allows you to verify that the scaling has been correctly detected, as shown in the example below. -**It might be necessary to restart the GUI subsequent to the labelling process in case of non-responsiveness** +.. figure:: scale_check.png + :scale: 30 % + :alt: scale_check + + **Left:** correctly detected line scaling, with a green line spanning the full reference scale in the image. + **Right:** correctly detected bar scaling, showing the pixel scale on one side and the distance between two bars on the other. + +9. If you select an automatic scaling method, it may fail for certain images. In such cases, an additional file named ``failed_images.txt`` will appear in the image directory. This file lists the images for which scaling was unsuccessful. Each line corresponds to one failed image, for example ``Scaling line not found in C:/path/to/your/image/image.tif`` .. _masklabel: Mask / label inspection """"""""""""""""""""""" +Assume that you have completed the analysis of all 60 subjects and now wish to verify that the ACSA segmentation of the rectus femoris is correct for each image. +DeepACSA allows you to visualize the binary mask overlaid on the original image to identify clearly erroneous segmentations or predictions. Based on this assessment, inaccurate segmentations can be discarded and, if necessary, the image can be reanalyzed manually. -1. Start the GUI and click the ``Advanced Methods`` button. -2. Select the ``Inspect Masks`` option. The ``Inspect Masks Winow`` will open. +1. For this inspection, organize all images in a single folder without subfolders. Store the corresponding binary masks (**with identical filenames**) in a separate folder, also without subfolders. +2. After launching the GUI, select the ``Inspect Mask`` option from the ``Advanced Methods`` menu to open the mask inspection window. +3. Click ``Image Dir`` to specify the directory containing your images, then click ``Mask Dir`` to select the directory containing the corresponding binary masks. +4. Optionally, you can choose the image from which to begin the inspection by specifying its zero-based index in the ``Start at Image:`` field. By default, inspection starts from the first image. -.. figure:: inspect.png - :scale: 50 % +.. figure:: inspect_masks.png + :scale: 80 % :alt: inspect_figure Inspect Masks Window. -3. In the ``Image Dir``, specify the path to the images you want to label by clicking the ``Image Dir`` button. These images should be contained in a single folder with no subfolders. -4. In the ``Mask Dir``, specify the path to the images you want to label by clicking the ``Mask Dir`` button. These masks should be contained in a single folder with no subfolders. -5. Change the ``Start Index``, in case you don't want to start at the first image. -6. Clik the ``Inspect Masks`` button to check you training images and masks. Another GUI will open. +5. Click ``Inspect Masks`` to verify your image–mask pairs. An information window will appear indicating whether the image and mask directories are correctly organized. Specifically, it checks that both directories contain the same number of files and that corresponding images and masks share identical filenames. If inconsistencies are detected, they will be reported in a pop-up window similar to the example shown below. -.. figure:: inspect.png - :scale: 50 % - :alt: inspect_figure +.. figure:: outliers.png + :scale: 25 % + :alt: outliers - Inspect Masks Window. + Information window displayed when two masks are found without corresponding images in the image folder. The table indicates the files that have no matching counterpart in the complementary directory. -7. An Information window will appear telling you if a similar amount of images / files are in the image and mask directories as well as whether they are names similarly. **Images and respective masks must have the same filename for proper pairing during training.** +6. After clicking ``OK``, a new window will open displaying the first image–mask pair. -.. figure:: inspect_info.png - :scale: 50 % - :alt: inspect_figure +.. figure:: inspect_gui.png + :scale: 30 % + :alt: inspect_gui + + Inspect Masks Window. - Inspection Information. +7. Here, you can scroll through all images with the overlaid masks using the **left and right arrow keys** and inspect them for potential errors. Ensure that the masks fully cover the muscle area and do not overlap with adjacent muscles or aponeuroses, nor exclude portions of the muscle region. To facilitate inspection, DeepACSA provides a navigation menu in the bottom-left corner of the window. -8. By clicking ``OK``, the inspection window will open. Here you can scroll through all your images with overlays masks and check for any errors. Please ensure masks cover muscle area completely and do not overlap with other muscles /aponeuroses or exclude muscle regions. -If errors are found, relabel images using create masks functionality or simply delete the image / label pair using the ``Delete`` button. + * The **Save** button (1) allows you to save a screenshot of the current GUI setup, with or without zoom. + * The **Configure Subplots** button (2) allows you to adjust the image position within the GUI for improved visualization. + * The **Zoom** button (3) allows you to zoom into a specific area by drawing a rectangle. Left-click and drag to zoom in; right-click and drag to zoom out. + * The **Pan** button (*four arrows icon*) (4) allows you to move the image and zoom locally. Left-click and drag to move the current view; right-click and drag to zoom in or out around the selected point. + * The **Back** and **Forward** arrows (5) allow you to navigate between previous visualization states. + * The **Home** button (6) restores the original view of the image. + +8. If errors are detected, you can relabel images using the ``Create Masks`` functionality or delete the image–mask pair using the ``Delete`` button. Note that clicking ``Delete`` permanently removes the image–mask pair from both directories; therefore, always keep a backup copy of your original images. +9. Below is an example of a correctly predicted ACSA and an erroneous prediction that was clearly missegmented. .. figure:: inspect_correct.png - :scale: 50 % + :scale: 60 % :alt: inspect_figure Correctly labelled image. -.. figure:: Inspect_incorrect.png - :scale: 50 % + +.. figure:: inspect_incorrect.png + :scale: 60 % :alt: inspect_figure Incorrectly labelled image. -.. _augmentlabel: +.. _volumelabel: -Image Augmentation -"""""""""""""""""" +Calculate muscle volume +""""""""""""""""""""""" -Prior to model training, it is possible to augment your images. The main goal is to enlarge the training data size. -For detailded information about the augmentation process take a look at our `paper `_ or at the respective functions in the docs. -1. Start the GUI and click the ``Advanced Methods`` button. -2. Select the ``Train Model`` option. -2. In the ``Image Directory``, specify the path to your training images by pressing the ``Images`` button. -3. In the ``Mask Directory``, specify the path to your training masks by pressing the ``Masks`` button. -4. Click the ``Augment Images`` button and the augmentation process starts. -The image augmentation process starts and the images will be augmented three-fold. +Now that you have verified that all acquired images have been correctly analyzed and that the ACSA values are accurate, you may proceed to calculate an approximation of the rectus femoris muscle volume for each one of your subjects. -Model Training -"""""""""""""" +DeepACSA employs the truncated cone formula to estimate muscle volume. Before performing volume calculations using DeepACSA, several important considerations should be kept in mind: -1. Start the GUI and click the ``Train Model`` button. The -2. In the ``Image Directory``, specify the path to your training images by pressing the ``Images`` button. -3. In the ``Mask Directory``, specify the path to your training masks by pressing the ``Masks`` button. -4. In the ``Output Directory``, specify the path to your output directory by pressing the ``Output`` button. Here, all the files from the training process will be saved. -5. Specify a ``Batch Size``. You can enter a value you like, but be cautioned to keep it proportional to your available computing power (i.e., smaller available RAM or no GPU = smaller ``Batch Size``). -6. Specify the ``Learning Rate``. Enter a value if you prefer a different learning rate than the default. -7. Choose a number of ``Epochs``. *Please pay attention to user MORE than 3 ``Epochs`` during actual model training*. We entered 3 as default for testing purposes, otherwise testing would take forever. -8. Define a ``Loss Function``. So far, you can choose binary cross-entropy (BCE), focal loss (FL) and dice loss (Dice). -9. Press the ``Start Training`` button and follow the instructions given by the pop-up messages. As stated above, the trained model will be in the ``Output Directory`` once the traing is completed. +* Muscle volume calculation can only be performed when multiple images of the same muscle from the same participant, acquired at different muscle regions, are available and stored in a single folder. +* The images **must** be named in order from proximal to distal (i.e., ``img0.tif``, ``img1.tif``, ``img2.tif``, ..., ``imgN.tif``). -**Restart the GUI when model training is completed to sucessfully use the trained models.** +.. figure:: volume_info.png + :scale: 20 % + :alt: volume_info -.. _volumelabel: + File naming in the correct proximal-to-distal order. -Calculate muscle volume ------------------------ +* The distance (**in cm**) between consecutive images must be known and constant. +* Increasing the number of images improves the accuracy of the volume estimation. -Here we employ the truncated cone formula to calculate the muscle volume. To calculate the volume of a muscle using DeepACSA, several prerequisites are important: +1. To calculate muscle volume, launch the main GUI and select the folder containing images of the same muscle from the same participant, acquired at different regions, as the ``Root Directory``. +2. Select the ``Model Path`` corresponding to the trained model you wish to use. +3. Specify the ``Scaling Type`` and ``Structure`` as described in the :ref:`automatic analysis section `. +4. In the muscle volume section, select ``Yes`` from the ``Volume Calculation`` drop-down menu. +5. Enter the ``Slice Distance (cm)`` corresponding to the **costant** distance between the acquired images. +6. Click ``Run``. The muscle volume will be calculated by combining all ACSA measurements from the images in the ``Root Directory`` and will be displayed at the bottom of the ``Results.xlsx`` table. -- Muscle volume calculation can only be done when several images of the same muscle across several muscle regions are available and stored in the same folder. -- The images **must** be named in order from proximal to distal (i.e, img0.tif, img1.tif, img2.tif, ..., imgN.tif). -- The distance between the images of different muscle regions is knwon and constant. -- The higher the number of images, the more accurate the volume calculation. -1. Once all prerequisites are fullfilled, select the folder conatining the images of the same muscle and different regions as ``Root Directory``. -2. Choose a ``Model Path`` to the model you wish to use and specify all other parameters in the GUI accordingly. -3. In the ``Muscle Volume`` section, select the checkbox ``Yes`` for ``Volume Calculation`` and specify the ``Distance`` as the distance between the images you collected. -4. Proceed to press the button ``Run`` and the muscle volume will be calculated combining all ACSA measurements of the images in the ``Root Directory`` and will be displayed in the .xlsx result file. diff --git a/docs/source/volume_info.png b/docs/source/volume_info.png new file mode 100644 index 0000000..917839e Binary files /dev/null and b/docs/source/volume_info.png differ diff --git a/environment.yml b/environment.yml index 32cdde1..da7c608 100644 --- a/environment.yml +++ b/environment.yml @@ -1,19 +1,28 @@ -name: DeepACSA +name: DeepACSA0.3.2 channels: - defaults + - conda-forge dependencies: - - python=3.9 - - pip=21.0.1 + - python=3.10 + - pip=22.2.2 - pip: - - Keras==2.9.0 - - matplotlib==3.5.2 - - numpy==1.21.2 - - opencv-contrib-python==4.5.3.56 - - pandas==1.3.3 - - Pillow==8.3.2 - - scikit-image==0.18.3 - - scikit-learn==0.24.2 - - tensorflow==2.9.0 - - tqdm==4.62.3 - - openpyxl==3.0.9 - - h5py==3.4.0 + - customtkinter==5.2.2 + - CTkToolTip==0.8 + - jupyter==1.0.0 + - Keras==2.13.1 + - keras-unet-collection==0.1.13 + - matplotlib==3.6.1 + - numpy==1.23.4 + - opencv-contrib-python==4.6.0.66 + - openpyxl==3.0.10 + - orientationpy==0.2.0.4 + - pandas==1.5.1 + - pandastable==0.13.1 + - Pillow==9.2.0 + - pre-commit==2.17.0 + - scikit-image==0.19.3 + - scikit-learn==1.1.2 + - sewar==0.4.5 + - shapely==2.0.5 + - tensorflow==2.13.0 + - tqdm==4.64.1 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6b68060..8b486e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,35 +1,39 @@ [build-system] -requires = ["hatchling"] +requires = ["setuptools", "wheel", "hatchling"] build-backend = "hatchling.build" + [project] name = "DeepACSA" -version = "0.3.1" +version = "0.3.2" authors = [ { name="Paul Ritsche", email="paul.ritsche@unibas.ch" }, ] description = "Automatic analysis of transversal muscle ultrasonography images" readme = "README_Pypi.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.9", - "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.10", "Operating System :: Microsoft :: Windows", ] dependencies = [ - "Keras==2.9.0", - "matplotlib==3.5.2", - "numpy==1.21.2", - "opencv-contrib-python==4.5.3.56", - "pandas==1.3.3", - "Pillow==8.3.2", - "scikit-image==0.18.3", - "scikit-learn==0.24.2", - "tensorflow==2.9.0", - "tqdm==4.62.2", - "openpyxl==3.0.9", - "h5py==3.4.0", + "customtkinter==5.2.2", + "CTKToolTip==0.8", + "Keras==2.13.1", + "matplotlib==3.6.1", + "numpy==1.23.4", + "opencv-contrib-python==4.6.0.66", + "openpyxl==3.0.10", + "pandas==1.5.1", + "Pillow==9.2.0", + "pre-commit==2.17.0", + "scikit-image==0.19.3", + "scikit-learn==1.1.2", + "sewar==0.4.5", + "shapely==2.0.5", + "tensorflow==2.13.0", + "tqdm==4.64.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 9aead37..7feb5d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,19 @@ -Keras==2.9.0 -matplotlib==3.8.0 -numpy==1.21.2 -opencv-contrib-python==4.5.3.56 -pandas==1.3.3 -Pillow==8.3.2 -scikit-image==0.18.3 -scikit-learn==0.24.2 -tensorflow==2.9.0 -tqdm==4.62.3 -openpyxl==3.0.9 -h5py==3.4.0 +customtkinter==5.2.2 +CTkToolTip==0.8 +Keras==2.13.1 +matplotlib==3.6.1 +numpy==1.23.4 +opencv-contrib-python==4.6.0.66 +openpyxl==3.0.10 +orientationpy==0.2.0.4 +pandas==1.5.1 +pandastable==0.13.1 +Pillow==9.2.0 +pre-commit==2.17.0 +scikit-image==0.19.3 +scikit-learn==1.1.2 +sewar==0.4.5 +shapely==2.0.5 +tensorflow==2.13.0 +tqdm==4.64.1 +keras-unet-collection==0.1.13 diff --git a/setup.py b/setup.py index 4d0368a..f461f98 100644 --- a/setup.py +++ b/setup.py @@ -8,25 +8,28 @@ from distutils.core import setup INSTALL_REQUIRES = [ - "Keras==2.9.0", - "matplotlib==3.5.2", - "numpy==1.21.2", - "opencv-contrib-python==4.5.3.56", - "pandas==1.3.3", - "Pillow==8.3.2", - "scikit-image==0.18.3", - "scikit-learn==0.24.2", - "tensorflow==2.9.0", - "tqdm==4.62.2", - "openpyxl==3.0.9", - "h5py==3.4.0", + "customtkinter==5.2.2", + "CTKToolTip=0.8", + "Keras==2.13.1", + "matplotlib==3.6.1", + "numpy==1.23.4", + "opencv-contrib-python==4.6.0.66", + "openpyxl==3.0.10", + "pandas==1.5.1", + "Pillow==9.2.0", + "pre-commit==2.17.0", + "scikit-image==0.19.3", + "scikit-learn==1.1.2", + "sewar==0.4.5", + "shapely==2.0.5", + "tensorflow==2.13.0", + "tqdm==4.64.1", ] CLASSIFIERS = [ "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Operating System :: Microsoft :: Windows", - "License :: OSI Approved :: Apache Software License", ] @@ -36,7 +39,7 @@ if __name__ == "__main__": setup( name="DeepACSA", - version="0.3.1", + version="0.3.2", author="Paul Ritsche", author_email="paul.ritsche@unibas.ch", long_description=long_descr, @@ -45,6 +48,6 @@ packages=find_packages(), package_data={"DeepACSA": ["*"]}, classifiers=CLASSIFIERS, - python_requires=">=3.9", + python_requires=">=3.10", install_requires=INSTALL_REQUIRES, )