[mob][photos] Clean up search service

This commit is contained in:
laurenspriem
2025-02-17 11:58:44 +05:30
parent 1fe6987acf
commit 5fd18807cc
2 changed files with 13 additions and 844 deletions

View File

@@ -242,7 +242,7 @@ extension SectionTypeExtensions on SectionType {
case SectionType.moment:
if (flagService.internalUser) {
return SearchService.instance.onThisDayOrWeekResults(context, limit);
return SearchService.instance.smartMemories(context, limit);
}
return SearchService.instance.getRandomMomentsSearchResults(context);

View File

@@ -4,7 +4,6 @@ import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:intl/intl.dart";
import 'package:logging/logging.dart';
import "package:ml_linalg/linalg.dart";
import "package:photos/core/constants.dart";
import 'package:photos/core/event_bus.dart';
import 'package:photos/data/holidays.dart';
@@ -15,7 +14,6 @@ import "package:photos/db/ml/db.dart";
import 'package:photos/events/local_photos_updated_event.dart';
import "package:photos/extensions/user_extension.dart";
import "package:photos/models/api/collection/user.dart";
import "package:photos/models/base_location.dart";
import 'package:photos/models/collection/collection.dart';
import 'package:photos/models/collection/collection_items.dart';
import "package:photos/models/file/extensions/file_props.dart";
@@ -24,6 +22,7 @@ import 'package:photos/models/file/file_type.dart';
import "package:photos/models/local_entity_data.dart";
import "package:photos/models/location/location.dart";
import "package:photos/models/location_tag/location_tag.dart";
import "package:photos/models/memory.dart";
import "package:photos/models/ml/face/person.dart";
import 'package:photos/models/search/album_search_result.dart';
import 'package:photos/models/search/generic_search_result.dart';
@@ -36,15 +35,14 @@ import "package:photos/models/search/hierarchical/magic_filter.dart";
import "package:photos/models/search/hierarchical/top_level_generic_filter.dart";
import "package:photos/models/search/search_constants.dart";
import "package:photos/models/search/search_types.dart";
import "package:photos/models/trip_memory.dart";
import "package:photos/service_locator.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/filter/db_filters.dart";
import "package:photos/services/location_service.dart";
import "package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart";
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
import "package:photos/services/machine_learning/ml_computer.dart";
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
import "package:photos/services/smart_memories_service.dart";
import "package:photos/services/user_remote_flag_service.dart";
import "package:photos/services/user_service.dart";
import "package:photos/states/location_screen_state.dart";
@@ -1191,864 +1189,35 @@ class SearchService {
return searchResults;
}
Future<List<GenericSearchResult>> getTripsResults(
/// For debug purposes only, don't use this in production!
Future<List<GenericSearchResult>> smartMemories(
BuildContext context,
int? limit,
) async {
final List<GenericSearchResult> searchResults = [];
final allFiles = await getAllFilesForSearch();
final Iterable<LocalEntity<LocationTag>> locationTagEntities =
(await locationService.getLocationTags());
if (allFiles.isEmpty) return [];
final currentTime = DateTime.now().toLocal();
final currentMonth = currentTime.month;
final cutOffTime = currentTime.subtract(const Duration(days: 365));
final Map<LocalEntity<LocationTag>, List<EnteFile>> tagToItemsMap = {};
for (int i = 0; i < locationTagEntities.length; i++) {
tagToItemsMap[locationTagEntities.elementAt(i)] = [];
}
final List<(List<EnteFile>, Location)> smallRadiusClusters = [];
final List<(List<EnteFile>, Location)> wideRadiusClusters = [];
// Go through all files and cluster the ones not inside any location tag
for (EnteFile file in allFiles) {
if (!file.hasLocation ||
file.uploadedFileID == null ||
!file.isOwner ||
file.creationTime == null) {
continue;
}
// Check if the file is inside any location tag
bool hasLocationTag = false;
for (LocalEntity<LocationTag> tag in tagToItemsMap.keys) {
if (isFileInsideLocationTag(
tag.item.centerPoint,
file.location!,
tag.item.radius,
)) {
hasLocationTag = true;
tagToItemsMap[tag]!.add(file);
}
}
// Cluster the files not inside any location tag (incremental clustering)
if (!hasLocationTag) {
// Small radius clustering for base locations
bool foundSmallCluster = false;
for (final cluster in smallRadiusClusters) {
final clusterLocation = cluster.$2;
if (isFileInsideLocationTag(
clusterLocation,
file.location!,
0.6,
)) {
cluster.$1.add(file);
foundSmallCluster = true;
break;
}
}
if (!foundSmallCluster) {
smallRadiusClusters.add(([file], file.location!));
}
// Wide radius clustering for trip locations
bool foundWideCluster = false;
for (final cluster in wideRadiusClusters) {
final clusterLocation = cluster.$2;
if (isFileInsideLocationTag(
clusterLocation,
file.location!,
100.0,
)) {
cluster.$1.add(file);
foundWideCluster = true;
break;
}
}
if (!foundWideCluster) {
wideRadiusClusters.add(([file], file.location!));
}
}
}
// Identify base locations
final List<BaseLocation> baseLocations = [];
for (final cluster in smallRadiusClusters) {
final files = cluster.$1;
final location = cluster.$2;
// Check that the photos are distributed over a longer time range (3+ months)
final creationTimes = <int>[];
final Set<int> uniqueDays = {};
for (final file in files) {
creationTimes.add(file.creationTime!);
final date = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final dayStamp =
DateTime(date.year, date.month, date.day).microsecondsSinceEpoch;
uniqueDays.add(dayStamp);
}
creationTimes.sort();
if (creationTimes.length < 10) continue;
final firstCreationTime = DateTime.fromMicrosecondsSinceEpoch(
creationTimes.first,
);
final lastCreationTime = DateTime.fromMicrosecondsSinceEpoch(
creationTimes.last,
);
if (lastCreationTime.difference(firstCreationTime).inDays < 90) {
continue;
}
// Check for a minimum average number of days photos are clicked in range
final daysRange = lastCreationTime.difference(firstCreationTime).inDays;
if (uniqueDays.length < daysRange * 0.1) continue;
// Check if it's a current or old base location
final bool isCurrent = lastCreationTime.isAfter(
DateTime.now().subtract(
const Duration(days: 90),
),
);
baseLocations.add(BaseLocation(files, location, isCurrent));
}
// Identify trip locations
final List<TripMemory> tripLocations = [];
clusteredLocations:
for (final cluster in wideRadiusClusters) {
final files = cluster.$1;
final location = cluster.$2;
// Check that it's at least 10km away from any base or tag location
bool tooClose = false;
for (final baseLocation in baseLocations) {
if (isFileInsideLocationTag(
baseLocation.location,
location,
10.0,
)) {
tooClose = true;
break;
}
}
for (final tag in tagToItemsMap.keys) {
if (isFileInsideLocationTag(
tag.item.centerPoint,
location,
10.0,
)) {
tooClose = true;
break;
}
}
if (tooClose) continue clusteredLocations;
// Check that the photos are distributed over a short time range (2-30 days) or multiple short time ranges only
files.sort((a, b) => a.creationTime!.compareTo(b.creationTime!));
// Find distinct time blocks (potential trips)
List<EnteFile> currentBlockFiles = [files.first];
int blockStart = files.first.creationTime!;
int lastTime = files.first.creationTime!;
DateTime lastDateTime = DateTime.fromMicrosecondsSinceEpoch(lastTime);
for (int i = 1; i < files.length; i++) {
final currentFile = files[i];
final currentTime = currentFile.creationTime!;
final gap = DateTime.fromMicrosecondsSinceEpoch(currentTime)
.difference(lastDateTime)
.inDays;
// If gap is too large, end current block and check if it's a valid trip
if (gap > 15) {
// 10 days gap to separate trips. If gap is small, it's likely not a trip
if (gap < 90) continue clusteredLocations;
final blockDuration = lastDateTime
.difference(DateTime.fromMicrosecondsSinceEpoch(blockStart))
.inDays;
// Check if current block is a valid trip (2-30 days)
if (blockDuration >= 2 && blockDuration <= 30) {
tripLocations.add(
TripMemory(
List.from(currentBlockFiles),
location,
firstCreationTime: blockStart,
lastCreationTime: lastTime,
),
);
}
// Start new block
currentBlockFiles = [];
blockStart = currentTime;
}
currentBlockFiles.add(currentFile);
lastTime = currentTime;
lastDateTime = DateTime.fromMicrosecondsSinceEpoch(lastTime);
}
// Check final block
final lastBlockDuration = lastDateTime
.difference(DateTime.fromMicrosecondsSinceEpoch(blockStart))
.inDays;
if (lastBlockDuration >= 2 && lastBlockDuration <= 30) {
tripLocations.add(
TripMemory(
List.from(currentBlockFiles),
location,
firstCreationTime: blockStart,
lastCreationTime: lastTime,
),
);
}
}
// Check if any trip locations should be merged
final List<TripMemory> mergedTrips = [];
for (final trip in tripLocations) {
final tripFirstTime = DateTime.fromMicrosecondsSinceEpoch(
trip.firstCreationTime!,
);
final tripLastTime = DateTime.fromMicrosecondsSinceEpoch(
trip.lastCreationTime!,
);
bool merged = false;
for (int idx = 0; idx < mergedTrips.length; idx++) {
final otherTrip = mergedTrips[idx];
final otherTripFirstTime =
DateTime.fromMicrosecondsSinceEpoch(otherTrip.firstCreationTime!);
final otherTripLastTime =
DateTime.fromMicrosecondsSinceEpoch(otherTrip.lastCreationTime!);
if (tripFirstTime
.isBefore(otherTripLastTime.add(const Duration(days: 3))) &&
tripLastTime.isAfter(
otherTripFirstTime.subtract(const Duration(days: 3)),
)) {
mergedTrips[idx] = TripMemory(
otherTrip.files + trip.files,
otherTrip.location,
firstCreationTime: min(otherTrip.firstCreationTime!, trip.firstCreationTime!),
lastCreationTime: max(otherTrip.lastCreationTime!, trip.lastCreationTime!),
);
_logger.finest('Merged two trip locations');
merged = true;
break;
}
}
if (merged) continue;
mergedTrips.add(
TripMemory(
trip.files,
trip.location,
firstCreationTime: trip.firstCreationTime,
lastCreationTime: trip.lastCreationTime,
),
);
}
// Remove too small and too recent trips
final List<TripMemory> validTrips = [];
for (final trip in mergedTrips) {
if (trip.files.length >= 20 &&
trip.averageCreationTime() < cutOffTime.microsecondsSinceEpoch) {
validTrips.add(trip);
}
}
// For now for testing let's just surface all base locations
for (final baseLocation in baseLocations) {
String name = "Base (${baseLocation.isCurrentBase ? 'current' : 'old'})";
final String? locationName = await _tryFindLocationName(
baseLocation.files,
base: true,
);
if (locationName != null) {
name =
"$locationName (Base, ${baseLocation.isCurrentBase ? 'current' : 'old'})";
}
SmartMemoriesService.instance.init(context);
final memories = await SmartMemoriesService.instance.getMemories(limit);
final searchResults = <GenericSearchResult>[];
for (final memory in memories) {
final name = memory.name ?? "memory";
final files = Memory.filesFromMemories(memory.memories);
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
baseLocation.files,
files,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(baseLocation.files),
matchedUploadedIDs: filesToUploadedFileIDs(files),
filterIcon: Icons.event_outlined,
),
),
);
}
// For now we surface the two most recent trips of current month, and if none, the earliest upcoming redundant trip
// Group the trips per month and then year
final Map<int, Map<int, List<TripMemory>>> tripsByMonthYear = {};
for (final trip in validTrips) {
final tripDate =
DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime());
tripsByMonthYear
.putIfAbsent(tripDate.month, () => {})
.putIfAbsent(tripDate.year, () => [])
.add(trip);
}
// Flatten trips for the current month and annotate with their average date.
final List<TripMemory> currentMonthTrips = [];
if (tripsByMonthYear.containsKey(currentMonth)) {
for (final trips in tripsByMonthYear[currentMonth]!.values) {
for (final trip in trips) {
currentMonthTrips.add(trip);
}
}
}
// If there are past trips this month, show the one or two most recent ones.
if (currentMonthTrips.isNotEmpty) {
currentMonthTrips.sort(
(a, b) => b.averageCreationTime().compareTo(a.averageCreationTime()),
);
final tripsToShow = currentMonthTrips.take(2);
for (final trip in tripsToShow) {
final year =
DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime()).year;
final String? locationName = await _tryFindLocationName(trip.files);
String name = "Trip in $year";
if (locationName != null) {
name = "Trip to $locationName";
} else if (year == currentTime.year - 1) {
name = "Last year's trip";
}
final photoSelection = await _bestSelection(trip.files);
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
if (limit != null && searchResults.length >= limit) {
return searchResults;
}
}
}
// Otherwise, if no trips happened in the current month,
// look for the earliest upcoming trip in another month that has 3+ trips.
else {
// TODO lau: make sure the same upcoming trip isn't shown multiple times over multiple months
final sortedUpcomingMonths =
List<int>.generate(12, (i) => ((currentMonth + i) % 12) + 1);
checkUpcomingMonths:
for (final month in sortedUpcomingMonths) {
if (tripsByMonthYear.containsKey(month)) {
final List<TripMemory> thatMonthTrips = [];
for (final trips in tripsByMonthYear[month]!.values) {
for (final trip in trips) {
thatMonthTrips.add(trip);
}
}
if (thatMonthTrips.length >= 3) {
// take and use the third earliest trip
thatMonthTrips.sort(
(a, b) => a.averageCreationTime().compareTo(b.averageCreationTime()),
);
final trip = thatMonthTrips[2];
final year =
DateTime.fromMicrosecondsSinceEpoch(trip.averageCreationTime())
.year;
final String? locationName = await _tryFindLocationName(trip.files);
String name = "Trip in $year";
if (locationName != null) {
name = "Trip to $locationName";
} else if (year == currentTime.year - 1) {
name = "Last year's trip";
}
final photoSelection = await _bestSelection(trip.files);
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
break checkUpcomingMonths;
}
}
}
}
return searchResults;
}
Future<List<GenericSearchResult>> onThisDayOrWeekResults(
BuildContext context,
int? limit,
) async {
final List<GenericSearchResult> searchResults = [];
final trips = await getTripsResults(context, limit);
if (trips.isNotEmpty) {
searchResults.addAll(trips);
}
final allFiles = await getAllFilesForSearch();
if (allFiles.isEmpty) return [];
final currentTime = DateTime.now().toLocal();
final currentDayMonth = currentTime.month * 100 + currentTime.day;
final currentWeek = _getWeekNumber(currentTime);
final currentMonth = currentTime.month;
final cutOffTime = currentTime.subtract(const Duration(days: 365));
final averageDailyPhotos = allFiles.length / 365;
final significantDayThreshold = averageDailyPhotos * 0.25;
final significantWeekThreshold = averageDailyPhotos * 0.40;
// Group files by day-month and year
final dayMonthYearGroups = <int, Map<int, List<EnteFile>>>{};
for (final file in allFiles) {
if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue;
final creationTime =
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final dayMonth = creationTime.month * 100 + creationTime.day;
final year = creationTime.year;
dayMonthYearGroups
.putIfAbsent(dayMonth, () => {})
.putIfAbsent(year, () => [])
.add(file);
}
// Process each nearby day-month to find significant days
for (final dayMonth in dayMonthYearGroups.keys) {
final dayDiff = dayMonth - currentDayMonth;
if (dayDiff < 0 || dayDiff > 2) continue;
// TODO: lau: this doesn't cover month changes properly
final yearGroups = dayMonthYearGroups[dayMonth]!;
final significantDays = yearGroups.entries
.where((e) => e.value.length > significantDayThreshold)
.map((e) => e.key)
.toList();
if (significantDays.length >= 3) {
// Combine all years for this day-month
final date =
DateTime(currentTime.year, dayMonth ~/ 100, dayMonth % 100);
final allPhotos = yearGroups.values.expand((x) => x).toList();
final photoSelection = await _bestSelection(allPhotos);
searchResults.add(
GenericSearchResult(
ResultType.event,
"${DateFormat('MMMM d').format(date)} through the years",
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: DateFormat('MMMM d').format(date),
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
} else {
// Individual entries for significant years
for (final year in significantDays) {
final date = DateTime(year, dayMonth ~/ 100, dayMonth % 100);
final files = yearGroups[year]!;
final photoSelection = await _bestSelection(files);
String name =
DateFormat.yMMMd(Localizations.localeOf(context).languageCode)
.format(date);
if (date.day == currentTime.day && date.month == currentTime.month) {
name = "This day, ${currentTime.year - date.year} years back";
}
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
}
}
if (limit != null && searchResults.length >= limit) return searchResults;
}
// process to find significant weeks (only if there are no significant days)
if (searchResults.isEmpty) {
// Group files by week and year
final currentWeekYearGroups = <int, List<EnteFile>>{};
for (final file in allFiles) {
if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue;
final creationTime =
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final week = _getWeekNumber(creationTime);
if (week != currentWeek) continue;
final year = creationTime.year;
currentWeekYearGroups.putIfAbsent(year, () => []).add(file);
}
// Process the week and see if it's significant
if (currentWeekYearGroups.isNotEmpty) {
final significantWeeks = currentWeekYearGroups.entries
.where((e) => e.value.length > significantWeekThreshold)
.map((e) => e.key)
.toList();
if (significantWeeks.length >= 3) {
// Combine all years for this week
final allPhotos =
currentWeekYearGroups.values.expand((x) => x).toList();
final photoSelection = await _bestSelection(allPhotos);
searchResults.add(
GenericSearchResult(
ResultType.event,
"This week through the years",
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: "Week $currentWeek",
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
} else {
// Individual entries for significant years
for (final year in significantWeeks) {
final date = DateTime(year, 1, 1).add(
Duration(days: (currentWeek - 1) * 7),
);
final files = currentWeekYearGroups[year]!;
final photoSelection = await _bestSelection(files);
final name =
"This week, ${currentTime.year - date.year} years back";
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
}
}
}
}
if (limit != null && searchResults.length >= limit) return searchResults;
// process to find fillers (months)
const wantedMemories = 3;
final neededMemories = wantedMemories - searchResults.length;
if (neededMemories <= 0) return searchResults;
const monthSelectionSize = 20;
// Group files by month and year
final currentMonthYearGroups = <int, List<EnteFile>>{};
for (final file in allFiles) {
if (file.creationTime! > cutOffTime.microsecondsSinceEpoch) continue;
final creationTime =
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final month = creationTime.month;
if (month != currentMonth) continue;
final year = creationTime.year;
currentMonthYearGroups.putIfAbsent(year, () => []).add(file);
}
// Add the largest two months plus the month through the years
final sortedYearsForCurrentMonth = currentMonthYearGroups.keys.toList()
..sort(
(a, b) => currentMonthYearGroups[b]!.length.compareTo(
currentMonthYearGroups[a]!.length,
),
);
if (neededMemories > 1) {
for (int i = neededMemories; i > 1; i--) {
if (sortedYearsForCurrentMonth.isEmpty) break;
final year = sortedYearsForCurrentMonth.removeAt(0);
final monthYearFiles = currentMonthYearGroups[year]!;
final photoSelection = await _bestSelection(
monthYearFiles,
prefferedSize: monthSelectionSize,
);
final monthName =
DateFormat.MMMM(Localizations.localeOf(context).languageCode)
.format(DateTime(year, currentMonth));
final name = monthName + ", ${currentTime.year - year} years back";
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
}
}
// Show the month through the remaining years
if (sortedYearsForCurrentMonth.isEmpty) return searchResults;
final allPhotos = sortedYearsForCurrentMonth
.expand((year) => currentMonthYearGroups[year]!)
.toList();
final photoSelection =
await _bestSelection(allPhotos, prefferedSize: monthSelectionSize);
final monthName =
DateFormat.MMMM(Localizations.localeOf(context).languageCode)
.format(DateTime(currentTime.year, currentMonth));
final name = monthName + " through the years";
searchResults.add(
GenericSearchResult(
ResultType.event,
name,
photoSelection,
hierarchicalSearchFilter: TopLevelGenericFilter(
filterName: name,
occurrence: kMostRelevantFilter,
filterResultType: ResultType.event,
matchedUploadedIDs: filesToUploadedFileIDs(photoSelection),
filterIcon: Icons.event_outlined,
),
),
);
return searchResults;
}
int _getWeekNumber(DateTime date) {
// Get day of year (1-366)
final int dayOfYear = int.parse(DateFormat('D').format(date));
// Integer division by 7 and add 1 to start from week 1
return ((dayOfYear - 1) ~/ 7) + 1;
}
Future<String?> _tryFindLocationName(
List<EnteFile> files, {
bool base = false,
}) async {
final results = await locationService.getFilesInCity(files, '');
final List<City> sortedByResultCount = results.keys.toList()
..sort((a, b) => results[b]!.length.compareTo(results[a]!.length));
if (sortedByResultCount.isEmpty) return null;
final biggestPlace = sortedByResultCount.first;
if (results[biggestPlace]!.length > files.length / 2) {
return biggestPlace.city;
}
if (results.length > 2 &&
results.keys.map((city) => city.country).toSet().length == 1 &&
!base) {
return biggestPlace.country;
}
return null;
}
/// Returns the best selection of files from the given list.
/// Makes sure that the selection is not more than [prefferedSize] or 10 files,
/// and that each year of the original list is represented.
Future<List<EnteFile>> _bestSelection(
List<EnteFile> files, {
int? prefferedSize,
}) async {
final fileCount = files.length;
int targetSize = prefferedSize ?? 10;
if (fileCount <= targetSize) return files;
final safeFiles =
files.where((file) => file.uploadedFileID != null).toList();
final safeCount = safeFiles.length;
final fileIDs = safeFiles.map((e) => e.uploadedFileID!).toSet();
final fileIdToFace = await MLDataDB.instance.getFacesForFileIDs(fileIDs);
final faceIDs =
fileIdToFace.values.expand((x) => x.map((face) => face.faceID)).toSet();
final faceIDsToPersonID =
await MLDataDB.instance.getFaceIdToPersonIdForFaces(faceIDs);
final fileIdToClip =
await MLDataDB.instance.getClipVectorsForFileIDs(fileIDs);
final allYears = safeFiles.map((e) {
final creationTime = DateTime.fromMicrosecondsSinceEpoch(e.creationTime!);
return creationTime.year;
}).toSet();
// Get clip scores for each file
const query =
'Photo of a precious memory radiating warmth, vibrant energy, or quiet beauty — alive with color, light, or emotion';
// TODO: lau: optimize this later so we don't keep computing embedding
final textEmbedding = await MLComputer.instance.runClipText(query);
final textVector = Vector.fromList(textEmbedding);
const clipThreshold = 0.75;
final fileToScore = <int, double>{};
for (final file in safeFiles) {
final clip = fileIdToClip[file.uploadedFileID!];
if (clip == null) {
fileToScore[file.uploadedFileID!] = 0;
continue;
}
final score = clip.vector.dot(textVector);
fileToScore[file.uploadedFileID!] = score;
}
// Get face scores for each file
final fileToFaceCount = <int, int>{};
for (final file in safeFiles) {
final fileID = file.uploadedFileID!;
fileToFaceCount[fileID] = 0;
final faces = fileIdToFace[fileID];
if (faces == null || faces.isEmpty) {
continue;
}
for (final face in faces) {
if (faceIDsToPersonID.containsKey(face.faceID)) {
fileToFaceCount[fileID] = fileToFaceCount[fileID]! + 10;
} else {
fileToFaceCount[fileID] = fileToFaceCount[fileID]! + 1;
}
}
}
final filteredFiles = <EnteFile>[];
if (allYears.length <= 1) {
// TODO: lau: eventually this sorting might have to be replaced with some scoring system
// sort first on clip embeddings score (descending)
safeFiles.sort(
(a, b) => fileToScore[b.uploadedFileID!]!
.compareTo(fileToScore[a.uploadedFileID!]!),
);
// then sort on faces (descending), heavily prioritizing named faces
safeFiles.sort(
(a, b) => fileToFaceCount[b.uploadedFileID!]!
.compareTo(fileToFaceCount[a.uploadedFileID!]!),
);
// then filter out similar images as much as possible
filteredFiles.add(safeFiles.first);
int skipped = 0;
filesLoop:
for (final file in safeFiles.sublist(1)) {
if (filteredFiles.length >= targetSize) break;
final clip = fileIdToClip[file.uploadedFileID!];
if (clip != null && (safeCount - skipped) > targetSize) {
for (final filteredFile in filteredFiles) {
final fClip = fileIdToClip[filteredFile.uploadedFileID!];
if (fClip == null) continue;
final similarity = clip.vector.dot(fClip.vector);
if (similarity > clipThreshold) {
skipped++;
continue filesLoop;
}
}
}
filteredFiles.add(file);
}
} else {
// Multiple years, each represented and roughly equally distributed
if (prefferedSize == null && (allYears.length * 2) > 10) {
targetSize = allYears.length * 3;
if (safeCount < targetSize) return safeFiles;
}
// Group files by year and sort each year's list by CLIP then face count
final yearToFiles = <int, List<EnteFile>>{};
for (final file in safeFiles) {
final creationTime =
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final year = creationTime.year;
yearToFiles.putIfAbsent(year, () => []).add(file);
}
for (final year in yearToFiles.keys) {
final yearFiles = yearToFiles[year]!;
// sort first on clip embeddings score (descending)
yearFiles.sort(
(a, b) => fileToScore[b.uploadedFileID!]!
.compareTo(fileToScore[a.uploadedFileID!]!),
);
// then sort on faces (descending), heavily prioritizing named faces
yearFiles.sort(
(a, b) => fileToFaceCount[b.uploadedFileID!]!
.compareTo(fileToFaceCount[a.uploadedFileID!]!),
);
}
// Then join the years together one by one and filter similar images
final years = yearToFiles.keys.toList()
..sort((a, b) => b.compareTo(a)); // Recent years first
int round = 0;
int skipped = 0;
whileLoop:
while (filteredFiles.length + skipped < safeCount) {
yearLoop:
for (final year in years) {
final yearFiles = yearToFiles[year]!;
if (yearFiles.isEmpty) continue;
final newFile = yearFiles.removeAt(0);
if (round != 0 && (safeCount - skipped) > targetSize) {
// check for filtering
final clip = fileIdToClip[newFile.uploadedFileID!];
if (clip != null) {
for (final filteredFile in filteredFiles) {
final fClip = fileIdToClip[filteredFile.uploadedFileID!];
if (fClip == null) continue;
final similarity = clip.vector.dot(fClip.vector);
if (similarity > clipThreshold) {
skipped++;
continue yearLoop;
}
}
}
}
filteredFiles.add(newFile);
if (filteredFiles.length >= targetSize ||
filteredFiles.length + skipped >= safeCount) {
break whileLoop;
}
}
round++;
// Extra safety to prevent infinite loops
if (round > safeCount) break;
}
}
// Order the final selection chronologically
filteredFiles.sort((a, b) => b.creationTime!.compareTo(a.creationTime!));
return filteredFiles;
}
Future<GenericSearchResult?> getRandomDateResults(
BuildContext context,
) async {