From 67be30dfc467ada75d0e312db12d2cc5c3d29b7b Mon Sep 17 00:00:00 2001 From: wuyiulin Date: Mon, 21 Sep 2026 14:44:02 +0800 Subject: [PATCH 1/5] Fix DPC border handling with CFA-aware padding (#31) --- modules/dead_pixel_correction.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/modules/dead_pixel_correction.py b/modules/dead_pixel_correction.py index 59b9293..c9a3ec7 100644 --- a/modules/dead_pixel_correction.py +++ b/modules/dead_pixel_correction.py @@ -30,10 +30,25 @@ def __init__(self, img, platform, sensor_info, parm_dpc, save_out_obj): self.save_out_obj = save_out_obj def padding(self): - """Return a mirror padded copy of image.""" + """Return a CFA-aware mirror padded copy of image.""" - img_pad = np.pad(self.img, (2, 2), "reflect") - return img_pad + return self.pad_cfa(self.img) + + @staticmethod + def pad_cfa(img): + """ + Pad each CFA channel separately by one pixel (two pixels on the raw image). + + Same-color neighbours are two pixels apart on a Bayer grid, so a plain + mirror/reflect padding of the raw image maps the neighbour of a pixel at + index 1 (or N-2) back onto the pixel itself. Reflecting every same-color + sub-image on its own gives the nearest real same-color pixel instead. + """ + padded = np.empty((img.shape[0] + 4, img.shape[1] + 4), dtype=img.dtype) + for row in (0, 1): + for col in (0, 1): + padded[row::2, col::2] = np.pad(img[row::2, col::2], 1, mode="reflect") + return padded def apply_fast_dead_pixel_correction(self): """This function detects and corrects Dead pixels using numpy @@ -55,8 +70,10 @@ def apply_fast_dead_pixel_correction(self): ] ) - # The maximum and minimum filters automatically pad the input image internally, - # eliminating the need for manual padding. + # Pad each CFA channel before filtering. Every pixel of the original image + # then has its 5x5 window inside the padded array, so the scipy "mode" + # below does not affect the result after the padding is removed. + self.img = self.pad_cfa(self.img) max_value = maximum_filter(self.img, footprint=window, mode="mirror") min_value = minimum_filter(self.img, footprint=window, mode="mirror") @@ -148,8 +165,7 @@ def apply_fast_dead_pixel_correction(self): ) # convolve each kernel over image to compute differences - # The correlate function automatically pads the input image internally, - # eliminating the need for manual padding. + # convolve each kernel over the CFA-padded image to compute differences diff_top_left = np.abs(correlate(self.img, ker_top_left, mode="mirror")) diff_top_mid = np.abs(correlate(self.img, ker_top_mid, mode="mirror")) @@ -318,6 +334,8 @@ def apply_fast_dead_pixel_correction(self): dpc_img = np.where(detection_mask, corrected_img, self.img) # Remove padding + dpc_img = dpc_img[2:-2, 2:-2] + detection_mask = detection_mask[2:-2, 2:-2] self.img = np.uint16(np.clip(dpc_img, 0, (2**self.bpp) - 1)) if self.is_debug: From dd400a84ede61ae66dd52a56c1f1e36a4a5c5e58 Mon Sep 17 00:00:00 2001 From: wuyiulin Date: Mon, 21 Sep 2026 14:45:42 +0800 Subject: [PATCH 2/5] Add unit tests for DPC border defects --- tests/test_dead_pixel_correction.py | 107 ++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_dead_pixel_correction.py diff --git a/tests/test_dead_pixel_correction.py b/tests/test_dead_pixel_correction.py new file mode 100644 index 0000000..b8d0c2b --- /dev/null +++ b/tests/test_dead_pixel_correction.py @@ -0,0 +1,107 @@ +""" +File: test_dead_pixel_correction.py +Description: Unit tests for DPC border handling (CFA-aware padding). + Run from the repo root: python -m pytest tests/test_dead_pixel_correction.py +------------------------------------------------------------ +""" +import sys + +import numpy as np +import pytest + +sys.path.append(".") +from modules.dead_pixel_correction import DeadPixelCorrection # pylint: disable=C0413 + +HEIGHT, WIDTH, BPP = 32, 32, 12 +THRESHOLD = 80 +BASE_LEVEL = 512 + +# Every row/col index a 5x5 window touches the border with: 0, 1, 2 and N-3, N-2, N-1 +BORDER_ROWS = [0, 1, 2, HEIGHT - 3, HEIGHT - 2, HEIGHT - 1] +BORDER_COLS = [0, 1, 2, WIDTH - 3, WIDTH - 2, WIDTH - 1] +BORDER_POSITIONS = [(r, c) for r in BORDER_ROWS for c in BORDER_COLS] + + +def make_dpc(img): + """Build a DPC object with minimal config dicts.""" + sensor_info = {"height": img.shape[0], "width": img.shape[1], "bit_depth": BPP} + parm_dpc = { + "is_enable": True, + "is_save": False, + "dp_threshold": THRESHOLD, + "is_debug": False, + } + platform = { + "in_file": "unit_test", + "disable_progress_bar": True, + "leave_pbar_string": False, + } + return DeadPixelCorrection(np.float32(img), platform, sensor_info, parm_dpc, None) + + +def make_clean_image(seed=0): + """Flat image with mild noise, far below the DPC threshold.""" + rng = np.random.default_rng(seed) + noise = rng.integers(0, 8, (HEIGHT, WIDTH)) + return (BASE_LEVEL + noise).astype(np.uint16) + + +@pytest.mark.parametrize("defect_value", [(2**BPP) - 1, 0], ids=["hot", "dead"]) +@pytest.mark.parametrize("pos", BORDER_POSITIONS) +def test_isolated_border_defect_is_corrected(pos, defect_value): + """A single defect at any border position must be detected and corrected.""" + img = make_clean_image() + img[pos] = defect_value + + out = make_dpc(img).apply_fast_dead_pixel_correction() + + assert abs(int(out[pos]) - BASE_LEVEL) < THRESHOLD, ( + f"defect at {pos} not corrected: {out[pos]}" + ) + + +def test_fast_and_loop_versions_match(): + """Vectorised and loop implementations must be bit-exact.""" + img = make_clean_image() + rng = np.random.default_rng(1) + rows = rng.choice(np.arange(0, HEIGHT, 4), 6, replace=False) + cols = rng.choice(np.arange(1, WIDTH, 4), 6, replace=False) + img[rows, cols] = (2**BPP) - 1 + + fast = make_dpc(img).apply_fast_dead_pixel_correction() + loop = make_dpc(img).apply_dead_pixel_correction() + + np.testing.assert_array_equal(fast, loop) + + +def test_clean_image_is_untouched(): + """No defects -> output equals input, and shape/dtype are preserved.""" + img = make_clean_image() + + out = make_dpc(img).apply_fast_dead_pixel_correction() + + assert out.shape == img.shape + assert out.dtype == np.uint16 + np.testing.assert_array_equal(out, img) + + +def test_pad_cfa_never_mirrors_pixel_onto_itself(): + """Same-color neighbours of row/col 1 must not be the pixel itself.""" + img = np.arange(HEIGHT * WIDTH, dtype=np.float32).reshape(HEIGHT, WIDTH) + + padded = DeadPixelCorrection.pad_cfa(img) + + assert padded.shape == (HEIGHT + 4, WIDTH + 4) + np.testing.assert_array_equal(padded[2:-2, 2:-2], img) + # original (1, 1) -> padded (3, 3); its same-color neighbours are 2 px away + center = padded[3, 3] + neighbours = padded[1:6:2, 1:6:2].ravel() + neighbours = np.delete(neighbours, 4) # drop the center itself + assert np.all(neighbours != center) + # every padded pixel keeps the Bayer phase of the original image + for row in (0, 1): + for col in (0, 1): + np.testing.assert_array_equal( + padded[row::2, col::2], + np.pad(img[row::2, col::2], 1, mode="reflect"), + ) \ No newline at end of file From 15787e60b2d7d9cdc12a1b4694669f2c72835693 Mon Sep 17 00:00:00 2001 From: wuyiulin Date: Mon, 21 Sep 2026 16:18:50 +0800 Subject: [PATCH 3/5] Fix lint: add final newline, update stale padding comment --- modules/dead_pixel_correction.py | 1 - tests/test_dead_pixel_correction.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/dead_pixel_correction.py b/modules/dead_pixel_correction.py index c9a3ec7..a9cf479 100644 --- a/modules/dead_pixel_correction.py +++ b/modules/dead_pixel_correction.py @@ -164,7 +164,6 @@ def apply_fast_dead_pixel_correction(self): ] ) - # convolve each kernel over image to compute differences # convolve each kernel over the CFA-padded image to compute differences diff_top_left = np.abs(correlate(self.img, ker_top_left, mode="mirror")) diff --git a/tests/test_dead_pixel_correction.py b/tests/test_dead_pixel_correction.py index b8d0c2b..340bb3c 100644 --- a/tests/test_dead_pixel_correction.py +++ b/tests/test_dead_pixel_correction.py @@ -104,4 +104,4 @@ def test_pad_cfa_never_mirrors_pixel_onto_itself(): np.testing.assert_array_equal( padded[row::2, col::2], np.pad(img[row::2, col::2], 1, mode="reflect"), - ) \ No newline at end of file + ) From 748e85c10c6fbe083c2e5069b670b5b8c2601d77 Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 23 Sep 2026 14:05:12 +0800 Subject: [PATCH 4/5] Move pad_cfa to util/utils.py and remove unused padding() --- modules/dead_pixel_correction.py | 28 ++++------------------------ tests/test_dead_pixel_correction.py | 4 +++- util/utils.py | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/modules/dead_pixel_correction.py b/modules/dead_pixel_correction.py index a9cf479..104ead0 100644 --- a/modules/dead_pixel_correction.py +++ b/modules/dead_pixel_correction.py @@ -10,6 +10,7 @@ import numpy as np from tqdm import tqdm from scipy.ndimage import maximum_filter, minimum_filter, correlate +from util.utils import pad_cfa class DeadPixelCorrection: @@ -29,27 +30,6 @@ def __init__(self, img, platform, sensor_info, parm_dpc, save_out_obj): self.is_debug = parm_dpc["is_debug"] self.save_out_obj = save_out_obj - def padding(self): - """Return a CFA-aware mirror padded copy of image.""" - - return self.pad_cfa(self.img) - - @staticmethod - def pad_cfa(img): - """ - Pad each CFA channel separately by one pixel (two pixels on the raw image). - - Same-color neighbours are two pixels apart on a Bayer grid, so a plain - mirror/reflect padding of the raw image maps the neighbour of a pixel at - index 1 (or N-2) back onto the pixel itself. Reflecting every same-color - sub-image on its own gives the nearest real same-color pixel instead. - """ - padded = np.empty((img.shape[0] + 4, img.shape[1] + 4), dtype=img.dtype) - for row in (0, 1): - for col in (0, 1): - padded[row::2, col::2] = np.pad(img[row::2, col::2], 1, mode="reflect") - return padded - def apply_fast_dead_pixel_correction(self): """This function detects and corrects Dead pixels using numpy array opertaions.""" @@ -73,7 +53,7 @@ def apply_fast_dead_pixel_correction(self): # Pad each CFA channel before filtering. Every pixel of the original image # then has its 5x5 window inside the padded array, so the scipy "mode" # below does not affect the result after the padding is removed. - self.img = self.pad_cfa(self.img) + self.img = pad_cfa(self.img) max_value = maximum_filter(self.img, footprint=window, mode="mirror") min_value = minimum_filter(self.img, footprint=window, mode="mirror") @@ -350,8 +330,8 @@ def apply_dead_pixel_correction(self): height, width = self.sensor_info["height"], self.sensor_info["width"] - # Mirror padding is applied to self.img. - img_padded = np.float32(self.padding()) + # CFA-aware padding is applied to self.img. + img_padded = np.float32(pad_cfa(self.img)) dpc_img = np.empty((height, width), np.float32) corrected_pv_count = 0 diff --git a/tests/test_dead_pixel_correction.py b/tests/test_dead_pixel_correction.py index 340bb3c..1e18666 100644 --- a/tests/test_dead_pixel_correction.py +++ b/tests/test_dead_pixel_correction.py @@ -11,6 +11,8 @@ sys.path.append(".") from modules.dead_pixel_correction import DeadPixelCorrection # pylint: disable=C0413 +from util.utils import pad_cfa # pylint: disable=C0413 + HEIGHT, WIDTH, BPP = 32, 32, 12 THRESHOLD = 80 @@ -89,7 +91,7 @@ def test_pad_cfa_never_mirrors_pixel_onto_itself(): """Same-color neighbours of row/col 1 must not be the pixel itself.""" img = np.arange(HEIGHT * WIDTH, dtype=np.float32).reshape(HEIGHT, WIDTH) - padded = DeadPixelCorrection.pad_cfa(img) + padded = pad_cfa(img) assert padded.shape == (HEIGHT + 4, WIDTH + 4) np.testing.assert_array_equal(padded[2:-2, 2:-2], img) diff --git a/util/utils.py b/util/utils.py index c7195f6..6788fc1 100644 --- a/util/utils.py +++ b/util/utils.py @@ -16,6 +16,22 @@ import matplotlib.pyplot as plt +def pad_cfa(img): + """ + Pad each CFA channel separately by one pixel (two pixels on the raw image). + + Same-color neighbours are two pixels apart on a Bayer grid, so a plain + mirror/reflect padding of the raw image maps the neighbour of a pixel at + index 1 (or N-2) back onto the pixel itself. Reflecting every same-color + sub-image on its own gives the nearest real same-color pixel instead. + """ + padded = np.empty((img.shape[0] + 4, img.shape[1] + 4), dtype=img.dtype) + for row in (0, 1): + for col in (0, 1): + padded[row::2, col::2] = np.pad(img[row::2, col::2], 1, mode="reflect") + return padded + + def introduce_defect(img, total_defective_pixels, padding): """ This function randomly replaces pixels values with extremely high or low From 190c3983f282211c9e6c841ab43cc8a1b2377072 Mon Sep 17 00:00:00 2001 From: marianadeem-10xe <108668196+marianadeem-10xe@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:04:41 +0500 Subject: [PATCH 5/5] Disable pylint warning for unused argument Added comment to disable pylint warning on `increase_indent`. The `indentless` argument is unused in the body but required to match the parent class's method parameters. --- util/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/utils.py b/util/utils.py index 6788fc1..f37c393 100644 --- a/util/utils.py +++ b/util/utils.py @@ -303,7 +303,7 @@ class CustomDumper(yaml.Dumper): of the increase_indent and write_line_break methods. It ensures that indentations and line breaks are inserted correctly in the output YAML file.""" - def increase_indent(self, flow=False, indentless=False): + def increase_indent(self, flow=False, indentless=False): # pylint: disable=unused-argument """For indentation""" return super(CustomDumper, self).increase_indent(flow, False)