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/Import/*
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 55432
COPY . .
RUN chown -R mythtv:mythtv ./static
RUN chown mythtv:mythtv ./static
RUN chown mythtv:mythtv ./static/*
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
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
View File

@@ -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
View File

@@ -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&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 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,10 +317,15 @@ def search():
# always show flat results for search to start with
folders=False
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(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()
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 )

10
job.py
View File

@@ -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)
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
View File

@@ -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:

View File

@@ -137,25 +137,19 @@ class Entry(Base):
in_dir = relationship ("Dir", secondary="entry_dir_link", uselist=False )
def FullPathOnFS(self):
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,6 +411,15 @@ def AddLogForJob(job, message):
session.add(log)
# some logs have DEBUG: in front, so clean that up
message = message.replace("DEBUG:", "" )
# 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
@@ -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")
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()
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) )

View File

@@ -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>&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
@@ -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>&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:
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>&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 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

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 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/' );

View File

@@ -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 %}

View File

@@ -49,7 +49,11 @@
</div>
<nav class="navbar navbar-expand-lg navbar-light bg-light justify-content-between">
{% 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">

View File

@@ -7,7 +7,7 @@
<div class="form-group">
<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()">
{% 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

View File

@@ -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 )
if( FiguresOrDirsOrBoth() == "figure" )
item_list = {
details: { name: "Details..." },
view: { name: "View File" },
sep: "---",
move: { name: "Move selected file(s) to new storage folder" }
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
};

View File

@@ -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
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 %}
<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">
<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-2" ) }}
{{ 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-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
<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 %}
> {{ref_img.fname}}</input>
<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>
<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-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" )}}
{{ 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>
</div class="row">
{% 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 %}

View File

@@ -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
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
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"