From 9e7713e83a99d8ca50ffff49def7085bb8f4e09c Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 14 Dec 2018 02:31:56 +0100 Subject: faiss cli lib --- megapixels/commands/faiss/build.py | 46 ++++++++++++++++++++++++++++++++++++++ megapixels/commands/faiss/sync.py | 17 ++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 megapixels/commands/faiss/build.py create mode 100644 megapixels/commands/faiss/sync.py (limited to 'megapixels/commands') diff --git a/megapixels/commands/faiss/build.py b/megapixels/commands/faiss/build.py new file mode 100644 index 00000000..e95619af --- /dev/null +++ b/megapixels/commands/faiss/build.py @@ -0,0 +1,46 @@ +""" +Index all of the FAISS datasets +""" + +import os +import click + +from app.utils.file_utils import load_recipe, load_csv +from app.settings import app_cfg as cfg + +@click.command() +@click.pass_context +def cli(ctx): + """train the FAISS index""" + + recipe = { + "dim": 128, + "factory_type": "Flat" + } + + datasets = [] + for fn in glob.iglob(os.path.join(cfg.DIR_FAISS_DATASETS, "*")): + name = os.path.basename(fn) + recipe_fn = os.path.join(cfg.DIR_FAISS_RECIPES, name + ".json") + if os.path.exists(recipe_fn): + train(name, load_recipe(recipe_fn)) + else: + train(name, recipe) + +def train(name, recipe): + vec_fn = os.path.join(cfg.DIR_FAISS_DATASETS, name, "vecs.csv") + index_fn = os.path.join(cfg.DIR_FAISS_INDEXES, name + ".index") + + index = faiss.index_factory(recipe.dimension, recipe.factory) + + keys, rows = file_utils.load_csv_safe(vec_fn) + feats = np.array([ float(x[1].split(",")) for x in rows]).astype('float32') + n, d = feats.shape + + train_start = time.time() + index.train(feats) + train_end = time.time() + train_time = train_end - train_start + print("{} train time: {:.1f}s".format(name, train_time)) + + faiss.write_index(index, index_fn) diff --git a/megapixels/commands/faiss/sync.py b/megapixels/commands/faiss/sync.py new file mode 100644 index 00000000..ae13b948 --- /dev/null +++ b/megapixels/commands/faiss/sync.py @@ -0,0 +1,17 @@ +""" +Sync the FAISS metadata +""" + +import subprocess +import click + +from app.settings import app_cfg as cfg + +@click.command() +@click.pass_context +def cli(ctx): + sts = call([ + "s3cmd", "sync", + "s3://megapixels/v1/metadata/", + cfg.DIR_FAISS_METADATA, + ]) -- cgit v1.2.3-70-g09d2 From e1fba831b7c22f9840c5e92227f688079b9a206e Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 14 Dec 2018 15:50:07 +0100 Subject: mysql import script --- megapixels/app/models/sql_factory.py | 91 +++++++++++++++++++++++++++++++++++ megapixels/app/settings/app_cfg.py | 11 ++++- megapixels/commands/faiss/build.py | 60 ++++++++++++++--------- megapixels/commands/faiss/build_db.py | 38 +++++++++++++++ megapixels/commands/faiss/sync.py | 5 +- 5 files changed, 180 insertions(+), 25 deletions(-) create mode 100644 megapixels/app/models/sql_factory.py create mode 100644 megapixels/commands/faiss/build_db.py (limited to 'megapixels/commands') diff --git a/megapixels/app/models/sql_factory.py b/megapixels/app/models/sql_factory.py new file mode 100644 index 00000000..4adc6f48 --- /dev/null +++ b/megapixels/app/models/sql_factory.py @@ -0,0 +1,91 @@ +import os + +from sqlalchemy import create_engine, Table, Column, String, Integer, DateTime, Float +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base, declared_attr +from sqlalchemy.ext.declarative import AbstractConcreteBase, ConcreteBase + +connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( + os.getenv("DB_USER"), + os.getenv("DB_PASS"), + os.getenv("DB_HOST"), + os.getenv("DB_NAME") +) + +engine = create_engine(connection_url) +Session = sessionmaker(bind=engine) +session = Session() +Base = declarative_base(engine) + +class SqlDataset: + def __init__(self, name): + self.name = name + self.tables = {} + + def get_table(self, type): + if type in self.tables: + return self.tables[type] + elif type == 'uuid': + self.tables[type] = self.uuid_table() + elif type == 'roi': + self.tables[type] = self.roi_table() + elif type == 'identity_meta': + self.tables[type] = self.identity_table() + elif type == 'pose': + self.tables[type] = self.pose_table() + else: + return None + return self.tables[type] + + # ==> uuids.csv <== + # index,uuid + # 0,f03fd921-2d56-4e83-8115-f658d6a72287 + def uuid_table(self): + class UUID(Base): + __tablename__ = self.name + "_uuid" + id = Column(Integer, primary_key=True) + uuid = Column(String(36), nullable=False) + return UUID + + # ==> roi.csv <== + # index,h,image_height,image_index,image_width,w,x,y + # 0,0.33000000000000007,250,0,250,0.32999999999999996,0.33666666666666667,0.35 + def roi_table(self): + class ROI(Base): + __tablename__ = self.name + "_roi" + id = Column(Integer, primary_key=True) + h = Column(Float, nullable=False) + image_height = Column(Integer, nullable=False) + image_index = Column(Integer, nullable=False) + image_width = Column(Integer, nullable=False) + w = Column(Float, nullable=False) + x = Column(Float, nullable=False) + y = Column(Float, nullable=False) + return ROI + + # ==> identity.csv <== + # index,fullname,description,gender,images,image_index + # 0,A. J. Cook,Canadian actress,f,1,0 + def identity_table(self): + class Identity(Base): + __tablename__ = self.name + "_identity" + id = Column(Integer, primary_key=True) + fullname = Column(String(36), nullable=False) + description = Column(String(36), nullable=False) + gender = Column(String(1), nullable=False) + images = Column(Integer, nullable=False) + image_id = Column(Integer, nullable=False) + return Identity + + # ==> pose.csv <== + # index,image_index,pitch,roll,yaw + # 0,0,11.16264458441435,10.415885631337728,22.99719032415318 + def pose_table(self): + class Pose(Base): + __tablename__ = self.name + "_pose" + id = Column(Integer, primary_key=True) + image_id = Column(Integer, primary_key=True) + pitch = Column(Float, nullable=False) + roll = Column(Float, nullable=False) + yaw = Column(Float, nullable=False) + return Pose diff --git a/megapixels/app/settings/app_cfg.py b/megapixels/app/settings/app_cfg.py index b2876af7..51392bcc 100644 --- a/megapixels/app/settings/app_cfg.py +++ b/megapixels/app/settings/app_cfg.py @@ -2,6 +2,7 @@ import os from os.path import join import logging import collections +from dotenv import load_dotenv import cv2 as cv @@ -42,9 +43,9 @@ DIR_MODELS_DLIB_68PT = join(DIR_MODELS_DLIB, 'shape_predictor_68_face_landmarks. DIR_MODELS_DLIB_FACEREC_RESNET = join(DIR_MODELS_DLIB, 'dlib_face_recognition_resnet_model_v1.dat') DIR_FAISS = join(DIR_APP, 'faiss') -DIR_FAISS_DATASETS = join(DIR_FAISS, 'datasets') DIR_FAISS_INDEXES = join(DIR_FAISS, 'indexes') DIR_FAISS_METADATA = join(DIR_FAISS, 'metadata') +DIR_FAISS_RECIPES = join(DIR_FAISS, 'recipes') # Test images DIR_TEST_IMAGES = join(DIR_APP, 'test', 'images') @@ -62,6 +63,7 @@ FP_FONT = join(DIR_ASSETS, 'font') DIR_COMMANDS_CV = 'commands/cv' DIR_COMMANDS_ADMIN = 'commands/admin' DIR_COMMANDS_DATASETS = 'commands/datasets' +DIR_COMMANDS_FAISS = 'commands/faiss' DIR_COMMANDS_MISC = 'commands/misc' # ----------------------------------------------------------------------------- @@ -109,3 +111,10 @@ LOGFILE_FORMAT = "%(log_color)s%(levelname)-8s%(reset)s %(cyan)s%(filename)s:%(l # ----------------------------------------------------------------------------- S3_MEDIA_ROOT = 's3://megapixels/v1/media/' S3_METADATA_ROOT = 's3://megapixels/v1/metadata/' + +# ----------------------------------------------------------------------------- +# .env config for keys +# ----------------------------------------------------------------------------- + +DIR_DOTENV = join(DIR_APP, '.env') +load_dotenv(dotenv_path=DIR_DOTENV) diff --git a/megapixels/commands/faiss/build.py b/megapixels/commands/faiss/build.py index e95619af..e525542a 100644 --- a/megapixels/commands/faiss/build.py +++ b/megapixels/commands/faiss/build.py @@ -3,44 +3,60 @@ Index all of the FAISS datasets """ import os +import glob import click +import faiss +import time +import numpy as np -from app.utils.file_utils import load_recipe, load_csv +from app.utils.file_utils import load_recipe, load_csv_safe from app.settings import app_cfg as cfg +engine = create_engine('sqlite:///:memory:') + +class DefaultRecipe: + def __init__(self): + self.dim = 128 + self.factory_type = 'Flat' + @click.command() @click.pass_context def cli(ctx): - """train the FAISS index""" - - recipe = { - "dim": 128, - "factory_type": "Flat" - } - + """build the FAISS index. + - looks for all datasets in faiss/metadata/ + - uses the recipe above by default + - however you can override this by adding a new recipe in faiss/recipes/{name}.json + """ datasets = [] - for fn in glob.iglob(os.path.join(cfg.DIR_FAISS_DATASETS, "*")): + for fn in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): name = os.path.basename(fn) recipe_fn = os.path.join(cfg.DIR_FAISS_RECIPES, name + ".json") if os.path.exists(recipe_fn): - train(name, load_recipe(recipe_fn)) + build_faiss(name, load_recipe(recipe_fn)) else: - train(name, recipe) - -def train(name, recipe): - vec_fn = os.path.join(cfg.DIR_FAISS_DATASETS, name, "vecs.csv") + build_faiss(name, DefaultRecipe()) + # index identities + # certain CSV files should be loaded into mysql + # User.__table__.drop() + SQLemployees.create(engine) + +def build_faiss(name, recipe): + vec_fn = os.path.join(cfg.DIR_FAISS_METADATA, name, "vecs.csv") index_fn = os.path.join(cfg.DIR_FAISS_INDEXES, name + ".index") - index = faiss.index_factory(recipe.dimension, recipe.factory) + index = faiss.index_factory(recipe.dim, recipe.factory_type) - keys, rows = file_utils.load_csv_safe(vec_fn) - feats = np.array([ float(x[1].split(",")) for x in rows]).astype('float32') + keys, rows = load_csv_safe(vec_fn) + feats = np.array([ list(map(float, row[3].split(","))) for row in rows ]).astype('float32') n, d = feats.shape - train_start = time.time() - index.train(feats) - train_end = time.time() - train_time = train_end - train_start - print("{} train time: {:.1f}s".format(name, train_time)) + print("{}: training {} x {} dim vectors".format(name, n, d)) + print(recipe.factory_type) + + add_start = time.time() + index.add(feats) + add_end = time.time() + add_time = add_end - add_start + print("{}: add time: {:.1f}s".format(name, add_time)) faiss.write_index(index, index_fn) diff --git a/megapixels/commands/faiss/build_db.py b/megapixels/commands/faiss/build_db.py new file mode 100644 index 00000000..c90d178b --- /dev/null +++ b/megapixels/commands/faiss/build_db.py @@ -0,0 +1,38 @@ +""" +Load all the CSV files into MySQL +""" + +import os +import glob +import click +import time +import pandas as pd + +from app.models.sql_factory import engine, SqlDataset +from app.utils.file_utils import load_recipe, load_csv_safe +from app.settings import app_cfg as cfg + +@click.command() +@click.pass_context +def cli(ctx): + """import the various CSVs into MySQL + """ + datasets = [] + for path in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): + build_dataset(path) + +def build_dataset(path): + name = os.path.basename(path) + dataset = SqlDataset(name) + + for fn in glob.iglob(os.path.join(path, "*.csv")): + key = os.path.basename(fn).replace(".csv", "") + table = dataset.get_table(key) + if table is None: + continue + df = pd.read_csv(fn) + + # fix columns that are named "index", a sql reserved word + df.columns = table.__table__.columns.keys() + + df.to_sql(name=table.__tablename__, con=engine, if_exists='replace', index=False) diff --git a/megapixels/commands/faiss/sync.py b/megapixels/commands/faiss/sync.py index ae13b948..b01211b4 100644 --- a/megapixels/commands/faiss/sync.py +++ b/megapixels/commands/faiss/sync.py @@ -10,8 +10,9 @@ from app.settings import app_cfg as cfg @click.command() @click.pass_context def cli(ctx): - sts = call([ + """synchronize metadata files from s3""" + sts = subprocess.call([ "s3cmd", "sync", "s3://megapixels/v1/metadata/", - cfg.DIR_FAISS_METADATA, + cfg.DIR_FAISS_METADATA + '/', ]) -- cgit v1.2.3-70-g09d2 From b39b1d51db2d485e9c60fb4d3f5445474cef8700 Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 14 Dec 2018 16:39:47 +0100 Subject: mysql import functions --- megapixels/app/models/sql_factory.py | 30 +++++++++------ megapixels/commands/faiss/build.py | 62 ------------------------------ megapixels/commands/faiss/build_faiss.py | 58 ++++++++++++++++++++++++++++ megapixels/commands/faiss/sync.py | 18 --------- megapixels/commands/faiss/sync_metadata.py | 18 +++++++++ 5 files changed, 95 insertions(+), 91 deletions(-) delete mode 100644 megapixels/commands/faiss/build.py create mode 100644 megapixels/commands/faiss/build_faiss.py delete mode 100644 megapixels/commands/faiss/sync.py create mode 100644 megapixels/commands/faiss/sync_metadata.py (limited to 'megapixels/commands') diff --git a/megapixels/app/models/sql_factory.py b/megapixels/app/models/sql_factory.py index 4adc6f48..ecca0c7f 100644 --- a/megapixels/app/models/sql_factory.py +++ b/megapixels/app/models/sql_factory.py @@ -2,8 +2,7 @@ import os from sqlalchemy import create_engine, Table, Column, String, Integer, DateTime, Float from sqlalchemy.orm import sessionmaker -from sqlalchemy.ext.declarative import declarative_base, declared_attr -from sqlalchemy.ext.declarative import AbstractConcreteBase, ConcreteBase +from sqlalchemy.ext.declarative import declarative_base connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( os.getenv("DB_USER"), @@ -12,15 +11,24 @@ connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( os.getenv("DB_NAME") ) -engine = create_engine(connection_url) -Session = sessionmaker(bind=engine) -session = Session() -Base = declarative_base(engine) +# Session = sessionmaker(bind=engine) +# session = Session() + class SqlDataset: - def __init__(self, name): + """ + Bridge between the facial information CSVs connected to the datasets, and MySQL + - each dataset should have files that can be loaded into these database models + - names will be fixed to work in SQL (index -> id) + - we can then have more generic models for fetching this info after doing a FAISS query + """ + def __init__(self, name, base_model=None): self.name = name self.tables = {} + if base_model is None: + engine = create_engine(connection_url) + base_model = declarative_base(engine) + self.base_model = base_model def get_table(self, type): if type in self.tables: @@ -41,7 +49,7 @@ class SqlDataset: # index,uuid # 0,f03fd921-2d56-4e83-8115-f658d6a72287 def uuid_table(self): - class UUID(Base): + class UUID(self.base_model): __tablename__ = self.name + "_uuid" id = Column(Integer, primary_key=True) uuid = Column(String(36), nullable=False) @@ -51,7 +59,7 @@ class SqlDataset: # index,h,image_height,image_index,image_width,w,x,y # 0,0.33000000000000007,250,0,250,0.32999999999999996,0.33666666666666667,0.35 def roi_table(self): - class ROI(Base): + class ROI(self.base_model): __tablename__ = self.name + "_roi" id = Column(Integer, primary_key=True) h = Column(Float, nullable=False) @@ -67,7 +75,7 @@ class SqlDataset: # index,fullname,description,gender,images,image_index # 0,A. J. Cook,Canadian actress,f,1,0 def identity_table(self): - class Identity(Base): + class Identity(self.base_model): __tablename__ = self.name + "_identity" id = Column(Integer, primary_key=True) fullname = Column(String(36), nullable=False) @@ -81,7 +89,7 @@ class SqlDataset: # index,image_index,pitch,roll,yaw # 0,0,11.16264458441435,10.415885631337728,22.99719032415318 def pose_table(self): - class Pose(Base): + class Pose(self.base_model): __tablename__ = self.name + "_pose" id = Column(Integer, primary_key=True) image_id = Column(Integer, primary_key=True) diff --git a/megapixels/commands/faiss/build.py b/megapixels/commands/faiss/build.py deleted file mode 100644 index e525542a..00000000 --- a/megapixels/commands/faiss/build.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Index all of the FAISS datasets -""" - -import os -import glob -import click -import faiss -import time -import numpy as np - -from app.utils.file_utils import load_recipe, load_csv_safe -from app.settings import app_cfg as cfg - -engine = create_engine('sqlite:///:memory:') - -class DefaultRecipe: - def __init__(self): - self.dim = 128 - self.factory_type = 'Flat' - -@click.command() -@click.pass_context -def cli(ctx): - """build the FAISS index. - - looks for all datasets in faiss/metadata/ - - uses the recipe above by default - - however you can override this by adding a new recipe in faiss/recipes/{name}.json - """ - datasets = [] - for fn in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): - name = os.path.basename(fn) - recipe_fn = os.path.join(cfg.DIR_FAISS_RECIPES, name + ".json") - if os.path.exists(recipe_fn): - build_faiss(name, load_recipe(recipe_fn)) - else: - build_faiss(name, DefaultRecipe()) - # index identities - # certain CSV files should be loaded into mysql - # User.__table__.drop() - SQLemployees.create(engine) - -def build_faiss(name, recipe): - vec_fn = os.path.join(cfg.DIR_FAISS_METADATA, name, "vecs.csv") - index_fn = os.path.join(cfg.DIR_FAISS_INDEXES, name + ".index") - - index = faiss.index_factory(recipe.dim, recipe.factory_type) - - keys, rows = load_csv_safe(vec_fn) - feats = np.array([ list(map(float, row[3].split(","))) for row in rows ]).astype('float32') - n, d = feats.shape - - print("{}: training {} x {} dim vectors".format(name, n, d)) - print(recipe.factory_type) - - add_start = time.time() - index.add(feats) - add_end = time.time() - add_time = add_end - add_start - print("{}: add time: {:.1f}s".format(name, add_time)) - - faiss.write_index(index, index_fn) diff --git a/megapixels/commands/faiss/build_faiss.py b/megapixels/commands/faiss/build_faiss.py new file mode 100644 index 00000000..96d3f99e --- /dev/null +++ b/megapixels/commands/faiss/build_faiss.py @@ -0,0 +1,58 @@ +""" +Index all of the FAISS datasets +""" + +import os +import glob +import click +import faiss +import time +import numpy as np + +from app.utils.file_utils import load_recipe, load_csv_safe +from app.settings import app_cfg as cfg + +engine = create_engine('sqlite:///:memory:') + +class DefaultRecipe: + def __init__(self): + self.dim = 128 + self.factory_type = 'Flat' + +@click.command() +@click.pass_context +def cli(ctx): + """build the FAISS index. + - looks for all datasets in faiss/metadata/ + - uses the recipe above by default + - however you can override this by adding a new recipe in faiss/recipes/{name}.json + """ + datasets = [] + for fn in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): + name = os.path.basename(fn) + recipe_fn = os.path.join(cfg.DIR_FAISS_RECIPES, name + ".json") + if os.path.exists(recipe_fn): + build_faiss(name, load_recipe(recipe_fn)) + else: + build_faiss(name, DefaultRecipe()) + +def build_faiss(name, recipe): + vec_fn = os.path.join(cfg.DIR_FAISS_METADATA, name, "vecs.csv") + index_fn = os.path.join(cfg.DIR_FAISS_INDEXES, name + ".index") + + index = faiss.index_factory(recipe.dim, recipe.factory_type) + + keys, rows = load_csv_safe(vec_fn) + feats = np.array([ list(map(float, row[3].split(","))) for row in rows ]).astype('float32') + n, d = feats.shape + + print("{}: training {} x {} dim vectors".format(name, n, d)) + print(recipe.factory_type) + + add_start = time.time() + index.add(feats) + add_end = time.time() + add_time = add_end - add_start + print("{}: add time: {:.1f}s".format(name, add_time)) + + faiss.write_index(index, index_fn) diff --git a/megapixels/commands/faiss/sync.py b/megapixels/commands/faiss/sync.py deleted file mode 100644 index b01211b4..00000000 --- a/megapixels/commands/faiss/sync.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Sync the FAISS metadata -""" - -import subprocess -import click - -from app.settings import app_cfg as cfg - -@click.command() -@click.pass_context -def cli(ctx): - """synchronize metadata files from s3""" - sts = subprocess.call([ - "s3cmd", "sync", - "s3://megapixels/v1/metadata/", - cfg.DIR_FAISS_METADATA + '/', - ]) diff --git a/megapixels/commands/faiss/sync_metadata.py b/megapixels/commands/faiss/sync_metadata.py new file mode 100644 index 00000000..b01211b4 --- /dev/null +++ b/megapixels/commands/faiss/sync_metadata.py @@ -0,0 +1,18 @@ +""" +Sync the FAISS metadata +""" + +import subprocess +import click + +from app.settings import app_cfg as cfg + +@click.command() +@click.pass_context +def cli(ctx): + """synchronize metadata files from s3""" + sts = subprocess.call([ + "s3cmd", "sync", + "s3://megapixels/v1/metadata/", + cfg.DIR_FAISS_METADATA + '/', + ]) -- cgit v1.2.3-70-g09d2 From 36b6082dfa768cbf35d40dc2c82706dfae0b687b Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 14 Dec 2018 17:24:23 +0100 Subject: flask server click script --- faiss/__init__.py | 0 faiss/requirements.txt | 11 + faiss/run.sh | 1 + faiss/server.py | 68 +++++ faiss/static/css/app.css | 289 ++++++++++++++++++++ faiss/static/favicon.ico | Bin 0 -> 15086 bytes faiss/static/img/play.png | Bin 0 -> 1231 bytes faiss/static/index.html | 83 ++++++ faiss/static/js/app.js | 491 ++++++++++++++++++++++++++++++++++ faiss/static/js/dataUriToBlob.js | 58 ++++ faiss/static/js/metadata-app.js | 50 ++++ faiss/static/js/store2.min.js | 5 + faiss/static/metadata.html | 11 + faiss/static/search.html | 1 + faiss/util.py | 29 ++ faiss/wsgi.py | 5 + megapixels/app/models/sql_factory.py | 1 - megapixels/app/server/api/image.py | 40 +++ megapixels/app/server/create.py | 27 ++ megapixels/app/server/static | 1 + megapixels/cli_flask.py | 19 ++ megapixels/commands/faiss/build_db.py | 21 +- 22 files changed, 1203 insertions(+), 8 deletions(-) create mode 100644 faiss/__init__.py create mode 100644 faiss/requirements.txt create mode 100644 faiss/run.sh create mode 100644 faiss/server.py create mode 100644 faiss/static/css/app.css create mode 100644 faiss/static/favicon.ico create mode 100644 faiss/static/img/play.png create mode 100644 faiss/static/index.html create mode 100644 faiss/static/js/app.js create mode 100644 faiss/static/js/dataUriToBlob.js create mode 100644 faiss/static/js/metadata-app.js create mode 100644 faiss/static/js/store2.min.js create mode 100644 faiss/static/metadata.html create mode 100644 faiss/static/search.html create mode 100644 faiss/util.py create mode 100644 faiss/wsgi.py create mode 100644 megapixels/app/server/api/image.py create mode 100644 megapixels/app/server/create.py create mode 120000 megapixels/app/server/static create mode 100644 megapixels/cli_flask.py (limited to 'megapixels/commands') diff --git a/faiss/__init__.py b/faiss/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/faiss/requirements.txt b/faiss/requirements.txt new file mode 100644 index 00000000..1d60aabc --- /dev/null +++ b/faiss/requirements.txt @@ -0,0 +1,11 @@ +Pillow +h5py +tensorflow +Keras +Flask +opencv-python +imagehash +scikit-image +scikit-learn +imutils + diff --git a/faiss/run.sh b/faiss/run.sh new file mode 100644 index 00000000..8f9e77e2 --- /dev/null +++ b/faiss/run.sh @@ -0,0 +1 @@ +uwsgi --http 127.0.0.1:5000 --file wsgi.py --callable app --processes 1 diff --git a/faiss/server.py b/faiss/server.py new file mode 100644 index 00000000..a8c660fa --- /dev/null +++ b/faiss/server.py @@ -0,0 +1,68 @@ +#!python + +import os +import sys +import json +import time +import argparse +import cv2 as cv +import numpy as np +from datetime import datetime +from flask import Flask, request, render_template, jsonify +from PIL import Image # todo: try to remove PIL dependency +import re + +sanitize_re = re.compile('[\W]+') +valid_exts = ['.gif', '.jpg', '.jpeg', '.png'] + +from dotenv import load_dotenv +load_dotenv() + +from feature_extractor import FeatureExtractor + +DEFAULT_LIMIT = 50 + +app = Flask(__name__, static_url_path="/search/static", static_folder="static") + +# static api routes - this routing is actually handled in the JS +@app.route('/', methods=['GET']) +def index(): + return app.send_static_file('metadata.html') + +# search using an uploaded file +@app.route('/search/api/upload', methods=['POST']) +def upload(): + file = request.files['query_img'] + fn = file.filename + if fn.endswith('blob'): + fn = 'filename.jpg' + + basename, ext = os.path.splitext(fn) + print("got {}, type {}".format(basename, ext)) + if ext.lower() not in valid_exts: + return jsonify({ 'error': 'not an image' }) + + uploaded_fn = datetime.now().isoformat() + "_" + basename + uploaded_fn = sanitize_re.sub('', uploaded_fn) + uploaded_img_path = "static/uploaded/" + uploaded_fn + ext + uploaded_img_path = uploaded_img_path.lower() + print('query: {}'.format(uploaded_img_path)) + + img = Image.open(file.stream).convert('RGB') + # img.save(uploaded_img_path) + # vec = db.load_feature_vector_from_file(uploaded_img_path) + vec = fe.extract(img) + # print(vec.shape) + + results = db.search(vec, limit=limit) + query = { + 'timing': time.time() - start, + } + print(results) + return jsonify({ + 'results': results, + }) + +if __name__=="__main__": + app.run("0.0.0.0", debug=False) + diff --git a/faiss/static/css/app.css b/faiss/static/css/app.css new file mode 100644 index 00000000..a3b24736 --- /dev/null +++ b/faiss/static/css/app.css @@ -0,0 +1,289 @@ +/* css boilerplate */ + +* { box-sizing: border-box; } +html,body { + margin: 0; padding: 0; + width: 100%; height: 100%; +} +body { + font-family: Helvetica, sans-serif; + font-weight: 300; + padding-top: 60px; +} + +/* header */ + +header { + position: fixed; + top: 0; + left: 0; + height: 60px; + width: 100%; + background: #11f; + color: white; + align-items: stretch; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + z-index: 3; +} +header > section { + justify-content: flex-start; + align-items: center; + display: flex; + flex: 1 0; + font-weight: bold; +} +header > section:last-of-type { + justify-content: flex-end; +} +header a { + color: hsla(0,0%,100%,.89); + text-decoration: none; + line-height: 18px; + font-size: 14px; + font-weight: 700; + padding: .35rem .4rem; + white-space: nowrap; +} +header .logged-in { + font-size: 12px; + font-weight: normal; + padding: 0 0.5rem; +} +header .logout { + padding: 0 6px; + border-left: 1px solid #99f; +} +header .logout a { + font-size: 12px; +} +.menuToggle { + width: 30px; + height: 30px; + margin: 5px; + cursor: pointer; + line-height: 1; +} + +/* form at the top */ + +#form { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 20px; + padding: 20px; + border: 1px solid #ddd; +} +input[type=text] { + border: 1px solid #888; + padding: 4px; + font-size: 15px; +} +input[type=file] { + max-width: 200px; + border-radius: 2px; +} +input[type=file]:invalid + button { visibility: hidden!important; } +input[type=file]:valid + button { visibility: visible!important; } +#form > div { + display: flex; + flex-direction: row; + align-items: center; +} +#form > div * { + margin: 0 3px; +} + +/* saving UI form */ + +label { + display: block; + white-space: nowrap; + padding-bottom: 10px; +} +label:last-child { + padding-bottom: 0; +} +label span { + display: inline-block; + min-width: 80px; +} +.saving_ui { + display: none; +} +.saving .saving_ui { + display: flex; + border: 1px solid #ddd; + margin: 20px; + padding: 20px; + flex-direction: row; + justify-content: space-between; +} + +/* query box, shows either searched image, directory name, etc */ + +.loading .results, +.prefetch .query, .prefetch .results, +.browsing .score, .browsing .browse, +.photo .browse, +.saving .score { + display: none; +} +.browsing .query div { display: inline; margin-left: 5px; font-weight: bold; } +.saving .query div { display: inline; margin-left: 5px; font-weight: bold; } +.load_message { + opacity: 0; +} +.loading .load_message { + display: block; + margin: 20px; + font-weight: bold; +} + +.query { + margin: 20px; +} +.query > div { + margin-top: 10px; + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; +} +.query img { + cursor: crosshair; + max-width: 400px; + display: block; +} +.query > div > .box { + position: absolute; + border: 1px solid #11f; + background: rgba(17,17,255,0.1); + pointer-events: none; +} +.query canvas { + margin-left: 20px; + max-width: 200px; +} + +/* search results */ + +.results { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.results > div { + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 210px; + margin: 15px; + padding: 5px; + border: 1px solid transparent; +} +.results > div.saved { + border-radius: 2px; + background: #fafaaa; +} +.results > div img { + cursor: pointer; + max-width: 210px; + margin-bottom: 10px; +} +.results > div > div { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} +.results a:visited .btn { + color: #99d; +} +.score { + font-size: 12px; + color: #444; +} + + +/* spinner */ + +.loader { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + background: rgba(255,255,255,0.9); +} +.loader > div { + background: white; + padding: 20px; + box-shadow: 0 1px 2px #bbb; + border-radius: 2px; +} +.spinner { + position: relative; + width: 32px; + height: 32px; + color: #11f; + margin: 0 auto; +} +.spinner:after { + position: absolute; + margin: auto; + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + content: " "; + display: inline-block; + border-radius: 50%; + border-style: solid; + border-width: 0.15em; + -webkit-background-clip: padding-box; + border-color: currentColor currentColor currentColor transparent; + box-sizing: border-box; + -webkit-animation: ld-cycle 0.7s infinite linear; + animation: ld-cycle 0.7s infinite linear; +} +@-webkit-keyframes ld-cycle { + 0%, 50%, 100% { + animation-timing-function: cubic-bezier(0.5, 0.5, 0.5, 0.5); + } + 0% { + -webkit-transform: rotate(0); + transform: rotate(0); + } + 50% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes ld-cycle { + 0%, 50%, 100% { + animation-timing-function: cubic-bezier(0.5, 0.5, 0.5, 0.5); + } + 0% { + -webkit-transform: rotate(0); + transform: rotate(0); + } + 50% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/faiss/static/favicon.ico b/faiss/static/favicon.ico new file mode 100644 index 00000000..d97f2f59 Binary files /dev/null and b/faiss/static/favicon.ico differ diff --git a/faiss/static/img/play.png b/faiss/static/img/play.png new file mode 100644 index 00000000..40f76045 Binary files /dev/null and b/faiss/static/img/play.png differ diff --git a/faiss/static/index.html b/faiss/static/index.html new file mode 100644 index 00000000..cf59c628 --- /dev/null +++ b/faiss/static/index.html @@ -0,0 +1,83 @@ + + + + + + + +VFrame Image Import + + + +
+ + + +
+ +
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ + + + +
+
+ +
+ +
+
+ +
+
+ + +
+ + + + + + + + diff --git a/faiss/static/js/app.js b/faiss/static/js/app.js new file mode 100644 index 00000000..77164c76 --- /dev/null +++ b/faiss/static/js/app.js @@ -0,0 +1,491 @@ +/* eslint no-use-before-define: 0, camelcase: 0, one-var-declaration-per-line: 0, one-var: 0, quotes: 0, prefer-destructuring: 0, no-alert: 0, no-console: 0, no-multi-assign: 0 */ + +function loadApp() { + const result_template = document.querySelector('#result-template').innerHTML + const results_el = document.querySelector('.results') + const query_div = document.body.querySelector('.query > div') + let bounds + let token, username + let x, y, mouse_x, mouse_y, dx, dy, box + let dragging = false + let cropping = false + let creating = false + let did_check = false + + function init() { + login() + bind() + route() + } + function bind() { + window.onpopstate = route + document.querySelector('[name=img]').addEventListener('change', upload) + on('click', '.results a', preventDefault) + on('click', '.search', search) + on('click', '.panic', panic) + on('click', '.upload_again', upload_again) + on('click', '.browse', browse) + on('click', '.results img', save) + on('click', '.view_saved', loadSaved) + on('click', '.create_new_group', createNewGroup) + on('click', '.reset', reset) + on('click', '.random', random) + on('click', '.check', check) + on('mousedown', '.query img', down) + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', up) + window.addEventListener('keydown', keydown) + } + function route() { + const path = window.location.pathname.split('/') + // remove initial slash + path.shift() + // remove dummy route + if (path[0] === 'search') path.shift() + switch (path[0]) { + case 'fetch': + search({ target: { url: window.location.search.substr(1).split('=')[1] } }) + break + case 'view': + search(path.slice(1)) + break + case 'q': + if (path.length === 3) { + search({ target: { dir: path[1], fn: path[2] } }) + } else { + browse({ target: { dir: path[1], fn: null } }) + } + break + case 'saved': + loadSaved() + break + default: + break + } + } + function keydown(e) { + switch (e.keyCode) { + case 27: // escape + panic() + break + default: + break + } + } + + // load search results + function loadResults(data) { + console.log(data) + if (!data.query.url) return + // console.log(data) + document.body.className = 'searching' + const path = getPathFromImage(data.query.url) + pushState('searching', "/search/fetch/?url=" + path.url) + if (path.dir === 'uploaded' && path.fn.match('_filename')) { + loadMessage( + "< Back | " + + "Searching subregion, " + + "found " + data.results.length + " images" + ) + } else { + loadMessage( + "Found " + data.results.length + " images" + ) + } + loadQuery(data.query.url) + if (!data.results.length) { + results_el.innerHTML = "No results" + return + } + const saved = window.store.get('saved', []) + + results_el.innerHTML = data.results.map(res => { + const { distance, file, hash, frame, url } = res + const isSaved = saved.indexOf(url) !== -1 + const { type } = getPathFromImage(url) + let className = isSaved ? 'saved' : '' + className += ' ' + type + let t = result_template + .replace('{score}', Math.floor(clamp(1 - distance, 0, 1) * 100) + "%") + .replace('{browse}', '/search/q/' + hash) + .replace('{search}', '/search/view/' + [file, hash, frame].join('/')) + .replace('{metadata}', '/metadata/' + hash) + .replace('{className}', className) + .replace('{saved_msg}', isSaved ? 'Saved' : 'Save') + .replace('{img}', url) + return t + }).join('') + } + + function loadDirectory(data) { + console.log(data) + document.body.className = 'browsing' + pushState('searching', "/search/q/" + data.path) + loadMessage("Video: " + data.path + "") + loadQuery("") + if (!data.results.length) { + results_el.innerHTML = "No frames found" + return + } + const saved = window.store.get('saved', []) + results_el.innerHTML = data.results + .map(result => [parseInt(result.frame, 10), result]) + .sort((a, b) => a[0] - b[0]) + .map(pair => { + let { file, hash, frame, url } = pair[1] + const isSaved = saved.indexOf(url) !== -1 + let className = isSaved ? 'saved' : '' + let t = result_template + .replace('{img}', url) + .replace('{browse}', '/search/q/' + hash) + .replace('{search}', '/search/view/' + [file, hash, frame].join('/')) + .replace('{metadata}', '/metadata/' + hash) + .replace('{className}', className) + .replace('{saved_msg}', isSaved ? 'Saved' : 'Save') + return t + }).join('') + } + function loadSaved() { + document.body.className = 'saving' + pushState('View saved', "/search/saved") + const saved = window.store.get('saved', []) + cropping = false + loadMessage(saved.length + " saved image" + (saved.length === 1 ? "" : "s")) + loadQuery('') + const box_el = document.querySelector('.box') + if (box_el) box_el.parentNode.removeChild(box_el) + results_el.innerHTML = saved.map(href => { + const { url, dir } = getPathFromImage({ src: href }) + let className = 'saved' + let t = result_template + .replace('{img}', href) + .replace('{browse}', '/search/q/' + dir) + .replace('{search}', '/search/fetch/?url=' + url) + .replace('{metadata}', '/metadata/' + dir) + .replace('{className}', className) + .replace('{saved_msg}', 'Saved') + return t + }).join('') + } + function loadQuery(path) { + if (cropping) return + const qd = document.querySelector('.query div') + qd.innerHTML = '' + if (path.match(/(gif|jpe?g|png)$/)) { + const img = new Image() + img.setAttribute('crossorigin', 'anonymous') + img.src = path.replace('sm', 'md') + qd.appendChild(img) + } else { + qd.innerHTML = path || "" + } + } + function loadMessage(msg) { + document.querySelector('.query .msg').innerHTML = msg + } + + // panic button + function panic() { + loadMessage('Query cleared') + loadQuery('') + results_el.innerHTML = '' + } + + // adding stuff to localstorage + function save(e) { + const { url } = getPathFromImage(e.target) + const saved = window.store.get('saved', []) + let newList = saved || [] + if (saved.indexOf(url) !== -1) { + newList = saved.filter(f => f !== url) + e.target.parentNode.classList.remove('saved') + } else { + newList.push(url) + e.target.parentNode.classList.add('saved') + } + window.store.set('saved', newList) + } + function reset() { + const shouldReset = window.confirm("This will reset the saved images. Are you sure?") + if (!shouldReset) return + window.store.set('saved', []) + loadSaved() + document.querySelector('[name=title]').value = '' + window.alert("Reset saved images") + } + + // submit the new group + function createNewGroup() { + const title = document.querySelector('[name=title]').value.trim().replace(/[^-_a-zA-Z0-9 ]/g, "") + const saved = window.store.get('saved', []) + const graphic = document.querySelector('[name=graphic]').checked + if (!title.length) return alert("Please enter a title for this group") + if (!saved.length) return alert("Please pick some images to save") + if (!did_check) { + alert('Automatically checking for duplicates. Please doublecheck your selection.') + return check() + } + if (creating) return null + creating = true + return http_post("/api/images/import/new/", { + title, + graphic, + saved + }).then(res => { + console.log(res) + window.store.set('saved', []) + window.location.href = '/groups/show/' + res.image_group.id + }).catch(res => { + alert('Error creating group. The server response is logged to the console.') + console.log(res) + creating = false + }) + } + + // api queries + function login() { + const isLocal = (window.location.hostname === '0.0.0.0') + try { + // csrftoken = "test" // getCookie('csrftoken') + const auth = JSON.parse(window.store.get('persist:root').auth) + token = auth.token + username = auth.user.username + if (!token && !isLocal) { + window.location.href = '/' + } + } catch (e) { + if (!isLocal) { + window.location.href = '/' + } + } + document.querySelector('.logged-in .capitalize').innerHTML = username || 'user' + } + + function upload(e) { + cropping = false + const files = e.dataTransfer ? e.dataTransfer.files : e.target.files + let i, f + for (i = 0, f; i < files.length; i++) { + f = files[i] + if (f && f.type.match('image.*')) break + } + if (!f) return + do_upload(f) + } + + function do_upload(f) { + const fd = new FormData() + fd.append('query_img', f) + document.body.className = 'loading' + http_post('/search/api/upload', fd).then(loadResults) + } + + function upload_again() { + const { files } = document.querySelector('input[type=file]') + if (!files.length) { + window.alert('Please upload a file.') + return + } + upload({ + dataTransfer: { files } + }) + } + + function search(e) { + if (e.length) return search_by_vector(e) + const { url } = getPath(e.target) + cropping = false + document.body.className = 'loading' + loadQuery(url) + loadMessage('Loading results...') + http_get('/search/api/fetch/?url=' + url).then(loadResults) + } + + function search_by_vector(e) { + cropping = false + document.body.className = 'loading' + loadQuery('') + loadMessage('Loading results...') + http_get('/search/api/search/' + e.join('/')).then(loadResults) + } + + function browse(e) { + document.body.className = 'loading' + cropping = false + let dir; + if (e.target.dir) { + dir = e.target.dir + } + else { + const href = e.target.parentNode.href + dir = href.split('/')[5] + console.log(href, dir) + } + loadMessage('Listing video...') + http_get('/search/api/list/' + dir).then(loadDirectory) + } + + function check() { + http_post('/api/images/import/search/', { + saved: window.store.get('saved') || [], + }).then(res => { + console.log(res) + const { good, bad } = res + did_check = true + window.store.set('saved', good) + if (!bad.length) { + return alert("No duplicates found.") + } + bad.forEach(path => { + const el = document.querySelector('img[src="' + path + '"]') + if (el) el.parentNode.classList.remove('saved') + }) + return alert("Untagged " + bad.length + " duplicate" + (bad.length === 1 ? "" : "s") + ".") + }) + } + + function random() { + http_get('/search/api/random').then(loadResults) + } + + // drawing a box + function down(e) { + e.preventDefault() + dragging = true + bounds = query_div.querySelector('img').getBoundingClientRect() + mouse_x = e.pageX + mouse_y = e.pageY + x = mouse_x - bounds.left + y = mouse_y - bounds.top + dx = dy = 0 + box = document.querySelector('.box') || document.createElement('div') + box.className = 'box' + box.style.left = x + 'px' + box.style.top = y + 'px' + box.style.width = 0 + 'px' + box.style.height = 0 + 'px' + query_div.appendChild(box) + } + function move(e) { + if (!dragging) return + e.preventDefault() + dx = clamp(e.pageX - mouse_x, 0, bounds.width - x) + dy = clamp(e.pageY - mouse_y, 0, bounds.height - y) + box.style.width = dx + 'px' + box.style.height = dy + 'px' + } + function up(e) { + if (!dragging) return + dragging = false + e.preventDefault() + const img = query_div.querySelector('img') + const canvas = query_div.querySelector('canvas') || document.createElement('canvas') + const ctx = canvas.getContext('2d') + const ratio = img.naturalWidth / bounds.width + canvas.width = dx * ratio + canvas.height = dy * ratio + if (dx < 10 || dy < 10) { + if (canvas.parentNode) canvas.parentNode.removeChild(canvas) + const box_el = document.querySelector('.box') + if (box_el) box_el.parentNode.removeChild(box_el) + return + } + query_div.appendChild(canvas) + ctx.drawImage( + img, + x * ratio, + y * ratio, + dx * ratio, + dy * ratio, + 0, 0, canvas.width, canvas.height + ) + cropping = true + const blob = window.dataUriToBlob(canvas.toDataURL('image/jpeg', 0.9)) + do_upload(blob) + } + + // utility functions + function http_get(url) { + return fetch(url).then(res => res.json()) + } + function http_post(url, data) { + let headers + if (data instanceof FormData) { + headers = { + Accept: 'application/json, application/xml, text/play, text/html, *.*', + Authorization: 'Token ' + token, + } + } else { + headers = { + Accept: 'application/json, application/xml, text/play, text/html, *.*', + 'Content-Type': 'application/json; charset=utf-8', + Authorization: 'Token ' + token, + } + data = JSON.stringify(data) + } + + // headers['X-CSRFToken'] = csrftoken + return fetch(url, { + method: 'POST', + body: data, + credentials: 'include', + headers, + }).then(res => res.json()) + } + function on(evt, sel, handler) { + document.addEventListener(evt, function (event) { + let t = event.target + while (t && t !== this) { + if (t.matches(sel)) { + handler.call(t, event) + } + t = t.parentNode + } + }) + } + function getPathFromImage(el) { + const url = el.src ? el.src : el + const partz = url.split('/') + let type, dir, fn + if (partz.length === 3) { + type = 'photo' + dir = '' + fn = '' + } + if (partz.length === 9) { + type = 'photo' + dir = partz[6] + fn = '' + } else if (partz.length === 10) { + type = 'video' + dir = partz[6] + fn = partz[7] + } + return { type, dir, fn, url } + } + function getPath(el) { + if (el.url) { + return getPathFromImage(el.url) + } if (el.dir) { + return el + } + el = el.parentNode.parentNode.parentNode.querySelector('img') + return getPathFromImage(el) + } + function pushState(txt, path) { + if (window.location.pathname === path) return + console.log('pushstate', path) + window.history.pushState({}, txt, path) + } + function preventDefault(e) { + if (e && !e.target.classList.contains('metadata')) { + e.preventDefault() + } + } + function clamp(n, a, b) { return n < a ? a : n < b ? n : b } + + // initialize the app when the DOM is ready + document.addEventListener('DOMContentLoaded', init) +} + +loadApp() diff --git a/faiss/static/js/dataUriToBlob.js b/faiss/static/js/dataUriToBlob.js new file mode 100644 index 00000000..80189b8d --- /dev/null +++ b/faiss/static/js/dataUriToBlob.js @@ -0,0 +1,58 @@ +var dataUriToUint8Array = function(uri){ + var data = uri.split(',')[1]; + var bytes = atob(data); + var buf = new ArrayBuffer(bytes.length); + var u8 = new Uint8Array(buf); + for (var i = 0; i < bytes.length; i++) { + u8[i] = bytes.charCodeAt(i); + } + return u8 +} + +window.dataUriToBlob = (function(){ +/** + * Blob constructor. + */ + +var Blob = window.Blob; + +/** + * ArrayBufferView support. + */ + +var hasArrayBufferView = new Blob([new Uint8Array(100)]).size == 100; + +/** + * Return a `Blob` for the given data `uri`. + * + * @param {String} uri + * @return {Blob} + * @api public + */ + +var dataUriToBlob = function(uri){ + var data = uri.split(',')[1]; + var bytes = atob(data); + var buf = new ArrayBuffer(bytes.length); + var arr = new Uint8Array(buf); + for (var i = 0; i < bytes.length; i++) { + arr[i] = bytes.charCodeAt(i); + } + + if (!hasArrayBufferView) arr = buf; + var blob = new Blob([arr], { type: mime(uri) }); + blob.slice = blob.slice || blob.webkitSlice; + return blob; +}; + +/** + * Return data uri mime type. + */ + +function mime(uri) { + return uri.split(';')[0].slice(5); +} + +return dataUriToBlob; + +})() diff --git a/faiss/static/js/metadata-app.js b/faiss/static/js/metadata-app.js new file mode 100644 index 00000000..fa2265fa --- /dev/null +++ b/faiss/static/js/metadata-app.js @@ -0,0 +1,50 @@ +!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=212)}([function(e,t,n){var r=n(99),o=36e5,i=6e4,a=2,u=/[T ]/,s=/:/,c=/^(\d{2})$/,l=[/^([+-]\d{2})$/,/^([+-]\d{3})$/,/^([+-]\d{4})$/],f=/^(\d{4})/,d=[/^([+-]\d{4})/,/^([+-]\d{5})/,/^([+-]\d{6})/],p=/^-(\d{2})$/,h=/^-?(\d{3})$/,m=/^-?(\d{2})-?(\d{2})$/,v=/^-?W(\d{2})$/,y=/^-?W(\d{2})-?(\d{1})$/,g=/^(\d{2}([.,]\d*)?)$/,_=/^(\d{2}):?(\d{2}([.,]\d*)?)$/,b=/^(\d{2}):?(\d{2}):?(\d{2}([.,]\d*)?)$/,w=/([Z+-].*)$/,x=/^(Z)$/,E=/^([+-])(\d{2})$/,O=/^([+-])(\d{2}):?(\d{2})$/;function S(e,t,n){t=t||0,n=n||0;var r=new Date(0);r.setUTCFullYear(e,0,4);var o=7*t+n+1-(r.getUTCDay()||7);return r.setUTCDate(r.getUTCDate()+o),r}e.exports=function(e,t){if(r(e))return new Date(e.getTime());if("string"!=typeof e)return new Date(e);var n=(t||{}).additionalDigits;n=null==n?a:Number(n);var T=function(e){var t,n={},r=e.split(u);if(s.test(r[0])?(n.date=null,t=r[0]):(n.date=r[0],t=r[1]),t){var o=w.exec(t);o?(n.time=t.replace(o[1],""),n.timezone=o[1]):n.time=t}return n}(e),k=function(e,t){var n,r=l[t],o=d[t];if(n=f.exec(e)||o.exec(e)){var i=n[1];return{year:parseInt(i,10),restDateString:e.slice(i.length)}}if(n=c.exec(e)||r.exec(e)){var a=n[1];return{year:100*parseInt(a,10),restDateString:e.slice(a.length)}}return{year:null}}(T.date,n),R=k.year,j=function(e,t){if(null===t)return null;var n,r,o,i;if(0===e.length)return(r=new Date(0)).setUTCFullYear(t),r;if(n=p.exec(e))return r=new Date(0),o=parseInt(n[1],10)-1,r.setUTCFullYear(t,o),r;if(n=h.exec(e)){r=new Date(0);var a=parseInt(n[1],10);return r.setUTCFullYear(t,0,a),r}if(n=m.exec(e)){r=new Date(0),o=parseInt(n[1],10)-1;var u=parseInt(n[2],10);return r.setUTCFullYear(t,o,u),r}if(n=v.exec(e))return i=parseInt(n[1],10)-1,S(t,i);if(n=y.exec(e)){i=parseInt(n[1],10)-1;var s=parseInt(n[2],10)-1;return S(t,i,s)}return null}(k.restDateString,R);if(j){var P,C=j.getTime(),M=0;return T.time&&(M=function(e){var t,n,r;if(t=g.exec(e))return(n=parseFloat(t[1].replace(",",".")))%24*o;if(t=_.exec(e))return n=parseInt(t[1],10),r=parseFloat(t[2].replace(",",".")),n%24*o+r*i;if(t=b.exec(e)){n=parseInt(t[1],10),r=parseInt(t[2],10);var a=parseFloat(t[3].replace(",","."));return n%24*o+r*i+1e3*a}return null}(T.time)),T.timezone?P=function(e){var t,n;return(t=x.exec(e))?0:(t=E.exec(e))?(n=60*parseInt(t[2],10),"+"===t[1]?-n:n):(t=O.exec(e))?(n=60*parseInt(t[2],10)+parseInt(t[3],10),"+"===t[1]?-n:n):0}(T.timezone):(P=new Date(C+M).getTimezoneOffset(),P=new Date(C+M+P*i).getTimezoneOffset()),new Date(C+M+P*i)}return new Date(e)}},function(e,t,n){"use strict";e.exports=n(213)},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(220),o=n(117),i=n(224);n.d(t,"Provider",function(){return r.b}),n.d(t,"createProvider",function(){return r.a}),n.d(t,"connectAdvanced",function(){return o.a}),n.d(t,"connect",function(){return i.a})},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";t.__esModule=!0;var r=function(e){return e&&e.__esModule?e:{default:e}}(n(340));t.default=r.default||function(e){for(var t=1;t0&&void 0!==arguments[0]?arguments[0]:{},t=arguments[1];if(u)throw u;for(var r=!1,o={},i=0;i0&&void 0!==arguments[0]?arguments[0]:0;e/=arguments.length>1&&void 0!==arguments[1]?arguments[1]:25;var t=p(Math.round(e)%60);return(e=Math.floor(e/60))>60?Math.floor(e/60)+":"+p(e%60)+":"+t:e%60+":"+t},t.percent=function(e){return(100*e).toFixed(1)+"%"},t.px=function(e,t){return Math.round(e*t)+"px"},t.clamp=function(e,t,n){return e3&&void 0!==arguments[3]?arguments[3]:"th";return["https://sa-vframe.ams3.digitaloceanspaces.com/v1/media/keyframes",d(e)?null:"unverified",h(t),f(n,6),r,"index.jpg"].filter(function(e){return!!e}).join("/")},v=(t.metadataUri=function(e,t){return"/metadata/"+e+"/"+t+"/"},t.keyframeUri=function(e,t){return"/metadata/"+e+"/keyframe/"+f(t,6)+"/"},t.preloadImage=function(e){var t=e.verified,n=e.hash,r=e.frame,o=e.url;n&&r&&(o=m(t,n,r,"md"));var i=new Image,a=!1;i.onload=function(){a||(a=!0,i.onload=null)},i.crossOrigin="anonymous",i.src=o,i.complete&&i.onload()},null),y="",g="",_=(t.post=function(e,t,n){_();var o=void 0;t instanceof FormData?o={Accept:"application/json, application/xml, text/play, text/html, *.*"}:(o={Accept:"application/json, application/xml, text/play, text/html, *.*","Content-Type":"application/json; charset=utf-8"},t=(0,r.default)(t));var i={method:"POST",body:t,headers:o,credentials:"include"};return n&&(o.Authorization="Token "+y),fetch(e,i).then(function(e){return e.json()})},t.login=function(){if(v)return v;var e="0.0.0.0"===window.location.hostname||"127.0.0.1"===window.location.hostname;try{var t=JSON.parse(JSON.parse(localStorage.getItem("persist:root")).auth);return y=t.token,g=t.user.username,y&&console.log("logged in",g),v=t,y||e||(window.location.href="/"),t}catch(t){return e||(window.location.href="/"),{}}})},function(e,t,n){var r=n(13),o=n(10),i=n(34),a=n(26),u=n(25),s=function(e,t,n){var c,l,f,d=e&s.F,p=e&s.G,h=e&s.S,m=e&s.P,v=e&s.B,y=e&s.W,g=p?o:o[t]||(o[t]={}),_=g.prototype,b=p?r:h?r[t]:(r[t]||{}).prototype;for(c in p&&(n=t),n)(l=!d&&b&&void 0!==b[c])&&u(g,c)||(f=l?b[c]:n[c],g[c]=p&&"function"!=typeof b[c]?n[c]:v&&l?i(f,r):y&&b[c]==f?function(e){var t=function(t,n,r){if(this instanceof e){switch(arguments.length){case 0:return new e;case 1:return new e(t);case 2:return new e(t,n)}return new e(t,n,r)}return e.apply(this,arguments)};return t.prototype=e.prototype,t}(f):m&&"function"==typeof f?i(Function.call,f):f,m&&((g.virtual||(g.virtual={}))[c]=f,e&s.R&&_&&!_[c]&&a(_,c,f)))};s.F=1,s.G=2,s.S=4,s.P=8,s.B=16,s.W=32,s.U=64,s.R=128,e.exports=s},function(e,t,n){"use strict";e.exports=function(e,t,n,r,o,i,a,u){if(!e){var s;if(void 0===t)s=new Error("Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.");else{var c=[n,r,o,i,a,u],l=0;(s=new Error(t.replace(/%s/g,function(){return c[l++]}))).name="Invariant Violation"}throw s.framesToPop=1,s}}},function(e,t,n){var r=n(23);e.exports=function(e){if(!r(e))throw TypeError(e+" is not an object!");return e}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.random=t.browse=t.search=t.searchByFrame=t.searchByVerifiedFrame=t.upload=t.updateOptions=t.panic=t.publicUrl=void 0;var r=s(n(4)),o=function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n]);return t.default=e,t}(n(39)),i=n(94),a=n(17),u=s(n(354));function s(e){return e&&e.__esModule?e:{default:e}}var c={upload:function(){return"https://syrianarchive.vframe.io/search/api/upload"},search:function(){return"https://syrianarchive.vframe.io/search/api/fetch"},searchByVerifiedFrame:function(e,t,n){return"https://syrianarchive.vframe.io/search/api/search/"+e+"/"+t+"/"+(0,a.pad)(n,6)},searchByFrame:function(e,t){return"https://syrianarchive.vframe.io/search/api/search/"+e+"/"+(0,a.pad)(t,6)},browse:function(e){return"https://syrianarchive.vframe.io/search/api/list/"+e},random:function(){return"https://syrianarchive.vframe.io/search/api/random"},check:function(){return"https://syrianarchive.vframe.io/api/images/import/search"}},l=t.publicUrl={browse:function(e){return"/search/browse/"+e},searchByVerifiedFrame:function(e,t,n){return"/search/keyframe/"+(0,a.verify)(e)+"/"+t+"/"+(0,a.pad)(n,6)},searchByFrame:function(e,t){return"/search/keyframe/"+e+"/"+(0,a.pad)(t,6)},review:function(){return"/search/review/"}},f=function(e,t){return{type:o.search.loading,tag:e,offset:t}},d=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return{type:o.search.loaded,tag:e,data:t,offset:n}},p=function(e,t){return{type:o.search.error,tag:e,err:t}};t.panic=function(){return function(e){i.history.push("/search/"),e({type:o.search.panic})}},t.updateOptions=function(e){return function(t){t({type:o.search.update_options,opt:e})}},t.upload=function(e,t){return function(n){var o=i.store.getState().search.options,u=new FormData;u.append("query_img",e),u.append("limit",o.perPage),t||n(f("query")),(0,a.post)(c.upload(),u).then(function(e){if(t){var o=e.query.timing;e.query=(0,r.default)({},t,{timing:o});var a={};if(e.query.crop){var u=e.query.crop,s=u.x,c=u.y,l=u.w,f=u.h;a.crop=[s,c,l,f].map(function(e){return parseInt(e,10)}).join(",")}t.url&&!t.hash&&(a.url=t.url)}else e.query.url&&!window.location.search.match(e.query.url)&&i.history.push("/search/?url="+e.query.url);n(d("query",e))}).catch(function(e){return n(p("query",e))})}},t.searchByVerifiedFrame=function(e,t,n){var r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0;return function(o){var s=i.store.getState().search.options;o(f("query",r));var l=u.default.stringify({limit:s.perPage,offset:r});(0,a.preloadImage)({verified:e,hash:t,frame:n}),fetch(c.searchByVerifiedFrame(e,t,n)+"?"+l,{method:"GET",mode:"cors"}).then(function(e){return e.json()}).then(function(e){return o(d("query",e,r))}).catch(function(e){return o(p("query",e))})}},t.searchByFrame=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return function(r){var o=i.store.getState().search.options;r(f("query",n));var s=u.default.stringify({limit:o.perPage,offset:n});(0,a.preloadImage)({verified:!1,hash:e,frame:t}),fetch(c.searchByFrame(e,t)+"?"+s,{method:"GET",mode:"cors"}).then(function(e){return e.json()}).then(function(e){return r(d("query",e,n))}).catch(function(e){return r(p("query",e))})}},t.search=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return function(n){var r=i.store.getState().search.options;n(f("query",t));var o=u.default.stringify({url:e,limit:r.perPage,offset:t});0===e.indexOf("static")&&(0,a.preloadImage)({uri:e}),fetch(c.search(e)+"?"+o,{method:"GET",mode:"cors"}).then(function(e){return e.json()}).then(function(e){return n(d("query",e,t))}).catch(function(e){return n(p("query",e))})}},t.browse=function(e){return function(t){var n="browse";t(f(n)),fetch(c[n](e),{method:"GET",mode:"cors"}).then(function(e){return e.json()}).then(function(e){return t(d(n,e))}).catch(function(e){return t(p(n,e))})}},t.random=function(){return function(e){var t=i.store.getState().search.options,n=u.default.stringify({limit:t.perPage});e(f("query")),fetch(c.random()+"?"+n,{method:"GET",mode:"cors"}).then(function(e){return e.json()}).then(function(t){e(d("query",t)),i.history.push(l.searchByVerifiedFrame(t.query.verified,t.query.hash,t.query.frame))}).catch(function(t){return e(p("query",t))})}}},function(e,t,n){var r=n(20),o=n(125),i=n(77),a=Object.defineProperty;t.f=n(24)?Object.defineProperty:function(e,t,n){if(r(e),t=i(t,!0),r(n),o)try{return a(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(e[t]=n.value),e}},function(e,t){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,t,n){e.exports=!n(35)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,t){var n={}.hasOwnProperty;e.exports=function(e,t){return n.call(e,t)}},function(e,t,n){var r=n(22),o=n(43);e.exports=n(24)?function(e,t,n){return r.f(e,t,o(1,n))}:function(e,t,n){return e[t]=n,e}},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(275);n.d(t,"createBrowserHistory",function(){return r.a});var o=n(278);n.d(t,"createHashHistory",function(){return o.a});var i=n(279);n.d(t,"createMemoryHistory",function(){return i.a});var a=n(62);n.d(t,"createLocation",function(){return a.a}),n.d(t,"locationsAreEqual",function(){return a.b});var u=n(47);n.d(t,"parsePath",function(){return u.d}),n.d(t,"createPath",function(){return u.b})},function(e,t,n){e.exports={default:n(331),__esModule:!0}},function(e,t,n){var r=n(0),o=n(30);e.exports=function(e){var t=r(e),n=t.getFullYear(),i=new Date(0);i.setFullYear(n+1,0,4),i.setHours(0,0,0,0);var a=o(i),u=new Date(0);u.setFullYear(n,0,4),u.setHours(0,0,0,0);var s=o(u);return t.getTime()>=a.getTime()?n+1:t.getTime()>=s.getTime()?n:n-1}},function(e,t,n){var r=n(66);e.exports=function(e){return r(e,{weekStartsOn:1})}},function(e,t,n){var r=n(0);e.exports=function(e){var t=r(e);return t.setHours(0,0,0,0),t}},function(e,t){"function"==typeof Object.create?e.exports=function(e,t){e.super_=t,e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}})}:e.exports=function(e,t){e.super_=t;var n=function(){};n.prototype=t.prototype,e.prototype=new n,e.prototype.constructor=e}},function(e,t,n){"use strict";var r=n(70),o=Object.keys||function(e){var t=[];for(var n in e)t.push(n);return t};e.exports=f;var i=n(54);i.inherits=n(32);var a=n(201),u=n(111);i.inherits(f,a);for(var s=o(u.prototype),c=0;c1)for(var n=1;n=t.length?{value:void 0,done:!0}:(e=r(t,n),this._i+=e.length,{value:e,done:!1})})},function(e,t,n){var r=n(129),o=n(82);e.exports=Object.keys||function(e){return r(e,o)}},function(e,t){var n={}.toString;e.exports=function(e){return n.call(e).slice(8,-1)}},function(e,t,n){"use strict";n.d(t,"a",function(){return r}),n.d(t,"f",function(){return o}),n.d(t,"c",function(){return i}),n.d(t,"e",function(){return a}),n.d(t,"g",function(){return u}),n.d(t,"d",function(){return s}),n.d(t,"b",function(){return c});var r=function(e){return"/"===e.charAt(0)?e:"/"+e},o=function(e){return"/"===e.charAt(0)?e.substr(1):e},i=function(e,t){return new RegExp("^"+t+"(\\/|\\?|#|$)","i").test(e)},a=function(e,t){return i(e,t)?e.substr(t.length):e},u=function(e){return"/"===e.charAt(e.length-1)?e.slice(0,-1):e},s=function(e){var t=e||"/",n="",r="",o=t.indexOf("#");-1!==o&&(r=t.substr(o),t=t.substr(0,o));var i=t.indexOf("?");return-1!==i&&(n=t.substr(i),t=t.substr(0,i)),{pathname:t,search:"?"===n?"":n,hash:"#"===r?"":r}},c=function(e){var t=e.pathname,n=e.search,r=e.hash,o=t||"/";return n&&"?"!==n&&(o+="?"===n.charAt(0)?n:"?"+n),r&&"#"!==r&&(o+="#"===r.charAt(0)?r:"#"+r),o}},function(e,t){e.exports=function(e){var t=[];return t.toString=function(){return this.map(function(t){var n=function(e,t){var n=e[1]||"",r=e[3];if(!r)return n;if(t&&"function"==typeof btoa){var o=function(e){return"/*# sourceMappingURL=data:application/json;charset=utf-8;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(e))))+" */"}(r),i=r.sources.map(function(e){return"/*# sourceURL="+r.sourceRoot+e+" */"});return[n].concat(i).concat([o]).join("\n")}return[n].join("\n")}(t,e);return t[2]?"@media "+t[2]+"{"+n+"}":n}).join("")},t.i=function(e,n){"string"==typeof e&&(e=[[null,e,""]]);for(var r={},o=0;o=0&&s.splice(t,1)}function h(e){var t=document.createElement("style");return void 0===e.attrs.type&&(e.attrs.type="text/css"),m(t,e.attrs),d(e,t),t}function m(e,t){Object.keys(t).forEach(function(n){e.setAttribute(n,t[n])})}function v(e,t){var n,r,o,i;if(t.transform&&e.css){if(!(i=t.transform(e.css)))return function(){};e.css=i}if(t.singleton){var s=u++;n=a||(a=h(t)),r=g.bind(null,n,s,!1),o=g.bind(null,n,s,!0)}else e.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(n=function(e){var t=document.createElement("link");return void 0===e.attrs.type&&(e.attrs.type="text/css"),e.attrs.rel="stylesheet",m(t,e.attrs),d(e,t),t}(t),r=function(e,t,n){var r=n.css,o=n.sourceMap,i=void 0===t.convertToAbsoluteUrls&&o;(t.convertToAbsoluteUrls||i)&&(r=c(r));o&&(r+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(o))))+" */");var a=new Blob([r],{type:"text/css"}),u=e.href;e.href=URL.createObjectURL(a),u&&URL.revokeObjectURL(u)}.bind(null,n,t),o=function(){p(n),n.href&&URL.revokeObjectURL(n.href)}):(n=h(t),r=function(e,t){var n=t.css,r=t.media;r&&e.setAttribute("media",r);if(e.styleSheet)e.styleSheet.cssText=n;else{for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(document.createTextNode(n))}}.bind(null,n),o=function(){p(n)});return r(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap)return;r(e=t)}else o()}}e.exports=function(e,t){if("undefined"!=typeof DEBUG&&DEBUG&&"object"!=typeof document)throw new Error("The style-loader cannot be used in a non-browser environment");(t=t||{}).attrs="object"==typeof t.attrs?t.attrs:{},t.singleton||"boolean"==typeof t.singleton||(t.singleton=o()),t.insertInto||(t.insertInto="head"),t.insertAt||(t.insertAt="bottom");var n=f(e,t);return l(n,t),function(e){for(var o=[],i=0;io?1:0}},function(e,t,n){(function(e){function n(e){return Object.prototype.toString.call(e)}t.isArray=function(e){return Array.isArray?Array.isArray(e):"[object Array]"===n(e)},t.isBoolean=function(e){return"boolean"==typeof e},t.isNull=function(e){return null===e},t.isNullOrUndefined=function(e){return null==e},t.isNumber=function(e){return"number"==typeof e},t.isString=function(e){return"string"==typeof e},t.isSymbol=function(e){return"symbol"==typeof e},t.isUndefined=function(e){return void 0===e},t.isRegExp=function(e){return"[object RegExp]"===n(e)},t.isObject=function(e){return"object"==typeof e&&null!==e},t.isDate=function(e){return"[object Date]"===n(e)},t.isError=function(e){return"[object Error]"===n(e)||e instanceof Error},t.isFunction=function(e){return"function"==typeof e},t.isPrimitive=function(e){return null===e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e||"symbol"==typeof e||void 0===e},t.isBuffer=e.isBuffer}).call(t,n(204).Buffer)},function(e,t){var n=0,r=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++n+r).toString(36))}},function(e,t){e.exports=function(e){if("function"!=typeof e)throw TypeError(e+" is not a function!");return e}},function(e,t,n){var r=n(22).f,o=n(25),i=n(14)("toStringTag");e.exports=function(e,t,n){e&&!o(e=n?e:e.prototype,i)&&r(e,i,{configurable:!0,value:t})}},function(e,t,n){n(254);for(var r=n(13),o=n(26),i=n(36),a=n(14)("toStringTag"),u="CSSRuleList,CSSStyleDeclaration,CSSValueList,ClientRectList,DOMRectList,DOMStringList,DOMTokenList,DataTransferItemList,FileList,HTMLAllCollection,HTMLCollection,HTMLFormElement,HTMLSelectElement,MediaList,MimeTypeArray,NamedNodeMap,NodeList,PaintRequestList,Plugin,PluginArray,SVGLengthList,SVGNumberList,SVGPathSegList,SVGPointList,SVGStringList,SVGTransformList,SourceBufferList,StyleSheetList,TextTrackCueList,TextTrackList,TouchList".split(","),s=0;s may have only one child element"),this.unlisten=r.listen(function(){e.setState({match:e.computeMatch(r.location.pathname)})})},t.prototype.componentWillReceiveProps=function(e){o()(this.props.history===e.history,"You cannot change ")},t.prototype.componentWillUnmount=function(){this.unlisten()},t.prototype.render=function(){var e=this.props.children;return e?s.a.Children.only(e):null},t}(s.a.Component);p.propTypes={history:l.a.object.isRequired,children:l.a.node},p.contextTypes={router:l.a.object},p.childContextTypes={router:l.a.object.isRequired},t.a=p},function(e,t,n){"use strict";var r=n(140),o=n.n(r),i={},a=0;t.a=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments[2];"string"==typeof t&&(t={path:t});var r=t,u=r.path,s=r.exact,c=void 0!==s&&s,l=r.strict,f=void 0!==l&&l,d=r.sensitive,p=void 0!==d&&d;if(null==u)return n;var h=function(e,t){var n=""+t.end+t.strict+t.sensitive,r=i[n]||(i[n]={});if(r[e])return r[e];var u=[],s={re:o()(e,u,t),keys:u};return a<1e4&&(r[e]=s,a++),s}(u,{end:c,strict:f,sensitive:p}),m=h.re,v=h.keys,y=m.exec(e);if(!y)return null;var g=y[0],_=y.slice(1),b=e===g;return c&&!b?null:{path:u,url:"/"===u&&""===g?"/":g,isExact:b,params:v.reduce(function(e,t,n){return e[t.name]=_[n],e},{})}}},function(e,t,n){"use strict";t.__esModule=!0;var r=function(e){return e&&e.__esModule?e:{default:e}}(n(126));t.default=function(e,t,n){return t in e?(0,r.default)(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}},function(e,t,n){var r=n(0);e.exports=function(e,t){var n=t&&Number(t.weekStartsOn)||0,o=r(e),i=o.getDay(),a=(i0?r:n)(e)}},function(e,t,n){var r=n(20),o=n(251),i=n(82),a=n(74)("IE_PROTO"),u=function(){},s=function(){var e,t=n(76)("iframe"),r=i.length;for(t.style.display="none",n(131).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write(" + diff --git a/faiss/static/search.html b/faiss/static/search.html new file mode 100644 index 00000000..056d06c1 --- /dev/null +++ b/faiss/static/search.html @@ -0,0 +1 @@ +search.html \ No newline at end of file diff --git a/faiss/util.py b/faiss/util.py new file mode 100644 index 00000000..97afbc22 --- /dev/null +++ b/faiss/util.py @@ -0,0 +1,29 @@ +import time +import simplejson as json +import pickle +from os import path +from collections import namedtuple + +# Converts JSON el['key'] to Pythonic object-style el.key +def _json_object_hook(d): + return namedtuple('X', d.keys())(*d.values()) + +# Load a JSON recipe +def load_recipe(path): + with open(path) as fh: + return json.load(fh, object_hook=_json_object_hook) + +# Load a pickle file +def load_pickle(data_dir, pkl_fn): + load_start = time.time() + with open(path.join(str(data_dir), str(pkl_fn)), 'rb') as fh: + raw = fh.read() + data = pickle.loads(raw) + load_end = time.time() + load_time = load_end - load_start + print("Pickle load time: {:.1f}s".format(load_time)) + return data + +def read_json(fn): + with open(fn, 'r') as json_file: + return json.load(json_file) diff --git a/faiss/wsgi.py b/faiss/wsgi.py new file mode 100644 index 00000000..371862fb --- /dev/null +++ b/faiss/wsgi.py @@ -0,0 +1,5 @@ +from server import app + +if __name__ == "__main__": + app.run() + diff --git a/megapixels/app/models/sql_factory.py b/megapixels/app/models/sql_factory.py index ecca0c7f..525492f1 100644 --- a/megapixels/app/models/sql_factory.py +++ b/megapixels/app/models/sql_factory.py @@ -14,7 +14,6 @@ connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( # Session = sessionmaker(bind=engine) # session = Session() - class SqlDataset: """ Bridge between the facial information CSVs connected to the datasets, and MySQL diff --git a/megapixels/app/server/api/image.py b/megapixels/app/server/api/image.py new file mode 100644 index 00000000..f2f4a4f9 --- /dev/null +++ b/megapixels/app/server/api/image.py @@ -0,0 +1,40 @@ +from flask import Blueprint, render_template, abort +# from jinja2 import TemplateNotFound + +router = Blueprint('image', __name__) + +@router.route('//test', methods=['POST']) +def test(name): + # dataset = +@router.route('//face', methods=['POST']) +def upload(name): + file = request.files['query_img'] + fn = file.filename + if fn.endswith('blob'): + fn = 'filename.jpg' + + basename, ext = os.path.splitext(fn) + print("got {}, type {}".format(basename, ext)) + if ext.lower() not in valid_exts: + return jsonify({ 'error': 'not an image' }) + + uploaded_fn = datetime.now().isoformat() + "_" + basename + uploaded_fn = sanitize_re.sub('', uploaded_fn) + uploaded_img_path = "static/uploaded/" + uploaded_fn + ext + uploaded_img_path = uploaded_img_path.lower() + print('query: {}'.format(uploaded_img_path)) + + img = Image.open(file.stream).convert('RGB') + # img.save(uploaded_img_path) + # vec = db.load_feature_vector_from_file(uploaded_img_path) + vec = fe.extract(img) + # print(vec.shape) + + results = db.search(vec, limit=limit) + query = { + 'timing': time.time() - start, + } + print(results) + return jsonify({ + 'results': results, + }) diff --git a/megapixels/app/server/create.py b/megapixels/app/server/create.py new file mode 100644 index 00000000..1119ee8f --- /dev/null +++ b/megapixels/app/server/create.py @@ -0,0 +1,27 @@ +from flask import Flask, Blueprint +from flask_sqlalchemy import SQLAlchemy +from app.models.sql_factory import connection_url + +from app.server.api import router as api_router + +# from app.server.views.assets import assets + +db = SQLAlchemy() + +def create_app(script_info=None): + app = Flask(__name__, static_url_path='') + app.config['SQLALCHEMY_DATABASE_URI'] = connection_url + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + db.init_app(app) + app.register_blueprint(api) + + @app.route('/', methods=['GET']) + def index(): + return app.send_static_file('index.html') + + @app.shell_context_processor + def shell_context(): + return { 'app': app, 'db': db } + + return app diff --git a/megapixels/app/server/static b/megapixels/app/server/static new file mode 120000 index 00000000..1dc7a639 --- /dev/null +++ b/megapixels/app/server/static @@ -0,0 +1 @@ +../../../site/public \ No newline at end of file diff --git a/megapixels/cli_flask.py b/megapixels/cli_flask.py new file mode 100644 index 00000000..369bec01 --- /dev/null +++ b/megapixels/cli_flask.py @@ -0,0 +1,19 @@ +# -------------------------------------------------------- +# wrapper for flask CLI API +# -------------------------------------------------------- + +import click + +from flask.cli import FlaskGroup +from app.server.create import create_app + +# from app.settings import app_cfg as cfg +# from app.utils import logger_utils + +cli = FlaskGroup(create_app=create_app) + +# -------------------------------------------------------- +# Entrypoint +# -------------------------------------------------------- +if __name__ == '__main__': + cli() diff --git a/megapixels/commands/faiss/build_db.py b/megapixels/commands/faiss/build_db.py index c90d178b..52c4980f 100644 --- a/megapixels/commands/faiss/build_db.py +++ b/megapixels/commands/faiss/build_db.py @@ -17,11 +17,15 @@ from app.settings import app_cfg as cfg def cli(ctx): """import the various CSVs into MySQL """ - datasets = [] + load_sql_datasets(clobber=True) + +def load_sql_datasets(path, clobber=False): + datasets = {} for path in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): - build_dataset(path) + dataset = load_sql_dataset(path, clobber) + datasets[dataset.name] = dataset -def build_dataset(path): +def load_sql_dataset(path, clobber=False): name = os.path.basename(path) dataset = SqlDataset(name) @@ -30,9 +34,12 @@ def build_dataset(path): table = dataset.get_table(key) if table is None: continue - df = pd.read_csv(fn) + if clobber: + df = pd.read_csv(fn) + + # fix columns that are named "index", a sql reserved word + df.columns = table.__table__.columns.keys() - # fix columns that are named "index", a sql reserved word - df.columns = table.__table__.columns.keys() + df.to_sql(name=table.__tablename__, con=engine, if_exists='replace', index=False) - df.to_sql(name=table.__tablename__, con=engine, if_exists='replace', index=False) + return dataset \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 38746f284b17400d4e2555509ea60df5912b824a Mon Sep 17 00:00:00 2001 From: Jules Laplace Date: Fri, 14 Dec 2018 18:10:27 +0100 Subject: all the sql stuff communicating nicely --- megapixels/app/models/sql_factory.py | 61 ++++++++++++++++++++++++++--- megapixels/app/server/api.py | 72 +++++++++++++++++++++++++++++++++++ megapixels/app/server/api/image.py | 40 ------------------- megapixels/app/server/create.py | 23 +++++++---- megapixels/commands/faiss/build_db.py | 36 ++---------------- 5 files changed, 147 insertions(+), 85 deletions(-) create mode 100644 megapixels/app/server/api.py delete mode 100644 megapixels/app/server/api/image.py (limited to 'megapixels/commands') diff --git a/megapixels/app/models/sql_factory.py b/megapixels/app/models/sql_factory.py index 525492f1..2a18d6af 100644 --- a/megapixels/app/models/sql_factory.py +++ b/megapixels/app/models/sql_factory.py @@ -1,9 +1,15 @@ import os +import glob +import time +import pandas as pd from sqlalchemy import create_engine, Table, Column, String, Integer, DateTime, Float from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base +from app.utils.file_utils import load_recipe, load_csv_safe +from app.settings import app_cfg as cfg + connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( os.getenv("DB_USER"), os.getenv("DB_PASS"), @@ -11,8 +17,49 @@ connection_url = "mysql+mysqldb://{}:{}@{}/{}".format( os.getenv("DB_NAME") ) -# Session = sessionmaker(bind=engine) -# session = Session() +datasets = {} +loaded = False + +def list_datasets(): + return [{ + 'name': name, + 'tables': list(datasets[name].tables.keys()), + } for name in datasets.keys()] + +def get_dataset(name): + return datasets[name] if name in datasets else None + +def get_table(name, table_name): + dataset = get_dataset(name) + return dataset.get_table(table_name) if dataset else None + +def load_sql_datasets(replace=False, base_model=None): + global datasets, loaded + if loaded: + return datasets + engine = create_engine(connection_url) if replace else None + for path in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): + dataset = load_sql_dataset(path, replace, engine, base_model) + datasets[dataset.name] = dataset + loaded = True + return datasets + +def load_sql_dataset(path, replace=False, engine=None, base_model=None): + name = os.path.basename(path) + dataset = SqlDataset(name, base_model=base_model) + + for fn in glob.iglob(os.path.join(path, "*.csv")): + key = os.path.basename(fn).replace(".csv", "") + table = dataset.get_table(key) + if table is None: + continue + if replace: + print('loading dataset {}'.format(fn)) + df = pd.read_csv(fn) + # fix columns that are named "index", a sql reserved word + df.columns = table.__table__.columns.keys() + df.to_sql(name=table.__tablename__, con=engine, if_exists='replace', index=False) + return dataset class SqlDataset: """ @@ -21,18 +68,18 @@ class SqlDataset: - names will be fixed to work in SQL (index -> id) - we can then have more generic models for fetching this info after doing a FAISS query """ - def __init__(self, name, base_model=None): + def __init__(self, name, engine=None, base_model=None): self.name = name self.tables = {} if base_model is None: - engine = create_engine(connection_url) + self.engine = create_engine(connection_url) base_model = declarative_base(engine) self.base_model = base_model def get_table(self, type): if type in self.tables: return self.tables[type] - elif type == 'uuid': + elif type == 'uuids': self.tables[type] = self.uuid_table() elif type == 'roi': self.tables[type] = self.roi_table() @@ -96,3 +143,7 @@ class SqlDataset: roll = Column(Float, nullable=False) yaw = Column(Float, nullable=False) return Pose + + +# Session = sessionmaker(bind=engine) +# session = Session() diff --git a/megapixels/app/server/api.py b/megapixels/app/server/api.py new file mode 100644 index 00000000..e7db11f1 --- /dev/null +++ b/megapixels/app/server/api.py @@ -0,0 +1,72 @@ +from flask import Blueprint, jsonify + +from app.models.sql_factory import list_datasets, get_dataset, get_table + +# from jinja2 import TemplateNotFound + +# import os +# import sys +# import json +# import time +# import argparse +# import cv2 as cv +# import numpy as np +# from datetime import datetime +# from flask import Flask, request, render_template, jsonify +# from PIL import Image # todo: try to remove PIL dependency +# import re + +# sanitize_re = re.compile('[\W]+') +# valid_exts = ['.gif', '.jpg', '.jpeg', '.png'] + +# from dotenv import load_dotenv +# load_dotenv() + +# from feature_extractor import FeatureExtractor + +# DEFAULT_LIMIT = 50 + +api = Blueprint('api', __name__) + +@api.route('/') +def index(): + return jsonify({ 'datasets': list_datasets() }) + +@api.route('/dataset//test', methods=['POST']) +def test(dataset='test'): + dataset = get_dataset(dataset) + print('hiiiiii') + return jsonify({ 'test': 'OK', 'dataset': dataset }) + +# @router.route('//face', methods=['POST']) +# def upload(name): +# file = request.files['query_img'] +# fn = file.filename +# if fn.endswith('blob'): +# fn = 'filename.jpg' + +# basename, ext = os.path.splitext(fn) +# print("got {}, type {}".format(basename, ext)) +# if ext.lower() not in valid_exts: +# return jsonify({ 'error': 'not an image' }) + +# uploaded_fn = datetime.now().isoformat() + "_" + basename +# uploaded_fn = sanitize_re.sub('', uploaded_fn) +# uploaded_img_path = "static/uploaded/" + uploaded_fn + ext +# uploaded_img_path = uploaded_img_path.lower() +# print('query: {}'.format(uploaded_img_path)) + +# img = Image.open(file.stream).convert('RGB') +# # img.save(uploaded_img_path) +# # vec = db.load_feature_vector_from_file(uploaded_img_path) +# vec = fe.extract(img) +# # print(vec.shape) + +# results = db.search(vec, limit=limit) +# query = { +# 'timing': time.time() - start, +# } +# print(results) +# return jsonify({ +# 'results': results, +# }) diff --git a/megapixels/app/server/api/image.py b/megapixels/app/server/api/image.py deleted file mode 100644 index f2f4a4f9..00000000 --- a/megapixels/app/server/api/image.py +++ /dev/null @@ -1,40 +0,0 @@ -from flask import Blueprint, render_template, abort -# from jinja2 import TemplateNotFound - -router = Blueprint('image', __name__) - -@router.route('//test', methods=['POST']) -def test(name): - # dataset = -@router.route('//face', methods=['POST']) -def upload(name): - file = request.files['query_img'] - fn = file.filename - if fn.endswith('blob'): - fn = 'filename.jpg' - - basename, ext = os.path.splitext(fn) - print("got {}, type {}".format(basename, ext)) - if ext.lower() not in valid_exts: - return jsonify({ 'error': 'not an image' }) - - uploaded_fn = datetime.now().isoformat() + "_" + basename - uploaded_fn = sanitize_re.sub('', uploaded_fn) - uploaded_img_path = "static/uploaded/" + uploaded_fn + ext - uploaded_img_path = uploaded_img_path.lower() - print('query: {}'.format(uploaded_img_path)) - - img = Image.open(file.stream).convert('RGB') - # img.save(uploaded_img_path) - # vec = db.load_feature_vector_from_file(uploaded_img_path) - vec = fe.extract(img) - # print(vec.shape) - - results = db.search(vec, limit=limit) - query = { - 'timing': time.time() - start, - } - print(results) - return jsonify({ - 'results': results, - }) diff --git a/megapixels/app/server/create.py b/megapixels/app/server/create.py index 1119ee8f..9efed669 100644 --- a/megapixels/app/server/create.py +++ b/megapixels/app/server/create.py @@ -1,10 +1,8 @@ -from flask import Flask, Blueprint +from flask import Flask, Blueprint, jsonify from flask_sqlalchemy import SQLAlchemy -from app.models.sql_factory import connection_url +from app.models.sql_factory import connection_url, load_sql_datasets -from app.server.api import router as api_router - -# from app.server.views.assets import assets +from app.server.api import api db = SQLAlchemy() @@ -14,8 +12,10 @@ def create_app(script_info=None): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db.init_app(app) - app.register_blueprint(api) - + datasets = load_sql_datasets(replace=False, base_model=db.Model) + + app.register_blueprint(api, url_prefix='/api') + @app.route('/', methods=['GET']) def index(): return app.send_static_file('index.html') @@ -24,4 +24,13 @@ def create_app(script_info=None): def shell_context(): return { 'app': app, 'db': db } + @app.route("/site-map") + def site_map(): + links = [] + for rule in app.url_map.iter_rules(): + # url = url_for(rule.endpoint, **(rule.defaults or {})) + # print(url) + links.append((rule.endpoint)) + return(jsonify(links)) + return app diff --git a/megapixels/commands/faiss/build_db.py b/megapixels/commands/faiss/build_db.py index 52c4980f..0f979e41 100644 --- a/megapixels/commands/faiss/build_db.py +++ b/megapixels/commands/faiss/build_db.py @@ -2,44 +2,14 @@ Load all the CSV files into MySQL """ -import os -import glob import click -import time -import pandas as pd -from app.models.sql_factory import engine, SqlDataset -from app.utils.file_utils import load_recipe, load_csv_safe -from app.settings import app_cfg as cfg +from app.models.sql_factory import load_sql_datasets @click.command() @click.pass_context def cli(ctx): """import the various CSVs into MySQL """ - load_sql_datasets(clobber=True) - -def load_sql_datasets(path, clobber=False): - datasets = {} - for path in glob.iglob(os.path.join(cfg.DIR_FAISS_METADATA, "*")): - dataset = load_sql_dataset(path, clobber) - datasets[dataset.name] = dataset - -def load_sql_dataset(path, clobber=False): - name = os.path.basename(path) - dataset = SqlDataset(name) - - for fn in glob.iglob(os.path.join(path, "*.csv")): - key = os.path.basename(fn).replace(".csv", "") - table = dataset.get_table(key) - if table is None: - continue - if clobber: - df = pd.read_csv(fn) - - # fix columns that are named "index", a sql reserved word - df.columns = table.__table__.columns.keys() - - df.to_sql(name=table.__tablename__, con=engine, if_exists='replace', index=False) - - return dataset \ No newline at end of file + print('Loading CSV datasets into SQL...') + load_sql_datasets(replace=True) -- cgit v1.2.3-70-g09d2