# fbdfile.py

# Copyright (c) 2012-2026, Christoph Gohlke
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""Read FLIMbox data and related files (FBD, FBF, and FBS.XML).

Fbdfile is a Python library to read FLIMbox data (FBD), firmware (FBF), and
setting (FBS.XML) files. The FLIMbox is an FPGA-based device for high
bandwidth, multi-channel data collection for fluorescence lifetime-resolved
imaging (FLIM) from a pulsed laser scanning confocal microscope.
The files are written by SimFCS and ISS VistaVision software.

:Author: `Christoph Gohlke <https://www.cgohlke.com>`_
:License: BSD-3-Clause
:Version: 2026.3.20
:DOI: `10.5281/zenodo.17136073 <https://doi.org/10.5281/zenodo.17136073>`_

Quickstart
----------

Install the fbdfile package and all dependencies from the
`Python Package Index <https://pypi.org/project/fbdfile/>`_::

    python -m pip install -U fbdfile[all]

See `Examples`_ for using the programming interface.

Source code and support are available on
`GitHub <https://github.com/cgohlke/fbdfile>`_.

Requirements
------------

This revision was tested with the following requirements and dependencies
(other versions may work):

- `CPython <https://www.python.org>`_ 3.12.10, 3.13.12, 3.14.3 64-bit
- `NumPy <https://pypi.org/project/numpy>`_ 2.4.3
- `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.10.8 (optional)
- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.3.3 (optional)
- `Click <https://pypi.python.org/pypi/click>`_ 8.3.1
  (optional, for command line apps)
- `Cython <https://pypi.org/project/cython/>`_ 3.2.4 (build)

Revisions
---------

2026.3.20

- Frames and asimage methods always refine laser_factor by default (breaking).
- Add tri-state refine option to frames and asimage methods.
- Add refine_settings method to refine pixel_dwell_time and laser_factor.
- Add more decoder settings (not tested).
- Fix decode to read all bytes from streams and fix skip/count guard.
- Fix cluster-to-frame-marker mapping in frames fallback path.
- Fix from_fbs header detection to use fbf_parse_header.
- Fix decoder_settings to not swallow errors from valid decoders.
- Use fbd_decode return value to trim markers array.
- Drop support for Python 3.11.

2026.2.6

- Fix code review issues.

2026.1.14

- Improve code quality.

2025.12.12

- Add attrs property to FbdFile.
- Improve code quality.

2025.11.8

- Allow to override FbdFile decoder, firmware, and settings.
- Always try to load settings from .fbs.xml file.
- Factor out BinaryFile base class.
- Derive FbdFileError from ValueError.
- Build ABI3 wheels.

2025.9.18

- Fix reading FBF and FBS files from streams.

2025.9.17

- Make frame_markers a numpy array.
- Add options to specify number of OpenMP threads.

2025.9.16

- Initial alpha release based on lfdfiles 2025.7.31.

Notes
-----

The API is not stable yet and might change between revisions.

Python <= 3.11 is no longer supported. 32-bit versions are deprecated.

The latest Microsoft Visual C++ Redistributable for Visual Studio 2015-2022
is required on Windows.

The FLIMbox formats are not documented and might change arbitrarily.
This implementation is based on reverse engineering existing files.
No guarantee can be made as to the correctness of code and documentation.

`SimFCS <https://www.lfd.uci.edu/globals/>`_, a.k.a. Globals for Images,
is software for fluorescence image acquisition, analysis, and simulation,
developed by Enrico Gratton at UCI.

`VistaVision <http://www.iss.com/microscopy/software/vistavision.html>`_
is commercial software for instrument control, data acquisition, and data
processing by ISS Inc (Champaign, IL).

Examples
--------

Read a FLIM lifetime image and metadata from an FBD file:

>>> with FbdFile(
...     'tests/data/flimbox_data$CBCO.fbd', pixel_dwell_time=0.937
... ) as fbd:
...     bins, times, markers = fbd.decode()
...     image = fbd.asimage()
>>> image.shape
(1, 2, 256, 256, 64)
>>> print(bins[0, :2], times[:2], markers[:2])
[50 58] [ 0 32] [1944097 2024815]
>>> import numpy
>>> hist = [numpy.bincount(b[b >= 0]) for b in bins]
>>> int(hist[0].argmax())
53

View the histogram and metadata in a FLIMbox data file from the console::

    $ python -m fbdfile tests/data/flimbox_data$CBCO.fbd

"""

from __future__ import annotations

__version__ = '2026.3.20'

__all__ = [
    'FbdFile',
    'FbdFileError',
    '__version__',
    'fbd_decode',
    'fbd_histogram',
    'fbd_to_b64',
    'fbf_read',
    'fbs_read',
    'sflim_decode',
]

import contextlib
import io
import logging
import os
import re
import struct
import sys
import warnings
from functools import cached_property
from typing import TYPE_CHECKING, ClassVar, final, override

if TYPE_CHECKING:
    from collections.abc import Container, Sequence
    from types import TracebackType
    from typing import IO, Any, Literal, Self

    from numpy.typing import ArrayLike, NDArray

import numpy

from ._fbdfile import fbd_decode, fbd_histogram, sflim_decode


class FbdFileError(ValueError):
    """Exception to indicate invalid FLIMbox data file."""


class BinaryFile:
    """Binary file.

    Parameters:
        file:
            File name or seekable binary stream.
        mode:
            File open mode if ``file`` is a file name.
            If not specified, defaults to ``'r'``. Files are always opened
            in binary mode.

    Raises:
        TypeError:
            File is a text stream, or an unsupported type.
        ValueError:
            Invalid file name, extension, or stream.
            File stream is not seekable.

    """

    _fh: IO[bytes]
    _path: str  # absolute path of file
    _name: str  # name of file or handle
    _close: bool  # file needs to be closed
    _closed: bool  # file is closed
    _ext: ClassVar[set[str]] = set()  # valid extensions, empty for any

    def __init__(
        self,
        file: str | os.PathLike[str] | IO[bytes],
        /,
        *,
        mode: Literal['r', 'r+'] | None = None,
    ) -> None:

        self._path = ''
        self._name = 'Unnamed'
        self._close = False
        self._closed = False

        if isinstance(file, (str, os.PathLike)):
            ext = os.path.splitext(file)[-1].lower()
            if self._ext and ext not in self._ext:
                msg = f'invalid file extension: {ext!r} not in {self._ext!r}'
                raise ValueError(msg)
            if mode is None:
                mode = 'r'
            else:
                if mode[-1:] == 'b':
                    # accept 'rb'/'r+b'
                    mode = mode[:-1]  # type: ignore[assignment]
                if mode not in ('r', 'r+'):
                    msg = f'invalid {mode=!r}'
                    raise ValueError(msg)
            self._path = os.path.abspath(file)
            self._close = True
            self._fh = open(self._path, mode + 'b')  # noqa: SIM115

        elif hasattr(file, 'seek'):
            # binary stream: open file, BytesIO, fsspec LocalFileOpener
            if isinstance(file, io.TextIOBase):  # type: ignore[unreachable]
                msg = (  # type: ignore[unreachable]
                    f'{file=!r} is not open in binary mode'
                )
                raise TypeError(msg)

            self._fh = file
            try:
                self._fh.tell()
            except Exception as exc:
                msg = f'{file=!r} is not seekable'
                raise ValueError(msg) from exc
            if hasattr(file, 'path'):
                self._path = os.path.abspath(file.path)
            elif hasattr(file, 'name'):
                self._path = os.path.abspath(file.name)

        elif hasattr(file, 'open'):
            # fsspec OpenFile
            self._fh = file.open()
            self._close = True
            try:
                self._fh.tell()
            except Exception as exc:
                with contextlib.suppress(Exception):
                    self._fh.close()
                msg = f'{file=!r} is not seekable'
                raise ValueError(msg) from exc
            if hasattr(file, 'path'):
                self._path = os.path.abspath(file.path)

        else:
            msg = f'cannot handle {type(file)=}'
            raise TypeError(msg)

        if hasattr(file, 'name') and file.name:
            self._name = os.path.basename(file.name)
        elif self._path:
            self._name = os.path.basename(self._path)
        else:
            self._name = type(file).__name__

    @property
    def filehandle(self) -> IO[bytes]:
        """File handle."""
        return self._fh

    @property
    def filepath(self) -> str:
        """Absolute path to file, or empty string if unavailable."""
        return self._path

    @property
    def filename(self) -> str:
        """Name of file, or empty if no path is available."""
        return os.path.basename(self._path)

    @property
    def dirname(self) -> str:
        """Directory containing file, or empty if no path is available."""
        return os.path.dirname(self._path)

    @property
    def name(self) -> str:
        """Display name of file."""
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        self._name = value

    @property
    def attrs(self) -> dict[str, Any]:
        """Selected metadata as dict."""
        return {'name': self.name, 'filepath': self.filepath}

    @property
    def closed(self) -> bool:
        """File is closed."""
        return self._closed

    def close(self) -> None:
        """Close file."""
        self._closed = True  # always report file as closed
        if self._close:
            with contextlib.suppress(Exception):
                self._fh.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        self.close()

    def __repr__(self) -> str:
        return f'<{self.__class__.__name__} {self._name!r}>'


@final
class FbdFile(BinaryFile):
    """FLIMbox data file.

    FBD files contain encoded data from the FLIMbox device, storing a
    stream of 16-bit or 32-bit integers (data words) that can be decoded to
    photon arrival windows, channels, and times.

    The measurement's frame size, pixel dwell time, number of sampling
    windows, and scanner type are encoded in the last four characters of the
    file name.
    Newer FBD files, where the 3rd character in the file name tag is `0`,
    may contain embedded firmware and measurement headers before the encoded
    data stream.
    FBD files written by VistaVision are accompanied by FBS.XML setting
    files.

    It depends on the application and its settings how to interpret the
    decoded data, for example, as time series, line scans, or image frames
    of FCS or digital frequency domain fluorescence lifetime measurements.

    The data word format depends on the device's firmware.
    A common layout is::

        |F|E|D|C|B|A|9|8|7|6|5|4|3|2|1|0|  data word bits
                            |-----------|  pcc (cross correlation phase)
                        |---------------|  tcc (cross correlation time)
                      |-|                  marker (indicates start of frame)
        |-------------|                    index into decoder table

    The data word can be decoded into a cross correlation phase histogram
    index (shown for the 1st harmonics)::

        bin = (pmax-1 - (pcc + win * (pmax//windows)) % pmax) // pdiv

    - ``bin``, cross correlation phase index (phase histogram bin number).
    - ``pcc``, cross correlation phase (counter).
    - ``pmax``, number of entries in cross correlation phase histogram.
    - ``pdiv``, divisor to reduce number of entries in phase histogram.
    - ``win``, arrival window.
    - ``windows``, number of sampling windows.

    The current implementation uses a decoder table to decode arrival windows
    for each channel. This method is inefficient for 32-bit FLIMbox data
    with large number of windows and channels.

    Parameters:
        file:
            File name or seekable binary stream.
        code:
            Four-character string, encoding frame size (1st char),
            pixel dwell time (2nd char), number of sampling windows
            (3rd char), and scanner type (4th char).
            By default, the code is extracted from the file name.
        decoder:
            Name of decoder settings function.
        fbf:
            FLIMbox firmware header settings.
            By default, firmware settings are loaded from the file header,
            if any.
        fbs:
            FLIMbox settings from FBS.XML file.
            By default, settings are loaded from companion file, if any.
        frame_size:
            Number of pixels in one line scan, excluding retrace.
        windows:
            Number of sampling windows used by FLIMbox.
        channels:
            Number of channels used by FLIMbox.
        harmonics:
            First or second harmonics.
        pdiv:
            Divisor to reduce number of entries in phase histogram.
        pixel_dwell_time:
            Number of microseconds the scanner remains at each pixel.
        laser_frequency:
            Laser frequency in Hz.
            The default is 20000000 Hz (or 40000000 Hz for second harmonics),
            the internal FLIMbox frequency.
        laser_factor:
            Factor to correct dwell_time/laser_frequency.
            Use when the scanner clock is not known exactly.
        scanner_line_length:
            Number of pixels in each line, including retrace.
        scanner_line_start:
            Index of first valid pixel in scan line.
        scanner_frame_start:
            Index of first valid pixel after marker.
        scanner:
            Scanner software or hardware.
        synthesizer:
            Synthesizer software or hardware.

    """

    _ext: ClassVar[set[str]] = {'.fbd'}
    _data_offset: int  # position of raw data in file
    _effective_header_t: list[tuple[str, str]] | None

    header: numpy.recarray[Any, Any] | None
    """File header, if any."""

    fbf: dict[str, Any] | None
    """Firmware header settings, if any."""

    fbs: dict[str, Any] | None
    """Settings from FBS.XML file, if any."""

    decoder: str | None
    """Decoder settings function."""

    code: str
    """Four-character string from file name, if any."""

    frame_size: int
    """Number of pixels in one line scan, excluding retrace."""

    windows: int
    """Number of sampling windows."""

    channels: int
    """Number of channels."""

    harmonics: int
    """First or second harmonics."""

    pdiv: int
    """Divisor to reduce number of entries in phase histogram."""

    pixel_dwell_time: float
    """Number of microseconds the scanner remains at each pixel."""

    laser_frequency: float
    """Laser frequency in Hz."""

    laser_factor: float
    """Factor to correct dwell_time/laser_frequency."""

    scanner_line_length: int
    """Number of pixels in each line, including retrace."""

    scanner_line_start: int
    """Index of first valid pixel in scan line."""

    scanner_frame_start: int
    """Index of first valid pixel after marker."""

    scanner: str
    """Scanner software or hardware."""

    synthesizer: str
    """Synthesizer software or hardware."""

    is_32bit: bool
    """Data words are 32-bit."""

    _attributes = (
        'decoder',
        'frame_size',
        'windows',
        'channels',
        'harmonics',
        'pdiv',
        'pmax',
        'pixel_dwell_time',
        'laser_frequency',
        'laser_factor',
        'synthesizer',
        'scanner',
        'scanner_line_length',
        'scanner_line_start',
        'scanner_frame_start',
    )

    _frame_size: ClassVar[dict[str, int]] = {
        # Map 1st character in file name tag to image frame size
        'A': 64,
        'B': 128,
        'C': 256,
        'D': 320,
        'E': 512,
        'F': 640,
        'G': 800,
        'H': 1024,
    }

    _flimbox_settings: ClassVar[dict[str, tuple[int, int, int]]] = {
        # Map 3rd character in file name tag to (windows, channels, harmonics)
        # '0': file contains header
        'A': (2, 2, 1),
        'B': (4, 2, 1),
        'C': (8, 2, 1),
        'F': (8, 4, 1),
        'D': (16, 2, 1),
        'E': (32, 2, 1),
        'H': (64, 1, 1),
        # second harmonics. P and Q might be switched in some files?
        'N': (2, 2, 2),
        'O': (4, 2, 2),  # frequency = 40000000 ?
        'P': (8, 2, 2),
        'G': (8, 4, 2),
        'Q': (16, 2, 2),
        'R': (32, 2, 2),
        'S': (64, 1, 2),
    }

    _scanner_settings: ClassVar[dict[str, dict[str, Any]]] = {
        # Map 4th and 2nd character in file name tag to pixel_dwell_time,
        # scanner_line_add, scanner_line_start, and scanner_frame_start.
        # As of SimFCS ~2011.
        # These values may not apply any longer and need to be overridden.
        'S': {
            'name': 'Native SimFCS, 3-axis card',
            'A': (4, 198, 99, 0),
            'J': (100, 20, 10, 0),
            'B': (5, 408, 204, 0),
            'K': (128, 16, 8, 0),
            'C': (8, 256, 128, 0),
            'L': (200, 10, 5, 0),
            'D': (10, 204, 102, 0),
            'M': (256, 8, 4, 0),
            'E': (16, 128, 64, 0),
            'N': (500, 4, 2, 0),
            'F': (20, 102, 51, 0),
            'O': (512, 4, 2, 0),
            'G': (32, 64, 32, 0),
            'P': (1000, 2, 1, 0),
            'H': (50, 40, 20, 0),
            'Q': (1024, 2, 1, 0),
            'I': (64, 32, 16, 0),
            'R': (2000, 1, 0, 0),
        },
        'O': {
            'name': 'Olympus FV 1000, NI USB',
            'A': (2, 10, 8, 0),
            'F': (20, 56, 55, 0),
            'B': (4, 10, 8, 0),
            'G': (40, 28, 20, 0),
            'C': (8, 10, 8, 0),
            'H': (50, 12, 10, 0),
            'D': (10, 112, 114, 0),
            'I': (100, 12, 9, 0),
            'E': (12.5, 90, 80, 0),
            'J': (200, 10, 89, 0),
        },
        'Y': {
            'name': 'Zeiss LSM510',
            'A': (6.39, 344, 22, 0),
            'D': (51.21, 176, 22, 414),
            'B': (12.79, 344, 22, 0),
            'E': (102.39, 88, 0, 242),
            'C': (25.61, 344, 22, 0),
            'F': (204.79, 12, 10, 0),
        },
        'Z': {
            'name': 'Zeiss LSM710',
            'A': (6.305, 344, 2, 0),
            'B': (12.79, 344, 2, 0),
            'C': (25.216, 344, 2, 0),
            'D': (50.42, 176, 8, 414),
            'E': (100.85, 88, 10, 242),
            'F': (177.32, 12, 10, 0),
        },
        'I': {
            'name': 'ISS Vista slow scanner',
            'A': (4, 112, 73, 0),
            'H': (32, 37, 28, 0),
            'B': (6, 112, 73, 0),
            'I': (40, 28, 19, 0),
            'C': (8, 112, 73, 0),
            'J': (64, 18, 14, 0),
            'D': (10, 112, 73, 0),
            'K': (200, 6, 4, 0),
            'E': (12.5, 90, 59, 0),
            'L': (500, 6, 4, 0),
            'F': (16, 73, 48, 0),
            'M': (1000, 6, 4, 0),
            'G': (20, 56, 37, 0),
        },
        'V': {
            'name': 'ISS Vista fast scanner',
            'A': (4, 112, 73, 0),
            'H': (32, 37, 28, 0),
            'B': (6, 112, 73, 0),
            'I': (40, 28, 19, 0),
            'C': (8, 112, 73, 0),
            'J': (64, 21, 14, 0),
            'D': (10, 112, 73, 0),
            'K': (100, 12, 8, 0),
            'E': (12.5, 90, 59, 0),
            'L': (200, 6, 4, 0),
            'F': (16, 73, 48, 0),
            'M': (500, 6, 4, 0),
            'G': (20, 56, 37, 0),
            'N': (1000, 6, 4, 0),
        },
        'T': {
            # used with new file format only?
            'name': 'IOTech scanner card'
        },
    }

    _header_t: list[tuple[str, str]] = [  # noqa: RUF012
        # Binary header starting at offset 1024 in files with $xx0x names.
        # This is written as a memory dump of a Delphi record, hence the pads
        ('owner', '<i4'),  # must be 0
        ('pixel_dwell_time_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('frame_size_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('line_length', '<i4'),
        ('points_end_of_frame', '<i4'),
        ('x_starting_pixel', '<i4'),
        ('line_integrate', '<i4'),
        ('scanner_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('synthesizer_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('windows_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('channels_index', '<i4'),  # index into SimFCS dropdown ctrl
        ('_pad1', '<i4'),
        ('line_time', '<f8'),
        ('frame_time', '<f8'),
        ('scanner', '<i4'),
        ('_pad2', '<f4'),
        ('laser_frequency', '<f8'),
        ('laser_factor', '<f8'),
        ('frames_to_average', '<i4'),
        ('enabled_before_start', '<i4'),
        # the my?calib fields may be missing
        ('mypcalib', '<16f4'),
        ('mymcalib', '<16f4'),
        ('mypcalib1', '<16f4'),
        ('mymcalib1', '<16f4'),
        ('mypcalib2', '<16f4'),
        ('mymcalib2', '<16f4'),
        ('mypcalib3', '<16f4'),
        ('mymcalib3', '<16f4'),
        ('h1', '<i4'),
        ('h2', '<i4'),
        ('process_enable', 'b'),
        ('integrate', 'b'),
        ('detect_maitai', 'b'),
        ('trigger_on_up', 'b'),
        ('line_scan', 'b'),
        ('circular_scan', 'b'),
        ('average_frames_on_reading', 'b'),
        ('show_each_frame', 'b'),
        ('write_each_frame', 'b'),
        ('write_one_big_file', 'b'),
        ('subtract_background', 'b'),
        ('normalize_to_frames', 'b'),
        ('second_harmonic', 'b'),
        ('_pad3', '3b'),
        ('bin_pixel_by_index', '<i4'),
        ('jitter_level', '<i4'),
        ('phaseshift1', '<i4'),
        ('phaseshift2', '<i4'),
        ('acquire_item_index', '<i4'),
        ('acquire_number_of_frames', '<i4'),
        ('show_channel_index', '<i4'),
    ]

    # fmt: off
    _header_pixel_dwell_time = (
        1, 2, 4, 5, 8, 10, 16, 20, 32, 50, 64,
        100, 128, 200, 256, 500, 512, 1000, 2000,
    )
    # fmt: on

    _header_frame_size = (64, 128, 256, 320, 512, 640, 800, 1024)

    _header_bin_pixel_by = (1, 2, 4, 8)

    _header_windows = (4, 8, 16, 32, 64)

    _header_channels = (
        1,
        2,
        4,
        # the following are not supported
        4,  # 2 to 4 ch
        4,  # 2 ch 8w Spartan6
        4,  # 4 ch 16w frame
    )

    _header_scanners = (
        'IOTech scanner card',
        'Native SimFCS, 3-axis card',
        'Olympus FV 1000, NI USB',
        'Zeiss LSM510',
        'Zeiss LSM710',
        'ISS Vista slow scanner',
        'ISS Vista fast scanner',
        'M2 laptop only',
        'IOTech scanner card',
        'Leica SP8',
        'Zeiss LSM880',
        'Olympus FV3000',
        'Olympus 2P',
    )

    _header_synthesizers = (
        'Internal FLIMbox frequency',
        'Fianium',
        'Spectra Physics MaiTai',
        'Spectra Physics Tsunami',
        'Coherent Chameleon',
    )

    def __init__(
        self,
        file: str | os.PathLike[str] | IO[bytes],
        /,
        *,
        mode: Literal['r', 'r+'] | None = None,
        code: str = '',
        decoder: str | None = None,
        fbf: dict[str, Any] | None = None,
        fbs: dict[str, Any] | None = None,
        frame_size: int = -1,
        windows: int = -1,
        channels: int = -1,
        harmonics: int = -1,
        pdiv: int = -1,
        pixel_dwell_time: float = -1.0,
        laser_frequency: float = -1.0,
        laser_factor: float = -1.0,
        scanner_line_length: int = -1,
        scanner_line_start: int = -1,
        scanner_frame_start: int = -1,
        scanner: str = '',
        synthesizer: str = '',
    ) -> None:
        super().__init__(file, mode=mode)

        self.header = None
        self.code = code
        self.decoder = decoder
        self.fbf = fbf
        self.fbs = fbs
        self.frame_size = frame_size
        self.windows = windows
        self.channels = channels
        self.harmonics = harmonics
        self.pdiv = pdiv
        self.pixel_dwell_time = pixel_dwell_time
        self.laser_frequency = laser_frequency
        self.laser_factor = laser_factor
        self.scanner_line_length = scanner_line_length
        self.scanner_line_start = scanner_line_start
        self.scanner_frame_start = scanner_frame_start
        self.scanner = scanner
        self.synthesizer = synthesizer
        self.is_32bit = False
        self._data_offset = 0
        self._effective_header_t = None
        self._pixel_dwell_time_from_table = False

        if not self.code:
            match = re.search(
                r'.*\$([A-Z][A-Z][A-Z0-9][A-Z])\.fbd',
                self.filename,
                re.IGNORECASE,
            )
            if match is None:
                self.code = 'CFCS'  # old FLIMbox file ?
                warnings.warn(
                    'FbdFile: failed to parse code from file name. '
                    f'Using {self.code!r}',
                    stacklevel=2,
                )
            else:
                self.code = match.group(1)

        assert len(self.code) == 4, code

        if self._from_fbs():  # and self.code[2].isnumeric()
            pass
        elif self.code[2] == '0':
            self._from_header()
        else:
            self._from_code()

        assert self.windows >= 0
        assert self.channels >= 0
        assert self.harmonics >= 0

        if self.decoder is None:
            bytes_ = 4 if self.is_32bit else 2
            self.decoder = f'_b{bytes_}w{self.windows}c{self.channels}'
            if (
                self.windows == 16
                and self.channels == 4
                and self.fbf is not None
                and self.fbf.get('time', '').endswith('Bit')
            ):
                t = self.fbf['time'][:-3]
                self.decoder += f't{t}'

        if self.pdiv <= 0:
            with contextlib.suppress(AttributeError):
                self.pdiv = max(1, self.pmax // 64)
        assert self.pdiv >= 0

        for attr in self._attributes:
            value = getattr(self, attr)
            if isinstance(value, (int, float)):
                if value < 0:
                    msg = f'{attr!r} not initialized'
                    raise ValueError(msg)
            elif not value:
                # empty str
                msg = f'{attr!r} not initialized'
                raise ValueError(msg)

    def _from_fbs(self) -> bool:
        """Initialize instance from VistaVision settings file."""
        if self.fbs is None:
            for ext in ('.FBS.XML', '.fbs.xml'):
                filename = self._path.rsplit('$', 1)[0] + ext
                if os.path.exists(filename):
                    break
            else:
                return False
            self.fbs = fbs_read(filename)

        if self.fbf is None:
            self.fbf = fbf_parse_header(
                self.fbs['FirmwareParams']['Description']
            )

        fbf = self.fbf
        scn = self.fbs['ScanParams']
        self.is_32bit = '32fifo' in fbf['decoder']
        if self.harmonics < 0:
            self.harmonics = (1, 2)[fbf['secondharmonic']]
        if self.windows < 0:
            self.windows = fbf['windows']
        if self.channels < 0:
            self.channels = fbf['channels']
        if self.synthesizer == '':
            self.synthesizer = 'Unknown'
        if self.scanner == '':
            try:
                self.scanner = scn['ScannerInfo']['ScannerID']
            except KeyError:
                self.scanner = 'Unknown'
        if self.laser_frequency < 0:
            self.laser_frequency = float(scn['ExcitationFrequency'])
        if self.laser_factor < 0:
            self.laser_factor = 1.0
        if self.scanner_line_length < 0:
            self.scanner_line_length = int(scn['ScanLineLength'])
        if self.scanner_line_start < 0:
            self.scanner_line_start = int(scn['ScanLineLeftBorder'])
        if 'PixelOffsetToFrameFlag' in scn:
            # TODO: check this
            self.scanner_frame_start = scn['PixelOffsetToFrameFlag']
        else:
            self.scanner_frame_start = 0
        if self.pixel_dwell_time < 0:
            self.pixel_dwell_time = scn['PixelDwellTime']['value']
            if (
                'Unit' in scn['PixelDwellTime']
                and 'milli' in scn['PixelDwellTime']['Unit'].lower()
            ):
                self.pixel_dwell_time *= 1000
        if self.frame_size < 0:
            self.frame_size = scn['XPixels']

        self._fh.seek(0)
        try:
            buf = self._fh.read(1024).split(b'\x00', 1)[0]
            if fbf_parse_header(bytes2str(buf)):
                # FBF firmware header detected; encoded data starts at 33K
                self._data_offset = 33792
        except (UnicodeDecodeError, ValueError):
            pass
        self._fh.seek(0)
        return True

    def _from_code(self) -> None:
        """Initialize instance from file name code."""
        code = self.code
        if self.frame_size < 0:
            try:
                self.frame_size = self._frame_size[code[0]]
            except KeyError:
                msg = (
                    f'unknown frame size code {code[0]!r} in {code!r}. '
                    'Override frame_size and other settings manually.'
                )
                raise FbdFileError(msg) from None
        if self.windows < 0 or self.channels < 0 or self.harmonics < 0:
            try:
                windows, channels, harmonics = self._flimbox_settings[code[2]]
            except KeyError:
                msg = (
                    f'unknown FLIMbox settings code {code[2]!r} in {code!r}. '
                    'Override windows, channels, and harmonics manually.'
                )
                raise FbdFileError(msg) from None
            if self.windows < 0:
                self.windows = windows
            if self.channels < 0:
                self.channels = channels
            if self.harmonics < 0:
                self.harmonics = harmonics
        if (
            self.pixel_dwell_time < 0
            or self.scanner_line_length < 0
            or self.scanner_line_start < 0
            or self.scanner_frame_start < 0
        ):
            try:
                (
                    pixel_dwell_time,
                    scanner_line_add,
                    scanner_line_start,
                    scanner_frame_start,
                ) = self._scanner_settings[code[3]][code[1]]
            except KeyError:
                msg = (
                    f'unknown scanner code {code[3]!r}/{code[1]!r}'
                    f' in {code!r}. '
                    'Override pixel_dwell_time and scanner settings manually.'
                )
                raise FbdFileError(msg) from None
            if self.pixel_dwell_time < 0:
                self.pixel_dwell_time = pixel_dwell_time
            if self.scanner_frame_start < 0:
                self.scanner_frame_start = scanner_frame_start
            if self.scanner_line_start < 0:
                self.scanner_line_start = scanner_line_start
            if self.scanner_line_length < 0:
                self.scanner_line_length = self.frame_size + scanner_line_add
        if self.scanner == '':
            try:
                self.scanner = self._scanner_settings[code[3]]['name']
            except KeyError:
                self.scanner = 'Unknown'
        if self.synthesizer == '':
            self.synthesizer = 'Unknown'
        if self.laser_frequency < 0:
            self.laser_frequency = 20000000.0 * self.harmonics
        if self.laser_factor < 0:
            self.laser_factor = 1.0

    def _from_header(self) -> None:
        """Initialize instance from 32 KB file header."""
        # the first 1024 bytes contain the start of a FLIMbox firmware file
        if self.fbf is None:
            self.fbf = fbf_read(self._fh.name)

        self.is_32bit = '32fifo' in self.fbf['decoder']

        # the next 31kB contain the binary file header
        self._fh.seek(1024)
        self.header = hdr = numpy.fromfile(self._fh, self._header_t, 1)[0]
        if hdr['owner'] != 0:
            msg = f'unknown header format {hdr["owner"]!r}'
            raise ValueError(msg)

        # detect corrupted header: the my?calib fields may be missing
        if (
            hdr['process_enable'] > 1
            or hdr['integrate'] > 1
            or hdr['line_scan'] > 1
            or hdr['subtract_background'] > 1
            or hdr['second_harmonic'] > 1
        ):
            # reload with trimmed header (calibration fields absent)
            self._effective_header_t = (
                FbdFile._header_t[:20] + FbdFile._header_t[28:]
            )
            self._fh.seek(1024)
            self.header = hdr = numpy.fromfile(
                self._fh, self._effective_header_t, 1
            )[0]
        else:
            self._effective_header_t = FbdFile._header_t

        if self.harmonics < 0:
            self.harmonics = (1, 2)[self.fbf['secondharmonic']]
        if self.windows < 0:
            windows = self._header_windows[hdr['windows_index']]
            self.windows = self.fbf.get('windows', windows)
            if self.windows != windows:
                logger().warning(
                    'FbdFile: windows mismatch between FBF and FBD header '
                    f'({self.windows!r} != {windows!r})'
                )
        if self.channels < 0:
            channels = self._header_channels[hdr['channels_index']]
            self.channels = self.fbf.get('channels', channels)
            if self.channels != channels:
                logger().warning(
                    'FbdFile: channels mismatch between FBF and FBD header '
                    f'({self.channels!r} != {channels!r})'
                )
        if self.synthesizer == '':
            try:
                self.synthesizer = self._header_synthesizers[
                    hdr['synthesizer_index']
                ]
            except IndexError:
                self.synthesizer = 'Unknown'
        if self.scanner == '':
            try:
                self.scanner = self._header_scanners[hdr['scanner_index']]
            except IndexError:
                self.scanner = 'Unknown'
        if self.laser_frequency < 0:
            self.laser_frequency = float(hdr['laser_frequency'])
        if self.laser_factor < 0:
            self.laser_factor = float(hdr['laser_factor'])
        if self.scanner_line_length < 0:
            self.scanner_line_length = int(hdr['line_length'])
        if self.scanner_line_start < 0:
            self.scanner_line_start = int(hdr['x_starting_pixel'])
        self.scanner_frame_start = max(self.scanner_frame_start, 0)

        if hdr['frame_time'] >= hdr['line_time'] > 1.0:
            if self.frame_size < 0:
                self.frame_size = round(hdr['frame_time'] / hdr['line_time'])
                for frame_size in self._header_frame_size:
                    if abs(self.frame_size - frame_size) < 3:
                        self.frame_size = frame_size
                        break
            if self.pixel_dwell_time < 0:
                self.pixel_dwell_time = float(
                    hdr['frame_time']
                    / (self.frame_size * self.scanner_line_length)
                )
        else:
            if self.frame_size < 0:
                try:
                    self.frame_size = round(
                        self._header_frame_size[hdr['frame_size_index']]
                    )
                except IndexError:
                    # fall back to filename
                    if self.code and self.code[0] in self._frame_size:
                        self.frame_size = self._frame_size[self.code[0]]
            if self.pixel_dwell_time < 0:
                try:
                    # use file name
                    dwt = self._scanner_settings[self.code[3]][self.code[1]][0]
                except (IndexError, KeyError, TypeError):
                    try:
                        dwt = self._header_pixel_dwell_time[
                            hdr['pixel_dwell_time_index']
                        ]
                        self._pixel_dwell_time_from_table = True
                    except IndexError:
                        dwt = 1.0
                self.pixel_dwell_time = dwt

        self._data_offset = 65536  # start of encoded data; contains 2 headers

    @property
    def pmax(self) -> int:
        """Number of entries in cross correlation phase histogram."""
        d = self.decoder_settings
        return int((d['pcc_mask'] >> d['pcc_shr']) + 1) // self.harmonics

    @property
    def scanner_line_add(self) -> int:
        """Number of pixels added to each line (for retrace)."""
        return self.scanner_line_length - self.frame_size

    @property
    def units_per_sample(self) -> float:
        """Number of FLIMbox units per scanner sample."""
        pmax = self.pmax
        if pmax <= 1:
            msg = f'{pmax=!r} <= 1'
            raise ValueError(msg)
        return float(
            (self.pixel_dwell_time * 1e-6)
            * (pmax / (pmax - 1))
            * (self.laser_frequency * self.laser_factor)
        )

    @property
    @override
    def attrs(self) -> dict[str, Any]:
        """Selected metadata as dict."""
        return {
            **super().attrs,
            # 'header': self.header,
            # 'fbf': self.fbf,
            # 'fbs': self.fbs,
            # 'decoder_settings': self.decoder_settings,
            'channels': self.channels,
            'code': self.code,
            'decoder': self.decoder,
            'frame_size': self.frame_size,
            'harmonics': self.harmonics,
            'is_32bit': self.is_32bit,
            'laser_factor': self.laser_factor,
            'laser_frequency': self.laser_frequency,
            'pdiv': self.pdiv,
            'pixel_dwell_time': self.pixel_dwell_time,
            'pmax': self.pmax,
            'scanner': self.scanner,
            'scanner_frame_start': self.scanner_frame_start,
            'scanner_line_add': self.scanner_line_add,
            'scanner_line_length': self.scanner_line_length,
            'scanner_line_start': self.scanner_line_start,
            'synthesizer': self.synthesizer,
            'units_per_sample': self.units_per_sample,
            'windows': self.windows,
        }

    @cached_property
    def decoder_settings(self) -> dict[str, NDArray[numpy.int16] | int | bool]:
        """Return parameters to decode FLIMbox data stream.

        Returns:
            - 'decoder_table' - Decoder table mapping channel and window
              indices to actual arrival windows.
              The shape is (channels, window indices) and dtype is int16.
            - 'tcc_mask', 'tcc_shr' - Binary mask and number of bits to right
              shift to extract cross correlation time from data word.
            - 'pcc_mask', 'pcc_shr' - Binary mask and number of bits to right
              shift to extract cross correlation phase from data word.
            - 'marker_mask', 'marker_shr' - Binary mask and number of bits to
              right shift to extract markers from data word.
            - 'win_mask', 'win_shr' - Binary mask and number of bits to right
              shift to extract index into lookup table from data word.
            - 'swap_words' - If True, swap the two 16-bit halves of each
              32-bit data word before decoding. Optional, defaults to False.

        """
        if self.decoder is None:
            msg = 'decoder is not set'
            raise ValueError(msg)
        try:
            settings = getattr(self, self.decoder)()
        except (AttributeError, NotImplementedError) as exc:
            msg = f'decoder {self.decoder!r} not implemented'
            raise ValueError(msg) from exc
        return settings  # type: ignore[no-any-return]

    @staticmethod
    def _b4w16c4t10() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 16 windows, 4 channels (Spartan6)
        # with line markers
        # 0b00000000000000000000000011111111 cross correlation phase
        # 0b00000000000000000000001111111111 cross correlation time  # 10 bit
        # 0b00000000000000000000010000000000 start of line marker
        # 0b00000000000000000000100000000000 start of frame marker
        # 0b00000000000000000001000000000000 ch0 photon
        # 0b00000000000000011110000000000000 ch0 window
        # 0b00000000000000100000000000000000 ch1 photon
        # 0b00000000001111000000000000000000 ch1 window
        # 0b00000000010000000000000000000000 ch2 photon
        # 0b00000111100000000000000000000000 ch2 window
        # 0b00001000000000000000000000000000 ch3 photon
        # 0b11110000000000000000000000000000 ch3 window
        table = numpy.full((4, 2**20), -1, numpy.int16)
        for i in range(2**20):
            if i & 0b1:
                table[0, i] = (i & 0b11110) >> 1
            if i & 0b100000:
                table[1, i] = (i & 0b1111000000) >> 6
            if i & 0b10000000000:
                table[2, i] = (i & 0b111100000000000) >> 11
            if i & 0b1000000000000000:
                table[3, i] = (i & 0b11110000000000000000) >> 16
        return {
            'decoder_table': table,
            'tcc_mask': 0x3FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            # 'line_mask': 0x400,
            # 'line_shr': 10,
            'marker_mask': 0x800,
            'marker_shr': 11,
            'win_mask': 0xFFFFF000,
            'win_shr': 12,
            'swap_words': True,
        }

    @staticmethod
    def _b4w16c4t11() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 16 windows, 4 channels (Spartan6)
        # without line markers
        # 0b00000000000000000000000011111111 cross correlation phase
        # 0b00000000000000000000011111111111 cross correlation time  # 11 bit
        # 0b00000000000000000000100000000000 start of frame marker
        # 0b00000000000000000001000000000000 ch0 photon
        # 0b00000000000000011110000000000000 ch0 window
        # 0b00000000000000100000000000000000 ch1 photon
        # 0b00000000001111000000000000000000 ch1 window
        # 0b00000000010000000000000000000000 ch2 photon
        # 0b00000111100000000000000000000000 ch2 window
        # 0b00001000000000000000000000000000 ch3 photon
        # 0b11110000000000000000000000000000 ch3 window
        return {
            **FbdFile._b4w16c4t10(),
            'tcc_mask': 0x7FF,
            'marker_mask': 0x800,
            'marker_shr': 11,
        }

    @staticmethod
    def _b4w8c4() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 8 windows, 4 channels (Spartan-6)
        # 0b00000000000000000000000000000001 ch0 photon
        # 0b00000000000000000000000000001110 ch0 window
        # 0b00000000000000000000000000010000 ch1 photon
        # 0b00000000000000000000000011100000 ch1 window
        # 0b00000000000000000000000100000000 ch2 photon
        # 0b00000000000000000000111000000000 ch2 window
        # 0b00000000000000000001000000000000 ch3 photon
        # 0b00000000000000001110000000000000 ch3 window
        # 0b00000000111111110000000000000000 cross correlation phase
        # 0b00011111111111110000000000000000 cross correlation time
        # 0b01000000000000000000000000000000 start of frame marker
        table = numpy.full((4, 2**16), -1, numpy.int16)
        for i in range(2**16):
            if i & 0b1:
                table[0, i] = (i & 0b1110) >> 1
            if i & 0b10000:
                table[1, i] = (i & 0b11100000) >> 5
            if i & 0b100000000:
                table[2, i] = (i & 0b111000000000) >> 9
            if i & 0b1000000000000:
                table[3, i] = (i & 0b1110000000000000) >> 13
        return {
            'decoder_table': table,
            'tcc_mask': 0x1FFF0000,
            'tcc_shr': 16,
            'pcc_mask': 0xFF0000,
            'pcc_shr': 16,
            'marker_mask': 0x40000000,
            'marker_shr': 30,
            'win_mask': 0xFFFF,
            'win_shr': 0,
            # 'swap_words': True,  # ?
        }

    @staticmethod
    def _b2w4c2() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 4 windows, 2 channels
        # fmt: off
        table = numpy.array(
            [[-1, 0, -1, 1, -1, 2, -1, 3, -1, 0, -1, -1, 1, 0, -1, 2,
              1, 0, 3, 2, 1, 0, 3, 2, 1, -1, 3, 2, -1, -1, 3, -1],
             [-1, -1, 0, -1, 1, -1, 2, -1, 3, 0, -1, -1, 0, 3, -1, 0,
              1, 2, 2, 1, 2, 1, 1, 2, 3, -1, 0, 3, -1, -1, 3, -1]],
            numpy.int16
        )
        # fmt: on
        return {
            'decoder_table': table,
            'tcc_mask': 0x3FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            'marker_mask': 0x400,
            'marker_shr': 10,
            'win_mask': 0xF800,
            'win_shr': 11,
        }

    @staticmethod
    def _b2w8c2() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 8 windows, 2 channels
        # TODO: this does not correctly decode data acquired with ISS firmware
        table = numpy.full((2, 81), -1, numpy.int16)
        table[0, 1:9] = range(8)
        table[1, 9:17] = range(8)
        table[:, 17:] = numpy.mgrid[0:8, 0:8].reshape((2, -1))[::-1, :]
        return {
            'decoder_table': table,
            'tcc_mask': 0xFF,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x100,
            'marker_shr': 8,
            'win_mask': 0xFFFF,
            'win_shr': 9,
        }

    @staticmethod
    def _b2w8c4() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 8 windows, 4 channels
        # 0b00000000000000000000000011111111 cross correlation time (8 bits)
        # 0b00000000000000000000000000111111 cross correlation phase (6 bits)
        # 0b00000000000000000000000100000000 frame sync marker (bit 8)
        # 0b00000000000000000000011100000000 window (bits [11:9], shared)
        # 0b00000000000000000001000000000000 ch0 photon (bit 12)
        # 0b00000000000000000010000000000000 ch1 photon (bit 13)
        # 0b00000000000000000100000000000000 ch2 photon (bit 14)
        # 0b00000000000000001000000000000000 ch3 photon (bit 15)
        logger().warning(
            'FbdFile: b2w8c4 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((4, 128), -1, numpy.int16)
        for i in range(128):
            win = i & 0b0000111
            ch0 = (i & 0b0001000) >> 3
            ch1 = (i & 0b0010000) >> 4
            ch2 = (i & 0b0100000) >> 5
            ch3 = (i & 0b1000000) >> 6
            # multiple channels may receive a photon simultaneously;
            # all active channels share the same window
            if ch0:
                table[0, i] = win
            if ch1:
                table[1, i] = win
            if ch2:
                table[2, i] = win
            if ch3:
                table[3, i] = win
        return {
            'decoder_table': table,
            'tcc_mask': 0xFF,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x100,
            'marker_shr': 8,
            'win_mask': 0xFFFF,
            'win_shr': 9,
        }

    @staticmethod
    def _b2w16c1() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 16 windows, 1 channel
        logger().warning(
            'FbdFile: b2w16c1 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((1, 32), -1, numpy.int16)
        for i in range(32):
            win = (i & 0b11110) >> 1
            ch0 = i & 0b00001
            if ch0:
                table[0, i] = win
        return {
            'decoder_table': table,
            'tcc_mask': 0xFF,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x100,
            'marker_shr': 8,
            'win_mask': 0xFFFF,
            'win_shr': 11,
        }

    @staticmethod
    def _b2w16c2() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 16 windows, 2 channels
        logger().warning(
            'FbdFile: b2w16c2 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((2, 64), -1, numpy.int16)
        for i in range(64):
            win = (i & 0b111100) >> 2
            ch0 = (i & 0b000010) >> 1
            ch1 = i & 0b000001
            if ch0 + ch1 != 1:
                continue
            if ch0:
                table[0, i] = win
            elif ch1:
                table[1, i] = win
        return {
            'decoder_table': table,
            'tcc_mask': 0xFF,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x100,
            'marker_shr': 8,
            'win_mask': 0xFFFF,
            'win_shr': 10,
        }

    @staticmethod
    def _b2w32c1() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32 windows, 1 channel
        # 0b00000000000000000000000111111111 cross correlation time (9 bits)
        # 0b00000000000000000000000011111111 cross correlation phase (8 bits)
        # 0b00000000000000000000001000000000 frame sync marker (bit 9)
        # 0b00000000000000000000010000000000 ch0 photon (bit 10)
        # 0b00000000000000001111100000000000 ch0 window (bits [15:11], 5 bits)
        table = numpy.full((1, 64), -1, numpy.int16)
        for i in range(64):
            ph0 = i & 0b000001
            win = (i & 0b111110) >> 1
            if ph0:
                table[0, i] = win
        return {
            'decoder_table': table,
            'tcc_mask': 0x1FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            'marker_mask': 0x200,
            'marker_shr': 9,
            'win_mask': 0xFFFF,
            'win_shr': 10,
        }

    @staticmethod
    def _b2w32c2() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32 windows, 2 channels
        # TODO
        msg = (
            'FbdFile: b2w32c2 decoder not implemented. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        raise NotImplementedError(msg)

    @staticmethod
    def _b4w32c2() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 32 windows, 2 channels
        # Firmware: 32w32fifo256bin
        # Each 32-bit word encodes two independent 16-bit 32w1ch words:
        # 0b00000000000000000000000111111111 ch0 CC time (9 bits)
        # 0b00000000000000000000000011111111 ch0 CC phase (8 bits)
        # 0b00000000000000000000001000000000 frame sync marker (bit 9)
        # 0b00000000000000000000010000000000 ch0 photon (bit 10)
        # 0b00000000000000001111100000000000 ch0 window (bits [15:11], 5 bits)
        # 0b00000001111111110000000000000000 ch1 CC time (bits [24:16])
        # 0b00000001000000000000000000000000 ch1 photon (bit 26)
        # 0b11111100000000000000000000000000 ch1 window (bits [31:27], 5 bits)
        # win_mask covers bits [31:10] = 22 bits;
        # ch0 at [15:10], ch1 at [31:26]
        logger().warning(
            'FbdFile: b4w32c2 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((2, 2**22), -1, numpy.int16)
        indices = numpy.arange(2**22, dtype=numpy.uint32)
        # ch0: bit 0 of index = ch0 photon (orig bit 10),
        # bits [5:1] = ch0 window
        ch0_photon = (indices & 0x1).astype(bool)
        ch0_window = (indices >> 1) & 0x1F
        table[0] = numpy.where(ch0_photon, ch0_window, -1).astype(numpy.int16)
        # ch1: bit 16 of index = ch1 photon (orig bit 26),
        # bits [21:17] = ch1 window
        ch1_photon = ((indices >> 16) & 0x1).astype(bool)
        ch1_window = (indices >> 17) & 0x1F
        table[1] = numpy.where(ch1_photon, ch1_window, -1).astype(numpy.int16)
        return {
            'decoder_table': table,
            'tcc_mask': 0x1FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            'marker_mask': 0x200,
            'marker_shr': 9,
            'win_mask': 0xFFFFFC00,
            'win_shr': 10,
        }

    @staticmethod
    def _b4w64c3() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 64 windows, 3 channels
        # Firmware: 64w32fifo256bin
        # 0b00000000000000000000001111111111 cross correlation time (10 bits)
        # 0b00000000000000000000000011111111 cross correlation phase (8 bits)
        # 0b00000000000000000000010000000000 frame sync marker (bit 10)
        # 0b00000000000000000000100000000000 ch0 photon (bit 11)
        # 0b00000000000000000111000000000000 ch0 window (bits [14:12]... wait)
        # bits [17:12] = ch0 window (6 bits), bit 11 = ch0 photon
        # bit 18 = ch1 photon, bits [24:19] = ch1 window
        # bit 25 = ch2 photon, bits [31:26] = ch2 window
        # win_mask covers bits [31:11] = 21 bits
        logger().warning(
            'FbdFile: b4w64c3 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((3, 2**21), -1, numpy.int16)
        indices = numpy.arange(2**21, dtype=numpy.uint32)
        # ch0: bit 0 of index = ch0 photon (orig bit 11),
        # bits [6:1] = ch0 window
        ch0_photon = (indices & 0x1).astype(bool)
        ch0_window = (indices >> 1) & 0x3F
        table[0] = numpy.where(ch0_photon, ch0_window, -1).astype(numpy.int16)
        # ch1: bit 7 of index = ch1 photon (orig bit 18),
        # bits [13:8] = ch1 window
        ch1_photon = ((indices >> 7) & 0x1).astype(bool)
        ch1_window = (indices >> 8) & 0x3F
        table[1] = numpy.where(ch1_photon, ch1_window, -1).astype(numpy.int16)
        # ch2: bit 14 of index = ch2 photon (orig bit 25),
        # bits [20:15] = ch2 window
        ch2_photon = ((indices >> 14) & 0x1).astype(bool)
        ch2_window = (indices >> 15) & 0x3F
        table[2] = numpy.where(ch2_photon, ch2_window, -1).astype(numpy.int16)
        return {
            'decoder_table': table,
            'tcc_mask': 0x3FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            'marker_mask': 0x400,
            'marker_shr': 10,
            'win_mask': 0xFFFFF800,
            'win_shr': 11,
        }

    @staticmethod
    def _b4w32c3() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 32 windows, 3 channels
        # Firmware: 32w32fifo256bin3ch
        # 0b00000000000000000000011111111111 cross correlation time (11 bits)
        # 0b00000000000000000000000011111111 cross correlation phase (8 bits)
        # 0b00000000000000000010000000000000 frame sync marker (bit 13)
        # 0b00000000000000000100000000000000 ch0 photon (bit 14)
        # 0b00000000000001111000000000000000 ch0 window (bits [19:15], 5 bits)
        # 0b00000000000100000000000000000000 ch1 photon (bit 20)
        # 0b00000011111000000000000000000000 ch1 window (bits [25:21], 5 bits)
        # 0b00000100000000000000000000000000 ch2 photon (bit 26)
        # 0b11111000000000000000000000000000 ch2 window (bits [31:27], 5 bits)
        # win_mask covers bits [31:14] = 18 bits
        logger().warning(
            'FbdFile: b4w32c3 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((3, 2**18), -1, numpy.int16)
        indices = numpy.arange(2**18, dtype=numpy.uint32)
        # ch0: bit 0 of index = ch0 photon (orig bit 14),
        # bits [5:1] = ch0 window
        ch0_photon = (indices & 0x1).astype(bool)
        ch0_window = (indices >> 1) & 0x1F
        table[0] = numpy.where(ch0_photon, ch0_window, -1).astype(numpy.int16)
        # ch1: bit 6 of index = ch1 photon (orig bit 20),
        # bits [11:7] = ch1 window
        ch1_photon = ((indices >> 6) & 0x1).astype(bool)
        ch1_window = (indices >> 7) & 0x1F
        table[1] = numpy.where(ch1_photon, ch1_window, -1).astype(numpy.int16)
        # ch2: bit 12 of index = ch2 photon (orig bit 26),
        # bits [17:13] = ch2 window
        ch2_photon = ((indices >> 12) & 0x1).astype(bool)
        ch2_window = (indices >> 13) & 0x1F
        table[2] = numpy.where(ch2_photon, ch2_window, -1).astype(numpy.int16)
        return {
            'decoder_table': table,
            'tcc_mask': 0x7FF,
            'tcc_shr': 0,
            'pcc_mask': 0xFF,
            'pcc_shr': 0,
            'marker_mask': 0x2000,
            'marker_shr': 13,
            'win_mask': 0xFFFFC000,
            'win_shr': 14,
        }

    @staticmethod
    def _b4w32c4() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 32-bit, 32 windows, 4 channels
        # Firmware: 32w32fifo64bin4ch (variable ccTimeBitCount/ccPhaseBitCount)
        # 0b00000000000000000000000001111111 tcc (variable, <=7 bits)
        # 0b00000000000000000000000000111111 pcc (variable, <=6 bits)
        # 0b00000000000000000000000010000000 frame sync marker (bit 7)
        # 0b00000000000000000000000100000000 ch0 photon (bit 8)
        # 0b00000000000000000000011110000000 ch0 window (bits [13:9], 5 bits)
        # Actually: bits [13:9] = ch0 window, bit 8 = ch0 photon
        # bit 14 = ch1 photon, bits [19:15] = ch1 window
        # bit 20 = ch2 photon, bits [25:21] = ch2 window
        # bit 26 = ch3 photon, bits [31:27] = ch3 window
        # win_mask covers bits [31:8] = 24 bits -> 2^24 = 16M entries
        # Note: uses ~128 MB of RAM for the decoder table
        logger().warning(
            'FbdFile: b4w32c4 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((4, 2**24), -1, numpy.int16)
        indices = numpy.arange(2**24, dtype=numpy.uint32)
        for ic in range(4):
            # bit (6*ic) of index = photon, bits [(6*ic+5):(6*ic+1)] = window
            photon = ((indices >> (ic * 6)) & 0x1).astype(bool)
            window = (indices >> (1 + ic * 6)) & 0x1F
            table[ic] = numpy.where(photon, window, -1).astype(numpy.int16)
        return {
            'decoder_table': table,
            'tcc_mask': 0x7F,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x80,
            'marker_shr': 7,
            'win_mask': 0xFFFFFF00,
            'win_shr': 8,
        }

    @staticmethod
    def _b2w64c1() -> dict[str, NDArray[numpy.int16] | int | bool]:
        # return parameters to decode 64 windows, 1 channel
        # 0b00000000000000000000000011111111 cross correlation time (8 bits)
        # 0b00000000000000000000000000111111 cross correlation phase (6 bits)
        # 0b00000000000000000000000100000000 frame sync marker (bit 8)
        # 0b00000000000000000000001000000000 ch0 photon (bit 9)
        # 0b00000000000000001111110000000000 ch0 window (bits [15:10], 6 bits)
        logger().warning(
            'FbdFile: b2w64c1 decoder not tested. '
            'Please submit an FBD file to https://github.com/cgohlke/fbdfile'
        )
        table = numpy.full((1, 128), -1, numpy.int16)
        for i in range(128):
            ph0 = i & 0b0000001
            win = (i & 0b1111110) >> 1
            if ph0:
                table[0, i] = win
        return {
            'decoder_table': table,
            'tcc_mask': 0xFF,
            'tcc_shr': 0,
            'pcc_mask': 0x3F,
            'pcc_shr': 0,
            'marker_mask': 0x100,
            'marker_shr': 8,
            'win_mask': 0xFE00,
            'win_shr': 9,
        }

    def decode(
        self,
        data: NDArray[Any] | None = None,
        *,
        word_count: int = -1,
        skip_words: int = 0,
        max_markers: int = 65536,
        num_threads: int = 0,
        **kwargs: Any,
    ) -> tuple[
        NDArray[numpy.int8 | numpy.int16],
        NDArray[numpy.uint32 | numpy.uint64],
        NDArray[numpy.intp],
    ]:
        """Return decoded records from FLIMbox data stream.

        Parameters:
            data:
                FLIMbox data stream. By default, the data is read from file.
            word_count:
                Number of data words to process.
                By default, all words are processed.
            skip_words:
                Number of data words to skip at beginning of stream.
            max_markers:
                Maximum number of markers expected in data stream.
            num_threads:
                Number of OpenMP threads to use for parallelization.

        Returns:
            bins:
                Cross correlation phase index for all channels and data points.
                An ``int8`` or ``int16`` array of shape ``(channels, size)``.
                A value of ``-1`` means no photon was counted.
            times:
                The times in FLIMbox counter units at each data point.
                An array of type uint64, potentially huge.
            markers:
                The indices of up markers in the data stream, usually
                indicating frame starts. An array of type ssize_t.

        """
        del kwargs  # unused
        dtype = numpy.dtype('<u4' if self.is_32bit else '<u2')
        if data is None:
            self._fh.seek(self._data_offset + skip_words * dtype.itemsize, 0)
            try:
                data = numpy.fromfile(self._fh, dtype=dtype, count=word_count)
            except io.UnsupportedOperation:
                if word_count < 0:
                    raw = self._fh.read()
                else:
                    raw = self._fh.read(dtype.itemsize * word_count)
                data = numpy.frombuffer(raw, dtype=dtype)
        elif skip_words > 0 or word_count >= 0:
            if word_count < 0:
                data = data[skip_words:]
            else:
                data = data[skip_words : skip_words + word_count]
        if data.dtype != dtype:
            msg = 'invalid data dtype'
            raise ValueError(msg)

        bins_out = numpy.empty(
            (self.channels, data.size),
            dtype=numpy.int8 if self.pmax // self.pdiv <= 128 else numpy.int16,
        )
        times_out = numpy.empty(data.size, dtype=numpy.uint64)
        markers_out = numpy.zeros(max_markers, dtype=numpy.intp)

        count = fbd_decode(
            data,
            bins_out,
            times_out,
            markers_out,
            self.windows,
            self.pdiv,
            self.harmonics,
            num_threads=num_threads,
            **self.decoder_settings,
        )

        markers_out = markers_out[:count]
        if count == max_markers:
            warnings.warn(
                f'number of markers exceeded buffer size {max_markers}',
                stacklevel=2,
            )

        return bins_out, times_out, markers_out

    def refine_settings(
        self,
        records: tuple[NDArray[Any], NDArray[Any], NDArray[Any]] | None = None,
        /,
        *,
        aspect_range: tuple[float, float] = (0.8, 1.2),
        **kwargs: Any,
    ) -> bool | None:
        """Refine pixel_dwell_time and laser_factor from frame durations.

        First evaluate the current ``pixel_dwell_time`` against the frame
        data. Then, only when ``pixel_dwell_time`` was originally derived
        from the header's ``pixel_dwell_time_index`` (i.e. not from
        computed timing metadata, XML/FBS settings, or user input), search
        ``_header_pixel_dwell_time`` for a value that produces *more* valid
        frames and replace ``pixel_dwell_time`` if found. Always refine
        ``laser_factor`` from the median of detected frame durations.
        Update attributes in-place and issue warnings.

        Parameters:
            records:
                Bins, times, and markers from decode function.
                By default, call :py:meth:`FbdFile.decode`.
            aspect_range:
                Minimum and maximum aspect ratios of valid frames.
            **kwargs:
                Additional arguments passed to
                :py:meth:`FbdFile.decode`.

        Returns:
            True if any setting was changed,
            False if settings are already optimal,
            and None if no valid frames could be found.

        """
        if records is None:
            records = self.decode(**kwargs)
        times, markers = records[-2:]

        if len(markers) < 2:
            msg = 'not enough markers to detect frames'
            raise ValueError(msg)

        frame_durations = numpy.ediff1d(times[markers])
        old_dwell_time = self.pixel_dwell_time
        old_laser_factor = self.laser_factor
        pmax = self.pmax
        pmax_factor = float(pmax / (pmax - 1))
        lf = self.laser_frequency * self.laser_factor
        best_dwell_time = old_dwell_time
        best_count = 0
        best_durations: list[float] = []
        best_line_num = self.frame_size

        # Seed best with the current pixel_dwell_time so table candidates
        # must strictly outperform it. This preserves correct user-supplied
        # or metadata-derived values that are not in _header_pixel_dwell_time.
        ups = old_dwell_time * 1e-6 * pmax_factor * lf
        lt = self.scanner_line_length * ups
        if lt > 0:
            _line_num = sys.maxsize
            for duration in frame_durations:
                lines = duration / lt
                if lines <= 0:
                    continue
                aspect = self.frame_size / lines
                if aspect_range[0] < aspect < aspect_range[1]:
                    best_count += 1
                    best_durations.append(float(duration))
                    _line_num = min(_line_num, lines)
            if _line_num < sys.maxsize:
                best_line_num = round(_line_num)

        if self._pixel_dwell_time_from_table:
            for candidate in self._header_pixel_dwell_time:
                ups = candidate * 1e-6 * pmax_factor * lf
                lt = self.scanner_line_length * ups
                if lt <= 0:
                    continue
                count = 0
                valid_durations: list[float] = []
                line_num = sys.maxsize
                for duration in frame_durations:
                    lines = duration / lt
                    if lines <= 0:
                        continue
                    aspect = self.frame_size / lines
                    if aspect_range[0] < aspect < aspect_range[1]:
                        count += 1
                        valid_durations.append(float(duration))
                        line_num = min(line_num, lines)
                if count > best_count:
                    best_count = count
                    best_dwell_time = float(candidate)
                    best_durations = valid_durations
                    if line_num < sys.maxsize:
                        best_line_num = round(line_num)

        if best_count == 0:
            if self._pixel_dwell_time_from_table:
                warnings.warn(
                    'refine_settings: no pixel_dwell_time in '
                    f'{self._header_pixel_dwell_time} produces valid frames',
                    stacklevel=2,
                )
            else:
                warnings.warn(
                    f'refine_settings: pixel_dwell_time {old_dwell_time} '
                    'does not produce valid frames',
                    stacklevel=2,
                )
            return None

        changed = False

        if best_dwell_time != old_dwell_time:
            self.pixel_dwell_time = best_dwell_time
            changed = True
            warnings.warn(
                f'refine_settings: pixel_dwell_time changed'
                f' from {old_dwell_time} to {best_dwell_time}',
                stacklevel=2,
            )

        # refine laser_factor from median of valid frame durations
        if best_durations:
            ups = self.pixel_dwell_time * 1e-6 * pmax_factor * lf
            lt = self.scanner_line_length * ups
            median_dur = float(numpy.median(best_durations))
            line_num = best_line_num
            if best_line_num < self.frame_size:
                line_num = self.frame_size
            new_factor = median_dur * self.laser_factor / (line_num * lt)
            if new_factor != old_laser_factor:
                self.laser_factor = new_factor
                changed = True
                if (
                    abs(new_factor - old_laser_factor) / old_laser_factor
                    > 1.0 / line_num
                ):
                    warnings.warn(
                        'refine_settings: laser_factor changed '
                        f'from {old_laser_factor} to {new_factor}',
                        stacklevel=2,
                    )

        return changed

    def frames(
        self,
        records: tuple[NDArray[Any], NDArray[Any], NDArray[Any]] | None = None,
        /,
        *,
        select_frames: slice | None = None,
        aspect_range: tuple[float, float] = (0.8, 1.2),
        frame_cluster: int = 0,
        refine: bool | None = True,
        **kwargs: Any,
    ) -> tuple[tuple[int, int], NDArray[Any]]:
        """Return shape and start/stop indices of scanner frames.

        If unable to detect any frames using the default settings, try to
        determine a correction factor from clusters of frame durations.

        Parameters:
            records:
                Bins, times, and markers from decode function.
                By default, call :py:meth:`FbdFile.decode`.
            select_frames:
                Specifies which image frames to return.
                By default, all frames are returned.
            aspect_range:
                Minimum and maximum aspect ratios of valid frames.
                The default lets 1:1 aspect pass.
            frame_cluster:
                Index of the frame duration cluster to use when
                calculating the correction factor.
            refine:
                Refine settings: True=always, None=if needed, False=never.
            **kwargs:
                Additional arguments passed to :py:meth:`FbdFile.decode`.

        Returns:
            1. shape: Dimensions of scanner frame.
            2. frame_markers: Start and stop indices of detected image frames.

        """
        if records is None:
            records = self.decode(**kwargs)
        times, markers = records[-2:]

        if len(markers) < 2:
            msg = 'not enough markers to detect frames'
            raise ValueError(msg)

        line_time = self.scanner_line_length * self.units_per_sample
        if line_time <= 0:
            msg = f'{line_time=!r} <= 0'
            raise ValueError(msg)
        frame_durations = numpy.ediff1d(times[markers])

        frame_markers = []
        valid_durations: list[float] = []
        if aspect_range:
            # detect frame markers assuming correct settings
            line_num = sys.maxsize
            for i, duration in enumerate(frame_durations):
                lines = duration / line_time
                if lines <= 0:
                    continue
                aspect = self.frame_size / lines
                if aspect_range[0] < aspect < aspect_range[1]:
                    frame_markers.append(
                        (int(markers[i]), int(markers[i + 1]) - 1)
                    )
                    valid_durations.append(float(duration))
                else:
                    continue
                line_num = min(line_num, lines)
            line_num = round(line_num)

        if refine is True and self.refine_settings(
            records, aspect_range=aspect_range
        ):
            # always refine settings before frame detection
            return self.frames(
                records,
                select_frames=select_frames,
                aspect_range=aspect_range,
                frame_cluster=frame_cluster,
                refine=False,
            )

        if (
            not frame_markers
            and refine is None
            and self.refine_settings(records, aspect_range=aspect_range)
        ):
            # refine settings as fallback when no frames detected
            return self.frames(
                records,
                select_frames=select_frames,
                aspect_range=aspect_range,
                frame_cluster=frame_cluster,
                refine=False,
            )

        if not frame_markers:
            # calculate frame duration clusters, assuming few clusters that
            # are narrower and more separated than cluster_size.
            cluster_size = 1024
            clusters: list[list[int]] = []
            cluster_indices = []
            for d in frame_durations:
                d_int = int(d)
                for i, c in enumerate(clusters):
                    if abs(d_int - c[0]) < cluster_size:
                        cluster_indices.append(i)
                        c[0] = min(c[0], d_int)
                        c[1] += 1
                        break
                else:
                    cluster_indices.append(len(clusters))
                    clusters.append([d_int, 1])
            if not clusters:
                msg = 'no frame duration clusters found'
                raise ValueError(msg)
            # sort by count descending, keeping track of original indices
            order = sorted(
                range(len(clusters)),
                key=lambda k: clusters[k][1],
                reverse=True,
            )
            clusters = [clusters[k] for k in order]
            # remap cluster_indices so they refer to the new sorted positions
            remap = {orig: new for new, orig in enumerate(order)}
            cluster_indices = [remap[c] for c in cluster_indices]
            # possible correction factors, assuming square frame shape
            line_num = self.frame_size
            laser_factors = [c[0] / (line_time * line_num) for c in clusters]
            # select specified frame cluster
            frame_cluster = min(frame_cluster, len(laser_factors) - 1)
            self.laser_factor = laser_factors[frame_cluster]
            frame_markers = [
                (int(markers[i]), int(markers[i + 1]) - 1)
                for i, c in enumerate(cluster_indices)
                if c == frame_cluster
            ]
            msgs = [
                'no frames detected with default settings. '
                'Using square shape and correction factor '
                f'{self.laser_factor:.5f}.'
            ]
            if len(laser_factors) > 1:
                factors = ', '.join(f'{i:.5f}' for i in laser_factors[:4])
                msgs.append(
                    f'The most probable correction factors are: {factors}'
                )
            warnings.warn('\n'.join(msgs), stacklevel=2)

        if not isinstance(select_frames, slice):
            select_frames = slice(select_frames)
        frame_markers = frame_markers[select_frames]
        if not frame_markers:
            msg = 'no frames selected'
            raise ValueError(msg)
        return (
            (line_num, self.scanner_line_length),
            numpy.asarray(frame_markers, dtype=numpy.intp),
        )

    def asimage(
        self,
        records: tuple[NDArray[Any], NDArray[Any], NDArray[Any]] | None = None,
        frames: tuple[tuple[int, int], NDArray[Any]] | None = None,
        /,
        *,
        integrate_frames: int = 1,
        square_frame: bool = True,
        num_threads: int = 0,
        **kwargs: Any,
    ) -> NDArray[numpy.uint16]:
        """Return image histograms from decoded records and detected frames.

        This function may fail to produce expected results when settings
        were recorded incorrectly, scanner and FLIMbox frequencies were out
        of sync, or scanner settings were changed during acquisition.

        Parameters:
            records:
                Bins, times, and markers from decode function.
                By default, call :py:meth:`FbdFile.decode`.
            frames:
                ``scanner_shape`` and ``frame_markers`` returned by
                :py:meth:`FbdFile.frames`.
                By default, call :py:meth:`FbdFile.frames`.
            integrate_frames:
                Specifies which frames to sum. By default, all frames are
                summed into one. If 0, no frames are summed.
            square_frame:
                If True, return square image (frame_size x frame_size),
                else return full scanner frame.
            num_threads:
                Number of OpenMP threads to use for parallelization.
            **kwargs:
                Additional arguments passed to
                :py:meth:`FbdFile.decode` and
                :py:meth:`FbdFile.frames`, including ``refine``
                (True=always, None=if needed, False=never).

        Returns:
            Image histogram of shape (number of frames, channels in bins
            array, lines per frame, scanner samples per line,
            histogram bins). If ``square_frame`` is True, both lines per frame
            and scanner samples per line equal ``frame_size``; otherwise they
            equal the detected line count and ``scanner_line_length``
            respectively.

        """
        kwargs_frames = parse_kwargs(
            kwargs,
            'select_frames',
            'aspect_range',
            'frame_cluster',
            'refine',
        )
        if records is None:
            records = self.decode(num_threads=num_threads, **kwargs)
        bins, times, _markers = records
        if frames is None:
            frames = self.frames(records, **kwargs_frames)
        scanner_shape, frame_markers = frames
        # capture units_per_sample after frames() may have updated laser_factor
        units_per_sample = self.units_per_sample
        if integrate_frames and integrate_frames > len(frame_markers):
            msg = (
                f'{integrate_frames=} exceeds '
                f'number of detected frames ({len(frame_markers)})'
            )
            raise ValueError(msg)
        # an extra line to scanner frame to allow clipping indices
        scanner_shape = scanner_shape[0] + 1, scanner_shape[1]
        # allocate output array of scanner frame shape
        shape = (
            integrate_frames or len(frame_markers),
            bins.shape[0],  # channels
            scanner_shape[0] * scanner_shape[1],
            self.pmax // self.pdiv,
        )
        result = numpy.zeros(shape, dtype=numpy.uint16)
        # calculate frame data histogram
        fbd_histogram(
            bins,
            times,
            frame_markers,
            units_per_sample,
            self.scanner_frame_start,
            result,
            num_threads,
        )
        # reshape frames and slice valid region
        result = result.reshape(shape[:2] + scanner_shape + shape[-1:])
        if square_frame:
            line_num = scanner_shape[0] - 1  # undo the +1 guard row
            if line_num < self.frame_size:
                warnings.warn(
                    f'square_frame=True: detected {line_num} lines is less '
                    f'than {self.frame_size=}; output will contain zeros',
                    stacklevel=2,
                )
            result = result[
                ...,
                : self.frame_size,
                self.scanner_line_start : self.scanner_line_start
                + self.frame_size,
                :,
            ]
        return result

    @override
    def __str__(self) -> str:
        info = [repr(self)]
        info.extend(f'{a}: {getattr(self, a)}' for a in self._attributes)
        if self.fbf is not None:
            info.append(
                indent(
                    'firmware:',
                    *(f'{k}: {v}'[:64] for k, v in self.fbf.items()),
                )
            )
        if self.header is not None:
            info.append(
                indent(
                    'header:',
                    *(
                        f'{k}: {self.header[k]}'[:64]
                        for k, _ in (
                            self._effective_header_t or FbdFile._header_t
                        )
                        if k[0] != '_'
                    ),
                )
            )
        info.append(
            indent(
                'decoder_settings:',
                *(
                    (
                        f'{k}: {v:#x}'
                        if 'mask' in k
                        else f'{k}: {v}'[:64].splitlines()[0]
                    )
                    for k, v in self.decoder_settings.items()
                ),
            )
        )
        return indent(*info)

    def plot(self, *, show: bool = True) -> None:
        """Plot histogram and image for all channels."""
        from matplotlib import pyplot
        from tifffile import imshow

        assert self.pmax is not None
        assert self.pdiv is not None

        fig = pyplot.figure(facecolor='w')
        ax = fig.add_subplot(1, 1, 1)
        ax.set_title(self.name)
        ax.set_xlabel('Bin')
        ax.set_ylabel('Counts')
        ax.set_xlim((0, self.pmax // self.pdiv - 1))
        bins, times, markers = self.decode()
        bins_channel: Any  # for mypy
        for ch, bins_channel in enumerate(bins):
            histogram = numpy.bincount(bins_channel[bins_channel >= 0])
            ax.plot(histogram, label=f'Ch{ch}')
        ax.legend()
        pyplot.tight_layout()

        image = self.asimage((bins, times, markers))
        if image.sum(dtype=numpy.uint32) > 0:
            img = image.sum(axis=(0, -1), dtype=numpy.uint32)
            imshow(
                img,
                title=self.name,
                photometric='minisblack',
                interpolation='nearest',
                show=False,
            )

        if show:
            pyplot.show()


def fbs_read(file: str | os.PathLike[str] | IO[str], /) -> dict[str, Any]:
    """Return metadata from FLIMbox settings (FBS.XML) file.

    VistaVision FBS.XML files contain FLIMbox acquisition settings in XML
    format.

    Parameters:
        file:
            Name of file to open, or seekable text stream.

    Examples:
        >>> fbs = fbs_read('tests/data/flimbox_settings.fbs.xml')
        >>> fbs['ScanParams']['ExcitationFrequency']
        20000000

    """
    fh: IO[str]
    if isinstance(file, (str, os.PathLike)):
        fh = open(file, encoding='utf-8')  # noqa: SIM115
        close = True
    elif hasattr(file, 'seek'):
        fh = file
        close = False
        fh.seek(0)
    else:
        msg = f'cannot open file of type {type(file)}'
        raise ValueError(msg)

    try:
        buf = fh.read(100)
        if not ('<?xml ' in buf and '<FastFlimFbdDataSettings>' in buf):
            msg = 'invalid FBS file format'
            raise ValueError(msg)
        fh.seek(0)
        buf = fh.read()
    finally:
        if close:
            with contextlib.suppress(Exception):
                fh.close()

    info = xml2dict(buf)
    result: dict[str, Any] = info['FastFlimFbdDataSettings']
    if not result:
        msg = 'no FastFlimFbdDataSettings element found'
        raise ValueError(msg)
    return result


def fbf_read(
    file: str | os.PathLike[str] | IO[bytes],
    /,
    *,
    firmware: bool = False,
    maxheaderlength: int = 1024,
) -> dict[str, Any]:
    """Return metadata from FLIMbox device firmware (FBF) file.

    FBF files contain FLIMbox device firmwares, stored in binary form
    following a NULL terminated ASCII string containing properties and
    description.

    Parameters:
        file:
            Name of FBF file to open, or seekable binary stream.
        firmware:
            Include the firmware binary data.
        maxheaderlength:
            Maximum length of ASCII header.

    Examples:
        >>> header = fbf_read('tests/data/flimbox_firmware.fbf')
        >>> header['windows']
        16
        >>> header['channels']
        2
        >>> header['secondharmonic']
        0
        >>> 'extclk' in header
        True

    """
    fh: IO[bytes]
    if isinstance(file, (str, os.PathLike)):
        fh = open(file, 'rb')  # noqa: SIM115
        close = True
    elif hasattr(file, 'seek'):
        fh = file
        close = False
        fh.seek(0)
    else:
        msg = f'cannot open file of type {type(file)}'
        raise ValueError(msg)

    try:
        try:
            # the first 1024 bytes contain the header
            buffer = fh.read(maxheaderlength).split(b'\x00', 1)[0]
        except ValueError as exc:
            msg = 'invalid FBF header'
            raise ValueError(msg) from exc
        header = bytes2str(buffer)
        meta = fbf_parse_header(header)
        if not meta:
            msg = 'failed to parse FBF header'
            raise ValueError(msg)
        if firmware:
            if len(header) != maxheaderlength:
                fh.seek(len(header) + 1)
            meta['firmware'] = fh.read()
    finally:
        if close:
            with contextlib.suppress(Exception):
                fh.close()
    return meta


def fbf_parse_header(header: str, /) -> dict[str, Any]:
    """Return FLIMbox firmware settings from header."""
    settings: dict[str, Any] = {}
    try:
        header, comment_ = header.rsplit('/', 1)
        comment = [comment_]
    except ValueError:
        comment = []
    for matches in re.findall(r'([a-zA-Z\s]*)([.\d]*)([a-zA-Z\d]*)/', header):
        name, value, unit = matches
        name = name.strip().lower()
        if not name:
            name = {'w': 'windows', 'ch': 'channels'}.get(unit)
            unit = ''
        if not name:
            comment.append(name + value + unit)
            continue
        if unit == 'MHz':
            unit = 1000000
        try:
            value = (int(value) * int(unit)) if unit else int(value)
            unit = 0
        except ValueError:
            pass
        settings[name] = (value + unit) if value != '' else True
    cstr = '/'.join(reversed(comment))
    if cstr:
        settings['comment'] = cstr
    return settings


def _fbd_decode(
    data: Any,
    bins_out: Any,
    times_out: Any,
    markers_out: Any,
    windows: Any,
    pdiv: Any,
    harmonics: Any,
    decoder_table: Any,
    tcc_mask: Any,
    tcc_shr: Any,
    pcc_mask: Any,
    pcc_shr: Any,
    marker_mask: Any,
    marker_shr: Any,
    win_mask: Any,
    win_shr: Any,
) -> None:
    """Decode FLIMbox data stream.

    See the documentation of the FbdFile class for parameter descriptions
    and the _fbdfile.pyx file for a faster implementation.

    This implementation is for reference only. Do not use!

    """
    del marker_shr
    tcc = data & tcc_mask  # cross correlation time
    if tcc_shr:
        tcc >>= tcc_shr
    times_out[:] = tcc
    times_out[times_out == 0] = (tcc_mask >> tcc_shr) + 1
    times_out[0] = 0
    times_out[1:] -= tcc[:-1]
    del tcc
    numpy.cumsum(times_out, out=times_out)

    markers = data & marker_mask
    markers = numpy.diff(markers.view(numpy.int16))
    markers = numpy.where(markers > 0)[0]  # trigger up
    markers += 1
    size = min(len(markers), len(markers_out))
    markers_out[:size] = markers[:size]
    del markers

    if win_mask != 0xFFFF:  # window index
        win = data & win_mask
        win >>= win_shr
    else:
        win = data >> win_shr
    win = decoder_table.take(win, axis=1)
    nophoton = win == -1
    pmax = ((pcc_mask >> pcc_shr) + 1) // harmonics
    win *= pmax // windows * harmonics
    pcc = data & pcc_mask  # cross correlation phase
    if pcc_shr:
        pcc >>= pcc_shr
    win += pcc
    del pcc
    win %= pmax
    win += 1 - pmax
    numpy.negative(win, win)
    if pdiv > 1:
        win //= pdiv
    win = win.astype(numpy.int8)
    win[nophoton] = -1
    bins_out[:] = win


def _fbd_histogram(
    bins: Any,
    times: Any,
    frame_markers: Any,
    units_per_sample: Any,
    scanner_frame_start: Any,
    out: Any,
) -> None:
    """Calculate histograms from decoded FLIMbox data and frame markers.

    See the documentation of the FbdFile class for parameter descriptions
    and the _fbdfile.pyx file for a much faster implementation.

    This implementation is for reference only. Do not use!

    """
    if units_per_sample <= 0:
        msg = f'{units_per_sample=!r} <= 0'
        raise ValueError(msg)
    nframes, nchannels, frame_length, nwindows = out.shape
    for i, (j, k) in enumerate(frame_markers):
        f = i % nframes
        t = times[j:k] - times[j]
        t /= units_per_sample  # index into flattened array
        if scanner_frame_start:
            t -= scanner_frame_start
        t = t.astype(numpy.uint32, copy=False)
        numpy.clip(t, 0, frame_length - 1, out=t)
        for c in range(nchannels):
            d = bins[c, j:k]
            for w in range(nwindows):
                x = numpy.where(d == w)[0]
                x = t.take(x)
                x = numpy.bincount(x, minlength=frame_length)
                out[f, c, :, w] += x


def fbd_to_b64(
    fbdfile: str | os.PathLike[Any],
    /,
    b64files: str = '{filename}_c{channel:02}t{frame:04}.b64',
    *,
    integrate_frames: int = 0,
    square_frame: bool = True,
    pdiv: int = -1,
    laser_frequency: float = -1,
    laser_factor: float = -1.0,
    pixel_dwell_time: float = -1.0,
    frame_size: int = -1,
    scanner_line_length: int = -1,
    scanner_line_start: int = -1,
    scanner_frame_start: int = -1,
    cmap: str = 'turbo',
    verbose: bool = True,
    show: bool = True,
) -> None:
    """Convert SimFCS FLIMbox data file to B64 files.

    Parameters:
        fbdfile:
            FLIMbox data file to convert.
        b64files:
            Format string for B64 output file names.
        integrate_frames:
            Specifies which frames to sum. By default, no frames are summed.
        square_frame:
            If True, crop to square image (frame_size x frame_size).
            Else keep the full scanner frame.
        pdiv:
            Divisor to reduce number of entries in phase histogram.
        laser_frequency:
            Laser frequency in Hz.
        laser_factor:
            Factor to correct dwell_time/laser_frequency.
        pixel_dwell_time:
            Number of microseconds the scanner remains at each pixel.
        frame_size:
            Number of pixels in one line scan, excluding retrace.
        scanner_line_length:
            Number of pixels in each line, including retrace.
        scanner_line_start:
            Index of first valid pixel in scan line.
        scanner_frame_start:
            Index of first valid pixel after marker.
        cmap:
            Matplotlib colormap for plotting images.
        verbose:
            Print detailed information about file and conversion.
        show:
            If True, plot (but do not write) the decoded frames.
            If False, one B64 files is written for each frame and channel.

    """
    prints: Any = print if verbose else nullfunc

    with FbdFile(
        fbdfile,
        pdiv=pdiv,
        laser_frequency=laser_frequency,
        laser_factor=laser_factor,
        pixel_dwell_time=pixel_dwell_time,
        frame_size=frame_size,
        scanner_line_length=scanner_line_length,
        scanner_line_start=scanner_line_start,
        scanner_frame_start=scanner_frame_start,
    ) as fbd:
        prints(fbd)
        records = fbd.decode()
        bins, times, markers = records
        # prints(records)
        frames = fbd.frames(records)
        # prints(frames)
        image = fbd.asimage(
            records,
            frames,
            integrate_frames=integrate_frames,
            square_frame=square_frame,
        )
        # prints(image)
        image = numpy.moveaxis(image, -1, 2)
        times_ = times / (fbd.laser_frequency * fbd.laser_factor)
        if show:
            from matplotlib import pyplot
            from tifffile import imshow

            pyplot.figure()
            pyplot.title('Bins and Markers')
            pyplot.xlabel('time (s)')
            pyplot.ylabel('Cross correlation phase index')
            pyplot.plot(times_, bins[0], '.', alpha=0.5, label='Ch0')
            pyplot.vlines(
                x=times_[markers],
                ymin=-1,
                ymax=1,
                colors='red',
                label='Markers',
            )
            pyplot.legend()

            pyplot.figure()
            pyplot.title('Cross correlation phase histogram')
            pyplot.xlabel('Bin')
            pyplot.ylabel('Counts')
            bins_channel: Any  # for mypy
            for ch, bins_channel in enumerate(records[0]):
                histogram = numpy.bincount(bins_channel[bins_channel >= 0])
                pyplot.plot(histogram, label=f'Ch{ch}')
            pyplot.legend()

            if not integrate_frames and image.shape[0] > 1:
                image = numpy.moveaxis(image, 0, 1)
                title = 'Photons[Channel, Frame, Bin, Y, X]'
            else:
                title = 'Photons[Channel, Bin, Y, X]'
            imshow(
                image,
                vmax=None,
                photometric='minisblack',
                cmap=cmap,
                title=title,
            )
            pyplot.show()
        else:
            prints()
            for j in range(image.shape[0]):
                for i in range(image.shape[1]):
                    b64name = b64files.format(
                        filename=fbdfile, channel=i, frame=j
                    )
                    prints(b64name, flush=True)
                    b64_write(
                        b64name, image[j, i].astype(numpy.int16, copy=False)
                    )


def b64_write(
    filename: os.PathLike[Any] | str,
    data: ArrayLike,
    /,
) -> None:
    """Write array of square int16 images to B64 file."""
    data = numpy.asarray(data)
    if data.dtype.char != 'h':
        msg = f'{data.dtype=} != int16'
        raise ValueError(msg)
    # TODO: write carpet
    if data.ndim != 3 or data.shape[1] != data.shape[2]:
        msg = f'{data.shape=} != (frames, size, size)'
        raise ValueError(msg)
    with open(filename, 'wb') as fh:
        fh.write(struct.pack('I', data.shape[-1]))
        data.tofile(fh)


def parse_kwargs(
    kwargs: dict[str, Any],
    /,
    *keys: str,
    _del: bool = True,
    **keyvalues: Any,
) -> dict[str, Any]:
    """Return a dict with selected values from ``kwargs`` and defaults.

    Examples:
        >>> kwargs = {'one': 1, 'two': 2, 'four': 4}
        >>> kwargs2 = parse_kwargs(kwargs, 'two', 'three', four=None, five=5)
        >>> kwargs == {'one': 1}
        True
        >>> kwargs2 == {'two': 2, 'four': 4, 'five': 5}
        True

    """
    result = {}
    for key in keys:
        if key in kwargs:
            result[key] = kwargs[key]
            if _del:
                del kwargs[key]
    for key, value in keyvalues.items():
        if key in kwargs:
            result[key] = kwargs[key]
            if _del:
                del kwargs[key]
        else:
            result[key] = value
    return result


def format_dict(
    adict: dict[str, Any],
    /,
    *,
    prefix: str = '',
    indent: str = '  ',
    bullets: tuple[str, str] = ('', ''),
    excludes: Sequence[str] = ('_',),
    linelen: int = 79,
    trim: int = 0,
) -> str:
    """Return pretty-print of nested dictionary."""
    result = []
    for k, v in sorted(adict.items(), key=lambda x: str(x[0]).lower()):
        if any(k.startswith(e) for e in excludes):
            continue
        if isinstance(v, dict):
            w = '\n' + format_dict(
                v, prefix=prefix + indent, excludes=excludes, trim=0
            )
            result.append(f'{prefix}{bullets[1]}{k}: {w}')
        else:
            result.append((f'{prefix}{bullets[0]}{k}: {v}')[:linelen].rstrip())
    if trim > 0:
        result[0] = result[0][trim:]
    return '\n'.join(result)


def nullfunc(*args: Any, **kwargs: Any) -> None:
    """Do nothing."""
    del args, kwargs


def stripnull(string: bytes) -> bytes:
    r"""Return byte string truncated at first null character.

    Use to clean NULL terminated C strings.

    >>> stripnull(b'bytes\x00\x00b')
    b'bytes'

    """
    i = string.find(b'\x00')
    return string if i < 0 else string[:i]


def bytes2str(
    b: bytes, /, encoding: str | None = None, errors: str = 'strict'
) -> str:
    """Return Unicode string from encoded bytes up to first NULL character."""
    if encoding is None or '16' not in encoding:
        i = b.find(b'\x00')
        if i >= 0:
            b = b[:i]
    else:
        # utf-16
        i = b.find(b'\x00\x00')
        if i >= 0:
            b = b[: i + i % 2]

    try:
        return b.decode('utf-8' if encoding is None else encoding, errors)
    except UnicodeDecodeError:
        if encoding is not None:
            raise
        return b.decode('cp1252', errors)


def xml2dict(
    xml: str,
    /,
    *,
    sanitize: bool = True,
    prefix: tuple[str, str] | None = None,
    exclude: Container[str] | None = None,
    sep: str = ',',
) -> dict[str, Any]:
    """Return XML as dictionary.

    Parameters:
        xml: XML data to convert.
        sanitize: Remove prefix from etree element.
        prefix: Prefixes for dictionary keys.
        exclude: Ignore element tags.
        sep: Sequence separator.

    Returns:
        dict: Dictionary representation of XML element.

    """
    from collections import defaultdict
    from xml.etree import ElementTree

    at, tx = prefix or ('', '')
    exclude = set() if exclude is None else exclude

    def astype(value: Any, /) -> Any:
        # return string value as int, float, bool, tuple, or unchanged
        if not isinstance(value, str):
            return value
        if sep and sep in value:
            # sequence of numbers?
            values = []
            for val in value.split(sep):
                v = astype(val)
                if isinstance(v, str):
                    return value
                values.append(v)
            return tuple(values)
        for t in (int, float, asbool):
            try:
                return t(value)
            except (TypeError, ValueError):
                pass
        return value

    def etree2dict(t: ElementTree.Element, /) -> dict[str, Any] | None:
        # adapted from https://stackoverflow.com/a/10077069/453463
        key = t.tag
        if sanitize:
            key = key.rsplit('}', 1)[-1]
        if key in exclude:
            return None
        d: dict[str, Any] = {key: {} if t.attrib else None}
        children = list(t)
        if children:
            dd = defaultdict(list)
            for dc in map(etree2dict, children):
                if dc is not None:
                    for k, v in dc.items():
                        dd[k].append(astype(v))
            d = {
                key: {
                    k: astype(v[0]) if len(v) == 1 else astype(v)
                    for k, v in dd.items()
                }
            }
        if t.attrib:
            d[key].update((at + k, astype(v)) for k, v in t.attrib.items())
        if t.text:
            text = t.text.strip()
            if children or t.attrib:
                if text:
                    d[key][tx + 'value'] = astype(text)
            else:
                d[key] = astype(text)
        return d

    result = etree2dict(ElementTree.fromstring(xml))  # noqa: S314
    return {} if result is None else result


def asbool(
    value: str,
    /,
    true: Sequence[str] | None = None,
    false: Sequence[str] | None = None,
) -> bool:
    """Return string as boolean if possible, else raise TypeError.

    >>> asbool('ON', ['on'], ['off'])
    True

    """
    value = value.strip().lower()
    if true is None:
        if value == 'true':
            return True
    elif value in true:
        return True
    if false is None:
        if value == 'false':
            return False
    elif value in false:
        return False
    raise TypeError


def indent(*args: Any) -> str:
    """Return joined string representations of objects with indented lines."""
    text = '\n'.join(str(arg) for arg in args)
    return '\n'.join(
        ('  ' + line if line else line) for line in text.splitlines() if line
    )[2:]


def logger() -> logging.Logger:
    """Return logger for fbdfile module."""
    return logging.getLogger('fbdfile')


def askopenfilename(**kwargs: Any) -> str:
    """Return file name from Tkinter's file open dialog."""
    from tkinter import Tk, filedialog

    root = Tk()
    root.withdraw()
    root.update()
    filenames = filedialog.askopenfilename(**kwargs)
    root.destroy()
    return filenames


def main() -> int:
    """Run the command-line interface and return the exit code."""
    import click

    @click.group()
    @click.version_option(version=__version__)
    def cli() -> None:
        pass

    # @cli.command(help='Convert files to TIFF.')
    # @click.option(
    #     '--format',
    #     default='tiff',
    #     help='Output file format.',
    #     type=click.Choice(['tiff']),
    # )
    # @click.option(
    #     '--compress',
    #     default=0,
    #     help='Zlib compression level.',
    #     type=click.IntRange(0, 10, clamp=False),
    # )
    # @click.argument('files', nargs=-1, type=click.Path(dir_okay=False))
    # def convert(format: str, compress: int, files: Any) -> None:
    #     if not files:
    #         files = askopenfilename(
    #             title='Select FBD file(s)',
    #             multiple=True,
    #             filetypes=[('FBD files', '*.FBD')],
    #         )
    #     if files:
    #         convert2tiff(files, compress=compress)

    @cli.command(help='View data in file.')
    @click.argument('files', nargs=-1, type=click.Path(dir_okay=False))
    def view(files: Any) -> None:
        if not files:
            files = askopenfilename(
                title='Select FLIMbox data file(s)',
                filetypes=[('FBD files', '*.FBD')],
            )
        if files:
            if isinstance(files, (list, tuple)):
                files = files[0]
            with FbdFile(files) as fbd:
                print(fbd)  # noqa: T201
                fbd.plot()

    if len(sys.argv) == 1:
        sys.argv.append('view')
    elif len(sys.argv) == 2 and os.path.isfile(sys.argv[1]):
        sys.argv.append(sys.argv[1])
        sys.argv[1] = 'view'

    cli(prog_name='fbdfile')
    return 0


if __name__ == '__main__':
    sys.exit(main())
