Skip to content

API Reference

This page provides automated Python API documentation generated directly from docstrings across core modules and analytical tool classes using mkdocstrings.


FITS Core Reader

pyql3.core.fits_reader

FitsReader

Wrapper class for handling FITS file reading and header management.

Source code in pyql3/core/fits_reader.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
class FitsReader:
    """Wrapper class for handling FITS file reading and header management."""
    def __init__(self, filepath=None):
        self.filepath = filepath
        self.hdul = None
        self.data = None
        self.header = None
        self.image_extensions = []
        self._file_signature = None

        if filepath:
            self.load(filepath)

    @staticmethod
    def _stat_signature(filepath):
        """Identity of the bytes on disk. Any change here means we must reopen."""
        try:
            st = os.stat(filepath)
        except OSError:
            return None
        return (st.st_mtime_ns, st.st_size, st.st_ino)

    def load(self, filepath, ext=None, force=False):
        """Loads a FITS file and its primary data/header.

        The HDUList is reopened whenever the file on disk differs from the one we hold
        open — *including* when the path is unchanged. An instrument or DRP that rewrites
        a path in place was previously served the cached copy forever (B5). Reuse is kept
        for the byte-identical case so that switching extensions stays cheap and does not
        throw away header edits that have not been saved yet. `force=True` reopens
        regardless, for an explicit "reload from disk".
        """
        if not os.path.exists(filepath):
            raise FileNotFoundError(f"FITS file not found: {filepath}")

        signature = self._stat_signature(filepath)
        stale = (self.hdul is None
                 or self.filepath != filepath
                 or signature is None
                 or signature != self._file_signature)
        if force or stale:
            self.close()
            # memmap=False: reading through a mapping of a file the DRP is rewriting under
            # us yields undefined data (and can SIGBUS if it shrinks), and writeto() back
            # to the same path can fail while the mapping is open.
            self.hdul = fits.open(filepath, memmap=False)
            self._file_signature = signature

        self.filepath = filepath
        self.image_extensions = self.get_image_extensions()

        self.data = None
        self.header = None
        self.current_ext = 0

        if ext is not None:
            self.data = self.hdul[ext].data
            self.header = self.hdul[ext].header
            self.current_ext = ext
        else:
            for i, _name in self.image_extensions:
                self.data = self.hdul[i].data
                self.header = self.hdul[i].header
                self.current_ext = i
                break

        # No displayable image extension. `data` deliberately stays None so callers
        # can tell "nothing to show" from "here is your image".
        #
        # This used to substitute np.zeros((10, 10)) with an empty Header. That made
        # `data` never None, which silently disabled every `if data is None` guard in
        # the application: a FITS carrying no image HDU was displayed as a black 10x10
        # square, titled with the filename and added to Recent Files, indistinguishable
        # from a real observation of an empty field.
        #
        # The primary header is still published, so a file worth inspecting but not
        # displaying can be opened in the Header Editor.
        if self.data is None and self.hdul:
            self.header = self.hdul[0].header

    def load_from_memory(self, data, header):
        if self.hdul:
            self.hdul.close()

        from astropy.io import fits
        hdu = fits.PrimaryHDU(data=data, header=header)
        self.hdul = fits.HDUList([hdu])
        self.filepath = None
        self._file_signature = None
        self.current_ext = 0
        self.data = data
        self.header = header
        self.image_extensions = self.get_image_extensions()


    def get_all_extensions(self):
        extensions = []
        if self.hdul:
            for idx, hdu in enumerate(self.hdul):
                name = hdu.name if hdu.name else f"EXT {idx}"
                extensions.append((idx, name))
        elif self.header is not None:
            extensions.append((0, "PRIMARY"))
        return extensions

    @staticmethod
    def _is_displayable(hdu):
        """Whether an HDU holds an image the viewer can actually show.

        Header-only on purpose: touching `hdu.data` here would pull every extension of a
        multi-extension file into memory now that `memmap` is off, and `data is not None`
        is also the wrong question — a `BINTABLE` answers yes (B6).
        """
        if not getattr(hdu, 'is_image', False):
            return False
        try:
            return int(hdu.header.get('NAXIS', 0)) > 0
        except (TypeError, ValueError):
            return False

    def get_image_extensions(self):
        """The extensions the viewer can display.

        The single definition of "displayable", shared with `load()` — the two used to
        disagree, so the Extension combo offered table HDUs that silently left the previous
        image on screen while `data`/`header` switched to the table underneath (B6).
        """
        extensions = []
        if self.hdul:
            for idx, hdu in enumerate(self.hdul):
                if not self._is_displayable(hdu):
                    continue
                name = hdu.name if hdu.name else f"EXT {idx}"
                if name == "PRIMARY" and idx != 0:
                    name = f"EXT {idx}"
                extensions.append((idx, name))
        return extensions

    def get_data(self):
        return self.data

    def get_header(self, ext=None):
        if ext is not None and self.hdul and 0 <= ext < len(self.hdul):
            return self.hdul[ext].header
        return self.header

    #: Keywords that describe the physical layout of the file rather than the
    #: observation. Editing them is either destructive or futile:
    #:
    #:   SIMPLE = F marks the file as non-conforming, and the primary HDU then stops
    #:   being recognised as an image — save() overwrites in place, so one keystroke
    #:   in the Header Editor could make a science file unreadable.
    #:
    #:   NAXIS/NAXISn/BITPIX and friends are regenerated by astropy from the data
    #:   array on write, so an edit silently reverts, which merely confuses.
    #:
    #: Either way the viewer should not be the tool that does it.
    PROTECTED_KEYWORDS = frozenset({
        'SIMPLE', 'XTENSION', 'BITPIX', 'NAXIS', 'END', 'EXTEND', 'PCOUNT', 'GCOUNT',
        'TFIELDS',
    })

    @classmethod
    def is_protected_keyword(cls, keyword):
        """True for structural keywords, including the NAXISn / TFORMn families."""
        name = str(keyword).strip().upper()
        if name in cls.PROTECTED_KEYWORDS:
            return True
        for prefix in ('NAXIS', 'TFORM', 'TBCOL'):
            if name.startswith(prefix) and name[len(prefix):].isdigit():
                return True
        return False

    def update_header_card(self, keyword, value, comment=None, ext=None):
        """Update or add a header card. Returns False if the keyword is protected."""
        if self.is_protected_keyword(keyword):
            return False

        hdr = self.get_header(ext=ext)
        if hdr is None:
            return False
        if comment is not None:
            hdr[keyword] = (value, comment)
        else:
            hdr[keyword] = value
        return True

    def save(self, output_filepath=None):
        """Write the in-memory HDUList out, overwriting the target if it exists.

        Saving over the file we currently have open needs care on Windows: a file with an
        open handle cannot be deleted or replaced, and astropy implements `overwrite=True`
        as `os.remove()` followed by a fresh create. That made the Header Editor's
        "save directly to file" fail with `PermissionError: [WinError 32]`.

        So: pull every HDU into memory, release the OS handle, write to a sibling temp file
        and swap it in with `os.replace()` (atomic on both platforms, and it never leaves a
        half-written file where the original was). Then reopen so our state matches disk.
        """
        if self.hdul is None:
            raise ValueError("No FITS file loaded.")

        save_path = output_filepath or self.filepath
        if not save_path:
            raise ValueError("No output path given and no current file path is known.")

        hdul = self.hdul

        # Materialise before dropping the handle: with memmap off these become real arrays
        # that stay valid after close(), which lazily-loaded HDUs would not.
        for hdu in hdul:
            _ = hdu.data

        try:
            hdul.close()
        except Exception:
            pass

        # Capture the target's permissions before it is replaced. mkstemp creates 0600
        # by design, and os.replace carries the temp file's mode onto the destination,
        # so saving a group-shared file used to silently drop it to owner-only and lock
        # collaborators out of a reduction directory. Nothing warned; they simply got
        # permission denied later.
        original_mode = None
        try:
            original_mode = stat.S_IMODE(os.stat(save_path).st_mode)
        except OSError:
            pass  # new file (Save As); fall back to the umask default below

        # Same directory as the target, so os.replace can never be cross-filesystem.
        directory = os.path.dirname(os.path.abspath(save_path)) or '.'
        fd, tmp_path = tempfile.mkstemp(prefix='.pyql3_save_', suffix='.fits', dir=directory)
        os.close(fd)
        try:
            hdul.writeto(tmp_path, overwrite=True)
            os.chmod(tmp_path, original_mode if original_mode is not None
                     else _default_file_mode())
            os.replace(tmp_path, save_path)
        except BaseException:
            try:
                if os.path.exists(tmp_path):
                    os.remove(tmp_path)
            except OSError:
                pass
            raise

        # Our handle is gone and the bytes on disk have changed, so reopen from scratch.
        reopen_path = save_path if self.filepath in (None, save_path) else self.filepath
        ext = self.current_ext
        self.hdul = None
        self._file_signature = None
        if os.path.exists(reopen_path):
            self.load(reopen_path, ext=ext, force=True)


    def close(self):
        """Closes the FITS file handle."""
        if self.hdul is not None:
            self.hdul.close()
            self.hdul = None
            self.data = None
            self.header = None
close()

Closes the FITS file handle.

Source code in pyql3/core/fits_reader.py
277
278
279
280
281
282
283
def close(self):
    """Closes the FITS file handle."""
    if self.hdul is not None:
        self.hdul.close()
        self.hdul = None
        self.data = None
        self.header = None
get_image_extensions()

The extensions the viewer can display.

The single definition of "displayable", shared with load() — the two used to disagree, so the Extension combo offered table HDUs that silently left the previous image on screen while data/header switched to the table underneath (B6).

Source code in pyql3/core/fits_reader.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def get_image_extensions(self):
    """The extensions the viewer can display.

    The single definition of "displayable", shared with `load()` — the two used to
    disagree, so the Extension combo offered table HDUs that silently left the previous
    image on screen while `data`/`header` switched to the table underneath (B6).
    """
    extensions = []
    if self.hdul:
        for idx, hdu in enumerate(self.hdul):
            if not self._is_displayable(hdu):
                continue
            name = hdu.name if hdu.name else f"EXT {idx}"
            if name == "PRIMARY" and idx != 0:
                name = f"EXT {idx}"
            extensions.append((idx, name))
    return extensions
is_protected_keyword(keyword) classmethod

True for structural keywords, including the NAXISn / TFORMn families.

Source code in pyql3/core/fits_reader.py
184
185
186
187
188
189
190
191
192
193
@classmethod
def is_protected_keyword(cls, keyword):
    """True for structural keywords, including the NAXISn / TFORMn families."""
    name = str(keyword).strip().upper()
    if name in cls.PROTECTED_KEYWORDS:
        return True
    for prefix in ('NAXIS', 'TFORM', 'TBCOL'):
        if name.startswith(prefix) and name[len(prefix):].isdigit():
            return True
    return False
load(filepath, ext=None, force=False)

Loads a FITS file and its primary data/header.

The HDUList is reopened whenever the file on disk differs from the one we hold open — including when the path is unchanged. An instrument or DRP that rewrites a path in place was previously served the cached copy forever (B5). Reuse is kept for the byte-identical case so that switching extensions stays cheap and does not throw away header edits that have not been saved yet. force=True reopens regardless, for an explicit "reload from disk".

Source code in pyql3/core/fits_reader.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def load(self, filepath, ext=None, force=False):
    """Loads a FITS file and its primary data/header.

    The HDUList is reopened whenever the file on disk differs from the one we hold
    open — *including* when the path is unchanged. An instrument or DRP that rewrites
    a path in place was previously served the cached copy forever (B5). Reuse is kept
    for the byte-identical case so that switching extensions stays cheap and does not
    throw away header edits that have not been saved yet. `force=True` reopens
    regardless, for an explicit "reload from disk".
    """
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"FITS file not found: {filepath}")

    signature = self._stat_signature(filepath)
    stale = (self.hdul is None
             or self.filepath != filepath
             or signature is None
             or signature != self._file_signature)
    if force or stale:
        self.close()
        # memmap=False: reading through a mapping of a file the DRP is rewriting under
        # us yields undefined data (and can SIGBUS if it shrinks), and writeto() back
        # to the same path can fail while the mapping is open.
        self.hdul = fits.open(filepath, memmap=False)
        self._file_signature = signature

    self.filepath = filepath
    self.image_extensions = self.get_image_extensions()

    self.data = None
    self.header = None
    self.current_ext = 0

    if ext is not None:
        self.data = self.hdul[ext].data
        self.header = self.hdul[ext].header
        self.current_ext = ext
    else:
        for i, _name in self.image_extensions:
            self.data = self.hdul[i].data
            self.header = self.hdul[i].header
            self.current_ext = i
            break

    # No displayable image extension. `data` deliberately stays None so callers
    # can tell "nothing to show" from "here is your image".
    #
    # This used to substitute np.zeros((10, 10)) with an empty Header. That made
    # `data` never None, which silently disabled every `if data is None` guard in
    # the application: a FITS carrying no image HDU was displayed as a black 10x10
    # square, titled with the filename and added to Recent Files, indistinguishable
    # from a real observation of an empty field.
    #
    # The primary header is still published, so a file worth inspecting but not
    # displaying can be opened in the Header Editor.
    if self.data is None and self.hdul:
        self.header = self.hdul[0].header
save(output_filepath=None)

Write the in-memory HDUList out, overwriting the target if it exists.

Saving over the file we currently have open needs care on Windows: a file with an open handle cannot be deleted or replaced, and astropy implements overwrite=True as os.remove() followed by a fresh create. That made the Header Editor's "save directly to file" fail with PermissionError: [WinError 32].

So: pull every HDU into memory, release the OS handle, write to a sibling temp file and swap it in with os.replace() (atomic on both platforms, and it never leaves a half-written file where the original was). Then reopen so our state matches disk.

Source code in pyql3/core/fits_reader.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def save(self, output_filepath=None):
    """Write the in-memory HDUList out, overwriting the target if it exists.

    Saving over the file we currently have open needs care on Windows: a file with an
    open handle cannot be deleted or replaced, and astropy implements `overwrite=True`
    as `os.remove()` followed by a fresh create. That made the Header Editor's
    "save directly to file" fail with `PermissionError: [WinError 32]`.

    So: pull every HDU into memory, release the OS handle, write to a sibling temp file
    and swap it in with `os.replace()` (atomic on both platforms, and it never leaves a
    half-written file where the original was). Then reopen so our state matches disk.
    """
    if self.hdul is None:
        raise ValueError("No FITS file loaded.")

    save_path = output_filepath or self.filepath
    if not save_path:
        raise ValueError("No output path given and no current file path is known.")

    hdul = self.hdul

    # Materialise before dropping the handle: with memmap off these become real arrays
    # that stay valid after close(), which lazily-loaded HDUs would not.
    for hdu in hdul:
        _ = hdu.data

    try:
        hdul.close()
    except Exception:
        pass

    # Capture the target's permissions before it is replaced. mkstemp creates 0600
    # by design, and os.replace carries the temp file's mode onto the destination,
    # so saving a group-shared file used to silently drop it to owner-only and lock
    # collaborators out of a reduction directory. Nothing warned; they simply got
    # permission denied later.
    original_mode = None
    try:
        original_mode = stat.S_IMODE(os.stat(save_path).st_mode)
    except OSError:
        pass  # new file (Save As); fall back to the umask default below

    # Same directory as the target, so os.replace can never be cross-filesystem.
    directory = os.path.dirname(os.path.abspath(save_path)) or '.'
    fd, tmp_path = tempfile.mkstemp(prefix='.pyql3_save_', suffix='.fits', dir=directory)
    os.close(fd)
    try:
        hdul.writeto(tmp_path, overwrite=True)
        os.chmod(tmp_path, original_mode if original_mode is not None
                 else _default_file_mode())
        os.replace(tmp_path, save_path)
    except BaseException:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except OSError:
            pass
        raise

    # Our handle is gone and the bytes on disk have changed, so reopen from scratch.
    reopen_path = save_path if self.filepath in (None, save_path) else self.filepath
    ext = self.current_ext
    self.hdul = None
    self._file_signature = None
    if os.path.exists(reopen_path):
        self.load(reopen_path, ext=ext, force=True)
update_header_card(keyword, value, comment=None, ext=None)

Update or add a header card. Returns False if the keyword is protected.

Source code in pyql3/core/fits_reader.py
195
196
197
198
199
200
201
202
203
204
205
206
207
def update_header_card(self, keyword, value, comment=None, ext=None):
    """Update or add a header card. Returns False if the keyword is protected."""
    if self.is_protected_keyword(keyword):
        return False

    hdr = self.get_header(ext=ext)
    if hdr is None:
        return False
    if comment is not None:
        hdr[keyword] = (value, comment)
    else:
        hdr[keyword] = value
    return True

Image Viewer

pyql3.gui.viewers.image_viewer

ImageViewer

Bases: QWidget

Source code in pyql3/gui/viewers/image_viewer.py
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
class ImageViewer(QWidget):
    request_depth_plot = Signal(object)
    request_gaussian_fit = Signal(object)
    #: A region was asked for from the right-click menu. Payload is `(kind, display pixel)`.
    request_new_region = Signal(str, object)
    #: Emitted when the displayed plane's geometry changes — a flip, a 90° rotation, a new axis
    #: mapping or new data. Anything drawn on top in stored coordinates has to be re-placed.
    display_changed = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.layout = QVBoxLayout(self)
        self.layout.setContentsMargins(0, 0, 0, 0)

        # Top: The Image View
        self.imv = pg.ImageView()
        self.imv.ui.histogram.hide()
        self.imv.ui.roiBtn.hide()
        self.imv.ui.menuBtn.hide()
        self.imv.getView().setAspectLocked(True)
        self.imv.getView().invertY(False)
        self.imv.ui.roiPlot.setMinimumHeight(65)
        self.imv.ui.roiPlot.setMaximumHeight(85)
        self.layout.addWidget(self.imv, stretch=1)

        # Add region for highlighting stacked Z slices in the timeline
        self.z_range_region = pg.LinearRegionItem([0, 1], brush=(255, 255, 0, 120), pen=pg.mkPen((255, 255, 0, 200), width=3))
        self.z_range_region.setZValue(10)
        self.z_range_region.hide()
        self.z_range_region.sigRegionChangeFinished.connect(self.on_region_change_finished)
        self.imv.ui.roiPlot.addItem(self.z_range_region)
        self._updating_region = False

        self.imv.ui.roiPlot.scene().sigMouseClicked.connect(self.on_roi_plot_clicked)

        # Context menu setup on main image ViewBox
        self.last_right_click_pixel_pos = None
        view = self.imv.getView()
        view.scene().sigMouseClicked.connect(self.on_view_clicked)
        view.sigRangeChanged.connect(self.on_view_range_changed)
        if hasattr(view, 'menu') and view.menu is not None:
            view.menu.addSeparator()
            act_depth = view.menu.addAction("Depth Plot...")
            act_depth.triggered.connect(self.on_context_plot_depth)
            act_fit = view.menu.addAction("Gaussian Fit...")
            act_fit.triggered.connect(self.on_context_gaussian_fit)

            # Held on self, or PySide6 deletes the C++ menu with the first Python wrapper that
            # touches it, taking its actions with it (`BUGS.md` M11).
            self.new_region_menu = view.menu.addMenu("New Region")
            for label, kind in (("Circle", "circle"), ("Box", "box"),
                                ("Arrow", "arrow"), ("Text...", "text")):
                act = self.new_region_menu.addAction(label)
                act.triggered.connect(
                    lambda checked=False, k=kind: self.on_context_new_region(k))



        # Top axis for Wavelength
        self.top_axis = self.imv.ui.roiPlot.plotItem.getAxis('top')
        self.top_axis.setStyle(showValues=True)
        self._original_tickStrings = self.top_axis.tickStrings
        self.top_axis.tickStrings = self._custom_tickStrings
        self.wcs_z_idx = None

        self.imv.timeLine.setPen(pg.mkPen('y', width=3))
        self.imv.timeLine.setHoverPen(pg.mkPen('y', width=5))

        self.set_colormap('cmc.oslo')

        # Data state
        self.raw_data = None
        self.transposed_data = None
        self.display_data = None
        self.wcs = None

        # Info Bar directly below image
        self.setup_info_panel()

        # Bottom: The Control Panels
        self.tabs = QTabWidget()
        self.layout.addWidget(self.tabs)

        self.tab_display = QWidget()
        self.display_layout = QVBoxLayout(self.tab_display)
        self.display_layout.setContentsMargins(4, 4, 4, 4)

        self.tab_advanced = QWidget()
        self.advanced_layout = QVBoxLayout(self.tab_advanced)
        self.advanced_layout.setContentsMargins(4, 4, 4, 4)

        self.tabs.addTab(self.tab_display, "Display")
        self.tabs.addTab(self.tab_advanced, "Advanced Data Cube")
        # Transforms state
        self.disp_as_dn = False
        self.rot_angle = 0
        self.view_rotation = 0.0
        self.flip = False
        self.pa_arrow = None
        self._itime_coadds = 1.0
        # Guards the slider <-> ImageView timeline sync (see on_imv_time_changed)
        self._syncing_slice = False
        # Exclusive drag handling; see begin_exclusive_drag
        self._drag_owner = None
        self._drag_handler_before = None
        self._drag_revoked_callback = None
        # True when the displayed plane has no finite pixels at all (see update_image_display)
        self._plane_all_invalid = False

        self.setup_zoom_contrast_panel()
        self.setup_extension_panel()
        self.setup_axis_panel()
        self.setup_z_axis_panel()

        self.tabs.currentChanged.connect(self.on_tab_changed)
        # Call it once after all layouts are fully constructed
        self.on_tab_changed(self.tabs.currentIndex())

        # Connect mouse movement and zoom scale
        self.proxy = pg.SignalProxy(self.imv.scene.sigMouseMoved, rateLimit=60, slot=self.mouse_moved)
        self.imv.timeLine.sigPositionChanged.connect(self.update_slice_info)

        # slider_slice is the single source of truth for the current z index; dragging the
        # pyqtgraph timeline has to write back to it or the labels, the pixel readout and
        # every analysis tool report a different slice than the one on screen.
        self.imv.sigTimeChanged.connect(self.on_imv_time_changed)

        # Drawn regions. Constructed last: it parents its items to the ImageItem and follows
        # `slider_slice`, so both have to exist first.
        from pyql3.gui.viewers.region_layer import RegionLayer
        self.region_layer = RegionLayer(self)

    def get_wavelength_for_slice(self, z):
        if getattr(self, 'wcs', None) is None or getattr(self, 'wcs_z_idx', None) is None:
            return None
        coords = [0] * self.wcs.naxis
        coords[self.wcs_z_idx] = z
        try:
            world = self.wcs.wcs_pix2world([coords], 0)[0]
            wave = world[self.wcs_z_idx]
            unit = self.wcs.wcs.cunit[self.wcs_z_idx]
            if str(unit).strip().lower() == 'm':
                wave *= 1e6
            return wave
        except Exception:
            return None

    def clamp_z_range(self, zmin, zmax, write_back=False):
        """Clamp a channel range to the cube and return it in ascending order.

        Returns `(zmin, zmax)`, inclusive, or None if there is no cube. Clamping *both*
        ends matters: a reversed or past-the-end range slices an empty subcube, and the
        nan-reductions then return an all-NaN plane (see B12/B16). With `write_back` the
        corrected values are pushed into the Z Min / Z Max boxes so the UI shows the range
        that was actually used.
        """
        if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
            return None
        nz = self.transposed_data.shape[0]
        zmin = int(np.clip(int(zmin), 0, nz - 1))
        zmax = int(np.clip(int(zmax), 0, nz - 1))
        if zmin > zmax:
            zmin, zmax = zmax, zmin

        if write_back and hasattr(self, 'txt_zmin'):
            # setText() does not re-emit editingFinished, so this cannot recurse
            if self.txt_zmin.text() != str(zmin):
                self.txt_zmin.setText(str(zmin))
            if self.txt_zmax.text() != str(zmax):
                self.txt_zmax.setText(str(zmax))
        return zmin, zmax

    def z_range_from_fields(self, write_back=False):
        """The clamped collapse range in the Z Min / Z Max boxes, or None if unparsable."""
        try:
            zmin = int(self.txt_zmin.text())
            zmax = int(self.txt_zmax.text())
        except (ValueError, AttributeError):
            return None
        return self.clamp_z_range(zmin, zmax, write_back=write_back)

    def boxcar_width(self):
        """The Boxcar setting, at least 1."""
        try:
            return max(1, int(self.txt_boxcar.text()))
        except (ValueError, AttributeError):
            return 1

    def boxcar_range(self, z=None):
        """The inclusive channel range the Boxcar setting averages around `z`."""
        if z is None:
            z = self.slider_slice.value()
        half = self.boxcar_width() // 2
        return self.clamp_z_range(z - half, z + half)

    def collapse_plane(self, zmin, zmax, method=None):
        """Collapse `transposed_data` over the inclusive range `[zmin, zmax]` into a plane.

        The single definition of the collapse arithmetic, shared by `apply_z_range`,
        `apply_z_slice` (Boxcar) and `current_plane`.
        """
        if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
            return None
        if method is None:
            method = self.combo_collapse.currentText()

        subcube = self.transposed_data[zmin:zmax + 1, :, :]
        if subcube.shape[0] == 0:
            return None  # clamp_z_range makes this unreachable; kept so callers can't blank

        # An all-NaN region is expected for dead slices and is handled downstream by the
        # all-invalid guard in update_image_display; don't spam the console about it.
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", RuntimeWarning)
            if method == "Median":
                return np.nanmedian(subcube, axis=0)
            if method == "Mean":
                return np.nanmean(subcube, axis=0)
            if method == "Sum":
                return np.nansum(subcube, axis=0)
        return subcube[0]

    def apply_spatial_transforms(self, arr):
        """Return `arr` with the display flip and 90° rotations applied.

        Pure: it neither touches `display_data` nor applies the DN multiplier. Accepts a
        2-D plane (x, y) or a 3-D cube (z, x, y).
        """
        if arr is None:
            return None
        k = self.rot_angle // 90
        if arr.ndim == 3:
            if self.flip:
                arr = np.flip(arr, axis=1)  # flip X
            if k != 0:
                arr = np.rot90(arr, k=k, axes=(1, 2))
        else:
            if self.flip:
                arr = np.flip(arr, axis=0)  # flip X
            if k != 0:
                arr = np.rot90(arr, k=k)
        return arr

    def orig_spatial_dims(self):
        """`(nx, ny)` of `transposed_data`'s spatial axes, before flip and rotation.

        The last two axes are the spatial ones for both a 2-D image `(x, y)` and a cube
        `(z, x, y)`. Returns None when there is nothing loaded.
        """
        if getattr(self, 'transposed_data', None) is None:
            return None
        nx, ny = self.transposed_data.shape[-2:]
        return int(nx), int(ny)

    # ------------------------------------------------------- exclusive drag handling

    def begin_exclusive_drag(self, owner, handler, on_revoked=None):
        """Give `owner` sole control of the view's drag handling until it gives it back.

        Drawing a box or a region works by replacing `ViewBox.mouseDragEvent`, and only one
        thing can hold it at a time. Each caller used to save the current handler itself and
        restore it on the way out, which corrupts as soon as two of them overlap: the second
        saves the *first's* handler, and whichever finishes last restores the wrong one, leaving
        the view permanently unable to pan. Ownership is tracked here instead, and the previous
        owner is told (`on_revoked`) so it can un-check its own button.
        """
        view = self.imv.getView()
        if self._drag_owner is not None and self._drag_owner is not owner:
            self._revoke_exclusive_drag()

        if self._drag_handler_before is None:
            # Captured once, and only ever the genuine original.
            self._drag_handler_before = view.mouseDragEvent

        view.mouseDragEvent = handler
        view.setMouseEnabled(x=False, y=False)
        self._drag_owner = owner
        self._drag_revoked_callback = on_revoked

    def end_exclusive_drag(self, owner=None):
        """Hand drag handling back. A non-owner asking for this is ignored, not obeyed."""
        if self._drag_owner is None:
            return
        if owner is not None and self._drag_owner is not owner:
            return

        view = self.imv.getView()
        if self._drag_handler_before is not None:
            view.mouseDragEvent = self._drag_handler_before
            self._drag_handler_before = None
        view.setMouseEnabled(x=True, y=True)
        self._drag_owner = None
        self._drag_revoked_callback = None

    def exclusive_drag_owner(self):
        return self._drag_owner

    def _revoke_exclusive_drag(self):
        """Take the drag away from its current owner and let it tidy up its own UI."""
        callback = self._drag_revoked_callback
        self.end_exclusive_drag()
        if callback is not None:
            callback()

    def display_axis_indices(self):
        """The 0-based FITS axis indices currently shown as X and Y.

        For a 2-D image this is `(0, 1)`; for an OSIRIS cube, whose X axis is FITS axis 3, it
        is `(2, 1)`. Anything that has to talk to a WCS or to ds9 needs this — ds9's `image`
        frame always means FITS axes 1 and 2, so a cube displayed on other axes cannot use it.

        The expression was repeated in three places with two different fallbacks, one of which
        (`AXIS 1`) names the wavelength axis for an OSIRIS cube.
        """
        def index_of(attribute, default):
            name = getattr(self, attribute, None) or default
            try:
                return max(0, int(str(name).split()[-1]) - 1)
            except (ValueError, IndexError):
                return int(str(default).split()[-1]) - 1

        naxis = self.raw_data.ndim if getattr(self, 'raw_data', None) is not None else 2
        if naxis < 3:
            # A 2-D image has no axis mapping to consult; X is axis 1 and Y is axis 2.
            return (0, 1)
        return (index_of('current_x_axis', 'AXIS 3'), index_of('current_y_axis', 'AXIS 2'))

    def orig_to_display(self, x, y):
        """Map a `transposed_data` coordinate to its position on screen, or None if no data.

        Thin adapter over `pyql3.core.coords`; see that module for what "orig" means and why
        the arithmetic lives in exactly one place (`BUGS.md` B13/B14).
        """
        dims = self.orig_spatial_dims()
        if dims is None:
            return None
        return coords.orig_to_display(x, y, *dims, flip=self.flip, rot_angle=self.rot_angle)

    def display_to_orig(self, x, y):
        """Map a screen coordinate back to a `transposed_data` coordinate, or None if no data."""
        dims = self.orig_spatial_dims()
        if dims is None:
            return None
        return coords.display_to_orig(x, y, *dims, flip=self.flip, rot_angle=self.rot_angle)

    def current_plane(self):
        """The 2-D plane currently on screen, oriented like `display_data` but *without*
        the DN multiplier folded in.

        Tools that analyse the displayed pixels of a cube must use this rather than
        indexing a single channel: in Boxcar or Z Range mode the screen shows a collapsed
        plane that exists in no single channel of the cube (see B17). Callers that apply
        `data_multiplier` themselves want this; callers that don't can use `display_data`.
        """
        if getattr(self, 'transposed_data', None) is None:
            return None
        if self.transposed_data.ndim != 3:
            return self.apply_spatial_transforms(self.transposed_data)

        if hasattr(self, 'radio_range') and self.radio_range.isChecked():
            rng = self.z_range_from_fields()
            plane = self.collapse_plane(*rng) if rng is not None else None
        elif self.boxcar_width() > 1:
            rng = self.boxcar_range()
            plane = self.collapse_plane(*rng, method="Median") if rng is not None else None
        else:
            plane = self.transposed_data[self.current_z()]

        return self.apply_spatial_transforms(plane)

    def current_z(self):
        """Canonical z index of the plane currently on screen.

        Prefer this over `imv.currentIndex` in tools: the ImageView index is only
        maintained while a single slice is displayed, and goes stale as soon as a boxcar
        or collapse range is drawn through `bypass_imv`.
        """
        if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
            return 0
        nz = self.transposed_data.shape[0]

        if hasattr(self, 'radio_range') and self.radio_range.isChecked():
            # Collapsed plane: report the middle of the range, as the WCS readout does
            rng = self.z_range_from_fields()
            if rng is None:
                return int(np.clip(self.slider_slice.value(), 0, nz - 1))
            return (rng[0] + rng[1]) // 2

        # Slice mode, boxcar included: the slider is the source of truth
        return int(np.clip(self.slider_slice.value(), 0, nz - 1))

    def on_imv_time_changed(self, ind, _time=None):
        """Mirror a user-driven timeline drag back onto the Z Slice slider.

        Only user interaction should reach here: our own calls into the ImageView are
        wrapped in `_syncing_slice`, because `imv.setImage()` emits a spurious
        sigTimeChanged(0) that would otherwise yank the slider back to slice 0.
        """
        if self._syncing_slice or getattr(self, 'transposed_data', None) is None:
            return
        if self.transposed_data.ndim != 3 or not self.radio_slice.isChecked():
            return

        try:
            ind = int(ind)
        except (TypeError, ValueError):
            return

        self._syncing_slice = True
        try:
            if self.slider_slice.value() != ind:
                # Drives lbl_slice_val, update_slice_info, the readouts and the tools
                self.slider_slice.setValue(ind)
            else:
                self.update_slice_info()
        finally:
            self._syncing_slice = False

    def refresh_plane_validity(self):
        """Recompute whether the plane on screen has any finite pixel at all.

        Cheap (one plane, never the whole cube) and has to be re-run on every slice change:
        paging onto a dead channel does not redisplay the image, it only moves the
        ImageView's index.
        """
        data = getattr(self, 'display_data', None)
        if data is None:
            self._plane_all_invalid = False
            return self._plane_all_invalid
        plane = data
        if plane.ndim == 3:
            plane = plane[int(np.clip(self.current_z(), 0, plane.shape[0] - 1))]
        self._plane_all_invalid = not bool(np.isfinite(plane).any())
        return self._plane_all_invalid

    def update_slice_info(self, *args, **kwargs):
        self._update_slice_info_text()
        # An all-invalid plane otherwise looks identical to a display bug (see B16)
        if self.refresh_plane_validity() and hasattr(self, 'lbl_slice_info'):
            self.lbl_slice_info.setText(self.lbl_slice_info.text() + "  —  no valid data")

    def _update_slice_info_text(self):
        if getattr(self, 'transposed_data', None) is None:
            if hasattr(self, 'lbl_slice_info'):
                self.lbl_slice_info.setText("Slice: N/A")
            return

        if hasattr(self, 'radio_range') and self.radio_range.isChecked():
            rng = self.z_range_from_fields()
            if rng is None:
                self.lbl_slice_info.setText("Collapsed: Invalid Range")
            else:
                zmin, zmax = rng
                text = f"Collapsed: {zmin}-{zmax}"

                wave_min = self.get_wavelength_for_slice(zmin)
                wave_max = self.get_wavelength_for_slice(zmax)
                if wave_min is not None and wave_max is not None:
                    text += f", Wavelengths: {wave_min:.4f}-{wave_max:.4f} µm"

                self.lbl_slice_info.setText(text)
        else:
            if self.transposed_data.ndim == 3:
                z = self.slider_slice.value()
                boxcar = self.boxcar_width()
                if boxcar > 1:
                    zmin, zmax = self.boxcar_range(z)
                    text = f"Slice (Boxcar): {zmin}-{zmax}"
                    wave_min = self.get_wavelength_for_slice(zmin)
                    wave_max = self.get_wavelength_for_slice(zmax)
                    if wave_min is not None and wave_max is not None:
                        text += f", Wavelengths: {wave_min:.4f}-{wave_max:.4f} µm"
                else:
                    text = f"Slice: {z}"
                    wave = self.get_wavelength_for_slice(z)
                    if wave is not None:
                        text += f", Wavelength: {wave:.4f} µm"

                self.lbl_slice_info.setText(text)
            else:
                self.lbl_slice_info.setText("Slice: N/A (2D Image)")

    def on_tab_changed(self, index):
        widget = self.tabs.widget(index)
        height = widget.sizeHint().height()
        bar_height = self.tabs.tabBar().sizeHint().height()
        total_height = height + bar_height + 8
        self.tabs.setMaximumHeight(total_height)
        self.tabs.setMinimumHeight(total_height)

    def _custom_tickStrings(self, values, scale, spacing):
        if self.wcs is None or self.wcs_z_idx is None:
            return self._original_tickStrings(values, scale, spacing)

        strings = []
        for val in values:
            if val < 0:
                strings.append("")
                continue
            coords = [0] * self.wcs.naxis
            coords[self.wcs_z_idx] = val
            try:
                world = self.wcs.wcs_pix2world([coords], 0)[0]
                wave = world[self.wcs_z_idx]
                unit = self.wcs.wcs.cunit[self.wcs_z_idx]
                if str(unit).strip().lower() == 'm':
                    wave *= 1e6
                strings.append(f"{wave:.4f} µm")
            except Exception:
                strings.append(str(val))
        return strings

    def setup_info_panel(self):
        hbox1 = QHBoxLayout()
        hbox1.setContentsMargins(4, 2, 4, 0)

        self.lbl_x = QLabel("X: N/A")
        self.lbl_y = QLabel("Y: N/A")
        self.lbl_val = QLabel("Value: N/A")
        self.lbl_slice_info = QLabel("Slice: N/A")

        sep1 = QFrame(); sep1.setFrameShape(QFrame.VLine); sep1.setFrameShadow(QFrame.Sunken)

        hbox1.addWidget(self.lbl_x)
        hbox1.addWidget(self.lbl_y)
        hbox1.addWidget(self.lbl_val)
        hbox1.addWidget(sep1)
        hbox1.addWidget(self.lbl_slice_info)
        hbox1.addStretch()

        hbox2 = QHBoxLayout()
        hbox2.setContentsMargins(4, 0, 4, 2)
        self.lbl_wcs = QLabel("WCS: N/A")
        hbox2.addWidget(self.lbl_wcs)
        hbox2.addStretch()

        vbox = QVBoxLayout()
        vbox.setContentsMargins(0, 0, 0, 0)
        vbox.addLayout(hbox1)
        vbox.addLayout(hbox2)

        info_widget = QWidget()
        info_widget.setLayout(vbox)
        self.layout.addWidget(info_widget)

    def setup_zoom_contrast_panel(self):
        hbox = QHBoxLayout()
        hbox.setContentsMargins(0, 0, 0, 0)
        self.btn_zoom_out = QPushButton("-")
        self.btn_zoom_out.setFixedWidth(30)
        self.btn_zoom_out.clicked.connect(self.main_zoom_out)

        self.btn_zoom_in = QPushButton("+")
        self.btn_zoom_in.setFixedWidth(30)
        self.btn_zoom_in.clicked.connect(self.main_zoom_in)

        self.btn_zoom_11 = QPushButton("1:1")
        self.btn_zoom_11.setFixedWidth(40)
        self.btn_zoom_11.clicked.connect(self.main_zoom_11)

        self.btn_zoom_fit = QPushButton("Fit")
        self.btn_zoom_fit.setFixedWidth(40)
        self.btn_zoom_fit.clicked.connect(self.main_zoom_fit)

        hbox.addWidget(self.btn_zoom_out)
        hbox.addWidget(self.btn_zoom_in)
        hbox.addWidget(self.btn_zoom_11)
        hbox.addWidget(self.btn_zoom_fit)
        hbox.addSpacing(10)

        hbox.addWidget(QLabel("Min:"))
        self.txt_min = QLineEdit("0.000")
        self.txt_min.setFixedWidth(60)
        self.txt_min.editingFinished.connect(self.apply_contrast)
        hbox.addWidget(self.txt_min)

        hbox.addWidget(QLabel("Max:"))
        self.txt_max = QLineEdit("1.000")
        self.txt_max.setFixedWidth(60)
        self.txt_max.editingFinished.connect(self.apply_contrast)
        hbox.addWidget(self.txt_max)

        self.btn_apply_contrast = QPushButton("Apply")
        self.btn_apply_contrast.clicked.connect(self.apply_contrast)
        hbox.addWidget(self.btn_apply_contrast)

        hbox.addSpacing(10)
        hbox.addWidget(QLabel("Scale:"))
        self.combo_scale = QComboBox()
        self.combo_scale.addItems(["Linear", "Negative", "HistEq", "Logarithmic", "Sqrt", "AsinH"])
        self.combo_scale.currentIndexChanged.connect(self.apply_contrast)
        hbox.addWidget(self.combo_scale)

        hbox.addStretch()
        self.display_layout.addLayout(hbox)
        self.display_layout.addStretch()

    def setup_extension_panel(self):
        hbox = QHBoxLayout()
        hbox.setContentsMargins(0, 0, 0, 0)
        hbox.addWidget(QLabel("Extension:"))
        self.combo_ext = QComboBox()
        self.combo_ext.addItem("Image")
        hbox.addWidget(self.combo_ext)
        hbox.addSpacing(20)
        hbox.addWidget(QLabel("Collapse:"))
        self.combo_collapse = QComboBox()
        self.combo_collapse.addItems(["Median", "Mean", "Sum"])
        self.combo_collapse.currentIndexChanged.connect(self.on_collapse_changed)
        hbox.addWidget(self.combo_collapse)
        hbox.addStretch()
        self.advanced_layout.addLayout(hbox)

    def setup_axis_panel(self):
        hbox = QHBoxLayout()
        hbox.setContentsMargins(0, 0, 0, 0)

        # X Axis Group
        self.group_x = QGroupBox()
        layout_x = QVBoxLayout(self.group_x)
        layout_x.setContentsMargins(2, 2, 2, 2)
        layout_x.setSpacing(2)
        row1_x = QHBoxLayout()
        row1_x.setContentsMargins(0, 0, 0, 0)
        row1_x.addWidget(QLabel("X:"))
        self.combo_x = QComboBox()
        self.combo_x.addItems(["AXIS 1", "AXIS 2", "AXIS 3"])
        self.combo_x.setCurrentText("AXIS 3")
        self.combo_x.currentIndexChanged.connect(self.on_axis_changed)
        row1_x.addWidget(self.combo_x)
        layout_x.addLayout(row1_x)

        row2_x = QHBoxLayout()
        row2_x.setContentsMargins(0, 0, 0, 0)
        self.btn_x_minus = QPushButton("-")
        self.btn_x_plus = QPushButton("+")
        self.btn_x_11 = QPushButton("1:1")
        self.btn_x_fit = QPushButton("Fit")

        for btn in [self.btn_x_minus, self.btn_x_plus]: btn.setFixedWidth(25)
        for btn in [self.btn_x_11, self.btn_x_fit]: btn.setFixedWidth(35)

        self.btn_x_minus.clicked.connect(lambda: self.independent_zoom(x=2.0))
        self.btn_x_plus.clicked.connect(lambda: self.independent_zoom(x=0.5))
        self.btn_x_11.clicked.connect(self.zoom_x_1_1)
        self.btn_x_fit.clicked.connect(self.zoom_x_fit)

        row2_x.addWidget(self.btn_x_minus)
        row2_x.addWidget(self.btn_x_plus)
        row2_x.addWidget(self.btn_x_11)
        row2_x.addWidget(self.btn_x_fit)
        layout_x.addLayout(row2_x)
        hbox.addWidget(self.group_x)

        # Y Axis Group
        self.group_y = QGroupBox()
        layout_y = QVBoxLayout(self.group_y)
        layout_y.setContentsMargins(2, 2, 2, 2)
        layout_y.setSpacing(2)
        row1_y = QHBoxLayout()
        row1_y.setContentsMargins(0, 0, 0, 0)
        row1_y.addWidget(QLabel("Y:"))
        self.combo_y = QComboBox()
        self.combo_y.addItems(["AXIS 1", "AXIS 2", "AXIS 3"])
        self.combo_y.setCurrentText("AXIS 2")
        self.combo_y.currentIndexChanged.connect(self.on_axis_changed)
        row1_y.addWidget(self.combo_y)
        layout_y.addLayout(row1_y)

        row2_y = QHBoxLayout()
        row2_y.setContentsMargins(0, 0, 0, 0)
        self.btn_y_minus = QPushButton("-")
        self.btn_y_plus = QPushButton("+")
        self.btn_y_11 = QPushButton("1:1")
        self.btn_y_fit = QPushButton("Fit")

        for btn in [self.btn_y_minus, self.btn_y_plus]: btn.setFixedWidth(25)
        for btn in [self.btn_y_11, self.btn_y_fit]: btn.setFixedWidth(35)

        self.btn_y_minus.clicked.connect(lambda: self.independent_zoom(y=2.0))
        self.btn_y_plus.clicked.connect(lambda: self.independent_zoom(y=0.5))
        self.btn_y_11.clicked.connect(self.zoom_y_1_1)
        self.btn_y_fit.clicked.connect(self.zoom_y_fit)

        row2_y.addWidget(self.btn_y_minus)
        row2_y.addWidget(self.btn_y_plus)
        row2_y.addWidget(self.btn_y_11)
        row2_y.addWidget(self.btn_y_fit)
        layout_y.addLayout(row2_y)
        hbox.addWidget(self.group_y)

        hbox.addStretch()
        self.advanced_layout.addLayout(hbox)

    def setup_z_axis_panel(self):
        self.group_z = QGroupBox()
        layout_z = QVBoxLayout(self.group_z)
        layout_z.setContentsMargins(2, 2, 2, 2)
        layout_z.setSpacing(2)

        # Z Info
        info_hbox = QHBoxLayout()
        self.lbl_zmin = QLabel("ZMin: 0")
        self.lbl_zmax = QLabel("ZMax: 0")
        self.lbl_zsize = QLabel("ZSize: 0")
        info_hbox.addWidget(self.lbl_zmin)
        info_hbox.addWidget(self.lbl_zmax)
        info_hbox.addWidget(self.lbl_zsize)
        info_hbox.addStretch()
        layout_z.addLayout(info_hbox)

        self.z_mode_group = QButtonGroup(self)

        # Range row
        self.radio_range = QRadioButton("Collapse Region")
        self.z_mode_group.addButton(self.radio_range)

        range_hbox = QHBoxLayout()
        range_hbox.setContentsMargins(0, 0, 0, 0)
        range_hbox.addWidget(self.radio_range)
        range_hbox.addWidget(QLabel("Z Min:"))
        self.txt_zmin = QLineEdit("0")
        self.txt_zmin.setFixedWidth(50)
        self.txt_zmin.editingFinished.connect(self.apply_z_range)
        range_hbox.addWidget(self.txt_zmin)
        range_hbox.addWidget(QLabel("Z Max:"))
        self.txt_zmax = QLineEdit("0")
        self.txt_zmax.setFixedWidth(50)
        self.txt_zmax.editingFinished.connect(self.apply_z_range)
        range_hbox.addWidget(self.txt_zmax)
        self.btn_apply_range = QPushButton("Apply")
        self.btn_apply_range.clicked.connect(self.apply_z_range)
        range_hbox.addWidget(self.btn_apply_range)
        range_hbox.addStretch()
        layout_z.addLayout(range_hbox)

        # Slice row
        self.radio_slice = QRadioButton("Z Slice")
        self.radio_slice.setChecked(True)
        self.z_mode_group.addButton(self.radio_slice)

        slice_hbox = QHBoxLayout()
        slice_hbox.setContentsMargins(0, 0, 0, 0)
        slice_hbox.addWidget(self.radio_slice)

        slice_inner_vbox = QVBoxLayout()
        self.lbl_slice_val = QLabel("0")
        self.lbl_slice_val.setAlignment(Qt.AlignCenter)
        self.slider_slice = JumpSlider(Qt.Horizontal)
        self.slider_slice.valueChanged.connect(self.on_slider_changed)
        slice_inner_vbox.addWidget(self.lbl_slice_val)
        slice_inner_vbox.addWidget(self.slider_slice)
        slice_hbox.addLayout(slice_inner_vbox)

        slice_hbox.addWidget(QLabel("Boxcar:"))
        self.txt_boxcar = QLineEdit("1")
        self.txt_boxcar.setFixedWidth(40)
        self.txt_boxcar.editingFinished.connect(self.apply_z_slice)
        slice_hbox.addWidget(self.txt_boxcar)
        self.btn_apply_slice = QPushButton("Apply")
        self.btn_apply_slice.clicked.connect(self.apply_z_slice)
        slice_hbox.addWidget(self.btn_apply_slice)
        slice_hbox.addStretch()
        layout_z.addLayout(slice_hbox)

        self.radio_range.toggled.connect(self.z_mode_changed)
        self.advanced_layout.addWidget(self.group_z)



    def main_zoom_out(self):
        self.imv.getView().setAspectLocked(True)
        self.imv.getView().scaleBy((2.0, 2.0))

    def main_zoom_in(self):
        self.imv.getView().setAspectLocked(True)
        self.imv.getView().scaleBy((0.5, 0.5))

    def main_zoom_fit(self):
        self.imv.getView().setAspectLocked(True)
        self.imv.autoRange()

    def main_zoom_11(self):
        self.imv.getView().setAspectLocked(True)
        if self.display_data is not None:
            w, h = self.display_data.shape[-2:]
            self.imv.getView().setRange(xRange=(0, w), yRange=(0, h), padding=0)

    def independent_zoom(self, x=None, y=None):
        self.imv.getView().setAspectLocked(False)
        self.imv.getView().scaleBy(x=x, y=y)

    def zoom_x_1_1(self):
        self.imv.getView().setAspectLocked(False)
        if self.display_data is not None:
            w = self.display_data.shape[-2]
            self.imv.getView().setRange(xRange=(0, w), padding=0)

    def zoom_y_1_1(self):
        self.imv.getView().setAspectLocked(False)
        if self.display_data is not None:
            h = self.display_data.shape[-1]
            self.imv.getView().setRange(yRange=(0, h), padding=0)

    def zoom_x_fit(self):
        self.imv.getView().setAspectLocked(False)
        self.imv.getView().enableAutoRange(axis=pg.ViewBox.XAxis)

    def zoom_y_fit(self):
        self.imv.getView().setAspectLocked(False)
        self.imv.getView().enableAutoRange(axis=pg.ViewBox.YAxis)

    def apply_contrast(self):
        is_range = hasattr(self, 'radio_range') and self.radio_range.isChecked()
        boxcar = 1
        if hasattr(self, 'txt_boxcar'):
            try:
                boxcar = int(self.txt_boxcar.text())
            except ValueError:
                pass

        needs_bypass = is_range or (boxcar > 1 and getattr(self, 'transposed_data', None) is not None and self.transposed_data.ndim == 3)
        current_slice = getattr(self, 'slider_slice', None).value() if hasattr(self, 'slider_slice') else None

        self.update_image_display(use_manual_levels=True, bypass_imv=needs_bypass, set_index=current_slice)

    def z_mode_changed(self):
        is_range = self.radio_range.isChecked()
        self.txt_zmin.setEnabled(is_range)
        self.txt_zmax.setEnabled(is_range)
        self.btn_apply_range.setEnabled(is_range)

        self.slider_slice.setEnabled(not is_range)
        self.txt_boxcar.setEnabled(not is_range)
        self.btn_apply_slice.setEnabled(not is_range)

        # Always allow moving the region itself, but lock the edges in slice mode
        self.z_range_region.setMovable(True)
        for line in self.z_range_region.lines:
            line.setMovable(is_range)

        if self.transposed_data is not None and self.transposed_data.ndim == 3:
            if not is_range:
                self.apply_z_slice()
            else:
                self.apply_z_range()

    def on_collapse_changed(self):
        if self.radio_range.isChecked():
            self.apply_z_range()

    def on_slider_changed(self, value):
        self.lbl_slice_val.setText(str(value))
        if self.radio_slice.isChecked() and self.transposed_data is not None and self.transposed_data.ndim == 3:
            boxcar = 1
            try:
                boxcar = int(self.txt_boxcar.text())
            except ValueError:
                pass

            if boxcar <= 1:
                if getattr(self, 'z_range_region', None) and self.z_range_region.isVisible():
                    self.apply_z_slice()
                else:
                    prev_sync = self._syncing_slice
                    self._syncing_slice = True
                    try:
                        self.imv.setCurrentIndex(value)
                    finally:
                        self._syncing_slice = prev_sync
                    self.update_slice_info()
            else:
                self.apply_z_slice()

    def apply_z_range(self):
        if self.transposed_data is None or self.transposed_data.ndim != 3:
            return
        if self._updating_region:
            return

        # Clamp both ends and reflect the correction in the UI before collapsing: a
        # reversed or out-of-range entry used to slice an empty subcube and blank the
        # display with only a console warning (B12).
        rng = self.z_range_from_fields(write_back=True)
        if rng is None:
            self.lbl_slice_info.setText("Collapsed: Invalid Range")
            return
        zmin, zmax = rng

        if getattr(self.imv, 'timeLine', None) is not None:
            self.imv.timeLine.hide()

        collapsed = self.collapse_plane(zmin, zmax)
        if collapsed is None:
            return

        self.display_data = collapsed
        self.apply_transforms()

        # Highlight the range on the pyqtgraph slider
        self._updating_region = True
        self.z_range_region.setRegion([zmin, zmax])
        self._updating_region = False
        self.z_range_region.show()

        # bypass_imv=True prevents pg.ImageView from realizing the data is now 2D and hiding the 3D timeline
        self.update_image_display(bypass_imv=True)
        self.lbl_zmin.setText(f"ZMin: {zmin}")
        self.lbl_zmax.setText(f"ZMax: {zmax}")
        self.update_slice_info()

    def on_roi_plot_clicked(self, event):
        if event.button() == Qt.LeftButton:
            # Check if click is actually within the plot area bounding box
            if self.imv.ui.roiPlot.sceneBoundingRect().contains(event.scenePos()):
                pos = self.imv.ui.roiPlot.getViewBox().mapSceneToView(event.scenePos())
                x_val = int(round(pos.x()))

                if self.transposed_data is not None:
                    max_val = self.transposed_data.shape[0] - 1
                    x_val = max(0, min(x_val, max_val))

                    if not self.radio_range.isChecked():
                        # Using setValue on the slider will correctly trigger the signals
                        self.slider_slice.setValue(x_val)

    def on_region_change_finished(self):
        if self._updating_region:
            return

        r_min, r_max = self.z_range_region.getRegion()

        if not self.radio_range.isChecked():
            # Z Slice mode with Boxcar > 1
            # User dragged the yellow boxcar region. Update the slice slider to the center!
            center = int(round((r_min + r_max) / 2.0))
            if self.transposed_data is not None:
                center = max(0, min(self.transposed_data.shape[0]-1, center))
            self.slider_slice.setValue(center)
            return

        zmin = max(0, int(round(r_min)))

        if self.transposed_data is not None:
            zmax = min(self.transposed_data.shape[0]-1, int(round(r_max)))
        else:
            zmax = int(round(r_max))

        self.txt_zmin.setText(str(zmin))
        self.txt_zmax.setText(str(zmax))
        self.apply_z_range()

    def apply_z_slice(self):
        if self.transposed_data is None or self.transposed_data.ndim != 3:
            return
        try:
            val = self.slider_slice.value()
            boxcar = self.boxcar_width()

            if boxcar == 1:
                if getattr(self.imv, 'timeLine', None) is not None:
                    self.imv.timeLine.show()
                self.z_range_region.hide()
                self.display_data = self.transposed_data.copy()
                self.apply_transforms()
                self.update_image_display(set_index=val)
            else:
                if getattr(self.imv, 'timeLine', None) is not None:
                    self.imv.timeLine.hide()
                zmin, zmax = self.boxcar_range(val)
                collapsed = self.collapse_plane(zmin, zmax, method="Median")
                if collapsed is None:
                    return
                self.display_data = collapsed
                self.apply_transforms()

                self._updating_region = True
                self.z_range_region.setRegion([zmin, zmax])
                self._updating_region = False
                self.z_range_region.show()

                self.update_image_display(bypass_imv=True)

            self.lbl_zmin.setText(f"ZMin: {val}")
            self.lbl_zmax.setText(f"ZMax: {val}")
            self.update_slice_info()
        except ValueError:
            pass

    def release_data(self):
        """Let go of the cube. Called when the window closes.

        Closing a window does not destroy it: a `lambda: self.x()` connected to a QAction is held
        by that action, which is held by a menu, which is held by this widget, so the cycle runs
        through C++ and Python's collector cannot break it. The viewer therefore stays alive for
        the life of the process — and with it `raw_data`, `transposed_data` and `display_data`,
        three arrays the size of the cube. Measured: five open-and-close cycles on an 8 MB cube
        left all five viewers alive, holding 40 MB of cube and 168 MB of RSS. An OSIRIS cube is
        larger than that by an order of magnitude.

        Dropping the references here is what actually frees the memory. The widget shell that
        survives is a few hundred kB.
        """
        self.raw_data = None
        self.transposed_data = None
        self.display_data = None
        self.header = None
        self.wcs = None
        try:
            self.imv.clear()
        except RuntimeError:
            # Qt is already tearing the widget down; there is nothing left to clear.
            pass

    def set_data(self, data, header=None):
        """Sets the FITS data into the viewer."""
        self.raw_data = data
        self.header = header
        self._is_new_data = True
        self.view_rotation = 0.0
        if data is None:
            self.imv.clear()
            self.wcs = None
            return

        if header:
            with warnings.catch_warnings():
                warnings.simplefilter('ignore')
                self.wcs = WCS(header)

            itime = header.get('ITIME', 1.0)
            if itime == 0:
                itime = header.get('TRUITIME', 1.0)
            coadds = header.get('COADDS', 1.0)
            self._itime_coadds = itime * coadds
        else:
            self.wcs = None
            self._itime_coadds = 1.0

        if data.ndim == 2:
            self.transposed_data = data.T
            self.display_data = self.transposed_data
            self.apply_transforms()
            self.update_image_display()
            # A 2-D image reaches the screen without going through refresh_display, so this is
            # the only place overlays learn that the plane's geometry changed.
            self.display_changed.emit()
            self.lbl_zsize.setText("ZSize: 1")
            self.tab_advanced.setEnabled(True)
            self.combo_ext.setEnabled(True)
            self.combo_collapse.setEnabled(False)
            if hasattr(self, 'group_x'): self.group_x.setEnabled(False)
            if hasattr(self, 'group_y'): self.group_y.setEnabled(False)
            if hasattr(self, 'group_z'): self.group_z.setEnabled(False)
        elif data.ndim == 3:
            self.tab_advanced.setEnabled(True)
            self.combo_ext.setEnabled(True)
            self.combo_collapse.setEnabled(True)
            if hasattr(self, 'group_x'): self.group_x.setEnabled(True)
            if hasattr(self, 'group_y'): self.group_y.setEnabled(True)
            if hasattr(self, 'group_z'): self.group_z.setEnabled(True)

            # Check CTYPE1 to determine default axes
            # OSIRIS has WAVE as CTYPE1, but non-OSIRIS usually has RA---TAN
            # Default to AXIS 1 if RA is first, else OSIRIS-default AXIS 3
            is_non_osiris = False
            if header and 'CTYPE1' in header:
                if 'RA' in header['CTYPE1'].upper():
                    is_non_osiris = True

            default_x = "AXIS 1" if is_non_osiris else "AXIS 3"

            self.combo_x.blockSignals(True)
            self.combo_x.setCurrentText(default_x)
            self.combo_x.blockSignals(False)

            self.combo_y.blockSignals(True)
            self.combo_y.setCurrentText("AXIS 2")
            self.combo_y.blockSignals(False)

            self.apply_axis_mapping()
        else:
            # Not a displayable image (a table's FITS_rec is 1-D, a 4-D cube is unsupported).
            # This used to fall through both branches and return silently, leaving the
            # previous extension's image on screen as if it were this one (B6).
            self.transposed_data = None
            self.display_data = None
            self.wcs = None
            self.imv.clear()
            self.lbl_zsize.setText("ZSize: N/A")
            self.lbl_slice_info.setText(f"Cannot display {data.ndim}-D data")
            self.combo_ext.setEnabled(True)
            self.combo_collapse.setEnabled(False)
            if hasattr(self, 'group_x'): self.group_x.setEnabled(False)
            if hasattr(self, 'group_y'): self.group_y.setEnabled(False)
            if hasattr(self, 'group_z'): self.group_z.setEnabled(False)


    def on_axis_changed(self):
        if self.raw_data is None or self.raw_data.ndim != 3:
            return
        self.apply_axis_mapping()

    def apply_axis_mapping(self):
        if self.raw_data is None or self.raw_data.ndim != 3:
            return

        axes = ["AXIS 1", "AXIS 2", "AXIS 3"]
        x_axis = self.combo_x.currentText()
        y_axis = self.combo_y.currentText()
        self.current_x_axis = x_axis
        self.current_y_axis = y_axis

        if x_axis == y_axis:
            # Avoid duplicate axes by picking the first available one
            available = [a for a in axes if a != x_axis]
            y_axis = available[0]
            # Update the UI without triggering the signal again
            self.combo_y.blockSignals(True)
            self.combo_y.setCurrentText(y_axis)
            self.combo_y.blockSignals(False)

        z_axis = [a for a in axes if a not in (x_axis, y_axis)][0]
        self.current_z_axis = z_axis

        # FITS AXIS 1 -> py 2 (Nx)
        # FITS AXIS 2 -> py 1 (Ny)
        # FITS AXIS 3 -> py 0 (Nz)
        fits_to_py = {"AXIS 1": 2, "AXIS 2": 1, "AXIS 3": 0}

        py_z = fits_to_py[z_axis]
        py_x = fits_to_py[x_axis]
        py_y = fits_to_py[y_axis]

        # Check if Z axis is wavelength
        wcs_z_idx = int(z_axis.split()[1]) - 1
        is_wavelength = False
        if self.wcs is not None and self.wcs.naxis > wcs_z_idx:
            ctype = str(self.wcs.wcs.ctype[wcs_z_idx]).upper()
            if 'WAVE' in ctype:
                is_wavelength = True

        if is_wavelength:
            self.wcs_z_idx = wcs_z_idx
            self.imv.ui.roiPlot.showAxis('top')
        else:
            self.wcs_z_idx = None
            self.imv.ui.roiPlot.hideAxis('top')

        self.transposed_data = self.raw_data.transpose(py_z, py_x, py_y)
        self.display_data = self.transposed_data.copy()
        self.apply_transforms()

        zs = self.transposed_data.shape[0]
        self.slider_slice.setMaximum(zs - 1)
        self.lbl_zsize.setText(f"ZSize: {zs}")
        cur_val = self.slider_slice.value()
        if cur_val >= zs:
            self.slider_slice.setValue(zs-1)

        self.txt_zmax.setText(str(zs-1))

        self.refresh_display()

    def refresh_display(self):
        if self.transposed_data is None:
            return

        self.display_data = self.transposed_data.copy()
        self.apply_transforms()
        self.update_image_display(bypass_imv=(self.display_data.ndim == 2))
        # A flip or rotation moves every drawn overlay; anything holding stored coordinates has
        # to re-derive its position from them (see RegionLayer.refresh).
        self.display_changed.emit()

    @property
    def data_multiplier(self):
        if self.disp_as_dn:
            return getattr(self, '_itime_coadds', 1.0)
        return 1.0

    def apply_transforms(self):
        """Applies DN scaling, rotation, and flips to display_data"""
        if self.display_data is None:
            return

        # 1. Total DN scaling
        if self.disp_as_dn:
            self.display_data = self.display_data * self._itime_coadds

        # 2. Rotation & Flip (shared with current_plane via apply_spatial_transforms)
        self.display_data = self.apply_spatial_transforms(self.display_data)


    def get_north_angle_base(self):
        """Return (theta_n_base, theta_e_base, is_wcs) representing the base angles
        of North and East (degrees CCW from +X axis) in the un-rotated, un-flipped
        transposed_data coordinate system. Returns (90.0, 180.0, False) if no info is available.
        """
        if self.display_data is None or self.transposed_data is None:
            return None, None, False

        import math
        has_wcs_pa = False
        theta_n_base = 90.0
        theta_e_base = 180.0

        if self.wcs is not None and getattr(self.wcs, 'naxis', 0) >= 2:
            try:
                ra_axis = -1
                dec_axis = -1

                # 1. Check Astropy physical types (e.g. 'pos.eq.ra', 'pos.eq.dec', 'pos.galactic.lon', etc.)
                if hasattr(self.wcs, 'world_axis_physical_types'):
                    phys_types = [str(pt).lower() for pt in self.wcs.world_axis_physical_types]
                    for idx, pt in enumerate(phys_types):
                        if ra_axis < 0 and ('ra' in pt or 'lon' in pt):
                            ra_axis = idx
                        elif dec_axis < 0 and ('dec' in pt or 'lat' in pt):
                            dec_axis = idx

                # 2. Fallback to CTYPE string matching
                if ra_axis < 0 or dec_axis < 0:
                    wcs_ctypes = [str(c).upper() for c in self.wcs.wcs.ctype]
                    for idx, ctype in enumerate(wcs_ctypes):
                        if ra_axis < 0 and ('RA' in ctype or 'LON' in ctype):
                            ra_axis = idx
                        elif dec_axis < 0 and ('DEC' in ctype or 'LAT' in ctype):
                            dec_axis = idx

                if ra_axis >= 0 and dec_axis >= 0:
                    if self.wcs.naxis == 2:
                        x_axis_idx = 0
                        y_axis_idx = 1
                    else:
                        x_axis_str = getattr(self, 'current_x_axis', 'AXIS 3')
                        y_axis_str = getattr(self, 'current_y_axis', 'AXIS 2')
                        x_axis_idx = int(x_axis_str.split()[-1]) - 1
                        y_axis_idx = int(y_axis_str.split()[-1]) - 1
                        if x_axis_idx >= self.wcs.naxis:
                            x_axis_idx = 0
                        if y_axis_idx >= self.wcs.naxis:
                            y_axis_idx = 1

                    trans_shape = self.transposed_data.shape
                    raw_cx = (trans_shape[-1] - 1) / 2.0
                    raw_cy = (trans_shape[-2] - 1) / 2.0

                    center_pix = [0.0] * self.wcs.naxis
                    center_pix[x_axis_idx] = raw_cx
                    center_pix[y_axis_idx] = raw_cy

                    world0 = list(self.wcs.pixel_to_world_values(*center_pix))
                    dec0 = world0[dec_axis]
                    ra0 = world0[ra_axis]

                    delta_deg = 0.001
                    world_n = list(world0)
                    world_n[dec_axis] = dec0 + delta_deg
                    pix_n = self.wcs.world_to_pixel_values(*world_n)
                    dx_n = pix_n[x_axis_idx] - raw_cx
                    dy_n = pix_n[y_axis_idx] - raw_cy

                    world_e = list(world0)
                    cos_dec = math.cos(math.radians(dec0)) if abs(dec0) < 89.5 else 1.0
                    world_e[ra_axis] = ra0 + (delta_deg / cos_dec)
                    pix_e = self.wcs.world_to_pixel_values(*world_e)
                    dx_e = pix_e[x_axis_idx] - raw_cx
                    dy_e = pix_e[y_axis_idx] - raw_cy

                    if math.hypot(dx_n, dy_n) > 1e-6 and math.hypot(dx_e, dy_e) > 1e-6:
                        theta_n_base = math.degrees(math.atan2(dy_n, dx_n))
                        theta_e_base = math.degrees(math.atan2(dy_e, dx_e))
                        has_wcs_pa = True
            except Exception:
                has_wcs_pa = False

        if not has_wcs_pa:
            header = self.header or {}
            if 'ROTPOSN' in header:
                rotposn = float(header.get('ROTPOSN', 0.0))
                instangl = float(header.get('INSTANGL', 0.0))
                instr = str(header.get('INSTR', '')).strip().lower()
                tel = str(header.get('TELESCOP', '')).strip()

                iangle = 42.5 if tel == 'Keck I' else 47.5
                if instr == 'spec':
                    north_pa = rotposn - instangl
                elif instr == 'imag':
                    north_pa = rotposn - instangl + iangle
                else:
                    north_pa = rotposn - instangl

                theta_n_base = 90.0 + north_pa
                theta_e_base = theta_n_base + 90.0
                has_wcs_pa = True

        return theta_n_base, theta_e_base, has_wcs_pa

    def north_east_display_angles(self, include_view_rotation=True):
        """North and East as they appear on screen: `(theta_n, theta_e, is_wcs)` in degrees.

        `get_north_angle_base()` reports the two directions in *orig* space; this puts them
        through the display transforms. Which means going through `coords`, in this order:
        the flip mirrors an angle, each 90° step adds 90°, and only then does the ImageItem's
        `view_rotation` apply — it rotates the already-flipped, already-rotated array, so it
        must sit outside the mirror.

        Getting that order wrong is `BUGS.md` B20: the compass used to mirror the whole sum,
        which pointed N and E backwards for a flip with a 90°/270° rotation and turned the
        wrong way for a flip with any view rotation. It was written that way in three places,
        so all three now call this.

        `include_view_rotation=False` gives the angle after the array transforms only, which
        is what solving for a "North Up" view rotation needs.
        """
        theta_n_base, theta_e_base, is_wcs = self.get_north_angle_base()
        if not is_wcs or theta_n_base is None or theta_e_base is None:
            return None, None, False

        def to_display(angle):
            shown = coords.orig_angle_to_display(angle, flip=self.flip, rot_angle=self.rot_angle)
            if include_view_rotation:
                shown += self.view_rotation
            return shown % 360.0

        return to_display(theta_n_base), to_display(theta_e_base), True

    def apply_view_rotation(self, angle):
        """Apply a visual-only rotation to the ImageItem via QTransform.
        Does not modify display_data or transposed_data."""
        from PySide6.QtGui import QTransform
        self.view_rotation = float(angle)

        if self.display_data is None:
            return

        disp_shape = self.display_data.shape
        cx = (disp_shape[-1] - 1) / 2.0
        cy = (disp_shape[-2] - 1) / 2.0

        transform = QTransform()
        transform.translate(cx, cy)
        transform.rotate(self.view_rotation)
        transform.translate(-cx, -cy)
        self.imv.getImageItem().setTransform(transform)

        if getattr(self, 'show_pa', False):
            self.toggle_position_angle(True)

    def on_view_range_changed(self):
        if getattr(self, '_updating_pa', False):
            return
        if getattr(self, 'show_pa', False):
            self._updating_pa = True
            try:
                self.toggle_position_angle(True)
            finally:
                self._updating_pa = False

    def toggle_position_angle(self, checked):
        self.show_pa = bool(checked)

        # Always remove any existing compass items first to prevent accumulating multiple arrows
        for item_attr in ('pa_arrow_n', 'pa_text_n', 'pa_arrow_e', 'pa_text_e'):
            item = getattr(self, item_attr, None)
            if item is not None:
                try:
                    self.imv.getView().removeItem(item)
                except Exception:
                    pass
                setattr(self, item_attr, None)

        if not self.show_pa or self.display_data is None or self.transposed_data is None:
            return

        import math

        theta_n_vis, theta_e_vis, is_wcs = self.north_east_display_angles()
        if not is_wcs:
            return

        # Compass size scaled to the visible view window
        view_rect = self.imv.getView().viewRect()
        cx = view_rect.center().x()
        cy = view_rect.center().y()
        v_width = abs(view_rect.width())
        v_height = abs(view_rect.height())
        v_size = min(v_width, v_height)

        L = v_size * 0.18
        headLen = L * 0.35
        tailWidth = max(v_size * 0.012, 1.0)

        # --- Draw North arrow ---
        rad_n = math.radians(theta_n_vis)
        tip_x_n = cx + L * math.cos(rad_n)
        tip_y_n = cy + L * math.sin(rad_n)
        angle_n = (theta_n_vis + 180.0) % 360.0

        self.pa_arrow_n = pg.ArrowItem(pos=(tip_x_n, tip_y_n), angle=angle_n, headLen=headLen, tailLen=L, tailWidth=tailWidth, pen='r', brush='r', pxMode=False)
        self.imv.getView().addItem(self.pa_arrow_n)

        txt_x_n = cx + (L + headLen * 1.6) * math.cos(rad_n)
        txt_y_n = cy + (L + headLen * 1.6) * math.sin(rad_n)
        self.pa_text_n = pg.TextItem('N', color='r', anchor=(0.5, 0.5))
        self.pa_text_n.setPos(txt_x_n, txt_y_n)
        self.imv.getView().addItem(self.pa_text_n)

        # --- Draw East arrow ---
        rad_e = math.radians(theta_e_vis)
        tip_x_e = cx + L * math.cos(rad_e)
        tip_y_e = cy + L * math.sin(rad_e)
        angle_e = (theta_e_vis + 180.0) % 360.0

        self.pa_arrow_e = pg.ArrowItem(pos=(tip_x_e, tip_y_e), angle=angle_e, headLen=headLen, tailLen=L, tailWidth=tailWidth, pen='r', brush='r', pxMode=False)
        self.imv.getView().addItem(self.pa_arrow_e)

        txt_x_e = cx + (L + headLen * 1.6) * math.cos(rad_e)
        txt_y_e = cy + (L + headLen * 1.6) * math.sin(rad_e)
        self.pa_text_e = pg.TextItem('E', color='r', anchor=(0.5, 0.5))
        self.pa_text_e.setPos(txt_x_e, txt_y_e)
        self.imv.getView().addItem(self.pa_text_e)

    def update_image_display(self, set_index=None, bypass_imv=False, use_manual_levels=False, reset_view=False):
        if self.display_data is None:
            return

        is_new = getattr(self, '_is_new_data', False) or reset_view
        self._is_new_data = False

        try:
            if use_manual_levels:
                vmin = float(self.txt_min.text())
                vmax = float(self.txt_max.text())
            else:
                valid_data = self.display_data[~np.isnan(self.display_data)]
                if valid_data.size > 0:
                    sample = valid_data[::4] if valid_data.size > 10000 else valid_data
                    vmin = np.percentile(sample, 1)
                    vmax = np.percentile(sample, 99)
                else:
                    vmin, vmax = 0, 1
                if vmin == vmax:
                    vmax = vmin + 1
        except Exception:
            vmin, vmax = 0, 1

        # float("nan")/float("inf") parse happily from the Min/Max boxes, and a non-finite
        # level poisons every scale mode into an all-NaN render
        if not (np.isfinite(vmin) and np.isfinite(vmax)):
            vmin, vmax = 0.0, 1.0

        if not use_manual_levels:
            self.txt_min.setText(f"{vmin:.3f}")
            self.txt_max.setText(f"{vmax:.3f}")

        # Integer FITS data (BITPIX 16/32) must be promoted before scaling: HistEq
        # normalizes into [0, 1) and Negative wraps for unsigned dtypes.
        base = self.display_data
        if not np.issubdtype(base.dtype, np.floating):
            base = base.astype(np.result_type(base.dtype, np.float32), copy=False)

        scale_type = self.combo_scale.currentText()
        if scale_type == "Linear":
            render_data = base
            render_vmin, render_vmax = vmin, vmax
        elif scale_type == "Negative":
            render_data = -base
            render_vmin, render_vmax = -vmax, -vmin
        elif scale_type == "Logarithmic":
            render_data = np.log10(np.clip(base - vmin + 1, 1, None))
            render_vmin, render_vmax = 0.0, np.log10(max(1.0, vmax - vmin + 1))
        elif scale_type == "Sqrt":
            render_data = np.sqrt(np.clip(base - vmin, 0, None))
            render_vmin, render_vmax = 0.0, np.sqrt(max(0.0, vmax - vmin))
        elif scale_type == "AsinH":
            noise = (vmax - vmin) / 100.0 if vmax > vmin else 1.0
            render_data = np.arcsinh((base - vmin) / noise)
            render_vmin, render_vmax = 0.0, np.arcsinh((vmax - vmin) / noise)
        elif scale_type == "HistEq":
            valid_mask = ~np.isnan(base)
            valid_data = base[valid_mask]
            sample = valid_data
            if sample.size > 50000:
                sample = np.random.choice(sample, 50000)
            sorted_flat = np.sort(sample)

            render_data = np.zeros(base.shape, dtype=base.dtype)
            if sorted_flat.size > 0:
                render_data[valid_mask] = np.searchsorted(sorted_flat, valid_data) / float(sorted_flat.size)
            render_vmin, render_vmax = 0.0, 1.0

        # Is the plane the user is looking at entirely invalid (a dead slice, a fully masked
        # region)? Probe the data, not render_data — HistEq substitutes zeros of its own,
        # which would mask the condition.
        self.refresh_plane_validity()

        # pyqtgraph derives its autorange from nanmin/nanmax and raises
        # "Cannot set range [nan, nan]" if nothing is finite, so hand it an empty frame with
        # fixed levels instead of a blank display and a traceback (B16). If the displayed
        # plane has any finite pixel the cube cannot be all-invalid, so the full scan only
        # runs in the rare case that matters.
        if self._plane_all_invalid and not np.isfinite(render_data).any():
            render_data = np.zeros(render_data.shape, dtype=float)
            render_vmin, render_vmax = 0.0, 1.0

        view = self.imv.getView()
        view_rect = None if is_new else (view.viewRect() if self.imv.image is not None else None)

        # setImage() resets the ImageView to index 0 and emits sigTimeChanged; re-assert the
        # slider's slice so a reload (or a poller auto-load) does not silently drop to
        # slice 0 while the slider, labels and tools still report the old index.
        if set_index is None and not bypass_imv and render_data.ndim == 3 \
                and hasattr(self, 'radio_slice') and self.radio_slice.isChecked():
            set_index = self.slider_slice.value()

        prev_sync = self._syncing_slice
        self._syncing_slice = True
        try:
            if bypass_imv:
                self.imv.getImageItem().setImage(render_data, autoLevels=False, levels=(render_vmin, render_vmax))
            else:
                self.imv.setImage(render_data, autoRange=is_new, autoLevels=False, levels=(render_vmin, render_vmax))
                if set_index is not None and hasattr(self.imv, 'tVals'):
                    self.imv.setCurrentIndex(int(set_index))
        finally:
            self._syncing_slice = prev_sync

        if is_new:
            view.autoRange()
        elif view_rect is not None and not view_rect.isEmpty():
            view.setRange(rect=view_rect, padding=0)

        self.update_slice_info()
        if getattr(self, 'view_rotation', 0.0) != 0.0:
            self.apply_view_rotation(self.view_rotation)
        elif getattr(self, 'show_pa', False):
            self.toggle_position_angle(True)

    def on_view_clicked(self, evt):
        """Remember where a right-click landed, as a display pixel.

        The bounds come from the *displayed* plane. They used to come from `transposed_data`, and
        with its two spatial extents the wrong way round — `shape[-1]` is the y extent, not x — so
        on a non-square cube a click near an edge was clipped to the wrong pixel, and a 90° rotation
        made it worse. Nothing showed while the only fixtures were square.
        """
        if evt.button() != Qt.RightButton:
            return
        img_item = self.imv.getImageItem()
        dims = self.orig_spatial_dims()
        if img_item is None or dims is None:
            return

        max_x, max_y = coords.display_dims(*dims, self.rot_angle)
        pt = img_item.mapFromScene(evt.scenePos())
        x = int(np.clip(pt.x(), 0, max_x - 1))
        y = int(np.clip(pt.y(), 0, max_y - 1))
        self.last_right_click_pixel_pos = (x, y)

    def on_context_plot_depth(self):
        pos = getattr(self, 'last_right_click_pixel_pos', None)
        self.request_depth_plot.emit(pos)

    def on_context_new_region(self, kind):
        """Ask for a region of `kind` where the right-click landed."""
        self.request_new_region.emit(kind, getattr(self, 'last_right_click_pixel_pos', None))

    def on_context_gaussian_fit(self):
        pos = getattr(self, 'last_right_click_pixel_pos', None)
        self.request_gaussian_fit.emit(pos)

    def mouse_moved(self, evt):
        if self.display_data is None:
            return

        pos = evt[0]  # using signal proxy returns tuple of args
        if self.imv.getView().sceneBoundingRect().contains(pos):
            mouse_point = self.imv.getView().mapSceneToView(pos)
            x, y = int(mouse_point.x()), int(mouse_point.y())

            shape = self.display_data.shape
            # If 3D, shape is (z, x, y)
            is_3d = (self.display_data.ndim == 3)
            max_x = shape[1] if is_3d else shape[0]
            max_y = shape[2] if is_3d else shape[1]

            if 0 <= x < max_x and 0 <= y < max_y:
                self.lbl_x.setText(f"X: {x}")
                self.lbl_y.setText(f"Y: {y}")

                if is_3d:
                    z = self.slider_slice.value()
                    val = self.display_data[z, x, y]
                else:
                    val = self.display_data[x, y]
                unit = "DN" if self.disp_as_dn else "DN/s"
                self.lbl_val.setText(f"Value: {val:.5g} {unit}")

                if self.wcs and self.wcs.naxis >= 2:
                    try:
                        # Undo the display flip and rotation to get the coordinate along the
                        # FITS axes, which is what the WCS is indexed by.
                        orig_x, orig_y = self.display_to_orig(x, y)

                        # WCS pixel to world
                        if self.wcs.naxis == 2:
                            p1 = orig_x; p2 = orig_y
                            world = self.wcs.pixel_to_world(p1, p2)
                            if hasattr(world, 'ra') and hasattr(world, 'dec'):
                                self.lbl_wcs.setText(f"WCS: {world.ra.to_string(unit=u.hour, sep='hms', precision=3)}  {world.dec.to_string(unit=u.deg, sep='dms', precision=2)}")
                            elif isinstance(world, (list, tuple)) and len(world) >= 2:
                                self.lbl_wcs.setText(f"WCS: {world[0]:.5g}  {world[1]:.5g}")
                            else:
                                self.lbl_wcs.setText(f"WCS: {world}")
                        elif self.wcs.naxis >= 3 and self.wcs_z_idx is not None:
                            if hasattr(self, 'radio_range') and self.radio_range.isChecked():
                                try:
                                    z_min = max(0, int(self.txt_zmin.text() or 0))
                                    z_max = max(0, int(self.txt_zmax.text() or 0))
                                    z = (z_min + z_max) // 2
                                except ValueError:
                                    z = 0
                            else:
                                z = self.slider_slice.value()

                            val_dict = {
                                getattr(self, 'current_x_axis', 'AXIS 1'): orig_x,
                                getattr(self, 'current_y_axis', 'AXIS 2'): orig_y,
                                getattr(self, 'current_z_axis', 'AXIS 3'): z
                            }
                            p1 = val_dict.get('AXIS 1', 0)
                            p2 = val_dict.get('AXIS 2', 0)
                            p3 = val_dict.get('AXIS 3', 0)

                            vals = self.wcs.pixel_to_world_values(p1, p2, p3)
                            disp_axes = [getattr(self, 'current_x_axis', 'AXIS 1'), getattr(self, 'current_y_axis', 'AXIS 2')]
                            wcs_parts = []
                            for ax in disp_axes:
                                ax_idx = int(ax.split()[-1]) - 1
                                if ax_idx < len(vals):
                                    val = vals[ax_idx]
                                    phys = self.wcs.world_axis_physical_types[ax_idx]
                                    if phys == 'pos.eq.ra':
                                        coord = SkyCoord(ra=val*u.deg, dec=0*u.deg)
                                        wcs_parts.append(f"RA: {coord.ra.to_string(unit=u.hour, sep='hms', precision=3)}")
                                    elif phys == 'pos.eq.dec':
                                        coord = SkyCoord(ra=0*u.deg, dec=val*u.deg)
                                        wcs_parts.append(f"DEC: {coord.dec.to_string(unit=u.deg, sep='dms', precision=2)}")
                                    else:
                                        unit = self.wcs.world_axis_units[ax_idx]
                                        wcs_parts.append(f"{phys}: {val:.5g} {unit}")
                            wcs_text = "  |  ".join(wcs_parts)

                            self.lbl_wcs.setText(f"WCS: {wcs_text}")
                    except Exception:
                        self.lbl_wcs.setText("WCS: N/A")
                else:
                    self.lbl_wcs.setText("WCS: N/A")
            else:
                self.lbl_val.setText("Value: Out of Bounds")

    def set_colormap(self, cmap_name=None, invert=None):
        if cmap_name is not None:
            self.current_cmap_name = cmap_name
        else:
            cmap_name = getattr(self, 'current_cmap_name', 'cmc.oslo')

        if invert is not None:
            self.is_cmap_inverted = invert
        else:
            invert = getattr(self, 'is_cmap_inverted', False)

        lookup_name = cmap_name
        if invert:
            if lookup_name.endswith('_r'):
                lookup_name = lookup_name[:-2]
            else:
                lookup_name = lookup_name + '_r'

        try:
            import pyqtgraph as pg

            cmap = None
            # 1. Try pyqtgraph native colormap
            try:
                cmap = pg.colormap.get(lookup_name)
            except Exception:
                pass

            # 2. Try matplotlib / cmcrameri colormap
            if cmap is None:
                try:
                    # Side-effect import; see the note at the top of this module.
                    import cmcrameri.cm  # noqa: F401
                except ImportError:
                    pass
                try:
                    cmap = pg.colormap.getFromMatplotlib(lookup_name)
                except Exception:
                    pass

            # 3. Try with/without cmc. prefix
            if cmap is None:
                alt_name = lookup_name[4:] if lookup_name.startswith('cmc.') else 'cmc.' + lookup_name
                try:
                    cmap = pg.colormap.getFromMatplotlib(alt_name)
                except Exception:
                    pass

            # 4. Fallback to viridis or grey if requested colormap is unavailable
            if cmap is None:
                try:
                    cmap = pg.colormap.get('viridis')
                except Exception:
                    cmap = pg.colormap.get('grey')

            if cmap is not None:
                self.imv.setColorMap(cmap)
        except Exception as e:
            print(f"Warning: Could not set colormap {lookup_name}: {e}")

    def toggle_colorbar(self, show: bool):
        if show:
            self.imv.ui.histogram.show()
            self.update_colorbar_label()
        else:
            self.imv.ui.histogram.hide()

    def update_colorbar_label(self):
        if self.imv.ui.histogram.isVisible():
            unit = "Total DN" if getattr(self, 'disp_as_dn', False) else "DN/s"
            self.imv.ui.histogram.axis.setLabel("Pixel Value", units=unit)
apply_spatial_transforms(arr)

Return arr with the display flip and 90° rotations applied.

Pure: it neither touches display_data nor applies the DN multiplier. Accepts a 2-D plane (x, y) or a 3-D cube (z, x, y).

Source code in pyql3/gui/viewers/image_viewer.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def apply_spatial_transforms(self, arr):
    """Return `arr` with the display flip and 90° rotations applied.

    Pure: it neither touches `display_data` nor applies the DN multiplier. Accepts a
    2-D plane (x, y) or a 3-D cube (z, x, y).
    """
    if arr is None:
        return None
    k = self.rot_angle // 90
    if arr.ndim == 3:
        if self.flip:
            arr = np.flip(arr, axis=1)  # flip X
        if k != 0:
            arr = np.rot90(arr, k=k, axes=(1, 2))
    else:
        if self.flip:
            arr = np.flip(arr, axis=0)  # flip X
        if k != 0:
            arr = np.rot90(arr, k=k)
    return arr
apply_transforms()

Applies DN scaling, rotation, and flips to display_data

Source code in pyql3/gui/viewers/image_viewer.py
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
def apply_transforms(self):
    """Applies DN scaling, rotation, and flips to display_data"""
    if self.display_data is None:
        return

    # 1. Total DN scaling
    if self.disp_as_dn:
        self.display_data = self.display_data * self._itime_coadds

    # 2. Rotation & Flip (shared with current_plane via apply_spatial_transforms)
    self.display_data = self.apply_spatial_transforms(self.display_data)
apply_view_rotation(angle)

Apply a visual-only rotation to the ImageItem via QTransform. Does not modify display_data or transposed_data.

Source code in pyql3/gui/viewers/image_viewer.py
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
def apply_view_rotation(self, angle):
    """Apply a visual-only rotation to the ImageItem via QTransform.
    Does not modify display_data or transposed_data."""
    from PySide6.QtGui import QTransform
    self.view_rotation = float(angle)

    if self.display_data is None:
        return

    disp_shape = self.display_data.shape
    cx = (disp_shape[-1] - 1) / 2.0
    cy = (disp_shape[-2] - 1) / 2.0

    transform = QTransform()
    transform.translate(cx, cy)
    transform.rotate(self.view_rotation)
    transform.translate(-cx, -cy)
    self.imv.getImageItem().setTransform(transform)

    if getattr(self, 'show_pa', False):
        self.toggle_position_angle(True)
begin_exclusive_drag(owner, handler, on_revoked=None)

Give owner sole control of the view's drag handling until it gives it back.

Drawing a box or a region works by replacing ViewBox.mouseDragEvent, and only one thing can hold it at a time. Each caller used to save the current handler itself and restore it on the way out, which corrupts as soon as two of them overlap: the second saves the first's handler, and whichever finishes last restores the wrong one, leaving the view permanently unable to pan. Ownership is tracked here instead, and the previous owner is told (on_revoked) so it can un-check its own button.

Source code in pyql3/gui/viewers/image_viewer.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def begin_exclusive_drag(self, owner, handler, on_revoked=None):
    """Give `owner` sole control of the view's drag handling until it gives it back.

    Drawing a box or a region works by replacing `ViewBox.mouseDragEvent`, and only one
    thing can hold it at a time. Each caller used to save the current handler itself and
    restore it on the way out, which corrupts as soon as two of them overlap: the second
    saves the *first's* handler, and whichever finishes last restores the wrong one, leaving
    the view permanently unable to pan. Ownership is tracked here instead, and the previous
    owner is told (`on_revoked`) so it can un-check its own button.
    """
    view = self.imv.getView()
    if self._drag_owner is not None and self._drag_owner is not owner:
        self._revoke_exclusive_drag()

    if self._drag_handler_before is None:
        # Captured once, and only ever the genuine original.
        self._drag_handler_before = view.mouseDragEvent

    view.mouseDragEvent = handler
    view.setMouseEnabled(x=False, y=False)
    self._drag_owner = owner
    self._drag_revoked_callback = on_revoked
boxcar_range(z=None)

The inclusive channel range the Boxcar setting averages around z.

Source code in pyql3/gui/viewers/image_viewer.py
222
223
224
225
226
227
def boxcar_range(self, z=None):
    """The inclusive channel range the Boxcar setting averages around `z`."""
    if z is None:
        z = self.slider_slice.value()
    half = self.boxcar_width() // 2
    return self.clamp_z_range(z - half, z + half)
boxcar_width()

The Boxcar setting, at least 1.

Source code in pyql3/gui/viewers/image_viewer.py
215
216
217
218
219
220
def boxcar_width(self):
    """The Boxcar setting, at least 1."""
    try:
        return max(1, int(self.txt_boxcar.text()))
    except (ValueError, AttributeError):
        return 1
clamp_z_range(zmin, zmax, write_back=False)

Clamp a channel range to the cube and return it in ascending order.

Returns (zmin, zmax), inclusive, or None if there is no cube. Clamping both ends matters: a reversed or past-the-end range slices an empty subcube, and the nan-reductions then return an all-NaN plane (see B12/B16). With write_back the corrected values are pushed into the Z Min / Z Max boxes so the UI shows the range that was actually used.

Source code in pyql3/gui/viewers/image_viewer.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def clamp_z_range(self, zmin, zmax, write_back=False):
    """Clamp a channel range to the cube and return it in ascending order.

    Returns `(zmin, zmax)`, inclusive, or None if there is no cube. Clamping *both*
    ends matters: a reversed or past-the-end range slices an empty subcube, and the
    nan-reductions then return an all-NaN plane (see B12/B16). With `write_back` the
    corrected values are pushed into the Z Min / Z Max boxes so the UI shows the range
    that was actually used.
    """
    if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
        return None
    nz = self.transposed_data.shape[0]
    zmin = int(np.clip(int(zmin), 0, nz - 1))
    zmax = int(np.clip(int(zmax), 0, nz - 1))
    if zmin > zmax:
        zmin, zmax = zmax, zmin

    if write_back and hasattr(self, 'txt_zmin'):
        # setText() does not re-emit editingFinished, so this cannot recurse
        if self.txt_zmin.text() != str(zmin):
            self.txt_zmin.setText(str(zmin))
        if self.txt_zmax.text() != str(zmax):
            self.txt_zmax.setText(str(zmax))
    return zmin, zmax
collapse_plane(zmin, zmax, method=None)

Collapse transposed_data over the inclusive range [zmin, zmax] into a plane.

The single definition of the collapse arithmetic, shared by apply_z_range, apply_z_slice (Boxcar) and current_plane.

Source code in pyql3/gui/viewers/image_viewer.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def collapse_plane(self, zmin, zmax, method=None):
    """Collapse `transposed_data` over the inclusive range `[zmin, zmax]` into a plane.

    The single definition of the collapse arithmetic, shared by `apply_z_range`,
    `apply_z_slice` (Boxcar) and `current_plane`.
    """
    if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
        return None
    if method is None:
        method = self.combo_collapse.currentText()

    subcube = self.transposed_data[zmin:zmax + 1, :, :]
    if subcube.shape[0] == 0:
        return None  # clamp_z_range makes this unreachable; kept so callers can't blank

    # An all-NaN region is expected for dead slices and is handled downstream by the
    # all-invalid guard in update_image_display; don't spam the console about it.
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", RuntimeWarning)
        if method == "Median":
            return np.nanmedian(subcube, axis=0)
        if method == "Mean":
            return np.nanmean(subcube, axis=0)
        if method == "Sum":
            return np.nansum(subcube, axis=0)
    return subcube[0]
current_plane()

The 2-D plane currently on screen, oriented like display_data but without the DN multiplier folded in.

Tools that analyse the displayed pixels of a cube must use this rather than indexing a single channel: in Boxcar or Z Range mode the screen shows a collapsed plane that exists in no single channel of the cube (see B17). Callers that apply data_multiplier themselves want this; callers that don't can use display_data.

Source code in pyql3/gui/viewers/image_viewer.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def current_plane(self):
    """The 2-D plane currently on screen, oriented like `display_data` but *without*
    the DN multiplier folded in.

    Tools that analyse the displayed pixels of a cube must use this rather than
    indexing a single channel: in Boxcar or Z Range mode the screen shows a collapsed
    plane that exists in no single channel of the cube (see B17). Callers that apply
    `data_multiplier` themselves want this; callers that don't can use `display_data`.
    """
    if getattr(self, 'transposed_data', None) is None:
        return None
    if self.transposed_data.ndim != 3:
        return self.apply_spatial_transforms(self.transposed_data)

    if hasattr(self, 'radio_range') and self.radio_range.isChecked():
        rng = self.z_range_from_fields()
        plane = self.collapse_plane(*rng) if rng is not None else None
    elif self.boxcar_width() > 1:
        rng = self.boxcar_range()
        plane = self.collapse_plane(*rng, method="Median") if rng is not None else None
    else:
        plane = self.transposed_data[self.current_z()]

    return self.apply_spatial_transforms(plane)
current_z()

Canonical z index of the plane currently on screen.

Prefer this over imv.currentIndex in tools: the ImageView index is only maintained while a single slice is displayed, and goes stale as soon as a boxcar or collapse range is drawn through bypass_imv.

Source code in pyql3/gui/viewers/image_viewer.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def current_z(self):
    """Canonical z index of the plane currently on screen.

    Prefer this over `imv.currentIndex` in tools: the ImageView index is only
    maintained while a single slice is displayed, and goes stale as soon as a boxcar
    or collapse range is drawn through `bypass_imv`.
    """
    if getattr(self, 'transposed_data', None) is None or self.transposed_data.ndim != 3:
        return 0
    nz = self.transposed_data.shape[0]

    if hasattr(self, 'radio_range') and self.radio_range.isChecked():
        # Collapsed plane: report the middle of the range, as the WCS readout does
        rng = self.z_range_from_fields()
        if rng is None:
            return int(np.clip(self.slider_slice.value(), 0, nz - 1))
        return (rng[0] + rng[1]) // 2

    # Slice mode, boxcar included: the slider is the source of truth
    return int(np.clip(self.slider_slice.value(), 0, nz - 1))
display_axis_indices()

The 0-based FITS axis indices currently shown as X and Y.

For a 2-D image this is (0, 1); for an OSIRIS cube, whose X axis is FITS axis 3, it is (2, 1). Anything that has to talk to a WCS or to ds9 needs this — ds9's image frame always means FITS axes 1 and 2, so a cube displayed on other axes cannot use it.

The expression was repeated in three places with two different fallbacks, one of which (AXIS 1) names the wavelength axis for an OSIRIS cube.

Source code in pyql3/gui/viewers/image_viewer.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def display_axis_indices(self):
    """The 0-based FITS axis indices currently shown as X and Y.

    For a 2-D image this is `(0, 1)`; for an OSIRIS cube, whose X axis is FITS axis 3, it
    is `(2, 1)`. Anything that has to talk to a WCS or to ds9 needs this — ds9's `image`
    frame always means FITS axes 1 and 2, so a cube displayed on other axes cannot use it.

    The expression was repeated in three places with two different fallbacks, one of which
    (`AXIS 1`) names the wavelength axis for an OSIRIS cube.
    """
    def index_of(attribute, default):
        name = getattr(self, attribute, None) or default
        try:
            return max(0, int(str(name).split()[-1]) - 1)
        except (ValueError, IndexError):
            return int(str(default).split()[-1]) - 1

    naxis = self.raw_data.ndim if getattr(self, 'raw_data', None) is not None else 2
    if naxis < 3:
        # A 2-D image has no axis mapping to consult; X is axis 1 and Y is axis 2.
        return (0, 1)
    return (index_of('current_x_axis', 'AXIS 3'), index_of('current_y_axis', 'AXIS 2'))
display_to_orig(x, y)

Map a screen coordinate back to a transposed_data coordinate, or None if no data.

Source code in pyql3/gui/viewers/image_viewer.py
372
373
374
375
376
377
def display_to_orig(self, x, y):
    """Map a screen coordinate back to a `transposed_data` coordinate, or None if no data."""
    dims = self.orig_spatial_dims()
    if dims is None:
        return None
    return coords.display_to_orig(x, y, *dims, flip=self.flip, rot_angle=self.rot_angle)
end_exclusive_drag(owner=None)

Hand drag handling back. A non-owner asking for this is ignored, not obeyed.

Source code in pyql3/gui/viewers/image_viewer.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def end_exclusive_drag(self, owner=None):
    """Hand drag handling back. A non-owner asking for this is ignored, not obeyed."""
    if self._drag_owner is None:
        return
    if owner is not None and self._drag_owner is not owner:
        return

    view = self.imv.getView()
    if self._drag_handler_before is not None:
        view.mouseDragEvent = self._drag_handler_before
        self._drag_handler_before = None
    view.setMouseEnabled(x=True, y=True)
    self._drag_owner = None
    self._drag_revoked_callback = None
get_north_angle_base()

Return (theta_n_base, theta_e_base, is_wcs) representing the base angles of North and East (degrees CCW from +X axis) in the un-rotated, un-flipped transposed_data coordinate system. Returns (90.0, 180.0, False) if no info is available.

Source code in pyql3/gui/viewers/image_viewer.py
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
def get_north_angle_base(self):
    """Return (theta_n_base, theta_e_base, is_wcs) representing the base angles
    of North and East (degrees CCW from +X axis) in the un-rotated, un-flipped
    transposed_data coordinate system. Returns (90.0, 180.0, False) if no info is available.
    """
    if self.display_data is None or self.transposed_data is None:
        return None, None, False

    import math
    has_wcs_pa = False
    theta_n_base = 90.0
    theta_e_base = 180.0

    if self.wcs is not None and getattr(self.wcs, 'naxis', 0) >= 2:
        try:
            ra_axis = -1
            dec_axis = -1

            # 1. Check Astropy physical types (e.g. 'pos.eq.ra', 'pos.eq.dec', 'pos.galactic.lon', etc.)
            if hasattr(self.wcs, 'world_axis_physical_types'):
                phys_types = [str(pt).lower() for pt in self.wcs.world_axis_physical_types]
                for idx, pt in enumerate(phys_types):
                    if ra_axis < 0 and ('ra' in pt or 'lon' in pt):
                        ra_axis = idx
                    elif dec_axis < 0 and ('dec' in pt or 'lat' in pt):
                        dec_axis = idx

            # 2. Fallback to CTYPE string matching
            if ra_axis < 0 or dec_axis < 0:
                wcs_ctypes = [str(c).upper() for c in self.wcs.wcs.ctype]
                for idx, ctype in enumerate(wcs_ctypes):
                    if ra_axis < 0 and ('RA' in ctype or 'LON' in ctype):
                        ra_axis = idx
                    elif dec_axis < 0 and ('DEC' in ctype or 'LAT' in ctype):
                        dec_axis = idx

            if ra_axis >= 0 and dec_axis >= 0:
                if self.wcs.naxis == 2:
                    x_axis_idx = 0
                    y_axis_idx = 1
                else:
                    x_axis_str = getattr(self, 'current_x_axis', 'AXIS 3')
                    y_axis_str = getattr(self, 'current_y_axis', 'AXIS 2')
                    x_axis_idx = int(x_axis_str.split()[-1]) - 1
                    y_axis_idx = int(y_axis_str.split()[-1]) - 1
                    if x_axis_idx >= self.wcs.naxis:
                        x_axis_idx = 0
                    if y_axis_idx >= self.wcs.naxis:
                        y_axis_idx = 1

                trans_shape = self.transposed_data.shape
                raw_cx = (trans_shape[-1] - 1) / 2.0
                raw_cy = (trans_shape[-2] - 1) / 2.0

                center_pix = [0.0] * self.wcs.naxis
                center_pix[x_axis_idx] = raw_cx
                center_pix[y_axis_idx] = raw_cy

                world0 = list(self.wcs.pixel_to_world_values(*center_pix))
                dec0 = world0[dec_axis]
                ra0 = world0[ra_axis]

                delta_deg = 0.001
                world_n = list(world0)
                world_n[dec_axis] = dec0 + delta_deg
                pix_n = self.wcs.world_to_pixel_values(*world_n)
                dx_n = pix_n[x_axis_idx] - raw_cx
                dy_n = pix_n[y_axis_idx] - raw_cy

                world_e = list(world0)
                cos_dec = math.cos(math.radians(dec0)) if abs(dec0) < 89.5 else 1.0
                world_e[ra_axis] = ra0 + (delta_deg / cos_dec)
                pix_e = self.wcs.world_to_pixel_values(*world_e)
                dx_e = pix_e[x_axis_idx] - raw_cx
                dy_e = pix_e[y_axis_idx] - raw_cy

                if math.hypot(dx_n, dy_n) > 1e-6 and math.hypot(dx_e, dy_e) > 1e-6:
                    theta_n_base = math.degrees(math.atan2(dy_n, dx_n))
                    theta_e_base = math.degrees(math.atan2(dy_e, dx_e))
                    has_wcs_pa = True
        except Exception:
            has_wcs_pa = False

    if not has_wcs_pa:
        header = self.header or {}
        if 'ROTPOSN' in header:
            rotposn = float(header.get('ROTPOSN', 0.0))
            instangl = float(header.get('INSTANGL', 0.0))
            instr = str(header.get('INSTR', '')).strip().lower()
            tel = str(header.get('TELESCOP', '')).strip()

            iangle = 42.5 if tel == 'Keck I' else 47.5
            if instr == 'spec':
                north_pa = rotposn - instangl
            elif instr == 'imag':
                north_pa = rotposn - instangl + iangle
            else:
                north_pa = rotposn - instangl

            theta_n_base = 90.0 + north_pa
            theta_e_base = theta_n_base + 90.0
            has_wcs_pa = True

    return theta_n_base, theta_e_base, has_wcs_pa
north_east_display_angles(include_view_rotation=True)

North and East as they appear on screen: (theta_n, theta_e, is_wcs) in degrees.

get_north_angle_base() reports the two directions in orig space; this puts them through the display transforms. Which means going through coords, in this order: the flip mirrors an angle, each 90° step adds 90°, and only then does the ImageItem's view_rotation apply — it rotates the already-flipped, already-rotated array, so it must sit outside the mirror.

Getting that order wrong is BUGS.md B20: the compass used to mirror the whole sum, which pointed N and E backwards for a flip with a 90°/270° rotation and turned the wrong way for a flip with any view rotation. It was written that way in three places, so all three now call this.

include_view_rotation=False gives the angle after the array transforms only, which is what solving for a "North Up" view rotation needs.

Source code in pyql3/gui/viewers/image_viewer.py
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
def north_east_display_angles(self, include_view_rotation=True):
    """North and East as they appear on screen: `(theta_n, theta_e, is_wcs)` in degrees.

    `get_north_angle_base()` reports the two directions in *orig* space; this puts them
    through the display transforms. Which means going through `coords`, in this order:
    the flip mirrors an angle, each 90° step adds 90°, and only then does the ImageItem's
    `view_rotation` apply — it rotates the already-flipped, already-rotated array, so it
    must sit outside the mirror.

    Getting that order wrong is `BUGS.md` B20: the compass used to mirror the whole sum,
    which pointed N and E backwards for a flip with a 90°/270° rotation and turned the
    wrong way for a flip with any view rotation. It was written that way in three places,
    so all three now call this.

    `include_view_rotation=False` gives the angle after the array transforms only, which
    is what solving for a "North Up" view rotation needs.
    """
    theta_n_base, theta_e_base, is_wcs = self.get_north_angle_base()
    if not is_wcs or theta_n_base is None or theta_e_base is None:
        return None, None, False

    def to_display(angle):
        shown = coords.orig_angle_to_display(angle, flip=self.flip, rot_angle=self.rot_angle)
        if include_view_rotation:
            shown += self.view_rotation
        return shown % 360.0

    return to_display(theta_n_base), to_display(theta_e_base), True
on_context_new_region(kind)

Ask for a region of kind where the right-click landed.

Source code in pyql3/gui/viewers/image_viewer.py
1599
1600
1601
def on_context_new_region(self, kind):
    """Ask for a region of `kind` where the right-click landed."""
    self.request_new_region.emit(kind, getattr(self, 'last_right_click_pixel_pos', None))
on_imv_time_changed(ind, _time=None)

Mirror a user-driven timeline drag back onto the Z Slice slider.

Only user interaction should reach here: our own calls into the ImageView are wrapped in _syncing_slice, because imv.setImage() emits a spurious sigTimeChanged(0) that would otherwise yank the slider back to slice 0.

Source code in pyql3/gui/viewers/image_viewer.py
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def on_imv_time_changed(self, ind, _time=None):
    """Mirror a user-driven timeline drag back onto the Z Slice slider.

    Only user interaction should reach here: our own calls into the ImageView are
    wrapped in `_syncing_slice`, because `imv.setImage()` emits a spurious
    sigTimeChanged(0) that would otherwise yank the slider back to slice 0.
    """
    if self._syncing_slice or getattr(self, 'transposed_data', None) is None:
        return
    if self.transposed_data.ndim != 3 or not self.radio_slice.isChecked():
        return

    try:
        ind = int(ind)
    except (TypeError, ValueError):
        return

    self._syncing_slice = True
    try:
        if self.slider_slice.value() != ind:
            # Drives lbl_slice_val, update_slice_info, the readouts and the tools
            self.slider_slice.setValue(ind)
        else:
            self.update_slice_info()
    finally:
        self._syncing_slice = False
on_view_clicked(evt)

Remember where a right-click landed, as a display pixel.

The bounds come from the displayed plane. They used to come from transposed_data, and with its two spatial extents the wrong way round — shape[-1] is the y extent, not x — so on a non-square cube a click near an edge was clipped to the wrong pixel, and a 90° rotation made it worse. Nothing showed while the only fixtures were square.

Source code in pyql3/gui/viewers/image_viewer.py
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
def on_view_clicked(self, evt):
    """Remember where a right-click landed, as a display pixel.

    The bounds come from the *displayed* plane. They used to come from `transposed_data`, and
    with its two spatial extents the wrong way round — `shape[-1]` is the y extent, not x — so
    on a non-square cube a click near an edge was clipped to the wrong pixel, and a 90° rotation
    made it worse. Nothing showed while the only fixtures were square.
    """
    if evt.button() != Qt.RightButton:
        return
    img_item = self.imv.getImageItem()
    dims = self.orig_spatial_dims()
    if img_item is None or dims is None:
        return

    max_x, max_y = coords.display_dims(*dims, self.rot_angle)
    pt = img_item.mapFromScene(evt.scenePos())
    x = int(np.clip(pt.x(), 0, max_x - 1))
    y = int(np.clip(pt.y(), 0, max_y - 1))
    self.last_right_click_pixel_pos = (x, y)
orig_spatial_dims()

(nx, ny) of transposed_data's spatial axes, before flip and rotation.

The last two axes are the spatial ones for both a 2-D image (x, y) and a cube (z, x, y). Returns None when there is nothing loaded.

Source code in pyql3/gui/viewers/image_viewer.py
277
278
279
280
281
282
283
284
285
286
def orig_spatial_dims(self):
    """`(nx, ny)` of `transposed_data`'s spatial axes, before flip and rotation.

    The last two axes are the spatial ones for both a 2-D image `(x, y)` and a cube
    `(z, x, y)`. Returns None when there is nothing loaded.
    """
    if getattr(self, 'transposed_data', None) is None:
        return None
    nx, ny = self.transposed_data.shape[-2:]
    return int(nx), int(ny)
orig_to_display(x, y)

Map a transposed_data coordinate to its position on screen, or None if no data.

Thin adapter over pyql3.core.coords; see that module for what "orig" means and why the arithmetic lives in exactly one place (BUGS.md B13/B14).

Source code in pyql3/gui/viewers/image_viewer.py
361
362
363
364
365
366
367
368
369
370
def orig_to_display(self, x, y):
    """Map a `transposed_data` coordinate to its position on screen, or None if no data.

    Thin adapter over `pyql3.core.coords`; see that module for what "orig" means and why
    the arithmetic lives in exactly one place (`BUGS.md` B13/B14).
    """
    dims = self.orig_spatial_dims()
    if dims is None:
        return None
    return coords.orig_to_display(x, y, *dims, flip=self.flip, rot_angle=self.rot_angle)
refresh_plane_validity()

Recompute whether the plane on screen has any finite pixel at all.

Cheap (one plane, never the whole cube) and has to be re-run on every slice change: paging onto a dead channel does not redisplay the image, it only moves the ImageView's index.

Source code in pyql3/gui/viewers/image_viewer.py
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def refresh_plane_validity(self):
    """Recompute whether the plane on screen has any finite pixel at all.

    Cheap (one plane, never the whole cube) and has to be re-run on every slice change:
    paging onto a dead channel does not redisplay the image, it only moves the
    ImageView's index.
    """
    data = getattr(self, 'display_data', None)
    if data is None:
        self._plane_all_invalid = False
        return self._plane_all_invalid
    plane = data
    if plane.ndim == 3:
        plane = plane[int(np.clip(self.current_z(), 0, plane.shape[0] - 1))]
    self._plane_all_invalid = not bool(np.isfinite(plane).any())
    return self._plane_all_invalid
release_data()

Let go of the cube. Called when the window closes.

Closing a window does not destroy it: a lambda: self.x() connected to a QAction is held by that action, which is held by a menu, which is held by this widget, so the cycle runs through C++ and Python's collector cannot break it. The viewer therefore stays alive for the life of the process — and with it raw_data, transposed_data and display_data, three arrays the size of the cube. Measured: five open-and-close cycles on an 8 MB cube left all five viewers alive, holding 40 MB of cube and 168 MB of RSS. An OSIRIS cube is larger than that by an order of magnitude.

Dropping the references here is what actually frees the memory. The widget shell that survives is a few hundred kB.

Source code in pyql3/gui/viewers/image_viewer.py
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
def release_data(self):
    """Let go of the cube. Called when the window closes.

    Closing a window does not destroy it: a `lambda: self.x()` connected to a QAction is held
    by that action, which is held by a menu, which is held by this widget, so the cycle runs
    through C++ and Python's collector cannot break it. The viewer therefore stays alive for
    the life of the process — and with it `raw_data`, `transposed_data` and `display_data`,
    three arrays the size of the cube. Measured: five open-and-close cycles on an 8 MB cube
    left all five viewers alive, holding 40 MB of cube and 168 MB of RSS. An OSIRIS cube is
    larger than that by an order of magnitude.

    Dropping the references here is what actually frees the memory. The widget shell that
    survives is a few hundred kB.
    """
    self.raw_data = None
    self.transposed_data = None
    self.display_data = None
    self.header = None
    self.wcs = None
    try:
        self.imv.clear()
    except RuntimeError:
        # Qt is already tearing the widget down; there is nothing left to clear.
        pass
set_data(data, header=None)

Sets the FITS data into the viewer.

Source code in pyql3/gui/viewers/image_viewer.py
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
def set_data(self, data, header=None):
    """Sets the FITS data into the viewer."""
    self.raw_data = data
    self.header = header
    self._is_new_data = True
    self.view_rotation = 0.0
    if data is None:
        self.imv.clear()
        self.wcs = None
        return

    if header:
        with warnings.catch_warnings():
            warnings.simplefilter('ignore')
            self.wcs = WCS(header)

        itime = header.get('ITIME', 1.0)
        if itime == 0:
            itime = header.get('TRUITIME', 1.0)
        coadds = header.get('COADDS', 1.0)
        self._itime_coadds = itime * coadds
    else:
        self.wcs = None
        self._itime_coadds = 1.0

    if data.ndim == 2:
        self.transposed_data = data.T
        self.display_data = self.transposed_data
        self.apply_transforms()
        self.update_image_display()
        # A 2-D image reaches the screen without going through refresh_display, so this is
        # the only place overlays learn that the plane's geometry changed.
        self.display_changed.emit()
        self.lbl_zsize.setText("ZSize: 1")
        self.tab_advanced.setEnabled(True)
        self.combo_ext.setEnabled(True)
        self.combo_collapse.setEnabled(False)
        if hasattr(self, 'group_x'): self.group_x.setEnabled(False)
        if hasattr(self, 'group_y'): self.group_y.setEnabled(False)
        if hasattr(self, 'group_z'): self.group_z.setEnabled(False)
    elif data.ndim == 3:
        self.tab_advanced.setEnabled(True)
        self.combo_ext.setEnabled(True)
        self.combo_collapse.setEnabled(True)
        if hasattr(self, 'group_x'): self.group_x.setEnabled(True)
        if hasattr(self, 'group_y'): self.group_y.setEnabled(True)
        if hasattr(self, 'group_z'): self.group_z.setEnabled(True)

        # Check CTYPE1 to determine default axes
        # OSIRIS has WAVE as CTYPE1, but non-OSIRIS usually has RA---TAN
        # Default to AXIS 1 if RA is first, else OSIRIS-default AXIS 3
        is_non_osiris = False
        if header and 'CTYPE1' in header:
            if 'RA' in header['CTYPE1'].upper():
                is_non_osiris = True

        default_x = "AXIS 1" if is_non_osiris else "AXIS 3"

        self.combo_x.blockSignals(True)
        self.combo_x.setCurrentText(default_x)
        self.combo_x.blockSignals(False)

        self.combo_y.blockSignals(True)
        self.combo_y.setCurrentText("AXIS 2")
        self.combo_y.blockSignals(False)

        self.apply_axis_mapping()
    else:
        # Not a displayable image (a table's FITS_rec is 1-D, a 4-D cube is unsupported).
        # This used to fall through both branches and return silently, leaving the
        # previous extension's image on screen as if it were this one (B6).
        self.transposed_data = None
        self.display_data = None
        self.wcs = None
        self.imv.clear()
        self.lbl_zsize.setText("ZSize: N/A")
        self.lbl_slice_info.setText(f"Cannot display {data.ndim}-D data")
        self.combo_ext.setEnabled(True)
        self.combo_collapse.setEnabled(False)
        if hasattr(self, 'group_x'): self.group_x.setEnabled(False)
        if hasattr(self, 'group_y'): self.group_y.setEnabled(False)
        if hasattr(self, 'group_z'): self.group_z.setEnabled(False)
z_range_from_fields(write_back=False)

The clamped collapse range in the Z Min / Z Max boxes, or None if unparsable.

Source code in pyql3/gui/viewers/image_viewer.py
206
207
208
209
210
211
212
213
def z_range_from_fields(self, write_back=False):
    """The clamped collapse range in the Z Min / Z Max boxes, or None if unparsable."""
    try:
        zmin = int(self.txt_zmin.text())
        zmax = int(self.txt_zmax.text())
    except (ValueError, AttributeError):
        return None
    return self.clamp_z_range(zmin, zmax, write_back=write_back)

Depth Plot & Line Lists

pyql3.gui.tools.depth_plot

DepthPlotDialog

Bases: BaseToolDialog

Source code in pyql3/gui/tools/depth_plot.py
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
class DepthPlotDialog(BaseToolDialog):
    def __init__(self, parent=None, image_viewer=None, initial_center=None):
        super().__init__(parent, image_viewer, "Plot Window")
        # A Qt signal may hand us its `checked` flag instead of a centre
        initial_center = as_center(initial_center)
        self.resize(700, 800)

        # Top Controls
        top_layout = QHBoxLayout()
        self.setup_draw_button(top_layout)

        self.btn_export = QPushButton("Export...")
        self.btn_export.setToolTip("Export plot data (CSV, Image, SVG, Vector)")
        self.btn_export.clicked.connect(self.open_export_dialog)
        top_layout.addWidget(self.btn_export)

        top_layout.addWidget(QLabel("Type:"))

        self.combo_type = QComboBox()
        self.combo_type.addItems(["Depth Plot", "Horizontal Cut", "Vertical Cut"])
        self.combo_type.currentIndexChanged.connect(self.update_plot)
        top_layout.addWidget(self.combo_type)

        top_layout.addWidget(QLabel("calc using:"))
        self.combo_calc = QComboBox()
        self.combo_calc.addItems(["Average", "Median", "Total"])
        self.combo_calc.currentIndexChanged.connect(self.update_plot)
        top_layout.addWidget(self.combo_calc)

        top_layout.addWidget(QLabel("Shape:"))
        self.combo_shape = QComboBox()
        self.combo_shape.addItems(["Rectangle", "Circle"])
        self.combo_shape.currentIndexChanged.connect(self.toggle_roi_shape)
        top_layout.addWidget(self.combo_shape)

        top_layout.addStretch()
        self.layout.addLayout(top_layout)

        # Plot Widget
        self.top_axis = PixelIndexAxis(orientation='top')
        self.plot_widget = pg.PlotWidget(background='w', axisItems={'top': self.top_axis})

        self.plot_widget.setLabel('bottom', "Wavelength", units="µm")
        unit = "DN" if self.image_viewer and getattr(self.image_viewer, 'disp_as_dn', False) else "DN/s"
        self.plot_widget.setLabel('left', f"Intensity ({unit})")

        # Dual axis setup
        self.plot_widget.showAxis('top')
        self.plot_widget.getAxis('top').setPen('k')
        self.plot_widget.getAxis('top').setTextPen('k')
        self.plot_widget.getAxis('top').setLabel("Slice Index (pixels)")

        self.plot_widget.getAxis('bottom').setPen('k')
        self.plot_widget.getAxis('bottom').setTextPen('k')
        self.plot_widget.getAxis('left').setPen('k')
        self.plot_widget.getAxis('left').setTextPen('k')

        self.plot_widget.getAxis('right').setPen('k')
        self.plot_widget.showAxis('right')

        self.layout.addWidget(self.plot_widget, stretch=1)

        self.plot_legend = self.plot_widget.addLegend(offset=(10, 10))
        self.plot_data = self.plot_widget.plot([], [], pen=pg.mkPen('k', width=2.5), name="Source")
        self.plot_bg = self.plot_widget.plot([], [], pen=pg.mkPen((255, 140, 0), width=2.5, style=Qt.DashLine), name="Background")
        self.plot_sub = self.plot_widget.plot([], [], pen=pg.mkPen('r', width=2.5), name="Subtracted")

        # Crosshair / Hover Label
        self.lbl_cursor = QLabel("X: --  Y: --")
        self.layout.addWidget(self.lbl_cursor)

        # Proxy for mouse move
        self.proxy = pg.SignalProxy(self.plot_widget.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved)

        # Plot Axes GroupBox
        group_axes = QGroupBox("PLOT AXES")
        axes_layout = QGridLayout(group_axes)

        self.spin_x_min = QDoubleSpinBox(); self.spin_x_min.setRange(-1e9, 1e9); self.spin_x_min.setDecimals(4)
        self.spin_x_max = QDoubleSpinBox(); self.spin_x_max.setRange(-1e9, 1e9); self.spin_x_max.setDecimals(4)
        btn_set_x = QPushButton("SET")
        btn_auto_x = QPushButton("Auto")
        self.chk_fix_x = QCheckBox("Fix")
        self.chk_log_x = QCheckBox("Log")

        axes_layout.addWidget(QLabel("X Range:"), 0, 0)
        axes_layout.addWidget(self.spin_x_min, 0, 1)
        axes_layout.addWidget(QLabel("to"), 0, 2)
        axes_layout.addWidget(self.spin_x_max, 0, 3)
        axes_layout.addWidget(btn_set_x, 0, 4)
        axes_layout.addWidget(btn_auto_x, 0, 5)
        axes_layout.addWidget(self.chk_fix_x, 0, 6)
        axes_layout.addWidget(self.chk_log_x, 0, 7)

        self.spin_y_min = QDoubleSpinBox(); self.spin_y_min.setRange(-1e9, 1e9); self.spin_y_min.setDecimals(4)
        self.spin_y_max = QDoubleSpinBox(); self.spin_y_max.setRange(-1e9, 1e9); self.spin_y_max.setDecimals(4)
        btn_set_y = QPushButton("SET")
        btn_auto_y = QPushButton("Auto")
        self.chk_fix_y = QCheckBox("Fix")
        self.chk_log_y = QCheckBox("Log")

        axes_layout.addWidget(QLabel("Y Range:"), 1, 0)
        axes_layout.addWidget(self.spin_y_min, 1, 1)
        axes_layout.addWidget(QLabel("to"), 1, 2)
        axes_layout.addWidget(self.spin_y_max, 1, 3)
        axes_layout.addWidget(btn_set_y, 1, 4)
        axes_layout.addWidget(btn_auto_y, 1, 5)
        axes_layout.addWidget(self.chk_fix_y, 1, 6)
        axes_layout.addWidget(self.chk_log_y, 1, 7)

        self.layout.addWidget(group_axes)

        btn_set_x.clicked.connect(self.apply_x_range)
        btn_auto_x.clicked.connect(self.auto_x_range)
        self.chk_fix_x.stateChanged.connect(self.toggle_fix_x)
        self.chk_log_x.stateChanged.connect(self.toggle_log_scale)

        btn_set_y.clicked.connect(self.apply_y_range)
        btn_auto_y.clicked.connect(self.auto_y_range)
        self.chk_fix_y.stateChanged.connect(self.toggle_fix_y)
        self.chk_log_y.stateChanged.connect(self.toggle_log_scale)

        # Region Controls GroupBox
        group_region = QGroupBox("INPUT DATA FROM CUBE")
        region_layout = QGridLayout(group_region)

        self.spin_x0 = QSpinBox(); self.spin_x0.setRange(0, 10000)
        self.spin_x1 = QSpinBox(); self.spin_x1.setRange(0, 10000)
        self.spin_y0 = QSpinBox(); self.spin_y0.setRange(0, 10000)
        self.spin_y1 = QSpinBox(); self.spin_y1.setRange(0, 10000)

        for spin in [self.spin_x0, self.spin_x1, self.spin_y0, self.spin_y1]:
            spin.valueChanged.connect(self.on_spin_changed)

        region_layout.addWidget(QLabel("X Region:"), 0, 0)
        region_layout.addWidget(self.spin_x0, 0, 1)
        region_layout.addWidget(QLabel("to"), 0, 2)
        region_layout.addWidget(self.spin_x1, 0, 3)

        region_layout.addWidget(QLabel("Y Region:"), 1, 0)
        region_layout.addWidget(self.spin_y0, 1, 1)
        region_layout.addWidget(QLabel("to"), 1, 2)
        region_layout.addWidget(self.spin_y1, 1, 3)

        # Background Region GroupBox
        self.group_bg = QGroupBox("BACKGROUND REGION")
        bg_layout = QGridLayout(self.group_bg)

        self.chk_enable_bg = QCheckBox("Enable Background Subtraction")
        self.combo_bg_calc = QComboBox()
        self.combo_bg_calc.addItems(["Median", "Average", "Total"])
        self.combo_bg_calc.setCurrentText("Median")
        self.combo_bg_calc.setEnabled(False)

        bg_layout.addWidget(self.chk_enable_bg, 0, 0, 1, 2)
        bg_layout.addWidget(QLabel("Calc using:"), 0, 2)
        bg_layout.addWidget(self.combo_bg_calc, 0, 3)

        self.spin_bg_x0 = QSpinBox(); self.spin_bg_x0.setRange(0, 10000); self.spin_bg_x0.setEnabled(False)
        self.spin_bg_x1 = QSpinBox(); self.spin_bg_x1.setRange(0, 10000); self.spin_bg_x1.setEnabled(False)
        self.spin_bg_y0 = QSpinBox(); self.spin_bg_y0.setRange(0, 10000); self.spin_bg_y0.setEnabled(False)
        self.spin_bg_y1 = QSpinBox(); self.spin_bg_y1.setRange(0, 10000); self.spin_bg_y1.setEnabled(False)

        self._updating_bg_spins = False
        for spin in [self.spin_bg_x0, self.spin_bg_x1, self.spin_bg_y0, self.spin_bg_y1]:
            spin.valueChanged.connect(self.on_bg_spin_changed)

        bg_layout.addWidget(QLabel("X Region:"), 1, 0)
        bg_layout.addWidget(self.spin_bg_x0, 1, 1)
        bg_layout.addWidget(QLabel("to"), 1, 2)
        bg_layout.addWidget(self.spin_bg_x1, 1, 3)

        bg_layout.addWidget(QLabel("Y Region:"), 2, 0)
        bg_layout.addWidget(self.spin_bg_y0, 2, 1)
        bg_layout.addWidget(QLabel("to"), 2, 2)
        bg_layout.addWidget(self.spin_bg_y1, 2, 3)

        # Add Cube Input Data and Background Region side-by-side
        regions_row_layout = QHBoxLayout()
        regions_row_layout.setContentsMargins(0, 0, 0, 0)
        regions_row_layout.setSpacing(6)
        regions_row_layout.addWidget(group_region)
        regions_row_layout.addWidget(self.group_bg)

        self.layout.addLayout(regions_row_layout)

        # Spectral Line List GroupBox in its own row
        self.group_linelist = QGroupBox("SPECTRAL LINE LIST")
        linelist_layout = QGridLayout(self.group_linelist)

        self.chk_enable_lines = QCheckBox("Overplot Line List")
        self.combo_linelist = QComboBox()
        self.btn_browse_linelist = QPushButton("Browse...")
        self.lbl_line_info = QLabel("")

        linelist_layout.addWidget(self.chk_enable_lines, 0, 0)
        linelist_layout.addWidget(QLabel("Line List:"), 0, 1)
        linelist_layout.addWidget(self.combo_linelist, 0, 2)
        linelist_layout.addWidget(self.btn_browse_linelist, 0, 3)
        linelist_layout.addWidget(self.lbl_line_info, 0, 4)

        self.layout.addWidget(self.group_linelist)

        self.line_items = []
        self.loaded_lines = []
        self.linelist_files = {}

        self.chk_enable_lines.stateChanged.connect(self.update_line_overlays)
        self.combo_linelist.currentIndexChanged.connect(self.on_linelist_selection_changed)
        self.btn_browse_linelist.clicked.connect(self.browse_custom_linelist)

        self._updating_spins = False
        self._updating_range_spins = False

        self.populate_linelists()

        # Setup signals for view range changed to update spinboxes
        self.plot_widget.getViewBox().sigXRangeChanged.connect(self.on_x_range_changed)
        self.plot_widget.getViewBox().sigYRangeChanged.connect(self.on_y_range_changed)

        if initial_center is not None:
            center_x, center_y = initial_center
        elif self.image_viewer and self.image_viewer.display_data is not None:
            shape = self.image_viewer.display_data.shape
            if len(shape) == 3:
                center_x, center_y = shape[1]//2, shape[2]//2
            else:
                center_x, center_y = shape[0]//2, shape[1]//2
        else:
            center_x, center_y = 2, 2

        # Must exist before the first update_plot(), which reads it
        self.bg_roi = None

        roi = pg.RectROI([center_x - 2, center_y - 2], [4, 4], pen=pg.mkPen((0, 255, 0), width=3), hoverPen=pg.mkPen((0, 255, 0), width=5))
        roi.addScaleHandle([1, 1], [0, 0])
        roi.addScaleHandle([0, 0], [1, 1])
        self.add_roi_to_viewer(roi)
        self.on_roi_changed()

        # Background-subtraction wiring belongs here, not in set_center(): the
        # dialog can be opened without an initial center (Plot -> Depth Plot),
        # and set_center() may be called repeatedly.
        self.chk_enable_bg.stateChanged.connect(self.toggle_background)
        self.combo_bg_calc.currentIndexChanged.connect(self.update_plot)

        self.update_plot()

    def set_center(self, center):
        center = as_center(center)
        if center is None or self.roi is None:
            return
        cx, cy = center
        w = self.roi.size().x()
        h = self.roi.size().y()
        self.roi.setPos([cx - w / 2.0, cy - h / 2.0])
        self.on_roi_changed()

    def open_export_dialog(self):
        """Open PyQtGraph native export dialog."""
        try:
            from pyqtgraph.GraphicsScene.exportDialog import ExportDialog
            scene = self.plot_widget.scene()
            scene.contextMenuItem = self.plot_widget.plotItem
            if getattr(scene, 'exportDialog', None) is None:
                scene.exportDialog = ExportDialog(scene)
            scene.exportDialog.show(self.plot_widget.plotItem)
        except Exception as e:
            print(f"Error opening export dialog: {e}")

    def mouse_moved(self, evt):
        pos = evt[0]
        if self.plot_widget.sceneBoundingRect().contains(pos):
            mousePoint = self.plot_widget.plotItem.vb.mapSceneToView(pos)
            x_val = mousePoint.x()
            y_val = mousePoint.y()
            if hasattr(self, 'current_wavelengths') and self.current_wavelengths is not None and len(self.current_wavelengths) > 0:
                pix_idx = int(round(np.interp(x_val, self.current_wavelengths, np.arange(len(self.current_wavelengths)))))
                unit_str = getattr(self, 'current_wavelength_unit', 'µm')
                self.lbl_cursor.setText(f"Wavelength: {x_val:.4f} {unit_str}  (Pixel: {pix_idx})   Intensity: {y_val:.4f}")
            else:
                self.lbl_cursor.setText(f"Pixel: {x_val:.1f}   Intensity: {y_val:.4f}")

    def apply_x_range(self):
        self.plot_widget.setXRange(self.spin_x_min.value(), self.spin_x_max.value(), padding=0)
        self.chk_fix_x.setChecked(True)

    def apply_y_range(self):
        self.plot_widget.setYRange(self.spin_y_min.value(), self.spin_y_max.value(), padding=0)
        self.chk_fix_y.setChecked(True)

    def auto_x_range(self):
        self.chk_fix_x.setChecked(False)
        self.plot_widget.enableAutoRange(axis=pg.ViewBox.XAxis)

    def auto_y_range(self):
        self.chk_fix_y.setChecked(False)
        self.plot_widget.enableAutoRange(axis=pg.ViewBox.YAxis)
        self.plot_widget.getViewBox().autoRange()
        self.update_line_overlays()

    def toggle_fix_x(self):
        if self.chk_fix_x.isChecked():
            self.plot_widget.disableAutoRange(axis=pg.ViewBox.XAxis)
        else:
            self.plot_widget.enableAutoRange(axis=pg.ViewBox.XAxis)

    def toggle_fix_y(self):
        if self.chk_fix_y.isChecked():
            self.plot_widget.disableAutoRange(axis=pg.ViewBox.YAxis)
        else:
            self.plot_widget.enableAutoRange(axis=pg.ViewBox.YAxis)

    def on_x_range_changed(self, _, range_val):
        if not self._updating_range_spins:
            self._updating_range_spins = True
            self.spin_x_min.setValue(range_val[0])
            self.spin_x_max.setValue(range_val[1])
            self._updating_range_spins = False
            self.update_line_overlays()

    def on_y_range_changed(self, _, range_val):
        if not self._updating_range_spins:
            self._updating_range_spins = True
            self.spin_y_min.setValue(range_val[0])
            self.spin_y_max.setValue(range_val[1])
            self._updating_range_spins = False
            self.update_line_overlays()

    def toggle_log_scale(self):
        self.plot_widget.setLogMode(x=self.chk_log_x.isChecked(), y=self.chk_log_y.isChecked())

    def on_spin_changed(self):
        if self._updating_spins:
            return
        x0 = self.spin_x0.value()
        x1 = self.spin_x1.value()
        y0 = self.spin_y0.value()
        y1 = self.spin_y1.value()

        w = max(1, x1 - x0)
        h = max(1, y1 - y0)

        self.roi.blockSignals(True)
        self.roi.setPos([x0, y0])
        self.roi.setSize([w, h])
        self.roi.blockSignals(False)
        self.update_plot()

    def on_roi_changed(self):
        pos = self.roi.pos()
        size = self.roi.size()

        x0, y0 = int(pos.x()), int(pos.y())
        w, h = int(size.x()), int(size.y())

        self._updating_spins = True
        self.spin_x0.setValue(x0)
        self.spin_x1.setValue(x0 + w)
        self.spin_y0.setValue(y0)
        self.spin_y1.setValue(y0 + h)
        self._updating_spins = False

        self.update_plot()

    def add_bg_roi(self):
        if self.bg_roi is not None:
            self.remove_bg_roi()

        if self.image_viewer is None or getattr(self.image_viewer, 'imv', None) is None:
            return

        shape = self.combo_shape.currentText()
        pos = self.roi.pos() if self.roi else [0, 0]
        size = self.roi.size() if self.roi else [4, 4]

        # Offset background ROI by width + 2 pixels
        bg_pos = [pos.x() + size.x() + 2, pos.y()]

        pen = pg.mkPen((255, 140, 0), width=3)
        hover_pen = pg.mkPen((255, 140, 0), width=5)

        if shape == "Circle":
            self.bg_roi = pg.CircleROI(bg_pos, size, pen=pen, hoverPen=hover_pen)
        else:
            self.bg_roi = pg.RectROI(bg_pos, size, pen=pen, hoverPen=hover_pen)
            self.bg_roi.addScaleHandle([1, 1], [0, 0])
            self.bg_roi.addScaleHandle([0, 0], [1, 1])

        img_item = self.image_viewer.imv.getImageItem()
        if img_item:
            self.bg_roi.setParentItem(img_item)
        else:
            self.image_viewer.imv.getView().addItem(self.bg_roi)
        self.bg_roi.sigRegionChanged.connect(self.on_bg_roi_changed)
        self.on_bg_roi_changed()

    def remove_bg_roi(self):
        if self.bg_roi is not None and self.image_viewer is not None:
            import warnings
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                try:
                    self.bg_roi.sigRegionChanged.disconnect(self.on_bg_roi_changed)
                except Exception:
                    pass
            try:
                self.bg_roi.setParentItem(None)
            except Exception:
                pass
            try:
                self.image_viewer.imv.getView().removeItem(self.bg_roi)
            except Exception:
                pass
            self.bg_roi = None

    def toggle_background(self, state=None):
        checked = self.chk_enable_bg.isChecked()
        if checked:
            if self.bg_roi is None:
                self.add_bg_roi()
            self.spin_bg_x0.setEnabled(True)
            self.spin_bg_x1.setEnabled(True)
            self.spin_bg_y0.setEnabled(True)
            self.spin_bg_y1.setEnabled(True)
            self.combo_bg_calc.setEnabled(True)
        else:
            self.remove_bg_roi()
            self.spin_bg_x0.setEnabled(False)
            self.spin_bg_x1.setEnabled(False)
            self.spin_bg_y0.setEnabled(False)
            self.spin_bg_y1.setEnabled(False)
            self.combo_bg_calc.setEnabled(False)
        self.update_plot()

    def on_bg_roi_changed(self):
        if self.bg_roi is None:
            return
        pos = self.bg_roi.pos()
        size = self.bg_roi.size()

        x0, y0 = int(pos.x()), int(pos.y())
        w, h = int(size.x()), int(size.y())

        self._updating_bg_spins = True
        self.spin_bg_x0.setValue(x0)
        self.spin_bg_x1.setValue(x0 + w)
        self.spin_bg_y0.setValue(y0)
        self.spin_bg_y1.setValue(y0 + h)
        self._updating_bg_spins = False

        self.update_plot()

    def on_bg_spin_changed(self):
        if getattr(self, '_updating_bg_spins', False) or self.bg_roi is None:
            return
        x0 = self.spin_bg_x0.value()
        x1 = self.spin_bg_x1.value()
        y0 = self.spin_bg_y0.value()
        y1 = self.spin_bg_y1.value()

        w = max(1, x1 - x0)
        h = max(1, y1 - y0)

        self.bg_roi.blockSignals(True)
        self.bg_roi.setPos([x0, y0])
        self.bg_roi.setSize([w, h])
        self.bg_roi.blockSignals(False)
        self.update_plot()

    def closeEvent(self, event):
        self.clear_line_overlays()
        self.remove_bg_roi()
        super().closeEvent(event)

    def get_data_dir(self):
        import sys
        import pyql3

        candidates = []
        if hasattr(sys, '_MEIPASS'):
            candidates.append(pathlib.Path(sys._MEIPASS) / "pyql3" / "data")
            candidates.append(pathlib.Path(sys._MEIPASS) / "data")

        try:
            from pyql3 import get_resource_path
            candidates.append(pathlib.Path(get_resource_path("pyql3/data")))
        except Exception:
            pass

        pyql3_dir = pathlib.Path(pyql3.__file__).resolve().parent
        candidates.append(pyql3_dir / "data")

        cur_dir = pathlib.Path(__file__).resolve().parent
        candidates.append(cur_dir.parents[1] / "data")
        candidates.append(cur_dir.parents[2] / "data")

        for cand in candidates:
            if cand.exists() and cand.is_dir():
                return cand

        return pyql3_dir / "data"

    def populate_linelists(self):
        self.combo_linelist.blockSignals(True)
        self.combo_linelist.clear()
        self.linelist_files.clear()

        data_dir = self.get_data_dir()
        if data_dir.exists():
            for p in sorted(data_dir.glob("*")):
                if p.suffix.lower() in [".txt", ".csv"]:
                    display_name = p.name
                    self.linelist_files[display_name] = str(p)
                    self.combo_linelist.addItem(display_name)

        self.combo_linelist.addItem("Load Custom CSV...")

        if "nir_stellar_lines.txt" in self.linelist_files:
            self.combo_linelist.setCurrentText("nir_stellar_lines.txt")
            self.loaded_lines = self.parse_line_list(self.linelist_files["nir_stellar_lines.txt"])
        elif "rayner_arcturus_atomic_line_list_reformat.txt" in self.linelist_files:
            self.combo_linelist.setCurrentText("rayner_arcturus_atomic_line_list_reformat.txt")
            self.loaded_lines = self.parse_line_list(self.linelist_files["rayner_arcturus_atomic_line_list_reformat.txt"])
        elif self.linelist_files:
            first_name = list(self.linelist_files.keys())[0]
            self.combo_linelist.setCurrentText(first_name)
            self.loaded_lines = self.parse_line_list(self.linelist_files[first_name])
        else:
            self.combo_linelist.setCurrentText("Load Custom CSV...")
            self.loaded_lines = []

        self.combo_linelist.blockSignals(False)

    def parse_line_list(self, filepath):
        lines = []
        if not filepath or not os.path.exists(filepath):
            return lines
        try:
            with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#') or line.startswith(';'):
                        continue
                    parts = line.split(',')
                    if len(parts) >= 2:
                        try:
                            wl = float(parts[0].strip())
                            name = parts[1].strip()
                            lines.append((wl, name))
                        except ValueError:
                            continue
        except Exception as e:
            print(f"Error parsing line list {filepath}: {e}")
        return lines

    def on_linelist_selection_changed(self):
        text = self.combo_linelist.currentText()
        if text in self.linelist_files:
            filepath = self.linelist_files[text]
            self.loaded_lines = self.parse_line_list(filepath)
            self.update_line_overlays()

    def browse_custom_linelist(self):
        data_dir = self.get_data_dir()
        if data_dir and data_dir.exists():
            initial_dir = str(data_dir)
        else:
            initial_dir = QDir.homePath()

        filepath, _ = QFileDialog.getOpenFileName(
            self, "Select Spectral Line List CSV", initial_dir, "CSV / Text Files (*.csv *.txt);;All Files (*)"
        )
        if filepath:
            name = os.path.basename(filepath)
            self.linelist_files[name] = filepath
            idx = self.combo_linelist.findText("Load Custom CSV...")
            if idx >= 0:
                self.combo_linelist.insertItem(idx, name)
                self.combo_linelist.setCurrentIndex(idx)
            else:
                self.combo_linelist.addItem(name)
                self.combo_linelist.setCurrentText(name)
            self.loaded_lines = self.parse_line_list(filepath)
            self.update_line_overlays()
        else:
            if self.combo_linelist.currentText() == "Load Custom CSV..." and self.combo_linelist.count() > 1:
                self.combo_linelist.setCurrentIndex(0)

    def wavelength_to_pixel(self, wavelengths_um):
        if self.image_viewer is None or getattr(self.image_viewer, 'wcs', None) is None:
            return None
        if getattr(self.image_viewer, 'wcs_z_idx', None) is None:
            return None

        wcs = self.image_viewer.wcs
        z_idx = self.image_viewer.wcs_z_idx

        try:
            cunit = str(wcs.wcs.cunit[z_idx]).strip().lower()
        except Exception:
            cunit = "m"

        if cunit == 'm':
            scale = 1e-6
        elif cunit in ['um', 'micron', 'microns', 'µm']:
            scale = 1.0
        elif cunit == 'nm':
            scale = 1e3
        elif cunit in ['angstrom', 'a', 'angstroms']:
            scale = 1e4
        else:
            scale = 1e-6

        wls_wcs = np.array(wavelengths_um) * scale
        n_lines = len(wls_wcs)
        coords_world = np.zeros((n_lines, wcs.naxis))

        if hasattr(self, 'world_axis') and self.world_axis.fixed_coords is not None:
            for i in range(wcs.naxis):
                if i == z_idx:
                    coords_world[:, i] = wls_wcs
                else:
                    coords_world[:, i] = self.world_axis.fixed_coords[i]
        else:
            ref_pix = np.zeros((1, wcs.naxis))
            ref_world = wcs.wcs_pix2world(ref_pix, 0)[0]
            for i in range(wcs.naxis):
                if i == z_idx:
                    coords_world[:, i] = wls_wcs
                else:
                    coords_world[:, i] = ref_world[i]

        try:
            pix_coords = wcs.wcs_world2pix(coords_world, 0)[:, z_idx]
            return pix_coords
        except Exception:
            return None

    def clear_line_overlays(self):
        for item in self.line_items:
            try:
                if isinstance(item, tuple):
                    line_item, text_item = item
                    self.plot_widget.removeItem(line_item)
                    self.plot_widget.removeItem(text_item)
                else:
                    self.plot_widget.removeItem(item)
            except Exception:
                pass
        self.line_items.clear()

    def update_line_overlays(self):
        if not hasattr(self, 'chk_enable_lines'):
            return

        plot_type = self.combo_type.currentText()
        lines_enabled = self.chk_enable_lines.isChecked() and (plot_type == "Depth Plot")

        if not lines_enabled or not self.loaded_lines or self.image_viewer is None or getattr(self.image_viewer, 'wcs', None) is None:
            self.clear_line_overlays()
            if hasattr(self, 'lbl_line_info'):
                if not self.chk_enable_lines.isChecked():
                    self.lbl_line_info.setText("")
                elif plot_type != "Depth Plot":
                    self.lbl_line_info.setText("Line list only available in Depth Plot mode.")
                elif getattr(self.image_viewer, 'wcs', None) is None:
                    self.lbl_line_info.setText("No WCS present for wavelength mapping.")
            return

        z_idx = getattr(self.image_viewer, 'wcs_z_idx', None)
        if z_idx is not None:
            ctype_raw = str(self.image_viewer.wcs.wcs.ctype[z_idx]).upper()
            if 'WAVE' not in ctype_raw and 'AWAV' not in ctype_raw:
                self.clear_line_overlays()
                self.lbl_line_info.setText("Z-axis is not Wavelength.")
                return

        use_wavelength_x = hasattr(self, 'current_wavelengths') and self.current_wavelengths is not None and len(self.current_wavelengths) > 0

        view_box = self.plot_widget.getViewBox()
        (view_x_min, view_x_max), (view_y_min, view_y_max) = view_box.viewRange()

        if view_x_min == 0.0 and view_x_max == 1.0 and hasattr(self, 'plot_data'):
            x_data, _ = self.plot_data.getData()
            if x_data is not None and len(x_data) > 1:
                view_x_min, view_x_max = float(x_data[0]), float(x_data[-1])

        visible_lines = []
        if use_wavelength_x:
            for wl_um, name in self.loaded_lines:
                if view_x_min <= wl_um <= view_x_max:
                    visible_lines.append((wl_um, name, wl_um))
        else:
            wls_um = [item[0] for item in self.loaded_lines]
            pix_coords = self.wavelength_to_pixel(wls_um)
            if pix_coords is None:
                self.clear_line_overlays()
                self.lbl_line_info.setText("WCS conversion failed.")
                return
            # strict=: pix_coords is derived one-for-one from loaded_lines, so a
            # length mismatch is a bug, not something to silently truncate.
            for (wl_um, name), x_px in zip(self.loaded_lines, pix_coords, strict=True):
                if view_x_min <= x_px <= view_x_max:
                    visible_lines.append((x_px, name, wl_um))

        num_needed = len(visible_lines)

        while len(self.line_items) > num_needed:
            item = self.line_items.pop()
            try:
                if isinstance(item, tuple):
                    line_item, text_item = item
                    self.plot_widget.removeItem(line_item)
                    self.plot_widget.removeItem(text_item)
                else:
                    self.plot_widget.removeItem(item)
            except Exception:
                pass

        pen = pg.mkPen(color=(0, 100, 220), style=Qt.PenStyle.DotLine, width=1.5)
        stagger_levels = [0.08, 0.22, 0.36, 0.50]
        last_x = -9999.0
        level = 0
        y_span = view_y_max - view_y_min
        min_spacing = 0.005 * (view_x_max - view_x_min) if use_wavelength_x else 18.0

        for idx, (x_pos, name, _wl_um) in enumerate(visible_lines):
            if abs(x_pos - last_x) < min_spacing:
                level = (level + 1) % len(stagger_levels)
            else:
                level = 0
            last_x = x_pos

            pos_val = stagger_levels[level]
            y_pos = view_y_min + pos_val * y_span

            html_content = latex_to_html(name)
            html_text = f'<span style="color: rgb(0, 70, 180); font-size: 12pt; font-weight: bold;">{html_content}</span>'

            if idx < len(self.line_items):
                line_item, text_item = self.line_items[idx]
                line_item.setPos(x_pos)
                line_item.setVisible(True)

                text_item.setHtml(html_text)
                text_item.setAngle(90)
                text_item.setPos(x_pos, y_pos)
                text_item.setVisible(True)
            else:
                line_item = pg.InfiniteLine(pos=x_pos, angle=90, pen=pen)
                text_item = pg.TextItem(html=html_text, anchor=(0.0, 0.5))
                line_item.dataBounds = lambda ax, *args, **kwargs: (None, None)
                text_item.dataBounds = lambda ax, *args, **kwargs: (None, None)
                text_item.setAngle(90)
                text_item.setPos(x_pos, y_pos)

                self.plot_widget.addItem(line_item)
                self.plot_widget.addItem(text_item)
                self.line_items.append((line_item, text_item))

        self.lbl_line_info.setText(f"{len(visible_lines)} line(s) visible (out of {len(self.loaded_lines)} total)")

    def toggle_roi_shape(self):
        shape = self.combo_shape.currentText()
        pos = self.roi.pos()
        size = self.roi.size()

        self.remove_roi_from_viewer()

        if shape == "Circle":
            roi = pg.CircleROI(pos, size, pen=pg.mkPen((0, 255, 0), width=3), hoverPen=pg.mkPen((0, 255, 0), width=5))
        else:
            roi = pg.RectROI(pos, size, pen=pg.mkPen((0, 255, 0), width=3), hoverPen=pg.mkPen((0, 255, 0), width=5))
            roi.addScaleHandle([1, 1], [0, 0])
            roi.addScaleHandle([0, 0], [1, 1])

        self.add_roi_to_viewer(roi)

        if self.chk_enable_bg.isChecked() and self.bg_roi is not None:
            bg_pos = self.bg_roi.pos()
            bg_size = self.bg_roi.size()
            self.remove_bg_roi()
            pen = pg.mkPen((255, 140, 0), width=3)
            hover_pen = pg.mkPen((255, 140, 0), width=5)
            if shape == "Circle":
                self.bg_roi = pg.CircleROI(bg_pos, bg_size, pen=pen, hoverPen=hover_pen)
            else:
                self.bg_roi = pg.RectROI(bg_pos, bg_size, pen=pen, hoverPen=hover_pen)
                self.bg_roi.addScaleHandle([1, 1], [0, 0])
                self.bg_roi.addScaleHandle([0, 0], [1, 1])
            img_item = self.image_viewer.imv.getImageItem()
            if img_item:
                self.bg_roi.setParentItem(img_item)
            else:
                self.image_viewer.imv.getView().addItem(self.bg_roi)
            self.bg_roi.sigRegionChanged.connect(self.on_bg_roi_changed)

        self.update_plot()

    def update_plot(self):
        if self.image_viewer is None or self.image_viewer.transposed_data is None:
            return

        if self.image_viewer.transposed_data.ndim != 3:
            return

        plot_type = self.combo_type.currentText()
        calc_method = self.combo_calc.currentText()

        if hasattr(self, 'group_bg'):
            self.group_bg.setEnabled(plot_type == "Depth Plot")
        if hasattr(self, 'group_linelist'):
            self.group_linelist.setEnabled(plot_type == "Depth Plot")

        # Transform the 3D cube to match the display coordinates (rotation, flip)
        cube = self.image_viewer.apply_spatial_transforms(self.image_viewer.transposed_data)

        pos = self.roi.pos()
        size = self.roi.size()

        x0, y0 = int(pos.x()), int(pos.y())
        w, h = int(size.x()), int(size.y())

        shape = cube.shape
        z_len, x_len, y_len = shape

        x0 = max(0, min(x0, x_len-1))
        y0 = max(0, min(y0, y_len-1))
        x1 = max(x0+1, min(x0+w, x_len))
        y1 = max(y0+1, min(y0+h, y_len))

        if plot_type == "Depth Plot":
            region = cube[:, x0:x1, y0:y1].astype(float, copy=True)
            if region.size == 0:
                return

            # If circle, apply mask
            if self.combo_shape.currentText() == "Circle":
                yy, xx = np.mgrid[:(x1-x0), :(y1-y0)]
                cx, cy = (x1-x0)/2.0 - 0.5, (y1-y0)/2.0 - 0.5
                r = min((x1-x0)/2.0, (y1-y0)/2.0)
                mask = ((xx - cy)**2 + (yy - cx)**2) <= r**2
                region = np.where(mask, region, np.nan)

            if calc_method == "Average":
                spectrum = np.nanmean(region, axis=(1, 2))
            elif calc_method == "Median":
                spectrum = np.nanmedian(region, axis=(1, 2))
            else:
                spectrum = np.nansum(region, axis=(1, 2))

            bg_spectrum = None
            subtracted_spectrum = None

            if self.chk_enable_bg.isChecked() and self.bg_roi is not None:
                bg_pos = self.bg_roi.pos()
                bg_size = self.bg_roi.size()
                bg_x0, bg_y0 = int(bg_pos.x()), int(bg_pos.y())
                bg_w, bg_h = int(bg_size.x()), int(bg_size.y())

                bg_x0 = max(0, min(bg_x0, x_len-1))
                bg_y0 = max(0, min(bg_y0, y_len-1))
                bg_x1 = max(bg_x0+1, min(bg_x0+bg_w, x_len))
                bg_y1 = max(bg_y0+1, min(bg_y0+bg_h, y_len))

                bg_region = cube[:, bg_x0:bg_x1, bg_y0:bg_y1].astype(float, copy=True)
                if bg_region.size > 0:
                    if self.combo_shape.currentText() == "Circle":
                        yy_bg, xx_bg = np.mgrid[:(bg_x1-bg_x0), :(bg_y1-bg_y0)]
                        cx_bg, cy_bg = (bg_x1-bg_x0)/2.0 - 0.5, (bg_y1-bg_y0)/2.0 - 0.5
                        r_bg = min((bg_x1-bg_x0)/2.0, (bg_y1-bg_y0)/2.0)
                        mask_bg = ((xx_bg - cy_bg)**2 + (yy_bg - cx_bg)**2) <= r_bg**2
                        bg_region = np.where(mask_bg, bg_region, np.nan)

                    bg_calc_method = self.combo_bg_calc.currentText()
                    if bg_calc_method == "Average":
                        bg_spectrum = np.nanmean(bg_region, axis=(1, 2))
                    elif bg_calc_method == "Median":
                        bg_spectrum = np.nanmedian(bg_region, axis=(1, 2))
                    else:
                        bg_spectrum = np.nansum(bg_region, axis=(1, 2))

                    # Option B: Subtract background spectrum from each pixel's spectrum in the source data
                    subtracted_region = region - bg_spectrum[:, None, None]

                    if calc_method == "Average":
                        subtracted_spectrum = np.nanmean(subtracted_region, axis=(1, 2))
                    elif calc_method == "Median":
                        subtracted_spectrum = np.nanmedian(subtracted_region, axis=(1, 2))
                    else:
                        subtracted_spectrum = np.nansum(subtracted_region, axis=(1, 2))

            x_axis = np.arange(z_len)
            wavelengths = None
            cunit = ""
            ctype = ""

            if self.image_viewer.wcs is not None and self.image_viewer.wcs_z_idx is not None:
                wcs = self.image_viewer.wcs
                z_idx = self.image_viewer.wcs_z_idx
                ctype_raw = str(wcs.wcs.ctype[z_idx]).upper()
                ctype = ctype_raw.split('-')[0] if '-' in ctype_raw else ctype_raw

                try:
                    cunit = str(wcs.wcs.cunit[z_idx]).strip()
                    if cunit.lower() == 'm':
                        cunit = 'µm'
                except Exception:
                    cunit = "µm"

                cx, cy = x0 + w/2.0, y0 + h/2.0

                # Un-flip and un-rotate to get coords in transposed_data space. This used to
                # be inlined here with one axis length used for both axes, which put the
                # lookup at the wrong pixel for a rotated non-square plane (BUGS.md B13).
                cx, cy = self.image_viewer.display_to_orig(cx, cy)

                x_idx, y_idx = self.image_viewer.display_axis_indices()

                fixed_coords = np.zeros((z_len, wcs.naxis))
                if wcs.naxis > max(x_idx, y_idx):
                    fixed_coords[:, x_idx] = cx
                    fixed_coords[:, y_idx] = cy
                fixed_coords[:, z_idx] = np.arange(z_len)

                try:
                    world = wcs.wcs_pix2world(fixed_coords, 0)
                    wavelengths = world[:, z_idx]
                    try:
                        orig_cunit = str(wcs.wcs.cunit[z_idx]).strip().lower()
                        if orig_cunit == 'm':
                            wavelengths = wavelengths * 1e6
                    except Exception:
                        pass
                except Exception as e:
                    print(f"Warning: WCS pixel_to_world failed in DepthPlotDialog: {e}")
                    wavelengths = None

            if wavelengths is not None and len(wavelengths) == z_len:
                x_axis = wavelengths
                self.current_wavelengths = wavelengths
                self.current_wavelength_unit = cunit

                label = "Wavelength" if 'WAVE' in ctype else ctype
                unit_str = f" ({cunit})" if cunit else ""
                self.plot_widget.getAxis('bottom').setLabel(f"{label}{unit_str}")

                self.top_axis.wavelengths = wavelengths
                self.plot_widget.showAxis('top')
                self.plot_widget.getAxis('top').setLabel("Slice Index (pixels)")
            else:
                x_axis = np.arange(z_len)
                self.current_wavelengths = None
                self.current_wavelength_unit = ""
                self.plot_widget.setLabel('bottom', "Slice Index (pixels)")
                self.top_axis.wavelengths = None
                self.plot_widget.hideAxis('top')

            mult = self.image_viewer.data_multiplier
            self.plot_data.setData(x_axis, spectrum * mult)

            if bg_spectrum is not None and subtracted_spectrum is not None:
                self.plot_bg.setData(x_axis, bg_spectrum * mult)
                self.plot_sub.setData(x_axis, subtracted_spectrum * mult)
            else:
                self.plot_bg.setData([], [])
                self.plot_sub.setData([], [])

        elif plot_type == "Horizontal Cut":
            # Cut the plane that is actually on screen: in Boxcar or Z Range mode that is a
            # collapsed plane which exists in no single channel of the cube (B17).
            plane = self.image_viewer.current_plane()
            if plane is None or plane.ndim != 2:
                return
            region = plane[x0:x1, y0:y1]
            if region.size == 0:
                return
            if calc_method == "Average":
                cut = np.nanmean(region, axis=1) # collapse Y
            elif calc_method == "Median":
                cut = np.nanmedian(region, axis=1)
            else:
                cut = np.nansum(region, axis=1)

            self.top_axis.wavelengths = None
            self.plot_widget.hideAxis('top')
            self.current_wavelengths = None
            self.current_wavelength_unit = ""

            x_axis = np.arange(x0, x1)
            self.plot_widget.setLabel('bottom', "X Pixel")
            unit = "DN" if self.image_viewer and getattr(self.image_viewer, 'disp_as_dn', False) else "DN/s"
            self.plot_widget.setLabel('left', f"Intensity ({unit})")
            self.plot_data.setData(x_axis, cut * self.image_viewer.data_multiplier)
            self.plot_bg.setData([], [])
            self.plot_sub.setData([], [])

        elif plot_type == "Vertical Cut":
            plane = self.image_viewer.current_plane()
            if plane is None or plane.ndim != 2:
                return
            region = plane[x0:x1, y0:y1]
            if region.size == 0:
                return
            if calc_method == "Average":
                cut = np.nanmean(region, axis=0) # collapse X
            elif calc_method == "Median":
                cut = np.nanmedian(region, axis=0)
            else:
                cut = np.nansum(region, axis=0)

            self.top_axis.wavelengths = None
            self.plot_widget.hideAxis('top')
            self.current_wavelengths = None
            self.current_wavelength_unit = ""
            x_axis = np.arange(y0, y1)
            self.plot_widget.setLabel('bottom', "Y Pixel")
            unit = "DN" if self.image_viewer and getattr(self.image_viewer, 'disp_as_dn', False) else "DN/s"
            self.plot_widget.setLabel('left', f"Intensity ({unit})")
            self.plot_data.setData(x_axis, cut * self.image_viewer.data_multiplier)
            self.plot_sub.setData([], [])

        self.update_line_overlays()
open_export_dialog()

Open PyQtGraph native export dialog.

Source code in pyql3/gui/tools/depth_plot.py
329
330
331
332
333
334
335
336
337
338
339
def open_export_dialog(self):
    """Open PyQtGraph native export dialog."""
    try:
        from pyqtgraph.GraphicsScene.exportDialog import ExportDialog
        scene = self.plot_widget.scene()
        scene.contextMenuItem = self.plot_widget.plotItem
        if getattr(scene, 'exportDialog', None) is None:
            scene.exportDialog = ExportDialog(scene)
        scene.exportDialog.show(self.plot_widget.plotItem)
    except Exception as e:
        print(f"Error opening export dialog: {e}")

PixelIndexAxis

Bases: AxisItem

Top axis displaying 0-indexed channel slice numbers when the bottom X-axis displays physical wavelengths.

Source code in pyql3/gui/tools/depth_plot.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class PixelIndexAxis(pg.AxisItem):
    """Top axis displaying 0-indexed channel slice numbers when the bottom X-axis displays physical wavelengths."""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.wavelengths = None

    def tickStrings(self, values, scale, spacing):
        if self.wavelengths is None or len(self.wavelengths) == 0:
            return super().tickStrings(values, scale, spacing)

        try:
            indices = np.interp(values, self.wavelengths, np.arange(len(self.wavelengths)))
            return [f"{int(round(idx))}" for idx in indices]
        except Exception:
            return super().tickStrings(values, scale, spacing)

2D Peak & Line Fitting

pyql3.gui.tools.fitting


Aperture Photometry

pyql3.gui.tools.photometry


Strehl Ratio Calculation

pyql3.gui.tools.strehl


Directory Polling Service

pyql3.services.poller

Watch a directory for new FITS files and announce them once they are complete.

Why this polls instead of using filesystem notifications

watchdog.observers.Observer resolves to a kernel backend — FSEvents on macOS, inotify on Linux. Both only report changes made through the local kernel. When the OSIRIS DRP writes from another host onto an NFS share, the watching client's kernel never learns anything happened and no event is ever delivered. We therefore use PollingObserver, which diffs directory snapshots and so sees remote writes.

Why a file is not announced the moment it appears

A file is created before it is written. Loading at first sight yields a truncated FITS, which astropy rejects (memmap=False makes it fail loudly rather than serving padded garbage). We therefore wait for (st_size, st_mtime_ns) to hold steady across SETTLE_CHECKS consecutive samples before announcing.

Note that on NFS this is necessary but not sufficient: clients cache file attributes (acregmin/acregmax, typically 3-30 s), so a file still growing on the server can look settled here. Stability decides when to try, never whether the file is good — the caller must treat a failed parse as "retry later", not "corrupt". MainWindow does exactly that.

Why only one poller may watch a directory

Each window owns a poller, and several windows are open at once. Two of them watching the same directory scan it twice and load every new frame twice, so start_polling takes the watch over from whoever holds it (watcher_of) instead of adding a second observer. Auto-loaded frames therefore land in exactly one window: the one that owns the watch.

DirectoryPoller

Bases: QObject

Emits :attr:file_detected with the newest FITS file once writing has stopped.

Source code in pyql3/services/poller.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
class DirectoryPoller(QObject):
    """Emits :attr:`file_detected` with the newest FITS file once writing has stopped."""

    #: Path of a settled FITS file that the application should display.
    file_detected = Signal(str)
    #: (number of files suppressed, path actually emitted) for a coalesced batch.
    batch_coalesced = Signal(int, str)

    # Internal: marshals a candidate from the observer thread onto the GUI thread.
    _candidate_seen = Signal(str)

    def __init__(self, parent=None, interval=DEFAULT_POLL_INTERVAL):
        super().__init__(parent)
        self.observer = None
        self.watch_path = None
        #: Canonical path this poller holds in `_watches`, or None when not watching.
        self._watch_key = None
        self._interval = float(interval)

        # path -> [last_signature, consecutive_stable_count]
        self._pending = {}
        # Newest file that has settled while we wait for a batch to finish arriving.
        self._held_path = None
        self._held_count = 0
        self._hold_ticks = 0

        self._timer = QTimer(self)
        self._timer.timeout.connect(self._check_pending)
        self._candidate_seen.connect(self._add_candidate)

    # ------------------------------------------------------------------ config

    @property
    def interval(self):
        """Seconds between directory scans. Applies on the next `start_polling`."""
        return self._interval

    @interval.setter
    def interval(self, seconds):
        self._interval = max(0.1, float(seconds))
        if self.observer is not None:
            # Restart so the observer picks up the new scan period.
            self.start_polling(self.watch_path)

    def is_polling(self):
        return self.observer is not None

    # ----------------------------------------------------------------- control

    def start_polling(self, path):
        """Watch `path`, taking the watch over from another poller if one holds it.

        Claiming the directory is done here rather than left to the caller so the
        one-watch-per-directory rule holds however polling is started -- the menu, the
        `--poll-dir` flag, or a test. The GUI asks the user before taking a watch away
        from another window; see `MainWindow.confirm_watch_takeover`.
        """
        self.stop_polling()

        if not path or not os.path.isdir(path):
            return False

        previous = watcher_of(path)
        if previous is not None and previous is not self:
            previous.stop_polling()

        self.watch_path = path
        self._watch_key = _watch_key(path)
        _watches[self._watch_key] = self
        handler = FITSFileHandler(self._candidate_seen.emit)
        self.observer = PollingObserver(timeout=self._interval)
        self.observer.schedule(handler, path, recursive=False)
        self.observer.start()
        self._timer.start(int(self._interval * 1000))
        return True

    def stop_polling(self):
        if self._watch_key is not None:
            # Only drop the registry entry if it is still ours: a poller that was
            # already taken over must not unregister its successor.
            if _watches.get(self._watch_key) is self:
                del _watches[self._watch_key]
            self._watch_key = None

        self._timer.stop()
        self._pending.clear()
        self._held_path = None
        self._held_count = 0
        self._hold_ticks = 0
        if self.observer:
            self.observer.stop()
            self.observer.join(timeout=5)
            self.observer = None

    # ------------------------------------------------------------------ innards

    def _add_candidate(self, path):
        """Register a path to watch for stability. GUI thread only.

        The filter is repeated here rather than trusted from the event handler, so
        that "the poller never touches a non-FITS file or one of our own temp files"
        holds at the single point where candidates enter, whatever calls it.
        """
        if not is_fits_path(path):
            return
        self._pending.setdefault(path, [None, 0])

    @staticmethod
    def _signature(path):
        try:
            st = os.stat(path)
        except OSError:
            return None
        return (st.st_size, st.st_mtime_ns)

    @staticmethod
    def _mtime(path):
        try:
            return os.stat(path).st_mtime_ns
        except OSError:
            return -1

    def _check_pending(self):
        """One settle tick: promote stable files, then decide whether to announce."""
        settled = []
        for path, state in list(self._pending.items()):
            signature = self._signature(path)
            if signature is None:  # deleted or replaced while we watched it
                del self._pending[path]
                continue

            if signature == state[0] and signature[0] > 0:
                state[1] += 1
            else:
                state[0] = signature
                state[1] = 0

            if state[1] >= SETTLE_CHECKS:
                del self._pending[path]
                settled.append(path)

        if settled:
            # Keep only the newest: during a bulk copy the earlier frames would be
            # on screen for a fraction of a second before the next one replaced them.
            candidates = settled + ([self._held_path] if self._held_path else [])
            self._held_count += len(settled)
            # Ties broken by name, so the choice is deterministic rather than
            # whichever-we-happened-to-see-first. Timestamps really do collide here: NTFS
            # resolves to 100 ns, an NFS share can report whole seconds, and a DRP writing
            # a burst of frames lands several inside one tick. Frame numbers are
            # zero-padded and increase, so the later frame also sorts later.
            self._held_path = max(candidates, key=lambda p: (self._mtime(p), p))

        if self._held_path is None:
            self._hold_ticks = 0
            return

        # Still files arriving: hold, so a batch produces one load instead of N.
        # The cap keeps a continuously-written directory from never displaying.
        if self._pending and self._hold_ticks < MAX_HOLD_TICKS:
            self._hold_ticks += 1
            return

        path, suppressed = self._held_path, self._held_count - 1
        self._held_path = None
        self._held_count = 0
        self._hold_ticks = 0

        if suppressed > 0:
            self.batch_coalesced.emit(suppressed, path)
        self.file_detected.emit(path)
interval property writable

Seconds between directory scans. Applies on the next start_polling.

start_polling(path)

Watch path, taking the watch over from another poller if one holds it.

Claiming the directory is done here rather than left to the caller so the one-watch-per-directory rule holds however polling is started -- the menu, the --poll-dir flag, or a test. The GUI asks the user before taking a watch away from another window; see MainWindow.confirm_watch_takeover.

Source code in pyql3/services/poller.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
def start_polling(self, path):
    """Watch `path`, taking the watch over from another poller if one holds it.

    Claiming the directory is done here rather than left to the caller so the
    one-watch-per-directory rule holds however polling is started -- the menu, the
    `--poll-dir` flag, or a test. The GUI asks the user before taking a watch away
    from another window; see `MainWindow.confirm_watch_takeover`.
    """
    self.stop_polling()

    if not path or not os.path.isdir(path):
        return False

    previous = watcher_of(path)
    if previous is not None and previous is not self:
        previous.stop_polling()

    self.watch_path = path
    self._watch_key = _watch_key(path)
    _watches[self._watch_key] = self
    handler = FITSFileHandler(self._candidate_seen.emit)
    self.observer = PollingObserver(timeout=self._interval)
    self.observer.schedule(handler, path, recursive=False)
    self.observer.start()
    self._timer.start(int(self._interval * 1000))
    return True

FITSFileHandler

Bases: FileSystemEventHandler

Feeds candidate paths to the poller. Runs on the observer's thread.

Source code in pyql3/services/poller.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class FITSFileHandler(FileSystemEventHandler):
    """Feeds candidate paths to the poller. Runs on the observer's thread."""

    def __init__(self, callback):
        super().__init__()
        self.callback = callback

    def _consider(self, path, is_directory):
        if not is_directory and is_fits_path(path):
            self.callback(path)

    def on_created(self, event):
        self._consider(event.src_path, event.is_directory)

    def on_modified(self, event):
        # Required for the slow-write case: the file is created empty and only
        # becomes loadable later. Without this, a file that is still being written
        # when it is first seen would never be revisited.
        self._consider(event.src_path, event.is_directory)

    def on_moved(self, event):
        self._consider(event.dest_path, event.is_directory)

is_fits_path(path)

True for FITS filenames, including the compressed forms.

os.path.splitext is deliberately not used: it returns .gz for frame.fits.gz and would drop compressed cubes on the floor.

Hidden files are excluded. FitsReader.save() writes .pyql3_save_*.fits into the same directory as the file being saved, so saving into a watched directory would otherwise offer our own half-written temp file to the poller as if it were a new frame. No legitimate instrument product is a dotfile.

Source code in pyql3/services/poller.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def is_fits_path(path):
    """True for FITS filenames, including the compressed forms.

    `os.path.splitext` is deliberately not used: it returns ``.gz`` for
    ``frame.fits.gz`` and would drop compressed cubes on the floor.

    Hidden files are excluded. `FitsReader.save()` writes `.pyql3_save_*.fits` into
    the same directory as the file being saved, so saving into a watched directory
    would otherwise offer our own half-written temp file to the poller as if it were
    a new frame. No legitimate instrument product is a dotfile.
    """
    name = os.path.basename(path)
    if name.startswith('.'):
        return False
    return name.lower().endswith(FITS_SUFFIXES)

watcher_of(path)

The poller already watching path, or None. Compares canonical paths.

A directory is watched by at most one poller per process, and this is how a caller finds out who holds a watch before taking it over. Two windows watching one directory would each run a PollingObserver over it, doubling the scan traffic on a share whose scan cost already grows with the number of files in the directory, and would then both load every new frame -- the same cube read twice, displayed twice, and two "could not be read" dialogs when a frame is still arriving.

Source code in pyql3/services/poller.py
69
70
71
72
73
74
75
76
77
78
79
def watcher_of(path):
    """The poller already watching `path`, or None. Compares canonical paths.

    A directory is watched by at most one poller per process, and this is how a caller
    finds out who holds a watch before taking it over. Two windows watching one
    directory would each run a `PollingObserver` over it, doubling the scan traffic on
    a share whose scan cost already grows with the number of files in the directory,
    and would then *both* load every new frame -- the same cube read twice, displayed
    twice, and two "could not be read" dialogs when a frame is still arriving.
    """
    return _watches.get(_watch_key(path))