Commit 7fe9a1ca authored by Kristin Berry's avatar Kristin Berry
Browse files

Merge pull request #53 from jlaura/master

Updates to ratio test for performance
parents 1cd112d6 03715ec5
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
{"AS15-M-0297_SML.png": ["AS15-M-0298_SML.png", "AS15-M-0299_SML.png"],
"AS15-M-0298_SML.png": ["AS15-M-0297_SML.png", "AS15-M-0299_SML.png"],
"AS15-M-0299_SML.png": ["AS15-M-0297_SML.png", "AS15-M-0298_SML.png"]}
"AS15-M-0298_SML.png": ["AS15-M-0299_SML.png", "AS15-M-0297_SML.png"],
"AS15-M-0299_SML.png": ["AS15-M-0298_SML.png", "AS15-M-0297_SML.png"]}
+26 −7
Original line number Diff line number Diff line
@@ -54,10 +54,11 @@ class Edge(dict, MutableMapping):

    @property
    def masks(self):
        mask_lookup = {'fundamental': 'fundamental_matrix'}
        mask_lookup = {'fundamental': 'fundamental_matrix',
                       'ratio': 'distance_ratio'}
        if not hasattr(self, '_masks'):
            if hasattr(self, 'matches'):
                self._masks = pd.DataFrame(True, columns=['symmetry', 'ratio'],
                self._masks = pd.DataFrame(True, columns=['symmetry'],
                                       index=self.matches.index)
            else:
                self._masks = pd.DataFrame()
@@ -110,9 +111,20 @@ class Edge(dict, MutableMapping):
        else:
            raise AttributeError('No matches have been computed for this edge.')

    def ratio_check(self, ratio=0.8):
    def ratio_check(self, ratio=0.8, clean_keys=[]):
        if hasattr(self, 'matches'):
            mask = od.distance_ratio(self.matches, ratio=ratio)

            if clean_keys:
                _, mask = self._clean(clean_keys)
            else:
                mask = pd.Series(True, self.matches.index)

            self.distance_ratio = od.DistanceRatio(self.matches)
            self.distance_ratio.compute(ratio, mask=mask, mask_name=None)

            # Setup to be notified
            self.distance_ratio._notify_subscribers(self.distance_ratio)

            self.masks = ('ratio', mask)
        else:
            raise AttributeError('No matches have been computed for this edge.')
@@ -207,7 +219,7 @@ class Edge(dict, MutableMapping):

    def subpixel_register(self, clean_keys=[], threshold=0.8, upsampling=16,
                          template_size=19, search_size=53, max_x_shift=1.0,
                          max_y_shift=1.0):
                          max_y_shift=1.0, tiled=False):
        """
        For the entire graph, compute the subpixel offsets using pattern-matching and add the result
        as an attribute to each edge of the graph.
@@ -252,6 +264,13 @@ class Edge(dict, MutableMapping):
        if clean_keys:
            matches, mask = self._clean(clean_keys)

        if tiled is True:
            s_img = self.source.handle
            d_img = self.destination.handle
        else:
            s_img = self.source.handle.read_array()
            d_img = self.destination.handle.read_array()

        # for each edge, calculate this for each keypoint pair
        for i, (idx, row) in enumerate(matches.iterrows()):
            s_idx = int(row['source_idx'])
@@ -261,8 +280,8 @@ class Edge(dict, MutableMapping):
            d_keypoint = self.destination.keypoints.iloc[d_idx][['x', 'y']].values

            # Get the template and search window
            s_template = sp.clip_roi(self.source.handle, s_keypoint, template_size)
            d_search = sp.clip_roi(self.destination.handle, d_keypoint, search_size)
            s_template = sp.clip_roi(s_img, s_keypoint, template_size)
            d_search = sp.clip_roi(d_img, d_keypoint, search_size)

            try:
                x_off, y_off, strength = sp.subpixel_offset(s_template, d_search, upsampling=upsampling)
+12 −8
Original line number Diff line number Diff line
@@ -198,7 +198,7 @@ class CandidateGraph(nx.Graph):
            node.extract_features(image, method=method,
                                extractor_parameters=extractor_parameters)

    def match_features(self, k=3):
    def match_features(self, k=None):
        """
        For all connected edges in the graph, apply feature matching

@@ -207,18 +207,22 @@ class CandidateGraph(nx.Graph):
        k : int
            The number of matches, minus 1, to find per feature.  For example
            k=5 will find the 4 nearest neighbors for every extracted feature.
            If None,  k = (2 * the number of edges connecting a node) +1
        """
        #Load a Fast Approximate Nearest Neighbor KD-Tree
        fl = FlannMatcher()
        degree = self.degree()

        self._fl = FlannMatcher()
        for i, node in self.nodes_iter(data=True):
            if not hasattr(node, 'descriptors'):
                raise AttributeError('Descriptors must be extracted before matching can occur.')
            fl.add(node.descriptors, key=i)
        fl.train()
            self._fl.add(node.descriptors, key=i)
        self._fl.train()

        for i, node in self.nodes_iter(data=True):
            if k is None:
                k = (degree[i] * 2) + 1
            descriptors = node.descriptors
            matches = fl.query(descriptors, i, k=k)
            matches = self._fl.query(descriptors, i, k=k)
            self.add_matches(matches)

    def add_matches(self, matches):
@@ -256,12 +260,12 @@ class CandidateGraph(nx.Graph):
        for s, d, edge in self.edges_iter(data=True):
            edge.symmetry_check()

    def ratio_checks(self, ratio=0.8):
    def ratio_checks(self, ratio=0.8, clean_keys=[]):
        """
        Perform a ratio check on all edges in the graph
        """
        for s, d, edge in self.edges_iter(data=True):
            edge.ratio_check(ratio=ratio)
            edge.ratio_check(ratio=ratio, clean_keys=clean_keys)

    def compute_homographies(self, clean_keys=[], **kwargs):
        """
+153 −60
Original line number Diff line number Diff line
from collections import deque

import cv2
import numpy as np
import pandas as pd


def self_neighbors(matches):
class DistanceRatio(object):

    """
    Returns a pandas data series intended to be used as a mask. Each row
    is True if it is not matched to a point in the same image (good) and
    False if it is (bad.)
    A stateful object to store ratio test results and provenance.

    Parameters
    Attributes
    ----------

    nvalid : int
             The number of valid entries in the mask

    mask : series
           Pandas boolean series indexed by the match id

    matches : dataframe
              the matches dataframe stored along the edge of the graph
              containing matched points with columns containing:
              matched image name, query index, train index, and
              descriptor distance
    Returns
    -------
    : dataseries
      Intended to mask the matches dataframe. True means the row is not matched to a point in the same image
      and false the row is.
              The matches dataframe from an edge.  This dataframe
              must have 'source_idx' and 'distance' columns.

    single : bool
             If True, then single entries in the distance ratio
             mask are assumed to have passed the ratio test.  Else
             False.

    """
    return matches.source_image != matches.destination_image

    def __init__(self, matches):

        self._action_stack = deque(maxlen=10)
        self._current_action_stack = 0
        self._observers = set()
        self.matches = matches
        self.mask = None

def distance_ratio(matches, ratio=0.8):
    @property
    def nvalid(self):
        return self.mask.sum()

    def compute(self, ratio, mask=None, mask_name=None, single=False):
        """
        Compute and return a mask for a matches dataframe
    using Lowe's ratio test.
        using Lowe's ratio test.  If keypoints have a single
        Lowe (2004) [Lowe2004]_

        Parameters
        ----------
    matches : dataframe
              the matches dataframe stored along the edge of the graph
              containing matched points with columns containing:
              matched image name, query index, train index, and
              descriptor distance.

        ratio : float
                the ratio between the first and second-best match distances
                for each keypoint to use as a bound for marking the first keypoint
                as "good". Default: 0.8
    Returns
    -------
     mask : ndarray
            Intended to mask the matches dataframe. Rows are True if the associated keypoint passes
            the ratio test and false otherwise. Keypoints without more than one match are True by
            default, since the ratio test will not work for them.

    """
        mask : series
               A pandas boolean series to initially mask the matches array

    mask = np.zeros(len(matches), dtype=bool)  # Pre-allocate the mask
    counter = 0
    for i, group in matches.groupby('source_idx'):
        group_size = len(group)
        n_unique = len(group['destination_idx'].unique())
        # If we can not perform the ratio check because all matches are symmetrical
        if n_unique == 1:
            mask[counter:counter + group_size] = True
            counter += group_size
        else:
            # Otherwise, we can perform the ratio test
            sorted_group = group.sort_values(by=['distance'])
            unique = sorted_group['distance'].unique()

            if len(unique) == 1:
                # The distances from the unique points are identical
                mask[counter: counter + group_size] = False
                counter += group_size
            elif unique[0] / unique[1] < ratio:
                # The ratio test passes
                mask[counter] = True
                mask[counter + 1:counter + group_size] = False
                counter += group_size
        mask_name : list or str
                    An arbitrary mask name for provenance tracking

        single : bool
                 If True, points with only a single entry are included (True)
                 in the result mask, else False.
        """
        def func(group):
            res = [False] * len(group)
            if len(res) == 1:
                return [single]
            if group.iloc[0] < group.iloc[1] * ratio:
                res[0] = True
            return res

        self.single = single

        if mask is not None:
            self.mask = mask.copy()
            new_mask = self.matches[mask].groupby('source_idx')['distance'].transform(func).astype('bool')
            self.mask[mask==True] = new_mask
        else:
                mask[counter: counter + group_size] = False
                counter += group_size
            new_mask = self.matches.groupby('source_idx')['distance'].transform(func).astype('bool')
            self.mask = new_mask.copy()

        state_package = {'ratio': ratio,
                         'mask': self.mask.copy(),
                         'clean_keys': mask_name,
                         'single': single
                         }
        self._action_stack.append(state_package)
        self._current_action_stack = len(self._action_stack) - 1

    def subscribe(self, func):
        """
        Subscribe some observer to the edge

    return mask
        Parameters
        ----------
        func : object
               The callable that is to be executed on update
        """
        self._observers.add(func)

    def _notify_subscribers(self, *args, **kwargs):
        """
        The 'update' call to notify all subscribers of
        a change.
        """
        for update_func in self._observers:
            update_func(self, *args, **kwargs)

    def rollforward(self, n=1):
        """
        Roll forwards in the object history, e.g. do

        Parameters
        ----------
        n : int
            the number of steps to roll forwards
        """
        idx = self._current_action_stack + n
        if idx > len(self._action_stack) - 1:
            idx = len(self._action_stack) - 1
        self._current_action_stack = idx
        state = self._action_stack[idx]
        setattr(self, 'mask', state['mask'])
        setattr(self, 'ratio', state['ratio'])
        setattr(self, 'clean_keys', state['clean_keys'])
        setattr(self, 'single', state['single'])
        # Reset attributes (could also cache)
        self._notify_subscribers(self)

    def rollback(self, n=1):
        """
        Roll backward in the object histroy, e.g. undo

        Parameters
        ----------
        n : int
            the number of steps to roll backwards
        """
        idx = self._current_action_stack - n
        if idx < 0:
            idx = 0
        self._current_action_stack = idx
        state = self._action_stack[idx]
        setattr(self, 'mask', state['mask'])
        setattr(self, 'ratio', state['ratio'])
        setattr(self, 'clean_keys', state['clean_keys'])
        setattr(self, 'single', state['single'])
        # Reset attributes (could also cache)
        self._notify_subscribers(self)


def self_neighbors(matches):
    """
    Returns a pandas data series intended to be used as a mask. Each row
    is True if it is not matched to a point in the same image (good) and
    False if it is (bad.)

    Parameters
    ----------
    matches : dataframe
              the matches dataframe stored along the edge of the graph
              containing matched points with columns containing:
              matched image name, query index, train index, and
              descriptor distance
    Returns
    -------
    : dataseries
      Intended to mask the matches dataframe. True means the row is not matched to a point in the same image
      and false the row is.
    """
    return matches.source_image != matches.destination_image


def mirroring_test(matches):
@@ -104,8 +197,7 @@ def mirroring_test(matches):
                 otherwise, they will be false. Keypoints with only one match will be False. Removes
                 duplicate rows.
    """
    duplicates = matches.duplicated(keep='first').values
    duplicates.astype(bool, copy=False)
    duplicates = matches.duplicated(keep='first').astype(bool)
    return duplicates


@@ -215,6 +307,7 @@ def compute_homography(kp1, kp2, method='ransac', **kwargs):
                                                     kp2,
                                                     method_,
                                                     **kwargs)
    if mask is not None:
        mask = mask.astype(bool)
    return transformation_matrix, mask

+7 −4
Original line number Diff line number Diff line
@@ -36,10 +36,12 @@ class TestOutlierDetector(unittest.TestCase):
            counter += 1

        fmatcher.train()
        self.matches = fmatcher.query(fd['AS15-M-0296_SML.png'][1],'AS15-M-0296_SML.png', k=3)
        self.matches = fmatcher.query(fd['AS15-M-0296_SML.png'][1], 'AS15-M-0296_SML.png')

    def test_distance_ratio(self):
        self.assertTrue(len(outlier_detector.distance_ratio(self.matches)), 13)
        d = outlier_detector.DistanceRatio(self.matches)
        d.compute(0.9)
        self.assertEqual(d.mask.sum(), 3)

    def test_distance_ratio_unique(self):
        data = [['A', 0, 'B', 1, 10],
@@ -47,8 +49,9 @@ class TestOutlierDetector(unittest.TestCase):
        df = pd.DataFrame(data, columns=['source_image', 'source_idx',
                                         'destination_image', 'destination_idx',
                                         'distance'])
        mask = outlier_detector.distance_ratio(df)
        self.assertTrue(mask.all() == False)
        d = outlier_detector.DistanceRatio(df)
        d.compute(0.9)
        self.assertTrue(d.mask.all() == False)

    def test_self_neighbors(self):
        # returned mask should be same length as input df
Loading