평가요소

TDL
기획-주제 선정
현재 가지고 있는 키트로 가능한 프로젝트 조사
아이디어 구현에 필요한 추가 소자 조사
ㄴ학원에서 지원(디바이스마트에서 구매 가능, 재고 있는 물품)
발표자 == 조장
소스 코드(파일) 및 시연 영상(PPT, 3~7분 사이)
보고서(설계, 구현, 테스트 결과)
PPT
프로젝트 개요
팀 구성 및 역할
수행절차(경과)(일 단위 구분(프로젝트 기획, 파트별 설계 및 구현, 기능 통합 및 테스트, 피드백))
결과(이미지+ 시연 영상)
자체평가
주제: 자동차 실내 환경 알림
온습도
LED
디스플레이
미세먼지
초음파센서
배터리 모듈
케이블
우노 보드
브레드 보드
저항
블루투스 모듈
소리 모듈
주제: 물류센터 자율주행 로봇용 보조 안전 모듈 및 데이터 분석
요약: 본 프로젝트는 자율주행 로봇의 실시간 충돌 회피 기능을 보완하는 것이 아니라,
근접 위험 데이터를 수집·분석하여 물류센터의 안전 관리 효율을 향상시키는 보조 분석 모듈을 구현하는 것이다.
물류센터의 자율주행 로봇(AMR)은 LiDAR 및 센서를 통해 실시간 충돌 회피 기능을 수행한다.
그러나 이러한 시스템은 ‘즉각적 위험 회피’에 초점을 두고 있으며,
근접 위험 상황(Near-Miss)에 대한 장기적 데이터 축적 및 분석 기능은 제한적이다.
본 프로젝트는 기존 자율주행 로봇의 제어 시스템을 대체하는 것이 아니라,
근접 위험 이벤트를 수집·분석하는 보조 데이터 레이어를 추가하는 것을 목표로 한다.
문제 정의
물류센터 환경에서는 다음과 같은 특성이 존재한다.
- 작업자와 로봇의 동선이 반복적으로 교차한다.
- 실제 충돌 사고는 적지만, 근접 위험(Near-Miss)은 빈번하다.
- 위험은 특정 구역 및 시간대에 집중적으로 발생하는 경향이 있다.
기존 시스템은 충돌은 방지하지만,
어디에서 위험이 반복적으로 발생하는지에 대한 구조적 분석은 부족하다.
제안 시스템의 역할
본 시스템은 자율주행 로봇에 장착 가능한 저비용 모듈로서,
- 일정 거리 이하 접근 이벤트 기록
- 작업자 감지 기반 위험 가중치 부여
- 급정지 및 급감속 이벤트 기록
- 시간대별 Near-Miss 데이터 수집
을 수행한다.
이 데이터를 기반으로 다음을 도출한다.
- 위험 발생 밀집 구역
- 시간대별 위험도 분포
- 반복적 근접 이벤트 패턴
- 운영 환경 개선 필요 지점
핵심 목적
본 시스템의 목적은
실시간 회피 효율을 높이는 것이 아니라,
“위험이 가장 빈번하게 발생하는 구역을 데이터 기반으로 식별하고,
안전 관리 자원을 효율적으로 배치할 수 있도록 지원하는 것”이다.
즉,
- 충돌을 막는 제어 시스템(Control Layer)이 아니라
- 위험 패턴을 분석하는 데이터 분석 레이어(Analytics Layer)이다.
| 품명 | 구성 | 가격 | 수량 | 구매링크 |
| 주행로봇 프레임 세트 | 5V TT 기어 DC 모터 PH2.0 * 4 바퀴 * 4 프레임 아크릴 보드 상하판 * 2 배터리 홀더(배터리 X) |
8300 | 1 | https://www.devicemart.co.kr/goods/view?no=1327455 |
| 모터드라이버 모듈 | L293D Motor Shield Module | 15000 | 1 | https://www.devicemart.co.kr/goods/view?no=14963802 |
| 배터리 | 18650 리튬배터리 3.7V 2200mAh | 3200 | 2 | https://www.devicemart.co.kr/goods/view?no=14117576 |
| 배터리 홀더 | 18650x2 리튬이온 배터리 홀더 | 990 | 1 | https://www.devicemart.co.kr/goods/view?no=1278962 |
| 충전기 | 건전지충전기(18650/AA/AAA용) (5핀 소켓 사용) |
6000 | 1 | https://www.devicemart.co.kr/goods/view?no=1279297 |
| 스위치 | KCD1-101A 빨강 ON/OFF | 300 | 1 | https://www.devicemart.co.kr/goods/view?no=1790 |
| 양면테이프 (초음파 고정용) |
Coms 강력양면테이프, 21mm | 1600 | 1 | https://www.devicemart.co.kr/goods/view?no=1342929 |
| 감지센서 | HC-SR501 인체감지센서 모듈 | 0 | 0 | X |
| 아두이노 우노 | Arduino Uno R3 | 0 | 0 | X |
| 서보 모터 | SG90 Servo Motor | 0 | 0 | X |
| 초음파 거리센서 모듈 | HC-SR04 | 0 | 0 | X |
| 블루투스 모듈 | HC-06 블루투스 Slave 모듈 | 0 | 0 | X |
| 부저 모듈 | —------------------------------------------ | 0 | 0 | X |
| LED | (red, green, yellow) | 0 | 0 | X |
| LCD | LCD 1602 IIC 모듈 | 0 | 0 | X |
| 저항 | 220 Ω | 0 | 0 | X |
| 브레드보드 | —------------------------------------------ | 0 | 0 | X |
| 점퍼선 | FF, MF, 모터드라이브용 | 0 | 0 | X |
| 총 가격 | 38500 | |||
자율주행 로봇용 코드
#include "AFMotor.h"
#include <Servo.h>
#define echopin A4 // echo pin
#define trigpin A5 // Trigger pin
Servo myservo;
const int MOTOR_1 = 1;
const int MOTOR_2 = 2;
const int MOTOR_3 = 3;
const int MOTOR_4 = 4;
AF_DCMotor motor1(MOTOR_1, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor2(MOTOR_2, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor3(MOTOR_3, MOTOR12_64KHZ); // create motor object, 64KHz pwm
AF_DCMotor motor4(MOTOR_4, MOTOR12_64KHZ); // create motor object, 64KHz pwm
//===============================================================================
// Initialization
//===============================================================================
int distance_L, distance_F, distance_R;
long distance;
int set = 20;
void setup() {
Serial.begin(9600); // Initialize serial port
Serial.println("Start");
myservo.attach(10);
myservo.write(90);
pinMode (trigpin, OUTPUT);
pinMode (echopin, INPUT );
motor1.setSpeed(180); // set the motor speed to 0-255
motor2.setSpeed(180);
motor3.setSpeed(180);
motor4.setSpeed(180);
}
//===============================================================================
// Main
//===============================================================================
void loop() {
distance_F = data();
Serial.print("S=");
Serial.println(distance_F);
if (distance_F > set){
Serial.println("Forward");
motor1.run(FORWARD); // turn it on going forward
motor2.run(FORWARD);
motor3.run(FORWARD);
motor4.run(FORWARD);
}
else{hc_sr4();}
}
long data(){
digitalWrite(trigpin, LOW);
delayMicroseconds(2);
digitalWrite(trigpin, HIGH);
delayMicroseconds(10);
distance = pulseIn (echopin, HIGH);
return distance / 29 / 2;
}
void compareDistance(){
if (distance_L > distance_R){
motor1.run(BACKWARD); // turn it on going left
motor2.run(BACKWARD);
motor3.run(FORWARD);
motor4.run(FORWARD);
delay(350);
}
else if (distance_R > distance_L){
motor1.run(FORWARD); // the other right
motor2.run(FORWARD);
motor3.run(BACKWARD);
motor4.run(BACKWARD);
delay(350);
}
else{
motor1.run(BACKWARD); // the other way
motor2.run(BACKWARD);
motor3.run(BACKWARD);
motor4.run(BACKWARD);
delay(300);
motor1.run(BACKWARD); // turn it on going left
motor2.run(BACKWARD);
motor3.run(FORWARD);
motor4.run(FORWARD);
delay(500);
}
}
void hc_sr4(){
Serial.println("Stop");
motor1.run(RELEASE); // stopped
motor2.run(RELEASE);
motor3.run(RELEASE);
motor4.run(RELEASE);
myservo.write(0);
delay(300);
distance_R = data();
delay(100);
myservo.write(170);
delay(500);
distance_L = data();
delay(100);
myservo.write(90);
delay(300);
compareDistance();
}
참고
https://www.youtube.com/watch?v=G0NQQ8GoSJU









GUI 임시 코드
"""
물류센터 자율주행 로봇 안전 분석 시스템
Near-Miss Analytics Dashboard - PyQt5 GUI 전체 틀
"""
import sys
import random
from datetime import datetime, timedelta
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter,
QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QPushButton, QTableWidget, QTableWidgetItem,
QTabWidget, QGroupBox, QStatusBar, QComboBox,
QDateEdit, QCheckBox, QHeaderView, QFrame,
QScrollArea, QSizePolicy, QProgressBar, QSpacerItem,
QDialog, QDialogButtonBox, QLineEdit, QFormLayout,
QMessageBox, QAction, QMenuBar, QSystemTrayIcon, QMenu
)
from PyQt5.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QDate, QSize, QPropertyAnimation
)
from PyQt5.QtGui import (
QColor, QFont, QPalette, QBrush, QIcon, QPainter,
QLinearGradient, QPixmap
)
# ── matplotlib 임베드를 위한 임포트 (pip install matplotlib 필요) ──────────────
try:
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 색상 & 스타일 상수
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COLOR = {
"BG_DARK": "#0d1117",
"BG_PANEL": "#161b22",
"BG_CARD": "#1c2128",
"BORDER": "#30363d",
"TEXT_PRIMARY": "#e6edf3",
"TEXT_MUTED": "#8b949e",
"ACCENT": "#58a6ff",
"SAFE": "#3fb950",
"CAUTION": "#d29922",
"NEAR_MISS": "#f0883e",
"HUMAN_RISK": "#da3633",
"HUMAN_NM": "#ff4444",
"EMERGENCY": "#ff0000",
}
RISK_COLOR = {
"SAFE": COLOR["SAFE"],
"CAUTION": COLOR["CAUTION"],
"NEAR_MISS": COLOR["NEAR_MISS"],
"HUMAN_RISK": COLOR["HUMAN_RISK"],
"HUMAN_NEAR_MISS": COLOR["HUMAN_NM"],
"EMERGENCY_STOP": COLOR["EMERGENCY"],
}
RISK_SCORE = {
"SAFE": 0, "CAUTION": 1, "NEAR_MISS": 3,
"HUMAN_RISK": 5, "HUMAN_NEAR_MISS": 8, "EMERGENCY_STOP": 4,
}
GLOBAL_STYLESHEET = f"""
QMainWindow, QWidget {{
background-color: {COLOR['BG_DARK']};
color: {COLOR['TEXT_PRIMARY']};
font-family: 'Consolas', 'D2Coding', 'Courier New', monospace;
font-size: 13px;
}}
QTabWidget::pane {{
border: 1px solid {COLOR['BORDER']};
background-color: {COLOR['BG_PANEL']};
border-radius: 6px;
}}
QTabBar::tab {{
background-color: {COLOR['BG_DARK']};
color: {COLOR['TEXT_MUTED']};
border: 1px solid {COLOR['BORDER']};
padding: 8px 20px;
margin-right: 2px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}}
QTabBar::tab:selected {{
background-color: {COLOR['BG_PANEL']};
color: {COLOR['ACCENT']};
border-bottom: 2px solid {COLOR['ACCENT']};
}}
QTableWidget {{
background-color: {COLOR['BG_DARK']};
gridline-color: {COLOR['BORDER']};
border: 1px solid {COLOR['BORDER']};
border-radius: 4px;
selection-background-color: #264f78;
}}
QTableWidget::item {{
padding: 6px 10px;
border-bottom: 1px solid {COLOR['BORDER']};
}}
QHeaderView::section {{
background-color: {COLOR['BG_CARD']};
color: {COLOR['TEXT_MUTED']};
border: none;
border-bottom: 2px solid {COLOR['BORDER']};
padding: 8px 10px;
font-weight: bold;
letter-spacing: 1px;
text-transform: uppercase;
}}
QPushButton {{
background-color: {COLOR['BG_CARD']};
color: {COLOR['TEXT_PRIMARY']};
border: 1px solid {COLOR['BORDER']};
border-radius: 6px;
padding: 7px 16px;
font-size: 12px;
}}
QPushButton:hover {{
background-color: #2d333b;
border-color: {COLOR['ACCENT']};
color: {COLOR['ACCENT']};
}}
QPushButton:pressed {{
background-color: #1f6feb;
border-color: {COLOR['ACCENT']};
}}
QComboBox {{
background-color: {COLOR['BG_CARD']};
border: 1px solid {COLOR['BORDER']};
border-radius: 4px;
padding: 5px 10px;
color: {COLOR['TEXT_PRIMARY']};
}}
QComboBox::drop-down {{ border: none; width: 20px; }}
QComboBox QAbstractItemView {{
background-color: {COLOR['BG_CARD']};
border: 1px solid {COLOR['BORDER']};
selection-background-color: #264f78;
}}
QDateEdit {{
background-color: {COLOR['BG_CARD']};
border: 1px solid {COLOR['BORDER']};
border-radius: 4px;
padding: 5px 10px;
color: {COLOR['TEXT_PRIMARY']};
}}
QScrollBar:vertical {{
background: {COLOR['BG_DARK']};
width: 8px;
border-radius: 4px;
}}
QScrollBar::handle:vertical {{
background: {COLOR['BORDER']};
border-radius: 4px;
min-height: 20px;
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0px; }}
QGroupBox {{
border: 1px solid {COLOR['BORDER']};
border-radius: 6px;
margin-top: 10px;
padding-top: 6px;
color: {COLOR['TEXT_MUTED']};
font-size: 11px;
letter-spacing: 1px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 6px;
}}
QStatusBar {{
background-color: {COLOR['BG_PANEL']};
border-top: 1px solid {COLOR['BORDER']};
color: {COLOR['TEXT_MUTED']};
font-size: 11px;
}}
QLineEdit {{
background-color: {COLOR['BG_CARD']};
border: 1px solid {COLOR['BORDER']};
border-radius: 4px;
padding: 5px 10px;
color: {COLOR['TEXT_PRIMARY']};
}}
QSplitter::handle {{
background-color: {COLOR['BORDER']};
width: 2px;
}}
"""
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 더미 데이터 생성 (실제 구현 시 BluetoothWorker + DB로 교체)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def make_dummy_event():
risks = ["SAFE", "SAFE", "SAFE", "CAUTION", "CAUTION",
"NEAR_MISS", "HUMAN_RISK", "HUMAN_NEAR_MISS"]
states = ["FORWARD", "FORWARD", "FORWARD", "STOP", "TURN_LEFT", "TURN_RIGHT", "BACKWARD"]
risk = random.choice(risks)
dist = random.randint(8, 80)
pir = 1 if "HUMAN" in risk else random.choice([0, 0, 1])
return {
"time": datetime.now().strftime("%H:%M:%S"),
"distance": dist,
"pir": pir,
"state": random.choice(states),
"risk_level": risk,
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 블루투스 수신 워커 (실제 연결 시 pyserial로 교체)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class BluetoothWorker(QThread):
data_received = pyqtSignal(dict)
connection_changed = pyqtSignal(bool)
def __init__(self):
super().__init__()
self.running = False
self.connected = False
self.port = "COM5" # ← 실제 포트로 교체
self.baudrate = 9600 # ← 실제 보드레이트로 교체
def connect_device(self):
# TODO: 실제 연결 코드
# import serial
# self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
self.connected = True
self.connection_changed.emit(True)
self.running = True
self.start()
def disconnect_device(self):
self.running = False
self.connected = False
self.connection_changed.emit(False)
def run(self):
"""수신 루프 — 실제 구현 시 serial.readline() 파싱으로 교체"""
import time
while self.running:
# TODO: 실제 데이터 수신
# line = self.ser.readline().decode('utf-8').strip()
# event = self.parse_line(line)
event = make_dummy_event() # 더미 데이터
self.data_received.emit(event)
time.sleep(0.5)
def parse_line(self, line: str) -> dict | None:
"""TIME:xxx,DIST:xx,PIR:x,STATE:xxx,RISK:xxx 형식 파싱"""
try:
parts = {k: v for k, v in (p.split(":") for p in line.split(","))}
return {
"time": parts.get("TIME", ""),
"distance": int(parts.get("DIST", 0)),
"pir": int(parts.get("PIR", 0)),
"state": parts.get("STATE", "UNKNOWN"),
"risk_level": parts.get("RISK", "SAFE"),
}
except Exception:
return None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 좌측 실시간 상태 패널
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class StatusPanel(QWidget):
connect_requested = pyqtSignal()
disconnect_requested = pyqtSignal()
def __init__(self):
super().__init__()
self.setFixedWidth(240)
self.blink_state = False
self.current_risk = "SAFE"
self._build_ui()
self._setup_blink_timer()
# ── UI 구성 ──────────────────────────────────────────────────────────────
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# 타이틀
title = QLabel("◈ LIVE STATUS")
title.setFont(QFont("Consolas", 11, QFont.Bold))
title.setStyleSheet(f"color: {COLOR['ACCENT']}; letter-spacing: 2px;")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
layout.addWidget(self._divider())
# 거리 카드
layout.addWidget(self._section_label("DISTANCE"))
self.lbl_distance = self._value_label("-- cm", size=28)
layout.addWidget(self.lbl_distance)
# 주행 상태 카드
layout.addWidget(self._section_label("DRIVE STATE"))
self.lbl_state = self._badge_label("--")
layout.addWidget(self.lbl_state)
# PIR 사람 감지
layout.addWidget(self._section_label("PIR SENSOR"))
self.lbl_pir = self._badge_label("감지 없음", bg="#1c2128")
layout.addWidget(self.lbl_pir)
# 위험 등급
layout.addWidget(self._section_label("RISK LEVEL"))
self.lbl_risk = self._badge_label("SAFE", bg=COLOR["SAFE"])
self.lbl_risk.setFont(QFont("Consolas", 14, QFont.Bold))
layout.addWidget(self.lbl_risk)
# 누적 위험 점수
layout.addWidget(self._section_label("DANGER SCORE (누적)"))
self.lbl_score = self._value_label("0 pt", size=18)
self.lbl_score.setStyleSheet(f"color: {COLOR['CAUTION']}; font-weight: bold;")
layout.addWidget(self.lbl_score)
# 진행 바 (위험 지수 시각화)
self.score_bar = QProgressBar()
self.score_bar.setRange(0, 200)
self.score_bar.setValue(0)
self.score_bar.setTextVisible(False)
self.score_bar.setFixedHeight(6)
self.score_bar.setStyleSheet(f"""
QProgressBar {{ background: {COLOR['BG_CARD']}; border-radius: 3px; border: none; }}
QProgressBar::chunk {{ background: {COLOR['CAUTION']}; border-radius: 3px; }}
""")
layout.addWidget(self.score_bar)
layout.addSpacerItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding))
layout.addWidget(self._divider())
# 연결 정보
layout.addWidget(self._section_label("BLUETOOTH"))
self.lbl_port = QLabel("포트: --")
self.lbl_port.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 11px;")
layout.addWidget(self.lbl_port)
self.lbl_conn_status = QLabel("● 연결 안됨")
self.lbl_conn_status.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 12px;")
layout.addWidget(self.lbl_conn_status)
# 연결/해제 버튼
btn_row = QHBoxLayout()
self.btn_connect = QPushButton("연결")
self.btn_connect.setStyleSheet(
f"background: #1a7f37; border-color: {COLOR['SAFE']}; color: white;")
self.btn_connect.clicked.connect(self.connect_requested.emit)
self.btn_disconnect = QPushButton("해제")
self.btn_disconnect.setStyleSheet(
f"background: #6e1a1a; border-color: {COLOR['HUMAN_RISK']}; color: white;")
self.btn_disconnect.setEnabled(False)
self.btn_disconnect.clicked.connect(self.disconnect_requested.emit)
btn_row.addWidget(self.btn_connect)
btn_row.addWidget(self.btn_disconnect)
layout.addLayout(btn_row)
# ── 데이터 업데이트 ───────────────────────────────────────────────────────
def update_data(self, event: dict, total_score: int):
risk = event.get("risk_level", "SAFE")
dist = event.get("distance", 0)
pir = event.get("pir", 0)
state = event.get("state", "--")
self.current_risk = risk
self.lbl_distance.setText(f"{dist} cm")
self.lbl_state.setText(state)
if pir:
self.lbl_pir.setText("👤 감지됨")
self.lbl_pir.setStyleSheet(
f"background: #6e1a1a; color: white; padding: 6px; border-radius: 4px; font-weight: bold;")
else:
self.lbl_pir.setText("감지 없음")
self.lbl_pir.setStyleSheet(
f"background: {COLOR['BG_CARD']}; color: {COLOR['TEXT_MUTED']}; padding: 6px; border-radius: 4px;")
color = RISK_COLOR.get(risk, COLOR["SAFE"])
self.lbl_risk.setText(risk)
self.lbl_risk.setStyleSheet(
f"background: {color}22; color: {color}; padding: 8px; border-radius: 4px; "
f"font-weight: bold; border: 1px solid {color};")
self.lbl_score.setText(f"{total_score} pt")
self.score_bar.setValue(min(total_score, 200))
def update_connection(self, connected: bool, port: str = ""):
if connected:
self.lbl_conn_status.setText("● 연결됨")
self.lbl_conn_status.setStyleSheet(f"color: {COLOR['SAFE']}; font-size: 12px;")
self.lbl_port.setText(f"포트: {port}")
self.btn_connect.setEnabled(False)
self.btn_disconnect.setEnabled(True)
else:
self.lbl_conn_status.setText("● 연결 안됨")
self.lbl_conn_status.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 12px;")
self.btn_connect.setEnabled(True)
self.btn_disconnect.setEnabled(False)
# ── 위험 점멸 애니메이션 ──────────────────────────────────────────────────
def _setup_blink_timer(self):
self.blink_timer = QTimer()
self.blink_timer.timeout.connect(self._blink)
def start_blink(self):
self.blink_timer.start(400)
def stop_blink(self):
self.blink_timer.stop()
def _blink(self):
self.blink_state = not self.blink_state
if self.blink_state:
self.lbl_risk.setStyleSheet(
f"background: {COLOR['HUMAN_NM']}; color: white; padding: 8px; "
f"border-radius: 4px; font-weight: bold;")
else:
self.lbl_risk.setStyleSheet(
f"background: {COLOR['BG_CARD']}; color: {COLOR['HUMAN_NM']}; padding: 8px; "
f"border-radius: 4px; font-weight: bold;")
# ── 헬퍼 위젯 ────────────────────────────────────────────────────────────
def _section_label(self, text):
lbl = QLabel(text)
lbl.setStyleSheet(
f"color: {COLOR['TEXT_MUTED']}; font-size: 10px; letter-spacing: 1px;")
return lbl
def _value_label(self, text, size=20):
lbl = QLabel(text)
lbl.setFont(QFont("Consolas", size, QFont.Bold))
lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet(f"color: {COLOR['TEXT_PRIMARY']}; padding: 4px;")
return lbl
def _badge_label(self, text, bg=None):
lbl = QLabel(text)
bg = bg or COLOR["BG_CARD"]
lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet(
f"background: {bg}; color: white; padding: 6px; border-radius: 4px; font-weight: bold;")
return lbl
def _divider(self):
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setStyleSheet(f"color: {COLOR['BORDER']};")
return line
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 탭1 — 이벤트 로그 테이블
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class LogTab(QWidget):
COLUMNS = ["시간", "거리 (cm)", "PIR", "주행 상태", "위험 등급"]
def __init__(self):
super().__init__()
self.auto_scroll = True
self.event_count = 0
self.danger_count = 0
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# 헤더 툴바
toolbar = QHBoxLayout()
title = QLabel("📋 EVENT LOG")
title.setFont(QFont("Consolas", 12, QFont.Bold))
title.setStyleSheet(f"color: {COLOR['ACCENT']};")
toolbar.addWidget(title)
toolbar.addStretch()
self.chk_auto_scroll = QCheckBox("자동 스크롤")
self.chk_auto_scroll.setChecked(True)
self.chk_auto_scroll.toggled.connect(lambda v: setattr(self, 'auto_scroll', v))
toolbar.addWidget(self.chk_auto_scroll)
self.chk_danger_only = QCheckBox("위험만 보기")
self.chk_danger_only.toggled.connect(self._filter_rows)
toolbar.addWidget(self.chk_danger_only)
btn_clear = QPushButton("지우기")
btn_clear.clicked.connect(self._clear_table)
toolbar.addWidget(btn_clear)
btn_export = QPushButton("📤 CSV 저장")
btn_export.clicked.connect(self._export_csv)
toolbar.addWidget(btn_export)
layout.addLayout(toolbar)
# 테이블
self.table = QTableWidget(0, len(self.COLUMNS))
self.table.setHorizontalHeaderLabels(self.COLUMNS)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setAlternatingRowColors(False)
self.table.verticalHeader().setVisible(False)
self.table.setShowGrid(True)
layout.addWidget(self.table)
# 하단 요약 바
self.lbl_summary = QLabel("총 이벤트: 0건 | 위험 이벤트: 0건 (0.0%)")
self.lbl_summary.setStyleSheet(
f"color: {COLOR['TEXT_MUTED']}; font-size: 11px; padding: 4px;")
layout.addWidget(self.lbl_summary)
def add_event(self, event: dict):
risk = event.get("risk_level", "SAFE")
pir = event.get("pir", 0)
self.event_count += 1
if risk not in ("SAFE",):
self.danger_count += 1
row = self.table.rowCount()
self.table.insertRow(row)
items = [
event.get("time", "--"),
str(event.get("distance", "--")),
"✅" if pir else "❌",
event.get("state", "--"),
risk,
]
color = QColor(RISK_COLOR.get(risk, COLOR["SAFE"]))
bg = QColor(color)
bg.setAlpha(30)
for col, text in enumerate(items):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignCenter)
item.setBackground(QBrush(bg))
if col == 4: # 위험 등급 열만 진하게
item.setForeground(QBrush(color))
font = item.font()
font.setBold(True)
item.setFont(font)
self.table.setItem(row, col, item)
if self.auto_scroll:
self.table.scrollToBottom()
# 최대 500행 유지
if self.table.rowCount() > 500:
self.table.removeRow(0)
ratio = (self.danger_count / self.event_count * 100) if self.event_count else 0
self.lbl_summary.setText(
f"총 이벤트: {self.event_count}건 | "
f"위험 이벤트: {self.danger_count}건 ({ratio:.1f}%)")
# 위험 등급 필터 적용
if self.chk_danger_only.isChecked() and risk == "SAFE":
self.table.setRowHidden(row, True)
def _filter_rows(self, danger_only: bool):
for row in range(self.table.rowCount()):
item = self.table.item(row, 4)
if item:
hidden = danger_only and item.text() == "SAFE"
self.table.setRowHidden(row, hidden)
def _clear_table(self):
self.table.setRowCount(0)
self.event_count = 0
self.danger_count = 0
self.lbl_summary.setText("총 이벤트: 0건 | 위험 이벤트: 0건 (0.0%)")
def _export_csv(self):
# TODO: QFileDialog + csv.writer로 저장
QMessageBox.information(self, "CSV 저장", "저장 기능은 DB 연결 후 구현 예정입니다.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 탭2 — 통계 그래프
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ChartTab(QWidget):
def __init__(self):
super().__init__()
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# 헤더
header = QHBoxLayout()
title = QLabel("📊 STATISTICS")
title.setFont(QFont("Consolas", 12, QFont.Bold))
title.setStyleSheet(f"color: {COLOR['ACCENT']};")
header.addWidget(title)
header.addStretch()
btn_refresh = QPushButton("🔄 새로고침")
btn_refresh.clicked.connect(self.refresh_charts)
header.addWidget(btn_refresh)
layout.addLayout(header)
if MATPLOTLIB_AVAILABLE:
self._build_charts(layout)
else:
lbl = QLabel("⚠ matplotlib이 설치되지 않았습니다.\n\npip install matplotlib")
lbl.setAlignment(Qt.AlignCenter)
lbl.setStyleSheet(f"color: {COLOR['CAUTION']}; font-size: 14px;")
layout.addWidget(lbl)
def _build_charts(self, layout):
# 상단: 막대 + 파이 나란히
top_row = QHBoxLayout()
# 그래프1 — 시간대별 Near-Miss 막대 그래프
self.fig1 = Figure(figsize=(5, 3), facecolor=COLOR['BG_PANEL'])
self.canvas1 = FigureCanvas(self.fig1)
self.ax1 = self.fig1.add_subplot(111)
self._style_ax(self.ax1)
self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax1.set_xlabel("시간대 (Hour)", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax1.set_ylabel("발생 횟수", color=COLOR['TEXT_MUTED'], fontsize=8)
top_row.addWidget(self.canvas1)
# 그래프2 — 위험 등급 파이 차트
self.fig2 = Figure(figsize=(4, 3), facecolor=COLOR['BG_PANEL'])
self.canvas2 = FigureCanvas(self.fig2)
self.ax2 = self.fig2.add_subplot(111)
self.ax2.set_facecolor(COLOR['BG_PANEL'])
self.ax2.set_title("위험 등급 분포", color=COLOR['TEXT_PRIMARY'], fontsize=10)
top_row.addWidget(self.canvas2)
layout.addLayout(top_row)
# 하단: 시간대별 평균 위험 점수 선 그래프
self.fig3 = Figure(figsize=(10, 2.5), facecolor=COLOR['BG_PANEL'])
self.canvas3 = FigureCanvas(self.fig3)
self.ax3 = self.fig3.add_subplot(111)
self._style_ax(self.ax3)
self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax3.set_xlabel("시간대 (Hour)", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax3.set_ylabel("평균 점수", color=COLOR['TEXT_MUTED'], fontsize=8)
layout.addWidget(self.canvas3)
# 초기 빈 데이터 렌더
self._draw_empty_charts()
def _style_ax(self, ax):
ax.set_facecolor(COLOR['BG_PANEL'])
ax.tick_params(colors=COLOR['TEXT_MUTED'], labelsize=8)
for spine in ax.spines.values():
spine.set_color(COLOR['BORDER'])
def _draw_empty_charts(self):
"""초기 빈 차트 렌더링 (DB 연결 전 플레이스홀더)"""
hours = list(range(24))
zeros = [0] * 24
self.ax1.clear()
self._style_ax(self.ax1)
self.ax1.bar(hours, zeros, color=COLOR['NEAR_MISS'], alpha=0.7, width=0.6)
self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax1.set_xticks(range(0, 24, 2))
self.fig1.tight_layout()
self.canvas1.draw()
self.ax2.clear()
labels = list(RISK_COLOR.keys())
sizes = [1] * len(labels)
colors = list(RISK_COLOR.values())
self.ax2.pie(sizes, labels=labels, colors=colors,
autopct='%1.1f%%', startangle=90,
textprops={'color': COLOR['TEXT_MUTED'], 'fontsize': 8})
self.ax2.set_title("위험 등급 분포 (샘플)", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.fig2.tight_layout()
self.canvas2.draw()
self.ax3.clear()
self._style_ax(self.ax3)
self.ax3.plot(hours, zeros, color=COLOR['ACCENT'], linewidth=2, marker='o', markersize=3)
self.ax3.fill_between(hours, zeros, alpha=0.15, color=COLOR['ACCENT'])
self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax3.set_xticks(range(0, 24, 2))
self.fig3.tight_layout()
self.canvas3.draw()
def refresh_charts(self):
"""DB에서 집계 데이터 로드 후 그래프 갱신 — 실제 구현 시 DB 쿼리로 교체"""
# TODO: DB 쿼리 결과로 교체
# hours_data = db.query("SELECT HOUR(event_time), COUNT(*) ...")
hours = list(range(24))
nm_counts = [random.randint(0, 20) for _ in hours]
avg_scores = [random.uniform(0, 6) for _ in hours]
risk_dist = {k: random.randint(5, 100) for k in RISK_COLOR}
if not MATPLOTLIB_AVAILABLE:
return
# 막대 그래프 갱신
self.ax1.clear()
self._style_ax(self.ax1)
bars = self.ax1.bar(hours, nm_counts, color=COLOR['NEAR_MISS'], alpha=0.8, width=0.6)
self.ax1.set_title("시간대별 Near-Miss 발생 빈도", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax1.set_xlabel("시간대", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax1.set_ylabel("횟수", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax1.set_xticks(range(0, 24, 2))
self.fig1.tight_layout()
self.canvas1.draw()
# 파이 차트 갱신
self.ax2.clear()
labels = list(risk_dist.keys())
sizes = list(risk_dist.values())
colors = [RISK_COLOR[k] for k in labels]
wedges, texts, autotexts = self.ax2.pie(
sizes, labels=labels, colors=colors, autopct='%1.1f%%',
startangle=90, textprops={'color': COLOR['TEXT_MUTED'], 'fontsize': 7})
for at in autotexts:
at.set_color(COLOR['TEXT_PRIMARY'])
self.ax2.set_title("위험 등급 분포", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.fig2.tight_layout()
self.canvas2.draw()
# 선 그래프 갱신
self.ax3.clear()
self._style_ax(self.ax3)
self.ax3.plot(hours, avg_scores, color=COLOR['ACCENT'], linewidth=2,
marker='o', markersize=4)
self.ax3.fill_between(hours, avg_scores, alpha=0.15, color=COLOR['ACCENT'])
self.ax3.set_title("시간대별 평균 위험 점수", color=COLOR['TEXT_PRIMARY'], fontsize=10)
self.ax3.set_xlabel("시간대", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax3.set_ylabel("점수", color=COLOR['TEXT_MUTED'], fontsize=8)
self.ax3.set_xticks(range(0, 24, 2))
self.fig3.tight_layout()
self.canvas3.draw()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 탭3 — 날짜별 조회
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class SearchTab(QWidget):
def __init__(self):
super().__init__()
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# 헤더
title = QLabel("📅 DATE SEARCH")
title.setFont(QFont("Consolas", 12, QFont.Bold))
title.setStyleSheet(f"color: {COLOR['ACCENT']};")
layout.addWidget(title)
# 검색 조건 패널
search_box = QGroupBox("조회 조건")
search_layout = QHBoxLayout(search_box)
search_layout.setSpacing(12)
search_layout.addWidget(QLabel("시작일:"))
self.date_start = QDateEdit(QDate.currentDate().addDays(-7))
self.date_start.setCalendarPopup(True)
self.date_start.setDisplayFormat("yyyy-MM-dd")
search_layout.addWidget(self.date_start)
search_layout.addWidget(QLabel("종료일:"))
self.date_end = QDateEdit(QDate.currentDate())
self.date_end.setCalendarPopup(True)
self.date_end.setDisplayFormat("yyyy-MM-dd")
search_layout.addWidget(self.date_end)
search_layout.addWidget(QLabel("위험 등급:"))
self.combo_risk = QComboBox()
self.combo_risk.addItems(
["전체", "SAFE", "CAUTION", "NEAR_MISS", "HUMAN_RISK", "HUMAN_NEAR_MISS", "EMERGENCY_STOP"])
self.combo_risk.setMinimumWidth(160)
search_layout.addWidget(self.combo_risk)
search_layout.addStretch()
btn_search = QPushButton("🔍 조회")
btn_search.setStyleSheet(
f"background: #1f6feb; border-color: {COLOR['ACCENT']}; color: white; padding: 7px 20px;")
btn_search.clicked.connect(self._run_search)
search_layout.addWidget(btn_search)
layout.addWidget(search_box)
# 요약 카드 4개
self.summary_box = QGroupBox("조회 결과 요약")
summary_layout = QGridLayout(self.summary_box)
summary_layout.setSpacing(8)
self.card_total = self._summary_card("총 이벤트", "--건", COLOR['ACCENT'])
self.card_near_miss = self._summary_card("NEAR_MISS", "--건", COLOR['NEAR_MISS'])
self.card_human = self._summary_card("HUMAN 위험", "--건", COLOR['HUMAN_RISK'])
self.card_avg_score = self._summary_card("평균 위험 점수","--점", COLOR['CAUTION'])
summary_layout.addWidget(self.card_total, 0, 0)
summary_layout.addWidget(self.card_near_miss, 0, 1)
summary_layout.addWidget(self.card_human, 0, 2)
summary_layout.addWidget(self.card_avg_score, 0, 3)
layout.addWidget(self.summary_box)
# 결과 테이블
result_label = QLabel("조회 결과")
result_label.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 11px;")
layout.addWidget(result_label)
self.result_table = QTableWidget(0, 5)
self.result_table.setHorizontalHeaderLabels(
["시간", "거리 (cm)", "PIR", "주행 상태", "위험 등급"])
self.result_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.result_table.setSelectionBehavior(QTableWidget.SelectRows)
self.result_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.result_table.verticalHeader().setVisible(False)
layout.addWidget(self.result_table)
# 내보내기
footer = QHBoxLayout()
footer.addStretch()
btn_export = QPushButton("📤 CSV 내보내기")
btn_export.clicked.connect(self._export_csv)
footer.addWidget(btn_export)
layout.addLayout(footer)
def _summary_card(self, label: str, value: str, color: str) -> QGroupBox:
box = QGroupBox()
box.setStyleSheet(
f"QGroupBox {{ border: 1px solid {color}44; border-radius: 6px; "
f"background: {color}11; }}")
v = QVBoxLayout(box)
v.setContentsMargins(12, 8, 12, 8)
lbl = QLabel(label)
lbl.setStyleSheet(f"color: {COLOR['TEXT_MUTED']}; font-size: 10px; letter-spacing: 1px;")
lbl.setAlignment(Qt.AlignCenter)
v.addWidget(lbl)
val = QLabel(value)
val.setFont(QFont("Consolas", 16, QFont.Bold))
val.setStyleSheet(f"color: {color};")
val.setAlignment(Qt.AlignCenter)
val.setObjectName("value_label")
v.addWidget(val)
return box
def _update_card(self, card: QGroupBox, value: str):
lbl = card.findChild(QLabel, "value_label")
if lbl:
lbl.setText(value)
def _run_search(self):
"""DB 쿼리 실행 — 실제 구현 시 MySQL 쿼리로 교체"""
start = self.date_start.date().toString("yyyy-MM-dd")
end = self.date_end.date().toString("yyyy-MM-dd")
risk_filter = self.combo_risk.currentText()
# TODO: 실제 DB 쿼리
# SELECT * FROM event_log WHERE DATE(event_time) BETWEEN '{start}' AND '{end}'
# AND (risk_level = '{risk_filter}' OR '{risk_filter}' = '전체')
# 더미 결과 표시
dummy_results = [make_dummy_event() for _ in range(random.randint(10, 30))]
if risk_filter != "전체":
dummy_results = [e for e in dummy_results if e["risk_level"] == risk_filter]
self._populate_result_table(dummy_results)
near_miss = sum(1 for e in dummy_results if "MISS" in e["risk_level"])
human = sum(1 for e in dummy_results if "HUMAN" in e["risk_level"])
avg_score = sum(RISK_SCORE.get(e["risk_level"], 0) for e in dummy_results)
avg_score = avg_score / len(dummy_results) if dummy_results else 0
self._update_card(self.card_total, f"{len(dummy_results)}건")
self._update_card(self.card_near_miss, f"{near_miss}건")
self._update_card(self.card_human, f"{human}건")
self._update_card(self.card_avg_score, f"{avg_score:.1f}점")
def _populate_result_table(self, events: list):
self.result_table.setRowCount(0)
for event in events:
risk = event.get("risk_level", "SAFE")
row = self.result_table.rowCount()
self.result_table.insertRow(row)
items = [
event.get("time", "--"),
str(event.get("distance", "--")),
"✅" if event.get("pir") else "❌",
event.get("state", "--"),
risk,
]
color = QColor(RISK_COLOR.get(risk, COLOR["SAFE"]))
bg = QColor(color); bg.setAlpha(25)
for col, text in enumerate(items):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignCenter)
item.setBackground(QBrush(bg))
if col == 4:
item.setForeground(QBrush(color))
f = item.font(); f.setBold(True); item.setFont(f)
self.result_table.setItem(row, col, item)
def _export_csv(self):
QMessageBox.information(self, "CSV 내보내기", "저장 기능은 DB 연결 후 구현 예정입니다.")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 연결 설정 다이얼로그
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ConnectionDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("블루투스 연결 설정")
self.setFixedSize(320, 200)
self.setStyleSheet(GLOBAL_STYLESHEET)
self._build_ui()
def _build_ui(self):
layout = QFormLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(12)
self.port_edit = QLineEdit("COM5")
self.port_edit.setPlaceholderText("예: COM5 또는 /dev/rfcomm0")
layout.addRow("포트:", self.port_edit)
self.baud_combo = QComboBox()
self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200"])
layout.addRow("보드레이트:", self.baud_combo)
self.db_host = QLineEdit("localhost")
layout.addRow("DB 호스트:", self.db_host)
self.db_name = QLineEdit("safety_db")
layout.addRow("DB 이름:", self.db_name)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
def get_config(self):
return {
"port": self.port_edit.text(),
"baudrate": int(self.baud_combo.currentText()),
"db_host": self.db_host.text(),
"db_name": self.db_name.text(),
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 메인 윈도우
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("물류센터 자율주행 로봇 안전 분석 시스템")
self.setMinimumSize(1200, 720)
self.resize(1400, 820)
self.setStyleSheet(GLOBAL_STYLESHEET)
self.total_score = 0
self.recv_count = 0
self.bt_worker = BluetoothWorker()
self._build_central()
self._build_menu()
self._build_statusbar()
self._connect_signals()
# 상태바 갱신 타이머
self.status_timer = QTimer()
self.status_timer.timeout.connect(self._update_statusbar)
self.status_timer.start(1000)
# ── 메뉴바 ────────────────────────────────────────────────────────────────
def _build_menu(self):
menubar = self.menuBar()
menubar.setStyleSheet(
f"QMenuBar {{ background: {COLOR['BG_PANEL']}; border-bottom: 1px solid {COLOR['BORDER']}; }}"
f"QMenuBar::item:selected {{ background: {COLOR['BG_CARD']}; color: {COLOR['ACCENT']}; }}"
f"QMenu {{ background: {COLOR['BG_CARD']}; border: 1px solid {COLOR['BORDER']}; }}"
f"QMenu::item:selected {{ background: #264f78; }}")
# 파일 메뉴
file_menu = menubar.addMenu("파일")
act_export = QAction("전체 데이터 내보내기", self)
act_exit = QAction("종료", self)
act_exit.triggered.connect(self.close)
file_menu.addAction(act_export)
file_menu.addSeparator()
file_menu.addAction(act_exit)
# 연결 메뉴
conn_menu = menubar.addMenu("연결")
act_setting = QAction("연결 설정...", self)
act_setting.triggered.connect(self._open_connection_dialog)
act_connect = QAction("연결", self)
act_connect.triggered.connect(self._on_connect)
act_disconnect = QAction("해제", self)
act_disconnect.triggered.connect(self._on_disconnect)
conn_menu.addAction(act_setting)
conn_menu.addSeparator()
conn_menu.addAction(act_connect)
conn_menu.addAction(act_disconnect)
# 보기 메뉴
view_menu = menubar.addMenu("보기")
act_chart_refresh = QAction("그래프 새로고침", self)
act_chart_refresh.triggered.connect(self.chart_tab.refresh_charts)
view_menu.addAction(act_chart_refresh)
# 도움말 메뉴
help_menu = menubar.addMenu("도움말")
act_about = QAction("프로젝트 정보", self)
act_about.triggered.connect(self._show_about)
help_menu.addAction(act_about)
# ── 중앙 위젯 ──────────────────────────────────────────────────────────────
def _build_central(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout(central)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(0)
# 좌측 상태 패널
self.status_panel = StatusPanel()
self.status_panel.connect_requested.connect(self._on_connect)
self.status_panel.disconnect_requested.connect(self._on_disconnect)
# 구분선
divider = QFrame()
divider.setFrameShape(QFrame.VLine)
divider.setStyleSheet(f"color: {COLOR['BORDER']};")
# 우측 탭
self.log_tab = LogTab()
self.chart_tab = ChartTab()
self.search_tab = SearchTab()
self.tab_widget = QTabWidget()
self.tab_widget.addTab(self.log_tab, "📋 이벤트 로그")
self.tab_widget.addTab(self.chart_tab, "📊 통계 분석")
self.tab_widget.addTab(self.search_tab, "📅 날짜 조회")
# 탭 변경 시 그래프 자동 새로고침
self.tab_widget.currentChanged.connect(self._on_tab_changed)
main_layout.addWidget(self.status_panel)
main_layout.addWidget(divider)
main_layout.addWidget(self.tab_widget)
# ── 상태바 ────────────────────────────────────────────────────────────────
def _build_statusbar(self):
sb = self.statusBar()
self.sb_bt_status = QLabel(" ● 블루투스: 연결 안됨 ")
self.sb_recv_count = QLabel(" 수신: 0건 ")
self.sb_last_recv = QLabel(" 마지막 수신: -- ")
self.sb_time = QLabel("")
for lbl in [self.sb_bt_status, self.sb_recv_count, self.sb_last_recv]:
lbl.setStyleSheet(
f"color: {COLOR['TEXT_MUTED']}; padding: 0 8px; "
f"border-right: 1px solid {COLOR['BORDER']};")
sb.addPermanentWidget(self.sb_bt_status)
sb.addPermanentWidget(self.sb_recv_count)
sb.addPermanentWidget(self.sb_last_recv)
sb.addPermanentWidget(self.sb_time)
# ── 시그널 연결 ───────────────────────────────────────────────────────────
def _connect_signals(self):
self.bt_worker.data_received.connect(self._on_data_received)
self.bt_worker.connection_changed.connect(self._on_connection_changed)
# ── 슬롯: 데이터 수신 ──────────────────────────────────────────────────────
def _on_data_received(self, event: dict):
self.recv_count += 1
self.total_score += RISK_SCORE.get(event.get("risk_level", "SAFE"), 0)
self.last_recv_time = datetime.now().strftime("%H:%M:%S")
# 각 탭 업데이트
self.status_panel.update_data(event, self.total_score)
self.log_tab.add_event(event)
# HUMAN_NEAR_MISS 경고 점멸
risk = event.get("risk_level", "SAFE")
if risk in ("HUMAN_NEAR_MISS", "HUMAN_RISK"):
self.status_panel.start_blink()
else:
self.status_panel.stop_blink()
self.sb_recv_count.setText(f" 수신: {self.recv_count}건 ")
self.sb_last_recv.setText(f" 마지막 수신: {self.last_recv_time} ")
# ── 슬롯: 연결 상태 변경 ──────────────────────────────────────────────────
def _on_connection_changed(self, connected: bool):
self.status_panel.update_connection(connected, self.bt_worker.port)
if connected:
self.sb_bt_status.setText(" ● 블루투스: 연결됨 ")
self.sb_bt_status.setStyleSheet(
f"color: {COLOR['SAFE']}; padding: 0 8px; border-right: 1px solid {COLOR['BORDER']};")
else:
self.sb_bt_status.setText(" ● 블루투스: 연결 안됨 ")
self.sb_bt_status.setStyleSheet(
f"color: {COLOR['TEXT_MUTED']}; padding: 0 8px; border-right: 1px solid {COLOR['BORDER']};")
# ── 슬롯: 탭 전환 ──────────────────────────────────────────────────────────
def _on_tab_changed(self, idx: int):
if idx == 1: # 통계 탭으로 이동 시 그래프 갱신
self.chart_tab.refresh_charts()
# ── 연결 / 해제 ──────────────────────────────────────────────────────────
def _on_connect(self):
self.bt_worker.connect_device()
def _on_disconnect(self):
self.bt_worker.disconnect_device()
def _open_connection_dialog(self):
dlg = ConnectionDialog(self)
if dlg.exec_() == QDialog.Accepted:
cfg = dlg.get_config()
self.bt_worker.port = cfg["port"]
self.bt_worker.baudrate = cfg["baudrate"]
# TODO: DB 설정 적용
# ── 상태바 시계 갱신 ──────────────────────────────────────────────────────
def _update_statusbar(self):
self.sb_time.setText(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ")
# ── 도움말 ────────────────────────────────────────────────────────────────
def _show_about(self):
QMessageBox.information(
self, "프로젝트 정보",
"물류센터 자율주행 로봇 안전 분석 시스템\n\n"
"Near-Miss Analytics Dashboard\n"
"Arduino + Bluetooth + MySQL + PyQt5\n\n"
"팀 구성: 길민준, 김아영\n"
"기획일: 2025-02-26"
)
def closeEvent(self, event):
self.bt_worker.disconnect_device()
super().closeEvent(event)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 엔트리포인트
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if __name__ == "__main__":
app = QApplication(sys.argv)
app.setStyle("Fusion")
# 다크 팔레트
palette = QPalette()
palette.setColor(QPalette.Window, QColor(COLOR['BG_DARK']))
palette.setColor(QPalette.WindowText, QColor(COLOR['TEXT_PRIMARY']))
palette.setColor(QPalette.Base, QColor(COLOR['BG_PANEL']))
palette.setColor(QPalette.AlternateBase, QColor(COLOR['BG_CARD']))
palette.setColor(QPalette.Text, QColor(COLOR['TEXT_PRIMARY']))
palette.setColor(QPalette.Button, QColor(COLOR['BG_CARD']))
palette.setColor(QPalette.ButtonText, QColor(COLOR['TEXT_PRIMARY']))
palette.setColor(QPalette.Highlight, QColor("#264f78"))
palette.setColor(QPalette.HighlightedText, QColor(COLOR['TEXT_PRIMARY']))
app.setPalette(palette)
window = MainWindow()
window.show()
sys.exit(app.exec_())
'로보테크AI' 카테고리의 다른 글
| 융합_로보테크 AI 자율주행 로봇 개발자 과정-26/03/03 (0) | 2026.03.03 |
|---|---|
| 융합_로보테크 AI 자율주행 로봇 개발자 과정-26/02/27 (0) | 2026.02.27 |
| 융합_로보테크 AI 자율주행 로봇 개발자 과정-26/02/25 (0) | 2026.02.25 |
| 융합_로보테크 AI 자율주행 로봇 개발자 과정-26/02/24[Arduino] (0) | 2026.02.24 |
| 융합_로보테크 AI 자율주행 로봇 개발자 과정-26/02/23[PyQt] (0) | 2026.02.23 |