pax_global_header00006660000000000000000000000064152645502450014522gustar00rootroot0000000000000052 comment=5f5dc78729197f147164b42baec18a05e79bad39 cgohlke-ptufile-933b7e3/000077500000000000000000000000001526455024500151455ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/.github/000077500000000000000000000000001526455024500165055ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/.github/workflows/000077500000000000000000000000001526455024500205425ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/.github/workflows/wheel.yml000066400000000000000000000037421526455024500223770ustar00rootroot00000000000000name: Build wheels on: workflow_dispatch: jobs: build: name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest, windows-11-arm] steps: - uses: actions/checkout@v7 - uses: pypa/cibuildwheel@v4.3.1 env: # MACOSX_DEPLOYMENT_TARGET: "11.0" # CIBW_ENVIRONMENT: "PIP_PRE=1" CIBW_BUILD_VERBOSITY: 2 # build ABI3 wheels with Python 3.12, and free-threaded wheels for Python >=3.14t CIBW_BUILD: "cp312-* cp314t-* cp315t-*" CIBW_SKIP: "*musllinux* *i686 *ppc64le *s390x" CIBW_ARCHS_LINUX: auto CIBW_ARCHS_MACOS: x86_64 arm64 CIBW_ARCHS_WINDOWS: native CIBW_TEST_REQUIRES: numpy CIBW_TEST_COMMAND: python -c "import ptufile;from ptufile import _ptufile;print(ptufile.__version__)" - uses: actions/setup-python@v7 with: python-version: 3.14 - name: Install packages run: | python -m pip install -U --find-links=wheelhouse ptufile abi3audit - name: Audit ABI3 wheels run: | abi3audit --strict --report wheelhouse/*abi3*.whl - name: Test ABI3 wheels run: | cd .. python -c "import ptufile;from ptufile import _ptufile;print(ptufile.__version__)" - uses: actions/upload-artifact@v7 with: path: ./wheelhouse/*.whl name: wheels-${{ matrix.os }} build_pyodide: name: Build Pyodide wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install cibuildwheel run: python -m pip install cibuildwheel - name: Build wheel run: python -m cibuildwheel --platform pyodide - uses: actions/upload-artifact@v7 with: path: ./wheelhouse/*.whl name: wheels-pyodide cgohlke-ptufile-933b7e3/.gitignore000066400000000000000000000112201526455024500171310ustar00rootroot00000000000000_*.c *.wpr *.wpu .idea # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST setup.cfg PKG-INFO mypy.ini # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py.cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control poetry.lock poetry.toml # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. # https://pdm-project.org/en/latest/usage/project/#working-with-version-control pdm.lock .pdm.toml .pdm-python .pdm-build/ # pixi # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. #pixi.lock # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one # in the .venv directory. It is recommended not to include this directory in version control. .pixi # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .envrc .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # Abstra # Abstra is an AI-powered process automation framework. # Ignore directories containing user credentials, local state, and settings. # Learn more at https://abstra.io/docs .abstra/ # Visual Studio Code # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ # Ruff stuff: .ruff_cache/ # PyPI configuration file .pypirc # Cursor # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore # Marimo marimo/_static/ marimo/_lsp/ __marimo__/ cgohlke-ptufile-933b7e3/CHANGES.rst000066400000000000000000000101461526455024500167510ustar00rootroot00000000000000.. This file is updated by setup.py. Do not edit. Revisions ========= 2026.10.16 - Support z-stack images in PTU reader and writer (#5). - Support sparse tag indices (#5, breaking). - Replace PtuWriter has_frames with dims parameter (breaking). - Add volume_time and global_volume_time properties to PtuFile. - Add plane parameter to PtuFile.decode_image and imread. - Generalize decode_t3_image to 6-dimensional data. 2026.8.8 - Add PQOVER file type. 2026.6.6 - Remove memmap parameter from PtuFile.read_records (breaking). - Return copy of records from PtuFile.read_records by default (breaking). - Add options for memory-mapping and locked reading to BinaryFile. - Add option to memory-map PTU files. - Drop support for numpy 2.0 (SPEC0). - Support Python 3.15. 2026.3.21 - Add bounds checking to encode_t3_image function. - Use format-dispatch in hot decode loops to allow compiler inlining. - Build wheels on Windows with LLVM (30% faster decoding than MSVC). - Drop support for Python 3.11. 2026.2.6 - Fix code review issues. 2026.1.14 - Improve code quality. 2025.12.12 - Add PQUNI file type. - Add attrs properties and return with xarray DataSets. - Improve code quality. 2025.11.8 - Fix reading files with negative TTResult_NumberOfRecords. - Remove cache argument from PtuFile.read_records (breaking). - Add cache_records property to PtuFile to control caching behavior. - Derive PqFileError from ValueError. - Factor out BinaryFile base class. - Build ABI3 wheels. 2025.9.9 - Log error when decoding image with invalid line or frame masks. 2025.7.30 - Add option to specify pixel time for decoding images. - Add functions to read and write PicoQuant BIN files. - Drop support for Python 3.10. 2025.5.10 - Mark Cython extension free-threading compatible. - Support Python 3.14. 2025.2.20 - Rename PqFileMagic to PqFileType (breaking). - Rename PqFile.magic to PqFile.type (breaking). - Add PQDAT and SPQR file types. 2025.2.12 - Add options to specify file open modes to PqFile and PtuFile.read_records. - Add convenience properties to PqFile and PtuFile. - Cache records read from file. 2025.1.13 - Fall back to file size if TTResult_NumberOfRecords is zero (#2). 2024.12.28 - Add imwrite function to encode TCSPC image histogram in T3 PTU format. - Add enums for more PTU tag values. - Add PqFile.datetime property. - Read TDateTime tag as datetime instead of struct_time (breaking). - Rename PtuFile.type property to record_type (breaking). - Fix reading PHU missing HistResDscr_HWBaseResolution tag. - Warn if tags are not 8-byte aligned in file. 2024.12.20 - Support bi-directional sinusoidal scanning (WIP). 2024.11.26 - Support bi-directional scanning (FLIMbee scanner). - Drop support for Python 3.9. 2024.10.10 - Also trim leading channels without photons (breaking). - Add property to identify channels with photons. 2024.9.14 - Improve typing. 2024.7.13 - Detect point scans in image mode. - Deprecate Python 3.9, support Python 3.13. 2024.5.24 - Fix docstring examples not correctly rendered on GitHub. 2024.4.24 - Build wheels with NumPy 2. 2024.2.20 - Change definition of PtuFile.frequency (breaking). - Add option to specify number of bins returned by decode_histogram. - Add option to return histograms of one period. 2024.2.15 - Add PtuFile.scanner property. - Add numcodecs compatible PTU codec. 2024.2.8 - Support sinusoidal scanning correction. 2024.2.2 - Change positive dtime parameter from index to size (breaking). - Fix segfault with ImgHdr_TimePerPixel = 0. - Rename MultiHarp to Generic conforming with changes in PicoQuant reference. 2023.11.16 - Fix empty line when first record is start marker. 2023.11.13 - Change image histogram dimension order to TYXCH (breaking). - Change frame start to start of first line in frame (breaking). - Improve trimming of incomplete frames (breaking). - Remove trim_dtime option (breaking). - Fix selection handling in PtuFile.decode_image. - Add option to trim T, C, and H axes of image histograms. - Add option to decode histograms to memory-mapped or user-provided arrays. - Add ``__getitem__`` interface to image histogram. 2023.11.1 - Initial alpha release. cgohlke-ptufile-933b7e3/LICENSE000066400000000000000000000027711526455024500161610ustar00rootroot00000000000000BSD-3-Clause license Copyright (c) 2023-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. cgohlke-ptufile-933b7e3/MANIFEST.in000066400000000000000000000010661526455024500167060ustar00rootroot00000000000000include LICENSE include README.rst include CHANGES.rst include pyproject.toml include ptufile/py.typed include ptufile/_ptufile.pyx include .github/workflows/wheel.yml exclude .env exclude *.cmd exclude *.yaml exclude mypy.ini exclude ruff.toml exclude *.code-workspace exclude .gitignore recursive-exclude .vscode * recursive-exclude doc * recursive-exclude docs * recursive-exclude test * recursive-exclude tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] recursive-exclude * *Copy* include tests/conftest.py include tests/test_ptufile.py cgohlke-ptufile-933b7e3/README.rst000066400000000000000000000213221526455024500166340ustar00rootroot00000000000000.. This file is generated by setup.py. Do not edit. Read and write PicoQuant PTU and related files ============================================== Ptufile is a Python library to 1. read data and metadata from PicoQuant PTU and related files (PHU, PCK, PCO, PFS, PUS, PQRES, PQDAT, PQUNI, PQOVER, SPQR, and BIN), and 2. write TCSPC histograms to T3 image mode PTU files. PTU files contain time correlated single photon counting (TCSPC) measurement data and instrumentation parameters. :Author: `Christoph Gohlke `_ :License: BSD-3-Clause :Version: 2026.10.16 :DOI: `10.5281/zenodo.10120021 `_ Quickstart ---------- Install the ptufile package and all dependencies from the `Python Package Index `_:: python -m pip install -U "ptufile[all]" See `Examples`_ and `Documentation `_ for using the programming interface. Source code and support are available on `GitHub `_. Requirements ------------ This revision was tested with the following requirements and dependencies (other versions may work): - `CPython `_ 3.12.10, 3.13.16, 3.14.8, 3.15.0 64-bit - `Numpy `_ 2.5.4 - `Xarray `_ 2026.9.0 (recommended) - `Matplotlib `_ 3.11.2 (optional) - `Tifffile `_ 2026.10.15 (optional) - `Numcodecs `_ 0.17.0 (optional) - `Python-dateutil `_ 2.9.0 (optional) - `Cython `_ 3.2.9 (build) Revisions --------- 2026.10.16 - Support z-stack images in PTU reader and writer (#5). - Support sparse tag indices (#5, breaking). - Replace PtuWriter has_frames with dims parameter (breaking). - Add volume_time and global_volume_time properties to PtuFile. - Add plane parameter to PtuFile.decode_image and imread. - Generalize decode_t3_image to 6-dimensional data. 2026.8.8 - Add PQOVER file type. 2026.6.6 - Remove memmap parameter from PtuFile.read_records (breaking). - Return copy of records from PtuFile.read_records by default (breaking). - Add options for memory-mapping and locked reading to BinaryFile. - Add option to memory-map PTU files. - Drop support for numpy 2.0 (SPEC0). - Support Python 3.15. 2026.3.21 - Add bounds checking to encode_t3_image function. - Use format-dispatch in hot decode loops to allow compiler inlining. - Build wheels on Windows with LLVM (30% faster decoding than MSVC). - Drop support for Python 3.11. 2026.2.6 - … Refer to the CHANGES file for older revisions. Notes ----- `PicoQuant GmbH `_ is a manufacturer of photonic components and instruments. The PicoQuant unified file formats are documented at the `PicoQuant-Time-Tagged-File-Format-Demos `_. All PicoQuant unified tagged formats use the same basic layout: a file header followed by typed tags and an optional data block. PTU is the main container for TTTR event streams, whereas related formats store histograms, instrument settings, analysis results, or other auxiliary data. The following features are currently not implemented due to the lack of test files or documentation: PT2 and PT3 files, decoding images from T2 and SPQR formats, bidirectional per frame, and deprecated image reconstruction. Compatibility with PTU files written by non-PicoQuant software (for example, Leica LAS X or Abberior Imspector) is limited, as is decoding line, bidirectional, and sinusoidal scanning. Other modules for reading or writing PicoQuant files are `Read_PTU.py `_, `readPTU `_, `readPTU_FLIM `_, `fastFLIM `_, `PyPTU `_, `PTU_Reader `_, `PTU_Writer `_, `FlimReader `_, `tangy `_, `tttrlib `_, `picoquantio `_, `ptuparser `_, `phconvert `_, `trattoria `_ (wrapper of `trattoria-core `_, `tttr-toolbox `_), `PAM `_, `FLOPA `_, `FLIMKit `_, and `napari-flim-phasor-plotter `_. Examples -------- Import functions and classes used in these examples: .. code-block:: python >>> from ptufile import PqFile, PtuFile, imwrite, imread Read properties and tags from any type of PicoQuant unified tagged file: .. code-block:: python >>> pq = PqFile('tests/data/Settings.pfs') >>> pq.type >>> pq.guid UUID('86d428e2-cb0b-4964-996c-04456ba6be7b') >>> pq.tags {...'CreatorSW_Name': 'SymPhoTime 64', 'CreatorSW_Version': '2.1'...} >>> pq.close() Read metadata from a PicoQuant PTU FLIM file: .. code-block:: python >>> ptu = PtuFile('tests/data/FLIM.ptu') >>> ptu.type >>> ptu.record_type >>> ptu.measurement_mode >>> ptu.measurement_submode Decode TTTR records from the PTU file to ``numpy.recarray``: .. code-block:: python >>> decoded = ptu.decode_records() >>> decoded.dtype dtype([('time', '>> decoded['time'][(decoded['marker'] & ptu.frame_change_mask) > 0] array([1571185680], dtype=uint64) Decode TTTR records to overall delay-time histograms per channel: .. code-block:: python >>> ptu.decode_histogram(dtype='uint8') array([[ 5, 7, 7, ..., 10, 9, 2]], shape=(2, 3126), dtype=uint8) Get information about the FLIM image histogram in the PTU file: .. code-block:: python >>> ptu.shape (1, 256, 256, 2, 3126) >>> ptu.dims ('T', 'Y', 'X', 'C', 'H') >>> ptu.coords {'T': ..., 'Y': ..., 'X': ..., 'H': ...} >>> ptu.dtype dtype('uint16') >>> ptu.active_channels (0, 1) Decode parts of the image histogram to ``numpy.ndarray`` using slice notation. Slice step sizes define binning, -1 being used to integrate along axis: .. code-block:: python >>> ptu[:, ..., 0, ::-1] array([[[103, ..., 38], ... [ 47, ..., 30]]], shape=(1, 256, 256), dtype=uint16) Alternatively, decode the first channel and integrate all histogram bins into a ``xarray.DataArray``, keeping reduced axes: .. code-block:: python >>> ptu.decode_image(channel=0, dtime=-1, asxarray=True) ... array([[[[[103]], ... [[ 30]]]]], shape=(1, 256, 256, 1, 1), dtype=uint16) Coordinates: * T (T) float64... 0.05625 * Y (Y) float64... -0.0001304 ... 0.0001294 * X (X) float64... -0.0001304 ... 0.0001294 * C (C) uint8... 0 * H (H) float64... 0.0 Attributes... name: FLIM.ptu ... Write the TCSPC histogram and metadata to a PicoHarpT3 image mode PTU file: .. code-block:: python >>> imwrite( ... '_test.ptu', ... ptu[:], ... ptu.global_resolution, ... ptu.tcspc_resolution, ... # optional metadata ... pixel_time=ptu.pixel_time, ... record_type=PtuRecordType.PicoHarpT3, ... comment='Written by ptufile.py', ... tags={'File_RawData_GUID': [ptu.guid]}, ... ) Read back the TCSPC histogram from the file: .. code-block:: python >>> tcspc_histogram = imread('_test.ptu') >>> import numpy >>> numpy.array_equal(tcspc_histogram, ptu[:]) True Close the file handle: .. code-block:: python >>> ptu.close() Preview the image and metadata in a PTU file from the console:: python -m ptufile tests/data/FLIM.ptucgohlke-ptufile-933b7e3/ptufile/000077500000000000000000000000001526455024500166155ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/ptufile/__init__.py000066400000000000000000000005361526455024500207320ustar00rootroot00000000000000# ptufile/__init__.py from .ptufile import * from .ptufile import __all__, __doc__, __version__ # constants are repeated for documentation __version__ = __version__ """Ptufile version string.""" T2_RECORD_DTYPE = T2_RECORD_DTYPE """Numpy dtype of decoded T2 records.""" T3_RECORD_DTYPE = T3_RECORD_DTYPE """Numpy dtype of decoded T3 records.""" cgohlke-ptufile-933b7e3/ptufile/__main__.py000066400000000000000000000001731526455024500207100ustar00rootroot00000000000000# ptufile/__main__.py """ptufile package command line script.""" import sys from .ptufile import main sys.exit(main()) cgohlke-ptufile-933b7e3/ptufile/_ptufile.pyx000066400000000000000000001621311526455024500211720ustar00rootroot00000000000000# _ptufile.pyx # distutils: language = c # cython: boundscheck = False # cython: wraparound = False # cython: cdivision = True # cython: nonecheck = False # cython: freethreading_compatible = True # Copyright (c) 2023-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. """Decode PicoQuant Time-Tagged Time-Resolved (TTTR) records.""" from libc.math cimport round from libc.stdint cimport ( UINT64_MAX, int8_t, int16_t, uint8_t, uint16_t, uint32_t, uint64_t, ) cdef packed struct t2_t: uint64_t time int8_t channel uint8_t marker # uint8_t[6] _align cdef packed struct t3_t: uint64_t time int16_t dtime int8_t channel uint8_t marker # uint8_t[4] _align ctypedef uint32_t (*encode_func_t)( const uint32_t time, const uint32_t dtime, const uint32_t channel, const uint32_t overflow, const uint32_t marker ) noexcept nogil ctypedef void (*decode_func_t)( const uint32_t record, uint32_t* time, uint32_t* dtime, uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil ctypedef fused uint_t: uint8_t uint16_t uint32_t uint64_t cdef int init_format( const uint32_t format, decode_func_t* decode, ssize_t* bins, # number_bins_max ssize_t* channels, # number_channels_max ) noexcept nogil: if format == 0x00010303: # PicoHarpT3/PicoHarp300 decode[0] = decode_pt3 bins[0] = 4096 channels[0] = 4 elif format == 0x00010203: # PicoHarpT2/PicoHarp300 decode[0] = decode_pt2 bins[0] = 0 channels[0] = 5 # ? elif format in { 0x01010204, # HydraHarp2T2 0x00010205, # TimeHarp260NT2 0x00010206, # TimeHarp260PT2 0x00010207, # GenericT2 (MultiHarpT2 and Picoharp330T2) }: decode[0] = decode_ht2 bins[0] = 0 channels[0] = 64 elif format == 0x00010204: # HydraHarpT2 decode[0] = decode_ht2v1 bins[0] = 0 channels[0] = 64 elif format in { 0x01010304, # HydraHarp2T3 0x00010305, # TimeHarp260NT3 0x00010306, # TimeHarp260PT3 0x00010307, # GenericT3 (MultiHarpT3 and Picoharp330T3) }: decode[0] = decode_ht3 bins[0] = 32768 channels[0] = 64 elif format == 0x00010304: # HydraHarpT3 decode[0] = decode_ht3v1 bins[0] = 32768 channels[0] = 64 else: return 1 return 0 def decode_info( const uint32_t[::1] records, const uint32_t format, const uint32_t line_start, const uint32_t line_stop, const uint32_t frame_change, const ssize_t lines_in_frame, # may be zero to disable frame trimming ): """Return information about PicoQuant TTTR records.""" cdef: ssize_t nrecords = records.size ssize_t i, maxchannels, maxbins ssize_t channels_active_first, channels_active_last ssize_t y = 0 ssize_t skip_first_frame = 0 ssize_t skip_last_frame = 0 uint64_t nbins = 0 uint64_t nframes = 0 uint64_t nphotons = 0 uint64_t nmarkers = 0 uint64_t nlines = 0 uint64_t overflow = 0 uint64_t time_line_start = UINT64_MAX uint64_t time_in_lines = 0 uint64_t channels_active = 0 uint32_t itime = 0 uint32_t idtime, ichannel uint8_t ispecial, imarker with nogil: # dispatch on format so compiler can inline decoder and optimize loop if format == 0x00010303: # PicoHarpT3/PicoHarp300 maxbins = 4096 maxchannels = 4 for i in range(nrecords): decode_pt3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial, ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) elif format == 0x00010203: # PicoHarpT2/PicoHarp300 maxbins = 0 maxchannels = 5 # ? for i in range(nrecords): decode_pt2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial, ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) elif format in { 0x01010204, # HydraHarp2T2 0x00010205, # TimeHarp260NT2 0x00010206, # TimeHarp260PT2 0x00010207, # GenericT2 (MultiHarpT2 and Picoharp330T2) }: maxbins = 0 maxchannels = 64 for i in range(nrecords): decode_ht2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) elif format == 0x00010204: # HydraHarpT2 maxbins = 0 maxchannels = 64 for i in range(nrecords): decode_ht2v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial, ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) elif format in { 0x01010304, # HydraHarp2T3 0x00010305, # TimeHarp260NT3 0x00010306, # TimeHarp260PT3 0x00010307, # GenericT3 (MultiHarpT3 and Picoharp330T3) }: maxbins = 32768 maxchannels = 64 for i in range(nrecords): decode_ht3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial, ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) elif format == 0x00010304: # HydraHarpT3 maxbins = 32768 maxchannels = 64 for i in range(nrecords): decode_ht3v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial, ) decode_info_record( itime, idtime, ichannel, overflow, imarker, ispecial, line_start, line_stop, frame_change, lines_in_frame, maxchannels, maxbins, &nphotons, &nbins, &nmarkers, &nlines, &nframes, &channels_active, &time_in_lines, &time_line_start, &y, &skip_first_frame, &skip_last_frame, ) else: with gil: msg = f'no decoder available for {format=:02x}' raise ValueError(msg) channels_active_first = 64 channels_active_last = 0 for i in range(64): if channels_active & (( 1) << i): if i < channels_active_first: channels_active_first = i channels_active_last = i if channels_active_first == 64: channels_active_first = 0 channels_active_last = 0 nbins = 0 if maxbins == 0 else nbins + 1 if nframes > 1 and y > 0 and lines_in_frame > y + 1: skip_last_frame = 1 if nframes == 0: skip_first_frame = 0 skip_last_frame = 0 # elif nframes == 1: # # leave incomplete single frame # skip_first_frame = 0 # skip_last_frame = 0 # elif nframes == 2: # if skip_first_frame and skip_last_frame: # # leave both incomplete frames # skip_first_frame = 0 # skip_last_frame = 0 # elif skip_first_frame or skip_last_frame: # # remove incomplete first xor last frame # nframes -= 1 else: # remove incomplete first and/or last frames if skip_first_frame: nframes -= 1 if skip_last_frame and nframes > 0: nframes -= 1 if nlines > 0: time_in_lines = ( round( time_in_lines / nlines) ) else: time_in_lines = 0 return ( format, nrecords, nphotons, nmarkers, nframes, nlines, maxchannels, channels_active, channels_active_first, channels_active_last, maxbins, nbins, skip_first_frame, skip_last_frame, time_in_lines, overflow + itime ) cdef inline void decode_info_record( const uint32_t itime, const uint32_t idtime, const uint32_t ichannel, const uint64_t overflow, const uint8_t imarker, const uint8_t ispecial, const uint32_t line_start, const uint32_t line_stop, const uint32_t frame_change, const ssize_t lines_in_frame, const ssize_t maxchannels, const ssize_t maxbins, uint64_t* nphotons, uint64_t* nbins, uint64_t* nmarkers, uint64_t* nlines, uint64_t* nframes, uint64_t* channels_active, uint64_t* time_in_lines, uint64_t* time_line_start, ssize_t* y, ssize_t* skip_first_frame, ssize_t* skip_last_frame, ) noexcept nogil: """Accumulate one decoded TTTR record into decode_info statistics.""" if ispecial == 0: # photon record nphotons[0] += 1 if ichannel < maxchannels: channels_active[0] |= ( 1) << ichannel if idtime > nbins[0] and idtime < maxbins: nbins[0] = idtime elif ispecial == 2: # marker record nmarkers[0] += 1 if imarker & frame_change: # frame marker if lines_in_frame > y[0] + 1: # not enough lines in frame if nframes[0] == 1: skip_first_frame[0] = 1 else: skip_last_frame[0] = 1 else: skip_last_frame[0] = 0 if not imarker & line_stop: time_line_start[0] = UINT64_MAX y[0] = 0 if imarker & line_stop: # line stop marker if ( time_line_start[0] != UINT64_MAX and (lines_in_frame == 0 or lines_in_frame >= y[0]) ): time_in_lines[0] += (overflow + itime) - time_line_start[0] time_line_start[0] = UINT64_MAX if imarker & line_start: # line start marker # TODO: add to time_in_lines if previous line did not stop? time_line_start[0] = overflow + itime if y[0] == 0: # new frame starts at first line # after start or frame change marker nframes[0] += 1 y[0] += 1 if lines_in_frame == 0 or lines_in_frame >= y[0]: nlines[0] += 1 def decode_t3_point( uint_t[:, :, ::1] histogram, uint64_t[::1] times, const uint32_t[::1] records, const uint32_t format, const uint64_t pixel_time, const ssize_t startt = 0, const ssize_t startc = 0, const ssize_t starth = 0, const ssize_t bint = 1, const ssize_t binc = 1, const ssize_t binh = 1, ): """Return TCSPC histogram from TTTR T3 records of point measurement.""" cdef: ssize_t sizec, sizet, sizeh, stopc, stopt, stoph ssize_t nrecords = records.size uint64_t overflow, time_global uint32_t itime, idtime, ichannel uint8_t ispecial, imarker ssize_t i, iframe, iframe_binned, maxbins_ decode_func_t decode_func if pixel_time == 0: raise ValueError(f'invalid {pixel_time=}') if init_format(format, &decode_func, &maxbins_, &i) != 0: raise ValueError(f'no decoder available for {format=:02x}') if maxbins_ == 0: raise NotImplementedError(f'not a T3 {format=:02x}') if startc < 0 or startt < 0 or starth < 0: raise ValueError(f'invalid {startt=}, {startc=}, or {starth=}') if binc < 1 or bint < 1 or binh < 1: raise ValueError(f'invalid {bint=}, {binc=}, or {binh=}') if times.size != histogram.shape[0]: raise ValueError(f'{times.size=} does not match {histogram.shape=}') sizet, sizec, sizeh = histogram.shape[:3] with nogil: stopt = startt + sizet * bint stopc = startc + sizec * binc stoph = starth + sizeh * binh overflow = 0 iframe = 0 iframe_binned = -1 for i in range(nrecords): decode_func( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: # regular record if ( ichannel < startc or ichannel >= stopc or idtime < starth or idtime >= stoph ): continue time_global = overflow + itime iframe = time_global // pixel_time if iframe < startt: continue if iframe >= stopt: break if iframe_binned != (iframe - startt) // bint: iframe_binned = (iframe - startt) // bint times[iframe_binned] = time_global histogram[ iframe_binned, (ichannel - startc) // binc, (idtime - starth) // binh, ] += 1 # elif ispecial == 1: # # overflow # elif ispecial == 2: # # no markers def decode_t3_line( uint_t[:, :, :, ::1] histogram, uint64_t[::1] times, const uint32_t[::1] records, const uint32_t format, const uint64_t pixel_time, const uint32_t line_start, const uint32_t line_stop, const ssize_t startt = 0, const ssize_t startx = 0, const ssize_t startc = 0, const ssize_t starth = 0, const ssize_t bint = 1, const ssize_t binx = 1, const ssize_t binc = 1, const ssize_t binh = 1, ): """Return TCSPC histogram from TTTR T3 records of line scan measurement.""" cdef: ssize_t sizec, sizet, sizex, sizeh ssize_t stopc, stopt, stopx, stoph ssize_t nrecords = records.size uint64_t overflow, time_global, time_line_start uint32_t itime, idtime, ichannel uint8_t ispecial, imarker ssize_t i, ix, iframe, iframe_binned, maxbins_ decode_func_t decode_func if pixel_time == 0: raise ValueError(f'invalid {pixel_time=}') if init_format(format, &decode_func, &maxbins_, &i) != 0: raise ValueError(f'no decoder available for {format=:02x}') if maxbins_ == 0: raise NotImplementedError(f'not a T3 {format=:02x}') if startc < 0 or startt < 0 or startx < 0 or starth < 0: raise ValueError( f'invalid {startt=}, {startx=}, {startc=}, or {starth=}' ) if binc < 1 or bint < 1 or binx < 1 or binh < 1: raise ValueError(f'invalid {bint=}, {binx=}, {binc=}, or {binh=}') if times.size != histogram.shape[0]: raise ValueError(f'{times.size=} does not match {histogram.shape=}') sizet, sizex, sizec, sizeh = histogram.shape[:4] with nogil: stopt = startt + sizet * bint stopx = startx + sizex * binx stopc = startc + sizec * binc stoph = starth + sizeh * binh time_line_start = UINT64_MAX overflow = 0 iframe = 0 iframe_binned = -1 ix = 0 for i in range(nrecords): decode_func( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) time_global = overflow + itime if ispecial == 0: # regular record if ( time_line_start == UINT64_MAX # no line start marker yet or ichannel < startc or ichannel >= stopc or idtime < starth or idtime >= stoph ): continue ix = (time_global - time_line_start) // pixel_time if ix < startx or ix >= stopx: continue histogram[ iframe_binned, (ix - startx) // binx, (ichannel - startc) // binc, (idtime - starth) // binh, ] += 1 # elif ispecial == 1: # # overflow elif ispecial == 2: # marker if imarker & line_stop: time_line_start = UINT64_MAX iframe += 1 if iframe == stopt: break if imarker & line_start and iframe >= startt: time_line_start = time_global if iframe_binned != (iframe - startt) // bint: iframe_binned = (iframe - startt) // bint times[iframe_binned] = time_line_start def decode_t3_image( uint_t[:, :, :, :, :, ::1] histogram, uint64_t[::1] times, const uint32_t[::1] records, const uint32_t format, const ssize_t pixels_in_line, ssize_t pixel_time, # average global time spent in pixel ssize_t line_time, # global time spent in one line const uint16_t[::1] pixel_at_time, # global time in line to pixel index const uint32_t line_start, # mask const uint32_t line_stop, # mask const uint32_t frame_change, # mask const ssize_t nplanes, # number of Z planes per volume in records const ssize_t startt = 0, const ssize_t startz = 0, const ssize_t starty = 0, const ssize_t startx = 0, const ssize_t startc = 0, const ssize_t starth = 0, const ssize_t bint = 1, const ssize_t binz = 1, const ssize_t biny = 1, const ssize_t binx = 1, const ssize_t binc = 1, const ssize_t binh = 1, const ssize_t bishift = 0, const bint bidirectional = False, const bint sinusoidal = False, const bint skip_first_frame = 0, ): """Return TCSPC histogram from TTTR T3 records of image measurement.""" cdef: ssize_t sizec, sizet, sizez, sizey, sizex, sizeh ssize_t stopc, stopt, stopz, stopy, stopx, stoph ssize_t nrecords = records.size ssize_t i, j, ix, iy, iy_binned, iframe, maxbins_, bidiv ssize_t it, iz, it_binned, iz_binned uint64_t overflow, overflowj, time_global, time_line_start uint32_t itime, idtime, ichannel uint8_t ispecial, imarker decode_func_t decode_func bint scanline = pixel_time <= 0 or line_time <= 0 if scanline and pixels_in_line <= 0: raise ValueError(f'invalid {pixels_in_line=}') if ( sinusoidal and (pixel_at_time is None or pixel_at_time.size != line_time) ): sizey = 0 if pixel_at_time is None else pixel_at_time.size raise ValueError( f'invalid pixel_at_time.size={sizey} != {line_time=}' ) if init_format(format, &decode_func, &maxbins_, &i) != 0: raise ValueError(f'no decoder available for {format=:02x}') if maxbins_ == 0: raise NotImplementedError(f'not a T3 {format=:02x}') if nplanes < 1: raise ValueError(f'invalid {nplanes=}') if ( startc < 0 or startt < 0 or startz < 0 or starty < 0 or startx < 0 or starth < 0 ): raise ValueError( f'invalid {startt=}, {startz=}, {starty=}, {startx=}, ' f'{startc=}, or {starth=}' ) if ( binc < 1 or bint < 1 or binz < 1 or biny < 1 or binx < 1 or binh < 1 ): raise ValueError( f'invalid {bint=}, {binz=}, {biny=}, {binx=}, {binc=}, ' f'or {binh=}' ) if times.size != histogram.shape[0]: raise ValueError(f'{times.size=} does not match {histogram.shape=}') # if wraparound and starth > 0: # raise ValueError(f'can not wrap dtime with {starth=}') sizet, sizez, sizey, sizex, sizec, sizeh = histogram.shape[:6] with nogil: stopt = startt + sizet * bint stopz = startz + sizez * binz stopy = starty + sizey * biny stopx = startx + sizex * binx stopc = startc + sizec * binc stoph = starth + sizeh * binh bidiv = starty % 2 time_line_start = UINT64_MAX overflow = 0 iframe = -2 if skip_first_frame else -1 it = -1 iz = -1 it_binned = -1 iz_binned = -1 iy_binned = -1 iy = -1 ix = 0 # TODO: process channels/frames in parallel? for i in range(nrecords): ispecial = 3 # invalid, should always be overridden decode_func( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) time_global = overflow + itime if ispecial == 0: # regular record if ( time_line_start == UINT64_MAX # no line start marker yet or ichannel < startc or ichannel >= stopc or idtime < starth or idtime >= stoph # or (not wraparound and idtime >= stoph) ): continue ix = (time_global - time_line_start) if bidirectional and iy_binned % 2 != bidiv: # line backward scan # TODO: support bidirectional per frame # TODO: is bishift correct for sinusoidal scanning? ix = line_time - 1 - ix + bishift if ix < 0 or ix >= line_time: ix = stopx elif sinusoidal: ix = pixel_at_time[ix] else: ix = ix // pixel_time if ix >= startx and ix < stopx: # idtime_binned = (idtime - starth) // binh # if wraparound and idtime_binned >= sizeh: # idtime_binned %= sizeh histogram[ it_binned, iz_binned, iy_binned, (ix - startx) // binx, (ichannel - startc) // binc, (idtime - starth) // binh, ] += 1 # elif ispecial == 1: # # overflow elif ispecial == 2: # marker if imarker & frame_change: time_line_start = UINT64_MAX iy = -1 if imarker & line_stop: time_line_start = UINT64_MAX if imarker & line_start: iy += 1 if iy == 0: # new frame starts at first line # after start or frame change marker iframe += 1 if iframe >= 0: it = iframe // nplanes iz = iframe % nplanes if it >= stopt: break if ( iframe >= 0 and it >= startt and iz >= startz and iz < stopz and iy >= starty and iy < stopy ): iy_binned = (iy - starty) // biny iz_binned = (iz - startz) // binz time_line_start = time_global if it_binned != (it - startt) // bint: it_binned = (it - startt) // bint times[it_binned] = time_line_start if scanline: # scan line to next marker to determine # line_time and pixel_time overflowj = overflow j = i + 1 while True: if j >= nrecords: break decode_func( records[j], &itime, &idtime, &ichannel, &overflowj, &imarker, &ispecial ) time_global = overflowj + itime if ispecial == 2: # any marker break j += 1 line_time = ( time_global - time_line_start ) # pixel_time = round( # line_time / pixels_in_line # ) pixel_time = line_time // pixels_in_line pixel_time = pixel_time if pixel_time > 0 else 1 else: # line is not part of selection time_line_start = UINT64_MAX elif ispecial != 1: with gil: raise ValueError(f'invalid records[{i}]={records[i]}') def decode_t3_histogram( uint_t[:, ::1] histogram, const uint32_t[::1] records, const uint32_t format, const ssize_t startc, ): """Decode PicoQuant T3 TTTR records to histogram per channel.""" cdef: ssize_t nrecords = records.size ssize_t i, nbins, nchannels uint64_t overflow uint32_t itime, idtime, ichannel uint8_t ispecial, imarker if startc < 0: raise ValueError(f'{startc=} < 0') nchannels, nbins = histogram.shape[:2] with nogil: overflow = 0 # dispatch on format so compiler can inline decoder and optimize loop if format == 0x00010303: # PicoHarpT3 for i in range(nrecords): decode_pt3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels and idtime < nbins: histogram[ichannel, idtime] += 1 elif format in { 0x01010304, # HydraHarp2T3 0x00010305, # TimeHarp260NT3 0x00010306, # TimeHarp260PT3 0x00010307, # GenericT3 (MultiHarpT3 and Picoharp330T3) }: for i in range(nrecords): decode_ht3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels and idtime < nbins: histogram[ichannel, idtime] += 1 elif format == 0x00010304: # HydraHarpT3 for i in range(nrecords): decode_ht3v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels and idtime < nbins: histogram[ichannel, idtime] += 1 # if wraparound: # histogram[ichannel, idtime % nbins] += 1 else: with gil: raise ValueError(f'no decoder available for {format=:02x}') def decode_t2_histogram( uint_t[:, ::1] histogram, const uint32_t[::1] records, const uint32_t format, const uint64_t bin_time, const ssize_t startc, ): """Decode PicoQuant T2 TTTR records to histogram per channel.""" cdef: ssize_t nrecords = records.size ssize_t i, ibin, nbins, nchannels uint64_t overflow uint32_t itime, idtime, ichannel uint8_t ispecial, imarker if bin_time == 0: raise ValueError(f'invalid {bin_time=}') if startc < 0: raise ValueError(f'{startc=} < 0') nchannels, nbins = histogram.shape[:2] with nogil: overflow = 0 # dispatch on format so compiler can inline decoder and optimize loop if format == 0x00010203: # PicoHarpT2 for i in range(nrecords): decode_pt2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ibin = ((overflow + itime) // bin_time) if ibin >= nbins: break ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels: histogram[ichannel, ibin] += 1 elif format in { 0x01010204, # HydraHarp2T2 0x00010205, # TimeHarp260NT2 0x00010206, # TimeHarp260PT2 0x00010207, # GenericT2 (MultiHarpT2 and Picoharp330T2) }: for i in range(nrecords): decode_ht2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ibin = ((overflow + itime) // bin_time) if ibin >= nbins: break ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels: histogram[ichannel, ibin] += 1 elif format == 0x00010204: # HydraHarpT2 for i in range(nrecords): decode_ht2v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) ibin = ((overflow + itime) // bin_time) if ibin >= nbins: break ichannel -= startc # may underflow if ispecial == 0 and ichannel < nchannels: histogram[ichannel, ibin] += 1 else: with gil: raise ValueError(f'no decoder available for {format=:02x}') def decode_t3_records( t3_t[::1] decoded, const uint32_t[::1] records, const uint32_t format ): """Decode PicoQuant T3 TTTR records.""" cdef: ssize_t nrecords = min(records.size, decoded.size) ssize_t i uint64_t overflow uint32_t itime, idtime, ichannel uint8_t ispecial, imarker with nogil: overflow = 0 # dispatch on format so compiler can inline decoder and optimize loop if format == 0x00010303: # PicoHarpT3 for i in range(nrecords): decode_pt3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].dtime = idtime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = imarker elif format in { 0x01010304, # HydraHarp2T3 0x00010305, # TimeHarp260NT3 0x00010306, # TimeHarp260PT3 0x00010307, # GenericT3 (MultiHarpT3 and Picoharp330T3) }: for i in range(nrecords): decode_ht3( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].dtime = idtime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = imarker elif format == 0x00010304: # HydraHarpT3 for i in range(nrecords): decode_ht3v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].dtime = idtime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].dtime = -1 decoded[i].channel = -1 decoded[i].marker = imarker else: with gil: raise ValueError(f'no decoder available for {format=:02x}') def decode_t2_records( t2_t[::1] decoded, const uint32_t[::1] records, const uint32_t format ): """Decode PicoQuant T2 TTTR records.""" cdef: ssize_t nrecords = min(records.size, decoded.size) ssize_t i uint64_t overflow uint32_t itime, idtime, ichannel uint8_t ispecial, imarker with nogil: overflow = 0 # dispatch on format so compiler can inline decoder and optimize loop if format == 0x00010203: # PicoHarpT2 for i in range(nrecords): decode_pt2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = imarker elif format in { 0x01010204, # HydraHarp2T2 0x00010205, # TimeHarp260NT2 0x00010206, # TimeHarp260PT2 0x00010207, # GenericT2 (MultiHarpT2 and Picoharp330T2) }: for i in range(nrecords): decode_ht2( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = imarker elif format == 0x00010204: # HydraHarpT2 for i in range(nrecords): decode_ht2v1( records[i], &itime, &idtime, &ichannel, &overflow, &imarker, &ispecial ) if ispecial == 0: decoded[i].time = overflow + itime decoded[i].channel = ichannel decoded[i].marker = 0 elif ispecial == 1: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = 0 elif ispecial == 2: decoded[i].time = overflow + itime decoded[i].channel = -1 decoded[i].marker = imarker else: with gil: raise ValueError(f'no decoder available for {format=:02x}') # Decode functions only set outputs relevant to each record type. # Callers must check special before using channel, dtime, or marker, # which may be stale from the previous call. cdef inline void decode_pt3( const uint32_t record, uint32_t* time, # nsync uint32_t* dtime, uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp time[0] = record & 0xffff # 16 bit nsync dtime[0] = (record >> 16) & 0xfff # 12 bit dtime tmp = (record >> 28) & 0xf # 4 bit channel if tmp != 0xf: # regular record # TODO: understand this undocumented channel decoding if tmp > 0 and tmp < 5: channel[0] = tmp - 1 # one to zero-based else: # should not happen channel[0] = 4 special[0] = 0 elif dtime[0] == 0: # overflow special[0] = 1 overflow[0] += 65536 else: # marker special[0] = 2 marker[0] = dtime[0] dtime[0] = 0 cdef inline void decode_pt2( const uint32_t record, uint32_t* time, # timetag uint32_t* dtime, # not used uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp dtime[0] = 0 # not present in T2 record time[0] = record & 0xfffffff # 28 bit timetag tmp = ((record >> 28) & 0xf) # 4 bit channel if tmp != 0xf: # regular record if tmp < 5: channel[0] = tmp else: # should not happen channel[0] = 5 special[0] = 0 else: tmp = record & 0xf # lower 4 bits if tmp == 0: # overflow special[0] = 1 overflow[0] += 210698240 else: # marker special[0] = 2 marker[0] = tmp cdef inline void decode_ht3( const uint32_t record, uint32_t* time, # nsync uint32_t* dtime, uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp time[0] = record & 0x3ff # 10 bit nsync dtime[0] = (record >> 10) & 0x7fff # 15 bit dtime tmp = (record >> 31) & 0x1 # 1 bit special if tmp == 0: # regular record special[0] = 0 channel[0] = (record >> 25) & 0x3f # 6 bit channel else: tmp = (record >> 25) & 0x3f # 6 bit if tmp == 0x3f: # overflow special[0] = 1 if time[0] <= 1: overflow[0] += 1024 else: overflow[0] += time[0] * 1024 elif 0 < tmp < 16: # marker special[0] = 2 marker[0] = tmp else: # reserved; treat as overflow/skip special[0] = 1 cdef inline void decode_ht3v1( const uint32_t record, uint32_t* time, # nsync uint32_t* dtime, uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp time[0] = record & 0x3ff # 10 bit nsync dtime[0] = (record >> 10) & 0x7fff # 15 bit dtime tmp = (record >> 31) & 0x1 # 1 bit special if tmp == 0: # regular record special[0] = 0 channel[0] = (record >> 25) & 0x3f # 6 bit channel else: tmp = (record >> 25) & 0x3f # 6 bit if tmp == 0x3f: # overflow special[0] = 1 overflow[0] += 1024 elif 0 < tmp < 16: # marker special[0] = 2 marker[0] = tmp else: # reserved; treat as overflow/skip special[0] = 1 cdef inline void decode_ht2( const uint32_t record, uint32_t* time, # timetag uint32_t* dtime, # not used uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp dtime[0] = 0 # not present in T2 record time[0] = record & 0x1ffffff # 25 bit timetag tmp = (record >> 31) & 0x1 # 1 bit special if tmp == 0: # regular record special[0] = 0 channel[0] = (record >> 25) & 0x3f # 6 bit channel else: tmp = (record >> 25) & 0x3f # 6 bit if tmp == 0x3f: # overflow special[0] = 1 if time[0] <= 1: overflow[0] += 33554432 else: overflow[0] += time[0] * 33554432 elif tmp == 0: # regular record special[0] = 0 channel[0] = 0 elif tmp < 16: # marker special[0] = 2 marker[0] = tmp else: # reserved; treat as overflow/skip special[0] = 1 cdef inline void decode_ht2v1( const uint32_t record, uint32_t* time, # timetag uint32_t* dtime, # not used uint32_t* channel, uint64_t* overflow, uint8_t* marker, uint8_t* special ) noexcept nogil: cdef uint32_t tmp dtime[0] = 0 # not present in T2 record time[0] = record & 0x1ffffff # 25 bit timetag tmp = (record >> 31) & 0x1 # 1 bit special if tmp == 0: # regular record special[0] = 0 channel[0] = (record >> 25) & 0x3f # 6 bit channel else: tmp = (record >> 25) & 0x3f # 6 bit if tmp == 0x3f: # overflow special[0] = 1 overflow[0] += 33552000 elif tmp == 0: # regular record special[0] = 0 channel[0] = 0 elif tmp < 16: # marker special[0] = 2 marker[0] = tmp else: # reserved; treat as overflow/skip special[0] = 1 def encode_t3_image( uint32_t[::1] records, const uint_t[:, :, :, :, ::1] histogram, const uint32_t format, const uint32_t pixel_time, # average global time spent in pixel const uint32_t line_start, # mask const uint32_t line_stop, # mask const uint32_t frame_change, # mask const uint32_t start_time=0, # nsync of first record ): """Return GenericT3 records from TCSPC image histogram.""" # TODO: frame and line markers may be combined in one record # TODO: record photons across channels at same time (slower) # TODO: randomize photon arrival times at fixed count rate cdef: ssize_t sizet, sizey, sizex, sizec, sizeh ssize_t t, y, x, c, h, nrecords, maxrecords uint32_t time, time_in_pixel, overflow, maxtime, maxoverflow, _i uint_t _count encode_func_t encode sizet, sizey, sizex, sizec, sizeh = histogram.shape[:5] maxrecords = records.size if format == 0x00010303: # PicoHarpT3/PicoHarp300 encode = encode_pt3 maxtime = 65536 maxoverflow = 1 elif format == 0x00010307: # GenericT3 (MultiHarpT3 and Picoharp330T3) encode = encode_ht3 maxtime = 1024 maxoverflow = 1023 else: raise ValueError(f'{format=} not supported') if start_time >= maxtime: raise ValueError(f'{start_time=} >= {maxtime=}') with nogil: time = start_time nrecords = 0 for t in range(sizet): # frame for y in range(sizey): # line if nrecords >= maxrecords: nrecords = -1 break # line start marker records[nrecords] = encode(time, 0, 0, 0, line_start) nrecords += 1 for x in range(sizex): if nrecords < 0: break # pixel time_in_pixel = 0 # record all photons at different time for c in range(sizec): # channel for h in range(sizeh): # bin for _count in range(histogram[t, y, x, c, h]): # photon if nrecords >= maxrecords: nrecords = -1 break records[nrecords] = encode( time, h, c, 0, 0 ) nrecords += 1 # increment time time += 1 if time == maxtime: # overflow time = 0 if nrecords >= maxrecords: nrecords = -1 break records[nrecords] = encode(0, 0, 0, 1, 0) nrecords += 1 time_in_pixel += 1 if time_in_pixel == pixel_time: break if nrecords < 0: break if time_in_pixel == pixel_time: break if nrecords < 0: break if time_in_pixel == pixel_time: break if nrecords < 0: break # move to next pixel # TODO: calculate overflows overflow = 0 for _i in range(pixel_time - time_in_pixel): time += 1 if time == maxtime: # overflow time = 0 overflow += 1 if overflow == maxoverflow: if nrecords >= maxrecords: nrecords = -1 break records[nrecords] = encode( 0, 0, 0, overflow, 0 ) nrecords += 1 overflow = 0 if nrecords >= 0 and overflow > 0: if nrecords >= maxrecords: nrecords = -1 else: records[nrecords] = encode(0, 0, 0, overflow, 0) nrecords += 1 if nrecords < 0: break if nrecords >= maxrecords: nrecords = -1 break # line end marker records[nrecords] = encode(time, 0, 0, 0, line_stop) nrecords += 1 if nrecords < 0: break if nrecords >= maxrecords: nrecords = -1 break # frame change marker records[nrecords] = encode(time, 0, 0, 0, frame_change) nrecords += 1 if nrecords < 0: pass # error: records array too small return nrecords cdef inline uint32_t encode_pt3( const uint32_t time, const uint32_t dtime, const uint32_t channel, const uint32_t overflow, const uint32_t marker, ) noexcept nogil: cdef: uint32_t record = time # & 0xffff # 16 bit nsync if marker != 0: # dtime is marker record |= marker << 16 # record |= (marker & 0xfff) << 16 # all channel bits set record |= 0xf0000000 elif overflow != 0: # dtime is 0, all channel bits set record |= 0xf0000000 else: # 12 bit dtime record |= dtime << 16 # record |= (dtime & 0xfff) << 16 # 4 bit channel, one-based record |= (channel + 1) << 28 # record |= ((channel + 1) & 0xf) << 28 return record cdef inline uint32_t encode_ht3( const uint32_t time, const uint32_t dtime, const uint32_t channel, const uint32_t overflow, const uint32_t marker, ) noexcept nogil: cdef: uint32_t record = time # & 0x3ff # 10 bit nsync record |= dtime << 10 # 15 bit dtime # record |= (dtime & 0x7fff) << 10 # 15 bit dtime if marker != 0: # 1 bit special record |= 0x80000000 # 4 bit marker instead of channel record |= marker << 25 # record |= (marker & 0xf) << 25 elif overflow != 0: # 1 bit special and all bits set instead of channel record |= 0xfe000000 # 10 bit multiple of 1024 increment of nsync instead of nsync record |= overflow # record |= overflow & 0x3ff else: # 6 bit channel record |= channel << 25 # record |= (channel & 0x3f) << 25 return record cgohlke-ptufile-933b7e3/ptufile/numcodecs.py000066400000000000000000000072601526455024500211540ustar00rootroot00000000000000# ptufile/numcodecs.py # Copyright (c) 2024-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. """PTU codec for the Numcodecs package.""" from __future__ import annotations __all__ = ['Ptu', 'register_codec'] from io import BytesIO from typing import TYPE_CHECKING from numcodecs import registry from numcodecs.abc import Codec from .ptufile import PtuFile if TYPE_CHECKING: from collections.abc import Sequence from types import EllipsisType from typing import Any from numpy.typing import ArrayLike, DTypeLike, NDArray class Ptu(Codec): # type: ignore[misc] """Ptu codec for Numcodecs.""" codec_id = 'ptufile' def __init__( self, *, selection: Sequence[int | slice | EllipsisType | None] | None = None, dtype: DTypeLike | None = None, channel: int | None = None, frame: int | None = None, dtime: int | None = None, pixel_time: float | None = None, trimdims: str | None = None, keepdims: bool = True, ) -> None: if selection is not None: # TODO: serialize slices, EllipsisType msg = f'{selection=}' raise NotImplementedError(msg) self.selection = selection self.dtype = dtype self.channel = channel self.frame = frame self.dtime = dtime self.pixel_time = pixel_time self.trimdims = trimdims self.keepdims = bool(keepdims) def encode(self, buf: ArrayLike) -> None: """Return Ptu file as bytes.""" raise NotImplementedError def decode(self, buf: bytes, out: Any | None = None) -> NDArray[Any]: """Return decoded image as NumPy array.""" with BytesIO(buf) as fh, PtuFile(fh, trimdims=self.trimdims) as ptu: return ptu.decode_image( self.selection, dtype=self.dtype, channel=self.channel, frame=self.frame, dtime=self.dtime, pixel_time=self.pixel_time, keepdims=self.keepdims, ) def register_codec(cls: Codec = Ptu, codec_id: str | None = None) -> None: """Register :py:class:`Ptu` codec with Numcodecs.""" registry.register_codec(cls, codec_id=codec_id) cgohlke-ptufile-933b7e3/ptufile/ptufile.py000066400000000000000000004237471526455024500206600ustar00rootroot00000000000000# ptufile.py # Copyright (c) 2023-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 and write PicoQuant PTU and related files. Ptufile is a Python library to 1. read data and metadata from PicoQuant PTU and related files (PHU, PCK, PCO, PFS, PUS, PQRES, PQDAT, PQUNI, PQOVER, SPQR, and BIN), and 2. write TCSPC histograms to T3 image mode PTU files. PTU files contain time correlated single photon counting (TCSPC) measurement data and instrumentation parameters. :Author: `Christoph Gohlke `_ :License: BSD-3-Clause :Version: 2026.10.16 :DOI: `10.5281/zenodo.10120021 `_ Quickstart ---------- Install the ptufile package and all dependencies from the `Python Package Index `_:: python -m pip install -U "ptufile[all]" See `Examples`_ and `Documentation `_ for using the programming interface. Source code and support are available on `GitHub `_. Requirements ------------ This revision was tested with the following requirements and dependencies (other versions may work): - `CPython `_ 3.12.10, 3.13.16, 3.14.8, 3.15.0 64-bit - `Numpy `_ 2.5.4 - `Xarray `_ 2026.9.0 (recommended) - `Matplotlib `_ 3.11.2 (optional) - `Tifffile `_ 2026.10.15 (optional) - `Numcodecs `_ 0.17.0 (optional) - `Python-dateutil `_ 2.9.0 (optional) - `Cython `_ 3.2.9 (build) Revisions --------- 2026.10.16 - Support z-stack images in PTU reader and writer (#5). - Support sparse tag indices (#5, breaking). - Replace PtuWriter has_frames with dims parameter (breaking). - Add volume_time and global_volume_time properties to PtuFile. - Add plane parameter to PtuFile.decode_image and imread. - Generalize decode_t3_image to 6-dimensional data. 2026.8.8 - Add PQOVER file type. 2026.6.6 - Remove memmap parameter from PtuFile.read_records (breaking). - Return copy of records from PtuFile.read_records by default (breaking). - Add options for memory-mapping and locked reading to BinaryFile. - Add option to memory-map PTU files. - Drop support for numpy 2.0 (SPEC0). - Support Python 3.15. 2026.3.21 - Add bounds checking to encode_t3_image function. - Use format-dispatch in hot decode loops to allow compiler inlining. - Build wheels on Windows with LLVM (30% faster decoding than MSVC). - Drop support for Python 3.11. 2026.2.6 - … Refer to the CHANGES file for older revisions. Notes ----- `PicoQuant GmbH `_ is a manufacturer of photonic components and instruments. The PicoQuant unified file formats are documented at the `PicoQuant-Time-Tagged-File-Format-Demos `_. All PicoQuant unified tagged formats use the same basic layout: a file header followed by typed tags and an optional data block. PTU is the main container for TTTR event streams, whereas related formats store histograms, instrument settings, analysis results, or other auxiliary data. The following features are currently not implemented due to the lack of test files or documentation: PT2 and PT3 files, decoding images from T2 and SPQR formats, bidirectional per frame, and deprecated image reconstruction. Compatibility with PTU files written by non-PicoQuant software (for example, Leica LAS X or Abberior Imspector) is limited, as is decoding line, bidirectional, and sinusoidal scanning. Other modules for reading or writing PicoQuant files are `Read_PTU.py `_, `readPTU `_, `readPTU_FLIM `_, `fastFLIM `_, `PyPTU `_, `PTU_Reader `_, `PTU_Writer `_, `FlimReader `_, `tangy `_, `tttrlib `_, `picoquantio `_, `ptuparser `_, `phconvert `_, `trattoria `_ (wrapper of `trattoria-core `_, `tttr-toolbox `_), `PAM `_, `FLOPA `_, `FLIMKit `_, and `napari-flim-phasor-plotter `_. Examples -------- Import functions and classes used in these examples: >>> from ptufile import PqFile, PtuFile, imwrite, imread Read properties and tags from any type of PicoQuant unified tagged file: >>> pq = PqFile('tests/data/Settings.pfs') >>> pq.type >>> pq.guid UUID('86d428e2-cb0b-4964-996c-04456ba6be7b') >>> pq.tags {...'CreatorSW_Name': 'SymPhoTime 64', 'CreatorSW_Version': '2.1'...} >>> pq.close() Read metadata from a PicoQuant PTU FLIM file: >>> ptu = PtuFile('tests/data/FLIM.ptu') >>> ptu.type >>> ptu.record_type >>> ptu.measurement_mode >>> ptu.measurement_submode Decode TTTR records from the PTU file to ``numpy.recarray``: >>> decoded = ptu.decode_records() >>> decoded.dtype dtype([('time', '>> decoded['time'][(decoded['marker'] & ptu.frame_change_mask) > 0] array([1571185680], dtype=uint64) Decode TTTR records to overall delay-time histograms per channel: >>> ptu.decode_histogram(dtype='uint8') array([[ 5, 7, 7, ..., 10, 9, 2]], shape=(2, 3126), dtype=uint8) Get information about the FLIM image histogram in the PTU file: >>> ptu.shape (1, 256, 256, 2, 3126) >>> ptu.dims ('T', 'Y', 'X', 'C', 'H') >>> ptu.coords {'T': ..., 'Y': ..., 'X': ..., 'H': ...} >>> ptu.dtype dtype('uint16') >>> ptu.active_channels (0, 1) Decode parts of the image histogram to ``numpy.ndarray`` using slice notation. Slice step sizes define binning, -1 being used to integrate along axis: >>> ptu[:, ..., 0, ::-1] array([[[103, ..., 38], ... [ 47, ..., 30]]], shape=(1, 256, 256), dtype=uint16) Alternatively, decode the first channel and integrate all histogram bins into a ``xarray.DataArray``, keeping reduced axes: >>> ptu.decode_image(channel=0, dtime=-1, asxarray=True) ... array([[[[[103]], ... [[ 30]]]]], shape=(1, 256, 256, 1, 1), dtype=uint16) Coordinates: * T (T) float64... 0.05625 * Y (Y) float64... -0.0001304 ... 0.0001294 * X (X) float64... -0.0001304 ... 0.0001294 * C (C) uint8... 0 * H (H) float64... 0.0 Attributes... name: FLIM.ptu ... Write the TCSPC histogram and metadata to a PicoHarpT3 image mode PTU file: >>> imwrite( ... '_test.ptu', ... ptu[:], ... ptu.global_resolution, ... ptu.tcspc_resolution, ... # optional metadata ... pixel_time=ptu.pixel_time, ... record_type=PtuRecordType.PicoHarpT3, ... comment='Written by ptufile.py', ... tags={'File_RawData_GUID': [ptu.guid]}, ... ) Read back the TCSPC histogram from the file: >>> tcspc_histogram = imread('_test.ptu') >>> import numpy >>> numpy.array_equal(tcspc_histogram, ptu[:]) True Close the file handle: >>> ptu.close() Preview the image and metadata in a PTU file from the console:: python -m ptufile tests/data/FLIM.ptu """ from __future__ import annotations __version__ = '2026.10.16' __all__ = [ 'FILE_EXTENSIONS', 'T2_RECORD_DTYPE', 'T3_RECORD_DTYPE', 'PhuFile', 'PhuMeasurementMode', 'PhuMeasurementSubMode', 'PqFile', 'PqFileError', 'PqFileType', 'PtuFile', 'PtuHwFeatures', 'PtuMeasurementMode', 'PtuMeasurementSubMode', 'PtuMeasurementWarnings', 'PtuRecordType', 'PtuScanDirection', 'PtuScannerType', 'PtuStopReason', 'PtuWriter', '__version__', 'binread', 'binwrite', 'imread', 'imwrite', ] import contextlib import dataclasses import enum import io import logging import math import mmap import os import struct import sys import threading import uuid from datetime import datetime, timedelta from functools import cached_property from typing import TYPE_CHECKING, final, overload, override if TYPE_CHECKING: from collections.abc import Iterable, Sequence from types import EllipsisType, TracebackType from typing import IO, Any, ClassVar, Literal, Self from numpy.typing import ArrayLike, DTypeLike, NDArray from xarray import DataArray import numpy type Dimension = Literal['T', 'C', 'H'] type OutputType = str | IO[bytes] | NDArray[Any] | None @overload def imread( file: str | os.PathLike[str] | IO[bytes], /, selection: Sequence[int | slice | EllipsisType | None] | None = None, *, dtype: DTypeLike | None = None, channel: int | None = None, frame: int | None = None, plane: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, trimdims: Sequence[Dimension] | str | None = None, keepdims: bool = True, memmap: bool = False, asxarray: Literal[False] = ..., out: OutputType = None, ) -> NDArray[Any]: ... @overload def imread( file: str | os.PathLike[str] | IO[bytes], /, selection: Sequence[int | slice | EllipsisType | None] | None = None, *, dtype: DTypeLike | None = None, channel: int | None = None, frame: int | None = None, plane: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, trimdims: Sequence[Dimension] | str | None = None, keepdims: bool = True, memmap: bool = False, asxarray: Literal[True] = ..., out: OutputType = None, ) -> DataArray: ... @overload def imread( file: str | os.PathLike[str] | IO[bytes], /, selection: Sequence[int | slice | EllipsisType | None] | None = None, *, dtype: DTypeLike | None = None, channel: int | None = None, frame: int | None = None, plane: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, trimdims: Sequence[Dimension] | str | None = None, keepdims: bool = True, memmap: bool = False, asxarray: bool = False, out: OutputType = None, ) -> NDArray[Any] | DataArray: ... def imread( file: str | os.PathLike[str] | IO[bytes], /, selection: Sequence[int | slice | EllipsisType | None] | None = None, *, dtype: DTypeLike | None = None, channel: int | None = None, frame: int | None = None, plane: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, trimdims: Sequence[Dimension] | str | None = None, keepdims: bool = True, memmap: bool = False, asxarray: bool = False, out: OutputType = None, ) -> NDArray[Any] | DataArray: """Return decoded image histogram from T3 mode PTU file. Parameters: file: File name or seekable binary stream. selection, dtype, channel, frame, plane, dtime, pixel_time, bishift,\ keepdims, asxarray, out: Passed to :py:meth:`PtuFile.decode_image`. trimdims, memmap: Passed to :py:class:`PtuFile`. Returns: image: Decoded TTTR T3 records as up to 6-dimensional image array. """ with PtuFile(file, trimdims=trimdims, memmap=memmap) as ptu: return ptu.decode_image( selection, dtype=dtype, channel=channel, frame=frame, plane=plane, dtime=dtime, bishift=bishift, pixel_time=pixel_time, keepdims=keepdims, asxarray=asxarray, out=out, ) def imwrite( file: str | os.PathLike[str] | IO[bytes], data: ArrayLike, /, global_resolution: float, tcspc_resolution: float, pixel_time: float | None = None, *, dims: Sequence[str] | None = None, record_type: PtuRecordType | None = None, pixel_resolution: float | None = None, guid: str | uuid.UUID | None = None, comment: str | None = None, datetime: datetime | None = None, tags: dict[str, Any] | None = None, mode: Literal['w', 'wb', 'x', 'xb'] | None = None, ) -> None: """Write TCSPC histogram to T3 image mode PTU file. Parameters: file: File name or writable binary stream. data: TCSPC histogram image stack. The order of dimensions must be ``'[T][Z]YX[C]H'``. See :py:class:`PtuWriter`. The dtype must be unsigned integer. TZ stacks are currently not supported by PicoQuant software, only T or Z stacks. global_resolution: Resolution of time tags in s, typically in ns range. The inverse of the synctime or laser frequency. One photon is encoded per time tag. tcspc_resolution: Resolution of TCSPC in s, typically in ps range. The width of a histogram bin. pixel_time: Time per pixel in s, typically in μs range. Photons that cannot be encoded within pixel_time are omitted. By default, pixel_time is set just large enough to encode all photons. dims: Axis labels of input ``shape`` in ``'[T][Z]YX[C]H'`` order. Disambiguates 3- to 5-dimensional shapes when the axis order is not the default ('YXH', 'YXCH', or 'TYXCH'). For example, ``dims='TYXH'`` or ``dims='ZYXCH'``. By default, inferred from xarray dimension names if available. record_type, pixel_resolution, guid, comment, datetime, tags, mode: Optional parameters passed to :py:class:`PtuWriter`. """ if dims is None and hasattr(data, 'dims'): xdims = [str(d) for d in data.dims] if all(d in 'TZYXCH' for d in xdims): dims = xdims data = numpy.asarray(data) if pixel_time is None: data = data.reshape(PtuWriter.normalize_shape(data.shape, dims=dims)) dims = None # shape is already normalized to 6D pixel_time = global_resolution * max( 1, float(numpy.max(data.sum(axis=(4, 5), dtype=numpy.uint64))) ) with PtuWriter( file, data.shape, global_resolution, tcspc_resolution, pixel_time, record_type=record_type, pixel_resolution=pixel_resolution, dims=dims, guid=guid, comment=comment, datetime=datetime, tags=tags, mode=mode, ) as ptu: ptu.write(data) @final class PtuWriter: """Write TCSPC histogram to T3 image mode PTU file. T3 TTTR records allow for a maximum of 63 channels and 32768 bins. The TTTR records written can only be used to reconstruct the encoded TCSPC histogram image stack, not for higher-than-pixel-time-resolution intensity time-trace or correlation analysis. Parameters: file: File name or writable binary stream. File names typically end in '.PTU'. shape: Shape of TCSPC histogram image stack to write. The order of dimensions must be ``'[T][Z]YX[C]H'``. TZ stacks are currently not supported by PicoQuant software, only T or Z stacks. global_resolution: Resolution of time tags in s, typically in ns range. The inverse of the synctime or laser frequency. One photon is encoded per time tag. tcspc_resolution: Resolution of TCSPC in s, typically in ps range. The width of a histogram bin. pixel_time: Time per pixel in s, typically in μs range. Photons that cannot be encoded within pixel_time are omitted. record_type: Type of TTTR T3 records to write. By default, write ``PicoHarpT3`` records for up to two channels and 4096 bins, else ``GenericT3``. pixel_resolution: Resolution of single pixel in μm. The default is 1 μm. dims: Axis labels of input ``shape`` in ``'[T][Z]YX[C]H'`` order. Disambiguates 3- to 5-dimensional shapes when the axis order is not the default ('YXH', 'YXCH', or 'TYXCH'). For example, ``dims='TYXH'`` or ``dims='ZYXCH'``. guid: Windows formatted GUID used as global file identifier. By default, a random GUID. Write to File_GUID tag. comment: File comment. Write to File_Comment tag. datetime: File creation date and time. The default is time at function call. Write to File_CreatingTime tag. tags: Additional tag Id and values to write. Critical tags are automatically set and cannot be modified. No validation is performed. Refer to the "PicoQuant Unified Tag Dictionary" for valid Id and values. mode: Binary file open mode if ``file`` is file name. The default is 'w', which opens files for writing, truncating existing files. 'x' opens files for exclusive creation, failing on existing files. Raises: ValueError Not ``0 < tcspc_resolution <= global_resolution <= pixel_time``. """ _fh: IO[bytes] | None _shape: tuple[int, int, int, int, int, int] # normalized TZYXCH _record_type: PtuRecordType _number_records: int _number_records_offset: int _number_frames: int _number_frames_offset: int _global_resolution: float _tcspc_resolution: float _pixel_time: int _time: int # nsync at end of last write _line_start = 1 _line_stop = 2 _frame_change = 3 def __init__( self, file: str | os.PathLike[str] | IO[bytes], /, shape: tuple[int, ...], global_resolution: float, tcspc_resolution: float, pixel_time: float, *, record_type: PtuRecordType | None = None, pixel_resolution: float | None = None, dims: Sequence[str] | None = None, guid: str | uuid.UUID | None = None, comment: str | None = None, datetime: datetime | None = None, tags: dict[str, Any] | None = None, mode: Literal['w', 'wb', 'x', 'xb'] | None = None, ) -> None: """Write PTU header to file.""" # 0 < tcspc_resolution <= global_resolution <= pixel_time if tcspc_resolution <= 0.0: msg = f'{tcspc_resolution=} <= 0.0' raise ValueError(msg) if tcspc_resolution > global_resolution: msg = f'{tcspc_resolution=} > {global_resolution=}' raise ValueError(msg) if pixel_time < global_resolution: msg = f'{pixel_time=} < {global_resolution=}' raise ValueError(msg) self._fh = None self._number_records = 0 self._number_records_offset = 0 self._number_frames = 0 self._number_frames_offset = 0 self._time = 0 self._global_resolution = global_resolution self._tcspc_resolution = tcspc_resolution self._pixel_time = round(pixel_time / global_resolution) self._shape = shape = PtuWriter.normalize_shape(shape, dims=dims) if record_type is None: if shape[4] <= 2 and shape[5] <= 4096: record_type = PtuRecordType.PicoHarpT3 else: record_type = PtuRecordType.GenericT3 if record_type == PtuRecordType.PicoHarpT3: if shape[4] > 4 or shape[5] > 4096: msg = ( f'{record_type=} does not support ' f'{shape[4]} channels and {shape[5]} bins' ) raise ValueError(msg) self._record_type = PtuRecordType.PicoHarpT3 elif record_type in { PtuRecordType.GenericT3, PtuRecordType.HydraHarp2T3, PtuRecordType.TimeHarp260NT3, PtuRecordType.TimeHarp260PT3, }: if shape[4] > 63 or shape[5] > 32768: msg = ( f'{record_type=} does not support ' f'{shape[4]} channels and {shape[5]} bins' ) raise ValueError(msg) self._record_type = PtuRecordType.GenericT3 else: msg = f'{record_type=} not supported' raise ValueError(msg) if comment is None: comment = '' if guid is None: guid = f'{{{uuid.uuid4()}}}' elif isinstance(guid, uuid.UUID): guid = f'{{{guid}}}' elif len(guid) != 38 or guid[9] != '-': msg = 'invalid GUID' raise ValueError(msg) if pixel_resolution is None: pixel_resolution = 1.0 elif pixel_resolution <= 0.0: msg = f'{pixel_resolution=} <= 0.0' raise ValueError(msg) if datetime is None: datetime = now() critical_tags = { # tags not to be overwritten by user 'Measurement_Mode': PtuMeasurementMode.T3, 'Measurement_SubMode': PtuMeasurementSubMode.IMAGE, 'MeasDesc_GlobalResolution': float(self._global_resolution), 'MeasDesc_Resolution': float(self._tcspc_resolution), 'MeasDesc_BinningFactor': 1, 'TTResult_NumberOfRecords': self._number_records, 'TTResult_SyncRate': round(1.0 / self._global_resolution), 'TTResultFormat_TTTRRecType': self._record_type, 'TTResultFormat_BitsPerRecord': 32, 'ImgHdr_Dimensions': 4 if shape[1] > 1 else 3, 'ImgHdr_Ident': PtuScannerType.LSM, 'ImgHdr_LineStart': self._line_start, 'ImgHdr_LineStop': self._line_stop, 'ImgHdr_Frame': self._frame_change, 'ImgHdr_MaxFrames': 0, 'ImgHdr_TimePerPixel': pixel_time * 1e3, # ms 'ImgHdr_PixX': int(self._shape[3]), 'ImgHdr_PixY': int(self._shape[2]), 'ImgHdr_BiDirect': False, 'ImgHdr_SinCorrection': 0, } if shape[1] > 1: critical_tags['ImgHdr_PixZ'] = shape[1] pqtags = { # required tags written first, allowed to be overwritten by user 'File_GUID': guid, 'File_Comment': comment, 'File_CreatingTime': datetime, 'CreatorSW_Name': 'ptufile.py', 'CreatorSW_Version': __version__, } pqtags.update(critical_tags) pqtags.update( # other tags allowed to be overwritten by user { 'ImgHdr_PixResol': float(pixel_resolution), 'HW_InpChannels': self._shape[4] + 1, # used by FlimReader # 'TTResult_StopReason': PtuStopReason(0) } ) if tags is not None: # add user tags but do not overwrite critical tags pqtags.update( {k: v for k, v in tags.items() if k not in critical_tags} ) header_list = [b'PQTTTR\x00\x001.0.00\x00\x00'] # magic and version for tagid, value in pqtags.items(): if isinstance(value, (list, tuple)): for index, item in enumerate(value): header_list.append(encode_tag(tagid, item, index)) else: header_list.append(encode_tag(tagid, value)) header_list.append(encode_tag('Header_End', None)) header = b''.join(header_list) offset = header.find(b'TTResult_NumberOfRecords') if offset < 0: msg = 'TTResult_NumberOfRecords tag not found in header' raise RuntimeError(msg) self._number_records_offset = offset + 40 offset = header.find(b'ImgHdr_MaxFrames') if offset < 0: msg = 'ImgHdr_MaxFrames tag not found in header' raise RuntimeError(msg) self._number_frames_offset = offset + 40 if isinstance(file, (str, os.PathLike)): if mode is None: mode = 'wb' elif mode[-1] != 'b': mode += 'b' # type: ignore[assignment] self._fh = open(file, mode) # noqa: SIM115 self._close = True elif hasattr(file, 'write') and hasattr(file, 'seek'): self._fh = file self._close = False else: msg = f'cannot write to {type(file)=}' raise ValueError(msg) self._fh.write(header) def write(self, data: ArrayLike, /) -> None: """Append T3 encoded TCSPC histogram to file. Parameters: data: TCSPC histogram image(s) in ``'[T][Z]YX[C]H'`` order. The size must be a multiple of one ``YXCH`` frame of the shape passed to PtuWriter(), for example, a single plane, a volume, or several volumes. Frames are appended in TZ order. The dtype must be unsigned integer. """ from ._ptufile import encode_t3_image data = numpy.asarray(data) if data.dtype.kind != 'u': msg = f'{data.dtype=} is not an unsigned integer' raise ValueError(msg) frame_shape = self._shape[2:] frame_size = product(frame_shape) if data.size % frame_size != 0: msg = ( f'{data.shape=} is not a multiple of frame shape ' f'{frame_shape}' ) raise ValueError(msg) data = data.reshape(-1, *frame_shape) number_photons = int(data.sum(dtype=numpy.uint64)) if self._record_type == PtuRecordType.PicoHarpT3: maxtime = 65536 else: maxtime = 1024 nframes = data.shape[0] global_time = nframes * frame_shape[0] * frame_shape[1] global_time *= self._pixel_time number_records = ( number_photons # overflows assuming all empty pixels + (self._time + global_time) // maxtime # line markers + nframes * frame_shape[0] * 2 # frame markers + nframes ) records = numpy.zeros(number_records, dtype=numpy.uint32) number_records = encode_t3_image( records, data, self._record_type, self._pixel_time, int(2 ** (self._line_start - 1)), int(2 ** (self._line_stop - 1)), int(2 ** (self._frame_change - 1)), self._time, ) if number_records < 0: msg = f'{number_records=} < 0' raise ValueError(msg) if self._fh is None: msg = 'file handle is closed' raise RuntimeError(msg) self._fh.write(records[:number_records].tobytes()) # every pixel consumes exactly pixel_time sync periods self._time = (self._time + global_time) % maxtime self._number_records += number_records self._number_frames += nframes def close(self) -> None: """Close file handle after writing final tag values.""" if self._fh is None: return nplanes = self._shape[1] if nplanes > 1 and self._number_frames % nplanes != 0: logger().warning( f'number of frames written {self._number_frames} ' f'is not a multiple of {nplanes=}' ) if self._number_records_offset > 0: self._fh.seek(self._number_records_offset) self._fh.write(struct.pack(' Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: self.close() @staticmethod def normalize_shape( shape: tuple[int, ...], /, *, dims: Sequence[str] | None = None, ) -> tuple[int, int, int, int, int, int]: """Return TCSPC histogram shape normalized to 6D 'TZYXCH'.""" ndim = len(shape) if dims is not None: dims = [d.upper() for d in dims] if len(dims) != ndim: msg = f'length of {dims=!r} does not match {ndim=}' raise ValueError(msg) invalid = set(dims) - set('TZYXCH') if invalid: msg = f'{dims=!r} contains invalid axis {sorted(invalid)!r}' raise ValueError(msg) positions = ['TZYXCH'.index(d) for d in dims] if len(set(positions)) != len(positions) or positions != sorted( positions ): msg = f'{dims=!r} axes are not in canonical TZYXCH order' raise ValueError(msg) return tuple( shape[dims.index(ax)] if ax in dims else 1 for ax in 'TZYXCH' ) # type: ignore[return-value] if ndim == 6: # TZYXCH return shape # type: ignore[return-value] if ndim == 5: # TYXCH return (shape[0], 1, shape[1], shape[2], shape[3], shape[4]) if ndim == 4: # YXCH return (1, 1, shape[0], shape[1], shape[2], shape[3]) if ndim == 3: # YXH return (1, 1, shape[0], shape[1], 1, shape[2]) msg = f'invalid number of dimensions {len(shape)=}' raise ValueError(msg) class PqFileType(enum.Enum): """PicoQuant file type identifiers.""" PTU = b'PQTTTR\0\0' """TTTR file, PTU, contains raw data in unified TTTR-format.""" PHU = b'PQHISTO\0' """Histogram file, PHU, contains TCSPC histograms.""" PCK = b'PQCHECK\0' """Internal file, PCK, contains post-acquisition analysis results.""" PCO = b'PQCOMNT\0' """Comment file, PCO, contains manually entered text.""" PFS = b'PQDEFLT\0' """Settings file, PFS or PUS, contains factory or user setting defaults.""" PQRES = b'PQRESLT\0' """Result file, PQRES, contains analysis generated during measurement.""" PQDAT = b'PQDATA\0\0' """Data file, PQDAT, contains undocumented data.""" PQUNI = b'PQUNI\0\0\0' """UniHarp file, PQUNI, contains memory and measured data.""" PQOVER = b'PQOVER\0\0' """Unknown file, PQOVER, contains undocumented data.""" SPQR = b'PQSPQR\0\0' """Unknown file, SPQR, contains undocumented data.""" class PqFileError(ValueError): """Exception to indicate invalid PicoQuant tagged file structure.""" 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. memmap: Map file into memory. Ignored if memory mapping is not available or if the stream does not support it. Notes: Memory mapping can improve random-access read performance on large files or repeated reads of the same file regions by reducing syscall overhead and data copying. For sequential one-pass reads, regular buffered I/O may be faster. 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] _mm: mmap.mmap | None _mv: memoryview | None # view of _mm _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 _memmap: bool # open companion files using memmap _lock: contextlib.AbstractContextManager[Any] _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, memmap: bool = False, ) -> None: self._mm = None self._mv = None self._path = '' self._name = 'Unnamed' self._close = False self._closed = False self._memmap = bool(memmap) self._lock = contextlib.nullcontext() 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__ if memmap: _fh: Any = self._fh if isinstance(_fh, mmap.mmap): self._mm = _fh self._mv = memoryview(self._mm) else: try: access = ( mmap.ACCESS_WRITE if self._fh.writable() else mmap.ACCESS_READ ) self._mm = mmap.mmap(self._fh.fileno(), 0, access=access) self._mv = memoryview(self._mm) except OSError: pass @property def filehandle(self) -> IO[bytes]: """File handle.""" return self._fh @property def filepath(self) -> str: """Absolute path to file, or empty string if no path is available.""" return self._path @property def filename(self) -> str: """Basename of file path, or empty string if no path is available.""" return os.path.basename(self._path) @property def dirname(self) -> str: """Directory containing file, or empty string if no path 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 lock(self) -> contextlib.AbstractContextManager[Any]: """Lock for thread-safe file access.""" return self._lock def set_lock(self, enabled: bool, /) -> None: # noqa: FBT001 """Enable or disable thread-safe file access. Parameters: enabled: If true, use a threading.RLock, else a no-op lock. Has no effect when memory-mapped I/O is active. """ if self._mm is not None: return self._lock = threading.RLock() if enabled else contextlib.nullcontext() def _write_at( self, offset: int, data: bytes | bytearray | memoryview, / ) -> None: """Write bytes to file at given offset. Parameters: offset: Byte offset from start of file. data: Data to write. """ if self._mm is not None and self.writable: # writable mmap: direct slice write, no cursor movement self._mm[offset : offset + len(data)] = data else: with self._lock: self._fh.seek(offset) self._fh.write(data) def _read_at(self, offset: int, size: int, /) -> bytes | memoryview: """Read bytes from file at given offset. For memory-mapped files, returned bytes are exposed as a ``memoryview`` of the mapping. Keeping that view alive may keep the memory map (and associated file resources/lock) alive. Parameters: offset: Byte offset from start of file. size: Number of bytes to read. """ mv = self._mv if mv is not None: return mv[offset : offset + size] fh = self._fh with self._lock: fh.seek(offset) return fh.read(size) def _read_into(self, offset: int, buffer: NDArray[numpy.uint8], /) -> int: """Read bytes from file at given offset into existing buffer. Parameters: offset: Byte offset from start of file. buffer: Flat, writable uint8 numpy array to read into. Returns: Number of bytes read. """ nbytes = len(buffer) mv = self._mv if mv is not None: n = min(nbytes, max(0, len(mv) - offset)) buffer[:n] = numpy.frombuffer(mv[offset : offset + n], numpy.uint8) return n fh = self._fh with self._lock: fh.seek(offset) try: return fh.readinto(buffer) # type: ignore[attr-defined, no-any-return] except (AttributeError, OSError): data = fh.read(nbytes) buffer[:] = numpy.frombuffer(data, numpy.uint8) return len(data) def _read_array( self, offset: int, count: int, dtype: DTypeLike, *, copy: bool = False, writable: bool = False, truncate: bool | None = False, ) -> NDArray[Any]: """Read numpy array from file at given offset. For memory-mapped files, returned data are exposed directly as a NumPy array view of the mapping (zero copy). Keeping that array alive may keep the memory map (and associated file resources/lock) alive. Parameters: offset: Byte offset from start of file. count: Number of elements to read. If ``-1``, read to end of file. dtype: Array element type. copy: If true, always return a detached copy in main memory. For memory-mapped files, bypass the direct-view fast path. writable: By default, return read-only array from memory-mapped file. Prevents accidental modification of underlying writable file. Has no effect for non-memory-mapped files (always writable). truncate: Allow partial reads of array. If None, log error on partial read. """ dtype = numpy.dtype(dtype) itemsize = dtype.itemsize if offset < 0: msg = f'{offset=} < 0' raise ValueError(msg) if count < -1: msg = f'{count=} < -1' raise ValueError(msg) mv = self._mv if mv is not None: if count == -1: count = max(0, (len(mv) - offset) // itemsize) nbytes = count * itemsize size = min(nbytes, max(0, len(mv) - offset)) n = size - size % itemsize array = numpy.frombuffer( mv[offset : offset + n], dtype, ) if copy: array = array.copy() elif not writable and array.flags.writeable: array.flags.writeable = False elif count > -1: fh = self._fh nbytes = count * itemsize array = numpy.empty(count, dtype) with self._lock: fh.seek(offset) n = fh.readinto(array.data) # type: ignore[attr-defined] else: fh = self._fh with self._lock: fh.seek(0, os.SEEK_END) size = fh.tell() count = max(0, (size - offset) // itemsize) nbytes = count * itemsize array = numpy.empty(count, dtype) fh.seek(offset) n = fh.readinto(array.data) # type: ignore[attr-defined] if n != nbytes: array = array[: n // itemsize] msg = f'expected {count} items, got {n // itemsize}' if truncate is None: logging.getLogger(__name__.split('.', 1)[0]).error(msg) elif not truncate: raise ValueError(msg) return array @cached_property def filesize(self) -> int: """Size of file in bytes.""" if self._mm is not None: return len(self._mm) fh = self._fh try: return os.fstat(fh.fileno()).st_size except (AttributeError, io.UnsupportedOperation, OSError): pass with self._lock: pos = fh.tell() fh.seek(0, os.SEEK_END) size = fh.tell() fh.seek(pos) return size @property def writable(self) -> bool: """File is open for writing.""" return self._fh.writable() @property def memmapped(self) -> bool: """File is memory-mapped.""" return self._mm is not None @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 self._mv = None # do not release(), threads may hold local refs if self._mm is not None and self._mm is not self._fh: # type: ignore[comparison-overlap] with contextlib.suppress(Exception): self._mm.close() 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}>' class PqFile(BinaryFile): """PicoQuant unified tagged file. PTU, PHU, PCK, PCO, PFS, PUS, PQRES, PQDAT, PQUNI, PQOVER, and SPQR files contain measurement metadata and settings encoded as unified tags. ``PqFile`` and subclass instances are not thread safe. Attributes are read-only unless the corresponding property defines a setter. ``PqFile`` and subclass instances must be closed with :py:meth:`PqFile.close`, which is automatically called when using the 'with' context manager. Parameters: file: File name or seekable binary stream. mode: File open mode if ``file`` is file name. The default is ``'r'``. Files are always opened in binary mode. memmap: Map file into memory. Ignored if memory mapping is not available or if the stream does not support it. fastload: If true, only read tags marked for fast loading, else read all tags. Raises: PqFileError: File is not a PicoQuant tagged file or is corrupted. """ type: PqFileType """PicoQuant file type identifier.""" version: str """File version.""" tags: dict[str, Any] """PicoQuant unified tags.""" _data_offset: int # position of raw data in file _number_records_offset: int # position of TTResult_NumberOfRecords value _TYPE: ClassVar[set[PqFileType]] = set(PqFileType) _STR_: tuple[str, ...] = ('type', 'version') # attributes listed first _MAX_TAG_INDEX: ClassVar[int] = 65535 # limit memory of sparse tags def __init__( self, file: str | os.PathLike[str] | IO[bytes], /, *, mode: Literal['r', 'r+'] | None = None, memmap: bool = False, fastload: bool = False, ) -> None: super().__init__(file, mode=mode, memmap=memmap) self.version = '' self.tags = {} try: self._init(fastload=fastload) except Exception: self.close() raise def _init(self, *, fastload: bool) -> None: header = bytes(self._read_at(0, 16)) offset = 16 try: self.type = PqFileType(header[:8]) except ValueError as exc: msg = ( f'{self.filename!r} is not a {self.__class__.__name__} ' f'{header[:8]!r}' ) raise PqFileError(msg) from exc if self.type not in self._TYPE: msg = ( f'{self.filename!r} type={self.type} is not in {self._TYPE!r}' ) raise PqFileError(msg) self.version = header[8:].strip(b'\0').decode() tags = self.tags def errmsg( msg: str, tagid: str, index: int, typecode: int, value: Any ) -> str: return ( f'{msg} @ {self.name!r} ' f'{tagid=}, {index=}, {typecode=}, {value=!r}' )[:160] tagid: str index: int typecode: int value: Any unpack = struct.unpack try: while True: tagid_, index, typecode, value = struct.unpack_from( '<32siI8s', self._read_at(offset, 48) ) offset += 48 # print(tagid.strip(b'\0'), index, typecode, value) tagid = tagid_.rstrip(b'\0').decode('ascii', errors='ignore') # tags must start on positions divisible by 8 # disabled for PQRES and PQDAT if offset % 8 and self.type not in { PqFileType.PQRES, PqFileType.PQDAT, }: logger().error( errmsg( f'tag offset={offset - 48} not divisible by 8', tagid, index, typecode, value, ) ) if tagid == 'Header_End': break if tagid == 'Fast_Load_End': if fastload: break continue match typecode: # frequent typecodes case PqTagType.Int8: value = unpack(' self._MAX_TAG_INDEX: logger().error( errmsg( 'tag index too large', tagid, index, typecode, value, ) ) elif tagid not in tags: # sparse indices are valid, missing entries are None tags[tagid] = [None] * index + [value] else: values = tags[tagid] if index >= len(values): values.extend([None] * (index - len(values))) values.append(value) else: if ( values[index] is not None and values[index] != value ): logger().warning( errmsg( 'duplicate tag', tagid, index, typecode, value, ) + f' != {values[index]!r}'[:16] ) values[index] = value except Exception as exc: raise PqFileError( errmsg('tag corrupted', tagid, index, typecode, value) ) from exc self._data_offset = offset @property def guid(self) -> uuid.UUID: """Global identifier of file.""" return uuid.UUID(self.tags['File_GUID']) @property def comment(self) -> str | None: """File comment, if any.""" return self.tags.get('File_Comment') @property def datetime(self) -> datetime | None: """File creation date, if any.""" if 'File_CreatingTime' not in self.tags: return None value = self.tags['File_CreatingTime'] if isinstance(value, datetime): return value if isinstance(value, str): try: return datetime.fromisoformat(value) except ValueError: try: from dateutil import parser return parser.parse(value) except Exception: return None if not isinstance(value, float): return None try: return datetime(1899, 12, 30) + timedelta(days=value) except Exception: return None @override @property def attrs(self) -> dict[str, Any]: """Selected metadata as dict.""" return { **super().attrs, 'type': self.type.name, 'version': self.version, 'guid': str(self.guid), 'comment': self.comment, 'datetime': ( None if self.datetime is None else self.datetime.isoformat() ), 'tags': self.tags, } @override def __enter__(self) -> Self: return self def __str__(self) -> str: return indent( repr(self), *( f'{name}: {getattr(self, name)!r}'[:160] for name in self._STR_ if getattr(self, name) is not None ), *( f'{name}: {getattr(self, name)!r}'[:160] for name in dir(self) if not ( name in self._STR_ or name in {'attrs', 'dirname', 'tags', 'name', 'filehandle'} or name.startswith('_') or callable(getattr(self, name)) ) ), indent( 'tags:', *( f'{key}: {value!r}'[:160] for key, value in self.tags.items() ), ), ) @final class PhuFile(PqFile): """PicoQuant histogram file. PHU files contain a series of TCSPC histograms in addition to unified tags. ``PhuFile`` instances are derived from :py:class:`PqFile`. Parameters: file: File name or seekable binary stream. mode: File open mode if ``file`` is file name. The default is ``'r'``. Files are always opened in binary mode. memmap: Map file into memory. Ignored if memory mapping is not available or if the stream does not support it. Raises: PqFileError: File is not a PicoQuant PHU file or is corrupted. """ _TYPE: ClassVar[set[PqFileType]] = {PqFileType.PHU} _STR_ = ('type', 'version', 'measurement_mode', 'measurement_submode') def __init__( self, file: str | os.PathLike[str] | IO[bytes], /, *, mode: Literal['r', 'r+'] | None = None, memmap: bool = False, ) -> None: super().__init__(file, mode=mode, memmap=memmap) @override def __enter__(self) -> Self: return self @property def measurement_mode(self) -> PhuMeasurementMode: """Kind of measurement: HISTOGRAM or CONTI.""" return PhuMeasurementMode(self.tags['Measurement_Mode']) @property def measurement_submode(self) -> PhuMeasurementSubMode: """Sub-kind of measurement: OSCILLOSCOPE, INTEGRATING, or TRES.""" return PhuMeasurementSubMode(self.tags['Measurement_SubMode']) @property def tcspc_resolution(self) -> float: """Resolution of TCSPC in s (BaseResolution * iBinningFactor).""" return float(self.tags.get('MeasDesc_Resolution', 0.0)) @property def histogram_resolutions(self) -> tuple[float, ...] | None: """Base resolution for each histogram.""" ncurves = self.number_histograms if 'HistResDscr_HWBaseResolution' in self.tags: return tuple(self.tags['HistResDscr_HWBaseResolution']) if 'HW_BaseResolution' in self.tags: return tuple([float(self.tags['HW_BaseResolution'])] * ncurves) return None @property def number_histograms(self) -> int: """Number of histograms stored in file.""" return int(self.tags['HistoResult_NumberOfCurves']) # @property # def bits_per_bin(self) -> int: # """Number of bits per histogram bin.""" # return int(self.tags.get('HistoResult_BitsPerBin', 32)) @override @property def attrs(self) -> dict[str, Any]: """Selected metadata as dict.""" return { **super().attrs, 'measurement_mode': self.measurement_mode.name, 'measurement_submode': self.measurement_submode.name, 'tcspc_resolution': self.tcspc_resolution, } @overload def histograms( self, index: int | slice | None = None, /, *, asxarray: Literal[False] = ..., ) -> tuple[NDArray[numpy.uint32], ...]: ... @overload def histograms( self, index: int | slice | None = None, /, *, asxarray: Literal[True] = ..., ) -> tuple[DataArray, ...]: ... @overload def histograms( self, index: int | slice | None = None, /, *, asxarray: bool = ..., ) -> tuple[NDArray[numpy.uint32] | DataArray, ...]: ... def histograms( self, index: int | slice | None = None, /, *, asxarray: bool = False ) -> tuple[NDArray[numpy.uint32] | DataArray, ...]: """Return sequences of histograms from file. Parameters: index: Index of histogram(s) to return. By default, all histograms are returned. asxarray: If true, return histograms as ``xarray.DataArray``, else ``numpy.ndarray`` (default). """ if index is None: index = slice(None) elif isinstance(index, int): index = slice(index, index + 1) ncurves = self.number_histograms if len(self.tags['HistResDscr_DataOffset']) != ncurves: msg = 'invalid HistResDscr_DataOffset tag' raise ValueError(msg) if len(self.tags['HistResDscr_HistogramBins']) != ncurves: msg = 'invalid HistResDscr_HistogramBins tag' raise ValueError(msg) histograms: list[NDArray[numpy.uint32] | DataArray] = [] for offset, nbins in zip( self.tags['HistResDscr_DataOffset'][index], self.tags['HistResDscr_HistogramBins'][index], strict=True, ): hist = self._read_array( offset, nbins, dtype=numpy.uint32, copy=False, writable=False, truncate=None, ) histograms.append(hist) if asxarray: from xarray import DataArray assert self.histogram_resolutions is not None histograms = [ DataArray( h, dims=('H',), coords={ # TODO: is this correct? 'H': numpy.linspace( 0, h.size * r, h.size, endpoint=False ) }, attrs=self.attrs, # name=self.name, ) for h, r in zip( histograms, self.histogram_resolutions, strict=True ) ] # TODO: do not return tuple if index is integer? return tuple(histograms) def plot(self, *, verbose: bool = False, show: bool = True) -> None: """Plot histograms using matplotlib. Parameters: verbose: Print information about histogram arrays. show: If true (default), display all figures. Else, defer to user or environment to display figures. """ from matplotlib import pyplot from tifffile import Timer t = Timer() histograms = self.histograms(asxarray=True) if verbose: t.print('decode histograms') print() for hist in histograms: print(hist) for i, hist in enumerate(histograms): y = numpy.trim_zeros(hist.values, trim='b') x = hist.coords['H'].values[: y.size] pyplot.plot(x, y, label=f'ch {i}') pyplot.title(repr(self)) pyplot.xlabel('delay time [s]') pyplot.ylabel('photon count') pyplot.legend() if show: pyplot.show() @final class PtuFile(PqFile): """PicoQuant time-tagged time-resolved (TTTR) file. PTU files contain TTTR records in addition to unified tags. ``PtuFile`` instances are not thread-safe. ``PtuFile`` is derived from :py:class:`PqFile`. Parameters: file: File name or seekable binary stream. mode: File open mode if ``file`` is file name. The default is ``'r'``. Files are always opened in binary mode. memmap: Map file into memory. Ignored if memory mapping is not available or if the stream does not support it. trimdims: Axes to trim. The default is ``'TCH'``: - ``'T'``: remove incomplete first or last frame. - ``'C'``: remove leading and trailing channels without photons. Else use record type's default :py:attr:`number_channels_max`. - ``'H'``: remove trailing delay-time bins without photons. Else use record type's default :py:attr:`number_bins_max`. Raises: PqFileError: File is not a PicoQuant PTU file or is corrupted. """ _trimdims: set[str] _cache: bool # cache records in memory _asxarray: bool # return DataArray from slicing and decode_ functions _dtype: numpy.dtype[Any] _records: NDArray[numpy.uint32] | None # cached records _TYPE: ClassVar[set[PqFileType]] = {PqFileType.PTU} _STR_ = ( 'type', 'version', 'record_type', 'measurement_mode', 'measurement_submode', 'scanner', 'sizes', ) def __init__( self, file: str | os.PathLike[str] | IO[bytes], /, *, mode: Literal['r', 'r+'] | None = None, memmap: bool = False, trimdims: Sequence[Dimension] | str | None = None, ) -> None: self._records = None super().__init__(file, mode=mode, memmap=memmap) try: if trimdims is None: self._trimdims = {'T', 'C', 'H'} else: self._trimdims = {ax.upper() for ax in trimdims} self._dtype = numpy.dtype(numpy.uint16) self._asxarray = False self._cache = not memmap # memmap takes care of caching except Exception: self.close() raise @override def __enter__(self) -> Self: return self def __getitem__(self, key: Any, /) -> NDArray[Any] | DataArray: return self.decode_image(key, keepdims=False) @override def close(self) -> None: """Close file handle and free resources.""" del self._records # close numpy.memmap file handle self._records = None super().close() @property def record_offset(self) -> int: """Position of records in file.""" return self._data_offset @property def record_type(self) -> PtuRecordType: """Type of TTTR records. Defines the TCSPC device and type of measurement that produced the records. """ return PtuRecordType(self.tags['TTResultFormat_TTTRRecType']) @property def measurement_mode(self) -> PtuMeasurementMode: """Kind of TCSPC measurement: T2 or T3.""" return PtuMeasurementMode(self.tags['Measurement_Mode']) @property def measurement_submode(self) -> PtuMeasurementSubMode: """Sub-kind of measurement: Point, line, or image scan.""" return PtuMeasurementSubMode(self.tags['Measurement_SubMode']) @property def measurement_ndim(self) -> int: """Dimensionality of measurement.""" # Measurement_SubMode is not always correct submode = self.tags['Measurement_SubMode'] if ( submode == 3 and self.tags.get('ImgHdr_Dimensions', 3) == 3 # optional # and self.tags.get('ImgHdr_PixY', 1) > 1 # may be missing ): return 3 if ( submode == 3 and self.tags.get('ImgHdr_Dimensions', 0) == 4 # optional # and self.tags.get('ImgHdr_PixZ', 1) > 1 # may be missing ): return 4 if ( submode == 2 and self.tags.get('ImgHdr_Dimensions', 2) == 2 # optional # and self.tags.get('ImgHdr_PixX', 1) > 1 # may be missing ): # TODO: need linescan test file return 2 return 1 @property def measurement_warnings(self) -> PtuMeasurementWarnings | None: """Warnings during measurement, or None if not specified.""" if 'TTResult_MDescWarningFlags' in self.tags: return PtuMeasurementWarnings( self.tags['TTResult_MDescWarningFlags'] ) return None @property def hardware_features(self) -> PtuHwFeatures | None: """Hardware features, or None if not specified.""" if 'HW_Features' in self.tags: return PtuHwFeatures(self.tags['HW_Features']) return None @property def stop_reason(self) -> PtuStopReason | None: """Reason for measurement end, or None if not specified.""" if 'TTResult_StopReason' in self.tags: return PtuStopReason(self.tags['TTResult_StopReason']) return None @property def scanner(self) -> PtuScannerType | None: """Scanner hardware, or None if not specified.""" if 'ImgHdr_Ident' in self.tags: return PtuScannerType(self.tags['ImgHdr_Ident']) return None @property def global_resolution(self) -> float: """Resolution of time tags in s.""" return float(self.tags['MeasDesc_GlobalResolution']) @property def tcspc_resolution(self) -> float: """Resolution of TCSPC in s (BaseResolution * iBinningFactor).""" return float(self.tags.get('MeasDesc_Resolution', 0.0)) @cached_property def number_records(self) -> int: """Number of TTTR records.""" # NOTE: this does not catch invalid TTResult_NumberOfRecords > 0 count = value = int(self.tags.get('TTResult_NumberOfRecords', 0)) if count <= 0: count = (self.filesize - self._data_offset) // 4 if count != 0: logger().warning( f'{self!r} invalid TTResult_NumberOfRecords={value}. ' 'Using remaining file content as records' ) return count @property def number_photons(self) -> int: """Number of photons counted.""" return self._info.photons @property def number_markers(self) -> int: """Number of marker events.""" return self._info.markers @property def number_images(self) -> int: """Number of images separated by frame change markers.""" return self._info.frames @property def number_planes(self) -> int: """Number of Z planes.""" return max(int(self.tags.get('ImgHdr_PixZ', 1)), 1) @property def number_lines(self) -> int: """Number of line marker pairs.""" return self._info.lines @property def number_channels_max(self) -> int: """Maximum number of channels for record type.""" return self._info.channels @property def number_channels(self) -> int: """Number of channels, without leading and trailing empty channels.""" return ( 1 + self._info.channels_active_last - self._info.channels_active_first ) @property def active_channels(self) -> tuple[int, ...]: """Indices of un-trimmed channels containing photons.""" channels_active = self._info.channels_active return tuple( ch for ch in range(self._info.channels) if channels_active & (1 << ch) ) @property def number_bins_max(self) -> int: """Maximum number of delay-time bins for record type.""" return self._info.bins @property def number_bins(self) -> int: """Number of bins up to the largest occupied delay-time bin. Not available for T2 records. """ return self._info.bins_used @property def number_bins_in_period(self) -> int: """Delay time in one period. Not available for T2 records. Same as ``global_resolution / tcspc_resolution`` """ if self.tcspc_resolution < 1e-14: return 1 nbins = math.floor(self.global_resolution / self.tcspc_resolution) return max(nbins, 1) @property def line_start_mask(self) -> int: """Marker mask defining line start, or 0 if not defined.""" value = self.tags.get('ImgHdr_LineStart', None) return int(2 ** (value - 1) if value is not None else 0) @property def line_stop_mask(self) -> int: """Marker mask defining line end, or 0 if not defined.""" value = self.tags.get('ImgHdr_LineStop', None) return int(2 ** (value - 1) if value is not None else 0) @property def frame_change_mask(self) -> int: """Marker mask defining image frame change, or 0 if not defined.""" value = self.tags.get('ImgHdr_Frame', None) return int(2 ** (value - 1) if value is not None else 0) @property def global_pixel_time(self) -> int: """Global time per pixel. Multiply with global resolution to get time in s. """ if self.tags.get('ImgHdr_TimePerPixel', 0.0) > 0.0: pixeltime = ( float(self.tags['ImgHdr_TimePerPixel']) / float(self.tags['MeasDesc_GlobalResolution']) / 1e3 ) elif self._info.lines > 0: pixeltime = self._info.line_time / self.pixels_in_line else: pixeltime = 1e-3 / float(self.tags['MeasDesc_GlobalResolution']) return max(1, round(pixeltime)) @property def global_line_time(self) -> int: """Global time per line, excluding retrace. Might be approximate. Multiply with global resolution to get time in s. """ if 'ImgHdr_TimePerPixel' in self.tags: linetime = self.pixels_in_line * self.global_pixel_time elif self._info.lines > 0: linetime = self._info.line_time else: # point scan: line of one pixel linetime = 1e-3 / self.tags['MeasDesc_GlobalResolution'] return round(linetime) @property def global_frame_time(self) -> int: """Global time per image, line, or point scan cycle. Multiply with global resolution to get time in s. """ if self.tags['Measurement_SubMode'] == 3: # image, including retrace if self._info.frames == 0: return self._info.acquisition_time return self._info.acquisition_time // self._info.frames if self._info.lines > 0: # line scan return self._info.line_time # point scan return self.pixels_in_frame * self.global_pixel_time @property def global_volume_time(self) -> int: """Global time per z-stack, or :py:attr:`global_frame_time`. Multiply with global resolution to get time in s. """ if self.measurement_ndim == 4: return self.global_frame_time * self.number_planes return self.global_frame_time @property def global_acquisition_time(self) -> int: """Global time of acquisition.""" # MeasDesc_AcquisitionTime not reliable # aqt = self.tags.get('MeasDesc_AcquisitionTime', 0.0) # if aqt > 0: # return round(1e-3 * aqt / self.global_resolution) return self._info.acquisition_time @property def pixels_in_frame(self) -> int: """Number of pixels in one scan cycle.""" return self.lines_in_frame * self.pixels_in_line @property def pixels_in_line(self) -> int: """Number of pixels in line.""" ndim = self.measurement_ndim if ndim in {3, 4}: # image pixels = self.tags['ImgHdr_PixX'] elif ndim == 2: # line scan time_per_pixel = self.tags['ImgHdr_TimePerPixel'] line_frequency = self.tags['ImgHdr_LineFrequency'] if time_per_pixel > 0.0 and line_frequency > 0.0: pixels = round(1e-3 / (time_per_pixel * line_frequency)) else: pixels = 1 else: pixels = 1 return max(1, int(pixels)) @property def lines_in_frame(self) -> int: """Number of lines in frame.""" if self.measurement_ndim in {3, 4}: # TODO: Warning from the PicoQuant documentation: # Attention, in some images one will find a different number # of lines than defined by PixY (less or more, even different # in every frame), so do not trust this value. return max(1, int(self.tags['ImgHdr_PixY'])) return 1 @property def pixel_time(self) -> float: """Time per pixel in s.""" pixel_time = float(self.tags.get('ImgHdr_TimePerPixel', 0.0)) if pixel_time > 0.0: return pixel_time * 1e-3 # ms to s return self.global_pixel_time * self.global_resolution @property def line_time(self) -> float: """Average time between line markers or ``pixel_time`` in s.""" return self.global_line_time * self.global_resolution @property def frame_time(self) -> float: """Time per image, line, or point scan cycle in s. Image scan times include retrace. """ return self.global_frame_time * self.global_resolution @property def volume_time(self) -> float: """Time per z-stack, or :py:attr:`frame_time` in s.""" return self.global_volume_time * self.global_resolution @property def acquisition_time(self) -> float: """Duration of acquisition in s.""" # MeasDesc_AcquisitionTime not reliable # if 'MeasDesc_AcquisitionTime' in self.tags: # return self.tags['MeasDesc_AcquisitionTime'] * 1e-3 return self._info.acquisition_time * self.global_resolution @property def frequency(self) -> float: """Repetition frequency in Hz. The inverse of :py:attr:`PtuFile.global_resolution`. """ period = float(self.tags.get('MeasDesc_GlobalResolution', 0.0)) return 1.0 / period if period > 1e-14 else 0.0 @property def syncrate(self) -> int: """Sync events per s as recorded at beginning of measurement.""" return int(self.tags['TTResult_SyncRate']) @property def is_image(self) -> bool: """File contains image data.""" return ( self.tags['Measurement_SubMode'] == 3 and self.tags.get('ImgHdr_Dimensions', 1) in {3, 4} and 'ImgHdr_PixX' in self.tags # some Leica PTU are missing this ) @property def is_t3(self) -> bool: """File contains T3 records.""" return bool(self.tags['Measurement_Mode'] == 3) # return self.tags['TTResultFormat_TTTRRecType'] in { # 0x00010303, 0x00010304, 0x01010304, # 0x00010305, 0x00010306, 0x00010307, # } @property def is_bidirectional(self) -> bool: """Bidirectional scan mode.""" return bool(self.tags.get('ImgHdr_BiDirect', 0) > 0) @property def is_sinusoidal(self) -> bool: """Sinusoidal scan mode.""" return bool(self.tags.get('ImgHdr_SinCorrection', 0) != 0) @property def use_xarray(self) -> bool: """Slicing and decode methods return ``xarray.DataArray``.""" return self._asxarray @use_xarray.setter def use_xarray(self, value: bool, /) -> None: self._asxarray = bool(value) @property def cache_records(self) -> bool: """Cache records read from file in memory.""" return self._cache @cache_records.setter def cache_records(self, value: bool, /) -> None: self._cache = bool(value) if not self._cache: del self._records # close numpy.memmap file handle self._records = None @property def dtype(self) -> numpy.dtype[Any]: """Data type of image histogram array.""" return self._dtype @dtype.setter def dtype(self, dtype: DTypeLike | None, /) -> None: dtype = numpy.dtype('uint16' if dtype is None else dtype) if dtype.kind != 'u': msg = f'{dtype=!r} not an unsigned integer' raise ValueError(msg) self._dtype = dtype @cached_property def shape(self) -> tuple[int, ...]: """Shape of image histogram array.""" if not self.is_t3: return () if 'C' in self._trimdims: nchannels = max(self.number_channels, 1) else: nchannels = self.number_channels_max if 'H' in self._trimdims: nbins = self.number_bins else: nbins = self.number_bins_max match self.measurement_ndim: case 4: nframes = self._info.frames nplanes = self.number_planes if nframes % nplanes != 0: logger().warning( f'{self!r} ' f'{nframes=} is not a multiple of {nplanes=}. ' 'Truncating to last complete z-stack' ) return ( max(nframes // nplanes, 1), nplanes, self.lines_in_frame, self.pixels_in_line, nchannels, nbins, ) case 3: return ( max(self._info.frames, 1), self.lines_in_frame, self.pixels_in_line, nchannels, nbins, ) case 2: return ( max(self._info.lines, 1), self.pixels_in_line, nchannels, nbins, ) case 0 | 1: return ( max(1, self._info.photons // self.global_pixel_time), nchannels, nbins, ) case _: return () @cached_property def dims(self) -> tuple[str, ...]: """Axes labels for each dimension in image histogram array.""" if not self.shape: return () match self.measurement_ndim: case 4: return ('T', 'Z', 'Y', 'X', 'C', 'H') case 3: return ('T', 'Y', 'X', 'C', 'H') case 2: return ('T', 'X', 'C', 'H') case _: return ('T', 'C', 'H') @property def sizes(self) -> dict[str, int]: """Map dimension names to lengths.""" return dict(zip(self.dims, self.shape, strict=True)) @property def ndims(self) -> int: """Number of dimensions in image histogram array.""" return len(self.dims) @property def nbytes(self) -> int: """Number of bytes consumed by image histogram.""" size = 1 for i in self.shape: size *= int(i) return size * self._dtype.itemsize @property def size(self) -> int: """Number of elements in image histogram.""" size = 1 for i in self.shape: size *= int(i) return size @property def itemsize(self) -> int: """Length of one array element in image histogram in bytes.""" return self._dtype.itemsize @property def _coords_c(self) -> NDArray[Any]: """Coordinate array labelling all channels.""" if 'C' in self._trimdims: return numpy.arange( self._info.channels_active_first, self._info.channels_active_last + 1, dtype=numpy.uint8, ) return numpy.arange(self._info.channels, dtype=numpy.uint8) @property def _coords_h(self) -> NDArray[Any]: """Coordinate array labelling all delay-time bins.""" if 'H' in self._trimdims: nbins = self.number_bins else: nbins = self.number_bins_max return numpy.linspace( 0, nbins * self.tags['MeasDesc_Resolution'], nbins, endpoint=False ) @cached_property def coords(self) -> dict[str, NDArray[Any]]: """Coordinate arrays labelling each point in image histogram array. Coordinates for the time axis are approximate. Exact coordinates are returned with :py:meth:`PtuFile.decode_image` as ``xarray.DataArray``. """ if not self.shape: return {} ndim = self.measurement_ndim coords = {} shape = self.shape # exact time coordinates must be decoded from records coords['T'] = numpy.linspace( 0, shape[0] * self.volume_time, shape[0], endpoint=False ) res = self.tags.get('ImgHdr_PixResol', None) if res is not None: res *= 1e-6 # um if ndim > 3: resz = self.tags.get('ImgHdr_PixResolZ', None) if resz is not None: offset = self.tags.get('ImgHdr_Z0', 0.0) * 1e-6 # um coords['Z'] = numpy.linspace( offset, offset + shape[-5] * resz * 1e-6, shape[-5], endpoint=False, ) if ndim > 2: offset = self.tags.get('ImgHdr_Y0', 0.0) * 1e-6 # um coords['Y'] = numpy.linspace( offset, offset + shape[-4] * res, shape[-4], endpoint=False ) if ndim > 1: offset = self.tags.get('ImgHdr_X0', 0.0) * 1e-6 coords['X'] = numpy.linspace( offset, offset + shape[-3] * res, shape[-3], endpoint=False ) coords['C'] = self._coords_c coords['H'] = self._coords_h return coords @override @property def attrs(self) -> dict[str, Any]: """Selected metadata as dict.""" return { **super().attrs, 'acquisition_time': self.acquisition_time, 'active_channels': self.active_channels, 'frame_time': self.frame_time, 'frequency': self.frequency, 'global_acquisition_time': self.global_acquisition_time, 'global_volume_time': self.global_volume_time, 'global_frame_time': self.global_frame_time, 'global_line_time': self.global_line_time, 'global_pixel_time': self.global_pixel_time, 'global_resolution': self.global_resolution, 'line_time': self.line_time, 'max_delaytime': self.number_bins_max, # for PhasorPy 'measurement_mode': self.measurement_mode.name, 'measurement_submode': self.measurement_submode.name, 'number_bins': self.number_bins, 'number_bins_in_period': self.number_bins_in_period, 'number_bins_max': self.number_bins_max, 'pixel_time': self.pixel_time, 'record_type': self.record_type.name, 'scanner': None if self.scanner is None else self.scanner.name, 'syncrate': self.syncrate, 'tcspc_resolution': self.tcspc_resolution, } @cached_property def _info(self) -> PtuInfo: """Information about decoded records.""" from ._ptufile import decode_info lines_in_frame = 0 if ( 'T' in self._trimdims and 'ImgHdr_PixY' in self.tags and self.tags['Measurement_SubMode'] == 3 # trimming incomplete frames would misalign Z planes and self.measurement_ndim != 4 ): lines_in_frame = max(1, self.tags['ImgHdr_PixY']) return PtuInfo( *decode_info( self.read_records(copy=False), self.tags['TTResultFormat_TTTRRecType'], self.line_start_mask, self.line_stop_mask, self.frame_change_mask, lines_in_frame, ) ) def read_records(self, *, copy: bool = True) -> NDArray[numpy.uint32]: """Return encoded TTTR records from file. If copy is false for a memory-mapped file, records are exposed directly as a NumPy array view of the mapping. Keeping that array alive may keep the memory map (and associated file resources/lock) alive. Records are cached according to :py:attr:`PtuFile.cache_records`: disabled by default for memory-mapped files, enabled otherwise. Parameters: copy: If true, always return a detached copy in main memory. This avoids cache corruption and returning arrays that reference memory-mapped resources. """ if self._cache and self._records is not None: if copy: return self._records.copy() return self._records if self.tags['TTResultFormat_BitsPerRecord'] not in {0, 32}: msg = f"invalid {self.tags['TTResultFormat_BitsPerRecord']=}" raise ValueError(msg) records = self._read_array( self._data_offset, self.number_records, numpy.uint32, copy=copy, truncate=None, # log errors on partial reads ) if self._cache: self._records = records if copy: return records.copy() return records def decode_records( self, records: NDArray[numpy.uint32] | None = None, /, *, out: OutputType = None, ) -> NDArray[Any]: """Return decoded TTTR records. Parameters: records: Encoded TTTR records. By default, read records from file. out: Array where decoded records are stored. If ``None``, create a new NumPy recarray in main memory. If ``'memmap'``, create a memory-mapped recarray in a temporary file. If a ``numpy.ndarray``, a writable recarray of compatible shape and dtype. If a ``file name`` or ``open file``, create a memory-mapped array in the specified file. Returns: : ``numpy.recarray`` of size :py:attr:`number_records` and dtype :py:attr:`T3_RECORD_DTYPE` or :py:attr:`T2_RECORD_DTYPE`. A channel >= 0 indicates that a record contains a photon. Otherwise, the record contains an overflow event or marker > 0. """ from ._ptufile import decode_t2_records, decode_t3_records if records is None: records = self.read_records(copy=False) rectype = self.tags['TTResultFormat_TTTRRecType'] if self.is_t3: result = create_output( out, (records.size,), T3_RECORD_DTYPE, fillvalue=0 ) decode_t3_records(result, records, rectype) else: result = create_output( out, (records.size,), T2_RECORD_DTYPE, fillvalue=0 ) decode_t2_records(result, records, rectype) return result @overload def decode_histogram( self, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, sampling_time: int | None = None, dtime: int | None = None, asxarray: Literal[False] = ..., out: OutputType = None, ) -> NDArray[Any]: ... @overload def decode_histogram( self, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, sampling_time: int | None = None, dtime: int | None = None, asxarray: Literal[True] = ..., out: OutputType = None, ) -> DataArray: ... @overload def decode_histogram( self, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, sampling_time: int | None = None, dtime: int | None = None, asxarray: bool = ..., out: OutputType = None, ) -> NDArray[Any] | DataArray: ... def decode_histogram( self, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, sampling_time: int | None = None, dtime: int | None = None, asxarray: bool = False, out: OutputType = None, ) -> NDArray[Any] | DataArray: """Return histogram of all photons by channel. Parameters: records: Encoded TTTR records. By default, read records from file. dtype: Unsigned integer type of histogram array. The default is ``uint32`` for T3, else ``uint16``. Increase the bit depth to avoid overflows. sampling_time: Global time per sample for T2 mode. The default is :py:attr:`PtuFile.global_pixel_time`. dtime: Number of bins in histogram. If 0, return :py:attr:`number_bins_in_period` bins. If > 0, return up to specified bin. asxarray: If true, return ``xarray.DataArray``, else ``numpy.ndarray`` (default). out: Array where decoded histogram is stored. If ``None``, create a new NumPy array in main memory. If ``'memmap'``, create a memory-mapped array in a temporary file. If a ``numpy.ndarray``, a writable, initialized array matching the resulting 2D histogram shape and unsigned integer dtype. If a ``file name`` or ``open file``, create a memory-mapped array in the specified file. Returns: : Decoded TTTR T3 records as 2-dimensional histogram array: - ``'C'`` channel - ``'H'`` histogram bins """ from ._ptufile import decode_t2_histogram, decode_t3_histogram if dtype is None: dtype = numpy.uint32 if self.is_t3 else numpy.uint16 dtype = numpy.dtype(dtype) if dtype.kind != 'u': msg = f'not an unsigned integer {dtype=!r}' raise ValueError(msg) if records is None: records = self.read_records(copy=False) rectype = self.tags['TTResultFormat_TTTRRecType'] if 'C' in self._trimdims: first_channel = self._info.channels_active_first else: first_channel = 0 if self.is_t3: if dtime is None: nbins = self.shape[-1] elif dtime == 0: nbins = self.number_bins_in_period elif dtime > 0: nbins = dtime else: msg = f'{dtime=} < 0' raise ValueError(msg) histogram = create_output( out, (self.shape[-2], nbins), dtype, fillvalue=0 ) decode_t3_histogram(histogram, records, rectype, first_channel) coords = numpy.linspace( 0, histogram.shape[-1] * self.tags['MeasDesc_Resolution'], histogram.shape[-1], endpoint=False, ) else: if sampling_time is None or sampling_time <= 0: sampling_time = self.global_pixel_time histogram = create_output( out, ( self.number_channels, max(1, self.global_acquisition_time // sampling_time), ), dtype, fillvalue=0, ) decode_t2_histogram( histogram, records, rectype, sampling_time, first_channel ) coords = numpy.linspace( 0, self.acquisition_time, histogram.shape[1], endpoint=False ) if not self._asxarray and not asxarray: return histogram from xarray import DataArray return DataArray( histogram, dims=('C', 'H'), coords={'C': self._coords_c, 'H': coords}, # name=self.name attrs=self.attrs, ) @overload def decode_image( self, selection: Sequence[int | slice | EllipsisType | None] | None = None, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, frame: int | None = None, plane: int | None = None, channel: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, keepdims: bool = True, asxarray: Literal[False] = ..., out: OutputType = None, ) -> NDArray[Any]: ... @overload def decode_image( self, selection: Sequence[int | slice | EllipsisType | None] | None = None, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, frame: int | None = None, plane: int | None = None, channel: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, keepdims: bool = True, asxarray: Literal[True] = ..., out: OutputType = None, ) -> DataArray: ... @overload def decode_image( self, selection: Sequence[int | slice | EllipsisType | None] | None = None, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, frame: int | None = None, plane: int | None = None, channel: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, keepdims: bool = True, asxarray: bool = ..., out: OutputType = None, ) -> NDArray[Any] | DataArray: ... def decode_image( self, selection: Sequence[int | slice | EllipsisType | None] | None = None, /, *, records: NDArray[numpy.uint32] | None = None, dtype: DTypeLike | None = None, frame: int | None = None, plane: int | None = None, channel: int | None = None, dtime: int | None = None, pixel_time: float | None = None, bishift: int | None = None, keepdims: bool = True, asxarray: bool = False, out: OutputType = None, ) -> NDArray[Any] | DataArray: """Return T3 mode point, line, image, or z-stack histogram. The histogram may not include photons counted during incomplete frame scans or during line retraces. Parameters: selection: Indices for all dimensions: - ``None``: return all items along axis (default). - ``Ellipsis``: return all items along multiple axes. - ``int``: return single item along axis. - ``slice``: return chunk of axis. ``slice.step`` is binning factor. If ``slice.step=-1``, integrate all items along axis. records: Encoded TTTR records. By default, read records from file. dtype: Unsigned integer type of image histogram array. The default is ``uint16``. Increase the bit depth to avoid overflows when integrating. frame: If < 0, integrate time axis, else return specified frame (or volume for z-stacks). Overrides ``selection`` for axis ``T``. plane: If < 0, integrate Z axis, else return specified plane. Overrides ``selection`` for axis ``Z``. Only valid for z-stacks. channel: If < 0, integrate channel axis, else return specified channel. Overrides ``selection`` for axis ``C``. dtime: Number of bins in image histogram. If 0, return :py:attr:`number_bins_in_period` bins. If < 0, integrate delay time axis. If > 0, return up to specified bin. Overrides ``selection`` for axis ``H``. pixel_time: float, optional Time per pixel in s. If zero, determine pixel times per line from line scan markers. Not supported for line-scan measurements or sinusoidal scanning. Bidirectional image scans are supported. The default is :py:attr:`PtuFile.pixel_time`. bishift: Global time shift of odd vs. even lines in bidirectional mode. The default is zero. Positive shifts invalidate left odd columns, while negative shifts invalidate right odd columns. keepdims: If true (default), reduced axes are left as size-one dimension. asxarray: If true, return ``xarray.DataArray``, else ``numpy.ndarray`` (default). out: Array where decoded image histogram is stored. If ``None``, create a new NumPy array in main memory. If ``'memmap'``, create a memory-mapped array in a temporary file. If a ``numpy.ndarray``, a writable, initialized array of :py:attr:`shape` and unsigned integer dtype. If a ``file name`` or ``open file``, create a memory-mapped array in the specified file. Returns: image: Decoded TTTR T3 records as up to 6-dimensional image array: - ``'T'`` time/frame/volume - ``'Z'`` focal plane for z-stacks - ``'Y'`` slow scan axis for image scans - ``'X'`` fast scan axis for line and image scans - ``'C'`` channel - ``'H'`` histogram bins Raises: NotImplementedError: T2 images, bidirectional sinusoidal scanning, and deprecated image reconstruction are not supported. IndexError: Selection is out of bounds. """ # TODO: support ReqHdr_ScanningPattern = 1, bidirectional per frame if not self.is_t3: # TODO: T2 images msg = 'not a T3 image' raise NotImplementedError(msg) if self.is_bidirectional and not self.is_image: msg = 'bidirectional scanning only supported for images' raise NotImplementedError(msg) if self.is_image and 'ImgHdr_LineStart' not in self.tags: # TODO: deprecated image reconstruction using # ImgHdr_PixResol, ImgHdr_TStartTo, ImgHdr_TStopTo, # ImgHdr_TStartFro, ImgHdr_TStopFro msg = 'old-style image reconstruction' raise NotImplementedError(msg) shape = list(self.shape) ndim = len(shape) keepaxes: list[slice | int] = [slice(None)] * ndim if selection is None: selection = [None] * ndim else: try: len(selection) except TypeError: selection = [selection] # type: ignore[list-item] if len(selection) > ndim: msg = f'too many indices in {selection=}' raise IndexError(msg) if len(selection) == ndim: selection = list(selection).copy() if Ellipsis in selection: selection[selection.index(Ellipsis)] = None # elif len(selection) < ndim: elif Ellipsis in selection: selection = list(selection).copy() i = selection.index(Ellipsis) selection = ( selection[:i] + ([None] * (1 + ndim - len(selection))) + selection[i + 1 :] ) else: selection = list(selection) + [None] * (ndim - len(selection)) if Ellipsis in selection: msg = f'more than one Ellipsis in {selection=}' raise IndexError(msg) if frame is not None: if frame >= shape[0]: msg = f'{frame=} out of range' raise IndexError(msg) selection[0] = frame if frame >= 0 else slice(None, None, -1) if plane is not None: if ndim != 6: msg = f'{plane=} requires z-stack' raise IndexError(msg) if plane >= shape[1]: msg = f'{plane=} out of range' raise IndexError(msg) selection[1] = plane if plane >= 0 else slice(None, None, -1) if channel is not None: if channel >= shape[-2]: msg = f'{channel=} out of range' raise IndexError(msg) selection[-2] = channel if channel >= 0 else slice(None, None, -1) if dtime is not None: if dtime == 0: dtime = self.number_bins_in_period if dtime > 0: if dtime > self.number_bins_max: msg = f'{dtime=} out of range {self.number_bins_max}' raise IndexError(msg) selection[-1] = slice(0, dtime, 1) shape[-1] = dtime else: selection[-1] = slice(None, None, -1) start = [0] * ndim step = [1] * ndim for i, (index, size) in enumerate( zip(selection, self.shape, strict=True) ): if index is None: pass elif isinstance(index, int): idx = index if idx < 0: idx %= shape[i] if not 0 <= idx < size: msg = f'axis {i} {idx=} out of range [0, {size}]' raise IndexError(msg) start[i] = idx shape[i] = 1 keepaxes[i] = 0 elif isinstance(index, slice): istart = index.start if istart is not None: if istart < 0: istart %= shape[i] if not 0 <= istart < shape[i]: msg = f'axis {i} {index=} start out of range' raise IndexError(msg) start[i] = istart if index.stop is not None: istop = index.stop if istop < 0: istop %= shape[i] if not start[i] < istop <= shape[i]: msg = f'axis {i} {index=} stop out of range' raise IndexError(msg) shape[i] = istop shape[i] -= start[i] if index.step is not None: if index.step == 0: msg = f'axis {i} slice step cannot be zero' raise IndexError(msg) if index.step < 0: # negative step size -> integrate all step[i] = shape[i] keepaxes[i] = 0 else: step[i] = min(index.step, shape[i]) shape[i] = shape[i] // step[i] + min(1, shape[i] % step[i]) else: msg = f'axis {i} index type {type(index)!r} invalid' raise IndexError(msg) if self._info.channels_active_first > 0 and 'C' in self._trimdims: # set channel offset start[-2] += self._info.channels_active_first if pixel_time is None: global_pixel_time = self.global_pixel_time global_line_time = self.global_line_time elif pixel_time <= 0.0: global_pixel_time = 0 global_line_time = 0 if self.is_sinusoidal: msg = f'cannot use sinusoidal correction with {pixel_time=}' raise ValueError(msg) if ndim == 4: msg = f'cannot decode line scan with {pixel_time=}' raise ValueError(msg) else: global_pixel_time = max( 1, round(pixel_time / self.global_resolution) ) global_line_time = global_pixel_time * self.pixels_in_line if self.is_sinusoidal: pixel_at_time = sinusoidal_correction( self.tags['ImgHdr_SinCorrection'], global_line_time, self.pixels_in_line, dtype=numpy.uint16, # should be enough for pixels_in_line ) else: pixel_at_time = None if dtype is None: dtype = self._dtype else: dtype = numpy.dtype(dtype) if dtype.kind != 'u': msg = f'not an unsigned integer {dtype=!r}' raise ValueError(msg) histogram = create_output(out, shape, dtype, fillvalue=0) times = numpy.zeros(shape[0], numpy.uint64) from ._ptufile import decode_t3_image, decode_t3_line, decode_t3_point if records is None: records = self.read_records(copy=False) if ndim in {5, 6}: if ndim == 6: nplanes = self.number_planes histogram6 = histogram else: nplanes = 1 histogram6 = histogram.reshape(shape[0], 1, *shape[1:]) start.insert(1, 0) step.insert(1, 1) if ( self.line_start_mask == 0 # noqa: PLR1714 or self.line_stop_mask == 0 or (self.frame_change_mask == 0 and shape[0] * nplanes > 1) or self.line_stop_mask == self.line_start_mask or self.frame_change_mask == self.line_start_mask or self.frame_change_mask == self.line_stop_mask ): logger().error( 'invalid line_start, line_stop, or frame_change masks (' f'0b{self.line_start_mask:b}, ' f'0b{self.line_stop_mask:b}, ' f'0b{self.frame_change_mask:b})' ) decode_t3_image( histogram6, times, records, self.tags['TTResultFormat_TTTRRecType'], self.pixels_in_line, global_pixel_time, global_line_time, pixel_at_time if self.is_sinusoidal else None, self.line_start_mask, self.line_stop_mask, self.frame_change_mask, nplanes, *start, *step, 0 if bishift is None else bishift, self.is_bidirectional, self.is_sinusoidal, self._info.skip_first_frame, ) if ndim == 5: del start[1] del step[1] elif ndim == 4: # not tested if ( self.line_start_mask == 0 # noqa: PLR1714 or self.line_stop_mask == 0 or self.line_stop_mask == self.line_start_mask ): logger().error( 'invalid line_start or line_stop masks (' f'0b{self.line_start_mask:b}, 0b{self.line_stop_mask:b})' ) decode_t3_line( histogram, times, records, self.tags['TTResultFormat_TTTRRecType'], global_pixel_time, self.line_start_mask, self.line_stop_mask, *start, *step, ) elif ndim == 3: decode_t3_point( histogram, times, records, self.tags['TTResultFormat_TTTRRecType'], global_pixel_time, *start, *step, ) if not keepdims: histogram = histogram[tuple(keepaxes)] if not self._asxarray and not asxarray: return histogram from xarray import DataArray if self._info.channels_active_first > 0 and 'C' in self._trimdims: # unset channel offset start[-2] -= self._info.channels_active_first dims = [] coords = self.coords.copy() for i, ax in enumerate(self.dims): if keepdims or keepaxes[i] != 0: dims.append(ax) if ax in coords: index = slice( start[i], start[i] + shape[i] * step[i], step[i] ) coords[ax] = coords[ax][index] elif ax in coords: del coords[ax] if 'H' in dims and len(coords['H']) < shape[-1]: coords['H'] = numpy.linspace( 0, shape[-1] * coords['H'][1], shape[-1], endpoint=False, ) if 'T' in dims: coords['T'] = times * self.global_resolution return DataArray( histogram, dims=dims, coords=coords, attrs=self.attrs, # name=self.name, ) def plot( self, *, samples: int | None = None, frame: int | None = None, plane: int | None = None, channel: int | None = None, dtime: int | None = -1, verbose: bool = False, show: bool = True, **kwargs: Any, ) -> None: """Plot histograms using matplotlib. Parameters: samples: Number of bins along measurement for T2 mode. The default is 1000. frame: If < 0, integrate time axis, else show specified frame. By default, all frames are shown. Applies to T3 images. plane: If < 0, integrate Z axis, else show specified plane. By default, all planes are shown. Applies to T3 z-stacks. channel: If < 0, integrate channel axis, else show specified channel. By default, all channels are shown. Applies to T3 images. dtime: Number of bins in T3 histograms. If < 0 (default), integrate delay time axis of images. If 0, show :py:attr:`number_bins_in_period` bins. If > 0, show histograms up to specified bin. If None, show all bins. verbose: Print information about histogram arrays. show: If true (default), display all figures. Else, defer to user or environment to display figures. **kwargs: Additional arguments passed to ``tifffile.imshow``. """ from matplotlib import pyplot from tifffile import Timer, imshow t = Timer() if self.is_t3: if self.measurement_ndim > 1: t.start() histogram: Any = self.decode_image( frame=frame, plane=plane if self.measurement_ndim == 4 else None, channel=channel, dtime=dtime, asxarray=True, ) if verbose: print() t.print('decode_image') print() print(histogram.squeeze()) imshow( numpy.transpose( histogram.values, ( (0, 3, 4, 1, 2) if histogram.ndim == 5 else (0, 4, 5, 1, 2, 3) ), ).squeeze(), title=repr(self), photometric='minisblack', **kwargs, ) pyplot.figure() t.start() # histogram = histogram.sum(axis=(0, 1, 2), dtype=numpy.uint32) dtime = None if dtime is None or dtime < 0 else dtime histogram = self.decode_histogram(asxarray=True, dtime=dtime) else: if samples is None or samples < 1: samples = 1000 histogram = self.decode_histogram( sampling_time=self.global_acquisition_time // samples, asxarray=True, ) if verbose: print() t.print('decode_histogram') print() print(histogram) channels = histogram.coords['C'].values for i, hist in enumerate(histogram): pyplot.plot( hist.coords['H'], hist.values, label=f'ch {channels[i]}' ) if self.frequency > 0.0: pyplot.axvline(x=1 / self.frequency, color='0.5', ls=':', lw=0.75) pyplot.title(repr(self)) pyplot.xlabel('delay time [s]' if self.is_t3 else 'time [s]') pyplot.ylabel('photon count') pyplot.legend() if show: pyplot.show() class PhuMeasurementMode(enum.IntEnum): """Kind of TCSPC measurement (Measurement_Mode tag).""" UNKNOWN = -1 """Unknown mode.""" HISTOGRAM = 0 """Histogram mode.""" CONTI = 8 """Conti mode.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(-1) # Unknown obj._value_ = value return obj class PhuMeasurementSubMode(enum.IntEnum): """Kind of measurement (Measurement_SubMode tag).""" UNKNOWN = -1 """Unknown mode.""" OSCILLOSCOPE = 0 """Oscilloscope mode.""" INTEGRATING = 1 """Integrating mode.""" TRES = 2 """Time-Resolved Emission Spectra mode.""" SEQ = 3 """Sequence mode.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(-1) obj._value_ = value return obj class PtuMeasurementMode(enum.IntEnum): """Kind of TCSPC Measurement (Measurement_Mode tag).""" UNKNOWN = -1 """Unknown mode.""" T2 = 2 """T2 mode.""" T3 = 3 """T3 mode.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(-1) # Unknown obj._value_ = value return obj class PtuMeasurementSubMode(enum.IntEnum): """Kind of measurement (Measurement_SubMode tag).""" UNKNOWN = -1 """Unknown mode.""" POINT = 1 """Point scan mode.""" LINE = 2 """Line scan mode.""" IMAGE = 3 """Image scan mode.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(1 if value == 0 else -1) obj._value_ = value return obj class PtuScannerType(enum.IntEnum): """Scanner hardware (ImgHdr_Ident tag).""" UNKNOWN = -1 """Unknown scanner.""" PI_E710 = 1 """PI E-710 scanner.""" LSM = 3 """PicoQuant LSM scanner.""" PI_LINEWBS = 5 """PI Line WB scanner.""" PI_E725 = 6 """PI E-725 scanner.""" PI_E727 = 7 """PI E-727 scanner.""" MCL = 8 """MCL scanner.""" FLIMBEE = 9 """PicoQuant FLIMBee scanner.""" SCANBOX = 10 """Zeiss ScanBox scanner.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(-1) # Unknown obj._value_ = value return obj class PtuScanDirection(enum.IntEnum): """Scan direction (ImgHdr_ScanDirection tag).""" XY = 0 """X-Y scan.""" XZ = 1 """X-Z scan.""" YZ = 2 """Y-Z scan.""" @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int) or value != 0: return None obj = cls(0) # XY obj._value_ = value return obj class PtuStopReason(enum.IntEnum): """Reason for measurement end (TTResult_StopReason tag).""" TIME_OVER = 0 MANUAL = 1 OVERFLOW = 2 ERROR = 3 UNKNOWN = -1 FIFO_OVERRUN = -2 LEGACY_ERROR = -3 TCSPC_ERROR = -4 FILE_ERROR = -5 OUT_OF_MEMORY = -6 SUSPENDED = -7 SYS_ERROR = -8 QUEUE_OVERRUN = -9 DATA_XFER_FAIL = -10 DATA_CHECK_FAIL = -11 REF_CLK_LOST = -12 SYNC_LOST = -13 @classmethod def _missing_(cls, value: object) -> object: if not isinstance(value, int): return None obj = cls(-1) # Unknown obj._value_ = value return obj class PtuMeasurementWarnings(enum.IntFlag): """Warnings during measurement (TTResult_MDescWarningFlags tag).""" SYNC_RATE_ZERO = 0x1 SYNC_RATE_TOO_LOW = 0x2 SYNC_RATE_TOO_HIGH = 0x4 INPT_RATE_ZERO = 0x10 INPT_RATE_TOO_HIGH = 0x40 EVENTS_DROPPED = 0x80 INPT_RATE_RATIO = 0x100 DIVIDER_GT_ONE = 0x200 TIME_SPAN_TOO_SMALL = 0x400 OFFSET_UNNECESSARY = 0x800 class PtuHwFeatures(enum.IntFlag): """Hardware features (HW_Features tag).""" DLL = 0x1 TTTR = 0x2 MARKERS = 0x4 LOW_RES = 0x8 TRIG_OUT = 0x10 PROG_DEADTIME = 0x20 EXT_FPGA = 0x40 PROG_HYSTERESES = 0x80 COINCIDENCE_FILTERING = 0x100 INPUT_MODES = 0x200 class PqTagType(enum.IntEnum): """Tag type definition.""" Empty8 = 0xFFFF0008 Bool8 = 0x00000008 Int8 = 0x10000008 BitSet64 = 0x11000008 Color8 = 0x12000008 Float8 = 0x20000008 TDateTime = 0x21000008 Float8Array = 0x2001FFFF AnsiString = 0x4001FFFF WideString = 0x4002FFFF BinaryBlob = 0xFFFFFFFF class PtuRecordType(enum.IntEnum): """TTTR record type.""" PicoHarpT3 = 0x00010303 """PicoHarp 300 T3.""" PicoHarpT2 = 0x00010203 """PicoHarp 300 T2.""" HydraHarpT3 = 0x00010304 """HydraHarp V1.x T3.""" HydraHarpT2 = 0x00010204 """HydraHarp V1.x T2.""" HydraHarp2T3 = 0x01010304 """HydraHarp V2.x T3.""" HydraHarp2T2 = 0x01010204 """HydraHarp V2.x T2.""" TimeHarp260NT3 = 0x00010305 """TimeHarp 260N T3.""" TimeHarp260NT2 = 0x00010205 """TimeHarp 260N T2.""" TimeHarp260PT3 = 0x00010306 """TimeHarp 260P T3.""" TimeHarp260PT2 = 0x00010206 """TimeHarp 260P T2.""" GenericT2 = 0x00010207 """MultiHarp and Picoharp 330 T2.""" GenericT3 = 0x00010307 """MultiHarp and Picoharp 330 T3.""" @dataclasses.dataclass class PtuInfo: """Information about decoded TTTR records. Returned by ``_ptufile.decode_info``. """ format: int """Type of records.""" records: int """Number of records.""" photons: int """Number of photons counted.""" markers: int """Number of marker events.""" frames: int """Number of frames detected. Includes time frames and z-planes. May exclude incomplete frames. """ lines: int """Number of lines between line markers.""" channels: int """Maximum number of channels for record type.""" channels_active: int """Bitfield identifying channels with photons.""" channels_active_first: int """First channel with photons.""" channels_active_last: int """Last channel with photons.""" bins: int """Maximum delay time for record type.""" bins_used: int """Number of bins up to the largest occupied delay-time bin.""" skip_first_frame: bool """First frame of multi-frame image is incomplete.""" skip_last_frame: bool """Last frame of multi-frame image is incomplete.""" line_time: int """Average global time between line markers.""" acquisition_time: int """Global time of last sync event.""" def __str__(self) -> str: return indent( f'{self.__class__.__name__}(', *(f'{key}={value},' for key, value in self.__dict__.items()), end='\n)', ) T2_RECORD_DTYPE = numpy.dtype( [ ('time', numpy.uint64), ('channel', numpy.int8), ('marker', numpy.uint8), ] ) """Numpy dtype of decoded T2 records.""" T3_RECORD_DTYPE = numpy.dtype( [ ('time', numpy.uint64), ('dtime', numpy.int16), ('channel', numpy.int8), ('marker', numpy.uint8), ] ) """Numpy dtype of decoded T3 records.""" FILE_EXTENSIONS = { '.ptu': PqFileType.PTU, '.phu': PqFileType.PHU, '.pck': PqFileType.PCK, '.pco': PqFileType.PCO, '.pfs': PqFileType.PFS, '.pus': PqFileType.PFS, '.pqres': PqFileType.PQRES, '.pqdat': PqFileType.PQDAT, '.pquni': PqFileType.PQUNI, '.pqover': PqFileType.PQOVER, '.spqr': PqFileType.SPQR, } """File extensions of PicoQuant tagged files.""" def binwrite( filename: str | os.PathLike[str], data: ArrayLike, /, tcspc_resolution: float, pixel_resolution: float, ) -> None: """Write TCSPC image histogram and metadata to PicoQuant BIN file. Parameters: filename: Name of PicoQuant BIN file. data: TCSPC image histogram array of shape (length, width, bins). Must be compatible with dtype.uint32. tcspc_resolution: TCSPC resolution in s. pixel_resolution: Pixel resolution in μm. """ data = numpy.asarray(data, ' tuple[NDArray[Any], dict[str, Any]]: """Return TCSPC image histogram and metadata from PicoQuant BIN file. Parameters: filename: Name of PicoQuant BIN file. memmap: If true, return a read-only memory-mapped array. Returns: tuple: - histogram: TCSPC image histogram array of shape (length, width, bins). - metadata: Dictionary with metadata. - 'shape': Shape of histogram array. - 'pixel_resolution': Pixel resolution in μm. - 'tcspc_resolution': TCSPC resolution in s. """ data: NDArray[Any] with open(filename, 'rb') as fh: size_x, size_y, pixel_resolution, size_h, tcspc_resolution = ( struct.unpack(' bytes: """Return encoded PicoQuant tag. Parameters: tagid: Tag identifier string. value: Tag value to encode. index: Array index for tag values, -1 for single values. Returns: Encoded tag as bytes. """ match value: case None: typecode = PqTagType.Empty8 buffer = b'\x00\x00\x00\x00\x00\x00\x00\x00' case bool() as v: # must check bool before int typecode = PqTagType.Bool8 buffer = struct.pack(' NDArray[Any]: """Return pixel indices of global times in line for sinusoidal scanning. Parameters: sincorrect: Amount of sine wave used for measurement. Either percentage of amplitude (PicoQuant) or period (Leica) depending on ``is_amplitude``. The value of the ``ImgHdr_SinCorrection`` tag. global_line_time: Global time per line. pixels_in_line: Number of pixels in line. is_amplitude: Correction value is percentage of amplitude of sine wave. If false, correction value is percentage of period of sine wave. Returns: Array of size ``global_line_time``, mapping global time in line to pixel index in line. """ dtype = numpy.dtype(numpy.uint16 if dtype is None else dtype) if sincorrect <= 0.0 or sincorrect > 100.0: msg = f'{sincorrect=} out of range' raise ValueError(msg) if global_line_time < 2: msg = f'{global_line_time=} out of range' raise ValueError(msg) if pixels_in_line < 2 or pixels_in_line >= numpy.iinfo(dtype).max: msg = f'{pixels_in_line=} out of range' raise ValueError(msg) if not is_amplitude: sincorrect = math.sin(sincorrect * math.pi / 200.0) * 100.0 limit = math.asin(-sincorrect / 100.0) a = numpy.linspace(limit, -limit, global_line_time, endpoint=False) a = numpy.sin(a) a *= -0.5 * pixels_in_line / a[0] a -= a[0] return a.astype(dtype) def create_output( out: OutputType, /, shape: Sequence[int], dtype: DTypeLike | None, *, mode: Literal['r+', 'w+', 'r', 'c'] = 'w+', suffix: str | None = None, fillvalue: float | None = None, ) -> NDArray[Any] | numpy.memmap[Any, Any]: """Return NumPy array where data of shape and dtype can be copied. Parameters: out: Specifies kind of array of ``shape`` and ``dtype`` to return: ``None``: Return new array. ``numpy.ndarray``: Return view of existing array. ``'memmap'`` or ``'memmap:tempdir'``: Return memory-map to array stored in temporary binary file. ``str`` or open file: Return memory-map to array stored in specified binary file. shape: Shape of array to return. dtype: Data type of array to return. If ``out`` is an existing array, ``dtype`` must be castable to its data type. mode: File mode to create memory-mapped array. The default is 'w+' to create new, or overwrite existing file for reading and writing. suffix: Suffix of ``NamedTemporaryFile`` if ``out`` is ``'memmap'``. The default is '.memmap'. fillvalue: Value to initialize output array. By default, return uninitialized array. Returns: NumPy array or memory-mapped array of ``shape`` and ``dtype``. Raises: ValueError: Existing array cannot be reshaped to ``shape`` or cast to ``dtype``. """ shape = tuple(shape) dtype = numpy.dtype(dtype) if out is None: if fillvalue is None: return numpy.empty(shape, dtype) if fillvalue: return numpy.full(shape, fillvalue, dtype) return numpy.zeros(shape, dtype) if isinstance(out, numpy.ndarray): if product(shape) != product(out.shape): msg = f'cannot reshape {shape} to {out.shape}' raise ValueError(msg) if not numpy.can_cast(dtype, out.dtype): msg = f'cannot cast {dtype} to {out.dtype}' raise ValueError(msg) if out.shape != shape: out = out.reshape(shape, copy=False) if fillvalue is not None: out.fill(fillvalue) return out if isinstance(out, str) and out[:6] == 'memmap': import tempfile tempdir = out[7:] if len(out) > 7 else None if suffix is None: suffix = '.memmap' with tempfile.NamedTemporaryFile(dir=tempdir, suffix=suffix) as fh: out = numpy.memmap(fh, shape=shape, dtype=dtype, mode=mode) if fillvalue is not None: out.fill(fillvalue) return out out = numpy.memmap(out, shape=shape, dtype=dtype, mode=mode) if fillvalue is not None: out.fill(fillvalue) return out def product(iterable: Iterable[int], /) -> int: """Return product of integers. Like math.prod, but does not overflow with numpy arrays. """ prod = 1 for i in iterable: prod *= int(i) return prod def now() -> datetime: """Return current date and time.""" return datetime.now() def align_bytes(size: int, align: int, /) -> bytes: """Return trailing bytes to align bytes of size.""" size %= align return b'' if size == 0 else b'\0' * (align - size) def indent(*args: Any, sep: str = '', end: str = '') -> str: """Return joined string representations of objects with indented lines.""" text = (sep + '\n').join( arg if isinstance(arg, str) else repr(arg) for arg in args ) return ( '\n'.join( (' ' + line if line else line) for line in text.splitlines() if line )[4:] + end ) def logger() -> logging.Logger: """Return logger for ptufile module.""" return logging.getLogger('ptufile') def askopenfilename(**kwargs: Any) -> str: """Return file name(s) 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(argv: list[str] | None = None) -> int: """Command line usage main function. Preview image and metadata in specified files or all files in directory. ``python -m ptufile file_or_directory`` """ from glob import glob from tifffile import Timer if argv is None: argv = sys.argv fltr = False if len(argv) == 1: path = askopenfilename( title='Select a PTU file', filetypes=[ (f'{ext.upper()} files', f'*{ext}') for ext in FILE_EXTENSIONS ] + [('All files', '*')], ) files = [path] if path else [] elif '*' in argv[1]: files = glob(argv[1]) elif os.path.isdir(argv[1]): files = glob(f'{argv[1]}/*.p*') fltr = True else: files = argv[1:] for filename in files: if ( fltr and os.path.splitext(filename)[-1].lower() not in FILE_EXTENSIONS ): continue try: with PqFile(filename) as pq: if pq.type == PqFileType.PTU: t = Timer() with PtuFile(filename, memmap=True) as ptu: t.print(' open file') t.start() ptu.read_records(copy=False) t.print('read records') t.start() ptu_info = ptu._info t.print('scan records') # t.start() # ptu.decode_records() # t.print('decode records') print() print(ptu) print(ptu_info) try: ptu.plot(verbose=True) except NotImplementedError as exc: print('NotImplementedError:', exc) elif pq.type == PqFileType.PHU: with PhuFile(filename, memmap=True) as phu: print(phu) phu.plot(verbose=True) else: print(pq) print() except Exception: import traceback print('Failed to read', filename) traceback.print_exc() print() continue return 0 if __name__ == '__main__': sys.exit(main()) cgohlke-ptufile-933b7e3/ptufile/py.typed000066400000000000000000000000001526455024500203020ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/pyproject.toml000066400000000000000000000004671526455024500200700ustar00rootroot00000000000000[build-system] requires = ["setuptools", "numpy>=2.1", "Cython!=3.3.0"] build-backend = "setuptools.build_meta" [tool.black] line-length = 79 target-version = ["py312", "py313", "py314", "py315"] skip-string-normalization = true [tool.isort] known_first_party = ["ptufile"] profile = "black" line_length = 79 cgohlke-ptufile-933b7e3/setup.py000066400000000000000000000152511526455024500166630ustar00rootroot00000000000000# ptufile/setup.py """Ptufile package Setuptools script.""" import os import re import sys import sysconfig import numpy from setuptools import Extension, setup DEBUG = bool(os.environ.get('CG_DEBUG', '')) LIMITED_API = os.environ.get('CG_LIMITED_API', '1').lower() in {'1', 'true'} GIL_DISABLED = bool(sysconfig.get_config_var('Py_GIL_DISABLED')) LLVM_PATH = os.environ.get('CG_LLVM_PATH', '') if LLVM_PATH: # Redirect the MSVC compiler class to use clang-cl/lld-link. # setuptools on Windows ignores CC; patching MSVCCompiler.initialize # is the only reliable way to swap the compiler executable. import importlib from collections.abc import Callable from typing import Any clang_cl = os.path.join(LLVM_PATH, 'bin', 'clang-cl.exe') lld_link = os.path.join(LLVM_PATH, 'bin', 'lld-link.exe') for modname in ( 'setuptools._distutils._msvccompiler', 'distutils._msvccompiler', ): try: mod = importlib.import_module(modname) def make_clang_init( orig: Callable[..., None], cc: str, linker: str ) -> Callable[..., None]: """Return patched MSVCCompiler.initialize method using LLVM.""" def clang_init( self: Any, plat_name: str | None = None ) -> None: orig(self, plat_name) self.cc = cc self.linker = linker # remove MSVC flags unsupported by clang-cl for attr in ('compile_options', 'compile_options_debug'): opts = getattr(self, attr, None) if opts is not None: setattr( self, attr, [f for f in opts if f != '/GL'], ) return clang_init mod.MSVCCompiler.initialize = make_clang_init( mod.MSVCCompiler.initialize, clang_cl, lld_link ) except (ImportError, AttributeError): pass if LIMITED_API and not GIL_DISABLED: py_limited_api = True define_macros = [ ('Py_LIMITED_API', 0x030C0000), ('CYTHON_LIMITED_API', '1'), ] setup_options = {'bdist_wheel': {'py_limited_api': 'cp312'}} else: py_limited_api = False define_macros = [] setup_options = {} def search(pattern: str, string: str, flags: int = 0) -> str: """Return first match of pattern in string.""" match = re.search(pattern, string, flags) if match is None: msg = f'{pattern=!r} not found' raise ValueError(msg) return match.groups()[0] def fix_docstring_examples(docstring: str) -> str: """Return docstring with examples fixed for GitHub.""" start = True indent = False lines = ['..', ' This file is generated by setup.py. Do not edit.', ''] for line in docstring.splitlines(): if not line.strip(): start = True indent = False if line.startswith('>>> '): indent = True if start: lines.extend(['.. code-block:: python', '']) start = False lines.append((' ' if indent else '') + line) return '\n'.join(lines) with open('ptufile/ptufile.py', encoding='utf-8') as fh: code = fh.read() version = search(r"__version__ = '(.*?)'", code).replace('.x.x', '.dev0') description = search(r'"""(.*)\.(?:\r\n|\r|\n)', code) readme = search( r'(?:\r\n|\r|\n){2}"""(.*)"""(?:\r\n|\r|\n){2}from __future__', code, re.MULTILINE | re.DOTALL, ) readme = '\n'.join( [description, '=' * len(description), *readme.splitlines()[1:]] ) if 'sdist' in sys.argv: # update README, LICENSE, and CHANGES files with open('README.rst', 'w', encoding='utf-8', newline='\n') as fh: fh.write(fix_docstring_examples(readme)) license = search( r'(# Copyright.*?(?:\r\n|\r|\n))(?:\r\n|\r|\n)+""', code, re.MULTILINE | re.DOTALL, ) license = license.replace('# ', '').replace('#', '') with open('LICENSE', 'w', encoding='utf-8', newline='\n') as fh: fh.write('BSD-3-Clause license\n\n') fh.write(license) revisions = search( r'(?:\r\n|\r|\n){2}(Revisions.*)- …', readme, re.MULTILINE | re.DOTALL, ).strip() with open('CHANGES.rst', encoding='utf-8') as fh: old = fh.read() old = old.split(revisions.splitlines()[-1])[-1] with open('CHANGES.rst', 'w', encoding='utf-8', newline='\n') as fh: fh.write('..\n This file is updated by setup.py. Do not edit.\n\n') fh.write(revisions.replace('---------', '=========').strip()) fh.write(old) ext_modules = [ Extension( 'ptufile._ptufile', ['ptufile/_ptufile.pyx'], define_macros=[ *define_macros, # ('CYTHON_TRACE_NOGIL', '1'), ('NPY_NO_DEPRECATED_API', 'NPY_2_1_API_VERSION'), ], py_limited_api=py_limited_api, extra_compile_args=( ['/Zi', '/Od'] if DEBUG else ['/GS-'] if LLVM_PATH else [] ), extra_link_args=['-debug:full'] if DEBUG else [], include_dirs=[numpy.get_include()], ) ] setup( name='ptufile', version=version, license='BSD-3-Clause', description=description, long_description=readme, long_description_content_type='text/x-rst', author='Christoph Gohlke', author_email='cgohlke@cgohlke.com', url='https://www.cgohlke.com', project_urls={ 'Bug Tracker': 'https://github.com/cgohlke/ptufile/issues', 'Source Code': 'https://github.com/cgohlke/ptufile', 'Documentation': 'https://www.cgohlke.com/docs/ptufile/', }, packages=['ptufile'], package_data={'ptufile': ['py.typed']}, entry_points={'console_scripts': ['ptufile = ptufile.ptufile:main']}, python_requires='>=3.12', install_requires=['numpy>=2.1'], extras_require={ 'all': [ 'xarray', 'tifffile', 'matplotlib', 'python-dateutil', 'numcodecs', ], }, ext_modules=ext_modules, options=setup_options, platforms=['any'], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', 'Intended Audience :: Developers', 'Operating System :: OS Independent', 'Programming Language :: Cython', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: 3.15', ], ) cgohlke-ptufile-933b7e3/tests/000077500000000000000000000000001526455024500163075ustar00rootroot00000000000000cgohlke-ptufile-933b7e3/tests/conftest.py000066400000000000000000000022241526455024500205060ustar00rootroot00000000000000# ptufile/tests/conftest.py """Pytest configuration.""" import os import sys import pytest if os.environ.get('VSCODE_CWD'): # work around pytest not using PYTHONPATH in VSCode sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) ) def pytest_configure(config): """Disable matplotlib when running tests with multiple threads.""" if int(config.getoption('parallel_threads', default=1)) > 1: os.environ['PTUFILE_DISABLE_PYPLOT'] = '1' def pytest_collection_modifyitems(items): """Run tests using caplog in a single thread.""" for item in items: if 'caplog' in getattr(item, 'fixturenames', ()): item.add_marker(pytest.mark.parallel_threads(1)) def pytest_report_header(config): """Return pytest report header.""" try: import ptufile return ( f'Python {sys.version.splitlines()[0]}\n' f'packagedir: {ptufile.__path__[0]}\n' f'version: ptufile {ptufile.__version__}' ) except Exception as exc: return f'pytest_report_header failed: {exc!s}' collect_ignore = ['data'] # mypy: ignore-errors cgohlke-ptufile-933b7e3/tests/test_ptufile.py000066400000000000000000003374261526455024500214070ustar00rootroot00000000000000# test_ptufile.py # Copyright (c) 2023-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. """Unittests for the ptufile package. :Version: 2026.10.16 """ import contextlib import datetime import glob import io import itertools import logging import mmap import os import pathlib import sys import sysconfig import tempfile import types import uuid import numpy import pytest from numpy import array_equal # faster than numpy.testing.assert_array_equal from numpy.testing import assert_allclose, assert_almost_equal import ptufile import ptufile.numcodecs from ptufile import ( FILE_EXTENSIONS, T2_RECORD_DTYPE, T3_RECORD_DTYPE, PhuFile, PhuMeasurementMode, PhuMeasurementSubMode, PqFile, PqFileError, PqFileType, PtuFile, PtuMeasurementMode, PtuMeasurementSubMode, PtuMeasurementWarnings, PtuRecordType, PtuScannerType, PtuStopReason, PtuWriter, __version__, binread, binwrite, imread, imwrite, ) from ptufile.ptufile import BinaryFile try: import fsspec except ImportError: fsspec = None # type: ignore[assignment] try: import xarray except ImportError: xarray = None # type: ignore[assignment] pyplot: types.ModuleType | None if os.environ.get('PTUFILE_DISABLE_PYPLOT'): pyplot = None else: try: from matplotlib import pyplot except ImportError: pyplot = None RNG = numpy.random.default_rng(42) DATA = pathlib.Path(os.path.dirname(__file__)) / 'data' FILES = [ # PicoHarpT3 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu', # PicoHarpT2 'Samples.sptw/Atto488_diff_cw_total_correlation.ptu', # HydraHarpT3 'picoquant-sample-data/hydraharp/v10_t3.ptu', # HydraHarpT2 'Samples.sptw/NV-Center_for_Antibunching_several.ptu', # HydraHarp2T3 'tttr-data/imaging/pq/Microtime200_HH400/beads.ptu', # HydraHarp2T2 'tttr-data/pq/ptu/pq_ptu_hh_t2_test2.ptu', # TODO: TimeHarp260NT3 # TimeHarp260NT2 'ptuparser/default_007.ptu', # TimeHarp260PT3 'tttr-data/imaging/pq/Microtime200_TH260/beads.ptu', # TODO: TimeHarp260PT2 # TODO: GenericT2/MultiHarpT2 or Picoharp330T2 # GenericT3/MultiHarpT3 'Tutorials.sptw/Hyperosmotic_Shock_MDCK_Cells.ptu', ] @pytest.mark.skipif(__doc__ is None, reason='__doc__ is None') def test_version(): """Assert ptufile versions match docstrings.""" ver = ':Version: ' + __version__ assert ver in __doc__ assert ver in ptufile.__doc__ def test_import_xarray(): """Assert xarray is installed.""" assert xarray is not None def test_import_matplotlib(): """Assert matplotlib is installed.""" if os.environ.get('PTUFILE_DISABLE_PYPLOT') == '1': assert pyplot is None else: assert pyplot is not None class TestBinaryFile: """Test BinaryFile with different file-like inputs.""" def setup_method(self): self.filename = os.path.normpath(DATA / 'binary.bin') if not os.path.exists(self.filename): pytest.skip(f'{self.filename!r} not found') def validate( self, fh: BinaryFile, filepath: str | None = None, filename: str | None = None, dirname: str | None = None, name: str | None = None, *, closed: bool = True, ) -> None: """Assert BinaryFile attributes.""" if filepath is None: filepath = self.filename if filename is None: filename = os.path.basename(self.filename) if dirname is None: dirname = os.path.dirname(self.filename) if name is None: name = fh.filename attrs = fh.attrs assert attrs['name'] == name assert attrs['filepath'] == filepath assert fh.filepath == filepath assert fh.filename == filename assert fh.dirname == dirname assert fh.name == name assert fh.closed is False assert fh.filesize == 256 assert len(fh.filehandle.read()) == 256 fh.filehandle.seek(10) assert fh.filehandle.tell() == 10 assert fh.filehandle.read(1) == b'\n' fh.close() # underlying filehandle may still be be open if # BinaryFile was given an open filehandle assert fh._fh.closed is closed # BinaryFile always reports itself as closed after close() is called assert fh.closed def test_str(self): """Test BinaryFile with str path.""" file = self.filename with BinaryFile(file) as fh: self.validate(fh, closed=True) def test_pathlib(self): """Test BinaryFile with pathlib.Path.""" file = pathlib.Path(self.filename) with BinaryFile(file) as fh: self.validate(fh, closed=True) def test_open_file(self): """Test BinaryFile with open binary file.""" with open(self.filename, 'rb') as fh, BinaryFile(fh) as bf: self.validate(bf, closed=False) def test_bytesio(self): """Test BinaryFile with BytesIO.""" with open(self.filename, 'rb') as fh: file = io.BytesIO(fh.read()) with BinaryFile(file) as fh: self.validate( fh, filepath='', filename='', dirname='', name='BytesIO', closed=False, ) @pytest.mark.skipif(fsspec is None, reason='fsspec not installed') def test_fsspec_openfile(self): """Test BinaryFile with fsspec OpenFile.""" file = fsspec.open(self.filename) with BinaryFile(file) as fh: self.validate(fh, closed=True) @pytest.mark.skipif(fsspec is None, reason='fsspec not installed') def test_fsspec_localfileopener(self): """Test BinaryFile with fsspec LocalFileOpener.""" with fsspec.open(self.filename) as file, BinaryFile(file) as fh: self.validate(fh, closed=False) def test_text_file_fails(self): """Test BinaryFile with open text file fails.""" with open(self.filename) as fh: # noqa: SIM117 with pytest.raises(TypeError): BinaryFile(fh) def test_file_extension_fails(self): """Test BinaryFile with wrong file extension fails.""" class TestFile(BinaryFile): _ext = {'.test'} # noqa: RUF012 with pytest.raises(ValueError): TestFile(self.filename) def test_file_not_seekable(self): """Test BinaryFile with non-seekable file fails.""" class File: # mock file object without tell methods def seek(self): pass with pytest.raises(ValueError): BinaryFile(File()) def test_openfile_not_seekable(self): """Test BinaryFile with non-seekable file fails.""" class File: # mock fsspec OpenFile without seek/tell methods @staticmethod def open(*args, **kwargs): del args, kwargs return File() with pytest.raises(ValueError): BinaryFile(File()) def test_invalid_object(self): """Test BinaryFile with invalid file object fails.""" class File: # mock non-file object pass with pytest.raises(TypeError): BinaryFile(File()) def test_invalid_mode(self): """Test BinaryFile with invalid mode fails.""" with pytest.raises(ValueError): BinaryFile(self.filename, mode='ab') def test_memmap_disabled(self): """Test memmap=False (default) does not memory-map file.""" with BinaryFile(self.filename) as fh: assert fh._mm is None with BinaryFile(self.filename, memmap=False) as fh: assert fh._mm is None def test_memmap_bytesio_ignored(self): """Test memmap=True is silently ignored for BytesIO.""" with open(self.filename, 'rb') as f: data = f.read() with BinaryFile(io.BytesIO(data), memmap=True) as fh: assert fh._mm is None assert bytes(fh._read_at(0, 4)) == b'\x00\x01\x02\x03' def test_memmap_mmap_object(self): """Test BinaryFile accepts an existing mmap.mmap as file handle.""" import mmap as mmap_module with open(self.filename, 'rb') as f: mm = mmap_module.mmap( f.fileno(), 0, access=mmap_module.ACCESS_READ ) arr = None try: with BinaryFile(mm, memmap=True) as fh: assert fh._mm is mm assert fh._mv is not None assert fh.memmapped assert fh.filesize == 256 assert bytes(fh._read_at(0, 4)) == b'\x00\x01\x02\x03' arr = fh._read_array(10, 3, numpy.uint8) assert numpy.array_equal(arr, [10, 11, 12]) finally: del arr # release mmap-backed array before closing mmap mm.close() @pytest.mark.parametrize('memmap', [False, True]) def test_read_at(self, memmap): """Test _read_at returns bytes or memoryview at offset.""" with BinaryFile(self.filename, memmap=memmap) as fh: data = fh._read_at(0, 4) assert isinstance(data, memoryview if memmap else bytes) assert bytes(data) == b'\x00\x01\x02\x03' data = fh._read_at(10, 3) assert bytes(data) == b'\x0a\x0b\x0c' @pytest.mark.parametrize('memmap', [False, True]) def test_read_into(self, memmap): """Test _read_into reads bytes at offset into existing uint8 buffer.""" with BinaryFile(self.filename, memmap=memmap) as fh: buf = numpy.empty(4, numpy.uint8) n = fh._read_into(0, buf) assert n == 4 assert numpy.array_equal(buf, [0, 1, 2, 3]) buf = numpy.empty(3, numpy.uint8) n = fh._read_into(10, buf) assert n == 3 assert numpy.array_equal(buf, [10, 11, 12]) # read past end of file returns only available bytes buf = numpy.empty(8, numpy.uint8) n = fh._read_into(252, buf) assert n == 4 assert numpy.array_equal(buf[:4], [252, 253, 254, 255]) @pytest.mark.parametrize('memmap', [False, True]) def test_read_array(self, memmap): """Test _read_array returns array at offset; dtype, count=-1, copy.""" dtype = numpy.dtype('uint8') with BinaryFile(self.filename, memmap=memmap) as fh: arr = fh._read_array(0, 4, dtype) assert arr.dtype == dtype assert numpy.array_equal(arr, [0, 1, 2, 3]) arr = fh._read_array(10, 3, dtype) assert numpy.array_equal(arr, [10, 11, 12]) if memmap: assert not arr.flags.writeable arr_copy = fh._read_array(0, 4, dtype, copy=True) assert arr_copy.flags.writeable arr_copy[0] = 99 # must not raise else: # multi-byte dtype: binary.bin bytes as uint16 LE pairs arr16 = fh._read_array(0, 4, numpy.dtype('' assert pq.tags['Defaults_Begin'] is None attrs = pq.attrs assert attrs['type'] == pq.type.name assert attrs['name'] == pq.name assert attrs['tags'] == pq.tags def test_pqres(caplog): """Test read PQRES file.""" filename = DATA / 'Samples.sptw/AnisotropyImage.pqres' with caplog.at_level(logging.ERROR): with PqFile(filename) as pq: str(pq) # assert 'not divisible by 8' in caplog.text assert pq.type == PqFileType.PQRES assert pq.version == '00.0.1' assert pq.tags['VarStatFilterGrpIdx'].startswith(b'\xe7/\x00\x00') attrs = pq.attrs assert attrs['type'] == pq.type.name assert attrs['name'] == pq.name assert attrs['tags'] == pq.tags def test_pqdat(caplog): """Test read PQDAT file.""" filename = DATA / 'Luminosa/FRET_20230606-185222/FittedCurveIRF.pqdat' with caplog.at_level(logging.ERROR), PqFile(filename) as pq: str(pq) # assert 'not divisible by 8' in caplog.text assert pq.type == PqFileType.PQDAT assert pq.version == '1.0.00' assert len(pq.tags['LSDCurveX']) == 1254 assert len(pq.tags['LSDCurveY']) == 1254 preview = numpy.frombuffer( pq.tags['PreviewImage'][4:], dtype=numpy.uint8 ).reshape((128, 128, 4)) assert preview.shape == (128, 128, 4) attrs = pq.attrs assert attrs['type'] == pq.type.name assert attrs['name'] == pq.name assert attrs['tags'] == pq.tags def test_pquni(): """Test read PQUNI file.""" # TODO need better PqUni test data filename = DATA / 'UniHarp/MicroBeads.PqUni' with PqFile(filename) as pq: str(pq) # assert 'not divisible by 8' in caplog.text assert pq.type == PqFileType.PQUNI assert pq.version == '1.0.0.0' assert pq.comment is None assert pq.datetime == datetime.datetime( 2025, 11, 14, 12, 15, 46, 665000 ) assert pq.guid == uuid.UUID('a8210025-5de6-418a-841c-186da82e169e') tags = pq.tags assert tags['File_GUID'] == '{A8210025-5DE6-418A-841C-186DA82E169E}' assert tags['CreatorSW_Name'] == 'UniHarp' assert tags['CreatorSW_Version'] == '1.1.1.130' assert tags['File_CreatingTime'] == datetime.datetime( 2025, 11, 14, 12, 15, 46, 665000 ) assert tags['CreatorSW_Modules'] == 0 assert tags['HistoResult_NumberOfCurves'] == 0 attrs = pq.attrs assert attrs['type'] == pq.type.name assert attrs['name'] == pq.name assert attrs['tags'] == pq.tags def test_spqr(): """Test read SPQR file.""" filename = ( DATA / 'Luminosa/GattaQUant_Cells_FLIM/GattaQUant_Cells_FLIM.spqr' ) with PqFile(filename) as pq: str(pq) assert pq.type == PqFileType.SPQR assert pq.version == '1.0.00' assert pq.tags['CreatorSW_Name'] == 'NovaConvert' preview = numpy.frombuffer( pq.tags['SPQRPrevImage'], dtype=numpy.uint8 ).reshape((128, 128, 4)) assert preview.shape == (128, 128, 4) assert len(pq.tags['SPQRBinWidths']) == 128 attrs = pq.attrs assert attrs['type'] == pq.type.name assert attrs['name'] == pq.name assert attrs['tags'] == pq.tags @pytest.mark.parametrize('filetype', [str, io.BytesIO, mmap]) def test_ptu(filetype): """Test read PTU file.""" filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) if filetype is io.BytesIO: filename = open(filename, 'rb') try: with PtuFile(filename, memmap=filetype is mmap) as ptu: str(ptu) assert ptu.memmapped == (filetype is mmap) assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.filehandle is not None if filetype is str: assert ptu.filename == str(filename.name) assert ptu.dirname == str(filename.parent) assert ptu.version == '00.0.1' assert ptu.comment == '' assert ptu.datetime is None assert str(ptu.guid) == '4f6e5f68-8289-483d-9d9a-7974b77ef8b8' assert ptu.tags['TTResultFormat_BitsPerRecord'] == 32 assert ptu.tags['\x02HWInpChan_CFDLeveld'] == [100] # corrupted? assert not ptu.tags['HW_ExternalRefClock'] # BinaryFile properties assert ptu.name == 'hazelnut_FLIM_single_image.ptu' assert ptu.filesize == 24285248 assert not ptu.writable assert not ptu.closed if filetype is str: assert ptu.filepath == str(filename) assert isinstance(ptu.lock, contextlib.nullcontext) # PtuFile properties assert ptu.record_offset == 4616 assert ptu.measurement_warnings is None assert ptu.hardware_features is None assert ptu.stop_reason == PtuStopReason.MANUAL assert ptu.global_resolution == 1.281722635221738e-08 assert ptu.tcspc_resolution == 9.696969697e-11 assert ptu.number_records == 6070158 assert ptu.number_photons == 6065123 assert ptu.number_markers == 2565 assert ptu.number_images == 5 assert ptu.number_planes == 1 assert ptu.number_lines == 1280 assert ptu.number_channels_max == 4 assert ptu.number_channels == 1 assert ptu.active_channels == (0,) assert ptu.number_bins_max == 4096 assert ptu.number_bins == 139 assert ptu.number_bins_in_period == 132 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.frame_change_mask == 4 assert ptu.global_pixel_time == 494 assert ptu.global_line_time == 126464 assert ptu.global_frame_time == 32374784 assert ptu.global_volume_time == 32374784 assert ptu.global_acquisition_time == 161873920 assert ptu.pixels_in_frame == 65536 assert ptu.pixels_in_line == 256 assert ptu.lines_in_frame == 256 assert ptu.pixel_time == 6.331709817995386e-06 assert ptu.line_time == 0.0016209177134068188 assert ptu.frame_time == 0.4149549346321456 assert ptu.volume_time == 0.4149549346321456 assert ptu.acquisition_time == 2.074774673160728 assert ptu.frequency == 78020000.0 assert ptu.syncrate == 78020000 assert ptu.is_image assert ptu.is_t3 assert not ptu.is_bidirectional assert not ptu.is_sinusoidal assert not ptu.use_xarray assert ptu.cache_records == (filetype is not mmap) assert ptu.dtype == numpy.uint16 assert ptu.shape == (5, 256, 256, 1, 139) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert ptu.sizes == {'T': 5, 'Y': 256, 'X': 256, 'C': 1, 'H': 139} assert ptu.ndims == 5 assert ptu.nbytes == 91095040 assert ptu.size == 45547520 assert ptu.itemsize == 2 coords = ptu.coords assert tuple(coords.keys()) == ('T', 'Y', 'X', 'C', 'H') for dim, size in ptu.sizes.items(): assert coords[dim].size == size assert_allclose(coords['T'][[1, -1]], [0.41495493, 1.65981974]) assert_allclose(coords['Y'][[1, -1]], [1.46326157e-7, 3.731317e-5]) assert_allclose(coords['X'][[1, -1]], [1.46326157e-7, 3.731317e-5]) assert array_equal(coords['C'], [0]) assert_allclose( coords['H'][[1, -1]], [9.6969697e-11, 1.3381818e-8] ) attrs = ptu.attrs assert attrs['type'] == ptu.type.name assert attrs['name'] == ptu.name assert attrs['guid'] == str(ptu.guid) assert attrs['datetime'] is None assert attrs['tags'] == ptu.tags assert attrs['filepath'] == ptu.filepath assert attrs['version'] == ptu.version assert attrs['comment'] == ptu.comment assert attrs['acquisition_time'] == ptu.acquisition_time assert attrs['active_channels'] == ptu.active_channels assert attrs['frame_time'] == ptu.frame_time assert attrs['frequency'] == ptu.frequency assert ( attrs['global_acquisition_time'] == ptu.global_acquisition_time ) assert attrs['global_volume_time'] == ptu.global_volume_time assert attrs['global_frame_time'] == ptu.global_frame_time assert attrs['global_line_time'] == ptu.global_line_time assert attrs['global_pixel_time'] == ptu.global_pixel_time assert attrs['global_resolution'] == ptu.global_resolution assert attrs['line_time'] == ptu.line_time assert ( attrs['max_delaytime'] == ptu.number_bins_max ) # for PhasorPy assert attrs['measurement_mode'] == ptu.measurement_mode.name assert attrs['measurement_submode'] == ptu.measurement_submode.name assert attrs['number_bins'] == ptu.number_bins assert attrs['number_bins_in_period'] == ptu.number_bins_in_period assert attrs['number_bins_max'] == ptu.number_bins_max assert attrs['pixel_time'] == ptu.pixel_time assert attrs['record_type'] == ptu.record_type.name assert attrs['scanner'] == ptu.scanner.name assert attrs['syncrate'] == ptu.syncrate assert attrs['tcspc_resolution'] == ptu.tcspc_resolution # decoding of records is tested separately finally: if filetype is io.BytesIO: filename.close() @pytest.mark.parametrize('filetype', [str, io.BytesIO, mmap]) def test_phu(filetype): """Test read PHU file.""" filename = DATA / 'TimeHarp/Decay_Coumarin_6.phu' if filetype is io.BytesIO: filename = open(filename, 'rb') try: with PhuFile(filename, memmap=filetype is mmap) as phu: str(phu) assert phu.type == PqFileType.PHU assert phu.measurement_mode == PhuMeasurementMode.HISTOGRAM assert phu.measurement_submode == PhuMeasurementSubMode.INTEGRATING assert phu.version == '1.1.00' assert not phu.tags['HWTriggerOut_On'] assert phu.tcspc_resolution == 2.5e-11 assert phu.number_histograms == 4 # assert phu.histogram_resolutions == (3e-11, 3e-11, 3e-11, 3e-11) attrs = phu.attrs assert attrs['type'] == phu.type.name assert attrs['name'] == phu.name assert attrs['tags'] == phu.tags assert attrs['measurement_mode'] == phu.measurement_mode.name assert attrs['measurement_submode'] == phu.measurement_submode.name assert attrs['tcspc_resolution'] == phu.tcspc_resolution # assert attrs['histogram_resolutions']==phu.histogram_resolutions assert array_equal( phu.tags['HistResDscr_DataOffset'], [11224, 142296, 273368, 404440], ) if xarray is not None: histograms = phu.histograms(asxarray=True) assert len(histograms) == 4 for h in histograms: assert h.shape == (32768,) assert histograms[2][1] == 3 assert array_equal(phu.histograms(2)[0], histograms[2]) if pyplot is not None: phu.plot(show=False, verbose=False) phu.plot(show=False, verbose=True) finally: if filetype is io.BytesIO: filename.close() def test_phu_baseres(): """Test read PHU file without HistResDscr_HWBaseResolution.""" # also has a non-ISO datetime string format filename = DATA / 'FluoPlot/Naphtal_BuOH_TRES.phu' with PhuFile(filename) as phu: str(phu) assert phu.type == PqFileType.PHU assert phu.measurement_mode == PhuMeasurementMode.HISTOGRAM assert phu.measurement_submode == PhuMeasurementSubMode.INTEGRATING assert phu.version == '1.0.00' assert phu.tags['File_CreatingTime'] == '29/11/06 18:51:08' assert phu.datetime == datetime.datetime(2006, 11, 29, 18, 51, 8) assert 'HistResDscr_HWBaseResolution' not in phu.tags assert phu.tcspc_resolution == 1.600000075995922e-11 assert phu.number_histograms == 42 if xarray is not None: histograms = phu.histograms(asxarray=True) assert len(histograms) == 42 for h in histograms: assert h.shape == (65536,) if pyplot is not None: phu.plot(show=False, verbose=False) phu.plot(show=False, verbose=True) def test_ptu_t3_image(): """Test decode T3 image.""" filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) with PtuFile(filename) as ptu: str(ptu) assert str(ptu.guid) == '4f6e5f68-8289-483d-9d9a-7974b77ef8b8' assert ptu.version == '00.0.1' # assert ptu._data_offset == 4616 assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.shape == (5, 256, 256, 1, 139) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0,) # TODO: verify coords assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 2.074774673160728 assert ptu.frame_time == 0.4149549346321456 assert ptu.frequency == 78020000.0 assert ptu.global_frame_time == 32374784 assert ptu.global_line_time == 126464 assert ptu.global_pixel_time == 494 assert ptu.global_resolution == 1.281722635221738e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 256 assert ptu.number_bins == 139 assert ptu.number_bins_in_period == 132 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 1 assert ptu.number_channels_max == 4 assert ptu.number_lines == 1280 assert ptu.number_markers == 2565 assert ptu.number_photons == 6065123 assert ptu.number_records == 6070158 assert ptu.pixels_in_frame == 65536 assert ptu.pixels_in_line == 256 assert ptu.syncrate == 78020000 assert ptu.tcspc_resolution == 9.696969697e-11 assert len(ptu.read_records()) == ptu.number_records # assert ptu.decode_records im0 = ptu.decode_image() assert im0.shape == (5, 256, 256, 1, 139) assert im0.dtype == numpy.uint16 im = ptu.decode_image(channel=0, frame=2, dtime=-1, dtype='uint32') assert im.shape == (1, 256, 256, 1, 1) assert im.dtype == numpy.uint32 assert array_equal(im[0, ..., 0, 0], im0[2, :, :, 0].sum(axis=-1)) im = ptu.decode_image(channel=0, frame=2, dtime=99, dtype='uint32') assert im.shape == (1, 256, 256, 1, 99) assert array_equal( im[0, :, :, 0].sum(axis=-1), im0[2, :, :, 0, :99].sum(axis=-1) ) im = ptu.decode_image(channel=0, frame=2, dtime=199, dtype='uint32') assert im.shape == (1, 256, 256, 1, 199) assert array_equal( im[0, :, :, 0].sum(axis=-1), im0[2, :, :, 0].sum(axis=-1) ) im = ptu.decode_image( [2, slice(0, 32), slice(100, 132), None, slice(None, None, -1)] ) assert im.shape == (1, 32, 32, 1, 1) assert array_equal(im[..., 0], im0[2:3, :32, 100:132].sum(axis=-1)) im = ptu.decode_image( [slice(1, None, 2)], # bin by 2 frames starting from second dtime=-1, ) assert im.shape == (2, 256, 256, 1, 1) assert array_equal( im[1, :, :, :, 0], im0[3:5].sum(axis=0).sum(axis=-1) ) # TODO: verify values with pytest.raises(ValueError): ptu.decode_image(dtype='int16') def test_ptu_t3_zstack(): """Test decode T3 z-stack image.""" # https://github.com/cgohlke/ptufile/issues/5 filename = DATA / 'issue5/RawImage.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu.record_type == PtuRecordType.GenericT3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.FLIMBEE assert ptu.measurement_ndim == 4 assert ptu.is_image assert ptu.number_planes == 11 assert ptu.number_images == 11 assert ptu.number_lines == 1408 assert ptu.number_photons == 1134 assert ptu.shape == (1, 11, 128, 128, 1, 1248) assert ptu.dims == ('T', 'Z', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (1,) assert ptu.global_pixel_time == 76 assert ptu.global_volume_time == ptu.global_frame_time * 11 assert_allclose(ptu.coords['Z'], numpy.arange(11) * 1e-6, atol=1e-9) # sparse tag indices tags = ptu.tags assert tags['DetectorCount'] == 5 assert tags['Detector'] == [ 'Detector 1 (HPD)', 'Detector 2 (SPAD)', 'Detector 3 (SPAD)', 'Detector 4 (SPAD)', 'Detector 5 (PDA-23)', ] assert tags['DetectorInUse'] == [False, True, False, False, False] assert tags['DetectorTCSPCChan'] == [None, None, None, None, 10] assert tags['DetectorNumTCSPCChan'] == [None, None, None, None, 23] im0 = ptu.decode_image() assert im0.shape == (1, 11, 128, 128, 1, 1248) assert im0.sum() == 338 # photons within line scans assert array_equal( im0.sum(axis=(0, 2, 3, 4, 5)), [84, 113, 30, 23, 19, 11, 19, 15, 13, 5, 6], ) im = ptu.decode_image(plane=3) assert im.shape == (1, 1, 128, 128, 1, 1248) assert array_equal(im, im0[:, 3:4]) im = ptu.decode_image(plane=-1, dtime=-1, dtype='uint32') assert im.shape == (1, 1, 128, 128, 1, 1) assert array_equal( im[:, 0, ..., 0], im0.sum(axis=(1, 5), dtype=numpy.uint32) ) im = ptu.decode_image( [0, slice(2, 10, 4), slice(0, 64), ..., slice(None, None, -1)], dtype='uint32', ) assert im.shape == (1, 2, 64, 128, 1, 1) expected = im0[:, 2:10, :64].reshape(1, 2, 4, 64, 128, 1, 1248) assert array_equal( im[..., 0], expected.sum(axis=(2, 6), dtype=numpy.uint32) ) im = ptu[0, 1, ..., 0, ::-1] assert im.shape == (128, 128) assert array_equal(im, im0[0, 1, :, :, 0].sum(axis=-1)) if xarray is not None: xim = ptu.decode_image(plane=5, dtime=-1, asxarray=True) assert xim.dims == ('T', 'Z', 'Y', 'X', 'C', 'H') assert_allclose(xim.coords['Z'], [5e-6], atol=1e-9) xim = ptu.decode_image(plane=5, keepdims=False, asxarray=True) assert xim.dims == ('T', 'Y', 'X', 'C', 'H') assert 'Z' not in xim.coords with pytest.raises(IndexError): ptu.decode_image(plane=11) filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) with PtuFile(filename) as ptu, pytest.raises(IndexError): ptu.decode_image(plane=0) def test_ptu_channels(): """Test decode T3 image with empty leading channels.""" filename = DATA / 'Tutorials.sptw/Kidney _Cell_FLIM.ptu' with PtuFile(filename) as ptu: str(ptu) assert str(ptu.guid) == 'b767c46e-9693-4ad9-9fcf-7fab5e4377fc' assert ptu.version == '1.0.00' assert ptu.comment.startswith('SPAD-CH1523') assert ptu.datetime == datetime.datetime(2020, 4, 7, 18, 8, 44, 860000) assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.GenericT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.FLIMBEE assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.shape == (3, 512, 512, 2, 501) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (2, 3) assert array_equal(ptu.coords['C'], (2, 3)) # TODO: verify coords assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 47.26384024428878 assert ptu.frame_time == 15.754613414762927 assert ptu.frequency == 24999920.0 assert ptu.global_frame_time == 393864075 assert ptu.global_line_time == 384000 assert ptu.global_pixel_time == 750 assert ptu.global_resolution == 4.00001280004096e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 501 assert ptu.number_bins_in_period == 500 assert ptu.number_bins_max == 32768 assert ptu.number_channels == 2 assert ptu.number_channels_max == 64 assert ptu.number_lines == 1536 assert ptu.number_markers == 3074 assert ptu.number_photons == 41039565 assert ptu.number_records == 42196537 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 24999920 assert ptu.tcspc_resolution == 7.999999968033578e-11 if xarray is None: return # skip xarray tests if not installed histogram = ptu.decode_histogram(asxarray=True) assert histogram.shape == (2, 501) assert array_equal(histogram.coords['C'], (2, 3)) assert histogram[1].coords['C'] == 3 histogram = ptu.decode_histogram(asxarray=True, dtime=0) assert histogram.shape == (2, 500) histogram = ptu.decode_histogram(asxarray=True, dtime=100) assert histogram.shape == (2, 100) image = ptu.decode_image(asxarray=True, dtime=-1) assert array_equal(image.coords['C'], (2, 3)) assert image[..., 1, :].coords['C'] == 3 assert array_equal(ptu._coords_c, (2, 3)) image = ptu.decode_image(asxarray=True, channel=1, dtime=-1) assert array_equal(image.coords['C'], (3,)) image = ptu.decode_image( asxarray=True, channel=1, dtime=-1, keepdims=False ) assert 'C' not in image.coords with PtuFile(filename, trimdims='') as ptu: str(ptu) assert ptu.shape == (3, 512, 512, 64, 32768) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert array_equal(ptu.coords['C'][[0, -1]], (0, 63)) assert array_equal(ptu._coords_c[[0, -1]], (0, 63)) if xarray is not None: image = ptu.decode_image(asxarray=True, dtime=-1, channel=2) assert array_equal(image.coords['C'], (2,)) def test_ptu_t3_sinusoidal(): """Test decode T3 image with sinusoidal correction.""" filename = DATA / 'tttrlib/5kDa_1st_1_1_1.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu.version == '1.0.00' # assert ptu._data_offset == 4616 assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_t3 assert ptu.is_image assert ptu.is_sinusoidal assert not ptu.is_bidirectional assert ptu.tags['ImgHdr_SinCorrection'] == 80 assert ptu.shape == (122, 512, 512, 1, 3216) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0,) # TODO: verify coords assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 118.88305798296687 assert ptu.frame_time == 0.9744512873563691 assert ptu.frequency == 38898320.0 assert ptu.global_acquisition_time == 4624351232 assert ptu.global_frame_time == 37904518 assert ptu.global_line_time == 18995 assert ptu.global_pixel_time == 37 assert ptu.global_resolution == 2.5708051144625268e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 3216 assert ptu.number_bins_in_period == 3213 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 1 assert ptu.number_channels_max == 4 assert ptu.number_images == 122 assert ptu.number_lines == 62464 assert ptu.number_markers == 125050 assert ptu.number_photons == 8664782 assert ptu.number_records == 8860394 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 38898320 assert ptu.tcspc_resolution == 7.999999968033578e-12 records = ptu.read_records() assert len(records) == ptu.number_records im = ptu.decode_image(frame=-1, dtime=-1, channel=0, keepdims=False) assert im.shape == (512, 512) assert im[399, 18] == 37 with pytest.raises(ValueError): ptu.decode_image(pixel_time=0) def test_ptu_t3_bidirectional(): """Test decode T3 image acquired with bidirectional scanning.""" filename = DATA / 'fastFLIM/A2_Shep2_26.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu.version == '1.0.00' # assert ptu._data_offset == 4616 assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.GenericT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.FLIMBEE assert ptu.measurement_ndim == 3 assert ptu.is_t3 assert ptu.is_image assert ptu.is_bidirectional assert not ptu.is_sinusoidal assert ptu.tags['ImgHdr_BiDirect'] assert ptu.shape == (1, 1024, 1024, 2, 502) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0, 1) # TODO: verify coords assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 192.27363031561703 assert ptu.frame_time == 192.27363031561703 assert ptu.frequency == 24999920.0 assert ptu.global_acquisition_time == 4806825376 assert ptu.global_frame_time == 4806825376 assert ptu.global_line_time == 3200000 assert ptu.global_pixel_time == 3125 assert ptu.global_resolution == 4.00001280004096e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 1024 assert ptu.number_bins == 502 assert ptu.number_bins_in_period == 500 assert ptu.number_bins_max == 32768 assert ptu.number_channels == 2 assert ptu.number_channels_max == 64 assert ptu.number_images == 1 assert ptu.number_lines == 1024 assert ptu.number_markers == 2049 assert ptu.number_photons == 239232451 assert ptu.number_records == 242488255 assert ptu.pixels_in_frame == 1048576 assert ptu.pixels_in_line == 1024 assert ptu.syncrate == 24999920 assert ptu.tcspc_resolution == 7.999999968033578e-11 records = ptu.read_records() assert len(records) == ptu.number_records im = ptu.decode_image(frame=-1, dtime=-1, channel=0, keepdims=False) assert im.shape == (1024, 1024) # TODO: compare to ground truth image from SymPhoTime assert im[430, 430] == 1057 # even line assert im[431, 431] == 1050 # odd line # selection m, n = 421, 440 selection = (0, slice(m, n), slice(m, n), 0, slice(None, None, -1)) im1 = ptu.decode_image(selection, records=records, keepdims=False) assert array_equal(im[m:n, m:n], im1) # x-shift by one pixel im = ptu.decode_image( records=records, frame=-1, dtime=-1, channel=0, bishift=-ptu.global_pixel_time, keepdims=False, ) assert im[430, 430] == 1057 # even line is same assert im[431, 430] == 1050 # odd line shifted def test_ptu_t3_bidirectional_sinusoidal(): """Test decode T3 image acquired with bidirectional sinusoidal scanning.""" filename = DATA / 'tttr-data/imaging/leica/sp8/d0/G-28_S1_1_1.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu.version == '1.0.00' # assert ptu._data_offset == 5816 assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_t3 assert ptu.is_image assert ptu.is_bidirectional assert ptu.is_sinusoidal assert ptu.tags['ImgHdr_BiDirect'] assert ptu.shape == (26, 512, 512, 1, 3212) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0,) # TODO: verify coords assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 14.697189081520644 assert ptu.frame_time == 0.5652764873231677 assert ptu.frequency == 19459120.0 assert ptu.global_acquisition_time == 285994366 assert ptu.global_frame_time == 10999783 assert ptu.global_line_time == 9534 assert ptu.global_pixel_time == 19 assert ptu.global_resolution == 5.138978535514453e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 3212 assert ptu.number_bins_in_period == 3211 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 1 assert ptu.number_channels_max == 4 assert ptu.number_images == 26 assert ptu.number_lines == 13658 assert ptu.number_markers == 27341 assert ptu.number_photons == 5585391 assert ptu.number_records == 5617095 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 19459120 assert ptu.tcspc_resolution == 1.5999999936067155e-11 records = ptu.read_records() assert len(records) == ptu.number_records im = ptu.decode_image(frame=-1, dtime=-1, channel=0, keepdims=False) assert im.shape == (512, 512) # TODO: compare to ground truth image from SymPhoTime assert im[430, 430] == 38 # even line assert im[431, 431] == 32 # odd line # selection m, n = 421, 440 selection = ( slice(None, None, -1), slice(m, n), slice(m, n), 0, slice(None, None, -1), ) im1 = ptu.decode_image(selection, records=records, keepdims=False) assert array_equal(im[m:n, m:n], im1) # x-shift by one pixel im = ptu.decode_image( records=records, frame=-1, dtime=-1, channel=0, bishift=-ptu.global_pixel_time, keepdims=False, ) assert im[430, 430] == 38 # even line is same assert im[431, 430] == 32 # odd line shifted @pytest.mark.skip('no test file available') def test_ptu_t3_line(): """Test decode T3 line scan.""" def test_ptu_t3_point(): """Test decode T3 point scan.""" filename = DATA / '1XEGFP_1.ptu' with PtuFile(filename) as ptu: str(ptu) assert str(ptu.guid) == 'dec6a033-99a9-482d-afbd-5b5743a25133' assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.POINT assert ptu.scanner == PtuScannerType.PI_E710 assert ptu.measurement_ndim == 1 assert not ptu.is_image assert not ptu.is_bidirectional assert not ptu.is_sinusoidal assert ptu.is_t3 assert ptu.shape == (287, 2, 1564) assert ptu.dims == ('T', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'C', 'H') assert ptu.active_channels == (0, 1) # TODO: verify coords assert ptu.frame_change_mask == 0 assert ptu.line_start_mask == 0 assert ptu.line_stop_mask == 0 assert ptu.acquisition_time == 59.998948937161735 assert ptu.frame_time == 0.0010000110003960143 assert ptu.frequency == 39998560.0 assert ptu.global_frame_time == 39999 assert ptu.global_line_time == 39999 assert ptu.global_pixel_time == 39999 assert ptu.global_resolution == 2.5000900032401165e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 1 assert ptu.number_bins == 1564 assert ptu.number_bins_in_period == 1562 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 2 assert ptu.number_channels_max == 4 assert ptu.number_lines == 0 assert ptu.number_markers == 0 assert ptu.number_photons == 11516799 assert ptu.number_records == 11553418 assert ptu.pixels_in_frame == 1 assert ptu.pixels_in_line == 1 assert ptu.syncrate == 39998560 assert ptu.tcspc_resolution == 1.5999999936067155e-11 assert len(ptu.read_records()) == ptu.number_records # assert ptu.decode_records im0 = ptu.decode_image() assert im0.shape == (287, 2, 1564) assert im0.dtype == numpy.uint16 im = ptu.decode_image(channel=0, frame=2, dtime=-1, dtype='uint32') assert im.shape == (1, 1, 1) assert im.dtype == numpy.uint32 # TODO: verify values @pytest.mark.parametrize('filename', FILES) def test_ptu_decode_records(filename): """Test decode records.""" with PtuFile(DATA / filename) as ptu: decoded = ptu.decode_records() assert decoded.dtype == ( T3_RECORD_DTYPE if ptu.is_t3 else T2_RECORD_DTYPE ) assert decoded.size == ptu.number_records assert decoded['time'][-1] == ptu.global_acquisition_time assert decoded['channel'].max() + 1 == ptu.number_channels assert decoded[decoded['channel'] >= 0].size == ptu.number_photons assert decoded[decoded['marker'] > 0].size == ptu.number_markers nframes = decoded[decoded['marker'] & ptu.frame_change_mask > 0].size if ptu.shape and ptu.tags['Measurement_SubMode'] > 0: assert abs(nframes - ptu.shape[0]) < 2 if ptu.is_t3: assert decoded['dtime'].max() + 1 == ptu.number_bins assert decoded[decoded['dtime'] >= 0].size == ptu.number_photons # TODO: verify values @pytest.mark.parametrize('asxarray', [False, True]) @pytest.mark.parametrize('filename', FILES) def test_ptu_decode_histogram(filename, asxarray): """Test decode histograms.""" if asxarray and xarray is None: pytest.skip('xarray not installed') with PtuFile(DATA / filename) as ptu: ptu.decode_histogram(asxarray=asxarray) # TODO: verify values with pytest.raises(ValueError): ptu.decode_histogram(dtype='int32') @pytest.mark.skipif(pyplot is None, reason='matplotlib not installed') @pytest.mark.parametrize('verbose', [False, True]) @pytest.mark.parametrize('filename', FILES) def test_ptu_plot(filename, verbose): """Test plot methods.""" with PtuFile(DATA / filename) as ptu: ptu.plot(show=False, verbose=verbose) @pytest.mark.parametrize('copy', [False, True]) @pytest.mark.parametrize('memmap', [False, True]) def test_ptu_read_records(copy, memmap): """Test PTU read_records method.""" # the file is tested in test_issue_skip_frame filename = DATA / 'Samples.sptw/GUVs.ptu' with PtuFile(filename, memmap=memmap) as ptu: # explicit copy mode records = ptu.read_records(copy=copy) assert ptu.cache_records == (not memmap) assert isinstance(records, numpy.ndarray), type(records) assert records.size == ptu.number_records if ptu.cache_records: assert ptu._records is not None if copy: assert records is not ptu._records assert not numpy.shares_memory(records, ptu._records) assert ptu.read_records(copy=copy) is not records else: assert records is ptu._records assert records is ptu.read_records(copy=copy) # default copy=True always returns detached arrays records_default = ptu.read_records() assert records_default is not ptu._records assert not numpy.shares_memory(records_default, ptu._records) im0 = ptu.decode_image(records=records, frame=1, channel=1, dtime=-1) del records # disable caching ptu.cache_records = False assert not ptu.cache_records assert ptu._records is None records = ptu.read_records(copy=copy) assert ptu._records is None assert isinstance(records, numpy.ndarray), type(records) assert ptu.read_records(copy=copy) is not records # not from cache # default copy=True with no caching assert ptu.read_records() is not records im1 = ptu.decode_image(records=records, frame=1, channel=1, dtime=-1) assert array_equal(im0, im1) @pytest.mark.parametrize('output', ['ndarray', 'memmap', 'filename']) def test_ptu_output(output): """Test PTU decoding to different output.""" # the file is tested in test_issue_skip_frame filename = DATA / 'Samples.sptw/GUVs.ptu' with PtuFile(filename) as ptu: assert ptu.shape == (100, 512, 512, 2, 4096) selection = ( slice(11, 66, 3), Ellipsis, slice(1, 2), slice(None, None, -1), ) shape = (19, 512, 512, 1, 1) if output == 'ndarray': out = numpy.zeros(shape, 'uint32') elif output == 'filename': out = tempfile.TemporaryFile() else: out = 'memmap' im = ptu.decode_image(selection, out=out) if output == 'ndarray': im = out else: assert isinstance(im, numpy.memmap) assert im[:, 281, 373, 0, 0].sum(axis=0) == 157 if output == 'filename': out.close() def test_ptu_getitem(): """Test slice PTU.""" # the file is tested in test_issue_skip_frame filename = DATA / 'Samples.sptw/GUVs.ptu' with PtuFile(filename) as ptu: assert ptu.shape == (100, 512, 512, 2, 4096) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert not ptu.use_xarray with pytest.raises(ValueError): ptu.dtype = 'int32' # not an unsigned integer assert ptu.dtype == 'uint16' ptu.dtype = 'uint32' assert ptu.dtype == 'uint32' # decode im0 = ptu.decode_image( (slice(11, 66, 3), Ellipsis, slice(1, 2), slice(None, None, -1)), keepdims=False, ) assert im0.shape == (19, 512, 512, 1) assert im0.dtype == 'uint32' assert im0[:, 281, 373, 0].sum(axis=0) == 157 # slice im = ptu[-89:-34:3, ..., :, 1:2, ::-1] assert im0.dtype == 'uint32' assert array_equal(im, im0) ptu.dtype = 'uint16' # slice uint16 im = ptu[11:66:3, ..., -1, ::-1] assert im.shape == (19, 512, 512) assert im.dtype == 'uint16' assert array_equal(im, im0.squeeze()) if xarray is None: return # slice with xarray ptu.use_xarray = True assert ptu.use_xarray im = ptu[11:66:3, ..., 1:2, ::-1] assert isinstance(im, xarray.DataArray) assert tuple(im.coords.keys()) == ('T', 'Y', 'X', 'C') assert im.coords['T'].values[0] == 20.62217778751141 # sum all ptu.dtype = 'uint64' im = ptu[::-1, ::-1, ::-1, ::-1, ::-1] assert isinstance(im, xarray.DataArray) assert im.shape == () assert tuple(im.coords.keys()) == () photons = ptu.decode_image([slice(None, None, -1)] * 5).sum() assert im.values == photons # 21243427 with pytest.raises(IndexError): im = ptu[0, 0, 0, 0, 0, 0] # too many indices with pytest.raises(IndexError): im = ptu[0, ..., 0, ..., 0] # more than one Ellipsis with pytest.raises(IndexError): im = ptu[101] # index out of range with pytest.raises(IndexError): im = ptu[50:49] # stop < start with pytest.raises(IndexError): im = ptu[101:102] # start out of range with pytest.raises(IndexError): im = ptu[1.0] # invalid type def test_issue_datetime(): """Test file with datetime stored as float, not str or TDateTime.""" filename = DATA / 'Zenodo_7656540/2a_FLIM_single_image.ptu' with PtuFile(filename) as ptu: assert ptu.version == '00.0.1' assert ptu.tags['File_CreatingTime'] == 13301655831.929 # raises OverflowError assert ptu.datetime is None def test_issue_falcon_point(): """Test PTU from FALCON with no ImgHdr_PixY.""" # file produced by FALCON in image mode but no ImgHdr_PixX/Y filename = DATA / 'FALCON_ptu_examples/40MHz_example.ptu' with PtuFile(filename) as ptu: assert ptu.version == '00.0.1' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 1 assert ptu.shape == (3, 1, 269) assert ptu.dims == ('T', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'C', 'H') assert ptu.active_channels == (0,) # TODO: verify coords assert ptu._info.skip_first_frame == 0 assert ptu._info.skip_last_frame == 0 assert ptu.frame_change_mask == 0 assert ptu.line_start_mask == 0 assert ptu.line_stop_mask == 0 assert ptu.acquisition_time == 15.4790509824 assert ptu.frame_time == 15.4790509824 assert ptu.frequency == 312500000.0 assert ptu.global_frame_time == 4837203432 assert ptu.global_line_time == 312500 assert ptu.global_pixel_time == 312500 assert ptu.global_resolution == 3.2e-9 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 1 assert ptu.number_bins == 269 assert ptu.number_bins_in_period == 32 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 1 assert ptu.number_channels_max == 4 assert ptu.number_lines == 0 assert ptu.number_markers == 0 assert ptu.number_photons == 1016546 assert ptu.number_records == 1090355 assert ptu.pixels_in_frame == 1 assert ptu.pixels_in_line == 1 assert ptu.syncrate == 0 assert ptu.tcspc_resolution == 9.696969697e-11 im = ptu.decode_image(channel=0, keepdims=False, dtype='uint32') assert im.shape == (3, 269) assert im.dtype == numpy.uint32 assert im[1, 23] == 1 def test_issue_skip_frame(): """Test PTU with incomplete last frame.""" filename = DATA / 'Samples.sptw/GUVs.ptu' with PtuFile(filename) as ptu: assert ptu.version == '00.0.0' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.shape == (100, 512, 512, 2, 4096) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0, 1) # TODO: verify coords assert ptu._info.skip_first_frame == 0 assert ptu._info.skip_last_frame == 1 assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 174.96876283164778 assert ptu.frame_time == 1.7496875403137495 assert ptu.frequency == 9999690.0 assert ptu.global_frame_time == 17496333 assert ptu.global_line_time == 20480 assert ptu.global_pixel_time == 40 assert ptu.global_resolution == 1.0000310009610297e-7 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 4096 assert ptu.number_bins_in_period == 6250 # > number_bins_max ! assert ptu.number_bins_max == 4096 assert ptu.number_channels == 2 assert ptu.number_channels_max == 4 assert ptu.number_lines == 51204 assert ptu.number_markers == 102509 assert ptu.number_photons == 32976068 assert ptu.number_records == 33105274 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 9999690 assert ptu.tcspc_resolution == 1.5999999936067155e-11 im = ptu.decode_image( 92, channel=1, dtime=-1, keepdims=False, dtype='uint32' ) assert im.shape == (512, 512) assert im.dtype == numpy.uint32 assert im[281, 373] == 3 def test_issue_marker_order(): """Test PTU with strange marker order.""" # the file has `[ | ][][][ | ]`` instead of `[] | [][] | []`` markers # first and last frame are incomplete filename = DATA / 'Samples.sptw/CENP-labelled_cells_for_FRET.ptu' with PtuFile(filename, trimdims='CH') as ptu: assert ptu.version == '00.0.0' # assert ptu._data_offset == 4616 assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.shape == (191, 512, 512, 3, 3126) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'C', 'H') assert ptu.active_channels == (0, 1, 2) # TODO: verify coords assert ptu._info.skip_first_frame == 0 assert ptu._info.skip_last_frame == 0 assert ptu._info.lines == 97596 assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 213.87296482079424 assert ptu.frame_time == 1.1197537179119068 assert ptu.frequency == 20001617.0 assert ptu.global_frame_time == 22396885 assert ptu.global_line_time == 20446 assert ptu.global_pixel_time == 40 assert ptu.global_resolution == 4.99959578268097e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 3126 assert ptu.number_bins_in_period == 3124 assert ptu.number_bins_max == 4096 assert ptu.number_channels == 3 assert ptu.number_channels_max == 4 assert ptu.number_lines == 97596 assert ptu.number_markers == 195384 assert ptu.number_photons == 17601306 assert ptu.number_records == 17861964 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 20001617 assert ptu.tcspc_resolution == 1.5999999936067155e-11 # image of shape (3, 191, 512, 512, 3126) too large 875 GiB im = ptu.decode_image(channel=0, frame=92, dtime=-1, dtype='uint32') assert im.shape == (1, 512, 512, 1, 1) assert im.dtype == numpy.uint32 assert im[0, 390, 277] == 6 del im with PtuFile(filename, trimdims='T') as ptu: # trim only time dimension assert ptu.shape == (189, 512, 512, 4, 4096) assert ptu._info.skip_first_frame == 1 assert ptu._info.skip_last_frame == 1 im = ptu.decode_image( channel=0, frame=91, dtime=-1, dtype='uint8', keepdims=False ) assert im.shape == (512, 512) assert im[390, 277] == 6 def test_issue_empty_line(): """Test line not empty when first record is start marker.""" filename = DATA / 'ExampleFLIM/Example_image.sc.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu.version == '00.0.1' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.shape == (1, 256, 256, 1, 133) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.coords['H'][1] == 9.696969697e-11 # 97 ps assert ptu.active_channels == (0,) assert ptu._info.skip_first_frame == 0 assert ptu._info.skip_last_frame == 0 assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 0.27299666752114843 assert ptu.frame_time == 0.27299666752114843 assert ptu.frequency == 78020000.0 assert ptu.syncrate == 78020000 assert ptu.number_markers == 513 assert ptu.number_photons == 722402 assert ptu.number_records == 723240 assert ptu.global_pixel_time == 325 # 324 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.decode_records()['marker'][0] == 1 # start marker assert ptu[0, 0, 100, 0, ::-1] == 40 # first line not empty def test_issue_pixeltime_zero(): """Test PTU with zero ImgHdr_TimePerPixel.""" filename = DATA / 'nc.picoquant.com/DaisyPollen1.ptu' with PtuFile(filename) as ptu: assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.GenericT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner is None assert ptu.measurement_ndim == 3 assert ptu.tags['ImgHdr_TimePerPixel'] == 0 # nasty assert ptu.global_pixel_time == 160 assert ptu.shape == (10, 512, 512, 2, 2510) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0, 1) # TODO: verify coords assert ptu._info.skip_first_frame == 1 assert ptu._info.skip_last_frame == 1 assert ptu.frame_change_mask == 4 assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.acquisition_time == 17.6701460317873 assert ptu.frame_time == 1.767014600678785 assert ptu.frequency == 40000880.0 assert ptu.global_frame_time == 70682139 assert ptu.global_line_time == 81920 assert ptu.global_pixel_time == 160 assert ptu.global_resolution == 2.4999450012099732e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 2510 assert ptu.number_bins_in_period == 2499 assert ptu.number_bins_max == 32768 assert ptu.number_channels == 2 assert ptu.number_channels_max == 64 assert ptu.number_lines == 5499 assert ptu.number_markers == 11009 assert ptu.number_photons == 37047472 assert ptu.number_records == 37748736 assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 40000880 assert ptu.tcspc_resolution == 9.999999960041972e-12 im = ptu.decode_image( 9, channel=0, dtime=-1, keepdims=False, dtype='uint32' ) assert im.shape == (512, 512) assert im.dtype == numpy.uint32 assert im[281, 373] == 25 im = ptu.decode_image( 9, channel=0, dtime=-1, pixel_time=0, keepdims=False, dtype='u4' ) assert im.shape == (512, 512) assert im.dtype == numpy.uint32 assert im[281, 373] == 25 def test_issue_pixeltime_off(): """Test PTU with ImgHdr_TimePerPixel metadata slightly off.""" filename = DATA / 'UNC/805_1.ptu' with PtuFile(filename) as ptu: assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.TimeHarp260PT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.PI_E727 assert ptu.measurement_ndim == 3 assert ptu.tags['ImgHdr_TimePerPixel'] == 2.0 # slightly off assert ptu.global_pixel_time == 40000 assert ptu.shape == (1, 256, 256, 2, 2002) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0, 1) assert ptu._info.line_time == 10232923 assert ptu._info.skip_first_frame == 0 assert ptu._info.skip_last_frame == 0 assert ptu.frame_change_mask == 0 # ! assert ptu.line_start_mask == 4 assert ptu.line_stop_mask == 8 assert ptu.acquisition_time == 172.07485649999998 assert ptu.frame_time == 172.07485649999998 assert ptu.frequency == 20000000.0 assert ptu.global_frame_time == 3441497130 assert ptu.global_line_time == 10240000 assert ptu.global_pixel_time == 40000 assert ptu.global_resolution == 5e-08 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 256 assert ptu.number_bins == 2002 assert ptu.number_bins_in_period == 1999 assert ptu.number_bins_max == 32768 assert ptu.number_channels == 2 assert ptu.number_channels_max == 64 assert ptu.number_lines == 256 assert ptu.number_markers == 512 assert ptu.number_photons == 56142925 assert ptu.number_records == 59504234 assert ptu.pixels_in_frame == 65536 assert ptu.pixels_in_line == 256 assert ptu.syncrate == 20000000 assert ptu.tcspc_resolution == 2.50000003337858e-11 data = binread(DATA / 'UNC/805.bin')[0].sum(axis=-1, dtype=numpy.int32) # use pixel time from metadata im = ptu.decode_image(channel=1, frame=0, keepdims=False) im = im[..., :2000].sum(axis=-1, dtype=numpy.int32) assert numpy.abs(im - data).max() == 298 # use pixel time from average line time im = ptu.decode_image( channel=1, frame=0, pixel_time=ptu._info.line_time / 256 * ptu.global_resolution, keepdims=False, ) im = im[..., :2000].sum(axis=-1, dtype=numpy.int32) assert numpy.abs(im - data).max() == 8 # use pixel time from line markers im = ptu.decode_image(channel=1, frame=0, pixel_time=0, keepdims=False) im = im[..., :2000].sum(axis=-1, dtype=numpy.int32) assert numpy.abs(im - data).max() == 8 def test_issue_number_records_zero(caplog): """Test PTU with zero TTResult_NumberOfRecords.""" # https://github.com/cgohlke/ptufile/issues/2 filename = DATA / 'FLIM_number_records_zero.ptu' with PtuFile(filename) as ptu: ptu.cache_records = False with caplog.at_level(logging.WARNING): assert ptu.number_records == 12769472 assert ptu.tags['TTResult_NumberOfRecords'] == 0 assert 'invalid TTResult_NumberOfRecords' in caplog.text assert len(ptu.read_records()) == 12769472 def test_issue_number_records_negative(caplog): """Test PTU with negative TTResult_NumberOfRecords.""" # file >4 GB produced by LAS X software. Received by email on Oct 27, 2025. filename = DATA / 'i3S/AlessandroSlide_10x_488nm.ptu' with PtuFile(filename) as ptu: ptu.cache_records = False with caplog.at_level(logging.WARNING): assert ptu.number_records == 3167584182 assert ptu.tags['TTResult_NumberOfRecords'] == -1127383114 assert 'invalid TTResult_NumberOfRecords' in caplog.text assert len(ptu.read_records()) == 3167584182 @pytest.mark.parametrize('memmap', [False, True]) def test_issue_record_number(memmap, caplog): """Test PTU with too few records.""" filename = DATA / 'Samples.sptw/Cy5_immo_FLIM+Pol-Imaging.ptu' with PtuFile(filename, memmap=memmap) as ptu: assert ptu.version == '00.0.0' with caplog.at_level(logging.ERROR): records = ptu.read_records() assert 'expected 3409856 items, got 3364091' in caplog.text assert len(records) == 3364091 decoded = ptu.decode_records() assert decoded.size == 3364091 assert decoded['time'][-1] == ptu.global_acquisition_time assert decoded['channel'].max() + 1 == ptu.number_channels assert decoded[decoded['channel'] >= 0].size == ptu.number_photons assert decoded[decoded['marker'] > 0].size == ptu.number_markers str(ptu) def test_issue_tag_index_order(caplog): """Test sparse tag indices.""" filename = DATA / 'picoquant-sample-data/hydraharp/v10_t2.ptu' with caplog.at_level(logging.WARNING): # noqa: SIM117 with PqFile(filename) as pq: str(pq) assert 'UsrHeadName' not in caplog.text assert pq.type == PqFileType.PTU assert pq.version == '1.0.00' assert pq.tags['UsrHeadName'] == [ None, '405.0nm (DC405)', None, '485.0nm (DC485)', ] @pytest.mark.skipif(xarray is None, reason='xarray not installed') @pytest.mark.parametrize( ('dtime', 'size'), [ (None, 139), # last bin with non-zero photons (0, 132), # last bin matching frequency (-1, 1), # integrate bins (32, 32), # specified number of bins (145, 145), ], ) def test_issue_dtime(dtime, size): """Test dtime parameter.""" filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) im = imread( filename, frame=0, channel=0, dtime=dtime, dtype=numpy.uint8, asxarray=True, ) assert im.dtype == numpy.uint8 assert im.shape == (1, 256, 256, 1, size) assert im.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(im.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') def test_issue_line_markers(): """Test nonsense line markers.""" # This file apparently omits line stop markers if no photons were # recorded in the line. # The line stop markers don't match the timing expected from pixel time # and number of pixels in lines. # There are many more lines than expected from the frame height. # In conclusion, the line time from the `info` object is not reliable. filename = DATA / 'Tutorials.sptw/MicroBeads.ptu' with PtuFile(filename) as ptu: str(ptu) assert ptu._info.lines == 300 assert ptu._info.line_time == 9000 # should be 18000 assert ptu.global_line_time == 18000 data = ptu.decode_image(pixel_time=0) assert data.shape == (2, 150, 150, 2, 626) assert data.sum(dtype=numpy.uint32) == 778696 def test_issue_imspector(caplog): """Test file written by Seidel group using Abberior Imspector software.""" # This file stores wrong marker masks and twice as many lines per frame. # Frames and channels need to be "deinterlaced". filename = DATA / 'HHU/PQSpcm_2021-12-13_17-53-45.ptu' with PtuFile(filename) as ptu: assert ptu.tags['CreatorSW_Name'] == 'Imspector' assert ptu.tags['ImgHdr_LineStart'] == 0 # invalid assert ptu.tags['ImgHdr_LineStop'] == 1 # wrong assert ptu.tags['ImgHdr_Frame'] == 2 # wrong assert ptu.tags['ImgHdr_PixY'] == 100 # wrong assert ptu._info.frames == 0 assert ptu._info.lines == 0 str(ptu) image = ptu.decode_image(dtime=-1, frame=-1, keepdims=False) assert 'invalid line_start' in caplog.text assert image.sum() == 0 # empty with PtuFile(filename) as ptu: # overwrite invalid header values before inspecting or decoding ptu.tags['ImgHdr_LineStart'] = 1 ptu.tags['ImgHdr_LineStop'] = 2 ptu.tags['ImgHdr_Frame'] = 3 ptu.tags['ImgHdr_PixY'] *= 2 assert ptu._info.frames == 61 assert ptu._info.lines == 12200 image = ptu.decode_image(dtime=-1, frame=-1, keepdims=False) assert image.sum() == 24953 # deinterlace lines and channels channel = [ image[::2, :, 0] + image[::2, :, 2], image[::2, :, 1] + image[::2, :, 3], image[1::2, :, 0] + image[1::2, :, 2], image[1::2, :, 1] + image[1::2, :, 3], ] assert channel[0].shape == (100, 100) assert channel[0].sum() == 7272 assert channel[1].sum() == 6658 assert channel[2].sum() == 3789 assert channel[3].sum() == 7234 @pytest.mark.skipif(xarray is None, reason='xarray not installed') def test_imread(): """Test imread function.""" filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) im = imread( filename, [slice(1), None], # first frame channel=0, frame=None, dtime=0, pixel_time=0, dtype=numpy.uint8, asxarray=True, ) assert im.dtype == numpy.uint8 assert im.shape == (1, 256, 256, 1, 132) assert im.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(im.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') @pytest.mark.parametrize('pixel_time', [None, 0.1]) @pytest.mark.parametrize('counts', [0, 1, 87]) @pytest.mark.parametrize( 'shape', [ (31, 33, 63), (5, 31, 33, 63), (31, 33, 2, 63), (5, 31, 33, 2, 63), (2, 3, 31, 33, 2, 63), ], ) @pytest.mark.parametrize( 'record_type', [PtuRecordType.PicoHarpT3, PtuRecordType.GenericT3] ) def test_imwrite(record_type, pixel_time, shape, counts): """Test imwrite function.""" if counts: data = RNG.integers(0, counts, shape, numpy.uint8) else: data = numpy.zeros(shape, numpy.uint8) dims = 'TYXH' if data.ndim == 4 and data.shape[0] == 5 else None if data.ndim == 3: shape = (1, 31, 33, 1, 63) if counts: data[..., -1] = 1 elif data.ndim == 4: if dims: shape = (5, 31, 33, 1, 63) if counts: data[..., -1] = 1 else: shape = (1, 31, 33, 2, 63) if counts: data[..., :, -1] = 1 else: shape = data.shape if counts: data[..., :, -1] = 1 guid = '{b767c46e-9693-4ad9-9fcf-7fab5e4377fc}' comment = f'{shape=} \u2764\ufe0f' buf = io.BytesIO() imwrite( buf, data, global_resolution=4e-8, tcspc_resolution=8e-11, record_type=record_type, pixel_time=pixel_time, pixel_resolution=0.5, dims=dims, comment=comment, guid=guid, tags={ 'HW_Markers': 4, 'HWMarkers_Enabled': [True, True, True, True], 'TTResult_MDescWarningFlags': PtuMeasurementWarnings(2), }, ) buf.seek(0) data = data.reshape(shape) if counts == 0: data = data[..., :1, :1] zstack = data.ndim == 6 with PtuFile(buf) as ptu: str(ptu) assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == record_type assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == (4 if zstack else 3) assert ptu.is_image assert ptu.is_t3 assert ptu.comment == comment assert ptu.shape == data.shape assert str(ptu.guid) == guid[1:-1] assert ptu.global_resolution == 4e-8 if pixel_time is not None: assert ptu.pixel_time == pixel_time assert ptu.global_line_time == pixel_time / 4e-8 * shape[-3] assert ptu._info.line_time == ptu.global_line_time assert ptu.tags['ImgHdr_MaxFrames'] == ( shape[0] * shape[1] if zstack else shape[0] ) assert ptu.tags['HW_InpChannels'] == shape[-2] + 1 assert ptu.tags['ImgHdr_PixResol'] == 0.5 assert ptu.tags['HW_Markers'] == 4 assert ptu.tags['HWMarkers_Enabled'] == [True, True, True, True] assert ptu.tags['TTResult_MDescWarningFlags'] == 2 data2 = ptu.decode_image() assert array_equal(data2, data) data2 = ptu.decode_image(pixel_time=0.0) assert array_equal(data2, data) @pytest.mark.parametrize( 'record_type', [PtuRecordType.PicoHarpT3, PtuRecordType.GenericT3] ) def test_imwrite_rewrite(record_type): """Test imwrite function with real data.""" filename = DATA / 'Tutorials.sptw/Kidney _Cell_FLIM.ptu' buf = io.BytesIO() with PtuFile(filename) as ptu0: data = ptu0.decode_image() assert data.shape == (3, 512, 512, 2, 501) imwrite( buf, data, global_resolution=ptu0.global_resolution, # 4.00001280004096e-8 tcspc_resolution=ptu0.tcspc_resolution, # 7.999999968033578e-11 record_type=record_type, pixel_time=ptu0.pixel_time, # 3e-5 pixel_resolution=ptu0.tags['ImgHdr_PixResol'], # 0.504453125 guid=ptu0.guid, datetime=ptu0.datetime, comment=ptu0.comment, tags={'File_RawData_GUID': [ptu0.guid]}, ) buf.seek(0) with PtuFile(buf) as ptu: str(ptu) assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == record_type assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.shape == (3, 512, 512, 2, 501) assert ptu.dims == ('T', 'Y', 'X', 'C', 'H') assert tuple(ptu.coords.keys()) == ('T', 'Y', 'X', 'C', 'H') assert ptu.active_channels == (0, 1) assert array_equal(ptu.coords['C'], (0, 1)) # TODO: verify coords assert ptu.guid == ptu0.guid assert ptu.comment == ptu0.comment assert ptu.datetime == ptu0.datetime assert ptu.line_start_mask == 1 assert ptu.line_stop_mask == 2 assert ptu.frame_change_mask == 4 assert ptu._info.line_time == 384000 assert ptu.acquisition_time == 23.593035497713593 assert ptu.frame_time == 7.864345165904531 assert ptu.frequency == 24999920.0 assert ptu.global_frame_time == 196608000 assert ptu.global_line_time == 384000 assert ptu.global_pixel_time == 750 assert ptu.global_resolution == 4.00001280004096e-8 assert ptu.pixel_time == pytest.approx( ptu.global_pixel_time * ptu.global_resolution, rel=1e-3 ) assert ptu.line_time == pytest.approx( ptu.global_line_time * ptu.global_resolution, rel=1e-3 ) assert ptu.lines_in_frame == 512 assert ptu.number_bins == 501 assert ptu.number_bins_in_period == 500 assert ptu.number_channels == 2 assert ptu.number_lines == 1536 assert ptu.number_markers == 3075 assert ptu.number_photons == data.sum(dtype=numpy.uint32) assert ptu.number_records in {21116856, 21683856} assert ptu.pixels_in_frame == 262144 assert ptu.pixels_in_line == 512 assert ptu.syncrate == 24999920 assert ptu.tcspc_resolution == 7.999999968033578e-11 assert ptu.tags['File_RawData_GUID'][0] == ( '{b767c46e-9693-4ad9-9fcf-7fab5e4377fc}' ) assert ptu.tags['ImgHdr_PixResol'] == 0.504453125 data2 = ptu.decode_image() assert array_equal(data2, data) @pytest.mark.parametrize('count', [0, 1, 87]) @pytest.mark.parametrize( 'record_type', [PtuRecordType.PicoHarpT3, PtuRecordType.GenericT3] ) def test_imwrite_pixel(record_type, count): """Test imwrite function with one pixel.""" data = numpy.full((1, 1, 1, 1, 1), count, numpy.uint8) buf = io.BytesIO() imwrite(buf, data, 4e-8, 8e-11, record_type=record_type) buf.seek(0) with PtuFile(buf) as ptu: str(ptu) assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == record_type assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.shape == (1, 1, 1, 1, 1) assert ptu.number_photons == count assert ptu.number_records == count + 3 records = ptu.decode_records() histogram = ptu.decode_histogram() image = ptu.decode_image() assert records.size == count + 3 assert histogram.shape == (1, 1) assert array_equal(image, data) def test_imwrite_zstack(): """Test imwrite function with z-stack data.""" t, z, y, x, c, h = 2, 3, 16, 16, 2, 32 data = RNG.integers(0, 3, (t, z, y, x, c, h), numpy.uint16) data[..., -1] = 1 buf = io.BytesIO() imwrite( buf, data, global_resolution=4e-8, tcspc_resolution=8e-11, pixel_time=0.1, ) buf.seek(0) with PtuFile(buf) as ptu: assert ptu.measurement_ndim == 4 assert ptu.tags['ImgHdr_Dimensions'] == 4 assert ptu.tags['ImgHdr_PixZ'] == z assert ptu.tags['ImgHdr_MaxFrames'] == t * z assert ptu.tags['ImgHdr_PixX'] == x assert ptu.tags['ImgHdr_PixY'] == y assert ptu.shape == (t, z, y, x, c, h) assert ptu.dims == ('T', 'Z', 'Y', 'X', 'C', 'H') assert ptu.number_photons == int(data.sum()) assert array_equal(ptu.decode_image(), data) assert array_equal(ptu.decode_image(pixel_time=0.0), data) assert array_equal(ptu.decode_image(frame=1, plane=2), data[1:2, 2:3]) assert array_equal( ptu.decode_image(frame=-1, plane=-1, dtype='uint32')[0, 0], data.sum(axis=(0, 1), dtype=numpy.uint32), ) def test_write_zstack_iterate(): """Test PtuWriter writing z-stack one volume at a time.""" t, z, y, x, c, h = 2, 3, 16, 16, 2, 32 data = numpy.ones((t, z, y, x, c, h), numpy.uint16) buf = io.BytesIO() with PtuWriter(buf, (1, z, y, x, c, h), 4e-8, 8e-11, 0.1) as ptu_out: for t_idx in range(t): ptu_out.write(data[t_idx]) buf.seek(0) with PtuFile(buf) as ptu: assert ptu.measurement_ndim == 4 assert ptu.tags['ImgHdr_Dimensions'] == 4 assert ptu.tags['ImgHdr_PixZ'] == z assert ptu.tags['ImgHdr_MaxFrames'] == t * z assert ptu.shape == (t, z, y, x, c, h) assert ptu.dims == ('T', 'Z', 'Y', 'X', 'C', 'H') assert ptu.number_photons == int(data.sum()) assert array_equal(ptu.decode_image(), data) @pytest.mark.parametrize( 'record_type', [PtuRecordType.PicoHarpT3, PtuRecordType.GenericT3] ) @pytest.mark.parametrize('chunks', [(1, 1, 1, 1, 1, 1), (2, 1, 3)]) def test_write_zstack_planes(record_type, chunks): """Test PtuWriter writing z-stack in chunks of planes.""" t, z, y, x, c, h = 2, 3, 8, 7, 2, 32 data = RNG.integers(0, 3, (t, z, y, x, c, h), numpy.uint16) data[..., -1] = 1 planes = data.reshape(t * z, y, x, c, h) buf = io.BytesIO() with PtuWriter( buf, (1, z, y, x, c, h), 4e-8, 8e-11, 1e-5, record_type=record_type ) as ptu_out: index = 0 for n in chunks: chunk = planes[index : index + n] ptu_out.write(chunk[0] if n == 1 else chunk) index += n buf.seek(0) with PtuFile(buf) as ptu: assert ptu.tags['ImgHdr_PixZ'] == z assert ptu.tags['ImgHdr_MaxFrames'] == t * z assert ptu.shape == (t, z, y, x, c, h) assert ptu.number_photons == int(data.sum()) assert array_equal(ptu.decode_image(), data) assert array_equal(ptu.decode_image(pixel_time=0.0), data) records = ptu.decode_records() # overflow records report time + count for GenericT3 events = (records['channel'] >= 0) | (records['marker'] > 0) times = records['time'][events].astype(numpy.int64) assert numpy.all(numpy.diff(times) >= 0) buf2 = io.BytesIO() with PtuWriter( buf2, (1, z, y, x, c, h), 4e-8, 8e-11, 1e-5, record_type=record_type ) as ptu_out: ptu_out.write(data) with PtuFile(buf2) as ptu2: records2 = ptu2.decode_records() # chunking must not change encoded records assert array_equal(records2, records) def test_write_frame_size_mismatch(): """Test PtuWriter.write with data not a multiple of frame size.""" buf = io.BytesIO() with PtuWriter(buf, (1, 3, 4, 5, 1, 6), 4e-8, 8e-11, 1e-6) as ptu: with pytest.raises(ValueError): ptu.write(numpy.ones((4, 5, 1, 7), numpy.uint8)) ptu.write(numpy.ones((3, 4, 5, 1, 6), numpy.uint8)) def test_write_zstack_incomplete(caplog): """Test PtuWriter warns about incomplete z-stack.""" t, z, y, x, c, h = 1, 3, 4, 5, 1, 6 data = numpy.ones((t, z, y, x, c, h), numpy.uint8) buf = io.BytesIO() with caplog.at_level(logging.WARNING): with PtuWriter(buf, data.shape, 4e-8, 8e-11, 1e-6) as ptu_out: ptu_out.write(data[0, :2]) assert 'not a multiple of nplanes=3' in caplog.text buf.seek(0) with PtuFile(buf) as ptu: assert ptu.tags['ImgHdr_MaxFrames'] == 2 assert ptu.number_photons == int(data[0, :2].sum()) def test_write_none(): """Test PtuWriter with no data.""" buf = io.BytesIO() with PtuWriter(buf, (2, 3, 4, 5, 6), 4e-8, 8e-11, 1e-6) as ptu: pass buf.seek(0) with PtuFile(buf) as ptu: str(ptu) assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.GenericT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.shape == (1, 3, 4, 1, 1) assert ptu.number_photons == 0 assert ptu.number_records == 0 records = ptu.decode_records() assert len(records) == 0 histogram = ptu.decode_histogram() assert array_equal(histogram, numpy.zeros((1, 1), numpy.uint32)) image = ptu.decode_image() assert array_equal(image, numpy.zeros((1, 3, 4, 1, 1), numpy.uint16)) def test_write_iterate(): """Test PtuWriter.write iteratively.""" filename = DATA / 'Samples.sptw/GUVs.ptu' with tempfile.NamedTemporaryFile(suffix='.ptu', delete=False) as tmp: fnout = tmp.name try: with PtuFile(filename) as ptu_in: assert ptu_in.shape == (100, 512, 512, 2, 4096) with PtuWriter( fnout, ptu_in.shape[1:], global_resolution=ptu_in.global_resolution, tcspc_resolution=ptu_in.tcspc_resolution, pixel_time=ptu_in.pixel_time, ) as ptu_out: for i in range(2): frame = ptu_in.decode_image(frame=i) assert frame.shape == (1, 512, 512, 2, 4096) # 4 GB ptu_out.write(frame) with PtuFile(fnout) as ptu: assert ptu.version == '1.0.00' assert ptu.type == PqFileType.PTU assert ptu.record_type == PtuRecordType.PicoHarpT3 assert ptu.measurement_mode == PtuMeasurementMode.T3 assert ptu.measurement_submode == PtuMeasurementSubMode.IMAGE assert ptu.scanner == PtuScannerType.LSM assert ptu.measurement_ndim == 3 assert ptu.is_image assert ptu.is_t3 assert ptu.tags['ImgHdr_MaxFrames'] == 2 assert ptu.number_records == 685208 assert ptu.shape == (2, 512, 512, 2, 4096) assert array_equal( ptu.decode_image(frame=1, dtime=-1, dtype=numpy.uint16), frame.sum(axis=-1, dtype=numpy.uint16, keepdims=True), ) finally: if os.path.exists(fnout): os.remove(fnout) def test_imwrite_exceptions(): """Test imwrite function exceptions.""" buf = io.BytesIO() kwargs = {'global_resolution': 4e-8, 'tcspc_resolution': 8e-11} with pytest.raises(ValueError): # cannot write to imwrite([], numpy.empty((31, 33, 1), numpy.uint8), **kwargs) with pytest.raises(ValueError): # not unsigned int imwrite(buf, numpy.empty((31, 33, 1), numpy.int8), **kwargs) with pytest.raises(ValueError): # not enough dimensions imwrite(buf, numpy.empty((31, 33), numpy.uint8), **kwargs) with pytest.raises(ValueError): # too many dimensions imwrite( buf, numpy.empty((1, 1, 31, 33, 1, 1, 1), numpy.uint8), **kwargs ) with pytest.raises(ValueError): # too many channels imwrite(buf, numpy.empty((1, 31, 33, 64, 1), numpy.uint8), **kwargs) with pytest.raises(ValueError): # too many bins imwrite(buf, numpy.empty((1, 1, 1, 1, 32769), numpy.uint8), **kwargs) with pytest.raises(ValueError): # invalid record_type imwrite( buf, numpy.empty((31, 33, 1), numpy.uint8), record_type=PtuRecordType.GenericT2, **kwargs, ) with pytest.raises(ValueError): # invalid global_resolution imwrite( buf, numpy.empty((31, 33, 1), numpy.uint8), global_resolution=0.0, tcspc_resolution=8e-11, ) with pytest.raises(ValueError): # tcspc_resolution > global_resolution imwrite( buf, numpy.empty((31, 33, 1), numpy.uint8), global_resolution=4e-8, tcspc_resolution=8e-6, ) with pytest.raises(ValueError): # pixel_time < global_resolution imwrite( buf, numpy.empty((31, 33, 1), numpy.uint8), global_resolution=4e-8, tcspc_resolution=8e-11, pixel_time=1e-9, ) # with pytest.raises(ValueError): # # global_pixel_time=250 < photons_in_pixel=255 # imwrite( # buf, # numpy.full((31, 33, 1), 255, numpy.uint8), # pixel_time=1e-5, # **kwargs, # ) with pytest.raises(ValueError): # invalid guid imwrite(buf, numpy.empty((31, 33, 1), numpy.uint8), guid='-', **kwargs) with pytest.raises(ValueError): # dims length does not match shape ndim imwrite( buf, numpy.empty((31, 33, 63), numpy.uint8), dims='TYXH', **kwargs, ) with pytest.raises(ValueError): # dims contains invalid axis labels imwrite( buf, numpy.empty((31, 33, 1, 63), numpy.uint8), dims='ABCD', **kwargs, ) with pytest.raises(ValueError): # dims not in canonical TZYXCH order imwrite( buf, numpy.empty((31, 33, 63), numpy.uint8), dims='HYX', **kwargs, ) def test_signal_from_ptu(): """Test PhasorPy signal_from_ptu function.""" try: from phasorpy.io import signal_from_ptu except ImportError: pytest.skip('PhasorPy not installed') filename = ( DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_single_image.ptu' ) signal = signal_from_ptu( filename, frame=-1, channel=0, dtime=0, keepdims=False ) assert signal.values.sum(dtype=numpy.uint64) == 6064854 assert signal.dtype == numpy.uint16 assert signal.shape == (256, 256, 132) assert signal.dims == ('Y', 'X', 'H') assert_almost_equal( signal.coords['H'].data[[1, -1]], [0.0969697, 12.7030303], decimal=4 ) assert signal.attrs['frequency'] == 78.02 assert signal.attrs['ptu_tags']['HW_Type'] == 'PicoHarp' signal = signal_from_ptu( filename, frame=-1, channel=0, dtime=None, keepdims=True, trimdims='TC', ) assert signal.values.sum(dtype=numpy.uint64) == 6065123 assert signal.dtype == numpy.uint16 assert signal.shape == (1, 256, 256, 1, 4096) assert signal.dims == ('T', 'Y', 'X', 'C', 'H') assert_almost_equal( signal.coords['H'].data[[1, -1]], [0.0969697, 397.09091], decimal=4 ) assert signal.attrs['frequency'] == 78.02 def test_signal_from_ptu_irf(): """Test read PhasorPy signal_from_ptu function with IRF.""" try: from phasorpy.io import signal_from_ptu except ImportError: pytest.skip('PhasorPy not installed') filename = DATA / 'Samples.sptw/Cy5_diff_IRF+FLCS-pattern.ptu' signal = signal_from_ptu(filename, channel=None, keepdims=True) assert signal.values.sum(dtype=numpy.uint64) == 13268548 assert signal.dtype == numpy.uint32 assert signal.shape == (1, 1, 1, 2, 6250) assert signal.dims == ('T', 'Y', 'X', 'C', 'H') assert_almost_equal( signal.coords['H'].data[[1, -1]], [0.007999, 49.991999], decimal=4 ) assert pytest.approx(signal.attrs['frequency'], abs=1e-4) == 19.999732 assert signal.attrs['ptu_tags']['HW_Type'] == 'PicoHarp 300' signal = signal_from_ptu(filename, channel=0, keepdims=True) assert signal.values.sum(dtype=numpy.uint64) == 6984849 assert signal.shape == (1, 1, 1, 1, 6250) assert signal.dims == ('T', 'Y', 'X', 'C', 'H') with pytest.raises(ValueError): signal_from_ptu(filename, dtime=-1) signal = signal_from_ptu(filename, channel=0, dtime=None, keepdims=False) assert signal.values.sum(dtype=numpy.uint64) == 6984849 assert signal.shape == (1, 1, 4096) assert signal.dims == ('Y', 'X', 'H') assert_almost_equal( signal.coords['H'].data[[1, -1]], [0.007999, 32.759999], decimal=4 ) @pytest.mark.parametrize( 'filename', tuple( itertools.chain.from_iterable( glob.glob(f'**/*{ext}', root_dir=DATA, recursive=True) for ext in FILE_EXTENSIONS ) ), ) def test_glob(filename): """Test read all PicoQuant files.""" filename = str(DATA / filename) if 'htmlcov' in filename or 'url' in filename or 'defective' in filename: pytest.skip() with PqFile(filename, memmap=True) as pq: str(pq) is_ptu = pq.type == PqFileType.PTU if is_ptu: with PtuFile(filename, memmap=True) as ptu: str(ptu) @pytest.mark.parametrize( ('trimdims', 'dtime', 'size'), [('TC', None, 4096), ('TCH', 0, 132)] ) def test_ptu_zip_sequence(trimdims, dtime, size): """Test read Z-stack with imread and tifffile.FileSequence.""" # requires ~28GB. Do not trim H dimensions such that files match from tifffile import FileSequence filename = DATA / 'napari_flim_phasor_plotter/hazelnut_FLIM_z_stack.zip' with FileSequence(imread, '*.ptu', container=filename) as ptus: assert ptus.shape == (11,) stack = ptus.asarray( channel=0, trimdims=trimdims, dtime=dtime, ioworkers=1 ) assert stack.shape == (11, 5, 256, 256, 1, size) # 11 files, 5 frames each assert stack.dtype == 'uint16' def test_ptu_leica_sequence(): """Test read Leica TZ-stack with imread and tifffile.imread.""" # 410 files. Requires ~16GB. import tifffile # >= 2024.2.12 filename = DATA / 'Flipper TR time series.sptw/*.ptu' stack = tifffile.imread( str(filename), # glob pattern needs to be str pattern=r'_(t)(\d+)_(z)(\d+)', imread=imread, chunkshape=(512, 512, 132), # shape returned by imread chunkdtype='uint8', # dtype returned by imread ioworkers=None, # use multi-threading imreadargs={ 'frame': 0, 'channel': 0, 'dtime': 132, # fix number of bins to 132 'dtype': 'uint8', # request uint8 output 'keepdims': False, }, ) assert stack.shape == (41, 10, 512, 512, 132) assert stack.dtype == 'uint8' assert stack[24, 4, 228, 279, 16] == 3 def test_ptu_numcodecs(): """Test Leica TZ-stack with tifffile.ZarrFileSequenceStore and fsspec.""" # 410 files. Requires ~16GB. try: import tifffile import tifffile.zarr import zarr from kerchunk.utils import refs_as_store except ImportError: pytest.skip('fsspec, tifffile, or zarr not installed') pathname = DATA / 'Flipper TR time series.sptw' url = str(pathname).replace('\\', '/') jsonfile = str(pathname / 'FLIPPER.json') filename = str(pathname / '*.ptu') # glob pattern needs to be str store = tifffile.imread( filename, pattern=r'_(t)(\d+)_(z)(\d+)', imread=imread, chunkshape=(512, 512), # shape returned by imread chunkdtype='uint8', # dtype returned by imread imreadargs={ 'frame': 0, 'channel': 0, 'dtime': -1, 'pixel_time': None, 'dtype': 'uint8', # request uint8 output 'trimdims': None, 'keepdims': False, }, aszarr=True, ) assert isinstance(store, tifffile.zarr.ZarrFileSequenceStore) store.write_fsspec( jsonfile, url=url, version=1, codec_id='ptufile', quote=False, ) store.close() ptufile.numcodecs.register_codec() stack = zarr.open(refs_as_store(jsonfile), mode='r') assert stack.shape == (41, 10, 512, 512) assert stack.dtype == 'uint8' assert stack[24, 4, 228, 279] == 18 @pytest.mark.skipif( not hasattr(sys, '_is_gil_enabled'), reason='Python < 3.13' ) def test_gil_enabled(): """Test that GIL state is consistent with build configuration.""" assert sys._is_gil_enabled() != sysconfig.get_config_var('Py_GIL_DISABLED') if __name__ == '__main__': import warnings # warnings.simplefilter('always') warnings.filterwarnings('ignore', category=ImportWarning) argv = sys.argv argv.append('--cov-report=html') argv.append('--cov=ptufile') argv.append('--verbose') sys.exit(pytest.main(argv)) # mypy: allow-untyped-defs # mypy: check-untyped-defs=False