draft: eager clustering

pull/396/head
Varun Patil 2023-02-08 21:55:12 -08:00
parent 74e69d0d6f
commit e7c8748cc9
10 changed files with 411 additions and 132 deletions

View File

@ -67,6 +67,7 @@ return [
['name' => 'Places#preview', 'url' => '/api/places/preview/{id}', 'verb' => 'GET'],
['name' => 'Map#clusters', 'url' => '/api/map/clusters', 'verb' => 'GET'],
['name' => 'Map#clusterPreview', 'url' => '/api/map/clusters/preview/{id}', 'verb' => 'GET'],
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],

View File

@ -297,7 +297,7 @@ class ApiBase extends Controller
/**
* Given a list of file ids, return the first preview image possible.
*/
protected function getPreviewFromImageList(array &$list)
protected function getPreviewFromImageList(array &$list, int $quality=512)
{
// Get preview manager
$previewManager = \OC::$server->get(\OCP\IPreview::class);
@ -318,7 +318,7 @@ class ApiBase extends Controller
// Get preview image
try {
$preview = $previewManager->getPreview($files[0], 512, 512, false);
$preview = $previewManager->getPreview($files[0], $quality, $quality, false);
$response = new DataDisplayResponse($preview->getContent(), Http::STATUS_OK, [
'Content-Type' => $preview->getMimeType(),
]);

View File

@ -60,7 +60,7 @@ class MapController extends ApiBase
$clusters = $this->timelineQuery->getMapClusters($gridLen, $bounds, $root);
// Merge clusters that are close together
$distanceThreshold = $gridLen / 3;
$distanceThreshold = $gridLen / 2;
$clusters = $this->mergeClusters($clusters, $distanceThreshold);
return new JSONResponse($clusters);
@ -69,6 +69,39 @@ class MapController extends ApiBase
}
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* Get preview for a cluster
*/
public function clusterPreview(int $id)
{
$user = $this->userSession->getUser();
if (null === $user) {
return new JSONResponse([], 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);
}
// Run actual query
$list = $this->timelineQuery->getMapClusterPreviews($id, $root);
if (null === $list || 0 === \count($list)) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
shuffle($list);
// Get preview from image list
return $this->getPreviewFromImageList(array_map(static function (&$item) {
return (int) $item['fileid'];
}, $list), 256);
}
private function mergeClusters($clusters, $distanceThreshold): array
{
$valid = array_fill(0, \count($clusters), true);
@ -117,7 +150,11 @@ class MapController extends ApiBase
($cluster1Count * $cluster1Center[0] + $cluster2Count * $cluster2Center[0]) / ($cluster1Count + $cluster2Count),
($cluster1Count * $cluster1Center[1] + $cluster2Count * $cluster2Center[1]) / ($cluster1Count + $cluster2Count),
];
$clusters[] = ['center' => $newCenter, 'count' => $cluster1Count + $cluster2Count];
$clusters[] = [
'id' => $clusters[$index1]['id'],
'center' => $newCenter,
'count' => $cluster1Count + $cluster2Count,
];
$valid[] = true;
$valid[$index1] = $valid[$index2] = false;
}

View File

@ -11,7 +11,7 @@ trait TimelineQueryMap
{
protected IDBConnection $connection;
public function transformMapBoundsFilter(IQueryBuilder &$query, string $userId, string $bounds)
public function transformMapBoundsFilter(IQueryBuilder &$query, string $userId, string $bounds, $table = 'm')
{
$bounds = explode(',', $bounds);
$bounds = array_map('floatval', $bounds);
@ -19,12 +19,14 @@ trait TimelineQueryMap
return;
}
$latCol = $table.'.lat';
$lonCol = $table.'.lon';
$query->andWhere(
$query->expr()->andX(
$query->expr()->gte('m.lat', $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)),
$query->expr()->lte('m.lat', $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)),
$query->expr()->gte('m.lon', $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)),
$query->expr()->lte('m.lon', $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR))
$query->expr()->gte($latCol, $query->createNamedParameter($bounds[0], IQueryBuilder::PARAM_STR)),
$query->expr()->lte($latCol, $query->createNamedParameter($bounds[1], IQueryBuilder::PARAM_STR)),
$query->expr()->gte($lonCol, $query->createNamedParameter($bounds[2], IQueryBuilder::PARAM_STR)),
$query->expr()->lte($lonCol, $query->createNamedParameter($bounds[3], IQueryBuilder::PARAM_STR))
)
);
}
@ -37,22 +39,31 @@ trait TimelineQueryMap
$query = $this->connection->getQueryBuilder();
// Get the average location of each cluster
$avgLat = $query->createFunction('AVG(m.lat) AS avgLat');
$avgLng = $query->createFunction('AVG(m.lon) AS avgLon');
$lat = $query->createFunction('AVG(c.lat) AS lat');
$lon = $query->createFunction('AVG(c.lon) AS lon');
$count = $query->createFunction('COUNT(m.fileid) AS count');
$query->select($avgLat, $avgLng, $count)
->from('memories', 'm')
$query->select($lat, $lon, $count)
->from('memories_map_clusters', 'c')
;
if ($gridLen > 0.2) {
// Coarse grouping
$query->addGroupBy($query->createFunction("CAST(c.lat / {$gridLen} AS INT)"));
$query->addGroupBy($query->createFunction("CAST(c.lon / {$gridLen} AS INT)"));
} else {
// Fine grouping
$query->addSelect('c.id')->groupBy('c.id');
}
// JOIN with memories for files from the current user
$query->innerJoin('c', 'memories', 'm', $query->expr()->eq('c.id', 'm.map_cluster_id'));
// JOIN with filecache for existing files
$query = $this->joinFilecache($query, $root, true, false);
// Group by cluster
$query->addGroupBy($query->createFunction("m.lat DIV {$gridLen}"));
$query->addGroupBy($query->createFunction("m.lon DIV {$gridLen}"));
// Apply all transformations (including map bounds)
$this->transformMapBoundsFilter($query, '', $bounds);
// Bound the query to the map bounds
$this->transformMapBoundsFilter($query, '', $bounds, 'c');
// Execute query
$cursor = $this->executeQueryWithCTEs($query);
@ -61,17 +72,47 @@ trait TimelineQueryMap
// Post-process results
$clusters = [];
foreach ($res as $cluster) {
$clusters[] =
[
foreach ($res as &$cluster) {
$c = [
'center' => [
(float) $cluster['avgLat'],
(float) $cluster['avgLon'],
(float) $cluster['lat'],
(float) $cluster['lon'],
],
'count' => (float) $cluster['count'],
];
if (\array_key_exists('id', $cluster)) {
$c['id'] = (int) $cluster['id'];
}
$clusters[] = $c;
}
return $clusters;
}
public function getMapClusterPreviews(int $clusterId, TimelineRoot &$root)
{
$query = $this->connection->getQueryBuilder();
// SELECT all photos with this tag
$query->select('f.fileid', 'f.etag')->from('memories', 'm')->where(
$query->expr()->eq('m.map_cluster_id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT))
);
// WHERE these photos are in the user's requested folder recursively
$query = $this->joinFilecache($query, $root, true, false);
// MAX 8
$query->setMaxResults(8);
// FETCH tag previews
$cursor = $this->executeQueryWithCTEs($query);
$ans = $cursor->fetchAll();
// Post-process
foreach ($ans as &$row) {
$row['fileid'] = (int) $row['fileid'];
}
return $ans;
}
}

View File

@ -18,6 +18,9 @@ const DELETE_TABLES = ['memories', 'memories_livephoto', 'memories_places'];
class TimelineWrite
{
use TimelineWriteMap;
use TimelineWriteOrphans;
use TimelineWritePlaces;
protected IDBConnection $connection;
protected IPreview $preview;
protected LivePhoto $livePhoto;
@ -79,7 +82,7 @@ class TimelineWrite
// Check if need to update
$query = $this->connection->getQueryBuilder();
$query->select('fileid', 'mtime')
$query->select('fileid', 'mtime', 'map_cluster_id')
->from('memories')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
@ -160,11 +163,13 @@ class TimelineWrite
// Store location data
$lat = null;
$lon = null;
$mapCluster = $prevRow ? (int) $prevRow['map_cluster_id'] : -1;
if (\array_key_exists('GPSLatitude', $exif) && \array_key_exists('GPSLongitude', $exif)) {
try {
$lat = (float) $exif['GPSLatitude'];
$lon = (float) $exif['GPSLongitude'];
$this->updatePlacesData($file, $lat, $lon);
$mapCluster = $this->getMapCluster($fileId, $mapCluster, $lat, $lon);
$this->updatePlacesData($fileId, $lat, $lon);
} catch (\Error $e) {
$logger = \OC::$server->get(LoggerInterface::class);
$logger->log(3, 'Error updating geo data: '.$e->getMessage(), ['app' => 'memories']);
@ -186,6 +191,7 @@ class TimelineWrite
'liveid' => $query->createNamedParameter($liveid, IQueryBuilder::PARAM_STR),
'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
'map_cluster_id' => $query->createNamedParameter($mapCluster, IQueryBuilder::PARAM_INT),
];
if ($prevRow) {
@ -239,103 +245,4 @@ class TimelineWrite
$this->connection->executeStatement($p->getTruncateTableSQL('*PREFIX*'.$table, false));
}
}
/**
* Mark a file as not orphaned.
*/
public function unorphan(File &$file)
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
/**
* Mark all files in the table as orphaned.
*
* @return int Number of rows affected
*/
public function orphanAll(): int
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
;
return $query->executeStatement();
}
/**
* Remove all entries that are orphans.
*
* @return int Number of rows affected
*/
public function removeOrphans(): int
{
$query = $this->connection->getQueryBuilder();
$query->delete('memories')
->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
;
return $query->executeStatement();
}
/**
* Add places data for a file.
*/
public function updatePlacesData(File &$file, float $lat, float $lon): void
{
// Get GIS type
$gisType = \OCA\Memories\Util::placesGISType();
// Construct WHERE clause depending on GIS type
$where = null;
if (1 === $gisType) {
$where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))";
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return;
}
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select($query->createFunction('DISTINCT(osm_id)'))
->from('memories_planet_geometry')
->where($query->createFunction($where))
;
// Cancel out inner rings
$query->groupBy('poly_id', 'osm_id');
$query->having($query->createFunction('SUM(type_id) > 0'));
// memories_planet_geometry has no *PREFIX*
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
// Run query
$result = $this->connection->executeQuery($sql);
$rows = $result->fetchAll();
// Delete previous records
$query = $this->connection->getQueryBuilder();
$query->delete('memories_places')
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
// Insert records
foreach ($rows as $row) {
$query = $this->connection->getQueryBuilder();
$query->insert('memories_places')
->values([
'fileid' => $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT),
'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
}
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineWriteMap
{
protected IDBConnection $connection;
protected function getMapCluster(int $fileId, int $prevCluster, float $lat, float $lon): int
{
// get all clusters within 30 metres
$query = $this->connection->getQueryBuilder();
$query->select('id')
->from('memories_map_clusters')
->andWhere($query->expr()->gte('lat', $query->createNamedParameter($lat - 0.0003, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->lte('lat', $query->createNamedParameter($lat + 0.0003, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->gte('lon', $query->createNamedParameter($lon - 0.0003, IQueryBuilder::PARAM_STR)))
->andWhere($query->expr()->lte('lon', $query->createNamedParameter($lon + 0.0003, IQueryBuilder::PARAM_STR)))
;
$result = $query->executeQuery();
$rows = $result->fetchAll();
// Find cluster closest to the point
$minDist = 999999999;
$minId = -1;
foreach ($rows as $r) {
$clusterLat = (float) $r['lat'];
$clusterLon = (float) $r['lon'];
$dist = ($lat - $clusterLat) ** 2 + ($lon - $clusterLon) ** 2;
if ($dist < $minDist) {
$minDist = $dist;
$minId = $r['id'];
}
}
// If no cluster found, create a new one
if ($minId <= 0) {
$this->removeFromCluster($prevCluster, $lat, $lon);
return $this->createMapCluster($lat, $lon);
}
// If the file was previously in the same cluster, return that
if ($prevCluster === $minId) {
return $minId;
}
// If the file was previously in a different cluster,
// remove it from the first cluster and add it to the second
$this->removeFromCluster($prevCluster, $lat, $lon);
$this->addToCluster($minId, $lat, $lon);
return $minId;
}
protected function createMapCluster(float $lat, float $lon): int
{
$query = $this->connection->getQueryBuilder();
$query->insert('memories_map_clusters')
->values([
'point_count' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT),
'lat_sum' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon_sum' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
'lat' => $query->createNamedParameter($lat, IQueryBuilder::PARAM_STR),
'lon' => $query->createNamedParameter($lon, IQueryBuilder::PARAM_STR),
])
;
$query->executeStatement();
return (int) $query->getLastInsertId();
}
protected function removeFromCluster(int $clusterId, float $lat, float $lon): void
{
if ($clusterId <= 0) {
return;
}
$query = $this->connection->getQueryBuilder();
$query->update('memories_map_clusters')
->set('point_count', $query->createFunction('point_count - 1'))
->set('lat_sum', $query->createFunction('lat_sum - '.$lat))
->set('lon_sum', $query->createFunction('lon_sum - '.$lon))
->set('lat', $query->createFunction('lat_sum / point_count'))
->set('lon', $query->createFunction('lon_sum / point_count'))
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
protected function addToCluster(int $clusterId, float $lat, float $lon): void
{
if ($clusterId <= 0) {
return;
}
$query = $this->connection->getQueryBuilder();
$query->update('memories_map_clusters')
->set('point_count', $query->createFunction('point_count + 1'))
->set('lat_sum', $query->createFunction('lat_sum + '.$lat))
->set('lon_sum', $query->createFunction('lon_sum + '.$lon))
->set('lat', $query->createFunction('lat_sum / point_count'))
->set('lon', $query->createFunction('lon_sum / point_count'))
->where($query->expr()->eq('id', $query->createNamedParameter($clusterId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\File;
use OCP\IDBConnection;
trait TimelineWriteOrphans
{
protected IDBConnection $connection;
/**
* Mark a file as not orphaned.
*/
public function unorphan(File &$file)
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
->where($query->expr()->eq('fileid', $query->createNamedParameter($file->getId(), IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
}
/**
* Mark all files in the table as orphaned.
*
* @return int Number of rows affected
*/
public function orphanAll(): int
{
$query = $this->connection->getQueryBuilder();
$query->update('memories')
->set('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
;
return $query->executeStatement();
}
/**
* Remove all entries that are orphans.
*
* @return int Number of rows affected
*/
public function removeOrphans(): int
{
$query = $this->connection->getQueryBuilder();
$query->delete('memories')
->where($query->expr()->eq('orphan', $query->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
;
return $query->executeStatement();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace OCA\Memories\Db;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
trait TimelineWritePlaces
{
protected IDBConnection $connection;
/**
* Add places data for a file.
*/
protected function updatePlacesData(int $fileId, float $lat, float $lon): void
{
// Get GIS type
$gisType = \OCA\Memories\Util::placesGISType();
// Construct WHERE clause depending on GIS type
$where = null;
if (1 === $gisType) {
$where = "ST_Contains(geometry, ST_GeomFromText('POINT({$lon} {$lat})'))";
} elseif (2 === $gisType) {
$where = "POINT('{$lon},{$lat}') <@ geometry";
} else {
return;
}
// Make query to memories_planet table
$query = $this->connection->getQueryBuilder();
$query->select($query->createFunction('DISTINCT(osm_id)'))
->from('memories_planet_geometry')
->where($query->createFunction($where))
;
// Cancel out inner rings
$query->groupBy('poly_id', 'osm_id');
$query->having($query->createFunction('SUM(type_id) > 0'));
// memories_planet_geometry has no *PREFIX*
$sql = str_replace('*PREFIX*memories_planet_geometry', 'memories_planet_geometry', $query->getSQL());
// Run query
$result = $this->connection->executeQuery($sql);
$rows = $result->fetchAll();
// Delete previous records
$query = $this->connection->getQueryBuilder();
$query->delete('memories_places')
->where($query->expr()->eq('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)))
;
$query->executeStatement();
// Insert records
foreach ($rows as $row) {
$query = $this->connection->getQueryBuilder();
$query->insert('memories_places')
->values([
'fileid' => $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT),
'osm_id' => $query->createNamedParameter($row['osm_id'], IQueryBuilder::PARAM_INT),
])
;
$query->executeStatement();
}
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="map-matter">
<l-map
<LMap
class="map"
ref="map"
:zoom="zoom"
@ -8,21 +8,26 @@
@moveend="refresh"
@zoomend="refresh"
>
<l-tile-layer :url="url" :attribution="attribution" />
<l-marker
<LTileLayer :url="url" :attribution="attribution" />
<LMarker
v-for="cluster in clusters"
:key="cluster.center.toString()"
:lat-lng="cluster.center"
>
<l-popup :content="cluster.count.toString()" />
</l-marker>
</l-map>
<LIcon v-if="cluster.id" :icon-anchor="[24, 24]">
<div class="preview">
<div class="count">{{ cluster.count }}</div>
<img :src="clusterPreviewUrl(cluster)" />
</div>
</LIcon>
</LMarker>
</LMap>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { LMap, LTileLayer, LMarker, LPopup } from "vue2-leaflet";
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet";
import { Icon } from "leaflet";
import { API } from "../../services/API";
@ -35,6 +40,7 @@ const ATTRIBUTION =
'&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a> contributors';
type IMarkerCluster = {
id?: number;
center: [number, number];
count: number;
};
@ -52,6 +58,7 @@ export default defineComponent({
LTileLayer,
LMarker,
LPopup,
LIcon,
},
data: () => ({
@ -101,6 +108,10 @@ export default defineComponent({
const res = await axios.get(url);
this.clusters = res.data;
},
clusterPreviewUrl(cluster: IMarkerCluster) {
return API.MAP_CLUSTER_PREVIEW(cluster.id);
},
},
});
</script>
@ -117,4 +128,42 @@ export default defineComponent({
margin: 0;
z-index: 0;
}
.preview {
width: 48px;
height: 48px;
background-color: #fff;
border-radius: 5px;
position: relative;
transition: transform 0.2s;
&:hover {
transform: scale(1.8);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 5px;
}
.count {
position: absolute;
top: 0;
right: 0;
background-color: var(--color-primary-default);
color: var(--color-primary-text);
padding: 0 4px;
border-radius: 5px;
font-size: 0.8em;
}
}
</style>
<style lang="scss">
// Show leaflet marker on top on hover
.leaflet-marker-icon:hover {
z-index: 100000 !important;
}
</style>

View File

@ -126,4 +126,8 @@ export class API {
static MAP_CLUSTERS() {
return tok(gen(`${BASE}/map/clusters`));
}
static MAP_CLUSTER_PREVIEW(id: number) {
return tok(gen(`${BASE}/map/clusters/preview/{id}`, { id }));
}
}