diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50afce6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests + run: pytest tests/ -v + env: + QT_QPA_PLATFORM: offscreen # headless Qt on Linux diff --git a/.github/workflows/generate-icns.yml b/.github/workflows/generate-icns.yml new file mode 100644 index 0000000..e272597 --- /dev/null +++ b/.github/workflows/generate-icns.yml @@ -0,0 +1,28 @@ +name: Generate macOS Icon + +on: + workflow_dispatch: # run manually from GitHub Actions UI + +jobs: + generate: + name: Generate Proteus.icns + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Generate .icns + run: | + mkdir -p packaging/Proteus.iconset + for size in 16 32 64 128 256 512; do + sips -z $size $size src/proteus/resources/Proteus.png \ + --out packaging/Proteus.iconset/icon_${size}x${size}.png + done + iconutil -c icns packaging/Proteus.iconset -o packaging/Proteus.icns + echo "Generated: $(ls -lh packaging/Proteus.icns)" + + - name: Upload .icns artifact + uses: actions/upload-artifact@v4 + with: + name: Proteus-icns + path: packaging/Proteus.icns diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..15817c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write # needed to create GitHub Release + +jobs: + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + archive: Proteus-linux.tar.gz + - os: macos-latest + archive: Proteus-macos.tar.gz + - os: windows-latest + archive: Proteus-windows.zip + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build (Linux / macOS) + if: runner.os != 'Windows' + run: bash packaging/build.sh --skip-tests + + - name: Build (Windows) + if: runner.os == 'Windows' + run: packaging\build.bat --skip-tests + + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.archive }} + path: ${{ matrix.archive }} + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + + steps: + - name: Download all archives + uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + name: ${{ github.ref_name }} + draft: false + prerelease: false + files: | + Proteus-linux.tar.gz + Proteus-macos.tar.gz + Proteus-windows.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e91ad4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +.eggs/ + +# Virtual environments +.venv/ +venv/ + +# PyInstaller +build/ +dist/ +*.spec.bak + +# IDE +.vscode/ +.idea/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 2b67e1d..9535c0d 100644 --- a/README.md +++ b/README.md @@ -1 +1,150 @@ # Proteus + +

+ Proteus Logo +

+ +

+ Scientific Image Processing Desktop Application +

+ +--- + +Proteus is a desktop application for scientific image processing, built with PySide6 (Qt) and OpenCV. It provides an interactive canvas with real-time tools for enhancement, analysis, and visualization of grayscale and multi-band imagery. + +## Features + +- **Image Enhancement** — Histogram equalization, power-law (gamma) transform, partial inversion, pseudocolor mapping (JET colormap) +- **Sharpen / Binarize** — Unsharp mask (Original), Otsu auto-threshold (B/W Auto), fixed threshold at 128 (B/W 128), custom threshold dialog (B/W Custom) +- **Noise Reduction** — Gaussian denoising, blur-divide background correction +- **Multi-Band Pseudocolor** — Merge two images with custom band labels (e.g. UV + IR), blend 50/50, apply JET colormap +- **PCA Analysis** — Covariance and SVD-based principal component analysis for multi-band images (3–16 images), with Prev/Next result navigation +- **Drawing Tools** — Freehand brush for mask creation and region annotation +- **ROI Selection** — Region-of-interest cropping, auto-applied to PCA +- **Undo/Redo** — Full operation history with Undo/Redo support +- **Themes** — Light, Dark, and High-Contrast themes with a one-click toggle, persisted across sessions + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.5.0 +- OpenCV >= 4.8.0 +- NumPy >= 1.24.0 + +## Installation (development) + +```bash +# Clone the repository +git clone https://github.com/yiyang26/Proteus.git +cd Proteus + +# Create a virtual environment +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# Install in development mode +pip install -e ".[dev]" +``` + +## Usage + +```bash +# Run via entry point +proteus + +# Or run as a module +python -m proteus +``` + +## Running Tests + +```bash +pytest +``` + +## Building a Standalone Executable + +The build scripts handle everything: creating a venv, installing dependencies, running tests, and producing an archive. + +> **Note:** Build on the target platform — Linux build for Linux, macOS for macOS, Windows for Windows. + +### Linux + +```bash +bash packaging/build.sh + +# Skip tests +bash packaging/build.sh --skip-tests +``` + +Output: `dist/Proteus/` + `Proteus-linux.tar.gz` + +### macOS + +**One-time icon prep** (requires macOS): +```bash +mkdir -p packaging/Proteus.iconset +sips -z 1024 1024 src/proteus/resources/Proteus.png \ + --out packaging/Proteus.iconset/icon_512x512@2x.png +iconutil -c icns packaging/Proteus.iconset -o packaging/Proteus.icns +``` + +```bash +bash packaging/build.sh + +# Skip tests +bash packaging/build.sh --skip-tests +``` + +Output: `dist/Proteus.app` + `Proteus-macos.tar.gz` + +### Windows + +**One-time icon prep** (requires [ImageMagick](https://imagemagick.org)): +```bat +magick src\proteus\resources\Proteus.png ^ + -define icon:auto-resize=256,128,64,32,16 packaging\Proteus.ico +``` + +```bat +packaging\build.bat + +:: Skip tests +packaging\build.bat --skip-tests +``` + +Output: `dist\Proteus\Proteus.exe` + `Proteus-windows.zip` + +## Project Structure + +``` +Proteus/ +├── src/proteus/ +│ ├── core/ # Processing logic (no UI dependencies) +│ │ ├── processing.py # Image enhancement & filtering functions +│ │ ├── pca.py # Principal component analysis +│ │ ├── image_io.py # Image load/save utilities +│ │ ├── state.py # ImageState & operation logging +│ │ └── utils.py # Shared helpers +│ ├── ui/ # PySide6 interface +│ │ ├── main_window.py # Main application window & signal wiring +│ │ ├── canvas.py # Interactive image canvas (QGraphicsView) +│ │ ├── top_bar.py # Logo, title, and theme toggle button +│ │ ├── sidebar.py # Collapsible tool panels +│ │ ├── status_bar.py # Status text and zoom controls +│ │ ├── dialogs.py # Parameter input dialogs +│ │ └── theme.py # Light / Dark / High-Contrast QSS theming +│ ├── resources/ # App icon and assets +│ └── app.py # Application entry point +├── tests/ # Test suite +├── packaging/ # Build scripts and PyInstaller spec +│ ├── Proteus.spec # PyInstaller configuration +│ ├── version_info.txt # Windows EXE version metadata +│ ├── build.sh # Linux / macOS build script +│ └── build.bat # Windows build script +└── pyproject.toml # Project metadata & dependencies +``` + +## License + +See [LICENSE](LICENSE) for details. diff --git a/Refactor/main.py b/Refactor/main.py deleted file mode 100644 index cb8a1c1..0000000 --- a/Refactor/main.py +++ /dev/null @@ -1,1593 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Dependencies: - pip install customtkinter pillow opencv-python numpy - -Run: - python proteus_single.py -""" - -import os -import sys -import math -import time -import json -from dataclasses import dataclass -from typing import Optional, List, Tuple, Dict, Any - -import numpy as np -import cv2 -from PIL import Image, ImageTk - -import tkinter as tk -from tkinter import filedialog, messagebox, simpledialog - -import customtkinter as ctk - - -# ========================= -# Utility: Tooltip -# ========================= -class Tooltip: - def __init__(self, widget, text: str, delay_ms=450): - self.widget = widget - self.text = text - self.delay_ms = delay_ms - self._after_id = None - self._tip = None - - widget.bind("", self._on_enter, add="+") - widget.bind("", self._on_leave, add="+") - widget.bind("", self._on_leave, add="+") - - def _on_enter(self, _evt=None): - self._after_id = self.widget.after(self.delay_ms, self._show) - - def _on_leave(self, _evt=None): - if self._after_id: - try: - self.widget.after_cancel(self._after_id) - except Exception: - pass - self._after_id = None - self._hide() - - def _show(self): - if self._tip or not self.text: - return - x = self.widget.winfo_rootx() + 12 - y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8 - self._tip = tk.Toplevel(self.widget) - self._tip.wm_overrideredirect(True) - self._tip.attributes("-topmost", True) - self._tip.geometry(f"+{x}+{y}") - - lbl = tk.Label( - self._tip, - text=self.text, - bg="#222", - fg="#fff", - padx=8, - pady=5, - font=("Arial", 10), - relief="solid", - bd=1 - ) - lbl.pack() - - def _hide(self): - if self._tip: - try: - self._tip.destroy() - except Exception: - pass - self._tip = None - - -# ========================= -# Image processing helper functions -# ========================= -def clamp(v, lo, hi): - return lo if v < lo else hi if v > hi else v - - -def to_uint8(img: np.ndarray) -> np.ndarray: - if img is None: - return img - if img.dtype == np.uint8: - return img - img2 = np.clip(img, 0, 255).astype(np.uint8) - return img2 - - -def ensure_gray(img: np.ndarray) -> np.ndarray: - if img is None: - return img - if img.ndim == 2: - return img - # assume BGR - return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - - -def ensure_color(img: np.ndarray) -> np.ndarray: - if img is None: - return img - if img.ndim == 3: - return img - return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) - - -def normalize_0_255(img: np.ndarray) -> np.ndarray: - if img is None: - return img - x = img.astype(np.float32) - mn = float(np.min(x)) - mx = float(np.max(x)) - if mx - mn < 1e-6: - return np.zeros_like(img, dtype=np.uint8) - y = (x - mn) / (mx - mn) * 255.0 - return y.astype(np.uint8) - - -def hist_equalize(img: np.ndarray) -> np.ndarray: - if img is None: - return img - if img.ndim == 2: - return cv2.equalizeHist(img) - # color: equalize Y channel - ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) - ycrcb[:, :, 0] = cv2.equalizeHist(ycrcb[:, :, 0]) - return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR) - - -def pseudocolor_jet(gray_u8: np.ndarray) -> np.ndarray: - gray_u8 = ensure_gray(gray_u8) - gray_u8 = to_uint8(gray_u8) - colored = cv2.applyColorMap(gray_u8, cv2.COLORMAP_JET) - return colored - - -def otsu_binarize(img: np.ndarray) -> np.ndarray: - g = ensure_gray(img) - g = to_uint8(g) - _, th = cv2.threshold(g, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) - return th - - -def fixed_binarize(img: np.ndarray, thresh: int = 128) -> np.ndarray: - g = ensure_gray(img) - g = to_uint8(g) - t = int(clamp(thresh, 0, 255)) - _, th = cv2.threshold(g, t, 255, cv2.THRESH_BINARY) - return th - - -def power_transform(img: np.ndarray, gamma: float, partial_invert: bool = False, pivot: int = 128) -> np.ndarray: - """Apply a power/gamma transform; optional partial inversion (invert pixels > pivot).""" - if img is None: - return img - x = img.astype(np.float32) / 255.0 - x = np.power(np.clip(x, 0, 1), gamma) - out = (x * 255.0).astype(np.uint8) - - if partial_invert: - if out.ndim == 2: - mask = out > pivot - out2 = out.copy() - out2[mask] = 255 - out2[mask] - return out2 - else: - g = ensure_gray(out) - mask = g > pivot - out2 = out.copy() - out2[mask] = 255 - out2[mask] - return out2 - return out - - -def blur_divide(img: np.ndarray, ksize: int = 31, sigma: float = 0) -> np.ndarray: - """Divide the image by its Gaussian-blurred version, then normalize and equalize.""" - if img is None: - return img - - if ksize % 2 == 0: - ksize += 1 - - if img.ndim == 2: - g = img.astype(np.float32) - blur = cv2.GaussianBlur(g, (ksize, ksize), sigmaX=sigma) - eps = 1e-6 - div = g / (blur + eps) - out = normalize_0_255(div) - out = hist_equalize(out) - return out - else: - # Processing the luminance channel is more stable - ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb).astype(np.float32) - y = ycrcb[:, :, 0] - blur = cv2.GaussianBlur(y, (ksize, ksize), sigmaX=sigma) - div = y / (blur + 1e-6) - y2 = normalize_0_255(div) - y2 = cv2.equalizeHist(y2) - ycrcb2 = ycrcb.copy() - ycrcb2[:, :, 0] = y2 - out = cv2.cvtColor(ycrcb2.astype(np.uint8), cv2.COLOR_YCrCb2BGR) - return out - - -def denoise_gaussian(img: np.ndarray, ksize: int = 5, sigma: float = 1.0) -> np.ndarray: - if img is None: - return img - if ksize % 2 == 0: - ksize += 1 - return cv2.GaussianBlur(img, (ksize, ksize), sigmaX=sigma) - - -def rotate_90(img: np.ndarray, direction: str) -> np.ndarray: - if img is None: - return img - if direction == "left": - return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) - return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) - - -# ========================= -# PCA multiband (3–16 grayscale images) -# ========================= -def pca_multiband(images_gray_u8: List[np.ndarray], roi: Optional[Tuple[int, int, int, int]] = None) -> Dict[str, Any]: - """ - images_gray_u8: list of HxW uint8 gray images, length = N (3..16) - roi: (x0,y0,x1,y1) in image coords, used to fit PCA; then applied to full image. - return dict: - { - 'pcs': [pc1_u8, pc2_u8, ...], # each HxW uint8 - 'explained': [ratio1, ratio2, ...], - 'mean': mean_vec, - 'components': comps, - } - """ - if not images_gray_u8 or len(images_gray_u8) < 3: - raise ValueError("PCA requires at least 3 grayscale images") - if len(images_gray_u8) > 16: - images_gray_u8 = images_gray_u8[:16] - - # Same size - H, W = images_gray_u8[0].shape[:2] - imgs = [] - for im in images_gray_u8: - if im.shape[:2] != (H, W): - raise ValueError("PCA input images must have the same size (selected files differ in dimensions)") - imgs.append(im.astype(np.float32)) - - # stack -> (H*W, N) - X = np.stack(imgs, axis=-1) # (H,W,N) - - if roi is not None: - x0, y0, x1, y1 = roi - x0, x1 = sorted([int(x0), int(x1)]) - y0, y1 = sorted([int(y0), int(y1)]) - x0 = clamp(x0, 0, W - 1) - x1 = clamp(x1, 1, W) - y0 = clamp(y0, 0, H - 1) - y1 = clamp(y1, 1, H) - X_fit = X[y0:y1, x0:x1, :].reshape(-1, X.shape[-1]) - else: - X_fit = X.reshape(-1, X.shape[-1]) - - # Mean-centering - mean = np.mean(X_fit, axis=0, keepdims=True) - Xc = X_fit - mean - - # Covariance and eigen-decomposition (N<=16 is small) - cov = (Xc.T @ Xc) / max(1, (Xc.shape[0] - 1)) - eigvals, eigvecs = np.linalg.eigh(cov) # ascending - idx = np.argsort(eigvals)[::-1] - eigvals = eigvals[idx] - eigvecs = eigvecs[:, idx] - - total = float(np.sum(eigvals)) if float(np.sum(eigvals)) > 1e-12 else 1.0 - explained = (eigvals / total).tolist() - - # Project full image - X_all = X.reshape(-1, X.shape[-1]).astype(np.float32) - X_all_c = X_all - mean - scores = X_all_c @ eigvecs # (H*W, N) - - pcs = [] - for k in range(min(len(images_gray_u8), 8)): # take the first 8 components for display - pc = scores[:, k].reshape(H, W) - pcs.append(normalize_0_255(pc)) - - return { - "pcs": pcs, - "explained": explained[:len(pcs)], - "mean": mean.flatten(), - "components": eigvecs - } - - -def pca_multiband_svd_variant( - images_gray_u8: List[np.ndarray], - roi: Optional[Tuple[int, int, int, int]] = None, - max_components: int = 8 -) -> Dict[str, Any]: - """ - SVD-variant implementation of multiband PCA (based on PCA.m) - Perform PCA across bands using an SVD-based approach: - - Z is d x n, where d is the number of bands and n is the number of samples (pixels) - - First center each row (band), then compute the SVD - The return structure is similar to pca_multiband: - { - 'pcs': [pc1_u8, pc2_u8, ...], - 'explained': [ratio1, ratio2, ...], - 'mean': mean_vec, # d dims - 'U': U, # d x r - 'S': S, # r - } - """ - if not images_gray_u8 or len(images_gray_u8) < 3: - raise ValueError("PCA (SVD) requires at least 3 grayscale images") - - # Limit to at most 16 bands - if len(images_gray_u8) > 16: - images_gray_u8 = images_gray_u8[:16] - - # Check sizes & convert to float32 - H, W = images_gray_u8[0].shape[:2] - imgs = [] - for im in images_gray_u8: - if im.shape[:2] != (H, W): - raise ValueError("PCA (SVD) input images must have the same size (selected files differ in dimensions)") - imgs.append(im.astype(np.float32)) - - # Choose the region used to fit PCA: ROI or full image - stack = np.stack(imgs, axis=0) # (N_bands, H, W) - if roi is not None: - x0, y0, x1, y1 = roi - x0, x1 = sorted([int(x0), int(x1)]) - y0, y1 = sorted([int(y0), int(y1)]) - x0 = clamp(x0, 0, W - 1) - x1 = clamp(x1, 1, W) - y0 = clamp(y0, 0, H - 1) - y1 = clamp(y1, 1, H) - sub = stack[:, y0:y1, x0:x1] - else: - sub = stack - - # Z: d x n (d=bands, n=pixels) - d = sub.shape[0] - Z = sub.reshape(d, -1) # (d, n) - - # Center each row (non-standard centering, matches PCA.m centerRows) - mu = np.mean(Z, axis=1, keepdims=True) # (d,1) - Zc = Z - mu - - # SVD decomposition (econ) - # Zc = U @ S_diag @ Vt - U, S, Vt = np.linalg.svd(Zc, full_matrices=False) - - # Truncate principal components - r = int(max_components) - r = max(1, min(r, d, U.shape[1])) - U_r = U[:, :r] # (d, r) - S_r = S[:r] # (r,) - Vt_r = Vt[:r, :] # (r, n) - - # Explained variance (proportional to eigenvalues; here eigenvalues ~ S^2) - eigvals = (S_r ** 2) - total = float(np.sum(eigvals)) if float(np.sum(eigvals)) > 1e-12 else 1.0 - explained = (eigvals / total).tolist() - - # Project onto the full image to obtain component images - Z_all = stack.reshape(d, -1) # (d, H*W) - Z_all_c = Z_all - mu # Use the same mean - # scores_all: r x (H*W) - scores_all = U_r.T @ Z_all_c - - pcs = [] - for k in range(r): - pc = scores_all[k, :].reshape(H, W) - pcs.append(normalize_0_255(pc)) - - return { - "pcs": pcs, - "explained": explained[:len(pcs)], - "mean": mu.flatten(), - "U": U_r, - "S": S_r, - } - - -# ========================= -# State stack (Undo/Redo) -# ========================= -@dataclass -class ImageState: - img: Optional[np.ndarray] # processed full-res image (BGR or GRAY) - draw_mask: Optional[np.ndarray] # HxW mask uint8 (0/255) for highlight - roi: Optional[Tuple[int, int, int, int]] - zoom: float - pan_x: float - pan_y: float - meta: Dict[str, Any] - - -# ========================= -# Main application -# ========================= -class ProteusApp(ctk.CTk): - def __init__(self): - super().__init__() - ctk.set_appearance_mode("dark") - ctk.set_default_color_theme("dark-blue") - - self.title("Proteus - Image Processing (Single File)") - try: - self.state("zoomed") - except Exception: - self.geometry("1200x760") - - # ---- Data ---- - self.base_img: Optional[np.ndarray] = None # original (or currently loaded) full-res - self.img: Optional[np.ndarray] = None # current processed full-res - self.draw_mask: Optional[np.ndarray] = None # brush highlight mask - self.roi: Optional[Tuple[int, int, int, int]] = None - - self.zoom = 1.0 - self.pan_x = 0.0 - self.pan_y = 0.0 - - self._pyramid: List[np.ndarray] = [] - self._tk_img = None - - self._mode = "pan" # pan | draw | roi - self._drawing = False - self._roi_dragging = False - self._last_xy = None - self._roi_start = None - self._roi_rect_id = None - - self.brush_size = 3 # 1-5 - self._pc_cache: Optional[Dict[str, Any]] = None - self._pc_index = 0 - - # Undo/Redo - self._history: List[ImageState] = [] - self._hist_i = -1 - - # Operation log (exported to txt) - self.ops_log: List[Dict[str, Any]] = [] - - # App logo (top-left) - self.logo_image = None - try: - base_dir = os.path.dirname(os.path.abspath(__file__)) - logo_path = os.path.join(base_dir, "Proteus.png") - if os.path.exists(logo_path): - _logo = Image.open(logo_path) - _logo = _logo.resize((80, 80), Image.LANCZOS) - self.logo_image = ImageTk.PhotoImage(_logo) - except Exception: - self.logo_image = None - - # ---- UI ---- - self._build_ui() - self._bind_shortcuts() - - # ---------------- UI ---------------- - def _build_ui(self): - self.grid_columnconfigure(1, weight=1) - self.grid_rowconfigure(0, weight=1) - - # Left toolbar (scrollable) - self.sidebar = ctk.CTkScrollableFrame(self, width=260, corner_radius=12) - self.sidebar.grid(row=0, column=0, sticky="nsw", padx=12, pady=12) - self.sidebar.grid_rowconfigure(99, weight=1) - self.sidebar.grid_columnconfigure(0, weight=1) - - # Title + logo - title_frame = ctk.CTkFrame(self.sidebar, fg_color="transparent") - title_frame.grid(row=0, column=0, padx=14, pady=(14, 10), sticky="w") - if self.logo_image is not None: - logo_label = ctk.CTkLabel(title_frame, image=self.logo_image, text="") - logo_label.grid(row=0, column=0, padx=(0, 8), pady=0, sticky="w") - title = ctk.CTkLabel(title_frame, text="Proteus Toolbox", font=ctk.CTkFont(size=18, weight="bold")) - title.grid(row=0, column=1, sticky="w") - - # File area - file_lbl = ctk.CTkLabel(self.sidebar, text="Files / History", font=ctk.CTkFont(size=14, weight="bold")) - file_lbl.grid(row=1, column=0, padx=14, pady=(8, 6), sticky="w") - - row = 2 - btn_open = ctk.CTkButton(self.sidebar, text="Open Image", command=self.open_image, width=220) - btn_open.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_open, "Open an image (common formats)") - - row += 1 - btn_save = ctk.CTkButton(self.sidebar, text="Save Current Image", command=self.save_image, width=220) - btn_save.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_save, "Save the current processed result (full resolution)") - - row += 1 - btn_clear = ctk.CTkButton(self.sidebar, text="Clear", fg_color="#7a1f1f", hover_color="#9a2a2a", command=self.clear_image, width=220) - btn_clear.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_clear, "Clear the current image and overlays") - - row += 1 - hist_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - hist_frame.grid(row=row, column=0, padx=14, pady=8) - hist_frame.grid_columnconfigure((0, 1), weight=1) - - btn_undo = ctk.CTkButton(hist_frame, text="Undo", command=self.undo, width=90) - btn_undo.grid(row=0, column=0, padx=6, pady=8) - Tooltip(btn_undo, "Undo one step (Ctrl+Z)") - - btn_redo = ctk.CTkButton(hist_frame, text="Redo", command=self.redo, width=90) - btn_redo.grid(row=0, column=1, padx=6, pady=8) - Tooltip(btn_redo, "Redo one step (Ctrl+Y)") - - # View/Edit - row += 1 - view_lbl = ctk.CTkLabel(self.sidebar, text="View / Annotate", font=ctk.CTkFont(size=14, weight="bold")) - view_lbl.grid(row=row, column=0, padx=14, pady=(12, 6), sticky="w") - - row += 1 - view_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - view_frame.grid(row=row, column=0, padx=14, pady=8) - view_frame.grid_columnconfigure((0, 1, 2), weight=1) - - btn_zi = ctk.CTkButton(view_frame, text="Zoom In +", command=self.zoom_in, width=60) - btn_zi.grid(row=0, column=0, padx=4, pady=8) - Tooltip(btn_zi, "Zoom in (shortcut +)") - - btn_zo = ctk.CTkButton(view_frame, text="Zoom Out -", command=self.zoom_out, width=60) - btn_zo.grid(row=0, column=1, padx=4, pady=8) - Tooltip(btn_zo, "Zoom out (shortcut -)") - - btn_zr = ctk.CTkButton(view_frame, text="Reset 0", command=self.reset_zoom, width=60) - btn_zr.grid(row=0, column=2, padx=4, pady=8) - Tooltip(btn_zr, "Reset zoom (shortcut 0)") - - row += 1 - mode_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - mode_frame.grid(row=row, column=0, padx=14, pady=8) - mode_frame.grid_columnconfigure((0, 1, 2), weight=1) - - self.btn_pan = ctk.CTkButton(mode_frame, text="Pan", command=lambda: self.set_mode("pan"), width=60) - self.btn_pan.grid(row=0, column=0, padx=4, pady=8) - Tooltip(self.btn_pan, "Drag to pan") - - self.btn_draw = ctk.CTkButton(mode_frame, text="Brush", command=lambda: self.set_mode("draw"), width=60) - self.btn_draw.grid(row=0, column=1, padx=4, pady=8) - Tooltip(self.btn_draw, "Yellow highlight (erasable)") - - self.btn_roi = ctk.CTkButton(mode_frame, text="ROI", command=lambda: self.set_mode("roi"), width=60) - self.btn_roi.grid(row=0, column=2, padx=4, pady=8) - Tooltip(self.btn_roi, "Select ROI (for PCA, etc.)") - - row += 1 - brush_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - brush_frame.grid(row=row, column=0, padx=14, pady=8) - brush_frame.grid_columnconfigure(0, weight=1) - - ctk.CTkLabel(brush_frame, text="Brush Size (1–5)").grid(row=0, column=0, padx=10, pady=(10, 2), sticky="w") - self.brush_slider = ctk.CTkSlider(brush_frame, from_=1, to=5, number_of_steps=4, command=self._on_brush_change) - self.brush_slider.set(self.brush_size) - self.brush_slider.grid(row=1, column=0, padx=10, pady=(2, 10), sticky="ew") - - row += 1 - btn_clear_draw = ctk.CTkButton(self.sidebar, text="Clear Drawing", command=self.clear_drawing, width=220) - btn_clear_draw.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_clear_draw, "Clear yellow highlights (does not affect the image)") - - row += 1 - btn_clear_roi = ctk.CTkButton(self.sidebar, text="Clear ROI", command=self.clear_roi, width=220) - btn_clear_roi.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_clear_roi, "Clear ROI selection") - - # Image Processing - row += 1 - proc_lbl = ctk.CTkLabel(self.sidebar, text="Image Processing", font=ctk.CTkFont(size=14, weight="bold")) - proc_lbl.grid(row=row, column=0, padx=14, pady=(14, 6), sticky="w") - - # Pseudocolor mode buttons: All / R / G / B - row += 1 - pc_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - pc_frame.grid(row=row, column=0, padx=14, pady=8) - pc_frame.grid_columnconfigure((0, 1, 2, 3), weight=1) - - btn_pc_all = ctk.CTkButton(pc_frame, text="Pseudocolor-All", command=lambda: self.apply_pseudocolor_channel("all"), width=60) - btn_pc_all.grid(row=0, column=0, padx=4, pady=8) - Tooltip(btn_pc_all, "Pseudocolor (All): based on overall intensity") - - btn_pc_r = ctk.CTkButton(pc_frame, text="Pseudocolor-R", command=lambda: self.apply_pseudocolor_channel("r"), width=60) - btn_pc_r.grid(row=0, column=1, padx=4, pady=8) - Tooltip(btn_pc_r, "Pseudocolor (R): based on R channel intensity") - - btn_pc_g = ctk.CTkButton(pc_frame, text="Pseudocolor-G", command=lambda: self.apply_pseudocolor_channel("g"), width=60) - btn_pc_g.grid(row=1, column=0, padx=4, pady=8) - Tooltip(btn_pc_g, "Pseudocolor (G): based on G channel intensity") - - btn_pc_b = ctk.CTkButton(pc_frame, text="Pseudocolor-B", command=lambda: self.apply_pseudocolor_channel("b"), width=60) - btn_pc_b.grid(row=1, column=1, padx=4, pady=8) - Tooltip(btn_pc_b, "Pseudocolor (B): based on B channel intensity") - - row += 1 - btn_pc2 = ctk.CTkButton(self.sidebar, text="Pseudocolor (Merge Two Images)", command=self.apply_pseudocolor_two, width=220) - btn_pc2.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_pc2, "Select two grayscale images, merge them, then apply JET (All mode)") - - # Sharpen (keep Otsu + fixed threshold 128) - row += 1 - sharp_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - sharp_frame.grid(row=row, column=0, padx=14, pady=8) - sharp_frame.grid_columnconfigure((0, 1), weight=1) - - btn_sharp_otsu = ctk.CTkButton(sharp_frame, text="Sharpen(Otsu)", command=self.apply_sharpen_otsu, width=100) - btn_sharp_otsu.grid(row=0, column=0, padx=6, pady=8) - Tooltip(btn_sharp_otsu, "Sharpen: Otsu automatic threshold") - - btn_sharp_fix = ctk.CTkButton(sharp_frame, text="Sharpen(128)", command=self.apply_sharpen_fixed, width=100) - btn_sharp_fix.grid(row=0, column=1, padx=6, pady=8) - Tooltip(btn_sharp_fix, "Sharpen: fixed threshold 128") - - row += 1 - btn_pow = ctk.CTkButton(self.sidebar, text="Power (Gamma)", command=self.apply_power, width=220) - btn_pow.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_pow, "Gamma/power transform + optional partial inversion") - - row += 1 - btn_inv = ctk.CTkButton(self.sidebar, text="Invert", command=self.apply_invert, width=220) - btn_inv.grid(row=row, column=0, padx=14, pady=6) - - row += 1 - rot_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - rot_frame.grid(row=row, column=0, padx=14, pady=8) - rot_frame.grid_columnconfigure((0, 1), weight=1) - - btn_rl = ctk.CTkButton(rot_frame, text="Rotate Left 90°", command=lambda: self.apply_rotate("left"), width=90) - btn_rl.grid(row=0, column=0, padx=6, pady=8) - - btn_rr = ctk.CTkButton(rot_frame, text="Rotate Right 90°", command=lambda: self.apply_rotate("right"), width=90) - btn_rr.grid(row=0, column=1, padx=6, pady=8) - - row += 1 - btn_bd = ctk.CTkButton(self.sidebar, text="Blur & Divide", command=self.apply_blur_divide, width=220) - btn_bd.grid(row=row, column=0, padx=14, pady=6) - Tooltip(btn_bd, "Divide after Gaussian blur + normalize + equalize") - - row += 1 - btn_dn = ctk.CTkButton(self.sidebar, text="Denoise (Gaussian)", command=self.apply_denoise, width=220) - btn_dn.grid(row=row, column=0, padx=14, pady=6) - - row += 1 - pca_frame = ctk.CTkFrame(self.sidebar, corner_radius=10) - pca_frame.grid(row=row, column=0, padx=14, pady=10) - pca_frame.grid_columnconfigure((0, 1, 2), weight=1) - - btn_pca = ctk.CTkButton(pca_frame, text="PCA", command=self.apply_pca, width=70) - btn_pca.grid(row=0, column=0, padx=4, pady=8) - Tooltip(btn_pca, "Select multiple grayscale images for PCA (covariance method, 3–16 images; ROI can be used for fitting)") - - btn_pca_svd = ctk.CTkButton(pca_frame, text="PCA-SVD", command=self.apply_pca_svd, width=80) - btn_pca_svd.grid(row=0, column=1, padx=4, pady=8) - Tooltip(btn_pca_svd, "PCA SVD variant (based on PCA.m, 3–16 images; ROI can be used for fitting)") - - btn_pc_next = ctk.CTkButton(pca_frame, text="Next PC", command=self.next_pc, width=70) - btn_pc_next.grid(row=0, column=2, padx=4, pady=8) - Tooltip(btn_pc_next, "Cycle PC1/PC2/… (run PCA or PCA-SVD first)") - - # Right-side canvas - self.viewer = ctk.CTkFrame(self, corner_radius=12) - self.viewer.grid(row=0, column=1, sticky="nsew", padx=(0, 12), pady=12) - self.viewer.grid_columnconfigure(0, weight=1) - self.viewer.grid_rowconfigure(1, weight=1) - - info_bar = ctk.CTkFrame(self.viewer, corner_radius=12) - info_bar.grid(row=0, column=0, sticky="ew", padx=12, pady=(12, 8)) - info_bar.grid_columnconfigure(0, weight=1) - - self.status = ctk.CTkLabel(info_bar, text="Ready: open an image to begin", anchor="w") - self.status.grid(row=0, column=0, padx=10, pady=10, sticky="ew") - - # Canvas (tk.Canvas works best for pan/roi/draw) - self.canvas = tk.Canvas(self.viewer, bg="#111", highlightthickness=0) - self.canvas.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12)) - - self.canvas.bind("", lambda e: self.render()) - self.canvas.bind("", self.on_mouse_down) - self.canvas.bind("", self.on_mouse_drag) - self.canvas.bind("", self.on_mouse_up) - self.canvas.bind("", self.on_wheel) # Windows/macOS - self.canvas.bind("", self.on_wheel_linux) # Linux up - self.canvas.bind("", self.on_wheel_linux) # Linux down - - self.set_mode("pan") - - def _bind_shortcuts(self): - self.bind_all("", lambda e: self.undo()) - self.bind_all("", lambda e: self.redo()) - self.bind_all("", lambda e: self.zoom_in()) - self.bind_all("", lambda e: self.zoom_in()) # On most keyboards, '+' requires Shift - self.bind_all("", lambda e: self.zoom_out()) - self.bind_all("", lambda e: self.reset_zoom()) - - # ---------------- State & Rendering ---------------- - def set_status(self, text: str): - self.status.configure(text=text) - - def _on_brush_change(self, v): - self.brush_size = int(round(float(v))) - - def set_mode(self, mode: str): - self._mode = mode - # Simple highlight: change the active button color - def style(btn, active: bool): - if active: - btn.configure(fg_color="#2b6cb0") - else: - btn.configure(fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"]) - - style(self.btn_pan, mode == "pan") - style(self.btn_draw, mode == "draw") - style(self.btn_roi, mode == "roi") - self.set_status(f"Mode:{mode}(Pan/Brush/ROI)") - - def _push_history(self, meta: Optional[Dict[str, Any]] = None): - """Push the current state onto the stack (for Undo/Redo).""" - if self.img is None: - return - st = ImageState( - img=self.img.copy(), - draw_mask=None if self.draw_mask is None else self.draw_mask.copy(), - roi=None if self.roi is None else tuple(self.roi), - zoom=float(self.zoom), - pan_x=float(self.pan_x), - pan_y=float(self.pan_y), - meta=meta or {} - ) - # Discard redo branch - if self._hist_i < len(self._history) - 1: - self._history = self._history[: self._hist_i + 1] - self._history.append(st) - self._hist_i = len(self._history) - 1 - - # Record operation log (only image-processing operations) - if meta is not None: - op = meta.get("op") - if op not in ("open", "draw", "roi", "clear_draw", "clear_roi"): - # Store a shallow copy to avoid later mutation - self.ops_log.append(dict(meta)) - - def undo(self): - if len(self._history) == 0: - self.set_status("Undo: history is empty (perform an operation first)") - return - if self._hist_i <= 0: - self.set_status("Undo: no earlier history (already at the initial state)") - return - self._hist_i -= 1 - self._apply_state(self._history[self._hist_i]) - self.set_status(f"Undo complete ({self._hist_i + 1}/{len(self._history)})") - - def redo(self): - if len(self._history) == 0: - self.set_status("Redo: history is empty (perform an operation first)") - return - if self._hist_i >= len(self._history) - 1: - self.set_status(f"Redo: already at the latest state (history: {len(self._history)} items, position: {self._hist_i + 1})") - return - self._hist_i += 1 - self._apply_state(self._history[self._hist_i]) - self.set_status(f"Redo complete ({self._hist_i + 1}/{len(self._history)})") - - def _apply_state(self, st: ImageState): - self.img = None if st.img is None else st.img.copy() - self.draw_mask = None if st.draw_mask is None else st.draw_mask.copy() - self.roi = None if st.roi is None else tuple(st.roi) - self.zoom = float(st.zoom) - self.pan_x = float(st.pan_x) - self.pan_y = float(st.pan_y) - self._rebuild_pyramid() - self.render() - - def _rebuild_pyramid(self): - """Pyramid cache: for faster zoom rendering""" - self._pyramid = [] - if self.img is None: - return - im = self.img - self._pyramid.append(im) - # Downsample level by level (until the shortest side < 256 or up to 6 levels) - for _ in range(6): - h, w = im.shape[:2] - if min(h, w) < 256: - break - im = cv2.pyrDown(im) - self._pyramid.append(im) - - def _pick_pyramid_level(self, zoom: float) -> Tuple[np.ndarray, float]: - """ - zoom=1 uses level0 - zoom=0.5 -> can use level1 as the base (because level1 is half size) - Returns: selected level image & zoom relative to that level - """ - if not self._pyramid: - return self.img, zoom - # When zoom < 1, choose a smaller pyramid level as the base - level = 0 - z = zoom - while z < 0.75 and level + 1 < len(self._pyramid): - z *= 2.0 - level += 1 - return self._pyramid[level], z - - def render(self): - self.canvas.delete("all") - if self.img is None: - self.canvas.create_text( - 20, 20, anchor="nw", - fill="#aaa", - font=("Arial", 14), - text="No image loaded: click [Open Image] on the left" - ) - return - - canvas_w = max(1, self.canvas.winfo_width()) - canvas_h = max(1, self.canvas.winfo_height()) - - base, rel_zoom = self._pick_pyramid_level(self.zoom) - H, W = base.shape[:2] - - # Display size - disp_w = int(W * rel_zoom) - disp_h = int(H * rel_zoom) - disp_w = max(1, disp_w) - disp_h = max(1, disp_h) - - # Compute pan (in screen coordinates) - cx = canvas_w / 2.0 + self.pan_x - cy = canvas_h / 2.0 + self.pan_y - - # Top-left corner of the target region in the displayed image - x0 = int(cx - disp_w / 2) - y0 = int(cy - disp_h / 2) - - # Generate the scaled display image - interp = cv2.INTER_AREA if rel_zoom < 1 else cv2.INTER_LINEAR - disp = cv2.resize(base, (disp_w, disp_h), interpolation=interp) - - # Overlay brush (yellow) - if self.draw_mask is not None: - # Mask is full-res; map it via pyramid level + zoom - # First resize the full-res mask to the selected base size - # Base corresponds to pyramid level L, i.e., full-res scaled by 1/(2^L) - # For simplicity and robustness, just resize (mirroring the pyrDown logic) - mask_base = cv2.resize(self.draw_mask, (W, H), interpolation=cv2.INTER_NEAREST) - mask_disp = cv2.resize(mask_base, (disp_w, disp_h), interpolation=cv2.INTER_NEAREST) - if disp.ndim == 2: - disp = ensure_color(disp) - overlay = disp.copy() - overlay[mask_disp > 0] = (0, 255, 255) # BGR: yellow - disp = cv2.addWeighted(disp, 0.78, overlay, 0.22, 0) - - # Convert to PIL / Tk - disp_rgb = disp - if disp_rgb.ndim == 2: - disp_rgb = cv2.cvtColor(disp_rgb, cv2.COLOR_GRAY2RGB) - else: - disp_rgb = cv2.cvtColor(disp_rgb, cv2.COLOR_BGR2RGB) - - pil = Image.fromarray(disp_rgb) - self._tk_img = ImageTk.PhotoImage(pil) - - # Draw image on the canvas - self.canvas.create_image(x0, y0, anchor="nw", image=self._tk_img) - - # ROI rectangle (drawn on the canvas) - if self.roi is not None: - # ROI is in full-res coords -> map to canvas coords (full-res is more accurate) - self._draw_roi_rect() - - def _draw_roi_rect(self): - if self.img is None or self.roi is None: - return - # Compute full-res -> canvas coordinate mapping first - canvas_w = max(1, self.canvas.winfo_width()) - canvas_h = max(1, self.canvas.winfo_height()) - - base, rel_zoom = self._pick_pyramid_level(self.zoom) - H, W = base.shape[:2] - disp_w = int(W * rel_zoom) - disp_h = int(H * rel_zoom) - disp_w = max(1, disp_w) - disp_h = max(1, disp_h) - - cx = canvas_w / 2.0 + self.pan_x - cy = canvas_h / 2.0 + self.pan_y - x0 = int(cx - disp_w / 2) - y0 = int(cy - disp_h / 2) - - # roi is in full-res; map to base size - full_h, full_w = self.img.shape[:2] - rx0, ry0, rx1, ry1 = self.roi - rx0, rx1 = sorted([rx0, rx1]) - ry0, ry1 = sorted([ry0, ry1]) - - # map full -> base - bx0 = rx0 / full_w * W - bx1 = rx1 / full_w * W - by0 = ry0 / full_h * H - by1 = ry1 / full_h * H - - # base -> disp - dx0 = bx0 * rel_zoom - dx1 = bx1 * rel_zoom - dy0 = by0 * rel_zoom - dy1 = by1 * rel_zoom - - # disp -> canvas - cx0 = x0 + dx0 - cx1 = x0 + dx1 - cy0 = y0 + dy0 - cy1 = y0 + dy1 - - self.canvas.create_rectangle(cx0, cy0, cx1, cy1, outline="#00ff99", width=2, dash=(6, 3)) - - # ---------------- Coordinate mapping ---------------- - def canvas_to_image_xy(self, cx: float, cy: float) -> Optional[Tuple[int, int]]: - """Canvas coordinates -> full-res image coordinates""" - if self.img is None: - return None - - canvas_w = max(1, self.canvas.winfo_width()) - canvas_h = max(1, self.canvas.winfo_height()) - - base, rel_zoom = self._pick_pyramid_level(self.zoom) - H, W = base.shape[:2] - disp_w = max(1, int(W * rel_zoom)) - disp_h = max(1, int(H * rel_zoom)) - - center_x = canvas_w / 2.0 + self.pan_x - center_y = canvas_h / 2.0 + self.pan_y - top_left_x = center_x - disp_w / 2.0 - top_left_y = center_y - disp_h / 2.0 - - # canvas -> disp - dx = cx - top_left_x - dy = cy - top_left_y - if dx < 0 or dy < 0 or dx >= disp_w or dy >= disp_h: - return None - - # disp -> base - bx = dx / rel_zoom - by = dy / rel_zoom - - # base -> full-res - full_h, full_w = self.img.shape[:2] - fx = int(bx / W * full_w) - fy = int(by / H * full_h) - fx = clamp(fx, 0, full_w - 1) - fy = clamp(fy, 0, full_h - 1) - return fx, fy - - # ---------------- Mouse interaction ---------------- - def on_mouse_down(self, e): - if self.img is None: - return - self._last_xy = (e.x, e.y) - - if self._mode == "draw": - self._drawing = True - self._draw_at(e.x, e.y) - elif self._mode == "roi": - self._roi_dragging = True - pt = self.canvas_to_image_xy(e.x, e.y) - if pt is not None: - self._roi_start = pt - self.roi = (pt[0], pt[1], pt[0], pt[1]) - else: - # pan - pass - - def on_mouse_drag(self, e): - if self.img is None or self._last_xy is None: - return - lx, ly = self._last_xy - dx = e.x - lx - dy = e.y - ly - self._last_xy = (e.x, e.y) - - if self._mode == "draw" and self._drawing: - self._draw_at(e.x, e.y) - elif self._mode == "roi" and self._roi_dragging: - pt = self.canvas_to_image_xy(e.x, e.y) - if pt is not None and self._roi_start is not None: - x0, y0 = self._roi_start - self.roi = (x0, y0, pt[0], pt[1]) - self.render() - else: - # pan - self.pan_x += dx - self.pan_y += dy - self.render() - - def on_mouse_up(self, e): - if self.img is None: - return - if self._mode == "draw" and self._drawing: - self._drawing = False - self._push_history({"op": "draw"}) - if self._mode == "roi" and self._roi_dragging: - self._roi_dragging = False - if self.roi is not None: - self._push_history({"op": "roi"}) - self.set_status("ROI set (PCA will prefer ROI for fitting)") - self._last_xy = None - - def on_wheel(self, e): - # Windows/macOS: delta positive=up - if e.delta > 0: - self.zoom_in() - else: - self.zoom_out() - - def on_wheel_linux(self, e): - if e.num == 4: - self.zoom_in() - else: - self.zoom_out() - - def _draw_at(self, cx, cy): - if self.img is None: - return - pt = self.canvas_to_image_xy(cx, cy) - if pt is None: - return - x, y = pt - h, w = self.img.shape[:2] - if self.draw_mask is None or self.draw_mask.shape[:2] != (h, w): - self.draw_mask = np.zeros((h, w), dtype=np.uint8) - - # Brush radius: inversely proportional to zoom so the on-screen brush size stays roughly constant - radius = int(round(self.brush_size * 6 / max(0.25, self.zoom))) - radius = clamp(radius, 2, 40) - cv2.circle(self.draw_mask, (x, y), radius, 255, thickness=-1) - self.render() - - # ---------------- Zoom ---------------- - def zoom_in(self): - self.zoom = clamp(self.zoom * 1.25, 0.25, 4.0) - self.render() - self.set_status(f"Zoom: {self.zoom:.2f}x") - - def zoom_out(self): - self.zoom = clamp(self.zoom / 1.25, 0.25, 4.0) - self.render() - self.set_status(f"Zoom: {self.zoom:.2f}x") - - def reset_zoom(self): - self.zoom = 1.0 - self.pan_x = 0.0 - self.pan_y = 0.0 - self.render() - self.set_status("Zoom reset: 1.00x") - - # ---------------- Files ---------------- - def open_image(self): - path = filedialog.askopenfilename( - title="Open Image", - filetypes=[ - ("Image", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), - ("All", "*.*") - ] - ) - if not path: - return - img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if img is None: - messagebox.showerror("Error", "Unable to read this image") - return - - # Normalize to uint8 (simple scaling for 16-bit) - if img.dtype == np.uint16: - img = normalize_0_255(img) - elif img.dtype != np.uint8: - img = to_uint8(img) - - # Support alpha channel - if img.ndim == 3 and img.shape[2] == 4: - img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) - - self.base_img = img.copy() - self.img = img.copy() - self.draw_mask = None - self.roi = None - self.zoom = 1.0 - self.pan_x = 0.0 - self.pan_y = 0.0 - self._pc_cache = None - self._pc_index = 0 - self._rebuild_pyramid() - self._history = [] - self._hist_i = -1 - self.ops_log = [] - self._push_history({"op": "open", "path": path}) - self.render() - self.set_status(f"Opened: {os.path.basename(path)} | {self.img.shape[1]}x{self.img.shape[0]}") - - def save_image(self): - if self.img is None: - return - path = filedialog.asksaveasfilename( - title="Save Image", - defaultextension=".png", - filetypes=[("PNG", "*.png"), ("JPG", "*.jpg"), ("BMP", "*.bmp"), ("TIFF", "*.tif *.tiff")] - ) - if not path: - return - - out = self.img.copy() - - # Overlay drawing? - if self.draw_mask is not None: - out2 = ensure_color(out) - overlay = out2.copy() - overlay[self.draw_mask > 0] = (0, 255, 255) - out = cv2.addWeighted(out2, 0.78, overlay, 0.22, 0) - - # Use imencode for non-ASCII paths - ext = os.path.splitext(path)[1].lower() - if ext in [".jpg", ".jpeg"]: - ok, buf = cv2.imencode(".jpg", out, [int(cv2.IMWRITE_JPEG_QUALITY), 95]) - elif ext in [".bmp"]: - ok, buf = cv2.imencode(".bmp", out) - elif ext in [".tif", ".tiff"]: - ok, buf = cv2.imencode(".tif", out) - else: - ok, buf = cv2.imencode(".png", out) - - if not ok: - messagebox.showerror("Error", "Save failed") - return - try: - buf.tofile(path) - except Exception as e: - messagebox.showerror("Error", f"Save failed:{e}") - return - - # Same-name txt log: record processing steps and parameters - try: - txt_path = os.path.splitext(path)[0] + ".txt" - - lines: List[str] = [] - # Follow the header structure of 1.txt - lines.append("Technical") - lines.append("System Manufacturer: ") - lines.append("Lights used: ") - lines.append("Name of photographer: ") - lines.append("Name of processor: ") - lines.append("") - lines.append("Object") - lines.append("Name (if any): ") - lines.append("Shelfmark: ") - lines.append("Material: ") - lines.append("Institution/owner: ") - lines.append("") - - # Summarize the 'Processes used' overview - from collections import Counter - - def _friendly_name(meta: Dict[str, Any]) -> str: - op = meta.get("op", "") - if op in ("pseudocolor_current", "pseudocolor_two", "pseudocolor_channel"): - ch = meta.get("channel") - if ch in ("r", "g", "b", "all"): - return f"Pseudocolor-{ch.upper()}" - return "Pseudocolor" - if op in ("sharpen_otsu", "sharpie_binarize"): - return "Sharpen(Otsu)" - if op == "sharpen_fixed": - return "Sharpen(128)" - if op == "power": - return "Power" - if op == "invert": - return "Invert" - if op == "blur_divide": - return "Blur&Divide" - if op == "denoise_gaussian": - return "Denoise(Gaussian)" - if op == "pca": - return "PCA" - if op == "pca_svd": - return "PCA-SVD" - if op == "rotate_90": - direction = meta.get("direction", "") - return f"Rotate90({direction})" - return op or "Unknown" - - friendly_ops = [_friendly_name(m) for m in self.ops_log] - if friendly_ops: - cnt = Counter(friendly_ops) - summary_parts = [f"{name} x{c}" for name, c in cnt.items()] - summary = ", ".join(summary_parts) - else: - summary = "None" - - lines.append(f"Processes used: {summary}") - lines.append("") - lines.append("Process details:") - - for i, meta in enumerate(self.ops_log, start=1): - name = _friendly_name(meta) - params = {k: v for k, v in meta.items() if k != "op"} - if params: - lines.append(f"{i}. {name} | params: {json.dumps(params, ensure_ascii=False)}") - else: - lines.append(f"{i}. {name}") - - with open(txt_path, "w", encoding="utf-8") as f: - f.write("\n".join(lines)) - except Exception as e: - # Only show a warning; do not block successful image saving - messagebox.showwarning("Warning", f"Image saved, but failed to write log txt: {e}") - - self.set_status(f"Saved: {path}") - - def clear_image(self): - self.base_img = None - self.img = None - self.draw_mask = None - self.roi = None - self._pyramid = [] - self._pc_cache = None - self._pc_index = 0 - self._history = [] - self._hist_i = -1 - self.render() - self.set_status("Cleared") - - def clear_drawing(self): - if self.img is None: - return - self.draw_mask = None - self._push_history({"op": "clear_draw"}) - self.render() - self.set_status("Drawing cleared") - - def clear_roi(self): - if self.img is None: - return - self.roi = None - self._push_history({"op": "clear_roi"}) - self.render() - self.set_status("ROI cleared") - - # ---------------- Operation wrapper ---------------- - def _apply_op(self, fn, meta: Dict[str, Any]): - if self.img is None: - messagebox.showinfo("Info", "Please open an image first") - return - try: - self.img = fn(self.img) - if self.img is None: - raise ValueError("Processing result is empty") - self._pc_cache = None # Non-PCA operations clear PCA results - self._pc_index = 0 - self._rebuild_pyramid() - self._push_history(meta) - self.render() - self.set_status(f"Done: {meta.get('op', 'operation')}") - except Exception as e: - messagebox.showerror("Error", f"Operation failed: {e}") - - # ---------------- Image Processing functions ---------------- - def apply_pseudocolor_current(self): - # Backward compatibility: equivalent to the All channel - self.apply_pseudocolor_channel("all") - - def apply_pseudocolor_channel(self, channel: str): - """ - Pseudocolor supports four modes: R / G / B / All: - - All: based on overall intensity (grayscale) - - R/G/B: generate pseudocolor based on the selected channel intensity - """ - - ch = str(channel).lower() - - def op(img): - if ch == "all": - g = ensure_gray(img) - return pseudocolor_jet(g) - - # Based on the selected color channel - color = ensure_color(img) - if ch == "r": - src = color[:, :, 2] # BGR -> R - elif ch == "g": - src = color[:, :, 1] - elif ch == "b": - src = color[:, :, 0] - else: - g = ensure_gray(color) - return pseudocolor_jet(g) - src_u8 = to_uint8(src) - return pseudocolor_jet(src_u8) - - self._apply_op(op, {"op": "pseudocolor_channel", "channel": ch}) - - def apply_pseudocolor_two(self): - paths = filedialog.askopenfilenames( - title="Select two grayscale images (or any images; they will be converted to grayscale)", - filetypes=[("Image", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("All", "*.*")] - ) - if not paths or len(paths) < 2: - return - p1, p2 = paths[0], paths[1] - - def read_gray(p): - im = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if im is None: - raise ValueError(f"Cannot read: {p}") - if im.dtype == np.uint16: - im = normalize_0_255(im) - im = to_uint8(im) - if im.ndim == 3 and im.shape[2] == 4: - im = cv2.cvtColor(im, cv2.COLOR_BGRA2BGR) - return ensure_gray(im) - - try: - g1 = read_gray(p1) - g2 = read_gray(p2) - if g1.shape != g2.shape: - messagebox.showerror("Error", "The two images must have the same size (currently they do not match)") - return - mix = cv2.addWeighted(g1, 0.5, g2, 0.5, 0) - out = pseudocolor_jet(mix) - self.img = out - self.base_img = out.copy() - self.draw_mask = None - self.roi = None - self._pc_cache = None - self._pc_index = 0 - self.zoom = 1.0 - self.pan_x = 0.0 - self.pan_y = 0.0 - self._rebuild_pyramid() - self._history = [] - self._hist_i = -1 - self.ops_log = [] - self._push_history({"op": "pseudocolor_two", "p1": p1, "p2": p2}) - self.render() - self.set_status("Done: Pseudocolor (Merge Two Images)") - except Exception as e: - messagebox.showerror("Error", str(e)) - - def apply_sharpen_otsu(self): - """Sharpen: Otsu automatic threshold""" - - def op(img): - return otsu_binarize(img) - - self._apply_op(op, {"op": "sharpen_otsu"}) - - def apply_sharpen_fixed(self): - """Sharpen: fixed threshold 128""" - - def op(img): - return fixed_binarize(img, thresh=128) - - self._apply_op(op, {"op": "sharpen_fixed", "thresh": 128}) - - def apply_power(self): - if self.img is None: - return - gamma = simpledialog.askfloat("Power (Gamma)", "Enter gamma (e.g., 0.5 / 1.2 / 2.0):", initialvalue=1.6, minvalue=0.01, maxvalue=10.0) - if gamma is None: - return - partial = messagebox.askyesno("Partial inversion", "Enable partial inversion?") - pivot = 128 - if partial: - pv = simpledialog.askinteger("Partial inversion threshold", "pivot (0–255); pixels > pivot will be inverted:", initialvalue=128, minvalue=0, maxvalue=255) - if pv is not None: - pivot = int(pv) - - def op(img): - return power_transform(img, gamma=float(gamma), partial_invert=bool(partial), pivot=int(pivot)) - self._apply_op(op, {"op": "power", "gamma": gamma, "partial": partial, "pivot": pivot}) - - def apply_invert(self): - """Invert (with Alpha intensity parameter)""" - if self.img is None: - return - alpha = simpledialog.askfloat( - "Invert", - "Invert intensity Alpha (0–1; 0 = original, 1 = fully inverted):", - initialvalue=1.0, - minvalue=0.0, - maxvalue=1.0, - ) - if alpha is None: - return - - a = float(alpha) - - def op(img): - inv = 255 - img - x = img.astype(np.float32) - y = inv.astype(np.float32) - out = (1.0 - a) * x + a * y - return out.clip(0, 255).astype(np.uint8) - - self._apply_op(op, {"op": "invert", "alpha": a}) - - def apply_rotate(self, direction: str): - def op(img): - return rotate_90(img, direction=direction) - self._apply_op(op, {"op": "rotate_90", "direction": direction}) - - def apply_blur_divide(self): - if self.img is None: - return - k = simpledialog.askinteger("Blur & Divide", "Gaussian kernel size (recommended 21–61, must be odd):", initialvalue=31, minvalue=3, maxvalue=201) - if k is None: - return - sigma = simpledialog.askfloat("Blur & Divide", "sigma (0 = auto):", initialvalue=0.0, minvalue=0.0, maxvalue=50.0) - if sigma is None: - return - - def op(img): - return blur_divide(img, ksize=int(k), sigma=float(sigma)) - self._apply_op(op, {"op": "blur_divide", "ksize": k, "sigma": sigma}) - - def apply_denoise(self): - if self.img is None: - return - k = simpledialog.askinteger("Denoise (Gaussian)", "Kernel size (odd, suggested 3/5/7):", initialvalue=5, minvalue=3, maxvalue=51) - if k is None: - return - sigma = simpledialog.askfloat("Denoise (Gaussian)", "sigma (suggested 0.8–2.5):", initialvalue=1.2, minvalue=0.0, maxvalue=10.0) - if sigma is None: - return - - def op(img): - return denoise_gaussian(img, ksize=int(k), sigma=float(sigma)) - self._apply_op(op, {"op": "denoise_gaussian", "ksize": k, "sigma": sigma}) - - def apply_pca(self): - paths = filedialog.askopenfilenames( - title="Select 3–16 grayscale images (sizes must match)", - filetypes=[("Image", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("All", "*.*")] - ) - if not paths: - return - if len(paths) < 3: - messagebox.showinfo("Info", "PCA: select at least 3 images") - return - if len(paths) > 16: - messagebox.showinfo("Info", "Maximum 16 images; only the first 16 will be used") - paths = paths[:16] - - def read_gray(p): - im = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if im is None: - raise ValueError(f"Cannot read: {p}") - if im.dtype == np.uint16: - im = normalize_0_255(im) - im = to_uint8(im) - if im.ndim == 3 and im.shape[2] == 4: - im = cv2.cvtColor(im, cv2.COLOR_BGRA2BGR) - return ensure_gray(im) - - try: - imgs = [read_gray(p) for p in paths] - roi = self.roi # full-res ROI (for PCA input files, assuming same size and coordinates) - self.set_status("Computing PCA… (may take a few seconds)") - self.update_idletasks() - - result = pca_multiband(imgs, roi=roi) - self._pc_cache = result - self._pc_index = 0 - pc1 = result["pcs"][0] - self.img = pc1 # Default to showing PC1 - self.draw_mask = None - self._rebuild_pyramid() - self._push_history({"op": "pca", "files": list(paths), "roi": roi}) - self.render() - - ratios = result.get("explained", []) - tip = "" - if ratios: - tip = " | ".join([f"PC{i+1}:{ratios[i]*100:.1f}%" for i in range(min(4, len(ratios)))]) - self.set_status(f"PCA done: showing PC1 (explained variance: {tip})") - - except Exception as e: - messagebox.showerror("Error", f"PCA failed: {e}") - self.set_status("PCA failed") - - def apply_pca_svd(self): - """PCA: SVD-variant implementation""" - paths = filedialog.askopenfilenames( - title="Select 3–16 grayscale images (sizes must match; for PCA-SVD)", - filetypes=[("Image", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff"), ("All", "*.*")] - ) - if not paths: - return - if len(paths) < 3: - messagebox.showinfo("Info", "PCA (SVD): select at least 3 images") - return - if len(paths) > 16: - messagebox.showinfo("Info", "Maximum 16 images; only the first 16 will be used") - paths = paths[:16] - - def read_gray(p): - im = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if im is None: - raise ValueError(f"Cannot read: {p}") - if im.dtype == np.uint16: - im = normalize_0_255(im) - im = to_uint8(im) - if im.ndim == 3 and im.shape[2] == 4: - im = cv2.cvtColor(im, cv2.COLOR_BGRA2BGR) - return ensure_gray(im) - - try: - imgs = [read_gray(p) for p in paths] - roi = self.roi - self.set_status("Computing PCA (SVD)… (may take a few seconds)") - self.update_idletasks() - - result = pca_multiband_svd_variant(imgs, roi=roi) - self._pc_cache = result - self._pc_index = 0 - pc1 = result["pcs"][0] - self.img = pc1 - self.draw_mask = None - self._rebuild_pyramid() - self._push_history({"op": "pca_svd", "files": list(paths), "roi": roi}) - self.render() - - ratios = result.get("explained", []) - tip = "" - if ratios: - tip = " | ".join([f"PC{i+1}:{ratios[i]*100:.1f}%" for i in range(min(4, len(ratios)))]) - self.set_status(f"PCA (SVD) done: showing PC1 (explained variance: {tip})") - - except Exception as e: - messagebox.showerror("Error", f"PCA (SVD) failed: {e}") - self.set_status("PCA (SVD) failed") - - def next_pc(self): - if not self._pc_cache or "pcs" not in self._pc_cache: - self.set_status("No PCA result found: run PCA first") - return - pcs = self._pc_cache["pcs"] - self._pc_index = (self._pc_index + 1) % len(pcs) - self.img = pcs[self._pc_index].copy() - self.draw_mask = None - self._rebuild_pyramid() - self._push_history({"op": "pca_switch", "pc_index": self._pc_index}) - self.render() - - ratios = self._pc_cache.get("explained", []) - r = ratios[self._pc_index] * 100.0 if self._pc_index < len(ratios) else None - if r is None: - self.set_status(f"Showing: PC{self._pc_index+1}") - else: - self.set_status(f"Showing: PC{self._pc_index+1} (explained variance {r:.1f}%)") - - -def main(): - try: - cv2.setNumThreads(0) - except Exception: - pass - - app = ProteusApp() - app.mainloop() - - -if __name__ == "__main__": - main() diff --git a/packaging/Proteus.icns b/packaging/Proteus.icns new file mode 100644 index 0000000..438cf77 Binary files /dev/null and b/packaging/Proteus.icns differ diff --git a/packaging/Proteus.ico b/packaging/Proteus.ico new file mode 100644 index 0000000..0e428e6 Binary files /dev/null and b/packaging/Proteus.ico differ diff --git a/packaging/Proteus.spec b/packaging/Proteus.spec new file mode 100644 index 0000000..32411b2 --- /dev/null +++ b/packaging/Proteus.spec @@ -0,0 +1,187 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for Proteus (PySide6 version). + +Usage: + pyinstaller --clean --noconfirm packaging/Proteus.spec + +Icons (place these files in packaging/ before building): + Windows : packaging/Proteus.ico (256x256 multi-size ICO) + macOS : packaging/Proteus.icns (converted from 1024x1024 PNG via iconutil) + Linux : no icon needed; the .png is embedded as app_datas +""" + +import os +import sys +from PyInstaller.utils.hooks import collect_data_files + +# ---- Project root (one level up from packaging/) ---- +PROJECT_ROOT = os.path.normpath(os.path.join(SPECPATH, '..')) + +# ---- Version (single source of truth: pyproject.toml) ---- +APP_VERSION = '2.0.0' +APP_NAME = 'Proteus' + +# ---- Configuration ---- +ONEFILE = False + +# ---- Platform-aware icon ---- +_ico = os.path.join(SPECPATH, 'Proteus.ico') +_icns = os.path.join(SPECPATH, 'Proteus.icns') + +if sys.platform == 'win32' and os.path.isfile(_ico): + ICON = _ico +elif sys.platform == 'darwin' and os.path.isfile(_icns): + ICON = _icns +else: + ICON = None # Linux: no icon needed in EXE + +# ---- PySide6 data files ---- +# Only collect plugins needed for basic widget rendering (platforms, imageformats, styles). +pyside6_datas = collect_data_files('PySide6', includes=[ + 'plugins/platforms/**', + 'plugins/imageformats/**', + 'plugins/styles/**', + 'plugins/xcbglintegrations/**', + 'plugins/platforminputcontexts/**', + 'plugins/egldeviceintegrations/**', +]) + +# ---- Hidden imports ---- +# Only the 3 PySide6 modules Proteus actually uses (not all 40+). +all_hiddenimports = [ + 'PySide6.QtCore', + 'PySide6.QtGui', + 'PySide6.QtWidgets', +] + +# ---- Application data files ---- +app_datas = [ + (os.path.join(PROJECT_ROOT, 'src', 'proteus', 'resources', 'Proteus.png'), + os.path.join('proteus', 'resources')), +] + +all_datas = app_datas + pyside6_datas + +# ---- Excludes ---- +# Unused Qt modules that PyInstaller may pull in transitively. +_unused_qt = [ + 'PySide6.Qt3DAnimation', 'PySide6.Qt3DCore', 'PySide6.Qt3DExtras', + 'PySide6.Qt3DInput', 'PySide6.Qt3DLogic', 'PySide6.Qt3DRender', + 'PySide6.QtBluetooth', 'PySide6.QtCharts', 'PySide6.QtConcurrent', + 'PySide6.QtDataVisualization', 'PySide6.QtDBus', 'PySide6.QtDesigner', + 'PySide6.QtHelp', 'PySide6.QtHttpServer', + 'PySide6.QtLocation', 'PySide6.QtMultimedia', 'PySide6.QtMultimediaWidgets', + 'PySide6.QtNetworkAuth', 'PySide6.QtNfc', + 'PySide6.QtOpenGL', 'PySide6.QtOpenGLWidgets', + 'PySide6.QtPdf', 'PySide6.QtPdfWidgets', 'PySide6.QtPositioning', + 'PySide6.QtQml', 'PySide6.QtQuick', 'PySide6.QtQuick3D', + 'PySide6.QtQuickControls2', 'PySide6.QtQuickWidgets', + 'PySide6.QtRemoteObjects', 'PySide6.QtScxml', 'PySide6.QtSensors', + 'PySide6.QtSerialBus', 'PySide6.QtSerialPort', + 'PySide6.QtSpatialAudio', 'PySide6.QtSql', 'PySide6.QtStateMachine', + 'PySide6.QtSvg', 'PySide6.QtSvgWidgets', 'PySide6.QtTest', + 'PySide6.QtTextToSpeech', 'PySide6.QtUiTools', + 'PySide6.QtWebChannel', 'PySide6.QtWebEngineCore', + 'PySide6.QtWebEngineQuick', 'PySide6.QtWebEngineWidgets', + 'PySide6.QtWebSockets', 'PySide6.QtXml', +] + +excludes = [ + # Unused Python packages + 'matplotlib', 'scipy', 'pandas', 'pytest', 'IPython', + 'notebook', 'sphinx', 'tkinter', 'customtkinter', + 'PIL', 'Pillow', 'PyQt5', 'PyQt6', 'wx', +] + _unused_qt + +# ---- Analysis ---- +a = Analysis( + [os.path.join(PROJECT_ROOT, 'src', 'proteus', 'app.py')], + pathex=[os.path.join(PROJECT_ROOT, 'src')], + binaries=[], + datas=all_datas, + hiddenimports=all_hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +# ---- Windows version metadata ---- +_version_file = os.path.join(SPECPATH, 'version_info.txt') +_win_version = _version_file if sys.platform == 'win32' and os.path.isfile(_version_file) else None + +if ONEFILE: + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=ICON, + version=_win_version, + ) +else: + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=ICON, + version=_win_version, + ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=APP_NAME, + ) + +# ---- macOS .app bundle ---- +if sys.platform == 'darwin': + app = BUNDLE( + coll if not ONEFILE else exe, + name=f'{APP_NAME}.app', + icon=ICON, + bundle_identifier='com.proteus.image', + info_plist={ + 'CFBundleName': APP_NAME, + 'CFBundleDisplayName': APP_NAME, + 'CFBundleVersion': APP_VERSION, + 'CFBundleShortVersionString': APP_VERSION, + 'NSHighResolutionCapable': True, + 'LSMinimumSystemVersion': '11.0', + 'NSHumanReadableCopyright': f'Copyright 2024 Proteus. All rights reserved.', + }, + ) diff --git a/packaging/build.bat b/packaging/build.bat new file mode 100644 index 0000000..427915b --- /dev/null +++ b/packaging/build.bat @@ -0,0 +1,85 @@ +@echo off +setlocal EnableDelayedExpansion +:: +:: Proteus Windows build script +:: +:: Usage: +:: packaging\build.bat -- full build (venv + tests + PyInstaller) +:: packaging\build.bat --skip-tests -- skip pytest step +:: + +set "PROJECT_DIR=%~dp0.." +cd /d "%PROJECT_DIR%" + +echo === Proteus Build Script (Windows) === +echo. + +:: ---- Parse args ---- +set "SKIP_TESTS=0" +for %%A in (%*) do ( + if /I "%%A"=="--skip-tests" set "SKIP_TESTS=1" +) + +:: ---- Check Python ---- +where python >nul 2>&1 +if errorlevel 1 ( + echo ERROR: python not found. Install Python 3.10+ and add it to PATH. + exit /b 1 +) +for /f "tokens=*" %%V in ('python --version 2^>^&1') do echo Using %%V + +:: ---- Create venv if needed ---- +if not exist ".venv\" ( + echo. + echo Creating virtual environment... + python -m venv .venv + if errorlevel 1 ( echo ERROR: Failed to create venv. & exit /b 1 ) +) +set "PYTHON=.venv\Scripts\python.exe" +set "PIP=.venv\Scripts\pip.exe" + +:: ---- Install dependencies ---- +echo. +echo Installing dependencies... +"%PYTHON%" -m pip install --upgrade pip --quiet +"%PYTHON%" -m pip install -e ".[dev]" --quiet +if errorlevel 1 ( echo ERROR: pip install failed. & exit /b 1 ) + +:: ---- Run tests ---- +if "%SKIP_TESTS%"=="0" ( + echo. + echo Running tests... + "%PYTHON%" -m pytest tests/ -v + if errorlevel 1 ( echo ERROR: Tests failed. Aborting build. & exit /b 1 ) +) else ( + echo. + echo Skipping tests. +) + +:: ---- Run PyInstaller ---- +echo. +echo Building Proteus... +"%PYTHON%" -m PyInstaller --clean --noconfirm packaging\Proteus.spec +if errorlevel 1 ( echo ERROR: PyInstaller build failed. & exit /b 1 ) + +:: ---- Archive ---- +echo. +echo Archiving... +set "ARCHIVE=Proteus-windows.zip" +if exist "%ARCHIVE%" del "%ARCHIVE%" +powershell -NoProfile -Command ^ + "Compress-Archive -Path 'dist\Proteus' -DestinationPath '%ARCHIVE%'" +if errorlevel 1 ( + echo WARNING: Archive step failed. The dist\Proteus\ folder is still usable. +) else ( + echo Archive: %ARCHIVE% +) + +:: ---- Report ---- +echo. +echo === Build complete === +if exist "dist\Proteus\Proteus.exe" ( + echo Output: dist\Proteus\Proteus.exe +) else ( + echo Output directory: dist\ +) diff --git a/packaging/build.sh b/packaging/build.sh new file mode 100755 index 0000000..b878360 --- /dev/null +++ b/packaging/build.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Proteus Linux / macOS build script +# +# Usage: +# bash packaging/build.sh -- full build (venv + tests + PyInstaller) +# bash packaging/build.sh --skip-tests -- skip pytest step +# +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +echo "=== Proteus Build Script ===" +echo "" + +# ---- Parse args ---- +SKIP_TESTS=0 +for arg in "$@"; do + [[ "$arg" == "--skip-tests" ]] && SKIP_TESTS=1 +done + +# ---- Check Python ---- +PYTHON="${PYTHON:-python3}" +if ! command -v "$PYTHON" &>/dev/null; then + echo "ERROR: $PYTHON not found. Install Python 3.10+ and try again." + exit 1 +fi +echo "Using Python: $($PYTHON --version)" + +# ---- Create/activate venv if needed ---- +if [ ! -d ".venv" ]; then + echo "Creating virtual environment..." + "$PYTHON" -m venv .venv +fi +PYTHON=".venv/bin/python" + +# ---- Install dependencies ---- +echo "" +echo "Installing dependencies..." +"$PYTHON" -m pip install --upgrade pip --quiet +"$PYTHON" -m pip install -e ".[dev]" --quiet + +# ---- Run tests ---- +if [ "$SKIP_TESTS" -eq 0 ]; then + echo "" + echo "Running tests..." + "$PYTHON" -m pytest tests/ -v +else + echo "" + echo "Skipping tests." +fi + +# ---- Run PyInstaller ---- +echo "" +echo "Building Proteus..." +"$PYTHON" -m PyInstaller --clean --noconfirm packaging/Proteus.spec + +# ---- Archive ---- +echo "" +echo "Archiving..." +if [[ "$(uname)" == "Darwin" ]]; then + ARCHIVE="Proteus-macos.tar.gz" + # macOS: bundle the .app if it exists, else the folder + if [ -d "dist/Proteus.app" ]; then + tar -czf "$ARCHIVE" -C dist Proteus.app + else + tar -czf "$ARCHIVE" -C dist Proteus + fi +else + ARCHIVE="Proteus-linux.tar.gz" + tar -czf "$ARCHIVE" -C dist Proteus +fi +echo "Archive: $ARCHIVE ($(du -sh "$ARCHIVE" | cut -f1))" + +# ---- Report ---- +echo "" +echo "=== Build complete ===" +if [ -f "dist/Proteus/Proteus" ]; then + echo "Output (onedir): dist/Proteus/" +elif [ -d "dist/Proteus.app" ]; then + echo "Output (macOS app): dist/Proteus.app" +else + echo "Output directory: dist/" +fi diff --git a/packaging/version_info.txt b/packaging/version_info.txt new file mode 100644 index 0000000..ad3ee68 --- /dev/null +++ b/packaging/version_info.txt @@ -0,0 +1,36 @@ +# UTF-8 +# +# Windows EXE version information for Proteus. +# Used by PyInstaller when building on Windows. +# Update the version tuples whenever APP_VERSION changes in Proteus.spec. +# +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(2, 0, 0, 0), + prodvers=(2, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable( + u'040904B0', + [ + StringStruct(u'CompanyName', u'Proteus'), + StringStruct(u'FileDescription', u'Proteus Scientific Image Processing'), + StringStruct(u'FileVersion', u'2.0.0.0'), + StringStruct(u'InternalName', u'Proteus'), + StringStruct(u'LegalCopyright', u'Copyright 2024 Proteus. All rights reserved.'), + StringStruct(u'OriginalFilename', u'Proteus.exe'), + StringStruct(u'ProductName', u'Proteus'), + StringStruct(u'ProductVersion', u'2.0.0.0'), + ] + ) + ]), + VarFileInfo([VarStruct(u'Translation', [0x0409, 1200])]) + ] +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0b793d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "proteus-image" +version = "2.0.0" +description = "Proteus - Scientific Image Processing Desktop Application" +requires-python = ">=3.10" +dependencies = [ + "PySide6>=6.5.0", + "opencv-python>=4.8.0", + "numpy>=1.24.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pyinstaller>=6.0", +] + +[project.scripts] +proteus = "proteus.app:main" + +[project.gui-scripts] +proteus-gui = "proteus.app:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/proteus/__init__.py b/src/proteus/__init__.py new file mode 100644 index 0000000..533dbfb --- /dev/null +++ b/src/proteus/__init__.py @@ -0,0 +1,3 @@ +"""Proteus - Scientific Image Processing Desktop Application.""" + +__version__ = "2.0.0" diff --git a/src/proteus/__main__.py b/src/proteus/__main__.py new file mode 100644 index 0000000..db90c65 --- /dev/null +++ b/src/proteus/__main__.py @@ -0,0 +1,4 @@ +"""Entry point for `python -m proteus`.""" +from proteus.app import main + +main() diff --git a/src/proteus/app.py b/src/proteus/app.py new file mode 100644 index 0000000..0eb8a18 --- /dev/null +++ b/src/proteus/app.py @@ -0,0 +1,30 @@ +"""Application entry point.""" + +import sys +import cv2 +from PySide6.QtWidgets import QApplication +from proteus.ui.main_window import ProteusMainWindow +from proteus.ui.theme import apply_theme + + +def main(): + try: + cv2.setNumThreads(0) + except Exception: + pass + + app = QApplication(sys.argv) + app.setApplicationName("Proteus") + app.setOrganizationName("Proteus") + + # Apply default light theme; main window will load saved preference + apply_theme(app, "light") + + window = ProteusMainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/proteus/commands/__init__.py b/src/proteus/commands/__init__.py new file mode 100644 index 0000000..5c3520a --- /dev/null +++ b/src/proteus/commands/__init__.py @@ -0,0 +1 @@ +"""Undo/redo command classes.""" diff --git a/src/proteus/commands/undo_commands.py b/src/proteus/commands/undo_commands.py new file mode 100644 index 0000000..a203c47 --- /dev/null +++ b/src/proteus/commands/undo_commands.py @@ -0,0 +1,61 @@ +"""QUndoCommand subclasses for all undoable operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PySide6.QtGui import QUndoCommand + +from proteus.core.state import ImageState + +if TYPE_CHECKING: + from proteus.ui.main_window import ProteusMainWindow + + +class ImageOperationCommand(QUndoCommand): + """Generic undo command that captures before/after ImageState snapshots.""" + + def __init__(self, window: ProteusMainWindow, before: ImageState, after: ImageState, description: str): + super().__init__(description) + self._window = window + self._before = before + self._after = after + + def undo(self) -> None: + self._window.restore_state(self._before) + + def redo(self) -> None: + self._window.restore_state(self._after) + + +class DrawStrokeCommand(QUndoCommand): + """Captures a complete brush stroke as one undoable action.""" + + def __init__(self, window: ProteusMainWindow, mask_before: np.ndarray | None, mask_after: np.ndarray): + super().__init__("Brush Stroke") + self._window = window + self._mask_before = mask_before.copy() if mask_before is not None else None + self._mask_after = mask_after.copy() + + def undo(self) -> None: + self._window.set_draw_mask(self._mask_before) + + def redo(self) -> None: + self._window.set_draw_mask(self._mask_after) + + +class RoiChangeCommand(QUndoCommand): + """Captures an ROI selection as one undoable action.""" + + def __init__(self, window: ProteusMainWindow, roi_before: tuple | None, roi_after: tuple | None): + super().__init__("ROI Selection") + self._window = window + self._roi_before = roi_before + self._roi_after = roi_after + + def undo(self) -> None: + self._window.set_roi(self._roi_before) + + def redo(self) -> None: + self._window.set_roi(self._roi_after) diff --git a/src/proteus/core/__init__.py b/src/proteus/core/__init__.py new file mode 100644 index 0000000..6f5a460 --- /dev/null +++ b/src/proteus/core/__init__.py @@ -0,0 +1,11 @@ +"""Core processing module - no UI dependencies.""" + +from proteus.core.utils import resource_path, clamp +from proteus.core.processing import ( + to_uint8, ensure_gray, ensure_color, normalize_0_255, + hist_equalize, pseudocolor_jet, otsu_binarize, fixed_binarize, + power_transform, blur_divide, denoise_gaussian, rotate_90, +) +from proteus.core.pca import pca_multiband, pca_multiband_svd_variant +from proteus.core.image_io import load_image, load_as_gray, save_image +from proteus.core.state import ImageState, OperationLog diff --git a/src/proteus/core/image_io.py b/src/proteus/core/image_io.py new file mode 100644 index 0000000..8dcea1a --- /dev/null +++ b/src/proteus/core/image_io.py @@ -0,0 +1,55 @@ +"""Image loading and saving functions. No UI dependencies.""" + +import os +import numpy as np +import cv2 +from typing import Optional + +from proteus.core.processing import normalize_0_255, to_uint8, ensure_gray, ensure_color + + +def load_image(path: str) -> np.ndarray: + """Load an image from disk. Handles 16-bit, alpha channel. + Returns BGR or grayscale uint8 ndarray. + Raises ValueError if the file cannot be read.""" + img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_UNCHANGED) + if img is None: + raise ValueError(f"Unable to read image: {path}") + if img.dtype == np.uint16: + img = normalize_0_255(img) + elif img.dtype != np.uint8: + img = to_uint8(img) + if img.ndim == 3 and img.shape[2] == 4: + img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + return img + + +def load_as_gray(path: str) -> np.ndarray: + """Load an image and ensure it is grayscale uint8.""" + img = load_image(path) + return ensure_gray(img) + + +def save_image(path: str, img: np.ndarray, draw_mask: Optional[np.ndarray] = None) -> None: + """Save image to disk. Optionally composites the draw mask overlay. + Raises ValueError on failure.""" + out = img.copy() + if draw_mask is not None: + out2 = ensure_color(out) + overlay = out2.copy() + overlay[draw_mask > 0] = (0, 255, 255) + out = cv2.addWeighted(out2, 0.78, overlay, 0.22, 0) + + ext = os.path.splitext(path)[1].lower() + if ext in (".jpg", ".jpeg"): + ok, buf = cv2.imencode(".jpg", out, [int(cv2.IMWRITE_JPEG_QUALITY), 95]) + elif ext == ".bmp": + ok, buf = cv2.imencode(".bmp", out) + elif ext in (".tif", ".tiff"): + ok, buf = cv2.imencode(".tif", out) + else: + ok, buf = cv2.imencode(".png", out) + + if not ok: + raise ValueError("Image encoding failed") + buf.tofile(path) diff --git a/src/proteus/core/pca.py b/src/proteus/core/pca.py new file mode 100644 index 0000000..e45a4c8 --- /dev/null +++ b/src/proteus/core/pca.py @@ -0,0 +1,174 @@ +"""PCA multiband analysis functions. No UI dependencies. + +Copied verbatim from Refactor/main.py (lines 246-407). +""" + +from typing import Optional, List, Tuple, Dict, Any + +import numpy as np + +from proteus.core.utils import clamp +from proteus.core.processing import normalize_0_255 + + +def pca_multiband(images_gray_u8: List[np.ndarray], roi: Optional[Tuple[int, int, int, int]] = None) -> Dict[str, Any]: + """ + images_gray_u8: list of HxW uint8 gray images, length = N (3..16) + roi: (x0,y0,x1,y1) in image coords, used to fit PCA; then applied to full image. + return dict: + { + 'pcs': [pc1_u8, pc2_u8, ...], # each HxW uint8 + 'explained': [ratio1, ratio2, ...], + 'mean': mean_vec, + 'components': comps, + } + """ + if not images_gray_u8 or len(images_gray_u8) < 3: + raise ValueError("PCA requires at least 3 grayscale images") + if len(images_gray_u8) > 16: + images_gray_u8 = images_gray_u8[:16] + + # Same size + H, W = images_gray_u8[0].shape[:2] + imgs = [] + for im in images_gray_u8: + if im.shape[:2] != (H, W): + raise ValueError("PCA input images must have the same size (selected files differ in dimensions)") + imgs.append(im.astype(np.float32)) + + # stack -> (H*W, N) + X = np.stack(imgs, axis=-1) # (H,W,N) + + if roi is not None: + x0, y0, x1, y1 = roi + x0, x1 = sorted([int(x0), int(x1)]) + y0, y1 = sorted([int(y0), int(y1)]) + x0 = clamp(x0, 0, W - 1) + x1 = clamp(x1, 1, W) + y0 = clamp(y0, 0, H - 1) + y1 = clamp(y1, 1, H) + X_fit = X[y0:y1, x0:x1, :].reshape(-1, X.shape[-1]) + else: + X_fit = X.reshape(-1, X.shape[-1]) + + # Mean-centering + mean = np.mean(X_fit, axis=0, keepdims=True) + Xc = X_fit - mean + + # Covariance and eigen-decomposition (N<=16 is small) + cov = (Xc.T @ Xc) / max(1, (Xc.shape[0] - 1)) + eigvals, eigvecs = np.linalg.eigh(cov) # ascending + idx = np.argsort(eigvals)[::-1] + eigvals = eigvals[idx] + eigvecs = eigvecs[:, idx] + + total = float(np.sum(eigvals)) if float(np.sum(eigvals)) > 1e-12 else 1.0 + explained = (eigvals / total).tolist() + + # Project full image + X_all = X.reshape(-1, X.shape[-1]).astype(np.float32) + X_all_c = X_all - mean + scores = X_all_c @ eigvecs # (H*W, N) + + pcs = [] + for k in range(min(len(images_gray_u8), 8)): # take the first 8 components for display + pc = scores[:, k].reshape(H, W) + pcs.append(normalize_0_255(pc)) + + return { + "pcs": pcs, + "explained": explained[:len(pcs)], + "mean": mean.flatten(), + "components": eigvecs + } + + +def pca_multiband_svd_variant( + images_gray_u8: List[np.ndarray], + roi: Optional[Tuple[int, int, int, int]] = None, + max_components: int = 8 +) -> Dict[str, Any]: + """ + SVD-variant implementation of multiband PCA (based on PCA.m) + Perform PCA across bands using an SVD-based approach: + - Z is d x n, where d is the number of bands and n is the number of samples (pixels) + - First center each row (band), then compute the SVD + The return structure is similar to pca_multiband: + { + 'pcs': [pc1_u8, pc2_u8, ...], + 'explained': [ratio1, ratio2, ...], + 'mean': mean_vec, # d dims + 'U': U, # d x r + 'S': S, # r + } + """ + if not images_gray_u8 or len(images_gray_u8) < 3: + raise ValueError("PCA (SVD) requires at least 3 grayscale images") + + # Limit to at most 16 bands + if len(images_gray_u8) > 16: + images_gray_u8 = images_gray_u8[:16] + + # Check sizes & convert to float32 + H, W = images_gray_u8[0].shape[:2] + imgs = [] + for im in images_gray_u8: + if im.shape[:2] != (H, W): + raise ValueError("PCA (SVD) input images must have the same size (selected files differ in dimensions)") + imgs.append(im.astype(np.float32)) + + # Choose the region used to fit PCA: ROI or full image + stack = np.stack(imgs, axis=0) # (N_bands, H, W) + if roi is not None: + x0, y0, x1, y1 = roi + x0, x1 = sorted([int(x0), int(x1)]) + y0, y1 = sorted([int(y0), int(y1)]) + x0 = clamp(x0, 0, W - 1) + x1 = clamp(x1, 1, W) + y0 = clamp(y0, 0, H - 1) + y1 = clamp(y1, 1, H) + sub = stack[:, y0:y1, x0:x1] + else: + sub = stack + + # Z: d x n (d=bands, n=pixels) + d = sub.shape[0] + Z = sub.reshape(d, -1) # (d, n) + + # Center each row (non-standard centering, matches PCA.m centerRows) + mu = np.mean(Z, axis=1, keepdims=True) # (d,1) + Zc = Z - mu + + # SVD decomposition (econ) + # Zc = U @ S_diag @ Vt + U, S, Vt = np.linalg.svd(Zc, full_matrices=False) + + # Truncate principal components + r = int(max_components) + r = max(1, min(r, d, U.shape[1])) + U_r = U[:, :r] # (d, r) + S_r = S[:r] # (r,) + + # Explained variance (proportional to eigenvalues; here eigenvalues ~ S^2) + eigvals = (S_r ** 2) + total = float(np.sum(eigvals)) if float(np.sum(eigvals)) > 1e-12 else 1.0 + explained = (eigvals / total).tolist() + + # Project onto the full image to obtain component images + Z_all = stack.reshape(d, -1) # (d, H*W) + Z_all_c = Z_all - mu # Use the same mean + # scores_all: r x (H*W) + scores_all = U_r.T @ Z_all_c + + pcs = [] + for k in range(r): + pc = scores_all[k, :].reshape(H, W) + pcs.append(normalize_0_255(pc)) + + return { + "pcs": pcs, + "explained": explained[:len(pcs)], + "mean": mu.flatten(), + "U": U_r, + "S": S_r, + } diff --git a/src/proteus/core/processing.py b/src/proteus/core/processing.py new file mode 100644 index 0000000..72ca409 --- /dev/null +++ b/src/proteus/core/processing.py @@ -0,0 +1,158 @@ +"""Pure image processing functions. No UI dependencies. + +All functions operate on numpy arrays with OpenCV. They are copied +verbatim from the original Refactor/main.py (lines 103-241). +""" + +import numpy as np +import cv2 + +from proteus.core.utils import clamp + + +def to_uint8(img: np.ndarray) -> np.ndarray: + if img is None: + return img + if img.dtype == np.uint8: + return img + img2 = np.clip(img, 0, 255).astype(np.uint8) + return img2 + + +def ensure_gray(img: np.ndarray) -> np.ndarray: + if img is None: + return img + if img.ndim == 2: + return img + # assume BGR + return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + +def ensure_color(img: np.ndarray) -> np.ndarray: + if img is None: + return img + if img.ndim == 3: + return img + return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + + +def normalize_0_255(img: np.ndarray) -> np.ndarray: + if img is None: + return img + x = img.astype(np.float32) + mn = float(np.min(x)) + mx = float(np.max(x)) + if mx - mn < 1e-6: + return np.zeros_like(img, dtype=np.uint8) + y = (x - mn) / (mx - mn) * 255.0 + return y.astype(np.uint8) + + +def hist_equalize(img: np.ndarray) -> np.ndarray: + if img is None: + return img + if img.ndim == 2: + return cv2.equalizeHist(img) + # color: equalize Y channel + ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) + ycrcb[:, :, 0] = cv2.equalizeHist(ycrcb[:, :, 0]) + return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR) + + +def pseudocolor_jet(gray_u8: np.ndarray) -> np.ndarray: + gray_u8 = ensure_gray(gray_u8) + gray_u8 = to_uint8(gray_u8) + colored = cv2.applyColorMap(gray_u8, cv2.COLORMAP_JET) + return colored + + +def otsu_binarize(img: np.ndarray) -> np.ndarray: + g = ensure_gray(img) + g = to_uint8(g) + _, th = cv2.threshold(g, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + return th + + +def fixed_binarize(img: np.ndarray, thresh: int = 128) -> np.ndarray: + g = ensure_gray(img) + g = to_uint8(g) + t = int(clamp(thresh, 0, 255)) + _, th = cv2.threshold(g, t, 255, cv2.THRESH_BINARY) + return th + + +def power_transform(img: np.ndarray, gamma: float, partial_invert: bool = False, pivot: int = 128) -> np.ndarray: + """Apply a power/gamma transform; optional partial inversion (invert pixels > pivot).""" + if img is None: + return img + x = img.astype(np.float32) / 255.0 + x = np.power(np.clip(x, 0, 1), gamma) + out = (x * 255.0).astype(np.uint8) + + if partial_invert: + if out.ndim == 2: + mask = out > pivot + out2 = out.copy() + out2[mask] = 255 - out2[mask] + return out2 + else: + g = ensure_gray(out) + mask = g > pivot + out2 = out.copy() + out2[mask] = 255 - out2[mask] + return out2 + return out + + +def blur_divide(img: np.ndarray, ksize: int = 31, sigma: float = 0) -> np.ndarray: + """Divide the image by its Gaussian-blurred version, then normalize and equalize.""" + if img is None: + return img + + if ksize % 2 == 0: + ksize += 1 + + if img.ndim == 2: + g = img.astype(np.float32) + blur = cv2.GaussianBlur(g, (ksize, ksize), sigmaX=sigma) + eps = 1e-6 + div = g / (blur + eps) + out = normalize_0_255(div) + out = hist_equalize(out) + return out + else: + # Processing the luminance channel is more stable + ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb).astype(np.float32) + y = ycrcb[:, :, 0] + blur = cv2.GaussianBlur(y, (ksize, ksize), sigmaX=sigma) + div = y / (blur + 1e-6) + y2 = normalize_0_255(div) + y2 = cv2.equalizeHist(y2) + ycrcb2 = ycrcb.copy() + ycrcb2[:, :, 0] = y2 + out = cv2.cvtColor(ycrcb2.astype(np.uint8), cv2.COLOR_YCrCb2BGR) + return out + + +def denoise_gaussian(img: np.ndarray, ksize: int = 5, sigma: float = 1.0) -> np.ndarray: + if img is None: + return img + if ksize % 2 == 0: + ksize += 1 + return cv2.GaussianBlur(img, (ksize, ksize), sigmaX=sigma) + + +def unsharp_mask(img: np.ndarray, sigma: float = 1.0, strength: float = 1.5) -> np.ndarray: + """Sharpen by subtracting a blurred version (unsharp masking). Works on grayscale or color.""" + if img is None: + return img + blurred = cv2.GaussianBlur(img, (0, 0), sigmaX=sigma) + return cv2.addWeighted(img, 1.0 + strength, blurred, -strength, 0) + + +def rotate_90(img: np.ndarray, direction: str) -> np.ndarray: + if img is None: + return img + if direction == "left": + return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) + return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) diff --git a/src/proteus/core/state.py b/src/proteus/core/state.py new file mode 100644 index 0000000..9e6b8bb --- /dev/null +++ b/src/proteus/core/state.py @@ -0,0 +1,120 @@ +"""Application state and operation logging. No UI dependencies.""" + +import json +from collections import Counter +from dataclasses import dataclass, field +from typing import Optional, List, Tuple, Dict, Any + +import numpy as np + + +@dataclass +class ImageState: + """Snapshot of the application state for undo/redo.""" + img: Optional[np.ndarray] + draw_mask: Optional[np.ndarray] + roi: Optional[Tuple[int, int, int, int]] + zoom: float + pan_x: float + pan_y: float + meta: Dict[str, Any] = field(default_factory=dict) + + def copy(self) -> "ImageState": + return ImageState( + img=None if self.img is None else self.img.copy(), + draw_mask=None if self.draw_mask is None else self.draw_mask.copy(), + roi=None if self.roi is None else tuple(self.roi), + zoom=self.zoom, + pan_x=self.pan_x, + pan_y=self.pan_y, + meta=dict(self.meta), + ) + + +class OperationLog: + """Records processing operations and exports metadata files.""" + + # Operations that should not be recorded in the log + _SKIP_OPS = {"open", "draw", "roi", "clear_draw", "clear_roi"} + + def __init__(self): + self.entries: List[Dict[str, Any]] = [] + + def record(self, meta: Dict[str, Any]) -> None: + op = meta.get("op") + if op not in self._SKIP_OPS: + self.entries.append(dict(meta)) + + def clear(self) -> None: + self.entries.clear() + + @staticmethod + def friendly_name(meta: Dict[str, Any]) -> str: + """Convert an operation meta dict into a human-readable name.""" + op = meta.get("op", "") + if op in ("pseudocolor_current", "pseudocolor_two", "pseudocolor_channel"): + ch = meta.get("channel") + if ch in ("r", "g", "b", "all"): + return f"Pseudocolor-{ch.upper()}" + return "Pseudocolor" + if op in ("sharpen_otsu", "sharpie_binarize"): + return "Sharpen(Otsu)" + if op == "sharpen_fixed": + return "Sharpen(128)" + if op == "power": + return "Power" + if op == "invert": + return "Invert" + if op == "blur_divide": + return "Blur&Divide" + if op == "denoise_gaussian": + return "Denoise(Gaussian)" + if op == "pca": + return "PCA" + if op == "pca_svd": + return "PCA-SVD" + if op == "rotate_90": + direction = meta.get("direction", "") + return f"Rotate90({direction})" + return op or "Unknown" + + def export_txt(self, path: str) -> None: + """Write the operation log as a text file alongside the saved image.""" + lines: List[str] = [] + # Header + lines.append("Technical") + lines.append("System Manufacturer: ") + lines.append("Lights used: ") + lines.append("Name of photographer: ") + lines.append("Name of processor: ") + lines.append("") + lines.append("Object") + lines.append("Name (if any): ") + lines.append("Shelfmark: ") + lines.append("Material: ") + lines.append("Institution/owner: ") + lines.append("") + + # Summary + friendly_ops = [self.friendly_name(m) for m in self.entries] + if friendly_ops: + cnt = Counter(friendly_ops) + summary_parts = [f"{name} x{c}" for name, c in cnt.items()] + summary = ", ".join(summary_parts) + else: + summary = "None" + + lines.append(f"Processes used: {summary}") + lines.append("") + lines.append("Process details:") + + for i, meta in enumerate(self.entries, start=1): + name = self.friendly_name(meta) + params = {k: v for k, v in meta.items() if k != "op"} + if params: + lines.append(f"{i}. {name} | params: {json.dumps(params, ensure_ascii=False)}") + else: + lines.append(f"{i}. {name}") + + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) diff --git a/src/proteus/core/utils.py b/src/proteus/core/utils.py new file mode 100644 index 0000000..687ab7c --- /dev/null +++ b/src/proteus/core/utils.py @@ -0,0 +1,15 @@ +"""Utility functions - no UI or heavy library dependencies.""" + +import os +import sys + + +def resource_path(relative_path: str) -> str: + """Return absolute path to a resource, works in dev and PyInstaller bundle.""" + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'resources', relative_path) + + +def clamp(v, lo, hi): + return lo if v < lo else hi if v > hi else v diff --git a/Refactor/Proteus.png b/src/proteus/resources/Proteus.png similarity index 100% rename from Refactor/Proteus.png rename to src/proteus/resources/Proteus.png diff --git a/src/proteus/resources/__init__.py b/src/proteus/resources/__init__.py new file mode 100644 index 0000000..dc6d5b0 --- /dev/null +++ b/src/proteus/resources/__init__.py @@ -0,0 +1 @@ +"""Application resources (images, icons).""" diff --git a/src/proteus/ui/__init__.py b/src/proteus/ui/__init__.py new file mode 100644 index 0000000..fa42b32 --- /dev/null +++ b/src/proteus/ui/__init__.py @@ -0,0 +1 @@ +"""PySide6 UI components.""" diff --git a/src/proteus/ui/canvas.py b/src/proteus/ui/canvas.py new file mode 100644 index 0000000..1a8da78 --- /dev/null +++ b/src/proteus/ui/canvas.py @@ -0,0 +1,225 @@ +"""Image canvas widget using QGraphicsView. + +Replaces the tkinter Canvas + manual image pyramid system. +QGraphicsView handles zoom/pan/coordinate mapping natively. +""" + +from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsRectItem +from PySide6.QtCore import Qt, Signal, QPointF, QRectF +from PySide6.QtGui import QPixmap, QImage, QPen, QColor, QPainter, QBrush, QWheelEvent, QMouseEvent + +import numpy as np +import cv2 + + +class DrawOverlayItem(QGraphicsPixmapItem): + """Transparent overlay that renders the brush mask as yellow highlights.""" + pass + + +class RoiRectItem(QGraphicsRectItem): + """Dashed cyan rectangle for ROI selection.""" + + def __init__(self): + super().__init__() + pen = QPen(QColor("#00ff99"), 2, Qt.DashLine) + self.setPen(pen) + self.setBrush(QBrush(Qt.NoBrush)) + + +class ImageCanvas(QGraphicsView): + """Central image display widget. + + Handles zoom (scroll wheel), pan (drag in pan mode), + brush drawing, and ROI selection via mouse events. + Emits signals for the main window to handle state changes. + """ + + # Signals + status_message = Signal(str) + zoom_changed = Signal(float) + brush_stroke = Signal(int, int) + roi_changed = Signal(int, int, int, int) + roi_finished = Signal() + draw_finished = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + + # Rendering + self.setRenderHint(QPainter.SmoothPixmapTransform, True) + self.setRenderHint(QPainter.Antialiasing, True) + self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setBackgroundBrush(QBrush(QColor("#e5e7eb"))) + + # Scene items (layered) + self._image_item = QGraphicsPixmapItem() + self._overlay_item = DrawOverlayItem() + self._roi_item = RoiRectItem() + self._scene.addItem(self._image_item) + self._scene.addItem(self._overlay_item) + self._scene.addItem(self._roi_item) + self._overlay_item.setZValue(1) + self._roi_item.setZValue(2) + self._roi_item.setVisible(False) + + # Interaction state + self._mode: str = "pan" + self._zoom_factor: float = 1.0 + self._drawing: bool = False + self._roi_origin: QPointF = QPointF() + self._image_size: tuple = (0, 0) + + # Start in pan mode + self.setDragMode(QGraphicsView.ScrollHandDrag) + + # Placeholder text + self._placeholder = self._scene.addText( + "No image loaded: click [Open Image] on the left" + ) + self._placeholder.setDefaultTextColor(QColor("#888888")) + font = self._placeholder.font() + font.setPointSize(14) + self._placeholder.setFont(font) + + def set_image(self, img: np.ndarray) -> None: + """Display an OpenCV image (BGR or grayscale uint8).""" + self._placeholder.setVisible(False) + if img.ndim == 2: + h, w = img.shape + rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) + else: + h, w = img.shape[:2] + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888) + pixmap = QPixmap.fromImage(qimg.copy()) + self._image_item.setPixmap(pixmap) + self._image_size = (w, h) + self._scene.setSceneRect(0, 0, w, h) + + def set_draw_mask(self, mask: np.ndarray | None) -> None: + """Update the draw overlay from a full-resolution mask.""" + if mask is None: + self._overlay_item.setPixmap(QPixmap()) + return + h, w = mask.shape + # ARGB32: yellow at 22% opacity where mask > 0 + overlay = np.zeros((h, w, 4), dtype=np.uint8) + overlay[mask > 0] = [255, 255, 0, 56] # R, G, B, A for RGBA + qimg = QImage(overlay.data, w, h, 4 * w, QImage.Format_RGBA8888) + self._overlay_item.setPixmap(QPixmap.fromImage(qimg.copy())) + + def set_roi(self, roi: tuple | None) -> None: + """Display or hide the ROI rectangle.""" + if roi is None: + self._roi_item.setVisible(False) + return + x0, y0, x1, y1 = roi + x0, x1 = sorted([x0, x1]) + y0, y1 = sorted([y0, y1]) + self._roi_item.setRect(QRectF(x0, y0, x1 - x0, y1 - y0)) + self._roi_item.setVisible(True) + + def set_mode(self, mode: str) -> None: + """Switch interaction mode: 'pan', 'draw', or 'roi'.""" + self._mode = mode + if mode == "pan": + self.setDragMode(QGraphicsView.ScrollHandDrag) + self.setCursor(Qt.OpenHandCursor) + else: + self.setDragMode(QGraphicsView.NoDrag) + self.setCursor(Qt.CrossCursor) + + def zoom_in(self) -> None: + new_zoom = min(self._zoom_factor * 1.25, 10.0) + self._set_zoom(new_zoom) + + def zoom_out(self) -> None: + new_zoom = max(self._zoom_factor / 1.25, 0.1) + self._set_zoom(new_zoom) + + def reset_view(self) -> None: + """Reset zoom and pan to defaults.""" + self.resetTransform() + self._zoom_factor = 1.0 + self.centerOn(self._image_item) + self.zoom_changed.emit(1.0) + self.status_message.emit("Zoom reset: 1.00x") + + def clear(self) -> None: + """Remove the displayed image and overlays.""" + self._image_item.setPixmap(QPixmap()) + self._overlay_item.setPixmap(QPixmap()) + self._roi_item.setVisible(False) + self._image_size = (0, 0) + self._placeholder.setVisible(True) + + def set_theme(self, theme_name: str) -> None: + """Update canvas background and placeholder for the current theme.""" + from proteus.ui.theme import get_canvas_bg, get_text_sec_color + bg = get_canvas_bg(theme_name) + self.setBackgroundBrush(QBrush(QColor(bg))) + self._placeholder.setDefaultTextColor(QColor(get_text_sec_color(theme_name))) + + @property + def zoom_factor(self) -> float: + return self._zoom_factor + + # ---- Private ---- + + def _set_zoom(self, factor: float) -> None: + scale_change = factor / self._zoom_factor + self._zoom_factor = factor + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self.scale(scale_change, scale_change) + self.zoom_changed.emit(factor) + self.status_message.emit(f"Zoom: {factor:.2f}x") + + # ---- Event handlers ---- + + def wheelEvent(self, event: QWheelEvent) -> None: + if event.angleDelta().y() > 0: + self.zoom_in() + else: + self.zoom_out() + + def mousePressEvent(self, event: QMouseEvent) -> None: + if self._mode == "pan": + super().mousePressEvent(event) + return + if event.button() != Qt.LeftButton: + return + scene_pos = self.mapToScene(event.position().toPoint()) + ix, iy = int(scene_pos.x()), int(scene_pos.y()) + if self._mode == "draw": + self._drawing = True + self.brush_stroke.emit(ix, iy) + elif self._mode == "roi": + self._roi_origin = scene_pos + self.roi_changed.emit(ix, iy, ix, iy) + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + if self._mode == "pan": + super().mouseMoveEvent(event) + return + scene_pos = self.mapToScene(event.position().toPoint()) + ix, iy = int(scene_pos.x()), int(scene_pos.y()) + if self._mode == "draw" and self._drawing: + self.brush_stroke.emit(ix, iy) + elif self._mode == "roi": + ox, oy = int(self._roi_origin.x()), int(self._roi_origin.y()) + self.roi_changed.emit(ox, oy, ix, iy) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if self._mode == "pan": + super().mouseReleaseEvent(event) + return + if self._mode == "draw": + self._drawing = False + self.draw_finished.emit() + elif self._mode == "roi": + self.roi_finished.emit() diff --git a/src/proteus/ui/dialogs.py b/src/proteus/ui/dialogs.py new file mode 100644 index 0000000..c12df97 --- /dev/null +++ b/src/proteus/ui/dialogs.py @@ -0,0 +1,203 @@ +"""Parameter input dialogs for image processing operations.""" + +from typing import Optional, Tuple + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, + QDoubleSpinBox, QSpinBox, QCheckBox, QDialogButtonBox, QLineEdit +) + + +class GammaDialog(QDialog): + """Combined gamma + partial inversion + pivot input dialog.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Power (Gamma)") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Gamma (e.g., 0.5 / 1.2 / 2.0):")) + self.gamma_spin = QDoubleSpinBox() + self.gamma_spin.setRange(0.01, 10.0) + self.gamma_spin.setValue(1.6) + self.gamma_spin.setSingleStep(0.1) + self.gamma_spin.setDecimals(2) + layout.addWidget(self.gamma_spin) + + self.partial_check = QCheckBox("Enable partial inversion") + layout.addWidget(self.partial_check) + + layout.addWidget(QLabel("Pivot (0-255, pixels > pivot inverted):")) + self.pivot_spin = QSpinBox() + self.pivot_spin.setRange(0, 255) + self.pivot_spin.setValue(128) + self.pivot_spin.setEnabled(False) + layout.addWidget(self.pivot_spin) + + self.partial_check.toggled.connect(self.pivot_spin.setEnabled) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_values(self) -> Optional[Tuple[float, bool, int]]: + """Show dialog, return (gamma, partial_invert, pivot) or None.""" + if self.exec() == QDialog.Accepted: + return (self.gamma_spin.value(), self.partial_check.isChecked(), self.pivot_spin.value()) + return None + + +class InvertDialog(QDialog): + """Alpha (inversion intensity) input dialog.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Invert") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Invert intensity Alpha (0-1; 0=original, 1=fully inverted):")) + self.alpha_spin = QDoubleSpinBox() + self.alpha_spin.setRange(0.0, 1.0) + self.alpha_spin.setValue(1.0) + self.alpha_spin.setSingleStep(0.05) + self.alpha_spin.setDecimals(2) + layout.addWidget(self.alpha_spin) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_value(self) -> Optional[float]: + if self.exec() == QDialog.Accepted: + return self.alpha_spin.value() + return None + + +class BlurDivideDialog(QDialog): + """Kernel size + sigma input for Blur & Divide.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Blur & Divide") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Gaussian kernel size (odd, recommended 21-61):")) + self.ksize_spin = QSpinBox() + self.ksize_spin.setRange(3, 201) + self.ksize_spin.setValue(31) + self.ksize_spin.setSingleStep(2) + layout.addWidget(self.ksize_spin) + + layout.addWidget(QLabel("Sigma (0 = auto):")) + self.sigma_spin = QDoubleSpinBox() + self.sigma_spin.setRange(0.0, 50.0) + self.sigma_spin.setValue(0.0) + self.sigma_spin.setSingleStep(0.5) + layout.addWidget(self.sigma_spin) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_values(self) -> Optional[Tuple[int, float]]: + if self.exec() == QDialog.Accepted: + k = self.ksize_spin.value() + if k % 2 == 0: + k += 1 + return (k, self.sigma_spin.value()) + return None + + +class BandLabelDialog(QDialog): + """Ask the user for short labels for two merged bands (e.g. UV, IR).""" + + def __init__(self, filename1: str, filename2: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Band Labels") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel(f"Label for band 1 ({filename1}):")) + self.label1 = QLineEdit() + self.label1.setPlaceholderText("e.g. UV") + layout.addWidget(self.label1) + + layout.addWidget(QLabel(f"Label for band 2 ({filename2}):")) + self.label2 = QLineEdit() + self.label2.setPlaceholderText("e.g. IR") + layout.addWidget(self.label2) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_values(self) -> Optional[tuple[str, str]]: + if self.exec() == QDialog.Accepted: + l1 = self.label1.text().strip() or "Band1" + l2 = self.label2.text().strip() or "Band2" + return (l1, l2) + return None + + +class ThresholdDialog(QDialog): + """Custom binarization threshold input (0-255).""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("B/W Custom Threshold") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Threshold (0-255):")) + self.thresh_spin = QSpinBox() + self.thresh_spin.setRange(0, 255) + self.thresh_spin.setValue(128) + layout.addWidget(self.thresh_spin) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_value(self) -> Optional[int]: + if self.exec() == QDialog.Accepted: + return self.thresh_spin.value() + return None + + +class DenoiseDialog(QDialog): + """Kernel size + sigma input for Gaussian denoising.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Denoise (Gaussian)") + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Kernel size (odd, suggested 3/5/7):")) + self.ksize_spin = QSpinBox() + self.ksize_spin.setRange(3, 51) + self.ksize_spin.setValue(5) + self.ksize_spin.setSingleStep(2) + layout.addWidget(self.ksize_spin) + + layout.addWidget(QLabel("Sigma (suggested 0.8-2.5):")) + self.sigma_spin = QDoubleSpinBox() + self.sigma_spin.setRange(0.0, 10.0) + self.sigma_spin.setValue(1.2) + self.sigma_spin.setSingleStep(0.1) + layout.addWidget(self.sigma_spin) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def get_values(self) -> Optional[Tuple[int, float]]: + if self.exec() == QDialog.Accepted: + k = self.ksize_spin.value() + if k % 2 == 0: + k += 1 + return (k, self.sigma_spin.value()) + return None diff --git a/src/proteus/ui/main_window.py b/src/proteus/ui/main_window.py new file mode 100644 index 0000000..9b5808f --- /dev/null +++ b/src/proteus/ui/main_window.py @@ -0,0 +1,597 @@ +"""Main application window - orchestrates sidebar, canvas, and core logic.""" + +import os + +import numpy as np +import cv2 +from PySide6.QtWidgets import QMainWindow, QApplication, QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QMessageBox +from PySide6.QtCore import Qt, QSettings +from PySide6.QtGui import QUndoStack, QShortcut, QKeySequence + +from proteus.core.processing import ( + ensure_gray, ensure_color, to_uint8, + pseudocolor_jet, otsu_binarize, fixed_binarize, unsharp_mask, + power_transform, blur_divide, denoise_gaussian, rotate_90, +) +from proteus.core.pca import pca_multiband, pca_multiband_svd_variant +from proteus.core.image_io import load_image, load_as_gray, save_image +from proteus.core.state import ImageState, OperationLog +from proteus.core.utils import clamp + +from proteus.ui.canvas import ImageCanvas +from proteus.ui.sidebar import SidebarWidget +from proteus.ui.status_bar import StatusBar +from proteus.ui.top_bar import TopBar +from proteus.ui.dialogs import GammaDialog, InvertDialog, BlurDivideDialog, DenoiseDialog, ThresholdDialog, BandLabelDialog +from proteus.commands.undo_commands import ImageOperationCommand, DrawStrokeCommand, RoiChangeCommand + +IMAGE_FILTER = "Image Files (*.png *.jpg *.jpeg *.bmp *.tif *.tiff);;All Files (*.*)" + + +class ProteusMainWindow(QMainWindow): + """Main application window. Owns all state and coordinates components.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Proteus") + self.resize(1200, 760) + self.showMaximized() + + # ---- State ---- + self.img: np.ndarray | None = None + self.draw_mask: np.ndarray | None = None + self.roi: tuple | None = None + self.brush_size: int = 3 + self._pc_cache: dict | None = None + self._pc_index: int = 0 + self._band_labels: tuple | None = None + self.ops_log = OperationLog() + + # For brush undo: snapshot mask at stroke start + self._mask_before_draw: np.ndarray | None = None + + # For ROI undo: snapshot ROI at drag start + self._roi_before: tuple | None = None + + # Undo stack + self._undo_stack = QUndoStack(self) + + # ---- Build UI ---- + central = QWidget() + self.setCentralWidget(central) + outer = QVBoxLayout(central) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # Top bar (full width) + self.top_bar = TopBar() + outer.addWidget(self.top_bar) + + # Content area: sidebar + canvas + bottom bar + content = QHBoxLayout() + content.setContentsMargins(0, 0, 0, 0) + content.setSpacing(0) + + self.sidebar = SidebarWidget() + content.addWidget(self.sidebar) + + # Right column: canvas + bottom bar + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(0) + + self.canvas = ImageCanvas() + right_layout.addWidget(self.canvas, stretch=1) + + self.status_bar = StatusBar() + right_layout.addWidget(self.status_bar) + + content.addWidget(right, stretch=1) + outer.addLayout(content) + + # ---- Connect signals ---- + self._connect_sidebar() + self._connect_canvas() + self._connect_bottom_bar() + self._connect_shortcuts() + + # ---- Theme ---- + self._current_theme = "light" + self.top_bar.theme_toggle_clicked.connect(self._cycle_theme) + self._load_saved_theme() + + self.set_status("Ready: open an image to begin") + + # ---- Signal wiring ---- + + def _connect_sidebar(self): + s = self.sidebar + s.open_requested.connect(self.open_image) + s.save_requested.connect(self.save_image) + s.clear_requested.connect(self.clear_image) + s.undo_requested.connect(self._undo_stack.undo) + s.redo_requested.connect(self._undo_stack.redo) + + s.mode_changed.connect(self._on_mode_changed) + s.brush_size_changed.connect(self._on_brush_size_changed) + s.clear_drawing_requested.connect(self.clear_drawing) + s.clear_roi_requested.connect(self.clear_roi) + + s.pseudocolor_requested.connect(self.apply_pseudocolor_channel) + s.pseudocolor_two_requested.connect(self.apply_pseudocolor_two) + s.sharpen_original_requested.connect(self.apply_sharpen_original) + s.sharpen_bw_auto_requested.connect(self.apply_sharpen_bw_auto) + s.sharpen_bw_128_requested.connect(self.apply_sharpen_bw_128) + s.sharpen_bw_custom_requested.connect(self.apply_sharpen_bw_custom) + s.power_requested.connect(self.apply_power) + s.invert_requested.connect(self.apply_invert) + s.rotate_requested.connect(self.apply_rotate) + s.blur_divide_requested.connect(self.apply_blur_divide) + s.denoise_requested.connect(self.apply_denoise) + s.pca_requested.connect(self.apply_pca) + s.pca_svd_requested.connect(self.apply_pca_svd) + s.prev_pc_requested.connect(self.prev_pc) + s.next_pc_requested.connect(self.next_pc) + + def _connect_canvas(self): + self.canvas.brush_stroke.connect(self._on_brush_stroke) + self.canvas.draw_finished.connect(self._on_draw_finished) + self.canvas.roi_changed.connect(self._on_roi_changed) + self.canvas.roi_finished.connect(self._on_roi_finished) + self.canvas.status_message.connect(self.set_status) + self.canvas.zoom_changed.connect(self.status_bar.set_zoom_level) + + def _connect_bottom_bar(self): + self.status_bar.zoom_in_clicked.connect(self.canvas.zoom_in) + self.status_bar.zoom_out_clicked.connect(self.canvas.zoom_out) + self.status_bar.zoom_reset_clicked.connect(self.canvas.reset_view) + + def _connect_shortcuts(self): + QShortcut(QKeySequence("Ctrl+Z"), self, self._undo_stack.undo) + QShortcut(QKeySequence("Ctrl+Y"), self, self._undo_stack.redo) + QShortcut(QKeySequence("+"), self, self.canvas.zoom_in) + QShortcut(QKeySequence("="), self, self.canvas.zoom_in) + QShortcut(QKeySequence("-"), self, self.canvas.zoom_out) + QShortcut(QKeySequence("0"), self, self.canvas.reset_view) + + # ---- State management ---- + + def _snapshot(self) -> ImageState: + return ImageState( + img=self.img.copy() if self.img is not None else None, + draw_mask=self.draw_mask.copy() if self.draw_mask is not None else None, + roi=tuple(self.roi) if self.roi is not None else None, + zoom=self.canvas.zoom_factor, + pan_x=0, pan_y=0, + meta={}, + ) + + def restore_state(self, state: ImageState) -> None: + """Restore the application to a previous state (called by undo commands).""" + self.img = state.img.copy() if state.img is not None else None + self.draw_mask = state.draw_mask.copy() if state.draw_mask is not None else None + self.roi = state.roi + self._update_canvas() + + def _update_canvas(self) -> None: + if self.img is not None: + self.canvas.set_image(self.img) + else: + self.canvas.clear() + self.canvas.set_draw_mask(self.draw_mask) + self.canvas.set_roi(self.roi) + + def set_status(self, text: str) -> None: + self.status_bar.set_text(text) + + # ---- Mode handling ---- + + def _on_mode_changed(self, mode: str) -> None: + self.canvas.set_mode(mode) + self.sidebar.highlight_mode(mode) + + def _on_brush_size_changed(self, value: int) -> None: + self.brush_size = value + + # ---- Generic operation wrapper ---- + + def _apply_op(self, fn, meta: dict) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + before = self._snapshot() + try: + result = fn(self.img) + if result is None: + raise ValueError("Processing result is empty") + self.img = result + self._pc_cache = None + self._pc_index = 0 + self.ops_log.record(meta) + after = self._snapshot() + cmd = ImageOperationCommand(self, before, after, meta.get("op", "operation")) + self._undo_stack.push(cmd) + self._update_canvas() + self.set_status(f"Done: {meta.get('op', 'operation')}") + except Exception as e: + QMessageBox.critical(self, "Error", f"Operation failed: {e}") + + # ---- File operations ---- + + def open_image(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Open Image", "", IMAGE_FILTER) + if not path: + return + try: + img = load_image(path) + except ValueError as e: + QMessageBox.critical(self, "Error", str(e)) + return + + self.img = img + self.draw_mask = None + self.roi = None + self._pc_cache = None + self._pc_index = 0 + self._undo_stack.clear() + self.ops_log.clear() + + self._update_canvas() + self.canvas.reset_view() + self.set_status(f"Opened: {os.path.basename(path)} | {img.shape[1]}x{img.shape[0]}") + + def save_image(self) -> None: + if self.img is None: + return + path, _ = QFileDialog.getSaveFileName( + self, "Save Image", "", + "PNG (*.png);;JPG (*.jpg);;BMP (*.bmp);;TIFF (*.tif *.tiff)" + ) + if not path: + return + try: + save_image(path, self.img, self.draw_mask) + except ValueError as e: + QMessageBox.critical(self, "Error", f"Save failed: {e}") + return + + # Write log + try: + txt_path = os.path.splitext(path)[0] + ".txt" + self.ops_log.export_txt(txt_path) + except Exception as e: + QMessageBox.warning(self, "Warning", f"Image saved, but failed to write log: {e}") + + self.set_status(f"Saved: {path}") + + def clear_image(self) -> None: + self.img = None + self.draw_mask = None + self.roi = None + self._pc_cache = None + self._pc_index = 0 + self._undo_stack.clear() + self.ops_log.clear() + self._update_canvas() + self.set_status("Cleared") + + # ---- Brush handling ---- + + def _on_brush_stroke(self, ix: int, iy: int) -> None: + if self.img is None: + return + h, w = self.img.shape[:2] + if self.draw_mask is None or self.draw_mask.shape[:2] != (h, w): + self.draw_mask = np.zeros((h, w), dtype=np.uint8) + # Snapshot mask at start of stroke + if self._mask_before_draw is None: + self._mask_before_draw = self.draw_mask.copy() + radius = int(round(self.brush_size * 6 / max(0.25, self.canvas.zoom_factor))) + radius = clamp(radius, 2, 40) + cv2.circle(self.draw_mask, (ix, iy), radius, 255, thickness=-1) + self.canvas.set_draw_mask(self.draw_mask) + + def _on_draw_finished(self) -> None: + if self.draw_mask is not None and self._mask_before_draw is not None: + cmd = DrawStrokeCommand(self, self._mask_before_draw, self.draw_mask) + self._undo_stack.push(cmd) + self._mask_before_draw = None + + def set_draw_mask(self, mask: np.ndarray | None) -> None: + """Called by undo commands to restore mask state.""" + self.draw_mask = mask.copy() if mask is not None else None + self.canvas.set_draw_mask(self.draw_mask) + + # ---- Drawing/ROI clearing ---- + + def clear_drawing(self) -> None: + if self.draw_mask is not None: + before = self.draw_mask.copy() + self.draw_mask = None + cmd = DrawStrokeCommand(self, before, np.zeros_like(before)) + self._undo_stack.push(cmd) + self.canvas.set_draw_mask(self.draw_mask) + self.set_status("Drawing cleared") + + def clear_roi(self) -> None: + if self.roi is not None: + before = self.roi + self.roi = None + cmd = RoiChangeCommand(self, before, None) + self._undo_stack.push(cmd) + self.canvas.set_roi(None) + self.set_status("ROI cleared") + + # ---- ROI handling ---- + + def _on_roi_changed(self, x0: int, y0: int, x1: int, y1: int) -> None: + if self._roi_before is None: + self._roi_before = self.roi + self.roi = (x0, y0, x1, y1) + self.canvas.set_roi(self.roi) + + def _on_roi_finished(self) -> None: + cmd = RoiChangeCommand(self, self._roi_before, self.roi) + self._undo_stack.push(cmd) + self._roi_before = None + + def set_roi(self, roi: tuple | None) -> None: + """Called by undo commands to restore ROI state.""" + self.roi = roi + self.canvas.set_roi(self.roi) + + # ---- Image processing operations ---- + + def apply_pseudocolor_channel(self, channel: str) -> None: + ch = str(channel).lower() + + def op(img): + if ch == "all": + return pseudocolor_jet(ensure_gray(img)) + color = ensure_color(img) + if ch == "r": + src = color[:, :, 2] + elif ch == "g": + src = color[:, :, 1] + elif ch == "b": + src = color[:, :, 0] + else: + return pseudocolor_jet(ensure_gray(color)) + return pseudocolor_jet(to_uint8(src)) + + self._apply_op(op, {"op": "pseudocolor_channel", "channel": ch}) + + def apply_pseudocolor_two(self) -> None: + paths, _ = QFileDialog.getOpenFileNames( + self, + "Select two grayscale images (they will be converted to grayscale)", + "", IMAGE_FILTER + ) + if not paths or len(paths) < 2: + return + + f1 = os.path.basename(paths[0]) + f2 = os.path.basename(paths[1]) + dlg = BandLabelDialog(f1, f2, self) + labels = dlg.get_values() + if labels is None: + return + + try: + g1 = load_as_gray(paths[0]) + g2 = load_as_gray(paths[1]) + if g1.shape != g2.shape: + QMessageBox.critical(self, "Error", "The two images must have the same size") + return + mix = cv2.addWeighted(g1, 0.5, g2, 0.5, 0) + out = pseudocolor_jet(mix) + + self.img = out + self.draw_mask = None + self.roi = None + self._pc_cache = None + self._pc_index = 0 + self._band_labels = labels + self._undo_stack.clear() + self.ops_log.clear() + self.ops_log.record({ + "op": "pseudocolor_two", + "p1": paths[0], "p2": paths[1], + "band1_label": labels[0], "band2_label": labels[1], + }) + self._update_canvas() + self.canvas.reset_view() + self.set_status(f"Done: Pseudocolor (Merge) [{labels[0]} + {labels[1]}]") + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + def apply_sharpen_original(self) -> None: + self._apply_op(lambda img: unsharp_mask(img), {"op": "sharpen_original"}) + + def apply_sharpen_bw_auto(self) -> None: + self._apply_op(lambda img: otsu_binarize(img), {"op": "sharpen_bw_auto"}) + + def apply_sharpen_bw_128(self) -> None: + self._apply_op(lambda img: fixed_binarize(img, thresh=128), {"op": "sharpen_bw_128", "thresh": 128}) + + def apply_sharpen_bw_custom(self) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + dlg = ThresholdDialog(self) + thresh = dlg.get_value() + if thresh is None: + return + self._apply_op(lambda img: fixed_binarize(img, thresh=thresh), {"op": "sharpen_bw_custom", "thresh": thresh}) + + def apply_power(self) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + dlg = GammaDialog(self) + result = dlg.get_values() + if result is None: + return + gamma, partial, pivot = result + + def op(img): + return power_transform(img, gamma=gamma, partial_invert=partial, pivot=pivot) + + self._apply_op(op, {"op": "power", "gamma": gamma, "partial": partial, "pivot": pivot}) + + def apply_invert(self) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + dlg = InvertDialog(self) + alpha = dlg.get_value() + if alpha is None: + return + a = float(alpha) + + def op(img): + inv = 255 - img + x = img.astype(np.float32) + y = inv.astype(np.float32) + out = (1.0 - a) * x + a * y + return out.clip(0, 255).astype(np.uint8) + + self._apply_op(op, {"op": "invert", "alpha": a}) + + def apply_rotate(self, direction: str) -> None: + self._apply_op(lambda img: rotate_90(img, direction=direction), {"op": "rotate_90", "direction": direction}) + + def apply_blur_divide(self) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + dlg = BlurDivideDialog(self) + result = dlg.get_values() + if result is None: + return + k, sigma = result + + def op(img): + return blur_divide(img, ksize=k, sigma=sigma) + + self._apply_op(op, {"op": "blur_divide", "ksize": k, "sigma": sigma}) + + def apply_denoise(self) -> None: + if self.img is None: + QMessageBox.information(self, "Info", "Please open an image first") + return + dlg = DenoiseDialog(self) + result = dlg.get_values() + if result is None: + return + k, sigma = result + + def op(img): + return denoise_gaussian(img, ksize=k, sigma=sigma) + + self._apply_op(op, {"op": "denoise_gaussian", "ksize": k, "sigma": sigma}) + + def apply_pca(self) -> None: + self._run_pca(pca_multiband, "PCA", "pca") + + def apply_pca_svd(self) -> None: + self._run_pca(pca_multiband_svd_variant, "PCA (SVD)", "pca_svd") + + def _run_pca(self, pca_fn, label: str, op_name: str) -> None: + paths, _ = QFileDialog.getOpenFileNames( + self, + f"Select 3-16 grayscale images (sizes must match) for {label}", + "", IMAGE_FILTER + ) + if not paths: + return + if len(paths) < 3: + QMessageBox.information(self, "Info", f"{label}: select at least 3 images") + return + if len(paths) > 16: + QMessageBox.information(self, "Info", "Maximum 16 images; only the first 16 will be used") + paths = paths[:16] + + try: + imgs = [load_as_gray(p) for p in paths] + self.set_status(f"Computing {label}... (may take a few seconds)") + + result = pca_fn(imgs, roi=self.roi) + self._pc_cache = result + self._pc_index = 0 + pc1 = result["pcs"][0] + self.img = pc1 + self.draw_mask = None + self.ops_log.record({"op": op_name, "files": list(paths), "roi": self.roi}) + + after = self._snapshot() + cmd = ImageOperationCommand(self, self._snapshot(), after, op_name) + self._undo_stack.push(cmd) + self._update_canvas() + + ratios = result.get("explained", []) + tip = "" + if ratios: + tip = " | ".join([f"C{i+1}:{ratios[i]*100:.1f}%" for i in range(min(4, len(ratios)))]) + self.set_status(f"{label} done: showing Component 1 (explained variance: {tip})") + + except Exception as e: + QMessageBox.critical(self, "Error", f"{label} failed: {e}") + self.set_status(f"{label} failed") + + def next_pc(self) -> None: + self._switch_pc(+1) + + def prev_pc(self) -> None: + self._switch_pc(-1) + + def _switch_pc(self, direction: int) -> None: + if not self._pc_cache or "pcs" not in self._pc_cache: + self.set_status("No PCA result found: run PCA first") + return + pcs = self._pc_cache["pcs"] + before = self._snapshot() + self._pc_index = (self._pc_index + direction) % len(pcs) + self.img = pcs[self._pc_index].copy() + self.draw_mask = None + + after = self._snapshot() + cmd = ImageOperationCommand(self, before, after, "pca_switch") + self._undo_stack.push(cmd) + self._update_canvas() + + ratios = self._pc_cache.get("explained", []) + r = ratios[self._pc_index] * 100.0 if self._pc_index < len(ratios) else None + n = len(pcs) + if r is None: + self.set_status(f"Showing: Component {self._pc_index+1} of {n}") + else: + self.set_status(f"Showing: Component {self._pc_index+1} of {n} (explained variance {r:.1f}%)") + + # ---- Theme management ---- + + def _load_saved_theme(self) -> None: + settings = QSettings() + saved = settings.value("theme", "light") + if saved not in ("light", "dark", "high-contrast"): + saved = "light" + self._apply_theme(saved) + + def _cycle_theme(self) -> None: + from proteus.ui.theme import next_theme + new_theme = next_theme(self._current_theme) + self._apply_theme(new_theme) + + def _apply_theme(self, name: str) -> None: + from proteus.ui.theme import apply_theme, THEME_INFO + app = QApplication.instance() + if app: + apply_theme(app, name) + self._current_theme = name + self.top_bar.set_theme(name) + self.canvas.set_theme(name) + self.sidebar.set_theme(name) + QSettings().setValue("theme", name) + label = THEME_INFO.get(name, ("",))[0] + self.set_status(f"Theme: {label}") diff --git a/src/proteus/ui/sidebar.py b/src/proteus/ui/sidebar.py new file mode 100644 index 0000000..f9a460a --- /dev/null +++ b/src/proteus/ui/sidebar.py @@ -0,0 +1,337 @@ +"""Sidebar widget containing all tool buttons organized in collapsible sections. + +Emits signals only — all logic is handled by the main window. +""" + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QScrollArea, QHBoxLayout, + QPushButton, QSlider, QLabel, QGridLayout +) +from PySide6.QtCore import Qt, Signal + + +# --------------------------------------------------------------------------- +# Collapsible section helper +# --------------------------------------------------------------------------- +class CollapsibleSection(QWidget): + """A section with a clickable header that toggles content visibility.""" + + def __init__(self, title: str, expanded: bool = True, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header button + self._toggle = QPushButton(f"\u25BC {title}" if expanded else f"\u25B6 {title}") + self._toggle.setObjectName("sectionHeader") + self._toggle.setCursor(Qt.PointingHandCursor) + self._toggle.clicked.connect(self._on_toggle) + layout.addWidget(self._toggle) + + # Content container + self._content = QWidget() + self._content_layout = QVBoxLayout(self._content) + self._content_layout.setContentsMargins(4, 6, 4, 8) + self._content_layout.setSpacing(6) + self._content.setVisible(expanded) + layout.addWidget(self._content) + + self._title = title + self._expanded = expanded + + @property + def content_layout(self) -> QVBoxLayout: + return self._content_layout + + def _on_toggle(self): + self._expanded = not self._expanded + self._content.setVisible(self._expanded) + arrow = "\u25BC" if self._expanded else "\u25B6" + self._toggle.setText(f"{arrow} {self._title}") + + +# --------------------------------------------------------------------------- +# Sidebar +# --------------------------------------------------------------------------- +class SidebarWidget(QScrollArea): + """Left sidebar with collapsible sections for Files, View, and Processing.""" + + # Section 1: Files / History + open_requested = Signal() + save_requested = Signal() + clear_requested = Signal() + undo_requested = Signal() + redo_requested = Signal() + + # Section 2: View / Annotate (zoom moved to bottom bar) + mode_changed = Signal(str) + brush_size_changed = Signal(int) + clear_drawing_requested = Signal() + clear_roi_requested = Signal() + + # Section 3: Image Processing + pseudocolor_requested = Signal(str) + pseudocolor_two_requested = Signal() + sharpen_original_requested = Signal() + sharpen_bw_auto_requested = Signal() + sharpen_bw_128_requested = Signal() + sharpen_bw_custom_requested = Signal() + power_requested = Signal() + invert_requested = Signal() + rotate_requested = Signal(str) + blur_divide_requested = Signal() + denoise_requested = Signal() + pca_requested = Signal() + pca_svd_requested = Signal() + prev_pc_requested = Signal() + next_pc_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFixedWidth(270) + + container = QWidget() + self._layout = QVBoxLayout(container) + self._layout.setContentsMargins(10, 10, 10, 10) + self._layout.setSpacing(4) + self.setWidget(container) + + self._mode_buttons = {} + self._sub_labels: list[QLabel] = [] + self._separators: list[QWidget] = [] + + self._build_files_section() + self._build_view_section() + self._build_processing_section() + self._layout.addStretch() + + # ------------------------------------------------------------------ Files + def _build_files_section(self): + section = CollapsibleSection("Files / History") + lay = section.content_layout + + btn_open = QPushButton("Open Image") + btn_open.setToolTip("Open an image (common formats)") + btn_open.clicked.connect(self.open_requested) + lay.addWidget(btn_open) + + btn_save = QPushButton("Save Current Image") + btn_save.setToolTip("Save the current processed result (full resolution)") + btn_save.clicked.connect(self.save_requested) + lay.addWidget(btn_save) + + btn_clear = QPushButton("Clear") + btn_clear.setObjectName("destructiveButton") + btn_clear.setToolTip("Clear the current image and overlays") + btn_clear.clicked.connect(self.clear_requested) + lay.addWidget(btn_clear) + + hist = QHBoxLayout() + hist.setSpacing(6) + btn_undo = QPushButton("Undo") + btn_undo.setToolTip("Undo one step (Ctrl+Z)") + btn_undo.clicked.connect(self.undo_requested) + btn_redo = QPushButton("Redo") + btn_redo.setToolTip("Redo one step (Ctrl+Y)") + btn_redo.clicked.connect(self.redo_requested) + hist.addWidget(btn_undo) + hist.addWidget(btn_redo) + lay.addLayout(hist) + + self._layout.addWidget(section) + + # ---------------------------------------------------------- View / Annotate + def _build_view_section(self): + section = CollapsibleSection("View / Annotate") + lay = section.content_layout + + # Mode buttons + mode_layout = QHBoxLayout() + mode_layout.setSpacing(6) + for label, mode_key in [("Pan", "pan"), ("Brush", "draw"), ("ROI", "roi")]: + btn = QPushButton(label) + btn.setCheckable(True) + btn.setToolTip({ + "pan": "Drag to pan the image", + "draw": "Yellow highlight brush", + "roi": "Select region of interest" + }[mode_key]) + btn.clicked.connect(lambda checked, m=mode_key: self.mode_changed.emit(m)) + mode_layout.addWidget(btn) + self._mode_buttons[mode_key] = btn + self._mode_buttons["pan"].setChecked(True) + lay.addLayout(mode_layout) + + # Brush size + brush_row = QHBoxLayout() + brush_row.setSpacing(8) + brush_lbl = QLabel("Brush") + brush_lbl.setObjectName("subCategoryLabel") + self._sub_labels.append(brush_lbl) + brush_row.addWidget(brush_lbl) + self._brush_slider = QSlider(Qt.Horizontal) + self._brush_slider.setRange(1, 5) + self._brush_slider.setValue(3) + self._brush_slider.setTickPosition(QSlider.TicksBelow) + self._brush_slider.setTickInterval(1) + self._brush_slider.valueChanged.connect(self.brush_size_changed) + brush_row.addWidget(self._brush_slider, stretch=1) + lay.addLayout(brush_row) + + # Clear buttons + clear_row = QHBoxLayout() + clear_row.setSpacing(6) + btn_cd = QPushButton("Clear Drawing") + btn_cd.setToolTip("Clear yellow highlights") + btn_cd.clicked.connect(self.clear_drawing_requested) + btn_cr = QPushButton("Clear ROI") + btn_cr.setToolTip("Clear ROI selection") + btn_cr.clicked.connect(self.clear_roi_requested) + clear_row.addWidget(btn_cd) + clear_row.addWidget(btn_cr) + lay.addLayout(clear_row) + + self._layout.addWidget(section) + + # ------------------------------------------------------- Image Processing + def _build_processing_section(self): + section = CollapsibleSection("Image Processing") + lay = section.content_layout + + # -- Pseudocolor + sub_lbl = QLabel("Pseudocolor") + sub_lbl.setObjectName("subCategoryLabel") + self._sub_labels.append(sub_lbl) + lay.addWidget(sub_lbl) + + pc_grid = QGridLayout() + pc_grid.setSpacing(4) + for i, (text, ch) in enumerate([("All", "all"), ("Red", "r"), + ("Green", "g"), ("Blue", "b")]): + btn = QPushButton(text) + btn.setToolTip(f"Pseudocolor based on {'overall' if ch == 'all' else ch.upper() + ' channel'} intensity") + btn.clicked.connect(lambda checked, c=ch: self.pseudocolor_requested.emit(c)) + pc_grid.addWidget(btn, i // 2, i % 2) + lay.addLayout(pc_grid) + + btn_pc2 = QPushButton("Merge Two Images") + btn_pc2.setToolTip("Select two images, blend 50/50, apply JET colormap") + btn_pc2.clicked.connect(self.pseudocolor_two_requested) + lay.addWidget(btn_pc2) + + # -- Sharpen + self._add_separator(lay) + sub_lbl2 = QLabel("Sharpen / Binarize") + sub_lbl2.setObjectName("subCategoryLabel") + self._sub_labels.append(sub_lbl2) + lay.addWidget(sub_lbl2) + + sharp_grid = QGridLayout() + sharp_grid.setSpacing(4) + for i, (text, tip, sig) in enumerate([ + ("Original", "Sharpen using unsharp mask", self.sharpen_original_requested), + ("B/W Auto", "Auto-threshold binarization (Otsu)", self.sharpen_bw_auto_requested), + ("B/W 128", "Fixed threshold binarization at 128", self.sharpen_bw_128_requested), + ("B/W Custom", "Custom threshold binarization (dialog)", self.sharpen_bw_custom_requested), + ]): + btn = QPushButton(text) + btn.setToolTip(tip) + btn.clicked.connect(sig) + sharp_grid.addWidget(btn, i // 2, i % 2) + lay.addLayout(sharp_grid) + + # -- Enhance + self._add_separator(lay) + sub_lbl3 = QLabel("Enhance") + sub_lbl3.setObjectName("subCategoryLabel") + self._sub_labels.append(sub_lbl3) + lay.addWidget(sub_lbl3) + + btn_pow = QPushButton("Power (Gamma)") + btn_pow.setToolTip("Gamma/power transform with optional partial inversion") + btn_pow.clicked.connect(self.power_requested) + lay.addWidget(btn_pow) + + btn_inv = QPushButton("Invert") + btn_inv.setToolTip("Invert image with alpha blending") + btn_inv.clicked.connect(self.invert_requested) + lay.addWidget(btn_inv) + + enhance_row = QHBoxLayout() + enhance_row.setSpacing(4) + btn_bd = QPushButton("Blur && Divide") + btn_bd.setToolTip("Divide by Gaussian blur, normalize, equalize") + btn_bd.clicked.connect(self.blur_divide_requested) + btn_dn = QPushButton("Denoise") + btn_dn.setToolTip("Gaussian denoise filter") + btn_dn.clicked.connect(self.denoise_requested) + enhance_row.addWidget(btn_bd) + enhance_row.addWidget(btn_dn) + lay.addLayout(enhance_row) + + # -- Transform + rot = QHBoxLayout() + rot.setSpacing(4) + btn_rl = QPushButton("Rotate \u2190 90\u00b0") + btn_rl.clicked.connect(lambda: self.rotate_requested.emit("left")) + btn_rr = QPushButton("Rotate \u2192 90\u00b0") + btn_rr.clicked.connect(lambda: self.rotate_requested.emit("right")) + rot.addWidget(btn_rl) + rot.addWidget(btn_rr) + lay.addLayout(rot) + + # -- PCA + self._add_separator(lay) + sub_lbl4 = QLabel("PCA") + sub_lbl4.setObjectName("subCategoryLabel") + self._sub_labels.append(sub_lbl4) + lay.addWidget(sub_lbl4) + + pca_row = QHBoxLayout() + pca_row.setSpacing(4) + btn_pca = QPushButton("PCA") + btn_pca.setToolTip("Principal Component Analysis (3-16 images)") + btn_pca.clicked.connect(self.pca_requested) + btn_svd = QPushButton("PCA-SVD") + btn_svd.setToolTip("PCA SVD variant (3-16 images)") + btn_svd.clicked.connect(self.pca_svd_requested) + pca_row.addWidget(btn_pca) + pca_row.addWidget(btn_svd) + lay.addLayout(pca_row) + + nav_row = QHBoxLayout() + nav_row.setSpacing(4) + btn_prev = QPushButton("Prev Result") + btn_prev.setToolTip("Show previous principal component result (run PCA first)") + btn_prev.clicked.connect(self.prev_pc_requested) + btn_next = QPushButton("Next Result") + btn_next.setToolTip("Show next principal component result (run PCA first)") + btn_next.clicked.connect(self.next_pc_requested) + nav_row.addWidget(btn_prev) + nav_row.addWidget(btn_next) + lay.addLayout(nav_row) + + self._layout.addWidget(section) + + # ---------------------------------------------------------------- Helpers + def _add_separator(self, layout: QVBoxLayout): + sep = QWidget() + sep.setFixedHeight(1) + sep.setObjectName("separator") + self._separators.append(sep) + layout.addWidget(sep) + + def highlight_mode(self, mode: str) -> None: + """Visually highlight the active mode button.""" + for key, btn in self._mode_buttons.items(): + btn.setChecked(key == mode) + + def set_theme(self, theme_name: str) -> None: + """Update inline-styled elements for the current theme.""" + from proteus.ui.theme import get_separator_color + sep_color = get_separator_color(theme_name) + for sep in self._separators: + sep.setStyleSheet(f"background-color: {sep_color};") diff --git a/src/proteus/ui/status_bar.py b/src/proteus/ui/status_bar.py new file mode 100644 index 0000000..43b86a9 --- /dev/null +++ b/src/proteus/ui/status_bar.py @@ -0,0 +1,56 @@ +"""Bottom bar with status text (left) and zoom controls (right).""" + +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton +from PySide6.QtCore import Qt, Signal + + +class StatusBar(QFrame): + """Bottom bar: status message on the left, zoom controls on the right.""" + + zoom_in_clicked = Signal() + zoom_out_clicked = Signal() + zoom_reset_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("bottomBar") + self.setFixedHeight(36) + + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(6) + + # Left: status text + self._label = QLabel("Ready") + self._label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) + layout.addWidget(self._label, stretch=1) + + # Right: zoom controls + btn_out = QPushButton("\u2212") # minus sign + btn_out.setToolTip("Zoom out") + btn_out.setFixedSize(26, 26) + btn_out.clicked.connect(self.zoom_out_clicked) + layout.addWidget(btn_out) + + self._zoom_label = QLabel("100%") + self._zoom_label.setAlignment(Qt.AlignCenter) + self._zoom_label.setFixedWidth(48) + self._zoom_label.setStyleSheet("font-weight: 500;") + layout.addWidget(self._zoom_label) + + btn_in = QPushButton("+") + btn_in.setToolTip("Zoom in") + btn_in.setFixedSize(26, 26) + btn_in.clicked.connect(self.zoom_in_clicked) + layout.addWidget(btn_in) + + btn_reset = QPushButton("Reset") + btn_reset.setToolTip("Reset zoom to 100%") + btn_reset.clicked.connect(self.zoom_reset_clicked) + layout.addWidget(btn_reset) + + def set_text(self, text: str) -> None: + self._label.setText(text) + + def set_zoom_level(self, factor: float) -> None: + self._zoom_label.setText(f"{factor * 100:.0f}%") diff --git a/src/proteus/ui/theme.py b/src/proteus/ui/theme.py new file mode 100644 index 0000000..83abeb4 --- /dev/null +++ b/src/proteus/ui/theme.py @@ -0,0 +1,389 @@ +"""Theme configuration for Proteus. + +Three themes matching the QuillApp suite design language: + - Light: neutral grays, blue accent, ghost buttons + - Dark: dark neutrals, brighter blue accent + - High Contrast: pure black/white, maximum readability +""" + +from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QPalette, QColor + + +# --------------------------------------------------------------------------- +# Colour token sets { key: (light, dark, high_contrast) } +# --------------------------------------------------------------------------- +_TOKENS = { + "bg": ("#fafafa", "#171717", "#000000"), + "card": ("#ffffff", "#222222", "#000000"), + "border": ("#e2e2e2", "#333333", "#ffffff"), + "border_light": ("#efefef", "#2a2a2a", "#cccccc"), + "text": ("#1a1a1a", "#f2f2f2", "#ffffff"), + "text_sec": ("#6b7280", "#9ca3af", "#cccccc"), + "accent": ("#2563eb", "#3b82f6", "#5ca0ff"), + "accent_hover": ("#1d4ed8", "#2563eb", "#4090f0"), + "accent_light": ("rgba(37,99,235,0.08)", "rgba(59,130,246,0.12)", "rgba(92,160,255,0.18)"), + "accent_border": ("rgba(37,99,235,0.35)", "rgba(59,130,246,0.45)", "rgba(92,160,255,0.6)"), + "destructive": ("#dc2626", "#ef4444", "#ff4444"), + "canvas_bg": ("#e5e7eb", "#111111", "#1a1a1a"), + "muted": ("#f3f4f6", "#2a2a2a", "#1a1a1a"), + "scrollbar": ("#c4c4c4", "#555555", "#666666"), + "tooltip_bg": ("#1a1a1a", "#f5f5f5", "#ffffff"), + "tooltip_fg": ("#ffffff", "#1a1a1a", "#000000"), + "input_bg": ("#ffffff", "#1e1e1e", "#111111"), + "pressed": ("#e5e5e5", "#3a3a3a", "#333333"), + "hover": ("#d0d0d0", "#444444", "#555555"), + "separator": ("#e5e7eb", "#2e2e2e", "#444444"), + "checked_text": ("#2563eb", "#60a5fa", "#7fbfff"), + "destr_border": ("rgba(220,38,38,0.3)", "rgba(239,68,68,0.35)", "rgba(255,68,68,0.5)"), + "destr_hover": ("rgba(220,38,38,0.06)", "rgba(239,68,68,0.1)", "rgba(255,68,68,0.15)"), + "destr_hover_bdr": ("rgba(220,38,38,0.5)", "rgba(239,68,68,0.5)", "rgba(255,68,68,0.7)"), +} + +THEME_NAMES = ("light", "dark", "high-contrast") + + +def _tok(name: str, theme_index: int) -> str: + return _TOKENS[name][theme_index] + + +def _build_stylesheet(i: int) -> str: + """Build a complete stylesheet from the token set at index *i*.""" + t = {k: v[i] for k, v in _TOKENS.items()} + return f""" +/* ---- Global ---- */ +QMainWindow {{ + background-color: {t['bg']}; +}} + +/* ---- Sidebar, cards, panels ---- */ +QGroupBox {{ + background-color: {t['card']}; + border: 1px solid {t['border']}; + border-radius: 10px; + margin-top: 6px; + padding: 14px 10px 10px 10px; + font-size: 12px; + font-weight: 600; + color: {t['text_sec']}; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + padding: 0 8px; + color: {t['text_sec']}; +}} + +/* ---- Buttons (ghost style) ---- */ +QPushButton {{ + background-color: transparent; + color: {t['text']}; + border: 1px solid {t['border']}; + border-radius: 8px; + padding: 7px 14px; + font-size: 13px; + font-weight: 500; +}} +QPushButton:hover {{ + background-color: {t['muted']}; + border-color: {t['hover']}; +}} +QPushButton:pressed {{ + background-color: {t['pressed']}; +}} +QPushButton:checked {{ + background-color: {t['accent_light']}; + border-color: {t['accent_border']}; + color: {t['checked_text']}; + font-weight: 600; +}} +QPushButton:disabled {{ + opacity: 0.5; + color: {t['text_sec']}; +}} + +/* ---- Destructive / Clear button ---- */ +QPushButton#destructiveButton {{ + color: {t['destructive']}; + border-color: {t['destr_border']}; +}} +QPushButton#destructiveButton:hover {{ + background-color: {t['destr_hover']}; + border-color: {t['destr_hover_bdr']}; +}} + +/* ---- Scroll areas ---- */ +QScrollArea {{ + background-color: {t['bg']}; + border: none; +}} +QScrollArea > QWidget > QWidget {{ + background-color: {t['bg']}; +}} + +/* ---- Sliders ---- */ +QSlider::groove:horizontal {{ + height: 4px; + background: {t['border']}; + border-radius: 2px; +}} +QSlider::handle:horizontal {{ + background: {t['accent']}; + width: 14px; + margin: -5px 0; + border-radius: 7px; +}} +QSlider::sub-page:horizontal {{ + background: {t['accent']}; + border-radius: 2px; +}} + +/* ---- Labels ---- */ +QLabel {{ + color: {t['text']}; + font-size: 13px; +}} + +/* ---- Tooltips ---- */ +QToolTip {{ + background-color: {t['tooltip_bg']}; + color: {t['tooltip_fg']}; + border: none; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; +}} + +/* ---- Spinboxes ---- */ +QSpinBox, QDoubleSpinBox {{ + background-color: {t['input_bg']}; + color: {t['text']}; + border: 1px solid {t['border']}; + border-radius: 6px; + padding: 5px 8px; + font-size: 13px; +}} +QSpinBox:focus, QDoubleSpinBox:focus {{ + border-color: {t['accent']}; +}} + +/* ---- Line edits ---- */ +QLineEdit {{ + background-color: {t['input_bg']}; + color: {t['text']}; + border: 1px solid {t['border']}; + border-radius: 6px; + padding: 5px 8px; + font-size: 13px; +}} +QLineEdit:focus {{ + border-color: {t['accent']}; +}} + +/* ---- Checkboxes ---- */ +QCheckBox {{ + color: {t['text']}; + font-size: 13px; + spacing: 8px; +}} +QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid {t['border']}; + border-radius: 4px; + background-color: {t['input_bg']}; +}} +QCheckBox::indicator:checked {{ + background-color: {t['accent']}; + border-color: {t['accent']}; +}} + +/* ---- Dialogs ---- */ +QDialog {{ + background-color: {t['bg']}; +}} +QDialogButtonBox QPushButton {{ + min-width: 80px; + background-color: {t['accent']}; + color: #ffffff; + border: none; + border-radius: 6px; + padding: 7px 16px; +}} +QDialogButtonBox QPushButton:hover {{ + background-color: {t['accent_hover']}; +}} + +/* ---- Scrollbars ---- */ +QScrollBar:vertical {{ + background: transparent; + width: 6px; + margin: 2px; +}} +QScrollBar::handle:vertical {{ + background: {t['scrollbar']}; + border-radius: 3px; + min-height: 24px; +}} +QScrollBar::handle:vertical:hover {{ + background: {t['hover']}; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} +QScrollBar:horizontal {{ + background: transparent; + height: 6px; + margin: 2px; +}} +QScrollBar::handle:horizontal {{ + background: {t['scrollbar']}; + border-radius: 3px; + min-width: 24px; +}} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ + width: 0px; +}} + +/* ---- Collapsible section headers ---- */ +QPushButton#sectionHeader {{ + background-color: transparent; + border: none; + border-radius: 6px; + padding: 6px 8px; + font-size: 12px; + font-weight: 600; + color: {t['text_sec']}; + text-align: left; +}} +QPushButton#sectionHeader:hover {{ + background-color: {t['muted']}; +}} + +/* ---- Top bar ---- */ +QFrame#topBar {{ + background-color: {t['card']}; + border-bottom: 1px solid {t['border']}; +}} + +/* ---- Bottom bar ---- */ +QFrame#bottomBar {{ + background-color: {t['card']}; + border-top: 1px solid {t['border']}; +}} +QFrame#bottomBar QPushButton {{ + border: none; + padding: 4px 8px; + font-size: 12px; + min-width: 0; +}} +QFrame#bottomBar QLabel {{ + font-size: 12px; + color: {t['text_sec']}; +}} + +/* ---- Theme toggle button ---- */ +QPushButton#themeToggle {{ + border: none; + border-radius: 6px; + padding: 4px 8px; + font-size: 18px; + min-width: 0; +}} +QPushButton#themeToggle:hover {{ + background-color: {t['muted']}; +}} + +/* ---- Sub-category labels in sidebar ---- */ +QLabel#subCategoryLabel {{ + font-size: 11px; + font-weight: 600; + color: {t['text_sec']}; +}} +""" + + +# Pre-built stylesheets +LIGHT_STYLESHEET = _build_stylesheet(0) +DARK_STYLESHEET = _build_stylesheet(1) +HIGH_CONTRAST_STYLESHEET = _build_stylesheet(2) + +_STYLESHEETS = { + "light": LIGHT_STYLESHEET, + "dark": DARK_STYLESHEET, + "high-contrast": HIGH_CONTRAST_STYLESHEET, +} + +# Theme display info: (label, icon_char) +THEME_INFO = { + "light": ("Light", "\u2600"), # ☀ + "dark": ("Dark", "\u263E"), # ☾ + "high-contrast": ("High Contrast", "\u25D1"), # ◑ +} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def _build_palette(i: int) -> QPalette: + """Build a QPalette from the token set at index *i*.""" + palette = QPalette() + palette.setColor(QPalette.Window, QColor(_tok("bg", i))) + palette.setColor(QPalette.WindowText, QColor(_tok("text", i))) + palette.setColor(QPalette.Base, QColor(_tok("input_bg", i))) + palette.setColor(QPalette.AlternateBase, QColor(_tok("muted", i))) + palette.setColor(QPalette.ToolTipBase, QColor(_tok("tooltip_bg", i))) + palette.setColor(QPalette.ToolTipText, QColor(_tok("tooltip_fg", i))) + palette.setColor(QPalette.Text, QColor(_tok("text", i))) + palette.setColor(QPalette.Button, QColor(_tok("card", i))) + palette.setColor(QPalette.ButtonText, QColor(_tok("text", i))) + palette.setColor(QPalette.Highlight, QColor(_tok("accent", i))) + palette.setColor(QPalette.HighlightedText, QColor("#ffffff")) + palette.setColor(QPalette.PlaceholderText, QColor(_tok("text_sec", i))) + return palette + + +def apply_theme(app: QApplication, name: str) -> None: + """Apply the named theme (light, dark, or high-contrast).""" + idx = THEME_NAMES.index(name) if name in THEME_NAMES else 0 + app.setPalette(_build_palette(idx)) + app.setStyleSheet(_STYLESHEETS.get(name, LIGHT_STYLESHEET)) + + +def get_canvas_bg(name: str) -> str: + """Return the canvas background colour for the given theme.""" + idx = THEME_NAMES.index(name) if name in THEME_NAMES else 0 + return _tok("canvas_bg", idx) + + +def get_text_color(name: str) -> str: + """Return the primary text colour for the given theme.""" + idx = THEME_NAMES.index(name) if name in THEME_NAMES else 0 + return _tok("text", idx) + + +def get_separator_color(name: str) -> str: + """Return the separator colour for the given theme.""" + idx = THEME_NAMES.index(name) if name in THEME_NAMES else 0 + return _tok("separator", idx) + + +def get_text_sec_color(name: str) -> str: + """Return the secondary text colour for the given theme.""" + idx = THEME_NAMES.index(name) if name in THEME_NAMES else 0 + return _tok("text_sec", idx) + + +def next_theme(current: str) -> str: + """Return the next theme name in the cycle.""" + idx = THEME_NAMES.index(current) if current in THEME_NAMES else 0 + return THEME_NAMES[(idx + 1) % len(THEME_NAMES)] + + +# Backwards compat +def apply_light_theme(app: QApplication) -> None: + apply_theme(app, "light") + + +def apply_dark_theme(app: QApplication) -> None: + apply_theme(app, "dark") diff --git a/src/proteus/ui/top_bar.py b/src/proteus/ui/top_bar.py new file mode 100644 index 0000000..e8d5793 --- /dev/null +++ b/src/proteus/ui/top_bar.py @@ -0,0 +1,74 @@ +"""Top application bar with logo, title, and theme toggle.""" + +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QPixmap + +from proteus.core.utils import resource_path +from proteus.ui.theme import THEME_INFO, next_theme + + +class TopBar(QFrame): + """Slim header bar: logo + title on the left, theme toggle on the right.""" + + theme_toggle_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("topBar") + self.setFixedHeight(48) + + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 0, 16, 0) + layout.setSpacing(10) + + # Logo + try: + logo_path = resource_path("Proteus.png") + pixmap = QPixmap(logo_path) + if not pixmap.isNull(): + logo = QLabel() + logo.setPixmap( + pixmap.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) + logo.setFixedSize(28, 28) + layout.addWidget(logo) + except Exception: + pass + + # Title + self._title = QLabel("Proteus") + self._title.setObjectName("appTitle") + layout.addWidget(self._title) + + layout.addStretch() + + # Theme toggle + self._theme_btn = QPushButton() + self._theme_btn.setObjectName("themeToggle") + self._theme_btn.setCursor(Qt.PointingHandCursor) + self._theme_btn.setFixedSize(36, 36) + self._theme_btn.clicked.connect(self.theme_toggle_clicked) + layout.addWidget(self._theme_btn) + + self._current_theme = "light" + self._update_theme_button("light") + + def _update_theme_button(self, theme_name: str) -> None: + info = THEME_INFO.get(theme_name, THEME_INFO["light"]) + label, icon = info + nxt = next_theme(theme_name) + nxt_label = THEME_INFO.get(nxt, THEME_INFO["light"])[0] + self._theme_btn.setText(icon) + self._theme_btn.setToolTip(f"Current: {label} — click for {nxt_label}") + + def set_theme(self, theme_name: str) -> None: + """Update the toggle button and title style for the current theme.""" + self._current_theme = theme_name + self._update_theme_button(theme_name) + # Title colour follows the theme text colour + from proteus.ui.theme import get_text_color + color = get_text_color(theme_name) + self._title.setStyleSheet( + f"font-size: 15px; font-weight: 600; color: {color}; border: none;" + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_pca.py b/tests/test_pca.py new file mode 100644 index 0000000..e83b5f3 --- /dev/null +++ b/tests/test_pca.py @@ -0,0 +1,88 @@ +"""Unit tests for core/pca.py.""" + +import numpy as np +import pytest + +from proteus.core.pca import pca_multiband, pca_multiband_svd_variant + + +def _make_test_images(n=4, h=20, w=20): + """Create n random grayscale test images.""" + rng = np.random.RandomState(42) + return [rng.randint(0, 256, (h, w), dtype=np.uint8) for _ in range(n)] + + +class TestPcaMultiband: + def test_basic_output(self): + imgs = _make_test_images(4) + result = pca_multiband(imgs) + assert "pcs" in result + assert "explained" in result + assert "mean" in result + assert len(result["pcs"]) > 0 + assert result["pcs"][0].shape == (20, 20) + assert result["pcs"][0].dtype == np.uint8 + + def test_explained_variance_sums_to_1(self): + imgs = _make_test_images(5) + result = pca_multiband(imgs) + total = sum(result["explained"]) + assert abs(total - 1.0) < 1e-6 + + def test_with_roi(self): + imgs = _make_test_images(4) + result = pca_multiband(imgs, roi=(2, 2, 15, 15)) + assert len(result["pcs"]) > 0 + assert result["pcs"][0].shape == (20, 20) # full image output + + def test_min_3_images(self): + imgs = _make_test_images(2) + with pytest.raises(ValueError, match="at least 3"): + pca_multiband(imgs) + + def test_mismatched_sizes(self): + imgs = [ + np.zeros((20, 20), dtype=np.uint8), + np.zeros((20, 20), dtype=np.uint8), + np.zeros((30, 30), dtype=np.uint8), + ] + with pytest.raises(ValueError, match="same size"): + pca_multiband(imgs) + + def test_max_8_components(self): + imgs = _make_test_images(10) + result = pca_multiband(imgs) + assert len(result["pcs"]) <= 8 + + +class TestPcaSvdVariant: + def test_basic_output(self): + imgs = _make_test_images(4) + result = pca_multiband_svd_variant(imgs) + assert "pcs" in result + assert "explained" in result + assert "U" in result + assert "S" in result + assert len(result["pcs"]) > 0 + assert result["pcs"][0].shape == (20, 20) + + def test_explained_variance_sums_to_1(self): + imgs = _make_test_images(5) + result = pca_multiband_svd_variant(imgs) + total = sum(result["explained"]) + assert abs(total - 1.0) < 1e-6 + + def test_with_roi(self): + imgs = _make_test_images(4) + result = pca_multiband_svd_variant(imgs, roi=(2, 2, 15, 15)) + assert len(result["pcs"]) > 0 + + def test_min_3_images(self): + imgs = _make_test_images(2) + with pytest.raises(ValueError, match="at least 3"): + pca_multiband_svd_variant(imgs) + + def test_max_components(self): + imgs = _make_test_images(4) + result = pca_multiband_svd_variant(imgs, max_components=2) + assert len(result["pcs"]) == 2 diff --git a/tests/test_processing.py b/tests/test_processing.py new file mode 100644 index 0000000..3c7c5c3 --- /dev/null +++ b/tests/test_processing.py @@ -0,0 +1,161 @@ +"""Unit tests for core/processing.py.""" + +import numpy as np +import pytest + +from proteus.core.processing import ( + to_uint8, ensure_gray, ensure_color, normalize_0_255, + hist_equalize, pseudocolor_jet, otsu_binarize, fixed_binarize, + power_transform, blur_divide, denoise_gaussian, rotate_90, +) + + +class TestToUint8: + def test_already_uint8(self): + img = np.array([[0, 128, 255]], dtype=np.uint8) + result = to_uint8(img) + assert result.dtype == np.uint8 + np.testing.assert_array_equal(result, img) + + def test_float_image(self): + img = np.array([[0.0, 128.5, 300.0]], dtype=np.float32) + result = to_uint8(img) + assert result.dtype == np.uint8 + np.testing.assert_array_equal(result, [[0, 128, 255]]) + + def test_none_returns_none(self): + assert to_uint8(None) is None + + +class TestEnsureGray: + def test_already_gray(self): + img = np.zeros((10, 10), dtype=np.uint8) + result = ensure_gray(img) + assert result.ndim == 2 + + def test_bgr_to_gray(self): + img = np.zeros((10, 10, 3), dtype=np.uint8) + result = ensure_gray(img) + assert result.ndim == 2 + assert result.shape == (10, 10) + + def test_none_returns_none(self): + assert ensure_gray(None) is None + + +class TestEnsureColor: + def test_already_color(self): + img = np.zeros((10, 10, 3), dtype=np.uint8) + result = ensure_color(img) + assert result.ndim == 3 + + def test_gray_to_bgr(self): + img = np.zeros((10, 10), dtype=np.uint8) + result = ensure_color(img) + assert result.ndim == 3 + assert result.shape == (10, 10, 3) + + def test_none_returns_none(self): + assert ensure_color(None) is None + + +class TestNormalize: + def test_basic(self): + img = np.array([[0, 50, 100]], dtype=np.uint8) + result = normalize_0_255(img) + assert result.dtype == np.uint8 + assert result.min() == 0 + assert result.max() == 255 + + def test_constant_image(self): + img = np.full((5, 5), 128, dtype=np.uint8) + result = normalize_0_255(img) + np.testing.assert_array_equal(result, np.zeros((5, 5), dtype=np.uint8)) + + def test_none_returns_none(self): + assert normalize_0_255(None) is None + + +class TestHistEqualize: + def test_grayscale(self): + img = np.random.randint(0, 256, (50, 50), dtype=np.uint8) + result = hist_equalize(img) + assert result.shape == img.shape + assert result.dtype == np.uint8 + + def test_color(self): + img = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8) + result = hist_equalize(img) + assert result.shape == img.shape + + +class TestPseudocolorJet: + def test_output_is_color(self): + gray = np.random.randint(0, 256, (20, 20), dtype=np.uint8) + result = pseudocolor_jet(gray) + assert result.ndim == 3 + assert result.shape == (20, 20, 3) + + +class TestBinarize: + def test_otsu(self): + img = np.random.randint(0, 256, (30, 30), dtype=np.uint8) + result = otsu_binarize(img) + assert set(np.unique(result)).issubset({0, 255}) + + def test_fixed(self): + img = np.array([[50, 150]], dtype=np.uint8) + result = fixed_binarize(img, thresh=128) + np.testing.assert_array_equal(result, [[0, 255]]) + + +class TestPowerTransform: + def test_gamma_1(self): + img = np.array([[100, 200]], dtype=np.uint8) + result = power_transform(img, gamma=1.0) + np.testing.assert_array_equal(result, img) + + def test_gamma_greater_than_1(self): + img = np.array([[128]], dtype=np.uint8) + result = power_transform(img, gamma=2.0) + assert result[0, 0] < 128 # darker + + def test_partial_invert(self): + img = np.array([[100, 200]], dtype=np.uint8) + result = power_transform(img, gamma=1.0, partial_invert=True, pivot=128) + assert result[0, 0] == 100 # below pivot, unchanged + assert result[0, 1] == 55 # above pivot, inverted + + +class TestBlurDivide: + def test_output_shape(self): + img = np.random.randint(0, 256, (50, 50), dtype=np.uint8) + result = blur_divide(img, ksize=5) + assert result.shape == img.shape + + def test_even_ksize_corrected(self): + img = np.random.randint(0, 256, (50, 50), dtype=np.uint8) + result = blur_divide(img, ksize=4) # should become 5 + assert result.shape == img.shape + + +class TestDenoiseGaussian: + def test_output_shape(self): + img = np.random.randint(0, 256, (30, 30), dtype=np.uint8) + result = denoise_gaussian(img, ksize=3) + assert result.shape == img.shape + + +class TestRotate90: + def test_rotate_left(self): + img = np.array([[1, 2], [3, 4]], dtype=np.uint8) + result = rotate_90(img, "left") + assert result.shape == (2, 2) + + def test_rotate_right(self): + img = np.array([[1, 2], [3, 4]], dtype=np.uint8) + result = rotate_90(img, "right") + assert result.shape == (2, 2) + + def test_none_returns_none(self): + assert rotate_90(None, "left") is None