import 'dart:async'; import "dart:io" show Directory; import "dart:math"; import "package:collection/collection.dart"; import "package:flutter/foundation.dart"; import 'package:logging/logging.dart'; import 'package:path/path.dart' show join; import 'package:path_provider/path_provider.dart'; import "package:photos/extensions/stop_watch.dart"; import 'package:photos/face/db_fields.dart'; import "package:photos/face/db_model_mappers.dart"; import "package:photos/face/model/face.dart"; import "package:photos/models/file/file.dart"; import "package:photos/services/machine_learning/face_ml/face_clustering/face_info_for_clustering.dart"; import 'package:photos/services/machine_learning/face_ml/face_filtering/face_filtering_constants.dart'; import 'package:sqflite/sqflite.dart'; import 'package:sqlite_async/sqlite_async.dart' as sqlite_async; /// Stores all data for the ML-related features. The database can be accessed by `MlDataDB.instance.database`. /// /// This includes: /// [facesTable] - Stores all the detected faces and its embeddings in the images. /// [personTable] - Stores all the clusters of faces which are considered to be the same person. class FaceMLDataDB { static final Logger _logger = Logger("FaceMLDataDB"); static const _databaseName = "ente.face_ml_db.db"; static const _databaseVersion = 1; FaceMLDataDB._privateConstructor(); static final FaceMLDataDB instance = FaceMLDataDB._privateConstructor(); // only have a single app-wide reference to the database static Future? _dbFuture; static Future? _sqliteAsyncDBFuture; Future get database async { _dbFuture ??= _initDatabase(); return _dbFuture!; } Future get sqliteAsyncDB async { _sqliteAsyncDBFuture ??= _initSqliteAsyncDatabase(); return _sqliteAsyncDBFuture!; } Future _initDatabase() async { final documentsDirectory = await getApplicationDocumentsDirectory(); final String databaseDirectory = join(documentsDirectory.path, _databaseName); return await openDatabase( databaseDirectory, version: _databaseVersion, onCreate: _onCreate, ); } Future _initSqliteAsyncDatabase() async { final Directory documentsDirectory = await getApplicationDocumentsDirectory(); final String databaseDirectory = join(documentsDirectory.path, _databaseName); _logger.info("Opening sqlite_async access: DB path " + databaseDirectory); return sqlite_async.SqliteDatabase(path: databaseDirectory, maxReaders: 1); } Future _onCreate(Database db, int version) async { await db.execute(createFacesTable); await db.execute(createFaceClustersTable); await db.execute(createClusterPersonTable); await db.execute(createClusterSummaryTable); await db.execute(createNotPersonFeedbackTable); await db.execute(fcClusterIDIndex); } // bulkInsertFaces inserts the faces in the database in batches of 1000. // This is done to avoid the error "too many SQL variables" when inserting // a large number of faces. Future bulkInsertFaces(List faces) async { final db = await instance.database; const batchSize = 500; final numBatches = (faces.length / batchSize).ceil(); for (int i = 0; i < numBatches; i++) { final start = i * batchSize; final end = min((i + 1) * batchSize, faces.length); final batch = faces.sublist(start, end); final batchInsert = db.batch(); for (final face in batch) { batchInsert.insert( facesTable, mapRemoteToFaceDB(face), conflictAlgorithm: ConflictAlgorithm.ignore, ); } await batchInsert.commit(noResult: true); } } Future updateClusterIdToFaceId( Map faceIDToClusterID, ) async { final db = await instance.database; const batchSize = 500; final numBatches = (faceIDToClusterID.length / batchSize).ceil(); for (int i = 0; i < numBatches; i++) { final start = i * batchSize; final end = min((i + 1) * batchSize, faceIDToClusterID.length); final batch = faceIDToClusterID.entries.toList().sublist(start, end); final batchUpdate = db.batch(); for (final entry in batch) { final faceID = entry.key; final clusterID = entry.value; batchUpdate.insert( faceClustersTable, {fcClusterID: clusterID, fcFaceId: faceID}, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batchUpdate.commit(noResult: true); } } /// Returns a map of fileID to the indexed ML version Future> getIndexedFileIds() async { final db = await instance.sqliteAsyncDB; final List> maps = await db.getAll( 'SELECT $fileIDColumn, $mlVersionColumn FROM $facesTable', ); final Map result = {}; for (final map in maps) { result[map[fileIDColumn] as int] = map[mlVersionColumn] as int; } return result; } Future getIndexedFileCount() async { final db = await instance.sqliteAsyncDB; final List> maps = await db.getAll( 'SELECT COUNT(DISTINCT $fileIDColumn) as count FROM $facesTable', ); return maps.first['count'] as int; } Future> clusterIdToFaceCount() async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $fcClusterID, COUNT(*) as count FROM $faceClustersTable where $fcClusterID IS NOT NULL GROUP BY $fcClusterID ', ); final Map result = {}; for (final map in maps) { result[map[fcClusterID] as int] = map['count'] as int; } return result; } Future> getPersonIgnoredClusters(String personID) async { final db = await instance.database; // find out clusterIds that are assigned to other persons using the clusters table final List> maps = await db.rawQuery( 'SELECT $clusterIDColumn FROM $clusterPersonTable WHERE $personIdColumn != ? AND $personIdColumn IS NOT NULL', [personID], ); final Set ignoredClusterIDs = maps.map((e) => e[clusterIDColumn] as int).toSet(); final List> rejectMaps = await db.rawQuery( 'SELECT $clusterIDColumn FROM $notPersonFeedback WHERE $personIdColumn = ?', [personID], ); final Set rejectClusterIDs = rejectMaps.map((e) => e[clusterIDColumn] as int).toSet(); return ignoredClusterIDs.union(rejectClusterIDs); } Future> getPersonClusterIDs(String personID) async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $clusterIDColumn FROM $clusterPersonTable WHERE $personIdColumn = ?', [personID], ); return maps.map((e) => e[clusterIDColumn] as int).toSet(); } Future clearTable() async { final db = await instance.database; await db.delete(facesTable); await db.delete(clusterPersonTable); await db.delete(clusterSummaryTable); await db.delete(personTable); await db.delete(notPersonFeedback); } Future> getFaceEmbeddingsForCluster( int clusterID, { int? limit, }) async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $faceEmbeddingBlob FROM $facesTable WHERE $faceIDColumn in (SELECT $fcFaceId from $faceClustersTable where $fcClusterID = ?) ${limit != null ? 'LIMIT $limit' : ''}', [clusterID], ); return maps.map((e) => e[faceEmbeddingBlob] as Uint8List); } Future>> getFaceEmbeddingsForClusters( Iterable clusterIDs, { int? limit, }) async { final db = await instance.database; final Map> result = {}; final selectQuery = ''' SELECT fc.$fcClusterID, fe.$faceEmbeddingBlob FROM $faceClustersTable fc INNER JOIN $facesTable fe ON fc.$fcFaceId = fe.$faceIDColumn WHERE fc.$fcClusterID IN (${clusterIDs.join(',')}) ${limit != null ? 'LIMIT $limit' : ''} '''; final List> maps = await db.rawQuery(selectQuery); for (final map in maps) { final clusterID = map[fcClusterID] as int; final faceEmbedding = map[faceEmbeddingBlob] as Uint8List; result.putIfAbsent(clusterID, () => []).add(faceEmbedding); } return result; } Future getCoverFaceForPerson({ required int recentFileID, String? personID, String? avatarFaceId, int? clusterID, }) async { // read person from db final db = await instance.database; if (personID != null) { final List fileId = [recentFileID]; int? avatarFileId; if (avatarFaceId != null) { avatarFileId = int.tryParse(avatarFaceId.split('_')[0]); if (avatarFileId != null) { fileId.add(avatarFileId); } } final cluterRows = await db.query( clusterPersonTable, columns: [clusterIDColumn], where: '$personIdColumn = ?', whereArgs: [personID], ); final clusterIDs = cluterRows.map((e) => e[clusterIDColumn] as int).toList(); final List> faceMaps = await db.rawQuery( 'SELECT * FROM $facesTable where ' '$faceIDColumn in (SELECT $fcFaceId from $faceClustersTable where $fcClusterID IN (${clusterIDs.join(",")}))' 'AND $fileIDColumn in (${fileId.join(",")}) AND $faceScore > $kMinimumQualityFaceScore ORDER BY $faceScore DESC', ); if (faceMaps.isNotEmpty) { if (avatarFileId != null) { final row = faceMaps.firstWhereOrNull( (element) => (element[fileIDColumn] as int) == avatarFileId, ); if (row != null) { return mapRowToFace(row); } } return mapRowToFace(faceMaps.first); } } if (clusterID != null) { final List> faceMaps = await db.query( faceClustersTable, columns: [fcFaceId], where: '$fcClusterID = ?', whereArgs: [clusterID], ); final List? faces = await getFacesForGivenFileID(recentFileID); if (faces != null) { for (final face in faces) { if (faceMaps .any((element) => (element[fcFaceId] as String) == face.faceID)) { return face; } } } } if (personID == null && clusterID == null) { throw Exception("personID and clusterID cannot be null"); } return null; } Future?> getFacesForGivenFileID(int fileUploadID) async { final db = await instance.database; final List> maps = await db.query( facesTable, columns: [ faceIDColumn, fileIDColumn, faceEmbeddingBlob, faceScore, faceDetectionColumn, faceBlur, imageHeight, imageWidth, mlVersionColumn, ], where: '$fileIDColumn = ?', whereArgs: [fileUploadID], ); if (maps.isEmpty) { return null; } return maps.map((e) => mapRowToFace(e)).toList(); } Future getFaceForFaceID(String faceID) async { final db = await instance.database; final result = await db.rawQuery( 'SELECT * FROM $facesTable where $faceIDColumn = ?', [faceID], ); if (result.isEmpty) { return null; } return mapRowToFace(result.first); } Future> getFaceIDsForCluster(int clusterID) async { final db = await instance.sqliteAsyncDB; final List> maps = await db.getAll( 'SELECT $fcFaceId FROM $faceClustersTable ' 'WHERE $faceClustersTable.$fcClusterID = ?', [clusterID], ); return maps.map((e) => e[fcFaceId] as String).toSet(); } Future> getFaceIDsForPerson(String personID) async { final db = await instance.sqliteAsyncDB; final faceIdsResult = await db.getAll( 'SELECT $fcFaceId FROM $faceClustersTable LEFT JOIN $clusterPersonTable ' 'ON $faceClustersTable.$fcClusterID = $clusterPersonTable.$clusterIDColumn ' 'WHERE $clusterPersonTable.$personIdColumn = ?', [personID], ); return faceIdsResult.map((e) => e[fcFaceId] as String).toSet(); } Future> getBlurValuesForCluster(int clusterID) async { final db = await instance.sqliteAsyncDB; const String query = ''' SELECT $facesTable.$faceBlur FROM $facesTable JOIN $faceClustersTable ON $facesTable.$faceIDColumn = $faceClustersTable.$fcFaceId WHERE $faceClustersTable.$fcClusterID = ? '''; // const String query2 = ''' // SELECT $faceBlur // FROM $facesTable // WHERE $faceIDColumn IN (SELECT $fcFaceId FROM $faceClustersTable WHERE $fcClusterID = ?) // '''; final List> maps = await db.getAll( query, [clusterID], ); return maps.map((e) => e[faceBlur] as double).toSet(); } Future> getFaceIDsToBlurValues( int maxBlurValue, ) async { final db = await instance.sqliteAsyncDB; final List> maps = await db.getAll( 'SELECT $faceIDColumn, $faceBlur FROM $facesTable WHERE $faceBlur < $maxBlurValue AND $faceBlur > 1 ORDER BY $faceBlur ASC', ); final Map result = {}; for (final map in maps) { result[map[faceIDColumn] as String] = map[faceBlur] as double; } return result; } Future> getFaceIdsToClusterIds( Iterable faceIds, ) async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $fcFaceId, $fcClusterID FROM $faceClustersTable where $fcFaceId IN (${faceIds.map((id) => "'$id'").join(",")})', ); final Map result = {}; for (final map in maps) { result[map[fcFaceId] as String] = map[fcClusterID] as int?; } return result; } Future>> getFileIdToClusterIds() async { final Map> result = {}; final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $fcClusterID, $fcFaceId FROM $faceClustersTable', ); for (final map in maps) { final clusterID = map[fcClusterID] as int; final faceID = map[fcFaceId] as String; final x = faceID.split('_').first; final fileID = int.parse(x); result[fileID] = (result[fileID] ?? {})..add(clusterID); } return result; } Future forceUpdateClusterIds( Map faceIDToClusterID, ) async { final db = await instance.database; // Start a batch final batch = db.batch(); for (final map in faceIDToClusterID.entries) { final faceID = map.key; final clusterID = map.value; batch.insert( faceClustersTable, {fcFaceId: faceID, fcClusterID: clusterID}, conflictAlgorithm: ConflictAlgorithm.replace, ); } // Commit the batch await batch.commit(noResult: true); } Future removePerson(String personID) async { final db = await instance.database; await db.delete( clusterPersonTable, where: '$personIdColumn = ?', whereArgs: [personID], ); await db.delete( notPersonFeedback, where: '$personIdColumn = ?', whereArgs: [personID], ); } Future> getFaceInfoForClustering({ double minScore = kMinimumQualityFaceScore, int minClarity = kLaplacianHardThreshold, int maxFaces = 20000, int offset = 0, int batchSize = 10000, }) async { final EnteWatch w = EnteWatch("getFaceEmbeddingMap")..start(); w.logAndReset( 'reading as float offset: $offset, maxFaces: $maxFaces, batchSize: $batchSize', ); final db = await instance.sqliteAsyncDB; final Set result = {}; while (true) { // Query a batch of rows final List> maps = await db.getAll( 'SELECT $faceIDColumn, $faceEmbeddingBlob, $faceScore, $faceBlur, $isSideways FROM $facesTable' ' WHERE $faceScore > $minScore AND $faceBlur > $minClarity' ' ORDER BY $faceIDColumn' ' DESC LIMIT $batchSize OFFSET $offset', ); // Break the loop if no more rows if (maps.isEmpty) { break; } final List faceIds = []; for (final map in maps) { faceIds.add(map[faceIDColumn] as String); } final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds); for (final map in maps) { final faceID = map[faceIDColumn] as String; final faceInfo = FaceInfoForClustering( faceID: faceID, clusterId: faceIdToClusterId[faceID], embeddingBytes: map[faceEmbeddingBlob] as Uint8List, faceScore: map[faceScore] as double, blurValue: map[faceBlur] as double, isSideways: (map[isSideways] as int) == 1, ); result.add(faceInfo); } if (result.length >= maxFaces) { break; } offset += batchSize; } w.stopWithLog('done reading face embeddings ${result.length}'); return result; } /// Returns a map of faceID to record of clusterId and faceEmbeddingBlob /// /// Only selects faces with score greater than [minScore] and blur score greater than [minClarity] Future> getFaceEmbeddingMap({ double minScore = kMinimumQualityFaceScore, int minClarity = kLaplacianHardThreshold, int maxFaces = 20000, int offset = 0, int batchSize = 10000, }) async { final EnteWatch w = EnteWatch("getFaceEmbeddingMap")..start(); w.logAndReset( 'reading as float offset: $offset, maxFaces: $maxFaces, batchSize: $batchSize', ); final db = await instance.sqliteAsyncDB; final Map result = {}; while (true) { // Query a batch of rows final List> maps = await db.getAll( 'SELECT $faceIDColumn, $faceEmbeddingBlob FROM $facesTable' ' WHERE $faceScore > $minScore AND $faceBlur > $minClarity' ' ORDER BY $faceIDColumn' ' DESC LIMIT $batchSize OFFSET $offset', // facesTable, // columns: [faceIDColumn, faceEmbeddingBlob], // where: '$faceScore > $minScore and $faceBlur > $minClarity', // limit: batchSize, // offset: offset, // orderBy: '$faceIDColumn DESC', ); // Break the loop if no more rows if (maps.isEmpty) { break; } final List faceIds = []; for (final map in maps) { faceIds.add(map[faceIDColumn] as String); } final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds); for (final map in maps) { final faceID = map[faceIDColumn] as String; result[faceID] = (faceIdToClusterId[faceID], map[faceEmbeddingBlob] as Uint8List); } if (result.length >= maxFaces) { break; } offset += batchSize; } w.stopWithLog('done reading face embeddings ${result.length}'); return result; } Future> getFaceEmbeddingMapForFile( List fileIDs, ) async { _logger.info('reading face embeddings for ${fileIDs.length} files'); final db = await instance.database; // Define the batch size const batchSize = 10000; int offset = 0; final Map result = {}; while (true) { // Query a batch of rows final List> maps = await db.query( facesTable, columns: [faceIDColumn, faceEmbeddingBlob], where: '$faceScore > $kMinimumQualityFaceScore AND $faceBlur > $kLaplacianHardThreshold AND $fileIDColumn IN (${fileIDs.join(",")})', limit: batchSize, offset: offset, orderBy: '$faceIDColumn DESC', ); // Break the loop if no more rows if (maps.isEmpty) { break; } for (final map in maps) { final faceID = map[faceIDColumn] as String; result[faceID] = map[faceEmbeddingBlob] as Uint8List; } if (result.length > 10000) { break; } offset += batchSize; } _logger.info('done reading face embeddings for ${fileIDs.length} files'); return result; } Future> getFaceEmbeddingMapForFaces( Iterable faceIDs, ) async { _logger.info('reading face embeddings for ${faceIDs.length} faces'); final db = await instance.sqliteAsyncDB; // Define the batch size const batchSize = 10000; int offset = 0; final Map result = {}; while (true) { // Query a batch of rows final String query = ''' SELECT $faceIDColumn, $faceEmbeddingBlob FROM $facesTable WHERE $faceIDColumn IN (${faceIDs.map((id) => "'$id'").join(",")}) ORDER BY $faceIDColumn DESC LIMIT $batchSize OFFSET $offset '''; final List> maps = await db.getAll(query); // Break the loop if no more rows if (maps.isEmpty) { break; } for (final map in maps) { final faceID = map[faceIDColumn] as String; result[faceID] = map[faceEmbeddingBlob] as Uint8List; } if (result.length > 10000) { break; } offset += batchSize; } _logger.info('done reading face embeddings for ${faceIDs.length} faces'); return result; } Future getTotalFaceCount({ double minFaceScore = kMinimumQualityFaceScore, }) async { final db = await instance.sqliteAsyncDB; final List> maps = await db.getAll( 'SELECT COUNT(*) as count FROM $facesTable WHERE $faceScore > $minFaceScore AND $faceBlur > $kLaplacianHardThreshold', ); return maps.first['count'] as int; } Future getClusteredToTotalFacesRatio() async { final db = await instance.sqliteAsyncDB; final List> totalFacesMaps = await db.getAll( 'SELECT COUNT(*) as count FROM $facesTable WHERE $faceScore > $kMinimumQualityFaceScore AND $faceBlur > $kLaplacianHardThreshold', ); final int totalFaces = totalFacesMaps.first['count'] as int; final List> clusteredFacesMaps = await db.getAll( 'SELECT COUNT(DISTINCT $fcFaceId) as count FROM $faceClustersTable', ); final int clusteredFaces = clusteredFacesMaps.first['count'] as int; return clusteredFaces / totalFaces; } Future getBlurryFaceCount([ int blurThreshold = kLaplacianHardThreshold, ]) async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT COUNT(*) as count FROM $facesTable WHERE $faceBlur <= $blurThreshold AND $faceScore > $kMinimumQualityFaceScore', ); return maps.first['count'] as int; } Future resetClusterIDs() async { final db = await instance.database; await db.execute(dropFaceClustersTable); await db.execute(createFaceClustersTable); await db.execute(fcClusterIDIndex); } Future assignClusterToPerson({ required String personID, required int clusterID, }) async { final db = await instance.database; await db.insert( clusterPersonTable, { personIdColumn: personID, clusterIDColumn: clusterID, }, ); } Future bulkAssignClusterToPersonID( Map clusterToPersonID, ) async { final db = await instance.database; final batch = db.batch(); for (final entry in clusterToPersonID.entries) { final clusterID = entry.key; final personID = entry.value; batch.insert( clusterPersonTable, { personIdColumn: personID, clusterIDColumn: clusterID, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); } Future captureNotPersonFeedback({ required String personID, required int clusterID, }) async { final db = await instance.database; await db.insert( notPersonFeedback, { personIdColumn: personID, clusterIDColumn: clusterID, }, ); } Future bulkCaptureNotPersonFeedback( Map clusterToPersonID, ) async { final db = await instance.database; final batch = db.batch(); for (final entry in clusterToPersonID.entries) { final clusterID = entry.key; final personID = entry.value; batch.insert( notPersonFeedback, { personIdColumn: personID, clusterIDColumn: clusterID, }, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); } Future removeClusterToPerson({ required String personID, required int clusterID, }) async { final db = await instance.database; return db.delete( clusterPersonTable, where: '$personIdColumn = ? AND $clusterIDColumn = ?', whereArgs: [personID, clusterID], ); } // for a given personID, return a map of clusterID to fileIDs using join query Future>> getFileIdToClusterIDSet(String personID) { final db = instance.database; return db.then((db) async { final List> maps = await db.rawQuery( 'SELECT $faceClustersTable.$fcClusterID, $fcFaceId FROM $faceClustersTable ' 'INNER JOIN $clusterPersonTable ' 'ON $faceClustersTable.$fcClusterID = $clusterPersonTable.$clusterIDColumn ' 'WHERE $clusterPersonTable.$personIdColumn = ?', [personID], ); final Map> result = {}; for (final map in maps) { final clusterID = map[clusterIDColumn] as int; final String faceID = map[fcFaceId] as String; final fileID = int.parse(faceID.split('_').first); result[fileID] = (result[fileID] ?? {})..add(clusterID); } return result; }); } Future>> getFileIdToClusterIDSetForCluster( Set clusterIDs, ) { final db = instance.database; return db.then((db) async { final List> maps = await db.rawQuery( 'SELECT $fcClusterID, $fcFaceId FROM $faceClustersTable ' 'WHERE $fcClusterID IN (${clusterIDs.join(",")})', ); final Map> result = {}; for (final map in maps) { final clusterID = map[fcClusterID] as int; final faceId = map[fcFaceId] as String; final fileID = int.parse(faceId.split("_").first); result[fileID] = (result[fileID] ?? {})..add(clusterID); } return result; }); } Future clusterSummaryUpdate(Map summary) async { final db = await instance.database; var batch = db.batch(); int batchCounter = 0; for (final entry in summary.entries) { if (batchCounter == 400) { await batch.commit(noResult: true); batch = db.batch(); batchCounter = 0; } final int cluserID = entry.key; final int count = entry.value.$2; final Uint8List avg = entry.value.$1; batch.insert( clusterSummaryTable, { clusterIDColumn: cluserID, avgColumn: avg, countColumn: count, }, conflictAlgorithm: ConflictAlgorithm.replace, ); batchCounter++; } await batch.commit(noResult: true); } /// Returns a map of clusterID to (avg embedding, count) Future> getAllClusterSummary([ int? minClusterSize, ]) async { final db = await instance.sqliteAsyncDB; final Map result = {}; final rows = await db.getAll( 'SELECT * FROM $clusterSummaryTable${minClusterSize != null ? ' WHERE $countColumn >= $minClusterSize' : ''}', ); for (final r in rows) { final id = r[clusterIDColumn] as int; final avg = r[avgColumn] as Uint8List; final count = r[countColumn] as int; result[id] = (avg, count); } return result; } Future> getClusterIDToPersonID() async { final db = await instance.database; final List> maps = await db.rawQuery( 'SELECT $personIdColumn, $clusterIDColumn FROM $clusterPersonTable', ); final Map result = {}; for (final map in maps) { result[map[clusterIDColumn] as int] = map[personIdColumn] as String; } return result; } /// WARNING: This will delete ALL data in the database! Only use this for debug/testing purposes! Future dropClustersAndPersonTable({bool faces = false}) async { final db = await instance.database; if (faces) { await db.execute(deleteFacesTable); await db.execute(createFacesTable); await db.execute(dropFaceClustersTable); await db.execute(createFaceClustersTable); await db.execute(fcClusterIDIndex); } await db.execute(deletePersonTable); await db.execute(dropClusterPersonTable); await db.execute(dropClusterSummaryTable); await db.execute(dropNotPersonFeedbackTable); await db.execute(createClusterPersonTable); await db.execute(createNotPersonFeedbackTable); await db.execute(createClusterSummaryTable); } /// WARNING: This will delete ALL data in the database! Only use this for debug/testing purposes! Future dropFeedbackTables() async { final db = await instance.database; await db.execute(deletePersonTable); await db.execute(dropClusterPersonTable); await db.execute(dropNotPersonFeedbackTable); await db.execute(dropClusterSummaryTable); await db.execute(createClusterPersonTable); await db.execute(createNotPersonFeedbackTable); await db.execute(createClusterSummaryTable); } Future removeFilesFromPerson( List files, String personID, ) async { final db = await instance.database; final faceIdsResult = await db.rawQuery( 'SELECT $fcFaceId FROM $faceClustersTable LEFT JOIN $clusterPersonTable ' 'ON $faceClustersTable.$fcClusterID = $clusterPersonTable.$clusterIDColumn ' 'WHERE $clusterPersonTable.$personIdColumn = ?', [personID], ); final Set fileIds = {}; for (final enteFile in files) { fileIds.add(enteFile.uploadedFileID.toString()); } int maxClusterID = DateTime.now().microsecondsSinceEpoch; final Map faceIDToClusterID = {}; for (final row in faceIdsResult) { final faceID = row[fcFaceId] as String; if (fileIds.contains(faceID.split('_').first)) { maxClusterID += 1; faceIDToClusterID[faceID] = maxClusterID; } } await forceUpdateClusterIds(faceIDToClusterID); } Future removeFilesFromCluster( List files, int clusterID, ) async { final db = await instance.database; final faceIdsResult = await db.rawQuery( 'SELECT $fcFaceId FROM $faceClustersTable ' 'WHERE $faceClustersTable.$fcClusterID = ?', [clusterID], ); final Set fileIds = {}; for (final enteFile in files) { fileIds.add(enteFile.uploadedFileID.toString()); } int maxClusterID = DateTime.now().microsecondsSinceEpoch; final Map faceIDToClusterID = {}; for (final row in faceIdsResult) { final faceID = row[fcFaceId] as String; if (fileIds.contains(faceID.split('_').first)) { maxClusterID += 1; faceIDToClusterID[faceID] = maxClusterID; } } await forceUpdateClusterIds(faceIDToClusterID); } Future addFacesToCluster( List faceIDs, int clusterID, ) async { final faceIDToClusterID = {}; for (final faceID in faceIDs) { faceIDToClusterID[faceID] = clusterID; } await forceUpdateClusterIds(faceIDToClusterID); } }