Skip to content

Interlacer

Interlacer dataclass #

Interlacer(
    crawl_index: str | pathlib.Path | pandas.DataFrame,
)

Builds and queries a forest of SeriesNode objects from DICOM series data.

Parameters:

Name Type Description Default

crawl_index #

str | pathlib.Path | pandas.DataFrame

Path to the CSV file or DataFrame containing the series data

required

Attributes:

Name Type Description
crawl_df pandas.DataFrame

DataFrame containing the data loaded from the CSV file or passed in crawl_index

series_nodes dict[str, imgtools.dicom.interlacer.SeriesNode]

Maps SeriesInstanceUID to SeriesNode objects

root_nodes list[imgtools.dicom.interlacer.SeriesNode]

List of root nodes in the forest

Methods:

Name Description
print_tree

Print a representation of the forest.

query

Query the forest for specific modalities.

query_all

Simply return ALL possible matches

visualize_forest

Visualize the forest as an interactive network graph.

print_tree #

print_tree(input_directory: pathlib.Path | None) -> None

Print a representation of the forest.

Source code in src/imgtools/dicom/interlacer.py
def print_tree(self, input_directory: Path | None) -> None:
    """Print a representation of the forest."""
    print_interlacer_tree(self.root_nodes, input_directory)

query #

query(
    query_string: str, group_by_root: bool = True
) -> list[list[imgtools.dicom.interlacer.SeriesNode]]

Query the forest for specific modalities.

Parameters:

Name Type Description Default

query_string #

str

Comma-separated string of modalities to query (e.g., 'CT,MR')

required

group_by_root #

bool

If True, group the returned SeriesNodes by their root CT/MR/PT node (i.e., avoid duplicate root nodes across results).

True

Returns:

Type Description
list[list[dict[str, str]]]

List of matched series groups where each series is represented by a dict containing 'Series' and 'Modality' keys

Notes

Supported modalities: - CT: Computed Tomography - PT: Positron Emission Tomography - MR: Magnetic Resonance Imaging - SEG: Segmentation - RTSTRUCT: Radiotherapy Structure - RTDOSE: Radiotherapy Dose

Source code in src/imgtools/dicom/interlacer.py
def query(
    self,
    query_string: str,
    group_by_root: bool = True,
) -> list[list[SeriesNode]]:
    """
    Query the forest for specific modalities.

    Parameters
    ----------
    query_string : str
        Comma-separated string of modalities to query (e.g., 'CT,MR')

    group_by_root : bool, default=True
        If True, group the returned SeriesNodes by their root CT/MR/PT
        node (i.e., avoid duplicate root nodes across results).

    Returns
    -------
    list[list[dict[str, str]]]
        List of matched series groups where each series is represented by a
        dict containing 'Series' and 'Modality' keys

    Notes
    -----
    Supported modalities:
    - CT: Computed Tomography
    - PT: Positron Emission Tomography
    - MR: Magnetic Resonance Imaging
    - SEG: Segmentation
    - RTSTRUCT: Radiotherapy Structure
    - RTDOSE: Radiotherapy Dose
    """
    if query_string in ["*", "all"]:
        query_results = self.query_all()
    else:
        queried_modalities = self._get_valid_query(query_string.split(","))
        query_results = self._query(queried_modalities)

    if not group_by_root:
        return query_results

    grouped: dict[SeriesNode, set[SeriesNode]] = defaultdict(set)
    # pretty much start with the root node, then add all branches
    for path in query_results:
        root = path[0]
        grouped[root].update(path[1:])

    # break each item into a list starting with key, then all the values
    return [[key] + list(value) for key, value in grouped.items()]

query_all #

query_all() -> (
    list[list[imgtools.dicom.interlacer.SeriesNode]]
)

Simply return ALL possible matches Note this has a different approach than query, since we dont care about the order of the modalities, just that they exist in the Branch

Source code in src/imgtools/dicom/interlacer.py
def query_all(self) -> list[list[SeriesNode]]:
    """Simply return ALL possible matches
    Note this has a different approach than query, since we dont care
    about the order of the modalities, just that they exist in the
    Branch
    """
    results: list[list[SeriesNode]] = []

    def dfs(node: SeriesNode, path: list[SeriesNode]) -> None:
        path.append(node)
        if len(node.children) == 0:
            # If this is a leaf node, check if the path is unique
            # but first, if the path has any 'RTPLAN' nodes, remove them
            # TODO:: create a global VALID_MODALITIES list instead of hardcoding
            cleaned_path = [n for n in path if n.Modality != "RTPLAN"]
            if cleaned_path not in results:
                results.append(cleaned_path)

        for child in node.children:
            dfs(child, path.copy())

    for root in self.root_nodes:
        dfs(root, [])
    return results

visualize_forest #

visualize_forest(
    save_path: str | pathlib.Path,
) -> pathlib.Path

Visualize the forest as an interactive network graph.

Creates an HTML visualization showing nodes for each SeriesNode and edges for parent-child relationships.

Parameters:

Name Type Description Default

save_path #

str | pathlib.Path

Path to save the HTML visualization.

required

Returns:

Type Description
pathlib.Path

Path to the saved HTML visualization

Source code in src/imgtools/dicom/interlacer.py
def visualize_forest(self, save_path: str | Path) -> Path:
    """
    Visualize the forest as an interactive network graph.

    Creates an HTML visualization showing nodes for each SeriesNode and
    edges for parent-child relationships.

    Parameters
    ----------
    save_path : str | Path
        Path to save the HTML visualization.

    Returns
    -------
    Path
        Path to the saved HTML visualization

    Raises
    ------
    OptionalImportError
        If pyvis package is not installed
    """
    if not _pyvis_available:
        raise OptionalImportError("pyvis")

    save_path = Path(save_path)
    save_path.parent.mkdir(parents=True, exist_ok=True)

    net = pyvis.network.Network(
        height="800px", width="100%", notebook=False, directed=True
    )

    modality_colors = {
        "CT": "#1f77b4",  # Blue
        "MR": "#ff7f0e",  # Orange
        "PT": "#2ca02c",  # Green
        "SEG": "#d62728",  # Red
        "RTSTRUCT": "#9467bd",  # Purple
        "RTPLAN": "#8c564b",  # Brown
        "RTDOSE": "#e377c2",  # Pink
    }

    patient_trees = {}  # Store patient-to-root mappings

    def add_node_and_edges(
        node: SeriesNode, parent: SeriesNode | None = None
    ) -> None:
        color = modality_colors.get(
            node.Modality, "#7f7f7f"
        )  # Default gray if unknown
        title = f"PatientID: {node.PatientID}\nSeries: {node.SeriesInstanceUID}"
        net.add_node(
            node.SeriesInstanceUID,
            label=node.Modality,
            title=title,
            color=color,
        )
        if parent:
            net.add_edge(node.SeriesInstanceUID, parent.SeriesInstanceUID)

        for child in node.children:
            add_node_and_edges(child, node)

    # Add root nodes (each representing a patient)
    for root in self.root_nodes:
        add_node_and_edges(root)
        patient_trees[root.PatientID] = (
            root.SeriesInstanceUID
        )  # Store the root Series as entry point for the patient

    net.force_atlas_2based()

    # Generate the sidebar HTML with clickable patient IDs
    sidebar_html = """
    <div id="sidebar">
        <h2>Patient List</h2>
        <ul>
    """
    for patient_id, root_series in patient_trees.items():
        sidebar_html += f'<li><a href="#" onclick="focusNode(\'{root_series}\')">{patient_id}</a></li>'

    sidebar_html += """
        </ul>
    </div>

    <style>
        body {
            margin: 0;
            padding: 0;
        }

        #sidebar {
            position: fixed;
            left: 0;
            top: 0;
            width: 250px;
            height: 100%;
            background: #f4f4f4;
            padding: 20px;
            overflow-y: auto;
            box-shadow: 2px 0 5px rgba(0,0,0,0.3);
            z-index: 1000;
        }

        #sidebar h2 {
            text-align: center;
            font-family: Arial, sans-serif;
        }

        #sidebar ul {
            list-style: none;
            padding: 0;
        }

        #sidebar li {
            margin: 10px 0;
        }

        #sidebar a {
            text-decoration: none;
            color: #007bff;
            font-weight: bold;
            font-family: Arial, sans-serif;
        }

        #sidebar a:hover {
            text-decoration: underline;
        }

        #mynetwork {
            margin-left: 270px; /* Room for sidebar */
            height: 100vh;
        }
    </style>

    <script type="text/javascript">
        function focusNode(nodeId) {
            if (typeof network !== 'undefined') {
                network.selectNodes([nodeId]);
                network.focus(nodeId, {
                    scale: 3.5,
                    animation: {
                        duration: 500,
                        easingFunction: "easeInOutQuad"
                    }
                });
            } else {
                alert("Network graph not loaded yet.");
            }
        }
    </script>
    """

    # Generate the full HTML file
    logger.info("Saving forest visualization...", path=save_path)
    net_html = net.generate_html()
    full_html = net_html.replace(
        "<body>", f"<body>{sidebar_html}"
    )  # Insert sidebar into HTML

    # Write the final HTML file
    save_path.write_text(full_html, encoding="utf-8")

    return save_path