Files
photoassistant/person.py

454 lines
20 KiB
Python

from wtforms import SubmitField, StringField, HiddenField, validators, Form
from flask_wtf import FlaskForm
from flask import request, render_template, redirect, url_for
from main import db, app, ma
from settings import Settings, AIModel
from sqlalchemy import Sequence
from sqlalchemy.exc import SQLAlchemyError
from status import st, Status
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from shared import GenFace, GenThumb, PA
from face import Face, FaceRefimgLink, FaceOverrideType, FaceNoMatchOverride, FaceForceMatchOverride
from path import Path, PathType
from job import JobExtra, NewJob
import os
import json
import time
from PIL import Image
import base64
from io import BytesIO
import os.path
# pylint: disable=no-member
################################################################################
# Class describing Refimg in DB via sqlalchemy
# fname: original file name of refimg
# face: actual binary of numpy data for this refimg's face (always only 1)
# orig*: width/height of original image, because when we show in person, it get scaled
# thumbnail: image data of actual img. once we load refimg, we only store this data, not the orig file
# model_used: which AI model (cnn or hog) used to create face
# person: read-only convenience field not in DB, just used in html
################################################################################
class Refimg(PA,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_top = db.Column(db.Integer)
face_right = db.Column(db.Integer)
face_bottom = db.Column(db.Integer)
face_left = db.Column(db.Integer)
thumbnail = db.Column(db.String, unique=True, nullable=False)
created_on = db.Column(db.Float)
model_used = db.Column(db.Integer, db.ForeignKey("ai_model.id") )
person = db.relationship( 'Person', secondary="person_refimg_link", uselist=False, viewonly=True )
################################################################################
# Class describing Person to Refimg link in DB via sqlalchemy
################################################################################
class PersonRefimgLink(PA,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)
################################################################################
# Class describing Person in DB via sqlalchemy
################################################################################
class Person(PA,db.Model):
id = db.Column(db.Integer, db.Sequence('person_id_seq'), primary_key=True )
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__, order_by=Refimg.id)
################################################################################
# Helper class that inherits a .dump() method to turn class Person into json / useful in jinja2
################################################################################
class PersonSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Person
ordered = True
################################################################################
# Helper class that defines a form for person, used to make html <form>, with field validation (via wtforms)
################################################################################
class PersonForm(FlaskForm):
id = HiddenField()
tag = StringField('Tag (searchable name):', [validators.DataRequired()])
firstname = StringField('FirstName(s):', [validators.DataRequired()])
surname = StringField('Surname:', [validators.DataRequired()])
save = SubmitField('Save' )
delete = SubmitField('Delete' )
################################################################################
# Helper functions
# AddRefimgToPerson( filename, person )
################################################################################
def AddRefimgToPerson( filename, person ):
refimg = Refimg( fname=os.path.basename( filename ) )
try:
#False == dont autorotate, its not needed on this image
refimg.thumbnail, refimg.orig_w, refimg.orig_h = GenThumb( filename, False )
settings = Settings.query.first()
model=AIModel.query.get(settings.default_refimg_model)
refimg.face, face_locn = GenFace( filename, model=model.name )
try:
os.remove(filename)
except Exception as e:
# can fail "silently" here, if the face_locn worked, great, its only
# a tmp file in /tmp - if not, the next if will send a msg to the front-end
print( f"Failed to delete tmp file for refimg addition: {e}" )
if not face_locn:
st.SetMessage( f"<b>Failed to find face in Refimg:</b>" )
raise Exception("Could not find face in uploaded reference image" )
return
refimg.face_top = face_locn[0]
refimg.face_right = face_locn[1]
refimg.face_bottom = face_locn[2]
refimg.face_left = face_locn[3]
refimg.model_used = settings.default_refimg_model
refimg.created_on = time.time()
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.SetMessage( f"<b>Failed to add Refimg:</b>&nbsp;{e.orig}", "danger" )
except Exception as e:
st.SetMessage( f"<b>Failed to modify Refimg:</b>&nbsp;{e}", "danger" )
return
################################################################################
# TempRefimgFile: helper function that takes data POST'd (from dialog box to
# add face to new/existing person). Converts data into a jpg file to be used by
# wrapper funcs to AI / refimg <-> person. filename will be <tag>.jpg
################################################################################
def TempRefimgFile( data, tag ):
# undo the munging sending via http has done
data=data.replace(' ', '+' )
# convert b64 encoded to a temp file to process...
bytes_decoded = base64.b64decode(data)
img = Image.open(BytesIO(bytes_decoded))
out_jpg = img.convert("RGB")
# save file to /tmp/<tag>.jpg
fname="/tmp/" + tag + ".jpg"
out_jpg.save(fname)
return fname
################################################################################
# Routes for person data
#
# /persons -> GET only -> prints out list of all persons
################################################################################
@app.route("/persons", methods=["GET"])
@login_required
def persons():
persons = Person.query.order_by(Person.tag).all()
# get the num matches for each person too, and allow link for it
stats = list( 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" ) )
for p in persons:
if not p.refimg:
continue
for s in stats:
if p.tag == s[0]:
p.num_matches = s[1];
break
return render_template("persons.html", persons=persons)
################################################################################
# /person -> GET/POST -> creates a new person and shows new creation
################################################################################
@app.route("/person", methods=["GET", "POST"])
@login_required
def new_person():
form = PersonForm(request.form)
page_title='Create new Person'
if request.method=='POST':
person = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] )
try:
db.session.add(person)
db.session.commit()
st.SetMessage( "Created new Person ({})".format(person.tag) )
return redirect( url_for( 'person', id=person.id) )
except SQLAlchemyError as e:
st.SetMessage( f"<b>Failed to add Person:</b>&nbsp;{e.orig}", "danger" )
return redirect( url_for( '/persons') )
else:
return render_template("person.html", person=None, form=form, page_title=page_title )
@app.route("/match_with_create_person", methods=["POST"])
@login_required
def match_with_create_person():
p = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] )
# add this fname (of temp refimg) to person
fname=TempRefimgFile( request.form['refimg_data'], p.tag )
AddRefimgToPerson( fname, p )
resp={}
resp['who']=p.tag
resp['distance']='0.0'
return resp
################################################################################
# /person/<id> -> GET/POST(save or delete) -> shows/edits/delets a single person
################################################################################
@app.route("/person/<id>", methods=["GET", "POST"])
@login_required
def person(id):
form = PersonForm(request.form)
page_title='Edit Person'
if request.method == 'POST':
try:
person = Person.query.get(id)
if 'delete' in request.form:
st.SetMessage("Successfully deleted Person: ({})".format( person.tag ) )
# do linkages by hand, or one day replace with delete cascade in the DB defintions
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}" )
db.session.execute( f"delete from refimg where id not in ( select refimg_id from person_refimg_link )" )
# now can delete the person entry with no foreign key data left
Person.query.filter(Person.id==id).delete()
db.session.commit()
return redirect( url_for( 'persons' ) )
elif request.form and form.validate():
new_refs=[]
for ref_img in person.refimg:
if "ref-img-id-{}".format(ref_img.id) in request.form:
new_refs.append(ref_img)
if new_refs != person.refimg:
deld = list(set(person.refimg) - set(new_refs));
person.refimg = new_refs
# delete the "match" between a face found in a file and this ref img
FaceRefimgLink.query.filter(FaceRefimgLink.refimg_id==deld[0].id).delete()
Refimg.query.filter(Refimg.id==deld[0].id).delete()
st.SetMessage( f"Successfully Updated Person: removed reference image {deld[0].fname}" )
else:
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']
st.AppendMessage(" To: ({}, {}, {})".format(person.tag, person.firstname, person.surname) )
db.session.add(person)
db.session.commit()
return redirect( url_for( 'person', id=person.id) )
except SQLAlchemyError as e:
st.SetMessage( f"<b>Failed to modify Person:</b>&nbsp;{e}", "danger" )
return redirect( url_for( 'persons' ) )
else:
person = Person.query.get(id)
if not person:
st.SetMessage( f"No such person with id: {id}", "danger" )
return render_template("base.html" )
form = PersonForm(request.values, obj=person)
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!")
try:
# save the actual uploaded image to reference_images/
f=request.files['refimg_file']
fname=secure_filename(f.filename)
if fname == "":
raise Exception("invalid filename")
fname = f"/tmp/{fname}"
f.save( fname )
except Exception as e:
st.SetMessage( f"<b>Failed to load reference image:</b>&nbsp;{e}", "danger" )
AddRefimgToPerson( fname, person )
return redirect( url_for( 'person', id=person.id) )
################################################################################
# /find_persons/<who> -> POST (an arbitrary string to find a person (via
# name/tag match)
################################################################################
@app.route("/find_persons/<who>", methods=["POST"])
@login_required
def find_persons(who):
resp={}
people = Person.query.filter( Person.tag.ilike(f'%{who}%') | Person.firstname.ilike(f'%{who}%') | Person.surname.ilike(f'%{who}%') ).all();
for p in people:
resp[p.id]={}
resp[p.id]['id']=p.id
resp[p.id]['tag']=p.tag
resp[p.id]['firstname']=p.firstname
resp[p.id]['surname']=p.surname
return resp
################################################################################
# /add_refimg_to_person/ -> POST
################################################################################
@app.route("/add_refimg_to_person", methods=["POST"])
@login_required
def add_refimg_to_person():
resp={}
f = Face.query.get( request.form['face_id'] )
p = Person.query.get( request.form['person_id'] )
# add this fname (of temp refimg) to person
fname=TempRefimgFile( request.form['refimg_data'], p.tag )
AddRefimgToPerson( fname, p )
if request.form['search'] == "true":
jex=[]
ptype=PathType.query.filter(PathType.name=='Import').first()
jex.append( JobExtra( name=f"person", value="all" ) )
jex.append( JobExtra( name=f"path_type", value=ptype.id ) )
job=NewJob( "run_ai_on_path", 0, None, jex )
st.SetMessage( f"Created&nbsp;<a href=/job/{job.id}>Job #{job.id}</a>&nbsp;to Look for face(s) in import path(s)")
jex=[]
ptype=PathType.query.filter(PathType.name=='Storage').first()
jex.append( JobExtra( name=f"person", value="all" ) )
jex.append( JobExtra( name=f"path_type", value=ptype.id ) )
job=NewJob( "run_ai_on_path", 0, None, jex )
st.SetMessage( f"Created&nbsp;<a href=/job/{job.id}>Job #{job.id}</a>&nbsp;to Look for face(s) in storage path(s)")
resp['who']=p.tag
resp['distance']='0.0'
return resp
################################################################################
# /add_force_match_override -> POST
################################################################################
@app.route("/add_force_match_override", methods=["POST"])
@login_required
def add_force_match_override():
person_id = request.form['person_id']
p = Person.query.get(person_id);
if not p:
raise Exception( f"could not find person (id={person_id}) to add override too!" )
face_id = request.form['face_id']
f = Face.query.get(face_id);
if not f:
raise Exception("could not find face to add override for!")
mo = FaceForceMatchOverride( face_id=f.id, person_id=p.id )
db.session.add( mo )
db.session.commit()
jex=[]
jex.append( JobExtra( name="which", value="add_force_match_override" ) )
jex.append( JobExtra( name="face_id", value=f.id ) )
jex.append( JobExtra( name="person_id", value=p.id ) )
# dont do status update here, the F/E is in the middle of a dbox, just send metadata through to the B/E
NewJob( "metadata", 0, None, jex )
print( f"Placing an override match with face_id {face_id}, for person: {p.tag}" )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override to person_tag
resp={}
resp['person_tag']=p.tag
return resp
################################################################################
# /remove_force_match_override -> POST
################################################################################
@app.route("/remove_force_match_override", methods=["POST"])
@login_required
def remove_force_match_override():
face_id = request.form['face_id']
person_tag = request.form['person_tag']
file_eid = request.form['file_eid']
print( f"Remove override force match of face_id={face_id} to person_tag={person_tag}" )
FaceForceMatchOverride.query.filter( FaceForceMatchOverride.face_id==face_id ).delete()
db.session.commit()
# needed to use person_id in job below (allows consistent processing in job_mgr)
p=Person.query.filter(Person.tag==person_tag).one()
jex=[]
jex.append( JobExtra( name="which", value="remove_force_match_override" ) )
jex.append( JobExtra( name="face_id", value=face_id ) )
jex.append( JobExtra( name="person_id", value=p.id ) )
# dont do status update here, the F/E is in the middle of a dbox, just send metadata through to the B/E
NewJob( "metadata", 0, None, jex )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override
resp={}
return resp
################################################################################
# /remove_no_match_override -> POST
################################################################################
@app.route("/remove_no_match_override", methods=["POST"])
@login_required
def remove_no_match_override():
face_id = request.form['face_id']
type_id = request.form['type_id']
print( f"Remove override of no match (type_id={type_id}) for face_id={face_id}" )
FaceNoMatchOverride.query.filter( FaceNoMatchOverride.face_id==face_id, FaceNoMatchOverride.type_id==type_id ).delete()
db.session.commit()
jex=[]
jex.append( JobExtra( name="which", value="remove_no_match_override" ) )
jex.append( JobExtra( name="face_id", value=face_id ) )
jex.append( JobExtra( name="type_id", value=type_id ) )
# dont do status update here, the F/E is in the middle of a dbox, just send metadata through to the B/E
NewJob( "metadata", 0, None, jex )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override
resp={}
return resp
################################################################################
# /add_no_match_override -> POST
################################################################################
@app.route("/add_no_match_override", methods=["POST"])
@login_required
def add_no_match_override():
face_id = request.form['face_id']
f = Face.query.get(face_id)
if not f:
raise Exception("could not find face to add override too!")
type_id = request.form['type_id']
t = FaceOverrideType.query.get(type_id);
if not t:
raise Exception("could not find override_type to add override for!")
nmo = FaceNoMatchOverride( face_id=f.id, type_id=t.id )
db.session.add( nmo )
db.session.commit()
jex=[]
jex.append( JobExtra( name="which", value="add_no_match_override" ) )
jex.append( JobExtra( name="face_id", value=f.id ) )
jex.append( JobExtra( name="type_id", value=t.id ) )
# dont do status update here, the F/E is in the middle of a dbox, just send metadata through to the B/E
NewJob( "metadata", 0, None, jex )
print( f"Placing an override of NO Match for face_id {face_id}" )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override to person_tag
resp={}
resp['type']=t.name
return resp