From 3ce1fd061ecbb10367883c61cae252a70a5b5bb1 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Sun, 13 Sep 2026 06:46:33 +0200 Subject: [PATCH 1/3] fix: correctness fixes in readers, dF/F, deconvolution and FluFinder detection - MDF: drop the private copy of resolveDataSizeAndDimensionArrangement, which the VirtualArray base class seals; the class could not be loaded at all, so MScan recordings could not be opened. - SciScanRaw.fileCheck returns a scalar logical. - CaImAn deconvolve: only call gcp when the Parallel Computing Toolbox is installed; it merely decides whether to print progress. - dffRoiMinusDffNpil: clear error when sgolayfilt (Signal Processing Toolbox) is missing, instead of an undefined-function error. - dffChenEtAl2013: take the baseline percentiles along time. - deconvolveDff: the input contract is numSamples x numRois (a vector is one ROI); orientation is no longer guessed from which dimension is larger, and the function now has help text. - FluFinder findUniqueRoisFromComponents: build the component image with accumarray (histcounts needed two bin edges and dropped the last unique pixel), return early when there are no components, and stop when the component image has no peak left instead of looping forever. - extractF help names RoI arrays instead of a nonexistent type. - ThorLabsTiffs and Suite2pCorrected call their own static method by qualified name instead of importing themselves. - Remove the EXTRACT ProcessorT class, unreferenced since 2022 apart from a commented-out block, and that block. Tests: every reader class must load, the SciScan predicate is scalar, the Chen dF/F of one ROI matches the multi-ROI result, and FluFinder's component picking handles no components, one repeated component and two separated components. Co-Authored-By: Claude Fable 5.1 --- .../+detect/findUniqueRoisFromComponents.m | 30 +++- .../+integration/+caiman/deconvolve.m | 14 +- .../+extract/@Processor/Processor.m | 14 -- .../+extract/@ProcessorT/ProcessorT.m | 134 ------------------ .../+integration/+suite2p/Suite2pCorrected.m | 3 +- .../+module/+twophoton/+io/+mscan/MDF.m | 30 ---- .../+twophoton/+io/+sciscan/SciScanRaw.m | 2 +- .../+twophoton/+io/+thorlabs/ThorLabsTiffs.m | 4 +- .../+process/+dff/dffChenEtAl2013.m | 4 +- .../+process/+dff/dffRoiMinusDffNpil.m | 7 + .../+twophoton/+roisignals/deconvolveDff.m | 38 +++-- .../+module/+twophoton/+roisignals/extractF.m | 3 +- tests/+twophotontest/DffTest.m | 16 ++- tests/+twophotontest/FluFinderDetectionTest.m | 58 ++++++++ tests/+twophotontest/ReaderResolutionTest.m | 33 +++++ 15 files changed, 179 insertions(+), 211 deletions(-) delete mode 100644 code/+nansen/+module/+twophoton/+integration/+extract/@ProcessorT/ProcessorT.m create mode 100644 tests/+twophotontest/FluFinderDetectionTest.m diff --git a/code/+nansen/+module/+twophoton/+autosegmentation/+flufinder/+detect/findUniqueRoisFromComponents.m b/code/+nansen/+module/+twophoton/+autosegmentation/+flufinder/+detect/findUniqueRoisFromComponents.m index 07b3cb8..da00b2b 100644 --- a/code/+nansen/+module/+twophoton/+autosegmentation/+flufinder/+detect/findUniqueRoisFromComponents.m +++ b/code/+nansen/+module/+twophoton/+autosegmentation/+flufinder/+detect/findUniqueRoisFromComponents.m @@ -58,17 +58,28 @@ % Boolean for all remaining components that wasn't taken care of yet remaining = true(numel(S), 1); - % Create a "sum projection" image of all components. - uniquePixelList = unique(allPixelIndices); - + % Create a "sum projection" image of all components: the number of + % components each pixel belongs to. accumarray counts every pixel, + % including the last unique one, and copes with zero or one pixel + % (histcounts needs at least two bin edges). componentImage = zeros(imageSize); - % Using a histogram here is faster than a for loop. % (Maybe only if there are 100ks of components...) - [N,E] = histcounts(allPixelIndices, uniquePixelList); - componentImage(E(1:end-1)) = N; + if ~isempty(allPixelIndices) + componentImage(:) = accumarray(allPixelIndices(:), 1, [numel(componentImage), 1]); + end summary.ComponentImageInit = componentImage; + summary.ComponentImageFinished = componentImage; %imviewer(componentImage) % Todo: return this as part of summary + if isempty(S) || isempty(allPixelIndices) + % Nothing to pick from; the merge and tagging below need rois. + warning('on', 'stats:linkage:NonMonotonicTree') + if nargout == 1 + clear summary + end + return + end + mask = false(imageSize); nRois = 0; @@ -83,7 +94,12 @@ % Find peak in the summed component image. This will be the most % active roi among candidates - [~, peakInd] = max(componentImage(:)); + [peakValue, peakInd] = max(componentImage(:)); + if peakValue <= 0 + % Every remaining pixel has been cleared; without this guard + % the loop would spin on an empty peak forever. + break + end % Find all components that contain this peak containsPeak = regionInd(allPixelIndices==peakInd); diff --git a/code/+nansen/+module/+twophoton/+integration/+caiman/deconvolve.m b/code/+nansen/+module/+twophoton/+integration/+caiman/deconvolve.m index 68b55e2..4470994 100644 --- a/code/+nansen/+module/+twophoton/+integration/+caiman/deconvolve.m +++ b/code/+nansen/+module/+twophoton/+integration/+caiman/deconvolve.m @@ -30,11 +30,7 @@ % todo... prevstr = []; starttime = tic; - if isempty(gcp('nocreate')) - dispProgress = true; - else - dispProgress = false; - end + dispProgress = ~isParallelPoolRunning(); if nRois < 5 dispProgress = false; @@ -141,3 +137,11 @@ fprintf(msg) end end + +function tf = isParallelPoolRunning() +%isParallelPoolRunning True if a parallel pool is open +% +% gcp belongs to the Parallel Computing Toolbox, which the module lists +% as optional, so it must not be called when the toolbox is absent. + tf = exist('gcp', 'file') == 2 && ~isempty(gcp('nocreate')); +end diff --git a/code/+nansen/+module/+twophoton/+integration/+extract/@Processor/Processor.m b/code/+nansen/+module/+twophoton/+integration/+extract/@Processor/Processor.m index c660da5..63c6c42 100644 --- a/code/+nansen/+module/+twophoton/+integration/+extract/@Processor/Processor.m +++ b/code/+nansen/+module/+twophoton/+integration/+extract/@Processor/Processor.m @@ -109,20 +109,6 @@ function onCompletion(obj) obj.mergeResults() end - % % % Todo: The following should be a separate session method... - % % if isempty(obj.OriginalStack) - % % sourceStack = obj.SourceStack; - % % else - % % sourceStack = obj.OriginalStack; - % % end - % % - % % % Get temporal segments - % % tExtracor = nansen.module.twophoton.integration.extract.ProcessorT(... - % % sourceStack, obj.Options, obj.MergedResults); - % % tExtracor.Options.Run.numFramesPerPart = 2000; - % % tExtracor.DataIoModel = obj.DataIoModel; - % % - % % tExtracor.runMethod() end %obj.createRoiClassificationData() diff --git a/code/+nansen/+module/+twophoton/+integration/+extract/@ProcessorT/ProcessorT.m b/code/+nansen/+module/+twophoton/+integration/+extract/@ProcessorT/ProcessorT.m deleted file mode 100644 index 2c46436..0000000 --- a/code/+nansen/+module/+twophoton/+integration/+extract/@ProcessorT/ProcessorT.m +++ /dev/null @@ -1,134 +0,0 @@ -classdef ProcessorT < nansen.stack.ImageStackProcessor - -% This class creates the following data variables: -% -% * ExtractTemporalWeights : Extracted temporal components -% (numRois x numTimepoints) single matrix - - % Rename to ExtractT - - % Todo: Run per channel and plane... - - properties (Constant, Hidden) - DATA_SUBFOLDER = fullfile('roi_data', 'autosegmentation_extract') - VARIABLE_PREFIX = 'ExtractTemporal' - end - - properties (Constant) - MethodName = 'EXTRACT (Signal Extraction)' - IsManual = false % Does method require manual supervision - IsQueueable = true % Can method be added to a queue - OptionsManager nansen.manage.OptionsManager = ... - nansen.OptionsManager('nansen.module.twophoton.integration.extract.Processor') - end - - properties %Options - end - - properties (Access = private) - SegmentationResults cell % nPlanes x nChannels - ExtractConfig cell % nPlanes x nChannels - %Results % cell array with cell for each part.. - end - - methods (Static) - function S = getDefaultOptions() - end - end - - methods % Structor - - function obj = ProcessorT(varargin) - - obj@nansen.stack.ImageStackProcessor(varargin{:}) - obj.SegmentationResults = varargin{3}; - - % Todo: Need to assign options and output from extract! - - if ~nargout - obj.runMethod() - clear obj - end - end - end - - methods (Access = protected) - - function onInitialization(obj) - %onInitialization Initialize variables - - filePath = obj.getDataFilePath('ExtractTemporalWeights_temp', '-w',... - 'Subfolder', obj.DATA_SUBFOLDER, 'IsInternal', true); - - if isfile(filePath) - obj.Results = obj.loadData('ExtractTemporalWeights_temp'); - end - - % Todo: Load from file.... - if isempty(obj.Results) - obj.Results = cell(1, obj.NumParts); - end - - if ~isa(obj.SegmentationResults, 'cell') - obj.SegmentationResults = {obj.SegmentationResults}; - end - assert(isa(obj.SegmentationResults{1}, 'struct'), ... - 'Expected segmentation results to be a cell array of structs') - obj.ExtractConfig = cell(size(obj.SegmentationResults)); - - for i = 1:numel(obj.SegmentationResults) - spatialWeights = obj.SegmentationResults{i}.spatial_weights; - [h, w, n] = size(spatialWeights); - - config = obj.SegmentationResults{i}.config; - config.max_iter = 0; - config.num_iter_stop_quality_checks=0; - config.S_init = reshape(spatialWeights, h * w, n); - config.verbose = 0; - - obj.ExtractConfig{i} = config; - end - end - - function onCompletion(obj) - % Todo: Fix this, need to run on a longer stack to ensure the temporal chunks are correctly merged... - % Todo: Adapt to work for multi-plane / multi-channel - % Concatenate temporal signals.... - - T = cat(2, obj.Results{:}); - signalArray = cat(3, obj.Results{:}); - - obj.MergedResults - % Save temporal profiles - obj.saveData('ExtractTemporalWeights', T, 'Subfolder', ... - obj.DATA_SUBFOLDER, 'IsInternal', true) - end - end - - methods (Access = protected) % Implement methods from ImageStackProcessor - - function [YOut, results] = processPart(obj, Y) - - iC = obj.StackIterator.CurrentIterationC; - iZ = obj.StackIterator.CurrentIterationZ; - config = obj.ExtractConfig{iZ, iC}; - - [~, results, ~] = run_extract(Y, config); - - YOut = []; - %obj.Results{obj.CurrentPart} = T; - %obj.saveData('ExtractTemporalWeights_temp', obj.Results) - %results = []; - end - - function tf = checkIfPartIsFinished(obj, partNumber) - tf = ~isempty(obj.Results{partNumber}); - end - end - - methods (Access = private) - - function loadResults(obj) - end - end -end diff --git a/code/+nansen/+module/+twophoton/+integration/+suite2p/Suite2pCorrected.m b/code/+nansen/+module/+twophoton/+integration/+suite2p/Suite2pCorrected.m index f2d5dea..22fb234 100644 --- a/code/+nansen/+module/+twophoton/+integration/+suite2p/Suite2pCorrected.m +++ b/code/+nansen/+module/+twophoton/+integration/+suite2p/Suite2pCorrected.m @@ -13,8 +13,7 @@ methods % Structors function obj = Suite2pCorrected(filePath, varargin) - import('nansen.module.twophoton.integration.suite2p.Suite2pCorrected') - filePath = Suite2pCorrected.lookForMultipartFiles(filePath); + filePath = nansen.module.twophoton.integration.suite2p.Suite2pCorrected.lookForMultipartFiles(filePath); obj@nansen.stack.virtual.TiffMultiPart(filePath, varargin{:}) end diff --git a/code/+nansen/+module/+twophoton/+io/+mscan/MDF.m b/code/+nansen/+module/+twophoton/+io/+mscan/MDF.m index b95b60d..b990216 100644 --- a/code/+nansen/+module/+twophoton/+io/+mscan/MDF.m +++ b/code/+nansen/+module/+twophoton/+io/+mscan/MDF.m @@ -122,36 +122,6 @@ function writeFrames(obj, frameIndex, data) methods (Access = private) - function resolveDataSizeAndDimensionArrangement(obj, stackSize) - % Todo: Remove when this is merged into main branch from - % dev-imagestack-5d - - % Find singleton dimensions. - isSingleton = stackSize == 1; - - % Get arrangement of dimensions of data - try - % Note subclasses might implement this as a constant - % property. If it's not implemented, use default DDA - dataDimensionArrangement = obj.DATA_DIMENSION_ARRANGEMENT; - catch - dataDimensionArrangement = obj.DEFAULT_DIMENSION_ARRANGEMENT; - end - - % Get order of dimensions of data - [~, ~, dimensionOrder] = intersect( obj.DEFAULT_DIMENSION_ARRANGEMENT, ... - dataDimensionArrangement, 'stable' ); - - % Rearrange beased on dimension order - isSingleton_(dimensionOrder) = isSingleton; - dataSize(dimensionOrder) = stackSize; - - % Assign size and dimension arrangement for data excluding - % singleton dimension. - obj.DataSize = dataSize(~isSingleton_); - obj.DataDimensionArrangement = dataDimensionArrangement(~isSingleton_); - end - function mdfParams = getScanParameters(obj) import nansen.module.twophoton.io.mscan.getScanParameters mdfParams = getScanParameters(obj.mfile); diff --git a/code/+nansen/+module/+twophoton/+io/+sciscan/SciScanRaw.m b/code/+nansen/+module/+twophoton/+io/+sciscan/SciScanRaw.m index ff98462..6cd74b4 100644 --- a/code/+nansen/+module/+twophoton/+io/+sciscan/SciScanRaw.m +++ b/code/+nansen/+module/+twophoton/+io/+sciscan/SciScanRaw.m @@ -431,7 +431,7 @@ function getChannelColors(obj) inistring = fileread(iniFilePath); sciscanVars = {'external.start.trigger.enable', 'aocard.model'}; - isValid = contains(inistring, sciscanVars); + isValid = all(contains(inistring, sciscanVars)); end end diff --git a/code/+nansen/+module/+twophoton/+io/+thorlabs/ThorLabsTiffs.m b/code/+nansen/+module/+twophoton/+io/+thorlabs/ThorLabsTiffs.m index 7139eca..837e794 100644 --- a/code/+nansen/+module/+twophoton/+io/+thorlabs/ThorLabsTiffs.m +++ b/code/+nansen/+module/+twophoton/+io/+thorlabs/ThorLabsTiffs.m @@ -43,9 +43,7 @@ methods % Structors function obj = ThorLabsTiffs(filePath, varargin) - import('nansen.module.twophoton.io.thorlabs.ThorLabsTiffs') - - filePath = ThorLabsTiffs.lookForMultipartFiles(filePath); + filePath = nansen.module.twophoton.io.thorlabs.ThorLabsTiffs.lookForMultipartFiles(filePath); obj@nansen.stack.data.VirtualArray(filePath, varargin{:}) end diff --git a/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffChenEtAl2013.m b/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffChenEtAl2013.m index 846a4aa..5577614 100644 --- a/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffChenEtAl2013.m +++ b/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffChenEtAl2013.m @@ -16,8 +16,8 @@ fPil = squeeze( mean(signalArray(:, 2:end, :), 2) ); end - npil_true0 = prctile(fPil, 20); + npil_true0 = prctile(fPil, 20, 1); f_true = fRoi - (0.7*fPil) + npil_true0; - f_true0 = prctile(f_true, 20); + f_true0 = prctile(f_true, 20, 1); dff_true = (f_true - f_true0) ./ f_true0; end diff --git a/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffRoiMinusDffNpil.m b/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffRoiMinusDffNpil.m index f739db8..2ec7543 100644 --- a/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffRoiMinusDffNpil.m +++ b/code/+nansen/+module/+twophoton/+roisignals/+process/+dff/dffRoiMinusDffNpil.m @@ -51,6 +51,13 @@ if ~isa(deltaDff, 'double') || ~isa(dffNpil, 'double') deltaDff = double(deltaDff); dffNpil = double(dffNpil); end + if exist('sgolayfilt', 'file') ~= 2 + error('NANSEN:TwoPhoton:SignalProcessingToolboxRequired', ... + ['The dF/F method "dffRoiMinusDffNpil" uses sgolayfilt from the ', ... + 'Signal Processing Toolbox, which is not installed. Install the ', ... + 'toolbox, set smoothSignals to true, or choose another dffFcn ', ... + 'such as dffClassic.']) + end baselineCorrection = sgolayfilt(deltaDff, 3, 11); dff = dffRoi - sgolayfilt(dffNpil, 3, 11) + baselineCorrection; end diff --git a/code/+nansen/+module/+twophoton/+roisignals/deconvolveDff.m b/code/+nansen/+module/+twophoton/+roisignals/deconvolveDff.m index 27f1dd9..965cf75 100644 --- a/code/+nansen/+module/+twophoton/+roisignals/deconvolveDff.m +++ b/code/+nansen/+module/+twophoton/+roisignals/deconvolveDff.m @@ -1,4 +1,20 @@ function [dec, den, opt] = deconvolveDff(dff, varargin) +%deconvolveDff Deconvolve dF/F signals with the selected method +% +% [dec, den, opt] = deconvolveDff(dff) deconvolves dff, a numSamples x +% numRois array as returned by computeDff, and returns the deconvolved +% (dec) and denoised (den) signals in the same shape, plus the options +% used (opt). A vector is treated as a single ROI in either orientation. +% +% [dec, den, opt] = deconvolveDff(dff, Name, Value, ...) or +% deconvolveDff(dff, options) sets the deconvolution parameters, see +% getDeconvolutionParameters. The deconvolutionMethod parameter selects +% a function from the process.deconvolve package by name. +% +% P = deconvolveDff() returns the default parameters. +% +% See also nansen.module.twophoton.roisignals.computeDff, +% nansen.module.twophoton.roisignals.getDeconvolutionParameters [P, V] = nansen.module.twophoton.roisignals.getDeconvolutionParameters(); P.deconvolutionMethod = 'caiman'; @@ -12,17 +28,19 @@ deconvPackage = 'nansen.module.twophoton.roisignals.process.deconvolve'; deconvFunction = str2func( strjoin({deconvPackage, params.deconvolutionMethod}, '.') ); - % dff must be nRois x nSamples for deconvolution method... - wasTransposed = false; - if size(dff, 1) > size(dff, 2) - dff = transpose(dff); - wasTransposed = true; + % The deconvolution methods want numRois x numSamples. The input + % contract is numSamples x numRois (computeDff's output); a vector is + % one ROI regardless of orientation. Shape is not guessed from which + % dimension is larger, so recordings with more ROIs than samples are + % deconvolved correctly. + if isvector(dff) + dffForMethod = reshape(dff, 1, []); + else + dffForMethod = transpose(dff); end - [dec, den, opt] = deconvFunction(dff, params); + [dec, den, opt] = deconvFunction(dffForMethod, params); - if wasTransposed - dec = transpose(dec); - den = transpose(den); - end + dec = reshape(transpose(dec), size(dff)); + den = reshape(transpose(den), size(dff)); end diff --git a/code/+nansen/+module/+twophoton/+roisignals/extractF.m b/code/+nansen/+module/+twophoton/+roisignals/extractF.m index 2510e80..1a5cfe3 100644 --- a/code/+nansen/+module/+twophoton/+roisignals/extractF.m +++ b/code/+nansen/+module/+twophoton/+roisignals/extractF.m @@ -3,7 +3,8 @@ % % signals = extractF(imageStack, roiArray) extracts signals for rois in % roiArray from image frames in imageData using default settings. -% roiArray must be a nansen.RoiArray object and imageData can be a +% roiArray must be an array of RoI objects (or a roimanager.roiGroup) +% and imageData can be a % numeric array of three or more dimensions or a nansen.ImageStack % object. % diff --git a/tests/+twophotontest/DffTest.m b/tests/+twophotontest/DffTest.m index 156466f..15ab753 100644 --- a/tests/+twophotontest/DffTest.m +++ b/tests/+twophotontest/DffTest.m @@ -82,14 +82,26 @@ function testChenDffSubtractsWeightedNeuropil(testCase) fRoi = squeeze(signalArray(:, 1, :)); fPil = squeeze(signalArray(:, 2, :)); - fTrue = fRoi - 0.7*fPil + prctile(fPil, 20); - fTrue0 = prctile(fTrue, 20); + fTrue = fRoi - 0.7*fPil + prctile(fPil, 20, 1); + fTrue0 = prctile(fTrue, 20, 1); expected = (fTrue - fTrue0) ./ fTrue0; testCase.verifySize(dff, [testCase.NumSamples, testCase.NumRois]) testCase.verifyEqual(dff, expected, 'AbsTol', testCase.AbsTol) end + function testChenDffOfASingleRoiMatchesTheMultiRoiResult(testCase) + % The baselines must be taken along time for each ROI, so the + % first ROI's dF/F must not depend on the other ROIs being + % present. + signalArray = testCase.createRoiSignals(2); + dffAll = testCase.computeDff(signalArray, 'dffFcn', 'dffChenEtAl2013'); + dffOne = testCase.computeDff(signalArray(:, :, 1), 'dffFcn', 'dffChenEtAl2013'); + + testCase.verifySize(dffOne, [testCase.NumSamples, 1]) + testCase.verifyEqual(dffOne, dffAll(:, 1), 'AbsTol', testCase.AbsTol) + end + function testRoiMinusNeuropilDffRuns(testCase) % dffRoiMinusDffNpil uses sgolayfilt from the Signal Processing % Toolbox, which the module manifest lists as optional. diff --git a/tests/+twophotontest/FluFinderDetectionTest.m b/tests/+twophotontest/FluFinderDetectionTest.m new file mode 100644 index 0000000..5a0f4d1 --- /dev/null +++ b/tests/+twophotontest/FluFinderDetectionTest.m @@ -0,0 +1,58 @@ +classdef FluFinderDetectionTest < matlab.unittest.TestCase +%FluFinderDetectionTest FluFinder's component picking on small inputs +% +% Exercises nansen.module.twophoton.autosegmentation.flufinder.detect. +% findUniqueRoisFromComponents with hand-built component stats, the +% struct array that getBwComponentStats returns (Area, Centroid, +% PixelIdxList). Covers the degenerate inputs that used to error in +% histcounts or spin forever: no components, and a single repeated +% component. +% +% Run tests: +% runtests('twophotontest.FluFinderDetectionTest') + + properties (Constant, Access = private) + ImageSize = [48, 48] + end + + methods (Access = private) + + function S = createComponents(testCase, center, radius, numObservations) + %createComponents The same disk observed numObservations times + [columns, rows] = meshgrid(1:testCase.ImageSize(2), 1:testCase.ImageSize(1)); + mask = (columns - center(1)).^2 + (rows - center(2)).^2 <= radius^2; + pixelIndices = find(mask); + S = repmat(struct('Area', numel(pixelIndices), 'Centroid', center, ... + 'PixelIdxList', pixelIndices), numObservations, 1); + end + + function rois = findRois(testCase, S) + rois = nansen.module.twophoton.autosegmentation.flufinder.detect. ... + findUniqueRoisFromComponents(testCase.ImageSize, S); + end + end + + methods (Test) + + function testNoComponentsGivesNoRois(testCase) + S = struct('Area', {}, 'Centroid', {}, 'PixelIdxList', {}); + rois = testCase.findRois(S); + testCase.verifyClass(rois, 'RoI') + testCase.verifyEmpty(rois) + end + + function testRepeatedComponentGivesOneRoi(testCase) + S = testCase.createComponents([20, 24], 5, 3); + rois = testCase.findRois(S); + testCase.verifyClass(rois, 'RoI') + testCase.verifyNumElements(rois, 1) + end + + function testTwoSeparatedComponentsGiveTwoRois(testCase) + S = [testCase.createComponents([14, 14], 5, 3); ... + testCase.createComponents([34, 34], 5, 3)]; + rois = testCase.findRois(S); + testCase.verifyNumElements(rois, 2) + end + end +end diff --git a/tests/+twophotontest/ReaderResolutionTest.m b/tests/+twophotontest/ReaderResolutionTest.m index ae5411a..2a60c08 100644 --- a/tests/+twophotontest/ReaderResolutionTest.m +++ b/tests/+twophotontest/ReaderResolutionTest.m @@ -17,6 +17,15 @@ % runtests('twophotontest.ReaderResolutionTest') properties (TestParameter) + ReaderClass = { ... + 'nansen.module.twophoton.io.sciscan.SciScanRaw', ... + 'nansen.module.twophoton.io.scanimage.ScanImageTiff', ... + 'nansen.module.twophoton.io.scanimage.ScanImageMultiRoiTiff', ... + 'nansen.module.twophoton.io.prairieview.PrairieViewTiffs', ... + 'nansen.module.twophoton.io.thorlabs.ThorLabsTiffs', ... + 'nansen.module.twophoton.io.mscan.MDF', ... + 'nansen.module.twophoton.integration.suite2p.Suite2pCorrected'} + FilenameCase = struct( ... 'prairieView', struct( ... 'filename', 'TSeries-01012020-0001_Cycle00001_Ch1_000001.ome.tif', ... @@ -59,6 +68,30 @@ function writeText(testCase, filePath, text) methods (Test) + function testReaderClassLoads(testCase, ReaderClass) + % A reader whose class definition cannot be loaded (for example + % an override of a sealed superclass method) is unusable from + % every entry point, so every reader must at least load. + metaClass = meta.class.fromName(ReaderClass); + testCase.verifyNotEmpty(metaClass, ... + sprintf('Reader class "%s" did not load', ReaderClass)) + end + + function testSciScanPredicateReturnsScalarLogical(testCase) + folderPath = testCase.makeFixtureFolder(); + baseName = '20200101_12_00_00_recording'; + rawFilePath = fullfile(folderPath, [baseName, '.raw']); + testCase.writeText(rawFilePath, ''); + testCase.writeText(fullfile(folderPath, [baseName, '.ini']), sprintf( ... + 'external.start.trigger.enable = TRUE\naocard.model = PCI-6110\n')); + + isValid = nansen.module.twophoton.io.sciscan.SciScanRaw.fileCheck(rawFilePath); + + testCase.verifyClass(isValid, 'logical') + testCase.verifySize(isValid, [1, 1]) + testCase.verifyTrue(isValid) + end + function testFilenameExpressionReaders(testCase, FilenameCase) className = testCase.resolve(FilenameCase.filename); testCase.verifyEqual(className, FilenameCase.className) From 57b1bcd36f27db50ffbd34031942d02b81d89c08 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Sun, 13 Sep 2026 06:50:29 +0200 Subject: [PATCH 2/3] ci: let the test-results step comment on pull requests The explicit permissions block lacked pull-requests: write, so the publish-unit-test-result step was refused with 403 on the first PR. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test-code.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index a4a187c..37c6984 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -21,6 +21,7 @@ concurrency: permissions: contents: write checks: write + pull-requests: write security-events: write jobs: From 9bf22b6aa7e914a15b4975367e91430565a08ca5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:53:26 +0000 Subject: [PATCH 3/3] Update GitHub badges [skip ci] --- .github/badges/code_issues.svg | 2 +- .github/badges/tests.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg index d0441a4..c0ee5dd 100644 --- a/.github/badges/code_issues.svg +++ b/.github/badges/code_issues.svg @@ -1 +1 @@ -code issues: 252code issues252 +code issues: 249code issues249 diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index d9e3818..97f46fd 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -tests: 26 passedtests26 passed +tests: 38 passedtests38 passed