Skip to content

feat(erasing): add grayscale fill mode#262

Open
federicopozzi33 wants to merge 6 commits into
albumentations-team:mainfrom
federicopozzi33:feature/erasing-grayscale-fill
Open

feat(erasing): add grayscale fill mode#262
federicopozzi33 wants to merge 6 commits into
albumentations-team:mainfrom
federicopozzi33:feature/erasing-grayscale-fill

Conversation

@federicopozzi33
Copy link
Copy Markdown
Contributor

@federicopozzi33 federicopozzi33 commented May 13, 2026

Summary by Sourcery

Add a grayscale fill mode to erasing/cutout dropout transforms for images, volumes, and volume batches, with appropriate validation and support across the dropout pipeline.

New Features:

  • Support using fill="grayscale" in Erasing and underlying cutout functions to convert erased regions to grayscale while preserving image shape and dtype for images, volumes, and batches.

Enhancements:

  • Broaden BaseDropout and Erasing fill parameter types to accept a generic string mode and document the new grayscale behavior.
  • Introduce helper functions to apply grayscale conversion to cutout holes for single images, single volumes, and batches of volumes, reusing common dropout infrastructure.
  • Improve erasing parameter generation by returning an empty holes array directly when no valid region can be found.

Tests:

  • Add tests covering grayscale fill behavior for single images, single volumes, and batches of volumes, including that only sampled regions are modified and that channels are equalized.
  • Add validation tests ensuring grayscale fill is restricted to 1- or 3-channel inputs and that fill_mask must be None when using fill="grayscale".

Copilot AI review requested due to automatic review settings May 13, 2026 20:25
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 13, 2026

Reviewer's Guide

Adds a new grayscale fill mode to Erasing/coarse dropout, implementing grayscale conversion for cutout holes across images, volumes, and batches, wires it into BaseDropout/Erasing APIs, and covers it with tests and minor refactors to hole sampling and typing.

Sequence diagram for grayscale fill mode in Erasing/BaseDropout

sequenceDiagram
    actor User
    participant Erasing as Erasing.transform
    participant BaseDropout as BaseDropout.apply
    participant Cutout as cutout
    participant GrayHoles as grayscale_holes
    participant GrayConv as to_gray_weighted_average
    participant GrayMC as grayscale_to_multichannel

    User->>Erasing: __call__(image, fill="grayscale")
    Erasing->>BaseDropout: apply(img, holes, seed)
    BaseDropout->>BaseDropout: validate fill == "grayscale" and channels in {1,3}
    BaseDropout->>Cutout: cutout(img, holes, fill="grayscale", random_generator)
    Cutout->>Cutout: detect fill == "grayscale"
    Cutout->>GrayHoles: grayscale_holes(img, holes)
    loop for each hole
        GrayHoles->>GrayConv: to_gray_weighted_average(patch)
        GrayConv-->>GrayHoles: grayscale_patch
        GrayHoles->>GrayMC: grayscale_to_multichannel(grayscale_patch, 3)
        GrayMC-->>GrayHoles: rgb_grayscale_patch
        GrayHoles-->>GrayHoles: write back rgb_grayscale_patch
    end
    GrayHoles-->>Cutout: img_with_grayscale_holes
    Cutout-->>BaseDropout: img_with_grayscale_holes
    BaseDropout-->>Erasing: img_with_grayscale_holes
    Erasing-->>User: transformed image
Loading

File-Level Changes

Change Details Files
Introduce grayscale fill implementation for cutout holes on images, single volumes, and batches of volumes/images.
  • Add grayscale_holes helper that converts selected rectangular regions to grayscale while preserving image shape, dtype, and channel layout using to_gray_weighted_average and grayscale_to_multichannel.
  • Add fill_volume_holes_with_grayscale to apply grayscale conversion across a single 4D volume, including validation of supported channel counts and early returns for degenerate cases.
  • Add fill_volumes_holes_with_grayscale to handle batched images/volumes (N,H,W,C or N,D,H,W,C) with recursive handling for 5D input and vectorized processing of shared holes.
  • Wire new grayscale mode into cutout, cutout_on_volume, and cutout_on_volumes by recognizing fill=='grayscale' and delegating to the grayscale helpers.
albumentations/augmentations/dropout/functional.py
Expose grayscale fill as a first-class option in Erasing/BaseDropout APIs with validation for compatible images and masks.
  • Relax fill type annotations in BaseDropout and Erasing to accept generic str and explicitly include 'grayscale' in the Literal types used by the init schema and Erasing constructor.
  • Add runtime validation in BaseDropout.init to forbid using fill_mask when fill='grayscale'.
  • Add per-target channel-count checks for grayscale fill in apply, apply_to_images, and apply_to_volumes, mirroring inpainting validation but with a dedicated error message.
  • Update Erasing docstrings and examples to document the new grayscale mode and clarify behavior when sampling invalid erasing parameters.
  • Refactor area sampling to return empty holes via np.empty((0,4), dtype=np.int32) for invalid geometry instead of constructing reshaped arrays.
albumentations/augmentations/dropout/transforms.py
albumentations/augmentations/dropout/coarse_dropout.py
Add tests covering grayscale fill behavior for images, volumes, and batched volumes, including failure modes.
  • Add tests ensuring grayscale fill modifies only the sampled erased patch and leaves other pixels unchanged for 2D RGB images.
  • Add tests verifying grayscale fill rejects non-RGB multi-channel inputs and requires fill_mask to be None.
  • Add tests confirming grayscale fill works on single volumes and batched volumes, with grayscale consistency across channels and no modification outside the holes.
tests/test_augmentations.py

Possibly linked issues

  • #(no number provided): PR implements the grayscale fill/patch replacement behavior for Erasing dropout exactly as requested in the issue

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The channel validation for grayscale/inpainting is duplicated in apply, apply_to_images, and apply_to_volumes; consider extracting a small helper to centralize the get_num_channels logic and error message so the behavior stays consistent across targets.
  • In grayscale_holes / fill_volume_holes_with_grayscale / fill_volumes_holes_with_grayscale, the error message and docstrings say 1- or 3-channel images are expected, but the code only raises for num_channels != 3 after early-returning for 1; it would be clearer to check explicitly for {1, 3} and use a message that matches that condition.
  • You widened several fill parameters from Literal[...] to plain str (e.g., in cutout, cutout_on_volume, cutout_on_volumes), which loses static type safety; consider keeping or reintroducing a Literal union that includes the new "grayscale" option rather than using bare str.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The channel validation for grayscale/inpainting is duplicated in `apply`, `apply_to_images`, and `apply_to_volumes`; consider extracting a small helper to centralize the `get_num_channels` logic and error message so the behavior stays consistent across targets.
- In `grayscale_holes` / `fill_volume_holes_with_grayscale` / `fill_volumes_holes_with_grayscale`, the error message and docstrings say 1- or 3-channel images are expected, but the code only raises for `num_channels != 3` after early-returning for 1; it would be clearer to check explicitly for `{1, 3}` and use a message that matches that condition.
- You widened several `fill` parameters from `Literal[...]` to plain `str` (e.g., in `cutout`, `cutout_on_volume`, `cutout_on_volumes`), which loses static type safety; consider keeping or reintroducing a `Literal` union that includes the new "grayscale" option rather than using bare `str`.

## Individual Comments

### Comment 1
<location path="tests/test_augmentations.py" line_range="1289-1313" />
<code_context>
             assert np.all(result[:, :, channel_idx] == expected_values[channel_idx])


+def test_erasing_grayscale_fill_converts_only_sampled_patch():
+    image = TestDataFactory.create_image((64, 64, 3), dtype=np.uint8, seed=137)
+
+    transform = A.Erasing(
+        scale=(0.2, 0.2),
+        ratio=(1.0, 1.0),
+        fill="grayscale",
+        p=1.0,
+    )
+    transform.set_random_seed(137)
+
+    result = transform(image=image)["image"]
+    holes = transform.params["holes"]
+
+    assert holes.shape == (1, 4)
+    x_min, y_min, x_max, y_max = holes[0]
+
+    outside_mask = np.ones(image.shape[:2], dtype=bool)
+    outside_mask[y_min:y_max, x_min:x_max] = False
+
+    np.testing.assert_array_equal(result[outside_mask], image[outside_mask])
+
+    hole_patch = result[y_min:y_max, x_min:x_max]
+    np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
+    np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that the dtype is preserved when using `fill="grayscale"`.

To better pin down the behavior of this mode, please also assert that `result.dtype == image.dtype` (here `np.uint8`), so that any future change that alters the dtype (e.g., to float) will be caught by this test.

```suggestion
def test_erasing_grayscale_fill_converts_only_sampled_patch():
    image = TestDataFactory.create_image((64, 64, 3), dtype=np.uint8, seed=137)

    transform = A.Erasing(
        scale=(0.2, 0.2),
        ratio=(1.0, 1.0),
        fill="grayscale",
        p=1.0,
    )
    transform.set_random_seed(137)

    result = transform(image=image)["image"]
    holes = transform.params["holes"]

    # Ensure that using fill="grayscale" does not change the dtype of the image
    assert result.dtype == image.dtype

    assert holes.shape == (1, 4)
    x_min, y_min, x_max, y_max = holes[0]

    outside_mask = np.ones(image.shape[:2], dtype=bool)
    outside_mask[y_min:y_max, x_min:x_max] = False

    np.testing.assert_array_equal(result[outside_mask], image[outside_mask])

    hole_patch = result[y_min:y_max, x_min:x_max]
    np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
    np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
```
</issue_to_address>

### Comment 2
<location path="tests/test_augmentations.py" line_range="1341-1367" />
<code_context>
+        )
+
+
+def test_erasing_grayscale_fill_on_volume():
+    volume = TestDataFactory.create_volume((4, 64, 64, 3), dtype=np.uint8, seed=137)
+
+    transform = A.Erasing(
+        scale=(0.2, 0.2),
+        ratio=(1.0, 1.0),
+        fill="grayscale",
+        p=1.0,
+    )
+    transform.set_random_seed(137)
+
+    result = transform(volume=volume)["volume"]
+    holes = transform.params["holes"]
+
+    assert holes.shape == (1, 4)
+    x_min, y_min, x_max, y_max = holes[0]
+
+    result_outside = result.copy()
+    volume_outside = volume.copy()
+    result_outside[:, y_min:y_max, x_min:x_max, :] = 0
+    volume_outside[:, y_min:y_max, x_min:x_max, :] = 0
+
+    np.testing.assert_array_equal(result_outside, volume_outside)
+
+    hole_patch = result[:, y_min:y_max, x_min:x_max, :]
+    np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
+    np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen the volume test by verifying that the erased region actually changes relative to the input.

Currently the test only confirms that the outside region is unchanged and that the erased region is grayscale, but not that the grayscale values differ from the original volume. To guard against regressions where the grayscale fill becomes a no-op, add an assertion like `assert not np.array_equal(hole_patch, volume[:, y_min:y_max, x_min:x_max, :])` (or a similar pixel-level check) to ensure the erased patch is actually modified.

```suggestion
def test_erasing_grayscale_fill_on_volume():
    volume = TestDataFactory.create_volume((4, 64, 64, 3), dtype=np.uint8, seed=137)

    transform = A.Erasing(
        scale=(0.2, 0.2),
        ratio=(1.0, 1.0),
        fill="grayscale",
        p=1.0,
    )
    transform.set_random_seed(137)

    result = transform(volume=volume)["volume"]
    holes = transform.params["holes"]

    assert holes.shape == (1, 4)
    x_min, y_min, x_max, y_max = holes[0]

    result_outside = result.copy()
    volume_outside = volume.copy()
    result_outside[:, y_min:y_max, x_min:x_max, :] = 0
    volume_outside[:, y_min:y_max, x_min:x_max, :] = 0

    np.testing.assert_array_equal(result_outside, volume_outside)

    hole_patch = result[:, y_min:y_max, x_min:x_max, :]
    volume_hole_patch = volume[:, y_min:y_max, x_min:x_max, :]

    # Ensure the erased region actually changes relative to the original volume
    assert not np.array_equal(hole_patch, volume_hole_patch)

    np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
    np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +1289 to +1313
def test_erasing_grayscale_fill_converts_only_sampled_patch():
image = TestDataFactory.create_image((64, 64, 3), dtype=np.uint8, seed=137)

transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)

result = transform(image=image)["image"]
holes = transform.params["holes"]

assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]

outside_mask = np.ones(image.shape[:2], dtype=bool)
outside_mask[y_min:y_max, x_min:x_max] = False

np.testing.assert_array_equal(result[outside_mask], image[outside_mask])

hole_patch = result[y_min:y_max, x_min:x_max]
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider asserting that the dtype is preserved when using fill="grayscale".

To better pin down the behavior of this mode, please also assert that result.dtype == image.dtype (here np.uint8), so that any future change that alters the dtype (e.g., to float) will be caught by this test.

Suggested change
def test_erasing_grayscale_fill_converts_only_sampled_patch():
image = TestDataFactory.create_image((64, 64, 3), dtype=np.uint8, seed=137)
transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)
result = transform(image=image)["image"]
holes = transform.params["holes"]
assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]
outside_mask = np.ones(image.shape[:2], dtype=bool)
outside_mask[y_min:y_max, x_min:x_max] = False
np.testing.assert_array_equal(result[outside_mask], image[outside_mask])
hole_patch = result[y_min:y_max, x_min:x_max]
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
def test_erasing_grayscale_fill_converts_only_sampled_patch():
image = TestDataFactory.create_image((64, 64, 3), dtype=np.uint8, seed=137)
transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)
result = transform(image=image)["image"]
holes = transform.params["holes"]
# Ensure that using fill="grayscale" does not change the dtype of the image
assert result.dtype == image.dtype
assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]
outside_mask = np.ones(image.shape[:2], dtype=bool)
outside_mask[y_min:y_max, x_min:x_max] = False
np.testing.assert_array_equal(result[outside_mask], image[outside_mask])
hole_patch = result[y_min:y_max, x_min:x_max]
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])

Comment on lines +1341 to +1367
def test_erasing_grayscale_fill_on_volume():
volume = TestDataFactory.create_volume((4, 64, 64, 3), dtype=np.uint8, seed=137)

transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)

result = transform(volume=volume)["volume"]
holes = transform.params["holes"]

assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]

result_outside = result.copy()
volume_outside = volume.copy()
result_outside[:, y_min:y_max, x_min:x_max, :] = 0
volume_outside[:, y_min:y_max, x_min:x_max, :] = 0

np.testing.assert_array_equal(result_outside, volume_outside)

hole_patch = result[:, y_min:y_max, x_min:x_max, :]
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Strengthen the volume test by verifying that the erased region actually changes relative to the input.

Currently the test only confirms that the outside region is unchanged and that the erased region is grayscale, but not that the grayscale values differ from the original volume. To guard against regressions where the grayscale fill becomes a no-op, add an assertion like assert not np.array_equal(hole_patch, volume[:, y_min:y_max, x_min:x_max, :]) (or a similar pixel-level check) to ensure the erased patch is actually modified.

Suggested change
def test_erasing_grayscale_fill_on_volume():
volume = TestDataFactory.create_volume((4, 64, 64, 3), dtype=np.uint8, seed=137)
transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)
result = transform(volume=volume)["volume"]
holes = transform.params["holes"]
assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]
result_outside = result.copy()
volume_outside = volume.copy()
result_outside[:, y_min:y_max, x_min:x_max, :] = 0
volume_outside[:, y_min:y_max, x_min:x_max, :] = 0
np.testing.assert_array_equal(result_outside, volume_outside)
hole_patch = result[:, y_min:y_max, x_min:x_max, :]
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])
def test_erasing_grayscale_fill_on_volume():
volume = TestDataFactory.create_volume((4, 64, 64, 3), dtype=np.uint8, seed=137)
transform = A.Erasing(
scale=(0.2, 0.2),
ratio=(1.0, 1.0),
fill="grayscale",
p=1.0,
)
transform.set_random_seed(137)
result = transform(volume=volume)["volume"]
holes = transform.params["holes"]
assert holes.shape == (1, 4)
x_min, y_min, x_max, y_max = holes[0]
result_outside = result.copy()
volume_outside = volume.copy()
result_outside[:, y_min:y_max, x_min:x_max, :] = 0
volume_outside[:, y_min:y_max, x_min:x_max, :] = 0
np.testing.assert_array_equal(result_outside, volume_outside)
hole_patch = result[:, y_min:y_max, x_min:x_max, :]
volume_hole_patch = volume[:, y_min:y_max, x_min:x_max, :]
# Ensure the erased region actually changes relative to the original volume
assert not np.array_equal(hole_patch, volume_hole_patch)
np.testing.assert_array_equal(hole_patch[..., 0], hole_patch[..., 1])
np.testing.assert_array_equal(hole_patch[..., 1], hole_patch[..., 2])

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new fill="grayscale" mode for dropout-style cutout operations (notably A.Erasing), allowing erased regions to be converted to grayscale instead of being replaced by a constant/random value or inpainted.

Changes:

  • Extend BaseDropout/Erasing fill to support "grayscale" and add related validation.
  • Implement grayscale hole filling in albumentations.augmentations.dropout.functional for images, volumes, and batches.
  • Add test coverage for grayscale fill on images and (batched) volumes, plus validation/error cases.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
tests/test_augmentations.py Adds unit tests validating grayscale fill behavior and constraints.
albumentations/augmentations/dropout/transforms.py Extends fill schema/type union and adds grayscale-specific validation for channel count and fill_mask.
albumentations/augmentations/dropout/functional.py Adds grayscale hole filling implementations and integrates them into cutout* functions.
albumentations/augmentations/dropout/coarse_dropout.py Updates Erasing docs/types and minor return-shape cleanup in parameter sampling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 330 to 332
if max_valid_area < min_area:
return {"holes": np.array([], dtype=np.int32).reshape((0, 4))}
return {"holes": np.empty((0, 4), dtype=np.int32)}

Comment thread albumentations/augmentations/dropout/functional.py
Comment thread albumentations/augmentations/dropout/functional.py Outdated
Comment thread albumentations/augmentations/dropout/functional.py Outdated
Comment thread albumentations/augmentations/dropout/coarse_dropout.py Outdated
return img

if num_channels not in {1, 3}:
raise ValueError("Grayscale fill works only for 1 or 3 channel images")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many ways to convert to grayscale, most of them support any number of channels, no need to use to_gray_weighted_average

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants