From a7c2dea6a9a69bc35d8d9db21bc810cf9c00c2af Mon Sep 17 00:00:00 2001 From: Crosson David Date: Sun, 4 Aug 2024 19:40:29 +0200 Subject: [PATCH] First attempt/experiment for #4 with some refactoring --- build.sbt | 37 ++-- .../sotohp/store/PhotoStoreService.scala | 2 + .../sotohp/store/PhotoStoreServiceLive.scala | 185 ++++++++---------- .../sotohp/core/PhotoStoreServiceFake.scala | 7 + .../sotohp/cli/FaceFeaturesClustering.scala | 40 ++++ 5 files changed, 154 insertions(+), 117 deletions(-) create mode 100644 user-interfaces/cli/src/main/scala/fr/janalyse/sotohp/cli/FaceFeaturesClustering.scala diff --git a/build.sbt b/build.sbt index 08ce541..cf927ef 100644 --- a/build.sbt +++ b/build.sbt @@ -33,18 +33,18 @@ val versions = new { } lazy val deepJavaLearningLibs = Seq( - "ai.djl" % "api" % versions.djl, - "ai.djl" % "basicdataset" % versions.djl, - "ai.djl" % "model-zoo" % versions.djl, - "ai.djl.huggingface" % "tokenizers" % versions.djl, - "ai.djl.mxnet" % "mxnet-engine" % versions.djl, - "ai.djl.mxnet" % "mxnet-model-zoo" % versions.djl, - "ai.djl.pytorch" % "pytorch-engine" % versions.djl, - "ai.djl.pytorch" % "pytorch-model-zoo" % versions.djl, - "ai.djl.tensorflow" % "tensorflow-engine" % versions.djl, - "ai.djl.tensorflow" % "tensorflow-model-zoo" % versions.djl, - "ai.djl.onnxruntime" % "onnxruntime-engine" % versions.djl, - "net.java.dev.jna" % "jna" % "5.14.0" + "ai.djl" % "api" % versions.djl, + "ai.djl" % "basicdataset" % versions.djl, + "ai.djl" % "model-zoo" % versions.djl, + "ai.djl.huggingface" % "tokenizers" % versions.djl, + "ai.djl.mxnet" % "mxnet-engine" % versions.djl, + "ai.djl.mxnet" % "mxnet-model-zoo" % versions.djl, + "ai.djl.pytorch" % "pytorch-engine" % versions.djl, + "ai.djl.pytorch" % "pytorch-model-zoo" % versions.djl, + "ai.djl.tensorflow" % "tensorflow-engine" % versions.djl, + "ai.djl.tensorflow" % "tensorflow-model-zoo" % versions.djl, + "ai.djl.onnxruntime" % "onnxruntime-engine" % versions.djl, + "net.java.dev.jna" % "jna" % "5.14.0" ) lazy val lmdbJavaOptions = Seq( @@ -153,13 +153,14 @@ lazy val userInterfacesCLI = fork := true, javaOptions ++= lmdbJavaOptions, libraryDependencies ++= Seq( - "dev.zio" %% "zio" % versions.zio, - "dev.zio" %% "zio-config" % versions.zioconfig, - "dev.zio" %% "zio-config-typesafe" % versions.zioconfig, - "dev.zio" %% "zio-config-magnolia" % versions.zioconfig, - "dev.zio" %% "zio-logging" % versions.ziologging, // Temporary - "dev.zio" %% "zio-logging-slf4j2-bridge" % versions.ziologging // Temporary + "dev.zio" %% "zio" % versions.zio, + "dev.zio" %% "zio-config" % versions.zioconfig, + "dev.zio" %% "zio-config-typesafe" % versions.zioconfig, + "dev.zio" %% "zio-config-magnolia" % versions.zioconfig, + "dev.zio" %% "zio-logging" % versions.ziologging, // Temporary + "dev.zio" %% "zio-logging-slf4j2-bridge" % versions.ziologging, // Temporary // "ch.qos.logback" % "logback-classic" % "1.4.11" // Temporary + "com.github.haifengl" % "smile-core" % "3.1.1" // Temporary for quick&dirty evaluation of the DBSCAN clustering algo ) ) diff --git a/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreService.scala b/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreService.scala index c984796..ca1bc3e 100644 --- a/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreService.scala +++ b/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreService.scala @@ -86,6 +86,7 @@ trait PhotoStoreService { def photoFacesDelete(photoId: PhotoId): IO[PhotoStoreIssue, Unit] // photo face features collection + def photoFaceFeaturesStream(): Stream[PhotoStoreIssue, (FaceId, FaceFeatures)] def photoFaceFeaturesGet(faceId: FaceId): IO[PhotoStoreIssue, Option[FaceFeatures]] def photoFaceFeaturesContains(faceId: FaceId): IO[PhotoStoreIssue, Boolean] def photoFaceFeaturesUpsert(faceId: FaceId, faceFeatures: FaceFeatures): IO[PhotoStoreIssue, Unit] @@ -156,6 +157,7 @@ object PhotoStoreService { def photoFacesUpsert(photoId: PhotoId, metaData: PhotoFaces): ZIO[PhotoStoreService, PhotoStoreIssue, Unit] = serviceWithZIO(_.photoFacesUpsert(photoId, metaData)) def photoFacesDelete(photoId: PhotoId): ZIO[PhotoStoreService, PhotoStoreIssue, Unit] = serviceWithZIO(_.photoFacesDelete(photoId)) + def photoFaceFeaturesStream(): ZStream[PhotoStoreService, PhotoStoreIssue, (FaceId, FaceFeatures)] = ZStream.serviceWithStream(_.photoFaceFeaturesStream()) def photoFaceFeaturesGet(faceId: FaceId): ZIO[PhotoStoreService, PhotoStoreIssue, Option[FaceFeatures]] = serviceWithZIO(_.photoFaceFeaturesGet(faceId)) def photoFaceFeaturesContains(faceId: FaceId): ZIO[PhotoStoreService, PhotoStoreIssue, Boolean] = serviceWithZIO(_.photoFaceFeaturesContains(faceId)) def photoFaceFeaturesUpsert(faceId: FaceId, faceFeatures: FaceFeatures): ZIO[PhotoStoreService, PhotoStoreIssue, Unit] = serviceWithZIO(_.photoFaceFeaturesUpsert(faceId, faceFeatures)) diff --git a/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreServiceLive.scala b/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreServiceLive.scala index ba9415a..2d964b1 100644 --- a/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreServiceLive.scala +++ b/modules/core/src/main/scala/fr/janalyse/sotohp/store/PhotoStoreServiceLive.scala @@ -167,19 +167,17 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, result => result.map((key, daoState) => daoStateToState(daoState))) // =================================================================================================================== - def daoSourceToSource(from: Option[DaoPhotoSource]): Option[PhotoSource] = { - from.map(daoSource => - PhotoSource( - photoId = PhotoId(ULID(daoSource.photoId)), - original = Original( - ownerId = PhotoOwnerId(ULID(daoSource.originalOwnerId)), - baseDirectory = Path.of(daoSource.originalBaseDirectory), - path = Path.of(daoSource.originalPath) - ), - fileSize = daoSource.fileSize, - fileHash = PhotoHash(daoSource.fileHash), - fileLastModified = daoSource.fileLastModified - ) + def daoSourceToSource(daoSource: DaoPhotoSource): PhotoSource = { + PhotoSource( + photoId = PhotoId(ULID(daoSource.photoId)), + original = Original( + ownerId = PhotoOwnerId(ULID(daoSource.originalOwnerId)), + baseDirectory = Path.of(daoSource.originalBaseDirectory), + path = Path.of(daoSource.originalPath) + ), + fileSize = daoSource.fileSize, + fileHash = PhotoHash(daoSource.fileHash), + fileLastModified = daoSource.fileLastModified ) } @@ -198,7 +196,7 @@ class PhotoStoreServiceLive private ( override def photoSourceGet(originalId: OriginalId): IO[PhotoStoreIssue, Option[PhotoSource]] = sourcesCollection .fetch(originalIdToCollectionKey(originalId)) - .mapBoth(convertFailures, daoSourceToSource) + .mapBoth(convertFailures, _.map(daoSourceToSource)) override def photoSourceContains(originalId: OriginalId): IO[PhotoStoreIssue, Boolean] = sourcesCollection @@ -216,15 +214,13 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoMetaDataToMetaData(from: Option[DaoPhotoMetaData]): Option[PhotoMetaData] = { - from.map(daoMetaData => - PhotoMetaData( - dimension = daoMetaData.dimension.map(daoDim => Dimension2D(width = daoDim.width, height = daoDim.height)), - shootDateTime = daoMetaData.shootDateTime, - orientation = PhotoOrientation.values.find(orientation => daoMetaData.orientation.isDefined && orientation.code == daoMetaData.orientation.get), // TODO can be enhanced - cameraName = daoMetaData.cameraName, - tags = daoMetaData.tags - ) + def daoMetaDataToMetaData(daoMetaData: DaoPhotoMetaData): PhotoMetaData = { + PhotoMetaData( + dimension = daoMetaData.dimension.map(daoDim => Dimension2D(width = daoDim.width, height = daoDim.height)), + shootDateTime = daoMetaData.shootDateTime, + orientation = PhotoOrientation.values.find(orientation => daoMetaData.orientation.isDefined && orientation.code == daoMetaData.orientation.get), // TODO can be enhanced + cameraName = daoMetaData.cameraName, + tags = daoMetaData.tags ) } @@ -241,7 +237,7 @@ class PhotoStoreServiceLive private ( override def photoMetaDataGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[PhotoMetaData]] = metaDataCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoMetaDataToMetaData) + .mapBoth(convertFailures, _.map(daoMetaDataToMetaData)) override def photoMetaDataContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = metaDataCollection @@ -259,14 +255,12 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoPlaceToPlace(from: Option[DaoPhotoPlace]): Option[PhotoPlace] = { - from.map(daoPlace => - PhotoPlace( - latitude = LatitudeDecimalDegrees(daoPlace.latitude), - longitude = LongitudeDecimalDegrees(daoPlace.longitude), - altitude = daoPlace.altitude, - deducted = daoPlace.deducted - ) + def daoPlaceToPlace(daoPlace: DaoPhotoPlace): PhotoPlace = { + PhotoPlace( + latitude = LatitudeDecimalDegrees(daoPlace.latitude), + longitude = LongitudeDecimalDegrees(daoPlace.longitude), + altitude = daoPlace.altitude, + deducted = daoPlace.deducted ) } @@ -281,7 +275,7 @@ class PhotoStoreServiceLive private ( override def photoPlaceGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[PhotoPlace]] = placesCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoPlaceToPlace) + .mapBoth(convertFailures, _.map(daoPlaceToPlace)) override def photoPlaceContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = placesCollection @@ -299,11 +293,9 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoMiniaturesToMiniatures(from: Option[DaoMiniatures]): Option[Miniatures] = { - from.map(daoMiniatures => - Miniatures( - sources = daoMiniatures.sources.map(s => MiniatureSource(size = s.size, dimension = Dimension2D(width = s.dimension.width, height = s.dimension.height))) - ) + def daoMiniaturesToMiniatures(daoMiniatures: DaoMiniatures): Miniatures = { + Miniatures( + sources = daoMiniatures.sources.map(s => MiniatureSource(size = s.size, dimension = Dimension2D(width = s.dimension.width, height = s.dimension.height))) ) } @@ -315,7 +307,7 @@ class PhotoStoreServiceLive private ( override def photoMiniaturesGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[Miniatures]] = miniaturesCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoMiniaturesToMiniatures) + .mapBoth(convertFailures, _.map(daoMiniaturesToMiniatures)) override def photoMiniaturesContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = miniaturesCollection @@ -333,14 +325,12 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== // =================================================================================================================== - def daoNormalizedToNormalized(from: Option[DaoNormalizedPhoto]): Option[NormalizedPhoto] = { - from.map(daoNormalized => - NormalizedPhoto( - size = daoNormalized.size, - dimension = Dimension2D( - width = daoNormalized.dimension.width, - height = daoNormalized.dimension.height - ) + def daoNormalizedToNormalized(daoNormalized: DaoNormalizedPhoto): NormalizedPhoto = { + NormalizedPhoto( + size = daoNormalized.size, + dimension = Dimension2D( + width = daoNormalized.dimension.width, + height = daoNormalized.dimension.height ) ) } @@ -357,7 +347,7 @@ class PhotoStoreServiceLive private ( override def photoNormalizedGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[NormalizedPhoto]] = normalizedCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoNormalizedToNormalized) + .mapBoth(convertFailures, _.map(daoNormalizedToNormalized)) override def photoNormalizedContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = normalizedCollection @@ -375,11 +365,9 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoClassificationsToClassifications(from: Option[DaoPhotoClassifications]): Option[PhotoClassifications] = { - from.map(daoClassifications => - PhotoClassifications( - classifications = daoClassifications.classifications.map(that => DetectedClassification(that.name)) - ) + def daoClassificationsToClassifications(daoClassifications: DaoPhotoClassifications): PhotoClassifications = { + PhotoClassifications( + classifications = daoClassifications.classifications.map(that => DetectedClassification(that.name)) ) } @@ -392,7 +380,7 @@ class PhotoStoreServiceLive private ( override def photoClassificationsGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[PhotoClassifications]] = classificationsCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoClassificationsToClassifications) + .mapBoth(convertFailures, _.map(daoClassificationsToClassifications)) override def photoClassificationsContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = classificationsCollection @@ -410,18 +398,16 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoObjectsToObjects(from: Option[DaoPhotoObjects]): Option[PhotoObjects] = { - from.map(daoObjects => - PhotoObjects( - objects = daoObjects.objects.map(that => - DetectedObject( - name = that.name, - box = BoundingBox( - x = that.box.x, - y = that.box.y, - width = that.box.width, - height = that.box.height - ) + def daoObjectsToObjects(daoObjects: DaoPhotoObjects): PhotoObjects = { + PhotoObjects( + objects = daoObjects.objects.map(that => + DetectedObject( + name = that.name, + box = BoundingBox( + x = that.box.x, + y = that.box.y, + width = that.box.width, + height = that.box.height ) ) ) @@ -447,7 +433,7 @@ class PhotoStoreServiceLive private ( override def photoObjectsGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[PhotoObjects]] = objectsCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoObjectsToObjects) + .mapBoth(convertFailures, _.map(daoObjectsToObjects)) override def photoObjectsContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = objectsCollection @@ -465,21 +451,19 @@ class PhotoStoreServiceLive private ( .mapBoth(convertFailures, _ => ()) // =================================================================================================================== - def daoFacesToFaces(from: Option[DaoPhotoFaces]): Option[PhotoFaces] = { - from.map(daoFaces => - PhotoFaces( - count = daoFaces.count, - faces = daoFaces.faces.map(that => - DetectedFace( - someoneId = that.someoneId.map(id => SomeoneId(ULID.fromString(id))), - box = BoundingBox( - x = that.box.x, - y = that.box.y, - width = that.box.width, - height = that.box.height - ), - faceId = FaceId(ULID.fromString(that.faceId)) - ) + def daoFacesToFaces(daoFaces: DaoPhotoFaces): PhotoFaces = { + PhotoFaces( + count = daoFaces.count, + faces = daoFaces.faces.map(that => + DetectedFace( + someoneId = that.someoneId.map(id => SomeoneId(ULID.fromString(id))), + box = BoundingBox( + x = that.box.x, + y = that.box.y, + width = that.box.width, + height = that.box.height + ), + faceId = FaceId(ULID.fromString(that.faceId)) ) ) ) @@ -506,7 +490,7 @@ class PhotoStoreServiceLive private ( override def photoFacesGet(photoId: PhotoId): IO[PhotoStoreIssue, Option[PhotoFaces]] = facesCollection .fetch(photoIdToCollectionKey(photoId)) - .mapBoth(convertFailures, daoFacesToFaces) + .mapBoth(convertFailures, _.map(daoFacesToFaces)) override def photoFacesContains(photoId: PhotoId): IO[PhotoStoreIssue, Boolean] = facesCollection @@ -525,23 +509,21 @@ class PhotoStoreServiceLive private ( // =================================================================================================================== - def daoFaceFeaturesToFaces(from: Option[DaoFaceFeatures]): Option[FaceFeatures] = { - from.map(daoFaceFeatures => - FaceFeatures( - photoId = PhotoId(ULID.fromString(daoFaceFeatures.photoId)), - someoneId = daoFaceFeatures.someoneId.map(id => SomeoneId(ULID.fromString(id))), - box = BoundingBox( - x = daoFaceFeatures.box.x, - y = daoFaceFeatures.box.y, - width = daoFaceFeatures.box.width, - height = daoFaceFeatures.box.height - ), - features = daoFaceFeatures.features - ) + def daoFaceFeaturesToFaceFeatures(daoFaceFeatures: DaoFaceFeatures): FaceFeatures = { + FaceFeatures( + photoId = PhotoId(ULID.fromString(daoFaceFeatures.photoId)), + someoneId = daoFaceFeatures.someoneId.map(id => SomeoneId(ULID.fromString(id))), + box = BoundingBox( + x = daoFaceFeatures.box.x, + y = daoFaceFeatures.box.y, + width = daoFaceFeatures.box.width, + height = daoFaceFeatures.box.height + ), + features = daoFaceFeatures.features ) } - def faceFeaturesToDaoFaces(from: FaceFeatures): DaoFaceFeatures = { + def faceFeaturesToDaoFaceFeatures(from: FaceFeatures): DaoFaceFeatures = { DaoFaceFeatures( photoId = from.photoId.id.toString(), someoneId = from.someoneId.map(_.toString), @@ -555,10 +537,15 @@ class PhotoStoreServiceLive private ( ) } + override def photoFaceFeaturesStream(): ZStream[Any, PhotoStoreIssue, (FaceId, FaceFeatures)] = + faceFeaturesCollection + .streamWithKeys() + .mapBoth(convertFailures, (key, value) => (FaceId(ULID.fromString(key)), daoFaceFeaturesToFaceFeatures(value))) + def photoFaceFeaturesGet(faceId: FaceId): IO[PhotoStoreIssue, Option[FaceFeatures]] = faceFeaturesCollection .fetch(faceIdToCollectionKey(faceId)) - .mapBoth(convertFailures, daoFaceFeaturesToFaces) + .mapBoth(convertFailures, _.map(daoFaceFeaturesToFaceFeatures)) def photoFaceFeaturesContains(faceId: FaceId): IO[PhotoStoreIssue, Boolean] = faceFeaturesCollection @@ -567,7 +554,7 @@ class PhotoStoreServiceLive private ( def photoFaceFeaturesUpsert(faceId: FaceId, faceFeatures: FaceFeatures): IO[PhotoStoreIssue, Unit] = faceFeaturesCollection - .upsertOverwrite(faceIdToCollectionKey(faceId), faceFeaturesToDaoFaces(faceFeatures)) + .upsertOverwrite(faceIdToCollectionKey(faceId), faceFeaturesToDaoFaceFeatures(faceFeatures)) .mapBoth(convertFailures, _ => ()) def photoFaceFeaturesDelete(faceId: FaceId): IO[PhotoStoreIssue, Unit] = diff --git a/modules/core/src/test/scala/fr/janalyse/sotohp/core/PhotoStoreServiceFake.scala b/modules/core/src/test/scala/fr/janalyse/sotohp/core/PhotoStoreServiceFake.scala index b0a2587..9882d4e 100644 --- a/modules/core/src/test/scala/fr/janalyse/sotohp/core/PhotoStoreServiceFake.scala +++ b/modules/core/src/test/scala/fr/janalyse/sotohp/core/PhotoStoreServiceFake.scala @@ -201,6 +201,13 @@ class PhotoStoreServiceFake( facesCollectionRef.update(collection => collection.removed(photoId)) // =================================================================================================================== + override def photoFaceFeaturesStream(): Stream[PhotoStoreIssue, (FaceId, FaceFeatures)] = { + val wrappedStream = for { + collection <- faceFeaturesCollectionRef.get + } yield ZStream.from(collection.toList) + ZStream.unwrap(wrappedStream) + } + override def photoFaceFeaturesGet(faceId: FaceId): IO[PhotoStoreIssue, Option[FaceFeatures]] = for { collection <- faceFeaturesCollectionRef.get } yield collection.get(faceId) diff --git a/user-interfaces/cli/src/main/scala/fr/janalyse/sotohp/cli/FaceFeaturesClustering.scala b/user-interfaces/cli/src/main/scala/fr/janalyse/sotohp/cli/FaceFeaturesClustering.scala new file mode 100644 index 0000000..2724790 --- /dev/null +++ b/user-interfaces/cli/src/main/scala/fr/janalyse/sotohp/cli/FaceFeaturesClustering.scala @@ -0,0 +1,40 @@ +package fr.janalyse.sotohp.cli + +import fr.janalyse.sotohp.core.* +import fr.janalyse.sotohp.model.* +import fr.janalyse.sotohp.processor.{FaceFeaturesProcessor, FacesProcessor, NormalizeProcessor} +import fr.janalyse.sotohp.store.{LazyPhoto, PhotoStoreIssue, PhotoStoreService} +import zio.* +import zio.config.typesafe.* +import zio.lmdb.LMDB + +import java.io.IOException +import java.time.{Instant, OffsetDateTime} +import scala.io.AnsiColor.* + +object FaceFeaturesClustering extends ZIOAppDefault with CommonsCLI { + + override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = + Runtime.setConfigProvider(TypesafeConfigProvider.fromTypesafeConfig(com.typesafe.config.ConfigFactory.load())) + + override def run = + logic + .provide( + LMDB.liveWithDatabaseName("photos"), + PhotoStoreService.live, + Scope.default + ) + // TODO this first quick & dirty implementation is memory based ! + val logic = ZIO.logSpan("FaceFeaturesClustering") { + for { + _ <- ZIO.logInfo("Face features clustering") + faceFeatures <- PhotoStoreService + .photoFaceFeaturesStream() + .runCollect() + data = faceFeatures.map((faceId, faceFeatures) => faceFeatures.features.map(_.toDouble)).toArray + clusters = smile.clustering.DBSCAN.fit(data, 20, 0.1) + _ <- Console.printLine(clusters.toString) + _ <- ZIO.logInfo(s"Face features clustering done - processed ${faceFeatures.size} faces") + } yield () + } +}