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
+
+
+
+
+
+
+ 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