diff --git a/docs/configuration.md b/docs/configuration.md index ac41c1d..24653e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,7 @@ window: display: mode: single # single | tile tile_columns: null # null picks a roughly square grid + zoom: 1.0 # 1.0 fits each new frame to its tile tools: header: true # FITS header panel @@ -95,6 +96,7 @@ the defaults for its other keys. | --- | --- | --- | --- | | `mode` | `single` or `tile` | `single` | See [](frames) | | `tile_columns` | integer or `null` | `null` | Positive; `null` picks a square-ish grid | +| `zoom` | number | `1.0` | 0.015625 to 512; the zoom new frames open at, see [](frames.md#zoom) | ### `tools.header` diff --git a/docs/frames.md b/docs/frames.md index 4e198ea..8ac3386 100644 --- a/docs/frames.md +++ b/docs/frames.md @@ -1,4 +1,4 @@ -# Frames and scales +# Frames, scales and zoom ## Frames @@ -27,6 +27,42 @@ roughly square arrangement from however many frames are open, which is usually what you want; fixing it to `1` gives a single column, which is how the `detector` profile stacks a signal frame above its reset frame. +(zoom)= +## Zoom + +**View → Zoom** magnifies the current frame. Like the scale, the zoom belongs +to the frame rather than to the window, so in `tile` mode one image can be +zoomed into a corner while its neighbour still shows the whole field. + +| Command | What it does | Shortcut | +| --- | --- | --- | +| Zoom In | Doubles the magnification | Ctrl++ | +| Zoom Out | Halves it | Ctrl+- | +| Zoom to Fit | The whole frame in its tile, centred | Ctrl+0 | +| Actual Pixels | One screen pixel per data pixel | Ctrl+9 | + +The mouse does the same thing more directly: turn the wheel over the image to +zoom about the pixel under the pointer, and drag with the left button to pan a +frame that no longer fits in its tile. + +Frames open fitted to their tile. A configuration can start them magnified +instead, which saves repeating the same keystrokes every session on a detector +nobody looks at whole: + +```yaml +display: + zoom: 4.0 # 1.0 fits the frame to its tile +``` + +Every bundled profile sets it explicitly, so a profile copied as a starting +point already has the key to edit. It is where new frames *start*, not a floor: +**Zoom to Fit** still shows the whole frame. + +A zoom survives everything that does not change the frame: resizing the window, +tiling and untiling, switching scale, and the next image of a +[live stream](live.md), so a detector corner stays under the eye exposure after +exposure. + ## Scales **View → Scale** decides how pixel values map onto the brightness of the diff --git a/docs/index.md b/docs/index.md index f177edd..0433bb8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ Open your first image and find your way around the window in five minutes. :link: frames :link-type: doc -Frames, single and tiled layouts, and the linear and log intensity scales. +Frames, single and tiled layouts, intensity scales, and zooming. ::: :::{grid-item-card} {octicon}`graph` Inspecting pixels diff --git a/docs/inspecting.md b/docs/inspecting.md index 91a251a..30b7405 100644 --- a/docs/inspecting.md +++ b/docs/inspecting.md @@ -30,7 +30,8 @@ than as a number, and colour frames report one sample per channel. A frame is usually shown smaller than it is, in which case several data pixels share one screen pixel and the readout names one of them. It always names the -pixel whose count it shows. +pixel whose count it shows. [Zooming in](frames.md#zoom) past 1:1 separates +them, so every pixel becomes a block you can point at individually. The readout is always available: it needs no configuration, adds no panel, and does no work at all until the cursor is over a frame. It sits beside the status diff --git a/docs/quickstart.md b/docs/quickstart.md index ffe0976..858daaa 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -28,7 +28,7 @@ every FITS file in a folder. | The image area | One frame, or a grid of tiles in `tile` mode | | **File** menu | Opening images and directories, quitting | | **Frame** menu | Moving between frames, deleting them | -| **View** menu | Single vs tile layout, intensity scale, panel visibility | +| **View** menu | Single vs tile layout, intensity scale, zoom, panel visibility | | **Tools** menu | Whatever optional tools your configuration switched on | | Status bar, left | Messages, such as errors from a tool | | Status bar, right | The [hover readout](inspecting.md#hover-readout) | @@ -47,6 +47,8 @@ header panel is opt-in. See [](configuration) for how to turn them on. | Ctrl+[ | Previous frame | | Ctrl+3 | Linear scale | | Ctrl+4 | Log scale | +| Ctrl++ / Ctrl+- | Zoom in and out | +| Ctrl+0 | Fit the frame to its tile | | Ctrl+W | Close the current frame | | Ctrl+Q | Quit | @@ -94,6 +96,6 @@ own YAML file and pass `--config`; [](configuration) covers the format. ## Next steps -- [](frames) for frames, layouts, and intensity scales +- [](frames) for frames, layouts, intensity scales, and zooming - [](inspecting) for reading pixel values and summary statistics - [](live) for following a detector in real time diff --git a/src/atlas/config/profiles/detector.yaml b/src/atlas/config/profiles/detector.yaml index 3da427c..c5b54ed 100644 --- a/src/atlas/config/profiles/detector.yaml +++ b/src/atlas/config/profiles/detector.yaml @@ -2,6 +2,7 @@ display: mode: tile tile_columns: 1 + zoom: 1.0 tools: header: true histogram: true diff --git a/src/atlas/config/profiles/hispec_fei.yaml b/src/atlas/config/profiles/hispec_fei.yaml index 4651522..4269144 100644 --- a/src/atlas/config/profiles/hispec_fei.yaml +++ b/src/atlas/config/profiles/hispec_fei.yaml @@ -1,6 +1,7 @@ # HISPEC FEI display: mode: single + zoom: 1.0 tools: header: true histogram: true diff --git a/src/atlas/config/profiles/minimal.yaml b/src/atlas/config/profiles/minimal.yaml index 4afdb03..330bab4 100644 --- a/src/atlas/config/profiles/minimal.yaml +++ b/src/atlas/config/profiles/minimal.yaml @@ -1,6 +1,7 @@ # Nothing but image display: no side panels, no tools. display: mode: single + zoom: 1.0 tools: header: false histogram: false diff --git a/src/atlas/config/profiles/viewer.yaml b/src/atlas/config/profiles/viewer.yaml index 99507d8..0cb43b9 100644 --- a/src/atlas/config/profiles/viewer.yaml +++ b/src/atlas/config/profiles/viewer.yaml @@ -1,6 +1,7 @@ # General-purpose FITS viewing: images, headers and pixel histograms. display: mode: single + zoom: 1.0 tools: header: true histogram: true diff --git a/src/atlas/config/schema.py b/src/atlas/config/schema.py index b186d5c..4faa027 100644 --- a/src/atlas/config/schema.py +++ b/src/atlas/config/schema.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field, fields, is_dataclass from typing import Optional +from atlas.model.zoom import ZOOM_FIT, ZOOM_MAX, ZOOM_MIN + class ConfigError(ValueError): """Raised when a configuration file or command-line override is not valid.""" @@ -28,10 +30,11 @@ def validate(self, path): @dataclass class DisplayConfig: - """How loaded frames are laid out.""" + """How loaded frames are laid out, and how far into them they open.""" mode: str = "single" - # None means "choose a roughly square grid from the frame count". tile_columns: Optional[int] = None + # The zoom every new frame starts at, relative to fitting its tile + zoom: float = ZOOM_FIT def validate(self, path): if self.mode not in DISPLAY_MODES: @@ -42,6 +45,11 @@ def validate(self, path): raise ConfigError( f"{path}.tile_columns must be a positive integer or null, " f"got {self.tile_columns!r}") + if (isinstance(self.zoom, bool) or not isinstance(self.zoom, (int, float)) + or not ZOOM_MIN <= self.zoom <= ZOOM_MAX): + raise ConfigError( + f"{path}.zoom must be a number between {ZOOM_MIN} and " + f"{ZOOM_MAX:g}, got {self.zoom!r}") @dataclass diff --git a/src/atlas/model/frame.py b/src/atlas/model/frame.py index ae996be..52ddb30 100644 --- a/src/atlas/model/frame.py +++ b/src/atlas/model/frame.py @@ -2,6 +2,8 @@ import itertools import os +from .zoom import ZOOM_FIT, clamp_zoom + class Frame: """ @@ -9,7 +11,8 @@ class Frame: A frame owns its data, header and rendered pixmap. Display state that is per-image rather than per-window (zoom, scale, colormap) belongs here too - as those features arrive. + as those features arrive, so tiled frames can be zoomed and scaled + independently of each other. """ _ids = itertools.count(1) @@ -21,6 +24,22 @@ def __init__(self, data, header, file_name=""): self.file_name = file_name self.pixmap = None self.scale = "linear" + self.zoom = ZOOM_FIT + self.center = None + + def set_zoom(self, factor): + """ + Zooms to a factor relative to the fit. + + Returns: + bool: True when the frame's view actually changed, so a caller + can avoid repainting for a zoom that was already at the limit. + """ + factor = clamp_zoom(factor) + if factor == self.zoom: + return False + self.zoom = factor + return True @property def label(self): diff --git a/src/atlas/model/pixel.py b/src/atlas/model/pixel.py index 68f32d4..2f8cd35 100644 --- a/src/atlas/model/pixel.py +++ b/src/atlas/model/pixel.py @@ -31,7 +31,7 @@ def text(self): return format_count(self.value) -def locate_pixel(point, label_size, displayed_size, source_size): +def locate_pixel(point, label_size, displayed_size, source_size, source_origin=(0, 0)): """ Maps a point on a frame's image label to an index into its data. @@ -43,13 +43,16 @@ def locate_pixel(point, label_size, displayed_size, source_size): point (tuple): (x, y) in the image label's own coordinates. label_size (tuple): (width, height) of the image label. displayed_size (tuple): (width, height) of the scaled pixmap. - source_size (tuple): (width, height) of the unscaled pixmap, which is - also the width and height of the data being displayed. + source_size (tuple): (width, height) of the region of the pixmap that + was drawn, which is the whole pixmap until the frame is zoomed. + source_origin (tuple): (x, y) of that region's top-left corner in the + pixmap. Zooming draws a crop, so the index has to be counted from + the crop's corner rather than the image's. Returns: tuple: (column, row), 0-based, or None when the point is not on the - image. Everything outside the pixmap is a miss, including the label's - own letterboxing, which belongs to no pixel. + image. Everything outside the drawn region is a miss, including the + label's own letterboxing, which belongs to no pixel. """ displayed_width, displayed_height = displayed_size source_width, source_height = source_size @@ -62,8 +65,8 @@ def locate_pixel(point, label_size, displayed_size, source_size): if not (0 <= x < displayed_width and 0 <= y < displayed_height): return None - return (x * source_width // displayed_width, - y * source_height // displayed_height) + return (source_origin[0] + x * source_width // displayed_width, + source_origin[1] + y * source_height // displayed_height) def read_pixel(plane, column, row): diff --git a/src/atlas/model/zoom.py b/src/atlas/model/zoom.py new file mode 100644 index 0000000..091aaa8 --- /dev/null +++ b/src/atlas/model/zoom.py @@ -0,0 +1,92 @@ +# Standard Library Imports +import math + +# A frame's zoom is a factor on top of the fit: 1.0 shows the whole image in +# whatever space the tile has, 2.0 shows half of it at twice the size. +ZOOM_FIT = 1.0 +ZOOM_MIN = 1.0 / 64.0 +ZOOM_MAX = 512.0 +ZOOM_STEP = 2.0 +WHEEL_STEP = 1.2 + + +def clamp_zoom(factor): + """Holds a zoom factor inside the range the viewer will render.""" + return min(ZOOM_MAX, max(ZOOM_MIN, float(factor))) + + +def fit_scale(label_size, source_size): + """ + Screen pixels per data pixel when the whole image is fitted to a tile. + + Args: + label_size (tuple): (width, height) of the image label. + source_size (tuple): (width, height) of the frame's pixmap. + + Returns: + float: the scale factor, or 0.0 when either size is degenerate, which + is what a tile mid-layout has. + """ + label_width, label_height = label_size + source_width, source_height = source_size + if min(label_width, label_height, source_width, source_height) <= 0: + return 0.0 + return min(label_width / source_width, label_height / source_height) + + +def _span(available, scale, limit): + """ + How many data pixels `available` screen pixels hold, at most `limit`. + """ + if scale <= 0: + return limit + return max(1, min(limit, math.ceil(available / scale - 1e-6))) + + +def visible_region(label_size, source_size, zoom=ZOOM_FIT, center=None): + """ + The part of a frame's pixmap a tile should draw, and how big to draw it. + + Args: + label_size (tuple): (width, height) of the image label. + source_size (tuple): (width, height) of the frame's pixmap. + zoom (float): factor on top of the fit, 1.0 being the whole image. + center (tuple): (x, y) in source pixels to centre the view on, or None + for the middle of the image. + + Returns: + tuple: (x, y, width, height, scale), the source rectangle to draw. + """ + fit = fit_scale(label_size, source_size) + if fit <= 0: + return None + + zoom = clamp_zoom(zoom) + scale = fit * zoom + source_width, source_height = source_size + width = _span(label_size[0], scale, source_width) + height = _span(label_size[1], scale, source_height) + + centre_x = source_width / 2 if center is None else center[0] + centre_y = source_height / 2 if center is None else center[1] + x = max(0, min(source_width - width, round(centre_x - width / 2))) + y = max(0, min(source_height - height, round(centre_y - height / 2))) + return (x, y, width, height, scale) + + +def anchored_center(point, center, ratio): + """ + The view centre that keeps `point` where it is while the zoom changes. + + Args: + point (tuple): (x, y) in source pixels, the anchor to hold still. + center (tuple): (x, y) in source pixels, the current view centre. + ratio (float): how much the scale is about to be multiplied by. + + Returns: + tuple: the new (x, y) view centre. + """ + if ratio <= 0: + return center + return (point[0] - (point[0] - center[0]) / ratio, + point[1] - (point[1] - center[1]) / ratio) diff --git a/src/atlas/view/frame_grid.py b/src/atlas/view/frame_grid.py index f46b618..1e1342b 100644 --- a/src/atlas/view/frame_grid.py +++ b/src/atlas/view/frame_grid.py @@ -39,11 +39,9 @@ def __init__(self, view_model, parent=None): self.view_model.frames_changed.connect(self.refresh) self.view_model.current_changed.connect(self.refresh) self.view_model.display_mode_changed.connect(self.refresh) - # Re-read rather than clear: under a live stream the cursor is usually - # still, and watching one pixel's counts change is the point of resting - # it there. A frame that has gone away is dropped by report_pixel. self.view_model.frames_changed.connect(self.report_pixel) self.view_model.current_changed.connect(self.report_pixel) + self.view_model.view_changed.connect(self.redraw) def column_count(self, frame_count): """ @@ -75,6 +73,21 @@ def select(self, position): if frame in self.view_model.frames: self.view_model.set_current_index(self.view_model.frames.index(frame)) + def redraw(self): + """Redraws every tile from its frame's pixmap, without relaying out.""" + for widget in self.widgets: + widget.rescale() + + def zoom_current_to_actual(self): + """ + Zooms the current frame to one screen pixel per data pixel. + """ + frame = self.view_model.current_frame + for widget in self.widgets: + if widget.frame is frame and widget.display_scale() > 0: + self.view_model.set_current_zoom(frame.zoom / widget.display_scale()) + return + def on_hover(self, position): """Records the pixel a tile reports under the cursor, and reports it on.""" self.hover = position diff --git a/src/atlas/view/frame_widget.py b/src/atlas/view/frame_widget.py index 96b6ab6..0071d97 100644 --- a/src/atlas/view/frame_widget.py +++ b/src/atlas/view/frame_widget.py @@ -1,18 +1,19 @@ # Third-Party Library Imports from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QSizePolicy -from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtCore import Qt, pyqtSignal, QEvent, QRect, QSize from PyQt5.QtGui import QPixmap from atlas.model.pixel import locate_pixel +from atlas.model.zoom import WHEEL_STEP, anchored_center, visible_region class FrameWidget(QWidget): """ Shows one frame: a caption above an aspect-preserving image. - The image is always rescaled from the frame's original pixmap rather than + The image is always redrawn from the frame's original pixmap rather than from the previously scaled one, so repeated resizing does not compound - quality loss. + quality loss, and zooming in never enlarges an already-shrunken copy. """ clicked = pyqtSignal() @@ -24,8 +25,8 @@ def __init__(self, parent=None): super().__init__(parent) self.frame = None self.is_current = False - # Without tracking, Qt only delivers moves while a button is held, and - # a readout that needs a drag to update is not a hover readout. + self.region = None + self.drag = None # where a pan was last seen self.setMouseTracking(True) layout = QVBoxLayout() @@ -49,6 +50,7 @@ def __init__(self, parent=None): # one frame squeeze its neighbours out of the grid. self.image.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) self.image.setMouseTracking(True) + self.image.installEventFilter(self) layout.addWidget(self.caption) layout.addWidget(self.image) @@ -60,6 +62,7 @@ def set_frame(self, frame): if frame is None: self.caption.setText("") self.image.setPixmap(QPixmap()) + self.region = None return self.caption.setText(frame.label) @@ -78,16 +81,67 @@ def apply_border(self): "QLabel { border: none; }") def rescale(self): - """Fits the frame's pixmap to the space currently available.""" + """ + Draws whatever the frame's zoom and pan currently select. + """ + self.region = None if self.frame is None or self.frame.pixmap is None: return - target = self.image.size() - if target.width() <= 0 or target.height() <= 0: + source = self.frame.pixmap + region = visible_region((self.image.width(), self.image.height()), + (source.width(), source.height()), + self.frame.zoom, self.frame.center) + if region is None: return - self.image.setPixmap(self.frame.pixmap.scaled( - target, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + x, y, width, height, scale = region + self.region = region + self.frame.center = (x + width / 2, y + height / 2) + + # Nearest neighbour as soon as a data pixel covers more than a screen pixel + mode = Qt.FastTransformation if scale >= 1 else Qt.SmoothTransformation + self.image.setPixmap(source.copy(QRect(x, y, width, height)).scaled( + QSize(round(width * scale), round(height * scale)), + Qt.KeepAspectRatio, mode)) + + def display_scale(self): + """Screen pixels per data pixel as drawn, or 0 when nothing is drawn.""" + return 0.0 if self.region is None else self.region[4] + + def zoom_by(self, ratio, anchor=None): + """Zooms this tile by a ratio, holding a data pixel still if given one.""" + if self.frame is None: + return + + before = self.frame.zoom + if not self.frame.set_zoom(before * ratio): + return # already as far in or out as the viewer will go + + if anchor is not None and self.frame.center is not None: + self.frame.center = anchored_center(anchor, self.frame.center, + self.frame.zoom / before) + self.rescale() + + def pan_by(self, dx, dy): + """ + Moves the view by a distance in screen pixels, dragging the image with it. + + Returns: + bool: True when the view moved, which it does not when the whole + frame is already on screen and there is nowhere to go. + """ + scale = self.display_scale() + if scale <= 0 or self.frame.center is None: + return False + _, _, width, height, _ = self.region + if (width, height) == (self.frame.pixmap.width(), self.frame.pixmap.height()): + return False + + self.frame.center = (self.frame.center[0] - dx / scale, + self.frame.center[1] - dy / scale) + self.rescale() + return True def resizeEvent(self, event): # pylint: disable=invalid-name """Qt override: keep the image fitted as the widget changes size.""" @@ -102,39 +156,63 @@ def pixel_at(self, position): tuple: (column, row), 0-based, or None when the point is not on the image. """ - if self.frame is None or self.frame.pixmap is None: + if self.frame is None or self.region is None: return None displayed = self.image.pixmap() if displayed is None or displayed.isNull(): return None - # The unscaled pixmap was rendered from the display plane, so its size - # is the shape of the data the index has to land in. + # The pixmap was rendered from the display plane + x, y, width, height, _ = self.region point = self.image.mapFrom(self, position) return locate_pixel((point.x(), point.y()), (self.image.width(), self.image.height()), (displayed.width(), displayed.height()), - (self.frame.pixmap.width(), self.frame.pixmap.height())) + (width, height), (x, y)) def mousePressEvent(self, event): # pylint: disable=invalid-name - """Qt override: clicking a frame makes it current.""" + """Qt override: clicking a frame makes it current, and begins a pan.""" super().mousePressEvent(event) + self.drag = event.pos() if event.button() == Qt.LeftButton else None self.clicked.emit() + def mouseReleaseEvent(self, event): # pylint: disable=invalid-name + """Qt override: the pan lasts as long as the button is held.""" + super().mouseReleaseEvent(event) + self.drag = None + def mouseMoveEvent(self, event): # pylint: disable=invalid-name """ - Qt override: report the pixel under the cursor as it moves. + Qt override: drag to pan, and report the pixel under the cursor. The child labels ignore mouse moves, so Qt propagates them here with the position already translated into this widget's coordinates. That is also how clicks on the image reach mousePressEvent above. """ super().mouseMoveEvent(event) + if self.drag is not None and event.buttons() & Qt.LeftButton: + delta = event.pos() - self.drag + if self.pan_by(delta.x(), delta.y()): + self.drag = event.pos() + index = self.pixel_at(event.pos()) self.hovered.emit(None if index is None else (self.frame, *index)) + def eventFilter(self, source, event): # pylint: disable=invalid-name + """ + Qt override: a wheel turned over the image zooms about the cursor. + """ + notches = 0 if event.type() != QEvent.Wheel else event.angleDelta().y() / 120.0 + if source is self.image and notches and self.frame is not None: + position = self.image.mapTo(self, event.pos()) + self.zoom_by(WHEEL_STEP ** notches, self.pixel_at(position)) + event.accept() + return True + return super().eventFilter(source, event) + def leaveEvent(self, event): # pylint: disable=invalid-name """Qt override: the cursor is off this tile, so it is on no pixel.""" super().leaveEvent(event) + self.drag = None self.hovered.emit(None) diff --git a/src/atlas/view/main_window.py b/src/atlas/view/main_window.py index 062bae5..8d6c704 100644 --- a/src/atlas/view/main_window.py +++ b/src/atlas/view/main_window.py @@ -5,6 +5,7 @@ from atlas.features import build_tools from atlas.model.fits_model import SCALES +from atlas.model.zoom import ZOOM_STEP from .frame_grid import FrameGrid FITS_FILTER = "FITS Files (*.fits *.fit *.fts *.fits.gz *.fz);;All Files (*)" @@ -82,6 +83,7 @@ def create_menus(self): self.create_display_mode_actions() self.view_menu.addSeparator() self.create_scale_actions() + self.create_zoom_actions() # Created up front so tools have somewhere to attach; hidden if empty. self.tools_menu = menu_bar.addMenu("Tools") @@ -117,6 +119,24 @@ def show_pixel(self, readout): text = f"{readout.label} {text}" self.pixel_readout.setText(text) + def create_zoom_actions(self): + """ + Adds the zoom commands, which apply to the current frame. + + Like the scale, the zoom belongs to the frame rather than the window, + so tiled frames can be magnified independently of each other. + """ + self.zoom_menu = self.view_menu.addMenu("Zoom") + self.add_action(self.zoom_menu, "Zoom In", + lambda: self.view_model.zoom_current_by(ZOOM_STEP), "Ctrl++") + self.add_action(self.zoom_menu, "Zoom Out", + lambda: self.view_model.zoom_current_by(1 / ZOOM_STEP), "Ctrl+-") + self.zoom_menu.addSeparator() + self.add_action(self.zoom_menu, "Zoom to Fit", + self.view_model.zoom_current_to_fit, "Ctrl+0") + self.add_action(self.zoom_menu, "Actual Pixels", + self.frame_grid.zoom_current_to_actual, "Ctrl+9") + def create_display_mode_actions(self): """Adds the single/tile display mode choice.""" group = QActionGroup(self) @@ -211,6 +231,7 @@ def update_frame_actions(self, *_): frame = self.view_model.current_frame self.sync_scale_actions(frame) + self.zoom_menu.setEnabled(frame is not None) title = self.config.window.title if frame is not None: diff --git a/src/atlas/viewmodel/frame_viewmodel.py b/src/atlas/viewmodel/frame_viewmodel.py index d3df1aa..807d0f7 100644 --- a/src/atlas/viewmodel/frame_viewmodel.py +++ b/src/atlas/viewmodel/frame_viewmodel.py @@ -8,6 +8,7 @@ from atlas.model.fits_model import FITSModel, SCALES from atlas.model.frame import Frame +from atlas.model.zoom import ZOOM_FIT # FITS files are conventionally named with any of these extensions. FITS_EXTENSIONS = ('.fits', '.fit', '.fts', '.fits.gz', '.fit.gz', '.fts.gz', '.fz') @@ -25,6 +26,7 @@ class FrameViewModel(QObject): frames_changed = pyqtSignal() current_changed = pyqtSignal(int) display_mode_changed = pyqtSignal(str) + view_changed = pyqtSignal() message = pyqtSignal(str) def __init__(self, config): @@ -54,7 +56,7 @@ def load_file(self, file_name): self.message.emit(f"{os.path.basename(file_name)} contains no image data.") return None - frame = Frame(data, header, file_name) + frame = self.new_frame(data, header, file_name) frame.pixmap = self.render(data, frame.scale) if frame.pixmap is None: return None @@ -65,6 +67,12 @@ def load_file(self, file_name): self.current_changed.emit(self.current_index) return frame + def new_frame(self, data, header, file_name=""): + """Creates a frame, opened at the zoom the configuration asks for.""" + frame = Frame(data, header, file_name) + frame.zoom = self.config.display.zoom + return frame + def update_live_frame(self, data, keywords): """Updates the live frame in place instead of appending a new one.""" if self._live_frame is not None and self._live_frame not in self.frames: @@ -80,7 +88,7 @@ def update_live_frame(self, data, keywords): header = fits.Header(keywords) # header.py expects frame.header.cards if self._live_frame is None: - self._live_frame = Frame(data, header) + self._live_frame = self.new_frame(data, header) self._live_frame.scale = scale self._live_frame.pixmap = pixmap self.frames.append(self._live_frame) @@ -153,6 +161,26 @@ def set_current_scale(self, scale): frame.pixmap = pixmap self.current_changed.emit(self.current_index) + def zoom_current_by(self, ratio): + """Zooms the current frame in or out by a factor, about its centre.""" + frame = self.current_frame + if frame is not None and frame.set_zoom(frame.zoom * ratio): + self.view_changed.emit() + + def set_current_zoom(self, factor): + """Zooms the current frame to a factor relative to fitting its tile.""" + frame = self.current_frame + if frame is not None and frame.set_zoom(factor): + self.view_changed.emit() + + def zoom_current_to_fit(self): + """Puts the whole of the current frame back in its tile, centred.""" + frame = self.current_frame + if frame is not None: + frame.zoom = ZOOM_FIT + frame.center = None + self.view_changed.emit() + def select_display_plane(self, data): """ Reduces FITS data to something displayable: a 2D array, or an diff --git a/tests/test_zoom.py b/tests/test_zoom.py new file mode 100644 index 0000000..04abc0c --- /dev/null +++ b/tests/test_zoom.py @@ -0,0 +1,84 @@ +""" +Zooming and panning a frame. +""" +# pylint: disable=redefined-outer-name + +# Third-Party Library Imports +import numpy as np +import pytest +from PyQt5.QtCore import QEvent, QPoint, QPointF, Qt +from PyQt5.QtGui import QColor, QMouseEvent, QWheelEvent + +from atlas.config.schema import AtlasConfig, ConfigError, DisplayConfig, ToolsConfig + +# Every pixel carries its own index +COUNTS = np.arange(256, dtype=np.int32).reshape(16, 16) + + +@pytest.fixture +def viewer(make_window, qapp): + """ + Builds a shown window around one frame of data. + + Shown, because a tile has no size until its window does, and the geometry + under test is exactly that size. + """ + def build(data, config=None): + window, view_model = make_window( + config or AtlasConfig(tools=ToolsConfig(header=False)), show=True) + view_model.update_live_frame(data, {}) + qapp.processEvents() + return window, view_model, window.frame_grid.widgets[0] + + return build + + +def displayed_values(widget): + """Every grey level in what the tile is currently drawing.""" + image = widget.image.pixmap().toImage() + return {QColor(image.pixel(x, y)).red() + for y in range(image.height()) for x in range(image.width())} + + +def image_point(widget, column, row): + """The centre of a data pixel, in the tile's image label coordinates.""" + displayed = widget.image.pixmap() + x, y, width, height, _ = widget.region + return QPoint( + (widget.image.width() - displayed.width()) // 2 + + int((column - x + 0.5) * displayed.width() / width), + (widget.image.height() - displayed.height()) // 2 + + int((row - y + 0.5) * displayed.height() / height)) + + +def send(qapp, widget, event): + """Delivers an event to the image label, where a real one arrives.""" + qapp.notify(widget.image, event) + qapp.processEvents() + + +def test_zooming_in_magnifies_without_correcting_the_image(viewer, qapp): + """ + A zoom crops the render and enlarges the crop. It does nothing else. + """ + data = (np.indices((32, 32)).sum(axis=0) % 2) * 1000 + data[0, 0] = 100000 # the outlier that sets the display range + _, view_model, widget = viewer(data.astype(np.int32)) + rendered = widget.frame.pixmap + + widget.frame.center = (28, 28) + view_model.set_current_zoom(8.0) + qapp.processEvents() + + assert widget.frame.pixmap is rendered, "the frame was re-rendered" + assert widget.display_scale() > 1, "this tile is not magnifying anything" + assert 0 not in widget.region[:2], "the view did not move off the hot pixel" + + x, y, width, height, _ = widget.region + crop = rendered.copy(x, y, width, height).toImage() + assert displayed_values(widget) == { + QColor(crop.pixel(i, j)).red() + for j in range(crop.height()) for i in range(crop.width())}, \ + "the magnified region holds greys its part of the render does not" + assert max(displayed_values(widget)) < 10, \ + "the dim region was restretched to its own range"