AliJabbarpour commited on
Commit
14a3f64
·
verified ·
1 Parent(s): d695033

Upload postprocess.py

Browse files
Files changed (1) hide show
  1. pipeline/postprocess.py +341 -0
pipeline/postprocess.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─── Post-Processing Engine v10 ────────────────────────────────────────────
2
+
3
+ MIN_DISPLAY = 1.5
4
+ MAX_DISPLAY = 7.0
5
+ MERGE_MAX_CHARS = 72
6
+ MAX_GAP = 1.5
7
+ MAX_LINE_CHARS = 60 # threshold برای split به دو subtitle جداگانه
8
+ WRAP_CHARS = 42 # استاندارد Netflix برای wrap به دو خط
9
+ CPS_LIMIT = 17 # حداکثر کاراکتر قابل‌خواندن در ثانیه - برای تشخیص جمله‌ی «به‌سرعت رد شده»
10
+
11
+ # کلمه‌های کوچیک و به‌هم‌چسبیده‌ای که نباید تنها و جدا از کلمه‌ی بعدشون
12
+ # ته یه segment (چه raw، چه بعد از split) بمونن.
13
+ # هم توی split_long_segment استفاده میشه، هم توی rebalance_boundaries.
14
+ WEAK_ENDINGS = {
15
+ 'in', 'my', 'your', 'his', 'her', 'our', 'their', 'its', 'the', 'a', 'an',
16
+ 'of', 'to', 'at', 'on', 'by', 'for', 'with', 'is', 'are', 'was', 'were',
17
+ 'and', 'or', 'but', 'so', 'if', 'that', 'this', 'we', 'you', 'he', 'she', 'they',
18
+ }
19
+
20
+
21
+ def format_timestamp(seconds: float) -> str:
22
+ ms = round((seconds % 1) * 1000)
23
+ s = int(seconds) % 60
24
+ m = int(seconds // 60) % 60
25
+ h = int(seconds // 3600)
26
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
27
+
28
+
29
+ def rebalance_boundaries(segments: list) -> list:
30
+ """
31
+ مشکلی که این تابع حل می‌کنه: خودِ Whisper بعضی‌وقتا وسط یه عبارت به‌هم‌چسبیده
32
+ (مثل «in my | opinion» یا «of this | video») مرز segment می‌ذاره — نه سر یه
33
+ مکث طبیعی. چون این دو تیکه معمولاً segment‌های RAW جدا از هم می‌مونن (مثلاً چون
34
+ مجموع مدت‌زمانشون از MAX_DISPLAY بیشتره و merge_segments نمی‌تونه merge‌شون کنه)،
35
+ این مرز اشتباه هیچ‌وقت به merge_segments یا split_long_segment نمی‌رسه که اصلاح بشه.
36
+
37
+ این تابع، قبل از هر پردازش دیگه‌ای، مرز بین segmentهای پشت‌سرهم رو چک می‌کنه:
38
+ اگه segment اول با یه یا چند کلمه‌ی «ضعیف» و بدون علامت نگارشی (in/my/this/your/...)
39
+ تموم بشه، اون کلمه(ها) با همون زمان‌بندی اصلی‌شون به ابتدای segment بعدی منتقل میشن.
40
+ فقط بین segmentهای هم‌گوینده (یا وقتی گوینده مشخص نیست) و با فاصله‌ی کوتاه انجام میشه.
41
+ """
42
+ segments = [dict(s, words=list(s.get("words", []))) for s in segments]
43
+
44
+ for i in range(len(segments) - 1):
45
+ cur = segments[i]
46
+ nxt = segments[i + 1]
47
+
48
+ cur_words = cur["words"]
49
+ nxt_words = nxt["words"]
50
+ if not cur_words or not nxt_words:
51
+ continue
52
+
53
+ # فقط بین segmentهای هم‌گوینده جابه‌جا کن (اگه گوینده مشخص نبود، اجازه بده)
54
+ if cur.get("speaker") is not None and nxt.get("speaker") is not None \
55
+ and cur.get("speaker") != nxt.get("speaker"):
56
+ continue
57
+
58
+ # فقط اگه فاصله کوتاهه، یعنی احتمالاً گفتار پیوسته‌ست نه یه مکث واقعی
59
+ gap = nxt_words[0]["start"] - cur_words[-1]["end"]
60
+ if gap > MAX_GAP:
61
+ continue
62
+
63
+ moved_any = False
64
+ while len(cur_words) > 1:
65
+ last = cur_words[-1]
66
+ stripped = last["word"].strip()
67
+ bare = stripped.strip(".,!?;:").lower()
68
+ has_punct = bool(stripped) and stripped[-1] in {'.', '!', '?', ',', ';', ':'}
69
+
70
+ if bare in WEAK_ENDINGS and not has_punct:
71
+ nxt_words.insert(0, cur_words.pop())
72
+ moved_any = True
73
+ else:
74
+ break
75
+
76
+ # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن —
77
+ # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن
78
+ if moved_any:
79
+ cur["text"] = " ".join(w["word"].strip() for w in cur_words)
80
+ cur["end"] = cur_words[-1]["end"]
81
+ nxt["text"] = " ".join(w["word"].strip() for w in nxt_words)
82
+ nxt["start"] = nxt_words[0]["start"]
83
+
84
+ return segments
85
+
86
+
87
+ def merge_segments(segments: list) -> list:
88
+ if not segments:
89
+ return []
90
+
91
+ merged = []
92
+ current = None
93
+
94
+ for seg in segments:
95
+ if current is None:
96
+ current = {
97
+ "text": seg["text"].strip(),
98
+ "start": seg["start"],
99
+ "end": seg["end"],
100
+ "words": list(seg.get("words", [])),
101
+ "speaker": seg.get("speaker"),
102
+ }
103
+ continue
104
+
105
+ gap = seg["start"] - current["end"]
106
+ combined_text = current["text"] + " " + seg["text"].strip()
107
+ combined_duration = seg["end"] - current["start"]
108
+ ends_sentence = current["text"].rstrip().endswith((".", "!", "?"))
109
+ current_duration = current["end"] - current["start"]
110
+ is_short = current_duration < 1.5
111
+ same_speaker = current.get("speaker") is None or current.get("speaker") == seg.get("speaker")
112
+ too_fast = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True
113
+ fits_dialogue = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS
114
+ already_dialogue = current.get("is_dialogue_block", False)
115
+
116
+ should_merge = (
117
+ not ends_sentence
118
+ and combined_duration <= MAX_DISPLAY
119
+ and same_speaker
120
+ and gap <= MAX_GAP
121
+ and (
122
+ is_short
123
+ or len(combined_text) <= MERGE_MAX_CHARS
124
+ )
125
+ )
126
+
127
+ should_pair_as_dialogue = (
128
+ not already_dialogue
129
+ and not same_speaker
130
+ and too_fast
131
+ and gap <= MAX_GAP
132
+ and fits_dialogue
133
+ )
134
+
135
+ if should_merge:
136
+ current["text"] = combined_text
137
+ current["end"] = seg["end"]
138
+ current["words"] += list(seg.get("words", []))
139
+ elif should_pair_as_dialogue:
140
+ current["text"] = f"- {current['text']}\n- {seg['text'].strip()}"
141
+ current["end"] = seg["end"]
142
+ current["words"] += list(seg.get("words", []))
143
+ current["is_dialogue_block"] = True
144
+ else:
145
+ merged.append(current)
146
+ current = {
147
+ "text": seg["text"].strip(),
148
+ "start": seg["start"],
149
+ "end": seg["end"],
150
+ "words": list(seg.get("words", [])),
151
+ "speaker": seg.get("speaker"),
152
+ }
153
+
154
+ if current:
155
+ merged.append(current)
156
+
157
+ return merged
158
+
159
+
160
+ def remove_duplicates(segments: list) -> list:
161
+ if not segments:
162
+ return []
163
+ result = [segments[0]]
164
+ for seg in segments[1:]:
165
+ if seg["text"].strip() != result[-1]["text"].strip():
166
+ result.append(seg)
167
+ return result
168
+
169
+
170
+ def fix_overlaps(segments: list) -> list:
171
+ fixed = []
172
+ for seg in segments:
173
+ seg = dict(seg)
174
+ if fixed and seg["start"] < fixed[-1]["end"]:
175
+ seg["start"] = fixed[-1]["end"] + 0.001
176
+ if seg["start"] >= seg["end"]:
177
+ seg["end"] = seg["start"] + MIN_DISPLAY
178
+ fixed.append(seg)
179
+ return fixed
180
+
181
+
182
+ def apply_min_display(segments: list) -> list:
183
+ result = []
184
+ for i, seg in enumerate(segments):
185
+ seg = dict(seg)
186
+ duration = seg["end"] - seg["start"]
187
+ if duration < MIN_DISPLAY:
188
+ if i + 1 < len(segments):
189
+ next_start = segments[i + 1]["start"]
190
+ max_end = next_start - 0.080
191
+ else:
192
+ max_end = seg["end"] + MAX_DISPLAY
193
+ new_end = seg["start"] + MIN_DISPLAY
194
+ seg["end"] = min(new_end, max(max_end, seg["end"]))
195
+ result.append(seg)
196
+ return result
197
+
198
+
199
+ def split_long_segment(seg: dict) -> list:
200
+ """
201
+ فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن.
202
+ اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه.
203
+ بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن.
204
+ """
205
+ if seg.get("is_dialogue_block"):
206
+ return [seg]
207
+
208
+ words = seg.get("words", [])
209
+ text = seg["text"].strip()
210
+
211
+ if len(text) <= MAX_LINE_CHARS or len(words) < 4:
212
+ return [seg]
213
+
214
+ punct_chars = {'.', '!', '?', ',', ';', ':'}
215
+ sentence_enders = {'.', '!', '?'}
216
+ min_part_chars = 10
217
+
218
+ full_text = text
219
+ mid_char = len(full_text) / 2
220
+
221
+ best_idx = -1
222
+ best_score = float('-inf')
223
+ char_pos = 0
224
+
225
+ for i, w in enumerate(words):
226
+ word_stripped = w["word"].strip()
227
+ this_word_end_char = char_pos + len(word_stripped)
228
+
229
+ if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1:
230
+ # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه
231
+ bare_word = word_stripped.strip(".,!?;:").lower()
232
+ part1_chars = this_word_end_char
233
+ part2_chars = len(full_text) - this_word_end_char
234
+
235
+ if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars:
236
+ dist_from_mid = abs(this_word_end_char - mid_char)
237
+ proximity_score = 1.0 - (dist_from_mid / len(full_text))
238
+ sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0
239
+ score = proximity_score + sentence_end_bonus
240
+
241
+ if score > best_score:
242
+ best_score = score
243
+ best_idx = i + 1
244
+
245
+ char_pos = this_word_end_char + 1
246
+
247
+ # هیچ نقطه‌ی شکستن مناسبی پیدا نشد → نشکن، wrap_text کافیه
248
+ if best_idx == -1:
249
+ return [seg]
250
+
251
+ words1 = words[:best_idx]
252
+ words2 = words[best_idx:]
253
+
254
+ if len(words1) < 2 or len(words2) < 2:
255
+ return [seg]
256
+
257
+ start2 = words2[0]["start"]
258
+ end1 = max(start2 - 0.080, words1[-1]["end"])
259
+
260
+ if end1 - words1[0]["start"] < MIN_DISPLAY:
261
+ end1 = words1[0]["start"] + MIN_DISPLAY
262
+ if end1 >= start2:
263
+ end1 = start2 - 0.040
264
+
265
+ seg1 = {
266
+ "text": " ".join(w["word"].strip() for w in words1),
267
+ "start": words1[0]["start"],
268
+ "end": end1,
269
+ "words": words1,
270
+ }
271
+ seg2 = {
272
+ "text": " ".join(w["word"].strip() for w in words2),
273
+ "start": start2,
274
+ "end": words2[-1]["end"],
275
+ "words": words2,
276
+ }
277
+
278
+ return split_long_segment(seg1) + split_long_segment(seg2)
279
+
280
+
281
+ def wrap_text(text: str) -> str:
282
+ """اگه متن بیشتر از WRAP_CHARS بود، به دو خط تقسیم کن.
283
+ اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون."""
284
+ if text.startswith("- ") and "\n" in text:
285
+ return text
286
+
287
+ if len(text) <= WRAP_CHARS:
288
+ return text
289
+
290
+ words = text.split()
291
+ best_break = -1
292
+ best_diff = float('inf')
293
+ current_len = 0
294
+
295
+ for i, word in enumerate(words):
296
+ current_len += len(word) + (1 if i > 0 else 0)
297
+ diff = abs(current_len - len(text) / 2)
298
+ if diff < best_diff:
299
+ best_diff = diff
300
+ best_break = i + 1
301
+
302
+ if best_break <= 0 or best_break >= len(words):
303
+ return text
304
+
305
+ line1 = " ".join(words[:best_break])
306
+ line2 = " ".join(words[best_break:])
307
+ return f"{line1}\n{line2}"
308
+
309
+
310
+ def build_srt(segments: list) -> str:
311
+ lines = []
312
+ index = 1
313
+ for seg in segments:
314
+ text = seg["text"].strip()
315
+ if not text:
316
+ continue
317
+ start_tc = format_timestamp(seg["start"])
318
+ end_tc = format_timestamp(seg["end"])
319
+ wrapped = wrap_text(text)
320
+ lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}")
321
+ index += 1
322
+ return "\n\n".join(lines) + "\n"
323
+
324
+
325
+ def process(segments: list) -> str:
326
+ if not segments:
327
+ return ""
328
+
329
+ segments = rebalance_boundaries(segments)
330
+ merged = merge_segments(segments)
331
+ merged = remove_duplicates(merged)
332
+ fixed = fix_overlaps(merged)
333
+
334
+ split = []
335
+ for seg in fixed:
336
+ split.extend(split_long_segment(seg))
337
+
338
+ split = fix_overlaps(split)
339
+ final = apply_min_display(split)
340
+
341
+ return build_srt(final)