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
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ public class FaceRecognitionController {
private final FaceRecognitionService faceRecognitionService;

/** 관리자 카메라 페이지마다 새 얼굴 인식 세션을 만든다. */
@Operation(summary = "얼굴 인식 세션 생성", description = "카메라 페이지마다 새 세션을 만든다. 성공 201.")
@Operation(summary = "얼굴 인식 세션 생성", description = "카메라 페이지·층마다 새 세션을 만든다. floor는 3, 4, 5 중 하나이고 다른 값이면 400 INVALID_FLOOR다. 성공 201.")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public FaceSessionResponse create(
@AuthenticationPrincipal Long memberId,
@Valid @RequestBody FaceSessionCreateRequest request
) {
return faceRecognitionService.create(memberId, request.purpose());
return faceRecognitionService.create(memberId, request.purpose(), request.floor());
}

/** 카메라 프레임 한 장의 얼굴을 인식하고 출석을 처리한다. 받은 이미지는 모든 응답 경로에서 0으로 덮어쓴다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public record Face(
Recognition recognition,
@Schema(description = "이 얼굴 트랙의 연속 인식 시도 횟수(DEC-004)", example = "1")
int attempts,
@Schema(description = "true면 QR 출석 안내를 띄운다(3회 초과 실패, DEC-004)")
@Schema(description = "true면 QR 출석 안내를 띄운다(3회 초과 실패, DEC-004). OTHER_FLOOR면 항상 false")
boolean qrRecommended
) {
}
Expand All @@ -35,7 +35,8 @@ public record Quality(
}

public record Recognition(
@Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED 중 하나")
@Schema(description = "KNOWN, UNKNOWN, NOT_ATTEMPTED, OTHER_FLOOR 중 하나. OTHER_FLOOR는 세션 층이 아닌 학생이고 이름·학번·출석이 없다",
allowableValues = {"KNOWN", "UNKNOWN", "NOT_ATTEMPTED", "OTHER_FLOOR"})
String status,
@Schema(description = "학생 이름. status가 KNOWN일 때만 있다")
String studentName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/** 관리자 얼굴 인식 세션 생성 요청. */
public record FaceSessionCreateRequest(
@Schema(description = "출석 용도. DORMITORY(기숙사 입소) 또는 STUDY_ROOM(자습실)", example = "DORMITORY")
@NotNull AttendancePurpose purpose
@NotNull AttendancePurpose purpose,
@Schema(description = "카메라가 맡은 층. 3, 4, 5 중 하나. 없거나 다른 값이면 400 INVALID_FLOOR", example = "4")
Integer floor
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
public record FaceSessionResponse(
@Schema(description = "얼굴 인식 세션 id. 프레임 전송·종료 경로에 쓴다") UUID sessionId,
@Schema(description = "출석 용도. DORMITORY 또는 STUDY_ROOM") AttendancePurpose purpose,
@Schema(description = "카메라가 맡은 층. 3, 4, 5 중 하나", example = "4") int floor,
@Schema(description = "AI 준비 확인과 세션 생성이 성공하면 ACTIVE") String status
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public class FaceRecognitionSession {
@Column(nullable = false, length = 20)
private AttendancePurpose purpose;

/** 이 카메라가 맡은 층(3·4·5). 층 도입 전에 만든 세션은 null이다. */
@Column
private Integer floor;

@Column(name = "created_at", nullable = false)
private Instant createdAt;

Expand All @@ -50,13 +54,15 @@ public static FaceRecognitionSession create(
UUID id,
Long adminMemberId,
AttendancePurpose purpose,
Integer floor,
Instant createdAt,
Instant lastActivityAt
) {
FaceRecognitionSession session = new FaceRecognitionSession();
session.id = id;
session.adminMemberId = adminMemberId;
session.purpose = purpose;
session.floor = floor;
session.createdAt = createdAt;
session.lastActivityAt = lastActivityAt;
session.active = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
public class FaceRecognitionService {
private static final int VECTOR_DIMENSION = 256;
private static final int MAX_TEMPLATE_REFRESH_ATTEMPTS = 3;
private static final Set<Integer> CAMERA_FLOORS = Set.of(3, 4, 5);

private final FaceTemplateRepository faceTemplateRepository;
private final FaceSessionStore faceSessionStore;
Expand Down Expand Up @@ -87,20 +88,27 @@ public FaceRecognitionService(

/**
* 관리자 카메라 페이지의 얼굴 인식 세션을 만든다. 출석 대상(필수 동의·호실)이고 얼굴을 등록한 학생이 후보다.
* 후보는 층과 상관없이 전체 출석 대상이다. 다른 층 학생을 이 층 학생으로 잘못 맞히지 않고 {@code OTHER_FLOOR}로 알리기 위해서다(#253).
* 같은 관리자의 다른 세션과 겹쳐도 거부하지 않는다. 층을 바꾸면 웹이 이전 세션을 닫고 새로 만든다.
*
* @param adminMemberId 세션의 관리자 회원 id
* @param purpose 출석 용도
* @return 새 세션 id와 용도
* @throws CustomException 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403),
* @param floor 카메라가 맡은 층(3·4·5). 이 층 학생만 출석 처리한다
* @return 새 세션 id와 용도·층
* @throws CustomException 층이 없거나 3·4·5가 아니면 {@link ErrorCode#INVALID_FLOOR}(400),
* 관리자가 아니면 {@link ErrorCode#ADMIN_ONLY}(403),
* 후보 학생이 없으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS}(409),
* 후보가 설정된 최대(기본 200명)를 넘으면 {@link ErrorCode#FACE_TOO_MANY_CANDIDATES}(422),
* 저장된 템플릿의 모델이 서로 다르거나 벡터가 깨졌으면 {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502).
* AI 서버 오류는 {@link com.checkup.checkup.domain.face.ai.AiFaceException}으로 던지고
* {@code GlobalExceptionHandler}가 {@link ErrorCode#FACE_AI_UNAVAILABLE}(503),
* {@link ErrorCode#FACE_AI_TIMEOUT}(504), {@link ErrorCode#FACE_AI_BAD_GATEWAY}(502) 등으로 바꾼다
*/
public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose) {
public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose, Integer floor) {
adminVerifier.verify(adminMemberId);
if (floor == null || !CAMERA_FLOORS.contains(floor)) {
throw new CustomException(ErrorCode.INVALID_FLOOR);
}
List<FaceTemplate> templates = eligibleTemplates();
if (templates.isEmpty()) {
throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS);
Expand All @@ -112,7 +120,7 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose)
UUID sessionId = UUID.randomUUID();
Instant now = clock.instant();
// AI를 부르기 전에 세션 소유 정보를 먼저 저장한다. 그래야 AI 쪽 정리가 실패해도 다시 시도할 수 있다.
faceSessionStore.create(sessionId, adminMemberId, purpose,
faceSessionStore.create(sessionId, adminMemberId, purpose, floor,
templates.stream().map(template -> template.getStudent().getId()).toList(),
now, now.minus(properties.minFrameInterval()));
try {
Expand All @@ -126,12 +134,13 @@ public FaceSessionResponse create(Long adminMemberId, AttendancePurpose purpose)
}
throw e;
}
return new FaceSessionResponse(sessionId, purpose, "ACTIVE");
return new FaceSessionResponse(sessionId, purpose, floor, "ACTIVE");
}

/**
* 카메라 프레임 한 장에서 얼굴을 인식하고, 현재 세션 후보로 확인된 학생을 출석 처리한다.
* AI가 후보 밖의 학생을 지목하면 출석으로 인정하지 않고 {@code UNKNOWN}으로 낮춘다. 결과는 최근 인식 기록에도 남긴다(#143).
* 세션 층이 아닌 학생은 이름·학번·출석 없이 {@code OTHER_FLOOR}로 알린다(#253).
* 프레임 이미지는 처리 뒤 메모리에서 지우고 저장하지 않는다.
*
* @param frameIdHeader 프레임 id(선택, 최대 128자). 없으면 새로 만든다
Expand Down Expand Up @@ -330,13 +339,20 @@ private FaceFrameResponse.Face toPublicFace(
String studentName = null;
Integer studentNumber = null;
String attendance = null;
boolean qrRecommended = face.qrRecommended();
if ("KNOWN".equals(status)) {
Long dataGsmStudentId = canonicalStudentId(face.recognition().studentId());
var student = dataGsmStudentId == null ? java.util.Optional.<Student>empty()
: studentRepository.findByDatagsmStudentId(dataGsmStudentId);
if (student.isPresent()
boolean candidate = student.isPresent()
&& session.candidateStudentIds().contains(student.get().getId())
&& student.get().isAttendanceEligible()) {
&& student.get().isAttendanceEligible();
if (candidate && isOtherFloor(session, student.get())) {
// 다른 층 학생은 이 카메라 화면에 신원을 보이지 않고 출석·최근 인식에도 남기지 않는다(#253).
// 층을 잘못 찾은 학생에게 QR 안내는 도움이 안 되므로 띄우지 않는다.
status = "OTHER_FLOOR";
qrRecommended = false;
} else if (candidate) {
studentName = student.get().getName();
studentNumber = student.get().getStudentNumber();
AttendanceRecordResult recorded = attendanceService.markAttendedFor(
Expand All @@ -356,7 +372,7 @@ private FaceFrameResponse.Face toPublicFace(
status = "UNKNOWN";
}
}
if (attendance == null && face.qrRecommended()) {
if (attendance == null && qrRecommended) {
// 못 알아본 얼굴은 AI가 QR을 안내할 때만 한 줄 남기고, 다른 학생의 이름을 만들지 않는다.
recordQuietly(session, face.trackId(), FaceRecognitionResult.FAILED, null, verifiedAt);
}
Expand All @@ -365,7 +381,12 @@ private FaceFrameResponse.Face toPublicFace(
return new FaceFrameResponse.Face(face.trackId(), face.bbox(), face.landmarks(),
new FaceFrameResponse.Quality(quality.brightness(), quality.sharpness(), quality.issues()),
new FaceFrameResponse.Recognition(status, studentName, studentNumber, attendance),
face.attempts(), face.qrRecommended());
face.attempts(), qrRecommended);
}

/** 층 도입 전에 만든 세션(층 null)은 층을 따지지 않는다. */
private static boolean isOtherFloor(FaceSessionView session, Student student) {
return session.floor() != null && !session.floor().equals(student.getDormitoryFloor());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ public class FaceSessionStore {
* @throws CustomException 후보 학생 중 저장되지 않은 학생이 있으면 {@link ErrorCode#FACE_NO_ENROLLED_STUDENTS}
*/
@Transactional
public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose, List<Long> studentIds,
Instant createdAt, Instant initialLastActivityAt) {
sessionRepository.save(
FaceRecognitionSession.create(sessionId, adminMemberId, purpose, createdAt, initialLastActivityAt));
public void create(UUID sessionId, Long adminMemberId, AttendancePurpose purpose, Integer floor,
List<Long> studentIds, Instant createdAt, Instant initialLastActivityAt) {
sessionRepository.save(FaceRecognitionSession.create(
sessionId, adminMemberId, purpose, floor, createdAt, initialLastActivityAt));
// 후보 수만큼 insert하지 않고 한 문장으로 저장한다. 저장되지 않은 학생이 있으면 개수가 모자라 트랜잭션째 취소된다.
if (candidateRepository.insertAll(sessionId, studentIds) != studentIds.size()) {
throw new CustomException(ErrorCode.FACE_NO_ENROLLED_STUDENTS);
Expand All @@ -56,7 +56,7 @@ public FaceSessionView findOwned(UUID sessionId, Long adminMemberId) {
.map(candidate -> candidate.getStudent().getId())
.collect(Collectors.toUnmodifiableSet());
return new FaceSessionView(session.getId(), session.getAdminMemberId(), session.getPurpose(),
session.getLastActivityAt(), session.isActive(), candidateIds);
session.getFloor(), session.getLastActivityAt(), session.isActive(), candidateIds);
}

/**
Expand Down Expand Up @@ -169,6 +169,6 @@ private static FaceSessionView view(FaceRecognitionSession session,
Set<Long> ids = candidates.stream().map(candidate -> candidate.getStudent().getId())
.collect(Collectors.toUnmodifiableSet());
return new FaceSessionView(session.getId(), session.getAdminMemberId(), session.getPurpose(),
session.getLastActivityAt(), session.isActive(), ids);
session.getFloor(), session.getLastActivityAt(), session.isActive(), ids);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
import java.util.Set;
import java.util.UUID;

/** 얼굴 인식 세션의 읽기 전용 상태와 후보 학생 DB id 목록. */
/** 얼굴 인식 세션의 읽기 전용 상태와 후보 학생 DB id 목록. {@code floor}는 카메라가 맡은 층이고 층 도입 전 세션은 null이다. */
public record FaceSessionView(
UUID id,
Long adminMemberId,
AttendancePurpose purpose,
Integer floor,
Instant lastActivityAt,
boolean active,
Set<Long> candidateStudentIds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public enum ErrorCode {
FACE_ENROLLMENT_NOT_ELIGIBLE(HttpStatus.FORBIDDEN, "현재 얼굴 등록 대상이 아닙니다."),
FACE_ENROLLMENT_MULTIPLE_IDENTITIES(HttpStatus.UNPROCESSABLE_ENTITY, "영상에 얼굴이 여러 명 포함되어 있습니다. 본인만 촬영해 주세요."),
FACE_ENROLLMENT_LOW_LIGHT(HttpStatus.UNPROCESSABLE_ENTITY, "영상이 어둡습니다. 밝은 곳에서 다시 촬영해 주세요."),
INVALID_FLOOR(HttpStatus.BAD_REQUEST, "얼굴 인식 층은 3, 4, 5층 중 하나여야 합니다."),
FACE_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "얼굴 인식 세션이 없거나 종료되었습니다."),
FACE_NO_ENROLLED_STUDENTS(HttpStatus.CONFLICT, "현재 인식할 수 있는 등록 학생이 없습니다."),
FACE_TOO_MANY_CANDIDATES(HttpStatus.UNPROCESSABLE_ENTITY, "인식 대상 학생이 허용 인원을 초과했습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 관리자 카메라가 맡은 층(#253). 이전에 만든 세션은 층이 없으므로 NULL을 허용한다.
ALTER TABLE face_recognition_session
ADD COLUMN floor INTEGER CHECK (floor BETWEEN 3 AND 5);
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ void deleteExpiredRemovesPreviousDays() {
}

private static FaceSessionView session() {
return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, BEFORE_BOUNDARY, true, Set.of(40L));
return new FaceSessionView(SESSION_ID, ADMIN_ID, AttendancePurpose.DORMITORY, 4, BEFORE_BOUNDARY, true, Set.of(40L));
}

private static FaceRecognitionLog log(FaceRecognitionResult result, Student student) {
Expand Down
Loading
Loading