From aeffe628f2fc6d0c99bd3e4537497eaf96d8fbc0 Mon Sep 17 00:00:00 2001 From: Matias De lellis Date: Thu, 8 Dec 2022 13:00:53 -0800 Subject: [PATCH] Integration with facerecognition --- appinfo/routes.php | 9 +- lib/Controller/ApiBase.php | 14 + lib/Controller/DaysController.php | 22 +- lib/Controller/PageController.php | 14 +- ...cesController.php => PeopleController.php} | 118 ++++++++- lib/Db/TimelineQuery.php | 3 +- lib/Db/TimelineQueryDays.php | 3 +- lib/Db/TimelineQueryPeopleFaceRecognition.php | 241 ++++++++++++++++++ ...s.php => TimelineQueryPeopleRecognize.php} | 15 +- lib/Util.php | 41 ++- src/App.vue | 48 +++- src/components/SelectionManager.vue | 6 +- src/components/Timeline.vue | 57 ++++- src/components/frame/Tag.vue | 15 +- src/components/modal/FaceDeleteModal.vue | 9 +- src/components/modal/FaceEditModal.vue | 15 +- src/components/modal/FaceList.vue | 12 +- src/components/modal/FaceMergeModal.vue | 2 +- src/components/top-matter/FaceTopMatter.vue | 2 +- src/components/top-matter/TopMatter.vue | 3 +- src/mixins/UserConfig.ts | 6 + src/router.ts | 13 +- src/services/API.ts | 11 +- src/services/Utils.ts | 16 +- src/services/dav/face.ts | 46 +++- src/types.ts | 2 +- 26 files changed, 653 insertions(+), 90 deletions(-) rename lib/Controller/{FacesController.php => PeopleController.php} (58%) create mode 100644 lib/Db/TimelineQueryPeopleFaceRecognition.php rename lib/Db/{TimelineQueryFaces.php => TimelineQueryPeopleRecognize.php} (91%) diff --git a/appinfo/routes.php b/appinfo/routes.php index 3324b3e8..e37edef4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -23,7 +23,8 @@ return [ // Routes with params w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'), w(['name' => 'Page#albums', 'url' => '/albums/{id}', 'verb' => 'GET'], 'id'), - w(['name' => 'Page#people', 'url' => '/people/{name}', 'verb' => 'GET'], 'name'), + w(['name' => 'Page#recognize', 'url' => '/recognize/{name}', 'verb' => 'GET'], 'name'), + w(['name' => 'Page#facerecognition', 'url' => '/facerecognition/{name}', 'verb' => 'GET'], 'name'), w(['name' => 'Page#tags', 'url' => '/tags/{name}', 'verb' => 'GET'], 'name'), // Public folder share @@ -50,8 +51,10 @@ return [ ['name' => 'Tags#tags', 'url' => '/api/tags', 'verb' => 'GET'], ['name' => 'Tags#preview', 'url' => '/api/tags/preview/{tag}', 'verb' => 'GET'], - ['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'], - ['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'], + ['name' => 'People#recognizePeople', 'url' => '/api/recognize/people', 'verb' => 'GET'], + ['name' => 'People#recognizePeoplePreview', 'url' => '/api/recognize/people/preview/{id}', 'verb' => 'GET'], + ['name' => 'People#facerecognitionPeople', 'url' => '/api/facerecognition/people', 'verb' => 'GET'], + ['name' => 'People#facerecognitionPeoplePreview', 'url' => '/api/facerecognition/people/preview/{id}', 'verb' => 'GET'], ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php index c0a674af..f63811d3 100644 --- a/lib/Controller/ApiBase.php +++ b/lib/Controller/ApiBase.php @@ -291,6 +291,20 @@ class ApiBase extends Controller return \OCA\Memories\Util::recognizeIsEnabled($this->appManager); } + // Check if facerecognition is installed and enabled for this user. + protected function facerecognitionIsInstalled(): bool + { + return \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager); + } + + /** + * Check if facerecognition is enabled for this user. + */ + protected function facerecognitionIsEnabled(): bool + { + return \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $this->getUID()); + } + /** * Helper to get one file or null from a fiolder. */ diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php index 97616af2..8f675071 100644 --- a/lib/Controller/DaysController.php +++ b/lib/Controller/DaysController.php @@ -198,16 +198,24 @@ class DaysController extends ApiBase $transforms[] = [$this->timelineQuery, 'transformVideoFilter']; } - // Filter only for one face - if ($this->recognizeIsEnabled()) { - $face = $this->request->getParam('face'); - if ($face) { - $transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face]; - } + // Filter only for one face on Recognize + if (($recognize = $this->request->getParam('recognize')) && $this->recognizeIsEnabled()) { + $transforms[] = [$this->timelineQuery, 'transformPeopleRecognitionFilter', $recognize]; $faceRect = $this->request->getParam('facerect'); if ($faceRect && !$aggregateOnly) { - $transforms[] = [$this->timelineQuery, 'transformFaceRect', $face]; + $transforms[] = [$this->timelineQuery, 'transformPeopleRecognizeRect', $recognize]; + } + } + + // Filter only for one face on Face Recognition + if (($face = $this->request->getParam('facerecognition')) && $this->facerecognitionIsEnabled()) { + $currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1); + $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionFilter', $currentModel, $face]; + + $faceRect = $this->request->getParam('facerect'); + if ($faceRect && !$aggregateOnly) { + $transforms[] = [$this->timelineQuery, 'transformPeopleFaceRecognitionRect', $face]; } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index eb13f0f7..aaebe09f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -86,6 +86,8 @@ class PageController extends Controller $this->initialState->provideInitialState('systemtags', true === $this->appManager->isEnabledForUser('systemtags')); $this->initialState->provideInitialState('maps', true === $this->appManager->isEnabledForUser('maps')); $this->initialState->provideInitialState('recognize', \OCA\Memories\Util::recognizeIsEnabled($this->appManager)); + $this->initialState->provideInitialState('facerecognitionInstalled', \OCA\Memories\Util::facerecognitionIsInstalled($this->appManager)); + $this->initialState->provideInitialState('facerecognitionEnabled', \OCA\Memories\Util::facerecognitionIsEnabled($this->config, $uid)); $this->initialState->provideInitialState('albums', \OCA\Memories\Util::albumsIsEnabled($this->appManager)); // App version @@ -181,7 +183,17 @@ class PageController extends Controller * * @NoCSRFRequired */ - public function people() + public function recognize() + { + return $this->main(); + } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + */ + public function facerecognition() { return $this->main(); } diff --git a/lib/Controller/FacesController.php b/lib/Controller/PeopleController.php similarity index 58% rename from lib/Controller/FacesController.php rename to lib/Controller/PeopleController.php index bb64c91a..12cbcd6e 100644 --- a/lib/Controller/FacesController.php +++ b/lib/Controller/PeopleController.php @@ -29,14 +29,14 @@ use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\JSONResponse; use OCP\Files\FileInfo; -class FacesController extends ApiBase +class PeopleController extends ApiBase { /** * @NoAdminRequired * * Get list of faces with counts of images */ - public function faces(): JSONResponse + public function recognizePeople(): JSONResponse { $user = $this->userSession->getUser(); if (null === $user) { @@ -45,7 +45,7 @@ class FacesController extends ApiBase // Check faces enabled for this user if (!$this->recognizeIsEnabled()) { - return new JSONResponse(['message' => 'Recognize app not enabled or not v3+'], Http::STATUS_PRECONDITION_FAILED); + return new JSONResponse(['message' => 'Recognize app not enabled or not v3+.'], Http::STATUS_PRECONDITION_FAILED); } // If this isn't the timeline folder then things aren't going to work @@ -55,7 +55,7 @@ class FacesController extends ApiBase } // Run actual query - $list = $this->timelineQuery->getFaces( + $list = $this->timelineQuery->getPeopleRecognize( $root, ); @@ -71,7 +71,7 @@ class FacesController extends ApiBase * * @return DataResponse */ - public function preview(string $id): Http\Response + public function recognizePeoplePreview(int $id): Http\Response { $user = $this->userSession->getUser(); if (null === $user) { @@ -90,11 +90,115 @@ class FacesController extends ApiBase } // Run actual query - $detections = $this->timelineQuery->getFacePreviewDetection($root, (int) $id); + $detections = $this->timelineQuery->getPeopleRecognizePreview($root, $id); + if (null === $detections || 0 === \count($detections)) { return new DataResponse([], Http::STATUS_NOT_FOUND); } + return $this->getPreviewResponse($detections, $user, 1.5); + } + + /** + * @NoAdminRequired + * + * Get list of faces with counts of images + */ + public function facerecognitionPeople(): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check if face recognition is installed and enabled for this user + if (!$this->facerecognitionIsInstalled()) { + return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // If this isn't the timeline folder then things aren't going to work + $root = $this->getRequestRoot(); + if ($root->isEmpty()) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // If the user has recognition disabled, just returns an empty response. + if (!$this->facerecognitionIsEnabled()) { + return new JSONResponse([]); + } + + // Run actual query + $currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1); + $list = $this->timelineQuery->getPeopleFaceRecognition( + $root, + $currentModel, + ); + // Just append unnamed clusters to the end. + $list = array_merge($list, $this->timelineQuery->getPeopleFaceRecognition( + $root, + $currentModel, + true + )); + + return new JSONResponse($list, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * Get face preview image cropped with imagick + * + * @return DataResponse + */ + public function facerecognitionPeoplePreview(string $id): Http\Response + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check if face recognition is installed and enabled for this user + if (!$this->facerecognitionIsInstalled()) { + return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Get folder to search for + $root = $this->getRequestRoot(); + if ($root->isEmpty()) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // If the user has facerecognition disabled, just returns an empty response. + if (!$this->facerecognitionIsEnabled()) { + return new JSONResponse([]); + } + + // Run actual query + $currentModel = (int) $this->config->getAppValue('facerecognition', 'model', -1); + $detections = $this->timelineQuery->getFaceRecognitionPreview($root, $currentModel, $id); + + if (null === $detections || 0 === \count($detections)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + return $this->getPreviewResponse($detections, $user, 1.8); + } + + /** + * Get face preview image cropped with imagick. + * + * @param array $detections Array of detections to search + * @param \OCP\IUser $user User to search for + * @param int $padding Padding to add to the face in preview + */ + private function getPreviewResponse( + array $detections, + \OCP\IUser $user, + float $padding + ): Http\Response + { // Get preview manager $previewManager = \OC::$server->get(\OCP\IPreview::class); @@ -152,7 +256,7 @@ class FacesController extends ApiBase $dh = (float) $detection['height']; $dcx = (float) $detection['x'] + (float) $detection['width'] / 2; $dcy = (float) $detection['y'] + (float) $detection['height'] / 2; - $faceDim = max($dw * $iw, $dh * $ih) * 1.5; + $faceDim = max($dw * $iw, $dh * $ih) * $padding; $image->cropImage( (int) $faceDim, (int) $faceDim, diff --git a/lib/Db/TimelineQuery.php b/lib/Db/TimelineQuery.php index e5a23ec0..8cc923c1 100644 --- a/lib/Db/TimelineQuery.php +++ b/lib/Db/TimelineQuery.php @@ -11,10 +11,11 @@ class TimelineQuery { use TimelineQueryAlbums; use TimelineQueryDays; - use TimelineQueryFaces; use TimelineQueryFilters; use TimelineQueryFolders; use TimelineQueryLivePhoto; + use TimelineQueryPeopleFaceRecognition; + use TimelineQueryPeopleRecognize; use TimelineQueryTags; protected IDBConnection $connection; diff --git a/lib/Db/TimelineQueryDays.php b/lib/Db/TimelineQueryDays.php index 4a60eda7..90750ccc 100644 --- a/lib/Db/TimelineQueryDays.php +++ b/lib/Db/TimelineQueryDays.php @@ -299,7 +299,8 @@ trait TimelineQueryDays } // All transform processing - $this->processFace($row); + $this->processPeopleRecognizeDetection($row); + $this->processFaceRecognitionDetection($row); // We don't need these fields unset($row['datetaken'], $row['rootid']); diff --git a/lib/Db/TimelineQueryPeopleFaceRecognition.php b/lib/Db/TimelineQueryPeopleFaceRecognition.php new file mode 100644 index 00000000..2676b873 --- /dev/null +++ b/lib/Db/TimelineQueryPeopleFaceRecognition.php @@ -0,0 +1,241 @@ +innerJoin('m', 'facerecog_images', 'fri', $query->expr()->andX( + $query->expr()->eq('fri.file', 'm.fileid'), + $query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)), + )); + + // Join with faces + $query->innerJoin( + 'fri', + 'facerecog_faces', + 'frf', + $query->expr()->eq('frf.image', 'fri.id') + ); + + // Join with persons + $nameField = is_numeric($personName) ? 'frp.id' : 'frp.name'; + $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->andX( + $query->expr()->eq('frf.person', 'frp.id'), + $query->expr()->eq('frp.user', $query->createNamedParameter($personUid)), + $query->expr()->eq($nameField, $query->createNamedParameter($personName)), + )); + } + + public function transformPeopleFaceRecognitionRect(IQueryBuilder &$query, string $userId) + { + // Include detection params in response + $query->addSelect( + 'frf.x AS face_x', + 'frf.y AS face_y', + 'frf.width AS face_width', + 'frf.height AS face_height', + 'm.w AS image_width', + 'm.h AS image_height', + ); + } + + public function getPeopleFaceRecognition(TimelineRoot &$root, int $currentModel, bool $show_clusters = false, bool $show_singles = false, bool $show_hidden = false) + { + $query = $this->connection->getQueryBuilder(); + + // SELECT all face clusters + $count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count'); + $query->select('frp.id', 'frp.user as user_id', 'frp.name', $count)->from('facerecog_persons', 'frp'); + + // WHERE there are faces with this cluster + $query->innerJoin('frp', 'facerecog_faces', 'frf', $query->expr()->eq('frp.id', 'frf.person')); + + // WHERE faces are from images. + $query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->eq('fri.id', 'frf.image')); + + // WHERE these items are memories indexed photos + $query->innerJoin('fri', 'memories', 'm', $query->expr()->andX( + $query->expr()->eq('fri.file', 'm.fileid'), + $query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)), + )); + + // WHERE these photos are in the user's requested folder recursively + $query = $this->joinFilecache($query, $root, true, false); + + if ($show_clusters) { + // GROUP by ID of face cluster + $query->groupBy('frp.id'); + $query->where($query->expr()->isNull('frp.name')); + } else { + // GROUP by name of face clusters + $query->groupBy('frp.name'); + $query->where($query->expr()->isNotNull('frp.name')); + } + + // By default hides individual faces when they have no name. + if ($show_clusters && !$show_singles) { + $query->having($query->expr()->gt('count', $query->createNamedParameter(1))); + } + + // By default it shows the people who were not hidden + if (!$show_hidden) { + $query->andWhere($query->expr()->eq('frp.is_visible', $query->createNamedParameter(true))); + } + + // ORDER by number of faces in cluster + $query->orderBy('count', 'DESC'); + $query->addOrderBy('name', 'ASC'); + $query->addOrderBy('frp.id'); // tie-breaker + + // FETCH all faces + $cursor = $this->executeQueryWithCTEs($query); + $faces = $cursor->fetchAll(); + + // Post process + foreach ($faces as &$row) { + $row['id'] = $row['name'] ?: (int) $row['id']; + $row['count'] = (int) $row['count']; + } + + return $faces; + } + + public function getFaceRecognitionPreview(TimelineRoot &$root, $currentModel, $previewId) + { + $query = $this->connection->getQueryBuilder(); + + // SELECT face detections + $query->select( + 'fri.file as file_id', // Get actual file + 'frf.x', // Image cropping + 'frf.y', + 'frf.width', + 'frf.height', + 'm.w as image_width', // Scoring + 'm.h as image_height', + 'frf.confidence', + 'm.fileid', + 'm.datetaken', // Just in case, for postgres + )->from('facerecog_faces', 'frf'); + + // WHERE faces are from images and current model. + $query->innerJoin('frf', 'facerecog_images', 'fri', $query->expr()->andX( + $query->expr()->eq('fri.id', 'frf.image'), + $query->expr()->eq('fri.model', $query->createNamedParameter($currentModel)), + )); + + // WHERE these photos are memories indexed + $query->innerJoin('fri', 'memories', 'm', $query->expr()->eq('m.fileid', 'fri.file')); + + $query->innerJoin('frf', 'facerecog_persons', 'frp', $query->expr()->eq('frp.id', 'frf.person')); + if (is_numeric($previewId)) { + // WHERE faces are from id persons (a cluster). + $query->where($query->expr()->eq('frp.id', $query->createNamedParameter($previewId))); + } else { + // WHERE faces are from name on persons. + $query->where($query->expr()->eq('frp.name', $query->createNamedParameter($previewId))); + } + + // WHERE these photos are in the user's requested folder recursively + $query = $this->joinFilecache($query, $root, true, false); + + // LIMIT results + $query->setMaxResults(15); + + // Sort by date taken so we get recent photos + $query->orderBy('m.datetaken', 'DESC'); + $query->addOrderBy('m.fileid', 'DESC'); // tie-breaker + + // FETCH face detections + $cursor = $this->executeQueryWithCTEs($query); + $previews = $cursor->fetchAll(); + if (empty($previews)) { + return null; + } + + // Score the face detections + foreach ($previews as &$p) { + // Get actual pixel size of face + $iw = min((int) ($p['image_width'] ?: 512), 2048); + $ih = min((int) ($p['image_height'] ?: 512), 2048); + + // Get percentage position and size + $p['x'] = (float) $p['x'] / $p['image_width']; + $p['y'] = (float) $p['y'] / $p['image_height']; + $p['width'] = (float) $p['width'] / $p['image_width']; + $p['height'] = (float) $p['height'] / $p['image_height']; + + $w = (float) $p['width']; + $h = (float) $p['height']; + + // Get center of face + $x = (float) $p['x'] + (float) $p['width'] / 2; + $y = (float) $p['y'] + (float) $p['height'] / 2; + + // 3D normal distribution - if the face is closer to the center, it's better + $positionScore = exp(-($x - 0.5) ** 2 * 4) * exp(-($y - 0.5) ** 2 * 4); + + // Root size distribution - if the image is bigger, it's better, + // but it doesn't matter beyond a certain point + $imgSizeScore = ($iw * 100) ** (1 / 2) * ($ih * 100) ** (1 / 2); + + // Faces occupying too much of the image don't look particularly good + $faceSizeScore = (-$w ** 2 + $w) * (-$h ** 2 + $h); + + // Combine scores + $p['score'] = $positionScore * $imgSizeScore * $faceSizeScore * $p['confidence']; + } + + // Sort previews by score descending + usort($previews, function ($a, $b) { + return $b['score'] <=> $a['score']; + }); + + return $previews; + } + + /** Convert face fields to object */ + private function processFaceRecognitionDetection(&$row, $days = false) + { + if (!isset($row)) { + return; + } + + // Differentiate Recognize queries from Face Recognition + if (!isset($row['face_width']) || !isset($row['image_width'])) { + return; + } + + if (!$days) { + $row['facerect'] = [ + // Get percentage position and size + 'w' => (float) $row['face_width'] / $row['image_width'], + 'h' => (float) $row['face_height'] / $row['image_height'], + 'x' => (float) $row['face_x'] / $row['image_width'], + 'y' => (float) $row['face_y'] / $row['image_height'], + ]; + } + + unset($row['face_x'], $row['face_y'], $row['face_w'], $row['face_h'], $row['image_height'], $row['image_width']); + } +} diff --git a/lib/Db/TimelineQueryFaces.php b/lib/Db/TimelineQueryPeopleRecognize.php similarity index 91% rename from lib/Db/TimelineQueryFaces.php rename to lib/Db/TimelineQueryPeopleRecognize.php index b9cae670..f54e0a83 100644 --- a/lib/Db/TimelineQueryFaces.php +++ b/lib/Db/TimelineQueryPeopleRecognize.php @@ -7,13 +7,13 @@ namespace OCA\Memories\Db; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; -trait TimelineQueryFaces +trait TimelineQueryPeopleRecognize { protected IDBConnection $connection; - public function transformFaceFilter(IQueryBuilder &$query, string $userId, string $faceStr) + public function transformPeopleRecognitionFilter(IQueryBuilder &$query, string $userId, string $faceStr) { - // Get title and uid of face user + // Get name and uid of face user $faceNames = explode('/', $faceStr); if (2 !== \count($faceNames)) { throw new \Exception('Invalid face query'); @@ -35,7 +35,7 @@ trait TimelineQueryFaces )); } - public function transformFaceRect(IQueryBuilder &$query, string $userId) + public function transformPeopleRecognizeRect(IQueryBuilder &$query, string $userId) { // Include detection params in response $query->addSelect( @@ -46,7 +46,7 @@ trait TimelineQueryFaces ); } - public function getFaces(TimelineRoot &$root) + public function getPeopleRecognize(TimelineRoot &$root) { $query = $this->connection->getQueryBuilder(); @@ -86,7 +86,7 @@ trait TimelineQueryFaces return $faces; } - public function getFacePreviewDetection(TimelineRoot &$root, int $id) + public function getPeopleRecognizePreview(TimelineRoot &$root, int $id) { $query = $this->connection->getQueryBuilder(); @@ -159,8 +159,9 @@ trait TimelineQueryFaces } /** Convert face fields to object */ - private function processFace(&$row, $days = false) + private function processPeopleRecognizeDetection(&$row, $days = false) { + // Differentiate Recognize queries from Face Recognition if (!isset($row) || !isset($row['face_w'])) { return; } diff --git a/lib/Util.php b/lib/Util.php index 551b2088..bea41b72 100644 --- a/lib/Util.php +++ b/lib/Util.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace OCA\Memories; +use OCP\App\IAppManager; +use OCP\IConfig; + class Util { public static $TAG_DAYID_START = -(1 << 30); // the world surely didn't exist @@ -46,10 +49,8 @@ class Util /** * Check if albums are enabled for this user. - * - * @param mixed $appManager */ - public static function albumsIsEnabled(&$appManager): bool + public static function albumsIsEnabled(IAppManager &$appManager): bool { if (!$appManager->isEnabledForUser('photos')) { return false; @@ -72,10 +73,8 @@ class Util /** * Check if recognize is enabled for this user. - * - * @param mixed $appManager */ - public static function recognizeIsEnabled(&$appManager): bool + public static function recognizeIsEnabled(IAppManager &$appManager): bool { if (!$appManager->isEnabledForUser('recognize')) { return false; @@ -87,11 +86,33 @@ class Util } /** - * Check if link sharing is allowed. - * - * @param mixed $config + * Check if Face Recognition is enabled by the user. */ - public static function isLinkSharingEnabled(&$config): bool + public static function facerecognitionIsEnabled(IConfig &$config, string $userId): bool + { + $e = $config->getUserValue($userId, 'facerecognition', 'enabled', 'false'); + + return 'true' === $e; + } + + /** + * Check if Face Recognition is installed and enabled for this user. + */ + public static function facerecognitionIsInstalled(IAppManager &$appManager): bool + { + if (!$appManager->isEnabledForUser('facerecognition')) { + return false; + } + + $v = $appManager->getAppInfo('facerecognition')['version']; + + return version_compare($v, '0.9.10-beta.2', '>='); + } + + /** + * Check if link sharing is allowed. + */ + public static function isLinkSharingEnabled(IConfig &$config): bool { // Check if the shareAPI is enabled if ('yes' !== $config->getAppValue('core', 'shareapi_enabled', 'yes')) { diff --git a/src/App.vue b/src/App.vue index 42a2f0b7..3bc784da 100644 --- a/src/App.vue +++ b/src/App.vue @@ -99,7 +99,7 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { private metadataComponent!: Metadata; - private readonly navItemsAll = [ + private readonly navItemsAll = (self: typeof this) => [ { name: "timeline", icon: ImageMultiple, @@ -124,13 +124,19 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { name: "albums", icon: AlbumIcon, title: t("memories", "Albums"), - if: (self: any) => self.showAlbums, + if: self.showAlbums, }, { - name: "people", + name: "recognize", icon: PeopleIcon, - title: t("memories", "People"), - if: (self: any) => self.showPeople, + title: self.recognize, + if: self.recognize, + }, + { + name: "facerecognition", + icon: PeopleIcon, + title: self.facerecognition, + if: self.facerecognition, }, { name: "archive", @@ -146,13 +152,13 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { name: "tags", icon: TagsIcon, title: t("memories", "Tags"), - if: (self: any) => self.config_tagsEnabled, + if: self.config_tagsEnabled, }, { name: "maps", icon: MapIcon, title: t("memories", "Maps"), - if: (self: any) => self.config_mapsEnabled, + if: self.config_mapsEnabled, }, ]; @@ -163,8 +169,28 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { return Number(version[0]); } - get showPeople() { - return this.config_recognizeEnabled || getCurrentUser()?.isAdmin; + get recognize() { + if (!this.config_recognizeEnabled) { + return false; + } + + if (this.config_facerecognitionInstalled) { + return t("memories", "People (Recognize)"); + } + + return t("memories", "People"); + } + + get facerecognition() { + if (!this.config_facerecognitionInstalled) { + return false; + } + + if (this.config_recognizeEnabled) { + return t("memories", "People (Face Recognition)"); + } + + return t("memories", "People"); } get isFirstStart() { @@ -192,8 +218,8 @@ export default class App extends Mixins(GlobalMixin, UserConfig) { this.doRouteChecks(); // Populate navigation - this.navItems = this.navItemsAll.filter( - (item) => !item.if || item.if(this) + this.navItems = this.navItemsAll(this).filter( + (item) => typeof item.if === "undefined" || Boolean(item.if) ); // Store CSS variables modified diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 002fcfd3..7d3fc0d3 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -209,13 +209,13 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) { name: t("memories", "Move to another person"), icon: MoveIcon, callback: this.moveSelectionToPerson.bind(this), - if: () => this.$route.name === "people", + if: () => this.$route.name === "recognize", }, { name: t("memories", "Remove from person"), icon: CloseIcon, callback: this.removeSelectionFromPerson.bind(this), - if: () => this.$route.name === "people", + if: () => this.$route.name === "recognize", }, ]; @@ -846,7 +846,7 @@ export default class SelectionManager extends Mixins(GlobalMixin, UserConfig) { private async removeSelectionFromPerson(selection: Selection) { // Make sure route is valid const { user, name } = this.$route.params; - if (this.$route.name !== "people" || !user || !name) { + if (this.$route.name !== "recognize" || !user || !name) { return; } diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 81175468..34063ad6 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -10,11 +10,12 @@ @@ -43,7 +44,7 @@ { - photo.flag = this.c.FLAG_IS_FACE | this.c.FLAG_IS_TAG; + photo.flag = flags; }); detail = detail.filter((photo: ITag) => { const pname = photo.name || photo.fileid.toString(); diff --git a/src/components/modal/FaceMergeModal.vue b/src/components/modal/FaceMergeModal.vue index f874c1e3..a6288680 100644 --- a/src/components/modal/FaceMergeModal.vue +++ b/src/components/modal/FaceMergeModal.vue @@ -135,7 +135,7 @@ export default class FaceMergeModal extends Mixins(GlobalMixin) { // Go to new face if (failures === 0) { this.$router.push({ - name: "people", + name: "recognize", params: { user: face.user_id, name: newName }, }); this.close(); diff --git a/src/components/top-matter/FaceTopMatter.vue b/src/components/top-matter/FaceTopMatter.vue index 96109477..d13d07c6 100644 --- a/src/components/top-matter/FaceTopMatter.vue +++ b/src/components/top-matter/FaceTopMatter.vue @@ -99,7 +99,7 @@ export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) { } back() { - this.$router.push({ name: "people" }); + this.$router.push({ name: this.$route.name }); } changeShowFaceRect() { diff --git a/src/components/top-matter/TopMatter.vue b/src/components/top-matter/TopMatter.vue index ebd33ff5..a50553b5 100644 --- a/src/components/top-matter/TopMatter.vue +++ b/src/components/top-matter/TopMatter.vue @@ -47,7 +47,8 @@ export default class TopMatter extends Mixins(GlobalMixin) { return this.$route.params.name ? TopMatterType.TAG : TopMatterType.NONE; - case "people": + case "recognize": + case "facerecognition": return this.$route.params.name ? TopMatterType.FACE : TopMatterType.NONE; diff --git a/src/mixins/UserConfig.ts b/src/mixins/UserConfig.ts index c0e4966b..a712f05a 100644 --- a/src/mixins/UserConfig.ts +++ b/src/mixins/UserConfig.ts @@ -26,6 +26,12 @@ export default class UserConfig extends Vue { config_recognizeEnabled = Boolean( loadState("memories", "recognize", "") ); + config_facerecognitionInstalled = Boolean( + loadState("memories", "facerecognitionInstalled", "") + ); + config_facerecognitionEnabled = Boolean( + loadState("memories", "facerecognitionEnabled", "") + ); config_mapsEnabled = Boolean(loadState("memories", "maps", "")); config_albumsEnabled = Boolean(loadState("memories", "albums", "")); diff --git a/src/router.ts b/src/router.ts index c6019570..0c7b7579 100644 --- a/src/router.ts +++ b/src/router.ts @@ -77,9 +77,18 @@ export default new Router({ }, { - path: "/people/:user?/:name?", + path: "/recognize/:user?/:name?", component: Timeline, - name: "people", + name: "recognize", + props: (route) => ({ + rootTitle: t("memories", "People"), + }), + }, + + { + path: "/facerecognition/:user?/:name?", + component: Timeline, + name: "facerecognition", props: (route) => ({ rootTitle: t("memories", "People"), }), diff --git a/src/services/API.ts b/src/services/API.ts index 4b658df1..0490bbcc 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -50,12 +50,15 @@ export class API { return gen(`${BASE}/tags/preview/{tag}`, { tag }); } - static FACE_LIST() { - return gen(`${BASE}/faces`); + static FACE_LIST(app: "recognize" | "facerecognition") { + return gen(`${BASE}/${app}/people`); } - static FACE_PREVIEW(face: string | number) { - return gen(`${BASE}/faces/preview/{face}`, { face }); + static FACE_PREVIEW( + app: "recognize" | "facerecognition", + face: string | number + ) { + return gen(`${BASE}/${app}/people/preview/{face}`, { face }); } static ARCHIVE(fileid: number) { diff --git a/src/services/Utils.ts b/src/services/Utils.ts index f774fdf5..c4c88376 100644 --- a/src/services/Utils.ts +++ b/src/services/Utils.ts @@ -212,7 +212,12 @@ export function convertFlags(photo: IPhoto) { delete photo.isfolder; } if (photo.isface) { - photo.flag |= constants.c.FLAG_IS_FACE; + const app = photo.isface; + if (app === "recognize") { + photo.flag |= constants.c.FLAG_IS_FACE_RECOGNIZE; + } else if (app === "facerecognition") { + photo.flag |= constants.c.FLAG_IS_FACE_RECOGNITION; + } delete photo.isface; } if (photo.istag) { @@ -310,10 +315,11 @@ export const constants = { FLAG_IS_FAVORITE: 1 << 3, FLAG_IS_FOLDER: 1 << 4, FLAG_IS_TAG: 1 << 5, - FLAG_IS_FACE: 1 << 6, - FLAG_IS_ALBUM: 1 << 7, - FLAG_SELECTED: 1 << 8, - FLAG_LEAVING: 1 << 9, + FLAG_IS_FACE_RECOGNIZE: 1 << 6, + FLAG_IS_FACE_RECOGNITION: 1 << 7, + FLAG_IS_ALBUM: 1 << 8, + FLAG_SELECTED: 1 << 9, + FLAG_LEAVING: 1 << 10, }, TagDayID: TagDayID, diff --git a/src/services/dav/face.ts b/src/services/dav/face.ts index e95be24f..8d3c044b 100644 --- a/src/services/dav/face.ts +++ b/src/services/dav/face.ts @@ -1,16 +1,19 @@ import axios from "@nextcloud/axios"; import { showError } from "@nextcloud/dialogs"; import { translate as t } from "@nextcloud/l10n"; +import { generateUrl } from "@nextcloud/router"; import { IDay, IPhoto } from "../../types"; import { API } from "../API"; -import client from "../DavClient"; import { constants } from "../Utils"; +import client from "../DavClient"; import * as base from "./base"; /** * Get list of tags and convert to Days response */ -export async function getPeopleData(): Promise { +export async function getPeopleData( + app: "recognize" | "facerecognition" +): Promise { // Query for photos let data: { id: number; @@ -19,7 +22,7 @@ export async function getPeopleData(): Promise { previews: IPhoto[]; }[] = []; try { - const res = await axios.get(API.FACE_LIST()); + const res = await axios.get(API.FACE_LIST(app)); data = res.data; } catch (e) { throw e; @@ -39,13 +42,48 @@ export async function getPeopleData(): Promise { ...face, fileid: face.id, istag: true, - isface: true, + isface: app, } as any) ), }, ]; } +export async function updatePeopleFaceRecognition( + name: string, + params: object +) { + if (Number.isInteger(Number(name))) { + return await axios.put( + generateUrl(`/apps/facerecognition/api/2.0/cluster/${name}`), + params + ); + } else { + return await axios.put( + generateUrl(`/apps/facerecognition/api/2.0/person/${name}`), + params + ); + } +} + +export async function renamePeopleFaceRecognition( + name: string, + newName: string +) { + return await updatePeopleFaceRecognition(name, { + name: newName, + }); +} + +export async function setVisibilityPeopleFaceRecognition( + name: string, + visibility: boolean +) { + return await updatePeopleFaceRecognition(name, { + visible: visibility, + }); +} + /** * Remove images from a face. * diff --git a/src/types.ts b/src/types.ts index 74ce2914..ef8f720c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,7 +105,7 @@ export type IPhoto = { /** Is this an album */ isalbum?: boolean; /** Is this a face */ - isface?: boolean; + isface?: "recognize" | "facerecognition"; /** Optional datetaken epoch */ datetaken?: number; };