face_widget.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import "dart:developer" show log;
  2. import "dart:typed_data";
  3. import "package:flutter/cupertino.dart";
  4. import "package:flutter/foundation.dart" show kDebugMode;
  5. import "package:flutter/material.dart";
  6. import "package:photos/extensions/stop_watch.dart";
  7. import "package:photos/face/db.dart";
  8. import "package:photos/face/model/face.dart";
  9. import "package:photos/face/model/person.dart";
  10. import 'package:photos/models/file/file.dart';
  11. import "package:photos/services/machine_learning/face_ml/face_detection/detection.dart";
  12. import "package:photos/services/machine_learning/face_ml/feedback/cluster_feedback.dart";
  13. import "package:photos/services/search_service.dart";
  14. import "package:photos/theme/ente_theme.dart";
  15. import "package:photos/ui/viewer/file/no_thumbnail_widget.dart";
  16. import "package:photos/ui/viewer/people/cluster_page.dart";
  17. import "package:photos/ui/viewer/people/cropped_face_image_view.dart";
  18. import "package:photos/ui/viewer/people/people_page.dart";
  19. import "package:photos/utils/face/face_box_crop.dart";
  20. import "package:photos/utils/thumbnail_util.dart";
  21. // import "package:photos/utils/toast_util.dart";
  22. const useGeneratedFaceCrops = true;
  23. class FaceWidget extends StatefulWidget {
  24. final EnteFile file;
  25. final Face face;
  26. final Future<Map<String, Uint8List>?>? faceCrops;
  27. final PersonEntity? person;
  28. final int? clusterID;
  29. final bool highlight;
  30. final bool editMode;
  31. const FaceWidget(
  32. this.file,
  33. this.face, {
  34. this.faceCrops,
  35. this.person,
  36. this.clusterID,
  37. this.highlight = false,
  38. this.editMode = false,
  39. Key? key,
  40. }) : super(key: key);
  41. @override
  42. State<FaceWidget> createState() => _FaceWidgetState();
  43. }
  44. class _FaceWidgetState extends State<FaceWidget> {
  45. bool isJustRemoved = false;
  46. @override
  47. Widget build(BuildContext context) {
  48. final bool givenFaces = widget.faceCrops != null;
  49. if (useGeneratedFaceCrops) {
  50. return _buildFaceImageGenerated(givenFaces);
  51. } else {
  52. return _buildFaceImageFlutterZoom();
  53. }
  54. }
  55. Widget _buildFaceImageGenerated(bool givenFaces) {
  56. return FutureBuilder<Map<String, Uint8List>?>(
  57. future: givenFaces ? widget.faceCrops : getFaceCrop(),
  58. builder: (context, snapshot) {
  59. if (snapshot.hasData) {
  60. final ImageProvider imageProvider =
  61. MemoryImage(snapshot.data![widget.face.faceID]!);
  62. return GestureDetector(
  63. onTap: () async {
  64. if (widget.editMode) return;
  65. log(
  66. "FaceWidget is tapped, with person ${widget.person} and clusterID ${widget.clusterID}",
  67. name: "FaceWidget",
  68. );
  69. if (widget.person == null && widget.clusterID == null) {
  70. // Get faceID and double check that it doesn't belong to an existing clusterID. If it does, push that cluster page
  71. final w = (kDebugMode ? EnteWatch('FaceWidget') : null)
  72. ?..start();
  73. final existingClusterID = await FaceMLDataDB.instance
  74. .getClusterIDForFaceID(widget.face.faceID);
  75. w?.log('getting existing clusterID for faceID');
  76. if (existingClusterID != null) {
  77. final fileIdsToClusterIds =
  78. await FaceMLDataDB.instance.getFileIdToClusterIds();
  79. final files = await SearchService.instance.getAllFiles();
  80. final clusterFiles = files
  81. .where(
  82. (file) =>
  83. fileIdsToClusterIds[file.uploadedFileID]
  84. ?.contains(existingClusterID) ??
  85. false,
  86. )
  87. .toList();
  88. await Navigator.of(context).push(
  89. MaterialPageRoute(
  90. builder: (context) => ClusterPage(
  91. clusterFiles,
  92. clusterID: existingClusterID,
  93. ),
  94. ),
  95. );
  96. }
  97. // Create new clusterID for the faceID and update DB to assign the faceID to the new clusterID
  98. final int newClusterID = DateTime.now().microsecondsSinceEpoch;
  99. await FaceMLDataDB.instance.updateFaceIdToClusterId(
  100. {widget.face.faceID: newClusterID},
  101. );
  102. // Push page for the new cluster
  103. await Navigator.of(context).push(
  104. MaterialPageRoute(
  105. builder: (context) => ClusterPage(
  106. [widget.file],
  107. clusterID: newClusterID,
  108. ),
  109. ),
  110. );
  111. }
  112. if (widget.person != null) {
  113. await Navigator.of(context).push(
  114. MaterialPageRoute(
  115. builder: (context) => PeoplePage(
  116. person: widget.person!,
  117. ),
  118. ),
  119. );
  120. } else if (widget.clusterID != null) {
  121. final fileIdsToClusterIds =
  122. await FaceMLDataDB.instance.getFileIdToClusterIds();
  123. final files = await SearchService.instance.getAllFiles();
  124. final clusterFiles = files
  125. .where(
  126. (file) =>
  127. fileIdsToClusterIds[file.uploadedFileID]
  128. ?.contains(widget.clusterID) ??
  129. false,
  130. )
  131. .toList();
  132. await Navigator.of(context).push(
  133. MaterialPageRoute(
  134. builder: (context) => ClusterPage(
  135. clusterFiles,
  136. clusterID: widget.clusterID!,
  137. ),
  138. ),
  139. );
  140. }
  141. },
  142. child: Column(
  143. children: [
  144. Stack(
  145. children: [
  146. Container(
  147. height: 60,
  148. width: 60,
  149. decoration: ShapeDecoration(
  150. shape: RoundedRectangleBorder(
  151. borderRadius: const BorderRadius.all(
  152. Radius.elliptical(16, 12),
  153. ),
  154. side: widget.highlight
  155. ? BorderSide(
  156. color: getEnteColorScheme(context).primary700,
  157. width: 1.0,
  158. )
  159. : BorderSide.none,
  160. ),
  161. ),
  162. child: ClipRRect(
  163. borderRadius:
  164. const BorderRadius.all(Radius.elliptical(16, 12)),
  165. child: SizedBox(
  166. width: 60,
  167. height: 60,
  168. child: Image(
  169. image: imageProvider,
  170. fit: BoxFit.cover,
  171. ),
  172. ),
  173. ),
  174. ),
  175. // TODO: the edges of the green line are still not properly rounded around ClipRRect
  176. if (widget.editMode)
  177. Positioned(
  178. right: 0,
  179. top: 0,
  180. child: GestureDetector(
  181. onTap: _cornerIconPressed,
  182. child: isJustRemoved
  183. ? const Icon(
  184. CupertinoIcons.add_circled_solid,
  185. color: Colors.green,
  186. )
  187. : const Icon(
  188. Icons.cancel,
  189. color: Colors.red,
  190. ),
  191. ),
  192. ),
  193. ],
  194. ),
  195. const SizedBox(height: 8),
  196. if (widget.person != null)
  197. Text(
  198. widget.person!.data.isIgnored
  199. ? '(ignored)'
  200. : widget.person!.data.name.trim(),
  201. style: Theme.of(context).textTheme.bodySmall,
  202. overflow: TextOverflow.ellipsis,
  203. maxLines: 1,
  204. ),
  205. if (kDebugMode)
  206. Text(
  207. 'S: ${widget.face.score.toStringAsFixed(3)}',
  208. style: Theme.of(context).textTheme.bodySmall,
  209. maxLines: 1,
  210. ),
  211. if (kDebugMode)
  212. Text(
  213. 'B: ${widget.face.blur.toStringAsFixed(0)}',
  214. style: Theme.of(context).textTheme.bodySmall,
  215. maxLines: 1,
  216. ),
  217. if (kDebugMode)
  218. Text(
  219. 'D: ${widget.face.detection.getFaceDirection().toDirectionString()}',
  220. style: Theme.of(context).textTheme.bodySmall,
  221. maxLines: 1,
  222. ),
  223. if (kDebugMode)
  224. Text(
  225. 'Sideways: ${widget.face.detection.faceIsSideways().toString()}',
  226. style: Theme.of(context).textTheme.bodySmall,
  227. maxLines: 1,
  228. ),
  229. if (kDebugMode && widget.face.score < 0.75)
  230. Text(
  231. '[Debug only]',
  232. style: Theme.of(context).textTheme.bodySmall,
  233. maxLines: 1,
  234. ),
  235. ],
  236. ),
  237. );
  238. } else {
  239. if (snapshot.connectionState == ConnectionState.waiting) {
  240. return const ClipRRect(
  241. borderRadius: BorderRadius.all(Radius.elliptical(16, 12)),
  242. child: SizedBox(
  243. width: 60,
  244. height: 60,
  245. child: CircularProgressIndicator(),
  246. ),
  247. );
  248. }
  249. if (snapshot.hasError) {
  250. log('Error getting face: ${snapshot.error}');
  251. }
  252. return const ClipRRect(
  253. borderRadius: BorderRadius.all(Radius.elliptical(16, 12)),
  254. child: SizedBox(
  255. width: 60,
  256. height: 60,
  257. child: NoThumbnailWidget(),
  258. ),
  259. );
  260. }
  261. },
  262. );
  263. }
  264. void _cornerIconPressed() async {
  265. log('face widget (file info) corner icon is pressed');
  266. try {
  267. if (isJustRemoved) {
  268. await ClusterFeedbackService.instance
  269. .addFilesToCluster([widget.face.faceID], widget.clusterID!);
  270. } else {
  271. await ClusterFeedbackService.instance
  272. .removeFilesFromCluster([widget.file], widget.clusterID!);
  273. }
  274. setState(() {
  275. isJustRemoved = !isJustRemoved;
  276. });
  277. } catch (e, s) {
  278. log("removing face/file from cluster from file info widget failed: $e, \n $s");
  279. }
  280. }
  281. Future<Map<String, Uint8List>?> getFaceCrop() async {
  282. try {
  283. final Uint8List? cachedFace = faceCropCache.get(widget.face.faceID);
  284. if (cachedFace != null) {
  285. return {widget.face.faceID: cachedFace};
  286. }
  287. final faceCropCacheFile = cachedFaceCropPath(widget.face.faceID);
  288. if ((await faceCropCacheFile.exists())) {
  289. final data = await faceCropCacheFile.readAsBytes();
  290. faceCropCache.put(widget.face.faceID, data);
  291. return {widget.face.faceID: data};
  292. }
  293. final result = await pool.withResource(
  294. () async => await getFaceCrops(
  295. widget.file,
  296. {
  297. widget.face.faceID: widget.face.detection.box,
  298. },
  299. ),
  300. );
  301. final Uint8List? computedCrop = result?[widget.face.faceID];
  302. if (computedCrop != null) {
  303. faceCropCache.put(widget.face.faceID, computedCrop);
  304. faceCropCacheFile.writeAsBytes(computedCrop).ignore();
  305. }
  306. return {widget.face.faceID: computedCrop!};
  307. } catch (e, s) {
  308. log(
  309. "Error getting face for faceID: ${widget.face.faceID}",
  310. error: e,
  311. stackTrace: s,
  312. );
  313. return null;
  314. }
  315. }
  316. Widget _buildFaceImageFlutterZoom() {
  317. return Builder(
  318. builder: (context) {
  319. return GestureDetector(
  320. onTap: () async {
  321. log(
  322. "FaceWidget is tapped, with person ${widget.person} and clusterID ${widget.clusterID}",
  323. name: "FaceWidget",
  324. );
  325. if (widget.person == null && widget.clusterID == null) {
  326. // Get faceID and double check that it doesn't belong to an existing clusterID. If it does, push that cluster page
  327. final w = (kDebugMode ? EnteWatch('FaceWidget') : null)?..start();
  328. final existingClusterID = await FaceMLDataDB.instance
  329. .getClusterIDForFaceID(widget.face.faceID);
  330. w?.log('getting existing clusterID for faceID');
  331. if (existingClusterID != null) {
  332. final fileIdsToClusterIds =
  333. await FaceMLDataDB.instance.getFileIdToClusterIds();
  334. final files = await SearchService.instance.getAllFiles();
  335. final clusterFiles = files
  336. .where(
  337. (file) =>
  338. fileIdsToClusterIds[file.uploadedFileID]
  339. ?.contains(existingClusterID) ??
  340. false,
  341. )
  342. .toList();
  343. await Navigator.of(context).push(
  344. MaterialPageRoute(
  345. builder: (context) => ClusterPage(
  346. clusterFiles,
  347. clusterID: existingClusterID,
  348. ),
  349. ),
  350. );
  351. }
  352. // Create new clusterID for the faceID and update DB to assign the faceID to the new clusterID
  353. final int newClusterID = DateTime.now().microsecondsSinceEpoch;
  354. await FaceMLDataDB.instance.updateFaceIdToClusterId(
  355. {widget.face.faceID: newClusterID},
  356. );
  357. // Push page for the new cluster
  358. await Navigator.of(context).push(
  359. MaterialPageRoute(
  360. builder: (context) => ClusterPage(
  361. [widget.file],
  362. clusterID: newClusterID,
  363. ),
  364. ),
  365. );
  366. }
  367. if (widget.person != null) {
  368. await Navigator.of(context).push(
  369. MaterialPageRoute(
  370. builder: (context) => PeoplePage(
  371. person: widget.person!,
  372. ),
  373. ),
  374. );
  375. } else if (widget.clusterID != null) {
  376. final fileIdsToClusterIds =
  377. await FaceMLDataDB.instance.getFileIdToClusterIds();
  378. final files = await SearchService.instance.getAllFiles();
  379. final clusterFiles = files
  380. .where(
  381. (file) =>
  382. fileIdsToClusterIds[file.uploadedFileID]
  383. ?.contains(widget.clusterID) ??
  384. false,
  385. )
  386. .toList();
  387. await Navigator.of(context).push(
  388. MaterialPageRoute(
  389. builder: (context) => ClusterPage(
  390. clusterFiles,
  391. clusterID: widget.clusterID!,
  392. ),
  393. ),
  394. );
  395. }
  396. },
  397. child: Column(
  398. children: [
  399. Stack(
  400. children: [
  401. Container(
  402. height: 60,
  403. width: 60,
  404. decoration: ShapeDecoration(
  405. shape: RoundedRectangleBorder(
  406. borderRadius: const BorderRadius.all(
  407. Radius.elliptical(16, 12),
  408. ),
  409. side: widget.highlight
  410. ? BorderSide(
  411. color: getEnteColorScheme(context).primary700,
  412. width: 1.0,
  413. )
  414. : BorderSide.none,
  415. ),
  416. ),
  417. child: ClipRRect(
  418. borderRadius:
  419. const BorderRadius.all(Radius.elliptical(16, 12)),
  420. child: SizedBox(
  421. width: 60,
  422. height: 60,
  423. child: CroppedFaceImageView(
  424. enteFile: widget.file,
  425. face: widget.face,
  426. ),
  427. ),
  428. ),
  429. ),
  430. if (widget.editMode)
  431. Positioned(
  432. right: 0,
  433. top: 0,
  434. child: GestureDetector(
  435. onTap: _cornerIconPressed,
  436. child: isJustRemoved
  437. ? const Icon(
  438. CupertinoIcons.add_circled_solid,
  439. color: Colors.green,
  440. )
  441. : const Icon(
  442. Icons.cancel,
  443. color: Colors.red,
  444. ),
  445. ),
  446. ),
  447. ],
  448. ),
  449. const SizedBox(height: 8),
  450. if (widget.person != null)
  451. Text(
  452. widget.person!.data.name.trim(),
  453. style: Theme.of(context).textTheme.bodySmall,
  454. overflow: TextOverflow.ellipsis,
  455. maxLines: 1,
  456. ),
  457. if (kDebugMode)
  458. Text(
  459. 'S: ${widget.face.score.toStringAsFixed(3)}',
  460. style: Theme.of(context).textTheme.bodySmall,
  461. maxLines: 1,
  462. ),
  463. if (kDebugMode)
  464. Text(
  465. 'B: ${widget.face.blur.toStringAsFixed(0)}',
  466. style: Theme.of(context).textTheme.bodySmall,
  467. maxLines: 1,
  468. ),
  469. if (kDebugMode)
  470. Text(
  471. 'D: ${widget.face.detection.getFaceDirection().toDirectionString()}',
  472. style: Theme.of(context).textTheme.bodySmall,
  473. maxLines: 1,
  474. ),
  475. if (kDebugMode)
  476. Text(
  477. 'Sideways: ${widget.face.detection.faceIsSideways().toString()}',
  478. style: Theme.of(context).textTheme.bodySmall,
  479. maxLines: 1,
  480. ),
  481. ],
  482. ),
  483. );
  484. },
  485. );
  486. }
  487. }