From 197b2f232757fd14797d1742451a40c6f856377a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Wed, 4 Oct 2017 19:03:15 +0200 Subject: [PATCH 1/9] Store documents words in a single text field --- image_match/elasticsearchflat_driver.py | 242 +++++++++++++++++++++++ image_match/signature_database_base.py | 24 ++- tests/elasticsearch_helper.py | 230 +++++++++++++++++++++ tests/test_elasticsearch_driver.py | 238 +--------------------- tests/test_elasticsearch_driver_speed.py | 160 +++++++++++++++ tests/test_elasticsearchflat_driver.py | 15 ++ 6 files changed, 670 insertions(+), 239 deletions(-) create mode 100644 image_match/elasticsearchflat_driver.py create mode 100644 tests/elasticsearch_helper.py create mode 100644 tests/test_elasticsearch_driver_speed.py create mode 100644 tests/test_elasticsearchflat_driver.py diff --git a/image_match/elasticsearchflat_driver.py b/image_match/elasticsearchflat_driver.py new file mode 100644 index 0000000..259dba3 --- /dev/null +++ b/image_match/elasticsearchflat_driver.py @@ -0,0 +1,242 @@ +from image_match.signature_database_base import SignatureDatabaseBase +from image_match.signature_database_base import normalized_distance +from image_match.signature_database_base import make_record +from datetime import datetime +from itertools import product +from operator import itemgetter +import numpy as np +from collections import deque + + +class SignatureES(SignatureDatabaseBase): + """Elasticsearch driver for image-match + + This driver deals with document where all simple words, from 1 to N, are stored + in a single string field in the document, named "simple_words". The words are + string separated, like "11111 22222 33333 44444 55555 66666 77777". + + The field is queried with a "match" on "simple_words" with a "minimum_should_match" + given as a parameter. i.e. the following document: + {"simple_words": "11111 22222 33333 44444 55555 66666 77777"} + will be returned by the search_single_record function by the given image words: + 11111 99999 33333 44444 00000 55555 88888 + if minimum_should_match is below or equal to 4, because for words are in common. + + The order of the words in the string field is maintained, although it does not make any + sort of importance because of the way the field is queried. + + """ + + def __init__(self, es, index='images', doc_type='image', timeout='10s', size=100, minimum_should_match=6, + *args, **kwargs): + """Extra setup for Elasticsearch + + Args: + es (elasticsearch): an instance of the elasticsearch python driver + index (Optional[string]): a name for the Elasticsearch index (default 'images') + doc_type (Optional[string]): a name for the document time (default 'image') + timeout (Optional[int]): how long to wait on an Elasticsearch query, in seconds (default 10) + size (Optional[int]): maximum number of Elasticsearch results (default 100) + minimum_should_match (Optional[int]): maximum number of common words in the queried image + and the document (default 6). + *args (Optional): Variable length argument list to pass to base constructor + **kwargs (Optional): Arbitrary keyword arguments to pass to base constructor + + Examples: + >>> from elasticsearch import Elasticsearch + >>> from image_match.elasticsearch_driver import SignatureES + >>> es = Elasticsearch() + >>> ses = SignatureES(es) + >>> ses.add_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + >>> ses.search_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + [ + {'dist': 0.0, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg', + 'score': 0.28797293} + ] + + """ + self.es = es + self.index = index + self.doc_type = doc_type + self.timeout = timeout + self.size = size + self.minimum_should_match = minimum_should_match + + super(SignatureES, self).__init__(*args, **kwargs) + + def search_single_record(self, rec, pre_filter=None): + path = rec.pop('path') + signature = rec.pop('signature') + if 'metadata' in rec: + rec.pop('metadata') + + query = { + 'query': { + 'bool': { + 'must': { + 'match': { + 'simple_words': { + "query": rec["simple_words"], + 'minimum_should_match': str(self.minimum_should_match) + } + }, + } + } + }, + '_source': {'excludes': ['simple_words']} + } + + if pre_filter is not None: + query['query']['bool']['filter'] = pre_filter + + # Perform minimum_should_match request + res = self.es.search(index=self.index, + doc_type=self.doc_type, + body=query, + size=self.size, + timeout=self.timeout)['hits']['hits'] + + sigs = np.array([x['_source']['signature'] for x in res]) + + if sigs.size == 0: + return [] + + dists = normalized_distance(sigs, np.array(signature)) + + formatted_res = [{'id': x['_id'], + 'score': x['_score'], + 'metadata': x['_source'].get('metadata'), + 'path': x['_source'].get('url', x['_source'].get('path'))} + for x in res] + + for i, row in enumerate(formatted_res): + row['dist'] = dists[i] + formatted_res = filter(lambda y: y['dist'] < self.distance_cutoff, formatted_res) + + return formatted_res + + def insert_single_record(self, rec, refresh_after=False): + rec['timestamp'] = datetime.now() + + self.es.index(index=self.index, doc_type=self.doc_type, body=rec, refresh=refresh_after) + + def delete_duplicates(self, path): + """Delete all but one entries in elasticsearch whose `path` value is equivalent to that of path. + Args: + path (string): path value to compare to those in the elastic search + """ + matching_paths = [item['_id'] for item in + self.es.search(body={'query': + {'match': + {'path': path} + } + }, + index=self.index)['hits']['hits'] + if item['_source']['path'] == path] + if len(matching_paths) > 0: + for id_tag in matching_paths[1:]: + self.es.delete(index=self.index, doc_type=self.doc_type, id=id_tag) + + def add_image(self, path, img=None, bytestream=False, metadata=None, refresh_after=False): + """Add a single image to the database + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or identifier for image. If img=None, then path is assumed to be + a URL or filesystem path + img (Optional[string]): usually raw image data. In this case, path will still be stored, but + a signature will be generated from data in img. If bytestream is False, but img is + not None, then img is assumed to be the URL or filesystem path. Thus, you can store + image records with a different 'path' than the actual image location (default None) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? If img is None but, this + argument will be ignored. If img is not None, and bytestream is False, then the behavior + is as described in the explanation for the img argument + (default False) + metadata (Optional): any other information you want to include, can be nested (default None) + + """ + rec = make_record(path, self.gis, self.k, self.N, img=img, bytestream=bytestream, metadata=metadata, flat=True) + self.insert_single_record(rec, refresh_after=refresh_after) + + def search_image(self, path, all_orientations=False, bytestream=False, pre_filter=None): + """Search for matches + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or image data. If bytestream=False, then path is assumed to be + a URL or filesystem path. Otherwise, it's assumed to be raw image data + all_orientations (Optional[boolean]): if True, search for all combinations of mirror + images, rotations, and color inversions (default False) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? + (default False) + pre_filter (Optional[dict]): filters list before applying the matching algorithm + (default None) + Returns: + a formatted list of dicts representing unique matches, sorted by dist + + For example, if three matches are found: + + [ + {'dist': 0.069116439263706961, + 'id': u'AVM37oZq0osmmAxpPvx7', + 'path': u'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg'}, + {'dist': 0.22484320805049718, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg'}, + {'dist': 0.42529792112113302, + 'id': u'AVM37p530osmmAxpPvx9', + 'path': u'https://c2.staticflickr.com/8/7158/6814444991_08d82de57e_z.jpg'} + ] + + """ + img = self.gis.preprocess_image(path, bytestream) + + if all_orientations: + # initialize an iterator of composed transformations + inversions = [lambda x: x, lambda x: -x] + + mirrors = [lambda x: x, np.fliplr] + + # an ugly solution for function composition + rotations = [lambda x: x, + np.rot90, + lambda x: np.rot90(x, 2), + lambda x: np.rot90(x, 3)] + + # cartesian product of all possible orientations + orientations = product(inversions, rotations, mirrors) + + else: + # otherwise just use the identity transformation + orientations = [lambda x: x] + + # try for every possible combination of transformations; if all_orientations=False, + # this will only take one iteration + result = [] + + orientations = set(np.ravel(list(orientations))) + for transform in orientations: + # compose all functions and apply on signature + transformed_img = transform(img) + + # generate the signature + transformed_record = make_record(transformed_img, self.gis, self.k, self.N, flat=True) + + l = self.search_single_record(transformed_record, pre_filter=pre_filter) + result.extend(l) + + ids = set() + unique = [] + for item in result: + if item['id'] not in ids: + unique.append(item) + ids.add(item['id']) + + r = sorted(unique, key=itemgetter('dist')) + return r diff --git a/image_match/signature_database_base.py b/image_match/signature_database_base.py index 42eadd0..70e01e5 100644 --- a/image_match/signature_database_base.py +++ b/image_match/signature_database_base.py @@ -286,7 +286,7 @@ def search_image(self, path, all_orientations=False, bytestream=False, pre_filte return r -def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): +def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False): """Makes a record suitable for database insertion. Note: @@ -311,11 +311,14 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): is as described in the explanation for the img argument (default False) metadata (Optional): any other information you want to include, can be nested (default None) + flat (Optional): by default, words are stored in separate properties from simple_word_0 to + simple_word_N (N given as a parameter). When flat is set to True, all the word are stored + into one single 'simple_words' property, as a string, space separated. Returns: An image record. - For example: + For example, when flat is set to False (default): {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] @@ -339,6 +342,15 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): 'metadata': {...} } + Or when flat is set to True: + + {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', + 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] + 'simple_words': "42252475 23885671 9967839 4257902 28651959 33773597 39331441 39327300 11337345 9571961 + 28697868 14834907 7434746 37985525 10753207 9566120 ..." + 'metadata': {...} + } + """ record = dict() record['path'] = path @@ -357,8 +369,12 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): words = words_to_int(words) - for i in range(N): - record[''.join(['simple_word_', str(i)])] = words[i].tolist() + if flat: + for i in range(N): + record['simple_words'] = " ".join(map(str, words.tolist())) + else: + for i in range(N): + record[''.join(['simple_word_', str(i)])] = words[i].tolist() return record diff --git a/tests/elasticsearch_helper.py b/tests/elasticsearch_helper.py new file mode 100644 index 0000000..cf991c5 --- /dev/null +++ b/tests/elasticsearch_helper.py @@ -0,0 +1,230 @@ +import pytest +import urllib.request +import os +import hashlib +import unittest +from elasticsearch import Elasticsearch, ConnectionError, RequestError, NotFoundError +from time import sleep + +from image_match.elasticsearch_driver import SignatureES +from PIL import Image + +test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' +test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' +urllib.request.urlretrieve(test_img_url1, 'test1.jpg') +urllib.request.urlretrieve(test_img_url2, 'test2.jpg') + +INDEX_NAME = 'test_environment_{}'.format(hashlib.md5(os.urandom(128)).hexdigest()[:12]) +DOC_TYPE = 'image' +MAPPINGS = { + "mappings": { + DOC_TYPE: { + "dynamic": True, + "properties": { + "metadata": { + "type": "object", + "dynamic": True, + "properties": { + "tenant_id": {"type": "keyword"} + } + } + } + } + } +} + +class BaseTestsParent: + class BaseTests(unittest.TestCase): + + @property + def es(self): + es_serv = Elasticsearch() + return es_serv + + @property + def ses(self): + es = self.es + return SignatureES(es=es, index=INDEX_NAME, doc_type=DOC_TYPE) + + @pytest.fixture(scope='function', autouse=True) + def setup_index(self, request, index_name): + try: + self.es.indices.create(index=index_name, body=MAPPINGS) + except RequestError as e: + if e.error == u'index_already_exists_exception': + self.es.indices.delete(index_name) + else: + raise + + def fin(): + try: + self.es.indices.delete(index_name) + except NotFoundError: + pass + + request.addfinalizer(fin) + + @pytest.fixture(scope='class') + def index_name(self): + return INDEX_NAME + + @pytest.fixture(scope='function', autouse=True) + def cleanup_index(self, request, index_name): + def fin(): + try: + self.es.indices.delete(index_name) + except NotFoundError: + pass + + request.addfinalizer(fin) + + def test_elasticsearch_running(self): + i = 0 + while i < 5: + try: + self.es.ping() + assert True + return + except ConnectionError: + i += 1 + sleep(2) + + pytest.fail('Elasticsearch not running (failed to connect after {} tries)' + .format(str(i))) + + def test_add_image_by_url(self): + self.ses.add_image(test_img_url1) + self.ses.add_image(test_img_url2) + assert True + + def test_add_image_by_path(self): + self.ses.add_image('test1.jpg') + assert True + + def test_index_refresh(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 + + def test_add_image_as_bytestream(self): + with open('test1.jpg', 'rb') as f: + self.ses.add_image('bytestream_test', img=f.read(), bytestream=True) + assert True + + def test_add_image_with_different_name(self): + self.ses.add_image('custom_name_test', img='test1.jpg', bytestream=False) + assert True + + def test_lookup_from_url(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image(test_img_url1) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_from_file(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_from_bytestream(self): + self.ses.add_image('test1.jpg', refresh_after=True) + with open('test1.jpg', 'rb') as f: + r = self.ses.search_image(f.read(), bytestream=True) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_with_cutoff(self): + self.ses.add_image('test2.jpg', refresh_after=True) + ses = self.ses + ses.distance_cutoff = 0.01 + r = ses.search_image('test1.jpg') + assert len(r) == 0 + + def check_distance_consistency(self): + self.ses.add_image('test1.jpg') + self.ses.add_image('test2.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert r[0]['dist'] == 0.0 + assert r[-1]['dist'] == 0.42672771706789686 + + def test_add_image_with_metadata(self): + metadata = {'some_info': + {'test': + 'ok!' + } + } + self.ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert r[0]['metadata'] == metadata + assert 'path' in r[0] + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_with_filter_by_metadata(self): + metadata = dict( + tenant_id='foo' + ) + self.ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) + + metadata2 = dict( + tenant_id='bar-2' + ) + self.ses.add_image('test2.jpg', metadata=metadata2, refresh_after=True) + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "foo"}}) + assert len(r) == 1 + assert r[0]['metadata'] == metadata + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-2"}}) + assert len(r) == 1 + assert r[0]['metadata'] == metadata2 + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-3"}}) + assert len(r) == 0 + + def test_all_orientations(self): + im = Image.open('test1.jpg') + im.rotate(90, expand=True).save('rotated_test1.jpg') + + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('rotated_test1.jpg', all_orientations=True) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert r[0]['dist'] < 0.05 # some error from rotation + + with open('rotated_test1.jpg', 'rb') as f: + r = self.ses.search_image(f.read(), bytestream=True, all_orientations=True) + assert len(r) == 1 + assert r[0]['dist'] < 0.05 # some error from rotation + + def test_duplicate(self): + self.ses.add_image('test1.jpg', refresh_after=True) + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 2 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_duplicate_removal(self): + for i in range(10): + self.ses.add_image('test1.jpg') + sleep(1) + r = self.ses.search_image('test1.jpg') + assert len(r) == 10 + self.ses.delete_duplicates('test1.jpg') + sleep(1) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 diff --git a/tests/test_elasticsearch_driver.py b/tests/test_elasticsearch_driver.py index e55fe5b..26c440c 100644 --- a/tests/test_elasticsearch_driver.py +++ b/tests/test_elasticsearch_driver.py @@ -1,237 +1,5 @@ -import pytest -import urllib.request -import os -import hashlib -from elasticsearch import Elasticsearch, ConnectionError, RequestError, NotFoundError -from time import sleep +from tests.elasticsearch_helper import BaseTestsParent -from image_match.elasticsearch_driver import SignatureES -from PIL import Image -test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' -test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' -urllib.request.urlretrieve(test_img_url1, 'test1.jpg') -urllib.request.urlretrieve(test_img_url2, 'test2.jpg') - -INDEX_NAME = 'test_environment_{}'.format(hashlib.md5(os.urandom(128)).hexdigest()[:12]) -DOC_TYPE = 'image' -MAPPINGS = { - "mappings": { - DOC_TYPE: { - "dynamic": True, - "properties": { - "metadata": { - "type": "object", - "dynamic": True, - "properties": { - "tenant_id": { "type": "keyword" } - } - } - } - } - } -} - - -@pytest.fixture(scope='module', autouse=True) -def index_name(): - return INDEX_NAME - -@pytest.fixture(scope='function', autouse=True) -def setup_index(request, index_name): - es = Elasticsearch() - try: - es.indices.create(index=index_name, body=MAPPINGS) - except RequestError as e: - if e.error == u'index_already_exists_exception': - es.indices.delete(index_name) - else: - raise - - def fin(): - try: - es.indices.delete(index_name) - except NotFoundError: - pass - - request.addfinalizer(fin) - -@pytest.fixture(scope='function', autouse=True) -def cleanup_index(request, es, index_name): - def fin(): - try: - es.indices.delete(index_name) - except NotFoundError: - pass - request.addfinalizer(fin) - -@pytest.fixture -def es(): - return Elasticsearch() - -@pytest.fixture -def ses(es, index_name): - return SignatureES(es=es, index=index_name, doc_type=DOC_TYPE) - -def test_elasticsearch_running(es): - i = 0 - while i < 5: - try: - es.ping() - assert True - return - except ConnectionError: - i += 1 - sleep(2) - - pytest.fail('Elasticsearch not running (failed to connect after {} tries)' - .format(str(i))) - - -def test_add_image_by_url(ses): - ses.add_image(test_img_url1) - ses.add_image(test_img_url2) - assert True - - -def test_add_image_by_path(ses): - ses.add_image('test1.jpg') - assert True - - -def test_index_refresh(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 1 - - -def test_add_image_as_bytestream(ses): - with open('test1.jpg', 'rb') as f: - ses.add_image('bytestream_test', img=f.read(), bytestream=True) - assert True - - -def test_add_image_with_different_name(ses): - ses.add_image('custom_name_test', img='test1.jpg', bytestream=False) - assert True - - -def test_lookup_from_url(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image(test_img_url1) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_lookup_from_file(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - -def test_lookup_from_bytestream(ses): - ses.add_image('test1.jpg', refresh_after=True) - with open('test1.jpg', 'rb') as f: - r = ses.search_image(f.read(), bytestream=True) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - -def test_lookup_with_cutoff(ses): - ses.add_image('test2.jpg', refresh_after=True) - ses.distance_cutoff=0.01 - r = ses.search_image('test1.jpg') - assert len(r) == 0 - - -def check_distance_consistency(ses): - ses.add_image('test1.jpg') - ses.add_image('test2.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert r[0]['dist'] == 0.0 - assert r[-1]['dist'] == 0.42672771706789686 - - -def test_add_image_with_metadata(ses): - metadata = {'some_info': - {'test': - 'ok!' - } - } - ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) - r = ses.search_image('test1.jpg') - assert r[0]['metadata'] == metadata - assert 'path' in r[0] - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_lookup_with_filter_by_metadata(ses): - metadata = dict( - tenant_id='foo' - ) - ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) - - metadata2 = dict( - tenant_id='bar-2' - ) - ses.add_image('test2.jpg', metadata=metadata2, refresh_after=True) - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "foo"}}) - assert len(r) == 1 - assert r[0]['metadata'] == metadata - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-2"}}) - assert len(r) == 1 - assert r[0]['metadata'] == metadata2 - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-3"}}) - assert len(r) == 0 - - -def test_all_orientations(ses): - im = Image.open('test1.jpg') - im.rotate(90, expand=True).save('rotated_test1.jpg') - - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('rotated_test1.jpg', all_orientations=True) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert r[0]['dist'] < 0.05 # some error from rotation - - with open('rotated_test1.jpg', 'rb') as f: - r = ses.search_image(f.read(), bytestream=True, all_orientations=True) - assert len(r) == 1 - assert r[0]['dist'] < 0.05 # some error from rotation - - -def test_duplicate(ses): - ses.add_image('test1.jpg', refresh_after=True) - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 2 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_duplicate_removal(ses): - for i in range(10): - ses.add_image('test1.jpg') - sleep(1) - r = ses.search_image('test1.jpg') - assert len(r) == 10 - ses.delete_duplicates('test1.jpg') - sleep(1) - r = ses.search_image('test1.jpg') - assert len(r) == 1 +class ElasticSearchFlatTestSuite(BaseTestsParent.BaseTests): + pass diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py new file mode 100644 index 0000000..1acd5dd --- /dev/null +++ b/tests/test_elasticsearch_driver_speed.py @@ -0,0 +1,160 @@ +import urllib.request +import os +from elasticsearch import Elasticsearch +import time +from numpy import random + +from image_match.elasticsearch_driver import SignatureES as SignatureES_fields +from image_match.elasticsearchflat_driver import SignatureES as SignatureES_flat + +test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' +test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' +urllib.request.urlretrieve(test_img_url1, 'test1.jpg') +urllib.request.urlretrieve(test_img_url2, 'test2.jpg') + +# ES for fields +INDEX_NAME_FIELDS = 'test_environment_fields' +DOC_TYPE_FIELDS = 'image' +MAPPINGS_FIELDS = { + "mappings": { + DOC_TYPE_FIELDS: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + } + } + } + } +} + +# ES for flat +INDEX_NAME_FLAT = 'test_environment_flat' +DOC_TYPE_FLAT = 'image_flat' +MAPPINGS_FLAT = { + "mappings": { + DOC_TYPE_FLAT: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + } + } + } + } +} + +es = Elasticsearch() + +# Define two ses +print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) +print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) +ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, doc_type=DOC_TYPE_FIELDS) +ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, doc_type=DOC_TYPE_FLAT) + +# Download dataset and populate index +dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" +dir_path = os.path.dirname(os.path.realpath(__file__)) +local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") +local_directory = os.path.join(dir_path, "101_ObjectCategories") +if not os.path.exists("101_ObjectCategories"): + cmd = "wget {} -O {}".format(dataset_url, local_file) + print(cmd) + os.system(cmd) + + cmd = "tar xzvf {}".format(local_file) + print(cmd) + os.system(cmd) + +# Populate the two indexes with images +all_files = [] +total_time_ingest_fields = 0 +total_time_ingest_flat = 0 +for root, dirs, files in os.walk(local_directory): + for file in files: + full_path = os.path.join(root, file) + all_files.append(full_path) + + if len(all_files) % 1000 == 0: + print("{} documents ingested (in each index)".format(len(all_files))) + + t_fields = time.time() + ses_fields.add_image(full_path) + total_time_ingest_fields += (time.time() - t_fields) + + t_flat = time.time() + ses_flat.add_image(full_path) + total_time_ingest_flat += (time.time() - t_flat) + +print("{} to ingest fields documents".format(total_time_ingest_fields)) +print("{} to ingest flats documents".format(total_time_ingest_flat)) + +# Pick 500 random files and request both indexes +total_time_search_fields = 0 +total_time_search_flat = 0 +num_random = 500 +max_msm = 6 +total_res = 0 # Total results cumulated between flat and fields +in_common = 0 # Number of results returned from both indices +random_images = random.choice(all_files, num_random).tolist() + +for msm in range(1, max_msm + 1): + ses_flat.minimum_should_match = msm + found_flat = [0, 0, 0] # (less_results, equal_results, more_results) + same_first = 0 # Number of time the first result is the same + + for image in random_images: + t_search_fields = time.time() + res_fields = ses_fields.search_image(image) + total_time_search_fields += (time.time() - t_search_fields) + + t_search_flat = time.time() + res_flat = ses_flat.search_image(image) + total_time_search_flat += (time.time() - t_search_flat) + + if len(res_fields) == len(res_flat): + found_flat[1] += 1 + elif len(res_fields) > len(res_flat): + found_flat[2] += 1 + else: + found_flat[0] += 1 + + total_res += len(res_fields) + len(res_flat) + in_common += len( + list( + set([r["path"] for r in res_fields]).intersection( + [r["path"] for r in res_flat]) + ) + ) + + if len(res_fields) > 0 and len(res_flat) > 0: + if res_fields[0]["path"] == res_flat[0]["path"]: + same_first += 1 + + print("") + print("minimum_should_match = {}".format(msm)) + print("{} less results in flat, {} same num results, {} more results in flat" + .format(found_flat[0], found_flat[1], found_flat[2])) + print("{} common results out of {} total results. {}% match" + .format(in_common, total_res, in_common * 100 / total_res)) + print("{} same first results (out of {})".format(same_first, num_random)) + +print("") +print("{} searches total".format(num_random * max_msm)) +print("{} to search fields documents".format(total_time_search_fields)) +print("{} to search flat documents".format(total_time_search_flat)) + +# Delete indexes +print("Delete indices") +es.indices.delete(INDEX_NAME_FIELDS) +es.indices.delete(INDEX_NAME_FLAT) diff --git a/tests/test_elasticsearchflat_driver.py b/tests/test_elasticsearchflat_driver.py new file mode 100644 index 0000000..fae59de --- /dev/null +++ b/tests/test_elasticsearchflat_driver.py @@ -0,0 +1,15 @@ +import unittest +from image_match.elasticsearchflat_driver import SignatureES + +from tests.elasticsearch_helper import BaseTestsParent, DOC_TYPE, INDEX_NAME + + +class ElasticSearchFlatTestSuite(BaseTestsParent.BaseTests): + @property + def ses(self): + """ + Override the ses property to use the flat driver. + :return: SignatureES from image_match.elasticsearchflat_driver + """ + es = self.es + return SignatureES(es=es, index=INDEX_NAME, doc_type=DOC_TYPE) From f94f00746ebd26fbfc8b453415a80256dd6b998a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Thu, 5 Oct 2017 11:18:15 +0200 Subject: [PATCH 2/9] Store documents words in a single text field --- image_match/elasticsearchflat_driver.py | 242 +++++++++++++++++++++++ image_match/signature_database_base.py | 24 ++- tests/elasticsearch_helper.py | 230 +++++++++++++++++++++ tests/test_elasticsearch_driver.py | 238 +--------------------- tests/test_elasticsearch_driver_speed.py | 160 +++++++++++++++ tests/test_elasticsearchflat_driver.py | 15 ++ 6 files changed, 670 insertions(+), 239 deletions(-) create mode 100644 image_match/elasticsearchflat_driver.py create mode 100644 tests/elasticsearch_helper.py create mode 100644 tests/test_elasticsearch_driver_speed.py create mode 100644 tests/test_elasticsearchflat_driver.py diff --git a/image_match/elasticsearchflat_driver.py b/image_match/elasticsearchflat_driver.py new file mode 100644 index 0000000..259dba3 --- /dev/null +++ b/image_match/elasticsearchflat_driver.py @@ -0,0 +1,242 @@ +from image_match.signature_database_base import SignatureDatabaseBase +from image_match.signature_database_base import normalized_distance +from image_match.signature_database_base import make_record +from datetime import datetime +from itertools import product +from operator import itemgetter +import numpy as np +from collections import deque + + +class SignatureES(SignatureDatabaseBase): + """Elasticsearch driver for image-match + + This driver deals with document where all simple words, from 1 to N, are stored + in a single string field in the document, named "simple_words". The words are + string separated, like "11111 22222 33333 44444 55555 66666 77777". + + The field is queried with a "match" on "simple_words" with a "minimum_should_match" + given as a parameter. i.e. the following document: + {"simple_words": "11111 22222 33333 44444 55555 66666 77777"} + will be returned by the search_single_record function by the given image words: + 11111 99999 33333 44444 00000 55555 88888 + if minimum_should_match is below or equal to 4, because for words are in common. + + The order of the words in the string field is maintained, although it does not make any + sort of importance because of the way the field is queried. + + """ + + def __init__(self, es, index='images', doc_type='image', timeout='10s', size=100, minimum_should_match=6, + *args, **kwargs): + """Extra setup for Elasticsearch + + Args: + es (elasticsearch): an instance of the elasticsearch python driver + index (Optional[string]): a name for the Elasticsearch index (default 'images') + doc_type (Optional[string]): a name for the document time (default 'image') + timeout (Optional[int]): how long to wait on an Elasticsearch query, in seconds (default 10) + size (Optional[int]): maximum number of Elasticsearch results (default 100) + minimum_should_match (Optional[int]): maximum number of common words in the queried image + and the document (default 6). + *args (Optional): Variable length argument list to pass to base constructor + **kwargs (Optional): Arbitrary keyword arguments to pass to base constructor + + Examples: + >>> from elasticsearch import Elasticsearch + >>> from image_match.elasticsearch_driver import SignatureES + >>> es = Elasticsearch() + >>> ses = SignatureES(es) + >>> ses.add_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + >>> ses.search_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + [ + {'dist': 0.0, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg', + 'score': 0.28797293} + ] + + """ + self.es = es + self.index = index + self.doc_type = doc_type + self.timeout = timeout + self.size = size + self.minimum_should_match = minimum_should_match + + super(SignatureES, self).__init__(*args, **kwargs) + + def search_single_record(self, rec, pre_filter=None): + path = rec.pop('path') + signature = rec.pop('signature') + if 'metadata' in rec: + rec.pop('metadata') + + query = { + 'query': { + 'bool': { + 'must': { + 'match': { + 'simple_words': { + "query": rec["simple_words"], + 'minimum_should_match': str(self.minimum_should_match) + } + }, + } + } + }, + '_source': {'excludes': ['simple_words']} + } + + if pre_filter is not None: + query['query']['bool']['filter'] = pre_filter + + # Perform minimum_should_match request + res = self.es.search(index=self.index, + doc_type=self.doc_type, + body=query, + size=self.size, + timeout=self.timeout)['hits']['hits'] + + sigs = np.array([x['_source']['signature'] for x in res]) + + if sigs.size == 0: + return [] + + dists = normalized_distance(sigs, np.array(signature)) + + formatted_res = [{'id': x['_id'], + 'score': x['_score'], + 'metadata': x['_source'].get('metadata'), + 'path': x['_source'].get('url', x['_source'].get('path'))} + for x in res] + + for i, row in enumerate(formatted_res): + row['dist'] = dists[i] + formatted_res = filter(lambda y: y['dist'] < self.distance_cutoff, formatted_res) + + return formatted_res + + def insert_single_record(self, rec, refresh_after=False): + rec['timestamp'] = datetime.now() + + self.es.index(index=self.index, doc_type=self.doc_type, body=rec, refresh=refresh_after) + + def delete_duplicates(self, path): + """Delete all but one entries in elasticsearch whose `path` value is equivalent to that of path. + Args: + path (string): path value to compare to those in the elastic search + """ + matching_paths = [item['_id'] for item in + self.es.search(body={'query': + {'match': + {'path': path} + } + }, + index=self.index)['hits']['hits'] + if item['_source']['path'] == path] + if len(matching_paths) > 0: + for id_tag in matching_paths[1:]: + self.es.delete(index=self.index, doc_type=self.doc_type, id=id_tag) + + def add_image(self, path, img=None, bytestream=False, metadata=None, refresh_after=False): + """Add a single image to the database + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or identifier for image. If img=None, then path is assumed to be + a URL or filesystem path + img (Optional[string]): usually raw image data. In this case, path will still be stored, but + a signature will be generated from data in img. If bytestream is False, but img is + not None, then img is assumed to be the URL or filesystem path. Thus, you can store + image records with a different 'path' than the actual image location (default None) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? If img is None but, this + argument will be ignored. If img is not None, and bytestream is False, then the behavior + is as described in the explanation for the img argument + (default False) + metadata (Optional): any other information you want to include, can be nested (default None) + + """ + rec = make_record(path, self.gis, self.k, self.N, img=img, bytestream=bytestream, metadata=metadata, flat=True) + self.insert_single_record(rec, refresh_after=refresh_after) + + def search_image(self, path, all_orientations=False, bytestream=False, pre_filter=None): + """Search for matches + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or image data. If bytestream=False, then path is assumed to be + a URL or filesystem path. Otherwise, it's assumed to be raw image data + all_orientations (Optional[boolean]): if True, search for all combinations of mirror + images, rotations, and color inversions (default False) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? + (default False) + pre_filter (Optional[dict]): filters list before applying the matching algorithm + (default None) + Returns: + a formatted list of dicts representing unique matches, sorted by dist + + For example, if three matches are found: + + [ + {'dist': 0.069116439263706961, + 'id': u'AVM37oZq0osmmAxpPvx7', + 'path': u'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg'}, + {'dist': 0.22484320805049718, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg'}, + {'dist': 0.42529792112113302, + 'id': u'AVM37p530osmmAxpPvx9', + 'path': u'https://c2.staticflickr.com/8/7158/6814444991_08d82de57e_z.jpg'} + ] + + """ + img = self.gis.preprocess_image(path, bytestream) + + if all_orientations: + # initialize an iterator of composed transformations + inversions = [lambda x: x, lambda x: -x] + + mirrors = [lambda x: x, np.fliplr] + + # an ugly solution for function composition + rotations = [lambda x: x, + np.rot90, + lambda x: np.rot90(x, 2), + lambda x: np.rot90(x, 3)] + + # cartesian product of all possible orientations + orientations = product(inversions, rotations, mirrors) + + else: + # otherwise just use the identity transformation + orientations = [lambda x: x] + + # try for every possible combination of transformations; if all_orientations=False, + # this will only take one iteration + result = [] + + orientations = set(np.ravel(list(orientations))) + for transform in orientations: + # compose all functions and apply on signature + transformed_img = transform(img) + + # generate the signature + transformed_record = make_record(transformed_img, self.gis, self.k, self.N, flat=True) + + l = self.search_single_record(transformed_record, pre_filter=pre_filter) + result.extend(l) + + ids = set() + unique = [] + for item in result: + if item['id'] not in ids: + unique.append(item) + ids.add(item['id']) + + r = sorted(unique, key=itemgetter('dist')) + return r diff --git a/image_match/signature_database_base.py b/image_match/signature_database_base.py index 42eadd0..70e01e5 100644 --- a/image_match/signature_database_base.py +++ b/image_match/signature_database_base.py @@ -286,7 +286,7 @@ def search_image(self, path, all_orientations=False, bytestream=False, pre_filte return r -def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): +def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False): """Makes a record suitable for database insertion. Note: @@ -311,11 +311,14 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): is as described in the explanation for the img argument (default False) metadata (Optional): any other information you want to include, can be nested (default None) + flat (Optional): by default, words are stored in separate properties from simple_word_0 to + simple_word_N (N given as a parameter). When flat is set to True, all the word are stored + into one single 'simple_words' property, as a string, space separated. Returns: An image record. - For example: + For example, when flat is set to False (default): {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] @@ -339,6 +342,15 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): 'metadata': {...} } + Or when flat is set to True: + + {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', + 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] + 'simple_words': "42252475 23885671 9967839 4257902 28651959 33773597 39331441 39327300 11337345 9571961 + 28697868 14834907 7434746 37985525 10753207 9566120 ..." + 'metadata': {...} + } + """ record = dict() record['path'] = path @@ -357,8 +369,12 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None): words = words_to_int(words) - for i in range(N): - record[''.join(['simple_word_', str(i)])] = words[i].tolist() + if flat: + for i in range(N): + record['simple_words'] = " ".join(map(str, words.tolist())) + else: + for i in range(N): + record[''.join(['simple_word_', str(i)])] = words[i].tolist() return record diff --git a/tests/elasticsearch_helper.py b/tests/elasticsearch_helper.py new file mode 100644 index 0000000..cf991c5 --- /dev/null +++ b/tests/elasticsearch_helper.py @@ -0,0 +1,230 @@ +import pytest +import urllib.request +import os +import hashlib +import unittest +from elasticsearch import Elasticsearch, ConnectionError, RequestError, NotFoundError +from time import sleep + +from image_match.elasticsearch_driver import SignatureES +from PIL import Image + +test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' +test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' +urllib.request.urlretrieve(test_img_url1, 'test1.jpg') +urllib.request.urlretrieve(test_img_url2, 'test2.jpg') + +INDEX_NAME = 'test_environment_{}'.format(hashlib.md5(os.urandom(128)).hexdigest()[:12]) +DOC_TYPE = 'image' +MAPPINGS = { + "mappings": { + DOC_TYPE: { + "dynamic": True, + "properties": { + "metadata": { + "type": "object", + "dynamic": True, + "properties": { + "tenant_id": {"type": "keyword"} + } + } + } + } + } +} + +class BaseTestsParent: + class BaseTests(unittest.TestCase): + + @property + def es(self): + es_serv = Elasticsearch() + return es_serv + + @property + def ses(self): + es = self.es + return SignatureES(es=es, index=INDEX_NAME, doc_type=DOC_TYPE) + + @pytest.fixture(scope='function', autouse=True) + def setup_index(self, request, index_name): + try: + self.es.indices.create(index=index_name, body=MAPPINGS) + except RequestError as e: + if e.error == u'index_already_exists_exception': + self.es.indices.delete(index_name) + else: + raise + + def fin(): + try: + self.es.indices.delete(index_name) + except NotFoundError: + pass + + request.addfinalizer(fin) + + @pytest.fixture(scope='class') + def index_name(self): + return INDEX_NAME + + @pytest.fixture(scope='function', autouse=True) + def cleanup_index(self, request, index_name): + def fin(): + try: + self.es.indices.delete(index_name) + except NotFoundError: + pass + + request.addfinalizer(fin) + + def test_elasticsearch_running(self): + i = 0 + while i < 5: + try: + self.es.ping() + assert True + return + except ConnectionError: + i += 1 + sleep(2) + + pytest.fail('Elasticsearch not running (failed to connect after {} tries)' + .format(str(i))) + + def test_add_image_by_url(self): + self.ses.add_image(test_img_url1) + self.ses.add_image(test_img_url2) + assert True + + def test_add_image_by_path(self): + self.ses.add_image('test1.jpg') + assert True + + def test_index_refresh(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 + + def test_add_image_as_bytestream(self): + with open('test1.jpg', 'rb') as f: + self.ses.add_image('bytestream_test', img=f.read(), bytestream=True) + assert True + + def test_add_image_with_different_name(self): + self.ses.add_image('custom_name_test', img='test1.jpg', bytestream=False) + assert True + + def test_lookup_from_url(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image(test_img_url1) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_from_file(self): + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_from_bytestream(self): + self.ses.add_image('test1.jpg', refresh_after=True) + with open('test1.jpg', 'rb') as f: + r = self.ses.search_image(f.read(), bytestream=True) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_with_cutoff(self): + self.ses.add_image('test2.jpg', refresh_after=True) + ses = self.ses + ses.distance_cutoff = 0.01 + r = ses.search_image('test1.jpg') + assert len(r) == 0 + + def check_distance_consistency(self): + self.ses.add_image('test1.jpg') + self.ses.add_image('test2.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert r[0]['dist'] == 0.0 + assert r[-1]['dist'] == 0.42672771706789686 + + def test_add_image_with_metadata(self): + metadata = {'some_info': + {'test': + 'ok!' + } + } + self.ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert r[0]['metadata'] == metadata + assert 'path' in r[0] + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_lookup_with_filter_by_metadata(self): + metadata = dict( + tenant_id='foo' + ) + self.ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) + + metadata2 = dict( + tenant_id='bar-2' + ) + self.ses.add_image('test2.jpg', metadata=metadata2, refresh_after=True) + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "foo"}}) + assert len(r) == 1 + assert r[0]['metadata'] == metadata + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-2"}}) + assert len(r) == 1 + assert r[0]['metadata'] == metadata2 + + r = self.ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-3"}}) + assert len(r) == 0 + + def test_all_orientations(self): + im = Image.open('test1.jpg') + im.rotate(90, expand=True).save('rotated_test1.jpg') + + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('rotated_test1.jpg', all_orientations=True) + assert len(r) == 1 + assert r[0]['path'] == 'test1.jpg' + assert r[0]['dist'] < 0.05 # some error from rotation + + with open('rotated_test1.jpg', 'rb') as f: + r = self.ses.search_image(f.read(), bytestream=True, all_orientations=True) + assert len(r) == 1 + assert r[0]['dist'] < 0.05 # some error from rotation + + def test_duplicate(self): + self.ses.add_image('test1.jpg', refresh_after=True) + self.ses.add_image('test1.jpg', refresh_after=True) + r = self.ses.search_image('test1.jpg') + assert len(r) == 2 + assert r[0]['path'] == 'test1.jpg' + assert 'score' in r[0] + assert 'dist' in r[0] + assert 'id' in r[0] + + def test_duplicate_removal(self): + for i in range(10): + self.ses.add_image('test1.jpg') + sleep(1) + r = self.ses.search_image('test1.jpg') + assert len(r) == 10 + self.ses.delete_duplicates('test1.jpg') + sleep(1) + r = self.ses.search_image('test1.jpg') + assert len(r) == 1 diff --git a/tests/test_elasticsearch_driver.py b/tests/test_elasticsearch_driver.py index e55fe5b..26c440c 100644 --- a/tests/test_elasticsearch_driver.py +++ b/tests/test_elasticsearch_driver.py @@ -1,237 +1,5 @@ -import pytest -import urllib.request -import os -import hashlib -from elasticsearch import Elasticsearch, ConnectionError, RequestError, NotFoundError -from time import sleep +from tests.elasticsearch_helper import BaseTestsParent -from image_match.elasticsearch_driver import SignatureES -from PIL import Image -test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' -test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' -urllib.request.urlretrieve(test_img_url1, 'test1.jpg') -urllib.request.urlretrieve(test_img_url2, 'test2.jpg') - -INDEX_NAME = 'test_environment_{}'.format(hashlib.md5(os.urandom(128)).hexdigest()[:12]) -DOC_TYPE = 'image' -MAPPINGS = { - "mappings": { - DOC_TYPE: { - "dynamic": True, - "properties": { - "metadata": { - "type": "object", - "dynamic": True, - "properties": { - "tenant_id": { "type": "keyword" } - } - } - } - } - } -} - - -@pytest.fixture(scope='module', autouse=True) -def index_name(): - return INDEX_NAME - -@pytest.fixture(scope='function', autouse=True) -def setup_index(request, index_name): - es = Elasticsearch() - try: - es.indices.create(index=index_name, body=MAPPINGS) - except RequestError as e: - if e.error == u'index_already_exists_exception': - es.indices.delete(index_name) - else: - raise - - def fin(): - try: - es.indices.delete(index_name) - except NotFoundError: - pass - - request.addfinalizer(fin) - -@pytest.fixture(scope='function', autouse=True) -def cleanup_index(request, es, index_name): - def fin(): - try: - es.indices.delete(index_name) - except NotFoundError: - pass - request.addfinalizer(fin) - -@pytest.fixture -def es(): - return Elasticsearch() - -@pytest.fixture -def ses(es, index_name): - return SignatureES(es=es, index=index_name, doc_type=DOC_TYPE) - -def test_elasticsearch_running(es): - i = 0 - while i < 5: - try: - es.ping() - assert True - return - except ConnectionError: - i += 1 - sleep(2) - - pytest.fail('Elasticsearch not running (failed to connect after {} tries)' - .format(str(i))) - - -def test_add_image_by_url(ses): - ses.add_image(test_img_url1) - ses.add_image(test_img_url2) - assert True - - -def test_add_image_by_path(ses): - ses.add_image('test1.jpg') - assert True - - -def test_index_refresh(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 1 - - -def test_add_image_as_bytestream(ses): - with open('test1.jpg', 'rb') as f: - ses.add_image('bytestream_test', img=f.read(), bytestream=True) - assert True - - -def test_add_image_with_different_name(ses): - ses.add_image('custom_name_test', img='test1.jpg', bytestream=False) - assert True - - -def test_lookup_from_url(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image(test_img_url1) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_lookup_from_file(ses): - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - -def test_lookup_from_bytestream(ses): - ses.add_image('test1.jpg', refresh_after=True) - with open('test1.jpg', 'rb') as f: - r = ses.search_image(f.read(), bytestream=True) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - -def test_lookup_with_cutoff(ses): - ses.add_image('test2.jpg', refresh_after=True) - ses.distance_cutoff=0.01 - r = ses.search_image('test1.jpg') - assert len(r) == 0 - - -def check_distance_consistency(ses): - ses.add_image('test1.jpg') - ses.add_image('test2.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert r[0]['dist'] == 0.0 - assert r[-1]['dist'] == 0.42672771706789686 - - -def test_add_image_with_metadata(ses): - metadata = {'some_info': - {'test': - 'ok!' - } - } - ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) - r = ses.search_image('test1.jpg') - assert r[0]['metadata'] == metadata - assert 'path' in r[0] - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_lookup_with_filter_by_metadata(ses): - metadata = dict( - tenant_id='foo' - ) - ses.add_image('test1.jpg', metadata=metadata, refresh_after=True) - - metadata2 = dict( - tenant_id='bar-2' - ) - ses.add_image('test2.jpg', metadata=metadata2, refresh_after=True) - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "foo"}}) - assert len(r) == 1 - assert r[0]['metadata'] == metadata - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-2"}}) - assert len(r) == 1 - assert r[0]['metadata'] == metadata2 - - r = ses.search_image('test1.jpg', pre_filter={"term": {"metadata.tenant_id": "bar-3"}}) - assert len(r) == 0 - - -def test_all_orientations(ses): - im = Image.open('test1.jpg') - im.rotate(90, expand=True).save('rotated_test1.jpg') - - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('rotated_test1.jpg', all_orientations=True) - assert len(r) == 1 - assert r[0]['path'] == 'test1.jpg' - assert r[0]['dist'] < 0.05 # some error from rotation - - with open('rotated_test1.jpg', 'rb') as f: - r = ses.search_image(f.read(), bytestream=True, all_orientations=True) - assert len(r) == 1 - assert r[0]['dist'] < 0.05 # some error from rotation - - -def test_duplicate(ses): - ses.add_image('test1.jpg', refresh_after=True) - ses.add_image('test1.jpg', refresh_after=True) - r = ses.search_image('test1.jpg') - assert len(r) == 2 - assert r[0]['path'] == 'test1.jpg' - assert 'score' in r[0] - assert 'dist' in r[0] - assert 'id' in r[0] - - -def test_duplicate_removal(ses): - for i in range(10): - ses.add_image('test1.jpg') - sleep(1) - r = ses.search_image('test1.jpg') - assert len(r) == 10 - ses.delete_duplicates('test1.jpg') - sleep(1) - r = ses.search_image('test1.jpg') - assert len(r) == 1 +class ElasticSearchFlatTestSuite(BaseTestsParent.BaseTests): + pass diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py new file mode 100644 index 0000000..1acd5dd --- /dev/null +++ b/tests/test_elasticsearch_driver_speed.py @@ -0,0 +1,160 @@ +import urllib.request +import os +from elasticsearch import Elasticsearch +import time +from numpy import random + +from image_match.elasticsearch_driver import SignatureES as SignatureES_fields +from image_match.elasticsearchflat_driver import SignatureES as SignatureES_flat + +test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' +test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' +urllib.request.urlretrieve(test_img_url1, 'test1.jpg') +urllib.request.urlretrieve(test_img_url2, 'test2.jpg') + +# ES for fields +INDEX_NAME_FIELDS = 'test_environment_fields' +DOC_TYPE_FIELDS = 'image' +MAPPINGS_FIELDS = { + "mappings": { + DOC_TYPE_FIELDS: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + } + } + } + } +} + +# ES for flat +INDEX_NAME_FLAT = 'test_environment_flat' +DOC_TYPE_FLAT = 'image_flat' +MAPPINGS_FLAT = { + "mappings": { + DOC_TYPE_FLAT: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + } + } + } + } +} + +es = Elasticsearch() + +# Define two ses +print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) +print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) +ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, doc_type=DOC_TYPE_FIELDS) +ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, doc_type=DOC_TYPE_FLAT) + +# Download dataset and populate index +dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" +dir_path = os.path.dirname(os.path.realpath(__file__)) +local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") +local_directory = os.path.join(dir_path, "101_ObjectCategories") +if not os.path.exists("101_ObjectCategories"): + cmd = "wget {} -O {}".format(dataset_url, local_file) + print(cmd) + os.system(cmd) + + cmd = "tar xzvf {}".format(local_file) + print(cmd) + os.system(cmd) + +# Populate the two indexes with images +all_files = [] +total_time_ingest_fields = 0 +total_time_ingest_flat = 0 +for root, dirs, files in os.walk(local_directory): + for file in files: + full_path = os.path.join(root, file) + all_files.append(full_path) + + if len(all_files) % 1000 == 0: + print("{} documents ingested (in each index)".format(len(all_files))) + + t_fields = time.time() + ses_fields.add_image(full_path) + total_time_ingest_fields += (time.time() - t_fields) + + t_flat = time.time() + ses_flat.add_image(full_path) + total_time_ingest_flat += (time.time() - t_flat) + +print("{} to ingest fields documents".format(total_time_ingest_fields)) +print("{} to ingest flats documents".format(total_time_ingest_flat)) + +# Pick 500 random files and request both indexes +total_time_search_fields = 0 +total_time_search_flat = 0 +num_random = 500 +max_msm = 6 +total_res = 0 # Total results cumulated between flat and fields +in_common = 0 # Number of results returned from both indices +random_images = random.choice(all_files, num_random).tolist() + +for msm in range(1, max_msm + 1): + ses_flat.minimum_should_match = msm + found_flat = [0, 0, 0] # (less_results, equal_results, more_results) + same_first = 0 # Number of time the first result is the same + + for image in random_images: + t_search_fields = time.time() + res_fields = ses_fields.search_image(image) + total_time_search_fields += (time.time() - t_search_fields) + + t_search_flat = time.time() + res_flat = ses_flat.search_image(image) + total_time_search_flat += (time.time() - t_search_flat) + + if len(res_fields) == len(res_flat): + found_flat[1] += 1 + elif len(res_fields) > len(res_flat): + found_flat[2] += 1 + else: + found_flat[0] += 1 + + total_res += len(res_fields) + len(res_flat) + in_common += len( + list( + set([r["path"] for r in res_fields]).intersection( + [r["path"] for r in res_flat]) + ) + ) + + if len(res_fields) > 0 and len(res_flat) > 0: + if res_fields[0]["path"] == res_flat[0]["path"]: + same_first += 1 + + print("") + print("minimum_should_match = {}".format(msm)) + print("{} less results in flat, {} same num results, {} more results in flat" + .format(found_flat[0], found_flat[1], found_flat[2])) + print("{} common results out of {} total results. {}% match" + .format(in_common, total_res, in_common * 100 / total_res)) + print("{} same first results (out of {})".format(same_first, num_random)) + +print("") +print("{} searches total".format(num_random * max_msm)) +print("{} to search fields documents".format(total_time_search_fields)) +print("{} to search flat documents".format(total_time_search_flat)) + +# Delete indexes +print("Delete indices") +es.indices.delete(INDEX_NAME_FIELDS) +es.indices.delete(INDEX_NAME_FLAT) diff --git a/tests/test_elasticsearchflat_driver.py b/tests/test_elasticsearchflat_driver.py new file mode 100644 index 0000000..fae59de --- /dev/null +++ b/tests/test_elasticsearchflat_driver.py @@ -0,0 +1,15 @@ +import unittest +from image_match.elasticsearchflat_driver import SignatureES + +from tests.elasticsearch_helper import BaseTestsParent, DOC_TYPE, INDEX_NAME + + +class ElasticSearchFlatTestSuite(BaseTestsParent.BaseTests): + @property + def ses(self): + """ + Override the ses property to use the flat driver. + :return: SignatureES from image_match.elasticsearchflat_driver + """ + es = self.es + return SignatureES(es=es, index=INDEX_NAME, doc_type=DOC_TYPE) From ffec258f7654d87fb85190d994fefb3e17c3c1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Thu, 5 Oct 2017 11:23:49 +0200 Subject: [PATCH 3/9] Add requirements file from my virtualenv pip freeze --- requirements.txt | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4646083 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +apipkg==1.4 +astroid==1.5.3 +coverage==4.4.1 +cycler==0.10.0 +decorator==4.1.2 +elasticsearch==5.4.0 +execnet==1.4.1 +isort==4.2.15 +lazy-object-proxy==1.3.1 +matplotlib==2.0.2 +mccabe==0.6.1 +networkx==2.0 +numpy==1.13.1 +olefile==0.44 +pep8==1.7.0 +Pillow==4.2.1 +py==1.4.34 +pyflakes==1.6.0 +pylint==1.7.2 +pyparsing==2.2.0 +pytest==3.2.2 +pytest-cov==2.5.1 +pytest-forked==0.2 +pytest-runner==2.12.1 +pytest-xdist==1.20.0 +python-dateutil==2.6.1 +pytz==2017.2 +PyWavelets==0.5.2 +scikit-image==0.13.0 +scipy==0.19.1 +six==1.11.0 +urllib3==1.22 +wrapt==1.10.11 From f37585bd534ea5c63eee65d2447ea405f0671ce8 Mon Sep 17 00:00:00 2001 From: miqwit Date: Thu, 5 Oct 2017 12:29:37 +0200 Subject: [PATCH 4/9] Remove verbose when untar --- tests/test_elasticsearch_driver_speed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py index 1acd5dd..086ddc6 100644 --- a/tests/test_elasticsearch_driver_speed.py +++ b/tests/test_elasticsearch_driver_speed.py @@ -72,7 +72,7 @@ print(cmd) os.system(cmd) - cmd = "tar xzvf {}".format(local_file) + cmd = "tar xzf {}".format(local_file) print(cmd) os.system(cmd) From f7e98bb32c857a4d86050619282fb7819414c3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Wed, 20 Dec 2017 10:21:18 +0100 Subject: [PATCH 5/9] Added a speed test with 'flatint', storing words as list of long --- image_match/elasticsearchflatint_driver.py | 237 +++++++++++++++++++++ image_match/signature_database_base.py | 20 +- tests/test_elasticsearch_driver_speed.py | 145 ++++++++++--- 3 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 image_match/elasticsearchflatint_driver.py diff --git a/image_match/elasticsearchflatint_driver.py b/image_match/elasticsearchflatint_driver.py new file mode 100644 index 0000000..13fb47a --- /dev/null +++ b/image_match/elasticsearchflatint_driver.py @@ -0,0 +1,237 @@ +from image_match.signature_database_base import SignatureDatabaseBase +from image_match.signature_database_base import normalized_distance +from image_match.signature_database_base import make_record +from datetime import datetime +from itertools import product +from operator import itemgetter +import numpy as np +from collections import deque + + +class SignatureES(SignatureDatabaseBase): + """Elasticsearch driver for image-match + + This driver deals with document where all simple words, from 1 to N, are stored + in a single string field in the document, named "simple_words". The words are + string separated, like "11111 22222 33333 44444 55555 66666 77777". + + The field is queried with a "match" on "simple_words" with a "minimum_should_match" + given as a parameter. i.e. the following document: + {"simple_words": "11111 22222 33333 44444 55555 66666 77777"} + will be returned by the search_single_record function by the given image words: + 11111 99999 33333 44444 00000 55555 88888 + if minimum_should_match is below or equal to 4, because for words are in common. + + The order of the words in the string field is maintained, although it does not make any + sort of importance because of the way the field is queried. + + """ + + def __init__(self, es, index='images', doc_type='image', timeout='10s', size=100, minimum_should_match=6, + *args, **kwargs): + """Extra setup for Elasticsearch + + Args: + es (elasticsearch): an instance of the elasticsearch python driver + index (Optional[string]): a name for the Elasticsearch index (default 'images') + doc_type (Optional[string]): a name for the document time (default 'image') + timeout (Optional[int]): how long to wait on an Elasticsearch query, in seconds (default 10) + size (Optional[int]): maximum number of Elasticsearch results (default 100) + minimum_should_match (Optional[int]): maximum number of common words in the queried image + and the document (default 6). + *args (Optional): Variable length argument list to pass to base constructor + **kwargs (Optional): Arbitrary keyword arguments to pass to base constructor + + Examples: + >>> from elasticsearch import Elasticsearch + >>> from image_match.elasticsearch_driver import SignatureES + >>> es = Elasticsearch() + >>> ses = SignatureES(es) + >>> ses.add_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + >>> ses.search_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + [ + {'dist': 0.0, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg', + 'score': 0.28797293} + ] + + """ + self.es = es + self.index = index + self.doc_type = doc_type + self.timeout = timeout + self.size = size + self.minimum_should_match = minimum_should_match + + super(SignatureES, self).__init__(*args, **kwargs) + + def search_single_record(self, rec, pre_filter=None): + path = rec.pop('path') + signature = rec.pop('signature') + if 'metadata' in rec: + rec.pop('metadata') + + query = { + 'query': { + 'bool': { + 'should': [{'term': {'simple_words': r}} for r in rec["simple_words"]], + 'minimum_should_match': str(self.minimum_should_match) + } + }, + '_source': {'excludes': ['simple_words']} + } + + if pre_filter is not None: + query['query']['bool']['filter'] = pre_filter + + # Perform minimum_should_match request + res = self.es.search(index=self.index, + doc_type=self.doc_type, + body=query, + size=self.size, + timeout=self.timeout)['hits']['hits'] + + sigs = np.array([x['_source']['signature'] for x in res]) + + if sigs.size == 0: + return [] + + dists = normalized_distance(sigs, np.array(signature)) + + formatted_res = [{'id': x['_id'], + 'score': x['_score'], + 'metadata': x['_source'].get('metadata'), + 'path': x['_source'].get('url', x['_source'].get('path'))} + for x in res] + + for i, row in enumerate(formatted_res): + row['dist'] = dists[i] + formatted_res = filter(lambda y: y['dist'] < self.distance_cutoff, formatted_res) + + return formatted_res + + def insert_single_record(self, rec, refresh_after=False): + rec['timestamp'] = datetime.now() + + self.es.index(index=self.index, doc_type=self.doc_type, body=rec, refresh=refresh_after) + + def delete_duplicates(self, path): + """Delete all but one entries in elasticsearch whose `path` value is equivalent to that of path. + Args: + path (string): path value to compare to those in the elastic search + """ + matching_paths = [item['_id'] for item in + self.es.search(body={'query': + {'match': + {'path': path} + } + }, + index=self.index)['hits']['hits'] + if item['_source']['path'] == path] + if len(matching_paths) > 0: + for id_tag in matching_paths[1:]: + self.es.delete(index=self.index, doc_type=self.doc_type, id=id_tag) + + def add_image(self, path, img=None, bytestream=False, metadata=None, refresh_after=False): + """Add a single image to the database + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or identifier for image. If img=None, then path is assumed to be + a URL or filesystem path + img (Optional[string]): usually raw image data. In this case, path will still be stored, but + a signature will be generated from data in img. If bytestream is False, but img is + not None, then img is assumed to be the URL or filesystem path. Thus, you can store + image records with a different 'path' than the actual image location (default None) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? If img is None but, this + argument will be ignored. If img is not None, and bytestream is False, then the behavior + is as described in the explanation for the img argument + (default False) + metadata (Optional): any other information you want to include, can be nested (default None) + + """ + rec = make_record(path, self.gis, self.k, self.N, img=img, bytestream=bytestream, metadata=metadata, flat=True, + flatint=True) + self.insert_single_record(rec, refresh_after=refresh_after) + + def search_image(self, path, all_orientations=False, bytestream=False, pre_filter=None): + """Search for matches + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or image data. If bytestream=False, then path is assumed to be + a URL or filesystem path. Otherwise, it's assumed to be raw image data + all_orientations (Optional[boolean]): if True, search for all combinations of mirror + images, rotations, and color inversions (default False) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? + (default False) + pre_filter (Optional[dict]): filters list before applying the matching algorithm + (default None) + Returns: + a formatted list of dicts representing unique matches, sorted by dist + + For example, if three matches are found: + + [ + {'dist': 0.069116439263706961, + 'id': u'AVM37oZq0osmmAxpPvx7', + 'path': u'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg'}, + {'dist': 0.22484320805049718, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg'}, + {'dist': 0.42529792112113302, + 'id': u'AVM37p530osmmAxpPvx9', + 'path': u'https://c2.staticflickr.com/8/7158/6814444991_08d82de57e_z.jpg'} + ] + + """ + img = self.gis.preprocess_image(path, bytestream) + + if all_orientations: + # initialize an iterator of composed transformations + inversions = [lambda x: x, lambda x: -x] + + mirrors = [lambda x: x, np.fliplr] + + # an ugly solution for function composition + rotations = [lambda x: x, + np.rot90, + lambda x: np.rot90(x, 2), + lambda x: np.rot90(x, 3)] + + # cartesian product of all possible orientations + orientations = product(inversions, rotations, mirrors) + + else: + # otherwise just use the identity transformation + orientations = [lambda x: x] + + # try for every possible combination of transformations; if all_orientations=False, + # this will only take one iteration + result = [] + + orientations = set(np.ravel(list(orientations))) + for transform in orientations: + # compose all functions and apply on signature + transformed_img = transform(img) + + # generate the signature + transformed_record = make_record(transformed_img, self.gis, self.k, self.N, flat=True, flatint=True) + + l = self.search_single_record(transformed_record, pre_filter=pre_filter) + result.extend(l) + + ids = set() + unique = [] + for item in result: + if item['id'] not in ids: + unique.append(item) + ids.add(item['id']) + + r = sorted(unique, key=itemgetter('dist')) + return r diff --git a/image_match/signature_database_base.py b/image_match/signature_database_base.py index 70e01e5..e5cddd0 100644 --- a/image_match/signature_database_base.py +++ b/image_match/signature_database_base.py @@ -286,7 +286,7 @@ def search_image(self, path, all_orientations=False, bytestream=False, pre_filte return r -def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False): +def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False, flatint=False): """Makes a record suitable for database insertion. Note: @@ -314,6 +314,8 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat flat (Optional): by default, words are stored in separate properties from simple_word_0 to simple_word_N (N given as a parameter). When flat is set to True, all the word are stored into one single 'simple_words' property, as a string, space separated. + flatint (Optional): only if flat is True. Will store words as an array of integers instead of + a long string space separated. Returns: An image record. @@ -351,6 +353,15 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat 'metadata': {...} } + Or when flat is set to True with flatint also set to True: + + {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', + 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] + 'simple_words': [42252475, 23885671, 9967839, 4257902, 28651959, 33773597, 39331441, 39327300, 11337345, + 9571961, 28697868, 14834907, 7434746, 37985525, 10753207, 9566120, ...] + 'metadata': {...} + } + """ record = dict() record['path'] = path @@ -370,8 +381,11 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat words = words_to_int(words) if flat: - for i in range(N): - record['simple_words'] = " ".join(map(str, words.tolist())) + if flatint: + record['simple_words'] = words.tolist() + else: + for i in range(N): + record['simple_words'] = " ".join(map(str, words.tolist())) else: for i in range(N): record[''.join(['simple_word_', str(i)])] = words[i].tolist() diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py index 1acd5dd..28ffee2 100644 --- a/tests/test_elasticsearch_driver_speed.py +++ b/tests/test_elasticsearch_driver_speed.py @@ -6,6 +6,16 @@ from image_match.elasticsearch_driver import SignatureES as SignatureES_fields from image_match.elasticsearchflat_driver import SignatureES as SignatureES_flat +from image_match.elasticsearchflatint_driver import SignatureES as SignatureES_flatint + +# To run this test, have an elasticsearch on ports 9200 and 9300 +# docker run -d -p 9200:9200 -p 9300:9300 elasticsearch:5.5.2 + +# Params +delete_indices = True +populate_indices = True +max_msm = 6 +range_msm = range(1, max_msm + 1) test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' @@ -54,15 +64,55 @@ } } +# ES for flatint +INDEX_NAME_FLATINT = 'test_environment_flatint' +DOC_TYPE_FLATINT = 'image_flatint' +MAPPINGS_FLATINT = { + "mappings": { + DOC_TYPE_FLATINT: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + }, + "simple_words": { + "type": "long", + "doc_values": False, + "store": False + } + } + } + } +} + es = Elasticsearch() -# Define two ses +if delete_indices: + print("Delete indices") + es.indices.delete(INDEX_NAME_FIELDS) + es.indices.delete(INDEX_NAME_FLAT) + es.indices.delete(INDEX_NAME_FLATINT) + + print("Create indices") + es.indices.create(index=INDEX_NAME_FIELDS, body=MAPPINGS_FIELDS) + es.indices.create(index=INDEX_NAME_FLAT, body=MAPPINGS_FLAT) + es.indices.create(index=INDEX_NAME_FLATINT, body=MAPPINGS_FLATINT) + +# Define three ses print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) +print("Created index {} for flatint documents".format(INDEX_NAME_FLATINT)) ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, doc_type=DOC_TYPE_FIELDS) ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, doc_type=DOC_TYPE_FLAT) +ses_flatint = SignatureES_flatint(es=es, index=INDEX_NAME_FLATINT, doc_type=DOC_TYPE_FLATINT) -# Download dataset and populate index +# Download dataset +print("Download dataset if does not exist") dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" dir_path = os.path.dirname(os.path.realpath(__file__)) local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") @@ -72,14 +122,16 @@ print(cmd) os.system(cmd) - cmd = "tar xzvf {}".format(local_file) + cmd = "tar xzf {}".format(local_file) print(cmd) os.system(cmd) -# Populate the two indexes with images +# Populate the three indexes with images +print("Ingest documents") all_files = [] total_time_ingest_fields = 0 total_time_ingest_flat = 0 +total_time_ingest_flatint = 0 for root, dirs, files in os.walk(local_directory): for file in files: full_path = os.path.join(root, file) @@ -88,30 +140,43 @@ if len(all_files) % 1000 == 0: print("{} documents ingested (in each index)".format(len(all_files))) - t_fields = time.time() - ses_fields.add_image(full_path) - total_time_ingest_fields += (time.time() - t_fields) + if populate_indices: + t_fields = time.time() + ses_fields.add_image(full_path) + total_time_ingest_fields += (time.time() - t_fields) + + t_flat = time.time() + ses_flat.add_image(full_path) + total_time_ingest_flat += (time.time() - t_flat) - t_flat = time.time() - ses_flat.add_image(full_path) - total_time_ingest_flat += (time.time() - t_flat) + t_flatint = time.time() + ses_flatint.add_image(full_path) + total_time_ingest_flatint += (time.time() - t_flatint) print("{} to ingest fields documents".format(total_time_ingest_fields)) print("{} to ingest flats documents".format(total_time_ingest_flat)) +print("{} to ingest flatint documents".format(total_time_ingest_flatint)) # Pick 500 random files and request both indexes total_time_search_fields = 0 total_time_search_flat = 0 +total_time_search_flatint = 0 num_random = 500 -max_msm = 6 -total_res = 0 # Total results cumulated between flat and fields -in_common = 0 # Number of results returned from both indices +total_res_flat = 0 # Total results cumulated between flat and fields +total_res_flatint = 0 # Total results cumulated between flatint and fields +in_common_flat = 0 # Number of results returned from both indices +in_common_flatint = 0 # Number of results returned from both indices + random_images = random.choice(all_files, num_random).tolist() -for msm in range(1, max_msm + 1): +for msm in range_msm: ses_flat.minimum_should_match = msm + ses_flatint.minimum_should_match = msm + found_flat = [0, 0, 0] # (less_results, equal_results, more_results) - same_first = 0 # Number of time the first result is the same + found_flatint = [0, 0, 0] # (less_results, equal_results, more_results) + same_first_flat = 0 # Number of time the first result is the same + same_first_flatint = 0 # Number of time the first result is the same for image in random_images: t_search_fields = time.time() @@ -122,6 +187,11 @@ res_flat = ses_flat.search_image(image) total_time_search_flat += (time.time() - t_search_flat) + t_search_flatint = time.time() + res_flatint = ses_flatint.search_image(image) + total_time_search_flatint += (time.time() - t_search_flatint) + + # stats for flat if len(res_fields) == len(res_flat): found_flat[1] += 1 elif len(res_fields) > len(res_flat): @@ -129,8 +199,8 @@ else: found_flat[0] += 1 - total_res += len(res_fields) + len(res_flat) - in_common += len( + total_res_flat += len(res_fields) + len(res_flat) + in_common_flat += len( list( set([r["path"] for r in res_fields]).intersection( [r["path"] for r in res_flat]) @@ -139,22 +209,47 @@ if len(res_fields) > 0 and len(res_flat) > 0: if res_fields[0]["path"] == res_flat[0]["path"]: - same_first += 1 + same_first_flat += 1 + + # stats for flatint + if len(res_fields) == len(res_flatint): + found_flatint[1] += 1 + elif len(res_fields) > len(res_flatint): + found_flatint[2] += 1 + else: + found_flatint[0] += 1 + + total_res_flatint += len(res_fields) + len(res_flatint) + in_common_flatint += len( + list( + set([r["path"] for r in res_fields]).intersection( + [r["path"] for r in res_flatint]) + ) + ) + + if len(res_fields) > 0 and len(res_flatint) > 0: + if res_fields[0]["path"] == res_flatint[0]["path"]: + same_first_flatint += 1 print("") print("minimum_should_match = {}".format(msm)) + + print("--flat--") print("{} less results in flat, {} same num results, {} more results in flat" .format(found_flat[0], found_flat[1], found_flat[2])) print("{} common results out of {} total results. {}% match" - .format(in_common, total_res, in_common * 100 / total_res)) - print("{} same first results (out of {})".format(same_first, num_random)) + .format(in_common_flat, total_res_flat, in_common_flat * 100 / total_res_flat)) + print("{} same first results (out of {})".format(same_first_flat, num_random)) + + print("--flatint--") + print("{} less results in flatint, {} same num results, {} more results in flatint" + .format(found_flatint[0], found_flatint[1], found_flatint[2])) + print("{} common results out of {} total results. {}% match" + .format(in_common_flatint, total_res_flatint, in_common_flatint * 100 / total_res_flatint)) + print("{} same first results (out of {})".format(same_first_flatint, num_random)) print("") print("{} searches total".format(num_random * max_msm)) print("{} to search fields documents".format(total_time_search_fields)) print("{} to search flat documents".format(total_time_search_flat)) - -# Delete indexes -print("Delete indices") -es.indices.delete(INDEX_NAME_FIELDS) -es.indices.delete(INDEX_NAME_FLAT) +print("{} to search flatint documents".format(total_time_search_flatint)) From 39a7faa775f1d27edabe0c36bbf3d33293d84e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl?= Date: Wed, 20 Dec 2017 10:30:04 +0100 Subject: [PATCH 6/9] Added a speed test with 'flatint', storing words as list of long --- image_match/elasticsearchflatint_driver.py | 237 +++++++++++++++++++++ image_match/signature_database_base.py | 20 +- tests/test_elasticsearch_driver_speed.py | 145 ++++++++++--- 3 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 image_match/elasticsearchflatint_driver.py diff --git a/image_match/elasticsearchflatint_driver.py b/image_match/elasticsearchflatint_driver.py new file mode 100644 index 0000000..13fb47a --- /dev/null +++ b/image_match/elasticsearchflatint_driver.py @@ -0,0 +1,237 @@ +from image_match.signature_database_base import SignatureDatabaseBase +from image_match.signature_database_base import normalized_distance +from image_match.signature_database_base import make_record +from datetime import datetime +from itertools import product +from operator import itemgetter +import numpy as np +from collections import deque + + +class SignatureES(SignatureDatabaseBase): + """Elasticsearch driver for image-match + + This driver deals with document where all simple words, from 1 to N, are stored + in a single string field in the document, named "simple_words". The words are + string separated, like "11111 22222 33333 44444 55555 66666 77777". + + The field is queried with a "match" on "simple_words" with a "minimum_should_match" + given as a parameter. i.e. the following document: + {"simple_words": "11111 22222 33333 44444 55555 66666 77777"} + will be returned by the search_single_record function by the given image words: + 11111 99999 33333 44444 00000 55555 88888 + if minimum_should_match is below or equal to 4, because for words are in common. + + The order of the words in the string field is maintained, although it does not make any + sort of importance because of the way the field is queried. + + """ + + def __init__(self, es, index='images', doc_type='image', timeout='10s', size=100, minimum_should_match=6, + *args, **kwargs): + """Extra setup for Elasticsearch + + Args: + es (elasticsearch): an instance of the elasticsearch python driver + index (Optional[string]): a name for the Elasticsearch index (default 'images') + doc_type (Optional[string]): a name for the document time (default 'image') + timeout (Optional[int]): how long to wait on an Elasticsearch query, in seconds (default 10) + size (Optional[int]): maximum number of Elasticsearch results (default 100) + minimum_should_match (Optional[int]): maximum number of common words in the queried image + and the document (default 6). + *args (Optional): Variable length argument list to pass to base constructor + **kwargs (Optional): Arbitrary keyword arguments to pass to base constructor + + Examples: + >>> from elasticsearch import Elasticsearch + >>> from image_match.elasticsearch_driver import SignatureES + >>> es = Elasticsearch() + >>> ses = SignatureES(es) + >>> ses.add_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + >>> ses.search_image('https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg') + [ + {'dist': 0.0, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg', + 'score': 0.28797293} + ] + + """ + self.es = es + self.index = index + self.doc_type = doc_type + self.timeout = timeout + self.size = size + self.minimum_should_match = minimum_should_match + + super(SignatureES, self).__init__(*args, **kwargs) + + def search_single_record(self, rec, pre_filter=None): + path = rec.pop('path') + signature = rec.pop('signature') + if 'metadata' in rec: + rec.pop('metadata') + + query = { + 'query': { + 'bool': { + 'should': [{'term': {'simple_words': r}} for r in rec["simple_words"]], + 'minimum_should_match': str(self.minimum_should_match) + } + }, + '_source': {'excludes': ['simple_words']} + } + + if pre_filter is not None: + query['query']['bool']['filter'] = pre_filter + + # Perform minimum_should_match request + res = self.es.search(index=self.index, + doc_type=self.doc_type, + body=query, + size=self.size, + timeout=self.timeout)['hits']['hits'] + + sigs = np.array([x['_source']['signature'] for x in res]) + + if sigs.size == 0: + return [] + + dists = normalized_distance(sigs, np.array(signature)) + + formatted_res = [{'id': x['_id'], + 'score': x['_score'], + 'metadata': x['_source'].get('metadata'), + 'path': x['_source'].get('url', x['_source'].get('path'))} + for x in res] + + for i, row in enumerate(formatted_res): + row['dist'] = dists[i] + formatted_res = filter(lambda y: y['dist'] < self.distance_cutoff, formatted_res) + + return formatted_res + + def insert_single_record(self, rec, refresh_after=False): + rec['timestamp'] = datetime.now() + + self.es.index(index=self.index, doc_type=self.doc_type, body=rec, refresh=refresh_after) + + def delete_duplicates(self, path): + """Delete all but one entries in elasticsearch whose `path` value is equivalent to that of path. + Args: + path (string): path value to compare to those in the elastic search + """ + matching_paths = [item['_id'] for item in + self.es.search(body={'query': + {'match': + {'path': path} + } + }, + index=self.index)['hits']['hits'] + if item['_source']['path'] == path] + if len(matching_paths) > 0: + for id_tag in matching_paths[1:]: + self.es.delete(index=self.index, doc_type=self.doc_type, id=id_tag) + + def add_image(self, path, img=None, bytestream=False, metadata=None, refresh_after=False): + """Add a single image to the database + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or identifier for image. If img=None, then path is assumed to be + a URL or filesystem path + img (Optional[string]): usually raw image data. In this case, path will still be stored, but + a signature will be generated from data in img. If bytestream is False, but img is + not None, then img is assumed to be the URL or filesystem path. Thus, you can store + image records with a different 'path' than the actual image location (default None) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? If img is None but, this + argument will be ignored. If img is not None, and bytestream is False, then the behavior + is as described in the explanation for the img argument + (default False) + metadata (Optional): any other information you want to include, can be nested (default None) + + """ + rec = make_record(path, self.gis, self.k, self.N, img=img, bytestream=bytestream, metadata=metadata, flat=True, + flatint=True) + self.insert_single_record(rec, refresh_after=refresh_after) + + def search_image(self, path, all_orientations=False, bytestream=False, pre_filter=None): + """Search for matches + + Overwrite the base function to search by flat image (call to make_record with flat=True) + + Args: + path (string): path or image data. If bytestream=False, then path is assumed to be + a URL or filesystem path. Otherwise, it's assumed to be raw image data + all_orientations (Optional[boolean]): if True, search for all combinations of mirror + images, rotations, and color inversions (default False) + bytestream (Optional[boolean]): will the image be passed as raw bytes? + That is, is the 'path_or_image' argument an in-memory image? + (default False) + pre_filter (Optional[dict]): filters list before applying the matching algorithm + (default None) + Returns: + a formatted list of dicts representing unique matches, sorted by dist + + For example, if three matches are found: + + [ + {'dist': 0.069116439263706961, + 'id': u'AVM37oZq0osmmAxpPvx7', + 'path': u'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg'}, + {'dist': 0.22484320805049718, + 'id': u'AVM37nMg0osmmAxpPvx6', + 'path': u'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg/687px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg'}, + {'dist': 0.42529792112113302, + 'id': u'AVM37p530osmmAxpPvx9', + 'path': u'https://c2.staticflickr.com/8/7158/6814444991_08d82de57e_z.jpg'} + ] + + """ + img = self.gis.preprocess_image(path, bytestream) + + if all_orientations: + # initialize an iterator of composed transformations + inversions = [lambda x: x, lambda x: -x] + + mirrors = [lambda x: x, np.fliplr] + + # an ugly solution for function composition + rotations = [lambda x: x, + np.rot90, + lambda x: np.rot90(x, 2), + lambda x: np.rot90(x, 3)] + + # cartesian product of all possible orientations + orientations = product(inversions, rotations, mirrors) + + else: + # otherwise just use the identity transformation + orientations = [lambda x: x] + + # try for every possible combination of transformations; if all_orientations=False, + # this will only take one iteration + result = [] + + orientations = set(np.ravel(list(orientations))) + for transform in orientations: + # compose all functions and apply on signature + transformed_img = transform(img) + + # generate the signature + transformed_record = make_record(transformed_img, self.gis, self.k, self.N, flat=True, flatint=True) + + l = self.search_single_record(transformed_record, pre_filter=pre_filter) + result.extend(l) + + ids = set() + unique = [] + for item in result: + if item['id'] not in ids: + unique.append(item) + ids.add(item['id']) + + r = sorted(unique, key=itemgetter('dist')) + return r diff --git a/image_match/signature_database_base.py b/image_match/signature_database_base.py index 70e01e5..e5cddd0 100644 --- a/image_match/signature_database_base.py +++ b/image_match/signature_database_base.py @@ -286,7 +286,7 @@ def search_image(self, path, all_orientations=False, bytestream=False, pre_filte return r -def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False): +def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat=False, flatint=False): """Makes a record suitable for database insertion. Note: @@ -314,6 +314,8 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat flat (Optional): by default, words are stored in separate properties from simple_word_0 to simple_word_N (N given as a parameter). When flat is set to True, all the word are stored into one single 'simple_words' property, as a string, space separated. + flatint (Optional): only if flat is True. Will store words as an array of integers instead of + a long string space separated. Returns: An image record. @@ -351,6 +353,15 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat 'metadata': {...} } + Or when flat is set to True with flatint also set to True: + + {'path': 'https://pixabay.com/static/uploads/photo/2012/11/28/08/56/mona-lisa-67506_960_720.jpg', + 'signature': [0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 2, 0, 0, 0, 0, 2, 2, 2, 2, 0 ... ] + 'simple_words': [42252475, 23885671, 9967839, 4257902, 28651959, 33773597, 39331441, 39327300, 11337345, + 9571961, 28697868, 14834907, 7434746, 37985525, 10753207, 9566120, ...] + 'metadata': {...} + } + """ record = dict() record['path'] = path @@ -370,8 +381,11 @@ def make_record(path, gis, k, N, img=None, bytestream=False, metadata=None, flat words = words_to_int(words) if flat: - for i in range(N): - record['simple_words'] = " ".join(map(str, words.tolist())) + if flatint: + record['simple_words'] = words.tolist() + else: + for i in range(N): + record['simple_words'] = " ".join(map(str, words.tolist())) else: for i in range(N): record[''.join(['simple_word_', str(i)])] = words[i].tolist() diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py index 1acd5dd..28ffee2 100644 --- a/tests/test_elasticsearch_driver_speed.py +++ b/tests/test_elasticsearch_driver_speed.py @@ -6,6 +6,16 @@ from image_match.elasticsearch_driver import SignatureES as SignatureES_fields from image_match.elasticsearchflat_driver import SignatureES as SignatureES_flat +from image_match.elasticsearchflatint_driver import SignatureES as SignatureES_flatint + +# To run this test, have an elasticsearch on ports 9200 and 9300 +# docker run -d -p 9200:9200 -p 9300:9300 elasticsearch:5.5.2 + +# Params +delete_indices = True +populate_indices = True +max_msm = 6 +range_msm = range(1, max_msm + 1) test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' @@ -54,15 +64,55 @@ } } +# ES for flatint +INDEX_NAME_FLATINT = 'test_environment_flatint' +DOC_TYPE_FLATINT = 'image_flatint' +MAPPINGS_FLATINT = { + "mappings": { + DOC_TYPE_FLATINT: { + "dynamic": True, + "properties": { + "metadata": { + "type": "nested", + "dynamic": True, + "properties": { + "tenant_id": { "type": "keyword" }, + "project_id": { "type": "keyword" } + } + }, + "simple_words": { + "type": "long", + "doc_values": False, + "store": False + } + } + } + } +} + es = Elasticsearch() -# Define two ses +if delete_indices: + print("Delete indices") + es.indices.delete(INDEX_NAME_FIELDS) + es.indices.delete(INDEX_NAME_FLAT) + es.indices.delete(INDEX_NAME_FLATINT) + + print("Create indices") + es.indices.create(index=INDEX_NAME_FIELDS, body=MAPPINGS_FIELDS) + es.indices.create(index=INDEX_NAME_FLAT, body=MAPPINGS_FLAT) + es.indices.create(index=INDEX_NAME_FLATINT, body=MAPPINGS_FLATINT) + +# Define three ses print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) +print("Created index {} for flatint documents".format(INDEX_NAME_FLATINT)) ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, doc_type=DOC_TYPE_FIELDS) ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, doc_type=DOC_TYPE_FLAT) +ses_flatint = SignatureES_flatint(es=es, index=INDEX_NAME_FLATINT, doc_type=DOC_TYPE_FLATINT) -# Download dataset and populate index +# Download dataset +print("Download dataset if does not exist") dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" dir_path = os.path.dirname(os.path.realpath(__file__)) local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") @@ -72,14 +122,16 @@ print(cmd) os.system(cmd) - cmd = "tar xzvf {}".format(local_file) + cmd = "tar xzf {}".format(local_file) print(cmd) os.system(cmd) -# Populate the two indexes with images +# Populate the three indexes with images +print("Ingest documents") all_files = [] total_time_ingest_fields = 0 total_time_ingest_flat = 0 +total_time_ingest_flatint = 0 for root, dirs, files in os.walk(local_directory): for file in files: full_path = os.path.join(root, file) @@ -88,30 +140,43 @@ if len(all_files) % 1000 == 0: print("{} documents ingested (in each index)".format(len(all_files))) - t_fields = time.time() - ses_fields.add_image(full_path) - total_time_ingest_fields += (time.time() - t_fields) + if populate_indices: + t_fields = time.time() + ses_fields.add_image(full_path) + total_time_ingest_fields += (time.time() - t_fields) + + t_flat = time.time() + ses_flat.add_image(full_path) + total_time_ingest_flat += (time.time() - t_flat) - t_flat = time.time() - ses_flat.add_image(full_path) - total_time_ingest_flat += (time.time() - t_flat) + t_flatint = time.time() + ses_flatint.add_image(full_path) + total_time_ingest_flatint += (time.time() - t_flatint) print("{} to ingest fields documents".format(total_time_ingest_fields)) print("{} to ingest flats documents".format(total_time_ingest_flat)) +print("{} to ingest flatint documents".format(total_time_ingest_flatint)) # Pick 500 random files and request both indexes total_time_search_fields = 0 total_time_search_flat = 0 +total_time_search_flatint = 0 num_random = 500 -max_msm = 6 -total_res = 0 # Total results cumulated between flat and fields -in_common = 0 # Number of results returned from both indices +total_res_flat = 0 # Total results cumulated between flat and fields +total_res_flatint = 0 # Total results cumulated between flatint and fields +in_common_flat = 0 # Number of results returned from both indices +in_common_flatint = 0 # Number of results returned from both indices + random_images = random.choice(all_files, num_random).tolist() -for msm in range(1, max_msm + 1): +for msm in range_msm: ses_flat.minimum_should_match = msm + ses_flatint.minimum_should_match = msm + found_flat = [0, 0, 0] # (less_results, equal_results, more_results) - same_first = 0 # Number of time the first result is the same + found_flatint = [0, 0, 0] # (less_results, equal_results, more_results) + same_first_flat = 0 # Number of time the first result is the same + same_first_flatint = 0 # Number of time the first result is the same for image in random_images: t_search_fields = time.time() @@ -122,6 +187,11 @@ res_flat = ses_flat.search_image(image) total_time_search_flat += (time.time() - t_search_flat) + t_search_flatint = time.time() + res_flatint = ses_flatint.search_image(image) + total_time_search_flatint += (time.time() - t_search_flatint) + + # stats for flat if len(res_fields) == len(res_flat): found_flat[1] += 1 elif len(res_fields) > len(res_flat): @@ -129,8 +199,8 @@ else: found_flat[0] += 1 - total_res += len(res_fields) + len(res_flat) - in_common += len( + total_res_flat += len(res_fields) + len(res_flat) + in_common_flat += len( list( set([r["path"] for r in res_fields]).intersection( [r["path"] for r in res_flat]) @@ -139,22 +209,47 @@ if len(res_fields) > 0 and len(res_flat) > 0: if res_fields[0]["path"] == res_flat[0]["path"]: - same_first += 1 + same_first_flat += 1 + + # stats for flatint + if len(res_fields) == len(res_flatint): + found_flatint[1] += 1 + elif len(res_fields) > len(res_flatint): + found_flatint[2] += 1 + else: + found_flatint[0] += 1 + + total_res_flatint += len(res_fields) + len(res_flatint) + in_common_flatint += len( + list( + set([r["path"] for r in res_fields]).intersection( + [r["path"] for r in res_flatint]) + ) + ) + + if len(res_fields) > 0 and len(res_flatint) > 0: + if res_fields[0]["path"] == res_flatint[0]["path"]: + same_first_flatint += 1 print("") print("minimum_should_match = {}".format(msm)) + + print("--flat--") print("{} less results in flat, {} same num results, {} more results in flat" .format(found_flat[0], found_flat[1], found_flat[2])) print("{} common results out of {} total results. {}% match" - .format(in_common, total_res, in_common * 100 / total_res)) - print("{} same first results (out of {})".format(same_first, num_random)) + .format(in_common_flat, total_res_flat, in_common_flat * 100 / total_res_flat)) + print("{} same first results (out of {})".format(same_first_flat, num_random)) + + print("--flatint--") + print("{} less results in flatint, {} same num results, {} more results in flatint" + .format(found_flatint[0], found_flatint[1], found_flatint[2])) + print("{} common results out of {} total results. {}% match" + .format(in_common_flatint, total_res_flatint, in_common_flatint * 100 / total_res_flatint)) + print("{} same first results (out of {})".format(same_first_flatint, num_random)) print("") print("{} searches total".format(num_random * max_msm)) print("{} to search fields documents".format(total_time_search_fields)) print("{} to search flat documents".format(total_time_search_flat)) - -# Delete indexes -print("Delete indices") -es.indices.delete(INDEX_NAME_FIELDS) -es.indices.delete(INDEX_NAME_FLAT) +print("{} to search flatint documents".format(total_time_search_flatint)) From c05934d04438d48744bb3ef547758ec33153616c Mon Sep 17 00:00:00 2001 From: miqwit Date: Thu, 14 Mar 2019 18:21:23 +0100 Subject: [PATCH 7/9] Generate plots when running benchmark --- requirements.txt | 32 +-- tests/test_elasticsearch_driver_speed.py | 342 ++++++++++++++++++----- 2 files changed, 270 insertions(+), 104 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4646083..6833249 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,5 @@ -apipkg==1.4 -astroid==1.5.3 -coverage==4.4.1 -cycler==0.10.0 -decorator==4.1.2 elasticsearch==5.4.0 -execnet==1.4.1 -isort==4.2.15 -lazy-object-proxy==1.3.1 -matplotlib==2.0.2 -mccabe==0.6.1 -networkx==2.0 +matplotlib numpy==1.13.1 -olefile==0.44 -pep8==1.7.0 Pillow==4.2.1 -py==1.4.34 -pyflakes==1.6.0 -pylint==1.7.2 -pyparsing==2.2.0 -pytest==3.2.2 -pytest-cov==2.5.1 -pytest-forked==0.2 -pytest-runner==2.12.1 -pytest-xdist==1.20.0 -python-dateutil==2.6.1 -pytz==2017.2 -PyWavelets==0.5.2 -scikit-image==0.13.0 -scipy==0.19.1 -six==1.11.0 -urllib3==1.22 -wrapt==1.10.11 +argparse \ No newline at end of file diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py index 28ffee2..9d3788c 100644 --- a/tests/test_elasticsearch_driver_speed.py +++ b/tests/test_elasticsearch_driver_speed.py @@ -1,26 +1,85 @@ -import urllib.request import os from elasticsearch import Elasticsearch import time from numpy import random +import numpy as np +from PIL import ImageFilter, Image +import matplotlib.pyplot as plt -from image_match.elasticsearch_driver import SignatureES as SignatureES_fields -from image_match.elasticsearchflat_driver import SignatureES as SignatureES_flat -from image_match.elasticsearchflatint_driver import SignatureES as SignatureES_flatint +from image_match.elasticsearch_driver \ + import SignatureES as SignatureES_fields +from image_match.elasticsearchflat_driver \ + import SignatureES as SignatureES_flat +from image_match.elasticsearchflatint_driver \ + import SignatureES as SignatureES_flatint # To run this test, have an elasticsearch on ports 9200 and 9300 # docker run -d -p 9200:9200 -p 9300:9300 elasticsearch:5.5.2 +import argparse +parser = argparse.ArgumentParser() +parser.add_argument("--delete-indices", default=False, + help="Will delete existing ES indices (test_environment_" + "fields, test_environment_int and test_environment_" + "flatint") +parser.add_argument("--populate-indices", default=False, + help="Ingest into indices all the images from dataset") +parser.add_argument("--max-msm", default=6, + help="Until which minimum should match (msm) value to run " + "this benchmark") +parser.add_argument("--num-random", default=500, + help="Total number of images to search for a given msm. " + "Total num searches = num_random * len(range_msm)") +args = parser.parse_args() + + # Params -delete_indices = True -populate_indices = True -max_msm = 6 +delete_indices = args.delete_indices +populate_indices = args.populate_indices +max_msm = args.max_msm +num_random = args.num_random range_msm = range(1, max_msm + 1) -test_img_url1 = 'https://camo.githubusercontent.com/810bdde0a88bc3f8ce70c5d85d8537c37f707abe/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f652f65632f4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a70672f36383770782d4d6f6e615f4c6973612c5f62795f4c656f6e6172646f5f64615f56696e63692c5f66726f6d5f4332524d465f7265746f75636865642e6a7067' -test_img_url2 = 'https://camo.githubusercontent.com/826e23bc3eca041110a5af467671b012606aa406/68747470733a2f2f63322e737461746963666c69636b722e636f6d2f382f373135382f363831343434343939315f303864383264653537655f7a2e6a7067' -urllib.request.urlretrieve(test_img_url1, 'test1.jpg') -urllib.request.urlretrieve(test_img_url2, 'test2.jpg') + +def noise_generator(noise_type, image): + """ + Found on https://stackoverflow.com/questions/22937589/ + how-to-add-noise-gaussian-salt-and-pepper-etc-to-image-in-python-with-opencv + Generate noise to a given Image based on required noise type + + Input parameters: + image: ndarray (input image data. It will be converted to float) + noise_type: string + 'gauss' Gaussian-distribution based noise + 's&p' Salt and Pepper noise, 0 or 1 + """ + if noise_type == "gauss": + row, col, ch = image.shape + mean = 0.5 + var = 0.01 + sigma = var ** 0.5 + gauss = np.random.normal(mean, sigma, (row, col, ch)) + gauss = gauss.reshape(row, col, ch) + noisy = image + gauss + return noisy.astype('uint8') + elif noise_type == "s&p": + s_vs_p = 0.5 + amount = 0.01 + out = image + # Generate Salt '1' noise + num_salt = np.ceil(amount * image.size * s_vs_p) + coords = [np.random.randint(0, idx - 1, int(num_salt)) + for idx in image.shape] + out[coords] = 255 + # Generate Pepper '0' noise + num_pepper = np.ceil(amount * image.size * (1. - s_vs_p)) + coords = [np.random.randint(0, idx - 1, int(num_pepper)) + for idx in image.shape] + out[coords] = 0 + return out + else: + return image + # ES for fields INDEX_NAME_FIELDS = 'test_environment_fields' @@ -34,8 +93,8 @@ "type": "nested", "dynamic": True, "properties": { - "tenant_id": { "type": "keyword" }, - "project_id": { "type": "keyword" } + "tenant_id": {"type": "keyword"}, + "project_id": {"type": "keyword"} } } } @@ -55,8 +114,8 @@ "type": "nested", "dynamic": True, "properties": { - "tenant_id": { "type": "keyword" }, - "project_id": { "type": "keyword" } + "tenant_id": {"type": "keyword"}, + "project_id": {"type": "keyword"} } } } @@ -76,8 +135,8 @@ "type": "nested", "dynamic": True, "properties": { - "tenant_id": { "type": "keyword" }, - "project_id": { "type": "keyword" } + "tenant_id": {"type": "keyword"}, + "project_id": {"type": "keyword"} } }, "simple_words": { @@ -107,13 +166,17 @@ print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) print("Created index {} for flatint documents".format(INDEX_NAME_FLATINT)) -ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, doc_type=DOC_TYPE_FIELDS) -ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, doc_type=DOC_TYPE_FLAT) -ses_flatint = SignatureES_flatint(es=es, index=INDEX_NAME_FLATINT, doc_type=DOC_TYPE_FLATINT) +ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, + doc_type=DOC_TYPE_FIELDS) +ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, + doc_type=DOC_TYPE_FLAT) +ses_flatint = SignatureES_flatint(es=es, index=INDEX_NAME_FLATINT, + doc_type=DOC_TYPE_FLATINT) # Download dataset print("Download dataset if does not exist") -dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" +dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/" \ + "101_ObjectCategories.tar.gz" dir_path = os.path.dirname(os.path.realpath(__file__)) local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") local_directory = os.path.join(dir_path, "101_ObjectCategories") @@ -138,7 +201,8 @@ all_files.append(full_path) if len(all_files) % 1000 == 0: - print("{} documents ingested (in each index)".format(len(all_files))) + print("{} documents ingested (in each index)" + .format(len(all_files))) if populate_indices: t_fields = time.time() @@ -161,95 +225,225 @@ total_time_search_fields = 0 total_time_search_flat = 0 total_time_search_flatint = 0 -num_random = 500 -total_res_flat = 0 # Total results cumulated between flat and fields -total_res_flatint = 0 # Total results cumulated between flatint and fields -in_common_flat = 0 # Number of results returned from both indices -in_common_flatint = 0 # Number of results returned from both indices random_images = random.choice(all_files, num_random).tolist() +# Store all stats per msm {"1": {"same_first_flat": 489, +# "not_same_first_flat": [0, 0, 0], "same_first_flatint": 3, +# "not_same_first_flatint": [0, 0, 0]}} +stats_msm = {} + for msm in range_msm: ses_flat.minimum_should_match = msm ses_flatint.minimum_should_match = msm - found_flat = [0, 0, 0] # (less_results, equal_results, more_results) - found_flatint = [0, 0, 0] # (less_results, equal_results, more_results) same_first_flat = 0 # Number of time the first result is the same + not_same_first_flat = [0, 0, 0] # both not found, found in fields, in flat same_first_flatint = 0 # Number of time the first result is the same + not_same_first_flatint = [0, 0, 0] # idem + + for image_path in random_images: + original_image = Image.open(image_path) + altered_path = "altered.jpg" + # altered_image = original_image.filter(ImageFilter.BLUR) + img_array_with_noise = noise_generator("s&p", np.array(original_image)) + altered_image = Image.fromarray(img_array_with_noise) + altered_image.save(altered_path) + image_path_to_search = altered_path - for image in random_images: t_search_fields = time.time() - res_fields = ses_fields.search_image(image) + res_fields = ses_fields.search_image(image_path_to_search) total_time_search_fields += (time.time() - t_search_fields) t_search_flat = time.time() - res_flat = ses_flat.search_image(image) + res_flat = ses_flat.search_image(image_path_to_search) total_time_search_flat += (time.time() - t_search_flat) t_search_flatint = time.time() - res_flatint = ses_flatint.search_image(image) + res_flatint = ses_flatint.search_image(image_path_to_search) total_time_search_flatint += (time.time() - t_search_flatint) - # stats for flat - if len(res_fields) == len(res_flat): - found_flat[1] += 1 - elif len(res_fields) > len(res_flat): - found_flat[2] += 1 - else: - found_flat[0] += 1 - - total_res_flat += len(res_fields) + len(res_flat) - in_common_flat += len( - list( - set([r["path"] for r in res_fields]).intersection( - [r["path"] for r in res_flat]) - ) - ) + # Delete blurred image + os.remove(altered_path) + # FLAT analysis + # Precision of first result + same_first_flat_bool = False if len(res_fields) > 0 and len(res_flat) > 0: if res_fields[0]["path"] == res_flat[0]["path"]: - same_first_flat += 1 + same_first_flat_bool = True + elif len(res_fields) == 0 and len(res_flat) == 0: + same_first_flat_bool = True # both fields and flat didn't find - # stats for flatint - if len(res_fields) == len(res_flatint): - found_flatint[1] += 1 - elif len(res_fields) > len(res_flatint): - found_flatint[2] += 1 + # When the first result is not the same, find out more details + if same_first_flat_bool: + same_first_flat += 1 else: - found_flatint[0] += 1 - - total_res_flatint += len(res_fields) + len(res_flatint) - in_common_flatint += len( - list( - set([r["path"] for r in res_fields]).intersection( - [r["path"] for r in res_flatint]) - ) - ) - + pathes_fields = [res["path"] for res in res_fields] + [""] + pathes_flat = [res["path"] for res in res_flat] + [""] + if image_path not in pathes_fields and image_path not in pathes_flat: + not_same_first_flat[0] += 1 + elif image_path not in pathes_fields and pathes_flat[0] == image_path: + not_same_first_flat[2] += 1 + elif image_path not in pathes_flat and pathes_fields[0] == image_path: + not_same_first_flat[1] += 1 + + # FLATINT analysis + # Precision of first result + same_first_flatint_bool = False if len(res_fields) > 0 and len(res_flatint) > 0: if res_fields[0]["path"] == res_flatint[0]["path"]: - same_first_flatint += 1 + same_first_flatint_bool = True + elif len(res_fields) == 0 and len(res_flatint) == 0: + same_first_flatint_bool = True # both fields and flatint didn't find + + # When the first result is not the same, find out more details + if same_first_flatint_bool: + same_first_flatint += 1 + else: + pathes_fields = [res["path"] for res in res_fields] + [""] + pathes_flatint = [res["path"] for res in res_flatint] + [""] + if image_path not in pathes_fields and image_path not in pathes_flatint: + not_same_first_flatint[0] += 1 + elif image_path not in pathes_fields and pathes_flatint[0] == image_path: + not_same_first_flatint[2] += 1 + elif image_path not in pathes_flatint and pathes_fields[0] == image_path: + not_same_first_flatint[1] += 1 + + stats_msm[str(msm)] = { + "same_first_flat": same_first_flat, + "not_same_first_flat": not_same_first_flat, + "same_first_flatint": same_first_flatint, + "not_same_first_flatint": not_same_first_flatint + } + + print(stats_msm) print("") print("minimum_should_match = {}".format(msm)) print("--flat--") - print("{} less results in flat, {} same num results, {} more results in flat" - .format(found_flat[0], found_flat[1], found_flat[2])) - print("{} common results out of {} total results. {}% match" - .format(in_common_flat, total_res_flat, in_common_flat * 100 / total_res_flat)) print("{} same first results (out of {})".format(same_first_flat, num_random)) + print("When not same first results ({} cases)".format(sum(not_same_first_flat))) + print(". {} both wrong".format(not_same_first_flat[0])) + print(". {} found in fields but not in flat".format(not_same_first_flat[1])) + print(". {} found in flat but not in fields".format(not_same_first_flat[2])) print("--flatint--") - print("{} less results in flatint, {} same num results, {} more results in flatint" - .format(found_flatint[0], found_flatint[1], found_flatint[2])) - print("{} common results out of {} total results. {}% match" - .format(in_common_flatint, total_res_flatint, in_common_flatint * 100 / total_res_flatint)) print("{} same first results (out of {})".format(same_first_flatint, num_random)) + print("When not same first results ({} cases)".format(sum(not_same_first_flatint))) + print(". {} both wrong".format(not_same_first_flatint[0])) + print(". {} found in fields but not in flatint".format(not_same_first_flatint[1])) + print(". {} found in flatint but not in fields".format(not_same_first_flatint[2])) + print("") -print("{} searches total".format(num_random * max_msm)) +num_searches = num_random * max_msm +print("{} searches total".format(num_searches)) print("{} to search fields documents".format(total_time_search_fields)) print("{} to search flat documents".format(total_time_search_flat)) print("{} to search flatint documents".format(total_time_search_flatint)) + +# ----------------------------------------------------------------------------- +# +# Draw plots in png files for further analysis +# +# ----------------------------------------------------------------------------- +# Typical stats_msm: +# {'1': {'same_first_flat': 499, 'not_same_first_flat': [0, 1, 0], +# 'same_first_flatint': 499, 'not_same_first_flatint': [0, 0, 0]}, +# '2': {'same_first_flat': 497, 'not_same_first_flat': [2, 1, 0], +# 'same_first_flatint': 494, 'not_same_first_flatint': [4, 1, 0]}, +# '3': {'same_first_flat': 496, 'not_same_first_flat': [1, 2, 0], +# 'same_first_flatint': 495, 'not_same_first_flatint': [1, 2, 0]}, +# '4': {'same_first_flat': 494, 'not_same_first_flat': [2, 2, 0], +# 'same_first_flatint': 495, 'not_same_first_flatint': [2, 2, 0]}, +# '5': {'same_first_flat': 488, 'not_same_first_flat': [6, 4, 0], +# 'same_first_flatint': 489, 'not_same_first_flatint': [6, 4, 0]}, +# '6': {'same_first_flat': 490, 'not_same_first_flat': [4, 5, 0], +# 'same_first_flatint': 489, 'not_same_first_flatint': [4, 5, 0]}} + +# Generate stat plot for ingestion +names = ["fields", "flat_txt", "flat_int"] +values_ingest = [total_time_ingest_fields, total_time_ingest_flat, total_time_ingest_flatint] +colors = ["red", "green", "blue"] +plt.xlabel("Ingestion Time (ms)") +plt.title("Average Ingestion Time ({} documents)".format(len(all_files))) +values_ingest_mean = [v / float(len(all_files)) for v in values_ingest] +plt.barh(names, values_ingest_mean, color=colors) +for i, v in enumerate(values_ingest_mean): + plt.text(v - .01, i + .25, "{:.4f} ms".format(v), color='black') +plt_file_name = 'plot_time_ingestion.png' +plt.savefig(plt_file_name) +plt.clf() +print("Save plot {}".format(plt_file_name)) + +# Size on disk +size_fields = es.indices.stats(INDEX_NAME_FIELDS)["indices"][INDEX_NAME_FIELDS]["total"]["store"]["size_in_bytes"] +size_flat = es.indices.stats(INDEX_NAME_FLAT)["indices"][INDEX_NAME_FLAT]["total"]["store"]["size_in_bytes"] +size_flatint = es.indices.stats(INDEX_NAME_FLATINT)["indices"][INDEX_NAME_FLATINT]["total"]["store"]["size_in_bytes"] +sizes = [size_fields, size_flat, size_flatint] +plt.xlabel("Size on disk (MB)") +plt.title("Size of ES index on disk") +values = [s/1024/1024 for s in sizes] +plt.barh(names, values, color=colors) +for i, v in enumerate(values): + plt.text(v - .01, i + .25, "{:.2f} MB".format(v), color='black') +plt_file_name = "plot_disk_usage.png" +plt.savefig(plt_file_name) +plt.clf() +print("Save plot {}".format(plt_file_name)) + +# Search Average Time +values_search = [total_time_search_fields, total_time_search_flat, total_time_search_flatint] +plt.xlabel("Search Time (ms)") +plt.title("Average Search Time ({} searches)".format(num_searches)) +values_search_mean = [v / float(len(all_files)) for v in values_search] +plt.barh(names, values_search_mean, color=colors) +for i, v in enumerate(values_search_mean): + plt.text(v - .01, i + .25, "{:.4f} ms".format(v), color='black') +plt_file_name = "plot_search_time.png" +plt.savefig(plt_file_name) +plt.clf() +print("Save plot {}".format(plt_file_name)) + +# Quantitative results + + +def draw_qualitative_plot(suffix): + """ + Draw a plot in a file from the stats_msm. + :param suffix: "flat" or "flatint" + :return: None + """ + names_msm = list(map(str, list(range_msm))) + same_first = [stats_msm[str(i_msm)]["same_first_" + suffix] for i_msm in list(range_msm)] + both_not_found = [stats_msm[str(i_msm)]["not_same_first_" + suffix][0] for i_msm in list(range_msm)] + found_in_fields = [stats_msm[str(i_msm)]["not_same_first_" + suffix][1] for i_msm in list(range_msm)] + found_in_flat = [stats_msm[str(i_msm)]["not_same_first_" + suffix][2] for i_msm in list(range_msm)] + fig, ax1 = plt.subplots() + + ax2 = ax1.twinx() + bar1 = ax2.bar(names_msm, both_not_found) + bar2 = ax2.bar(names_msm, found_in_fields, bottom=both_not_found) + bar3 = ax2.bar(names_msm, found_in_flat, bottom=found_in_fields) + maxval = max([sum([stats_msm[r]["not_same_first_" + suffix][idx_res] for r in names_msm]) + for idx_res in range(0, 3)]) + ax2.set_ylim(0, int(maxval + maxval*.75)) # Expand y limit of max histogram to have some space + + plot1 = ax1.plot(names_msm, same_first, "r-o") + minval = min([stats_msm[s]["same_first_" + suffix] for s in names_msm]) + ax1.set_ylim(int(500 - ((500 - minval) * 2.75)), 500) + + plt.xlabel("Minimum Should Match") + plt.ylabel("Hits") + plt.legend((plot1[0], bar1[0], bar2[0], bar3[0]), ('Same first result', 'Not found in both', + 'Found only in fields', 'Found only in ' + suffix)) + plt_file_name = 'plot_qualitative_{}.png'.format(suffix) + plt.savefig(plt_file_name) + plt.clf() + print("Save plot {}".format(plt_file_name)) + + +draw_qualitative_plot("flat") +draw_qualitative_plot("flatint") From 4ddcfa67f2d8e8258cb1903e233967f50736888a Mon Sep 17 00:00:00 2001 From: miqwit Date: Tue, 19 Mar 2019 16:04:11 +0100 Subject: [PATCH 8/9] Minor comments and changes --- tests/test_elasticsearch_driver_speed.py | 25 ++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/test_elasticsearch_driver_speed.py b/tests/test_elasticsearch_driver_speed.py index 9d3788c..2135fad 100644 --- a/tests/test_elasticsearch_driver_speed.py +++ b/tests/test_elasticsearch_driver_speed.py @@ -166,17 +166,22 @@ def noise_generator(noise_type, image): print("Created index {} for fields documents".format(INDEX_NAME_FIELDS)) print("Created index {} for flat documents".format(INDEX_NAME_FLAT)) print("Created index {} for flatint documents".format(INDEX_NAME_FLATINT)) + +# The relatively small size of returned document (100, which is default) +# can lead to hard to inconsistent results (typically +# documents only found in flat but not in fields) because the correct document +# might not be in the top 100 for a fields search, but in the top 100 for a +# flat or flatint search. ses_fields = SignatureES_fields(es=es, index=INDEX_NAME_FIELDS, - doc_type=DOC_TYPE_FIELDS) + doc_type=DOC_TYPE_FIELDS, size=100) ses_flat = SignatureES_flat(es=es, index=INDEX_NAME_FLAT, - doc_type=DOC_TYPE_FLAT) + doc_type=DOC_TYPE_FLAT, size=100) ses_flatint = SignatureES_flatint(es=es, index=INDEX_NAME_FLATINT, - doc_type=DOC_TYPE_FLATINT) + doc_type=DOC_TYPE_FLATINT, size=100) # Download dataset print("Download dataset if does not exist") -dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/" \ - "101_ObjectCategories.tar.gz" +dataset_url = "http://www.vision.caltech.edu/Image_Datasets/Caltech101/101_ObjectCategories.tar.gz" dir_path = os.path.dirname(os.path.realpath(__file__)) local_file = os.path.join(dir_path, "101_ObjectCategories.tar.gz") local_directory = os.path.join(dir_path, "101_ObjectCategories") @@ -263,9 +268,6 @@ def noise_generator(noise_type, image): res_flatint = ses_flatint.search_image(image_path_to_search) total_time_search_flatint += (time.time() - t_search_flatint) - # Delete blurred image - os.remove(altered_path) - # FLAT analysis # Precision of first result same_first_flat_bool = False @@ -310,6 +312,9 @@ def noise_generator(noise_type, image): elif image_path not in pathes_flatint and pathes_fields[0] == image_path: not_same_first_flatint[1] += 1 + # Delete blurred image + os.remove(altered_path) + stats_msm[str(msm)] = { "same_first_flat": same_first_flat, "not_same_first_flat": not_same_first_flat, @@ -317,8 +322,6 @@ def noise_generator(noise_type, image): "not_same_first_flatint": not_same_first_flatint } - print(stats_msm) - print("") print("minimum_should_match = {}".format(msm)) @@ -337,6 +340,8 @@ def noise_generator(noise_type, image): print(". {} found in flatint but not in fields".format(not_same_first_flatint[2])) +print(stats_msm) + print("") num_searches = num_random * max_msm print("{} searches total".format(num_searches)) From a0028d69e1b5e72e3aec929f2c67155ce4d05e61 Mon Sep 17 00:00:00 2001 From: miqwit Date: Tue, 19 Mar 2019 16:09:41 +0100 Subject: [PATCH 9/9] Added plots examples --- tests/plots/plot_disk_usage.png | Bin 0 -> 17024 bytes tests/plots/plot_qualitative_flat.png | Bin 0 -> 26033 bytes tests/plots/plot_qualitative_flatint.png | Bin 0 -> 26785 bytes tests/plots/plot_search_time.png | Bin 0 -> 18446 bytes tests/plots/plot_time_ingestion.png | Bin 0 -> 19182 bytes 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/plots/plot_disk_usage.png create mode 100644 tests/plots/plot_qualitative_flat.png create mode 100644 tests/plots/plot_qualitative_flatint.png create mode 100644 tests/plots/plot_search_time.png create mode 100644 tests/plots/plot_time_ingestion.png diff --git a/tests/plots/plot_disk_usage.png b/tests/plots/plot_disk_usage.png new file mode 100644 index 0000000000000000000000000000000000000000..507d4f15ce93226021adf790a58fb0379d8ab7c6 GIT binary patch literal 17024 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̤xsTUT{LCfyjHkWeg;q10iEt42ZNIFggi| zLryL(S;(?Y`yPpW{}7iLYuQ^CB;t^23s{NL{{#G}2@-Asiplx!A`B{RS!PWMSJFr> z$18Hd3j|(zhy8qTjJk`OCJMo$yf=48U>NKVyD^LYi7a-$^V+1 z(R@ z{VfNWt2EJ-NI$L86 za3$JuJQPH~q)^^}xdq()6@n1KU!lAYpsAy0HNZDkB9O4O)GEyD`9Iv`x6f^}n134v zfc(WXl9B@m=+(no3e}_;m&YL(0^Ql!CBt3kV@b>sBB6eOhd5l}n7j0>Nl4A&piy~C z?~6b*$XRngKiHHgn}FZ34h&x2T17@Ytp|78K( z)pM(;-o)T6Q9Vw`plvmFKJ)jFLcE)(=CTPJsAJVK;&3aN9%M-XBTI6uHtTrbMFa<# z7W6Y4G_ttjdGzWs;$DZKg>mrk_*yA%K-wB)a7vR?ruWF$> zgxj2UR!C%`f_JbjD;fLXHS$b9fQpsDG7&cI$EF%X0~fOY`C@5?bV_(BSQM9$Unld1 zMH7e20J*-F^DjvPU2r>A*BcrcU^mT8^c%xDLgFFKZG{6=tFyC{9FYRinM59)gM;JT z^71m1kaY|iaTFG`P>!SV4s`lm&OcAVl^Q@X4MM_3z@+&FzzrsS2P(?Kj6vGJ+y?A1 z`;8SgK|DHl*VEGSlH(FUXL?B3RdKB^2?QVBDJ}R6w17z`DW9efX?;E{$K%Ks_1#*x z>nO4`RYzLiTMDcA!R+?Vuf}**Ad_$tK^;QVF3ya0$N&^hcxA@G4GP8y5k{=fEjT@~ z%l<3X1Ogf@O}MQr@q)D_j8)sciBR_>^#IyZi2E2af&c!IzU%==-x!L%;bW%FcQ@`t zR=|3+T~L}F`T>B|jJf%sZsrl>XT%&{LgCo%(Drf`K2;J}?!>bysu#1!B69e_&NTe_ z^&JfqQG}tfcu3pd8d|?_?Dl0-9r&^7Z zW09&MbWJVYARX+&ztIVqfw=p7kJMAaC=^jBv2 z01yt|KM*E=_38`oj3_8W@&NyDlB)}r5L`C`$|eE7!RC}#P?g46W{_N$2VU>NhmZ_8 zlp?yJ6=!ujEFhz%q)>wN;e=s_l3-|#j|%`QD$hk?gE=}nCb^IpA+8<|DPOR0YNSmC z;!Mhl>p⪙UH+Nl{N|JJ;dHcQGmo$Oavep8T4(Nra~m9de~UB( zY&+Zy6yM2j^b|KaTn@iVF;4OKb^S;p{gK%GIh-3Sm>%tj$03zCD9~OH9+X>EklhuCdI4u!#$nZo%DR2hhQ56i47h-T$`cGo4`3psIdAl&(~ zPKoztyDrC>(L_uyIZ)UNtAQM^v$LDgC@sKolLqJnF35#DXt5CeeWbLvdU>weZ4e3p z@qpL{(yGN2?CrBrIQ`5XmM{`Rgse{PP*NUqV}?QPX~;2}5!LMOZTG0$_s-$~gAC^D z*ZBDFlVGHfwB$QwNw7-m!Hp2i>_FU~{16a=4~LQF#&s1i9BDXM4`YVr6mEG zrGlKb;weeTxt@!`0+oj~bL$s>iTAcFxi~v}gTGo>S@GgnmmbKUU|$=a%=ZQzt5}Gc zCdE`7*-3deQOC&|q=hrCf21L7b*F%dJcO;`Eb9rsz`z(VQ$qj~B9XBTU>}jZ>+*C~ zVM{XCJS4r93_G_yGE>)>iwXy=h3;v_Y=5qGE-l4o(}4f>&2%>R0^e}H6R$rs_Ay*bSLVzqoL|U3vQxJnB5H^*Gz)sWl>`jdco9@5lI0rjE zG^=eS+hsujsr#?L?B$h|qL_wk^S(_v;?p1|<%M?pZU z8^gt1E{EbT9xpjg*o`umm6!V>|G^X-8oKe$J9j9$-B6rjVq@Q-_Z44PR#v7^py^&} zk~*`FmiFY{d0i-rxV26FXVx?{G&Fa1-u*JLp^ch1xU$A5WA^8_)f?7IH2eDdGw_6e zzVROMgfLO3ZA|J~S|#=MTPyS?MmLm!Zb`=*FziBb~F*HP5 z3G7rnV2$I+^k~PBH=B8zkcQ_G6pR&0t~HQXSAS4jd#QP3I>xx|;GR8HUN8T8nC~#L z_WASY=Pq1Wh|oAQIio{$?o8KB2ZtTo9#F4dz1n_ho?lO2|C?owITF2!ii&4MqBAlW z93j-K^lzNA?^lWje)xwFviQAvr^MH!iIi0e8PG%9bUwz08cWMTQ( z-p-6O9zfExffpqwx`OcE5qu>7FTm?J+0gXRLDm7Fx_`WWt7}Z9(lA5q+b{b!85vPb zUphN|aGURqGDaQ5FI>3rb73hkIQUL^@9i%G?NiR}rpIyUY`Q|?%)0uqd*&$B&oekvQmtgHDnh62+DUXQ21)P0i0g(v+;d{Z%ao zN}OL<7?*OqQb|dPVpB?L>Rx_+{+CTd@TMgbl;htz&2aIp+)TTBZ`kPBy&eFQ>qi;S z#5bHjb?VmS^mLrSEfW)ybGo`wK!4IeFE?)7IH#!@%Hg%yV&l83^1v5?7x!P$(D1fj zx_XoYXQ{>!C8}wz+W+`mebC8eQ9h=JH}v%{NJ!jSv+9<7$V(1f`eXO6;kk?Veq8n1 zG8ZoHwP)|kaNCJKnGJMw*9{B|w3EMdbUcKoeho=T?PQ5FZu=|8v?!Z4E%ihBRMph{ zh>W#_+qx`P7`L?z4H;&0_kUEEu$%;Sd&yY5|Ifd_u6lg$-aU?zHxxGf+l%Kg leFN6|@0b0be>}lZ^OyGbGdsMzfW9cwlCo!#PG7zKKLFHVthfLG literal 0 HcmV?d00001 diff --git a/tests/plots/plot_qualitative_flat.png b/tests/plots/plot_qualitative_flat.png new file mode 100644 index 0000000000000000000000000000000000000000..c61e499bf7f6c7ffe83e6c916ebb884cb00be716 GIT binary patch literal 26033 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 F{|WBi?e+iw literal 0 HcmV?d00001 diff --git a/tests/plots/plot_qualitative_flatint.png b/tests/plots/plot_qualitative_flatint.png new file mode 100644 index 0000000000000000000000000000000000000000..0524c7cd243ca2326f45ec2b7c29b722f0063ea4 GIT binary patch literal 26785 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%0A!K@^fE-l6{Pjkr*i`X13JyVw=uD>OraA8jt00k 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 z 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 SQ;rWsF<)!7CUuqDq5lArNj5A1 literal 0 HcmV?d00001 diff --git a/tests/plots/plot_search_time.png b/tests/plots/plot_search_time.png new file mode 100644 index 0000000000000000000000000000000000000000..2a493c4786b467f0bf65aa9bc2a15e0e04777aa4 GIT binary patch literal 18446 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 z%0A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 UEIb_ogcJ%bsVI>z{^Zqv12b~aV*mgE literal 0 HcmV?d00001