├── oiffile ├── py.typed ├── __main__.py ├── __init__.py └── oiffile.py ├── test.oib ├── .gitattributes ├── pyproject.toml ├── MANIFEST.in ├── LICENSE ├── .gitignore ├── setup.py └── README.rst /oiffile/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test.oib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgohlke/oiffile/HEAD/test.oib -------------------------------------------------------------------------------- /oiffile/__main__.py: -------------------------------------------------------------------------------- 1 | # oiffile/__main__.py 2 | 3 | """Oiffile package command line script.""" 4 | 5 | import sys 6 | 7 | from .oiffile import main 8 | 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | *.ppm binary 4 | *.pbm binary 5 | *.pgm binary 6 | *.pnm binary 7 | *.pam binary 8 | *.container binary 9 | -------------------------------------------------------------------------------- /oiffile/__init__.py: -------------------------------------------------------------------------------- 1 | # oiffile/__init__.py 2 | 3 | from .oiffile import * 4 | from .oiffile import __all__, __doc__, __version__ 5 | 6 | # constants are repeated for documentation 7 | 8 | __version__ = __version__ 9 | """Oiffile version string.""" 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 79 7 | target-version = ["py311", "py312", "py313", "py314"] 8 | skip-string-normalization = true 9 | 10 | [tool.isort] 11 | known_first_party = ["oiffile"] 12 | profile = "black" 13 | line_length = 79 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include pyproject.toml 4 | include test.oib 5 | 6 | include oiffile/py.typed 7 | 8 | exclude .env 9 | exclude *.cmd 10 | exclude *.yaml 11 | exclude mypy.ini 12 | exclude ruff.toml 13 | recursive-exclude doc * 14 | recursive-exclude docs * 15 | recursive-exclude test * 16 | recursive-exclude tests * 17 | 18 | recursive-exclude * __pycache__ 19 | recursive-exclude * *.py[co] 20 | recursive-exclude * *Copy* 21 | 22 | # include docs/conf.py 23 | # include docs/make.py 24 | # include docs/_static/custom.css 25 | 26 | # include tests/conftest.py 27 | # include tests/test_oiffile.py 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD-3-Clause license 2 | 3 | Copyright (c) 2012-2025, Christoph Gohlke 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, 10 | this list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _*.c 2 | *.wpr 3 | *.wpu 4 | .idea 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | setup.cfg 34 | PKG-INFO 35 | mypy.ini 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # celery beat schedule file 101 | celerybeat-schedule 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # oiffile/setup.py 2 | 3 | """Oiffile package setuptools script.""" 4 | 5 | import re 6 | import sys 7 | 8 | from setuptools import setup 9 | 10 | 11 | def search(pattern: str, string: str, flags: int = 0) -> str: 12 | """Return first match of pattern in string.""" 13 | match = re.search(pattern, string, flags) 14 | if match is None: 15 | raise ValueError(f'{pattern!r} not found') 16 | return match.groups()[0] 17 | 18 | 19 | def fix_docstring_examples(docstring: str) -> str: 20 | """Return docstring with examples fixed for GitHub.""" 21 | start = True 22 | indent = False 23 | lines = ['..', ' This file is generated by setup.py', ''] 24 | for line in docstring.splitlines(): 25 | if not line.strip(): 26 | start = True 27 | indent = False 28 | if line.startswith('>>> '): 29 | indent = True 30 | if start: 31 | lines.extend(['.. code-block:: python', '']) 32 | start = False 33 | lines.append((' ' if indent else '') + line) 34 | return '\n'.join(lines) 35 | 36 | 37 | with open('oiffile/oiffile.py', encoding='utf-8') as fh: 38 | code = fh.read() 39 | 40 | version = search(r"__version__ = '(.*?)'", code).replace('.x.x', '.dev0') 41 | 42 | description = search(r'"""(.*)\.(?:\r\n|\r|\n)', code) 43 | 44 | readme = search( 45 | r'(?:\r\n|\r|\n){2}"""(.*)"""(?:\r\n|\r|\n){2}from __future__', 46 | code, 47 | re.MULTILINE | re.DOTALL, 48 | ) 49 | readme = '\n'.join( 50 | [description, '=' * len(description), *readme.splitlines()[1:]] 51 | ) 52 | 53 | if 'sdist' in sys.argv: 54 | # update README, LICENSE, and CHANGES files 55 | 56 | with open('README.rst', 'w', encoding='utf-8') as fh: 57 | fh.write(fix_docstring_examples(readme)) 58 | 59 | license = search( 60 | r'(# Copyright.*?(?:\r\n|\r|\n))(?:\r\n|\r|\n)+""', 61 | code, 62 | re.MULTILINE | re.DOTALL, 63 | ) 64 | license = license.replace('# ', '').replace('#', '') 65 | 66 | with open('LICENSE', 'w', encoding='utf-8') as fh: 67 | fh.write('BSD-3-Clause license\n\n') 68 | fh.write(license) 69 | 70 | setup( 71 | name='oiffile', 72 | version=version, 73 | license='BSD-3-Clause', 74 | description=description, 75 | long_description=readme, 76 | long_description_content_type='text/x-rst', 77 | author='Christoph Gohlke', 78 | author_email='cgohlke@cgohlke.com', 79 | url='https://www.cgohlke.com', 80 | project_urls={ 81 | 'Bug Tracker': 'https://github.com/cgohlke/oiffile/issues', 82 | 'Source Code': 'https://github.com/cgohlke/oiffile', 83 | # 'Documentation': 'https://', 84 | }, 85 | packages=['oiffile'], 86 | package_data={'oiffile': ['py.typed']}, 87 | python_requires='>=3.11', 88 | install_requires=['numpy', 'tifffile'], 89 | extras_require={'all': ['matplotlib']}, 90 | platforms=['any'], 91 | classifiers=[ 92 | 'Development Status :: 4 - Beta', 93 | 'Intended Audience :: Science/Research', 94 | 'Intended Audience :: Developers', 95 | 'Operating System :: OS Independent', 96 | 'Programming Language :: Python :: 3 :: Only', 97 | 'Programming Language :: Python :: 3.11', 98 | 'Programming Language :: Python :: 3.12', 99 | 'Programming Language :: Python :: 3.13', 100 | 'Programming Language :: Python :: 3.14', 101 | ], 102 | ) 103 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. 2 | This file is generated by setup.py 3 | 4 | Read Olympus image files (OIF and OIB) 5 | ====================================== 6 | 7 | Oiffile is a Python library to read image and metadata from Olympus Image 8 | Format files. OIF is the native file format of the Olympus FluoView(tm) 9 | software for confocal microscopy. 10 | 11 | There are two variants of the format: 12 | 13 | - OIF (Olympus Image File) is a multi-file format that includes a main setting 14 | file (.oif) and an associated directory with data and setting files (.tif, 15 | .bmp, .txt, .pty, .roi, and .lut). 16 | 17 | - OIB (Olympus Image Binary) is a compound document file, storing OIF and 18 | associated files within a single file. 19 | 20 | :Author: `Christoph Gohlke `_ 21 | :License: BSD-3-Clause 22 | :Version: 2025.12.12 23 | :DOI: `10.5281/zenodo.17905223 `_ 24 | 25 | Quickstart 26 | ---------- 27 | 28 | Install the oiffile package and all dependencies from the 29 | `Python Package Index `_:: 30 | 31 | python -m pip install -U "oiffile[all]" 32 | 33 | View image and metadata stored in an OIF or OIB file:: 34 | 35 | python -m oiffile file.oif 36 | 37 | See `Examples`_ for using the programming interface. 38 | 39 | Source code and support are available on 40 | `GitHub `_. 41 | 42 | Requirements 43 | ------------ 44 | 45 | This revision was tested with the following requirements and dependencies 46 | (other versions may work): 47 | 48 | - `CPython `_ 3.11.9, 3.12.10, 3.13.11 3.14.2 64-bit 49 | - `NumPy `_ 2.3.5 50 | - `Tifffile `_ 2025.10.16 51 | 52 | Revisions 53 | --------- 54 | 55 | 2025.12.12 56 | 57 | - Derive OifFileError from ValueError. 58 | - Drop support for Python 3.10. 59 | 60 | 2025.5.10 61 | 62 | - Remove doctest command line option. 63 | - Support Python 3.14. 64 | 65 | 2025.1.1 66 | 67 | - Improve type hints. 68 | - Drop support for Python 3.9, support Python 3.13. 69 | 70 | 2024.5.24 71 | 72 | - Support NumPy 2. 73 | - Fix docstring examples not correctly rendered on GitHub. 74 | 75 | 2023.8.30 76 | 77 | - Fix linting issues. 78 | - Add py.typed marker. 79 | - Drop support for Python 3.8 and numpy < 1.22 (NEP29). 80 | 81 | 2022.9.29 82 | 83 | - Switch to Google style docstrings. 84 | 85 | 2022.2.2 86 | 87 | - Add type hints. 88 | - Add main function. 89 | - Add FileSystemAbc abstract base class. 90 | - Remove OifFile.tiffs (breaking). 91 | - Drop support for Python 3.7 and numpy < 1.19 (NEP29). 92 | 93 | 2021.6.6 94 | 95 | - Fix unclosed file warnings. 96 | 97 | 2020.9.18 98 | 99 | - Remove support for Python 3.6 (NEP 29). 100 | - Support os.PathLike file names. 101 | - Fix unclosed files. 102 | 103 | 2020.1.18 104 | 105 | - Fix indentation error. 106 | 107 | 2020.1.1 108 | 109 | - Support multiple image series. 110 | - Parse shape and dtype from settings file. 111 | - Remove support for Python 2.7 and 3.5. 112 | - Update copyright. 113 | 114 | Notes 115 | ----- 116 | 117 | No specification document is available. 118 | 119 | Tested only with files produced on Olympus FV1000 hardware. 120 | 121 | Examples 122 | -------- 123 | 124 | Read the image from an OIB file as numpy array: 125 | 126 | .. code-block:: python 127 | 128 | >>> image = imread('test.oib') 129 | >>> image.shape 130 | (3, 256, 256) 131 | >>> image[:, 95, 216] 132 | array([820, 50, 436], dtype=uint16) 133 | 134 | Read the image from a single TIFF file in an OIB file: 135 | 136 | .. code-block:: python 137 | 138 | >>> from tifffile import natural_sorted 139 | >>> with OifFile('test.oib') as oib: 140 | ... filename = natural_sorted(oib.glob('*.tif'))[0] 141 | ... image = oib.asarray(filename) 142 | ... 143 | >>> filename 144 | 'Storage00001/s_C001.tif' 145 | >>> print(image[95, 216]) 146 | 820 147 | 148 | Access metadata and the OIB main file: 149 | 150 | .. code-block:: python 151 | 152 | >>> with OifFile('test.oib') as oib: 153 | ... oib.axes 154 | ... oib.shape 155 | ... oib.dtype 156 | ... dataname = oib.mainfile['File Info']['DataName'] 157 | ... 158 | 'CYX' 159 | (3, 256, 256) 160 | dtype('uint16') 161 | >>> dataname 162 | 'Cell 1 mitoEGFP.oib' 163 | 164 | Extract the OIB file content to an OIF file and associated data directory: 165 | 166 | .. code-block:: python 167 | 168 | >>> import tempfile 169 | >>> tempdir = tempfile.mkdtemp() 170 | >>> oib2oif('test.oib', location=tempdir) 171 | Saving ... done. 172 | 173 | Read the image from the extracted OIF file: 174 | 175 | .. code-block:: python 176 | 177 | >>> image = imread(f'{tempdir}/{dataname[:-4]}.oif') 178 | >>> image[:, 95, 216] 179 | array([820, 50, 436], dtype=uint16) 180 | 181 | Read OLE compound file and access the 'OibInfo.txt' settings file: 182 | 183 | .. code-block:: python 184 | 185 | >>> with CompoundFile('test.oib') as com: 186 | ... info = com.open_file('OibInfo.txt') 187 | ... len(com.files()) 188 | ... 189 | 14 190 | >>> info = SettingsFile(info, 'OibInfo.txt') 191 | >>> info['OibSaveInfo']['Version'] 192 | '2.0.0.0' -------------------------------------------------------------------------------- /oiffile/oiffile.py: -------------------------------------------------------------------------------- 1 | # oiffile.py 2 | 3 | # Copyright (c) 2012-2025, Christoph Gohlke 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 12 | # 2. Redistributions in binary form must reproduce the above copyright notice, 13 | # this list of conditions and the following disclaimer in the documentation 14 | # and/or other materials provided with the distribution. 15 | # 16 | # 3. Neither the name of the copyright holder nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | # POSSIBILITY OF SUCH DAMAGE. 31 | 32 | """Read Olympus image files (OIF and OIB). 33 | 34 | Oiffile is a Python library to read image and metadata from Olympus Image 35 | Format files. OIF is the native file format of the Olympus FluoView(tm) 36 | software for confocal microscopy. 37 | 38 | There are two variants of the format: 39 | 40 | - OIF (Olympus Image File) is a multi-file format that includes a main setting 41 | file (.oif) and an associated directory with data and setting files (.tif, 42 | .bmp, .txt, .pty, .roi, and .lut). 43 | 44 | - OIB (Olympus Image Binary) is a compound document file, storing OIF and 45 | associated files within a single file. 46 | 47 | :Author: `Christoph Gohlke `_ 48 | :License: BSD-3-Clause 49 | :Version: 2025.12.12 50 | :DOI: `10.5281/zenodo.17905223 `_ 51 | 52 | Quickstart 53 | ---------- 54 | 55 | Install the oiffile package and all dependencies from the 56 | `Python Package Index `_:: 57 | 58 | python -m pip install -U "oiffile[all]" 59 | 60 | View image and metadata stored in an OIF or OIB file:: 61 | 62 | python -m oiffile file.oif 63 | 64 | See `Examples`_ for using the programming interface. 65 | 66 | Source code and support are available on 67 | `GitHub `_. 68 | 69 | Requirements 70 | ------------ 71 | 72 | This revision was tested with the following requirements and dependencies 73 | (other versions may work): 74 | 75 | - `CPython `_ 3.11.9, 3.12.10, 3.13.11 3.14.2 64-bit 76 | - `NumPy `_ 2.3.5 77 | - `Tifffile `_ 2025.10.16 78 | 79 | Revisions 80 | --------- 81 | 82 | 2025.12.12 83 | 84 | - Derive OifFileError from ValueError. 85 | - Drop support for Python 3.10. 86 | 87 | 2025.5.10 88 | 89 | - Remove doctest command line option. 90 | - Support Python 3.14. 91 | 92 | 2025.1.1 93 | 94 | - Improve type hints. 95 | - Drop support for Python 3.9, support Python 3.13. 96 | 97 | 2024.5.24 98 | 99 | - Support NumPy 2. 100 | - Fix docstring examples not correctly rendered on GitHub. 101 | 102 | 2023.8.30 103 | 104 | - Fix linting issues. 105 | - Add py.typed marker. 106 | - Drop support for Python 3.8 and numpy < 1.22 (NEP29). 107 | 108 | 2022.9.29 109 | 110 | - Switch to Google style docstrings. 111 | 112 | 2022.2.2 113 | 114 | - Add type hints. 115 | - Add main function. 116 | - Add FileSystemAbc abstract base class. 117 | - Remove OifFile.tiffs (breaking). 118 | - Drop support for Python 3.7 and numpy < 1.19 (NEP29). 119 | 120 | 2021.6.6 121 | 122 | - Fix unclosed file warnings. 123 | 124 | 2020.9.18 125 | 126 | - Remove support for Python 3.6 (NEP 29). 127 | - Support os.PathLike file names. 128 | - Fix unclosed files. 129 | 130 | 2020.1.18 131 | 132 | - Fix indentation error. 133 | 134 | 2020.1.1 135 | 136 | - Support multiple image series. 137 | - Parse shape and dtype from settings file. 138 | - Remove support for Python 2.7 and 3.5. 139 | - Update copyright. 140 | 141 | Notes 142 | ----- 143 | 144 | No specification document is available. 145 | 146 | Tested only with files produced on Olympus FV1000 hardware. 147 | 148 | Examples 149 | -------- 150 | 151 | Read the image from an OIB file as numpy array: 152 | 153 | >>> image = imread('test.oib') 154 | >>> image.shape 155 | (3, 256, 256) 156 | >>> image[:, 95, 216] 157 | array([820, 50, 436], dtype=uint16) 158 | 159 | Read the image from a single TIFF file in an OIB file: 160 | 161 | >>> from tifffile import natural_sorted 162 | >>> with OifFile('test.oib') as oib: 163 | ... filename = natural_sorted(oib.glob('*.tif'))[0] 164 | ... image = oib.asarray(filename) 165 | ... 166 | >>> filename 167 | 'Storage00001/s_C001.tif' 168 | >>> print(image[95, 216]) 169 | 820 170 | 171 | Access metadata and the OIB main file: 172 | 173 | >>> with OifFile('test.oib') as oib: 174 | ... oib.axes 175 | ... oib.shape 176 | ... oib.dtype 177 | ... dataname = oib.mainfile['File Info']['DataName'] 178 | ... 179 | 'CYX' 180 | (3, 256, 256) 181 | dtype('uint16') 182 | >>> dataname 183 | 'Cell 1 mitoEGFP.oib' 184 | 185 | Extract the OIB file content to an OIF file and associated data directory: 186 | 187 | >>> import tempfile 188 | >>> tempdir = tempfile.mkdtemp() 189 | >>> oib2oif('test.oib', location=tempdir) 190 | Saving ... done. 191 | 192 | Read the image from the extracted OIF file: 193 | 194 | >>> image = imread(f'{tempdir}/{dataname[:-4]}.oif') 195 | >>> image[:, 95, 216] 196 | array([820, 50, 436], dtype=uint16) 197 | 198 | Read OLE compound file and access the 'OibInfo.txt' settings file: 199 | 200 | >>> with CompoundFile('test.oib') as com: 201 | ... info = com.open_file('OibInfo.txt') 202 | ... len(com.files()) 203 | ... 204 | 14 205 | >>> info = SettingsFile(info, 'OibInfo.txt') 206 | >>> info['OibSaveInfo']['Version'] 207 | '2.0.0.0' 208 | 209 | """ 210 | 211 | from __future__ import annotations 212 | 213 | __version__ = '2025.12.12' 214 | 215 | __all__ = [ 216 | 'CompoundFile', 217 | 'FileSystemAbc', 218 | 'OibFileSystem', 219 | 'OifFile', 220 | 'OifFileError', 221 | 'OifFileSystem', 222 | 'SettingsFile', 223 | '__version__', 224 | 'filetime', 225 | 'imread', 226 | 'oib2oif', 227 | ] 228 | 229 | import abc 230 | import os 231 | import re 232 | import struct 233 | import sys 234 | from datetime import datetime, timezone 235 | from glob import glob 236 | from io import BytesIO 237 | from typing import TYPE_CHECKING 238 | 239 | import numpy 240 | from tifffile import TiffFile, TiffSequence, natural_sorted 241 | 242 | if TYPE_CHECKING: 243 | from collections.abc import Generator, Iterable, Iterator 244 | from types import TracebackType 245 | from typing import IO, Any, BinaryIO, Literal, Self 246 | 247 | from numpy.typing import NDArray 248 | 249 | 250 | def imread(filename: str | os.PathLike[Any], /, **kwargs: Any) -> NDArray[Any]: 251 | """Return image data from OIF or OIB file. 252 | 253 | Parameters: 254 | filename: 255 | Path to OIB or OIF file. 256 | **kwargs: 257 | Additional arguments passed to :py:meth:`OifFile.asarray`. 258 | 259 | """ 260 | with OifFile(filename) as oif: 261 | return oif.asarray(**kwargs) 262 | 263 | 264 | def oib2oif( 265 | filename: str | os.PathLike[Any], 266 | /, 267 | location: str = '', 268 | *, 269 | verbose: int = 1, 270 | ) -> None: 271 | """Convert OIB file to OIF. 272 | 273 | Parameters: 274 | filename: 275 | Name of OIB file to convert. 276 | location: 277 | Directory, where files are written. 278 | verbose: 279 | Level of printed status messages. 280 | 281 | """ 282 | with OibFileSystem(filename) as oib: 283 | oib.saveas_oif(location=location, verbose=verbose) 284 | 285 | 286 | class OifFileError(ValueError): 287 | """Exception to raise issues with OIF or OIB structure.""" 288 | 289 | 290 | class OifFile: 291 | """Olympus Image File. 292 | 293 | Parameters: 294 | filename: Path to OIB or OIF file. 295 | 296 | """ 297 | 298 | filename: str 299 | """Name of OIB or OIF file.""" 300 | 301 | filesystem: FileSystemAbc 302 | """Underlying file system instance.""" 303 | 304 | mainfile: SettingsFile 305 | """Main settings.""" 306 | 307 | _files_flat: dict[str, str] 308 | _series: tuple[TiffSequence, ...] | None 309 | 310 | def __init__(self, filename: str | os.PathLike[Any], /) -> None: 311 | self.filename = filename = os.fspath(filename) 312 | if filename.lower().endswith('.oif'): 313 | self.filesystem = OifFileSystem(filename) 314 | else: 315 | self.filesystem = OibFileSystem(filename) 316 | self.mainfile = self.filesystem.settings 317 | # map file names to storage names (flattened name space) 318 | self._files_flat = { 319 | os.path.basename(f): f for f in self.filesystem.files() 320 | } 321 | self._series = None 322 | 323 | def open_file(self, filename: str, /) -> BinaryIO: 324 | """Return open file object from path name. 325 | 326 | Parameters: 327 | filename: Name of file to open. 328 | 329 | """ 330 | try: 331 | return self.filesystem.open_file( 332 | self._files_flat.get(filename, filename) 333 | ) 334 | except (KeyError, OSError) as exc: 335 | raise FileNotFoundError(f'No such file: {filename}') from exc 336 | 337 | def glob(self, pattern: str = '*', /) -> Iterator[str]: 338 | """Return iterator over unsorted file names matching pattern. 339 | 340 | Parameters: 341 | pattern: File glob pattern. 342 | 343 | """ 344 | if pattern == '*': 345 | return self.filesystem.files() 346 | ptrn = re.compile(pattern.replace('.', r'\.').replace('*', '.*')) 347 | return (f for f in self.filesystem.files() if ptrn.match(f)) 348 | 349 | @property 350 | def is_oib(self) -> bool: 351 | """File has OIB format.""" 352 | return isinstance(self.filesystem, OibFileSystem) 353 | 354 | @property 355 | def axes(self) -> str: 356 | """Character codes for dimensions in image array according to mainfile. 357 | 358 | This might differ from the axes order of series. 359 | 360 | """ 361 | return str(self.mainfile['Axis Parameter Common']['AxisOrder'][::-1]) 362 | 363 | @property 364 | def shape(self) -> tuple[int, ...]: 365 | """Shape of image data according to mainfile. 366 | 367 | This might differ from the shape of series. 368 | 369 | """ 370 | size = { 371 | self.mainfile[f'Axis {i} Parameters Common']['AxisCode']: int( 372 | self.mainfile[f'Axis {i} Parameters Common']['MaxSize'] 373 | ) 374 | for i in range(8) 375 | } 376 | return tuple(size[ax] for ax in self.axes) 377 | 378 | @property 379 | def dtype(self) -> numpy.dtype[Any]: 380 | """Type of image data according to mainfile. 381 | 382 | This might differ from the dtype of series. 383 | 384 | """ 385 | bitcount = int( 386 | self.mainfile['Reference Image Parameter']['ValidBitCounts'] 387 | ) 388 | return numpy.dtype(' 8 else 'u1') 389 | 390 | @property 391 | def series(self) -> tuple[TiffSequence, ...]: 392 | """Sequence of series of TIFF files with matching names.""" 393 | if self._series is not None: 394 | return self._series 395 | tiffiles: dict[str, list[str]] = {} 396 | for fname in self.glob('*.tif'): 397 | key = ''.join( 398 | c for c in os.path.split(fname)[-1][:-4] if c.isalpha() 399 | ) 400 | if key in tiffiles: 401 | tiffiles[key].append(fname) 402 | else: 403 | tiffiles[key] = [fname] 404 | series = tuple( 405 | TiffSequence( 406 | natural_sorted(files), imread=self.asarray, pattern='axes' 407 | ) 408 | for files in tiffiles.values() 409 | ) 410 | if len(series) > 1: 411 | series = tuple(sorted(series, key=lambda x: len(x), reverse=True)) 412 | self._series = series 413 | return series 414 | 415 | def asarray(self, series: int | str = 0, **kwargs: Any) -> NDArray[Any]: 416 | """Return image data from TIFF file(s) as numpy array. 417 | 418 | Parameters: 419 | series: 420 | Specifies which series to return as array. 421 | kwargs: 422 | Additional parameters passed to :py:meth:`TiffFile.asarray` 423 | or :py:meth:`TiffSequence.asarray`. 424 | 425 | """ 426 | if isinstance(series, int): 427 | return self.series[series].asarray(**kwargs) 428 | fh = self.open_file(series) 429 | try: 430 | with TiffFile(fh, name=series) as tif: 431 | result = tif.asarray(**kwargs) 432 | finally: 433 | fh.close() 434 | return result 435 | 436 | def close(self) -> None: 437 | """Close file handle.""" 438 | self.filesystem.close() 439 | 440 | def __enter__(self) -> Self: 441 | return self 442 | 443 | def __exit__( 444 | self, 445 | exc_type: type[BaseException] | None, 446 | exc_value: BaseException | None, 447 | traceback: TracebackType | None, 448 | ) -> None: 449 | self.close() 450 | 451 | def __repr__(self) -> str: 452 | filename = os.path.split(self.filename)[-1] 453 | return f'<{self.__class__.__name__} {filename!r}>' 454 | 455 | def __str__(self) -> str: 456 | # info = self.mainfile['Version Info'] 457 | return indent( 458 | repr(self), 459 | f'axes: {self.axes}', 460 | f'shape: {self.shape}', 461 | f'dtype: {self.dtype}', 462 | # f'system name: {info.get("SystemName", "None")}', 463 | # f'system version: {info.get("SystemVersion", "None")}', 464 | # f'file version: {info.get("FileVersion", "None")}', 465 | # indent(f'series: {len(self.series)}', *self.series), 466 | f'series: {len(self.series)}', 467 | ) 468 | 469 | 470 | class FileSystemAbc(metaclass=abc.ABCMeta): 471 | """Abstract base class for structures with key.""" 472 | 473 | filename: str 474 | """Name of OIB or OIF file.""" 475 | 476 | name: str 477 | """Name from settings file.""" 478 | 479 | version: str 480 | """Version from settings file.""" 481 | 482 | mainfile: str 483 | """Name of main settings file.""" 484 | 485 | settings: SettingsFile 486 | """Main settings.""" 487 | 488 | @abc.abstractmethod 489 | def open_file(self, filename: str, /) -> BinaryIO: 490 | """Return file object from path name. 491 | 492 | Parameters: 493 | filename: Name of file to open. 494 | 495 | """ 496 | 497 | @abc.abstractmethod 498 | def files(self) -> Iterator[str]: 499 | """Return iterator over unsorted files in FileSystem.""" 500 | 501 | @abc.abstractmethod 502 | def close(self) -> None: 503 | """Close file handle.""" 504 | 505 | def __repr__(self) -> str: 506 | return ( 507 | f'<{self.__class__.__name__} {os.path.split(self.filename)[-1]!r}>' 508 | ) 509 | 510 | 511 | class OifFileSystem(FileSystemAbc): 512 | """Olympus Image File file system. 513 | 514 | Parameters: 515 | filename: 516 | Name of OIF file. 517 | storage_ext: 518 | Name extension of storage directory. 519 | 520 | """ 521 | 522 | filename: str 523 | name: str 524 | version: str 525 | mainfile: str 526 | settings: SettingsFile 527 | _files: list[str] 528 | _path: str 529 | 530 | def __init__( 531 | self, filename: str | os.PathLike[Any], /, storage_ext: str = '.files' 532 | ) -> None: 533 | self.filename = filename = os.fspath(filename) 534 | self._path, self.mainfile = os.path.split(os.path.abspath(filename)) 535 | self.settings = SettingsFile(filename, name=self.mainfile) 536 | self.name = self.settings['ProfileSaveInfo']['Name'] 537 | self.version = self.settings['ProfileSaveInfo']['Version'] 538 | # check that storage directory exists 539 | storage = os.path.join(self._path, self.mainfile + storage_ext) 540 | if not os.path.exists(storage) or not os.path.isdir(storage): 541 | raise OSError( 542 | f'OIF storage path not found: {self.mainfile}{storage_ext}' 543 | ) 544 | # list all files 545 | pathlen = len(self._path + os.path.sep) 546 | self._files = [self.mainfile] 547 | for f in glob(os.path.join(storage, '*')): 548 | self._files.append(f[pathlen:]) 549 | 550 | def open_file(self, filename: str, /) -> BinaryIO: 551 | """Return file object from path name. 552 | 553 | The returned file object must be closed by the user. 554 | 555 | Parameters: 556 | filename: Name of file to open. 557 | 558 | """ 559 | return open(os.path.join(self._path, filename), 'rb') 560 | 561 | def files(self) -> Iterator[str]: 562 | """Return iterator over unsorted files in OIF.""" 563 | return iter(self._files) 564 | 565 | def close(self) -> None: 566 | """Close file handle.""" 567 | 568 | def __enter__(self) -> Self: 569 | return self 570 | 571 | def __exit__( 572 | self, 573 | exc_type: type[BaseException] | None, 574 | exc_value: BaseException | None, 575 | traceback: TracebackType | None, 576 | ) -> None: 577 | self.close() 578 | 579 | def __str__(self) -> str: 580 | return indent( 581 | repr(self), 582 | f'name: {self.name}', 583 | f'version: {self.version}', 584 | f'mainfile: {self.mainfile}', 585 | ) 586 | 587 | 588 | class OibFileSystem(FileSystemAbc): 589 | """Olympus Image Binary file system. 590 | 591 | Parameters: 592 | filename: Name of OIB file. 593 | 594 | """ 595 | 596 | filename: str 597 | name: str 598 | version: str 599 | mainfile: str 600 | settings: SettingsFile 601 | com: CompoundFile 602 | compression: str 603 | _files: dict[str, str] 604 | _folders: dict[str, str] 605 | 606 | def __init__(self, filename: str | os.PathLike[Any], /) -> None: 607 | # open compound document and read OibInfo.txt settings 608 | self.filename = filename = os.fspath(filename) 609 | self.com = CompoundFile(filename) 610 | info = SettingsFile(self.com.open_file('OibInfo.txt'), 'OibInfo.txt')[ 611 | 'OibSaveInfo' 612 | ] 613 | self.name = info.get('Name', None) 614 | self.version = info.get('Version', None) 615 | self.compression = info.get('Compression', None) 616 | self.mainfile = info[info['MainFileName']] 617 | # map OIB file names to CompoundFile file names 618 | oibfiles = {os.path.split(i)[-1]: i for i in self.com.files()} 619 | self._files = { 620 | v: oibfiles[k] for k, v in info.items() if k.startswith('Stream') 621 | } 622 | # map storage names to directory names 623 | self._folders = { 624 | i[0]: i[1] for i in info.items() if i[0].startswith('Storage') 625 | } 626 | # read main settings file 627 | self.settings = SettingsFile( 628 | self.open_file(self.mainfile), name=self.mainfile 629 | ) 630 | 631 | def open_file(self, filename: str, /) -> BinaryIO: 632 | """Return file object from case sensitive path name. 633 | 634 | Parameters: 635 | filename: Name of file to open. 636 | 637 | """ 638 | try: 639 | return self.com.open_file(self._files[filename]) 640 | except KeyError as exc: 641 | raise FileNotFoundError(f'No such file: {filename}') from exc 642 | 643 | def files(self) -> Iterator[str]: 644 | """Return iterator over unsorted files in OIB.""" 645 | return iter(self._files.keys()) 646 | 647 | def saveas_oif(self, location: str = '', *, verbose: int = 0) -> None: 648 | """Save all streams in OIB file as separate files. 649 | 650 | Raise OSError if target files or directories already exist. 651 | 652 | The main .oif file name and storage names are determined from the 653 | OibInfo.txt settings. 654 | 655 | Parameters: 656 | location: 657 | Directory, where files are written. 658 | verbose: 659 | Level of printed status messages. 660 | 661 | """ 662 | if location and not os.path.exists(location): 663 | os.makedirs(location) 664 | mainfile = os.path.join(location, self.mainfile) 665 | if os.path.exists(mainfile): 666 | raise FileExistsError(mainfile + ' already exists') 667 | for folder in self._folders: 668 | path = os.path.join(location, self._folders.get(folder, '')) 669 | if os.path.exists(path): 670 | raise FileExistsError(path + ' already exists') 671 | os.makedirs(path) 672 | if verbose: 673 | print('Saving', mainfile, end=' ') # noqa: T201 674 | for f in self._files: 675 | folder, name = os.path.split(f) 676 | folder = os.path.join(location, self._folders.get(folder, '')) 677 | path = os.path.join(folder, name) 678 | if verbose == 1: 679 | print(end='.') # noqa: T201 680 | elif verbose > 1: 681 | print(path) # noqa: T201 682 | with open(path, 'w+b') as fh: 683 | fh.write(self.open_file(f).read()) 684 | if verbose == 1: 685 | print(' done.') # noqa: T201 686 | 687 | def close(self) -> None: 688 | """Close file handle.""" 689 | self.com.close() 690 | 691 | def __enter__(self) -> Self: 692 | return self 693 | 694 | def __exit__( 695 | self, 696 | exc_type: type[BaseException] | None, 697 | exc_value: BaseException | None, 698 | traceback: TracebackType | None, 699 | ) -> None: 700 | self.close() 701 | 702 | def __str__(self) -> str: 703 | return indent( 704 | repr(self), 705 | f'name: {self.name}', 706 | f'version: {self.version}', 707 | f'mainfile: {self.mainfile}', 708 | f'compression: {self.compression}', 709 | ) 710 | 711 | 712 | class SettingsFile(dict): # type: ignore[type-arg] 713 | """Olympus settings file (oif, txt, pty, roi, lut). 714 | 715 | Settings files contain little endian utf-16 encoded strings, except for 716 | [ColorLUTData] sections, which contain uint8 binary arrays. 717 | 718 | Settings can be accessed as a nested dictionary {section: {key: value}}, 719 | except for {'ColorLUTData': numpy array}. 720 | 721 | Parameters: 722 | file: 723 | Name of file or open file containing little endian UTF-16 string. 724 | File objects are closed. 725 | name: 726 | Human readable label of stream. 727 | 728 | """ 729 | 730 | name: str 731 | """Name of settings.""" 732 | 733 | def __init__( 734 | self, 735 | file: str | os.PathLike[Any] | IO[bytes], 736 | /, 737 | name: str | None = None, 738 | ) -> None: 739 | # read settings file and parse into nested dictionaries 740 | fh: IO[bytes] 741 | content: bytes 742 | content_list: list[bytes] 743 | 744 | dict.__init__(self) 745 | if isinstance(file, (str, os.PathLike)): 746 | self.name = os.path.split(file)[-1] 747 | fh = open(file, 'rb') # noqa: SIM115 748 | else: 749 | self.name = str(name) 750 | fh = file 751 | 752 | try: 753 | content = fh.read() 754 | finally: 755 | fh.close() 756 | 757 | if content[:4] == b'\xff\xfe\x5b\x00': 758 | # UTF16 BOM 759 | content_list = content.rsplit( 760 | b'[\x00C\x00o\x00l\x00o\x00r\x00L\x00U\x00T\x00' 761 | b'D\x00a\x00t\x00a\x00]\x00\x0d\x00\x0a\x00', 762 | 1, 763 | ) 764 | if len(content_list) > 1: 765 | self['ColorLUTData'] = ( 766 | numpy.frombuffer(content_list[1], dtype=numpy.uint8) 767 | .copy() 768 | .reshape(-1, 4) 769 | ) 770 | contents = content_list[0].decode('utf-16') 771 | elif content[:1] == b'[': 772 | # try UTF-8 773 | content_list = content.rsplit(b'[ColorLUTData]\r\n', 1) 774 | if len(content_list) > 1: 775 | self['ColorLUTData'] = ( 776 | numpy.frombuffer(content_list[1], dtype=numpy.uint8) 777 | .copy() 778 | .reshape(-1, 4) 779 | ) 780 | try: 781 | contents = content_list[0].decode() 782 | except Exception as exc: 783 | raise ValueError('not a valid settings file') from exc 784 | else: 785 | raise ValueError('not a valid settings file') 786 | 787 | for line in contents.splitlines(): 788 | line = line.strip() # noqa: PLW2901 789 | if line.startswith(';'): 790 | continue 791 | if line.startswith('[') and line.endswith(']'): 792 | self[line[1:-1]] = properties = {} 793 | else: 794 | key, value = line.split('=') 795 | properties[key] = astype(value) 796 | 797 | def __repr__(self) -> str: 798 | return f'<{self.__class__.__name__} {self.name!r}>' 799 | 800 | def __str__(self) -> str: 801 | return indent(repr(self), format_dict(self)) 802 | 803 | 804 | class CompoundFile: 805 | """Compound Document File. 806 | 807 | A partial implementation of the "[MS-CFB] - v20120705, Compound File 808 | Binary File Format" specification by Microsoft Corporation. 809 | 810 | This should be able to read Olympus OIB and Zeiss ZVI files. 811 | 812 | Parameters: 813 | filename: Path to compound document file. 814 | 815 | """ 816 | 817 | filename: str 818 | clsid: bytes | None 819 | version_minor: int 820 | version_major: int 821 | byteorder: Literal['<'] 822 | dir_len: int 823 | fat_len: int 824 | dir_start: int 825 | mini_stream_cutof_size: int 826 | minifat_start: int 827 | minifat_len: int 828 | difat_start: int 829 | difat_len: int 830 | sec_size: int 831 | short_sec_size: int 832 | _files: dict[str, DirectoryEntry] 833 | _fat: list[Any] 834 | _minifat: list[Any] 835 | _difat: list[Any] 836 | _dirs: list[DirectoryEntry] 837 | 838 | MAXREGSECT = 0xFFFFFFFA 839 | DIFSECT = 0xFFFFFFFC 840 | FATSECT = 0xFFFFFFFD 841 | ENDOFCHAIN = 0xFFFFFFFE 842 | FREESECT = 0xFFFFFFFF 843 | MAXREGSID = 0xFFFFFFFA 844 | NOSTREAM = 0xFFFFFFFF 845 | 846 | def __init__(self, filename: str | os.PathLike[Any], /) -> None: 847 | self.filename = filename = os.fspath(filename) 848 | self._fh = open(filename, 'rb') # noqa: SIM115 849 | try: 850 | self._fromfile() 851 | except Exception: 852 | self._fh.close() 853 | raise 854 | 855 | def _fromfile(self) -> None: 856 | """Initialize instance from file.""" 857 | if self._fh.read(8) != b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1': 858 | raise ValueError('not a compound document file') 859 | ( 860 | self.clsid, 861 | self.version_minor, 862 | self.version_major, 863 | byteorder, 864 | sector_shift, 865 | mini_sector_shift, 866 | _, 867 | _, 868 | self.dir_len, 869 | self.fat_len, 870 | self.dir_start, 871 | _, 872 | self.mini_stream_cutof_size, 873 | self.minifat_start, 874 | self.minifat_len, 875 | self.difat_start, 876 | self.difat_len, 877 | ) = struct.unpack('<16sHHHHHHIIIIIIIIII', self._fh.read(68)) 878 | 879 | if byteorder == 0xFFFE: 880 | self.byteorder = '<' 881 | else: 882 | # 0xFEFF 883 | # self.byteorder = '>' 884 | raise NotImplementedError('big-endian byte order not supported') 885 | 886 | if self.clsid == b'\x00' * 16: 887 | self.clsid = None 888 | if self.clsid is not None: 889 | raise OifFileError(f'cannot handle {self.clsid=!r}') 890 | 891 | if self.version_minor != 0x3E: 892 | raise OifFileError(f'cannot handle {self.version_minor=}') 893 | if mini_sector_shift != 0x0006: 894 | raise OifFileError(f'cannot handle {mini_sector_shift=}') 895 | if not ( 896 | (self.version_major == 0x4 and sector_shift == 0x000C) 897 | or ( 898 | self.version_major == 0x3 899 | and sector_shift == 0x0009 900 | and self.dir_len == 0 901 | ) 902 | ): 903 | raise OifFileError( 904 | f'cannot handle {self.version_major=} and {sector_shift=}' 905 | ) 906 | 907 | self.sec_size = 2**sector_shift 908 | self.short_sec_size = 2**mini_sector_shift 909 | 910 | secfmt = '<' + ('I' * (self.sec_size // 4)) 911 | # read DIFAT 912 | self._difat = list( 913 | struct.unpack('<' + ('I' * 109), self._fh.read(436)) 914 | ) 915 | nextsec = self.difat_start 916 | for _i in range(self.difat_len): 917 | if nextsec >= CompoundFile.MAXREGSID: 918 | raise OifFileError(f'{nextsec=} >= {CompoundFile.MAXREGSID=}') 919 | sec = struct.unpack(secfmt, self._sec_read(nextsec)) 920 | self._difat.extend(sec[:-1]) 921 | nextsec = sec[-1] 922 | # if nextsec != CompoundFile.ENDOFCHAIN: raise OifFileError() 923 | self._difat = self._difat[: self.fat_len] 924 | # read FAT 925 | self._fat = [] 926 | for secid in self._difat: 927 | self._fat.extend(struct.unpack(secfmt, self._sec_read(secid))) 928 | # read mini FAT 929 | self._minifat = [] 930 | for i, sector in enumerate(self._sec_chain(self.minifat_start)): 931 | if i >= self.minifat_len: 932 | break 933 | self._minifat.extend(struct.unpack(secfmt, sector)) 934 | # read directories 935 | self._dirs = [] 936 | for sector in self._sec_chain(self.dir_start): 937 | for i in range(0, self.sec_size, 128): 938 | self._dirs.append( 939 | DirectoryEntry(sector[i : i + 128], self.version_major) 940 | ) 941 | # read root storage 942 | if len(self._dirs) <= 0: 943 | raise OifFileError('no directories found') 944 | root = self._dirs[0] 945 | if root.name != 'Root Entry': 946 | raise OifFileError(f'no root directory found, got {root.name!r}') 947 | if root.create_time is not None: # and root.modify_time is None 948 | raise OifFileError(f'invalid {root.create_time=}') 949 | if root.stream_size % self.short_sec_size != 0: 950 | raise OifFileError( 951 | f'{root.stream_size=} does not match {self.short_sec_size=}' 952 | ) 953 | # read mini stream 954 | self._ministream = b''.join(self._sec_chain(root.sector_start)) 955 | self._ministream = self._ministream[: root.stream_size] 956 | # map stream/file names to directory entries 957 | nostream = CompoundFile.NOSTREAM 958 | join = '/'.join # os.path.sep.join 959 | dirs = self._dirs 960 | visited = [False] * len(self._dirs) 961 | 962 | def parse( 963 | dirid: int, path: list[str] 964 | ) -> Generator[tuple[str, DirectoryEntry]]: 965 | # return iterator over all file names and their directory entries 966 | # TODO: replace with red-black tree parser 967 | if dirid == nostream or visited[dirid]: 968 | return 969 | visited[dirid] = True 970 | de = dirs[dirid] 971 | if de.is_stream: 972 | yield join([*path, de.name]), de 973 | yield from parse(de.left_sibling_id, path) 974 | yield from parse(de.right_sibling_id, path) 975 | if de.is_storage: 976 | yield from parse(de.child_id, [*path, de.name]) 977 | 978 | self._files = dict(parse(self._dirs[0].child_id, [])) 979 | 980 | def _read_stream(self, direntry: DirectoryEntry, /) -> bytes: 981 | """Return content of stream.""" 982 | if direntry.stream_size < self.mini_stream_cutof_size: 983 | result = b''.join(self._mini_sec_chain(direntry.sector_start)) 984 | else: 985 | result = b''.join(self._sec_chain(direntry.sector_start)) 986 | return result[: direntry.stream_size] 987 | 988 | def _sec_read(self, secid: int, /) -> bytes: 989 | """Return content of sector from file.""" 990 | self._fh.seek(self.sec_size + secid * self.sec_size) 991 | return self._fh.read(self.sec_size) 992 | 993 | def _sec_chain(self, secid: int, /) -> Generator[bytes]: 994 | """Return iterator over FAT sector chain content.""" 995 | while secid != CompoundFile.ENDOFCHAIN: 996 | if secid <= CompoundFile.MAXREGSECT: 997 | yield self._sec_read(secid) 998 | secid = self._fat[secid] 999 | 1000 | def _mini_sec_read(self, secid: int, /) -> bytes: 1001 | """Return content of sector from mini stream.""" 1002 | pos = secid * self.short_sec_size 1003 | return self._ministream[pos : pos + self.short_sec_size] 1004 | 1005 | def _mini_sec_chain(self, secid: int, /) -> Generator[bytes]: 1006 | """Return iterator over mini FAT sector chain content.""" 1007 | while secid != CompoundFile.ENDOFCHAIN: 1008 | if secid <= CompoundFile.MAXREGSECT: 1009 | yield self._mini_sec_read(secid) 1010 | secid = self._minifat[secid] 1011 | 1012 | def files(self) -> Iterable[str]: 1013 | """Return sequence of file names.""" 1014 | return self._files.keys() 1015 | 1016 | def direntry(self, filename: str, /) -> DirectoryEntry: 1017 | """Return DirectoryEntry of filename. 1018 | 1019 | Parameters: 1020 | filename: Name of file. 1021 | 1022 | """ 1023 | return self._files[filename] 1024 | 1025 | def open_file(self, filename: str, /) -> BytesIO: 1026 | """Return stream as file like object. 1027 | 1028 | Parameters: 1029 | filename: Name of file to open. 1030 | 1031 | """ 1032 | return BytesIO(self._read_stream(self._files[filename])) 1033 | 1034 | def format_tree(self) -> str: 1035 | """Return formatted string with list of all files.""" 1036 | return '\n'.join(natural_sorted(self.files())) 1037 | 1038 | def close(self) -> None: 1039 | """Close file handle.""" 1040 | self._fh.close() 1041 | 1042 | def __enter__(self) -> Self: 1043 | return self 1044 | 1045 | def __exit__( 1046 | self, 1047 | exc_type: type[BaseException] | None, 1048 | exc_value: BaseException | None, 1049 | traceback: TracebackType | None, 1050 | ) -> None: 1051 | self.close() 1052 | 1053 | def __repr__(self) -> str: 1054 | filename = os.path.split(self.filename)[-1] 1055 | return f'<{self.__class__.__name__} {filename!r}>' 1056 | 1057 | def __str__(self) -> str: 1058 | return indent( 1059 | repr(self), 1060 | *( 1061 | f'{attr}: {getattr(self, attr)}' 1062 | for attr in ( 1063 | 'clsid', 1064 | 'version_minor', 1065 | 'version_major', 1066 | 'byteorder', 1067 | 'dir_len', 1068 | 'fat_len', 1069 | 'dir_start', 1070 | 'mini_stream_cutof_size', 1071 | 'minifat_start', 1072 | 'minifat_len', 1073 | 'difat_start', 1074 | 'difat_len', 1075 | ) 1076 | ), 1077 | ) 1078 | 1079 | 1080 | class DirectoryEntry: 1081 | """Compound Document Directory Entry. 1082 | 1083 | Parameters: 1084 | header: 1085 | 128 bytes compound document directory entry header. 1086 | version_major: 1087 | Major version of compound document. 1088 | 1089 | """ 1090 | 1091 | __slots__ = ( 1092 | 'child_id', 1093 | 'clsid', 1094 | 'color', 1095 | 'create_time', 1096 | 'entry_type', 1097 | 'is_storage', 1098 | 'is_stream', 1099 | 'left_sibling_id', 1100 | 'modify_time', 1101 | 'name', 1102 | 'right_sibling_id', 1103 | 'sector_start', 1104 | 'stream_size', 1105 | 'user_flags', 1106 | ) 1107 | 1108 | name: str 1109 | entry_type: int 1110 | color: int 1111 | left_sibling_id: int 1112 | right_sibling_id: int 1113 | child_id: int 1114 | clsid: bytes | None 1115 | user_flags: int 1116 | create_time: datetime | None 1117 | modify_time: datetime | None 1118 | sector_start: int 1119 | stream_size: int 1120 | is_stream: bool 1121 | is_storage: bool 1122 | 1123 | def __init__(self, header: bytes, version_major: int, /) -> None: 1124 | ( 1125 | name, 1126 | name_len, 1127 | self.entry_type, 1128 | self.color, 1129 | self.left_sibling_id, 1130 | self.right_sibling_id, 1131 | self.child_id, 1132 | self.clsid, 1133 | self.user_flags, 1134 | create_time, 1135 | modify_time, 1136 | self.sector_start, 1137 | self.stream_size, 1138 | ) = struct.unpack('<64sHBBIII16sIQQIQ', header) 1139 | 1140 | if version_major == 3: 1141 | self.stream_size = struct.unpack(' 64: 1146 | raise OifFileError(f'invalid {name_len=}') 1147 | if self.color not in (0, 1): 1148 | raise OifFileError(f'invalid {self.color=}') 1149 | 1150 | self.name = name[: name_len - 2].decode('utf-16') 1151 | self.create_time = filetime(create_time) 1152 | self.modify_time = filetime(modify_time) 1153 | self.is_stream = self.entry_type == 2 1154 | self.is_storage = self.entry_type == 1 1155 | 1156 | def __repr__(self) -> str: 1157 | return f'<{self.__class__.__name__} {self.name!r}>' 1158 | 1159 | def __str__(self) -> str: 1160 | return indent( 1161 | repr(self), 1162 | *(f'{attr}: {getattr(self, attr)}' for attr in self.__slots__[1:]), 1163 | ) 1164 | 1165 | 1166 | def indent(*args: Any) -> str: 1167 | """Return joined string representations of objects with indented lines.""" 1168 | text = '\n'.join(str(arg) for arg in args) 1169 | return '\n'.join( 1170 | (' ' + line if line else line) for line in text.splitlines() if line 1171 | )[2:] 1172 | 1173 | 1174 | def format_dict( 1175 | adict: dict[str, Any], 1176 | prefix: str = ' ', 1177 | indent: str = ' ', 1178 | bullets: tuple[str, str] = ('', ''), 1179 | excludes: tuple[str] = ('_',), 1180 | linelen: int = 79, 1181 | trim: int = 1, 1182 | ) -> str: 1183 | """Return pretty-print of nested dictionary.""" 1184 | result = [] 1185 | for item in sorted(adict.items(), key=lambda x: str(x[0]).lower()): 1186 | k, v = item 1187 | if any(k.startswith(e) for e in excludes): 1188 | continue 1189 | if isinstance(v, dict): 1190 | v = '\n' + format_dict( 1191 | v, prefix=prefix + indent, excludes=excludes, trim=0 1192 | ) 1193 | result.append(f'{prefix}{bullets[1]}{k}: {v}') 1194 | else: 1195 | result.append((f'{prefix}{bullets[0]}{k}: {v}')[:linelen].rstrip()) 1196 | if trim > 0: 1197 | result[0] = result[0][trim:] 1198 | return '\n'.join(result) 1199 | 1200 | 1201 | def astype(arg: str, types: Iterable[type] | None = None) -> Any: 1202 | """Return argument as one of types if possible. 1203 | 1204 | Parameters: 1205 | arg: 1206 | String representation of value. 1207 | types: 1208 | Possible types of value. By default, int, float, and str. 1209 | 1210 | """ 1211 | if arg[0] in '\'"': 1212 | return arg[1:-1] 1213 | if types is None: 1214 | types = int, float, str 1215 | for typ in types: 1216 | try: 1217 | return typ(arg) 1218 | except (ValueError, TypeError, UnicodeEncodeError): 1219 | pass 1220 | return arg 1221 | 1222 | 1223 | def filetime(ft: int, /) -> datetime | None: 1224 | """Return Python datetime from Microsoft FILETIME number. 1225 | 1226 | Parameters: 1227 | ft: Microsoft FILETIME number. 1228 | 1229 | """ 1230 | if not ft: 1231 | return None 1232 | sec, nsec = divmod(ft - 116444736000000000, 10000000) 1233 | return datetime.fromtimestamp(sec, timezone.utc).replace( 1234 | microsecond=nsec // 10 1235 | ) 1236 | 1237 | 1238 | def main(argv: list[str] | None = None) -> int: 1239 | """Oiffile command line usage main function. 1240 | 1241 | ``python -m oiffile file_or_directory`` 1242 | 1243 | """ 1244 | if argv is None: 1245 | argv = sys.argv 1246 | 1247 | if len(argv) != 2: 1248 | print('Usage: python -m oiffile file_or_directory') # noqa: T201 1249 | return 0 1250 | 1251 | from matplotlib import pyplot 1252 | from tifffile import imshow 1253 | 1254 | with OifFile(sys.argv[1]) as oif: 1255 | print(oif) # noqa: T201 1256 | print(oif.mainfile) # noqa: T201 1257 | for series in oif.series: 1258 | # print(series) 1259 | image = series.asarray() 1260 | figure = pyplot.figure() 1261 | imshow(image, figure=figure) 1262 | pyplot.show() 1263 | 1264 | return 0 1265 | 1266 | 1267 | if __name__ == '__main__': 1268 | sys.exit(main()) 1269 | --------------------------------------------------------------------------------