diff --git a/qim3d/io/__init__.py b/qim3d/io/__init__.py index 7dc89cfca6d71d61fff9207c9c2d28ae7ad406fd..3384ec71aa400a9ff34debdd75d6b0a1c5dc8bb9 100644 --- a/qim3d/io/__init__.py +++ b/qim3d/io/__init__.py @@ -1,5 +1,5 @@ from .downloader import Downloader from .load import DataLoader, load, ImgExamples -from .save import save +from .save import DataSaver, save from .sync import Sync from . import logger \ No newline at end of file diff --git a/qim3d/io/save.py b/qim3d/io/save.py index 5ee21dfe0376ed6b615c164317eaa335f14d7376..ad988b9a73b534f2381bc23f5973312e83e47952 100644 --- a/qim3d/io/save.py +++ b/qim3d/io/save.py @@ -1,10 +1,106 @@ +"""Provides functionality for saving data to various file formats.""" + +import os +import tifffile +import numpy as np +from qim3d.io.logger import log + class DataSaver: - def __init__(self): - self.verbose = False + """Utility class for saving data to different file formats. + + Attributes: + replace (bool): Specifies if an existing file with identical path is replaced. + compression (bool): Specifies if the file is with Deflate compression. + + Methods: + save_tiff(path,data): Save data to a TIFF file to the given path. + load(path,data): Save data to the given path. + + Example: + image = qim3d.examples.blobs_256x256 + saver = qim3d.io.DataSaver(compression=True) + saver.save_tiff("image.tif",image) + """ + + def __init__(self, **kwargs): + """Initializes a new instance of the DataSaver class. + + Args: + replace (bool, optional): Specifies if an existing file with identical path should be replaced. + Default is False. + compression (bool, optional): Specifies if the file should be saved with Deflate compression. + Default is False. + """ + self.replace = kwargs.get("replace",False) + self.compression = kwargs.get("compression",False) + + def save_tiff(self,path,data): + """Save data to a TIFF file to the given path. + + Args: + path (str): The path to save file to + data (numpy.ndarray): The data to be saved + """ + tifffile.imwrite(path,data,compression=self.compression) def save(self, path, data): - raise NotImplementedError("Save is not implemented yet") + """Save data to the given path. + + Args: + path (str): The path to save file to + data (numpy.ndarray): The data to be saved + + Raises: + ValueError: If the file format is not supported. + ValueError: If the specified folder does not exist. + ValueError: If a file extension is not provided. + ValueError: if a file with the specified path already exists and replace=False. + + Example: + image = qim3d.examples.blobs_256x256 + saver = qim3d.io.DataSaver(compression=True) + saver.save("image.tif",image) + """ + folder = os.path.dirname(path) or '.' + # Check if directory exists + if os.path.isdir(folder): + _, ext = os.path.splitext(path) + # Check if provided path contains file extension + if ext: + # Check if a file with the given path already exists + if os.path.isfile(path) and not self.replace: + raise ValueError("A file with the provided path already exists. To replace it set 'replace=True'") + + if path.endswith((".tif",".tiff")): + return self.save_tiff(path,data) + else: + raise ValueError("Unsupported file format") + else: + raise ValueError('Please provide a file extension') + else: + raise ValueError(f'The directory {folder} does not exist. Please provide a valid directory') + +def save(path, + data, + replace=False, + compression=False, + **kwargs + ): + """Save data to a specified file path. -def save(path, data): - return DataSaver().save(path, data) + Args: + path (str): The path to save file to + data (numpy.ndarray): The data to be saved + replace (bool, optional): Specifies if an existing file with identical path should be replaced. + Default is False. + compression (bool, optional): Specifies if the file should be saved with Deflate compression (lossless). + Default is False. + **kwargs: Additional keyword arguments to be passed to the DataSaver constructor + + Example: + image = qim3d.examples.blobs_256x256 + qim3d.io.save("image.tif",image,compression=True) + """ + + DataSaver(replace=replace, compression=compression, **kwargs).save(path, data) \ No newline at end of file diff --git a/qim3d/tests/io/test_save.py b/qim3d/tests/io/test_save.py new file mode 100644 index 0000000000000000000000000000000000000000..43e2597f8fe9f8caa26b0b292e5379007075c7c3 --- /dev/null +++ b/qim3d/tests/io/test_save.py @@ -0,0 +1,166 @@ +import qim3d +import tempfile +import numpy as np +import os +import hashlib +import pytest + +def test_image_exist(): + # Create random test image + test_image = np.random.randint(0,256,(100,100,100),'uint8') + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"test_image.tif") + + # Save to temporary directory + qim3d.io.save(image_path,test_image) + + # Assert that test image has been saved + assert os.path.exists(image_path) + +def test_compression(): + # Get test image (should not be random in order for compression to function) + test_image = qim3d.examples.blobs_256x256 + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"test_image.tif") + compressed_image_path = os.path.join(temp_dir,"compressed_test_image.tif") + + # Save to temporary directory with and without compression + qim3d.io.save(image_path,test_image) + qim3d.io.save(compressed_image_path,test_image,compression=True) + + # Compute file sizes + file_size = os.path.getsize(image_path) + compressed_file_size = os.path.getsize(compressed_image_path) + + # Assert that compressed file size is smaller than non-compressed file size + assert compressed_file_size < file_size + +def test_image_matching(): + # Create random test image + original_image = np.random.randint(0,256,(100,100,100),'uint8') + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"original_image.tif") + + # Save to temporary directory + qim3d.io.save(image_path,original_image) + + # Load from temporary directory + saved_image = qim3d.io.load(image_path) + + # Get hashes + original_hash = calculate_image_hash(original_image) + saved_hash = calculate_image_hash(saved_image) + + # Assert that original image is identical to saved_image + assert original_hash == saved_hash + +def test_compressed_image_matching(): + # Get test image (should not be random in order for compression to function) + original_image = qim3d.examples.blobs_256x256 + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"original_image.tif") + + # Save to temporary directory + qim3d.io.save(image_path,original_image,compression=True) + + # Load from temporary directory + saved_image_compressed = qim3d.io.load(image_path) + + # Get hashes + original_hash = calculate_image_hash(original_image) + compressed_hash = calculate_image_hash(saved_image_compressed) + + # Assert that original image is identical to saved_image + assert original_hash == compressed_hash + +def test_file_replace(): + # Create random test image + test_image1 = np.random.randint(0,256,(100,100,100),'uint8') + test_image2 = np.random.randint(0,256,(100,100,100),'uint8') + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"test_image.tif") + + # Save first test image to temporary directory + qim3d.io.save(image_path,test_image1) + # Get hash + hash1 = calculate_image_hash(qim3d.io.load(image_path)) + + # Replace existing file + qim3d.io.save(image_path,test_image2,replace=True) + # Get hash again + hash2 = calculate_image_hash(qim3d.io.load(image_path)) + + # Assert that the file was modified by checking if the second modification time is newer than the first + assert hash1 != hash2 + +def test_file_already_exists(): + # Create random test image + test_image1 = np.random.randint(0,256,(100,100,100),'uint8') + test_image2 = np.random.randint(0,256,(100,100,100),'uint8') + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,"test_image.tif") + + # Save first test image to temporary directory + qim3d.io.save(image_path,test_image1) + + with pytest.raises(ValueError,match="A file with the provided path already exists. To replace it set 'replace=True'"): + # Try to save another image to the existing path + qim3d.io.save(image_path,test_image2) + +def test_no_file_ext(): + # Create random test image + test_image = np.random.randint(0,256,(100,100,100),'uint8') + + # Create filename without extension + filename = 'test_image' + + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,filename) + + with pytest.raises(ValueError,match='Please provide a file extension'): + # Try to save the test image to a path witout file extension + qim3d.io.save(image_path,test_image) + +def test_folder_doesnt_exist(): + # Create random test image + test_image = np.random.randint(0,256,(100,100,100),'uint8') + + # Create invalid path + invalid_path = os.path.join('this','path','doesnt','exist.tif') + + with pytest.raises(ValueError,match=f'The directory {os.path.dirname(invalid_path)} does not exist. Please provide a valid directory'): + # Try to save test image to an invalid path + qim3d.io.save(invalid_path,test_image) + +def test_unsupported_file_format(): + # Create random test image + test_image = np.random.randint(0,256,(100,100,100),'uint8') + + # Create filename with unsupported format + filename = 'test_image.unsupported' + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + image_path = os.path.join(temp_dir,filename) + + with pytest.raises(ValueError,match='Unsupported file format'): + # Try to save test image with an unsupported file extension + qim3d.io.save(image_path,test_image) + + +def calculate_image_hash(image): + image_bytes = image.tobytes() + hash_object = hashlib.md5(image_bytes) + return hash_object.hexdigest() \ No newline at end of file