From 5931e0b244cb68d2aa52fec63bc8aae9f8e78d6f Mon Sep 17 00:00:00 2001 From: Ian Dobbie Date: Tue, 18 Jul 2023 22:24:41 -0400 Subject: [PATCH] Squashed commit of the following: Interactove ROI from Issue #837 added dialog to apply ROI to other active cameras ROI selection now not square, but can be forced with shift-drag Added UPDATE_ROI event and changed imageviewer and mosaics to use it keep ROI bounding box in camera view until a new image is snapped force a moasic update after an roi change so moasics have the correct image size on them Allow setting roi interactively --- cockpit/devices/microscopeCamera.py | 26 ++++- cockpit/events.py | 4 + cockpit/gui/imageViewer/viewCanvas.py | 147 ++++++++++++++++++++++++-- cockpit/gui/mainWindow.py | 38 ++++++- cockpit/gui/mosaic/window.py | 41 +++++-- cockpit/handlers/camera.py | 16 +++ 6 files changed, 249 insertions(+), 23 deletions(-) diff --git a/cockpit/devices/microscopeCamera.py b/cockpit/devices/microscopeCamera.py index ef084ed3..2a99b63e 100644 --- a/cockpit/devices/microscopeCamera.py +++ b/cockpit/devices/microscopeCamera.py @@ -217,6 +217,9 @@ def getHandlers(self): 'getExposureTime': self.getExposureTime, 'setExposureTime': self.setExposureTime, 'getSavefileInfo': self.getSavefileInfo, + 'setROI': self.setROI, + 'getROI': self.getROI, + 'getSensorShape': self.getSensorShape, 'makeUI': self.makeUI, 'softTrigger': self.softTrigger}, cockpit.handlers.camera.TRIGGER_SOFT, @@ -269,16 +272,25 @@ def getExposureTime(self, name=None, isExact=False): def getImageSize(self, name): """Read the image size from the camera.""" - roi = self._proxy.get_roi() # left, bottom, right, top - if not isinstance(roi, ROI): - cockpit.util.logger.log.warning("%s returned tuple not ROI()" % self.name) - roi = ROI(*roi) + roi = self.getROI(name) binning = self._proxy.get_binning() if not isinstance(binning, Binning): cockpit.util.logger.log.warning("%s returned tuple not Binning()" % self.name) binning = Binning(*binning) return (roi.width//binning.h, roi.height//binning.v) + def getROI(self, name): + """Read the ROI from the camera""" + roi = self._proxy.get_roi() + if not isinstance(roi, ROI): + cockpit.util.logger.log.warning("%s returned tuple not ROI()" % self.name) + roi = ROI(*roi) + return roi + + def getSensorShape(self, name): + """Read the sensor shape from the camera""" + sensor_shape = self._proxy.get_sensor_shape() + return sensor_shape def getSavefileInfo(self, name): """Return an info string describing the measurement.""" @@ -329,6 +341,12 @@ def setExposureTime(self, name, exposureTime): self._proxy.set_exposure_time(exposureTime / 1000.0) + def setROI(self, name, roi): + result = self._proxy.set_roi(roi) + + if not result: + cockpit.util.logger.log.warning("%s could not set ROI" % self.name) + def softTrigger(self, name=None): if self.enabled: self._proxy.soft_trigger() diff --git a/cockpit/events.py b/cockpit/events.py index 2596ae2b..8e985a0d 100644 --- a/cockpit/events.py +++ b/cockpit/events.py @@ -71,6 +71,9 @@ ``CAMERA_ENABLE`` +``UPDATE_ROI`` +A camera has updated its ROI which might change other displays. + ``CLEANUP_AFTER_EXPERIMENT`` ``DEVICE_STATUS`` @@ -185,6 +188,7 @@ LIGHT_SOURCE_ENABLE = 'light source enable' LIGHT_EXPOSURE_UPDATE = 'light exposure update' CAMERA_ENABLE = 'camera enable' +UPDATE_ROI = 'update roi' FILTER_CHANGE = 'filter change' STAGE_POSITION = 'stage position' STAGE_MOVER = 'stage mover' diff --git a/cockpit/gui/imageViewer/viewCanvas.py b/cockpit/gui/imageViewer/viewCanvas.py index dfbe6393..ccbd37de 100644 --- a/cockpit/gui/imageViewer/viewCanvas.py +++ b/cockpit/gui/imageViewer/viewCanvas.py @@ -78,7 +78,7 @@ HISTOGRAM_HEIGHT = 40 ## Drag modes -(DRAG_NONE, DRAG_CANVAS, DRAG_BLACKPOINT, DRAG_WHITEPOINT) = range(4) +(DRAG_NONE, DRAG_CANVAS, DRAG_BLACKPOINT, DRAG_WHITEPOINT, DRAG_ROI) = range(5) class BaseGL(): @@ -467,6 +467,14 @@ def __init__(self, parent, *args, **kwargs): self.panX = 0 self.panY = 0 + ## ROI + self.roi = None # Current roi + self.roi_drag = None # ROI currently being defined + self.definingROI = False # Are we defining the ROI + self.definedROI = False # New ROI defined but not grabbed. + self.shift_down = False #shift key drags a square ROI + + ## What kind of dragging we're doing. self.dragMode = DRAG_NONE @@ -485,12 +493,17 @@ def __init__(self, parent, *args, **kwargs): self.Bind(wx.EVT_MOUSE_EVENTS, self.onMouse) self.Bind(wx.EVT_MOUSEWHEEL, self.onMouseWheel) self.Bind(wx.EVT_DPI_CHANGED, self.onDPIchange) + # Right click also creates context menu event, which will pass up # if unhandled. Bind it to None to prevent the main window # context menu being displayed after our own. self.Bind(wx.EVT_CONTEXT_MENU, lambda event: None) self.painting = False + #shift key to make ROI square requirse knowing if the key is down + self.Bind(wx.EVT_KEY_DOWN, self.onKeyDown) + self.Bind(wx.EVT_KEY_UP, self.onKeyUp) + # Initialise FFT variables self.showFFT = False @@ -554,6 +567,7 @@ def clear(self, shouldDestroy = False): # actually display the image. def setImage(self, newImage): self.imageQueue.put_nowait(newImage) + self.definedROI = False # new image will have new ROI. ## Consume images out of self.imageQueue and either display them or @@ -633,8 +647,10 @@ def onPaint(self, event): self.image.draw(pan=(self.panX, self.panY), zoom=self.zoom) if self.showCrosshair: self.drawCrosshair() - - + if self.definingROI: + self.drawROI() + if self.definedROI: + self.drawROI() glViewport(0, 0, self.w, Hist_Height//2) self.histogram.draw() glColor(0, 1, 0, 1) @@ -674,6 +690,24 @@ def drawCrosshair(self): (self.zoom*self.panX, -1), (self.zoom*self.panX, 1)]) glDrawArrays(GL_LINES, 0, 4) + @cockpit.util.threads.callInMainThread + def drawROI(self): + if self.roi_drag: + glColor3f(255, 0, 0) + + # Get raw gl co-ordinates + v = [self.indicesToGl(self.roi_drag[1], self.roi_drag[0]), + self.indicesToGl(self.roi_drag[1] + self.roi_drag[3], self.roi_drag[0]), + self.indicesToGl(self.roi_drag[1] + self.roi_drag[3], self.roi_drag[0] + self.roi_drag[2]), + self.indicesToGl(self.roi_drag[1], self.roi_drag[0] + self.roi_drag[2]) + ] + + # Correct co-ordinates for zoom and pan + v_zoompan = [((p[0] + self.panX) * self.zoom, (p[1] + self.panY) * self.zoom) for p in v] + + # Draw roi box + glVertexPointerf(v_zoompan) + glDrawArrays(GL_LINE_LOOP, 0, 4) ## Update the size of the canvas by scaling it. def setSize(self, size): @@ -695,12 +729,16 @@ def onMouse(self, event): elif event.LeftDown(): # Started dragging self.mouseDragX, self.mouseDragY = self.curMouseX, self.curMouseY + self.mouseLdownX, self.mouseLdownY = self.curMouseX, self.curMouseY blackPointX = 0.5 * (1+self.histogram.data2gl(self.histogram.lthresh)) * self.w whitePointX = 0.5 * (1+self.histogram.data2gl(self.histogram.uthresh)) * self.w # Set drag mode based on current window position if self.h - self.curMouseY >= (HISTOGRAM_HEIGHT * self.GetContentScaleFactor()* 2): - self.dragMode = DRAG_CANVAS + if self.definingROI: + self.dragMode = DRAG_ROI + else: + self.dragMode = DRAG_CANVAS elif abs(self.curMouseX - blackPointX) < abs(self.curMouseX - whitePointX): self.dragMode = DRAG_BLACKPOINT else: @@ -722,10 +760,60 @@ def onMouse(self, event): else: self.histogram.uthresh = threshold self.image.vmax = threshold + elif self.dragMode == DRAG_ROI: + + if self.shift_down: + #shift is down so force square ROI + # Get co-ordinates in canvas units + coords_x = [self.mouseDragX, self.mouseLdownX] + coords_y = [self.mouseDragY, self.mouseLdownY] + roi_xmin, roi_ymin = min(coords_x), min(coords_y) + roi_xmax, roi_ymax = max(coords_x), max(coords_y) + # Convert to data indices + roi_min_ind = self.canvasToIndices(roi_xmin, roi_ymin) + roi_max_ind = self.canvasToIndices(roi_xmax, roi_ymax) + # Get size of roi + roi_maxsize = max((roi_max_ind[0] - roi_min_ind[0], roi_max_ind[1] - roi_min_ind[1])) + roi_size = (roi_maxsize, roi_maxsize) + # Set roi (left, top, width, height) + self.roi_drag = (roi_min_ind[1], roi_min_ind[0], + roi_size[1], roi_size[0]) + else: + #Shift is up so use real coords + roi_start=[self.mouseLdownY,self.mouseLdownX] + roi_end=[self.mouseDragY-self.mouseLdownY, + self.mouseDragX-self.mouseLdownX] + (roi_xmin_ind, + roi_ymin_ind)=self.canvasToIndices(roi_start[0], + roi_start[1]) + (roi_xmax_ind, + roi_ymax_ind)=self.canvasToIndices(roi_end[0], + roi_end[1]) + + self.roi_drag =(roi_xmin_ind,roi_ymin_ind, + roi_xmax_ind,roi_ymax_ind) + + self.mouseDragX = self.curMouseX self.mouseDragY = self.curMouseY elif event.RightDown(): cockpit.gui.guiUtils.placeMenuAtMouse(self, self._menu) + elif event.LeftUp(): + if self.definingROI: + camera = self.Parent.Parent.curCamera + + # Set ROI in camera, correcting for current roi + if self.roi: + roi = (self.roi[0] + self.roi_drag[0], self.roi[1] + self.roi_drag[1], self.roi_drag[2], self.roi_drag[3]) + else: + roi = self.roi_drag + + camera.setROI(roi) + events.publish(events.UPDATE_ROI,camera.name) + + self.roi = roi + self.definingROI = False + self.definedROI = True elif event.Entering() and self.TopLevelParent.IsActive(): self.SetFocus() else: @@ -742,6 +830,9 @@ def getMenuActions(self): ('Set histogram parameters', self.onSetHistogram), ('Toggle clip highlighting', self.image.toggleClipHighlight), ('', None), + ('Set ROI', self.onDefineROI), + ('Clear ROI', self.onClearROI), + ('', None), ('Toggle alignment crosshair', self.toggleCrosshair), ('Toggle sync view', self.toggleSyncViews), ("Toggle FFT mode", self.toggleFFT), @@ -774,6 +865,34 @@ def toggleSyncViews(self, event=None): self.setView, ) + def onDefineROI(self, event = None): + self.roi_drag = None + self.definingROI = True + + def onClearROI(self, event = None): + camera = self.Parent.Parent.curCamera + sensor_shape = camera.getSensorShape() + roi = (0,0,sensor_shape[0], sensor_shape[1]) + camera.setROI(roi) + self.roi = roi + self.definedROI = False + events.publish(events.UPDATE_ROI,camera.name) + self.Refresh() + + def onKeyDown(self, event): + keycode = event.GetKeyCode() + if keycode == wx.WXK_SHIFT: + self.shift_down = True + event.Skip() + self.Refresh() + + def onKeyUp(self, event): + keycode = event.GetKeyCode() + if keycode == wx.WXK_SHIFT: + self.shift_down = False + event.Skip() + self.Refresh() + def toggleCrosshair(self, event=None): self.showCrosshair = not(self.showCrosshair) @@ -799,10 +918,22 @@ def canvasToGl(self, x, y): ## Convert gl co-ordinates to indices into the data. # Note: pass in x,y, but returns row-major datay, datax def glToIndices(self, glx, gly): - datax = (1 + glx) * self.imageShape[1] // 2 - datay = self.imageShape[0]-((1 + gly) * self.imageShape[0] // 2) - return (datay, datax) - + # Vertical and horizontal modifiers for non-square images. + hlim = self.imageShape[1] / max(self.imageShape) + vlim = self.imageShape[0] / max(self.imageShape) + datax = int((1 + glx / hlim) * self.imageShape[1] // 2) + datay = int(self.imageShape[0]-((1 + gly / vlim) * self.imageShape[0] // 2)) + datax_clamped = max(0, min(datax, self.imageShape[1])) + datay_clamped = max(0, min(datay, self.imageShape[0])) + return (datay_clamped, datax_clamped) + + ## Convert data indices to gl co-ordinates. + # Note: pass in row-major datay, datax, but returns x,y + def indicesToGl(self, datay, datax): + glx = 2 / self.imageShape[1] * datax - 1 + gly = 2 / self.imageShape[0] * (self.imageShape[0] - datay) - 1 + + return(glx, gly) ## Convert window co-ordinates to indices into the data. def canvasToIndices(self, x, y): diff --git a/cockpit/gui/mainWindow.py b/cockpit/gui/mainWindow.py index 14268e80..00b76d05 100644 --- a/cockpit/gui/mainWindow.py +++ b/cockpit/gui/mainWindow.py @@ -205,7 +205,43 @@ def __init__(self, *args, **kwargs): self.SetDropTarget(viewFileDropTarget.ViewFileDropTarget(self)) - + #subscribe to camera UPDATE_ROI events so we can ask to apply it to all + #cameras or not. + self.updateROI = False + events.subscribe(events.UPDATE_ROI, self.updateCamROI) + + #function fired on camera ROI update. It looks to see if there are + #other active cameras and asks if you want to set all cameras to that + #ROI or not. + def updateCamROI(self,camName): + #track if already updating cameras, only need to ask once + if self.updateROI: + return + self.updateROI=True + asked = False + update = False + active= depot.getHandlerWithName(camName) + cameras = sorted(depot.getHandlersOfType(depot.CAMERA), + key=lambda c: c.name) + roi=active.getROI() + for camera in cameras: + if camera.isEnabled and (camera.name is not camName): + #another active camera camera so ask. + title = "Apply to other Cameras?" + msg = ( + " You have added a new ROI to camera '%s'." + " Do you wish to apply the same ROI to all " + " other cameras?" % (camName) + ) + if not asked: + asked = True + if cockpit.gui.guiUtils.getUserPermission(msg, title): + #update ROI on this camera. + update = True + if update: + camera.setROI(roi) + self.updateROI=False + ## User clicked the "view last file" button; open the last experiment's # file in an image viewer. A bit tricky when there's multiple files # generated due to the splitting logic. We just view the first one in diff --git a/cockpit/gui/mosaic/window.py b/cockpit/gui/mosaic/window.py index 44158f8b..a7478096 100644 --- a/cockpit/gui/mosaic/window.py +++ b/cockpit/gui/mosaic/window.py @@ -485,7 +485,9 @@ def __init__(self, *args, **kwargs): events.subscribe(events.STAGE_POSITION, self.onAxisRefresh) events.subscribe(events.SOFT_SAFETY_LIMIT, self.onAxisRefresh) - + events.subscribe(events.MOSAIC_UPDATE, self.mosaicUpdate) + events.subscribe(events.UPDATE_ROI,self.updateROI) + abort_emitter = cockpit.gui.EvtEmitter(self, events.USER_ABORT) abort_emitter.Bind(cockpit.gui.EVT_COCKPIT, self.onAbort) @@ -553,7 +555,8 @@ def _OnObjectiveChanged(self, event: wx.CommandEvent) -> None: self.crosshairBoxSize = 512 * wx.GetApp().Objectives.GetPixelSize() self.offset = wx.GetApp().Objectives.GetOffset() #force a redraw so that the crosshairs are properly sized - self.Refresh() + # Refresh this and other mosaic views. + events.publish(events.MOSAIC_UPDATE) event.Skip() @@ -847,7 +850,8 @@ def transferCameraImage(self): z-self.offset[2]), (width, height), scalings = cockpit.gui.camera.window.getCameraScaling(camera)) - self.Refresh() + # Refresh this and other mosaic views. + events.publish(events.MOSAIC_UPDATE) def togglescalebar(self): #toggle the scale bar between 0 and 1. @@ -857,22 +861,34 @@ def togglescalebar(self): self.scalebar = 1 #store current state for future. cockpit.util.userConfig.setValue('mosaicScaleBar',self.scalebar) - self.Refresh() + #send a mosaic update event to update touchscreen + events.publish(events.MOSAIC_UPDATE) def toggleDisplayTrails(self): #toggle Display of trails in mosaic. self.displayTrails = not self.displayTrails - #send a mosaic update event to update touchscreen - events.publish(events.MOSAIC_UPDATE) #store current state for future. cockpit.util.userConfig.setValue('mosaicDisplayTrails',self.displayTrails) - self.Refresh() + #send a mosaic update event to update touchscreen + events.publish(events.MOSAIC_UPDATE) + def clearTrails(self): #clear all exisiting trails self.trails=[] events.publish(events.MOSAIC_UPDATE) + + + def mosaicUpdate(self): self.Refresh() + + def updateROI(self,cameraname): + #camera roi has updated so check if we are using this camera and + #if so update imaging region + if self.camera is not None: + if cameraname == self.camera.name : + #publish event toi update this mosaic and touchscreen + events.publish(events.MOSAIC_UPDATE) ## Save the current stage position as a new site with the specified # color (or our currently-selected color if none is provided). @@ -1013,7 +1029,9 @@ def deleteSelectedSites(self, event = None): ## Deselect everything to work around issue #408 (under gtk, ## deleting items will move the selection to the next item) self.sitesBox.SetSelection(wx.NOT_FOUND) - self.Refresh() + # Refresh this and other mosaic views. + events.publish(events.MOSAIC_UPDATE) + ## Move the selected sites by an offset. @@ -1037,7 +1055,8 @@ def offsetSelectedSites(self, event = None): self.sitesBox.Clear() for site in cockpit.interfaces.stageMover.getAllSites(): self._AddSiteToList(site, shouldRefresh = False) - self.Refresh() + # Refresh this and other mosaic views. + events.publish(events.MOSAIC_UPDATE) ## Save sites to a file. @@ -1077,7 +1096,9 @@ def _AddSiteToList(self, site, shouldRefresh=True): label = '%04d' % label self.sitesBox.Append("%s: %s" % (label, position)) if shouldRefresh: - self.Refresh() + # Refresh this and other mosaic views. + events.publish(events.MOSAIC_UPDATE) + ## A site was deleted; remove it from our sites box. diff --git a/cockpit/handlers/camera.py b/cockpit/handlers/camera.py index 6798f256..2906ccfd 100644 --- a/cockpit/handlers/camera.py +++ b/cockpit/handlers/camera.py @@ -80,6 +80,10 @@ class CameraHandler(deviceHandler.DeviceHandler): # - getExposureTime(name, isExact): Returns the time in milliseconds that # the camera is set to expose for when triggered. If isExact is set, # returns a decimal.Decimal instance. + # - setROI(name, roi): Change the camera's region of interest to + # the specified region. + # - getROI(name): Returns the current region of interest. + # - getSensorShape(name): Returns the camera's sensor shape. # - prepareForExperiment(name, experiment): Get the camera ready for an # experiment. # - Optional: getMinExposureTime(name): returns the minimum exposure time @@ -222,6 +226,18 @@ def setExposureTime(self, time): def getExposureTime(self, isExact = False): return self.callbacks['getExposureTime'](self.name, isExact) + @reset_cache + def setROI(self, roi): + return self.callbacks['setROI'](self.name, roi) + + @cached + def getROI(self): + return self.callbacks['getROI'](self.name) + + @cached + def getSensorShape(self): + return self.callbacks['getSensorShape'](self.name) + ## Do any necessary preparation for the camera to participate in an # experiment. @reset_cache