ytcollector 1.0.9__py3-none-any.whl → 1.1.2__py3-none-any.whl
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/analyzer.py +134 -28
- ytcollector/cli.py +4 -4
- ytcollector/config.py +35 -13
- ytcollector/dataset_builder.py +6 -71
- ytcollector/downloader.py +63 -66
- ytcollector/utils.py +126 -0
- {ytcollector-1.0.9.dist-info → ytcollector-1.1.2.dist-info}/METADATA +19 -16
- ytcollector-1.1.2.dist-info/RECORD +12 -0
- ytcollector-1.0.9.dist-info/RECORD +0 -11
- {ytcollector-1.0.9.dist-info → ytcollector-1.1.2.dist-info}/WHEEL +0 -0
- {ytcollector-1.0.9.dist-info → ytcollector-1.1.2.dist-info}/entry_points.txt +0 -0
- {ytcollector-1.0.9.dist-info → ytcollector-1.1.2.dist-info}/top_level.txt +0 -0
ytcollector/analyzer.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import re
|
|
2
|
+
import threading
|
|
2
3
|
from .config import LICENSE_PLATE_PATTERNS
|
|
3
4
|
|
|
4
5
|
# 선택적 import
|
|
@@ -20,23 +21,47 @@ try:
|
|
|
20
21
|
except ImportError:
|
|
21
22
|
NUMPY_AVAILABLE = False
|
|
22
23
|
|
|
24
|
+
try:
|
|
25
|
+
from ultralytics import YOLOWorld
|
|
26
|
+
YOLO_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
YOLOWorld = None
|
|
29
|
+
YOLO_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
from .config import LICENSE_PLATE_PATTERNS, YOLO_MODEL_NAME, YOLO_CONFIDENCE, YOLO_PROMPTS
|
|
32
|
+
|
|
23
33
|
|
|
24
34
|
class VideoAnalyzer:
|
|
25
35
|
"""영상 분석 클래스 - 얼굴, 텍스트, 번호판, 타투 감지"""
|
|
26
36
|
|
|
37
|
+
_ocr_lock = threading.Lock()
|
|
38
|
+
|
|
27
39
|
def __init__(self):
|
|
28
40
|
self.ocr_reader = None
|
|
29
41
|
self.face_cascade = None
|
|
42
|
+
self.yolo_model = None
|
|
30
43
|
|
|
31
44
|
if CV2_AVAILABLE:
|
|
32
45
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
33
46
|
self.face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
34
47
|
|
|
35
48
|
def _init_ocr(self):
|
|
36
|
-
"""OCR 리더 초기화 (필요할
|
|
49
|
+
"""OCR 리더 초기화 (필요할 때만, 스레드 안전)"""
|
|
37
50
|
if EASYOCR_AVAILABLE and self.ocr_reader is None:
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
with self._ocr_lock:
|
|
52
|
+
if self.ocr_reader is None:
|
|
53
|
+
print(" OCR 엔진 초기화 중...")
|
|
54
|
+
self.ocr_reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
|
|
55
|
+
|
|
56
|
+
def _init_yolo(self):
|
|
57
|
+
"""YOLO-World 모델 초기화 (필요할 때만, 스레드 안전)"""
|
|
58
|
+
if YOLO_AVAILABLE and self.yolo_model is None:
|
|
59
|
+
with self._ocr_lock:
|
|
60
|
+
if self.yolo_model is None:
|
|
61
|
+
print(f" YOLO-World 모델({YOLO_MODEL_NAME}) 로딩 중...")
|
|
62
|
+
self.yolo_model = YOLOWorld(YOLO_MODEL_NAME)
|
|
63
|
+
# 감지할 클래스(프롬프트) 설정
|
|
64
|
+
self.yolo_model.set_classes(YOLO_PROMPTS)
|
|
40
65
|
|
|
41
66
|
def extract_frames(self, video_path, num_frames=10):
|
|
42
67
|
"""영상에서 균등 간격으로 프레임 추출"""
|
|
@@ -75,29 +100,75 @@ class VideoAnalyzer:
|
|
|
75
100
|
)
|
|
76
101
|
|
|
77
102
|
def detect_text(self, frame):
|
|
78
|
-
"""EasyOCR로 텍스트 감지"""
|
|
103
|
+
"""EasyOCR로 텍스트 감지 (스레드 안전)"""
|
|
79
104
|
if not EASYOCR_AVAILABLE:
|
|
80
105
|
return []
|
|
81
106
|
|
|
82
107
|
self._init_ocr()
|
|
83
108
|
try:
|
|
84
109
|
h, w = frame.shape[:2]
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
|
|
111
|
+
# 가독성 개선을 위해 1080p 수준으로 리사이즈 (너무 작으면 인식률 저하)
|
|
112
|
+
if w > 1280:
|
|
113
|
+
scale = 1280 / w
|
|
114
|
+
frame = cv2.resize(frame, (1280, int(h * scale)), interpolation=cv2.INTER_LANCZOS4)
|
|
115
|
+
elif w < 640:
|
|
116
|
+
# 너무 작은 경우 확대
|
|
117
|
+
scale = 960 / w
|
|
118
|
+
frame = cv2.resize(frame, (960, int(h * scale)), interpolation=cv2.INTER_CUBIC)
|
|
119
|
+
|
|
120
|
+
# 전처리: 그레이스케일 및 대비 강화 (옵션)
|
|
121
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
122
|
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
|
|
123
|
+
processed = clahe.apply(gray)
|
|
124
|
+
|
|
125
|
+
with self._ocr_lock:
|
|
126
|
+
# 원본(컬러)과 전처리(그레이) 중 선택 가능하나 보통 EasyOCR은 컬러에서 잘 작동함
|
|
127
|
+
# 대비 강화된 그레이스케일을 사용해봄
|
|
128
|
+
results = self.ocr_reader.readtext(processed)
|
|
129
|
+
|
|
130
|
+
# 신뢰도 임계값 0.25로 약간 하향 조정 (기존 0.3)
|
|
131
|
+
return [r[1] for r in results if r[2] > 0.25]
|
|
132
|
+
except Exception as e:
|
|
133
|
+
print(f" ⚠ OCR 에러: {e}")
|
|
92
134
|
return []
|
|
93
135
|
|
|
94
|
-
def detect_license_plate(self, texts):
|
|
95
|
-
"""텍스트에서 번호판 패턴 감지"""
|
|
136
|
+
def detect_license_plate(self, texts, frame=None):
|
|
137
|
+
"""텍스트에서 번호판 패턴 감지 및 YOLO-World 보조 감지"""
|
|
138
|
+
# 1. YOLO-World로 번호판 영역 확인
|
|
139
|
+
if YOLO_AVAILABLE and frame is not None:
|
|
140
|
+
try:
|
|
141
|
+
self._init_yolo()
|
|
142
|
+
if self.yolo_model:
|
|
143
|
+
results = self.yolo_model(frame, verbose=False, conf=YOLO_CONFIDENCE)
|
|
144
|
+
for r in results:
|
|
145
|
+
# YOLO-World 클래스 인덱스는 YOLO_PROMPTS 순서와 같음
|
|
146
|
+
# 0: license plate, 1: tattoo, 2: face (config 기준)
|
|
147
|
+
if any(box.cls == 0 for box in r.boxes):
|
|
148
|
+
# 번호판이 감지됨 -> 텍스트가 조금이라도 있으면 통과
|
|
149
|
+
if texts: return True
|
|
150
|
+
# 텍스트가 없어도 신뢰도가 높으면 감지된 것으로 간주 (옵션)
|
|
151
|
+
if any(box.conf > 0.5 for box in r.boxes if box.cls == 0):
|
|
152
|
+
return True
|
|
153
|
+
except:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
if not texts:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# 2. 개별 텍스트 박스 체크
|
|
96
160
|
for text in texts:
|
|
97
|
-
text_clean =
|
|
161
|
+
text_clean = re.sub(r'[^0-9가-힣]', '', text)
|
|
98
162
|
for pattern in LICENSE_PLATE_PATTERNS:
|
|
99
163
|
if re.search(pattern, text_clean):
|
|
100
164
|
return True
|
|
165
|
+
|
|
166
|
+
# 3. 프레임 내 모든 텍스트 결합 후 체크 (번호판이 쪼개진 경우 대응)
|
|
167
|
+
combined_text = "".join([re.sub(r'[^0-9가-힣]', '', t) for t in texts])
|
|
168
|
+
for pattern in LICENSE_PLATE_PATTERNS:
|
|
169
|
+
if re.search(pattern, combined_text):
|
|
170
|
+
return True
|
|
171
|
+
|
|
101
172
|
return False
|
|
102
173
|
|
|
103
174
|
def detect_tattoo(self, frame):
|
|
@@ -151,42 +222,77 @@ class VideoAnalyzer:
|
|
|
151
222
|
'license_plate': False,
|
|
152
223
|
'tattoo': False,
|
|
153
224
|
'face_count': 0,
|
|
154
|
-
'detected_texts': []
|
|
225
|
+
'detected_texts': [],
|
|
226
|
+
'first_detection_sec': None,
|
|
227
|
+
'first_detection_ts': None
|
|
155
228
|
}
|
|
156
229
|
|
|
157
230
|
if not CV2_AVAILABLE:
|
|
158
231
|
print(" ⚠ OpenCV 미설치")
|
|
159
232
|
return results
|
|
160
233
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
234
|
+
# 영상 정보 가져오기
|
|
235
|
+
cap = cv2.VideoCapture(video_path)
|
|
236
|
+
if not cap.isOpened():
|
|
164
237
|
return results
|
|
238
|
+
|
|
239
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30
|
|
240
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
241
|
+
cap.release()
|
|
165
242
|
|
|
243
|
+
# 분석 프레임 수 증가 (10 -> 20)
|
|
244
|
+
num_analysis_frames = 20
|
|
245
|
+
# 영상이 아주 긴 경우(10분 이상) 더 많은 프레임 추출
|
|
246
|
+
if total_frames / fps > 600:
|
|
247
|
+
num_analysis_frames = 30
|
|
248
|
+
|
|
249
|
+
frame_indices = [int(i * total_frames / (num_analysis_frames + 1)) for i in range(1, num_analysis_frames + 1)]
|
|
250
|
+
|
|
166
251
|
all_texts = []
|
|
167
252
|
total_faces = 0
|
|
168
253
|
|
|
169
|
-
|
|
254
|
+
cap = cv2.VideoCapture(video_path)
|
|
255
|
+
for idx in frame_indices:
|
|
256
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
|
|
257
|
+
ret, frame = cap.read()
|
|
258
|
+
if not ret: continue
|
|
259
|
+
|
|
260
|
+
# 현재 프레임의 시간(초)
|
|
261
|
+
current_sec = idx / fps
|
|
262
|
+
detected_now = False
|
|
263
|
+
|
|
170
264
|
# 얼굴
|
|
171
265
|
faces = self.detect_faces(frame)
|
|
172
266
|
if len(faces) > 0:
|
|
173
267
|
results['face'] = True
|
|
174
268
|
total_faces += len(faces)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
269
|
+
detected_now = True
|
|
270
|
+
|
|
271
|
+
# 텍스트 및 번호판
|
|
272
|
+
texts = self.detect_text(frame)
|
|
273
|
+
if texts:
|
|
274
|
+
results['text'] = True
|
|
275
|
+
all_texts.extend(texts)
|
|
276
|
+
detected_now = True
|
|
277
|
+
|
|
278
|
+
# 번호판 감지 (프레임별로 결합 텍스트 및 YOLO 확인)
|
|
279
|
+
if self.detect_license_plate(texts, frame=frame):
|
|
280
|
+
results['license_plate'] = True
|
|
182
281
|
|
|
183
282
|
# 타투
|
|
184
283
|
if self.detect_tattoo(frame):
|
|
185
284
|
results['tattoo'] = True
|
|
285
|
+
detected_now = True
|
|
286
|
+
|
|
287
|
+
# 첫 감지 시점 기록
|
|
288
|
+
if detected_now and results['first_detection_sec'] is None:
|
|
289
|
+
results['first_detection_sec'] = current_sec
|
|
290
|
+
m, s = int(current_sec // 60), int(current_sec % 60)
|
|
291
|
+
results['first_detection_ts'] = f"{m:02d}:{s:02d}"
|
|
292
|
+
|
|
293
|
+
cap.release()
|
|
186
294
|
|
|
187
|
-
# 번호판 (텍스트에서)
|
|
188
295
|
if all_texts:
|
|
189
|
-
results['license_plate'] = self.detect_license_plate(all_texts)
|
|
190
296
|
results['detected_texts'] = list(set(all_texts))[:10]
|
|
191
297
|
|
|
192
298
|
results['face_count'] = total_faces
|
ytcollector/cli.py
CHANGED
|
@@ -51,8 +51,8 @@ def create_parser():
|
|
|
51
51
|
parser.add_argument(
|
|
52
52
|
'-o', '--output',
|
|
53
53
|
type=str,
|
|
54
|
-
default=
|
|
55
|
-
help='저장 경로 (기본:
|
|
54
|
+
default=".",
|
|
55
|
+
help='저장 경로 (기본: 현재 폴더)'
|
|
56
56
|
)
|
|
57
57
|
parser.add_argument(
|
|
58
58
|
'--fast',
|
|
@@ -74,7 +74,7 @@ def create_parser():
|
|
|
74
74
|
parser.add_argument(
|
|
75
75
|
'-v', '--version',
|
|
76
76
|
action='version',
|
|
77
|
-
version='%(prog)s 1.
|
|
77
|
+
version='%(prog)s 1.1.2'
|
|
78
78
|
)
|
|
79
79
|
parser.add_argument(
|
|
80
80
|
'--check-deps',
|
|
@@ -115,7 +115,7 @@ def run(
|
|
|
115
115
|
categories = ['face']
|
|
116
116
|
|
|
117
117
|
if output is None:
|
|
118
|
-
output =
|
|
118
|
+
output = "."
|
|
119
119
|
|
|
120
120
|
# 의존성 체크
|
|
121
121
|
missing = check_dependencies()
|
ytcollector/config.py
CHANGED
|
@@ -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
|
|
|
@@ -55,12 +51,38 @@ CATEGORY_NAMES = {
|
|
|
55
51
|
'text': '텍스트'
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
#
|
|
54
|
+
# 카테고리별 제외 키워드 (제목에 포함 시 스킵)
|
|
55
|
+
BLACKLIST_KEYWORDS = {
|
|
56
|
+
'tattoo': [
|
|
57
|
+
"두피 문신", "두피문신",
|
|
58
|
+
"눈썹 문신", "눈썹문신",
|
|
59
|
+
"입술 문신", "입술문신",
|
|
60
|
+
"립타투", "립 타투",
|
|
61
|
+
"헤어타투", "헤어 타투",
|
|
62
|
+
"구레나룻문신", "구레나룻 문신",
|
|
63
|
+
"틴트 입술",
|
|
64
|
+
"반영구", "SMP"
|
|
65
|
+
],
|
|
66
|
+
'face': [],
|
|
67
|
+
'license_plate': [],
|
|
68
|
+
'text': []
|
|
69
|
+
}
|
|
70
|
+
|
|
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
|
+
# 번호판 정규식 패턴 (한국 자동차 번호판 중심)
|
|
59
77
|
LICENSE_PLATE_PATTERNS = [
|
|
60
|
-
|
|
61
|
-
r'
|
|
62
|
-
|
|
63
|
-
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+',
|
|
64
86
|
]
|
|
65
87
|
|
|
66
88
|
# 스킵할 에러 메시지
|
ytcollector/dataset_builder.py
CHANGED
|
@@ -1,25 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import subprocess
|
|
3
3
|
from yt_dlp import YoutubeDL
|
|
4
|
-
|
|
5
|
-
def get_url_list(file_path):
|
|
6
|
-
if not os.path.exists(file_path):
|
|
7
|
-
return []
|
|
8
|
-
|
|
9
|
-
urls = []
|
|
10
|
-
with open(file_path, 'r', encoding='utf-8') as f:
|
|
11
|
-
for line in f:
|
|
12
|
-
line = line.strip()
|
|
13
|
-
if not line or line.startswith('#'):
|
|
14
|
-
continue
|
|
15
|
-
parts = [p.strip() for p in line.split(',')]
|
|
16
|
-
if len(parts) >= 3:
|
|
17
|
-
urls.append({
|
|
18
|
-
'url': parts[0],
|
|
19
|
-
'timestamp': parts[1],
|
|
20
|
-
'task': parts[2]
|
|
21
|
-
})
|
|
22
|
-
return urls
|
|
4
|
+
from .utils import clip_video, get_url_list, get_video_duration, timestamp_to_seconds
|
|
23
5
|
|
|
24
6
|
def download_videos(url_list, output_dir):
|
|
25
7
|
os.makedirs(output_dir, exist_ok=True)
|
|
@@ -46,57 +28,9 @@ def download_videos(url_list, output_dir):
|
|
|
46
28
|
except Exception as e:
|
|
47
29
|
print(f"[{index_str}] Failed: {e}")
|
|
48
30
|
|
|
49
|
-
def get_video_duration(file_path):
|
|
50
|
-
cmd = [
|
|
51
|
-
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
|
52
|
-
'-of', 'default=noprint_wrappers=1:nokey=1', file_path
|
|
53
|
-
]
|
|
54
|
-
try:
|
|
55
|
-
output = subprocess.check_output(cmd).decode('utf-8').strip()
|
|
56
|
-
return float(output)
|
|
57
|
-
except:
|
|
58
|
-
return 0.0
|
|
59
|
-
|
|
60
|
-
def timestamp_to_seconds(timestamp):
|
|
61
|
-
try:
|
|
62
|
-
parts = timestamp.split(':')
|
|
63
|
-
if len(parts) == 2:
|
|
64
|
-
return int(parts[0]) * 60 + int(parts[1])
|
|
65
|
-
return 0.0
|
|
66
|
-
except:
|
|
67
|
-
return 0.0
|
|
68
|
-
|
|
69
|
-
def clip_video(input_path, output_path, center_timestamp, window_seconds=90):
|
|
70
|
-
duration = get_video_duration(input_path)
|
|
71
|
-
if duration == 0: return False
|
|
72
|
-
|
|
73
|
-
center_sec = timestamp_to_seconds(center_timestamp)
|
|
74
|
-
start_sec = max(0, center_sec - window_seconds)
|
|
75
|
-
end_sec = min(duration, start_sec + (window_seconds * 2))
|
|
76
|
-
|
|
77
|
-
if (end_sec - start_sec) < (window_seconds * 2) and start_sec > 0:
|
|
78
|
-
start_sec = max(0, end_sec - (window_seconds * 2))
|
|
79
|
-
|
|
80
|
-
actual_duration = end_sec - start_sec
|
|
81
|
-
|
|
82
|
-
cmd = [
|
|
83
|
-
'ffmpeg', '-y', '-ss', str(start_sec), '-t', str(actual_duration),
|
|
84
|
-
'-i', input_path, '-c', 'copy', output_path
|
|
85
|
-
]
|
|
86
|
-
try:
|
|
87
|
-
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
88
|
-
return True
|
|
89
|
-
except:
|
|
90
|
-
cmd[7:9] = ['-c:v', 'libx264', '-crf', '23', '-c:a', 'aac']
|
|
91
|
-
try:
|
|
92
|
-
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
93
|
-
return True
|
|
94
|
-
except:
|
|
95
|
-
return False
|
|
96
|
-
|
|
97
31
|
def build_dataset(url_file, output_root="."):
|
|
98
|
-
video_dir = os.path.join(output_root, "video")
|
|
99
|
-
clip_dir = os.path.join(output_root, "video_clips")
|
|
32
|
+
video_dir = os.path.abspath(os.path.join(output_root, "video"))
|
|
33
|
+
clip_dir = os.path.abspath(os.path.join(output_root, "video_clips"))
|
|
100
34
|
|
|
101
35
|
urls = get_url_list(url_file)
|
|
102
36
|
if not urls:
|
|
@@ -119,9 +53,10 @@ def build_dataset(url_file, output_root="."):
|
|
|
119
53
|
if os.path.exists(output_file): continue
|
|
120
54
|
|
|
121
55
|
print(f"[{index_str}] Clipping: {files[0]}")
|
|
122
|
-
|
|
56
|
+
center_sec = timestamp_to_seconds(item['timestamp'])
|
|
57
|
+
clip_video(input_file, output_file, center_sec)
|
|
123
58
|
|
|
124
|
-
print(f"\nDone! Clips saved in: {
|
|
59
|
+
print(f"\nDone! Clips saved in: {clip_dir}")
|
|
125
60
|
|
|
126
61
|
def main():
|
|
127
62
|
import argparse
|
ytcollector/downloader.py
CHANGED
|
@@ -2,20 +2,24 @@ import os
|
|
|
2
2
|
import time
|
|
3
3
|
import random
|
|
4
4
|
import shutil
|
|
5
|
+
import threading
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
7
|
|
|
7
8
|
from yt_dlp import YoutubeDL
|
|
8
9
|
|
|
9
|
-
from .config import USER_AGENTS, CATEGORY_QUERIES, CATEGORY_NAMES, SKIP_ERRORS
|
|
10
|
+
from .config import USER_AGENTS, CATEGORY_QUERIES, CATEGORY_NAMES, SKIP_ERRORS, BLACKLIST_KEYWORDS
|
|
10
11
|
from .analyzer import VideoAnalyzer
|
|
12
|
+
from .utils import clip_video, append_to_url_list, get_video_duration, get_next_index
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class YouTubeDownloader:
|
|
14
16
|
"""YouTube 다운로더 클래스"""
|
|
17
|
+
|
|
18
|
+
_file_lock = threading.Lock()
|
|
15
19
|
|
|
16
20
|
def __init__(self, output_path, max_duration=180, proxy=None, fast_mode=False, workers=3):
|
|
17
21
|
self.output_path = output_path
|
|
18
|
-
self.max_duration = max_duration
|
|
22
|
+
self.max_duration = max_duration # 기본 180초(3분)
|
|
19
23
|
self.proxy = proxy
|
|
20
24
|
self.fast_mode = fast_mode
|
|
21
25
|
self.workers = workers
|
|
@@ -146,7 +150,7 @@ class YouTubeDownloader:
|
|
|
146
150
|
return None
|
|
147
151
|
|
|
148
152
|
def _process_video(self, entry, category, cat_name):
|
|
149
|
-
"""단일 영상 처리 (다운로드 + 분석)"""
|
|
153
|
+
"""단일 영상 처리 (다운로드 + 분석 + 자동 트리밍 + URL 기록)"""
|
|
150
154
|
vid = entry.get('id')
|
|
151
155
|
url = f"https://www.youtube.com/watch?v={vid}"
|
|
152
156
|
title = entry.get('title', '?')[:45]
|
|
@@ -156,6 +160,7 @@ class YouTubeDownloader:
|
|
|
156
160
|
result_info = {'title': title, 'status': status, 'saved': False}
|
|
157
161
|
|
|
158
162
|
if status == "ok" and filepath:
|
|
163
|
+
print(f" 🔍 분석 중...")
|
|
159
164
|
analysis = self.analyzer.analyze(filepath)
|
|
160
165
|
|
|
161
166
|
detected = []
|
|
@@ -171,12 +176,34 @@ class YouTubeDownloader:
|
|
|
171
176
|
result_info['detected'] = detected
|
|
172
177
|
|
|
173
178
|
if analysis.get(category):
|
|
179
|
+
# 1. 태스크별 전용 youtube_url_{category}.txt 업데이트
|
|
180
|
+
url_file_path = f"youtube_url_{category}.txt"
|
|
181
|
+
ts = analysis.get('first_detection_ts', '00:00')
|
|
182
|
+
append_to_url_list(url_file_path, url, ts, category)
|
|
183
|
+
|
|
184
|
+
# 2. 결과 폴더 이동 및 파일명 변경 (category_0001.mp4 형식)
|
|
174
185
|
dest_dir = os.path.join(self.output_path, cat_name)
|
|
175
186
|
os.makedirs(dest_dir, exist_ok=True)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
|
|
188
|
+
# 파일명 접두어 결정 (license_plate -> license)
|
|
189
|
+
prefix = category.replace('license_plate', 'license')
|
|
190
|
+
|
|
191
|
+
with self._file_lock:
|
|
192
|
+
idx = get_next_index(dest_dir, prefix)
|
|
193
|
+
new_filename = f"{prefix}_{idx:04d}.mp4"
|
|
194
|
+
dest = os.path.join(dest_dir, new_filename)
|
|
195
|
+
|
|
196
|
+
# 원본 길이가 3분(180초) 초과면 감지 시점 기준 트리밍
|
|
197
|
+
duration = get_video_duration(filepath)
|
|
198
|
+
if duration > 180:
|
|
199
|
+
print(f" ✂ 3분 초과 영상 자동 트리밍 ({self._format_duration(duration)} -> 3:00)")
|
|
200
|
+
clip_video(filepath, dest, analysis.get('first_detection_sec', 0))
|
|
201
|
+
else:
|
|
202
|
+
if not os.path.exists(dest):
|
|
203
|
+
shutil.move(filepath, dest)
|
|
204
|
+
|
|
179
205
|
result_info['saved'] = True
|
|
206
|
+
result_info['new_path'] = dest
|
|
180
207
|
else:
|
|
181
208
|
if category == 'license_plate':
|
|
182
209
|
dest_dir = os.path.join(self.output_path, "번호판_미감지")
|
|
@@ -201,7 +228,9 @@ class YouTubeDownloader:
|
|
|
201
228
|
print(f"\n{'='*60}")
|
|
202
229
|
print(f"[{cat_name}] 검색: {query}")
|
|
203
230
|
mode = "⚡ 고속" if self.fast_mode else "일반"
|
|
204
|
-
|
|
231
|
+
# 검색 시에는 제한을 20분(1200초)으로 완화하여 더 많은 영상 확보
|
|
232
|
+
search_limit = 1200
|
|
233
|
+
print(f"목표: {max_videos}개 | 검색제한: {self._format_duration(search_limit)} | {mode}")
|
|
205
234
|
print('='*60)
|
|
206
235
|
|
|
207
236
|
# 검색
|
|
@@ -212,23 +241,29 @@ class YouTubeDownloader:
|
|
|
212
241
|
|
|
213
242
|
print(f"검색됨: {len(entries)}개")
|
|
214
243
|
|
|
215
|
-
#
|
|
244
|
+
# 필터링
|
|
216
245
|
filtered = []
|
|
217
246
|
for entry in entries:
|
|
218
|
-
if not entry:
|
|
219
|
-
continue
|
|
247
|
+
if not entry: continue
|
|
220
248
|
|
|
221
249
|
vid = entry.get('id')
|
|
222
|
-
title = entry.get('title', '
|
|
250
|
+
title = entry.get('title', '')
|
|
223
251
|
dur = entry.get('duration') or self._get_duration(vid)
|
|
224
252
|
|
|
225
|
-
|
|
253
|
+
# 블랙리스트 키워드 체크
|
|
254
|
+
blacklist = BLACKLIST_KEYWORDS.get(category, [])
|
|
255
|
+
if any(kw in title for kw in blacklist):
|
|
256
|
+
print(f" ✗ [제외] {title[:40]}...")
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# 너무 긴 영상(예: 20분 초과) 제외
|
|
260
|
+
if dur and dur < search_limit:
|
|
226
261
|
filtered.append(entry)
|
|
227
262
|
print(f" ✓ [{self._format_duration(dur)}] {title}")
|
|
228
263
|
if len(filtered) >= max_videos:
|
|
229
264
|
break
|
|
230
265
|
elif dur:
|
|
231
|
-
print(f" ✗ [{self._format_duration(dur)}]
|
|
266
|
+
print(f" ✗ [{self._format_duration(dur)}] (너무 filter됨)")
|
|
232
267
|
|
|
233
268
|
if not self.fast_mode:
|
|
234
269
|
time.sleep(0.3)
|
|
@@ -237,30 +272,27 @@ class YouTubeDownloader:
|
|
|
237
272
|
print("조건 맞는 영상 없음")
|
|
238
273
|
return 0
|
|
239
274
|
|
|
240
|
-
print(f"\n
|
|
275
|
+
print(f"\n다운로드 및 분석: {len(filtered)}개" + (" (병렬)" if self.fast_mode else ""))
|
|
241
276
|
success = 0
|
|
242
277
|
|
|
243
278
|
if self.fast_mode and self.workers > 1:
|
|
244
|
-
# 병렬 다운로드
|
|
245
279
|
with ThreadPoolExecutor(max_workers=self.workers) as executor:
|
|
246
280
|
futures = {
|
|
247
281
|
executor.submit(self._process_video, entry, category, cat_name): entry
|
|
248
282
|
for entry in filtered
|
|
249
283
|
}
|
|
250
|
-
|
|
251
284
|
for i, future in enumerate(as_completed(futures)):
|
|
252
285
|
entry = futures[future]
|
|
253
286
|
title = entry.get('title', '?')[:45]
|
|
254
|
-
|
|
255
287
|
try:
|
|
256
288
|
result = future.result()
|
|
257
289
|
print(f"\n[{i+1}/{len(filtered)}] {title}")
|
|
258
|
-
|
|
259
290
|
if result['status'] == "ok":
|
|
260
291
|
if result.get('detected'):
|
|
261
292
|
print(f" 감지: {', '.join(result['detected'])}")
|
|
262
293
|
if result['saved']:
|
|
263
|
-
|
|
294
|
+
new_name = os.path.basename(result['new_path'])
|
|
295
|
+
print(f" ✅ 저장: {cat_name}/{new_name}")
|
|
264
296
|
success += 1
|
|
265
297
|
elif result.get('undetected_saved'):
|
|
266
298
|
print(" 📁 미감지 보관")
|
|
@@ -276,61 +308,26 @@ class YouTubeDownloader:
|
|
|
276
308
|
print(f"\n[{i+1}/{len(filtered)}] {title}")
|
|
277
309
|
print(f" ✗ 에러: {e}")
|
|
278
310
|
else:
|
|
279
|
-
# 순차 다운로드
|
|
280
311
|
for i, entry in enumerate(filtered):
|
|
281
312
|
vid = entry.get('id')
|
|
282
|
-
url = f"https://www.youtube.com/watch?v={vid}"
|
|
283
313
|
title = entry.get('title', '?')[:45]
|
|
284
|
-
|
|
285
314
|
print(f"\n[{i+1}/{len(filtered)}] {title}")
|
|
286
315
|
|
|
287
|
-
|
|
288
|
-
if
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
detected = []
|
|
296
|
-
if result['face']:
|
|
297
|
-
detected.append(f"얼굴({result['face_count']})")
|
|
298
|
-
if result['text']:
|
|
299
|
-
detected.append("텍스트")
|
|
300
|
-
if result['license_plate']:
|
|
301
|
-
detected.append("번호판")
|
|
302
|
-
if result['tattoo']:
|
|
303
|
-
detected.append("타투")
|
|
304
|
-
|
|
305
|
-
if detected:
|
|
306
|
-
print(f" 감지: {', '.join(detected)}")
|
|
307
|
-
|
|
308
|
-
if result.get(category):
|
|
309
|
-
dest_dir = os.path.join(self.output_path, cat_name)
|
|
310
|
-
os.makedirs(dest_dir, exist_ok=True)
|
|
311
|
-
dest = os.path.join(dest_dir, os.path.basename(filepath))
|
|
312
|
-
if not os.path.exists(dest):
|
|
313
|
-
shutil.move(filepath, dest)
|
|
314
|
-
print(f" ✅ 저장: {cat_name}/")
|
|
316
|
+
result = self._process_video(entry, category, cat_name)
|
|
317
|
+
if result['status'] == "ok":
|
|
318
|
+
if result.get('detected'):
|
|
319
|
+
print(f" 감지: {', '.join(result['detected'])}")
|
|
320
|
+
if result['saved']:
|
|
321
|
+
new_name = os.path.basename(result['new_path'])
|
|
322
|
+
print(f" ✅ 저장: {cat_name}/{new_name}")
|
|
315
323
|
success += 1
|
|
324
|
+
elif result.get('undetected_saved'):
|
|
325
|
+
print(" 📁 미감지 보관")
|
|
316
326
|
else:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
os.makedirs(dest_dir, exist_ok=True)
|
|
320
|
-
dest = os.path.join(dest_dir, os.path.basename(filepath))
|
|
321
|
-
if not os.path.exists(dest):
|
|
322
|
-
shutil.move(filepath, dest)
|
|
323
|
-
print(" 📁 미감지 보관")
|
|
324
|
-
else:
|
|
325
|
-
try:
|
|
326
|
-
os.remove(filepath)
|
|
327
|
-
except:
|
|
328
|
-
pass
|
|
329
|
-
print(" ❌ 미감지 삭제")
|
|
330
|
-
|
|
331
|
-
elif status == "skipped":
|
|
327
|
+
print(" ❌ 미감지 삭제")
|
|
328
|
+
elif result['status'] == "skipped":
|
|
332
329
|
print(" ⏭ 이미 있음")
|
|
333
|
-
elif status == "unavailable":
|
|
330
|
+
elif result['status'] == "unavailable":
|
|
334
331
|
print(" ⏭ 사용불가")
|
|
335
332
|
else:
|
|
336
333
|
print(" ✗ 실패")
|
ytcollector/utils.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
def get_video_duration(file_path):
|
|
5
|
+
"""영상 전체 길이를 초 단위로 반환"""
|
|
6
|
+
cmd = [
|
|
7
|
+
'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
|
8
|
+
'-of', 'default=noprint_wrappers=1:nokey=1', file_path
|
|
9
|
+
]
|
|
10
|
+
try:
|
|
11
|
+
output = subprocess.check_output(cmd).decode('utf-8').strip()
|
|
12
|
+
return float(output)
|
|
13
|
+
except:
|
|
14
|
+
return 0.0
|
|
15
|
+
|
|
16
|
+
def timestamp_to_seconds(timestamp):
|
|
17
|
+
"""MM:SS 또는 SS 형식을 초 단위로 변환"""
|
|
18
|
+
if isinstance(timestamp, (int, float)):
|
|
19
|
+
return float(timestamp)
|
|
20
|
+
try:
|
|
21
|
+
parts = str(timestamp).split(':')
|
|
22
|
+
if len(parts) == 2:
|
|
23
|
+
return int(parts[0]) * 60 + int(parts[1])
|
|
24
|
+
return float(parts[0])
|
|
25
|
+
except:
|
|
26
|
+
return 0.0
|
|
27
|
+
|
|
28
|
+
def seconds_to_timestamp(seconds):
|
|
29
|
+
"""초 단위를 MM:SS 형식으로 변환"""
|
|
30
|
+
m = int(seconds // 60)
|
|
31
|
+
s = int(seconds % 60)
|
|
32
|
+
return f"{m:02d}:{s:02d}"
|
|
33
|
+
|
|
34
|
+
def clip_video(input_path, output_path, center_sec, window_seconds=90):
|
|
35
|
+
"""center_sec를 기준으로 앞뒤 window_seconds만큼 자름"""
|
|
36
|
+
duration = get_video_duration(input_path)
|
|
37
|
+
if duration == 0:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
start_sec = max(0, center_sec - window_seconds)
|
|
41
|
+
end_sec = min(duration, start_sec + (window_seconds * 2))
|
|
42
|
+
|
|
43
|
+
if (end_sec - start_sec) < (window_seconds * 2) and start_sec > 0:
|
|
44
|
+
start_sec = max(0, end_sec - (window_seconds * 2))
|
|
45
|
+
|
|
46
|
+
actual_duration = end_sec - start_sec
|
|
47
|
+
|
|
48
|
+
# 임시 파일 경로
|
|
49
|
+
temp_output = output_path + ".tmp.mp4"
|
|
50
|
+
|
|
51
|
+
cmd = [
|
|
52
|
+
'ffmpeg', '-y', '-ss', f"{start_sec:.2f}", '-t', f"{actual_duration:.2f}",
|
|
53
|
+
'-i', input_path, '-c', 'copy', temp_output
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
58
|
+
if os.path.exists(output_path):
|
|
59
|
+
os.remove(output_path)
|
|
60
|
+
os.rename(temp_output, output_path)
|
|
61
|
+
return True
|
|
62
|
+
except:
|
|
63
|
+
# copy 실패 시 재인코딩
|
|
64
|
+
cmd[7:9] = ['-c:v', 'libx264', '-crf', '23', '-c:a', 'aac']
|
|
65
|
+
try:
|
|
66
|
+
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
67
|
+
if os.path.exists(temp_output):
|
|
68
|
+
if os.path.exists(output_path): os.remove(output_path)
|
|
69
|
+
os.rename(temp_output, output_path)
|
|
70
|
+
return True
|
|
71
|
+
except:
|
|
72
|
+
if os.path.exists(temp_output): os.remove(temp_output)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def append_to_url_list(file_path, url, timestamp, task):
|
|
76
|
+
"""youtube_url.txt에 데이터 추가"""
|
|
77
|
+
line = f"{url}, {timestamp}, {task}\n"
|
|
78
|
+
# 파일이 없으면 헤더 추가
|
|
79
|
+
exists = os.path.exists(file_path)
|
|
80
|
+
with open(file_path, 'a', encoding='utf-8') as f:
|
|
81
|
+
if not exists:
|
|
82
|
+
f.write("# URL, MM:SS, TaskName\n")
|
|
83
|
+
f.write(line)
|
|
84
|
+
|
|
85
|
+
def get_url_list(file_path):
|
|
86
|
+
"""youtube_url.txt 파일을 읽어서 리스트로 반환"""
|
|
87
|
+
if not os.path.exists(file_path):
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
urls = []
|
|
91
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
92
|
+
for line in f:
|
|
93
|
+
line = line.strip()
|
|
94
|
+
if not line or line.startswith('#'):
|
|
95
|
+
continue
|
|
96
|
+
parts = [p.strip() for p in line.split(',')]
|
|
97
|
+
if len(parts) >= 3:
|
|
98
|
+
urls.append({
|
|
99
|
+
'url': parts[0],
|
|
100
|
+
'timestamp': parts[1],
|
|
101
|
+
'task': parts[2]
|
|
102
|
+
})
|
|
103
|
+
return urls
|
|
104
|
+
|
|
105
|
+
def get_next_index(directory, prefix):
|
|
106
|
+
"""
|
|
107
|
+
directory 내에서 {prefix}_{index:04d}.mp4 형식의 파일들을 찾아
|
|
108
|
+
가장 높은 index + 1을 반환함. 파일이 없으면 1 반환.
|
|
109
|
+
"""
|
|
110
|
+
if not os.path.exists(directory):
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
max_idx = 0
|
|
114
|
+
pattern = f"{prefix}_"
|
|
115
|
+
for filename in os.listdir(directory):
|
|
116
|
+
if filename.startswith(pattern) and filename.endswith(".mp4"):
|
|
117
|
+
try:
|
|
118
|
+
# {prefix}_0001.mp4 -> 0001 추출
|
|
119
|
+
idx_part = filename[len(pattern):].split('.')[0]
|
|
120
|
+
idx = int(idx_part)
|
|
121
|
+
if idx > max_idx:
|
|
122
|
+
max_idx = idx
|
|
123
|
+
except (ValueError, IndexError):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
return max_idx + 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytcollector
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.2
|
|
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"
|
|
@@ -46,7 +47,9 @@ pip install yt-dlp
|
|
|
46
47
|
### 분석 기능용 패키지 (권장)
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
|
-
pip install opencv-python easyocr numpy
|
|
50
|
+
pip install opencv-python easyocr numpy ultralytics
|
|
51
|
+
# YOLO-World 기능을 사용하려면 아래 CLIP 라이브러리 수동 설치가 필요합니다.
|
|
52
|
+
pip install "git+https://github.com/ultralytics/CLIP.git"
|
|
50
53
|
```
|
|
51
54
|
|
|
52
55
|
## 사용법
|
|
@@ -54,7 +57,7 @@ pip install opencv-python easyocr numpy
|
|
|
54
57
|
### 기본 실행
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
|
-
|
|
60
|
+
ytcollector
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
기본값: 얼굴 카테고리 5개, 최대 3분 영상
|
|
@@ -66,7 +69,7 @@ python main.py
|
|
|
66
69
|
| `-c`, `--categories` | 수집할 카테고리 | `face` |
|
|
67
70
|
| `-n`, `--count` | 카테고리당 다운로드 수 | `5` |
|
|
68
71
|
| `-d`, `--duration` | 최대 영상 길이(분) | `3` |
|
|
69
|
-
| `-o`, `--output` | 저장 경로 |
|
|
72
|
+
| `-o`, `--output` | 저장 경로 | `.` (현재 폴더) |
|
|
70
73
|
| `--fast` | 고속 모드 (병렬 다운로드) | 비활성화 |
|
|
71
74
|
| `-w`, `--workers` | 병렬 다운로드 수 | `3` |
|
|
72
75
|
| `--proxy` | 프록시 주소 | 없음 |
|
|
@@ -86,45 +89,45 @@ python main.py
|
|
|
86
89
|
|
|
87
90
|
```bash
|
|
88
91
|
# 얼굴 영상 10개 수집
|
|
89
|
-
|
|
92
|
+
ytcollector -c face -n 10
|
|
90
93
|
|
|
91
94
|
# 번호판 영상 수집 (최대 5분)
|
|
92
|
-
|
|
95
|
+
ytcollector -c license_plate -d 5
|
|
93
96
|
|
|
94
97
|
# 타투 영상 수집
|
|
95
|
-
|
|
98
|
+
ytcollector -c tattoo -n 5
|
|
96
99
|
```
|
|
97
100
|
|
|
98
101
|
### 여러 카테고리
|
|
99
102
|
|
|
100
103
|
```bash
|
|
101
104
|
# 얼굴과 텍스트 각 10개씩
|
|
102
|
-
|
|
105
|
+
ytcollector -c face text -n 10
|
|
103
106
|
|
|
104
107
|
# 모든 카테고리 수집
|
|
105
|
-
|
|
108
|
+
ytcollector -c face license_plate tattoo text -n 5
|
|
106
109
|
```
|
|
107
110
|
|
|
108
111
|
### 고속 모드
|
|
109
112
|
|
|
110
113
|
```bash
|
|
111
114
|
# 병렬 다운로드 (기본 3개 동시)
|
|
112
|
-
|
|
115
|
+
ytcollector -c face -n 10 --fast
|
|
113
116
|
|
|
114
117
|
# 5개 동시 다운로드
|
|
115
|
-
|
|
118
|
+
ytcollector -c face -n 10 --fast -w 5
|
|
116
119
|
```
|
|
117
120
|
|
|
118
121
|
### 저장 경로 지정
|
|
119
122
|
|
|
120
123
|
```bash
|
|
121
|
-
|
|
124
|
+
ytcollector -c face -o /path/to/save
|
|
122
125
|
```
|
|
123
126
|
|
|
124
127
|
### 프록시 사용
|
|
125
128
|
|
|
126
129
|
```bash
|
|
127
|
-
|
|
130
|
+
ytcollector -c face --proxy http://proxy.server:8080
|
|
128
131
|
```
|
|
129
132
|
|
|
130
133
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
@@ -184,9 +187,9 @@ https://www.youtube.com/watch?v=aqz-KE-bpKQ, 00:10, sample_task
|
|
|
184
187
|
| 감지 항목 | 사용 기술 | 설명 |
|
|
185
188
|
|-----------|-----------|------|
|
|
186
189
|
| 얼굴 | OpenCV Haar Cascade | 정면 얼굴 감지 |
|
|
187
|
-
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 |
|
|
188
|
-
| 번호판 |
|
|
189
|
-
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 |
|
|
190
|
+
| 텍스트 | EasyOCR | 한국어/영어 문자 인식 (분석 품질 및 프레임 수 개선) |
|
|
191
|
+
| 번호판 | YOLO-World + OCR | YOLO-World 기반 시각적 감지 + OCR 매칭 |
|
|
192
|
+
| 타투 | OpenCV HSV 분석 | 피부 영역 내 잉크 패턴 감지 |
|
|
190
193
|
|
|
191
194
|
## 주의사항
|
|
192
195
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ytcollector/__init__.py,sha256=OkibE8GYgt1qwOmkiBNXywkGVdnMj5sVpVzDVPSRXQg,1094
|
|
2
|
+
ytcollector/analyzer.py,sha256=wrTSjvo1g6OKYlXcTWEDqZImbafTketf2zkW62Ojf-I,11115
|
|
3
|
+
ytcollector/cli.py,sha256=gccaMLEyJm_DIpFaW3hyDzluUXCOHGgHxOR-XryLUGc,5577
|
|
4
|
+
ytcollector/config.py,sha256=ZjyDWQg4haJPwUlP-eW0hXa_I2g9wyNaI8y5mxEU0vc,3040
|
|
5
|
+
ytcollector/dataset_builder.py,sha256=nfArEwszoCln48n3T0Eff_4OOaYv8FF0YH8cARBGMWQ,2608
|
|
6
|
+
ytcollector/downloader.py,sha256=ss6V3aBjNZkwLR6FRZuxAwrMkt86Xd6hZc6G2PrNt28,13253
|
|
7
|
+
ytcollector/utils.py,sha256=6XDif-e3GbMHmUvTsBT0YblxNxYnS-2I8HnmjMBZs-M,4254
|
|
8
|
+
ytcollector-1.1.2.dist-info/METADATA,sha256=Wok3o3YiMOC3VrlyjPabZK4IYEGD_LmlP9RQ9KU38Hs,6464
|
|
9
|
+
ytcollector-1.1.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
10
|
+
ytcollector-1.1.2.dist-info/entry_points.txt,sha256=waiVuSJJYt-6_DAal-T4JkHgejo7wKYLdKrEI7tZ-ms,127
|
|
11
|
+
ytcollector-1.1.2.dist-info/top_level.txt,sha256=wozNyCUm0eMOm-9U81yTql6oGaM2O5rWVBXDb93zzyQ,12
|
|
12
|
+
ytcollector-1.1.2.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
ytcollector/__init__.py,sha256=OkibE8GYgt1qwOmkiBNXywkGVdnMj5sVpVzDVPSRXQg,1094
|
|
2
|
-
ytcollector/analyzer.py,sha256=JvppXAcoZ43lXJnGRX-dVGTSZ0QQ-IxBzF6ljT1BjJQ,6388
|
|
3
|
-
ytcollector/cli.py,sha256=zOwnHs7kClOkcWHSUPXrVIPaZYKADMNCBsIosZEzmYc,5629
|
|
4
|
-
ytcollector/config.py,sha256=w5Sx-jKdp4R-rCncDdOXc3WfSuH5OXkVRMIeMXL48VU,2216
|
|
5
|
-
ytcollector/dataset_builder.py,sha256=HGVX_mR1W7_wBl2C5C6Cj43OCVseAGIYmg3-n8WLKuo,4598
|
|
6
|
-
ytcollector/downloader.py,sha256=yQGGTR9ErjHlXHp_RXIDD3Zbl9geTyTHGROPO0nuxV8,12794
|
|
7
|
-
ytcollector-1.0.9.dist-info/METADATA,sha256=bIEbwbhupi-Eo6HQ_4KCPRsM_09d6QK6HAnq2aMiNdM,6212
|
|
8
|
-
ytcollector-1.0.9.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
9
|
-
ytcollector-1.0.9.dist-info/entry_points.txt,sha256=waiVuSJJYt-6_DAal-T4JkHgejo7wKYLdKrEI7tZ-ms,127
|
|
10
|
-
ytcollector-1.0.9.dist-info/top_level.txt,sha256=wozNyCUm0eMOm-9U81yTql6oGaM2O5rWVBXDb93zzyQ,12
|
|
11
|
-
ytcollector-1.0.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|