From 6142e79c7e0162bc629b0f68e8543aa0e9f3771d Mon Sep 17 00:00:00 2001 From: ncguilbeault Date: Mon, 10 Nov 2025 13:53:51 +0000 Subject: [PATCH 1/4] Updated decoder package to support overlaying posterior onto image --- .../PosteriorImageOverlay.cs | 161 ++++++++++++++++++ .../PosteriorVisualizer.cs | 13 +- .../GetClassifierData.cs | 18 +- .../GetDecoderData.cs | 18 +- 4 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 src/Bonsai.ML.PointProcessDecoder.Design/PosteriorImageOverlay.cs diff --git a/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorImageOverlay.cs b/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorImageOverlay.cs new file mode 100644 index 00000000..da1cec10 --- /dev/null +++ b/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorImageOverlay.cs @@ -0,0 +1,161 @@ +using Bonsai; +using Bonsai.Design; +using Bonsai.Expressions; +using System; +using OxyPlot.Series; +using OxyPlot.Axes; +using OxyPlot; +using Bonsai.Vision.Design; +using Bonsai.ML.Torch; +using OpenCV.Net; +using static TorchSharp.torch; +using PointProcessDecoder.Core; +using System.Drawing.Imaging; +using System.Linq; +using TorchSharp; + +[assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorImageOverlay), + Target = typeof(MashupSource))] + +namespace Bonsai.ML.PointProcessDecoder.Design +{ + /// + /// Class that overlays the true posterior distribution on the input image. + /// + public class PosteriorImageOverlay : DialogTypeVisualizer + { + + private ImageMashupVisualizer imageVisualizer; + private int[] _stateSpaceMin; + private int[] _stateSpaceMax; + private int _height; + private int _width; + private PointProcessModel _model; + private string _modelName; + private static Func _extractPosterior; + + /// + public override void Load(IServiceProvider provider) + { + imageVisualizer = (ImageMashupVisualizer)provider.GetService(typeof(MashupVisualizer)); + } + + /// + public override void Show(object value) + { + if (value is not DecoderDataFrame && value is not ClassifierDataFrame) + { + return; + } + + _modelName ??= value switch + { + DecoderDataFrame decoderDataFrame => decoderDataFrame.Name, + ClassifierDataFrame classifierDataFrame => classifierDataFrame.Name, + _ => throw new InvalidOperationException("The input value is invalid.") + }; + + _extractPosterior ??= value switch + { + DecoderDataFrame _ => input => ((DecoderDataFrame)input).DecoderData.Posterior, + ClassifierDataFrame _ => input => ((ClassifierDataFrame)input).ClassifierData.DecoderData.Posterior, + _ => throw new InvalidOperationException("The node is invalid.") + }; + + if (_model is null) + { + _model = PointProcessModelManager.GetModel(_modelName); + + _stateSpaceMin = [.. _model.StateSpace.Points + .min(dim: 0) + .values + .to_type(ScalarType.Int32) + .data() + ]; + + _stateSpaceMax = [.. _model.StateSpace.Points + .max(dim: 0) + .values + .to_type(ScalarType.Int32) + .data() + ]; + + _width = _stateSpaceMax[0] - _stateSpaceMin[0]; + _height = _stateSpaceMax[1] - _stateSpaceMin[1]; + } + + var image = imageVisualizer.VisualizerImage; + + var posterior = _extractPosterior(value)[-1].T.unsqueeze(0); + + var posteriorScaled = torchvision.transforms.functional.resize(posterior, _height, _width); + posteriorScaled -= posteriorScaled.min(); + posteriorScaled /= posteriorScaled.max(); + posteriorScaled *= 255.0; + + var fullPosterior = zeros([1, image.Height, image.Width], dtype: ScalarType.Byte, device: posterior.device); + + fullPosterior[0, torch.TensorIndex.Slice(_stateSpaceMin[1], _stateSpaceMax[1]), torch.TensorIndex.Slice(_stateSpaceMin[0], _stateSpaceMax[0])] = posteriorScaled.to_type(ScalarType.Byte); + + var posteriorImage = OpenCVHelper.ToImage(fullPosterior.cpu().permute(1, 2, 0), CPU); + + var posteriorOverlay = new IplImage(posteriorImage.Size, posteriorImage.Depth, 3); + + CV.CvtColor(posteriorImage, posteriorOverlay, ColorConversion.Gray2Rgb); + + CV.LUT(posteriorOverlay, posteriorOverlay, ColormapExtensions.HotLut); + + CV.AddWeighted(image, 0.8, posteriorOverlay, 0.5, 0, image); + } + + /// + public override void Unload() + { + imageVisualizer = null; + _model = null; + _stateSpaceMin = null; + _stateSpaceMax = null; + _modelName = null; + _extractPosterior = null; + } + } + + internal static class ColormapExtensions + { + public static Mat HotLut => _hotLut ??= EnsureHotLut(); + private static Mat _hotLut; + private static Mat EnsureHotLut() + { + _hotLut = new Mat(1, 256, Depth.U8, 3); + for (int i = 0; i < 256; i++) + { + double t = i / 255.0; + double r, g, b; + if (t < 1.0 / 3.0) + { + r = 3 * t; + g = 0; + b = 0; + } + else if (t < 2.0 / 3.0) + { + r = 1; + g = 3 * t - 1; + b = 0; + } + else + { + r = 1; + g = 1; + b = 3 * t - 2; + } + // Clamp and convert to bytes (BGR order) + byte R = (byte)Math.Round(r * 255); + byte G = (byte)Math.Round(g * 255); + byte B = (byte)Math.Round(b * 255); + _hotLut[i] = new OpenCV.Net.Scalar(B, G, R); + } + return _hotLut; + } + } +} \ No newline at end of file diff --git a/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorVisualizer.cs b/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorVisualizer.cs index 138ec275..d952f1d1 100644 --- a/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorVisualizer.cs +++ b/src/Bonsai.ML.PointProcessDecoder.Design/PosteriorVisualizer.cs @@ -18,8 +18,12 @@ Target = typeof(Bonsai.ML.PointProcessDecoder.Decode))] [assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorVisualizer), Target = typeof(Bonsai.ML.PointProcessDecoder.GetDecoderData))] -[assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorVisualizer), +[assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorVisualizer), Target = typeof(Bonsai.ML.PointProcessDecoder.GetClassifierData))] +[assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorVisualizer), + Target = typeof(Bonsai.ML.PointProcessDecoder.DecoderDataFrame))] +[assembly: TypeVisualizer(typeof(Bonsai.ML.PointProcessDecoder.Design.PosteriorVisualizer), + Target = typeof(Bonsai.ML.PointProcessDecoder.ClassifierDataFrame))] namespace Bonsai.ML.PointProcessDecoder.Design { @@ -85,7 +89,7 @@ public override void Load(IServiceProvider provider) } } - if (node == null) + if (node is null) { throw new InvalidOperationException("The decode node is invalid."); } @@ -93,8 +97,9 @@ public override void Load(IServiceProvider provider) _convertInputData = node switch { Decode _ => input => (Tensor)input, - GetDecoderData _ => input => ((DecoderData)input).Posterior, - GetClassifierData _ => input => ((ClassifierData)input).DecoderData.Posterior, + GetDecoderData _ => input => ((DecoderDataFrame)input).DecoderData.Posterior, + GetClassifierData _ => input => ((ClassifierDataFrame)input).ClassifierData.DecoderData.Posterior, + DecoderDataFrame _ => input => ((DecoderDataFrame)input).DecoderData.Posterior, _ => throw new InvalidOperationException("The node is invalid.") }; diff --git a/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs b/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs index e044615e..85f5564a 100644 --- a/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs +++ b/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Reactive.Linq; +using PointProcessDecoder.Core; using PointProcessDecoder.Core.Decoder; using static TorchSharp.torch; @@ -26,13 +27,24 @@ public class GetClassifierData : IPointProcessModelReference /// /// /// - public IObservable Process(IObservable source) + public IObservable Process(IObservable source) { var modelName = Name; - return source.Select(input => + return source.Select(input => { var model = PointProcessModelManager.GetModel(modelName); - return new ClassifierData(model.StateSpace, input); + var classifierData = new ClassifierData(model.StateSpace, input); + return new ClassifierDataFrame( + classifierData, + modelName); }); } +} + +public readonly struct ClassifierDataFrame( + ClassifierData classifierData, + string name) : IPointProcessModelReference +{ + public ClassifierData ClassifierData => classifierData; + public string Name => name; } \ No newline at end of file diff --git a/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs b/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs index 95647757..e8ff0b6f 100644 --- a/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs +++ b/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using System.Reactive.Linq; +using PointProcessDecoder.Core; using PointProcessDecoder.Core.Decoder; using static TorchSharp.torch; @@ -26,13 +27,24 @@ public class GetDecoderData : IPointProcessModelReference /// /// /// - public IObservable Process(IObservable source) + public IObservable Process(IObservable source) { var modelName = Name; - return source.Select(input => + return source.Select(input => { var model = PointProcessModelManager.GetModel(modelName); - return new DecoderData(model.StateSpace, input); + var decoderData = new DecoderData(model.StateSpace, input); + return new DecoderDataFrame( + decoderData, + modelName); }); } +} + +public readonly struct DecoderDataFrame( + DecoderData decoderData, + string name) : IPointProcessModelReference +{ + public DecoderData DecoderData => decoderData; + public string Name => name; } \ No newline at end of file From 511f57feae06e50295d2280a6596e37b1e8c0ddc Mon Sep 17 00:00:00 2001 From: ncguilbeault Date: Mon, 10 Nov 2025 18:40:15 +0000 Subject: [PATCH 2/4] Moved structs to dedicated classes and added XML doc strings --- .../ClassifierDataFrame.cs | 23 +++++++++++++++++++ .../DecoderDataFrame.cs | 23 +++++++++++++++++++ .../GetClassifierData.cs | 8 ------- .../GetDecoderData.cs | 8 ------- 4 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 src/Bonsai.ML.PointProcessDecoder/ClassifierDataFrame.cs create mode 100644 src/Bonsai.ML.PointProcessDecoder/DecoderDataFrame.cs diff --git a/src/Bonsai.ML.PointProcessDecoder/ClassifierDataFrame.cs b/src/Bonsai.ML.PointProcessDecoder/ClassifierDataFrame.cs new file mode 100644 index 00000000..e6f37780 --- /dev/null +++ b/src/Bonsai.ML.PointProcessDecoder/ClassifierDataFrame.cs @@ -0,0 +1,23 @@ +using PointProcessDecoder.Core.Decoder; + +namespace Bonsai.ML.PointProcessDecoder; + +/// +/// Represents a packaged data frame containing the output of a point process classifier model. +/// +/// +/// +public readonly struct ClassifierDataFrame( + ClassifierData classifierData, + string name) : IPointProcessModelReference +{ + /// + /// The packaged classifier data. + /// + public ClassifierData ClassifierData => classifierData; + + /// + /// The name of the point process model. + /// + public string Name => name; +} \ No newline at end of file diff --git a/src/Bonsai.ML.PointProcessDecoder/DecoderDataFrame.cs b/src/Bonsai.ML.PointProcessDecoder/DecoderDataFrame.cs new file mode 100644 index 00000000..e0d230b9 --- /dev/null +++ b/src/Bonsai.ML.PointProcessDecoder/DecoderDataFrame.cs @@ -0,0 +1,23 @@ +using PointProcessDecoder.Core.Decoder; + +namespace Bonsai.ML.PointProcessDecoder; + +/// +/// Represents a packaged data frame containing the output of a point process decoder model. +/// +/// +/// +public readonly struct DecoderDataFrame( + DecoderData decoderData, + string name) : IPointProcessModelReference +{ + /// + /// The packaged decoder data. + /// + public DecoderData DecoderData => decoderData; + + /// + /// The name of the point process model. + /// + public string Name => name; +} \ No newline at end of file diff --git a/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs b/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs index 85f5564a..e06a24f3 100644 --- a/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs +++ b/src/Bonsai.ML.PointProcessDecoder/GetClassifierData.cs @@ -39,12 +39,4 @@ public IObservable Process(IObservable source) modelName); }); } -} - -public readonly struct ClassifierDataFrame( - ClassifierData classifierData, - string name) : IPointProcessModelReference -{ - public ClassifierData ClassifierData => classifierData; - public string Name => name; } \ No newline at end of file diff --git a/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs b/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs index e8ff0b6f..711354b6 100644 --- a/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs +++ b/src/Bonsai.ML.PointProcessDecoder/GetDecoderData.cs @@ -39,12 +39,4 @@ public IObservable Process(IObservable source) modelName); }); } -} - -public readonly struct DecoderDataFrame( - DecoderData decoderData, - string name) : IPointProcessModelReference -{ - public DecoderData DecoderData => decoderData; - public string Name => name; } \ No newline at end of file From 1a219052f55154ffe6e0f47e148a04b2a706ce9b Mon Sep 17 00:00:00 2001 From: ncguilbeault Date: Fri, 27 Feb 2026 17:05:21 +0000 Subject: [PATCH 3/4] Updated `PointProcessDecoder` package version to latest --- .../Bonsai.ML.PointProcessDecoder.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bonsai.ML.PointProcessDecoder/Bonsai.ML.PointProcessDecoder.csproj b/src/Bonsai.ML.PointProcessDecoder/Bonsai.ML.PointProcessDecoder.csproj index 3730d386..4bd5ee16 100644 --- a/src/Bonsai.ML.PointProcessDecoder/Bonsai.ML.PointProcessDecoder.csproj +++ b/src/Bonsai.ML.PointProcessDecoder/Bonsai.ML.PointProcessDecoder.csproj @@ -11,7 +11,7 @@ - + From d70b04713251c2e585a61f6f0bfa2d0b18ff35e3 Mon Sep 17 00:00:00 2001 From: ncguilbeault Date: Fri, 27 Feb 2026 17:06:14 +0000 Subject: [PATCH 4/4] Aligned parameter names to latest version of decoder package --- .../CreatePointProcessModel.cs | 34 +++++++++---------- src/Bonsai.ML.PointProcessDecoder/Decode.cs | 14 ++++---- src/Bonsai.ML.PointProcessDecoder/Encode.cs | 10 +++--- .../PointProcessModelManager.cs | 30 ++++++++-------- 4 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/Bonsai.ML.PointProcessDecoder/CreatePointProcessModel.cs b/src/Bonsai.ML.PointProcessDecoder/CreatePointProcessModel.cs index e0299381..f96a893e 100644 --- a/src/Bonsai.ML.PointProcessDecoder/CreatePointProcessModel.cs +++ b/src/Bonsai.ML.PointProcessDecoder/CreatePointProcessModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Xml.Serialization; using System.Linq; @@ -87,7 +87,7 @@ public ScalarType? ScalarType /// [Category("Covariate Parameters")] [Description("The number of dimensions of the covariate.")] - public int Dimensions + public int CovariateDimensions { get { @@ -163,7 +163,7 @@ public long[] Steps [Category("Covariate Parameters")] [Description("The kernel bandwidth used to estimate the probability density over the covariate dimensions. Must be the same length as the covariate dimensions.")] [TypeConverter(typeof(UnidimensionalArrayConverter))] - public double[] Bandwidth + public double[] CovariateBandwidth { get { @@ -195,7 +195,7 @@ public EncoderType EncoderType private int? kernelLimit = null; /// - /// Gets or sets the maximum number of kernels maintained in memory for each probability density estimation made by the encoder. + /// Gets or sets the maximum number of kernels maintained in memory for each probability density estimation made by the encoder. /// /// /// In the case of sorted spikes, there is an estimate for the full covariate distribution and an estimate for each unit. # @@ -215,22 +215,22 @@ public int? KernelLimit } } - private int? nUnits = null; + private int? numUnits = null; /// /// Gets or sets the number of sorted spiking units. /// Only used when the encoder type is set to . /// [Category("Encoder Parameters")] [Description("The number of sorted spiking units. Only used when the encoder type is set to SortedSpikeEncoder.")] - public int? NUnits + public int? NumUnits { get { - return nUnits; + return numUnits; } set { - nUnits = value; + numUnits = value; } } @@ -253,22 +253,22 @@ public int? MarkDimensions } } - private int? markChannels = null; + private int? numChannels = null; /// /// Gets or sets the number of mark recording channels. /// Only used when the encoder type is set to . /// [Category("Encoder Parameters")] [Description("The number of mark recording channels. Only used when the encoder type is set to ClusterlessMarkEncoder.")] - public int? MarkChannels + public int? NumChannels { get { - return markChannels; + return numChannels; } set { - markChannels = value; + numChannels = value; } } @@ -352,7 +352,7 @@ public TransitionsType TransitionsType private double? sigmaRandomWalk = null; /// /// Gets or sets the standard deviation of the random walk transitions model. - /// Only used when the transitions type is set to or when the decoder type is set to + /// Only used when the transitions type is set to or when the decoder type is set to /// [Category("Decoder Parameters")] [Description("The standard deviation of the random walk transitions model. Only used when the transitions type is set to RandomWalk or when the decoder type is set to HybridStateSpaceClassifier.")] @@ -433,12 +433,12 @@ public IObservable Process() minStateSpace: minCovariateRange, maxStateSpace: maxCovariateRange, stepsStateSpace: stepsCovariateRange, - observationBandwidth: covariateBandwidth, + covariateBandwidth: covariateBandwidth, stateSpaceDimensions: covariateDimensions, markDimensions: markDimensions, - markChannels: markChannels, + numChannels: numChannels, markBandwidth: markBandwidth, - nUnits: nUnits, + numUnits: numUnits, distanceThreshold: distanceThreshold, sigmaRandomWalk: sigmaRandomWalk, kernelLimit: kernelLimit, @@ -449,4 +449,4 @@ public IObservable Process() .Concat(Observable.Never(resource.Model)) .Finally(resource.Dispose)); } -} \ No newline at end of file +} diff --git a/src/Bonsai.ML.PointProcessDecoder/Decode.cs b/src/Bonsai.ML.PointProcessDecoder/Decode.cs index a0b85bbf..403eecb3 100644 --- a/src/Bonsai.ML.PointProcessDecoder/Decode.cs +++ b/src/Bonsai.ML.PointProcessDecoder/Decode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Reactive.Linq; using System.Text; @@ -39,23 +39,23 @@ public bool IgnoreNoSpikes } /// - /// Decodes the input neural data into a posterior state estimate using a point process model. + /// Decodes the observations into a posterior state estimate using a point process model. /// /// /// public IObservable Process(IObservable source) { var modelName = Name; - return source.Select(input => + return source.Select(observations => { var model = PointProcessModelManager.GetModel(modelName); - if (_updateIgnoreNoSpikes) + if (_updateIgnoreNoSpikes) { model.Likelihood.IgnoreNoSpikes = _ignoreNoSpikes; _updateIgnoreNoSpikes = false; } - - return model.Decode(input); + + return model.Decode(observations); }); } -} \ No newline at end of file +} diff --git a/src/Bonsai.ML.PointProcessDecoder/Encode.cs b/src/Bonsai.ML.PointProcessDecoder/Encode.cs index 51a68a40..f60ad915 100644 --- a/src/Bonsai.ML.PointProcessDecoder/Encode.cs +++ b/src/Bonsai.ML.PointProcessDecoder/Encode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Reactive.Linq; @@ -11,7 +11,7 @@ namespace Bonsai.ML.PointProcessDecoder; /// [Combinator] [WorkflowElementCategory(ElementCategory.Sink)] -[Description("Encodes the combined state observation data and neural data into a point process model.")] +[Description("Encodes the combined state observation data and neural data into a point process model. The input should be a tuple of (covariate, observation) tensors. Covariate tensors should have shape (numSamples, covariateDim). Spike observations should have shape (num_samples, num_units). Clusterless marks should have shape (numSamples, markDim, numChannels).")] public class Encode : IPointProcessModelReference { /// @@ -32,8 +32,8 @@ public IObservable> Process(IObservable { var model = PointProcessModelManager.GetModel(modelName); - var (neuralData, stateObservations) = input; - model.Encode(neuralData, stateObservations); + var (covariates, observations) = input; + model.Encode(covariates, observations); }); } -} \ No newline at end of file +} diff --git a/src/Bonsai.ML.PointProcessDecoder/PointProcessModelManager.cs b/src/Bonsai.ML.PointProcessDecoder/PointProcessModelManager.cs index b1a5336a..ddd54cf4 100644 --- a/src/Bonsai.ML.PointProcessDecoder/PointProcessModelManager.cs +++ b/src/Bonsai.ML.PointProcessDecoder/PointProcessModelManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Reactive.Disposables; @@ -43,13 +43,13 @@ internal static PointProcessModelDisposable Reserve( double[] minStateSpace, double[] maxStateSpace, long[] stepsStateSpace, - double[] observationBandwidth, + double[] covariateBandwidth, int stateSpaceDimensions, int? markDimensions = null, - int? markChannels = null, + int? numChannels = null, double[]? markBandwidth = null, bool ignoreNoSpikes = false, - int? nUnits = null, + int? numUnits = null, double? distanceThreshold = null, double? sigmaRandomWalk = null, int? kernelLimit = null, @@ -68,13 +68,13 @@ internal static PointProcessModelDisposable Reserve( minStateSpace: minStateSpace, maxStateSpace: maxStateSpace, stepsStateSpace: stepsStateSpace, - observationBandwidth: observationBandwidth, + covariateBandwidth: covariateBandwidth, stateSpaceDimensions: stateSpaceDimensions, markDimensions: markDimensions, - markChannels: markChannels, + numChannels: numChannels, markBandwidth: markBandwidth, ignoreNoSpikes: ignoreNoSpikes, - nUnits: nUnits, + numUnits: numUnits, distanceThreshold: distanceThreshold, sigmaRandomWalk: sigmaRandomWalk, kernelLimit: kernelLimit, @@ -84,10 +84,11 @@ internal static PointProcessModelDisposable Reserve( ); models.Add(name, model); - + return new PointProcessModelDisposable( - model, - Disposable.Create(() => { + model, + Disposable.Create(() => + { models.Remove(name); }) ); @@ -101,12 +102,13 @@ internal static PointProcessModelDisposable Load( { var model = PointProcessModel.Load(path, device) as PointProcessModel ?? throw new InvalidOperationException("The model could not be loaded."); models.Add(name, model); - + return new PointProcessModelDisposable( - model, - Disposable.Create(() => { + model, + Disposable.Create(() => + { models.Remove(name); }) ); } -} \ No newline at end of file +}