Skip to content

Commit

Permalink
Merged in bugfix/RAM-4135_image_generator_magnification (pull request #…
Browse files Browse the repository at this point in the history
…475)

Fix magnification doubling and add plot method

Approved-by: Randy Taylor
  • Loading branch information
jrkerns committed Nov 13, 2024
2 parents 21a0d8a + 18a860e commit bc7d6c2
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 83 deletions.
8 changes: 7 additions & 1 deletion bitbucket-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ definitions:
script:
- apt-get update
- apt-get -y install git
- uv sync --frozen
- uv sync --frozen --quiet
- uv tool run pre-commit run --all-files
caches:
- precommit
Expand Down Expand Up @@ -41,6 +41,8 @@ definitions:
size: 2x
script:
# set up memory monitoring
- apt-get update
- apt-get install -y jq
- chmod +x memory_monitor.sh
- nohup ./memory_monitor.sh &
- MONITOR_PID=$!
Expand Down Expand Up @@ -198,6 +200,8 @@ definitions:
size: 2x
script:
# set up memory monitoring
- apt-get update
- apt-get install -y jq
- chmod +x memory_monitor.sh
- nohup ./memory_monitor.sh &
- MONITOR_PID=$!
Expand All @@ -217,6 +221,8 @@ definitions:
size: 2x
script:
# set up memory monitoring
- apt-get update
- apt-get install -y jq
- chmod +x memory_monitor.sh
- nohup ./memory_monitor.sh &
- MONITOR_PID=$!
Expand Down
48 changes: 48 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# conftest.py

import json
import os
import threading

import pytest

# Define the path for the active tests file
ACTIVE_TESTS_FILE = "active_tests.json"

# Initialize a thread-safe set to store active tests
active_tests_lock = threading.Lock()
active_tests = set()


def update_active_tests_file():
"""Writes the current active tests to a JSON file."""
with active_tests_lock:
data = list(active_tests)
with open(ACTIVE_TESTS_FILE, "w") as f:
json.dump(data, f)


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item, nextitem):
"""Hook to track test start and end, and trace memory allocations."""
# Before the test runs
with active_tests_lock:
active_tests.add(item.nodeid)
update_active_tests_file()

try:
# Run the actual test
yield
finally:
# After the test runs
with active_tests_lock:
active_tests.discard(item.nodeid)
update_active_tests_file()


@pytest.fixture(scope="session", autouse=True)
def ensure_active_tests_file_cleanup():
"""Ensure that the active tests file is removed after the test session."""
yield
if os.path.exists(ACTIVE_TESTS_FILE):
os.remove(ACTIVE_TESTS_FILE)
8 changes: 8 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ TRS-398
This change will affect absorbed dose TRS-398 calculations if you rely on the ``k_tp`` function. If you are using TRS-398, please verify that your
results are still accurate. We apologize for this oversight.

Image Generator
^^^^^^^^^^^^^^^

* :bdg-warning:`Fixed` The image generator suffered from a double magnification error of field/cone size when the SID was not at 1000.
I.e. a field size of 100x100mm at 1500mm would be 1.5**2 = 2.25x instead of 1.5x (1500/1000). This has been fixed.
* :bdg-success:`Feature` The ``Simulator`` class and its subclasses (AS500, AS1000, etc) have a new method: ``plot``.
It does what it says on the tin.

CT
^^

Expand Down
26 changes: 23 additions & 3 deletions docs/source/image_generator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ images. This module is different than other modules in that the goal here is non
analysis routines here. What is here started as a testing concept for pylinac itself, but has uses for advanced users
of pylinac who wish to build their own tools.

.. warning:: This feature is currently experimental and untested.

The module allows users to create a pipeline ala keras, where layers are added to an empty image. The user can add as
many layers as they wish.

Expand Down Expand Up @@ -53,7 +51,7 @@ Extending Layers & Simulators
-----------------------------

This module is meant to be extensible. That's why the structures are defined so simply. To create a custom simulator,
inherit from ``Simulator`` and define the pixel size and shape. Note that generating DICOM does not come for free:
inherit from ``Simulator`` and define the pixel size and shape:

.. code-block:: python
Expand Down Expand Up @@ -87,6 +85,28 @@ To implement a custom layer, inherit from ``Layer`` and implement the ``apply``
as1200.add_layer(MyAwesomeLayer())
...
Exporting Images to DICOM
-------------------------

The ``Simulator`` class has two methods for generating DICOM. One returns a Dataset and another fully saves it out to a file.

.. code-block:: python
from pylinac.core.image_generator import AS1200Image
from pylinac.core.image_generator.layers import FilteredFieldLayer, GaussianFilterLayer
as1200 = AS1200Image()
as1200.add_layer(FilteredFieldLayer(field_size_mm=(50, 50)))
as1200.add_layer(GaussianFilterLayer(sigma_mm=2))
# generate a pydicom Dataset
ds = as1200.as_dicom(gantry_angle=45)
# do something with that dataset as needed
ds.PatientID = "12345"
# or save it out to a file
as1200.generate_dicom(file_out_name="my_AS1200.dcm", gantry_angle=45)
Examples
--------

Expand Down
63 changes: 58 additions & 5 deletions memory_monitor.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
#!/bin/bash

# Configuration
LOG_FILE="memory_usage.log"
echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB)" > $LOG_FILE
ACTIVE_TESTS_FILE="active_tests.json"
# Dynamically set TEMP_DIR based on TMPDIR environment variable; default to /tmp if TMPDIR is not set
TEMP_DIR="${TMPDIR:-/tmp}"
POLL_INTERVAL=10 # Polling interval in seconds

# Initialize the log file with headers
echo "Timestamp,MemoryUsage(MB),MemoryLimit(MB),ActiveTests,TempDirSize(MB)" > "$LOG_FILE"

while true; do
# Capture the current timestamp
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes)
MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)

# Read memory usage and limit
if [ -f /sys/fs/cgroup/memory/memory.usage_in_bytes ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
MEMORY_USAGE=$(cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null || echo 0)
MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 0)
else
# Fallback for systems without cgroup memory files
MEMORY_USAGE=$(free -b | awk '/Mem:/ {print $3}' || echo 0)
MEMORY_LIMIT=$(free -b | awk '/Mem:/ {print $2}' || echo 0)
fi

MEMORY_USAGE_MB=$((MEMORY_USAGE / 1024 / 1024))
MEMORY_LIMIT_MB=$((MEMORY_LIMIT / 1024 / 1024))
echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB" >> $LOG_FILE
sleep 10

# Read active tests
if [ -f "$ACTIVE_TESTS_FILE" ]; then
# Use jq to parse the JSON array and concatenate test names
ACTIVE_TESTS=$(jq -r '.[]' "$ACTIVE_TESTS_FILE" | paste -sd "," -)
# Handle empty active tests
if [ -z "$ACTIVE_TESTS" ]; then
ACTIVE_TESTS="None"
fi
else
ACTIVE_TESTS="No tests running"
fi

# Determine the temporary directory to monitor
# Prefer TMPDIR if set; else default to /tmp
if [ -n "$TMPDIR" ]; then
CURRENT_TEMP_DIR="$TMPDIR"
else
CURRENT_TEMP_DIR="/tmp"
fi

# Calculate the size of the temporary directory in MB
if [ -d "$CURRENT_TEMP_DIR" ]; then
# Use du to calculate the size. Suppress errors for directories with restricted permissions.
TEMP_DIR_SIZE=$(du -sm "$CURRENT_TEMP_DIR" 2>/dev/null | awk '{print $1}')
# Handle cases where du fails
if [ -z "$TEMP_DIR_SIZE" ]; then
TEMP_DIR_SIZE="Unknown"
fi
else
TEMP_DIR_SIZE="Directory not found"
fi

# Log the data
echo "$TIMESTAMP,$MEMORY_USAGE_MB,$MEMORY_LIMIT_MB,\"$ACTIVE_TESTS\",$TEMP_DIR_SIZE" >> "$LOG_FILE"

# Wait for the next poll
sleep "$POLL_INTERVAL"
done
16 changes: 13 additions & 3 deletions pylinac/core/image_generator/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,17 @@ class Layer(ABC):
def apply(
self, image: np.ndarray, pixel_size: float, mag_factor: float
) -> np.ndarray:
"""Apply the layer. Takes a 2D array and pixel size value in and returns a modified array."""
"""Apply the layer. Takes a 2D array and pixel size value in and returns a modified array.
Parameters
----------
image : np.ndarray
The image to modify.
pixel_size : float
The pixel size of the image AT SAD.
mag_factor : float
The magnification factor of the image. SID/SAD. E.g. 1.5 for 150 cm SID and 100 cm SAD.
"""
pass


Expand Down Expand Up @@ -103,7 +113,7 @@ def apply(
def _create_perfect_field(
self, image: np.ndarray, pixel_size: float, mag_factor: float
) -> (np.ndarray, ...):
cone_size_pix = ((self.cone_size_mm / 2) / pixel_size) * mag_factor**2
cone_size_pix = mag_factor * (self.cone_size_mm / 2) / pixel_size
# we rotate the point around the center of the image
offset_pix_y, offset_pix_x = rotate_point(
x=self.cax_offset_mm[0] * mag_factor / pixel_size,
Expand Down Expand Up @@ -207,7 +217,7 @@ def _create_perfect_field(
self, image: np.ndarray, pixel_size: float, mag_factor: float
) -> (np.ndarray, ...):
field_size_pix = [
even_round(f * mag_factor**2 / pixel_size) for f in self.field_size_mm
even_round(f * mag_factor / pixel_size) for f in self.field_size_mm
]
cax_offset_pix_mag = [v * mag_factor / pixel_size for v in self.cax_offset_mm]
field_center = [
Expand Down
25 changes: 25 additions & 0 deletions pylinac/core/image_generator/simulators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from abc import ABC

import numpy as np
from plotly import graph_objects as go
from pydicom.dataset import Dataset, FileMetaDataset
from pydicom.uid import UID

from ..array_utils import array_to_dicom
from ..plotly_utils import add_title
from .layers import Layer


Expand Down Expand Up @@ -72,6 +74,29 @@ def generate_dicom(self, file_out_name: str, *args, **kwargs) -> None:
ds = self.as_dicom(*args, **kwargs)
ds.save_as(file_out_name, write_like_original=False)

def plot(self, show: bool = True) -> go.Figure:
"""Plot the simulated image."""
fig = go.Figure()
fig.add_heatmap(
z=self.image,
colorscale="gray",
x0=-self.image.shape[1] / 2 * self.pixel_size,
dx=self.pixel_size,
y0=-self.image.shape[0] / 2 * self.pixel_size,
dy=self.pixel_size,
)
fig.update_layout(
yaxis_constrain="domain",
xaxis_scaleanchor="y",
xaxis_constrain="domain",
xaxis_title="Crossplane (mm)",
yaxis_title="Inplane (mm)",
)
add_title(fig, f"Simulated {self.__class__.__name__} @{self.sid}mm SID")
if show:
fig.show()
return fig


class AS500Image(Simulator):
"""Simulates an AS500 EPID image."""
Expand Down
7 changes: 2 additions & 5 deletions tests_basic/core/test_gamma.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,10 +797,7 @@ def test_low_density_eval(self):

def test_different_epids(self):
"""This test the same profile but with different EPIDs (i.e. pixel size)"""
# we offset the reference by 1% to ensure we have a realistic gamma value
img1200 = generate_open_field(
field_size=(100, 100), imager=AS1200Image, alpha=0.99
)
img1200 = generate_open_field(field_size=(100, 100), imager=AS1200Image)
img1000 = generate_open_field(field_size=(100, 100), imager=AS1000Image)
p1200 = img1200.image[640, :]
p1000 = img1000.image[384, :]
Expand All @@ -810,4 +807,4 @@ def test_different_epids(self):
reference_profile=p1200_prof, dose_to_agreement=1, gamma_cap_value=2
)
# gamma is very low; just pixel noise from the image generator
self.assertAlmostEqual(np.nanmean(gamma), 0.938, delta=0.01)
self.assertLessEqual(np.nanmean(gamma), 0.005)
16 changes: 12 additions & 4 deletions tests_basic/core/test_image_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,18 @@ def profiles_from_simulator(
img = load(stream)
y_pixel = int(round(simulator.shape[0] * y_position))
x_pixel = int(round(simulator.shape[1] * x_position))
# The dpmm property is always scaled to SAD!!!!
# Thus, we do dpmm / mag_factor because we are taking the profile effectively at the SID
# and the dpmm at SID (vs SAD where we normally define it) is different if SID != SAD
inplane_profile = SingleProfile(
img[:, x_pixel].copy(),
dpmm=img.dpmm,
dpmm=img.dpmm / simulator.mag_factor,
interpolation=interpolation,
normalization_method=Normalization.NONE,
)
cross_profile = SingleProfile(
img[y_pixel, :].copy(),
dpmm=img.dpmm,
dpmm=img.dpmm / simulator.mag_factor,
interpolation=interpolation,
normalization_method=Normalization.NONE,
)
Expand Down Expand Up @@ -450,8 +453,13 @@ def test_10mm_150sid(self):
stream.seek(0)
img = load(stream)
img.invert() # we invert so the BB looks like a profile, not a dip
inplane_profile = SingleProfile(img[:, int(as1200.shape[1] / 2)], dpmm=img.dpmm)
cross_profile = SingleProfile(img[int(as1200.shape[0] / 2), :], dpmm=img.dpmm)
# correct for the dpmm via the mag factor because we are effectively at the SID (1500 per above)
inplane_profile = SingleProfile(
img[:, int(as1200.shape[1] / 2)], dpmm=img.dpmm / as1200.mag_factor
)
cross_profile = SingleProfile(
img[int(as1200.shape[0] / 2), :], dpmm=img.dpmm / as1200.mag_factor
)
self.assertAlmostEqual(
inplane_profile.fwxm_data()["width (exact) mm"], 15, delta=1
)
Expand Down
Loading

0 comments on commit bc7d6c2

Please sign in to comment.