From 2a9a59bf2c83371851e08c3f226ff439971741d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:22:57 +0200 Subject: [PATCH 01/42] feat: add tiling run_id --- configs/data/dataset.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/data/dataset.yaml b/configs/data/dataset.yaml index ef6cbe7..625d07d 100644 --- a/configs/data/dataset.yaml +++ b/configs/data/dataset.yaml @@ -12,6 +12,7 @@ dataset: train_test_split_run_id: "90d7c88418ae4b879c3febbb70e9e741" train_split_filename: "split_mapping/train_split.csv" test_split_filename: "split_mapping/test_split.csv" + tiling_run_id: "fdf7550a2004474f8c7a05dc0cf1fd86" exclusions: bad_slides: From bb6dc1bcbf2fada46970c710e6eef45adab23da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:25:19 +0200 Subject: [PATCH 02/42] feat: add configs for tile masks --- .../experiment/preprocessing/tile_masks_05mpp.yaml | 8 ++++++++ configs/preprocessing/tile_masks.yaml | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 configs/experiment/preprocessing/tile_masks_05mpp.yaml create mode 100644 configs/preprocessing/tile_masks.yaml diff --git a/configs/experiment/preprocessing/tile_masks_05mpp.yaml b/configs/experiment/preprocessing/tile_masks_05mpp.yaml new file mode 100644 index 0000000..fee1fa7 --- /dev/null +++ b/configs/experiment/preprocessing/tile_masks_05mpp.yaml @@ -0,0 +1,8 @@ +# @package _global_ + +defaults: + - /data: dataset + - _self_ + +slides_artifact_path: train_split/slides.parquet +tiles_artifact_path: train_split/tiles.parquet diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml new file mode 100644 index 0000000..550c07e --- /dev/null +++ b/configs/preprocessing/tile_masks.yaml @@ -0,0 +1,11 @@ +# @package _global_ + +slides_artifact_path: ??? +tiles_artifact_path: ??? + +max_concurrent: 8 +mlflow_artifact_path: tile_masks + +metadata: + run_name: Tile Masks ${dataset.name} + description: "Tile mask generation" From 8b5e5ac5e00045916287f84e6e0d39d6a30e0244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:27:09 +0200 Subject: [PATCH 03/42] feat: implement tile mask pipeline --- preprocessing/tile_masks.py | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 preprocessing/tile_masks.py diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py new file mode 100644 index 0000000..4c8cf85 --- /dev/null +++ b/preprocessing/tile_masks.py @@ -0,0 +1,83 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +import hydra +import mlflow +import pandas as pd +import pyvips +import ray +from omegaconf import DictConfig +from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.mlkit import autolog, with_cli_args +from rationai.mlkit.lightning.loggers import MLFlowLogger + + +@ray.remote(num_cpus=1, memory=3 * 1024**3) +def process_slide( + item: tuple[dict, pd.DataFrame], + output_dir: str, +) -> None: + slide, slide_tiles = item + slide_path = Path(slide["path"]) + + mask = tile_mask( + slide_tiles, + tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), + size=(slide["extent_x"], slide["extent_y"]), + ) + + width, height = mask.size + mask_bytes = mask.tobytes() + del mask + + write_big_tiff( + image=pyvips.Image.new_from_memory( + data=mask_bytes, width=width, height=height, bands=1, format="uchar" + ), + path=Path(output_dir, slide_path.with_suffix(".tiff").name), + mpp_x=slide["mpp_x"], + mpp_y=slide["mpp_y"], + ) + + +@with_cli_args(["+preprocessing=tile_masks"]) +@hydra.main(config_path="../configs", config_name="preprocessing", version_base=None) +@autolog +def main(config: DictConfig, logger: MLFlowLogger) -> None: + tiling_run_id = config.dataset.mlflow_artifacts.tiling_run_id + + slides = pd.read_parquet( + mlflow.artifacts.download_artifacts( + run_id=tiling_run_id, + artifact_path=config.slides_artifact_path, + ) + ) + tiles = pd.read_parquet( + mlflow.artifacts.download_artifacts( + run_id=tiling_run_id, + artifact_path=config.tiles_artifact_path, + ) + ) + + items = [ + (slide.to_dict(), tiles[tiles["slide_id"] == slide["id"]][["x", "y"]]) + for _, slide in slides.iterrows() + ] + + with TemporaryDirectory() as output_dir: + process_items( + items, + process_item=process_slide, + fn_kwargs={"output_dir": output_dir}, + max_concurrent=config.max_concurrent, + ) + + logger.log_artifacts( + local_dir=output_dir, artifact_path=config.mlflow_artifact_path + ) + + +if __name__ == "__main__": + ray.init() + main() + ray.shutdown() From 04283b2872fa5e9ee764a792896e1f72497d0292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:27:46 +0200 Subject: [PATCH 04/42] feat: add sumbission script for the tile masks --- scripts/submit_tile_masks.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scripts/submit_tile_masks.py diff --git a/scripts/submit_tile_masks.py b/scripts/submit_tile_masks.py new file mode 100644 index 0000000..1356a6a --- /dev/null +++ b/scripts/submit_tile_masks.py @@ -0,0 +1,18 @@ +from kube_jobs import storage, submit_job + + +submit_job( + job_name="tissue-classification-tile-masks", + username=..., + cpu=8, + memory="32Gi", + gpu=None, + public=False, + script=[ + "git clone https://gitlab.ics.muni.cz/rationai/digital-pathology/pathology/tissue-classification.git workdir", + "cd workdir", + "uv sync", + "uv run python -m preprocessing.tile_masks +experiment=...", + ], + storage=[storage.secure.DATA], +) From 654710a51eafecbc5ba8b1b830eb2c8edbf4b811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:35:49 +0200 Subject: [PATCH 05/42] fix: group by slides before the loop --- preprocessing/tile_masks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 4c8cf85..4ef0fde 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -59,8 +59,13 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ) ) + tiles_by_slide = { + slide_id: group[["x", "y"]] + for slide_id, group in tiles.groupby("slide_id") + } + items = [ - (slide.to_dict(), tiles[tiles["slide_id"] == slide["id"]][["x", "y"]]) + (slide.to_dict(), tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"]))) for _, slide in slides.iterrows() ] From 93eecb9814ab8791f7be217631ef555350768d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:44:07 +0200 Subject: [PATCH 06/42] fix: update submission script to use GitHub repository URL Co-Authored-By: Claude Sonnet 4.6 --- scripts/submit_tile_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/submit_tile_masks.py b/scripts/submit_tile_masks.py index 1356a6a..bb57a59 100644 --- a/scripts/submit_tile_masks.py +++ b/scripts/submit_tile_masks.py @@ -9,7 +9,7 @@ gpu=None, public=False, script=[ - "git clone https://gitlab.ics.muni.cz/rationai/digital-pathology/pathology/tissue-classification.git workdir", + "git clone https://github.com/RationAI/tissue-classification.git workdir", "cd workdir", "uv sync", "uv run python -m preprocessing.tile_masks +experiment=...", From 682a5d3cd0876971e8d1e737d31796e8b4acc07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 21:58:50 +0200 Subject: [PATCH 07/42] perf: load only required columns from tiles parquet Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 4ef0fde..cf3d0e3 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -56,11 +56,12 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: mlflow.artifacts.download_artifacts( run_id=tiling_run_id, artifact_path=config.tiles_artifact_path, - ) + ), + columns=["slide_id", "x", "y"], ) tiles_by_slide = { - slide_id: group[["x", "y"]] + slide_id: group.drop(columns="slide_id") for slide_id, group in tiles.groupby("slide_id") } From cb29d16aa99326a5cba5624f65f23315d3c3374a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:14:39 +0200 Subject: [PATCH 08/42] perf: replace PIL tile_mask with vectorized numpy implementation Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 50 ++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index cf3d0e3..02851d0 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -3,15 +3,55 @@ import hydra import mlflow +import numpy as np import pandas as pd import pyvips import ray from omegaconf import DictConfig -from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.masks import process_items, write_big_tiff from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger +def _draw_tile_outlines( + tiles: pd.DataFrame, + tile_extent: tuple[int, int], + size: tuple[int, int], + outline_width: int = 4, +) -> np.ndarray: + tw, th = tile_extent + width, height = size + ow = outline_width + mask = np.zeros((height, width), dtype=np.uint8) + + xs = tiles["x"].to_numpy() + ys = tiles["y"].to_numpy() + + dc = np.arange(tw) + dr = np.arange(th) + dw = np.arange(ow) + + # Horizontal borders (top and bottom) + h_rows_top = (ys[:, None, None] + dw[None, :, None]).ravel() + h_rows_bot = (ys[:, None, None] + (th - ow) + dw[None, :, None]).ravel() + h_cols = (xs[:, None, None] + dc[None, None, :]).ravel() + h_cols = np.tile(h_cols.reshape(len(xs), 1, tw), (1, ow, 1)).ravel() + mask[h_rows_top, h_cols] = 255 + mask[h_rows_bot, h_cols] = 255 + + # Vertical borders (left and right) + v_rows = (ys[:, None, None] + dr[None, :, None]).ravel() + v_rows = np.tile(v_rows.reshape(len(xs), th, 1), (1, 1, ow)).ravel() + v_cols_left = (xs[:, None, None] + dw[None, None, :]).ravel() + v_cols_right = (xs[:, None, None] + (tw - ow) + dw[None, None, :]).ravel() + v_cols_left = np.tile(v_cols_left.reshape(len(xs), 1, ow), (1, th, 1)).ravel() + v_cols_right = np.tile(v_cols_right.reshape(len(xs), 1, ow), (1, th, 1)).ravel() + mask[v_rows, v_cols_left] = 255 + mask[v_rows, v_cols_right] = 255 + + return mask + + @ray.remote(num_cpus=1, memory=3 * 1024**3) def process_slide( item: tuple[dict, pd.DataFrame], @@ -20,19 +60,17 @@ def process_slide( slide, slide_tiles = item slide_path = Path(slide["path"]) - mask = tile_mask( + mask = _draw_tile_outlines( slide_tiles, tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), size=(slide["extent_x"], slide["extent_y"]), ) - width, height = mask.size - mask_bytes = mask.tobytes() - del mask + height, width = mask.shape write_big_tiff( image=pyvips.Image.new_from_memory( - data=mask_bytes, width=width, height=height, bands=1, format="uchar" + data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" ), path=Path(output_dir, slide_path.with_suffix(".tiff").name), mpp_x=slide["mpp_x"], From 7d2e4759df6088afd909f8ba264d609fd204c1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:19:05 +0200 Subject: [PATCH 09/42] fix: correct shape mismatch in vectorized tile outline drawing Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 02851d0..edf3d7d 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -27,27 +27,17 @@ def _draw_tile_outlines( xs = tiles["x"].to_numpy() ys = tiles["y"].to_numpy() - dc = np.arange(tw) - dr = np.arange(th) - dw = np.arange(ow) - - # Horizontal borders (top and bottom) - h_rows_top = (ys[:, None, None] + dw[None, :, None]).ravel() - h_rows_bot = (ys[:, None, None] + (th - ow) + dw[None, :, None]).ravel() - h_cols = (xs[:, None, None] + dc[None, None, :]).ravel() - h_cols = np.tile(h_cols.reshape(len(xs), 1, tw), (1, ow, 1)).ravel() - mask[h_rows_top, h_cols] = 255 - mask[h_rows_bot, h_cols] = 255 - - # Vertical borders (left and right) - v_rows = (ys[:, None, None] + dr[None, :, None]).ravel() - v_rows = np.tile(v_rows.reshape(len(xs), th, 1), (1, 1, ow)).ravel() - v_cols_left = (xs[:, None, None] + dw[None, None, :]).ravel() - v_cols_right = (xs[:, None, None] + (tw - ow) + dw[None, None, :]).ravel() - v_cols_left = np.tile(v_cols_left.reshape(len(xs), 1, ow), (1, th, 1)).ravel() - v_cols_right = np.tile(v_cols_right.reshape(len(xs), 1, ow), (1, th, 1)).ravel() - mask[v_rows, v_cols_left] = 255 - mask[v_rows, v_cols_right] = 255 + # Precompute shared index spans — O(n × tile_size) each + h_cols = (xs[:, None] + np.arange(tw)[None, :]).ravel() # (n × tw,) + v_rows = (ys[:, None] + np.arange(th)[None, :]).ravel() # (n × th,) + + for dy in range(ow): + mask[np.repeat(ys + dy, tw), h_cols] = 255 # top border + mask[np.repeat(ys + th - ow + dy, tw), h_cols] = 255 # bottom border + + for dx in range(ow): + mask[v_rows, np.repeat(xs + dx, th)] = 255 # left border + mask[v_rows, np.repeat(xs + tw - ow + dx, th)] = 255 # right border return mask From b94b3f850db7b71df2e8c126744c34d4b3012924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:23:06 +0200 Subject: [PATCH 10/42] fix: clip out-of-bounds tile pixels in vectorized outline drawing Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index edf3d7d..8a6fb1a 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -31,13 +31,20 @@ def _draw_tile_outlines( h_cols = (xs[:, None] + np.arange(tw)[None, :]).ravel() # (n × tw,) v_rows = (ys[:, None] + np.arange(th)[None, :]).ravel() # (n × th,) + h_cols_in = (h_cols >= 0) & (h_cols < width) + v_rows_in = (v_rows >= 0) & (v_rows < height) + for dy in range(ow): - mask[np.repeat(ys + dy, tw), h_cols] = 255 # top border - mask[np.repeat(ys + th - ow + dy, tw), h_cols] = 255 # bottom border + for ry in (ys + dy, ys + th - ow + dy): + rows = np.repeat(ry, tw) + ok = h_cols_in & (rows >= 0) & (rows < height) + mask[rows[ok], h_cols[ok]] = 255 for dx in range(ow): - mask[v_rows, np.repeat(xs + dx, th)] = 255 # left border - mask[v_rows, np.repeat(xs + tw - ow + dx, th)] = 255 # right border + for cx in (xs + dx, xs + tw - ow + dx): + cols = np.repeat(cx, th) + ok = v_rows_in & (cols >= 0) & (cols < width) + mask[v_rows[ok], cols[ok]] = 255 return mask From ef4e51ec263cb3dee3eed312a7400ab3d0c4c822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:30:02 +0200 Subject: [PATCH 11/42] perf: add configurable downsampling to tile mask generation Co-Authored-By: Claude Sonnet 4.6 --- .../preprocessing/tile_masks_05mpp.yaml | 1 + configs/preprocessing/tile_masks.yaml | 5 +++- preprocessing/tile_masks.py | 27 ++++++++++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/configs/experiment/preprocessing/tile_masks_05mpp.yaml b/configs/experiment/preprocessing/tile_masks_05mpp.yaml index fee1fa7..b353855 100644 --- a/configs/experiment/preprocessing/tile_masks_05mpp.yaml +++ b/configs/experiment/preprocessing/tile_masks_05mpp.yaml @@ -6,3 +6,4 @@ defaults: slides_artifact_path: train_split/slides.parquet tiles_artifact_path: train_split/tiles.parquet +downsample: 4 diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 550c07e..2a4d8d2 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -2,10 +2,13 @@ slides_artifact_path: ??? tiles_artifact_path: ??? +downsample: 1 max_concurrent: 8 mlflow_artifact_path: tile_masks metadata: run_name: Tile Masks ${dataset.name} - description: "Tile mask generation" + description: "Tile mask generation (downsample ${downsample}x)" + hyperparams: + downsample: ${downsample} diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 8a6fb1a..5282a5c 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -53,14 +53,26 @@ def _draw_tile_outlines( def process_slide( item: tuple[dict, pd.DataFrame], output_dir: str, + downsample: int, ) -> None: slide, slide_tiles = item slide_path = Path(slide["path"]) + scaled_tiles = slide_tiles.assign( + x=slide_tiles["x"] // downsample, + y=slide_tiles["y"] // downsample, + ) + mask = _draw_tile_outlines( - slide_tiles, - tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), - size=(slide["extent_x"], slide["extent_y"]), + scaled_tiles, + tile_extent=( + slide["tile_extent_x"] // downsample, + slide["tile_extent_y"] // downsample, + ), + size=( + slide["extent_x"] // downsample, + slide["extent_y"] // downsample, + ), ) height, width = mask.shape @@ -70,8 +82,8 @@ def process_slide( data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" ), path=Path(output_dir, slide_path.with_suffix(".tiff").name), - mpp_x=slide["mpp_x"], - mpp_y=slide["mpp_y"], + mpp_x=slide["mpp_x"] * downsample, + mpp_y=slide["mpp_y"] * downsample, ) @@ -109,7 +121,10 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: process_items( items, process_item=process_slide, - fn_kwargs={"output_dir": output_dir}, + fn_kwargs={ + "output_dir": output_dir, + "downsample": config.downsample, + }, max_concurrent=config.max_concurrent, ) From f96c636492c3f6c56af0f9f82be865812f427e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:33:54 +0200 Subject: [PATCH 12/42] style: drop comments with ambiguous unicode multiplication sign Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 5282a5c..eed8895 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -27,9 +27,8 @@ def _draw_tile_outlines( xs = tiles["x"].to_numpy() ys = tiles["y"].to_numpy() - # Precompute shared index spans — O(n × tile_size) each - h_cols = (xs[:, None] + np.arange(tw)[None, :]).ravel() # (n × tw,) - v_rows = (ys[:, None] + np.arange(th)[None, :]).ravel() # (n × th,) + h_cols = (xs[:, None] + np.arange(tw)[None, :]).ravel() + v_rows = (ys[:, None] + np.arange(th)[None, :]).ravel() h_cols_in = (h_cols >= 0) & (h_cols < width) v_rows_in = (v_rows >= 0) & (v_rows < height) @@ -113,7 +112,10 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: } items = [ - (slide.to_dict(), tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"]))) + ( + slide.to_dict(), + tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"])), + ) for _, slide in slides.iterrows() ] From 8504b840c694eb84717a733a16ac152dda67f9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 22 Apr 2026 22:42:57 +0200 Subject: [PATCH 13/42] chore: increase tile mask downsample to 8x to avoid OOM Co-Authored-By: Claude Sonnet 4.6 --- configs/experiment/preprocessing/tile_masks_05mpp.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/experiment/preprocessing/tile_masks_05mpp.yaml b/configs/experiment/preprocessing/tile_masks_05mpp.yaml index b353855..af89e15 100644 --- a/configs/experiment/preprocessing/tile_masks_05mpp.yaml +++ b/configs/experiment/preprocessing/tile_masks_05mpp.yaml @@ -6,4 +6,4 @@ defaults: slides_artifact_path: train_split/slides.parquet tiles_artifact_path: train_split/tiles.parquet -downsample: 4 +downsample: 8 From 6ad1b6221774169c05db4c2b237e0508b96bcf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sat, 25 Apr 2026 00:43:10 +0200 Subject: [PATCH 14/42] fix: tile boundaries --- preprocessing/tile_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index eed8895..ad28e95 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -21,7 +21,7 @@ def _draw_tile_outlines( ) -> np.ndarray: tw, th = tile_extent width, height = size - ow = outline_width + ow = min(outline_width, tw // 2, th // 2) mask = np.zeros((height, width), dtype=np.uint8) xs = tiles["x"].to_numpy() From d2d1e7cae3dee26631aa349b48a642c6d28645cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sat, 25 Apr 2026 00:46:03 +0200 Subject: [PATCH 15/42] fix: set default downsample to 8 in base config, remove redundant override Co-Authored-By: Claude Sonnet 4.6 --- configs/experiment/preprocessing/tile_masks_05mpp.yaml | 1 - configs/preprocessing/tile_masks.yaml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/configs/experiment/preprocessing/tile_masks_05mpp.yaml b/configs/experiment/preprocessing/tile_masks_05mpp.yaml index af89e15..fee1fa7 100644 --- a/configs/experiment/preprocessing/tile_masks_05mpp.yaml +++ b/configs/experiment/preprocessing/tile_masks_05mpp.yaml @@ -6,4 +6,3 @@ defaults: slides_artifact_path: train_split/slides.parquet tiles_artifact_path: train_split/tiles.parquet -downsample: 8 diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 2a4d8d2..52f5f36 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -2,7 +2,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? -downsample: 1 +downsample: 8 max_concurrent: 8 mlflow_artifact_path: tile_masks From fc51a29ca73fd9414fd755b75d1014176b5cdc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sat, 25 Apr 2026 00:46:53 +0200 Subject: [PATCH 16/42] fix: use slide id as output filename to prevent cross-source collisions Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index ad28e95..8e689c0 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -55,7 +55,6 @@ def process_slide( downsample: int, ) -> None: slide, slide_tiles = item - slide_path = Path(slide["path"]) scaled_tiles = slide_tiles.assign( x=slide_tiles["x"] // downsample, @@ -80,7 +79,7 @@ def process_slide( image=pyvips.Image.new_from_memory( data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" ), - path=Path(output_dir, slide_path.with_suffix(".tiff").name), + path=Path(output_dir, f"{slide['id']}.tiff"), mpp_x=slide["mpp_x"] * downsample, mpp_y=slide["mpp_y"] * downsample, ) From bf6ae43c165a0e1508b222b948f6de3d5fa3e345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sat, 25 Apr 2026 15:45:39 +0200 Subject: [PATCH 17/42] fix: mypy --- preprocessing/tile_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 8e689c0..19875b1 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -50,7 +50,7 @@ def _draw_tile_outlines( @ray.remote(num_cpus=1, memory=3 * 1024**3) def process_slide( - item: tuple[dict, pd.DataFrame], + item: tuple[dict[str, object], pd.DataFrame], output_dir: str, downsample: int, ) -> None: From b32c6b6b3d2b2a254c7eef970323215ff16552be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Mon, 27 Apr 2026 17:07:32 +0200 Subject: [PATCH 18/42] fix(tile_masks): resolve mypy type errors Use dict[str, Any] instead of dict[str, object] so arithmetic on slide fields type-checks, and add an explicit annotation on items so process_items can infer its type parameter. --- preprocessing/tile_masks.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 19875b1..25a419b 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,5 +1,6 @@ from pathlib import Path from tempfile import TemporaryDirectory +from typing import Any import hydra import mlflow @@ -50,7 +51,7 @@ def _draw_tile_outlines( @ray.remote(num_cpus=1, memory=3 * 1024**3) def process_slide( - item: tuple[dict[str, object], pd.DataFrame], + item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, downsample: int, ) -> None: @@ -110,9 +111,9 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: for slide_id, group in tiles.groupby("slide_id") } - items = [ + items: list[tuple[dict[str, Any], pd.DataFrame]] = [ ( - slide.to_dict(), + {str(k): v for k, v in slide.to_dict().items()}, tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"])), ) for _, slide in slides.iterrows() From de381f3401ccecb5da9be6441cbafa2ec5e8430f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Tue, 28 Apr 2026 10:29:00 +0200 Subject: [PATCH 19/42] refactor(tile_masks): use rationai.masks.tile_mask and name outputs by WSI stem Replace the local numpy-based outline drawing with the library's tile_mask helper, and name output TIFFs by the WSI stem so masks map directly to their source slides. Co-Authored-By: Claude Opus 4.7 --- preprocessing/tile_masks.py | 44 ++++--------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 25a419b..780d054 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -4,51 +4,15 @@ import hydra import mlflow -import numpy as np import pandas as pd import pyvips import ray from omegaconf import DictConfig -from rationai.masks import process_items, write_big_tiff +from rationai.masks import process_items, tile_mask, write_big_tiff from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger -def _draw_tile_outlines( - tiles: pd.DataFrame, - tile_extent: tuple[int, int], - size: tuple[int, int], - outline_width: int = 4, -) -> np.ndarray: - tw, th = tile_extent - width, height = size - ow = min(outline_width, tw // 2, th // 2) - mask = np.zeros((height, width), dtype=np.uint8) - - xs = tiles["x"].to_numpy() - ys = tiles["y"].to_numpy() - - h_cols = (xs[:, None] + np.arange(tw)[None, :]).ravel() - v_rows = (ys[:, None] + np.arange(th)[None, :]).ravel() - - h_cols_in = (h_cols >= 0) & (h_cols < width) - v_rows_in = (v_rows >= 0) & (v_rows < height) - - for dy in range(ow): - for ry in (ys + dy, ys + th - ow + dy): - rows = np.repeat(ry, tw) - ok = h_cols_in & (rows >= 0) & (rows < height) - mask[rows[ok], h_cols[ok]] = 255 - - for dx in range(ow): - for cx in (xs + dx, xs + tw - ow + dx): - cols = np.repeat(cx, th) - ok = v_rows_in & (cols >= 0) & (cols < width) - mask[v_rows[ok], cols[ok]] = 255 - - return mask - - @ray.remote(num_cpus=1, memory=3 * 1024**3) def process_slide( item: tuple[dict[str, Any], pd.DataFrame], @@ -62,7 +26,7 @@ def process_slide( y=slide_tiles["y"] // downsample, ) - mask = _draw_tile_outlines( + mask = tile_mask( scaled_tiles, tile_extent=( slide["tile_extent_x"] // downsample, @@ -74,13 +38,13 @@ def process_slide( ), ) - height, width = mask.shape + width, height = mask.size write_big_tiff( image=pyvips.Image.new_from_memory( data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" ), - path=Path(output_dir, f"{slide['id']}.tiff"), + path=Path(output_dir, f"{Path(slide['wsi_path']).stem}.tiff"), mpp_x=slide["mpp_x"] * downsample, mpp_y=slide["mpp_y"] * downsample, ) From 1f6f1d9abf83a27cc32e9d406abd90043a3b7779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Tue, 28 Apr 2026 10:33:27 +0200 Subject: [PATCH 20/42] fix(tile_masks): use 'path' column for WSI filename The tiling run's slides parquet uses 'path' (from read_slides), not 'wsi_path' which only exists upstream in wsi_mapping output. Co-Authored-By: Claude Opus 4.7 --- preprocessing/tile_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 780d054..3348ae7 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -44,7 +44,7 @@ def process_slide( image=pyvips.Image.new_from_memory( data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" ), - path=Path(output_dir, f"{Path(slide['wsi_path']).stem}.tiff"), + path=Path(output_dir, f"{Path(slide['path']).stem}.tiff"), mpp_x=slide["mpp_x"] * downsample, mpp_y=slide["mpp_y"] * downsample, ) From 979d4ead16b1387d94d7d00e63a01e69ce9ed7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Tue, 28 Apr 2026 10:36:57 +0200 Subject: [PATCH 21/42] fix(tile_masks): ceil canvas size to avoid clipping edge tiles Floor on extent_x/y could shrink the canvas by up to downsample-1 px, clipping the rightmost/bottommost tile outline. Use ceil for size while keeping floor for tile_extent and tile coordinates so outlines still tile cleanly at the scaled stride. Co-Authored-By: Claude Opus 4.7 --- preprocessing/tile_masks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 3348ae7..e58c545 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,3 +1,4 @@ +from math import ceil from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -33,8 +34,8 @@ def process_slide( slide["tile_extent_y"] // downsample, ), size=( - slide["extent_x"] // downsample, - slide["extent_y"] // downsample, + ceil(slide["extent_x"] / downsample), + ceil(slide["extent_y"] / downsample), ), ) From 97f3951b0ccc878958a79a0e2f505377ce425224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 15:17:21 +0200 Subject: [PATCH 22/42] fix: use stride as the box size --- preprocessing/tile_masks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index e58c545..2f70c38 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -30,8 +30,8 @@ def process_slide( mask = tile_mask( scaled_tiles, tile_extent=( - slide["tile_extent_x"] // downsample, - slide["tile_extent_y"] // downsample, + slide["stride_x"] // downsample, + slide["stride_y"] // downsample, ), size=( ceil(slide["extent_x"] / downsample), From a302ed74b8356d7d1a57d4d5a4317621b5c87873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 15:29:19 +0200 Subject: [PATCH 23/42] fix: tile masks allignment --- preprocessing/tile_masks.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 2f70c38..75cf291 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,4 +1,3 @@ -from math import ceil from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -22,29 +21,19 @@ def process_slide( ) -> None: slide, slide_tiles = item - scaled_tiles = slide_tiles.assign( - x=slide_tiles["x"] // downsample, - y=slide_tiles["y"] // downsample, - ) - mask = tile_mask( - scaled_tiles, - tile_extent=( - slide["stride_x"] // downsample, - slide["stride_y"] // downsample, - ), - size=( - ceil(slide["extent_x"] / downsample), - ceil(slide["extent_y"] / downsample), - ), + slide_tiles, + tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), + size=(slide["extent_x"], slide["extent_y"]), ) width, height = mask.size + vips_image = pyvips.Image.new_from_memory( + data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" + ).resize(1.0 / downsample) write_big_tiff( - image=pyvips.Image.new_from_memory( - data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" - ), + image=vips_image, path=Path(output_dir, f"{Path(slide['path']).stem}.tiff"), mpp_x=slide["mpp_x"] * downsample, mpp_y=slide["mpp_y"] * downsample, From a0f1011f853cf68ae39c089afc888d9f39d61d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 15:38:56 +0200 Subject: [PATCH 24/42] feat: use ScalarMaskBuilder --- preprocessing/tile_masks.py | 39 +++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 75cf291..4eba452 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -5,10 +5,11 @@ import hydra import mlflow import pandas as pd -import pyvips import ray +import torch from omegaconf import DictConfig -from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.masks import process_items +from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger @@ -17,27 +18,26 @@ def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, - downsample: int, ) -> None: slide, slide_tiles = item - mask = tile_mask( - slide_tiles, - tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), - size=(slide["extent_x"], slide["extent_y"]), + builder = ScalarMaskBuilder( + save_dir=output_dir, + filename=Path(slide["path"]).stem, + extent_x=slide["extent_x"], + extent_y=slide["extent_y"], + mpp_x=slide["mpp_x"], + mpp_y=slide["mpp_y"], + extent_tile=slide["tile_extent_x"], + stride=slide["stride_x"], ) - width, height = mask.size - vips_image = pyvips.Image.new_from_memory( - data=mask.tobytes(), width=width, height=height, bands=1, format="uchar" - ).resize(1.0 / downsample) + xs = torch.tensor(slide_tiles["x"].values) + ys = torch.tensor(slide_tiles["y"].values) + data = torch.ones(len(slide_tiles), 1) - write_big_tiff( - image=vips_image, - path=Path(output_dir, f"{Path(slide['path']).stem}.tiff"), - mpp_x=slide["mpp_x"] * downsample, - mpp_y=slide["mpp_y"] * downsample, - ) + builder.update(data, xs, ys) + builder.save() @with_cli_args(["+preprocessing=tile_masks"]) @@ -77,10 +77,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: process_items( items, process_item=process_slide, - fn_kwargs={ - "output_dir": output_dir, - "downsample": config.downsample, - }, + fn_kwargs={"output_dir": output_dir}, max_concurrent=config.max_concurrent, ) From 1884819090c63bc3d2a51a49427e0aee48e4d86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 15:50:00 +0200 Subject: [PATCH 25/42] feat: compute also tile coverage with heatmap --- preprocessing/tile_masks.py | 56 +++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 4eba452..b00d0c4 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -4,11 +4,13 @@ import hydra import mlflow +import numpy as np import pandas as pd +import pyvips import ray import torch from omegaconf import DictConfig -from rationai.masks import process_items +from rationai.masks import process_items, tile_mask, write_big_tiff from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger @@ -18,33 +20,48 @@ def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, + tile_percentage_cols: list[str], ) -> None: slide, slide_tiles = item + filename = f"{Path(slide['path']).stem}.tiff" - builder = ScalarMaskBuilder( - save_dir=output_dir, - filename=Path(slide["path"]).stem, - extent_x=slide["extent_x"], - extent_y=slide["extent_y"], + for percentage_col in tile_percentage_cols: + builder = ScalarMaskBuilder( + save_dir=Path(output_dir, percentage_col), + filename=filename, + extent_x=slide["extent_x"], + extent_y=slide["extent_y"], + mpp_x=slide["mpp_x"], + mpp_y=slide["mpp_y"], + extent_tile=slide["tile_extent_x"], + stride=slide["stride_x"], + ) + builder.update( + data=torch.tensor(slide_tiles[percentage_col].values).unsqueeze(1), + xs=torch.tensor(slide_tiles["x"].values), + ys=torch.tensor(slide_tiles["y"].values), + ) + builder.save() + + mask = tile_mask( + slide_tiles, + tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), + size=(slide["extent_x"], slide["extent_y"]), + ) + write_big_tiff( + pyvips.Image.new_from_array(np.array(mask)), + Path(output_dir, "outlines", filename), mpp_x=slide["mpp_x"], mpp_y=slide["mpp_y"], - extent_tile=slide["tile_extent_x"], - stride=slide["stride_x"], ) - xs = torch.tensor(slide_tiles["x"].values) - ys = torch.tensor(slide_tiles["y"].values) - data = torch.ones(len(slide_tiles), 1) - - builder.update(data, xs, ys) - builder.save() - @with_cli_args(["+preprocessing=tile_masks"]) @hydra.main(config_path="../configs", config_name="preprocessing", version_base=None) @autolog def main(config: DictConfig, logger: MLFlowLogger) -> None: tiling_run_id = config.dataset.mlflow_artifacts.tiling_run_id + tile_percentage_cols: list[str] = list(config.tile_percentage_cols) slides = pd.read_parquet( mlflow.artifacts.download_artifacts( @@ -57,7 +74,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: run_id=tiling_run_id, artifact_path=config.tiles_artifact_path, ), - columns=["slide_id", "x", "y"], + columns=["slide_id", "x", "y", *tile_percentage_cols], ) tiles_by_slide = { @@ -68,7 +85,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: items: list[tuple[dict[str, Any], pd.DataFrame]] = [ ( {str(k): v for k, v in slide.to_dict().items()}, - tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"])), + tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols])), ) for _, slide in slides.iterrows() ] @@ -77,7 +94,10 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: process_items( items, process_item=process_slide, - fn_kwargs={"output_dir": output_dir}, + fn_kwargs={ + "output_dir": output_dir, + "tile_percentage_cols": tile_percentage_cols, + }, max_concurrent=config.max_concurrent, ) From 760e13a314762dca4d73b58b3adaeeac57bd83d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 15:55:54 +0200 Subject: [PATCH 26/42] feat(tile_masks): add per-class coverage heatmaps and configure submit script Co-Authored-By: Claude Sonnet 4.6 --- configs/experiment/preprocessing/tile_masks_05mpp.yaml | 8 ++++++++ configs/preprocessing/tile_masks.yaml | 6 ++---- preprocessing/tile_masks.py | 4 +++- scripts/submit_tile_masks.py | 6 +++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/configs/experiment/preprocessing/tile_masks_05mpp.yaml b/configs/experiment/preprocessing/tile_masks_05mpp.yaml index fee1fa7..8cfc5a9 100644 --- a/configs/experiment/preprocessing/tile_masks_05mpp.yaml +++ b/configs/experiment/preprocessing/tile_masks_05mpp.yaml @@ -6,3 +6,11 @@ defaults: slides_artifact_path: train_split/slides.parquet tiles_artifact_path: train_split/tiles.parquet +tile_percentage_cols: + - tile_coverage_Nerve + - tile_coverage_Blood + - tile_coverage_Connective-Tissue + - tile_coverage_Fat + - tile_coverage_Epithelium + - tile_coverage_Muscle + - tile_coverage_Other diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 52f5f36..10c1273 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -2,13 +2,11 @@ slides_artifact_path: ??? tiles_artifact_path: ??? -downsample: 8 +tile_percentage_cols: ??? max_concurrent: 8 mlflow_artifact_path: tile_masks metadata: run_name: Tile Masks ${dataset.name} - description: "Tile mask generation (downsample ${downsample}x)" - hyperparams: - downsample: ${downsample} + description: Tile mask generation diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index b00d0c4..888d8c0 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -85,7 +85,9 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: items: list[tuple[dict[str, Any], pd.DataFrame]] = [ ( {str(k): v for k, v in slide.to_dict().items()}, - tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols])), + tiles_by_slide.get( + slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols]) + ), ) for _, slide in slides.iterrows() ] diff --git a/scripts/submit_tile_masks.py b/scripts/submit_tile_masks.py index bb57a59..fc290ca 100644 --- a/scripts/submit_tile_masks.py +++ b/scripts/submit_tile_masks.py @@ -3,16 +3,16 @@ submit_job( job_name="tissue-classification-tile-masks", - username=..., + username="vcifka", cpu=8, memory="32Gi", gpu=None, public=False, script=[ - "git clone https://github.com/RationAI/tissue-classification.git workdir", + "git clone --branch feature/implement-tile-masks https://github.com/RationAI/tissue-classification.git workdir", "cd workdir", "uv sync", - "uv run python -m preprocessing.tile_masks +experiment=...", + "uv run python -m preprocessing.tile_masks +experiment=preprocessing/tile_masks_05mpp", ], storage=[storage.secure.DATA], ) From 9cff04d121a475d0dee24cbcc27bcd1134b70c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:01:23 +0200 Subject: [PATCH 27/42] fix: remove heatmap generation --- configs/preprocessing/tile_masks.yaml | 1 - preprocessing/tile_masks.py | 27 ++------------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 10c1273..2480b95 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -2,7 +2,6 @@ slides_artifact_path: ??? tiles_artifact_path: ??? -tile_percentage_cols: ??? max_concurrent: 8 mlflow_artifact_path: tile_masks diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 888d8c0..f39469d 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -8,10 +8,8 @@ import pandas as pd import pyvips import ray -import torch from omegaconf import DictConfig from rationai.masks import process_items, tile_mask, write_big_tiff -from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger @@ -20,29 +18,10 @@ def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, - tile_percentage_cols: list[str], ) -> None: slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" - for percentage_col in tile_percentage_cols: - builder = ScalarMaskBuilder( - save_dir=Path(output_dir, percentage_col), - filename=filename, - extent_x=slide["extent_x"], - extent_y=slide["extent_y"], - mpp_x=slide["mpp_x"], - mpp_y=slide["mpp_y"], - extent_tile=slide["tile_extent_x"], - stride=slide["stride_x"], - ) - builder.update( - data=torch.tensor(slide_tiles[percentage_col].values).unsqueeze(1), - xs=torch.tensor(slide_tiles["x"].values), - ys=torch.tensor(slide_tiles["y"].values), - ) - builder.save() - mask = tile_mask( slide_tiles, tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), @@ -61,7 +40,6 @@ def process_slide( @autolog def main(config: DictConfig, logger: MLFlowLogger) -> None: tiling_run_id = config.dataset.mlflow_artifacts.tiling_run_id - tile_percentage_cols: list[str] = list(config.tile_percentage_cols) slides = pd.read_parquet( mlflow.artifacts.download_artifacts( @@ -74,7 +52,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: run_id=tiling_run_id, artifact_path=config.tiles_artifact_path, ), - columns=["slide_id", "x", "y", *tile_percentage_cols], + columns=["slide_id", "x", "y"], ) tiles_by_slide = { @@ -86,7 +64,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ( {str(k): v for k, v in slide.to_dict().items()}, tiles_by_slide.get( - slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols]) + slide["id"], pd.DataFrame(columns=["x", "y"]) ), ) for _, slide in slides.iterrows() @@ -98,7 +76,6 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: process_item=process_slide, fn_kwargs={ "output_dir": output_dir, - "tile_percentage_cols": tile_percentage_cols, }, max_concurrent=config.max_concurrent, ) From c35e29d6f2772eda20065b1f351a85031a500bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:08:17 +0200 Subject: [PATCH 28/42] fix: downsample the images --- preprocessing/tile_masks.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index f39469d..b558729 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,3 +1,4 @@ +from math import gcd from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -22,16 +23,17 @@ def process_slide( slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" + d = gcd(int(slide["stride_x"]), int(slide["tile_extent_x"])) mask = tile_mask( - slide_tiles, - tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), - size=(slide["extent_x"], slide["extent_y"]), + slide_tiles.assign(x=slide_tiles["x"] // d, y=slide_tiles["y"] // d), + tile_extent=(slide["tile_extent_x"] // d, slide["tile_extent_y"] // d), + size=(slide["extent_x"] // d, slide["extent_y"] // d), ) write_big_tiff( pyvips.Image.new_from_array(np.array(mask)), Path(output_dir, "outlines", filename), - mpp_x=slide["mpp_x"], - mpp_y=slide["mpp_y"], + mpp_x=slide["mpp_x"] * d, + mpp_y=slide["mpp_y"] * d, ) From 3984431ae1b96a74e285b3ee9579e08fbb9eb86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:11:41 +0200 Subject: [PATCH 29/42] fix: remove directory --- preprocessing/tile_masks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index b558729..363c248 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -73,6 +73,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ] with TemporaryDirectory() as output_dir: + Path(output_dir, "outlines").mkdir() process_items( items, process_item=process_slide, From 98f435b145c2d20ceb79661bf9742cb98775d298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:21:07 +0200 Subject: [PATCH 30/42] fix: remove downsample and free memory bounds --- preprocessing/tile_masks.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 363c248..bdc3fb4 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,4 +1,3 @@ -from math import gcd from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -15,7 +14,7 @@ from rationai.mlkit.lightning.loggers import MLFlowLogger -@ray.remote(num_cpus=1, memory=3 * 1024**3) +@ray.remote def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, @@ -23,17 +22,16 @@ def process_slide( slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" - d = gcd(int(slide["stride_x"]), int(slide["tile_extent_x"])) mask = tile_mask( - slide_tiles.assign(x=slide_tiles["x"] // d, y=slide_tiles["y"] // d), - tile_extent=(slide["tile_extent_x"] // d, slide["tile_extent_y"] // d), - size=(slide["extent_x"] // d, slide["extent_y"] // d), + slide_tiles, + tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), + size=(slide["extent_x"], slide["extent_y"]), ) write_big_tiff( pyvips.Image.new_from_array(np.array(mask)), Path(output_dir, "outlines", filename), - mpp_x=slide["mpp_x"] * d, - mpp_y=slide["mpp_y"] * d, + mpp_x=slide["mpp_x"], + mpp_y=slide["mpp_y"], ) From 64501fc328a66b9a013d15cbb1714ed414936966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:25:46 +0200 Subject: [PATCH 31/42] feat: use numpy --- preprocessing/tile_masks.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index bdc3fb4..c3ed501 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -4,12 +4,11 @@ import hydra import mlflow -import numpy as np import pandas as pd import pyvips import ray from omegaconf import DictConfig -from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.masks import process_items, write_big_tiff from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger @@ -22,13 +21,13 @@ def process_slide( slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" - mask = tile_mask( - slide_tiles, - tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), - size=(slide["extent_x"], slide["extent_y"]), - ) + img = pyvips.Image.black(int(slide["extent_x"]), int(slide["extent_y"])) + te_x = int(slide["tile_extent_x"]) + te_y = int(slide["tile_extent_y"]) + for _, row in slide_tiles.iterrows(): + img.draw_rect([255], int(row["x"]), int(row["y"]), te_x, te_y, fill=False) write_big_tiff( - pyvips.Image.new_from_array(np.array(mask)), + img, Path(output_dir, "outlines", filename), mpp_x=slide["mpp_x"], mpp_y=slide["mpp_y"], From 67f0b2d201d7436330420f226d120316d317c2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:32:02 +0200 Subject: [PATCH 32/42] fix: lower concurrency --- configs/preprocessing/tile_masks.yaml | 2 +- preprocessing/tile_masks.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 2480b95..c3849d2 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -3,7 +3,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? -max_concurrent: 8 +max_concurrent: 1 mlflow_artifact_path: tile_masks metadata: diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index c3ed501..bdc3fb4 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -4,11 +4,12 @@ import hydra import mlflow +import numpy as np import pandas as pd import pyvips import ray from omegaconf import DictConfig -from rationai.masks import process_items, write_big_tiff +from rationai.masks import process_items, tile_mask, write_big_tiff from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger @@ -21,13 +22,13 @@ def process_slide( slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" - img = pyvips.Image.black(int(slide["extent_x"]), int(slide["extent_y"])) - te_x = int(slide["tile_extent_x"]) - te_y = int(slide["tile_extent_y"]) - for _, row in slide_tiles.iterrows(): - img.draw_rect([255], int(row["x"]), int(row["y"]), te_x, te_y, fill=False) + mask = tile_mask( + slide_tiles, + tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), + size=(slide["extent_x"], slide["extent_y"]), + ) write_big_tiff( - img, + pyvips.Image.new_from_array(np.array(mask)), Path(output_dir, "outlines", filename), mpp_x=slide["mpp_x"], mpp_y=slide["mpp_y"], From fa9c8eca70a46078ca996314ae47239698367631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 20:44:33 +0200 Subject: [PATCH 33/42] feat: bring back ray --- preprocessing/tile_masks.py | 4 +--- scripts/submit_tile_masks.py | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index bdc3fb4..70440c6 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -63,9 +63,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: items: list[tuple[dict[str, Any], pd.DataFrame]] = [ ( {str(k): v for k, v in slide.to_dict().items()}, - tiles_by_slide.get( - slide["id"], pd.DataFrame(columns=["x", "y"]) - ), + tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"])), ) for _, slide in slides.iterrows() ] diff --git a/scripts/submit_tile_masks.py b/scripts/submit_tile_masks.py index fc290ca..7fc8a94 100644 --- a/scripts/submit_tile_masks.py +++ b/scripts/submit_tile_masks.py @@ -3,16 +3,16 @@ submit_job( job_name="tissue-classification-tile-masks", - username="vcifka", + username=..., cpu=8, - memory="32Gi", + memory="64Gi", gpu=None, public=False, script=[ - "git clone --branch feature/implement-tile-masks https://github.com/RationAI/tissue-classification.git workdir", + "git clone https://github.com/RationAI/tissue-classification.git workdir", "cd workdir", "uv sync", - "uv run python -m preprocessing.tile_masks +experiment=preprocessing/tile_masks_05mpp", + "uv run python -m preprocessing.tile_masks +experiment=...", ], storage=[storage.secure.DATA], ) From 4e243250edc13c6b29e081010be98df39a983797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 22:22:06 +0200 Subject: [PATCH 34/42] refactor: drop ray, add heatmaps --- configs/preprocessing/tile_masks.yaml | 2 +- preprocessing/tile_masks.py | 45 ++++++++++++++++++--------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index c3849d2..21dbf3f 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -2,8 +2,8 @@ slides_artifact_path: ??? tiles_artifact_path: ??? +tile_percentage_cols: ??? -max_concurrent: 1 mlflow_artifact_path: tile_masks metadata: diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 70440c6..4013cae 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -7,21 +7,41 @@ import numpy as np import pandas as pd import pyvips -import ray +import torch from omegaconf import DictConfig -from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.masks import tile_mask, write_big_tiff +from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger +from tqdm import tqdm -@ray.remote def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, + tile_percentage_cols: list[str], ) -> None: slide, slide_tiles = item filename = f"{Path(slide['path']).stem}.tiff" + for percentage_col in tile_percentage_cols: + builder = ScalarMaskBuilder( + save_dir=Path(output_dir, percentage_col), + filename=filename, + extent_x=slide["extent_x"], + extent_y=slide["extent_y"], + mpp_x=slide["mpp_x"], + mpp_y=slide["mpp_y"], + extent_tile=slide["tile_extent_x"], + stride=slide["stride_x"], + ) + builder.update( + data=torch.tensor(slide_tiles[percentage_col].values).unsqueeze(1), + xs=torch.tensor(slide_tiles["x"].values), + ys=torch.tensor(slide_tiles["y"].values), + ) + builder.save() + mask = tile_mask( slide_tiles, tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), @@ -40,6 +60,7 @@ def process_slide( @autolog def main(config: DictConfig, logger: MLFlowLogger) -> None: tiling_run_id = config.dataset.mlflow_artifacts.tiling_run_id + tile_percentage_cols: list[str] = list(config.tile_percentage_cols) slides = pd.read_parquet( mlflow.artifacts.download_artifacts( @@ -52,7 +73,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: run_id=tiling_run_id, artifact_path=config.tiles_artifact_path, ), - columns=["slide_id", "x", "y"], + columns=["slide_id", "x", "y", *tile_percentage_cols], ) tiles_by_slide = { @@ -63,21 +84,17 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: items: list[tuple[dict[str, Any], pd.DataFrame]] = [ ( {str(k): v for k, v in slide.to_dict().items()}, - tiles_by_slide.get(slide["id"], pd.DataFrame(columns=["x", "y"])), + tiles_by_slide.get( + slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols]) + ), ) for _, slide in slides.iterrows() ] with TemporaryDirectory() as output_dir: Path(output_dir, "outlines").mkdir() - process_items( - items, - process_item=process_slide, - fn_kwargs={ - "output_dir": output_dir, - }, - max_concurrent=config.max_concurrent, - ) + for item in tqdm(items): + process_slide(item, output_dir, tile_percentage_cols) logger.log_artifacts( local_dir=output_dir, artifact_path=config.mlflow_artifact_path @@ -85,6 +102,4 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: if __name__ == "__main__": - ray.init() main() - ray.shutdown() From 432ad82e8f1767fdc5b7ea4ec473230c8e2f974e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Wed, 29 Apr 2026 22:33:01 +0200 Subject: [PATCH 35/42] fix: bring back ray but dump the memory --- configs/preprocessing/tile_masks.yaml | 1 + preprocessing/tile_masks.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 21dbf3f..a287440 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -4,6 +4,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? tile_percentage_cols: ??? +max_concurrent: 1 mlflow_artifact_path: tile_masks metadata: diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index 4013cae..cc26408 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -7,15 +7,16 @@ import numpy as np import pandas as pd import pyvips +import ray import torch from omegaconf import DictConfig -from rationai.masks import tile_mask, write_big_tiff +from rationai.masks import process_items, tile_mask, write_big_tiff from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger -from tqdm import tqdm +@ray.remote(max_calls=1) def process_slide( item: tuple[dict[str, Any], pd.DataFrame], output_dir: str, @@ -93,8 +94,15 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: with TemporaryDirectory() as output_dir: Path(output_dir, "outlines").mkdir() - for item in tqdm(items): - process_slide(item, output_dir, tile_percentage_cols) + process_items( + items, + process_item=process_slide, + fn_kwargs={ + "output_dir": output_dir, + "tile_percentage_cols": tile_percentage_cols, + }, + max_concurrent=config.max_concurrent, + ) logger.log_artifacts( local_dir=output_dir, artifact_path=config.mlflow_artifact_path @@ -102,4 +110,6 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: if __name__ == "__main__": + ray.init() main() + ray.shutdown() From 72f12a9f1fc6881357746c8c8a35686a188e45ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Thu, 30 Apr 2026 11:06:20 +0200 Subject: [PATCH 36/42] fix: generate the mask in place --- preprocessing/tile_masks.py | 57 +++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index cc26408..a3df4e6 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -4,7 +4,6 @@ import hydra import mlflow -import numpy as np import pandas as pd import pyvips import ray @@ -18,11 +17,16 @@ @ray.remote(max_calls=1) def process_slide( - item: tuple[dict[str, Any], pd.DataFrame], + slide: dict[str, Any], output_dir: str, tile_percentage_cols: list[str], + tiles_path: str, ) -> None: - slide, slide_tiles = item + slide_tiles = pd.read_parquet( + tiles_path, + columns=["x", "y", *tile_percentage_cols], + filters=[("slide_id", "==", slide["id"])], + ) filename = f"{Path(slide['path']).stem}.tiff" for percentage_col in tile_percentage_cols: @@ -48,9 +52,19 @@ def process_slide( tile_extent=(slide["tile_extent_x"], slide["tile_extent_y"]), size=(slide["extent_x"], slide["extent_y"]), ) + width, height = mask.size + mask_bytes = mask.tobytes() + del mask + write_big_tiff( - pyvips.Image.new_from_array(np.array(mask)), - Path(output_dir, "outlines", filename), + image=pyvips.Image.new_from_memory( + data=mask_bytes, + width=width, + height=height, + bands=1, + format="uchar", + ), + path=Path(output_dir, "outlines", filename), mpp_x=slide["mpp_x"], mpp_y=slide["mpp_y"], ) @@ -63,32 +77,18 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: tiling_run_id = config.dataset.mlflow_artifacts.tiling_run_id tile_percentage_cols: list[str] = list(config.tile_percentage_cols) - slides = pd.read_parquet( - mlflow.artifacts.download_artifacts( - run_id=tiling_run_id, - artifact_path=config.slides_artifact_path, - ) + slides_path = mlflow.artifacts.download_artifacts( + run_id=tiling_run_id, + artifact_path=config.slides_artifact_path, ) - tiles = pd.read_parquet( - mlflow.artifacts.download_artifacts( - run_id=tiling_run_id, - artifact_path=config.tiles_artifact_path, - ), - columns=["slide_id", "x", "y", *tile_percentage_cols], + tiles_path = mlflow.artifacts.download_artifacts( + run_id=tiling_run_id, + artifact_path=config.tiles_artifact_path, ) + slides = pd.read_parquet(slides_path) - tiles_by_slide = { - slide_id: group.drop(columns="slide_id") - for slide_id, group in tiles.groupby("slide_id") - } - - items: list[tuple[dict[str, Any], pd.DataFrame]] = [ - ( - {str(k): v for k, v in slide.to_dict().items()}, - tiles_by_slide.get( - slide["id"], pd.DataFrame(columns=["x", "y", *tile_percentage_cols]) - ), - ) + items: list[dict[str, Any]] = [ + {str(k): v for k, v in slide.to_dict().items()} for _, slide in slides.iterrows() ] @@ -100,6 +100,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: fn_kwargs={ "output_dir": output_dir, "tile_percentage_cols": tile_percentage_cols, + "tiles_path": tiles_path, }, max_concurrent=config.max_concurrent, ) From 096119af8662b03c49a69e212be9a50f9e1d22ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Thu, 30 Apr 2026 11:33:14 +0200 Subject: [PATCH 37/42] fix: raise concurrency --- configs/preprocessing/tile_masks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index a287440..10c1273 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -4,7 +4,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? tile_percentage_cols: ??? -max_concurrent: 1 +max_concurrent: 8 mlflow_artifact_path: tile_masks metadata: From 79c343d85ecebb48b87b4daa882393e6db7587d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Thu, 30 Apr 2026 11:49:09 +0200 Subject: [PATCH 38/42] fix: lower concurrency to 4 --- configs/preprocessing/tile_masks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 10c1273..3f344ca 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -4,7 +4,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? tile_percentage_cols: ??? -max_concurrent: 8 +max_concurrent: 4 mlflow_artifact_path: tile_masks metadata: From 1eecb8434a171c65f8c03fa5eed07cfbba863887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Thu, 30 Apr 2026 21:03:49 +0200 Subject: [PATCH 39/42] feat: lower concurrency to 1 --- configs/preprocessing/tissue_masks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/preprocessing/tissue_masks.yaml b/configs/preprocessing/tissue_masks.yaml index 4de9a5c..58168f7 100644 --- a/configs/preprocessing/tissue_masks.yaml +++ b/configs/preprocessing/tissue_masks.yaml @@ -3,7 +3,7 @@ mpp: ??? disk_factor: ??? -max_concurrent: 8 +max_concurrent: 1 mlflow_artifact_path: tissue_masks metadata: From 8d02a50b1681792ad64280952f6da39cfb311e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sun, 3 May 2026 13:00:45 +0200 Subject: [PATCH 40/42] refactor: drop ray --- configs/preprocessing/tile_masks.yaml | 2 +- configs/preprocessing/tissue_masks.yaml | 2 +- preprocessing/tile_masks.py | 29 +++++++++---------------- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/configs/preprocessing/tile_masks.yaml b/configs/preprocessing/tile_masks.yaml index 3f344ca..a287440 100644 --- a/configs/preprocessing/tile_masks.yaml +++ b/configs/preprocessing/tile_masks.yaml @@ -4,7 +4,7 @@ slides_artifact_path: ??? tiles_artifact_path: ??? tile_percentage_cols: ??? -max_concurrent: 4 +max_concurrent: 1 mlflow_artifact_path: tile_masks metadata: diff --git a/configs/preprocessing/tissue_masks.yaml b/configs/preprocessing/tissue_masks.yaml index 58168f7..4de9a5c 100644 --- a/configs/preprocessing/tissue_masks.yaml +++ b/configs/preprocessing/tissue_masks.yaml @@ -3,7 +3,7 @@ mpp: ??? disk_factor: ??? -max_concurrent: 1 +max_concurrent: 8 mlflow_artifact_path: tissue_masks metadata: diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index a3df4e6..edd0974 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -6,16 +6,15 @@ import mlflow import pandas as pd import pyvips -import ray import torch from omegaconf import DictConfig -from rationai.masks import process_items, tile_mask, write_big_tiff +from rationai.masks import tile_mask, write_big_tiff from rationai.masks.mask_builders import ScalarMaskBuilder from rationai.mlkit import autolog, with_cli_args from rationai.mlkit.lightning.loggers import MLFlowLogger +from tqdm import tqdm -@ray.remote(max_calls=1) def process_slide( slide: dict[str, Any], output_dir: str, @@ -87,23 +86,17 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ) slides = pd.read_parquet(slides_path) - items: list[dict[str, Any]] = [ - {str(k): v for k, v in slide.to_dict().items()} - for _, slide in slides.iterrows() - ] + items = slides.to_dict(orient="records") with TemporaryDirectory() as output_dir: Path(output_dir, "outlines").mkdir() - process_items( - items, - process_item=process_slide, - fn_kwargs={ - "output_dir": output_dir, - "tile_percentage_cols": tile_percentage_cols, - "tiles_path": tiles_path, - }, - max_concurrent=config.max_concurrent, - ) + for slide in tqdm(items): + process_slide( + slide, + output_dir=output_dir, + tile_percentage_cols=tile_percentage_cols, + tiles_path=tiles_path, + ) logger.log_artifacts( local_dir=output_dir, artifact_path=config.mlflow_artifact_path @@ -111,6 +104,4 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: if __name__ == "__main__": - ray.init() main() - ray.shutdown() From 524d116f163b53af3185c3e0c785a6a609704f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sun, 3 May 2026 14:47:41 +0200 Subject: [PATCH 41/42] refactor: drop Ray from tile_masks, simplify slide iteration Replace Ray remote dispatch and process_items with a plain tqdm loop. Also simplify iterrows to to_dict(orient="records"). Co-Authored-By: Claude Sonnet 4.6 --- preprocessing/tile_masks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index edd0974..fcf1f1b 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -1,6 +1,6 @@ from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any +from typing import Any, cast import hydra import mlflow @@ -86,7 +86,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ) slides = pd.read_parquet(slides_path) - items = slides.to_dict(orient="records") + items = cast(list[dict[str, Any]], slides.to_dict(orient="records")) with TemporaryDirectory() as output_dir: Path(output_dir, "outlines").mkdir() From b9c6aab7673df4a21e51ec9e2f0efb1fa6f039bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20C=C3=ADfka?= <550433@mail.muni.cz> Date: Sun, 3 May 2026 14:49:06 +0200 Subject: [PATCH 42/42] fix: lint --- preprocessing/tile_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preprocessing/tile_masks.py b/preprocessing/tile_masks.py index fcf1f1b..9738f3d 100644 --- a/preprocessing/tile_masks.py +++ b/preprocessing/tile_masks.py @@ -86,7 +86,7 @@ def main(config: DictConfig, logger: MLFlowLogger) -> None: ) slides = pd.read_parquet(slides_path) - items = cast(list[dict[str, Any]], slides.to_dict(orient="records")) + items = cast("list[dict[str, Any]]", slides.to_dict(orient="records")) with TemporaryDirectory() as output_dir: Path(output_dir, "outlines").mkdir()