From f6d9551787f5a7e1226130a9f9f27c5e31b80d19 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:53:49 +0000 Subject: [PATCH 1/2] Implement flexible Filter Stack for Ops - Replaced fixed Subtract/Divide operations with a dynamic filter stack UI. - Implemented new filters: Median, Min, Max, Gaussian Blur, Highpass, Local Normalization (CLAHE/Mean), Threshold, Clip. - Refactored `ImageData` pipeline to support sequential list-based operations. - Added `FilterStackWidget` for managing filter order and parameters. - Updated `MainWindow` to handle reference image loading for arithmetic ops within the stack. Co-authored-by: PiMaV <93649984+PiMaV@users.noreply.github.com> --- blitz/data/filters.py | 147 +++++++++++++ blitz/data/image.py | 137 +++++++++--- blitz/layout/filter_stack.py | 389 +++++++++++++++++++++++++++++++++++ blitz/layout/main.py | 231 ++++++++++++++------- blitz/layout/ui.py | 57 +---- 5 files changed, 808 insertions(+), 153 deletions(-) create mode 100644 blitz/data/filters.py create mode 100644 blitz/layout/filter_stack.py diff --git a/blitz/data/filters.py b/blitz/data/filters.py new file mode 100644 index 0000000..3a524ff --- /dev/null +++ b/blitz/data/filters.py @@ -0,0 +1,147 @@ +import cv2 +import numpy as np + + +def ensure_float32(image: np.ndarray) -> np.ndarray: + if image.dtype != np.float32: + return image.astype(np.float32) + return image + + +def median(image: np.ndarray, ksize: int) -> np.ndarray: + """Apply median filter to remove hot pixels/noise. + ksize must be odd. + """ + if ksize < 1: + return image + if ksize % 2 == 0: + ksize += 1 + + # Median Blur only supports uint8, uint16, int16, float32 + # But usually float32 is fine. + # Note: cv2.medianBlur works on single channel or 3 channel images. + # If image is 4D (T, H, W, C) or 3D (H, W, C), handle accordingly. + # Since operations usually run on single image (H, W, C) or single frame (H, W) in the pipeline: + if image.ndim == 3 and image.shape[2] == 1: + return cv2.medianBlur(image, ksize) + elif image.ndim == 2: + return cv2.medianBlur(image, ksize) + else: + # Multi-channel logic if needed, but usually we process per channel or assume grayscale for scientific data + # If float32, cv2.medianBlur is supported. + return cv2.medianBlur(image, ksize) + + +def min_filter(image: np.ndarray, ksize: int) -> np.ndarray: + """Apply minimum filter (Erosion).""" + if ksize < 1: + return image + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize)) + return cv2.erode(image, kernel) + + +def max_filter(image: np.ndarray, ksize: int) -> np.ndarray: + """Apply maximum filter (Dilation).""" + if ksize < 1: + return image + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize)) + return cv2.dilate(image, kernel) + + +def gaussian_blur(image: np.ndarray, sigma: float) -> np.ndarray: + """Apply Gaussian Blur (Lowpass).""" + if sigma <= 0: + return image + # ksize=0 means computed from sigma + return cv2.GaussianBlur(image, (0, 0), sigmaX=sigma, sigmaY=sigma) + + +def highpass(image: np.ndarray, sigma: float) -> np.ndarray: + """Apply Highpass filter (Original - Lowpass).""" + if sigma <= 0: + return np.zeros_like(image) + low = gaussian_blur(image, sigma) + return image - low + + +def clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid_size: int = 8) -> np.ndarray: + """Contrast Limited Adaptive Histogram Equalization. + Requires uint8/uint16 input for cv2.createCLAHE. + If input is float, we normalize to 0-65535 (uint16) apply CLAHE, then convert back. + """ + if tile_grid_size < 1: + return image + + orig_dtype = image.dtype + if orig_dtype == np.float32 or orig_dtype == np.float64: + # Normalize to 0-1 range first if not already + min_val, max_val = image.min(), image.max() + if max_val == min_val: + return image + + # Temporarily convert to uint16 for CLAHE + norm = (image - min_val) / (max_val - min_val) + norm_uint16 = (norm * 65535).astype(np.uint16) + + clahe_obj = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid_size, tile_grid_size)) + + # CLAHE only works on single channel. + if image.ndim == 3: + res_channels = [] + for c in range(image.shape[2]): + res_channels.append(clahe_obj.apply(norm_uint16[..., c])) + res_uint16 = np.stack(res_channels, axis=-1) + else: + res_uint16 = clahe_obj.apply(norm_uint16) + + # Convert back to original range + return (res_uint16.astype(np.float32) / 65535.0) * (max_val - min_val) + min_val + + elif orig_dtype == np.uint8: + clahe_obj = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid_size, tile_grid_size)) + if image.ndim == 3: + res_channels = [] + for c in range(image.shape[2]): + res_channels.append(clahe_obj.apply(image[..., c])) + return np.stack(res_channels, axis=-1) + return clahe_obj.apply(image) + + return image + + +def local_normalize_mean(image: np.ndarray, ksize: int) -> np.ndarray: + """Local Normalization: (Pixel - Mean) / (Std + eps) or similar. + Here implementing a simpler version requested: divide by local mean (or blur). + Let's implement: Image / (Gaussian(Image) + eps). + This highlights local variations relative to background. + """ + if ksize < 1: + return image + # Use Gaussian as "local mean" approximation + # Sigma roughly ksize / 6 for 99% coverage or just let cv2 decide from ksize + # ksize for GaussianBlur must be odd + if ksize % 2 == 0: + ksize += 1 + + local_mean = cv2.GaussianBlur(image, (ksize, ksize), 0) + + # Avoid division by zero + return image / (local_mean + 1e-6) + + +def threshold_binary(image: np.ndarray, thresh: float, maxval: float = 1.0, type_: int = cv2.THRESH_BINARY) -> np.ndarray: + """Apply thresholding.""" + _, res = cv2.threshold(image, thresh, maxval, type_) + return res + + +def clip_values(image: np.ndarray, min_val: float, max_val: float) -> np.ndarray: + """Clip values to range.""" + return np.clip(image, min_val, max_val) + +def histogram_clipping(image: np.ndarray, min_percentile: float, max_percentile: float) -> np.ndarray: + """Clip image intensity based on percentiles.""" + # Compute percentiles + low = np.percentile(image, min_percentile) + high = np.percentile(image, max_percentile) + return np.clip(image, low, high) diff --git a/blitz/data/image.py b/blitz/data/image.py index 54c3596..2337cb5 100644 --- a/blitz/data/image.py +++ b/blitz/data/image.py @@ -7,6 +7,7 @@ # from .. import settings from ..tools import log from . import ops +from . import filters from .tools import ensure_4d @@ -48,7 +49,7 @@ def __init__( self._flipped_x = False self._flipped_y = False self._redop: ops.ReduceOperation | str | None = None - self._ops_pipeline: dict | None = None # {subtract, divide} steps + self._ops_pipeline: list[dict] | None = None # List of filter steps self._agg_bounds: tuple[int, int] | None = None # Non-destructive agg range self._result_cache: dict[tuple[object, object], np.ndarray] = {} # (op, bounds) -> result self._bench_cache_hits: int = 0 # For Bench tab @@ -74,38 +75,102 @@ def _compute_ref( return None def _apply_ops_pipeline(self, image: np.ndarray) -> np.ndarray: - """Apply subtract and divide steps. Returns float32 array.""" + """Apply pipeline steps sequentially. Returns float32 array.""" pipeline = self._ops_pipeline if not pipeline: return image.astype(np.float32) if image.dtype != np.float32 else image + + # Ensure we work on float32 copy image = image.astype(np.float32) - eps = 1e-10 - for op_name in ("subtract", "divide"): - step = pipeline.get(op_name) - if not step: - continue - ref = self._compute_ref(step, image) - if ref is None: - continue - amount = np.float32(step.get("amount", 1.0)) - if amount <= 0: + + for step in pipeline: + type_ = step.get("type") + if not type_: continue - if op_name == "subtract": - ref_scaled = amount * ref - if ref.shape[0] == 1: - image -= ref_scaled - else: - image = image[: ref.shape[0]] - image -= ref_scaled - else: # divide: blend denominator towards 1 when amount<1 - denom = amount * ref + (np.float32(1.0) - amount) - denom = np.where(denom != 0, denom, np.float32(np.nan)) - if ref.shape[0] == 1: - image /= denom - else: - image = image[: ref.shape[0]] - image /= denom - np.nan_to_num(image, copy=False, nan=0.0, posinf=0.0, neginf=0.0) + + # Arithmetic Ops (Subtract, Divide) + if type_ in ("subtract", "divide"): + ref = self._compute_ref(step, image) + if ref is None: + continue + amount = np.float32(step.get("amount", 1.0)) + if amount <= 0: + continue + + if type_ == "subtract": + ref_scaled = amount * ref + if ref.shape[0] == 1: + image -= ref_scaled + else: + image = image[: ref.shape[0]] + image -= ref_scaled + else: # divide + denom = amount * ref + (np.float32(1.0) - amount) + denom = np.where(denom != 0, denom, np.float32(np.nan)) + if ref.shape[0] == 1: + image /= denom + else: + image = image[: ref.shape[0]] + image /= denom + np.nan_to_num(image, copy=False, nan=0.0, posinf=0.0, neginf=0.0) + + # Filters + elif type_ == "median": + ksize = int(step.get("ksize", 3)) + # Apply per frame + for i in range(image.shape[0]): + image[i] = filters.median(image[i], ksize) + + elif type_ == "min": + ksize = int(step.get("ksize", 3)) + for i in range(image.shape[0]): + image[i] = filters.min_filter(image[i], ksize) + + elif type_ == "max": + ksize = int(step.get("ksize", 3)) + for i in range(image.shape[0]): + image[i] = filters.max_filter(image[i], ksize) + + elif type_ == "gaussian_blur": + sigma = float(step.get("sigma", 1.0)) + for i in range(image.shape[0]): + image[i] = filters.gaussian_blur(image[i], sigma) + + elif type_ == "highpass": + sigma = float(step.get("sigma", 1.0)) + for i in range(image.shape[0]): + image[i] = filters.highpass(image[i], sigma) + + elif type_ == "clahe": + clip = float(step.get("clip_limit", 2.0)) + grid = int(step.get("tile_grid_size", 8)) + for i in range(image.shape[0]): + image[i] = filters.clahe(image[i], clip, grid) + + elif type_ == "local_normalize_mean": + ksize = int(step.get("ksize", 15)) + for i in range(image.shape[0]): + image[i] = filters.local_normalize_mean(image[i], ksize) + + elif type_ == "threshold_binary": + thresh = float(step.get("thresh", 0.5)) + maxval = float(step.get("maxval", 1.0)) + # If image is normalized 0-1 or raw counts, threshold depends on context + # Assuming user provides threshold in relevant units + for i in range(image.shape[0]): + image[i] = filters.threshold_binary(image[i], thresh, maxval) + + elif type_ == "clip_values": + min_val = float(step.get("min_val", 0.0)) + max_val = float(step.get("max_val", 1.0)) + image = filters.clip_values(image, min_val, max_val) + + elif type_ == "histogram_clipping": + min_p = float(step.get("min_percentile", 1.0)) + max_p = float(step.get("max_percentile", 99.0)) + for i in range(image.shape[0]): + image[i] = filters.histogram_clipping(image[i], min_p, max_p) + return image def _invalidate_result(self) -> None: @@ -208,8 +273,20 @@ def reduce( self._redop = operation self._agg_bounds = bounds - def set_ops_pipeline(self, config: dict | None) -> None: - """Set ops pipeline. config: {subtract:{source,bounds?,method?,reference?,amount}, divide:{...}}.""" + def set_ops_pipeline(self, config: list[dict] | None) -> None: + """Set ops pipeline. config: list of filter steps.""" + # Convert legacy dict to list if needed (for backward compatibility if I was being careful, but let's assume strict update) + if isinstance(config, dict): + # Try to convert old format to new list format + new_config = [] + if sub := config.get("subtract"): + sub["type"] = "subtract" + new_config.append(sub) + if div := config.get("divide"): + div["type"] = "divide" + new_config.append(div) + config = new_config + if self._ops_pipeline != config: self._invalidate_result() self._ops_pipeline = config diff --git a/blitz/layout/filter_stack.py b/blitz/layout/filter_stack.py new file mode 100644 index 0000000..1672a7b --- /dev/null +++ b/blitz/layout/filter_stack.py @@ -0,0 +1,389 @@ +from typing import Any, Callable + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from ..theme import get_style + + +class FilterItemWidget(QFrame): + """Widget representing a single filter in the stack.""" + + removed = pyqtSignal() + moved_up = pyqtSignal() + moved_down = pyqtSignal() + changed = pyqtSignal() + + # Signal to request loading a reference file (for Subtract/Divide) + load_reference_requested = pyqtSignal() + + def __init__(self, filter_type: str, params: dict | None = None) -> None: + super().__init__() + self.filter_type = filter_type + self.params = params or {} + + # Hold the reference image object (ImageData) separately from serializable params + self._reference_image: Any = None + if "reference" in self.params: + self._reference_image = self.params.pop("reference") + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + # Using a slightly darker background to distinguish items + self.setStyleSheet( + "FilterItemWidget { border: 1px solid #444; border-radius: 4px; " + "background-color: #2a2a2a; margin-bottom: 2px; }" + "QLabel { color: #eee; }" + ) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(4, 4, 4, 4) + self.layout.setSpacing(2) + + # Header: Name + Buttons + header_layout = QHBoxLayout() + name_label = QLabel(self._get_display_name(filter_type)) + name_label.setStyleSheet("font-weight: bold; font-size: 11px;") + header_layout.addWidget(name_label) + header_layout.addStretch() + + btn_style = "QPushButton { max-width: 16px; max-height: 16px; padding: 0px; font-size: 10px; }" + + self.btn_up = QPushButton("▲") + self.btn_up.setStyleSheet(btn_style) + self.btn_up.clicked.connect(self.moved_up.emit) + header_layout.addWidget(self.btn_up) + + self.btn_down = QPushButton("▼") + self.btn_down.setStyleSheet(btn_style) + self.btn_down.clicked.connect(self.moved_down.emit) + header_layout.addWidget(self.btn_down) + + self.btn_remove = QPushButton("✕") + self.btn_remove.setStyleSheet(btn_style + "QPushButton { color: #ff5555; }") + self.btn_remove.clicked.connect(self.removed.emit) + header_layout.addWidget(self.btn_remove) + + self.layout.addLayout(header_layout) + + # Params Area + self.params_layout = QHBoxLayout() + self.layout.addLayout(self.params_layout) + + self._setup_params_ui() + + def set_reference(self, image: Any) -> None: + self._reference_image = image + self.params["reference_loaded"] = (image is not None) + self._update_ref_button() + self.changed.emit() + + def _update_ref_button(self) -> None: + if hasattr(self, "btn_load_ref"): + loaded = self._reference_image is not None + self.btn_load_ref.setText("Remove Ref" if loaded else "Load Ref") + if loaded: + self.btn_load_ref.setStyleSheet("color: #ffaa00;") + else: + self.btn_load_ref.setStyleSheet("") + + def _get_display_name(self, type_: str) -> str: + mapping = { + "subtract": "Subtract", + "divide": "Divide", + "median": "Median (Hotpixel)", + "min": "Minimum (Erosion)", + "max": "Maximum (Dilation)", + "gaussian_blur": "Lowpass (Gaussian)", + "highpass": "Highpass", + "clahe": "Local Norm (CLAHE)", + "local_normalize_mean": "Local Norm (Mean)", + "threshold_binary": "Threshold (Binary)", + "clip_values": "Clip Values", + "histogram_clipping": "Histogram Clip", + } + return mapping.get(type_, type_.capitalize()) + + def _add_spinbox(self, key: str, label: str, val_type: type, min_: float, max_: float, step: float, default: float) -> None: + row = QHBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet("font-size: 10px;") + row.addWidget(lbl) + + if val_type == int: + sb = QSpinBox() + else: + sb = QDoubleSpinBox() + + sb.setRange(min_, max_) + sb.setSingleStep(step) + sb.setStyleSheet("font-size: 10px;") + + current_val = self.params.get(key, default) + sb.setValue(current_val) + + sb.valueChanged.connect(lambda v, k=key: self._update_param(k, v)) + row.addWidget(sb) + self.params_layout.addLayout(row) + + def _add_combobox(self, key: str, label: str, options: list[tuple[str, str]], default: str) -> QComboBox: + row = QHBoxLayout() + lbl = QLabel(label) + lbl.setStyleSheet("font-size: 10px;") + row.addWidget(lbl) + + cb = QComboBox() + cb.setStyleSheet("font-size: 10px;") + for text, data in options: + cb.addItem(text, data) + + current_val = self.params.get(key, default) + idx = cb.findData(current_val) + if idx >= 0: + cb.setCurrentIndex(idx) + + cb.currentIndexChanged.connect(lambda i, k=key, c=cb: self._update_param(k, c.itemData(i))) + row.addWidget(cb) + self.params_layout.addLayout(row) + return cb + + def _update_param(self, key: str, value: Any) -> None: + self.params[key] = value + self.changed.emit() + + def _setup_params_ui(self) -> None: + t = self.filter_type + + if t in ("subtract", "divide"): + self.cb_source = self._add_combobox("source", "Src:", [ + ("Aggregate", "aggregate"), + ("Ref File", "file"), + ], "aggregate") + + self._add_spinbox("amount", "Amt %:", float, 0.0, 100.0, 5.0, 100.0 if t == "subtract" else 100.0) + + # File Load Button (Visible only if source == file) + self.btn_load_ref = QPushButton("Load Ref") + self.btn_load_ref.setStyleSheet("font-size: 10px;") + self.btn_load_ref.clicked.connect(self.load_reference_requested.emit) + self.params_layout.addWidget(self.btn_load_ref) + + self._update_ref_button() + + # Update visibility based on source + def _update_vis(): + is_file = self.params.get("source") == "file" + self.btn_load_ref.setVisible(is_file) + + self.cb_source.currentIndexChanged.connect(_update_vis) + _update_vis() + + elif t == "median": + self._add_spinbox("ksize", "K:", int, 1, 99, 2, 3) + + elif t in ("min", "max"): + self._add_spinbox("ksize", "K:", int, 1, 99, 1, 3) + + elif t in ("gaussian_blur", "highpass"): + self._add_spinbox("sigma", "σ:", float, 0.1, 50.0, 0.5, 1.0) + + elif t == "clahe": + self._add_spinbox("clip_limit", "Clip:", float, 0.1, 100.0, 0.5, 2.0) + self._add_spinbox("tile_grid_size", "Grid:", int, 1, 64, 1, 8) + + elif t == "local_normalize_mean": + self._add_spinbox("ksize", "K:", int, 3, 255, 2, 15) + + elif t == "threshold_binary": + self._add_spinbox("thresh", "T:", float, -99999, 99999, 1.0, 0.5) + self._add_spinbox("maxval", "V:", float, 0, 99999, 1.0, 1.0) + + elif t == "clip_values": + self._add_spinbox("min_val", "Min:", float, -99999, 99999, 1.0, 0.0) + self._add_spinbox("max_val", "Max:", float, -99999, 99999, 1.0, 255.0) + + elif t == "histogram_clipping": + self._add_spinbox("min_percentile", "Min %:", float, 0.0, 100.0, 0.1, 1.0) + self._add_spinbox("max_percentile", "Max %:", float, 0.0, 100.0, 0.1, 99.0) + + +class FilterStackWidget(QWidget): + + pipeline_changed = pyqtSignal() + load_reference_requested = pyqtSignal(FilterItemWidget) # Pass the widget that requested it + + def __init__(self) -> None: + super().__init__() + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + + # Controls + controls_layout = QHBoxLayout() + self.cb_add = QComboBox() + self.cb_add.addItems([ + "Median (Hotpixel)", + "Subtract", + "Divide", + "Lowpass (Gaussian)", + "Highpass", + "Minimum (Erosion)", + "Maximum (Dilation)", + "Local Norm (CLAHE)", + "Local Norm (Mean)", + "Threshold (Binary)", + "Clip Values", + "Histogram Clip", + ]) + + # Map display names back to internal types + self._type_map = { + "Median (Hotpixel)": "median", + "Subtract": "subtract", + "Divide": "divide", + "Lowpass (Gaussian)": "gaussian_blur", + "Highpass": "highpass", + "Minimum (Erosion)": "min", + "Maximum (Dilation)": "max", + "Local Norm (CLAHE)": "clahe", + "Local Norm (Mean)": "local_normalize_mean", + "Threshold (Binary)": "threshold_binary", + "Clip Values": "clip_values", + "Histogram Clip": "histogram_clipping", + } + + self.btn_add = QPushButton("Add") + self.btn_add.clicked.connect(self._add_filter_from_ui) + + controls_layout.addWidget(self.cb_add, 1) + controls_layout.addWidget(self.btn_add) + self.layout.addLayout(controls_layout) + + # Scroll Area for stack + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self.stack_container = QWidget() + self.stack_layout = QVBoxLayout(self.stack_container) + self.stack_layout.setContentsMargins(0, 0, 0, 0) + self.stack_layout.setSpacing(2) + self.stack_layout.addStretch() # Push items up + + self.scroll.setWidget(self.stack_container) + self.layout.addWidget(self.scroll) + + self._items: list[FilterItemWidget] = [] + self._block_signals = False + + def _add_filter_from_ui(self) -> None: + name = self.cb_add.currentText() + type_ = self._type_map.get(name) + if type_: + self.add_filter(type_) + + def add_filter(self, type_: str, params: dict | None = None) -> None: + item = FilterItemWidget(type_, params) + item.removed.connect(lambda: self._remove_item(item)) + item.moved_up.connect(lambda: self._move_item(item, -1)) + item.moved_down.connect(lambda: self._move_item(item, 1)) + item.changed.connect(self._on_change) + item.load_reference_requested.connect(lambda: self.load_reference_requested.emit(item)) + + # Add to layout before the stretch + count = self.stack_layout.count() + self.stack_layout.insertWidget(count - 1, item) + self._items.append(item) + + self._on_change() + + def _remove_item(self, item: FilterItemWidget) -> None: + self.stack_layout.removeWidget(item) + item.deleteLater() + if item in self._items: + self._items.remove(item) + self._on_change() + + def _move_item(self, item: FilterItemWidget, direction: int) -> None: + if item not in self._items: + return + + idx = self._items.index(item) + new_idx = idx + direction + if 0 <= new_idx < len(self._items): + # Update internal list + self._items.pop(idx) + self._items.insert(new_idx, item) + + # Update Layout + # We must remove and re-insert. + self.stack_layout.removeWidget(item) + # Note: insertWidget(index, widget). + # layout indices map 1:1 to self._items indices because the stretch is at the end. + self.stack_layout.insertWidget(new_idx, item) + + self._on_change() + + def _on_change(self) -> None: + if not self._block_signals: + self.pipeline_changed.emit() + + def get_pipeline(self) -> list[dict]: + pipeline = [] + for item in self._items: + step = item.params.copy() + step["type"] = item.filter_type + + # Include the runtime reference object if present + if item._reference_image is not None: + step["reference"] = item._reference_image + + # For arithmetic, convert percentage amount to 0-1 factor + if item.filter_type in ("subtract", "divide"): + # The spinbox is 0-100, we need 0.0-1.0 + val = step.get("amount", 100.0) + step["amount"] = val / 100.0 + + pipeline.append(step) + return pipeline + + def set_pipeline(self, pipeline: list[dict]) -> None: + self._block_signals = True + # Clear existing + while self._items: + # We can't use _remove_item safely inside loop on self._items if modifying it + # But here we just want to clear UI + w = self._items.pop(0) + self.stack_layout.removeWidget(w) + w.deleteLater() + + for step in pipeline: + type_ = step.get("type") + if not type_: + continue + + params = step.copy() + del params["type"] + + # Convert factor back to percentage for UI + if type_ in ("subtract", "divide"): + val = params.get("amount", 1.0) + params["amount"] = val * 100.0 + + self.add_filter(type_, params) + + self._block_signals = False + self.pipeline_changed.emit() diff --git a/blitz/layout/main.py b/blitz/layout/main.py index 29306ba..a1c8018 100644 --- a/blitz/layout/main.py +++ b/blitz/layout/main.py @@ -21,6 +21,7 @@ from .winamp_mock import WinampMockLiveWidget from .tof import TOFAdapter from .ui import UI_MainWindow +from .filter_stack import FilterItemWidget URL_GITHUB = QUrl("https://github.com/CodeSchmiedeHGW/BLITZ") URL_INP = QUrl("https://www.inp-greifswald.de/") @@ -223,26 +224,14 @@ def setup_connections(self) -> None: self.ui.button_ops_open_aggregate.clicked.connect( self._ops_open_aggregate_tab ) - self.ui.combobox_ops_subtract_src.currentIndexChanged.connect( - self._update_ops_file_visibility - ) - self.ui.combobox_ops_divide_src.currentIndexChanged.connect( - self._update_ops_file_visibility - ) - for cb in (self.ui.combobox_ops_subtract_src, self.ui.combobox_ops_divide_src): - cb.currentIndexChanged.connect(self.apply_ops) - self.ui.slider_ops_subtract.valueChanged.connect( - self._update_ops_slider_labels - ) - self.ui.slider_ops_subtract.valueChanged.connect(self.apply_ops) - self.ui.slider_ops_divide.valueChanged.connect( - self._update_ops_slider_labels - ) - self.ui.slider_ops_divide.valueChanged.connect(self.apply_ops) - self.ui.button_ops_load_file.clicked.connect(self.load_ops_file) + + # New Filter Stack Connections + self.ui.filter_stack.pipeline_changed.connect(self.apply_ops) + self.ui.filter_stack.load_reference_requested.connect(self.load_reference_for_filter) self.ui.spinbox_crop_range_start.editingFinished.connect(self.apply_ops) self.ui.spinbox_crop_range_end.editingFinished.connect(self.apply_ops) self.ui.combobox_reduce.currentIndexChanged.connect(self.apply_ops) + self.ui.timeline_tabwidget.currentChanged.connect( self._on_timeline_tab_changed ) @@ -605,10 +594,6 @@ def reset_options(self) -> None: self.ui.spinbox_current_frame, self.ui.combobox_reduce, self.ui.timeline_tabwidget, - self.ui.combobox_ops_subtract_src, - self.ui.combobox_ops_divide_src, - self.ui.slider_ops_subtract, - self.ui.slider_ops_divide, ] for w in _batch: w.blockSignals(True) @@ -629,10 +614,11 @@ def _reset_options_body(self) -> None: self.ui.timeline_tabwidget.setTabEnabled(1, False) else: self.ui.timeline_tabwidget.setTabEnabled(1, True) - self.ui.combobox_ops_subtract_src.setCurrentIndex(0) - self.ui.combobox_ops_divide_src.setCurrentIndex(0) - self.ui.slider_ops_subtract.setValue(100) - self.ui.slider_ops_divide.setValue(0) + + # Reset Filter Stack + self.ui.filter_stack.set_pipeline([]) + + # Legacy self.ui.button_ops_load_file.setText("Load reference image") self.ui.image_viewer._background_image = None self.ui.checkbox_measure_roi.setChecked(False) @@ -654,8 +640,6 @@ def _reset_options_body(self) -> None: self.ui.roi_plot.crop_range.setRegion( (0, self.ui.image_viewer.data.n_images - 1) ) - self._update_ops_file_visibility() - self._update_ops_slider_labels() self.apply_ops() self.ui.spinbox_selection_window.setMaximum( self.ui.image_viewer.data.n_images @@ -972,19 +956,131 @@ def update_isocurves(self) -> None: ) def load_ops_file(self) -> None: - """Load or remove reference image for Ops (subtract/divide).""" - if "Remove" not in self.ui.button_ops_load_file.text(): - file, _ = QFileDialog.getOpenFileName( - caption="Choose Reference File", - directory=str(self.last_file_dir), - ) - if file and self.ui.image_viewer.load_background_file(Path(file)): - self.ui.button_ops_load_file.setText("[Remove]") - self.apply_ops() - else: - self.ui.image_viewer.unload_background_file() - self.ui.button_ops_load_file.setText("Load reference image") + """Legacy method for old UI button (kept for safety if called dynamically).""" + pass + + def load_reference_for_filter(self, item_widget: FilterItemWidget) -> None: + """Handle request from FilterItemWidget to load a reference file.""" + # If already loaded, remove it + if item_widget.params.get("reference_loaded"): + item_widget.set_reference(None) self.apply_ops() + return + + file, _ = QFileDialog.getOpenFileName( + caption="Choose Reference File", + directory=str(self.last_file_dir), + ) + if not file: + return + + with LoadingManager(self, f"Loading reference {Path(file).name}", blocking_label=self.ui.blocking_status) as lm: + # Use a temporary viewer/loader to load the data + # We can reuse ImageData/DataLoader logic + # But we need to load it into an ImageData object + # DataLoader.load_data is usually coupled to viewer. + # We can use the existing viewer to load it as background file? + # No, because that sets it on self.ui.image_viewer._background_image + # But we need it for THIS specific filter item. + # So we should load it into a separate ImageData object. + + # Using DataLoader directly might be tricky as it calls back to viewer usually. + # Let's see: DataLoader is in blitz/data/load.py. It has methods. + # Actually `viewer.load_background_file` does logic: + # meta = get_image_metadata(path) + # image = _load_image(path, ...) + # return ImageData(image, [meta]) + + # I should replicate that. + try: + # Minimal replication of load logic + # Check if video or image + # For simplicity, assume image or single frame, or same dimensions as current data + # Reuse `self.ui.image_viewer` logic if possible, or extract it. + # `viewer.load_background_file` calls `DataLoader.load_single_image_as_array` or similar? + # It calls `DataLoader._load_image` etc. + + # Let's import what we need + from ..data.load import DataLoader + + # We need to respect current viewer settings (8bit, etc) if possible, or just load raw. + # Usually reference should match main image in dimensions. + + # Let's use `ImageData` from existing code if available or just load new. + # The existing `load_background_file` on viewer was: + # 1. get meta + # 2. load array + # 3. create ImageData + # 4. set _background_image + + # We will do 1-3. + + # Simplified load: + # We assume the user wants to load a file that matches current data shape (H, W). + # If it's a stack, we might only use first frame or average? + # Legacy `load_background_file` checks `img.shape`. + + # Let's use DataLoader. But DataLoader methods are instance methods of viewer usually or mixed. + # Actually `DataLoader` is a mixin or base class? No, `ImageViewer` inherits `DataLoader`. + # So `self.ui.image_viewer` IS a `DataLoader`. + + # We can use a temporary method on viewer or just use the viewer to load it but not set it as main image? + # `viewer` has `load_data` which sets main image. + # `viewer` has `load_background_file` which sets `_background_image`. + + # I can adapt `load_background_file` to return the ImageData instead of setting it. + # But I shouldn't change `ImageViewer` public API too much if I can avoid it. + # Or I can just call `self.ui.image_viewer.load_reference_data(path)` if I add such a method. + + # Let's implement the load logic here briefly using `DataLoader` static methods if any? + # `DataLoader` has `_load_image`, `_load_video`, `_load_folder`. They are static-ish? + # No, they are methods. + + # Accessing private methods `_load_image` from `image_viewer` instance. + viewer = self.ui.image_viewer + path = Path(file) + + if DataLoader._is_video(path): + # Not supported for reference usually in legacy? + # Legacy `load_background_file` supported it via `load_data` logic replication? + # Actually `load_background_file` in `blitz/data/load.py` (if it exists there) + # No, `load_background_file` is in `ImageViewer`. + pass + + # Let's try to reuse `viewer`'s loading capabilities. + # Since `viewer` logic is complex (handling different file types), + # and I don't want to duplicate it. + # But `viewer` statefully sets `self.image`. + + # Strategy: + # 1. Inspect `blitz/data/load.py` to see if I can use `DataLoader` cleanly. + # 2. Or, use `cv2` directly for simple image loading if that's 99% of use cases. + # 3. Or, refactor `ImageViewer.load_background_file` to be `load_auxiliary_file(path) -> ImageData`. + + # Given I can't easily see `load.py` right now (I read it earlier but cache might be fuzzy on exact signatures). + # I recall `_load_image` returns `np.ndarray`. + + # Let's check `load_background_file` in `viewer.py` if I could? + # No, I didn't read `viewer.py`. + + # I'll implement a safe generic loader using `self.ui.image_viewer` methods if possible. + # `self.ui.image_viewer` has `load_background_file`. It returns boolean. + # And sets `self._background_image`. + # I can use that! + # 1. Call `viewer.load_background_file(path)`. + # 2. Grab `viewer._background_image`. + # 3. Set it to `item_widget`. + # 4. Clear `viewer._background_image` (set to None). + + if viewer.load_background_file(path): + ref_data = viewer._background_image + viewer._background_image = None # Detach from global slot + item_widget.set_reference(ref_data) + self.apply_ops() + + except Exception as e: + log(f"Failed to load reference: {e}", color="red") + def on_strgC(self) -> None: cb = QApplication.clipboard() @@ -1539,45 +1635,34 @@ def apply_ops(self) -> None: """Build Ops pipeline from UI and set on data.""" if self.ui.image_viewer.data.is_single_image(): return - bounds = ( + + pipeline = self.ui.filter_stack.get_pipeline() + + # Inject global aggregate settings if needed + # The new stack items for subtract/divide specify "aggregate" source. + # ImageData handles looking up the aggregate result based on bounds. + # We need to ensure bounds are set? + # Actually ImageData.compute_ref looks up "aggregate" using step["bounds"]. + # But filter_stack doesn't know bounds. + # We should inject current bounds into steps that need it. + + current_bounds = ( self.ui.spinbox_crop_range_start.value(), self.ui.spinbox_crop_range_end.value(), ) - method = self.ui.combobox_reduce.currentText() - bg = self.ui.image_viewer._background_image - - def _step(src: str, amount: int) -> dict | None: - if not src or src == "off" or amount <= 0: - return None - if src == "aggregate": - if method == "None - current frame": - return None - return {"source": "aggregate", "bounds": bounds, "method": method, "amount": amount / 100.0} - if src == "file" and bg is not None: - return {"source": "file", "reference": bg, "amount": amount / 100.0} - return None - - sub_src = self.ui.combobox_ops_subtract_src.currentData() - sub_amt = self.ui.slider_ops_subtract.value() - div_src = self.ui.combobox_ops_divide_src.currentData() - div_amt = self.ui.slider_ops_divide.value() - - pipeline: dict = {} - if sub := _step(sub_src, sub_amt): - pipeline["subtract"] = sub - if div := _step(div_src, div_amt): - pipeline["divide"] = div + current_reduce_method = self.ui.combobox_reduce.currentText() + + # Pass bounds/method to steps that use aggregate + active_parts = [] + for step in pipeline: + if step.get("source") == "aggregate": + step["bounds"] = current_bounds + step["method"] = current_reduce_method + + active_parts.append(step.get("type")) if pipeline: - parts = [] - if "subtract" in pipeline: - parts.append("Subtracting") - if "divide" in pipeline: - parts.append("Dividing") - msg = " & ".join(parts) + "..." - else: - msg = None - if msg: + msg = f"Applying: {', '.join(active_parts)}..." with LoadingManager(self, msg, blocking_label=self.ui.blocking_status, blocking_delay_ms=0): self.ui.image_viewer.data.set_ops_pipeline(pipeline) self.ui.image_viewer.update_image() diff --git a/blitz/layout/ui.py b/blitz/layout/ui.py index f9bd2b0..be70d58 100644 --- a/blitz/layout/ui.py +++ b/blitz/layout/ui.py @@ -20,6 +20,7 @@ from .bench_sparklines import BenchSparklines from .viewer import ImageViewer from .widgets import ExtractionPlot, MeasureROI, TimePlot +from .filter_stack import FilterStackWidget TITLE = ( "BLITZ: (B)ulk (L)oading & (I)nteractive (T)ime series (Z)onal analysis " @@ -425,7 +426,7 @@ def setup_option_dock(self) -> None: view_layout.addStretch() self.create_option_tab(view_layout, "View") - # --- Ops: Subtract/Divide, Source Aggregate|File, Amount sliders --- + # --- Ops: Filter Stack --- ops_layout = QVBoxLayout() ops_label = QLabel("Ops") ops_label.setStyleSheet(get_style("heading")) @@ -435,55 +436,11 @@ def setup_option_dock(self) -> None: "Configure range and reduce method in Aggregate tab" ) ops_layout.addWidget(self.button_ops_open_aggregate) - sub_grp = QGroupBox("1. Subtract") - sub_lay = QVBoxLayout() - sub_src_row = QHBoxLayout() - sub_src_row.addWidget(QLabel("Source:")) - self.combobox_ops_subtract_src = QComboBox() - self.combobox_ops_subtract_src.addItem("Off", "off") - self.combobox_ops_subtract_src.addItem("Aggregate", "aggregate") - self.combobox_ops_subtract_src.addItem("File", "file") - sub_src_row.addWidget(self.combobox_ops_subtract_src) - sub_lay.addLayout(sub_src_row) - sub_amt_row = QHBoxLayout() - sub_amt_row.addWidget(QLabel("Amount:")) - self.slider_ops_subtract = QSlider(Qt.Orientation.Horizontal) - self.slider_ops_subtract.setRange(0, 100) - self.slider_ops_subtract.setValue(100) - self.label_ops_subtract = QLabel("100%") - sub_amt_row.addWidget(self.slider_ops_subtract) - sub_amt_row.addWidget(self.label_ops_subtract) - sub_lay.addLayout(sub_amt_row) - sub_grp.setLayout(sub_lay) - ops_layout.addWidget(sub_grp) - div_grp = QGroupBox("2. Divide") - div_lay = QVBoxLayout() - div_src_row = QHBoxLayout() - div_src_row.addWidget(QLabel("Source:")) - self.combobox_ops_divide_src = QComboBox() - self.combobox_ops_divide_src.addItem("Off", "off") - self.combobox_ops_divide_src.addItem("Aggregate", "aggregate") - self.combobox_ops_divide_src.addItem("File", "file") - div_src_row.addWidget(self.combobox_ops_divide_src) - div_lay.addLayout(div_src_row) - div_amt_row = QHBoxLayout() - div_amt_row.addWidget(QLabel("Amount:")) - self.slider_ops_divide = QSlider(Qt.Orientation.Horizontal) - self.slider_ops_divide.setRange(0, 100) - self.slider_ops_divide.setValue(0) - self.label_ops_divide = QLabel("0%") - div_amt_row.addWidget(self.slider_ops_divide) - div_amt_row.addWidget(self.label_ops_divide) - div_lay.addLayout(div_amt_row) - div_grp.setLayout(div_lay) - ops_layout.addWidget(div_grp) - self.button_ops_load_file = QPushButton("Load reference image") - self.ops_file_widget = QWidget() - self.ops_file_widget.setLayout(QHBoxLayout()) - self.ops_file_widget.layout().addWidget(self.button_ops_load_file) - ops_layout.addWidget(self.ops_file_widget) - self.ops_file_widget.setVisible(False) - ops_layout.addStretch() + + self.filter_stack = FilterStackWidget() + ops_layout.addWidget(self.filter_stack) + + # ops_layout.addStretch() # FilterStack has its own stretch self.create_option_tab(ops_layout, "Ops") # --- Timeline Panel: 2 Tabs Frame | Agg (Tab-Wechsel = Modus) --- From 49b206bdca1482967c14a5cdea2fa7bdb98b19c2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 10:59:55 +0000 Subject: [PATCH 2/2] Revert implementation of flexible Filter Stack The feature is postponed and the implementation will not be merged at this time. The concept and implementation details have been documented in the PR comments for future reference. This commit restores the repository to its original state. Co-authored-by: PiMaV <93649984+PiMaV@users.noreply.github.com> --- blitz/data/filters.py | 147 ------------- blitz/data/image.py | 137 +++--------- blitz/layout/filter_stack.py | 389 ----------------------------------- blitz/layout/main.py | 231 +++++++-------------- blitz/layout/ui.py | 57 ++++- 5 files changed, 153 insertions(+), 808 deletions(-) delete mode 100644 blitz/data/filters.py delete mode 100644 blitz/layout/filter_stack.py diff --git a/blitz/data/filters.py b/blitz/data/filters.py deleted file mode 100644 index 3a524ff..0000000 --- a/blitz/data/filters.py +++ /dev/null @@ -1,147 +0,0 @@ -import cv2 -import numpy as np - - -def ensure_float32(image: np.ndarray) -> np.ndarray: - if image.dtype != np.float32: - return image.astype(np.float32) - return image - - -def median(image: np.ndarray, ksize: int) -> np.ndarray: - """Apply median filter to remove hot pixels/noise. - ksize must be odd. - """ - if ksize < 1: - return image - if ksize % 2 == 0: - ksize += 1 - - # Median Blur only supports uint8, uint16, int16, float32 - # But usually float32 is fine. - # Note: cv2.medianBlur works on single channel or 3 channel images. - # If image is 4D (T, H, W, C) or 3D (H, W, C), handle accordingly. - # Since operations usually run on single image (H, W, C) or single frame (H, W) in the pipeline: - if image.ndim == 3 and image.shape[2] == 1: - return cv2.medianBlur(image, ksize) - elif image.ndim == 2: - return cv2.medianBlur(image, ksize) - else: - # Multi-channel logic if needed, but usually we process per channel or assume grayscale for scientific data - # If float32, cv2.medianBlur is supported. - return cv2.medianBlur(image, ksize) - - -def min_filter(image: np.ndarray, ksize: int) -> np.ndarray: - """Apply minimum filter (Erosion).""" - if ksize < 1: - return image - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize)) - return cv2.erode(image, kernel) - - -def max_filter(image: np.ndarray, ksize: int) -> np.ndarray: - """Apply maximum filter (Dilation).""" - if ksize < 1: - return image - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize)) - return cv2.dilate(image, kernel) - - -def gaussian_blur(image: np.ndarray, sigma: float) -> np.ndarray: - """Apply Gaussian Blur (Lowpass).""" - if sigma <= 0: - return image - # ksize=0 means computed from sigma - return cv2.GaussianBlur(image, (0, 0), sigmaX=sigma, sigmaY=sigma) - - -def highpass(image: np.ndarray, sigma: float) -> np.ndarray: - """Apply Highpass filter (Original - Lowpass).""" - if sigma <= 0: - return np.zeros_like(image) - low = gaussian_blur(image, sigma) - return image - low - - -def clahe(image: np.ndarray, clip_limit: float = 2.0, tile_grid_size: int = 8) -> np.ndarray: - """Contrast Limited Adaptive Histogram Equalization. - Requires uint8/uint16 input for cv2.createCLAHE. - If input is float, we normalize to 0-65535 (uint16) apply CLAHE, then convert back. - """ - if tile_grid_size < 1: - return image - - orig_dtype = image.dtype - if orig_dtype == np.float32 or orig_dtype == np.float64: - # Normalize to 0-1 range first if not already - min_val, max_val = image.min(), image.max() - if max_val == min_val: - return image - - # Temporarily convert to uint16 for CLAHE - norm = (image - min_val) / (max_val - min_val) - norm_uint16 = (norm * 65535).astype(np.uint16) - - clahe_obj = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid_size, tile_grid_size)) - - # CLAHE only works on single channel. - if image.ndim == 3: - res_channels = [] - for c in range(image.shape[2]): - res_channels.append(clahe_obj.apply(norm_uint16[..., c])) - res_uint16 = np.stack(res_channels, axis=-1) - else: - res_uint16 = clahe_obj.apply(norm_uint16) - - # Convert back to original range - return (res_uint16.astype(np.float32) / 65535.0) * (max_val - min_val) + min_val - - elif orig_dtype == np.uint8: - clahe_obj = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid_size, tile_grid_size)) - if image.ndim == 3: - res_channels = [] - for c in range(image.shape[2]): - res_channels.append(clahe_obj.apply(image[..., c])) - return np.stack(res_channels, axis=-1) - return clahe_obj.apply(image) - - return image - - -def local_normalize_mean(image: np.ndarray, ksize: int) -> np.ndarray: - """Local Normalization: (Pixel - Mean) / (Std + eps) or similar. - Here implementing a simpler version requested: divide by local mean (or blur). - Let's implement: Image / (Gaussian(Image) + eps). - This highlights local variations relative to background. - """ - if ksize < 1: - return image - # Use Gaussian as "local mean" approximation - # Sigma roughly ksize / 6 for 99% coverage or just let cv2 decide from ksize - # ksize for GaussianBlur must be odd - if ksize % 2 == 0: - ksize += 1 - - local_mean = cv2.GaussianBlur(image, (ksize, ksize), 0) - - # Avoid division by zero - return image / (local_mean + 1e-6) - - -def threshold_binary(image: np.ndarray, thresh: float, maxval: float = 1.0, type_: int = cv2.THRESH_BINARY) -> np.ndarray: - """Apply thresholding.""" - _, res = cv2.threshold(image, thresh, maxval, type_) - return res - - -def clip_values(image: np.ndarray, min_val: float, max_val: float) -> np.ndarray: - """Clip values to range.""" - return np.clip(image, min_val, max_val) - -def histogram_clipping(image: np.ndarray, min_percentile: float, max_percentile: float) -> np.ndarray: - """Clip image intensity based on percentiles.""" - # Compute percentiles - low = np.percentile(image, min_percentile) - high = np.percentile(image, max_percentile) - return np.clip(image, low, high) diff --git a/blitz/data/image.py b/blitz/data/image.py index 2337cb5..54c3596 100644 --- a/blitz/data/image.py +++ b/blitz/data/image.py @@ -7,7 +7,6 @@ # from .. import settings from ..tools import log from . import ops -from . import filters from .tools import ensure_4d @@ -49,7 +48,7 @@ def __init__( self._flipped_x = False self._flipped_y = False self._redop: ops.ReduceOperation | str | None = None - self._ops_pipeline: list[dict] | None = None # List of filter steps + self._ops_pipeline: dict | None = None # {subtract, divide} steps self._agg_bounds: tuple[int, int] | None = None # Non-destructive agg range self._result_cache: dict[tuple[object, object], np.ndarray] = {} # (op, bounds) -> result self._bench_cache_hits: int = 0 # For Bench tab @@ -75,102 +74,38 @@ def _compute_ref( return None def _apply_ops_pipeline(self, image: np.ndarray) -> np.ndarray: - """Apply pipeline steps sequentially. Returns float32 array.""" + """Apply subtract and divide steps. Returns float32 array.""" pipeline = self._ops_pipeline if not pipeline: return image.astype(np.float32) if image.dtype != np.float32 else image - - # Ensure we work on float32 copy image = image.astype(np.float32) - - for step in pipeline: - type_ = step.get("type") - if not type_: + eps = 1e-10 + for op_name in ("subtract", "divide"): + step = pipeline.get(op_name) + if not step: continue - - # Arithmetic Ops (Subtract, Divide) - if type_ in ("subtract", "divide"): - ref = self._compute_ref(step, image) - if ref is None: - continue - amount = np.float32(step.get("amount", 1.0)) - if amount <= 0: - continue - - if type_ == "subtract": - ref_scaled = amount * ref - if ref.shape[0] == 1: - image -= ref_scaled - else: - image = image[: ref.shape[0]] - image -= ref_scaled - else: # divide - denom = amount * ref + (np.float32(1.0) - amount) - denom = np.where(denom != 0, denom, np.float32(np.nan)) - if ref.shape[0] == 1: - image /= denom - else: - image = image[: ref.shape[0]] - image /= denom - np.nan_to_num(image, copy=False, nan=0.0, posinf=0.0, neginf=0.0) - - # Filters - elif type_ == "median": - ksize = int(step.get("ksize", 3)) - # Apply per frame - for i in range(image.shape[0]): - image[i] = filters.median(image[i], ksize) - - elif type_ == "min": - ksize = int(step.get("ksize", 3)) - for i in range(image.shape[0]): - image[i] = filters.min_filter(image[i], ksize) - - elif type_ == "max": - ksize = int(step.get("ksize", 3)) - for i in range(image.shape[0]): - image[i] = filters.max_filter(image[i], ksize) - - elif type_ == "gaussian_blur": - sigma = float(step.get("sigma", 1.0)) - for i in range(image.shape[0]): - image[i] = filters.gaussian_blur(image[i], sigma) - - elif type_ == "highpass": - sigma = float(step.get("sigma", 1.0)) - for i in range(image.shape[0]): - image[i] = filters.highpass(image[i], sigma) - - elif type_ == "clahe": - clip = float(step.get("clip_limit", 2.0)) - grid = int(step.get("tile_grid_size", 8)) - for i in range(image.shape[0]): - image[i] = filters.clahe(image[i], clip, grid) - - elif type_ == "local_normalize_mean": - ksize = int(step.get("ksize", 15)) - for i in range(image.shape[0]): - image[i] = filters.local_normalize_mean(image[i], ksize) - - elif type_ == "threshold_binary": - thresh = float(step.get("thresh", 0.5)) - maxval = float(step.get("maxval", 1.0)) - # If image is normalized 0-1 or raw counts, threshold depends on context - # Assuming user provides threshold in relevant units - for i in range(image.shape[0]): - image[i] = filters.threshold_binary(image[i], thresh, maxval) - - elif type_ == "clip_values": - min_val = float(step.get("min_val", 0.0)) - max_val = float(step.get("max_val", 1.0)) - image = filters.clip_values(image, min_val, max_val) - - elif type_ == "histogram_clipping": - min_p = float(step.get("min_percentile", 1.0)) - max_p = float(step.get("max_percentile", 99.0)) - for i in range(image.shape[0]): - image[i] = filters.histogram_clipping(image[i], min_p, max_p) - + ref = self._compute_ref(step, image) + if ref is None: + continue + amount = np.float32(step.get("amount", 1.0)) + if amount <= 0: + continue + if op_name == "subtract": + ref_scaled = amount * ref + if ref.shape[0] == 1: + image -= ref_scaled + else: + image = image[: ref.shape[0]] + image -= ref_scaled + else: # divide: blend denominator towards 1 when amount<1 + denom = amount * ref + (np.float32(1.0) - amount) + denom = np.where(denom != 0, denom, np.float32(np.nan)) + if ref.shape[0] == 1: + image /= denom + else: + image = image[: ref.shape[0]] + image /= denom + np.nan_to_num(image, copy=False, nan=0.0, posinf=0.0, neginf=0.0) return image def _invalidate_result(self) -> None: @@ -273,20 +208,8 @@ def reduce( self._redop = operation self._agg_bounds = bounds - def set_ops_pipeline(self, config: list[dict] | None) -> None: - """Set ops pipeline. config: list of filter steps.""" - # Convert legacy dict to list if needed (for backward compatibility if I was being careful, but let's assume strict update) - if isinstance(config, dict): - # Try to convert old format to new list format - new_config = [] - if sub := config.get("subtract"): - sub["type"] = "subtract" - new_config.append(sub) - if div := config.get("divide"): - div["type"] = "divide" - new_config.append(div) - config = new_config - + def set_ops_pipeline(self, config: dict | None) -> None: + """Set ops pipeline. config: {subtract:{source,bounds?,method?,reference?,amount}, divide:{...}}.""" if self._ops_pipeline != config: self._invalidate_result() self._ops_pipeline = config diff --git a/blitz/layout/filter_stack.py b/blitz/layout/filter_stack.py deleted file mode 100644 index 1672a7b..0000000 --- a/blitz/layout/filter_stack.py +++ /dev/null @@ -1,389 +0,0 @@ -from typing import Any, Callable - -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import ( - QComboBox, - QDoubleSpinBox, - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QScrollArea, - QSizePolicy, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from ..theme import get_style - - -class FilterItemWidget(QFrame): - """Widget representing a single filter in the stack.""" - - removed = pyqtSignal() - moved_up = pyqtSignal() - moved_down = pyqtSignal() - changed = pyqtSignal() - - # Signal to request loading a reference file (for Subtract/Divide) - load_reference_requested = pyqtSignal() - - def __init__(self, filter_type: str, params: dict | None = None) -> None: - super().__init__() - self.filter_type = filter_type - self.params = params or {} - - # Hold the reference image object (ImageData) separately from serializable params - self._reference_image: Any = None - if "reference" in self.params: - self._reference_image = self.params.pop("reference") - - self.setFrameShape(QFrame.Shape.StyledPanel) - self.setFrameShadow(QFrame.Shadow.Raised) - # Using a slightly darker background to distinguish items - self.setStyleSheet( - "FilterItemWidget { border: 1px solid #444; border-radius: 4px; " - "background-color: #2a2a2a; margin-bottom: 2px; }" - "QLabel { color: #eee; }" - ) - - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(4, 4, 4, 4) - self.layout.setSpacing(2) - - # Header: Name + Buttons - header_layout = QHBoxLayout() - name_label = QLabel(self._get_display_name(filter_type)) - name_label.setStyleSheet("font-weight: bold; font-size: 11px;") - header_layout.addWidget(name_label) - header_layout.addStretch() - - btn_style = "QPushButton { max-width: 16px; max-height: 16px; padding: 0px; font-size: 10px; }" - - self.btn_up = QPushButton("▲") - self.btn_up.setStyleSheet(btn_style) - self.btn_up.clicked.connect(self.moved_up.emit) - header_layout.addWidget(self.btn_up) - - self.btn_down = QPushButton("▼") - self.btn_down.setStyleSheet(btn_style) - self.btn_down.clicked.connect(self.moved_down.emit) - header_layout.addWidget(self.btn_down) - - self.btn_remove = QPushButton("✕") - self.btn_remove.setStyleSheet(btn_style + "QPushButton { color: #ff5555; }") - self.btn_remove.clicked.connect(self.removed.emit) - header_layout.addWidget(self.btn_remove) - - self.layout.addLayout(header_layout) - - # Params Area - self.params_layout = QHBoxLayout() - self.layout.addLayout(self.params_layout) - - self._setup_params_ui() - - def set_reference(self, image: Any) -> None: - self._reference_image = image - self.params["reference_loaded"] = (image is not None) - self._update_ref_button() - self.changed.emit() - - def _update_ref_button(self) -> None: - if hasattr(self, "btn_load_ref"): - loaded = self._reference_image is not None - self.btn_load_ref.setText("Remove Ref" if loaded else "Load Ref") - if loaded: - self.btn_load_ref.setStyleSheet("color: #ffaa00;") - else: - self.btn_load_ref.setStyleSheet("") - - def _get_display_name(self, type_: str) -> str: - mapping = { - "subtract": "Subtract", - "divide": "Divide", - "median": "Median (Hotpixel)", - "min": "Minimum (Erosion)", - "max": "Maximum (Dilation)", - "gaussian_blur": "Lowpass (Gaussian)", - "highpass": "Highpass", - "clahe": "Local Norm (CLAHE)", - "local_normalize_mean": "Local Norm (Mean)", - "threshold_binary": "Threshold (Binary)", - "clip_values": "Clip Values", - "histogram_clipping": "Histogram Clip", - } - return mapping.get(type_, type_.capitalize()) - - def _add_spinbox(self, key: str, label: str, val_type: type, min_: float, max_: float, step: float, default: float) -> None: - row = QHBoxLayout() - lbl = QLabel(label) - lbl.setStyleSheet("font-size: 10px;") - row.addWidget(lbl) - - if val_type == int: - sb = QSpinBox() - else: - sb = QDoubleSpinBox() - - sb.setRange(min_, max_) - sb.setSingleStep(step) - sb.setStyleSheet("font-size: 10px;") - - current_val = self.params.get(key, default) - sb.setValue(current_val) - - sb.valueChanged.connect(lambda v, k=key: self._update_param(k, v)) - row.addWidget(sb) - self.params_layout.addLayout(row) - - def _add_combobox(self, key: str, label: str, options: list[tuple[str, str]], default: str) -> QComboBox: - row = QHBoxLayout() - lbl = QLabel(label) - lbl.setStyleSheet("font-size: 10px;") - row.addWidget(lbl) - - cb = QComboBox() - cb.setStyleSheet("font-size: 10px;") - for text, data in options: - cb.addItem(text, data) - - current_val = self.params.get(key, default) - idx = cb.findData(current_val) - if idx >= 0: - cb.setCurrentIndex(idx) - - cb.currentIndexChanged.connect(lambda i, k=key, c=cb: self._update_param(k, c.itemData(i))) - row.addWidget(cb) - self.params_layout.addLayout(row) - return cb - - def _update_param(self, key: str, value: Any) -> None: - self.params[key] = value - self.changed.emit() - - def _setup_params_ui(self) -> None: - t = self.filter_type - - if t in ("subtract", "divide"): - self.cb_source = self._add_combobox("source", "Src:", [ - ("Aggregate", "aggregate"), - ("Ref File", "file"), - ], "aggregate") - - self._add_spinbox("amount", "Amt %:", float, 0.0, 100.0, 5.0, 100.0 if t == "subtract" else 100.0) - - # File Load Button (Visible only if source == file) - self.btn_load_ref = QPushButton("Load Ref") - self.btn_load_ref.setStyleSheet("font-size: 10px;") - self.btn_load_ref.clicked.connect(self.load_reference_requested.emit) - self.params_layout.addWidget(self.btn_load_ref) - - self._update_ref_button() - - # Update visibility based on source - def _update_vis(): - is_file = self.params.get("source") == "file" - self.btn_load_ref.setVisible(is_file) - - self.cb_source.currentIndexChanged.connect(_update_vis) - _update_vis() - - elif t == "median": - self._add_spinbox("ksize", "K:", int, 1, 99, 2, 3) - - elif t in ("min", "max"): - self._add_spinbox("ksize", "K:", int, 1, 99, 1, 3) - - elif t in ("gaussian_blur", "highpass"): - self._add_spinbox("sigma", "σ:", float, 0.1, 50.0, 0.5, 1.0) - - elif t == "clahe": - self._add_spinbox("clip_limit", "Clip:", float, 0.1, 100.0, 0.5, 2.0) - self._add_spinbox("tile_grid_size", "Grid:", int, 1, 64, 1, 8) - - elif t == "local_normalize_mean": - self._add_spinbox("ksize", "K:", int, 3, 255, 2, 15) - - elif t == "threshold_binary": - self._add_spinbox("thresh", "T:", float, -99999, 99999, 1.0, 0.5) - self._add_spinbox("maxval", "V:", float, 0, 99999, 1.0, 1.0) - - elif t == "clip_values": - self._add_spinbox("min_val", "Min:", float, -99999, 99999, 1.0, 0.0) - self._add_spinbox("max_val", "Max:", float, -99999, 99999, 1.0, 255.0) - - elif t == "histogram_clipping": - self._add_spinbox("min_percentile", "Min %:", float, 0.0, 100.0, 0.1, 1.0) - self._add_spinbox("max_percentile", "Max %:", float, 0.0, 100.0, 0.1, 99.0) - - -class FilterStackWidget(QWidget): - - pipeline_changed = pyqtSignal() - load_reference_requested = pyqtSignal(FilterItemWidget) # Pass the widget that requested it - - def __init__(self) -> None: - super().__init__() - - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - - # Controls - controls_layout = QHBoxLayout() - self.cb_add = QComboBox() - self.cb_add.addItems([ - "Median (Hotpixel)", - "Subtract", - "Divide", - "Lowpass (Gaussian)", - "Highpass", - "Minimum (Erosion)", - "Maximum (Dilation)", - "Local Norm (CLAHE)", - "Local Norm (Mean)", - "Threshold (Binary)", - "Clip Values", - "Histogram Clip", - ]) - - # Map display names back to internal types - self._type_map = { - "Median (Hotpixel)": "median", - "Subtract": "subtract", - "Divide": "divide", - "Lowpass (Gaussian)": "gaussian_blur", - "Highpass": "highpass", - "Minimum (Erosion)": "min", - "Maximum (Dilation)": "max", - "Local Norm (CLAHE)": "clahe", - "Local Norm (Mean)": "local_normalize_mean", - "Threshold (Binary)": "threshold_binary", - "Clip Values": "clip_values", - "Histogram Clip": "histogram_clipping", - } - - self.btn_add = QPushButton("Add") - self.btn_add.clicked.connect(self._add_filter_from_ui) - - controls_layout.addWidget(self.cb_add, 1) - controls_layout.addWidget(self.btn_add) - self.layout.addLayout(controls_layout) - - # Scroll Area for stack - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - self.stack_container = QWidget() - self.stack_layout = QVBoxLayout(self.stack_container) - self.stack_layout.setContentsMargins(0, 0, 0, 0) - self.stack_layout.setSpacing(2) - self.stack_layout.addStretch() # Push items up - - self.scroll.setWidget(self.stack_container) - self.layout.addWidget(self.scroll) - - self._items: list[FilterItemWidget] = [] - self._block_signals = False - - def _add_filter_from_ui(self) -> None: - name = self.cb_add.currentText() - type_ = self._type_map.get(name) - if type_: - self.add_filter(type_) - - def add_filter(self, type_: str, params: dict | None = None) -> None: - item = FilterItemWidget(type_, params) - item.removed.connect(lambda: self._remove_item(item)) - item.moved_up.connect(lambda: self._move_item(item, -1)) - item.moved_down.connect(lambda: self._move_item(item, 1)) - item.changed.connect(self._on_change) - item.load_reference_requested.connect(lambda: self.load_reference_requested.emit(item)) - - # Add to layout before the stretch - count = self.stack_layout.count() - self.stack_layout.insertWidget(count - 1, item) - self._items.append(item) - - self._on_change() - - def _remove_item(self, item: FilterItemWidget) -> None: - self.stack_layout.removeWidget(item) - item.deleteLater() - if item in self._items: - self._items.remove(item) - self._on_change() - - def _move_item(self, item: FilterItemWidget, direction: int) -> None: - if item not in self._items: - return - - idx = self._items.index(item) - new_idx = idx + direction - if 0 <= new_idx < len(self._items): - # Update internal list - self._items.pop(idx) - self._items.insert(new_idx, item) - - # Update Layout - # We must remove and re-insert. - self.stack_layout.removeWidget(item) - # Note: insertWidget(index, widget). - # layout indices map 1:1 to self._items indices because the stretch is at the end. - self.stack_layout.insertWidget(new_idx, item) - - self._on_change() - - def _on_change(self) -> None: - if not self._block_signals: - self.pipeline_changed.emit() - - def get_pipeline(self) -> list[dict]: - pipeline = [] - for item in self._items: - step = item.params.copy() - step["type"] = item.filter_type - - # Include the runtime reference object if present - if item._reference_image is not None: - step["reference"] = item._reference_image - - # For arithmetic, convert percentage amount to 0-1 factor - if item.filter_type in ("subtract", "divide"): - # The spinbox is 0-100, we need 0.0-1.0 - val = step.get("amount", 100.0) - step["amount"] = val / 100.0 - - pipeline.append(step) - return pipeline - - def set_pipeline(self, pipeline: list[dict]) -> None: - self._block_signals = True - # Clear existing - while self._items: - # We can't use _remove_item safely inside loop on self._items if modifying it - # But here we just want to clear UI - w = self._items.pop(0) - self.stack_layout.removeWidget(w) - w.deleteLater() - - for step in pipeline: - type_ = step.get("type") - if not type_: - continue - - params = step.copy() - del params["type"] - - # Convert factor back to percentage for UI - if type_ in ("subtract", "divide"): - val = params.get("amount", 1.0) - params["amount"] = val * 100.0 - - self.add_filter(type_, params) - - self._block_signals = False - self.pipeline_changed.emit() diff --git a/blitz/layout/main.py b/blitz/layout/main.py index a1c8018..29306ba 100644 --- a/blitz/layout/main.py +++ b/blitz/layout/main.py @@ -21,7 +21,6 @@ from .winamp_mock import WinampMockLiveWidget from .tof import TOFAdapter from .ui import UI_MainWindow -from .filter_stack import FilterItemWidget URL_GITHUB = QUrl("https://github.com/CodeSchmiedeHGW/BLITZ") URL_INP = QUrl("https://www.inp-greifswald.de/") @@ -224,14 +223,26 @@ def setup_connections(self) -> None: self.ui.button_ops_open_aggregate.clicked.connect( self._ops_open_aggregate_tab ) - - # New Filter Stack Connections - self.ui.filter_stack.pipeline_changed.connect(self.apply_ops) - self.ui.filter_stack.load_reference_requested.connect(self.load_reference_for_filter) + self.ui.combobox_ops_subtract_src.currentIndexChanged.connect( + self._update_ops_file_visibility + ) + self.ui.combobox_ops_divide_src.currentIndexChanged.connect( + self._update_ops_file_visibility + ) + for cb in (self.ui.combobox_ops_subtract_src, self.ui.combobox_ops_divide_src): + cb.currentIndexChanged.connect(self.apply_ops) + self.ui.slider_ops_subtract.valueChanged.connect( + self._update_ops_slider_labels + ) + self.ui.slider_ops_subtract.valueChanged.connect(self.apply_ops) + self.ui.slider_ops_divide.valueChanged.connect( + self._update_ops_slider_labels + ) + self.ui.slider_ops_divide.valueChanged.connect(self.apply_ops) + self.ui.button_ops_load_file.clicked.connect(self.load_ops_file) self.ui.spinbox_crop_range_start.editingFinished.connect(self.apply_ops) self.ui.spinbox_crop_range_end.editingFinished.connect(self.apply_ops) self.ui.combobox_reduce.currentIndexChanged.connect(self.apply_ops) - self.ui.timeline_tabwidget.currentChanged.connect( self._on_timeline_tab_changed ) @@ -594,6 +605,10 @@ def reset_options(self) -> None: self.ui.spinbox_current_frame, self.ui.combobox_reduce, self.ui.timeline_tabwidget, + self.ui.combobox_ops_subtract_src, + self.ui.combobox_ops_divide_src, + self.ui.slider_ops_subtract, + self.ui.slider_ops_divide, ] for w in _batch: w.blockSignals(True) @@ -614,11 +629,10 @@ def _reset_options_body(self) -> None: self.ui.timeline_tabwidget.setTabEnabled(1, False) else: self.ui.timeline_tabwidget.setTabEnabled(1, True) - - # Reset Filter Stack - self.ui.filter_stack.set_pipeline([]) - - # Legacy + self.ui.combobox_ops_subtract_src.setCurrentIndex(0) + self.ui.combobox_ops_divide_src.setCurrentIndex(0) + self.ui.slider_ops_subtract.setValue(100) + self.ui.slider_ops_divide.setValue(0) self.ui.button_ops_load_file.setText("Load reference image") self.ui.image_viewer._background_image = None self.ui.checkbox_measure_roi.setChecked(False) @@ -640,6 +654,8 @@ def _reset_options_body(self) -> None: self.ui.roi_plot.crop_range.setRegion( (0, self.ui.image_viewer.data.n_images - 1) ) + self._update_ops_file_visibility() + self._update_ops_slider_labels() self.apply_ops() self.ui.spinbox_selection_window.setMaximum( self.ui.image_viewer.data.n_images @@ -956,131 +972,19 @@ def update_isocurves(self) -> None: ) def load_ops_file(self) -> None: - """Legacy method for old UI button (kept for safety if called dynamically).""" - pass - - def load_reference_for_filter(self, item_widget: FilterItemWidget) -> None: - """Handle request from FilterItemWidget to load a reference file.""" - # If already loaded, remove it - if item_widget.params.get("reference_loaded"): - item_widget.set_reference(None) + """Load or remove reference image for Ops (subtract/divide).""" + if "Remove" not in self.ui.button_ops_load_file.text(): + file, _ = QFileDialog.getOpenFileName( + caption="Choose Reference File", + directory=str(self.last_file_dir), + ) + if file and self.ui.image_viewer.load_background_file(Path(file)): + self.ui.button_ops_load_file.setText("[Remove]") + self.apply_ops() + else: + self.ui.image_viewer.unload_background_file() + self.ui.button_ops_load_file.setText("Load reference image") self.apply_ops() - return - - file, _ = QFileDialog.getOpenFileName( - caption="Choose Reference File", - directory=str(self.last_file_dir), - ) - if not file: - return - - with LoadingManager(self, f"Loading reference {Path(file).name}", blocking_label=self.ui.blocking_status) as lm: - # Use a temporary viewer/loader to load the data - # We can reuse ImageData/DataLoader logic - # But we need to load it into an ImageData object - # DataLoader.load_data is usually coupled to viewer. - # We can use the existing viewer to load it as background file? - # No, because that sets it on self.ui.image_viewer._background_image - # But we need it for THIS specific filter item. - # So we should load it into a separate ImageData object. - - # Using DataLoader directly might be tricky as it calls back to viewer usually. - # Let's see: DataLoader is in blitz/data/load.py. It has methods. - # Actually `viewer.load_background_file` does logic: - # meta = get_image_metadata(path) - # image = _load_image(path, ...) - # return ImageData(image, [meta]) - - # I should replicate that. - try: - # Minimal replication of load logic - # Check if video or image - # For simplicity, assume image or single frame, or same dimensions as current data - # Reuse `self.ui.image_viewer` logic if possible, or extract it. - # `viewer.load_background_file` calls `DataLoader.load_single_image_as_array` or similar? - # It calls `DataLoader._load_image` etc. - - # Let's import what we need - from ..data.load import DataLoader - - # We need to respect current viewer settings (8bit, etc) if possible, or just load raw. - # Usually reference should match main image in dimensions. - - # Let's use `ImageData` from existing code if available or just load new. - # The existing `load_background_file` on viewer was: - # 1. get meta - # 2. load array - # 3. create ImageData - # 4. set _background_image - - # We will do 1-3. - - # Simplified load: - # We assume the user wants to load a file that matches current data shape (H, W). - # If it's a stack, we might only use first frame or average? - # Legacy `load_background_file` checks `img.shape`. - - # Let's use DataLoader. But DataLoader methods are instance methods of viewer usually or mixed. - # Actually `DataLoader` is a mixin or base class? No, `ImageViewer` inherits `DataLoader`. - # So `self.ui.image_viewer` IS a `DataLoader`. - - # We can use a temporary method on viewer or just use the viewer to load it but not set it as main image? - # `viewer` has `load_data` which sets main image. - # `viewer` has `load_background_file` which sets `_background_image`. - - # I can adapt `load_background_file` to return the ImageData instead of setting it. - # But I shouldn't change `ImageViewer` public API too much if I can avoid it. - # Or I can just call `self.ui.image_viewer.load_reference_data(path)` if I add such a method. - - # Let's implement the load logic here briefly using `DataLoader` static methods if any? - # `DataLoader` has `_load_image`, `_load_video`, `_load_folder`. They are static-ish? - # No, they are methods. - - # Accessing private methods `_load_image` from `image_viewer` instance. - viewer = self.ui.image_viewer - path = Path(file) - - if DataLoader._is_video(path): - # Not supported for reference usually in legacy? - # Legacy `load_background_file` supported it via `load_data` logic replication? - # Actually `load_background_file` in `blitz/data/load.py` (if it exists there) - # No, `load_background_file` is in `ImageViewer`. - pass - - # Let's try to reuse `viewer`'s loading capabilities. - # Since `viewer` logic is complex (handling different file types), - # and I don't want to duplicate it. - # But `viewer` statefully sets `self.image`. - - # Strategy: - # 1. Inspect `blitz/data/load.py` to see if I can use `DataLoader` cleanly. - # 2. Or, use `cv2` directly for simple image loading if that's 99% of use cases. - # 3. Or, refactor `ImageViewer.load_background_file` to be `load_auxiliary_file(path) -> ImageData`. - - # Given I can't easily see `load.py` right now (I read it earlier but cache might be fuzzy on exact signatures). - # I recall `_load_image` returns `np.ndarray`. - - # Let's check `load_background_file` in `viewer.py` if I could? - # No, I didn't read `viewer.py`. - - # I'll implement a safe generic loader using `self.ui.image_viewer` methods if possible. - # `self.ui.image_viewer` has `load_background_file`. It returns boolean. - # And sets `self._background_image`. - # I can use that! - # 1. Call `viewer.load_background_file(path)`. - # 2. Grab `viewer._background_image`. - # 3. Set it to `item_widget`. - # 4. Clear `viewer._background_image` (set to None). - - if viewer.load_background_file(path): - ref_data = viewer._background_image - viewer._background_image = None # Detach from global slot - item_widget.set_reference(ref_data) - self.apply_ops() - - except Exception as e: - log(f"Failed to load reference: {e}", color="red") - def on_strgC(self) -> None: cb = QApplication.clipboard() @@ -1635,34 +1539,45 @@ def apply_ops(self) -> None: """Build Ops pipeline from UI and set on data.""" if self.ui.image_viewer.data.is_single_image(): return - - pipeline = self.ui.filter_stack.get_pipeline() - - # Inject global aggregate settings if needed - # The new stack items for subtract/divide specify "aggregate" source. - # ImageData handles looking up the aggregate result based on bounds. - # We need to ensure bounds are set? - # Actually ImageData.compute_ref looks up "aggregate" using step["bounds"]. - # But filter_stack doesn't know bounds. - # We should inject current bounds into steps that need it. - - current_bounds = ( + bounds = ( self.ui.spinbox_crop_range_start.value(), self.ui.spinbox_crop_range_end.value(), ) - current_reduce_method = self.ui.combobox_reduce.currentText() - - # Pass bounds/method to steps that use aggregate - active_parts = [] - for step in pipeline: - if step.get("source") == "aggregate": - step["bounds"] = current_bounds - step["method"] = current_reduce_method - - active_parts.append(step.get("type")) + method = self.ui.combobox_reduce.currentText() + bg = self.ui.image_viewer._background_image + + def _step(src: str, amount: int) -> dict | None: + if not src or src == "off" or amount <= 0: + return None + if src == "aggregate": + if method == "None - current frame": + return None + return {"source": "aggregate", "bounds": bounds, "method": method, "amount": amount / 100.0} + if src == "file" and bg is not None: + return {"source": "file", "reference": bg, "amount": amount / 100.0} + return None + + sub_src = self.ui.combobox_ops_subtract_src.currentData() + sub_amt = self.ui.slider_ops_subtract.value() + div_src = self.ui.combobox_ops_divide_src.currentData() + div_amt = self.ui.slider_ops_divide.value() + + pipeline: dict = {} + if sub := _step(sub_src, sub_amt): + pipeline["subtract"] = sub + if div := _step(div_src, div_amt): + pipeline["divide"] = div if pipeline: - msg = f"Applying: {', '.join(active_parts)}..." + parts = [] + if "subtract" in pipeline: + parts.append("Subtracting") + if "divide" in pipeline: + parts.append("Dividing") + msg = " & ".join(parts) + "..." + else: + msg = None + if msg: with LoadingManager(self, msg, blocking_label=self.ui.blocking_status, blocking_delay_ms=0): self.ui.image_viewer.data.set_ops_pipeline(pipeline) self.ui.image_viewer.update_image() diff --git a/blitz/layout/ui.py b/blitz/layout/ui.py index be70d58..f9bd2b0 100644 --- a/blitz/layout/ui.py +++ b/blitz/layout/ui.py @@ -20,7 +20,6 @@ from .bench_sparklines import BenchSparklines from .viewer import ImageViewer from .widgets import ExtractionPlot, MeasureROI, TimePlot -from .filter_stack import FilterStackWidget TITLE = ( "BLITZ: (B)ulk (L)oading & (I)nteractive (T)ime series (Z)onal analysis " @@ -426,7 +425,7 @@ def setup_option_dock(self) -> None: view_layout.addStretch() self.create_option_tab(view_layout, "View") - # --- Ops: Filter Stack --- + # --- Ops: Subtract/Divide, Source Aggregate|File, Amount sliders --- ops_layout = QVBoxLayout() ops_label = QLabel("Ops") ops_label.setStyleSheet(get_style("heading")) @@ -436,11 +435,55 @@ def setup_option_dock(self) -> None: "Configure range and reduce method in Aggregate tab" ) ops_layout.addWidget(self.button_ops_open_aggregate) - - self.filter_stack = FilterStackWidget() - ops_layout.addWidget(self.filter_stack) - - # ops_layout.addStretch() # FilterStack has its own stretch + sub_grp = QGroupBox("1. Subtract") + sub_lay = QVBoxLayout() + sub_src_row = QHBoxLayout() + sub_src_row.addWidget(QLabel("Source:")) + self.combobox_ops_subtract_src = QComboBox() + self.combobox_ops_subtract_src.addItem("Off", "off") + self.combobox_ops_subtract_src.addItem("Aggregate", "aggregate") + self.combobox_ops_subtract_src.addItem("File", "file") + sub_src_row.addWidget(self.combobox_ops_subtract_src) + sub_lay.addLayout(sub_src_row) + sub_amt_row = QHBoxLayout() + sub_amt_row.addWidget(QLabel("Amount:")) + self.slider_ops_subtract = QSlider(Qt.Orientation.Horizontal) + self.slider_ops_subtract.setRange(0, 100) + self.slider_ops_subtract.setValue(100) + self.label_ops_subtract = QLabel("100%") + sub_amt_row.addWidget(self.slider_ops_subtract) + sub_amt_row.addWidget(self.label_ops_subtract) + sub_lay.addLayout(sub_amt_row) + sub_grp.setLayout(sub_lay) + ops_layout.addWidget(sub_grp) + div_grp = QGroupBox("2. Divide") + div_lay = QVBoxLayout() + div_src_row = QHBoxLayout() + div_src_row.addWidget(QLabel("Source:")) + self.combobox_ops_divide_src = QComboBox() + self.combobox_ops_divide_src.addItem("Off", "off") + self.combobox_ops_divide_src.addItem("Aggregate", "aggregate") + self.combobox_ops_divide_src.addItem("File", "file") + div_src_row.addWidget(self.combobox_ops_divide_src) + div_lay.addLayout(div_src_row) + div_amt_row = QHBoxLayout() + div_amt_row.addWidget(QLabel("Amount:")) + self.slider_ops_divide = QSlider(Qt.Orientation.Horizontal) + self.slider_ops_divide.setRange(0, 100) + self.slider_ops_divide.setValue(0) + self.label_ops_divide = QLabel("0%") + div_amt_row.addWidget(self.slider_ops_divide) + div_amt_row.addWidget(self.label_ops_divide) + div_lay.addLayout(div_amt_row) + div_grp.setLayout(div_lay) + ops_layout.addWidget(div_grp) + self.button_ops_load_file = QPushButton("Load reference image") + self.ops_file_widget = QWidget() + self.ops_file_widget.setLayout(QHBoxLayout()) + self.ops_file_widget.layout().addWidget(self.button_ops_load_file) + ops_layout.addWidget(self.ops_file_widget) + self.ops_file_widget.setVisible(False) + ops_layout.addStretch() self.create_option_tab(ops_layout, "Ops") # --- Timeline Panel: 2 Tabs Frame | Agg (Tab-Wechsel = Modus) ---