kuko6 commited on
Commit
0e6ebc7
·
1 Parent(s): f272af0

Add image cleanup app

Browse files
.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: Ihc Cleanup
3
- emoji: 👀
4
- colorFrom: yellow
5
  colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 1e59d801b7a8b04ec4711b1d511530865936c52a83d97fec57c6463223894e1d
  • Pointer size: 134 Bytes
  • Size of remote file: 157 MB
data/dirty.png ADDED

Git LFS Details

  • SHA256: c8e1bcc94f8b6b0f814d53fbc5ca4cd748afc52211affbe224bfb737a6eea1c4
  • Pointer size: 133 Bytes
  • Size of remote file: 46 MB
data/dirty.tiff ADDED

Git LFS Details

  • SHA256: acf828f44d52110910843305754ea31f7961fb04fe820314c72843d92464fe7a
  • Pointer size: 133 Bytes
  • Size of remote file: 89.2 MB
data/fixed_ns.png ADDED

Git LFS Details

  • SHA256: f589fa165600e04705c13f60bfcb3f7d2fefed2583334b51318d0fb8eb1174e0
  • Pointer size: 132 Bytes
  • Size of remote file: 1.64 MB
data/fixed_telea.png ADDED

Git LFS Details

  • SHA256: 5ae10225cf11d2ea65376219aa3764f34cb1ed681fba302b2dd8fa371aa6b9db
  • Pointer size: 132 Bytes
  • Size of remote file: 1.65 MB
data/original.png ADDED

Git LFS Details

  • SHA256: 85bef42bc0b532df2ae7ab0197e81acab38cc673bad67da31283c7feede07f8b
  • Pointer size: 132 Bytes
  • Size of remote file: 2.06 MB
data/original.tiff ADDED

Git LFS Details

  • SHA256: cade8c491fe1837189674421e7582ca45017239381ca2586791c5e70b0624c12
  • Pointer size: 133 Bytes
  • Size of remote file: 89.2 MB
data/original2.png ADDED

Git LFS Details

  • SHA256: 376bd7b89ec655eba361183427f0b0513c6ce31feb54bab0406ac8434be7d465
  • Pointer size: 132 Bytes
  • Size of remote file: 1.62 MB
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