<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Healthcare AI - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Healthcare AI - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 08 Aug 2026 19:12:05 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/healthcare-ai/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Privacy-First Medical Image De-Identification Agent with Claude and MCP ]]>
                </title>
                <description>
                    <![CDATA[ Imagine asking an AI assistant to de-identify thousands of medical images. It runs the pipeline, tracks progress, summarizes every decision, and tells you which files need human review, all without ev ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-privacy-first-medical-image-de-identification-agent/</link>
                <guid isPermaLink="false">6a7450ad88b91c86871fa9de</guid>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Claude Desktop ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Thu, 06 Aug 2026 09:15:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/93b0e883-87c6-4e07-97ca-4024da3e45e3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine asking an AI assistant to de-identify thousands of medical images. It runs the pipeline, tracks progress, summarizes every decision, and tells you which files need human review, all without ever seeing a single pixel of patient data.</p>
<p>That sounds impossible at first. AI assistants typically need access to the data they are helping you process.</p>
<p>In this tutorial, you'll build an AI agent that doesn’t inspect sensitive medical images. Instead, it orchestrates a local de-identification pipeline through carefully designed tools, keeping the patient data entirely on your machine.</p>
<p>The technology that makes this possible is the Model Context Protocol (MCP), an open standard that lets AI models call external tools instead of relying only on their built-in capabilities.</p>
<p>In my previous article, <a href="https://www.freecodecamp.org/news/build-ai-image-de-identification-for-clinical-research/">How to Build an AI-Powered Medical Image De-Identification Pipeline for Clinical Research</a>, we already saw how to build the de-identification tool - <em><strong>Aegis</strong></em>, an open-source tool built using a MONAI (PyTorch) pipeline that removes PHI from both DICOM metadata and image pixels using OCR and NER.</p>
<p>I have since extended it with local MCP (Model Context Protocol) server support, and in this article we'll build that server from scratch with FastMCP. Then we'll connect it to Claude Desktop, turning Claude into an AI agent that can run, monitor, and audit de-identification jobs through natural conversation.</p>
<p>One note before we start: while Aegis is the example throughout, the pattern in this tutorial applies to any Python tool you want to give an AI agent access to. If you have your own pipeline, CLI, or library, you can follow along and wrap that instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-aegis-does">What Aegis Does</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-aegis">How to Set Up Aegis</a></p>
</li>
<li><p><a href="#heading-what-is-mcp-and-why-use-it">What Is MCP, and Why Use It?</a></p>
</li>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-step-1-design-the-tool-surface">Step 1: Design the Tool Surface</a></p>
</li>
<li><p><a href="#heading-step-2-build-the-mcp-server-with-fastmcp">Step 2: Build the MCP Server with FastMCP</a></p>
</li>
<li><p><a href="#heading-step-3-test-with-mcp-inspector-before-any-ai-is-involved">Step 3: Test with MCP Inspector — Before Any AI Is Involved</a></p>
</li>
<li><p><a href="#heading-step-4-connect-claude-desktop">Step 4: Connect Claude Desktop</a></p>
</li>
<li><p><a href="#heading-step-5-talk-to-your-agent">Step 5: Talk to Your Agent</a></p>
</li>
<li><p><a href="#heading-verification">Verification</a></p>
</li>
<li><p><a href="#heading-does-the-ai-ever-see-patient-data">Does the AI Ever See Patient Data?</a></p>
</li>
<li><p><a href="#heading-security-considerations">Security Considerations</a></p>
</li>
<li><p><a href="#heading-a-note-on-the-word-de-identification">A Note on the Word "De-identification"</a></p>
</li>
<li><p><a href="#heading-where-this-fits-and-whats-next">Where This Fits, and What's Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this tutorial, you'll have:</p>
<ul>
<li><p>A local MCP server that exposes the Aegis de-identification pipeline as six tools.</p>
</li>
<li><p>Claude Desktop connected to that server, with human-in-the-loop approval for each action.</p>
</li>
<li><p>An agent you can talk to in plain English: <em>"De-identify this folder and tell me if anything needs manual review."</em></p>
</li>
<li><p>A verifiable audit trail on disk that you can check against everything the agent reports.</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you should have:</p>
<ul>
<li><p>Intermediate Python experience</p>
</li>
<li><p>The Aegis repository (or your own Python tool to wrap): <a href="https://github.com/lakshmi-mahabaleshwara/aegis">https://github.com/lakshmi-mahabaleshwara/aegis</a></p>
</li>
<li><p>Python 3.10 or later</p>
</li>
<li><p>Claude Desktop installed (macOS or Windows); a free Claude account works for local MCP servers</p>
</li>
<li><p>Node.js (only used for a testing tool, not for the server itself)</p>
</li>
</ul>
<p>We'll use:</p>
<ul>
<li><p>The MCP Python SDK (<code>mcp</code>)</p>
</li>
<li><p>FastMCP (included in the SDK)</p>
</li>
<li><p>MCP Inspector for testing</p>
</li>
<li><p>Claude Desktop as the MCP host</p>
</li>
</ul>
<p>If you haven't read the previous article, you don't need to rebuild the pipeline from scratch. Cloning the Aegis repository is enough, but the earlier article explains what the pipeline is actually doing under the hood.</p>
<h2 id="heading-what-aegis-does">What Aegis Does</h2>
<p>Aegis is a medical image de-identification pipeline that removes PHI from DICOM metadata and image pixels, records every action in audit reports, and routes uncertain cases for manual review.</p>
<h2 id="heading-how-to-set-up-aegis">How to Set Up Aegis</h2>
<p>Before we build the server, get Aegis installed. The server we write in the next step imports this package, so this has to be in place first.</p>
<pre><code class="language-python"># Get the code
git clone https://github.com/lakshmi-mahabaleshwara/aegis.git
cd aegis

# Create and activate a virtual environment
python3 -m venv venv
source venv/bin/activate
# On Windows: venv\Scripts\activate

# Install Aegis (editable) plus the MCP server in one step.
# The [mcp] pulls in the MCP SDK; it also installs the
# `aegis-mcp` console command you'll point Claude Desktop at.
pip install -e ".[mcp]"

# One-time: download the OCR and NER model weights
python scripts/prefetch_models.py
</code></pre>
<p>The editable install (<code>pip install -e</code>) makes the <code>monai_aegis</code> package importable from any directory and puts the <code>aegis-mcp</code> console command on your PATH (inside the venv). The MCP server depends on this, because Claude Desktop launches it from its own working directory, not from the repository root.</p>
<p>With the package installed, we can start building the server that exposes it.</p>
<h2 id="heading-what-is-mcp-and-why-use-it">What Is MCP, and Why Use It?</h2>
<p>The Model Context Protocol (MCP) is an open standard that lets AI applications call external tools. Instead of trying to solve everything from the information already in its context, an AI model can invoke functions exposed by an external program.</p>
<p>Three roles are involved:</p>
<ul>
<li><p>Host – the AI application (Claude Desktop in our case)</p>
</li>
<li><p>Server – a small program you write that exposes tools</p>
</li>
<li><p>Tools – Python functions with names, parameters, and descriptions that the AI can call</p>
</li>
</ul>
<p>When Claude Desktop starts, it launches your MCP server and discovers the tools it exposes. It sees only each tool’s name, parameter schema, and docstring, not your implementation code. In practice, <strong>your docstrings become the prompt</strong> that helps Claude decide when to call a tool.</p>
<p><em><strong>Why use MCP at all?</strong></em> If you’re comfortable with Python, you could call the Aegis library directly from your own scripts. MCP becomes valuable when you want an AI assistant to operate that pipeline through natural conversation. Instead of writing scripts or remembering command-line options, you can simply ask:</p>
<ul>
<li><p>“De-identify this folder.”</p>
</li>
<li><p>“Is the batch finished?”</p>
</li>
<li><p>“Which files need manual review?”</p>
</li>
<li><p>“Summarize today’s run.”</p>
</li>
</ul>
<p>The underlying pipeline never changes. MCP simply provides a safe interface between the AI and your software, allowing the model to orchestrate the workflow while the actual processing remains in your local Python application.</p>
<p>We’ll use Claude Desktop as the host because it supports MCP natively. There are no bridges, extra services, or network ports to configure the server communicates with Claude Desktop over standard input/output as a local subprocess, and the entire setup is configured through a single JSON file.</p>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/1dfe474b-d9a0-4256-9dd7-302beecef935.png" alt="Architecture diagram showing Claude Desktop communicating with a local MCP server, which invokes the Aegis de-identification pipeline. Medical images are processed locally, and only summary results such as counts, decisions, and file paths are returned to the AI model." style="display:block;margin:0 auto" width="2720" height="1880" loading="lazy">

<p>Claude Desktop communicates with the MCP server, which invokes the Aegis pipeline locally. The pipeline processes the medical images on your machine, while only summaries such as counts, decisions, and file paths are returned to Claude.</p>
<h2 id="heading-step-1-design-the-tool-surface">Step 1: Design the Tool Surface</h2>
<p>Before writing code, decide what the agent can do. Our server exposes six tools:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>warm_up</code></td>
<td>Preload the OCR and NER models in Aegis so the first real call is fast</td>
</tr>
<tr>
<td><code>deidentify_file</code></td>
<td>De-identify a single DICOM/JPEG/PNG file using Aegis pipeline</td>
</tr>
<tr>
<td><code>start_batch_job</code></td>
<td>Discover and process all DICOM/image files in a directory, in the background</td>
</tr>
<tr>
<td><code>get_job_status</code></td>
<td>Check the progress of a batch job initiated previously</td>
</tr>
<tr>
<td><code>summarize_run</code></td>
<td>Audit a completed run from its report files</td>
</tr>
<tr>
<td><code>list_review_queue</code></td>
<td>List files routed to manual human review</td>
</tr>
</tbody></table>
<p><strong>We designed these tools around three simple principles:</strong></p>
<ul>
<li><p>Return summaries instead of sensitive data.</p>
</li>
<li><p>Use background jobs for long-running tasks.</p>
</li>
<li><p>Keep the tool surface small and focused.</p>
</li>
</ul>
<h2 id="heading-step-2-build-the-mcp-server-with-fastmcp">Step 2: Build the MCP Server with FastMCP</h2>
<p>Now let’s turn that design into a working MCP server. We will build it one piece at a time so you can reuse the same pattern for your own Python tools. The complete implementation lives in <code>src/monai_aegis/mcp_server.py</code>; the sections below show how it comes together.</p>
<h3 id="heading-1-the-server-instance">1. The Server Instance</h3>
<p>FastMCP (bundled with the MCP Python SDK) turns a decorated Python function into a tool.</p>
<pre><code class="language-python">from mcp.server.fastmcp import FastMCP

# creates a FastMCP server instance
mcp = FastMCP("aegis-mcp")
</code></pre>
<p>The string <code>"aegis-mcp"</code> is just the server's name. It's what Claude Desktop shows in its tool list. Every tool we add from here is a function decorated with <code>@mcp.tool()</code>.</p>
<h3 id="heading-2-your-first-tool">2. Your First Tool</h3>
<p>A tool is a decorator, a typed signature, and a docstring. Here's the one that does the real work, de-identifying a single file:</p>
<pre><code class="language-python">@mcp.tool()
def deidentify_file(input_path: str, output_dir: str = "") -&gt; dict:
    """De-identify a single medical image (DICOM, JPEG, or PNG).

    Scrubs DICOM header PHI and redacts burned-in pixel PHI using
    OCR and NER. Returns summary statistics and the output location
    only, never the redacted text or any pixel data.
    """
    ...
    return {
        "status": "success",
        "source_file": src.name,
        "output_dir": str(out),
        "pixel_regions_detected": len(pixel_rows),
        "pixel_decisions": decisions,   # e.g. {"redacted": 4, "safelisted": 10}
        "header_tags_scrubbed": tags_scrubbed,
        "needs_manual_review": decisions.get("low_confidence", 0) &gt; 0,
    }
</code></pre>
<p>Notice that Claude only sees the docstring, while the tool returns summary statistics rather than image data or extracted text.</p>
<h3 id="heading-3-never-print-to-stdout">3. Never Print to <code>stdout</code></h3>
<p>Do not use <code>print()</code> in an MCP server because <code>stdout</code> is reserved for JSON-RPC. Send logs to <code>stderr</code> instead.</p>
<h3 id="heading-4-long-jobs-need-the-async-pattern">4. Long Jobs Need the Async Pattern</h3>
<p>Processing an entire directory can take several minutes, which is longer than an MCP tool call should block. Instead of waiting synchronously, <code>start_batch_job</code> creates a background thread, immediately returns a <code>job_id</code>, and lets Claude poll the progress using <code>get_job_status()</code>.</p>
<pre><code class="language-python">import threading, uuid

_jobs = {}

@mcp.tool()
def start_batch_job(input_dir: str, output_dir: str = "", mode: str = "auto") -&gt; dict:
    """Start a background job that de-identifies all DICOM/image files
    in a directory. Returns immediately with a job_id. Use get_job_status
    to check progress, do not wait synchronously.
    """
    job_id = uuid.uuid4().hex[:8]
    _jobs[job_id] = {"job_id": job_id, "state": "queued",
                     "processed": 0, "total": None,
                     "decisions": {}, "errors": []}
    threading.Thread(
        target=_batch_worker, args=(job_id, input_dir, output_dir),
        daemon=True,
    ).start()
    return {"status": "started", "job_id": job_id,
            "next_step": f"Call get_job_status with job_id '{job_id}'."}
</code></pre>
<p>The worker thread updates <code>_jobs[job_id]</code> as it processes each file, and the polling tool just reads it back:</p>
<pre><code class="language-python">@mcp.tool()
def get_job_status(job_id: str) -&gt; dict:
    """Return the current state, progress, and decision counts for a job."""
    return _jobs.get(job_id, {"status": "unknown", "job_id": job_id})
</code></pre>
<p>Since the MCP server is a long-running process, it can keep the job registry in memory throughout the conversation. If the server restarts, active job IDs are lost, but the de-identified files and audit reports remain safely on disk.</p>
<h3 id="heading-5-heavy-models-need-a-warm-up"><strong>5. Heavy Models Need a Warm-Up</strong></h3>
<p>Aegis loads EasyOCR and a Stanford NER model, which takes time. The server builds the pipeline lazily and caches it, so it pays that cost once, and exposes a <code>warm_up</code> tool so the first <em>real</em> call doesn't run into a timeout while models load:</p>
<pre><code class="language-python">@mcp.tool()
def warm_up() -&gt; dict:
    """Preload the OCR and NER models so the first real call is fast."""
    _get_pipeline()   # builds and caches the pipeline on first use
    return {"status": "ready"}
</code></pre>
<h3 id="heading-6-the-audit-tools-read-the-records-not-the-images">6. The Audit Tools Read the Records, Not the Images</h3>
<p>The remaining tools read the reports and review folders that Aegis already produces. Because they summarize existing audit records rather than reprocessing images, Claude can answer questions about completed runs without accessing the underlying medical images.</p>
<pre><code class="language-python">@mcp.tool()
def summarize_run(run_dir: str) -&gt; dict:
    """Audit a run from its CSV reports. Returns counts only — never text or tag values."""
    run = Path(run_dir).expanduser().resolve()
    pixels = _read_csv_rows(run / "aegis_pixel_detections.csv")
    tags = _read_csv_rows(run / "aegis_tag_actions.csv")
    return {
        "pixel_decisions": Counter(r["decision"] for r in pixels),  # redacted / safelisted / low_confidence
        "tag_actions": Counter(r["action"] for r in tags),          # REMOVE / REMAP / ZERO / DUMMY / ATTEST
</code></pre>
<pre><code class="language-python">@mcp.tool()
def list_review_queue() -&gt; dict:
    """List files quarantined for manual review — names only, never contents."""
    names = sorted(f for _, _, fs in os.walk(REVIEW_DIR) for f in fs if not f.startswith("."))
    return {"count": len(names), "files": names[:50]}
</code></pre>
<h2 id="heading-step-3-test-with-mcp-inspector-before-any-ai-is-involved">Step 3: Test with MCP Inspector — Before Any AI Is Involved</h2>
<p>If you are skeptical about all of this (I was), this step is for you. MCP Inspector is a debug UI that connects to your server and lets <em>you</em> click the tools manually.</p>
<pre><code class="language-shell">npx @modelcontextprotocol/inspector /&lt;ABSOLUTE PATH&gt;/aegis/venv/bin/aegis-mcp
</code></pre>
<p>The Inspector opens on the <strong>Servers</strong> screen with your <code>aegis-mcp</code> server listed. Click the toggle to connect, it turns green when the server is running.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/7e952989-9e3e-4f45-9a08-579821699963.png" alt="MCP Inspector showing the Aegis MCP server connected over STDIO, with the server status active and ready for testing." style="display:block;margin:0 auto" width="2513" height="983" loading="lazy">

<p>Next, open the <strong>Tools</strong> tab. You’ll see the six tools exposed by your MCP server. Run <code>warm_up</code> first, and watch the terminal where you started the Inspector to see the OCR and NER models load.</p>
<p>Next, run <code>deidentify_file</code> with the path to a test image. The <strong>Results</strong> panel shows the tool’s JSON response, while <strong>Messages</strong> shows the request and response exchanged with the server.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/a3d0ee93-165f-4093-bb38-70a6776d6f03.png" alt="MCP Inspector displaying the deidentify_file tool with its input fields and the JSON response returned after processing a test medical image." style="display:block;margin:0 auto" width="2544" height="1129" loading="lazy">

<h2 id="heading-step-4-connect-claude-desktop">Step 4: Connect Claude Desktop</h2>
<p>Open <strong>Claude Desktop</strong> and go to <strong>Settings → Developer → Edit Config</strong>. This opens (or creates) the MCP configuration file. On macOS it’s located at <code>~/Library/Application Support/Claude/claude_desktop_config.json</code>, and on Windows at <code>%APPDATA%\Claude\claude_desktop_config.json</code>.</p>
<p>Add the following <code>aegis</code> entry under <code>mcpServers</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "aegis": {
      "command": "/&lt;PATH TO AEGIS&gt;/aegis/venv/bin/aegis-mcp",
      "args": [],
      "env": {
        "AEGIS_OUTPUT_DIR": "/&lt;PATH TO AEGIS&gt;/aegis/staging_output",
        "AEGIS_REVIEW_DIR": "/&lt;PATH TO AEGIS&gt;/aegis/staging_not_processed",
        "AEGIS_DEVICE": "mps"
      }
    }
  }
}
</code></pre>
<p>Configuration for Claude Desktop to connect to the Aegis MCP server.</p>
<p>A few things to check before saving:</p>
<ul>
<li><p>Use absolute paths for command, <code>AEGIS_OUTPUT_DIR</code>, and <code>AEGIS_REVIEW_DIR</code>.</p>
</li>
<li><p>Point command to the aegis-mcp executable inside your virtual environment so Claude uses the correct Python installation and dependencies.</p>
</li>
<li><p>Set <code>AEGIS_DEVICE</code> to <code>mps</code> for Apple Silicon, <code>cpu</code> for Intel Macs or Linux, or <code>cuda</code> if you have an NVIDIA GPU.</p>
</li>
</ul>
<p>Save the file, <strong>fully quit and restart Claude Desktop</strong> (closing the window isn’t enough). In a new chat, open the <strong>Search &amp; Tools</strong> menu, you should now see the <strong>Aegis</strong> MCP server with its six available tools.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/d3ec5a5e-80db-48f8-8521-ce56ada47e4c.png" alt="Claude Desktop Search &amp; Tools menu showing the Aegis MCP server and its available tools, including warm_up, deidentify_file, start_batch_job, get_job_status, summarize_run, and list_review_queue." style="display:block;margin:0 auto" width="868" height="776" loading="lazy">

<h2 id="heading-step-5-talk-to-your-agent">Step 5: Talk to Your Agent</h2>
<p>Time for the first conversation. Send:</p>
<blockquote>
<p>Warm up the Aegis de-identification server.</p>
</blockquote>
<p>Claude will ask for permission before calling the tool. This prompt is a feature, every capability you have given the agent requires your explicit approval, and you can grant it per call or per tool.</p>
<p>Once approved, the models load and Claude reports back with the elapsed time.</p>
<p>De-identify a single file:</p>
<blockquote>
<p>De-identify the file &lt;/test_ultrasound.dcm.&gt;</p>
</blockquote>
<p>Claude calls <code>deidentify_file</code> and answers with the counts: how many text regions were detected, how many were redacted versus safe listed as clinical text, how many DICOM tags were scrubbed, and whether anything needs review. It narrates all of this without ever having seen the image.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/68139acd-4fc7-4ad9-9199-4d419a0062f0.png" alt="Claude Desktop conversation where the user asks the Aegis MCP server to de-identify a medical image, and Claude reports summary statistics such as detected text regions, redactions, and whether manual review is required." style="display:block;margin:0 auto" width="1124" height="360" loading="lazy">

<p>Next, a batch:</p>
<blockquote>
<p>Now start a batch de-identification job for &lt;/test_ultrasound&gt;</p>
</blockquote>
<p>The tool returns instantly with a job ID, and the processing continues in the background. Ask about it a moment later:</p>
<blockquote>
<p>How's that job going?</p>
</blockquote>
<p>Claude remembers the job ID across turns and polls <code>get_job_status</code>, reporting files processed, running decision counts, and any errors.</p>
<p>Once the job finishes, you can ask Claude to summarize the run, list files requiring manual review, or generate a report for your review meeting, all from the pipeline’s audit records.</p>
<blockquote>
<p>Summarize the completed run. Does anything need manual review?</p>
</blockquote>
<blockquote>
<p>What exactly was done to test_ultrasound.dcm, which header tags were touched?</p>
</blockquote>
<blockquote>
<p>Draft a short de-identification summary for this run, suitable for a review meeting. Include totals, the decision breakdown, and files pending human review.</p>
</blockquote>
<p>Everything in the below summary came from tool results, counts and decisions read from the pipeline's own records.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/70353644-c512-470c-a32e-72794d644343.png" alt="Claude Desktop conversation showing the progress of a batch de-identification job, including processed files, decision counts, and current job status returned by the MCP server." style="display:block;margin:0 auto" width="907" height="963" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/1b5f583c-224c-4f89-bf09-3208332beafb.png" alt="Claude Desktop generating a summary report of a completed de-identification run, including totals, decision breakdowns, and files requiring manual review based on Aegis audit records." style="display:block;margin:0 auto" width="903" height="1077" loading="lazy">

<h2 id="heading-verification">Verification</h2>
<p>Compare Claude’s summary with the CSV reports and <code>job_summary_&lt;job_id&gt;.json</code>. The numbers should match, giving you a simple way to verify everything the agent reports.</p>
<h2 id="heading-does-the-ai-ever-see-patient-data">Does the AI Ever See Patient Data?</h2>
<p>No. The tools never return image pixels, OCR-extracted text, or DICOM tag values, so Claude cannot access the underlying PHI.</p>
<p>For example, if you ask:</p>
<blockquote>
<p>Show me the patient name detected in that file.</p>
</blockquote>
<p>Claude cannot answer because that information is never exposed by the MCP tools.</p>
<p>However, conversation metadata does reach the model. File names, directory paths, counts, and tool outputs are processed by Claude just like any other chat. Keep these points in mind:</p>
<ul>
<li><p><strong>Avoid PHI in file names.</strong> Rename files if they contain patient names or identifiers.</p>
</li>
<li><p><strong>Keep error messages clean.</strong> Don’t include sensitive DICOM values in exceptions returned by your tools.</p>
</li>
<li><p><strong>Use synthetic data while developing.</strong> This tutorial uses fake PHI. Before working with real patient data, follow your organization’s security and compliance requirements.</p>
</li>
</ul>
<h2 id="heading-security-considerations">Security Considerations</h2>
<p>If you adapt this pattern for your own tools, consider these best practices:</p>
<ul>
<li><p><strong>Restrict file access.</strong> Limit tools to approved directories instead of allowing any readable path.</p>
</li>
<li><p><strong>Review tool permissions.</strong> It’s reasonable to always allow read-only tools like <code>get_job_status</code>, but keep approval prompts for tools that modify files.</p>
</li>
<li><p><strong>Return structured results.</strong> Prefer counts and categories over raw text to reduce the chance of exposing sensitive information.</p>
</li>
<li><p><strong>Pin model versions.</strong> Using fixed OCR and NER model versions makes your pipeline more reproducible and predictable.</p>
</li>
</ul>
<h2 id="heading-a-note-on-the-word-de-identification">A Note on the Word "De-identification"</h2>
<p>In regulations such as HIPAA, <strong>de-identification</strong> has a specific legal meaning with defined requirements. This tutorial shows how to build an AI agent interface for a de-identification pipeline, not how to certify regulatory compliance. The pipeline intentionally routes uncertain cases for human review, and any real-world deployment should be validated against your organization’s policies and applicable regulations.</p>
<h2 id="heading-where-this-fits-and-whats-next">Where This Fits, and What's Next</h2>
<p>MCP doesn’t change how well Aegis detects PHI, it changes <strong>how people interact with it</strong>. Instead of using the command line, users can run jobs, review results, and ask questions in natural language.</p>
<p>For automated workflows such as nightly batch processing, the CLI is still the better choice. MCP is best for interactive, human-in-the-loop tasks, while the CLI remains ideal for scheduled jobs. In both cases, the files and audit reports on disk remain the source of truth.</p>
<p><strong>Here are a few directions you can explore next:</strong></p>
<ul>
<li><p><strong>Run everything locally.</strong> Pair the same MCP server with Ollama and Open WebUI so both the pipeline and the AI model stay on your machine.</p>
</li>
<li><p><strong>Strengthen security</strong>. Restrict tools to approved directories before deploying in a shared environment.</p>
</li>
<li><p><strong>Build dataset preparation workflows</strong>. Use the agent to de-identify data, summarize results, and prepare datasets for machine learning.</p>
</li>
<li><p><strong>Add downstream analysis.</strong> Run vision models on the de-identified outputs instead of the original images.</p>
</li>
<li><p><strong>Reuse the pattern elsewhere</strong>. The same architecture can orchestrate pipelines that remove sensitive information from legal documents, logs, financial records, or other confidential data.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>We turned an existing Python pipeline into an AI-accessible tool using MCP. The key design principle is <strong>the model orchestrates the workflow but never sees the sensitive data.</strong></p>
<p>This pattern extends beyond medical imaging. Any pipeline that handles sensitive information: such as legal documents, financial records, or personal photos can expose safe, structured tools while keeping the underlying data private.</p>
<p>You can find the complete implementation in the <strong>Aegis</strong> repository: <a href="https://github.com/lakshmi-mahabaleshwara/aegis">https://github.com/lakshmi-mahabaleshwara/aegis</a>. If you found this tutorial useful, <em><strong>consider starring the repository to help others discover it.</strong></em></p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://modelcontextprotocol.io/">Model Context Protocol documentation</a></p>
</li>
<li><p><a href="https://github.com/modelcontextprotocol/python-sdk">MCP Python SDK</a></p>
</li>
<li><p><a href="https://support.claude.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop">Getting started with local MCP servers on Claude Desktop</a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/build-ai-image-de-identification-for-clinical-research/">How to Build an AI-Powered Medical Image De-Identification Pipeline for Clinical Research</a></p>
</li>
<li><p><a href="https://github.com/lakshmi-mahabaleshwara/aegis">Aegis on GitHub</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Train a Tumor Segmentation Model on Ultrasound Data with MONAI ]]>
                </title>
                <description>
                    <![CDATA[ Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-train-a-tumor-segmentation-model-on-ultrasound-data-with-monai/</link>
                <guid isPermaLink="false">6a60f5843dee1fe3a0faaca9</guid>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ medical image segmentation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:53:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3e7cfe47-858c-4ce9-b8f9-1c5fc22f29b7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the data.</p>
<p>In this tutorial we’ll profile the dataset first, then let those observations drive every design decision in a MONAI segmentation pipeline.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-who-is-this-for">Who is This For?</a></p>
</li>
<li><p><a href="#heading-about-the-dataset">About the Dataset</a></p>
</li>
<li><p><a href="#heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</a></p>
</li>
<li><p><a href="#heading-what-is-dice">What is Dice?</a></p>
</li>
<li><p><a href="#heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</a></p>
<ul>
<li><p><a href="#heading-class-balance-drives-the-loss">Class Balance Drives the Loss</a></p>
</li>
<li><p><a href="#heading-patient-counts-drive-the-split">Patient Counts Drive the Split</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</a></p>
<ul>
<li><p><a href="#heading-a-single-config-object">A Single Config Object</a></p>
</li>
<li><p><a href="#heading-the-patient-grouped-split">The Patient-grouped Split</a></p>
</li>
<li><p><a href="#heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</a></p>
</li>
<li><p><a href="#heading-model-loss-and-metric">Model, Loss, and Metric</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-reading-the-results">Reading the Results</a></p>
</li>
<li><p><a href="#heading-prediction-visualization">Prediction Visualization</a></p>
</li>
<li><p><a href="#heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
<li><p><a href="#heading-takeaway">Takeaway</a></p>
</li>
<li><p><a href="#heading-reference">Reference</a></p>
</li>
</ul>
<h2 id="heading-who-is-this-for">Who is This For?</h2>
<p>This walkthrough assumes you have some comfort with Python and the basics of training a neural network. It explains the MONAI-specific pieces (dictionary transforms, <code>DiceCELoss</code>, <code>DiceMetric</code>) and the medical-imaging terms (BI-RADS, hypoechoic, patient-grouped folds) as they come up. No prior ultrasound experience is needed.</p>
<h2 id="heading-about-the-dataset">About the Dataset</h2>
<p>The dataset is <a href="https://www.kaggle.com/datasets/orvile/bus-bra-a-breast-ultrasound-dataset">BUS-BRA</a>, a public collection of breast ultrasound images with biopsy-proven labels and tumor segmentation masks.</p>
<p>Each image carries a benign/malignant label, a BI-RADS (Breast Imaging Reporting and Data System)&nbsp;category (a radiologist's suspicion score from 2 to 5), a histology string, and a binary tumor mask. The CSV that ships with it also includes predefined cross-validation folds.</p>
<p>The task is binary: separate tumor from background. BUS-BRA contains 1,875 B-mode breast ultrasound images from 1,064 patients, acquired on four scanners at a cancer institute in Brazil.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/24ecadc1-13ff-4dfb-a6fc-61ab03785a7e.png" alt="Example from the BUS-BRA dataset showing a breast ultrasound image, its binary tumor segmentation mask, and the mask overlaid on the original image." style="display:block;margin:0 auto" width="640" height="409" loading="lazy">

<h2 id="heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</h2>
<p>MONAI (Medical Open Network for AI) is an open-source PyTorch framework built specifically for medical imaging. It's a domain-specific layer that sits on top of PyTorch: you still write standard PyTorch training loops, but MONAI provides the medical imaging-specific components so you don't have to build them yourself.</p>
<p>It gives you:</p>
<ul>
<li><p><strong>Transforms</strong> for medical data, loading formats like DICOM and NIfTI, normalizing intensities, resizing, and augmenting, all in a dictionary-based pipeline that keeps an image and its mask in sync.</p>
</li>
<li><p><strong>Network architectures</strong> common in medical segmentation (U-Net, UNETR, SegResNet, and others) ready to instantiate.</p>
</li>
<li><p><strong>Loss functions and metrics</strong> designed for segmentation, including Dice-based losses and the Dice metric.</p>
</li>
</ul>
<p>The result is less boilerplate and fewer chances for an image and its mask to drift out of alignment.</p>
<h2 id="heading-what-is-dice">What is Dice?</h2>
<p>Dice (the Dice similarity coefficient) measures how much two regions overlap. In segmentation, it compares the model's predicted mask against the ground-truth mask and returns a score from 0 to 1: 0 means no overlap at all, 1 means a perfect match.</p>
<p>The formula is:</p>
<p><code>Dice = 2 × (overlap) / (predicted area + true area)</code></p>
<p>The "2 ×" in the numerator is what keeps the score in the 0-to-1 range even though the denominator counts the overlapping pixels on both sides.</p>
<p>Two roles it plays in this tutorial:</p>
<ul>
<li><p>As a <strong>metric</strong>, Dice is how the run is scored. A validation Dice of 0.876 means the predicted tumor masks overlap the true masks by about 88% on average.</p>
</li>
<li><p>As a <strong>loss</strong> (<code>DiceCELoss</code>), a Dice-based term is what the model trains against. This is the part that matters for the class-imbalance problem: because Dice measures overlap rather than per-pixel correctness, a model can't score well by labeling everything as background. A small tumor counts as much as a large one, so the model is pushed to actually find the tumor region.</p>
</li>
</ul>
<h2 id="heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</h2>
<p>This first pass is data profiling. It reads every image and mask once and answers a short list of questions whose answers determine how the pipeline must be built. Running these checks takes a few seconds and saves a lot of guesswork later.</p>
<p>The snapshot below summarizes the properties that directly influenced the pipeline design. We’ll let these observations determine each step of the workflow.</p>
<table>
<thead>
<tr>
<th>What the snapshot measured</th>
<th>The number</th>
<th>What it forces</th>
</tr>
</thead>
<tbody><tr>
<td>Distinct image resolutions</td>
<td>Hundreds of different (width, height) pairs</td>
<td>Images must be resized to a fixed size before batching</td>
</tr>
<tr>
<td>Class balance</td>
<td>Background : foreground ≈ 10.6 : 1</td>
<td>A plain pixel-wise loss may converge toward predicting mostly background because doing so already yields high pixel accuracy on this imbalanced dataset.</td>
</tr>
<tr>
<td>Per-image brightness</td>
<td>Wide spread across the dataset</td>
<td>Intensity normalization belongs in the transform pipeline</td>
</tr>
<tr>
<td>Patients vs. images</td>
<td>1,064 patients, 1,875 images (paired left/right views)</td>
<td>Splits must be grouped by patient, or the same person leaks across train and validation</td>
</tr>
<tr>
<td>Mask components</td>
<td>Every mask is a single connected region</td>
<td>A prediction with several disconnected blobs is provably wrong</td>
</tr>
<tr>
<td>Pixel format</td>
<td>Images are 8-bit grayscale, masks are 1-bit binary</td>
<td>Load as single-channel, binarize the mask after loading</td>
</tr>
</tbody></table>
<p>Two of these deserve a closer look because they shape the two most important decisions.</p>
<h3 id="heading-class-balance-drives-the-loss">Class Balance Drives the Loss</h3>
<p>Tumors are small. Across the dataset, background pixels outnumber tumor pixels by more than ten to one.</p>
<p>A model trained with ordinary binary cross-entropy can score around 91% pixel accuracy by labeling everything as background. This high number reflects the imbalance rather than any ability to find the tumor.</p>
<p>The fix is a loss that rewards overlap with the actual tumor region, which points directly at Dice.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/69c14498-7119-4f15-a29b-9bf34fdcd51f.png" alt="Bar chart comparing foreground and background pixels in the BUS-BRA dataset. Background pixels outnumber tumor pixels by approximately 10.6 to 1, illustrating the strong class imbalance." style="display:block;margin:0 auto" width="1731" height="649" loading="lazy">

<h3 id="heading-patient-counts-drive-the-split">Patient Counts Drive the Split</h3>
<p>There are fewer patients than images because many patients contribute both a left-side and a right-side scan. If a random split puts one patient's left scan in training and their right scan in validation, the validation score is inflated by leakage.</p>
<p>The dataset authors already solved this: the CSV ships a <code>K5P</code> column: a 5-fold split where <strong>P</strong> stands for patient-grouped, meaning every image from a given patient lands in the same fold. Reusing it is safer than rebuilding the same grouping by hand.</p>
<p>With those answers in hand, the pipeline has a specification to build now.</p>
<h2 id="heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</h2>
<p>Everything below uses MONAI for the segmentation-specific work:<br>transforms, dataset wrapping, the network, the loss, and the metric.</p>
<h3 id="heading-a-single-config-object">A Single Config Object</h3>
<p>The pipeline reads all its knobs from one dataclass. Nothing downstream hard-codes a constant, so re-running an experiment with a different fold or image size is a single edit.</p>
<pre><code class="language-python">from dataclasses import dataclass
from typing import Tuple, Optional
from pathlib import Path

@dataclass
class TrainConfig:
    data_root: Optional[Path] = None
     fold_column: str = "K5P"          # patient-grouped 5-fold (dev set)
    val_fold: int = 1                 # which K5P fold is validation
    test_column: str = "HOP"          # patient-grouped hold-out partition
    test_group: int = 1               # HOP value reserved as the test set

    image_size: Tuple[int, int] = (256, 256)
    batch_size: int = 16
    lr: float = 1e-3
    epochs: int = 30
    use_amp: bool = True              # mixed precision
    ckpt_path: str = "best_model.pt"

cfg = TrainConfig()
</code></pre>
<p>The code above defines a <code>TrainConfig</code> dataclass holding every setting the pipeline needs: the fold column and which fold to validate on, the target image size, batch size, learning rate, epoch count, a mixed-precision switch, and where to save the best model. Creating <code>cfg</code> once gives every later step a single place to read its settings from.</p>
<h3 id="heading-the-patient-grouped-split">The Patient-grouped Split</h3>
<p>The split uses two predefined columns. <code>HOP</code> (Hold-Out Partition) reserves a patient-disjoint slice as the test set, untouched until the very end. Within the remaining development set, one <code>K5P</code> fold becomes validation and the other four are training. Short assertions confirm no patient appears in more than one split.</p>
<pre><code class="language-python">dev_df   = manifest[manifest[cfg.test_column] != cfg.test_group]
test_df  = manifest[manifest[cfg.test_column] == cfg.test_group]

train_df = dev_df[dev_df[cfg.fold_column] != cfg.val_fold]
val_df   = dev_df[dev_df[cfg.fold_column] == cfg.val_fold]

# no patient may appear in more than one split
for a, b in [(train_df, val_df), (train_df, test_df), (val_df, test_df)]:
    assert not (set(a["Case"]) &amp; set(b["Case"])), "patient leakage"
</code></pre>
<p>The above code first splits off the <code>HOP</code> test set, then divides the remaining development rows into validation (the chosen <code>K5P</code> fold) and training (the rest). It then checks that every pair of splits shares no patient <code>Case</code>. If any does, the assertion fails immediately.</p>
<h3 id="heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</h3>
<p>MONAI's dictionary transforms operate on records keyed by name (<code>"image"</code> and <code>"label"</code>) and apply matched operations to both. Each step here answers a <strong>Part 1 data profile</strong> finding.</p>
<pre><code class="language-python">from monai.transforms import (
    Compose, LoadImaged, EnsureChannelFirstd, ScaleIntensityd,
    AsDiscreted, Resized, RandFlipd, EnsureTyped,
)
import torch

base = [
    LoadImaged(keys=["image", "label"], reader="PILReader", image_only=True),
    EnsureChannelFirstd(keys=["image", "label"]),
    ScaleIntensityd(keys="image"),                       # brightness spread
    AsDiscreted(keys="label", threshold=0.5),            # clean {0, 1} mask
    Resized(keys=["image", "label"],                     # hundreds of sizes
            spatial_size=cfg.image_size,
            mode=("bilinear", "nearest")),
]

train_transforms = Compose(base + [
    RandFlipd(keys=["image", "label"], prob=0.5, spatial_axis=1),  # horizontal
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
val_transforms = Compose(base + [
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
</code></pre>
<p>The above code builds a shared list of base steps, loads the PNG, moves the channel to the front, scales the image to [0, 1], binarizes the mask, and resizes both to 256×256. It then wraps that list in two pipelines. The training pipeline adds a random horizontal flip, and the validation pipeline does not, so evaluation always sees the image as-is.</p>
<p>Horizontal flips are a simple augmentation that preserve anatomical plausibility in this dataset. More aggressive augmentations, such as large rotations or elastic deformations, should be validated carefully because they may distort clinically meaningful structures.</p>
<p>Images use bilinear interpolation to preserve intensity gradients, while masks use nearest-neighbor interpolation so class labels remain strictly 0 or 1. Bilinear interpolation on masks would create artificial label values along object boundaries.</p>
<h3 id="heading-model-loss-and-metric">Model, Loss, and Metric</h3>
<p>The network is a MONAI <code>UNet</code> with one input channel (grayscale) and one output channel (the tumor logit). The loss is the one the class-balance finding pointed at.</p>
<p>U-Net consists of an encoder that captures context at progressively coarser resolutions and a decoder that reconstructs fine spatial detail. Skip connections transfer high-resolution features directly from encoder to decoder, making U-Net especially effective for medical segmentation where boundaries matter.</p>
<pre><code class="language-python">from monai.networks.nets import UNet
from monai.losses import DiceCELoss
from monai.metrics import DiceMetric
from monai.transforms import Activations, AsDiscrete

model = UNet(
    spatial_dims=2, in_channels=1, out_channels=1,
    channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2),
    num_res_units=2,
).to(device)

loss_fn = DiceCELoss(sigmoid=True)       # Dice handles the imbalance; CE smooths the gradient
metric  = DiceMetric(include_background=True, reduction="mean")
post_pred = Compose([Activations(sigmoid=True), AsDiscrete(threshold=0.5)])
   
</code></pre>
<p>The above code creates the U-Net (five resolution levels, one input and one output channel) and moves it to the GPU. It then defines the three pieces that surround it: the loss, the validation metric, and a <code>post_pred</code> step that turns raw model outputs into a clean 0/1 mask by applying a sigmoid and thresholding at 0.5.</p>
<p><code>DiceCELoss</code> combines two terms. The Dice part is scale-invariant in the foreground area, so a small tumor counts as much as a large one and the model can't win by ignoring tumors. The cross-entropy part adds a smoother gradient where Dice is flat. The <code>sigmoid=True</code> flag tells the loss to apply the activation itself, so the model outputs raw logits and the <code>post_pred</code> step handles the sigmoid-and-threshold at evaluation time. This U-Net comes out to about 1.6 million parameters.</p>
<p>The training loop itself is mostly standard PyTorch. MONAI stays out of the optimization logic, the only segmentation-specific pieces are the loss, transforms, and evaluation metric.</p>
<pre><code class="language-python">for epoch in range(1, cfg.epochs + 1):
    model.train()
    for batch in train_loader:
        img, lab = batch["image"].to(device), batch["label"].to(device)
        optimizer.zero_grad(set_to_none=True)
        with torch.amp.autocast("cuda", enabled=cfg.use_amp):
            loss = loss_fn(model(img), lab)
        scaler.scale(loss).backward()
        scaler.step(optimizer); scaler.update()

    model.eval(); metric.reset()
    with torch.no_grad():
        for batch in val_loader:
            img, lab = batch["image"].to(device), batch["label"].to(device)
            pred = post_pred(model(img))
            metric(y_pred=pred, y=lab)
    val_dice = metric.aggregate().item()
    if val_dice &gt; best_dice:
        best_dice = val_dice
        torch.save(model.state_dict(), cfg.ckpt_path)
</code></pre>
<p>In the above code, each epoch runs two passes. The training pass moves every batch to the GPU, computes the loss under mixed precision, and updates the weights through the gradient scaler. The validation pass then runs with gradients turned off, converts predictions with <code>post_pred</code>, and accumulates Dice across the fold. Whenever the epoch's Dice beats the best seen so far, the model weights are saved to disk.</p>
<h2 id="heading-reading-the-results">Reading the Results</h2>
<p>Two curves summarize the run. Training loss falls steadily and flattens near 0.12. Validation Dice climbs from about 0.57 to a plateau, with a best of <strong>0.866</strong> reached at epoch 28.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/2a70807b-9d3d-4ff1-9610-7100aaaac984.png" alt="Training curves showing loss decreasing steadily over 30 epochs while validation Dice increases and plateaus around 0.866, indicating convergence with mild overfitting." style="display:block;margin:0 auto" width="1526" height="470" loading="lazy">

<p>A few things are worth reading off these curves:</p>
<ul>
<li><p>The loss decreasing monotonically means the model is learning. The gradient signal is real.</p>
</li>
<li><p>The loss flattening above zero rather than reaching it is expected. <code>DiceCELoss</code> has a floor, because the cross-entropy term never fully vanishes on ambiguous boundary pixels. A loss that reached zero would be a warning sign, not a triumph.</p>
</li>
<li><p>Validation Dice plateauing above ~0.85 while training loss keeps falling is the mild-overfitting signature. Extra epochs mostly lower train loss without moving val Dice. It's not severe here, so the 30-epoch budget is fine, but a patience-based early-stopping rule would be a reasonable add.</p>
</li>
</ul>
<p>A validation Dice of 0.866 sits in a reasonable range for a plain 2D U-Net on this dataset. But validation Dice measures a checkpoint chosen using that same set, so it runs a little optimistic.</p>
<p>The final, untouched check is the <code>HOP</code> test set, scored exactly once, after all training and model selection are done. It comes in at <strong>0.864</strong>, essentially matching the 0.866 validation figure. The model generalizes to patients it never saw during training or selection, and the validation number wasn't hiding leakage.</p>
<h2 id="heading-prediction-visualization"><strong>Prediction Visualization</strong></h2>
<p>Metrics summarize overall performance, but they don’t show <em>how</em> the model is segmenting individual tumors.</p>
<p>The figure below presents a representative validation example. From left to right are the input ultrasound image, the ground-truth mask, the model’s predicted mask, and the prediction overlaid on the original image.</p>
<p>The close agreement between the prediction and the ground-truth annotation illustrates how the model localizes both the position and the boundary of the lesion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/ada82859-b2ba-4fd4-bc45-086177990a3f.png" alt="Four-panel visualization showing a representative segmentation result: the original breast ultrasound image, the ground-truth tumor mask, the model’s predicted mask, and the predicted mask overlaid on the original image. The prediction closely matches the annotated tumor boundary." style="display:block;margin:0 auto" width="1597" height="1129" loading="lazy">

<h2 id="heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</h2>
<p>An average Dice of 0.866 can hide very different behaviors. It could mean every case is mediocre, or most cases are excellent and a few fail badly.</p>
<p>To distinguish between those possibilities, sort the validation set by per-image Dice and inspect the lowest-scoring predictions.</p>
<p>On this fold, only 4 of 299 validation cases scored below 0.5, about 1%. Looking at those four overlays surfaces a clear pattern. Three of the four worst predictions are <strong>fragmented</strong>: the model outputs several disconnected blobs where the ground truth is a single region. The fourth confuses a dark acoustic shadow, a common ultrasound artifact, for tumor tissue.</p>
<p>That fragmentation pattern connects straight back to a snapshot finding: the data-quality pass measured that <strong>every ground-truth mask in BUS-BRA is a single connected component</strong>. So a multi-blob prediction is wrong by a property of the dataset, which points at keeping only the largest connected component as a post-processing step:</p>
<pre><code class="language-python">from monai.transforms import KeepLargestConnectedComponent

post_pred = Compose([
    Activations(sigmoid=True),
    AsDiscrete(threshold=0.5),
    KeepLargestConnectedComponent(applied_labels=[1]),
])
</code></pre>
<p>This code rebuilds the <code>post_pred</code> pipeline with one extra step at the end. After the sigmoid and threshold produce a binary mask, <code>KeepLargestConnectedComponent</code> discards every predicted region except the largest one, so a prediction split into several blobs collapses to its single biggest piece. This matches the dataset's one-region-per-mask property.</p>
<p>I measured this on the validation set, and the honest result is more nuanced than "free accuracy." It recovers a few of the fragmented cases, but the net change in mean Dice is marginal and can even go slightly negative. When a real lesion is predicted as two touching pieces, discarding the smaller one throws away true-positive area. So it's a targeted lever for a specific failure mode, not a free boost: worth exploring, not adopting blindly.</p>
<p>The shadow-confusion case is harder still, telling a hypoechoic tumor from a dark shadow region sometimes needs context a small grayscale crop doesn't carry. This points toward higher resolution or a wider receptive field as directions for later experiments.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>Once you have a reliable baseline, the next experiments become much more meaningful. Rather than randomly trying larger models, start from the failure modes you observed:</p>
<ul>
<li><p>Replace the 2D U-Net with Attention U-Net or DynUNet.</p>
</li>
<li><p>Train at higher resolution to better capture small lesions.</p>
</li>
<li><p>Apply connected-component analysis selectively during inference.</p>
</li>
<li><p>Explore test-time augmentation.</p>
</li>
<li><p>Compare DiceCE with Focal Tversky loss for highly imbalanced lesions.</p>
</li>
</ul>
<h2 id="heading-takeaway">Takeaway</h2>
<p>The through-line is profile the data, then let what you find make the decisions.</p>
<p>The resize came from a resolution check. The loss came from a class-balance check. The split came from a patient count. The most useful post-processing idea came from a mask-component check run before training started. None of these were guesses, and none of them needed a sweep to discover.</p>
<p>A model is easy to build. A model whose every choice has a reason behind it is easier to trust, easier to debug, and easier to explain to the person who reads it next.</p>
<h2 id="heading-reference">Reference</h2>
<p>The complete, runnable code for this walkthrough is available as a MONAI notebook: <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/bus_bra_tumor_segmentation/data_and_tutorials/bus_bra_tutor_segmentation"><code>busbra_segmentation_monai.ipynb</code></a>. It runs top to bottom on Kaggle or Colab, and auto-downloads the dataset if it's not already attached.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Hidden PHI Problem in Medical Images: Building a Synthetic Dataset for AI De-Identification ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you'll learn how my team built a synthetic PHI generation pipeline to create privacy-safe training and validation data for medical imaging AI. The Problem Imagine you’re building an A ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-synthetic-dataset-for-ai-de-identification/</link>
                <guid isPermaLink="false">6a357b2a9d624935c947cccf</guid>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ dicom ]]>
                    </category>
                
                    <category>
                        <![CDATA[ synthetic data ]]>
                    </category>
                
                    <category>
                        <![CDATA[ healthtech ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Fri, 19 Jun 2026 17:23:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/74f053ea-3efc-4ef0-932b-d423dccba44a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you'll learn how my team built a synthetic PHI generation pipeline to create privacy-safe training and validation data for medical imaging AI.</p>
<h3 id="heading-the-problem">The Problem</h3>
<p>Imagine you’re building an AI system that removes patient information from medical images.</p>
<p>The model needs thousands of examples showing where Protected Health Information (PHI) appears and what it looks like. The more examples it sees, the better it becomes at finding and removing sensitive information.</p>
<p>But there is a problem:</p>
<p><strong>The data you need to train the model is the same data you’re not allowed to share freely.</strong></p>
<p>Healthcare organizations must protect patient privacy. Regulations like HIPAA require that patient identifiers are removed before medical images can be shared for research, AI development, or external collaboration.</p>
<p>This creates an interesting engineering challenge: How do you build and test de-identification systems when the data needed to train those systems can't be easily used?</p>
<p>One practical solution is <strong>Synthetic PHI.</strong></p>
<p>In this article, I’ll show why synthetic PHI is valuable, explain the hidden PHI problem inside medical images, and walk through a pipeline my team built that generates realistic ultrasound datasets with fully controlled synthetic patient information.</p>
<h2 id="heading-what-youll-learn-in-this-tutorial">What You'll Learn in This Tutorial</h2>
<p>By the end of this tutorial, you'll understand:</p>
<ul>
<li><p>The hidden PHI challenges in medical imaging data.</p>
</li>
<li><p>Why synthetic PHI is useful for building and testing healthcare AI systems.</p>
</li>
<li><p>How to generate realistic synthetic patient identities using Python and Faker.</p>
</li>
<li><p>How to inject PHI into both image pixels and DICOM metadata.</p>
</li>
<li><p>How to create ground-truth labels for AI model training and evaluation.</p>
</li>
<li><p>How to validate synthetic medical imaging datasets before using them in downstream workflows.</p>
</li>
</ul>
<h2 id="heading-what-well-cover"><strong>What We'll Cover:</strong></h2>
<ul>
<li><p><a href="#heading-source-images-openpocus">Source Images: OpenPOCUS</a></p>
</li>
<li><p><a href="#heading-the-iceberg-problem-most-phi-is-hidden">The Iceberg Problem: Most PHI Is Hidden</a></p>
</li>
<li><p><a href="#heading-why-synthetic-phi-matters">Why Synthetic PHI Matters</a></p>
<ul>
<li><p><a href="#heading-challenge-1-privacy-regulations">Challenge 1: Privacy Regulations</a></p>
</li>
<li><p><a href="#heading-challenge-2-annotation-at-scale">Challenge 2: Annotation at Scale</a></p>
</li>
<li><p><a href="#heading-challenge-3-validation">Challenge 3: Validation</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-synthetic-phi-solves-all-three-problems">Synthetic PHI Solves All Three Problems</a></p>
</li>
<li><p><a href="#heading-building-a-synthetic-phi-pipeline">Building a Synthetic PHI Pipeline</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-safety-checks-before-burning">Safety Checks Before Burning</a></p>
<ul>
<li><p><a href="#heading-step-1-generate-synthetic-patient-identities">Step 1: Generate Synthetic Patient Identities</a></p>
</li>
<li><p><a href="#heading-step-2-burn-phi-into-image-pixels">Step 2: Burn PHI into Image Pixels</a></p>
</li>
<li><p><a href="#heading-step-3-add-phi-to-dicom-headers">Step 3: Add PHI to DICOM Headers</a></p>
</li>
<li><p><a href="#heading-step-4-identity-mapping-the-de-identified-patientid">Step 4: Identity Mapping: The De-Identified PatientID</a></p>
</li>
<li><p><a href="#heading-step-5-ground-truth-structured-csv-output">Step 5: Ground Truth: Structured CSV Output</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-three-tier-dicom-validation">Three-Tier DICOM Validation</a></p>
</li>
<li><p><a href="#heading-a-surprising-bug-monai-vs-pil">A Surprising Bug: MONAI vs PIL</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-source-images-openpocus">Source Images: OpenPOCUS</h2>
<p>The synthetic PHI generation uses lung point-of-care ultrasound (POCUS) frames from <a href="https://github.com/kumarandre/OpenPOCUS">OpenPOCUS</a>, an openly licensed collection of real ultrasound images contributed by the POCUS community.</p>
<p>These images carry no real PHI. OpenPOCUS provides clinically authentic ultrasound images while avoiding patient privacy concerns. This makes it an ideal foundation for synthetic PHI generation because we can focus entirely on creating and tracking identifiers without risking exposure of real patient information.</p>
<h2 id="heading-the-iceberg-problem-most-phi-is-hidden">The Iceberg Problem: Most PHI Is Hidden</h2>
<p>When people think about PHI in medical images, they usually think about visible text overlays.</p>
<p>These include:</p>
<pre><code class="language-plaintext">Patient name
Medical Record Number (MRN)
Date of birth
Study date
</code></pre>
<p>These identifiers are often burned directly into image pixels by ultrasound, X-ray, CT, and MRI systems.</p>
<p>But visible text is only the tip of the iceberg. Much of the remaining PHI lives inside the DICOM header, a collection of metadata fields that describe the image and the study. These fields contains identifiers such as <code>PatientName</code>, <code>PatientID</code>, <code>StudyDate</code>, <code>institution names</code>, and other sensitive information.</p>
<p>Unlike burned-in text, header PHI isn't visible when looking at the image itself, but it travels with the file and must also be removed during de-identification.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/4f1036fd-009f-4be7-944a-af5380dfdfcb.png" alt="Iceberg illustration showing visible PHI in image pixels and hidden PHI in DICOM metadata." style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>A de-identification system must handle both.</p>
<p>Removing visible text while leaving PHI inside DICOM metadata still creates a privacy risk. Likewise, stripping metadata while leaving patient names burned into image pixels is equally problematic.</p>
<p>This hidden PHI challenge makes testing de-identification software much harder than it first appears.</p>
<h2 id="heading-why-synthetic-phi-matters">Why Synthetic PHI Matters</h2>
<p>At first glance, it seems hospitals already have plenty of real-world data available. So why not simply use that?</p>
<p>The answer comes down to three challenges.</p>
<h3 id="heading-challenge-1-privacy-regulations">Challenge 1: Privacy Regulations</h3>
<p>Medical images often contain patient identifiers.</p>
<p>Sharing those images outside secure clinical environments introduces significant legal and compliance risk.</p>
<p>The more institutions involved, the more difficult governance becomes.</p>
<h3 id="heading-challenge-2-annotation-at-scale">Challenge 2: Annotation at Scale</h3>
<p>Modern AI systems require labeled examples.</p>
<p>Someone must identify:</p>
<ul>
<li><p>Where PHI appears</p>
</li>
<li><p>What type of PHI is it</p>
</li>
<li><p>Which DICOM tags contain PHI</p>
</li>
</ul>
<p>Creating these annotations manually is expensive and time-consuming.</p>
<h3 id="heading-challenge-3-validation">Challenge 3: Validation</h3>
<p>Suppose you’re evaluating a de-identification tool. How do you know whether it successfully removed every identifier?</p>
<p>With real patient data, you often don’t know exactly where every piece of PHI exists. Without ground truth, measuring accuracy becomes difficult.</p>
<h2 id="heading-synthetic-phi-solves-all-three-problems">Synthetic PHI Solves All Three Problems</h2>
<p>Instead of starting with real patient identifiers, we can generate realistic fake identities and intentionally inject them into medical images.</p>
<p>Because the pipeline creates the PHI itself, we know:</p>
<ul>
<li><p>Every identifier value</p>
</li>
<li><p>Every pixel location</p>
</li>
<li><p>Every DICOM tag</p>
</li>
<li><p>Every expected output</p>
</li>
</ul>
<p>This gives us perfect ground truth.</p>
<p>Now, a de-identification system can be evaluated objectively. If a patient name remains after processing, we know it failed. If clinical content is accidentally removed, we know that too.</p>
<p>Synthetic PHI creates a privacy-safe dataset that can be used for:</p>
<ul>
<li><p>Training AI models</p>
</li>
<li><p>Benchmarking de-identification software</p>
</li>
<li><p>Regression testing</p>
</li>
<li><p>Validation before deployment</p>
</li>
</ul>
<h2 id="heading-building-a-synthetic-phi-pipeline">Building a Synthetic PHI Pipeline</h2>
<p>To explore this problem, my team built a pipeline that generates synthetic PHI for lung Point-of-Care Ultrasound (POCUS) images.</p>
<p>The goal was to:</p>
<ol>
<li><p>Start with ultrasound images containing no patient information.</p>
</li>
<li><p>Generate realistic synthetic patient identities.</p>
</li>
<li><p>Burn PHI into image pixels.</p>
</li>
<li><p>Insert matching PHI into DICOM metadata.</p>
</li>
<li><p>Automatically generate ground truth labels.</p>
</li>
<li><p>Validate the resulting DICOM files.</p>
</li>
</ol>
<p>The output looks realistic from the perspective of a de-identification system while containing no real patient information.</p>
<h2 id="heading-pipeline-architecture"><strong>Pipeline Architecture</strong></h2>
<p>The workflow looks like this (we'll go over each step in detail below):</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/f293fb38-b09f-451c-b6ee-75c71e9a7e66.png" alt="Workflow for generating synthetic PHI in ultrasound images and DICOM files." style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>Each stage produces artifacts consumed by the next stage. Failures are quarantined rather than silently ignored.</p>
<h2 id="heading-safety-checks-before-burning">Safety Checks Before Burning</h2>
<p>Before writing synthetic PHI onto an image, the pipeline performs a safety check to ensure that the selected region to insert PHI lies outside the ultrasound fan.</p>
<p>The top-left corner of a lung POCUS image is usually outside the imaging fan, a dark border, safe to burn PHI onto without obscuring clinical content.</p>
<p>To make sure this region holds good for every image, the pipeline runs two checks per image:</p>
<ul>
<li><p><strong>Brightness check:</strong> If the average intensity of the configured burn region exceeds a threshold, the region likely overlaps the ultrasound fan rather than the dark border.</p>
</li>
<li><p><strong>Boundary check:</strong> The pipeline verifies that the configured burn region fits entirely within the image. Images that are smaller than the expected burn area are quarantined.</p>
</li>
</ul>
<p>In either case, the image is quarantined with the reason recorded into the manifest. There are no partial burns, no overwritten clinical content, and no silent corruption of test data.</p>
<p>This prevents synthetic identifiers from accidentally obscuring anatomy.</p>
<pre><code class="language-python">def burn_region_is_safe(arr):
    """Check the burn region is dark enough to be outside the fan."""
    h, w = arr.shape
    y2 = min(BURN_REGION_Y + BURN_REGION_H, h)
    x2 = min(BURN_REGION_X + BURN_REGION_W, w)
    region = arr[BURN_REGION_Y:y2, BURN_REGION_X:x2]
    if region.size == 0:
        return False, float("nan")
    mean = float(region.mean())
    return mean &lt;= BRIGHTNESS_SKIP_THRESHOLD, mean
</code></pre>
<p>The function extracts the configured burn region and computes its average brightness. If the region is too bright, it likely overlaps the ultrasound fan rather than the border.</p>
<h3 id="heading-step-1-generate-synthetic-patient-identities">Step 1: Generate Synthetic Patient Identities</h3>
<p>The synthetic identity is produced by <a href="https://faker.readthedocs.io/">Faker</a> and seeded per case, so the same image always yields the same fake patient.</p>
<p>Determinism matters because:</p>
<ul>
<li><p>Reproducing a test result requires reproducing the test data.</p>
</li>
<li><p>Debugging downstream tools is easier when the input doesn't change between runs.</p>
</li>
<li><p>Comparing two de-identification tools fairly requires both to see the same planted PHI.</p>
</li>
</ul>
<pre><code class="language-python">def case_seed(global_seed: int, source_id: str) -&gt; int:
    """Per-image deterministic seed derived from global seed and source path."""
    h = hashlib.sha256(f"{global_seed}|{source_id}".encode()).hexdigest()
    return int(h[:8], 16)


def generate_phi(seed: int) -&gt; dict:
    fake = Faker()
    Faker.seed(seed)
    rng = random.Random(seed)

    last = fake.last_name()
    first = fake.first_name()
    middle = fake.random_letter().upper()
    mrn = f"{rng.randint(1000000, 9999999)}"
    dob = fake.date_of_birth(minimum_age=18, maximum_age=95)
    study_date = fake.date_time_this_decade()
    institution = rng.choice(INSTITUTION_POOL)

    return {
        "case_uuid": f"SYNTH-{uuid.UUID(int=rng.getrandbits(128))}",
        "patient_name_display": f"{last}, {first} {middle}.",
        "patient_name_dicom": f"{last}^{first}^{middle}",   # DICOM PN VR format
        "patient_id": mrn,
        "dob": dob,
        "study_date": study_date,
        "institution_name": institution,
    }
</code></pre>
<p>The <code>case_seed()</code> function generates a deterministic seed from the source image path. That seed is then used by Faker to create a synthetic identity.</p>
<p>Because the seed is repeatable, the same input image always receives the same synthetic patient information. This makes debugging and benchmarking reproducible.</p>
<h3 id="heading-step-2-burn-phi-into-image-pixels">Step 2: Burn PHI into Image Pixels</h3>
<p>Rendering text onto an image is comparatively expensive. For a single zone containing 30+ frames, repeating that work per frame is wasteful.</p>
<p>The pipeline instead renders the PHI overlay onto a transparent canvas one time per zone. This mirrors how many ultrasound systems operate in practice, where patient information remains fixed while the underlying image content changes from frame to frame.</p>
<pre><code class="language-python">def make_phi_overlay(shape, phi):
    """Render PHI ONCE onto a canvas. Returns (overlay_array, overlays_meta)."""
    h, w = shape
    canvas = Image.new("L", (w, h), 0)  # blank canvas
    draw = ImageDraw.Draw(canvas)

    overlays, x, y = [], BURN_REGION_X, BURN_REGION_Y
    for entry in _phi_text_block(phi):
        x0, y0, x1, y1 = draw.textbbox((x, y), entry["line"], font=FONT)
        tw, th = x1 - x0, y1 - y0

        if x + tw &gt; w or y + th &gt; h:
            raise ValueError(
                f"rendered PHI overflows image: '{entry['line']}' "
                f"at ({x},{y}) size ({tw}x{th}), image {w}x{h}"
            )

        draw.text((x, y), entry["line"], font=FONT, fill=TEXT_COLOR)
        overlays.append({
            "phi_category": entry["phi_category"],
            "rendered_text": entry["line"],
            "phi_value": entry["value"],
            "bbox": [x, y, tw, th],
            "dicom_tag": entry["dicom_tag"],
        })
        y += th + LINE_GAP
    return np.array(canvas), overlays
</code></pre>
<p>The <code>make_phi_overlay()</code> function creates a blank canvas and renders each PHI line onto it. At the same time, it records metadata such as the rendered text, bounding box coordinates, and corresponding DICOM tag.</p>
<p>The function returns both the image overlay and the annotation metadata, ensuring that the ground truth always matches the pixels that were actually drawn.</p>
<p>Rendering once and reusing the overlay provides several advantages:</p>
<ul>
<li><p>Faster processing</p>
</li>
<li><p>Consistent PHI placement across frames</p>
</li>
<li><p>Simplified ground-truth generation</p>
</li>
<li><p>Behavior that more closely matches real ultrasound devices</p>
</li>
</ul>
<p>An additional benefit is that the pipeline automatically records the location of every burned identifier.</p>
<h3 id="heading-step-3-add-phi-to-dicom-headers">Step 3: Add PHI to DICOM Headers</h3>
<p>The DICOM standard supports two ways to represent a cine ultrasound loop: as a sequence of single-frame DICOMs that share a series UID, or as one multi-frame DICOM where the pixel data holds every frame stacked together.</p>
<p>The pipeline uses the multi-frame approach because:</p>
<ul>
<li><p>It matches how real ultrasound devices write cine loops.</p>
</li>
<li><p>One header serves all frames — no duplication of patient metadata.</p>
</li>
<li><p>Storage and transfer are more efficient.</p>
</li>
</ul>
<pre><code class="language-python">ds.PatientName = phi["patient_name_dicom"]
ds.PatientID = deid_patient_id
ds.PatientBirthDate = phi["dob"].strftime("%Y%m%d")

ds.StudyInstanceUID = study_uid
ds.StudyDate = phi["study_date"].strftime("%Y%m%d")
ds.InstitutionName = phi["institution_name"]
</code></pre>
<p>These fields populate the DICOM header with the same synthetic identity used in the image overlay. This ensures that visible PHI and hidden metadata remain consistent, producing realistic test data.</p>
<p>A few details that the DICOM standard enforces but the spec doesn't make obvious:</p>
<ul>
<li><p><code>StudyID</code> is required and must be a short string, distinct from <code>StudyInstanceUID</code>. It's easy to forget.</p>
</li>
<li><p><code>ImageType</code> must be present. <code>["DERIVED", "SECONDARY"]</code> is the honest value for synthetic data because it wasn't acquired by a device.</p>
</li>
<li><p><code>Manufacturer</code> is part of the General Equipment IOD module and is required even though the data is synthetic. Setting it to a clearly synthetic value (<code>SYNTHETIC-DEID-TUTORIAL</code>) makes the origin unambiguous.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/f6c75149-c287-479a-895b-5572fbd6afbf.png" alt="Synthetic ultrasound DICOM containing generated PHI in image overlays and metadata." style="display:block;margin:0 auto" width="1506" height="757" loading="lazy">

<h3 id="heading-step-4-identity-mapping-the-de-identified-patientid">Step 4: Identity Mapping: The De-Identified PatientID</h3>
<p>To support downstream evaluation, every source patient receives a stable identifier such as <code>DEID-0001</code>. A mapping file links source patients, synthetic studies, and generated DICOM objects. This allows evaluators to compare a de-identification tool’s output against the original ground truth.</p>
<pre><code class="language-plaintext">source_patient,deid_patient_id,study_instance_uid
patient_001,DEID-0001,1.2.826.0.1.3680043.8.498.1234...
patient_002,DEID-0002,1.2.826.0.1.3680043.8.498.5678...
</code></pre>
<h3 id="heading-step-5-ground-truth-structured-csv-output">Step 5: Ground Truth: Structured CSV Output</h3>
<p>One major advantage of synthetic PHI is automatic label generation. Because the pipeline creates every identifier, it already knows the text value, bounding box coordinates, and corresponding DICOM tag.</p>
<p>These annotations are exported as structured CSV files and become the ground truth used for training and evaluation.</p>
<pre><code class="language-python">def build_overlay_rows(*, case_uuid, sop_instance_uid, source_id, source_relpath, output_dicom_relpath, overlays,
                      image_shape):
    h, w = image_shape
    rows = []
    for ov in overlays:
        x, y, ow, oh = ov["bbox"]
        rows.append({
            "case_uuid": case_uuid,
            "sop_instance_uid": sop_instance_uid,
            "source_id": source_id,
            "source_relpath": source_relpath,
            "output_dicom_relpath": output_dicom_relpath,
            "image_h": h,
            "image_w": w,
            "region": "top_left_banner",
            "phi_category": ov["phi_category"],
            "phi_value": ov["phi_value"],
            "rendered_text": ov["rendered_text"],
            "bbox_x": x, "bbox_y": y,
            "bbox_w": ow, "bbox_h": oh,
            "dicom_tag": ov["dicom_tag"],
            "seed": SEED,
            "pipeline_version": PIPELINE_VERSION,
            "run_id": RUN_ID,
        })
    return rows
</code></pre>
<p><code>build_overlay_rows</code> function converts each overlay into a row of structured metadata. Along with the text and bounding box coordinates, it records identifiers and reproducibility information such as the pipeline version and random seed.</p>
<p>These CSV files become the ground truth used for training and evaluating de-identification systems.</p>
<p>At the end of the run, the accumulated rows are grouped by de-identified patient ID and written into per-patient CSV files. Each patient folder receives its own <code>phi_overlays.csv</code> covering all of that patient's zones, alongside a <code>run_manifest.csv</code> summarizing zone-level status (processed, quarantined, failed) and paths.</p>
<h2 id="heading-three-tier-dicom-validation">Three-Tier DICOM Validation</h2>
<p>A synthetic DICOM file is only useful if it actually conforms to the DICOM standard. Otherwise, downstream tools that consume it will fail or worse silently mis-handle it.</p>
<p>The pipeline uses a three-tier validation chain that gracefully degrades depending on what's available in the environment:</p>
<ol>
<li><p><code>dciodvfy</code> from dicom3tools: the most rigorous standards-conformance validator, written by David Clunie. It's not pip-installable. It checks against the full DICOM IOD definitions. If it's available on <code>PATH</code>, this is the preferred check.</p>
</li>
<li><p><a href="https://pypi.org/project/dicom-validator/"><code>dicom-validator</code></a> CLI: this is pip-installable. It downloads the DICOM standard definitions on first run, then validates IOD compliance. it's used when <code>dciodvfy</code> isn't available.</p>
</li>
<li><p><code>pydicom</code> re-read: the minimal fallback. It confirms that every file can be re-opened, decoded, and that pixel data round-trips correctly. It doesn't check standards compliance, but catches gross corruption.</p>
</li>
</ol>
<h2 id="heading-a-surprising-bug-monai-vs-pil"><strong>A Surprising Bug: MONAI vs PIL</strong></h2>
<p>Originally, I planned to use MONAI for image loading because it's widely used in medical imaging workflows.</p>
<p>During testing, I discovered an issue: MONAI’s image loading conventions caused non-square images to appear rotated when downstream code assumed traditional image layouts.</p>
<p>At the same time, many ultrasound images contained EXIF orientation metadata that required correction.</p>
<p>Switching to PIL solved both issues.</p>
<pre><code class="language-python">from PIL import Image, ImageOps

img = Image.open(path)
img = ImageOps.exif_transpose(img)
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Synthetic PHI does not replace real-world testing, but it provides something healthcare AI teams rarely have: a safe, shareable, and fully labeled dataset with known answers.</p>
<p>By generating realistic identifiers and embedding them into both image pixels and DICOM metadata, we can build reproducible benchmarks for de-identification systems without exposing real patient data.</p>
<p>As AI systems become increasingly responsible for handling sensitive medical information, synthetic PHI may become one of the most important tools for building trustworthy healthcare AI workflows.</p>
<p>The complete implementation is available as a Jupyter notebook in the <a href="https://github.com/Project-MONAI/wg-ultrasound/tree/main/annotation_and_anonymization">MONAI Ultrasound Working Group</a> repository. You can explore the notebook and experiment with the pipeline yourself.</p>
<p>Sometimes the safest way to test whether a system can remove PHI is to create the PHI yourself.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Why Your Deep Learning Model Isn't Learning: Diagnosing Data Problems in Medical Imaging ]]>
                </title>
                <description>
                    <![CDATA[ I built a clean, well-structured deep learning pipeline using MONAI (Medical Open Network for AI) on a public abdominal ultrasound dataset. The pipeline included: proper subject-grouped train/validat ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-your-deep-learning-model-isn-t-learning-data-problems-in-medical-imaging/</link>
                <guid isPermaLink="false">6a19aed9b55c6a731d1d7c06</guid>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dataanalysis ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Fri, 29 May 2026 15:20:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/36be814e-4189-4905-9470-1cb5860e7124.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I built a clean, well-structured deep learning pipeline using <a href="https://project-monai.github.io/">MONAI</a> (Medical Open Network for AI) on a public abdominal ultrasound dataset.</p>
<p>The pipeline included:</p>
<ul>
<li><p>proper subject-grouped train/validation splits</p>
</li>
<li><p>robust preprocessing</p>
</li>
<li><p>carefully decoded segmentation masks</p>
</li>
<li><p>sensible loss functions</p>
</li>
<li><p>consistent evaluation</p>
</li>
</ul>
<p>And the model still struggled to learn.</p>
<p>The interesting part isn't that the model underperformed. What mattered was the diagnosis: a series of simple checks that traced the problem back to the dataset, not the model.</p>
<p>Those checks are useful far beyond medical imaging. They apply to almost any machine learning project.</p>
<p>If you're new to ML, this is a lesson worth carrying into every project: <strong>understand your data before you tune your model.</strong></p>
<p>I set out to build a medical image segmentation tutorial. I ended up learning a more valuable lesson: no amount of careful engineering can rescue a model from a dataset that can't support the task.</p>
<p>By the end of this article, you'll understand:</p>
<ul>
<li><p>How to evaluate whether a dataset can actually support your task</p>
</li>
<li><p>Why "the model isn't learning" is often a data problem</p>
</li>
<li><p>How to rule out engineering bugs before blaming the data</p>
</li>
<li><p>Practical diagnostics you can run in minutes</p>
</li>
<li><p>Why synthetic training data often struggles in real-world deployment</p>
</li>
<li><p>When to stop tuning and walk away from a dataset</p>
</li>
</ul>
<p>This is not a beginner introduction to deep learning – it assumes familiarity with concepts like UNet architectures and training loops. But the data-quality lessons apply broadly to many ML projects.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-the-dataset">The Dataset</a></p>
</li>
<li><p><a href="#heading-step-1-rule-out-the-pipeline-before-blaming-the-data">Step 1: Rule Out the Pipeline Before Blaming the Data</a></p>
<ul>
<li><p><a href="#heading-subject-grouped-splits">Subject-grouped splits</a></p>
</li>
<li><p><a href="#heading-decoding-masks-correctly">Decoding masks correctly</a></p>
</li>
<li><p><a href="#heading-loss-design-and-class-weighting">Loss design and class weighting</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-2-the-model-still-struggled">Step 2: The Model Still Struggled</a></p>
</li>
<li><p><a href="#heading-step-3-interrogating-the-dataset">Step 3: Interrogating the Dataset</a></p>
<ul>
<li><p><a href="#heading-diagnostic-1-what-does-the-dataset-actually-contain">Diagnostic 1: What Does the Dataset Actually Contain?</a></p>
</li>
<li><p><a href="#heading-diagnostic-2-do-synthetic-and-real-images-look-similar">Diagnostic 2: Do Synthetic and Real Images Look Similar?</a></p>
</li>
<li><p><a href="#heading-diagnostic-3-can-the-gap-be-fixed-by-adding-real-data">Diagnostic 3: Can the gap be fixed by adding real data?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-4-knowing-when-to-stop">Step 4: Knowing When to Stop</a></p>
</li>
<li><p><a href="#heading-a-practical-dataset-evaluation-checklist">A Practical Dataset Evaluation Checklist</a></p>
</li>
<li><p><a href="#heading-what-i-would-try-next">What I Would Try Next</a></p>
</li>
<li><p><a href="#heading-the-bigger-lesson">The Bigger Lesson</a></p>
</li>
</ul>
<h2 id="heading-the-dataset">The Dataset</h2>
<p>I used the <a href="https://www.kaggle.com/datasets/ignaciorlando/ussimandsegm">US Simulation &amp; Segmentation dataset</a>, a public collection of abdominal ultrasound images with organ segmentation labels from Kaggle.</p>
<p>It contains:</p>
<ul>
<li><p><strong>926 synthetic ultrasound images</strong> — generated by a ray-casting simulator from CT scans, with full organ annotations</p>
</li>
<li><p><strong>617 real ultrasound images</strong> — from an actual ultrasound scanner</p>
</li>
<li><p><strong>Labels for 8 organs</strong> — liver, kidney, gallbladder, pancreas, spleen, bones, vessels, and adrenals</p>
</li>
</ul>
<p>At first glance, the dataset looked ideal:</p>
<ul>
<li><p>thousands of images</p>
</li>
<li><p>multiple organ classes</p>
</li>
<li><p>both synthetic and real ultrasound data</p>
</li>
</ul>
<p>Whether it actually supported the task was a different question.</p>
<h2 id="heading-step-1-rule-out-the-pipeline-before-blaming-the-data">Step 1: Rule Out the Pipeline Before Blaming the Data</h2>
<p>Ground rule: you should always rule out the pipeline before blaming the data. A model failing on buggy code looks exactly like a model failing on bad data. The engineering needs to be trustworthy.</p>
<h3 id="heading-subject-grouped-splits">Subject-Grouped Splits</h3>
<p>A common mistake in medical imaging is randomly splitting images into train and test sets.</p>
<p>That approach is problematic because many frames come from the same patient. Those frames share anatomy, scanner settings, and noise patterns.</p>
<p>If frames from the same patient appear in both the train and test sets, the model can partially memorize patient-specific patterns. Test scores look artificially good, even though the model may fail on truly unseen patients.</p>
<p>This is called <strong>subject leakage</strong>.</p>
<p>The fix is to split by patient instead of by image:</p>
<pre><code class="language-python">from sklearn.model_selection import GroupShuffleSplit

def assign_splits(manifest, val_fraction=0.15, seed=42):
    train_data = manifest[manifest["orig_split"] == "train"]
    groups = train_data["subject_id"].values

    gss = GroupShuffleSplit(n_splits=1, test_size=val_fraction, random_state=seed)
    train_idx, val_idx = next(gss.split(X=train_data, y=None, groups=groups))

    train_subjects = set(train_data.iloc[train_idx]["subject_id"].unique())
    val_subjects = set(train_data.iloc[val_idx]["subject_id"].unique())

    # Crash loudly if leakage ever sneaks in
    assert train_subjects.isdisjoint(val_subjects), "Subject leak detected!"
    return train_subjects, val_subjects
</code></pre>
<p><strong>That assertion matters.</strong> If the split logic ever breaks, the pipeline fails loudly instead of silently producing misleading metrics.</p>
<h3 id="heading-decoding-masks-correctly">Decoding Masks Correctly</h3>
<p>The dataset stores labels as color-coded masks. Each organ corresponds to a different RGB color.</p>
<p>Training requires converting those colors into integer class labels.</p>
<p>A naïve implementation uses exact color matching, but resizing operations can slightly alter colors at mask boundaries.</p>
<p>A more robust approach maps each pixel to its nearest palette color:</p>
<pre><code class="language-python">import numpy as np

PALETTE = np.array([
    [0, 0, 0],
    [100, 0, 100],
    [255, 255, 255],
    [0, 255, 0],
    [255, 255, 0],
    [0, 0, 255],
    [255, 0, 0],
    [255, 0, 255],
    [0, 255, 255],
], dtype=np.int32)

def decode_mask(mask_rgb):
    h, w = mask_rgb.shape[:2]
    flat = mask_rgb.reshape(-1, 3).astype(np.int32)
    d2 = (
        (flat[:, None, :] - PALETTE[None, :, :]) ** 2
    ).sum(-1)
    classes = d2.argmin(axis=1).astype(np.uint8)
    return classes.reshape(h, w)
</code></pre>
<p>Before training, it’s worth visually checking a few decoded masks against the original images. This catches issues like incorrect palettes, RGB/BGR channel swaps, or resizing artifacts that silently corrupt labels.</p>
<p>These bugs rarely throw errors. Instead, the model simply learns poorly. And “<em>trained on wrong labels</em>” looks exactly like “<em>the model can’t learn the data.</em>”</p>
<p>Verifying masks early removes that uncertainty.</p>
<h3 id="heading-loss-design-and-class-weighting">Loss Design and Class Weighting</h3>
<p>For training, I usd standard MONAI segmentation losses. The goal wasn’t to aggressively maximize performance, but to establish a stable and trustworthy baseline.</p>
<p>The training curves below show that the model optimized normally: the loss decreased consistently, and the validation dice stabilized rather than diverging. This helped rule out optimization instability as the primary cause of poor final performance.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/841346d4-d3df-48a9-bc4d-31a5dd0d9bb0.png" alt="Two training curves from a MONAI liver segmentation experiment. The left plot shows training loss steadily decreasing across 50 epochs, while the right plot shows validation Dice scores stabilizing around 0.55–0.60 after initial fluctuations, indicating stable optimization but limited segmentation performance." style="display:block;margin:0 auto" width="1594" height="448" loading="lazy">

<p>Three choices were deliberate:</p>
<ul>
<li><p><strong>Dice + Cross-Entropy combined:</strong> Cross-entropy keeps learning stable early on – Dice directly rewards good region overlap. Together they balance each other.</p>
</li>
<li><p><code>include_background=False</code> <strong>for binary segmentation:</strong> In a single-organ task, background can be 85–90% of the pixels. Counting it in the loss drowns out the signal for the organ you actually care about, so it's better left out.</p>
</li>
<li><p><strong>Class weighting for multi-class segmentation:</strong> With organs of very different sizes, an unweighted loss lets the model ignore the small, rare ones and still score well. Weighting rare-class mistakes more heavily pushes back against that.</p>
</li>
</ul>
<h2 id="heading-step-2-the-model-still-struggled">Step 2: The Model Still Struggled</h2>
<p>The first experiment focused on liver segmentation — the simplest single-organ task in the dataset.</p>
<table>
<thead>
<tr>
<th>Test set</th>
<th>Liver Dice</th>
</tr>
</thead>
<tbody><tr>
<td>Synthetic test set</td>
<td>~0.68</td>
</tr>
<tr>
<td>Real ultrasound test set</td>
<td>~0.48</td>
</tr>
</tbody></table>
<p>Dice scores range from 0 (no overlap) to 1 (perfect overlap).</p>
<p>Qualitatively, the predictions often captured rough liver regions but failed at boundaries and consistency across real scans.</p>
<p>Especially important:</p>
<ul>
<li><p>the model struggled even on synthetic in-domain data</p>
</li>
<li><p>performance dropped further on real ultrasound images</p>
</li>
</ul>
<p>At this point, two explanations were possible:</p>
<ol>
<li><p>the model or pipeline was flawed</p>
</li>
<li><p>the dataset itself was limiting performance</p>
</li>
</ol>
<p>Because the engineering had been carefully validated, the second possibility became worth investigating seriously.</p>
<p>That's where the real lesson began.</p>
<h2 id="heading-step-3-interrogating-the-dataset">Step 3: Interrogating the Dataset</h2>
<p>Rather than endlessly tuning the model, the productive move is to turn the diagnostic lens on the dataset.</p>
<p>Three simple checks revealed the real problem. None required retraining or expensive experiments.</p>
<h3 id="heading-diagnostic-1-what-does-the-dataset-actually-contain">Diagnostic 1: What Does the Dataset Actually Contain?</h3>
<p>The first step was simply plotting the dataset composition.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/d2855b12-b416-4a76-b743-971bf4389628.png" alt="Bar chart showing the composition of the ultrasound segmentation dataset. The dataset contains 926 labeled synthetic ultrasound images, 60 labeled real ultrasound images, and 557 unlabeled real ultrasound images, for a total of 1,543 images. Labeled real data represents only 3.9% of the dataset." style="display:block;margin:0 auto" width="1574" height="932" loading="lazy">

<ul>
<li><p><strong>926 labeled synthetic images</strong> (the bulk of training data)</p>
</li>
<li><p><strong>Only 60 labeled real images</strong> — less than 4% of the dataset</p>
</li>
<li><p><strong>557 unlabeled real images</strong> — real data exists, but without labels it can't be used for supervised training</p>
</li>
</ul>
<p>This immediately changed the interpretation of the dataset.</p>
<p>Although the dataset contains many real ultrasound scans, almost all labeled training data is synthetic.</p>
<p>The model is effectively trained on synthetic ultrasound and expected to generalize to real ultrasound.</p>
<p>That's a difficult transfer problem from the start.</p>
<p>The limitation is simple: the real images mostly don't have labels, so supervised training has very little real-world data to learn from.</p>
<p><strong>Lesson:</strong> Before training anything, chart the dataset composition. A headline image count can be misleading. "1,500 images" sounds large until you discover that only a tiny fraction are labeled examples from the target domain.</p>
<h3 id="heading-diagnostic-2-do-synthetic-and-real-images-look-similar">Diagnostic 2: Do Synthetic and Real Images Look Similar?</h3>
<p>The next question was whether the synthetic and real ultrasound images actually followed similar visual distributions.</p>
<p>Plotting intensity histograms showed a clear mismatch.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/baac5168-292e-45f8-ab9c-fd468dc63b46.png" alt="Histogram comparing pixel intensity distributions between synthetic and real ultrasound images. Synthetic images cluster heavily around lower intensity values, while real ultrasound images show a broader mid-range distribution. The figure also reports summary statistics including mean intensity, standard deviation, and percentile ranges for both datasets." style="display:block;margin:0 auto" width="1705" height="951" loading="lazy">

<ul>
<li><p>synthetic images clustered heavily near darker intensities</p>
</li>
<li><p>real ultrasound images had broader mid-range intensity distributions</p>
</li>
</ul>
<p>The synthetic simulator captured anatomical geometry reasonably well, but it didn't reproduce the texture and noise characteristics of real ultrasound:</p>
<ul>
<li><p>speckle patterns</p>
</li>
<li><p>intensity falloff</p>
</li>
<li><p>scanner-specific artifacts</p>
</li>
</ul>
<p>This is the classic <strong>synthetic-to-real domain gap.</strong></p>
<p>The model learned features tuned to synthetic images and then encountered a substantially different distribution during evaluation. Poor transfer performance became expected rather than surprising.</p>
<p><strong>Lesson:</strong> Whenever training and deployment happen on different domains — synthetic → real, scanner A → scanner B, hospital A → hospital B — measure the distribution shift directly. Simple histogram comparisons can reveal major problems in minutes.</p>
<h3 id="heading-diagnostic-3-can-the-gap-be-fixed-by-adding-real-data">Diagnostic 3: Can the gap be fixed by adding real data?</h3>
<p>The obvious next idea was: why not include some real labeled data during training?</p>
<p>But before implementing that approach, it's worth checking how many distinct patients actually had labels.</p>
<pre><code class="language-plaintext">Labeled real images: 60
Distinct subjects (labeled real): 4

Frames per subject:
  subject h: 26
  subject a: 16
  subject g: 10
  subject b: 8
</code></pre>
<p>Only <strong>four</strong> patients.</p>
<p>That result fundamentally changed the situation.</p>
<p>Proper medical imaging evaluation requires subject-grouped train/test splits. But with only four patients, any evaluation becomes statistically unstable.</p>
<p>Training on two or three patients and testing on one or two patients would produce highly unreliable metrics that depend heavily on which patient happened to be held out.</p>
<p>At that point, the dataset simply couldn't support trustworthy real-world evaluation.</p>
<p><strong>Lesson:</strong> In medical imaging, count subjects, not images. The true size of a dataset is bounded by the number of independent patients, not the number of files.</p>
<h2 id="heading-step-4-knowing-when-to-stop">Step 4: Knowing When to Stop</h2>
<p>At this point, additional tuning no longer made sense.</p>
<p>The bottleneck was not the architecture, optimizer, or learning rate. The bottleneck was the dataset itself.</p>
<p>The pipeline was still valuable and reusable. But this particular dataset couldn't reliably support the intended segmentation task.</p>
<p>That distinction matters: sometimes a problem is difficult but solvable, and sometimes the data simply can't support the conclusion you want to draw.</p>
<p>Learning to recognize the difference is an important ML skill.</p>
<h2 id="heading-a-practical-dataset-evaluation-checklist">A Practical Dataset Evaluation Checklist</h2>
<p>Before committing weeks to model development, these checks are worth running on any dataset:</p>
<ol>
<li><p><strong>Chart the dataset composition</strong> — labeled vs unlabeled, class distribution, domain distribution</p>
</li>
<li><p><strong>Count subjects, not images</strong> — independent patients matter more than frame count</p>
</li>
<li><p><strong>Check class balance</strong> — rare classes are often ignored without weighting or sampling strategies</p>
</li>
<li><p><strong>Compare train and deployment distributions</strong> — especially for cross-domain problems</p>
</li>
<li><p><strong>Verify labels visually</strong> — catch preprocessing or annotation errors early</p>
</li>
<li><p><strong>Look for published baselines</strong> — low published performance may indicate dataset limitations</p>
</li>
</ol>
<p>These checks take minutes and can save weeks of unnecessary tuning.</p>
<h2 id="heading-what-i-would-try-next">What I Would Try Next</h2>
<p>Improving results would likely require better data rather than a larger model. The next steps I'd prioritize:</p>
<ul>
<li><p>collecting more labeled real ultrasound scans, from more distinct patients</p>
</li>
<li><p>improving annotation consistency</p>
</li>
<li><p>semi-supervised learning to make use of the unlabeled real images</p>
</li>
<li><p>domain adaptation between synthetic and real ultrasound</p>
</li>
</ul>
<p>All of these target the actual bottleneck: data quality and data diversity.</p>
<h2 id="heading-the-bigger-lesson">The Bigger Lesson</h2>
<p>In machine learning, it's easy to focus most of our attention on architectures, hyperparameters, optimization tricks, and newer models.</p>
<p>But the dataset quietly defines the ceiling.</p>
<p>A sophisticated model on weak data often disappoints, while a simpler model on strong data performs surprisingly well.</p>
<p>That was the real lesson from this project.</p>
<p>The most valuable skill wasn't building the pipeline. It was diagnosing why the model couldn't succeed and being willing to trust what the data was saying.</p>
<p>The workflow — checking dataset composition, counting subjects, comparing distributions, ruling out engineering bugs, and deciding when to stop — transfers to almost any ML project.</p>
<p>In many projects, better judgment about the data matters more than a better model.</p>
<p>The pipeline code and diagnostic notebooks are available at the <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/abdomen_simulation_segmentation/data_and_tutorials/abdomen_us_multiorgan_segmentation">MONAI</a> <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/abdomen_simulation_segmentation/data_and_tutorials/abdomen_us_multiorgan_segmentation">Ultrasound Working Group</a> <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/abdomen_simulation_segmentation/data_and_tutorials/abdomen_us_multiorgan_segmentation">repository</a>. Questions, corrections, and improvements are always welcome.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
