From 12b7e7def3108f4fc0496fac7b1cd480ce71964a Mon Sep 17 00:00:00 2001 From: Manuel Osburg <79084170+ManuelOsburg@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:35:49 +0200 Subject: [PATCH] support NEF/ARW/DNG raw formats and zero-padded image IDs Adopted and reworked from the annatroff/LEDSAwindows fork: - extend the raw format whitelist in image_reading by .NEF, .ARW and .DNG (rawpy reads them already; EXIF works with the existing exiv2 binding) and raise a clear ValueError for unsupported extensions instead of failing with UnboundLocalError - fix a path bug in ConfigData.get_start_time where directory and file name were concatenated without a separator inside os.path.join - add image_handling.format_img_name and use it wherever image names are built from the config template: plain {} placeholders now keep zero-padded IDs such as '0001' (DSC_0001.NEF), while numeric format specs like {:04d} still receive an integer The int() casts previously applied before formatting broke camera series with zero-padded file counters. Unit tests cover the extension routing and both formatting paths. --- ledsa/core/ConfigData.py | 2 +- ledsa/core/image_handling.py | 20 +++++++++ ledsa/core/image_reading.py | 8 +++- ledsa/data_extraction/DataExtractor.py | 4 +- ledsa/data_extraction/init_functions.py | 5 ++- ledsa/tests/UnitTests/__init__.py | 0 .../tests/UnitTests/test_raw_format_compat.py | 41 +++++++++++++++++++ 7 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 ledsa/tests/UnitTests/__init__.py create mode 100644 ledsa/tests/UnitTests/test_raw_format_compat.py diff --git a/ledsa/core/ConfigData.py b/ledsa/core/ConfigData.py index 5bda6d3..27f863f 100644 --- a/ledsa/core/ConfigData.py +++ b/ledsa/core/ConfigData.py @@ -415,7 +415,7 @@ def get_start_time(self) -> None: Updates the 'DEFAULT' key with the 'start_time' computed. """ - exif_entry = get_exif_entry(os.path.join(self['DEFAULT']['img_directory'] + self['DEFAULT']['img_name_string'].format( + exif_entry = get_exif_entry(os.path.join(self['DEFAULT']['img_directory'], self['DEFAULT']['img_name_string'].format( self['DEFAULT']['first_img_experiment_id'])), 'DateTimeOriginal') date, time_meta = exif_entry.split(' ') time_img = _get_datetime_from_str(date, time_meta) diff --git a/ledsa/core/image_handling.py b/ledsa/core/image_handling.py index 86285ac..6342da2 100644 --- a/ledsa/core/image_handling.py +++ b/ledsa/core/image_handling.py @@ -3,6 +3,26 @@ from ledsa.core.file_handling import read_table +def format_img_name(img_name_string: str, img_id) -> str: + """ + Build an image file name from the config template and an image ID. + + Plain ``{}`` placeholders keep the ID as given, so zero-padded IDs such as + '0001' (e.g. DSC_0001.NEF) are preserved. Numeric format specifications + such as ``{:04d}`` require an integer, for which the ID is cast. + + :param img_name_string: Name template from the config, e.g. 'DSC_{}.CR3'. + :type img_name_string: str + :param img_id: The image ID, as string or integer. + :return: The formatted image file name. + :rtype: str + """ + try: + return img_name_string.format(img_id) + except ValueError: + return img_name_string.format(int(img_id)) + + def get_img_name(img_id: str) -> str: """ Retrieves the image path corresponding to a given image ID. diff --git a/ledsa/core/image_reading.py b/ledsa/core/image_reading.py index f7cc8f9..35a0712 100644 --- a/ledsa/core/image_reading.py +++ b/ledsa/core/image_reading.py @@ -22,8 +22,10 @@ def read_channel_data_from_img(filename: str, channel: int) -> np.ndarray: extension = os.path.splitext(filename)[-1] if extension in ['.JPG', '.JPEG', '.jpg', '.jpeg', '.PNG', '.png']: channel_array = _read_channel_data_from_img_file(filename, channel) - elif extension in ['.CR2', '.CR3']: + elif extension in ['.CR2', '.CR3', '.NEF', '.ARW', '.DNG']: channel_array = _read_channel_data_from_raw_file(filename, channel) + else: + raise ValueError(f"Unsupported image format '{extension}' of file {filename}.") return channel_array def read_img_array_from_img(filename: str, channel: int) -> np.ndarray: @@ -41,8 +43,10 @@ def read_img_array_from_img(filename: str, channel: int) -> np.ndarray: extension = os.path.splitext(filename)[-1] if extension in ['.JPG', '.JPEG', '.jpg', '.jpeg', '.PNG', '.png']: img_array = _read_grayscale_img_array_from_img_file(filename) - elif extension in ['.CR2', '.CR3']: + elif extension in ['.CR2', '.CR3', '.NEF', '.ARW', '.DNG']: img_array, _ = _read_img_array_from_raw_file(filename, channel) + else: + raise ValueError(f"Unsupported image format '{extension}' of file {filename}.") return img_array diff --git a/ledsa/data_extraction/DataExtractor.py b/ledsa/data_extraction/DataExtractor.py index 47978b5..7fc5c63 100644 --- a/ledsa/data_extraction/DataExtractor.py +++ b/ledsa/data_extraction/DataExtractor.py @@ -93,7 +93,7 @@ def find_search_areas(self) -> None: Identify all LEDs in the reference image and define the areas where LEDs will be searched in the experiment images. """ config = self.config['find_search_areas'] - in_file_path = os.path.join(config['img_directory'], config['img_name_string'].format(int(config['ref_img_id']))) + in_file_path = os.path.join(config['img_directory'], ledsa.core.image_handling.format_img_name(config['img_name_string'], config['ref_img_id'])) channel = config['channel'] search_area_radius = int(config['search_area_radius']) max_num_leds = int(config['max_num_leds']) @@ -126,7 +126,7 @@ def plot_search_areas(self, reorder_leds=False) -> None: if self.search_areas is None: self.load_search_areas() - in_file_path = os.path.join(config['img_directory'], config['img_name_string'].format(int(config['ref_img_id']))) + in_file_path = os.path.join(config['img_directory'], ledsa.core.image_handling.format_img_name(config['img_name_string'], config['ref_img_id'])) # TODO this currently only works for RAW files but should work for JPG files as well data = ledsa.core.image_reading.read_img_array_from_img(in_file_path, channel=0) search_area_radius = int(config['search_area_radius']) diff --git a/ledsa/data_extraction/init_functions.py b/ledsa/data_extraction/init_functions.py index 484b055..da22341 100644 --- a/ledsa/data_extraction/init_functions.py +++ b/ledsa/data_extraction/init_functions.py @@ -3,6 +3,7 @@ from typing import List from ledsa.core.ConfigData import ConfigData +from ledsa.core.image_handling import format_img_name from ledsa.core.image_reading import get_exif_entry @@ -114,7 +115,7 @@ def _calc_experiment_and_real_time(build_type: str, config: ConfigData, tag: str :rtype: tuple """ exif_entry = get_exif_entry(os.path.join(config['DEFAULT']['img_directory'], - config['DEFAULT']['img_name_string'].format(int(img_number))), tag) + format_img_name(config['DEFAULT']['img_name_string'], img_number)), tag) date, time_meta = exif_entry.split(' ') date_time_img = _get_datetime_from_str(date, time_meta) @@ -201,7 +202,7 @@ def _build_img_data_string(build_type: str, config: ConfigData) -> str: for img_id in img_id_list: tag = 'DateTimeOriginal' experiment_time, time = _calc_experiment_and_real_time(build_type, config, tag, img_id) - img_data += (str(img_idx) + ',' + config[build_type]['img_name_string'].format(int(img_id)) + + img_data += (str(img_idx) + ',' + format_img_name(config[build_type]['img_name_string'], img_id) + ',' + time.strftime('%H:%M:%S') + ',' + str(experiment_time) + '\n') img_idx += 1 return img_data diff --git a/ledsa/tests/UnitTests/__init__.py b/ledsa/tests/UnitTests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ledsa/tests/UnitTests/test_raw_format_compat.py b/ledsa/tests/UnitTests/test_raw_format_compat.py new file mode 100644 index 0000000..02e9b62 --- /dev/null +++ b/ledsa/tests/UnitTests/test_raw_format_compat.py @@ -0,0 +1,41 @@ +import numpy as np +import pytest + +import ledsa.core.image_reading as image_reading +from ledsa.core.image_handling import format_img_name + + +class TestRawExtensionRouting: + @pytest.fixture + def raw_reader_stub(self, monkeypatch): + calls = [] + + def stub(filename, channel): + calls.append(filename) + return np.zeros((4, 4)) + + monkeypatch.setattr(image_reading, '_read_channel_data_from_raw_file', stub) + return calls + + @pytest.mark.parametrize('extension', ['.CR2', '.CR3', '.NEF', '.ARW', '.DNG']) + def test_raw_formats_are_routed_to_raw_reader(self, raw_reader_stub, extension): + image_reading.read_channel_data_from_img(f'img_0001{extension}', channel=0) + assert raw_reader_stub == [f'img_0001{extension}'] + + def test_unsupported_format_raises(self): + with pytest.raises(ValueError, match='Unsupported image format'): + image_reading.read_channel_data_from_img('img_0001.xyz', channel=0) + + +class TestFormatImgName: + def test_plain_placeholder_preserves_leading_zeros(self): + assert format_img_name('DSC_{}.NEF', '0001') == 'DSC_0001.NEF' + + def test_plain_placeholder_with_int_id(self): + assert format_img_name('test_img_{}.jpg', 7) == 'test_img_7.jpg' + + def test_numeric_format_spec_with_string_id(self): + assert format_img_name('IMG_{:04d}.CR2', '7') == 'IMG_0007.CR2' + + def test_numeric_format_spec_with_int_id(self): + assert format_img_name('IMG_{:04d}.CR2', 7) == 'IMG_0007.CR2'