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 1/2] 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' From d674ceef2b66e9bb899d979c5314648cde30a264 Mon Sep 17 00:00:00 2001 From: Manuel Osburg <79084170+ManuelOsburg@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:19:27 +0200 Subject: [PATCH 2/2] use format_img_name for the start time and time reference image names Since 12b7e7d plain {} placeholders keep zero-padded image IDs, while numeric format specs such as {:d} or {:04d} receive an integer. Two places in ConfigData still called img_name_string.format() directly, so templates with a numeric format spec failed there with a ValueError and series with unpadded file names (img_1.jpg) could not be configured at all. Use format_img_name in get_start_time and in_time_diff_to_img_time as well. The acceptance test images are named test_img_1.jpg etc. With the zero-padded IDs the template test_img_{}.jpg pointed to test_img_0001.jpg and every suite from S3 on failed. Use test_img_{:d}.jpg in the test library, which is the template for unpadded file names. (cherry picked from commit c0bbf50373de4a62c08a967799695daf792daf0d) --- ledsa/core/ConfigData.py | 8 +++++--- ledsa/tests/AcceptanceTests/LedsaATestLibrary.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ledsa/core/ConfigData.py b/ledsa/core/ConfigData.py index 27f863f..f23e21a 100644 --- a/ledsa/core/ConfigData.py +++ b/ledsa/core/ConfigData.py @@ -298,6 +298,7 @@ def in_time_diff_to_img_time(self) -> None: If the 'time_ref_img_time' is not set, prompts the user to provide the time shown on the clock in the time reference image. """ import warnings + from ledsa.core.image_handling import format_img_name # local import, image_handling imports ConfigData indirectly has_id = self['DEFAULT']['time_ref_img_id'] != 'None' has_file = self['DEFAULT']['time_ref_img_file'] != 'None' @@ -311,7 +312,7 @@ def in_time_diff_to_img_time(self) -> None: if has_id: generated_path = os.path.abspath(os.path.join( self['DEFAULT']['img_directory'], - self['DEFAULT']['img_name_string'].format(self['DEFAULT']['time_ref_img_id']) + format_img_name(self['DEFAULT']['img_name_string'], self['DEFAULT']['time_ref_img_id']) )) if has_file: @@ -415,8 +416,9 @@ 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( - self['DEFAULT']['first_img_experiment_id'])), 'DateTimeOriginal') + from ledsa.core.image_handling import format_img_name # local import, image_handling imports ConfigData indirectly + exif_entry = get_exif_entry(os.path.join(self['DEFAULT']['img_directory'], format_img_name( + self['DEFAULT']['img_name_string'], self['DEFAULT']['first_img_experiment_id'])), 'DateTimeOriginal') date, time_meta = exif_entry.split(' ') time_img = _get_datetime_from_str(date, time_meta) start_time = time_img - timedelta(seconds=self['DEFAULT'].getint('exif_time_infront_real_time')) diff --git a/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py b/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py index d616b12..61615f3 100644 --- a/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py +++ b/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py @@ -144,7 +144,7 @@ def create_and_fill_config_for_stacked_cam(self, cam_z, first=1, last=4): time_ref_img_id=None, time_ref_img_time=None, time_diff_to_image_time=0, - img_name_string='test_img_{}.jpg', + img_name_string='test_img_{:d}.jpg', num_img_overflow=None, first_img_experiment_id=first, last_img_experiment_id=last, @@ -286,7 +286,7 @@ def create_and_fill_config(self, first=1, last=4): conf = ConfigData(load_config_file=False, img_directory='test_data/', search_area_radius=10, pixel_value_percentile=99.875, channel=0, max_num_leds=1000, num_arrays=1, num_cores=1, date=None, start_time=None, time_ref_img_id=None, time_ref_img_time=None, time_diff_to_image_time=0, - img_name_string='test_img_{}.jpg', num_img_overflow=None, first_img_experiment_id=first, + img_name_string='test_img_{:d}.jpg', num_img_overflow=None, first_img_experiment_id=first, last_img_experiment_id=last, ref_img_id=1, ignore_led_indices=None, led_array_edge_indices=None, led_array_edge_coordinates=None, first_img_analysis_id=first, last_img_analysis_id=last, num_skip_imgs=0, num_skip_leds=0, @@ -314,7 +314,7 @@ def execute_ledsa_s1(self, use_config): out = self.execute_ledsa('-s1') else: self.execute_ledsa('--config') - inp = b'test_data/\ntest_img_{}.jpg\n1\n12:00:00\n1\n1000\n1\n1\n1' + inp = b'test_data/\ntest_img_{:d}.jpg\n1\n12:00:00\n1\n1000\n1\n1\n1' out = self.execute_ledsa('-s1', inp) check_error_msg(out) return out[0].decode('utf-8')[-9:-6]