Package pyvision :: Package analysis :: Module Montage
[hide private]
[frames] | no frames]

Source Code for Module pyvision.analysis.Montage

  1  """ 
  2  Created on Mar 14, 2011 
  3  @author: svohara 
  4  """ 
  5  # PyVision License 
  6  # 
  7  # Copyright (c) 2006-2008 Stephen O'Hara 
  8  # All rights reserved. 
  9  # 
 10  # Redistribution and use in source and binary forms, with or without 
 11  # modification, are permitted provided that the following conditions 
 12  # are met: 
 13  #  
 14  # 1. Redistributions of source code must retain the above copyright 
 15  # notice, this list of conditions and the following disclaimer. 
 16  #  
 17  # 2. Redistributions in binary form must reproduce the above copyright 
 18  # notice, this list of conditions and the following disclaimer in the 
 19  # documentation and/or other materials provided with the distribution. 
 20  #  
 21  # 3. Neither name of copyright holders nor the names of its contributors 
 22  # may be used to endorse or promote products derived from this software 
 23  # without specific prior written permission. 
 24  #  
 25  #  
 26  # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
 27  # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
 28  # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 
 29  # A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR 
 30  # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
 31  # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
 32  # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
 33  # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 
 34  # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 
 35  # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 
 36  # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 37  import pyvision as pv 
 38  import cv 
 39  import PIL.Image 
 40  import weakref 
 41   
42 -class ImageMontage(object):
43 """ 44 Displays thumbnails of a list of input images as a single 45 'montage' image. Supports scrolling if there are more images 46 than "viewports" in the layout. 47 """ 48
49 - def __init__(self, image_list, layout=(2, 4), tile_size=(64, 48), gutter=2, by_row=True, labels='index', 50 keep_aspect=True, highlight_selected=False):
51 """ 52 Constructor 53 @param image_list: A list of pyvision images that you wish to display 54 as a montage. 55 @param layout: A tuple (rows,cols) that indicates the number of tiles to 56 show in a single montage page, oriented in a grid. 57 @param tile_size: The size of each thumbnail image to display in the montage. 58 @param gutter: The width in pixels of the gutter between thumbnails. 59 @param by_row: If true, the image tiles are placed in row-major order, that 60 is, one row of the montage is filled before moving to the next. If false, 61 then column order is used instead. 62 @param labels: Used to show a label at the lower left corner of each image in the montage. 63 If this parameter is a list, then it should be the same length as len(image_list) and contain 64 the label to be used for the corresponding image. If labels == 'index', then the image 65 montage will simply display the index of the image in image_list. Set labels to None to suppress labels. 66 @param keep_aspect: If true the original image aspect ratio will be preserved. 67 @param highlight_selected: If true, any image tile in the montage which has been clicked will 68 be drawn with a rectangular highlight. This will toggle, such that if an image is clicked a second 69 time, the highlighting will be removed. 70 """ 71 self._tileSize = tile_size 72 self._rows = layout[0] 73 self._cols = layout[1] 74 self._images = image_list 75 self._gutter = gutter 76 self._by_row = by_row 77 self._txtfont = cv.InitFont(cv.CV_FONT_HERSHEY_SIMPLEX, 0.5, 0.5) 78 self._txtcolor = (255, 255, 255) 79 self._imgPtr = 0 80 self._labels = labels 81 self._clickHandler = clickHandler(self) 82 self._keep_aspect = keep_aspect 83 self._image_positions = [] 84 self._select_handler = None 85 self._highlighted = highlight_selected 86 self._selected_tiles = [] #which images have been selected (or clicked) by user 87 88 #check if we need to allow for scroll-arrow padding 89 if self._rows * self._cols < len(image_list): 90 if by_row: 91 self._xpad = 0 92 self._ypad = 25 93 else: 94 self._ypad = 0 95 self._xpad = 25 96 else: 97 #there will be no scrolling required 98 self._xpad = 0 99 self._ypad = 0 100 101 imgWidth = self._cols * ( tile_size[0] + gutter ) + gutter + 2 * self._xpad 102 imgHeight = self._rows * (tile_size[1] + gutter) + gutter + 2 * self._ypad 103 self._size = (imgWidth, imgHeight) 104 105 cvimg = cv.CreateImage(self._size, cv.IPL_DEPTH_8U, 3) 106 self._cvMontageImage = cvimg 107 108 self._initDecrementArrow() #build the polygon for the decrement arrow 109 self._initIncrementArrow() #build the polygon for the increment arrow 110 self.draw() #compute the initial montage image
111
112 - def draw(self, mousePos=None):
113 """ 114 Computes the image montage from the source images based on the current 115 image pointer (position in list of images), etc. This internally constructs 116 the montage, but show() is required for display and mouse-click handling. 117 """ 118 cv.SetZero(self._cvMontageImage) 119 120 img_ptr = self._imgPtr 121 if img_ptr > 0: 122 #we are not showing the first few images in imageList 123 #so display the decrement arrow 124 cv.FillConvexPoly(self._cvMontageImage, self._decrArrow, (125, 125, 125)) 125 126 if img_ptr + (self._rows * self._cols) < len(self._images): 127 #we are not showing the last images in imageList 128 #so display increment arrow 129 cv.FillConvexPoly(self._cvMontageImage, self._incrArrow, (125, 125, 125)) 130 131 self._image_positions = [] 132 if self._by_row: 133 for row in range(self._rows): 134 for col in range(self._cols): 135 if img_ptr > len(self._images) - 1: break 136 tile = pv.Image(self._images[img_ptr].asAnnotated()) 137 self._composite(tile, (row, col), img_ptr) 138 img_ptr += 1 139 else: 140 for col in range(self._cols): 141 for row in range(self._rows): 142 if img_ptr > len(self._images) - 1: break 143 tile = pv.Image(self._images[img_ptr].asAnnotated()) 144 self._composite(tile, (row, col), img_ptr) 145 img_ptr += 1
146 147 #if mousePos != None: 148 # (x,y) = mousePos 149 # cv.Rectangle(self._cvMontageImage, (x-2,y-2), (x+2,y+2), (0,0,255), thickness=cv.CV_FILLED) 150 151
152 - def asImage(self):
153 """ 154 If you don't want to use the montage's built-in mouse-click handling by calling 155 the ImageMontage.show() method, then this method will return the montage image 156 computed from the last call to draw(). 157 """ 158 return pv.Image(self._cvMontageImage)
159
160 - def show(self, window="Image Montage", pos=None, delay=0):
161 """ 162 Will display the montage image, as well as register the mouse handling callback 163 function so that the user can scroll the montage by clicking the increment/decrement 164 arrows. 165 @return: The key code of the key pressed, if any, that dismissed the window. 166 """ 167 img = self.asImage() 168 cv.NamedWindow(window) 169 cv.SetMouseCallback(window, self._clickHandler.onClick, window) 170 key = img.show(window=window, pos=pos, delay=delay) 171 return key
172
173 - def setSelectHandler(self, handler):
174 """ 175 Add a function that will be called when an image is selected. 176 The handler function should take an image, the image index, 177 a list of labels, and a dictionary of other info. 178 """ 179 self._select_handler = handler
180
181 - def setHighlighted(self, idxs):
182 ''' 183 If the montage was created with highlight_selected option enabled, 184 then this function will cause a set of tiles in the montage to be 185 highlighted. 186 @note: Calling this method will erase any previous selections made 187 by the user. 188 ''' 189 self._selected_tiles = idxs 190 self.draw()
191
192 - def getHighlighted(self):
193 ''' 194 Returns the index list of the tiles which were selected/highlighted 195 by the users 196 ''' 197 return sorted(self._selected_tiles)
198
199 - def _checkClickRegion(self, x, y):
200 """ 201 internal method to determine the clicked region of the montage. 202 @return: -1 for decrement region, 1 for increment region, and 0 otherwise. 203 If a select handler function was defined (via setSelectHandler), then 204 this function will be called when the user clicks within the region 205 of one of the tiles of the montage. Signature of selectHandler function 206 is f(img, imgNum, dict). As of now, the only key/value pair passed 207 into the dict is "imgLabel":<label>. 208 """ 209 if self._by_row: 210 #scroll up/down to expose next/prev row 211 decr_rect = pv.Rect(0, 0, self._size[0], self._ypad) 212 incr_rect = pv.Rect(0, self._size[1] - self._ypad, self._size[0], self._ypad) 213 else: 214 #scroll left/right to expose next/prev col 215 decr_rect = pv.Rect(0, 0, self._xpad, self._size[1]) 216 incr_rect = pv.Rect(self._size[0] - self._xpad, 0, self._xpad, self._size[1]) 217 218 pt = pv.Point(x, y) 219 if incr_rect.containsPoint(pt): 220 #print "DEBUG: Increment Region" 221 return 1 222 elif decr_rect.containsPoint(pt): 223 #print "DEBUG: Decrement Region" 224 return -1 225 else: 226 #print "DEBUG: Neither Region" 227 for img, imgNum, rect in self._image_positions: 228 if rect.containsPoint(pt): 229 if imgNum in self._selected_tiles: 230 self._selected_tiles.remove(imgNum) 231 else: 232 self._selected_tiles.append(imgNum) 233 if self._select_handler != None: 234 imgLabel = self._labels[imgNum] if type(self._labels) == list else str(imgNum) 235 self._select_handler(img, imgNum, {"imgLabel":imgLabel}) 236 return 0
237
238 - def _initDecrementArrow(self):
239 """ 240 internal method to compute the list of points that represents 241 the appropriate decrement arrow (leftwards or upwards) depending 242 on the image montage layout. 243 """ 244 if self._by_row: 245 #decrement upwards 246 x1 = self._size[0] / 2 247 y1 = 2 248 halfpad = self._ypad / 2 249 self._decrArrow = [(x1, y1), (x1 + halfpad, self._ypad - 2), (x1 - halfpad, self._ypad - 2)] 250 else: 251 #decrement leftwards 252 x1 = 2 253 y1 = self._size[1] / 2 254 halfpad = self._xpad / 2 255 self._decrArrow = [(x1, y1), (x1 + self._xpad - 3, y1 - halfpad), (x1 + self._xpad - 3, y1 + halfpad)]
256
257 - def _initIncrementArrow(self):
258 """ 259 internal method to compute the list of points that represents 260 the appropriate increment arrow (rightwards or downwards) depending 261 on the image montage layout. 262 """ 263 if self._by_row: 264 #increment downwards 265 x1 = self._size[0] / 2 266 y1 = self._size[1] - 3 267 halfpad = self._ypad / 2 268 self._incrArrow = [(x1, y1), (x1 + halfpad, y1 - self._ypad + 3), (x1 - halfpad, y1 - self._ypad + 3)] 269 else: 270 #increment rightwards 271 x1 = self._size[0] - 2 272 y1 = self._size[1] / 2 273 halfpad = self._xpad / 2 274 self._incrArrow = [(x1, y1), (x1 - self._xpad + 2, y1 - halfpad), (x1 - self._xpad + 2, y1 + halfpad)]
275
276 - def _decr(self):
277 """ 278 internal method used by _onClick to compute the new imgPtr location after a decrement 279 """ 280 tmp_ptr = self._imgPtr 281 if self._by_row: 282 tmp_ptr -= self._cols 283 else: 284 tmp_ptr -= self._rows 285 if tmp_ptr < 0: 286 self._imgPtr = 0 287 else: 288 self._imgPtr = tmp_ptr
289
290 - def _incr(self):
291 """ 292 internal method used by _onClick to compute the new imgPtr location after an increment 293 """ 294 tmp_ptr = self._imgPtr 295 if self._by_row: 296 tmp_ptr += self._cols 297 else: 298 tmp_ptr += self._rows 299 300 self._imgPtr = tmp_ptr
301 302
303 - def _composite(self, img, pos, imgNum):
304 """ 305 Internal method to composite the thumbnail of a given image into the 306 correct position, given by (row,col). 307 @param img: The image from which a thumbnail will be composited onto the montage 308 @param pos: A tuple (row,col) for the position in the montage layout 309 @param imgNum: The image index of the tile being drawn, this helps us display the 310 appropriate label in the lower left corner if self._labels is not None. 311 """ 312 (row, col) = pos 313 314 if self._keep_aspect: 315 # Get the current size 316 w, h = img.size 317 318 # Find the scale 319 scale = min(1.0 * self._tileSize[0] / w, 1.0 * self._tileSize[1] / h) 320 w = int(scale * w) 321 h = int(scale * h) 322 323 # Resize preserving aspect 324 img2 = img.resize((w, h)).asPIL() 325 326 # Create a new image with the old image centered 327 x = (self._tileSize[0] - w) / 2 328 y = (self._tileSize[1] - h) / 2 329 pil = PIL.Image.new('RGB', self._tileSize, "#000000") 330 pil.paste(img2, (x, y, x + w, y + h)) 331 332 # Generate the tile 333 tile = pv.Image(pil) 334 else: 335 tile = img.resize(self._tileSize) 336 337 pos_x = col * (self._tileSize[0] + self._gutter) + self._gutter + self._xpad 338 pos_y = row * (self._tileSize[1] + self._gutter) + self._gutter + self._ypad 339 340 cvImg = self._cvMontageImage 341 cvTile = tile.asOpenCV() 342 cv.SetImageROI(cvImg, (pos_x, pos_y, self._tileSize[0], self._tileSize[1])) 343 344 # Save the position of this image 345 self._image_positions.append( 346 [self._images[imgNum], imgNum, pv.Rect(pos_x, pos_y, self._tileSize[0], self._tileSize[1])]) 347 348 depth = cvTile.nChannels 349 if depth == 1: 350 cvTileBGR = cv.CreateImage(self._tileSize, cv.IPL_DEPTH_8U, 3) 351 cv.CvtColor(cvTile, cvTileBGR, cv.CV_GRAY2BGR) 352 cv.Copy(cvTileBGR, cvImg) #should respect the ROI 353 else: 354 cv.Copy(cvTile, cvImg) #should respect the ROI 355 356 if self._labels == 'index': 357 #draw image number in lower left corner, respective to ROI 358 lbltext = "%d" % imgNum 359 elif type(self._labels) == list: 360 lbltext = str(self._labels[imgNum]) 361 else: 362 lbltext = None 363 364 if not lbltext is None: 365 ((tw, th), _) = cv.GetTextSize(lbltext, self._txtfont) 366 #print "DEBUG: tw, th = %d,%d"%(tw,th) 367 if tw > 0 and th > 0: 368 cv.Rectangle(cvImg, (0, self._tileSize[1] - 1), (tw + 1, self._tileSize[1] - (th + 1) - self._gutter), 369 (0, 0, 0), thickness=cv.CV_FILLED) 370 font = self._txtfont 371 color = self._txtcolor 372 cv.PutText(cvImg, lbltext, (1, self._tileSize[1] - self._gutter - 2), font, color) 373 374 if self._highlighted and (imgNum in self._selected_tiles): 375 #draw a highlight around this image 376 cv.Rectangle(cvImg, (0, 0), (self._tileSize[0], self._tileSize[1]), (0, 255, 255), thickness=4) 377 378 #reset ROI 379 cv.SetImageROI(cvImg, (0, 0, self._size[0], self._size[1]))
380 381
382 -class clickHandler(object):
383 """ 384 A class for objects designed to handle click events on ImageMontage objects. 385 We separate this out from the ImageMontage object to address a memory leak 386 when using cv.SetMouseCallback(window, self._onClick, window), because we 387 don't want the image data associated with the click handler 388 """ 389
390 - def __init__(self, IM_Object):
391 392 self.IM = weakref.ref(IM_Object)
393
394 - def onClick(self, event, x, y, flags, window):
395 """ 396 Handle the mouse click for an image montage object. 397 Increment or Decrement the set of images shown in the montage 398 if appropriate. 399 """ 400 IM = self.IM() #IM object is obtained via weak reference to image montage 401 if IM is None: return #if the reference was deleted already... 402 403 #print "event",event 404 if event == cv.CV_EVENT_LBUTTONDOWN: 405 rc = IM._checkClickRegion(x, y) 406 if rc == -1 and IM._imgPtr > 0: 407 #user clicked in the decrement region 408 IM._decr() 409 elif rc == 1 and IM._imgPtr < (len(IM._images) - (IM._rows * IM._cols)): 410 IM._incr() 411 else: 412 pass #do nothing 413 414 IM.draw((x, y)) 415 cv.ShowImage(window, IM._cvMontageImage)
416 417
418 -class VideoMontage(pv.Video):
419 """ 420 Provides a visualization of several videos playing back in 421 a single window. This can be very handy, for example, to 422 show tracking results of multiple objects from a single video, 423 or for minimizing screen real-estate when showing multiple 424 video sources. 425 426 A video montage object is an iterator, so you "play" the 427 montage by iterating through all the frames, just as with 428 a standard video object. 429 """ 430
431 - def __init__(self, videoDict, layout=(2, 4), tile_size=(64, 48)):
432 """ 433 @param videoDict: A dictionary of videos to display in the montage. The keys are the video labels, and 434 the values are objects adhering to the pyvision video interface. (pv.Video, pv.VideoFromImages, etc.) 435 @param layout: A tuple of (rows,cols) to indicate the layout of the montage. Videos will be separated by 436 a one-pixel gutter. Videos will be drawn to the montage such that a row is filled up prior to moving 437 to the next. The videos are drawn to the montage in the sorted order of the video keys in the dictionary. 438 @param tile_size: The window size to display each video in the montage. If the video frame sizes are larger than 439 this size, it will be cropped. If you wish to resize, use the size option in the pv.Video class to have 440 the output size of the video resized appropriately. 441 """ 442 if len(videoDict) < 1: 443 raise ValueError("You must provide at least one video in the videoDict variable.") 444 445 self.vids = videoDict 446 self.layout = layout 447 self.vidsize = tile_size 448 self.imgs = {} 449 self.stopped = []
450
451 - def __iter__(self):
452 """ Return an iterator for this video """ 453 return VideoMontage(self.vids, layout=self.layout, tile_size=self.vidsize)
454
455 - def next(self):
456 if len(self.stopped) == len(self.vids.keys()): 457 print "All Videos in the Video Montage Have Completed." 458 raise StopIteration 459 460 #get next image from each video and put on montage 461 #if video has ended, continue to display last image 462 #stop when all videos are done. 463 for key in self.vids.keys(): 464 if key in self.stopped: continue #this video has already reached its end. 465 v = self.vids[key] 466 try: 467 tmp = v.next() 468 self.imgs[key] = tmp 469 except StopIteration: 470 #print "End of a Video %s Reached"%key 471 self.stopped.append(key) 472 473 keys = sorted(self.imgs.keys()) 474 imageList = [] 475 for k in keys: 476 imageList.append(self.imgs[k]) 477 478 im = ImageMontage(imageList, self.layout, self.vidsize, gutter=2, by_row=True, labels=keys) 479 return im.asImage()
480 481
482 -def demo_imageMontage():
483 import os 484 485 imageList = [] 486 counter = 0 487 488 #get all the jpgs in the data/misc directory 489 JPGDIR = os.path.join(pv.__path__[0], 'data', 'misc') 490 filenames = os.listdir(JPGDIR) 491 jpgs = [os.path.join(JPGDIR, f) for f in filenames if f.endswith(".jpg")] 492 493 for fn in jpgs: 494 print counter 495 if counter > 8: break 496 imageList.append(pv.Image(fn)) 497 counter += 1 498 499 im = ImageMontage(imageList, (2, 3), tile_size=(128, 96), gutter=2, by_row=False) 500 im.show(window="Image Montage", delay=0) 501 cv.DestroyWindow('Image Montage')
502 503
504 -def demo_videoMontage():
505 import os 506 507 TOYCAR_VIDEO = os.path.join(pv.__path__[0], 'data', 'test', 'toy_car.m4v') 508 TAZ_VIDEO = os.path.join(pv.__path__[0], 'data', 'test', 'TazSample.m4v') 509 510 vid1 = pv.Video(TOYCAR_VIDEO) 511 vid2 = pv.Video(TAZ_VIDEO) 512 #vid3 = pv.Video(TOYCAR_VIDEO) 513 #vid4 = pv.Video(TAZ_VIDEO) 514 vid_dict = {"V1": vid1, "V2": vid2} #, "V3":vid3, "V4":vid4} 515 vm = VideoMontage(vid_dict, layout=(2, 1), tile_size=(256, 192)) 516 vm.play("Video Montage", delay=60, pos=(10, 10))
517 518 #if __name__ == '__main__': 519 520 # print "Demo of an Image Montage..." 521 # demo_imageMontage() 522 523 # print "Demo of a Video Montage..." 524 # demo_videoMontage() 525