diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..feaec95 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +name: Docs + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install tox + run: | + python -m pip install --upgrade pip + pip install tox + # tox -e docs sets skip_install, so the documentation builds without atlas, + # without Qt and without a display. It also passes -W, so a broken cross + # reference or a page missing from the toctree fails the job. + - name: Build the documentation + run: | + tox -e docs + - name: Upload the built site + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html + retention-days: 14 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index df36383..22933d5 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -10,7 +10,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.14" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7039b95..a872e24 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.14" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index 410378d..e3fa761 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,19 @@ **atlas** is a Python GUI for viewing FITS images, in the spirit of SAOImage DS9. +It opens files into *frames*, shows them singly or tiled, and lets you read +individual pixel counts straight off the image. Everything beyond the image +itself is a tool you switch on, so a default launch stays a plain viewer rather +than a wall of panels. + ## Installation -Requires Python 3.14 or higher. +Requires Python 3.12 or higher. ```sh pip install -e . # atlas and its runtime dependencies pip install -e ".[zmq]" # also the optional ZMQ tool -pip install -e ".[dev]" # also pylint, for the CI checks +pip install -e ".[dev]" # also pylint and pytest, for the CI checks ``` Dependencies are declared in `pyproject.toml`. @@ -35,144 +40,51 @@ PYTHONPATH=src python -m atlas.main --profile viewer image.fits Run `atlas --help` for the full command line, and `--list-profiles` for the bundled profiles. -## Frames - -atlas borrows DS9's **frame** concept: each loaded image lives in its own frame, -and the display mode decides how frames appear on screen. - -| Mode | What you see | Shortcut | -| --- | --- | --- | -| `single` | one frame at a time | `Ctrl+1` | -| `tile` | every frame in a grid | `Ctrl+2` | - -Move between frames with `Ctrl+]` and `Ctrl+[`, or click a tile. `Ctrl+W` -closes the current frame. - -## Scales - -**View → Scale** decides how pixel values map onto the brightness of the -display. It is a property of the frame, not of the window, so in `tile` mode -one image can be shown on a log scale beside another on a linear one. - -| Scale | What it does | Shortcut | -| --- | --- | --- | -| `linear` | brightness proportional to pixel value | `Ctrl+3` | -| `log` | stretches the faint end, compresses the bright end | `Ctrl+4` | +## What it does -Both scales first map the frame's own minimum and maximum onto the full display -range, so changing scale never clips a pixel that was visible before, it only -redistributes contrast. Only the rendered pixmap changes; the raw data is left -alone, so switching back and forth is lossless. +| | | +| --- | --- | +| **Frames** | Each image in its own frame, shown singly or tiled | +| **Scales** | Linear or log, per frame rather than per window | +| **Hover readout** | Pixel index and raw count in the status bar, always on | +| **Header panel** | The current frame's FITS header, on by default | +| **Statistics** | Mean, median, standard deviation, min, max, pixel count | +| **Histograms** | Pixel-value distributions for every frame on screen | +| **Live streams** | Follow a detector over ImageStreamIO shared memory or ZMQ | +| **Tap subtraction** | COO detector signal and reset taps | -The histogram window has its own **Log count axis** checkbox, independent of the -frame's scale. A pixel histogram is usually dominated by a single sky or bias -peak, and a log count axis is what makes the faint tail visible. +Everything except the header panel is opt-in, from a profile, a configuration +file, or `--enable` on the command line. -## Hover readout +## Documentation -Resting the cursor on a pixel puts its index and its count on the right of the -status bar: +The full documentation lives in [`docs/`](docs/): -``` -(341, 169) 27,922 -``` +- [Installation](docs/installation.md) and [Quickstart](docs/quickstart.md) +- [Frames and scales](docs/frames.md) +- [Inspecting pixels](docs/inspecting.md): hover readout, statistics, headers, histograms +- [Live streams](docs/live.md): shared memory and ZMQ +- [Detector tools](docs/detector.md): tap subtraction +- [Command line](docs/cli.md) and [Configuration](docs/configuration.md) reference +- [Development](docs/development.md) -The indices are 0-based, `x` across and `y` down, so the pair reads directly as -`data[y, x]` in whatever you are inspecting the frame with. This is numpy's -convention rather than DS9's 1-based one, and `y` counts down because atlas -draws the first row of the array at the top of the tile. - -The count comes from the raw array, so it is a detector count and does not move -when the display scale changes. Blank pixels (NaN/inf) read as a dash rather -than as a number, and colour frames report one sample per channel. - -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 -messages rather than replacing them, so neither overwrites the other. - -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. - -While a live stream is running, the readout re-reads the hovered pixel as each -frame arrives, so resting the cursor on one pixel shows its counts changing. -When tiling, it reports whichever tile the cursor is over, prefixed with that -frame's name, which need not be the current frame. - -## Statistics - -The **statistics** tool adds a dock panel summarising the current frame's pixel -values: mean, median, standard deviation, min and max, plus the pixel count. -Enable it with `--enable statistics` or `tools.statistics` in a configuration. - -The figures come from the raw array, not the rendered image, so they describe -detector counts and do not move when the display scale changes. Blank pixels -(NaN/inf) are excluded and reported separately, since a single NaN would -otherwise make every statistic NaN. - -Recomputing is not free. The median alone costs about ten times the other -statistics put together, roughly 30 ms on a 2048x2048 frame. So while a live -stream is running the panel refreshes at `update_hz` (2 Hz by default) rather -than on every displayed frame. Switching frames by hand still recomputes -immediately, and a hidden panel does no work at all. - -## Configuration - -A configuration is resolved from, in increasing order of precedence: built-in -defaults, the `--profile`, the `--config` file, then command-line overrides. -Unknown keys are rejected with an error rather than ignored, so a typo cannot -silently leave a feature switched off. - -```yaml -window: - title: atlas - width_fraction: 0.8 # of the screen - height_fraction: 0.8 - -display: - mode: single # single | tile - tile_columns: null # null picks a roughly square grid - -tools: - header: true # FITS header panel - histogram: false # pixel-intensity histograms - tap_subtraction: false # COO detector signal/reset taps - zmq: false # load frames announced over ZMQ - statistics: false # pixel statistics panel -``` +Build it with [tox](https://tox.wiki/): -Any tool taking options can be written either as a bare boolean or as a section: - -```yaml -tools: - tap_subtraction: - enabled: true - tap_width: 128 - num_taps: 32 - zmq: - enabled: true - address: tcp://localhost:5555 - socket_type: SUB # SUB | PULL - bind: false - statistics: - enabled: true - update_hz: 2.0 # recompute rate while a live stream is running +```sh +tox -e docs # writes docs/_build/html/index.html +tox -e serve # rebuilds on save at http://127.0.0.1:8000 ``` -Individual tools can be toggled from the command line without editing anything: +## Development ```sh -python src/main.py --profile minimal --enable histogram -python src/main.py --profile detector --disable zmq +tox # the test suite and pylint, as CI runs them +tox -e tests +tox -e lint ``` -### Bundled profiles - -| Profile | Purpose | -| --- | --- | -| `minimal` | Image display only — no panels, no tools. | -| `viewer` | General FITS viewing: headers and histograms. | -| `detector` | COO detector work: tiled frames, tap subtraction, ZMQ. | +See [Development](docs/development.md) for the full set of environments and how +to add a documentation page. ## Reporting Issues diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..a00b780 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,20 @@ +/* Keep the hover-readout and status-bar samples reading as fixed-width UI + text rather than as code the reader is meant to run. */ +.sample-readout pre { + font-size: 0.95rem; +} + +/* Shortcut keys appear in most tables on the site; give them a little air. */ +kbd { + padding: 0.1em 0.4em; + border: 1px solid var(--sy-c-border, #d0d7de); + border-bottom-width: 2px; + border-radius: 4px; + font-size: 0.85em; + white-space: nowrap; +} + +/* The landing page cards read better with even heights. */ +.sd-card { + height: 100%; +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..3d11695 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,70 @@ +# Command line + +```text +atlas [files ...] [-c FILE] [-p NAME] [-m MODE] [--tile-columns N] + [--enable TOOL] [--disable TOOL] [--list-profiles] [-h] +``` + +Run `atlas --help` for the same list from the program itself. + +## Arguments + +`files` +: Zero or more FITS files, each opened into its own frame. Shell globs work: + `atlas *.fits`. + +## Options + +`-c FILE`, `--config FILE` +: A YAML configuration file. See [](configuration). + +`-p NAME`, `--profile NAME` +: One of the bundled profiles: `minimal`, `viewer`, `detector`. + +`-m MODE`, `--mode MODE` +: Display mode, `single` or `tile`, overriding the configuration. + +`--tile-columns N` +: Columns to use when tiling. Omit it to let atlas pick a roughly square grid. + +`--enable TOOL` +: Turn a tool on. Repeatable. Valid names are `header`, `histogram`, `shm`, + `statistics`, `tap_subtraction`, `zmq`. + +`--disable TOOL` +: Turn a tool off. Repeatable. + +`--list-profiles` +: Print the bundled profile names and exit, without opening a window. + +`-h`, `--help` +: Print usage and exit. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Normal exit, including `--list-profiles` | +| `2` | The configuration was rejected; the reason is printed to stderr | + +A rejected configuration is caught before any widget is created, so atlas +either starts with the features you asked for or does not start at all. It +never opens a window with a feature silently missing. + +## Examples + +```sh +atlas # defaults: single frame, header panel +atlas image.fits # open a file straight away +atlas *.fits --mode tile # every file in its own tile +atlas --profile minimal # image display and nothing else +atlas --config observing.yaml # your own configuration +atlas --profile detector --disable zmq +atlas image.fits --enable histogram --enable statistics +``` + +Running from a checkout without installing: + +```sh +PYTHONPATH=src python -m atlas.main --profile viewer image.fits +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..08eb671 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,70 @@ +"""Sphinx configuration for the atlas documentation.""" +# Sphinx reads its settings from lowercase module-level names, so the +# constant-naming rule does not apply to this file. +# pylint: disable=invalid-name + +from importlib.metadata import PackageNotFoundError, version as package_version + +project = "atlas" +author = "Caltech Optical Observatories" +copyright = "2026, Caltech Optical Observatories" # pylint: disable=redefined-builtin + +try: + release = package_version("atlas") +except PackageNotFoundError: + # The docs build does not need atlas importable, so an uninstalled + # checkout should still produce a complete set of pages. + release = "0.1.0" +version = release + +extensions = [ + "myst_parser", + "sphinx_copybutton", + "sphinx_design", +] + +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# Markdown niceties used across the guide: ``:::{note}`` blocks, tables with +# footnotes, and literal dashes that should not turn into typographic ones. +myst_enable_extensions = [ + "colon_fence", + "deflist", + "linkify", + "substitution", +] +myst_heading_anchors = 3 + +html_theme = "shibuya" +html_static_path = ["_static"] +html_css_files = ["custom.css"] +html_title = "atlas" +html_copy_source = False +html_show_sourcelink = False + +html_theme_options = { + "accent_color": "cyan", + "github_url": "https://github.com/CaltechOpticalObservatories/atlas", + "nav_links": [ + {"title": "Install", "url": "installation"}, + {"title": "Quickstart", "url": "quickstart"}, + {"title": "Configuration", "url": "configuration"}, + {"title": "Issues", + "url": "https://github.com/CaltechOpticalObservatories/atlas/issues"}, + ], + "globaltoc_expand_depth": 1, +} + +# Powers the theme's "Edit this page" and repository links. +html_context = { + "source_type": "github", + "source_user": "CaltechOpticalObservatories", + "source_repo": "atlas", + "source_version": "main", + "source_docs_path": "/docs/", +} + +# Keep the copy button from grabbing shell prompts and REPL markers. +copybutton_prompt_text = r">>> |\.\.\. |\$ " +copybutton_prompt_is_regexp = True diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ac41c1d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,191 @@ +# Configuration + +## How a configuration is resolved + +A configuration is resolved from four sources, in increasing order of +precedence: + +1. Built-in defaults +2. The `--profile` +3. The `--config` file +4. Command-line overrides (`--mode`, `--tile-columns`, `--enable`, `--disable`) + +Each layer overrides the one before it, so `--profile detector --disable zmq` +is exactly the detector profile with one tool switched off. + +:::{important} +Unknown keys are **rejected with an error** rather than ignored, so a typo +cannot silently leave a feature switched off. The error names the key and lists +the ones that section does accept: + +```text +atlas: unknown key in tools.statistics: update_hertz (known: enabled, update_hz) +``` + +Values are validated too, and the whole configuration is resolved before any +widget exists. atlas either starts as you asked or exits with status 2. +::: + +## A complete file + +Every key below is optional; what is shown is the default. + +```yaml +window: + title: atlas + width_fraction: 0.8 # of the screen, 0.1 to 1.0 + height_fraction: 0.8 + +display: + mode: single # single | tile + tile_columns: null # null picks a roughly square grid + +tools: + header: true # FITS header panel + histogram: false # pixel-intensity histograms + tap_subtraction: false # COO detector signal/reset taps + zmq: false # load frames announced over ZMQ + shm: false # live ImageStreamIO shared-memory frames + statistics: false # pixel statistics panel +``` + +## Boolean shorthand + +Any tool taking options can be written either as a bare boolean or as a +section. These two are identical: + +::::{grid} 1 1 2 2 +:gutter: 2 + +:::{grid-item-card} Shorthand +```yaml +tools: + statistics: true +``` +::: + +:::{grid-item-card} Full form +```yaml +tools: + statistics: + enabled: true + update_hz: 2.0 +``` +::: + +:::: + +Write the section form when you want to change an option, the shorthand when +you only want the tool on. A bare `false` switches a tool off without losing +the defaults for its other keys. + +## Section reference + +### `window` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `title` | string | `atlas` | Window title | +| `width_fraction` | number | `0.8` | Fraction of screen width, 0.1 to 1.0 | +| `height_fraction` | number | `0.8` | Fraction of screen height, 0.1 to 1.0 | + +### `display` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `mode` | `single` or `tile` | `single` | See [](frames) | +| `tile_columns` | integer or `null` | `null` | Positive; `null` picks a square-ish grid | + +### `tools.header` + +A plain boolean, on by default. Adds the FITS header dock panel described in +[](inspecting.md#header-panel). + +### `tools.histogram` + +A plain boolean, off by default. Adds **Tools → Histogram…**; see +[](inspecting.md#histograms). + +### `tools.statistics` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | | +| `update_hz` | number | `2.0` | 0.1 to 60; recompute rate during a live stream | + +See [](inspecting.md#statistics) for why the rate is capped. + +### `tools.tap_subtraction` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | | +| `tap_width` | integer | `128` | Positive and **even** | +| `num_taps` | integer | `32` | Positive | + +See [](detector.md#tap-subtraction). + +### `tools.zmq` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | Needs the `zmq` extra installed | +| `address` | string | `tcp://localhost:5555` | Must contain `://` | +| `socket_type` | `SUB` or `PULL` | `SUB` | | +| `bind` | boolean | `false` | Bind instead of connect | +| `topic` | string | `""` | `SUB` prefix filter; empty means everything | + +See [](live.md#zmq). + +### `tools.shm` + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `enabled` | boolean | `false` | | +| `segment_name` | string | `camera` | Non-empty; the `.im.shm` file's stem | +| `shm_dir` | string | `""` | Empty falls back to `MILK_SHM_DIR`, `/milk/shm`, `/tmp` | +| `display_fps_cap` | number | `15.0` | Positive; adjustable live from the menu | + +See [](live.md#shared-memory). + +## Bundled profiles + +| Profile | Purpose | +| --- | --- | +| `minimal` | Image display only: no panels, no tools | +| `viewer` | General FITS viewing: headers and histograms | +| `detector` | COO detector work: tiled frames, tap subtraction, ZMQ | + +`atlas --list-profiles` prints them. The YAML lives in +`src/atlas/config/profiles/`, which is a good place to look when writing your +own: copy the closest one and pass it with `--config`. + +## Worked example + +An observing configuration that watches a camera live, keeps statistics +updating slowly enough not to fight the display, and leaves ZMQ alone: + +```yaml +window: + title: HISPEC tracking camera + width_fraction: 0.9 + height_fraction: 0.9 + +display: + mode: single + +tools: + header: true + histogram: true + statistics: + enabled: true + update_hz: 1.0 + shm: + enabled: true + segment_name: hispec_tracking_camera + display_fps_cap: 20.0 +``` + +```sh +atlas --config hispec.yaml +``` diff --git a/docs/detector.md b/docs/detector.md new file mode 100644 index 0000000..2cd0ea8 --- /dev/null +++ b/docs/detector.md @@ -0,0 +1,90 @@ +# Detector tools + +## Tap subtraction + +COO detectors read out in **taps**, and each tap carries a signal half and a +reset half side by side. The **tap_subtraction** tool rebuilds a corrected +frame by gathering the reset halves of one frame, gathering the signal halves +of another, and subtracting one from the other. + +This is instrument-specific, so it stays off unless you ask for it: + +```yaml +tools: + tap_subtraction: + enabled: true + tap_width: 128 # pixels per tap, must be even + num_taps: 32 +``` + +Or for a single session: + +```sh +atlas signal.fits reset.fits --enable tap_subtraction +``` + +### Using it + +**Tools → Subtract Signal/Reset Taps** operates on the **current frame and the +one before it**: the earlier frame supplies the signal halves, the current one +supplies the reset halves. So you choose the pair with **Frame → Next** and +**Previous** rather than from a dialog, and the result arrives as a new frame. + +The difference is computed in `int64`, which matters: reset minus signal is +genuinely signed, and an unsigned accumulator would wrap negative pixels around +to full brightness. + +### Geometry, and the errors it produces + +The frame has to match the configured geometry. atlas expects a width of +`tap_width * (num_taps + 1)`, the extra tap being the reference tap, and it +says so plainly when the numbers do not line up: + +```text +Tap subtraction failed: frame is 2048 px wide but 32 taps of 128 px needs 4224 +``` + +Two other cases you may meet in the status bar: + +| Message | Means | +| --- | --- | +| `Tap subtraction needs two frames; open a second image first.` | Only one frame is open | +| `expected a 2D frame, got shape ...` | The frame is a cube or a colour image | + +`tap_width` must be even, since each tap splits into two halves; a configuration +with an odd `tap_width` is rejected at startup rather than at the moment you +click the menu item. + +## The detector profile + +The bundled `detector` profile assembles the pieces a COO detector session +usually wants: + +```sh +atlas --profile detector +``` + +```yaml +display: + mode: tile + tile_columns: 1 # signal above reset, not side by side +tools: + header: true + histogram: true + tap_subtraction: + enabled: true + tap_width: 128 + num_taps: 32 + zmq: + enabled: true + address: tcp://localhost:5555 + socket_type: SUB + shm: + enabled: false # off until verified against a real segment + segment_name: hispec_tracking_camera +``` + +Note that `shm` is present but disabled in the shipped profile. Turn it on for +a session with `atlas --profile detector --enable shm`, or copy the profile +into your own `--config` file and set it there along with the right +`segment_name`. See [](live.md#shared-memory). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..9b0b698 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,180 @@ +# Development + +atlas uses [tox](https://tox.wiki/) to run its checks and build these docs, so +every task has one command that works the same on a laptop and in CI. + +## Install tox + +tox is the only thing you install globally; it creates and manages the +environment for each task itself. + +```sh +pipx install tox # recommended, keeps tox out of your project env +``` + +or + +```sh +python -m pip install --user tox +``` + +or, if you already use [uv](https://docs.astral.sh/uv/): + +```sh +uv tool install tox --with tox-uv +``` + +## Run everything + +```sh +tox +``` + +That runs the default environments: the test suite and pylint, the same two +checks the GitHub Actions workflows run on every push. + +## Run one task + +```sh +tox -e tests # pytest +tox -e lint # pylint, using .pylintrc +tox -e docs # build the HTML documentation +tox -e serve # build the docs and watch for changes +``` + +`tox list` shows all four with their descriptions, and `tox -e tests -- -k statistics` passes arguments +through to the underlying tool, here to pytest. + +## The environments + +| Environment | Runs | Notes | +| --- | --- | --- | +| `tests` | `pytest` | Sets `QT_QPA_PLATFORM=offscreen`, so no display is needed | +| `lint` | `pylint` over every tracked `.py` | Uses the repository `.pylintrc` | +| `docs` | `sphinx-build -W` | Warnings are errors; a broken link fails the build | +| `serve` | `sphinx-autobuild` | Serves on and rebuilds on save | + +`tests` and `lint` install atlas with the `dev` and `zmq` extras. `docs` +installs only the `docs` extra and **not** atlas itself, so building the +documentation needs no Qt and no display. + +## Documentation + +The docs are [Sphinx](https://www.sphinx-doc.org/) with the +[Shibuya](https://shibuya.lepture.com/) theme, written in Markdown through +[MyST](https://myst-parser.readthedocs.io/). + +```sh +tox -e docs +open docs/_build/html/index.html +``` + +While writing, `tox -e serve` is the better loop: it rebuilds on save and +reloads the browser. + +### Adding a page + +1. Create `docs/your-page.md`. +2. Add it to a `toctree` in `docs/index.md`, under whichever caption fits. +3. Run `tox -e docs` and fix anything it reports. + +Because the build runs with `-W`, a page missing from every toctree, a link to +a heading that does not exist, or a malformed directive all fail the build +rather than producing a quietly broken site. + +### Cross-references + +Link to another page with an empty link text and MyST fills in its title: + +```markdown +See [](configuration) for the full list. +``` + +Link to a heading with the file and its anchor: + +```markdown +See [](live.md#shared-memory). +``` + +Anchors are generated for headings down to three levels deep +(`myst_heading_anchors = 3`). Where a heading's own anchor would be awkward to +depend on, the page defines an explicit one with `(name)=` above the heading. + +### Without tox + +If you would rather drive Sphinx directly, do it through the interpreter rather +than through the `sphinx-build` script: + +```sh +python -m venv .venv +source .venv/bin/activate +pip install -e ".[docs]" +python -m sphinx -b html -W docs docs/_build/html +``` + +:::{warning} +Use `python -m sphinx`, not bare `sphinx-build`. If any other Python on the +machine also has Sphinx installed, a stale entry on `PATH` can win over the +virtual environment's, and you get a confusing failure from a Sphinx that has +none of the extensions: + +```text +Could not import extension sphinx_copybutton + (exception: No module named 'sphinx_copybutton') +``` + +Note the `Python version:` line in that error report: it names the interpreter +that actually ran, which is the quickest way to spot the mismatch. `python -m +sphinx` cannot pick the wrong one. `tox -e docs` is immune for the same reason, +since it builds in its own isolated environment. +::: + +## Testing + +```sh +tox -e tests +``` + +Tests live in `tests/` and run under pytest with `--strict-markers` and +`--strict-config`, so an unregistered mark or a typo'd fixture fails rather +than skipping quietly. The Qt tests run headless via `QT_QPA_PLATFORM=offscreen`. + +`scripts/demo_shm_viewer.py` is a manual demo rather than a test. It launches +the real window against a synthetic 60 Hz producer so the +[live path](live.md#shared-memory) can be exercised without a detector; pytest +does not collect it. + +## Linting + +```sh +tox -e lint +``` + +pylint runs over every tracked Python file with the repository `.pylintrc`. CI +runs exactly the same command, so a clean `tox -e lint` means a clean CI run. + +## Continuous integration + +Three workflows run on every push: + +| Workflow | Does | Equivalent | +| --- | --- | --- | +| `.github/workflows/tests.yml` | pytest on Python 3.12 | `tox -e tests` | +| `.github/workflows/pylint.yml` | pylint on Python 3.12 | `tox -e lint` | +| `.github/workflows/docs.yml` | Builds the documentation | `tox -e docs` | + +The docs job runs `tox -e docs` rather than calling Sphinx itself, so the +dependency list and the `-W` flag have one definition rather than two. Because +that environment sets `skip_install`, the job needs neither Qt nor a matching +Python, +and it finishes in a few seconds. + +It uploads the built site as a `docs-html` artifact, kept for 14 days. That is +the quickest way to read a documentation change as rendered HTML before merging +it: open the run's summary page and download the artifact. + +## Reporting issues + +Please open an issue on the +[GitHub Issues page](https://github.com/CaltechOpticalObservatories/atlas/issues). +Your feedback helps us improve the project. diff --git a/docs/frames.md b/docs/frames.md new file mode 100644 index 0000000..4e198ea --- /dev/null +++ b/docs/frames.md @@ -0,0 +1,62 @@ +# Frames and scales + +## Frames + +atlas borrows DS9's **frame** concept: each loaded image lives in its own +frame, and the display mode decides how frames appear on screen. + +| Mode | What you see | Shortcut | +| --- | --- | --- | +| `single` | One frame at a time | Ctrl+1 | +| `tile` | Every frame in a grid | Ctrl+2 | + +Move between frames with Ctrl+] and +Ctrl+[, or click a tile. +Ctrl+W closes the current frame, and **Frame → Delete All +Frames** clears the lot. + +Set the starting mode from the command line or a configuration: + +```sh +atlas *.fits --mode tile +atlas *.fits --mode tile --tile-columns 1 +``` + +`tile_columns` fixes the width of the grid. Left unset (`null`), atlas picks a +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. + +## Scales + +**View → Scale** decides how pixel values map onto the brightness of the +display. It is a property of the frame, not of the window, so in `tile` mode +one image can be shown on a log scale beside another on a linear one. + +| Scale | What it does | Shortcut | +| --- | --- | --- | +| `linear` | Brightness proportional to pixel value | Ctrl+3 | +| `log` | Stretches the faint end, compresses the bright end | Ctrl+4 | + +Both scales first map the frame's own minimum and maximum onto the full display +range, so changing scale never clips a pixel that was visible before; it only +redistributes contrast. + +:::{note} +Only the rendered pixmap changes. The raw data is left alone, so switching back +and forth is lossless, and everything that reports numbers (the +[hover readout](inspecting.md#hover-readout), the +[statistics panel](inspecting.md#statistics), the +[histogram](inspecting.md#histograms)) keeps reporting detector counts rather +than display brightness. +::: + +### When to reach for log + +A log scale earns its keep when one part of the frame is far brighter than the +rest: a saturated star over a faint field, or a bright column in an otherwise +flat bias. On a linear scale the bright feature takes the whole display range +and everything else goes black. Note that this is about *looking* at the frame; +if you want to see how the counts are actually distributed, the +[histogram](inspecting.md#histograms) is the better instrument, and it has its +own independent log option for the count axis. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f177edd --- /dev/null +++ b/docs/index.md @@ -0,0 +1,100 @@ +# atlas + +**atlas** is a Python GUI for viewing FITS images, in the spirit of +[SAOImage DS9](https://sites.google.com/cfa.harvard.edu/saoimageds9). + +It opens files into *frames*, shows them singly or tiled, and lets you read +individual pixel counts straight off the image. Everything beyond the image +itself is a **tool** you switch on, so a default launch stays a plain viewer +rather than a wall of panels. + +```sh +pip install -e . +atlas image.fits +``` + +::::{grid} 1 1 2 2 +:gutter: 3 + +:::{grid-item-card} {octicon}`download` Install +:link: installation +:link-type: doc + +Get atlas onto your machine, with or without the optional ZMQ support. +::: + +:::{grid-item-card} {octicon}`rocket` Quickstart +:link: quickstart +:link-type: doc + +Open your first image and find your way around the window in five minutes. +::: + +:::{grid-item-card} {octicon}`image` Viewing images +:link: frames +:link-type: doc + +Frames, single and tiled layouts, and the linear and log intensity scales. +::: + +:::{grid-item-card} {octicon}`graph` Inspecting pixels +:link: inspecting +:link-type: doc + +The hover readout, the statistics panel, headers, and histograms. +::: + +:::{grid-item-card} {octicon}`broadcast` Live streams +:link: live +:link-type: doc + +Follow a detector in real time over shared memory or ZMQ. +::: + +:::{grid-item-card} {octicon}`gear` Configuration +:link: configuration +:link-type: doc + +Profiles, YAML files, and command-line overrides, in precedence order. +::: + +:::: + +## Where to go next + +If you have never run atlas before, start with [](installation) and then +[](quickstart). If you are setting it up for an instrument, the pages you want +are [](live) and [](configuration). + +```{toctree} +:hidden: +:caption: Getting started + +installation +quickstart +``` + +```{toctree} +:hidden: +:caption: User guide + +frames +inspecting +live +detector +``` + +```{toctree} +:hidden: +:caption: Reference + +cli +configuration +``` + +```{toctree} +:hidden: +:caption: Development + +development +``` diff --git a/docs/inspecting.md b/docs/inspecting.md new file mode 100644 index 0000000..91a251a --- /dev/null +++ b/docs/inspecting.md @@ -0,0 +1,111 @@ +# Inspecting pixels + +Four things in atlas report numbers rather than pictures: the hover readout, +the statistics panel, the FITS header panel, and the histogram window. All of +them read the **raw array**, not the rendered image, so none of them move when +you change the [display scale](frames.md#scales). + +(hover-readout)= +## Hover readout + +Resting the cursor on a pixel puts its index and its count on the right of the +status bar: + +```{eval-rst} +.. container:: sample-readout + + :: + + (341, 169) 27,922 +``` + +The indices are 0-based, `x` across and `y` down, so the pair reads directly as +`data[y, x]` in whatever you are inspecting the frame with. This is numpy's +convention rather than DS9's 1-based one, and `y` counts down because atlas +draws the first row of the array at the top of the tile. + +The count comes from the raw array, so it is a detector count and does not move +when the display scale changes. Blank pixels (NaN or inf) read as a dash rather +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. + +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 +messages rather than replacing them, so neither overwrites the other. + +:::{tip} +While a [live stream](live.md) is running, the readout re-reads the hovered +pixel as each frame arrives, so resting the cursor on one pixel shows its +counts changing. When tiling, it reports whichever tile the cursor is over, +prefixed with that frame's name, which need not be the current frame. +::: + +(statistics)= +## Statistics + +The **statistics** tool adds a dock panel summarising the current frame's pixel +values: mean, median, standard deviation, min and max, plus the pixel count. + +```sh +atlas image.fits --enable statistics +``` + +```yaml +tools: + statistics: true +``` + +The figures come from the raw array, not the rendered image, so they describe +detector counts and do not move when the display scale changes. Blank pixels +(NaN or inf) are excluded and reported separately, since a single NaN would +otherwise make every statistic NaN. + +### Why the panel has an update rate + +Recomputing is not free. The median alone costs about ten times the other +statistics put together, roughly 30 ms on a 2048x2048 frame. So while a live +stream is running the panel refreshes at `update_hz` (2 Hz by default) rather +than on every displayed frame: + +```yaml +tools: + statistics: + enabled: true + update_hz: 2.0 # between 0.1 and 60 +``` + +Switching frames by hand still recomputes immediately, and a hidden panel does +no work at all, so closing the panel is a real way to give the display back its +time budget. + +## Header panel + +The **header** tool is the one tool that is on by default. It docks on the +right and shows the FITS header of whichever frame is current, following your +selection as you move between frames. + +Hide and show it from the **View** menu, or switch it off entirely: + +```sh +atlas image.fits --disable header +``` + +(histograms)= +## Histograms + +The **histogram** tool adds **Tools → Histogram…**, which opens a window +plotting the pixel-value distribution of every frame currently on screen. In +tile mode that means all the tiles, one curve each, which is the quickest way +to see that two frames really do share a bias level. + +```sh +atlas image.fits --enable histogram +``` + +The histogram window has its own **Log count axis** checkbox, independent of +the frame's [scale](frames.md#scales). A pixel histogram is usually dominated +by a single sky or bias peak, and a log count axis is what makes the faint tail +visible. Changing it affects only the plot, never the image. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e00eaaa --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,105 @@ +# Installation + +## Requirements + +atlas needs **Python 3.12 or newer**. Check what you have: + +```sh +python --version +``` + +It draws its window with PyQt5, so it also needs a graphical session. Over SSH +that means X11 forwarding (`ssh -Y`) or a remote desktop; there is no +terminal-only mode. + +## Install + +From a checkout of the repository: + +::::{tab-set} + +:::{tab-item} Just atlas +```sh +pip install -e . +``` +Everything you need to open FITS files and use the built-in tools. +::: + +:::{tab-item} With ZMQ +```sh +pip install -e ".[zmq]" +``` +Adds `pyzmq`, needed only for the [ZMQ tool](live.md#zmq), which is off by +default. +::: + +:::{tab-item} For development +```sh +pip install -e ".[dev,zmq]" +``` +Adds pylint and pytest, the two checks CI runs. See [](development). +::: + +:::: + +A virtual environment is strongly recommended, so atlas and its Qt build stay +out of your system Python: + +```sh +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +Runtime dependencies are declared in `pyproject.toml`. The `requirements.txt` +at the top of the repository exists only so that `pip install -r +requirements.txt` still does the right thing; it installs atlas in editable +mode with the ZMQ extra. + +## Check it worked + +Installing puts an `atlas` command on your path: + +```sh +atlas --list-profiles +``` + +`--list-profiles` prints the three bundled profiles and exits without opening a +window, which makes it a good smoke test on a machine with no display: + +```text +detector +minimal +viewer +``` + +Then launch it properly: + +```sh +atlas +``` + +## Running without installing + +atlas also runs straight from a checkout: + +```sh +PYTHONPATH=src python -m atlas.main --profile viewer image.fits +``` + +This is handy when you are editing the source and do not want an editable +install in the way, but note that `--profile` still needs the bundled YAML +files in `src/atlas/config/profiles/`, which a checkout always has. + +## Optional: ImageStreamIO shared memory + +The [shared-memory tool](live.md#shared-memory) reads +[ImageStreamIO](https://github.com/milk-org/ImageStreamIO) segments directly +with `mmap`, so there is **nothing extra to install**: no `ImageStreamIOWrap`, +no pybind11, no cmake build. It does need a producer such as `camerad` writing +to a segment, and a shared-memory directory it can find (see +[](live.md#shared-memory) for how that directory is resolved). + +## Next steps + +Go to [](quickstart) to open your first image. diff --git a/docs/live.md b/docs/live.md new file mode 100644 index 0000000..2ef0005 --- /dev/null +++ b/docs/live.md @@ -0,0 +1,135 @@ +# Live streams + +atlas can follow a running detector instead of opening files by hand. There are +two ways in, and they solve different problems: + +| Tool | Carries | Use it when | +| --- | --- | --- | +| [Shared memory](#shared-memory) | Pixel data, frame by frame | You want to *watch* the detector at video rates | +| [ZMQ](#zmq) | File **names**, not pixels | Something else is writing FITS files and wants atlas to open them | + +Both are off by default. A plain local viewing session has no reason to attach +to a segment or open a network socket, so you have to ask. + +(shared-memory)= +## Shared memory + +The **shm** tool live-displays frames arriving on an +[ImageStreamIO](https://github.com/milk-org/ImageStreamIO) shared-memory +segment, the format `milk` and `camerad` write. + +### Turning it on + +```yaml +tools: + shm: + enabled: true + segment_name: hispec_tracking_camera + shm_dir: "" # empty means "work it out", see below + display_fps_cap: 15.0 +``` + +```sh +atlas --config observing.yaml +``` + +Once enabled, the tool adds a **Tools → Shared Memory** submenu: + +- **Connect to *segment*** starts reading. +- **Disconnect from SHM** stops. +- **Set display rate…** opens a slider, 1 to 120 Hz. + +### Where the segment is looked for + +atlas opens `/.im.shm`, resolving the directory the way +ImageStreamIO itself does. It takes the first of these that exists: + +1. The `shm_dir` you configured +2. The `MILK_SHM_DIR` environment variable +3. `/milk/shm` +4. `/tmp` + +Leaving `shm_dir` empty is the normal case on a machine where `MILK_SHM_DIR` is +already set for the rest of the toolchain. + +### The display rate cap + +A detector can produce frames faster than a GUI can draw them; 60 Hz arrival +against a display that needs tens of milliseconds per frame is a losing race. +atlas reads every frame on a background thread but only hands the **latest** one +to the GUI, no more often than `display_fps_cap` (15 Hz by default). Frames in +between are dropped rather than queued, so the display stays current instead of +falling steadily further behind. + +The **Set display rate…** slider changes the cap live, whether or not you are +connected, so you can turn it down when the window starts to feel heavy and back +up when it does not. Turning the cap down does **not** slow the producer or lose +you any data on disk; it only changes how often atlas redraws. + +:::{tip} +If you have the [statistics panel](inspecting.md#statistics) open during a live +stream, it has its own, much lower, `update_hz`. That is deliberate: the median +is expensive enough that recomputing it per displayed frame would compete with +drawing the frame. +::: + +### Installation notes + +There is nothing extra to install. atlas reads the segment directly with `mmap` +and `struct` rather than through `ImageStreamIOWrap`, which needs a from-source +pybind11 and cmake build and does not work on macOS at all, since Darwin has no +`sem_timedwait`. The trade is that atlas polls the frame counter rather than +waiting on the segment's semaphore. + +(zmq)= +## ZMQ + +The **zmq** tool listens on a ZMQ endpoint for FITS **file names** and loads +each one into a frame. The pixels travel over the filesystem; only the +announcement travels over the socket. That makes it the right tool when some +other process in your pipeline already writes files and simply wants a viewer +to keep up. + +It needs the optional dependency: + +```sh +pip install -e ".[zmq]" +``` + +```yaml +tools: + zmq: + enabled: true + address: tcp://localhost:5555 + socket_type: SUB # SUB or PULL + bind: false + topic: "" # SUB topic filter +``` + +**Tools → Connect to ZMQ…** prompts for an endpoint, pre-filled with the +configured one, so you can point a configured session somewhere else without +editing anything. **Disconnect from ZMQ** stops listening. + +| Setting | Meaning | +| --- | --- | +| `address` | A ZMQ endpoint, such as `tcp://localhost:5555` | +| `socket_type` | `SUB` for a publisher fan-out, `PULL` for a work queue | +| `bind` | `true` to bind the socket instead of connecting to a peer | +| `topic` | Prefix filter, `SUB` sockets only; empty means everything | + +Because atlas loads whatever path arrives, the announcing process and atlas +must see the same filesystem. Over NFS, announce the path as atlas will see it, +not as the writer sees it. + +## Trying it without a detector + +`scripts/demo_shm_viewer.py` launches the real UI against a synthetic producer, +an orbiting bright spot at 60 Hz, so you can see the live path work without a +segment or a camera: + +```sh +PYTHONPATH=src python scripts/demo_shm_viewer.py +``` + +Pass a FITS file as an argument to load it alongside the live view in tile +mode. This is a manual demo, not a test; pytest does not pick it up. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..ffe0976 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,99 @@ +# Quickstart + +This page gets you from an installed copy of atlas to a FITS image on screen, +and names the parts of the window as it goes. + +## Open an image + +The quickest route is to name the file on the command line: + +```sh +atlas image.fits +``` + +Every file you name gets its own frame: + +```sh +atlas bias.fits dark.fits flat.fits +``` + +Or start empty and use **File → Open Image…** (Ctrl+O), +which accepts several files at once, or **File → Open Directory…** to take +every FITS file in a folder. + +## Find your way around + +| Part of the window | What it is | +| --- | --- | +| 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 | +| **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) | + +An empty **Tools** menu is normal on a default launch: every tool except the +header panel is opt-in. See [](configuration) for how to turn them on. + +## The shortcuts worth learning first + +| Shortcut | Does | +| --- | --- | +| Ctrl+O | Open image | +| Ctrl+1 | Show one frame at a time | +| Ctrl+2 | Tile every frame | +| Ctrl+] | Next frame | +| Ctrl+[ | Previous frame | +| Ctrl+3 | Linear scale | +| Ctrl+4 | Log scale | +| Ctrl+W | Close the current frame | +| Ctrl+Q | Quit | + +## Compare two images + +Open both, then tile them and step between them: + +```sh +atlas bias.fits dark.fits --mode tile +``` + +In tile mode the scale belongs to each frame separately, so you can put one +image on a log scale beside another on a linear one. Hover anywhere and the +status bar names the tile the cursor is over as well as the pixel. + +## Turn a tool on for one session + +You do not need a configuration file to try a tool. `--enable` takes any tool +name: + +```sh +atlas image.fits --enable histogram --enable statistics +``` + +`--disable` does the reverse, which is useful for stripping a panel out of a +profile you otherwise want: + +```sh +atlas --profile detector --disable zmq +``` + +## Start from a profile + +Three profiles ship with atlas: + +```sh +atlas --profile minimal # image display, nothing else +atlas --profile viewer # headers and histograms +atlas --profile detector # tiled frames, tap subtraction, ZMQ +``` + +`atlas --list-profiles` prints the list, and `atlas --help` prints the full +command line. When a profile is close but not quite right, copy it into your +own YAML file and pass `--config`; [](configuration) covers the format. + +## Next steps + +- [](frames) for frames, layouts, and intensity scales +- [](inspecting) for reading pixel values and summary statistics +- [](live) for following a detector in real time diff --git a/pyproject.toml b/pyproject.toml index 4622d85..1ebf4d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "atlas" version = "0.1.0" description = "A configurable FITS image viewer" readme = "README.md" -requires-python = ">=3.14" +requires-python = ">=3.12" license = { text = "BSD-3-Clause" } authors = [ { name = "Prakriti Gupta", email = "pgupta@astro.caltech.edu" }, @@ -18,6 +18,8 @@ classifiers = [ "Environment :: X11 Applications :: Qt", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Astronomy", ] @@ -34,9 +36,20 @@ dependencies = [ # Only needed for the optional `zmq` tool, which is off by default. zmq = ["pyzmq"] dev = ["pylint", "pytest"] +# Building the documentation needs no Qt and no display, so these are kept +# separate from `dev` and `tox -e docs` installs them without atlas itself. +docs = [ + "sphinx>=7.2", + "shibuya>=2024.10.15", + "myst-parser>=3.0", + "sphinx-copybutton>=0.5", + "sphinx-design>=0.6", + "linkify-it-py>=2.0", +] [project.urls] Homepage = "https://github.com/CaltechOpticalObservatories/atlas" +Documentation = "https://github.com/CaltechOpticalObservatories/atlas/tree/main/docs" Issues = "https://github.com/CaltechOpticalObservatories/atlas/issues" [project.scripts] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1b3c801 --- /dev/null +++ b/tox.ini @@ -0,0 +1,56 @@ +[tox] +# Every check has one command that behaves the same locally and in CI. +env_list = tests, lint +# A missing 3.12 must fail rather than skip: a silent skip would report +# "congratulations" without having run a single test. The docs environment sets +# its own basepython, so it builds regardless of which interpreters are around. +skip_missing_interpreters = false + +[testenv] +basepython = python3.12 + +[testenv:tests] +description = Run the test suite +extras = + dev + zmq +setenv = + # The Qt tests need no display; this is what CI uses too. + QT_QPA_PLATFORM = offscreen +commands = pytest {posargs:-v} + +[testenv:lint] +description = Run pylint over every tracked Python file +extras = + dev + zmq +commands = pylint --rcfile=.pylintrc {posargs:src tests scripts docs/conf.py} + +[testenv:docs] +description = Build the HTML documentation +# atlas itself is deliberately not installed: the docs are hand written, so the +# build needs neither Qt nor a display. +skip_install = true +basepython = python3 +deps = + sphinx>=7.2 + shibuya>=2024.10.15 + myst-parser>=3.0 + sphinx-copybutton>=0.5 + sphinx-design>=0.6 + linkify-it-py>=2.0 +# -W turns warnings into errors, so a bad cross reference fails the build +# instead of producing a quietly broken page. +commands = + sphinx-build -b html -W --keep-going {posargs} docs docs/_build/html + python -c 'print("\ndocs built: docs/_build/html/index.html")' + +[testenv:serve] +description = Build the docs and rebuild on every save +skip_install = true +basepython = python3 +deps = + {[testenv:docs]deps} + sphinx-autobuild>=2024.2 +commands = + sphinx-autobuild -b html --open-browser --port 8000 {posargs} docs docs/_build/html