From 07972af05cf24cac14350dec054e48739e277101 Mon Sep 17 00:00:00 2001 From: Camilo Sevilla Date: Fri, 29 May 2026 21:08:40 -0500 Subject: [PATCH] Add helper to create ring VOIs --- CHANGELOG.md | 1 + matRad/geometry/matRad_createRing.m | 92 +++++++++++++++++++++++++++++ test/geometry/test_createRing.m | 70 ++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 matRad/geometry/matRad_createRing.m create mode 100644 test/geometry/test_createRing.m diff --git a/CHANGELOG.md b/CHANGELOG.md index d82b13d3e..1600c1463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Userfolders can now also be set via environment variable `MATRAD_USERDATA` - Documentation: Documented the userfolder feature and its usage as well as other datastructures more clearly +- Added helper to create ring VOIs from margins around existing structures ### Fixed - possible negative doses in finesampling engine due to extrapolation in kernel interpolation diff --git a/matRad/geometry/matRad_createRing.m b/matRad/geometry/matRad_createRing.m new file mode 100644 index 000000000..25de50b25 --- /dev/null +++ b/matRad/geometry/matRad_createRing.m @@ -0,0 +1,92 @@ +function [cst, ixRing] = matRad_createRing(ixBase, ixLimit, cst, ct, vOuterMargin, vInnerMargin, metadata) +% matRad function to create an isotropic ring VOI clipped to a limiting VOI +% +% call: +% [cst, ixRing] = matRad_createRing(ixBase, ixLimit, cst, ct, vOuterMargin, vInnerMargin, metadata) +% +% input: +% ixBase: row index of the base VOI in the cst struct +% ixLimit: row index of the limiting VOI in the cst struct +% cst: matRad cst struct +% ct: matRad ct struct +% vOuterMargin: outer margin in mm, with fields x, y and z +% vInnerMargin: inner margin in mm, with fields x, y and z +% metadata: struct with fields name, type and visibleColor for the +% created ring VOI +% +% output: +% cst: updated matRad cst struct +% ixRing: row index of the created ring VOI +% +% References +% - +% +% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% +% Copyright 2026 the matRad development team. +% +% This file is part of the matRad project. It is subject to the license +% terms in the LICENSE file found in the top-level directory of this +% distribution and at https://github.com/e0404/matRad/LICENSE.md. No part +% of the matRad project, including this file, may be copied, modified, +% propagated, or distributed except according to the terms contained in the +% LICENSE file. +% +% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +matRadCfg = MatRad_Config.instance(); + +if nargin < 7 + matRadCfg.dispError('Not enough input arguments specified for matRad_createRing.'); +end + +if ixBase < 1 || ixBase > size(cst, 1) || ixLimit < 1 || ixLimit > size(cst, 1) + matRadCfg.dispError('Base and limiting VOI indices must refer to existing cst rows.'); +end + +if ~isfield(ct, 'numOfCtScen') || ct.numOfCtScen < 1 + matRadCfg.dispError('ct.numOfCtScen must be available and positive.'); +end + +if ~isfield(ct, 'cubeDim') || ~isfield(ct, 'resolution') + matRadCfg.dispError('ct.cubeDim and ct.resolution are required to create a ring VOI.'); +end + +if ~all(isfield(vOuterMargin, {'x', 'y', 'z'})) || ~all(isfield(vInnerMargin, {'x', 'y', 'z'})) + matRadCfg.dispError('Ring margins must contain x, y and z fields.'); +end + +if ~all(isfield(metadata, {'name', 'type', 'visibleColor'})) + matRadCfg.dispError('Ring metadata must contain name, type and visibleColor fields.'); +end + +if numel(cst{ixBase, 4}) < ct.numOfCtScen || numel(cst{ixLimit, 4}) < ct.numOfCtScen + matRadCfg.dispError('Base and limiting VOIs must contain all CT scenarios.'); +end + +voiRing = cell(1, ct.numOfCtScen); +useDiagonalConnectivity = false; + +for scenIx = 1:ct.numOfCtScen + baseVoxels = cst{ixBase, 4}{scenIx}; + limitVoxels = cst{ixLimit, 4}{scenIx}; + + baseMask = zeros(ct.cubeDim); + baseMask(baseVoxels) = 1; + + outerVoxels = find(matRad_addMargin(baseMask, cst, ct.resolution, vOuterMargin, useDiagonalConnectivity)); + innerVoxels = find(matRad_addMargin(baseMask, cst, ct.resolution, vInnerMargin, useDiagonalConnectivity)); + + voiRing{scenIx} = intersect(setdiff(outerVoxels, innerVoxels), limitVoxels); +end + +ixRing = size(cst, 1) + 1; + +cst{ixRing, 1} = cst{end, 1} + 1; +cst{ixRing, 2} = metadata.name; +cst{ixRing, 3} = metadata.type; +cst{ixRing, 4} = voiRing; +cst{ixRing, 5} = cst{ixBase, 5}; +cst{ixRing, 5}.visibleColor = metadata.visibleColor; + +end diff --git a/test/geometry/test_createRing.m b/test/geometry/test_createRing.m new file mode 100644 index 000000000..901c6c1a7 --- /dev/null +++ b/test/geometry/test_createRing.m @@ -0,0 +1,70 @@ +function test_suite = test_createRing + +test_functions = localfunctions(); + +initTestSuite; + +function test_createRingBuildsOuterMinusInnerVoi +[ct, cst] = helper_createRingFixture(); +[outerMargin, innerMargin, metadata] = helper_ringArguments(); + +[cst, ixRing] = matRad_createRing(1, 2, cst, ct, outerMargin, innerMargin, metadata); + +targetVoxel = sub2ind(ct.cubeDim, 3, 3, 2); +expectedRing = sort([ ... + sub2ind(ct.cubeDim, 2, 3, 2); ... + sub2ind(ct.cubeDim, 4, 3, 2); ... + sub2ind(ct.cubeDim, 3, 2, 2); ... + sub2ind(ct.cubeDim, 3, 4, 2); ... + sub2ind(ct.cubeDim, 3, 3, 1); ... + sub2ind(ct.cubeDim, 3, 3, 3)]); + +assertEqual(ixRing, 3); +assertEqual(cst{ixRing, 2}, 'PTV_RING'); +assertEqual(cst{ixRing, 3}, 'OAR'); +assertEqual(cst{ixRing, 5}.visibleColor, [0 1 0]); +assertFalse(any(cst{ixRing, 4}{1} == targetVoxel)); +assertEqual(sort(cst{ixRing, 4}{1}), expectedRing); + +function test_createRingClipsToLimitVoi +[ct, cst] = helper_createRingFixture(); +cst{2, 4}{1} = sub2ind(ct.cubeDim, [2 3 4], [3 3 3], [2 2 2])'; +[outerMargin, innerMargin, metadata] = helper_ringArguments(); + +[cst, ixRing] = matRad_createRing(1, 2, cst, ct, outerMargin, innerMargin, metadata); + +expectedRing = sort([ ... + sub2ind(ct.cubeDim, 2, 3, 2); ... + sub2ind(ct.cubeDim, 4, 3, 2)]); + +assertEqual(sort(cst{ixRing, 4}{1}), expectedRing); + +function [ct, cst] = helper_createRingFixture +ct.cubeDim = [5 5 3]; +ct.numOfCtScen = 1; +ct.resolution = struct('x', 1, 'y', 1, 'z', 1); + +targetVoxel = sub2ind(ct.cubeDim, 3, 3, 2); +bodyVoxels = (1:prod(ct.cubeDim))'; + +cst = cell(2, 6); +cst{1, 1} = 0; +cst{1, 2} = 'PTV'; +cst{1, 3} = 'TARGET'; +cst{1, 4} = {targetVoxel}; +cst{1, 5} = struct('visibleColor', [1 0 0]); +cst{1, 6} = {}; + +cst{2, 1} = 1; +cst{2, 2} = 'BODY'; +cst{2, 3} = 'OAR'; +cst{2, 4} = {bodyVoxels}; +cst{2, 5} = struct('visibleColor', [0 0 1]); +cst{2, 6} = {}; + +function [outerMargin, innerMargin, metadata] = helper_ringArguments +metadata.name = 'PTV_RING'; +metadata.type = 'OAR'; +metadata.visibleColor = [0 1 0]; +outerMargin = struct('x', 1, 'y', 1, 'z', 1); +innerMargin = struct('x', 0, 'y', 0, 'z', 0);