Source code for cosedaUI.image_tools

"""Utilities for inspecting and exporting images in the COSEDA UI."""

import os

from PyQt6.QtCore import Qt, QRect, QSize, QPoint, pyqtSignal
from PyQt6.QtWidgets import (
    QRubberBand, QDialog, QLabel, QVBoxLayout,
    QMessageBox, QFileDialog
)
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont

_SCALEBAR_STEPS_NM = [10, 20, 50, 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 2e4, 5e4,
                      1e5, 2e5, 5e5, 1e6]


[docs] def draw_pixmap_scalebar(pixmap, x_span_m, font_frac=0.045, margin_x_frac=0.05): """Draw a 1-2-5 'nice' scalebar (nm/µm/mm) bottom-left onto a QPixmap. ``x_span_m`` is the physical width the pixmap spans, in metres. Fractions size the font / horizontal inset relative to the pixmap so it works at any scale. """ if not (x_span_m and x_span_m > 0): return W, H = pixmap.width(), pixmap.height() target_nm = 0.15 * x_span_m * 1e9 nice = _SCALEBAR_STEPS_NM[0] for step in _SCALEBAR_STEPS_NM: if step <= target_nm: nice = step bar_px = max(1, int((nice * 1e-9) / x_span_m * W)) label = (f"{nice:g} nm" if nice < 1e3 else f"{nice / 1e3:g} µm" if nice < 1e6 else f"{nice / 1e6:g} mm") painter = QPainter(pixmap) try: pad = max(3, int(H * 0.02)) thick = max(2, int(H * 0.012)) font = QFont() font.setPixelSize(max(9, int(W * font_frac))) painter.setFont(font) fm = painter.fontMetrics() tw, th = fm.horizontalAdvance(label), fm.height() box_w = max(bar_px, tw) + 2 * pad box_h = thick + th + 3 * pad margin_x = max(6, int(W * margin_x_frac)) margin_y = max(4, int(H * 0.03)) box_left, box_top = margin_x - pad, H - margin_y - box_h painter.fillRect(box_left, box_top, box_w, box_h, QColor(0, 0, 0, 140)) bar_top = box_top + pad painter.fillRect(margin_x, bar_top, bar_px, thick, QColor(255, 255, 255)) painter.setPen(QColor(255, 255, 255)) painter.drawText(margin_x, bar_top + thick + pad + fm.ascent(), label) finally: painter.end()
from PIL.ImageQt import ImageQt from PIL import Image, ImageDraw, ImageFont import numpy as np
[docs] class HoverLabel(QLabel): """QLabel that emits cursor position while hovering (mouse tracking on).""" hovered = pyqtSignal(int, int) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setMouseTracking(True)
[docs] def mouseMoveEvent(self, event): pos = event.position() self.hovered.emit(int(pos.x()), int(pos.y())) super().mouseMoveEvent(event)
[docs] class ImageInspector: """Interactive ROI inspector that shows zoomed pixel values from the displayed image.""" def __init__(self, image_label, original_image_getter, parent): """ Create an inspector. image_label: QLabel showing the pixmap to inspect. original_image_getter: callable returning the underlying PIL image (or None). parent: parent widget for dialogs. """ self.image_label = image_label self.original_image_getter = original_image_getter self.parent = parent self.inspect_mode = False self.origin = None self.rubber_band = None
[docs] def start_inspect(self): """Enable inspection mode and set crosshair cursor.""" if self.original_image_getter() is None: QMessageBox.warning(self.parent, "No Image", "Load an image first.") return self.inspect_mode = True self.image_label.setCursor(Qt.CursorShape.CrossCursor)
[docs] def on_mouse_press(self, event): """Begin rubber-band selection when in inspect mode.""" if self.inspect_mode and event.button() == Qt.MouseButton.LeftButton: self.origin = event.position().toPoint() self.rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.image_label) self.rubber_band.setGeometry(QRect(self.origin, QSize())) self.rubber_band.show() event.accept() else: event.ignore()
[docs] def on_mouse_move(self, event): """Update rubber-band rectangle while dragging.""" if self.inspect_mode and self.rubber_band: current = event.position().toPoint() rect = QRect(self.origin, current).normalized() self.rubber_band.setGeometry(rect) event.accept() else: event.ignore()
[docs] def on_mouse_release(self, event): """Finalize selection and open the inspection popup.""" if (self.inspect_mode and event.button() == Qt.MouseButton.LeftButton and self.rubber_band): rect = self.rubber_band.geometry() self.rubber_band.hide() self.inspect_mode = False self.image_label.setCursor(Qt.CursorShape.ArrowCursor) self.open_popup(rect) event.accept() else: event.ignore()
[docs] def open_popup(self, rect): """Open a modal dialog showing magnified pixel values for the selected ROI.""" original_image = self.original_image_getter() pixmap = self.image_label.pixmap() if pixmap is None or original_image is None: return pw, ph = pixmap.width(), pixmap.height() iw, ih = original_image.size scale_x, scale_y = iw / pw, ih / ph x1 = int(rect.x() * scale_x) y1 = int(rect.y() * scale_y) x2 = min(int((rect.x() + rect.width()) * scale_x), iw) y2 = min(int((rect.y() + rect.height()) * scale_y), ih) # Prefer raw intensity array if available raw = getattr(self.parent, 'raw_image', None) if raw is not None: # full-depth 2D numpy array crop_arr = raw[y1:y2, x1:x2] # build an 8-bit preview for display display_crop = Image.fromarray( np.clip(crop_arr, 0, 255).astype(np.uint8), mode='L' ) else: # fallback: convert displayed image to grayscale display_crop = original_image.crop((x1, y1, x2, y2)).convert('L') # Magnify for clarity w, h = display_crop.size cell_size = 20 scaled = display_crop.resize((w*cell_size, h*cell_size), resample=Image.NEAREST) scaled = scaled.convert('RGB') draw = ImageDraw.Draw(scaled) font = ImageFont.load_default() # Annotate true raw values (full bit depth) for yy in range(h): for xx in range(w): if raw is not None: val = int(crop_arr[yy, xx]) else: val = display_crop.getpixel((xx, yy)) tx = xx*cell_size + cell_size//6 ty = yy*cell_size + cell_size//6 draw.text((tx, ty), str(val), fill='red', font=font) dlg = QDialog(self.parent) dlg.setWindowTitle("Inspect Image") layout = QVBoxLayout(dlg) annotated_qimg = ImageQt(scaled) pixmap = QPixmap.fromImage(annotated_qimg) zoom_label = HoverLabel() zoom_label.setPixmap(pixmap) layout.addWidget(zoom_label) coord_label = QLabel("Coord: -") layout.addWidget(coord_label) dlg.resize(pixmap.width(), pixmap.height()) def on_hover(px: int, py: int): if 0 <= px < pixmap.width() and 0 <= py < pixmap.height(): cx = px // cell_size cy = py // cell_size if 0 <= cx < w and 0 <= cy < h: val = int(crop_arr[cy, cx]) if raw is not None else display_crop.getpixel((cx, cy)) coord_label.setText(f"Coord: {x1+cx}, {y1+cy} (ROI {cx}, {cy}) Value: {val}") return coord_label.setText("Coord: -") zoom_label.hovered.connect(on_hover) dlg.exec()
[docs] def export_raw_image(parent, raw_array): """Prompt for a filename and save the raw numpy array preserving its bit depth.""" path, _selected_filter = QFileDialog.getSaveFileName( parent, 'Save Raw Image', filter='TIFF (*.tif *.tiff)' ) if not path: return if raw_array is None: QMessageBox.warning(parent, "Export Failed", "No raw image data is available.") return if not os.path.splitext(path)[1]: path = f"{path}.tif" arr = raw_array dt = arr.dtype # Choose PIL mode based on dtype if dt == np.uint8: mode = 'L' elif dt == np.uint16: mode = 'I;16' elif dt == np.float32: mode = 'F' else: # fallback: cast to float32 TIFF arr = arr.astype(np.float32) mode = 'F' img = Image.fromarray(arr, mode=mode) try: img.save(path) except Exception as exc: QMessageBox.critical(parent, "Export Failed", f"Could not save raw image:\n{exc}")
[docs] def export_display_image(parent, pil_image): """Prompt for a filename and save the displayed PIL image (RGB) as PNG or JPEG.""" path, selected_filter = QFileDialog.getSaveFileName( parent, 'Save Displayed Image', filter='PNG (*.png);;JPEG (*.jpg *.jpeg)' ) if not path: return if pil_image is None: QMessageBox.warning(parent, "Export Failed", "No display image is available.") return if not os.path.splitext(path)[1]: if "JPEG" in selected_filter.upper(): path = f"{path}.jpg" else: path = f"{path}.png" try: pil_image.save(path) except Exception as exc: QMessageBox.critical(parent, "Export Failed", f"Could not save displayed image:\n{exc}")
[docs] def export_display_pixmap(parent, pixmap): """Prompt for a filename and save the displayed QPixmap as PNG or JPEG.""" path, selected_filter = QFileDialog.getSaveFileName( parent, 'Save Displayed Image', filter='PNG (*.png);;JPEG (*.jpg *.jpeg)' ) if not path: return if pixmap is None or pixmap.isNull(): QMessageBox.warning(parent, "Export Failed", "No display image is available.") return if not os.path.splitext(path)[1]: if "JPEG" in selected_filter.upper(): path = f"{path}.jpg" else: path = f"{path}.png" if not pixmap.save(path): QMessageBox.critical(parent, "Export Failed", "Could not save displayed image.")