인증용 프로그램 정리
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 |