Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
fec4812
added tables for red marrow, yellow marrow and soft tissue
stevenkboyd Oct 21, 2025
937fa65
added iodine and calcium
stevenkboyd Oct 29, 2025
1ad257b
Adding more materials
stevenkboyd Nov 12, 2025
1b3bf3c
Removed user output on maskThreshold as it creates output I do not wa…
stevenkboyd Nov 28, 2025
fba0a11
Added ability to use custom labels
stevenkboyd Nov 29, 2025
cf30238
Added SNR
stevenkboyd Nov 29, 2025
59d64ed
added utility to estimate linear attenuation
stevenkboyd Dec 1, 2025
68501c3
corrected error
stevenkboyd Dec 3, 2025
b17978a
Making progress...
stevenkboyd Dec 3, 2025
00c4720
some useful utilities
stevenkboyd Dec 3, 2025
2304f54
Updates on how we calculate attenuation with mixed materials
stevenkboyd Dec 4, 2025
2b2bdbe
Added a new function.
stevenkboyd Dec 6, 2025
3c7f47c
Added new functionailty
stevenkboyd Dec 6, 2025
29398db
Working on spectral toolkit
stevenkboyd Dec 6, 2025
6f28ffe
Updating code
stevenkboyd Dec 7, 2025
c0548aa
Fixing up more
stevenkboyd Dec 7, 2025
d9e42b1
Updates, but not working properly yet
stevenkboyd Dec 8, 2025
fc3e1cb
Solved some blunders in the code
stevenkboyd Dec 9, 2025
4128bab
Changed to cubic interpolation
stevenkboyd Dec 12, 2025
796889a
Corrected an error
stevenkboyd Dec 16, 2025
22888e8
Minor typo
stevenkboyd Dec 18, 2025
b458e78
Cleaning up
stevenkboyd Dec 21, 2025
6cb07d0
Refactor mass attenuation interpolation and improve error handling
stevenkboyd Jan 6, 2026
3921b7c
Add .DS_Store files to .gitignore for cleaner repository
stevenkboyd Jan 6, 2026
484acff
Refactor mass attenuation interpolation parameters for clarity and co…
stevenkboyd Jan 6, 2026
4f1387d
Enhance interpolate_mass_attenuation function with K-edge truncation …
stevenkboyd Jan 6, 2026
07ab2c4
Refactor interpolate_mass_attenuation function to improve K-edge hand…
stevenkboyd Jan 7, 2026
f30361e
Add launch.json to .gitignore for cleaner repository
stevenkboyd Jan 7, 2026
7cbe462
Enhance CalculateLinearAttenuation function to include ICRU mass dens…
stevenkboyd Jan 7, 2026
fdfe5ea
Update formatting of ICRU material density output for improved precision
stevenkboyd Jan 8, 2026
bdb8c45
added more detailed output
stevenkboyd Jan 30, 2026
27443dc
Adjusted mass density of HA according to ICRU44
stevenkboyd Jan 30, 2026
1c25dc6
update
stevenkboyd Jan 30, 2026
c0f3798
Update requirements to specify version constraints for numpy, opencv-…
stevenkboyd Feb 1, 2026
5068733
Fix docstring formatting for mass attenuation tables
stevenkboyd Feb 2, 2026
18d858b
added iron, brain, liver
stevenkboyd Feb 4, 2026
cb61704
Add CTWater material and update mass density definitions
stevenkboyd Feb 11, 2026
8d2269a
Refactor MaterialDecomposition to use dynamic mass density keys and i…
stevenkboyd Feb 12, 2026
4eea2d7
Update custom_labelsDict to handle blank lines in header information
stevenkboyd Feb 25, 2026
bc6ed0b
Updated get_phantom to use correct labels and calibration values
stevenkboyd Mar 5, 2026
628e408
Add k2hpo4 to k-edge dictionary and fix formatting for adipose entry
stevenkboyd Mar 9, 2026
7b69eb4
added k2hpo4
stevenkboyd Mar 9, 2026
72f2180
adding some more k-edge values to avoid numerical interpolation issue…
stevenkboyd Mar 9, 2026
5bf06a7
Adjusting styles for Ogo
stevenkboyd Mar 30, 2026
55a0e91
updating
stevenkboyd Mar 30, 2026
f1d8095
Added ability to show labels on vis2D
stevenkboyd Mar 30, 2026
eacbf63
Add new CLI tools for image processing
stevenkboyd Apr 12, 2026
ae3dadf
Enhance print_image_info function to support brief mode and improve o…
stevenkboyd Apr 12, 2026
7607e45
Updates
stevenkboyd Apr 23, 2026
257544d
Add AssignPhantomLabels.py and edit setup.cfg
shasick May 15, 2026
d12543d
Updated to be compatible with 2D and 4D images
stevenkboyd Jul 9, 2026
036a6b7
changes to environment
stevenkboyd Jul 9, 2026
e554f65
Resolve 4 conflicts with steve/spectral to prepare a merge to the mas…
friedchickean Oct 13, 2026
5f4c247
Fixed accidental duplication of labels 92-115
friedchickean Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,8 @@ ENV/

# mypy
.mypy_cache/
ogo/util/.DS_Store
ogo/util/graphcuts/.DS_Store
ogo/.vscode/launch.json
.vscode/launch.json
.DS_Store
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"idf.pythonInstallPath": "/Users/skboyd/miniconda3/bin/python",
"python-envs.defaultEnvManager": "ms-python.python:conda",
"python-envs.defaultPackageManager": "ms-python.python:conda"
}
30 changes: 20 additions & 10 deletions ogo/cli/AnalyzeBMD.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@


# Start Script
def AnalyzeBMD(image_filename, mask_filename, labels, output_filename, noheader, overwrite=False):
def AnalyzeBMD(image_filename, mask_filename, labels, output_filename, custom_labels=None, noheader=False, overwrite=False):
if output_filename:
ogo.message("Start of Script...")

# Master list of labels
labelsDict = lb.labels_dict
if custom_labels:
[labelsDict,labelsHdr] = ogo.custom_labelsDict(custom_labels)
else:
# Master list of labels
labelsDict = lb.labels_dict

# Check if output exists and should overwrite
ogo.pass_check_if_output_exists(output_filename,overwrite)
Expand Down Expand Up @@ -62,7 +65,7 @@ def AnalyzeBMD(image_filename, mask_filename, labels, output_filename, noheader,
if labels[0] == 0:
if output_filename:
ogo.message('No labels specified. Building list...')
labels = np.unique(array)
labels = np.unique(array).tolist()

# Remove label 0 from list (background)
if labels[0] == 0:
Expand All @@ -78,7 +81,7 @@ def AnalyzeBMD(image_filename, mask_filename, labels, output_filename, noheader,
os.sys.exit()
# if (str(labels[idx]) not in labelsDict):
if labels[idx] not in labelsDict:
print("ERROR: Label {:d} does not exist in master list of labels.".format(labels[idx]))
print("ERROR: Label {:d} does not exist in list of labels.".format(labels[idx]))
os.sys.exit()
if output_filename:
ogo.message('!> label {:3d} {:12s} --> {:d} voxels'.format(labels[idx], labelsDict[labels[idx]]['LABEL'],
Expand Down Expand Up @@ -108,10 +111,13 @@ def AnalyzeBMD(image_filename, mask_filename, labels, output_filename, noheader,
parameters_dict['Label'] = lab
parameters_dict['LabelDesc'] = labelsDict[lab]['LABEL']

parameters_dict['Integral BMD [mg/cc]'] = '{:.3f}'.format(bmd_outcomes['Integral BMD [mg/cc]'])
parameters_dict['Integral BMC [mg]'] = '{:.3f}'.format(bmd_outcomes['Integral BMC [mg]'])
parameters_dict['Bone Volume [mm^3]'] = '{:.3f}'.format(bmd_outcomes['Bone Volume [mm^3]'])
parameters_dict['Bone Volume [cm^3]'] = '{:.3f}'.format(bmd_outcomes['Bone Volume [cm^3]'])
parameters_dict['Integral BMD [mg/cc]'] = '{:.4f}'.format(bmd_outcomes['Integral BMD [mg/cc]'])
parameters_dict['Integral BMC [mg]'] = '{:.4f}'.format(bmd_outcomes['Integral BMC [mg]'])
parameters_dict['Bone Volume [mm^3]'] = '{:.4f}'.format(bmd_outcomes['Bone Volume [mm^3]'])
parameters_dict['Bone Volume [cm^3]'] = '{:.4f}'.format(bmd_outcomes['Bone Volume [cm^3]'])
parameters_dict['mean_signal'] = '{:.4f}'.format(bmd_outcomes['mean_signal'])
parameters_dict['std_noise'] = '{:.4f}'.format(bmd_outcomes['std_noise'])
parameters_dict['SNR'] = '{:.4f}'.format(bmd_outcomes['SNR'])

if output_filename:
if idx == 0 and not noheader:
Expand Down Expand Up @@ -144,6 +150,8 @@ def main():

If no output text file is defined then results are output to the screen.

Outputs the mean and standard deviation of the ROI to allow SNR calculation.

WARNING: If your input image is not calibrated then the results here will be
incorrect. There is no calibration done as part of this application.

Expand Down Expand Up @@ -171,9 +179,11 @@ def main():
help='Specify list of labels; space separated (e.g. 1 2 3) (Default: report all available)')
parser.add_argument('--output_filename', default=None, metavar='TEXTFILE',
help='Output file name (*.txt) (Default: dump to screen)')
parser.add_argument('--custom_labels', default=None, metavar='TEXTFILE',
help='Custom labels of type ITKSnap (*.txt) (Default: OgoMasterLabels)')
parser.add_argument('--noheader', action='store_true', help='Suppress printing header (default: %(default)s)')
parser.add_argument('--overwrite', action='store_true', help='Overwrite output without asking')
print()
#print()

# Parse and display
args = parser.parse_args()
Expand Down
278 changes: 278 additions & 0 deletions ogo/cli/AssignPhantomLabels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# /------------------------------------------------------------------------------+
# | 24-APR-2026 |
# | Copyright (c) Bone Imaging Laboratory |
# | All rights reserved |
# | bonelab@ucalgary.ca |
# +------------------------------------------------------------------------------+

# Post-processing script for ML-based calibration phantom rod segmentation.
# Takes the raw CT scan and the ML model's binary rod segmentation (all rods
# labeled 1) and automatically identifies the correct calibration phantom by
# fitting the measured rod HU values against known phantom densities from ogo.
# Rods are then relabeled with their correct phantom-specific labels.

# Imports
import argparse
import inspect
import re

import numpy as np
import nibabel as nib
import scipy as sp
from ogo.calib.standard_calibration import StandardCalibration
from ogo.calib.mindways_calibration import MindwaysCalibration
import ogo.util.Helper as ogo
from ogo.util.echo_arguments import echo_arguments
from ogo.util.cli_tools import (
check_overwrite,
validate_input_file
)


#------------------------------------------------------------------------------+
# Helper Functions
def get_known_phantoms():
"""Return a list of all known phantom names."""
source = inspect.getsource(ogo.get_phantom)
known_phantoms = re.findall(r"phantom_type in '([^']+)'", source)
return known_phantoms


def get_all_phantoms():
"""Return a list of all phantom dicts."""
known_phantoms = get_known_phantoms()
return [ogo.get_phantom(name) for name in known_phantoms]


def extract_rod_statistics(raw_array, rod_array, quiet):
"""Extract connected components and compute mean HU for each rod."""
labeled_array, num_features = sp.ndimage.label(rod_array)
n_rods = num_features

if not quiet:
ogo.message(f'Found {n_rods} rods')

rod_stats = []

for rod_id in range(1, n_rods + 1):
rod_mask = (labeled_array == rod_id)
mean_hu = raw_array[rod_mask].mean()

rod_stats.append({
"rod_id": rod_id,
"mean_hu": mean_hu,
"mask": rod_mask
})

if not quiet:
ogo.message(f' Rod {rod_id}: mean HU = {mean_hu:.2f}')

# Sort rods by mean HU ascending to match to phantom densities later
rod_stats.sort(key=lambda x: x["mean_hu"])

return rod_stats


def fit_phantom(rod_stats, phantom_dict, quiet):
"""Fit phantom calibration to rod statistics."""
hu_values = [rod["mean_hu"] for rod in rod_stats]
densities = phantom_dict["densities"]

if len(hu_values) != len(densities):
return None

# Sort densities ascending and reorder HU to match
paired = sorted(zip(densities, hu_values), key=lambda x: x[0])
densities_sorted = [d for d, h in paired]
hu_sorted = [h for d, h in paired]

try:
if phantom_dict["type"] == "k2hpo4":
water = phantom_dict["h2o_densities"]

# Sort all three lists together by density ascending
triples = sorted(zip(densities, hu_values, water), key=lambda x: x[0])
densities_sorted = [d for d, h, w in triples]
hu_sorted = [h for d, h, w in triples]
water_sorted = [w for d, h, w in triples]

calibrator = MindwaysCalibration()
calibrator.fit(hu_sorted, densities_sorted, water_sorted)
else:
calibrator = StandardCalibration()
calibrator.fit(hu_sorted, densities_sorted)

return calibrator

except Exception as e:
if not quiet:
ogo.message(f' Fit failed for {phantom_dict["name"]}: {e}')
return None


def rank_phantoms(rod_stats, all_phantoms, quiet):
"""Try fitting every known phantom and rank by R^2."""
n_rods = len(rod_stats)
results = []

for phantom_dict in all_phantoms:
if phantom_dict["number_rods"] != n_rods:
continue

calibrator = fit_phantom(rod_stats, phantom_dict, quiet)

if calibrator is not None:
r2 = calibrator.r_value ** 2
if not quiet:
ogo.message(f' {phantom_dict["name"]}: R^2 = {r2:.4f}')
results.append((phantom_dict, calibrator, r2))

results.sort(key=lambda x: x[2], reverse=True)
return results


def assign_labels(rod_stats, best_phantom, quiet):
"""Assign labels by matching HU rank to density rank."""
density_label_pairs = sorted(
zip(best_phantom["densities"], best_phantom["rod_labels"]),
key=lambda x: x[0]
)

if not quiet:
ogo.message('\nLabel assignments:')

for rod, (density, label) in zip(rod_stats, density_label_pairs):
rod["assigned_label"] = label
rod["assigned_density"] = density
if not quiet:
ogo.message(f' Rod {rod["rod_id"]}: mean HU = {rod["mean_hu"]:.2f} -> label {label} ({density} mg/cc)')

return rod_stats


def save_labeled_nifti(rod_stats, reference_img, output_file, quiet):
"""Build output label image with correct rod labels and save."""
output_array = np.zeros(reference_img.shape, dtype=np.int16)

for rod in rod_stats:
output_array[rod["mask"]] = rod["assigned_label"]

output_img = nib.Nifti1Image(output_array, affine=reference_img.affine, header=reference_img.header)
nib.save(output_img, output_file)

if not quiet:
ogo.message(f'\nSaved labeled output to: {output_file}')


#------------------------------------------------------------------------------+
# Main function
def AssignPhantomLabels(raw_nifti_file, rod_nifti_file, output_file, overwrite, quiet):
"""Assign correct labels to segmented phantom rods based on HU values.

This program automatically identifies the calibration phantom from segmented
phantom rods by fitting measured rod HU values against known phantom densities.
Rods are then relabeled with their correct phantom-specific labels.
"""

# Check if output exists and should overwrite
if not check_overwrite(output_file, overwrite):
return 1

# Load images - raw NIfTI image and segmented image of rods
if not quiet:
ogo.message(f'Reading raw image: {raw_nifti_file}')

raw_img = nib.load(raw_nifti_file)
raw_array = raw_img.get_fdata()

if not quiet:
ogo.message(f'Reading rod segmentation: {rod_nifti_file}')

rod_img = nib.load(rod_nifti_file)
rod_array = rod_img.get_fdata()

# Extract rod statistics
rod_stats = extract_rod_statistics(raw_array, rod_array, quiet)

if len(rod_stats) == 0:
ogo.message('ERROR: No rods found in segmentation.')
return 1

# Get all known phantoms and rank by fit
all_phantoms = get_all_phantoms()
ranked = rank_phantoms(rod_stats, all_phantoms, quiet)

if not ranked:
ogo.message(f'ERROR: No known phantom matched {len(rod_stats)} rods. Check segmentation.')
return 1

# Select best matching phantom
best_phantom, best_calibrator, best_r2 = ranked[0]
if not quiet:
ogo.message(f'\nBest match: {best_phantom["name"]} (R^2 = {best_r2:.4f})')

if best_r2 < 0.99:
ogo.message(f' WARNING: R^2 = {best_r2:.4f} is low — check segmentation')

# Assign labels and save output
rod_stats = assign_labels(rod_stats, best_phantom, quiet)
save_labeled_nifti(rod_stats, rod_img, output_file, quiet)

if not quiet:
ogo.message('[DONE]')

return 0


def main():
"""Setup argument parsing and run program."""

description = '''Assign correct labels to segmented calibration phantom rods.

Post-processing script for ML-based calibration phantom rod segmentation.
Takes the raw CT scan and the ML model's binary rod segmentation (all rods
labeled 1) and automatically identifies the correct calibration phantom by
fitting the measured rod HU values against known phantom densities. Rods are
then relabeled with their correct phantom-specific labels.

Output: <rod_segmentation>_labeled.nii.gz — rod mask with correct phantom labels
'''

epilog = '''Example calls:
ogoAssignPhantomLabels raw.nii.gz rods.nii.gz output.nii.gz
ogoAssignPhantomLabels raw.nii.gz rods.nii.gz output.nii.gz --quiet
'''

# Setup argument parsing
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter,
prog="ogoAssignPhantomLabels",
description=description,
epilog=epilog
)

parser.add_argument('raw_nifti_file', type=validate_input_file(['.nii', '.nii.gz']),
help='Raw CT image file (*.nii, *.nii.gz)')
parser.add_argument('rod_nifti_file', type=validate_input_file(['.nii', '.nii.gz']),
help='Rod segmentation file (*.nii, *.nii.gz)')
parser.add_argument('output_file', type=str,
help='Output labeled rod segmentation file (*.nii, *.nii.gz)')
parser.add_argument('--overwrite', '-ow', action='store_true',
help='Overwrite output without asking')
parser.add_argument('--quiet', '-q', action='store_true',
help='Suppress informational output to stdout')

print()

# Parse and display
args = parser.parse_args()

if not args.quiet:
ogo.message(echo_arguments('AssignPhantomLabels', vars(args)))

# Run program
return AssignPhantomLabels(**vars(args))


if __name__ == '__main__':
exit(main())
Loading
Loading