diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..c5f3f6b9c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/src/main/java/fiji/plugin/trackmate/gui/components/tracker/KalmanTrackerFFConfigPanel.java b/src/main/java/fiji/plugin/trackmate/gui/components/tracker/KalmanTrackerFFConfigPanel.java new file mode 100644 index 000000000..d425d6da3 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/gui/components/tracker/KalmanTrackerFFConfigPanel.java @@ -0,0 +1,204 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2024 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.gui.components.tracker; + +import static fiji.plugin.trackmate.gui.Fonts.BIG_FONT; +import static fiji.plugin.trackmate.gui.Fonts.FONT; +import static fiji.plugin.trackmate.gui.Fonts.TEXTFIELD_DIMENSION; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_GAP_CLOSING_MAX_FRAME_GAP; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_LINKING_MAX_DISTANCE; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_KALMAN_SEARCH_RADIUS; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_EXPECTED_MOVEMENT; + +import java.awt.Font; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.JFormattedTextField; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.SwingConstants; +import javax.swing.JTextField; + +import fiji.plugin.trackmate.gui.GuiUtils; +import fiji.plugin.trackmate.gui.components.ConfigurationPanel; + +public class KalmanTrackerFFConfigPanel extends ConfigurationPanel +{ + private static final long serialVersionUID = 1L; + + private final JFormattedTextField tfInitSearchRadius; + + private final JFormattedTextField tfSearchRadius; + + private final JFormattedTextField tfMaxFrameGap; + + private final JTextField tfExpectedMovement; + + public KalmanTrackerFFConfigPanel( final String trackerName, final String infoText, final String spaceUnits ) + { + setLayout( null ); + + final JLabel lbl1 = new JLabel( "Settings for tracker:" ); + lbl1.setBounds( 6, 6, 288, 16 ); + lbl1.setFont( FONT ); + add( lbl1 ); + + final JLabel lblTrackerName = new JLabel( trackerName ); + lblTrackerName.setFont( BIG_FONT ); + lblTrackerName.setHorizontalAlignment( SwingConstants.CENTER ); + lblTrackerName.setBounds( 6, 34, 288, 32 ); + add( lblTrackerName ); + + final JLabel lblTrackerDescription = new JLabel( "" ); + lblTrackerDescription.setFont( FONT.deriveFont( Font.ITALIC ) ); + lblTrackerDescription.setVerticalAlignment( SwingConstants.TOP ); + lblTrackerDescription.setBounds( 6, 81, 288, 175 ); + lblTrackerDescription.setText( infoText + .replace( "
", "" ) + .replace( "

", "

" ) + .replace( "", "

" ) ); + add( lblTrackerDescription ); + + final JLabel lblInitSearchRadius = new JLabel( "Initial search radius:" ); + lblInitSearchRadius.setFont( FONT ); + lblInitSearchRadius.setBounds( 6, 348, 173, 16 ); + add( lblInitSearchRadius ); + + final JLabel lblSearchRadius = new JLabel( "Search radius:" ); + lblSearchRadius.setFont( FONT ); + lblSearchRadius.setBounds( 6, 376, 173, 16 ); + add( lblSearchRadius ); + + final JLabel lblMaxFrameGap = new JLabel( "Max frame gap:" ); + lblMaxFrameGap.setFont( FONT ); + lblMaxFrameGap.setBounds( 6, 404, 173, 16 ); + add( lblMaxFrameGap ); + + tfInitSearchRadius = new JFormattedTextField( 15. ); + tfInitSearchRadius.setHorizontalAlignment( SwingConstants.CENTER ); + tfInitSearchRadius.setFont( FONT ); + tfInitSearchRadius.setBounds( 167, 348, 60, 28 ); + add( tfInitSearchRadius ); + tfInitSearchRadius.setSize( TEXTFIELD_DIMENSION ); + + tfSearchRadius = new JFormattedTextField( 15. ); + tfSearchRadius.setHorizontalAlignment( SwingConstants.CENTER ); + tfSearchRadius.setFont( FONT ); + tfSearchRadius.setBounds( 167, 376, 60, 28 ); + add( tfSearchRadius ); + tfSearchRadius.setSize( TEXTFIELD_DIMENSION ); + + tfMaxFrameGap = new JFormattedTextField( 2 ); + tfMaxFrameGap.setHorizontalAlignment( SwingConstants.CENTER ); + tfMaxFrameGap.setFont( FONT ); + tfMaxFrameGap.setBounds( 167, 404, 60, 28 ); + add( tfMaxFrameGap ); + tfMaxFrameGap.setSize( TEXTFIELD_DIMENSION ); + + tfExpectedMovement = new JTextField("0;0;0"); // Initialize with default value + tfExpectedMovement.setHorizontalAlignment(SwingConstants.CENTER); + tfExpectedMovement.setFont(FONT); + tfExpectedMovement.setBounds(167, 432, 100, 28); // Adjust position as needed + add(tfExpectedMovement); + + // Adding input validation + tfExpectedMovement.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + if (!validateExpectedMovementInput(tfExpectedMovement.getText())) { + JOptionPane.showMessageDialog(null, "Invalid input. Please enter a semicolon-separated list of three numbers (e.g., '123;0;0').", "Invalid Input", JOptionPane.ERROR_MESSAGE); + tfExpectedMovement.setText(String.format("%.1f;%.1f;%.1f", 0, 0, 0)); + } + } + }); + + final JLabel lblSpaceUnits1 = new JLabel( spaceUnits ); + lblSpaceUnits1.setFont( FONT ); + lblSpaceUnits1.setBounds( 219, 348, 51, 16 ); + add( lblSpaceUnits1 ); + + final JLabel lblSpaceUnits2 = new JLabel( spaceUnits ); + lblSpaceUnits2.setFont( FONT ); + lblSpaceUnits2.setBounds( 219, 376, 51, 16 ); + add( lblSpaceUnits2 ); + + final JLabel lblFrameUnits = new JLabel( "frames" ); + lblFrameUnits.setFont( FONT ); + lblFrameUnits.setBounds( 219, 404, 51, 16 ); + add( lblFrameUnits ); + + // Select text-fields content on focus. + GuiUtils.selectAllOnFocus( tfInitSearchRadius ); + GuiUtils.selectAllOnFocus( tfMaxFrameGap ); + GuiUtils.selectAllOnFocus( tfSearchRadius ); + GuiUtils.selectAllOnFocus( tfExpectedMovement ); + } + + private boolean validateExpectedMovementInput(String input) { + String[] parts = input.split(";"); + if (parts.length != 3) + return false; + try { + for (String part : parts) { + Double.parseDouble(part.trim()); + } + } catch (NumberFormatException e) { + return false; + } + return true; + } + + @Override + public void setSettings( final Map< String, Object > settings ) + { + tfInitSearchRadius.setValue( settings.get( KEY_LINKING_MAX_DISTANCE ) ); + tfSearchRadius.setValue( settings.get( KEY_KALMAN_SEARCH_RADIUS ) ); + tfMaxFrameGap.setValue( settings.get( KEY_GAP_CLOSING_MAX_FRAME_GAP ) ); + double[] expectedMovementArray = (double[]) settings.get( KEY_EXPECTED_MOVEMENT ); + tfExpectedMovement.setText( expectedMovementArray[0] + ";" + expectedMovementArray[1] + ";" + expectedMovementArray[2] ); + } + + @Override + public Map< String, Object > getSettings() + { + final Map< String, Object > settings = new HashMap<>(); + settings.put( KEY_LINKING_MAX_DISTANCE, ( ( Number ) tfInitSearchRadius.getValue() ).doubleValue() ); + settings.put( KEY_KALMAN_SEARCH_RADIUS, ( ( Number ) tfSearchRadius.getValue() ).doubleValue() ); + settings.put( KEY_GAP_CLOSING_MAX_FRAME_GAP, ( ( Number ) tfMaxFrameGap.getValue() ).intValue() ); + + String[] parts = tfExpectedMovement.getText().split(";"); + double[] expectedMovement = new double[3]; + for (int i = 0; i < parts.length; i++) { + expectedMovement[i] = Double.parseDouble(parts[i].trim()); + } + settings.put(KEY_EXPECTED_MOVEMENT, expectedMovement); + + return settings; + } + + @Override + public void clean() + {} +} diff --git a/src/main/java/fiji/plugin/trackmate/tracking/TrackerKeys.java b/src/main/java/fiji/plugin/trackmate/tracking/TrackerKeys.java index 5c45912a1..fc12356c2 100644 --- a/src/main/java/fiji/plugin/trackmate/tracking/TrackerKeys.java +++ b/src/main/java/fiji/plugin/trackmate/tracking/TrackerKeys.java @@ -100,6 +100,13 @@ public class TrackerKeys /** A default value for the {@value #KEY_KALMAN_SEARCH_RADIUS} parameter. */ public static final double DEFAULT_KALMAN_SEARCH_RADIUS = 20.0; + + /** Search radius for the Kalman tracker - Fast Flow. */ + public static final String KEY_EXPECTED_MOVEMENT = "EXPECTED_MOVEMENT"; + + /** A default value for the {@value #KEY_EXPECTED_MOVEMENT} parameter. */ + public static final double[] DEFAULT_EXPECTED_MOVEMENT = {0.0, 0.0, 0.0}; + /** * Key for the parameter specifying whether we allow the detection of diff --git a/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFF.java b/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFF.java new file mode 100644 index 000000000..92140ad85 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFF.java @@ -0,0 +1,559 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2024 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.tracking.kalman; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NavigableSet; + +import org.jgrapht.graph.DefaultWeightedEdge; +import org.jgrapht.graph.SimpleWeightedGraph; +import org.scijava.Cancelable; + +import fiji.plugin.trackmate.Logger; +import fiji.plugin.trackmate.Spot; +import fiji.plugin.trackmate.SpotCollection; +import fiji.plugin.trackmate.tracking.SpotTracker; +import fiji.plugin.trackmate.tracking.jaqaman.JaqamanLinker; +import fiji.plugin.trackmate.tracking.jaqaman.costfunction.CostFunction; +import fiji.plugin.trackmate.tracking.jaqaman.costfunction.FeaturePenaltyCostFunction; +import fiji.plugin.trackmate.tracking.jaqaman.costfunction.SquareDistCostFunction; +import fiji.plugin.trackmate.tracking.jaqaman.costmatrix.JaqamanLinkingCostMatrixCreator; +import net.imglib2.algorithm.Benchmark; + +public class KalmanTrackerFF implements SpotTracker, Benchmark, Cancelable +{ + + private static final double ALTERNATIVE_COST_FACTOR = 1.05d; + + private static final double PERCENTILE = 1d; + + private static final String BASE_ERROR_MSG = "[KalmanTracker-FastFlow] "; + + private SimpleWeightedGraph< Spot, DefaultWeightedEdge > graph; + + private String errorMessage; + + private Logger logger = Logger.VOID_LOGGER; + + private final SpotCollection spots; + + private final double maxSearchRadius; + + private final int maxFrameGap; + + private final double initialSearchRadius; + + private final Map< String, Double > featurePenalties; + + private final double[] expectedMovement; + + private boolean savePredictions = false; + + private SpotCollection predictionsCollection; + + private long processingTime; + + private boolean isCanceled; + + private String cancelReason; + + /* + * CONSTRUCTOR + */ + + /** + * @param spots + * the spots to track. + * @param maxSearchRadius + * @param maxFrameGap + * @param initialSearchRadius + * @param expectedMovement + */ + public KalmanTrackerFF( final SpotCollection spots, final double maxSearchRadius, final int maxFrameGap, final double initialSearchRadius, final Map< String, Double > featurePenalties, final double[] expectedMovement ) + { + this.spots = spots; + this.maxSearchRadius = maxSearchRadius; + this.maxFrameGap = maxFrameGap; + this.initialSearchRadius = initialSearchRadius; + this.featurePenalties = featurePenalties; + this.expectedMovement = expectedMovement; + } + + /* + * PUBLIC METHODS + */ + + @Override + public SimpleWeightedGraph< Spot, DefaultWeightedEdge > getResult() + { + return graph; + } + + @Override + public boolean checkInput() + { + return true; + } + + @Override + public boolean process() + { + final long start = System.currentTimeMillis(); + + isCanceled = false; + cancelReason = null; + + /* + * Outputs + */ + + graph = new SimpleWeightedGraph<>( DefaultWeightedEdge.class ); + predictionsCollection = new SpotCollection(); + + /* + * Constants. + */ + + // Max KF search cost. + final double maxCost = maxSearchRadius * maxSearchRadius; + // Cost function to nucleate KFs. + // final CostFunction< Spot, Spot > nucleatingCostFunction = new + // SquareDistCostFunction(); + final CostFunction< Spot, Spot > nucleatingCostFunction = getCostFunction( featurePenalties ); + // Max cost to nucleate KFs. + final double maxInitialCost = initialSearchRadius * initialSearchRadius; + + final CostFunction< Spot, Spot > costFunction = getCostFunction( featurePenalties ); + + // Find first and second non-empty frames. + final NavigableSet< Integer > keySet = spots.keySet(); + final Iterator< Integer > frameIterator = keySet.iterator(); + + /* + * Initialize. Find first links just based on square distance. We do + * this via the orphan spots lists. + */ + + // Spots in the PREVIOUS frame that were not part of a link. + Collection< Spot > previousOrphanSpots = new ArrayList<>(); + if ( !frameIterator.hasNext() ) + return true; + + int firstFrame = frameIterator.next(); + while ( true ) + { + previousOrphanSpots = generateSpotList( spots, firstFrame ); + if ( !frameIterator.hasNext() ) + return true; + if ( !previousOrphanSpots.isEmpty() ) + break; + + firstFrame = frameIterator.next(); + } + + /* + * Spots in the current frame that are not part of a new link (no + * parent). + */ + Collection< Spot > orphanSpots = new ArrayList<>(); + int secondFrame = frameIterator.next(); + while ( true ) + { + orphanSpots = generateSpotList( spots, secondFrame ); + if ( !frameIterator.hasNext() ) + return true; + if ( !orphanSpots.isEmpty() ) + break; + + secondFrame = frameIterator.next(); + } + + /* + * Estimate Kalman filter variances. + * + * The search radius is used to derive an estimate of the noise that + * affects position and velocity. The two are linked: if we need a large + * search radius, then the fluctuations over predicted states are large. + */ + final double positionProcessStd = maxSearchRadius / 3d; + final double velocityProcessStd = maxSearchRadius / 3d; + /* + * We assume the detector did a good job and that positions measured are + * accurate up to a fraction of the spot radius + */ + + double meanSpotRadius = 0d; + for ( final Spot spot : orphanSpots ) + meanSpotRadius += spot.getFeature( Spot.RADIUS ).doubleValue(); + + meanSpotRadius /= orphanSpots.size(); + final double positionMeasurementStd = meanSpotRadius / 10d; + + // The master map that contains the currently active KFs. + final Map< CVMKalmanFilter, Spot > kalmanFiltersMap = new HashMap<>( orphanSpots.size() ); + + /* + * Then loop over time, starting from second frame. + */ + int p = 1; + for ( int frame = secondFrame; frame <= keySet.last(); frame++ ) + { + if ( isCanceled() ) + return true; // It's ok to be canceled. + + p++; + + // Use the spot in the next frame has measurements. + final List< Spot > measurements = generateSpotList( spots, frame ); + + /* + * Predict for all Kalman filters, and use it to generate linking + * candidates. + */ + final Map< Spot, CVMKalmanFilter > predictionMap = new HashMap<>( kalmanFiltersMap.size() ); + for ( final CVMKalmanFilter kf : kalmanFiltersMap.keySet() ) + { + final double[] X = kf.predict(); + final Spot s = kalmanFiltersMap.get( kf ); + final Spot predSpot = new Spot( X[ 0 ], X[ 1 ], X[ 2 ], s.getFeature( Spot.RADIUS ), s.getFeature( Spot.QUALITY ) ); + // copy the necessary features of original spot to the predicted + // spot + if ( null != featurePenalties ) + predSpot.copyFeatures( s, featurePenalties ); + + predictionMap.put( predSpot, kf ); + + if ( savePredictions ) + { + final Spot pred = new Spot( X[ 0 ], X[ 1 ], X[ 2 ], s.getFeature( Spot.RADIUS ), s.getFeature( Spot.QUALITY ) ); + pred.setName( "Pred_" + s.getName() ); + pred.putFeature( Spot.RADIUS, s.getFeature( Spot.RADIUS ) ); + predictionsCollection.add( predSpot, frame ); + } + + } + final List< Spot > predictions = new ArrayList<>( predictionMap.keySet() ); + + /* + * The KF for which we could not find a measurement in the target + * frame. Is updated later. + */ + final Collection< CVMKalmanFilter > childlessKFs = new HashSet<>( kalmanFiltersMap.keySet() ); + + /* + * Find the global (in space) optimum for associating a prediction + * to a measurement. + */ + + orphanSpots = new HashSet<>( measurements ); + if ( !predictions.isEmpty() && !measurements.isEmpty() ) + { + // Only link measurements to predictions if we have predictions. + final JaqamanLinkingCostMatrixCreator< Spot, Spot > crm = new JaqamanLinkingCostMatrixCreator<>( + predictions, + measurements, + costFunction, + maxCost, + ALTERNATIVE_COST_FACTOR, + PERCENTILE ); + final JaqamanLinker< Spot, Spot > linker = new JaqamanLinker<>( crm ); + if ( !linker.checkInput() || !linker.process() ) + { + errorMessage = BASE_ERROR_MSG + "Error linking candidates in frame " + frame + ": " + linker.getErrorMessage(); + return false; + } + final Map< Spot, Spot > agnts = linker.getResult(); + final Map< Spot, Double > costs = linker.getAssignmentCosts(); + // Deal with found links. + for ( final Spot spotty : agnts.keySet() ) + { + final CVMKalmanFilter kf = predictionMap.get( spotty ); + + // Create links for found match. + final Spot source = kalmanFiltersMap.get( kf ); + final Spot target = agnts.get( spotty ); + + graph.addVertex( source ); + graph.addVertex( target ); + final DefaultWeightedEdge edge = graph.addEdge( source, target ); + final double cost = costs.get( spotty ); + graph.setEdgeWeight( edge, cost ); + + // Update Kalman filter + kf.update( toMeasurement( target ) ); + + // Update Kalman track spot + kalmanFiltersMap.put( kf, target ); + + // Remove from orphan set + orphanSpots.remove( target ); + + // Remove from childless KF set + childlessKFs.remove( kf ); + } + } + + /* + * Deal with orphans from the previous frame. (We deal with orphans + * from previous frame only now because we want to link in priority + * target spots to predictions. Nucleating new KF from nearest + * neighbor only comes second. + */ + if ( !previousOrphanSpots.isEmpty() && !orphanSpots.isEmpty() ) + { + + /* + * We now deal with orphans of the previous frame. We try to + * find them a target from the list of spots that are not + * already part of a link created via KF. That is: the orphan + * spots of this frame. + */ + /* + * To deal with fast flows with a prevalent direction, + * copies of the orphans from the previous frame are moved in + * order to search for a target in the next frame, at the + * expected distance. + */ + + Collection translatedOrphanSpots = new ArrayList<>(); + + Map originalToTranslatedSpotMap = new HashMap<>(); + + for (Spot spot : previousOrphanSpots) { + Spot translatedSpot = new Spot(spot); + + // Copy all features + for (String feature : spot.getFeatures().keySet()) { + translatedSpot.putFeature(feature, spot.getFeature(feature)); + } + + // Check if expectedMovement is null and provide default values if it is + double[] movement = expectedMovement != null ? expectedMovement : new double[]{0.0, 0.0, 0.0}; + + translatedSpot.putFeature("POSITION_X", spot.getDoublePosition(0) + movement[0]); + translatedSpot.putFeature("POSITION_Y", spot.getDoublePosition(1) + movement[1]); + translatedSpot.putFeature("POSITION_Z", spot.getDoublePosition(2) + movement[2]); + + translatedOrphanSpots.add(translatedSpot); + + originalToTranslatedSpotMap.put(translatedSpot, spot); // Keep track of original spots + } + + final JaqamanLinkingCostMatrixCreator< Spot, Spot > ic = new JaqamanLinkingCostMatrixCreator<>( + translatedOrphanSpots, + orphanSpots, + nucleatingCostFunction, + maxInitialCost, + ALTERNATIVE_COST_FACTOR, + PERCENTILE ); + final JaqamanLinker< Spot, Spot > newLinker = new JaqamanLinker<>( ic ); + if ( !newLinker.checkInput() || !newLinker.process() ) + { + errorMessage = BASE_ERROR_MSG + "Error linking spots from frame " + ( frame - 1 ) + " to frame " + frame + ": " + newLinker.getErrorMessage(); + return false; + } + final Map< Spot, Spot > newAssignments = newLinker.getResult(); + final Map< Spot, Double > assignmentCosts = newLinker.getAssignmentCosts(); + + // Build links and new KFs from these links. + for ( final Spot translatedSource : newAssignments.keySet() ) + { + final Spot target = newAssignments.get( translatedSource ); + final Spot originalSource = originalToTranslatedSpotMap.get( translatedSource ); + + // Remove from orphan collection. + orphanSpots.remove( target ); + + // Derive initial state and create Kalman filter. + final double[] XP = estimateInitialState( originalSource, target ); + final CVMKalmanFilter kt = new CVMKalmanFilter( XP, Double.MIN_NORMAL, positionProcessStd, velocityProcessStd, positionMeasurementStd ); + // We trust the initial state a lot. + + // Store filter and source + kalmanFiltersMap.put( kt, target ); + + // Add edge to the graph. + graph.addVertex( originalSource ); + graph.addVertex( target ); + final DefaultWeightedEdge edge = graph.addEdge( originalSource, target ); + final double cost = assignmentCosts.get( translatedSource ); + graph.setEdgeWeight( edge, cost ); + } + } + previousOrphanSpots = orphanSpots; + + // Deal with childless KFs. + for ( final CVMKalmanFilter kf : childlessKFs ) + { + // Echo we missed a measurement + kf.update( null ); + + /* + * We can bridge a limited number of gaps. If too much, we die. + * If not, we will use predicted state next time. + */ + if ( kf.getNOcclusion() > maxFrameGap ) + kalmanFiltersMap.remove( kf ); + } + + final double progress = ( double ) p / keySet.size(); + logger.setProgress( progress ); + } + + if ( savePredictions ) + predictionsCollection.setVisible( true ); + + final long end = System.currentTimeMillis(); + processingTime = end - start; + + return true; + } + + @Override + public String getErrorMessage() + { + return errorMessage; + } + + /** + * Returns the saved predicted state as a {@link SpotCollection}. + * + * @return the predicted states. + * @see #setSavePredictions(boolean) + */ + public SpotCollection getPredictions() + { + return predictionsCollection; + } + + /** + * Sets whether the tracker saves the predicted states. + * + * @param doSave + * if true, the predicted states will be saved. + * @see #getPredictions() + */ + public void setSavePredictions( final boolean doSave ) + { + this.savePredictions = doSave; + } + + @Override + public void setNumThreads() + {} + + @Override + public void setNumThreads( final int numThreads ) + {} + + @Override + public int getNumThreads() + { + return 1; + } + + @Override + public long getProcessingTime() + { + return processingTime; + } + + @Override + public void setLogger( final Logger logger ) + { + this.logger = logger; + } + + private static final double[] toMeasurement( final Spot spot ) + { + final double[] d = new double[] { + spot.getDoublePosition( 0 ), + spot.getDoublePosition( 1 ), + spot.getDoublePosition( 2 ) + }; + return d; + } + + private static final double[] estimateInitialState( final Spot first, final Spot second ) + { + final double[] xp = new double[] { + second.getDoublePosition( 0 ), + second.getDoublePosition( 1 ), + second.getDoublePosition( 2 ), + second.diffTo( first, Spot.POSITION_X ), + second.diffTo( first, Spot.POSITION_Y ), + second.diffTo( first, Spot.POSITION_Z ) + }; + return xp; + } + + private static final List< Spot > generateSpotList( final SpotCollection spots, final int frame ) + { + final List< Spot > list = new ArrayList<>( spots.getNSpots( frame, true ) ); + for ( final Iterator< Spot > iterator = spots.iterator( frame, true ); iterator.hasNext(); ) + list.add( iterator.next() ); + + return list; + } + + /** + * Creates a suitable cost function. + * + * @param featurePenalties + * feature penalties to base costs on. Can be null. + * @return a new {@link CostFunction} + */ + protected CostFunction< Spot, Spot > getCostFunction( final Map< String, Double > featurePenalties ) + { + if ( null == featurePenalties || featurePenalties.isEmpty() ) + return new SquareDistCostFunction(); + + return new FeaturePenaltyCostFunction( featurePenalties ); + } + + // --- org.scijava.Cancelable methods --- + + @Override + public boolean isCanceled() + { + return isCanceled; + } + + @Override + public void cancel( final String reason ) + { + isCanceled = true; + cancelReason = reason; + } + + @Override + public String getCancelReason() + { + return cancelReason; + } +} diff --git a/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFFFactory.java b/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFFFactory.java new file mode 100644 index 000000000..03110f393 --- /dev/null +++ b/src/main/java/fiji/plugin/trackmate/tracking/kalman/KalmanTrackerFFFactory.java @@ -0,0 +1,244 @@ +/*- + * #%L + * TrackMate: your buddy for everyday tracking. + * %% + * Copyright (C) 2010 - 2024 TrackMate developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +package fiji.plugin.trackmate.tracking.kalman; + +import static fiji.plugin.trackmate.io.IOUtils.readDoubleAttribute; +import static fiji.plugin.trackmate.io.IOUtils.readIntegerAttribute; +import static fiji.plugin.trackmate.io.IOUtils.writeAttribute; +import static fiji.plugin.trackmate.tracking.TrackerKeys.DEFAULT_GAP_CLOSING_MAX_FRAME_GAP; +import static fiji.plugin.trackmate.tracking.TrackerKeys.DEFAULT_LINKING_MAX_DISTANCE; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_GAP_CLOSING_MAX_FRAME_GAP; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_LINKING_MAX_DISTANCE; +import static fiji.plugin.trackmate.util.TMUtils.checkParameter; + +import java.util.HashMap; +import java.util.Map; + +import javax.swing.ImageIcon; + +import org.jdom2.Element; +import org.scijava.plugin.Plugin; + +import fiji.plugin.trackmate.Model; +import fiji.plugin.trackmate.SpotCollection; +import fiji.plugin.trackmate.gui.components.ConfigurationPanel; +import fiji.plugin.trackmate.gui.components.tracker.KalmanTrackerFFConfigPanel; +import fiji.plugin.trackmate.tracking.SpotTracker; +import fiji.plugin.trackmate.tracking.SpotTrackerFactory; +import static fiji.plugin.trackmate.tracking.TrackerKeys.DEFAULT_KALMAN_SEARCH_RADIUS; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_KALMAN_SEARCH_RADIUS; +import static fiji.plugin.trackmate.tracking.TrackerKeys.DEFAULT_EXPECTED_MOVEMENT; +import static fiji.plugin.trackmate.tracking.TrackerKeys.KEY_EXPECTED_MOVEMENT; + +@Plugin( type = SpotTrackerFactory.class ) +public class KalmanTrackerFFFactory implements SpotTrackerFactory +{ + + private static final String INFO_TEXT_PART2 = "This tracker needs three parameters (on top of the maximal frame gap tolerated): " + + "
" + + "\t - the max search radius defines how far from a predicted position it should look " + + "for candidate spots;
" + + "\t - the initial search radius defines how far two spots can be apart when initiating " + + "a new track." + + "\t - the expected movement defines how far a spot is expected to move when initiating " + + "a new track in X, Y, and Z directions.
" + + "
"; + + private static final String INFO_TEXT = "" + + "This tracker is best suited for objects that " + + "move with a roughly constant velocity vector." + + "

" + + "It relies on the Kalman filter to predict the next most likely position of a spot. " + + "The predictions for all current tracks are linked to the spots actually " + + "found in the next frame, thanks to the LAP framework already present in the LAP tracker. " + + "Predictions are continuously refined and the tracker can accommodate moderate " + + "velocity direction and magnitude changes. " + + "

" + + "This tracker can bridge gaps: If a spot is not found close enough to a prediction, " + + "then the Kalman filter will make another prediction in the next frame and re-iterate " + + "the search. " + + "

" + + "The first frames of a track are critical for this tracker to work properly: Tracks" + + "are initiated by looking for close neighbors (again via the LAP tracker). " + + "Spurious spots in the beginning of each track can confuse the tracker. " + + "The user can specify the expected initial movement vector to compensate for " + + "fast flow, where spots move a considerable amount in a prevalent direction." + //+ "\t Modified by Lorenzo Pedrolli, 2024" + + "

" + + INFO_TEXT_PART2; + + public static final String KEY = "KALMAN_TRACKER_FAST_FLOW"; + + public static final String NAME = "Kalman tracker - Fast Flow"; + + private String errorMessage; + + @Override + public String getInfoText() + { + return INFO_TEXT; + } + + @Override + public ImageIcon getIcon() + { + return null; + } + + @Override + public String getKey() + { + return KEY; + } + + @Override + public String getName() + { + return NAME; + } + + @Override + public SpotTracker create( final SpotCollection spots, final Map< String, Object > settings ) + { + final double maxSearchRadius = ( Double ) settings.get( KEY_KALMAN_SEARCH_RADIUS ); + final int maxFrameGap = ( Integer ) settings.get( KEY_GAP_CLOSING_MAX_FRAME_GAP ); + final double initialSearchRadius = ( Double ) settings.get( KEY_LINKING_MAX_DISTANCE ); + final double[] expectedMovement = (double[]) settings.get(KEY_EXPECTED_MOVEMENT); + return new KalmanTrackerFF( spots, maxSearchRadius, maxFrameGap, initialSearchRadius, null, expectedMovement ); + } + + @Override + public ConfigurationPanel getTrackerConfigurationPanel( final Model model ) + { + final String spaceUnits = model.getSpaceUnits(); + return new KalmanTrackerFFConfigPanel( getName(), "" + INFO_TEXT_PART2, spaceUnits ); + } + + @Override + public boolean marshall( final Map< String, Object > settings, final Element element ) + { + boolean ok = true; + final StringBuilder str = new StringBuilder(); + + ok = ok & writeAttribute( settings, element, KEY_LINKING_MAX_DISTANCE, Double.class, str ); + ok = ok & writeAttribute( settings, element, KEY_KALMAN_SEARCH_RADIUS, Double.class, str ); + ok = ok & writeAttribute( settings, element, KEY_GAP_CLOSING_MAX_FRAME_GAP, Integer.class, str ); + ok = ok & writeAttribute( settings, element, KEY_EXPECTED_MOVEMENT, double[].class, str ); + return ok; + } + + @Override + public boolean unmarshall( final Element element, final Map< String, Object > settings ) + { + settings.clear(); + final StringBuilder errorHolder = new StringBuilder(); + boolean ok = true; + + ok = ok & readDoubleAttribute( element, settings, KEY_LINKING_MAX_DISTANCE, errorHolder ); + ok = ok & readDoubleAttribute( element, settings, KEY_KALMAN_SEARCH_RADIUS, errorHolder ); + ok = ok & readIntegerAttribute( element, settings, KEY_GAP_CLOSING_MAX_FRAME_GAP, errorHolder ); + ok = ok & readDoubleArrayAttribute( element, settings, KEY_EXPECTED_MOVEMENT, errorHolder ); + return ok; + } + + @Override + public String toString( final Map< String, Object > settings ) + { + if ( !checkSettingsValidity( settings ) ) { return errorMessage; } + + final double maxSearchRadius = ( Double ) settings.get( KEY_KALMAN_SEARCH_RADIUS ); + final int maxFrameGap = ( Integer ) settings.get( KEY_GAP_CLOSING_MAX_FRAME_GAP ); + final double initialSearchRadius = ( Double ) settings.get( KEY_LINKING_MAX_DISTANCE ); + final double[] expectedMovement = (double[]) settings.get(KEY_EXPECTED_MOVEMENT); + final StringBuilder str = new StringBuilder(); + + str.append( String.format( " - initial search radius: %.1f\n", initialSearchRadius)); + str.append( String.format( " - max search radius: %.1f\n", maxSearchRadius ) ); + str.append( String.format( " - max frame gap: %d\n", maxFrameGap ) ); + str.append( String.format( " - expected movement: [%.1f;%.1f;%.1f]\n", expectedMovement[0], expectedMovement[1], expectedMovement[2])); + + return str.toString(); + } + + @Override + public Map< String, Object > getDefaultSettings() + { + final Map< String, Object > sm = new HashMap<>( 3 ); + sm.put( KEY_KALMAN_SEARCH_RADIUS, DEFAULT_KALMAN_SEARCH_RADIUS ); + sm.put( KEY_LINKING_MAX_DISTANCE, DEFAULT_LINKING_MAX_DISTANCE ); + sm.put( KEY_GAP_CLOSING_MAX_FRAME_GAP, DEFAULT_GAP_CLOSING_MAX_FRAME_GAP ); + sm.put( KEY_EXPECTED_MOVEMENT, DEFAULT_EXPECTED_MOVEMENT ); + return sm; + } + + @Override + public boolean checkSettingsValidity( final Map< String, Object > settings ) + { + if ( null == settings ) + { + errorMessage = "Settings map is null.\n"; + return false; + } + + boolean ok = true; + final StringBuilder str = new StringBuilder(); + + ok = ok & checkParameter( settings, KEY_LINKING_MAX_DISTANCE, Double.class, str ); + ok = ok & checkParameter( settings, KEY_KALMAN_SEARCH_RADIUS, Double.class, str ); + ok = ok & checkParameter( settings, KEY_GAP_CLOSING_MAX_FRAME_GAP, Integer.class, str ); + ok = ok & checkParameter( settings, KEY_EXPECTED_MOVEMENT, double[].class, str); + + if ( !ok ) + { + errorMessage = str.toString(); + } + return ok; + } + + @Override + public String getErrorMessage() + { + return errorMessage; + } + + @Override + public KalmanTrackerFFFactory copy() + { + return new KalmanTrackerFFFactory(); + } + + private boolean readDoubleArrayAttribute(Element element, Map settings, String key, StringBuilder errorHolder) { + try { + String str = element.getAttributeValue(key); + String[] values = str.split(";"); + double[] array = new double[values.length]; + for (int i = 0; i < values.length; i++) { + array[i] = Double.parseDouble(values[i]); + } + settings.put(key, array); + return true; + } catch (Exception e) { + errorHolder.append( "Could not read double array for key " + key + "\n" ); + return false; + } + } +}