diff --git a/appinfo/routes.php b/appinfo/routes.php
index e11adb3c..1bb59e98 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -4,24 +4,20 @@ return [
// Days and folder API
['name' => 'page#main', 'url' => '/', 'verb' => 'GET'],
['name' => 'page#folder', 'url' => '/folders/{path}', 'verb' => 'GET',
- 'requirements' => [
- 'path' => '.*',
- ],
- 'defaults' => [
- 'path' => '',
- ]
+ 'requirements' => [ 'path' => '.*' ],
+ 'defaults' => [ 'path' => '' ]
],
['name' => 'page#favorites', 'url' => '/favorites', 'verb' => 'GET'],
['name' => 'page#videos', 'url' => '/videos', 'verb' => 'GET'],
['name' => 'page#archive', 'url' => '/archive', 'verb' => 'GET'],
['name' => 'page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
+ ['name' => 'page#people', 'url' => '/people/{name}', 'verb' => 'GET',
+ 'requirements' => [ 'name' => '.*' ],
+ 'defaults' => [ 'name' => '' ]
+ ],
['name' => 'page#tags', 'url' => '/tags/{name}', 'verb' => 'GET',
- 'requirements' => [
- 'name' => '.*',
- ],
- 'defaults' => [
- 'name' => '',
- ]
+ 'requirements' => [ 'name' => '.*' ],
+ 'defaults' => [ 'name' => '' ]
],
// API
@@ -29,6 +25,8 @@ return [
['name' => 'api#dayPost', 'url' => '/api/days', 'verb' => 'POST'],
['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'],
['name' => 'api#tags', 'url' => '/api/tags', 'verb' => 'GET'],
+ ['name' => 'api#faces', 'url' => '/api/faces', 'verb' => 'GET'],
+ ['name' => 'api#facePreviews', 'url' => '/api/face-previews/{id}', 'verb' => 'GET'],
['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'],
['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
['name' => 'api#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php
index e1fcceec..25c0712c 100644
--- a/lib/Controller/ApiController.php
+++ b/lib/Controller/ApiController.php
@@ -81,6 +81,12 @@ class ApiController extends Controller {
$transforms[] = array($this->timelineQuery, 'transformVideoFilter');
}
+ // Filter only for one face
+ $faceId = $this->request->getParam('face');
+ if ($faceId) {
+ $transforms[] = array($this->timelineQuery, 'transformFaceFilter', intval($faceId));
+ }
+
// Filter only for one tag
$tagName = $this->request->getParam('tag');
if ($tagName) {
@@ -303,6 +309,57 @@ class ApiController extends Controller {
return new JSONResponse($list, Http::STATUS_OK);
}
+ /**
+ * @NoAdminRequired
+ *
+ * Get list of faces with counts of images
+ * @return JSONResponse
+ */
+ public function faces(): JSONResponse {
+ $user = $this->userSession->getUser();
+ if (is_null($user)) {
+ return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
+ }
+
+ // If this isn't the timeline folder then things aren't going to work
+ $folder = $this->getRequestFolder();
+ if (is_null($folder)) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ // Run actual query
+ $list = $this->timelineQuery->getFaces(
+ $folder,
+ );
+ return new JSONResponse($list, Http::STATUS_OK);
+ }
+
+ /**
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ *
+ * Get preview objects for a face ID
+ * @return JSONResponse
+ */
+ public function facePreviews(string $id): JSONResponse {
+ $user = $this->userSession->getUser();
+ if (is_null($user)) {
+ return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
+ }
+
+ // If this isn't the timeline folder then things aren't going to work
+ $folder = $this->getRequestFolder();
+ if (is_null($folder)) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ // Run actual query
+ $list = $this->timelineQuery->getFacePreviews(
+ $folder, intval($id),
+ );
+ return new JSONResponse($list, Http::STATUS_OK);
+ }
+
/**
* @NoAdminRequired
*
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index a73025fc..3ef05f18 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -68,6 +68,7 @@ class PageController extends Controller {
// Apps enabled
$this->initialState->provideInitialState('systemtags', $this->appManager->isEnabledForUser('systemtags') === true);
+ $this->initialState->provideInitialState('recognize', $this->appManager->isEnabledForUser('recognize') === true);
$response = new TemplateResponse($this->appName, 'main');
return $response;
@@ -113,6 +114,14 @@ class PageController extends Controller {
return $this->main();
}
+ /**
+ * @NoAdminRequired
+ * @NoCSRFRequired
+ */
+ public function people() {
+ return $this->main();
+ }
+
/**
* @NoAdminRequired
* @NoCSRFRequired
diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php
index 13092d31..c31895d1 100644
--- a/lib/Db/TimelineQuery.php
+++ b/lib/Db/TimelineQuery.php
@@ -9,6 +9,7 @@ class TimelineQuery {
use TimelineQueryDays;
use TimelineQueryFilters;
use TimelineQueryTags;
+ use TimelineQueryFaces;
protected IDBConnection $connection;
diff --git a/lib/Db/TimelineQueryFaces.php b/lib/Db/TimelineQueryFaces.php
new file mode 100644
index 00000000..cd959854
--- /dev/null
+++ b/lib/Db/TimelineQueryFaces.php
@@ -0,0 +1,86 @@
+innerJoin('m', 'recognize_face_detections', 'rfd', $query->expr()->andX(
+ $query->expr()->eq('rfd.file_id', 'm.fileid'),
+ $query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($faceId)),
+ ));
+ }
+
+ public function getFaces(Folder $folder) {
+ $query = $this->connection->getQueryBuilder();
+
+ // SELECT all face clusters
+ $count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
+ $query->select('rfc.id', 'rfc.title', $count)->from('recognize_face_clusters', 'rfc');
+
+ // WHERE there are faces with this cluster
+ $query->innerJoin('rfc', 'recognize_face_detections', 'rfd', $query->expr()->eq('rfc.id', 'rfd.cluster_id'));
+
+ // WHERE these items are memories indexed photos
+ $query->innerJoin('rfd', 'memories', 'm', $query->expr()->eq('m.fileid', 'rfd.file_id'));
+
+ // WHERE these photos are in the user's requested folder recursively
+ $query->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, true, false));
+
+ // GROUP by ID of face cluster
+ $query->groupBy('rfc.id');
+
+ // ORDER by number of faces in cluster
+ $query->orderBy('count', 'DESC');
+
+ // FETCH all faces
+ $faces = $query->executeQuery()->fetchAll();
+
+ // Post process
+ foreach($faces as &$row) {
+ $row["name"] = $row["title"];
+ unset($row["title"]);
+ $row["count"] = intval($row["count"]);
+ }
+
+ return $faces;
+ }
+
+ public function getFacePreviews(Folder $folder, int $faceId) {
+ $query = $this->connection->getQueryBuilder();
+
+ // SELECT face detections for ID
+ $query->select('rfd.file_id', 'rfd.x', 'rfd.y', 'rfd.width', 'rfd.height', 'f.etag')->from('recognize_face_detections', 'rfd');
+ $query->where($query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($faceId)));
+
+ // WHERE these photos are memories indexed
+ $query->innerJoin('rfd', 'memories', 'm', $query->expr()->eq('m.fileid', 'rfd.file_id'));
+
+ // WHERE these photos are in the user's requested folder recursively
+ $query->innerJoin('m', 'filecache', 'f', $this->getFilecacheJoinQuery($query, $folder, true, false));
+
+ // MAX 4 results
+ $query->setMaxResults(4);
+
+ // FETCH all face detections
+ $previews = $query->executeQuery()->fetchAll();
+
+ // Post-process, everthing is a number
+ foreach($previews as &$row) {
+ $row["fileid"] = intval($row["file_id"]);
+ unset($row["file_id"]);
+ $row["x"] = floatval($row["x"]);
+ $row["y"] = floatval($row["y"]);
+ $row["width"] = floatval($row["width"]);
+ $row["height"] = floatval($row["height"]);
+ }
+
+ return $previews;
+ }
+}
\ No newline at end of file
diff --git a/src/App.vue b/src/App.vue
index 872f1168..32e6376b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -19,6 +19,10 @@
:title="t('memories', 'Videos')">
+
+
+
@@ -79,6 +83,7 @@ import Star from 'vue-material-design-icons/Star.vue'
import Video from 'vue-material-design-icons/Video.vue'
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
import CalendarIcon from 'vue-material-design-icons/Calendar.vue';
+import PeopleIcon from 'vue-material-design-icons/AccountBoxMultiple.vue';
import TagsIcon from 'vue-material-design-icons/Tag.vue';
@Component({
@@ -98,6 +103,7 @@ import TagsIcon from 'vue-material-design-icons/Tag.vue';
Video,
ArchiveIcon,
CalendarIcon,
+ PeopleIcon,
TagsIcon,
},
})
diff --git a/src/components/Tag.vue b/src/components/Tag.vue
index 3cc4876b..b624514f 100644
--- a/src/components/Tag.vue
+++ b/src/components/Tag.vue
@@ -3,8 +3,9 @@
hasPreview: previews.length > 0,
onePreview: previews.length === 1,
hasError: error,
+ isFace: isFace,
}"
- @click="openTag(data)"
+ @click="openTag()"
v-bind:style="{
width: rowHeight + 'px',
height: rowHeight + 'px',
@@ -22,6 +23,7 @@
'p-loading': !(info.flag & c.FLAG_LOADED),
'p-load-fail': info.flag & c.FLAG_LOAD_FAIL,
}"
+ :style="extraStyles"
@load="info.flag |= c.FLAG_LOADED"
@error="info.flag |= c.FLAG_LOAD_FAIL" />
@@ -39,6 +41,14 @@ import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble'
import axios from '@nextcloud/axios'
import GlobalMixin from '../mixins/GlobalMixin';
+import { constants } from '../services/Utils';
+
+interface IFaceDetection extends IPhoto {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
@Component({
components: {
@@ -55,8 +65,8 @@ export default class Tag extends Mixins(GlobalMixin) {
// Error occured fetching thumbs
private error = false;
- /** Passthrough */
- private getPreviewUrl = getPreviewUrl;
+ /** Extra styles to apply on image */
+ private extraStyles = {};
mounted() {
this.refreshPreviews();
@@ -67,12 +77,31 @@ export default class Tag extends Mixins(GlobalMixin) {
this.refreshPreviews();
}
- /** Refresh previews */
+ getPreviewUrl(fileid: number, etag: string) {
+ if (this.isFace) {
+ return generateUrl(`/core/preview?fileId=${fileid}&c=${etag}&x=2048&y=2048&forceIcon=0&a=1`);
+ }
+ return getPreviewUrl(fileid, etag);
+ }
+
+ get isFace() {
+ return this.data.flag & constants.c.FLAG_IS_FACE;
+ }
+
async refreshPreviews() {
// Reset state
this.error = false;
+ this.extraStyles = {};
- // Get previews
+ if (this.isFace) {
+ await this.refreshPreviewsFace();
+ } else {
+ await this.refreshPreviewsTag();
+ }
+ }
+
+ /** Refresh previews for tag */
+ async refreshPreviewsTag() {
const url = `/apps/memories/api/days/*?limit=4&tag=${this.data.name}`;
try {
const res = await axios.get(generateUrl(url));
@@ -87,9 +116,66 @@ export default class Tag extends Mixins(GlobalMixin) {
}
}
+ /** Refresh previews for face */
+ async refreshPreviewsFace() {
+ const url = `/apps/memories/api/face-previews/${this.data.faceid}`;
+ try {
+ const res = await axios.get(generateUrl(url));
+ res.data.forEach((p) => p.flag = 0);
+ const face = this.chooseFaceDetection(res.data);
+ this.previews = [face];
+ this.extraStyles = this.getCoverStyle(face);
+ } catch (e) {
+ this.error = true;
+ console.error(e);
+ }
+ }
+
/** Open tag */
- openTag(tag: ITag) {
- this.$router.push({ name: 'tags', params: { name: tag.name }});
+ openTag() {
+ if (this.isFace) {
+ this.$router.push({ name: 'people', params: { name: this.data.faceid.toString() }});
+ } else {
+ this.$router.push({ name: 'tags', params: { name: this.data.name }});
+ }
+ }
+
+ /** Choose the most appropriate face detection */
+ private chooseFaceDetection(detections: IFaceDetection[]) {
+ const scoreFacePosition = (faceDetection: IFaceDetection) => {
+ return Math.max(0, -1 * (faceDetection.x - faceDetection.width * 0.5))
+ + Math.max(0, -1 * (faceDetection.y - faceDetection.height * 0.5))
+ + Math.max(0, -1 * (1 - (faceDetection.x + faceDetection.width) - faceDetection.width * 0.5))
+ + Math.max(0, -1 * (1 - (faceDetection.y + faceDetection.height) - faceDetection.height * 0.5))
+ }
+
+ return detections.sort((a, b) =>
+ scoreFacePosition(a)
+ - scoreFacePosition(b)
+ )[0];
+ }
+
+ /**
+ * This will produce an inline style to apply to images
+ * to zoom toward the detected face
+ *
+ * @return {{}|{transform: string, width: string, transformOrigin: string}}
+ */
+ getCoverStyle(detection: IFaceDetection) {
+ // Zoom into the picture so that the face fills the --photos-face-width box nicely
+ // if the face is larger than the image, we don't zoom out (reason for the Math.max)
+ const zoom = Math.max(1, (1 / detection.width) * 0.4)
+
+ const horizontalCenterOfFace = (detection.x + detection.width / 2) * 100
+ const verticalCenterOfFace = (detection.y + detection.height / 2) * 100
+
+ return {
+ // we translate the image so that the center of the detected face is in the center
+ // and add the zoom
+ transform: `translate(calc(${this.rowHeight}px/2 - ${horizontalCenterOfFace}% ), calc(${this.rowHeight}px/2 - ${verticalCenterOfFace}% )) scale(${zoom})`,
+ // this is necessary for the zoom to zoom toward the center of the face
+ transformOrigin: `${horizontalCenterOfFace}% ${verticalCenterOfFace}%`,
+ }
}
}
@@ -112,6 +198,12 @@ export default class Tag extends Mixins(GlobalMixin) {
word-wrap: break-word;
text-overflow: ellipsis;
line-height: 1em;
+
+ .isFace > & {
+ top: unset;
+ bottom: 10%;
+ transform: unset;
+ }
}
.bbl {
@@ -134,6 +226,7 @@ export default class Tag extends Mixins(GlobalMixin) {
margin: 0;
width: 50%;
height: 50%;
+ overflow: hidden;
display: inline-block;
.tag.onePreview > & {
@@ -143,7 +236,6 @@ export default class Tag extends Mixins(GlobalMixin) {
> img {
padding: 0;
width: 100%;
- height: 100%;
filter: brightness(50%);
cursor: pointer;
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index ee690c7d..a1b0f542 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -514,6 +514,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
query.set('archive', '1');
}
+ // People
+ if (this.$route.name === 'people' && this.$route.params.name) {
+ query.set('face', this.$route.params.name);
+ }
+
// Tags
if (this.$route.name === 'tags' && this.$route.params.name) {
query.set('tag', this.$route.params.name);
@@ -550,11 +555,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// Special headers
if (head.dayId === this.TagDayID.FOLDERS) {
- head.name = this.t("memories", "Folders");
- return head.name;
+ return (head.name = this.t("memories", "Folders"));
} else if (head.dayId === this.TagDayID.TAGS) {
- head.name = this.t("memories", "Tags");
- return head.name;
+ return (head.name = this.t("memories", "Tags"));
+ } else if (head.dayId === this.TagDayID.FACES) {
+ return (head.name = this.t("memories", "People"));
}
// Make date string
@@ -582,6 +587,8 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
data = await dav.getOnThisDayData();
} else if (this.$route.name === 'tags' && !this.$route.params.name) {
data = await dav.getTagsData();
+ } else if (this.$route.name === 'people' && !this.$route.params.name) {
+ data = await dav.getPeopleData();
} else {
data = (await axios.get(generateUrl(this.appendQuery(url), params))).data;
}
@@ -895,6 +902,10 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
photo.flag |= this.c.FLAG_IS_FOLDER;
delete photo.isfolder;
}
+ if (photo.isface) {
+ photo.flag |= this.c.FLAG_IS_FACE;
+ delete photo.isface;
+ }
if (photo.istag) {
photo.flag |= this.c.FLAG_IS_TAG;
delete photo.istag;
diff --git a/src/mixins/UserConfig.ts b/src/mixins/UserConfig.ts
index deb9a664..c0682296 100644
--- a/src/mixins/UserConfig.ts
+++ b/src/mixins/UserConfig.ts
@@ -33,6 +33,7 @@ export default class UserConfig extends Vue {
config_timelinePath = loadState('memories', 'timelinePath') || '';
config_showHidden = loadState('memories', 'showHidden') === "true";
config_tagsEnabled = loadState('memories', 'systemtags');
+ config_recognizeEnabled = loadState('memories', 'recognize');
created() {
subscribe(eventName, this.updateLocalSetting)
diff --git a/src/router.ts b/src/router.ts
index b65ded14..b0c7b240 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -100,6 +100,15 @@
}),
},
+ {
+ path: '/people/:name*',
+ component: Timeline,
+ name: 'people',
+ props: route => ({
+ rootTitle: t('memories', 'People'),
+ }),
+ },
+
{
path: '/tags/:name*',
component: Timeline,
diff --git a/src/services/DavRequests.ts b/src/services/DavRequests.ts
index fbaee789..15262951 100644
--- a/src/services/DavRequests.ts
+++ b/src/services/DavRequests.ts
@@ -430,7 +430,7 @@ export async function getOnThisDayData(): Promise {
/**
* Get list of tags and convert to Days response
*/
- export async function getTagsData(): Promise {
+export async function getTagsData(): Promise {
// Query for photos
let data: {
count: number;
@@ -455,4 +455,36 @@ export async function getOnThisDayData(): Promise {
istag: true,
} as ITag)),
}]
+}
+
+/**
+ * Get list of tags and convert to Days response
+ */
+ export async function getPeopleData(): Promise {
+ // Query for photos
+ let data: {
+ id: number;
+ count: number;
+ name: string;
+ }[] = [];
+ try {
+ const res = await axios.get(generateUrl('/apps/memories/api/faces'));
+ data = res.data;
+ } catch (e) {
+ throw e;
+ }
+
+ // Convert to days response
+ return [{
+ dayid: constants.TagDayID.FACES,
+ count: data.length,
+ detail: data.map((face) => ({
+ name: face.name,
+ count: face.count,
+ fileid: hashCode(face.name),
+ faceid: face.id,
+ istag: true,
+ isface: true,
+ } as any)),
+ }]
}
\ No newline at end of file
diff --git a/src/services/Utils.ts b/src/services/Utils.ts
index 0a0c399f..b7656730 100644
--- a/src/services/Utils.ts
+++ b/src/services/Utils.ts
@@ -52,16 +52,18 @@ export const constants = {
FLAG_IS_FAVORITE: 1 << 4,
FLAG_IS_FOLDER: 1 << 5,
FLAG_IS_TAG: 1 << 6,
- FLAG_SELECTED: 1 << 7,
- FLAG_LEAVING: 1 << 8,
- FLAG_EXIT_LEFT: 1 << 9,
- FLAG_ENTER_RIGHT: 1 << 10,
- FLAG_FORCE_RELOAD: 1 << 11,
+ FLAG_IS_FACE: 1 << 7,
+ FLAG_SELECTED: 1 << 8,
+ FLAG_LEAVING: 1 << 9,
+ FLAG_EXIT_LEFT: 1 << 10,
+ FLAG_ENTER_RIGHT: 1 << 11,
+ FLAG_FORCE_RELOAD: 1 << 12,
},
TagDayID: {
START: -(1 << 30),
FOLDERS: -(1 << 30) + 1,
TAGS: -(1 << 30) + 2,
+ FACES: -(1 << 30) + 3,
},
}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index b076581c..a6cc8497 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -49,6 +49,8 @@ export type IPhoto = {
isfolder?: boolean;
/** Is this a tag */
istag?: boolean;
+ /** Is this a face */
+ isface?: boolean;
/** Optional datetaken epoch */
datetaken?: number;
}
@@ -67,6 +69,8 @@ export interface ITag extends IPhoto {
name: string;
/** Number of images in this tag */
count: number;
+ /** ID of face if this is a face */
+ faceid?: number;
}
export type IRow = {