ytcollector 1.1.1__tar.gz → 1.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ytcollector-1.1.1 → ytcollector-1.1.6}/PKG-INFO +19 -8
- {ytcollector-1.1.1 → ytcollector-1.1.6}/README.md +17 -7
- {ytcollector-1.1.1 → ytcollector-1.1.6}/pyproject.toml +2 -1
- ytcollector-1.1.6/ytcollector/analyzer.py +324 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/cli.py +1 -1
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/config.py +21 -13
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/downloader.py +33 -1
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/PKG-INFO +19 -8
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/requires.txt +1 -0
- ytcollector-1.1.1/ytcollector/analyzer.py +0 -239
- {ytcollector-1.1.1 → ytcollector-1.1.6}/setup.cfg +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/__init__.py +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/dataset_builder.py +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector/utils.py +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/SOURCES.txt +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/dependency_links.txt +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/entry_points.txt +0 -0
- {ytcollector-1.1.1 → ytcollector-1.1.6}/ytcollector.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytcollector
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지
|
|
5
5
|
Author: YTCollector Team
|
|
6
6
|
License: MIT
|
|
@@ -24,6 +24,7 @@ Provides-Extra: analysis
|
|
|
24
24
|
Requires-Dist: opencv-python>=4.5.0; extra == "analysis"
|
|
25
25
|
Requires-Dist: easyocr>=1.6.0; extra == "analysis"
|
|
26
26
|
Requires-Dist: numpy>=1.20.0; extra == "analysis"
|
|
27
|
+
Requires-Dist: ultralytics>=8.0.0; extra == "analysis"
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
29
30
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
@@ -43,10 +44,14 @@ Requires-Dist: ytcollector[analysis,dev]; extra == "all"
|
|
|
43
44
|
pip install yt-dlp
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
### 분석 기능용 패키지 (권장)
|
|
47
|
+
### 분석 기능용 패키지 (권장 - v1.1.6+)
|
|
48
|
+
|
|
49
|
+
분석 기능을 원활하게 사용하려면 아래 패키지들이 필요합니다. GPU(CUDA)가 설치된 경우 자동으로 가속이 활성화됩니다.
|
|
47
50
|
|
|
48
51
|
```bash
|
|
49
|
-
pip install opencv-python easyocr numpy
|
|
52
|
+
pip install opencv-python easyocr numpy ultralytics
|
|
53
|
+
# YOLO-World 기능을 사용하려면 아래 CLIP 라이브러리 수동 설치가 필요합니다.
|
|
54
|
+
pip install "git+https://github.com/ultralytics/CLIP.git"
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
## 사용법
|
|
@@ -129,7 +134,7 @@ ytcollector -c face --proxy http://proxy.server:8080
|
|
|
129
134
|
|
|
130
135
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
131
136
|
|
|
132
|
-
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다.
|
|
137
|
+
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다. (v1.1.6에서 ROI 엔진 최적화 적용)
|
|
133
138
|
|
|
134
139
|
### 실행 방법
|
|
135
140
|
|
|
@@ -165,7 +170,8 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
165
170
|
├── 번호판_미감지/ # 번호판 미감지 (수동 확인용)
|
|
166
171
|
├── 타투/ # 타투 감지된 영상
|
|
167
172
|
├── 텍스트/ # 텍스트 감지된 영상
|
|
168
|
-
|
|
173
|
+
├── .archive.txt # 기본 다운로드 기록
|
|
174
|
+
└── youtube_url_*.txt # 카테고리별 성공 로그 (v1.1.5+ 중복 체크에 활용)
|
|
169
175
|
```
|
|
170
176
|
|
|
171
177
|
## 파일 구조
|
|
@@ -184,9 +190,14 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
184
190
|
| 감지 항목 | 사용 기술 | 설명 |
|
|
185
191
|
|-----------|-----------|------|
|
|
186
192
|
| 얼굴 | OpenCV Haar Cascade | 정면 얼굴 감지 |
|
|
187
|
-
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 |
|
|
188
|
-
| 번호판 |
|
|
189
|
-
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 |
|
|
193
|
+
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 (분석 품질 및 프레임 수 개선) |
|
|
194
|
+
| 번호판 | YOLO-World + ROI OCR | v1.1.6: YOLO로 감지 후 해당 영역만 OCR (속도 2x, 정확도 향상) |
|
|
195
|
+
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 감지 |
|
|
196
|
+
|
|
197
|
+
### 주요 최적화 (v1.1.5~1.1.6)
|
|
198
|
+
- **ROI 기반 감지**: 전체 화면이 아닌 YOLO가 지정한 영역만 OCR하여 속도와 정확도 대폭 향상
|
|
199
|
+
- **GPU 가속 지원**: CUDA 사용 가능 시 YOLO 및 OCR 자동 가속
|
|
200
|
+
- **로그 기반 중복 방지**: 로컬 파일이 없어도 `youtube_url_*.txt` 기록을 참조하여 중복 분석 방지
|
|
190
201
|
|
|
191
202
|
## 주의사항
|
|
192
203
|
|
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
pip install yt-dlp
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
### 분석 기능용 패키지 (권장)
|
|
13
|
+
### 분석 기능용 패키지 (권장 - v1.1.6+)
|
|
14
|
+
|
|
15
|
+
분석 기능을 원활하게 사용하려면 아래 패키지들이 필요합니다. GPU(CUDA)가 설치된 경우 자동으로 가속이 활성화됩니다.
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
pip install opencv-python easyocr numpy
|
|
18
|
+
pip install opencv-python easyocr numpy ultralytics
|
|
19
|
+
# YOLO-World 기능을 사용하려면 아래 CLIP 라이브러리 수동 설치가 필요합니다.
|
|
20
|
+
pip install "git+https://github.com/ultralytics/CLIP.git"
|
|
17
21
|
```
|
|
18
22
|
|
|
19
23
|
## 사용법
|
|
@@ -96,7 +100,7 @@ ytcollector -c face --proxy http://proxy.server:8080
|
|
|
96
100
|
|
|
97
101
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
98
102
|
|
|
99
|
-
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다.
|
|
103
|
+
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다. (v1.1.6에서 ROI 엔진 최적화 적용)
|
|
100
104
|
|
|
101
105
|
### 실행 방법
|
|
102
106
|
|
|
@@ -132,7 +136,8 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
132
136
|
├── 번호판_미감지/ # 번호판 미감지 (수동 확인용)
|
|
133
137
|
├── 타투/ # 타투 감지된 영상
|
|
134
138
|
├── 텍스트/ # 텍스트 감지된 영상
|
|
135
|
-
|
|
139
|
+
├── .archive.txt # 기본 다운로드 기록
|
|
140
|
+
└── youtube_url_*.txt # 카테고리별 성공 로그 (v1.1.5+ 중복 체크에 활용)
|
|
136
141
|
```
|
|
137
142
|
|
|
138
143
|
## 파일 구조
|
|
@@ -151,9 +156,14 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
151
156
|
| 감지 항목 | 사용 기술 | 설명 |
|
|
152
157
|
|-----------|-----------|------|
|
|
153
158
|
| 얼굴 | OpenCV Haar Cascade | 정면 얼굴 감지 |
|
|
154
|
-
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 |
|
|
155
|
-
| 번호판 |
|
|
156
|
-
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 |
|
|
159
|
+
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 (분석 품질 및 프레임 수 개선) |
|
|
160
|
+
| 번호판 | YOLO-World + ROI OCR | v1.1.6: YOLO로 감지 후 해당 영역만 OCR (속도 2x, 정확도 향상) |
|
|
161
|
+
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 감지 |
|
|
162
|
+
|
|
163
|
+
### 주요 최적화 (v1.1.5~1.1.6)
|
|
164
|
+
- **ROI 기반 감지**: 전체 화면이 아닌 YOLO가 지정한 영역만 OCR하여 속도와 정확도 대폭 향상
|
|
165
|
+
- **GPU 가속 지원**: CUDA 사용 가능 시 YOLO 및 OCR 자동 가속
|
|
166
|
+
- **로그 기반 중복 방지**: 로컬 파일이 없어도 `youtube_url_*.txt` 기록을 참조하여 중복 분석 방지
|
|
157
167
|
|
|
158
168
|
## 주의사항
|
|
159
169
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ytcollector"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.6"
|
|
8
8
|
description = "YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -34,6 +34,7 @@ analysis = [
|
|
|
34
34
|
"opencv-python>=4.5.0",
|
|
35
35
|
"easyocr>=1.6.0",
|
|
36
36
|
"numpy>=1.20.0",
|
|
37
|
+
"ultralytics>=8.0.0",
|
|
37
38
|
]
|
|
38
39
|
dev = [
|
|
39
40
|
"pytest>=7.0.0",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import torch
|
|
3
|
+
USE_GPU = torch.cuda.is_available()
|
|
4
|
+
except ImportError:
|
|
5
|
+
USE_GPU = False
|
|
6
|
+
|
|
7
|
+
from .config import LICENSE_PLATE_PATTERNS, YOLO_MODEL_NAME, YOLO_CONFIDENCE, YOLO_PROMPTS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VideoAnalyzer:
|
|
11
|
+
"""영상 분석 클래스 - 얼굴, 텍스트, 번호판, 타투 감지"""
|
|
12
|
+
|
|
13
|
+
_ocr_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.ocr_reader = None
|
|
17
|
+
self.face_cascade = None
|
|
18
|
+
self.yolo_model = None
|
|
19
|
+
|
|
20
|
+
if CV2_AVAILABLE:
|
|
21
|
+
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
22
|
+
self.face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
23
|
+
|
|
24
|
+
def _init_ocr(self):
|
|
25
|
+
"""OCR 리더 초기화 (필요할 때만, 스레드 안전, GPU 가속 체크)"""
|
|
26
|
+
if EASYOCR_AVAILABLE and self.ocr_reader is None:
|
|
27
|
+
with self._ocr_lock:
|
|
28
|
+
if self.ocr_reader is None:
|
|
29
|
+
gpu_status = "사용" if USE_GPU else "미사용"
|
|
30
|
+
print(f" OCR 엔진 초기화 중... (GPU: {gpu_status})")
|
|
31
|
+
self.ocr_reader = easyocr.Reader(['ko', 'en'], gpu=USE_GPU, verbose=False)
|
|
32
|
+
|
|
33
|
+
def _init_yolo(self):
|
|
34
|
+
"""YOLO-World 모델 초기화 (필요할 때만, 스레드 안전, GPU 가속 체크)"""
|
|
35
|
+
if YOLO_AVAILABLE and self.yolo_model is None:
|
|
36
|
+
with self._ocr_lock:
|
|
37
|
+
if self.yolo_model is None:
|
|
38
|
+
device = "cuda" if USE_GPU else "cpu"
|
|
39
|
+
print(f" YOLO-World 모델 로딩 중... (Device: {device})")
|
|
40
|
+
self.yolo_model = YOLOWorld(YOLO_MODEL_NAME)
|
|
41
|
+
self.yolo_model.to(device)
|
|
42
|
+
# 감지할 클래스(프롬프트) 설정
|
|
43
|
+
self.yolo_model.set_classes(YOLO_PROMPTS)
|
|
44
|
+
|
|
45
|
+
def extract_frames(self, video_path, num_frames=10):
|
|
46
|
+
"""영상에서 균등 간격으로 프레임 추출"""
|
|
47
|
+
if not CV2_AVAILABLE:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
cap = cv2.VideoCapture(video_path)
|
|
51
|
+
if not cap.isOpened():
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
55
|
+
if total_frames <= 0:
|
|
56
|
+
cap.release()
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
frame_indices = [int(i * total_frames / (num_frames + 1)) for i in range(1, num_frames + 1)]
|
|
60
|
+
frames = []
|
|
61
|
+
|
|
62
|
+
for idx in frame_indices:
|
|
63
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
64
|
+
ret, frame = cap.read()
|
|
65
|
+
if ret:
|
|
66
|
+
frames.append(frame)
|
|
67
|
+
|
|
68
|
+
cap.release()
|
|
69
|
+
return frames
|
|
70
|
+
|
|
71
|
+
def detect_faces(self, frame):
|
|
72
|
+
"""Haar Cascade로 얼굴 감지"""
|
|
73
|
+
if not CV2_AVAILABLE or self.face_cascade is None:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
77
|
+
return self.face_cascade.detectMultiScale(
|
|
78
|
+
gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def detect_text(self, frame):
|
|
82
|
+
"""EasyOCR로 텍스트 감지 (스레드 안전)"""
|
|
83
|
+
if not EASYOCR_AVAILABLE:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
self._init_ocr()
|
|
87
|
+
try:
|
|
88
|
+
h, w = frame.shape[:2]
|
|
89
|
+
|
|
90
|
+
# 가독성 개선을 위해 1080p 수준으로 리사이즈 (너무 작으면 인식률 저하)
|
|
91
|
+
if w > 1280:
|
|
92
|
+
scale = 1280 / w
|
|
93
|
+
frame = cv2.resize(frame, (1280, int(h * scale)), interpolation=cv2.INTER_LANCZOS4)
|
|
94
|
+
elif w < 640:
|
|
95
|
+
# 너무 작은 경우 확대
|
|
96
|
+
scale = 960 / w
|
|
97
|
+
frame = cv2.resize(frame, (960, int(h * scale)), interpolation=cv2.INTER_CUBIC)
|
|
98
|
+
|
|
99
|
+
# 전처리: 그레이스케일 및 대비 강화 (옵션)
|
|
100
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
101
|
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
|
102
|
+
processed = clahe.apply(gray)
|
|
103
|
+
|
|
104
|
+
with self._ocr_lock:
|
|
105
|
+
# 원본(컬러)과 전처리(그레이) 중 선택 가능하나 보통 EasyOCR은 컬러에서 잘 작동함
|
|
106
|
+
# 대비 강화된 그레이스케일을 사용해봄
|
|
107
|
+
results = self.ocr_reader.readtext(processed)
|
|
108
|
+
|
|
109
|
+
# 신뢰도 임계값 0.25로 약간 하향 조정 (기존 0.3)
|
|
110
|
+
return [r[1] for r in results if r[2] > 0.25]
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f" ⚠ OCR 에러: {e}")
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
def detect_license_plate(self, frame, texts=None):
|
|
116
|
+
"""
|
|
117
|
+
ROI 기반 번호판 감지 (최적화 버전)
|
|
118
|
+
1. YOLO로 번호판 영역(ROI)을 먼저 찾음
|
|
119
|
+
2. 찾은 영역만 잘라서 OCR 수행 (속도 및 정확도 향상)
|
|
120
|
+
"""
|
|
121
|
+
if not YOLO_AVAILABLE or frame is None:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
self._init_yolo()
|
|
126
|
+
results = self.yolo_model(frame, verbose=False, conf=YOLO_CONFIDENCE)
|
|
127
|
+
|
|
128
|
+
yolo_high_conf = False
|
|
129
|
+
roi_ocr_matched = False
|
|
130
|
+
|
|
131
|
+
for r in results:
|
|
132
|
+
# 0: license plate
|
|
133
|
+
for box in r.boxes:
|
|
134
|
+
if box.cls == 0:
|
|
135
|
+
conf = float(box.conf)
|
|
136
|
+
if conf > 0.8:
|
|
137
|
+
yolo_high_conf = True
|
|
138
|
+
|
|
139
|
+
# ROI 크로핑 및 타겟 OCR
|
|
140
|
+
x1, y1, x2, y2 = map(int, box.xyxy[0])
|
|
141
|
+
h, w = frame.shape[:2]
|
|
142
|
+
# 패딩 10% 추가
|
|
143
|
+
pad_w = int((x2 - x1) * 0.1)
|
|
144
|
+
pad_h = int((y2 - y1) * 0.1)
|
|
145
|
+
|
|
146
|
+
crop_x1 = max(0, x1 - pad_w)
|
|
147
|
+
crop_y1 = max(0, y1 - pad_h)
|
|
148
|
+
crop_x2 = min(w, x2 + pad_w)
|
|
149
|
+
crop_y2 = min(h, y2 + pad_h)
|
|
150
|
+
|
|
151
|
+
roi = frame[crop_y1:crop_y2, crop_x1:crop_x2]
|
|
152
|
+
if roi.size > 0:
|
|
153
|
+
# ROI에 대해서만 OCR 실행
|
|
154
|
+
roi_texts = self.detect_text(roi)
|
|
155
|
+
if roi_texts:
|
|
156
|
+
combined_roi = "".join([re.sub(r'[^0-9가-힣]', '', t) for t in roi_texts])
|
|
157
|
+
for pattern in LICENSE_PLATE_PATTERNS:
|
|
158
|
+
# 개별 텍스트 및 결합 텍스트 확인
|
|
159
|
+
if any(re.search(pattern, re.sub(r'[^0-9가-힣]', '', t)) for t in roi_texts) or \
|
|
160
|
+
re.search(pattern, combined_roi):
|
|
161
|
+
roi_ocr_matched = True
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
if roi_ocr_matched: break
|
|
165
|
+
if roi_ocr_matched: break
|
|
166
|
+
|
|
167
|
+
# 최종 판정
|
|
168
|
+
if roi_ocr_matched or yolo_high_conf:
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
# print(f" ⚠ 번호판 ROI 분석 에러: {e}")
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def detect_tattoo(self, frame):
|
|
178
|
+
"""피부 영역에서 타투(어두운 잉크 패턴) 감지"""
|
|
179
|
+
if not CV2_AVAILABLE or not NUMPY_AVAILABLE:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
|
|
184
|
+
|
|
185
|
+
# 피부색 범위
|
|
186
|
+
lower_skin = np.array([0, 30, 80], dtype=np.uint8)
|
|
187
|
+
upper_skin = np.array([17, 170, 255], dtype=np.uint8)
|
|
188
|
+
skin_mask = cv2.inRange(hsv, lower_skin, upper_skin)
|
|
189
|
+
|
|
190
|
+
# 노이즈 제거
|
|
191
|
+
kernel = np.ones((5, 5), np.uint8)
|
|
192
|
+
skin_mask = cv2.morphologyEx(skin_mask, cv2.MORPH_OPEN, kernel)
|
|
193
|
+
skin_mask = cv2.morphologyEx(skin_mask, cv2.MORPH_CLOSE, kernel)
|
|
194
|
+
|
|
195
|
+
skin_pixels = cv2.countNonZero(skin_mask)
|
|
196
|
+
total_pixels = frame.shape[0] * frame.shape[1]
|
|
197
|
+
|
|
198
|
+
# 피부 영역 최소 10% 필요
|
|
199
|
+
if skin_pixels < total_pixels * 0.10:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
# 피부 영역 내 어두운 픽셀(타투) 감지
|
|
203
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
204
|
+
skin_gray = cv2.bitwise_and(gray, gray, mask=skin_mask)
|
|
205
|
+
dark_mask = cv2.inRange(skin_gray, 1, 80)
|
|
206
|
+
|
|
207
|
+
dark_pixels = cv2.countNonZero(dark_mask)
|
|
208
|
+
dark_ratio = dark_pixels / max(skin_pixels, 1)
|
|
209
|
+
|
|
210
|
+
# 어두운 영역이 3~35%일 때 타투로 판정
|
|
211
|
+
if 0.03 < dark_ratio < 0.35:
|
|
212
|
+
contours, _ = cv2.findContours(dark_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
213
|
+
significant = [c for c in contours if cv2.contourArea(c) > 100]
|
|
214
|
+
return len(significant) >= 1
|
|
215
|
+
|
|
216
|
+
return False
|
|
217
|
+
except:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def analyze(self, video_path, target_category=None):
|
|
221
|
+
"""영상 전체 분석"""
|
|
222
|
+
results = {
|
|
223
|
+
'face': False,
|
|
224
|
+
'text': False,
|
|
225
|
+
'license_plate': False,
|
|
226
|
+
'tattoo': False,
|
|
227
|
+
'face_count': 0,
|
|
228
|
+
'detected_texts': [],
|
|
229
|
+
'first_detection_sec': None,
|
|
230
|
+
'first_detection_ts': None
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if not CV2_AVAILABLE:
|
|
234
|
+
print(" ⚠ OpenCV 미설치")
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
# 영상 정보 가져오기
|
|
238
|
+
cap = cv2.VideoCapture(video_path)
|
|
239
|
+
if not cap.isOpened():
|
|
240
|
+
return results
|
|
241
|
+
|
|
242
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30
|
|
243
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
244
|
+
cap.release()
|
|
245
|
+
|
|
246
|
+
# 분석 프레임 수 증가 (10 -> 20)
|
|
247
|
+
num_analysis_frames = 20
|
|
248
|
+
# 영상이 아주 긴 경우(10분 이상) 더 많은 프레임 추출
|
|
249
|
+
if total_frames / fps > 600:
|
|
250
|
+
num_analysis_frames = 30
|
|
251
|
+
|
|
252
|
+
frame_indices = [int(i * total_frames / (num_analysis_frames + 1)) for i in range(1, num_analysis_frames + 1)]
|
|
253
|
+
|
|
254
|
+
all_texts = []
|
|
255
|
+
total_faces = 0
|
|
256
|
+
|
|
257
|
+
cap = cv2.VideoCapture(video_path)
|
|
258
|
+
for idx in frame_indices:
|
|
259
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
260
|
+
ret, frame = cap.read()
|
|
261
|
+
if not ret: continue
|
|
262
|
+
|
|
263
|
+
# 현재 프레임의 시간(초)
|
|
264
|
+
current_sec = idx / fps
|
|
265
|
+
detected_now = False
|
|
266
|
+
|
|
267
|
+
# 얼굴
|
|
268
|
+
faces = self.detect_faces(frame)
|
|
269
|
+
if len(faces) > 0:
|
|
270
|
+
results['face'] = True
|
|
271
|
+
total_faces += len(faces)
|
|
272
|
+
detected_now = True
|
|
273
|
+
|
|
274
|
+
# 번호판 감지 (타겟 카테고리가 번호판인 경우 우선 수행 - ROI 최적화)
|
|
275
|
+
if target_category == 'license_plate':
|
|
276
|
+
if self.detect_license_plate(frame):
|
|
277
|
+
results['license_plate'] = True
|
|
278
|
+
detected_now = True
|
|
279
|
+
|
|
280
|
+
# 텍스트 감지 (일반 텍스트 카테고리이거나 번호판 수집 중에도 텍스트 로그 기록을 위해 실행)
|
|
281
|
+
# 번호판 감지가 필요 없는 경우 전체 OCR을 건너뛰어 속도 향상 가능
|
|
282
|
+
if target_category == 'text' or (detected_now and target_category != 'license_plate'):
|
|
283
|
+
texts = self.detect_text(frame)
|
|
284
|
+
if texts:
|
|
285
|
+
results['text'] = True
|
|
286
|
+
all_texts.extend(texts)
|
|
287
|
+
detected_now = True
|
|
288
|
+
elif target_category == 'license_plate' and not results['license_plate']:
|
|
289
|
+
# 번호판을 못 찾은 경우에만 전체 화면 OCR 한 번 더 시도 (보수적 접근)
|
|
290
|
+
texts = self.detect_text(frame)
|
|
291
|
+
if texts:
|
|
292
|
+
all_texts.extend(texts)
|
|
293
|
+
# 이미 detect_license_plate에서 결과가 안 나왔으므로 여기서는 텍스트만 수집
|
|
294
|
+
|
|
295
|
+
# 타투
|
|
296
|
+
if self.detect_tattoo(frame):
|
|
297
|
+
results['tattoo'] = True
|
|
298
|
+
detected_now = True
|
|
299
|
+
|
|
300
|
+
# 첫 감지 시점 기록
|
|
301
|
+
if detected_now and results['first_detection_sec'] is None:
|
|
302
|
+
results['first_detection_sec'] = current_sec
|
|
303
|
+
m, s = int(current_sec // 60), int(current_sec % 60)
|
|
304
|
+
results['first_detection_ts'] = f"{m:02d}:{s:02d}"
|
|
305
|
+
|
|
306
|
+
cap.release()
|
|
307
|
+
|
|
308
|
+
if all_texts:
|
|
309
|
+
results['detected_texts'] = list(set(all_texts))[:10]
|
|
310
|
+
|
|
311
|
+
results['face_count'] = total_faces
|
|
312
|
+
return results
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def check_dependencies():
|
|
316
|
+
"""의존성 체크"""
|
|
317
|
+
missing = []
|
|
318
|
+
if not CV2_AVAILABLE:
|
|
319
|
+
missing.append("opencv-python")
|
|
320
|
+
if not EASYOCR_AVAILABLE:
|
|
321
|
+
missing.append("easyocr")
|
|
322
|
+
if not NUMPY_AVAILABLE:
|
|
323
|
+
missing.append("numpy")
|
|
324
|
+
return missing
|
|
@@ -37,14 +37,10 @@ CATEGORY_QUERIES = {
|
|
|
37
37
|
],
|
|
38
38
|
'text': [
|
|
39
39
|
"SBS 런닝맨 레전드",
|
|
40
|
-
"SBS
|
|
41
|
-
"
|
|
42
|
-
"SBS
|
|
43
|
-
"SBS
|
|
44
|
-
"SBS 맛남의광장 클립",
|
|
45
|
-
"SBS 불타는청춘 명장면",
|
|
46
|
-
"SBS 정글의법칙 레전드",
|
|
47
|
-
"SBS 예능",
|
|
40
|
+
"SBS 예능 쇼츠",
|
|
41
|
+
"재미있는 자막 영상 쇼츠",
|
|
42
|
+
"SBS 파워FM 보이는 라디오",
|
|
43
|
+
"SBS 연예대상 소감",
|
|
48
44
|
],
|
|
49
45
|
}
|
|
50
46
|
|
|
@@ -61,6 +57,9 @@ BLACKLIST_KEYWORDS = {
|
|
|
61
57
|
"두피 문신", "두피문신",
|
|
62
58
|
"눈썹 문신", "눈썹문신",
|
|
63
59
|
"입술 문신", "입술문신",
|
|
60
|
+
"립타투", "립 타투",
|
|
61
|
+
"헤어타투", "헤어 타투",
|
|
62
|
+
"구레나룻문신", "구레나룻 문신",
|
|
64
63
|
"틴트 입술",
|
|
65
64
|
"반영구", "SMP"
|
|
66
65
|
],
|
|
@@ -69,12 +68,21 @@ BLACKLIST_KEYWORDS = {
|
|
|
69
68
|
'text': []
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
#
|
|
71
|
+
# YOLO 설정
|
|
72
|
+
YOLO_MODEL_NAME = 'yolov8s-world.pt' # YOLO-World 모델 (Open Vocabulary)
|
|
73
|
+
YOLO_CONFIDENCE = 0.3 # YOLO-World는 임계값을 약간 낮게 설정 가능
|
|
74
|
+
YOLO_PROMPTS = ["license plate"]
|
|
75
|
+
|
|
76
|
+
# 번호판 정규식 패턴 (한국 자동차 번호판 중심)
|
|
73
77
|
LICENSE_PLATE_PATTERNS = [
|
|
74
|
-
|
|
75
|
-
r'
|
|
76
|
-
|
|
77
|
-
r'\d{2
|
|
78
|
+
# 1. 신형/구형 번호판 (12가 3456, 123가 4567)
|
|
79
|
+
r'\d{2,3}[가-힣]{1}\d{4}',
|
|
80
|
+
# 2. 지역 포함 번호판 (서울 12 가 3456)
|
|
81
|
+
r'[가-힣]{2}\d{2}[가-힣]{1}\d{4}',
|
|
82
|
+
# 3. 전기차/외교/임시 등 특수 패턴 대응
|
|
83
|
+
r'[가-힣]{2,3}\d{4}', # (예: 외교 1234, 임시 1234)
|
|
84
|
+
# 4. 결합된 텍스트에서 숫자-글자-숫자 구성 포착
|
|
85
|
+
r'\d+[가-힣]+\d+',
|
|
78
86
|
]
|
|
79
87
|
|
|
80
88
|
# 스킵할 에러 메시지
|
|
@@ -31,6 +31,29 @@ class YouTubeDownloader:
|
|
|
31
31
|
def _get_ua(self):
|
|
32
32
|
return random.choice(USER_AGENTS)
|
|
33
33
|
|
|
34
|
+
def _extract_vid(self, url):
|
|
35
|
+
"""URL에서 비디오 ID 추출"""
|
|
36
|
+
if "v=" in url:
|
|
37
|
+
return url.split("v=")[1].split("&")[0]
|
|
38
|
+
return url.split("/")[-1]
|
|
39
|
+
|
|
40
|
+
def _load_processed_ids(self):
|
|
41
|
+
"""모든 youtube_url_*.txt 파일에서 이미 처리된 비디오 ID 목록 로드"""
|
|
42
|
+
processed_ids = set()
|
|
43
|
+
for filename in os.listdir("."):
|
|
44
|
+
if filename.startswith("youtube_url_") and filename.endswith(".txt"):
|
|
45
|
+
try:
|
|
46
|
+
with open(filename, 'r', encoding='utf-8') as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
if line.strip() and not line.strip().startswith('#'):
|
|
49
|
+
url = line.split(',')[0].strip()
|
|
50
|
+
vid = self._extract_vid(url)
|
|
51
|
+
if vid:
|
|
52
|
+
processed_ids.add(vid)
|
|
53
|
+
except:
|
|
54
|
+
continue
|
|
55
|
+
return processed_ids
|
|
56
|
+
|
|
34
57
|
def _get_query(self, category):
|
|
35
58
|
"""검색어 순환 반환"""
|
|
36
59
|
if category not in self.query_index:
|
|
@@ -161,7 +184,7 @@ class YouTubeDownloader:
|
|
|
161
184
|
|
|
162
185
|
if status == "ok" and filepath:
|
|
163
186
|
print(f" 🔍 분석 중...")
|
|
164
|
-
analysis = self.analyzer.analyze(filepath)
|
|
187
|
+
analysis = self.analyzer.analyze(filepath, target_category=category)
|
|
165
188
|
|
|
166
189
|
detected = []
|
|
167
190
|
if analysis['face']:
|
|
@@ -243,10 +266,19 @@ class YouTubeDownloader:
|
|
|
243
266
|
|
|
244
267
|
# 필터링
|
|
245
268
|
filtered = []
|
|
269
|
+
processed_ids = self._load_processed_ids()
|
|
270
|
+
|
|
246
271
|
for entry in entries:
|
|
247
272
|
if not entry: continue
|
|
248
273
|
|
|
249
274
|
vid = entry.get('id')
|
|
275
|
+
if not vid: continue
|
|
276
|
+
|
|
277
|
+
# 이미 처리된 영상은 즉시 패스
|
|
278
|
+
if vid in processed_ids:
|
|
279
|
+
print(f" ⏭ [기록됨] {vid}")
|
|
280
|
+
continue
|
|
281
|
+
|
|
250
282
|
title = entry.get('title', '')
|
|
251
283
|
dur = entry.get('duration') or self._get_duration(vid)
|
|
252
284
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytcollector
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.6
|
|
4
4
|
Summary: YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지
|
|
5
5
|
Author: YTCollector Team
|
|
6
6
|
License: MIT
|
|
@@ -24,6 +24,7 @@ Provides-Extra: analysis
|
|
|
24
24
|
Requires-Dist: opencv-python>=4.5.0; extra == "analysis"
|
|
25
25
|
Requires-Dist: easyocr>=1.6.0; extra == "analysis"
|
|
26
26
|
Requires-Dist: numpy>=1.20.0; extra == "analysis"
|
|
27
|
+
Requires-Dist: ultralytics>=8.0.0; extra == "analysis"
|
|
27
28
|
Provides-Extra: dev
|
|
28
29
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
29
30
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
@@ -43,10 +44,14 @@ Requires-Dist: ytcollector[analysis,dev]; extra == "all"
|
|
|
43
44
|
pip install yt-dlp
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
### 분석 기능용 패키지 (권장)
|
|
47
|
+
### 분석 기능용 패키지 (권장 - v1.1.6+)
|
|
48
|
+
|
|
49
|
+
분석 기능을 원활하게 사용하려면 아래 패키지들이 필요합니다. GPU(CUDA)가 설치된 경우 자동으로 가속이 활성화됩니다.
|
|
47
50
|
|
|
48
51
|
```bash
|
|
49
|
-
pip install opencv-python easyocr numpy
|
|
52
|
+
pip install opencv-python easyocr numpy ultralytics
|
|
53
|
+
# YOLO-World 기능을 사용하려면 아래 CLIP 라이브러리 수동 설치가 필요합니다.
|
|
54
|
+
pip install "git+https://github.com/ultralytics/CLIP.git"
|
|
50
55
|
```
|
|
51
56
|
|
|
52
57
|
## 사용법
|
|
@@ -129,7 +134,7 @@ ytcollector -c face --proxy http://proxy.server:8080
|
|
|
129
134
|
|
|
130
135
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
131
136
|
|
|
132
|
-
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다.
|
|
137
|
+
URL 리스트를 기반으로 영상을 수집하고 특정 시점을 기준으로 자동으로 클리핑(3분 미만)하는 기능입니다. (v1.1.6에서 ROI 엔진 최적화 적용)
|
|
133
138
|
|
|
134
139
|
### 실행 방법
|
|
135
140
|
|
|
@@ -165,7 +170,8 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
165
170
|
├── 번호판_미감지/ # 번호판 미감지 (수동 확인용)
|
|
166
171
|
├── 타투/ # 타투 감지된 영상
|
|
167
172
|
├── 텍스트/ # 텍스트 감지된 영상
|
|
168
|
-
|
|
173
|
+
├── .archive.txt # 기본 다운로드 기록
|
|
174
|
+
└── youtube_url_*.txt # 카테고리별 성공 로그 (v1.1.5+ 중복 체크에 활용)
|
|
169
175
|
```
|
|
170
176
|
|
|
171
177
|
## 파일 구조
|
|
@@ -184,9 +190,14 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
184
190
|
| 감지 항목 | 사용 기술 | 설명 |
|
|
185
191
|
|-----------|-----------|------|
|
|
186
192
|
| 얼굴 | OpenCV Haar Cascade | 정면 얼굴 감지 |
|
|
187
|
-
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 |
|
|
188
|
-
| 번호판 |
|
|
189
|
-
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 |
|
|
193
|
+
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 (분석 품질 및 프레임 수 개선) |
|
|
194
|
+
| 번호판 | YOLO-World + ROI OCR | v1.1.6: YOLO로 감지 후 해당 영역만 OCR (속도 2x, 정확도 향상) |
|
|
195
|
+
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 감지 |
|
|
196
|
+
|
|
197
|
+
### 주요 최적화 (v1.1.5~1.1.6)
|
|
198
|
+
- **ROI 기반 감지**: 전체 화면이 아닌 YOLO가 지정한 영역만 OCR하여 속도와 정확도 대폭 향상
|
|
199
|
+
- **GPU 가속 지원**: CUDA 사용 가능 시 YOLO 및 OCR 자동 가속
|
|
200
|
+
- **로그 기반 중복 방지**: 로컬 파일이 없어도 `youtube_url_*.txt` 기록을 참조하여 중복 분석 방지
|
|
190
201
|
|
|
191
202
|
## 주의사항
|
|
192
203
|
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import threading
|
|
3
|
-
from .config import LICENSE_PLATE_PATTERNS
|
|
4
|
-
|
|
5
|
-
# 선택적 import
|
|
6
|
-
try:
|
|
7
|
-
import cv2
|
|
8
|
-
CV2_AVAILABLE = True
|
|
9
|
-
except ImportError:
|
|
10
|
-
CV2_AVAILABLE = False
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
import easyocr
|
|
14
|
-
EASYOCR_AVAILABLE = True
|
|
15
|
-
except ImportError:
|
|
16
|
-
EASYOCR_AVAILABLE = False
|
|
17
|
-
|
|
18
|
-
try:
|
|
19
|
-
import numpy as np
|
|
20
|
-
NUMPY_AVAILABLE = True
|
|
21
|
-
except ImportError:
|
|
22
|
-
NUMPY_AVAILABLE = False
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class VideoAnalyzer:
|
|
26
|
-
"""영상 분석 클래스 - 얼굴, 텍스트, 번호판, 타투 감지"""
|
|
27
|
-
|
|
28
|
-
_ocr_lock = threading.Lock()
|
|
29
|
-
|
|
30
|
-
def __init__(self):
|
|
31
|
-
self.ocr_reader = None
|
|
32
|
-
self.face_cascade = None
|
|
33
|
-
|
|
34
|
-
if CV2_AVAILABLE:
|
|
35
|
-
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
36
|
-
self.face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
37
|
-
|
|
38
|
-
def _init_ocr(self):
|
|
39
|
-
"""OCR 리더 초기화 (필요할 때만, 스레드 안전)"""
|
|
40
|
-
if EASYOCR_AVAILABLE and self.ocr_reader is None:
|
|
41
|
-
with self._ocr_lock:
|
|
42
|
-
if self.ocr_reader is None:
|
|
43
|
-
print(" OCR 엔진 초기화 중...")
|
|
44
|
-
self.ocr_reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
|
45
|
-
|
|
46
|
-
def extract_frames(self, video_path, num_frames=10):
|
|
47
|
-
"""영상에서 균등 간격으로 프레임 추출"""
|
|
48
|
-
if not CV2_AVAILABLE:
|
|
49
|
-
return []
|
|
50
|
-
|
|
51
|
-
cap = cv2.VideoCapture(video_path)
|
|
52
|
-
if not cap.isOpened():
|
|
53
|
-
return []
|
|
54
|
-
|
|
55
|
-
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
56
|
-
if total_frames <= 0:
|
|
57
|
-
cap.release()
|
|
58
|
-
return []
|
|
59
|
-
|
|
60
|
-
frame_indices = [int(i * total_frames / (num_frames + 1)) for i in range(1, num_frames + 1)]
|
|
61
|
-
frames = []
|
|
62
|
-
|
|
63
|
-
for idx in frame_indices:
|
|
64
|
-
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
65
|
-
ret, frame = cap.read()
|
|
66
|
-
if ret:
|
|
67
|
-
frames.append(frame)
|
|
68
|
-
|
|
69
|
-
cap.release()
|
|
70
|
-
return frames
|
|
71
|
-
|
|
72
|
-
def detect_faces(self, frame):
|
|
73
|
-
"""Haar Cascade로 얼굴 감지"""
|
|
74
|
-
if not CV2_AVAILABLE or self.face_cascade is None:
|
|
75
|
-
return []
|
|
76
|
-
|
|
77
|
-
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
78
|
-
return self.face_cascade.detectMultiScale(
|
|
79
|
-
gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30)
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
def detect_text(self, frame):
|
|
83
|
-
"""EasyOCR로 텍스트 감지 (스레드 안전)"""
|
|
84
|
-
if not EASYOCR_AVAILABLE:
|
|
85
|
-
return []
|
|
86
|
-
|
|
87
|
-
self._init_ocr()
|
|
88
|
-
try:
|
|
89
|
-
h, w = frame.shape[:2]
|
|
90
|
-
if w > 640:
|
|
91
|
-
scale = 640 / w
|
|
92
|
-
frame = cv2.resize(frame, (640, int(h * scale)))
|
|
93
|
-
|
|
94
|
-
with self._ocr_lock:
|
|
95
|
-
results = self.ocr_reader.readtext(frame)
|
|
96
|
-
return [r[1] for r in results if r[2] > 0.3]
|
|
97
|
-
except:
|
|
98
|
-
return []
|
|
99
|
-
|
|
100
|
-
def detect_license_plate(self, texts):
|
|
101
|
-
"""텍스트에서 번호판 패턴 감지"""
|
|
102
|
-
for text in texts:
|
|
103
|
-
text_clean = text.replace(' ', '').upper()
|
|
104
|
-
for pattern in LICENSE_PLATE_PATTERNS:
|
|
105
|
-
if re.search(pattern, text_clean):
|
|
106
|
-
return True
|
|
107
|
-
return False
|
|
108
|
-
|
|
109
|
-
def detect_tattoo(self, frame):
|
|
110
|
-
"""피부 영역에서 타투(어두운 잉크 패턴) 감지"""
|
|
111
|
-
if not CV2_AVAILABLE or not NUMPY_AVAILABLE:
|
|
112
|
-
return False
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
|
|
116
|
-
|
|
117
|
-
# 피부색 범위
|
|
118
|
-
lower_skin = np.array([0, 30, 80], dtype=np.uint8)
|
|
119
|
-
upper_skin = np.array([17, 170, 255], dtype=np.uint8)
|
|
120
|
-
skin_mask = cv2.inRange(hsv, lower_skin, upper_skin)
|
|
121
|
-
|
|
122
|
-
# 노이즈 제거
|
|
123
|
-
kernel = np.ones((5, 5), np.uint8)
|
|
124
|
-
skin_mask = cv2.morphologyEx(skin_mask, cv2.MORPH_OPEN, kernel)
|
|
125
|
-
skin_mask = cv2.morphologyEx(skin_mask, cv2.MORPH_CLOSE, kernel)
|
|
126
|
-
|
|
127
|
-
skin_pixels = cv2.countNonZero(skin_mask)
|
|
128
|
-
total_pixels = frame.shape[0] * frame.shape[1]
|
|
129
|
-
|
|
130
|
-
# 피부 영역 최소 10% 필요
|
|
131
|
-
if skin_pixels < total_pixels * 0.10:
|
|
132
|
-
return False
|
|
133
|
-
|
|
134
|
-
# 피부 영역 내 어두운 픽셀(타투) 감지
|
|
135
|
-
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
136
|
-
skin_gray = cv2.bitwise_and(gray, gray, mask=skin_mask)
|
|
137
|
-
dark_mask = cv2.inRange(skin_gray, 1, 80)
|
|
138
|
-
|
|
139
|
-
dark_pixels = cv2.countNonZero(dark_mask)
|
|
140
|
-
dark_ratio = dark_pixels / max(skin_pixels, 1)
|
|
141
|
-
|
|
142
|
-
# 어두운 영역이 3~35%일 때 타투로 판정
|
|
143
|
-
if 0.03 < dark_ratio < 0.35:
|
|
144
|
-
contours, _ = cv2.findContours(dark_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
145
|
-
significant = [c for c in contours if cv2.contourArea(c) > 100]
|
|
146
|
-
return len(significant) >= 1
|
|
147
|
-
|
|
148
|
-
return False
|
|
149
|
-
except:
|
|
150
|
-
return False
|
|
151
|
-
|
|
152
|
-
def analyze(self, video_path):
|
|
153
|
-
"""영상 전체 분석"""
|
|
154
|
-
results = {
|
|
155
|
-
'face': False,
|
|
156
|
-
'text': False,
|
|
157
|
-
'license_plate': False,
|
|
158
|
-
'tattoo': False,
|
|
159
|
-
'face_count': 0,
|
|
160
|
-
'detected_texts': [],
|
|
161
|
-
'first_detection_sec': None,
|
|
162
|
-
'first_detection_ts': None
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if not CV2_AVAILABLE:
|
|
166
|
-
print(" ⚠ OpenCV 미설치")
|
|
167
|
-
return results
|
|
168
|
-
|
|
169
|
-
# 영상 정보 가져오기
|
|
170
|
-
cap = cv2.VideoCapture(video_path)
|
|
171
|
-
if not cap.isOpened():
|
|
172
|
-
return results
|
|
173
|
-
|
|
174
|
-
fps = cap.get(cv2.CAP_PROP_FPS) or 30
|
|
175
|
-
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
176
|
-
cap.release()
|
|
177
|
-
|
|
178
|
-
num_analysis_frames = 10
|
|
179
|
-
frame_indices = [int(i * total_frames / (num_analysis_frames + 1)) for i in range(1, num_analysis_frames + 1)]
|
|
180
|
-
|
|
181
|
-
all_texts = []
|
|
182
|
-
total_faces = 0
|
|
183
|
-
|
|
184
|
-
cap = cv2.VideoCapture(video_path)
|
|
185
|
-
for idx in frame_indices:
|
|
186
|
-
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
187
|
-
ret, frame = cap.read()
|
|
188
|
-
if not ret: continue
|
|
189
|
-
|
|
190
|
-
# 현재 프레임의 시간(초)
|
|
191
|
-
current_sec = idx / fps
|
|
192
|
-
detected_now = False
|
|
193
|
-
|
|
194
|
-
# 얼굴
|
|
195
|
-
faces = self.detect_faces(frame)
|
|
196
|
-
if len(faces) > 0:
|
|
197
|
-
results['face'] = True
|
|
198
|
-
total_faces += len(faces)
|
|
199
|
-
detected_now = True
|
|
200
|
-
|
|
201
|
-
# 텍스트
|
|
202
|
-
texts = self.detect_text(frame)
|
|
203
|
-
if texts:
|
|
204
|
-
results['text'] = True
|
|
205
|
-
all_texts.extend(texts)
|
|
206
|
-
detected_now = True
|
|
207
|
-
|
|
208
|
-
# 타투
|
|
209
|
-
if self.detect_tattoo(frame):
|
|
210
|
-
results['tattoo'] = True
|
|
211
|
-
detected_now = True
|
|
212
|
-
|
|
213
|
-
# 첫 감지 시점 기록
|
|
214
|
-
if detected_now and results['first_detection_sec'] is None:
|
|
215
|
-
results['first_detection_sec'] = current_sec
|
|
216
|
-
m, s = int(current_sec // 60), int(current_sec % 60)
|
|
217
|
-
results['first_detection_ts'] = f"{m:02d}:{s:02d}"
|
|
218
|
-
|
|
219
|
-
cap.release()
|
|
220
|
-
|
|
221
|
-
# 번호판 (텍스트에서)
|
|
222
|
-
if all_texts:
|
|
223
|
-
results['license_plate'] = self.detect_license_plate(all_texts)
|
|
224
|
-
results['detected_texts'] = list(set(all_texts))[:10]
|
|
225
|
-
|
|
226
|
-
results['face_count'] = total_faces
|
|
227
|
-
return results
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def check_dependencies():
|
|
231
|
-
"""의존성 체크"""
|
|
232
|
-
missing = []
|
|
233
|
-
if not CV2_AVAILABLE:
|
|
234
|
-
missing.append("opencv-python")
|
|
235
|
-
if not EASYOCR_AVAILABLE:
|
|
236
|
-
missing.append("easyocr")
|
|
237
|
-
if not NUMPY_AVAILABLE:
|
|
238
|
-
missing.append("numpy")
|
|
239
|
-
return missing
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|