Skip to content

scaffold for sub-pixel drift correction for time series#1493

Open
Yujin-Bao wants to merge 7 commits into
python-microscopy:masterfrom
Yujin-Bao:subpixel_drift_correct
Open

scaffold for sub-pixel drift correction for time series#1493
Yujin-Bao wants to merge 7 commits into
python-microscopy:masterfrom
Yujin-Bao:subpixel_drift_correct

Conversation

@Yujin-Bao

Copy link
Copy Markdown
Contributor

Is this a bugfix or an enhancement?
Enhancement

Proposed changes:
Sub-pixel drift correction for time series. This is a work in progress, currently only showing a scaffold of the idea. It runs but: the result is problematic; the speed is slow; the code is system-specific for the microscope I am building. Would like to see if there would be any ideas.
@David-Baddeley

Checklist:
The below is a list of things what will be considered when reviewing PRs. It is not prescriptive, and does not
imply that PRs which touch any of these will be rejected but gives a rough indication of where there is a potential
for hangups (i.e. factors which could turn a 5 min review into a half hour or longer and shunt it to the bottom
of the TODO list).

  • [Yes] Does the PR avoid variable renaming in existing code, whitespace changes, and other forms of tidying? [There is a place for code tidying, but it makes reviewing
    much simpler if this is kept separate from functional changes. The auto-formatting performed by some editors is particulaly egregious and can lead to files with thousands
    of non-functional changes with a few functional changes scattered amoungst them]

If an enhancement (or non-trivial bugfix):

  • [Yes] Has this been discussed in advance (feature request, PR proposal, email, or direct conversation)?
  • [Yes] Does this change how users interact with the software? How will these changes be communicated? This function can be used in PYMEImage by clicking the drop down menu Modules-->drift_correct and then clicking the drop down menu Processing-->Sub-pixel Drift Correction. The drift corrected image will be shown in a new window.
  • [Yes] Does this maintain backwards compatibility with old data?
  • [No] Does this change the required dependencies?
  • [No] Are there any other side effects of the change?

@David-Baddeley

Copy link
Copy Markdown
Contributor

Hi Yujin,

a few ideas:

  • I don't think you need to do the correction in 3D. The focus lock should be keeping it stable in z, so you could probably just do the correction in x and y. This should improve memory usage and performance.
  • Sorry I didn't mention it earlier, but there is also ndimage.shift() which might be easier / less overhead than .map_coordinates()
  • It would be really good to avoid duplication on the event parsing. I think we should either use pipeline.processEvents() or (at a pinch) just copy lines 259 ad 261 from pipeline.processEvents(). There is no reasontime_to_frames() shouldn't work in single-shot acquisition mode (although we are reliant on having the events from the OIDIC process to get frame timings, so we can't just copy the events from the SMLM process and call it done - this should shake out soon with the spooling refactor).
  • We can safely ignore the image origin when doing drift correction (no need to add it to the coordinates)
  • Ideally we'd implement as a recipe module rather than PYMEImage plugin.
  • I'm not sure on this, but it might make sense to break into 2 modules - one to get the shift, and one to do the correction (for easier re-use if, e.g. someone wanted to correct the shift from fiducials). We can always get something working then come back to this.

If we are not doing 3D correction, it might be elegant to implement as a DataSource similar to BGSDataSource.XYZTCBgsSource e.g.

class XYZTCDriftCorrectSource(XYZTCDataSource):
    moduleName = 'DriftCorrectDataSource'
    
    def __init__(self, datasource, x_mapping, y_mapping, x_scale=1.0, y_scale=1.0):
        if (not isinstance(datasource, XYZTCDataSource)) and (not datasource.ndim == 5) :
            datasource = XYZTCWrapper.auto_promote(datasource)
        
        self._datasource = datasource
        size_z, size_t, size_c = datasource.shape[2:]

        self._x_map = x_mapping # a piecewise mapping object
        self._x_scale = x_scale #allows conversion between different camera pixel units. TODO - make it an affine transformation matrix to allow for rotation as well.
        self._y_map = y_mapping
        self._y_scale = y_scale
        
        XYZTCDataSource.__init__(self, input_order=datasource._input_order, size_z=size_z, size_t=size_t, size_c=size_c)
    
    def getSlice(self, ind):
        sl = self._datasource.getSlice(ind)
        return ndimage.shift(sl, [self._x_scale*self._x_map(ind), self._y_scale*self._y_map(ind)]) 
    
   # proxy original data source attributes 
    def __getattr__(self, item):
        return getattr(self._datasource, item)
        
    def getSliceShape(self):
        return self._datasource.getSliceShape()

    def getNumSlices(self):
        return self._datasource.getNumSlices()

    def getEvents(self):
        return self._datasource.getEvents()

    @property
    def is_complete(self):
        return self._datasource.is_complete()

The advantages of this method is that it should work regardless of the dimensionality and dimension order of the input (i.e. it should work for z stacks and time sequences etc ... without any futzing around). The potential caveat is that it is lazily computed (i.e. computed on access). This means that it will return pretty much instantly, but there will be a lag when each frame is accessed - depending on how fast ndimage.shift is this could either be a good or a bad thing. If lazy computation is not desired, the computation can easily be forced by doing, e.g.

ds = XYZTCDriftCorrectSource(...)
ImageStack(ds.data_xyztc[:,:,:,:,:], mdh=ds.mdh)

rather than

ImageStack(XYZTCDriftCorrectSource(...))

Comment thread PYME/DSView/modules/drift_correct.py Outdated
(Ynm + ShiftMeasure_positions[idx][1]*vy - y0)/vy,
(Znm + ShiftMeasure_positions[idx][2]*1000 - z0)/vz],
mode='nearest', order=3)
drift_corrected.append(corrected)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than making a list, it would be more efficient to pre-allocate an output array and write into that.

Comment thread PYME/DSView/modules/drift_correct.py Outdated
drift_corrected = np.asarray(drift_corrected)
drift_corrected.shape = (self.image.data_xyztc.shape[0], self.image.data_xyztc.shape[1], self.image.data_xyztc.shape[2], idx, 1)
#print(drift_corrected.shape)
im = BaseDataSource.XYZTCWrapper(ArrayDataSource.ArrayDataSource(drift_corrected), 'XYZTC', self.image.data_xyztc.shape[2], drift_corrected.shape[3], 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an anti-pattern - in any new code we should be using 5 dimensional datasources throughout, and not using the wrappers. They exist solely for backwards compatibility. Go straight for ArrayDataSource.XYZTCArrayDataSource, or just pass the 5d np array to ImageStack

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now see where this was copied from - the MemoryBackend. For a bit of context, the usage in MemoryBackend is deliberate as raw data comes from the camera as essentially XYT and anything else needs to be put over the top of this as an interpretation. The goal is that analysis code does not need to worry about what order data comes in or the low level stuff - i.e. memory backend is the only place we should be doing this.

@csoeller

Copy link
Copy Markdown
Contributor

You guys will probably call it nitpicking but I would vote against any print() calls in production code. While ok for development we should stick to the logging facility for anything to be kept in PYME proper and issue these at the desired log level.

@David-Baddeley

David-Baddeley commented Mar 25, 2024

Copy link
Copy Markdown
Contributor

@csoeller - definitely a good idea to limit / phase out print()s, but think we're a bit early to be worrying about that on this one. I'm probably the chief offender for letting them slip into production, so might have to think about an automatic test for them ... maybe something along the lines of:

git diff upstream/master -G '^\s*print\(' -U0 --exit-code

although that one will pick up both additions and deletions ...

@David-Baddeley

Copy link
Copy Markdown
Contributor

@csoeller - maybe git diff upstream/master -G '^\s*print\(' -U0 | grep -e '^+\s*printf(' to just pickup additions ...

@csoeller

Copy link
Copy Markdown
Contributor

No worries re print etc. More generally in relation to this PR, why would one want to drift correct the source images. Presumably this is not for images eventually intended for SMLM?

@David-Baddeley

Copy link
Copy Markdown
Contributor

The application here is a bit complicated, but essentially boils down to taking simultaneous (or interleaved) SMLM and OIDIC images, and correcting the non SMLM channel. In principle it could also be used for widefield fluorescence / SIM / etc ...

@Yujin-Bao

Copy link
Copy Markdown
Contributor Author

Thanks @David-Baddeley @csoeller for the suggestions! It is definitely necessary to limit print()s. Sorry for my knowledge limitation at coding, but this is the easiest way to help me with debugging. Now I remove the print()s and the code has been modified as suggested. A DataSource has been created and it has been implemented as a recipe module. Please let me know if you have any further thoughts.

@Yujin-Bao

Copy link
Copy Markdown
Contributor Author

Hi David, since my fluorescence channel has scaling and rotation relative to the OI-DIC (also the drift tracking) channel, I modified the code to generalize the correction using an affine transformation as suggested in XYZTCDriftCorrectSource by you. I separate the x and y pixel sizes in case they are different, while I am not sure if this is worthwhile since such case may be rare. Please let me know if the variable settings are reasonable. Thanks!

Comment thread PYME/LMVis/pipeline.py
# self.eventCharts = eventCharts
# self.ev_mappings = ev_mappings

if b'PYME2ShiftMeasure' in evKeyNames:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if b'PYME2ShiftMeasure' in evKeyNames:
elif b'PYME2ShiftMeasure' in evKeyNames:
# Standard ShiftMeasure events should have priority (i.e. this should only execute if we haven't found any standard ShiftMeasure events
# TODO - find a way to remove the need for this special case / event type.

"""

input = Input('input')
drift_tracking_channel_pixel_size_x = Float(64.5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In many cases the drift-tracking camera will be flipped as well as rotated with respect to the imaging camera. The general case (as is used for point-data) is to specify an affine transformation matrix, rather than pixel sizes and rotations, although that is arguably a bit less user-friendly.

I'd need to double check, but it's likely that the necessary transformation can be spoofed with the pixel-size and angle if we allow negative pixel sizes. If this is the case we need to think about whether we want to be consistent with the point data, or whether the pixel size and angle specification is sufficiently more user friendly to make it a better choice.

x1 = x0*self._xx_scale*np.cos(self._theta) + y0*self._yx_scale*np.sin(self._theta)
y1 = y0*self._yy_scale*np.cos(self._theta) - x0*self._xy_scale*np.sin(self._theta)
#return ndimage.shift(sl, [-self._x_scale*self._x_map(np.array([ind+1])), -self._y_scale*self._y_map(np.array([ind+1]))], order=3, mode='nearest')
return ndimage.shift(sl, [-x1, -y1], order=3, mode='nearest')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe compute the affine transformation matrix in __init__ and just apply here?? Also, we need to specify a rotation origin as well as pixel sizes and rotation.

The affine transformation should really be a 3x2 (2x3?) matrix .... applied to [x, y, 1]

@David-Baddeley

Copy link
Copy Markdown
Contributor

Hi @Yujin-Bao - sorry it's taken so long to have a good look at this. Main potential issues (as mentioned in comments on the code) are as follows:

  • Need to support flipping between cameras, and a rotation origin (i.e. full affine transform)
  • Need to look at interface consistency with drift correction of point data (this may not be breaking)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants