Spaces:
Paused
Paused
Add image cleanup app
Browse files- .gitattributes +3 -0
- .gitignore +4 -0
- .python-version +1 -0
- README.md +41 -6
- app.py +162 -0
- cleaning.py +145 -0
- create_test_images.py +39 -0
- data/A-4.ome.tiff +3 -0
- data/dirty.png +3 -0
- data/dirty.tiff +3 -0
- data/fixed_ns.png +3 -0
- data/fixed_telea.png +3 -0
- data/original.png +3 -0
- data/original.tiff +3 -0
- data/original2.png +3 -0
- main.py +53 -0
- pyproject.toml +120 -0
- pyrefly.toml +4 -0
- requirements.txt +3 -0
- test.ipynb +0 -0
- uv.lock +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.tif filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.tiff filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__
|
| 3 |
+
out/
|
| 4 |
+
data/test
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12.11
|
README.md
CHANGED
|
@@ -1,13 +1,48 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Image Cleanup
|
| 3 |
+
emoji: 🧑🔬
|
| 4 |
+
colorFrom: pink
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.19.0
|
| 8 |
+
python_version: 3.12.11
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Image cleanup
|
| 14 |
+
|
| 15 |
+
Script for removing dark, low-saturation debris from IHC images with OpenCV
|
| 16 |
+
inpainting.
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
uv sync
|
| 20 |
+
uv run python main.py data/dirty.png
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
PNG, JPEG, TIFF, and TIF inputs are supported. Cleaned images are written to
|
| 24 |
+
`out/cleaned/` with `_cleaned` added before the original extension, and masks
|
| 25 |
+
are written to `out/masks/` as PNG files.
|
| 26 |
+
|
| 27 |
+
An optional Gradio interface is also available:
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
uv run python app.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Open the local URL printed in the terminal, upload an input image, and select
|
| 34 |
+
**Clean image**. TIFF inputs are converted to PNG previews in the interface,
|
| 35 |
+
and the cleaned file can be downloaded with the original extension.
|
| 36 |
+
|
| 37 |
+
For batch processing, open the **Image directory** tab and select a folder.
|
| 38 |
+
The app processes all uploaded images, including `.tif` and `.tiff` files, and
|
| 39 |
+
returns a ZIP archive.
|
| 40 |
+
|
| 41 |
+
Project layout:
|
| 42 |
+
|
| 43 |
+
- `main.py` — primary image-cleaning script
|
| 44 |
+
- `cleaning.py` — reusable cleaning operations
|
| 45 |
+
- `app.py` — optional Gradio interface
|
| 46 |
+
- `data/` — source and example images
|
| 47 |
+
- `out/` — generated cleaned images and masks
|
| 48 |
+
- `test.ipynb` — image-cleaning experiments
|
app.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
import zipfile
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from cleaning import (
|
| 9 |
+
clean_image,
|
| 10 |
+
cleaned_image_name,
|
| 11 |
+
read_image_rgb,
|
| 12 |
+
write_image_rgb,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
_BATCH_OUTPUTS = tempfile.TemporaryDirectory(prefix="ihc-cleaner-")
|
| 16 |
+
_EXAMPLE_IMAGES = [["data/dirty.png"], ["data/dirty.tiff"]]
|
| 17 |
+
_IMAGE_FILE_TYPES = ["image", ".tif", ".tiff"]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _uploaded_path(file_path: str | Path | None) -> Path:
|
| 21 |
+
if not file_path:
|
| 22 |
+
raise ValueError("Upload an input image.")
|
| 23 |
+
|
| 24 |
+
return Path(file_path)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def preview_image(file_path: str | Path | None):
|
| 28 |
+
if not file_path:
|
| 29 |
+
return None, None, None
|
| 30 |
+
|
| 31 |
+
return read_image_rgb(file_path), None, None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def clean_uploaded_image(file_path: str | Path | None):
|
| 35 |
+
image_path = _uploaded_path(file_path)
|
| 36 |
+
output_dir = Path(_BATCH_OUTPUTS.name) / uuid4().hex
|
| 37 |
+
output_dir.mkdir(parents=True)
|
| 38 |
+
|
| 39 |
+
image_rgb = read_image_rgb(image_path)
|
| 40 |
+
cleaned_rgb = clean_image(image_rgb)
|
| 41 |
+
cleaned_path = output_dir / cleaned_image_name(image_path)
|
| 42 |
+
write_image_rgb(cleaned_path, cleaned_rgb)
|
| 43 |
+
|
| 44 |
+
return image_rgb, cleaned_rgb, str(cleaned_path)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def clean_directory(image_paths: list[str] | None) -> tuple[str, str]:
|
| 48 |
+
if not image_paths:
|
| 49 |
+
raise ValueError("Upload a directory containing images.")
|
| 50 |
+
|
| 51 |
+
batch_dir = Path(_BATCH_OUTPUTS.name) / uuid4().hex
|
| 52 |
+
cleaned_dir = batch_dir / "cleaned"
|
| 53 |
+
cleaned_dir.mkdir(parents=True)
|
| 54 |
+
|
| 55 |
+
used_names: set[str] = set()
|
| 56 |
+
|
| 57 |
+
for image_path_string in image_paths:
|
| 58 |
+
image_path = Path(image_path_string)
|
| 59 |
+
cleaned_rgb = clean_image(read_image_rgb(image_path))
|
| 60 |
+
|
| 61 |
+
output_name = cleaned_image_name(image_path, used_names)
|
| 62 |
+
write_image_rgb(cleaned_dir / output_name, cleaned_rgb)
|
| 63 |
+
|
| 64 |
+
archive_path = batch_dir / "cleaned_images.zip"
|
| 65 |
+
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
| 66 |
+
for cleaned_path in sorted(cleaned_dir.iterdir()):
|
| 67 |
+
archive.write(cleaned_path, arcname=cleaned_path.name)
|
| 68 |
+
|
| 69 |
+
count = len(used_names)
|
| 70 |
+
return str(archive_path), f"Cleaned {count} image{'s' if count != 1 else ''}."
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_app() -> gr.Blocks:
|
| 74 |
+
with gr.Blocks(title="Image Cleanup") as app:
|
| 75 |
+
gr.Markdown(
|
| 76 |
+
"# Image Cleanup\n"
|
| 77 |
+
"Upload an input image to remove dark, low-saturation debris."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
with gr.Tab("Single image"):
|
| 81 |
+
with gr.Row():
|
| 82 |
+
input_image = gr.File(
|
| 83 |
+
label="Input image",
|
| 84 |
+
file_types=_IMAGE_FILE_TYPES,
|
| 85 |
+
type="filepath",
|
| 86 |
+
)
|
| 87 |
+
cleaned_file = gr.File(
|
| 88 |
+
label="Cleaned file",
|
| 89 |
+
interactive=False,
|
| 90 |
+
)
|
| 91 |
+
with gr.Row():
|
| 92 |
+
input_preview = gr.Image(
|
| 93 |
+
label="Input preview",
|
| 94 |
+
format="png",
|
| 95 |
+
buttons=["fullscreen"],
|
| 96 |
+
interactive=False,
|
| 97 |
+
)
|
| 98 |
+
cleaned_preview = gr.Image(
|
| 99 |
+
label="Cleaned image",
|
| 100 |
+
format="png",
|
| 101 |
+
buttons=["fullscreen"],
|
| 102 |
+
interactive=False,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
with gr.Row():
|
| 106 |
+
clean_button = gr.Button("Clean image", variant="primary")
|
| 107 |
+
gr.ClearButton(
|
| 108 |
+
[input_image, input_preview, cleaned_preview, cleaned_file]
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
gr.Examples(
|
| 112 |
+
examples=_EXAMPLE_IMAGES,
|
| 113 |
+
inputs=input_image,
|
| 114 |
+
label="Example image",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
input_image.change(
|
| 118 |
+
fn=preview_image,
|
| 119 |
+
inputs=input_image,
|
| 120 |
+
outputs=[input_preview, cleaned_preview, cleaned_file],
|
| 121 |
+
api_name=False,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
clean_button.click(
|
| 125 |
+
fn=clean_uploaded_image,
|
| 126 |
+
inputs=input_image,
|
| 127 |
+
outputs=[input_preview, cleaned_preview, cleaned_file],
|
| 128 |
+
api_name="clean_image",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
with gr.Tab("Image directory"):
|
| 132 |
+
directory_input = gr.File(
|
| 133 |
+
label="Image directory",
|
| 134 |
+
file_count="directory",
|
| 135 |
+
file_types=_IMAGE_FILE_TYPES,
|
| 136 |
+
type="filepath",
|
| 137 |
+
)
|
| 138 |
+
clean_directory_button = gr.Button(
|
| 139 |
+
"Clean directory",
|
| 140 |
+
variant="primary",
|
| 141 |
+
)
|
| 142 |
+
batch_status = gr.Textbox(label="Status", interactive=False)
|
| 143 |
+
batch_download = gr.File(
|
| 144 |
+
label="Cleaned images",
|
| 145 |
+
interactive=False,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
clean_directory_button.click(
|
| 149 |
+
fn=clean_directory,
|
| 150 |
+
inputs=directory_input,
|
| 151 |
+
outputs=[batch_download, batch_status],
|
| 152 |
+
api_name="clean_directory",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return app
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
demo = build_app()
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
if __name__ == "__main__":
|
| 162 |
+
demo.launch(ssr_mode=False)
|
cleaning.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import cv2
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _as_rgb_uint8(image: np.ndarray) -> np.ndarray:
|
| 10 |
+
image = np.asarray(image)
|
| 11 |
+
|
| 12 |
+
if image.ndim == 3 and image.shape[2] == 1:
|
| 13 |
+
image = image[:, :, 0]
|
| 14 |
+
|
| 15 |
+
if image.ndim == 2:
|
| 16 |
+
image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
|
| 17 |
+
elif image.ndim != 3:
|
| 18 |
+
raise ValueError("Expected a grayscale or RGB image.")
|
| 19 |
+
|
| 20 |
+
if image.shape[2] == 4:
|
| 21 |
+
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
|
| 22 |
+
elif image.shape[2] != 3:
|
| 23 |
+
raise ValueError("Expected an image with 1, 3, or 4 channels.")
|
| 24 |
+
|
| 25 |
+
if image.dtype != np.uint8:
|
| 26 |
+
image = image.astype(np.float32)
|
| 27 |
+
if image.size:
|
| 28 |
+
max_value = float(np.nanmax(image))
|
| 29 |
+
if max_value <= 1:
|
| 30 |
+
image *= 255
|
| 31 |
+
elif max_value > 255:
|
| 32 |
+
image *= 255 / max_value
|
| 33 |
+
image = np.clip(image, 0, 255).astype(np.uint8)
|
| 34 |
+
|
| 35 |
+
return image
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _is_tiff_path(image_path: str | Path) -> bool:
|
| 39 |
+
return Path(image_path).suffix.lower() in TIFF_EXTENSIONS
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def cleaned_image_name(
|
| 43 |
+
image_path: str | Path,
|
| 44 |
+
used_names: set[str] | None = None,
|
| 45 |
+
) -> str:
|
| 46 |
+
image_path = Path(image_path)
|
| 47 |
+
extension = image_path.suffix or ".png"
|
| 48 |
+
output_name = f"{image_path.stem}_cleaned{extension}"
|
| 49 |
+
|
| 50 |
+
if used_names is None:
|
| 51 |
+
return output_name
|
| 52 |
+
|
| 53 |
+
suffix = 2
|
| 54 |
+
while output_name in used_names:
|
| 55 |
+
output_name = f"{image_path.stem}_cleaned_{suffix}{extension}"
|
| 56 |
+
suffix += 1
|
| 57 |
+
|
| 58 |
+
used_names.add(output_name)
|
| 59 |
+
return output_name
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _select_tiff_plane(image: np.ndarray) -> np.ndarray:
|
| 63 |
+
image = np.asarray(image)
|
| 64 |
+
|
| 65 |
+
while image.ndim > 2 and 1 in image.shape:
|
| 66 |
+
image = np.squeeze(image)
|
| 67 |
+
|
| 68 |
+
if image.ndim == 2:
|
| 69 |
+
return image
|
| 70 |
+
|
| 71 |
+
if image.ndim == 3:
|
| 72 |
+
if image.shape[-1] in {1, 3, 4}:
|
| 73 |
+
return image
|
| 74 |
+
if image.shape[0] in {1, 3, 4}:
|
| 75 |
+
return np.moveaxis(image, 0, -1)
|
| 76 |
+
|
| 77 |
+
raise ValueError("Expected a 2D grayscale or RGB TIFF image.")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def read_image_rgb(image_path: str | Path) -> np.ndarray:
|
| 81 |
+
image_path = Path(image_path)
|
| 82 |
+
|
| 83 |
+
if _is_tiff_path(image_path):
|
| 84 |
+
try:
|
| 85 |
+
import tifffile
|
| 86 |
+
except ImportError as exc:
|
| 87 |
+
raise ImportError(
|
| 88 |
+
"Reading TIFF images requires the tifffile package."
|
| 89 |
+
) from exc
|
| 90 |
+
|
| 91 |
+
image = tifffile.imread(image_path)
|
| 92 |
+
return _as_rgb_uint8(_select_tiff_plane(image))
|
| 93 |
+
|
| 94 |
+
image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
|
| 95 |
+
if image is None:
|
| 96 |
+
raise FileNotFoundError(f"Could not read image: {image_path}")
|
| 97 |
+
|
| 98 |
+
if image.ndim == 3 and image.shape[2] == 3:
|
| 99 |
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 100 |
+
elif image.ndim == 3 and image.shape[2] == 4:
|
| 101 |
+
image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)
|
| 102 |
+
|
| 103 |
+
return _as_rgb_uint8(image)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def write_image_rgb(image_path: str | Path, image: np.ndarray) -> None:
|
| 107 |
+
image_path = Path(image_path)
|
| 108 |
+
image = _as_rgb_uint8(image)
|
| 109 |
+
|
| 110 |
+
if _is_tiff_path(image_path):
|
| 111 |
+
try:
|
| 112 |
+
import tifffile
|
| 113 |
+
except ImportError as exc:
|
| 114 |
+
raise ImportError(
|
| 115 |
+
"Writing TIFF images requires the tifffile package."
|
| 116 |
+
) from exc
|
| 117 |
+
|
| 118 |
+
tifffile.imwrite(image_path, image)
|
| 119 |
+
return
|
| 120 |
+
|
| 121 |
+
image_bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
| 122 |
+
|
| 123 |
+
if not cv2.imwrite(str(image_path), image_bgr):
|
| 124 |
+
raise OSError(f"Could not write image: {image_path}")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def create_debris_mask(image: np.ndarray) -> np.ndarray:
|
| 128 |
+
image = _as_rgb_uint8(image)
|
| 129 |
+
hsv = cv2.cvtColor(image, cv2.COLOR_RGB2HSV)
|
| 130 |
+
_, saturation, value = cv2.split(hsv)
|
| 131 |
+
|
| 132 |
+
debris_mask = ((value < 60) & (saturation < 50)).astype(np.uint8) * 255
|
| 133 |
+
|
| 134 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| 135 |
+
debris_mask = cv2.morphologyEx(debris_mask, cv2.MORPH_OPEN, kernel)
|
| 136 |
+
return cv2.dilate(debris_mask, kernel)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def clean_image(image: np.ndarray | None) -> np.ndarray:
|
| 140 |
+
if image is None:
|
| 141 |
+
raise ValueError("An input image is required.")
|
| 142 |
+
|
| 143 |
+
image = _as_rgb_uint8(image)
|
| 144 |
+
debris_mask = create_debris_mask(image)
|
| 145 |
+
return cv2.inpaint(image, debris_mask, 3, cv2.INPAINT_NS)
|
create_test_images.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
from skimage import io
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def add_debris(img, n_debris=40, seed=None):
|
| 7 |
+
rng = np.random.default_rng(seed)
|
| 8 |
+
h, w = img.shape[:2]
|
| 9 |
+
out = img.copy()
|
| 10 |
+
mask = np.zeros((h, w), dtype=np.uint8)
|
| 11 |
+
|
| 12 |
+
for _ in range(n_debris):
|
| 13 |
+
cx = rng.integers(10, w - 10)
|
| 14 |
+
cy = rng.integers(10, h - 10)
|
| 15 |
+
radius = int(rng.choice(np.concatenate([
|
| 16 |
+
rng.integers(20, 40, size=6), # small specks
|
| 17 |
+
rng.integers(40, 60, size=3), # medium
|
| 18 |
+
rng.integers(100, 120, size=1), # occasional big chunk
|
| 19 |
+
])))
|
| 20 |
+
|
| 21 |
+
# irregular blob via random polygon
|
| 22 |
+
n_pts = rng.integers(6, 14)
|
| 23 |
+
angles = np.linspace(0, 2 * np.pi, n_pts, endpoint=False)
|
| 24 |
+
angles += rng.uniform(-0.2, 0.2, n_pts)
|
| 25 |
+
radii = radius * rng.uniform(0.5, 1.4, n_pts)
|
| 26 |
+
pts = np.stack([
|
| 27 |
+
cx + radii * np.cos(angles),
|
| 28 |
+
cy + radii * np.sin(angles)
|
| 29 |
+
], axis=1).astype(np.int32)
|
| 30 |
+
|
| 31 |
+
cv2.fillPoly(out, [pts], color=(10, 10, 10))
|
| 32 |
+
cv2.fillPoly(mask, [pts], color=255)
|
| 33 |
+
|
| 34 |
+
return out, mask
|
| 35 |
+
img = io.imread("data/original.tiff")[:, :, :3]
|
| 36 |
+
|
| 37 |
+
for i in range(10):
|
| 38 |
+
dirty, mask = add_debris(img, n_debris=60)
|
| 39 |
+
io.imsave(f"data/test/dirty{i}.tiff", dirty)
|
data/A-4.ome.tiff
ADDED
|
|
Git LFS Details
|
data/dirty.png
ADDED
|
Git LFS Details
|
data/dirty.tiff
ADDED
|
|
Git LFS Details
|
data/fixed_ns.png
ADDED
|
Git LFS Details
|
data/fixed_telea.png
ADDED
|
Git LFS Details
|
data/original.png
ADDED
|
Git LFS Details
|
data/original.tiff
ADDED
|
|
Git LFS Details
|
data/original2.png
ADDED
|
Git LFS Details
|
main.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import cv2
|
| 5 |
+
|
| 6 |
+
from cleaning import (
|
| 7 |
+
clean_image,
|
| 8 |
+
cleaned_image_name,
|
| 9 |
+
create_debris_mask,
|
| 10 |
+
read_image_rgb,
|
| 11 |
+
write_image_rgb,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main(
|
| 16 |
+
image_paths: list[str],
|
| 17 |
+
output_dir: str | Path = "out",
|
| 18 |
+
) -> None:
|
| 19 |
+
output_dir = Path(output_dir)
|
| 20 |
+
cleaned_dir = output_dir / "cleaned"
|
| 21 |
+
masks_dir = output_dir / "masks"
|
| 22 |
+
cleaned_dir.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
masks_dir.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
for image_path_string in image_paths:
|
| 26 |
+
image_path = Path(image_path_string)
|
| 27 |
+
image_rgb = read_image_rgb(image_path)
|
| 28 |
+
debris_mask = create_debris_mask(image_rgb)
|
| 29 |
+
cleaned_rgb = clean_image(image_rgb)
|
| 30 |
+
|
| 31 |
+
cv2.imwrite(str(masks_dir / f"{image_path.stem}_mask.png"), debris_mask)
|
| 32 |
+
cleaned_path = cleaned_dir / cleaned_image_name(image_path)
|
| 33 |
+
write_image_rgb(cleaned_path, cleaned_rgb)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_args() -> argparse.Namespace:
|
| 37 |
+
parser = argparse.ArgumentParser(description="Clean debris from IHC images.")
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"images",
|
| 40 |
+
nargs="+",
|
| 41 |
+
help="Paths to one or more input images.",
|
| 42 |
+
)
|
| 43 |
+
parser.add_argument(
|
| 44 |
+
"--output-dir",
|
| 45 |
+
default="out",
|
| 46 |
+
help="Directory for cleaned images and masks (default: out).",
|
| 47 |
+
)
|
| 48 |
+
return parser.parse_args()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
args = parse_args()
|
| 53 |
+
main(args.images, args.output_dir)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "ihc-cleanup"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
requires-python = ">=3.12"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"anyio==4.13.0",
|
| 7 |
+
"appnope==0.1.4",
|
| 8 |
+
"argon2-cffi==25.1.0",
|
| 9 |
+
"argon2-cffi-bindings==25.1.0",
|
| 10 |
+
"arrow==1.4.0",
|
| 11 |
+
"asttokens==3.0.1",
|
| 12 |
+
"async-lru==2.3.0",
|
| 13 |
+
"attrs==26.1.0",
|
| 14 |
+
"babel==2.18.0",
|
| 15 |
+
"beautifulsoup4==4.15.0",
|
| 16 |
+
"bleach==6.4.0",
|
| 17 |
+
"certifi==2026.5.20",
|
| 18 |
+
"cffi==2.0.0",
|
| 19 |
+
"charset-normalizer==3.4.7",
|
| 20 |
+
"comm==0.2.3",
|
| 21 |
+
"contourpy==1.3.3",
|
| 22 |
+
"cycler==0.12.1",
|
| 23 |
+
"debugpy==1.8.21",
|
| 24 |
+
"decorator==5.3.1",
|
| 25 |
+
"defusedxml==0.7.1",
|
| 26 |
+
"executing==2.2.1",
|
| 27 |
+
"fastjsonschema==2.21.2",
|
| 28 |
+
"fonttools==4.63.0",
|
| 29 |
+
"fqdn==1.5.1",
|
| 30 |
+
"gradio>=5.0,<7",
|
| 31 |
+
"h11==0.16.0",
|
| 32 |
+
"httpcore==1.0.9",
|
| 33 |
+
"httpx==0.28.1",
|
| 34 |
+
"idna==3.18",
|
| 35 |
+
"imageio==2.37.3",
|
| 36 |
+
"ipykernel==7.3.0",
|
| 37 |
+
"ipython==9.14.1",
|
| 38 |
+
"ipython-pygments-lexers==1.1.1",
|
| 39 |
+
"ipywidgets==8.1.8",
|
| 40 |
+
"isoduration==20.11.0",
|
| 41 |
+
"jedi==0.20.0",
|
| 42 |
+
"jinja2==3.1.6",
|
| 43 |
+
"json5==0.14.0",
|
| 44 |
+
"jsonpointer==3.1.1",
|
| 45 |
+
"jsonschema==4.26.0",
|
| 46 |
+
"jsonschema-specifications==2025.9.1",
|
| 47 |
+
"jupyter==1.1.1",
|
| 48 |
+
"jupyter-client==8.9.1",
|
| 49 |
+
"jupyter-console==6.6.3",
|
| 50 |
+
"jupyter-core==5.9.1",
|
| 51 |
+
"jupyter-events==0.12.1",
|
| 52 |
+
"jupyter-lsp==2.3.1",
|
| 53 |
+
"jupyter-server==2.19.0",
|
| 54 |
+
"jupyter-server-terminals==0.5.4",
|
| 55 |
+
"jupyterlab==4.5.8",
|
| 56 |
+
"jupyterlab-pygments==0.3.0",
|
| 57 |
+
"jupyterlab-server==2.28.0",
|
| 58 |
+
"jupyterlab-widgets==3.0.16",
|
| 59 |
+
"kiwisolver==1.5.0",
|
| 60 |
+
"lark==1.3.1",
|
| 61 |
+
"lazy-loader==0.5",
|
| 62 |
+
"markupsafe==3.0.3",
|
| 63 |
+
"matplotlib==3.10.9",
|
| 64 |
+
"matplotlib-inline==0.2.2",
|
| 65 |
+
"mistune==3.2.1",
|
| 66 |
+
"nbclient==0.11.0",
|
| 67 |
+
"nbconvert==7.17.1",
|
| 68 |
+
"nbformat==5.10.4",
|
| 69 |
+
"nest-asyncio2==1.7.2",
|
| 70 |
+
"networkx==3.6.1",
|
| 71 |
+
"notebook==7.5.7",
|
| 72 |
+
"notebook-shim==0.2.4",
|
| 73 |
+
"numpy==2.4.6",
|
| 74 |
+
"opencv-python==4.13.0.92",
|
| 75 |
+
"packaging==26.2",
|
| 76 |
+
"pandocfilters==1.5.1",
|
| 77 |
+
"parso==0.8.7",
|
| 78 |
+
"pexpect==4.9.0",
|
| 79 |
+
"pillow==12.2.0",
|
| 80 |
+
"platformdirs==4.10.0",
|
| 81 |
+
"prometheus-client==0.25.0",
|
| 82 |
+
"prompt-toolkit==3.0.52",
|
| 83 |
+
"psutil==7.2.2",
|
| 84 |
+
"ptyprocess==0.7.0",
|
| 85 |
+
"pure-eval==0.2.3",
|
| 86 |
+
"pycparser==3.0",
|
| 87 |
+
"pygments==2.20.0",
|
| 88 |
+
"pyparsing==3.3.2",
|
| 89 |
+
"python-dateutil==2.9.0.post0",
|
| 90 |
+
"python-json-logger==4.1.0",
|
| 91 |
+
"pyyaml==6.0.3",
|
| 92 |
+
"pyzmq==27.1.0",
|
| 93 |
+
"referencing==0.37.0",
|
| 94 |
+
"requests==2.34.2",
|
| 95 |
+
"rfc3339-validator==0.1.4",
|
| 96 |
+
"rfc3986-validator==0.1.1",
|
| 97 |
+
"rfc3987-syntax==1.1.0",
|
| 98 |
+
"rpds-py==2026.5.1",
|
| 99 |
+
"scikit-image==0.26.0",
|
| 100 |
+
"scipy==1.17.1",
|
| 101 |
+
"send2trash==2.1.0",
|
| 102 |
+
"setuptools==82.0.1",
|
| 103 |
+
"six==1.17.0",
|
| 104 |
+
"soupsieve==2.8.4",
|
| 105 |
+
"stack-data==0.6.3",
|
| 106 |
+
"terminado==0.18.1",
|
| 107 |
+
"tifffile==2026.6.1",
|
| 108 |
+
"tinycss2==1.5.1",
|
| 109 |
+
"tornado==6.5.7",
|
| 110 |
+
"traitlets==5.15.1",
|
| 111 |
+
"typing-extensions==4.15.0",
|
| 112 |
+
"tzdata==2026.2",
|
| 113 |
+
"uri-template==1.3.0",
|
| 114 |
+
"urllib3==2.7.0",
|
| 115 |
+
"wcwidth==0.8.1",
|
| 116 |
+
"webcolors==25.10.0",
|
| 117 |
+
"webencodings==0.5.1",
|
| 118 |
+
"websocket-client==1.9.0",
|
| 119 |
+
"widgetsnbextension==4.0.15",
|
| 120 |
+
]
|
pyrefly.toml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project-includes = [
|
| 2 |
+
"**/*.py*",
|
| 3 |
+
"**/*.ipynb",
|
| 4 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==2.4.6
|
| 2 |
+
opencv-python-headless==4.13.0.92
|
| 3 |
+
tifffile==2026.6.1
|
test.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|