Skip to content

Commit 7bef02b

Browse files
committed
[feat] Ensure pyramidal tiff on import
1 parent c1b1ff1 commit 7bef02b

6 files changed

Lines changed: 555 additions & 0 deletions

File tree

src/odemis/dataio/test/tiff_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import logging
2626
import os
2727
import re
28+
import tempfile
2829
import time
2930
import unittest
3031
import xml.etree.ElementTree as ET
@@ -47,6 +48,52 @@
4748
logging.getLogger().setLevel(logging.DEBUG)
4849

4950
FILENAME = "test" + tiff.EXTENSIONS[0]
51+
52+
53+
class TestTiffPyramidalIO(unittest.TestCase):
54+
55+
def setUp(self) -> None:
56+
self.temp_dir = tempfile.mkdtemp()
57+
58+
def tearDown(self) -> None:
59+
try:
60+
for filename in os.listdir(self.temp_dir):
61+
file_path = os.path.join(self.temp_dir, filename)
62+
if os.path.isfile(file_path):
63+
os.remove(file_path)
64+
os.rmdir(self.temp_dir)
65+
except Exception:
66+
pass
67+
68+
def _create_test_tiff(self, filename: str, pyramid: bool = False) -> str:
69+
array = numpy.random.randint(0, 255, (256, 256), dtype=numpy.uint8)
70+
data = model.DataArray(array)
71+
full_path = os.path.join(self.temp_dir, filename)
72+
tiff.export(full_path, data, pyramid=pyramid, imagej=True)
73+
return full_path
74+
75+
def test_is_pyramidal_tiff(self) -> None:
76+
pyramidal_file = self._create_test_tiff("pyramidal.tif", pyramid=True)
77+
self.assertTrue(tiff.is_pyramidal(pyramidal_file))
78+
79+
def test_is_pyramidal_non_pyramidal_tiff(self) -> None:
80+
non_pyramidal_file = self._create_test_tiff("non_pyramidal.tif", pyramid=False)
81+
self.assertFalse(tiff.is_pyramidal(non_pyramidal_file))
82+
83+
def test_is_pyramidal_invalid_file(self) -> None:
84+
with self.assertRaises(IOError):
85+
tiff.is_pyramidal("/nonexistent/file.tif")
86+
87+
def test_convert_to_pyramidal_tiff(self) -> None:
88+
source_file = self._create_test_tiff("source.tif", pyramid=False)
89+
dest_file = os.path.join(self.temp_dir, "converted.tif")
90+
91+
tiff.convert_to_pyramidal(source_file, dest_file, compressed=True)
92+
93+
self.assertTrue(os.path.exists(dest_file))
94+
self.assertTrue(tiff.is_pyramidal(dest_file))
95+
96+
5097
class TestTiffIO(unittest.TestCase):
5198

5299
def tearDown(self):

src/odemis/dataio/tiff.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2401,6 +2401,42 @@ def read_data(filename):
24012401
return [acd.content[n].getData() for n in range(len(acd.content))]
24022402

24032403

2404+
def is_pyramidal(filename: str) -> bool:
2405+
"""
2406+
Check whether a TIFF file has SubIFD pyramid levels.
2407+
2408+
:param filename: Path to TIFF file
2409+
:return: True when TIFFTAG_SUBIFD contains one or more entries
2410+
:raises IOError: If the file cannot be opened as TIFF
2411+
"""
2412+
try:
2413+
tiff_file = TIFF.open(filename, mode="r")
2414+
try:
2415+
sub_ifds = tiff_file.GetField(T.TIFFTAG_SUBIFD)
2416+
return sub_ifds is not None and len(sub_ifds) > 0
2417+
finally:
2418+
tiff_file.close()
2419+
except Exception as exc:
2420+
raise IOError("Failed to inspect TIFF pyramid status for %s: %s" % (filename, exc))
2421+
2422+
2423+
def convert_to_pyramidal(src_filename: str, dst_filename: str, compressed: bool = True) -> None:
2424+
"""
2425+
Convert TIFF content into a pyramidal TIFF.
2426+
2427+
:param src_filename: Source TIFF file path
2428+
:param dst_filename: Destination TIFF file path
2429+
:param compressed: Whether to write with TIFF compression
2430+
:raises IOError: If conversion fails
2431+
"""
2432+
try:
2433+
data = read_data(src_filename)
2434+
export(dst_filename, data, compressed=compressed, pyramid=True)
2435+
except Exception as exc:
2436+
raise IOError("Failed to convert TIFF %s to pyramidal %s: %s" %
2437+
(src_filename, dst_filename, exc))
2438+
2439+
24042440
def read_thumbnail(filename):
24052441
"""
24062442
Read the thumbnail data of a given TIFF file.

src/odemis/gui/cont/tabs/analysis_tab.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
TOOL_NONE
6161
from odemis.gui.util import call_in_wx_main
6262
from odemis.util.dataio import data_to_static_streams, open_acquisition, open_files_and_stitch
63+
from odemis.util.tiff_convert import (
64+
ensure_pyramidal_tiff_for_file_gui,
65+
ensure_pyramidal_tiff_for_tileset_gui,
66+
)
6367

6468

6569
class AnalysisTab(Tab):
@@ -319,10 +323,14 @@ def _on_add_tileset(self):
319323
self.select_acq_file(extend=True, tileset=True)
320324

321325
def load_tileset(self, filenames, extend=False):
326+
# Convert all files to pyramidal if they're TIFFs
327+
filenames = ensure_pyramidal_tiff_for_tileset_gui(filenames, self.panel, self.tab_data_model.main)
322328
data = open_files_and_stitch(filenames) # TODO: allow user defined registration / weave methods
323329
self.display_new_data(filenames[0], data, extend=extend)
324330

325331
def load_data(self, filename, fmt=None, extend=False):
332+
# Convert to pyramidal if it's a TIFF file
333+
filename = ensure_pyramidal_tiff_for_file_gui(filename, self.panel, self.tab_data_model.main)
326334
data = open_acquisition(filename, fmt)
327335
self.display_new_data(filename, data, extend=extend)
328336

src/odemis/gui/cont/tabs/correlation_tab.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
from odemis.gui.util import call_in_wx_main
4646
from odemis.gui.cont.tabs.tab import Tab
4747
from odemis.util.dataio import data_to_static_streams, open_acquisition, open_files_and_stitch
48+
from odemis.util.tiff_convert import (
49+
ensure_pyramidal_tiff_for_file_gui,
50+
ensure_pyramidal_tiff_for_tileset_gui,
51+
)
4852

4953

5054
class CorrelationTab(Tab):
@@ -167,10 +171,14 @@ def _on_add_tileset(self) -> None:
167171
self.select_acq_file(extend=True, tileset=True)
168172

169173
def load_tileset(self, filenames: List[str], extend: bool = False) -> None:
174+
# Convert all files to pyramidal if they're TIFFs
175+
filenames = ensure_pyramidal_tiff_for_tileset_gui(filenames, self.panel, self.main_data)
170176
data = open_files_and_stitch(filenames) # TODO: allow user defined registration / weave methods
171177
self.load_streams(data)
172178

173179
def load_data(self, filename: str, fmt: str = None, extend: bool = False) -> None:
180+
# Convert to pyramidal if it's a TIFF file
181+
filename = ensure_pyramidal_tiff_for_file_gui(filename, self.panel, self.main_data)
174182
data = open_acquisition(filename, fmt)
175183
self.load_streams(data)
176184

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Created on 17 Mar 2026
4+
5+
@author: Tim Moerkerken
6+
7+
Copyright © 2014-2026 Tim Moerkerken, Delmic
8+
9+
This file is part of Odemis.
10+
11+
Odemis is free software: you can redistribute it and/or modify it under the terms
12+
of the GNU General Public License version 2 as published by the Free Software
13+
Foundation.
14+
15+
Odemis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
16+
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
17+
PURPOSE. See the GNU General Public License for more details.
18+
19+
You should have received a copy of the GNU General Public License along with
20+
Odemis. If not, see http://www.gnu.org/licenses/.
21+
"""
22+
23+
import os
24+
import tempfile
25+
import unittest
26+
import numpy
27+
28+
from odemis import model
29+
from odemis.dataio import tiff
30+
from odemis.util.tiff_convert import (
31+
get_conversion_output_path,
32+
ensure_pyramidal_tiff,
33+
)
34+
35+
36+
class TestTiffConversion(unittest.TestCase):
37+
38+
def setUp(self) -> None:
39+
"""Set up test fixtures."""
40+
self.temp_dir = tempfile.mkdtemp()
41+
42+
def tearDown(self) -> None:
43+
"""Clean up temporary files."""
44+
# Remove all temporary files
45+
try:
46+
for filename in os.listdir(self.temp_dir):
47+
file_path = os.path.join(self.temp_dir, filename)
48+
if os.path.isfile(file_path):
49+
os.remove(file_path)
50+
os.rmdir(self.temp_dir)
51+
except Exception:
52+
pass
53+
54+
def _create_test_tiff(self, filename: str, pyramid: bool = False) -> str:
55+
"""
56+
Create a test TIFF file.
57+
58+
:param filename: Output filename
59+
:param pyramid: If True, create a pyramidal TIFF
60+
:return: Full path to created file
61+
"""
62+
# Create simple test image
63+
array = numpy.random.randint(0, 255, (256, 256), dtype=numpy.uint8)
64+
data = model.DataArray(array)
65+
66+
full_path = os.path.join(self.temp_dir, filename)
67+
tiff.export(full_path, data, pyramid=pyramid, imagej=True) # Use ImageJ format to avoid OME issues
68+
return full_path
69+
70+
def test_get_conversion_output_path_standalone(self) -> None:
71+
"""Test output path generation in standalone mode."""
72+
source_file = os.path.join(self.temp_dir, "original.tif")
73+
output = get_conversion_output_path(source_file, standalone_mode=True)
74+
75+
# Should be in same directory with visible prefix
76+
self.assertEqual(os.path.dirname(output), self.temp_dir)
77+
self.assertTrue(os.path.basename(output).startswith("converted_"))
78+
self.assertTrue(os.path.basename(output).endswith(".tif"))
79+
80+
def test_get_conversion_output_path_normal_mode(self) -> None:
81+
"""Test output path generation in normal mode."""
82+
source_file = "/some/path/original.tif"
83+
project_folder = "/project/folder"
84+
output = get_conversion_output_path(source_file, standalone_mode=False, project_folder=project_folder)
85+
86+
# Should be in project folder
87+
self.assertEqual(os.path.dirname(output), project_folder)
88+
self.assertTrue(os.path.basename(output).startswith("converted_"))
89+
90+
def test_get_conversion_output_path_normal_mode_no_project(self) -> None:
91+
"""Test that ValueError is raised when project_folder is missing in normal mode."""
92+
source_file = "/some/path/original.tif"
93+
with self.assertRaises(ValueError):
94+
get_conversion_output_path(source_file, standalone_mode=False, project_folder=None)
95+
96+
def test_ensure_pyramidal_tiff_already_pyramidal(self) -> None:
97+
"""Test ensure_pyramidal_tiff with already pyramidal file."""
98+
pyramidal_file = self._create_test_tiff("already_pyramidal.tif", pyramid=True)
99+
100+
# Call ensure_pyramidal_tiff
101+
result = ensure_pyramidal_tiff(pyramidal_file, standalone_mode=True)
102+
103+
# Should return the same file without conversion
104+
self.assertEqual(result, pyramidal_file)
105+
106+
def test_ensure_pyramidal_tiff_non_tiff_file(self) -> None:
107+
"""Test ensure_pyramidal_tiff with non-TIFF file."""
108+
# Create a non-TIFF file
109+
non_tiff_file = os.path.join(self.temp_dir, "test.txt")
110+
with open(non_tiff_file, 'w') as f:
111+
f.write("test content")
112+
113+
# Non-TIFF should return as-is (in ensure_pyramidal_tiff before conversion attempt)
114+
result = ensure_pyramidal_tiff(non_tiff_file, standalone_mode=True)
115+
self.assertEqual(result, non_tiff_file)
116+
117+
def test_ensure_pyramidal_tiff_nonexistent_file(self) -> None:
118+
"""Test ensure_pyramidal_tiff with non-existent file."""
119+
with self.assertRaises(IOError):
120+
ensure_pyramidal_tiff("/nonexistent/file.tif", standalone_mode=True)
121+
122+
if __name__ == '__main__':
123+
unittest.main()

0 commit comments

Comments
 (0)