Files
2026-01-08 16:05:05 +09:00

2452 lines
105 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, sys, time, warnings, threading, queue, subprocess
from datetime import datetime, timedelta
# iotdemo 경로 우선 추가 (사용자 제공 위치)
try:
sys.path.append(os.path.expanduser('./iotdemo'))
except Exception:
pass
# ===== Optional deps (GUI/DB/IO) =====
import tkinter as tk
from tkinter import ttk, messagebox
try:
import pymysql
except Exception:
pymysql = None
try:
from PIL import Image, ImageTk
PIL_OK = True
except Exception:
Image = None; ImageTk = None; PIL_OK = False
try:
import cv2
except Exception:
cv2 = None
try:
import serial
except Exception:
serial = None
# (사용자 환경의 iotdemo.* 가 있을 수도/없을 수도 있으므로 try)
try:
from iotdemo import FactoryController, Inputs, Outputs, PyDuino, PyFt232
except Exception:
FactoryController = Inputs = Outputs = PyDuino = PyFt232 = None
# ====== ONNX/NumPy ======
import numpy as np
import onnxruntime as ort
from math import pi
# ---------- UI colors ----------
PINK = (203, 80, 165) # bbox
WHITE = (255,255,255)
# =========================
# Detection/Seg utilities
# =========================
def letterbox(im, size, color=(114,114,114)):
h, w = im.shape[:2]
r = min(size/h, size/w)
nh, nw = int(round(h*r)), int(round(w*r))
resized = cv2.resize(im, (nw, nh), interpolation=cv2.INTER_LINEAR)
canvas = np.full((size, size, 3), color, dtype=im.dtype)
top = (size-nh)//2; left = (size-nw)//2
canvas[top:top+nh, left:left+nw] = resized
return canvas, r, left, top
def _rounded_rect(img, pt1, pt2, color, thickness=2, r=8):
x1,y1 = pt1; x2,y2 = pt2
if r<=0:
cv2.rectangle(img, pt1, pt2, color, thickness); return
cv2.line(img,(x1+r,y1),(x2-r,y1),color,thickness)
cv2.line(img,(x1+r,y2),(x2-r,y2),color,thickness)
cv2.line(img,(x1,y1+r),(x1,y2-r),color,thickness)
cv2.line(img,(x2,y1+r),(x2,y2-r),color,thickness)
cv2.ellipse(img,(x1+r,y1+r),(r,r),180,0,90,color,thickness)
cv2.ellipse(img,(x2-r,y1+r),(r,r),270,0,90,color,thickness)
cv2.ellipse(img,(x1+r,y2-r),(r,r),90 ,0,90,color,thickness)
cv2.ellipse(img,(x2-r,y2-r),(r,r),0 ,0,90,color,thickness)
def _label_badge(img, x, y, text, bg, fg=WHITE):
font = cv2.FONT_HERSHEY_SIMPLEX; scale=0.55; thick=2; pad=6; r=6
(tw,th), _ = cv2.getTextSize(text, font, scale, thick)
bx1,by1 = x, max(0, y - th - 2*pad - 6)
bx2,by2 = bx1 + tw + 2*pad + 2*r, by1 + th + 2*pad
cv2.rectangle(img,(bx1,by1),(bx2,by2),bg,-1)
_rounded_rect(img,(bx1,by1),(bx2,by2),bg,2,r)
cv2.putText(img, text, (bx1+pad+r//2, by2-pad), font, scale, fg, thick, cv2.LINE_AA)
# [PATCH] 좌측 상단에 멀티라인 텍스트를 그려주는 헬퍼
def put_lines_top_left(img, lines, colors=None, x=12, y0=22, dy=20):
"""lines: ['text1','text2',...] colors: [(b,g,r), ...] 또는 None"""
font = cv2.FONT_HERSHEY_SIMPLEX; scale=0.55; thick=2
for i, text in enumerate(lines):
y = y0 + i * dy
color = (colors[i] if (colors and i < len(colors)) else (255,255,255))
cv2.putText(img, str(text), (x, y), font, scale, color, thick, cv2.LINE_AA)
def draw_box(frame, x1,y1,x2,y2, score, cls, labels, color=PINK):
# [PATCH] 박스만 그리고, 텍스트 라벨은 표시하지 않음
_rounded_rect(frame, (x1,y1), (x2,y2), color, 2, r=8)
def draw_seg_on_roi(roi_bgr, mask_uint8, labels, *, sticker_cid=1, stain_cid=2):
h,w = mask_uint8.shape
out = roi_bgr.copy()
class_plan = [
(sticker_cid, {"fill":(0,255,255), "alpha":0.45, "edge":(60,220,60), "badge":(40,180,40), "fallback":"sticker"}),
(stain_cid, {"fill":(255,120,0), "alpha":0.45, "edge":(255,210,120), "badge":(200,160,80), "fallback":"stain"}),
]
for cid, style in class_plan:
if cid < 0:
continue
m = (mask_uint8 == cid).astype(np.uint8)
if m.sum() == 0:
continue
overlay = out.copy()
overlay[m>0] = style["fill"]
cv2.addWeighted(overlay, style["alpha"], out, 1-style["alpha"], 0, out)
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(out, contours, -1, style["edge"], 2)
if contours:
c = max(contours, key=cv2.contourArea)
M = cv2.moments(c)
if M["m00"] != 0:
cx = int(M["m10"]/M["m00"]); cy = int(M["m01"]/M["m00"])
name = labels[cid] if cid < len(labels) else style["fallback"]
font=cv2.FONT_HERSHEY_SIMPLEX; scale=0.5; thick=1; pad=4
(tw,th), _ = cv2.getTextSize(name, font, scale, thick)
bx1,by1 = max(0, cx-tw//2-pad), max(0, cy- th//2 - pad - 12)
bx2,by2 = min(w-1, bx1 + tw + 2*pad), min(h-1, by1 + th + 2*pad)
cv2.rectangle(out,(bx1,by1),(bx2,by2),style["badge"],-1)
cv2.putText(out, name, (bx1+pad, by2-pad), font, scale, WHITE, 1, cv2.LINE_AA)
return out
# ---------- Gate & geometry helpers (추가) ----------
def rect_from_ratio(W, H, wr, hr):
bw, bh = int(W*wr), int(H*hr)
cx0, cy0 = W//2, H//2
return (cx0 - bw//2, cy0 - bh//2, bw, bh)
def rect_from_pct(W, H, L, T, R, B):
x1 = int(np.clip(L, 0, 1) * W)
y1 = int(np.clip(T, 0, 1) * H)
x2 = int(np.clip(R, 0, 1) * W)
y2 = int(np.clip(B, 0, 1) * H)
if x2 < x1: x1, x2 = x2, x1
if y2 < y1: y1, y2 = y2, y1
return (x1, y1, x2 - x1, y2 - y1)
def draw_gate_box(frame, rect, color=(0,255,255), thick=2):
x, y, w, h = rect
cv2.rectangle(frame, (x, y), (x+w, y+h), color, thick)
def circle_fully_inside_rect(cx, cy, r, rect):
x, y, w, h = rect
x1, y1, x2, y2 = x, y, x+w, y+h
return (cx - r >= x1) and (cx + r <= x2) and (cy - r >= y1) and (cy + r <= y2)
# ---------- Detection decoders ----------
_GRID_CACHE = {}
def _build_grids(size, strides=(8,16,32)):
grids, s_all = [], []
for s in strides:
ny, nx = size//s, size//s
yv, xv = np.meshgrid(np.arange(ny), np.arange(nx), indexing="ij")
g = np.stack([xv, yv], -1).reshape(-1,2).astype(np.float32)
grids.append(g); s_all.append(np.full((g.shape[0],1), s, np.float32))
return np.concatenate(grids,0), np.concatenate(s_all,0)
def _sig(x): return 1/(1+np.exp(-x))
def decode_yolox_raw(pred, size, conf=0.35, num_classes=None):
C5,N = pred.shape
if num_classes is None: num_classes = C5-5
if size not in _GRID_CACHE: _GRID_CACHE[size] = _build_grids(size)
grid, strides = _GRID_CACHE[size]
if grid.shape[0] != N:
M=min(N,grid.shape[0]); pred=pred[:,:M]; N=M
px,py,pw,ph = pred[0],pred[1],pred[2],pred[3]
pobj=_sig(pred[4]); pcls=_sig(pred[5:5+num_classes])
grid=grid[:N]; s=strides[:N,0]
cx=(_sig(px)+grid[:,0])*s; cy=(_sig(py)+grid[:,1])*s
w=np.exp(pw)*s; h=np.exp(ph)*s
x1,y1,x2,y2 = cx-w/2, cy-h/2, cx+w/2, cy+h/2
cls_idx=np.argmax(pcls,0); scores=pcls[cls_idx,np.arange(N)]*pobj
keep=scores>=conf
if not np.any(keep): return np.zeros((0,6),np.float32)
return np.stack([x1[keep],y1[keep],x2[keep],y2[keep],scores[keep],cls_idx[keep].astype(np.float32)],1)
def decode_detection_output(arr, img_size, conf=0.35):
dets=[]
for image_id,label,score,x_min,y_min,x_max,y_max in arr.reshape(-1,7):
if score>=conf:
dets.append([x_min*img_size,y_min*img_size,x_max*img_size,y_max*img_size,score,label])
return np.array(dets,np.float32) if dets else np.zeros((0,6),np.float32)
def decode_box5_plus_cls(out0, out1, size, conf=0.35):
B,N,C = out0.shape; assert B==1 and C==5
boxes = out0[0].astype(np.float32)
clsid = out1[0].astype(np.float32) if (out1.ndim==2 and out1.shape[0]==1) else np.zeros((N,),np.float32)
if np.all((boxes[:,:4]>=0)&(boxes[:,:4]<=1.5)): boxes[:,:4]*=size
keep = boxes[:,4] >= conf
if not np.any(keep): return np.zeros((0,6),np.float32)
b=boxes[keep]; c=clsid[keep]
return np.concatenate([b[:,:4], b[:,4:5], c[:,None]],1)
def normalize_dets(arr, size):
"""(N,6) [x1,y1,x2,y2,score,cls] float32로 강제 변환."""
if arr is None:
return np.zeros((0,6), np.float32)
X = np.asarray(arr)
if X.ndim == 3 and X.shape[0] == 1:
X = X[0]
if X.ndim != 2:
return np.zeros((0,6), np.float32)
if X.shape[1] >= 7: # [image_id,label,score,x1,y1,x2,y2]
coords = np.stack([X[:,3], X[:,4], X[:,5], X[:,6]], 1).astype(np.float32)
if np.all((coords >= 0.0) & (coords <= 1.5)):
coords *= float(size)
score = X[:,2:3].astype(np.float32)
cls = X[:,1:2].astype(np.float32)
Y = np.concatenate([coords, score, cls], 1)
return Y.astype(np.float32)
if X.shape[1] == 6:
Y = X.astype(np.float32)
elif X.shape[1] == 5:
score = X[:,4:5].astype(np.float32)
cls = np.zeros((X.shape[0],1), np.float32)
Y = np.concatenate([X[:,:4].astype(np.float32), score, cls], 1)
elif X.shape[1] == 4:
score = np.ones((X.shape[0],1), np.float32)
cls = np.zeros((X.shape[0],1), np.float32)
Y = np.concatenate([X[:,:4].astype(np.float32), score, cls], 1)
else:
Y = X[:,:6].astype(np.float32)
if np.all((Y[:,:4] >= 0.0) & (Y[:,:4] <= 1.5)):
Y[:,:4] *= float(size)
return Y.astype(np.float32)
# ---------- Seg helpers ----------
def softmax(x, axis=1):
x = x - np.max(x, axis=axis, keepdims=True)
e = np.exp(x)
return e / np.sum(e, axis=axis, keepdims=True)
def _prepare_cls_input(bgr, size, scale=255.0):
# 중앙 정사각형 크롭 → resize → RGB → NCHW float32 [0..1]
h, w = bgr.shape[:2]
s = min(h, w)
y0 = (h - s) // 2; x0 = (w - s) // 2
crop = bgr[y0:y0+s, x0:x0+s]
img = cv2.resize(crop, (size, size), interpolation=cv2.INTER_LINEAR)
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / float(scale or 255.0)
return rgb.transpose(2,0,1)[None]
def _argmax_logits(arr):
X = np.asarray(arr)
X = X.reshape(1, -1) if X.ndim != 2 else X
X = X - X.max(axis=1, keepdims=True)
e = np.exp(X); probs = e / e.sum(axis=1, keepdims=True)
idx = int(np.argmax(probs[0])); conf = float(probs[0, idx])
return idx, conf
# ---------- ORT helpers ----------
def ort_session(path: str):
if not os.path.isfile(path):
raise FileNotFoundError(f"Model not found: {path}")
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
sess_opt = ort.SessionOptions()
sess_opt.intra_op_num_threads = 0
sess_opt.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
return ort.InferenceSession(path, sess_options=sess_opt, providers=providers)
def ort_run(sess, inputs: dict, out_names=None):
outs = sess.get_outputs()
names = [o.name for o in outs] if out_names is None else out_names
return sess.run(names, inputs)
# ---------- seg-map helpers ----------
def parse_seg_map(s: str, num_classes_hint=3):
mapping = {}
for part in s.split(","):
part = part.strip()
if not part:
continue
a, b = part.split(":")
mapping[int(a)] = int(b)
for i in range(num_classes_hint):
mapping.setdefault(i, i)
return mapping
def remap_mask(mask: np.ndarray, m: dict):
out = np.zeros_like(mask)
for src, dst in m.items():
out[mask == src] = dst
return out
# ---------- Cap circle + stain ratio ----------
def try_hough_circle(bgr_roi):
gray = cv2.cvtColor(bgr_roi, cv2.COLOR_BGR2GRAY)
gray = cv2.medianBlur(gray, 5)
circs = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.2, minDist=40,
param1=120, param2=30, minRadius=10, maxRadius=800)
if circs is None:
return None
x, y, r = np.round(circs[0,0]).astype(int)
return (x, y, r)
def grade_from_ratio(ratio):
if ratio <= 0.0:
return (0,255,0)
elif ratio <= 30.0:
return (0,255,255)
else:
return (0,0,255)
def draw_cap_circle_and_ratio(frame, cx, cy, diameter_px, stain_mask_full, *, stain_id=2):
"""
기존 함수 이름 유지.
변경 내용:
- 삭제/제거 X (연결성 필터, 열림연산 등 전부 제거)
- '픽셀 수'로만 stain 비율 계산
- 분모 = 이너서클 픽셀 총계
- 분자 = 이너서클 내 stain_id 픽셀 수
- rim(림 제외) 옵션만 유지 (기본 6%)
"""
import os
H, W = frame.shape[:2]
cx = int(np.clip(cx, 0, W - 1))
cy = int(np.clip(cy, 0, H - 1))
# 림 제외 비율
rim_frac = float(os.getenv('STAIN_RIM_FRAC', '0.06')) # 6%
r_out = int(max(1, round(diameter_px / 2.0)))
r_in = max(1, int(round(r_out * (1.0 - np.clip(rim_frac, 0.0, 0.3)))))
# ---- 분모(캡 전체) 마스크 생성 ----
cap_mask = np.zeros((H, W), dtype=np.uint8)
cv2.circle(cap_mask, (cx, cy), r_in, 255, -1)
# ---- 분자(stain 픽셀) 계산 (삭제/제거 무시) ----
stain_bin = (stain_mask_full == stain_id).astype(np.uint8) * 255
inter = cv2.bitwise_and(stain_bin, cap_mask)
stain_px = int(cv2.countNonZero(inter))
cap_px = int(cv2.countNonZero(cap_mask))
ratio = float(stain_px) / max(cap_px, 1) * 100.0
ratio = float(np.clip(ratio, 0.0, 100.0))
# ---- 오버레이 표시 (GUI용) ----
color = grade_from_ratio(ratio)
cv2.circle(frame, (cx, cy), r_out, (200, 120, 255), 1) # 원 가이드
cv2.circle(frame, (cx, cy), r_in, (255, 0, 255), 2) # 분모 영역
cv2.line(frame, (max(0, cx - r_in), cy), (min(W - 1, cx + r_in), cy), (255, 0, 255), 2)
cv2.putText(frame, f"{ratio:.1f}%", (max(0, cx - r_in), min(H - 10, cy + r_in + 24)),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
return ratio, stain_px, cap_px
# =========================
# Detection Worker Thread
# =========================
class DetectionWorker(threading.Thread):
"""
실시간 워커:
- 프레임에서 '디텍션만' 수행 (세그는 하지 않음)
- 게이트 박스 안 (full/center) 조건 충족 시 캡처
- 캡처된 원본 프레임을 snap_q로 전달 (DecideWorker가 Seg/판정)
- 프리뷰에는 bbox/게이트만 그려줌 (stain% 미표시)
"""
def __init__(self, get_frame_fn, config, snap_q=None):
super().__init__(daemon=True)
self.get_frame = get_frame_fn
self.cfg = config
self._stop = threading.Event()
self.last_annot = None
self.last_ratio = None # 실시간 ratio는 제공하지 않음
self.last_time = 0.0
# ONNX Runtime (Det only)
self.det_sess = ort_session(self.cfg['det_path'])
self.det_in_name = self.det_sess.get_inputs()[0].name
self.det_out_names = [o.name for o in self.det_sess.get_outputs()]
# labels
self.det_labels = [s.strip() for s in self.cfg['det_labels'].split(",") if s.strip()]
# gate / capture
self._gate_rect = None
self._last_capture_ts = 0.0
self.snap_q = snap_q if snap_q is not None else queue.Queue(maxsize=16)
# FPS
self._fps_t0 = time.time()
self._fps_n = 0
def stop(self):
self._stop.set()
def run(self):
while not self._stop.is_set():
frame = self.get_frame()
if frame is None:
time.sleep(0.01)
continue
try:
out = self._process_frame(frame)
self.last_annot = out
except Exception:
self.last_annot = frame
time.sleep(0.005)
def _process_frame(self, frame):
H, W = frame.shape[:2]
out_frame = frame.copy()
# ---------- 게이트 직사각형 초기화 ----------
if self._gate_rect is None:
roi_pct = self.cfg.get('roi_pct', '')
box_str = self.cfg.get('box', '')
cap_box = self.cfg.get('capture_box', '0.3,0.3')
if roi_pct:
L,T,R,B = [float(v) for v in roi_pct.split(",")]
self._gate_rect = rect_from_pct(W, H, L, T, R, B)
elif box_str:
bx,by,bw,bh = [int(v) for v in box_str.split(",")]
self._gate_rect = (bx, by, bw, bh)
else:
wr,hr = [float(x) for x in cap_box.split(",")] if "," in cap_box else (0.3,0.3)
self._gate_rect = rect_from_ratio(W, H, wr, hr)
# ---------- DET ----------
d_img, d_r, d_l, d_t = letterbox(frame, self.cfg['det_size'])
d_rgb = cv2.cvtColor(d_img, cv2.COLOR_BGR2RGB)
d_ten = d_rgb.transpose(2,0,1)[None].astype(np.float32)
if self.cfg.get('scale'): d_ten /= self.cfg['scale']
outs = self.det_sess.run(self.det_out_names, {self.det_in_name: d_ten})
d_arrs = []
for a in outs:
d_arrs.extend(a if isinstance(a, list) else [np.asarray(a)])
det = None
if len(d_arrs) >= 2:
a0, a1 = d_arrs[0], d_arrs[1]
if a0.ndim==3 and a0.shape[0]==1 and a0.shape[2]==5 and a1.ndim>=2:
det = decode_box5_plus_cls(a0, a1, self.cfg['det_size'], self.cfg['det_conf'])
elif a1.ndim==3 and a1.shape[0]==1 and a1.shape[2]==5 and a0.ndim>=2:
det = decode_box5_plus_cls(a1, a0, self.cfg['det_size'], self.cfg['det_conf'])
if det is None or (hasattr(det, "size") and det.size==0):
for a in d_arrs:
if (a.ndim==2 and a.shape[-1]>=6): det=a; break
if (a.ndim==3 and a.shape[0]==1 and a.shape[-1]>=6): det=a[0]; break
if det is None or (hasattr(det, "size") and det.size==0):
for a in d_arrs:
if a.ndim==4 and a.shape[-1]==7:
det = decode_detection_output(a, self.cfg['det_size'], self.cfg['det_conf'])
break
if det is None or (hasattr(det, "size") and det.size==0):
for a in d_arrs:
if a.ndim==3 and a.shape[0]==1 and (a.shape[1]>=6 or a.shape[2]>=6):
raw=a[0]
pred = raw if (raw.ndim==2 and raw.shape[0]>=6) else (raw.T if (raw.ndim==2 and raw.shape[1]>=6) else None)
if pred is None and raw.ndim==3:
tmp=raw.reshape(raw.shape[0],-1); pred=tmp if tmp.shape[0]>=6 else tmp.T
if pred is not None:
det = decode_yolox_raw(pred, self.cfg['det_size'], self.cfg['det_conf'])
break
if det is None:
det = np.zeros((0,6), np.float32)
det = normalize_dets(det, self.cfg['det_size'])
# ---------- 프리뷰: 게이트/박스만 ----------
draw_gate_box(out_frame, self._gate_rect, (0,255,255), 2)
if det.shape[0] == 0:
self._tick_fps(out_frame)
return out_frame
# 최고 score 하나 쓰기
x1,y1,x2,y2,score,cls = map(float, det[np.argmax(det[:,4])][:6])
gx1=int(np.clip((x1-d_l)/d_r,0,W-1)); gy1=int(np.clip((y1-d_t)/d_r,0,H-1))
gx2=int(np.clip((x2-d_l)/d_r,0,W-1)); gy2=int(np.clip((y2-d_t)/d_r,0,H-1))
if gx2<=gx1 or gy2<=gy1:
self._tick_fps(out_frame)
return out_frame
# 박스/라벨만 표시
draw_box(out_frame, gx1, gy1, gx2, gy2, score, cls, self.det_labels, color=PINK)
# ---------- 게이트 판정 + 캡처 ----------
gate_mode = self.cfg.get('gate_mode', 'full')
dyn_k = float(self.cfg.get('dynamic_diam_k', 0.90))
dyn_diam = max(10.0, dyn_k * min(gx2-gx1, gy2-gy1))
r_cap = int(round(dyn_diam / 2.0))
# 중심점(허프 보정 옵션 없음: Seg가 없으므로 ROI만으로 계산)
cx, cy = (gx1+gx2)//2, (gy1+gy2)//2
if gate_mode == "full":
inside_ok = circle_fully_inside_rect(cx, cy, r_cap, self._gate_rect)
else:
gx, gy, gw2, gh2 = self._gate_rect
inside_ok = (gx <= cx <= gx+gw2 and gy <= cy <= gy+gh2)
if inside_ok:
now = time.time()
cooldown = float(self.cfg.get('capture_cooldown', 0.2))
if now - self._last_capture_ts >= cooldown:
ts_ms = int(now * 1000)
# 옵션: raw 저장
if self.cfg.get('save_raw_caps', 0):
os.makedirs("captures/raw", exist_ok=True)
cv2.imwrite(f"captures/raw/{ts_ms}.jpg", frame)
# 스냅샷 전달 (DecideWorker가 Seg/판정)
try:
self.snap_q.put_nowait((ts_ms, frame.copy()))
except queue.Full:
try: self.snap_q.get_nowait()
except: pass
self.snap_q.put_nowait((ts_ms, frame.copy()))
self._last_capture_ts = now
self._tick_fps(out_frame)
return out_frame
def _tick_fps(self, out_frame):
self._fps_n += 1
dt = time.time() - self._fps_t0
if dt >= 0.5:
self._fps_t0 = time.time()
self._fps_n = 0
# =========================
# Decide Worker (캡쳐 후 오프라인 판정 + 액츄에이터 콜백)
# =========================
class DecideWorker(threading.Thread):
"""
오프라인 워커:
- DetectionWorker가 큐에 넣은 스냅샷을 소비
- '세그멘테이션 + mask remap + 오염비율 계산 + 등급(OK/NG30/NG80)'
- 주석이미지 저장 / CSV 로그 / 액츄에이터 콜백
"""
def __init__(self, snap_q: "queue.Queue", cfg: dict, on_decision_cb):
super().__init__(daemon=True)
self.snap_q = snap_q
self.cfg = cfg
self.on_decision = on_decision_cb
self._stop = threading.Event()
# ONNX Runtime (Seg + Det 재사용: 스냅샷에서도 1회 det 수행 → ROI 잡아 Seg)
self.det_sess = ort_session(self.cfg['det_path'])
self.seg_sess = ort_session(self.cfg['seg_path'])
self.det_in = self.det_sess.get_inputs()[0].name
self.seg_in = self.seg_sess.get_inputs()[0].name
self.det_outs = [o.name for o in self.det_sess.get_outputs()]
self.seg_out = self.seg_sess.get_outputs()[0].name
# label & seg map
self.det_labels = [s.strip() for s in self.cfg['det_labels'].split(",") if s.strip()]
self.seg_labels = [s.strip() for s in self.cfg['seg_labels'].split(",") if s.strip()]
self.seg_map = parse_seg_map(self.cfg['seg_map'], num_classes_hint=max(3, len(self.seg_labels) or 3)) # snap 스타일:contentReference[oaicite:12]{index=12}
self.stain_disp_id = self.seg_map.get(self.cfg['stain_id'], self.cfg['stain_id'])
self.sticker_disp_id = self.seg_map.get(self.cfg['sticker_id'], self.cfg['sticker_id'])
# --- [추가] 색상 분류 세션(옵션) ---
self.cls_sess = None
self.cls_in = None
self.cls_out = None
self.cls_labels = [s.strip() for s in (self.cfg.get('cls_labels') or '').split(',') if s.strip()]
self.cls_size = int(self.cfg.get('cls_size', 224))
self.expected_color = (self.cfg.get('expected_color') or '').strip().lower() or None
self.cls_map = parse_seg_map(self.cfg.get('cls_map',''), num_classes_hint=max(3, len(self.cls_labels) or 3))
try:
cls_path = self.cfg.get('cls_path')
if cls_path and os.path.isfile(cls_path):
self.cls_sess = ort_session(cls_path)
self.cls_in = self.cls_sess.get_inputs()[0].name
self.cls_out = self.cls_sess.get_outputs()[0].name
except Exception:
self.cls_sess = None
def stop(self):
self._stop.set()
def run(self):
# 로그/이미지 디렉토리
log_dir = "captures/log"
os.makedirs(log_dir, exist_ok=True)
log_path_v2 = os.path.join(log_dir, "decisions_v2.csv")
if self.cfg.get('log_csv', 1):
if not os.path.isfile(log_path_v2):
with open(log_path_v2, "w") as f:
# v2: ts,grade,color,color_conf,sticker,stain_pct
f.write("ts,grade,color,color_conf,sticker,stain_pct\n")
if self.cfg.get('save_annotated', 1):
os.makedirs("captures/annotated", exist_ok=True)
print("[DecideWorker] Warming up ONNX sessions...")
try:
# 1. Segmentation Model Warm-up (가장 무거울 가능성 높음)
# seg_size에 맞는 더미 입력 생성
dummy_seg_input = np.zeros(
(1, 3, self.cfg['seg_size'], self.cfg['seg_size']),
dtype=np.float32
)
self.seg_sess.run([self.seg_out], {self.seg_in: dummy_seg_input})
print("[DecideWorker] Segmentation model warmed up.")
# 2. Detection Model Warm-up (DecideWorker에서도 사용)
dummy_det_input = np.zeros(
(1, 3, self.cfg['det_size'], self.cfg['det_size']),
dtype=np.float32
)
self.det_sess.run(self.det_outs, {self.det_in: dummy_det_input})
print("[DecideWorker] Detection model warmed up.")
# 3. Classification Model Warm-up (옵션, 있다면)
if self.cls_sess is not None:
dummy_cls_input = np.zeros(
(1, 3, self.cls_size, self.cls_size),
dtype=np.float32
)
self.cls_sess.run([self.cls_out], {self.cls_in: dummy_cls_input})
print("[DecideWorker] Classification model warmed up.")
except Exception as e:
print(f"[DecideWorker] WARNING: Failed to warm up session: {e}")
# ---------------------------------------------------
while not self._stop.is_set():
try:
ts_ms, snap_bgr = self.snap_q.get(timeout=0.25)
except queue.Empty:
continue
try:
grade, ratio, annotated, has_sticker, pred_color, pred_conf = self._analyze_one(snap_bgr)
# 저장 파일명에 color 태그 포함
if self.cfg.get('save_annotated', 1):
color_tag = f"color-{pred_color}" if pred_color else "color-NA"
sticker_tag = "yes" if has_sticker else "no"
outp = f"captures/annotated/{grade}_{color_tag}_sticker-{sticker_tag}_{ratio:.1f}pct_{ts_ms}.jpg"
cv2.imwrite(outp, annotated)
# CSV(v2): ts,grade,color,color_conf,sticker,stain_pct
if self.cfg.get('log_csv', 1):
cc = f"{pred_conf:.3f}" if (pred_conf is not None) else ""
with open(log_path_v2, "a") as f:
f.write(f"{ts_ms},{grade},{pred_color or ''},{cc},{'1' if has_sticker else '0'},{ratio:.3f}\n")
# 기존 콜백(시그니처 그대로)
if callable(self.on_decision):
self.on_decision(grade, ratio, has_sticker)
except Exception as e:
pass
def _analyze_one(self, img_bgr):
"""
snap_after_seg3.py의 analyze_one_frame 로직을 run.py 스타일로 이식
(det → ROI → seg → mask remap → 비율 계산 → 등급)
"""
H, W = img_bgr.shape[:2]
# ---------- gate (표시용) ----------
if self.cfg.get('roi_pct'):
L,T,R,B = [float(v) for v in self.cfg['roi_pct'].split(",")]
gate = rect_from_pct(W, H, L, T, R, B)
elif self.cfg.get('box'):
bx,by,bw,bh = [int(v) for v in self.cfg['box'].split(",")]
gate = (bx,by,bw,bh)
else:
wr,hr = [float(x) for x in self.cfg.get('capture_box','0.3,0.3').split(",")]
gate = rect_from_ratio(W, H, wr, hr)
annotated = img_bgr.copy()
draw_gate_box(annotated, gate, (0,255,255), 2)
# ---------- det (스냅샷에서 다시 한 번) ----------
d_img, d_r, d_l, d_t = letterbox(img_bgr, self.cfg['det_size'])
d_rgb = cv2.cvtColor(d_img, cv2.COLOR_BGR2RGB)
d = d_rgb.transpose(2,0,1)[None].astype(np.float32)
if self.cfg.get('scale'): d /= self.cfg['scale']
outs = self.det_sess.run(self.det_outs, {self.det_in:d})
arrs=[]; [arrs.extend(a if isinstance(a,list) else [np.asarray(a)]) for a in outs]
det=None
if len(arrs)>=2:
a0,a1=arrs[0],arrs[1]
if a0.ndim==3 and a0.shape[0]==1 and a0.shape[2]==5 and a1.ndim>=2:
det=decode_box5_plus_cls(a0,a1,self.cfg['det_size'],self.cfg['det_conf'])
elif a1.ndim==3 and a1.shape[0]==1 and a1.shape[2]==5 and a0.ndim>=2:
det=decode_box5_plus_cls(a1,a0,self.cfg['det_size'],self.cfg['det_conf'])
if det is None or (hasattr(det, "size") and det.size==0):
for a in arrs:
if (a.ndim==2 and a.shape[-1]>=6): det=a; break
if (a.ndim==3 and a.shape[0]==1 and a.shape[-1]>=6): det=a[0]; break
if det is None or (hasattr(det, "size") and det.size==0):
for a in arrs:
if a.ndim==4 and a.shape[-1]==7:
det=decode_detection_output(a,self.cfg['det_size'],self.cfg['det_conf']); break
if det is None or (hasattr(det, "size") and det.size==0):
for a in arrs:
if a.ndim==3 and a.shape[0]==1 and (a.shape[1]>=6 or a.shape[2]>=6):
raw=a[0]; pred=raw if (raw.ndim==2 and raw.shape[0]>=6) else (raw.T if (raw.ndim==2 and raw.shape[1]>=6) else None)
if pred is None and raw.ndim==3:
tmp=raw.reshape(raw.shape[0],-1); pred=tmp if tmp.shape[0]>=6 else tmp.T
if pred is not None: det=decode_yolox_raw(pred,self.cfg['det_size'],self.cfg['det_conf']); break
if det is None:
det=np.zeros((0,6),np.float32)
if det.shape[0]==0:
_label_badge(annotated, 12, 40, "NO_CAP", (40,40,40), WHITE)
return "NO_CAP", 0.0, annotated, False,None,None # [PATCH] has_sticker=False
# 최고 score 1개 ROI
x1,y1,x2,y2,score,cls = map(float, det[np.argmax(det[:,4])][:6])
gx1=int(np.clip((x1-d_l)/d_r,0,W-1)); gy1=int(np.clip((y1-d_t)/d_r,0,H-1))
gx2=int(np.clip((x2-d_l)/d_r,0,W-1)); gy2=int(np.clip((y2-d_t)/d_r,0,H-1))
if gx2<=gx1 or gy2<=gy1:
return "NO_CAP", 0.0, annotated, False, None, None
roi = annotated[gy1:gy2, gx1:gx2]
if roi.size==0:
return "NO_CAP", 0.0, annotated, False, None, None
# ---------- [추가] 색상 분류 ----------
pred_color = None; pred_conf = None
if self.cls_sess is not None:
try:
cls_in = _prepare_cls_input(roi, self.cls_size, scale=(self.cfg.get('scale') or 255.0))
logits = self.cls_sess.run([self.cls_out], {self.cls_in: cls_in})[0]
raw_idx, conf = _argmax_logits(logits)
idx = self.cls_map.get(int(raw_idx), int(raw_idx)) if hasattr(self, 'cls_map') and isinstance(self.cls_map, dict) else int(raw_idx)
pred_color = (self.cls_labels[idx] if 0 <= idx < len(self.cls_labels) else str(idx)).lower()
pred_color = (pred_color or 'unknown').lower()
pred_conf = conf
except Exception:
pass
# ---------- seg ----------
s_img, s_r, s_l, s_t = letterbox(roi, self.cfg['seg_size'])
s_rgb = cv2.cvtColor(s_img, cv2.COLOR_BGR2RGB)
s = s_rgb.transpose(2,0,1)[None].astype(np.float32)
if self.cfg.get('scale'): s /= self.cfg['scale']
s_res = self.seg_sess.run([self.seg_out], {self.seg_in:s})[0]
# multi-class / binary 대응
if s_res.ndim==4 and s_res.shape[1]>1:
prob = softmax(s_res, axis=1)[0]
mask = np.argmax(prob, axis=0).astype(np.uint8)
else:
mask = (s_res[0,0] > 0.5).astype(np.uint8)
# 표기가 원하는 class id에 맞게 remap
mask = remap_mask(mask, self.seg_map)
# letterbox 되돌리기 (ROI 크기와 정합)
canvas = np.zeros((self.cfg['seg_size'], self.cfg['seg_size']), dtype=np.uint8)
nh = int(round(roi.shape[0]*s_r)); nw = int(round(roi.shape[1]*s_r))
top = (self.cfg['seg_size']-nh)//2; left=(self.cfg['seg_size']-nw)//2
mask_rs = cv2.resize(mask,(nw,nh),interpolation=cv2.INTER_NEAREST)
canvas[top:top+nh, left:left+nw] = mask_rs
mask_roi = cv2.resize(canvas,(roi.shape[1],roi.shape[0]),interpolation=cv2.INTER_NEAREST)
# ROI 오버레이
annotated[gy1:gy2, gx1:gx2] = draw_seg_on_roi(
roi, mask_roi, self.seg_labels,
sticker_cid=self.sticker_disp_id,
stain_cid=self.stain_disp_id
)
# ---------- [추가] COLOR 배지(프리뷰/저장 공통) ----------
color_mismatch = False
if self.expected_color and pred_color:
color_mismatch = (pred_color != self.expected_color)
if pred_color:
badge_txt = f"COLOR: {pred_color.upper()}"
if pred_conf is not None:
badge_txt += f" ({pred_conf*100:.0f}%)"
_label_badge(
annotated,
max(0, gx1), max(0, gy1-36),
badge_txt,
(0,0,255) if color_mismatch else (80,80,80),
WHITE
)
# ---------- STICKER 여부 판단 + 상단 배지 출력 [PATCH] ----------
has_sticker = bool(np.any(mask_roi == self.sticker_disp_id))
badge_text = f"STICKER: {'YES' if has_sticker else 'NO'}"
badge_bg = (40,180,40) if has_sticker else (50,50,200)
# 박스 상단 중앙 쯤에 뱃지
bx = max(12, gx1 + (gx2 - gx1)//2 - 80)
by = max(24, gy1 - 10)
_label_badge(annotated, bx, by, badge_text, badge_bg, WHITE)
# ---------- 오염 비율 (원형 캡 내 비율) ----------
# 동적 지름: 디텍션 박스 기반 or 허프 보정
dyn_k = float(self.cfg.get('dynamic_diam_k', 0.90))
dyn_diam = max(10.0, dyn_k * min(gx2-gx1, gy2-gy1))
cx, cy = (gx1+gx2)//2, (gy1+gy2)//2
if self.cfg.get('use_hough'):
hc = try_hough_circle(annotated[gy1:gy2, gx1:gx2])
if hc is not None:
rx, ry, rr = hc
cx, cy = gx1 + rx, gy1 + ry
dyn_diam = max(dyn_diam, rr * 2.0)
stain_full = np.zeros((H,W), np.uint8)
stain_full[gy1:gy2, gx1:gx2] = np.where(mask_roi==self.stain_disp_id, self.stain_disp_id, 0)
# [PATCH] 픽셀 카운트도 받는다
ratio, stain_px, cap_px = draw_cap_circle_and_ratio(
annotated, cx, cy, dyn_diam, stain_full, stain_id=self.stain_disp_id
)
ratio = float(np.clip(ratio, 0.0, 100.0))
# [PATCH] 좌측 상단에 메트릭 표기
sticker_line = f"sticker: {'YES' if has_sticker else 'NO'}"
lines = [
sticker_line,
f"cap_px: {cap_px}",
f"stain_px: {stain_px}",
f"stain: {ratio:.1f}%",
]
colors = [
(40,180,40) if has_sticker else (50,50,200),
(255,255,255),
(255,255,255),
(255,255,255),
]
# ---------- [추가] 메트릭 첫 줄에 color ----------
if pred_color:
# 숫자일 경우 라벨 이름으로 변환
if isinstance(pred_color, (int, np.integer)) and hasattr(self, "cls_labels"):
if 0 <= pred_color < len(self.cls_labels):
pred_color = self.cls_labels[pred_color]
else:
pred_color = str(pred_color)
lines.insert(0, f"color: {pred_color} ({pred_conf*100:.0f}% )" if pred_conf is not None else f"color: {pred_color}")
colors.insert(0, (0,0,255) if color_mismatch else (255,255,255))
put_lines_top_left(annotated, lines, colors)
# ---------- 등급 판정 ----------
if color_mismatch:
grade = "NG80" # 색상 불일치 = 완전불량(강제)
elif not has_sticker:
grade = "NG_STICKER" # 스티커 없음 = 불량
elif ratio <= 0.0:
grade = "OK"
elif ratio <= 30.0:
grade = "NG30"
else:
grade = "NG80"
# 중앙 배지: 등급 | STAIN xx.x%
_label_badge(annotated, max(0,cx-60), max(0,cy-80), f"{grade} | STAIN {ratio:.1f}%", (40,40,40), WHITE)
draw_box(annotated, gx1, gy1, gx2, gy2, score, cls, self.det_labels, color=PINK)
return grade, ratio, annotated, has_sticker, pred_color, pred_conf # [PATCH] sticker flag 반환
# =========================
# Hardware / DB / Serial
# =========================
class HW:
"""FactoryController 어댑터: 릴레이 8개 + 센서 4개 + 시스템 시작/정지"""
def __init__(self, port=None, debug=True):
self.ctrl = None
self.is_dummy = True
self.dev_name = "dummy"
self.state = {i: False for i in range(1,9)}
if FactoryController:
try:
self.ctrl = FactoryController(port=port, debug=debug)
self.is_dummy = self.ctrl.is_dummy
self.dev_name = getattr(self.ctrl, "_FactoryController__device_name", "unknown")
except Exception:
self.ctrl = None
self.DEV_ON = getattr(FactoryController, "DEV_ON", False)
self.DEV_OFF = getattr(FactoryController, "DEV_OFF", True)
self.relay_map = {
1: getattr(Outputs, "BEACON_RED", None),
2: getattr(Outputs, "BEACON_ORANGE", None),
3: getattr(Outputs, "BEACON_GREEN", None),
4: getattr(Outputs, "BEACON_BUZZER", None),
5: getattr(Outputs, "LED", None),
6: getattr(Outputs, "CONVEYOR_EN", None),
7: getattr(Outputs, "ACTUATOR_1", None),
8: getattr(Outputs, "ACTUATOR_2", None),
}
self.pwm_pin = getattr(Outputs, "CONVEYOR_PWM", None)
def set_relay(self, idx:int, on:bool):
self.state[idx] = on
if not self.ctrl: return
pin = self.relay_map.get(idx)
if pin is None: return
dev = getattr(self.ctrl, "_FactoryController__device", None)
if dev is None: return
# Conveyor EN(active-low), PWM 연동
if pin == getattr(Outputs, "CONVEYOR_EN", -1):
level = self.DEV_OFF if on else self.DEV_ON
try:
dev.set(pin, level)
if self.pwm_pin is not None:
dev.set(self.pwm_pin, 0 if on else 255)
except Exception:
pass
return
try:
dev.set(pin, self.DEV_ON if on else self.DEV_OFF)
except Exception:
pass
def system_start(self):
if hasattr(self.ctrl, "system_start"):
try: self.ctrl.system_start()
except Exception: pass
def system_stop(self):
if hasattr(self.ctrl, "system_stop"):
try: self.ctrl.system_stop()
except Exception: pass
def get_sensors(self):
res = {"start_button": False, "stop_button": False, "sensor_1": False, "sensor_2": False}
if not self.ctrl: return res
dev = getattr(self.ctrl, "_FactoryController__device", None)
name = getattr(self.ctrl, "_FactoryController__device_name", "")
if name == "arduino" and dev is not None and Inputs:
try:
res["start_button"] = (dev.get(Inputs.START_BUTTON) == 0)
res["stop_button"] = (dev.get(Inputs.STOP_BUTTON) == 0)
res["sensor_1"] = (dev.get(Inputs.PHOTOELECTRIC_SENSOR_1) == 0)
res["sensor_2"] = (dev.get(Inputs.PHOTOELECTRIC_SENSOR_2) == 0)
except Exception:
pass
return res
def get_outputs(self):
out = {}
if not self.ctrl:
return dict(self.state)
dev = getattr(self.ctrl, "_FactoryController__device", None)
name = getattr(self.ctrl, "_FactoryController__device_name", "")
for idx, pin in self.relay_map.items():
val = None
if name == "arduino" and dev is not None and pin is not None:
try:
raw = dev.get(pin)
if raw in (0,1,True,False):
# active-low: 0이면 ON (단, EN은 반대)
val = (raw == 0) if pin != getattr(Outputs,"CONVEYOR_EN",-1) else (raw == 1)
except Exception:
val = None
out[idx] = self.state[idx] if val is None else bool(val)
return out
def close(self):
try:
if self.ctrl: self.ctrl.close()
except Exception:
pass
# ==== Env / Config ====
DB_HOST = os.getenv('DB_HOST', '10.10.14.17')
DB_USER = os.getenv('DB_USER', 'root')
DB_PASS = os.getenv('DB_PASS', '2453.')
DB_NAME = os.getenv('DB_NAME', 'iotdb')
DB_CHARSET = 'utf8mb4'
DB_TABLE_6 = os.getenv('DB_TABLE_6', 'qc_agg6') # ts, id, m1_good,m1_sev,m1_par,m2_good,m2_sev,m2_par
PARTIAL_WEIGHT = float(os.getenv('PARTIAL_WEIGHT', '0.3'))
# Camera1
CAM_INDEX = int(os.getenv('CAM_INDEX', '0'))
CAM_REQ_W = int(os.getenv('CAM_REQ_W', '640'))
CAM_REQ_H = int(os.getenv('CAM_REQ_H', '480'))
CAM_VIEW_W = int(os.getenv('CAM_VIEW_W', '560'))
CAM_VIEW_H = int(os.getenv('CAM_VIEW_H', '315'))
CAM_FPS = int(os.getenv('CAM_FPS', '30'))
CAM_BUFFERS = int(os.getenv('CAM_BUFFERS', '1'))
CAM_FOURCC = os.getenv('CAM_FOURCC', 'MJPG')
CAM_BACKEND = os.getenv('CAM_BACKEND', '')
RESAMPLE = os.getenv('RESAMPLE', 'LANCZOS')
# Camera2
CAM2_INDEX = int(os.getenv('CAM2_INDEX', '2'))
CAM2_REQ_W = int(os.getenv('CAM2_REQ_W', '640'))
CAM2_REQ_H = int(os.getenv('CAM2_REQ_H', '480'))
CAM2_VIEW_W = int(os.getenv('CAM2_VIEW_W', str(CAM_VIEW_W)))
CAM2_VIEW_H = int(os.getenv('CAM2_VIEW_H', str(CAM_VIEW_H)))
CAM2_FPS = int(os.getenv('CAM2_FPS', str(CAM_FPS)))
CAM2_FOURCC = os.getenv('CAM2_FOURCC', 'MJPG')
CAM2_BACKEND = os.getenv('CAM2_BACKEND', '')
def parse_window(win_str):
try:
if win_str.endswith('m'): return timedelta(minutes=int(win_str[:-1]))
if win_str.endswith('h'): return timedelta(hours=int(win_str[:-1]))
if win_str.endswith('d'): return timedelta(days=int(win_str[:-1]))
except Exception:
pass
return timedelta(minutes=5)
# ==== DB helper ====
def db_query(sql, args=None):
if pymysql is None:
raise RuntimeError("pymysql not installed. pip install pymysql")
conn = pymysql.connect(host=DB_HOST, user=DB_USER, password=DB_PASS,
db=DB_NAME, charset=DB_CHARSET, autocommit=True)
try:
with conn.cursor() as c:
c.execute(sql, args or ())
if sql.strip().lower().startswith("select"):
rows = c.fetchall()
return rows, [d[0] for d in c.description]
else:
return (), []
finally:
conn.close()
# ==== Camera Thread (dual) ====
class CameraThread(threading.Thread):
def __init__(self, index=0, req_w=None, req_h=None, fps=None, fourcc=None, buffers=None, backend=None):
super().__init__(daemon=True)
self.index=index; self.frame=None; self._stop=threading.Event(); self.cap=None
self._lock = threading.Lock()
self._req_w = req_w or CAM_REQ_W
self._req_h = req_h or CAM_REQ_H
self._fps = fps or CAM_FPS
self._fourcc = (fourcc or CAM_FOURCC)[:4]
self._buffers = buffers if buffers is not None else CAM_BUFFERS
self._backend = (backend or CAM_BACKEND).upper()
def _open(self):
if cv2 is None: return None
backend_map={'V4L2': cv2.CAP_V4L2, 'DSHOW': cv2.CAP_DSHOW, 'MSMF': cv2.CAP_MSMF}
backend_flag = backend_map.get(self._backend, 0)
cap = cv2.VideoCapture(self.index, backend_flag) if backend_flag else cv2.VideoCapture(self.index)
if not (cap and cap.isOpened()):
for flag in [cv2.CAP_V4L2, cv2.CAP_MSMF, cv2.CAP_DSHOW]:
cap = cv2.VideoCapture(self.index, flag)
if cap and cap.isOpened():
break
if not (cap and cap.isOpened()): return None
try: cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
except Exception: pass
try: cap.set(cv2.CAP_PROP_BUFFERSIZE, self._buffers)
except Exception: pass
try:
fourcc = cv2.VideoWriter_fourcc(*self._fourcc)
cap.set(cv2.CAP_PROP_FOURCC, fourcc)
except Exception: pass
try: cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._req_w)
except Exception: pass
try: cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._req_h)
except Exception: pass
try: cap.set(cv2.CAP_PROP_FPS, self._fps)
except Exception: pass
time.sleep(0.1)
ok, f = cap.read()
if not ok or f is None:
try: cap.release()
except Exception: pass
return None
return cap
def run(self):
while not self._stop.is_set():
cap = self._open()
self.cap = cap
if not cap:
time.sleep(1.5); continue
interval = 1.0 / max(5, self._fps)
while not self._stop.is_set():
if not cap.grab():
time.sleep(0.05)
ok, _ = cap.read()
if not ok:
try: cap.release()
except Exception: pass
break
ok, frame = cap.retrieve()
if ok:
with self._lock:
self.frame = frame
else:
try: cap.release()
except Exception: pass
break
time.sleep(interval * 0.5)
def get_frame(self):
with self._lock:
return None if self.frame is None else self.frame.copy()
def stop(self):
self._stop.set()
try:
if self.cap: self.cap.release()
except Exception: pass
# ====== 작은 유틸 ======
def _has_model(cfg: dict) -> bool:
return os.path.isfile(cfg.get('det_path','')) and os.path.isfile(cfg.get('seg_path',''))
# =========================
# App (Tk GUI)
# =========================
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Factory QC — Dual Camera + DB(6-cols) + Conveyor + Device Panel (overlay + delayed pulse)")
self.geometry("1360x980")
# 신호등(비콘) 상태 추적
self._last_beacon = 'none'
self._beacon_after = None
self._beacon_busy = False # 부팅 시퀀스 중에는 비콘 제어 잠금
# 하드웨어 컨트롤러
self.hw = HW(port=None, debug=True)
# --- 시스템 실행 상태 / arming ---
self._is_running = False # RUN/STOP 램프용
self.active_model = 0 # 0=none, 1=M1, 2=M2
# 컨트롤러 초기화 직후 강제 STOP (시작 깜빡임 방지)
try:
if self.hw:
self.hw.system_stop()
self.hw.set_relay(6, False) # Conveyor OFF
except Exception:
pass
# 부팅 인디케이터: RED → ORANGE → GREEN → ALL OFF
try:
self._beacon_boot_chase(step_ms=250, hold_ms=1000)
except Exception:
pass
try:
if self.hw:
for idx in (1, 2, 3, 4, 5, 7, 8): # 1=R,2=O,3=G,4=Buzzer,5=LED,7/8=Actuators
self.hw.set_relay(idx, False)
except Exception:
pass
# 시작 후 1초 지연 기간(arming): 자동동작/버튼 RUN 차단
self._armed = False
self.after(1000, lambda: setattr(self, "_armed", True))
# Top bar
top = ttk.Frame(self); top.pack(fill='x', padx=8, pady=6)
try:
from PIL import Image, ImageTk
logo_path = os.path.join(os.path.dirname(__file__), "logo.png")
if os.path.exists(logo_path):
logo_img = Image.open(logo_path).resize((42, 30))
self._logo_tk = ImageTk.PhotoImage(logo_img)
ttk.Label(top, image=self._logo_tk).pack(side="left", padx=(2, 10))
else:
ttk.Label(top, text="LOGO", font=("Arial", 12, "bold")).pack(side="left", padx=(2, 10))
except Exception as e:
pass
self.btn_m1 = ttk.Button(top, text="Model 1 · RUN", command=lambda:self.on_run(1))
self.btn_m2 = ttk.Button(top, text="Model 2 · RUN", command=lambda:self.on_run(2))
self.btn_stop = ttk.Button(top, text="STOP", command=self.on_stop)
self.status_lbl = ttk.Label(top, text="대기")
ttk.Label(top, text="RUN:").pack(side='left', padx=(0,6))
self.win_var = tk.StringVar(value='5m')
self.win_box = ttk.Combobox(top, textvariable=self.win_var, values=['5m','30m','1h','1d'], width=6, state='readonly')
self.btn_m1.pack(side='left', padx=4); self.btn_m2.pack(side='left', padx=4); self.btn_stop.pack(side='left', padx=4)
self.win_box.pack(side='left'); ttk.Label(top, text=" 상태:").pack(side='left', padx=(16,4)); self.status_lbl.pack(side='left')
# --- [ADMIN] Hidden toggle hotspot & keybinding ---
self._admin_visible = False
self._admin_win = None # Toplevel 창 캐시
self._hidden_hotspot = tk.Label(top, text=" ", background=self.cget("bg"))
self._hidden_hotspot.place(relx=1.0, x=-2, y=2, anchor="ne")
self._hidden_hotspot.config(width=1, height=1)
self._hidden_hotspot.bind("<Button-1>", lambda e: self._toggle_admin_panel())
self.bind_all("<Control-Alt-a>", lambda e: self._toggle_admin_panel())
# 모델별 상태 라벨
self.m1_state = tk.StringVar(value='대기')
self.m2_state = tk.StringVar(value='대기')
ttk.Label(top, text=" | M1:").pack(side='left', padx=(14,2))
ttk.Label(top, textvariable=self.m1_state).pack(side='left')
ttk.Label(top, text=" M2:").pack(side='left', padx=(8,2))
ttk.Label(top, textvariable=self.m2_state).pack(side='left')
# Grid layout
root = ttk.Frame(self); root.pack(fill='both', expand=True, padx=8, pady=6)
root.grid_columnconfigure(0, weight=1)
root.grid_columnconfigure(1, weight=1)
root.grid_rowconfigure(0, weight=1) # 카메라 영역
root.grid_rowconfigure(1, weight=3) # 모델/최근(오른쪽) 크게
root.grid_rowconfigure(2, weight=1) # 장치 패널
# KPI 확장용 변수
self.k_defw_abs = tk.StringVar(value='-')
self.k_tput = tk.StringVar(value='-')
self.k_updated = tk.StringVar(value='-')
# 오버레이에 같이 표기할 실시간 stain% 라벨
self.r1_var = tk.StringVar(value='CAM1: - %')
self.r2_var = tk.StringVar(value='CAM2: - %')
# KPI
kpi = ttk.LabelFrame(root, text="통합 KPI")
kpi.grid(row=0, column=0, sticky='nsew', padx=(0,6), pady=(0,6))
self.var_total = tk.StringVar(value='-'); self.var_good = tk.StringVar(value='-')
self.var_defw = tk.StringVar(value='-'); self.var_rate = tk.StringVar(value='-')
g = ttk.Frame(kpi); g.pack(fill='x', padx=8, pady=8)
self._row(g,0,"총 처리", self.var_total); self._row(g,1,"정상품", self.var_good)
self._row(g,2,"불량", self.var_defw); self._row(g,3,"불량률", self.var_rate)
self._row(g,4,"불량(가중)", self.k_defw_abs)
self._row(g,5,"처리속도", self.k_tput)
self._row(g,6,"업데이트", self.k_updated)
ttk.Label(kpi, textvariable=self.r1_var).pack(anchor="w", padx=10)
ttk.Label(kpi, textvariable=self.r2_var).pack(anchor="w", padx=10)
self.pb = ttk.Progressbar(kpi, orient='horizontal', length=400, mode='determinate', maximum=100)
self.pb.pack(padx=8, pady=(4,10))
# Cameras (overlay 표시)
camf = ttk.LabelFrame(root, text="USB 카메라")
camf.grid(row=0, column=1, sticky='nsew', padx=(6,0), pady=(0,6))
camgrid = ttk.Frame(camf); camgrid.pack(fill='both', expand=True, padx=6, pady=6)
camgrid.grid_columnconfigure(0, weight=1); camgrid.grid_columnconfigure(1, weight=1)
self.cam1_lbl = ttk.Label(camgrid, text="CAM1 초기화 중...")
self.cam2_lbl = ttk.Label(camgrid, text="CAM2 초기화 중...")
self.cam1_lbl.grid(row=0, column=0, padx=4, pady=4, sticky='nsew')
self.cam2_lbl.grid(row=0, column=1, padx=4, pady=4, sticky='nsew')
self.cam1 = CameraThread(index=CAM_INDEX,
req_w=CAM_REQ_W, req_h=CAM_REQ_H, fps=CAM_FPS,
fourcc=CAM_FOURCC, backend=CAM_BACKEND)
self.cam2 = CameraThread(index=CAM2_INDEX,
req_w=CAM2_REQ_W, req_h=CAM2_REQ_H, fps=CAM2_FPS,
fourcc=CAM2_FOURCC, backend=CAM2_BACKEND)
self.cam1.start(); self.cam2.start()
# Detection workers: CAM1(모델1), CAM2(모델2)
self.det_cfg_base = {
# 모델 2 입니다.
# --- [추가] 색상 분류(classification) 관련 ---
'cls_path': os.path.expanduser(os.getenv('CLASS_MODEL', os.path.join(os.path.dirname(os.path.abspath(__file__)), "class.onnx"))),
'cls_labels': os.getenv('CLASS_LABELS', 'pink,purple'),
'cls_size': int(os.getenv('CLASS_SIZE', '224')),
'cls_map': os.getenv('CLASS_MAP', '1:0,2:1'),
'expected_color': None, # 개별 모델 cfg에서 채움 (model1=pink, model2=purple)
'det_path': os.path.expanduser(os.getenv('DET_MODEL', 'det.onnx')),
'seg_path': os.path.expanduser(os.getenv('SEG_MODEL', 'seg.onnx')),
'det_size': int(os.getenv('DET_SIZE', '640')),
'seg_size': int(os.getenv('SEG_SIZE', '512')),
'det_conf': float(os.getenv('DET_CONF', '0.35')),
'det_labels': os.getenv('DET_LABELS', 'cap'),
'seg_labels': os.getenv('SEG_LABELS', 'background,sticker,stain'),
'seg_map': os.getenv('SEG_MAP', '0:0,1:2,2:1'),
'scale': float(os.getenv('SCALE', '255.0')),
'cap_diam': float(os.getenv('CAP_DIAM', '170')),
'stain_id': int(os.getenv('STAIN_ID', '1')),
'sticker_id': int(os.getenv('STICKER_ID', '2')),
'use_hough': bool(int(os.getenv('USE_HOUGH', '1'))),
# --- [추가] 게이트/캡쳐 관련 ---
'roi_pct': os.getenv('ROI_PCT', '0.3, 0.3, 0.7, 0.7'), # "L,T,R,B" (0~1)
'box': os.getenv('BOX', ''), # "x,y,w,h" (px)
'capture_box': os.getenv('CAPTURE_BOX', '0.3,0.3'), # 중앙 비율 (백업)
'gate_mode': os.getenv('GATE_MODE', 'full'), # full|center
'capture_cooldown': float(os.getenv('CAPTURE_COOLDOWN', '0.2')),
'dynamic_diam_k': float(os.getenv('DYN_DIAM_K', '0.90')), # 검출박스 기반 지름 스케일
'save_raw_caps': int(os.getenv('SAVE_RAW_CAPS', '0')), # 게이트 통과시 원본컷 저장
'save_annotated': int(os.getenv('SAVE_ANNOTATED', '1')), # 판정 결과 이미지 저장
'log_csv': int(os.getenv('LOG_CSV', '1')), # 판정 CSV 로깅
}
model_dir = os.path.dirname(os.path.abspath(__file__))
DEFAULT_DET_LOCAL = os.path.join(model_dir, "det.onnx")
DEFAULT_SEG_LOCAL = os.path.join(model_dir, "seg.onnx")
# Model 1 (핑크 전용)
self.det_cfg_m1 = dict(self.det_cfg_base)
self.det_cfg_m1['det_path'] = os.path.expanduser(os.getenv('DET_MODEL_M1', os.getenv('DET_MODEL', DEFAULT_DET_LOCAL)))
self.det_cfg_m1['seg_path'] = os.path.expanduser(os.getenv('SEG_MODEL_M1', os.getenv('SEG_MODEL', DEFAULT_SEG_LOCAL)))
self.det_cfg_m1['cls_path'] = os.path.expanduser(os.getenv('CLASS_MODEL_M1', self.det_cfg_base['cls_path']))
self.det_cfg_m1['expected_color'] = os.getenv('EXPECTED_COLOR_M1', 'pink')
# Model 2 (퍼플 전용)
self.det_cfg_m2 = dict(self.det_cfg_base)
self.det_cfg_m2['det_path'] = os.path.expanduser(os.getenv('DET_MODEL_M2', os.getenv('DET_MODEL', self.det_cfg_base['det_path'])))
self.det_cfg_m2['seg_path'] = os.path.expanduser(os.getenv('SEG_MODEL_M2', os.getenv('SEG_MODEL', self.det_cfg_base['seg_path'])))
self.det_cfg_m2['cls_path'] = os.path.expanduser(os.getenv('CLASS_MODEL_M2', self.det_cfg_base['cls_path']))
self.det_cfg_m2['expected_color'] = os.getenv('EXPECTED_COLOR_M2', 'purple')
self.worker1 = None
self.worker2 = None
self._snap_q = queue.Queue(maxsize=16)
self._decider = None
self._decider = DecideWorker(self._snap_q, self.det_cfg_m1, self._on_decision)
self._decider.start()
self.status_lbl.config(text="대기")
# Models (요약 패널)
model_area = ttk.Frame(root); model_area.grid(row=1, column=0, sticky='nsew', padx=(0,6))
model_area.grid_columnconfigure(0, weight=1); model_area.grid_columnconfigure(1, weight=1)
self.m1_vars = {k: tk.StringVar(value='-') for k in ['good','sev','par','par_weight','def_weight','overall','rate']}
self.m2_vars = {k: tk.StringVar(value='-') for k in ['good','sev','par','par_weight','def_weight','overall','rate']}
self._model_panel_simple(model_area, "Model 1", self.m1_vars).grid(row=0, column=0, sticky='nsew', padx=(0,6))
self._model_panel_simple(model_area, "Model 2", self.m2_vars).grid(row=0, column=1, sticky='nsew', padx=(6,0))
# Recent — DB 전용
recent = ttk.LabelFrame(root, text="최근 50건")
recent.grid(row=1, column=1, rowspan=2, sticky='nsew', padx=(6,0), pady=(0,0))
self.tree = ttk.Treeview(
recent,
columns=("ts","m1_good","m1_sev","m1_par","m2_good","m2_sev","m2_par"),
show='headings', height=24
)
for col, text, w in [
("ts","시간",170),("m1_good","M1 정상",70),("m1_sev","M1 완전 불량",70),("m1_par","M1 부분 불량",70),
("m2_good","M2 정상",70),("m2_sev","M2 완전 불량",70),("m2_par","M2 부분 불량",70)
]:
self.tree.heading(col, text=text)
self.tree.column(col, width=w, anchor='center')
self.tree.pack(fill='both', expand=True, padx=6, pady=6)
self._fill_recent50_once()
# (너의 Treeview 생성 블록 바로 아래)
self._recent_last_key = None # ← 마지막으로 본 (id,ts) 키 저장
self.after(300, self._recent_tick) # ← 0.3초 뒤부터 주기 갱신 시작
# 장치 패널 — 가로 전체
self._build_device_panel(root)
# 처음 화면에 STOP 램프가 반드시 들어오도록
self._sync_start_stop_lamps()
# === 액츄에이터 제어용: 디바운스 + 지연(arming) ===
self._act1_last = 0.0
self._act2_last = 0.0
self._act_debounce = float(os.getenv('ACT_DEBOUNCE_SEC', '0.2')) # 0.8초 디바운스
self._act_delay = float(os.getenv('ACT_DELAY_SEC', '0.3')) # 0.5초 지연(감지 후)
# --- [추가] 센서 게이팅용 3초 무장 창 ---
self._arm1_deadline = 0.0 # CAM1 유효 마감시각 (epoch)
self._arm2_deadline = 0.0 # CAM2 유효 마감시각 (epoch)
self._arm1_cond = None # 'sev' 등
self._arm2_cond = None # 'par' or 'good'
self._prev_s1 = False
self._prev_s2 = False
self._pending1 = None
self._pending2 = None
self._prev_start_btn = False
self._prev_stop_btn = False
# 기존 짧은 엣지 디바운스(남겨둠, 내부 보조용)
self._btn_debounce = float(os.getenv('BTN_DEBOUNCE_SEC', '0.15'))
self._last_start_edge = 0.0
self._last_stop_edge = 0.0
# STOP 후 START 잠금(기존 로직)
self._stop_latched_until = 0.0
# ★ 3초 레벨 디바운스(눌린 상태 무시)용 타이머
self._start_db_sec = float(os.getenv('START_DEBOUNCE_SEC', '3.0'))
self._stop_db_sec = float(os.getenv('STOP_DEBOUNCE_SEC', '3.0'))
self._start_ignore_until = 0.0
self._stop_ignore_until = 0.0
# 실시간 카운터(M1,M2) + DB 이벤트 옵션
self._counts = {
1: {"good":0, "sev":0, "par":0},
2: {"good":0, "sev":0, "par":0},
}
self._write_db_on_event = bool(int(os.getenv("WRITE_DB_ON_EVENT", "1")))
# timers
self.after(120, self.update_camera)
self.after(200, self._poll_device_panel)
self.after(180, self._logic_loop) # stain% & 센서 연동 로직
self.protocol("WM_DELETE_WINDOW", self._on_close)
# 모델이 없으면 RUN 버튼 비활성화(선택)
if not _has_model(self.det_cfg_m1):
self.btn_m1.config(state="disabled", text="Model 1 (미설치)")
if not _has_model(self.det_cfg_m2):
self.btn_m2.config(state="disabled", text="Model 2 (미설치)")
self._buzz_arm = {"sensor": None, "pattern": None, "deadline": 0.0} # 단일 버저 ARM 슬롯
def destroy(self):
try:
if self.worker1: self.worker1.stop()
if self.worker2: self.worker2.stop()
if hasattr(self, "_decider") and self._decider: self._decider.stop() # [추가]
except Exception: pass
try: self.cam1.stop(); self.cam2.stop()
except Exception: pass
try:
if hasattr(self, "hw") and self.hw: self.hw.close()
except Exception: pass
return super().destroy()
def _row(self, parent, r, name, var):
ttk.Label(parent, text=name, width=16).grid(row=r, column=0, sticky='w', padx=4, pady=2)
ttk.Label(parent, textvariable=var, width=12).grid(row=r, column=1, sticky='w', padx=4, pady=2)
def _fill_recent50_once(self):
"""
DB에서 최근 50건을 읽어 Treeview를 '필요할 때만' 갱신한다.
- 맨 위 (id, ts)가 이전과 같으면 아무 것도 하지 않음 → 깜빡임 최소화
- 바뀌었을 때만 전체 갱신
"""
try:
rows, _ = db_query(f'''
SELECT id, ts, m1_good, m1_sev, m1_par, m2_good, m2_sev, m2_par
FROM {DB_TABLE_6}
ORDER BY ts DESC, id DESC
LIMIT 50
''')
except Exception:
rows = []
# 표시용으로 가공
recent_rows = [
(
ts.strftime('%Y-%m-%d %H:%M:%S'),
int(m1g or 0), int(m1s or 0), int(m1p or 0),
int(m2g or 0), int(m2s or 0), int(m2p or 0)
)
for _id, ts, m1g, m1s, m1p, m2g, m2s, m2p in rows
]
# 변경 확인용 키: 맨 위 레코드의 (id,ts) 조합
new_top_key = None
if rows:
_id0, ts0 = rows[0][0], rows[0][1]
new_top_key = f"{_id0}:{ts0.strftime('%Y-%m-%d %H:%M:%S')}"
# 이전과 동일하면 무변경 → 리턴
if new_top_key and getattr(self, "_recent_last_key", None) == new_top_key:
return False
# 여기서만 전체 리프레시 (변경 있을 때만 실행되므로 깜빡임 최소화)
try:
for iid in self.tree.get_children(""):
self.tree.delete(iid)
except Exception:
pass
for row in recent_rows:
self.tree.insert("", "end", values=row)
self._recent_last_key = new_top_key
return True
def _recent_tick(self):
"""최근 50건을 필요할 때만 갱신하고, 다음 주기를 예약한다."""
try:
self._fill_recent50_once() # 변경 없으면 내부에서 아무 것도 안 함
except Exception:
pass
# 1초 간격으로 폴링 (원하면 1500~2000ms로 늘려도 됨)
self.after(1000, self._recent_tick)
def _model_panel_simple(self, parent, title, vars_dict):
lf = ttk.LabelFrame(parent, text=title)
g = ttk.Frame(lf); g.pack(fill='x', padx=8, pady=8)
items=[("정상품","good"),("완전오염","sev"),("30%오염","par"),
("Partial 가중","par_weight"),("불량(가중)","def_weight"),
("총 처리","overall"),("가중 불량률","rate")]
for i,(nm,key) in enumerate(items):
ttk.Label(g, text=nm, width=14).grid(row=i, column=0, sticky='w', padx=4, pady=2)
ttk.Label(g, textvariable=vars_dict[key], width=10).grid(row=i, column=1, sticky='w', padx=4, pady=2)
return lf
# === RUN/STOP ===
def on_run(self, model_id:int):
err = None
# arming 전에 RUN 금지 (초기 깜빡임/오동작 방지)
if not self._armed:
self.status_lbl.config(text="초기화 중(잠시 후 RUN 가능)")
return
# 모델 가드
if model_id == 1 and not _has_model(self.det_cfg_m1):
messagebox.showwarning("Model 1", "Model 1 파일이 없습니다.\n(DET_MODEL_M1 / SEG_MODEL_M1)")
return
if model_id == 2 and not _has_model(self.det_cfg_m2):
messagebox.showwarning("Model 2", "Model 2 파일이 없습니다.\n(DET_MODEL_M2 / SEG_MODEL_M2)")
return
if not self._restart_workers_for(model_id):
return
self._is_running = True
self.active_model = model_id
self._sync_start_stop_lamps()
self._set_beacons(red=False, orange=False, green=False)
self._pattern_on_model_start(model_id)
self.status_lbl.config(text=f"Model {model_id} 작동중")
if model_id == 1:
self.m1_state.set("작동중"); self.m2_state.set("대기")
else:
self.m1_state.set("대기"); self.m2_state.set("작동중")
# 백엔드 알림 비동기
try:
url = os.getenv("BACKEND_URL")
if url:
def _notify_run(mid):
import json, urllib.request
req = urllib.request.Request(url.rstrip("/")+"/control",
data=json.dumps({"action":"run","model":mid}).encode("utf-8"),
headers={"Content-Type":"application/json"})
urllib.request.urlopen(req, timeout=1.5)
self._bg(_notify_run, model_id)
except Exception as e:
err = err or e
# Conveyor ON 확정
try:
if self.hw: self.hw.set_relay(6, True) # Conveyor ON
if hasattr(self, "_relay_vars") and 6 in self._relay_vars:
self._relay_vars[6].set(True)
self._refresh_relay_visual(6)
except Exception:
pass
if err:
messagebox.showwarning("제어 알림", f"장치/백엔드 알림 중 오류: {err}")
def _stop_workers(self):
# 두 워커 정지 및 해제
for w in (self.worker1, self.worker2):
try:
if w: w.stop()
except Exception:
pass
for w in (self.worker1, self.worker2):
try:
if w:w.join(timeout=0.8)
except Exception:
pass
self.worker1 = None
self.worker2 = None
def _start_workers_with_cfg(self, cfg):
# 두 카메라 모두 같은 cfg(선택한 모델)로 추론 워커 시작
if cv2 is None or not PIL_OK:
self.status_lbl.config(text="OpenCV/Pillow 필요");
return False
try:
self.worker1 = DetectionWorker(self.cam1.get_frame, dict(cfg), self._snap_q)
self.worker1.start()
self.worker2 = DetectionWorker(self.cam2.get_frame, dict(cfg), self._snap_q)
self.worker2.start()
# [추가] 판정 워커 시작 (중복 방지)
try:
if self._decider:
self._decider.stop()
self._decider = None
except Exception:
pass
self._decider = DecideWorker(self._snap_q, dict(cfg), self._on_decision)
self._decider.start()
self.status_lbl.config(text="Det/Seg 가동(오버레이 표시) · CAM1/CAM2=같은 모델")
return True
except Exception as e:
self.status_lbl.config(text=f"Worker 시작 실패: {e}")
self._stop_workers()
return False
def _restart_workers_for(self, model_id:int):
# model_id=1이면 det_cfg_m1, 2면 det_cfg_m2를 두 카메라에 동시에 적용
cfg = self.det_cfg_m1 if model_id == 1 else self.det_cfg_m2
if not _has_model(cfg):
messagebox.showwarning(
f"Model {model_id}",
f"Model {model_id} 파일이 없습니다.\n(DET_MODEL_M{model_id} / SEG_MODEL_M{model_id})"
)
return False
self._stop_workers()
return self._start_workers_with_cfg(cfg)
def on_stop(self):
err = None
self.active_model = 0
self._is_running = False
self._stop_workers()
self._sync_start_stop_lamps()
self.status_lbl.config(text="대기")
self.m1_state.set("대기"); self.m2_state.set("대기")
# 백엔드 알림 비동기
try:
url = os.getenv("BACKEND_URL")
if url:
def _notify_stop():
import json, urllib.request
req = urllib.request.Request(url.rstrip("/")+"/control",
data=json.dumps({"action":"stop"}).encode("utf-8"),
headers={"Content-Type":"application/json"})
urllib.request.urlopen(req, timeout=1.5)
self._bg(_notify_stop)
except Exception as e:
err = err or e
# Conveyor OFF 확정
try:
if self.hw: self.hw.set_relay(6, False) # Conveyor OFF
if hasattr(self, "_relay_vars") and 6 in self._relay_vars:
self._relay_vars[6].set(False)
self._refresh_relay_visual(6)
except Exception:
pass
if err:
messagebox.showwarning("제어 알림", f"장치/백엔드 알림 중 오류: {err}")
# === Camera update (dual, overlay 우선) ===
def update_camera(self):
try:
if cv2 is None or not PIL_OK:
self.cam1_lbl.configure(text="(OpenCV/Pillow 필요)")
self.cam2_lbl.configure(text="(OpenCV/Pillow 필요)")
else:
resample = dict(LANCZOS=Image.LANCZOS, BILINEAR=Image.BILINEAR, BICUBIC=Image.BICUBIC).get(RESAMPLE.upper(), Image.LANCZOS)
# CAM1: worker 오버레이가 있으면 우선 사용
f1 = self.worker1.last_annot if (self.worker1 and self.worker1.last_annot is not None) else self.cam1.get_frame()
if f1 is not None:
rgb1 = cv2.cvtColor(f1, cv2.COLOR_BGR2RGB)
img1 = Image.fromarray(rgb1).resize((CAM_VIEW_W, CAM_VIEW_H), resample=resample).copy()
self._tk_img1 = ImageTk.PhotoImage(img1)
self.cam1_lbl.configure(image=self._tk_img1, text='')
# CAM2: worker 오버레이 우선
f2 = self.worker2.last_annot if (self.worker2 and self.worker2.last_annot is not None) else self.cam2.get_frame()
if f2 is not None:
rgb2 = cv2.cvtColor(f2, cv2.COLOR_BGR2RGB)
img2 = Image.fromarray(rgb2).resize((CAM2_VIEW_W, CAM2_VIEW_H), resample=resample).copy()
self._tk_img2 = ImageTk.PhotoImage(img2)
self.cam2_lbl.configure(image=self._tk_img2, text='')
except Exception as e:
self.cam1_lbl.configure(text=f"(CAM1 오류: {e})")
self.cam2_lbl.configure(text=f"(CAM2 오류: {e})")
finally:
self.after(90, self.update_camera)
# === 백그라운드 실행 헬퍼 ===
def _bg(self, target, *args, **kwargs):
th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True)
th.start()
return th
# === 공통 갱신 로직(중복 제거) ===
def _apply_simple_node(self, node, vars_dict):
sev = int(node['sev']); par = int(node['par']); good = int(node['good'])
par_weight = round(par * PARTIAL_WEIGHT, 2)
def_weight = round(sev + par_weight, 2)
overall = good + sev + par
rate = round((def_weight/overall*100.0), 1) if overall>0 else 0.0
vars_dict['good'].set(good)
vars_dict['sev'].set(sev)
vars_dict['par'].set(par)
vars_dict['par_weight'].set(f"{par_weight:.2f}")
vars_dict['def_weight'].set(f"{def_weight:.2f}")
vars_dict['overall'].set(overall)
vars_dict['rate'].set(f"{rate}%")
return overall, def_weight, good
def _set_kpi(self, total, good, defw, rate):
self.var_total.set(total); self.var_good.set(good)
self.var_defw.set(f"{defw:.2f}"); self.var_rate.set(f"{rate}%")
self.pb['value'] = min(100, rate)
def _update_from_counts(self, m1_counts: dict, m2_counts: dict):
m1_overall, m1_defw, m1_good = self._apply_simple_node(m1_counts, self.m1_vars)
m2_overall, m2_defw, m2_good = self._apply_simple_node(m2_counts, self.m2_vars)
total = m1_overall + m2_overall
total_good = m1_good + m2_good
total_defw = round(m1_defw + m2_defw, 2)
rate = round((total_defw/total*100.0), 1) if total>0 else 0.0
self._set_kpi(total, total_good, total_defw, rate)
self._maybe_blink_beacon(rate)
# KPI 확장 항목
self.k_defw_abs.set(f"{total_defw:.2f}")
win_minutes = max(1e-9, parse_window(self.win_var.get()).total_seconds()/60.0)
tput = total / win_minutes if total > 0 else 0.0
self.k_tput.set(f"{tput:.1f} 개/분")
def _win_minutes(self) -> int:
s = (self.win_var.get() or '5m').strip().lower()
if s.endswith('m'):
return max(1, int(s[:-1] or 5))
if s.endswith('h'):
return max(1, int(s[:-1] or 1) * 60)
if s.endswith('d'):
return max(1, int(s[:-1] or 1) * 1440)
return 5
def _set_lamp(self, canvas: tk.Canvas, on: bool):
if not canvas:
return
canvas.delete("all")
color = "#2ecc71" if on else "#95a5a6"
canvas.create_oval(2, 2, 20, 20, fill=color, outline="black")
def _sync_start_stop_lamps(self):
"""START/STOP 타일(왼쪽 하단)의 램프를 시스템 상태(_is_running) 기준으로 표시"""
try:
# START 버튼 타일 = RUN일 때 초록, STOP 버튼 타일 = RUN 아닐 때 초록
if hasattr(self, "_sensor_lamps"):
self._set_lamp(self._sensor_lamps.get("start_button"), bool(self._is_running))
self._set_lamp(self._sensor_lamps.get("stop_button"), not bool(self._is_running))
except Exception:
pass
# ==== Beacon helpers ==================================================
def _set_beacons(self, red=None, orange=None, green=None):
mapping = {1: red, 2: orange, 3: green}
for idx, val in mapping.items():
if val is None:
continue
try:
self._relay_vars[idx].set(bool(val))
if self.hw:
self.hw.set_relay(idx, bool(val))
self._refresh_relay_visual(idx)
except Exception:
pass
def _pulse_beacon(self, kind: str, ms: int = None):
"""
kind: "good" | "par" | "sev"
요청 패턴:
- sev(RED): 0.5s ON → 0.5s OFF → 0.5s ON → 0.5s OFF (총 2.0s)
- par(ORANGE):0.5s ON → 0.5s OFF (총 1.0s)
- good(GREEN):1.0s ON (총 1.0s)
"""
if getattr(self, "_beacon_busy", False):
return
# 먼저 모두 OFF
try:
self._set_beacons(red=False, orange=False, green=False)
except Exception:
pass
def seq_red():
# ON 0.5s
self._set_beacons(red=True)
self.after(500, lambda: (
self._set_beacons(red=False),
self.after(500, lambda: (
self._set_beacons(red=True),
self.after(500, lambda: self._set_beacons(red=False))
))
))
def seq_orange():
self._set_beacons(orange=True)
self.after(500, lambda: self._set_beacons(orange=False)) # 0.5s 후 OFF (총 1.0s 간격)
def seq_green():
self._set_beacons(green=True)
self.after(1000, lambda: self._set_beacons(green=False))
if kind == "sev":
seq_red()
elif kind == "par":
seq_orange()
elif kind == "good":
seq_green()
else:
return
# === 모델 시작 패턴: 비프 + GREEN 점멸 ===============================
def _beep(self, n=1, dur=0.1, gap=0.1):
"""릴레이 #4 (Buzzer) 짧게 n회 비프. GUI 블로킹 방지: after 체인."""
if not self.hw:
return
total_steps = n * 2 # ON/OFF 세트
def step(i=0):
try:
on = (i % 2 == 0)
self.hw.set_relay(4, on)
if 4 in self._relay_vars:
self._relay_vars[4].set(on); self._refresh_relay_visual(4)
except Exception:
pass
if i + 1 < total_steps:
self.after(int(1000*(dur if on else gap)), lambda: step(i+1))
else:
# 마지막은 확실히 OFF
try:
self.hw.set_relay(4, False)
if 4 in self._relay_vars:
self._relay_vars[4].set(False); self._refresh_relay_visual(4)
except Exception:
pass
step(0)
def _blink_green(self, count=1, on_ms=500, off_ms=200):
"""릴레이 #3 (Green Beacon) 점멸 count회."""
if not self.hw:
return
def one_blink(k):
# ON
try:
self._set_beacons(green=True)
except Exception:
pass
# OFF 예약
self.after(on_ms, lambda: (
self._set_beacons(green=False),
self.after(off_ms, lambda: one_blink(k-1) if k-1>0 else None)
))
# 먼저 모두 OFF
try:
self._set_beacons(red=False, orange=False, green=False)
except Exception:
pass
one_blink(count)
def _pattern_on_model_start(self, model_id:int):
"""Model 1: 비프 1회 + 초록 0.5s 1회 / Model 2: 비프 2회 + 초록 0.5s 2회"""
if model_id == 1:
self._beep(n=1, dur=0.1, gap=0.1)
self._blink_green(count=1, on_ms=500, off_ms=200)
else:
self._beep(n=2, dur=0.1, gap=0.1)
self._blink_green(count=2, on_ms=500, off_ms=200)
def _maybe_blink_beacon(self, rate: float, on_ms: int = 800):
# 부팅 시퀀스(혹은 다른 배타적 제어) 중엔 개입 금지
if getattr(self, "_beacon_busy", False):
return
desired = 'none'
if rate >= 100.0:
desired = 'red'
elif rate >= 30.0:
desired = 'orange'
elif rate == 0.0:
desired = 'green'
if desired == self._last_beacon:
return
self._last_beacon = desired
if self._beacon_after:
try: self.after_cancel(self._beacon_after)
except Exception: pass
self._beacon_after = None
self._set_beacons(red=False, orange=False, green=False)
if desired == 'none':
return
if desired == 'red':
self._set_beacons(red=True)
elif desired == 'orange':
self._set_beacons(orange=True)
elif desired == 'green':
self._set_beacons(green=True)
try:
self._beacon_after = self.after(on_ms, lambda: self._set_beacons(red=False, orange=False, green=False))
except Exception:
pass
def _beacon_boot_chase(self, step_ms: int = 250, hold_ms: int = 1000):
"""
앱 시작시: RED -> ORANGE -> GREEN 순서로 "누적" 켠 뒤,
GREEN까지 켜진 상태로 hold_ms 유지하고 모두 OFF.
"""
# === 잠금 시작 ===
self._beacon_busy = True
# 기존에 깜빡임 예약이 걸려 있었다면 취소 (중복 개입 차단)
try:
if getattr(self, "_beacon_after", None):
self.after_cancel(self._beacon_after)
self._beacon_after = None
except Exception:
pass
try:
# 모두 끈 상태에서 시작
self._set_beacons(red=False, orange=False, green=False)
except Exception:
pass
def on_red():
# RED 켜고 유지
self._set_beacons(red=True, orange=False, green=False)
self.after(step_ms, on_orange)
def on_orange():
# ORANGE만 True 전달(RED는 None이라 그대로 유지)
self._set_beacons(red=True, orange=True, green=False)
self.after(step_ms, on_green)
def on_green():
# GREEN만 True 전달(RED/ORANGE는 None이라 그대로 유지)
self._set_beacons(red=True, orange=True, green=True)
# 모든 불이 켜진 상태를 hold_ms 유지
self.after(hold_ms, all_off)
def all_off():
self._set_beacons(red=False, orange=False, green=False)
# === 잠금 해제 ===
self._beacon_busy = False
# 살짝 딜레이 후 시작
self.after(50, on_red)
# ======================================================================
# App 클래스 안에 추가 (기존 _set_recent가 있다면 이 버전으로 교체)
def _set_recent(self, recent_rows):
"""
recent_rows: 최신이 앞쪽에 오는 리스트.
트리에 데이터가 없을 때만 전체 채우고,
그 외에는 깜빡임 없이 차분 적용(_set_recent_diff)으로 위임.
"""
try:
children = self.tree.get_children("")
except Exception:
children = ()
if not children:
# 최초 1회만 풀 렌더 (깜빡임 거의 없음)
for row in recent_rows:
self.tree.insert("", "end", values=row)
return
# 이후부터는 차분 적용
self._set_recent_diff(recent_rows)
def _update_row_if_changed(self, iid, new_values):
"""iid 행의 값이 new_values와 다르면 그 행만 갱신(깜빡임 없음)."""
try:
cur = self.tree.item(iid, "values")
# values는 튜플/리스트 문자열들이라 문자열 비교로 충분
if tuple(map(str, cur)) != tuple(map(str, new_values)):
self.tree.item(iid, values=new_values)
except Exception:
pass
def _set_recent_diff(self, recent_rows):
"""
깜빡임 없이 신규만 위로 추가, 기존 동일 타임스탬프는 값만 갱신.
최신이 앞쪽(0번)으로 온다는 가정.
"""
try:
children = list(self.tree.get_children(""))
except Exception:
children = []
# 현재 트리 최상단 ts
top_ts = None
if children:
try:
top_ts = self.tree.item(children[0], "values")[0]
except Exception:
top_ts = None
# 1) 위쪽으로 새로 추가할 개수 계산
# recent_rows는 최신이 앞쪽이므로, 기존 top_ts를 만날 때까지가 "신규"
new_count = 0
for row in recent_rows:
ts = row[0]
if top_ts is not None and str(ts) == str(top_ts):
break
new_count += 1
# 2) 신규를 역순으로 맨 위에 insert (시간 오름차순 유지 위해 역순)
if new_count > 0:
for row in reversed(recent_rows[:new_count]):
self.tree.insert("", 0, values=row)
# 3) 겹치는 구간(=이미 트리에 있는 범위)은 값이 바뀌었으면 그 행만 갱신
# recent_rows[new_count:] 를 트리 상단부터 차례로 대응시켜 비교 갱신
start = new_count
iids = list(self.tree.get_children(""))
for idx, row in enumerate(recent_rows[start:], start=0):
if idx >= len(iids):
break
self._update_row_if_changed(iids[idx], row)
# 4) 행 수 50개로 유지 (초과분은 아래쪽부터 삭제)
MAX_N = 50
iids = list(self.tree.get_children(""))
if len(iids) > MAX_N:
for iid in iids[MAX_N:]:
self.tree.delete(iid)
# === 장치 패널(임베디드) ===
def _build_device_panel(self, root):
panel = ttk.LabelFrame(root, text=f"장치 패널 — {'device panel' if self.hw.is_dummy else self.hw.dev_name}")
panel.grid(row=2, column=0, columnspan=1, sticky='nsew', padx=(0,6), pady=(6,0))
grid = ttk.Frame(panel); grid.pack(fill='both', expand=True, padx=6, pady=6)
for r in range(3):
grid.rowconfigure(r, weight=1, uniform="devrow")
for c in range(4):
grid.columnconfigure(c, weight=1, uniform="devcol")
self._relay_vars = {}
self._relay_lamps = {}
names = ["Beacon Red","Beacon Orange","Beacon Green","Buzzer","LED","Conveyor","Actuator 1","Actuator 2"]
for i in range(8):
r = i // 4
c = i % 4
fr = ttk.Labelframe(grid, text=names[i], padding=8)
fr.grid(row=r, column=c, padx=4, pady=4, sticky="nsew")
var = tk.BooleanVar(value=False)
self._relay_vars[i+1] = var
lamp = tk.Canvas(fr, width=22, height=22, highlightthickness=0)
lamp.grid(row=0, column=0, padx=4, pady=4)
self._relay_lamps[i+1] = lamp
self._set_lamp(lamp, False)
fr.columnconfigure(0, weight=1)
self._sensor_lamps = {}
sensor_titles = [
("START BUTTON", "start_button"),
("STOP BUTTON", "stop_button"),
("SENSOR 1", "sensor_1"),
("SENSOR 2", "sensor_2"),
]
for c, (title, key) in enumerate(sensor_titles):
fr = ttk.Labelframe(grid, text=title, padding=8)
fr.grid(row=2, column=c, padx=4, pady=4, sticky="nsew")
lamp = tk.Canvas(fr, width=22, height=22, highlightthickness=0)
lamp.grid(row=0, column=0, padx=4, pady=4)
self._sensor_lamps[key] = lamp
self._set_lamp(lamp, False)
fr.columnconfigure(0, weight=1)
def _refresh_relay_visual(self, idx:int):
on = self._relay_vars[idx].get()
lamp = self._relay_lamps.get(idx)
self._set_lamp(lamp, on)
def _poll_device_panel(self):
try:
st = self.hw.get_sensors() if self.hw else {
"start_button": False, "stop_button": False, "sensor_1": False, "sensor_2": False
}
except Exception:
st = {"start_button": False, "stop_button": False, "sensor_1": False, "sensor_2": False}
try:
outs = self.hw.get_outputs() if self.hw else {}
except Exception:
outs = {}
# 램프 상태 갱신
try:
for idx, on in outs.items():
var = self._relay_vars.get(idx)
if var:
if var.get() != bool(on):
var.set(bool(on))
self._refresh_relay_visual(idx)
except Exception:
pass
# === 하드웨어 START/STOP 버튼 처리 (3초 레벨 디바운스 + STOP 우선) ===
try:
b_start = bool(st.get("start_button", False))
b_stop = bool(st.get("stop_button", False))
now = time.time()
# ---- STOP: Rising-edge + 3초 레벨 디바운스 ----
if b_stop and not self._prev_stop_btn:
if now >= self._stop_ignore_until:
self._stop_ignore_until = now + self._stop_db_sec # 3초간 추가 입력 무시
self._last_stop_edge = now
self._stop_latched_until = now + 1.0 # (기존) 1초 START 락
# UI STOP 루틴 호출 (워커/컨베이어 정지 포함)
self.on_stop()
# 안전하게 비콘/컨베이어 확실히 OFF
try:
self._set_beacons(red=False, orange=False, green=False)
except Exception: pass
try:
if self.hw:
self.hw.set_relay(6, False) # Conveyor OFF
if hasattr(self.hw, "system_stop"):
self.hw.system_stop()
if hasattr(self, "_relay_vars") and 6 in self._relay_vars:
self._relay_vars[6].set(False)
self._refresh_relay_visual(6)
except Exception: pass
# ---- START: Rising-edge + 3초 레벨 디바운스 + 모델 토글 ----
if b_start and not self._prev_start_btn:
if now >= self._start_ignore_until:
self._start_ignore_until = now + self._start_db_sec # 3초간 추가 입력 무시
self._last_start_edge = now
# STOP과 동시가 아니고, STOP 락 해제되었고, arming 완료만 확인
if self._armed and (not b_stop) and (now >= self._stop_latched_until):
if not self._is_running or self.active_model == 0:
# 처음 시작 → Model 1
self.on_run(1)
else:
# 토글: 1 ↔ 2
next_model = 2 if self.active_model == 1 else 1
self.on_run(next_model)
# 이전 상태 저장 (엣지 검출용)
self._prev_start_btn = b_start
self._prev_stop_btn = b_stop
except Exception:
pass
# 센서 램프
try:
self._sync_start_stop_lamps() # START/STOP은 시스템 상태 기준
if "sensor_1" in self._sensor_lamps:
self._set_lamp(self._sensor_lamps["sensor_1"], bool(st.get("sensor_1", False)))
if "sensor_2" in self._sensor_lamps:
self._set_lamp(self._sensor_lamps["sensor_2"], bool(st.get("sensor_2", False)))
except Exception:
pass
self.after(200, self._poll_device_panel)
# === Stain% & 센서 연동 로직 (카메라 역할 고정 + 디바운스/지연 + 엣지 스냅샷) ===
def _logic_loop(self):
"""
센서 라이징 엣지에서 stain% 스냅샷을 저장하고 0.5초 뒤 액츄에이터 펄스.
- CAM1: r1 >= 31% AND sensor_1 rising → 0.5s 뒤 릴레이7 (kind='sev')
- CAM2: 1% <= r2 <= 30% AND sensor_2 rising → 0.5s 뒤 릴레이8 (kind='par')
0%는 동작 없음. 디바운스 적용.
"""
try:
st = self.hw.get_sensors() if self.hw else {"sensor_1":False,"sensor_2":False}
s1 = bool(st.get("sensor_1", False))
s2 = bool(st.get("sensor_2", False))
now = time.time()
# 실시간 stain% 표시(있으면)
r1 = self.worker1.last_ratio if self.worker1 else None
r2 = self.worker2.last_ratio if self.worker2 else None
if r1 is not None: self.r1_var.set(f"CAM1: {r1:.1f}%")
if r2 is not None: self.r2_var.set(f"CAM2: {r2:.1f}%")
# --- SENSOR 1: CAM1 = 완전오염 전용 ---
# --- SENSOR 1: CAM1 (무장창 내에서만 유효) ---
if s1 and not self._prev_s1 and self._is_running:
now = time.time()
if (self._arm1_cond == "sev") and (now <= self._arm1_deadline):
if (now - self._act1_last) >= self._act_debounce and self._pending1 is None:
self._pending1 = {"when": now, "kind": "sev"}
delay_ms = int(self._act_delay * 1000) # 0.3s
self.after(delay_ms, self._do_act1)
# 트리거 이후 즉시 해제(중복 방지)
self._arm1_deadline = 0.0
self._arm1_cond = None
# --- SENSOR 2: CAM2 (무장창 내에서만 유효) ---
if s2 and not self._prev_s2 and self._is_running:
now = time.time()
if (self._arm2_cond in ("par", "good")) and (now <= self._arm2_deadline):
# par: Act2 + 주황 패턴 / good: 액츄에이터 없이 green 패턴만
if self._arm2_cond == "par":
if (now - self._act2_last) >= self._act_debounce and self._pending2 is None:
self._pending2 = {"when": now, "kind": "par"}
delay_ms = int(self._act_delay * 1000) # 0.3s
self.after(delay_ms, self._do_act2)
elif self._arm2_cond == "good":
# 0.3s 지연 후 GREEN 패턴만 1회
def _green_only():
# DB 카운터(정상품) + 비콘 그린 패턴
self._record_event(kind="good")
self.after(int(self._act_delay * 1000), _green_only)
# 트리거 이후 즉시 해제(중복 방지)
self._arm2_deadline = 0.0
self._arm2_cond = None
# 이전 상태 저장
self._prev_s1 = s1
self._prev_s2 = s2
except Exception:
pass
finally:
self.after(60, self._logic_loop) # 주기 60ms
def _do_act1(self):
info = self._pending1; self._pending1 = None
self._act1_last = time.time()
self._pulse_actuator(7, ms=int(os.getenv('ACT1_PULSE_MS', '250')))
self._bg(self._beep_pattern, "sev")
if info:
self._record_event(kind=info.get("kind","sev"))
def _do_act2(self):
info = self._pending2; self._pending2 = None
self._act2_last = time.time()
self._pulse_actuator(8, ms=int(os.getenv('ACT2_PULSE_MS', '250')))
self._bg(self._beep_pattern, "par")
if info:
self._record_event(kind=info.get("kind","par"))
def _pulse_actuator(self, relay_idx:int, ms:int=250):
"""릴레이 idx를 ms 밀리초 동안 ON 후 자동 OFF"""
try:
self._relay_vars[relay_idx].set(True)
if self.hw: self.hw.set_relay(relay_idx, True)
self._refresh_relay_visual(relay_idx)
self.after(ms, lambda: self._pulse_off(relay_idx))
except Exception:
pass
def _pulse_off(self, relay_idx:int):
try:
self._relay_vars[relay_idx].set(False)
if self.hw: self.hw.set_relay(relay_idx, False)
self._refresh_relay_visual(relay_idx)
except Exception:
pass
def _record_event(self, model:int=None, kind:str="good"):
# model 미지정 → 현재 활성 모델로 귀속
if model is None:
model = self.active_model
if model not in (1,2):
return # STOP 상태면 기록하지 않음
node = self._counts[model]
if kind == "sev":
node["sev"] += 1
elif kind == "par":
node["par"] += 1
else:
node["good"] += 1
# UI 즉시 반영
self._update_from_counts(self._counts[1], self._counts[2])
# ★ 이벤트에 맞춰 비콘 펄스
try:
self._pulse_beacon(kind)
except Exception:
pass
# 선택적 DB 한 줄 INSERT (WRITE_DB_ON_EVENT=1일 때)
if self._write_db_on_event and pymysql is not None:
try:
cols = ("m1_good","m1_sev","m1_par","m2_good","m2_sev","m2_par")
vals = [0,0,0,0,0,0]
idx_map = {
(1,"good"): 0, (1,"sev"): 1, (1,"par"): 2,
(2,"good"): 3, (2,"sev"): 4, (2,"par"): 5,
}
j = idx_map.get((model,kind), None)
if j is not None:
vals[j] = 1
sql = f"INSERT INTO {DB_TABLE_6} ({','.join(cols)}) VALUES (%s,%s,%s,%s,%s,%s)"
self._bg(lambda: db_query(sql, vals))
except Exception:
pass
def _toggle_admin_panel(self):
if self._admin_win and self._admin_win.winfo_exists():
self._admin_win.destroy()
self._admin_win = None
self._admin_visible = False
return
self._admin_visible = True
self._build_admin_panel()
def _build_admin_panel(self):
win = tk.Toplevel(self)
self._admin_win = win
win.title("관리자 패널")
win.geometry("680x320")
try:
win.attributes("-topmost", True)
except Exception:
pass
def make_toggle_cell(parent, title, relay_idx):
cell = ttk.LabelFrame(parent, text=title, padding=8)
state = {"on": False}
def apply(new_state):
state["on"] = bool(new_state)
try:
if self.hw:
self.hw.set_relay(relay_idx, state["on"])
except Exception:
pass
btn.config(text=("ON" if state["on"] else "OFF"))
if relay_idx == 6:
self._is_running = state["on"]
self._sync_start_stop_lamps()
if relay_idx in self._relay_vars:
self._relay_vars[relay_idx].set(state["on"])
self._refresh_relay_visual(relay_idx)
btn = ttk.Button(cell, text="OFF", width=10, command=lambda: apply(not state["on"]))
btn.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
try:
outs = self.hw.get_outputs() if self.hw else {}
cur = bool(outs.get(relay_idx, False))
apply(cur)
except Exception:
pass
cell.rowconfigure(0, weight=1)
cell.columnconfigure(0, weight=1)
return cell
body = ttk.Frame(win); body.pack(fill="both", expand=True, padx=8, pady=8)
rows, cols = 2, 4
for r in range(rows):
body.rowconfigure(r, weight=1, uniform="admin_row")
for c in range(cols):
body.columnconfigure(c, weight=1, uniform="admin_col")
items = [
("Beacon RED", 1),
("Beacon ORANGE", 2),
("Beacon GREEN", 3),
("Buzzer", 4),
("LED", 5),
("Conveyor", 6),
("Actuator 1", 7),
("Actuator 2", 8),
]
for i, (title, idx) in enumerate(items):
r, c = divmod(i, cols)
cell = make_toggle_cell(body, title, idx)
cell.grid(row=r, column=c, padx=6, pady=6, sticky="nsew")
def on_close():
self._admin_visible = False
win.destroy()
self._admin_win = None
win.protocol("WM_DELETE_WINDOW", on_close)
def _on_close(self):
self.destroy()
def _buzzer_set(self, on: bool):
# UI 패널 기준 릴레이 #4가 버저
try:
if getattr(self, "hw", None):
self.hw.set_relay(4, bool(on))
except Exception:
pass
def _beep_pattern(self, pattern: str):
# 릴레이 특성 고려: 짧게 '딸깍' 수준
import time
def pulse(dt=0.1):
self._buzzer_set(True)
time.sleep(max(0.01, dt))
self._buzzer_set(False)
if pattern == "sev":
# 0.1s → (0.5s 휴지) → 0.1s → (0.5s 휴지)
pulse(0.1); time.sleep(0.5); pulse(0.1); time.sleep(0.5)
elif pattern == "par":
# 0.1s → (0.5s 휴지)
pulse(0.1); time.sleep(0.5)
def _maybe_beep(self, sensor_id: int):
now = time.time()
arm = self._buzz_arm
if arm["sensor"] == sensor_id and now <= arm["deadline"] and arm["pattern"]:
# 비동기로 울려서 흐름 안막히게
self._bg(self._beep_pattern, arm["pattern"])
# 1회 사용 후 소진
self._buzz_arm = {"sensor": None, "pattern": None, "deadline": 0.0}
def _on_decision(self, grade, ratio, has_sticker):
print(f"[DECISION] {grade}, stain={ratio:.1f}%, sticker={has_sticker}")
now = time.time()
# NEW: DecideWorker가 준 grade 로 액추에이터 제어 (색상 불일치 포함)
if grade in ("NG80", "NG_STICKER"):
# 완전불량 → Actuator 1 (RED)
self._arm1_deadline = now + 3.0
self._arm1_cond = "sev"
self._buzz_arm = {"sensor": 1, "pattern": "sev", "deadline": now + 3.0}
elif grade == "NG30":
# 부분불량 → Actuator 2 (ORANGE)
self._arm2_deadline = now + 3.0
self._arm2_cond = "par"
self._buzz_arm = {"sensor": 2, "pattern": "par", "deadline": now + 3.0}
else: # "OK"
# 정상품 → GREEN만 (버저 ARM 없음)
self._arm2_deadline = now + 3.0
self._arm2_cond = "good"
self._buzz_arm = {"sensor": None, "pattern": None, "deadline": 0.0}
# === Camera controls (3초 지연) ===
def delayed_camera_setup():
time.sleep(3)
cmds = [
["v4l2-ctl", "-d", "/dev/video0", "-c", "auto_exposure=1", "-c", "exposure_dynamic_framerate=0",
"-c", "white_balance_automatic=0", "-c", "focus_automatic_continuous=0"],
["v4l2-ctl", "-d", "/dev/video0", "-c", "exposure_time_absolute=60", "-c", "gain=128",
"-c", "white_balance_temperature=4500", "-c", "focus_absolute=70"],
["v4l2-ctl", "-d", "/dev/video2", "-c", "auto_exposure=1", "-c", "exposure_dynamic_framerate=0",
"-c", "white_balance_automatic=0", "-c", "focus_automatic_continuous=0"],
["v4l2-ctl", "-d", "/dev/video2", "-c", "exposure_time_absolute=60", "-c", "gain=128",
"-c", "white_balance_temperature=4500", "-c", "focus_absolute=70"],
]
for cmd in cmds:
subprocess.run(cmd, check=False)
# =========================
# Entrypoint
# =========================
if __name__ == "__main__":
warnings.filterwarnings("ignore", category=RuntimeWarning)
# GUI 시작
app = App()
# ✅ 3초 후 백그라운드로 카메라 세팅
threading.Thread(target=delayed_camera_setup, daemon=True).start()
app.mainloop()