25.09.05 / 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 3일차

2025. 11. 4. 10:19·LMS 7/개발일지

인증용 프로그램 정리

1. 영상처리 및 오버레이

더보기
QImage MainWindow::matToQImage(const cv::Mat& mat) {
    if (mat.empty()) return QImage();
    if (mat.type() == CV_8UC1) {
        return QImage(mat.data, mat.cols, mat.rows, mat.step, QImage::Format_Grayscale8).copy();
    } else if (mat.type() == CV_8UC3) {
        cv::Mat rgb; cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB);
        return QImage(rgb.data, rgb.cols, rgb.rows, rgb.step, QImage::Format_RGB888).copy();
    } else {
        cv::Mat gray;
        if (mat.channels() == 3) cv::cvtColor(mat, gray, cv::COLOR_BGR2GRAY);
        else mat.convertTo(gray, CV_8U);
        return QImage(gray.data, gray.cols, gray.rows, gray.step, QImage::Format_Grayscale8).copy();
    }
}

 

matToQImage(const cv::Mat& mat) → QImage

OpenCV cv::Mat을 Qt QImage로 변환.

입력 채널에 따라:

CV_8UC1(그레이) → QImage::Format_Grayscale8

CV_8UC3(BGR) → RGB로 색공간 변환 후 QImage::Format_RGB888

그 외 → 그레이로 변환

 

void MainWindow::showMatOn(QLabel* label, const cv::Mat& mat, const QString& text) {
    if (!label) return;
    QImage img = matToQImage(mat);
    if (img.isNull()) return;

    if (!text.isEmpty()) {
        QPainter painter(&img);
        painter.setRenderHint(QPainter::TextAntialiasing, true);
        painter.setPen(Qt::green);

        // 폰트 폴백: Noto → Nanum → 시스템 기본
        QFont font;
        QStringList candidates = {
            "Noto Sans CJK KR", "Noto Sans KR", "NanumGothic", "Nanum Gothic"
        };
        bool set = false;
        for (const QString& fam : candidates) {
            if (QFontDatabase().families().contains(fam)) { font.setFamily(fam); set = true; break; }
        }
        if (!set) {
            // 시스템 기본 사용
            font = painter.font();
        }
        font.setPointSize(14);
        painter.setFont(font);

        painter.drawText(10, 25, text);
        painter.end();
    }

    label->setPixmap(QPixmap::fromImage(img)
                         .scaled(label->size(), Qt::KeepAspectRatio, Qt::FastTransformation));
}

 

showMatOn(QLabel* label, const cv::Mat& mat, const QString& text)

변환된 프레임을 지정된 QLabel에 맞춰 비율 유지로 스케일링해 표시.

text가 비어있지 않으면 QPainter로 좌상단(10,25)에 한글 오버레이를 그림.

폰트는 Noto/Nanum 계열을 우선 시도하고, 없으면 시스템 기본 사용.


2. 얼굴 검출 · 인식 준비

더보기
cv::Rect MainWindow::largestRect(const std::vector<cv::Rect>& rects) {
    int idx = -1; int areaMax = -1;
    for (int i=0;i<(int)rects.size();++i) {
        int a = rects[i].area(); if (a > areaMax) { areaMax = a; idx = i; }
    }
    return (idx >= 0) ? rects[idx] : cv::Rect();
}

 

largestRect(const std::vector<cv::Rect>& rects) → cv::Rect

검출된 여러 얼굴 중 가장 큰 사각형을 선택.

 

QString MainWindow::findCascadeLocal() const {
    QStringList candidates;
    candidates << QDir(QCoreApplication::applicationDirPath()).filePath("haarcascade_frontalface_default.xml");
    candidates << QDir::current().filePath("haarcascade_frontalface_default.xml");
    candidates << "/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml";
    for (const auto& p : candidates) if (QFile::exists(p)) return p;
    return QString();
}

 

findCascadeLocal() → QString

얼굴 검출용 Haar cascade 파일을 다음 경로 순서로 탐색:

실행 파일 폴더

현재 작업 디렉토리

/usr/share/opencv4/haarcascades/

존재하는 첫 경로를 반환.

 

void MainWindow::ensureCascadeLoaded() {
    if (!cascadePath.isEmpty() && !faceCasc.empty()) return;
    cascadePath = findCascadeLocal();
    if (!cascadePath.isEmpty()) faceCasc.load(cascadePath.toStdString());
}

 

ensureCascadeLoaded()

아직 로드 안 된 경우 cascade 파일 경로를 찾아 faceCasc에 로드.


3. 상태/메시지 표시

void MainWindow::setStatus(const QString& s)  {
    if (ui->lblStatus) ui->lblStatus->setText(s);
}
void MainWindow::setMessage(const QString& s) {
    if (ui->lblMessage) ui->lblMessage->setText(s);
}

4. 카메라 제어

void MainWindow::startCamera() {
    if (!cap.isOpened()) {
        if (!cap.open(0)) {
            setStatus("카메라 열기 실패");
            return;
        }
    }
    ensureCascadeLoaded();
    setStatus((cascadePath.isEmpty() || faceCasc.empty())
                  ? "카메라 시작됨 (얼굴 인식 파일 없음)"
                  : "카메라 시작됨 (얼굴 인식 가능)");
    connect(&timer, &QTimer::timeout, this, &MainWindow::onFrameTick, Qt::UniqueConnection);
    timer.start(33); // ~30fps
}

 

startCamera()

cv::VideoCapture 0번 장치를 열고 타이머(33ms)로 프레임 루프 시작.

Cascade 로드 상황에 따라 상태 메시지:

파일 없음: “얼굴 인식 파일 없음”

파일 있음: “얼굴 인식 가능”

 

void MainWindow::stopCamera() {
    timer.stop();
    if (cap.isOpened()) cap.release();
    setStatus("카메라 중지됨");
}

 

stopCamera()

타이머 정지, 카메라 해제, 상태 “카메라 중지됨”.


5. 생성자/소멸자

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent), ui(new Ui::MainWindow) {
    ui->setupUi(this);
    if (ui->videoLabel) ui->videoLabel->setText("Video Preview");

    // 1) DB에서 학습
    if (trainFromDatabase()) {
        setMessage("모델 준비 완료");
    } else {
        setMessage("모델 준비 실패(등록 데이터 부족 또는 DB 오류)");
    }

    if (!openSerial("/dev/rfcomm0", 9600)) {
        if (!openSerial(QString(), 9600)) {
            openSerial("/dev/ttyACM0", 9600);
        }
    }

    // 2) 카메라 시작
    startCamera();
}

 

MainWindow::MainWindow(...)

ui->setupUi(this) 후 videoLabel 초기 문구 설정.

DB에서 학습(trainFromDatabase()) 시도 → 성공/실패 메시지.

시리얼 포트 열기

우선 /dev/rfcomm0@9600 시도 → 실패 시 자동 검색 → 마지막으로 /dev/ttyACM0@9600.

카메라 시작.

 

MainWindow::~MainWindow() {
    stopCamera();
    db.close();
    delete ui;
}

 

~MainWindow()

카메라 정지, DB 닫기, UI 해제.


6. DB -> 학습

더보기
bool MainWindow::decodeRowToGray128(const QByteArray& png, cv::Mat& outGray128, cv::Mat* outColor128) {
    std::vector<uchar> buf(png.begin(), png.end());
    cv::Mat img = cv::imdecode(buf, cv::IMREAD_COLOR); // DB에는 컬러 PNG 저장한다고 합의됨
    if (img.empty()) return false;
    cv::Mat gray; cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
    cv::resize(gray, outGray128, cv::Size(128,128));
    if (outColor128) {
        cv::Mat color128; cv::resize(img, color128, cv::Size(128,128));
        *outColor128 = color128;
    }
    return true;
}

 

decodeRowToGray128(const QByteArray& png, cv::Mat& outGray128, cv::Mat* outColor128)

DB에 컬러 PNG로 저장된 얼굴 이미지를 로드.

그레이 128×128로 리사이즈(필수), 필요 시 컬러 128×128도 반환.

bool MainWindow::trainFromDatabase() {
    // 초기화
    labelToName.clear();
    pairToLabel.clear();
    conflictIds.clear();
    nextLabelId = 1;

#if HAS_OPENCV_FACE
    model = cv::face::LBPHFaceRecognizer::create(1, 8, 8, 8, threshold);
    model->setThreshold(threshold);
#endif

    if (!db.open()) {
        setStatus("DB 연결 실패");
        return false;
    }

    // 1) 우선 user_id별 이름 분포를 확인해 충돌 ID를 수집
    {
        QHash<int, QSet<QString>> namesPerId;
        QSqlQuery q(db.database());
        if (!q.exec("SELECT user_id, user_name FROM face_images")) {
            setStatus(QString("DB 조회 실패: %1").arg(q.lastError().text()));
            return false;
        }
        while (q.next()) {
            int uid = q.value(0).toInt();
            QString uname = q.value(1).toString().trimmed();
            namesPerId[uid].insert(uname);
        }
        for (auto it = namesPerId.begin(); it != namesPerId.end(); ++it) {
            if (it.value().size() > 1) {
                conflictIds.insert(it.key());
                qWarning() << "[WARN] user_id" << it.key()
                           << "has multiple names:" << it.value().values();
            }
        }
    }

    // 2) 실제 이미지 로딩 & 라벨링
    std::vector<cv::Mat> images; // gray 128x128
    std::vector<int> labels;
    int loaded = 0;

    QSqlQuery q2(db.database());
    if (!q2.exec("SELECT user_id, user_name, face_data FROM face_images ORDER BY created_at ASC")) {
        setStatus(QString("DB 조회 실패: %1").arg(q2.lastError().text()));
        return false;
    }

    while (q2.next()) {
        int uid = q2.value(0).toInt();
        QString uname = q2.value(1).toString().trimmed();
        QByteArray ba = q2.value(2).toByteArray();

        cv::Mat gray128;
        if (!decodeRowToGray128(ba, gray128)) continue;

        int labelInt = -1;
        if (conflictIds.contains(uid)) {
            // ⚠ 충돌하는 user_id는 (uid, uname) 단위로 분리
            labelInt = ensureLabelForPair(uid, uname);
        } else {
            // 충돌 없음: user_id 하나당 단일 이름으로 간주
            // 같은 user_id의 모든 샘플은 같은 labelInt 사용
            labelInt = ensureLabelForPair(uid, uname.isEmpty() ? QString::number(uid) : uname);
        }

        images.push_back(gray128);
        labels.push_back(labelInt);
        loaded++;
    }

    if (loaded == 0) {
        setStatus("DB에 등록 데이터가 없습니다");
        return false;
    }

#if HAS_OPENCV_FACE
    try {
        model->train(images, labels);
        setStatus(QString("학습 완료: %1장, 클래스 %2개")
                      .arg(loaded)
                      .arg(QSet<int>(labels.begin(), labels.end()).size()));
    } catch (const cv::Exception& e) {
        setStatus(QString("LBPH 학습 오류: %1").arg(e.what()));
        return false;
    }
#else
    trainImages = images;
    trainLabels = labels;
    setStatus(QString("학습(단순 NN) 준비: %1장, 클래스 %2개")
                  .arg(loaded)
                  .arg(QSet<int>(labels.begin(), labels.end()).size()));
#endif
    return true;
}

 

trainFromDatabase() → bool

내부 라벨 매핑 초기화.

(옵션) HAS_OPENCV_FACE가 정의되면 LBPHFaceRecognizer 생성 및 threshold 설정.

DB 연결 확인 → 실패 시 “DB 연결 실패”.

face_images(user_id, user_name, face_data, created_at)를 읽어:

user_id ↔ name 충돌(같은 id에 서로 다른 name) 탐지 → 충돌 id는 (uid, uname) 쌍 단위로 라벨링.

정상 id는 user_id 기준으로 묶어서 라벨링.

하나도 못 읽으면 “등록 데이터가 없습니다”.

LBPH 사용 시 model->train(images, labels); 아니면 간단 최근접(거리 기반) 대체 모델 준비.


7) 예측 (LBPH 또는 대체 NN)

bool MainWindow::predictLabel(const cv::Mat& roiGray128, int& outLabel, double& outScore) {
#if HAS_OPENCV_FACE
    int label = -1; double conf = 0.0;
    model->predict(roiGray128, label, conf);
    outLabel = label; outScore = conf;
    // LBPH는 낮을수록 유사. conf <= threshold일 때 매칭 성공으로 본다.
    return true;
#else
    // 간단 최근접 이웃 (L2 거리). 낮을수록 유사.
    if (trainImages.empty()) return false;
    double best = 1e18; int bestLabel = -1;
    for (size_t i=0; i<trainImages.size(); ++i) {
        cv::Mat diff; cv::absdiff(roiGray128, trainImages[i], diff);
        diff.convertTo(diff, CV_32F);
        double dist = std::sqrt(cv::sum(diff.mul(diff))[0]);
        if (dist < best) { best = dist; bestLabel = trainLabels[i]; }
    }
    outLabel = bestLabel; outScore = best;
    return true;
#endif
}

predictLabel(const cv::Mat& roiGray128, int& outLabel, double& outScore) → bool

HAS_OPENCV_FACE:

model->predict()로 라벨과 **conf(낮을수록 유사)**를 얻어서 반환.

Fallback(대체 NN):

학습 이미지와 L2 거리 비교 → 가장 작은 거리와 해당 라벨 반환.


8. 프레임 루프

bool MainWindow::predictLabel(const cv::Mat& roiGray128, int& outLabel, double& outScore) {
#if HAS_OPENCV_FACE
    int label = -1; double conf = 0.0;
    model->predict(roiGray128, label, conf);
    outLabel = label; outScore = conf;
    // LBPH는 낮을수록 유사. conf <= threshold일 때 매칭 성공으로 본다.
    return true;
#else
    // 간단 최근접 이웃 (L2 거리). 낮을수록 유사.
    if (trainImages.empty()) return false;
    double best = 1e18; int bestLabel = -1;
    for (size_t i=0; i<trainImages.size(); ++i) {
        cv::Mat diff; cv::absdiff(roiGray128, trainImages[i], diff);
        diff.convertTo(diff, CV_32F);
        double dist = std::sqrt(cv::sum(diff.mul(diff))[0]);
        if (dist < best) { best = dist; bestLabel = trainLabels[i]; }
    }
    outLabel = bestLabel; outScore = best;
    return true;
#endif
}

 

onFrameTick()

카메라에서 프레임 획득.

ensureCascadeLoaded() 후 그레이 변환 + detectMultiScale()로 얼굴 검출, 모든 얼굴에 초록 박스.

**가장 큰 얼굴(best)**이 있으면:

ROI를 128×128 그레이로 리사이즈해 predictLabel() 호출.

LBPH:

score <= threshold이면 매칭 성공 → who(라벨→이름 매핑)와 신뢰도 표시.

아니면 “미등록”.

Fallback NN:

거리 기준으로 ok/미등록 판단.

이후 sendSerial(ok) 호출(열림/닫힘 의사결정 전달).

얼굴이 없으면:

메시지 “얼굴을 화면 중앙에 맞춰주세요”, sendSerial(false).

최종적으로 showMatOn(videoLabel, frame, overlayText)로 한글 오버레이를 그려서 표시.


9. 도어락 제어

void MainWindow::sendSerial(bool on) {
    if (!serial.isOpen()) {
        if (!openSerial(QString(), 9600)) return;
    }

    // 튜닝 파라미터
    static const int OPEN_CONFIRM_FRAMES = 5;     // 연속 ok 프레임 수(≈ 5*33ms ≈ 165ms)
    static const int CLOSE_GRACE_MS      = 3000;  // ok 끊긴 뒤 닫기까지 대기 시간

    // 내부 상태
    /* removed local static isOpen; using MainWindow::isOpen */
    // 현재 문이 "열림 상태"라고 판단했는가
    static int    okStreak = 0;           // 연속 ok 카운트
    static qint64 lastSeenOkMs = 0;       // 마지막으로 ok 본 시각(ms)

    const qint64 now = QDateTime::currentMSecsSinceEpoch();

    // 연속 ok 카운트/타임스탬프 갱신
    if (on) {
        okStreak++;
        lastSeenOkMs = now;
    } else {
        okStreak = 0;
    }

    // 1) 아직 안 열린 상태에서, ok가 충분히 안정되면 한 번만 OPEN 전송
    if (!isOpen && okStreak >= OPEN_CONFIRM_FRAMES) {
        serial.write("OPEN\n");
        serial.flush();
        serial.waitForBytesWritten(10);
        isOpen = true;
        // OPEN 직후에는 바로 CLOSE가 나가지 않도록 lastSeenOkMs가 now로 찍혀 있음
        return;
    }

    // 2) 열린 상태에서, ok가 일정 시간(CLOSE_GRACE_MS) 동안 안 보이면 한 번만 CLOSE 전송
    if (isOpen && (now - lastSeenOkMs) > CLOSE_GRACE_MS) {
        serial.write("CLOSE\n");
        serial.flush();
        serial.waitForBytesWritten(10);
        isOpen = false;
        return;
    }

    // 그 외에는 아무 것도 보내지 않음 (스팸 방지)
}

 

sendSerial(bool on)

얼굴 인식 결과(on == true면 “열어도 됨”)를 받아 스팸 없이 OPEN/CLOSE 명령을 1회씩만 전송하도록 함.

내부 로직(프레임 기반 & 시간 기반):

OPEN_CONFIRM_FRAMES = 5

→ 열기 확정: 연속 5프레임(≈165ms) 동안 ok일 때 한 번만 OPEN\n 송신, isOpen = true.

CLOSE_GRACE_MS = 3000ms

→ 닫기 확정: 열린 상태에서 마지막 ok 이후 3초 동안 ok가 안 보이면 한 번만 CLOSE\n 송신, isOpen = false.

필요 시 포트가 닫혀 있으면 즉시 재오픈 시도(자동 탐색 + 9600).

'LMS 7 > 개발일지' 카테고리의 다른 글

25.09.09/ 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 5일차  (0) 2025.11.04
25.09.08 / 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 4일차  (0) 2025.11.04
25.08.13 / QT6 개인 프로젝트 1  (4) 2025.08.13
25.08.11 학습개발일지 / QT6 Chapter26, 28  (3) 2025.08.13
25.08.10 학습개발일지 / QT6 Chapter 10  (0) 2025.08.11
'LMS 7/개발일지' 카테고리의 다른 글
  • 25.09.09/ 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 5일차
  • 25.09.08 / 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 4일차
  • 25.08.13 / QT6 개인 프로젝트 1
  • 25.08.11 학습개발일지 / QT6 Chapter26, 28
m_Dev
m_Dev
  • m_Dev
    m_Dev
    m_Dev
  • 전체
    오늘
    어제
    • 분류 전체보기
      • MAIN STUDY
        • 정보보안
        • 빅데이터
        • 정보처리
        • 컴퓨터 구조
        • 기타
      • JOB
        • Study
        • Project
      • LMS 7
        • 개발일지
      • FRAMEWORK
        • Qt
        • MFC
        • Winform
        • WPF
        • MAUI
      • NETWORK
        • Study
        • Assignment
      • PYTHON
        • Set
        • Study
        • Assignment
        • Project
      • C
        • Set
        • Study
        • Assignment
        • Project
      • C++
        • Set
        • Study
        • Assignment
        • Project
      • C#
        • Set
        • Study
        • Assignment
        • Project
      • DATABASE
        • MySQL
        • SQLite
      • IDE
        • VisualStudioCode
        • VisualStudio
        • Pycharm
        • Colab
      • 기타
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
m_Dev
25.09.05 / 제60회 전국기능경기대회 전시 작품 제작 프로젝트 2팀(안전/보안) / 3일차
상단으로

티스토리툴바