feat: 초기 프로젝트 구조 추가
17
.claude/settings.local.json
Normal file
@@ -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 ' *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
263
PROJECT_CONTEXT.md
Normal file
@@ -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=<DB명>;
|
||||||
|
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 생성 |
|
||||||
3
ai/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ai 패키지 — Detector, Trainer 노출
|
||||||
|
from .detector import Detector
|
||||||
|
from .trainer import Trainer
|
||||||
9
ai/dataset/data.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
names:
|
||||||
|
- 스크래치
|
||||||
|
- 이물
|
||||||
|
- 흑점
|
||||||
|
- 변형
|
||||||
|
nc: 4
|
||||||
|
path: e:\ANT\ai\dataset
|
||||||
|
train: images/train
|
||||||
|
val: images/val
|
||||||
BIN
ai/dataset/images/train/스크린샷 2026-04-27 175539.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
ai/dataset/images/train/스크린샷 2026-05-07 100837.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
ai/dataset/images/val/스크린샷 2026-04-27 175231.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
ai/dataset/labels/train.cache
Normal file
1
ai/dataset/labels/train/스크린샷 2026-04-27 175539.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0 0.233974 0.409524 0.339744 0.590476
|
||||||
1
ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0 0.318598 0.542781 0.472561 0.754011
|
||||||
BIN
ai/dataset/labels/val.cache
Normal file
2
ai/dataset/labels/val/스크린샷 2026-04-27 175231.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
0 0.227074 0.195219 0.401747 0.215139
|
||||||
|
1 0.853712 0.252988 0.257642 0.330677
|
||||||
59
ai/detector.py
Normal file
@@ -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 []
|
||||||
BIN
ai/models/best.pt
Normal file
BIN
ai/runs/train/BoxF1_curve.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
ai/runs/train/BoxPR_curve.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ai/runs/train/BoxP_curve.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
ai/runs/train/BoxR_curve.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
110
ai/runs/train/args.yaml
Normal file
@@ -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
|
||||||
BIN
ai/runs/train/confusion_matrix.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
ai/runs/train/confusion_matrix_normalized.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
101
ai/runs/train/results.csv
Normal file
@@ -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
|
||||||
|
BIN
ai/runs/train/results.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
ai/runs/train/train_batch0.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
ai/runs/train/train_batch1.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
ai/runs/train/train_batch2.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
ai/runs/train/train_batch90.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
ai/runs/train/train_batch91.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
ai/runs/train/train_batch92.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
ai/runs/train/val_batch0_labels.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
ai/runs/train/val_batch0_pred.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
ai/runs/train/weights/best.pt
Normal file
BIN
ai/runs/train/weights/last.pt
Normal file
269
ai/trainer.py
Normal file
@@ -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()
|
||||||
BIN
assets/images/ant_logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/product_1.bmp
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
assets/patterns.pkl
Normal file
3
assets/products.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"products": []
|
||||||
|
}
|
||||||
31
build.py
Normal file
@@ -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",
|
||||||
|
])
|
||||||
3
camera/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# camera 패키지 — InSightCamera, BaslerCamera 노출
|
||||||
|
from .insight import InSightCamera
|
||||||
|
from .basler import BaslerCamera
|
||||||
67
camera/basler.py
Normal file
@@ -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()
|
||||||
189
camera/insight.py
Normal file
@@ -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()
|
||||||
9
check_build.py
Normal file
@@ -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 파일이 없음")
|
||||||
27
config.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
db/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# db 패키지 — MySQLClient 노출
|
||||||
|
from .mysql_client import MySQLClient
|
||||||
36
db/mysql_client.py
Normal file
@@ -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
|
||||||
74
db/sql_client.py
Normal file
@@ -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
|
||||||
203
find_cells_trigger.py
Normal file
@@ -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()
|
||||||
1736
find_cells_trigger_result.txt
Normal file
1
gui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# gui 패키지
|
||||||
1
gui/dialogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# gui/dialogs 패키지
|
||||||
245
gui/dialogs/image_settings_dialog.py
Normal file
@@ -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)
|
||||||
195
gui/image_settings_dialog.py
Normal file
@@ -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
|
||||||
361
gui/main_window.py
Normal file
@@ -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;"
|
||||||
|
)
|
||||||
|
|
||||||
1
gui/pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# gui/pages 패키지
|
||||||
726
gui/pages/inspect_page.py
Normal file
@@ -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)
|
||||||
297
gui/pages/register_page.py
Normal file
@@ -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
|
||||||
1193
gui/pages/retrain_page.py
Normal file
1219
gui/pages/settings_page.py
Normal file
182
gui/splash_screen.py
Normal file
@@ -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)
|
||||||
237
logger.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# 로그 시스템 — 앱 로그(텍스트) + 검사 결과(CSV) + 학습 이력(텍스트)
|
||||||
|
# 폴더 구조: logs/<category>/<YYYY-MM-DD>.<ext>
|
||||||
|
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/<category>/<YYYY-MM-DD>.<ext> 경로 (폴더 자동 생성)."""
|
||||||
|
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
|
||||||
3
logic/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# logic 패키지 — Inspector, GroupManager 노출
|
||||||
|
from .inspector import Inspector
|
||||||
|
from .group_manager import GroupManager
|
||||||
25
logic/group_manager.py
Normal file
@@ -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
|
||||||
120
logic/inspector.py
Normal file
@@ -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"
|
||||||
222
logic/pattern_matcher.py
Normal file
@@ -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
|
||||||
65
logs/inspect/2026-05-06.csv
Normal file
@@ -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,
|
||||||
|
9
logs/inspect/2026-05-07.csv
Normal file
@@ -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,
|
||||||
|
10
logs/inspect/2026-05-08.csv
Normal file
@@ -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,
|
||||||
|
14
logs/inspect/2026-05-13.csv
Normal file
@@ -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,
|
||||||
|
4
logs/inspect/2026-05-20.csv
Normal file
@@ -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,
|
||||||
|
541
logs/timing/2026-05-06.csv
Normal file
@@ -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
|
||||||
|
97
logs/timing/2026-05-07.csv
Normal file
@@ -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
|
||||||
|
109
logs/timing/2026-05-08.csv
Normal file
@@ -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
|
||||||
|
157
logs/timing/2026-05-13.csv
Normal file
@@ -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
|
||||||
|
25
logs/timing/2026-05-20.csv
Normal file
@@ -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
|
||||||
|
156
main.py
Normal file
@@ -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()
|
||||||
195
main_py_필요모듈.txt
Normal file
@@ -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 (빌드 전용)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
60
paths.py
Normal file
@@ -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("\\", "/")
|
||||||
2
plc/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# plc 패키지 — PLCClient 노출
|
||||||
|
from .plc_client import PLCClient
|
||||||
46
plc/plc_client.py
Normal file
@@ -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
|
||||||
372
plc_test_gui.py
Normal file
@@ -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_())
|
||||||
235
progress_report.txt
Normal file
@@ -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 불러오기 연결
|
||||||
14
test.py
Normal file
@@ -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()
|
||||||
0
utils/__init__.py
Normal file
9
utils/ai/dataset/data.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
names:
|
||||||
|
- 스크래치
|
||||||
|
- 이물
|
||||||
|
- 흑점
|
||||||
|
- 변형
|
||||||
|
nc: 4
|
||||||
|
path: e:\ANT\utils\ai\dataset
|
||||||
|
train: images/train
|
||||||
|
val: images/val
|
||||||
BIN
utils/ai/dataset/images/train/스크린샷 2026-04-27 175231.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
utils/ai/dataset/images/train/스크린샷 2026-05-07 100837.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
utils/ai/dataset/images/val/스크린샷 2026-04-27 175539.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
utils/ai/dataset/labels/train.cache
Normal file
2
utils/ai/dataset/labels/train/스크린샷 2026-04-27 175231.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
0 0.227074 0.195219 0.401747 0.215139
|
||||||
|
1 0.853712 0.252988 0.257642 0.330677
|
||||||
1
utils/ai/dataset/labels/train/스크린샷 2026-05-07 100837.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0 0.318598 0.542781 0.472561 0.754011
|
||||||
BIN
utils/ai/dataset/labels/val.cache
Normal file
1
utils/ai/dataset/labels/val/스크린샷 2026-04-27 175539.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0 0.233974 0.409524 0.339744 0.590476
|
||||||
110
utils/ai/runs/train/args.yaml
Normal file
@@ -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
|
||||||
6
utils/ai/runs/train/results.csv
Normal file
@@ -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
|
||||||
|
BIN
utils/ai/runs/train/weights/best.pt
Normal file
BIN
utils/ai/runs/train/weights/last.pt
Normal file
47
utils/path_helper.py
Normal file
@@ -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
|
||||||