Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/hair-analysis.prod.env
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
HAIR_ANALYSIS_ENABLED=true
HAIR_ANALYSIS_MODEL_HOST_DIR=/home/ubuntu/heddy-models
HAIR_ANALYSIS_MODEL_PATH=/opt/heddy/models/face-parsing.onnx
HAIR_ANALYSIS_MODEL_VERSION=segformer-b5-int8-758b82e-320-v1
HAIR_ANALYSIS_MODEL_VERSION=segformer-b5-int8-758b82e-320-v1-metrics-v2
HAIR_ANALYSIS_INPUT_SIZE=320
HAIR_ANALYSIS_WORKER_THREADS=1
JAVA_TOOL_OPTIONS=-Xmx256m -Xms64m
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ HairAnalysisOutcome calculate(FaceParsingOutput parsing) {
Roughness roughness = roughness(parsing, hair.count);

double meanHairProbability = meanHairProbability(parsing);
double rollDegrees = Math.toDegrees(Math.abs(Math.atan2(axis.eyeDy, axis.eyeDx)));
// An eye axis is an undirected line: anatomical left/right labels may reverse its direction.
double rollDegrees = Math.toDegrees(Math.atan2(Math.abs(axis.eyeDy), Math.abs(axis.eyeDx)));
double frontalness = frontalness(axis, nose, rollDegrees);
double blurVariance = laplacianVariance(parsing);
double lightingUniformity = lightingUniformity(parsing);
Expand All @@ -91,7 +92,7 @@ HairAnalysisOutcome calculate(FaceParsingOutput parsing) {
metrics.put(MetricType.ROUGHNESS, metric(roughness.score));

Map<String, Object> raw = new LinkedHashMap<>();
raw.put("pipeline", "segformer-plus-deterministic-metrics-v1");
raw.put("pipeline", "segformer-plus-deterministic-metrics-v2");
raw.put("hair_area_ratio", round(hairAreaRatio, 4));
raw.put("mean_hair_probability", round(meanHairProbability, 4));
raw.put("face_roll_degrees", round(rollDegrees, 2));
Expand Down Expand Up @@ -540,6 +541,8 @@ static Axis from(Region firstEye, Region secondEye) {
if (Math.hypot(dx, dy) < 2) {
throw new IllegalArgumentException("눈 중심점을 구분할 수 없습니다");
}
// Keep the same half-plane assignment when anatomical labels are reversed.
if (dx < 0 || (dx == 0 && dy < 0)) { dx = -dx; dy = -dy; }
return new Axis((firstX + secondX) / 2, (firstY + secondY) / 2, dx, dy);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ public AnalysisJob request(RequestAnalysisUseCase.Command command) {
TreatmentRecord record = ownedRecord(command.requesterId(), command.recordId());
TreatmentPhoto photo = selectPhoto(record, command.photoId());
requireReadyInput(command.requesterId(), photo);
if (jobRepositoryPort.findInProgressByPhotoId(photo.photoId()).isPresent()) {
throw new ApplicationException(ErrorCode.ANALYSIS_ALREADY_IN_PROGRESS);
}
AnalysisJob inProgress = jobRepositoryPort.findInProgressByPhotoId(photo.photoId())
.filter(job -> command.requesterId().equals(job.userId())
&& record.recordId().equals(job.recordId())).orElse(null);
// A reload loses the client's job ID. Reattach to existing work instead of returning 409.
if (inProgress != null) return inProgress;
try {
AnalysisJob saved = jobRepositoryPort.insert(AnalysisJob.create(
command.requesterId(), record.recordId(), photo.photoId(), Instant.now()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ var record = records.findByIdAndUserId(latest.recordId(), userId).orElse(null);
if (profile != null && profile.hairLength() != null && profile.updatedAt() != null
&& file.createdAt() != null && !profile.updatedAt().isBefore(file.createdAt())) {
return new Resolved(profile, new PhotoHairEvidence(file.fileId(), evidence.modelVersion(),
"NEWER_PROFILE", evidence.observedLength(), false, evidence.hairShape(), 0));
evidence.observedLength() == null ? evidence.status() : "NEWER_PROFILE",
evidence.observedLength(), false, evidence.hairShape(), 0));
}
var observed = evidence.observedLength();
// A newer result photo updates current length in either direction. The newer-profile guard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ private String canonicalSnapshot(
List<HairstyleCandidate> candidates,
TreatmentHistorySummary treatmentHistory
) {
StringBuilder value = new StringBuilder("RULE_BASED_V1:CURRENT_LENGTH_V6|");
StringBuilder value = new StringBuilder("RULE_BASED_V1:PREFERENCE_PRIORITY_V7|");
value.append(explanationPort.cacheKey()).append('|');
HairProfile profile = context.hairProfile();
if (profile != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ public List<ScoredRecommendation> topThree(
List<ScoredRecommendation> orderedCandidates, Map<UUID, Long> exposureCounts
) {
List<ScoredRecommendation> selected = new ArrayList<>(3);
Map<Long, List<ScoredRecommendation>> tiers = new TreeMap<>();
// Explicit preferences take priority over variety and refresh exposure counts.
// Nonmatching styles only fill remaining slots when matching candidates are insufficient.
Map<Tier, List<ScoredRecommendation>> tiers = new TreeMap<>();
for (ScoredRecommendation candidate : orderedCandidates) {
long exposures = exposureCounts.getOrDefault(candidate.candidate().hairstyleId(), 0L);
tiers.computeIfAbsent(exposures, ignored -> new ArrayList<>()).add(candidate);
Tier tier = new Tier(candidate.scoreBreakdown().preferredTagScore() > 0 ? 0 : 1, exposures);
tiers.computeIfAbsent(tier, ignored -> new ArrayList<>()).add(candidate);
}
for (List<ScoredRecommendation> tier : tiers.values()) {
addEligible(tier, selected, true, true);
Expand All @@ -37,6 +40,13 @@ public List<ScoredRecommendation> topThree(
return List.copyOf(selected);
}

private record Tier(int preferenceOrder, long exposures) implements Comparable<Tier> {
public int compareTo(Tier other) {
int preference = Integer.compare(preferenceOrder, other.preferenceOrder);
return preference != 0 ? preference : Long.compare(exposures, other.exposures);
}
}

private void addEligible(
List<ScoredRecommendation> candidates,
List<ScoredRecommendation> selected,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ public Optional<ScoredRecommendation> score(
if (context.recentTreatments().stream().anyMatch(record -> record.satisfaction() != null)) {
availableMaximum += 40;
}
if (!context.savedHairstyleIds().isEmpty() || !context.preferredTagIds().isEmpty()) {
availableMaximum += 30;
}
if (!context.savedHairstyleIds().isEmpty()) availableMaximum += 15;
if (!context.preferredTagIds().isEmpty()) availableMaximum += 15;
availableMaximum += hair.availableMaximum();
if (context.hairProfile() != null
&& context.hairProfile().availableCareTimeMinutes() != null) {
Expand Down Expand Up @@ -197,7 +196,8 @@ private double preferredTagScore(
RecommendationReason.Code.PREFERRED_TAG_MATCH,
Map.of("tag_id", representative.toString(),
"tag_name", candidate.tags().get(representative))));
return 15.0 * matches.size() / candidate.tags().size();
// Extra catalog metadata must not dilute a user's explicit preference.
return 15.0 * matches.size() / context.preferredTagIds().size();
}

private HairResult hairScore(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,29 @@ void forcedRefreshStillCreatesANewRunAndFiltersLongStyles() throws Exception {
assertThat(runCount()).isEqualTo(2);
}

@Test
void preferredStyleStaysFirstOnRefreshAndExclusionImmediatelyRemovesIt() throws Exception {
UUID preferredStyle = UUID.fromString("94000000-0000-4000-8000-000000000001");
generateRun();
mockMvc.perform(post("/recommendations/generate").with(authentication(auth(USER_ID)))
.contentType(MediaType.APPLICATION_JSON).content("{\"force_refresh\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].hairstyle.hairstyle_id").value(preferredStyle.toString()));
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/me/style-preferences")
.with(authentication(auth(USER_ID))).contentType(MediaType.APPLICATION_JSON)
.content("{\"preferred_tag_ids\":[],\"excluded_tag_ids\":[\"" + PREFERRED_TAG_ID + "\"]}"))
.andExpect(status().isOk());
String response = mockMvc.perform(get("/recommendations/latest").with(authentication(auth(USER_ID))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.recommendation_basis.style_preferences.excluded_count").value(1))
.andReturn().getResponse().getContentAsString();
assertThat(styleIdsForRun(runId(response))).doesNotContain(preferredStyle);
}

@Test
void forcedRefreshExploresEligibleStylesAndLatestKeepsTheRefreshedSelection() throws Exception {
// Isolate this ranking signal from the explicit preference priority tested separately.
jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID);
for (int i = 4; i <= 9; i++) {
insertStyle("94000000-0000-4000-8000-%012d".formatted(i),
"추가 스타일 " + i, "MEDIUM", "OVERLAY", 10 - i, false);
Expand Down Expand Up @@ -444,6 +465,8 @@ private List<UUID> styleIdsForRun(String runId) {

@Test
void cutShapeEditReranksLatestAndThenReusesItWithoutANewAccount() throws Exception {
// Isolate this ranking signal from the explicit preference priority tested separately.
jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID);
jdbcTemplate.update("UPDATE treatment_records SET cut_shape = 'LAYERED' WHERE record_id = ?", treatmentRecordId);
String oldId = generateRun();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch(
Expand Down Expand Up @@ -580,6 +603,8 @@ INSERT INTO treatment_records (record_id, user_id, service_types, performed_at,

@Test
void replacingPhotoWithSameLengthChangesRankingAndPersistsItsEvidence() throws Exception {
// Isolate this ranking signal from the explicit preference priority tested separately.
jdbcTemplate.update("DELETE FROM user_style_preferences WHERE user_id = ?", USER_ID);
UUID oldPhoto = attachAfterPhoto();
jdbcTemplate.update("UPDATE files SET created_at = now() - interval '1 hour' WHERE file_id = ?", oldPhoto);
var left = java.util.stream.IntStream.range(0, 96).mapToObj(i -> i < 48 ? 1.0 : 0.0).toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
class HairMetricCalculatorTest {

private final HairMetricCalculator calculator = new HairMetricCalculator(
new ObjectMapper(), "test-segformer-v1", 0);
new ObjectMapper(), "test-segformer-v1", 50);

@Test
void neverProducesScoresWithoutHairFromTheSegmentationModel() {
Expand Down Expand Up @@ -76,6 +76,15 @@ void computesAllFourMetricsOnlyFromAValidModelSegmentation() {
HairAnalysisOutcome outcome = calculator.calculate(
new FaceParsingOutput(width, height, rgb, labels, hairProbability));

byte[] swappedLabels = labels.clone();
for (int i = 0; i < swappedLabels.length; i++) {
if (labels[i] == HairMetricCalculator.LEFT_EYE_LABEL) swappedLabels[i] = HairMetricCalculator.RIGHT_EYE_LABEL;
if (labels[i] == HairMetricCalculator.RIGHT_EYE_LABEL) swappedLabels[i] = HairMetricCalculator.LEFT_EYE_LABEL;
}
HairAnalysisOutcome reversed = calculator.calculate(
new FaceParsingOutput(width, height, rgb, swappedLabels, hairProbability));
assertThat(reversed).as("anatomical eye label order must not change confidence or metrics").isEqualTo(outcome);

assertThat(outcome).isInstanceOfSatisfying(HairAnalysisOutcome.Succeeded.class,
succeeded -> {
assertThat(succeeded.prediction().modelVersion())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.heddy.application.analysis.service;

import com.heddy.domain.analysis.model.AnalysisJob;
import com.heddy.domain.analysis.port.in.RequestAnalysisUseCase;
import com.heddy.domain.analysis.port.out.*;
import com.heddy.domain.file.model.*;
import com.heddy.domain.file.port.out.FileRepositoryPort;
import com.heddy.domain.treatment.model.*;
import com.heddy.domain.treatment.port.out.TreatmentRecordRepositoryPort;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Instant;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

class AnalysisApplicationServiceTest {
@Test void reattachesToOwnedPendingJobWithoutDuplicateInferenceAfterReload() {
UUID user = UUID.randomUUID(), recordId = UUID.randomUUID(), photoId = UUID.randomUUID();
var records = mock(TreatmentRecordRepositoryPort.class);
var files = mock(FileRepositoryPort.class);
var jobs = mock(AnalysisJobRepositoryPort.class);
var engine = mock(HairAnalysisEnginePort.class);
var events = mock(ApplicationEventPublisher.class);
var file = new StoredFile(UUID.randomUUID(), UUID.randomUUID(), user, FilePurpose.TREATMENT_PHOTO,
FileStatus.READY, "object", "image/jpeg", "photo.jpg", 100, null, null, null,
Instant.now(), Instant.now(), null);
var photo = new TreatmentPhoto(photoId, recordId, file.fileId(), ImageType.AFTER, 0, Instant.now());
var record = new TreatmentRecord(recordId, user, Set.of(ServiceType.CUT), null, null,
Instant.now(), null, null, null, null, null, null, List.of(photo), Instant.now());
var existing = AnalysisJob.create(user, recordId, photoId, Instant.now());
when(engine.isReady()).thenReturn(true);
when(records.findByIdAndUserId(recordId, user)).thenReturn(Optional.of(record));
when(files.findById(file.fileId())).thenReturn(Optional.of(file));
when(jobs.findInProgressByPhotoId(photoId)).thenReturn(Optional.of(existing));
var service = new AnalysisApplicationService(records, files, jobs,
mock(AnalysisResultRepositoryPort.class), mock(AnalysisOverlayRepositoryPort.class), engine, events, 3);
assertThat(service.request(new RequestAnalysisUseCase.Command(user, recordId, photoId))).isEqualTo(existing);
verify(jobs, never()).insert(any());
verifyNoInteractions(events);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ var record = record(now, ImageType.AFTER, user);
assertThat(resolved.evidence().status()).isEqualTo("NEWER_PROFILE");
verify(analyzer).analyze(any());
}
@Test void newerProfileDoesNotHidePhotoAnalysisFailure() {
var record = record(now, ImageType.AFTER, user);
when(analyzer.analyze(any())).thenReturn(PhotoHairEvidence.absent("UNAVAILABLE"));
var resolved = resolver.resolve(user, profile(HairLength.BELOW_CHIN, now.plusSeconds(60)), List.of(record));
assertThat(resolved.evidence().status()).isEqualTo("UNAVAILABLE");
assertThat(resolved.evidence().applied()).isFalse();
}
@Test void neverUsesBeforePhotoOrAnotherAccountsFile() {
var before = record(now, ImageType.BEFORE, user);
assertThat(resolver.resolve(user, null, List.of(before)).evidence().applied()).isFalse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ void fillsFromLeastExposedStylesWithoutDuplicatesWhenUnseenStylesRunOut() {
assertThat(diversifier.topThree(List.of(frequent), counts)).containsExactly(frequent);
}

@Test
void preferencesSurviveRefreshAndDiversityLimits() {
UUID tag = UUID.randomUUID();
var preferred = List.of(preferredItem("A", tag, 90), preferredItem("A", tag, 80),
preferredItem("A", tag, 70));
var unrelated = item("B", UUID.randomUUID(), 100);
var candidates = new java.util.ArrayList<>(List.of(unrelated));
candidates.addAll(preferred);
var exposures = new java.util.HashMap<UUID, Long>();
preferred.forEach(item -> exposures.put(item.candidate().hairstyleId(), 5L));
assertThat(diversifier.topThree(candidates, exposures)).containsExactlyElementsOf(preferred);
assertThat(diversifier.topThree(List.of(unrelated, preferred.getFirst()), exposures))
.containsExactly(preferred.getFirst(), unrelated);
}

private ScoredRecommendation preferredItem(String category, UUID tag, double score) {
var original = item(category, tag, score);
return new ScoredRecommendation(original.candidate(),
new ScoreBreakdown(0, 0, 15, 0, 0, 15, 100, score), List.of(), null);
}

private ScoredRecommendation item(String category, UUID tagId, double score) {
HairstyleCandidate candidate = new HairstyleCandidate(UUID.randomUUID(), "style", category,
UUID.randomUUID(), "NONE", true, "1", Set.of(), Set.of(), Set.of(), Set.of(), Set.of(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ void savedStylePreferredTagHairAndCareScoresAreCombined() {
var result = scorer.score(candidate, context).orElseThrow().scoreBreakdown();

assertThat(result.savedStyleScore()).isEqualTo(15);
assertThat(result.preferredTagScore()).isEqualTo(7.5);
assertThat(result.preferredTagScore()).isEqualTo(15);
assertThat(result.hairCompatibilityScore()).isEqualTo(20);
assertThat(result.careTimeScore()).isEqualTo(10);
assertThat(result.finalScore()).isEqualTo(87.5);
assertThat(result.finalScore()).isEqualTo(100);
}

@Test
Expand Down
Loading