ytcollector 1.0.9__tar.gz → 1.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ytcollector
3
- Version: 1.0.9
3
+ Version: 1.1.1
4
4
  Summary: YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지
5
5
  Author: YTCollector Team
6
6
  License: MIT
@@ -54,7 +54,7 @@ pip install opencv-python easyocr numpy
54
54
  ### 기본 실행
55
55
 
56
56
  ```bash
57
- python main.py
57
+ ytcollector
58
58
  ```
59
59
 
60
60
  기본값: 얼굴 카테고리 5개, 최대 3분 영상
@@ -66,7 +66,7 @@ python main.py
66
66
  | `-c`, `--categories` | 수집할 카테고리 | `face` |
67
67
  | `-n`, `--count` | 카테고리당 다운로드 수 | `5` |
68
68
  | `-d`, `--duration` | 최대 영상 길이(분) | `3` |
69
- | `-o`, `--output` | 저장 경로 | `~/Downloads/youtube_collection` |
69
+ | `-o`, `--output` | 저장 경로 | `.` (현재 폴더) |
70
70
  | `--fast` | 고속 모드 (병렬 다운로드) | 비활성화 |
71
71
  | `-w`, `--workers` | 병렬 다운로드 수 | `3` |
72
72
  | `--proxy` | 프록시 주소 | 없음 |
@@ -86,45 +86,45 @@ python main.py
86
86
 
87
87
  ```bash
88
88
  # 얼굴 영상 10개 수집
89
- python main.py -c face -n 10
89
+ ytcollector -c face -n 10
90
90
 
91
91
  # 번호판 영상 수집 (최대 5분)
92
- python main.py -c license_plate -d 5
92
+ ytcollector -c license_plate -d 5
93
93
 
94
94
  # 타투 영상 수집
95
- python main.py -c tattoo -n 5
95
+ ytcollector -c tattoo -n 5
96
96
  ```
97
97
 
98
98
  ### 여러 카테고리
99
99
 
100
100
  ```bash
101
101
  # 얼굴과 텍스트 각 10개씩
102
- python main.py -c face text -n 10
102
+ ytcollector -c face text -n 10
103
103
 
104
104
  # 모든 카테고리 수집
105
- python main.py -c face license_plate tattoo text -n 5
105
+ ytcollector -c face license_plate tattoo text -n 5
106
106
  ```
107
107
 
108
108
  ### 고속 모드
109
109
 
110
110
  ```bash
111
111
  # 병렬 다운로드 (기본 3개 동시)
112
- python main.py -c face -n 10 --fast
112
+ ytcollector -c face -n 10 --fast
113
113
 
114
114
  # 5개 동시 다운로드
115
- python main.py -c face -n 10 --fast -w 5
115
+ ytcollector -c face -n 10 --fast -w 5
116
116
  ```
117
117
 
118
118
  ### 저장 경로 지정
119
119
 
120
120
  ```bash
121
- python main.py -c face -o /path/to/save
121
+ ytcollector -c face -o /path/to/save
122
122
  ```
123
123
 
124
124
  ### 프록시 사용
125
125
 
126
126
  ```bash
127
- python main.py -c face --proxy http://proxy.server:8080
127
+ ytcollector -c face --proxy http://proxy.server:8080
128
128
  ```
129
129
 
130
130
  ## SBS Dataset 구축 (URL 리스트 기반)
@@ -21,7 +21,7 @@ pip install opencv-python easyocr numpy
21
21
  ### 기본 실행
22
22
 
23
23
  ```bash
24
- python main.py
24
+ ytcollector
25
25
  ```
26
26
 
27
27
  기본값: 얼굴 카테고리 5개, 최대 3분 영상
@@ -33,7 +33,7 @@ python main.py
33
33
  | `-c`, `--categories` | 수집할 카테고리 | `face` |
34
34
  | `-n`, `--count` | 카테고리당 다운로드 수 | `5` |
35
35
  | `-d`, `--duration` | 최대 영상 길이(분) | `3` |
36
- | `-o`, `--output` | 저장 경로 | `~/Downloads/youtube_collection` |
36
+ | `-o`, `--output` | 저장 경로 | `.` (현재 폴더) |
37
37
  | `--fast` | 고속 모드 (병렬 다운로드) | 비활성화 |
38
38
  | `-w`, `--workers` | 병렬 다운로드 수 | `3` |
39
39
  | `--proxy` | 프록시 주소 | 없음 |
@@ -53,45 +53,45 @@ python main.py
53
53
 
54
54
  ```bash
55
55
  # 얼굴 영상 10개 수집
56
- python main.py -c face -n 10
56
+ ytcollector -c face -n 10
57
57
 
58
58
  # 번호판 영상 수집 (최대 5분)
59
- python main.py -c license_plate -d 5
59
+ ytcollector -c license_plate -d 5
60
60
 
61
61
  # 타투 영상 수집
62
- python main.py -c tattoo -n 5
62
+ ytcollector -c tattoo -n 5
63
63
  ```
64
64
 
65
65
  ### 여러 카테고리
66
66
 
67
67
  ```bash
68
68
  # 얼굴과 텍스트 각 10개씩
69
- python main.py -c face text -n 10
69
+ ytcollector -c face text -n 10
70
70
 
71
71
  # 모든 카테고리 수집
72
- python main.py -c face license_plate tattoo text -n 5
72
+ ytcollector -c face license_plate tattoo text -n 5
73
73
  ```
74
74
 
75
75
  ### 고속 모드
76
76
 
77
77
  ```bash
78
78
  # 병렬 다운로드 (기본 3개 동시)
79
- python main.py -c face -n 10 --fast
79
+ ytcollector -c face -n 10 --fast
80
80
 
81
81
  # 5개 동시 다운로드
82
- python main.py -c face -n 10 --fast -w 5
82
+ ytcollector -c face -n 10 --fast -w 5
83
83
  ```
84
84
 
85
85
  ### 저장 경로 지정
86
86
 
87
87
  ```bash
88
- python main.py -c face -o /path/to/save
88
+ ytcollector -c face -o /path/to/save
89
89
  ```
90
90
 
91
91
  ### 프록시 사용
92
92
 
93
93
  ```bash
94
- python main.py -c face --proxy http://proxy.server:8080
94
+ ytcollector -c face --proxy http://proxy.server:8080
95
95
  ```
96
96
 
97
97
  ## SBS Dataset 구축 (URL 리스트 기반)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ytcollector"
7
- version = "1.0.9"
7
+ version = "1.1.1"
8
8
  description = "YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,4 +1,5 @@
1
1
  import re
2
+ import threading
2
3
  from .config import LICENSE_PLATE_PATTERNS
3
4
 
4
5
  # 선택적 import
@@ -24,6 +25,8 @@ except ImportError:
24
25
  class VideoAnalyzer:
25
26
  """영상 분석 클래스 - 얼굴, 텍스트, 번호판, 타투 감지"""
26
27
 
28
+ _ocr_lock = threading.Lock()
29
+
27
30
  def __init__(self):
28
31
  self.ocr_reader = None
29
32
  self.face_cascade = None
@@ -33,10 +36,12 @@ class VideoAnalyzer:
33
36
  self.face_cascade = cv2.CascadeClassifier(cascade_path)
34
37
 
35
38
  def _init_ocr(self):
36
- """OCR 리더 초기화 (필요할 때만)"""
39
+ """OCR 리더 초기화 (필요할 때만, 스레드 안전)"""
37
40
  if EASYOCR_AVAILABLE and self.ocr_reader is None:
38
- print(" OCR 엔진 초기화 중...")
39
- self.ocr_reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
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)
40
45
 
41
46
  def extract_frames(self, video_path, num_frames=10):
42
47
  """영상에서 균등 간격으로 프레임 추출"""
@@ -75,7 +80,7 @@ class VideoAnalyzer:
75
80
  )
76
81
 
77
82
  def detect_text(self, frame):
78
- """EasyOCR로 텍스트 감지"""
83
+ """EasyOCR로 텍스트 감지 (스레드 안전)"""
79
84
  if not EASYOCR_AVAILABLE:
80
85
  return []
81
86
 
@@ -86,7 +91,8 @@ class VideoAnalyzer:
86
91
  scale = 640 / w
87
92
  frame = cv2.resize(frame, (640, int(h * scale)))
88
93
 
89
- results = self.ocr_reader.readtext(frame)
94
+ with self._ocr_lock:
95
+ results = self.ocr_reader.readtext(frame)
90
96
  return [r[1] for r in results if r[2] > 0.3]
91
97
  except:
92
98
  return []
@@ -151,38 +157,66 @@ class VideoAnalyzer:
151
157
  'license_plate': False,
152
158
  'tattoo': False,
153
159
  'face_count': 0,
154
- 'detected_texts': []
160
+ 'detected_texts': [],
161
+ 'first_detection_sec': None,
162
+ 'first_detection_ts': None
155
163
  }
156
164
 
157
165
  if not CV2_AVAILABLE:
158
166
  print(" ⚠ OpenCV 미설치")
159
167
  return results
160
168
 
161
- frames = self.extract_frames(video_path, num_frames=8)
162
- if not frames:
163
- print(" ⚠ 프레임 추출 실패")
169
+ # 영상 정보 가져오기
170
+ cap = cv2.VideoCapture(video_path)
171
+ if not cap.isOpened():
164
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()
165
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
+
166
181
  all_texts = []
167
182
  total_faces = 0
168
183
 
169
- for i, frame in enumerate(frames):
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
+
170
194
  # 얼굴
171
195
  faces = self.detect_faces(frame)
172
196
  if len(faces) > 0:
173
197
  results['face'] = True
174
198
  total_faces += len(faces)
199
+ detected_now = True
175
200
 
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)
201
+ # 텍스트
202
+ texts = self.detect_text(frame)
203
+ if texts:
204
+ results['text'] = True
205
+ all_texts.extend(texts)
206
+ detected_now = True
182
207
 
183
208
  # 타투
184
209
  if self.detect_tattoo(frame):
185
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()
186
220
 
187
221
  # 번호판 (텍스트에서)
188
222
  if all_texts:
@@ -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.1'
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()
@@ -55,6 +55,20 @@ CATEGORY_NAMES = {
55
55
  'text': '텍스트'
56
56
  }
57
57
 
58
+ # 카테고리별 제외 키워드 (제목에 포함 시 스킵)
59
+ BLACKLIST_KEYWORDS = {
60
+ 'tattoo': [
61
+ "두피 문신", "두피문신",
62
+ "눈썹 문신", "눈썹문신",
63
+ "입술 문신", "입술문신",
64
+ "틴트 입술",
65
+ "반영구", "SMP"
66
+ ],
67
+ 'face': [],
68
+ 'license_plate': [],
69
+ 'text': []
70
+ }
71
+
58
72
  # 번호판 정규식 패턴
59
73
  LICENSE_PLATE_PATTERNS = [
60
74
  r'\d{2,3}[가-힣]\d{4}',
@@ -0,0 +1,71 @@
1
+ import os
2
+ import subprocess
3
+ from yt_dlp import YoutubeDL
4
+ from .utils import clip_video, get_url_list, get_video_duration, timestamp_to_seconds
5
+
6
+ def download_videos(url_list, output_dir):
7
+ os.makedirs(output_dir, exist_ok=True)
8
+ for idx, item in enumerate(url_list, 1):
9
+ url = item['url']
10
+ task = item['task']
11
+ index_str = f"{idx:03d}"
12
+
13
+ existing_files = [f for f in os.listdir(output_dir) if f.startswith(f"{index_str}_")]
14
+ if existing_files:
15
+ print(f"[{index_str}] Skip: {existing_files[0]}")
16
+ continue
17
+
18
+ print(f"[{index_str}] Downloading: {url} ({task})")
19
+ try:
20
+ ydl_opts = {
21
+ 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
22
+ 'outtmpl': os.path.join(output_dir, f"{index_str}_{task}_%(title)s.%(ext)s"),
23
+ 'quiet': True,
24
+ 'no_warnings': True,
25
+ }
26
+ with YoutubeDL(ydl_opts) as ydl:
27
+ ydl.download([url])
28
+ except Exception as e:
29
+ print(f"[{index_str}] Failed: {e}")
30
+
31
+ def build_dataset(url_file, output_root="."):
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"))
34
+
35
+ urls = get_url_list(url_file)
36
+ if not urls:
37
+ print(f"Error: No valid data in {url_file}")
38
+ return
39
+
40
+ print(f"--- Step 1: Downloading {len(urls)} videos ---")
41
+ download_videos(urls, video_dir)
42
+
43
+ print(f"\n--- Step 2: Clipping videos ---")
44
+ os.makedirs(clip_dir, exist_ok=True)
45
+ for idx, item in enumerate(urls, 1):
46
+ index_str = f"{idx:03d}"
47
+ files = [f for f in os.listdir(video_dir) if f.startswith(f"{index_str}_")]
48
+ if not files: continue
49
+
50
+ input_file = os.path.join(video_dir, files[0])
51
+ output_file = os.path.join(clip_dir, files[0])
52
+
53
+ if os.path.exists(output_file): continue
54
+
55
+ print(f"[{index_str}] Clipping: {files[0]}")
56
+ center_sec = timestamp_to_seconds(item['timestamp'])
57
+ clip_video(input_file, output_file, center_sec)
58
+
59
+ print(f"\nDone! Clips saved in: {clip_dir}")
60
+
61
+ def main():
62
+ import argparse
63
+ parser = argparse.ArgumentParser(description='Build SBS Dataset from YouTube URL list')
64
+ parser.add_argument('file', help='Path to youtube_url.txt')
65
+ parser.add_argument('-o', '--output', default='.', help='Output root directory (default: .)')
66
+ args = parser.parse_args()
67
+
68
+ build_dataset(args.file, args.output)
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -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(" ✗ 실패")
@@ -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.1
4
4
  Summary: YouTube 콘텐츠 수집기 - 얼굴, 번호판, 타투, 텍스트 감지
5
5
  Author: YTCollector Team
6
6
  License: MIT
@@ -54,7 +54,7 @@ pip install opencv-python easyocr numpy
54
54
  ### 기본 실행
55
55
 
56
56
  ```bash
57
- python main.py
57
+ ytcollector
58
58
  ```
59
59
 
60
60
  기본값: 얼굴 카테고리 5개, 최대 3분 영상
@@ -66,7 +66,7 @@ python main.py
66
66
  | `-c`, `--categories` | 수집할 카테고리 | `face` |
67
67
  | `-n`, `--count` | 카테고리당 다운로드 수 | `5` |
68
68
  | `-d`, `--duration` | 최대 영상 길이(분) | `3` |
69
- | `-o`, `--output` | 저장 경로 | `~/Downloads/youtube_collection` |
69
+ | `-o`, `--output` | 저장 경로 | `.` (현재 폴더) |
70
70
  | `--fast` | 고속 모드 (병렬 다운로드) | 비활성화 |
71
71
  | `-w`, `--workers` | 병렬 다운로드 수 | `3` |
72
72
  | `--proxy` | 프록시 주소 | 없음 |
@@ -86,45 +86,45 @@ python main.py
86
86
 
87
87
  ```bash
88
88
  # 얼굴 영상 10개 수집
89
- python main.py -c face -n 10
89
+ ytcollector -c face -n 10
90
90
 
91
91
  # 번호판 영상 수집 (최대 5분)
92
- python main.py -c license_plate -d 5
92
+ ytcollector -c license_plate -d 5
93
93
 
94
94
  # 타투 영상 수집
95
- python main.py -c tattoo -n 5
95
+ ytcollector -c tattoo -n 5
96
96
  ```
97
97
 
98
98
  ### 여러 카테고리
99
99
 
100
100
  ```bash
101
101
  # 얼굴과 텍스트 각 10개씩
102
- python main.py -c face text -n 10
102
+ ytcollector -c face text -n 10
103
103
 
104
104
  # 모든 카테고리 수집
105
- python main.py -c face license_plate tattoo text -n 5
105
+ ytcollector -c face license_plate tattoo text -n 5
106
106
  ```
107
107
 
108
108
  ### 고속 모드
109
109
 
110
110
  ```bash
111
111
  # 병렬 다운로드 (기본 3개 동시)
112
- python main.py -c face -n 10 --fast
112
+ ytcollector -c face -n 10 --fast
113
113
 
114
114
  # 5개 동시 다운로드
115
- python main.py -c face -n 10 --fast -w 5
115
+ ytcollector -c face -n 10 --fast -w 5
116
116
  ```
117
117
 
118
118
  ### 저장 경로 지정
119
119
 
120
120
  ```bash
121
- python main.py -c face -o /path/to/save
121
+ ytcollector -c face -o /path/to/save
122
122
  ```
123
123
 
124
124
  ### 프록시 사용
125
125
 
126
126
  ```bash
127
- python main.py -c face --proxy http://proxy.server:8080
127
+ ytcollector -c face --proxy http://proxy.server:8080
128
128
  ```
129
129
 
130
130
  ## SBS Dataset 구축 (URL 리스트 기반)
@@ -6,6 +6,7 @@ ytcollector/cli.py
6
6
  ytcollector/config.py
7
7
  ytcollector/dataset_builder.py
8
8
  ytcollector/downloader.py
9
+ ytcollector/utils.py
9
10
  ytcollector.egg-info/PKG-INFO
10
11
  ytcollector.egg-info/SOURCES.txt
11
12
  ytcollector.egg-info/dependency_links.txt
@@ -1,136 +0,0 @@
1
- import os
2
- import subprocess
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
23
-
24
- def download_videos(url_list, output_dir):
25
- os.makedirs(output_dir, exist_ok=True)
26
- for idx, item in enumerate(url_list, 1):
27
- url = item['url']
28
- task = item['task']
29
- index_str = f"{idx:03d}"
30
-
31
- existing_files = [f for f in os.listdir(output_dir) if f.startswith(f"{index_str}_")]
32
- if existing_files:
33
- print(f"[{index_str}] Skip: {existing_files[0]}")
34
- continue
35
-
36
- print(f"[{index_str}] Downloading: {url} ({task})")
37
- try:
38
- ydl_opts = {
39
- 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
40
- 'outtmpl': os.path.join(output_dir, f"{index_str}_{task}_%(title)s.%(ext)s"),
41
- 'quiet': True,
42
- 'no_warnings': True,
43
- }
44
- with YoutubeDL(ydl_opts) as ydl:
45
- ydl.download([url])
46
- except Exception as e:
47
- print(f"[{index_str}] Failed: {e}")
48
-
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
- 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")
100
-
101
- urls = get_url_list(url_file)
102
- if not urls:
103
- print(f"Error: No valid data in {url_file}")
104
- return
105
-
106
- print(f"--- Step 1: Downloading {len(urls)} videos ---")
107
- download_videos(urls, video_dir)
108
-
109
- print(f"\n--- Step 2: Clipping videos ---")
110
- os.makedirs(clip_dir, exist_ok=True)
111
- for idx, item in enumerate(urls, 1):
112
- index_str = f"{idx:03d}"
113
- files = [f for f in os.listdir(video_dir) if f.startswith(f"{index_str}_")]
114
- if not files: continue
115
-
116
- input_file = os.path.join(video_dir, files[0])
117
- output_file = os.path.join(clip_dir, files[0])
118
-
119
- if os.path.exists(output_file): continue
120
-
121
- print(f"[{index_str}] Clipping: {files[0]}")
122
- clip_video(input_file, output_file, item['timestamp'])
123
-
124
- print(f"\nDone! Clips saved in: {os.path.abspath(clip_dir)}")
125
-
126
- def main():
127
- import argparse
128
- parser = argparse.ArgumentParser(description='Build SBS Dataset from YouTube URL list')
129
- parser.add_argument('file', help='Path to youtube_url.txt')
130
- parser.add_argument('-o', '--output', default='.', help='Output root directory (default: .)')
131
- args = parser.parse_args()
132
-
133
- build_dataset(args.file, args.output)
134
-
135
- if __name__ == "__main__":
136
- main()
File without changes