aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2022-04-30 13:24:26 +1000
committerTerry Truong <terry06890@gmail.com>2022-04-30 13:24:26 +1000
commitd87bb9bc0991d7ce4eeb895da61c63a204edaa4d (patch)
tree8a5e51817aba00f4d1a281749764805e2aee618a
parent565495b1153c87cbf907de31d116c5f89bcffc2a (diff)
Add scripts for downloading/reviewing/cropping_and_resizing images
Also adjust client code to handle new format, and add backend/data/README.md explaining image production process.
-rw-r--r--backend/data/README.md13
-rwxr-xr-xbackend/data/downloadImgsForReview.py143
-rw-r--r--backend/data/eol/README.md15
-rwxr-xr-xbackend/data/genEolNameData.py (renamed from backend/data/eolNamesToSqlite.py)15
-rwxr-xr-xbackend/data/genImgsForWeb.py94
-rwxr-xr-xbackend/data/genOtolData.py (renamed from backend/data/otolToSqlite.py)29
-rw-r--r--backend/data/otol/README.md4
-rwxr-xr-xbackend/data/reviewImgs.py176
-rwxr-xr-xbackend/server.py111
-rw-r--r--package.json1
-rw-r--r--src/App.vue2
-rw-r--r--src/components/Tile.vue10
-rw-r--r--src/components/TileInfoModal.vue10
-rw-r--r--src/layout.ts1
-rw-r--r--src/tol.ts2
15 files changed, 545 insertions, 81 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()
+
diff --git a/backend/server.py b/backend/server.py
index ded74d6..178d95c 100755
--- a/backend/server.py
+++ b/backend/server.py
@@ -1,13 +1,15 @@
#!/usr/bin/python3
import sys, re, sqlite3, json
+import os.path
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
hostname = "localhost"
port = 8000
dbFile = "data/data.db"
-tolnodeReqDepth = 1
+imgDir = "../public/img/"
+NODE_REQ_DEPTH = 1
# For a /node?name=name1 request, respond with name1's node, and descendent nodes in a subtree to some depth > 0
usageInfo = f"usage: {sys.argv[0]}\n"
@@ -16,17 +18,40 @@ usageInfo += "Responds to path+query /data/type1?name=name1 with JSON data.\n"
usageInfo += "\n"
usageInfo += "If type1 is 'node': \n"
usageInfo += " Responds with a map from names to node objects, representing\n"
-usageInfo += " nodes name1, and child nodes up to depth " + str(tolnodeReqDepth) + ".\n"
+usageInfo += " nodes name1, and child nodes up to depth " + str(NODE_REQ_DEPTH) + ".\n"
usageInfo += "If type1 is 'children': Like 'node', but excludes node name1.\n"
usageInfo += "If type1 is 'chain': Like 'node', but gets nodes from name1 up to the root, and their direct children.\n"
usageInfo += "If type1 is 'search': Responds with a tolnode name that has alt-name name1, or null.\n"
+if len(sys.argv) > 1:
+ print(usageInfo, file=sys.stderr)
+ sys.exit(1)
dbCon = sqlite3.connect(dbFile)
def lookupNode(name):
+ # Get from db
cur = dbCon.cursor()
cur.execute("SELECT name, data FROM nodes WHERE name = ?", (name,))
row = cur.fetchone()
- return row[1] if row != None else None
+ if row == None:
+ return None
+ nodeObj = json.loads(row[1])
+ # Check for image file
+ match = re.fullmatch(r"\[(.+) \+ (.+)]", name)
+ if match == None:
+ nodeObj["imgFile"] = nodeNameToFile(name, cur)
+ else:
+ nodeObj["imgFile"] = nodeNameToFile(match.group(1), cur)
+ if nodeObj["imgFile"] == None:
+ nodeObj["imgFile"] = nodeNameToFile(match.group(2), cur)
+ return nodeObj;
+def nodeNameToFile(name, cur):
+ row = cur.execute("SELECT name, eol_id FROM names WHERE name = ?", (name,)).fetchone()
+ if row == None:
+ return None
+ imgFile = str(row[1]) + ".jpg"
+ if not os.path.exists(imgDir + imgFile):
+ return None
+ return imgFile
def lookupName(name):
cur = dbCon.cursor()
cur.execute("SELECT name, alt_name FROM names WHERE alt_name = ?", (name,))
@@ -44,57 +69,57 @@ class DbServer(BaseHTTPRequestHandler):
if match != None and match.group(1) == "data" and "name" in queryDict:
reqType = match.group(2)
name = queryDict["name"][0]
- print(name)
# Check query string
if reqType == "node":
- nodeJson = lookupNode(name)
- if nodeJson != None:
- results = []
- getResultsUntilDepth(name, nodeJson, tolnodeReqDepth, results)
- self.respondJson(nodeResultsToJSON(results))
+ nodeObj = lookupNode(name)
+ if nodeObj != None:
+ results = {}
+ getResultsUntilDepth(name, nodeObj, NODE_REQ_DEPTH, results)
+ self.respondJson(json.dumps(results))
return
elif reqType == "children":
- nodeJson = lookupNode(name)
- if nodeJson != None:
- obj = json.loads(nodeJson)
- results = []
- for childName in obj["children"]:
- nodeJson = lookupNode(childName)
- if nodeJson != None:
- getResultsUntilDepth(childName, nodeJson, tolnodeReqDepth, results)
- self.respondJson(nodeResultsToJSON(results))
+ nodeObj = lookupNode(name)
+ if nodeObj != None:
+ results = {}
+ for childName in nodeObj["children"]:
+ nodeObj = lookupNode(childName)
+ if nodeObj != None:
+ getResultsUntilDepth(childName, nodeObj, NODE_REQ_DEPTH, results)
+ self.respondJson(json.dumps(results))
return
elif reqType == "chain":
- results = []
+ results = {}
ranOnce = False
while True:
- jsonResult = lookupNode(name)
- if jsonResult == None:
+ # Get node
+ nodeObj = lookupNode(name)
+ if nodeObj == None:
if ranOnce:
print("ERROR: Parent-chain node {} not found".format(name), file=sys.stderr)
break
- results.append([name, jsonResult])
- obj = json.loads(jsonResult)
- # Add children
+ results[name] = nodeObj
+ # Conditionally add children
if not ranOnce:
ranOnce = True
else:
internalFail = False
- for childName in obj["children"]:
- jsonResult = lookupNode(childName)
- if jsonResult == None:
+ for childName in nodeObj["children"]:
+ if childName in results:
+ continue
+ nodeObj = lookupNode(childName)
+ if nodeObj == None:
print("ERROR: Parent-chain-child node {} not found".format(name), file=sys.stderr)
internalFail = True
break
- results.append([childName, jsonResult])
+ results[childName] = nodeObj
if internalFail:
break
# Check if root
- if obj["parent"] == None:
- self.respondJson(nodeResultsToJSON(results))
+ if nodeObj["parent"] == None:
+ self.respondJson(json.dumps(results))
return
else:
- name = obj["parent"]
+ name = nodeObj["parent"]
elif reqType == "search":
nameJson = lookupName(name)
if nameJson != None:
@@ -108,24 +133,14 @@ class DbServer(BaseHTTPRequestHandler):
self.send_header("Content-type", "application/json")
self.end_headers()
self.wfile.write(jsonStr.encode("utf-8"))
-def getResultsUntilDepth(name, nodeJson, depth, results):
- """Given a node [name, nodeJson] pair, adds child node pairs to 'results', up until 'depth'"""
- results.append([name, nodeJson])
+def getResultsUntilDepth(name, nodeObj, depth, results):
+ """Given a node [name, nodeObj] pair, adds child node pairs to 'results', up until 'depth'"""
+ results[name] = nodeObj
if depth > 0:
- obj = json.loads(nodeJson)
- for childName in obj["children"]:
- childJson = lookupNode(childName)
- if childJson != None:
- getResultsUntilDepth(childName, childJson, depth-1, results)
-def nodeResultsToJSON(results):
- """Given a list of [name, nodeJson] pairs, returns a representative JSON string"""
- jsonStr = "{"
- for i in range(len(results)):
- jsonStr += json.dumps(results[i][0]) + ":" + results[i][1]
- if i < len(results) - 1:
- jsonStr += ","
- jsonStr += "}"
- return jsonStr
+ for childName in nodeObj["children"]:
+ childObj = lookupNode(childName)
+ if childObj != None:
+ getResultsUntilDepth(childName, childObj, depth-1, results)
server = HTTPServer((hostname, port), DbServer)
print("Server started at http://{}:{}".format(hostname, port))
diff --git a/package.json b/package.json
index b251046..1f73d3c 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"author": "Terry Truong",
"license": "MIT",
"dependencies": {
+ "smartcrop-cli": "^2.0.3",
"vue": "^3.2.25"
},
"devDependencies": {
diff --git a/src/App.vue b/src/App.vue
index 89d227d..b683b21 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -541,7 +541,7 @@ export default defineComponent({
text-white/40 hover:text-white hover:cursor-pointer"/>
<!-- Modals -->
<transition name="fade">
- <tile-info-modal v-if="infoModalNode != null" :node="infoModalNode" :uiOpts="uiOpts"
+ <tile-info-modal v-if="infoModalNode != null" :node="infoModalNode" :tolMap="tolMap" :uiOpts="uiOpts"
@info-modal-close="infoModalNode = null"/>
</transition>
<transition name="fade">
diff --git a/src/components/Tile.vue b/src/components/Tile.vue
index 1087a58..2ab1df1 100644
--- a/src/components/Tile.vue
+++ b/src/components/Tile.vue
@@ -88,11 +88,11 @@ export default defineComponent({
leafStyles(): Record<string,string> {
return {
// Image (and scrims)
- //backgroundImage:
- // 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.4) 100%),' +
- // 'url(\'/img/' + this.layoutNode.name.replaceAll('\'', '\\\'') + '.png\')',
- backgroundImage:
- 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.4) 100%)',
+ backgroundImage: this.tolNode.imgFile != null ?
+ 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.4) 100%),' +
+ 'url(\'/img/' + this.tolNode.imgFile.replaceAll('\'', '\\\'') + '\')' :
+ 'none',
+ backgroundColor: '#1c1917',
backgroundSize: 'cover',
// Other
borderRadius: this.uiOpts.borderRadius + 'px',
diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue
index 0e2fc94..9805b48 100644
--- a/src/components/TileInfoModal.vue
+++ b/src/components/TileInfoModal.vue
@@ -7,13 +7,19 @@ import {LayoutNode} from '../layout';
export default defineComponent({
props: {
node: {type: Object as PropType<LayoutNode>, required: true},
+ tolMap: {type: Object as PropType<TolMap>, required: true},
uiOpts: {type: Object, required: true},
},
computed: {
+ tolNode(): TolNode {
+ return this.tolMap.get(this.node.name)!;
+ },
imgStyles(): Record<string,string> {
return {
- //backgroundImage: 'url(\'/img/' + this.node.name.replaceAll('\'', '\\\'') + '.png\')',
- background: 'black',
+ backgroundImage: this.tolNode.imgFile != null ?
+ 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.4) 100%),' +
+ 'url(\'/img/' + this.tolNode.imgFile.replaceAll('\'', '\\\'') + '\')' :
+ 'none',
width: this.uiOpts.infoModalImgSz + 'px',
height: this.uiOpts.infoModalImgSz + 'px',
backgroundSize: 'cover',
diff --git a/src/layout.ts b/src/layout.ts
index fd65b87..e023619 100644
--- a/src/layout.ts
+++ b/src/layout.ts
@@ -6,7 +6,6 @@
* find a tile-based layout, filling in node fields to represent placement.
*/
-import {TolNode} from './tol';
import type {TolMap} from './tol';
import {range, arraySum, linspace, limitVals, updateAscSeq} from './util';
diff --git a/src/tol.ts b/src/tol.ts
index daa3339..bfc778f 100644
--- a/src/tol.ts
+++ b/src/tol.ts
@@ -10,10 +10,12 @@ export class TolNode {
parent: string | null;
tips: number;
pSupport: boolean;
+ imgFile: string | null;
constructor(children: string[] = [], parent = null, tips = 0, pSupport = false){
this.children = children;
this.parent = parent;
this.tips = tips;
this.pSupport = pSupport;
+ this.imgFile = null;
}
}