diff --git a/CHANGELOG.md b/CHANGELOG.md index 44407c6..1d4dc39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.3.0] - 2024-11-20 +This is compatible with the 0.11.x line of `looptrace`. + +### Changed +* Updated expected column and file names of input to match those emitted by `looptrace` processing; +in particular, the file previously represented by `*_rois.proximity_accepted.nuclei_labeled.csv` is replaced by `*_rois.with_trace_ids.csv`. + +### Added +* Now the joint tracing structure of ROIs from different regional barcodes / imaging timepoints can be visualised. + +### Changed +* Adapted to new `looptrace` column names + ## [v0.2.0] - 2024-10-21 +* This is compatible with the 0.10.x line line of `looptrace`. ### Changed * Patterns expected for input files: diff --git a/docs/using-the-plugin.md b/docs/using-the-plugin.md index 67b936f..2aeff79 100644 --- a/docs/using-the-plugin.md +++ b/docs/using-the-plugin.md @@ -31,15 +31,17 @@ The spots are color-coded: Spot shape differs by $z$-slice; the $z$-slice closest to the truncated $z$-coordinate of the spot's centroid will be square while everywhere else will be circular. We do not add $z$ slices for spots for which the bounding box extends below $0$, but there can be $z$ slices "beyond" the true images, if a spot was detected close to the max $z$ depth. This may also change in a future release. -For the ROIs/spots _resulting from_ a merger, you should see the ID of the ROI itself and the IDs of those which were merged to create it. -For the ROIs/spots _contributing to_ a merger, you should see the ID of the ROI itself and the ID(s) of the ROI(s) to which it contributed. +Some spots are labeled: +* For the ROIs/spots _resulting from_ a merger, you should see the ID of the ROI itself (`i=`). +* For the ROIs/spots _contributing to_ a merger, you should see the ID of the resulting merger ROI (`i=`). +* For the ROIs which participate in a larger tracing structure, you should see the ID (`t=`). ### Necessary data files 1. 1 ZARR per field of view you wish to view, named like `P0001.zarr` 1. 0 or 1 files of each of the following types, per field of view, organized into a folder that has the same field of view name as the ZARR, e.g. `P0001`. There must be at least 1 of these 3 files present: - A `*_rois.csv` file: unfiltered regional spots - A `*_rois.proximity_accepted.csv` file: regional spots after discarding those which are too close together - - A `*_rois.proximity_accepted.nuclei_labeled.csv` file: regional spots after proximity-based filtration and filtration for inclusion in nuclei + - A `*_rois.with_trace_ids.csv` file: regional spots after proximity-based filtration and labeling attribution of ROIs/spots to nuclei ### File format notes * For each spot, the following must be parsed: @@ -51,4 +53,9 @@ For the ROIs/spots _contributing to_ a merger, you should see the ID of the ROI * The bounding box is defined by columns suffixed `Min` and `Max` for each axis, e.g. `zMin`, `zMax`, etc. * The timepoint is read from column `timepoint`. * The channel is read from the `channel` column. -* For the `*_rois.proximity_accepted.nuclei_labeled.csv` file, `mergeRois` and `nucleusNumber` columns are parsed. +* For the merge contributors file, the `mergeOutput` column is parsed to get the ID of the merge result. +* For the `*_rois.with_trace_ids.csv` file, the following additional columns are parsed: + * `mergePartners` (to tell singleton ROIs from merger output ROIs) + * `nucleusNumber` (to tell nuclear from non-nuclear ROIs) + * `traceId` (to label ROIs which participate in a multi-ROI trace) + * `tracePartners` (to determine whether a ROI participates in a multi-rOI trace) diff --git a/looptrace_regionals_vis/__init__.py b/looptrace_regionals_vis/__init__.py index 2084d92..8e90075 100644 --- a/looptrace_regionals_vis/__init__.py +++ b/looptrace_regionals_vis/__init__.py @@ -10,7 +10,7 @@ from numpydoc_decorator import doc # type: ignore[import-untyped] -__version__ = "0.2.0" +__version__ = "0.3" _PACKAGE_NAME = package = Path(__file__).parent.name diff --git a/looptrace_regionals_vis/examples/P0001_rois.merge_contributors.csv b/looptrace_regionals_vis/examples/P0001_rois.merge_contributors.csv index 639399d..3c806b7 100644 --- a/looptrace_regionals_vis/examples/P0001_rois.merge_contributors.csv +++ b/looptrace_regionals_vis/examples/P0001_rois.merge_contributors.csv @@ -1,10 +1,10 @@ -index,fieldOfView,timepoint,spotChannel,zc,yc,xc,intensityMean,zMin,zMax,yMin,yMax,xMin,xMax,mergeIndices +index,fieldOfView,timepoint,spotChannel,zc,yc,xc,intensityMean,zMin,zMax,yMin,yMax,xMin,xMax,mergeOutput 0,P0001.zarr,79,0,3.907628987532479,231.9874778925304,871.9833511648726,118.26726920593931,-2.092371012467521,9.90762898753248,219.9874778925304,243.9874778925304,859.9833511648726,883.9833511648726,101 -1,P0001.zarr,79,0,17.994259347453493,24.042015416774795,1360.0069098862991,117.1394688491732,11.994259347453491,23.994259347453493,12.042015416774795,36.0420154167748,1348.0069098862991,1372.0069098862991,102;103 +1,P0001.zarr,79,0,17.994259347453493,24.042015416774795,1360.0069098862991,117.1394688491732,11.994259347453491,23.994259347453493,12.042015416774795,36.0420154167748,1348.0069098862991,1372.0069098862991,103 2,P0001.zarr,79,0,23.00910242218976,231.98008711401275,871.9596645390719,116.14075915047448,17.009102422189756,29.00910242218976,219.98008711401275,243.98008711401275,859.9596645390719,883.9596645390719,101 -3,P0001.zarr,79,0,16.527137137619988,422.5165732477932,667.2129969610728,157.0530303030303,10.527137137619988,22.527137137619988,410.5165732477932,434.5165732477932,655.2129969610728,679.2129969610728,102;103 -4,P0001.zarr,79,0,16.950424004488077,118.88259896330818,349.6540530977019,161.81065410400436,10.950424004488076,22.95042400448808,106.88259896330818,130.88259896330817,337.6540530977019,361.6540530977019,102;103 +3,P0001.zarr,79,0,16.527137137619988,422.5165732477932,667.2129969610728,157.0530303030303,10.527137137619988,22.527137137619988,410.5165732477932,434.5165732477932,655.2129969610728,679.2129969610728,102 +4,P0001.zarr,79,0,16.950424004488077,118.88259896330818,349.6540530977019,161.81065410400436,10.950424004488076,22.95042400448808,106.88259896330818,130.88259896330817,337.6540530977019,361.6540530977019,103 5,P0001.zarr,79,0,18.177805530982404,445.4564669785048,607.9657421380375,160.33961681087763,12.177805530982404,24.177805530982404,433.4564669785048,457.4564669785048,595.9657421380375,619.9657421380375,100 -6,P0001.zarr,79,0,17.83959674876146,1006.0753359579252,306.5263466292306,160.1254275940707,11.839596748761458,23.83959674876146,994.0753359579252,1018.0753359579252,294.5263466292306,318.5263466292306,100 +6,P0001.zarr,79,0,17.83959674876146,1006.0753359579252,306.5263466292306,160.1254275940707,11.839596748761458,23.83959674876146,994.0753359579252,1018.0753359579252,294.5263466292306,318.5263466292306,102 7,P0001.zarr,79,0,17.70877472362621,1040.482813665982,290.6567022086824,163.12094117647058,11.70877472362621,23.70877472362621,1028.482813665982,1052.482813665982,278.6567022086824,302.6567022086824,100 8,P0001.zarr,79,0,17.294947508199503,1701.3423124347908,1665.0076588607058,152.65736930345983,11.294947508199504,23.294947508199503,1689.3423124347908,1713.3423124347908,1653.0076588607058,1677.0076588607058,101 diff --git a/looptrace_regionals_vis/examples/P0001_rois.proximity_accepted.nuclei_labeled.csv b/looptrace_regionals_vis/examples/P0001_rois.with_trace_ids.csv similarity index 87% rename from looptrace_regionals_vis/examples/P0001_rois.proximity_accepted.nuclei_labeled.csv rename to looptrace_regionals_vis/examples/P0001_rois.with_trace_ids.csv index 10fc005..a204068 100644 --- a/looptrace_regionals_vis/examples/P0001_rois.proximity_accepted.nuclei_labeled.csv +++ b/looptrace_regionals_vis/examples/P0001_rois.with_trace_ids.csv @@ -1,10 +1,10 @@ -index,fieldOfView,timepoint,spotChannel,zc,yc,xc,intensityMean,zMin,zMax,yMin,yMax,xMin,xMax,mergeRois,nucleusNumber -3,P0001.zarr,79,0,16.527137137619988,422.5165732477932,667.2129969610728,157.0530303030303,10.527137137619988,22.527137137619988,410.5165732477932,434.5165732477932,655.2129969610728,679.2129969610728,100;101,3 -4,P0001.zarr,79,0,16.950424004488077,118.88259896330818,349.6540530977019,161.81065410400436,10.950424004488076,22.95042400448808,106.88259896330818,130.88259896330817,337.6540530977019,361.6540530977019,,2 -5,P0001.zarr,79,0,18.177805530982404,445.4564669785048,607.9657421380375,160.33961681087763,12.177805530982404,24.177805530982404,433.4564669785048,457.4564669785048,595.9657421380375,619.9657421380375,,3 -6,P0001.zarr,79,0,17.83959674876146,1006.0753359579252,306.5263466292306,160.1254275940707,11.839596748761458,23.83959674876146,994.0753359579252,1018.0753359579252,294.5263466292306,318.5263466292306,102;110,7 -7,P0001.zarr,79,0,17.70877472362621,1040.482813665982,290.6567022086824,163.12094117647058,11.70877472362621,23.70877472362621,1028.482813665982,1052.482813665982,278.6567022086824,302.6567022086824,,7 -8,P0001.zarr,79,0,17.294947508199503,1701.3423124347908,1665.0076588607058,152.65736930345983,11.294947508199504,23.294947508199503,1689.3423124347908,1713.3423124347908,1653.0076588607058,1677.0076588607058,,4 -10,P0001.zarr,79,0,18.5289337545209,28.24453371789589,1634.215648033695,149.63521150882,12.5289337545209,24.5289337545209,16.244533717895894,40.24453371789589,1622.215648033695,1646.215648033695,,0 -11,P0001.zarr,79,0,18.266489401564822,533.7770489058973,1747.1225533182217,159.3707150612688,12.266489401564826,24.266489401564822,521.7770489058973,545.7770489058973,1735.1225533182217,1759.1225533182217,121;131,4 -12,P0001.zarr,79,0,18.09858176406764,863.1257478224696,984.5327240679828,146.90628445424477,12.09858176406764,24.09858176406764,851.1257478224696,875.1257478224696,972.5327240679828,996.5327240679828,,5 +index,fieldOfView,timepoint,spotChannel,zc,yc,xc,intensityMean,zMin,zMax,yMin,yMax,xMin,xMax,mergePartners,nucleusNumber,traceId,tracePartners +3,P0001.zarr,79,0,16.527137137619988,422.5165732477932,667.2129969610728,157.0530303030303,10.527137137619988,22.527137137619988,410.5165732477932,434.5165732477932,655.2129969610728,679.2129969610728,100;101,3,13,5 +4,P0001.zarr,79,0,16.950424004488077,118.88259896330818,349.6540530977019,161.81065410400436,10.950424004488076,22.95042400448808,106.88259896330818,130.88259896330817,337.6540530977019,361.6540530977019,,2,14, +5,P0001.zarr,79,0,18.177805530982404,445.4564669785048,607.9657421380375,160.33961681087763,12.177805530982404,24.177805530982404,433.4564669785048,457.4564669785048,595.9657421380375,619.9657421380375,,3,13,3 +6,P0001.zarr,79,0,17.83959674876146,1006.0753359579252,306.5263466292306,160.1254275940707,11.839596748761458,23.83959674876146,994.0753359579252,1018.0753359579252,294.5263466292306,318.5263466292306,102;110,7,16,7;12 +7,P0001.zarr,79,0,17.70877472362621,1040.482813665982,290.6567022086824,163.12094117647058,11.70877472362621,23.70877472362621,1028.482813665982,1052.482813665982,278.6567022086824,302.6567022086824,,7,16,6;12 +8,P0001.zarr,79,0,17.294947508199503,1701.3423124347908,1665.0076588607058,152.65736930345983,11.294947508199504,23.294947508199503,1689.3423124347908,1713.3423124347908,1653.0076588607058,1677.0076588607058,,4,17, +10,P0001.zarr,79,0,18.5289337545209,28.24453371789589,1634.215648033695,149.63521150882,12.5289337545209,24.5289337545209,16.244533717895894,40.24453371789589,1622.215648033695,1646.215648033695,,0,18, +11,P0001.zarr,79,0,18.266489401564822,533.7770489058973,1747.1225533182217,159.3707150612688,12.266489401564826,24.266489401564822,521.7770489058973,545.7770489058973,1735.1225533182217,1759.1225533182217,121;131,4,19, +12,P0001.zarr,79,0,18.09858176406764,863.1257478224696,984.5327240679828,146.90628445424477,12.09858176406764,24.09858176406764,851.1257478224696,875.1257478224696,972.5327240679828,996.5327240679828,,5,16,6;7 diff --git a/looptrace_regionals_vis/napari.yaml b/looptrace_regionals_vis/napari.yaml index c0108ad..85d65f5 100644 --- a/looptrace_regionals_vis/napari.yaml +++ b/looptrace_regionals_vis/napari.yaml @@ -9,5 +9,5 @@ contributions: filename_patterns: - '*_rois.merge_contributors.csv' - '*_rois.proximity_rejected.csv' - - '*_rois.proximity_accepted.nuclei_labeled.csv' + - '*_rois.with_trace_ids.csv' accepts_directories: true diff --git a/looptrace_regionals_vis/reader.py b/looptrace_regionals_vis/reader.py index aed8241..a01ced3 100644 --- a/looptrace_regionals_vis/reader.py +++ b/looptrace_regionals_vis/reader.py @@ -3,12 +3,13 @@ import dataclasses import logging from collections import Counter -from collections.abc import Callable, Iterable +from collections.abc import Callable, Sized from enum import Enum from pathlib import Path -from typing import Literal, Optional +from typing import Literal, Optional, TypeAlias import pandas as pd +from gertils.types import TraceIdFrom0 from numpydoc_decorator import doc # type: ignore[import-untyped] from .bounding_box import BoundingBox3D @@ -38,7 +39,7 @@ class InputFileContentType(Enum): MergeContributors = ".merge_contributors.csv" ProximityRejects = ".proximity_rejected.csv" - NucleiLabeled = ".proximity_accepted.nuclei_labeled.csv" + NucleiLabeled = ".with_trace_ids.csv" @classmethod def from_filename(cls, fn: str) -> Optional["InputFileContentType"]: @@ -109,6 +110,7 @@ def build_layers(folder) -> list[FullDataLayer]: # type: ignore[no-untyped-def] layers: list[FullDataLayer] = [] + # Each file type may have a particular parse strategy. for file_type, file_path in file_by_kind.items(): logging.debug("Processing data for file type %s: %s", file_type.name, file_path) if file_type == InputFileContentType.MergeContributors: @@ -120,10 +122,10 @@ def build_layers(folder) -> list[FullDataLayer]: # type: ignore[no-untyped-def] } elif file_type == InputFileContentType.NucleiLabeled: rois_by_type = {} - for roi in _parse_non_contributor_non_proximal_rois(file_path): + for r in _parse_non_contributor_non_proximal_rois(file_path): # Ignore type check here since we can't properly disambiguate the subcases # w.r.t. ROI type. - rois_by_type.setdefault(type(roi), []).append(roi) # type: ignore[arg-type] + rois_by_type.setdefault(type(r), []).append(r) # type: ignore[arg-type] elif file_type == InputFileContentType.ProximityRejects: rois_by_type = { ProximityRejectedRoi: [ # type: ignore[dict-item] @@ -134,59 +136,80 @@ def build_layers(folder) -> list[FullDataLayer]: # type: ignore[no-untyped-def] else: raise RuntimeError(f"Unexpected file type (can't determine ROI type)! {file_type}") + # For some file types, this loop is trivial (single-element rois_by_type). for roi_type, rois in rois_by_type.items(): - get_text_color: Callable[[], str] = lambda: rois[0].color # noqa: B023 + layer_color: str = rois[0].color + + # 1. build up the points for a layer. corners: list[list[list[int | FloatLike]]] = [] shapes: list[str] = [] - for roi in rois: # type: ignore[assignment] + for r in rois: # type: ignore[assignment] for ( q1, q2, q3, q4, is_center_slice, - ) in roi.bounding_box.iter_z_slices_nonnegative(): + ) in r.bounding_box.iter_z_slices_nonnegative(): corners.append( [ - [roi.timepoint, roi.channel, *_point_to_list(pt)] + [r.timepoint, r.channel, *_point_to_list(pt)] for pt in [q1, q2, q3, q4] ] ) shapes.append("rectangle" if is_center_slice else "ellipse") - logging.debug("Point count for ROI type %s: %d", roi.typename, len(shapes)) + logging.debug("Point count for ROI type %s: %d", r.typename, len(corners)) + + # 2. Build up collection of layer parameters. params: dict[str, object] = { - "name": roi.typename, + # We need these parameters regardless of the ROI type + "name": roi_type.__name__, "shape_type": shapes, "face_color": "transparent", - COLOR_PARAMS_KEY: roi.color, + COLOR_PARAMS_KEY: layer_color, } - if roi_type in [MergeContributorRoi, MergedRoi]: - format_string: str - features: dict[str, object] = {} - ids, labels = zip( - *[ - (roi_id, roi_text) - for roi in rois - for roi_id, roi_text in _create_roi_id_text_pairs(roi) - ], - strict=False, - ) - if roi_type == MergeContributorRoi: - format_string = "{id} --> {merged_outputs}" - features = {"id": ids, "merged_outputs": labels} - elif roi_type == MergedRoi: - format_string = "{id} <-- {contributors}" - features = {"id": ids, "contributors": labels} - else: - raise RuntimeError( - f"Could not determine how to build text for ROI layer of type {roi_type}" + + # 3. Add text if necessary based on ROI type. + # Add the merge index for merge input or output record. + # Add the trace ID whenever > 1 ROI is in the same trace. + if roi_type in [MergeContributorRoi, MergedRoi, SingletonRoi]: + format_string_parts: list[str] = [] + features: dict[str, Sized] = {} + if roi_type in [MergeContributorRoi, MergedRoi]: + features.update( + { + "mergeId": [ + r.id if roi_type == MergedRoi else r.merge_index + for r in rois + for _ in r.bounding_box.iter_z_slices_nonnegative() + ] + } ) + format_string_parts.append("i={mergeId}") + if roi_type in [MergedRoi, SingletonRoi]: + features.update( + { + "traceId": [ + r.traceId.get if r.trace_partners else "" # type: ignore[attr-defined] + for r in rois + for _ in r.bounding_box.iter_z_slices_nonnegative() + ] + } + ) + format_string_parts.append("t={traceId}") text_properties: dict[str, object] = { - "string": format_string, + "string": ", ".join(format_string_parts), "size": TEXT_SIZE, - "color": get_text_color(), + "color": layer_color, } params.update({"features": features, "text": text_properties}) + if features: + logging.debug( + "Feature counts: %s", + ", ".join(f"{k} -> {len(vs)}" for k, vs in features.items()), + ) + + # 4. Add the layer to the growing collection. layers.append((corners, params, "shapes")) return layers @@ -194,19 +217,6 @@ def build_layers(folder) -> list[FullDataLayer]: # type: ignore[no-untyped-def] return build_layers -def _create_roi_id_text_pairs(roi: MergeContributorRoi | MergedRoi) -> Iterable[tuple[RoiId, str]]: - indices: set[RoiId] - if isinstance(roi, MergeContributorRoi): - indices = roi.merge_indices - elif isinstance(roi, MergedRoi): - indices = roi.contributors - else: - raise TypeError(f"Cannot create ROI IDs text for value of type {type(roi).__name__}") - text = ";".join(map(str, sorted(indices))) - for _ in roi.bounding_box.iter_z_slices_nonnegative(): - yield roi.id, text - - @doc( summary="Determine if the given path may be an input file to parse.", parameters=dict(path="Path to test as a plausible input file"), @@ -221,8 +231,8 @@ def _parse_non_contributor_non_proximal_rois( ) -> list[NonNuclearRoi | SingletonRoi | MergedRoi]: rois: list[NonNuclearRoi | SingletonRoi | MergedRoi] = [] for _, row in pd.read_csv(path).iterrows(): - time, channel, box, maybe_nuc_num, maybe_id_and_contribs = _parse_nucleus_labeled_record( - row + time, channel, box, traceId, trace_partners, maybe_nuc_num, maybe_id_and_contribs = ( + _parse_nucleus_labeled_record(row) ) roi: NonNuclearRoi | SingletonRoi | MergedRoi match (maybe_nuc_num, maybe_id_and_contribs): @@ -233,6 +243,8 @@ def _parse_non_contributor_non_proximal_rois( timepoint=time, channel=channel, bounding_box=box, + traceId=traceId, + trace_partners=trace_partners, nucleus_number=nuc_num, # type: ignore[arg-type] ) case (nuc_num, (main_id, contrib_ids)): @@ -242,6 +254,8 @@ def _parse_non_contributor_non_proximal_rois( channel=channel, bounding_box=box, nucleus_number=nuc_num, # type: ignore[arg-type] + traceId=traceId, + trace_partners=trace_partners, contributors=contrib_ids, # type: ignore[arg-type] ) case _: @@ -275,50 +289,77 @@ def _parse_merge_contributor_record( ) -> MergeContributorRoi: record: dict[str, int | FloatLike] = record.to_dict() # type: ignore[no-redef] index: RoiId = record["index"] - merge_column_name = "mergeIndices" - raw_merge_indices = record[merge_column_name] - merge_indices: set[RoiId] - if isinstance(raw_merge_indices, RoiId): - merge_indices = {raw_merge_indices} - elif isinstance(raw_merge_indices, str): - merge_indices = {int(i) for i in raw_merge_indices.split(";")} + merge_column_name = "mergeOutput" + raw_merge_index = record[merge_column_name] + MergeIdType: TypeAlias = RoiId + merge_index: MergeIdType + if isinstance(raw_merge_index, MergeIdType): + merge_index = raw_merge_index + elif isinstance(raw_merge_index, str): + merge_index = MergeIdType(raw_merge_index) else: raise TypeError( - f"Got {type(raw_merge_indices)}, not str or int, from '{merge_column_name}': {raw_merge_indices}" + f"Got {type(raw_merge_index)}, not str or {MergeIdType.__name__}, from '{merge_column_name}': {raw_merge_index}" ) time, channel, box = _parse_time_channel_box_trio(record) return MergeContributorRoi( - id=index, timepoint=time, channel=channel, bounding_box=box, merge_indices=merge_indices + id=index, timepoint=time, channel=channel, bounding_box=box, merge_index=merge_index ) def _parse_nucleus_labeled_record( record: pd.Series, # type: ignore[type-arg] -) -> tuple[Timepoint, Channel, BoundingBox3D, Optional[NucleusNumber], Optional[IdAndContributors]]: +) -> tuple[ + Timepoint, + Channel, + BoundingBox3D, + TraceIdFrom0, + set[RoiId], + Optional[NucleusNumber], + Optional[IdAndContributors], +]: record: dict[str, int | FloatLike] = record.to_dict() # type: ignore[no-redef] + + traceId: TraceIdFrom0 = TraceIdFrom0(record["traceId"]) + trace_partners: set[RoiId] = _parse_roi_ids_field(record, key="tracePartners") + raw_nuc_num: int = record["nucleusNumber"] maybe_nuc_num: Optional[NucleusNumber] = ( None if raw_nuc_num == 0 else NucleusNumber(raw_nuc_num) ) - merge_column_name = "mergeRois" - raw_merge_indices: object = record[merge_column_name] + + merge_column_name = "mergePartners" id_and_contribs: Optional[IdAndContributors] - if raw_merge_indices is None or pd.isna(raw_merge_indices): # type: ignore[call-overload] + raw_merge_indices = record[merge_column_name] + if raw_merge_indices is None or raw_merge_indices == "" or pd.isna(raw_merge_indices): id_and_contribs = None else: roi_id: RoiId = record["index"] - contribs: set[RoiId] - if isinstance(raw_merge_indices, RoiId): - contribs = {raw_merge_indices} - elif isinstance(raw_merge_indices, str): - contribs = {int(i) for i in raw_merge_indices.split(";")} - else: - raise TypeError( - f"Got {type(raw_merge_indices)}, not str or int, from '{merge_column_name}': {raw_merge_indices}" - ) + contribs = _parse_roi_ids_field(record, key=merge_column_name) id_and_contribs = (roi_id, contribs) + time, channel, box = _parse_time_channel_box_trio(record) - return time, channel, box, maybe_nuc_num, id_and_contribs + + return time, channel, box, traceId, trace_partners, maybe_nuc_num, id_and_contribs + + +def _parse_roi_ids_field( + row: pd.Series, # type: ignore[type-arg] + *, + key: str, + intra_field_delimiter: str = ";", +) -> set[RoiId]: + raw_value: object = row[key] + result: set[RoiId] + if isinstance(raw_value, int): + result = {raw_value} + elif isinstance(raw_value, str): + result = set(map(int, raw_value.split(intra_field_delimiter))) + elif pd.isna(raw_value): # type: ignore[call-overload] + result = set() + else: + raise TypeError(f"Got {type(raw_value)}, not str or int, from '{key}': {raw_value}") + return result def _parse_time_channel_box_trio( diff --git a/looptrace_regionals_vis/roi.py b/looptrace_regionals_vis/roi.py index b62b127..399bcd5 100644 --- a/looptrace_regionals_vis/roi.py +++ b/looptrace_regionals_vis/roi.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Protocol +from gertils.types import TraceIdFrom0 + from .bounding_box import BoundingBox3D from .colors import IBM_BLUE, IBM_ORANGE, IBM_PINK, IBM_PURPLE, IBM_YELLOW from .types import Channel, NucleusNumber, RoiId, Timepoint @@ -30,7 +32,7 @@ class MergeContributorRoi(RegionOfInterest): timepoint: Timepoint channel: Channel bounding_box: BoundingBox3D - merge_indices: set[RoiId] + merge_index: RoiId @property def color(self) -> str: @@ -38,10 +40,8 @@ def color(self) -> str: return IBM_PURPLE def __post_init__(self) -> None: - if self.id in self.merge_indices: - raise ValueError( - f"A {self.__class__.__name__}'s index can't equal be among its merge_indices" - ) + if self.id == self.merge_index: + raise ValueError(f"A {self.__class__.__name__}'s index can't equal its merge_index") @dataclass(kw_only=True, frozen=True) @@ -79,6 +79,8 @@ class SingletonRoi(RegionOfInterest): timepoint: Timepoint channel: Channel bounding_box: BoundingBox3D + traceId: TraceIdFrom0 + trace_partners: set[RoiId] # which ROIs are part of the same trace nucleus_number: NucleusNumber @property @@ -96,6 +98,8 @@ class MergedRoi(RegionOfInterest): channel: Channel bounding_box: BoundingBox3D contributors: set[RoiId] + traceId: TraceIdFrom0 + trace_partners: set[RoiId] # which ROIs are part of the same trace nucleus_number: NucleusNumber def __post_init__(self) -> None: diff --git a/tests/test_file_type_inference.py b/tests/test_file_type_inference.py index bb8c007..78034d6 100644 --- a/tests/test_file_type_inference.py +++ b/tests/test_file_type_inference.py @@ -66,6 +66,7 @@ def test_from_filepath_requires_path(arg): ("", ".proximity_rejected.csv", None), ("", ".proximity_accepted.nuclei_labeled.csv", None), ("", ".proximity_rejected.nuclei_labeled.csv", None), + ("", ".with_trace_ids.csv", None), ("", ".merge_contributors.csv", None), ("_rois", "", None), ("_rois", ".CSV", None), @@ -73,7 +74,8 @@ def test_from_filepath_requires_path(arg): ("_rois", ".proximity_rejected.csv", InputFileContentType.ProximityRejects), ("_rois", ".proximity_accepted.csv", None), ("_rois", ".proximity_rejected.nuclei_labeled.csv", None), - ("_rois", ".proximity_accepted.nuclei_labeled.csv", InputFileContentType.NucleiLabeled), + ("_rois", ".proximity_accepted.nuclei_labeled.csv", None), + ("_rois", ".with_trace_ids.csv", InputFileContentType.NucleiLabeled), ("_rois", ".csv", None), ("_rois", ".nuclei_labeled.proximity_accepted.csv", None), ]