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 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
@@ -0,0 +1,4 @@
1
+ from .viewer import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ ![termfits showing a radio field: default limits vs a tightened upper limit](https://raw.githubusercontent.com/ianheywood/termfits/main/docs/images/radio_sigma_limits.png)
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
+ ![Block mean vs block max for the whole field](https://raw.githubusercontent.com/ianheywood/termfits/main/docs/images/mean_vs_max.png)
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
+ ![The ten colour maps on the same field](https://raw.githubusercontent.com/ianheywood/termfits/main/docs/images/colourmaps.png)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ termfits = termfits.viewer:main
@@ -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