블로그로 돌아가기

파이썬으로 얼굴인식 출결 시스템 만들기

빌드 로그6분
Sungbin Kim

1. 프로젝트 소개

이 프로젝트는 파이썬 face_recognition 라이브러리랑 PyQt5로 만든 얼굴인식 출결 시스템입니다. QR 출결 말고 카메라로 자동 출석을 처리해보고 싶어서 만들었어요. 막상 해보니까 얼굴 인식 자체보다 UI랑 예외 처리, 대량 등록 같은 주변 기능에 손이 훨씬 많이 가더라고요.

넣은 기능

  • 실시간 얼굴 인식 및 출석 체크
  • 새로운 얼굴 등록 및 관리
  • 출석 기록 저장 및 내보내기
  • 직관적인 그래픽 사용자 인터페이스
  • 대량 얼굴 등록 기능

2. 개발 환경

필요한 라이브러리

pip install face_recognition
pip install PyQt5
pip install opencv-python
pip install numpy
pip install Pillow

주요 라이브러리

  • face_recognition: 얼굴 인식 핵심 기능
  • PyQt5: GUI 구현
  • opencv-python: 카메라 스트림 처리
  • Pillow: 이미지 처리
  • numpy: 배열 및 행렬 연산

3. 시스템 구조

3.1 전체 시스템 구조

프로그램은 크게 클래스 세 개로 나눴습니다.

  1. AttendanceSystem: 메인 윈도우 클래스
  2. AttendanceTab: 출석 체크 탭
  3. ManagementTab: 얼굴 관리 탭

각 클래스가 맡은 역할은 비교적 단순하게 뒀고요.

3.2 AttendanceSystem 클래스

이 클래스가 메인 윈도우랑 전체 UI 스타일을 맡습니다.

class AttendanceSystem(QMainWindow):
    def __init__(self):
        super().__init__()
        self.initUI()
        self.showMaximized()  # 전체화면으로 시작

    def initUI(self):
        self.setWindowTitle('얼굴인식 출결 시스템')
        # 스타일시트 설정
        self.setStyleSheet("""
            QMainWindow {
                background-color: #f0f0f0;
            }
            QPushButton {
                background-color: #2196F3;
                color: white;
                border: none;
                padding: 12px 24px;
                border-radius: 6px;
                font-size: 14px;
                min-width: 120px;
            }
            # ... 기타 스타일 정의 ...
        """)

3.3 AttendanceTab 클래스 상세 구현

출석 체크를 담당하는 핵심 클래스예요. 실시간 카메라 처리랑 얼굴 인식을 여기서 해요.

3.3.1 초기화 및 UI 구성

class AttendanceTab(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.initUI()

        # 카메라 및 얼굴인식 관련 변수 초기화
        self.camera = None
        self.timer = QTimer()
        self.timer.timeout.connect(self.update_frame)
        self.is_running = False
        self.frame_count = 0

        # 얼굴 인식 데이터 초기화
        self.known_face_encodings = []
        self.known_face_names = []
        self.load_known_faces()

        # 출석한 학생 목록
        self.present_students = set()

3.3.2 실시간 얼굴 인식 처리

프레임 처리랑 얼굴 인식은 이 로직에서 돌아갑니다.

def update_frame(self):
    ret, frame = self.camera.read()
    if ret:
        # 성능 최적화를 위해 3프레임마다 처리
        process_this_frame = self.frame_count % 3 == 0
        self.frame_count += 1

        if process_this_frame:
            # 프레임 크기 조정으로 처리 속도 향상
            height, width = frame.shape[:2]
            small_frame = cv2.resize(frame, (width//4, height//4))
            rgb_small_frame = cv2.cvtColor(small_frame, cv2.COLOR_BGR2RGB)

            try:
                # 얼굴 위치 감지
                face_locations = face_recognition.face_locations(
                    rgb_small_frame,
                    model="hog"  # CPU 최적화 모드
                )

                if face_locations:
                    # 얼굴 특징 추출
                    face_encodings = face_recognition.face_encodings(
                        rgb_small_frame,
                        face_locations
                    )

                    # 각 얼굴에 대해 처리
                    for (top, right, bottom, left), face_encoding in zip(
                        face_locations, face_encodings
                    ):
                        if len(self.known_face_encodings) > 0:
                            # 벡터화된 연산으로 얼굴 비교
                            face_distances = face_recognition.face_distance(
                                self.known_face_encodings,
                                face_encoding
                            )
                            best_match_index = np.argmin(face_distances)

                            # 유사도 임계값(0.6) 이하일 때만 인식
                            if face_distances[best_match_index] < 0.6:
                                name = self.known_face_names[best_match_index]
                                self.record_attendance(name)
                                self.show_notification(name)

3.3.3 출석 기록 처리

출석 기록을 저장하고 UI를 갱신하는 부분이에요.

def record_attendance(self, name):
    try:
        current_time = datetime.datetime.now()
        time_string = current_time.strftime('%H:%M:%S')

        # 중복 출석 방지 (5분 이내)
        if name in self.present_students:
            return

        # 얼굴 이미지 처리 및 표시
        faces_dir = "faces"
        image_path = os.path.join(faces_dir, f"{name}.jpg")
        if os.path.exists(image_path):
            # 최근 인식된 얼굴 표시
            pixmap = QPixmap(image_path)
            pixmap = pixmap.scaled(
                250, 250,
                Qt.KeepAspectRatio,
                Qt.SmoothTransformation
            )
            self.recent_face_label.setPixmap(pixmap)

            # 상태 메시지 업데이트
            self.status_label.setStyleSheet("""
                QLabel {
                    color: #4CAF50;
                    padding: 15px;
                    background-color: #E8F5E9;
                    border-radius: 8px;
                    font-weight: bold;
                    font-size: 16px;
                }
            """)
            self.status_label.setText(f'✓ {name}님 출석이 확인되었습니다.')

            # 출석 테이블에 기록 추가
            self.update_attendance_table(name, time_string, pixmap)

            # CSV 파일에 기록
            self.save_attendance_record(name, time_string)

            # 출석 상태 업데이트
            self.present_students.add(name)
            self.update_absent_list()

    except Exception as e:
        print(f"출석 기록 중 오류 발생: {str(e)}")

3.4 ManagementTab 클래스 상세 구현

얼굴 등록이랑 관리를 담당하는 클래스입니다.

3.4.1 얼굴 등록 기능

한 명씩 등록하는 것도, 한꺼번에 등록하는 것도 다 넣었어요.

def register_face(self):
    try:
        file_name, _ = QFileDialog.getOpenFileName(
            self, "얼굴 이미지 선택", "",
            "Image files (*.jpg *.jpeg *.png)"
        )
        if file_name:
            name, ok = QInputDialog.getText(
                self, '이름 입력',
                '등록할 사람의 이름을 입력하세요:'
            )
            if ok and name:
                faces_dir = "faces"
                if not os.path.exists(faces_dir):
                    os.makedirs(faces_dir)

                # 파일 이름 중복 방지
                new_path = os.path.join(faces_dir, f"{name}.jpg")
                counter = 1
                while os.path.exists(new_path):
                    new_path = os.path.join(faces_dir, f"{name}_{counter}.jpg")
                    counter += 1

                # 이미지 처리 및 저장
                image = Image.open(file_name)
                image = image.convert('RGB')
                image.save(new_path, 'JPEG', quality=95)

                self.known_faces.append((name, new_path))
                self.update_face_grid()
                QMessageBox.information(
                    self, '등록 완료',
                    f'{name}의 얼굴이 등록되었습니다.'
                )

    except Exception as e:
        QMessageBox.warning(
            self, '오류',
            f'얼굴 등록 중 오류가 발생했습니다: {str(e)}'
        )

3.4.2 대량 얼굴 등록 기능

def bulk_register_faces(self):
    try:
        folder_path = QFileDialog.getExistingDirectory(
            self, "이미지가 있는 폴더 선택"
        )

        if folder_path:
            # 결과 추적을 위한 변수들
            success_count = 0
            skip_count = 0
            fail_count = 0
            error_files = []

            # 진행 상황 표시
            progress = QMessageBox()
            progress.setWindowTitle("등록 진행 중")
            progress.setText("얼굴 등록을 진행하고 있습니다...")
            progress.show()

            valid_extensions = ('.jpg', '.jpeg', '.png')

            # 폴더 내 모든 이미지 처리
            for filename in os.listdir(folder_path):
                if filename.lower().endswith(valid_extensions):
                    try:
                        file_path = os.path.join(folder_path, filename)
                        name = os.path.splitext(filename)[0]

                        # 중복 체크
                        existing_path = os.path.join(faces_dir, f"{name}.jpg")
                        if os.path.exists(existing_path):
                            skip_count += 1
                            continue

                        # 얼굴 인식 확인
                        image = face_recognition.load_image_file(file_path)
                        face_locations = face_recognition.face_locations(image)

                        if face_locations:
                            # 이미지 처리 및 저장
                            img = Image.open(file_path)
                            img = img.convert('RGB')
                            img.save(new_path, 'JPEG', quality=95)
                            success_count += 1
                        else:
                            fail_count += 1
                            error_files.append(f"{filename} (얼굴 감지 실패)")

                    except Exception as e:
                        fail_count += 1
                        error_files.append(f"{filename} (오류: {str(e)})")

            # 결과 표시
            result_message = (
                f"등록 완료:\n\n"
                f"성공: {success_count}개\n"
                f"건너뜀: {skip_count}개\n"
                f"실패: {fail_count}개"
            )
            if error_files:
                result_message += "\n\n실패한 파일들:\n" + "\n".join(error_files)

            QMessageBox.information(self, '대량 등록 완료', result_message)

    except Exception as e:
        QMessageBox.warning(
            self, '오류',
            f'대량 등록 중 오류가 발생했습니다: {str(e)}'
        )

3.5 느려서 고친 것들

처음엔 매 프레임을 그대로 face_recognition에 넣었는데 카메라가 슬라이드쇼처럼 끊기더라고요. 그래서 3프레임에 한 번만 인식하고, 그 프레임도 가로세로 1/4로 줄여서 넣었습니다. 감지 모델도 CNN 대신 HOG를 썼고요. 노트북 CPU에서 돌릴 거라 정확도보다 프레임을 택한 건데, 그만큼 옆얼굴이나 어두운 데서 놓치는 게 늘었죠.

인식을 별도 스레드로 빼서 UI가 멈추지 않게 한 것도 이때 했습니다. 메모리 쪽은 따로 손댄 게 없습니다.

3.6 실제로 났던 오류

카메라를 못 잡는 경우가 제일 많더라고요. cv2.VideoCapture(0)이 노트북에 따라 내장 캠이 아닐 때가 있어서, 인덱스를 0부터 몇 개 돌려보고 열리는 걸 쓰게 했죠.

같은 이름으로 얼굴을 또 등록하면 기존 파일을 덮어쓰던 것도 고쳤고요. 인식이 실패하는 건 오류가 아니라 정상 상태라서, 화면에 "인식 안 됨"만 표시하고 넘어가게 뒀습니다.

4. 쓰는 순서

처음 한 번은 관리 탭에서 얼굴을 등록해야 합니다. 그다음부터는 출결 탭에서 '출석 시작'을 누르면 카메라가 켜지고, 등록된 얼굴이 들어오면 시간이랑 같이 기록돼요. 기록은 CSV로 내보낼 수 있고요.

5. 아쉬운 점

인식률을 제대로 재본 적이 없죠. "대충 잘 되네" 수준에서 멈췄고, 조명이나 각도별로 얼마나 틀리는지는 숫자로 모릅니다.

코드도 한 파일에 거의 다 들어가 있어요. 카메라, 인식, 저장, UI가 다 섞여 있어서 뭐 하나 고치려면 전체를 읽어야 했죠.

전체 소스는 깃허브에 올려뒀습니다.

제일 걸렸던 건 성능이 아니라 얼굴 사진을 등록해서 계속 들고 있어야 한다는 점이었어요. 반 친구들 얼굴이 제 노트북 폴더에 파일로 쌓여 있는 게 내내 마음에 걸렸고요. 그래서 다음엔 같은 문제를 QR로 다시 만들었죠.