Added TODOs lists to the README.
[pyplotsuite.git] / imageanalyzer.py
blobaa295f639389cc293134e9108117fe81fe3cb97d
1 #!/usr/bin/env python
2 # -*- coding: UTF8 -*-
4 # Image Analizer -- A tool to visualize and analyze images
5 # Version: 0.1-alpha (see README)
7 # Copyright (C) 2006-2007 Antonio Ingargiola <tritemio@gmail.com>
9 # This program is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; either version 2 of the License, or
12 # (at your option) any later version.
14 import sys
16 # Global variables
17 rootdir = sys.path[0] + '/'
18 sourcedir = rootdir + 'imageanalyzer/'
19 gladefile = sourcedir + 'image_analyzer.glade'
20 dark_suffix = '-buio'
21 debug = True
23 from PIL import Image
25 # Generic window description classes
26 from common.gtk_generic_windows import \
27 GenericMainPlotWindow, MyNavigationToolbar, \
28 GenericSecondaryWindow, DialogWindowWithCancel
30 # Dialogs classes
31 from imageanalyzer.histogramdialog import HistogramApp
32 from imageanalyzer.sectionsdialog import SectionApp
34 # Mathematical functions
35 from numpy import arange, array, sqrt, log10
36 import scipy.ndimage.filters as filters
38 # Import matplotlib widgets and functions
39 from matplotlib.cm import get_cmap
40 from matplotlib.collections import LineCollection
41 from matplotlib.colors import colorConverter
43 # Some custom utility modules
44 from common.arrayfromfile import arrayfromfile, arrayFromZemax
45 from common.img import image2array
46 from imageanalyzer.circularlist import CircularList
47 from imageanalyzer.section import ArraySection
48 from imageanalyzer.loadfile import *
50 # Import the profiler
51 #import profile
52 #profile.Profile.bias = 5e-6
54 def dprint(*args):
55 global debug
56 if debug: print args
59 # Implementation Classes
61 class ImageApp(GenericMainPlotWindow):
62 """
63 Main application window object.
64 """
65 def __init__(self, filename=None):
66 GenericMainPlotWindow.__init__(self, 'ImageWindow', gladefile,
67 autoconnect=True, makeAxis=False, makeToolbar=False)
69 self.image_loaded = False # Must stay before gui_setup()
70 self.gui_setup()
71 self.filename = filename
73 # Create additional attributes with default values
74 self.gridon = True
75 self.origin = 'upper' # Image origin ('upper' or 'lower')
76 self.gridcolor = 'white'
77 self.xPixelDim = 1
78 self.yPixelDim = 1
79 self.min = 0
80 self.max = 2**14+1
81 self.interp = 'Nearest' # corresponds to .set_active(12)
82 self.colormap = get_cmap('jet') # corresponds to .set_active(8)
83 #self.position = None
84 self.histogramApp = None
85 self.sectionApp = None
86 self.colors = ('green', 'red', 'blue', 'magenta', 'cyan', 'yellow',
87 'orange', 'gray')
89 self.segments = []
90 self.linescolors = []
91 self.measuring = False # - indicate if the measure button is pushed
92 self.secondPoint = False # - indicate if we're waiting for the second
93 # point of a distance measure
94 self.title = None
95 self.xlabel = None
96 self.ylabel = None
97 self.colorlist = None
98 self.sectionsLC = None # LinesCollection representing the sections
100 # Show the window and each child
101 self.window.show_all()
103 # Load the eventual image
104 if not filename == None:
105 self.load_image(filename)
106 else:
107 self.statusBar.push(self.context,
108 'Use menu File -> Open to load an image.')
110 def gui_setup(self):
111 # Create the matplotlib toolbar
112 self.mpl_toolbar = MyNavigationToolbar(self.canvas, self.window,
113 ButtonCallBack = self.on_measureButton_toggled,
114 icon_fname = sourcedir+'icons/measure-icon-20.png',
115 label = 'Measure Tool',
116 tooltip = 'Enable/Disable the "Measure Tool".'
118 toolbar_container = self.widgetTree.get_widget('MyToolbarAlignment')
119 toolbar_container.add(self.mpl_toolbar)
121 # Create an handle for the grid check box
122 self.gridCheckBox = self.widgetTree.get_widget('grid')
124 # Create handles for the SpinBoxes
125 self.minSpinButton = self.widgetTree.get_widget('minSpinButton')
126 self.maxSpinButton = self.widgetTree.get_widget('maxSpinButton')
128 # Set the handler for the colormap ComboBox
129 self.cmapComboBox = self.widgetTree.get_widget('cmapComboBox')
130 self.cmapComboBox.set_active(8) # 'jet'
132 # Set the handler for the interpolation ComboBox
133 self.interpComboBox = self.widgetTree.get_widget('interpComboBox')
134 self.interpComboBox.set_active(12) # 'Nearest'
137 # The following are plotting methods
139 def _load_file(self, fname):
141 Low-level function used by load_image to handle file open, format
142 autodetection and loading of basic data.
144 load_image requires that _load_file set the following:
145 - self.imagemode
146 - self.image_array
147 and optionally also self.image (the PIL image object).
149 try:
150 self.image_array, self.imagemode, self.image = load_file(fname)
151 except IOError, error_string:
152 self.statusBar.push(self.context, error_string)
153 return False
154 except UnknownFileType:
155 self.statusBar.push(self.context,
156 'File "%s" is of unkown type.' % fname)
157 return False
158 else:
159 return True
161 def load_image(self, filename):
163 Load the given image filename, and plot it. It set also the statical
164 attributes for a given image (.min, .max, .bits, .isColorImage)
167 # Try to open the file and load the image ...
168 if not self._load_file(filename): return
170 # ... and if the image il loaded ...
171 self.window.set_title(filename)
172 self.figure.clf()
173 self.axim = self.figure.add_subplot(111)
175 if self.imagemode == 'L':
176 self.nbit = 8
177 self.isColorImage = False
178 elif self.imagemode == 'I;16':
179 self.nbit = 14
180 self.isColorImage = False
181 elif self.imagemode == 'floatarray':
182 self.nbit = 8
183 self.isColorImage = False
184 else:
185 self.nbit = 8
186 self.isColorImage = True
188 if self.isColorImage:
189 self.statusBar.push(self.context,
190 'Image RGB(A): ignoring the range (min and max).')
191 else:
192 self.min = self.image_array.min()
193 self.max = self.image_array.max()
194 if self.imagemode == 'floatarray':
195 s = 'float values.'
196 else:
197 s = str(self.nbit)+' bit.'
198 self.statusBar.push(self.context,
199 'Image L (Luminance) with '+s)
201 self.autoset_extent()
202 self.plot_image(self.min, self.max)
203 self.image_loaded = True
205 def plot_image(self, min=None, max=None, interp=None, cmap_name=None,
206 origin=None, extent=None):
208 Plot the image self.image_array. The optional parameters allow to
209 choose min and max range value, interpolation method and colormap.
210 If not specified the default values are took from the object
211 attributes (self.min, self.max, self.cmap, self.interp).
213 Assumes the statical attributes for the given image to be set (for
214 example .isColorImage).
216 if not min == None: self.set_min(min)
217 if not max == None: self.set_max(max)
218 if not interp == None: self.interp = interp
219 if not origin == None: self.origin = origin
220 if not cmap_name == None: self.colormap = get_cmap(cmap_name)
221 if not extent == None: self.extent = extend
223 self.axim.clear()
224 self.i = self.axim.imshow(self.image_array,
225 interpolation=self.interp,
226 vmin=self.min, vmax=self.max,
227 cmap=self.colormap,
228 origin=self.origin,
229 extent=self.extent)
231 if not self.isColorImage:
232 # Plot the colorbar
233 try:
234 # Try to plot the colorbar on an existing second axis
235 self.figure.colorbar(self.i, self.figure.axes[1])
236 except:
237 # Otherwise let colorbar() to create its default axis
238 self.figure.colorbar(self.i)
240 if self.gridon:
241 self.axim.grid(color=self.gridcolor)
242 self.axim.axis('image')
243 self.set_title_and_labels()
244 if self.sectionsLC != None:
245 self.axim.add_collection(self.sectionsLC)
246 self.canvas.draw()
248 def autoset_extent(self):
250 Auto-set the image extent using image dimension and pixel size.
252 if self.isColorImage:
253 ly, lx, ch = self.image_array.shape
254 else:
255 ly, lx = self.image_array.shape
256 self.extent = (0, lx*self.xPixelDim, 0, ly*self.yPixelDim)
258 def set_min(self, min):
259 self.min = min
260 self.minSpinButton.set_value(min)
262 def set_max(self, max):
263 self.max = max
264 self.maxSpinButton.set_value(max)
266 def set_title_and_labels(self):
267 if self.title != None:
268 self.axim.set_title(self.title)
269 if self.xlabel != None:
270 self.axim.set_xlabel(self.xlabel)
271 if self.ylabel != None:
272 self.axim.set_ylabel(self.ylabel)
275 # Methods for the "Measure" tool and to manage sections
277 def on_canvas_clicked(self, event):
278 if not event.inaxes == self.axim: return
279 if not self.secondPoint:
280 self.secondPoint = True
281 print "x:", event.xdata, ", y:", event.ydata
282 self.x1 = int(event.xdata) + 0.5
283 self.y1 = int(event.ydata) + 0.5
284 m = 'First point: X1 = '+str(self.x1)+', Y1 = '+str(self.y1)+'.'
285 m += ' Select the second point.'
286 self.statusBar.push(self.context, m)
287 else:
288 print "x:", event.xdata, ", y:", event.ydata
289 x2 = int(event.xdata) + 0.5
290 y2 = int(event.ydata) + 0.5
292 self.segments.append(((self.x1, self.y1), (x2, y2)))
293 self.linescolors.append(self.colorlist.next())
294 self.sectionsLC = LineCollection(self.segments,
295 colors=self.linescolors)
296 self.sectionsLC.set_linewidth(3)
297 self.axim.add_collection(self.sectionsLC)
298 self.canvas.draw()
300 # Print distance to status bar
301 self.calculateDistance(self.segments[-1])
303 if self.sectionApp != None:
304 self.sectionApp.addsection(self.calculateSection(-1))
306 self.secondPoint = False
307 self.x1, self.y1 = None, None
309 def calculateDistance(self, section, useStatusBar=True):
310 (x1, y1), (x2, y2) = section
311 dx = (x2 - x1)
312 dy = (y2 - y1)
313 dist = sqrt(dx**2 + dy**2)
314 if useStatusBar:
315 m = 'X1 = %d, Y1 = %d; ' % (self.x1, self.y1)
316 m += 'X2 = %d, Y2 = %d; ' % (x2, y2)
317 m += 'Distance: %5.1f μm.' % (dist)
318 self.statusBar.push(self.context, m)
319 return dist
321 def calculateSection(self, segmentindex):
322 (x1, y1), (x2, y2) = self.segments[segmentindex]
324 print 'xpdim:', self.xPixelDim, 'ypdim:', self.yPixelDim
325 px = lambda xi: int(round(xi/float(self.xPixelDim)))
326 py = lambda yi: int(round(yi/float(self.yPixelDim)))
328 px1, px2, py1, py2 = px(x1), px(x2), py(y1), py(y2)
329 print 'Segment:', px1, px2, py1, py2
331 if self.origin == 'upper':
332 py1, py2 = self.image_array.shape[0] - array((py1, py2)) - 1
333 print 'Segment UpDown:', px1, px2, py1, py2
335 section = ArraySection(self.image_array, px1, px2, py1, py2,
336 self.xPixelDim, self.yPixelDim, debug=True)
338 # Recall the corresponding section color
339 section.color = self.linescolors[segmentindex]
341 return section
344 # The following are Toolbar callbacks
346 def on_cmapComboBox_changed(self, widget, *args):
347 colormap_name = self.cmapComboBox.get_active_text().lower()
348 self.colormap = get_cmap(colormap_name)
349 if not self.image_loaded: return
350 elif self.isColorImage:
351 self.colormap = get_cmap(colormap_name)
352 self.statusBar.push(self.context,
353 'Colormaps are ignored for color images.')
354 else:
355 self.i.set_cmap(self.colormap)
356 self.canvas.draw()
358 def on_interpComboBox_changed(self, widget, *args):
359 if not self.image_loaded: return None
360 interpolation_mode = self.interpComboBox.get_active_text()
361 dprint ("Interpolation:", interpolation_mode)
362 self.interp = interpolation_mode
363 self.i.set_interpolation(interpolation_mode)
364 self.canvas.draw()
366 def on_histogramButton_clicked(self, widget, *args):
367 if not self.image_loaded:
368 self.statusBar.push(self.context,
369 'You should open an image to visualyze the histogram.')
370 return None
371 elif self.isColorImage:
372 self.statusBar.push(self.context,
373 'Histogram view is not available for color images.')
374 return None
376 if self.histogramApp == None:
377 dprint ("new histogram")
378 self.histogramApp = HistogramApp(gladefile, self, self.nbit,
379 debug=debug)
380 else:
381 dprint ("old histogram")
382 self.histogramApp.window.show()
384 def on_applyButton_clicked(self, widget, *args):
385 min = self.minSpinButton.get_value()
386 max = self.maxSpinButton.get_value()
387 if self.min != min or self.max != max:
388 self.min, self.max = min, max
389 self.plot_image(self.min, self.max)
391 def on_measureButton_toggled(self, widget, data=None):
392 self.measuring = not self.measuring
393 if self.measuring:
394 # Create the list of colors the first time
395 if self.colorlist == None:
396 self.colorlist = CircularList(len(self.colors))
397 for c in self.colors:
398 self.colorlist.append(colorConverter.to_rgba(c))
399 # Connect the cb to handle the measure
400 self.cid = self.canvas.mpl_connect('button_release_event',
401 self.on_canvas_clicked)
402 self.statusBar.push(self.context,
403 '"Measure Length" tool enabled: select the first point.')
404 else:
405 self.statusBar.push(self.context,
406 '"Measure Length" tool disabled.')
407 self.canvas.mpl_disconnect(self.cid)
410 # The following are menu callbacks
412 def on_openfile_activate(self, widget, *args):
413 openfileDialog = OpenFileDialog(self)
415 def on_close_activate(self, widget, *args):
416 self.figure.clf()
417 self.canvas.draw()
418 self.image_loaded = False
420 def on_quit_activate(self, widget, *args):
421 self.on_imagewindow_destroy(widget, *args)
423 def on_downup_activate(self, widget, *args):
424 if self.origin == 'upper': self.origin = 'lower'
425 else: self.origin = 'upper'
426 if self.image_loaded: self.plot_image()
427 if self.sectionApp != None:
428 print " *** WARNING: swap of segments not implemented."
430 def on_grid_activate(self, widget, data=None):
431 if self.gridon: self.axim.grid(False) # Toggle ON -> OFF
432 else: self.axim.grid(color=self.gridcolor) # Toggle OFF -> ON
433 self.canvas.draw()
434 self.gridon = not self.gridon
436 def on_whitegrid_activate(self, widget, *args):
437 if self.gridcolor == 'white': self.gridcolor = 'black'
438 else: self.gridcolor = 'white'
439 dprint ("grid color:", self.gridcolor)
441 if self.gridon:
442 self.axim.grid(self.gridon, color=self.gridcolor)
443 self.canvas.draw()
445 def on_information_activate(self, widget, *args):
446 about = AboutDialog()
448 def on_pixelDimension_activate(self, widget, *args):
449 PixelDimensionWindow(self)
451 def on_title_and_axes_labels_activate(self, widget, *args):
452 TitleAndAxesWindow(self)
454 def on_sections_activate(self, widget, *args):
455 if self.isColorImage:
456 self.statusBar.push(self.context,
457 'Function not available for color images.')
458 elif self.segments == []:
459 self.statusBar.push(self.context, 'No section to plot.')
460 elif self.sectionApp == None:
461 for index, color in enumerate(self.linescolors):
462 dprint (index, color, "cycle")
463 aSection = self.calculateSection(index)
464 print 'color', aSection.color
465 if self.sectionApp == None:
466 self.sectionApp = SectionApp(gladefile, aSection, self)
467 else:
468 self.sectionApp.addsection(aSection)
469 else:
470 self.sectionApp.window.show()
472 def on_sectionlist_activate(self, widget, *args):
473 colors = CircularList(len(self.colors))
474 colors.fromlist(self.colors)
475 text = ''
476 for n, seg in enumerate(self.segments):
477 (x1, y1), (x2, y2) = seg
478 lenAU = self.calculateDistance(seg, False)
479 text += '== Section '+ str(n+1) +' ('+ colors.get(n)+') ==\n'
480 text += 'x1 = %4d y1 = %4d\n' % (x1, y1)
481 text += 'x2 = %4d y2 = %4d\n' % (x2, y2)
482 text += 'Length: %5.2f, with pixel dimention (%2.2f x %2.2f).\n'\
483 % (lenAU, self.xPixelDim, self.yPixelDim)
484 text += '\n'
485 SectionListApp(text)
487 def on_clearsegments_activate(self, widget, *args):
488 if self.segments == []: return
489 if self.sectionApp != None:
490 self.sectionApp.destroy()
491 self.sectionApp = None
492 self.segments = []
493 self.linescolors = []
494 self.colorlist.resetIndex()
495 self.sectionsLC = None
496 #self.axim.clear()
497 self.plot_image()
499 def on_resetrange_activate(self, widget, *args):
500 self.plot_image(
501 min=int(self.image_array.min()),
502 max=int(self.image_array.max()))
504 def on_gaussianFilter_activate(self, widget, *args):
505 dprint ("Gaussian Filer")
506 GaussianFilterWindow(self)
508 def on_scrollingmean_activate(self, widget, *args):
509 if not self.isColorImage:
510 print 'Type:', self.image_array.dtype.name
511 self.image_array = self.image_array.astype('float32')
512 print 'Convolution spatial filter ... '
513 kernel = array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype='float32')
514 kernel /= kernel.sum()
515 print ' - Convolution kernel: \n', kernel
516 self.image_array = filters.convolve(self.image_array, kernel)
517 self.imagemode = 'floatarray'
518 self.plot_image()
519 print 'OK\n'
520 else:
521 self.statusBar.push(self.context,
522 "This mean can't be performed on color images.")
524 def on_reload_activate(self, widget, *args):
525 dprint ("Reloading image ...")
526 if self.filename != None:
527 self.on_clearsegments_activate(None, None)
528 self.load_image(self.filename)
531 class SectionListApp(GenericSecondaryWindow):
532 """
533 This class implement the text window used to show the sections' list.
535 def __init__(self, text):
536 GenericSecondaryWindow.__init__(self, 'SectionListWindow', gladefile)
538 textview = self.widgetTree.get_widget('sectionsTextView')
539 buffer = textview.get_buffer()
540 buffer.set_text(text)
542 def on_closeButton_clicked(self, widget, data=None):
543 self.window.destroy()
546 class GaussianFilterWindow(DialogWindowWithCancel):
548 Dialog for setting the gaussian filter options.
550 def __init__(self, callerApp):
551 DialogWindowWithCancel.__init__(self, 'GaussianFilterWindow',
552 gladefile, callerApp)
553 self.sigmaSpinButton = self.widgetTree.get_widget('sigmaSpinButton')
555 def on_okButton_clicked(self, widget, *args):
556 sigma = self.sigmaSpinButton.get_value()
557 dprint ("Ok, sigma =", sigma)
558 self.callerApp.image_array = filters.gaussian_filter(
559 self.callerApp.image_array, sigma)
560 self.callerApp.imagemode = 'floatarray'
561 self.callerApp.plot_image()
562 self.window.destroy()
565 class PixelDimensionWindow(DialogWindowWithCancel):
567 Dialog for setting the pixel dimension.
569 def __init__(self, callerApp):
570 DialogWindowWithCancel.__init__(self, 'PixelDimensionWindow',
571 gladefile, callerApp)
573 xDim = self.callerApp.xPixelDim
574 yDim = self.callerApp.yPixelDim
576 self.xPixDimSpinButt = self.widgetTree.get_widget('xPixDimSpinButt')
577 self.yPixDimSpinButt = self.widgetTree.get_widget('yPixDimSpinButt')
578 self.xPixDimSpinButt.set_value(xDim)
579 self.yPixDimSpinButt.set_value(yDim)
581 def on_okButton_clicked(self, widget, *args):
582 dprint ("OK")
583 self.callerApp.xPixelDim = self.xPixDimSpinButt.get_value()
584 self.callerApp.yPixelDim = self.yPixDimSpinButt.get_value()
585 self.callerApp.autoset_extent()
586 self.callerApp.plot_image()
587 self.window.destroy()
590 class TitleAndAxesWindow(DialogWindowWithCancel):
592 Dialog for setting Title and axes labels.
594 def __init__(self, callerApp):
595 DialogWindowWithCancel.__init__(self, 'TitleAndAxesWindow',
596 gladefile, callerApp)
598 self.titleEntry = self.widgetTree.get_widget('titleEntry')
599 self.xlabelEntry = self.widgetTree.get_widget('xlabelEntry')
600 self.ylabelEntry = self.widgetTree.get_widget('ylabelEntry')
602 def sync(field, entry):
603 if field != None: entry.set_text(field)
605 sync(self.callerApp.title, self.titleEntry)
606 sync(self.callerApp.xlabel, self.xlabelEntry)
607 sync(self.callerApp.ylabel, self.ylabelEntry)
609 def on_okButton_clicked(self, widget, *args):
610 dprint ("Ok clicked")
611 self.callerApp.axim.set_title(self.titleEntry.get_text())
612 self.callerApp.axim.set_xlabel(self.xlabelEntry.get_text())
613 self.callerApp.axim.set_ylabel(self.ylabelEntry.get_text())
614 self.callerApp.canvas.draw()
616 self.callerApp.title = self.titleEntry.get_text()
617 self.callerApp.xlabel = self.xlabelEntry.get_text()
618 self.callerApp.ylabel = self.ylabelEntry.get_text()
620 self.window.destroy()
623 class OpenFileDialog(DialogWindowWithCancel):
625 This class implements the "Open File" dialog.
627 def __init__(self, callerApp):
628 DialogWindowWithCancel.__init__(self, 'openfileDialog',
629 gladefile, callerApp)
631 def openSelectedFile(self):
632 filename = self.window.get_filename()
633 if filename != None:
634 self.window.hide()
635 self.callerApp.load_image(filename)
637 def on_openButton_clicked(self, widget, *args):
638 dprint ("open button clicked", self.window.get_filename())
639 self.openSelectedFile()
641 def on_openfileDialog_file_activated(self, widget, *args):
642 dprint ("Open File Dialog: file_activated", self.window.get_filename())
644 def on_openfileDialog_response(self, widget, *args):
645 dprint ("\nOpen File Dialog: response event")
648 class AboutDialog(GenericSecondaryWindow):
649 """
650 Object for the "About Dialog".
652 def __init__(self):
653 GenericSecondaryWindow.__init__(self, 'aboutDialog', gladefile)
654 self.window.set_version(read_version())
656 def read_version():
657 """ Extract version from README file. """
658 file = open(rootdir+'README')
659 ver = None
660 for line in file:
661 if line.strip().startswith('*Latest Version*'):
662 s = line.split()
663 ver = s[2]
664 break
665 file.close()
666 return ver
668 if __name__ == "__main__":
669 file_name = sourcedir + 'samples/test-img.tif'
670 files = sys.argv[1:]
671 if len(files) < 1: files =(file_name,)
673 for filename in files:
674 try:
675 file = open(filename,'r')
676 ia = ImageApp(filename)
677 file.close()
678 except IOError:
679 print "\n File '"+filename+"' is not existent or not readable.\n"
680 ia = ImageApp()
681 ia.start()