diff --git a/.dockerignore b/.dockerignore index b66042e..ac549d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,3 @@ new_img_dir static/Bin/* static/Import/* static/Storage/* -reference_images/* diff --git a/BUGs b/BUGs index 76a52ec..af06ee4 100644 --- a/BUGs +++ b/BUGs @@ -1 +1 @@ -### Next: 39 +### Next: 44 diff --git a/Dockerfile b/Dockerfile index ec845f5..8484186 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,5 +22,6 @@ RUN pip3 install --upgrade pillow --user EXPOSE 443 EXPOSE 55432 COPY . . -RUN chown -R mythtv:mythtv ./static +RUN chown mythtv:mythtv ./static +RUN chown mythtv:mythtv ./static/* CMD ["./wrapper.sh"] diff --git a/README b/README index 319ddbe..b037f98 100644 --- a/README +++ b/README @@ -1,5 +1,10 @@ In here we can put instructions on how to run this / any general info +to edit src: + +git.... +CAM: fill this in pls + ubuntu packages: sudo apt-get install -y mediainfo cmake python3-flask @@ -21,10 +26,16 @@ pip packages: upstream packages... mkdir static/upstream cd static/upstream + mkdir bootstrap-4.6.0-dist + cd bootstrap-4.6.0-dist + mkdir css # for boostrap: wget https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css - wget https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js + + mkdir js + # to note we might need bootstrap.bundle.min.js if we use new features? + wget https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.min.js # for jquery https://code.jquery.com/jquery-3.6.0.min.js @@ -50,7 +61,7 @@ to run prod version of web server: gunicorn --bind="192.168.0.2:5000" --threads=2 --workers=2 main:app Also have to run the job manager for jobs to work: - python3 pa_job_manager.py + FLASK_ENV="development" python3 pa_job_manager.py To rebuild DB from scratch/empty data: @@ -79,3 +90,25 @@ To get back a 'working' but scanned set of data: # gunzip -c /home/ddp/src/photoassistant/DB_BACKUP/20200126-all-imported-no-duplicates.sql.gz > /srv/docker/container/padb/docker-entrypoint-initdb.d/tables.sql ( cd /srv/docker/config/ ; sudo docker-compose stop padb ; yes | sudo docker-compose rm padb ; sudo rm -rf /srv/docker/container/padb/data/ ; sudo docker-compose up padb ) + + +HANDY SQLs/commands: +# long-running AI job (in this case #46), which is not committing joblog per file, and isnt tracking counts properly (temporary bug) + +sudo docker exec -it padb bash + echo 'select * from joblog where job_id = 46;' | psql --user=pa pa | grep 'ooking for' | awk '{ print $15 } ' | sort -u | wc -l + +# how many entries are in a path +sudo docker exec -it padb bash + psql --user=pa pa + select count(entry_id) from entry_dir_link where dir_eid in ( select distinct dir_eid from path_dir_link where path_id = 2 ); + +# how many Images are in a path +sudo docker exec -it padb bash + psql --user=pa pa + select count(distinct e.id) from entry e, entry_dir_link edl where e.type_id = 1 and e.id = edl.entry_id and edl.dir_eid in ( select distinct dir_eid from path_dir_link where path_id = 2 ); + + +# get abs filenames of matching files (for liz person.tag, but could easily add +# d.rel_path like 'liz' too : +select '"'||replace(replace(p.path_prefix,'static/Storage/',''),'static/Import/', '')||'/'||d.rel_path||'/'||e.name||'"' from entry e, entry_dir_link edl, path_dir_link pdl, path p, dir d where e.id = edl.entry_id and edl.dir_eid = pdl.dir_eid and pdl.path_id = p.id and d.eid = edl.dir_eid and e.id in ( select e.id from entry e, face_file_link ffl, face_refimg_link frl, person_refimg_link prl, person p where e.id = ffl.file_eid and ffl.face_id = frl.face_id and frl.refimg_id = prl.refimg_id and prl.person_id = p.id and p.tag = 'liz' ); diff --git a/TODO b/TODO index 5909535..10e373e 100644 --- a/TODO +++ b/TODO @@ -1,26 +1,26 @@ ## GENERAL - * incorporate flask-login and flask-ldap3-login - https://flask-login.readthedocs.io/en/latest/ - https://flask-ldap3-login.readthedocs.io/en/latest/ - https://pythonhosted.org/Flask-Principal/ - # this is an example: - https://code.tutsplus.com/tutorials/flask-authentication-with-ldap--cms-23101 - * user management scope - - do I want admins only? I definitely * want a read-only / share (but to a subset potentially?) - (see point above for how to do all this) + * allow rotate of image (permanently on FS, so its right everywhere) + + * improve photo browser -> view file, rather than just allowing browser to show image + + * face locations: + START FORM SCRATCH so all images have face_locn data + right now GenThumb is in shared, and does width, height as well --> in person.py BUT need this for pa_job_manager + + * allow for threshold/settings to be tweaked from the GUI + - it would be good to then say, just run the scanner against this image or maybe this DIR, to see how it IDs ppl + ---> settings for default value + ---> override table to do per file combos? + + * refimg + - remove AI menu from top-level -> make a sub-of Person, and just have Match or AI + * fix up logging in general * comment your code * more OO goodness :) ## DB - * Need to think about... - file (image) -> has X faces, Y matches - X == Y (optim: dont scan again) - say X-Y == 1, then to optimise, we need to only check the missing - face... at the moment, the DB structure is not that clever... - (file_refimg_link --> file_refimg_link needs a face_num?) - * Dir can have date in the DB, so we can do Oldest/Newest dirs in Folder view ### BACKEND @@ -34,9 +34,6 @@ *** Need to use thread-safe sessions per Thread, half-assed version did not work - - would it be quicker/smarter to use md5 hash matching on import (and if - so, not re-do face* ) ??? - need a manual button to restart a job in the GUI, (based on file-level optims, just run the job as new and it will optim over already done parts and continue) @@ -52,6 +49,7 @@ Admin -> delete old jobs / auto delete jobs older than ??? + -> do I want to have admin roles/users? ### UI ??? ipads can't do selections and contextMenus, do I want to re-factor to cater for this? @@ -67,7 +65,6 @@ need to copy into here the jquery/fa files so we don't need internet to function - for that matter run lightspeed against all this - timelineview? (I think maybe sunburst for large amounts of files, then maybe something more timeline-series for drilling in?) (vertical timeline, date has thumbnails (small) horizontally along a page, etc.? @@ -76,12 +73,13 @@ https://www.highcharts.com/demo/heatmap https://www.highcharts.com/demo/packed-bubble-split -### AI - * allow for threshold/settings to be tweaked from the GUI - - it would be good to then say, just run the scanner against this image or maybe this DIR, to see how it IDs ppl - ### SORTER * exif processing? * location stuff - test a new photo from my camera out -- image is in dir, need to look at exifread output + +### FUTURE: + * can emby use nfo for images (for AI/tags?) + -NO sadly + diff --git a/ai.py b/ai.py index 29b3fe3..ff73a02 100644 --- a/ai.py +++ b/ai.py @@ -5,11 +5,14 @@ from main import db, app, ma from sqlalchemy import Sequence from sqlalchemy.exc import SQLAlchemyError from status import st, Status -from files import Entry, File, FileRefimgLink -from person import Person, PersonRefimgLink -from refimg import Refimg +from files import Entry, File +from person import Refimg, Person, PersonRefimgLink from flask_login import login_required, current_user +from job import Job, JobExtra, Joblog, NewJob +from face import Face, FaceFileLink, FaceRefimgLink + + # pylint: disable=no-member ################################################################################ @@ -18,13 +21,35 @@ from flask_login import login_required, current_user @app.route("/aistats", methods=["GET", "POST"]) @login_required def aistats(): - tmp=db.session.query(Entry,Person).join(File).join(FileRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(FileRefimgLink.matched==True).all() - entries=[] - last_fname="" - for e, p in tmp: - if last_fname != e.name: - entry = { 'name': e.name, 'people': [] } - entries.append( entry ) - last_fname = e.name - entry['people'].append( { 'tag': p.tag } ) - return render_template("aistats.html", page_title='Placeholder', entries=entries) + stats = db.session.execute( "select p.tag, count(f.id) from person p, face f, face_file_link ffl, face_refimg_link frl, person_refimg_link prl where p.id = prl.person_id and prl.refimg_id = frl.refimg_id and frl.face_id = ffl.face_id and ffl.face_id = f.id group by p.tag" ) + fstats={} + fstats['files_with_a_face'] = db.session.execute( "select count(distinct file_eid) as count from face_file_link" ).first()[0] + fstats['files_with_a_match'] = db.session.execute( "select count(distinct ffl.file_eid) as count from face_file_link ffl, face_refimg_link frl where frl.face_id = ffl.face_id" ).first()[0] + fstats['files_with_missing_matches'] = db.session.execute( "select count(distinct ffl.file_eid) from face f left join face_refimg_link frl on f.id = frl.face_id join face_file_link ffl on f.id = ffl.face_id where frl.refimg_id is null" ).first()[0] + + # files_with_no_matches? + + fstats['all_faces'] = db.session.execute( "select count(distinct face_id) as count from face_file_link" ).first()[0] + fstats['all_matched_faces'] = db.session.execute( "select count(distinct face_id) as count from face_refimg_link" ).first()[0] + fstats['all_unmatched_faces'] = db.session.execute( "select count(f.id) from face f left join face_refimg_link frl on f.id = frl.face_id where frl.refimg_id is null" ).first()[0] + + return render_template("aistats.html", page_title='AI Statistics', stats=stats, fstats=fstats ) + + +################################################################################ +# /run_ai_on -> CAM: needs more thought (what actual params, e.g list of file - +# tick, but which face or faces? are we forcing a re-finding of unknown faces +# or just looking for matches? (maybe in the long run there are different +# routes, not params - stuff we will work out as we go) +################################################################################ +@app.route("/run_ai_on", methods=["POST"]) +@login_required +def run_ai_on(): + jex=[] + for el in request.form: + jex.append( JobExtra( name=f"{el}", value=request.form[el] ) ) + print( f"would create new job with extras={jex}" ) + job=NewJob( "run_ai_on", 0, None, jex ) + st.SetAlert("success") + st.SetMessage( f"Created Job #{job.id} to Look for face(s) in selected file(s)") + return render_template("base.html") diff --git a/face.py b/face.py new file mode 100644 index 0000000..48427b6 --- /dev/null +++ b/face.py @@ -0,0 +1,29 @@ +from main import db, app, ma +from sqlalchemy import Sequence +from sqlalchemy.exc import SQLAlchemyError + + +class Face(db.Model): + __tablename__ = "face" + id = db.Column(db.Integer, db.Sequence('face_id_seq'), primary_key=True ) + face = db.Column( db.LargeBinary ) + + def __repr__(self): + return f"".format(self.id, self.name, self.type, self.dir_details, self.file_details, self.in_dir) -class FileRefimgLink(db.Model): - __tablename__ = "file_refimg_link" - file_id = db.Column(db.Integer, db.ForeignKey('file.eid'), unique=True, nullable=False, primary_key=True) - refimg_id = db.Column(db.Integer, db.ForeignKey('refimg.id'), unique=True, nullable=False, primary_key=True) - when_processed = db.Column(db.Float) - matched = db.Column(db.Boolean) - def __repr__(self): - return f" show thumbnail view of files from storage_path @@ -269,17 +265,21 @@ def files_sp(): noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) entries=[] + people = Person.query.all() + # per storage path, add entries to view settings=Settings.query.first() paths = settings.storage_path.split("#") for path in paths: + if not os.path.exists(path): + continue prefix = SymlinkName("Storage",path,path+'/') if folders: entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ) else: entries+=GetEntriesInFlatView( cwd, prefix, noo, offset, how_many ) - return render_template("files.html", page_title='View Files (Storage Path)', entry_data=entries, noo=noo, grouping=grouping, how_many=how_many, offset=offset, size=size, folders=folders, cwd=cwd, root=root ) + return render_template("files.html", page_title='View Files (Storage Path)', entry_data=entries, noo=noo, grouping=grouping, how_many=how_many, offset=offset, size=size, folders=folders, cwd=cwd, root=root, people=people ) ################################################################################ @@ -295,6 +295,8 @@ def files_rbp(): settings=Settings.query.first() paths = settings.recycle_bin_path.split("#") for path in paths: + if not os.path.exists(path): + continue prefix = SymlinkName("Bin",path,path+'/') if folders: entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ) @@ -315,11 +317,16 @@ def search(): # always show flat results for search to start with folders=False - file_data=Entry.query.join(File).filter(Entry.name.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() - dir_data=Entry.query.join(File).join(EntryDirLink).join(Dir).filter(Dir.rel_path.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() - ai_data=Entry.query.join(File).join(FileRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(FileRefimgLink.matched==True).filter(Person.tag.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() - - all_entries = file_data + dir_data + ai_data + term=request.form['term'] + if 'AI:' in term: + term = term.replace('AI:','') + all_entries = Entry.query.join(File).join(FaceFileLink).join(Face).join(FaceRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(Person.tag.ilike(f"%{term}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() + print( all_entries ) + else: + file_data=Entry.query.join(File).filter(Entry.name.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() + dir_data=Entry.query.join(File).join(EntryDirLink).join(Dir).filter(Dir.rel_path.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() + ai_data=Entry.query.join(File).join(FaceFileLink).join(Face).join(FaceRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(Person.tag.ilike(f"%{request.form['term']}%")).order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name).offset(offset).limit(how_many).all() + all_entries = file_data + dir_data + ai_data return render_template("files.html", page_title='View Files', search_term=request.form['term'], entry_data=all_entries, noo=noo, grouping=grouping, how_many=how_many, offset=offset, size=size, folders=folders, cwd=cwd, root=root ) diff --git a/job.py b/job.py index 2978f1b..1d6b372 100644 --- a/job.py +++ b/job.py @@ -105,18 +105,24 @@ def jobs(): ############################################################################### # /job/ -> GET -> shows status/history of jobs ################################################################################ -@app.route("/job/", methods=["GET"]) +@app.route("/job/", methods=["GET","POST"]) @login_required def joblog(id): page_title='Show Job Details' joblog = Job.query.get(id) - logs=Joblog.query.filter(Joblog.job_id==id).order_by(Joblog.log_date).all() + log_cnt = db.session.execute( f"select count(id) from joblog where job_id = {id}" ).first()[0] + first_logs_only = True + if request.method == 'POST': + logs=Joblog.query.filter(Joblog.job_id==id).order_by(Joblog.log_date).all() + first_logs_only = False + else: + logs=Joblog.query.filter(Joblog.job_id==id).order_by(Joblog.log_date).limit(50).all() if joblog.pa_job_state == "Completed": duration=(joblog.last_update-joblog.start_time) else: duration=(datetime.now(pytz.utc)-joblog.start_time) duration= duration-timedelta(microseconds=duration.microseconds) - return render_template("joblog.html", job=joblog, logs=logs, duration=duration, page_title=page_title) + return render_template("joblog.html", job=joblog, logs=logs, log_cnt=log_cnt, duration=duration, page_title=page_title, first_logs_only=first_logs_only) ############################################################################### # /job/ -> GET -> shows status/history of jobs diff --git a/main.py b/main.py index 6bcfe6f..348e2e7 100644 --- a/main.py +++ b/main.py @@ -7,14 +7,13 @@ from wtforms import SubmitField, StringField, HiddenField, SelectField, IntegerF from flask_wtf import FlaskForm from status import st, Status from shared import CreateSelect, CreateFoldersSelect, LocationIcon, DB_URL -from flask_login import login_required, current_user - # for ldap auth from flask_ldap3_login import LDAP3LoginManager -from flask_login import LoginManager, login_user, UserMixin, current_user +from flask_login import LoginManager, login_user, login_required, UserMixin, current_user from flask_ldap3_login.forms import LDAPLoginForm +import os import re import socket @@ -22,13 +21,16 @@ import socket ####################################### Flask App globals ####################################### PROD_HOST="pa_web" + hostname = socket.gethostname() print( "Running on: {}".format( hostname) ) + app = Flask(__name__) ### what is this value? I gather I should change it? app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['ENV'] = os.environ['FLASK_ENV'] app.config.from_mapping( SECRET_KEY=b'\xd6\x04\xbdj\xfe\xed$c\x1e@\xad\x0f\x13,@G') # ldap config vars: (the last one is required, or python ldap freaks out) @@ -49,20 +51,14 @@ login_manager = LoginManager(app) # Setup a Flask-Login Manager ldap_manager = LDAP3LoginManager(app) # Setup a LDAP3 Login Manager. login_manager.login_view = "login" # default login route, failed with url_for, so hard-coded -# Create a dictionary to store the users in when they authenticate -# This example stores users in memory. -users = {} - - - ################################# Now, import non-book classes ################################### from settings import Settings from files import Entry, GetJM_Message, ClearJM_Message from person import Person -from refimg import Refimg from job import Job, GetNumActiveJobs from ai import aistats from path import StoragePathNames +from user import PAUser ####################################### GLOBALS ####################################### # allow jinja2 to call these python functions directly @@ -98,9 +94,8 @@ class User(UserMixin): # returns None. @login_manager.user_loader def load_user(id): - if id in users: - return users[id] - return None + pau=PAUser.query.filter(PAUser.dn==id).first() + return pau # Declare The User Saver for Flask-Ldap3-Login # This method is called whenever a LDAPLoginForm() successfully validates. @@ -108,9 +103,14 @@ def load_user(id): # login controller. @ldap_manager.save_user def save_user(dn, username, data, memberships): - user = User(dn, username, data) - users[dn] = user - return user + pau=PAUser.query.filter(PAUser.dn==dn).first() + # if we already have a valid user/session, and say the web has restarted, just re-use it, dont make more users + if pau: + return pau + pau=PAUser(dn=dn) + db.session.add(pau) + db.session.commit() + return pau # default page, just the navbar @app.route("/", methods=["GET"]) @@ -129,9 +129,15 @@ def login(): form = LDAPLoginForm() form.submit.label.text="Login" + # the re matches on any special LDAP chars, we dont want someone + # ldap-injecting our username, so send them back to the login page instead + if request.method == 'POST' and re.search( r'[()\\*&!]', request.form['username']): + print( f"WARNING: Detected special LDAP chars in username: {request.form['username']}") + return redirect('/login') if form.validate_on_submit(): # Successfully logged in, We can now access the saved user object # via form.user. + print( f"form user = {form.user}" ) login_user(form.user, remember=True) # Tell flask-login to log them in. next = request.args.get("next") if next: diff --git a/pa_job_manager.py b/pa_job_manager.py index e15f7b4..9c68b11 100644 --- a/pa_job_manager.py +++ b/pa_job_manager.py @@ -137,25 +137,19 @@ class Entry(Base): in_dir = relationship ("Dir", secondary="entry_dir_link", uselist=False ) def FullPathOnFS(self): - s=self.in_dir.in_path.path_prefix + '/' - if len(self.in_dir.rel_path) > 0: - s += self.in_dir.rel_path + '/' + if self.in_dir: + s=self.in_dir.in_path.path_prefix + '/' + if len(self.in_dir.rel_path) > 0: + s += self.in_dir.rel_path + '/' + # this occurs when we have a dir that is the root of a path + else: + s=self.dir_details.in_path.path_prefix+'/' s += self.name return s def __repr__(self): return f"" -class FileRefimgLink(Base): - __tablename__ = "file_refimg_link" - file_id = Column(Integer, ForeignKey('file.eid'), unique=True, nullable=False, primary_key=True) - refimg_id = Column(Integer, ForeignKey('refimg.id'), unique=True, nullable=False, primary_key=True) - when_processed = Column(Float) - matched = Column(Boolean) - - def __repr__(self): - return f"" - class FileType(Base): __tablename__ = "file_type" id = Column(Integer, Sequence('file_type_id_seq'), primary_key=True ) @@ -223,11 +216,39 @@ class Refimg(Base): __tablename__ = "refimg" id = Column(Integer, Sequence('refimg_id_seq'), primary_key=True ) fname = Column(String(256), unique=True, nullable=False) - encodings = Column(LargeBinary) + face = Column(LargeBinary, unique=True, nullable=False) + thumbnail = Column(String, unique=False, nullable=True) created_on = Column(Float) + orig_w = Column(Integer) + orig_h = Column(Integer) + face_locn = Column(String) def __repr__(self): - return f"" + return f"" + +class Face(Base): + __tablename__ = "face" + id = Column(Integer, Sequence('face_id_seq'), primary_key=True ) + face = Column( LargeBinary ) + + def __repr__(self): + return f" 5: + job.last_commmit=now + print( "DELME: we have taken longer than 5 seconds since last commit so do it") + session.commit() + else: + job.last_commit = now + if DEBUG: + print( f"DEBUG: {message}" ) return def RunJob(job): @@ -408,6 +448,8 @@ def RunJob(job): JobRestoreFiles(job) elif job.name == "processai": JobProcessAI(job) + elif job.name == "run_ai_on": + JobRunAIOn(job) else: print("ERROR: Requested to process unknown job type: {}".format(job.name)) # okay, we finished a job, so check for any jobs that are dependant on this and run them... @@ -491,8 +533,9 @@ def JobScanStorageDir(job): def JobForceScan(job): JobProgressState( job, "In Progress" ) + session.query(FaceFileLink).delete() + session.query(FaceRefimgLink).delete() session.query(DelFile).delete() - session.query(FileRefimgLink).delete() session.query(EntryDirLink).delete() session.query(PathDirLink).delete() session.query(Path).delete() @@ -893,11 +936,11 @@ def JobImportDir(job): FinishJob(job, f"Finished Importing: {path} - Processed {overall_file_cnt} files, Removed {rm_cnt} file(s)") return -def RunFuncOnFilesInPath( job, path, file_func ): +def RunFuncOnFilesInPath( job, path, file_func, count_dirs ): d = session.query(Dir).join(PathDirLink).join(Path).filter(Path.path_prefix==path).filter(Dir.rel_path=='').first() files = session.query(Entry).join(EntryDirLink).filter(EntryDirLink.dir_eid==d.eid).all() for e in files: - ProcessFilesInDir(job, e, file_func) + ProcessFilesInDir(job, e, file_func, count_dirs) return @@ -908,15 +951,76 @@ def JobProcessAI(job): p = session.query(Path).filter(Path.path_prefix==path).first() job.num_files=p.num_files - people = session.query(Person).all() - for person in people: - generateKnownEncodings(person) - - RunFuncOnFilesInPath( job, path, ProcessAI ) + RunFuncOnFilesInPath( job, path, ProcessAI, True ) FinishJob(job, "Finished Processesing AI") return +def WrapperForScanFileForPerson(job, entry): + which_person=[jex.value for jex in job.extra if jex.name == "person"][0] + + if entry.type.name == 'Image': + if DEBUG: + AddLogForJob( job, f'INFO: processing File: {entry.name}' ) + for pid in job.ppl: + ScanFileForPerson( job, entry, pid, force=False) + # processed this file, add 1 to count + job.current_file_num+=1 + return + +def AddToJobImageCount(job, entry ): + if entry.type.name == 'Image': + job.num_files += 1 + return + + +def JobRunAIOn(job): + AddLogForJob(job, f"INFO: Starting looking For faces in files job...") + which_person=[jex.value for jex in job.extra if jex.name == "person"][0] + if which_person == "all": + ppl=session.query(Person).all() + else: + ppl=session.query(Person).filter(Person.tag==which_person).all() + + # start by working out how many images in this selection we will need face match on + job.num_files = 0 + for jex in job.extra: + if 'eid-' in jex.name: + entry=session.query(Entry).get(jex.value) + if entry.type.name == 'Directory': + # False in last param says, dont count dirs (we won't AI a dir entry itself) + ProcessFilesInDir( job, entry, AddToJobImageCount, False ) + elif entry.type.name == 'Image': + job.num_files += 1 + # update job, so file count UI progress bar will work + # remember that ProcessFilesInDir updates the current_file_num so zero it out so we can start again + job.current_file_num = 0 + session.commit() + + ppl_lst=[] + for person in ppl: + ppl_lst.append(person.id) + + job.ppl = ppl_lst + + for jex in job.extra: + if 'eid-' in jex.name: + entry=session.query(Entry).get(jex.value) + if entry.type.name == 'Directory': + # False in last param says, dont count dirs (we won't AI a dir entry itself) + ProcessFilesInDir( job, entry, WrapperForScanFileForPerson, False ) + elif entry.type.name == 'Image': + which_file=session.query(Entry).join(File).filter(Entry.id==jex.value).first() + if DEBUG: + AddLogForJob( job, f'INFO: processing File: {entry.name}' ) + for person in ppl: + ScanFileForPerson( job, which_file, person.id, force=False) + # processed this file, add 1 to count + job.current_file_num+=1 + else: + AddLogForJob( job, f'Not processing Entry: {entry.name} - not an image' ) + FinishJob(job, "Finished Processesing AI") + return def GenHashAndThumb(job, e): # commit every 100 files to see progress being made but not hammer the database @@ -944,7 +1048,7 @@ def ProcessAI(job, e): job.current_file_num+=1 return - file = e.FullPathOnFS() + file = e.FullPathOnFS() stat = os.stat(file) # find if file is newer than when we found faces before (fyi: first time faces_created_on == 0) if stat.st_ctime > e.file_details.faces_created_on: @@ -980,27 +1084,9 @@ def ProcessAI(job, e): return def lookForPersonInImage(job, person, unknown_encoding, e): - for refimg in person.refimg: - # lets see if we have tried this check before - frl=session.query(FileRefimgLink).filter(FileRefimgLink.file_id==e.id, FileRefimgLink.refimg_id==refimg.id).first() - if not frl: - frl = FileRefimgLink(refimg_id=refimg.id, file_id=e.file_details.eid) - else: - stat=os.stat( e.FullPathOnFS() ) - # file & refimg are not newer then we dont need to check - if frl.matched and stat.st_ctime < frl.when_processed and refimg.created_on < frl.when_processed: - print(f"OPTIM: lookForPersonInImage: file {e.name} has a previous match for: {refimg.fname}, and the file & refimg haven't changed") - return + FinishJob( job, "THIS CODE HAS BEEN REMOVED, need to use new Face* tables, and rethink", "Failed" ) + return - session.add(frl) - frl.matched=False - frl.when_processed=time.time() - deserialized_bytes = numpy.frombuffer(refimg.encodings, dtype=numpy.float64) - results = compareAI(deserialized_bytes, unknown_encoding) - if results[0]: - AddLogForJob(job, f'Found a match between: {person.tag} and {e.name}') - frl.matched=True - return def generateUnknownEncodings(im): unknown_image = numpy.array(im) @@ -1011,37 +1097,23 @@ def generateUnknownEncodings(im): return unknown_encodings -def generateKnownEncodings(person): - for refimg in person.refimg: - file = 'reference_images/'+refimg.fname - stat = os.stat(file) - if refimg.created_on and stat.st_ctime < refimg.created_on: - print("OPTIM: skipping re-creating encoding for refimg because file has not changed") - continue - img = face_recognition.load_image_file(file) - location = face_recognition.face_locations(img) - encodings = face_recognition.face_encodings(img, known_face_locations=location) - refimg.encodings = encodings[0].tobytes() - refimg.created_on = time.time() - session.add(refimg) - session.commit() - def compareAI(known_encoding, unknown_encoding): results = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.55) return results -def ProcessFilesInDir(job, e, file_func): +def ProcessFilesInDir(job, e, file_func, count_dirs): if DEBUG==1: print( f"DEBUG: ProcessFilesInDir: {e.FullPathOnFS()}") if e.type.name != 'Directory': file_func(job, e) else: d=session.query(Dir).filter(Dir.eid==e.id).first() - job.current_file_num+=1 + if count_dirs: + job.current_file_num+=1 files = session.query(Entry).join(EntryDirLink).filter(EntryDirLink.dir_eid==d.eid).all() for sub in files: - ProcessFilesInDir(job, sub, file_func) + ProcessFilesInDir(job, sub, file_func, count_dirs) return def JobGetFileDetails(job): @@ -1054,7 +1126,7 @@ def JobGetFileDetails(job): job.current_file_num = 0 job.num_files = p.num_files session.commit() - RunFuncOnFilesInPath( job, path_prefix, GenHashAndThumb ) + RunFuncOnFilesInPath( job, path_prefix, GenHashAndThumb, True ) FinishJob(job, "File Details job finished") session.commit() return @@ -1086,8 +1158,7 @@ def isImage(file): except: return False -def GenImageThumbnail(job, file): - ProcessFileForJob( job, "Generate Thumbnail from Image file: {}".format( file ), file ) +def GenThumb(file): try: im_orig = Image.open(file) im = ImageOps.exif_transpose(im_orig) @@ -1100,10 +1171,14 @@ def GenImageThumbnail(job, file): img_bytearray = img_bytearray.getvalue() thumbnail = base64.b64encode(img_bytearray) thumbnail = str(thumbnail)[2:-1] + return thumbnail except Exception as e: AddLogForJob(job, f"WARNING: No EXIF TAF found for: {file} - error={e}") return None - return thumbnail + +def GenImageThumbnail(job, file): + ProcessFileForJob( job, "Generate Thumbnail from Image file: {}".format( file ), file ) + return GenThumb(file) def GenVideoThumbnail(job, file): ProcessFileForJob( job, "Generate Thumbnail from Video file: {}".format( file ), file ) @@ -1251,7 +1326,7 @@ def JobDeleteFiles(job): if 'eid-' in jex.name: del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first() MoveFileToRecycleBin(job,del_me) - now=datetime.now(pytz.utc) + ynw=datetime.now(pytz.utc) next_job=Job(start_time=now, last_update=now, name="checkdups", state="New", wait_for=None, pa_job_state="New", current_file_num=0 ) session.add(next_job) MessageToFE( job.id, "success", "Completed (delete of selected files)" ) @@ -1292,6 +1367,10 @@ def InitialValidationChecks(): break if not rbp_exists: AddLogForJob(job, "ERROR: The bin path in settings does not exist - Please fix now"); + else: + bin_path=session.query(Path).join(PathType).filter(PathType.name=='Bin').first() + if not bin_path: + ProcessRecycleBinDir(job) sp_exists=0 paths = settings.storage_path.split("#") for path in paths: @@ -1313,9 +1392,73 @@ def InitialValidationChecks(): if not rbp_exists or not sp_exists or not ip_exists: FinishJob(job,"ERROR: Job manager EXITing until above errors are fixed by paths being created or settings being updated to valid paths", "Failed" ) exit(-1) + FinishJob(job,"Finished Initial Validation Checks") return +def AddFaceToFile( face_data, file_eid ): + face = Face( face=face_data.tobytes() ) + session.add(face) + session.commit() + ffl = FaceFileLink( face_id=face.id, file_eid=file_eid ) + session.add(ffl) + session.commit() + return face + +def DelFacesForFile( eid ): + session.execute( f"delete from face where id in (select face_id from face_file_link where file_eid = {eid})" ) + session.commit() + return + +def MatchRefimgToFace( refimg_id, face_id ): + rfl = FaceRefimgLink( refimg_id = refimg_id, face_id = face_id ) + session.add(rfl) + session.commit() + return + +def UnmatchedFacesForFile( eid ): + rows = session.execute( f"select f.* from face f left join face_refimg_link frl on f.id = frl.face_id join face_file_link ffl on f.id = ffl.face_id where ffl.file_eid = {eid} and frl.refimg_id is null" ) + return rows + +def ScanFileForPerson( job, e, person_id, force=False ): + file_h = session.query(File).get( e.id ) + # if we are forcing this, delete any old faces (this will also delete linked tables), and reset faces_created_on to None + if force: + AddLogForJob( job, f'INFO: force is true, so deleting old face information for {e.name}' ) + DelFacesForFile( e.id ) + file_h.faces_created_on = 0 + + # optimise: dont rescan if we already have faces (we are just going to try + # to match (maybe?) a refimg + if file_h.faces_created_on == 0: + if DEBUG: + AddLogForJob( job, f"DEBUG: {e.name} is missing unknown faces, generating them" ) + im = face_recognition.load_image_file(e.FullPathOnFS()) + face_locations = face_recognition.face_locations(im) + unknown_encodings = face_recognition.face_encodings(im, known_face_locations=face_locations) + for face in unknown_encodings: + AddFaceToFile( face, e.id ) + file_h.faces_created_on = time.time() + session.commit() + + ## now look for person + refimgs = session.query(Refimg).join(PersonRefimgLink).filter(PersonRefimgLink.person_id==person_id).all() + uf = UnmatchedFacesForFile( e.id ) + if DEBUG and not uf: + AddLogForJob( job, "DEBUG: {e.name} all faces already matched - finished" ) + + for face in uf: + for r in refimgs: + unknown_face_data = numpy.frombuffer(face.face, dtype=numpy.float64) + refimg_face_data = numpy.frombuffer(r.face, dtype=numpy.float64) + match = compareAI(refimg_face_data, unknown_face_data) + if match[0]: + AddLogForJob(job, f'WE MATCHED: {r.fname} with file: {e.name} ') + MatchRefimgToFace( r.id, face.id ) + # no need to keep looking for this face, we found it, go to next unknown face + break + return + if __name__ == "__main__": print("INFO: PA job manager starting - listening on {}:{}".format( PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT) ) diff --git a/person.py b/person.py index 7d00668..369d1d2 100644 --- a/person.py +++ b/person.py @@ -1,18 +1,36 @@ from wtforms import SubmitField, StringField, HiddenField, validators, Form from flask_wtf import FlaskForm -from flask import request, render_template, redirect +from flask import request, render_template, redirect, url_for from main import db, app, ma from sqlalchemy import Sequence from sqlalchemy.exc import SQLAlchemyError from status import st, Status -from refimg import Refimg from flask_login import login_required, current_user +from werkzeug import secure_filename +from shared import GenFace, GenThumb +from face import FaceRefimgLink +import os +import json + # pylint: disable=no-member ################################################################################ # Class describing Person in the database, and via sqlalchemy, connected to the DB as well ################################################################################ +class Refimg(db.Model): + id = db.Column(db.Integer, db.Sequence('refimg_id_seq'), primary_key=True ) + fname = db.Column(db.String(256), unique=True, nullable=False) + face = db.Column(db.LargeBinary, unique=True, nullable=False) + orig_w = db.Column(db.Integer) + orig_h = db.Column(db.Integer) + face_locn = db.Column(db.String) + thumbnail = db.Column(db.String, unique=True, nullable=False) + created_on = db.Column(db.Float) + + def __repr__(self): + return "".format(self.id, self.fname ) + class PersonRefimgLink(db.Model): __tablename__ = "person_refimg_link" person_id = db.Column(db.Integer, db.ForeignKey('person.id'), unique=True, nullable=False, primary_key=True) @@ -27,7 +45,7 @@ class Person(db.Model): tag = db.Column(db.String(48), unique=False, nullable=False) surname = db.Column(db.String(48), unique=False, nullable=False) firstname = db.Column(db.String(48), unique=False, nullable=False) - refimg = db.relationship('Refimg', secondary=PersonRefimgLink.__table__) + refimg = db.relationship('Refimg', secondary=PersonRefimgLink.__table__, order_by=Refimg.id) def __repr__(self): return "".format(self.tag,self.firstname, self.surname, self.refimg) @@ -48,7 +66,7 @@ class PersonForm(FlaskForm): tag = StringField('Tag (searchable name):', [validators.DataRequired()]) firstname = StringField('FirstName(s):', [validators.DataRequired()]) surname = StringField('Surname:', [validators.DataRequired()]) - submit = SubmitField('Save' ) + save = SubmitField('Save' ) delete = SubmitField('Delete' ) ################################################################################ @@ -71,22 +89,20 @@ def persons(): def new_person(): form = PersonForm(request.form) page_title='Create new Person' - reference_imgs = Refimg.query.all() if 'surname' not in request.form: - return render_template("person.html", reference_imgs=reference_imgs, form=form, page_title=page_title ) + return render_template("person.html", person=None, form=form, page_title=page_title ) else: person = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] ) try: db.session.add(person) db.session.commit() - print(person) st.SetMessage( "Created new Person ({})".format(person.tag) ) - return redirect( '/persons' ) + return redirect( f'/person/{person.id}' ) except SQLAlchemyError as e: st.SetAlert( "danger" ) st.SetMessage( "Failed to add Person: {}".format(e.orig) ) - return render_template("person.html", object=person, form=form, reference_imgs=reference_imgs, page_title = page_title) + return render_template("person.html", person=person, form=form, page_title = page_title) ################################################################################ # /person/ -> GET/POST(save or delete) -> shows/edits/delets a single @@ -97,34 +113,76 @@ def new_person(): def person(id): form = PersonForm(request.form) page_title='Edit Person' - reference_imgs = Refimg.query.all() if request.method == 'POST': try: person = Person.query.get(id) if 'delete' in request.form: st.SetMessage("Successfully deleted Person: ({})".format( person.tag ) ) + # do the linkage tables by hand + db.session.execute( f"delete from face_refimg_link frl where refimg_id in ( select refimg_id from person_refimg_link where person_id = {id} )" ) + db.session.execute( f"delete from person_refimg_link where person_id = {id}" ) person = Person.query.filter(Person.id==id).delete() - if 'submit' in request.form and form.validate(): + db.session.commit() + return redirect( f'/persons' ) + elif request.form and form.validate(): st.SetMessage("Successfully Updated Person: (From: {}, {}, {})".format(person.tag, person.firstname, person.surname) ) person.tag = request.form['tag'] person.surname = request.form['surname'] person.firstname = request.form['firstname'] - person.refimg =[] - for ref_img in reference_imgs: + new_refs=[] + for ref_img in person.refimg: if "ref-img-id-{}".format(ref_img.id) in request.form: - print('{} was checked, id: {}'.format(ref_img.fname, ref_img.id)) - person.refimg.append(ref_img) - + new_refs.append(ref_img) + person.refimg = new_refs + db.session.add(person) st.AppendMessage(" To: ({}, {}, {})".format(person.tag, person.firstname, person.surname) ) db.session.commit() - return redirect( '/persons' ) + return redirect( f'/person/{person.id}' ) except SQLAlchemyError as e: st.SetAlert( "danger" ) st.SetMessage( "Failed to modify Person: {}".format(e) ) - return render_template("person.html", form=form, reference_imgs="test", page_title=page_title) + return render_template("person.html", form=form, page_title=page_title) else: person = Person.query.get(id) - print(person) + for r in person.refimg: + r.face_locn=json.loads(r.face_locn) form = PersonForm(request.values, obj=person) - return render_template("person.html", object=person, form=form, reference_imgs=reference_imgs, page_title = page_title) + return render_template("person.html", person=person, form=form, page_title = page_title) + +################################################################################ +# /add_refimg -> POST(add new refimg to a person) +################################################################################ +@app.route("/add_refimg", methods=["POST"]) +@login_required +def add_refimg(): + # now save into the DB + person = Person.query.get(request.form['person_id']); + if not person: + raise Exception("could not find person to add reference image too!") + f=request.files['refimg_file'] + refimg = Refimg( fname=f.filename ) + try: + # save the actual uploaded image to reference_images/ + fname=secure_filename(f.filename) + if fname == "": + raise Exception("invalid filename") + + fname = f"/tmp/{fname}" + f.save( fname ) + refimg.thumbnail, refimg.orig_w, refimg.orig_h = GenThumb( fname ) + refimg.face, face_locn = GenFace( fname ) + refimg.face_locn = json.dumps(face_locn) + os.remove(fname) + person.refimg.append(refimg) + db.session.add(person) + db.session.add(refimg) + db.session.commit() + st.SetMessage( f"Associated new Refimg ({refimg.fname}) with person: {person.tag}" ) + except SQLAlchemyError as e: + st.SetAlert( "danger" ) + st.SetMessage( f"Failed to add Refimg: {e.orig}" ) + except Exception as e: + st.SetAlert( "danger" ) + st.SetMessage( f"Failed to modify Refimg: {e}" ) + return redirect( url_for( 'person', id=person.id) ) diff --git a/refimg.py b/refimg.py deleted file mode 100644 index 9379bcd..0000000 --- a/refimg.py +++ /dev/null @@ -1,124 +0,0 @@ -from wtforms import SubmitField, StringField, HiddenField, FileField, validators, Form -from flask_wtf import FlaskForm -from flask import request, render_template, redirect -from main import db, app, ma -from sqlalchemy import Sequence -from sqlalchemy.exc import SQLAlchemyError -from status import st, Status -import os -from flask_login import login_required, current_user - -# pylint: disable=no-member - -################################################################################ -# Class describing Refimg in the database, and via sqlalchemy, connected to the DB as well -################################################################################ -class Refimg(db.Model): - id = db.Column(db.Integer, db.Sequence('refimg_id_seq'), primary_key=True ) - fname = db.Column(db.String(256), unique=True, nullable=False) - - def __repr__(self): - return "".format(self.id, self.fname ) - -#class Person_Refimg_Link(db.Model): -# __tablename__ = "person_refimg_link" -# person_id = db.Column(db.Integer, db.ForeignKey('person.id'), unique=True, nullable=False, primary_key=True) -# refimg_id = db.Column(db.Integer, db.ForeignKey('refimg.id'), unique=True, nullable=False, primary_key=True) -# -# def __repr__(self): -# return "".format(self.person_id, self.refimg_id) - -################################################################################ -# Helper class that inherits a .dump() method to turn class Refimg into json / useful in jinja2 -################################################################################ -class RefimgSchema(ma.SQLAlchemyAutoSchema): - class Meta: - model = Refimg - ordered = True - -################################################################################ -# Helper class that defines a form for refimg, used to make html
, with field validation (via wtforms) -################################################################################ -class RefimgForm(FlaskForm): - id = HiddenField() - fname = StringField('File name:', [validators.DataRequired()]) - refimg_file = FileField('File name:') - submit = SubmitField('Save' ) - delete = SubmitField('Delete' ) - -################################################################################ -# Routes for refimg data -# -# /refimgs -> GET only -> prints out list of all refimgs -################################################################################ -@app.route("/refimgs", methods=["GET"]) -@login_required -def refimgs(): - refimgs = Refimg.query.all() - return render_template("refimgs.html", refimgs=refimgs) - -################################################################################ -# /refimg -> GET/POST -> creates a new refimg type and when created, takes you back to /refimgs -################################################################################ -@app.route("/refimg", methods=["GET", "POST"]) -@login_required -def new_refimg(): - form = RefimgForm(request.form) - page_title='Create new Reference Image' - if request.method == 'GET': - return render_template("refimg.html", form=form, page_title=page_title ) - else: - # now save into the DB - refimg = Refimg( fname=request.form["fname"] ) - try: - # save the actual uploaded image to reference_images/ - f=request.files['refimg_file'] - f.save(os.path.join("reference_images", request.form["fname"])) - db.session.add(refimg) - db.session.commit() - st.SetMessage( "Created new Refimg ({})".format(refimg.fname) ) - return redirect( '/refimgs' ) - except SQLAlchemyError as e: - st.SetAlert( "danger" ) - st.SetMessage( "Failed to add Refimg: {}".format(e.orig) ) - except Exception as e: - st.SetAlert( "danger" ) - st.SetMessage( "Failed to modify Refimg: {}".format(e) ) - return render_template("refimg.html", form=form, page_title=page_title) - -################################################################################ -# /refimg/ -> GET/POST(save or delete) -> shows/edits/delets a single -# refimg -################################################################################ -@app.route("/refimg/", methods=["GET", "POST"]) -@login_required -def refimg(id): - form = RefimgForm(request.form) - page_title='Edit Reference Image' - if request.method == 'POST': - try: - refimg = Refimg.query.get(id) - os.remove("reference_images/{}".format(refimg.fname) ) - if 'delete' in request.form: - st.SetMessage("Successfully deleted Refimg: ({})".format( refimg.fname ) ) - refimg = Refimg.query.filter(Refimg.id==id).delete() - if 'submit' in request.form and form.validate(): - st.SetMessage("Successfully Updated Refimg: (From: {})".format(refimg.fname)) - refimg.fname = request.form['fname'] - st.AppendMessage(" To: ({})".format(refimg.fname) ) - # save the actual uploaded image to reference_images/ - f=request.files['refimg_file'] - f.save(os.path.join("reference_images", request.form["fname"])) - db.session.commit() - return redirect( '/refimgs' ) - except SQLAlchemyError as e: - st.SetAlert( "danger" ) - st.SetMessage( "Failed to modify Refimg: {}".format(e.orig) ) - except Exception as e: - st.SetAlert( "danger" ) - st.SetMessage( "Failed to modify Refimg: {}".format(e) ) - return render_template("refimg.html", form=form, page_title=page_title) - else: - refimg = Refimg.query.get(id) - form = RefimgForm(request.values, obj=refimg) - return render_template("refimg.html", object=refimg, form=form, page_title = page_title) diff --git a/shared.py b/shared.py index 69324d0..0e36a04 100644 --- a/shared.py +++ b/shared.py @@ -1,5 +1,9 @@ import socket import os +import face_recognition +import io +import base64 +from PIL import Image, ImageOps hostname = socket.gethostname() PROD_HOST="pa_web" @@ -9,15 +13,18 @@ ICON["Import"]="fa-file-upload" ICON["Storage"]="fa-database" ICON["Bin"]="fa-trash-alt" -if hostname == PROD_HOST: - PA_JOB_MANAGER_HOST="192.168.0.2" - DB_URL = 'postgresql+psycopg2://pa:for_now_pa@192.168.0.2:55432/pa' -elif hostname == "lappy": +if hostname == "lappy": PA_JOB_MANAGER_HOST="localhost" DB_URL = 'postgresql+psycopg2://pa:for_now_pa@localhost:5432/pa' -else: +elif 'FLASK_ENV' not in os.environ or os.environ['FLASK_ENV'] == "development": PA_JOB_MANAGER_HOST="localhost" - DB_URL = 'postgresql+psycopg2://pa:for_now_pa@mara.ddp.net:55432/pa' + DB_URL = 'postgresql+psycopg2://pa:for_now_pa@mara.ddp.net:65432/pa' +elif os.environ['FLASK_ENV'] == "production": + PA_JOB_MANAGER_HOST="localhost" + DB_URL = 'postgresql+psycopg2://pa:for_now_pa@padb/pa' +else: + print( "ERROR: I do not know which environment (development, etc.) and which DB (on which host to use)" ) + exit( -1 ) PA_JOB_MANAGER_PORT=55430 @@ -70,3 +77,34 @@ def SymlinkName(ptype, path, file): if symlink[-1] == '/': symlink=symlink[0:-1] return symlink + + +def GenThumb(fname): + print( f"GenThumb({fname})" ) + + try: + im_orig = Image.open(fname) + im = ImageOps.exif_transpose(im_orig) + bands = im.getbands() + if 'A' in bands: + im = im.convert('RGB') + orig_w, orig_h = im.size + im.thumbnail((THUMBSIZE,THUMBSIZE)) + img_bytearray = io.BytesIO() + im.save(img_bytearray, format='JPEG') + img_bytearray = img_bytearray.getvalue() + thumbnail = base64.b64encode(img_bytearray) + thumbnail = str(thumbnail)[2:-1] + return thumbnail, orig_w, orig_h + except Exception as e: + print( f"GenThumb failed: {e}") + return None, None, None + +def GenFace(fname): + img = face_recognition.load_image_file(fname) + location = face_recognition.face_locations(img) + encodings = face_recognition.face_encodings(img, known_face_locations=location) + if len(encodings): + return encodings[0].tobytes(), location + else: + return None, None diff --git a/tables.sql b/tables.sql index f82832f..148c9dd 100644 --- a/tables.sql +++ b/tables.sql @@ -2,6 +2,8 @@ alter database PA set timezone to 'Australia/Victoria'; create table SETTINGS( ID integer, IMPORT_PATH varchar, STORAGE_PATH varchar, RECYCLE_BIN_PATH varchar, constraint PK_SETTINGS_ID primary key(ID) ); +create table PA_USER( ID integer, dn varchar, constraint PK_PA_USER_ID primary key(ID) ); + create table FILE_TYPE ( ID integer, NAME varchar(32) unique, constraint PK_FILE_TYPE_ID primary key(ID) ); create table PATH_TYPE ( ID integer, NAME varchar(16) unique, constraint PK_PATH_TYPE_ID primary key(ID) ); @@ -38,13 +40,19 @@ create table ENTRY_DIR_LINK ( entry_id integer, dir_eid integer, create table PERSON ( ID integer, TAG varchar(48), FIRSTNAME varchar(48), SURNAME varchar(48), constraint PK_PERSON_ID primary key(ID) ); -create table REFIMG ( ID integer, FNAME varchar(256), ENCODINGS bytea, - CREATED_ON float, +create table REFIMG ( ID integer, FNAME varchar(128), FACE bytea, ORIG_W integer, ORIG_H integer, FACE_LOCN varchar(32), CREATED_ON float, THUMBNAIL varchar, constraint PK_REFIMG_ID primary key(ID) ); -create table FILE_REFIMG_LINK ( FILE_ID integer, REFIMG_ID integer, WHEN_PROCESSED float, MATCHED boolean, - constraint PK_FRL primary key(FILE_ID, REFIMG_ID), - constraint FK_FRL_FILE_ID foreign key (FILE_ID) references FILE(EID), +create table FACE( ID integer, FACE bytea, constraint PK_FACE_ID primary key(ID) ); + +create table FACE_FILE_LINK( FACE_ID integer, FILE_EID integer, + constraint PK_FFL_FACE_ID_FILE_ID primary key(FACE_ID, FILE_EID), + constraint FK_FFL_FACE_ID foreign key (FACE_ID) references FACE(ID) on delete cascade, + constraint FK_FFL_FILE_EID foreign key (FILE_EID) references FILE(EID) ); + +create table FACE_REFIMG_LINK( FACE_ID integer, REFIMG_ID integer, + constraint PK_FRL_FACE_ID_REFIMG_ID primary key(FACE_ID, REFIMG_ID), + constraint FK_FRL_FACE_ID foreign key (FACE_ID) references FACE(ID) on delete cascade, constraint FK_FRL_REFIMG_ID foreign key (REFIMG_ID) references REFIMG(ID) ); create table PERSON_REFIMG_LINK ( PERSON_ID integer, REFIMG_ID integer, @@ -69,6 +77,8 @@ create table PA_JOB_MANAGER_FE_MESSAGE ( ID integer, JOB_ID integer, ALERT varch constraint PA_JOB_MANAGER_FE_ACKS_ID primary key(ID), constraint FK_PA_JOB_MANAGER_FE_MESSAGE_JOB_ID foreign key(JOB_ID) references JOB(ID) ); +create sequence PA_USER_ID_SEQ; +create sequence FACE_ID_SEQ; create sequence PATH_ID_SEQ; create sequence PATH_TYPE_ID_SEQ; create sequence FILE_ID_SEQ; @@ -98,15 +108,7 @@ insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'dad', 'Damien', insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'mum', 'Mandy', 'De Paoli' ); insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'cam', 'Cameron', 'De Paoli' ); insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'mich', 'Michelle', 'De Paoli' ); -insert into REFIMG values ( (select nextval('REFIMG_ID_SEQ')), 'dad.jpg'); -insert into REFIMG values ( (select nextval('REFIMG_ID_SEQ')), 'mum.jpg'); -insert into REFIMG values ( (select nextval('REFIMG_ID_SEQ')), 'cam.jpg'); -insert into REFIMG values ( (select nextval('REFIMG_ID_SEQ')), 'mich.jpg'); -insert into PERSON_REFIMG_LINK values ( 1, 1 ); -insert into PERSON_REFIMG_LINK values ( 2, 2 ); -insert into PERSON_REFIMG_LINK values ( 3, 3 ); -insert into PERSON_REFIMG_LINK values ( 4, 4 ); -- DEV: -insert into SETTINGS ( id, import_path, storage_path, recycle_bin_path ) values ( (select nextval('SETTINGS_ID_SEQ')), '/home/ddp/src/photoassistant/images_to_process/#c:/Users/cam/Desktop/code/python/photoassistant/photos/#/home/ddp/src/photoassistant/new_img_dir/', '/home/ddp/src/photoassistant/storage/', '/home/ddp/src/photoassistant/.pa_bin/' ); +insert into SETTINGS ( id, import_path, storage_path, recycle_bin_path ) values ( (select nextval('SETTINGS_ID_SEQ')), '/home/ddp/src/photoassistant/images_to_process/#c:/Users/cam/Desktop/code/python/photoassistant/photos/#/home/ddp/src/photoassistant/new_img_dir/', '/home/ddp/src/photoassistant/storage/#c:/Users/cam/Desktop/code/python/photoassistant/storage/', '/home/ddp/src/photoassistant/.pa_bin/#c:/Users/cam/Desktop/code/python/photoassistant/.pa_bin/' ); -- PROD: --insert into SETTINGS ( id, import_path, storage_path, recycle_bin_path ) values ( (select nextval('SETTINGS_ID_SEQ')), '/export/docker/storage/Camera_uploads/', '/export/docker/storage/photos/', '/export/docker/storage/.pa_bin/' ); diff --git a/templates/aistats.html b/templates/aistats.html index 0797cb6..963c781 100644 --- a/templates/aistats.html +++ b/templates/aistats.html @@ -3,13 +3,19 @@ {% block main_content %}

Basic AI stats

- - {% for e in entries %} - + + + + + + + +
FileAI Matched people
{{e.name}} - {% for p in e.people %} - {{p.tag}} - {% endfor %} -
WhatAmount
Files with a face{{fstats['files_with_a_face']}}
Files with a matched face{{fstats['files_with_a_match']}}
Files with missing matches{{fstats['files_with_missing_matches']}}
All faces found{{fstats['all_faces']}}
All faces matched{{fstats['all_matched_faces']}}
All faces unmatched{{fstats['all_unmatched_faces']}}
+ + + + {% for s in stats %} + {% endfor %}
Person (tag)Number of files matched
{{s[0]}}{{s[1]}}
{% endblock main_content %} diff --git a/templates/base.html b/templates/base.html index 64e9bd6..e4447b6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -49,7 +49,11 @@