Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
38 changes: 37 additions & 1 deletion docs/frames.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Frames and scales
# Frames, scales and zoom

## Frames

Expand Down Expand Up @@ -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 | <kbd>Ctrl</kbd>+<kbd>+</kbd> |
| Zoom Out | Halves it | <kbd>Ctrl</kbd>+<kbd>-</kbd> |
| Zoom to Fit | The whole frame in its tile, centred | <kbd>Ctrl</kbd>+<kbd>0</kbd> |
| Actual Pixels | One screen pixel per data pixel | <kbd>Ctrl</kbd>+<kbd>9</kbd> |

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
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/inspecting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -47,6 +47,8 @@ header panel is opt-in. See [](configuration) for how to turn them on.
| <kbd>Ctrl</kbd>+<kbd>[</kbd> | Previous frame |
| <kbd>Ctrl</kbd>+<kbd>3</kbd> | Linear scale |
| <kbd>Ctrl</kbd>+<kbd>4</kbd> | Log scale |
| <kbd>Ctrl</kbd>+<kbd>+</kbd> / <kbd>Ctrl</kbd>+<kbd>-</kbd> | Zoom in and out |
| <kbd>Ctrl</kbd>+<kbd>0</kbd> | Fit the frame to its tile |
| <kbd>Ctrl</kbd>+<kbd>W</kbd> | Close the current frame |
| <kbd>Ctrl</kbd>+<kbd>Q</kbd> | Quit |

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions src/atlas/config/profiles/detector.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
display:
mode: tile
tile_columns: 1
zoom: 1.0
tools:
header: true
histogram: true
Expand Down
1 change: 1 addition & 0 deletions src/atlas/config/profiles/hispec_fei.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# HISPEC FEI
display:
mode: single
zoom: 1.0
tools:
header: true
histogram: true
Expand Down
1 change: 1 addition & 0 deletions src/atlas/config/profiles/minimal.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Nothing but image display: no side panels, no tools.
display:
mode: single
zoom: 1.0
tools:
header: false
histogram: false
1 change: 1 addition & 0 deletions src/atlas/config/profiles/viewer.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# General-purpose FITS viewing: images, headers and pixel histograms.
display:
mode: single
zoom: 1.0
tools:
header: true
histogram: true
12 changes: 10 additions & 2 deletions src/atlas/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/atlas/model/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
import itertools
import os

from .zoom import ZOOM_FIT, clamp_zoom


class Frame:
"""
One loaded image, following the frame concept from SAOImage DS9.

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)
Expand All @@ -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):
Expand Down
17 changes: 10 additions & 7 deletions src/atlas/model/pixel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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):
Expand Down
92 changes: 92 additions & 0 deletions src/atlas/model/zoom.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 16 additions & 3 deletions src/atlas/view/frame_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading