├── external ├── __init__.py ├── README.md ├── LICENSE_MIT.txt ├── LICENSE_BSD.txt ├── simplify_polygon_rdp.py ├── read_imagej.py ├── kids_cache.py └── simplify_polygon_visvalingam.py ├── video ├── gui │ ├── __init__.py │ └── region_picker.py ├── analysis │ ├── __init__.py │ ├── video.py │ ├── shapes_3d.py │ ├── active_contour.py │ ├── curves.py │ ├── image.py │ ├── morphological_graph.py │ └── regions.py ├── README ├── io │ ├── __init__.py │ ├── computed.py │ ├── memory.py │ ├── display.py │ ├── backend_opencv.py │ ├── file.py │ ├── composer.py │ └── parallel.py ├── __init__.py ├── debug.py └── filters.py ├── .gitignore ├── LICENSE.txt └── README.md /external/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /video/gui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /video/analysis/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains general image processing routines 3 | """ -------------------------------------------------------------------------------- /external/README.md: -------------------------------------------------------------------------------- 1 | This package contains code from other projects that have been copied here for 2 | easier distribution. 3 | These projects have been published under the following licenses, which are also 4 | provided with this module: 5 | 6 | Module | License 7 | -----------------------------|---------- 8 | kids_cache | BSD 9 | read_imagej | MIT 10 | simplify_polygon_rdp | MIT 11 | simplify_polygon_visvalingam | unknown -------------------------------------------------------------------------------- /video/README: -------------------------------------------------------------------------------- 1 | Necessary python packages: 2 | cv2 -- OpenCV python bindings 3 | numpy -- Array library used for manipulating data 4 | 5 | The following python packages are necessary to use the video.analysis package: 6 | scipy -- for manipulating images 7 | shapely -- for manipulating shapes 8 | 9 | Optional python packages: 10 | tqdm -- for showing a progress bar while iterating 11 | sharedmem -- for showing the debug video in a separate process while iterating 12 | json -- for parsing output of ffprobe 13 | 14 | Note that this package uses the `fcntl` package and thus only works reliably 15 | on UNIX systems. -------------------------------------------------------------------------------- /video/io/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package provides representations of videos with a unified interface for 3 | accessing their data. 4 | 5 | Colors are always represented by float values ranging from 0 to 1. 6 | Two formats are supported: 7 | A single value indicates a grey scale 8 | Three values indicate RGB values 9 | """ 10 | 11 | from .base import VideoFork 12 | from .memory import VideoMemory 13 | from .file import (VideoFile, VideoFileStack, VideoImageStack, VideoFileWriter, 14 | show_video, load_any_video, write_video) 15 | from .computed import VideoGaussianNoise 16 | from .composer import VideoComposer 17 | from .display import ImageWindow 18 | -------------------------------------------------------------------------------- /video/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains tools for analyzing videos. 3 | 4 | The focus of the package lies on providing efficient implementations of typical 5 | functions required for editing and analyzing large videos. The tools are 6 | therefore organized such that the video need not be kept in memory as a whole. 7 | All tools are implemented as filters, which can be iterated over (sort of like 8 | generators in python language). This should make it easy to run the code in 9 | parallel. Different backends are supported, such that videos from different 10 | sources can accessed transparently. 11 | """ 12 | 13 | import logging 14 | 15 | # suppress the warning about logging handlers not being configured 16 | logging.raiseExceptions = False 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # MacOS 56 | .DS_Store 57 | -------------------------------------------------------------------------------- /external/LICENSE_MIT.txt: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 13 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /video/io/computed.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 5, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import numpy as np 10 | 11 | from .base import VideoBase 12 | from utils.math import safe_typecast 13 | 14 | 15 | class VideoGaussianNoise(VideoBase): 16 | """ class that creates Gaussian noise for each frame """ 17 | 18 | def __init__(self, frame_count, size, mean=0, std=1, fps=None, is_color=False, dtype=None): 19 | 20 | self.mean = mean 21 | self.std = std 22 | self.dtype = dtype 23 | 24 | super(VideoGaussianNoise, self).__init__(size=size, frame_count=frame_count, 25 | fps=fps, is_color=is_color) 26 | 27 | self._frame_shape = self.shape[1:] 28 | 29 | 30 | def get_frame(self, index): 31 | if index < 0: 32 | index += self.frame_count 33 | 34 | if index >= self.frame_count: 35 | raise IndexError 36 | else: 37 | frame = self.mean + self.std*np.random.randn(*self._frame_shape) 38 | if self.dtype is None: 39 | return frame 40 | else: 41 | return safe_typecast(frame, self.dtype) 42 | -------------------------------------------------------------------------------- /external/LICENSE_BSD.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Valentin Lab 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /video/analysis/video.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 4, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import numpy as np 10 | 11 | from utils.misc import display_progress 12 | 13 | 14 | def reduce_video(video, function, initial_value=None): 15 | """ applies function to consecutive frames """ 16 | result = initial_value 17 | for frame in display_progress(video): 18 | if result is None: 19 | result = frame 20 | else: 21 | result = function(frame, result) 22 | return result 23 | 24 | 25 | 26 | def measure_mean(video): 27 | """ 28 | measures the mean of each movie pixel over time 29 | """ 30 | mean = np.zeros(video.shape[1:]) 31 | 32 | for n, frame in enumerate(display_progress(video)): 33 | mean = mean*n/(n + 1) + frame/(n + 1) 34 | 35 | return mean 36 | 37 | 38 | 39 | def measure_mean_std(video): 40 | """ 41 | measures the mean and the standard deviation of each movie pixel over time 42 | Uses https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Incremental_algorithm 43 | """ 44 | mean = np.zeros(video.shape[1:]) 45 | M2 = np.zeros(video.shape[1:]) 46 | 47 | for n, frame in enumerate(display_progress(video)): 48 | delta = frame - mean 49 | mean = mean + delta/(n + 1) 50 | M2 = M2 + delta*(frame - mean) 51 | 52 | if (n < 2): 53 | return frame, 0 54 | 55 | return mean, np.sqrt(M2/n) 56 | 57 | 58 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Unless otherwise specified by LICENSE.txt files in individual 2 | directories, all code is 3 | 4 | Copyright (C) 2014, David Zwicker 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | 1. Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in 15 | the documentation and/or other materials provided with the 16 | distribution. 17 | 3. Neither the name of skimage nor the names of its contributors may be 18 | used to endorse or promote products derived from this software without 19 | specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 22 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 25 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 28 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 29 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 30 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | video-analysis 2 | ============== 3 | This python package contains python code for doing video analysis with OpenCV. 4 | The package is organized in multiple sub-packages: 5 | 6 |
7 |
video
8 |
9 | General code that can be used to process videos using python. 10 | Special attention has been paid to develop video classes that can be easily 11 | used in iterating over video frames, also with multiprocessing support. 12 | The sub-package `analysis` contains several modules that contain functions 13 | useful for general image or video analysis. 14 | 15 | Part of the modules have been modified from code from moviepy, which 16 | is released under the MIT license at github. The license is included 17 | at the end of this file. 18 |
19 |
external
20 |
21 | Small package that collects modules copied from other authors. 22 |
23 |
24 | 25 | 26 | The MIT License (MIT) [OSI Approved License] 27 | -------------------------------------------- 28 | 29 | Copyright (c) 2014 Zulko 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in 39 | all copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 47 | THE SOFTWARE. 48 | -------------------------------------------------------------------------------- /video/io/memory.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Jul 31, 2014 3 | 4 | @author: David Zwicker 5 | 6 | This package provides class definitions for describing videos 7 | that are stored in memory using numpt arrays 8 | ''' 9 | 10 | from __future__ import division 11 | 12 | import numpy as np 13 | 14 | from .base import VideoBase 15 | 16 | 17 | class VideoMemory(VideoBase): 18 | """ 19 | class which holds all the video data in memory. 20 | We allow for direct manipulation of the data attribute in order to make 21 | reading and writing data convenient. 22 | """ 23 | 24 | write_access = True 25 | seekable = True 26 | 27 | def __init__(self, data, fps=25, copy_data=True): 28 | 29 | # only copy the _data if requested or required 30 | self.data = np.array(data, copy=copy_data) 31 | 32 | # remove the color dimension if it is single 33 | if self.data.ndim > 3 and self.data.shape[3] == 1: 34 | self.data = np.squeeze(self.data, 3) 35 | 36 | # read important information 37 | frame_count = data.shape[0] 38 | size = (data.shape[2], data.shape[1]) 39 | if data.ndim == 3: 40 | is_color = False 41 | elif data.shape[3] == 3: 42 | is_color = True 43 | else: 44 | raise ValueError('The last dimension of the data must be either 1 or 3.') 45 | 46 | super(VideoMemory, self).__init__(size=size, frame_count=frame_count, 47 | fps=fps, is_color=is_color) 48 | 49 | 50 | def get_frame(self, index): 51 | if index < 0: 52 | index += self.frame_count 53 | 54 | return self.data[index] 55 | 56 | 57 | def __getitem__(self, key): 58 | return self.data[key] 59 | 60 | 61 | def __setitem__(self, key, value): 62 | """ writes video data to the frame or slice given in key """ 63 | # delegate the writing to the data directly 64 | self.data[key] = value 65 | 66 | 67 | 68 | class VideoMemoryBuffer(VideoBase): 69 | """ class which receives frames from another video and holds them in memory 70 | until they are consumed. This class thus acts as a video buffer """ 71 | pass 72 | 73 | -------------------------------------------------------------------------------- /video/gui/region_picker.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Feb 25, 2016 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import numpy as np 10 | import matplotlib.pyplot as plt 11 | from matplotlib import patches, widgets 12 | 13 | from utils.plotting import backend 14 | from video.analysis.shapes import Rectangle 15 | 16 | 17 | 18 | class RegionPicker(object): 19 | """ class that allows to pick a region in a given image """ 20 | 21 | def __init__(self, ax=None): 22 | if ax is None: 23 | self.ax = plt.gca() 24 | else: 25 | self.ax = ax 26 | 27 | self.width = self.ax.get_xlim()[1] 28 | self.height = self.ax.get_ylim()[1] 29 | 30 | # create the widget for selecting the range 31 | useblit = backend.supports_blitting() 32 | self.selector = \ 33 | widgets.RectangleSelector(self.ax, self.select_callback, 34 | drawtype='box', 35 | useblit=useblit, 36 | button=[1]) # left button 37 | 38 | # the rectangle marking the selected area 39 | self.selected = None 40 | self.selected_marker = patches.Rectangle((0, -5), 0, 0, color='y', 41 | alpha=0.5) 42 | self.ax.add_patch(self.selected_marker) 43 | 44 | 45 | def select_callback(self, eclick, erelease): 46 | """ callback for changing the selection range 47 | eclick and erelease are the press and release events """ 48 | x1, x2 = eclick.xdata, erelease.xdata 49 | y1, y2 = eclick.ydata, erelease.ydata 50 | 51 | # determine chosen range 52 | left = int(min(x1, x2) + 0.5) 53 | right = int(np.ceil(max(x1, x2) + 0.5)) 54 | 55 | top = int(min(y1, y2) + 0.5) 56 | bottom = int(np.ceil(max(y1, y2) + 0.5)) 57 | 58 | 59 | self.selected = Rectangle.from_points((x1, y1), (x2, y2)) 60 | self.selected_marker.set_x(left - 0.5) 61 | self.selected_marker.set_width(right - left) 62 | self.selected_marker.set_y(top - 0.5) 63 | self.selected_marker.set_height(bottom - top) 64 | self.ax.figure.canvas.draw() #< update the graphics 65 | 66 | 67 | def show(self): 68 | """ show the picker and block until the window is closed. Returns the 69 | selected rectangle """ 70 | plt.show() 71 | return self.selected 72 | 73 | -------------------------------------------------------------------------------- /external/simplify_polygon_rdp.py: -------------------------------------------------------------------------------- 1 | """ 2 | rdp 3 | ~~~ 4 | 5 | Pure Python implementation of the Ramer-Douglas-Peucker algorithm. 6 | 7 | :copyright: (c) 2014 Fabian Hirschmann 8 | :license: MIT, see LICENSE_MIT.txt for more details. 9 | 10 | The code was copied from 11 | https://github.com/fhirschmann/rdp/blob/master/rdp/__init__.py 12 | """ 13 | 14 | import numpy as np 15 | 16 | def pldist(x0, x1, x2): 17 | """ 18 | Calculates the distance from the point ``x0`` to the line given 19 | by the points ``x1`` and ``x2``. 20 | 21 | :param x0: a point 22 | :type x0: a 2x1 numpy array 23 | :param x1: a point of the line 24 | :type x1: 2x1 numpy array 25 | :param x2: another point of the line 26 | :type x2: 2x1 numpy array 27 | """ 28 | if x1[0] == x2[0]: 29 | return np.abs(x0[0] - x1[0]) 30 | 31 | return np.divide(np.linalg.norm(np.linalg.det([x2 - x1, x1 - x0])), 32 | np.linalg.norm(x2 - x1)) 33 | 34 | 35 | def _rdp(M, epsilon, dist): 36 | """ 37 | Simplifies a given array of points. 38 | 39 | :param M: an array 40 | :type M: Nx2 numpy array 41 | :param epsilon: epsilon in the rdp algorithm 42 | :type epsilon: float 43 | :param dist: distance function 44 | :type dist: function with signature ``f(x1, x2, x3)`` 45 | """ 46 | dmax = 0.0 47 | index = -1 48 | 49 | for i in xrange(1, M.shape[0]): 50 | d = dist(M[i], M[0], M[-1]) 51 | 52 | if d > dmax: 53 | index = i 54 | dmax = d 55 | 56 | if dmax > epsilon: 57 | r1 = rdp(M[:index + 1], epsilon) 58 | r2 = rdp(M[index:], epsilon) 59 | 60 | return np.vstack((r1[:-1], r2)) 61 | else: 62 | return np.vstack((M[0], M[-1])) 63 | 64 | 65 | def _rdp_nn(seq, epsilon, dist): 66 | """ 67 | Simplifies a given array of points. 68 | 69 | :param seq: a series of points 70 | :type seq: sequence of 2-tuples 71 | :param epsilon: epsilon in the rdp algorithm 72 | :type epsilon: float 73 | :param dist: distance function 74 | :type dist: function with signature ``f(x1, x2, x3)`` 75 | """ 76 | return rdp(np.array(seq), epsilon, dist).tolist() 77 | 78 | 79 | def rdp(M, epsilon=0, dist=pldist): 80 | """ 81 | Simplifies a given array of points. 82 | 83 | :param M: a series of points 84 | :type M: either a Nx2 numpy array or sequence of 2-tuples 85 | :param epsilon: epsilon in the rdp algorithm 86 | :type epsilon: float 87 | :param dist: distance function 88 | :type dist: function with signature ``f(x1, x2, x3)`` 89 | """ 90 | if "numpy" in str(type(M)): 91 | return _rdp(M, epsilon, dist) 92 | else: 93 | return _rdp_nn(M, epsilon, dist) 94 | 95 | -------------------------------------------------------------------------------- /external/read_imagej.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright: Luis Pedro Coelho , 2012 3 | License: MIT 4 | 5 | File has been copied from https://gist.github.com/luispedro/3437255 6 | """ 7 | 8 | from __future__ import division 9 | 10 | import numpy as np 11 | 12 | 13 | def read_roi(fileobj): 14 | ''' 15 | points = read_roi(fileobj) 16 | 17 | Read ImageJ's ROI format 18 | ''' 19 | # This is based on: 20 | # http://rsbweb.nih.gov/ij/developer/source/ij/io/RoiDecoder.java.html 21 | 22 | 23 | SPLINE_FIT = 1 24 | DOUBLE_HEADED = 2 25 | OUTLINE = 4 26 | OVERLAY_LABELS = 8 27 | OVERLAY_NAMES = 16 28 | OVERLAY_BACKGROUNDS = 32 29 | OVERLAY_BOLD = 64 30 | SUB_PIXEL_RESOLUTION = 128 31 | DRAW_OFFSET = 256 32 | 33 | 34 | pos = [4] 35 | def get8(): 36 | pos[0] += 1 37 | s = fileobj.read(1) 38 | if not s: 39 | raise IOError('readroi: Unexpected EOF') 40 | return ord(s) 41 | 42 | def get16(): 43 | b0 = get8() 44 | b1 = get8() 45 | return (b0 << 8) | b1 46 | 47 | def get32(): 48 | s0 = get16() 49 | s1 = get16() 50 | return (s0 << 16) | s1 51 | 52 | def getfloat(): 53 | v = np.int32(get32()) 54 | return v.view(np.float32) 55 | 56 | 57 | magic = fileobj.read(4) 58 | if magic != 'Iout': 59 | raise IOError('Magic number not found') 60 | version = get16() 61 | 62 | # It seems that the roi type field occupies 2 Bytes, but only one is used 63 | roi_type = get8() 64 | # Discard second Byte: 65 | get8() 66 | 67 | if not (0 <= roi_type < 11): 68 | raise ValueError('roireader: ROI type %s not supported' % roi_type) 69 | 70 | if roi_type != 7: 71 | raise ValueError('roireader: ROI type %s not supported (!= 7)' % roi_type) 72 | 73 | top = get16() 74 | left = get16() 75 | bottom = get16() 76 | right = get16() 77 | n_coordinates = get16() 78 | 79 | x1 = getfloat() 80 | y1 = getfloat() 81 | x2 = getfloat() 82 | y2 = getfloat() 83 | stroke_width = get16() 84 | shape_roi_size = get32() 85 | stroke_color = get32() 86 | fill_color = get32() 87 | subtype = get16() 88 | if subtype != 0: 89 | raise ValueError('roireader: ROI subtype %s not supported (!= 0)' % subtype) 90 | options = get16() 91 | arrow_style = get8() 92 | arrow_head_size = get8() 93 | rect_arc_size = get16() 94 | position = get32() 95 | header2offset = get32() 96 | 97 | if options & SUB_PIXEL_RESOLUTION: 98 | getc = getfloat 99 | points = np.empty((n_coordinates, 2), dtype=np.float32) 100 | else: 101 | getc = get16 102 | points = np.empty((n_coordinates, 2), dtype=np.int16) 103 | points[:,1] = [getc() for i in xrange(n_coordinates)] 104 | points[:,0] = [getc() for i in xrange(n_coordinates)] 105 | points[:,1] += left 106 | points[:,0] += top 107 | points -= 1 108 | 109 | return points 110 | 111 | 112 | -------------------------------------------------------------------------------- /video/analysis/shapes_3d.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Nov 5, 2015 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import numpy as np 10 | 11 | 12 | 13 | class Cuboid(object): 14 | """ class that represents a cuboid in n dimensions """ 15 | 16 | def __init__(self, pos, size): 17 | self.pos = np.asarray(pos) 18 | self.size = np.asarray(size) 19 | assert len(self.pos) == len(self.size) 20 | 21 | @classmethod 22 | def from_points(cls, p1, p2): 23 | p1 = np.asarray(p1) 24 | p2 = np.asarray(p2) 25 | return cls(np.minimum(p1, p2), np.abs(p1 - p2)) 26 | 27 | @classmethod 28 | def from_centerpoint(cls, centerpoint, size): 29 | centerpoint = np.asarray(centerpoint) 30 | size = np.asarray(size) 31 | return cls(centerpoint - size/2, size) 32 | 33 | def copy(self): 34 | return self.__class__(self.pos, self.size) 35 | 36 | def __repr__(self): 37 | return "%s(pos=%s, size=%s)" % (self.__class__.__name__, self.pos, 38 | self.size) 39 | 40 | def set_corners(self, p1, p2): 41 | p1 = np.asarray(p1) 42 | p2 = np.asarray(p2) 43 | self.pos = np.minimum(p1, p2) 44 | self.size = np.abs(p1 - p2) 45 | 46 | @property 47 | def bounds(self): 48 | return [(p, p + s) for p, s in zip(self.pos, self.size)] 49 | 50 | @property 51 | def corners(self): 52 | return self.pos, self.pos + self.size 53 | @corners.setter 54 | def corners(self, ps): 55 | self.set_corners(ps[0], ps[1]) 56 | 57 | @property 58 | def dimension(self): 59 | return len(self.pos) 60 | 61 | @property 62 | def slices(self): 63 | return [slice(int(p), int(p + s)) for p, s in zip(self.pos, self.size)] 64 | 65 | @property 66 | def centroid(self): 67 | return [p + s/2 for p, s in zip(self.pos, self.size)] 68 | 69 | @property 70 | def volume(self): 71 | return np.prod(self.size) 72 | 73 | 74 | def translate(self, distance=0, inplace=True): 75 | """ translates the cuboid by a certain distance in all directions """ 76 | distance = np.asarray(distance) 77 | if inplace: 78 | self.pos += distance 79 | return self 80 | else: 81 | return self.__class__(self.pos + distance, self.size) 82 | 83 | 84 | def buffer(self, amount=0, inplace=True): 85 | """ dilate the cuboid by a certain amount in all directions """ 86 | amount = np.asarray(amount) 87 | if inplace: 88 | self.pos -= amount 89 | self.size += 2*amount 90 | return self 91 | else: 92 | return self.__class__(self.pos - amount, self.size + 2*amount) 93 | 94 | 95 | def scale(self, factor=1, inplace=True): 96 | """ scale the cuboid by a certain amount in all directions """ 97 | factor = np.asarray(factor) 98 | if inplace: 99 | self.pos *= factor 100 | self.size *= factor 101 | return self 102 | else: 103 | return self.__class__(self.pos * factor, self.size * factor) 104 | 105 | 106 | -------------------------------------------------------------------------------- /external/kids_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | kids_cache 3 | ~~~~~~~~~~ 4 | 5 | kids.cache is a Python library providing a cache decorator. It's part of 'Kids' 6 | (for Keep It Dead Simple) library. It has no dependency to any python library. 7 | 8 | :copyright: (c) 2015 Valentin Lab. 9 | :license: BSD, see LICENSE_BSD.txt for more details. 10 | 11 | The code was copied from 12 | https://github.com/0k/kids.cache 13 | """ 14 | import threading 15 | import functools 16 | import collections 17 | 18 | 19 | CacheInfo = collections.namedtuple('CacheInfo', 20 | 'type hits misses maxsize currsize') 21 | 22 | 23 | def make_key(obj, typed=True): 24 | args, kwargs = obj 25 | key = (tuple(args), tuple(sorted(kwargs.items()))) 26 | if typed: 27 | key += tuple(type(v) for v in args) 28 | key += tuple(type(v) for _, v in sorted(kwargs.items())) 29 | return key 30 | 31 | 32 | def is_hashable(obj): 33 | try: 34 | hash(obj) 35 | return True 36 | except Exception: ## pylint: disable-msg=W0703 37 | return False 38 | 39 | 40 | def make_key_hippie(obj, typed=True): 41 | """Return hashable structure from non-hashable structure using hippie means 42 | dict and set are sorted and their content subjected to same hippie means. 43 | Note that the key identifies the current content of the structure. 44 | """ 45 | ftype = type if typed else lambda o: None 46 | if is_hashable(obj): 47 | ## DO NOT RETURN hash(obj), as hash collision would generate bad 48 | ## cache collisions. 49 | return obj, ftype(obj) 50 | ## should we try to convert to frozen{set,dict} to get the C 51 | ## hashing function speed ? But the convertion has a cost also. 52 | if isinstance(obj, set): 53 | obj = sorted(obj) 54 | if isinstance(obj, (list, tuple)): 55 | return tuple(make_key_hippie(e, typed) for e in obj) 56 | if isinstance(obj, dict): 57 | return tuple(sorted(((make_key_hippie(k, typed), 58 | make_key_hippie(v, typed)) 59 | for k, v in obj.items()))) 60 | raise ValueError( 61 | "%r can not be hashed. Try providing a custom key function." 62 | % obj) 63 | 64 | 65 | def hashing(typed=True, strict=False): 66 | """Returns a typed and/or strict key callable. 67 | A strict key callable will fail on traditionaly non-hashable object, 68 | while a strict=False hashing will use hippie hashing that can hash 69 | mutable object. 70 | A typed key callable will use type of each object in the hash and will 71 | distinguish with same hash but different type (example: 2 and 2.0). 72 | """ 73 | hashable_struct_producer = make_key if strict else make_key_hippie 74 | 75 | def _make_key(*args, **kwargs): 76 | ## use a list to avoid using hash of tuples... 77 | return hashable_struct_producer([list(args), kwargs], typed=typed) 78 | return _make_key 79 | 80 | 81 | SUPPORTED_DECORATOR = { 82 | property: lambda f: f.fget, 83 | classmethod: lambda f: f.__func__, 84 | staticmethod: lambda f: f.__func__, 85 | } 86 | 87 | def undecorate(func): 88 | """Returns the decorator and the undecorated function of given object.""" 89 | orig_call_wrapper = lambda x: x 90 | for call_wrapper, unwrap in SUPPORTED_DECORATOR.items(): 91 | if isinstance(func, call_wrapper): 92 | func = unwrap(func) 93 | orig_call_wrapper = call_wrapper 94 | break 95 | return orig_call_wrapper, func 96 | 97 | 98 | ## inspired by cachetools.decorators.cachedfunc 99 | def cachedfunc(cache_store, key=make_key_hippie): 100 | context = threading.RLock() ## stats lock 101 | 102 | def decorator(func): 103 | stats = [0, 0] 104 | 105 | wrapper, wrapped = undecorate(func) 106 | 107 | @functools.wraps(wrapped) 108 | def _cache_wrapper(*args, **kwargs): 109 | k = key(*args, **kwargs) 110 | with context: 111 | try: 112 | result = cache_store[k] 113 | stats[0] += 1 114 | return result 115 | except KeyError: 116 | stats[1] += 1 117 | result = wrapped(*args, **kwargs) 118 | with context: 119 | try: 120 | cache_store[k] = result 121 | except ValueError: 122 | ## Value 'too large', only casted with cachetools stores. 123 | pass 124 | return result 125 | 126 | ## mimic's python3 ``lru_cache`` facilities. 127 | 128 | def cache_info(): 129 | with context: 130 | hits, misses = stats 131 | maxsize = getattr(cache_store, "maxsize", None) 132 | currsize = getattr(cache_store, "currsize", len(cache_store)) 133 | return CacheInfo( 134 | type(cache_store).__name__, hits, misses, maxsize, currsize) 135 | 136 | def cache_clear(): 137 | with context: 138 | cache_store.clear() 139 | 140 | _cache_wrapper.cache_info = cache_info 141 | _cache_wrapper.cache_clear = cache_clear 142 | 143 | return wrapper(_cache_wrapper) 144 | 145 | return decorator 146 | 147 | 148 | def cache(*args, **kwargs): 149 | """The @cache decorator 150 | Compatibility with using ``@cache()`` and ``@cache`` is managed in 151 | the current function. 152 | """ 153 | ## only one argument ? 154 | if len(args) == 1 and len(kwargs) == 0 and \ 155 | (callable(args[0]) or \ 156 | isinstance(args[0], tuple(SUPPORTED_DECORATOR.keys()))): 157 | return _cache_w_args(args[0]) 158 | return lambda f: _cache_w_args(f, *args, **kwargs) 159 | 160 | 161 | ## No locking mecanism because this should be implemented in the Cache 162 | ## objects if needed. 163 | def _cache_w_args(f, use=None, cache_factory=dict, 164 | key=None, strict=False, typed=False): 165 | if key is None: 166 | key = hashing(strict=strict, typed=typed) 167 | if use is None: 168 | use = cache_factory() 169 | return cachedfunc(cache_store=use, key=key)(f) 170 | 171 | 172 | hippie_hashing = hashing() 173 | 174 | 175 | -------------------------------------------------------------------------------- /video/io/display.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 14, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import sys 10 | import multiprocessing as mp 11 | import logging 12 | 13 | import numpy as np 14 | 15 | import cv2 16 | 17 | try: 18 | import sharedmem 19 | except ImportError: 20 | sharedmem = None 21 | 22 | 23 | logger = logging.getLogger('video.io') 24 | 25 | 26 | def _show_image_from_pipe(pipe, image_array, title, position=None): 27 | """ function that runs in a separate process to display an image """ 28 | cv2.namedWindow(title) 29 | if position is not None: 30 | cv2.moveWindow(title, position[0], position[1]) 31 | cv2.waitKey(1) 32 | 33 | try: 34 | while True: 35 | # read next command from pipe 36 | command = pipe.recv() 37 | while pipe.poll(): 38 | command = pipe.recv() 39 | if command == 'close': 40 | break 41 | 42 | # process the last command 43 | if command == 'update': 44 | # update the image 45 | cv2.imshow(title, image_array) 46 | 47 | elif command == 'check_events': 48 | pass 49 | 50 | elif command == 'close': 51 | break 52 | 53 | else: 54 | raise ValueError('Unknown command `%s`' % command) 55 | 56 | # check whether the user wants to abort 57 | while True: 58 | # waitKey also handles other GUI events and we thus call it 59 | # until everything is handled 60 | key = cv2.waitKey(1) 61 | if key & 0xFF in {27, ord('q')}: 62 | raise KeyboardInterrupt 63 | elif key == -1: 64 | break 65 | 66 | except KeyboardInterrupt: 67 | pipe.send('interrupt') 68 | 69 | # cleanup 70 | cv2.destroyWindow(title) 71 | # work-around to handle GUI event loop 72 | for _ in xrange(10): 73 | cv2.waitKey(1) 74 | 75 | 76 | 77 | class ImageWindow(object): 78 | """ class that can show an image """ 79 | 80 | def __init__(self, size, title='', output_period=1, 81 | multiprocessing=True, position=None): 82 | """ initializes the video shower. 83 | size sets the width and the height of the image to be shown 84 | title sets the title of the window. This should be unique if multiple 85 | windows are used. 86 | output_period determines if frames are skipped during display. 87 | For instance, `output_period=10` only shows every tenth frame. 88 | multiprocessing indicates whether a separate process is used for 89 | displaying. If multiprocessing=None, multiprocessing is used 90 | for all platforms, except MacOX 91 | position determines the coordinates of the top left corner of the 92 | window that displays the image 93 | """ 94 | self.title = title 95 | self.output_period = output_period 96 | self.this_frame = 0 97 | self._proc = None 98 | 99 | # multiprocessing does not work in current MacOS OpenCV 100 | if multiprocessing is None: 101 | multiprocessing = (sys.platform != "darwin") 102 | 103 | if multiprocessing: 104 | # open 105 | if sharedmem: 106 | try: 107 | # create the pipe to talk to the child 108 | self._pipe, pipe_child = mp.Pipe(duplex=True) 109 | # setup the shared memory area 110 | self._data = sharedmem.empty(size, np.uint8) 111 | # initialize the process that shows the image 112 | self._proc = mp.Process(target=_show_image_from_pipe, 113 | args=(pipe_child, self._data, 114 | title, position)) 115 | self._proc.daemon = True 116 | self._proc.start() 117 | logger.debug('Started process %d for displaying images' % self._proc.pid) 118 | 119 | except AssertionError: 120 | logger.warn('Could not start a separate process to display images. ' 121 | 'The main process will thus be used.') 122 | 123 | else: 124 | logger.warn('Package sharedmem could not be imported and ' 125 | 'images are thus shown using the main process.') 126 | 127 | if self._proc is None: 128 | # open window in this process 129 | cv2.namedWindow(title) 130 | if position is not None: 131 | cv2.moveWindow(title, position[0], position[1]) 132 | cv2.waitKey(1) 133 | 134 | 135 | def check_gui_events(self): 136 | """ checks whether the GUI sent any events to the window. 137 | The function raises a KeyboardInterrupt if the user wants to abort """ 138 | if self._proc: 139 | # check the viewer process for events 140 | if self._pipe.poll() and self._pipe.recv() == 'interrupt': 141 | raise KeyboardInterrupt 142 | else: 143 | # check the window for events 144 | while True: 145 | # waitKey also handles other GUI events and we thus call it 146 | # until everything is handled 147 | key = cv2.waitKey(1) 148 | if key & 0xFF in {27, ord('q')}: 149 | raise KeyboardInterrupt 150 | elif key == -1: 151 | break 152 | 153 | 154 | def show(self, image=None): 155 | """ show an image. 156 | May raise KeyboardInterrupt, if the user opted to exit 157 | """ 158 | # check whether the current frame should be displayed 159 | if image is not None and (self.this_frame % self.output_period) == 0: 160 | if image.ndim > 2: 161 | # reverse the color axis, to get BGR image required by OpenCV 162 | image = image[:, :, ::-1] 163 | 164 | if self._proc: 165 | # copy data to shared memory 166 | self._data[:] = image 167 | # and tell the process to update window 168 | self._pipe.send('update') 169 | 170 | else: 171 | # update the image in our window 172 | cv2.imshow(self.title, image.astype(np.uint8)) 173 | 174 | else: 175 | # image is not shown => still poll for events 176 | if self._proc: 177 | self._pipe.send('check_events') 178 | self.check_gui_events() 179 | 180 | # keep the internal frame count up to date 181 | self.this_frame += 1 182 | 183 | 184 | def close(self): 185 | """ closes the window """ 186 | if self._proc is not None: 187 | # shut down the process 188 | self._pipe.send('close') 189 | self._proc.join() 190 | self._pipe.close() 191 | self._proc = None 192 | 193 | else: 194 | # delete the opencv window 195 | cv2.destroyWindow(self.title) 196 | # work-around to handle GUI event loop 197 | for _ in xrange(10): 198 | cv2.waitKey(1) 199 | 200 | 201 | def __del__(self): 202 | self.close() 203 | -------------------------------------------------------------------------------- /video/analysis/active_contour.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Dec 19, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import cv2 10 | import numpy as np 11 | from scipy import spatial 12 | 13 | from utils.data_structures.cache import DictFiniteCapacity 14 | import curves 15 | import image 16 | 17 | 18 | 19 | class ActiveContour(object): 20 | """ class that manages an algorithm for using active contours for edge 21 | detection [http://en.wikipedia.org/wiki/Active_contour_model] 22 | 23 | This implementation is inspired by the following articles: 24 | http://www.pagines.ma1.upc.edu/~toni/files/SnakesAivru86c.pdf 25 | http://www.cb.uu.se/~cris/blog/index.php/archives/217 26 | """ 27 | 28 | max_iterations = 50 #< maximal number of iterations 29 | max_cache_count = 20 #< maximal number of cache entries 30 | residual_tolerance = 1 #< stop iteration when reaching this residual value 31 | 32 | 33 | def __init__(self, blur_radius=10, alpha=0, beta=1e2, gamma=0.001, 34 | closed_loop=False): 35 | """ initializes the active contour model 36 | blur_radius sets the length scale of the attraction to features. 37 | As a drawback, this is also the largest feature size that can be 38 | resolved by the contour. 39 | alpha is the line tension of the contour (high alpha leads to shorter 40 | contours) 41 | beta is the stiffness of the contour (high beta leads to straighter 42 | contours) 43 | gamma is the time scale of the convergence (high gamma might lead to 44 | overshoot) 45 | closed_loop indicates whether the contour is a closed loop 46 | """ 47 | 48 | self.blur_radius = blur_radius 49 | self.alpha = float(alpha) #< line tension 50 | self.beta = float(beta) #< stiffness 51 | self.gamma = float(gamma) #< convergence rate 52 | self.closed_loop = closed_loop 53 | 54 | self.clear_cache() #< also initializes the cache 55 | self.fx = self.fy = None 56 | self.info = {} 57 | 58 | 59 | def clear_cache(self): 60 | """ clears the cache. This method should be called if any of the 61 | parameters of the model are changed """ 62 | self._Pinv_cache = DictFiniteCapacity(capacity=self.max_cache_count) 63 | 64 | 65 | def get_evolution_matrix(self, N, ds): 66 | """ calculates the evolution matrix """ 67 | # scale parameters 68 | alpha = self.alpha/ds**2 # tension ~1/ds^2 69 | beta = self.beta/ds**4 # stiffness ~ 1/ds^4 70 | 71 | # calculate matrix entries 72 | a = self.gamma*(2*alpha + 6*beta) + 1 73 | b = self.gamma*(-alpha - 4*beta) 74 | c = self.gamma*beta 75 | 76 | if self.closed_loop: 77 | # matrix for closed loop 78 | P = ( 79 | np.diag(np.zeros(N) + a) + 80 | np.diag(np.zeros(N-1) + b, 1) + np.diag( [b], -N+1) + 81 | np.diag(np.zeros(N-1) + b,-1) + np.diag( [b], N-1) + 82 | np.diag(np.zeros(N-2) + c, 2) + np.diag([c, c], -N+2) + 83 | np.diag(np.zeros(N-2) + c,-2) + np.diag([c, c], N-2) 84 | ) 85 | 86 | else: 87 | # matrix for open end with vanishing derivatives 88 | P = ( 89 | np.diag(np.zeros(N) + a) + 90 | np.diag(np.zeros(N-1) + b, 1) + 91 | np.diag(np.zeros(N-1) + b,-1) + 92 | np.diag(np.zeros(N-2) + c, 2) + 93 | np.diag(np.zeros(N-2) + c,-2) 94 | ) 95 | P[0, 1] = P[-1, -2] = 2*b 96 | P[0, 2] = P[-1, -3] = 2*c 97 | P[0, 2] = P[-1, -3] = 2*c 98 | P[1, 1] = P[-2, -2] = a + c 99 | 100 | # create inverse matrix for iteration 101 | return np.linalg.inv(P) 102 | 103 | 104 | def set_potential(self, potential): 105 | """ sets the potential and calculates the associated derivatives """ 106 | # get image gradient 107 | if self.blur_radius > 0: 108 | potential = cv2.GaussianBlur(potential, (0, 0), self.blur_radius) 109 | self.fx = cv2.Sobel(potential, cv2.CV_64F, 1, 0, ksize=5) 110 | self.fy = cv2.Sobel(potential, cv2.CV_64F, 0, 1, ksize=5) 111 | 112 | 113 | def find_contour(self, curve, anchor_x=None, anchor_y=None): 114 | """ adapts the contour given by points to the potential image 115 | anchor_x can be a list of indices for those points whose x-coordinate 116 | should be kept fixed. 117 | anchor_y is the respective argument for the y-coordinate 118 | """ 119 | if self.fx is None: 120 | raise RuntimeError('Potential must be set before the contour can ' 121 | 'be adapted.') 122 | 123 | # curve must be equidistant for this implementation to work 124 | curve = np.asarray(curve) 125 | points = curves.make_curve_equidistant(curve) 126 | 127 | # check for marginal small cases 128 | if len(points) <= 2: 129 | return points 130 | 131 | def _get_anchors(indices, coord): 132 | """ helper function for determining the anchor points """ 133 | if indices is None or len(indices) == 0: 134 | return tuple(), tuple() 135 | # get points where the coordinate `coord` has to be kept fixed 136 | ps = curve[indices, :] 137 | # find the points closest to the anchor points 138 | dist = spatial.distance.cdist(points, ps) 139 | return np.argmin(dist, axis=0), ps[:, coord] 140 | 141 | # determine anchor_points if requested 142 | if anchor_x is not None or anchor_y is not None: 143 | has_anchors = True 144 | x_idx, x_vals = _get_anchors(anchor_x, 0) 145 | y_idx, y_vals = _get_anchors(anchor_y, 1) 146 | else: 147 | has_anchors = False 148 | 149 | # determine point spacing if it is not given 150 | ds = curves.curve_length(points)/(len(points) - 1) 151 | 152 | # try loading the evolution matrix from the cache 153 | cache_key = (len(points), ds) 154 | Pinv = self._Pinv_cache.get(cache_key, None) 155 | if Pinv is None: 156 | # add new item to cache 157 | Pinv = self.get_evolution_matrix(len(points), ds) 158 | self._Pinv_cache[cache_key] = Pinv 159 | 160 | # restrict control points to shape of the potential 161 | points[:, 0] = np.clip(points[:, 0], 0, self.fx.shape[1] - 2) 162 | points[:, 1] = np.clip(points[:, 1], 0, self.fx.shape[0] - 2) 163 | 164 | # create intermediate array 165 | points_initial = points.copy() 166 | ps = points.copy() 167 | 168 | for k in xrange(self.max_iterations): 169 | # calculate external force 170 | fex = image.subpixels(self.fx, points) 171 | fey = image.subpixels(self.fy, points) 172 | 173 | # move control points 174 | ps[:, 0] = np.dot(Pinv, points[:, 0] + self.gamma*fex) 175 | ps[:, 1] = np.dot(Pinv, points[:, 1] + self.gamma*fey) 176 | 177 | # enforce the position of the anchor points 178 | if has_anchors: 179 | ps[x_idx, 0] = x_vals 180 | ps[y_idx, 1] = y_vals 181 | 182 | # check the distance that we evolved 183 | residual = np.abs(ps - points).sum() 184 | 185 | # restrict control points to shape of the potential 186 | points[:, 0] = np.clip(ps[:, 0], 0, self.fx.shape[1] - 2) 187 | points[:, 1] = np.clip(ps[:, 1], 0, self.fx.shape[0] - 2) 188 | 189 | if residual < self.residual_tolerance * self.gamma: 190 | break 191 | 192 | # collect additional information 193 | self.info['iteration_count'] = k + 1 194 | self.info['total_variation'] = np.abs(points_initial - points).sum() 195 | 196 | return points 197 | 198 | -------------------------------------------------------------------------------- /video/analysis/curves.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 11, 2014 3 | 4 | @author: David Zwicker 5 | 6 | contains functions that are useful for curve analysis 7 | ''' 8 | 9 | from __future__ import division 10 | 11 | import itertools 12 | import math 13 | import numpy as np 14 | from scipy import interpolate, odr 15 | 16 | import cv2 17 | from shapely import geometry 18 | 19 | import shapes 20 | 21 | # make simplify_curve available under current scope 22 | from external.simplify_polygon_rdp import rdp as simplify_curve # @UnusedImport 23 | 24 | 25 | 26 | def point_distance(p1, p2): 27 | """ calculates the distance between point p1 and p2 """ 28 | return math.hypot(p1[0] - p2[0], p1[1] - p2[1]) 29 | 30 | 31 | 32 | def angle_between_points(p1, p2, p3): 33 | """ calculates the angle at p2 of the line given by the three points """ 34 | ps = np.array([p1, p2, p3]) 35 | d12 = ps[1] - ps[0] 36 | d23 = ps[2] - ps[1] 37 | # use dot product to get the angle 38 | denom = np.linalg.norm(d12)*np.linalg.norm(d23) 39 | if denom == 0: 40 | angle = np.nan 41 | else: 42 | arg = np.dot(d12, d23)/denom 43 | try: 44 | angle = math.acos(arg) 45 | except ValueError: 46 | # raised, when argument is not in [-1, 1] 47 | # => we just extrapolate the value at the boundary 48 | angle = 0 if arg > 0 else math.pi 49 | return angle 50 | 51 | 52 | 53 | def translate_points(points, xoff, yoff): 54 | """ translate points by a certain offset """ 55 | if isinstance(points, np.ndarray): 56 | # handle numpy array 57 | offset = np.array([xoff, yoff]) 58 | return points + offset[..., :] 59 | 60 | else: 61 | # use simple list comprehension 62 | return [(p[0] + xoff, p[1] + yoff) for p in points] 63 | 64 | 65 | 66 | def curve_length(points): 67 | """ returns the total arc length of a curve defined by a number of points """ 68 | if len(points) < 2: 69 | return 0 70 | else: 71 | return cv2.arcLength(np.asarray(points, np.single), False) 72 | # return sum(math.hypot(p1[0] - p2[0], p1[1] - p2[1]) 73 | # for p1, p2 in itertools.izip(points, points[1:])) 74 | # Note that a vectorized numpy version using np.diff and np.hypot or using 75 | # np.linalg.norm is considerably slower for the typical short lists that 76 | # are encountered here. 77 | 78 | 79 | 80 | def curve_segment_lengths(points): 81 | """ returns the length of all segments of a curve """ 82 | dp = np.diff(points, axis=0) 83 | return np.hypot(dp[:, 0], dp[:, 1]) 84 | 85 | 86 | 87 | def merge_curves(points1, points2): 88 | """ merges two curves that touch each other """ 89 | if np.allclose(points1[-1], points2[0]): 90 | return np.r_[points1, points2] 91 | elif np.allclose(points1[0], points2[0]): 92 | return np.r_[points1[::-1], points2] 93 | elif np.allclose(points1[0], points2[-1]): 94 | return np.r_[points1[::-1], points2[::-1]] 95 | elif np.allclose(points1[-1], points2[-1]): 96 | return np.r_[points1, points2[::-1]] 97 | else: 98 | raise ValueError('The two curves do not touch each other at their end ' 99 | 'points') 100 | 101 | 102 | 103 | def make_curve_equidistant(points, spacing=None, count=None): 104 | """ returns a new parameterization of the same curve where points have been 105 | chosen equidistantly. The original curve may be slightly modified """ 106 | points = np.asarray(points, np.double) 107 | 108 | if spacing is not None: 109 | # walk along and pick points with given spacing 110 | profile_length = curve_length(points) 111 | if profile_length < spacing: 112 | return points 113 | 114 | dx = profile_length/np.round(profile_length/spacing) 115 | dist = 0 116 | result = [points[0]] 117 | for p1, p2 in itertools.izip(points[:-1], points[1:]): 118 | # determine the distance between the last two points 119 | dp = np.linalg.norm(p2 - p1) 120 | # add points to the result list 121 | while dist + dp > dx: 122 | p1 = p1 + (dx - dist)/dp*(p2 - p1) 123 | result.append(p1.copy()) 124 | dp = np.linalg.norm(p2 - p1) 125 | dist = 0 126 | 127 | # add the remaining distance 128 | dist += dp 129 | 130 | # add the last point if necessary 131 | if dist > 1e-8: 132 | result.append(points[-1]) 133 | 134 | else: 135 | if count is None: 136 | count = len(points) 137 | 138 | # get arc length of support points 139 | s = np.cumsum([point_distance(p1, p2) 140 | for p1, p2 in itertools.izip(points, points[1:])]) 141 | s = np.insert(s, 0, 0) # prepend element for first point 142 | # divide arc length equidistantly 143 | sp = np.linspace(s[0], s[-1], count) 144 | # interpolate points 145 | result = np.transpose((np.interp(sp, s, points[:, 0]), 146 | np.interp(sp, s, points[:, 1]))) 147 | 148 | return result 149 | 150 | 151 | 152 | def get_projection_point(line, point): 153 | """ determines the point on the line closest to `point` """ 154 | point = geometry.Point(point) 155 | line = geometry.LineString(line) 156 | point = line.interpolate(line.project(point)) 157 | return (point.x, point.y) 158 | 159 | 160 | 161 | def average_normalized_functions(profiles): 162 | """ averages functions defined on the interval [0, 1] """ 163 | len_max = max(len(ps) for ps in profiles) 164 | xs = np.linspace(0, 1, len_max) 165 | ys = np.mean([np.interp(xs, ps[:, 0], ps[:, 1]) 166 | for ps in profiles], axis=0) 167 | return np.c_[xs, ys] 168 | 169 | 170 | 171 | def smooth_curve(points, smoothing=10, degree=3, derivative=0, num_points=None): 172 | """ smooth a curve by interpolating the points 173 | `smoothing` determines the smoothness of the curve. This value can be used 174 | to control the trade-off between closeness and smoothness of fit. 175 | Larger values means more smoothing while smaller values indicate less 176 | smoothing. The resulting, smoothed yi fulfill 177 | sum((y - yi)**2, axis=0) <= smoothing*len(points) 178 | `degree` determines the degree of the splines used 179 | `derivative` determines the order of the derivative 180 | `num_points` determines how many support points are used. If this value is 181 | None, len(points) are used. 182 | """ 183 | if num_points is None: 184 | num_points = len(points) 185 | 186 | u = np.linspace(0, 1, num_points) 187 | try: 188 | # do spline fitting to smooth the line 189 | tck, _ = interpolate.splprep(np.transpose(points), u=u, k=degree, 190 | s=smoothing*len(points)) 191 | except ValueError: 192 | # spline fitting did not work 193 | if num_points != len(points): 194 | points = make_curve_equidistant(points, count=num_points) 195 | else: 196 | # interpolate the line 197 | points = interpolate.splev(u, tck, der=derivative) 198 | points = zip(*points) #< transpose list 199 | 200 | return np.asarray(points) 201 | 202 | 203 | 204 | def fit_circle(points): 205 | """ 206 | fits a circle to the given points. The method has been adapted from 207 | http://wiki.scipy.org/Cookbook/Least_Squares_Circle 208 | The function returns an instance of Circle 209 | """ 210 | def calc_dist(xc, yc): 211 | """ calculate the distance of each point from the center (xc, yc) """ 212 | return np.linalg.norm(points - np.array([[xc, yc]]), axis=0) 213 | 214 | def circle_implicit(beta, x): 215 | """ implicit definition of the circle """ 216 | return (x[0] - beta[0])**2 + (x[1] - beta[1])**2 - beta[2]**2 217 | 218 | # coordinates of the bary center 219 | x_m, y_m = np.mean(points, axis=0) 220 | 221 | # initial guess for parameters 222 | R_m = calc_dist(x_m, y_m).mean() 223 | beta0 = [x_m, y_m, R_m] 224 | 225 | # for implicit function : 226 | # data.x contains both coordinates of the points (data.x = [x, y]) 227 | # data.y is the dimensionality of the response 228 | lsc_data = odr.Data(points.T, y=1) 229 | lsc_model = odr.Model(circle_implicit, implicit=True) 230 | lsc_odr = odr.ODR(lsc_data, lsc_model, beta0) 231 | lsc_out = lsc_odr.run() 232 | 233 | # collect result 234 | xc, yc, R = lsc_out.beta 235 | return shapes.Circle(xc, yc, R) 236 | 237 | -------------------------------------------------------------------------------- /video/io/backend_opencv.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Jul 31, 2014 3 | 4 | @author: David Zwicker 5 | 6 | This package provides class definitions for referencing a single video file. 7 | The video is loaded using OpenCV. 8 | ''' 9 | 10 | from __future__ import division 11 | 12 | import os.path 13 | import platform 14 | import logging 15 | 16 | import cv2 17 | 18 | from .base import VideoBase, VideoImageStackBase 19 | 20 | logger = logging.getLogger('video.io') 21 | 22 | 23 | # dictionary that maps standard file endings to fourcc codes 24 | # more codes can be found at http://www.fourcc.org/codecs.php 25 | if platform.system() == 'Darwin': 26 | CODECS = { 27 | '.xvid': 'XVID', 28 | '.mov': 'mp4v', # standard quicktime codec - tested 29 | '.mpeg': 'FMP4', # mpeg 4 variant 30 | '.avi': 'IYUV', # uncompressed avi - tested 31 | } 32 | else: 33 | CODECS = { 34 | '.xvid': 'XVID', 35 | '.mov': 'mp4v', #'SVQ3', # standard quicktime codec 36 | '.mpeg': 'FMP4', # mpeg 4 variant 37 | '.avi': 'IYUV', # uncompressed avi 38 | } 39 | 40 | 41 | 42 | class VideoOpenCV(VideoBase): 43 | """ 44 | Class handling a single movie file using OpenCV 45 | """ 46 | 47 | seekable = True #< this video is seekable 48 | 49 | def __init__(self, filename, parameters=None): 50 | """ load the video from filename `filename` """ 51 | if parameters: 52 | self.parameters = parameters 53 | else: 54 | self.parameters = {} 55 | 56 | # load the video 57 | self.filename = os.path.expanduser(filename) 58 | 59 | self._movie = cv2.VideoCapture(self.filename) 60 | # this call doesn't fail if the file could not be found, but returns 61 | # an empty video instead. We thus fail later by checking the video length 62 | 63 | # determine _movie properties 64 | size = (int(self._movie.get(cv2.CAP_PROP_FRAME_WIDTH)), 65 | int(self._movie.get(cv2.CAP_PROP_FRAME_HEIGHT))) 66 | frame_count = int(self._movie.get(cv2.CAP_PROP_FRAME_COUNT)) 67 | fps = self._movie.get(cv2.CAP_PROP_FPS) 68 | self.pix_fmt = 'bgr' #< seems to be OpenCV default 69 | 70 | if frame_count == 0: 71 | raise IOError('There were problems loading the video.') 72 | 73 | # rewind _movie 74 | self.set_frame_pos(0) 75 | 76 | super(VideoOpenCV, self).__init__(size=size, frame_count=frame_count, 77 | fps=fps, is_color=True) 78 | 79 | logger.debug('Initialized video `%s` with %d frames using OpenCV', 80 | filename, frame_count) 81 | 82 | 83 | @property 84 | def closed(self): 85 | return self._movie is None 86 | 87 | 88 | def open(self): 89 | """ Opens the video file """ 90 | logger.debug('Open video `%s`' % self.filename) 91 | self.close() #< make sure that the previous video is closed 92 | self._movie = cv2.VideoCapture(self.filename) 93 | 94 | 95 | def get_frame_pos(self): 96 | """ returns the 0-based index of the next frame """ 97 | return int(self._movie.get(cv2.CAP_PROP_POS_FRAMES)) 98 | 99 | 100 | def set_frame_pos(self, index): 101 | """ sets the 0-based index of the next frame """ 102 | if index < 0: 103 | index += self.frame_count 104 | 105 | frame_pos = self.get_frame_pos() 106 | 107 | if index < frame_pos and not self.seekable: 108 | # reopen the video to be able to seek forward 109 | self.open() 110 | frame_pos = self.get_frame_pos() 111 | 112 | if index > frame_pos: 113 | # OpenCV seeking is not exact to the frame 114 | # => we seek about 1 sec before the frame ... 115 | if index > frame_pos + self.fps: 116 | self._movie.set(cv2.CAP_PROP_POS_FRAMES, index - self.fps) 117 | 118 | # ... and iterate through the remaining frames 119 | for _ in xrange(self.get_frame_pos(), index): 120 | self._movie.grab() 121 | 122 | # just double check that we are at the right frame 123 | if self.get_frame_pos() != index: 124 | raise IndexError('Seeking to frame %d was not possible. The ' 125 | 'video is at frame %d.' 126 | % (index, self.get_frame_pos())) 127 | 128 | 129 | def get_next_frame(self): 130 | """ returns the next frame """ 131 | # get the next frame, which automatically increments the internal index 132 | ret, frame = self._movie.read() 133 | 134 | if ret: 135 | return frame 136 | else: 137 | # reading the data failed for whatever reason 138 | raise StopIteration 139 | 140 | 141 | def get_frame(self, index): 142 | """ 143 | returns a specific frame identified by its index. 144 | Note that this sets the internal frame index of the video and this 145 | function should thus not be used while iterating over the video. 146 | """ 147 | self.set_frame_pos(index) 148 | 149 | # get the next frame, which also increments the internal frame index 150 | ret, frame = self._movie.read() 151 | 152 | if ret: 153 | return frame 154 | else: 155 | # reading the data failed for whatever reason 156 | raise IndexError('OpenCV could not read frame.') 157 | 158 | 159 | def close(self): 160 | if self._movie: 161 | self._movie.release() 162 | self._movie = None 163 | 164 | 165 | def __enter__(self): 166 | return self 167 | 168 | 169 | def __exit__(self, e_type, e_value, e_traceback): 170 | self.close() 171 | 172 | 173 | def __del__(self): 174 | self.close() 175 | 176 | 177 | 178 | class VideoImageStackOpenCV(VideoImageStackBase): 179 | """ class that loads a stack of images using opencv """ 180 | 181 | seekable = True 182 | 183 | def get_frame(self, index): 184 | return cv2.imread(self.filenames[index]) 185 | 186 | 187 | 188 | def show_video_opencv(video): 189 | """ shows a video using opencv """ 190 | for frame in video: 191 | # Display the resulting frame 192 | cv2.imshow('frame', frame) 193 | if cv2.waitKey(100) & 0xFF == ord('q'): 194 | break 195 | 196 | cv2.destroyAllWindows() 197 | 198 | 199 | 200 | class VideoWriterOpenCV(object): 201 | def __init__(self, filename, size, fps, is_color=True, codec=None, 202 | **kwargs): 203 | """ 204 | Saves the video to the file indicated by filename. 205 | codec must be a fourcc code from http://www.fourcc.org/codecs.php 206 | If codec is None, the code is determined from the filename extension 207 | """ 208 | self.filename = os.path.expanduser(filename) 209 | self.size = size 210 | self.is_color = is_color 211 | self.frames_written = 0 212 | 213 | if codec is None: 214 | # detect format from file ending 215 | file_ext = os.path.splitext(filename)[1].lower() 216 | try: 217 | codec = CODECS[file_ext] 218 | except KeyError: 219 | raise ValueError('Video format `%s` is unsupported.' % codec) 220 | 221 | # get the code defining the video format 222 | fourcc = cv2.VideoWriter_fourcc(*codec) 223 | self._writer = cv2.VideoWriter(self.filename, fourcc=fourcc, fps=fps, 224 | frameSize=(size[1], size[0]), 225 | isColor=is_color) 226 | 227 | logger.info('Start writing video `%s` with codec `%s`', 228 | self.filename, codec) 229 | 230 | 231 | @property 232 | def shape(self): 233 | """ returns the shape of the data describing the movie """ 234 | shape = (self.size[1], self.size[0]) 235 | if self.is_color: 236 | shape += (3,) 237 | return shape 238 | 239 | 240 | def write_frame(self, frame): 241 | self._writer.write(cv2.convertScaleAbs(frame)) 242 | self.frames_written += 1 243 | 244 | 245 | def close(self): 246 | self._writer.release() 247 | logger.info('Wrote video to file `%s`', self.filename) 248 | 249 | 250 | def __enter__(self): 251 | return self 252 | 253 | 254 | def __exit__(self, e_type, e_value, e_traceback): 255 | self.close() 256 | 257 | 258 | def __del__(self): 259 | self.close() 260 | 261 | -------------------------------------------------------------------------------- /video/io/file.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Jul 31, 2014 3 | 4 | @author: David Zwicker 5 | 6 | This package provides class definitions for describing videos 7 | that are based on a single file or on several files. 8 | ''' 9 | 10 | from __future__ import division 11 | 12 | import os 13 | import glob 14 | import itertools 15 | import logging 16 | 17 | from .base import VideoBase 18 | from .backend_opencv import (show_video_opencv, VideoWriterOpenCV, VideoOpenCV, 19 | VideoImageStackOpenCV) 20 | from .backend_ffmpeg import (FFMPEG_BINARY, VideoFFmpeg, VideoWriterFFmpeg) 21 | 22 | logger = logging.getLogger('video.io') 23 | 24 | # set default handlers 25 | show_video = show_video_opencv 26 | VideoImageStack = VideoImageStackOpenCV 27 | 28 | if FFMPEG_BINARY is not None: 29 | VideoFile = VideoFFmpeg 30 | VideoFileWriter = VideoWriterFFmpeg 31 | else: 32 | VideoFile = VideoOpenCV 33 | VideoFileWriter = VideoWriterOpenCV 34 | 35 | 36 | 37 | def load_any_video(video_filename_pattern, parameters=None): 38 | """ loads either a video file or a video file stack, depending 39 | on the video_filename_pattern supplied """ 40 | if any(c in video_filename_pattern for c in r'*?%'): 41 | # contains placeholder => load multiple videos 42 | return VideoFileStack(video_filename_pattern, keep_files_open=False, 43 | parameters=parameters) 44 | else: 45 | # no placeholder => load single video 46 | return VideoFile(video_filename_pattern, parameters=parameters) 47 | 48 | 49 | 50 | def write_video(video, filename, **kwargs): 51 | """ 52 | Saves the video to the file indicated by filename. 53 | The extra arguments determine the codec used and similar parameters. 54 | The accepted values depend on the backend chosen for the video writer. 55 | """ 56 | 57 | # initialize the video writer 58 | with VideoFileWriter(filename, size=video.size, fps=video.fps, 59 | is_color=video.is_color, **kwargs) as writer: 60 | 61 | # write out all individual frames 62 | for frame in video: 63 | # convert the data to uint8 before writing it out 64 | writer.write_frame(frame) 65 | 66 | 67 | 68 | class VideoFileStack(VideoBase): 69 | """ 70 | Class handling a video distributed over several files. 71 | The filenames must contain consecutive numbers 72 | """ 73 | 74 | def __init__(self, filename_scheme='%d', index_start=1, index_end=None, 75 | video_file_class=VideoFile, keep_files_open=True, 76 | parameters=None): 77 | """ 78 | initialize the VideoFileStack. 79 | 80 | A list of videos is found using a filename pattern, where two 81 | alternative patterns are supported: 82 | 1) Using linux globs, i.e. placeholders * and ? in normal files 83 | 2) Using enumeration, where %d is replaced by consecutive integers 84 | For the second method, the start and end of the running index can 85 | be determined using index_start and index_end. 86 | 87 | video_file_class determines the class with which videos are loaded 88 | keep_files_open determines whether all files are kept open at all times. 89 | Otherwise only the file that is currently needed is opened. This 90 | can result in severe performance penalties if frames are accessed 91 | randomly. Conversely, keeping only one file open at a time can 92 | increase robustness. 93 | """ 94 | 95 | # initialize the list containing all the files 96 | self._videos = [] 97 | # register at what frame_count the video start 98 | self._offsets = [] 99 | # internal pointer to the current video from which to take a frame 100 | self._video_pos = 0 101 | 102 | # get parameters from video_file_class and the ones given here 103 | self.parameters = video_file_class.parameters_default.copy() 104 | if parameters: 105 | self.parameters.update(parameters) 106 | 107 | self.keep_files_open = keep_files_open 108 | 109 | # find all files that have to be considered 110 | if '*' in filename_scheme or '?' in filename_scheme: 111 | logger.debug('Using glob module to locate files.') 112 | filenames = sorted(glob.glob(filename_scheme)) 113 | 114 | elif r'%' in filename_scheme: 115 | logger.debug('Iterating over possible filenames to find videos.') 116 | 117 | # determine over which indices we have to iterate 118 | if index_end is None: 119 | indices = itertools.count(index_start) 120 | else: 121 | indices = xrange(index_start, index_end+1) 122 | 123 | filenames = [] 124 | for index in indices: 125 | filename = filename_scheme % index 126 | 127 | # append filename to list if file is readable 128 | if os.path.isfile(filename) and os.access(filename, os.R_OK): 129 | filenames.append(filename) 130 | else: 131 | break 132 | 133 | else: 134 | logger.warn('It seems as the filename scheme refers to a single ' 135 | 'file.') 136 | filenames = [filename_scheme] 137 | 138 | if not filenames: 139 | raise IOError('Could not find any files matching the pattern `%s`' 140 | % filename_scheme) 141 | 142 | # load all the files that have been found 143 | self.seekable = True 144 | frame_count = 0 145 | last_video = None 146 | for filename in filenames: 147 | 148 | # try to load the video with given index 149 | try: 150 | video = video_file_class(filename, parameters=self.parameters) 151 | except IOError: 152 | raise IOError('Could not read video `%s`' % filename) 153 | continue 154 | 155 | # compare its format to the previous videos 156 | if last_video: 157 | if video.fps != last_video.fps: 158 | raise ValueError('The frame rates of two videos differ') 159 | if video.size != last_video.size: 160 | raise ValueError('The sizes of two videos differ') 161 | if video.is_color != last_video.is_color: 162 | raise ValueError('The color formats of two videos differ') 163 | 164 | # set parameters of the video 165 | if self.parameters: 166 | video.parameters = self.parameters 167 | 168 | # calculate at which frame this video starts 169 | self._offsets.append(frame_count) 170 | frame_count += video.frame_count 171 | 172 | # save the video in the list 173 | self._videos.append(video) 174 | 175 | # check whether this video is seekable 176 | if not video.seekable: 177 | self.seekable = False 178 | 179 | logger.info('Found video `%s`', video.filename) 180 | 181 | if not self.keep_files_open: 182 | video.close() 183 | 184 | if not self._videos: 185 | raise RuntimeError('Could not load any videos') 186 | 187 | super(VideoFileStack, self).__init__(size=video.size, 188 | frame_count=frame_count, 189 | fps=video.fps, 190 | is_color=video.is_color) 191 | 192 | 193 | @property 194 | def filecount(self): 195 | return len(self._videos) 196 | 197 | 198 | def get_property_list(self): 199 | return super(VideoFileStack, self).get_property_list() \ 200 | + ('filecount=%s' % self.filecount,) 201 | 202 | 203 | def get_video_index(self, frame_index): 204 | """ returns the video and local frame_index to which a certain frame 205 | belongs """ 206 | 207 | for video_index, video_start in enumerate(self._offsets): 208 | if frame_index < video_start: 209 | video_index -= 1 210 | break 211 | 212 | return video_index, frame_index - self._offsets[video_index] 213 | 214 | 215 | def set_frame_pos(self, index): 216 | """ sets the 0-based index of the next frame. 217 | This opens a potentially closed video and keeps it open afterwards. 218 | This also rewinds all successive open videos. 219 | """ 220 | if index < 0: 221 | index += self.frame_count 222 | 223 | # set the frame position 224 | super(VideoFileStack, self).set_frame_pos(index) 225 | 226 | # identify the video that frame belongs to 227 | self._video_pos, frame_index = self.get_video_index(index) 228 | 229 | video = self._videos[self._video_pos] 230 | if video.closed: 231 | video.open() 232 | video.set_frame_pos(frame_index) 233 | 234 | # rewind all subsequent _videos, because we cannot start iterating 235 | # from the current position of the video 236 | for video in self._videos[self._video_pos + 1:]: 237 | if not video.closed: 238 | video.set_frame_pos(0) 239 | 240 | 241 | def get_next_frame(self): 242 | """ returns the next frame in the video stack """ 243 | 244 | # iterate until all _videos are exhausted 245 | while True: 246 | try: 247 | # return next frame 248 | video = self._videos[self._video_pos] 249 | if video.closed: 250 | video.open() 251 | frame = video.get_next_frame() 252 | break 253 | 254 | except StopIteration: 255 | # if video is exhausted, step to next video 256 | if not self.keep_files_open: 257 | self._videos[self._video_pos].close() 258 | self._video_pos += 1 259 | 260 | except IndexError: 261 | # if the next video does not exist, stop the iteration 262 | raise StopIteration 263 | 264 | # step to the next frame 265 | self._frame_pos += 1 266 | return frame 267 | 268 | 269 | def get_frame(self, index): 270 | """ returns a specific frame identified by its index """ 271 | if index < 0: 272 | index += self.frame_count 273 | 274 | video_index, frame_index = self.get_video_index(index) 275 | video = self._videos[video_index] 276 | if video.closed: 277 | video.open() 278 | frame = video.get_frame(frame_index) 279 | if not self.keep_files_open: 280 | video.close() 281 | return frame 282 | 283 | 284 | def close(self): 285 | for video in self._videos: 286 | video.close() 287 | -------------------------------------------------------------------------------- /external/simplify_polygon_visvalingam.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | This script is to created to simplify shapefile geometry using the 4 | Visvalingam algorithm found here 5 | http://www2.dcs.hull.ac.uk/CISRG/publications/DPs/DP10/DP10.html 6 | 7 | Threshold is the area of the largest allowed triangle 8 | 9 | This code was copied from https://github.com/ARSimmons/Shapely_Fiona_Visvalingam_Simplify 10 | """ 11 | 12 | __author__ = 'asimmons' 13 | 14 | 15 | from shapely.geometry import Polygon, MultiPolygon, LineString, MultiLineString 16 | import heapq 17 | from shapely.geometry.polygon import LinearRing 18 | 19 | 20 | class TriangleCalculator(object): 21 | def __init__(self, point, index): 22 | # Need to add better validation 23 | 24 | # Save instance variables 25 | self.point = point 26 | self.ringIndex = index 27 | self.prevTriangle = None 28 | self.nextTriangle = None 29 | 30 | # enables the instantiation of 'TriangleCalculator' to be compared 31 | # by the calcArea(). 32 | def __cmp__(self, other): 33 | return cmp(self.calcArea(), other.calcArea()) 34 | 35 | ## calculate the effective area of a triangle given 36 | ## its vertices -- using the cross product 37 | def calcArea(self): 38 | # Add validation 39 | if not self.prevTriangle or not self.nextTriangle: 40 | print "ERROR:" 41 | 42 | p1 = self.point 43 | p2 = self.prevTriangle.point 44 | p3 = self.nextTriangle.point 45 | area = abs(p1[0] * (p2[1] - p3[1]) + p2[0] * (p3[1] - p1[1]) + p3[0] * (p1[1] - p2[1])) / 2.0 46 | #print "area = " + str(area) + ", point = " + str(self.point) 47 | return area 48 | 49 | 50 | 51 | def simplify_line(line, threshold): 52 | # unlike rings: we need to keep beginning and end points static throughout the simplification process 53 | 54 | 55 | # Build list of Triangles from the line points 56 | triangleArray = [] 57 | ## each triangle contains an index and a point (x,y) 58 | # handle line 'interior' (i.e. the vertices 59 | # between start and end) first -- explicitly 60 | # defined using the below slice notation 61 | # i.e. [1:-1] 62 | for index, point in enumerate(line.coords[1:-1]): 63 | triangleArray.append(TriangleCalculator(point, index)) 64 | 65 | # then create start/end points separate from the triangleArray (meaning 66 | # we cannot have the start/end points included in the heap sort) 67 | startIndex = 0 68 | endIndex = len(line.coords)-1 69 | startTriangle = TriangleCalculator(line.coords[startIndex], startIndex) 70 | endTriangle = TriangleCalculator(line.coords[endIndex], endIndex) 71 | 72 | # Hook up triangles with next and prev references (doubly-linked list) 73 | # NOTE: linked list are composed of nodes, which have at 74 | # least one link to another node (and this is a doubly-linked list..pointing at 75 | # both our prevTriangle & our nextTriangle) 76 | # NOTE: in this code block the 'triangle' is our 'triangle node' 77 | 78 | for index, triangle in enumerate(triangleArray): 79 | # set prevIndex to be the adjacent point to index 80 | prevIndex = index - 1 81 | nextIndex = index + 1 82 | 83 | if prevIndex >= 0: 84 | triangle.prevTriangle = triangleArray[prevIndex] 85 | else: 86 | triangle.prevTriangle = startTriangle 87 | 88 | if nextIndex < len(triangleArray): 89 | triangle.nextTriangle = triangleArray[nextIndex] 90 | else: 91 | triangle.nextTriangle = endTriangle 92 | 93 | # Build a min-heap from the TriangleCalculator list 94 | # print "heapify" 95 | heapq.heapify(triangleArray) 96 | 97 | 98 | # Simplify steps... 99 | 100 | 101 | # Note: in contrast 102 | # to our function 'simplify_ring' 103 | # we can allow our array to go down to 0 and STILL have a valid line 104 | # because we will still have the start and end points 105 | while len(triangleArray) > 0: 106 | # if the smallest triangle is greater than the threshold, we can stop 107 | # i.e. loop to point where the heap head is >= threshold 108 | if triangleArray[0].calcArea() >= threshold: 109 | #print "break" 110 | break 111 | else: 112 | # print statement for debugging - prints area's and coords of deleted/simplified pts 113 | #print "simplify...triangle area's and their corresponding points that were less then the threshold" 114 | #print "area = " + str(triangleArray[0].calcArea()) + ", point = " + str(triangleArray[0].point) 115 | tri_prev = triangleArray[0].prevTriangle 116 | tri_next = triangleArray[0].nextTriangle 117 | tri_prev.nextTriangle = tri_next 118 | tri_next.prevTriangle = tri_prev 119 | # This has to be done after updating the linked list 120 | # in order for the areas to be correct when the 121 | # heap re-sorts 122 | # print "popping (i.e. re-measuring area & comparing)" 123 | heapq.heappop(triangleArray) 124 | #print "area = " + str(triangle.calcArea()) + ", point = " + str(triangle.point) 125 | #print "done popping (i.e. area that is less than threshold, and will have point removed)" 126 | 127 | # Create an list of indices from the triangleRing heap 128 | indexList = [] 129 | for triangle in triangleArray: 130 | # add 1 b/c the triangle array's first index is actually the second point 131 | indexList.append(triangle.ringIndex + 1) 132 | # Append start and end points back into the array 133 | indexList.append(startTriangle.ringIndex) 134 | indexList.append(endTriangle.ringIndex) 135 | 136 | # Sort the index list 137 | indexList.sort() 138 | 139 | # Create a new simplified ring 140 | simpleLine = [] 141 | for index in indexList: 142 | simpleLine.append(line.coords[index]) 143 | 144 | # Convert list into LineString 145 | simpleLine = LineString(simpleLine) 146 | 147 | # print statements for debugging to check if points are being reduced... 148 | #print "Starting size (incl. beginning/end point): " + str(len(line.coords)) 149 | #print "Ending size (incl. beginning/end point): " + str(len(simpleLine.coords)) 150 | #print "Starting Coord: " + str(line.coords[startIndex]) 151 | #print "End Coord: " + str(line.coords[endIndex]) 152 | #print list(simpleLine.coords) 153 | return simpleLine 154 | 155 | 156 | def simplify_ring(ring, threshold): 157 | 158 | # Build list of TriangleCalculators 159 | triangleRing = [] 160 | ## each triangle contains an index and a point (x,y) 161 | ## because rings have a point on top of a point 162 | ## we are skipping the last point by using slice notation[:-1] 163 | ## *i.e. 'a[:-1]' # everything except the last item* 164 | for index, point in enumerate(ring.coords[:-1]): 165 | triangleRing.append(TriangleCalculator(point, index)) 166 | 167 | # Hook up triangles with next and prev references (doubly-linked list) 168 | for index, triangle in enumerate(triangleRing): 169 | # set prevIndex to be the adjacent point to index 170 | # these steps are necessary for dealing with 171 | # closed rings 172 | prevIndex = index - 1 173 | if prevIndex < 0: 174 | # if prevIndex is less than 0, then it means index = 0, and 175 | # the prevIndex is set to last value in the index 176 | # (i.e. adjacent to index[0]) 177 | prevIndex = len(triangleRing) - 1 178 | # set nextIndex adjacent to index 179 | nextIndex = index + 1 180 | if nextIndex == len(triangleRing): 181 | # if nextIndex is equivalent to the length of the array 182 | # set nextIndex to 0 183 | nextIndex = 0 184 | triangle.prevTriangle = triangleRing[prevIndex] 185 | triangle.nextTriangle = triangleRing[nextIndex] 186 | 187 | # Build a min-heap from the TriangleCalculator list 188 | heapq.heapify(triangleRing) 189 | 190 | # Simplify 191 | while len(triangleRing) > 2: 192 | # if the smallest triangle is greater than the threshold, we can stop 193 | # i.e. loop to point where the heap head is >= threshold 194 | 195 | if triangleRing[0].calcArea() >= threshold: 196 | break 197 | else: 198 | tri_prev = triangleRing[0].prevTriangle 199 | tri_next = triangleRing[0].nextTriangle 200 | tri_prev.nextTriangle = tri_next 201 | tri_next.prevTriangle = tri_prev 202 | # This has to be done after updating the linked list 203 | # in order for the areas to be correct when the 204 | # heap re-sorts 205 | heapq.heappop(triangleRing) 206 | 207 | # Handle case where we've removed too many points for the ring to be a polygon 208 | if len(triangleRing) < 3: 209 | return None 210 | 211 | # Create an list of indices from the triangleRing heap 212 | indexList = [] 213 | for triangle in triangleRing: 214 | indexList.append(triangle.ringIndex) 215 | 216 | # Sort the index list 217 | indexList.sort() 218 | 219 | # Create a new simplified ring 220 | simpleRing = [] 221 | for index in indexList: 222 | simpleRing.append(ring.coords[index]) 223 | 224 | # Convert list into LinearRing 225 | simpleRing = LinearRing(simpleRing) 226 | 227 | # print statements for debugging to check if points are being reduced... 228 | #print "Starting size: " + str(len(ring.coords)) 229 | #print "Ending size: " + str(len(simpleRing.coords)) 230 | 231 | return simpleRing 232 | 233 | 234 | def simplify_multipolygon(mpoly, threshold): 235 | # break multipolygon into polys 236 | polyList = mpoly.geoms 237 | simplePolyList = [] 238 | 239 | # call simplify_polygon() on each 240 | for poly in polyList: 241 | simplePoly = simplify_polygon(poly, threshold) 242 | #if not none append to list 243 | if simplePoly: 244 | simplePolyList.append(simplePoly) 245 | 246 | # check that polygon count > 0, otherwise return None 247 | if not simplePolyList: 248 | return None 249 | 250 | # put back into multipolygon 251 | return MultiPolygon(simplePolyList) 252 | 253 | 254 | def simplify_polygon(poly, threshold): 255 | 256 | # Get exterior ring 257 | simpleExtRing = simplify_ring(poly.exterior, threshold) 258 | 259 | # If the exterior ring was removed by simplification, return None 260 | if simpleExtRing is None: 261 | return None 262 | 263 | simpleIntRings = [] 264 | for ring in poly.interiors: 265 | simpleRing = simplify_ring(ring, threshold) 266 | if simpleRing is not None: 267 | simpleIntRings.append(simpleRing) 268 | return Polygon(simpleExtRing, simpleIntRings) 269 | 270 | 271 | def simplify_multiline(mline, threshold): 272 | # break MultiLineString into lines 273 | lineList = mline.geoms 274 | simpleLineList = [] 275 | 276 | # call simplify_line on each 277 | for line in lineList: 278 | simpleLine = simplify_line(line, threshold) 279 | #if not none append to list 280 | if simpleLine: 281 | simpleLineList.append(simpleLine) 282 | 283 | # check that line count > 0, otherwise return None 284 | if not simpleLineList: 285 | return None 286 | 287 | # put back into multilinestring 288 | return MultiLineString(simpleLineList) 289 | 290 | -------------------------------------------------------------------------------- /video/debug.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 8, 2014 3 | 4 | @author: David Zwicker 5 | 6 | module that contains several functions useful when debugging the algorithm 7 | ''' 8 | 9 | from __future__ import division 10 | 11 | import functools 12 | import itertools 13 | 14 | import numpy as np 15 | 16 | import cv2 17 | 18 | 19 | # define exported functions such that loaded modules are not leaked into 20 | # the importing space 21 | __all__ = ['show_image', 'show_shape', 'show_tracking_graph', 22 | 'get_grabcut_image', 'print_filter_chain', 'save_frame_from_video'] 23 | 24 | 25 | 26 | def get_subplot_shape(num_plots=1): 27 | """ calculates an optimal subplot shape for a given number of images """ 28 | if num_plots <= 2: 29 | num_rows = 1 30 | elif num_plots <= 6: 31 | num_rows = 2 32 | elif num_plots <= 12: 33 | num_rows = 3 34 | else: 35 | num_rows = 4 36 | num_cols = int(np.ceil(num_plots/num_rows)) 37 | return num_rows, num_cols 38 | 39 | 40 | 41 | def _ax_format_coord(x, y, image): 42 | """ returns a string usable for formating the status line """ 43 | col = int(x + 0.5) 44 | row = int(y + 0.5) 45 | if 0 <= col < image.shape[1] and 0 <= row < image.shape[0]: 46 | z = image[row, col] 47 | if hasattr(z, '__iter__'): 48 | z_str = "(" + ', '.join("%1.1f" % v for v in z) + ")" 49 | else: 50 | z_str = "%1.5g" % z 51 | return 'x=%1.2f, y=%1.2f, z=%s' % (x, y, z_str) 52 | else: 53 | return 'x=%1.2f, y=%1.2f' % (x, y) 54 | 55 | 56 | 57 | def show_image(*images, **kwargs): 58 | """ shows a collection of images using matplotlib and waits for the user 59 | to continue """ 60 | import matplotlib 61 | import matplotlib.pyplot as plt 62 | 63 | # the macosx backend does not support proper interpolation, yet 64 | if matplotlib.get_backend() == 'MacOSX': 65 | interpolation = 'nearest' 66 | else: 67 | interpolation = 'none' 68 | 69 | # determine the number of rows and columns to show 70 | num_rows, num_cols = get_subplot_shape(len(images)) 71 | 72 | # get additional parameters 73 | aspect = kwargs.get('aspect', 'equal') 74 | 75 | # get the color scale 76 | if kwargs.pop('equalize_colors', False): 77 | vmin, vmax = np.inf, -np.inf 78 | for image in images: 79 | vmin = min(vmin, image.min()) 80 | vmax = max(vmax, image.max()) 81 | else: 82 | vmin, vmax = None, None 83 | 84 | # apply mask if requested 85 | mask = kwargs.pop('mask', None) 86 | if mask is not None: 87 | images = [np.ma.array(image, mask=~mask) for image in images] 88 | 89 | # see if all the images have the same dimensions 90 | try: 91 | share_axes = (len(set(image.shape[:2] for image in images)) == 1) 92 | except AttributeError: 93 | # there is something else than a np.ndarray in the list 94 | share_axes = False 95 | 96 | # choose the color map and color scaling 97 | plt.gray() 98 | if kwargs.pop('lognorm', False): 99 | from matplotlib.colors import LogNorm 100 | vmin = max(vmin, 1e-4) 101 | norm = LogNorm(vmin, vmax) 102 | else: 103 | norm = None 104 | 105 | # plot all the images 106 | for k, image in enumerate(images): 107 | # create the axes 108 | if share_axes: 109 | # share axes with the first subplot 110 | if k == 0: 111 | ax = plt.subplot(num_rows, num_cols, k + 1) 112 | share_axes = ax 113 | else: 114 | ax = plt.subplot(num_rows, num_cols, k + 1, 115 | sharex=share_axes, sharey=share_axes) 116 | else: 117 | ax = plt.subplot(num_rows, num_cols, k + 1) 118 | 119 | # plot the image 120 | if isinstance(image, np.ndarray): 121 | img = ax.imshow(image, interpolation=interpolation, aspect=aspect, 122 | vmin=vmin, vmax=vmax, norm=norm) 123 | # add the colorbar 124 | if image.min() != image.max(): 125 | # recipe from http://stackoverflow.com/a/18195921/932593 126 | from mpl_toolkits.axes_grid1 import make_axes_locatable # @UnresolvedImport 127 | divider = make_axes_locatable(plt.gca()) 128 | cax = divider.append_axes("right", size="5%", pad=0.05) 129 | try: 130 | plt.colorbar(img, cax=cax) 131 | except DeprecationWarning: 132 | # we don't care about these in the debug module 133 | pass 134 | 135 | # adjust the mouse over effects 136 | ax.format_coord = functools.partial(_ax_format_coord, image=image) 137 | 138 | elif len(image) == 2: 139 | # assume it's a vector field plot 140 | u, v = image 141 | #max_len = np.hypot(u, v).max() 142 | 143 | ax.quiver(u[::-10, ::10], -v[::-10, ::10], pivot='tip', 144 | angles='xy', scale_units='xy') 145 | plt.axis('equal') 146 | 147 | else: 148 | raise ValueError('Unsupported image type') 149 | 150 | # show the images and wait for user input 151 | plt.show() 152 | if kwargs.get('wait_for_key', True): 153 | raw_input('Press enter to continue...') 154 | 155 | 156 | 157 | def show_shape(*shapes, **kwargs): 158 | """ plots several shapes """ 159 | import matplotlib.pyplot as plt 160 | import shapely.geometry as geometry 161 | import descartes 162 | 163 | background = kwargs.get('background', None) 164 | wait_for_key = kwargs.get('wait_for_key', True) 165 | mark_points = kwargs.get('mark_points', False) 166 | aspect_equal = kwargs.get('aspect_equal', False) 167 | show_legend = kwargs.get('show_legend', True) 168 | 169 | # set up the plotting 170 | plt.figure() 171 | ax = plt.gca() 172 | colors = itertools.cycle('b g r c m y k'.split(' ')) 173 | 174 | # plot background, if applicable 175 | if background is not None: 176 | axim = ax.imshow(background, origin='upper', 177 | interpolation='nearest', cmap=plt.get_cmap('gray')) 178 | # adjust the mouse over effects 179 | ax.format_coord = functools.partial(_ax_format_coord, image=background) 180 | if background.min() != background.max(): 181 | # recipe from http://stackoverflow.com/a/18195921/932593 182 | from mpl_toolkits.axes_grid1 import make_axes_locatable # @UnresolvedImport 183 | divider = make_axes_locatable(ax) 184 | cax = divider.append_axes("right", size="5%", pad=0.05) 185 | try: 186 | plt.colorbar(axim, cax=cax) 187 | except DeprecationWarning: 188 | # we don't care about these in the debug module 189 | pass 190 | 191 | # iterate through all shapes and plot them 192 | for shape_id, shape in enumerate(shapes, 1): 193 | color = kwargs.get('color', colors.next()) 194 | line_width = kwargs.get('lw', 3) 195 | label = 'Shape %d' % shape_id 196 | 197 | if isinstance(shape, (geometry.Point, geometry.point.Point)): 198 | # simple point 199 | ax.plot(shape.x, shape.y, 'o', color=color, ms=20, label=label) 200 | 201 | elif isinstance(shape, geometry.MultiPoint): 202 | # many points 203 | coords = np.array([(p.x, p.y) for p in shape]) 204 | ax.plot(coords[:, 0], coords[:, 1], 'o', color=color, ms=5, 205 | label=label) 206 | 207 | elif isinstance(shape, geometry.LineString): 208 | # simple line string 209 | ax.plot(shape.xy[0], shape.xy[1], color=color, lw=line_width, 210 | label=label) 211 | if mark_points: 212 | ax.plot(shape.xy[0], shape.xy[1], 'o', 213 | markersize=2*line_width, color=color) 214 | 215 | elif isinstance(shape, geometry.multilinestring.MultiLineString): 216 | # many line strings 217 | for line_id, line in enumerate(shape): 218 | ax.plot(line.xy[0], line.xy[1], color=color, lw=line_width, 219 | label=(label if line_id == 0 else '')) 220 | if mark_points: 221 | ax.plot(line.xy[0], line.xy[1], 'o', 222 | markersize=2*line_width, color=color) 223 | 224 | elif isinstance(shape, geometry.Polygon): 225 | # simple polygon 226 | patch = descartes.PolygonPatch(shape, 227 | ec=kwargs.get('ec', 'none'), 228 | fc=color, alpha=0.5) 229 | ax.add_patch(patch) 230 | plt.plot([], [], color=color, label=label) 231 | if mark_points: 232 | ax.plot(shape.xy[0], shape.xy[1], 'o', 233 | markersize=2*line_width, color=color) 234 | 235 | elif isinstance(shape, geometry.MultiPolygon): 236 | # many polygons 237 | for poly_id, polygon in enumerate(shape): 238 | patch = descartes.PolygonPatch(polygon, 239 | ec=kwargs.get('ec', 'none'), 240 | fc=color, alpha=0.5) 241 | ax.add_patch(patch) 242 | if poly_id == 0: 243 | plt.plot([], [], color=color, label=label) 244 | if mark_points: 245 | ax.plot(shape.xy[0], shape.xy[1], 'o', 246 | markersize=2*line_width, color=color) 247 | 248 | else: 249 | raise ValueError("Don't know how to plot %r" % shape) 250 | 251 | # adjust image axes 252 | if background is None: 253 | ax.invert_yaxis() 254 | ax.margins(0.1) 255 | ax.autoscale_view(tight=False, scalex=True, scaley=True) 256 | else: 257 | ax.set_xlim(0, background.shape[1]) 258 | ax.set_ylim(background.shape[0], 0) 259 | 260 | if aspect_equal: 261 | ax.set_aspect('equal', 'datalim') 262 | 263 | if show_legend: 264 | plt.legend(loc='best') 265 | plt.show() 266 | if wait_for_key: 267 | raw_input('Press enter to continue...') 268 | 269 | 270 | 271 | def show_tracking_graph(graph, path=None, **kwargs): 272 | """ displays a representation of the tracking graph """ 273 | import matplotlib.pyplot as plt 274 | 275 | # plot the known chunks 276 | for node, data in graph.nodes_iter(data=True): 277 | color = 'r' if data['highlight'] else 'g' 278 | plt.plot([node.start, node.end], 279 | [node.first.pos[0], node.last.pos[0]], 280 | color, lw=(4 + 10*node.mouse_score)) 281 | 282 | try: 283 | max_weight = max(data['cost'] 284 | for _, _, data in graph.edges_iter(data=True)) 285 | except ValueError: 286 | max_weight = 1 287 | 288 | if kwargs.get('plot_edges', False): 289 | for (a, b, d) in graph.edges_iter(data=True): 290 | plt.plot([a.end, b.start], 291 | [a.last.pos[0], b.first.pos[0]], 292 | color=str(d['cost']/max_weight), lw=1) 293 | 294 | # plot the actual graph 295 | if kwargs.get('plot_graph', True): 296 | if path is not None: 297 | node_prev = None 298 | for node in path: 299 | plt.plot([node.start, node.end], 300 | [node.first.pos[0], node.last.pos[0]], 301 | 'b', lw=2) 302 | if node_prev is not None: 303 | plt.plot([node_prev.end, node.start], 304 | [node_prev.last.pos[0], node.first.pos[0]], 305 | 'b', lw=2) 306 | node_prev = node 307 | 308 | # show plot 309 | plt.xlabel('Time in Frames') 310 | plt.ylabel('X Position') 311 | plt.margins(0, 0.1) 312 | plt.show() 313 | if kwargs.get('wait_for_key', True): 314 | raw_input('Press enter to continue...') 315 | 316 | 317 | 318 | def get_grabcut_image(mask): 319 | """ returns an image from a mask that was prepared for the grab cut 320 | algorithm, where the foreground is bright and the background is dark """ 321 | image = np.zeros_like(mask, np.uint8) 322 | c = 255//4 323 | image[mask == cv2.GC_BGD ] = 1*c 324 | image[mask == cv2.GC_PR_BGD] = 2*c 325 | image[mask == cv2.GC_PR_FGD] = 3*c 326 | image[mask == cv2.GC_FGD ] = 4*c 327 | return image 328 | 329 | 330 | 331 | def print_filter_chain(video): 332 | """ prints information about a filter chain """ 333 | # print statistics of current video 334 | print(str(video)) 335 | 336 | # go up one level 337 | try: 338 | print_filter_chain(video._source) 339 | except AttributeError: 340 | pass 341 | 342 | 343 | 344 | def save_frame_from_video(video, outfile): 345 | """ save the next video frame to outfile """ 346 | # get frame 347 | pos = video.get_frame_pos() 348 | frame = video.next() 349 | video.set_frame_pos(pos) 350 | 351 | # turn it into image 352 | from PIL import Image # @UnresolvedImport 353 | im = Image.fromarray(frame) 354 | im.save(outfile) 355 | -------------------------------------------------------------------------------- /video/analysis/image.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 22, 2014 3 | 4 | @author: David Zwicker 5 | 6 | 7 | contains functions that are useful for image analysis 8 | ''' 9 | 10 | from __future__ import division 11 | 12 | import functools 13 | 14 | import numpy as np 15 | from scipy import ndimage 16 | 17 | import cv2 18 | 19 | from utils.data_structures.cache import cached_property 20 | 21 | 22 | 23 | def subpixel(img, pt): 24 | """ gets image intensities at a single point with sub pixel accuracy """ 25 | x, y = pt 26 | xi = int(x) 27 | yi = int(y) 28 | dx = x - xi 29 | dy = y - int(y) 30 | 31 | weight_tl = (1.0 - dx) * (1.0 - dy) 32 | weight_tr = (dx) * (1.0 - dy) 33 | weight_bl = (1.0 - dx) * (dy) 34 | weight_br = (dx) * (dy) 35 | return (weight_tl*img[yi , xi ] + 36 | weight_tr*img[yi , xi+1] + 37 | weight_bl*img[yi+1, xi ] + 38 | weight_br*img[yi+1, xi+1]) 39 | 40 | 41 | 42 | def subpixels(img, pts): 43 | """ gets image intensities of multiple points with sub pixel accuracy """ 44 | x, y = pts[:, 0], pts[:, 1] 45 | xi = x.astype(np.int) 46 | yi = y.astype(np.int) 47 | dx = x - xi 48 | dy = y - yi 49 | 50 | weight_tl = (1.0 - dx) * (1.0 - dy) 51 | weight_tr = (dx) * (1.0 - dy) 52 | weight_bl = (1.0 - dx) * (dy) 53 | weight_br = (dx) * (dy) 54 | return (weight_tl*img[yi , xi ] + 55 | weight_tr*img[yi , xi+1] + 56 | weight_bl*img[yi+1, xi ] + 57 | weight_br*img[yi+1, xi+1]) 58 | 59 | 60 | 61 | def get_subimage(img, slice_x, slice_y, width=None, height=None): 62 | """ 63 | extracts the subimage specified by `slice_x` and `slice_y`. 64 | Optionally, the image can also be resampled, by specifying a different 65 | number of pixels in either direction using the last two arguments 66 | """ 67 | p1_x, p2_x = slice_x[:2] 68 | p1_y, p2_y = slice_y[:2] 69 | 70 | if width is None: 71 | width = p2_x - p1_x 72 | 73 | if height is None: 74 | height = (p2_y - p1_y) * width / (p2_x - p1_x) 75 | 76 | # get corresponding points between the two images 77 | pts1 = np.array(((p1_x, p1_y), (p1_x, p2_y), (p2_x, p1_y)), np.float32) 78 | pts2 = np.array(((0, 0), (height, 0), (0, width)), np.float32) 79 | 80 | # determine and apply the affine transformation 81 | matrix = cv2.getAffineTransform(pts1, pts2) 82 | res = cv2.warpAffine(img, matrix, (int(round(height)), int(round(width)))) 83 | 84 | # return the profile 85 | return res 86 | 87 | 88 | 89 | def line_scan(img, p1, p2, half_width=5): 90 | """ returns the average intensity of an image along a strip of a given 91 | half_width, ranging from point p1 to p2. 92 | """ 93 | 94 | # get corresponding points between the two images 95 | length = np.hypot(p2[0] - p1[0], p2[1] - p1[1]) 96 | angle = np.arctan2(p2[1] - p1[1], p2[0] - p1[0]) 97 | p0 = (p1[0] + half_width*np.sin(angle), p1[1] - half_width*np.cos(angle)) 98 | pts1 = np.array((p0, p1, p2), np.float32) 99 | pts2 = np.array(((0, 0), (0, half_width), (length, half_width)), np.float32) 100 | 101 | # determine and apply the affine transformation 102 | matrix = cv2.getAffineTransform(pts1, pts2) 103 | res = cv2.warpAffine(img, matrix, (int(length), int(2*half_width))) 104 | 105 | # return the profile 106 | return res.mean(axis=0) 107 | 108 | 109 | 110 | def get_steepest_point(profile, direction=1, smoothing=0): 111 | """ returns the index where the profile is steepest. 112 | 113 | profile is a 1D array of intensities 114 | direction determines whether ascending (direction=1) or 115 | descending (direction=-1) slopes are search for 116 | smoothing determines the standard deviation of a Gaussian smoothing 117 | filter that is applied before looking for the slope 118 | """ 119 | if len(profile) < 2: 120 | return np.nan 121 | 122 | if smoothing > 0: 123 | profile = ndimage.filters.gaussian_filter1d(profile, smoothing) 124 | 125 | i_max = np.argmax(direction*np.diff(profile)) 126 | 127 | return i_max + 0.5 128 | 129 | 130 | 131 | def get_image_statistics(img, kernel='box', ksize=5, ret_var=True, 132 | prior=None, exclude_center=False): 133 | """ calculate mean and variance in a window around all points of an image 134 | `kernel` chooses the kernel that is used for the local sum 135 | `ksize` determines the size of that kernel 136 | `ret_var` determines whether the variance is returned alongside the mean 137 | `prior` denotes a value that is subtracted from the image before 138 | calculating statistics. This can be necessary for numerical stability. 139 | The prior should be close to the mean of the values. If prior is None, 140 | it is automatically set to the mean of the image. 141 | `exclude_center` determines whether also the color value at the current 142 | point or only the points around it are considered. 143 | """ 144 | # determine the prior automatically 145 | if prior is None: 146 | prior = img.mean() 147 | 148 | # calculate the window size 149 | ksize = 2*int(ksize) + 1 150 | 151 | # check for possible integer overflow (very conservatively) 152 | if np.iinfo(np.int).max < (ksize*max(prior, 255 - prior))**2: 153 | raise RuntimeError('Window is too large and an integer overflow ' 154 | 'could happen.') 155 | 156 | # prepare the function that does the actual filtering 157 | if kernel == 'box': 158 | filter_image = functools.partial(cv2.boxFilter, ddepth=-1, 159 | ksize=(ksize, ksize), normalize=False, 160 | borderType=cv2.BORDER_CONSTANT) 161 | count = ksize**2 162 | 163 | elif kernel == 'ellipse' or kernel == 'circle': 164 | kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, 165 | ksize=(ksize, ksize)) 166 | filter_image = functools.partial(cv2.filter2D, ddepth=-1, 167 | kernel=kernel, 168 | borderType=cv2.BORDER_CONSTANT) 169 | count = kernel.sum() 170 | 171 | else: 172 | raise ValueError('Unknown filter kernel `%s`' % kernel) 173 | 174 | # create the image from which the statistics will be calculated 175 | data = img.astype(np.int) - prior 176 | 177 | # calculate how many on pixel there are in each region 178 | 179 | # calculate the local sums 180 | s1 = filter_image(data) 181 | if exclude_center: 182 | # remove the central point from the calculation 183 | s1 = s1 - data 184 | count -= 1 185 | # don't use -= here, since s1 seems to be int32 only 186 | 187 | # calculate mean and variance 188 | mean = s1/count + prior 189 | 190 | if ret_var: 191 | # calculate the local sums of squares 192 | np.square(data, data) #< square the data in-place 193 | s2 = filter_image(data) 194 | if exclude_center: 195 | s2 = s2 - data 196 | var = (s2 - s1**2/count)/(count - 1) 197 | 198 | return mean, var 199 | 200 | else: 201 | return mean 202 | 203 | 204 | 205 | def set_image_border(img, size=1, color=0): 206 | """ sets the border of an image to `color` """ 207 | img[ :size, :] = color 208 | img[-size:, :] = color 209 | img[:, :size] = color 210 | img[:, -size:] = color 211 | 212 | 213 | 214 | def mask_thinning(img, method='auto'): 215 | """ 216 | returns the skeleton (thinned image) of a mask. 217 | This uses `thinning.guo_hall_thinning` if available and otherwise falls back 218 | to a slow python implementation taken from 219 | http://opencvpython.blogspot.com/2012/05/skeletonization-using-opencv-python.html 220 | Note that this implementation is not equivalent to guo_hall implementation 221 | """ 222 | # try importing the thinning module 223 | try: 224 | import thinning 225 | except ImportError: 226 | thinning = None 227 | 228 | # determine the method to use if automatic method is requested 229 | if method == 'auto': 230 | if thinning is None: 231 | method = 'python' 232 | else: 233 | method = 'guo-hall' 234 | 235 | # do the thinning with the requested method 236 | if method == 'guo-hall': 237 | if thinning is None: 238 | raise ImportError('Using the `guo-hall` method for thinning ' 239 | 'requires the `thinning` module, which could not ' 240 | 'be imported.') 241 | skel = thinning.guo_hall_thinning(img) 242 | 243 | elif method =='python': 244 | # thinning module was not available and we use a python implementation 245 | size = np.size(img) 246 | skel = np.zeros(img.shape, np.uint8) 247 | 248 | kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) 249 | while True: 250 | eroded = cv2.erode(img, kernel) 251 | temp = cv2.dilate(eroded, kernel) 252 | cv2.subtract(img, temp, temp) 253 | cv2.bitwise_or(skel, temp, skel) 254 | img = eroded 255 | 256 | zeros = size - cv2.countNonZero(img) 257 | if zeros==size: 258 | break 259 | 260 | else: 261 | raise ValueError('Unknown thinning method `%s`' % method) 262 | 263 | return skel 264 | 265 | 266 | 267 | def detect_peaks(img, include_plateaus=True): 268 | """ 269 | Takes an image and detect the peaks using the local maximum filter. 270 | Returns a boolean mask of the peaks (i.e. 1 when the pixel's value is the 271 | neighborhood maximum, 0 otherwise) 272 | Code taken from http://stackoverflow.com/a/3689710/932593 273 | """ 274 | 275 | # define an 8-connected neighborhood 276 | neighborhood = ndimage.generate_binary_structure(2, 2) 277 | 278 | if include_plateaus: 279 | #apply the local maximum filter; all pixel of maximal value 280 | #in their neighborhood are set to 1 281 | img_max = ndimage.maximum_filter(img, footprint=neighborhood) 282 | local_max = (img == img_max) 283 | #local_max is a mask that contains the peaks we are 284 | #looking for, but also the background. 285 | #In order to isolate the peaks we must remove the background from the mask. 286 | 287 | #we create the mask of the background 288 | background = (img == 0) 289 | 290 | #a little technicality: we must erode the background in order to 291 | #successfully subtract it form local_max, otherwise a line will 292 | #appear along the background border (artifact of the local maximum filter) 293 | eroded_background = ndimage.binary_erosion(background, 294 | structure=neighborhood, 295 | border_value=1) 296 | 297 | #we obtain the final mask, containing only peaks, 298 | #by removing the background from the local_max mask 299 | detected_peaks = local_max - eroded_background 300 | 301 | else: 302 | neighborhood[1, 1] = 0 303 | img_max = ndimage.maximum_filter(img, footprint=neighborhood) 304 | detected_peaks = (img > img_max) 305 | 306 | return detected_peaks 307 | 308 | 309 | 310 | class regionprops(object): 311 | """ calculates useful properties of regions in binary images. 312 | Much of this code was inspired by the excellent skimage package, which is 313 | available at http://scikit-image.org 314 | The original source code can be found on github at 315 | https://github.com/scikit-image/scikit-image 316 | 317 | The license coming with scikit reads: 318 | 319 | Copyright (C) 2011, the scikit-image team 320 | All rights reserved. 321 | 322 | Redistribution and use in source and binary forms, with or without 323 | modification, are permitted provided that the following conditions are 324 | met: 325 | 326 | 1. Redistributions of source code must retain the above copyright 327 | notice, this list of conditions and the following disclaimer. 328 | 2. Redistributions in binary form must reproduce the above copyright 329 | notice, this list of conditions and the following disclaimer in 330 | the documentation and/or other materials provided with the 331 | distribution. 332 | 3. Neither the name of skimage nor the names of its contributors may be 333 | used to endorse or promote products derived from this software without 334 | specific prior written permission. 335 | 336 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 337 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 338 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 339 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 340 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 341 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 342 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 343 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 344 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 345 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 346 | POSSIBILITY OF SUCH DAMAGE. 347 | """ 348 | 349 | def __init__(self, mask=None, contour=None, moments=None): 350 | if moments is not None: 351 | self.moments = moments 352 | elif mask is not None: 353 | self.moments = cv2.moments(mask.astype(np.uint8)) 354 | elif contour is not None: 355 | self.moments = cv2.moments(contour) 356 | else: 357 | raise ValueError('Either the mask or the moments must be given') 358 | 359 | @property 360 | def area(self): 361 | return self.moments['m00'] 362 | 363 | @cached_property() 364 | def centroid(self): 365 | m = self.moments 366 | return (m['m10']/m['m00'], m['m01']/m['m00']) 367 | 368 | @cached_property() 369 | def orientation(self): 370 | m = self.moments 371 | a, b, c = m['mu20'], m['mu11'], m['mu02'] 372 | if a - c == 0: 373 | if b > 0: 374 | return -np.pi/4 375 | else: 376 | return np.pi/4 377 | else: 378 | return -np.arctan2(2*b, (a - c))/2 379 | 380 | @cached_property() 381 | def inertia_tensor_eigvals(self): 382 | m = self.moments 383 | a, b, c = m['mu20']/m['m00'], -m['mu11']/m['m00'], m['mu02']/m['m00'] 384 | # eigenvalues of inertia tensor 385 | e1 = (a + c) + np.sqrt(4*b**2 + (a - c)**2) 386 | e2 = (a + c) - np.sqrt(4*b**2 + (a - c)**2) 387 | return e1, e2 388 | 389 | @cached_property() 390 | def eccentricity(self): 391 | e1, e2 = self.inertia_tensor_eigvals() 392 | if e1 == 0: 393 | return 0 394 | else: 395 | return np.sqrt(1 - e2/e1) 396 | 397 | @cached_property() 398 | def major_axis_length(self): 399 | e1, _ = self.inertia_tensor_eigvals 400 | return 4*np.sqrt(e1) 401 | 402 | @cached_property() 403 | def minor_axis_length(self): 404 | _, e2 = self.inertia_tensor_eigvals 405 | return 4*np.sqrt(e2) -------------------------------------------------------------------------------- /video/io/composer.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 6, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | import numpy as np 10 | import cv2 11 | from matplotlib.colors import ColorConverter 12 | 13 | from utils.math import contiguous_true_regions 14 | from .file import VideoFileWriter 15 | from ..analysis.regions import rect_to_corners 16 | 17 | 18 | 19 | def get_color(color): 20 | """ 21 | function that returns a RGB color with channels ranging from 0..255. 22 | The matplotlib color notation is used. 23 | """ 24 | 25 | if get_color.parser is None: 26 | get_color.parser = ColorConverter().to_rgb 27 | 28 | return [int(255*c) for c in get_color.parser(color)] 29 | 30 | get_color.parser = None 31 | 32 | 33 | 34 | def skip_if_no_output(func): 35 | """ decorator which only calls the function if the current _frame will 36 | be written to the file """ 37 | def func_wrapper(self, *args, **kwargs): 38 | if self.output_this_frame: 39 | return func(self, *args, **kwargs) 40 | return func_wrapper 41 | 42 | 43 | CHANNEL_NAMES = {0: 0, 'r': 0, 'red': 0, 44 | 1: 1, 'g': 1, 'green': 1, 45 | 2: 2, 'b': 2, 'blue': 2} 46 | 47 | 48 | class VideoComposer(VideoFileWriter): 49 | """ A class that can be used to compose a video _frame by _frame. 50 | Additional elements like geometric objects can be added to each _frame 51 | """ 52 | 53 | def __init__(self, filename, size, fps, is_color, output_period=1, 54 | zoom_factor=1, **kwargs): 55 | """ 56 | Initializes a video file writer with additional functionality to annotate 57 | videos. The first arguments (size, fps, is_color) are directly passed on 58 | to the VideoFileWriter. 59 | `output_period` determines how often frames are actually written. 60 | `output_period=10` for instance only outputs every tenth _frame to 61 | the file. 62 | `zoom_factor` determines how much the output will be scaled down. The 63 | width of the new image is given by dividing the width of the 64 | original image by the zoom_factor. 65 | """ 66 | self._frame = None 67 | self.next_frame = -1 68 | self.output_period = output_period 69 | self.zoom_factor = zoom_factor 70 | target_size = (int(size[0]/zoom_factor), int(size[1]/zoom_factor)) 71 | 72 | super(VideoComposer, self).__init__(filename, target_size, fps, 73 | is_color, **kwargs) 74 | 75 | 76 | def get_color(self, color): 77 | """ takes the color and converts it into a usable representation """ 78 | color = get_color(color) 79 | if not self.is_color: 80 | # turn into grey scale 81 | color = int(np.mean(color)) 82 | return color 83 | 84 | 85 | @property 86 | def output_this_frame(self): 87 | """ determines whether the current _frame should be written to the video """ 88 | return (self.next_frame % self.output_period) == 0 89 | 90 | 91 | def set_frame(self, frame, copy=True): 92 | """ set the current _frame from an image """ 93 | self.next_frame += 1 94 | 95 | if self.output_this_frame: 96 | # scale current frame if necessary 97 | if self.zoom_factor != 1: 98 | frame = cv2.resize(frame, self.size) 99 | copy = False #< copy already happened 100 | 101 | if self._frame is None: 102 | # first frame => initialize the video 103 | if self.is_color and frame.ndim == 2: 104 | # copy the monochrome _frame into the color video 105 | self._frame = np.repeat(frame[:, :, None], 3, axis=2) 106 | elif not self.is_color and frame.ndim == 3: 107 | raise ValueError('Cannot copy a color image into a ' 108 | 'monochrome video.') 109 | elif copy: 110 | self._frame = frame.copy() 111 | else: 112 | self._frame = frame 113 | 114 | else: 115 | # had a previous _frame => write the last frame 116 | self.write_frame(self._frame) 117 | 118 | # set current frame 119 | if self.is_color and frame.ndim == 2: 120 | # set all three color channels 121 | # Here, explicit iteration is faster than numpy broadcasting 122 | for c in xrange(3): 123 | self._frame[:, :, c] = frame 124 | elif copy: 125 | self._frame[:] = frame 126 | else: 127 | self._frame = frame 128 | 129 | 130 | @skip_if_no_output 131 | def highlight_mask(self, mask, channel='all', strength=128): 132 | """ highlights the non-zero entries of a mask in the current _frame """ 133 | # determine which color channel to use 134 | if channel is None or channel == 'all': 135 | if self.is_color: 136 | channel = slice(0, 3) 137 | else: 138 | channel = 0 139 | elif self.is_color: 140 | try: 141 | channel = CHANNEL_NAMES[channel] 142 | except KeyError: 143 | raise ValueError('Unknown value `%s` for channel.' % channel) 144 | else: 145 | raise ValueError('Highlighting a specific channel is only ' 146 | 'supported for color videos.') 147 | 148 | # scale mask if necessary 149 | if self.zoom_factor != 1: 150 | mask = cv2.resize(mask.astype(np.uint8), self.size).astype(np.bool) 151 | 152 | # mark the mask area in the image 153 | factor = (255 - strength)/255 154 | self._frame[mask, channel] = strength + factor*self._frame[mask, channel] 155 | 156 | 157 | def _prepare_images(self, image, mask=None): 158 | """ scale image if necessary """ 159 | if self.zoom_factor != 1: 160 | image = cv2.resize(image, self.size) 161 | if mask: 162 | mask = cv2.resize(mask.astype(np.uint8), 163 | self.size).astype(np.bool) 164 | return image, mask 165 | 166 | 167 | @skip_if_no_output 168 | def add_image(self, image, mask=None): 169 | """ adds an image to the _frame """ 170 | frame = self._frame 171 | image, mask = self._prepare_images(image, mask) 172 | 173 | # check image dimensions 174 | if frame.shape[:2] != image.shape[:2]: 175 | raise ValueError('The two images to be added must have the same size') 176 | 177 | # check color properties 178 | if frame.ndim == 3 and image.ndim == 2: 179 | image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) 180 | elif frame.ndim == 2 and image.ndim == 3: 181 | raise ValueError('Cannot add a color image to a monochrome one') 182 | 183 | if mask is None: 184 | cv2.add(frame, image, frame) 185 | else: 186 | cv2.add(frame, image, frame, mask=mask.astype(np.uint8)) 187 | 188 | 189 | @skip_if_no_output 190 | def blend_image(self, image, weight=0.5, mask=None): 191 | """ overlay image with weight """ 192 | frame = self._frame 193 | image, mask = self._prepare_images(image, mask) 194 | 195 | # check image dimensions 196 | if frame.shape[:2] != image.shape[:2]: 197 | raise ValueError('The two images to be added must have the same size') 198 | 199 | # check color properties 200 | if frame.ndim == 3 and image.ndim == 2: 201 | image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) 202 | elif frame.ndim == 2 and image.ndim == 3: 203 | raise ValueError('Cannot add a color image to a monochrome one') 204 | 205 | result = cv2.addWeighted(frame, 1 - weight, image, weight, gamma=0) 206 | 207 | if mask is not None: 208 | result[~mask] = frame[~mask] 209 | 210 | self._frame = result 211 | 212 | 213 | @skip_if_no_output 214 | def add_contour(self, mask_or_contour, color='w', thickness=1, copy=True): 215 | """ adds the contours of a mask. 216 | Note that this function modifies the mask, unless copy=True 217 | """ 218 | if isinstance(mask_or_contour, list): 219 | # assume that it is a list of contours 220 | contours = mask_or_contour 221 | elif any(s == 1 for s in mask_or_contour.shape[:2]): 222 | # given value is a list of contour points 223 | contours = [mask_or_contour] 224 | else: 225 | # given value is a mask 226 | if copy: 227 | mask_or_contour = mask_or_contour.copy() 228 | contours = cv2.findContours(mask_or_contour, cv2.RETR_EXTERNAL, 229 | cv2.CHAIN_APPROX_SIMPLE)[1] 230 | 231 | if self.zoom_factor != 1: 232 | contours = np.asarray(contours, np.double) / self.zoom_factor 233 | contours = contours.astype(np.int) 234 | thickness = np.ceil(thickness / self.zoom_factor) 235 | 236 | cv2.drawContours(self._frame, contours, -1, 237 | self.get_color(color), thickness=int(thickness)) 238 | 239 | 240 | @skip_if_no_output 241 | def add_line(self, points, color='w', is_closed=True, mark_points=False, 242 | width=1): 243 | """ adds a polygon to the _frame """ 244 | if len(points) == 0: 245 | return 246 | 247 | points = np.asarray(points) 248 | 249 | # find the regions where the points are finite 250 | # Here, we compare to 0 to capture nans in the int32 array 251 | indices = contiguous_true_regions(points[:, 0] > 0) 252 | 253 | for start, end in indices: 254 | # add the line 255 | line_points = (points[start:end, :]/self.zoom_factor).astype(np.int) 256 | thickness = int(np.ceil(width / self.zoom_factor)) 257 | cv2.polylines(self._frame, [line_points], 258 | isClosed=is_closed, color=self.get_color(color), 259 | thickness=thickness) 260 | # mark the anchor points if requested 261 | if mark_points: 262 | for p in points[start:end, :]: 263 | self.add_circle(p, 2*width, color, thickness=-1) 264 | 265 | 266 | @skip_if_no_output 267 | def add_rectangle(self, rect, color='w', width=1): 268 | """ add a rect=(left, top, width, height) to the _frame """ 269 | if self.zoom_factor != 1: 270 | rect = np.asarray(rect) / self.zoom_factor 271 | thickness = int(np.ceil(width / self.zoom_factor)) 272 | else: 273 | thickness = int(width) 274 | 275 | # extract the corners of the rectangle 276 | try: 277 | corners = rect.corners 278 | except AttributeError: 279 | corners = rect_to_corners(rect) 280 | 281 | corners = [(int(p[0]), int(p[1])) for p in corners] 282 | 283 | # draw the rectangle 284 | cv2.rectangle(self._frame, *corners, color=self.get_color(color), 285 | thickness=thickness) 286 | 287 | 288 | @skip_if_no_output 289 | def add_circle(self, pos, radius=2, color='w', thickness=-1): 290 | """ add a circle to the _frame. 291 | thickness=-1 denotes a filled circle 292 | """ 293 | try: 294 | pos = (int(pos[0]/self.zoom_factor), int(pos[1]/self.zoom_factor)) 295 | radius = int(np.ceil(radius / self.zoom_factor)) 296 | if thickness > 0: 297 | thickness = int(np.ceil(thickness / self.zoom_factor)) 298 | cv2.circle(self._frame, pos, radius, self.get_color(color), 299 | thickness=thickness) 300 | except (ValueError, OverflowError): 301 | pass 302 | 303 | 304 | @skip_if_no_output 305 | def add_points(self, points, radius=1, color='w'): 306 | """ adds a sequence of points to the _frame """ 307 | for p in points: 308 | self.add_circle(p, radius, color, thickness=-1) 309 | 310 | 311 | @skip_if_no_output 312 | def add_text(self, text, pos, color='w', size=1, anchor='bottom', 313 | font=cv2.FONT_HERSHEY_COMPLEX_SMALL): 314 | """ adds text to the video. 315 | `pos` determines the position of the anchor of the text 316 | `anchor` can be a string containing (left, center, right) for 317 | horizontal placement and (upper, middle, lower) for the vertical one 318 | """ 319 | if self.zoom_factor != 1: 320 | pos = [int(pos[0]/self.zoom_factor), int(pos[1]/self.zoom_factor)] 321 | size /= self.zoom_factor 322 | else: 323 | pos = [int(pos[0]), int(pos[1])] 324 | 325 | # determine text size to allow flexible positioning 326 | text_size, _ = cv2.getTextSize(text, font, fontScale=size, thickness=1) 327 | 328 | # determine horizontal position of text 329 | if 'right' in anchor: 330 | pos[0] = pos[0] - text_size[0] 331 | elif 'center' in anchor: 332 | pos[0] = pos[0] - text_size[0]//2 333 | 334 | # determine vertical position of text 335 | if 'upper' in anchor or 'top' in anchor: 336 | pos[1] = pos[1] + text_size[1] 337 | elif 'middle' in anchor: 338 | pos[1] = pos[1] + text_size[1]//2 339 | 340 | # place text 341 | cv2.putText(self._frame, text, tuple(pos), font, fontScale=size, 342 | color=self.get_color(color), thickness=1) 343 | 344 | 345 | def close(self): 346 | # write the last _frame 347 | if self._frame is not None: 348 | self.write_frame(self._frame) 349 | self._frame = None 350 | 351 | # close the video writer 352 | super(VideoComposer, self).close() 353 | 354 | 355 | 356 | class VideoComposerListener(VideoComposer): 357 | """ A class that can be used to compose a video _frame by _frame. 358 | This class automatically listens to another video and captures the newest 359 | _frame from it. Additional elements like geometric objects can then be added 360 | to each _frame. This is useful to annotate a copy of a video. 361 | """ 362 | 363 | 364 | def __init__(self, filename, background_video, is_color=None, **kwargs): 365 | self.background_video = background_video 366 | self.background_video.register_listener(self.set_frame) 367 | 368 | super(VideoComposerListener, self).__init__(filename, 369 | self.background_video.size, 370 | self.background_video.fps, 371 | is_color, **kwargs) 372 | 373 | 374 | def close(self): 375 | try: 376 | self.background.unregister_listener(self.set_frame) 377 | except (AttributeError, ValueError): 378 | # apparently, the listener is already removed 379 | pass 380 | super(VideoComposerListener, self).close() 381 | -------------------------------------------------------------------------------- /video/analysis/morphological_graph.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Apr 28, 2015 3 | 4 | @author: David Zwicker 5 | 6 | 7 | contains functions that are useful for image analysis 8 | ''' 9 | 10 | from __future__ import division 11 | 12 | import cv2 13 | import numpy as np 14 | import networkx as nx 15 | from shapely import geometry 16 | 17 | import curves 18 | 19 | 20 | 21 | class MorphologicalGraph(nx.MultiGraph): 22 | """ class that represents a morphological graph. 23 | Note that a morphological graph generally might have parallel edges. 24 | """ 25 | 26 | def __init__(self, *args, **kwargs): 27 | super(MorphologicalGraph, self).__init__(*args, **kwargs) 28 | self._unique_node_id = 0 #< used to ensure unique nodes 29 | 30 | 31 | def get_node_points(self): 32 | """ returns the coordinates of all nodes """ 33 | return nx.get_node_attributes(self, 'coords').values() 34 | 35 | 36 | def get_edge_curves(self): 37 | """ returns a list of all edge curves """ 38 | return nx.get_edge_attributes(self, 'curve').values() 39 | 40 | 41 | def add_node_point(self, coords): 42 | """ adds a node to the graph """ 43 | # check whether this node already exists 44 | for key, value in nx.get_node_attributes(self, 'coords').iteritems(): 45 | if np.allclose(coords, value): 46 | # it does already exist => return its node_id 47 | node_id = key 48 | break 49 | else: 50 | # it does not exist => create a new node 51 | self._unique_node_id += 1 52 | node_id = self._unique_node_id 53 | self.add_node(node_id, {'coords': coords}) 54 | return node_id 55 | 56 | 57 | def add_edge_line(self, n1, n2, curve): 58 | """ adds an edge to the graph """ 59 | curve = np.asarray(curve) 60 | p1, p2 = curve[0], curve[-1] 61 | 62 | coords1, coords2 = self.node[n1]['coords'], self.node[n2]['coords'] 63 | 64 | if (np.allclose(p1, coords1) and np.allclose(p2, coords2)): 65 | data = {'curve': curve, 66 | 'length': curves.curve_length(curve)} 67 | 68 | elif (np.allclose(p1, coords2) and np.allclose(p2, coords1)): 69 | data = {'curve': curve[::-1], 70 | 'length': curves.curve_length(curve)} 71 | 72 | else: 73 | raise ValueError('The curve given by `curve` does not connect the ' 74 | 'specified nodes.') 75 | 76 | if data['length'] > 1e-6: 77 | self.add_edge(n1, n2, attr_dict=data) 78 | 79 | 80 | def insert_node_into_edge(self, edge, point_id): 81 | """ inserts a node into the given edge at the point given by point id """ 82 | if len(edge) == 2: 83 | edge = next(self.edges_iter(nbunch=edge, keys=True)) 84 | 85 | # get the curve of the edge to intersect with 86 | curve = self.edge[edge[0]][edge[1]][edge[2]]['curve'] 87 | 88 | # insert a new node 89 | node_id = self.add_node_point(curve[point_id]) 90 | 91 | # check which side is connected to which node 92 | p1, p2 = curve[0], curve[-1] 93 | coords1 = self.node[edge[0]]['coords'] 94 | coords2 = self.node[edge[1]]['coords'] 95 | 96 | if (np.allclose(p1, coords1) and np.allclose(p2, coords2)): 97 | self.add_edge_line(edge[0], node_id, curve[:point_id + 1]) 98 | self.add_edge_line(node_id, edge[1], curve[point_id:]) 99 | 100 | elif (np.allclose(p1, coords2) and np.allclose(p2, coords1)): 101 | self.add_edge_line(edge[1], node_id, curve[:point_id + 1]) 102 | self.add_edge_line(node_id, edge[0], curve[point_id:]) 103 | 104 | else: 105 | raise ValueError('The edge (%d, %d) is inconsistent with its curve' 106 | % edge[:2]) 107 | 108 | # break the old edge at the intersection point 109 | self.remove_edge(*edge) 110 | 111 | return node_id 112 | 113 | def connect_point_to_edge(self, point, edge, point_id): 114 | """ adds a node representing the point and connect this point to 115 | the given edge by intersecting the edge at the given support point. 116 | An edge is specified by the ids of its two end nodes and an optional 117 | integer if there are multiple edges between the nodes """ 118 | # add new nodes and the edge between them 119 | node_point = self.add_node_point(point) 120 | node_int = self.insert_node_into_edge(edge, point_id) 121 | 122 | self.add_edge_line(node_point, node_int, 123 | curve=[point, self.node[node_int]['coords']]) 124 | 125 | 126 | def translate(self, x, y): 127 | """ translate the whole graph in space by x and y """ 128 | # change all nodes 129 | for _, data in self.nodes_iter(data=True): 130 | c = data['coords'] 131 | data['coords'] = (c[0] + x, c[1] + y) 132 | # change all edges 133 | offset = np.array([x, y]) 134 | for _, _, data in self.edges_iter(data=True): 135 | data['curve'] += offset 136 | 137 | 138 | def remove_short_edges(self, length_min=1, exclude_nodes=None): 139 | """ removes edges that are shorter than the given minimal length. If 140 | `exclude_nodes` is given, nodes mentioned in their are left untouched. 141 | """ 142 | if exclude_nodes is None: 143 | exclude_nodes = set() 144 | else: 145 | exclude_nodes = set(exclude_nodes) 146 | 147 | # iterate until all short edges are removed 148 | changed = True 149 | while changed: 150 | changed = False 151 | for n1, n2, key, data in self.edges_iter(data=True, keys=True): 152 | # skip nodes that are to be excluded 153 | if n1 in exclude_nodes or n2 in exclude_nodes: 154 | continue 155 | 156 | # check the length of the current edge 157 | if data['length'] < length_min: 158 | degrees = self.degree((n1, n2)) 159 | if (1 in degrees.values()): 160 | # edge connected to at least one end point 161 | self.remove_edge(n1, n2) 162 | for n, d in degrees.iteritems(): 163 | if d == 1: 164 | self.remove_node(n) 165 | changed = True 166 | 167 | elif n1 == n2: 168 | # edge is a loop and can be safely removed 169 | self.remove_edge(n1, n2, key) 170 | changed = True 171 | 172 | 173 | def get_single_edge_data(self, n1, n2): 174 | """ retrieve data from a single edge. This function fails with an error 175 | if there are multiple edges between the nodes """ 176 | edges = self.get_edge_data(n1, n2) 177 | if len(edges) == 1: 178 | return edges.values()[0] #< return only element 179 | else: 180 | raise ValueError('There are multiple edges between the nodes %d ' 181 | 'and %d.' % (n1, n2)) 182 | 183 | 184 | def simplify(self, epsilon=0): 185 | """ remove nodes with degree=2 and simplifies the curves describing the 186 | edges if epsilon is larger than 0 """ 187 | # remove nodes with degree=2 188 | while True: 189 | for n, d in self.degree_iter(): 190 | if d == 2: 191 | try: 192 | n1, n2 = self.neighbors(n) 193 | except ValueError: 194 | # this can happen for a self-loop, where n1 == n2 195 | continue 196 | 197 | # get the points 198 | points1 = self.get_single_edge_data(n, n1)['curve'] 199 | points2 = self.get_single_edge_data(n, n2)['curve'] 200 | 201 | # remove the node and the edges 202 | self.remove_edges_from([(n, n1), (n, n2)]) 203 | self.remove_node(n) 204 | 205 | # add the new edge 206 | points = curves.merge_curves(points1, points2) 207 | self.add_edge_line(n1, n2, points) 208 | break 209 | else: 210 | break #< no changes => we're done! 211 | 212 | # simplify the curves describing the edges 213 | if epsilon > 0: 214 | for _, _, data in self.edges_iter(data=True): 215 | data['curve'] = curves.simplify_curve(data['curve'], epsilon) 216 | data['length'] = curves.curve_length(data['curve']) 217 | 218 | 219 | def get_point_on_edge(self, n1, n2, point_id): 220 | """ returns the point on the edge (n1, n2) that has the point_id """ 221 | return self.get_single_edge_data(n1, n2)['curve'][point_id, :] 222 | 223 | 224 | def get_closest_node(self, point): 225 | """ get the node that is closest to a given point. 226 | This function returns three values: 227 | * the id of the closest node 228 | * its coordinates 229 | * the distance of this node to the given point 230 | """ 231 | node_min, coord_min, dist_min = None, None, np.inf 232 | for node, data in self.nodes_iter(data=True): 233 | dist = curves.point_distance(point, data['coords']) 234 | if dist < dist_min: 235 | node_min, coord_min, dist_min = node, data['coords'], dist 236 | 237 | return node_min, coord_min, dist_min 238 | 239 | 240 | def get_closest_edge(self, point): 241 | """ get the edge that is closest to a given point. This function returns 242 | three values: 243 | * the edge (indicated by the two nodes that it connects) 244 | * the id of the point on the connection curve 245 | * the distance between this point and the given point 246 | """ 247 | point = geometry.Point(point) 248 | edge_min, dist_min, data_min = None, np.inf, None 249 | for n1, n2, key, data in self.edges_iter(data=True, keys=True): 250 | if 'linestring' in data: 251 | line = data['linestring'] 252 | else: 253 | line = geometry.LineString(data['curve']) 254 | data['linestring'] = line 255 | dist = line.distance(point) 256 | if dist < dist_min: 257 | edge_min, dist_min, data_min = (n1, n2, key), dist, data 258 | 259 | # calculate the projection point 260 | if edge_min: 261 | # find the index of the projection point on the line 262 | coords = data_min['curve'] 263 | dists = np.linalg.norm(coords - np.array(point.coords), axis=1) 264 | projection_id = np.argmin(dists) 265 | dist = dists[projection_id] 266 | else: 267 | projection_id = None 268 | 269 | return edge_min, projection_id, dist 270 | 271 | 272 | def get_total_length(self): 273 | """ return total length of all edges """ 274 | for _, _, data in self.edges_iter(data=True): 275 | print curves.curve_length(data['curve']) 276 | return sum(curves.curve_length(data['curve']) 277 | for _, _, data in self.edges_iter(data=True)) 278 | 279 | 280 | def add_and_connect_node_point(self, point): 281 | """ adds a point to the graph and connects it to the nearest edge """ 282 | edge_min, projection_id, _ = self.get_closest_edge(point) 283 | self.connect_point_to_edge(point, edge_min, projection_id) 284 | 285 | 286 | @classmethod 287 | def from_skeleton(cls, skeleton, copy=True, post_process=True): 288 | """ determines the morphological graph from the image `skeleton` 289 | `copy` determines whether the skeleton is copied before its modified 290 | `post_process` determines whether some post processing is performed 291 | that removes spurious edges and nodes 292 | """ 293 | if copy: 294 | skeleton = skeleton.copy() 295 | graph = cls() 296 | 297 | # count how many neighbors each point has 298 | kernel = np.ones((3, 3), np.uint8) 299 | kernel[1, 1] = 0 300 | neighbors = cv2.filter2D(skeleton, -1, kernel) * skeleton 301 | 302 | # find an point with minimal neighbors to start iterating from 303 | neighbors_min = neighbors[neighbors > 0].min() 304 | ps = np.nonzero(neighbors == neighbors_min) 305 | start_point = (ps[1][0], ps[0][0]) 306 | 307 | # initialize graph by adding first node 308 | start_node = graph.add_node_point(start_point) 309 | edge_seeds = {start_point: start_node} 310 | 311 | # iterate over all edges 312 | while edge_seeds: 313 | # pick new point from edge_seeds and initialize the point list 314 | p, start_node = edge_seeds.popitem() 315 | points = [graph.node[start_node]['coords']] 316 | 317 | # iterate along the edge 318 | while True: 319 | # handle the current point 320 | points.append(p) 321 | skeleton[p[1], p[0]] = 0 322 | 323 | # look at the neighborhood of the current point 324 | ps_n = skeleton[p[1]-1 : p[1]+2, p[0]-1 : p[0]+2] 325 | neighbor_count = ps_n.sum() 326 | #print neighbor_count 327 | 328 | if neighbor_count == 1: 329 | # find the next point along this edge 330 | dy, dx = np.nonzero(ps_n) 331 | p = (p[0] + dx[0] - 1, p[1] + dy[0] - 1) 332 | else: 333 | # the current point ends the edge 334 | break 335 | 336 | # check whether we are close to another edge seed 337 | for p_seed in edge_seeds: 338 | dist = curves.point_distance(p, p_seed) 339 | if dist < 1.5: 340 | # distance should be either 1 or sqrt(2) 341 | if dist > 0: 342 | points.append(p_seed) 343 | node = edge_seeds.pop(p_seed) 344 | points.append(graph.node[node]['coords']) 345 | break 346 | else: 347 | # could not find a close edge seed => this is an end point 348 | node = graph.add_node_point(p) 349 | 350 | # check whether we have to branch off other edges 351 | if neighbor_count > 0: 352 | assert neighbor_count > 1 353 | # current points is a crossing => branch off new edge seeds 354 | # find all points from which we have to branch off 355 | dps = np.transpose(np.nonzero(ps_n)) 356 | # initialize all edge seeds 357 | seeds = set([(p[0] + dx - 1, p[1] + dy - 1) 358 | for dy, dx in dps]) 359 | while seeds: 360 | # check the neighbor hood of the seed point 361 | p_seed = seeds.pop() 362 | skeleton[p_seed[1], p_seed[0]] = 0 363 | ps_n = skeleton[p_seed[1]-1 : p_seed[1]+2, 364 | p_seed[0]-1 : p_seed[0]+2] 365 | neighbor_count = ps_n.sum() 366 | #print 'test_seed', p_seed, neighbor_count 367 | if neighbor_count == 1: 368 | edge_seeds[p_seed] = node 369 | else: 370 | # add more seeds 371 | dps = np.transpose(np.nonzero(ps_n)) 372 | for dy, dx in dps: 373 | p = (p_seed[0] + dx - 1, p_seed[1] + dy - 1) 374 | if p not in seeds and p not in edge_seeds: 375 | seeds.add(p) 376 | 377 | # add the edge to the graph 378 | graph.add_edge_line(start_node, node, points) 379 | 380 | if post_process: 381 | # remove small edges and self-loops 382 | graph.remove_short_edges(4) 383 | # remove nodes of degree 2 384 | graph.simplify() 385 | 386 | return graph 387 | 388 | 389 | def debug_visualization(self, **kwargs): 390 | """ visualizes the morphological graph. 391 | Keyword arguments are passed on to the `debug.show_shape` function 392 | """ 393 | from .. import debug 394 | 395 | debug.show_shape(geometry.MultiLineString(self.get_edge_curves()), 396 | geometry.MultiPoint(self.get_node_points()), 397 | **kwargs) 398 | 399 | 400 | -------------------------------------------------------------------------------- /video/io/parallel.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 22, 2014 3 | 4 | @author: David Zwicker 5 | 6 | Establishes a video pipe, which can be used to transfer video frames between 7 | different processes. 8 | The module provides the class VideoPipe, which controls the pipe and supplies 9 | a VideoPipeReceiver instance as video_pipe.receiver, which acts as a video for the 10 | child process. 11 | The synchronization and communication between these classes is handled using 12 | normal pipes, while frames are transported using the sharedmem package. 13 | ''' 14 | 15 | from __future__ import division 16 | 17 | import logging 18 | import multiprocessing as mp 19 | import time 20 | 21 | import numpy as np 22 | import sharedmem 23 | 24 | from .base import VideoBase, VideoFilterBase, SynchronizationError 25 | from .file import VideoFile 26 | from utils.concurrency import WorkerThread 27 | 28 | logger = logging.getLogger('video.io') 29 | 30 | 31 | class VideoPipeError(RuntimeError): pass 32 | 33 | START_TIME = time.time() 34 | 35 | class VideoPipeReceiver(VideoBase): 36 | """ class that receives frames from a VideoPipe. 37 | This class usually needs not be instantiated directly, but is returned by 38 | video_pipe.receiver 39 | """ 40 | 41 | def __init__(self, pipe, frame_buffer, video_format=None, name=''): 42 | # initialize the VideoBase 43 | if video_format is None: 44 | video_format = {} 45 | super(VideoPipeReceiver, self).__init__(**video_format) 46 | 47 | # set extra arguments of the video receiver 48 | self.pipe = pipe 49 | self.frame_buffer = frame_buffer 50 | self.name = name 51 | self.name_repr = '' if self.name is None else ' `%s`' % self.name 52 | 53 | 54 | def send_command(self, command, wait_for_reply=True): 55 | """ send a command to the associated VideoPipe """ 56 | if not self.pipe.closed: 57 | logger.debug('Send command `%s`.', command) 58 | self.pipe.send(command) 59 | # wait for the sender to acknowledge the command 60 | if wait_for_reply and self.pipe.recv() != command + '_OK': 61 | raise VideoPipeError('Command `%s` failed' % command) 62 | 63 | 64 | def abort_iteration(self): 65 | logger.debug('Receiver%s aborts iteration.', self.name_repr) 66 | self.send_command('abort_iteration', wait_for_reply=False) 67 | super(VideoPipeReceiver, self).abort_iteration() 68 | 69 | 70 | def wait_for_frame(self, index=None): 71 | """ request a _frame from the sender """ 72 | # abort iteration if the pipe has been closed 73 | if self.pipe.closed: 74 | self.abort_iteration() 75 | raise SystemExit 76 | 77 | # send the request 78 | if index is None: 79 | self.pipe.send('next_frame') 80 | else: 81 | self.pipe.send('specific_frame') 82 | self.pipe.send(index) 83 | # wait for reply 84 | reply = self.pipe.recv() 85 | 86 | # handle the reply 87 | if reply == 'frame_ready': 88 | # set the internal pointer to the next _frame 89 | self._frame_pos += 1 90 | # return the _frame 91 | return self.frame_buffer 92 | 93 | elif reply == StopIteration: 94 | # signal that the iterator is exhausted 95 | raise StopIteration 96 | 97 | elif reply == 'abort_iteration': 98 | # signal that the iteration was aborted 99 | self.abort_iteration() 100 | raise SystemExit 101 | 102 | else: 103 | raise VideoPipeError('Unknown reply `%s`', reply) 104 | 105 | 106 | def get_next_frame(self): 107 | """ request the next _frame from the sender """ 108 | return self.wait_for_frame() 109 | 110 | 111 | def get_frame(self, index): 112 | """ request a specific _frame from the sender """ 113 | if index < 0: 114 | index += self.frame_count 115 | return self.wait_for_frame(index) 116 | 117 | 118 | def close(self): 119 | self.send_command('finished', wait_for_reply=False) 120 | self.pipe.close() 121 | logger.debug('Receiver%s closed itself.', self.name_repr) 122 | 123 | 124 | 125 | class VideoPipeSender(VideoFilterBase): 126 | """ class that can be used to transport video frames between processes. 127 | Internally, the class uses sharedmem to share memory among different 128 | processes. This class has an event loop which handles commands entering 129 | via a normal pipe from the VideoPipeReceiver. This construct can be used 130 | to read a video in the current process and work on it in a different 131 | process. 132 | 133 | If read_ahead is True, the next _frame is already read before it is 134 | requested. 135 | """ 136 | 137 | poll_frequency = 200 #< Frequency of polling pipe in Hz 138 | 139 | def __init__(self, video, pipe, frame_buffer, name=None, read_ahead=False): 140 | super(VideoPipeSender, self).__init__(video) 141 | self.pipe = pipe 142 | self.frame_next = None 143 | self.name = name 144 | self.read_ahead = read_ahead 145 | self.running = True 146 | self._waiting_for_frame = False 147 | self._waiting_for_read_ahead = False 148 | self.frame_buffer = frame_buffer 149 | 150 | 151 | def try_reading_ahead(self): 152 | """ tries to retrieve a _frame from the video and copy it to the shared 153 | frame_buffer """ 154 | try: 155 | # get the next _frame 156 | self.frame_next = self.get_next_frame() 157 | 158 | except SynchronizationError: 159 | self._waiting_for_read_ahead = True 160 | 161 | except StopIteration: 162 | # we reached the end of the video and the iteration should stop 163 | self.frame_next = StopIteration 164 | 165 | else: 166 | self._waiting_for_read_ahead = False 167 | 168 | 169 | def try_getting_frame(self, index=None): 170 | """ tries to retrieve a _frame from the video and copy it to the shared 171 | frame_buffer """ 172 | try: 173 | # get the next _frame 174 | if index is None or index == self.get_frame_pos(): 175 | self.frame_buffer[:] = self.get_next_frame() 176 | else: 177 | self.frame_buffer[:] = self.get_frame(index) 178 | 179 | except SynchronizationError: 180 | # _frame is not ready yet, wait another round 181 | self._waiting_for_frame = True 182 | 183 | except StopIteration: 184 | # we reached the end of the video and the iteration should stop 185 | self._waiting_for_frame = False 186 | self.pipe.send(StopIteration) 187 | 188 | else: 189 | # _frame is ready and was copied to the shared frame_buffer 190 | self._waiting_for_frame = False 191 | # notify the receiver that the _frame is ready 192 | self.pipe.send('frame_ready') 193 | if self.read_ahead and index is None: 194 | self.try_reading_ahead() 195 | 196 | 197 | def abort_iteration(self): 198 | """ abort the iteration and notify the receiver """ 199 | if not self.pipe.closed: 200 | self.pipe.send('abort_iteration') 201 | self.running = False 202 | super(VideoPipeSender, self).abort_iteration() 203 | 204 | 205 | def load_next_frame(self): 206 | """ tries loading the next _frame, either directly from the video 207 | or from the buffer that has been filled by a read-ahead process """ 208 | if self.read_ahead: 209 | if self._waiting_for_frame: 210 | # this should not happen, since the event loop only finishes 211 | # when the _frame was successfully read 212 | raise VideoPipeError('Frame was not properly read in advance.') 213 | 214 | elif self.frame_next is None: 215 | # _frame is not buffered 216 | # => read it directly and notify receiver 217 | self.try_getting_frame() 218 | 219 | elif self.frame_next is StopIteration: 220 | # we reached the end of the iteration 221 | self.pipe.send(StopIteration) 222 | 223 | else: 224 | # copy _frame to the right buffer 225 | self.frame_buffer[:] = self.frame_next 226 | self.frame_next = None 227 | # tell receiver that the _frame is ready 228 | self.pipe.send('frame_ready') 229 | # try getting the next _frame, which flags self._waiting_for_frame 230 | # and thus starts the event loop 231 | self._waiting_for_read_ahead = True 232 | 233 | else: 234 | self.try_getting_frame() 235 | 236 | 237 | def handle_command(self, command): 238 | """ handles commands received from the VideoPipeReceiver """ 239 | if command == 'next_frame': 240 | # receiver requests the next _frame 241 | self.load_next_frame() 242 | 243 | elif command == 'abort_iteration': 244 | # receiver reached the end of the iteration 245 | self.abort_iteration() 246 | 247 | elif command == 'specific_frame': 248 | # receiver requests a specific _frame 249 | frame_id = self.pipe.recv() 250 | logger.debug('Specific _frame %d was requested from sender.', 251 | frame_id) 252 | self.try_getting_frame(index=frame_id) 253 | 254 | elif command == 'finished': 255 | # the receiver wants to terminate the video pipe 256 | if not self.pipe.closed: 257 | self.pipe.send('finished_OK') 258 | self.pipe.close() 259 | logger.debug('Sender%s closed itself.', 260 | '' if self.name is None else ' `%s`' % self.name) 261 | self.running = False 262 | 263 | else: 264 | raise VideoPipeError('Unknown command `%s`', command) 265 | 266 | 267 | def check(self): 268 | """ handles a command if one has been sent """ 269 | # see whether we are waiting for a _frame 270 | if self._waiting_for_frame: 271 | self.try_getting_frame() 272 | 273 | if self._waiting_for_read_ahead: 274 | self.try_reading_ahead() 275 | 276 | # otherwise check the pipe for new commands 277 | if not self.pipe.closed and self.pipe.poll(): 278 | command = self.pipe.recv() 279 | self.handle_command(command) 280 | 281 | return self.running 282 | 283 | 284 | def start(self): 285 | """ starts the event loop which handles commands until the receiver 286 | is finished """ 287 | try: 288 | while self.running and not self.pipe.closed: 289 | # wait for a command of the receiver 290 | command = self.pipe.recv() 291 | self.handle_command(command) 292 | 293 | # wait for frames to be retrieved 294 | while self.running and (self._waiting_for_frame 295 | or self._waiting_for_read_ahead): 296 | 297 | time.sleep(1/self.poll_frequency) 298 | if self._waiting_for_frame: 299 | self.try_getting_frame() 300 | 301 | if self._waiting_for_read_ahead: 302 | self.try_reading_ahead() 303 | 304 | except (KeyboardInterrupt, SystemExit): 305 | self.abort_iteration() 306 | 307 | 308 | 309 | def create_video_pipe(video, name=None, read_ahead=False): 310 | """ creates the two ends of a video pipe. 311 | 312 | The typical use case is 313 | 314 | def worker_process(self, video): 315 | ''' worker process processing a video ''' 316 | expensive_function(video) 317 | 318 | if __name__ == '__main__': 319 | # load a video file 320 | video = VideoFile('test.mov') 321 | # create the video pipe 322 | sender, receiver = create_video_pipe(video) 323 | # create the worker process 324 | proc = multiprocessing.Process(target=worker_process, 325 | args=(receiver,)) 326 | proc.start() 327 | sender.start() 328 | 329 | """ 330 | # create the pipe used for communication 331 | pipe_sender, pipe_receiver = mp.Pipe(duplex=True) 332 | # create the buffer in memory that is used for passing frames 333 | frame_buffer = sharedmem.empty(video.shape[1:], np.uint8) 334 | 335 | # create the two ends of the video pipe 336 | sender = VideoPipeSender(video, pipe_sender, frame_buffer, 337 | name, read_ahead) 338 | receiver = VideoPipeReceiver(pipe_receiver, frame_buffer, 339 | video.video_format, name) 340 | 341 | return sender, receiver 342 | 343 | 344 | 345 | class VideoReaderProcess(mp.Process): 346 | """ Process that reads a video and returns it using a video pipe """ 347 | def __init__(self, filename, video_class=VideoFile): 348 | super(VideoReaderProcess, self).__init__() 349 | self.daemon = True 350 | self.running = False 351 | self.filename = filename 352 | self.video_class = video_class 353 | 354 | # create the pipe used for communication 355 | self.pipe_sender, pipe_receiver = mp.Pipe(duplex=True) 356 | 357 | video = self.video_class(self.filename) 358 | # create the buffer in memory that is used for passing frames 359 | self.frame_buffer = sharedmem.empty(video.shape[1:], np.uint8) 360 | self.receiver = VideoPipeReceiver(pipe_receiver, self.frame_buffer, video.video_format) 361 | video.close() 362 | 363 | 364 | def run(self): 365 | logger.debug('Started process %d to read video' % self.pid) 366 | video = self.video_class(self.filename) 367 | video_sender = VideoPipeSender(video, self.pipe_sender, self.frame_buffer) 368 | self.running = True 369 | video_sender.start() 370 | 371 | 372 | def terminate(self): 373 | self.video_pipe.abort_iteration() 374 | 375 | 376 | 377 | def video_reader_process(filename, video_class=VideoFile): 378 | """ reads the given filename in a separate process. 379 | The given video_class is used to open the file. """ 380 | proc = VideoReaderProcess(filename, video_class) 381 | proc.start() 382 | return proc.receiver 383 | 384 | 385 | 386 | class VideoPreprocessor(object): 387 | """ class that reads video in a separate thread and apply additional 388 | functions using additional threads. 389 | 390 | Example: Given a `video` and a function `blur_frame` that takes an image 391 | and returns a blurred one, the class can be used as follows 392 | 393 | video_processor = VideoPreprocessor(video, {'blur': blur_frame}) 394 | for data in video_processor: 395 | frame_raw = data['raw'] 396 | frame_blurred = data['blur'] 397 | 398 | Importantly, the function used for preprocessing should release the python 399 | global interpreter lock (GIL) most of the time such that multiple threads 400 | can be run concurrently. 401 | """ 402 | 403 | def __init__(self, video, functions, preprocess=None, use_threads=True): 404 | """ initializes the preprocessor 405 | `video` is the video to be iterated over 406 | `functions` is a dictionary of functions that should be applied while 407 | iterating 408 | `preprocess` can be a function that will be applied to the frame before 409 | anything is returned 410 | """ 411 | if 'raw' in functions: 412 | raise KeyError('The key `raw` is reserved for the raw _frame and ' 413 | 'may not be used for functions.') 414 | 415 | self.length = len(video) 416 | self.video_iter = iter(video) 417 | self.functions = functions 418 | self.preprocess = preprocess 419 | 420 | # initialize internal structures 421 | self._frame = None 422 | 423 | # initialize the background workers 424 | self._worker_next_frame = WorkerThread(self._get_next_frame, 425 | use_threads=use_threads) 426 | self._workers = {name: WorkerThread(func, use_threads=use_threads) 427 | for name, func in self.functions.iteritems()} 428 | 429 | self._init_next_processing(self._get_next_frame()) 430 | 431 | # 432 | 433 | def __len__(self): 434 | return self.length 435 | 436 | 437 | def _get_next_frame(self): 438 | """ get the next frame and preprocess it if necessary """ 439 | try: 440 | frame = self.video_iter.next() 441 | except StopIteration: 442 | frame = None 443 | else: 444 | if self.preprocess: 445 | frame = self.preprocess(frame) 446 | return frame 447 | 448 | 449 | def _init_next_processing(self, frame_next): 450 | """ prepare the next processed frame in the background 451 | `frame_next` is the raw data of this _frame 452 | """ 453 | self._frame = frame_next 454 | # ask all workers to process this frame 455 | for worker in self._workers.itervalues(): 456 | worker.put(frame_next) 457 | # ask for the next frame 458 | self._worker_next_frame.put() 459 | 460 | 461 | def __iter__(self): 462 | return self 463 | 464 | 465 | def next(self): 466 | """ grab the raw and processed data of the next frame """ 467 | # check whether there is data available 468 | if self._frame is None: 469 | raise StopIteration 470 | 471 | # grab all results for the current _frame 472 | result = {name: worker.get() 473 | for name, worker in self._workers.iteritems()} 474 | # store information about the current frame 475 | result['raw'] = self._frame 476 | 477 | # grab the next frame 478 | frame_next = self._worker_next_frame.get() 479 | if frame_next is None: 480 | # stop the iteration in the next step. We still have to exit from 481 | # this function since we have results to return 482 | self._frame = None 483 | else: 484 | # start fetching the result for this next frame 485 | self._init_next_processing(frame_next) 486 | 487 | # while this is underway, return the current results 488 | return result 489 | 490 | -------------------------------------------------------------------------------- /video/filters.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 1, 2014 3 | 4 | @author: David Zwicker 5 | 6 | Filter are iterators that take a video as an input and return a special Video 7 | that can be iterated over, but that doesn't store its data in memory. 8 | 9 | Some filters allow to access the underlying frames at random using the 10 | get_frame method 11 | 12 | ''' 13 | 14 | from __future__ import division 15 | 16 | import logging 17 | import numpy as np 18 | 19 | try: 20 | import cv2 21 | except ImportError: 22 | print("OpenCV was not found. Functions requiring OpenCV will not work.") 23 | 24 | 25 | from .io.base import VideoFilterBase 26 | from .analysis.regions import rect_to_slices 27 | from utils.math import get_number_range 28 | 29 | logger = logging.getLogger('video') 30 | 31 | # translation dictionary for color channels 32 | COLOR_CHANNELS = {'blue': 0, 'b': 0, 0: 0, 33 | 'green': 1, 'g': 1, 1: 1, 34 | 'red': 2, 'r': 2, 2: 2} 35 | 36 | 37 | 38 | def get_color_range(dtype): 39 | """ 40 | determines the color depth of the numpy array `data`. 41 | If the dtype is an integer, the range that it can hold is returned. 42 | If dtype is an inexact number (a float point), zero and one is returned 43 | """ 44 | if np.issubdtype(dtype, np.integer): 45 | info = np.iinfo(dtype) 46 | return info.min, info.max 47 | elif np.issubdtype(dtype, np.floating): 48 | return 0, 1 49 | else: 50 | raise ValueError('Unsupported data type `%r`' % dtype) 51 | 52 | 53 | 54 | 55 | 56 | class FilterFunction(VideoFilterBase): 57 | """ applies function to every frame """ 58 | 59 | def __init__(self, source, function): 60 | 61 | self._function = function 62 | 63 | super(FilterFunction, self).__init__(source) 64 | 65 | logger.debug('Created filter applying a function to every _frame') 66 | 67 | 68 | def _process_frame(self, frame): 69 | # process the current _frame 70 | frame = self._function(frame) 71 | # pass it to the parent function 72 | return super(FilterFunction, self)._process_frame(frame) 73 | 74 | 75 | 76 | class FilterNormalize(VideoFilterBase): 77 | """ normalizes a color range to the interval 0..1 """ 78 | 79 | def __init__(self, source, vmin=None, vmax=None, dtype=None): 80 | """ 81 | warning: 82 | vmin must not be smaller than the smallest value source can hold. 83 | Otherwise wrapping can occur. The same thing holds for vmax, which 84 | must not be larger than the maximum value in the color channels. 85 | """ 86 | 87 | # interval From which to convert 88 | self._fmin = vmin 89 | self._fmax = vmax 90 | 91 | # interval To which to convert 92 | self._dtype = dtype 93 | self._tmin = None 94 | self._alpha = None 95 | 96 | super(FilterNormalize, self).__init__(source) 97 | logger.debug('Created filter for normalizing range [%g..%g]', 98 | vmin, vmax) 99 | 100 | 101 | def _process_frame(self, frame): 102 | 103 | # get dtype and bounds from first _frame if they were not specified 104 | if self._dtype is None: 105 | self._dtype = frame.dtype 106 | if self._fmin is None: 107 | self._fmin = frame.min() 108 | if self._fmax is None: 109 | self._fmax = frame.max() 110 | 111 | # ensure that we know the bounds of this dtype 112 | if self._tmin is None: 113 | self._tmin, tmax = get_color_range(self._dtype) 114 | self._alpha = (tmax - self._tmin)/(self._fmax - self._fmin) 115 | 116 | # some safety checks on the first run: 117 | fmin, fmax = get_number_range(frame.dtype) 118 | if self._fmin < fmin: 119 | logger.warn('Lower normalization bound is below what the ' 120 | 'format can hold.') 121 | if self._fmax > fmax: 122 | logger.warn('Upper normalization bound is above what the ' 123 | 'format can hold.') 124 | 125 | # clip the data before converting 126 | np.clip(frame, self._fmin, self._fmax, out=frame) 127 | 128 | # do the conversion from [fmin, fmax] to [tmin, tmax] 129 | frame = (frame - self._fmin)*self._alpha + self._tmin 130 | 131 | # cast the data to the right type 132 | frame = frame.astype(self._dtype) 133 | 134 | # pass the _frame to the parent function 135 | return super(FilterNormalize, self)._process_frame(frame) 136 | 137 | 138 | 139 | def _check_coordinate(value, max_value): 140 | """ helper function checking the bounds of the rectangle """ 141 | if -1 < value < 1: 142 | # convert to integer by interpreting float values as fractions 143 | value = int(value * max_value) 144 | 145 | # interpret negative numbers as counting from opposite boundary 146 | if value < 0: 147 | value += max_value 148 | 149 | # check whether the value is within bounds 150 | if not 0 <= value < max_value: 151 | raise IndexError('Coordinate %d is out of bounds [0, %d].' 152 | % (value, max_value)) 153 | 154 | return value 155 | 156 | 157 | 158 | class FilterCrop(VideoFilterBase): 159 | """ crops the video to the given region """ 160 | 161 | def __init__(self, source, rect=None, region='', color_channel=None, 162 | size_alignment=1): 163 | """ 164 | initialized the filter that crops the video to the specified rectangle. 165 | 166 | The rectangle can be either given directly by supplying 167 | rect=(left, top, width, height) or a region can be specified in the 168 | `region` parameter, which can take the following values : 'lower', 169 | 'upper', 'left', and 'right or combinations thereof. If both `rect` and 170 | `region` are supplied, the `region` is discarded. 171 | 172 | If color_channel is given, it is assumed that the input video is a color 173 | video and only the specified color channel is returned, thus turning 174 | the video into a monochrome one. 175 | 176 | `size_alignment` can be given to force the width and the height to be a 177 | multiple of the given integer. This might be useful to force the 178 | size to be an even number, which some video codecs require. The 179 | default value is 1 and the width and the height is thus any integer. 180 | """ 181 | source_width, source_height = source.size 182 | 183 | if rect is not None: 184 | # interpret float values as fractions of width/height 185 | left = _check_coordinate(rect[0], source_width) 186 | top = _check_coordinate(rect[1], source_height) 187 | width = _check_coordinate(rect[2], source_width) 188 | height = _check_coordinate(rect[3], source_height) 189 | 190 | else: 191 | # construct the rect from the given string 192 | region = region.lower() 193 | left, top = 0, 0 194 | width, height = source_width, source_height 195 | 196 | if 'left' in region: 197 | width //= 2 198 | elif 'right' in region: 199 | width //= 2 200 | left = source_width - width 201 | 202 | if 'upper' in region: 203 | height //= 2 204 | elif 'lower' in region: 205 | height //= 2 206 | top = source_height - height 207 | 208 | # contract with parent crop filters, if they exist 209 | while isinstance(source, FilterCrop): 210 | logger.debug('Combine this crop filter with the parent one.') 211 | left += source.rect[0] 212 | top += source.rect[1] 213 | if source.color_channel is not None: 214 | color_channel = source.color_channel 215 | source = source._source 216 | 217 | # extract color information 218 | self.color_channel = COLOR_CHANNELS.get(color_channel, color_channel) 219 | is_color = None if color_channel is None else False 220 | 221 | # enforce alignment 222 | if size_alignment != 1: 223 | # we use round to make sure we pick the size that is closest to the 224 | # specified one 225 | width = int(round(width / size_alignment) * size_alignment) 226 | height = int(round(height / size_alignment) * size_alignment) 227 | 228 | # create the rectangle and store it 229 | self.rect = (left, top, width, height) 230 | self.slices = rect_to_slices(self.rect) 231 | 232 | # correct the size, since we are going to crop the movie 233 | super(FilterCrop, self).__init__(source, size=self.rect[2:], 234 | is_color=is_color) 235 | logger.debug('Created filter for cropping to rectangle %s', self.rect) 236 | 237 | 238 | def _process_frame(self, frame): 239 | if self.color_channel is None: 240 | # extract the given rectangle 241 | frame = frame[self.slices] 242 | 243 | else: 244 | # extract the given rectangle and get the color channel 245 | frame = frame[self.slices[0], self.slices[1], self.color_channel] 246 | 247 | # pass the _frame to the parent function 248 | return super(FilterCrop, self)._process_frame(frame) 249 | 250 | 251 | 252 | class FilterResize(VideoFilterBase): 253 | """ resizes the video to a new size """ 254 | 255 | def __init__(self, source, size=None, interpolation='auto', 256 | even_dimensions=False): 257 | """ 258 | initialized the filter that crops to the given size=(width, height) 259 | If size is a single value it is interpreted as a factor of the input 260 | video size. 261 | `interpolation` chooses the interpolation used for the resizing 262 | `even_dimensions` is a flag that determines whether the image 263 | dimensions are enforced to be even numbers 264 | """ 265 | # determine target size 266 | if hasattr(size, '__iter__'): 267 | width, height = size 268 | else: 269 | width = int(source.size[0] * size) 270 | height = int(source.size[1] * size) 271 | if even_dimensions: 272 | width += (width % 2) 273 | height += (height % 2) 274 | 275 | # set interpolation method 276 | if (width, height) == source.size: 277 | self.interpolation = None 278 | elif interpolation == 'auto': 279 | if width*height < source.size[0]*source.size[1]: 280 | # image size is decreased 281 | self.interpolation = cv2.INTER_AREA 282 | else: 283 | # image size is increased 284 | self.interpolation = cv2.INTER_CUBIC 285 | elif interpolation == 'nearest': 286 | self.interpolation = cv2.INTER_NEAREST 287 | elif interpolation == 'linear': 288 | self.interpolation = cv2.INTER_LINEAR 289 | elif interpolation == 'area': 290 | self.interpolation = cv2.INTER_AREA 291 | elif interpolation == 'cubic': 292 | self.interpolation = cv2.INTER_CUBIC 293 | elif interpolation == 'lanczos': 294 | self.interpolation = cv2.INTER_LANCZOS4 295 | else: 296 | raise ValueError('Unknown interpolation method: %s', interpolation) 297 | 298 | # contract with parent crop filters, if they exist 299 | while isinstance(source, FilterResize): 300 | logger.debug('Combine this resize filter with the parent one.') 301 | source = source._source 302 | 303 | # correct the size, since we are going to crop the movie 304 | super(FilterResize, self).__init__(source, size=(width, height)) 305 | logger.debug('Created filter for resizing to size %dx%d', width, height) 306 | 307 | 308 | def _process_frame(self, frame): 309 | # resize the frame if necessary 310 | if self.interpolation: 311 | frame = cv2.resize(frame, self.size, 312 | interpolation=self.interpolation) 313 | 314 | # pass the frame to the parent function 315 | return super(FilterResize, self)._process_frame(frame) 316 | 317 | 318 | 319 | class FilterRotate(VideoFilterBase): 320 | """ returns the video rotated in counter-clockwise direction """ 321 | 322 | def __init__(self, source, angle=0): 323 | """ rotate the video by angle in counter-clockwise direction """ 324 | angle = angle % 360 325 | 326 | if angle == 0 or angle == 180: 327 | size = source.size 328 | elif angle == 90 or angle == 270: 329 | size = (source.size[1], source.size[0]) 330 | else: 331 | raise ValueError('angle must be from [0, 90, 180, 270] but was %s' 332 | % angle) 333 | self.angle = angle 334 | 335 | # correct the size, since we are going to crop the movie 336 | super(FilterRotate, self).__init__(source, size=size) 337 | 338 | 339 | def _process_frame(self, frame): 340 | # rotate the array 341 | frame = np.rot90(frame, self.angle // 90) 342 | 343 | # pass the frame to the parent function 344 | return super(FilterRotate, self)._process_frame(frame) 345 | 346 | 347 | 348 | class FilterMonochrome(VideoFilterBase): 349 | """ returns the video as monochrome """ 350 | 351 | def __init__(self, source, mode='mean'): 352 | self.mode = COLOR_CHANNELS.get(mode.lower(), mode.lower()) 353 | super(FilterMonochrome, self).__init__(source, is_color=False) 354 | 355 | logger.debug('Created filter for converting video to monochrome with ' 356 | 'method `%s`', mode) 357 | 358 | 359 | def _process_frame(self, frame): 360 | """ 361 | reduces a single _frame from color to monochrome, but keeps the 362 | extra dimension in the data 363 | """ 364 | try: 365 | if self.mode == 'mean': 366 | frame = np.mean(frame, axis=2).astype(frame.dtype) 367 | else: 368 | frame = frame[:, :, self.mode] 369 | except ValueError: 370 | raise ValueError('Unsupported conversion method to monochrome: %s' 371 | % self.mode) 372 | 373 | # pass the frame to the parent function 374 | return super(FilterMonochrome, self)._process_frame(frame) 375 | 376 | 377 | 378 | class FilterBlur(VideoFilterBase): 379 | """ returns the video with a Gaussian blur filter """ 380 | 381 | def __init__(self, source, sigma=3): 382 | self.sigma = sigma 383 | super(FilterBlur, self).__init__(source) 384 | 385 | logger.debug('Created filter blurring the video with radius %g', sigma) 386 | 387 | 388 | def _process_frame(self, frame): 389 | """ 390 | blurs a single _frame 391 | """ 392 | return cv2.GaussianBlur(frame.astype(np.uint8), (0, 0), self.sigma) 393 | 394 | 395 | 396 | class FilterReplicate(VideoFilterBase): 397 | """ replicates the video `count` times """ 398 | 399 | def __init__(self, source, count=1): 400 | 401 | self.count = count 402 | 403 | # calculate the number of frames to be expected 404 | frame_count = source.frame_count * count 405 | 406 | # correct the size, since we are going to crop the movie 407 | super(FilterReplicate, self).__init__(source, frame_count=frame_count) 408 | 409 | 410 | def set_frame_pos(self, index): 411 | if index < 0: 412 | index += self.frame_count 413 | 414 | if not 0 <= index < self.frame_count: 415 | raise IndexError('Cannot access frame %d.' % index) 416 | 417 | self._source.set_frame_pos(index % self._source.frame_count) 418 | self._frame_pos = index 419 | 420 | 421 | def get_next_frame(self): 422 | if self.get_frame_pos() % self._source.frame_count == 0: 423 | # rewind source video 424 | self._source.set_frame_pos(0) 425 | 426 | frame = self._source.get_next_frame() 427 | 428 | # advance to the next _frame 429 | self._frame_pos += 1 430 | return frame 431 | 432 | 433 | 434 | class FilterDropFrames(VideoFilterBase): 435 | """ removes frames to accelerate the video """ 436 | 437 | def __init__(self, source, compression=1): 438 | """ `source` is the source video and `compression` sets the compression 439 | factor """ 440 | 441 | self._compression = compression 442 | fps = source.fps / self._compression 443 | 444 | # calculate the number of frames to be expected 445 | frame_count = int((source.frame_count - 1) / self._compression) + 1 446 | 447 | # correct the duration and the fps 448 | super(FilterDropFrames, self).__init__(source, frame_count=frame_count, 449 | fps=fps) 450 | 451 | logger.debug('Created filter to change frame rate from %g to %g ' 452 | '(compression factor: %g)' % 453 | (source.fps, self.fps, self._compression)) 454 | 455 | 456 | def _source_index(self, index): 457 | """ calculates the index in the source video corresponding to the given 458 | `index` in the current video """ 459 | return int(index * self._compression) 460 | 461 | 462 | def set_frame_pos(self, index): 463 | if index < 0: 464 | index += self.frame_count 465 | if not 0 <= index < self.frame_count: 466 | raise IndexError('Cannot access frame %d.' % index) 467 | 468 | self._source.set_frame_pos(self._source_index(index)) 469 | self._frame_pos = index 470 | 471 | 472 | def get_frame(self, index): 473 | if index < 0: 474 | index += self.frame_count 475 | frame = self._source[self._source_index(index)] 476 | self._frame_pos = index + 1 477 | return frame 478 | 479 | 480 | def get_next_frame(self): 481 | frame = self._source[self._source_index(self._frame_pos)] 482 | self._frame_pos += 1 483 | return frame 484 | 485 | 486 | 487 | #=============================================================================== 488 | # FILTERS THAT ANALYZE CONSECUTIVE FRAMES 489 | #=============================================================================== 490 | 491 | 492 | class FilterDiffBase(VideoFilterBase): 493 | """ 494 | Base class for filtering a video based on comparing consecutive frames. 495 | """ 496 | 497 | def __init__(self, source): 498 | """ 499 | dtype contains the dtype that is used to calculate the difference. 500 | If dtype is None, no type casting is done. 501 | """ 502 | 503 | self._prev_frame = None 504 | 505 | # correct the _frame count since we are going to return differences 506 | super(FilterDiffBase, self).__init__(source, 507 | frame_count=source.frame_count - 1) 508 | 509 | 510 | def set_frame_pos(self, index): 511 | if index < 0: 512 | index += self.frame_count 513 | # set the underlying movie to requested position 514 | self._source.set_frame_pos(index) 515 | # advance one _frame and save it in the previous _frame structure 516 | self._prev_frame = self._source.next() 517 | 518 | 519 | def _compare_frames(self, this_frame, prev_frame): 520 | raise NotImplementedError 521 | 522 | 523 | def get_frame(self, index): 524 | if index < 0: 525 | index += self.frame_count 526 | return self._compare_frames(self._source.get_frame(index + 1), 527 | self._source.get_frame(index)) 528 | 529 | 530 | def next(self): 531 | # get this _frame and evaluate it 532 | this_frame = self._source.next() 533 | result = self._compare_frames(this_frame, self._prev_frame) 534 | 535 | # this _frame will be the previous _frame of the next one 536 | self._prev_frame = this_frame 537 | 538 | return result 539 | 540 | 541 | 542 | class FilterTimeDifference(FilterDiffBase): 543 | """ 544 | returns the differences between consecutive frames. 545 | This filter is best used by just iterating over it. Retrieving individual 546 | _frame differences can be a bit slow, since two frames have to be loaded. 547 | """ 548 | 549 | def __init__(self, source, dtype=np.int16): 550 | """ 551 | dtype contains the dtype that is used to calculate the difference. 552 | If dtype is None, no type casting is done. 553 | """ 554 | 555 | self._dtype = dtype 556 | 557 | # correct the _frame count since we are going to return differences 558 | super(FilterTimeDifference, self).__init__(source) 559 | 560 | logger.debug('Created filter for calculating differences between ' 561 | 'consecutive frames.') 562 | 563 | 564 | def _compare_frames(self, this_frame, prev_frame): 565 | # cast into different dtype if requested 566 | if self._dtype is not None: 567 | this_frame = this_frame.astype(self._dtype) 568 | return this_frame - prev_frame 569 | 570 | 571 | 572 | class FilterOpticalFlow(FilterDiffBase): 573 | """ 574 | calculates the flow of consecutive frames 575 | """ 576 | 577 | def __init__(self, *args, **kwargs): 578 | super(FilterOpticalFlow, self).__init__(*args, **kwargs) 579 | 580 | 581 | def _compare_frames(self, this_frame, prev_frame): 582 | flow = cv2.calcOpticalFlowFarneback(prev_frame, this_frame, 583 | pyr_scale=0.5, levels=3, 584 | winsize=2, iterations=3, poly_n=5, 585 | poly_sigma=1.2, flags=0) 586 | 587 | mag, _ = cv2.cartToPolar(flow[..., 0], flow[..., 1]) 588 | 589 | return mag 590 | 591 | -------------------------------------------------------------------------------- /video/analysis/regions.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on Aug 4, 2014 3 | 4 | @author: David Zwicker 5 | ''' 6 | 7 | from __future__ import division 8 | 9 | from collections import defaultdict 10 | import itertools 11 | 12 | import cv2 13 | import numpy as np 14 | from scipy import ndimage 15 | from shapely import geometry, geos 16 | 17 | import curves 18 | from external import simplify_polygon_visvalingam as simple_poly 19 | 20 | from .. import debug # @UnusedImport 21 | 22 | 23 | def corners_to_rect(p1, p2): 24 | """ creates a rectangle from two corner points. 25 | The points are both included in the rectangle. 26 | """ 27 | xmin, xmax = min(p1[0], p2[0]), max(p1[0], p2[0]) 28 | ymin, ymax = min(p1[1], p2[1]), max(p1[1], p2[1]) 29 | return (xmin, ymin, xmax - xmin + 1, ymax - ymin + 1) 30 | 31 | 32 | 33 | def rect_to_corners(rect, count=2): 34 | """ returns `count` corner points for a rectangle. 35 | These points are included in the rectangle. 36 | count determines the number of corners. 2 and 4 are allowed values. 37 | """ 38 | p1 = (rect[0], rect[1]) 39 | p2 = (rect[0] + rect[2] - 1, rect[1] + rect[3] - 1) 40 | if count == 2: 41 | return p1, p2 42 | elif count == 4: 43 | return p1, (p2[0], p1[1]), p2, (p1[0], p2[1]) 44 | else: 45 | raise ValueError('count must be 2 or 4 (cannot be %d)' % count) 46 | 47 | 48 | 49 | def rect_to_slices(rect): 50 | """ creates slices for an array from a rectangle """ 51 | slice_x = slice(rect[0], rect[2] + rect[0]) 52 | slice_y = slice(rect[1], rect[3] + rect[1]) 53 | return slice_y, slice_x 54 | 55 | 56 | 57 | def get_overlapping_slices(t_pos, t_shape, i_shape, anchor='center', ret_rect=False): 58 | """ calculates slices to compare parts of two images with each other 59 | i_shape is the shape of the larger image in which a smaller image of 60 | shape t_shape will be placed. 61 | Here, t_pos specifies the position of the smaller image in the large one. 62 | The input variables follow this convention: 63 | t_pos = (x-position, y-position) 64 | t_shape = (template-height, template-width) 65 | i_shape = (image-height, image-width) 66 | """ 67 | 68 | # get dimensions to determine center position 69 | t_top = t_shape[0]//2 70 | t_left = t_shape[1]//2 71 | if anchor == 'center': 72 | pos = (t_pos[0] - t_left, t_pos[1] - t_top) 73 | elif anchor == 'upper left': 74 | pos = t_pos 75 | else: 76 | raise ValueError('Unknown anchor point: %s' % anchor) 77 | 78 | # get the dimensions of the overlapping region 79 | h = min(t_shape[0], i_shape[0] - pos[1]) 80 | w = min(t_shape[1], i_shape[1] - pos[0]) 81 | if h <= 0 or w <= 0: 82 | raise RuntimeError('Template and image do not overlap') 83 | 84 | # get the leftmost point in both images 85 | if pos[0] >= 0: 86 | i_x, t_x = pos[0], 0 87 | elif pos[0] <= -t_shape[1]: 88 | raise RuntimeError('Template and image do not overlap') 89 | else: # pos[0] < 0: 90 | i_x, t_x = 0, -pos[0] 91 | w += pos[0] 92 | 93 | # get the upper point in both images 94 | if pos[1] >= 0: 95 | i_y, t_y = pos[1], 0 96 | elif pos[1] <= -t_shape[0]: 97 | raise RuntimeError('Template and image do not overlap') 98 | else: # pos[1] < 0: 99 | i_y, t_y = 0, -pos[1] 100 | h += pos[1] 101 | 102 | # build the slices used to extract the information 103 | slices= ((slice(t_y, t_y + h), slice(t_x, t_x + w)), # slice for the template 104 | (slice(i_y, i_y + h), slice(i_x, i_x + w))) # slice for the image 105 | 106 | if ret_rect: 107 | return slices, (i_x, i_y, w, h) 108 | else: 109 | return slices 110 | 111 | 112 | 113 | def find_bounding_box(mask): 114 | """ finds the rectangle, which bounds a white region in a mask. 115 | The rectangle is returned as [left, top, width, height] 116 | Currently, this function only works reliably for connected regions 117 | """ 118 | 119 | # find top boundary 120 | top = 0 121 | while not np.any(mask[top, :]): 122 | top += 1 123 | # top contains the first non-empty row 124 | 125 | # find bottom boundary 126 | bottom = top + 1 127 | try: 128 | while np.any(mask[bottom, :]): 129 | bottom += 1 130 | except IndexError: 131 | bottom = mask.shape[0] 132 | # bottom contains the first empty row 133 | 134 | # find left boundary 135 | left = 0 136 | while not np.any(mask[:, left]): 137 | left += 1 138 | # left contains the first non-empty column 139 | 140 | # find right boundary 141 | try: 142 | right = left + 1 143 | while np.any(mask[:, right]): 144 | right += 1 145 | except IndexError: 146 | right = mask.shape[1] 147 | # right contains the first empty column 148 | 149 | return (left, top, right - left, bottom - top) 150 | 151 | 152 | 153 | def expand_rectangle(rect, amount=1): 154 | """ expands a rectangle by a given amount """ 155 | return (rect[0] - amount, rect[1] - amount, rect[2] + 2*amount, rect[3] + 2*amount) 156 | 157 | 158 | 159 | def get_largest_region(mask, ret_area=False): 160 | """ returns a mask only containing the largest region """ 161 | # find all regions and label them 162 | labels, num_features = ndimage.measurements.label(mask) 163 | 164 | # find the areas corresponding to all regions 165 | areas = [np.sum(labels == label) 166 | for label in xrange(1, num_features + 1)] 167 | 168 | # find the label of the largest region 169 | label_max = np.argmax(areas) + 1 170 | 171 | if ret_area: 172 | return labels == label_max, areas[label_max - 1] 173 | else: 174 | return labels == label_max 175 | 176 | 177 | 178 | def get_contour_from_largest_region(mask, ret_area=False): 179 | """ determines the contour of the largest region in the mask """ 180 | contours = cv2.findContours(mask.astype(np.uint8, copy=False), 181 | cv2.RETR_EXTERNAL, 182 | cv2.CHAIN_APPROX_SIMPLE)[1] 183 | 184 | if not contours: 185 | raise RuntimeError('Could not find any contour') 186 | 187 | # find the contour with the largest area, in case there are multiple 188 | contour_areas = [cv2.contourArea(cnt) for cnt in contours] 189 | contour_id = np.argmax(contour_areas) 190 | 191 | # simplify the contour 192 | contour = np.squeeze(np.asarray(contours[contour_id], np.double)) 193 | 194 | if ret_area: 195 | return contour, contour_areas[contour_id] 196 | else: 197 | return contour 198 | 199 | 200 | 201 | def get_external_contour(points, resolution=None): 202 | """ takes a list of `points` defining a linear ring, which can be 203 | self-intersecting, and returns an approximation to the external contour """ 204 | if resolution is None: 205 | # determine resolution from minimal distance of consecutive points 206 | dist_min = np.inf 207 | for p1, p2 in itertools.izip(np.roll(points, 1, axis=0), points): 208 | dist = curves.point_distance(p1, p2) 209 | if dist > 0: 210 | dist_min = min(dist_min, dist) 211 | resolution = 0.5*dist_min 212 | 213 | # limit the resolution such that there are at most 2048 points 214 | dim_max = np.max(np.ptp(points, axis=0)) #< longest dimension 215 | resolution = max(resolution, dim_max/2048) 216 | 217 | # build a linear ring with integer coordinates 218 | ps_int = np.array(np.asarray(points)/resolution, np.int) 219 | ring = geometry.LinearRing(ps_int) 220 | 221 | # get the image of the linear ring by plotting it into a mask 222 | x_min, y_min, x_max, y_max = ring.bounds 223 | shape = ((y_max - y_min) + 3, (x_max - x_min) + 3) 224 | x_off, y_off = int(x_min - 1), int(y_min - 1) 225 | mask = np.zeros(shape, np.uint8) 226 | cv2.fillPoly(mask, [ps_int], 255, offset=(-x_off, -y_off)) 227 | 228 | # find the contour of this mask to recover the exterior contour 229 | contours = cv2.findContours(mask, cv2.RETR_EXTERNAL, 230 | cv2.CHAIN_APPROX_SIMPLE, 231 | offset=(x_off, y_off))[1] 232 | return np.array(np.squeeze(contours))*resolution 233 | 234 | 235 | 236 | def get_enclosing_outline(polygon): 237 | """ gets the enclosing contour of a (possibly complex) polygon """ 238 | polygon = regularize_polygon(polygon) 239 | 240 | # get the contour 241 | try: 242 | contour = polygon.boundary 243 | except ValueError: 244 | # return empty feature since `polygon` was not a valid polygon 245 | return geometry.LinearRing() 246 | 247 | if isinstance(contour, geometry.multilinestring.MultiLineString): 248 | largest_polygon = None 249 | # find the largest polygon, which should be the enclosing contour 250 | for line in contour: 251 | poly = geometry.Polygon(line) 252 | if largest_polygon is None or poly.area > largest_polygon.area: 253 | largest_polygon = poly 254 | if largest_polygon is None: 255 | contour = geometry.LinearRing() 256 | else: 257 | contour = largest_polygon.boundary 258 | return contour 259 | 260 | 261 | 262 | def regularize_polygon(polygon): 263 | """ regularize a shapely polygon using polygon.buffer(0) """ 264 | area_orig = polygon.area #< the result should have a similar area 265 | 266 | # try regularizing polygon using the buffer(0) trick 267 | result = polygon.buffer(0) 268 | if isinstance(result, geometry.MultiPolygon): 269 | # retrieve the result with the largest area 270 | result = max(result, key=lambda obj: obj.area) 271 | 272 | # check the resulting area 273 | if result.area < 0.5*area_orig: 274 | # the polygon was likely complex and the buffer(0) trick did not work 275 | # => we use a more reliable but slower method 276 | contour = get_external_contour(polygon.exterior.coords) 277 | result = geometry.Polygon(contour) 278 | 279 | return result 280 | 281 | 282 | 283 | def regularize_linear_ring(linear_ring): 284 | """ regularize a list of points defining a contour """ 285 | polygon = geometry.Polygon(linear_ring) 286 | regular_polygon = regularize_polygon(polygon) 287 | if regular_polygon.is_empty: 288 | return geometry.LinearRing() #< empty linear ring 289 | else: 290 | return regular_polygon.exterior 291 | 292 | 293 | 294 | def regularize_contour_points(contour): 295 | """ regularize a list of points defining a contour """ 296 | if len(contour) >= 3: 297 | polygon = geometry.Polygon(np.asarray(contour, np.double)) 298 | regular_polygon = regularize_polygon(polygon) 299 | if regular_polygon.is_empty: 300 | return [] #< empty list of points 301 | else: 302 | contour = regular_polygon.exterior.coords 303 | return contour 304 | 305 | 306 | 307 | def simplify_contour(contour, threshold): 308 | """ simplifies a contour based on its area. 309 | Single points are removed if the area change of the resulting polygon 310 | is smaller than `threshold`. 311 | """ 312 | if isinstance(contour, geometry.LineString): 313 | return simple_poly.simplify_line(contour, threshold) 314 | elif isinstance(contour, geometry.LinearRing): 315 | return simple_poly.simplify_ring(contour, threshold) 316 | elif isinstance(contour, geometry.Polygon): 317 | return simple_poly.simplify_polygon(contour, threshold) 318 | else: 319 | # assume contour are coordinates of a linear ring 320 | ring = geometry.LinearRing(contour) 321 | ring = simple_poly.simplify_ring(ring, threshold) 322 | if ring is None: 323 | return None 324 | else: 325 | return ring.coords[:-1] 326 | 327 | 328 | 329 | def get_intersections(geometry1, geometry2): 330 | """ get intersection points between two (line) geometries """ 331 | # find the intersections between the ray and the burrow contour 332 | try: 333 | inter = geometry1.intersection(geometry2) 334 | except geos.TopologicalError: 335 | return [] 336 | 337 | # process the result 338 | if inter is None or inter.is_empty: 339 | return [] 340 | elif isinstance(inter, geometry.Point): 341 | # intersection is a single point 342 | return [inter.coords[0]] 343 | elif isinstance(inter, geometry.MultiPoint): 344 | # intersection contains multiple points 345 | return [p.coords[0] for p in inter] 346 | else: 347 | # intersection contains objects of lines 348 | # => we cannot do anything sensible and thus return nothing 349 | return [] 350 | 351 | 352 | 353 | def get_ray_hitpoint(point_anchor, point_far, line_string, ret_dist=False): 354 | """ returns the point where a ray anchored at point_anchor hits the polygon 355 | given by line_string. The ray extends out to point_far, which should be a 356 | point beyond the polygon. 357 | If ret_dist is True, the distance to the hit point is also returned. 358 | """ 359 | # define the ray 360 | ray = geometry.LineString((point_anchor, point_far)) 361 | 362 | # find the intersections between the ray and the burrow contour 363 | try: 364 | inter = line_string.intersection(ray) 365 | except geos.TopologicalError: 366 | inter = None 367 | 368 | # process the result 369 | if isinstance(inter, geometry.Point): 370 | if ret_dist: 371 | # also return the distance 372 | dist = curves.point_distance(inter.coords[0], point_anchor) 373 | return inter.coords[0], dist 374 | else: 375 | return inter.coords[0] 376 | 377 | elif inter is not None and not inter.is_empty: 378 | # find closest intersection if there are many points 379 | dists = [curves.point_distance(p.coords[0], point_anchor) for p in inter] 380 | k_min = np.argmin(dists) 381 | if ret_dist: 382 | return inter[k_min].coords[0], dists[k_min] 383 | else: 384 | return inter[k_min].coords[0] 385 | 386 | else: 387 | # return empty result 388 | if ret_dist: 389 | return None, np.nan 390 | else: 391 | return None 392 | 393 | 394 | 395 | def get_ray_intersections(point_anchor, angles, polygon, ray_length=1000): 396 | """ shoots out rays from point_anchor in different angles and determines 397 | the points where polygon is hit. 398 | """ 399 | points = [] 400 | for angle in angles: 401 | point_far = (point_anchor[0] + ray_length*np.cos(angle), 402 | point_anchor[1] + ray_length*np.sin(angle)) 403 | point_hit = get_ray_hitpoint(point_anchor, point_far, polygon) 404 | points.append(point_hit) 405 | return points 406 | 407 | 408 | 409 | def get_farthest_ray_intersection(point_anchor, angles, polygon, ray_length=1000): 410 | """ shoots out rays from point_anchor in different angles and determines 411 | the farthest point where polygon is hit. 412 | Returns the hit point, its distance to point_anchor and the associated 413 | angle 414 | """ 415 | point_max, dist_max, angle_max = None, 0, None 416 | # try some rays distributed around `angle` 417 | for angle in angles: 418 | point_far = (point_anchor[0] + ray_length*np.cos(angle), 419 | point_anchor[1] + ray_length*np.sin(angle)) 420 | point_hit, dist_hit = get_ray_hitpoint(point_anchor, point_far, 421 | polygon, ret_dist=True) 422 | if dist_hit > dist_max: 423 | dist_max = dist_hit 424 | point_max = point_hit 425 | angle_max = angle 426 | return point_max, dist_max, angle_max 427 | 428 | 429 | 430 | def triangle_area(a, b, c): 431 | """ returns the area of a triangle with sides a, b, c 432 | Note that the input could also be numpy arrays 433 | """ 434 | # use Heron's formula to calculate triangle area 435 | s = (a + b + c)/2 436 | radicand = s*(s - a)*(s - b)*(s - c) 437 | 438 | if isinstance(radicand, np.ndarray): 439 | i = (radicand > 0) 440 | np.sqrt(radicand[i], out=radicand[i]) 441 | # sometimes rounding errors produce small negative quantities 442 | radicand[~i] = 0 443 | return radicand 444 | 445 | else: 446 | # input is plain number 447 | if radicand > 0: 448 | return np.sqrt(radicand) 449 | else: 450 | # sometimes rounding errors produce small negative quantities 451 | return 0 452 | 453 | 454 | 455 | def make_distance_map(mask, start_points, end_points=None): 456 | """ 457 | fills a binary region of the array `mask` with new values. 458 | The values are based on the distance to the start points `start_points`, 459 | which must lie in the domain. 460 | If end_points are supplied, the functions stops when any of these 461 | points is reached. 462 | The function does not return anything but rather modifies the mask itself 463 | """ 464 | if end_points is None: 465 | end_points = set() 466 | else: 467 | end_points = set(end_points) 468 | 469 | SQRT2 = np.sqrt(2) 470 | 471 | # initialize the shape 472 | ymax, xmax = mask.shape[:2] 473 | stack = defaultdict(set) 474 | # initialize the stack with the start points 475 | stack[2] = set((int(point[0]), int(point[1])) 476 | for point in start_points) 477 | 478 | # loop until all points are filled 479 | while stack: 480 | # get next distance to consider 481 | dist = min(stack.keys()) 482 | # iterate through all points with the minimal distance 483 | for x, y in stack.pop(dist): 484 | # check whether x, y is a valid point that can be filled 485 | # Note that we only write and check each point once. This is valid 486 | # since we fill points one after another and can thus ensure that 487 | # we write the closest points first. We tested that changing the 488 | # condition to mask[x, y] > dist does not change the result 489 | if 0 <= x < xmax and 0 <= y < ymax and mask[y, x] == 1: 490 | mask[y, x] = dist 491 | 492 | # finish if we found an end point 493 | if (x, y) in end_points: 494 | return 495 | 496 | # add all surrounding points to the stack 497 | stack[dist + 1] |= set(((x - 1, y), 498 | (x + 1, y), 499 | (x, y - 1), 500 | (x, y + 1))) 501 | 502 | stack[dist + SQRT2] |= set(((x - 1, y - 1), 503 | (x + 1, y - 1), 504 | (x - 1, y + 1), 505 | (x + 1, y + 1))) 506 | 507 | 508 | 509 | # make array that contains the distances to the center point 510 | DIST_LOCAL = np.full((3, 3), 1 / np.sqrt(2), np.double) 511 | DIST_LOCAL[1, :] = DIST_LOCAL[:, 1] = 1 512 | 513 | def shortest_path_in_distance_map(distance_map, end_point): 514 | """ finds and returns the shortest path in the distance map `distance_map` 515 | that leads from the given `end_point` to a start point (defined by having 516 | the minimal distance value in the map) """ 517 | # create distance map 518 | w, h = distance_map.shape 519 | dist_map = np.zeros((w + 2, h + 2), np.int) 520 | 521 | # copy distance map and make sure there is a border 522 | dist_map[1:-1, 1:-1] = distance_map 523 | 524 | # make sure points outside the shape are not included in the distance 525 | dist_map[dist_map <= 1] = np.iinfo(dist_map.dtype).max 526 | 527 | # initialize the list and move end point by one to reflect border 528 | x = int(end_point[0]) + 1 529 | y = int(end_point[1]) + 1 530 | points = [(x, y)] 531 | d = dist_map[y, x] 532 | 533 | # iterate through path until we reached the minimum 534 | while True: 535 | # extract surrounding of current point 536 | dist_surrounding = dist_map[y-1:y+2, x-1:x+2] 537 | if dist_surrounding.shape != (3, 3): 538 | # we somehow reached the side of the mask => abort search 539 | break 540 | 541 | # find point with minimal distance in surrounding 542 | surrounding = (dist_surrounding - d) * DIST_LOCAL 543 | dy, dx = np.unravel_index(surrounding.argmin(), (3, 3)) 544 | # get new coordinates 545 | x += dx - 1 546 | y += dy - 1 547 | # check whether the new point is viable 548 | if dist_map[y, x] < d: 549 | # distance decreased => keep this as the new point 550 | d = dist_map[y, x] 551 | 552 | elif dist_map[y, x] == d: 553 | # distance stayed constant => might keep this as a new point 554 | if (x, y) in points: 555 | # we already saw this point => stop iterating 556 | break 557 | 558 | else: 559 | # distance increased => we reached a minimum and will thus stop 560 | break 561 | points.append((x, y)) 562 | 563 | # shift back all the points to remove the border 564 | return np.array(points) - 1 565 | 566 | 567 | 568 | def get_farthest_points(mask, p1=None, ret_path=False): 569 | """ returns the path between the two points in the mask which are farthest 570 | away from each other. """ 571 | 572 | # find a random starting point 573 | if p1 is None: 574 | # locate objects in the mask 575 | contours = cv2.findContours(mask.copy(), cv2.RETR_EXTERNAL, 576 | cv2.CHAIN_APPROX_SIMPLE)[1] 577 | 578 | # pick the largest contour 579 | contour = max(contours, key=lambda cnt: cv2.arcLength(cnt, closed=True)) 580 | 581 | # get point in the contour 582 | p1 = (contour[0, 0, 0], contour[0, 0, 1]) 583 | 584 | # create a mask to hold the distance map 585 | mask_int = np.empty_like(mask, np.int) 586 | np.clip(mask, 0, 1, mask_int) 587 | 588 | dist_prev = 0 589 | # iterate until second point is found 590 | while True: 591 | # make distance map starting from point p1 592 | distance_map = mask_int.copy() 593 | 594 | make_distance_map(distance_map, start_points=(p1,)) 595 | 596 | # find point farthest point away from p1 597 | idx_max = np.unravel_index(distance_map.argmax(), 598 | distance_map.shape) 599 | dist = distance_map[idx_max] 600 | p2 = idx_max[1], idx_max[0] 601 | 602 | if dist <= dist_prev: 603 | break 604 | dist_prev = dist 605 | # take farthest point as new start point 606 | p1 = p2 607 | 608 | # find path between p1 and p2 609 | if ret_path: 610 | return shortest_path_in_distance_map(distance_map, p2) 611 | else: 612 | return p1, p2 613 | 614 | --------------------------------------------------------------------------------