"""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 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.")