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;
};