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.
- {ytcollector-1.0.9 → ytcollector-1.1.1}/PKG-INFO +12 -12
- {ytcollector-1.0.9 → ytcollector-1.1.1}/README.md +11 -11
- {ytcollector-1.0.9 → ytcollector-1.1.1}/pyproject.toml +1 -1
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector/analyzer.py +50 -16
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector/cli.py +4 -4
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector/config.py +14 -0
- ytcollector-1.1.1/ytcollector/dataset_builder.py +71 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector/downloader.py +63 -66
- ytcollector-1.1.1/ytcollector/utils.py +126 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/PKG-INFO +12 -12
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/SOURCES.txt +1 -0
- ytcollector-1.0.9/ytcollector/dataset_builder.py +0 -136
- {ytcollector-1.0.9 → ytcollector-1.1.1}/setup.cfg +0 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector/__init__.py +0 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/dependency_links.txt +0 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/entry_points.txt +0 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/requires.txt +0 -0
- {ytcollector-1.0.9 → ytcollector-1.1.1}/ytcollector.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ytcollector
|
|
3
|
-
Version: 1.
|
|
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
|
-
|
|
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` | 저장 경로 |
|
|
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
|
-
|
|
89
|
+
ytcollector -c face -n 10
|
|
90
90
|
|
|
91
91
|
# 번호판 영상 수집 (최대 5분)
|
|
92
|
-
|
|
92
|
+
ytcollector -c license_plate -d 5
|
|
93
93
|
|
|
94
94
|
# 타투 영상 수집
|
|
95
|
-
|
|
95
|
+
ytcollector -c tattoo -n 5
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### 여러 카테고리
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
101
|
# 얼굴과 텍스트 각 10개씩
|
|
102
|
-
|
|
102
|
+
ytcollector -c face text -n 10
|
|
103
103
|
|
|
104
104
|
# 모든 카테고리 수집
|
|
105
|
-
|
|
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
|
-
|
|
112
|
+
ytcollector -c face -n 10 --fast
|
|
113
113
|
|
|
114
114
|
# 5개 동시 다운로드
|
|
115
|
-
|
|
115
|
+
ytcollector -c face -n 10 --fast -w 5
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
### 저장 경로 지정
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
|
|
121
|
+
ytcollector -c face -o /path/to/save
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
### 프록시 사용
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
|
|
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
|
-
|
|
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` | 저장 경로 |
|
|
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
|
-
|
|
56
|
+
ytcollector -c face -n 10
|
|
57
57
|
|
|
58
58
|
# 번호판 영상 수집 (최대 5분)
|
|
59
|
-
|
|
59
|
+
ytcollector -c license_plate -d 5
|
|
60
60
|
|
|
61
61
|
# 타투 영상 수집
|
|
62
|
-
|
|
62
|
+
ytcollector -c tattoo -n 5
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
### 여러 카테고리
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
# 얼굴과 텍스트 각 10개씩
|
|
69
|
-
|
|
69
|
+
ytcollector -c face text -n 10
|
|
70
70
|
|
|
71
71
|
# 모든 카테고리 수집
|
|
72
|
-
|
|
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
|
-
|
|
79
|
+
ytcollector -c face -n 10 --fast
|
|
80
80
|
|
|
81
81
|
# 5개 동시 다운로드
|
|
82
|
-
|
|
82
|
+
ytcollector -c face -n 10 --fast -w 5
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
### 저장 경로 지정
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
|
|
88
|
+
ytcollector -c face -o /path/to/save
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
### 프록시 사용
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
|
|
94
|
+
ytcollector -c face --proxy http://proxy.server:8080
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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=
|
|
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.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 =
|
|
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
|
-
|
|
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(" ✗ 실패")
|
|
@@ -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.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
|
-
|
|
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` | 저장 경로 |
|
|
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
|
-
|
|
89
|
+
ytcollector -c face -n 10
|
|
90
90
|
|
|
91
91
|
# 번호판 영상 수집 (최대 5분)
|
|
92
|
-
|
|
92
|
+
ytcollector -c license_plate -d 5
|
|
93
93
|
|
|
94
94
|
# 타투 영상 수집
|
|
95
|
-
|
|
95
|
+
ytcollector -c tattoo -n 5
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### 여러 카테고리
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
101
|
# 얼굴과 텍스트 각 10개씩
|
|
102
|
-
|
|
102
|
+
ytcollector -c face text -n 10
|
|
103
103
|
|
|
104
104
|
# 모든 카테고리 수집
|
|
105
|
-
|
|
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
|
-
|
|
112
|
+
ytcollector -c face -n 10 --fast
|
|
113
113
|
|
|
114
114
|
# 5개 동시 다운로드
|
|
115
|
-
|
|
115
|
+
ytcollector -c face -n 10 --fast -w 5
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
### 저장 경로 지정
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
|
|
121
|
+
ytcollector -c face -o /path/to/save
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
### 프록시 사용
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
|
|
127
|
+
ytcollector -c face --proxy http://proxy.server:8080
|
|
128
128
|
```
|
|
129
129
|
|
|
130
130
|
## SBS Dataset 구축 (URL 리스트 기반)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|