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
63 changes: 63 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Release

on:
push:
tags:
- 'v*'

permissions:
contents: write

jobs:
build:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install Linux Qt system libraries
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0

- name: Install build dependencies
run: pip install -r requirements-build.txt

- name: Build binary
run: pyinstaller AstroImageLab.spec

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/AstroImageLab-*.zip
if-no-files-found: error

release:
needs: build
runs-on: ubuntu-latest

steps:
- name: Download all platform artifacts
uses: actions/download-artifact@v4
with:
pattern: dist-*
merge-multiple: true
path: dist/

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*.zip
generate_release_notes: true
436 changes: 436 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions AstroImageLab.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
#
# build the local environment: conda env create -f d:\GitHub\AstroImageLab\environment.yml
#
# PR → merge to main (CI runs tests + build to verify everything works)
# Tag the merge commit on main → triggers the release workflow
# git tag v0.0.6
# git push origin v0.0.6

import sys
import os

Expand Down
13 changes: 6 additions & 7 deletions QuickStart.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

## Overview

Astro Image Lab is a dual-image narrowband filter characterisation tool for astrophotography.
Astro Image Lab is a dual-image characterisation tool for astrophotography.
Load two calibrated image stacks of the same sky target (captured through different filters,
different cameras, or different conditions), and the tool produces a side-by-side comparison
report covering:
different cameras, or different conditions), and the tool produces a side-by-side comparison report covering:

- **PSF / MTF** — star size, shape, and resolution limit
- **Halo analysis** — scattering around bright stars
Expand All @@ -20,11 +19,11 @@ Output is an HTML or PDF report and an interactive Report Inspector window.

## Prerequisites

- Two FITS or XISF image stacks (`.fits`, `.fit`, `.fts`, `.xisf`) of the **same sky target**,
- Two FITS, XISF, TIFF image stacks (`.fits`, `.fit`, `.fts`, `.xisf`, `.tiff`, `.tif`) of the **same sky target**,
registered and cropped to a common frame — see [Image Preparation](#image-preparation-before-you-open-the-app) below
- **Highly recommended:** starless versions of each image (generated with StarNet2,
GraXpert, StarXTerminator, or similar)
- Filter bandwidth in nm for each image (from the filter spec sheet — e.g. `3` for a 3 nm Hα filter)
GraXpert, StarXTerminator, SyQon Starless, or similar)
- If this is comparing two narrowband filters, the bandwidth in nm for each image (from the filter spec sheet — e.g. `3` for a 3 nm Hα filter)
- Filter thickness in mm for each image (from the filter spec sheet — typically 1–3 mm)
- An output folder with write access

Expand Down Expand Up @@ -75,7 +74,7 @@ to compare filter performance on the nebula itself. Starless images remove this
contamination.

**How to generate a starless image:**
- Run **StarNet2**, **GraXpert** (star removal mode), or PixInsight's **StarXTerminator**
- Run **StarNet2**, **GraXpert** (star removal mode), or **StarXTerminator**, or SyQon Starless on each of your final registered stacks.
on the final registered, calibrated stack.
- Save with a `_starless` suffix in the same folder as the main stack
(e.g. `image_starless.fits`). The app detects this naming pattern automatically on load.
Expand Down
9 changes: 6 additions & 3 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
if TYPE_CHECKING:
import matplotlib.figure


APP_VERSION = "0.0.6" # semver string; bump on each GitHub release tag

# === CONSTANTS ===

MIN_STAR_SNR = 30.0
Expand All @@ -23,7 +26,7 @@
FILTER_THICKNESS_MM = 1.0 # narrowband filter substrate thickness (mm); default for UI
GLASS_REFRACTIVE_INDEX = 1.9 # dichroic filter substrate refractive index
RDF_BIN_WIDTH = 1.0 # px; annular bin width for RDF mean/std computation
POWER_SPECTRUM_NPIX = 256
POWER_SPECTRUM_NPIX = 1024

STD_KERNEL_SIZES = (5, 15, 31)
LOG_SIGMAS = (1.5, 3.0, 6.0)
Expand All @@ -32,12 +35,12 @@

XS_LINE_ALPHA = 0.8 # alpha for all cross-section profile lines in reports
XS_SNR_REGION_WIDTH = 15 # px; width of bright/dark sample region for cross-section SNR
EPSF_MAX_STARS = 500 # maximum candidate stars passed to EPSFBuilder; limits computation time
EPSF_MAX_STARS = 600 # maximum candidate stars passed to EPSFBuilder; limits computation time
SECTION8_BORDER_CROP_FRACTION = 0.05 # fraction of each image dimension cropped from perimeter in Section 8 display maps
SECTION8_ANALYSIS_CMAP = "viridis" # colormap for Section 8 A/B analysis map panels (std, LoG, wavelet)

PSF_SPATIAL_MAP_SIZE = 150 # px; long-axis resolution of FWHM / eccentricity spatial maps
PSF_SPATIAL_MAP_SMOOTH_SIGMA = 2.0 # Gaussian smoothing sigma (px) applied to spatial maps before display
PSF_SPATIAL_MAP_SMOOTH_SIGMA = 5.0 # Gaussian smoothing sigma (px) applied to spatial maps before display
LABEL_MAX_LEN = 30 # characters; filenames longer than this are replaced with "Image A"/"Image B" in all plots and legends
SPLASH_DURATION_MS = 4000 # ms; minimum time the splash screen stays visible on startup
REF_SEEING_ARCSEC = 3.0 # arcsec; reference "good seeing" FWHM for the benchmark PSF in PSF/MTF reports
Expand Down
44 changes: 44 additions & 0 deletions core/update_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
import time
import urllib.request

from PyQt6.QtCore import QThread, pyqtSignal, QSettings

_GITHUB_API = "https://api.github.com/repos/brentmantooth/AstroImageLab/releases/latest"
_CHECK_INTERVAL_S = 7 * 86400 # 7 days


def _vtuple(v: str) -> tuple[int, ...]:
return tuple(int(x) for x in v.lstrip("v").split(".") if x.isdigit())


class UpdateCheckerThread(QThread):
update_available = pyqtSignal(str, str) # (current_version, latest_version)

def __init__(self, current_version: str, parent=None):
super().__init__(parent)
self._current = current_version

def run(self) -> None:
settings = QSettings("FilterImageComparator", "FilterImageComparator")
last = settings.value("last_version_check", 0.0, type=float)
now = time.time()
if now - last < _CHECK_INTERVAL_S:
return

try:
req = urllib.request.Request(
_GITHUB_API,
headers={"User-Agent": "AstroImageLab-UpdateChecker"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
latest = data.get("tag_name", "").lstrip("v")
if not latest:
return
settings.setValue("last_version_check", now)
if _vtuple(latest) > _vtuple(self._current):
self.update_available.emit(self._current, latest)
except Exception:
pass # network unavailable or rate-limited — fail silently
34 changes: 31 additions & 3 deletions gui/halo_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,29 @@
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QProgressBar, QSpinBox, QSplitter, QTableWidget,
QTableWidgetItem, QHeaderView, QWidget,
QTableWidgetItem, QHeaderView, QTextEdit, QWidget,
)

from core.astro_image import AstroImage
from core.stretch import normalize_for_display
from gui.image_panel import ZoomableImageLabel


_PROCESSING_NOTES_HTML = """
<b>Processing notes</b>
<p style="margin:2px 0 3px 0"><b>Image panels</b> show a raw (non-background-subtracted)
cutout. All quantitative analysis uses a global 2D background map (photutils
<i>Background2D</i>, 64×64 px mesh) subtracted from the full image first.</p>
<p style="margin:2px 0 3px 0"><b>PSF fit &amp; shape metrics</b> use a fixed 25×25 px
window regardless of sample radius. An additional local correction (median of the
outer 3-px border ring) is subtracted before fitting to remove nebula gradients.</p>
<p style="margin:2px 0 0px 0"><b>Background (ADU)</b> is the median of the outer
annulus (r ≥ 70% of sample radius) on the <i>background-subtracted</i> image —
a residual, not the raw sky level. It will differ from sky values in the main report.
SNR = peak (r &lt; 30%) ÷ σ(outer annulus).</p>
"""


def _moffat_fwhm(gamma: float, alpha: float) -> float:
return 2.0 * gamma * math.sqrt(2.0 ** (1.0 / alpha) - 1.0)

Expand Down Expand Up @@ -553,7 +568,7 @@ def _shape_metrics(self, bgsub: np.ndarray, xc: float, yc: float) -> dict | None
return {
"ecc": float(props.eccentricity.value),
"ell": float(props.ellipticity.value),
"orient": float(props.orientation.value), # degrees in photutils 3.x
"orient": float(props.orientation.value) % 180, # degrees in photutils 3.x; normalised to [0, 180)
}
except Exception:
return None
Expand Down Expand Up @@ -662,7 +677,20 @@ def _build_ui(self) -> None:
mid_splitter.addWidget(img_container)

self._table = self._build_table()
mid_splitter.addWidget(self._table)

right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(4)
right_layout.addWidget(self._table, stretch=1)

self._method_box = QTextEdit()
self._method_box.setReadOnly(True)
self._method_box.setHtml(_PROCESSING_NOTES_HTML)
self._method_box.setMaximumHeight(150)
right_layout.addWidget(self._method_box, stretch=0)

mid_splitter.addWidget(right_widget)
mid_splitter.setSizes([750, 350])

# --- Vertical splitter: middle area above, matplotlib figure below ---
Expand Down
35 changes: 29 additions & 6 deletions gui/main_window.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QAction
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtGui import QAction, QDesktopServices
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
QSplitter, QMessageBox, QFileDialog, QPushButton,
Expand All @@ -26,6 +26,7 @@ def __init__(self, parent=None):

self._build_ui()
self._build_menu()
self._start_update_check()

# ------------------------------------------------------------------
# UI layout
Expand Down Expand Up @@ -112,6 +113,10 @@ def _build_menu(self) -> None:
tools_menu.addAction(act_halo)

help_menu = mb.addMenu("&Help")
act_quickstart = QAction("&Quick Start Guide", self)
act_quickstart.triggered.connect(self._open_quickstart)
help_menu.addAction(act_quickstart)
help_menu.addSeparator()
act_about = QAction("&About", self)
act_about.triggered.connect(self._show_about)
help_menu.addAction(act_about)
Expand Down Expand Up @@ -352,14 +357,32 @@ def _on_error(self, msg: str) -> None:
self._control.set_run_enabled(True)
QMessageBox.critical(self, "Analysis error", msg)

def _start_update_check(self) -> None:
from core.update_checker import UpdateCheckerThread
from core.models import APP_VERSION
self._update_thread = UpdateCheckerThread(APP_VERSION, parent=self)
self._update_thread.update_available.connect(self._on_update_available)
self._update_thread.start()

def _on_update_available(self, current: str, latest: str) -> None:
self.statusBar().showMessage(
f"Update available: v{latest} (you have v{current}) — "
f"github.com/brentmantooth/AstroImageLab/releases",
0, # 0 = persistent until cleared
)

def _open_quickstart(self) -> None:
QDesktopServices.openUrl(QUrl("https://github.com/brentmantooth/AstroImageLab/blob/main/QuickStart.md"))

def _show_about(self) -> None:
from core.models import APP_VERSION
QMessageBox.about(
self,
"Filter Image Comparator",
"<b>Filter Image Comparator</b><br>"
"Astrophotography narrowband filter characterisation tool.<br><br>"
"Astro Image Lab",
f"<b>Astro Image Lab</b> v{APP_VERSION}<br>"
"Astrophotography Image characterisation tool to compare images.<br><br>"
"Metrics: PSF/MTF · Halo · Ghost · Edge · Power spectrum · "
"Spatial detail (std / LoG / wavelet)<br><br>"
"Supports FITS and XISF input formats.<br><br>"
"Supports FITS, XISF, TIFF input formats.<br><br>"
"Developed by: Brent Mantooth (bmantooth@gmail.com)",
)
11 changes: 11 additions & 0 deletions report/report_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2546,6 +2546,17 @@ def panel(arr, title, caption=""):

return f"""
<h3>PSF Simulation — test chart convolved at native pixel resolution</h3>
<p style="border-left:4px solid #e6a817;background:#fdf6e3;padding:0.6em 0.9em;margin-bottom:1em;border-radius:3px;">
<strong>Note on deconvolution tools:</strong> The ePSF and detail analysis in this section
are derived from star shape. If you applied deconvolution software such as
<em>BlurXTerminator</em>, <em>SyQon Parallax</em>, or similar tools, be aware that some of
these modify star shape independently of how they sharpen diffuse structure like nebula detail.
The convolved test-chart simulation below therefore reflects the measured star-based PSF and
may not accurately represent the sharpness of extended nebula structure in your image.
<strong>Section&nbsp;8 — Spatial Detail Comparison</strong> provides metrics that are
independent of star shape and will give a more accurate picture of diffuse-detail contrast and
resolution.
</p>
<p>
<strong>How this simulation works:</strong> During PSF analysis, the empirical Point Spread
Function (ePSF) is built by aligning and stacking the pixel profiles of the brightest, most
Expand Down
Loading