Merge branch 'master' of 192.168.0.2:photoassistant

This commit is contained in:
Damien De Paoli
2021-07-12 19:24:09 +10:00
25 changed files with 856 additions and 394 deletions

View File

@@ -5,4 +5,3 @@ new_img_dir
static/Bin/* static/Bin/*
static/Import/* static/Import/*
static/Storage/* static/Storage/*
reference_images/*

2
BUGs
View File

@@ -1 +1 @@
### Next: 39 ### Next: 44

View File

@@ -22,5 +22,6 @@ RUN pip3 install --upgrade pillow --user
EXPOSE 443 EXPOSE 443
EXPOSE 55432 EXPOSE 55432
COPY . . COPY . .
RUN chown -R mythtv:mythtv ./static RUN chown mythtv:mythtv ./static
RUN chown mythtv:mythtv ./static/*
CMD ["./wrapper.sh"] CMD ["./wrapper.sh"]

37
README
View File

@@ -1,5 +1,10 @@
In here we can put instructions on how to run this / any general info 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: ubuntu packages:
sudo apt-get install -y mediainfo cmake python3-flask sudo apt-get install -y mediainfo cmake python3-flask
@@ -21,10 +26,16 @@ pip packages:
upstream packages... upstream packages...
mkdir static/upstream mkdir static/upstream
cd static/upstream cd static/upstream
mkdir bootstrap-4.6.0-dist
cd bootstrap-4.6.0-dist
mkdir css
# for boostrap: # 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/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 # for jquery
https://code.jquery.com/jquery-3.6.0.min.js 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 gunicorn --bind="192.168.0.2:5000" --threads=2 --workers=2 main:app
Also have to run the job manager for jobs to work: 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: 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 # 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 ) ( 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' );

46
TODO
View File

@@ -1,26 +1,26 @@
## GENERAL ## GENERAL
* incorporate flask-login and flask-ldap3-login * allow rotate of image (permanently on FS, so its right everywhere)
https://flask-login.readthedocs.io/en/latest/
https://flask-ldap3-login.readthedocs.io/en/latest/ * improve photo browser -> view file, rather than just allowing browser to show image
https://pythonhosted.org/Flask-Principal/
# this is an example: * face locations:
https://code.tutsplus.com/tutorials/flask-authentication-with-ldap--cms-23101 START FORM SCRATCH so all images have face_locn data
* user management scope right now GenThumb is in shared, and does width, height as well --> in person.py BUT need this for pa_job_manager
- 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 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 * fix up logging in general
* comment your code * comment your code
* more OO goodness :) * more OO goodness :)
## DB ## 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 * Dir can have date in the DB, so we can do Oldest/Newest dirs in Folder view
### BACKEND ### BACKEND
@@ -34,9 +34,6 @@
*** Need to use thread-safe sessions per Thread, half-assed version did not work *** 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, 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) (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 Admin
-> delete old jobs / auto delete jobs older than ??? -> delete old jobs / auto delete jobs older than ???
-> do I want to have admin roles/users?
### UI ### UI
??? ipads can't do selections and contextMenus, do I want to re-factor to cater for this? ??? 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 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 - 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?) 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 (vertical timeline, date has thumbnails (small) horizontally along
a page, etc.? a page, etc.?
@@ -76,12 +73,13 @@
https://www.highcharts.com/demo/heatmap https://www.highcharts.com/demo/heatmap
https://www.highcharts.com/demo/packed-bubble-split 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 ### SORTER
* exif processing? * exif processing?
* location stuff - test a new photo from my camera out * location stuff - test a new photo from my camera out
-- image is in dir, need to look at exifread output -- image is in dir, need to look at exifread output
### FUTURE:
* can emby use nfo for images (for AI/tags?)
-NO sadly

51
ai.py
View File

@@ -5,11 +5,14 @@ from main import db, app, ma
from sqlalchemy import Sequence from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
from files import Entry, File, FileRefimgLink from files import Entry, File
from person import Person, PersonRefimgLink from person import Refimg, Person, PersonRefimgLink
from refimg import Refimg
from flask_login import login_required, current_user 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 # pylint: disable=no-member
################################################################################ ################################################################################
@@ -18,13 +21,35 @@ from flask_login import login_required, current_user
@app.route("/aistats", methods=["GET", "POST"]) @app.route("/aistats", methods=["GET", "POST"])
@login_required @login_required
def aistats(): def aistats():
tmp=db.session.query(Entry,Person).join(File).join(FileRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(FileRefimgLink.matched==True).all() 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" )
entries=[] fstats={}
last_fname="" fstats['files_with_a_face'] = db.session.execute( "select count(distinct file_eid) as count from face_file_link" ).first()[0]
for e, p in tmp: 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]
if last_fname != e.name: 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]
entry = { 'name': e.name, 'people': [] }
entries.append( entry ) # files_with_no_matches?
last_fname = e.name
entry['people'].append( { 'tag': p.tag } ) fstats['all_faces'] = db.session.execute( "select count(distinct face_id) as count from face_file_link" ).first()[0]
return render_template("aistats.html", page_title='Placeholder', entries=entries) 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&nbsp;<a href=/job/{job.id}>Job #{job.id}</a>&nbsp;to Look for face(s) in selected file(s)")
return render_template("base.html")

29
face.py Normal file
View File

@@ -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"<id: {self.id}, face={self.face}"
class FaceFileLink(db.Model):
__tablename__ = "face_file_link"
face_id = db.Column(db.Integer, db.ForeignKey("face.id"), primary_key=True )
file_eid = db.Column(db.Integer, db.ForeignKey("file.eid"), primary_key=True )
def __repr__(self):
return f"<face_id: {self.face_id}, file_eid={self.file_eid}"
class FaceRefimgLink(db.Model):
__tablename__ = "face_refimg_link"
face_id = db.Column(db.Integer, db.ForeignKey("face.id"), primary_key=True )
refimg_id = db.Column(db.Integer, db.ForeignKey("refimg.id"), primary_key=True )
def __repr__(self):
return f"<face_id: {self.face_id}, refimg_id={self.refimg_id}"

View File

@@ -23,11 +23,11 @@ from flask_login import login_required, current_user
################################################################################ ################################################################################
from job import Job, JobExtra, Joblog, NewJob from job import Job, JobExtra, Joblog, NewJob
from path import PathType, Path from path import PathType, Path
from person import Person, PersonRefimgLink from person import Refimg, Person, PersonRefimgLink
from refimg import Refimg
from settings import Settings from settings import Settings
from shared import SymlinkName from shared import SymlinkName
from dups import Duplicates from dups import Duplicates
from face import Face, FaceFileLink, FaceRefimgLink
# pylint: disable=no-member # pylint: disable=no-member
@@ -73,15 +73,6 @@ class Entry(db.Model):
def __repr__(self): def __repr__(self):
return "<id: {}, name: {}, type={}, dir_details={}, file_details={}, in_dir={}>".format(self.id, self.name, self.type, self.dir_details, self.file_details, self.in_dir) return "<id: {}, name: {}, type={}, dir_details={}, file_details={}, in_dir={}>".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"<file_id: {self.file_id}, refimg_id: {self.refimg_id} when_processed={self.when_processed}, matched={self.matched}"
class File(db.Model): class File(db.Model):
__tablename__ = "file" __tablename__ = "file"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True ) eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
@@ -248,17 +239,22 @@ def files_ip():
noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
entries=[] entries=[]
people = Person.query.all()
# per import path, add entries to view # per import path, add entries to view
settings=Settings.query.first() settings=Settings.query.first()
paths = settings.import_path.split("#") paths = settings.import_path.split("#")
for path in paths: for path in paths:
if not os.path.exists(path):
continue
prefix = SymlinkName("Import",path,path+'/') prefix = SymlinkName("Import",path,path+'/')
if folders: if folders:
entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ) entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many )
else: else:
entries+=GetEntriesInFlatView( cwd, prefix, noo, offset, how_many ) entries+=GetEntriesInFlatView( cwd, prefix, noo, offset, how_many )
return render_template("files.html", page_title='View Files (Import 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 (Import Path)', entry_data=entries, noo=noo, grouping=grouping, how_many=how_many, offset=offset, size=size, folders=folders, cwd=cwd, root=root, people=people )
################################################################################ ################################################################################
# /files -> show thumbnail view of files from storage_path # /files -> 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 ) noo, grouping, how_many, offset, size, folders, cwd, root = ViewingOptions( request )
entries=[] entries=[]
people = Person.query.all()
# per storage path, add entries to view # per storage path, add entries to view
settings=Settings.query.first() settings=Settings.query.first()
paths = settings.storage_path.split("#") paths = settings.storage_path.split("#")
for path in paths: for path in paths:
if not os.path.exists(path):
continue
prefix = SymlinkName("Storage",path,path+'/') prefix = SymlinkName("Storage",path,path+'/')
if folders: if folders:
entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ) entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many )
else: else:
entries+=GetEntriesInFlatView( cwd, prefix, noo, offset, how_many ) 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() settings=Settings.query.first()
paths = settings.recycle_bin_path.split("#") paths = settings.recycle_bin_path.split("#")
for path in paths: for path in paths:
if not os.path.exists(path):
continue
prefix = SymlinkName("Bin",path,path+'/') prefix = SymlinkName("Bin",path,path+'/')
if folders: if folders:
entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many ) entries+=GetEntriesInFolderView( cwd, prefix, noo, offset, how_many )
@@ -315,11 +317,16 @@ def search():
# always show flat results for search to start with # always show flat results for search to start with
folders=False 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() term=request.form['term']
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() if 'AI:' in term:
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() 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()
all_entries = file_data + dir_data + ai_data 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 ) 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 )

12
job.py
View File

@@ -105,18 +105,24 @@ def jobs():
############################################################################### ###############################################################################
# /job/<id> -> GET -> shows status/history of jobs # /job/<id> -> GET -> shows status/history of jobs
################################################################################ ################################################################################
@app.route("/job/<id>", methods=["GET"]) @app.route("/job/<id>", methods=["GET","POST"])
@login_required @login_required
def joblog(id): def joblog(id):
page_title='Show Job Details' page_title='Show Job Details'
joblog = Job.query.get(id) 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": if joblog.pa_job_state == "Completed":
duration=(joblog.last_update-joblog.start_time) duration=(joblog.last_update-joblog.start_time)
else: else:
duration=(datetime.now(pytz.utc)-joblog.start_time) duration=(datetime.now(pytz.utc)-joblog.start_time)
duration= duration-timedelta(microseconds=duration.microseconds) 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/<id> -> GET -> shows status/history of jobs # /job/<id> -> GET -> shows status/history of jobs

38
main.py
View File

@@ -7,14 +7,13 @@ from wtforms import SubmitField, StringField, HiddenField, SelectField, IntegerF
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from status import st, Status from status import st, Status
from shared import CreateSelect, CreateFoldersSelect, LocationIcon, DB_URL from shared import CreateSelect, CreateFoldersSelect, LocationIcon, DB_URL
from flask_login import login_required, current_user
# for ldap auth # for ldap auth
from flask_ldap3_login import LDAP3LoginManager 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 from flask_ldap3_login.forms import LDAPLoginForm
import os
import re import re
import socket import socket
@@ -22,13 +21,16 @@ import socket
####################################### Flask App globals ####################################### ####################################### Flask App globals #######################################
PROD_HOST="pa_web" PROD_HOST="pa_web"
hostname = socket.gethostname() hostname = socket.gethostname()
print( "Running on: {}".format( hostname) ) print( "Running on: {}".format( hostname) )
app = Flask(__name__) app = Flask(__name__)
### what is this value? I gather I should change it? ### what is this value? I gather I should change it?
app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL app.config['SQLALCHEMY_DATABASE_URI'] = DB_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 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') 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) # 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. ldap_manager = LDAP3LoginManager(app) # Setup a LDAP3 Login Manager.
login_manager.login_view = "login" # default login route, failed with url_for, so hard-coded 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 ################################### ################################# Now, import non-book classes ###################################
from settings import Settings from settings import Settings
from files import Entry, GetJM_Message, ClearJM_Message from files import Entry, GetJM_Message, ClearJM_Message
from person import Person from person import Person
from refimg import Refimg
from job import Job, GetNumActiveJobs from job import Job, GetNumActiveJobs
from ai import aistats from ai import aistats
from path import StoragePathNames from path import StoragePathNames
from user import PAUser
####################################### GLOBALS ####################################### ####################################### GLOBALS #######################################
# allow jinja2 to call these python functions directly # allow jinja2 to call these python functions directly
@@ -98,9 +94,8 @@ class User(UserMixin):
# returns None. # returns None.
@login_manager.user_loader @login_manager.user_loader
def load_user(id): def load_user(id):
if id in users: pau=PAUser.query.filter(PAUser.dn==id).first()
return users[id] return pau
return None
# Declare The User Saver for Flask-Ldap3-Login # Declare The User Saver for Flask-Ldap3-Login
# This method is called whenever a LDAPLoginForm() successfully validates. # This method is called whenever a LDAPLoginForm() successfully validates.
@@ -108,9 +103,14 @@ def load_user(id):
# login controller. # login controller.
@ldap_manager.save_user @ldap_manager.save_user
def save_user(dn, username, data, memberships): def save_user(dn, username, data, memberships):
user = User(dn, username, data) pau=PAUser.query.filter(PAUser.dn==dn).first()
users[dn] = user # if we already have a valid user/session, and say the web has restarted, just re-use it, dont make more users
return user if pau:
return pau
pau=PAUser(dn=dn)
db.session.add(pau)
db.session.commit()
return pau
# default page, just the navbar # default page, just the navbar
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@@ -129,9 +129,15 @@ def login():
form = LDAPLoginForm() form = LDAPLoginForm()
form.submit.label.text="Login" 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(): if form.validate_on_submit():
# Successfully logged in, We can now access the saved user object # Successfully logged in, We can now access the saved user object
# via form.user. # via form.user.
print( f"form user = {form.user}" )
login_user(form.user, remember=True) # Tell flask-login to log them in. login_user(form.user, remember=True) # Tell flask-login to log them in.
next = request.args.get("next") next = request.args.get("next")
if next: if next:

View File

@@ -137,25 +137,19 @@ class Entry(Base):
in_dir = relationship ("Dir", secondary="entry_dir_link", uselist=False ) in_dir = relationship ("Dir", secondary="entry_dir_link", uselist=False )
def FullPathOnFS(self): def FullPathOnFS(self):
s=self.in_dir.in_path.path_prefix + '/' if self.in_dir:
if len(self.in_dir.rel_path) > 0: s=self.in_dir.in_path.path_prefix + '/'
s += self.in_dir.rel_path + '/' 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 s += self.name
return s return s
def __repr__(self): def __repr__(self):
return f"<id: {self.id}, name: {self.name}, type={self.type}, exists_on_fs={self.exists_on_fs}, dir_details={self.dir_details}, file_details={self.file_details}, in_dir={self.in_dir}>" return f"<id: {self.id}, name: {self.name}, type={self.type}, exists_on_fs={self.exists_on_fs}, dir_details={self.dir_details}, file_details={self.file_details}, in_dir={self.in_dir}>"
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"<file_id: {self.file_id}, refimg_id: {self.refimg_id} when_processed={self.when_processed}, matched={self.matched}"
class File(Base): class File(Base):
__tablename__ = "file" __tablename__ = "file"
eid = Column(Integer, ForeignKey("entry.id"), primary_key=True ) eid = Column(Integer, ForeignKey("entry.id"), primary_key=True )
@@ -181,7 +175,6 @@ class DelFile(Base):
def __repr__(self): def __repr__(self):
return f"<file_eid: {self.file_eid}, orig_path_prefix={self.orig_path_prefix}>" return f"<file_eid: {self.file_eid}, orig_path_prefix={self.orig_path_prefix}>"
class FileType(Base): class FileType(Base):
__tablename__ = "file_type" __tablename__ = "file_type"
id = Column(Integer, Sequence('file_type_id_seq'), primary_key=True ) id = Column(Integer, Sequence('file_type_id_seq'), primary_key=True )
@@ -223,11 +216,39 @@ class Refimg(Base):
__tablename__ = "refimg" __tablename__ = "refimg"
id = Column(Integer, Sequence('refimg_id_seq'), primary_key=True ) id = Column(Integer, Sequence('refimg_id_seq'), primary_key=True )
fname = Column(String(256), unique=True, nullable=False) 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) created_on = Column(Float)
orig_w = Column(Integer)
orig_h = Column(Integer)
face_locn = Column(String)
def __repr__(self): def __repr__(self):
return f"<id: {self.id}, fname: {self.fname}, created_on: {self.created_on}, encodings: {self.encodings}>" return f"<id: {self.id}, fname: {self.fname}, created_on: {self.created_on}>"
class Face(Base):
__tablename__ = "face"
id = Column(Integer, Sequence('face_id_seq'), primary_key=True )
face = Column( LargeBinary )
def __repr__(self):
return f"<id: {self.id}, face={self.face}"
class FaceFileLink(Base):
__tablename__ = "face_file_link"
face_id = Column(Integer, ForeignKey("face.id"), primary_key=True )
file_eid = Column(Integer, ForeignKey("file.eid"), primary_key=True )
def __repr__(self):
return f"<face_id: {self.face_id}, file_eid={self.file_eid}"
class FaceRefimgLink(Base):
__tablename__ = "face_refimg_link"
face_id = Column(Integer, ForeignKey("face.id"), primary_key=True )
refimg_id = Column(Integer, ForeignKey("refimg.id"), primary_key=True )
def __repr__(self):
return f"<face_id: {self.face_id}, refimg_id={self.refimg_id}"
@@ -293,13 +314,23 @@ def MessageToFE( job_id, alert, message ):
session.commit() session.commit()
return msg.id return msg.id
def ProcessRecycleBinDir(parent_job): def ProcessRecycleBinDir(job):
settings = session.query(Settings).first() settings = session.query(Settings).first()
if settings == None: if settings == None:
raise Exception("Cannot create file data with no settings / recycle bin path is missing") raise Exception("Cannot create file data with no settings / recycle bin path is missing")
paths = settings.recycle_bin_path.split("#")
ptype = session.query(PathType).filter(PathType.name=='Bin').first() ptype = session.query(PathType).filter(PathType.name=='Bin').first()
JobsForPaths( parent_job, paths, ptype ) paths = settings.recycle_bin_path.split("#")
for path in paths:
if not os.path.exists( path ):
AddLogForJob( job, f"Not Importing {path} -- Path does not exist" )
continue
symlink=SymlinkName(ptype.name, path, path)
# create the Path (and Dir objects for the Bin)
AddPath( job, symlink, ptype.id )
session.commit()
return return
def ProcessStorageDirs(parent_job): def ProcessStorageDirs(parent_job):
@@ -380,7 +411,16 @@ def AddLogForJob(job, message):
session.add(log) session.add(log)
# some logs have DEBUG: in front, so clean that up # some logs have DEBUG: in front, so clean that up
message = message.replace("DEBUG:", "" ) message = message.replace("DEBUG:", "" )
print( f"DEBUG: {message}" ) # if its been more than 5 seconds since our last log, then commit to the DB to show some progress
if hasattr(job, 'last_commit'):
if (now - job.last_commit).seconds > 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 return
def RunJob(job): def RunJob(job):
@@ -408,6 +448,8 @@ def RunJob(job):
JobRestoreFiles(job) JobRestoreFiles(job)
elif job.name == "processai": elif job.name == "processai":
JobProcessAI(job) JobProcessAI(job)
elif job.name == "run_ai_on":
JobRunAIOn(job)
else: else:
print("ERROR: Requested to process unknown job type: {}".format(job.name)) 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... # 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): def JobForceScan(job):
JobProgressState( job, "In Progress" ) JobProgressState( job, "In Progress" )
session.query(FaceFileLink).delete()
session.query(FaceRefimgLink).delete()
session.query(DelFile).delete() session.query(DelFile).delete()
session.query(FileRefimgLink).delete()
session.query(EntryDirLink).delete() session.query(EntryDirLink).delete()
session.query(PathDirLink).delete() session.query(PathDirLink).delete()
session.query(Path).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)") FinishJob(job, f"Finished Importing: {path} - Processed {overall_file_cnt} files, Removed {rm_cnt} file(s)")
return 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() 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() files = session.query(Entry).join(EntryDirLink).filter(EntryDirLink.dir_eid==d.eid).all()
for e in files: for e in files:
ProcessFilesInDir(job, e, file_func) ProcessFilesInDir(job, e, file_func, count_dirs)
return return
@@ -908,15 +951,76 @@ def JobProcessAI(job):
p = session.query(Path).filter(Path.path_prefix==path).first() p = session.query(Path).filter(Path.path_prefix==path).first()
job.num_files=p.num_files job.num_files=p.num_files
people = session.query(Person).all() RunFuncOnFilesInPath( job, path, ProcessAI, True )
for person in people:
generateKnownEncodings(person)
RunFuncOnFilesInPath( job, path, ProcessAI )
FinishJob(job, "Finished Processesing AI") FinishJob(job, "Finished Processesing AI")
return 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): def GenHashAndThumb(job, e):
# commit every 100 files to see progress being made but not hammer the database # 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 job.current_file_num+=1
return return
file = e.FullPathOnFS() file = e.FullPathOnFS()
stat = os.stat(file) stat = os.stat(file)
# find if file is newer than when we found faces before (fyi: first time faces_created_on == 0) # 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: if stat.st_ctime > e.file_details.faces_created_on:
@@ -980,27 +1084,9 @@ def ProcessAI(job, e):
return return
def lookForPersonInImage(job, person, unknown_encoding, e): def lookForPersonInImage(job, person, unknown_encoding, e):
for refimg in person.refimg: FinishJob( job, "THIS CODE HAS BEEN REMOVED, need to use new Face* tables, and rethink", "Failed" )
# lets see if we have tried this check before return
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
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): def generateUnknownEncodings(im):
unknown_image = numpy.array(im) unknown_image = numpy.array(im)
@@ -1011,37 +1097,23 @@ def generateUnknownEncodings(im):
return unknown_encodings 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): def compareAI(known_encoding, unknown_encoding):
results = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.55) results = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.55)
return results return results
def ProcessFilesInDir(job, e, file_func): def ProcessFilesInDir(job, e, file_func, count_dirs):
if DEBUG==1: if DEBUG==1:
print( f"DEBUG: ProcessFilesInDir: {e.FullPathOnFS()}") print( f"DEBUG: ProcessFilesInDir: {e.FullPathOnFS()}")
if e.type.name != 'Directory': if e.type.name != 'Directory':
file_func(job, e) file_func(job, e)
else: else:
d=session.query(Dir).filter(Dir.eid==e.id).first() 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() files = session.query(Entry).join(EntryDirLink).filter(EntryDirLink.dir_eid==d.eid).all()
for sub in files: for sub in files:
ProcessFilesInDir(job, sub, file_func) ProcessFilesInDir(job, sub, file_func, count_dirs)
return return
def JobGetFileDetails(job): def JobGetFileDetails(job):
@@ -1054,7 +1126,7 @@ def JobGetFileDetails(job):
job.current_file_num = 0 job.current_file_num = 0
job.num_files = p.num_files job.num_files = p.num_files
session.commit() session.commit()
RunFuncOnFilesInPath( job, path_prefix, GenHashAndThumb ) RunFuncOnFilesInPath( job, path_prefix, GenHashAndThumb, True )
FinishJob(job, "File Details job finished") FinishJob(job, "File Details job finished")
session.commit() session.commit()
return return
@@ -1086,8 +1158,7 @@ def isImage(file):
except: except:
return False return False
def GenImageThumbnail(job, file): def GenThumb(file):
ProcessFileForJob( job, "Generate Thumbnail from Image file: {}".format( file ), file )
try: try:
im_orig = Image.open(file) im_orig = Image.open(file)
im = ImageOps.exif_transpose(im_orig) im = ImageOps.exif_transpose(im_orig)
@@ -1100,10 +1171,14 @@ def GenImageThumbnail(job, file):
img_bytearray = img_bytearray.getvalue() img_bytearray = img_bytearray.getvalue()
thumbnail = base64.b64encode(img_bytearray) thumbnail = base64.b64encode(img_bytearray)
thumbnail = str(thumbnail)[2:-1] thumbnail = str(thumbnail)[2:-1]
return thumbnail
except Exception as e: except Exception as e:
AddLogForJob(job, f"WARNING: No EXIF TAF found for: {file} - error={e}") AddLogForJob(job, f"WARNING: No EXIF TAF found for: {file} - error={e}")
return None 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): def GenVideoThumbnail(job, file):
ProcessFileForJob( job, "Generate Thumbnail from Video file: {}".format( file ), file ) ProcessFileForJob( job, "Generate Thumbnail from Video file: {}".format( file ), file )
@@ -1251,7 +1326,7 @@ def JobDeleteFiles(job):
if 'eid-' in jex.name: if 'eid-' in jex.name:
del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first() del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
MoveFileToRecycleBin(job,del_me) 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 ) 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) session.add(next_job)
MessageToFE( job.id, "success", "Completed (delete of selected files)" ) MessageToFE( job.id, "success", "Completed (delete of selected files)" )
@@ -1292,6 +1367,10 @@ def InitialValidationChecks():
break break
if not rbp_exists: if not rbp_exists:
AddLogForJob(job, "ERROR: The bin path in settings does not exist - Please fix now"); 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 sp_exists=0
paths = settings.storage_path.split("#") paths = settings.storage_path.split("#")
for path in paths: for path in paths:
@@ -1313,9 +1392,73 @@ def InitialValidationChecks():
if not rbp_exists or not sp_exists or not ip_exists: 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" ) 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) exit(-1)
FinishJob(job,"Finished Initial Validation Checks") FinishJob(job,"Finished Initial Validation Checks")
return 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__": if __name__ == "__main__":
print("INFO: PA job manager starting - listening on {}:{}".format( PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT) ) print("INFO: PA job manager starting - listening on {}:{}".format( PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT) )

View File

@@ -1,18 +1,36 @@
from wtforms import SubmitField, StringField, HiddenField, validators, Form from wtforms import SubmitField, StringField, HiddenField, validators, Form
from flask_wtf import FlaskForm 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 main import db, app, ma
from sqlalchemy import Sequence from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from status import st, Status from status import st, Status
from refimg import Refimg
from flask_login import login_required, current_user 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 # pylint: disable=no-member
################################################################################ ################################################################################
# Class describing Person in the database, and via sqlalchemy, connected to the DB as well # 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 "<id: {}, fname: {}>".format(self.id, self.fname )
class PersonRefimgLink(db.Model): class PersonRefimgLink(db.Model):
__tablename__ = "person_refimg_link" __tablename__ = "person_refimg_link"
person_id = db.Column(db.Integer, db.ForeignKey('person.id'), unique=True, nullable=False, primary_key=True) 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) tag = db.Column(db.String(48), unique=False, nullable=False)
surname = 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) 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): def __repr__(self):
return "<tag: {}, firstname: {}, surname: {}, refimg: {}>".format(self.tag,self.firstname, self.surname, self.refimg) return "<tag: {}, firstname: {}, surname: {}, refimg: {}>".format(self.tag,self.firstname, self.surname, self.refimg)
@@ -48,7 +66,7 @@ class PersonForm(FlaskForm):
tag = StringField('Tag (searchable name):', [validators.DataRequired()]) tag = StringField('Tag (searchable name):', [validators.DataRequired()])
firstname = StringField('FirstName(s):', [validators.DataRequired()]) firstname = StringField('FirstName(s):', [validators.DataRequired()])
surname = StringField('Surname:', [validators.DataRequired()]) surname = StringField('Surname:', [validators.DataRequired()])
submit = SubmitField('Save' ) save = SubmitField('Save' )
delete = SubmitField('Delete' ) delete = SubmitField('Delete' )
################################################################################ ################################################################################
@@ -71,22 +89,20 @@ def persons():
def new_person(): def new_person():
form = PersonForm(request.form) form = PersonForm(request.form)
page_title='Create new Person' page_title='Create new Person'
reference_imgs = Refimg.query.all()
if 'surname' not in request.form: 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: else:
person = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] ) person = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] )
try: try:
db.session.add(person) db.session.add(person)
db.session.commit() db.session.commit()
print(person)
st.SetMessage( "Created new Person ({})".format(person.tag) ) st.SetMessage( "Created new Person ({})".format(person.tag) )
return redirect( '/persons' ) return redirect( f'/person/{person.id}' )
except SQLAlchemyError as e: except SQLAlchemyError as e:
st.SetAlert( "danger" ) st.SetAlert( "danger" )
st.SetMessage( "<b>Failed to add Person:</b>&nbsp;{}".format(e.orig) ) st.SetMessage( "<b>Failed to add Person:</b>&nbsp;{}".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/<id> -> GET/POST(save or delete) -> shows/edits/delets a single # /person/<id> -> GET/POST(save or delete) -> shows/edits/delets a single
@@ -97,34 +113,76 @@ def new_person():
def person(id): def person(id):
form = PersonForm(request.form) form = PersonForm(request.form)
page_title='Edit Person' page_title='Edit Person'
reference_imgs = Refimg.query.all()
if request.method == 'POST': if request.method == 'POST':
try: try:
person = Person.query.get(id) person = Person.query.get(id)
if 'delete' in request.form: if 'delete' in request.form:
st.SetMessage("Successfully deleted Person: ({})".format( person.tag ) ) 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() 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) ) st.SetMessage("Successfully Updated Person: (From: {}, {}, {})".format(person.tag, person.firstname, person.surname) )
person.tag = request.form['tag'] person.tag = request.form['tag']
person.surname = request.form['surname'] person.surname = request.form['surname']
person.firstname = request.form['firstname'] person.firstname = request.form['firstname']
person.refimg =[] new_refs=[]
for ref_img in reference_imgs: for ref_img in person.refimg:
if "ref-img-id-{}".format(ref_img.id) in request.form: if "ref-img-id-{}".format(ref_img.id) in request.form:
print('{} was checked, id: {}'.format(ref_img.fname, ref_img.id)) new_refs.append(ref_img)
person.refimg.append(ref_img) person.refimg = new_refs
db.session.add(person)
st.AppendMessage(" To: ({}, {}, {})".format(person.tag, person.firstname, person.surname) ) st.AppendMessage(" To: ({}, {}, {})".format(person.tag, person.firstname, person.surname) )
db.session.commit() db.session.commit()
return redirect( '/persons' ) return redirect( f'/person/{person.id}' )
except SQLAlchemyError as e: except SQLAlchemyError as e:
st.SetAlert( "danger" ) st.SetAlert( "danger" )
st.SetMessage( "<b>Failed to modify Person:</b>&nbsp;{}".format(e) ) st.SetMessage( "<b>Failed to modify Person:</b>&nbsp;{}".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: else:
person = Person.query.get(id) 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) 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"<b>Failed to add Refimg:</b>&nbsp;{e.orig}" )
except Exception as e:
st.SetAlert( "danger" )
st.SetMessage( f"<b>Failed to modify Refimg:</b>&nbsp;{e}" )
return redirect( url_for( 'person', id=person.id) )

124
refimg.py
View File

@@ -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 "<id: {}, fname: {}>".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 "<person_id: {}, refimg_id>".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 <form>, 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( "<b>Failed to add Refimg:</b>&nbsp;{}".format(e.orig) )
except Exception as e:
st.SetAlert( "danger" )
st.SetMessage( "<b>Failed to modify Refimg:</b>&nbsp;{}".format(e) )
return render_template("refimg.html", form=form, page_title=page_title)
################################################################################
# /refimg/<id> -> GET/POST(save or delete) -> shows/edits/delets a single
# refimg
################################################################################
@app.route("/refimg/<id>", 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( "<b>Failed to modify Refimg:</b>&nbsp;{}".format(e.orig) )
except Exception as e:
st.SetAlert( "danger" )
st.SetMessage( "<b>Failed to modify Refimg:</b>&nbsp;{}".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)

View File

@@ -1,5 +1,9 @@
import socket import socket
import os import os
import face_recognition
import io
import base64
from PIL import Image, ImageOps
hostname = socket.gethostname() hostname = socket.gethostname()
PROD_HOST="pa_web" PROD_HOST="pa_web"
@@ -9,15 +13,18 @@ ICON["Import"]="fa-file-upload"
ICON["Storage"]="fa-database" ICON["Storage"]="fa-database"
ICON["Bin"]="fa-trash-alt" ICON["Bin"]="fa-trash-alt"
if hostname == PROD_HOST: if hostname == "lappy":
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":
PA_JOB_MANAGER_HOST="localhost" PA_JOB_MANAGER_HOST="localhost"
DB_URL = 'postgresql+psycopg2://pa:for_now_pa@localhost:5432/pa' 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" 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 PA_JOB_MANAGER_PORT=55430
@@ -70,3 +77,34 @@ def SymlinkName(ptype, path, file):
if symlink[-1] == '/': if symlink[-1] == '/':
symlink=symlink[0:-1] symlink=symlink[0:-1]
return symlink 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

View File

@@ -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 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 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) ); 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), create table PERSON ( ID integer, TAG varchar(48), FIRSTNAME varchar(48), SURNAME varchar(48),
constraint PK_PERSON_ID primary key(ID) ); constraint PK_PERSON_ID primary key(ID) );
create table REFIMG ( ID integer, FNAME varchar(256), ENCODINGS bytea, 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,
CREATED_ON float,
constraint PK_REFIMG_ID primary key(ID) ); constraint PK_REFIMG_ID primary key(ID) );
create table FILE_REFIMG_LINK ( FILE_ID integer, REFIMG_ID integer, WHEN_PROCESSED float, MATCHED boolean, create table FACE( ID integer, FACE bytea, constraint PK_FACE_ID primary key(ID) );
constraint PK_FRL primary key(FILE_ID, REFIMG_ID),
constraint FK_FRL_FILE_ID foreign key (FILE_ID) references FILE(EID), 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) ); constraint FK_FRL_REFIMG_ID foreign key (REFIMG_ID) references REFIMG(ID) );
create table PERSON_REFIMG_LINK ( PERSON_ID integer, REFIMG_ID integer, 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 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) ); 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_ID_SEQ;
create sequence PATH_TYPE_ID_SEQ; create sequence PATH_TYPE_ID_SEQ;
create sequence FILE_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')), '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')), 'cam', 'Cameron', 'De Paoli' );
insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'mich', 'Michelle', '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: -- 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: -- 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/' ); --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/' );

View File

@@ -3,13 +3,19 @@
{% block main_content %} {% block main_content %}
<h3>Basic AI stats</h3> <h3>Basic AI stats</h3>
<table class="table table-striped table-sm"> <table class="table table-striped table-sm">
<tbody><thead class="thead-light"><tr><th>File</th><th>AI Matched people</th></thead> <tbody><thead class="thead-light"><tr><th>What</th><th>Amount</th></tr></thead>
{% for e in entries %} <tr><td>Files with a face</td><td>{{fstats['files_with_a_face']}}</td></tr>
<tr><td>{{e.name}}</td><td> <tr><td>Files with a matched face</td><td>{{fstats['files_with_a_match']}}</td></tr>
{% for p in e.people %} <tr><td>Files with missing matches</td><td>{{fstats['files_with_missing_matches']}}</td></tr>
{{p.tag}} <tr><td>All faces found</td><td>{{fstats['all_faces']}}</td></tr>
{% endfor %} <tr><td>All faces matched</td><td>{{fstats['all_matched_faces']}}</td></tr>
</td></tr> <tr><td>All faces unmatched</td><td>{{fstats['all_unmatched_faces']}}</td></tr>
</tbody></table>
<table class="table table-striped table-sm">
<tbody><thead class="thead-light"><tr><th>Person (tag)</th><th>Number of files matched</th></thead>
{% for s in stats %}
<tr><td>{{s[0]}}</td><td>{{s[1]}}</td></tr>
{% endfor %} {% endfor %}
</tbody></table> </tbody></table>
{% endblock main_content %} {% endblock main_content %}

View File

@@ -49,7 +49,11 @@
</div> </div>
<nav class="navbar navbar-expand-lg navbar-light bg-light justify-content-between"> <nav class="navbar navbar-expand-lg navbar-light bg-light justify-content-between">
<a class="navbar-brand" href="/">Photo Assistant</a> {% if config.env == "Production" %}
<a class="navbar-brand" href="/">Photo Assistant</a>
{% else %}
<a class="navbar-brand bg-secondary text-white px-2" style="border-radius:4px" href="/">PA (DEV)</a>
{% endif %}
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@@ -71,13 +75,6 @@
<a class="dropdown-item" href="{{url_for('persons')}}">Show People</a> <a class="dropdown-item" href="{{url_for('persons')}}">Show People</a>
</div> </div>
</div class="nav-item dropdown"> </div class="nav-item dropdown">
<div class="nav-item dropdown">
<a class="nav-item dropdown nav-link dropdown-toggle" href="#" id="RefMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Ref Image</a>
<div class="dropdown-menu" aria-labelledby="AIMenu">
<a class="dropdown-item" href="{{url_for('new_refimg')}}">Create Reference Image</a>
<a class="dropdown-item" href="{{url_for('refimgs')}}">View Reference Images</a>
</div>
</div class="nav-item dropdown">
<div class="nav-item dropdown"> <div class="nav-item dropdown">
<a class="nav-item dropdown nav-link dropdown-toggle" href="#" id="AIMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">AI</a> <a class="nav-item dropdown nav-link dropdown-toggle" href="#" id="AIMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">AI</a>
<div class="dropdown-menu" aria-labelledby="AIMenu"> <div class="dropdown-menu" aria-labelledby="AIMenu">

View File

@@ -7,7 +7,7 @@
<div class="form-group"> <div class="form-group">
<label for="pagesize">{{DD.total_dups}} duplicate files ({{DD.uniq_dups}} Unique) -- Showing&nbsp;</label> <label for="pagesize">{{DD.total_dups}} duplicate files ({{DD.uniq_dups}} Unique) -- Showing&nbsp;</label>
<select id="pagesize" class="form form-control" name="pagesize" onChange="ResetPageSize()"> <select id="pagesize" class="form form-control" name="pagesize" onChange="ResetPageSize()">
{% for o in "5", "10", "15", "20", "25", "50", "75", "100", "200" %} {% for o in "5", "10", "15", "20", "25", "50", "75", "100", "200", "500", "1000", "5000", "20000" %}
<option <option
{% if o|int == pagesize %} {% if o|int == pagesize %}
selected selected

View File

@@ -12,7 +12,6 @@
caption-side: bottom; caption-side: bottom;
} }
</style> </style>
<div class="container-fluid"> <div class="container-fluid">
<form id="main_form" method="POST"> <form id="main_form" method="POST">
<input type="hidden" name="cwd" id="cwd" value="{{cwd}}"> <input type="hidden" name="cwd" id="cwd" value="{{cwd}}">
@@ -122,7 +121,7 @@
{# rare event of empty folder, still need to show back button #} {# rare event of empty folder, still need to show back button #}
{% if folders and entry_data|length == 0 %} {% if folders and entry_data|length == 0 %}
{% if cwd != root %} {% if cwd != root %}
<figure class="px-1 dir" dir="{{cwd|ParentPath}}"> <figure class="px-1 dir entry" ecnt="1" dir="{{cwd|ParentPath}}">
<span style="font-size:{{(size|int-22)/2}}" class="fa-stack"> <span style="font-size:{{(size|int-22)/2}}" class="fa-stack">
<i style="color:grey" class="fas fa-folder fa-stack-2x"></i> <i style="color:grey" class="fas fa-folder fa-stack-2x"></i>
<i class="fas fa-level-up-alt fa-flip-horizontal fa-stack-1x fa-inverse"></i> <i class="fas fa-level-up-alt fa-flip-horizontal fa-stack-1x fa-inverse"></i>
@@ -140,7 +139,7 @@
{% for obj in entry_data %} {% for obj in entry_data %}
{% if loop.index==1 and folders %} {% if loop.index==1 and folders %}
{% if cwd != root %} {% if cwd != root %}
<figure class="px-1 dir" dir="{{cwd|ParentPath}}"> <figure class="px-1 dir entry" ecnt="{{loop.index}}" dir="{{cwd|ParentPath}}">
<span style="font-size:{{(size|int-22)/2}}" class="fa-stack"> <span style="font-size:{{(size|int-22)/2}}" class="fa-stack">
<i style="color:grey" class="fas fa-folder fa-stack-2x"></i> <i style="color:grey" class="fas fa-folder fa-stack-2x"></i>
<i class="fas fa-level-up-alt fa-flip-horizontal fa-stack-1x fa-inverse"></i> <i class="fas fa-level-up-alt fa-flip-horizontal fa-stack-1x fa-inverse"></i>
@@ -188,7 +187,7 @@
{% endif %} {% endif %}
{% if obj.type.name != "Directory" %} {% if obj.type.name != "Directory" %}
{% if (not folders) or ((obj.in_dir.in_path.path_prefix+'/'+obj.in_dir.rel_path+'/'+obj.name) | TopLevelFolderOf(cwd)) %} {% if (not folders) or ((obj.in_dir.in_path.path_prefix+'/'+obj.in_dir.rel_path+'/'+obj.name) | TopLevelFolderOf(cwd)) %}
<figure id="{{obj.id}}" img="{{loop.index-1}}" class="figure mx-1" path_type="{{obj.in_dir.in_path.type.name}}" size="{{obj.file_details.size_mb}}" hash="{{obj.file_details.hash}}" in_dir="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}" fname="{{obj.name}}" yr="{{obj.file_details.year}}" date="{{obj.file_details.year}}{{"%02d" % obj.file_details.month}}{{"%02d" % obj.file_details.day}}" pretty_date="{{obj.file_details.day}}/{{obj.file_details.month}}/{{obj.file_details.year}}"> <figure id="{{obj.id}}" ecnt="{{loop.index}}" class="figure entry mx-1" path_type="{{obj.in_dir.in_path.type.name}}" size="{{obj.file_details.size_mb}}" hash="{{obj.file_details.hash}}" in_dir="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}" fname="{{obj.name}}" yr="{{obj.file_details.year}}" date="{{obj.file_details.year}}{{"%02d" % obj.file_details.month}}{{"%02d" % obj.file_details.day}}" pretty_date="{{obj.file_details.day}}/{{obj.file_details.month}}/{{obj.file_details.year}}">
{% if obj.type.name=="Image" %} {% if obj.type.name=="Image" %}
<div style="position:relative; width:100%"> <div style="position:relative; width:100%">
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}"><img class="thumb" height="{{size}}" src="data:image/jpeg;base64,{{obj.file_details.thumbnail}}"></img></a> <a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}"><img class="thumb" height="{{size}}" src="data:image/jpeg;base64,{{obj.file_details.thumbnail}}"></img></a>
@@ -223,7 +222,7 @@
{% endif %} {% endif %}
{# if this dir is the toplevel of the cwd, show the folder icon #} {# if this dir is the toplevel of the cwd, show the folder icon #}
{% if dirname| TopLevelFolderOf(cwd) %} {% if dirname| TopLevelFolderOf(cwd) %}
<figure class="px-1 dir" dir="{{dirname}}"> <figure class="px-1 dir entry" id={{obj.id}} ecnt={{loop.index}} dir="{{dirname}}">
<i style="font-size:{{size|int-22}};" class="fas fa-folder"></i> <i style="font-size:{{size|int-22}};" class="fas fa-folder"></i>
<figcaption class="figure-caption text-center text-wrap text-break">{{obj.name}}</figcaption> <figcaption class="figure-caption text-center text-wrap text-break">{{obj.name}}</figcaption>
</figure class="figure"> </figure class="figure">
@@ -271,6 +270,13 @@ function GetSelnAsData()
return to_del return to_del
} }
function RunAIOnSeln(person)
{
post_data = GetSelnAsData()
post_data += '&person='+person.replace('ai-','')
$.ajax({ type: 'POST', data: post_data, url: '/run_ai_on', success: function(data){ window.location='/'; return false; } })
}
function DelDBox(del_or_undel) function DelDBox(del_or_undel)
{ {
to_del = GetSelnAsData() to_del = GetSelnAsData()
@@ -398,19 +404,25 @@ function DoSel(e, el)
} }
if( e.shiftKey ) if( e.shiftKey )
{ {
st=Number($('.highlight').first().attr('img')) st=Number($('.highlight').first().attr('ecnt'))
end=Number($('.highlight').last().attr('img')) end=Number($('.highlight').last().attr('ecnt'))
clicked=Number($(el).attr('img')) clicked=Number($(el).attr('ecnt'))
if( ! folders )
{
st -= 1
end -= 1
clicked -= 1
}
// if we shift-click first element, then st/end are NaN, so just highlightthe one clicked // if we shift-click first element, then st/end are NaN, so just highlightthe one clicked
if( isNaN(st) ) if( isNaN(st) )
{ {
$('.figure').slice( clicked, clicked+1 ).addClass('highlight') $('.entry').slice( clicked, clicked+1 ).addClass('highlight')
return return
} }
if( clicked > end ) if( clicked > end )
$('.figure').slice( end, clicked+1 ).addClass('highlight') $('.entry').slice( end, clicked+1 ).addClass('highlight')
else else
$('.figure').slice( clicked, st ).addClass('highlight') $('.entry').slice( clicked, st ).addClass('highlight')
return return
} }
$('.highlight').removeClass('highlight') $('.highlight').removeClass('highlight')
@@ -429,6 +441,24 @@ function SetButtonState() {
} }
} }
function FiguresOrDirsOrBoth() {
var figure=false
var dir=false
$('.highlight').each(function( index ) {
if( $(this).hasClass('figure') ) {
figure=true
}
if( $(this).hasClass('dir') ) {
dir=true
}
} )
if( figure & ! dir )
return "figure"
if( ! figure & dir )
return "dir"
return "both"
}
function SelContainsBinAndNotBin() { function SelContainsBinAndNotBin() {
var bin=false var bin=false
var not_bin=false var not_bin=false
@@ -458,17 +488,35 @@ function NoSel() {
$('.figure').click( function(e) { DoSel(e, this ); SetButtonState(); return false; }); $('.figure').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$(document).on('click', function(e) { $('.highlight').removeClass('highlight') ; SetButtonState() }); $(document).on('click', function(e) { $('.highlight').removeClass('highlight') ; SetButtonState() });
// different context menu on files
$.contextMenu({ $.contextMenu({
selector: '.figure', selector: '.entry',
build: function($triggerElement, e){ build: function($triggerElement, e){
if( NoSel() ) // when right-clicking & no selection add one OR deal with ctrl/shift right-lick as it always changes seln
if( NoSel() || e.ctrlKey || e.shiftKey )
DoSel(e, e.currentTarget ) DoSel(e, e.currentTarget )
item_list = {
details: { name: "Details..." }, if( FiguresOrDirsOrBoth() == "figure" )
view: { name: "View File" }, item_list = {
sep: "---", details: { name: "Details..." },
move: { name: "Move selected file(s) to new storage folder" } view: { name: "View File" },
} sep: "---",
move: { name: "Move selected file(s) to new storage folder" },
sep2: "---" }
else
item_list = {}
item_list['ai'] = {
name: "Scan file for faces",
items: {
{% for p in people %}
"ai-{{p.tag}}": {"name": "{{p.tag}}"},
{% endfor %}
"ai-all": {"name": "all"},
}
}
if( SelContainsBinAndNotBin() ) { if( SelContainsBinAndNotBin() ) {
item_list['both']= { name: 'Cannot delete and restore at same time', disabled: true } item_list['both']= { name: 'Cannot delete and restore at same time', disabled: true }
} else { } else {
@@ -485,6 +533,7 @@ $.contextMenu({
if( key == "move" ) { MoveDBox() } if( key == "move" ) { MoveDBox() }
if( key == "del" ) { DelDBox('Delete') } if( key == "del" ) { DelDBox('Delete') }
if( key == "undel" ) { DelDBox('Restore') } if( key == "undel" ) { DelDBox('Restore') }
if( key.startsWith("ai")) { RunAIOnSeln(key) }
}, },
items: item_list items: item_list
}; };

View File

@@ -66,6 +66,14 @@
{% for log in logs %} {% for log in logs %}
<tr><td>{{log.log_date|vicdate}}</td><td>{{log.log|safe}}</td></tr> <tr><td>{{log.log_date|vicdate}}</td><td>{{log.log|safe}}</td></tr>
{% endfor %} {% endfor %}
{% if log_cnt > logs|length %}
<tr>
<td class="align-middle">Remaining logs truncated</td>
<td>
<button type="button" class="btn btn-info my-0 py-1" onClick="document.body.innerHTML+='<form id=_fm method=POST action={{url_for('joblog', id=job.id)}}></form>';document.getElementById('_fm').submit();">Show all logs</button>
</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -73,7 +81,7 @@
{% endblock main_content %} {% endblock main_content %}
{% block script_content %} {% block script_content %}
<script> <script>
{% if job.pa_job_state != "Completed" %} {% if first_logs_only and job.pa_job_state != "Completed" %}
setTimeout(function(){ window.location.reload(1); }, 3000 ) setTimeout(function(){ window.location.reload(1); }, 3000 )
{% endif %} {% endif %}
</script> </script>

78
templates/login.html Normal file
View File

@@ -0,0 +1,78 @@
<html>
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- font awesome -->
<link rel="stylesheet" href="{{ url_for( 'static', filename='upstream/fontawesome-free-5.15.3-web/css/all.min.css' ) }}">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="{{ url_for( 'static', filename='upstream/bootstrap-4.6.0-dist/css/bootstrap.min.css' ) }}">
<link rel="stylesheet" href="{{ url_for( 'static', filename='upstream/jquery.contextMenu.css' ) }}">
<!-- code to get bootstrap to work -->
<script src="{{ url_for( 'static', filename='upstream/jquery-3.6.0.min.js')}}"></script>
<script src="{{ url_for( 'static', filename='upstream/bootstrap-4.6.0-dist/js/bootstrap.min.js')}}"></script>
<script src="{{ url_for( 'static', filename='upstream/jquery.contextMenu.min.js')}}"></script>
<script src="{{ url_for( 'static', filename='upstream/jquery.ui.position.min.js')}}"></script>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
{% import "bootstrap/wtf.html" as wtf %}
<style>
.highlight { box-shadow: 0 0 7px 4px #5bc0de }
.sm-txt { font-size: 0.8rem }
</style>
</head>
<body>
<div class="container">
{% if form.errors|length > 0 %}
<div class="row my-5">
<alert class="alert alert-danger alert-dismissible fade show">
{% set last_err = namespace(txt="") %}
{% for e in form.errors %}
{% if last_err.txt != form.errors[e] %}
{% set err = form.errors[e]|replace("['", "" ) %}
{% set err = err|replace("']", "" ) %}
{{err}}
{% set last_err.txt=form.errors[e] %}
{% endif %}
{% endfor %}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</alert>
</div class="row">
{% endif %}
{# <div class="row my-5 col-lg-6 rounded border border-primary border-3"> #}
<div class="row my-5 col-lg-6" style="border: 3px solid #5bc0de; border-radius: 15px;">
<h3 class="col-lg-12 my-3 text-center" style="color: #5bc0de">
<span class="fa-stack">
<i class="fas fa-brain fa-stack-2x"></i>
<i class="far fa-image fa-stack-1x fa-inverse"></i>
</span>
&nbsp;Photo Assistant Login</h3>
<form class="form form-inline" method="POST">
<div class="col-lg-12">
<label labelfor="username" class="text-right form-control-plaintext col-lg-4 text-info">Username:</label>
<input class="form-control col-lg-8" type="text" name="username"></input>
</div>
<div class="col-lg-12">
<label labelfor="password" class="text-right form-control-plaintext col-lg-4 text-info">Password:</label>
<input class="form-control col-lg-8" type="password" name="password"></input>
</div>
<div class="col-lg-12 my-2 text-center">
{{ form.submit( class="form-control text-info") }}
</div>
{{ form.hidden_tag() }}
</form>
</div class="row">
</div class="container">
</body>
</html>

View File

@@ -1,42 +1,118 @@
{% extends "base.html" %} {% block main_content %} {% extends "base.html" %} {% block main_content %}
<div class="container"> <script>
<h3 class="offset-lg-2">{{page_title}}</h3> // Define this once and before it will be called, hence at the top of this file
<div class="row"> function DrawRefimg(img, canvas, orig_face )
<form class="form form-inline col-lg-12" action="" method="POST"> {
{% for field in form %} // FIXME: should get this from shared.py, not sure why this doesnt work at present
{% if field.type == 'HiddenField' or field.type == 'CSRFTokenField' %} thumbsize=256
{{field}}<br>
{% elif field.type != 'SubmitField' %} context=canvas.getContext('2d')
<div class="form-row col-lg-12"> // another call to this func will occur on load, so skip this one
{{ field.label( class="col-lg-2" ) }} if( img.width == 0 )
{{ field( class="form-control col" ) }} return
</div class="form-row col-lg-12">
{% endif %} // only set canvas.width once we have valid img dimensions
{% endfor %} canvas.width=img.width/2
<div class="form-row col-lg-12">
<span class="col-lg-2"><center>Reference Images:</center></span> // actually draw the pixel images to the canvas at the right size
<div class="form-row col"> context.drawImage(img, 0, 0, img.width/(img.height/canvas.height), canvas.height);
{% for ref_img in reference_imgs %}
<div class="form-control col-lg"> // draw rectangle on face
<input id="ref-img-id-{{ref_img.id}}" name="ref-img-id-{{ref_img.id}}" type="checkbox" context.beginPath();
{% if object is defined and ref_img in object.refimg %} new_x=(orig_face.x/orig_face.orig_w)*img.width/(img.height/canvas.height)
checked new_y=(orig_face.y/orig_face.orig_h)*thumbsize/(img.height/canvas.height)
{% endif %} new_w=(orig_face.w/orig_face.orig_w)*img.width/(img.height/canvas.height)
> {{ref_img.fname}}</input> new_h=(orig_face.h/orig_face.orig_h)*thumbsize/(img.height/canvas.height)
context.rect(new_x, new_y, new_w, new_h)
context.lineWidth = 2;
context.strokeStyle = 'green';
context.stroke();
}
</script>
<div class="container-fluid">
<h3 class="offset-lg-3">{{page_title}}</h3>
<form id="pfm" class="form form-inline" action="" method="POST">
{% for field in form %}
{% if field.type == 'HiddenField' or field.type == 'CSRFTokenField' %}
{{field}}<br>
{% elif field.type != 'SubmitField' %}
<div class="form-row col-lg-12">
{{ field.label( class="col-lg-3" ) }}
{{ field( class="form-control col" ) }}
</div class="form-row col-lg-12">
{% endif %}
{% endfor %}
<div class="form-row col-lg-12">
<span class="col-lg-3"><center>Reference Images:</center></span>
{% for refimg in person.refimg %}
{% set offset="" %}
{% if (loop.index % 10) == 0 %}
{% set offset= "offset-lg-3" %}
{% endif %}
<div id="RI{{refimg.id}}" class="px-0 col-lg-1 w-100 {{offset}}">
<center>
<input type="hidden" id="ref-img-id-{{refimg.id}}" name="ref-img-id-{{refimg.id}}" value="1"></input>
<figure style="border: 1px solid #5bc0de; border-radius: 3px;" class="figure my-auto h-100 w-100">
<div style="position:relative">
<canvas id="c_{{refimg.id}}" height="128"></canvas>
<script>
var im_{{refimg.id}}=new Image();
im_{{refimg.id}}.src="data:image/jpeg;base64,{{refimg.thumbnail}}";
// store this stuff in an javascript Object to use when document is ready event is triggered
var orig_face_{{refimg.id}}=new Object;
orig_face_{{refimg.id}}.x = {{refimg.face_locn[0][3]}}
orig_face_{{refimg.id}}.y = {{refimg.face_locn[0][0]}}
orig_face_{{refimg.id}}.w = {{refimg.face_locn[0][1]}}-{{refimg.face_locn[0][3]}}
orig_face_{{refimg.id}}.h = {{refimg.face_locn[0][2]}}-{{refimg.face_locn[0][0]}}
orig_face_{{refimg.id}}.orig_w = {{refimg.orig_w}}
orig_face_{{refimg.id}}.orig_h = {{refimg.orig_h}}
// when the document is ready, then DrawRefimg
$(function() { DrawRefimg(im_{{refimg.id}}, c_{{refimg.id}}, orig_face_{{refimg.id}} ) });
</script>
<div style="position:absolute; top: 2; right: 2;">
<button type="button" style="font-size:12px" class="btn btn-danger"
onClick="DelImg({{refimg.id}})">X</button>
</div> </div>
{% endfor %} <figcaption class="figure-caption text-center text-wrap text-break">{{refimg.fname}}</figcaption>
</div class="form-row col-lg-10"> </div>
</div class="form-row col-lg-12"> </figure>
<div class="form-row col-lg-12"> </center>
<br> </div id="/RI*">
</div class="form-row"> {% endfor %}
<div class="form-row col-lg-12"> </div class="form-row col-lg-12">
{{ form.submit( class="btn btn-primary offset-lg-2 col-lg-2" )}} <div class="form-row col-lg-12">
{% if 'Edit' in page_title %} <br>
{{ form.delete( class="btn btn-outline-danger col-lg-2" )}} </div class="form-row">
{% endif %} <div class="form-row col-lg-12">
</div class="form-row"> {{ form.save( id="save", class="btn btn-primary offset-lg-3 col-lg-2" )}}
</form> {% if 'Edit' in page_title %}
</div class="row"> {{ form.delete( class="btn btn-outline-danger col-lg-2" )}}
</div class="container"> {% endif %}
</div class="form-row">
</form>
{% if person.id %}
<form id="new_ri" class="form" action="{{url_for('add_refimg')}}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="person_id" value="{{person.id}}"></input>
<label class="btn btn-success offset-lg-3 col-lg-2">
Add reference image
<input name="refimg_file" type="file" onChange="$('#new_ri').submit()" style="display:none;" id="new_file_chooser">
</label>
</form>
{% endif %}
</div class="container">
{% endblock main_content %} {% endblock main_content %}
{% block script_content %}
<script>
function DelImg(ri_num)
{
$('#RI'+ri_num).remove()
$('#pfm').submit()
}
</script>
{% endblock script_content %}

View File

@@ -4,12 +4,18 @@
<h3>Show All People</h3> <h3>Show All People</h3>
<table id="person_table" class="table table-striped table-sm" data-toolbar="#toolbar" data-search="true"> <table id="person_table" class="table table-striped table-sm" data-toolbar="#toolbar" data-search="true">
<thead> <thead>
<tr class="thead-light"><th>Tag</th><th>Firstname(s)</th><th>Surname</th></tr> <tr class="thead-light"><th>Tag</th><th>Firstname(s)</th><th>Surname</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for person in persons %} {% for person in persons %}
<tr><td><a href="{{url_for('person', id=person.id )}}">{{person.tag}}</td><td>{{person.firstname}}</td> <tr><td><a href="{{url_for('person', id=person.id )}}">{{person.tag}}</td><td>{{person.firstname}}</td>
<td>{{person.surname}}</td></tr> <td>{{person.surname}}</td>
<td>
{% for refimg in person.refimg %}
<img height=24 src="data:image/jpeg;base64,{{refimg.thumbnail}}";</img>
{% endfor %}
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

21
user.py Normal file
View File

@@ -0,0 +1,21 @@
from main import db
from sqlalchemy import Sequence
from flask_login import UserMixin
from sqlalchemy.exc import SQLAlchemyError
from status import st, Status
# pylint: disable=no-member
################################################################################
# Class describing Person in the database, and via sqlalchemy, connected to the DB as well
################################################################################
class PAUser(UserMixin,db.Model):
__tablename__ = "pa_user"
id = db.Column(db.Integer, db.Sequence('pa_user_id_seq'), primary_key=True)
dn = db.Column(db.String)
def __repr__(self):
return self.dn
def get_id(self):
return self.dn

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
su mythtv -g mythtv -c "python3 /code/pa_job_manager.py" & su mythtv -g mythtv -c "FLASK_ENV="production" python3 /code/pa_job_manager.py" &
gunicorn --bind=0.0.0.0:443 --workers=8 --threads=8 --certfile /etc/letsencrypt/live/pa.depaoli.id.au/fullchain.pem --keyfile /etc/letsencrypt/live/pa.depaoli.id.au/privkey.pem main:app gunicorn --bind=0.0.0.0:443 --workers=4 --threads=16 --certfile /etc/letsencrypt/live/pa.depaoli.id.au/fullchain.pem --keyfile /etc/letsencrypt/live/pa.depaoli.id.au/privkey.pem main:app --env FLASK_ENV="production"