From e1e8411930372f279d728361e77333c3bcd98e94 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Mon, 16 Dec 2019 22:29:37 -0800 Subject: [PATCH 1/6] ZoomFrame: refactor to simplify state management This widget was overly bloated considering the things that it actually needed to manage. For example, we were storing several different types of width/height, even though the only "important" ones are the widget dimensions and the dimensions of each image. --- ultratrace/__main__.py | 10 +- ultratrace/modules/dicom.py | 10 +- ultratrace/modules/search.py | 2 + ultratrace/widgets/crosshairs.py | 22 +-- ultratrace/widgets/zoom_frame.py | 244 +++++++++++++------------------ 5 files changed, 127 insertions(+), 161 deletions(-) diff --git a/ultratrace/__main__.py b/ultratrace/__main__.py index ff1aba3..ed137e9 100755 --- a/ultratrace/__main__.py +++ b/ultratrace/__main__.py @@ -223,7 +223,7 @@ def afterstartup(self): self.getWinSize() self.alignBottomRight(self.oldwidth-self.leftwidth) if self.Dicom.zframe.image: - self.Dicom.zframe.setImage(self.Dicom.zframe.image) + self.Dicom.zframe.set_image(self.Dicom.zframe.image) def alignBottomLeftWrapper(self, event=None): if self.isResizing: return @@ -246,7 +246,7 @@ def alignBottomLeft(self, event=None): if event == None or event.widget == self: self.alignBottomRight(self.winfo_width() - self.leftwidth) if self.Dicom.zframe.image: - self.Dicom.zframe.setImage(self.Dicom.zframe.image) + self.Dicom.zframe.set_image(self.Dicom.zframe.image) self.isResizing = False def alignBottomRight(self,x): ''' ''' @@ -416,7 +416,7 @@ def onRelease(self,event): #resize dicom image png_loc = self.Data.getPreprocessedDicom(self.frame) image = PIL.Image.open( png_loc ) - self.Dicom.zframe.setImage(image) + self.Dicom.zframe.set_image(image) # x = self.Dicom.zframe.width x = self.winfo_width() - self.LEFT.winfo_width() # y = self.Dicom.zframe.height @@ -455,8 +455,8 @@ def onMotion(self, event): lastClick = self.click thisClick = (event.x, event.y) # enforce minimum distance b/w new crosshairs - dx = abs(thisClick[0] - lastClick[0]) / self.Dicom.zframe.imgscale - dy = abs(thisClick[1] - lastClick[1]) / self.Dicom.zframe.imgscale + dx = abs(thisClick[0] - lastClick[0]) / self.Dicom.zframe.get_zoom_scale() + dy = abs(thisClick[1] - lastClick[1]) / self.Dicom.zframe.get_zoom_scale() if dx > util.CROSSHAIR_DRAG_BUFFER or dy > util.CROSSHAIR_DRAG_BUFFER: self.click = thisClick ch = self.Trace.add( *self.click ) diff --git a/ultratrace/modules/dicom.py b/ultratrace/modules/dicom.py index 8d128a0..6466bd2 100644 --- a/ultratrace/modules/dicom.py +++ b/ultratrace/modules/dicom.py @@ -35,7 +35,7 @@ def __init__(self, app): self.reader = None # zoom frame (contains our tracing canvas) - self.zframe = ZoomFrame(self.app.RIGHT, 1.3, app) + self.zframe = ZoomFrame(self.app.RIGHT, app) # reset zoom button self.zoomResetBtn = Button(self.app.LEFT, text='Reset image', command=self.zoomReset, takefocus=0)#, pady=7 ) @@ -54,9 +54,6 @@ def zoomReset(self, fromButton=False): if self.isLoaded(): # creates a new canvas object and we redraw everything to it self.zframe.resetCanvas() - # self.zframe.canvas.bind('', self.app.onClickZoom ) - # self.zframe.canvas.bind('', self.app.onRelease ) - # self.zframe.canvas.bind('', self.app.onMotion ) # we want to go here only after a button press if fromButton: self.app.framesUpdate() @@ -66,7 +63,7 @@ def update(self, _frame=None): change the image on the zoom frame ''' if self.reader and self.reader.loaded: - self.zframe.setImage(self.reader.getFrame(_frame or self.app.frame)) + self.zframe.set_image(self.reader.getFrame(_frame or self.app.frame)) def load(self, event=None): ''' @@ -163,6 +160,7 @@ def grid(self): self.app.framesNextBtn.grid( row=0, column=3 ) self.zoomResetBtn.grid( row=7 ) self.app.Control.grid() + self.zframe.grid() def grid_remove(self): ''' @@ -175,3 +173,5 @@ def grid_remove(self): self.app.framesNextBtn.grid_remove() self.zoomResetBtn.grid_remove() self.app.Control.grid_remove() + self.zframe.grid_remove() + diff --git a/ultratrace/modules/search.py b/ultratrace/modules/search.py index 20c2275..832ef04 100644 --- a/ultratrace/modules/search.py +++ b/ultratrace/modules/search.py @@ -59,6 +59,8 @@ def loadIntervals(self): tg = self.app.Data.checkFileLevel('.TextGrid', f) if tg: grid = self.app.TextGrid.fromFile(tg) + if grid is None: + return for tier in grid: if TextGrid.isIntervalTier(tier): for el in tier: diff --git a/ultratrace/widgets/crosshairs.py b/ultratrace/widgets/crosshairs.py index b36fb61..78b8d54 100644 --- a/ultratrace/widgets/crosshairs.py +++ b/ultratrace/widgets/crosshairs.py @@ -62,13 +62,13 @@ def transformCoordsToTrue(self, x, y): canvas coords -> absolute coords absolute coords are % along each axis (e.g. center of image = [.5,.5]) ''' - # x = (self.trueX - self.zframe.panX) / self.zframe.imgscale - # y = (self.trueY - self.zframe.panY) / self.zframe.imgscale + # x = (self.trueX - self.zframe.panX) / self.zframe.get_zoom_scale() + # y = (self.trueY - self.zframe.panY) / self.zframe.get_zoom_scale() # return x,y - truex = (x-self.zframe.panX)/(self.zframe.width*self.zframe.imgscale) - truey = (y-self.zframe.panY)/(self.zframe.height*self.zframe.imgscale) - # truex = (x-self.zframe.panX)/self.zframe.imgscale - # truey = (y-self.zframe.panY)/self.zframe.imgscale + truex = (x-self.zframe.panX)/(self.zframe.width*self.zframe.get_zoom_scale()) + truey = (y-self.zframe.panY)/(self.zframe.height*self.zframe.get_zoom_scale()) + # truex = (x-self.zframe.panX)/self.zframe.get_zoom_scale() + # truey = (y-self.zframe.panY)/self.zframe.get_zoom_scale() debug(truex, truey) return truex, truey @@ -77,10 +77,10 @@ def transformTrueToCoords(self, truex, truey): absolute coords -> canvas coords absolute coords are % along each axis (e.g. center of image = [.5,.5]) ''' - # x = (_x * self.zframe.imgscale) + self.zframe.panX - # y = (_y * self.zframe.imgscale) + self.zframe.panY - x = truex * self.zframe.width * self.zframe.imgscale + self.zframe.panX - y = truey * self.zframe.height * self.zframe.imgscale + self.zframe.panY + # x = (_x * self.zframe.get_zoom_scale()) + self.zframe.panX + # y = (_y * self.zframe.get_zoom_scale()) + self.zframe.panY + x = truex * self.zframe.width * self.zframe.get_zoom_scale() + self.zframe.panX + y = truey * self.zframe.height * self.zframe.get_zoom_scale() + self.zframe.panY return x, y def transformCoords(self, x, y): @@ -91,7 +91,7 @@ def transformCoords(self, x, y): def transformLength(self, l): ''' transforms a length by our current zoom-amount ''' - return l * self.zframe.imgscale + return l * self.zframe.get_zoom_scale() def getDistance(self, click): ''' calculates the distance from centerpoint to a click event ''' diff --git a/ultratrace/widgets/zoom_frame.py b/ultratrace/widgets/zoom_frame.py index cbe631f..089bfe4 100644 --- a/ultratrace/widgets/zoom_frame.py +++ b/ultratrace/widgets/zoom_frame.py @@ -1,158 +1,122 @@ -from tkinter import Canvas, Frame -from PIL import ImageTk # pillow +import tkinter as tk +from PIL import ImageTk from .rect_tracker import RectTracker -class ZoomFrame(Frame): - ''' - Wrapper for a Tk Frame() object that includes zooming and panning functionality. - This code is inspired by the answer from https://stackoverflow.com/users/7550928/foo-bar - at https://stackoverflow.com/questions/41656176/tkinter-canvas-zoom-move-pan ... - Could probably be cleaned up and slimmed down - ''' - def __init__(self, master, delta, app): - Frame.__init__(self, master) - self.app = app - self.delta = delta - self.maxZoom = 5 - #self.resetCanvas(master) - - self.canvas_width = 800 - self.width = 0 - self.canvas_height = 600 - self.height = 0 - self.shown = False - self.aspect_ratio = 4.0/3.0 - - self.canvas = Canvas( master, bg='grey', width=self.canvas_width, height=self.canvas_height, highlightthickness=0 ) - self.canvas.grid(row=0, column=0, sticky='news') - self.canvas.update() # do i need - rect = RectTracker(self.canvas) - rect.autodraw(outline='blue') +class ZoomFrame(tk.Frame): + WIDGET_WIDTH = 800 + WIDGET_HEIGHT = 600 + MIN_ZOOM = -5 + MAX_ZOOM = 5 + SCALE_FACTOR = 1.3 + def __init__(self, master, app): - # self.master.rowconfigure(0, weight=1) # do i need - # self.master.columnconfigure(0, weight=1) # do i need + super().__init__(master) - # self.canvas.bind('', self.showImage ) # on canvas resize events - self.canvas.bind('', self.moveFrom ) - self.canvas.bind('', self.moveTo ) - self.canvas.bind('', self.wheel ) # Windows & Linux - self.canvas.bind('', self.wheel ) # Linux scroll up - self.canvas.bind('', self.wheel ) # Linux scroll down + self.app = app - self.resetCanvas() + self.canvas = tk.Canvas( + master, + bg='grey', + width=self.WIDGET_WIDTH, + height=self.WIDGET_HEIGHT, + highlightthickness=0) + self.canvas.update() # tkinter needs this or else it won't render the image - self.canvas.bind('', self.app.onClickZoom ) - self.canvas.bind('', self.app.onMotion ) + self.canvas.bind('', self.pan_start) + self.canvas.bind('', self.pan) + self.canvas.bind('', self.zoom) # Windows / MacOS + self.canvas.bind('', self.zoom_in) # Linux scroll up + self.canvas.bind('', self.zoom_out) # Linux scroll down + self.canvas.bind('', self.app.onClickZoom) + self.canvas.bind('', self.app.onMotion) - self.app.bind('', self.wheel ) - self.app.bind('', self.wheel ) + self.app.bind('', self.zoom_in) + self.app.bind('', self.zoom_out) - def resetCanvas(self): - self.canvas_width = 800 - self.canvas_height = 600 + RectTracker(self.canvas).autodraw(outline='blue') - # self.canvas = Canvas( master, bg='grey', width=self.canvas_width, height=self.canvas_height, highlightthickness=0 ) - # self.canvas.grid(row=0, column=0, sticky='news') - # self.canvas.update() # do i need + self.shown = False - # self.master.rowconfigure(0, weight=1) # do i need - # self.master.columnconfigure(0, weight=1) # do i need - # - # self.canvas.bind('', self.showImage ) # on canvas resize events - # self.canvas.bind('', self.moveFrom ) - # self.canvas.bind('', self.moveTo ) - # self.canvas.bind('', self.wheel ) # Windows & Linux FIXME - # self.canvas.bind('', self.wheel ) # Linux scroll up - # self.canvas.bind('', self.wheel ) # Linux scroll down + self.reset_canvas() - self.origX = self.canvas.xview()[0] - 1 - self.origY = self.canvas.yview()[0] - 150 + def reset_canvas(self): - self.zoom = 0 - self.imgscale = 1.0 self.image = None - self.panStartX = 0 - self.panStartY = 0 - self.panX = 0 - self.panY = 0 - - def resetImageDimensions(self): - self.width = 0 - self.height = 0 - self.aspect_ratio = 1 + self.zoom = 0 + self.pan_start_x = 0 + self.pan_start_y = 0 + self.pan_x = 0 + self.pan_y = 0 - def setImage(self, image): # expect an Image() instance + def set_image(self, image): self.image = image - if self.width == 0: - self.width, self.height = self.image.size - self.aspect_ratio = self.width/self.height - win_width = self.app.RIGHT.winfo_width() - asp_height = round(win_width / self.aspect_ratio) - win_height = self.app.RIGHT.winfo_height() - asp_width = round(win_height * self.aspect_ratio) - if asp_height > win_height: - self.height = win_height - self.width = asp_width - else: - self.height = asp_height - self.width = win_width - self.showImage() + self.show_image() - def showImage(self, event=None): - # even if we're not showing a new frame, we want to remove the old one + def get_zoom_scale(self): + return self.ZOOM_FACTOR ** self.zoom + + def show_image(self, event=None): self.canvas.delete('delendum') - if self.image != None: - self.container = self.canvas.create_rectangle(0,0,self.width,self.height,width=0, tags='delendum') - self.canvas.scale('all', 0, 0, self.imgscale, self.imgscale) - self.canvas.move('all', self.panX, self.panY) - bbox = self.canvas.bbox(self.container) - image = self.image.resize((bbox[2] - bbox[0], bbox[3] - bbox[1])) - imagetk = ImageTk.PhotoImage(image) - image = self.canvas.create_image(bbox[0], bbox[1], anchor='nw', image=imagetk, tags='delendum') - self.canvas.lower(image) - self.canvas.imagetk = imagetk - self.shown = True - self.app.Trace.update() - - def wheel(self, event): - if self.image != None: - if event.keysym == 'equal' or event.keysym == 'minus': #what is this for? - x = self.canvas_width/2 - y = self.canvas_height/2 - else: #do these vars get used? - x = self.canvas.canvasx(event.x) - y = self.canvas.canvasy(event.y) - - # Respond to Linux (event.num) or Windows (event.delta) wheel event - if event.num == 5 or event.delta < 0 or event.keysym == 'minus': # zoom out - if self.zoom < self.maxZoom: - self.zoom += 1 - self.imgscale /= self.delta - - elif event.num == 4 or event.delta > 0 or event.keysym == 'equal': # zoom in - if self.zoom > self.maxZoom * -1: - self.zoom -= 1 - self.imgscale *= self.delta - - bbox = self.canvas.coords(self.container) - self.panX = bbox[0] - self.panY = bbox[1] - self.showImage() - - def scrollY(self, *args, **kwargs): - self.canvas.yview(*args, **kwargs) - self.showImage() - - def moveFrom(self, event): - self.panStartX = event.x - self.panStartY = event.y - - def moveTo(self, event): - dx = event.x - self.panStartX - dy = event.y - self.panStartY - self.panStartX = event.x - self.panStartY = event.y - self.panX += dx - self.panY += dy - self.showImage() + if self.image is None: + return + + width, height = self.image.size + scale = self.get_zoom_scale() + + self.container = self.canvas.create_rectangle( + 0,#-width/2, + 0,#-height/2, + width,#width/2, + height,#height/2, + width=0, + tags='delendum') + self.canvas.scale('all', 0, 0, scale, scale) + self.canvas.move('all', self.pan_x, self.pan_y) + + x0, y0, x1, y1 = self.canvas.bbox(self.container) + scaled_image = self.image.resize((x1 - x0, y1 - y0)) + + # save a reference so that python doesn't garbage collect it + self.image_tk = ImageTk.PhotoImage(scaled_image) + self.canvas.create_image( + x0, + y0, + anchor='nw', + image=self.image_tk, + tags='delendum') + self.canvas.lower('delendum') + + def zoom(self, event): + if event.delta > 0: + self.zoom_in() + else: + self.zoom_out() + + def zoom_in(self): + if self.image is not None and self.zoom > self.MIN_ZOOM: + self.zoom += 1 + self.show_image() + + def zoom_out(self): + if self.image is not None and self.zoom < self.MAX_ZOOM: + self.zoom -= 1 + self.show_image() + + def pan_start(self, event): + self.pan_start_x = event.x + self.pan_start_y = event.y + + def pan(self, event): + self.pan_x += event.x - self.pan_start_x + self.pan_y += event.y - self.pan_start_y + self.pan_start_x = event.x + self.pan_start_y = event.y + self.show_image() + + def grid(self): + self.canvas.grid(row=0, column=0, sticky='news') + + def grid_remove(self): + self.canvas.grid_remove() + From f89b13b11b2f9a13b03ff384749eff82470f3851 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Mon, 16 Dec 2019 22:32:12 -0800 Subject: [PATCH 2/6] TextGrid: handle some errors that were causing program crash If we tried to load an invalid TextGrid file, that would cause the program to raise a (native) TextGrid error and crash us. --- ultratrace/modules/textgrid.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ultratrace/modules/textgrid.py b/ultratrace/modules/textgrid.py index 762f0ec..6286d72 100644 --- a/ultratrace/modules/textgrid.py +++ b/ultratrace/modules/textgrid.py @@ -12,6 +12,7 @@ try: from textgrid import TextGrid as TextGridFile, IntervalTier, PointTier, Point # textgrid + from textgrid.exceptions import TextGridError LIBS_INSTALLED = True except ImportError as e: warn(e) @@ -180,7 +181,8 @@ def fromFile(self, filename): if LIBS_INSTALLED: try: return TextGridFile.fromFile(filename) - except UnicodeDecodeError: + except (TextGridError, UnicodeDecodeError) as e: + error(e) f = open(filename, 'rb') bytes = f.read() f.close() @@ -198,9 +200,13 @@ def fromFile(self, filename): if not found: raise else: - ret = TextGridFile.fromFile(tmp.name) - tmp.close() - return ret + try: + ret = TextGridFile.fromFile(tmp.name) + tmp.close() + return ret + except TextGridError as e: + error(e) + return None else: error("can't load from file: textgrid lib not installed") return None @@ -656,7 +662,7 @@ def fillCanvases(self): else: fill = 'gray50' frame = frames.create_line(x_coord, 0, x_coord, self.canvas_height, tags="frame"+tier[i].mark, fill=fill) - if first_frame_found == False: + if first_frame_found == False and i + 1 < len(tier): self.firstFrame = int(tier[i].mark) + 1 first_frame_found = True self.frame_len = tier[i+1].time - tier[i].time From f7c01cb9edd55d013095a87fae71929412ccb056 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Mon, 16 Dec 2019 22:32:28 -0800 Subject: [PATCH 3/6] TextGrid: fix typo in method call --- ultratrace/modules/textgrid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultratrace/modules/textgrid.py b/ultratrace/modules/textgrid.py index 6286d72..21bdbee 100644 --- a/ultratrace/modules/textgrid.py +++ b/ultratrace/modules/textgrid.py @@ -224,7 +224,7 @@ def loadOrGenerate(self): sentenceTier = IntervalTier("text") sentenceTier.add(minTime, maxTime, "text") self.TextGrid.tiers.append(sentenceTier) - fname = self.app.Data.unrelativize(self.app.Data.getCurrentFileName() + '.TextGrid') + fname = self.app.Data.unrelativize(self.app.Data.getCurrentFilename() + '.TextGrid') self.app.Data.setFileLevel('.TextGrid', fname) names = self.TextGrid.getNames() for i, n in enumerate(names): From ac5e96f49028ee2affd0975c33f2832bde23f7c7 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Tue, 17 Dec 2019 01:16:55 -0800 Subject: [PATCH 4/6] Pull #98: Delete commented code --- ultratrace/widgets/zoom_frame.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ultratrace/widgets/zoom_frame.py b/ultratrace/widgets/zoom_frame.py index 089bfe4..1c151da 100644 --- a/ultratrace/widgets/zoom_frame.py +++ b/ultratrace/widgets/zoom_frame.py @@ -65,10 +65,10 @@ def show_image(self, event=None): scale = self.get_zoom_scale() self.container = self.canvas.create_rectangle( - 0,#-width/2, - 0,#-height/2, - width,#width/2, - height,#height/2, + 0, + 0, + width, + height, width=0, tags='delendum') self.canvas.scale('all', 0, 0, scale, scale) From ab12dd54502be33edad504e7c6d98502bd39b880 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Tue, 17 Dec 2019 01:18:56 -0800 Subject: [PATCH 5/6] Pull #98: Reverse min/max zoom direction checking --- ultratrace/widgets/zoom_frame.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultratrace/widgets/zoom_frame.py b/ultratrace/widgets/zoom_frame.py index 1c151da..224cbdc 100644 --- a/ultratrace/widgets/zoom_frame.py +++ b/ultratrace/widgets/zoom_frame.py @@ -94,12 +94,12 @@ def zoom(self, event): self.zoom_out() def zoom_in(self): - if self.image is not None and self.zoom > self.MIN_ZOOM: + if self.image is not None and self.zoom < self.MAX_ZOOM: self.zoom += 1 self.show_image() def zoom_out(self): - if self.image is not None and self.zoom < self.MAX_ZOOM: + if self.image is not None and self.zoom > self.MIN_ZOOM: self.zoom -= 1 self.show_image() From cd0eb99bd1296e76ed8ed50fc65195da0a55110a Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Tue, 17 Dec 2019 12:32:33 -0800 Subject: [PATCH 6/6] Search: `continue` on TextGrid error instead of `return` --- ultratrace/modules/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultratrace/modules/search.py b/ultratrace/modules/search.py index 832ef04..0b70812 100644 --- a/ultratrace/modules/search.py +++ b/ultratrace/modules/search.py @@ -60,7 +60,7 @@ def loadIntervals(self): if tg: grid = self.app.TextGrid.fromFile(tg) if grid is None: - return + continue for tier in grid: if TextGrid.isIntervalTier(tier): for el in tier: