diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6a75c09 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(C:/Users/Administrator/AppData/Local/Programs/Python/Python314/python.exe *)", + "Bash(python *)", + "Bash(.venv\\\\Scripts\\\\python.exe *)", + "PowerShell(& \".\\\\.venv\\\\Scripts\\\\python.exe\" -c \"import ast; ast.parse\\(open\\('gui/pages/settings_page.py', encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", + "PowerShell(& \".\\\\.venv\\\\Scripts\\\\python.exe\" -c \"import ast; ast.parse\\(open\\('logger.py', encoding='utf-8'\\).read\\(\\)\\); print\\('logger OK'\\)\")", + "PowerShell(& \".\\\\.venv\\\\Scripts\\\\python.exe\" -c \"import ast; ast.parse\\(open\\('gui/pages/inspect_page.py', encoding='utf-8'\\).read\\(\\)\\); print\\('inspect_page OK'\\)\")", + "WebSearch", + "PowerShell(& \".\\\\.venv\\\\Scripts\\\\python.exe\" -c \"import ast; ast.parse\\(open\\('gui/pages/register_page.py', encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", + "Bash(dir E:\\\\ANT\\\\*.py)", + "PowerShell(E:\\\\ANT\\\\.venv\\\\Scripts\\\\pip install pymelsec 2>&1 | Select-Object -Last 5)", + "Bash(.venv/Scripts/python -c ' *)" + ] + } +} diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..be97c12 --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,263 @@ +# ANT 비전 검사 시스템 — 프로젝트 컨텍스트 + +> 최종 갱신: 2026-05-13 + +--- + +## 1. 프로젝트 구조 및 파일 목록 + +``` +E:\ANT\ +├── main.py # 엔트리포인트 (스플래시 → 백그라운드 초기화 → MainWindow) +├── logger.py # 로그 시스템 (app/inspect/timing/train CSV) +├── find_cells_trigger.py # Cognex 셀 전체 스캔 유틸 (진단용) +├── config.json # 런타임 설정 저장 (cognex/basler/db/ai/conveyor/plc) +│ +├── camera/ +│ ├── insight.py # InSightCamera — Telnet + FTP 영구세션 +│ └── basler.py # BaslerCamera — pypylon SDK +│ +├── db/ +│ ├── sql_client.py # SQLClient — MS SQL Server (pyodbc, ODBC Driver 18) +│ └── mysql_client.py # MySQLClient — 미사용 레거시 (pymysql) +│ +├── ai/ +│ ├── detector.py # Detector — YOLOv8 추론 (ultralytics) +│ └── trainer.py # Trainer + TrainWorker — 재학습 (별도 subprocess) +│ +├── logic/ +│ ├── inspector.py # Inspector — PatMax 결과 판독 + 모델 판별 + Pass/Fail +│ ├── group_manager.py # GroupManager — A/B 그룹 수동 전환 (최대 4종) +│ └── pattern_matcher.py # PatternMatcher — ORB 특징점 + 엣지 NCC fallback +│ +├── plc/ +│ └── plc_client.py # PLCClient — 인터페이스만 정의 (미구현) +│ +├── gui/ +│ ├── main_window.py # MainWindow — 4탭 네비게이션, 상태바 +│ ├── splash_screen.py # SplashScreen + InitWorker +│ ├── image_settings_dialog.py # 이미지 설정 다이얼로그 +│ └── pages/ +│ ├── settings_page.py # 환경설정 탭 (연결카드 + 관리자 설정 다이얼로그) +│ ├── register_page.py # 제품 등록 탭 (MES 불러오기 + 캡처) +│ ├── inspect_page.py # 검사 탭 (파이프라인 워커 + 결과 표시) +│ └── retrain_page.py # 재학습 탭 (YOLOv8 재학습 UI) +│ +├── assets/ +│ ├── images/ant_logo.png +│ └── patterns.pkl # PatternMatcher 등록 패턴 저장 파일 +│ +└── logs/ + ├── app/YYYY-MM-DD.log # 전체 앱 로그 (print 가로채기) + ├── inspect/YYYY-MM-DD.csv # 검사 결과 CSV + └── timing/YYYY-MM-DD.csv # Cognex/Basler 타이밍 CSV +``` + +--- + +## 2. 확정된 셀 주소 (Cognex GV 방식) + +`logic/inspector.py` `PATTERN_RESULT_CELLS` 딕셔너리에 확정: + +| 셀 주소 | ID | 제품명 | 모델 | Type | +|---------|-----|--------|------|------| +| **A27** | 1 | LOW REF | LX3 | RH | +| **A77** | 2 | LOW REF | LX3 | LH | +| **A127** | 3 | LOW REF NAS | LX3 | RH | +| **A177** | 4 | LOW REF NAS | LX3 | LH | + +- 읽기 방식: `GV{셀주소}` 전송 → 응답 `"1"` + 값 라인 수신 +- 값 파싱: `"(736.1,742.0) -1.8 = 82.9"` 형식에서 `=` 뒤 점수 추출 +- `#ERR` 포함 시 → matched=False, score=0.0 + +> Cognex 인식 외 추가 제품은 `PatternMatcher` (Python ORB/NCC) 경로로 처리 + +--- + +## 3. DB 접속 정보 + +**드라이버**: `ODBC Driver 18 for SQL Server` (pyodbc) + +**연결 문자열 구조** (`db/sql_client.py`): +``` +DRIVER={ODBC Driver 18 for SQL Server}; +SERVER=<서버주소,포트>; +DATABASE=; +UID=<사용자명>; +PWD=<비밀번호>; +TrustServerCertificate=yes; +Encrypt=optional; +``` + +**설정 UI 예시 서버 주소**: `Wizis.iptime.org,20220` + +**사용 뷰/테이블**: +- `vi_AI_mt_Article` — 제품 등록 탭에서 리플렉터 목록 조회 + ```sql + SELECT ArticleID, Article, BuyerArticleNo + FROM vi_AI_mt_Article + WHERE Article LIKE '%REF%' + ORDER BY ArticleID + ``` + +**설정 저장 위치**: `config.json` → `"db"` 키 (server/database/username/password) + +**자동 연결**: 앱 시작 시 `MainWindow._auto_connect_db()` 가 config.json 값으로 자동 시도 + +--- + +## 4. 구현 완료된 기능 + +### 카메라 +- [x] Cognex In-Sight — Telnet 로그인 (admin/빈 비밀번호) +- [x] Cognex In-Sight — FTP 영구세션 (NOOP keepalive + 자동 재연결) +- [x] Cognex In-Sight — SE8 소프트웨어 트리거 +- [x] Cognex In-Sight — FTP BMP 우선 / JPG fallback 이미지 수신 +- [x] Basler USB — pypylon GrabOne 단일 캡처 +- [x] Basler USB — ExposureAuto=Continuous 연결 +- [x] Basler USB — 연속 grab (start_continuous / grab_latest) + +### 검사 로직 +- [x] Cognex GV 방식 PatMax 결과 조회 (A27/A77/A127/A177) +- [x] Python PatternMatcher — ORB 특징점 매칭 (Lowe's ratio test 0.75) +- [x] Python PatternMatcher — 엣지 NCC fallback (특징점 10개 미만 시) +- [x] PatternMatcher — ORB+NCC 혼합 결과 병합 +- [x] 모델 판별 (최고 점수 패턴 선택 → 허용 그룹 여부 확인) +- [x] Pass/Fail 최종 판정 (cognex_pass AND basler_pass) +- [x] 그룹 A/B 수동 전환 (최대 4종 per 그룹) + +### 검사 파이프라인 (InspectWorker) +- [x] Cognex 트리거 → 1초 대기 → FTP 수신 → PatMax 병렬 처리 (서브스레드) +- [x] Basler 캡처 — 트리거 기준 belt_delay 후 촬영 (동시 진행) +- [x] YOLOv8 추론 — 불량 감지 (confidence ≥ 0.5) +- [x] 검사 결과 카운터 — 그룹별 전체/양품/불량/미인식 + +### AI +- [x] YOLOv8 모델 로드/해제 (지연 로딩 — torch DLL 충돌 방지) +- [x] 4클래스 추론: 스크래치/이물/흑점/변형 +- [x] 재학습 — 데이터셋 준비 (80/20 train/val 자동 분할) +- [x] 재학습 — YOLOv8n 기반, 별도 subprocess 격리 (Qt 충돌 방지) +- [x] 재학습 — 에포크별 진행 콜백 → UI ProgressBar 연동 + +### GUI +- [x] 1920×1080 전체화면, 다크 테마 +- [x] ChevronTabButton — breadcrumb 스타일 4탭 (환경설정/제품 등록/검사/재학습) +- [x] 재학습 탭 더블클릭(600ms) → 창 최소화 단축 +- [x] 환경설정 탭 — 연결 카드 (코그넥스/Basler/DB/AI) +- [x] 관리자 설정 다이얼로그 — 6탭 (코그넥스/Basler/DB/AI모델/컨베이어/PLC) +- [x] 관리자 인증 — 4자리 PIN 키패드 (기본값: 1234) +- [x] 제품 등록 탭 — MES 불러오기 (DB 연결 시 활성화) +- [x] 제품 등록 탭 — In-Sight 트리거 캡처 + 미리보기 +- [x] 검사 탭 — Cognex/Basler 듀얼 영상 표시 +- [x] 검사 탭 — Basler 이미지에 불량 BBox 오버레이 (클래스별 색상) +- [x] 상태바 — 코그넥스/Basler/DB 연결 상태 실시간 표시 +- [x] 컨베이어 딜레이 설정 — 거리(cm) ÷ 속도(cm/s) 자동 계산 + +### 로그 +- [x] print 가로채기 — 타임스탬프 자동 prefix + 파일 저장 +- [x] 세션 시작/종료 헤더 (비정상 종료 감지 포함) +- [x] 검사 결과 CSV (`logs/inspect/`) +- [x] 카메라 타이밍 CSV (`logs/timing/`) +- [x] 사용자 액션 강조 로그 + +--- + +## 5. 미구현 / 블로킹 기능 + +### 블로킹 (핵심 미완성) +| 항목 | 파일 | 상태 | +|------|------|------| +| 검사 결과 DB 저장 | `db/sql_client.py:save_inspection_result()` | TODO — 저장 테이블 미확정 | +| PatternMatcher 등록 UI 연동 | `gui/pages/register_page.py` | 캡처만 구현, `matcher.train()` 호출 없음 | +| PatternMatcher 등록 저장 버튼 | `gui/pages/register_page.py` | 미구현 | + +### 미구현 (인터페이스만 정의) +| 항목 | 파일 | 상태 | +|------|------|------| +| PLC 통신 전체 | `plc/plc_client.py` | 통신 방식 미확정 (Modbus TCP / MC프로토콜 / OPC-UA 중 선택 필요) | +| PLC 연결 버튼 | `gui/pages/settings_page.py:_build_tab_plc()` | print만 출력 | +| DB 리플렉터 등록/갱신 | `db/mysql_client.py:save_reflector()` | `pass` | +| 재학습 후 모델 자동 교체 | `gui/pages/retrain_page.py` | 미확인 | + +### 부분 구현 +| 항목 | 상태 | +|------|------| +| Basler 노출/게인 설정 | UI만 있음, 연결 후 `ExposureAuto=Continuous` 고정 | +| 제품 등록 탭 RH/LH 방향 표시 | DB `type` 필드가 비어있어 화살표 미표시 | + +--- + +## 6. 시스템 흐름 요약 + +### 앱 시작 +``` +main.py + └─ SplashScreen + InitWorker (백그라운드) + ├─ InSightCamera.connect() → Telnet + FTP 세션 + ├─ BaslerCamera.connect() → pypylon + ├─ config.json 로드 + └─ MainWindow(insight, basler, config) + ├─ PatternMatcher.load() → assets/patterns.pkl + ├─ SQLClient 자동 연결 → config.json db 설정 + └─ Detector (모델 미로드 상태) +``` + +### 검사 1사이클 (InspectWorker.run) +``` +[메인 루프] + │ + ├─ [Cognex 서브스레드] ─────────────────────────────────────┐ + │ SE8 트리거 전송 │ + │ → sleep(1.0) │ + │ → FTP get_image() (BMP/JPG) │ + │ → cognex_image_ready.emit(raw) ← GUI 표시 │ + │ → inspector.read_patmax_results() ← GV 셀 조회 │ + │ → inspector.match_image() ← Python ORB/NCC │ + │ → cognex_out["results"] 저장 │ + │ │ + ├─ [워커 메인] belt_delay 대기 후 │ + │ Basler.capture() │ + │ → detector.detect() ← YOLOv8 추론 │ + │ → basler_image_ready.emit(frame, detections) ← GUI │ + │ │ + ├─ ct.join(timeout=10.0) ← Cognex 서브스레드 완료 대기 ───┘ + │ + ├─ inspector.identify_model(results, allowed_ids) + │ → 최고 점수 패턴 선택 → 허용 그룹 여부 확인 + │ + ├─ inspector.judge(cognex_pass, basler_pass) + │ → "PASS" or "FAIL" + │ + └─ result_ready.emit(dict) → UI 카운터/결과 표시 + CSV 기록 +``` + +### 모델 판별 우선순위 +1. **Cognex GV 셀** (A27/A77/A127/A177) — job 파일 내 PatMax 결과 +2. **Python PatternMatcher** (ORB 특징점 → NCC fallback) — 추가 등록 제품 + +두 결과를 병합하여 가장 높은 점수의 패턴을 최종 선택. + +### 컨베이어 딜레이 계산 +``` +belt_delay (초) = 카메라 간 거리(cm) ÷ 벨트 속도(cm/s) +기본값: 100cm ÷ 30cm/s = 3.33초 +``` + +### 설정 저장 +- 모든 설정은 `config.json` (프로젝트 루트)에 JSON으로 저장 +- 앱 재시작 시 자동 로드 및 카메라/DB 자동 연결 시도 + +--- + +## 7. 주요 의존성 + +| 패키지 | 용도 | +|--------|------| +| PyQt5 | GUI 프레임워크 | +| pypylon | Basler 카메라 SDK | +| ultralytics | YOLOv8 추론/학습 | +| opencv-python | 이미지 처리, ORB, NCC | +| pyodbc | MS SQL Server 연결 | +| pymysql | MySQL (레거시, 미사용) | +| numpy | 배열 처리 | +| pyyaml | 학습 데이터셋 yaml 생성 | diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..b0da513 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,3 @@ +# ai 패키지 — Detector, Trainer 노출 +from .detector import Detector +from .trainer import Trainer diff --git a/ai/dataset/data.yaml b/ai/dataset/data.yaml new file mode 100644 index 0000000..969c193 --- /dev/null +++ b/ai/dataset/data.yaml @@ -0,0 +1,9 @@ +names: +- 스크래치 +- 이물 +- 흑점 +- 변형 +nc: 4 +path: e:\ANT\ai\dataset +train: images/train +val: images/val diff --git a/ai/dataset/images/train/스크린샷 2026-04-27 175539.png b/ai/dataset/images/train/스크린샷 2026-04-27 175539.png new file mode 100644 index 0000000..256939a Binary files /dev/null and b/ai/dataset/images/train/스크린샷 2026-04-27 175539.png differ diff --git a/ai/dataset/images/train/스크린샷 2026-05-07 100837.png b/ai/dataset/images/train/스크린샷 2026-05-07 100837.png new file mode 100644 index 0000000..025d2a3 Binary files /dev/null and b/ai/dataset/images/train/스크린샷 2026-05-07 100837.png differ diff --git a/ai/dataset/images/val/스크린샷 2026-04-27 175231.png b/ai/dataset/images/val/스크린샷 2026-04-27 175231.png new file mode 100644 index 0000000..21f21b5 Binary files /dev/null and b/ai/dataset/images/val/스크린샷 2026-04-27 175231.png differ diff --git a/ai/dataset/labels/train.cache b/ai/dataset/labels/train.cache new file mode 100644 index 0000000..31eb079 Binary files /dev/null and b/ai/dataset/labels/train.cache differ diff --git a/ai/dataset/labels/train/스크린샷 2026-04-27 175539.txt b/ai/dataset/labels/train/스크린샷 2026-04-27 175539.txt new file mode 100644 index 0000000..3811f2b --- /dev/null +++ b/ai/dataset/labels/train/스크린샷 2026-04-27 175539.txt @@ -0,0 +1 @@ +0 0.233974 0.409524 0.339744 0.590476 \ No newline at end of file diff --git a/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt b/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt new file mode 100644 index 0000000..69abecd --- /dev/null +++ b/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt @@ -0,0 +1 @@ +0 0.318598 0.542781 0.472561 0.754011 \ No newline at end of file diff --git a/ai/dataset/labels/val.cache b/ai/dataset/labels/val.cache new file mode 100644 index 0000000..9650645 Binary files /dev/null and b/ai/dataset/labels/val.cache differ diff --git a/ai/dataset/labels/val/스크린샷 2026-04-27 175231.txt b/ai/dataset/labels/val/스크린샷 2026-04-27 175231.txt new file mode 100644 index 0000000..8ac7a0c --- /dev/null +++ b/ai/dataset/labels/val/스크린샷 2026-04-27 175231.txt @@ -0,0 +1,2 @@ +0 0.227074 0.195219 0.401747 0.215139 +1 0.853712 0.252988 0.257642 0.330677 \ No newline at end of file diff --git a/ai/detector.py b/ai/detector.py new file mode 100644 index 0000000..4ca0f73 --- /dev/null +++ b/ai/detector.py @@ -0,0 +1,59 @@ +# AI 추론 — YOLOv8 기반 불량(스크래치/이물/흑점/변형) 검출 +import os +import numpy as np + +from utils.path_helper import BASE_PATH + + +class Detector: + class_names = ["스크래치", "이물", "흑점", "변형"] + + def __init__(self): + self._model = None + self.model_path = None + + def load_model(self, model_path: str) -> bool: + if model_path and not os.path.isabs(model_path): + model_path = os.path.join(BASE_PATH, model_path) + try: + from ultralytics import YOLO # 지연 로딩 — 앱 시작 시 torch DLL 오류 방지 + self._model = YOLO(model_path) + self.model_path = model_path + print(f"[AI] 모델 로드 성공: {model_path}") + return True + except Exception as e: + print(f"[AI] 모델 로드 실패: {e}") + self._model = None + return False + + def is_loaded(self) -> bool: + return self._model is not None + + def detect(self, image: np.ndarray) -> list: + """ + image: numpy array (BGR) + 반환: [{"class_id": int, "class_name": str, + "confidence": float, "bbox": [x1,y1,x2,y2]}, ...] + """ + if self._model is None: + return [] + try: + results = self._model(image, verbose=False) + detections = [] + for result in results: + for box in result.boxes: + class_id = int(box.cls[0]) + detections.append({ + "class_id": class_id, + "class_name": ( + self.class_names[class_id] + if class_id < len(self.class_names) + else str(class_id) + ), + "confidence": float(box.conf[0]), + "bbox": box.xyxy[0].tolist(), + }) + return detections + except Exception as e: + print(f"[AI] 추론 오류: {e}") + return [] diff --git a/ai/models/best.pt b/ai/models/best.pt new file mode 100644 index 0000000..2c9e471 Binary files /dev/null and b/ai/models/best.pt differ diff --git a/ai/runs/train/BoxF1_curve.png b/ai/runs/train/BoxF1_curve.png new file mode 100644 index 0000000..8fe4511 Binary files /dev/null and b/ai/runs/train/BoxF1_curve.png differ diff --git a/ai/runs/train/BoxPR_curve.png b/ai/runs/train/BoxPR_curve.png new file mode 100644 index 0000000..6e3698b Binary files /dev/null and b/ai/runs/train/BoxPR_curve.png differ diff --git a/ai/runs/train/BoxP_curve.png b/ai/runs/train/BoxP_curve.png new file mode 100644 index 0000000..1bd9f3f Binary files /dev/null and b/ai/runs/train/BoxP_curve.png differ diff --git a/ai/runs/train/BoxR_curve.png b/ai/runs/train/BoxR_curve.png new file mode 100644 index 0000000..ec9b8d8 Binary files /dev/null and b/ai/runs/train/BoxR_curve.png differ diff --git a/ai/runs/train/args.yaml b/ai/runs/train/args.yaml new file mode 100644 index 0000000..8c680ad --- /dev/null +++ b/ai/runs/train/args.yaml @@ -0,0 +1,110 @@ +task: detect +mode: train +model: yolov8n.pt +data: e:\ANT\ai\dataset\data.yaml +epochs: 100 +time: null +patience: 100 +batch: 16 +imgsz: 640 +save: true +save_period: -1 +cache: false +device: null +workers: 0 +project: e:\ANT\ai\runs +name: train +exist_ok: true +pretrained: true +optimizer: auto +verbose: true +seed: 0 +deterministic: true +single_cls: false +rect: false +cos_lr: false +close_mosaic: 10 +resume: false +amp: false +fraction: 1.0 +profile: false +freeze: null +multi_scale: 0.0 +compile: false +overlap_mask: true +mask_ratio: 4 +dropout: 0.0 +val: true +split: val +save_json: false +conf: null +iou: 0.7 +max_det: 300 +half: false +dnn: false +plots: false +end2end: null +source: null +vid_stride: 1 +stream_buffer: false +visualize: false +augment: false +agnostic_nms: false +classes: null +retina_masks: false +embed: null +show: false +save_frames: false +save_txt: false +save_conf: false +save_crop: false +show_labels: true +show_conf: true +show_boxes: true +line_width: null +format: torchscript +keras: false +optimize: false +int8: false +dynamic: false +simplify: true +opset: null +workspace: null +nms: false +lr0: 0.01 +lrf: 0.01 +momentum: 0.937 +weight_decay: 0.0005 +warmup_epochs: 3.0 +warmup_momentum: 0.8 +warmup_bias_lr: 0.1 +box: 7.5 +cls: 0.5 +cls_pw: 0.0 +dfl: 1.5 +pose: 12.0 +kobj: 1.0 +rle: 1.0 +angle: 1.0 +nbs: 64 +hsv_h: 0.015 +hsv_s: 0.7 +hsv_v: 0.4 +degrees: 0.0 +translate: 0.1 +scale: 0.5 +shear: 0.0 +perspective: 0.0 +flipud: 0.0 +fliplr: 0.5 +bgr: 0.0 +mosaic: 1.0 +mixup: 0.0 +cutmix: 0.0 +copy_paste: 0.0 +copy_paste_mode: flip +auto_augment: randaugment +erasing: 0.4 +cfg: null +tracker: botsort.yaml +save_dir: E:\ANT\ai\runs\train diff --git a/ai/runs/train/confusion_matrix.png b/ai/runs/train/confusion_matrix.png new file mode 100644 index 0000000..193ebf1 Binary files /dev/null and b/ai/runs/train/confusion_matrix.png differ diff --git a/ai/runs/train/confusion_matrix_normalized.png b/ai/runs/train/confusion_matrix_normalized.png new file mode 100644 index 0000000..6ec40ed Binary files /dev/null and b/ai/runs/train/confusion_matrix_normalized.png differ diff --git a/ai/runs/train/results.csv b/ai/runs/train/results.csv new file mode 100644 index 0000000..be3da04 --- /dev/null +++ b/ai/runs/train/results.csv @@ -0,0 +1,101 @@ +epoch,time,train/box_loss,train/cls_loss,train/dfl_loss,metrics/precision(B),metrics/recall(B),metrics/mAP50(B),metrics/mAP50-95(B),val/box_loss,val/cls_loss,val/dfl_loss,lr/pg0,lr/pg1,lr/pg2 +1,0.744601,2.4784,4.2576,2.37292,0.00581,0.5,0.00599,0.00178,2.08106,4.5092,2.3941,0,0,0 +2,1.17594,3.54636,4.89413,2.76008,0.00588,0.5,0.00622,0.00124,2.10159,4.51392,2.40401,1.23763e-05,1.23763e-05,1.23763e-05 +3,1.4434,3.2212,4.47996,3.91873,0.01514,1,0.01568,0.0022,2.24221,4.37976,2.4629,2.4505e-05,2.4505e-05,2.4505e-05 +4,1.81097,3.23832,4.18544,3.38558,0.01459,1,0.01534,0.0037,2.32948,4.47892,2.46147,3.63862e-05,3.63862e-05,3.63862e-05 +5,2.17724,2.76973,4.43589,2.74067,0.00556,0.5,0.00711,0.00195,2.33679,4.46132,2.45867,4.802e-05,4.802e-05,4.802e-05 +6,2.47758,2.93916,4.25681,2.81574,0.00562,0.5,0.00711,0.00197,2.23356,4.4439,2.55513,5.94062e-05,5.94062e-05,5.94062e-05 +7,2.77856,2.95798,3.80506,2.41629,0.01433,1,0.0167,0.00167,2.13047,4.47684,2.46296,7.0545e-05,7.0545e-05,7.0545e-05 +8,3.08538,2.97272,3.8706,2.66583,0.00847,0.5,0.00939,0.00094,1.93983,4.4964,2.44096,8.14362e-05,8.14362e-05,8.14362e-05 +9,3.38628,1.96121,4.27233,2.04591,0,0,0,0,1.67434,4.64554,2.3051,9.208e-05,9.208e-05,9.208e-05 +10,3.68733,2.62766,3.67184,2.50334,0,0,0,0,1.44683,4.71852,2.19216,0.000102476,0.000102476,0.000102476 +11,3.97817,2.60241,4.1424,2.70991,0.00556,0.5,0.01605,0.0016,1.42181,4.70011,2.16441,0.000112625,0.000112625,0.000112625 +12,4.28608,3.28551,4.52089,2.67545,0.00588,0.5,0.01345,0.00269,1.52292,4.67835,2.13383,0.000122526,0.000122526,0.000122526 +13,4.58546,2.2033,4.2669,2.49512,0.00562,0.5,0.01157,0.00231,1.68387,4.61194,2.17338,0.00013218,0.00013218,0.00013218 +14,4.88485,2.87394,4.40826,2.70916,0.00568,0.5,0.00975,0.00195,1.89075,4.58147,2.30027,0.000141586,0.000141586,0.000141586 +15,5.19968,2.71982,4.27341,2.72914,0,0,0,0,1.96866,4.44855,2.33624,0.000150745,0.000150745,0.000150745 +16,5.49215,1.68605,4.24646,1.87531,0,0,0,0,2.00553,4.48707,2.29285,0.000159656,0.000159656,0.000159656 +17,5.79325,2.00138,3.94773,2.26758,0,0,0,0,2.09648,4.63246,2.4007,0.00016832,0.00016832,0.00016832 +18,6.09155,3.05671,5.53908,2.66707,0,0,0,0,2.09648,4.63246,2.4007,0.000176736,0.000176736,0.000176736 +19,6.39052,2.20901,3.52372,2.04393,0.00806,0.5,0.02369,0.00237,2.20865,4.54601,2.56285,0.000184905,0.000184905,0.000184905 +20,6.66892,2.4483,3.70065,2.23467,0.00806,0.5,0.02369,0.00237,2.20865,4.54601,2.56285,0.000192826,0.000192826,0.000192826 +21,6.9332,2.19448,3.86943,2.10117,0.00463,0.5,0.03317,0.00663,2.32559,4.48125,2.62548,0.0002005,0.0002005,0.0002005 +22,7.30519,2.9479,4.3599,2.76934,0.00463,0.5,0.03317,0.00663,2.32559,4.48125,2.62548,0.000207926,0.000207926,0.000207926 +23,7.68986,1.39714,3.40426,1.6151,0.01578,1,0.08837,0.01146,2.44041,4.6826,2.72339,0.000215105,0.000215105,0.000215105 +24,7.99967,2.46841,3.48691,2.13383,0.01578,1,0.08837,0.01146,2.44041,4.6826,2.72339,0.000222036,0.000222036,0.000222036 +25,8.31396,2.56835,4.10281,2.20972,0.00481,0.5,0.01777,0.00355,2.61255,4.61058,2.86581,0.00022872,0.00022872,0.00022872 +26,8.55453,1.65391,3.70866,1.88456,0.00481,0.5,0.01777,0.00355,2.61255,4.61058,2.86581,0.000235156,0.000235156,0.000235156 +27,8.80235,2.00119,3.45089,1.89926,0.01604,1,0.05212,0.00521,2.74287,4.6437,2.91424,0.000241345,0.000241345,0.000241345 +28,9.04528,1.89956,3.68555,2.16165,0.01604,1,0.05212,0.00521,2.74287,4.6437,2.91424,0.000247286,0.000247286,0.000247286 +29,9.29273,1.42472,2.92227,1.75125,0.01111,0.5,0.12437,0.01244,3.06977,4.5736,3.06093,0.00025298,0.00025298,0.00025298 +30,9.60359,1.35767,2.62078,1.55004,0.01111,0.5,0.12437,0.01244,3.06977,4.5736,3.06093,0.000258426,0.000258426,0.000258426 +31,9.94422,2.37035,4.66417,2.27559,0.01316,0.5,0.16583,0.01658,3.36893,4.24859,3.23425,0.000263625,0.000263625,0.000263625 +32,10.2594,1.61322,2.86179,1.53237,0.01316,0.5,0.16583,0.01658,3.36893,4.24859,3.23425,0.000268576,0.000268576,0.000268576 +33,10.6412,1.85856,3.15064,1.88045,0.02083,0.5,0.16583,0.01658,3.44544,4.47775,3.34489,0.00027328,0.00027328,0.00027328 +34,10.9661,1.62452,2.88937,1.72429,0.02083,0.5,0.16583,0.01658,3.44544,4.47775,3.34489,0.000277736,0.000277736,0.000277736 +35,11.2888,1.21431,3.51628,1.62717,0,0,0,0,3.47481,4.57555,3.33539,0.000281945,0.000281945,0.000281945 +36,11.5318,1.39337,2.94668,1.47319,0,0,0,0,3.47481,4.57555,3.33539,0.000285906,0.000285906,0.000285906 +37,11.7887,2.14196,3.0238,1.80533,0,0,0,0,3.7452,4.84862,3.44227,0.00028962,0.00028962,0.00028962 +38,12.0408,1.81846,2.33607,1.79617,0,0,0,0,3.7452,4.84862,3.44227,0.000293086,0.000293086,0.000293086 +39,12.2951,1.17175,1.92086,1.3652,0,0,0,0,4.06912,4.91101,3.62994,0.000296305,0.000296305,0.000296305 +40,12.5315,1.62593,2.48208,1.50529,0,0,0,0,4.06912,4.91101,3.62994,0.000299276,0.000299276,0.000299276 +41,12.8727,2.11165,2.67321,1.7814,0,0,0,0,4.39582,4.54093,3.81935,0.000302,0.000302,0.000302 +42,13.1287,1.58216,2.68408,1.63978,0,0,0,0,4.39582,4.54093,3.81935,0.000304476,0.000304476,0.000304476 +43,13.4027,1.4236,2.75829,1.527,0.01351,0.5,0.02163,0.00216,4.3829,4.34724,3.73475,0.000306705,0.000306705,0.000306705 +44,13.6611,1.58086,3.04931,1.97095,0.01351,0.5,0.02163,0.00216,4.3829,4.34724,3.73475,0.000308686,0.000308686,0.000308686 +45,13.94,1.70552,2.1028,1.7847,0.0119,0.5,0.03109,0.00311,4.02893,4.68709,3.46274,0.00031042,0.00031042,0.00031042 +46,14.2063,1.17287,2.30212,1.21687,0.0119,0.5,0.03109,0.00311,4.02893,4.68709,3.46274,0.000311906,0.000311906,0.000311906 +47,14.4787,0.75953,1.79745,0.99198,0.01111,0.5,0.04523,0.00452,4.16401,4.4644,3.55846,0.000313145,0.000313145,0.000313145 +48,14.7356,2.12559,4.16293,2.21951,0.01111,0.5,0.04523,0.00452,4.16401,4.4644,3.55846,0.000314136,0.000314136,0.000314136 +49,15.0753,0.92121,2.05049,1.10199,0.00962,0.5,0.04146,0.00829,3.90365,4.64333,3.48688,0.00031488,0.00031488,0.00031488 +50,15.3345,1.19831,1.99876,1.28056,0.00962,0.5,0.04146,0.00829,3.90365,4.64333,3.48688,0.000315376,0.000315376,0.000315376 +51,15.6062,2.135,2.40625,1.81904,0.01042,0.5,0.05528,0.0229,3.18088,5.77238,3.2612,0.000315625,0.000315625,0.000315625 +52,15.9429,1.72253,3.24847,1.71661,0.01042,0.5,0.05528,0.0229,3.18088,5.77238,3.2612,0.000315626,0.000315626,0.000315626 +53,16.2756,0.98827,2.52014,1.26921,0.01042,0.5,0.05528,0.0229,3.18088,5.77238,3.2612,0.00031538,0.00031538,0.00031538 +54,16.6349,1.8328,2.53368,1.66716,0.01316,0.5,0.12437,0.01244,3.8633,4.65938,3.23911,0.000314886,0.000314886,0.000314886 +55,16.8944,1.1818,1.78296,1.15008,0.01316,0.5,0.12437,0.01244,3.8633,4.65938,3.23911,0.000314145,0.000314145,0.000314145 +56,17.2351,1.26713,2.38825,1.40594,0.01316,0.5,0.12437,0.01244,3.8633,4.65938,3.23911,0.000313156,0.000313156,0.000313156 +57,17.5174,1.32827,1.94378,1.44297,0.01562,0.5,0.02073,0.00207,3.86529,4.77191,3.13775,0.00031192,0.00031192,0.00031192 +58,17.7998,0.93866,1.97455,1.19817,0.01562,0.5,0.02073,0.00207,3.86529,4.77191,3.13775,0.000310436,0.000310436,0.000310436 +59,18.0565,1.34164,4.69334,1.52493,0.01562,0.5,0.02073,0.00207,3.86529,4.77191,3.13775,0.000308705,0.000308705,0.000308705 +60,18.3262,1.70827,3.61104,1.67179,0,0,0,0,3.93656,4.99738,3.11248,0.000306726,0.000306726,0.000306726 +61,18.6133,0.82569,1.96032,1.1105,0,0,0,0,3.93656,4.99738,3.11248,0.0003045,0.0003045,0.0003045 +62,18.8669,1.33918,2.16787,1.37237,0,0,0,0,3.93656,4.99738,3.11248,0.000302026,0.000302026,0.000302026 +63,19.133,0.9404,2.00419,1.09295,0,0,0,0,3.54929,5.50927,3.04358,0.000299305,0.000299305,0.000299305 +64,19.4517,0.83957,1.71834,1.04536,0,0,0,0,3.54929,5.50927,3.04358,0.000296336,0.000296336,0.000296336 +65,19.7046,1.05975,1.98836,1.25573,0,0,0,0,3.54929,5.50927,3.04358,0.00029312,0.00029312,0.00029312 +66,19.966,0.79495,1.71511,1.07986,0.01111,0.5,0.02073,0.00415,3.48677,6.07562,2.94905,0.000289656,0.000289656,0.000289656 +67,20.2315,0.89674,1.76647,1.12805,0.01111,0.5,0.02073,0.00415,3.48677,6.07562,2.94905,0.000285945,0.000285945,0.000285945 +68,20.4951,1.27186,2.05522,1.24621,0.01111,0.5,0.02073,0.00415,3.48677,6.07562,2.94905,0.000281986,0.000281986,0.000281986 +69,20.763,0.72455,1.86789,1.0569,0,0,0,0,4.16796,5.98914,3.4865,0.00027778,0.00027778,0.00027778 +70,21.0206,0.94838,1.5672,1.15049,0,0,0,0,4.16796,5.98914,3.4865,0.000273326,0.000273326,0.000273326 +71,21.2798,1.62956,2.8345,1.75908,0,0,0,0,4.16796,5.98914,3.4865,0.000268625,0.000268625,0.000268625 +72,21.6185,0.82159,1.62683,0.94171,0,0,0,0,4.70051,5.39397,3.87655,0.000263676,0.000263676,0.000263676 +73,21.8885,0.84998,2.02611,1.30797,0,0,0,0,4.70051,5.39397,3.87655,0.00025848,0.00025848,0.00025848 +74,22.1454,0.85087,1.42472,1.15593,0,0,0,0,4.70051,5.39397,3.87655,0.000253036,0.000253036,0.000253036 +75,22.4095,1.1786,1.83474,1.21092,0,0,0,0,4.86689,6.24371,3.95955,0.000247345,0.000247345,0.000247345 +76,22.6694,0.78564,1.66187,0.97539,0,0,0,0,4.86689,6.24371,3.95955,0.000241406,0.000241406,0.000241406 +77,22.9286,0.79013,1.96926,1.26206,0,0,0,0,4.86689,6.24371,3.95955,0.00023522,0.00023522,0.00023522 +78,23.1943,0.8152,1.59465,1.07906,0,0,0,0,4.81344,5.97521,4.01022,0.000228786,0.000228786,0.000228786 +79,23.4546,0.61829,1.29337,0.98868,0,0,0,0,4.81344,5.97521,4.01022,0.000222105,0.000222105,0.000222105 +80,23.7685,0.95007,1.79471,1.1416,0,0,0,0,4.81344,5.97521,4.01022,0.000215176,0.000215176,0.000215176 +81,24.1034,0.93272,2.22583,1.20421,0.00617,0.5,0.00905,0.0009,4.60774,5.39816,3.81369,0.000208,0.000208,0.000208 +82,24.3629,0.63241,1.21512,1.08097,0.00617,0.5,0.00905,0.0009,4.60774,5.39816,3.81369,0.000200576,0.000200576,0.000200576 +83,24.6222,1.39649,2.39204,1.52958,0.00617,0.5,0.00905,0.0009,4.60774,5.39816,3.81369,0.000192905,0.000192905,0.000192905 +84,24.9101,0.63485,1.24139,0.96726,0.00641,0.5,0.01913,0.00459,4.14874,5.15654,3.60735,0.000184986,0.000184986,0.000184986 +85,25.1903,0.88995,1.76109,1.04753,0.00641,0.5,0.01913,0.00459,4.14874,5.15654,3.60735,0.00017682,0.00017682,0.00017682 +86,25.4597,1.16455,2.35159,1.15176,0.00641,0.5,0.01913,0.00459,4.14874,5.15654,3.60735,0.000168406,0.000168406,0.000168406 +87,25.761,0.86574,1.60094,1.1271,0.00641,0.5,0.01913,0.00459,4.14874,5.15654,3.60735,0.000159745,0.000159745,0.000159745 +88,26.1232,0.57931,1.17361,0.95885,0.00704,0.5,0.01658,0.00332,3.66565,5.88071,3.25055,0.000150836,0.000150836,0.000150836 +89,26.4022,0.88857,1.97356,1.07497,0.00704,0.5,0.01658,0.00332,3.66565,5.88071,3.25055,0.00014168,0.00014168,0.00014168 +90,26.7071,0.71015,1.22162,1.07789,0.00704,0.5,0.01658,0.00332,3.66565,5.88071,3.25055,0.000132276,0.000132276,0.000132276 +91,27.0615,0.80611,2.15097,0.95926,0.00704,0.5,0.01658,0.00332,3.66565,5.88071,3.25055,0.000122625,0.000122625,0.000122625 +92,27.3798,0.75395,1.9817,1.18844,0.00758,0.5,0.01463,0.00368,3.64756,5.57493,3.04703,0.000112726,0.000112726,0.000112726 +93,27.6734,0.52999,2.16673,0.94249,0.00758,0.5,0.01463,0.00368,3.64756,5.57493,3.04703,0.00010258,0.00010258,0.00010258 +94,27.9692,0.29519,1.34975,0.94508,0.00758,0.5,0.01463,0.00368,3.64756,5.57493,3.04703,9.21862e-05,9.21862e-05,9.21862e-05 +95,28.2525,0.533,1.80971,0.96369,0.00758,0.5,0.01463,0.00368,3.64756,5.57493,3.04703,8.1545e-05,8.1545e-05,8.1545e-05 +96,28.5584,0.37619,1.67301,0.82287,0.00714,0.5,0.01309,0.00361,3.45585,5.23022,2.909,7.06563e-05,7.06563e-05,7.06563e-05 +97,28.8522,0.58993,1.73032,0.93823,0.00714,0.5,0.01309,0.00361,3.45585,5.23022,2.909,5.952e-05,5.952e-05,5.952e-05 +98,29.2014,1.19689,2.95317,1.27242,0.00714,0.5,0.01309,0.00361,3.45585,5.23022,2.909,4.81363e-05,4.81363e-05,4.81363e-05 +99,29.5194,0.44641,1.50801,0.93925,0.00714,0.5,0.01309,0.00361,3.45585,5.23022,2.909,3.6505e-05,3.6505e-05,3.6505e-05 +100,29.8906,0.52149,1.81476,0.89577,0.0061,0.5,0.01157,0.00231,3.45621,5.51821,2.93443,2.46263e-05,2.46263e-05,2.46263e-05 diff --git a/ai/runs/train/results.png b/ai/runs/train/results.png new file mode 100644 index 0000000..3058375 Binary files /dev/null and b/ai/runs/train/results.png differ diff --git a/ai/runs/train/train_batch0.jpg b/ai/runs/train/train_batch0.jpg new file mode 100644 index 0000000..d53943b Binary files /dev/null and b/ai/runs/train/train_batch0.jpg differ diff --git a/ai/runs/train/train_batch1.jpg b/ai/runs/train/train_batch1.jpg new file mode 100644 index 0000000..c6db934 Binary files /dev/null and b/ai/runs/train/train_batch1.jpg differ diff --git a/ai/runs/train/train_batch2.jpg b/ai/runs/train/train_batch2.jpg new file mode 100644 index 0000000..1be98fb Binary files /dev/null and b/ai/runs/train/train_batch2.jpg differ diff --git a/ai/runs/train/train_batch90.jpg b/ai/runs/train/train_batch90.jpg new file mode 100644 index 0000000..d8ee738 Binary files /dev/null and b/ai/runs/train/train_batch90.jpg differ diff --git a/ai/runs/train/train_batch91.jpg b/ai/runs/train/train_batch91.jpg new file mode 100644 index 0000000..ab28acf Binary files /dev/null and b/ai/runs/train/train_batch91.jpg differ diff --git a/ai/runs/train/train_batch92.jpg b/ai/runs/train/train_batch92.jpg new file mode 100644 index 0000000..e7f877c Binary files /dev/null and b/ai/runs/train/train_batch92.jpg differ diff --git a/ai/runs/train/val_batch0_labels.jpg b/ai/runs/train/val_batch0_labels.jpg new file mode 100644 index 0000000..fadb70f Binary files /dev/null and b/ai/runs/train/val_batch0_labels.jpg differ diff --git a/ai/runs/train/val_batch0_pred.jpg b/ai/runs/train/val_batch0_pred.jpg new file mode 100644 index 0000000..23639bb Binary files /dev/null and b/ai/runs/train/val_batch0_pred.jpg differ diff --git a/ai/runs/train/weights/best.pt b/ai/runs/train/weights/best.pt new file mode 100644 index 0000000..2c9e471 Binary files /dev/null and b/ai/runs/train/weights/best.pt differ diff --git a/ai/runs/train/weights/last.pt b/ai/runs/train/weights/last.pt new file mode 100644 index 0000000..8d5f0b8 Binary files /dev/null and b/ai/runs/train/weights/last.pt differ diff --git a/ai/trainer.py b/ai/trainer.py new file mode 100644 index 0000000..058ab30 --- /dev/null +++ b/ai/trainer.py @@ -0,0 +1,269 @@ +# AI 학습 — YOLOv8 재학습 및 모델 저장 +import multiprocessing +import os +import random +import shutil + +from utils.path_helper import get_path + +import yaml +from PyQt5.QtCore import QThread, pyqtSignal + + +class Trainer: + def __init__(self): + self.model = None + self.is_training = False + + # ------------------------------------------------------------------ # + + def prepare_dataset(self, image_folder: str) -> str: + dataset_dir = get_path("ai", "dataset") + if os.path.exists(dataset_dir): + shutil.rmtree(dataset_dir) + + for split in ("train", "val"): + os.makedirs(os.path.join(dataset_dir, "images", split), exist_ok=True) + os.makedirs(os.path.join(dataset_dir, "labels", split), exist_ok=True) + + pairs = [] + for f in os.listdir(image_folder): + if f.lower().endswith((".jpg", ".jpeg", ".png", ".bmp")): + img_path = os.path.join(image_folder, f) + txt_path = os.path.join( + image_folder, os.path.splitext(f)[0] + ".txt" + ) + if os.path.exists(txt_path): + pairs.append((img_path, txt_path)) + + random.shuffle(pairs) + split_idx = int(len(pairs) * 0.8) + train_pairs = pairs[:split_idx] + val_pairs = pairs[split_idx:] + + for img, lbl in train_pairs: + shutil.copy(img, os.path.join(dataset_dir, "images", "train")) + shutil.copy(lbl, os.path.join(dataset_dir, "labels", "train")) + for img, lbl in val_pairs: + shutil.copy(img, os.path.join(dataset_dir, "images", "val")) + shutil.copy(lbl, os.path.join(dataset_dir, "labels", "val")) + + yaml_content = { + "path": dataset_dir, + "train": "images/train", + "val": "images/val", + "nc": 4, + "names": ["스크래치", "이물", "흑점", "변형"], + } + yaml_path = os.path.join(dataset_dir, "data.yaml") + with open(yaml_path, "w", encoding="utf-8") as fh: + yaml.dump(yaml_content, fh, allow_unicode=True) + + return yaml_path + + # ------------------------------------------------------------------ # + + def train( + self, + image_folder: str, + epochs: int, + batch: int, + save_path: str, + log_callback=None, + progress_callback=None, + ): + self.is_training = True + + try: + if log_callback: + log_callback("데이터셋 준비 중...") + yaml_path = self.prepare_dataset(image_folder) + if log_callback: + log_callback(f"데이터셋 준비 완료: {yaml_path}") + + if log_callback: + log_callback("YOLOv8 모델 로드 중...") + from ultralytics import YOLO # 지연 로딩 — 앱 시작 시 torch DLL 오류 방지 + self.model = YOLO("yolov8n.pt") + + if log_callback: + log_callback(f"학습 시작 (epoch={epochs}, batch={batch})") + + def _on_epoch_end(trainer): + ep = trainer.epoch + 1 + try: + loss_val = float(trainer.loss) + loss_str = f"{loss_val:.4f}" + except Exception: + loss_str = "?" + if log_callback: + log_callback(f"Epoch {ep}/{epochs} loss={loss_str}") + if progress_callback: + progress_callback(int(ep / epochs * 100)) + + self.model.add_callback("on_train_epoch_end", _on_epoch_end) + + self.model.train( + data=yaml_path, + epochs=epochs, + batch=batch, + imgsz=640, + project=get_path("ai", "runs"), + name="train", + exist_ok=True, + verbose=True, + workers=0, # disable DataLoader multiprocessing inside subprocess + amp=False, # AMP check also spawns a subprocess on Windows + plots=False, # matplotlib can interfere with Qt event loop + ) + + # best.pt 복사 + best_pt = get_path("ai", "runs", "train", "weights", "best.pt") + if os.path.exists(best_pt): + os.makedirs(os.path.dirname(save_path), exist_ok=True) + shutil.copy(best_pt, save_path) + if log_callback: + log_callback(f"모델 저장 완료: {save_path}") + + if progress_callback: + progress_callback(100) + if log_callback: + log_callback("학습 완료!") + + except BaseException as e: + import traceback + if log_callback: + try: + log_callback(f"학습 오류: {e}") + log_callback(traceback.format_exc()) + except Exception: + pass + + finally: + self.is_training = False + + # ------------------------------------------------------------------ # + + def stop(self): + self.is_training = False + + +# ====================================================================== # +# Subprocess entry point — defined at module level so it is picklable. +# ultralytics training can call sys.exit() internally; running it in a +# separate process completely isolates the Qt application from that. +# ====================================================================== # + +def _train_subprocess_main(queue, image_folder, epochs, batch, save_path): + """Entry point for the isolated training subprocess.""" + try: + trainer = Trainer() + + def _log(msg): + try: + queue.put(("log", msg)) + except Exception: + pass + + def _progress(pct): + try: + queue.put(("progress", int(pct))) + except Exception: + pass + + trainer.train( + image_folder=image_folder, + epochs=epochs, + batch=batch, + save_path=save_path, + log_callback=_log, + progress_callback=_progress, + ) + queue.put(("done", True)) + + except BaseException as e: + import traceback + try: + queue.put(("log", f"학습 오류: {e}")) + queue.put(("log", traceback.format_exc())) + except Exception: + pass + try: + queue.put(("done", False)) + except Exception: + pass + + +# ====================================================================== # + + +class TrainWorker(QThread): + log_signal = pyqtSignal(str) + progress_signal = pyqtSignal(int) + finished_signal = pyqtSignal(bool) + + def __init__(self, trainer, image_folder, epochs, batch, save_path): + super().__init__() + self.trainer = trainer + self.image_folder = image_folder + self.epochs = epochs + self.batch = batch + self.save_path = save_path + self._proc = None # training subprocess handle + + def run(self): + # Spawn an isolated subprocess so that any sys.exit() call inside + # ultralytics does not reach PyQt5's QThread handler and trigger + # QApplication.exit() in the main process. + ctx = multiprocessing.get_context("spawn") + q = ctx.Queue() + + self._proc = ctx.Process( + target=_train_subprocess_main, + args=(q, self.image_folder, self.epochs, self.batch, self.save_path), + daemon=True, + ) + self._proc.start() + + success = False + while True: + proc_alive = self._proc.is_alive() + try: + msg_type, msg_data = q.get(timeout=0.3) + except Exception: + # queue.Empty — check if subprocess died unexpectedly + if not proc_alive: + # Give one last chance to read remaining messages + while True: + try: + msg_type, msg_data = q.get_nowait() + except Exception: + break + if msg_type == "log": + self.log_signal.emit(str(msg_data)) + elif msg_type == "progress": + self.progress_signal.emit(int(msg_data)) + elif msg_type == "done": + success = bool(msg_data) + break + continue + + if msg_type == "log": + self.log_signal.emit(str(msg_data)) + elif msg_type == "progress": + self.progress_signal.emit(int(msg_data)) + elif msg_type == "done": + success = bool(msg_data) + break + + self._proc.join(timeout=30) + if self._proc.is_alive(): + self._proc.terminate() + self._proc.join(timeout=5) + + self.finished_signal.emit(success) + + def stop_subprocess(self): + """Call from the main thread to forcefully stop training.""" + if self._proc and self._proc.is_alive(): + self._proc.terminate() diff --git a/assets/images/ant_logo.png b/assets/images/ant_logo.png new file mode 100644 index 0000000..c5a9152 Binary files /dev/null and b/assets/images/ant_logo.png differ diff --git a/assets/images/product_1.bmp b/assets/images/product_1.bmp new file mode 100644 index 0000000..f034770 Binary files /dev/null and b/assets/images/product_1.bmp differ diff --git a/assets/patterns.pkl b/assets/patterns.pkl new file mode 100644 index 0000000..694a6d9 Binary files /dev/null and b/assets/patterns.pkl differ diff --git a/assets/products.json b/assets/products.json new file mode 100644 index 0000000..c8e0e60 --- /dev/null +++ b/assets/products.json @@ -0,0 +1,3 @@ +{ + "products": [] +} \ No newline at end of file diff --git a/build.py b/build.py new file mode 100644 index 0000000..c46238e --- /dev/null +++ b/build.py @@ -0,0 +1,31 @@ +import PyInstaller.__main__ +import os + +os.chdir(os.path.dirname(os.path.abspath(__file__))) + +PyInstaller.__main__.run([ + "main.py", + "--onefile", + "--windowed", + "--name=reflector_inspector", + "--add-data=config.json;.", + "--add-data=assets;assets", + "--add-data=ai/models;ai/models", + "--hidden-import=PyQt5", + "--hidden-import=PyQt5.QtCore", + "--hidden-import=PyQt5.QtGui", + "--hidden-import=PyQt5.QtWidgets", + "--hidden-import=cv2", + "--hidden-import=numpy", + "--hidden-import=pymysql", + "--hidden-import=ultralytics", + "--hidden-import=pypylon.pylon", + "--hidden-import=ftplib", + "--hidden-import=socket", + "--collect-all=ultralytics", + "--collect-all=pypylon", + "--distpath=E:/ANT/dist", + "--workpath=E:/ANT/build", + "--specpath=E:/ANT", + "--noconfirm", +]) diff --git a/camera/__init__.py b/camera/__init__.py new file mode 100644 index 0000000..fd55bc8 --- /dev/null +++ b/camera/__init__.py @@ -0,0 +1,3 @@ +# camera 패키지 — InSightCamera, BaslerCamera 노출 +from .insight import InSightCamera +from .basler import BaslerCamera diff --git a/camera/basler.py b/camera/basler.py new file mode 100644 index 0000000..a7dd895 --- /dev/null +++ b/camera/basler.py @@ -0,0 +1,67 @@ +# Basler pylon SDK를 별도로 설치해야 합니다. +# 설치 방법: https://www.baslerweb.com/en/downloads/software-downloads/ +# SDK 설치 후: pip install pypylon +import numpy as np +from pypylon import pylon + + +class BaslerCamera: + def __init__(self): + self.camera = None + + def connect(self): + """첫 번째 Basler 장치에 연결, ExposureAuto=Continuous""" + try: + self.camera = pylon.InstantCamera( + pylon.TlFactory.GetInstance().CreateFirstDevice() + ) + self.camera.Open() + self.camera.ExposureAuto.SetValue("Continuous") + print("[Basler] 연결 성공") + except Exception as e: + print(f"[Basler] 연결 실패: {e}") + self.camera = None + + def disconnect(self): + if self.camera and self.camera.IsOpen(): + try: + self.camera.Close() + except Exception: + pass + print("[Basler] 연결 종료") + + def is_connected(self) -> bool: + return self.camera is not None and self.camera.IsOpen() + + def capture(self) -> np.ndarray: + """단일 프레임 촬영 — GrabOne() 사용 (모든 카메라 모델 호환)""" + if not self.camera: + return None + try: + with self.camera.GrabOne(5000) as result: + if result.GrabSucceeded(): + return result.Array.copy() + except Exception as e: + print(f"[Basler] capture 오류: {e}") + return None + + def start_continuous(self): + """GrabStrategy_LatestImageOnly로 연속 grab 시작""" + if self.camera: + self.camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) + + def grab_latest(self) -> np.ndarray: + """최신 프레임 반환""" + if not self.camera or not self.camera.IsGrabbing(): + return None + try: + with self.camera.RetrieveResult(1000) as result: + if result.GrabSucceeded(): + return result.Array.copy() + except Exception as e: + print(f"[Basler] grab_latest 오류: {e}") + return None + + def stop_continuous(self): + if self.camera: + self.camera.StopGrabbing() diff --git a/camera/insight.py b/camera/insight.py new file mode 100644 index 0000000..a8e4e54 --- /dev/null +++ b/camera/insight.py @@ -0,0 +1,189 @@ +import socket +import ftplib +import io +import time + + +def _ts(): + return time.perf_counter() + + +class InSightCamera: + def __init__(self): + self._sock = None + self._buf = b"" + self._ip = None + self._ftp: "ftplib.FTP | None" = None # 영구 FTP 세션 + + # ------------------------------------------------------------------ # + # 연결 / 해제 + # ------------------------------------------------------------------ # + + def connect(self, ip: str, port: int = 23): + """Telnet 연결 후 FTP 영구 세션도 함께 수립.""" + self._ip = ip + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024) + self._sock.settimeout(5) + self._sock.connect((ip, port)) + self._buf = b"" + + banner = self._read_line() + print(f"[InSight] 배너: {banner!r}") + + self._send("admin") + pwd_prompt = self._read_line() + print(f"[InSight] 프롬프트: {pwd_prompt!r}") + + self._sock.sendall(b"\r\n") + login_result = self._read_line() + print(f"[InSight] 로그인: {login_result!r}") + + print(f"[InSight] Telnet 연결 성공: {ip}:{port}") + except Exception as e: + print(f"[InSight] Telnet 연결 실패: {e}") + self._sock = None + return + + # Telnet 성공 후 FTP 영구 연결 + self.connect_ftp() + + def connect_ftp(self): + """FTP 영구 세션 수립. 실패해도 나중에 _ensure_ftp()가 재시도.""" + if not self._ip: + return + try: + ftp = ftplib.FTP() + ftp.connect(self._ip, 21, timeout=10) + ftp.login("admin", "") + self._ftp = ftp + print("[InSight FTP] 영구 세션 수립") + except Exception as e: + print(f"[InSight FTP] 세션 수립 실패 (자동 재연결 대기): {e}") + self._ftp = None + + def disconnect(self): + self._close_ftp() + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + print("[InSight] 연결 종료") + + def _close_ftp(self): + if self._ftp: + try: + self._ftp.quit() + except Exception: + pass + self._ftp = None + + def is_connected(self) -> bool: + return self._sock is not None + + # ------------------------------------------------------------------ # + # 트리거 / 이미지 + # ------------------------------------------------------------------ # + + def software_trigger(self) -> bool: + """SE8 전송 → 응답이 '1'이면 True""" + t0 = _ts() + self._send("SE8") + resp = self._read_line() + print(f"[InSight][시간] SE8 응답: {resp!r} ({_ts()-t0:.3f}s)") + return resp == "1" + + def trigger_and_get_image(self) -> bytes: + """SE8 트리거 → 1초 대기 → FTP 이미지 수신.""" + self.software_trigger() + time.sleep(1.0) + return self.get_image() + + def get_image(self) -> bytes: + """영구 FTP 세션으로 이미지 수신. 끊겼으면 자동 재연결 후 1회 재시도.""" + if not self._ip: + return b"" + + ftp = self._ensure_ftp() + if ftp is None: + return b"" + + t0 = _ts() + try: + target = self._find_bmp_first(ftp) + print(f"[InSight FTP][시간] 파일 탐색: {_ts()-t0:.3f}s → {target}") + buf = io.BytesIO() + ftp.retrbinary(f"RETR {target}", buf.write) + data = buf.getvalue() + print(f"[InSight FTP] 수신 완료: {len(data)} bytes ({_ts()-t0:.3f}s)") + return data + except Exception as e: + print(f"[InSight FTP] 오류: {e} — 세션 재연결 후 재시도") + self._ftp = None + ftp2 = self._ensure_ftp() + if ftp2 is None: + return b"" + try: + target = self._find_bmp_first(ftp2) + buf = io.BytesIO() + ftp2.retrbinary(f"RETR {target}", buf.write) + return buf.getvalue() + except Exception as e2: + print(f"[InSight FTP] 재시도 실패: {e2}") + return b"" + + def _ensure_ftp(self) -> "ftplib.FTP | None": + """FTP 세션이 살아있으면 재사용, 끊겼으면 재연결.""" + if self._ftp is not None: + try: + self._ftp.voidcmd("NOOP") + return self._ftp + except Exception: + self._ftp = None + self.connect_ftp() + return self._ftp + + # ------------------------------------------------------------------ # + # 내부 헬퍼 + # ------------------------------------------------------------------ # + + def _find_bmp_first(self, ftp: ftplib.FTP) -> str: + """BMP 우선, 없으면 JPG. 기본값 /image.bmp.""" + try: + files = ftp.nlst() + print(f"[InSight FTP] 파일 목록: {files}") + for f in files: + if f.lower().endswith(".bmp"): + return f"/{f}" if not f.startswith("/") else f + for f in files: + if f.lower().endswith(".jpg") or f.lower().endswith(".jpeg"): + return f"/{f}" if not f.startswith("/") else f + except Exception as e: + print(f"[InSight FTP] 파일 탐색 오류: {e}") + return "/image.bmp" + + def _send(self, cmd: str): + self._sock.sendall((cmd + "\r\n").encode()) + + def _read_line(self) -> str: + while b"\r\n" not in self._buf: + try: + chunk = self._sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + self._buf += chunk + + if b"\r\n" in self._buf: + idx = self._buf.index(b"\r\n") + line = self._buf[:idx] + self._buf = self._buf[idx + 2:] + else: + line = self._buf + self._buf = b"" + + return line.decode(errors="ignore").strip() diff --git a/check_build.py b/check_build.py new file mode 100644 index 0000000..6fe1064 --- /dev/null +++ b/check_build.py @@ -0,0 +1,9 @@ +import os + +dist_path = "E:/ANT/dist/reflector_inspector.exe" +if os.path.exists(dist_path): + size_mb = os.path.getsize(dist_path) / (1024 * 1024) + print(f"빌드 성공: {dist_path}") + print(f"파일 크기: {size_mb:.1f} MB") +else: + print("빌드 실패: exe 파일이 없음") diff --git a/config.json b/config.json new file mode 100644 index 0000000..9fc4f23 --- /dev/null +++ b/config.json @@ -0,0 +1,27 @@ +{ + "cognex": { + "ip": "169.254.0.1", + "port": 23 + }, + "basler": { + "exposure": 10000, + "gain": 20 + }, + "conveyor": { + "distance_cm": 100.0, + "speed_cms": 30.0 + }, + "db": { + "server": "Wizis.iptime.org,20220", + "database": "MES_ANT", + "username": "AIUser", + "password": "AIUser" + }, + "plc": { + "ip": "192.168.3.39", + "port": 5010 + }, + "ai": { + "model_path": "ai/models/best.pt" + } +} \ No newline at end of file diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..fbb8f9c --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,2 @@ +# db 패키지 — MySQLClient 노출 +from .mysql_client import MySQLClient diff --git a/db/mysql_client.py b/db/mysql_client.py new file mode 100644 index 0000000..733c087 --- /dev/null +++ b/db/mysql_client.py @@ -0,0 +1,36 @@ +# DB 클라이언트 — MySQL 연결 및 리플렉터 데이터 조회 +import pymysql + + +class MySQLClient: + def __init__(self): + self._conn = None + + def connect(self, host: str, port: int, user: str, password: str, database: str): + self._conn = pymysql.connect( + host=host, port=port, user=user, + password=password, database=database, + charset="utf8mb4", autocommit=True, + ) + print(f"[DB] 연결 성공: {host}:{port}/{database}") + + def disconnect(self): + if self._conn: + self._conn.close() + self._conn = None + print("[DB] 연결 종료") + + def is_connected(self) -> bool: + return self._conn is not None + + def get_reflector_list(self) -> list[dict]: + """리플렉터 목록 조회 — 반환: [{"id": ..., "name": ..., "type": ...}, ...]""" + pass + + def save_reflector(self, name: str, type_lr: str, pattern_data: bytes): + """리플렉터 등록/갱신 — 미구현""" + pass + + def save_inspection_result(self, product_id: int, result: str, defects: list): + """검사 결과 저장 — 미구현""" + pass diff --git a/db/sql_client.py b/db/sql_client.py new file mode 100644 index 0000000..ed53413 --- /dev/null +++ b/db/sql_client.py @@ -0,0 +1,74 @@ +import pyodbc + + +class SQLClient: + def __init__(self): + self.conn = None + self.cursor = None + + def connect(self, server: str, database: str, + username: str, password: str) -> bool: + try: + conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};" + f"SERVER={server};" + f"DATABASE={database};" + f"UID={username};" + f"PWD={password};" + f"TrustServerCertificate=yes;" + f"Encrypt=optional;" + ) + self.conn = pyodbc.connect(conn_str, timeout=10) + self.cursor = self.conn.cursor() + print(f"[DB] 연결 성공: {server}/{database}") + return True + except Exception as e: + print(f"[DB] 연결 실패: {e}") + self.conn = None + return False + + def disconnect(self): + if self.cursor: + self.cursor.close() + if self.conn: + self.conn.close() + self.conn = None + self.cursor = None + print("[DB] 연결 해제") + + def is_connected(self) -> bool: + return self.conn is not None + + def get_reflector_list(self) -> list: + """ + vi_AI_mt_Article 뷰에서 리플렉터 제품 목록 조회. + 반환: [{"article_id": ..., "article": ..., "buyer_article_no": ...}, ...] + """ + if not self.is_connected(): + return [] + try: + self.cursor.execute(""" + SELECT ArticleID, Article, BuyerArticleNo + FROM vi_AI_mt_Article + WHERE Article LIKE '%REF%' + ORDER BY ArticleID + """) + rows = self.cursor.fetchall() + return [ + { + "article_id": row[0], + "article": row[1], + "buyer_article_no": row[2], + } + for row in rows + ] + except Exception as e: + print(f"[DB] 조회 실패: {e}") + return [] + + def save_inspection_result(self, article_id: str, + result: str, score: float) -> bool: + """검사 결과 저장 — 테이블 확정 후 구현.""" + # TODO: 결과 저장 테이블 확정 후 쿼리 구현 + print(f"[DB] 검사 결과 저장: {article_id} {result} {score}") + return True diff --git a/find_cells_trigger.py b/find_cells_trigger.py new file mode 100644 index 0000000..2540397 --- /dev/null +++ b/find_cells_trigger.py @@ -0,0 +1,203 @@ +""" +In-Sight 2001C-353 트리거 후 전체 셀 스캔 스크립트 +SE8 트리거 실행 → 결과 갱신 완료 후 A0~Z50 범위 GV 스캔. +결과는 콘솔 출력과 find_cells_trigger_result.txt 에 동시 저장. +""" + +import socket +import string +import time + +IP = "169.254.0.1" +PORT = 23 +COLS = list(string.ascii_uppercase) # A ~ Z (26개) +ROWS = range(0, 251) # 0 ~ 250 (251개) → 총 6526셀 +RESULT_FILE = "find_cells_trigger_result.txt" + + +# ------------------------------------------------------------------ # +# 저수준 소켓 헬퍼 (insight.py 방식 동일) +# ------------------------------------------------------------------ # + +def _read_line(sock: socket.socket, buf: bytearray) -> str: + """버퍼에서 \\r\\n까지 읽어 문자열로 반환. 남은 데이터는 buf에 보존.""" + while b"\r\n" not in buf: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + buf += chunk + + if b"\r\n" in buf: + idx = buf.index(b"\r\n") + line = bytes(buf[:idx]) + del buf[:idx + 2] + else: + line = bytes(buf) + buf.clear() + + for encoding in ["euc-kr", "cp949", "utf-8"]: + try: + return line.decode(encoding).strip() + except Exception: + continue + return line.decode(errors="ignore").strip() + + +def _send(sock: socket.socket, cmd: str): + sock.sendall((cmd + "\r\n").encode()) + + +# ------------------------------------------------------------------ # +# 로그인 (insight.py connect() 시퀀스와 동일) +# ------------------------------------------------------------------ # + +def _login(sock: socket.socket, buf: bytearray): + banner = _read_line(sock, buf) + print(f"[연결] 배너: {banner!r}") + + _send(sock, "admin") + while b"Password:" not in buf: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + buf += chunk + prompt = buf.decode(errors="ignore").strip() + buf.clear() + print(f"[연결] 프롬프트: {prompt!r}") + + sock.sendall(b"\r\n") # 빈 비밀번호 + result = _read_line(sock, buf) + print(f"[연결] 로그인: {result!r}") + + if "Logged In" not in result: + raise RuntimeError(f"로그인 실패: {result!r}") + + +# ------------------------------------------------------------------ # +# SE8 트리거 +# ------------------------------------------------------------------ # + +def _trigger(sock: socket.socket, buf: bytearray): + """SE8 소프트웨어 트리거 전송 후 응답 수신.""" + _send(sock, "SE8") + response = _read_line(sock, buf) + print(f"[트리거] SE8 응답: {response!r}") + + +# ------------------------------------------------------------------ # +# 셀 스캔 +# ------------------------------------------------------------------ # + +def _scan(sock: socket.socket, buf: bytearray) -> dict: + """GV[열][행] 전체 스캔 → {셀주소: 값문자열}""" + total = len(COLS) * len(ROWS) + found = {} + + for col in COLS: + for row in ROWS: + cell = f"{col}{row}" + _send(sock, f"GV{cell}") + + status = _read_line(sock, buf) # "1" 또는 "0" + + if status == "1": + value = _read_line(sock, buf) + if not value.strip(): + pass + else: + found[cell] = value + try: + print(f"\r[{cell:<4}] = {value:<40}") + except UnicodeEncodeError: + print(f"\r[{cell:<4}] = {value.encode('utf-8', errors='replace')!r}") + + done = (COLS.index(col)) * len(ROWS) + row + 1 + print( + f"스캔 중... [{cell} / Z{ROWS[-1]}] ({done}/{total})", + end="\r", flush=True, + ) + + print() # 진행바 줄 정리 + return found + + +# ------------------------------------------------------------------ # +# 결과 저장 +# ------------------------------------------------------------------ # + +def _save(found: dict, path: str = RESULT_FILE): + with open(path, "w", encoding="utf-8") as f: + f.write(f"스캔 범위: A0 ~ Z{ROWS[-1]} / 유효 셀: {len(found)}개\n") + f.write("=" * 40 + "\n") + for cell, val in found.items(): + f.write(f"[{cell:<4}] = {val}\n") + print(f"결과 저장 완료 → {path}") + + +# ------------------------------------------------------------------ # +# 값 검색 +# ------------------------------------------------------------------ # + +def _search(found: dict, query: str): + matches = {c: v for c, v in found.items() if query.lower() in v.lower()} + if matches: + print(f"\n[검색] '{query}' 포함 셀 {len(matches)}개:") + for cell, val in matches.items(): + print(f" [{cell:<4}] = {val}") + else: + print(f"[검색] '{query}'를 포함한 셀이 없습니다.") + + +# ------------------------------------------------------------------ # +# 메인 +# ------------------------------------------------------------------ # + +def main(): + print(f"[연결] {IP}:{PORT} 접속 중...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3.0) + sock.connect((IP, PORT)) + buf = bytearray() + + try: + _login(sock, buf) + + input("\n준비됐으면 엔터를 누르세요 (리플렉터를 카메라 앞에 올려두세요): ") + + print("[트리거] SE8 전송 중...") + _trigger(sock, buf) + + print("[대기] 카메라 처리 완료 대기 (1초)...") + time.sleep(1) + + print(f"\n스캔 시작: A0 ~ Z{ROWS[-1]} (총 {len(COLS) * len(ROWS)}셀)\n") + found = _scan(sock, buf) + finally: + sock.close() + + print(f"\n스캔 완료. 총 {len(found)}개 셀 발견") + print("=" * 40) + for cell, val in found.items(): + try: + print(f" [{cell:<4}] = {val}") + except UnicodeEncodeError: + print(f" [{cell:<4}] = {val.encode('utf-8', errors='replace')!r}") + + _save(found) + + print() + while True: + query = input("찾는 값 입력 (엔터 시 종료): ").strip() + if not query: + break + _search(found, query) + + +if __name__ == "__main__": + main() diff --git a/find_cells_trigger_result.txt b/find_cells_trigger_result.txt new file mode 100644 index 0000000..22c09eb --- /dev/null +++ b/find_cells_trigger_result.txt @@ -0,0 +1,1736 @@ +스캔 범위: A0 ~ Z250 / 유효 셀: 1734개 +======================================== +[A0 ] = #Image +[A1 ] = 960.000 +[A2 ] = 트리거 +[A3 ] = 2.000 +[A5 ] = 시작 행 +[A6 ] = 0.000 +[A7 ] = 1.000 +[A8 ] = 2001c-353 +[A9 ] = 320.000,420.000,320.000,440.000,0.000,0.000 +[A10 ] = 0.000,0.000,960.000,1280.000,0.000,0.000 +[A11 ] = 0.000 +[A12 ] = 109.314,188.293,680.428,977.468,0.000,0.000 +[A13 ] = 초점 제어 +[A14 ] = 1.000 +[A17 ] = PatMax 패턴 찾기 +[A18 ] = #Image +[A19 ] = 픽스쳐 +[A21 ] = #Patterns +[A22 ] = 도구 활성 +[A23 ] = 1.000 +[A24 ] = 모드 찾기 +[A25 ] = 0.000 +[A26 ] = 결과 +[A27 ] = #ERR +[A28 ] = #ERR +[A29 ] = #ERR +[A30 ] = #ERR +[A31 ] = #ERR +[A32 ] = #ERR +[A33 ] = #ERR +[A34 ] = #ERR +[A35 ] = #ERR +[A36 ] = #ERR +[A37 ] = #ERR +[A38 ] = #ERR +[A40 ] = #ERR +[A41 ] = #ERR +[A42 ] = #ERR +[A43 ] = #ERR +[A44 ] = #ERR +[A45 ] = #ERR +[A46 ] = #ERR +[A47 ] = #ERR +[A48 ] = #ERR +[A49 ] = #ERR +[A51 ] = 0.000 +[A52 ] = 1.000 +[A53 ] = 2.000 +[A54 ] = 3.000 +[A55 ] = 4.000 +[A56 ] = 5.000 +[A57 ] = 6.000 +[A58 ] = 7.000 +[A59 ] = 8.000 +[A60 ] = 9.000 +[A61 ] = 활성화된 상태 +[A62 ] = 1.000 +[A63 ] = 1.000 +[A64 ] = #Count +[A67 ] = PatMax 패턴 찾기 +[A68 ] = #Image +[A69 ] = 픽스쳐 +[A71 ] = #Patterns +[A72 ] = 도구 활성 +[A73 ] = 1.000 +[A74 ] = 모드 찾기 +[A75 ] = 0.000 +[A76 ] = 결과 +[A77 ] = #ERR +[A78 ] = #ERR +[A79 ] = #ERR +[A80 ] = #ERR +[A81 ] = #ERR +[A82 ] = #ERR +[A83 ] = #ERR +[A84 ] = #ERR +[A85 ] = #ERR +[A86 ] = #ERR +[A87 ] = #ERR +[A88 ] = #ERR +[A90 ] = #ERR +[A91 ] = #ERR +[A92 ] = #ERR +[A93 ] = #ERR +[A94 ] = #ERR +[A95 ] = #ERR +[A96 ] = #ERR +[A97 ] = #ERR +[A98 ] = #ERR +[A99 ] = #ERR +[A101] = 0.000 +[A102] = 1.000 +[A103] = 2.000 +[A104] = 3.000 +[A105] = 4.000 +[A106] = 5.000 +[A107] = 6.000 +[A108] = 7.000 +[A109] = 8.000 +[A110] = 9.000 +[A111] = 활성화된 상태 +[A112] = 1.000 +[A113] = 1.000 +[A114] = #Count +[A117] = PatMax 패턴 찾기 +[A118] = #Image +[A119] = 픽스쳐 +[A121] = #Patterns +[A122] = 도구 활성 +[A123] = 1.000 +[A124] = 모드 찾기 +[A125] = 0.000 +[A126] = 결과 +[A127] = #ERR +[A128] = #ERR +[A129] = #ERR +[A130] = #ERR +[A131] = #ERR +[A132] = #ERR +[A133] = #ERR +[A134] = #ERR +[A135] = #ERR +[A136] = #ERR +[A137] = #ERR +[A138] = #ERR +[A140] = #ERR +[A141] = #ERR +[A142] = #ERR +[A143] = #ERR +[A144] = #ERR +[A145] = #ERR +[A146] = #ERR +[A147] = #ERR +[A148] = #ERR +[A149] = #ERR +[A151] = 0.000 +[A152] = 1.000 +[A153] = 2.000 +[A154] = 3.000 +[A155] = 4.000 +[A156] = 5.000 +[A157] = 6.000 +[A158] = 7.000 +[A159] = 8.000 +[A160] = 9.000 +[A161] = 활성화된 상태 +[A162] = 1.000 +[A163] = 1.000 +[A164] = #Count +[A167] = PatMax 패턴 찾기 +[A168] = #Image +[A169] = 픽스쳐 +[A171] = #Patterns +[A172] = 도구 활성 +[A173] = 1.000 +[A174] = 모드 찾기 +[A175] = 0.000 +[A176] = 결과 +[A177] = #ERR +[A178] = #ERR +[A179] = #ERR +[A180] = #ERR +[A181] = #ERR +[A182] = #ERR +[A183] = #ERR +[A184] = #ERR +[A185] = #ERR +[A186] = #ERR +[A187] = #ERR +[A188] = #ERR +[A190] = #ERR +[A191] = #ERR +[A192] = #ERR +[A193] = #ERR +[A194] = #ERR +[A195] = #ERR +[A196] = #ERR +[A197] = #ERR +[A198] = #ERR +[A199] = #ERR +[A201] = 0.000 +[A202] = 1.000 +[A203] = 2.000 +[A204] = 3.000 +[A205] = 4.000 +[A206] = 5.000 +[A207] = 6.000 +[A208] = 7.000 +[A209] = 8.000 +[A210] = 9.000 +[A211] = 활성화된 상태 +[A212] = 1.000 +[A213] = 1.000 +[A214] = #Count +[B1 ] = 32.000 +[B2 ] = 트리거 지체(밀리초) +[B3 ] = 0.000 +[B4 ] = 0.000 +[B5 ] = 행 수 +[B6 ] = 960.000 +[B8 ] = 1.000 +[B9 ] = 320.000 +[B10 ] = 0.000 +[B11 ] = 1.000 +[B12 ] = 109.314 +[B14 ] = 1.000 +[B18 ] = 행 +[B19 ] = 0.000 +[B21 ] = 0.000 +[B22 ] = 작업 통과에 포함 +[B23 ] = 1.000 +[B24 ] = 엄격 채점 +[B25 ] = 0.000 +[B26 ] = 설명 +[B28 ] = 행 +[B29 ] = #ERR +[B30 ] = #ERR +[B31 ] = #ERR +[B32 ] = #ERR +[B33 ] = #ERR +[B34 ] = #ERR +[B35 ] = #ERR +[B36 ] = #ERR +[B37 ] = #ERR +[B38 ] = #ERR +[B40 ] = #ERR +[B41 ] = #ERR +[B42 ] = #ERR +[B43 ] = #ERR +[B44 ] = #ERR +[B45 ] = #ERR +[B46 ] = #ERR +[B47 ] = #ERR +[B48 ] = #ERR +[B49 ] = #ERR +[B50 ] = Index +[B51 ] = 0.000 +[B52 ] = 1.000 +[B53 ] = 2.000 +[B54 ] = 3.000 +[B55 ] = 4.000 +[B56 ] = 5.000 +[B57 ] = 6.000 +[B58 ] = 7.000 +[B59 ] = 8.000 +[B60 ] = 9.000 +[B62 ] = 0.000 +[B63 ] = 통과된 수 +[B64 ] = 0.000 +[B68 ] = 행 +[B69 ] = 0.000 +[B71 ] = 0.000 +[B72 ] = 작업 통과에 포함 +[B73 ] = 1.000 +[B74 ] = 엄격 채점 +[B75 ] = 0.000 +[B76 ] = 설명 +[B78 ] = 행 +[B79 ] = #ERR +[B80 ] = #ERR +[B81 ] = #ERR +[B82 ] = #ERR +[B83 ] = #ERR +[B84 ] = #ERR +[B85 ] = #ERR +[B86 ] = #ERR +[B87 ] = #ERR +[B88 ] = #ERR +[B90 ] = #ERR +[B91 ] = #ERR +[B92 ] = #ERR +[B93 ] = #ERR +[B94 ] = #ERR +[B95 ] = #ERR +[B96 ] = #ERR +[B97 ] = #ERR +[B98 ] = #ERR +[B99 ] = #ERR +[B100] = Index +[B101] = 0.000 +[B102] = 1.000 +[B103] = 2.000 +[B104] = 3.000 +[B105] = 4.000 +[B106] = 5.000 +[B107] = 6.000 +[B108] = 7.000 +[B109] = 8.000 +[B110] = 9.000 +[B112] = 0.000 +[B113] = 통과된 수 +[B114] = 0.000 +[B118] = 행 +[B119] = 0.000 +[B121] = 0.000 +[B122] = 작업 통과에 포함 +[B123] = 1.000 +[B124] = 엄격 채점 +[B125] = 0.000 +[B126] = 설명 +[B128] = 행 +[B129] = #ERR +[B130] = #ERR +[B131] = #ERR +[B132] = #ERR +[B133] = #ERR +[B134] = #ERR +[B135] = #ERR +[B136] = #ERR +[B137] = #ERR +[B138] = #ERR +[B140] = #ERR +[B141] = #ERR +[B142] = #ERR +[B143] = #ERR +[B144] = #ERR +[B145] = #ERR +[B146] = #ERR +[B147] = #ERR +[B148] = #ERR +[B149] = #ERR +[B150] = Index +[B151] = 0.000 +[B152] = 1.000 +[B153] = 2.000 +[B154] = 3.000 +[B155] = 4.000 +[B156] = 5.000 +[B157] = 6.000 +[B158] = 7.000 +[B159] = 8.000 +[B160] = 9.000 +[B162] = 0.000 +[B163] = 통과된 수 +[B164] = 0.000 +[B168] = 행 +[B169] = 0.000 +[B171] = 0.000 +[B172] = 작업 통과에 포함 +[B173] = 1.000 +[B174] = 엄격 채점 +[B175] = 0.000 +[B176] = 설명 +[B178] = 행 +[B179] = #ERR +[B180] = #ERR +[B181] = #ERR +[B182] = #ERR +[B183] = #ERR +[B184] = #ERR +[B185] = #ERR +[B186] = #ERR +[B187] = #ERR +[B188] = #ERR +[B190] = #ERR +[B191] = #ERR +[B192] = #ERR +[B193] = #ERR +[B194] = #ERR +[B195] = #ERR +[B196] = #ERR +[B197] = #ERR +[B198] = #ERR +[B199] = #ERR +[B200] = Index +[B201] = 0.000 +[B202] = 1.000 +[B203] = 2.000 +[B204] = 3.000 +[B205] = 4.000 +[B206] = 5.000 +[B207] = 6.000 +[B208] = 7.000 +[B209] = 8.000 +[B210] = 9.000 +[B212] = 0.000 +[B213] = 통과된 수 +[B214] = 0.000 +[C1 ] = 1.000 +[C2 ] = 트리거 간격(밀리초) +[C3 ] = 0.000 +[C4 ] = 0.000 +[C5 ] = 조명 제어 모드 +[C6 ] = 0.000 +[C7 ] = 1.000 +[C8 ] = 0.000 +[C9 ] = 420.000 +[C10 ] = 0.000 +[C11 ] = #WhiteBal +[C12 ] = 188.293 +[C14 ] = 0.000 +[C15 ] = 1.000 +[C18 ] = Col +[C19 ] = 0.000 +[C22 ] = 트레인 +[C23 ] = 0.000 +[C24 ] = 극성 무시 +[C25 ] = 0.000 +[C26 ] = 모델 활성 상태 +[C27 ] = 0.000 +[C28 ] = Col +[C29 ] = #ERR +[C30 ] = #ERR +[C31 ] = #ERR +[C32 ] = #ERR +[C33 ] = #ERR +[C34 ] = #ERR +[C35 ] = #ERR +[C36 ] = #ERR +[C37 ] = #ERR +[C38 ] = #ERR +[C40 ] = #ERR +[C41 ] = #ERR +[C42 ] = #ERR +[C43 ] = #ERR +[C44 ] = #ERR +[C45 ] = #ERR +[C46 ] = #ERR +[C47 ] = #ERR +[C48 ] = #ERR +[C49 ] = #ERR +[C50 ] = X +[C51 ] = #ERR +[C52 ] = #ERR +[C53 ] = #ERR +[C54 ] = #ERR +[C55 ] = #ERR +[C56 ] = #ERR +[C57 ] = #ERR +[C58 ] = #ERR +[C59 ] = #ERR +[C60 ] = #ERR +[C61 ] = 도구 패스 +[C62 ] = 0.000 +[C63 ] = 실패 +[C64 ] = 18.000 +[C68 ] = Col +[C69 ] = 0.000 +[C72 ] = 트레인 +[C73 ] = 0.000 +[C74 ] = 극성 무시 +[C75 ] = 0.000 +[C76 ] = 모델 활성 상태 +[C77 ] = 0.000 +[C78 ] = Col +[C79 ] = #ERR +[C80 ] = #ERR +[C81 ] = #ERR +[C82 ] = #ERR +[C83 ] = #ERR +[C84 ] = #ERR +[C85 ] = #ERR +[C86 ] = #ERR +[C87 ] = #ERR +[C88 ] = #ERR +[C90 ] = #ERR +[C91 ] = #ERR +[C92 ] = #ERR +[C93 ] = #ERR +[C94 ] = #ERR +[C95 ] = #ERR +[C96 ] = #ERR +[C97 ] = #ERR +[C98 ] = #ERR +[C99 ] = #ERR +[C100] = X +[C101] = #ERR +[C102] = #ERR +[C103] = #ERR +[C104] = #ERR +[C105] = #ERR +[C106] = #ERR +[C107] = #ERR +[C108] = #ERR +[C109] = #ERR +[C110] = #ERR +[C111] = 도구 패스 +[C112] = 0.000 +[C113] = 실패 +[C114] = 18.000 +[C118] = Col +[C119] = 0.000 +[C122] = 트레인 +[C123] = 0.000 +[C124] = 극성 무시 +[C125] = 0.000 +[C126] = 모델 활성 상태 +[C127] = 0.000 +[C128] = Col +[C129] = #ERR +[C130] = #ERR +[C131] = #ERR +[C132] = #ERR +[C133] = #ERR +[C134] = #ERR +[C135] = #ERR +[C136] = #ERR +[C137] = #ERR +[C138] = #ERR +[C140] = #ERR +[C141] = #ERR +[C142] = #ERR +[C143] = #ERR +[C144] = #ERR +[C145] = #ERR +[C146] = #ERR +[C147] = #ERR +[C148] = #ERR +[C149] = #ERR +[C150] = X +[C151] = #ERR +[C152] = #ERR +[C153] = #ERR +[C154] = #ERR +[C155] = #ERR +[C156] = #ERR +[C157] = #ERR +[C158] = #ERR +[C159] = #ERR +[C160] = #ERR +[C161] = 도구 패스 +[C162] = 0.000 +[C163] = 실패 +[C164] = 18.000 +[C168] = Col +[C169] = 0.000 +[C172] = 트레인 +[C173] = 0.000 +[C174] = 극성 무시 +[C175] = 0.000 +[C176] = 모델 활성 상태 +[C177] = 0.000 +[C178] = Col +[C179] = #ERR +[C180] = #ERR +[C181] = #ERR +[C182] = #ERR +[C183] = #ERR +[C184] = #ERR +[C185] = #ERR +[C186] = #ERR +[C187] = #ERR +[C188] = #ERR +[C190] = #ERR +[C191] = #ERR +[C192] = #ERR +[C193] = #ERR +[C194] = #ERR +[C195] = #ERR +[C196] = #ERR +[C197] = #ERR +[C198] = #ERR +[C199] = #ERR +[C200] = X +[C201] = #ERR +[C202] = #ERR +[C203] = #ERR +[C204] = #ERR +[C205] = #ERR +[C206] = #ERR +[C207] = #ERR +[C208] = #ERR +[C209] = #ERR +[C210] = #ERR +[C211] = 도구 패스 +[C212] = 0.000 +[C213] = 실패 +[C214] = 18.000 +[D1 ] = 0.000 +[D2 ] = 노출(밀리초) +[D3 ] = 28.000 +[D4 ] = 1.000 +[D5 ] = 조명 강도 +[D6 ] = 70.000 +[D7 ] = 1.000 +[D8 ] = 0.000 +[D9 ] = 320.000 +[D10 ] = 960.000 +[D11 ] = 0.000 +[D12 ] = 680.428 +[D13 ] = 초점 위치 +[D14 ] = 117.000 +[D18 ] = 각도 +[D19 ] = 0.000 +[D21 ] = 1.000 +[D22 ] = 결과 수 +[D23 ] = 1.000 +[D24 ] = 외부 재트레이닝 +[D25 ] = 0.000 +[D28 ] = 각도 +[D29 ] = #ERR +[D30 ] = #ERR +[D31 ] = #ERR +[D32 ] = #ERR +[D33 ] = #ERR +[D34 ] = #ERR +[D35 ] = #ERR +[D36 ] = #ERR +[D37 ] = #ERR +[D38 ] = #ERR +[D40 ] = #ERR +[D41 ] = #ERR +[D42 ] = #ERR +[D43 ] = #ERR +[D44 ] = #ERR +[D45 ] = #ERR +[D46 ] = #ERR +[D47 ] = #ERR +[D48 ] = #ERR +[D49 ] = #ERR +[D50 ] = Y +[D51 ] = #ERR +[D52 ] = #ERR +[D53 ] = #ERR +[D54 ] = #ERR +[D55 ] = #ERR +[D56 ] = #ERR +[D57 ] = #ERR +[D58 ] = #ERR +[D59 ] = #ERR +[D60 ] = #ERR +[D61 ] = 도구 실패 +[D62 ] = 1.000 +[D63 ] = 오류 +[D64 ] = 0.000 +[D68 ] = 각도 +[D69 ] = 0.000 +[D71 ] = 1.000 +[D72 ] = 결과 수 +[D73 ] = 1.000 +[D74 ] = 외부 재트레이닝 +[D75 ] = 0.000 +[D78 ] = 각도 +[D79 ] = #ERR +[D80 ] = #ERR +[D81 ] = #ERR +[D82 ] = #ERR +[D83 ] = #ERR +[D84 ] = #ERR +[D85 ] = #ERR +[D86 ] = #ERR +[D87 ] = #ERR +[D88 ] = #ERR +[D90 ] = #ERR +[D91 ] = #ERR +[D92 ] = #ERR +[D93 ] = #ERR +[D94 ] = #ERR +[D95 ] = #ERR +[D96 ] = #ERR +[D97 ] = #ERR +[D98 ] = #ERR +[D99 ] = #ERR +[D100] = Y +[D101] = #ERR +[D102] = #ERR +[D103] = #ERR +[D104] = #ERR +[D105] = #ERR +[D106] = #ERR +[D107] = #ERR +[D108] = #ERR +[D109] = #ERR +[D110] = #ERR +[D111] = 도구 실패 +[D112] = 1.000 +[D113] = 오류 +[D114] = 0.000 +[D118] = 각도 +[D119] = 0.000 +[D121] = 1.000 +[D122] = 결과 수 +[D123] = 1.000 +[D124] = 외부 재트레이닝 +[D125] = 0.000 +[D128] = 각도 +[D129] = #ERR +[D130] = #ERR +[D131] = #ERR +[D132] = #ERR +[D133] = #ERR +[D134] = #ERR +[D135] = #ERR +[D136] = #ERR +[D137] = #ERR +[D138] = #ERR +[D140] = #ERR +[D141] = #ERR +[D142] = #ERR +[D143] = #ERR +[D144] = #ERR +[D145] = #ERR +[D146] = #ERR +[D147] = #ERR +[D148] = #ERR +[D149] = #ERR +[D150] = Y +[D151] = #ERR +[D152] = #ERR +[D153] = #ERR +[D154] = #ERR +[D155] = #ERR +[D156] = #ERR +[D157] = #ERR +[D158] = #ERR +[D159] = #ERR +[D160] = #ERR +[D161] = 도구 실패 +[D162] = 1.000 +[D163] = 오류 +[D164] = 0.000 +[D168] = 각도 +[D169] = 0.000 +[D171] = 1.000 +[D172] = 결과 수 +[D173] = 1.000 +[D174] = 외부 재트레이닝 +[D175] = 0.000 +[D178] = 각도 +[D179] = #ERR +[D180] = #ERR +[D181] = #ERR +[D182] = #ERR +[D183] = #ERR +[D184] = #ERR +[D185] = #ERR +[D186] = #ERR +[D187] = #ERR +[D188] = #ERR +[D190] = #ERR +[D191] = #ERR +[D192] = #ERR +[D193] = #ERR +[D194] = #ERR +[D195] = #ERR +[D196] = #ERR +[D197] = #ERR +[D198] = #ERR +[D199] = #ERR +[D200] = Y +[D201] = #ERR +[D202] = #ERR +[D203] = #ERR +[D204] = #ERR +[D205] = #ERR +[D206] = #ERR +[D207] = #ERR +[D208] = #ERR +[D209] = #ERR +[D210] = #ERR +[D211] = 도구 실패 +[D212] = 1.000 +[D213] = 오류 +[D214] = 0.000 +[E1 ] = 1000.000 +[E2 ] = 자동 노출 +[E3 ] = 0.000 +[E4 ] = 1.000 +[E5 ] = 게인 +[E6 ] = 0.000 +[E7 ] = 1.000 +[E8 ] = 0.000 +[E9 ] = 440.000 +[E10 ] = 1280.000 +[E11 ] = 0.000 +[E12 ] = 977.468 +[E13 ] = 작업 로드에 자동 초점 +[E14 ] = 0.000 +[E22 ] = 임계치 수락 +[E23 ] = 50.000 +[E24 ] = 순으로 정렬 +[E25 ] = 0.000 +[E26 ] = 찾은 수 +[E27 ] = 0.000 +[E29 ] = #ERR +[E30 ] = #Plot +[E31 ] = #Plot +[E32 ] = #Plot +[E33 ] = #Plot +[E34 ] = #Plot +[E35 ] = #Plot +[E36 ] = #Plot +[E37 ] = #Plot +[E38 ] = #Plot +[E40 ] = #ERR +[E41 ] = #ERR +[E42 ] = #ERR +[E43 ] = #ERR +[E44 ] = #ERR +[E45 ] = #ERR +[E46 ] = #ERR +[E47 ] = #ERR +[E48 ] = #ERR +[E49 ] = #ERR +[E50 ] = 각도 +[E51 ] = #ERR +[E52 ] = #ERR +[E53 ] = #ERR +[E54 ] = #ERR +[E55 ] = #ERR +[E56 ] = #ERR +[E57 ] = #ERR +[E58 ] = #ERR +[E59 ] = #ERR +[E60 ] = #ERR +[E61 ] = 상태 +[E62 ] = 2.000 +[E63 ] = 총합 +[E64 ] = 18.000 +[E65 ] = 86.328 +[E72 ] = 임계치 수락 +[E73 ] = 95.000 +[E74 ] = 순으로 정렬 +[E75 ] = 0.000 +[E76 ] = 찾은 수 +[E77 ] = 0.000 +[E79 ] = #ERR +[E80 ] = #Plot +[E81 ] = #Plot +[E82 ] = #Plot +[E83 ] = #Plot +[E84 ] = #Plot +[E85 ] = #Plot +[E86 ] = #Plot +[E87 ] = #Plot +[E88 ] = #Plot +[E90 ] = #ERR +[E91 ] = #ERR +[E92 ] = #ERR +[E93 ] = #ERR +[E94 ] = #ERR +[E95 ] = #ERR +[E96 ] = #ERR +[E97 ] = #ERR +[E98 ] = #ERR +[E99 ] = #ERR +[E100] = 각도 +[E101] = #ERR +[E102] = #ERR +[E103] = #ERR +[E104] = #ERR +[E105] = #ERR +[E106] = #ERR +[E107] = #ERR +[E108] = #ERR +[E109] = #ERR +[E110] = #ERR +[E111] = 상태 +[E112] = 2.000 +[E113] = 총합 +[E114] = 18.000 +[E115] = 74.360 +[E122] = 임계치 수락 +[E123] = 95.000 +[E124] = 순으로 정렬 +[E125] = 0.000 +[E126] = 찾은 수 +[E127] = 0.000 +[E129] = #ERR +[E130] = #Plot +[E131] = #Plot +[E132] = #Plot +[E133] = #Plot +[E134] = #Plot +[E135] = #Plot +[E136] = #Plot +[E137] = #Plot +[E138] = #Plot +[E140] = #ERR +[E141] = #ERR +[E142] = #ERR +[E143] = #ERR +[E144] = #ERR +[E145] = #ERR +[E146] = #ERR +[E147] = #ERR +[E148] = #ERR +[E149] = #ERR +[E150] = 각도 +[E151] = #ERR +[E152] = #ERR +[E153] = #ERR +[E154] = #ERR +[E155] = #ERR +[E156] = #ERR +[E157] = #ERR +[E158] = #ERR +[E159] = #ERR +[E160] = #ERR +[E161] = 상태 +[E162] = 2.000 +[E163] = 총합 +[E164] = 18.000 +[E165] = 81.792 +[E172] = 임계치 수락 +[E173] = 80.000 +[E174] = 순으로 정렬 +[E175] = 0.000 +[E176] = 찾은 수 +[E177] = 0.000 +[E179] = #ERR +[E180] = #Plot +[E181] = #Plot +[E182] = #Plot +[E183] = #Plot +[E184] = #Plot +[E185] = #Plot +[E186] = #Plot +[E187] = #Plot +[E188] = #Plot +[E190] = #ERR +[E191] = #ERR +[E192] = #ERR +[E193] = #ERR +[E194] = #ERR +[E195] = #ERR +[E196] = #ERR +[E197] = #ERR +[E198] = #ERR +[E199] = #ERR +[E200] = 각도 +[E201] = #ERR +[E202] = #ERR +[E203] = #ERR +[E204] = #ERR +[E205] = #ERR +[E206] = #ERR +[E207] = #ERR +[E208] = #ERR +[E209] = #ERR +[E210] = #ERR +[E211] = 상태 +[E212] = 2.000 +[E213] = 총합 +[E214] = 18.000 +[E215] = 79.426 +[F1 ] = 1280.000 +[F2 ] = 최대 노출 시간 +[F3 ] = 950.000 +[F4 ] = 0.000 +[F6 ] = 70.000 +[F7 ] = 0.000 +[F8 ] = 1.000 +[F11 ] = 32.000 +[F12 ] = 0.000 +[F13 ] = 자동 초점 +[F14 ] = 1.000 +[F17 ] = 1.000 +[F19 ] = 0.000 +[F20 ] = 0.000 +[F21 ] = 80.000 +[F22 ] = 대비 임계치 +[F23 ] = 10.000 +[F24 ] = 수평 오프셋 +[F25 ] = 0.000 +[F29 ] = #ERR +[F30 ] = 0.000 +[F31 ] = 0.000 +[F32 ] = 0.000 +[F33 ] = 0.000 +[F34 ] = 0.000 +[F35 ] = 0.000 +[F36 ] = 0.000 +[F37 ] = 0.000 +[F38 ] = 0.000 +[F40 ] = #ERR +[F41 ] = #ERR +[F42 ] = #ERR +[F43 ] = #ERR +[F44 ] = #ERR +[F45 ] = #ERR +[F46 ] = #ERR +[F47 ] = #ERR +[F48 ] = #ERR +[F49 ] = #ERR +[F50 ] = 점수 +[F51 ] = #ERR +[F52 ] = #ERR +[F53 ] = #ERR +[F54 ] = #ERR +[F55 ] = #ERR +[F56 ] = #ERR +[F57 ] = #ERR +[F58 ] = #ERR +[F59 ] = #ERR +[F60 ] = #ERR +[F61 ] = 합격/불합격 +[F62 ] = 0.000 +[F63 ] = 패턴_1 +[F64 ] = 0.000 +[F65 ] = 1.589 +[F67 ] = 2.000 +[F69 ] = 0.000 +[F70 ] = 0.000 +[F71 ] = 36.630 +[F72 ] = 대비 임계치 +[F73 ] = 10.000 +[F74 ] = 수평 오프셋 +[F75 ] = 0.000 +[F79 ] = #ERR +[F80 ] = 0.000 +[F81 ] = 0.000 +[F82 ] = 0.000 +[F83 ] = 0.000 +[F84 ] = 0.000 +[F85 ] = 0.000 +[F86 ] = 0.000 +[F87 ] = 0.000 +[F88 ] = 0.000 +[F90 ] = #ERR +[F91 ] = #ERR +[F92 ] = #ERR +[F93 ] = #ERR +[F94 ] = #ERR +[F95 ] = #ERR +[F96 ] = #ERR +[F97 ] = #ERR +[F98 ] = #ERR +[F99 ] = #ERR +[F100] = 점수 +[F101] = #ERR +[F102] = #ERR +[F103] = #ERR +[F104] = #ERR +[F105] = #ERR +[F106] = #ERR +[F107] = #ERR +[F108] = #ERR +[F109] = #ERR +[F110] = #ERR +[F111] = 합격/불합격 +[F112] = 0.000 +[F113] = 패턴_2 +[F114] = 0.000 +[F115] = 1.498 +[F117] = 3.000 +[F119] = 0.000 +[F120] = 0.000 +[F121] = 47.619 +[F122] = 대비 임계치 +[F123] = 10.000 +[F124] = 수평 오프셋 +[F125] = 0.000 +[F129] = #ERR +[F130] = 0.000 +[F131] = 0.000 +[F132] = 0.000 +[F133] = 0.000 +[F134] = 0.000 +[F135] = 0.000 +[F136] = 0.000 +[F137] = 0.000 +[F138] = 0.000 +[F140] = #ERR +[F141] = #ERR +[F142] = #ERR +[F143] = #ERR +[F144] = #ERR +[F145] = #ERR +[F146] = #ERR +[F147] = #ERR +[F148] = #ERR +[F149] = #ERR +[F150] = 점수 +[F151] = #ERR +[F152] = #ERR +[F153] = #ERR +[F154] = #ERR +[F155] = #ERR +[F156] = #ERR +[F157] = #ERR +[F158] = #ERR +[F159] = #ERR +[F160] = #ERR +[F161] = 합격/불합격 +[F162] = 0.000 +[F163] = 패턴_3 +[F164] = 0.000 +[F165] = 1.448 +[F167] = 4.000 +[F169] = 0.000 +[F170] = 0.000 +[F171] = 51.282 +[F172] = 대비 임계치 +[F173] = 10.000 +[F174] = 수평 오프셋 +[F175] = 0.000 +[F179] = #ERR +[F180] = 0.000 +[F181] = 0.000 +[F182] = 0.000 +[F183] = 0.000 +[F184] = 0.000 +[F185] = 0.000 +[F186] = 0.000 +[F187] = 0.000 +[F188] = 0.000 +[F190] = #ERR +[F191] = #ERR +[F192] = #ERR +[F193] = #ERR +[F194] = #ERR +[F195] = #ERR +[F196] = #ERR +[F197] = #ERR +[F198] = #ERR +[F199] = #ERR +[F200] = 점수 +[F201] = #ERR +[F202] = #ERR +[F203] = #ERR +[F204] = #ERR +[F205] = #ERR +[F206] = #ERR +[F207] = #ERR +[F208] = #ERR +[F209] = #ERR +[F210] = #ERR +[F211] = 합격/불합격 +[F212] = 0.000 +[F213] = 패턴_4 +[F214] = 0.000 +[F215] = 1.396 +[G1 ] = 1.000 +[G2 ] = 목표치 이미지 밝기 +[G3 ] = 50.000 +[G4 ] = 28.000 +[G6 ] = #Time +[G7 ] = 01/01/1970 00:15:51.866 +[G8 ] = 01/01/1970 +[G9 ] = 00:15:51.866 +[G11 ] = 0.000 +[G12 ] = 0.000 +[G15 ] = 0.007 +[G17 ] = 패턴_1 +[G18 ] = 패턴 +[G19 ] = #ERR +[G21 ] = #Image +[G22 ] = 회전 오차허용 +[G23 ] = 15.000 +[G24 ] = 수직 오프셋 +[G25 ] = 0.000 +[G29 ] = #ERR +[G30 ] = 0.000 +[G31 ] = 0.000 +[G32 ] = 0.000 +[G33 ] = 0.000 +[G34 ] = 0.000 +[G35 ] = 0.000 +[G36 ] = 0.000 +[G37 ] = 0.000 +[G38 ] = 0.000 +[G40 ] = #ERR +[G41 ] = #ERR +[G42 ] = #ERR +[G43 ] = #ERR +[G44 ] = #ERR +[G45 ] = #ERR +[G46 ] = #ERR +[G47 ] = #ERR +[G48 ] = #ERR +[G49 ] = #ERR +[G50 ] = 스케일 +[G51 ] = #ERR +[G52 ] = #ERR +[G53 ] = #ERR +[G54 ] = #ERR +[G55 ] = #ERR +[G56 ] = #ERR +[G57 ] = #ERR +[G58 ] = #ERR +[G59 ] = #ERR +[G60 ] = #ERR +[G61 ] = 1.000 +[G62 ] = 1.000 +[G63 ] = #Plot +[G65 ] = 87.917 +[G67 ] = 패턴_2 +[G68 ] = 패턴 +[G69 ] = #ERR +[G71 ] = #Image +[G72 ] = 회전 오차허용 +[G73 ] = 15.000 +[G74 ] = 수직 오프셋 +[G75 ] = 0.000 +[G79 ] = #ERR +[G80 ] = 0.000 +[G81 ] = 0.000 +[G82 ] = 0.000 +[G83 ] = 0.000 +[G84 ] = 0.000 +[G85 ] = 0.000 +[G86 ] = 0.000 +[G87 ] = 0.000 +[G88 ] = 0.000 +[G90 ] = #ERR +[G91 ] = #ERR +[G92 ] = #ERR +[G93 ] = #ERR +[G94 ] = #ERR +[G95 ] = #ERR +[G96 ] = #ERR +[G97 ] = #ERR +[G98 ] = #ERR +[G99 ] = #ERR +[G100] = 스케일 +[G101] = #ERR +[G102] = #ERR +[G103] = #ERR +[G104] = #ERR +[G105] = #ERR +[G106] = #ERR +[G107] = #ERR +[G108] = #ERR +[G109] = #ERR +[G110] = #ERR +[G111] = 1.000 +[G112] = 1.000 +[G113] = #Plot +[G115] = 75.858 +[G117] = 패턴_3 +[G118] = 패턴 +[G119] = #ERR +[G121] = #Image +[G122] = 회전 오차허용 +[G123] = 15.000 +[G124] = 수직 오프셋 +[G125] = 0.000 +[G129] = #ERR +[G130] = 0.000 +[G131] = 0.000 +[G132] = 0.000 +[G133] = 0.000 +[G134] = 0.000 +[G135] = 0.000 +[G136] = 0.000 +[G137] = 0.000 +[G138] = 0.000 +[G140] = #ERR +[G141] = #ERR +[G142] = #ERR +[G143] = #ERR +[G144] = #ERR +[G145] = #ERR +[G146] = #ERR +[G147] = #ERR +[G148] = #ERR +[G149] = #ERR +[G150] = 스케일 +[G151] = #ERR +[G152] = #ERR +[G153] = #ERR +[G154] = #ERR +[G155] = #ERR +[G156] = #ERR +[G157] = #ERR +[G158] = #ERR +[G159] = #ERR +[G160] = #ERR +[G161] = 1.000 +[G162] = 1.000 +[G163] = #Plot +[G165] = 83.240 +[G167] = 패턴_4 +[G168] = 패턴 +[G169] = #ERR +[G171] = #Image +[G172] = 회전 오차허용 +[G173] = 15.000 +[G174] = 수직 오프셋 +[G175] = 0.000 +[G179] = #ERR +[G180] = 0.000 +[G181] = 0.000 +[G182] = 0.000 +[G183] = 0.000 +[G184] = 0.000 +[G185] = 0.000 +[G186] = 0.000 +[G187] = 0.000 +[G188] = 0.000 +[G190] = #ERR +[G191] = #ERR +[G192] = #ERR +[G193] = #ERR +[G194] = #ERR +[G195] = #ERR +[G196] = #ERR +[G197] = #ERR +[G198] = #ERR +[G199] = #ERR +[G200] = 스케일 +[G201] = #ERR +[G202] = #ERR +[G203] = #ERR +[G204] = #ERR +[G205] = #ERR +[G206] = #ERR +[G207] = #ERR +[G208] = #ERR +[G209] = #ERR +[G210] = #ERR +[G211] = 1.000 +[G212] = 1.000 +[G213] = #Plot +[G215] = 80.822 +[H2 ] = 창 모드 +[H3 ] = 0.000 +[H4 ] = 0.000 +[H5 ] = 0.000 +[H6 ] = 0.000 +[H15 ] = 끝 +[H18 ] = #Calib +[H19 ] = #Calib +[H20 ] = #ERR +[H21 ] = #ERR +[H22 ] = 스케일 오차 허용 +[H23 ] = 0.000 +[H24 ] = 타임아웃 +[H25 ] = 5000.000 +[H27 ] = 0.000 +[H29 ] = #ERR +[H30 ] = 0.000 +[H31 ] = 0.000 +[H32 ] = 0.000 +[H33 ] = 0.000 +[H34 ] = 0.000 +[H35 ] = 0.000 +[H36 ] = 0.000 +[H37 ] = 0.000 +[H38 ] = 0.000 +[H40 ] = 1.000 +[H41 ] = 0.000 +[H42 ] = 0.000 +[H43 ] = 0.000 +[H44 ] = 0.000 +[H45 ] = 0.000 +[H46 ] = 0.000 +[H47 ] = 0.000 +[H48 ] = 0.000 +[H49 ] = 0.000 +[H50 ] = 찾음 +[H51 ] = 0.000 +[H52 ] = 0.000 +[H53 ] = 0.000 +[H54 ] = 0.000 +[H55 ] = 0.000 +[H56 ] = 0.000 +[H57 ] = 0.000 +[H58 ] = 0.000 +[H59 ] = 0.000 +[H60 ] = 0.000 +[H65 ] = 끝 +[H68 ] = #Calib +[H69 ] = #Calib +[H70 ] = #ERR +[H71 ] = #ERR +[H72 ] = 스케일 오차 허용 +[H73 ] = 0.000 +[H74 ] = 타임아웃 +[H75 ] = 5000.000 +[H77 ] = 0.000 +[H79 ] = #ERR +[H80 ] = 0.000 +[H81 ] = 0.000 +[H82 ] = 0.000 +[H83 ] = 0.000 +[H84 ] = 0.000 +[H85 ] = 0.000 +[H86 ] = 0.000 +[H87 ] = 0.000 +[H88 ] = 0.000 +[H90 ] = 1.000 +[H91 ] = 0.000 +[H92 ] = 0.000 +[H93 ] = 0.000 +[H94 ] = 0.000 +[H95 ] = 0.000 +[H96 ] = 0.000 +[H97 ] = 0.000 +[H98 ] = 0.000 +[H99 ] = 0.000 +[H100] = 찾음 +[H101] = 0.000 +[H102] = 0.000 +[H103] = 0.000 +[H104] = 0.000 +[H105] = 0.000 +[H106] = 0.000 +[H107] = 0.000 +[H108] = 0.000 +[H109] = 0.000 +[H110] = 0.000 +[H115] = 끝 +[H118] = #Calib +[H119] = #Calib +[H120] = #ERR +[H121] = #ERR +[H122] = 스케일 오차 허용 +[H123] = 0.000 +[H124] = 타임아웃 +[H125] = 5000.000 +[H127] = 0.000 +[H129] = #ERR +[H130] = 0.000 +[H131] = 0.000 +[H132] = 0.000 +[H133] = 0.000 +[H134] = 0.000 +[H135] = 0.000 +[H136] = 0.000 +[H137] = 0.000 +[H138] = 0.000 +[H140] = 1.000 +[H141] = 0.000 +[H142] = 0.000 +[H143] = 0.000 +[H144] = 0.000 +[H145] = 0.000 +[H146] = 0.000 +[H147] = 0.000 +[H148] = 0.000 +[H149] = 0.000 +[H150] = 찾음 +[H151] = 0.000 +[H152] = 0.000 +[H153] = 0.000 +[H154] = 0.000 +[H155] = 0.000 +[H156] = 0.000 +[H157] = 0.000 +[H158] = 0.000 +[H159] = 0.000 +[H160] = 0.000 +[H165] = 끝 +[H168] = #Calib +[H169] = #Calib +[H170] = #ERR +[H171] = #ERR +[H172] = 스케일 오차 허용 +[H173] = 0.000 +[H174] = 타임아웃 +[H175] = 5000.000 +[H177] = 0.000 +[H179] = #ERR +[H180] = 0.000 +[H181] = 0.000 +[H182] = 0.000 +[H183] = 0.000 +[H184] = 0.000 +[H185] = 0.000 +[H186] = 0.000 +[H187] = 0.000 +[H188] = 0.000 +[H190] = 1.000 +[H191] = 0.000 +[H192] = 0.000 +[H193] = 0.000 +[H194] = 0.000 +[H195] = 0.000 +[H196] = 0.000 +[H197] = 0.000 +[H198] = 0.000 +[H199] = 0.000 +[H200] = 찾음 +[H201] = 0.000 +[H202] = 0.000 +[H203] = 0.000 +[H204] = 0.000 +[H205] = 0.000 +[H206] = 0.000 +[H207] = 0.000 +[H208] = 0.000 +[H209] = 0.000 +[H210] = 0.000 +[H215] = 끝 +[S0 ] = 전체 합격/불합격 +[S1 ] = 0.000 +[S2 ] = 0.000 +[S3 ] = 0.000 +[S5 ] = #Count +[S8 ] = 이미지 주위로 합격/불합격 경계선 그리기 +[S9 ] = 합격/불합격 +[S10 ] = 0.000 +[S15 ] = 접점 입력 읽기 +[S16 ] = 라인 +[S17 ] = 0.000 +[S18 ] = 1.000 +[S19 ] = 2.000 +[S20 ] = 3.000 +[S21 ] = 4.000 +[S22 ] = 5.000 +[S23 ] = 6.000 +[S24 ] = 7.000 +[S25 ] = 8.000 +[S26 ] = 9.000 +[S27 ] = 10.000 +[S28 ] = 11.000 +[S31 ] = 접점 출력 쓰기 +[S32 ] = 라인 +[S33 ] = 0.000 +[S34 ] = 1.000 +[S35 ] = 2.000 +[S36 ] = 3.000 +[S37 ] = 4.000 +[S38 ] = 5.000 +[S39 ] = 6.000 +[S40 ] = 7.000 +[S41 ] = 8.000 +[S42 ] = 9.000 +[S43 ] = 10.000 +[S44 ] = 11.000 +[S45 ] = 12.000 +[S46 ] = 13.000 +[S49 ] = 보정 없음 +[S50 ] = 기본설정 +[S51 ] = #Calib +[S53 ] = FTP 설정 쓰기 +[S54 ] = 호스트명 1 +[S56 ] = 호스트명 2 +[S59 ] = 도구 속성 참조 +[T1 ] = 1.000 +[T2 ] = 1.000 +[T3 ] = 1.000 +[T4 ] = 통과된 수 +[T5 ] = 0.000 +[T9 ] = 직선 +[T10 ] = #Plot +[T11 ] = #Plot +[T12 ] = #Plot +[T13 ] = #Plot +[T16 ] = 이름 +[T17 ] = Input 0 +[T18 ] = Input 1 +[T19 ] = Input 2 +[T20 ] = Input 3 +[T21 ] = Input 4 +[T22 ] = Input 5 +[T23 ] = Input 6 +[T24 ] = Input 7 +[T25 ] = Input 8 +[T26 ] = Input 9 +[T27 ] = 입력 10 +[T28 ] = 입력 11 +[T32 ] = 값 +[T33 ] = 0.000 +[T34 ] = 0.000 +[T35 ] = 0.000 +[T36 ] = 0.000 +[T37 ] = 0.000 +[T38 ] = 1.000 +[T39 ] = 0.000 +[T40 ] = 0.000 +[T41 ] = 0.000 +[T42 ] = 0.000 +[T43 ] = 0.000 +[T44 ] = 0.000 +[T45 ] = 0.000 +[T46 ] = 0.000 +[T50 ] = 클래식 +[T51 ] = #Calib +[T54 ] = 사용자명 1 +[T56 ] = 사용자명 2 +[U1 ] = 2.000 +[U2 ] = 1.000 +[U3 ] = 1.000 +[U4 ] = 실패 +[U5 ] = 18.000 +[U9 ] = 오프셋 +[U10 ] = 0.000 +[U16 ] = 값 +[U17 ] = 0.000 +[U18 ] = 0.000 +[U19 ] = 0.000 +[U20 ] = 0.000 +[U21 ] = 0.000 +[U22 ] = 0.000 +[U23 ] = 0.000 +[U24 ] = 0.000 +[U25 ] = 0.000 +[U26 ] = 0.000 +[U27 ] = 0.000 +[U28 ] = 0.000 +[U32 ] = 강제 +[U33 ] = 0.000 +[U34 ] = 0.000 +[U35 ] = 0.000 +[U36 ] = 0.000 +[U37 ] = 0.000 +[U38 ] = 0.000 +[U39 ] = 0.000 +[U40 ] = 0.000 +[U41 ] = 0.000 +[U42 ] = 0.000 +[U43 ] = 0.000 +[U44 ] = 0.000 +[U45 ] = 0.000 +[U46 ] = 0.000 +[U51 ] = 0.000 +[U54 ] = 암호 1 +[U55 ] = ******************** +[U56 ] = 암호 2 +[U57 ] = ******************** +[V1 ] = #ERR +[V2 ] = 1.000 +[V3 ] = 1.000 +[V4 ] = 오류 +[V5 ] = 0.000 +[V13 ] = 끝 +[V16 ] = 강제 +[V17 ] = 0.000 +[V18 ] = 0.000 +[V19 ] = 0.000 +[V20 ] = 0.000 +[V21 ] = 0.000 +[V22 ] = 0.000 +[V23 ] = 0.000 +[V24 ] = 0.000 +[V25 ] = 0.000 +[V26 ] = 0.000 +[V27 ] = 0.000 +[V28 ] = 0.000 +[V32 ] = Force를 포함한 값 +[V33 ] = 0.000 +[V34 ] = 0.000 +[V35 ] = 0.000 +[V36 ] = 0.000 +[V37 ] = 0.000 +[V38 ] = 1.000 +[V39 ] = 0.000 +[V40 ] = 0.000 +[V41 ] = 0.000 +[V42 ] = 0.000 +[V43 ] = 0.000 +[V44 ] = 0.000 +[V45 ] = 0.000 +[V46 ] = 0.000 +[V50 ] = 출력 +[V51 ] = #Calib +[V54 ] = 연결 유형 1 +[V55 ] = 0.000 +[V56 ] = 연결 유형 2 +[V57 ] = 0.000 +[W4 ] = 총합 +[W5 ] = 18.000 +[W16 ] = Force를 포함한 값 +[W17 ] = 0.000 +[W18 ] = 0.000 +[W19 ] = 0.000 +[W20 ] = 0.000 +[W21 ] = 0.000 +[W22 ] = 0.000 +[W23 ] = 0.000 +[W24 ] = 0.000 +[W25 ] = 0.000 +[W26 ] = 0.000 +[W27 ] = 0.000 +[W28 ] = 0.000 +[W32 ] = 상태 +[W33 ] = 0.000 +[W34 ] = 0.000 +[W35 ] = 0.000 +[W36 ] = 0.000 +[W37 ] = 0.000 +[W38 ] = 1.000 +[W39 ] = 0.000 +[W40 ] = 0.000 +[W41 ] = 0.000 +[W42 ] = 0.000 +[W43 ] = 0.000 +[W44 ] = 0.000 +[W45 ] = 0.000 +[W46 ] = 0.000 +[W50 ] = 단위 +[W51 ] = pixels +[X1 ] = 0.000 +[X2 ] = 0.000 +[X3 ] = 0.000 +[X4 ] = 0.000 +[X5 ] = 1.000 +[X6 ] = 끝 +[X16 ] = 상태 +[X17 ] = 0.000 +[X18 ] = 0.000 +[X19 ] = 0.000 +[X20 ] = 0.000 +[X21 ] = 0.000 +[X22 ] = 0.000 +[X23 ] = 0.000 +[X24 ] = 0.000 +[X25 ] = 0.000 +[X26 ] = 0.000 +[X27 ] = 0.000 +[X28 ] = 0.000 +[X32 ] = 32.000 +[X33 ] = 0.000 +[X34 ] = 0.000 +[X35 ] = 0.000 +[X36 ] = 0.000 +[X37 ] = 0.000 +[X38 ] = 32.000 +[X39 ] = 0.000 +[X40 ] = 0.000 +[X41 ] = 0.000 +[X42 ] = 0.000 +[X43 ] = 0.000 +[X44 ] = 0.000 +[X45 ] = 0.000 +[X46 ] = 0.000 +[X53 ] = WriteFTPSettings +[X54 ] = 명령 1 +[X55 ] = gtwr +[X56 ] = 명령 2 +[X57 ] = gtwr +[Y16 ] = 0.000 +[Y17 ] = 6.02.01 (029) +[Y18 ] = 6.000 +[Y19 ] = 2.000 +[Y20 ] = 12.000 +[Y22 ] = -1.000 +[Y23 ] = 0.000 +[Y24 ] = #Event +[Y29 ] = 끝 +[Y32 ] = 32.000 +[Y33 ] = 6.02.01 (029) +[Y34 ] = 6.000 +[Y35 ] = 2.000 +[Y36 ] = 14.000 +[Y47 ] = 끝 +[Y51 ] = 끝 +[Y54 ] = 네트워크 오류 +[Y56 ] = #Event +[Y57 ] = 끝 +[Z72 ] = 끝 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..c79c9c8 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ +# gui 패키지 diff --git a/gui/dialogs/__init__.py b/gui/dialogs/__init__.py new file mode 100644 index 0000000..3ed9ffc --- /dev/null +++ b/gui/dialogs/__init__.py @@ -0,0 +1 @@ +# gui/dialogs 패키지 diff --git a/gui/dialogs/image_settings_dialog.py b/gui/dialogs/image_settings_dialog.py new file mode 100644 index 0000000..168acfa --- /dev/null +++ b/gui/dialogs/image_settings_dialog.py @@ -0,0 +1,245 @@ +# 이미지 설정 다이얼로그 — In-Sight 2000C Telnet 셀 직접 GV/SV +from PyQt5.QtWidgets import ( + QDialog, QFormLayout, QVBoxLayout, QHBoxLayout, + QPushButton, QDoubleSpinBox, QSpinBox, QMessageBox, QLabel, +) +from PyQt5.QtCore import Qt + + +# (레이블, 셀주소, 위젯종류, min, max, 기본값, 소수점자리) +_PARAMS = [ + ("노출 (밀리초)", "D3", "double", 0.01, 1000.0, 30.0, 3), + ("최대 노출 시간", "F3", "double", 0.01, 1000.0, 950.0, 3), + ("목표지 이미지 밝기", "G3", "double", 0.0, 255.0, 50.0, 1), + ("조명 강도", "D6", "int", 0, 100, 70, 0), + ("초점 위치", "D14", "int", 0, 999, 139, 0), +] + +# 스캔할 셀 범위 (A1~J20) +_SCAN_COLS = list("ABCDEFGHIJ") +_SCAN_ROWS = range(1, 21) + +_STYLE = """ +QDialog, QWidget { + background: #1a1a1a; + color: #ffffff; + font-size: 14px; +} +QLabel { color: #cccccc; } +QDoubleSpinBox, QSpinBox { + background: #2a2a2a; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 4px 8px; + min-height: 38px; +} +QDoubleSpinBox::up-button, QDoubleSpinBox::down-button, +QSpinBox::up-button, QSpinBox::down-button { + width: 20px; +} +QPushButton { + background: #2e2e2e; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + min-height: 56px; + padding: 0 24px; + font-size: 15px; +} +QPushButton:hover { background: #3a3a3a; } +QPushButton:pressed { background: #1e1e1e; } +QPushButton#scan { + min-height: 38px; + font-size: 13px; + color: #aaaaaa; +} +""" + + +class ImageSettingsDialog(QDialog): + def __init__(self, insight_cam, parent=None): + super().__init__(parent) + self._cam = insight_cam + self._widgets = {} # 셀주소 → 위젯 + self._originals = {} # 셀주소 → 로드 시 원본값 + + self.setWindowTitle("이미지 설정 — In-Sight 2000C") + self.setMinimumWidth(440) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.setStyleSheet(_STYLE) + + self._build_ui() + self._load_values() + + # ================================================================== # + # UI 구성 + # ================================================================== # + + def _build_ui(self): + form = QFormLayout() + form.setLabelAlignment(Qt.AlignRight) + form.setRowWrapPolicy(QFormLayout.DontWrapRows) + form.setHorizontalSpacing(20) + form.setVerticalSpacing(12) + + for label, cell, kind, lo, hi, default, decimals in _PARAMS: + if kind == "double": + w = QDoubleSpinBox() + w.setRange(lo, hi) + w.setDecimals(decimals) + w.setValue(default) + w.setSingleStep(0.1 if decimals > 0 else 1.0) + else: + w = QSpinBox() + w.setRange(int(lo), int(hi)) + w.setValue(int(default)) + + w.setMinimumWidth(140) + self._widgets[cell] = w + form.addRow(label, w) + + btn_scan = QPushButton("쓰기 가능한 셀 자동 탐지…") + btn_scan.setObjectName("scan") + btn_scan.clicked.connect(self._on_scan) + + btn_ok = QPushButton("확인") + btn_ok.setDefault(True) + btn_ok.clicked.connect(self._on_ok) + + btn_cancel = QPushButton("취소") + btn_cancel.clicked.connect(self.reject) + + btn_row = QHBoxLayout() + btn_row.addWidget(btn_scan) + btn_row.addStretch() + btn_row.addWidget(btn_ok) + btn_row.addWidget(btn_cancel) + + root = QVBoxLayout(self) + root.setContentsMargins(24, 20, 24, 20) + root.setSpacing(16) + root.addLayout(form) + root.addLayout(btn_row) + + # ================================================================== # + # 값 로드 — GV{cell}: 응답 "1" + 값 + # ================================================================== # + + def _load_values(self): + for _, cell, kind, _, _, _, _ in _PARAMS: + val = self._gv(cell) + print(f"[설정창] GV{cell} → {val!r}") + w = self._widgets[cell] + if val is not None: + w.setValue(val if kind == "double" else int(round(val))) + self._originals[cell] = val + else: + self._originals[cell] = None # 조회 실패 → 기본값 유지 + + def _gv(self, cell: str): + """GV{cell} 전송 → float 반환, 실패 시 None""" + try: + self._cam._send(f"GV{cell}") + status = self._cam._read_line() + if status != "1": + return None + return float(self._cam._read_line()) + except Exception: + return None + + # ================================================================== # + # 셀 자동 탐지 — GV로 읽히고 SV로 쓸 수 있는 셀 목록 + # ================================================================== # + + def _on_scan(self): + """A1~J20 범위를 스캔해 Online(쓰기 가능) 셀을 찾아 표시.""" + readable = [] # [(cell, value)] + writable = [] # [(cell, value)] + + for col in _SCAN_COLS: + for row in _SCAN_ROWS: + cell = f"{col}{row}" + val = self._gv(cell) + if val is None: + continue + readable.append((cell, val)) + ok, _ = self._sv(cell, val, "double") # 같은 값 재기입 → 실질적 변경 없음 + if ok: + writable.append((cell, val)) + + if writable: + lines = "\n".join(f" {c} = {v}" for c, v in writable) + msg = ( + f"쓰기 가능한 셀 {len(writable)}개 발견:\n\n" + f"{lines}\n\n" + f"image_settings_dialog.py 상단 _PARAMS의\n" + f"셀 주소를 위 주소로 변경하세요." + ) + elif readable: + lines = "\n".join(f" {c} = {v}" for c, v in readable[:20]) + msg = ( + f"읽기 전용 셀 {len(readable)}개 발견 (쓰기 가능 셀 없음):\n\n" + f"{lines}\n\n" + f"─────────────────────────────\n" + f"Cognex In-Sight Explorer에서\n" + f"제어하려는 셀을 우클릭 →\n" + f"[Cell Properties] → [Online] 체크 후\n" + f"job을 카메라에 업로드하세요.\n\n" + f"이후 이 버튼을 다시 누르면\n" + f"쓰기 가능한 셀 주소를 알 수 있습니다." + ) + else: + msg = ( + f"A1~J20 범위에서 읽을 수 있는 셀이 없습니다.\n\n" + f"카메라 연결 상태 또는 job 파일을 확인하세요." + ) + + QMessageBox.information(self, "셀 탐지 결과", msg) + + # ================================================================== # + # 확인 버튼 — 변경된 항목만 SV{cell} {value} + # ================================================================== # + + def _on_ok(self): + changes = [] + for _, cell, kind, _, _, _, _ in _PARAMS: + w = self._widgets[cell] + current = float(w.value()) + original = self._originals.get(cell) + if original is None or abs(current - original) >= 1e-9: + changes.append((cell, current, kind)) + + if not changes: + self.accept() + return + + errors = [] + for cell, value, kind in changes: + ok, resp = self._sv(cell, value, kind) + if not ok: + errors.append((cell, resp)) + + if errors: + detail = "\n".join(f" • {c} (카메라 응답: {r!r})" for c, r in errors) + QMessageBox.critical( + self, "설정 실패", + f"다음 셀 설정에 실패했습니다:\n{detail}\n\n" + f"'쓰기 가능한 셀 자동 탐지' 버튼으로\n" + f"현재 Online 셀 주소를 확인하세요.", + ) + return + + self.accept() + + def _sv(self, cell: str, value: float, kind: str): + """SV{cell} {value} 전송 → (성공여부, 응답코드 문자열)""" + try: + fmt = str(int(round(value))) if kind == "int" else f"{value:.6g}" + self._cam._send(f"SV{cell} {fmt}") + resp = self._cam._read_line() + print(f"[설정창] SV{cell} {fmt} → {resp!r}") + return resp.strip() == "1", resp.strip() + except Exception as e: + print(f"[설정창] SV{cell} 오류: {e}") + return False, str(e) diff --git a/gui/image_settings_dialog.py b/gui/image_settings_dialog.py new file mode 100644 index 0000000..c0389df --- /dev/null +++ b/gui/image_settings_dialog.py @@ -0,0 +1,195 @@ +from PyQt5.QtWidgets import ( + QDialog, QFormLayout, QVBoxLayout, QHBoxLayout, + QPushButton, QDoubleSpinBox, QSpinBox, QMessageBox, QLabel +) +from PyQt5.QtCore import Qt + + +# (레이블, 셀주소, 위젯종류, min, max, 기본값, 소수점자리) +# D3(노출)은 AcqExposure() 출력 셀 — 읽기 전용, 목록에서 제외 +_PARAMS = [ + ("최대 노출 시간", "F3", "double", 0.01, 1000.0, 950.0, 3), + ("목표 이미지 밝기", "G3", "double", 0.0, 255.0, 50.0, 1), + ("조명 강도", "D6", "int", 0, 100, 70, 0), + ("초점 위치", "D14", "int", 0, 999, 139, 0), +] + + +class ImageSettingsDialog(QDialog): + def __init__(self, insight_cam, parent=None): + super().__init__(parent) + self._cam = insight_cam + self.setWindowTitle("이미지 설정 — In-Sight 2000C") + self.setMinimumWidth(360) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + self._widgets = {} # 셀주소 → 위젯 + self._originals = {} # 셀주소 → 로드 시 원본값 + + self._build_ui() + self._load_values() + + # ------------------------------------------------------------------ # + # UI 구성 + # ------------------------------------------------------------------ # + + def _build_ui(self): + form = QFormLayout() + form.setLabelAlignment(Qt.AlignRight) + form.setRowWrapPolicy(QFormLayout.DontWrapRows) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(10) + + # 노출(D3)은 읽기 전용 표시 + self._exposure_label = QLabel("—") + self._exposure_label.setStyleSheet("color: gray;") + form.addRow("노출 (밀리초, 읽기 전용):", self._exposure_label) + + for label, cell, kind, lo, hi, default, decimals in _PARAMS: + if kind == "double": + w = QDoubleSpinBox() + w.setRange(lo, hi) + w.setDecimals(decimals) + w.setValue(default) + w.setSingleStep(0.1) + else: + w = QSpinBox() + w.setRange(int(lo), int(hi)) + w.setValue(int(default)) + + w.setMinimumWidth(120) + self._widgets[cell] = w + form.addRow(label, w) + + btn_ok = QPushButton("확인") + btn_ok.setDefault(True) + btn_ok.clicked.connect(self._on_ok) + + btn_cancel = QPushButton("취소") + btn_cancel.clicked.connect(self.reject) + + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_row.addWidget(btn_ok) + btn_row.addWidget(btn_cancel) + + root = QVBoxLayout(self) + root.addLayout(form) + root.addSpacing(8) + root.addLayout(btn_row) + + # ------------------------------------------------------------------ # + # 값 로드 — GV{cell} 명령, 응답 2줄(상태코드 + 값) + # ------------------------------------------------------------------ # + + def _load_values(self): + # D3: 읽기 전용 표시 + exp = self._gv("D3") + print(f"[설정창] GVD3 → {exp!r}") + if exp is not None: + self._exposure_label.setText(f"{exp:.3f} ms") + + for _, cell, kind, _, _, _, _ in _PARAMS: + val = self._gv(cell) + print(f"[설정창] GV{cell} → {val!r}") + w = self._widgets[cell] + if val is not None: + if kind == "double": + w.setValue(val) + else: + w.setValue(int(round(val))) + self._originals[cell] = val + else: + self._originals[cell] = None + + def _gv(self, cell: str): + """GV{cell} 전송 → 숫자 반환, 실패 시 None""" + try: + self._cam._send(f"GV{cell}") + status = self._cam._read_line() # "1" 또는 "0" + if status != "1": + print(f"[설정창] GV{cell} 상태코드: {status!r}") + return None + raw = self._cam._read_line() # 실제 값 + return float(raw) + except Exception as e: + print(f"[설정창] GV{cell} 오류: {e}") + return None + + # ------------------------------------------------------------------ # + # 확인 버튼 — SV{cell} {value} 명령 + # ------------------------------------------------------------------ # + + def _on_ok(self): + import time + + # 변경 항목 수집 + changes = [] + for _, cell, kind, _, _, _, _ in _PARAMS: + w = self._widgets[cell] + current = float(w.value()) + original = self._originals.get(cell) + if original is None or abs(current - original) >= 1e-9: + changes.append((cell, current, kind)) + + if not changes: + self.accept() + return + + # 오프라인 전환 + self._cam._send("SO0") + resp = self._cam._read_line() + print(f"[설정창] SO0 (오프라인) → {resp!r}") + if resp.strip() != "1": + QMessageBox.critical(self, "오류", f"오프라인 전환 실패 (응답: {resp!r})") + return + + time.sleep(0.3) # 카메라가 오프라인 전환 완료 대기 + + # 값 설정 + errors = [] + for cell, value, kind in changes: + if not self._sv(cell, value, kind): + errors.append(cell) + + time.sleep(0.2) # SV 처리 완료 대기 + + # 온라인 복귀 — 최대 3회 재시도 + online_ok = False + for attempt in range(3): + self._cam._send("SO1") + resp = self._cam._read_line() + print(f"[설정창] SO1 (온라인) 시도 {attempt+1} → {resp!r}") + if resp.strip() == "1": + online_ok = True + break + time.sleep(0.5) + + if not online_ok: + QMessageBox.warning( + self, "온라인 복귀 실패", + f"SO1 명령이 실패했습니다 (마지막 응답: {resp!r})\n" + "카메라를 수동으로 재시작하거나 In-Sight Explorer에서 Online으로 전환하세요." + ) + + if errors: + QMessageBox.critical( + self, "설정 실패", + f"다음 셀 설정 실패:\n{chr(10).join(errors)}\n\n" + "셀이 읽기 전용이거나 값 범위를 벗어났을 수 있습니다." + ) + return + + self.accept() + + def _sv(self, cell: str, value: float, kind: str) -> bool: + """SV{cell} {value} 전송 → 응답 '1'이면 True""" + try: + formatted = str(int(round(value))) if kind == "int" else f"{value:.6g}" + self._cam._send(f"SV{cell} {formatted}") + resp = self._cam._read_line() + print(f"[설정창] SV{cell} {formatted} → {resp!r}") + return resp.strip() == "1" + except Exception as e: + print(f"[설정창] SV{cell} 오류: {e}") + return False diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..100bb8a --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,361 @@ +# 메인 윈도우 — 1920x1080 전체화면, 다크 테마, 4탭 네비게이션 +from PyQt5.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QStackedWidget, QLabel, QSizePolicy, +) +from PyQt5.QtCore import Qt, QPoint, QRect, QElapsedTimer +from PyQt5.QtGui import QPainter, QPolygon, QColor, QFont + +from gui.pages.settings_page import SettingsPage +from gui.pages.register_page import RegisterPage +from gui.pages.inspect_page import InspectPage +from gui.pages.retrain_page import RetrainPage +from ai.detector import Detector +from logic.pattern_matcher import PatternMatcher +from db.sql_client import SQLClient + + +_DOT_OK = ("background:#22cc55; border-radius:7px;" + "min-width:14px; max-width:14px; min-height:14px; max-height:14px;") +_DOT_FAIL = ("background:#cc2222; border-radius:7px;" + "min-width:14px; max-width:14px; min-height:14px; max-height:14px;") + + +class ChevronTabButton(QPushButton): + # 우측이 ">" 화살표 모양인 breadcrumb/stepper 스타일 탭 버튼 + CHEVRON_W = 26 # 화살표 뾰족한 부분 너비(px) + + COLOR_ACTIVE_BG = QColor("#0055cc") + COLOR_ACTIVE_FG = QColor("#ffffff") + COLOR_HOVER_BG = QColor("#2e2e2e") + COLOR_HOVER_FG = QColor("#ffffff") + COLOR_IDLE_BG = QColor("#222222") + COLOR_IDLE_FG = QColor("#aaaaaa") + + def __init__(self, text: str, is_first: bool = False, + is_last: bool = False, parent=None): + super().__init__(text, parent) + self._is_first = is_first + self._is_last = is_last + self._active = False + self._hover = False + self.setMinimumHeight(60) + self.setCursor(Qt.PointingHandCursor) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFlat(True) + self.setAttribute(Qt.WA_Hover, True) + + def setActive(self, active: bool): + self._active = active + self.update() + + def enterEvent(self, event): + self._hover = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hover = False + self.update() + super().leaveEvent(event) + + def paintEvent(self, event): + if self._active: + bg, fg = self.COLOR_ACTIVE_BG, self.COLOR_ACTIVE_FG + elif self._hover: + bg, fg = self.COLOR_HOVER_BG, self.COLOR_HOVER_FG + else: + bg, fg = self.COLOR_IDLE_BG, self.COLOR_IDLE_FG + + w, h = self.width(), self.height() + cw = self.CHEVRON_W + left_cw = 0 if self._is_first else cw + right_cw = 0 if self._is_last else cw + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(bg) + + # 시계방향: 좌상 → 우상 → (우측 ▶) → 우하 → 좌하 → (좌측 V) + pts = [QPoint(0, 0)] + if self._is_last: + pts += [QPoint(w, 0), QPoint(w, h)] + else: + pts += [QPoint(w - cw, 0), QPoint(w, h // 2), QPoint(w - cw, h)] + pts.append(QPoint(0, h)) + if not self._is_first: + pts.append(QPoint(cw, h // 2)) + + p.drawPolygon(QPolygon(pts)) + + p.setPen(fg) + font = QFont(self.font()) + font.setPointSize(18) + font.setBold(self._active) + p.setFont(font) + text_rect = QRect(left_cw, 0, w - left_cw - right_cw, h) + p.drawText(text_rect, Qt.AlignCenter, self.text()) + + +class MainWindow(QMainWindow): + def __init__(self, insight_cam, basler_cam, config: dict, plc_client=None): + super().__init__() + self.insight = insight_cam + self.basler = basler_cam + self.config = config + self.detector = Detector() + self.matcher = PatternMatcher() + self.matcher.load() # 앱 시작 시 저장된 패턴 자동 로드 + self.db_client = SQLClient() + self.plc_client = plc_client + + self.setWindowTitle("비전 검사 시스템") + self.showFullScreen() + + # 재학습 탭 연속 클릭(창 최소화 단축) 감지용 + self._retrain_click_timer = QElapsedTimer() + self._retrain_click_timer.start() + + self._build_ui() + self._switch_tab(0) + self._auto_connect_db() + self.update_connection_status() + + # ================================================================== # + # UI 구성 + # ================================================================== # + + def _build_ui(self): + root = QWidget() + self.setCentralWidget(root) + layout = QVBoxLayout(root) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addWidget(self._build_tab_bar()) + layout.addWidget(self._build_stack(), stretch=1) + layout.addWidget(self._build_status_bar()) + + def _build_tab_bar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(60) + # 화살표 모양 사이의 빈 삼각형 영역에서 보일 배경 + bar.setStyleSheet("background:#1a1a1a;") + row = QHBoxLayout(bar) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(0) + + labels = ["환경설정", "제품 등록", "검사", "재학습"] + self._tab_btns = [] + for i, label in enumerate(labels): + btn = ChevronTabButton( + label, + is_first=(i == 0), + is_last=(i == len(labels) - 1), + ) + btn.clicked.connect(lambda _, idx=i: self._switch_tab(idx)) + self._tab_btns.append(btn) + row.addWidget(btn) + return bar + + def _build_stack(self) -> QStackedWidget: + self._stack = QStackedWidget() + + # belt_delay = 거리 / 속도 (config 기본값 사용) + _conv = self.config.get("conveyor", {}) + _dist = _conv.get("distance_cm", 100.0) + _speed = _conv.get("speed_cms", 30.0) + _belt_delay = _dist / _speed if _speed > 0 else 3.33 + + self._settings_page = SettingsPage( + self.insight, self.basler, self.config, + detector=self.detector, + update_insight_cb=self.update_insight, + update_basler_cb=self.update_basler, + update_detector_cb=self.update_detector, + update_db_cb=self.update_db, + update_plc_cb=self.update_plc, + plc_client=self.plc_client, + ) + self._settings_page.cognex_status_changed.connect( + lambda _: self.update_connection_status() + ) + self._settings_page.basler_status_changed.connect( + lambda _: self.update_connection_status() + ) + self._settings_page.plc_status_changed.connect( + lambda _: self.update_connection_status() + ) + + self._register_page = RegisterPage( + self.insight, matcher=self.matcher, db_client=self.db_client + ) + self._inspect_page = InspectPage( + self.insight, self.basler, + detector=self.detector, + belt_delay=_belt_delay, + ) + self._inspect_page.update_matcher(self.matcher) + self._settings_page.belt_settings_changed.connect( + self._inspect_page.update_belt_delay + ) + + self._pages = [ + self._settings_page, + self._register_page, + self._inspect_page, + RetrainPage(), + ] + for page in self._pages: + self._stack.addWidget(page) + return self._stack + + def _build_status_bar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(36) + bar.setStyleSheet("background:#111111;") + row = QHBoxLayout(bar) + row.setContentsMargins(16, 0, 16, 0) + row.setSpacing(20) + + self._dot_cognex = self._make_dot(self.insight.is_connected()) + self._dot_basler = self._make_dot( + self.basler.is_connected() if self.basler else False + ) + self._dot_db = self._make_dot(False) + self._dot_plc = self._make_dot( + bool(self.plc_client and self.plc_client.is_connected()) + ) + + self._lbl_cognex = QLabel("코그넥스") + self._lbl_basler = QLabel("Basler") + self._lbl_db = QLabel("DB") + self._lbl_plc = QLabel("PLC") + for lbl in (self._lbl_cognex, self._lbl_basler, self._lbl_db, self._lbl_plc): + lbl.setStyleSheet("color:#aaaaaa; font-size:13px;") + + row.addWidget(self._dot_cognex) + row.addWidget(self._lbl_cognex) + row.addWidget(self._dot_basler) + row.addWidget(self._lbl_basler) + row.addWidget(self._dot_db) + row.addWidget(self._lbl_db) + row.addWidget(self._dot_plc) + row.addWidget(self._lbl_plc) + row.addStretch() + return bar + + @staticmethod + def _make_dot(connected: bool) -> QLabel: + dot = QLabel() + dot.setStyleSheet(_DOT_OK if connected else _DOT_FAIL) + return dot + + # ================================================================== # + # 탭 전환 + # ================================================================== # + + def _switch_tab(self, idx: int): + # 재학습 탭(3)을 600ms 이내에 연속으로 두 번 클릭하면 창 최소화 + if idx == 3: + if (self._stack.currentIndex() == 3 + and self._retrain_click_timer.elapsed() < 600): + self.showMinimized() + self._retrain_click_timer.restart() + return + self._retrain_click_timer.restart() + + self._stack.setCurrentIndex(idx) + for i, btn in enumerate(self._tab_btns): + btn.setActive(i == idx) + if idx == 0: + self._settings_page._sync_connection_status() + self.update_connection_status() + + # ================================================================== # + # 코그넥스 인스턴스 교체 (SettingsPage 연결 성공 시 호출) + # ================================================================== # + + def update_insight(self, new_insight): + self.insight = new_insight + self._register_page._insight = new_insight + self._inspect_page.update_insight(new_insight) + + def update_basler(self, new_basler): + self.basler = new_basler + self._inspect_page.update_basler(new_basler) + + def update_detector(self, new_detector): + self.detector = new_detector + if hasattr(self, "_inspect_page"): + self._inspect_page.update_detector(new_detector) + + def update_db(self, db_client): + self.db_client = db_client + connected = db_client is not None and db_client.is_connected() + if hasattr(self, "_register_page"): + self._register_page.update_db(db_client) + if hasattr(self, "_settings_page"): + self._settings_page._db_client = db_client + self._settings_page._set_db_connected(connected) + self.update_connection_status() + + def update_plc(self, plc_client): + self.plc_client = plc_client + connected = plc_client is not None and plc_client.is_connected() + if hasattr(self, "_settings_page"): + self._settings_page._plc_client = plc_client + self._settings_page._set_plc_connected(connected) + self.update_connection_status() + + def _auto_connect_db(self): + """앱 시작 시 config.json DB 접속 정보로 자동 연결 시도.""" + db_cfg = self.config.get("db", {}) + server = db_cfg.get("server", "").strip() + database = db_cfg.get("database", "").strip() + username = db_cfg.get("username", "").strip() + password = db_cfg.get("password", "") + if not server or not database: + return + ok = self.db_client.connect(server, database, username, password) + if ok: + self.update_db(self.db_client) + + # ================================================================== # + # 실제 연결 상태를 읽어 상태바 전체 갱신 + # ================================================================== # + + def update_connection_status(self): + cognex_ok = bool(self.insight and self.insight.is_connected()) + self._dot_cognex.setStyleSheet(_DOT_OK if cognex_ok else _DOT_FAIL) + self._lbl_cognex.setText("코그넥스 연결됨" if cognex_ok else "코그넥스 연결 안됨") + self._lbl_cognex.setStyleSheet( + "color:#1D9E75; font-size:13px; font-weight:bold;" if cognex_ok + else "color:#aaaaaa; font-size:13px;" + ) + + basler_ok = bool(self.basler and self.basler.is_connected()) + self._dot_basler.setStyleSheet(_DOT_OK if basler_ok else _DOT_FAIL) + self._lbl_basler.setText("Basler 연결됨" if basler_ok else "Basler 연결 안됨") + self._lbl_basler.setStyleSheet( + "color:#1D9E75; font-size:13px; font-weight:bold;" if basler_ok + else "color:#aaaaaa; font-size:13px;" + ) + + db_ok = bool(self.db_client and self.db_client.is_connected()) + self._dot_db.setStyleSheet(_DOT_OK if db_ok else _DOT_FAIL) + self._lbl_db.setText("DB 연결됨" if db_ok else "DB 연결 안됨") + self._lbl_db.setStyleSheet( + "color:#1D9E75; font-size:13px; font-weight:bold;" if db_ok + else "color:#aaaaaa; font-size:13px;" + ) + + plc_ok = bool(self.plc_client and self.plc_client.is_connected()) + self._dot_plc.setStyleSheet(_DOT_OK if plc_ok else _DOT_FAIL) + self._lbl_plc.setText("PLC 연결됨" if plc_ok else "PLC 연결 안됨") + self._lbl_plc.setStyleSheet( + "color:#1D9E75; font-size:13px; font-weight:bold;" if plc_ok + else "color:#aaaaaa; font-size:13px;" + ) + diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py new file mode 100644 index 0000000..c482cf2 --- /dev/null +++ b/gui/pages/__init__.py @@ -0,0 +1 @@ +# gui/pages 패키지 diff --git a/gui/pages/inspect_page.py b/gui/pages/inspect_page.py new file mode 100644 index 0000000..ceaf0b6 --- /dev/null +++ b/gui/pages/inspect_page.py @@ -0,0 +1,726 @@ +# 검사 페이지 — 코그넥스/Basler 영상, 그룹 A/B 설정, Pass/Fail 표시 +import time +import threading +import itertools +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize +from PyQt5.QtGui import QImage, QPixmap +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QGroupBox, + QPushButton, QLabel, QCheckBox, QFrame, + QGridLayout, QSizePolicy, +) + +from logic.inspector import Inspector +from logic.group_manager import GroupManager +from logger import log_inspect_result, log_camera_timing, log_action, log_defect_image + +_DEFECT_COLORS = { + "스크래치": (0, 0, 255), + "이물": (0, 165, 255), + "흑점": (128, 0, 128), + "변형": (255, 165, 0 ), +} + + +def _draw_detections(frame: np.ndarray, detections: list) -> np.ndarray: + """frame 복사본에 BBox 오버레이를 그려 반환.""" + img = frame.copy() + for det in detections: + x1, y1, x2, y2 = [int(v) for v in det["bbox"]] + name = det["class_name"] + conf = det["confidence"] + color = _DEFECT_COLORS.get(name, (0, 255, 0)) + cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) + cv2.putText( + img, f"{name} {conf:.0%}", + (x1, max(y1 - 8, 0)), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2, + ) + return img + +# 검사 그룹 A/B 선택용 모델 목록 +_MODELS = [ + "LOW REF / LX3 / RH", + "LOW REF / LX3 / LH", + "LOW REF NAS / LX3 / RH", + "LOW REF NAS / LX3 / LH", + "LOW REF NAS / MX5a 2.0TH / RH", + "LOW REF NAS / MX5a 2.0TH / LH", + "HIGH REF / LX3 / RH", + "HIGH REF / LX3 / LH", + "LOW REF NAS 1.5 GEN / CN7 PE / RH", + "LOW REF DOM 1.5 GEN / CN7 PE / LH", +] + +_MODEL_ID_MAP = { + "LOW REF / LX3 / RH": 1, + "LOW REF / LX3 / LH": 2, + "LOW REF NAS / LX3 / RH": 3, + "LOW REF NAS / LX3 / LH": 4, + "LOW REF NAS / MX5a 2.0TH / RH": 5, + "LOW REF NAS / MX5a 2.0TH / LH": 6, + "HIGH REF / LX3 / RH": 7, + "HIGH REF / LX3 / LH": 8, + "LOW REF NAS 1.5 GEN / CN7 PE / RH": 9, + "LOW REF DOM 1.5 GEN / CN7 PE / LH": 10, +} + + +# ================================================================== # +# 백그라운드 워커 — 파이프라인 방식 +# +# [Cognex 서브스레드] trigger → sleep(1.0) → FTP(영구세션) → PatMax +# ↕ 병렬 ↕ join +# [워커 메인] sleep(belt_delay) → Basler 캡처 → 판정 → emit +# +# belt_delay = 카메라 간 거리(cm) / 벨트 속도(cm/s) +# 두 작업이 동시에 시작되어 같은 제품을 각 위치에서 촬영함. +# ================================================================== # + +class InspectWorker(QThread): + cognex_image_ready = pyqtSignal(bytes) # raw BMP/JPG 바이트 + basler_image_ready = pyqtSignal(object, list) # (ndarray, detections) + result_ready = pyqtSignal(dict) + + def __init__(self, insight, basler, inspector, groups, + belt_delay: float = 3.33, parent=None): + super().__init__(parent) + self._insight = insight + self._basler = basler + self._inspector = inspector + self._groups = groups + self.detector = None + self.matcher = None # PatternMatcher — InspectPage에서 주입 + self._belt_delay = belt_delay + self._stop_flag = False + self._pause_flag = False + self._seq = itertools.count(1) + + # ── 외부 제어 ──────────────────────────────────────────────────── # + + def stop(self): + self._stop_flag = True + + def pause(self): + self._pause_flag = True + + def resume(self): + self._pause_flag = False + + def set_belt_delay(self, delay: float): + self._belt_delay = delay + + # ── 스레드 본체 ────────────────────────────────────────────────── # + + def run(self): + self._stop_flag = False + self._pause_flag = False + while not self._stop_flag: + if self._pause_flag: + time.sleep(0.1) + continue + self._do_one_cycle() + + # ── 검사 1사이클 ───────────────────────────────────────────────── # + + def _do_one_cycle(self): + group = self._groups.get_active_name() + seq = next(self._seq) + trigger_time = time.perf_counter() + + def _ms() -> float: + return (time.perf_counter() - trigger_time) * 1000 + + log_camera_timing(seq, "cycle_start", 0.0, f"group={group} belt_delay={self._belt_delay:.2f}s") + + # ── Cognex 작업: 서브 스레드에서 병렬 실행 ── + cognex_out: dict = {} + + def _cognex_work(): + try: + log_camera_timing(seq, "cognex_trigger_send", _ms()) + ok = self._insight.software_trigger() + log_camera_timing(seq, f"cognex_trigger_{'ok' if ok else 'fail'}", _ms()) + if not ok: + cognex_out["error"] = "trigger_failed" + return + time.sleep(1.0) + log_camera_timing(seq, "cognex_ftp_start", _ms()) + raw = self._insight.get_image() + log_camera_timing( + seq, "cognex_ftp_done", _ms(), + f"{len(raw)}bytes" if raw else "empty", + ) + if raw: + self.cognex_image_ready.emit(raw) + log_camera_timing(seq, "cognex_patmax_start", _ms()) + # Cognex job 파일 결과 (항상) + gv_results = self._inspector.read_patmax_results(self._insight) + # Python ORB 결과 (추가 등록 제품, 있을 때만) + py_results = {} + if self.matcher and self.matcher.registered_ids and raw: + py_results = self._inspector.match_image(raw, self.matcher) + cognex_out["results"] = {**gv_results, **py_results} + log_camera_timing(seq, "cognex_patmax_done", _ms()) + except Exception as e: + print(f"[워커] Cognex 서브스레드 오류: {e}") + cognex_out["error"] = str(e) + log_camera_timing(seq, "cognex_error", _ms(), str(e)) + + ct = threading.Thread(target=_cognex_work, daemon=True) + ct.start() + + # ── Basler: trigger 시점 기준 belt_delay 후 캡처 ── + elapsed = time.perf_counter() - trigger_time + remaining = self._belt_delay - elapsed + if remaining > 0: + time.sleep(remaining) + + basler_pass = True + basler_detections = [] + try: + log_camera_timing(seq, "basler_capture_start", _ms()) + frame = self._basler.capture() + log_camera_timing( + seq, "basler_capture_done", _ms(), + f"{frame.shape}" if frame is not None else "failed", + ) + if frame is not None: + if self.detector and self.detector.is_loaded(): + detections = self.detector.detect(frame) + defects = [d for d in detections if d["confidence"] >= 0.5] + basler_pass = len(defects) == 0 + basler_detections = defects + if defects: + print(f"[워커] 불량 감지: {[d['class_name'] for d in defects]}") + annotated = _draw_detections(frame, defects) + log_defect_image(annotated, defects) + self.basler_image_ready.emit(frame, basler_detections) + except Exception as e: + print(f"[워커 오류] Basler: {e}") + log_camera_timing(seq, "basler_error", _ms(), str(e)) + + # ── Cognex 서브스레드 완료 대기 ── + log_camera_timing(seq, "cognex_join_wait", _ms()) + ct.join(timeout=10.0) + log_camera_timing(seq, "cognex_join_done", _ms()) + + # ── 모델 판별 ── + results = cognex_out.get("results", {}) + active_names = self._groups.get_active_group() + allowed_ids = [_MODEL_ID_MAP[n] for n in active_names if n in _MODEL_ID_MAP] + result_info = { + "matched": False, "in_allowed": False, + "model": None, "score": 0.0, + "cognex_pass": False, "status": "인식 불가", + } + try: + if results: + result_info = self._inspector.identify_model(results, allowed_ids) + except Exception as e: + print(f"[워커 오류] 모델 판별: {e}") + + cognex_pass = result_info["cognex_pass"] + matched = result_info["matched"] + final = self._inspector.judge(cognex_pass, basler_pass) + + self.result_ready.emit({ + "group": group, + "matched": matched, + "result": final, + "cognex_pass": cognex_pass, + "basler_pass": basler_pass, + "result_info": result_info, + }) + + log_camera_timing( + seq, "cycle_done", _ms(), + f"result={final} cognex={'PASS' if cognex_pass else 'FAIL'} basler={'PASS' if basler_pass else 'FAIL'}", + ) + + try: + log_inspect_result( + group=group, + result=("UNKNOWN" if not matched else final), + cognex_pass=cognex_pass, + basler_pass=basler_pass, + detected=[result_info["model"]] if result_info.get("model") else None, + ) + except Exception as e: + print(f"[워커 오류] 로그 기록: {e}") + + +# ================================================================== # +# 검사 페이지 +# ================================================================== # + +class InspectPage(QWidget): + def __init__(self, insight_cam, basler_cam, detector=None, + belt_delay: float = 3.33, parent=None): + super().__init__(parent) + self._insight = insight_cam + self._basler = basler_cam + self.detector = detector + self._inspector = Inspector() + self._groups = GroupManager() + + self._counts = { + "A": {"total": 0, "pass": 0, "fail": 0, "unknown": 0}, + "B": {"total": 0, "pass": 0, "fail": 0, "unknown": 0}, + } + + self._matcher = None + + self._worker = InspectWorker( + self._insight, self._basler, self._inspector, self._groups, + belt_delay=belt_delay, + ) + self._worker.detector = self.detector + self._worker.matcher = self._matcher + self._worker.cognex_image_ready.connect(self._display_cognex_image) + self._worker.basler_image_ready.connect(self._on_basler_ready) + self._worker.result_ready.connect(self._on_result) + + self._build_ui() + + # ================================================================== # + # 최상위 레이아웃 + # ================================================================== # + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self._build_top(), stretch=7) + root.addWidget(self._build_bottom(), stretch=3) + + # ================================================================== # + # 상단: 코그넥스 (좌 50 %) / Basler (우 50 %) + # ================================================================== # + + def _build_top(self) -> QWidget: + w = QWidget() + w.setStyleSheet("background:#0d0d0d;") + layout = QHBoxLayout(w) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addLayout(self._build_cam_col("■ In-Sight 2000C", "#4488ff", "_cognex_label"), stretch=1) + layout.addWidget(_vline()) + layout.addLayout(self._build_cam_col("■ Basler USB", "#44cc88", "_basler_label"), stretch=1) + return w + + def _build_cam_col(self, title: str, color: str, attr: str) -> QVBoxLayout: + col = QVBoxLayout() + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(0) + + title_lbl = QLabel(title) + title_lbl.setFixedHeight(32) + title_lbl.setStyleSheet( + f"color:{color}; font-size:15px; font-weight:bold;" + "padding-left:10px; background:#111111;" + ) + + img_lbl = QLabel() + img_lbl.setAlignment(Qt.AlignCenter) + img_lbl.setStyleSheet("background:#0d0d0d;") + img_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + img_lbl.setMinimumSize(640, 480) + img_lbl.setScaledContents(False) + setattr(self, attr, img_lbl) + + col.addWidget(title_lbl) + col.addWidget(img_lbl, stretch=1) + return col + + # ================================================================== # + # 하단: 3열 + # ================================================================== # + + def _build_bottom(self) -> QWidget: + w = QWidget() + w.setStyleSheet("background:#1a1a1a;") + layout = QHBoxLayout(w) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(0) + + layout.addWidget(self._build_col_groups(), stretch=5) + layout.addWidget(_vline()) + layout.addWidget(self._build_col_controls(), stretch=3) + layout.addWidget(_vline()) + layout.addWidget(self._build_col_counters(), stretch=4) + return w + + def _build_col_groups(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(4, 4, 8, 4) + layout.setSpacing(6) + + checks_row = QHBoxLayout() + checks_row.setSpacing(8) + checks_row.addWidget(self._build_group_section("A"), stretch=1) + checks_row.addWidget(self._build_group_section("B"), stretch=1) + layout.addLayout(checks_row, stretch=1) + + self._switch_btn = QPushButton("현재: 그룹 A 활성 → B로 전환") + self._switch_btn.setFixedHeight(56) + self._switch_btn.setStyleSheet( + "background:#1a3a5c; color:#ffffff; border:none; border-radius:4px;" + "font-size:14px; font-weight:bold;" + ) + self._switch_btn.clicked.connect(self._on_switch) + layout.addWidget(self._switch_btn) + return w + + def _build_group_section(self, name: str) -> QGroupBox: + active_color = "#4488ff" if name == "A" else "#cc8844" + g = QGroupBox(f"그룹 {name} (최대 4종)") + g.setStyleSheet( + f"QGroupBox {{ background:#222222; border:1px solid #333333; border-radius:6px;" + f" margin-top:12px; padding:6px 4px 4px 4px; }}" + f"QGroupBox::title {{ color:{active_color}; subcontrol-origin:margin;" + f" left:8px; font-size:13px; font-weight:bold; }}" + ) + layout = QVBoxLayout(g) + layout.setSpacing(1) + layout.setContentsMargins(4, 2, 4, 2) + + checks = [] + for model in _MODELS: + cb = QCheckBox(model) + cb.setStyleSheet( + "QCheckBox { font-size:12px; min-height:24px; color:#cccccc; }" + "QCheckBox::indicator { width:16px; height:16px; }" + ) + checks.append(cb) + layout.addWidget(cb) + + if name == "A": + self._group_a_checks = checks + for cb in checks: + cb.clicked.connect(lambda checked, c=cb: self._on_group_changed("A", c, checked)) + else: + self._group_b_checks = checks + for cb in checks: + cb.clicked.connect(lambda checked, c=cb: self._on_group_changed("B", c, checked)) + + return g + + def _build_col_controls(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 4, 12, 4) + layout.setSpacing(6) + + self._start_btn = QPushButton("검사 시작") + self._start_btn.setFixedHeight(70) + self._start_btn.setStyleSheet( + "background:#1a5c1a; color:#ffffff; border:none; border-radius:4px;" + "font-size:18px; font-weight:bold;" + ) + self._start_btn.clicked.connect(self._on_start) + + self._pause_btn = QPushButton("일시 정지") + self._pause_btn.setFixedHeight(70) + self._pause_btn.setEnabled(False) + self._pause_btn.setStyleSheet( + "background:#5c5500; color:#ffffff; border:none; border-radius:4px;" + "font-size:18px; font-weight:bold;" + ) + self._pause_btn.clicked.connect(self._on_pause) + + self._active_lbl = QLabel("활성 그룹: A") + self._active_lbl.setAlignment(Qt.AlignCenter) + self._active_lbl.setStyleSheet("font-size:14px; color:#aaaaaa;") + + self._model_lbl = QLabel("인식 모델: —") + self._model_lbl.setAlignment(Qt.AlignCenter) + self._model_lbl.setWordWrap(True) + self._model_lbl.setStyleSheet("font-size:13px; color:#cccccc;") + + self._belt_lbl = QLabel( + f"벨트 딜레이: {self._worker._belt_delay:.2f}s" + ) + self._belt_lbl.setAlignment(Qt.AlignCenter) + self._belt_lbl.setStyleSheet("font-size:12px; color:#666666;") + + self._result_lbl = QLabel("대기 중") + self._result_lbl.setAlignment(Qt.AlignCenter) + self._result_lbl.setStyleSheet( + "font-size:48px; font-weight:bold; background:#2a2a2a;" + "color:#666666; border-radius:8px;" + ) + self._result_lbl.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + layout.addWidget(self._start_btn) + layout.addWidget(self._pause_btn) + layout.addWidget(self._active_lbl) + layout.addWidget(self._model_lbl) + layout.addWidget(self._belt_lbl) + layout.addWidget(self._result_lbl, stretch=1) + return w + + def _build_col_counters(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 4, 8, 4) + layout.setSpacing(6) + + grid = QGridLayout() + grid.setSpacing(4) + + for col, text in enumerate(["", "그룹 A", "그룹 B"]): + lbl = QLabel(text) + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet("font-size:13px; color:#888888; font-weight:bold;") + grid.addWidget(lbl, 0, col) + + row_defs = [ + ("전체", "total", "#ffffff"), + ("양품", "pass", "#22cc55"), + ("불량", "fail", "#cc2222"), + ("미인식", "unknown", "#ff9900"), + ] + self._cnt_lbls = {} + for r, (display, key, color) in enumerate(row_defs, start=1): + row_lbl = QLabel(display) + row_lbl.setStyleSheet(f"font-size:14px; color:{color}; font-weight:bold;") + grid.addWidget(row_lbl, r, 0) + + self._cnt_lbls[key] = {} + for g_col, group in enumerate(["A", "B"], start=1): + lbl = QLabel("0") + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet( + f"font-size:36px; font-weight:bold; color:{color};" + "background:#222222; border-radius:4px; padding:2px 6px;" + ) + grid.addWidget(lbl, r, g_col) + self._cnt_lbls[key][group] = lbl + + layout.addLayout(grid) + + btn_reset = QPushButton("카운터 초기화") + btn_reset.setFixedHeight(56) + btn_reset.setStyleSheet( + "background:#3a1a1a; color:#ffffff; border:none; border-radius:4px; font-size:14px;" + ) + btn_reset.clicked.connect(self._on_reset) + layout.addWidget(btn_reset) + layout.addStretch() + return w + + # ================================================================== # + # 슬롯 — UI 이벤트 + # ================================================================== # + + def _on_group_changed(self, group: str, changed_cb: QCheckBox, is_checked: bool): + checks = self._group_a_checks if group == "A" else self._group_b_checks + if is_checked and sum(1 for c in checks if c.isChecked()) > GroupManager.MAX_PER_GROUP: + changed_cb.setChecked(False) + return + models = [c.text() for c in checks if c.isChecked()] + if group == "A": + self._groups.set_group_a(models) + else: + self._groups.set_group_b(models) + + def _on_switch(self): + active = self._groups.switch_group() + other = "B" if active == "A" else "A" + self._switch_btn.setText(f"현재: 그룹 {active} 활성 → {other}로 전환") + self._active_lbl.setText(f"활성 그룹: {active}") + print(f"[검사] 그룹 전환 → 활성 그룹 {active}") + + def _on_start(self): + if self._worker.isRunning(): + return + log_action("[검사] 검사 시작") + self._start_btn.setEnabled(False) + self._pause_btn.setEnabled(True) + self._pause_btn.setText("일시 정지") + self._worker.start() + + def _on_pause(self): + if self._worker.isRunning() and not self._worker._pause_flag: + self._worker.pause() + self._pause_btn.setText("재개") + self._start_btn.setEnabled(True) + log_action("[검사] 일시 정지") + else: + self._worker.resume() + self._pause_btn.setText("일시 정지") + self._start_btn.setEnabled(False) + log_action("[검사] 검사 재개") + + def _on_reset(self): + log_action("[검사] 카운트 리셋") + for key in ("total", "pass", "fail", "unknown"): + for g in ("A", "B"): + self._counts[g][key] = 0 + self._cnt_lbls[key][g].setText("0") + + def closeEvent(self, event): + self._worker.stop() + self._worker.wait(3000) + super().closeEvent(event) + + # ================================================================== # + # 외부에서 호출하는 업데이트 메서드 + # ================================================================== # + + def update_detector(self, detector): + self.detector = detector + self._worker.detector = detector + print(f"[검사] AI 모델 업데이트: {detector.model_path if detector else None}") + + def update_matcher(self, matcher): + self._matcher = matcher + self._worker.matcher = matcher + n = len(matcher.registered_ids) if matcher else 0 + print(f"[검사] PatternMatcher 업데이트: {n}개 패턴") + + def update_insight(self, new_insight): + """카메라 재연결 시 워커에도 반영.""" + self._insight = new_insight + self._worker._insight = new_insight + + def update_basler(self, new_basler): + self._basler = new_basler + self._worker._basler = new_basler + + def update_belt_delay(self, delay: float): + """설정 페이지에서 컨베이어 값 변경 시 호출.""" + self._worker.set_belt_delay(delay) + self._belt_lbl.setText(f"벨트 딜레이: {delay:.2f}s") + + # ================================================================== # + # 워커 signal 슬롯 (메인 스레드) + # ================================================================== # + + def _on_basler_ready(self, frame, detections): + self._display_basler_image(frame, detections=detections) + + def _on_result(self, data: dict): + group = data["group"] + matched = data["matched"] + result = data["result"] + cognex_pass = data["cognex_pass"] + basler_pass = data["basler_pass"] + result_info = data["result_info"] + + self._model_lbl.setText(result_info["status"]) + + self._counts[group]["total"] += 1 + if not matched: + self._counts[group]["unknown"] += 1 + elif result == "PASS": + self._counts[group]["pass"] += 1 + else: + self._counts[group]["fail"] += 1 + for key in ("total", "pass", "fail", "unknown"): + self._cnt_lbls[key][group].setText(str(self._counts[group][key])) + + if not matched: + self._set_result("미인식", "#332200", "#ff9900") + elif result == "PASS": + self._set_result("PASS", "#003300", "#22ff55") + else: + self._set_result("FAIL", "#330000", "#ff2222") + + # ================================================================== # + # 이미지 표시 헬퍼 + # ================================================================== # + + def _set_result(self, text: str, bg: str, fg: str): + self._result_lbl.setText(text) + self._result_lbl.setStyleSheet( + f"font-size:48px; font-weight:bold; background:{bg};" + f"color:{fg}; border-radius:8px;" + ) + + def _display_cognex_image(self, raw_data: bytes): + try: + arr = np.frombuffer(raw_data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + print("[코그넥스] 이미지 디코딩 실패") + return + + if len(img.shape) == 2: + h, w = img.shape + qimg = QImage(img.data.tobytes(), w, h, w, QImage.Format_Grayscale8) + else: + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape + qimg = QImage(rgb.data.tobytes(), w, h, w * ch, QImage.Format_RGB888) + + self._cognex_label.setPixmap( + QPixmap.fromImage(qimg).scaled( + self._cognex_label.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + except Exception as e: + print(f"[코그넥스] 이미지 표시 오류: {e}") + + def _display_basler_image(self, frame, detections=None): + try: + img = _draw_detections(frame, detections or []) + + if len(img.shape) == 2: + h, w = img.shape + qimg = QImage(img.data.tobytes(), w, h, w, QImage.Format_Grayscale8) + else: + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape + qimg = QImage(rgb.data.tobytes(), w, h, w * ch, QImage.Format_RGB888) + + self._basler_label.setPixmap( + QPixmap.fromImage(qimg).scaled( + self._basler_label.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + except Exception as e: + print(f"[Basler] 이미지 표시 오류: {e}") + + +# ================================================================== # +# 모듈 수준 유틸리티 +# ================================================================== # + +def _vline() -> QFrame: + f = QFrame() + f.setFrameShape(QFrame.VLine) + f.setFixedWidth(2) + f.setStyleSheet("background:#333333; border:none;") + return f + + +def _raw_to_pixmap(raw: bytes, size: QSize) -> "QPixmap | None": + arr = np.frombuffer(raw, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + return None + return _ndarray_to_pixmap(img, size) + + +def _ndarray_to_pixmap(img: np.ndarray, size: QSize) -> "QPixmap | None": + if img.ndim == 2: + img_c = np.ascontiguousarray(img) + h, w = img_c.shape + qimg = QImage(img_c.data, w, h, w, QImage.Format_Grayscale8) + else: + rgb = np.ascontiguousarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) + h, w, ch = rgb.shape + qimg = QImage(rgb.data, w, h, w * ch, QImage.Format_RGB888) + return QPixmap.fromImage(qimg).scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) diff --git a/gui/pages/register_page.py b/gui/pages/register_page.py new file mode 100644 index 0000000..6ebbda6 --- /dev/null +++ b/gui/pages/register_page.py @@ -0,0 +1,297 @@ +# 제품 등록 페이지 — 기준 이미지 캡처 +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtGui import QImage, QPixmap +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QGroupBox, + QPushButton, QListWidget, QListWidgetItem, QLabel, + QMessageBox, QScrollArea, QFrame, +) + + +_GRP_STYLE = ( + "QGroupBox {" + " background:#222222; border:1px solid #333333; border-radius:6px;" + " margin-top:14px; padding:14px 12px 12px 12px;" + "}" + "QGroupBox::title { color:#aaaaaa; subcontrol-origin:margin; left:10px; padding:0 4px; }" +) + + +class RegisterPage(QWidget): + def __init__(self, insight_cam, matcher=None, db_client=None, parent=None): + super().__init__(parent) + self._insight = insight_cam + self._db_client = db_client + self._db_items = [] + self._selected = None + self._captured_img = None + + self._build_ui() + + # ================================================================== # + # UI 구성 + # ================================================================== # + + def _build_ui(self): + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self._build_left_panel(), stretch=2) + root.addWidget(self._build_right_panel(), stretch=3) + + def _build_left_panel(self) -> QWidget: + w = QWidget() + w.setStyleSheet("background:#1a1a1a;") + layout = QVBoxLayout(w) + layout.setContentsMargins(16, 16, 8, 16) + layout.setSpacing(10) + + self._btn_mes = QPushButton("MES 불러오기") + self._btn_mes.setEnabled(False) + self._btn_mes.setFixedHeight(56) + self._btn_mes.setToolTip("DB 연결 후 사용 가능") + self._btn_mes.setStyleSheet( + "background:#444444; color:#777777; border:none; border-radius:4px; font-size:15px;" + ) + self._btn_mes.clicked.connect(self._on_load_from_db) + layout.addWidget(self._btn_mes) + + lbl = QLabel("제품 목록") + lbl.setStyleSheet("font-size:13px; color:#777777;") + layout.addWidget(lbl) + + self._list = QListWidget() + self._list.setStyleSheet(""" + QListWidget { + background:#222222; border:1px solid #333333; + border-radius:4px; outline:none; font-size:15px; + } + QListWidget::item { + padding:0px 14px; border-bottom:1px solid #2a2a2a; color:#dddddd; + } + QListWidget::item:selected { background:#185FA5; color:#ffffff; } + QListWidget::item:hover:!selected { background:#2d2d2d; } + """) + self._list.currentRowChanged.connect(self._on_select) + layout.addWidget(self._list, stretch=1) + return w + + def _build_right_panel(self) -> QScrollArea: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setStyleSheet("background:#1a1a1a;") + + inner = QWidget() + inner.setStyleSheet("background:#1a1a1a;") + layout = QVBoxLayout(inner) + layout.setContentsMargins(8, 16, 16, 16) + layout.setSpacing(0) + + layout.addWidget(self._build_detail_section()) + layout.addWidget(_divider()) + layout.addWidget(self._build_capture_section()) + layout.addStretch() + + scroll.setWidget(inner) + return scroll + + # ── 섹션 1: 제품 상세 ────────────────────────────────────────────── + + def _build_detail_section(self) -> QGroupBox: + g = QGroupBox("제품 상세 정보") + g.setStyleSheet(_GRP_STYLE) + layout = QVBoxLayout(g) + layout.setSpacing(8) + + self._lbl_name = _info_value("—") + self._lbl_model = _info_value("—") + self._lbl_type = _info_value("—") + layout.addLayout(_info_row("카테고리", self._lbl_name)) + layout.addLayout(_info_row("모델명", self._lbl_model)) + layout.addLayout(_info_row("Type", self._lbl_type)) + + self._arrow_lbl = QLabel("") + self._arrow_lbl.setAlignment(Qt.AlignCenter) + self._arrow_lbl.setFixedHeight(80) + self._arrow_lbl.setStyleSheet("font-size:60px; background:transparent;") + layout.addWidget(self._arrow_lbl) + return g + + # ── 섹션 2: 캡처 ────────────────────────────────────────────────── + + def _build_capture_section(self) -> QGroupBox: + g = QGroupBox("기준 이미지 캡처") + g.setStyleSheet(_GRP_STYLE) + layout = QVBoxLayout(g) + layout.setSpacing(12) + + btn = QPushButton("캡처 (In-Sight 트리거)") + btn.setFixedHeight(56) + btn.setStyleSheet( + "background:#1a3a5c; color:#ffffff; border:none; border-radius:4px;" + "font-size:15px; font-weight:bold;" + ) + btn.clicked.connect(self._on_capture) + + self._preview = QLabel() + self._preview.setAlignment(Qt.AlignCenter) + self._preview.setFixedSize(400, 300) + self._preview.setStyleSheet( + "background:#111111; color:#555555; border:1px solid #333333;" + "border-radius:4px; font-size:14px;" + ) + self._preview.setText("캡처 이미지 없음") + + layout.addWidget(btn) + layout.addWidget(self._preview, alignment=Qt.AlignHCenter) + return g + + # ================================================================== # + # 슬롯 + # ================================================================== # + + def _on_select(self, row: int): + if row < 0: + return + item = self._list.item(row) + if item is None: + return + + article_id = item.data(Qt.UserRole) + if article_id is None: + return + + db_item = next( + (x for x in self._db_items if x["article_id"] == article_id), None + ) + r = { + "id": article_id, + "name": item.text(), + "model": db_item.get("buyer_article_no", "") if db_item else "", + "type": "", + } + + self._selected = r + self._captured_img = None + + self._lbl_name.setText(r["name"]) + self._lbl_model.setText(r["model"]) + t = r.get("type", "") + self._lbl_type.setText(t if t else "—") + + if t == "RH": + self._arrow_lbl.setText("→") + self._arrow_lbl.setStyleSheet("font-size:60px; color:#4488ff; background:transparent;") + elif t == "LH": + self._arrow_lbl.setText("←") + self._arrow_lbl.setStyleSheet("font-size:60px; color:#ff8844; background:transparent;") + else: + self._arrow_lbl.setText("") + + self._reset_preview() + + def _on_capture(self): + if self._selected is None: + QMessageBox.warning(self, "경고", "먼저 제품을 선택하세요.") + return + if not self._insight.is_connected(): + QMessageBox.critical(self, "오류", "Cognex 카메라가 연결되어 있지 않습니다.") + return + + raw = self._insight.trigger_and_get_image() + if not raw: + QMessageBox.critical(self, "캡처 실패", + "이미지를 수신하지 못했습니다.\n카메라 연결 상태를 확인하세요.") + return + + arr = np.frombuffer(raw, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + QMessageBox.critical(self, "캡처 실패", "이미지 디코딩에 실패했습니다.") + return + + self._captured_img = img + self._show_ndarray(img) + + def update_db(self, db_client): + """MainWindow에서 DB 연결/해제 시 호출.""" + self._db_client = db_client + enabled = db_client is not None and db_client.is_connected() + self._btn_mes.setEnabled(enabled) + self._btn_mes.setStyleSheet( + "background:#1a3a5c; color:#ffffff; border:none; border-radius:4px; font-size:15px;" + if enabled else + "background:#444444; color:#777777; border:none; border-radius:4px; font-size:15px;" + ) + + def _on_load_from_db(self): + if not self._db_client or not self._db_client.is_connected(): + QMessageBox.warning(self, "경고", "DB를 먼저 연결해주세요.") + return + + items = self._db_client.get_reflector_list() + if not items: + QMessageBox.warning(self, "경고", "조회된 제품이 없습니다.") + return + + self._list.clear() + self._db_items = items + for item in items: + li = QListWidgetItem(item['article']) + li.setSizeHint(QSize(0, 52)) + li.setData(Qt.UserRole, item["article_id"]) + self._list.addItem(li) + + QMessageBox.information( + self, "완료", f"{len(items)}개 제품을 불러왔습니다." + ) + + # ================================================================== # + # 헬퍼 + # ================================================================== # + + def _show_ndarray(self, img: np.ndarray): + h_img, w_img = img.shape[:2] + if img.ndim == 2: + qimg = QImage(img.data, w_img, h_img, w_img, QImage.Format_Grayscale8) + else: + rgb = np.ascontiguousarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) + qimg = QImage(rgb.data, w_img, h_img, w_img * 3, QImage.Format_RGB888) + pix = QPixmap.fromImage(qimg).scaled(400, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self._preview.setPixmap(pix) + self._preview.setText("") + + def _reset_preview(self): + self._preview.clear() + self._preview.setText("캡처 이미지 없음") + + +# ================================================================== # +# 모듈 수준 유틸리티 +# ================================================================== # + +def _divider() -> QFrame: + f = QFrame() + f.setFrameShape(QFrame.HLine) + f.setFixedHeight(1) + f.setStyleSheet("background:#333333; border:none;") + return f + + +def _info_value(text: str) -> QLabel: + lbl = QLabel(text) + lbl.setStyleSheet("color:#ffffff; font-size:16px; font-weight:bold;") + return lbl + + +def _info_row(label: str, value_widget: QLabel) -> QHBoxLayout: + row = QHBoxLayout() + key = QLabel(label + ":") + key.setFixedWidth(80) + key.setStyleSheet("color:#888888; font-size:14px;") + row.addWidget(key) + row.addWidget(value_widget, stretch=1) + return row diff --git a/gui/pages/retrain_page.py b/gui/pages/retrain_page.py new file mode 100644 index 0000000..afee2a1 --- /dev/null +++ b/gui/pages/retrain_page.py @@ -0,0 +1,1193 @@ +# 재학습 페이지 — 이미지 로드·라벨링 UI + 학습 제어 +import json +import os +import shutil +from datetime import datetime + +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QPoint, QRect, pyqtSignal +from PyQt5.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QImage, QCursor +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QGroupBox, + QPushButton, QLabel, QListWidget, QListWidgetItem, + QSpinBox, QLineEdit, QProgressBar, QTextEdit, + QFormLayout, QFileDialog, QSizePolicy, QScrollArea, QMessageBox, +) + +from ai.trainer import Trainer, TrainWorker +from paths import resolve_path, to_project_relative +from logger import log_train, log_action + +_CLASS_COLORS = { + "스크래치": "#854F0B", + "이물": "#185FA5", + "흑점": "#3C3489", + "변형": "#A32D2D", +} +_CLASS_NAMES = list(_CLASS_COLORS.keys()) + +_GRP = ( + "QGroupBox {" + " background:#222222; border:1px solid #333333; border-radius:6px;" + " margin-top:14px; padding:12px 10px 10px 10px;" + "}" + "QGroupBox::title {" + " color:#aaaaaa; subcontrol-origin:margin; left:10px; padding:0 4px;" + "}" +) + + +# ============================================================ # +# 라벨링 캔버스 +# ============================================================ # + +class LabelingCanvas(QWidget): + box_added = pyqtSignal(dict) + boxes_changed = pyqtSignal() + selection_changed = pyqtSignal(int) + zoom_changed = pyqtSignal(int) # 정수 퍼센트 (100 = 100%) + + CLASS_MAP = { + "스크래치": 0, "이물": 1, "흑점": 2, "변형": 3, + } + CLASS_COLORS = { + "스크래치": "#FF4444", "이물": "#44AAFF", + "흑점": "#AA44FF", "변형": "#FF8800", + } + + _HANDLE_SIZE = 8 + # 핸들 인덱스: 0=TL, 1=T, 2=TR, 3=R, 4=BR, 5=B, 6=BL, 7=L + _HANDLE_CURSORS = [ + Qt.SizeFDiagCursor, # 0 TL + Qt.SizeVerCursor, # 1 T + Qt.SizeBDiagCursor, # 2 TR + Qt.SizeHorCursor, # 3 R + Qt.SizeFDiagCursor, # 4 BR + Qt.SizeVerCursor, # 5 B + Qt.SizeBDiagCursor, # 6 BL + Qt.SizeHorCursor, # 7 L + ] + + def __init__(self, parent=None): + super().__init__(parent) + + # 이미지 & 박스 상태 + self.image: np.ndarray = None + self.boxes: list = [] + self.history: list = [] # Ctrl+Z 용 (새 박스 추가 직전 스냅샷) + + # 현재 그릴 클래스 + self.current_class_id: int = 0 + self.current_class_name: str = "스크래치" + + # 선택 & 인터랙션 + self.selected_index: int = -1 + self.drag_mode: str = "none" # none/new_box/move/resize/pan + self.resize_handle: int = -1 + self.current_rect: QRect = None + + # 줌 / 패닝 + self.scale: float = 1.0 + self.offset_x: float = 0.0 + self.offset_y: float = 0.0 + self.space_pressed: bool = False + self._need_fit: bool = False + + # 드래그 임시 상태 + self._drag_start_img: QPoint = None + self._drag_start_screen: QPoint = None + self._move_orig_rect: QRect = None + self._resize_orig_rect: QRect = None + self._pan_start: QPoint = None + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setStyleSheet("background:#111111;") + self.setFocusPolicy(Qt.StrongFocus) + self.setMouseTracking(True) + self.setCursor(Qt.CrossCursor) + + # ── 좌표 변환 ──────────────────────────────────────────────────── # + + def screen_to_image(self, pos: QPoint): + if self.image is None or self.scale == 0: + return None + img_h, img_w = self.image.shape[:2] + x = (pos.x() - self.offset_x) / self.scale + y = (pos.y() - self.offset_y) / self.scale + return QPoint(int(max(0.0, min(x, img_w - 1))), + int(max(0.0, min(y, img_h - 1)))) + + def image_to_screen_rect(self, rect: QRect) -> QRect: + return QRect( + int(rect.x() * self.scale + self.offset_x), + int(rect.y() * self.scale + self.offset_y), + int(rect.width() * self.scale), + int(rect.height() * self.scale), + ) + + # ── 핸들 / 히트테스트 ──────────────────────────────────────────── # + + def _handle_rects(self, sr: QRect) -> list: + hs = self._HANDLE_SIZE + hh = hs // 2 + cx = sr.center().x() + cy = sr.center().y() + l, t, r, b = sr.left(), sr.top(), sr.right(), sr.bottom() + return [ + QRect(l - hh, t - hh, hs, hs), + QRect(cx - hh, t - hh, hs, hs), + QRect(r - hh, t - hh, hs, hs), + QRect(r - hh, cy - hh, hs, hs), + QRect(r - hh, b - hh, hs, hs), + QRect(cx - hh, b - hh, hs, hs), + QRect(l - hh, b - hh, hs, hs), + QRect(l - hh, cy - hh, hs, hs), + ] + + def get_handle_at(self, pos: QPoint, sr: QRect) -> int: + for i, hr in enumerate(self._handle_rects(sr)): + if hr.adjusted(-2, -2, 2, 2).contains(pos): + return i + return -1 + + def get_box_at(self, pos: QPoint) -> int: + result = -1 + for i, box in enumerate(self.boxes): + if self.image_to_screen_rect(box["rect"]).contains(pos): + result = i + return result + + # ── 히스토리 / 복사 ────────────────────────────────────────────── # + + def _copy_boxes(self) -> list: + return [ + { + "class_id": b["class_id"], + "class_name": b["class_name"], + "rect": QRect(b["rect"].x(), b["rect"].y(), + b["rect"].width(), b["rect"].height()), + } + for b in self.boxes + ] + + def _save_history(self): + self.history.append(self._copy_boxes()) + if len(self.history) > 50: + self.history.pop(0) + + # ── 커서 업데이트 ──────────────────────────────────────────────── # + + def _update_cursor(self, pos: QPoint): + if self.space_pressed: + self.setCursor(Qt.OpenHandCursor) + return + if 0 <= self.selected_index < len(self.boxes): + sr = self.image_to_screen_rect(self.boxes[self.selected_index]["rect"]) + h = self.get_handle_at(pos, sr) + if h >= 0: + self.setCursor(self._HANDLE_CURSORS[h]) + return + if sr.contains(pos): + self.setCursor(Qt.SizeAllCursor) + return + if self.get_box_at(pos) >= 0: + self.setCursor(Qt.SizeAllCursor) + return + self.setCursor(Qt.CrossCursor) + + # ── 퍼블릭 API ─────────────────────────────────────────────────── # + + def set_image(self, img: np.ndarray): + self.image = img + self.boxes = [] + self.history = [] + self.selected_index = -1 + self.drag_mode = "none" + self.current_rect = None + self._need_fit = True + self.fit_to_window() + self.selection_changed.emit(-1) + + def set_class(self, class_id: int, class_name: str): + self.current_class_id = class_id + self.current_class_name = class_name + + def change_selected_class(self, class_id: int, class_name: str): + if 0 <= self.selected_index < len(self.boxes): + self._save_history() + self.boxes[self.selected_index]["class_id"] = class_id + self.boxes[self.selected_index]["class_name"] = class_name + self.boxes_changed.emit() + self.update() + + def delete_selected_box(self, index: int = -1): + if index < 0: + index = self.selected_index + if not (0 <= index < len(self.boxes)): + return + self.boxes.pop(index) + if self.selected_index >= len(self.boxes): + self.selected_index = -1 + elif self.selected_index == index: + self.selected_index = -1 + self.selection_changed.emit(self.selected_index) + self.boxes_changed.emit() + self.update() + + def clear_boxes(self): + self.boxes = [] + self.selected_index = -1 + self.selection_changed.emit(-1) + self.boxes_changed.emit() + self.update() + + def fit_to_window(self): + if self.image is None or self.width() == 0 or self.height() == 0: + return + img_h, img_w = self.image.shape[:2] + self.scale = min(self.width() / img_w, self.height() / img_h) + self.offset_x = (self.width() - img_w * self.scale) / 2 + self.offset_y = (self.height() - img_h * self.scale) / 2 + self._need_fit = False + self.zoom_changed.emit(int(self.scale * 100)) + self.update() + + def get_yolo_labels(self) -> list: + if self.image is None: + return [] + img_h, img_w = self.image.shape[:2] + result = [] + for box in self.boxes: + r = box["rect"] + cx = (r.x() + r.width() / 2) / img_w + cy = (r.y() + r.height() / 2) / img_h + nw = r.width() / img_w + nh = r.height() / img_h + result.append( + f"{box['class_id']} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}" + ) + return result + + # ── 마우스 이벤트 ──────────────────────────────────────────────── # + + def mousePressEvent(self, e): + if self.image is None: + return + pos = e.pos() + self.setFocus() + + # 패닝: 스페이스+좌클릭 or 중간 버튼 + if (e.button() == Qt.MiddleButton or + (e.button() == Qt.LeftButton and self.space_pressed)): + self.drag_mode = "pan" + self._pan_start = pos + self.setCursor(Qt.ClosedHandCursor) + return + + if e.button() != Qt.LeftButton: + return + + # 선택된 박스의 핸들 먼저 확인 + if 0 <= self.selected_index < len(self.boxes): + sr = self.image_to_screen_rect(self.boxes[self.selected_index]["rect"]) + h = self.get_handle_at(pos, sr) + if h >= 0: + self.drag_mode = "resize" + self.resize_handle = h + self._drag_start_screen = pos + self._resize_orig_rect = QRect(self.boxes[self.selected_index]["rect"]) + return + if sr.contains(pos): + self.drag_mode = "move" + self._drag_start_screen = pos + self._move_orig_rect = QRect(self.boxes[self.selected_index]["rect"]) + return + + # 다른 박스 히트테스트 + hit = self.get_box_at(pos) + if hit >= 0: + prev = self.selected_index + self.selected_index = hit + if hit != prev: + self.selection_changed.emit(hit) + sr = self.image_to_screen_rect(self.boxes[hit]["rect"]) + h = self.get_handle_at(pos, sr) + if h >= 0: + self.drag_mode = "resize" + self.resize_handle = h + self._drag_start_screen = pos + self._resize_orig_rect = QRect(self.boxes[hit]["rect"]) + else: + self.drag_mode = "move" + self._drag_start_screen = pos + self._move_orig_rect = QRect(self.boxes[hit]["rect"]) + self.update() + return + + # 빈 공간 클릭 → 선택 해제 + 새 박스 그리기 시작 + if self.selected_index >= 0: + self.selected_index = -1 + self.selection_changed.emit(-1) + img_pt = self.screen_to_image(pos) + if img_pt is not None: + self._drag_start_img = img_pt + self.drag_mode = "new_box" + self.current_rect = None + self.update() + + def mouseMoveEvent(self, e): + pos = e.pos() + + if self.drag_mode == "pan": + self.offset_x += pos.x() - self._pan_start.x() + self.offset_y += pos.y() - self._pan_start.y() + self._pan_start = pos + self.update() + return + + if self.drag_mode == "new_box": + ip = self.screen_to_image(pos) + if ip is not None and self._drag_start_img is not None: + self.current_rect = QRect(self._drag_start_img, ip).normalized() + self.update() + return + + if self.drag_mode == "move": + self._do_move(pos) + self.update() + return + + if self.drag_mode == "resize": + self._do_resize(pos) + self.update() + return + + self._update_cursor(pos) + + def mouseReleaseEvent(self, e): + pos = e.pos() + + if self.drag_mode == "pan" and e.button() in (Qt.LeftButton, Qt.MiddleButton): + self.drag_mode = "none" + self._update_cursor(pos) + return + + if e.button() != Qt.LeftButton: + return + + if self.drag_mode == "new_box": + if (self.current_rect is not None + and self.current_rect.width() >= 10 + and self.current_rect.height() >= 10): + self._save_history() + box = { + "class_id": self.current_class_id, + "class_name": self.current_class_name, + "rect": self.current_rect, + } + self.boxes.append(box) + self.selected_index = len(self.boxes) - 1 + self.selection_changed.emit(self.selected_index) + self.box_added.emit(box) + self.current_rect = None + self._drag_start_img = None + + elif self.drag_mode in ("move", "resize"): + self.boxes_changed.emit() + + self.drag_mode = "none" + self._update_cursor(pos) + self.update() + + def mouseDoubleClickEvent(self, e): + if e.button() == Qt.LeftButton: + self.fit_to_window() + + def wheelEvent(self, e): + if self.image is None: + return + pos = e.pos() + delta = e.angleDelta().y() + factor = 1.15 if delta > 0 else 1.0 / 1.15 + new_scale = max(0.5, min(self.scale * factor, 5.0)) + if new_scale == self.scale: + return + img_x = (pos.x() - self.offset_x) / self.scale + img_y = (pos.y() - self.offset_y) / self.scale + self.scale = new_scale + self.offset_x = pos.x() - img_x * self.scale + self.offset_y = pos.y() - img_y * self.scale + self.zoom_changed.emit(int(self.scale * 100)) + self.update() + + # ── 키보드 이벤트 ──────────────────────────────────────────────── # + + def keyPressEvent(self, e): + key = e.key() + + if key == Qt.Key_Space and not e.isAutoRepeat(): + self.space_pressed = True + self.setCursor(Qt.OpenHandCursor) + return + + if key in (Qt.Key_Delete, Qt.Key_Backspace): + if self.selected_index >= 0: + self.delete_selected_box(self.selected_index) + return + + if key == Qt.Key_Z and (e.modifiers() & Qt.ControlModifier): + if self.history: + self.boxes = self.history.pop() + self.selected_index = min(self.selected_index, len(self.boxes) - 1) + if self.selected_index < 0: + self.selected_index = -1 + self.selection_changed.emit(self.selected_index) + self.boxes_changed.emit() + self.update() + return + + if key == Qt.Key_Escape: + self.selected_index = -1 + self.drag_mode = "none" + self.current_rect = None + self._drag_start_img = None + self.selection_changed.emit(-1) + self._update_cursor(self.mapFromGlobal(QCursor.pos())) + self.update() + return + + super().keyPressEvent(e) + + def keyReleaseEvent(self, e): + if e.key() == Qt.Key_Space and not e.isAutoRepeat(): + self.space_pressed = False + if self.drag_mode == "pan": + self.drag_mode = "none" + self._update_cursor(self.mapFromGlobal(QCursor.pos())) + super().keyReleaseEvent(e) + + # ── paintEvent ─────────────────────────────────────────────────── # + + def paintEvent(self, _e): + painter = QPainter(self) + painter.fillRect(self.rect(), QColor("#111111")) + + if self.image is None: + painter.setPen(QColor("#555555")) + painter.setFont(QFont("Arial", 14)) + painter.drawText(self.rect(), Qt.AlignCenter, "이미지를 선택하세요") + painter.end() + return + + # numpy BGR → QPixmap + rgb = np.ascontiguousarray(cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB)) + ih, iw, ch = rgb.shape + qimg = QImage(rgb.data, iw, ih, ch * iw, QImage.Format_RGB888) + pix = QPixmap.fromImage(qimg) + sw = max(1, int(iw * self.scale)) + sh = max(1, int(ih * self.scale)) + scaled = pix.scaled(sw, sh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) + painter.drawPixmap(int(self.offset_x), int(self.offset_y), scaled) + + # 박스 그리기 + painter.setFont(QFont("Arial", 10, QFont.Bold)) + for i, box in enumerate(self.boxes): + color = QColor(self.CLASS_COLORS.get(box["class_name"], "#ffffff")) + sr = self.image_to_screen_rect(box["rect"]) + is_sel = (i == self.selected_index) + + if is_sel: + fill = QColor(color); fill.setAlpha(40) + painter.fillRect(sr, fill) + painter.setPen(QPen(QColor("#ffffff"), 3)) + painter.drawRect(sr) + # 8개 핸들 + painter.setPen(QPen(QColor("#555555"), 1)) + for hr in self._handle_rects(sr): + painter.fillRect(hr, QColor("#ffffff")) + painter.drawRect(hr) + lbl_bg = QColor("#ffffff") + lbl_txt = QColor("#222222") + else: + painter.setPen(QPen(color, 2)) + painter.drawRect(sr) + lbl_bg = color + lbl_txt = QColor("#ffffff") + + # 클래스명 레이블 + text = box["class_name"] + lbl_w = max(len(text) * 11, 60) + lbl_y = sr.y() - 18 if sr.y() >= 18 else sr.y() + lbl_r = QRect(sr.x(), lbl_y, lbl_w, 18) + painter.fillRect(lbl_r, lbl_bg) + painter.setPen(lbl_txt) + painter.drawText(lbl_r.x() + 3, lbl_r.y() + 13, text) + + # 드래그 중인 새 박스 (점선 흰색) + if self.drag_mode == "new_box" and self.current_rect is not None: + sr = self.image_to_screen_rect(self.current_rect) + painter.setPen(QPen(QColor("#ffffff"), 2, Qt.DashLine)) + painter.setBrush(Qt.NoBrush) + painter.drawRect(sr) + + painter.end() + + def resizeEvent(self, e): + super().resizeEvent(e) + if self._need_fit and self.width() > 0 and self.height() > 0: + self.fit_to_window() + else: + self.update() + + # ── 이동 / 리사이즈 헬퍼 ──────────────────────────────────────── # + + def _do_move(self, screen_pos: QPoint): + if self.image is None or self.selected_index < 0: + return + img_h, img_w = self.image.shape[:2] + dx = (screen_pos.x() - self._drag_start_screen.x()) / self.scale + dy = (screen_pos.y() - self._drag_start_screen.y()) / self.scale + orig = self._move_orig_rect + nx = max(0, min(int(orig.x() + dx), img_w - orig.width())) + ny = max(0, min(int(orig.y() + dy), img_h - orig.height())) + self.boxes[self.selected_index]["rect"] = QRect(nx, ny, orig.width(), orig.height()) + + def _do_resize(self, screen_pos: QPoint): + if self.image is None or self.selected_index < 0: + return + img_h, img_w = self.image.shape[:2] + dx = (screen_pos.x() - self._drag_start_screen.x()) / self.scale + dy = (screen_pos.y() - self._drag_start_screen.y()) / self.scale + orig = self._resize_orig_rect + x1, y1 = orig.x(), orig.y() + x2, y2 = orig.x() + orig.width(), orig.y() + orig.height() + h = self.resize_handle + + if h in (0, 6, 7): x1 = int(orig.x() + dx) + if h in (2, 3, 4): x2 = int(orig.x() + orig.width() + dx) + if h in (0, 1, 2): y1 = int(orig.y() + dy) + if h in (4, 5, 6): y2 = int(orig.y() + orig.height() + dy) + + if x2 - x1 < 10: + if h in (0, 6, 7): x1 = x2 - 10 + else: x2 = x1 + 10 + if y2 - y1 < 10: + if h in (0, 1, 2): y1 = y2 - 10 + else: y2 = y1 + 10 + + x1 = max(0, x1); y1 = max(0, y1) + x2 = min(img_w, x2); y2 = min(img_h, y2) + self.boxes[self.selected_index]["rect"] = QRect(x1, y1, x2 - x1, y2 - y1) + + +# ============================================================ # +# 재학습 페이지 +# ============================================================ # + +class RetrainPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._img_dir = "" + self._img_files = [] + self._cur_path = "" + self._trainer = Trainer() + self._worker = None + + self._build_ui() + + # ================================================================ # + # 레이아웃 + # ================================================================ # + + def _build_ui(self): + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self._build_left(), stretch=2) + root.addWidget(self._build_right(), stretch=3) + + def _build_left(self) -> QScrollArea: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setStyleSheet("background:#1a1a1a;") + inner = QWidget() + inner.setStyleSheet("background:#1a1a1a;") + lay = QVBoxLayout(inner) + lay.setContentsMargins(12, 12, 8, 12) + lay.setSpacing(0) + lay.addWidget(self._build_img_load_section()) + lay.addWidget(self._build_class_section()) + lay.addWidget(self._build_dataset_section()) + lay.addStretch() + scroll.setWidget(inner) + return scroll + + # ── 섹션 1: 이미지 로드 ────────────────────────────────────────── # + + def _build_img_load_section(self) -> QGroupBox: + g = QGroupBox("이미지 로드") + g.setStyleSheet(_GRP) + lay = QVBoxLayout(g) + lay.setSpacing(8) + + btn = QPushButton("이미지 폴더 선택") + btn.setFixedHeight(56) + btn.setStyleSheet(_btn_style("#1a3a5c")) + btn.clicked.connect(self._on_select_folder) + + self._folder_lbl = QLabel("폴더를 선택하세요") + self._folder_lbl.setStyleSheet("color:#777777; font-size:12px;") + self._folder_lbl.setWordWrap(True) + + self._img_list = QListWidget() + self._img_list.setMinimumHeight(160) + self._img_list.setStyleSheet(""" + QListWidget { + background:#1a1a1a; border:1px solid #333333; + border-radius:4px; font-size:13px; color:#cccccc; + } + QListWidget::item { padding:4px 8px; border-bottom:1px solid #2a2a2a; } + QListWidget::item:selected { background:#185FA5; color:#ffffff; } + """) + self._img_list.currentRowChanged.connect(self._on_img_selected) + + lay.addWidget(btn) + lay.addWidget(self._folder_lbl) + lay.addWidget(self._img_list, stretch=1) + return g + + # ── 섹션 2: 불량 클래스 선택 ───────────────────────────────────── # + + def _build_class_section(self) -> QGroupBox: + g = QGroupBox("불량 클래스 선택") + g.setStyleSheet(_GRP) + lay = QVBoxLayout(g) + lay.setSpacing(6) + + self._class_btns: dict[str, QPushButton] = {} + for cls_name, color in _CLASS_COLORS.items(): + btn = QPushButton(cls_name) + btn.setFixedHeight(56) + btn.setCheckable(True) + btn.setStyleSheet(_cls_btn_style(color, checked=False)) + btn.clicked.connect(lambda _, c=cls_name: self._on_class_select(c)) + self._class_btns[cls_name] = btn + lay.addWidget(btn) + + first = _CLASS_NAMES[0] + self._class_btns[first].setChecked(True) + self._class_btns[first].setStyleSheet(_cls_btn_style(_CLASS_COLORS[first], checked=True)) + self._active_cls = first + return g + + # ── 섹션 3: 데이터셋 설정 ──────────────────────────────────────── # + + def _build_dataset_section(self) -> QGroupBox: + g = QGroupBox("데이터셋 설정") + g.setStyleSheet(_GRP) + lay = QVBoxLayout(g) + lay.setSpacing(8) + + form = QFormLayout() + form.setHorizontalSpacing(12) + form.setVerticalSpacing(8) + + self._epoch_spin = QSpinBox() + self._epoch_spin.setRange(1, 300) + self._epoch_spin.setValue(100) + self._epoch_spin.setStyleSheet(_spinbox_style()) + + self._batch_spin = QSpinBox() + self._batch_spin.setRange(1, 64) + self._batch_spin.setValue(16) + self._batch_spin.setStyleSheet(_spinbox_style()) + + form.addRow("Epoch", self._epoch_spin) + form.addRow("Batch", self._batch_spin) + lay.addLayout(form) + + path_lbl = QLabel("모델 저장 경로") + path_lbl.setStyleSheet("color:#888888; font-size:13px;") + path_row = QHBoxLayout() + self._save_path_edit = QLineEdit("ai/models/best.pt") + self._save_path_edit.setFixedHeight(44) + self._save_path_edit.setStyleSheet( + "background:#2a2a2a; color:#ffffff; border:1px solid #555555;" + "border-radius:4px; padding:0 8px; font-size:13px;" + ) + btn_browse = QPushButton("찾기") + btn_browse.setFixedSize(64, 44) + btn_browse.setStyleSheet(_btn_style("#333333", font_size=13)) + btn_browse.clicked.connect(self._on_browse_save) + path_row.addWidget(self._save_path_edit, stretch=1) + path_row.addWidget(btn_browse) + + lay.addWidget(path_lbl) + lay.addLayout(path_row) + return g + + # ── 우측 패널 ──────────────────────────────────────────────────── # + + def _build_right(self) -> QWidget: + w = QWidget() + w.setStyleSheet("background:#1a1a1a;") + lay = QVBoxLayout(w) + lay.setContentsMargins(8, 12, 12, 12) + lay.setSpacing(0) + lay.addWidget(self._build_labeling_section(), stretch=55) + lay.addWidget(self._build_train_section(), stretch=25) + lay.addWidget(self._build_save_section(), stretch=20) + return w + + def _build_labeling_section(self) -> QGroupBox: + g = QGroupBox("이미지 표시 / 라벨링") + g.setStyleSheet(_GRP) + outer = QVBoxLayout(g) + outer.setSpacing(6) + + # ── 툴바 (줌 표시 + 초기화 버튼) ── + toolbar = QHBoxLayout() + self._zoom_lbl = QLabel("100%") + self._zoom_lbl.setStyleSheet( + "color:#aaaaaa; font-size:12px; min-width:55px;" + ) + hint = QLabel("더블클릭: fit | 휠: 줌 | Space+드래그: 패닝 | Del: 박스삭제 | Ctrl+Z: 실행취소") + hint.setStyleSheet("color:#555555; font-size:11px;") + btn_fit = QPushButton("초기화") + btn_fit.setFixedHeight(28) + btn_fit.setStyleSheet(_btn_style("#333333", font_size=12)) + btn_fit.clicked.connect(lambda: self._canvas.fit_to_window()) + toolbar.addWidget(self._zoom_lbl) + toolbar.addWidget(hint) + toolbar.addStretch() + toolbar.addWidget(btn_fit) + outer.addLayout(toolbar) + + # ── 캔버스 + 박스 목록 ── + main_row = QHBoxLayout() + + self._canvas = LabelingCanvas() + self._canvas.box_added.connect(lambda _: self._refresh_box_list()) + self._canvas.boxes_changed.connect(self._refresh_box_list) + self._canvas.selection_changed.connect(self._on_canvas_selection_changed) + self._canvas.zoom_changed.connect(lambda pct: self._zoom_lbl.setText(f"{pct}%")) + main_row.addWidget(self._canvas, stretch=3) + + side = QVBoxLayout() + side.setSpacing(6) + box_lbl = QLabel("박스 목록") + box_lbl.setStyleSheet("color:#888888; font-size:13px;") + self._box_list = QListWidget() + self._box_list.setStyleSheet(""" + QListWidget { + background:#1a1a1a; border:1px solid #333333; + border-radius:4px; font-size:12px; color:#cccccc; + } + QListWidget::item { padding:3px 6px; } + QListWidget::item:selected { background:#3C3489; } + """) + self._box_list.currentRowChanged.connect(self._on_box_list_select) + + btn_del = QPushButton("박스 삭제") + btn_del.setFixedHeight(44) + btn_del.setStyleSheet(_btn_style("#5c1a1a", font_size=13)) + btn_del.clicked.connect(self._on_del_box) + side.addWidget(box_lbl) + side.addWidget(self._box_list, stretch=1) + side.addWidget(btn_del) + main_row.addLayout(side, stretch=1) + + outer.addLayout(main_row, stretch=1) + + btn_save = QPushButton("라벨 저장 (YOLO .txt)") + btn_save.setFixedHeight(50) + btn_save.setStyleSheet(_btn_style("#2e5c2e", font_size=14)) + btn_save.clicked.connect(self._on_label_save) + outer.addWidget(btn_save) + return g + + def _build_train_section(self) -> QGroupBox: + g = QGroupBox("학습 제어") + g.setStyleSheet(_GRP) + lay = QVBoxLayout(g) + lay.setSpacing(6) + + btn_row = QHBoxLayout() + self._start_btn = QPushButton("학습 시작") + self._start_btn.setFixedHeight(70) + self._start_btn.setStyleSheet(_btn_style("#1D9E75", font_size=17, bold=True)) + self._start_btn.clicked.connect(self._on_train_start) + + self._stop_btn = QPushButton("학습 중지") + self._stop_btn.setFixedHeight(70) + self._stop_btn.setEnabled(False) + self._stop_btn.setStyleSheet(_btn_style("#A32D2D", font_size=17, bold=True)) + self._stop_btn.clicked.connect(self._on_train_stop) + btn_row.addWidget(self._start_btn) + btn_row.addWidget(self._stop_btn) + + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 100) + self._progress_bar.setValue(0) + self._progress_bar.setFixedHeight(22) + self._progress_bar.setStyleSheet(""" + QProgressBar { + background:#2a2a2a; border:1px solid #555555; + border-radius:4px; text-align:center; color:#ffffff; font-size:13px; + } + QProgressBar::chunk { background:#1D9E75; border-radius:4px; } + """) + + self._status_lbl = QLabel("대기 중") + self._status_lbl.setStyleSheet("color:#888888; font-size:14px;") + self._status_lbl.setAlignment(Qt.AlignCenter) + + self._log_box = QTextEdit() + self._log_box.setReadOnly(True) + self._log_box.setFixedHeight(80) + self._log_box.setStyleSheet( + "background:#111111; color:#aaaaaa; border:1px solid #333333;" + "border-radius:4px; font-size:12px; font-family:Consolas,monospace;" + ) + + lay.addLayout(btn_row) + lay.addWidget(self._progress_bar) + lay.addWidget(self._status_lbl) + lay.addWidget(self._log_box) + return g + + def _build_save_section(self) -> QGroupBox: + g = QGroupBox("모델 저장") + g.setStyleSheet(_GRP) + lay = QVBoxLayout(g) + lay.setSpacing(8) + + self._save_btn = QPushButton("모델 저장") + self._save_btn.setFixedHeight(56) + self._save_btn.setEnabled(False) + self._save_btn.setStyleSheet(_btn_style("#1a4d1a", font_size=15, bold=True)) + self._save_btn.clicked.connect(self._on_model_save) + + self._save_result_lbl = QLabel("") + self._save_result_lbl.setAlignment(Qt.AlignCenter) + self._save_result_lbl.setStyleSheet("color:#aaaaaa; font-size:13px;") + + lay.addWidget(self._save_btn) + lay.addWidget(self._save_result_lbl) + return g + + # ================================================================ # + # 슬롯 + # ================================================================ # + + def _on_select_folder(self): + folder = QFileDialog.getExistingDirectory(self, "이미지 폴더 선택", "") + if not folder: + return + self._img_dir = folder + self._folder_lbl.setText(folder) + exts = {".jpg", ".jpeg", ".png", ".bmp"} + self._img_files = sorted( + f for f in os.listdir(folder) + if os.path.splitext(f)[1].lower() in exts + ) + self._img_list.clear() + for fname in self._img_files: + item = QListWidgetItem(fname) + item.setSizeHint(item.sizeHint().__class__(0, 44)) + self._img_list.addItem(item) + print(f"[재학습] 폴더: {folder} ({len(self._img_files)}개)") + + def _on_img_selected(self, row: int): + if row < 0 or row >= len(self._img_files): + return + path = os.path.join(self._img_dir, self._img_files[row]) + self._cur_path = path + + img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR) + if img is None: + print(f"[재학습] 이미지 로드 실패: {path}") + return + + self._canvas.set_image(img) + class_id = LabelingCanvas.CLASS_MAP.get(self._active_cls, 0) + self._canvas.set_class(class_id, self._active_cls) + + txt_path = os.path.splitext(path)[0] + ".txt" + if os.path.exists(txt_path): + self._load_yolo_labels(txt_path, img.shape) + + self._refresh_box_list() + + def _load_yolo_labels(self, txt_path: str, img_shape): + img_h, img_w = img_shape[:2] + id_to_name = {v: k for k, v in LabelingCanvas.CLASS_MAP.items()} + try: + with open(txt_path, "r", encoding="utf-8") as f: + for line in f: + parts = line.strip().split() + if len(parts) != 5: + continue + cid = int(parts[0]) + cx, cy, nw, nh = map(float, parts[1:]) + x = int((cx - nw / 2) * img_w) + y = int((cy - nh / 2) * img_h) + w = int(nw * img_w) + h = int(nh * img_h) + self._canvas.boxes.append({ + "class_id": cid, + "class_name": id_to_name.get(cid, "스크래치"), + "rect": QRect(x, y, w, h), + }) + self._canvas.update() + except Exception as err: + print(f"[재학습] 라벨 로드 실패: {err}") + + def _on_class_select(self, cls_name: str): + for name, btn in self._class_btns.items(): + is_active = (name == cls_name) + btn.setChecked(is_active) + btn.setStyleSheet(_cls_btn_style(_CLASS_COLORS[name], checked=is_active)) + self._active_cls = cls_name + class_id = LabelingCanvas.CLASS_MAP.get(cls_name, 0) + self._canvas.set_class(class_id, cls_name) + # 선택된 박스가 있으면 클래스 변경 + if self._canvas.selected_index >= 0: + self._canvas.change_selected_class(class_id, cls_name) + + # ── 캔버스 ↔ 박스 목록 동기화 ─────────────────────────────────── # + + def _on_canvas_selection_changed(self, index: int): + """캔버스 선택 변경 → 리스트 하이라이트 (루프 방지).""" + self._box_list.blockSignals(True) + self._box_list.setCurrentRow(index) + self._box_list.blockSignals(False) + + def _on_box_list_select(self, row: int): + """리스트 클릭 → 캔버스 선택 변경.""" + if row != self._canvas.selected_index: + self._canvas.selected_index = row + self._canvas.update() + + def _on_del_box(self): + idx = self._canvas.selected_index + if idx < 0: + idx = self._box_list.currentRow() + if idx >= 0: + self._canvas.delete_selected_box(idx) + # _refresh_box_list는 boxes_changed 시그널로 자동 호출 + + def _on_label_save(self): + if not self._cur_path: + QMessageBox.warning(self, "경고", "이미지를 먼저 선택하세요.") + return + labels = self._canvas.get_yolo_labels() + if not labels: + QMessageBox.warning(self, "경고", "박스를 먼저 그려주세요.") + return + txt_path = os.path.splitext(self._cur_path)[0] + ".txt" + try: + with open(txt_path, "w", encoding="utf-8") as f: + f.write("\n".join(labels)) + QMessageBox.information(self, "저장 완료", f"라벨 저장 완료:\n{txt_path}") + self._log_append(f"라벨 저장: {os.path.basename(txt_path)}") + except Exception as err: + QMessageBox.critical(self, "저장 실패", str(err)) + + def _on_browse_save(self): + path, _ = QFileDialog.getSaveFileName( + self, "모델 저장 경로", "ai/models/best.pt", "PyTorch 모델 (*.pt)" + ) + if path: + self._save_path_edit.setText(path) + + def _on_train_start(self): + if not self._img_dir: + QMessageBox.warning(self, "경고", "이미지 폴더를 먼저 선택하세요.") + return + + label_files = [ + f for f in os.listdir(self._img_dir) if f.lower().endswith(".txt") + ] + if not label_files: + QMessageBox.warning( + self, "경고", "라벨 파일이 없습니다. 먼저 라벨링해주세요." + ) + return + + self._progress_bar.setValue(0) + self._progress_bar.setFormat("0%") + self._log_box.clear() + self._status_lbl.setText("학습 중...") + self._status_lbl.setStyleSheet("color:#1D9E75; font-size:14px; font-weight:bold;") + self._start_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + self._save_btn.setEnabled(False) + + save_path = self._save_path_edit.text().strip() or "ai/models/best.pt" + epochs = self._epoch_spin.value() + batch = self._batch_spin.value() + log_action( + f"[재학습] 학습 시작 | epochs={epochs} | batch={batch} | 저장={save_path}" + ) + log_train( + f"학습 시작 | 데이터셋={self._img_dir} | epochs={epochs} | " + f"batch={batch} | 저장={save_path} | 라벨파일={len(label_files)}개" + ) + self._worker = TrainWorker( + self._trainer, + self._img_dir, + epochs, + batch, + save_path, + ) + self._worker.log_signal.connect(self._on_log) + self._worker.progress_signal.connect(self._on_progress) + self._worker.finished_signal.connect(self._on_finished) + self._worker.start() + + def _on_train_stop(self): + if self._worker and self._worker.isRunning(): + self._worker.stop_subprocess() + self._worker.terminate() + self._trainer.stop() + self._log_append("학습 중지됨") + log_action("[재학습] 학습 중지 (사용자)") + log_train("학습 중지됨 (사용자 중지)") + self._status_lbl.setText("중지됨") + self._status_lbl.setStyleSheet("color:#A32D2D; font-size:14px; font-weight:bold;") + self._start_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + + def _on_log(self, message: str): + ts = datetime.now().strftime("%H:%M:%S") + self._log_box.append(f"[{ts}] {message}") + sb = self._log_box.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _on_progress(self, value: int): + self._progress_bar.setValue(value) + self._progress_bar.setFormat(f"{value}%") + + def _on_finished(self, success: bool): + self._start_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + if success: + self._save_btn.setEnabled(True) + self._status_lbl.setText("학습 완료") + self._status_lbl.setStyleSheet("color:#22cc55; font-size:14px; font-weight:bold;") + log_train("학습 완료") + QMessageBox.information(self, "완료", "학습 완료!") + else: + self._status_lbl.setText("학습 실패") + self._status_lbl.setStyleSheet("color:#A32D2D; font-size:14px; font-weight:bold;") + log_train("학습 실패") + QMessageBox.critical(self, "실패", "학습 실패. 로그를 확인해주세요.") + + def _on_model_save(self): + dest_input = self._save_path_edit.text().strip() or "ai/models/best.pt" + dest = resolve_path(dest_input) + src = resolve_path("ai/runs/train/weights/best.pt") + if not os.path.exists(src): + QMessageBox.warning(self, "경고", f"학습 결과 파일을 찾을 수 없습니다:\n{src}") + return + try: + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copy(src, dest) + saved_path = to_project_relative(dest) + # config.json ai.model_path 업데이트 + config_path = resolve_path("config.json") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as fh: + cfg = json.load(fh) + cfg.setdefault("ai", {})["model_path"] = saved_path + with open(config_path, "w", encoding="utf-8") as fh: + json.dump(cfg, fh, ensure_ascii=False, indent=2) + self._save_result_lbl.setText(f"저장 완료: {saved_path}") + self._save_result_lbl.setStyleSheet("color:#22cc55; font-size:13px;") + log_action(f"[재학습] 모델 저장 완료 → {saved_path}") + log_train(f"모델 저장 완료 | {saved_path}") + QMessageBox.information(self, "완료", f"모델 저장 완료\n{dest}") + except Exception as e: + log_train(f"모델 저장 실패 | {e}") + QMessageBox.critical(self, "저장 실패", str(e)) + + # ================================================================ # + # 헬퍼 + # ================================================================ # + + def _refresh_box_list(self): + self._box_list.blockSignals(True) + self._box_list.clear() + for box in self._canvas.boxes: + r = box["rect"] + color = LabelingCanvas.CLASS_COLORS.get(box["class_name"], "#ffffff") + item = QListWidgetItem( + f"[{box['class_name']}] x:{r.x()} y:{r.y()} w:{r.width()} h:{r.height()}" + ) + item.setForeground(QColor(color)) + self._box_list.addItem(item) + self._box_list.setCurrentRow(self._canvas.selected_index) + self._box_list.blockSignals(False) + + def _log_append(self, text: str): + ts = datetime.now().strftime("%H:%M:%S") + self._log_box.append(f"[{ts}] {text}") + sb = self._log_box.verticalScrollBar() + sb.setValue(sb.maximum()) + + +# ============================================================ # +# 스타일 헬퍼 +# ============================================================ # + +def _btn_style(bg: str, font_size: int = 14, bold: bool = False) -> str: + weight = "bold" if bold else "normal" + return ( + f"QPushButton {{" + f" background:{bg}; color:#ffffff; border:none; border-radius:4px;" + f" font-size:{font_size}px; font-weight:{weight}; min-height:28px;" + f"}}" + f"QPushButton:hover {{ background:{_lighten(bg)}; }}" + f"QPushButton:pressed {{ background:{_darken(bg)}; }}" + f"QPushButton:disabled {{ background:#3a3a3a; color:#666666; }}" + ) + + +def _cls_btn_style(color: str, checked: bool) -> str: + bg = color if checked else "#333333" + border = f"border:2px solid {color};" if checked else "border:1px solid #555555;" + return ( + f"QPushButton {{" + f" background:{bg}; color:#ffffff; {border} border-radius:4px;" + f" font-size:15px; font-weight:bold; min-height:56px;" + f"}}" + f"QPushButton:hover {{ background:{_lighten(bg)}; }}" + ) + + +def _spinbox_style() -> str: + return ( + "QSpinBox {" + " background:#2a2a2a; color:#ffffff; border:1px solid #555555;" + " border-radius:4px; padding:4px 8px; font-size:14px; min-height:38px;" + "}" + ) + + +def _lighten(hex_color: str) -> str: + try: + r, g, b = int(hex_color[1:3], 16), int(hex_color[3:5], 16), int(hex_color[5:7], 16) + return f"#{min(r+30,255):02x}{min(g+30,255):02x}{min(b+30,255):02x}" + except Exception: + return hex_color + + +def _darken(hex_color: str) -> str: + try: + r, g, b = int(hex_color[1:3], 16), int(hex_color[3:5], 16), int(hex_color[5:7], 16) + return f"#{max(r-30,0):02x}{max(g-30,0):02x}{max(b-30,0):02x}" + except Exception: + return hex_color diff --git a/gui/pages/settings_page.py b/gui/pages/settings_page.py new file mode 100644 index 0000000..332e1bf --- /dev/null +++ b/gui/pages/settings_page.py @@ -0,0 +1,1219 @@ +import json +import os + +from PyQt5.QtCore import Qt, QTimer, pyqtSignal +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, + QPushButton, QLineEdit, QSpinBox, QDoubleSpinBox, + QLabel, QMessageBox, QApplication, QFileDialog, + QDialog, QTabWidget, QFrame, +) + +from camera.insight import InSightCamera +from camera.basler import BaslerCamera +from ai.detector import Detector +from db.sql_client import SQLClient +from plc.plc_client import PLCClient +from paths import resolve_path, to_project_relative +from utils.path_helper import get_path +from logger import log_action + +ADMIN_PASSWORD = "1234" + +# ── 카드 스타일 ──────────────────────────────────────────────────────── # +_CARD = ( + "QFrame {" + " background:#1a1a1a;" + " border:1px solid #2a2a2a;" + " border-radius:8px;" + "}" +) +_STATUS_OK = "color:#1D9E75; font-size:12px; background:transparent; border:none;" +_STATUS_FAIL = "color:#E24B4A; font-size:12px; background:transparent; border:none;" + +_BTN_CARD_CONNECT = ( + "QPushButton {" + " background:#1D9E75; color:#E1F5EE;" + " border:none; border-radius:4px;" + " min-height:43px; max-height:43px;" + " padding:0 19px; font-size:14px;" + "}" + "QPushButton:hover { background:#20b585; }" + "QPushButton:disabled { background:#145f48; color:#5a9e7e; }" +) +_BTN_CARD_DISCONNECT = ( + "QPushButton {" + " background:#3D1515; color:#F09595;" + " border:none; border-radius:4px;" + " min-height:43px; max-height:43px;" + " padding:0 19px; font-size:14px;" + "}" + "QPushButton:hover { background:#4a1818; }" + "QPushButton:disabled { background:#222222; color:#666666; }" +) +_BTN_ADMIN = ( + "QPushButton {" + " background:#333333; color:#aaaaaa;" + " border:1px solid #555555; border-radius:4px;" + " font-size:13px; padding:0 16px;" + "}" + "QPushButton:hover { background:#444444; color:#ffffff; }" +) + +# ── 관리자 다이얼로그 내부 스타일 ────────────────────────────────────── # +_GRP = ( + "QGroupBox {" + " background:#222222; border:1px solid #333333; border-radius:6px;" + " margin-top:14px; padding:14px 12px 12px 12px;" + "}" + "QGroupBox::title { color:#aaaaaa; subcontrol-origin:margin; left:10px; padding:0 4px; }" +) +_BTN_DLG = ( + "QPushButton {" + " background:#333333; color:#ffffff; border:1px solid #555555;" + " border-radius:4px; min-height:56px; font-size:14px;" + "}" + "QPushButton:hover { background:#444444; }" +) +_BTN_DLG_PRIMARY = ( + "QPushButton {" + " background:#1D9E75; color:#ffffff; border:none;" + " border-radius:4px; min-height:56px; font-size:14px; font-weight:bold;" + "}" + "QPushButton:hover { background:#20b585; }" +) + + +# ══════════════════════════════════════════════════════════════════════ # +# PasswordDialog +# ══════════════════════════════════════════════════════════════════════ # + +class PasswordDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("관리자 인증") + self.setModal(True) + self.setFixedSize(360, 570) + self.setStyleSheet("background:#1a1a1a;") + self._pw = "" + self._locked = False # 오입력 후 잠시 입력 차단 + self._build_ui() + + # ── UI ───────────────────────────────────────────────────────────── # + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 28, 24, 24) + layout.setSpacing(12) + + title = QLabel("관리자 인증") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet("color:#ffffff; font-size:17px; font-weight:bold;") + layout.addWidget(title) + + desc = QLabel("비밀번호를 입력하세요.") + desc.setAlignment(Qt.AlignCenter) + desc.setStyleSheet("color:#888888; font-size:13px;") + layout.addWidget(desc) + + # 4자리 점 표시 + self._dot_lbl = QLabel("○ ○ ○ ○") + self._dot_lbl.setAlignment(Qt.AlignCenter) + self._dot_lbl.setStyleSheet( + "color:#444444; font-size:30px; background:transparent;" + ) + layout.addWidget(self._dot_lbl) + layout.addSpacing(6) + + # 키패드 + pad = QGridLayout() + pad.setSpacing(8) + pad.setContentsMargins(0, 0, 0, 0) + + for idx, digit in enumerate("123456789"): + btn = self._pad_btn(digit, font_size=22) + btn.clicked.connect(lambda _, d=digit: self._on_digit(d)) + pad.addWidget(btn, idx // 3, idx % 3) + + btn_del = self._pad_btn("⌫", bg="#252525", fg="#888888", font_size=20) + btn_del.clicked.connect(self._on_backspace) + + btn_0 = self._pad_btn("0", font_size=22) + btn_0.clicked.connect(lambda: self._on_digit("0")) + + btn_ok = self._pad_btn("확인", bg="#1D9E75", fg="#E1F5EE", font_size=16) + btn_ok.clicked.connect(self._on_confirm) + + pad.addWidget(btn_del, 3, 0) + pad.addWidget(btn_0, 3, 1) + pad.addWidget(btn_ok, 3, 2) + + layout.addLayout(pad) + layout.addSpacing(4) + + btn_cancel = QPushButton("취소") + btn_cancel.setFixedHeight(52) + btn_cancel.setStyleSheet( + "QPushButton { background:#222222; color:#777777; border:none;" + " border-radius:6px; font-size:15px; }" + "QPushButton:pressed { background:#1a1a1a; }" + ) + btn_cancel.clicked.connect(self.reject) + layout.addWidget(btn_cancel) + + @staticmethod + def _pad_btn(text: str, bg="#2a2a2a", fg="#ffffff", font_size=22) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(68) + btn.setStyleSheet( + f"QPushButton {{ background:{bg}; color:{fg}; border:none;" + f" border-radius:6px; font-size:{font_size}px; }}" + f"QPushButton:pressed {{ background:#111111; }}" + ) + return btn + + # ── 입력 로직 ────────────────────────────────────────────────────── # + + def _on_digit(self, digit: str): + if self._locked or len(self._pw) >= 4: + return + self._pw += digit + self._refresh_dots() + if len(self._pw) == 4: + # 4자리 완성 → 120ms 후 자동 확인 (시각 피드백 확보) + QTimer.singleShot(120, self._on_confirm) + + def _on_backspace(self): + if self._locked: + return + self._pw = self._pw[:-1] + self._refresh_dots() + + def _on_confirm(self): + if self._pw == ADMIN_PASSWORD: + self.accept() + return + # 오입력: 빨간 점 → 600ms 후 초기화 + self._locked = True + self._pw = "" + self._dot_lbl.setText("● ● ● ●") + self._dot_lbl.setStyleSheet( + "color:#E24B4A; font-size:30px; background:transparent;" + ) + QTimer.singleShot(600, self._reset_dots) + + def _reset_dots(self): + self._locked = False + self._refresh_dots() + + def _refresh_dots(self): + n = len(self._pw) + dots = " ".join("●" if i < n else "○" for i in range(4)) + color = "#1D9E75" if n > 0 else "#444444" + self._dot_lbl.setText(dots) + self._dot_lbl.setStyleSheet( + f"color:{color}; font-size:30px; background:transparent;" + ) + + def keyPressEvent(self, event): + # 터치 전용 — 키보드 입력 차단 (Escape 제외) + if event.key() == Qt.Key_Escape: + self.reject() + + +# ══════════════════════════════════════════════════════════════════════ # +# AdminSettingsDialog +# ══════════════════════════════════════════════════════════════════════ # + +class AdminSettingsDialog(QDialog): + def __init__(self, settings_page, parent=None): + super().__init__(parent) + self._sp = settings_page + self.setWindowTitle("관리자 설정") + self.setModal(True) + self.setFixedSize(900, 700) + self.setStyleSheet("background:#1a1a1a;") + self._build_ui() + self._populate() + + # ── UI 뼈대 ─────────────────────────────────────────────────────── # + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + self._tabs = QTabWidget() + self._tabs.setStyleSheet( + "QTabWidget::pane { border:none; background:#1a1a1a; }" + "QTabBar::tab {" + " background:#222222; color:#888888;" + " padding:10px 24px; font-size:14px; border:none;" + " border-right:1px solid #333333;" + "}" + "QTabBar::tab:selected { background:#2e2e2e; color:#ffffff; font-weight:bold; }" + "QTabBar::tab:hover { background:#2a2a2a; color:#cccccc; }" + ) + + self._tabs.addTab(self._build_tab_cognex(), "코그넥스") + self._tabs.addTab(self._build_tab_basler(), "Basler") + self._tabs.addTab(self._build_tab_db(), "DB") + self._tabs.addTab(self._build_tab_ai(), "AI 모델") + self._tabs.addTab(self._build_tab_conveyor(), "컨베이어") + self._tabs.addTab(self._build_tab_plc(), "PLC") + + root.addWidget(self._tabs, stretch=1) + root.addWidget(self._build_bottom_bar()) + + @staticmethod + def _tab_wrap(inner_widget) -> QWidget: + w = QWidget() + w.setStyleSheet("background:#1a1a1a;") + layout = QVBoxLayout(w) + layout.setContentsMargins(32, 24, 32, 20) + layout.setSpacing(16) + layout.addWidget(inner_widget) + layout.addStretch() + return w + + @staticmethod + def _btn_pair(*btns) -> QWidget: + w = QWidget() + row = QHBoxLayout(w) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(8) + for b in btns: + row.addWidget(b, stretch=1) + return w + + # ── 탭 1 — 코그넥스 ─────────────────────────────────────────────── # + + def _build_tab_cognex(self) -> QWidget: + g = self._make_group("코그넥스 In-Sight 2000C") + form = QFormLayout(g) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + + self._cognex_ip = QLineEdit() + self._cognex_ip.setPlaceholderText("예: 169.254.0.1") + self._cognex_ip.setFixedHeight(42) + + self._cognex_port = QSpinBox() + self._cognex_port.setRange(1, 65535) + self._cognex_port.setFixedHeight(42) + + btn_connect = QPushButton("연결") + btn_connect.setFixedHeight(56) + btn_connect.setStyleSheet(_BTN_DLG) + btn_connect.clicked.connect(self._on_cognex_connect) + + btn_save = QPushButton("저장") + btn_save.setFixedHeight(56) + btn_save.setStyleSheet(_BTN_DLG) + btn_save.clicked.connect(self._on_cognex_save) + + form.addRow("IP 주소", self._cognex_ip) + form.addRow("포트", self._cognex_port) + form.addRow("", self._btn_pair(btn_connect, btn_save)) + return self._tab_wrap(g) + + # ── 탭 2 — Basler ───────────────────────────────────────────────── # + + def _build_tab_basler(self) -> QWidget: + g = self._make_group("Basler USB 카메라") + form = QFormLayout(g) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + + self._basler_exposure = QSpinBox() + self._basler_exposure.setRange(100, 1000000) + self._basler_exposure.setFixedHeight(42) + + self._basler_gain = QSpinBox() + self._basler_gain.setRange(0, 100) + self._basler_gain.setFixedHeight(42) + + btn_connect = QPushButton("연결") + btn_connect.setFixedHeight(56) + btn_connect.setStyleSheet(_BTN_DLG) + btn_connect.clicked.connect(self._sp._on_basler_connect) + + btn_apply = QPushButton("설정 적용") + btn_apply.setFixedHeight(56) + btn_apply.setStyleSheet(_BTN_DLG) + btn_apply.clicked.connect(self._on_basler_apply) + + form.addRow("노출 (µs)", self._basler_exposure) + form.addRow("게인", self._basler_gain) + form.addRow("", self._btn_pair(btn_connect, btn_apply)) + return self._tab_wrap(g) + + # ── 탭 3 — DB ───────────────────────────────────────────────────── # + + def _build_tab_db(self) -> QWidget: + g = self._make_group("MS SQL Server DB") + form = QFormLayout(g) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + + self._db_server = QLineEdit() + self._db_server.setPlaceholderText("예: Wizis.iptime.org,20220") + self._db_server.setFixedHeight(42) + + self._db_database = QLineEdit() + self._db_database.setFixedHeight(42) + + self._db_username = QLineEdit() + self._db_username.setFixedHeight(42) + + self._db_password = QLineEdit() + self._db_password.setEchoMode(QLineEdit.Password) + self._db_password.setFixedHeight(42) + + btn_connect = QPushButton("연결") + btn_connect.setFixedHeight(56) + btn_connect.setStyleSheet(_BTN_DLG) + btn_connect.clicked.connect(self._on_db_connect) + + btn_save = QPushButton("저장") + btn_save.setFixedHeight(56) + btn_save.setStyleSheet(_BTN_DLG) + btn_save.clicked.connect(self._on_db_save) + + form.addRow("서버", self._db_server) + form.addRow("DB명", self._db_database) + form.addRow("사용자명", self._db_username) + form.addRow("비밀번호", self._db_password) + form.addRow("", self._btn_pair(btn_connect, btn_save)) + return self._tab_wrap(g) + + # ── 탭 4 — AI 모델 ──────────────────────────────────────────────── # + + def _build_tab_ai(self) -> QWidget: + g = self._make_group("AI 모델 (YOLOv8)") + layout = QVBoxLayout(g) + layout.setSpacing(10) + + path_row = QHBoxLayout() + self._ai_path_edit = QLineEdit() + self._ai_path_edit.setReadOnly(True) + self._ai_path_edit.setFixedHeight(42) + self._ai_path_edit.setPlaceholderText("모델 경로 미설정") + + btn_browse = QPushButton("파일 선택") + btn_browse.setFixedHeight(42) + btn_browse.setFixedWidth(120) + btn_browse.setStyleSheet(_BTN_DLG.replace("min-height:56px", "min-height:42px")) + btn_browse.clicked.connect(self._on_ai_browse) + + path_row.addWidget(self._ai_path_edit, stretch=1) + path_row.addWidget(btn_browse) + + btn_load = QPushButton("로드") + btn_load.setFixedHeight(56) + btn_load.setStyleSheet(_BTN_DLG_PRIMARY) + btn_load.clicked.connect(self._on_ai_load) + + btn_unload = QPushButton("해제") + btn_unload.setFixedHeight(56) + btn_unload.setStyleSheet(_BTN_DLG) + btn_unload.clicked.connect(self._on_ai_unload) + + self._ai_info_lbl = QLabel("") + self._ai_info_lbl.setStyleSheet("color:#888888; font-size:12px; background:transparent;") + self._ai_info_lbl.setWordWrap(True) + + layout.addLayout(path_row) + layout.addWidget(self._btn_pair(btn_load, btn_unload)) + layout.addWidget(self._ai_info_lbl) + return self._tab_wrap(g) + + # ── 탭 5 — 컨베이어 ─────────────────────────────────────────────── # + + def _build_tab_conveyor(self) -> QWidget: + g = self._make_group("컨베이어 설정") + form = QFormLayout(g) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + + self._conv_distance = QDoubleSpinBox() + self._conv_distance.setRange(1.0, 10000.0) + self._conv_distance.setDecimals(1) + self._conv_distance.setSuffix(" cm") + self._conv_distance.setValue(100.0) + self._conv_distance.setFixedHeight(42) + + self._conv_speed = QDoubleSpinBox() + self._conv_speed.setRange(0.1, 10000.0) + self._conv_speed.setDecimals(1) + self._conv_speed.setSuffix(" cm/s") + self._conv_speed.setValue(30.0) + self._conv_speed.setFixedHeight(42) + + self._conv_delay_lbl = QLabel("3.33 s") + self._conv_delay_lbl.setStyleSheet( + "color:#44cc88; font-size:14px; font-weight:bold; background:transparent;" + ) + + self._conv_distance.valueChanged.connect(self._on_conveyor_changed) + self._conv_speed.valueChanged.connect(self._on_conveyor_changed) + + btn_apply = QPushButton("적용") + btn_apply.setFixedHeight(56) + btn_apply.setStyleSheet(_BTN_DLG_PRIMARY) + btn_apply.clicked.connect(self._on_conveyor_apply) + + form.addRow("카메라 간 거리", self._conv_distance) + form.addRow("벨트 속도", self._conv_speed) + form.addRow("계산된 딜레이", self._conv_delay_lbl) + form.addRow("", btn_apply) + return self._tab_wrap(g) + + # ── 탭 6 — PLC ──────────────────────────────────────────────────── # + + def _build_tab_plc(self) -> QWidget: + g = self._make_group("PLC 설정 (MELSEC Q06UDEHCPU · MC 프로토콜 3E)") + form = QFormLayout(g) + form.setHorizontalSpacing(16) + form.setVerticalSpacing(12) + + self._plc_ip = QLineEdit() + self._plc_ip.setPlaceholderText("예: 192.168.3.39") + self._plc_ip.setFixedHeight(42) + + self._plc_port = QSpinBox() + self._plc_port.setRange(1, 65535) + self._plc_port.setFixedHeight(42) + + btn_connect = QPushButton("연결") + btn_connect.setFixedHeight(56) + btn_connect.setStyleSheet(_BTN_DLG) + btn_connect.clicked.connect(self._on_plc_connect) + + form.addRow("IP", self._plc_ip) + form.addRow("포트", self._plc_port) + form.addRow("", btn_connect) + return self._tab_wrap(g) + + def _on_plc_connect(self): + ip = self._plc_ip.text().strip() + port = self._plc_port.value() + log_action(f"[설정] PLC 연결 시도: {ip}:{port}") + if not ip: + QMessageBox.warning(self, "입력 오류", "IP 주소를 입력하세요.") + return + if self._sp._plc_client and self._sp._plc_client.is_connected(): + self._sp._plc_client.disconnect() + client = PLCClient() + ok = client.connect(ip, port) + if ok: + self._sp._plc_client = client + self._sp._set_plc_connected(True) + self._sp._save_config({"plc": {"ip": ip, "port": port}}) + self._sp._config.setdefault("plc", {}).update({"ip": ip, "port": port}) + if self._sp._update_plc_cb: + self._sp._update_plc_cb(client) + self._sp.plc_status_changed.emit(True) + QMessageBox.information(self, "연결 성공", f"PLC 연결 성공\n{ip}:{port}") + else: + QMessageBox.critical(self, "연결 실패", f"PLC에 연결할 수 없습니다.\n{ip}:{port}") + + # ── 하단 버튼 바 ─────────────────────────────────────────────────── # + + def _build_bottom_bar(self) -> QWidget: + bar = QWidget() + bar.setStyleSheet("background:#111111;") + bar.setFixedHeight(72) + row = QHBoxLayout(bar) + row.setContentsMargins(24, 8, 24, 8) + row.setSpacing(12) + + btn_save_all = QPushButton("전체 설정 저장") + btn_save_all.setFixedHeight(56) + btn_save_all.setStyleSheet(_BTN_DLG_PRIMARY) + btn_save_all.clicked.connect(self._on_save_all) + + btn_close = QPushButton("닫기") + btn_close.setFixedHeight(56) + btn_close.setStyleSheet(_BTN_DLG) + btn_close.clicked.connect(self.accept) + + row.addWidget(btn_save_all, stretch=2) + row.addWidget(btn_close, stretch=1) + return bar + + @staticmethod + def _make_group(title: str): + from PyQt5.QtWidgets import QGroupBox + g = QGroupBox(title) + g.setStyleSheet(_GRP) + return g + + # ── 필드 채우기 ──────────────────────────────────────────────────── # + + def _populate(self): + cfg = self._sp._config + + cognex = cfg.get("cognex", {}) + self._cognex_ip.setText(cognex.get("ip", "")) + self._cognex_port.setValue(cognex.get("port", 23)) + + basler = cfg.get("basler", {}) + self._basler_exposure.setValue(basler.get("exposure", 10000)) + self._basler_gain.setValue(basler.get("gain", 20)) + + conv = cfg.get("conveyor", {}) + self._conv_distance.setValue(conv.get("distance_cm", 100.0)) + self._conv_speed.setValue(conv.get("speed_cms", 30.0)) + self._on_conveyor_changed() + + db = cfg.get("db", {}) + self._db_server.setText(db.get("server", "")) + self._db_database.setText(db.get("database", "")) + self._db_username.setText(db.get("username", "")) + self._db_password.setText(db.get("password", "")) + + ai = cfg.get("ai", {}) + if "model_path" in ai: + self._ai_path_edit.setText(to_project_relative(ai["model_path"])) + + if self._sp._detector and self._sp._detector.is_loaded(): + names = ", ".join(self._sp._detector.class_names) + self._ai_info_lbl.setText( + f"클래스: {names} ({len(self._sp._detector.class_names)}개)" + ) + + plc = cfg.get("plc", {}) + self._plc_ip.setText(plc.get("ip", "")) + self._plc_port.setValue(plc.get("port", 5010)) + + # ── 코그넥스 탭 슬롯 ─────────────────────────────────────────────── # + + def _on_cognex_connect(self): + ip = self._cognex_ip.text().strip() + port = self._cognex_port.value() + log_action(f"[설정] Cognex 연결 시도: {ip}:{port}") + if not ip: + QMessageBox.warning(self, "입력 오류", "IP 주소를 입력하세요.") + return + if self._sp._insight and self._sp._insight._sock is not None: + self._sp._insight.disconnect() + new_cam = InSightCamera() + new_cam.connect(ip, port) + if new_cam._sock is None: + QMessageBox.critical( + self, "연결 실패", + f"In-Sight 카메라에 연결할 수 없습니다.\nIP: {ip} 포트: {port}", + ) + return + self._sp._insight = new_cam + if self._sp._update_insight_cb: + self._sp._update_insight_cb(new_cam) + self._sp._set_cognex_connected(True) + self._sp._save_cognex_config(ip, port) + self._sp.cognex_status_changed.emit(True) + QMessageBox.information(self, "연결 성공", f"In-Sight 카메라 연결 성공\n{ip}:{port}") + + def _on_cognex_save(self): + ip = self._cognex_ip.text().strip() + port = self._cognex_port.value() + self._sp._save_cognex_config(ip, port) + QMessageBox.information(self, "저장", "코그넥스 설정이 저장되었습니다.") + + # ── Basler 탭 슬롯 ───────────────────────────────────────────────── # + + def _on_basler_apply(self): + log_action(f"[설정] Basler 설정 적용: 노출={self._basler_exposure.value()}µs 게인={self._basler_gain.value()}") + if not self._sp._basler or self._sp._basler.camera is None: + QMessageBox.warning(self, "경고", "Basler 카메라가 연결되어 있지 않습니다.") + return + exposure = self._basler_exposure.value() + gain = self._basler_gain.value() + errors = [] + try: + self._sp._basler.camera.ExposureAuto.SetValue("Off") + self._sp._basler.camera.ExposureTime.SetValue(float(exposure)) + except Exception as e: + errors.append(f"노출: {e}") + try: + self._sp._basler.camera.Gain.SetValue(float(gain)) + except Exception as e: + errors.append(f"게인: {e}") + if errors: + QMessageBox.warning(self, "설정 적용 오류", "\n".join(errors)) + else: + self._sp._save_config({"basler": {"exposure": exposure, "gain": gain}}) + self._sp._config.setdefault("basler", {}).update( + {"exposure": exposure, "gain": gain} + ) + QMessageBox.information(self, "적용 완료", f"노출: {exposure} µs 게인: {gain}") + + # ── DB 탭 슬롯 ───────────────────────────────────────────────────── # + + def _on_db_connect(self): + server = self._db_server.text().strip() + database = self._db_database.text().strip() + username = self._db_username.text().strip() + password = self._db_password.text() + log_action(f"[설정] DB 연결 시도: {server}/{database}") + if not server or not database: + QMessageBox.warning(self, "경고", "서버와 DB명을 입력해주세요.") + return + client = SQLClient() + ok = client.connect(server, database, username, password) + if ok: + self._sp._db_client = client + self._sp._set_db_connected(True) + cfg = {"server": server, "database": database, + "username": username, "password": password} + self._sp._save_config({"db": cfg}) + self._sp._config.setdefault("db", {}).update(cfg) + if self._sp._update_db_cb: + self._sp._update_db_cb(client) + QMessageBox.information(self, "연결 성공", f"DB 연결 성공\n{server}/{database}") + else: + QMessageBox.critical( + self, "연결 실패", + "DB 연결에 실패했습니다.\n서버 주소, DB명, 계정 정보를 확인하세요.", + ) + + def _on_db_save(self): + cfg = { + "server": self._db_server.text().strip(), + "database": self._db_database.text().strip(), + "username": self._db_username.text().strip(), + "password": self._db_password.text(), + } + self._sp._save_config({"db": cfg}) + self._sp._config.setdefault("db", {}).update(cfg) + QMessageBox.information(self, "저장", "DB 설정이 저장되었습니다.") + + # ── AI 탭 슬롯 ───────────────────────────────────────────────────── # + + def _on_ai_browse(self): + path, _ = QFileDialog.getOpenFileName( + self, "YOLOv8 모델 선택", "", "YOLOv8 모델 (*.pt)" + ) + if path: + self._ai_path_edit.setText(to_project_relative(path)) + + def _on_ai_load(self): + path = self._ai_path_edit.text().strip() + log_action(f"[설정] AI 모델 로드: {path}") + if not path: + QMessageBox.warning(self, "경고", "모델 경로를 먼저 선택하세요.") + return + ok = self._sp._do_load_model(path) + if ok: + names = ", ".join(self._sp._detector.class_names) + self._ai_info_lbl.setText( + f"클래스: {names} ({len(self._sp._detector.class_names)}개)" + ) + self._sp._set_ai_loaded(True) + QMessageBox.information(self, "로드 완료", f"모델 로드 완료\n{path}") + else: + self._sp._set_ai_loaded(False) + QMessageBox.critical(self, "로드 실패", f"모델을 로드할 수 없습니다.\n{path}") + + def _on_ai_unload(self): + log_action("[설정] AI 모델 해제") + if self._sp._detector: + self._sp._detector._model = None + self._sp._detector.model_path = None + self._sp._set_ai_loaded(False) + self._ai_info_lbl.setText("") + + # ── 컨베이어 탭 슬롯 ─────────────────────────────────────────────── # + + def _on_conveyor_changed(self): + dist = self._conv_distance.value() + speed = self._conv_speed.value() + self._conv_delay_lbl.setText(f"{dist / speed:.2f} s" if speed > 0 else "—") + + def _on_conveyor_apply(self): + dist = self._conv_distance.value() + speed = self._conv_speed.value() + delay = dist / speed if speed > 0 else 0.0 + log_action(f"[설정] 컨베이어 적용: {dist}cm / {speed}cm·s → 딜레이 {delay:.2f}s") + try: + self._sp._save_config({"conveyor": {"distance_cm": dist, "speed_cms": speed}}) + self._sp._config.setdefault("conveyor", {}).update( + {"distance_cm": dist, "speed_cms": speed} + ) + self._sp.belt_settings_changed.emit(delay) + QMessageBox.information( + self, "적용 완료", + f"거리: {dist} cm / 속도: {speed} cm/s\n딜레이: {delay:.2f}s", + ) + except Exception as e: + QMessageBox.critical(self, "저장 실패", str(e)) + + # ── 전체 저장 ────────────────────────────────────────────────────── # + + def _on_save_all(self): + log_action("[설정] 전체 설정 저장") + data = { + "cognex": { + "ip": self._cognex_ip.text().strip(), + "port": self._cognex_port.value(), + }, + "basler": { + "exposure": self._basler_exposure.value(), + "gain": self._basler_gain.value(), + }, + "db": { + "server": self._db_server.text().strip(), + "database": self._db_database.text().strip(), + "username": self._db_username.text().strip(), + "password": self._db_password.text(), + }, + "ai": { + "model_path": to_project_relative(self._ai_path_edit.text().strip()), + }, + "conveyor": { + "distance_cm": self._conv_distance.value(), + "speed_cms": self._conv_speed.value(), + }, + "plc": { + "ip": self._plc_ip.text().strip(), + "port": self._plc_port.value(), + }, + } + try: + self._sp._save_config(data) + for k, v in data.items(): + if isinstance(self._sp._config.get(k), dict): + self._sp._config[k].update(v) + else: + self._sp._config[k] = v + QMessageBox.information(self, "저장 완료", "전체 설정이 저장되었습니다.") + except Exception as e: + QMessageBox.critical(self, "저장 실패", str(e)) + + +# ══════════════════════════════════════════════════════════════════════ # +# SettingsPage (작업자 메인 화면) +# ══════════════════════════════════════════════════════════════════════ # + +class SettingsPage(QWidget): + cognex_status_changed = pyqtSignal(bool) + basler_status_changed = pyqtSignal(bool) + belt_settings_changed = pyqtSignal(float) + plc_status_changed = pyqtSignal(bool) + + def __init__(self, insight_cam, basler_cam, config: dict, + detector=None, update_insight_cb=None, + update_basler_cb=None, update_detector_cb=None, + update_db_cb=None, update_plc_cb=None, + plc_client=None, parent=None): + super().__init__(parent) + self._insight = insight_cam + self._basler = basler_cam + self._config = config + self._detector = detector + self._update_insight_cb = update_insight_cb + self._update_basler_cb = update_basler_cb + self._update_detector_cb = update_detector_cb + self._update_db_cb = update_db_cb + self._update_plc_cb = update_plc_cb + self._db_client = None + self._plc_client = plc_client + + self._build_ui() + self._sync_connection_status() + self._auto_load_model() + + # ── UI 구성 ──────────────────────────────────────────────────────── # + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(40, 32, 40, 32) + root.setSpacing(0) + + # 상단 행: 페이지 제목 + 관리자 버튼 + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 16) + + title_lbl = QLabel("연결 상태") + title_lbl.setStyleSheet("color:#888888; font-size:14px;") + top_row.addWidget(title_lbl) + top_row.addStretch() + + admin_btn = QPushButton("⚙ 관리자 설정") + admin_btn.setFixedHeight(40) + admin_btn.setStyleSheet(_BTN_ADMIN) + admin_btn.clicked.connect(self._on_admin_settings) + top_row.addWidget(admin_btn) + + root.addLayout(top_row) + + # 2×2 카드 그리드 — 카드 고정 너비 500px, 왼쪽 정렬 + self._cognex_card = self._build_card( + "코그넥스 In-Sight 2000C", + icon_color="#042C53", icon_text="IN", + connect_cb=self._on_cognex_connect_quick, + disconnect_cb=self._on_cognex_disconnect, + ) + self._basler_card = self._build_card( + "Basler USB 카메라", + icon_color="#26215C", icon_text="BA", + connect_cb=self._on_basler_connect, + disconnect_cb=self._on_basler_disconnect, + ) + self._db_card = self._build_card( + "MS SQL Server DB", + icon_color="#412402", icon_text="DB", + connect_cb=self._on_db_connect_quick, + disconnect_cb=self._on_db_disconnect, + ) + self._ai_card = self._build_card( + "AI 모델 (YOLOv8)", + icon_color="#085041", icon_text="AI", + connect_label="로드", + disconnect_label="해제", + connect_cb=self._on_ai_load_quick, + disconnect_cb=self._on_ai_unload, + ) + self._plc_card = self._build_card( + "PLC (MELSEC Q06UDEHCPU)", + icon_color="#3B1A04", icon_text="PLC", + connect_cb=self._on_plc_connect_quick, + disconnect_cb=self._on_plc_disconnect, + ) + + grid = QGridLayout() + grid.setSpacing(10) + grid.setContentsMargins(0, 0, 0, 0) + grid.addWidget(self._cognex_card["frame"], 0, 0) + grid.addWidget(self._basler_card["frame"], 0, 1) + grid.addWidget(self._db_card["frame"], 1, 0) + grid.addWidget(self._ai_card["frame"], 1, 1) + grid.addWidget(self._plc_card["frame"], 2, 0) + + # 그리드를 왼쪽 정렬로 감싸는 컨테이너 + grid_container = QWidget() + grid_container.setLayout(grid) + + outer = QHBoxLayout() + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(grid_container) + outer.addStretch() + + root.addLayout(outer) + root.addStretch() + + def _build_card(self, title: str, + icon_color: str = "#333333", icon_text: str = "", + connect_label: str = "연결", + disconnect_label: str = "연결 해제", + connect_cb=None, disconnect_cb=None) -> dict: + frame = QFrame() + frame.setStyleSheet(_CARD) + frame.setFixedSize(500, 96) + + row = QHBoxLayout(frame) + row.setContentsMargins(16, 0, 16, 0) + row.setSpacing(14) + + # 아이콘 박스 + icon = QLabel(icon_text) + icon.setFixedSize(36, 36) + icon.setAlignment(Qt.AlignCenter) + icon.setStyleSheet( + f"background:{icon_color}; color:#ffffff;" + "border-radius:6px; font-size:13px; font-weight:500;" + ) + + # 제목 + 상태 (세로 스택) + info_w = QWidget() + info_w.setStyleSheet("background:transparent;") + info_v = QVBoxLayout(info_w) + info_v.setContentsMargins(0, 0, 0, 0) + info_v.setSpacing(2) + + title_lbl = QLabel(title) + title_lbl.setStyleSheet( + "color:#cccccc; font-size:14px; background:transparent; border:none;" + ) + + status_lbl = QLabel("● 연결 안됨") + status_lbl.setStyleSheet(_STATUS_FAIL) + + info_v.addWidget(title_lbl) + info_v.addWidget(status_lbl) + + # 버튼 쌍 + btn_connect = QPushButton(connect_label) + btn_connect.setFixedHeight(43) + btn_connect.setStyleSheet(_BTN_CARD_CONNECT) + if connect_cb: + btn_connect.clicked.connect(connect_cb) + + btn_disconnect = QPushButton(disconnect_label) + btn_disconnect.setFixedHeight(43) + btn_disconnect.setStyleSheet(_BTN_CARD_DISCONNECT) + btn_disconnect.setEnabled(False) + if disconnect_cb: + btn_disconnect.clicked.connect(disconnect_cb) + + row.addWidget(icon) + row.addWidget(info_w, stretch=1) + row.addWidget(btn_connect) + row.addWidget(btn_disconnect) + + return { + "frame": frame, + "status_lbl": status_lbl, + "btn_connect": btn_connect, + "btn_disconnect": btn_disconnect, + } + + # ── 관리자 설정 ──────────────────────────────────────────────────── # + + def _on_admin_settings(self): + dlg = PasswordDialog(self) + if dlg.exec_() == QDialog.Accepted: + AdminSettingsDialog(self, self).exec_() + + # ── 연결 상태 동기화 (외부에서도 호출 가능) ──────────────────────── # + + def _sync_connection_status(self): + self._set_cognex_connected(bool(self._insight and self._insight.is_connected())) + self._set_basler_connected(bool(self._basler and self._basler.is_connected())) + self._set_db_connected(bool(self._db_client and self._db_client.is_connected())) + if self._detector: + self._set_ai_loaded(self._detector.is_loaded()) + self._set_plc_connected(bool(self._plc_client and self._plc_client.is_connected())) + + def _set_card_state(self, card: dict, connected: bool, + ok_text: str, fail_text: str): + if connected: + card["status_lbl"].setText(f"● {ok_text}") + card["status_lbl"].setStyleSheet(_STATUS_OK) + else: + card["status_lbl"].setText(f"● {fail_text}") + card["status_lbl"].setStyleSheet(_STATUS_FAIL) + card["btn_connect"].setEnabled(not connected) + card["btn_disconnect"].setEnabled(connected) + + def _set_cognex_connected(self, connected: bool): + self._set_card_state(self._cognex_card, connected, "연결됨", "연결 안됨") + + def _set_basler_connected(self, connected: bool): + self._set_card_state(self._basler_card, connected, "연결됨", "연결 안됨") + + def _set_db_connected(self, connected: bool): + self._set_card_state(self._db_card, connected, "연결됨", "연결 안됨") + + def _set_ai_loaded(self, loaded: bool): + self._set_card_state(self._ai_card, loaded, "로드됨", "로드 안됨") + + def _set_plc_connected(self, connected: bool): + self._set_card_state(self._plc_card, connected, "연결됨", "연결 안됨") + + # ── 빠른 연결 (카드 버튼 — config.json 값 그대로 사용) ──────────── # + + def _on_cognex_connect_quick(self): + cfg = self._config.get("cognex", {}) + ip = cfg.get("ip", "").strip() + port = cfg.get("port", 23) + log_action(f"[설정] Cognex 연결: {ip}:{port}") + if not ip: + QMessageBox.warning( + self, "설정 필요", + "관리자 설정에서 코그넥스 IP를 먼저 설정해주세요.", + ) + return + if self._insight and self._insight._sock is not None: + self._insight.disconnect() + new_cam = InSightCamera() + new_cam.connect(ip, port) + if new_cam._sock is None: + QMessageBox.critical( + self, "연결 실패", + f"In-Sight 카메라에 연결할 수 없습니다.\n{ip}:{port}", + ) + return + self._insight = new_cam + if self._update_insight_cb: + self._update_insight_cb(new_cam) + self._set_cognex_connected(True) + self.cognex_status_changed.emit(True) + + def _on_cognex_disconnect(self): + log_action("[설정] Cognex 연결 해제") + if self._insight: + self._insight.disconnect() + self._set_cognex_connected(False) + self.cognex_status_changed.emit(False) + + def _on_basler_connect(self): + log_action("[설정] Basler 연결") + if self._basler and self._basler.camera is not None: + self._basler.disconnect() + new_cam = BaslerCamera() + new_cam.connect() + if new_cam.camera is None: + QMessageBox.critical( + self, "연결 실패", + "Basler 카메라에 연결할 수 없습니다.\nUSB 연결 및 전원을 확인하세요.", + ) + return + self._basler = new_cam + if self._update_basler_cb: + self._update_basler_cb(new_cam) + self._set_basler_connected(True) + self.basler_status_changed.emit(True) + + def _on_basler_disconnect(self): + log_action("[설정] Basler 연결 해제") + if self._basler: + self._basler.disconnect() + self._set_basler_connected(False) + self.basler_status_changed.emit(False) + + def _on_db_connect_quick(self): + cfg = self._config.get("db", {}) + server = cfg.get("server", "").strip() + log_action(f"[설정] DB 연결: {server}") + database = cfg.get("database", "").strip() + username = cfg.get("username", "").strip() + password = cfg.get("password", "") + if not server or not database: + QMessageBox.warning( + self, "설정 필요", + "관리자 설정에서 DB 서버 정보를 먼저 설정해주세요.", + ) + return + client = SQLClient() + ok = client.connect(server, database, username, password) + if ok: + self._db_client = client + self._set_db_connected(True) + if self._update_db_cb: + self._update_db_cb(client) + else: + QMessageBox.critical( + self, "연결 실패", + "DB 연결에 실패했습니다.\n관리자 설정에서 서버 정보를 확인하세요.", + ) + + def _on_db_disconnect(self): + log_action("[설정] DB 연결 해제") + if self._db_client: + self._db_client.disconnect() + self._db_client = None + if self._update_db_cb: + self._update_db_cb(None) + self._set_db_connected(False) + + def _on_ai_load_quick(self): + path = self._config.get("ai", {}).get("model_path", "").strip() + log_action(f"[설정] AI 모델 로드: {path}") + if not path: + QMessageBox.warning( + self, "설정 필요", + "관리자 설정에서 AI 모델 경로를 먼저 설정해주세요.", + ) + return + ok = self._do_load_model(path) + if ok: + self._set_ai_loaded(True) + else: + QMessageBox.critical(self, "로드 실패", f"모델을 로드할 수 없습니다.\n{path}") + + def _on_ai_unload(self): + log_action("[설정] AI 모델 해제") + if self._detector: + self._detector._model = None + self._detector.model_path = None + self._set_ai_loaded(False) + + def _on_plc_connect_quick(self): + cfg = self._config.get("plc", {}) + ip = cfg.get("ip", "").strip() + port = cfg.get("port", 5010) + log_action(f"[설정] PLC 연결: {ip}:{port}") + if not ip: + QMessageBox.warning( + self, "설정 필요", + "관리자 설정에서 PLC IP를 먼저 설정해주세요.", + ) + return + client = PLCClient() + ok = client.connect(ip, port) + if ok: + self._plc_client = client + self._set_plc_connected(True) + if self._update_plc_cb: + self._update_plc_cb(client) + self.plc_status_changed.emit(True) + else: + QMessageBox.critical( + self, "연결 실패", + f"PLC에 연결할 수 없습니다.\n{ip}:{port}", + ) + + def _on_plc_disconnect(self): + log_action("[설정] PLC 연결 해제") + if self._plc_client: + self._plc_client.disconnect() + self._plc_client = None + if self._update_plc_cb: + self._update_plc_cb(None) + self._set_plc_connected(False) + self.plc_status_changed.emit(False) + + # ── AI 모델 로드 공통 ────────────────────────────────────────────── # + + def _do_load_model(self, path: str) -> bool: + if self._detector is None: + self._detector = Detector() + abs_path = resolve_path(path) + ok = self._detector.load_model(abs_path) + if ok: + saved_path = to_project_relative(abs_path) + self._save_config({"ai": {"model_path": saved_path}}) + self._config.setdefault("ai", {})["model_path"] = saved_path + if self._update_detector_cb: + self._update_detector_cb(self._detector) + return ok + + def _auto_load_model(self): + path = self._config.get("ai", {}).get("model_path", "") + abs_path = resolve_path(path) + if abs_path and os.path.exists(abs_path): + ok = self._do_load_model(abs_path) + if ok: + self._set_ai_loaded(True) + + # ── config 읽기 / 쓰기 ───────────────────────────────────────────── # + + def _load_config(self) -> dict: + try: + with open(get_path("config.json"), "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + def _save_config(self, data: dict): + cfg_path = get_path("config.json") + try: + with open(cfg_path, "r", encoding="utf-8") as f: + existing = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + existing = {} + for key, val in data.items(): + if isinstance(val, dict) and isinstance(existing.get(key), dict): + existing[key].update(val) + else: + existing[key] = val + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(existing, f, ensure_ascii=False, indent=2) + + def _save_cognex_config(self, ip: str, port: int): + try: + self._save_config({"cognex": {"ip": ip, "port": port}}) + self._config.setdefault("cognex", {}).update({"ip": ip, "port": port}) + except Exception as e: + print(f"[설정] config.json 저장 실패: {e}") diff --git a/gui/splash_screen.py b/gui/splash_screen.py new file mode 100644 index 0000000..fc43ead --- /dev/null +++ b/gui/splash_screen.py @@ -0,0 +1,182 @@ +import json +import os + +from PyQt5.QtWidgets import QWidget, QLabel, QProgressBar, QVBoxLayout, QApplication +from PyQt5.QtCore import Qt, QThread, pyqtSignal +from PyQt5.QtGui import QPainter, QPainterPath, QColor + + +class SplashScreen(QWidget): + def __init__(self): + super().__init__() + self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setFixedSize(500, 300) + + screen = QApplication.primaryScreen().geometry() + self.move( + (screen.width() - self.width()) // 2, + (screen.height() - self.height()) // 2, + ) + self._build_ui() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(40, 40, 40, 30) + layout.setSpacing(0) + + title = QLabel("리플렉터 검사 시스템") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet( + "color:#ffffff; font-size:22px; font-weight:bold; background:transparent;" + ) + + subtitle = QLabel("Reflector Inspection System") + subtitle.setAlignment(Qt.AlignCenter) + subtitle.setStyleSheet("color:#555555; font-size:12px; background:transparent;") + + layout.addStretch(2) + layout.addWidget(title) + layout.addSpacing(6) + layout.addWidget(subtitle) + layout.addStretch(3) + + self.status_label = QLabel("초기화 중...") + self.status_label.setAlignment(Qt.AlignCenter) + self.status_label.setStyleSheet( + "color:#888888; font-size:13px; background:transparent;" + ) + layout.addWidget(self.status_label) + layout.addSpacing(10) + + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setFixedHeight(6) + self.progress_bar.setTextVisible(False) + self.progress_bar.setStyleSheet(""" + QProgressBar { + background: #2a2a2a; + border: none; + border-radius: 3px; + } + QProgressBar::chunk { + background: #1D9E75; + border-radius: 3px; + } + """) + layout.addWidget(self.progress_bar) + layout.addStretch(1) + + version = QLabel("v1.0.0") + version.setAlignment(Qt.AlignCenter) + version.setStyleSheet("color:#333333; font-size:11px; background:transparent;") + layout.addWidget(version) + + def update_progress(self, value: int, message: str): + self.progress_bar.setValue(value) + self.status_label.setText(message) + QApplication.processEvents() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + path = QPainterPath() + path.addRoundedRect(0.0, 0.0, float(self.width()), float(self.height()), 12.0, 12.0) + painter.fillPath(path, QColor("#0d0d0d")) + + +class InitWorker(QThread): + progress = pyqtSignal(int, str) # (진행률, 메시지) + finished = pyqtSignal(object) # 초기화 결과 dict + + def run(self): + from utils.path_helper import get_path + from camera.insight import InSightCamera + from camera.basler import BaslerCamera + from ai.detector import Detector + from db.sql_client import SQLClient + + results = {} + + # ── 1단계: 설정 로드 ──────────────────────────────────────────── # + self.progress.emit(10, "설정 불러오는 중...") + try: + with open(get_path("config.json"), encoding="utf-8") as f: + config = json.load(f) + except Exception as e: + print(f"[InitWorker] 설정 로드 실패: {e}") + config = {} + results["config"] = config + + # ── 2단계: 코그넥스 카메라 ────────────────────────────────────── # + self.progress.emit(25, "코그넥스 카메라 연결 중...") + insight = InSightCamera() + try: + cognex_cfg = config.get("cognex", {}) + ip = cognex_cfg.get("ip", "") + port = cognex_cfg.get("port", 23) + if ip: + insight.connect(ip, port) + except Exception as e: + print(f"[InitWorker] 코그넥스 연결 실패: {e}") + results["insight"] = insight + + # ── 3단계: Basler 카메라 ──────────────────────────────────────── # + self.progress.emit(45, "Basler 카메라 연결 중...") + basler = BaslerCamera() + try: + basler.connect() + except Exception as e: + print(f"[InitWorker] Basler 연결 실패: {e}") + results["basler"] = basler + + # ── 4단계: AI 모델 로드 ───────────────────────────────────────── # + self.progress.emit(65, "AI 모델 로드 중...") + detector = Detector() + try: + from paths import resolve_path + model_path = config.get("ai", {}).get("model_path", "") + if model_path: + abs_path = resolve_path(model_path) + if abs_path and os.path.exists(abs_path): + detector.load_model(abs_path) + except Exception as e: + print(f"[InitWorker] AI 모델 로드 실패: {e}") + results["detector"] = detector + + # ── 5단계: DB 연결 ────────────────────────────────────────────── # + self.progress.emit(80, "DB 연결 중...") + db_client = SQLClient() + try: + db_cfg = config.get("db", {}) + if db_cfg.get("server"): + db_client.connect( + db_cfg["server"], + db_cfg["database"], + db_cfg["username"], + db_cfg["password"], + ) + except Exception as e: + print(f"[InitWorker] DB 연결 실패: {e}") + results["db"] = db_client + + # ── 6단계: PLC 연결 ───────────────────────────────────────────── # + self.progress.emit(92, "PLC 연결 중...") + plc_client = None + try: + from plc.plc_client import PLCClient + plc_cfg = config.get("plc", {}) + ip = plc_cfg.get("ip", "").strip() + port = plc_cfg.get("port", 5010) + if ip: + client = PLCClient() + if client.connect(ip, port): + plc_client = client + except Exception as e: + print(f"[InitWorker] PLC 연결 실패: {e}") + results["plc"] = plc_client + + # ── 완료 ──────────────────────────────────────────────────────── # + self.progress.emit(100, "시작 중...") + self.finished.emit(results) diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..6d5b01c --- /dev/null +++ b/logger.py @@ -0,0 +1,237 @@ +# 로그 시스템 — 앱 로그(텍스트) + 검사 결과(CSV) + 학습 이력(텍스트) +# 폴더 구조: logs//. +import builtins +import csv +import os +import sys +from datetime import datetime +from pathlib import Path + +from paths import PROJECT_ROOT + +LOGS_ROOT = Path(PROJECT_ROOT) / "logs" +_BORDER = "=" * 60 + +_orig_print = builtins.print +_session_start: datetime | None = None +_ACTION_LINE = "─" * 64 + + +# ───────────────────────────── 경로 헬퍼 ───────────────────────────── # + +def _date_path(category: str, ext: str = "log") -> Path: + """logs//. 경로 (폴더 자동 생성).""" + now = datetime.now() + folder = LOGS_ROOT / category + folder.mkdir(parents=True, exist_ok=True) + return folder / f"{now:%Y-%m-%d}.{ext}" + + +def _append_text(path: Path, line: str): + with open(path, "a", encoding="utf-8") as f: + if not line.endswith("\n"): + line += "\n" + f.write(line) + + +def _safe_write_app(line: str): + try: + _append_text(_date_path("app"), line) + except Exception as e: + _orig_print(f"[logger] 파일 쓰기 실패: {e}", file=sys.stderr) + + +# ───────────────────────────── 세션 헤더 ───────────────────────────── # + +def _previous_session_crashed() -> bool: + """오늘 로그 파일에서 마지막 '세션 시작' 이후 '세션 종료'가 없으면 크래시로 간주.""" + today = _date_path("app") + if not today.exists(): + return False + try: + with open(today, "rb") as f: + try: + f.seek(-4096, os.SEEK_END) + except OSError: + f.seek(0) + tail = f.read().decode("utf-8", errors="ignore") + last_start = tail.rfind("=== 세션 시작") + last_end = tail.rfind("=== 세션 종료") + return last_start > last_end + except Exception: + return False + + +def setup_logging(): + """앱 시작 시 호출. print 가로채기 + 세션 시작 헤더 기록.""" + global _session_start + + crashed = _previous_session_crashed() + _session_start = datetime.now() + pid = os.getpid() + + header = [ + "", + _BORDER, + f"=== 세션 시작 : {_session_start:%Y-%m-%d %H:%M:%S} PID {pid}", + ] + if crashed: + header.append("=== ⚠ 직전 세션 비정상 종료 감지 ===") + header.append(_BORDER) + for line in header: + _safe_write_app(line) + + def _ts_print(*args, sep=" ", end="\n", file=None, flush=False, **kwargs): + now = datetime.now() + msg = sep.join(str(a) for a in args) + # 콘솔: [HH:MM:SS] + _orig_print( + f"[{now:%H:%M:%S}]", *args, + sep=sep, end=end, file=file, flush=flush, **kwargs, + ) + # 파일: 풀 시각 + 메시지 + _safe_write_app(f"{now:%Y-%m-%d %H:%M:%S.%f}"[:-3] + f" {msg}") + + builtins.print = _ts_print + + +def teardown_logging(): + """앱 정상 종료 시 호출. 세션 종료 헤더 기록.""" + global _session_start + if _session_start is None: + return + end = datetime.now() + secs = int((end - _session_start).total_seconds()) + h, rem = divmod(secs, 3600) + m, s = divmod(rem, 60) + + for line in [ + _BORDER, + f"=== 세션 종료 : {end:%Y-%m-%d %H:%M:%S} 운영시간 {h:02d}:{m:02d}:{s:02d}", + _BORDER, + "", + ]: + _safe_write_app(line) + _session_start = None + + +# ───────────────────────────── 사용자 액션 ───────────────────────────── # + +def log_action(msg: str): + """버튼 클릭·기능 실행 등 사용자 액션을 구분선으로 강조해 app 로그에 기록.""" + _safe_write_app(_ACTION_LINE) + print(f"▶ {msg}") + _safe_write_app(_ACTION_LINE) + + +# ───────────────────────────── 검사 결과 CSV ───────────────────────────── # + +_INSPECT_HEADER = [ + "timestamp", "group", "result", + "cognex_pass", "basler_pass", "detected_models", +] + + +def log_inspect_result( + group: str, + result: str, + cognex_pass: bool | None = None, + basler_pass: bool | None = None, + detected: list[str] | None = None, +): + """검사 결과 1건을 logs/inspect/YYYY-MM-DD.csv 에 append. + 파일 신규일 때만 헤더 행 작성. utf-8-sig 로 Excel 호환.""" + csv_path = _date_path("inspect", ext="csv") + is_new = not csv_path.exists() + try: + with open(csv_path, "a", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + if is_new: + w.writerow(_INSPECT_HEADER) + w.writerow([ + f"{datetime.now():%Y-%m-%d %H:%M:%S}", + group, + result, + "" if cognex_pass is None else ("Y" if cognex_pass else "N"), + "" if basler_pass is None else ("Y" if basler_pass else "N"), + ",".join(detected) if detected else "", + ]) + except Exception as e: + _orig_print(f"[logger] 검사 CSV 기록 실패: {e}", file=sys.stderr) + + +# ───────────────────────────── 카메라 타이밍 CSV ───────────────────────────── # + +_TIMING_HEADER = ["timestamp", "seq", "event", "elapsed_ms", "detail"] +_timing_lock = __import__("threading").Lock() + + +def log_camera_timing(seq: int, event: str, elapsed_ms: float, detail: str = ""): + """카메라 촬영 타이밍 1이벤트를 logs/timing/YYYY-MM-DD.csv 에 append. + Cognex/Basler 서브스레드에서 호출되므로 Lock으로 동시 쓰기 보호.""" + csv_path = _date_path("timing", ext="csv") + is_new = not csv_path.exists() + try: + with _timing_lock: + with open(csv_path, "a", encoding="utf-8-sig", newline="") as f: + w = csv.writer(f) + if is_new: + w.writerow(_TIMING_HEADER) + w.writerow([ + f"{datetime.now():%H:%M:%S.%f}"[:-3], + seq, + event, + f"{elapsed_ms:.1f}", + detail, + ]) + except Exception as e: + _orig_print(f"[logger] 타이밍 CSV 기록 실패: {e}", file=sys.stderr) + + +# ───────────────────────────── 학습 로그 ───────────────────────────── # + +def log_train(message: str): + """재학습 이벤트 1건을 logs/train/YYYY/MM/YYYY-MM-DD.log 에 append.""" + line = f"{datetime.now():%Y-%m-%d %H:%M:%S} {message}" + try: + _append_text(_date_path("train"), line) + except Exception as e: + _orig_print(f"[logger] 학습 로그 기록 실패: {e}", file=sys.stderr) + + +# ───────────────────────────── 불량 이미지 저장 ───────────────────────────── # + +def log_defect_image(image: "np.ndarray", defects: list) -> bool: + """불량 감지 이미지를 logs/defect/YYYY-MM-DD/HHMMSS_SSS_{클래스}.jpg 에 저장. + 반환: 저장 성공 여부. + """ + import cv2 + import numpy as np # noqa: F401 — type annotation용 + + now = datetime.now() + folder = LOGS_ROOT / "defect" / now.strftime("%Y-%m-%d") + try: + folder.mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"[logger] 불량 이미지 폴더 생성 실패: {e} 경로: {folder}") + return False + + # 중복 제거 후 순서 유지한 클래스명 목록 + seen = set() + names = [ + d["class_name"] for d in defects + if not (d["class_name"] in seen or seen.add(d["class_name"])) + ] + ms = now.microsecond // 1000 + filename = f"{now:%H%M%S}_{ms:03d}_{'_'.join(names)}.jpg" + path = folder / filename + + try: + ok = cv2.imwrite(str(path), image) + if not ok: + print(f"[logger] 불량 이미지 저장 실패 (imwrite 반환 False): {path}") + return False + return True + except Exception as e: + print(f"[logger] 불량 이미지 저장 예외: {e} 경로: {path}") + return False diff --git a/logic/__init__.py b/logic/__init__.py new file mode 100644 index 0000000..41f1707 --- /dev/null +++ b/logic/__init__.py @@ -0,0 +1,3 @@ +# logic 패키지 — Inspector, GroupManager 노출 +from .inspector import Inspector +from .group_manager import GroupManager diff --git a/logic/group_manager.py b/logic/group_manager.py new file mode 100644 index 0000000..ce9d44a --- /dev/null +++ b/logic/group_manager.py @@ -0,0 +1,25 @@ +# 그룹 관리 — A/B 모델 그룹 수동 전환 (최대 4종 per group) +class GroupManager: + MAX_PER_GROUP = 4 + + def __init__(self): + self._group_a: list = [] + self._group_b: list = [] + self._active: str = "A" + + def set_group_a(self, model_list: list): + self._group_a = model_list[: self.MAX_PER_GROUP] + + def set_group_b(self, model_list: list): + self._group_b = model_list[: self.MAX_PER_GROUP] + + def get_active_group(self) -> list: + return self._group_a if self._active == "A" else self._group_b + + def get_active_name(self) -> str: + return self._active + + def switch_group(self) -> str: + """A↔B 전환 후 활성 그룹 이름 반환""" + self._active = "B" if self._active == "A" else "A" + return self._active diff --git a/logic/inspector.py b/logic/inspector.py new file mode 100644 index 0000000..f816cdb --- /dev/null +++ b/logic/inspector.py @@ -0,0 +1,120 @@ +# 검사 판별 로직 — PatMax 결과 판독 + 모델 판별 + Pass/Fail 판정 +import cv2 +import numpy as np + +# Cognex 카메라 셀 매핑 (GV 방식 fallback용으로 유지) +PATTERN_RESULT_CELLS = { + "A27": {"id": 1, "name": "LOW REF", "model": "LX3", "type": "RH"}, + "A77": {"id": 2, "name": "LOW REF", "model": "LX3", "type": "LH"}, + "A127": {"id": 3, "name": "LOW REF NAS", "model": "LX3", "type": "RH"}, + "A177": {"id": 4, "name": "LOW REF NAS", "model": "LX3", "type": "LH"}, +} + + +class Inspector: + + # ── Python PatMax 매칭 (주 경로) ─────────────────────────────────── # + + def match_image(self, image_bytes: bytes, matcher: "PatternMatcher") -> dict: + """ + FTP로 받은 이미지 바이트를 Python PatternMatcher로 매칭. + 반환 형식은 read_patmax_results와 동일하여 identify_model에서 그대로 사용. + """ + if not image_bytes: + return {} + + arr = np.frombuffer(image_bytes, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED) + if img is None: + return {} + + all_scores = matcher.match_all(img) + results = {} + + for pid, score in all_scores.items(): + info = matcher.get_product_info(pid) + if info is None: + continue + results[f"PY_{pid}"] = { + "matched": score >= matcher.score_threshold, + "score": score, + "model": info, + "raw": f"python_match={score:.1f}", + } + + return results + + # ── Cognex GV 셀 방식 (fallback) ────────────────────────────────── # + + def read_patmax_results(self, insight) -> dict: + """A27/A77/A127/A177 셀 조회 → #ERR이면 실패, 그 외 점수 파싱.""" + results = {} + for cell, model_info in PATTERN_RESULT_CELLS.items(): + try: + insight._send(f"GV{cell}") + code = insight._read_line() + if code != "1": + results[cell] = { + "matched": False, "score": 0.0, + "model": model_info, "raw": "" + } + continue + value = insight._read_line() + if "#ERR" in value or value.strip() == "": + results[cell] = { + "matched": False, "score": 0.0, + "model": model_info, "raw": value + } + else: + # "(736.1,742.0) -1.8 = 82.9" 형식에서 = 뒤 값 추출 + try: + score = float(value.split("=")[-1].strip()) + except Exception: + score = 0.0 + results[cell] = { + "matched": True, "score": score, + "model": model_info, "raw": value + } + except Exception as e: + print(f"[PatMax] {cell} 읽기 오류: {e}") + results[cell] = { + "matched": False, "score": 0.0, + "model": model_info, "raw": "" + } + return results + + # ── 공통: 모델 판별 + 판정 ──────────────────────────────────────── # + + def identify_model(self, results: dict, allowed_model_ids: list) -> dict: + """매칭된 패턴 중 점수가 가장 높은 것을 선택해 허용 모델 여부 판별.""" + matched_patterns = [ + (cell, info) for cell, info in results.items() + if info["matched"] + ] + + if not matched_patterns: + return { + "matched": False, "in_allowed": False, + "model": None, "score": 0.0, + "cognex_pass": False, "status": "인식 불가" + } + + _best_cell, best_info = max(matched_patterns, key=lambda x: x[1]["score"]) + model = best_info["model"] + in_allowed = model["id"] in allowed_model_ids + + return { + "matched": True, + "in_allowed": in_allowed, + "model": model, + "score": best_info["score"], + "cognex_pass": in_allowed, + "status": ( + f"{model['name']} {model['model']} {model['type']} ({best_info['score']:.1f}점)" + if in_allowed + else f"허용 외 모델: {model['name']} {model['model']} {model['type']}" + ), + } + + def judge(self, cognex_pass: bool, basler_pass: bool) -> str: + return "PASS" if cognex_pass and basler_pass else "FAIL" diff --git a/logic/pattern_matcher.py b/logic/pattern_matcher.py new file mode 100644 index 0000000..c357106 --- /dev/null +++ b/logic/pattern_matcher.py @@ -0,0 +1,222 @@ +# Python PatMax 대체 구현 +# ORB 특징점 매칭 (위치·회전 불변) + 엣지 NCC fallback (특징점 부족 시) +import os +import pickle +import cv2 +import numpy as np +from typing import Optional + +_PATTERNS_PATH = os.path.join("assets", "patterns.pkl") +SCORE_THRESHOLD = 60.0 +_MAX_IMG_SIZE = 1200 # ORB 처리 전 긴 변 최대 픽셀 (속도·메모리 제한) +_GOOD_MATCH_REF = 20 # good match 이 수 이상 → 100점 기준 + + +class PatternMatcher: + + def __init__(self, threshold: float = SCORE_THRESHOLD): + # {product_id: {"method": "orb"|"ncc", "des": ndarray|None, ...}} + self._patterns: dict = {} + self._threshold = threshold + + # ── 학습 ──────────────────────────────────────────────────────────── # + + def train(self, image: np.ndarray, product_id: int, product_info: dict, + roi=None): + """ + roi: (x, y, w, h) 픽셀 좌표, None이면 전체 이미지. + ORB로 특징점을 자동 검출해 등록. 특징점 10개 미만이면 엣지 NCC 방식으로 전환. + """ + gray = _to_gray(image) + if roi is not None: + x, y, rw, rh = roi + x, y = max(0, x), max(0, y) + rw = min(rw, gray.shape[1] - x) + rh = min(rh, gray.shape[0] - y) + gray = gray[y:y + rh, x:x + rw].copy() + + gray = _resize_if_large(gray) + kp, des = cv2.ORB_create(nfeatures=1000).detectAndCompute(gray, None) + + if des is not None and len(kp) >= 10: + method = "orb" + n_kp = len(kp) + edges = None + else: + method = "ncc" + n_kp = 0 + des = None + edges = _to_edges(gray) + + name = (f"{product_info.get('name')} " + f"{product_info.get('model')} {product_info.get('type')}") + suffix = f" ORB 특징점 {n_kp}개" if method == "orb" else " 엣지 NCC (특징점 부족)" + print(f"[PatternMatcher] 등록: id={product_id} {name}{suffix}") + + self._patterns[product_id] = { + "method": method, + "gray": gray, # 축소된 그레이 (시각화·fallback용) + "des": des, # ORB 디스크립터 (np.ndarray) or None + "n_kp": n_kp, + "edges": edges, # Canny 엣지 or None + "info": product_info, + "roi": roi, + } + + # ── 매칭 ──────────────────────────────────────────────────────────── # + + def match_all(self, image: np.ndarray) -> dict: + """모든 등록 패턴에 대한 점수 반환 {product_id: score(0~100)}""" + gray = _resize_if_large(_to_gray(image)) + ncc_edges = None # NCC용 엣지는 필요할 때만 계산 + scores = {} + for pid, data in self._patterns.items(): + if data.get("method") == "orb" and data.get("des") is not None: + scores[pid] = _orb_score(gray, data["des"]) + else: + if ncc_edges is None: + ncc_edges = _to_edges(gray) + tmpl = data.get("edges") or _to_edges(data["gray"]) + scores[pid] = _best_rotated_score(ncc_edges, tmpl) + return scores + + # ── 저장 / 로드 ────────────────────────────────────────────────────── # + + def save(self, path: str = _PATTERNS_PATH): + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "wb") as f: + pickle.dump({"patterns": self._patterns, "threshold": self._threshold}, f) + print(f"[PatternMatcher] 저장: {len(self._patterns)}개 → {path}") + + def load(self, path: str = _PATTERNS_PATH) -> bool: + if not os.path.exists(path): + return False + try: + with open(path, "rb") as f: + data = pickle.load(f) + self._patterns = data.get("patterns", {}) + self._threshold = data.get("threshold", SCORE_THRESHOLD) + # 구버전 호환: method 키 없으면 NCC로 처리 + for pat in self._patterns.values(): + if "method" not in pat: + pat["method"] = "ncc" + if not pat.get("edges"): + pat["edges"] = _to_edges(pat["gray"]) + print(f"[PatternMatcher] 로드: {len(self._patterns)}개 ← {path}") + return True + except Exception as e: + print(f"[PatternMatcher] 로드 실패: {e}") + return False + + # ── 조회 ──────────────────────────────────────────────────────────── # + + def has_pattern(self, product_id: int) -> bool: + return product_id in self._patterns + + def remove_pattern(self, product_id: int): + self._patterns.pop(product_id, None) + + def get_product_info(self, product_id: int) -> Optional[dict]: + data = self._patterns.get(product_id) + return data["info"] if data else None + + def get_pattern_summary(self, product_id: int) -> str: + """패턴 등록 방식 및 특징점 수 요약 문자열.""" + data = self._patterns.get(product_id) + if not data: + return "" + if data.get("method") == "orb": + return f"ORB 특징점 {data.get('n_kp', '?')}개 검출됨" + return "엣지 NCC 방식 (특징점 부족)" + + @property + def registered_ids(self) -> list: + return list(self._patterns.keys()) + + @property + def score_threshold(self) -> float: + return self._threshold + + +# ── 공통 유틸 ──────────────────────────────────────────────────────────── # + +def _to_gray(image: np.ndarray) -> np.ndarray: + if len(image.shape) == 3: + return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + return image.copy() + + +def _resize_if_large(image: np.ndarray) -> np.ndarray: + """긴 변이 _MAX_IMG_SIZE 초과 시 비율 유지 축소.""" + h, w = image.shape[:2] + if max(h, w) <= _MAX_IMG_SIZE: + return image + scale = _MAX_IMG_SIZE / max(h, w) + return cv2.resize(image, (int(w * scale), int(h * scale)), + interpolation=cv2.INTER_AREA) + + +# ── ORB 특징점 매칭 ────────────────────────────────────────────────────── # + +def _orb_score(image: np.ndarray, template_des: np.ndarray) -> float: + """ + ORB 디스크립터로 유사도 점수 계산 (0~100). + Lowe's ratio test(0.75)로 good match 필터링. + good match _GOOD_MATCH_REF개 이상이면 100점. + """ + orb = cv2.ORB_create(nfeatures=1000) + bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) + kp2, des2 = orb.detectAndCompute(image, None) + if des2 is None or len(kp2) < 2: + return 0.0 + try: + matches = bf.knnMatch(template_des, des2, k=2) + except cv2.error: + return 0.0 + good = [p[0] for p in matches + if len(p) == 2 and p[0].distance < 0.75 * p[1].distance] + return min(len(good) / _GOOD_MATCH_REF * 100.0, 100.0) + + +# ── 엣지 NCC fallback (특징점 없는 매끄러운 제품용) ───────────────────── # + +_ROTATION_ANGLES = list(range(-15, 16, 5)) + + +def _to_edges(image: np.ndarray) -> np.ndarray: + return cv2.Canny(cv2.GaussianBlur(image, (3, 3), 0), 50, 150) + + +def _rotate_template(image: np.ndarray, angle_deg: float) -> np.ndarray: + if abs(angle_deg) < 0.1: + return image + h, w = image.shape[:2] + cx, cy = w / 2.0, h / 2.0 + M = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) + cos_a, sin_a = abs(M[0, 0]), abs(M[0, 1]) + new_w = int(h * sin_a + w * cos_a) + new_h = int(h * cos_a + w * sin_a) + M[0, 2] += (new_w - w) / 2.0 + M[1, 2] += (new_h - h) / 2.0 + return cv2.warpAffine(image, M, (new_w, new_h)) + + +def _best_rotated_score(search: np.ndarray, template: np.ndarray) -> float: + return max(_ncc_score(search, _rotate_template(template, float(a))) + for a in _ROTATION_ANGLES) + + +def _ncc_score(image: np.ndarray, template: np.ndarray) -> float: + h, w = image.shape[:2] + th, tw = template.shape[:2] + if th > h or tw > w: + scale = 0.8 * min(h / th, w / tw) + template = cv2.resize(template, + (max(1, int(tw * scale)), max(1, int(th * scale))), + interpolation=cv2.INTER_AREA) + th, tw = template.shape[:2] + if th > h or tw > w: + return 0.0 + result = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED) + _, max_val, _, _ = cv2.minMaxLoc(result) + return float(max(0.0, max_val)) * 100.0 diff --git a/logs/inspect/2026-05-06.csv b/logs/inspect/2026-05-06.csv new file mode 100644 index 0000000..f424faa --- /dev/null +++ b/logs/inspect/2026-05-06.csv @@ -0,0 +1,65 @@ +timestamp,group,result,cognex_pass,basler_pass,detected_models +2026-05-06 09:23:34,A,UNKNOWN,N,Y, +2026-05-06 09:23:37,A,UNKNOWN,N,Y, +2026-05-06 09:23:39,A,UNKNOWN,N,Y, +2026-05-06 09:23:41,A,UNKNOWN,N,Y, +2026-05-06 09:23:44,A,UNKNOWN,N,Y, +2026-05-06 09:23:46,A,UNKNOWN,N,Y, +2026-05-06 09:23:48,A,UNKNOWN,N,Y, +2026-05-06 09:23:51,A,UNKNOWN,N,Y, +2026-05-06 09:23:53,A,UNKNOWN,N,Y, +2026-05-06 09:23:55,A,UNKNOWN,N,Y, +2026-05-06 09:23:58,A,UNKNOWN,N,Y, +2026-05-06 09:24:00,A,UNKNOWN,N,Y, +2026-05-06 09:24:02,A,UNKNOWN,N,Y, +2026-05-06 09:24:05,A,UNKNOWN,N,Y, +2026-05-06 09:24:07,A,UNKNOWN,N,Y, +2026-05-06 09:24:09,A,UNKNOWN,N,Y, +2026-05-06 09:24:12,A,UNKNOWN,N,Y, +2026-05-06 09:24:14,A,UNKNOWN,N,Y, +2026-05-06 09:24:16,A,UNKNOWN,N,Y, +2026-05-06 09:24:19,A,UNKNOWN,N,Y, +2026-05-06 09:24:21,A,UNKNOWN,N,Y, +2026-05-06 09:37:41,A,UNKNOWN,N,Y, +2026-05-06 09:37:44,A,UNKNOWN,N,Y, +2026-05-06 09:37:48,A,UNKNOWN,N,Y, +2026-05-06 09:37:52,A,UNKNOWN,N,Y, +2026-05-06 09:37:55,A,UNKNOWN,N,Y, +2026-05-06 09:37:59,A,UNKNOWN,N,Y, +2026-05-06 09:38:02,A,UNKNOWN,N,Y, +2026-05-06 09:38:06,A,UNKNOWN,N,Y, +2026-05-06 09:38:10,A,UNKNOWN,N,Y, +2026-05-06 09:38:13,A,UNKNOWN,N,Y, +2026-05-06 09:38:17,A,UNKNOWN,N,Y, +2026-05-06 09:38:20,A,UNKNOWN,N,Y, +2026-05-06 09:38:24,A,UNKNOWN,N,Y, +2026-05-06 09:38:28,A,UNKNOWN,N,Y, +2026-05-06 09:38:31,A,UNKNOWN,N,Y, +2026-05-06 09:38:35,A,UNKNOWN,N,Y, +2026-05-06 09:38:38,A,UNKNOWN,N,Y, +2026-05-06 10:29:02,A,UNKNOWN,N,Y, +2026-05-06 10:29:06,A,UNKNOWN,N,Y, +2026-05-06 10:29:09,A,UNKNOWN,N,Y, +2026-05-06 10:29:13,A,UNKNOWN,N,Y, +2026-05-06 10:29:17,A,UNKNOWN,N,Y, +2026-05-06 10:29:20,A,UNKNOWN,N,Y, +2026-05-06 10:29:24,A,UNKNOWN,N,Y, +2026-05-06 10:29:28,A,UNKNOWN,N,Y, +2026-05-06 10:29:31,A,UNKNOWN,N,Y, +2026-05-06 10:29:35,A,UNKNOWN,N,Y, +2026-05-06 10:29:38,A,UNKNOWN,N,Y, +2026-05-06 18:06:40,A,UNKNOWN,N,Y, +2026-05-06 18:06:43,A,UNKNOWN,N,Y, +2026-05-06 18:06:47,A,UNKNOWN,N,Y, +2026-05-06 18:07:01,A,UNKNOWN,N,Y, +2026-05-06 18:07:04,A,UNKNOWN,N,Y, +2026-05-06 18:07:07,A,UNKNOWN,N,Y, +2026-05-06 18:07:11,A,UNKNOWN,N,Y, +2026-05-06 18:07:14,A,UNKNOWN,N,Y, +2026-05-06 18:07:18,A,UNKNOWN,N,Y, +2026-05-06 18:07:21,A,UNKNOWN,N,Y, +2026-05-06 18:07:24,A,UNKNOWN,N,Y, +2026-05-06 18:07:28,A,UNKNOWN,N,Y, +2026-05-06 18:07:31,A,UNKNOWN,N,Y, +2026-05-06 18:07:34,A,UNKNOWN,N,Y, +2026-05-06 18:07:38,A,UNKNOWN,N,Y, diff --git a/logs/inspect/2026-05-07.csv b/logs/inspect/2026-05-07.csv new file mode 100644 index 0000000..3ea6111 --- /dev/null +++ b/logs/inspect/2026-05-07.csv @@ -0,0 +1,9 @@ +timestamp,group,result,cognex_pass,basler_pass,detected_models +2026-05-07 17:36:51,A,UNKNOWN,N,Y, +2026-05-07 17:36:54,A,UNKNOWN,N,Y, +2026-05-07 17:36:58,A,UNKNOWN,N,Y, +2026-05-07 17:37:01,A,UNKNOWN,N,Y, +2026-05-07 17:37:04,A,UNKNOWN,N,Y, +2026-05-07 17:37:08,A,UNKNOWN,N,Y, +2026-05-07 17:37:11,A,UNKNOWN,N,Y, +2026-05-07 17:37:14,A,UNKNOWN,N,Y, diff --git a/logs/inspect/2026-05-08.csv b/logs/inspect/2026-05-08.csv new file mode 100644 index 0000000..824119d --- /dev/null +++ b/logs/inspect/2026-05-08.csv @@ -0,0 +1,10 @@ +timestamp,group,result,cognex_pass,basler_pass,detected_models +2026-05-08 14:33:04,A,UNKNOWN,N,Y, +2026-05-08 14:33:07,A,UNKNOWN,N,Y, +2026-05-08 14:33:11,A,UNKNOWN,N,Y, +2026-05-08 14:33:14,A,UNKNOWN,N,Y, +2026-05-08 14:33:18,A,UNKNOWN,N,Y, +2026-05-08 14:33:21,A,UNKNOWN,N,Y, +2026-05-08 14:33:25,A,UNKNOWN,N,Y, +2026-05-08 14:33:29,A,UNKNOWN,N,Y, +2026-05-08 14:33:32,A,UNKNOWN,N,Y, diff --git a/logs/inspect/2026-05-13.csv b/logs/inspect/2026-05-13.csv new file mode 100644 index 0000000..a679a56 --- /dev/null +++ b/logs/inspect/2026-05-13.csv @@ -0,0 +1,14 @@ +timestamp,group,result,cognex_pass,basler_pass,detected_models +2026-05-13 09:17:49,A,UNKNOWN,N,Y, +2026-05-13 09:17:52,A,UNKNOWN,N,Y, +2026-05-13 09:17:55,A,UNKNOWN,N,Y, +2026-05-13 09:17:59,A,UNKNOWN,N,Y, +2026-05-13 09:18:02,A,UNKNOWN,N,Y, +2026-05-13 09:18:05,A,UNKNOWN,N,Y, +2026-05-13 09:18:09,A,UNKNOWN,N,Y, +2026-05-13 09:18:12,A,UNKNOWN,N,Y, +2026-05-13 09:18:15,A,UNKNOWN,N,Y, +2026-05-13 09:18:19,A,UNKNOWN,N,Y, +2026-05-13 09:18:22,A,UNKNOWN,N,Y, +2026-05-13 09:18:25,A,UNKNOWN,N,Y, +2026-05-13 09:18:29,A,UNKNOWN,N,Y, diff --git a/logs/inspect/2026-05-20.csv b/logs/inspect/2026-05-20.csv new file mode 100644 index 0000000..1902952 --- /dev/null +++ b/logs/inspect/2026-05-20.csv @@ -0,0 +1,4 @@ +timestamp,group,result,cognex_pass,basler_pass,detected_models +2026-05-20 10:10:41,A,UNKNOWN,N,Y, +2026-05-20 10:10:49,A,UNKNOWN,N,Y, +2026-05-20 10:10:53,A,UNKNOWN,N,Y, diff --git a/logs/timing/2026-05-06.csv b/logs/timing/2026-05-06.csv new file mode 100644 index 0000000..9eabaf1 --- /dev/null +++ b/logs/timing/2026-05-06.csv @@ -0,0 +1,541 @@ +timestamp,seq,event,elapsed_ms,detail +10:28:58.280,1,cycle_start,0.0,group=A belt_delay=3.33s +10:28:58.280,1,cognex_trigger_send,22.6, +10:28:58.295,1,cognex_trigger_ok,29.2, +10:28:59.302,1,cognex_ftp_start,1031.6, +10:29:00.048,1,cognex_ftp_done,1777.7,3686454bytes +10:29:00.048,1,cognex_patmax_start,1779.2, +10:29:00.048,1,cognex_patmax_done,1791.1, +10:29:01.605,1,basler_capture_start,3333.9, +10:29:01.854,1,basler_capture_done,3591.2,"(4504, 4504)" +10:29:02.636,1,cognex_join_wait,4376.1, +10:29:02.636,1,cognex_join_done,4377.7, +10:29:02.665,1,cycle_done,4394.5,result=FAIL cognex=FAIL basler=PASS +10:29:02.669,2,cycle_start,0.0,group=A belt_delay=3.33s +10:29:02.671,2,cognex_trigger_send,2.9, +10:29:02.672,2,cognex_trigger_ok,9.4, +10:29:03.681,2,cognex_ftp_start,1011.7, +10:29:04.413,2,cognex_ftp_done,1744.1,3686454bytes +10:29:04.414,2,cognex_patmax_start,1745.6, +10:29:04.414,2,cognex_patmax_done,1757.2, +10:29:06.003,2,basler_capture_start,3333.8, +10:29:06.252,2,basler_capture_done,3590.7,"(4504, 4504)" +10:29:06.252,2,cognex_join_wait,3597.3, +10:29:06.284,2,cognex_join_done,3614.1, +10:29:06.287,2,cycle_done,3617.9,result=FAIL cognex=FAIL basler=PASS +10:29:06.290,3,cycle_start,0.0,group=A belt_delay=3.33s +10:29:06.290,3,cognex_trigger_send,2.5, +10:29:06.299,3,cognex_trigger_ok,9.0, +10:29:07.302,3,cognex_ftp_start,1011.4, +10:29:08.031,3,cognex_ftp_done,1741.0,3686454bytes +10:29:08.031,3,cognex_patmax_start,1742.5, +10:29:08.031,3,cognex_patmax_done,1754.0, +10:29:09.625,3,basler_capture_start,3333.9, +10:29:09.869,3,basler_capture_done,3589.8,"(4504, 4504)" +10:29:09.884,3,cognex_join_wait,3596.7, +10:29:09.903,3,cognex_join_done,3614.2, +10:29:09.903,3,cycle_done,3617.5,result=FAIL cognex=FAIL basler=PASS +10:29:09.903,4,cycle_start,0.0,group=A belt_delay=3.33s +10:29:09.914,4,cognex_trigger_send,2.3, +10:29:09.920,4,cognex_trigger_ok,8.7, +10:29:10.923,4,cognex_ftp_start,1010.9, +10:29:11.648,4,cognex_ftp_done,1736.6,3686454bytes +10:29:11.648,4,cognex_patmax_start,1738.1, +10:29:11.648,4,cognex_patmax_done,1749.6, +10:29:13.246,4,basler_capture_start,3334.3, +10:29:13.500,4,basler_capture_done,3590.8,"(4504, 4504)" +10:29:13.500,4,cognex_join_wait,3597.8, +10:29:13.527,4,cognex_join_done,3608.0, +10:29:13.527,4,cycle_done,3618.5,result=FAIL cognex=FAIL basler=PASS +10:29:13.534,5,cycle_start,0.0,group=A belt_delay=3.33s +10:29:13.536,5,cognex_trigger_send,2.4, +10:29:13.539,5,cognex_trigger_ok,9.2, +10:29:14.546,5,cognex_ftp_start,1011.6, +10:29:15.264,5,cognex_ftp_done,1744.0,3686454bytes +10:29:15.264,5,cognex_patmax_start,1745.6, +10:29:15.281,5,cognex_patmax_done,1757.1, +10:29:16.868,5,basler_capture_start,3334.1, +10:29:17.119,5,basler_capture_done,3591.4,"(4504, 4504)" +10:29:17.119,5,cognex_join_wait,3598.4, +10:29:17.135,5,cognex_join_done,3602.3, +10:29:17.153,5,cycle_done,3619.1,result=FAIL cognex=FAIL basler=PASS +10:29:17.157,6,cycle_start,0.0,group=A belt_delay=3.33s +10:29:17.157,6,cognex_trigger_send,2.4, +10:29:17.166,6,cognex_trigger_ok,9.2, +10:29:18.169,6,cognex_ftp_start,1011.5, +10:29:18.914,6,cognex_ftp_done,1758.7,3686454bytes +10:29:18.914,6,cognex_patmax_start,1760.2, +10:29:18.914,6,cognex_patmax_done,1772.1, +10:29:20.491,6,basler_capture_start,3334.0, +10:29:20.739,6,basler_capture_done,3591.4,"(4504, 4504)" +10:29:20.755,6,cognex_join_wait,3598.5, +10:29:20.774,6,cognex_join_done,3615.4, +10:29:20.776,6,cycle_done,3619.0,result=FAIL cognex=FAIL basler=PASS +10:29:20.777,7,cycle_start,0.0,group=A belt_delay=3.33s +10:29:20.782,7,cognex_trigger_send,2.4, +10:29:20.788,7,cognex_trigger_ok,9.0, +10:29:21.791,7,cognex_ftp_start,1010.8, +10:29:22.514,7,cognex_ftp_done,1749.6,3686454bytes +10:29:22.531,7,cognex_patmax_start,1751.2, +10:29:22.531,7,cognex_patmax_done,1760.6, +10:29:24.113,7,basler_capture_start,3333.5, +10:29:24.368,7,basler_capture_done,3590.6,"(4504, 4504)" +10:29:24.368,7,cognex_join_wait,3597.8, +10:29:24.368,7,cognex_join_done,3599.4, +10:29:24.390,7,cycle_done,3615.9,result=FAIL cognex=FAIL basler=PASS +10:29:24.399,8,cycle_start,0.0,group=A belt_delay=3.33s +10:29:24.401,8,cognex_trigger_send,1.9, +10:29:24.405,8,cognex_trigger_ok,8.4, +10:29:25.410,8,cognex_ftp_start,1010.6, +10:29:26.165,8,cognex_ftp_done,1768.0,3686454bytes +10:29:26.165,8,cognex_patmax_start,1769.5, +10:29:26.180,8,cognex_patmax_done,1780.9, +10:29:27.733,8,basler_capture_start,3333.7, +10:29:27.985,8,basler_capture_done,3591.1,"(4504, 4504)" +10:29:27.985,8,cognex_join_wait,3598.1, +10:29:28.001,8,cognex_join_done,3601.9, +10:29:28.017,8,cycle_done,3618.3,result=FAIL cognex=FAIL basler=PASS +10:29:28.022,9,cycle_start,0.0,group=A belt_delay=3.33s +10:29:28.023,9,cognex_trigger_send,2.4, +10:29:28.031,9,cognex_trigger_ok,9.0, +10:29:29.033,9,cognex_ftp_start,1011.4, +10:29:29.748,9,cognex_ftp_done,1741.5,3686454bytes +10:29:29.765,9,cognex_patmax_start,1743.1, +10:29:29.765,9,cognex_patmax_done,1754.6, +10:29:31.355,9,basler_capture_start,3333.4, +10:29:31.603,9,basler_capture_done,3589.9,"(4504, 4504)" +10:29:31.619,9,cognex_join_wait,3597.0, +10:29:31.638,9,cognex_join_done,3614.9, +10:29:31.640,9,cycle_done,3619.4,result=FAIL cognex=FAIL basler=PASS +10:29:31.640,10,cycle_start,0.0,group=A belt_delay=3.33s +10:29:31.640,10,cognex_trigger_send,2.2, +10:29:31.651,10,cognex_trigger_ok,8.7, +10:29:32.655,10,cognex_ftp_start,1010.9, +10:29:33.381,10,cognex_ftp_done,1737.0,3686454bytes +10:29:33.381,10,cognex_patmax_start,1738.5, +10:29:33.381,10,cognex_patmax_done,1750.2, +10:29:34.978,10,basler_capture_start,3334.0, +10:29:35.234,10,basler_capture_done,3591.0,"(4504, 4504)" +10:29:35.234,10,cognex_join_wait,3598.4, +10:29:35.255,10,cognex_join_done,3608.7, +10:29:35.255,10,cycle_done,3619.3,result=FAIL cognex=FAIL basler=PASS +10:29:35.268,11,cycle_start,0.0,group=A belt_delay=3.33s +10:29:35.268,11,cognex_trigger_send,2.4, +10:29:35.268,11,cognex_trigger_ok,8.9, +10:29:36.279,11,cognex_ftp_start,1011.4, +10:29:37.031,11,cognex_ftp_done,1763.6,3686454bytes +10:29:37.031,11,cognex_patmax_start,1765.1, +10:29:37.031,11,cognex_patmax_done,1776.5, +10:29:38.601,11,basler_capture_start,3333.6, +10:29:38.855,11,basler_capture_done,3590.7,"(4504, 4504)" +10:29:38.855,11,cognex_join_wait,3597.8, +10:29:38.885,11,cognex_join_done,3608.3, +10:29:38.888,11,cycle_done,3619.7,result=FAIL cognex=FAIL basler=PASS +18:06:16.958,1,cycle_start,0.0,group=A belt_delay=3.33s +18:06:16.958,1,cognex_trigger_send,3.0, +18:06:16.974,1,cognex_trigger_ok,9.5, +18:06:17.977,1,cognex_ftp_start,1011.8, +18:06:18.704,1,cognex_ftp_done,1738.6,3686454bytes +18:06:18.705,1,cognex_patmax_start,1739.9, +18:06:18.832,1,cognex_patmax_done,1866.3, +18:06:20.299,1,basler_capture_start,3333.8, +18:06:20.302,1,basler_capture_done,3336.9,failed +18:06:20.304,1,cognex_join_wait,3338.9, +18:06:20.306,1,cognex_join_done,3340.9, +18:06:20.309,1,cycle_done,3343.2,result=PASS cognex=PASS basler=PASS +18:06:20.311,2,cycle_start,0.0,group=A belt_delay=3.33s +18:06:20.313,2,cognex_trigger_send,2.3, +18:06:20.320,2,cognex_trigger_ok,8.8, +18:06:21.323,2,cognex_ftp_start,1011.6, +18:06:22.040,2,cognex_ftp_done,1743.5,3686454bytes +18:06:22.056,2,cognex_patmax_start,1745.1, +18:06:22.162,2,cognex_patmax_done,1850.8, +18:06:23.645,2,basler_capture_start,3333.7, +18:06:23.645,2,basler_capture_done,3336.5,failed +18:06:23.645,2,cognex_join_wait,3338.4, +18:06:23.645,2,cognex_join_done,3340.4, +18:06:23.645,2,cycle_done,3342.3,result=PASS cognex=PASS basler=PASS +18:06:23.645,3,cycle_start,0.0,group=A belt_delay=3.33s +18:06:23.645,3,cognex_trigger_send,2.2, +18:06:23.664,3,cognex_trigger_ok,8.6, +18:06:24.666,3,cognex_ftp_start,1010.5, +18:06:25.406,3,cognex_ftp_done,1758.8,3686454bytes +18:06:25.406,3,cognex_patmax_start,1760.2, +18:06:25.522,3,cognex_patmax_done,1866.4, +18:06:26.990,3,basler_capture_start,3334.1, +18:06:26.992,3,basler_capture_done,3337.1,failed +18:06:26.992,3,cognex_join_wait,3338.5, +18:06:26.992,3,cognex_join_done,3339.7, +18:06:26.992,3,cycle_done,3341.0,result=FAIL cognex=FAIL basler=PASS +18:06:26.992,4,cycle_start,0.0,group=A belt_delay=3.33s +18:06:26.992,4,cognex_trigger_send,1.7, +18:06:27.005,4,cognex_trigger_ok,7.7, +18:06:28.009,4,cognex_ftp_start,1010.2, +18:06:28.740,4,cognex_ftp_done,1756.4,3686454bytes +18:06:28.756,4,cognex_patmax_start,1758.0, +18:06:28.861,4,cognex_patmax_done,1863.5, +18:06:30.332,4,basler_capture_start,3333.5, +18:06:30.332,4,basler_capture_done,3336.2,failed +18:06:30.332,4,cognex_join_wait,3338.1, +18:06:30.332,4,cognex_join_done,3340.0, +18:06:30.332,4,cycle_done,3342.0,result=FAIL cognex=FAIL basler=PASS +18:06:30.332,5,cycle_start,0.0,group=A belt_delay=3.33s +18:06:30.344,5,cognex_trigger_send,2.4, +18:06:30.351,5,cognex_trigger_ok,8.7, +18:06:31.354,5,cognex_ftp_start,1011.5, +18:06:32.106,5,cognex_ftp_done,1764.4,3686454bytes +18:06:32.106,5,cognex_patmax_start,1765.8, +18:06:32.212,5,cognex_patmax_done,1869.6, +18:06:33.676,5,basler_capture_start,3333.6, +18:06:33.678,5,basler_capture_done,3336.7,failed +18:06:33.678,5,cognex_join_wait,3338.6, +18:06:33.678,5,cognex_join_done,3340.5, +18:06:33.678,5,cycle_done,3342.4,result=FAIL cognex=FAIL basler=PASS +18:06:33.678,6,cycle_start,0.0,group=A belt_delay=3.33s +18:06:33.678,6,cognex_trigger_send,2.4, +18:06:33.697,6,cognex_trigger_ok,8.9, +18:06:34.699,6,cognex_ftp_start,1011.8, +18:06:35.456,6,cognex_ftp_done,1769.3,3686454bytes +18:06:35.456,6,cognex_patmax_start,1770.9, +18:06:35.565,6,cognex_patmax_done,1878.1, +18:06:37.021,6,basler_capture_start,3333.9, +18:06:37.021,6,basler_capture_done,3337.3,failed +18:06:37.021,6,cognex_join_wait,3339.3, +18:06:37.027,6,cognex_join_done,3341.2, +18:06:37.027,6,cycle_done,3343.1,result=FAIL cognex=FAIL basler=PASS +18:06:37.027,7,cycle_start,0.0,group=A belt_delay=3.33s +18:06:37.027,7,cognex_trigger_send,2.4, +18:06:37.041,7,cognex_trigger_ok,8.9, +18:06:38.044,7,cognex_ftp_start,1011.4, +18:06:38.772,7,cognex_ftp_done,1739.0,3686454bytes +18:06:38.773,7,cognex_patmax_start,1740.5, +18:06:38.880,7,cognex_patmax_done,1847.0, +18:06:40.367,7,basler_capture_start,3334.3, +18:06:40.367,7,basler_capture_done,3337.4,failed +18:06:40.367,7,cognex_join_wait,3339.3, +18:06:40.367,7,cognex_join_done,3341.3, +18:06:40.375,7,cycle_done,3343.2,result=FAIL cognex=FAIL basler=PASS +18:06:40.375,8,cycle_start,0.0,group=A belt_delay=3.33s +18:06:40.375,8,cognex_trigger_send,2.3, +18:06:40.375,8,cognex_trigger_ok,8.8, +18:06:41.391,8,cognex_ftp_start,1011.2, +18:06:42.139,8,cognex_ftp_done,1759.5,3686454bytes +18:06:42.140,8,cognex_patmax_start,1761.2, +18:06:42.247,8,cognex_patmax_done,1867.4, +18:06:43.713,8,basler_capture_start,3333.6, +18:06:43.713,8,basler_capture_done,3336.5,failed +18:06:43.713,8,cognex_join_wait,3338.5, +18:06:43.713,8,cognex_join_done,3340.5, +18:06:43.713,8,cycle_done,3342.4,result=FAIL cognex=FAIL basler=PASS +18:06:43.713,9,cycle_start,0.0,group=A belt_delay=3.33s +18:06:43.727,9,cognex_trigger_send,2.2, +18:06:43.727,9,cognex_trigger_ok,8.8, +18:06:44.737,9,cognex_ftp_start,1011.0, +18:06:45.473,9,cognex_ftp_done,1760.9,3686454bytes +18:06:45.473,9,cognex_patmax_start,1762.4, +18:06:45.593,9,cognex_patmax_done,1867.3, +18:06:47.060,9,basler_capture_start,3334.0, +18:06:47.060,9,basler_capture_done,3337.1,failed +18:06:47.060,9,cognex_join_wait,3339.1, +18:06:47.060,9,cognex_join_done,3341.0, +18:06:47.060,9,cycle_done,3342.9,result=FAIL cognex=FAIL basler=PASS +18:06:57.940,10,cycle_start,0.0,group=A belt_delay=3.33s +18:06:57.940,10,cognex_trigger_send,3.7, +18:06:57.945,10,cognex_trigger_ok,10.4, +18:06:58.953,10,cognex_ftp_start,1012.9, +18:06:59.690,10,cognex_ftp_done,1765.5,3686454bytes +18:06:59.706,10,cognex_patmax_start,1767.1, +18:06:59.812,10,cognex_patmax_done,1872.7, +18:07:01.274,10,basler_capture_start,3334.1, +18:07:01.274,10,basler_capture_done,3337.2,failed +18:07:01.278,10,cognex_join_wait,3338.5, +18:07:01.278,10,cognex_join_done,3339.8, +18:07:01.278,10,cycle_done,3341.1,result=FAIL cognex=FAIL basler=PASS +18:07:01.278,11,cycle_start,0.0,group=A belt_delay=3.33s +18:07:01.278,11,cognex_trigger_send,1.6, +18:07:01.278,11,cognex_trigger_ok,7.6, +18:07:02.293,11,cognex_ftp_start,1009.0, +18:07:03.033,11,cognex_ftp_done,1749.5,3686454bytes +18:07:03.034,11,cognex_patmax_start,1750.9, +18:07:03.137,11,cognex_patmax_done,1853.5, +18:07:04.618,11,basler_capture_start,3334.1, +18:07:04.618,11,basler_capture_done,3337.6,failed +18:07:04.618,11,cognex_join_wait,3339.5, +18:07:04.618,11,cognex_join_done,3341.5, +18:07:04.618,11,cycle_done,3343.4,result=FAIL cognex=FAIL basler=PASS +18:07:04.628,12,cycle_start,0.0,group=A belt_delay=3.33s +18:07:04.628,12,cognex_trigger_send,2.2, +18:07:04.628,12,cognex_trigger_ok,8.5, +18:07:05.641,12,cognex_ftp_start,1010.7, +18:07:06.373,12,cognex_ftp_done,1744.0,3686454bytes +18:07:06.373,12,cognex_patmax_start,1745.4, +18:07:06.473,12,cognex_patmax_done,1852.2, +18:07:07.964,12,basler_capture_start,3333.9, +18:07:07.964,12,basler_capture_done,3337.2,failed +18:07:07.964,12,cognex_join_wait,3339.2, +18:07:07.964,12,cognex_join_done,3341.0, +18:07:07.964,12,cycle_done,3343.0,result=FAIL cognex=FAIL basler=PASS +18:07:07.976,13,cycle_start,0.0,group=A belt_delay=3.33s +18:07:07.976,13,cognex_trigger_send,2.2, +18:07:07.976,13,cognex_trigger_ok,8.8, +18:07:08.988,13,cognex_ftp_start,1010.9, +18:07:09.723,13,cognex_ftp_done,1761.7,3686454bytes +18:07:09.740,13,cognex_patmax_start,1763.3, +18:07:09.842,13,cognex_patmax_done,1865.8, +18:07:11.311,13,basler_capture_start,3334.1, +18:07:11.312,13,basler_capture_done,3337.4,failed +18:07:11.312,13,cognex_join_wait,3339.4, +18:07:11.312,13,cognex_join_done,3341.2, +18:07:11.312,13,cycle_done,3343.1,result=FAIL cognex=FAIL basler=PASS +18:07:11.312,14,cycle_start,0.0,group=A belt_delay=3.33s +18:07:11.312,14,cognex_trigger_send,2.2, +18:07:11.327,14,cognex_trigger_ok,8.7, +18:07:12.334,14,cognex_ftp_start,1010.9, +18:07:13.090,14,cognex_ftp_done,1767.6,3686454bytes +18:07:13.090,14,cognex_patmax_start,1769.1, +18:07:13.197,14,cognex_patmax_done,1873.5, +18:07:14.657,14,basler_capture_start,3333.9, +18:07:14.657,14,basler_capture_done,3336.8,failed +18:07:14.662,14,cognex_join_wait,3339.6, +18:07:14.662,14,cognex_join_done,3341.5, +18:07:14.662,14,cycle_done,3343.4,result=FAIL cognex=FAIL basler=PASS +18:07:14.662,15,cycle_start,0.0,group=A belt_delay=3.33s +18:07:14.662,15,cognex_trigger_send,2.2, +18:07:14.678,15,cognex_trigger_ok,8.8, +18:07:15.681,15,cognex_ftp_start,1010.9, +18:07:16.423,15,cognex_ftp_done,1754.9,3686454bytes +18:07:16.423,15,cognex_patmax_start,1756.4, +18:07:16.523,15,cognex_patmax_done,1861.8, +18:07:18.004,15,basler_capture_start,3333.8, +18:07:18.004,15,basler_capture_done,3337.1,failed +18:07:18.004,15,cognex_join_wait,3339.1, +18:07:18.004,15,cognex_join_done,3341.0, +18:07:18.011,15,cycle_done,3342.9,result=FAIL cognex=FAIL basler=PASS +18:07:18.011,16,cycle_start,0.0,group=A belt_delay=3.33s +18:07:18.011,16,cognex_trigger_send,2.2, +18:07:18.011,16,cognex_trigger_ok,8.5, +18:07:19.027,16,cognex_ftp_start,1010.8, +18:07:19.757,16,cognex_ftp_done,1755.9,3686454bytes +18:07:19.773,16,cognex_patmax_start,1757.5, +18:07:19.873,16,cognex_patmax_done,1864.6, +18:07:21.350,16,basler_capture_start,3333.7, +18:07:21.350,16,basler_capture_done,3336.8,failed +18:07:21.350,16,cognex_join_wait,3338.8, +18:07:21.350,16,cognex_join_done,3340.7, +18:07:21.350,16,cycle_done,3342.5,result=FAIL cognex=FAIL basler=PASS +18:07:21.360,17,cycle_start,0.0,group=A belt_delay=3.33s +18:07:21.360,17,cognex_trigger_send,2.2, +18:07:21.360,17,cognex_trigger_ok,8.6, +18:07:22.373,17,cognex_ftp_start,1010.8, +18:07:23.123,17,cognex_ftp_done,1761.5,3686454bytes +18:07:23.123,17,cognex_patmax_start,1763.0, +18:07:23.223,17,cognex_patmax_done,1869.6, +18:07:24.696,17,basler_capture_start,3333.7, +18:07:24.696,17,basler_capture_done,3337.0,failed +18:07:24.696,17,cognex_join_wait,3338.4, +18:07:24.696,17,cognex_join_done,3339.6, +18:07:24.696,17,cycle_done,3340.9,result=FAIL cognex=FAIL basler=PASS +18:07:24.696,18,cycle_start,0.0,group=A belt_delay=3.33s +18:07:24.696,18,cognex_trigger_send,1.6, +18:07:24.711,18,cognex_trigger_ok,7.3, +18:07:25.714,18,cognex_ftp_start,1008.8, +18:07:26.440,18,cognex_ftp_done,1735.4,3686454bytes +18:07:26.440,18,cognex_patmax_start,1736.9, +18:07:26.540,18,cognex_patmax_done,1842.2, +18:07:28.040,18,basler_capture_start,3334.1, +18:07:28.042,18,basler_capture_done,3337.6,failed +18:07:28.042,18,cognex_join_wait,3340.2, +18:07:28.042,18,cognex_join_done,3342.2, +18:07:28.042,18,cycle_done,3344.1,result=FAIL cognex=FAIL basler=PASS +18:07:28.042,19,cycle_start,0.0,group=A belt_delay=3.33s +18:07:28.042,19,cognex_trigger_send,2.2, +18:07:28.057,19,cognex_trigger_ok,8.7, +18:07:29.064,19,cognex_ftp_start,1011.0, +18:07:29.823,19,cognex_ftp_done,1771.2,3686454bytes +18:07:29.823,19,cognex_patmax_start,1772.7, +18:07:29.923,19,cognex_patmax_done,1879.1, +18:07:31.387,19,basler_capture_start,3334.3, +18:07:31.387,19,basler_capture_done,3337.4,failed +18:07:31.387,19,cognex_join_wait,3339.9, +18:07:31.395,19,cognex_join_done,3341.9, +18:07:31.395,19,cycle_done,3343.9,result=FAIL cognex=FAIL basler=PASS +18:07:31.395,20,cycle_start,0.0,group=A belt_delay=3.33s +18:07:31.395,20,cognex_trigger_send,2.3, +18:07:31.395,20,cognex_trigger_ok,8.6, +18:07:32.412,20,cognex_ftp_start,1010.9, +18:07:33.123,20,cognex_ftp_done,1737.1,3686454bytes +18:07:33.139,20,cognex_patmax_start,1738.7, +18:07:33.240,20,cognex_patmax_done,1840.1, +18:07:34.734,20,basler_capture_start,3333.5, +18:07:34.734,20,basler_capture_done,3336.8,failed +18:07:34.734,20,cognex_join_wait,3339.1, +18:07:34.742,20,cognex_join_done,3341.1, +18:07:34.742,20,cycle_done,3343.1,result=FAIL cognex=FAIL basler=PASS +18:07:34.742,21,cycle_start,0.0,group=A belt_delay=3.33s +18:07:34.742,21,cognex_trigger_send,2.2, +18:07:34.742,21,cognex_trigger_ok,8.8, +18:07:35.758,21,cognex_ftp_start,1011.1, +18:07:36.490,21,cognex_ftp_done,1756.5,3686454bytes +18:07:36.490,21,cognex_patmax_start,1758.0, +18:07:36.610,21,cognex_patmax_done,1863.5, +18:07:38.081,21,basler_capture_start,3333.8, +18:07:38.081,21,basler_capture_done,3336.6,failed +18:07:38.081,21,cognex_join_wait,3338.6, +18:07:38.081,21,cognex_join_done,3340.5, +18:07:38.081,21,cycle_done,3342.4,result=FAIL cognex=FAIL basler=PASS +18:09:47.668,22,cycle_start,0.0,group=A belt_delay=3.33s +18:09:47.670,22,cognex_trigger_send,3.4, +18:09:47.670,22,cognex_trigger_ok,10.0, +18:09:48.680,22,cognex_ftp_start,1012.4, +18:09:49.416,22,cognex_ftp_done,1754.5,3686454bytes +18:09:49.416,22,cognex_patmax_start,1755.9, +18:09:49.551,22,cognex_patmax_done,1896.3, +18:09:51.002,22,basler_capture_start,3334.0, +18:09:51.002,22,basler_capture_done,3337.1,failed +18:09:51.006,22,cognex_join_wait,3339.1, +18:09:51.006,22,cognex_join_done,3341.1, +18:09:51.006,22,cycle_done,3343.0,result=PASS cognex=PASS basler=PASS +18:09:51.006,23,cycle_start,0.0,group=A belt_delay=3.33s +18:09:51.006,23,cognex_trigger_send,2.4, +18:09:51.022,23,cognex_trigger_ok,9.0, +18:09:52.025,23,cognex_ftp_start,1011.6, +18:09:52.767,23,cognex_ftp_done,1758.5,3686454bytes +18:09:52.767,23,cognex_patmax_start,1760.0, +18:09:52.902,23,cognex_patmax_done,1897.6, +18:09:54.347,23,basler_capture_start,3333.4, +18:09:54.347,23,basler_capture_done,3336.3,failed +18:09:54.347,23,cognex_join_wait,3338.8, +18:09:54.347,23,cognex_join_done,3340.7, +18:09:54.347,23,cycle_done,3342.6,result=PASS cognex=PASS basler=PASS +18:11:41.743,24,cycle_start,0.0,group=A belt_delay=3.33s +18:11:41.745,24,cognex_trigger_send,3.5, +18:11:41.745,24,cognex_trigger_ok,10.3, +18:11:42.757,24,cognex_ftp_start,1013.2, +18:11:43.483,24,cognex_ftp_done,1749.5,3686454bytes +18:11:43.483,24,cognex_patmax_start,1751.0, +18:11:43.621,24,cognex_patmax_done,1885.8, +18:11:45.078,24,basler_capture_start,3334.2, +18:11:45.078,24,basler_capture_done,3337.3,failed +18:11:45.078,24,cognex_join_wait,3338.6, +18:11:45.078,24,cognex_join_done,3339.8, +18:11:45.078,24,cycle_done,3341.4,result=PASS cognex=PASS basler=PASS +18:11:45.078,25,cycle_start,0.0,group=A belt_delay=3.33s +18:11:45.078,25,cognex_trigger_send,1.7, +18:11:45.093,25,cognex_trigger_ok,7.6, +18:11:46.096,25,cognex_ftp_start,1009.6, +18:11:46.832,25,cognex_ftp_done,1755.0,3686454bytes +18:11:46.832,25,cognex_patmax_start,1756.5, +18:11:46.970,25,cognex_patmax_done,1884.8, +18:11:48.421,25,basler_capture_start,3334.0, +18:11:48.421,25,basler_capture_done,3337.0,failed +18:11:48.424,25,cognex_join_wait,3339.0, +18:11:48.424,25,cognex_join_done,3340.9, +18:11:48.424,25,cycle_done,3342.7,result=PASS cognex=PASS basler=PASS +18:11:48.424,26,cycle_start,0.0,group=A belt_delay=3.33s +18:11:48.424,26,cognex_trigger_send,2.3, +18:11:48.440,26,cognex_trigger_ok,8.8, +18:11:49.443,26,cognex_ftp_start,1011.2, +18:11:50.182,26,cognex_ftp_done,1758.9,3686454bytes +18:11:50.182,26,cognex_patmax_start,1760.4, +18:11:50.320,26,cognex_patmax_done,1888.4, +18:11:51.765,26,basler_capture_start,3333.7, +18:11:51.765,26,basler_capture_done,3337.0,failed +18:11:51.769,26,cognex_join_wait,3338.9, +18:11:51.769,26,cognex_join_done,3340.8, +18:11:51.774,26,cycle_done,3342.6,result=PASS cognex=PASS basler=PASS +18:11:51.774,27,cycle_start,0.0,group=A belt_delay=3.33s +18:11:51.774,27,cognex_trigger_send,2.3, +18:11:51.785,27,cognex_trigger_ok,8.8, +18:11:52.788,27,cognex_ftp_start,1011.7, +18:11:53.520,27,cognex_ftp_done,1749.0,3686454bytes +18:11:53.520,27,cognex_patmax_start,1750.6, +18:11:53.640,27,cognex_patmax_done,1878.4, +18:11:55.111,27,basler_capture_start,3334.0, +18:11:55.111,27,basler_capture_done,3337.1,failed +18:11:55.111,27,cognex_join_wait,3339.0, +18:11:55.111,27,cognex_join_done,3340.9, +18:11:55.111,27,cycle_done,3342.8,result=PASS cognex=PASS basler=PASS +18:11:55.111,28,cycle_start,0.0,group=A belt_delay=3.33s +18:11:55.111,28,cognex_trigger_send,2.3, +18:11:55.130,28,cognex_trigger_ok,8.8, +18:11:56.133,28,cognex_ftp_start,1011.6, +18:11:56.850,28,cognex_ftp_done,1741.1,3686454bytes +18:11:56.850,28,cognex_patmax_start,1742.5, +18:11:56.992,28,cognex_patmax_done,1872.0, +18:11:58.456,28,basler_capture_start,3334.1, +18:11:58.456,28,basler_capture_done,3337.1,failed +18:11:58.456,28,cognex_join_wait,3339.0, +18:11:58.461,28,cognex_join_done,3340.8, +18:11:58.461,28,cycle_done,3342.8,result=PASS cognex=PASS basler=PASS +18:11:58.461,29,cycle_start,0.0,group=A belt_delay=3.33s +18:11:58.461,29,cognex_trigger_send,2.3, +18:11:58.475,29,cognex_trigger_ok,8.9, +18:11:59.478,29,cognex_ftp_start,1011.7, +18:12:00.219,29,cognex_ftp_done,1755.7,3686454bytes +18:12:00.219,29,cognex_patmax_start,1757.2, +18:12:00.354,29,cognex_patmax_done,1886.8, +18:12:01.801,29,basler_capture_start,3333.8, +18:12:01.801,29,basler_capture_done,3337.1,failed +18:12:01.801,29,cognex_join_wait,3339.0, +18:12:01.801,29,cognex_join_done,3340.9, +18:12:01.809,29,cycle_done,3342.8,result=PASS cognex=PASS basler=PASS +18:12:01.809,30,cycle_start,0.0,group=A belt_delay=3.33s +18:12:01.809,30,cognex_trigger_send,2.3, +18:12:01.820,30,cognex_trigger_ok,8.8, +18:12:02.823,30,cognex_ftp_start,1011.4, +18:12:03.551,30,cognex_ftp_done,1749.0,3686454bytes +18:12:03.551,30,cognex_patmax_start,1750.5, +18:12:03.686,30,cognex_patmax_done,1876.1, +18:12:05.146,30,basler_capture_start,3334.1, +18:12:05.146,30,basler_capture_done,3337.2,failed +18:12:05.146,30,cognex_join_wait,3339.3, +18:12:05.146,30,cognex_join_done,3341.1, +18:12:05.146,30,cycle_done,3342.9,result=PASS cognex=PASS basler=PASS +18:12:05.157,31,cycle_start,0.0,group=A belt_delay=3.33s +18:12:05.157,31,cognex_trigger_send,2.3, +18:12:05.165,31,cognex_trigger_ok,8.7, +18:12:06.169,31,cognex_ftp_start,1011.4, +18:12:06.886,31,cognex_ftp_done,1739.1,3686454bytes +18:12:06.886,31,cognex_patmax_start,1740.6, +18:12:07.020,31,cognex_patmax_done,1868.9, +18:12:08.491,31,basler_capture_start,3333.6, +18:12:08.491,31,basler_capture_done,3336.5,failed +18:12:08.491,31,cognex_join_wait,3338.3, +18:12:08.491,31,cognex_join_done,3339.5, +18:12:08.491,31,cycle_done,3340.8,result=PASS cognex=PASS basler=PASS +18:12:08.491,32,cycle_start,0.0,group=A belt_delay=3.33s +18:12:08.491,32,cognex_trigger_send,1.7, +18:12:08.507,32,cognex_trigger_ok,7.6, +18:12:09.509,32,cognex_ftp_start,1009.6, +18:12:10.234,32,cognex_ftp_done,1743.2,3686454bytes +18:12:10.234,32,cognex_patmax_start,1744.6, +18:12:10.370,32,cognex_patmax_done,1872.5, +18:12:11.833,32,basler_capture_start,3333.5, +18:12:11.833,32,basler_capture_done,3336.8,failed +18:12:11.833,32,cognex_join_wait,3338.8, +18:12:11.833,32,cognex_join_done,3340.6, +18:12:11.842,32,cycle_done,3342.5,result=PASS cognex=PASS basler=PASS +18:12:11.842,33,cycle_start,0.0,group=A belt_delay=3.33s +18:12:11.842,33,cognex_trigger_send,2.4, +18:12:11.854,33,cognex_trigger_ok,9.2, +18:12:12.856,33,cognex_ftp_start,1011.9, +18:12:13.586,33,cognex_ftp_done,1749.6,3686454bytes +18:12:13.586,33,cognex_patmax_start,1751.1, +18:12:13.704,33,cognex_patmax_done,1869.1, +18:12:15.179,33,basler_capture_start,3334.2, +18:12:15.179,33,basler_capture_done,3337.3,failed +18:12:15.179,33,cognex_join_wait,3339.3, +18:12:15.179,33,cognex_join_done,3341.2, +18:12:15.179,33,cycle_done,3343.1,result=PASS cognex=PASS basler=PASS +18:12:15.179,34,cycle_start,0.0,group=A belt_delay=3.33s +18:12:15.190,34,cognex_trigger_send,2.5, +18:12:15.198,34,cognex_trigger_ok,9.1, +18:12:16.201,34,cognex_ftp_start,1011.3, +18:12:16.920,34,cognex_ftp_done,1738.0,3686454bytes +18:12:16.920,34,cognex_patmax_start,1739.4, +18:12:17.053,34,cognex_patmax_done,1868.0, +18:12:18.524,34,basler_capture_start,3333.6, +18:12:18.524,34,basler_capture_done,3336.8,failed +18:12:18.524,34,cognex_join_wait,3338.7, +18:12:18.524,34,cognex_join_done,3340.6, +18:12:18.524,34,cycle_done,3342.5,result=PASS cognex=PASS basler=PASS diff --git a/logs/timing/2026-05-07.csv b/logs/timing/2026-05-07.csv new file mode 100644 index 0000000..567bc21 --- /dev/null +++ b/logs/timing/2026-05-07.csv @@ -0,0 +1,97 @@ +timestamp,seq,event,elapsed_ms,detail +17:36:47.998,1,cycle_start,0.0,group=A belt_delay=3.33s +17:36:47.998,1,cognex_trigger_send,8.0, +17:36:48.045,1,cognex_trigger_ok,44.9, +17:36:49.051,1,cognex_ftp_start,1047.1, +17:36:49.787,1,cognex_ftp_done,1791.9,3686454bytes +17:36:49.787,1,cognex_patmax_start,1793.3, +17:36:49.808,1,cognex_patmax_done,1806.9, +17:36:51.338,1,basler_capture_start,3333.8, +17:36:51.340,1,basler_capture_done,3336.6,failed +17:36:51.342,1,cognex_join_wait,3338.6, +17:36:51.344,1,cognex_join_done,3340.6, +17:36:51.346,1,cycle_done,3342.5,result=FAIL cognex=FAIL basler=PASS +17:36:51.349,2,cycle_start,0.0,group=A belt_delay=3.33s +17:36:51.352,2,cognex_trigger_send,2.4, +17:36:51.384,2,cognex_trigger_ok,35.1, +17:36:52.387,2,cognex_ftp_start,1037.2, +17:36:53.137,2,cognex_ftp_done,1790.2,3686454bytes +17:36:53.137,2,cognex_patmax_start,1791.5, +17:36:53.153,2,cognex_patmax_done,1802.8, +17:36:54.684,2,basler_capture_start,3333.8, +17:36:54.684,2,basler_capture_done,3336.8,failed +17:36:54.684,2,cognex_join_wait,3338.7, +17:36:54.684,2,cognex_join_done,3340.8, +17:36:54.684,2,cycle_done,3342.7,result=FAIL cognex=FAIL basler=PASS +17:36:54.696,3,cycle_start,0.0,group=A belt_delay=3.33s +17:36:54.696,3,cognex_trigger_send,2.3, +17:36:54.727,3,cognex_trigger_ok,35.1, +17:36:55.734,3,cognex_ftp_start,1037.6, +17:36:56.454,3,cognex_ftp_done,1773.0,3686454bytes +17:36:56.471,3,cognex_patmax_start,1774.7, +17:36:56.471,3,cognex_patmax_done,1786.2, +17:36:58.030,3,basler_capture_start,3334.1, +17:36:58.030,3,basler_capture_done,3337.3,failed +17:36:58.030,3,cognex_join_wait,3339.2, +17:36:58.030,3,cognex_join_done,3341.2, +17:36:58.030,3,cycle_done,3343.5,result=FAIL cognex=FAIL basler=PASS +17:36:58.043,4,cycle_start,0.0,group=A belt_delay=3.33s +17:36:58.043,4,cognex_trigger_send,2.4, +17:36:58.075,4,cognex_trigger_ok,35.7, +17:36:59.082,4,cognex_ftp_start,1038.2, +17:36:59.820,4,cognex_ftp_done,1776.9,3686454bytes +17:36:59.821,4,cognex_patmax_start,1778.4, +17:36:59.821,4,cognex_patmax_done,1789.5, +17:37:01.377,4,basler_capture_start,3333.8, +17:37:01.377,4,basler_capture_done,3336.9,failed +17:37:01.377,4,cognex_join_wait,3339.0, +17:37:01.377,4,cognex_join_done,3340.9, +17:37:01.377,4,cycle_done,3342.9,result=FAIL cognex=FAIL basler=PASS +17:37:01.377,5,cycle_start,0.0,group=A belt_delay=3.33s +17:37:01.377,5,cognex_trigger_send,2.3, +17:37:01.422,5,cognex_trigger_ok,35.2, +17:37:02.428,5,cognex_ftp_start,1037.7, +17:37:03.187,5,cognex_ftp_done,1799.2,3686454bytes +17:37:03.187,5,cognex_patmax_start,1800.6, +17:37:03.187,5,cognex_patmax_done,1811.8, +17:37:04.724,5,basler_capture_start,3334.0, +17:37:04.724,5,basler_capture_done,3337.2,failed +17:37:04.729,5,cognex_join_wait,3339.3, +17:37:04.729,5,cognex_join_done,3341.3, +17:37:04.729,5,cycle_done,3343.2,result=FAIL cognex=FAIL basler=PASS +17:37:04.729,6,cycle_start,0.0,group=A belt_delay=3.33s +17:37:04.729,6,cognex_trigger_send,2.3, +17:37:04.760,6,cognex_trigger_ok,35.0, +17:37:05.774,6,cognex_ftp_start,1037.5, +17:37:06.521,6,cognex_ftp_done,1789.0,3686454bytes +17:37:06.521,6,cognex_patmax_start,1790.4, +17:37:06.537,6,cognex_patmax_done,1801.4, +17:37:08.070,6,basler_capture_start,3333.9, +17:37:08.070,6,basler_capture_done,3336.9,failed +17:37:08.070,6,cognex_join_wait,3339.0, +17:37:08.070,6,cognex_join_done,3340.9, +17:37:08.078,6,cycle_done,3342.8,result=FAIL cognex=FAIL basler=PASS +17:37:08.078,7,cycle_start,0.0,group=A belt_delay=3.33s +17:37:08.078,7,cognex_trigger_send,2.3, +17:37:08.109,7,cognex_trigger_ok,35.6, +17:37:09.121,7,cognex_ftp_start,1037.9, +17:37:09.871,7,cognex_ftp_done,1791.7,3686454bytes +17:37:09.871,7,cognex_patmax_start,1793.2, +17:37:09.871,7,cognex_patmax_done,1802.6, +17:37:11.417,7,basler_capture_start,3333.7, +17:37:11.417,7,basler_capture_done,3336.5,failed +17:37:11.417,7,cognex_join_wait,3337.9, +17:37:11.417,7,cognex_join_done,3339.2, +17:37:11.417,7,cycle_done,3340.6,result=FAIL cognex=FAIL basler=PASS +17:37:11.417,8,cycle_start,0.0,group=A belt_delay=3.33s +17:37:11.427,8,cognex_trigger_send,1.7, +17:37:11.458,8,cognex_trigger_ok,33.6, +17:37:12.462,8,cognex_ftp_start,1036.0, +17:37:13.187,8,cognex_ftp_done,1764.3,3686454bytes +17:37:13.187,8,cognex_patmax_start,1765.7, +17:37:13.203,8,cognex_patmax_done,1777.1, +17:37:14.760,8,basler_capture_start,3333.5, +17:37:14.760,8,basler_capture_done,3336.4,failed +17:37:14.760,8,cognex_join_wait,3338.3, +17:37:14.766,8,cognex_join_done,3340.2, +17:37:14.766,8,cycle_done,3342.1,result=FAIL cognex=FAIL basler=PASS diff --git a/logs/timing/2026-05-08.csv b/logs/timing/2026-05-08.csv new file mode 100644 index 0000000..5c87528 --- /dev/null +++ b/logs/timing/2026-05-08.csv @@ -0,0 +1,109 @@ +timestamp,seq,event,elapsed_ms,detail +14:32:59.310,1,cycle_start,0.0,group=A belt_delay=3.33s +14:32:59.310,1,cognex_trigger_send,8.2, +14:32:59.310,1,cognex_trigger_ok,14.9, +14:33:00.324,1,cognex_ftp_start,1017.5, +14:33:01.068,1,cognex_ftp_done,1770.0,3686454bytes +14:33:01.068,1,cognex_patmax_start,1771.6, +14:33:01.083,1,cognex_patmax_done,1784.4, +14:33:02.640,1,basler_capture_start,3333.9, +14:33:03.071,1,basler_capture_done,3778.9,"(4504, 4504)" +14:33:04.120,1,cognex_join_wait,4815.0, +14:33:04.120,1,cognex_join_done,4816.8, +14:33:04.136,1,cycle_done,4843.3,result=FAIL cognex=FAIL basler=PASS +14:33:04.152,2,cycle_start,0.0,group=A belt_delay=3.33s +14:33:04.152,2,cognex_trigger_send,2.3, +14:33:04.161,2,cognex_trigger_ok,8.7, +14:33:05.165,2,cognex_ftp_start,1011.3, +14:33:05.903,2,cognex_ftp_done,1754.8,3686454bytes +14:33:05.903,2,cognex_patmax_start,1756.2, +14:33:05.918,2,cognex_patmax_done,1767.5, +14:33:07.488,2,basler_capture_start,3334.0, +14:33:07.635,2,basler_capture_done,3495.6,"(4504, 4504)" +14:33:07.651,2,cognex_join_wait,3500.8, +14:33:07.651,2,cognex_join_done,3503.1, +14:33:07.679,2,cycle_done,3524.7,result=FAIL cognex=FAIL basler=PASS +14:33:07.683,3,cycle_start,0.0,group=A belt_delay=3.33s +14:33:07.686,3,cognex_trigger_send,2.4, +14:33:07.692,3,cognex_trigger_ok,9.2, +14:33:08.695,3,cognex_ftp_start,1011.9, +14:33:09.420,3,cognex_ftp_done,1751.4,3686454bytes +14:33:09.435,3,cognex_patmax_start,1753.0, +14:33:09.435,3,cognex_patmax_done,1764.2, +14:33:11.017,3,basler_capture_start,3334.2, +14:33:11.170,3,basler_capture_done,3499.9,"(4504, 4504)" +14:33:11.185,3,cognex_join_wait,3506.1, +14:33:11.211,3,cognex_join_done,3519.0, +14:33:11.213,3,cycle_done,3530.5,result=FAIL cognex=FAIL basler=PASS +14:33:11.217,4,cycle_start,0.0,group=A belt_delay=3.33s +14:33:11.219,4,cognex_trigger_send,2.4, +14:33:11.226,4,cognex_trigger_ok,9.0, +14:33:12.228,4,cognex_ftp_start,1011.2, +14:33:12.969,4,cognex_ftp_done,1758.8,3686454bytes +14:33:12.969,4,cognex_patmax_start,1760.3, +14:33:12.985,4,cognex_patmax_done,1771.7, +14:33:14.551,4,basler_capture_start,3334.3, +14:33:14.703,4,basler_capture_done,3498.9,"(4504, 4504)" +14:33:14.718,4,cognex_join_wait,3505.0, +14:33:14.734,4,cognex_join_done,3522.9, +14:33:14.743,4,cycle_done,3526.9,result=FAIL cognex=FAIL basler=PASS +14:33:14.747,5,cycle_start,0.0,group=A belt_delay=3.33s +14:33:14.750,5,cognex_trigger_send,2.4, +14:33:14.757,5,cognex_trigger_ok,8.9, +14:33:15.759,5,cognex_ftp_start,1011.3, +14:33:16.504,5,cognex_ftp_done,1761.8,3686454bytes +14:33:16.504,5,cognex_patmax_start,1763.3, +14:33:16.519,5,cognex_patmax_done,1774.5, +14:33:18.082,5,basler_capture_start,3334.0, +14:33:18.282,5,basler_capture_done,3543.4,"(4504, 4504)" +14:33:18.298,5,cognex_join_wait,3550.7, +14:33:18.314,5,cognex_join_done,3567.9, +14:33:18.314,5,cycle_done,3571.6,result=FAIL cognex=FAIL basler=PASS +14:33:18.314,6,cycle_start,0.0,group=A belt_delay=3.33s +14:33:18.314,6,cognex_trigger_send,2.4, +14:33:18.332,6,cognex_trigger_ok,9.0, +14:33:19.335,6,cognex_ftp_start,1011.5, +14:33:20.069,6,cognex_ftp_done,1754.5,3686454bytes +14:33:20.069,6,cognex_patmax_start,1756.0, +14:33:20.085,6,cognex_patmax_done,1767.5, +14:33:21.657,6,basler_capture_start,3334.1, +14:33:21.865,6,basler_capture_done,3544.9,"(4504, 4504)" +14:33:21.865,6,cognex_join_wait,3551.8, +14:33:21.881,6,cognex_join_done,3562.9, +14:33:21.896,6,cycle_done,3573.4,result=FAIL cognex=FAIL basler=PASS +14:33:21.900,7,cycle_start,0.0,group=A belt_delay=3.33s +14:33:21.902,7,cognex_trigger_send,2.4, +14:33:21.908,7,cognex_trigger_ok,8.9, +14:33:22.911,7,cognex_ftp_start,1011.2, +14:33:23.652,7,cognex_ftp_done,1757.8,3686454bytes +14:33:23.652,7,cognex_patmax_start,1759.3, +14:33:23.668,7,cognex_patmax_done,1768.4, +14:33:25.234,7,basler_capture_start,3334.2, +14:33:25.447,7,basler_capture_done,3557.9,"(4504, 4504)" +14:33:25.463,7,cognex_join_wait,3564.9, +14:33:25.463,7,cognex_join_done,3566.6, +14:33:25.478,7,cycle_done,3583.8,result=FAIL cognex=FAIL basler=PASS +14:33:25.478,8,cycle_start,0.0,group=A belt_delay=3.33s +14:33:25.478,8,cognex_trigger_send,1.7, +14:33:25.497,8,cognex_trigger_ok,10.0, +14:33:26.500,8,cognex_ftp_start,1012.4, +14:33:27.221,8,cognex_ftp_done,1747.7,3686454bytes +14:33:27.236,8,cognex_patmax_start,1749.2, +14:33:27.236,8,cognex_patmax_done,1760.8, +14:33:28.822,8,basler_capture_start,3334.2, +14:33:29.032,8,basler_capture_done,3556.5,"(4504, 4504)" +14:33:29.048,8,cognex_join_wait,3563.6, +14:33:29.064,8,cognex_join_done,3576.3, +14:33:29.064,8,cycle_done,3587.3,result=FAIL cognex=FAIL basler=PASS +14:33:29.077,9,cycle_start,0.0,group=A belt_delay=3.33s +14:33:29.080,9,cognex_trigger_send,2.3, +14:33:29.087,9,cognex_trigger_ok,9.0, +14:33:30.089,9,cognex_ftp_start,1011.1, +14:33:30.804,9,cognex_ftp_done,1737.9,3686454bytes +14:33:30.804,9,cognex_patmax_start,1739.4, +14:33:30.829,9,cognex_patmax_done,1750.8, +14:33:32.412,9,basler_capture_start,3333.8, +14:33:32.662,9,basler_capture_done,3590.5,"(4504, 4504)" +14:33:32.662,9,cognex_join_wait,3597.5, +14:33:32.693,9,cognex_join_done,3619.7, +14:33:32.693,9,cycle_done,3623.2,result=FAIL cognex=FAIL basler=PASS diff --git a/logs/timing/2026-05-13.csv b/logs/timing/2026-05-13.csv new file mode 100644 index 0000000..a8de0b3 --- /dev/null +++ b/logs/timing/2026-05-13.csv @@ -0,0 +1,157 @@ +timestamp,seq,event,elapsed_ms,detail +09:17:45.802,1,cycle_start,0.0,group=A belt_delay=3.33s +09:17:45.802,1,cognex_trigger_send,2.0, +09:17:45.844,1,cognex_trigger_ok,34.8, +09:17:46.848,1,cognex_ftp_start,1037.4, +09:17:47.596,1,cognex_ftp_done,1788.8,3686454bytes +09:17:47.596,1,cognex_patmax_start,1790.2, +09:17:47.612,1,cognex_patmax_done,1801.3, +09:17:49.144,1,basler_capture_start,3333.8, +09:17:49.144,1,basler_capture_done,3337.2,failed +09:17:49.144,1,cognex_join_wait,3339.7, +09:17:49.144,1,cognex_join_done,3341.7, +09:17:49.153,1,cycle_done,3343.7,result=FAIL cognex=FAIL basler=PASS +09:17:49.153,2,cycle_start,0.0,group=A belt_delay=3.33s +09:17:49.153,2,cognex_trigger_send,2.4, +09:17:49.184,2,cognex_trigger_ok,35.3, +09:17:50.196,2,cognex_ftp_start,1038.2, +09:17:50.929,2,cognex_ftp_done,1773.1,3686454bytes +09:17:50.929,2,cognex_patmax_start,1774.5, +09:17:50.929,2,cognex_patmax_done,1785.3, +09:17:52.492,2,basler_capture_start,3334.1, +09:17:52.492,2,basler_capture_done,3337.1,failed +09:17:52.492,2,cognex_join_wait,3339.1, +09:17:52.492,2,cognex_join_done,3341.1, +09:17:52.492,2,cycle_done,3343.0,result=FAIL cognex=FAIL basler=PASS +09:17:52.502,3,cycle_start,0.0,group=A belt_delay=3.33s +09:17:52.502,3,cognex_trigger_send,2.3, +09:17:52.533,3,cognex_trigger_ok,35.5, +09:17:53.542,3,cognex_ftp_start,1038.1, +09:17:54.263,3,cognex_ftp_done,1773.4,3686454bytes +09:17:54.279,3,cognex_patmax_start,1774.9, +09:17:54.279,3,cognex_patmax_done,1786.1, +09:17:55.838,3,basler_capture_start,3333.8, +09:17:55.838,3,basler_capture_done,3337.9,failed +09:17:55.838,3,cognex_join_wait,3340.0, +09:17:55.838,3,cognex_join_done,3341.9, +09:17:55.838,3,cycle_done,3343.8,result=FAIL cognex=FAIL basler=PASS +09:17:55.851,4,cycle_start,0.0,group=A belt_delay=3.33s +09:17:55.851,4,cognex_trigger_send,2.3, +09:17:55.882,4,cognex_trigger_ok,35.0, +09:17:56.889,4,cognex_ftp_start,1037.4, +09:17:57.628,4,cognex_ftp_done,1777.1,3686454bytes +09:17:57.629,4,cognex_patmax_start,1778.8, +09:17:57.629,4,cognex_patmax_done,1789.8, +09:17:59.185,4,basler_capture_start,3333.8, +09:17:59.185,4,basler_capture_done,3337.5,failed +09:17:59.185,4,cognex_join_wait,3339.4, +09:17:59.185,4,cognex_join_done,3341.3, +09:17:59.185,4,cycle_done,3343.2,result=FAIL cognex=FAIL basler=PASS +09:17:59.185,5,cycle_start,0.0,group=A belt_delay=3.33s +09:17:59.185,5,cognex_trigger_send,2.3, +09:17:59.232,5,cognex_trigger_ok,34.7, +09:18:00.235,5,cognex_ftp_start,1037.0, +09:18:00.963,5,cognex_ftp_done,1765.1,3686454bytes +09:18:00.963,5,cognex_patmax_start,1766.7, +09:18:00.963,5,cognex_patmax_done,1777.6, +09:18:02.532,5,basler_capture_start,3334.0, +09:18:02.535,5,basler_capture_done,3337.4,failed +09:18:02.535,5,cognex_join_wait,3339.8, +09:18:02.535,5,cognex_join_done,3341.7, +09:18:02.535,5,cycle_done,3343.7,result=FAIL cognex=FAIL basler=PASS +09:18:02.535,6,cycle_start,0.0,group=A belt_delay=3.33s +09:18:02.535,6,cognex_trigger_send,2.4, +09:18:02.567,6,cognex_trigger_ok,35.8, +09:18:03.584,6,cognex_ftp_start,1038.2, +09:18:04.329,6,cognex_ftp_done,1784.6,3686454bytes +09:18:04.329,6,cognex_patmax_start,1786.0, +09:18:04.329,6,cognex_patmax_done,1796.8, +09:18:05.880,6,basler_capture_start,3334.1, +09:18:05.880,6,basler_capture_done,3337.4,failed +09:18:05.880,6,cognex_join_wait,3339.4, +09:18:05.886,6,cognex_join_done,3341.4, +09:18:05.886,6,cycle_done,3343.3,result=FAIL cognex=FAIL basler=PASS +09:18:05.886,7,cycle_start,0.0,group=A belt_delay=3.33s +09:18:05.886,7,cognex_trigger_send,2.3, +09:18:05.918,7,cognex_trigger_ok,35.7, +09:18:06.931,7,cognex_ftp_start,1038.3, +09:18:07.679,7,cognex_ftp_done,1787.3,3686454bytes +09:18:07.679,7,cognex_patmax_start,1788.9, +09:18:07.679,7,cognex_patmax_done,1797.9, +09:18:09.227,7,basler_capture_start,3334.1, +09:18:09.227,7,basler_capture_done,3338.3,failed +09:18:09.227,7,cognex_join_wait,3340.1, +09:18:09.227,7,cognex_join_done,3341.4, +09:18:09.227,7,cycle_done,3342.7,result=FAIL cognex=FAIL basler=PASS +09:18:09.236,8,cycle_start,0.0,group=A belt_delay=3.33s +09:18:09.236,8,cognex_trigger_send,1.6, +09:18:09.267,8,cognex_trigger_ok,33.6, +09:18:10.274,8,cognex_ftp_start,1035.8, +09:18:11.029,8,cognex_ftp_done,1792.5,3686454bytes +09:18:11.029,8,cognex_patmax_start,1794.0, +09:18:11.029,8,cognex_patmax_done,1805.1, +09:18:12.572,8,basler_capture_start,3333.9, +09:18:12.572,8,basler_capture_done,3337.1,failed +09:18:12.572,8,cognex_join_wait,3339.0, +09:18:12.572,8,cognex_join_done,3340.9, +09:18:12.572,8,cycle_done,3342.8,result=FAIL cognex=FAIL basler=PASS +09:18:12.572,9,cycle_start,0.0,group=A belt_delay=3.33s +09:18:12.585,9,cognex_trigger_send,2.2, +09:18:12.617,9,cognex_trigger_ok,35.1, +09:18:13.622,9,cognex_ftp_start,1037.4, +09:18:14.379,9,cognex_ftp_done,1795.2,3686454bytes +09:18:14.379,9,cognex_patmax_start,1796.8, +09:18:14.379,9,cognex_patmax_done,1807.6, +09:18:15.918,9,basler_capture_start,3334.2, +09:18:15.922,9,basler_capture_done,3337.8,failed +09:18:15.922,9,cognex_join_wait,3340.4, +09:18:15.922,9,cognex_join_done,3342.3, +09:18:15.922,9,cycle_done,3344.1,result=FAIL cognex=FAIL basler=PASS +09:18:15.922,10,cycle_start,0.0,group=A belt_delay=3.33s +09:18:15.922,10,cognex_trigger_send,2.2, +09:18:15.953,10,cognex_trigger_ok,35.3, +09:18:16.970,10,cognex_ftp_start,1038.3, +09:18:17.713,10,cognex_ftp_done,1782.4,3686454bytes +09:18:17.713,10,cognex_patmax_start,1783.9, +09:18:17.713,10,cognex_patmax_done,1795.0, +09:18:19.265,10,basler_capture_start,3333.9, +09:18:19.265,10,basler_capture_done,3336.9,failed +09:18:19.269,10,cognex_join_wait,3338.8, +09:18:19.269,10,cognex_join_done,3340.7, +09:18:19.269,10,cycle_done,3342.6,result=FAIL cognex=FAIL basler=PASS +09:18:19.269,11,cycle_start,0.0,group=A belt_delay=3.33s +09:18:19.269,11,cognex_trigger_send,2.2, +09:18:19.300,11,cognex_trigger_ok,35.9, +09:18:20.316,11,cognex_ftp_start,1038.1, +09:18:21.063,11,cognex_ftp_done,1786.5,3686454bytes +09:18:21.063,11,cognex_patmax_start,1788.0, +09:18:21.063,11,cognex_patmax_done,1798.9, +09:18:22.611,11,basler_capture_start,3333.9, +09:18:22.611,11,basler_capture_done,3336.9,failed +09:18:22.611,11,cognex_join_wait,3340.2, +09:18:22.619,11,cognex_join_done,3342.2, +09:18:22.619,11,cycle_done,3344.0,result=FAIL cognex=FAIL basler=PASS +09:18:22.619,12,cycle_start,0.0,group=A belt_delay=3.33s +09:18:22.619,12,cognex_trigger_send,2.3, +09:18:22.650,12,cognex_trigger_ok,35.8, +09:18:23.663,12,cognex_ftp_start,1037.8, +09:18:24.413,12,cognex_ftp_done,1789.3,3686454bytes +09:18:24.413,12,cognex_patmax_start,1790.7, +09:18:24.413,12,cognex_patmax_done,1801.7, +09:18:25.959,12,basler_capture_start,3333.7, +09:18:25.959,12,basler_capture_done,3336.6,failed +09:18:25.959,12,cognex_join_wait,3338.6, +09:18:25.959,12,cognex_join_done,3340.5, +09:18:25.959,12,cycle_done,3342.3,result=FAIL cognex=FAIL basler=PASS +09:18:25.969,13,cycle_start,0.0,group=A belt_delay=3.33s +09:18:25.969,13,cognex_trigger_send,2.3, +09:18:26.004,13,cognex_trigger_ok,34.8, +09:18:27.009,13,cognex_ftp_start,1038.3, +09:18:27.746,13,cognex_ftp_done,1775.8,3686454bytes +09:18:27.746,13,cognex_patmax_start,1777.3, +09:18:27.746,13,cognex_patmax_done,1788.4, +09:18:29.305,13,basler_capture_start,3333.8, +09:18:29.305,13,basler_capture_done,3337.0,failed +09:18:29.305,13,cognex_join_wait,3338.9, +09:18:29.305,13,cognex_join_done,3340.8, +09:18:29.305,13,cycle_done,3342.6,result=FAIL cognex=FAIL basler=PASS diff --git a/logs/timing/2026-05-20.csv b/logs/timing/2026-05-20.csv new file mode 100644 index 0000000..a948e8d --- /dev/null +++ b/logs/timing/2026-05-20.csv @@ -0,0 +1,25 @@ +timestamp,seq,event,elapsed_ms,detail +10:10:37.775,1,cycle_start,0.0,group=A belt_delay=3.33s +10:10:37.775,1,cognex_trigger_send,1.9, +10:10:37.775,1,cognex_error,6.0,'NoneType' object has no attribute 'sendall' +10:10:41.118,1,basler_capture_start,3333.8, +10:10:41.118,1,basler_capture_done,3336.5,failed +10:10:41.118,1,cognex_join_wait,3338.5, +10:10:41.118,1,cognex_join_done,3340.4, +10:10:41.118,1,cycle_done,3342.4,result=FAIL cognex=FAIL basler=PASS +10:10:46.463,2,cycle_start,0.0,group=A belt_delay=3.33s +10:10:46.465,2,cognex_trigger_send,2.5, +10:10:46.469,2,cognex_error,6.0,'NoneType' object has no attribute 'sendall' +10:10:49.797,2,basler_capture_start,3333.9, +10:10:49.797,2,basler_capture_done,3336.8,failed +10:10:49.797,2,cognex_join_wait,3338.8, +10:10:49.797,2,cognex_join_done,3340.8, +10:10:49.797,2,cycle_done,3342.7,result=FAIL cognex=FAIL basler=PASS +10:10:49.797,3,cycle_start,0.0,group=A belt_delay=3.33s +10:10:49.810,3,cognex_trigger_send,2.3, +10:10:49.810,3,cognex_error,5.6,'NoneType' object has no attribute 'sendall' +10:10:53.143,3,basler_capture_start,3334.0, +10:10:53.143,3,basler_capture_done,3337.1,failed +10:10:53.143,3,cognex_join_wait,3339.3, +10:10:53.143,3,cognex_join_done,3341.3, +10:10:53.151,3,cycle_done,3343.2,result=FAIL cognex=FAIL basler=PASS diff --git a/main.py b/main.py new file mode 100644 index 0000000..1234eac --- /dev/null +++ b/main.py @@ -0,0 +1,156 @@ +# 엔트리포인트 — 스플래시 화면 표시 후 백그라운드 초기화, GUI 실행 +import multiprocessing +import sys + +from logger import setup_logging, teardown_logging + +from PyQt5.QtWidgets import QApplication +from PyQt5.QtGui import QIcon + +from utils.path_helper import get_path +from gui.main_window import MainWindow +from gui.splash_screen import SplashScreen, InitWorker + +ICON_PATH = get_path("assets", "images", "ant_logo.png") + + +def _apply_windows_taskbar_icon(app_id: str = "ant.vision.inspection.1.0"): + if sys.platform != "win32": + return + try: + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + except Exception as e: + print(f"[main] AppUserModelID 설정 실패: {e}") + + +def main(): + # setup_logging() must be called inside main(), not at module level. + # On Windows, multiprocessing spawns subprocesses that re-import __main__, + # and module-level setup_logging() would corrupt the log file and replace + # builtins.print in every worker process. + setup_logging() + + _apply_windows_taskbar_icon() + + app = QApplication(sys.argv) + icon = QIcon(ICON_PATH) + app.setWindowIcon(icon) + app.setStyleSheet(_DARK_STYLE) + + splash = SplashScreen() + splash.show() + QApplication.processEvents() + + worker = InitWorker() + worker.progress.connect(splash.update_progress) + + def on_finished(results): + window = MainWindow( + results["insight"], + results["basler"], + results["config"], + plc_client=results.get("plc"), + ) + window.setWindowIcon(icon) + window.show() + splash.close() + + def cleanup(): + results["insight"].disconnect() + results["basler"].disconnect() + if results["db"].is_connected(): + results["db"].disconnect() + plc = results.get("plc") + if plc and plc.is_connected(): + plc.disconnect() + + app.aboutToQuit.connect(cleanup) + + worker.finished.connect(on_finished) + worker.start() + + try: + ret = app.exec_() + finally: + + teardown_logging() + + sys.exit(ret) + + +_DARK_STYLE = """ +QWidget { + background-color: #1a1a1a; + color: #ffffff; + font-size: 14px; +} +QGroupBox { + background-color: #222222; + border: 1px solid #444444; + border-radius: 6px; + margin-top: 14px; + padding: 10px 8px 8px 8px; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #aaaaaa; +} +QPushButton { + background-color: #2e2e2e; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + min-height: 56px; + padding: 0 20px; + font-size: 14px; +} +QPushButton:hover { background-color: #3a3a3a; } +QPushButton:pressed { background-color: #1e1e1e; } +QPushButton:checked { background-color: #0055cc; border-color: #0077ff; } +QPushButton:disabled { color: #666666; } +QLineEdit, QSpinBox, QDoubleSpinBox { + background-color: #2a2a2a; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 6px 8px; + min-height: 38px; +} +QLabel { color: #ffffff; } +QListWidget { + background-color: #222222; + color: #ffffff; + border: 1px solid #444444; + outline: none; +} +QListWidget::item:selected { background-color: #0055cc; } +QProgressBar { + background-color: #2a2a2a; + border: 1px solid #555555; + border-radius: 4px; + text-align: center; + min-height: 22px; +} +QProgressBar::chunk { background-color: #0055cc; border-radius: 4px; } +QCheckBox { min-height: 38px; } +QCheckBox::indicator { width: 22px; height: 22px; } +QScrollBar:vertical { + background: #2a2a2a; + width: 10px; + border-radius: 5px; +} +QScrollBar::handle:vertical { + background: #555555; + border-radius: 5px; + min-height: 30px; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +""" + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/main_py_필요모듈.txt b/main_py_필요모듈.txt new file mode 100644 index 0000000..a168ce2 --- /dev/null +++ b/main_py_필요모듈.txt @@ -0,0 +1,195 @@ +================================================================================ + main.py 실행에 필요한 모듈 및 환경 정리 + 프로젝트: ANT 리플렉터 비전 검사 시스템 + 분석 기준: E:\ANT 폴더 소스 코드 및 .venv 설치 목록 + 작성일: 2026-06-10 +================================================================================ + +■ 1. 실행 방법 +-------------------------------------------------------------------------------- + 1) 프로젝트 루트(E:\ANT)에서 실행 + python main.py + + 2) 가상환경 사용 권장 (.venv) + .venv\Scripts\activate + python main.py + + ※ Python 3.12 기준으로 .venv가 구성되어 있음 (pyvenv.cfg: 3.12.10) + + +■ 2. pip 설치 패키지 (서드파티 — 필수) +-------------------------------------------------------------------------------- + 패키지명 pip 명령 용도 사용 위치 + ───────────────────────────────────────────────────────────────────────────── + PyQt5 pip install PyQt5 GUI 프레임워크 main.py, gui/* + opencv-python pip install opencv-python 이미지 처리, ORB/NCC inspect_page, register_page, + retrain_page, pattern_matcher, inspector + numpy pip install numpy 배열/이미지 데이터 detector, basler, inspect_page 등 + ultralytics pip install ultralytics YOLOv8 추론/재학습 ai/detector.py, ai/trainer.py + pypylon pip install pypylon Basler 카메라 SDK camera/basler.py + pyodbc pip install pyodbc MS SQL Server 연결 db/sql_client.py + pymelsec pip install pymelsec Mitsubishi PLC (MC 3E) plc/plc_client.py + pyyaml pip install pyyaml 학습 데이터셋 YAML 생성 ai/trainer.py + + ※ ultralytics 설치 시 torch, torchvision 등 AI 관련 의존성이 함께 설치됨 + ※ 현재 .venv 기준: torch 2.5.1+cu121, torchvision 0.20.1+cu121, ultralytics 8.4.41 + + +■ 3. pip 설치 패키지 (간접/자동 의존성) +-------------------------------------------------------------------------------- + ultralytics → torch, torchvision, scipy, matplotlib, pillow, polars, psutil 등 + opencv-python → numpy + PyQt5 → PyQt5-Qt5, PyQt5-sip + + 현재 .venv에 함께 설치된 주요 간접 패키지: + - scipy 1.17.1 + - matplotlib 3.10.9 + - pillow 12.1.1 + - requests 2.33.1 + - lap 0.5.13 (ultralytics 추적용) + + +■ 4. pip 설치 패키지 (선택 / 레거시) +-------------------------------------------------------------------------------- + pymysql pip install pymysql MySQL 클라이언트 (레거시) db/mysql_client.py + ※ main.py 실행 경로에서는 미사용 + ※ db/sql_client.py (pyodbc)가 실제 사용됨 + + pyinstaller pip install pyinstaller EXE 빌드용 reflector_inspector.spec + ※ 앱 실행 자체에는 불필요 + + +■ 5. 시스템 / 외부 소프트웨어 의존성 +-------------------------------------------------------------------------------- + [필수 — Windows] + - Windows 10/11 (main.py에서 작업표시줄 아이콘 설정 시 ctypes 사용) + + [DB 사용 시] + - Microsoft ODBC Driver 18 for SQL Server + ※ pyodbc 연결 문자열: DRIVER={ODBC Driver 18 for SQL Server} + ※ config.json → db.server / database / username / password + + [Basler 카메라 사용 시] + - Basler pylon Camera Software Suite (별도 설치 필수) + 다운로드: https://www.baslerweb.com/en/downloads/software-downloads/ + ※ pypylon은 pylon SDK가 먼저 설치되어 있어야 동작함 + + [Cognex In-Sight 카메라 사용 시] + - 네트워크 연결 (Telnet 포트 23, FTP) + ※ config.json → cognex.ip / cognex.port + + [PLC 사용 시] + - Mitsubishi PLC (MC Protocol 3E, 기본 포트 5010) + ※ config.json → plc.ip / plc.port + + [AI 추론/재학습 GPU 가속 (선택)] + - NVIDIA GPU + CUDA 12.1 드라이버 + ※ CPU만으로도 실행 가능 (속도는 느림) + + +■ 6. 프로젝트 내부 모듈 (로컬 Python 패키지) +-------------------------------------------------------------------------------- + main.py가 직접/간접으로 import하는 프로젝트 모듈: + + [루트] + logger.py — 로그 설정/해제, 검사·타이밍·학습 로그 + paths.py — PROJECT_ROOT / BUNDLE_ROOT 경로 해석 + + [utils/] + utils/path_helper.py — get_path(), BASE_PATH (PyInstaller 호환) + + [gui/] + gui/main_window.py — 메인 윈도우 (4탭 네비게이션) + gui/splash_screen.py — 스플래시 화면 + InitWorker (백그라운드 초기화) + gui/pages/settings_page.py — 환경설정 탭 + gui/pages/register_page.py — 제품 등록 탭 + gui/pages/inspect_page.py — 검사 탭 + gui/pages/retrain_page.py — 재학습 탭 + gui/dialogs/image_settings_dialog.py — 이미지 설정 다이얼로그 + + [camera/] + camera/insight.py — Cognex In-Sight (Telnet + FTP) + camera/basler.py — Basler USB 카메라 (pypylon) + + [ai/] + ai/detector.py — YOLOv8 불량 검출 (ultralytics 지연 로딩) + ai/trainer.py — YOLOv8 재학습 + TrainWorker (subprocess 격리) + + [db/] + db/sql_client.py — MS SQL Server (pyodbc) + + [plc/] + plc/plc_client.py — Mitsubishi PLC (pymelsec Type3E) + + [logic/] + logic/inspector.py — PatMax 결과 판독 + Pass/Fail + logic/group_manager.py — A/B 그룹 전환 + logic/pattern_matcher.py — ORB + NCC 패턴 매칭 + + +■ 7. Python 표준 라이브러리 (별도 pip 설치 불필요) +-------------------------------------------------------------------------------- + multiprocessing, sys, json, os, socket, ftplib, io, time, + threading, itertools, pickle, shutil, random, csv, builtins, + pathlib, datetime, typing, ctypes (Windows 작업표시줄 아이콘) + + +■ 8. 실행 시 필요한 파일 / 리소스 +-------------------------------------------------------------------------------- + [필수] + config.json — 카메라/DB/AI/PLC 설정 (프로젝트 루트) + + [권장 / 기능별] + ai/models/best.pt — YOLO AI 모델 (config.json → ai.model_path) + assets/images/ant_logo.png — 앱 아이콘 (main.py ICON_PATH) + assets/patterns.pkl — PatternMatcher 등록 패턴 (없으면 신규 생성 가능) + + [자동 생성] + logs/app/ — 앱 로그 + logs/inspect/ — 검사 결과 CSV + logs/timing/ — 카메라 타이밍 CSV + logs/train/ — 재학습 로그 + + +■ 9. main.py 실행 흐름 (모듈 로딩 순서) +-------------------------------------------------------------------------------- + main.py + → logger.setup_logging() + → PyQt5 QApplication 생성 + → gui/splash_screen.SplashScreen 표시 + → gui/splash_screen.InitWorker (QThread) 백그라운드 초기화: + 1) config.json 로드 + 2) camera/insight.InSightCamera 연결 + 3) camera/basler.BaslerCamera 연결 + 4) ai/detector.Detector 모델 로드 (ultralytics) + 5) db/sql_client.SQLClient DB 연결 (pyodbc) + 6) plc/plc_client.PLCClient 연결 (pymelsec) + → gui/main_window.MainWindow 표시 + → PyQt5 이벤트 루프 (app.exec_()) + → logger.teardown_logging() + + +■ 10. 한 번에 설치하는 pip 명령 (권장) +-------------------------------------------------------------------------------- + pip install PyQt5 opencv-python numpy ultralytics pypylon pyodbc pymelsec pyyaml + + ※ GPU(CUDA 12.1) torch가 필요한 경우 ultralytics 설치 전/후에 + PyTorch 공식 사이트 기준 cu121 wheel을 별도 설치할 수 있음. + + +■ 11. 현재 .venv 설치 버전 참고 (2026-06-10 기준) +-------------------------------------------------------------------------------- + PyQt5==5.15.11 + opencv-python==4.13.0.92 + numpy==2.4.3 + ultralytics==8.4.41 + torch==2.5.1+cu121 + torchvision==0.20.1+cu121 + pypylon==26.3.1 + pyodbc==5.3.0 + pymelsec==0.2.5 + pyyaml==6.0.3 + pymysql==1.1.3 (레거시, main 실행 경로 미사용) + pyinstaller==6.20.0 (빌드 전용) + +================================================================================ diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..d2bb7ae --- /dev/null +++ b/paths.py @@ -0,0 +1,60 @@ +# 프로젝트 경로 유틸리티 — 개발/EXE 환경 모두에서 상대↔절대 경로 일관 처리 +# +# 두 종류의 루트가 있음: +# PROJECT_ROOT : 사용자가 보는 실제 폴더. 로그/설정/모델 등 쓰기 가능한 위치. +# - dev 모드: 이 파일이 있는 폴더 (E:\ANT) +# - EXE(--onefile) 모드: EXE가 놓인 폴더 (예: E:\ANT\dist) +# BUNDLE_ROOT : 번들에 포함된 읽기 전용 자원 위치. +# - dev 모드: PROJECT_ROOT 와 동일 +# - EXE 모드: PyInstaller 임시 추출 폴더 (sys._MEIPASS) +import os +import sys + +_FROZEN = getattr(sys, "frozen", False) + +# 번들된 자원(읽기 전용): config.json, ai/models, assets 등 +BUNDLE_ROOT = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) + +# 사용자 데이터(읽기/쓰기): logs, 변경된 config, 새로 저장된 모델 등 +if _FROZEN: + PROJECT_ROOT = os.path.dirname(os.path.abspath(sys.executable)) +else: + PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) + + +def resolve_path(path: str) -> str: + """상대경로 → 절대경로 변환. + 1) 절대경로/빈 값은 그대로 반환 + 2) PROJECT_ROOT 기준 경로가 존재하면 사용 (사용자가 변경한 사본 우선) + 3) BUNDLE_ROOT 기준 경로가 존재하면 폴백 (번들 기본 사본) + 4) 둘 다 없으면 PROJECT_ROOT 기준 경로 반환 (신규 생성 시 사용자 영역에 만들도록) + """ + if not path: + return path + if os.path.isabs(path): + return os.path.normpath(path) + primary = os.path.normpath(os.path.join(PROJECT_ROOT, path)) + if os.path.exists(primary): + return primary + if BUNDLE_ROOT and BUNDLE_ROOT != PROJECT_ROOT: + fallback = os.path.normpath(os.path.join(BUNDLE_ROOT, path)) + if os.path.exists(fallback): + return fallback + return primary + + +def to_project_relative(path: str) -> str: + """PROJECT_ROOT 또는 BUNDLE_ROOT 하위면 슬래시 구분 상대경로로, 외부면 슬래시 구분 절대경로로.""" + if not path: + return path + abs_path = os.path.abspath(path) + for root in (PROJECT_ROOT, BUNDLE_ROOT): + if not root: + continue + try: + rel = os.path.relpath(abs_path, root) + except ValueError: + continue + if not rel.startswith(".."): + return rel.replace("\\", "/") + return abs_path.replace("\\", "/") diff --git a/plc/__init__.py b/plc/__init__.py new file mode 100644 index 0000000..dd18d59 --- /dev/null +++ b/plc/__init__.py @@ -0,0 +1,2 @@ +# plc 패키지 — PLCClient 노출 +from .plc_client import PLCClient diff --git a/plc/plc_client.py b/plc/plc_client.py new file mode 100644 index 0000000..c421271 --- /dev/null +++ b/plc/plc_client.py @@ -0,0 +1,46 @@ +from pymelsec import Type3E + + +class PLCClient: + def __init__(self): + self.plc = None + self._ip = "" + self._port = 5010 + self._connected = False + + def connect(self, ip: str, port: int = 5010) -> bool: + try: + self._ip = ip + self._port = port + self.plc = Type3E(host=ip, port=port, plc_type="Q") + self.plc.connect(ip, port) + self._connected = True + print(f"[PLC] 연결 성공: {ip}:{port}") + return True + except Exception as e: + print(f"[PLC] 연결 실패: {e}") + self._connected = False + self.plc = None + return False + + def disconnect(self): + try: + if self.plc: + self.plc.close() + print("[PLC] 연결 해제") + except Exception as e: + print(f"[PLC] 연결 해제 오류: {e}") + finally: + self.plc = None + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + def send_signal(self, signal: str, value=None): + """신호 전송 — 미구현""" + pass + + def read_signal(self, address: str): + """신호 읽기 — 미구현""" + pass diff --git a/plc_test_gui.py b/plc_test_gui.py new file mode 100644 index 0000000..0a0e8f0 --- /dev/null +++ b/plc_test_gui.py @@ -0,0 +1,372 @@ +import sys +import time +from datetime import datetime + +from PyQt5.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QTextEdit, QFrame, +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal + +from pymelsec import Type3E +from pymelsec.constants import DT + +PLC_IP = "192.168.3.39" +PLC_PORT = 5010 + +# ── 스타일 상수 ─────────────────────────────────────────────────────────── # +_PANEL = ( + "QFrame {" + " background:#222222; border:1px solid #333333; border-radius:6px;" + "}" +) +_BTN_GREEN = ( + "QPushButton {" + " background:#1D9E75; color:#ffffff; border:none; border-radius:4px;" + " min-height:38px; font-size:13px;" + "}" + "QPushButton:hover { background:#20b585; }" + "QPushButton:disabled { background:#145f48; color:#5a9e7e; }" +) +_BTN_RED = ( + "QPushButton {" + " background:#8B2020; color:#ffffff; border:none; border-radius:4px;" + " min-height:38px; font-size:13px;" + "}" + "QPushButton:hover { background:#a02828; }" + "QPushButton:disabled { background:#4a1515; color:#9e5a5a; }" +) +_BTN_GRAY = ( + "QPushButton {" + " background:#333333; color:#aaaaaa; border:none; border-radius:4px;" + " min-height:38px; font-size:13px;" + "}" + "QPushButton:hover { background:#444444; color:#ffffff; }" + "QPushButton:disabled { background:#2a2a2a; color:#555555; }" +) +_BTN_RED_OUTLINE = ( + "QPushButton {" + " background:#3D1515; color:#F09595; border:none; border-radius:4px;" + " min-height:34px; font-size:13px;" + "}" + "QPushButton:hover { background:#4a1818; }" + "QPushButton:disabled { background:#222222; color:#555555; }" +) + + +# ══════════════════════════════════════════════════════════════════════════ # +# PLCMonitor — D500 폴링 스레드 +# ══════════════════════════════════════════════════════════════════════════ # + +class PLCMonitor(QThread): + signal_received = pyqtSignal(int) + error_occurred = pyqtSignal(str) + + def __init__(self, plc: Type3E): + super().__init__() + self.plc = plc + self.running = False + + def run(self): + self.running = True + while self.running: + try: + result = self.plc.batch_read( + ref_device="D500", + read_size=1, + data_type=DT.SWORD, + ) + raw = result[0] + value = int(raw.value if hasattr(raw, "value") else raw) + self.signal_received.emit(value) + except Exception as e: + self.error_occurred.emit(str(e)) + time.sleep(0.1) + + def stop(self): + self.running = False + self.wait(2000) + if self.isRunning(): + self.terminate() + + +# ══════════════════════════════════════════════════════════════════════════ # +# PLCTestGUI +# ══════════════════════════════════════════════════════════════════════════ # + +class PLCTestGUI(QWidget): + def __init__(self): + super().__init__() + self.plc = None + self._connected = False + self._monitor = None + self._last_m100 = -1 + + self.setWindowTitle("PLC 신호 테스트") + self.setFixedSize(600, 400) + self.setStyleSheet("background:#1a1a1a; color:#ffffff; font-size:13px;") + + self._build_ui() + self._connect_plc() + + # ── UI 구성 ────────────────────────────────────────────────────────── # + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(16, 12, 16, 12) + root.setSpacing(8) + + root.addWidget(self._build_header()) + root.addWidget(self._separator()) + + center = QHBoxLayout() + center.setSpacing(10) + center.addWidget(self._build_send_panel(), stretch=1) + center.addWidget(self._build_recv_panel(), stretch=1) + root.addLayout(center, stretch=1) + + root.addWidget(self._separator()) + root.addWidget(self._build_log()) + + def _build_header(self) -> QWidget: + w = QWidget() + w.setStyleSheet("background:transparent;") + row = QHBoxLayout(w) + row.setContentsMargins(0, 0, 0, 0) + + lbl_ip = QLabel(f"PLC IP: {PLC_IP} : {PLC_PORT}") + lbl_ip.setStyleSheet("color:#888888; font-size:13px;") + + self._dot = QLabel("●") + self._dot.setStyleSheet("color:#cc2222; font-size:16px;") + + self._lbl_status = QLabel("연결 안됨") + self._lbl_status.setStyleSheet("color:#cc2222; font-size:13px;") + + row.addWidget(lbl_ip) + row.addStretch() + row.addWidget(self._dot) + row.addSpacing(4) + row.addWidget(self._lbl_status) + return w + + def _build_send_panel(self) -> QFrame: + frame = QFrame() + frame.setStyleSheet(_PANEL) + v = QVBoxLayout(frame) + v.setContentsMargins(12, 10, 12, 10) + v.setSpacing(6) + + title = QLabel("PC → PLC 신호 전송") + title.setStyleSheet( + "color:#aaaaaa; font-size:12px; background:transparent; border:none;" + ) + v.addWidget(title) + + self._btn_pass = QPushButton("PASS 신호 전송 (M200 = 1)") + self._btn_pass.setStyleSheet(_BTN_GREEN) + self._btn_pass.clicked.connect(self._send_pass) + + self._btn_fail = QPushButton("FAIL 신호 전송 (M201 = 1)") + self._btn_fail.setStyleSheet(_BTN_RED) + self._btn_fail.clicked.connect(self._send_fail) + + self._btn_reset = QPushButton("신호 초기화 (M200 = M201 = D100 = 0)") + self._btn_reset.setStyleSheet(_BTN_GRAY) + self._btn_reset.clicked.connect(self._send_reset) + + self._send_btns = [self._btn_pass, self._btn_fail, self._btn_reset] + v.addWidget(self._btn_pass) + v.addWidget(self._btn_fail) + v.addWidget(self._btn_reset) + v.addStretch() + return frame + + def _build_recv_panel(self) -> QFrame: + frame = QFrame() + frame.setStyleSheet(_PANEL) + v = QVBoxLayout(frame) + v.setContentsMargins(12, 10, 12, 10) + v.setSpacing(6) + + title = QLabel("PLC → PC 신호 수신") + title.setStyleSheet( + "color:#aaaaaa; font-size:12px; background:transparent; border:none;" + ) + v.addWidget(title) + + self._lbl_d500 = QLabel("● 대기 중") + self._lbl_d500.setAlignment(Qt.AlignCenter) + self._lbl_d500.setStyleSheet( + "color:#555555; font-size:15px; font-weight:normal;" + "background:transparent; border:none;" + ) + v.addWidget(self._lbl_d500) + + self._btn_mon_start = QPushButton("신호 감지 시작") + self._btn_mon_start.setStyleSheet(_BTN_GREEN.replace("min-height:38px", "min-height:34px")) + self._btn_mon_start.clicked.connect(self._start_monitor) + + self._btn_mon_stop = QPushButton("신호 감지 중지") + self._btn_mon_stop.setEnabled(False) + self._btn_mon_stop.setStyleSheet(_BTN_RED_OUTLINE) + self._btn_mon_stop.clicked.connect(self._stop_monitor) + + v.addWidget(self._btn_mon_start) + v.addWidget(self._btn_mon_stop) + v.addStretch() + return frame + + @staticmethod + def _separator() -> QFrame: + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet("border:none; background:#2a2a2a; max-height:1px;") + return line + + def _build_log(self) -> QTextEdit: + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setFixedHeight(108) + self._log.setStyleSheet( + "background:#111111; color:#888888;" + "border:1px solid #2a2a2a; border-radius:4px;" + "font-family: Consolas, monospace; font-size:12px;" + ) + return self._log + + # ── PLC 연결 ───────────────────────────────────────────────────────── # + + def _connect_plc(self): + try: + self.plc = Type3E(host=PLC_IP, port=PLC_PORT, plc_type="Q") + self.plc.connect(PLC_IP, PLC_PORT) + self._connected = True + self._dot.setStyleSheet("color:#1D9E75; font-size:16px;") + self._lbl_status.setStyleSheet("color:#1D9E75; font-size:13px;") + self._lbl_status.setText("연결됨") + self._log_msg(f"PLC 연결 성공: {PLC_IP}:{PLC_PORT}") + except Exception as e: + self._connected = False + self._log_msg(f"PLC 연결 실패: {e}") + for btn in self._send_btns: + btn.setEnabled(False) + self._btn_mon_start.setEnabled(False) + + # ── 신호 전송 ──────────────────────────────────────────────────────── # + + def _send_pass(self): + if not self._connected: + return + try: + self.plc.batch_write(ref_device="M200", values=[1], data_type=DT.BIT) + self._log_msg("PC → PLC: M200 = 1 (PASS)") + except Exception as e: + self._log_msg(f"전송 오류: {e}") + + def _send_fail(self): + if not self._connected: + return + try: + self.plc.batch_write(ref_device="M201", values=[1], data_type=DT.BIT) + self._log_msg("PC → PLC: M201 = 1 (FAIL)") + except Exception as e: + self._log_msg(f"전송 오류: {e}") + + def _send_reset(self): + if not self._connected: + return + try: + self.plc.batch_write(ref_device="M200", values=[0], data_type=DT.BIT) + self.plc.batch_write(ref_device="M201", values=[0], data_type=DT.BIT) + self.plc.batch_write(ref_device="D100", values=[0], data_type=DT.SWORD) + self._log_msg("PC → PLC: M200=0, M201=0, D100=0 (초기화)") + except Exception as e: + self._log_msg(f"초기화 오류: {e}") + + # ── 신호 수신 폴링 ─────────────────────────────────────────────────── # + + def _start_monitor(self): + if not self._connected or self._monitor: + return + self._last_m100 = -1 + self._monitor = PLCMonitor(self.plc) + self._monitor.signal_received.connect(self._on_signal) + self._monitor.error_occurred.connect(self._on_poll_error) + self._monitor.start() + self._btn_mon_start.setEnabled(False) + self._btn_mon_stop.setEnabled(True) + self._log_msg("D500 폴링 시작 (100ms 간격)") + + def _stop_monitor(self): + if self._monitor: + self._monitor.stop() + self._monitor = None + self._btn_mon_start.setEnabled(True) + self._btn_mon_stop.setEnabled(False) + self._lbl_d500.setText("● 대기 중") + self._lbl_d500.setStyleSheet( + "color:#555555; font-size:15px; font-weight:normal;" + "background:transparent; border:none;" + ) + self._log_msg("D500 폴링 중지") + + def _on_signal(self, value: int): + prev = self._last_m100 + self._last_m100 = value + + if value >= 1: + self._lbl_d500.setText(f"● 신호 수신! ({value})") + self._lbl_d500.setStyleSheet( + "color:#1D9E75; font-size:15px; font-weight:bold;" + "background:transparent; border:none;" + ) + if prev < 1: + self._log_msg(f"PLC → PC: D500 = {value} 수신!") + else: + self._lbl_d500.setText("● 대기 중") + self._lbl_d500.setStyleSheet( + "color:#555555; font-size:15px; font-weight:normal;" + "background:transparent; border:none;" + ) + if prev >= 1: + self._log_msg(f"PLC → PC: D500 = {value} (신호 해제)") + + def _on_poll_error(self, msg: str): + self._log_msg(f"폴링 오류: {msg}") + + # ── 로그 ───────────────────────────────────────────────────────────── # + + def _log_msg(self, text: str): + ts = datetime.now().strftime("%H:%M:%S") + self._log.append(f"[{ts}] {text}") + + # ── 종료 ───────────────────────────────────────────────────────────── # + + def closeEvent(self, event): + if self._monitor: + self._monitor.stop() + if self.plc and self._connected: + try: + self.plc.close() + except Exception: + pass + event.accept() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setStyleSheet(""" + QWidget { background:#1a1a1a; color:#ffffff; font-size:13px; } + QScrollBar:vertical { + background:#2a2a2a; width:8px; border-radius:4px; + } + QScrollBar::handle:vertical { + background:#444444; border-radius:4px; min-height:20px; + } + QScrollBar::add-line:vertical, + QScrollBar::sub-line:vertical { height:0; } + """) + window = PLCTestGUI() + window.show() + sys.exit(app.exec_()) diff --git a/progress_report.txt b/progress_report.txt new file mode 100644 index 0000000..cdee6aa --- /dev/null +++ b/progress_report.txt @@ -0,0 +1,235 @@ +=== 리플렉터 검사 시스템 실제 구현 현황 === +분석 일시: 2026-05-07 +전체 진행률: 27/35 (77%) + +판정 기준 + ✅ 완료 : 실제 동작 코드, placeholder 없음 + 🔶 부분구현 : 일부는 실제 동작, 일부 print()/pass placeholder 존재 + ❌ 미구현 : 코드 없음 또는 전부 pass/print placeholder + 🔲 블로킹 : 코드 외부 의존성(장비 설정/데이터)으로 구현 불가 + +──────────────────────────────────────────────────────────── + +[환경설정] 6/7 + + ✅ 1. 코그넥스 IP/포트 연결 + 근거: settings_page.py L.520~567 _on_cognex_connect()에서 + InSightCamera().connect(ip, port) 실제 호출. + insight.py L.22~51 소켓 연결, FTP 세션 수립 실제 구현. + + 🔶 2. 이미지 설정 팝업 (GV/SV 셀 명령) + 근거: gui/dialogs/image_settings_dialog.py 자체는 완성. + _gv() L.126~137 GV{cell} 실제 전송, + _sv() L.172~182 SV{cell} 실제 전송. + BUT settings_page.py에 이 다이얼로그를 여는 버튼 없음. + 코드는 있으나 UI에서 접근 불가. + + ✅ 3. Basler 카메라 설정 (ExposureTime/Gain) + 근거: settings_page.py L.278~301 _on_basler_apply()에서 + camera.ExposureTime.SetValue(float(exposure)), + camera.Gain.SetValue(float(gain)) 실제 호출. + + 🔶 4. MySQL DB 연결 버튼 + 근거: settings_page.py L.414 + btn.clicked.connect(lambda: print("[설정] DB 연결")) → print() placeholder. + MySQLClient.connect() 자체(db/mysql_client.py L.9~15)는 + pymysql.connect() 실제 구현됨. + 연결 테스트(_test_db() L.805~822)도 실제 pymysql.connect() 호출. + 단, 설정 탭 "연결" 버튼이 placeholder. + + ✅ 5. AI 모델 로드 (YOLO) + 근거: settings_page.py L.694~720 _do_load_model()에서 + Detector().load_model() 실제 호출. + detector.py L.19 YOLO(model_path) 지연 로딩 실제 구현. + L.769~774 앱 시작 시 config.json 경로로 자동 로드. + + ✅ 6. 설정 저장/불러오기 (config.json) + 근거: settings_page.py L.588~608 _load_config()/_save_config() + json.load/json.dump 실제 구현. + L.646~688 _on_save_all()/_on_load_all() 실제 구현. + + ✅ 7. 전체 연결 테스트 버튼 + 근거: settings_page.py L.780~844. + 코그넥스: socket.connect() 실제 호출 (L.786~792). + Basler: pylon.TlFactory.EnumerateDevices() 실제 호출 (L.794~803). + DB: pymysql.connect() 실제 호출 (L.805~822). + +──────────────────────────────────────────────────────────── + +[제품 등록] 5/6 + + ✅ 8. 제품 목록 표시 (QListWidget) + 근거: register_page.py L.232~241 _populate_list()에서 + REFLECTOR_LIST 10개를 QListWidget에 표시. 실제 구현. + + ❌ 9. MES DB 불러오기 + 근거: register_page.py L.72~79 "MES 불러오기" 버튼 setEnabled(False). + db/mysql_client.py L.26~28 get_reflector_list() → pass placeholder. + 연결도 없고 쿼리도 없음. + + ✅ 10. Type L/R 시각적 표시 (화살표) + 근거: register_page.py L.259~264 _on_select()에서 + RH이면 "→" (파란색), LH이면 "←" (주황색) 실제 표시. + + ✅ 11. 캡처 기능 (trigger_and_get_image) + 근거: register_page.py L.289 self._insight.trigger_and_get_image() 실제 호출. + insight.py L.99~103 trigger_and_get_image(): SE8 트리거 → sleep(1.0) + → get_image() 실제 구현. + + ✅ 12. PatMax 패턴 등록 + 근거: register_page.py L.322~326 self._matcher.train(img, rid, info, roi=self._roi) + 실제 호출. + logic/pattern_matcher.py: ORB 특징점 자동 검출 + Canny 엣지 NCC fallback + 실제 구현. (Cognex Explorer PatMax 대신 Python ORB로 대체) + + ✅ 13. 저장 기능 (products.json) + 근거: register_page.py L.335~344 _write_saved()에서 json.dump 실제 저장. + 이미지 BMP도 cv2.imwrite() 실제 저장 (L.332~333). + +──────────────────────────────────────────────────────────── + +[검사] 7/9 + + ✅ 14. 그룹 A/B 최대 4종 제한 + 근거: inspect_page.py L.493~505 _on_group_changed()에서 + len(checked) > GroupManager.MAX_PER_GROUP(=4) 이면 체크 해제. + group_manager.py L.11 MAX_PER_GROUP = 4. + + ✅ 15. 그룹 A/B 수동 전환 + 근거: inspect_page.py L.507~511 _on_switch()에서 + self._groups.switch_group() 실제 호출. + group_manager.py L.22~25 switch_group() A↔B 전환 실제 구현. + + ✅ 16. 코그넥스 트리거 + 이미지 표시 + 근거: inspect_page.py L.117~144 InspectWorker._cognex_work()에서 + software_trigger() 실제 호출, get_image() FTP 수신 실제 호출. + L.133 cognex_image_ready.emit(raw) → _display_cognex_image() 실제 표시. + (QTimer 방식 아닌 QThread 파이프라인으로 구현) + + ✅ 17. PatMax 결과 읽기 + 모델 판별 + 근거: inspect_page.py L.136 read_patmax_results() + L.138 match_image() 병합. + inspector.py L.49~84 GV A27/A77/A127/A177 읽기, #ERR 판별 실제 구현. + inspector.py L.88~117 identify_model() 최고점수 선택 실제 구현. + + ✅ 18. Basler 이미지 표시 + 근거: inspect_page.py L.158~172 self._basler.capture() 실제 호출. + basler.py L.36~46 GrabOne(5000) 실제 구현. + inspect_page.py L.644~681 _display_basler_image() 실제 표시. + + ✅ 19. YOLOv8 추론 + 불량 박스 + 근거: inspect_page.py L.165~169 self.detector.detect(frame) 실제 호출. + detector.py L.32~58 self._model(image, verbose=False) 실제 추론. + inspect_page.py L.651~663 바운딩 박스 오버레이 실제 구현. + (AI 모델 파일 로드 시 동작) + + ✅ 20. Pass/Fail AND 판별 + 근거: inspect_page.py L.199 self._inspector.judge(cognex_pass, basler_pass). + inspector.py L.119~120 "PASS" if cognex_pass and basler_pass else "FAIL". + + ❌ 21. PLC 불량 신호 전송 + 근거: inspect_page.py 전체 어디에도 plc_client.send_signal() 호출 없음. + plc_client.py L.20~22 send_signal() → pass placeholder. + settings_page.py L.462 PLC 연결 버튼도 print() placeholder. + + ✅ 22. 양품/불량 카운터 집계 + 근거: inspect_page.py L.588~596 _on_result()에서 + total/pass/fail/unknown 실시간 카운터 업데이트 실제 구현. + +──────────────────────────────────────────────────────────── + +[재학습] 6/6 + + ✅ 23. 이미지 폴더 선택 + 목록 표시 + 근거: retrain_page.py L.885~901 _on_select_folder()에서 + QFileDialog.getExistingDirectory() 실제 호출, 목록 QListWidget 표시. + + ✅ 24. 불량 클래스 선택 토글 (4개) + 근거: retrain_page.py L.948~958 _on_class_select()에서 + 버튼 스타일 전환, canvas.set_class() 실제 구현. + 선택된 박스 클래스도 즉시 변경됨. + + ✅ 25. 바운딩 박스 라벨링 + YOLO .txt 저장 + 근거: retrain_page.py LabelingCanvas L.274~401 마우스 드래그 박스 실제 구현 + (new_box/move/resize/pan 4가지 모드, 8핸들 리사이즈, Ctrl+Z 실행취소). + L.982~997 _on_label_save() YOLO format .txt 실제 저장. + L.256~269 get_yolo_labels() cx/cy/nw/nh 정규화 실제 구현. + + ✅ 26. YOLOv8 학습 실행 (QThread) + 근거: retrain_page.py L.1006~1046 _on_train_start()에서 + TrainWorker(...).start() 실제 호출. + trainer.py L.101~162 YOLO("yolov8n.pt").train() 실제 구현. + trainer.py L.55~97 prepare_dataset() train/val split 실제 구현. + + ✅ 27. 학습 로그 실시간 표시 + 근거: retrain_page.py L.1059~1063 _on_log() → QTextEdit.append() 실제 구현. + trainer.py L.14~41 _StdoutCapture: sys.stdout 가로채기로 + YOLOv8 Epoch 로그 실시간 캡처 실제 구현. + + ✅ 28. 모델 저장 (best.pt 복사) + 근거: retrain_page.py L.1084~1109 _on_model_save()에서 + shutil.copy(src, dest) 실제 구현. + trainer.py L.151~156 학습 후 자동 best.pt 복사 실제 구현. + config.json ai.model_path도 자동 갱신. + +──────────────────────────────────────────────────────────── + +[공통/기타] 5/7 + + ✅ 29. 하단 상태바 업데이트 + 근거: main_window.py L.280~304 update_connection_status()에서 + 코그넥스/Basler/DB 도트 색상 및 텍스트 실시간 갱신. + L.174~178 시그널 연결로 연결 상태 변경 시 자동 업데이트. + + ❌ 30. PLC 통신 모듈 + 근거: plc_client.py L.9~25 + connect(), disconnect(), send_signal(), read_signal() 전부 pass placeholder. + 주석에 "통신 방식 미확정: Modbus TCP / MC프로토콜 / OPC-UA 중 선택 후 구현" + + ❌ 31. MySQL DB 쿼리 메서드 + 근거: db/mysql_client.py L.26~35 + get_reflector_list() → pass, + save_reflector() → pass, + save_inspection_result() → pass. + connect() 자체는 pymysql.connect() 실제 구현이나 + 모든 쿼리 메서드가 placeholder. + + ✅ 32. PyInstaller 패키징 (build.py) + 근거: build.py 존재. PyInstaller.__main__.run() 실제 호출. + --onefile --windowed, hidden-import 목록, + pypylon/ultralytics collect-all 포함 실제 구현. + + ✅ 33. AI 모델 + 검사 연동 + 근거: inspect_page.py L.165 self.detector.detect(frame) 실제 호출. + L.546~549 update_detector() → self._worker.detector 업데이트 실제 구현. + main_window.py L.106 Detector() 생성, L.273~275 update_detector() 연동. + + 🔲 34. 트리거 모드 수동 변경 + 근거: 코드 외부 작업 — Cognex In-Sight Explorer에서 + job 파일의 트리거 모드를 "소프트웨어 트리거"로 변경 필요. + 소프트웨어 트리거 명령(SE8)은 insight.py에 구현되어 있으나 + 카메라 job 설정이 외부 의존성. + + 🔲 35. AI 학습 데이터 수집 + 근거: trainer.py L.55~97 prepare_dataset()은 이미지+라벨 쌍 처리 가능. + retrain_page.py 라벨링 도구도 완성. + 실제 불량 이미지 데이터 수집은 운영 중 진행 필요 (외부 의존성). + +──────────────────────────────────────────────────────────── + +요약 + + ✅ 완료 27개 (77%) + 🔶 부분구현 2개 (6%) → #2 이미지설정 팝업 버튼 없음, #4 DB 연결버튼 placeholder + ❌ 미구현 4개 (11%) → #9 MES 불러오기, #21 PLC 신호전송, #30 PLC 모듈, #31 DB 쿼리 + 🔲 블로킹 2개 (6%) → #34 트리거모드 설정, #35 AI 학습 데이터 + +잔여 작업 우선순위 제안 + 1순위 (빠른 완성 가능) + - #2 : settings_page.py에 ImageSettingsDialog 열기 버튼 추가 (1~2줄) + - #4 : settings_page.py DB 연결 버튼에 MySQLClient.connect() 실제 호출 연결 + 2순위 (통신 방식 결정 후) + - #21 : PLC 신호 전송 — 방식 결정(Modbus TCP 권장) 후 plc_client.py 구현 + - #30 : PLC 모듈 전체 구현 + 3순위 (DB 서버 준비 후) + - #31 : DB 쿼리 메서드 구현 (get_reflector_list, save_inspection_result) + - #9 : MES DB 불러오기 연결 diff --git a/test.py b/test.py new file mode 100644 index 0000000..8dc5367 --- /dev/null +++ b/test.py @@ -0,0 +1,14 @@ +import re +from pymelsec import Type3E +from pymelsec.constants import DT + +plc = Type3E(host="192.168.3.39", port=5010, plc_type="Q") +plc.connect("192.168.3.39", 5010) + +# D500에 값 전송 +plc.batch_write(ref_device="B5F", values=[1], data_type=DT.BIT) + +re = plc.batch_read(ref_device="B52", read_size=1, data_type=DT.BIT) +print(re) + +plc.close() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/ai/dataset/data.yaml b/utils/ai/dataset/data.yaml new file mode 100644 index 0000000..7ab2641 --- /dev/null +++ b/utils/ai/dataset/data.yaml @@ -0,0 +1,9 @@ +names: +- 스크래치 +- 이물 +- 흑점 +- 변형 +nc: 4 +path: e:\ANT\utils\ai\dataset +train: images/train +val: images/val diff --git a/utils/ai/dataset/images/train/스크린샷 2026-04-27 175231.png b/utils/ai/dataset/images/train/스크린샷 2026-04-27 175231.png new file mode 100644 index 0000000..21f21b5 Binary files /dev/null and b/utils/ai/dataset/images/train/스크린샷 2026-04-27 175231.png differ diff --git a/utils/ai/dataset/images/train/스크린샷 2026-05-07 100837.png b/utils/ai/dataset/images/train/스크린샷 2026-05-07 100837.png new file mode 100644 index 0000000..025d2a3 Binary files /dev/null and b/utils/ai/dataset/images/train/스크린샷 2026-05-07 100837.png differ diff --git a/utils/ai/dataset/images/val/스크린샷 2026-04-27 175539.png b/utils/ai/dataset/images/val/스크린샷 2026-04-27 175539.png new file mode 100644 index 0000000..256939a Binary files /dev/null and b/utils/ai/dataset/images/val/스크린샷 2026-04-27 175539.png differ diff --git a/utils/ai/dataset/labels/train.cache b/utils/ai/dataset/labels/train.cache new file mode 100644 index 0000000..65cae3a Binary files /dev/null and b/utils/ai/dataset/labels/train.cache differ diff --git a/utils/ai/dataset/labels/train/스크린샷 2026-04-27 175231.txt b/utils/ai/dataset/labels/train/스크린샷 2026-04-27 175231.txt new file mode 100644 index 0000000..8ac7a0c --- /dev/null +++ b/utils/ai/dataset/labels/train/스크린샷 2026-04-27 175231.txt @@ -0,0 +1,2 @@ +0 0.227074 0.195219 0.401747 0.215139 +1 0.853712 0.252988 0.257642 0.330677 \ No newline at end of file diff --git a/utils/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt b/utils/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt new file mode 100644 index 0000000..69abecd --- /dev/null +++ b/utils/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt @@ -0,0 +1 @@ +0 0.318598 0.542781 0.472561 0.754011 \ No newline at end of file diff --git a/utils/ai/dataset/labels/val.cache b/utils/ai/dataset/labels/val.cache new file mode 100644 index 0000000..282c4da Binary files /dev/null and b/utils/ai/dataset/labels/val.cache differ diff --git a/utils/ai/dataset/labels/val/스크린샷 2026-04-27 175539.txt b/utils/ai/dataset/labels/val/스크린샷 2026-04-27 175539.txt new file mode 100644 index 0000000..3811f2b --- /dev/null +++ b/utils/ai/dataset/labels/val/스크린샷 2026-04-27 175539.txt @@ -0,0 +1 @@ +0 0.233974 0.409524 0.339744 0.590476 \ No newline at end of file diff --git a/utils/ai/runs/train/args.yaml b/utils/ai/runs/train/args.yaml new file mode 100644 index 0000000..e0bf476 --- /dev/null +++ b/utils/ai/runs/train/args.yaml @@ -0,0 +1,110 @@ +task: detect +mode: train +model: yolov8n.pt +data: e:\ANT\utils\ai\dataset\data.yaml +epochs: 5 +time: null +patience: 100 +batch: 16 +imgsz: 640 +save: true +save_period: -1 +cache: false +device: null +workers: 0 +project: e:\ANT\utils\ai\runs +name: train +exist_ok: true +pretrained: true +optimizer: auto +verbose: true +seed: 0 +deterministic: true +single_cls: false +rect: false +cos_lr: false +close_mosaic: 10 +resume: false +amp: false +fraction: 1.0 +profile: false +freeze: null +multi_scale: 0.0 +compile: false +overlap_mask: true +mask_ratio: 4 +dropout: 0.0 +val: true +split: val +save_json: false +conf: null +iou: 0.7 +max_det: 300 +half: false +dnn: false +plots: false +end2end: null +source: null +vid_stride: 1 +stream_buffer: false +visualize: false +augment: false +agnostic_nms: false +classes: null +retina_masks: false +embed: null +show: false +save_frames: false +save_txt: false +save_conf: false +save_crop: false +show_labels: true +show_conf: true +show_boxes: true +line_width: null +format: torchscript +keras: false +optimize: false +int8: false +dynamic: false +simplify: true +opset: null +workspace: null +nms: false +lr0: 0.01 +lrf: 0.01 +momentum: 0.937 +weight_decay: 0.0005 +warmup_epochs: 3.0 +warmup_momentum: 0.8 +warmup_bias_lr: 0.1 +box: 7.5 +cls: 0.5 +cls_pw: 0.0 +dfl: 1.5 +pose: 12.0 +kobj: 1.0 +rle: 1.0 +angle: 1.0 +nbs: 64 +hsv_h: 0.015 +hsv_s: 0.7 +hsv_v: 0.4 +degrees: 0.0 +translate: 0.1 +scale: 0.5 +shear: 0.0 +perspective: 0.0 +flipud: 0.0 +fliplr: 0.5 +bgr: 0.0 +mosaic: 1.0 +mixup: 0.0 +cutmix: 0.0 +copy_paste: 0.0 +copy_paste_mode: flip +auto_augment: randaugment +erasing: 0.4 +cfg: null +tracker: botsort.yaml +save_dir: E:\ANT\utils\ai\runs\train diff --git a/utils/ai/runs/train/results.csv b/utils/ai/runs/train/results.csv new file mode 100644 index 0000000..13c22f5 --- /dev/null +++ b/utils/ai/runs/train/results.csv @@ -0,0 +1,6 @@ +epoch,time,train/box_loss,train/cls_loss,train/dfl_loss,metrics/precision(B),metrics/recall(B),metrics/mAP50(B),metrics/mAP50-95(B),val/box_loss,val/cls_loss,val/dfl_loss,lr/pg0,lr/pg1,lr/pg2 +1,0.455317,2.31474,4.08107,2.28289,0.02041,1,0.4975,0.04975,3.2799,4.23304,3.16176,0,0,0 +2,0.862726,2.30536,4.11057,2.05367,0.02041,1,0.4975,0.04975,3.26087,4.28545,3.03613,1.0025e-05,1.0025e-05,1.0025e-05 +3,1.1759,2.77566,3.95724,3.15328,0.02083,1,0.4975,0.04975,3.25368,4.2721,3.07091,1.51e-05,1.51e-05,1.51e-05 +4,1.48949,1.89878,3.81945,2.43286,0.02041,1,0.4975,0.04975,3.22409,4.21402,3.13953,1.5225e-05,1.5225e-05,1.5225e-05 +5,1.80265,2.28623,4.60095,2.64542,0.02,1,0.4975,0.04975,3.17688,4.12774,3.17531,1.04e-05,1.04e-05,1.04e-05 diff --git a/utils/ai/runs/train/weights/best.pt b/utils/ai/runs/train/weights/best.pt new file mode 100644 index 0000000..f12f03e Binary files /dev/null and b/utils/ai/runs/train/weights/best.pt differ diff --git a/utils/ai/runs/train/weights/last.pt b/utils/ai/runs/train/weights/last.pt new file mode 100644 index 0000000..107f823 Binary files /dev/null and b/utils/ai/runs/train/weights/last.pt differ diff --git a/utils/path_helper.py b/utils/path_helper.py new file mode 100644 index 0000000..44abb74 --- /dev/null +++ b/utils/path_helper.py @@ -0,0 +1,47 @@ +# PyInstaller frozen 환경과 일반 실행 환경 모두에서 올바른 기준 경로 반환 +# +# 두 종류의 루트: +# BASE_PATH : 사용자가 보는 실제 폴더(쓰기 가능). 로그/변경된 설정/저장된 모델이 여기로. +# - dev 모드: __main__ 파일 위치 (= 프로젝트 루트) +# - EXE(--onefile) 모드: EXE가 놓인 폴더 (예: E:\ANT\dist) +# BUNDLE_PATH : 번들 안의 읽기 전용 자원 위치. +# - dev 모드: BASE_PATH 와 동일 +# - EXE 모드: PyInstaller 임시 추출 폴더 (sys._MEIPASS) +import os +import sys + + +def get_base_path() -> str: + """사용자 데이터 위치(읽기/쓰기).""" + if getattr(sys, "frozen", False): + return os.path.dirname(os.path.abspath(sys.executable)) + main_mod = sys.modules.get("__main__") + main_file = getattr(main_mod, "__file__", None) + if main_file: + return os.path.dirname(os.path.abspath(main_file)) + # path_helper.py lives in utils/ — go up one level to reach the project root + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_bundle_path() -> str: + """번들된 자원 위치(읽기 전용). EXE에선 _MEIPASS, dev에선 BASE_PATH.""" + return getattr(sys, "_MEIPASS", BASE_PATH) + + +BASE_PATH = get_base_path() +BUNDLE_PATH = get_bundle_path() + + +def get_path(*paths) -> str: + """경로 결합. BASE_PATH 기준 우선, 거기 없으면 BUNDLE_PATH 폴백. + 파일이 양쪽에 다 없으면 BASE_PATH 기준 경로 반환(신규 생성 시 사용자 영역에 만들도록).""" + if not paths: + return BASE_PATH + primary = os.path.join(BASE_PATH, *paths) + if os.path.exists(primary): + return primary + if BUNDLE_PATH and BUNDLE_PATH != BASE_PATH: + fallback = os.path.join(BUNDLE_PATH, *paths) + if os.path.exists(fallback): + return fallback + return primary diff --git a/yolo26n.pt b/yolo26n.pt new file mode 100644 index 0000000..be48188 Binary files /dev/null and b/yolo26n.pt differ diff --git a/yolov8n.pt b/yolov8n.pt new file mode 100644 index 0000000..0db4ca4 Binary files /dev/null and b/yolov8n.pt differ