Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion engines/qus
Submodule qus updated 49 files
+3 −5 README.md
+3 −3 quantus/analysis/README.md
+24 −0 quantus/analysis/bmode/analysis_methods/bmode_intensity.py
+25 −0 quantus/analysis/bmode/analysis_methods/bmode_snr.py
+3 −3 quantus/analysis/bmode/framework.py
+0 −44 quantus/analysis/bmode/functions.py
+15 −12 quantus/analysis/options.py
+85 −0 quantus/analysis/paramap/analysis_methods/attenuation_coef.py
+64 −0 quantus/analysis/paramap/analysis_methods/bsc.py
+368 −0 quantus/analysis/paramap/analysis_methods/bsc_stft.py
+75 −0 quantus/analysis/paramap/analysis_methods/central_freq_shift.py
+31 −0 quantus/analysis/paramap/analysis_methods/compute_power_spectra.py
+159 −0 quantus/analysis/paramap/analysis_methods/hscan.py
+76 −0 quantus/analysis/paramap/analysis_methods/lizzi_feleppa.py
+37 −0 quantus/analysis/paramap/analysis_methods/nakagami_params.py
+4 −3 quantus/analysis/paramap/framework.py
+0 −849 quantus/analysis/paramap/functions.py
+3 −3 quantus/analysis_config/README.md
+30 −0 quantus/analysis_config/utc_config/config_loaders/clarius_C3_config.py
+31 −0 quantus/analysis_config/utc_config/config_loaders/clarius_L15_config.py
+57 −0 quantus/analysis_config/utc_config/config_loaders/custom.py
+31 −0 quantus/analysis_config/utc_config/config_loaders/philips_3d_config.py
+27 −0 quantus/analysis_config/utc_config/config_loaders/pkl_rf.py
+0 −168 quantus/analysis_config/utc_config/functions.py
+9 −8 quantus/analysis_config/utc_config/options.py
+3 −3 quantus/data_export/README.md
+93 −0 quantus/data_export/csv/export_funcs/bsc_stft_arr.py
+26 −0 quantus/data_export/csv/export_funcs/descr_vals.py
+40 −0 quantus/data_export/csv/export_funcs/hscan_arr.py
+70 −0 quantus/data_export/csv/export_funcs/hscan_stats.py
+38 −0 quantus/data_export/csv/export_funcs/paramap_arr.py
+54 −0 quantus/data_export/csv/export_funcs/radiomics_stats.py
+3 −2 quantus/data_export/csv/framework.py
+0 −288 quantus/data_export/csv/functions.py
+15 −13 quantus/data_export/options.py
+2 −2 quantus/seg_loading/README.md
+14 −10 quantus/seg_loading/options.py
+0 −0 quantus/seg_loading/seg_loaders/__init__.py
+37 −0 quantus/seg_loading/seg_loaders/nifti_voi.py
+4 −34 quantus/seg_loading/seg_loaders/pkl_roi.py
+2 −2 quantus/visualizations/README.md
+16 −14 quantus/visualizations/options.py
+3 −2 quantus/visualizations/paramap/framework.py
+0 −491 quantus/visualizations/paramap/functions.py
+228 −0 quantus/visualizations/paramap/visualization_funcs/plot_bsc_stft.py
+164 −0 quantus/visualizations/paramap/visualization_funcs/plot_hscan_result.py
+66 −0 quantus/visualizations/paramap/visualization_funcs/plot_hscan_wavelets.py
+53 −0 quantus/visualizations/paramap/visualization_funcs/plot_ps_window_data.py
+ requirements.txt
109 changes: 107 additions & 2 deletions src/ceus/application_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"""

import os
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List
from PyQt6.QtCore import QThread, pyqtSignal

from .mvc.base_model import BaseModel
Expand Down Expand Up @@ -88,6 +88,7 @@ class ApplicationModel(BaseModel):

# Additional signals for application-specific events
image_loaded = pyqtSignal(UltrasoundImage)
preprocessing_complete = pyqtSignal(UltrasoundImage)
segmentation_loaded = pyqtSignal(CeusSeg)

def __init__(self):
Expand Down Expand Up @@ -251,7 +252,90 @@ def load_image(self, image_path: str, scan_loader_kwargs: Dict[str, Any] = None)
# Start loading
self._set_loading(True)
self._scan_worker.start()


# =========================================================================
# Preprocessing Interface
# =========================================================================

def get_preprocessing_options(self) -> Dict[str, Any]:
"""
Get available preprocessing functions from the engine.

Returns:
Dict[str, Any]: Dictionary of function names and function objects
"""
from engines.ceus.src.image_preprocessing.options import get_im_preproc_funcs
return get_im_preproc_funcs()

def get_preprocessing_kwargs_requirements(self, func_names: list) -> list:
"""
Get required keyword arguments for a list of preprocessing functions.

Args:
func_names: List of preprocessing function names

Returns:
list: List of required keyword arguments
"""
from engines.ceus.src.image_preprocessing.options import get_required_im_preproc_kwargs
return get_required_im_preproc_kwargs(func_names)

def apply_preprocessing(self, func_configs: List[Dict[str, Any]]) -> None:
"""
Apply preprocessing to the model's current image.
This modifies the image data in the model.

Args:
func_configs: List of dicts with 'name' and 'kwargs' for each function
"""
if not self._image_data:
self._emit_error("No image loaded to preprocess")
return

try:
funcs = self.get_preprocessing_options()
processed_image = self._image_data

for config in func_configs:
name = config['name']
kwargs = config.get('kwargs', {})
if name in funcs:
processed_image = funcs[name](processed_image, **kwargs)
else:
print(f"WARNING: Preprocessing function {name} not found")

self._image_data = processed_image
self.preprocessing_complete.emit(self._image_data)
except Exception as e:
self._emit_error(f"Error during preprocessing: {e}")

def enhance_image(self, image: UltrasoundImage, func_configs: List[Dict[str, Any]]) -> UltrasoundImage:
"""
Enhance a given UltrasoundImage and return the result.
Does not modify the model state. Used for preview/on-the-fly enhancement.

Args:
image: UltrasoundImage object to enhance
func_configs: List of dicts with 'name' and 'kwargs' for each function

Returns:
UltrasoundImage: The enhanced image object
"""
try:
funcs = self.get_preprocessing_options()
processed_image = image

for config in func_configs:
name = config['name']
kwargs = config.get('kwargs', {})
if name in funcs:
processed_image = funcs[name](processed_image, **kwargs)

return processed_image
except Exception as e:
print(f"DEBUG: enhance_image error: {e}")
return image

def _validate_image_input(self, input_data: Dict[str, Any]) -> bool:
"""
Validate input data for scan loading.
Expand Down Expand Up @@ -294,6 +378,17 @@ def _on_image_loading_complete(self, image_data: UltrasoundImage) -> None:
# Check if loading was successful
if isinstance(image_data, UltrasoundImage):
self._image_data = image_data

# Print NIfTI information if applicable
scan_path = getattr(image_data, 'scan_path', '')
if scan_path and scan_path.lower().endswith(('.nii', '.nii.gz')):
print(f"\n--- NIfTI Image Loaded (QuantUS GUI) ---")
print(f"Path: {scan_path}")
print(f"Shape: {getattr(image_data.pixel_data, 'shape', 'Unknown')}")
print(f"Pixel Dimensions: {getattr(image_data, 'pixdim', 'Unknown')}")
print(f"Frame Rate: {getattr(image_data, 'frame_rate', 'Unknown')}")
print(f"----------------------------------------\n")

self.image_loaded.emit(image_data)
else:
print(f"DEBUG: Image loading failed - invalid image data:")
Expand Down Expand Up @@ -434,6 +529,16 @@ def _on_segmentation_loading_complete(self, seg_data: CeusSeg) -> None:
# Check if loading was successful
if seg_data and hasattr(seg_data, 'seg_mask') and seg_data.seg_mask is not None:
self._seg_data = seg_data

# Print NIfTI information if applicable
seg_path = getattr(self._seg_worker, 'seg_path', '')
if seg_path and seg_path.lower().endswith(('.nii', '.nii.gz')):
print(f"\n--- NIfTI Segmentation Loaded (QuantUS GUI) ---")
print(f"Path: {seg_path}")
print(f"Shape: {getattr(seg_data.seg_mask, 'shape', 'Unknown')}")
print(f"Pixel Dimensions: {getattr(seg_data, 'pixdim', 'Unknown')}")
print(f"-----------------------------------------------\n")

self.segmentation_loaded.emit(seg_data)
else:
print(f"DEBUG: Segmentation loading failed - invalid seg data")
Expand Down
19 changes: 12 additions & 7 deletions src/ceus/image_loading/views/file_selection_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ def _show_loading_message(self) -> None:

def _on_choose_image_path(self) -> None:
"""Handle image file selection."""
if self._file_extensions == ["FOLDER"]:
is_folder = any(ext.upper() == "FOLDER" for ext in self._file_extensions)
if is_folder:
dir_name = QFileDialog.getExistingDirectory(self, "Select Directory")
if dir_name:
self._ui.image_path_input.setText(dir_name)
Expand All @@ -133,12 +134,16 @@ def _on_generate_image(self) -> None:
if not os.path.exists(image_path):
self.show_error(f"Image file does not exist: {os.path.basename(image_path)}")
return
if not image_path.endswith(tuple(self._file_extensions)) and self._file_extensions != ['FOLDER']:
self.show_error(f"Image file must have one of the following extensions: {', '.join(self._file_extensions)}")
return
if self._file_extensions == ["FOLDER"] and not os.path.isdir(image_path):
self.show_error("Input path must be a folder!")
return

is_folder = any(ext.upper() == "FOLDER" for ext in self._file_extensions)
if not is_folder:
if not image_path.endswith(tuple(self._file_extensions)):
self.show_error(f"Image file must have one of the following extensions: {', '.join(self._file_extensions)}")
return
else:
if not os.path.isdir(image_path):
self.show_error("Input path must be a folder!")
return

self.clear_error()

Expand Down
168 changes: 166 additions & 2 deletions src/ceus/seg_loading/views/draw_roi_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,24 @@
from scipy import interpolate
from PIL import Image, ImageDraw
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.colors import LinearSegmentedColormap

from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog, QSlider, QVBoxLayout, QFrame, QCheckBox, QLabel
from PyQt6.QtCore import pyqtSignal, Qt

from ...mvc.base_view import BaseViewMixin
from ..ui.draw_roi_ui import Ui_constructRoi
from engines.ceus.src.data_objs import UltrasoundImage
from engines.ceus.src.image_preprocessing.functions import enhance_clahe, enhance_gamma

# Philips CEUS Colormap: Grayscale -> Red -> Yellow
philips_colors = [
(0.0, 0.0, 0.0), # 0% - Black
(0.4, 0.4, 0.4), # 40% - Gray
(0.8, 0.0, 0.0), # 80% - Red
(1.0, 1.0, 0.0) # 100% - Yellow
]
philips_cmap = LinearSegmentedColormap.from_list("philips_ceus", philips_colors)


class DrawROIWidget(QWidget, BaseViewMixin):
Expand Down Expand Up @@ -59,6 +70,18 @@ def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None
self._target_frame = 0 # Target frame for smooth transitions
self._frame_update_pending = False

# Enhancement parameters
self._clahe_clip_limit = 1.2
self._gamma = 1.5
self._width_scale = 1.0

# Enhancement parameters
self._clahe_clip_limit = 1.2
self._gamma = 1.5
self._use_philips_ceus = False
self._enhanced_cache = None # Cache for enhanced current frame
self._enhanced_cache_idx = -1

self._setup_ui()
self._connect_signals()
self._show_draw_type_selection()
Expand Down Expand Up @@ -110,6 +133,7 @@ def _setup_ui(self) -> None:

# Setup matplotlib canvas for frame preview
self._setup_matplotlib_canvas()
self._setup_enhancement_controls()

# Display frame preview
self._initialize_frame_preview()
Expand Down Expand Up @@ -230,6 +254,136 @@ def _update_roi_scatter(self) -> None:
else:
self._roi_scatter_artist.set_offsets(np.empty((0, 2)))

def _on_width_changed(self, value: int) -> None:
"""Handle width scale change."""
self._width_scale = value / 10.0
if hasattr(self, 'width_val_lbl'):
self.width_val_lbl.setText(f"{self._width_scale:.1f}")
self._update_aspect_ratio()

def _update_aspect_ratio(self) -> None:
"""Update the aspect ratio of the main axes based on width scale."""
if not hasattr(self, '_ax') or self._ax is None:
return

# Calculate base physical aspect ratio
width_phys = self._all_frames.shape[2] * self._image_data.pixdim[1] * self._width_scale
height_phys = self._all_frames.shape[1] * self._image_data.pixdim[0]

if height_phys != 0:
new_aspect = width_phys / height_phys
extent = self._im_artist.get_extent()
self._ax.set_aspect(abs((extent[1]-extent[0])/(extent[3]-extent[2]))/new_aspect)
self._matplotlib_canvas.draw_idle()

def _setup_enhancement_controls(self) -> None:
"""Add enhancement sliders beside the frame slider in a single horizontal line."""
# Container frame for enhancement controls
enh_group = QFrame()
enh_group.setStyleSheet("background-color: rgba(255, 255, 255, 0); border: none;")

# Main horizontal layout for the enhancement section
container_layout = QHBoxLayout(enh_group)
container_layout.setContentsMargins(0, 0, 15, 0)
container_layout.setSpacing(15)

def create_compact_control(label_text, min_val, max_val, current_val, callback):
# Widget to hold label, slider, and value in ONE line
ctrl_widget = QWidget()
ctrl_layout = QHBoxLayout(ctrl_widget)
ctrl_layout.setContentsMargins(0, 0, 0, 0)
ctrl_layout.setSpacing(5)

lbl = QLabel(label_text)
lbl.setStyleSheet("font-size: 10px; color: white; font-weight: bold;")
ctrl_layout.addWidget(lbl)

# Slider
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(min_val, max_val)
slider.setValue(current_val)
slider.setStyleSheet(self._ui.frame_slider.styleSheet())
slider.setFixedWidth(70)
slider.setFixedHeight(12)
slider.valueChanged.connect(callback)
ctrl_layout.addWidget(slider)

val_lbl = QLabel(f"{current_val/10.0:.1f}")
val_lbl.setStyleSheet("color: #3498db; font-weight: bold; font-size: 10px;")
val_lbl.setMinimumWidth(22)
val_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft)
ctrl_layout.addWidget(val_lbl)

return ctrl_widget, slider, val_lbl

# Create controls
clahe_w, self.clahe_slider, self.clahe_val_lbl = create_compact_control(
"CLAHE", 1, 100, int(self._clahe_clip_limit * 10), self._on_clahe_changed
)
gamma_w, self.gamma_slider, self.gamma_val_lbl = create_compact_control(
"GAMMA", 1, 40, int(self._gamma * 10), self._on_gamma_changed
)
width_w, self.width_slider, self.width_val_lbl = create_compact_control(
"WIDTH", 1, 50, int(self._width_scale * 10), self._on_width_changed
)

# Pseudo colouring toggle nicely aligned
self.philips_check = QCheckBox("Pseudo colouring")
self.philips_check.setStyleSheet("color: white; font-weight: bold; font-size: 11px;")
self.philips_check.stateChanged.connect(self._on_philips_toggled)

# Add to horizontal layout
container_layout.addWidget(clahe_w)
container_layout.addWidget(gamma_w)
container_layout.addWidget(width_w)
container_layout.addWidget(self.philips_check)

# Add to the layout beside the frame slider (below the image)
self._ui.frameControlsLayout.insertWidget(0, enh_group)

def _on_clahe_changed(self, value: int) -> None:
"""Handle CLAHE clip limit change."""
self._clahe_clip_limit = value / 10.0
if hasattr(self, 'clahe_val_lbl'):
self.clahe_val_lbl.setText(f"{self._clahe_clip_limit:.1f}")
self._invalidate_enhancement_cache()

def _on_gamma_changed(self, value: int) -> None:
"""Handle gamma change."""
self._gamma = value / 10.0
if hasattr(self, 'gamma_val_lbl'):
self.gamma_val_lbl.setText(f"{self._gamma:.1f}")
self._invalidate_enhancement_cache()

def _on_philips_toggled(self, state: int) -> None:
"""Handle Philips CEUS pseudocolor toggle."""
self._use_philips_ceus = state == Qt.CheckState.Checked.value
# Update colormap on artist
if self._im_artist:
new_cmap = philips_cmap if self._use_philips_ceus else 'gray'
self._im_artist.set_cmap(new_cmap)
self._matplotlib_canvas.draw_idle()

def _invalidate_enhancement_cache(self) -> None:
"""Invalidate the enhancement cache and trigger display update."""
self._enhanced_cache = None
self._enhanced_cache_idx = -1
self._force_frame_update()

def _enhance_frame(self, frame_2d: np.ndarray) -> np.ndarray:
"""Enhance a 2D image frame using backend engine functions."""
# Create a temporary UltrasoundImage for processing
temp_im = UltrasoundImage(self._image_data.scan_path)
temp_im.pixel_data = frame_2d
temp_im.pixdim = self._image_data.pixdim
temp_im.frame_rate = self._image_data.frame_rate

# Apply enhancements
temp_im = enhance_clahe(temp_im, clip_limit=self._clahe_clip_limit)
temp_im = enhance_gamma(temp_im, gamma=self._gamma)

return temp_im.pixel_data

def _on_frame_changed(self, value: int) -> None:
"""Handle frame slider change with optimized performance."""
self._target_frame = value
Expand All @@ -239,8 +393,18 @@ def _on_frame_changed(self, value: int) -> None:
def _update_frame_display(self, frame_index: int) -> None:
"""Update the frame display with consistent parameters."""
if self._im_artist:
self._displayed_im = self._all_frames[frame_index]
# Update cache if needed
if self._enhanced_cache is None or self._enhanced_cache_idx != frame_index:
self._enhanced_cache = self._enhance_frame(self._all_frames[frame_index])
self._enhanced_cache_idx = frame_index

self._displayed_im = self._enhanced_cache
self._im_artist.set_array(self._displayed_im)

# Ensure correct colormap is applied (e.g. after initialization)
new_cmap = philips_cmap if self._use_philips_ceus else 'gray'
self._im_artist.set_cmap(new_cmap)

self._ui.cur_frame_label.setText(str(np.round(frame_index*self._image_data.frame_rate, decimals=2)))

def _force_frame_update(self) -> None:
Expand Down
Loading
Loading