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 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
- print(" OCR 엔진 초기화 중...")
39
- self.ocr_reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
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
- if w > 640:
86
- scale = 640 / w
87
- frame = cv2.resize(frame, (640, int(h * scale)))
88
-
89
- results = self.ocr_reader.readtext(frame)
90
- return [r[1] for r in results if r[2] > 0.3]
91
- except:
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 = text.replace(' ', '').upper()
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
- frames = self.extract_frames(video_path, num_frames=8)
162
- if not frames:
163
- print(" ⚠ 프레임 추출 실패")
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
- for i, frame in enumerate(frames):
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
- if i % 2 == 0 and EASYOCR_AVAILABLE:
178
- texts = self.detect_text(frame)
179
- if texts:
180
- results['text'] = True
181
- all_texts.extend(texts)
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=os.path.expanduser("~/youtube"),
55
- help='저장 경로 (기본: ~/youtube)'
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.0.9'
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 = os.path.expanduser("~/youtube")
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
- "SBS 동상이몽 클립",
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
- r'\d{2,3}[가-힣]\d{4}',
61
- r'[가-힣]{2}\d{2}[가-힣]\d{4}',
62
- r'[A-Z]{2,3}[-\s]?\d{2,4}[-\s]?[A-Z]{0,3}',
63
- r'\d{2,4}[-\s]?[A-Z]{2,3}[-\s]?\d{2,4}',
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
  # 스킵할 에러 메시지
@@ -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
- clip_video(input_file, output_file, item['timestamp'])
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: {os.path.abspath(clip_dir)}")
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
- dest = os.path.join(dest_dir, os.path.basename(filepath))
177
- if not os.path.exists(dest):
178
- shutil.move(filepath, dest)
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
- print(f"목표: {max_videos}개 | 최대길이: {self._format_duration(self.max_duration)} | {mode}")
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', '?')[:40]
250
+ title = entry.get('title', '')
223
251
  dur = entry.get('duration') or self._get_duration(vid)
224
252
 
225
- if dur and dur < self.max_duration:
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)}] {title}")
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다운로드: {len(filtered)}개" + (" (병렬)" if self.fast_mode else ""))
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
- print(f" ✅ 저장: {cat_name}/")
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
- status, filepath, _ = self._download_one(url)
288
- if not self.fast_mode:
289
- print()
290
-
291
- if status == "ok" and filepath:
292
- print(" 🔍 분석...")
293
- result = self.analyzer.analyze(filepath)
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
- if category == 'license_plate':
318
- dest_dir = os.path.join(self.output_path, "번호판_미감지")
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.0.9
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
- python main.py
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` | 저장 경로 | `~/Downloads/youtube_collection` |
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
- python main.py -c face -n 10
92
+ ytcollector -c face -n 10
90
93
 
91
94
  # 번호판 영상 수집 (최대 5분)
92
- python main.py -c license_plate -d 5
95
+ ytcollector -c license_plate -d 5
93
96
 
94
97
  # 타투 영상 수집
95
- python main.py -c tattoo -n 5
98
+ ytcollector -c tattoo -n 5
96
99
  ```
97
100
 
98
101
  ### 여러 카테고리
99
102
 
100
103
  ```bash
101
104
  # 얼굴과 텍스트 각 10개씩
102
- python main.py -c face text -n 10
105
+ ytcollector -c face text -n 10
103
106
 
104
107
  # 모든 카테고리 수집
105
- python main.py -c face license_plate tattoo text -n 5
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
- python main.py -c face -n 10 --fast
115
+ ytcollector -c face -n 10 --fast
113
116
 
114
117
  # 5개 동시 다운로드
115
- python main.py -c face -n 10 --fast -w 5
118
+ ytcollector -c face -n 10 --fast -w 5
116
119
  ```
117
120
 
118
121
  ### 저장 경로 지정
119
122
 
120
123
  ```bash
121
- python main.py -c face -o /path/to/save
124
+ ytcollector -c face -o /path/to/save
122
125
  ```
123
126
 
124
127
  ### 프록시 사용
125
128
 
126
129
  ```bash
127
- python main.py -c face --proxy http://proxy.server:8080
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
- | 번호판 | EasyOCR + 정규식 | 번호판 패턴 매칭 |
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,,