termfits 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termfits/__init__.py +7 -0
- termfits/__main__.py +4 -0
- termfits/viewer.py +725 -0
- termfits-0.1.0.dist-info/METADATA +162 -0
- termfits-0.1.0.dist-info/RECORD +9 -0
- termfits-0.1.0.dist-info/WHEEL +5 -0
- termfits-0.1.0.dist-info/entry_points.txt +2 -0
- termfits-0.1.0.dist-info/licenses/LICENSE +21 -0
- termfits-0.1.0.dist-info/top_level.txt +1 -0
termfits/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""termfits - a terminal FITS image viewer with zoom and pan, tuned for radio images."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .viewer import Viewer, main, robust_stats, CMAPS, STRETCHES # noqa: E402,F401
|
|
6
|
+
|
|
7
|
+
__all__ = ["Viewer", "main", "robust_stats", "CMAPS", "STRETCHES", "__version__"]
|
termfits/__main__.py
ADDED
termfits/viewer.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""
|
|
2
|
+
termfits - a terminal FITS image viewer with zoom and pan, tuned for radio images.
|
|
3
|
+
|
|
4
|
+
Renders with Unicode upper-half blocks (two image pixels per character cell),
|
|
5
|
+
so a 100 x 100 character view shows a 100 x 200 grid of near-square pixels.
|
|
6
|
+
|
|
7
|
+
Radio-friendly defaults:
|
|
8
|
+
* Display limits are set from a robust noise estimate (median and 1.4826 x MAD),
|
|
9
|
+
-1 sigma to +10 sigma, rather than from percentiles.
|
|
10
|
+
* Inverted greyscale, linear stretch. Colour maps include heat (DS9-style orange),
|
|
11
|
+
inferno, magma, viridis, cubehelix, and 'diverging', which is centred on the
|
|
12
|
+
median so negative sidelobes show blue and real emission red.
|
|
13
|
+
* Status bar shows the pixel value in BUNIT and in sigma, the rms, and the
|
|
14
|
+
restoring beam (BMAJ/BMIN/BPA); the beam is drawn in the lower-left corner.
|
|
15
|
+
* When zoomed out, image pixels are combined into blocks (never skipped).
|
|
16
|
+
'b' switches between block mean (lower noise, good for faint extended
|
|
17
|
+
emission) and block max (keeps faint compact sources visible).
|
|
18
|
+
|
|
19
|
+
Requires: numpy. astropy is recommended (compressed images, WCS / RA-Dec readout);
|
|
20
|
+
without it a built-in reader handles plain uncompressed images.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
termfits image.fits
|
|
24
|
+
termfits cube.fits --plane 10 --sigma-lo 2 --sigma-hi 20
|
|
25
|
+
termfits image.fits --snapshot # print one frame and exit
|
|
26
|
+
|
|
27
|
+
Keys:
|
|
28
|
+
arrows / h j k l pan (10% of view)
|
|
29
|
+
Shift+arrows / H J K L pan (50% of view)
|
|
30
|
+
+ / = zoom in - / _ zoom out
|
|
31
|
+
f fit image 1 1:1 (one image pixel per screen pixel)
|
|
32
|
+
r reset (fit + recentre)
|
|
33
|
+
< > lower / raise the upper display limit (in sigma)
|
|
34
|
+
( ) lower / raise the lower display limit (in sigma)
|
|
35
|
+
c cycle clip mode (sigma, 99.5, 99, 98, 95 percent, min/max)
|
|
36
|
+
n re-measure the noise from the region in view N back to global
|
|
37
|
+
b zoomed-out binning: block mean / block max
|
|
38
|
+
e toggle beam ellipse
|
|
39
|
+
s cycle stretch (linear, sqrt, log, asinh)
|
|
40
|
+
m / M next / previous colour map (inverted, gray, heat, inferno, magma,
|
|
41
|
+
viridis, cubehelix, cool, rainbow, diverging)
|
|
42
|
+
x toggle crosshair
|
|
43
|
+
[ ] previous / next plane (channels / Stokes)
|
|
44
|
+
Tab next image HDU
|
|
45
|
+
? help
|
|
46
|
+
q / Esc quit
|
|
47
|
+
"""
|
|
48
|
+
import argparse
|
|
49
|
+
import math
|
|
50
|
+
import os
|
|
51
|
+
import select
|
|
52
|
+
import signal
|
|
53
|
+
import sys
|
|
54
|
+
import warnings
|
|
55
|
+
|
|
56
|
+
import numpy as np
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from astropy.io import fits
|
|
60
|
+
except ImportError:
|
|
61
|
+
fits = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Minimal fallback FITS reader (used only if astropy is not installed).
|
|
66
|
+
# Handles uncompressed primary/IMAGE HDUs; skips tables. No WCS support.
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
class _MiniHDU:
|
|
69
|
+
def __init__(self, header, data, is_image):
|
|
70
|
+
self.header, self.data, self.is_image = header, data, is_image
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _MiniHDUList(list):
|
|
74
|
+
def close(self):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _mini_open(path):
|
|
79
|
+
dtypes = {8: ">u1", 16: ">i2", 32: ">i4", 64: ">i8", -32: ">f4", -64: ">f8"}
|
|
80
|
+
raw = np.memmap(path, dtype=np.uint8, mode="r")
|
|
81
|
+
pos, hdus = 0, _MiniHDUList()
|
|
82
|
+
while pos + 2880 <= raw.size:
|
|
83
|
+
header = {}
|
|
84
|
+
done = False
|
|
85
|
+
while not done:
|
|
86
|
+
block = bytes(raw[pos:pos + 2880]).decode("ascii", "replace")
|
|
87
|
+
pos += 2880
|
|
88
|
+
for i in range(0, 2880, 80):
|
|
89
|
+
card = block[i:i + 80]
|
|
90
|
+
key = card[:8].strip()
|
|
91
|
+
if key == "END":
|
|
92
|
+
done = True
|
|
93
|
+
break
|
|
94
|
+
if card[8:10] != "= ":
|
|
95
|
+
continue
|
|
96
|
+
val = card[10:]
|
|
97
|
+
if val.lstrip().startswith("'"):
|
|
98
|
+
v = val.lstrip()[1:]
|
|
99
|
+
header[key] = v[: v.find("'")].rstrip() if "'" in v else v.strip()
|
|
100
|
+
else:
|
|
101
|
+
v = val.split("/")[0].strip()
|
|
102
|
+
try:
|
|
103
|
+
header[key] = int(v)
|
|
104
|
+
except ValueError:
|
|
105
|
+
try:
|
|
106
|
+
header[key] = float(v.replace("D", "E"))
|
|
107
|
+
except ValueError:
|
|
108
|
+
header[key] = {"T": True, "F": False}.get(v, v)
|
|
109
|
+
if pos >= raw.size and not done:
|
|
110
|
+
return hdus
|
|
111
|
+
naxis = int(header.get("NAXIS", 0))
|
|
112
|
+
shape = [int(header.get(f"NAXIS{k}", 0)) for k in range(1, naxis + 1)]
|
|
113
|
+
bitpix = int(header.get("BITPIX", 8))
|
|
114
|
+
nelem = int(np.prod(shape)) if naxis else 0
|
|
115
|
+
nbytes = abs(bitpix) // 8 * (header.get("GCOUNT", 1) * (header.get("PCOUNT", 0) + nelem))
|
|
116
|
+
xt = str(header.get("XTENSION", "IMAGE")).strip()
|
|
117
|
+
is_image = (xt == "IMAGE" or "SIMPLE" in header) and naxis >= 2 and nelem > 0
|
|
118
|
+
data = None
|
|
119
|
+
if is_image:
|
|
120
|
+
arr = np.frombuffer(raw[pos:pos + nelem * abs(bitpix) // 8], dtype=dtypes[bitpix])
|
|
121
|
+
arr = arr.reshape(shape[::-1])
|
|
122
|
+
bs, bz = header.get("BSCALE", 1.0), header.get("BZERO", 0.0)
|
|
123
|
+
if bs != 1.0 or bz != 0.0:
|
|
124
|
+
arr = arr.astype(np.float32) * bs + bz
|
|
125
|
+
data = arr
|
|
126
|
+
hdus.append(_MiniHDU(header, data, is_image))
|
|
127
|
+
pos += -(-nbytes // 2880) * 2880
|
|
128
|
+
return hdus
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def open_fits(path):
|
|
132
|
+
if fits is not None:
|
|
133
|
+
return fits.open(path, memmap=True)
|
|
134
|
+
return _mini_open(path)
|
|
135
|
+
|
|
136
|
+
UPPER = "▀" # upper half block: fg = top pixel, bg = bottom pixel
|
|
137
|
+
|
|
138
|
+
def _cubehelix(start=0.5, rot=-1.5, hue=1.2, n=9):
|
|
139
|
+
"""Green (2011) cubehelix: brightness rises monotonically, so it prints well in grey too."""
|
|
140
|
+
out = []
|
|
141
|
+
for t in np.linspace(0, 1, n):
|
|
142
|
+
ang = 2 * math.pi * (start / 3 + 1 + rot * t)
|
|
143
|
+
amp = hue * t * (1 - t) / 2
|
|
144
|
+
c, s = math.cos(ang), math.sin(ang)
|
|
145
|
+
rgb = (t + amp * (-0.14861 * c + 1.78277 * s),
|
|
146
|
+
t + amp * (-0.29227 * c - 0.90649 * s),
|
|
147
|
+
t + amp * (1.97294 * c))
|
|
148
|
+
out.append(tuple(int(round(255 * min(1, max(0, v)))) for v in rgb))
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Sequential maps run low -> high. Maps listed in DIVERGING are centred on the
|
|
153
|
+
# median (zero for a clean radio image): negatives go one way, positives the other.
|
|
154
|
+
CMAPS = {
|
|
155
|
+
"inverted": [(255, 255, 255), (0, 0, 0)],
|
|
156
|
+
"gray": [(0, 0, 0), (255, 255, 255)],
|
|
157
|
+
"heat": [(0, 0, 0), (128, 0, 0), (255, 128, 0), (255, 255, 128), (255, 255, 255)],
|
|
158
|
+
"inferno": [(0, 0, 4), (31, 12, 72), (85, 15, 109), (136, 34, 106), (186, 54, 85),
|
|
159
|
+
(227, 89, 51), (249, 140, 10), (249, 201, 50), (252, 255, 164)],
|
|
160
|
+
"magma": [(0, 0, 4), (28, 16, 68), (79, 18, 123), (129, 37, 129), (181, 54, 122),
|
|
161
|
+
(229, 80, 100), (251, 135, 97), (254, 194, 135), (252, 253, 191)],
|
|
162
|
+
"viridis": [(68, 1, 84), (71, 45, 123), (59, 82, 139), (44, 114, 142), (33, 145, 140),
|
|
163
|
+
(39, 173, 129), (94, 201, 98), (170, 220, 50), (253, 231, 37)],
|
|
164
|
+
"cubehelix": _cubehelix(),
|
|
165
|
+
"cool": [(0, 0, 32), (0, 64, 160), (0, 200, 255), (255, 255, 255)],
|
|
166
|
+
"rainbow": [(48, 18, 59), (70, 107, 227), (40, 188, 235), (50, 241, 151), (164, 252, 60),
|
|
167
|
+
(237, 208, 58), (251, 128, 34), (210, 49, 5), (122, 4, 3)], # turbo-like
|
|
168
|
+
"diverging": [(5, 48, 97), (33, 102, 172), (103, 169, 207), (209, 229, 240), (247, 247, 247),
|
|
169
|
+
(253, 219, 199), (239, 138, 98), (178, 24, 43), (103, 0, 31)],
|
|
170
|
+
}
|
|
171
|
+
DIVERGING = {"diverging"}
|
|
172
|
+
STRETCHES = ["linear", "sqrt", "log", "asinh"]
|
|
173
|
+
CLIP_MODES = ["sigma", 99.5, 99.0, 98.0, 95.0, 100.0] # "sigma" = median + k * robust rms
|
|
174
|
+
|
|
175
|
+
OUTSIDE_RGB = (24, 24, 30) # beyond the image edge
|
|
176
|
+
NAN_RGB = (55, 40, 62) # NaN / blanked pixels (e.g. outside the primary-beam cut)
|
|
177
|
+
CROSS_RGB = (255, 60, 60)
|
|
178
|
+
BEAM_RGB = (0, 170, 230)
|
|
179
|
+
|
|
180
|
+
HELP_TEXT = __doc__.split("Keys:")[1].rstrip().splitlines()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def build_lut(anchors):
|
|
184
|
+
a = np.array(anchors, float)
|
|
185
|
+
x = np.linspace(0, 1, len(a))
|
|
186
|
+
t = np.linspace(0, 1, 256)
|
|
187
|
+
return np.stack([np.interp(t, x, a[:, k]) for k in range(3)], 1).astype(np.uint8)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
LUTS = {name: build_lut(a) for name, a in CMAPS.items()}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def apply_stretch(v, kind):
|
|
194
|
+
if kind == "sqrt":
|
|
195
|
+
return np.sqrt(v)
|
|
196
|
+
if kind == "log":
|
|
197
|
+
return np.log1p(1000 * v) / np.log1p(1000)
|
|
198
|
+
if kind == "asinh":
|
|
199
|
+
return np.arcsinh(10 * v) / np.arcsinh(10)
|
|
200
|
+
return v
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def fmt_sexa(ra, dec):
|
|
204
|
+
ra %= 360.0
|
|
205
|
+
h = ra / 15.0
|
|
206
|
+
hh = int(h); mm = int((h - hh) * 60); ss = ((h - hh) * 60 - mm) * 60
|
|
207
|
+
sign = "-" if dec < 0 else "+"
|
|
208
|
+
d = abs(dec)
|
|
209
|
+
dd = int(d); dm = int((d - dd) * 60); ds = ((d - dd) * 60 - dm) * 60
|
|
210
|
+
return f"{hh:02d}:{mm:02d}:{ss:05.2f} {sign}{dd:02d}:{dm:02d}:{ds:04.1f}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def robust_stats(a, max_samples=1_000_000):
|
|
214
|
+
"""Median and MAD-based rms (1.4826 * MAD) of the finite values in a."""
|
|
215
|
+
flat = np.asarray(a).ravel()
|
|
216
|
+
if flat.size > 2 * max_samples:
|
|
217
|
+
flat = flat[:: flat.size // max_samples]
|
|
218
|
+
flat = flat[np.isfinite(flat)]
|
|
219
|
+
if flat.size == 0:
|
|
220
|
+
return 0.0, 1.0, flat
|
|
221
|
+
med = float(np.median(flat))
|
|
222
|
+
rms = 1.4826 * float(np.median(np.abs(flat - med)))
|
|
223
|
+
if not rms > 0:
|
|
224
|
+
rms = float(np.std(flat)) or 1.0
|
|
225
|
+
return med, rms, flat
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class Viewer:
|
|
229
|
+
STATUS_LINES = 4
|
|
230
|
+
|
|
231
|
+
def __init__(self, path, args):
|
|
232
|
+
self.path = path
|
|
233
|
+
self.hdul = open_fits(path)
|
|
234
|
+
self.hdus = [i for i, h in enumerate(self.hdul)
|
|
235
|
+
if h.is_image and h.header.get("NAXIS", 0) >= 2]
|
|
236
|
+
if not self.hdus:
|
|
237
|
+
sys.exit(f"No 2-D (or higher) image HDUs found in {path}")
|
|
238
|
+
if args.hdu is not None:
|
|
239
|
+
if args.hdu not in self.hdus:
|
|
240
|
+
sys.exit(f"HDU {args.hdu} is not an image HDU; image HDUs: {self.hdus}")
|
|
241
|
+
self.hi = self.hdus.index(args.hdu)
|
|
242
|
+
else:
|
|
243
|
+
self.hi = 0
|
|
244
|
+
self.req_w, self.req_h = args.width, args.height
|
|
245
|
+
self.truecolor = not args.colors256
|
|
246
|
+
self.plane = args.plane
|
|
247
|
+
self.stretch_i = STRETCHES.index(args.stretch)
|
|
248
|
+
self.clip_i = CLIP_MODES.index("sigma" if args.clip == "sigma" else float(args.clip))
|
|
249
|
+
self.sig_lo, self.sig_hi = args.sigma_lo, args.sigma_hi
|
|
250
|
+
self.cmap_names = list(CMAPS)
|
|
251
|
+
self.cmap_i = self.cmap_names.index(args.cmap)
|
|
252
|
+
self.binmode = args.bin
|
|
253
|
+
self.cross = True
|
|
254
|
+
self.show_beam = True
|
|
255
|
+
self.show_help = False
|
|
256
|
+
self.message = ""
|
|
257
|
+
self.last_b = 1
|
|
258
|
+
self.update_geometry()
|
|
259
|
+
self.load_plane()
|
|
260
|
+
self.fit()
|
|
261
|
+
|
|
262
|
+
# ---------------- data ----------------
|
|
263
|
+
def load_plane(self):
|
|
264
|
+
hdu = self.hdul[self.hdus[self.hi]]
|
|
265
|
+
data = hdu.data
|
|
266
|
+
planes = data.reshape(-1, data.shape[-2], data.shape[-1])
|
|
267
|
+
self.nplanes = planes.shape[0]
|
|
268
|
+
self.plane = max(0, min(self.plane, self.nplanes - 1))
|
|
269
|
+
self.img = np.asarray(planes[self.plane], dtype=np.float32)
|
|
270
|
+
self.ny, self.nx = self.img.shape
|
|
271
|
+
self.header = hdu.header
|
|
272
|
+
self.pyramid = {}
|
|
273
|
+
self.read_header_info()
|
|
274
|
+
self.med, self.rms, self._sample = robust_stats(self.img)
|
|
275
|
+
self.noise_src = "global"
|
|
276
|
+
self.compute_limits()
|
|
277
|
+
|
|
278
|
+
def read_header_info(self):
|
|
279
|
+
h = self.header
|
|
280
|
+
self.wcs = None
|
|
281
|
+
try:
|
|
282
|
+
from astropy.wcs import WCS, FITSFixedWarning
|
|
283
|
+
with warnings.catch_warnings():
|
|
284
|
+
warnings.simplefilter("ignore", FITSFixedWarning)
|
|
285
|
+
w = WCS(h)
|
|
286
|
+
if w.has_celestial:
|
|
287
|
+
self.wcs = w.celestial
|
|
288
|
+
except Exception:
|
|
289
|
+
self.wcs = None
|
|
290
|
+
self.bunit = str(h.get("BUNIT", "")).strip()
|
|
291
|
+
pix = h.get("CDELT2", h.get("CD2_2", None))
|
|
292
|
+
self.pixscale = abs(float(pix)) if pix not in (None, 0) else None # degrees
|
|
293
|
+
try:
|
|
294
|
+
self.beam = (float(h["BMAJ"]), float(h["BMIN"]), float(h.get("BPA", 0.0)))
|
|
295
|
+
except (KeyError, TypeError, ValueError):
|
|
296
|
+
self.beam = None
|
|
297
|
+
|
|
298
|
+
def compute_limits(self):
|
|
299
|
+
mode = CLIP_MODES[self.clip_i]
|
|
300
|
+
if mode == "sigma":
|
|
301
|
+
self.vmin = self.med - self.sig_lo * self.rms
|
|
302
|
+
self.vmax = self.med + self.sig_hi * self.rms
|
|
303
|
+
else:
|
|
304
|
+
flat = self._sample
|
|
305
|
+
if flat.size == 0:
|
|
306
|
+
self.vmin, self.vmax = 0.0, 1.0
|
|
307
|
+
else:
|
|
308
|
+
lo, hi = (100 - mode) / 2, 100 - (100 - mode) / 2
|
|
309
|
+
self.vmin, self.vmax = (float(v) for v in np.percentile(flat, [lo, hi]))
|
|
310
|
+
if not self.vmax > self.vmin:
|
|
311
|
+
self.vmax = self.vmin + (self.rms or 1.0)
|
|
312
|
+
|
|
313
|
+
def noise_from_view(self):
|
|
314
|
+
half_w, half_h = self.W / 2 / self.zoom, self.H / 2 / self.zoom
|
|
315
|
+
x0, x1 = int(max(0, self.cx - half_w)), int(min(self.nx, self.cx + half_w + 1))
|
|
316
|
+
y0, y1 = int(max(0, self.cy - half_h)), int(min(self.ny, self.cy + half_h + 1))
|
|
317
|
+
region = self.img[y0:y1, x0:x1]
|
|
318
|
+
if np.isfinite(region).sum() < 100:
|
|
319
|
+
self.message = "too few valid pixels in view to estimate noise"
|
|
320
|
+
return
|
|
321
|
+
self.med, self.rms, _ = robust_stats(region)
|
|
322
|
+
self.noise_src = "view"
|
|
323
|
+
self.compute_limits()
|
|
324
|
+
|
|
325
|
+
def binned(self, b):
|
|
326
|
+
"""Downsampled copy for zoomed-out display: block mean or block max."""
|
|
327
|
+
if b == 1:
|
|
328
|
+
return self.img
|
|
329
|
+
key = (b, self.binmode)
|
|
330
|
+
if key not in self.pyramid:
|
|
331
|
+
ny2, nx2 = -(-self.ny // b) * b, -(-self.nx // b) * b
|
|
332
|
+
a = np.full((ny2, nx2), np.nan, np.float32)
|
|
333
|
+
a[: self.ny, : self.nx] = self.img
|
|
334
|
+
a = a.reshape(ny2 // b, b, nx2 // b, b)
|
|
335
|
+
with warnings.catch_warnings(), np.errstate(all="ignore"):
|
|
336
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
337
|
+
f = np.nanmax if self.binmode == "max" else np.nanmean
|
|
338
|
+
self.pyramid[key] = f(a, axis=(1, 3)).astype(np.float32)
|
|
339
|
+
return self.pyramid[key]
|
|
340
|
+
|
|
341
|
+
# ---------------- geometry ----------------
|
|
342
|
+
def update_geometry(self):
|
|
343
|
+
try:
|
|
344
|
+
ts = os.get_terminal_size()
|
|
345
|
+
tcols, trows = ts.columns, ts.lines
|
|
346
|
+
except OSError:
|
|
347
|
+
tcols, trows = self.req_w, self.req_h + self.STATUS_LINES
|
|
348
|
+
self.cols = max(10, min(self.req_w, tcols))
|
|
349
|
+
self.rows = max(5, min(self.req_h, trows - self.STATUS_LINES))
|
|
350
|
+
self.small = (self.cols < self.req_w) or (self.rows < self.req_h)
|
|
351
|
+
self.W, self.H = self.cols, self.rows * 2 # pixel grid (half blocks)
|
|
352
|
+
|
|
353
|
+
def fit_zoom(self):
|
|
354
|
+
return min(self.W / self.nx, self.H / self.ny)
|
|
355
|
+
|
|
356
|
+
def fit(self):
|
|
357
|
+
self.cx, self.cy = (self.nx - 1) / 2, (self.ny - 1) / 2
|
|
358
|
+
self.zoom = self.fit_zoom()
|
|
359
|
+
|
|
360
|
+
def set_zoom(self, z):
|
|
361
|
+
self.zoom = max(0.25 * self.fit_zoom(), min(64.0, z))
|
|
362
|
+
|
|
363
|
+
def pan(self, dx_screen, dy_screen):
|
|
364
|
+
self.cx += dx_screen / self.zoom
|
|
365
|
+
self.cy += dy_screen / self.zoom
|
|
366
|
+
self.cx = max(0, min(self.nx - 1, self.cx))
|
|
367
|
+
self.cy = max(0, min(self.ny - 1, self.cy))
|
|
368
|
+
|
|
369
|
+
# ---------------- rendering ----------------
|
|
370
|
+
def render_rgb(self):
|
|
371
|
+
s = self.zoom
|
|
372
|
+
# Bin by a power of two >= 1/zoom so no image pixel is ever skipped.
|
|
373
|
+
b = 1 if s >= 1 else 2 ** int(math.ceil(math.log2(1 / s) - 1e-9))
|
|
374
|
+
self.last_b = b
|
|
375
|
+
src = self.binned(b)
|
|
376
|
+
sh, sw = src.shape
|
|
377
|
+
i0, j0 = self.W // 2, self.H // 2
|
|
378
|
+
x = self.cx + (np.arange(self.W) - i0) / s
|
|
379
|
+
y = self.cy - (np.arange(self.H) - j0) / s # FITS y increases upward
|
|
380
|
+
xb = np.floor((x + 0.5) / b).astype(np.int64)
|
|
381
|
+
yb = np.floor((y + 0.5) / b).astype(np.int64)
|
|
382
|
+
okx = (x >= -0.5) & (x < self.nx - 0.5)
|
|
383
|
+
oky = (y >= -0.5) & (y < self.ny - 0.5)
|
|
384
|
+
vals = src[np.clip(yb, 0, sh - 1)[:, None], np.clip(xb, 0, sw - 1)[None, :]]
|
|
385
|
+
outside = ~(oky[:, None] & okx[None, :])
|
|
386
|
+
nan = ~np.isfinite(vals) & ~outside
|
|
387
|
+
|
|
388
|
+
cmap = self.cmap_names[self.cmap_i]
|
|
389
|
+
with np.errstate(all="ignore"):
|
|
390
|
+
if cmap in DIVERGING:
|
|
391
|
+
# Symmetric about the median: white = median, blue = negative, red = positive.
|
|
392
|
+
span = max(self.vmax - self.med, self.med - self.vmin)
|
|
393
|
+
d = np.nan_to_num((vals - self.med) / span, nan=0.0)
|
|
394
|
+
mag = apply_stretch(np.clip(np.abs(d), 0, 1), STRETCHES[self.stretch_i])
|
|
395
|
+
v = 0.5 + 0.5 * np.sign(d) * mag
|
|
396
|
+
else:
|
|
397
|
+
v = (vals - self.vmin) / (self.vmax - self.vmin)
|
|
398
|
+
v = np.clip(np.nan_to_num(v, nan=0.0), 0, 1)
|
|
399
|
+
v = apply_stretch(v, STRETCHES[self.stretch_i])
|
|
400
|
+
rgb = LUTS[cmap][(np.clip(v, 0, 1) * 255).astype(np.uint8)]
|
|
401
|
+
rgb[outside] = OUTSIDE_RGB
|
|
402
|
+
rgb[nan] = NAN_RGB
|
|
403
|
+
|
|
404
|
+
if self.show_beam:
|
|
405
|
+
self.draw_beam(rgb)
|
|
406
|
+
if self.cross:
|
|
407
|
+
for d in (2, 3, 4):
|
|
408
|
+
for (yy, xx) in ((j0, i0 - d), (j0, i0 + d), (j0 - d, i0), (j0 + d, i0)):
|
|
409
|
+
if 0 <= yy < self.H and 0 <= xx < self.W:
|
|
410
|
+
rgb[yy, xx] = CROSS_RGB
|
|
411
|
+
return rgb
|
|
412
|
+
|
|
413
|
+
def beam_pixels(self):
|
|
414
|
+
if self.beam is None or self.pixscale is None:
|
|
415
|
+
return None
|
|
416
|
+
bmaj, bmin, bpa = self.beam
|
|
417
|
+
return bmaj / self.pixscale, bmin / self.pixscale, bpa
|
|
418
|
+
|
|
419
|
+
def draw_beam(self, rgb):
|
|
420
|
+
"""Filled restoring-beam ellipse in the lower-left corner (north up, east left)."""
|
|
421
|
+
bp = self.beam_pixels()
|
|
422
|
+
if bp is None:
|
|
423
|
+
return
|
|
424
|
+
a = bp[0] / 2 * self.zoom
|
|
425
|
+
b = bp[1] / 2 * self.zoom
|
|
426
|
+
if a < 0.75 or a > self.H / 4:
|
|
427
|
+
return
|
|
428
|
+
th = math.radians(bp[2])
|
|
429
|
+
u = (-math.sin(th), -math.cos(th)) # major axis, screen coords (y down)
|
|
430
|
+
v = (math.cos(th), -math.sin(th))
|
|
431
|
+
r = int(math.ceil(a)) + 1
|
|
432
|
+
ex, ey = 2 + r, self.H - 3 - r
|
|
433
|
+
yy, xx = np.mgrid[ey - r: ey + r + 1, ex - r: ex + r + 1]
|
|
434
|
+
dx, dy = xx - ex, yy - ey
|
|
435
|
+
p = dx * u[0] + dy * u[1]
|
|
436
|
+
q = dx * v[0] + dy * v[1]
|
|
437
|
+
inside = (p / a) ** 2 + (q / max(b, 0.5)) ** 2 <= 1
|
|
438
|
+
ok = inside & (yy >= 0) & (yy < self.H) & (xx >= 0) & (xx < self.W)
|
|
439
|
+
rgb[yy[ok], xx[ok]] = BEAM_RGB
|
|
440
|
+
|
|
441
|
+
def color_code(self, rgb, fg):
|
|
442
|
+
if self.truecolor:
|
|
443
|
+
return f"{38 if fg else 48};2;{rgb[0]};{rgb[1]};{rgb[2]}"
|
|
444
|
+
r, g, b = (int(round(c / 255 * 5)) for c in rgb)
|
|
445
|
+
if r == g == b:
|
|
446
|
+
gray = int(round(rgb[0] / 255 * 25)) # use the finer gray ramp
|
|
447
|
+
idx = 16 if gray == 0 else (231 if gray == 25 else 231 + gray)
|
|
448
|
+
else:
|
|
449
|
+
idx = 16 + 36 * r + 6 * g + b
|
|
450
|
+
return f"{38 if fg else 48};5;{idx}"
|
|
451
|
+
|
|
452
|
+
def frame(self, home=True):
|
|
453
|
+
rgb = self.render_rgb().tolist()
|
|
454
|
+
out = ["\x1b[H" if home else ""]
|
|
455
|
+
for r in range(self.rows):
|
|
456
|
+
if home:
|
|
457
|
+
out.append(f"\x1b[{r + 1};1H")
|
|
458
|
+
top, bot = rgb[2 * r], rgb[2 * r + 1]
|
|
459
|
+
prev = None
|
|
460
|
+
for c in range(self.cols):
|
|
461
|
+
key = (tuple(top[c]), tuple(bot[c]))
|
|
462
|
+
if key != prev:
|
|
463
|
+
out.append(f"\x1b[{self.color_code(key[0], True)};"
|
|
464
|
+
f"{self.color_code(key[1], False)}m")
|
|
465
|
+
prev = key
|
|
466
|
+
out.append(UPPER)
|
|
467
|
+
out.append("\x1b[0m\x1b[K" + ("" if home else "\n"))
|
|
468
|
+
out.extend(self.status(home))
|
|
469
|
+
return "".join(out)
|
|
470
|
+
|
|
471
|
+
# ---------------- status ----------------
|
|
472
|
+
def flux_fmt(self):
|
|
473
|
+
"""Returns (scale, unit label) chosen so the rms reads as a sensible number."""
|
|
474
|
+
u = self.bunit
|
|
475
|
+
if "JY" in u.upper():
|
|
476
|
+
rest = u[2:] if u.upper().startswith("JY") else ""
|
|
477
|
+
rest = "/beam" if rest.upper() == "/BEAM" else rest
|
|
478
|
+
for scale, prefix in ((1, ""), (1e3, "m"), (1e6, "µ"), (1e9, "n")):
|
|
479
|
+
if abs(self.rms) * scale >= 1 or scale == 1e9:
|
|
480
|
+
return scale, f"{prefix}Jy{rest}"
|
|
481
|
+
return 1.0, u
|
|
482
|
+
|
|
483
|
+
def pixel_value(self):
|
|
484
|
+
ix, iy = int(round(self.cx)), int(round(self.cy))
|
|
485
|
+
if 0 <= ix < self.nx and 0 <= iy < self.ny:
|
|
486
|
+
return ix, iy, float(self.img[iy, ix])
|
|
487
|
+
return ix, iy, float("nan")
|
|
488
|
+
|
|
489
|
+
def status(self, home):
|
|
490
|
+
ix, iy, val = self.pixel_value()
|
|
491
|
+
scale, unit = self.flux_fmt()
|
|
492
|
+
hdu_idx = self.hdus[self.hi]
|
|
493
|
+
name = self.header.get("EXTNAME", "PRIMARY" if hdu_idx == 0 else "")
|
|
494
|
+
plane = f" plane {self.plane + 1}/{self.nplanes}" if self.nplanes > 1 else ""
|
|
495
|
+
binning = f" (bin {self.last_b} {self.binmode})" if self.last_b > 1 else ""
|
|
496
|
+
l1 = (f"{os.path.basename(self.path)} HDU {hdu_idx} {name} "
|
|
497
|
+
f"{self.nx}x{self.ny}{plane} zoom {self.zoom:.3g}x{binning}")
|
|
498
|
+
|
|
499
|
+
if math.isfinite(val):
|
|
500
|
+
snr = (val - self.med) / self.rms
|
|
501
|
+
l2 = f"pix ({ix + 1}, {iy + 1}) {val * scale:.4g} {unit} {snr:+.1f}σ"
|
|
502
|
+
else:
|
|
503
|
+
l2 = f"pix ({ix + 1}, {iy + 1}) blank"
|
|
504
|
+
if self.wcs is not None:
|
|
505
|
+
try:
|
|
506
|
+
ra, dec = self.wcs.pixel_to_world_values(self.cx, self.cy)
|
|
507
|
+
l2 += f" {fmt_sexa(float(ra), float(dec))}"
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
mode = CLIP_MODES[self.clip_i]
|
|
512
|
+
if mode == "sigma":
|
|
513
|
+
clip = f"{-self.sig_lo:+g}σ..{self.sig_hi:+.3g}σ"
|
|
514
|
+
else:
|
|
515
|
+
clip = "min/max" if mode == 100 else f"{mode}%"
|
|
516
|
+
l3 = f"clip {clip} rms {self.rms * scale:.3g} {unit} ({self.noise_src})"
|
|
517
|
+
bp = self.beam_pixels()
|
|
518
|
+
if self.beam is not None:
|
|
519
|
+
l3 += (f" beam {self.beam[0] * 3600:.3g}\"x{self.beam[1] * 3600:.3g}\" "
|
|
520
|
+
f"{self.beam[2]:.0f}°")
|
|
521
|
+
if bp is not None:
|
|
522
|
+
l3 += f" ({bp[0]:.1f}px)"
|
|
523
|
+
l3 += f" {STRETCHES[self.stretch_i]} {self.cmap_names[self.cmap_i]}"
|
|
524
|
+
|
|
525
|
+
l4 = self.message or ("arrows pan +/- zoom f fit 1 1:1 < > max ( ) min "
|
|
526
|
+
"c clip n noise b bin ? help q quit")
|
|
527
|
+
if self.small:
|
|
528
|
+
l4 = f"(terminal too small for {self.req_w}x{self.req_h}; showing {self.cols}x{self.rows}) " + l4
|
|
529
|
+
out = []
|
|
530
|
+
for k, line in enumerate([l1, l2, l3, l4]):
|
|
531
|
+
line = line[: max(self.cols, 40)]
|
|
532
|
+
style = "\x1b[7m" if k == 0 else ("\x1b[2m" if k == 3 else "")
|
|
533
|
+
if home:
|
|
534
|
+
out.append(f"\x1b[{self.rows + 1 + k};1H{style}{line}\x1b[0m\x1b[K")
|
|
535
|
+
else:
|
|
536
|
+
out.append(line + "\n")
|
|
537
|
+
return out
|
|
538
|
+
|
|
539
|
+
def help_frame(self):
|
|
540
|
+
out = ["\x1b[H\x1b[2J", "\x1b[1m termfits - keys\x1b[0m\r\n\r\n"]
|
|
541
|
+
for line in HELP_TEXT:
|
|
542
|
+
out.append(line + "\r\n")
|
|
543
|
+
out.append("\r\n press any key to return")
|
|
544
|
+
return "".join(out)
|
|
545
|
+
|
|
546
|
+
# ---------------- input ----------------
|
|
547
|
+
def handle(self, key):
|
|
548
|
+
self.message = ""
|
|
549
|
+
step, big = self.W / 10, self.W / 2
|
|
550
|
+
stepy, bigy = self.H / 10, self.H / 2
|
|
551
|
+
moves = {
|
|
552
|
+
"LEFT": (-step, 0), "RIGHT": (step, 0), "UP": (0, stepy), "DOWN": (0, -stepy),
|
|
553
|
+
"h": (-step, 0), "l": (step, 0), "k": (0, stepy), "j": (0, -stepy),
|
|
554
|
+
"S-LEFT": (-big, 0), "S-RIGHT": (big, 0), "S-UP": (0, bigy), "S-DOWN": (0, -bigy),
|
|
555
|
+
"H": (-big, 0), "L": (big, 0), "K": (0, bigy), "J": (0, -bigy),
|
|
556
|
+
}
|
|
557
|
+
if key in moves:
|
|
558
|
+
self.pan(*moves[key])
|
|
559
|
+
elif key in ("+", "="):
|
|
560
|
+
self.set_zoom(self.zoom * math.sqrt(2))
|
|
561
|
+
elif key in ("-", "_"):
|
|
562
|
+
self.set_zoom(self.zoom / math.sqrt(2))
|
|
563
|
+
elif key == "f":
|
|
564
|
+
self.zoom = self.fit_zoom()
|
|
565
|
+
elif key == "1":
|
|
566
|
+
self.zoom = 1.0
|
|
567
|
+
elif key == "r":
|
|
568
|
+
self.fit()
|
|
569
|
+
elif key in ("<", ",", ">", ".", "(", ")"):
|
|
570
|
+
self.clip_i = 0 # these keys work in sigma mode
|
|
571
|
+
if key in ("<", ","):
|
|
572
|
+
self.sig_hi = max(0.5, self.sig_hi / 1.25)
|
|
573
|
+
elif key in (">", "."):
|
|
574
|
+
self.sig_hi = min(1e5, self.sig_hi * 1.25)
|
|
575
|
+
elif key == "(":
|
|
576
|
+
self.sig_lo += 0.5
|
|
577
|
+
elif key == ")" and self.sig_hi + (self.sig_lo - 0.5) > 0.4:
|
|
578
|
+
self.sig_lo -= 0.5
|
|
579
|
+
self.compute_limits()
|
|
580
|
+
elif key == "c":
|
|
581
|
+
self.clip_i = (self.clip_i + 1) % len(CLIP_MODES)
|
|
582
|
+
self.compute_limits()
|
|
583
|
+
elif key == "n":
|
|
584
|
+
self.noise_from_view()
|
|
585
|
+
elif key == "N":
|
|
586
|
+
self.med, self.rms, _ = robust_stats(self.img)
|
|
587
|
+
self.noise_src = "global"
|
|
588
|
+
self.compute_limits()
|
|
589
|
+
elif key == "b":
|
|
590
|
+
self.binmode = "max" if self.binmode == "mean" else "mean"
|
|
591
|
+
self.message = f"zoomed-out binning: block {self.binmode}"
|
|
592
|
+
elif key == "e":
|
|
593
|
+
self.show_beam = not self.show_beam
|
|
594
|
+
elif key == "s":
|
|
595
|
+
self.stretch_i = (self.stretch_i + 1) % len(STRETCHES)
|
|
596
|
+
elif key == "m":
|
|
597
|
+
self.cmap_i = (self.cmap_i + 1) % len(self.cmap_names)
|
|
598
|
+
self.message = f"colour map: {self.cmap_names[self.cmap_i]}"
|
|
599
|
+
elif key == "M":
|
|
600
|
+
self.cmap_i = (self.cmap_i - 1) % len(self.cmap_names)
|
|
601
|
+
self.message = f"colour map: {self.cmap_names[self.cmap_i]}"
|
|
602
|
+
elif key == "x":
|
|
603
|
+
self.cross = not self.cross
|
|
604
|
+
elif key in ("[", "]"):
|
|
605
|
+
if self.nplanes > 1:
|
|
606
|
+
self.plane = (self.plane + (1 if key == "]" else -1)) % self.nplanes
|
|
607
|
+
self.load_plane()
|
|
608
|
+
else:
|
|
609
|
+
self.message = "single-plane image"
|
|
610
|
+
elif key == "TAB":
|
|
611
|
+
if len(self.hdus) > 1:
|
|
612
|
+
self.hi = (self.hi + 1) % len(self.hdus)
|
|
613
|
+
self.plane = 0
|
|
614
|
+
self.load_plane()
|
|
615
|
+
self.fit()
|
|
616
|
+
else:
|
|
617
|
+
self.message = "only one image HDU in this file"
|
|
618
|
+
elif key == "?":
|
|
619
|
+
self.show_help = True
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def read_key(fd):
|
|
623
|
+
ch = os.read(fd, 1)
|
|
624
|
+
if ch == b"\x1b":
|
|
625
|
+
r, _, _ = select.select([fd], [], [], 0.03)
|
|
626
|
+
if not r:
|
|
627
|
+
return "ESC"
|
|
628
|
+
seq = os.read(fd, 16)
|
|
629
|
+
arrows = {b"A": "UP", b"B": "DOWN", b"C": "RIGHT", b"D": "LEFT"}
|
|
630
|
+
if seq[:1] in (b"[", b"O") and seq[-1:] in arrows:
|
|
631
|
+
name = arrows[seq[-1:]]
|
|
632
|
+
return ("S-" + name) if b";2" in seq else name
|
|
633
|
+
return None
|
|
634
|
+
if ch == b"\t":
|
|
635
|
+
return "TAB"
|
|
636
|
+
try:
|
|
637
|
+
return ch.decode()
|
|
638
|
+
except UnicodeDecodeError:
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def run(viewer):
|
|
643
|
+
try:
|
|
644
|
+
import termios
|
|
645
|
+
import tty
|
|
646
|
+
except ImportError:
|
|
647
|
+
sys.exit("termfits needs a POSIX terminal (Linux, macOS or WSL on Windows). "
|
|
648
|
+
"--snapshot works anywhere.")
|
|
649
|
+
fd = sys.stdin.fileno()
|
|
650
|
+
if not os.isatty(fd):
|
|
651
|
+
sys.exit("stdin is not a terminal (use --snapshot for non-interactive output)")
|
|
652
|
+
old = termios.tcgetattr(fd)
|
|
653
|
+
resized = {"flag": False}
|
|
654
|
+
signal.signal(signal.SIGWINCH, lambda *_: resized.update(flag=True))
|
|
655
|
+
w = sys.stdout.write
|
|
656
|
+
try:
|
|
657
|
+
tty.setcbreak(fd)
|
|
658
|
+
w("\x1b[?1049h\x1b[?25l\x1b[2J")
|
|
659
|
+
while True:
|
|
660
|
+
if resized["flag"]:
|
|
661
|
+
resized["flag"] = False
|
|
662
|
+
viewer.update_geometry()
|
|
663
|
+
w("\x1b[2J")
|
|
664
|
+
if viewer.show_help:
|
|
665
|
+
w(viewer.help_frame())
|
|
666
|
+
sys.stdout.flush()
|
|
667
|
+
read_key(fd)
|
|
668
|
+
viewer.show_help = False
|
|
669
|
+
w("\x1b[2J")
|
|
670
|
+
continue
|
|
671
|
+
w(viewer.frame())
|
|
672
|
+
sys.stdout.flush()
|
|
673
|
+
try:
|
|
674
|
+
key = read_key(fd)
|
|
675
|
+
except InterruptedError:
|
|
676
|
+
continue
|
|
677
|
+
if key in ("q", "Q", "ESC"):
|
|
678
|
+
break
|
|
679
|
+
if key:
|
|
680
|
+
viewer.handle(key)
|
|
681
|
+
finally:
|
|
682
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
683
|
+
w("\x1b[0m\x1b[?25h\x1b[?1049l")
|
|
684
|
+
sys.stdout.flush()
|
|
685
|
+
viewer.hdul.close()
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def main(argv=None):
|
|
689
|
+
from . import __version__
|
|
690
|
+
p = argparse.ArgumentParser(
|
|
691
|
+
prog="termfits",
|
|
692
|
+
description="Terminal FITS image viewer with zoom and pan, tuned for radio images.",
|
|
693
|
+
epilog="Press ? inside the viewer for the key list.")
|
|
694
|
+
p.add_argument("file", help="FITS file")
|
|
695
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
696
|
+
p.add_argument("--hdu", type=int, help="HDU index to open (default: first image HDU)")
|
|
697
|
+
p.add_argument("--plane", type=int, default=0, help="starting plane for cubes (0-based)")
|
|
698
|
+
p.add_argument("--width", type=int, default=100, help="view width in characters (default 100)")
|
|
699
|
+
p.add_argument("--height", type=int, default=100, help="view height in characters (default 100)")
|
|
700
|
+
p.add_argument("--clip", choices=["sigma", "99.5", "99", "98", "95", "100"], default="sigma",
|
|
701
|
+
help="display limits: sigma (median + k*rms, default) or a percentile")
|
|
702
|
+
p.add_argument("--sigma-lo", type=float, default=1.0, help="lower limit in rms below median (default 1)")
|
|
703
|
+
p.add_argument("--sigma-hi", type=float, default=10.0, help="upper limit in rms above median (default 10)")
|
|
704
|
+
p.add_argument("--stretch", choices=STRETCHES, default="linear")
|
|
705
|
+
p.add_argument("--cmap", choices=list(CMAPS), default="inverted",
|
|
706
|
+
help="starting colour map (default inverted; m / M cycle in the viewer)")
|
|
707
|
+
p.add_argument("--bin", choices=["mean", "max"], default="mean",
|
|
708
|
+
help="how pixels are combined when zoomed out (default mean)")
|
|
709
|
+
p.add_argument("--256", dest="colors256", action="store_true",
|
|
710
|
+
help="use 256-colour mode for terminals without truecolor")
|
|
711
|
+
p.add_argument("--snapshot", action="store_true", help="print one frame to stdout and exit")
|
|
712
|
+
args = p.parse_args(argv)
|
|
713
|
+
if not os.path.isfile(args.file):
|
|
714
|
+
p.error(f"no such file: {args.file}")
|
|
715
|
+
|
|
716
|
+
v = Viewer(args.file, args)
|
|
717
|
+
if args.snapshot:
|
|
718
|
+
sys.stdout.write(v.frame(home=False))
|
|
719
|
+
v.hdul.close()
|
|
720
|
+
else:
|
|
721
|
+
run(v)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
if __name__ == "__main__":
|
|
725
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termfits
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal FITS image viewer with zoom and pan, tuned for radio images
|
|
5
|
+
Author-email: Ian Heywood <email.ian.heywood@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ianheywood/termfits
|
|
8
|
+
Project-URL: Issues, https://github.com/ianheywood/termfits/issues
|
|
9
|
+
Keywords: fits,astronomy,radio astronomy,terminal,viewer,tui
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.21
|
|
23
|
+
Requires-Dist: astropy>=5.0
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# termfits
|
|
29
|
+
|
|
30
|
+
A FITS image viewer that runs in your terminal, built for quick looks at radio images
|
|
31
|
+
over SSH: no X forwarding, no VNC, no copying files back to your laptop.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
It draws the image with Unicode half-block characters in 24-bit colour. Each character
|
|
36
|
+
cell holds two pixels, so a 100 x 100 character window shows a 100 x 200 grid of
|
|
37
|
+
roughly square pixels. You can zoom, pan, step through cube planes and switch extensions,
|
|
38
|
+
and the status bar reports the pixel value, its significance in sigma, the position
|
|
39
|
+
in RA/Dec, the noise and the beam.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install termfits
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This installs a `termfits` command. termfits needs Python 3.9+, numpy and astropy, and
|
|
48
|
+
a terminal with truecolour support: most current terminals have it, including iTerm2,
|
|
49
|
+
GNOME Terminal, Konsole, kitty, WezTerm, Alacritty, Windows Terminal (via WSL) and tmux
|
|
50
|
+
with `set -g default-terminal "tmux-256color"` and `set -as terminal-features ",*:RGB"`.
|
|
51
|
+
It runs on Linux and macOS, or under WSL on Windows.
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
termfits image.fits
|
|
57
|
+
termfits cube.fits --plane 120 --cmap inferno
|
|
58
|
+
termfits image.fits --sigma-hi 4 # tighter limits for faint emission
|
|
59
|
+
termfits image.fits --width 100 --height 50 # square-ish view for a square image
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Make your terminal at least 100 columns by 104 rows for the default view (100 x 100
|
|
63
|
+
plus a 4-line status bar), or set `--width` / `--height` to fit. Terminal characters are
|
|
64
|
+
about twice as tall as they are wide, so `--height` set to half of `--width` gives a
|
|
65
|
+
square viewing area.
|
|
66
|
+
|
|
67
|
+
Press `?` in the viewer for the key list.
|
|
68
|
+
|
|
69
|
+
| Keys | Action |
|
|
70
|
+
|---|---|
|
|
71
|
+
| arrows or `h j k l` | pan by 10% of the view |
|
|
72
|
+
| Shift+arrows or `H J K L` | pan by half the view |
|
|
73
|
+
| `+` / `-` | zoom in / out |
|
|
74
|
+
| `f` / `1` / `r` | fit image / 1:1 / reset |
|
|
75
|
+
| `<` `>` | lower / raise the upper display limit (sigma) |
|
|
76
|
+
| `(` `)` | lower / raise the lower display limit (sigma) |
|
|
77
|
+
| `c` | cycle clip mode: sigma, 99.5, 99, 98, 95 %, min/max |
|
|
78
|
+
| `n` / `N` | measure the noise in the current view / back to the whole image |
|
|
79
|
+
| `b` | zoomed-out binning: block mean / block max |
|
|
80
|
+
| `m` / `M` | next / previous colour map |
|
|
81
|
+
| `s` | cycle stretch: linear, sqrt, log, asinh |
|
|
82
|
+
| `e` / `x` | toggle beam ellipse / crosshair |
|
|
83
|
+
| `[` `]` | previous / next plane (channel, Stokes) |
|
|
84
|
+
| `Tab` | next image extension |
|
|
85
|
+
| `q` or `Esc` | quit |
|
|
86
|
+
|
|
87
|
+
`termfits image.fits --snapshot` prints one frame to stdout and exits, which is handy
|
|
88
|
+
for logs or notebooks.
|
|
89
|
+
|
|
90
|
+
## Radio defaults
|
|
91
|
+
|
|
92
|
+
- **Display limits from the noise.** The rms is estimated as 1.4826 x the median
|
|
93
|
+
absolute deviation, which bright sources barely affect. The default range is -1 to
|
|
94
|
+
+10 sigma about the median. Press `n` to re-measure the noise from the region in view
|
|
95
|
+
when it varies across the field (e.g. towards the primary-beam edge).
|
|
96
|
+
- **Values in sigma.** The status bar shows the pixel under the crosshair in BUNIT
|
|
97
|
+
(scaled to Jy, mJy or µJy so the rms reads sensibly) and in sigma.
|
|
98
|
+
- **Beam.** BMAJ/BMIN/BPA from the header are shown in arcsec and pixels, and the beam
|
|
99
|
+
is drawn in the lower-left corner once it is large enough to see.
|
|
100
|
+
- **Blanked pixels** (NaNs, e.g. outside a primary-beam cut) are drawn in a muted purple
|
|
101
|
+
so they are not mistaken for zero.
|
|
102
|
+
|
|
103
|
+
## Zoomed out: what you can and can't trust
|
|
104
|
+
|
|
105
|
+
When the image is larger than the view, blocks of pixels are combined into one screen
|
|
106
|
+
pixel; none are skipped.
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
- **Block mean** (default) lowers the noise, so faint *extended* emission stands out,
|
|
111
|
+
but faint *compact* sources are diluted and can vanish.
|
|
112
|
+
- **Block max** (`b`) keeps every compact source visible, but it also lifts the
|
|
113
|
+
background: the maximum of many noise pixels sits around +3 sigma, so noise peaks
|
|
114
|
+
look like sources.
|
|
115
|
+
|
|
116
|
+
Use either for finding candidates, then press `1` to inspect them at full resolution.
|
|
117
|
+
For decisions about marginal detections, use a full-resolution viewer such as CARTA
|
|
118
|
+
or DS9.
|
|
119
|
+
|
|
120
|
+
## Colour maps
|
|
121
|
+
|
|
122
|
+

|
|
123
|
+
|
|
124
|
+
`inverted` (default) and `gray`; `heat` (DS9-style); `inferno`, `magma`, `viridis` and
|
|
125
|
+
`cubehelix`, which are perceptually uniform and the best colour choices for faint
|
|
126
|
+
features; `cool`; `rainbow` (turbo-like, good for bright high-dynamic-range regions,
|
|
127
|
+
but it creates false contours in noise); and `diverging`, which is centred on the
|
|
128
|
+
median so negative sidelobes show blue and positive emission red.
|
|
129
|
+
|
|
130
|
+
Truecolour gives the full range; `--256` is a fallback for terminals without it, and
|
|
131
|
+
colour maps get coarse there (6 levels per channel).
|
|
132
|
+
|
|
133
|
+
## Data support
|
|
134
|
+
|
|
135
|
+
- 2-D images, and cubes of any dimension: extra axes (FREQ, STOKES, ...) are
|
|
136
|
+
flattened into planes stepped with `[` and `]`.
|
|
137
|
+
- Multi-extension files: every image HDU is reachable with `Tab`.
|
|
138
|
+
- RA/Dec readout from the celestial WCS via astropy.
|
|
139
|
+
- Large images: one plane is loaded into memory as float32 (about 400 MB for
|
|
140
|
+
10k x 10k). The first zoom-out builds the binned copies, which takes a few seconds;
|
|
141
|
+
after that it is quick.
|
|
142
|
+
- If astropy is missing, a built-in reader handles plain uncompressed images, without
|
|
143
|
+
WCS.
|
|
144
|
+
|
|
145
|
+
Not yet supported: per-channel beams stored in a CASA `BEAMS` table (the header beam is
|
|
146
|
+
used), and tile-compressed images without astropy.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
git clone https://github.com/ianheywood/termfits
|
|
152
|
+
cd termfits
|
|
153
|
+
pip install -e ".[test]"
|
|
154
|
+
pytest
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The screenshots in `docs/images` were rendered from termfits' own output, using
|
|
158
|
+
synthetic data.
|
|
159
|
+
|
|
160
|
+
## Licence
|
|
161
|
+
|
|
162
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
termfits/__init__.py,sha256=7GMBXFQ0nqBqVCn7iIzsyHazHIz1K89HFbqmCannTH0,280
|
|
2
|
+
termfits/__main__.py,sha256=WtCt1iyekjUh0YPYLne9Fd6e3DsB83PZQYetYMvCNNY,64
|
|
3
|
+
termfits/viewer.py,sha256=iE36Tw-Ld086clNmgFo6eHoYNhXFmjXMRaZlOmnDJPc,29859
|
|
4
|
+
termfits-0.1.0.dist-info/licenses/LICENSE,sha256=PxHT1GItiPOQJfOrydpzSFFBHR9jghVzVpUbqPERcxw,1068
|
|
5
|
+
termfits-0.1.0.dist-info/METADATA,sha256=E-W-5WuJSWr9ib8fZ2qazLWeE7vUHio8zza_0V3NP5w,6881
|
|
6
|
+
termfits-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
termfits-0.1.0.dist-info/entry_points.txt,sha256=tT_mwVI3zPQqCifc2f7OfVZBgokdHTS6-yByVVAor3w,50
|
|
8
|
+
termfits-0.1.0.dist-info/top_level.txt,sha256=1YYwQUYxorUf25Y_rdRN8pZZTcSAvetv9Ao0MWSb_vs,9
|
|
9
|
+
termfits-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ian Heywood
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
termfits
|