diff options
Diffstat (limited to 'backend/data')
| -rw-r--r-- | backend/data/README.md | 13 | ||||
| -rwxr-xr-x | backend/data/downloadImgsForReview.py | 143 | ||||
| -rw-r--r-- | backend/data/eol/README.md | 15 | ||||
| -rwxr-xr-x | backend/data/genEolNameData.py (renamed from backend/data/eolNamesToSqlite.py) | 15 | ||||
| -rwxr-xr-x | backend/data/genImgsForWeb.py | 94 | ||||
| -rwxr-xr-x | backend/data/genOtolData.py (renamed from backend/data/otolToSqlite.py) | 29 | ||||
| -rw-r--r-- | backend/data/otol/README.md | 4 | ||||
| -rwxr-xr-x | backend/data/reviewImgs.py | 176 |
8 files changed, 465 insertions, 24 deletions
diff --git a/backend/data/README.md b/backend/data/README.md new file mode 100644 index 0000000..3cbeb03 --- /dev/null +++ b/backend/data/README.md @@ -0,0 +1,13 @@ +File Generation Process +======================= +1 Obtain data in otol/ and eol/, as specified in their README files. +2 Run genOtolData.py, which creates data.db, and adds a 'nodes' + table using data in otol/*. +3 Run genEolNameData.py, which adds a 'names' table to data.db, + using data in eol/vernacularNames.csv and the 'nodes' table. +4 Use downloadImgsForReview.py to download EOL images into imgsForReview/. + It uses data in eol/imagesList.db, and the 'nodes' table. +5 Use reviewImgs.py to filter images in imgsForReview/ into EOL-id-unique + images in imgsReviewed/. +6 Use genImgsForWeb.py to create cropped/resized images in img/, using + images in imgsReviewed, and also to add an 'images' table to data.db. diff --git a/backend/data/downloadImgsForReview.py b/backend/data/downloadImgsForReview.py new file mode 100755 index 0000000..12b52ff --- /dev/null +++ b/backend/data/downloadImgsForReview.py @@ -0,0 +1,143 @@ +#!/usr/bin/python3 + +import sys, re, os, random +import sqlite3 +import urllib.parse, requests +import time +from threading import Thread +import signal + +usageInfo = f"usage: {sys.argv[0]}\n" +usageInfo += "Downloads images from URLs specified in an image-list database, using\n" +usageInfo += "EOL IDs obtained from another database. Downloaded images get names of\n" +usageInfo += "the form 'eolId1 contentId1.ext1'\n" +usageInfo += "\n" +usageInfo += "SIGINT causes the program to finish ongoing downloads and exit.\n" +usageInfo += "The program can be re-run to continue downloading, and uses\n" +usageInfo += "existing downloaded files to decide where to continue from.\n" +if len(sys.argv) > 1: + print(usageInfo, file=sys.stderr) + sys.exit(1) + +imagesListDb = "eol/imagesList.db" +dbFile = "data.db" +outDir = "imgsForReview/" +LICENSE_REGEX = r"cc-by((-nc)?(-sa)?(-[234]\.[05])?)|cc-publicdomain|cc-0-1\.0|public domain" +POST_DL_DELAY_MIN = 2 # Minimum delay in seconds to pause after download before starting another (for each thread) +POST_DL_DELAY_MAX = 3 + +# Get eol-ids from data db +eolIds = set() +print("Reading in EOL IDs") +dbCon = sqlite3.connect(dbFile) +dbCur = dbCon.cursor() +for row in dbCur.execute("SELECT DISTINCT eol_id FROM names"): + eolIds.add(row[0]) +dbCon.close() +# Get eol-ids from images db +imgDbCon = sqlite3.connect(imagesListDb) +imgCur = imgDbCon.cursor() +imgListIds = set() +for row in imgCur.execute("SELECT DISTINCT page_id FROM images"): + imgListIds.add(row[0]) +# Get eol-id intersection, and sort into list +eolIds = eolIds.intersection(imgListIds) +eolIds = sorted(eolIds) + +MAX_IMGS_PER_ID = 3 +MAX_THREADS = 10 +numThreads = 0 +threadException = None # Used for ending main thread after a non-main thread exception +def downloadImg(url, outFile): + global numThreads, threadException + try: + data = requests.get(url) + with open(outFile, 'wb') as file: + file.write(data.content) + time.sleep(random.random() * (POST_DL_DELAY_MAX - POST_DL_DELAY_MIN) + POST_DL_DELAY_MIN) + except Exception as e: + print("Error while downloading to {}: {}".format(outFile, str(e)), file=sys.stderr) + threadException = e + numThreads -= 1 +# Create output directory if not present +if not os.path.exists(outDir): + os.mkdir(outDir) +# Find next eol ID to download for +print("Finding next ID to download for") +nextIdx = 0 +fileList = os.listdir(outDir) +ids = list(map(lambda filename: int(filename.split(" ")[0]), fileList)) +if len(ids) > 0: + ids.sort() + nextIdx = eolIds.index(ids[-1]) +if nextIdx == len(eolIds): + print("No IDs left. Exiting...") + sys.exit(0) +# Detect SIGINT signals +interrupted = False +oldHandler = None +def onSigint(sig, frame): + global interrupted + interrupted = True + signal.signal(signal.SIGINT, oldHandler) +oldHandler = signal.signal(signal.SIGINT, onSigint) +# Manage downloading +for idx in range(nextIdx, len(eolIds)): + eolId = eolIds[idx] + # Get image urls + imgDataList = [] + ownerSet = set() # Used to get images from different owners, for variety + for row in imgCur.execute( + "SELECT content_id, page_id, copy_url, license, copyright_owner FROM images WHERE page_id = ?", (eolId,)): + license = row[3] + copyrightOwner = row[4] + if re.fullmatch(LICENSE_REGEX, license) == None: + continue + if len(copyrightOwner) > 100: # Ignore certain copyrightOwner fields that seem long and problematic + continue + if copyrightOwner not in ownerSet: + ownerSet.add(copyrightOwner) + imgDataList.append(row) + if len(ownerSet) == MAX_IMGS_PER_ID: + break + if len(imgDataList) == 0: + continue + # Determine output filenames + outFiles = [] + urls = [] + for row in imgDataList: + contentId = row[0] + url = row[2] + if url.startswith("data/"): + url = "https://content.eol.org/" + url + urlParts = urllib.parse.urlparse(url) + extension = os.path.splitext(urlParts.path)[1] + if len(extension) <= 1: + print("WARNING: No filename extension found in URL {}".format(url), file=sys.stderr) + continue + outFiles.append(str(eolId) + " " + str(contentId) + extension) + urls.append(url) + # Start downloads + exitLoop = False + for i in range(len(outFiles)): + outPath = outDir + outFiles[i] + if not os.path.exists(outPath): + # Enforce thread limit + while numThreads == MAX_THREADS: + time.sleep(1) + # Wait for threads after an interrupt or thread-exception + if interrupted or threadException != None: + print("Waiting for existing threads to end") + while numThreads > 0: + time.sleep(1) + exitLoop = True + break + print("Downloading image to {}".format(outPath)) + # Perform download + numThreads += 1 + thread = Thread(target=downloadImg, args=(urls[i], outPath), daemon=True) + thread.start() + if exitLoop: + break +# Close images-list db +imgDbCon.close() diff --git a/backend/data/eol/README.md b/backend/data/eol/README.md index ed970d2..3ce9799 100644 --- a/backend/data/eol/README.md +++ b/backend/data/eol/README.md @@ -1,8 +1,15 @@ -Files -===== -- images\_list.tgz +Downloaded Files +================ +- imagesList.tgz Obtained from https://opendata.eol.org/dataset/images-list on 24/04/2022 Listed as being last updated on 05/02/2020 -- vernacular\_names.csv +- vernacularNames.csv Obtained from https://opendata.eol.org/dataset/vernacular-names on 24/04/2022 Listed as being last updated on 27/10/2020 + +Generated Files +=============== +- imagesList/ + Obtained by extracting imagesList.tgz +- imagesList.db + Represents data from eol/imagesList/*, and is created by genImagesListDb.sh diff --git a/backend/data/eolNamesToSqlite.py b/backend/data/genEolNameData.py index 1df1c23..ce887b3 100755 --- a/backend/data/eolNamesToSqlite.py +++ b/backend/data/genEolNameData.py @@ -1,9 +1,20 @@ #!/usr/bin/python3 +# + import sys, re import csv, sqlite3 -vnamesFile = "eol/vernacular_names.csv" +usageInfo = f"usage: {sys.argv[0]}\n" +usageInfo += "Reads vernacular-names CSV data (from the Encyclopedia of Life site),\n" +usageInfo += "makes associations with node data in a sqlite database, and writes\n" +usageInfo += "name data to that database.\n" +usageInfo += "\n" +usageInfo += "Expects a CSV header describing lines with format:\n" +usageInfo += " page_id, canonical_form, vernacular_string, language_code,\n" +usageInfo += " resource_name, is_preferred_by_resource, is_preferred_by_eol\n" + +vnamesFile = "eol/vernacularNames.csv" dbFile = "data.db" # Read in vernacular-names data @@ -34,7 +45,7 @@ with open(vnamesFile, newline="") as csvfile: if lineNum == 1: continue pid = int(row[0]) - name1 = re.sub(r"<[^>]+>", "", row[1].lower()) + name1 = re.sub(r"<[^>]+>", "", row[1].lower()) # Remove tags name2 = row[2].lower() # Add to maps updateMaps(name1, pid, True) diff --git a/backend/data/genImgsForWeb.py b/backend/data/genImgsForWeb.py new file mode 100755 index 0000000..14583d6 --- /dev/null +++ b/backend/data/genImgsForWeb.py @@ -0,0 +1,94 @@ +#!/usr/bin/python3 + +import sys, os, subprocess +import sqlite3 +import signal + +usageInfo = f"usage: {sys.argv[0]}\n" +usageInfo += "Creates web-usable copies of reviewed images.\n" +usageInfo += "Looks in a reviewed-images directory for images named 'eolId1 contentId1.ext1', \n" +usageInfo += "and places copied/resized versions in another directory, with name 'eolId1.jpg'.\n" +usageInfo += "Also adds image metadata to a database, making use of an images-list database.\n" +usageInfo += "\n" +usageInfo += "SIGINT can be used to stop conversion, and the program can be re-run to\n" +usageInfo += "continue processing. It uses existing output files to decide where from.\n" +if len(sys.argv) > 1: + print(usageInfo, file=sys.stderr) + sys.exit(1) + +imgDir = "imgsReviewed/" +outDir = "img/" +imagesListDb = "eol/imagesList.db" +dbFile = "data.db" +IMG_OUT_SZ = 200 + +# Create output directory if not present +if not os.path.exists(outDir): + os.mkdir(outDir) +# Open images-list db +imagesListDbCon = sqlite3.connect(imagesListDb) +imagesListCur = imagesListDbCon.cursor() +# Create/open data db +dbCon = sqlite3.connect(dbFile) +dbCur = dbCon.cursor() +if dbCur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images'").fetchone() == None: + dbCur.execute("CREATE TABLE images (eol_id INT PRIMARY KEY, source_url TEXT, license TEXT, copyright_owner TEXT)") +def closeDb(): + dbCon.commit() + dbCon.close() +# Get list of input images +print("Reading input image list") +inputImgList = os.listdir(imgDir) +inputImgList.sort(key=lambda s: int(s.split(" ")[0])) +if len(inputImgList) == 0: + print("No input images found") + closeDb() + sys.exit(0) +# Get next image to convert +inputImgIdx = 0 +print("Checking for existing output files") +outputImgList = os.listdir(outDir) +if len(outputImgList) > 0: + latestOutputId = 0 + for filename in outputImgList: + latestOutputId = max(latestOutputId, int(filename.split(".")[0])) + while int(inputImgList[inputImgIdx].split(" ")[0]) <= latestOutputId: + inputImgIdx += 1 + if inputImgIdx == len(inputImgList): + print("No unprocessed input images found") + closeDb() + sys.exit(0) +# Detect SIGINT signals +interrupted = False +def onSigint(sig, frame): + global interrupted + interrupted = True +signal.signal(signal.SIGINT, onSigint) +# Convert input images + # There are two interrupt checks because the subprocess exits on a SIGINT (not prevented by the handler above). + # The second check prevents adding a db entry for a non-created file. + # The first check prevents starting a new subprocess after a sigint occurs while adding to db +print("Converting images") +for i in range(inputImgIdx, len(inputImgList)): + if interrupted: + print("Exiting") + break + imgName = inputImgList[i] + [eolIdStr, otherStr] = imgName.split(" ") + contentId = int(otherStr.split(".")[0]) + print("Converting {}".format(imgName)) + subprocess.run( + ['npx', 'smartcrop-cli', + '--width', str(IMG_OUT_SZ), + '--height', str(IMG_OUT_SZ), + imgDir + imgName, + outDir + eolIdStr + ".jpg"], + stdout=subprocess.DEVNULL) + if interrupted: + print("Exiting") + break + # Add entry to db + imagesListQuery = "SELECT content_id, source_url, license, copyright_owner FROM images WHERE content_id = ?" + row = imagesListCur.execute(imagesListQuery, (contentId,)).fetchone() + dbCur.execute("INSERT INTO images VALUES (?, ?, ?, ?)", (int(eolIdStr), row[1], row[2], row[3])) +closeDb() diff --git a/backend/data/otolToSqlite.py b/backend/data/genOtolData.py index 2ee47b7..a7d9c03 100755 --- a/backend/data/otolToSqlite.py +++ b/backend/data/genOtolData.py @@ -5,20 +5,17 @@ import os.path usageInfo = f"usage: {sys.argv[0]}\n" usageInfo += "Reads labelled_supertree_ottnames.tre & annotations.json (from an Open Tree of Life release), \n" -usageInfo += "and creates an sqlite database otol.db, which holds entries of the form (name text, data text).\n" +usageInfo += "and creates a sqlite database, which holds entries of the form (name text, data text).\n" usageInfo += "Each row holds a tree-of-life node name, and a JSON string with the form \n" usageInfo += "{\"children\": [name1, ...], \"parent\": name1, \"tips\": int1, \"pSupport\": bool1}, holding \n" usageInfo += "child names, a parent name or null, descendant 'tips', and a phylogeny-support indicator\n" usageInfo += "\n" -usageInfo += "This script was adapted to handle Open Tree of Life version 13.4.\n" -usageInfo += "Link: https://tree.opentreeoflife.org/about/synthesis-release/v13.4\n" -usageInfo += "\n" -usageInfo += "labelled_supertree_ottnames.tre format:\n" +usageInfo += "Expected labelled_supertree_ottnames.tre format:\n" usageInfo += " Represents a tree-of-life in Newick format, roughly like (n1,n2,(n3,n4)n5)n6,\n" usageInfo += " where root node is named n6, and has children n1, n2, and n5.\n" usageInfo += " Name forms include Homo_sapiens_ott770315, mrcaott6ott22687, and 'Oxalis san-miguelii ott5748753'\n" usageInfo += " Some names can be split up into a 'simple' name (like Homo_sapiens) and an id (like ott770315)\n" -usageInfo += "annotations.json format:\n" +usageInfo += "Expected annotations.json format:\n" usageInfo += " JSON object holding information about the tree-of-life release.\n" usageInfo += " The object's 'nodes' field maps node IDs to objects holding information about that node,\n" usageInfo += " such as phylogenetic trees that support/conflict with it's placement.\n" @@ -50,8 +47,8 @@ def parseNewick(): if dataIdx == len(data): print("ERROR: Unexpected EOF at index " + str(dataIdx), file=sys.stderr) return None - # Check for inner-node start - if data[dataIdx] == "(": + # Check for node + if data[dataIdx] == "(": # parse inner node dataIdx += 1 childNames = [] while True: @@ -91,7 +88,7 @@ def parseNewick(): for childName in childNames: nodeMap[childName]["parent"] = name return name - else: + else: # Parse node name [name, id] = parseNewickName() idToName[id] = name nodeMap[name] = {"n": name, "id": id, "children": [], "parent": None, "tips": 1, "pSupport": False} @@ -173,9 +170,9 @@ def applyMrcaNameConvert(name, namesToSwap): childTips[maxIdx] = 0 maxTips2 = max(childTips) maxIdx2 = childTips.index(maxTips2) - # childName1 = node["children"][maxIdx] childName2 = node["children"][maxIdx2] + # Check for composite child names if childName1.startswith("mrca"): childName1 = applyMrcaNameConvert(childName1, namesToSwap) if childName2.startswith("mrca"): @@ -218,10 +215,10 @@ for node in nodeMap.values(): del node["id"] # Create db -con = sqlite3.connect(dbFile) -cur = con.cursor() -cur.execute("CREATE TABLE nodes (name TEXT PRIMARY KEY, data TEXT)") +dbCon = sqlite3.connect(dbFile) +dbCur = dbCon.cursor() +dbCur.execute("CREATE TABLE nodes (name TEXT PRIMARY KEY, data TEXT)") for name in nodeMap.keys(): - cur.execute("INSERT INTO nodes VALUES (?, ?)", (name, json.dumps(nodeMap[name]))) -con.commit() -con.close() + dbCur.execute("INSERT INTO nodes VALUES (?, ?)", (name, json.dumps(nodeMap[name]))) +dbCon.commit() +dbCon.close() diff --git a/backend/data/otol/README.md b/backend/data/otol/README.md index f720772..58aad3c 100644 --- a/backend/data/otol/README.md +++ b/backend/data/otol/README.md @@ -1,5 +1,5 @@ -Files -===== +Downloaded Files +================ - labelled\_supertree\_ottnames.tre Obtained from https://tree.opentreeoflife.org/about/synthesis-release/v13.4 - annotations.json diff --git a/backend/data/reviewImgs.py b/backend/data/reviewImgs.py new file mode 100755 index 0000000..3df7809 --- /dev/null +++ b/backend/data/reviewImgs.py @@ -0,0 +1,176 @@ +#!/usr/bin/python3 + +import sys, re, os +import tkinter as tki +from tkinter import ttk +import PIL +from PIL import ImageTk, Image + +usageInfo = f"usage: {sys.argv[0]}\n" +usageInfo += "Provides a GUI for reviewing images. Looks in a for-review directory for\n" +usageInfo += "images named 'eolId1 contentId1.ext1', and, for each EOL ID, enables the user to\n" +usageInfo += "choose an image to keep, or reject all. Also provides image rotation.\n" +usageInfo += "Chosen images are placed in another directory, and rejected ones are deleted.\n" +if len(sys.argv) > 1: + print(usageInfo, file=sys.stderr) + sys.exit(1) + +imgDir = "imgsForReview/" +outDir = "imgsReviewed/" +IMG_DISPLAY_SZ = 400 +MAX_IMGS_PER_ID = 3 +PLACEHOLDER_IMG = Image.new("RGB", (IMG_DISPLAY_SZ, IMG_DISPLAY_SZ), (88, 28, 135)) + +# Create output directory if not present +if not os.path.exists(outDir): + os.mkdir(outDir) +# Get images for review +print("Reading input image list") +imgList = os.listdir(imgDir) +imgList.sort(key=lambda s: int(s.split(" ")[0])) +if len(imgList) == 0: + print("No input images found", file=sys.stderr) + sys.exit(1) + +class EolImgReviewer: + """ Provides the GUI for reviewing images """ + def __init__(self, root, imgList): + self.root = root + root.title("EOL Image Reviewer") + # Setup main frame + mainFrame = ttk.Frame(root, padding="5 5 5 5") + mainFrame.grid(column=0, row=0, sticky=(tki.N, tki.W, tki.E, tki.S)) + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + # Set up images-to-be-reviewed frames + self.imgs = [PLACEHOLDER_IMG] * MAX_IMGS_PER_ID # Stored as fields for use in rotation + self.photoImgs = list(map(lambda img: ImageTk.PhotoImage(img), self.imgs)) # Image objects usable by tkinter + # These need a persistent reference for some reason (doesn't display otherwise) + self.labels = [] + for i in range(MAX_IMGS_PER_ID): + frame = ttk.Frame(mainFrame, width=IMG_DISPLAY_SZ, height=IMG_DISPLAY_SZ) + frame.grid(column=i, row=0) + label = ttk.Label(frame, image=self.photoImgs[i]) + label.grid(column=0, row=0) + self.labels.append(label) + # Add padding + for child in mainFrame.winfo_children(): + child.grid_configure(padx=5, pady=5) + # Add bindings + root.bind("<q>", self.quit) + root.bind("<Key-j>", lambda evt: self.accept(0)) + root.bind("<Key-k>", lambda evt: self.accept(1)) + root.bind("<Key-l>", lambda evt: self.accept(2)) + root.bind("<Key-i>", lambda evt: self.reject()) + root.bind("<Key-a>", lambda evt: self.rotate(0)) + root.bind("<Key-s>", lambda evt: self.rotate(1)) + root.bind("<Key-d>", lambda evt: self.rotate(2)) + root.bind("<Key-A>", lambda evt: self.rotate(0, True)) + root.bind("<Key-S>", lambda evt: self.rotate(1, True)) + root.bind("<Key-D>", lambda evt: self.rotate(2, True)) + # Initialise images to review + self.imgList = imgList + self.imgListIdx = 0 + self.nextEolId = 0 + self.nextImgNames = [] + self.rotations = [] + self.getNextImgs() + def getNextImgs(self): + """ Updates display with new images to review, or ends program """ + # Gather names of next images to review + for i in range(MAX_IMGS_PER_ID): + if self.imgListIdx == len(self.imgList): + if i == 0: + self.quit() + return + break + imgName = self.imgList[self.imgListIdx] + eolId = int(re.match(r"(\d+) (\d+)", imgName).group(1)) + if i == 0: + self.nextEolId = eolId + self.nextImgNames = [imgName] + self.rotations = [0] + else: + if self.nextEolId != eolId: + break + self.nextImgNames.append(imgName) + self.rotations.append(0) + self.imgListIdx += 1 + # Update displayed images + idx = 0 + while idx < MAX_IMGS_PER_ID: + if idx < len(self.nextImgNames): + try: + img = Image.open(imgDir + self.nextImgNames[idx]) + except PIL.UnidentifiedImageError: + os.remove(imgDir + self.nextImgNames[idx]) + del self.nextImgNames[idx] + del self.rotations[idx] + continue + self.imgs[idx] = self.resizeForDisplay(img) + else: + self.imgs[idx] = PLACEHOLDER_IMG + self.photoImgs[idx] = ImageTk.PhotoImage(self.imgs[idx]) + self.labels[idx].config(image=self.photoImgs[idx]) + idx += 1 + # Restart if all image files non-recognisable + if len(self.nextImgNames) == 0: + self.getNextImgs() + return + # Update title + firstImgIdx = self.imgListIdx - len(self.nextImgNames) + 1 + lastImgIdx = self.imgListIdx + self.root.title("Reviewing EOL ID {} (imgs {} to {} out of {})".format( + self.nextEolId, firstImgIdx, lastImgIdx, len(self.imgList))) + def accept(self, imgIdx): + """ React to a user selecting an image """ + if imgIdx >= len(self.nextImgNames): + print("Invalid selection") + return + for i in range(len(self.nextImgNames)): + inFile = imgDir + self.nextImgNames[i] + if i == imgIdx: # Move accepted image, rotating if needed + outFile = outDir + self.nextImgNames[i] + if self.rotations[i] == 0: + os.replace(inFile, outFile) + else: + img = Image.open(inFile) + img = img.rotate(self.rotations[i], expand=True) + img.save(outFile) + os.remove(inFile) + else: # Delete non-accepted image + os.remove(inFile) + self.getNextImgs() + def reject(self): + """ React to a user rejecting all images of a set """ + for i in range(len(self.nextImgNames)): + os.remove(imgDir + self.nextImgNames[i]) + self.getNextImgs() + def rotate(self, imgIdx, anticlockwise = False): + """ Respond to a user rotating an image """ + deg = -90 if not anticlockwise else 90 + self.imgs[imgIdx] = self.imgs[imgIdx].rotate(deg) + self.photoImgs[imgIdx] = ImageTk.PhotoImage(self.imgs[imgIdx]) + self.labels[imgIdx].config(image=self.photoImgs[imgIdx]) + self.rotations[imgIdx] = (self.rotations[imgIdx] + deg) % 360 + def quit(self, e = None): + self.root.destroy() + def resizeForDisplay(self, img): + """ Returns a copy of an image, shrunk to fit the display (keeps aspect ratio), and with a background """ + if max(img.width, img.height) > IMG_DISPLAY_SZ: + if (img.width > img.height): + newHeight = int(img.height * IMG_DISPLAY_SZ/img.width) + img = img.resize((IMG_DISPLAY_SZ, newHeight)) + else: + newWidth = int(img.width * IMG_DISPLAY_SZ / img.height) + img = img.resize((newWidth, IMG_DISPLAY_SZ)) + bgImg = PLACEHOLDER_IMG.copy() + bgImg.paste(img, box=( + int((IMG_DISPLAY_SZ - img.width) / 2), + int((IMG_DISPLAY_SZ - img.height) / 2))) + return bgImg +# Create GUI and defer control +root = tki.Tk() +EolImgReviewer(root, imgList) +root.mainloop() + |
