feat(recognition): make face detection quality thresholds user-tunable
Faces coming back too small or too low quality had no lever to pull: YuNet's own confidence floor was hardcoded, and no minimum face size or sharpness check existed anywhere in the pipeline. Three new recognition preferences (min-face-size-fraction, min-detection-confidence, min-sharpness), visible/editable in Settings, filtered once in MediaRecognitionService.detect() so they apply regardless of which engine produced the region. Every default preserves current behaviour exactly (confidence default matches YuNet's existing 0.6 floor, the other two default to disabled) -- strictly opt-in. Sharpness is a variance-of-Laplacian score on the face crop, resized to a fixed canonical size first so it stays comparable across differently sized faces -- no new dependency or model, reuses the image already decoded for detection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JzvA5ySQUsYrMUTj7sHxFA
This commit is contained in:
@@ -3,12 +3,15 @@ package org.icroco.pholio.infra.recognition;
|
||||
import org.icroco.pholio.domain.library.EMediaFileProcessingFlag;
|
||||
import org.icroco.pholio.domain.library.MediaFile;
|
||||
import org.icroco.pholio.domain.media.ImageFormat;
|
||||
import org.icroco.pholio.domain.recognition.BoundingBox;
|
||||
import org.icroco.pholio.domain.recognition.DetectedRegion;
|
||||
import org.icroco.pholio.domain.recognition.MediaFaceRegion;
|
||||
import org.icroco.pholio.domain.recognition.RecognitionResult;
|
||||
import org.icroco.pholio.infra.library.MediaMetadataEditService;
|
||||
import org.icroco.pholio.infra.media.MediaFormatRegistry;
|
||||
import org.icroco.pholio.infra.media.ThumbnailGenerator;
|
||||
import org.icroco.pholio.infra.persistence.folder.MediaFileRepository;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -16,6 +19,8 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -39,6 +44,10 @@ public class MediaRecognitionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MediaRecognitionService.class);
|
||||
|
||||
/** Face crop is resized to this square before scoring, so the sharpness metric stays comparable
|
||||
* regardless of how large the detected face actually was in the source photo. */
|
||||
private static final int SHARPNESS_SAMPLE_SIZE = 128;
|
||||
|
||||
private final MediaFormatRegistry formats;
|
||||
private final ThumbnailGenerator thumbnailGenerator;
|
||||
private final IRecognitionService recognitionService;
|
||||
@@ -48,12 +57,13 @@ public class MediaRecognitionService {
|
||||
private final MediaFileAssembler mediaFileAssembler;
|
||||
private final MediaMetadataEditService metadataEditService;
|
||||
private final ApplicationEventPublisher publisher;
|
||||
private final AppPreferences preferences;
|
||||
|
||||
public MediaRecognitionService(MediaFormatRegistry formats, ThumbnailGenerator thumbnailGenerator,
|
||||
IRecognitionService recognitionService, FaceRegionQueryService faceRegionQueryService,
|
||||
FaceClusteringService faceClusteringService, MediaFileRepository mediaFileRepository,
|
||||
MediaFileAssembler mediaFileAssembler, MediaMetadataEditService metadataEditService,
|
||||
ApplicationEventPublisher publisher) {
|
||||
ApplicationEventPublisher publisher, AppPreferences preferences) {
|
||||
this.formats = formats;
|
||||
this.thumbnailGenerator = thumbnailGenerator;
|
||||
this.recognitionService = recognitionService;
|
||||
@@ -63,6 +73,7 @@ public class MediaRecognitionService {
|
||||
this.mediaFileAssembler = mediaFileAssembler;
|
||||
this.metadataEditService = metadataEditService;
|
||||
this.publisher = publisher;
|
||||
this.preferences = preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +95,8 @@ public class MediaRecognitionService {
|
||||
BufferedImage oriented = thumbnailGenerator.applyOrientation(decoded.get(), thumbnailGenerator.orientationOf(absolute));
|
||||
|
||||
RecognitionResult result = recognitionService.analyze(oriented);
|
||||
faceRegionQueryService.replaceRegionsFor(mediaFileId, result.regions());
|
||||
List<DetectedRegion> kept = filterLowQuality(result.regions(), oriented);
|
||||
faceRegionQueryService.replaceRegionsFor(mediaFileId, kept);
|
||||
faceClusteringService.reconcilePersonRegions();
|
||||
writeAutoConfirmedRegionsIfAny(mediaFileId);
|
||||
|
||||
@@ -116,4 +128,104 @@ public class MediaRecognitionService {
|
||||
}
|
||||
mediaFileAssembler.assemble(mediaFileId).ifPresent(file -> metadataEditService.updateFaceRegions(file, regions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Three opt-in quality gates, applied once here rather than inside a specific engine — so they hold
|
||||
* whether a region came from the local ONNX engine or a remote provider (see {@link RecognitionService}).
|
||||
* Every default preserves today's behaviour exactly: {@code min-detection-confidence}'s default (0.6)
|
||||
* matches {@code YuNetSFaceFaceDetectionEngine}'s own hardcoded floor (nothing below it is ever produced
|
||||
* in the first place), and the other two default to 0 (disabled).
|
||||
*/
|
||||
private List<DetectedRegion> filterLowQuality(List<DetectedRegion> regions, BufferedImage image) {
|
||||
double minSize = preferences.getValueOr("recognition", "min-face-size-fraction", Double.class, 0.0);
|
||||
double minConfidence = preferences.getValueOr("recognition", "min-detection-confidence", Double.class, 0.6);
|
||||
double minSharpness = preferences.getValueOr("recognition", "min-sharpness", Double.class, 0.0);
|
||||
|
||||
List<DetectedRegion> kept = regions.stream()
|
||||
.filter(region -> region.confidence() >= minConfidence)
|
||||
.filter(region -> Math.min(region.box().w(), region.box().h()) >= minSize)
|
||||
.filter(region -> minSharpness <= 0.0 || sharpnessOf(image, region.box()) >= minSharpness)
|
||||
.toList();
|
||||
if (kept.size() != regions.size()) {
|
||||
log.debug("Dropped {} low-quality region(s) out of {}", regions.size() - kept.size(), regions.size());
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variance-of-Laplacian of {@code box}'s own crop, resized to {@link #SHARPNESS_SAMPLE_SIZE} first — a
|
||||
* standard, cheap blur proxy: a sharp crop has strong high-frequency edges (high variance), a blurry one
|
||||
* is smoothed toward a flat response. A degenerate crop (the box falls entirely outside {@code image}
|
||||
* once clamped — pathological, but geometry is not a reason to reject a region) returns a value that
|
||||
* never fails the check rather than silently discarding it.
|
||||
*/
|
||||
private static double sharpnessOf(BufferedImage image, BoundingBox box) {
|
||||
int imageWidth = image.getWidth();
|
||||
int imageHeight = image.getHeight();
|
||||
int x = (int) Math.round((box.x() - box.w() / 2) * imageWidth);
|
||||
int y = (int) Math.round((box.y() - box.h() / 2) * imageHeight);
|
||||
int w = (int) Math.round(box.w() * imageWidth);
|
||||
int h = (int) Math.round(box.h() * imageHeight);
|
||||
|
||||
int clampedX = Math.max(0, x);
|
||||
int clampedY = Math.max(0, y);
|
||||
int clampedW = Math.min(w - (clampedX - x), imageWidth - clampedX);
|
||||
int clampedH = Math.min(h - (clampedY - y), imageHeight - clampedY);
|
||||
if (clampedW <= 0 || clampedH <= 0) {
|
||||
return Double.MAX_VALUE;
|
||||
}
|
||||
|
||||
BufferedImage crop = image.getSubimage(clampedX, clampedY, clampedW, clampedH);
|
||||
BufferedImage resized = resize(crop, SHARPNESS_SAMPLE_SIZE, SHARPNESS_SAMPLE_SIZE);
|
||||
return laplacianVariance(resized);
|
||||
}
|
||||
|
||||
private static BufferedImage resize(BufferedImage source, int width, int height) {
|
||||
BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = resized.createGraphics();
|
||||
try {
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.drawImage(source, 0, 0, width, height, null);
|
||||
}
|
||||
finally {
|
||||
g.dispose();
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
/** {@code Var(4*center - N - S - E - W)} over grayscale intensities — the classic 3x3 Laplacian kernel
|
||||
* {@code [[0,1,0],[1,-4,1],[0,1,0]]}, computed in one pass via {@code E[X^2] - E[X]^2}. */
|
||||
private static double laplacianVariance(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
double[] gray = new double[width * height];
|
||||
for (int yy = 0; yy < height; yy++) {
|
||||
for (int xx = 0; xx < width; xx++) {
|
||||
int rgb = image.getRGB(xx, yy);
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
gray[yy * width + xx] = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
double sum = 0;
|
||||
double sumSquares = 0;
|
||||
for (int yy = 1; yy < height - 1; yy++) {
|
||||
for (int xx = 1; xx < width - 1; xx++) {
|
||||
double value = 4 * gray[yy * width + xx]
|
||||
- gray[(yy - 1) * width + xx] - gray[(yy + 1) * width + xx]
|
||||
- gray[yy * width + xx - 1] - gray[yy * width + xx + 1];
|
||||
sum += value;
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
double mean = sum / count;
|
||||
return sumSquares / count - mean * mean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ public class SettingsView extends BorderPane implements Disposable {
|
||||
"ai",
|
||||
"sync",
|
||||
"library",
|
||||
"recognition",
|
||||
"onboarding"
|
||||
);
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ settings.group.photo-detail=Photo viewer
|
||||
settings.group.ai=Image analysis
|
||||
settings.group.imports=Import
|
||||
settings.group.onboarding=Onboarding
|
||||
settings.group.recognition=Recognition
|
||||
settings.library.lastOpened=Open library
|
||||
settings.library.available=Available libraries
|
||||
settings.appearance.colorMode=Appearance
|
||||
@@ -173,6 +174,9 @@ settings.recognition.providersJson=Recognition providers
|
||||
settings.recognition.activeProvider=Active recognition provider
|
||||
settings.recognition.clusterThreshold=Person clustering similarity threshold
|
||||
settings.recognition.autoConfirmThreshold=Person auto-confirm similarity threshold
|
||||
settings.recognition.minFaceSizeFraction=Minimum face size (fraction of image, 0 disables)
|
||||
settings.recognition.minDetectionConfidence=Minimum detection confidence
|
||||
settings.recognition.minSharpness=Minimum sharpness (0 disables)
|
||||
settings.ai.provider=Provider
|
||||
settings.ai.endpoint=Endpoint
|
||||
settings.imports.largeFolderThreshold=Confirm above (files)
|
||||
|
||||
@@ -141,6 +141,7 @@ settings.group.photo-detail=Visionneuse
|
||||
settings.group.ai=Analyse d'image
|
||||
settings.group.imports=Importation
|
||||
settings.group.onboarding=Présentation initiale
|
||||
settings.group.recognition=Reconnaissance
|
||||
settings.library.lastOpened=Photothèque ouverte
|
||||
settings.library.available=Photothèques disponibles
|
||||
settings.appearance.colorMode=Apparence
|
||||
@@ -176,6 +177,9 @@ settings.recognition.providersJson=Fournisseurs de reconnaissance
|
||||
settings.recognition.activeProvider=Fournisseur de reconnaissance actif
|
||||
settings.recognition.clusterThreshold=Seuil de similarité pour le regroupement de personnes
|
||||
settings.recognition.autoConfirmThreshold=Seuil de similarité pour la confirmation automatique
|
||||
settings.recognition.minFaceSizeFraction=Taille minimale du visage (fraction de l'image, 0 désactive)
|
||||
settings.recognition.minDetectionConfidence=Confiance minimale de détection
|
||||
settings.recognition.minSharpness=Netteté minimale (0 désactive)
|
||||
settings.ai.provider=Fournisseur
|
||||
settings.ai.endpoint=Point d'accès
|
||||
settings.imports.largeFolderThreshold=Confirmer au-delà de (fichiers)
|
||||
|
||||
@@ -288,6 +288,40 @@ recognition:
|
||||
visible: false
|
||||
editable: false
|
||||
|
||||
# Post-detection quality filters — see MediaRecognitionService.filterLowQuality. Unlike the rest of this
|
||||
# group, these three are visible/editable: they are a direct answer to faces coming back too small or too
|
||||
# low quality, not an implementation detail a user shouldn't touch. Every default preserves today's
|
||||
# behaviour exactly (strictly opt-in) until raised.
|
||||
|
||||
# Smaller box dimension (min(w,h), already a 0..1 fraction of the image — BoundingBox's own unit) below
|
||||
# which a detected region is discarded. 0 = disabled.
|
||||
min-face-size-fraction:
|
||||
type: DOUBLE
|
||||
label: settings.recognition.minFaceSizeFraction
|
||||
default-value: 0.0
|
||||
min: 0.0
|
||||
max: 0.5
|
||||
|
||||
# Below YuNet's own hardcoded 0.6 score floor (YuNetSFaceFaceDetectionEngine.YUNET_SCORE_THRESHOLD), no
|
||||
# region is ever produced in the first place, hence the min here — a lower spinner value would be
|
||||
# misleading, it could never have any effect.
|
||||
min-detection-confidence:
|
||||
type: DOUBLE
|
||||
label: settings.recognition.minDetectionConfidence
|
||||
default-value: 0.6
|
||||
min: 0.6
|
||||
max: 0.99
|
||||
|
||||
# Variance-of-Laplacian sharpness score of the face crop (resized to a fixed canonical size first, so the
|
||||
# metric stays comparable regardless of the source face's own size) below which a region is discarded. 0 =
|
||||
# disabled. There is no universal "sharp" number — this is a starting point to tune against real photos.
|
||||
min-sharpness:
|
||||
type: DOUBLE
|
||||
label: settings.recognition.minSharpness
|
||||
default-value: 0.0
|
||||
min: 0.0
|
||||
max: 500.0
|
||||
|
||||
# Cosine-similarity threshold above which FaceClusteringService links an unnamed face to an existing
|
||||
# Person cluster rather than minting a new one. 0.363 is SFace's own calibrated same-identity threshold
|
||||
# (OpenCV Zoo's face_recognition_sface README) — the embedding is SFace's, so its own threshold applies.
|
||||
|
||||
@@ -453,7 +453,7 @@ class PreferenceServiceTest {
|
||||
|
||||
SoftAssertions.assertSoftly(softly -> {
|
||||
softly.assertThat(preferences.visibleGroupNames())
|
||||
.containsExactly("library", "ui", "sync", "thumbnails", "gallery", "photo-detail", "ai", "imports", "onboarding");
|
||||
.containsExactly("library", "ui", "sync", "thumbnails", "gallery", "photo-detail", "ai", "imports", "recognition", "onboarding");
|
||||
softly.assertThat(preferences.groupNames()).contains("window.main");
|
||||
softly.assertThat(preferences.getValue("ui", "color-mode", String.class)).isEqualTo("SYSTEM");
|
||||
softly.assertThat(preferences.getValue("ui", "theme-family", String.class)).isEqualTo("PRIMER");
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package org.icroco.pholio.infra.recognition;
|
||||
|
||||
import org.icroco.pholio.domain.media.ImageFormat;
|
||||
import org.icroco.pholio.domain.media.Orientation;
|
||||
import org.icroco.pholio.domain.recognition.BoundingBox;
|
||||
import org.icroco.pholio.domain.recognition.DetectedRegion;
|
||||
import org.icroco.pholio.domain.recognition.EEntityKind;
|
||||
import org.icroco.pholio.domain.recognition.RecognitionResult;
|
||||
import org.icroco.pholio.infra.library.MediaMetadataEditService;
|
||||
import org.icroco.pholio.infra.media.MediaFormatRegistry;
|
||||
import org.icroco.pholio.infra.media.ThumbnailGenerator;
|
||||
import org.icroco.pholio.infra.persistence.folder.MediaFileRepository;
|
||||
import org.icroco.pholio.infra.preferences.AppPreferences;
|
||||
import org.icroco.pholio.infra.preferences.PreferencesFixture;
|
||||
import org.icroco.pholio.infra.task.TaskService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MediaRecognitionServiceTest {
|
||||
|
||||
private static final long MEDIA_FILE_ID = 1L;
|
||||
private static final Path ABSOLUTE = Path.of("photo.jpg");
|
||||
|
||||
private MediaFormatRegistry formats;
|
||||
private ThumbnailGenerator thumbnailGenerator;
|
||||
private IRecognitionService recognitionService;
|
||||
private FaceRegionQueryService faceRegionQueryService;
|
||||
private MediaFileRepository mediaFileRepository;
|
||||
private AppPreferences preferences;
|
||||
private BufferedImage decoded;
|
||||
private MediaRecognitionService service;
|
||||
private TaskService.BatchTask batchTask;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
formats = mock(MediaFormatRegistry.class);
|
||||
thumbnailGenerator = mock(ThumbnailGenerator.class);
|
||||
recognitionService = mock(IRecognitionService.class);
|
||||
faceRegionQueryService = mock(FaceRegionQueryService.class);
|
||||
FaceClusteringService faceClusteringService = mock(FaceClusteringService.class);
|
||||
mediaFileRepository = mock(MediaFileRepository.class);
|
||||
MediaFileAssembler mediaFileAssembler = mock(MediaFileAssembler.class);
|
||||
MediaMetadataEditService metadataEditService = mock(MediaMetadataEditService.class);
|
||||
ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
|
||||
preferences = PreferencesFixture.fromBundledSchema();
|
||||
batchTask = mock(TaskService.BatchTask.class);
|
||||
|
||||
service = new MediaRecognitionService(formats, thumbnailGenerator, recognitionService, faceRegionQueryService,
|
||||
faceClusteringService, mediaFileRepository, mediaFileAssembler,
|
||||
metadataEditService, publisher, preferences);
|
||||
|
||||
decoded = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
|
||||
when(formats.formatOf(ABSOLUTE)).thenReturn(Optional.of(ImageFormat.JPEG));
|
||||
when(thumbnailGenerator.decode(ABSOLUTE, ImageFormat.JPEG)).thenReturn(Optional.of(decoded));
|
||||
when(thumbnailGenerator.orientationOf(ABSOLUTE)).thenReturn(Orientation.NORMAL);
|
||||
when(mediaFileRepository.findById(MEDIA_FILE_ID)).thenReturn(Optional.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultThresholdsKeepEveryRegionUntouched() {
|
||||
BufferedImage image = flatImage(200, 200, 128);
|
||||
DetectedRegion region = person(0.6, new BoundingBox(0.5, 0.5, 0.3, 0.3));
|
||||
stubOriented(image);
|
||||
when(recognitionService.analyze(image)).thenReturn(new RecognitionResult(List.of(region)));
|
||||
|
||||
service.detect(MEDIA_FILE_ID, ABSOLUTE, batchTask);
|
||||
|
||||
assertThat(persistedRegions()).containsExactly(region);
|
||||
}
|
||||
|
||||
@Test
|
||||
void regionBelowMinDetectionConfidenceIsDropped() {
|
||||
preferences.setValue("recognition", "min-detection-confidence", 0.8);
|
||||
BufferedImage image = flatImage(200, 200, 128);
|
||||
DetectedRegion tooUncertain = person(0.7, new BoundingBox(0.5, 0.5, 0.3, 0.3));
|
||||
DetectedRegion confident = person(0.9, new BoundingBox(0.5, 0.5, 0.3, 0.3));
|
||||
stubOriented(image);
|
||||
when(recognitionService.analyze(image)).thenReturn(new RecognitionResult(List.of(tooUncertain, confident)));
|
||||
|
||||
service.detect(MEDIA_FILE_ID, ABSOLUTE, batchTask);
|
||||
|
||||
assertThat(persistedRegions()).containsExactly(confident);
|
||||
}
|
||||
|
||||
@Test
|
||||
void regionBelowMinFaceSizeFractionIsDropped() {
|
||||
preferences.setValue("recognition", "min-face-size-fraction", 0.1);
|
||||
BufferedImage image = flatImage(200, 200, 128);
|
||||
DetectedRegion tooSmall = person(0.9, new BoundingBox(0.5, 0.5, 0.05, 0.05));
|
||||
DetectedRegion bigEnough = person(0.9, new BoundingBox(0.5, 0.5, 0.3, 0.3));
|
||||
stubOriented(image);
|
||||
when(recognitionService.analyze(image)).thenReturn(new RecognitionResult(List.of(tooSmall, bigEnough)));
|
||||
|
||||
service.detect(MEDIA_FILE_ID, ABSOLUTE, batchTask);
|
||||
|
||||
assertThat(persistedRegions()).containsExactly(bigEnough);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blurryRegionBelowMinSharpnessIsDropped() {
|
||||
preferences.setValue("recognition", "min-sharpness", 50.0);
|
||||
BufferedImage image = flatImage(200, 200, 128);
|
||||
DetectedRegion region = person(0.9, new BoundingBox(0.5, 0.5, 0.8, 0.8));
|
||||
stubOriented(image);
|
||||
when(recognitionService.analyze(image)).thenReturn(new RecognitionResult(List.of(region)));
|
||||
|
||||
service.detect(MEDIA_FILE_ID, ABSOLUTE, batchTask);
|
||||
|
||||
assertThat(persistedRegions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharpRegionAboveMinSharpnessIsKept() {
|
||||
preferences.setValue("recognition", "min-sharpness", 50.0);
|
||||
BufferedImage image = noisyImage(200, 200);
|
||||
DetectedRegion region = person(0.9, new BoundingBox(0.5, 0.5, 0.8, 0.8));
|
||||
stubOriented(image);
|
||||
when(recognitionService.analyze(image)).thenReturn(new RecognitionResult(List.of(region)));
|
||||
|
||||
service.detect(MEDIA_FILE_ID, ABSOLUTE, batchTask);
|
||||
|
||||
assertThat(persistedRegions()).containsExactly(region);
|
||||
}
|
||||
|
||||
private void stubOriented(BufferedImage oriented) {
|
||||
when(thumbnailGenerator.applyOrientation(eq(decoded), any())).thenReturn(oriented);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<DetectedRegion> persistedRegions() {
|
||||
ArgumentCaptor<List<DetectedRegion>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(faceRegionQueryService).replaceRegionsFor(eq(MEDIA_FILE_ID), captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private static DetectedRegion person(double confidence, BoundingBox box) {
|
||||
return new DetectedRegion(EEntityKind.PERSON, box, confidence, new float[]{ 0.1f }, null, "local-onnx");
|
||||
}
|
||||
|
||||
private static BufferedImage flatImage(int width, int height, int gray) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
int rgb = (gray << 16) | (gray << 8) | gray;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
image.setRGB(x, y, rgb);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private static BufferedImage noisyImage(int width, int height) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Random random = new Random(42);
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
image.setRGB(x, y, random.nextInt(0xFFFFFF));
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user