Mark person in preview (fix #79)

old-stable24
Varun Patil 2022-10-18 14:08:27 -07:00
parent c62db4679a
commit bb27b3261a
8 changed files with 145 additions and 25 deletions

View File

@ -99,6 +99,11 @@ class ApiController extends Controller {
if ($face) { if ($face) {
$transforms[] = array($this->timelineQuery, 'transformFaceFilter', $face); $transforms[] = array($this->timelineQuery, 'transformFaceFilter', $face);
} }
$faceRect = $this->request->getParam('facerect');
if ($faceRect) {
$transforms[] = array($this->timelineQuery, 'transformFaceRect', $face);
}
} }
// Filter only for one tag // Filter only for one tag

View File

@ -18,6 +18,9 @@ trait TimelineQueryDays {
foreach($days as &$row) { foreach($days as &$row) {
$row["dayid"] = intval($row["dayid"]); $row["dayid"] = intval($row["dayid"]);
$row["count"] = intval($row["count"]); $row["count"] = intval($row["count"]);
// All transform processing
$this->processFace($row, true);
} }
return $days; return $days;
} }
@ -44,6 +47,9 @@ trait TimelineQueryDays {
$row["isfavorite"] = 1; $row["isfavorite"] = 1;
} }
unset($row["categoryid"]); unset($row["categoryid"]);
// All transform processing
$this->processFace($row);
} }
return $day; return $day;
} }

View File

@ -17,25 +17,49 @@ trait TimelineQueryFaces {
$faceUid = $faceNames[0]; $faceUid = $faceNames[0];
$faceName = $faceNames[1]; $faceName = $faceNames[1];
// Get cluster ID
$sq = $query->getConnection()->getQueryBuilder();
$idQuery = $sq->select('id')->from('recognize_face_clusters')
->where($query->expr()->eq('user_id', $sq->createNamedParameter($faceUid)));
// If name is a number then it is an ID
$nameField = is_numeric($faceName) ? 'id' : 'title';
$idQuery->andWhere($query->expr()->eq($nameField, $sq->createNamedParameter($faceName)));
$id = $idQuery->executeQuery()->fetchOne();
if (!$id) throw new \Exception("Unknown person: $faceStr");
// Join with cluster // Join with cluster
$nameField = is_numeric($faceName) ? 'rfc.id' : 'rfc.title';
$query->innerJoin('m', 'recognize_face_clusters', 'rfc', $query->expr()->andX(
$query->expr()->eq('user_id', $query->createNamedParameter($faceUid)),
$query->expr()->eq($nameField, $query->createNamedParameter($faceName)),
));
// Join with detections
$query->innerJoin('m', 'recognize_face_detections', 'rfd', $query->expr()->andX( $query->innerJoin('m', 'recognize_face_detections', 'rfd', $query->expr()->andX(
$query->expr()->eq('rfd.file_id', 'm.fileid'), $query->expr()->eq('rfd.file_id', 'm.fileid'),
$query->expr()->eq('rfd.cluster_id', $query->createNamedParameter($id)), $query->expr()->eq('rfd.cluster_id', 'rfc.id'),
)); ));
} }
public function transformFaceRect(IQueryBuilder &$query, string $userId) {
// Include detection params in response
$query->addSelect(
'rfd.width AS face_w',
'rfd.height AS face_h',
'rfd.x AS face_x',
'rfd.y AS face_y',
);
}
/** Convert face fields to object */
private function processFace(&$row, $days=false) {
if (!isset($row) || !isset($row['face_w'])) return;
if (!$days) {
$row["facerect"] = [
"w" => floatval($row["face_w"]),
"h" => floatval($row["face_h"]),
"x" => floatval($row["face_x"]),
"y" => floatval($row["face_y"]),
];
}
unset($row["face_w"]);
unset($row["face_h"]);
unset($row["face_x"]);
unset($row["face_y"]);
}
public function getFaces(Folder $folder) { public function getFaces(Folder $folder) {
$query = $this->connection->getQueryBuilder(); $query = $this->connection->getQueryBuilder();

View File

@ -106,6 +106,7 @@ import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs' import { showError } from '@nextcloud/dialogs'
import { NcEmptyContent } from '@nextcloud/vue'; import { NcEmptyContent } from '@nextcloud/vue';
import GlobalMixin from '../mixins/GlobalMixin'; import GlobalMixin from '../mixins/GlobalMixin';
import UserConfig from "../mixins/UserConfig";
import { ViewerManager } from "../services/Viewer"; import { ViewerManager } from "../services/Viewer";
import { getLayout } from "../services/Layout"; import { getLayout } from "../services/Layout";
@ -119,7 +120,6 @@ import TopMatter from "./top-matter/TopMatter.vue";
import OnThisDay from "./top-matter/OnThisDay.vue"; import OnThisDay from "./top-matter/OnThisDay.vue";
import SelectionManager from './SelectionManager.vue'; import SelectionManager from './SelectionManager.vue';
import ScrollerManager from './ScrollerManager.vue'; import ScrollerManager from './ScrollerManager.vue';
import UserConfig from "../mixins/UserConfig";
import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue'; import ArchiveIcon from 'vue-material-design-icons/PackageDown.vue';
import CheckCircle from 'vue-material-design-icons/CheckCircle.vue'; import CheckCircle from 'vue-material-design-icons/CheckCircle.vue';
@ -436,6 +436,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
// People // People
if (this.$route.name === 'people' && this.$route.params.user && this.$route.params.name) { if (this.$route.name === 'people' && this.$route.params.user && this.$route.params.name) {
query.set('face', `${this.$route.params.user}/${this.$route.params.name}`); query.set('face', `${this.$route.params.user}/${this.$route.params.name}`);
// Face rect
if (this.config_showFaceRect) {
query.set('facerect', '1');
}
} }
// Tags // Tags
@ -492,12 +497,11 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
/** Fetch timeline main call */ /** Fetch timeline main call */
async fetchDays(noCache=false) { async fetchDays(noCache=false) {
let url = '/apps/memories/api/days';
let params: any = {}; let params: any = {};
let url = generateUrl(this.appendQuery('/apps/memories/api/days'), params);
// Try cache first // Try cache first
let cache: IDay[]; let cache: IDay[];
const cacheUrl = window.location.pathname + 'api/days';
// Make sure to refresh scroll later // Make sure to refresh scroll later
this.currentEnd = -1; this.currentEnd = -1;
@ -515,18 +519,18 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
data = await dav.getPeopleData(); data = await dav.getPeopleData();
} else { } else {
// Try the cache // Try the cache
cache = noCache ? null : (await utils.getCachedData(cacheUrl)); cache = noCache ? null : (await utils.getCachedData(url));
if (cache) { if (cache) {
await this.processDays(cache); await this.processDays(cache);
this.loading--; this.loading--;
} }
// Get from network // Get from network
data = (await axios.get<IDay[]>(generateUrl(this.appendQuery(url), params))).data; data = (await axios.get<IDay[]>(url)).data;
} }
// Put back into cache // Put back into cache
utils.cacheData(cacheUrl, data); utils.cacheData(url, data);
// Make sure we're still on the same page // Make sure we're still on the same page
if (this.state !== startState) return; if (this.state !== startState) return;

View File

@ -22,16 +22,18 @@
@touchend="touchend" @touchend="touchend"
@touchcancel="touchend" > @touchcancel="touchend" >
<img <img
ref="img"
class="fill-block" class="fill-block"
:src="src()" :src="src"
:key="data.fileid" :key="data.fileid"
@load="load"
@error="error" /> @error="error" />
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Emit, Mixins } from 'vue-property-decorator'; import { Component, Prop, Emit, Mixins, Watch } from 'vue-property-decorator';
import { IDay, IPhoto } from "../../types"; import { IDay, IPhoto } from "../../types";
import { getPreviewUrl } from "../../services/FileUtils"; import { getPreviewUrl } from "../../services/FileUtils";
@ -51,6 +53,8 @@ import Star from 'vue-material-design-icons/Star.vue';
}) })
export default class Photo extends Mixins(GlobalMixin) { export default class Photo extends Mixins(GlobalMixin) {
private touchTimer = 0; private touchTimer = 0;
private src = null;
private hasFaceRect = false;
@Prop() data: IPhoto; @Prop() data: IPhoto;
@Prop() day: IDay; @Prop() day: IDay;
@ -58,10 +62,27 @@ export default class Photo extends Mixins(GlobalMixin) {
@Emit('select') emitSelect(data: IPhoto) {} @Emit('select') emitSelect(data: IPhoto) {}
@Emit('click') emitClick() {} @Emit('click') emitClick() {}
@Watch('data')
onDataChange(newData: IPhoto, oldData: IPhoto) {
// Copy flags relevant to this component
if (oldData && newData) {
newData.flag |= oldData.flag & (this.c.FLAG_SELECTED | this.c.FLAG_LOAD_FAIL);
}
}
mounted() {
this.hasFaceRect = false;
this.refresh();
}
async refresh() {
this.src = await this.getSrc();
}
/** Get src for image to show */ /** Get src for image to show */
src() { async getSrc() {
if (this.data.flag & this.c.FLAG_PLACEHOLDER) { if (this.data.flag & this.c.FLAG_PLACEHOLDER) {
return undefined; return null;
} else if (this.data.flag & this.c.FLAG_LOAD_FAIL) { } else if (this.data.flag & this.c.FLAG_LOAD_FAIL) {
return errorsvg; return errorsvg;
} else { } else {
@ -78,9 +99,41 @@ export default class Photo extends Mixins(GlobalMixin) {
return getPreviewUrl(this.data.fileid, this.data.etag, false, size) return getPreviewUrl(this.data.fileid, this.data.etag, false, size)
} }
/** Set src with overlay face rect */
async addFaceRect() {
if (!this.data.facerect || this.hasFaceRect) return;
this.hasFaceRect = true;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const img = this.$refs.img as HTMLImageElement;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
context.drawImage(img, 0, 0);
context.strokeStyle = '#00ff00';
context.lineWidth = 2;
context.strokeRect(
this.data.facerect.x * img.naturalWidth,
this.data.facerect.y * img.naturalHeight,
this.data.facerect.w * img.naturalWidth,
this.data.facerect.h * img.naturalHeight,
);
canvas.toBlob((blob) => {
this.src = URL.createObjectURL(blob);
}, 'image/jpeg', 0.95)
}
/** Post load tasks */
load() {
this.addFaceRect();
}
/** Error in loading image */ /** Error in loading image */
error(e: any) { error(e: any) {
this.data.flag |= this.c.FLAG_LOAD_FAIL; this.data.flag |= this.c.FLAG_LOAD_FAIL;
this.refresh();
} }
/** Clear timers */ /** Clear timers */

View File

@ -19,6 +19,10 @@
{{ t('memories', 'Merge with different person') }} {{ t('memories', 'Merge with different person') }}
<template #icon> <MergeIcon :size="20" /> </template> <template #icon> <MergeIcon :size="20" /> </template>
</NcActionButton> </NcActionButton>
<NcActionCheckbox :aria-label="t('memories', 'Mark person in preview')" :checked.sync="config_showFaceRect" @change="changeShowFaceRect">
{{ t('memories', 'Mark person in preview') }}
<template #icon> <MergeIcon :size="20" /> </template>
</NcActionCheckbox>
<NcActionButton :aria-label="t('memories', 'Remove person')" @click="showDeleteModal=true" close-after-click> <NcActionButton :aria-label="t('memories', 'Remove person')" @click="showDeleteModal=true" close-after-click>
{{ t('memories', 'Remove person') }} {{ t('memories', 'Remove person') }}
<template #icon> <DeleteIcon :size="20" /> </template> <template #icon> <DeleteIcon :size="20" /> </template>
@ -35,8 +39,9 @@
<script lang="ts"> <script lang="ts">
import { Component, Mixins, Watch } from 'vue-property-decorator'; import { Component, Mixins, Watch } from 'vue-property-decorator';
import GlobalMixin from '../../mixins/GlobalMixin'; import GlobalMixin from '../../mixins/GlobalMixin';
import UserConfig from "../../mixins/UserConfig";
import { NcActions, NcActionButton } from '@nextcloud/vue'; import { NcActions, NcActionButton, NcActionCheckbox } from '@nextcloud/vue';
import FaceEditModal from '../modal/FaceEditModal.vue'; import FaceEditModal from '../modal/FaceEditModal.vue';
import FaceDeleteModal from '../modal/FaceDeleteModal.vue'; import FaceDeleteModal from '../modal/FaceDeleteModal.vue';
import FaceMergeModal from '../modal/FaceMergeModal.vue'; import FaceMergeModal from '../modal/FaceMergeModal.vue';
@ -49,6 +54,7 @@ import MergeIcon from 'vue-material-design-icons/Merge.vue';
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
NcActionCheckbox,
FaceEditModal, FaceEditModal,
FaceDeleteModal, FaceDeleteModal,
FaceMergeModal, FaceMergeModal,
@ -58,7 +64,7 @@ import MergeIcon from 'vue-material-design-icons/Merge.vue';
MergeIcon, MergeIcon,
}, },
}) })
export default class FaceTopMatter extends Mixins(GlobalMixin) { export default class FaceTopMatter extends Mixins(GlobalMixin, UserConfig) {
private name: string = ''; private name: string = '';
private showEditModal: boolean = false; private showEditModal: boolean = false;
private showDeleteModal: boolean = false; private showDeleteModal: boolean = false;
@ -80,6 +86,13 @@ export default class FaceTopMatter extends Mixins(GlobalMixin) {
back() { back() {
this.$router.push({ name: 'people' }); this.$router.push({ name: 'people' });
} }
changeShowFaceRect() {
localStorage.setItem('memories_showFaceRect', this.config_showFaceRect ? '1' : '0');
setTimeout(() => {
this.$router.go(0); // refresh page
}, 500);
}
} }
</script> </script>

View File

@ -34,7 +34,9 @@ export default class UserConfig extends Vue {
config_showHidden = loadState('memories', 'showHidden') === "true"; config_showHidden = loadState('memories', 'showHidden') === "true";
config_tagsEnabled = loadState('memories', 'systemtags'); config_tagsEnabled = loadState('memories', 'systemtags');
config_recognizeEnabled = loadState('memories', 'recognize'); config_recognizeEnabled = loadState('memories', 'recognize');
config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1'; config_squareThumbs = localStorage.getItem('memories_squareThumbs') === '1';
config_showFaceRect = localStorage.getItem('memories_showFaceRect') === '1';
created() { created() {
subscribe(eventName, this.updateLocalSetting) subscribe(eventName, this.updateLocalSetting)

View File

@ -39,6 +39,7 @@ export type IPhoto = {
w?: number; w?: number;
/** Height of full image */ /** Height of full image */
h?: number; h?: number;
/** Grid display width percentage */ /** Grid display width percentage */
dispWp?: number; dispWp?: number;
/** Grid display height (forced) */ /** Grid display height (forced) */
@ -49,8 +50,13 @@ export type IPhoto = {
dispY?: number; dispY?: number;
/** Grid display row id (relative to head) */ /** Grid display row id (relative to head) */
dispRowNum?: number; dispRowNum?: number;
/** Reference to day object */ /** Reference to day object */
d?: IDay; d?: IDay;
/** Face dimensions */
facerect?: IFaceRect;
/** Video flag from server */ /** Video flag from server */
isvideo?: boolean; isvideo?: boolean;
/** Favorite flag from server */ /** Favorite flag from server */
@ -85,6 +91,13 @@ export interface ITag extends IPhoto {
previews?: IPhoto[]; previews?: IPhoto[];
} }
export interface IFaceRect {
w: number;
h: number;
x: number;
y: number;
}
export type IRow = { export type IRow = {
/** Vue Recycler identifier */ /** Vue Recycler identifier */
id?: string; id?: string;