오늘 만들 프로그램
이 단원에서 인공지능의 무엇을 배우고 확인할 수 있나요?
캡스톤은 새로운 AI 기술을 배우는 단원이 아니라, 이미 만든 서로 다른 AI 모델 3개를 하나의 시스템으로 연결하는 경험을 하는 단원입니다. 실제 현업의 AI 서비스(스마트 키오스크, 콜센터 AI, 자율주행 등)도 대부분 "하나의 거대한 AI"가 아니라, 목적이 다른 여러 모델이 파이프라인처럼 이어져 동작합니다. 이번 실습이 바로 그 구조를 작게 체험하는 것입니다.
사전 준비 확인하기
/home/pi/)를 기준으로 작성했습니다. 다른 사용자 계정이라면 경로를 변경해 주세요. 1단계 얼굴 모델은 코사인 유사도 기반 NPZ, 2단계 사물 모델은 MobileNetV2 ONNX 특징 추출 기반 NPZ입니다. 확장자를 변경하는 것만으로는 서로 호환되지 않습니다.아래 3가지가 모두 준비되어 있어야 캡스톤이 정상 작동합니다. 라즈베리파이 터미널에서 순서대로 확인하세요.
ls -lh /home/pi/face_recognition_raspi_app/trainer/face_model.npz
ls -lh /home/pi/object_recognition_raspi_app/model/object_model.npz /home/pi/object_recognition_raspi_app/model/mobilenetv2-7.onnx
if [ -n "$GEMINI_API_KEY" ]; then echo "Gemini API 키 설정됨"; else echo "Gemini API 키 미설정"; fi
1단계. 캡스톤 전용 폴더 만들기
cd $HOME mkdir ai_kiosk_capstone_app cd ai_kiosk_capstone_app ls -la
2단계. 라이브러리 설치 확인
1~3단계를 모두 완료했다면 아래 라이브러리가 이미 설치되어 있을 것입니다. 확인 차원에서 한 번 더 실행해도 안전합니다(이미 설치되어 있으면 그대로 넘어갑니다).
⚠️ 라즈베리파이 아키텍처와 OS 버전에 따라 이 설치 명령은 실패할 수 있습니다. Raspberry Pi OS 패키지와 가상환경 버전을 우선 확인하세요.
python3 -m pip install flask requests numpy opencv-python
python3 -c "import flask, requests, cv2, numpy; print('캡스톤 준비 완료')"
3단계. app.py 작성하기
cd $HOME/ai_kiosk_capstone_app nano app.py
from flask import Flask, request, render_template_string, Response
import os
import cv2
import numpy as np
import requests
import urllib.request
import time
import threading
app = Flask(__name__)
app.config["JSON_AS_ASCII"] = False
@app.after_request
def add_utf8_headers(response):
if response.mimetype == "text/html":
response.headers["Content-Type"] = "text/html; charset=utf-8"
return response
# 캡스톤: 1~3단계에서 만든 결과물을 그대로 재사용합니다.
USER_HOME = os.path.expanduser("~")
FACE_APP_DIR = os.path.join(USER_HOME, "face_recognition_raspi_app")
FACE_MODEL_PATH = os.path.join(FACE_APP_DIR, "trainer", "face_model.npz")
OBJECT_APP_DIR = os.path.join(USER_HOME, "object_recognition_raspi_app")
OBJECT_MODEL_PATH = os.path.join(OBJECT_APP_DIR, "model", "object_model.npz")
MOBILENET_PATH = os.path.join(OBJECT_APP_DIR, "model", "mobilenetv2-7.onnx")
FACE_SIMILARITY_THRESHOLD = 72.0
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
MODEL_NAME = "gemini-3.1-flash-lite"
GEMINI_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/"
f"{MODEL_NAME}:generateContent"
)
last_observation = "아직 통합 관찰을 하지 않았습니다."
last_face_text = ""
last_object_text = ""
last_image_url = None
last_debug_image_url = None
chat_history = []
PERSONAS = {
"admission_consultant": """
너는 '20년차 입시전략 컨설턴트' 역할의 교육용 AI야.
고등학생과 학부모의 진로·진학·내신·수능·학생부·면접 고민에
구체적이고 현실적인 실행 방법을 제시해.
합격을 보장하지 말고 최신 모집요강 확인이 필요한 정보는 그렇게 안내해.
민감한 개인정보를 요구하지 마.
""",
"einstein_physics": """
너는 '아인슈타인 물리 선생님'처럼 사고실험과 쉬운 비유를 활용하는 과학 멘토야.
고등학생 눈높이에서 핵심 개념 → 생활 속 예시 → 필요하면 쉬운 수식 순으로 설명해.
실제 아인슈타인의 발언을 지어내지 마.
""",
"youth_counselor": """
너는 '20년 경력 청소년 상담사' 역할의 안전한 상담형 AI야.
판단하지 말고 차분하게 경청하며, 상황을 정리한 뒤 현실적인 선택지를 제안해.
자해·폭력·학대·긴급 위험 신호가 있으면 보호자, 학교 상담교사,
119 또는 지역 긴급 지원에 도움을 요청하도록 안내해.
전문 의료인이나 실제 상담기관을 대신하지 않는다.
""",
"aristotle_philosopher": """
너는 아리스토텔레스의 관점으로 대화하는 철학 멘토야.
행복, 덕, 목적, 습관, 우정, 정의, 공동체를 고등학생 눈높이에 맞춰 설명해.
실제 저작의 문장을 지어내어 직접 인용하지 마.
""",
"gandalf_wizard": """
너는 경험 많은 회색 마법사처럼 말하는 판타지 멘토야.
학생의 공부, 진로, 친구관계, 프로젝트 고민을 하나의 퀘스트처럼 바꾸어 조언해.
작품의 대사를 길게 그대로 인용하지 마.
"""
}
def get_face_cascade():
xml_name = "haarcascade_frontalface_default.xml"
xml_path = cv2.data.haarcascades + xml_name
cascade = cv2.CascadeClassifier(xml_path)
if cascade.empty():
if not os.path.exists(xml_name):
url = "https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml"
urllib.request.urlretrieve(url, xml_name)
cascade = cv2.CascadeClassifier(xml_name)
return cascade
def make_face_feature(face_gray):
# 1단계 Raspberry Pi 얼굴인식 코드와 동일한 특징 벡터 생성
face_gray = cv2.resize(face_gray, (100, 100))
face_gray = cv2.equalizeHist(face_gray)
feature = face_gray.astype(np.float32).reshape(-1)
feature -= float(feature.mean())
norm = float(np.linalg.norm(feature))
if norm > 0:
feature /= norm
return feature
def load_face_model():
if not os.path.isfile(FACE_MODEL_PATH):
raise FileNotFoundError('얼굴 모델 없음: ' + FACE_MODEL_PATH)
with np.load(FACE_MODEL_PATH, allow_pickle=False) as data:
if not {'names', 'prototypes'}.issubset(data.files):
raise ValueError('얼굴 NPZ 구조가 1단계 모델과 다릅니다.')
return data['names'].copy(), data['prototypes'].copy()
def load_object_model():
if not os.path.isfile(OBJECT_MODEL_PATH):
raise FileNotFoundError('사물 모델 없음: ' + OBJECT_MODEL_PATH)
with np.load(OBJECT_MODEL_PATH, allow_pickle=False) as data:
if not {'names', 'features', 'labels', 'model_version'}.issubset(data.files):
raise ValueError('사물 NPZ 구조가 2단계 CNN 모델과 다릅니다.')
if int(data['model_version'][0]) != 3:
raise ValueError('사물인식 MobileNetV2 CNN v3 모델이 아닙니다.')
return data['names'].copy(), data['features'].copy(), data['labels'].copy()
_object_net = None
def get_object_net():
global _object_net
if _object_net is None:
if not os.path.isfile(MOBILENET_PATH):
raise FileNotFoundError('MobileNetV2 ONNX 파일 없음: ' + MOBILENET_PATH)
_object_net = cv2.dnn.readNetFromONNX(MOBILENET_PATH)
_object_net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
_object_net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
return _object_net
def extract_object_features(image):
# 2단계 Raspberry Pi CNN 교재의 전처리와 일치
h, w = image.shape[:2]
if h <= 0 or w <= 0:
raise ValueError('물체 영역 크기가 잘못되었습니다.')
size = 224
scale = min(size / float(w), size / float(h))
nw, nh = max(1, int(round(w * scale))), max(1, int(round(h * scale)))
resized = cv2.resize(image, (nw, nh), interpolation=cv2.INTER_AREA)
canvas = np.full((size, size, 3), 127, dtype=np.uint8)
x, y = (size - nw) // 2, (size - nh) // 2
canvas[y:y+nh, x:x+nw] = resized
rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
rgb = (rgb - mean) / std
blob = np.transpose(rgb, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
net = get_object_net()
net.setInput(blob)
feature = net.forward().reshape(-1).astype(np.float32)
norm = float(np.linalg.norm(feature))
if norm > 0:
feature /= norm
return feature
def predict_object(image, names, train_features, train_labels):
feature = extract_object_features(image)
sims = np.dot(train_features, feature)
scores = []
for i, name in enumerate(names):
cls = sims[train_labels == i]
if not cls.size:
continue
k = min(3, cls.size)
nearest = np.partition(cls, cls.size - k)[-k:]
scores.append((str(name), float(np.mean(nearest))))
scores.sort(key=lambda it: it[1], reverse=True)
if not scores:
raise ValueError('학습된 사물 클래스가 없습니다.')
margin = scores[0][1] - (scores[1][1] if len(scores)>1 else -1.0)
name = scores[0][0] if scores[0][1] >= 0.35 and margin >= 0.008 else 'UNKNOWN'
return name, scores[0][1] * 100.0, scores[:3]
# 사물인식은 2단계와 동일한 CNN 전처리를 사용하며 MediaPipe는 사용하지 않습니다.
# 실시간 웹캠 미리보기 상태 (미리보기를 켠 동안에만 카메라가 켜져 있습니다)
camera = None
latest_frame = None
streaming = False
camera_lock = threading.Lock()
def get_camera():
global camera
with camera_lock:
if camera is None or not camera.isOpened():
camera = cv2.VideoCapture(0, cv2.CAP_V4L2)
if not camera.isOpened():
camera.release()
camera = cv2.VideoCapture(0)
return camera
def release_camera():
global camera, streaming, latest_frame
streaming = False
with camera_lock:
if camera is not None:
camera.release()
camera = None
latest_frame = None
def gen_frames():
# 브라우저의 <img> 태그가 이 스트림을 계속 받아 "동영상처럼" 보여줍니다.
# 사용자가 미리보기를 끄거나 페이지를 벗어나 연결이 끊기면 finally에서 카메라를 끕니다.
global latest_frame, streaming
streaming = True
cam = get_camera()
try:
while streaming:
ok, frame = cam.read()
if not ok:
break
frame = cv2.flip(frame, 1)
latest_frame = frame
# 사물인식이 보는 중앙 네모 칸을 미리보기에도 실시간으로 그려 보여줍니다.
preview = frame.copy()
h, w = preview.shape[:2]
box = min(w, h) // 2
x1, y1 = w // 2 - box // 2, h // 2 - box // 2
x2, y2 = x1 + box, y1 + box
cv2.rectangle(preview, (x1, y1), (x2, y2), (255, 140, 0), 3)
cv2.putText(preview, "Put object here", (x1, max(0, y1 - 10)),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 140, 0), 2)
ok2, buffer = cv2.imencode(".jpg", preview)
if not ok2:
continue
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + buffer.tobytes() + b"\r\n")
finally:
release_camera()
def observe_everything():
# 캡처한 사진 위에 "어느 모델이 어디를 보고 뭐라고 판단했는지"를 직접 그려서
# static/observation.jpg로 저장합니다. 학생이 결과 화면에서 그 사진을 보면서
# 1단계(얼굴·초록/주황 박스)와 2단계(사물·파란/빨강 박스) 모델이 서로 다른
# 영역을, 서로 다른 방식으로 본다는 걸 눈으로 바로 확인할 수 있습니다.
if latest_frame is None:
error_text = "먼저 '🎥 미리보기 켜기'를 눌러 카메라를 켜고, 네모 칸에 물건을 둔 뒤 다시 시도해 주세요."
return {
"face_text": error_text,
"object_text": "",
"combined_text": error_text,
"image_url": None,
"debug_image_url": None
}
frame = latest_frame.copy()
annotated = frame.copy()
# 1) 얼굴인식 (1단계 Raspberry Pi 코사인 유사도 NPZ 모델 재사용)
try:
names, prototypes = load_face_model()
face_cascade = get_face_cascade()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(70,70))
if len(faces) == 0:
face_text = "👤 얼굴인식: 화면에 얼굴이 뚜렷하게 보이지 않습니다."
else:
x, y, w, h = max(faces, key=lambda r: r[2] * r[3])
feature = make_face_feature(gray[y:y+h, x:x+w])
raw_scores = np.dot(prototypes, feature)
index = int(np.argmax(raw_scores))
similarity = max(0.0, min(100.0, (float(raw_scores[index]) + 1.0) * 50.0))
known = similarity >= FACE_SIMILARITY_THRESHOLD
name = str(names[index]) if known else "등록되지 않은 사람"
face_text = f"👤 얼굴인식: {name} (유사도 {similarity:.0f}%, 확률이 아닌 유사도 점수)"
face_color = (0,255,0) if known else (0,0,255)
cv2.rectangle(annotated,(x,y),(x+w,y+h),face_color,3)
cv2.putText(annotated,"FACE",(x,max(0,y-12)),cv2.FONT_HERSHEY_SIMPLEX,0.7,face_color,2)
except Exception as e:
face_text = f"👤 얼굴인식 모델을 확인해 주세요: {e}"
# 2) 사물인식 (2단계 Raspberry Pi MobileNetV2 CNN v3 NPZ 재사용)
h, w = frame.shape[:2]
box = min(w, h) // 2
x1, y1 = w//2-box//2, h//2-box//2
x2, y2 = x1+box, y1+box
debug_image_url = None
roi = frame[y1:y2, x1:x2]
try:
names, train_features, train_labels = load_object_model()
# 원본 학습 모델의 CNN 전처리와 동일하게 처리합니다.
# 기존 다른 알고리즘의 MediaPipe 배경 제거는 여기서 사용하지 않습니다.
os.makedirs("static", exist_ok=True)
cv2.imwrite(os.path.join("static", "object_debug.jpg"), roi)
debug_image_url = "/static/object_debug.jpg?t=" + str(int(time.time()))
name, score, top3 = predict_object(roi, names, train_features, train_labels)
others = ", ".join(f"{n} {max(0,v*100):.0f}%" for n,v in top3[1:])
object_text = (f"📦 사물인식(CNN): '{name}' (유사도 {score:.1f}%)"
+ (f" / 다음 후보: {others}" if others else "")
+ " ※ 유사도는 확률이 아닙니다.")
obj_color = (255,100,0) if name != 'UNKNOWN' else (0,0,255)
except Exception as e:
object_text = f"📦 사물인식 모델 또는 MobileNetV2 ONNX를 확인해 주세요: {e}"
obj_color = (255,255,255)
cv2.rectangle(annotated,(x1,y1),(x2,y2),obj_color,3)
cv2.putText(annotated,"OBJECT",(x1,max(0,y1-12)),cv2.FONT_HERSHEY_SIMPLEX,0.6,obj_color,2)
combined_text = face_text + " " + object_text
os.makedirs("static", exist_ok=True)
cv2.imwrite(os.path.join("static", "observation.jpg"), annotated)
return {
"face_text": face_text,
"object_text": object_text,
"combined_text": combined_text,
"image_url": "/static/observation.jpg?t=" + str(int(time.time())),
"debug_image_url": debug_image_url
}
def build_prompt(persona_text, observation, user_message):
history_text = ""
for speaker, msg in chat_history[-8:]:
history_text += f"{speaker}: {msg}/n"
return f"""
너는 아래 페르소나 역할을 유지하면서 고등학생과 역할대화를 한다.
이 앱은 얼굴인식 + 사물인식 + 페르소나 대화를 하나로 합친 캡스톤 프로젝트다.
[페르소나 역할]
{persona_text}
[웹캠 통합 관찰 결과 - 얼굴인식 + 사물인식]
{observation}
[관찰 결과 사용 규칙]
- 관찰 결과에 적힌 범위 안에서만 자연스럽게 반응해.
- 얼굴인식 결과가 "등록되지 않은 사람"이면 이름을 함부로 추측하지 마.
- 사물인식 결과는 100% 정확하지 않을 수 있으니 "~로 보입니다"처럼 표현해.
- 성별, 나이, 신원, 실제 감정 상태는 추정하지 마.
- 답변은 5문장 이내로 해.
- 위험하거나 민감한 고민은 보호자, 학교 상담교사, 관련 전문가에게 도움을 요청하도록 안내해.
[이전 대화]
{history_text}
[사용자 입력]
{user_message}
"""
def call_gemini_with_retry(headers, data, max_retries=3, wait_seconds=4):
# Gemini 서버가 일시적으로 혼잡할 때(429/500/503 등) 몇 초 기다렸다가
# 자동으로 다시 시도합니다.
last_response = None
last_exception = None
for attempt in range(max_retries):
try:
response = requests.post(GEMINI_URL, headers=headers, json=data, timeout=30)
except Exception as e:
last_exception = e
time.sleep(wait_seconds)
continue
if response.status_code == 200:
return response, None
last_response = response
if response.status_code in (429, 500, 502, 503, 504):
time.sleep(wait_seconds)
continue
break
if last_response is not None:
return None, f"Gemini API 오류: {last_response.status_code}/n{last_response.text}"
if last_exception is not None:
return None, f"Gemini 호출 중 오류가 발생했습니다: {last_exception}"
return None, "Gemini 호출에 실패했습니다."
def ask_gemini(persona_text, observation, user_message):
if not GEMINI_API_KEY:
return "GEMINI_API_KEY가 설정되지 않았습니다. 3단계(페르소나)에서 설정한 환경변수를 확인해 주세요."
headers = {
"Content-Type": "application/json",
"x-goog-api-key": GEMINI_API_KEY
}
data = {
"contents": [
{"parts": [{"text": build_prompt(persona_text, observation, user_message)}]}
]
}
try:
response, error_message = call_gemini_with_retry(headers, data)
if error_message:
return error_message
result = response.json()
return result["candidates"][0]["content"]["parts"][0]["text"]
except Exception as e:
return f"Gemini 호출 중 오류가 발생했습니다: {e}"
HTML = """
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI 안내 키오스크 - 캡스톤</title>
<style>
body{margin:0;font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif;background:#f3f6fb;color:#172033}
.container{max-width:920px;margin:28px auto;background:white;border-radius:22px;padding:30px;box-shadow:0 8px 28px rgba(0,0,0,.12)}
h1{text-align:center;margin-top:0}
.notice{background:#eef5ff;padding:16px;border-radius:14px;line-height:1.7;margin-bottom:20px}
label{font-weight:800;display:block;margin-top:18px;margin-bottom:8px}
select,textarea{width:100%;box-sizing:border-box;border:1px solid #cfd6e4;border-radius:12px;padding:12px;font-size:16px}
textarea{min-height:95px}
button{margin-top:15px;padding:14px 18px;border:none;border-radius:12px;font-size:16px;cursor:pointer;background:#4338ca;color:white;font-weight:800}
.sub-button{background:#5b667a}
.row{display:flex;gap:10px;flex-wrap:wrap}
.observation{background:#fff7e6;border-radius:14px;padding:16px;line-height:1.7;margin:18px 0}
.obs-photo{width:100%;border-radius:14px;margin:14px 0;border:1px solid #e2e8f0;display:block}
.obs-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:10px 0}
.obs-card{border-radius:14px;padding:12px 14px;line-height:1.6;font-size:14px}
.obs-face{background:#eafaf0;border:2px solid #16a34a}
.obs-object{background:#eef4ff;border:2px solid #2563eb}
.obs-legend{font-size:12px;color:#667085;margin-top:4px}
@media(max-width:560px){.obs-grid{grid-template-columns:1fr}}
.chat-box{background:#f7f8fc;border-radius:14px;padding:18px;margin-top:20px;min-height:120px}
.msg{padding:12px;margin:8px 0;border-radius:12px;line-height:1.65;white-space:pre-wrap}
.user{background:#e8edff}
.ai{background:#e8fff1}
.voice-panel{margin-top:12px;padding:14px;border-radius:14px;background:#f0f5ff;border:1px solid #d7e1ff}
.voice-button{background:#8a3ffc}
.stop-button{background:#d94b4b}
.voice-status{display:inline-block;margin-left:10px;font-size:14px;color:#526079}
.voice-option{display:flex;align-items:center;gap:8px;margin-top:12px;font-weight:700}
.voice-option input{width:auto}
.voice-select{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
.voice-select label{font-size:13px;color:#526079}
.voice-select select{width:100%;margin-top:5px}
.voice-note{margin-top:8px;font-size:12px;color:#667085}
</style>
</head>
<body>
<div class="container">
<h1>🧩 AI 안내 키오스크 (캡스톤)</h1>
<div class="notice">
이 앱은 1단계 얼굴인식 모델과 2단계 사물인식 모델을 그대로 불러와 사용합니다.<br>
웹캠 사진은 외부로 전송하지 않고, 노트북 내부에서만 분석한 뒤 결과 텍스트만 Gemini에게 전달합니다.<br>
마이크 버튼으로 음성 질문도 할 수 있고, AI 답변은 스피커로 읽어줄 수 있습니다.
</div>
<form method="post">
<label>페르소나 역할 선택</label>
<select name="role">
<option value="admission_consultant">20년차 입시전략 컨설턴트</option>
<option value="einstein_physics">아인슈타인 물리 선생님</option>
<option value="youth_counselor">20년 경력 청소년 상담사</option>
<option value="aristotle_philosopher">아리스토텔레스 철학 멘토</option>
<option value="gandalf_wizard">경험 많은 회색 마법사</option>
</select>
<label>직접 만든 역할 프롬프트 (선택)</label>
<textarea name="custom_persona" placeholder="3단계에서 만든 나만의 역할 프롬프트를 붙여넣어도 됩니다.">{{ custom_persona }}</textarea>
<div class="row">
<button type="button" id="startPreviewBtn">🎥 미리보기 켜기</button>
<button class="sub-button" type="submit" name="action" value="reset">대화 초기화</button>
</div>
<div id="previewBox" style="display:none">
<img id="livePreview" class="obs-photo" alt="실시간 웹캠 미리보기">
<div class="obs-legend">주황 네모 칸 안에 물건을 놓고 카메라를 바라보세요. 준비되면 아래 버튼을 누르세요.</div>
<div class="row">
<button type="submit" name="action" value="scan" id="captureBtn">📸 지금 모습으로 확인하기</button>
<button class="sub-button" type="button" id="stopPreviewBtn">⏹ 미리보기 끄기</button>
</div>
</div>
<div class="observation">
<strong>통합 관찰 결과 (얼굴인식 + 사물인식)</strong>
{% if image_url %}
<img class="obs-photo" src="{{ image_url }}" alt="캡처된 관찰 사진">
<div class="obs-legend">🟩 초록/주황 네모 = 1단계 얼굴인식(코사인 유사도) 모델이 본 영역 · 🟦 파란/빨강 네모 = 2단계 사물인식(CNN) 모델이 본 영역(화면 중앙 고정)</div>
{% endif %}
{% if debug_image_url %}
<div class="obs-legend">🔍 아래는 "MobileNetV2 CNN 입력으로 사용한 사물 영역 이미지"입니다. 2단계 MobileNetV2 전처리에 들어가는 원본 물체 영역을 보여 않은 것입니다(물체 인식 결과 문구에서도 확인할 수 있어요).</div>
<img class="obs-photo" src="{{ debug_image_url }}" alt="사물인식 모델이 실제로 본 이미지(CNN 입력 확인)" style="max-width:220px;">
{% endif %}
<div class="obs-grid">
<div class="obs-card obs-face">{{ face_text }}</div>
<div class="obs-card obs-object">{{ object_text }}</div>
</div>
</div>
<label>역할대화 입력</label>
<textarea id="messageInput" name="message" placeholder="예: 내가 들고 있는 물건이 뭐야? / 나 알아보겠어? / 오늘 미션을 줘"></textarea>
<div class="voice-panel">
<div class="row">
<button id="micButton" class="voice-button" type="button">🎤 음성으로 말하기</button>
<button id="stopVoiceButton" class="stop-button" type="button">⏹ 음성 중지</button>
</div>
<span id="voiceStatus" class="voice-status">마이크 대기 중</span>
<div class="voice-select">
<label>음성 선택 방식
<select id="voiceMode">
<option value="auto" selected>역할별 자동 선택</option>
<option value="manual">직접 선택</option>
</select>
</label>
<label>사용할 목소리
<select id="voiceSelect"></select>
</label>
</div>
<div class="voice-note">자동 선택은 역할에 따라 음성 속도·높낮이를 다르게 적용합니다.</div>
<label class="voice-option">
<input id="autoSpeak" type="checkbox" checked>
AI 답변 자동으로 읽기
</label>
</div>
<button id="chatButton" type="submit" name="action" value="chat">페르소나와 대화하기</button>
</form>
<div class="chat-box">
<strong>대화 내용</strong>
{% for speaker, msg in chat_history %}
{% if speaker == "사용자" %}
<div class="msg user"><strong>사용자</strong><br>{{ msg }}</div>
{% else %}
<div class="msg ai"><strong>AI 페르소나</strong><br>{{ msg }}</div>
{% endif %}
{% endfor %}
</div>
</div>
<script>
const startPreviewBtn = document.getElementById('startPreviewBtn');
const stopPreviewBtn = document.getElementById('stopPreviewBtn');
const previewBox = document.getElementById('previewBox');
const livePreview = document.getElementById('livePreview');
startPreviewBtn.addEventListener('click', () => {
livePreview.src = '/video_feed?t=' + Date.now();
previewBox.style.display = 'block';
startPreviewBtn.style.display = 'none';
});
stopPreviewBtn.addEventListener('click', () => {
fetch('/stop_feed', { method: 'POST' }).catch(() => {});
livePreview.src = '';
previewBox.style.display = 'none';
startPreviewBtn.style.display = 'inline-block';
});
const messageInput = document.getElementById('messageInput');
const micButton = document.getElementById('micButton');
const stopVoiceButton = document.getElementById('stopVoiceButton');
const chatButton = document.getElementById('chatButton');
const voiceStatus = document.getElementById('voiceStatus');
const autoSpeak = document.getElementById('autoSpeak');
const voiceMode = document.getElementById('voiceMode');
const voiceSelect = document.getElementById('voiceSelect');
const roleSelect = document.querySelector('select[name="role"]');
let availableVoices = [];
let recognition = null;
let recognizing = false;
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.interimResults = true;
// 스마트폰(특히 Android)은 continuous:true 상태에서 같은 구간을 중복 인식해
// 단어가 겹쳐 입력되는 경우가 있습니다. 그래서 매번 "한 구간(continuous:false)"만
// 정확하게 인식시키고, 사용자가 끝내기 전까지는 onend에서 자동으로 재시작해 이어붙입니다.
recognition.continuous = false;
let finalTranscript = '';
let keepListening = false;
recognition.onstart = () => {
recognizing = true;
voiceStatus.textContent = '🎙 듣고 있습니다. 하고 싶은 말을 다 한 뒤 마이크 버튼을 다시 눌러 끝내세요.';
micButton.textContent = '⏹ 듣기 종료하고 전송';
};
recognition.onresult = (event) => {
let currentText = '';
for (let i = 0; i < event.results.length; i++) {
currentText += event.results[i][0].transcript;
}
messageInput.value = (finalTranscript ? finalTranscript + ' ' : '') + currentText;
};
recognition.onerror = (event) => {
if (event.error === 'no-speech' && keepListening) {
return;
}
keepListening = false;
recognizing = false;
if (event.error === 'not-allowed') {
voiceStatus.textContent =
'⚠ 마이크 권한이 필요합니다. 브라우저 주소창의 마이크 권한을 허용하세요.';
} else if (event.error === 'no-speech') {
voiceStatus.textContent =
'말소리를 듣지 못했습니다. 다시 눌러 천천히 말씀하세요.';
} else {
voiceStatus.textContent =
'음성 인식 오류: ' + event.error;
}
micButton.textContent = '🎤 음성으로 말하기';
};
recognition.onend = () => {
if (keepListening) {
finalTranscript = messageInput.value.trim();
setTimeout(() => {
if (!keepListening) return;
try {
recognition.start();
} catch (e) {
keepListening = false;
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
}
}, 150);
return;
}
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
if (messageInput.value.trim()) {
voiceStatus.textContent = '✅ 음성 인식을 마쳤습니다. 잠시 후 질문을 전송합니다.';
setTimeout(() => chatButton.click(), 500);
} else {
voiceStatus.textContent = '마이크 대기 중';
}
};
micButton.addEventListener('click', () => {
window.speechSynthesis.cancel();
if (recognizing) {
keepListening = false;
recognition.stop();
return;
}
finalTranscript = '';
keepListening = true;
messageInput.value = '';
try {
recognition.start();
} catch (e) {
voiceStatus.textContent =
'음성 인식을 다시 시작해 주세요.';
}
});
} else {
micButton.disabled = true;
voiceStatus.textContent =
'이 브라우저에서는 음성 인식이 지원되지 않습니다. 최신 Chrome 또는 Edge를 사용하세요.';
}
function loadVoices() {
availableVoices = window.speechSynthesis.getVoices();
voiceSelect.innerHTML = '';
const korean = availableVoices.filter(v =>
(v.lang || '').toLowerCase().startsWith('ko')
);
const list = korean.length ? korean : availableVoices;
if (!list.length) {
const option = document.createElement('option');
option.textContent = '음성을 불러오는 중...';
voiceSelect.appendChild(option);
return;
}
list.forEach(v => {
const option = document.createElement('option');
option.value = v.name;
option.textContent = `${v.name} (${v.lang})`;
voiceSelect.appendChild(option);
});
}
const roleVoiceProfiles = {
admission_consultant: {rate:1.00, pitch:1.00, keywords:['SunHi','선희','Heami','혜미','Google 한국어','Korean']},
einstein_physics: {rate:0.90, pitch:0.86, keywords:['Heami','혜미','SunHi','선희','Korean']},
youth_counselor: {rate:0.92, pitch:1.05, keywords:['SunHi','선희','Google 한국어','Heami','혜미']},
aristotle_philosopher: {rate:0.84, pitch:0.80, keywords:['Heami','혜미','Korean','SunHi','선희']},
gandalf_wizard: {rate:0.80, pitch:0.72, keywords:['Heami','혜미','Korean','SunHi','선희']}
};
function findVoice(keywords) {
const korean = availableVoices.filter(v =>
(v.lang || '').toLowerCase().startsWith('ko')
);
const pool = korean.length ? korean : availableVoices;
for (const keyword of keywords) {
const found = pool.find(v =>
v.name.toLowerCase().includes(keyword.toLowerCase())
);
if (found) return found;
}
return pool[0] || null;
}
function currentProfile() {
const role = roleSelect ? roleSelect.value : '';
return roleVoiceProfiles[role] || {
rate:1.0, pitch:1.0,
keywords:['SunHi','선희','Google 한국어','Heami','혜미','Korean']
};
}
function updateVoiceSelection() {
if (!voiceSelect.options.length) return;
if (voiceMode.value === 'auto') {
const voice = findVoice(currentProfile().keywords);
if (voice) voiceSelect.value = voice.name;
voiceSelect.disabled = true;
} else {
voiceSelect.disabled = false;
}
}
loadVoices();
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => {
loadVoices();
updateVoiceSelection();
};
}
voiceMode.addEventListener('change', updateVoiceSelection);
if (roleSelect) roleSelect.addEventListener('change', updateVoiceSelection);
setTimeout(updateVoiceSelection, 300);
function speakText(text) {
if (!('speechSynthesis' in window) || !text.trim()) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
const profile = currentProfile();
if (voiceMode.value === 'manual') {
const voice = availableVoices.find(v => v.name === voiceSelect.value);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang || 'ko-KR';
} else {
utterance.lang = 'ko-KR';
}
utterance.rate = 1.0;
utterance.pitch = 1.0;
} else {
const voice = findVoice(profile.keywords);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang || 'ko-KR';
} else {
utterance.lang = 'ko-KR';
}
utterance.rate = profile.rate;
utterance.pitch = profile.pitch;
}
window.speechSynthesis.speak(utterance);
}
stopVoiceButton.addEventListener('click', () => {
if (recognition && recognizing) {
recognition.stop();
}
window.speechSynthesis.cancel();
voiceStatus.textContent = '⏹ 음성을 중지했습니다.';
});
window.addEventListener('load', () => {
const aiMessages = document.querySelectorAll('.msg.ai');
if (autoSpeak.checked && aiMessages.length > 0) {
const latest = aiMessages[aiMessages.length - 1].cloneNode(true);
const strong = latest.querySelector('strong');
if (strong) strong.remove();
const text = latest.textContent.trim();
if (text) {
setTimeout(() => speakText(text), 350);
}
}
});
</script>
</body>
</html>
"""
@app.route("/video_feed")
def video_feed():
return Response(gen_frames(), mimetype="multipart/x-mixed-replace; boundary=frame")
@app.route("/stop_feed", methods=["POST"])
def stop_feed():
release_camera()
return ("", 204)
@app.route("/", methods=["GET", "POST"])
def index():
global last_observation, last_face_text, last_object_text, last_image_url, last_debug_image_url, chat_history
role = request.form.get("role", "admission_consultant")
custom_persona = request.form.get("custom_persona", "").strip()
action = request.form.get("action", "")
persona_text = custom_persona or PERSONAS.get(role, PERSONAS["admission_consultant"])
if action == "scan":
result = observe_everything()
last_observation = result["combined_text"]
last_face_text = result["face_text"]
last_object_text = result["object_text"]
last_image_url = result["image_url"]
last_debug_image_url = result["debug_image_url"]
greeting_request = (
"웹캠 통합 관찰 결과(얼굴인식+사물인식)를 바탕으로 "
"페르소나 역할을 유지하면서 첫 인사를 해줘."
)
ai_answer = ask_gemini(persona_text, last_observation, greeting_request)
chat_history.append(("AI", ai_answer))
elif action == "chat":
message = request.form.get("message", "").strip()
if message:
chat_history.append(("사용자", message))
ai_answer = ask_gemini(persona_text, last_observation, message)
chat_history.append(("AI", ai_answer))
elif action == "reset":
chat_history = []
last_observation = "대화가 초기화되었습니다. 다시 통합 관찰을 시작할 수 있습니다."
last_face_text = ""
last_object_text = ""
last_image_url = None
last_debug_image_url = None
return render_template_string(
HTML,
custom_persona=custom_persona,
observation=last_observation,
face_text=last_face_text,
object_text=last_object_text,
image_url=last_image_url,
debug_image_url=last_debug_image_url,
chat_history=chat_history
)
if __name__ == "__main__":
# threaded=True가 꼭 필요합니다: 실시간 미리보기(스트리밍)와 페이지 요청을
# 동시에 처리해야 하기 때문입니다.
app.run(host="127.0.0.1", port=8502, threaded=True)
4단계. 실행과 사용 순서
cd $HOME/ai_kiosk_capstone_app python3 app.py
http://127.0.0.1:8502
- 페르소나 역할을 선택합니다 (3단계에서 만든 나만의 역할도 붙여넣을 수 있습니다).
- 🎥 미리보기 켜기를 누르면 실시간 웹캠 영상이 뜹니다. 화면의 주황 네모 칸 안에 물건을 천천히 위치시키면서 각도를 맞춥니다.
- 준비됐으면 📸 지금 모습으로 확인하기를 누릅니다. 그 순간의 모습이 캡처되고, 미리보기는 자동으로 꺼집니다(카메라도 함께 꺼집니다).
- 방금 찍힌 사진이 그대로 화면에 뜨고, 그 위에 초록/주황 네모(1단계 얼굴인식)와 파란/빨강 네모(2단계 사물인식)가 표시됩니다. 두 모델의 결과도 각각 색이 다른 카드에 따로 나옵니다.
- 마음에 안 들면 🎥 미리보기 켜기를 다시 눌러 몇 번이든 다시 찍을 수 있습니다. 미리보기만 끄고 싶다면(찍지 않고) ⏹ 미리보기 끄기를 누릅니다.
- 글 입력 또는 🎤 음성으로 말하기로 자유롭게 대화합니다. 시간 제한이 없으니 천천히 말하고, 다 말했으면 같은 버튼(⏹ 듣기 종료하고 전송)을 다시 눌러 직접 끝냅니다.
화면에 찍힌 사진을 꼭 확인하세요
이전 버전은 "글자 결과"만 보여줘서 실제로 얼굴·물건이 카메라에 제대로 잡혔는지 알 수 없었습니다. 이제는 방금 캡처된 사진이 그대로 화면에 표시되므로, 그 사진을 보면서 바로 확인할 수 있습니다.
| 박스 색 | 어떤 모델 | 의미 |
|---|---|---|
| 🟩 초록 | 1단계 얼굴인식(코사인 유사도) | 등록된 사람으로 인식됨 |
| 🟧 주황 | 1단계 얼굴인식(코사인 유사도) | 얼굴은 찾았지만 등록되지 않은 사람으로 판단됨 |
| 🟦 파란 | 2단계 사물인식(CNN) | 유사도 기준에 따라 인식으로 판단됨 (이 네모 안의 물건만 분석합니다) |
| 🟥 빨강 | 2단계 사물인식(CNN) | 미등록 또는 낮은 유사도 — 물건을 네모 중앙에 더 크게, 밝은 곳에서 보여주세요 |
배경 자동 제거 (MediaPipe, 캡스톤 전용 실험 기능)
사물인식이 배경 색까지 함께 분석해서 헷갈리는 문제를 줄이기 위해, 사물인식 직전에만 손(사람)으로 보이는 부분만 남기고 배경을 회색으로 지우는 기능을 추가했습니다(Zoom 배경 흐림과 비슷한 원리지만, "흐리게"가 아니라 "지우기"입니다).
- MediaPipe의 "사람 분리" 기능은 사람 전신·얼굴 기준으로 학습된 모델이라, 손에 든 작은 물건까지 항상 정확히 "사람의 일부"로 포함해주지는 않습니다. 물건에 따라 효과가 다를 수 있습니다.
- 이 기능은 지금 촬영하는 화면에만 적용되고, 2단계에서 이미 학습해 둔 모델(사진들)에는 적용되어 있지 않습니다. 즉 "배경이 없는 사진"으로 비교하는 게 아니라 "배경이 있는 학습 사진"과 "배경을 지운 지금 사진"을 비교하는 셈이라, 오히려 결과가 달라지기만 할 수도 있습니다.
- 가장 확실한 개선은 여전히 2단계에서 손에 든 모습으로 재학습하는 것입니다. 이 기능은 그 전까지 임시로 시도해볼 수 있는 실험적 보조 수단입니다.
디지털 리터러시: "AI 기능 추가"가 항상 정답은 아니다
잘 안 될 때
| 증상 | 해결 |
|---|---|
| "얼굴인식 모델이 없습니다" 메시지 | 1단계 얼굴인식 실습을 먼저 완료해 trainer/face_model.npz을 만듭니다. |
| "사물인식 모델이 없습니다" 메시지 | 2단계 사물인식 실습을 먼저 완료해 model/object_model.npz을 만듭니다. |
| "GEMINI_API_KEY가 설정되지 않았습니다" 메시지 | 3단계 페르소나 실습의 export GEMINI_API_KEY 단계를 다시 확인합니다. |
| Gemini API 오류: 503 / 429 / 500 | 내 설정 문제가 아니라 Google Gemini 서버가 일시적으로 혼잡하다는 뜻입니다. 이 앱은 자동으로 몇 초 간격으로 최대 3번 재시도합니다. 계속되면 1~2분 후 다시 시도하세요. |
| 웹캠이 안 열림 | 라즈베리파이의 USB 웹캠(/dev/video0) 연결 및 카메라 점유 프로그램을 확인하고 다시 실행합니다. CSI 카메라는 별도 Picamera2 연동이 필요할 수 있습니다. |
| 사물인식 결과가 부정확함 | 관찰 결과에 뜨는 사진을 확인하세요. 파란/빨강 네모 칸 안에 물건이 들어있지 않으면 애초에 인식 대상이 아닙니다. 물건을 그 네모 중앙에 크게, 2단계 학습 때와 비슷한 밝기·각도로 보여주세요. |
| 얼굴은 인식되는데 사물만 계속 틀림(또는 반대) | 정상적인 상황입니다 — 두 모델은 서로 다른 데이터로 따로 학습된 완전히 독립적인 모델이라, 한쪽이 잘된다고 다른 쪽도 잘되는 건 아닙니다. 사진의 네모 색으로 어느 모델이 문제인지 구분해서 그 단계(1단계 또는 2단계)의 데이터를 보강하세요. |
| "🎥 미리보기 켜기"를 눌러도 영상이 안 뜸 | app.py 마지막 줄이 threaded=True로 되어 있는지 확인하세요. 이게 빠지면 미리보기와 페이지 요청이 동시에 처리되지 않아 멈춥니다. 코드를 다시 복사해 저장한 뒤 재실행하세요. |
| 미리보기는 되는데 "📸 지금 모습으로 확인하기"를 눌러도 "먼저 미리보기를 켜주세요" 메시지가 나옴 | 미리보기를 켠 직후 너무 빨리 눌렀을 수 있습니다. 영상이 실제로 움직이는 걸 1~2초 확인한 뒤 눌러보세요. |
| 미리보기 화면이 느리거나 끊김 | 노트북 성능·Wi-Fi와는 무관하며(로컬 통신), 웹캠 자체 프레임 속도나 다른 프로그램이 카메라를 같이 쓰고 있는지 확인하세요(Zoom, Teams 등을 닫습니다). |
| ModuleNotFoundError: No module named 'mediapipe' | python3 -m pip install opencv-python를 실행한 뒤 다시 실행하세요. |
| mediapipe 설치가 계속 실패함 | 간혹 아주 최신 Python 버전에서 mediapipe가 아직 지원되지 않을 수 있습니다. 이 교재 전체가 Python 3.12.10 기준이므로 python3 -m pip install opencv-python로 정확히 3.12 환경에 설치되는지 확인하세요. |
| AttributeError: module 'mediapipe' has no attribute 'solutions' | mediapipe 버전에 따라 접근 방식이 달라 생기던 오류로, 코드에 이미 대체 경로와 안전장치를 넣어뒀습니다. 최신 코드로 다시 복사해 저장하면 이 오류 없이 실행되며, 혹시 두 방식이 모두 실패하더라도 앱이 멈추지 않고 배경 제거 기능만 자동으로 꺼진 채 나머지는 정상 작동합니다(터미널에 안내 문구가 출력됩니다). |
| 8502 포트를 이미 사용 중이라는 오류 | 이전에 실행한 app.py 창을 Ctrl+C로 완전히 종료한 뒤 다시 실행합니다. |
수업 끝나고 전체 삭제하기
cd $HOME rm -r ai_kiosk_capstone_app ls -la