Improved face navigation
Signed-off-by: James Ketrenos <james_gitlab@ketrenos.com>
This commit is contained in:
parent
694a6e0a39
commit
bfcd137ea9
304
frontend/face-explorer.html
Normal file
304
frontend/face-explorer.html
Normal file
@ -0,0 +1,304 @@
|
||||
<html>
|
||||
<script>'<base href="BASEPATH">';</script>
|
||||
<body>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.face {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
border: 1px solid rgb(128,0,0);
|
||||
border-radius: 0.5em;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.face:hover {
|
||||
border-color: #ff0000;
|
||||
background-color: rgba(128,0,0,0.5);
|
||||
}
|
||||
|
||||
#photo {
|
||||
position: fixed;
|
||||
display: inline-block;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 50%;
|
||||
background-size: contain;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
#placeholder {
|
||||
position: absolute;
|
||||
left: -1000px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
opacity: 0.6;
|
||||
background: linear-gradient(45deg, rgba(16, 16, 16, 1), transparent);
|
||||
color: white;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5em;
|
||||
font-family: Arial;
|
||||
box-shadow: 0px -0.25em 1em black;
|
||||
text-shadow: 0 1px 0 black;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#info {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
#loading {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
<img id="placeholder"></img>
|
||||
<div id="photo">
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div id="info">Loading photoset...</div>
|
||||
<div id="loading"></div>
|
||||
</div>
|
||||
<script>
|
||||
var base, placeholder, info, photos = [], photoIndex;
|
||||
|
||||
var countdown = 15;
|
||||
|
||||
const days = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ],
|
||||
months = [ "January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December" ];
|
||||
|
||||
let activeFaces = [];
|
||||
|
||||
function makeFaceBoxes() {
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.face'), (el) => {
|
||||
el.parentElement.removeChild(el);
|
||||
});
|
||||
|
||||
const el = document.getElementById("photo"),
|
||||
photo = photos[photoIndex];
|
||||
|
||||
let width, height, offsetLeft = 0, offsetTop = 0;
|
||||
|
||||
/* If photo is wider than viewport, it will be 100% width and < 100% height */
|
||||
if (photo.width / photo.height > el.offsetWidth / el.offsetHeight) {
|
||||
width = 100;
|
||||
height = 100 * photo.height / photo.width * el.offsetWidth / el.offsetHeight;
|
||||
offsetLeft = 0;
|
||||
offsetTop = (100 - height) * 0.5;
|
||||
} else {
|
||||
width = 100 * photo.width / photo.height * el.offsetHeight / el.offsetWidth;
|
||||
height = 100;
|
||||
offsetLeft = (100 - width) * 0.5;
|
||||
offsetTop = 0;
|
||||
}
|
||||
|
||||
|
||||
photo.faces.forEach((face) => {
|
||||
const box = document.createElement("div");
|
||||
box.classList.add("face");
|
||||
document.body.appendChild(box);
|
||||
box.style.left = offsetLeft + Math.floor(face.left * width) + "%";
|
||||
box.style.top = offsetTop + Math.floor(face.top * height) + "%";
|
||||
box.style.width = Math.floor((face.right - face.left) * width) + "%";
|
||||
box.style.height = Math.floor((face.bottom - face.top) * height) + "%";
|
||||
box.addEventListener("click", (event) => {
|
||||
face.relatedPhotos.forEach((photo) => {
|
||||
window.open(base + "face-explorer.html?" + photo.id, "ketr.photo-" + photo.id);
|
||||
});
|
||||
event.preventDefault = true;
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadPhoto(index) {
|
||||
const photo = photos[index],
|
||||
xml = new XMLHttpRequest(),
|
||||
url = base + photo.path /* + "thumbs/scaled/"*/ + photo.filename,
|
||||
taken = new Date(photo.taken);
|
||||
|
||||
document.getElementById("loading").textContent = "0%";
|
||||
|
||||
xml.onprogress = function (event) {
|
||||
var alpha = 0;
|
||||
if (event.total) {
|
||||
alpha = event.loaded / event.total;
|
||||
document.getElementById("loading").textContent = Math.ceil(100 * alpha) + "%";
|
||||
} else {
|
||||
document.getElementById("loading").textContent = "0%";
|
||||
}
|
||||
};
|
||||
|
||||
xml.onload = function(event) {
|
||||
info.textContent =
|
||||
days[taken.getDay()] + ", " +
|
||||
months[taken.getMonth()] + " " +
|
||||
taken.getDate() + " " +
|
||||
taken.getFullYear();
|
||||
document.getElementById("photo").style.backgroundImage = "url(" + encodeURI(url).replace(/\(/g, '%28').replace(/\)/g, '%29') + ")";
|
||||
makeFaceBoxes();
|
||||
var parts = window.location.href.match(/^.*\?(.*)$/);
|
||||
if (!parts) {
|
||||
countdown = 15;
|
||||
tick();
|
||||
}
|
||||
}
|
||||
|
||||
xml.onerror = function(event) {
|
||||
info.textContent = "Error loading photo. Trying next photo.";
|
||||
nextPhoto();
|
||||
}
|
||||
|
||||
xml.open("GET", url, true);
|
||||
xml.send();
|
||||
}
|
||||
|
||||
function nextPhoto() {
|
||||
if (photoIndex < photos.length - 1) {
|
||||
photoIndex++;
|
||||
loadPhoto(photoIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
window.fetch("api/v1/photos/random")
|
||||
.then(res => res.json()).then(function(data) {
|
||||
info.textContent = "Random photo: " + data.id;
|
||||
photos.push(data);
|
||||
photoIndex = photos.length - 1;
|
||||
loadPhoto(photoIndex);
|
||||
});
|
||||
}
|
||||
|
||||
var scheduled = false;
|
||||
|
||||
function tick() {
|
||||
if (scheduled) {
|
||||
clearTimeout(scheduled);
|
||||
}
|
||||
document.getElementById("loading").textContent = countdown + "s";
|
||||
if (countdown > 0) {
|
||||
/* If there is a timer running, then decrement the counter */
|
||||
if (scheduled) {
|
||||
countdown--;
|
||||
}
|
||||
scheduled = setTimeout(tick, 1000);
|
||||
} else {
|
||||
scheduled = null;
|
||||
countdown = 15;
|
||||
nextPhoto();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paused = false,
|
||||
tap = 0;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var tmp = document.querySelector("base");
|
||||
if (tmp) {
|
||||
base = new URL(tmp.href).pathname.replace(/\/$/, "") + "/"; /* Make sure there is a trailing slash */
|
||||
} else {
|
||||
base = "/";
|
||||
}
|
||||
|
||||
var timeout = 0;
|
||||
window.addEventListener("resize", (event) => {
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
timeout = window.setTimeout(makeFaceBoxes, 250);
|
||||
});
|
||||
|
||||
document.addEventListener("click", function(event) {
|
||||
toggleFullscreen();
|
||||
var now = new Date().getTime();
|
||||
if (tap && (now - tap < 300)) {
|
||||
toggleFullscreen();
|
||||
tap = 0;
|
||||
} else {
|
||||
tap = new Date().getTime();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function(event) {
|
||||
switch (event.keyCode) {
|
||||
case 32: /* space */
|
||||
paused = !paused;
|
||||
if (!paused) {
|
||||
tick();
|
||||
} else {
|
||||
document.getElementById("loading").textContent = "||";
|
||||
clearTimeout(scheduled);
|
||||
scheduled = null;
|
||||
}
|
||||
return;
|
||||
|
||||
case 37: /* left */
|
||||
if (photoIndex == 0) {
|
||||
photoIndex = photos.length - 1;
|
||||
} else {
|
||||
photoIndex--;
|
||||
}
|
||||
loadPhoto(photoIndex);
|
||||
return;
|
||||
|
||||
case 39: /* right */
|
||||
nextPhoto();
|
||||
return;
|
||||
|
||||
case 13: /* enter */
|
||||
toggleFullscreen();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
info = document.getElementById("info");
|
||||
|
||||
/* Trim off everything up to and including the ? (if its there) */
|
||||
var parts = window.location.href.match(/^.*\?(.*)$/), id = ""
|
||||
if (parts) {
|
||||
id = "/" + parts[1];
|
||||
}
|
||||
|
||||
window.fetch("api/v1/photos/random" + id)
|
||||
.then(res => res.json()).then(function(data) {
|
||||
info.textContent = "Random photo: " + data.id;
|
||||
photoIndex = 0;
|
||||
photos = [data];
|
||||
loadPhoto(photoIndex);
|
||||
}).catch(function(error) {
|
||||
console.error(error);
|
||||
info.textContent = "Unable to load random photo :(";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
@ -137,8 +137,8 @@ function makeFaceBoxes() {
|
||||
box.style.height = Math.floor((face.bottom - face.top) * height) + "%";
|
||||
box.addEventListener("click", (event) => {
|
||||
console.log(face);
|
||||
face.relatedPhotos.forEach((path) => {
|
||||
window.open(base + path);
|
||||
face.relatedPhotos.forEach((photo) => {
|
||||
window.open(base + photo.path);
|
||||
});
|
||||
event.preventDefault = true;
|
||||
event.stopImmediatePropagation();
|
||||
@ -172,7 +172,7 @@ function loadPhoto(index) {
|
||||
months[taken.getMonth()] + " " +
|
||||
taken.getDate() + " " +
|
||||
taken.getFullYear();
|
||||
document.getElementById("photo").style.backgroundImage = "url(" + encodeURI(url) + ")";
|
||||
document.getElementById("photo").style.backgroundImage = "url(" + encodeURI(url).replace(/\(/g, '%28').replace(/\)/g, '%29') + ")";
|
||||
countdown = 15;
|
||||
tick();
|
||||
window.fetch("api/v1/photos/faces/" + photo.id).then(res => res.json()).then((faces) => {
|
||||
|
@ -61,7 +61,7 @@ faceapi.nets.ssdMobilenetv1.loadFromDisk('./models')
|
||||
return photoDB.sequelize.query("SELECT photos.id,photos.filename,photos.width,photos.height,albums.path " +
|
||||
"FROM photos " +
|
||||
"LEFT JOIN albums ON (albums.id=photos.albumId) " +
|
||||
"WHERE faces=-1 ORDER BY albums.path,photos.filename", {
|
||||
"WHERE faces=-1 AND deleted=0 ORDER BY albums.path,photos.filename", {
|
||||
type: photoDB.sequelize.QueryTypes.SELECT,
|
||||
raw: true
|
||||
}
|
||||
|
@ -781,11 +781,7 @@ router.get("/trash", function(req, res/*, next*/) {
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/faces/:id", (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (id != req.params.id) {
|
||||
return res.status(400).send({ message: "Usage faces/:id"});
|
||||
}
|
||||
function getFacesForPhoto(id) {
|
||||
return photoDB.sequelize.query("SELECT * FROM faces WHERE photoId=:id", {
|
||||
replacements: {
|
||||
id: id
|
||||
@ -797,7 +793,7 @@ router.get("/faces/:id", (req, res) => {
|
||||
return photoDB.sequelize.query(
|
||||
"SELECT face1ID,face2ID " +
|
||||
"FROM facedistances " +
|
||||
"WHERE distance<0.45 AND (face1ID=:id OR face2ID=:id) " +
|
||||
"WHERE distance<0.5 AND (face1ID=:id OR face2ID=:id) " +
|
||||
"ORDER BY distance ASC", {
|
||||
replacements: {
|
||||
id: face.id
|
||||
@ -821,15 +817,72 @@ router.get("/faces/:id", (req, res) => {
|
||||
});
|
||||
}).then((photos) => {
|
||||
face.relatedPhotos = photos.filter((photo) => { return photo.id != id }).map((photo) => {
|
||||
return photo.path + photo.filename;
|
||||
return { id: photo.id, path: photo.path + photo.filename };
|
||||
});
|
||||
});
|
||||
}).then(() => {
|
||||
return res.status(200).json(faces);
|
||||
return faces;
|
||||
});
|
||||
});
|
||||
}
|
||||
router.get("/faces/:id", (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (id != req.params.id) {
|
||||
return res.status(400).send({ message: "Usage faces/:id"});
|
||||
}
|
||||
return getFacesForPhoto(id).then((faces) => {
|
||||
return res.status(200).json(faces);
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/random/:id?", (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
let filter = "";
|
||||
|
||||
if (id == req.params.id) {
|
||||
filter = "AND id=:id";
|
||||
} else {
|
||||
filter = "AND faces>0";
|
||||
}
|
||||
return photoDB.sequelize.query("SELECT id FROM photos WHERE deleted=0 " + filter, {
|
||||
replacements: {
|
||||
id: id
|
||||
},
|
||||
type: photoDB.Sequelize.QueryTypes.SELECT,
|
||||
raw: true
|
||||
}).then((results) => {
|
||||
if (!results.length) {
|
||||
return res.status(404);
|
||||
}
|
||||
const id = results[Math.floor(Math.random() * results.length)].id;
|
||||
|
||||
return photoDB.sequelize.query(
|
||||
"SELECT photos.*,albums.path AS path FROM photos " +
|
||||
"INNER JOIN albums ON albums.id=photos.albumId " +
|
||||
"WHERE photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL AND photos.id=:id", {
|
||||
replacements: {
|
||||
id: id,
|
||||
},
|
||||
type: photoDB.Sequelize.QueryTypes.SELECT,
|
||||
raw: true
|
||||
});
|
||||
}).then(function(photos) {
|
||||
if (!photos.length) {
|
||||
return res.status(404);
|
||||
}
|
||||
const photo = photos[0];
|
||||
for (var key in photo) {
|
||||
if (photo[key] instanceof Date) {
|
||||
photo[key] = moment(photo[key]);
|
||||
}
|
||||
}
|
||||
return getFacesForPhoto(photo.id).then((faces) => {
|
||||
photo.faces = faces;
|
||||
return res.status(200).json(photo);
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
router.get("/mvimg/*", function(req, res/*, next*/) {
|
||||
let limit = parseInt(req.query.limit) || 50,
|
||||
id, cursor, index;
|
||||
|
Loading…
x
Reference in New Issue
Block a user