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