Compare commits

...

28 Commits

Author SHA1 Message Date
w2 a5b1da352a Merge pull request 'Fix and simplify rate calculation' (#15) from w2/photovoter_backend:rate-calculation-fix into main
4 years ago
rrr-marble 8a378923af fix: rate calculation, #14
4 years ago
g 4b5a20a109 Merge pull request 'Transform coordinates to decimal format' (#11) from w2/photovoter_backend:decimal-coordinates into main
4 years ago
rrr-marble b601b106b4 ref: change coordinates db datatype
4 years ago
rrr-marble be6a1f4eb7 del: redundant db columns in update requests
4 years ago
rrr-marble 2816355886 del: redundant db columns
4 years ago
rrr-marble 6bd14777a3 add: take into account reference cardinal direction
4 years ago
rrr-marble d18786b000 add: transform coordinates to decimal form
4 years ago
w2 9dfacfd6c2 Merge pull request 'Rotate and resize images on upload' (#10) from w2/photovoter_backend:image-processing into main
4 years ago
rrr-marble 415b3f50a2 add: resize image so shorter side<=1000px
4 years ago
rrr-marble c70415ede2 fix: image flips after stripping orientation exif
4 years ago
w2 2f967b584f Merge pull request 'upload endpoint' (#5) from w2/photovoter_backend:upload into main
4 years ago
rrr-marble 9620458977 fix: normalize global rel paths for win compat
4 years ago
rrr-marble 3e7ef81102 msc: report abs db path on creation
4 years ago
rrr-marble b78c53441d add: skip adding images with invalid exif to db
4 years ago
rrr-marble a1cd63eca0 fix: switch to epoch seconds to allow for win path
4 years ago
rrr-marble 576b23b2c9 fix: add 202 status code to upload_pictures()
4 years ago
rrr-marble 3e35188739 msc: upload_pictures() docs
4 years ago
rrr-marble 4b0b6ab9cc ref: extract upload credentials into config
4 years ago
rrr-marble 54a7010e76 add: process uploaded photo
4 years ago
rrr-marble 250f09153d ref: use explicit paths instead of chdir
4 years ago
rrr-marble 822e512a01 ref: extract globals into common config.py
4 years ago
rrr-marble ffc3ce75cc ref: pass cmdline params into fn explicitly
4 years ago
rrr-marble b86efb2154 ref: pass global params into fn explicitly
4 years ago
rrr-marble 36cd3297af add: accept and extract zip photo archives
4 years ago
rrr-marble 7df1ff3503 add: upload_pictures() prototype
4 years ago
w2 ca4cac9c02 Merge pull request 'photo_points' (#6) from photo_points into main
4 years ago
g 6a9d70f139 Merge pull request 'photo_points' (#4) from w2/photovoter_backend:photo_points into photo_points
4 years ago

@ -0,0 +1,15 @@
# use database residing here
DB_LOCATION = (
"testbox/photovoter.dblite" # Q: any allowances for this being not OUR database?
)
DATA_LOCATION = "/tmp/123"
# place compressed images here (needs to exist)
DEST_SHRUNK = "image/"
# move originals here (needs to exist)
DEST_ORIGINAL = "original/"
# upload interface credentials
CRED_USERNAME = "changeme"
CRED_PASSWORD = "CHANGEME"

@ -1,18 +1,33 @@
from fastapi import FastAPI from fastapi import FastAPI, File, UploadFile, Depends, BackgroundTasks
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.middleware.cors import CORSMiddleware # CORS from fastapi.middleware.cors import CORSMiddleware # CORS
from secrets import compare_digest
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
import sqlite3 import sqlite3
import zipfile
# Global settings of this program
# use database residing here # ./config.py
DB_LOCATION = ( from config import (
"db/photovoter.dblite" # Q: any allowances for this being not OUR database? DB_LOCATION,
DATA_LOCATION,
DEST_SHRUNK,
DEST_ORIGINAL,
CRED_USERNAME,
CRED_PASSWORD,
) )
# our own util for photo upload and processing
from util import import_photos as iph
# Initialization logic
app = FastAPI() app = FastAPI()
security = HTTPBasic()
iph.check_database(database_path=DB_LOCATION)
con = sqlite3.connect(DB_LOCATION) con = sqlite3.connect(DB_LOCATION)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
cur = con.cursor() # NB! single is enough for now, we might require multiple later cur = con.cursor() # NB! single is enough for now, we might require multiple later
@ -151,15 +166,16 @@ async def photo_points():
"""Get points with the url of a photo and the rate""" """Get points with the url of a photo and the rate"""
# assume we always have at least some photos # assume we always have at least some photos
# fetch them all # fetch them all
# if frontend uses 1 and 0 to represent like and dislike
# we can just take the average and turn it into %
# 100 * AVG(marks.mark)
cur.execute( cur.execute(
"""SELECT images.imgid, resizedpath, GPSLatitude, GPSLongitude, """SELECT images.imgid, resizedpath, GPSLatitude, GPSLongitude,
100*SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark) 100 * AVG(marks.mark)
FROM images FROM images
LEFT JOIN marks ON images.imgid = marks.imgid LEFT JOIN marks ON images.imgid = marks.imgid
GROUP BY images.imgid; GROUP BY images.imgid;
""", # 100 * SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark) """,
# is an ad-hoc percentage of likes without know how front end defined like/dislike
# returns None with no marks (sqlite handles division by 0 gracefully)
) )
points = cur.fetchall() points = cur.fetchall()
return [ return [
@ -168,7 +184,93 @@ async def photo_points():
"url": point["resizedpath"], "url": point["resizedpath"],
"lon": point["GPSLongitude"], "lon": point["GPSLongitude"],
"lat": point["GPSLatitude"], "lat": point["GPSLatitude"],
"rate": point["100*SUM(marks.mark)/COUNT(marks.mark)/MAX(marks.mark)"], "rate": point["100 * AVG(marks.mark)"],
} }
for point in points for point in points
] ]
@app.post(
"/upload_pictures",
responses={
202: {"description": "Archive accepted into processing"},
401: {"description": "Authentication is required to access this resource"},
415: {"description": "Cannot process uploaded archive"},
},
)
async def upload_pictures(
background_tasks: BackgroundTasks,
credentials: HTTPBasicCredentials = Depends(security),
file: UploadFile = File(...),
):
"""Photo upload endpoint"""
"""
Accepts photo in zip archives with any internal directory structure
Valid uploads yield 202 status message and process photos in the background
Non-zip archives yeild 415 error
Upload is restricted by basic HTTP login, configurable in config.py
"""
# check authenticity
correct_username = compare_digest(credentials.username, CRED_USERNAME)
correct_password = compare_digest(credentials.password, CRED_PASSWORD)
if not (correct_username and correct_password):
return JSONResponse(status_code=401)
# slurp the zip
if not zipfile.is_zipfile(file.file):
return JSONResponse(status_code=415)
# detach from the interface
# unpack zip
tasks = BackgroundTasks()
tasks.add_task(
unpack_pictures_zip,
file=file,
time=datetime.utcnow().replace(microsecond=0),
)
# feed the pictures to util/import_photos.py
return JSONResponse("Accepted", status_code=202, background=tasks)
def unpack_pictures_zip(file: UploadFile, time):
"""
Unpack and process zip archived photo
Extract pictures in the DATA_LOCATION/processing
and feed them to util/import_photos.py
Walk the nested DATA_LOCATION/processing ourselves
Uses: DB_LOCATION, DATA_LOCATION
"""
# we only call this function sporadically, so import here
import os
from shutil import rmtree
print(f"Accepted {file.filename} at {time.isoformat()} into processing")
processing_path = os.path.join(DATA_LOCATION, "processing" + str(time.timestamp()))
os.makedirs(processing_path, exist_ok=True)
# using private ._file field is a dirty hack, but
# SpooledTemporaryFile does not implement seekable
# required by zipfile 'r' mode
# https://bugs.python.org/issue26175
with zipfile.ZipFile(file.file._file) as photo_zip:
problem_files = photo_zip.testzip()
if problem_files is not None:
print(
f"Errors in {file.filename} from {time.isoformat()} at {problem_files}"
)
photo_zip.extractall(path=processing_path)
photo_zip.close()
print(f"Start processing {file.filename} from {time.isoformat()}")
iph.check_database(database_path=DB_LOCATION)
for (dir, _, _) in os.walk(processing_path):
iph.run(
db_location=DB_LOCATION,
source=os.path.join(dir),
dest_shrunk=os.path.join(DATA_LOCATION, os.path.normcase(DEST_SHRUNK)),
dest_original=os.path.join(DATA_LOCATION, os.path.normcase(DEST_ORIGINAL)),
)
rmtree(processing_path)
print(f"Succesfully processed {file.filename} from {time.isoformat()}")

@ -11,7 +11,9 @@ pathspec==0.8.1
pycodestyle==2.7.0 pycodestyle==2.7.0
pydantic==1.8.2 pydantic==1.8.2
pyflakes==2.3.1 pyflakes==2.3.1
python-multipart==0.0.5
regex==2021.4.4 regex==2021.4.4
six==1.16.0
starlette==0.14.2 starlette==0.14.2
toml==0.10.2 toml==0.10.2
typing-extensions==3.10.0.0 typing-extensions==3.10.0.0

@ -6,34 +6,60 @@ import filetype
from os import path, walk from os import path, walk
from sys import argv, stderr from sys import argv, stderr
from shutil import move from shutil import move
from fractions import Fraction
import sqlite3 import sqlite3
# update database residing here
DB_LOCATION = (
"db/photovoter.dblite" # Q: any allowances for this being not OUR database?
)
# place compressed images here (needs to exist)
DEST_SHRUNK = "db/image/"
# move originals here (needs to exist)
DEST_ORIGINAL = "db/original/"
def decimal_from_rational64u(dms: str, ref):
"""Convert coordinates from rational64u EXIF uses to represent
degrees, minutes, and seconds of a point to decimal format
Take into account reference cardinal direction and
turn [S]outh and [W]est coordinates negative
def usage(): General formula is
"""Brief usage explanation""" dec_degree = degreesNumerator / degreesDenominator
print("USAGE: python {name} /path/to/images".format(name=argv[0]), file=stderr) + minutesNumerator / minutesDenominator / 60
+ secondsNumerator / secondsDenominator / 3600
https://en.wikipedia.org/wiki/Geographic_coordinate_conversion
https://gis.stackexchange.com/questions/136925/how-to-parse-exif-gps-information-to-lat-lng-decimal-numbers
>>> decimal_from_rational64u(dms="42/1, 18/1, 2914/100", "S")
-42.30809
"""
# 1 split by comma
# 2 turn each into Fraction
# 3 zip fractions with their respective (degrees, minutes, seconds) denominator
# 4 divide the fraction
# 5 sum up the result
# 6 convert to decimal
# 7 round to 5th decimal point
dec_coordinates = round(
float(
sum(
a / b
for (a, b) in zip((Fraction(f) for f in dms.split(",")), (1, 60, 3600))
)
),
5,
)
if ref in ("S", "W"):
dec_coordinates = -dec_coordinates
return dec_coordinates
def process_pictures(): def process_pictures(source: str, dest_shrunk: str, dest_original: str):
"""Process images from the base directory in the first command line argument. """Process images from the base directory in the first command line argument.
Place the resized copies to DEST_SHRUNK and Place the resized copies to dest_shrunk and
move the originals to DEST_ORIGINAL. move the originals to dest_original.
Return a dict for each image processed for database collection. Return a dict for each image processed for database collection.
Uses: DEST_SHRUNK, DEST_ORIGINAL
""" """
# walk every pic # walk every pic
# We only care about files in the root of the path # We only care about files in the root of the path
# Ignore any nested directories # Ignore any nested directories
(root, _, filenames) = next(walk(argv[1], topdown=True), (None, None, [])) (root, _, filenames) = next(walk(source, topdown=True), (None, None, []))
for filename in filenames: for filename in filenames:
# FIXME[0]:what if picture with the same name already exists? # FIXME[0]:what if picture with the same name already exists?
# skip any non-image files # skip any non-image files
@ -47,55 +73,62 @@ def process_pictures():
(k[5:], v) for k, v in image.metadata.items() if k.startswith("exif:") (k[5:], v) for k, v in image.metadata.items() if k.startswith("exif:")
) )
with image.clone() as cloned: with image.clone() as cloned:
# adjust an image so that its orientation is suitable for viewing
# (i.e. top-left orientation) by checking EXIF data
cloned.auto_orient()
# strip an image of all profiles and comments
cloned.strip() # Q: may damage icc, do we allow that or use smh else? cloned.strip() # Q: may damage icc, do we allow that or use smh else?
cloned.transform(resize="50%") # Q: what do we want here? # resize the shorter side to be no more than 1000px
# https://legacy.imagemagick.org/discourse-server/viewtopic.php?p=44329#p44329
cloned.transform(resize="1000^>") # Q: what do we want here?
# move them to the processed folder # move them to the processed folder
cloned.save(filename=path.join(DEST_SHRUNK, filename)) cloned.save(filename=path.join(dest_shrunk, filename))
# move the originals out of the working directory # move the originals out of the working directory
# Q: do we strip exif from originals? # Q: do we strip exif from originals?
move(path.join(root, filename), DEST_ORIGINAL) move(path.join(root, filename), dest_original)
# return the freshly processed picture info try:
yield { # return the freshly processed picture info
"ResizedImage": path.join(DEST_SHRUNK, filename), yield {
"OriginalImage": path.join(DEST_ORIGINAL, filename), "ResizedImage": path.join(dest_shrunk, filename),
"DateTimeOriginal": exif["DateTimeOriginal"], # Q: normalize it? "OriginalImage": path.join(dest_original, filename),
"GPSLatitude": exif["GPSLatitude"], "DateTimeOriginal": exif["DateTimeOriginal"], # Q: normalize it?
"GPSLatitudeRef": exif["GPSLatitudeRef"], "GPSLatitude": decimal_from_rational64u(
"GPSLongitude": exif["GPSLongitude"], exif["GPSLatitude"], exif["GPSLatitudeRef"]
"GPSLongitudeRef": exif["GPSLongitudeRef"], ),
} "GPSLongitude": decimal_from_rational64u(
exif["GPSLongitude"], exif["GPSLongitudeRef"]
),
}
except KeyError as e:
print(f"Image '{filename}' has no valid exif")
continue
def update_database(pic_info: dict): def update_database(pic_info: dict, db_location: str):
"""Append new image information to the existing database """Append new image information to the existing database
or create a new one, if it does not exist yet or create a new one, if it does not exist yet
Uses: DB_LOCATION
""" """
# make sure the database exists # make sure the database exists
check_database(DB_LOCATION) check_database(db_location)
# FIXME[1]: closure it, so we open it only once? # FIXME[1]: closure it, so we open it only once?
con = sqlite3.connect(DB_LOCATION) con = sqlite3.connect(db_location)
cur = con.cursor() cur = con.cursor()
# insert new pictures to the image table # insert new pictures to the image table
cur.execute( cur.execute(
"""INSERT INTO images(resizedpath, """INSERT INTO images(resizedpath,
origpath, origpath,
date, date,
GPSLatitude, GPSLatitude,
GPSLatitudeRef, GPSLongitude)
GPSLongitude,
GPSLongitudeRef)
VALUES (:ResizedImage, VALUES (:ResizedImage,
:OriginalImage, :OriginalImage,
:DateTimeOriginal, :DateTimeOriginal,
:GPSLatitude, :GPSLatitude,
:GPSLatitudeRef, :GPSLongitude)
:GPSLongitude,
:GPSLongitudeRef)
""", """,
pic_info, pic_info,
) )
@ -114,7 +147,7 @@ def check_database(database_path: str):
return return
# make one # make one
else: else:
print("No DB, creating", database_path) print("No DB, creating", path.abspath(database_path))
con = sqlite3.connect(database_path) con = sqlite3.connect(database_path)
cur = con.cursor() cur = con.cursor()
@ -126,10 +159,8 @@ def check_database(database_path: str):
resizedpath TEXT NOT NULL, resizedpath TEXT NOT NULL,
origpath TEXT NOT NULL, origpath TEXT NOT NULL,
date TEXT, date TEXT,
GPSLatitude TEXT, GPSLatitude REAL,
GPSLatitudeRef TEXT, GPSLongitude REAL
GPSLongitude TEXT,
GPSLongitudeRef TEXT
)""" )"""
) )
con.commit() con.commit()
@ -169,23 +200,46 @@ def check_database(database_path: str):
con.close() con.close()
def main(): def run(db_location: str, source: str, dest_shrunk: str, dest_original: str):
if len(argv) != 2: """Core program logic"""
usage()
exit(1)
pics_processed = 0 pics_processed = 0
# process each pic and add it to the database # process each pic and add it to the database
for pic in process_pictures(): for pic in process_pictures(source, dest_shrunk, dest_original):
update_database(pic) update_database(pic, db_location)
pics_processed += 1 pics_processed += 1
if pics_processed == 0: if pics_processed == 0:
print("No more pictures processed from", argv[1]) print("No pictures processed from", source)
print("Do we have enough permissions?") print("Do we have enough permissions?")
else: else:
print("Pictures processed:", pics_processed) print("Pictures processed:", pics_processed)
def usage():
"""Brief usage explanation"""
print("USAGE: python {name} /path/to/images".format(name=argv[0]), file=stderr)
def main():
if len(argv) != 2:
usage()
exit(1)
import sys
import os
# append root directory to sys.path
# to allow import globals from ../config.py
sys.path.append(os.path.dirname(__file__) + "/..")
import config as cfg
run(
cfg.DB_LOCATION,
argv[1],
path.normcase(cfg.DEST_SHRUNK),
path.normcase(cfg.DEST_ORIGINAL),
)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

Loading…
Cancel
Save