AliJabbarpour commited on
Commit
4e92ebd
·
verified ·
1 Parent(s): 0d3ffcc

Upload postprocess.py

Browse files
Files changed (1) hide show
  1. pipeline/postprocess.py +435 -0
pipeline/postprocess.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ SENTENCE_ENDERS = {'.', '!', '?'}
30
+ LEAD_WORD_BUDGET = 9 # حداکثر تعداد کلمه‌ای که اجازه داریم از ابتدای segment بعدی به عقب بکشیم
31
+ LEAD_CHAR_BUDGET = 55 # حداکثر تعداد کاراکتر همون دنباله
32
+
33
+
34
+ def rebalance_boundaries(segments: list) -> list:
35
+ """
36
+ مشکلی که این تابع حل می‌کنه: خودِ Whisper بعضی‌وقتا وسط یه عبارت به‌هم‌چسبیده
37
+ (مثل «in my | opinion» یا «of this | video») مرز segment می‌ذاره — نه سر یه
38
+ مکث طبیعی. چون این دو تیکه معمولاً segment‌های RAW جدا از هم می‌مونن (مثلاً چون
39
+ مجموع مدت‌زمانشون از MAX_DISPLAY بیشتره و merge_segments نمی‌تونه merge‌شون کنه)،
40
+ این مرز اشتباه هیچ‌وقت به merge_segments یا split_long_segment نمی‌رسه که اصلاح بشه.
41
+
42
+ این تابع، قبل از هر پردازش دیگه‌ای، مرز بین segmentهای پشت‌سرهم رو از دو جهت چک می‌کنه:
43
+
44
+ جهتِ ۱ (رو به جلو): اگه segment اول با یه یا چند کلمه‌ی «ضعیف» و بدون علامت نگارشی
45
+ (in/my/this/your/...) تموم بشه، اون کلمه(ها) به ابتدای segment بعدی منتقل میشن.
46
+ مثال: «...building. In my» + «opinion, ...» → «...building.» + «In my opinion, ...»
47
+
48
+ جهتِ ۲ (رو به عقب): اگه segment اول هنوز به یه پایانِ جمله‌ی واقعی (./!/؟) نرسیده باشه،
49
+ و ابتدای segment بعدی یه دنباله‌ی کوتاهه (حداکثر چند کلمه) که زود به یه پایانِ جمله‌ی
50
+ واقعی می‌رسه، اون دنباله به آخر segment اول منتقل میشه — چون یعنی داره همون جمله‌ی
51
+ ناتمومِ قبلی رو کامل می‌کنه، نه اینکه جمله‌ی جدیدی باشه.
52
+ مثال: «...TVs and other» + «electronics. I am curious...» → «...TVs and other electronics.» + «I am curious...»
53
+
54
+ هر دو جهت فقط بین segmentهای هم‌گوینده (یا وقتی گوینده مشخص نیست) و با فاصله‌ی کوتاه انجام میشن.
55
+ """
56
+ segments = [dict(s, words=list(s.get("words", []))) for s in segments]
57
+
58
+ for i in range(len(segments) - 1):
59
+ cur = segments[i]
60
+ nxt = segments[i + 1]
61
+
62
+ cur_words = cur["words"]
63
+ nxt_words = nxt["words"]
64
+ if not cur_words or not nxt_words:
65
+ continue
66
+
67
+ # فقط بین segmentهای هم‌گوینده جابه‌جا کن (اگه گوینده مشخص نبود، اجازه بده)
68
+ if cur.get("speaker") is not None and nxt.get("speaker") is not None \
69
+ and cur.get("speaker") != nxt.get("speaker"):
70
+ continue
71
+
72
+ # فقط اگه فاصله کوتاهه، یعنی احتمالاً گفتار پیوسته‌ست نه یه مکث واقعی
73
+ gap = nxt_words[0]["start"] - cur_words[-1]["end"]
74
+ if gap > MAX_GAP:
75
+ continue
76
+
77
+ moved_any = False
78
+
79
+ # جهتِ ۱: کلمه‌ی ضعیفِ آخرِ cur رو به جلو بفرست
80
+ while len(cur_words) > 1:
81
+ last = cur_words[-1]
82
+ stripped = last["word"].strip()
83
+ bare = stripped.strip(".,!?;:").lower()
84
+ has_punct = bool(stripped) and stripped[-1] in {'.', '!', '?', ',', ';', ':'}
85
+
86
+ if bare in WEAK_ENDINGS and not has_punct:
87
+ nxt_words.insert(0, cur_words.pop())
88
+ moved_any = True
89
+ else:
90
+ break
91
+
92
+ # جهتِ ۲: اگه cur هنوز به یه پایانِ جمله‌ی واقعی نرسیده، دنباله‌ی کوتاهِ ابتدای nxt رو
93
+ # (اگه زود به یه پایانِ جمله‌ی واقعی می‌رسه) به عقب بکش
94
+ cur_last = cur_words[-1]["word"].strip()
95
+ cur_ends_sentence = bool(cur_last) and cur_last[-1] in SENTENCE_ENDERS
96
+
97
+ if not cur_ends_sentence and len(nxt_words) > 1:
98
+ lead = []
99
+ lead_chars = 0
100
+ found_end = False
101
+ for w in nxt_words:
102
+ if len(lead) >= LEAD_WORD_BUDGET or lead_chars >= LEAD_CHAR_BUDGET:
103
+ break
104
+ stripped = w["word"].strip()
105
+ lead.append(w)
106
+ lead_chars += len(stripped) + 1
107
+ if stripped and stripped[-1] in SENTENCE_ENDERS:
108
+ found_end = True
109
+ break
110
+
111
+ # فقط وقتی که به یه پایانِ جمله‌ی واقعی رسیدیم (نه چون بودجه‌مون تموم شد)،
112
+ # و حداقل یه کلمه توی nxt باقی می‌مونه، جابه‌جایی رو انجام بده
113
+ if found_end and len(lead) < len(nxt_words):
114
+ for _ in lead:
115
+ cur_words.append(nxt_words.pop(0))
116
+ moved_any = True
117
+
118
+ # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن —
119
+ # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن
120
+ if moved_any:
121
+ cur["text"] = " ".join(w["word"].strip() for w in cur_words)
122
+ cur["end"] = cur_words[-1]["end"]
123
+ nxt["text"] = " ".join(w["word"].strip() for w in nxt_words)
124
+ nxt["start"] = nxt_words[0]["start"]
125
+
126
+ return segments
127
+
128
+
129
+ def merge_segments(segments: list) -> list:
130
+ if not segments:
131
+ return []
132
+
133
+ merged = []
134
+ current = None
135
+
136
+ for seg in segments:
137
+ if current is None:
138
+ current = {
139
+ "text": seg["text"].strip(),
140
+ "start": seg["start"],
141
+ "end": seg["end"],
142
+ "words": list(seg.get("words", [])),
143
+ "speaker": seg.get("speaker"),
144
+ }
145
+ continue
146
+
147
+ gap = seg["start"] - current["end"]
148
+ combined_text = current["text"] + " " + seg["text"].strip()
149
+ combined_duration = seg["end"] - current["start"]
150
+ ends_sentence = current["text"].rstrip().endswith((".", "!", "?"))
151
+ current_duration = current["end"] - current["start"]
152
+ is_short = current_duration < 1.5
153
+ same_speaker = current.get("speaker") is None or current.get("speaker") == seg.get("speaker")
154
+ too_fast = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True
155
+ fits_dialogue = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS
156
+ already_dialogue = current.get("is_dialogue_block", False)
157
+
158
+ should_merge = (
159
+ not ends_sentence
160
+ and combined_duration <= MAX_DISPLAY
161
+ and same_speaker
162
+ and gap <= MAX_GAP
163
+ and (
164
+ is_short
165
+ or len(combined_text) <= MERGE_MAX_CHARS
166
+ )
167
+ )
168
+
169
+ should_pair_as_dialogue = (
170
+ not already_dialogue
171
+ and not same_speaker
172
+ and too_fast
173
+ and gap <= MAX_GAP
174
+ and fits_dialogue
175
+ )
176
+
177
+ if should_merge:
178
+ current["text"] = combined_text
179
+ current["end"] = seg["end"]
180
+ current["words"] += list(seg.get("words", []))
181
+ elif should_pair_as_dialogue:
182
+ current["text"] = f"- {current['text']}\n- {seg['text'].strip()}"
183
+ current["end"] = seg["end"]
184
+ current["words"] += list(seg.get("words", []))
185
+ current["is_dialogue_block"] = True
186
+ else:
187
+ merged.append(current)
188
+ current = {
189
+ "text": seg["text"].strip(),
190
+ "start": seg["start"],
191
+ "end": seg["end"],
192
+ "words": list(seg.get("words", [])),
193
+ "speaker": seg.get("speaker"),
194
+ }
195
+
196
+ if current:
197
+ merged.append(current)
198
+
199
+ return merged
200
+
201
+
202
+ def remove_duplicates(segments: list) -> list:
203
+ if not segments:
204
+ return []
205
+ result = [segments[0]]
206
+ for seg in segments[1:]:
207
+ if seg["text"].strip() != result[-1]["text"].strip():
208
+ result.append(seg)
209
+ return result
210
+
211
+
212
+ def fix_overlaps(segments: list) -> list:
213
+ fixed = []
214
+ for seg in segments:
215
+ seg = dict(seg)
216
+ if fixed and seg["start"] < fixed[-1]["end"]:
217
+ seg["start"] = fixed[-1]["end"] + 0.001
218
+ if seg["start"] >= seg["end"]:
219
+ seg["end"] = seg["start"] + MIN_DISPLAY
220
+ fixed.append(seg)
221
+ return fixed
222
+
223
+
224
+ def apply_min_display(segments: list) -> list:
225
+ result = []
226
+ for i, seg in enumerate(segments):
227
+ seg = dict(seg)
228
+ duration = seg["end"] - seg["start"]
229
+ if duration < MIN_DISPLAY:
230
+ if i + 1 < len(segments):
231
+ next_start = segments[i + 1]["start"]
232
+ max_end = next_start - 0.080
233
+ else:
234
+ max_end = seg["end"] + MAX_DISPLAY
235
+ new_end = seg["start"] + MIN_DISPLAY
236
+ seg["end"] = min(new_end, max(max_end, seg["end"]))
237
+ result.append(seg)
238
+ return result
239
+
240
+
241
+ def split_long_segment(seg: dict) -> list:
242
+ """
243
+ فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن.
244
+ اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه.
245
+ بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن.
246
+ """
247
+ if seg.get("is_dialogue_block"):
248
+ return [seg]
249
+
250
+ words = seg.get("words", [])
251
+ text = seg["text"].strip()
252
+
253
+ if len(text) <= MAX_LINE_CHARS or len(words) < 4:
254
+ return [seg]
255
+
256
+ punct_chars = {'.', '!', '?', ',', ';', ':'}
257
+ sentence_enders = {'.', '!', '?'}
258
+ min_part_chars = 10
259
+
260
+ full_text = text
261
+ mid_char = len(full_text) / 2
262
+
263
+ best_idx = -1
264
+ best_score = float('-inf')
265
+ char_pos = 0
266
+
267
+ for i, w in enumerate(words):
268
+ word_stripped = w["word"].strip()
269
+ this_word_end_char = char_pos + len(word_stripped)
270
+
271
+ if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1:
272
+ # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه
273
+ bare_word = word_stripped.strip(".,!?;:").lower()
274
+ part1_chars = this_word_end_char
275
+ part2_chars = len(full_text) - this_word_end_char
276
+
277
+ if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars:
278
+ dist_from_mid = abs(this_word_end_char - mid_char)
279
+ proximity_score = 1.0 - (dist_from_mid / len(full_text))
280
+ sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0
281
+ score = proximity_score + sentence_end_bonus
282
+
283
+ if score > best_score:
284
+ best_score = score
285
+ best_idx = i + 1
286
+
287
+ char_pos = this_word_end_char + 1
288
+
289
+ # هیچ نقطه‌ی شکستن مناسبی پیدا نشد → نشکن، wrap_text کافیه
290
+ if best_idx == -1:
291
+ return [seg]
292
+
293
+ words1 = words[:best_idx]
294
+ words2 = words[best_idx:]
295
+
296
+ if len(words1) < 2 or len(words2) < 2:
297
+ return [seg]
298
+
299
+ start2 = words2[0]["start"]
300
+ end1 = max(start2 - 0.080, words1[-1]["end"])
301
+
302
+ if end1 - words1[0]["start"] < MIN_DISPLAY:
303
+ end1 = words1[0]["start"] + MIN_DISPLAY
304
+ if end1 >= start2:
305
+ end1 = start2 - 0.040
306
+
307
+ seg1 = {
308
+ "text": " ".join(w["word"].strip() for w in words1),
309
+ "start": words1[0]["start"],
310
+ "end": end1,
311
+ "words": words1,
312
+ }
313
+ seg2 = {
314
+ "text": " ".join(w["word"].strip() for w in words2),
315
+ "start": start2,
316
+ "end": words2[-1]["end"],
317
+ "words": words2,
318
+ }
319
+
320
+ return split_long_segment(seg1) + split_long_segment(seg2)
321
+
322
+
323
+ def merge_short_fragments(segments: list) -> list:
324
+ """
325
+ بعد از شکستنِ جمله‌های بلند، بعضی‌وقتا یه تیکه‌ی خیلی کوچیک باقی می‌مونه که مدت‌زمان
326
+ نمایشش زیر MIN_DISPLAY (۱.۵ ثانیه) میشه — نه چون واقعاً یه عبارت مستقله، بلکه چون
327
+ نقطه‌ی شکستن (punctuation) دقیقاً همون‌جا بوده. این تابع، به‌صورت یه پاسِ نهایی،
328
+ هر تیکه‌ی زیر ۱.۵ ثانیه رو در صورت امکان با همسایه‌ش (قبلی یا بعدی) یکی می‌کنه —
329
+ فقط وقتی که ادغام از سقف‌های نمایشی (MAX_DISPLAY و طول قابل‌قبول برای دو خط) رد نشه،
330
+ از رو یه پایانِ جمله‌ی واقعی (./!/؟) عبور نکنه، و از همه مهم‌تر: فاصله‌ی زمانیِ واقعی
331
+ بین دو تیکه از MAX_GAP بیشتر نباشه — یعنی اگه یه کلمه یا عبارتِ کوتاه چند ثانیه بعد از
332
+ تیکه‌ی قبلی پخش میشه (یه مکثِ واقعی وسطشونه)، حتی با اینکه کوتاهه، مستقل می‌مونه و به
333
+ تیکه‌ی قبلی نمی‌چسبه.
334
+
335
+ نکته: این یه قانونِ کاملاً مکانیکیه (فقط بر اساس مدت‌زمان)، نه یه تشخیص معنایی از
336
+ اینکه عبارت «کامل» هست یا نه — پس ممکنه عبارت‌های کوتاهِ ولی کاملی (مثل «For example,»)
337
+ رو هم با همسایه‌شون ادغام کنه.
338
+ """
339
+ if not segments:
340
+ return []
341
+
342
+ MAX_MERGED_CHARS = WRAP_CHARS * 2 + 10 # فضای قابل‌قبول برای یه cue دو‌خطی
343
+
344
+ segments = [dict(s) for s in segments]
345
+ result = [segments[0]]
346
+
347
+ for seg in segments[1:]:
348
+ prev = result[-1]
349
+ prev_duration = prev["end"] - prev["start"]
350
+ seg_duration = seg["end"] - seg["start"]
351
+
352
+ prev_ends_sentence = prev["text"].rstrip().endswith((".", "!", "?"))
353
+ gap = seg["start"] - prev["end"]
354
+
355
+ should_merge = False
356
+ if not prev_ends_sentence and gap <= MAX_GAP and (seg_duration < MIN_DISPLAY or prev_duration < MIN_DISPLAY):
357
+ combined_duration = seg["end"] - prev["start"]
358
+ combined_text = prev["text"].rstrip() + " " + seg["text"].lstrip()
359
+ if combined_duration <= MAX_DISPLAY and len(combined_text) <= MAX_MERGED_CHARS:
360
+ should_merge = True
361
+
362
+ if should_merge:
363
+ prev["text"] = prev["text"].rstrip() + " " + seg["text"].lstrip()
364
+ prev["end"] = seg["end"]
365
+ if "words" in prev or "words" in seg:
366
+ prev["words"] = list(prev.get("words", [])) + list(seg.get("words", []))
367
+ else:
368
+ result.append(dict(seg))
369
+
370
+ return result
371
+
372
+
373
+ def wrap_text(text: str) -> str:
374
+ """اگه متن بیشتر از WRAP_CHARS بود، به دو خط تقسیم کن.
375
+ اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون."""
376
+ if text.startswith("- ") and "\n" in text:
377
+ return text
378
+
379
+ if len(text) <= WRAP_CHARS:
380
+ return text
381
+
382
+ words = text.split()
383
+ best_break = -1
384
+ best_diff = float('inf')
385
+ current_len = 0
386
+
387
+ for i, word in enumerate(words):
388
+ current_len += len(word) + (1 if i > 0 else 0)
389
+ diff = abs(current_len - len(text) / 2)
390
+ if diff < best_diff:
391
+ best_diff = diff
392
+ best_break = i + 1
393
+
394
+ if best_break <= 0 or best_break >= len(words):
395
+ return text
396
+
397
+ line1 = " ".join(words[:best_break])
398
+ line2 = " ".join(words[best_break:])
399
+ return f"{line1}\n{line2}"
400
+
401
+
402
+ def build_srt(segments: list) -> str:
403
+ lines = []
404
+ index = 1
405
+ for seg in segments:
406
+ text = seg["text"].strip()
407
+ if not text:
408
+ continue
409
+ start_tc = format_timestamp(seg["start"])
410
+ end_tc = format_timestamp(seg["end"])
411
+ wrapped = wrap_text(text)
412
+ lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}")
413
+ index += 1
414
+ return "\n\n".join(lines) + "\n"
415
+
416
+
417
+ def process(segments: list) -> str:
418
+ if not segments:
419
+ return ""
420
+
421
+ segments = rebalance_boundaries(segments)
422
+ merged = merge_segments(segments)
423
+ merged = remove_duplicates(merged)
424
+ fixed = fix_overlaps(merged)
425
+
426
+ split = []
427
+ for seg in fixed:
428
+ split.extend(split_long_segment(seg))
429
+
430
+ split = fix_overlaps(split)
431
+ split = merge_short_fragments(split)
432
+ split = fix_overlaps(split)
433
+ final = apply_min_display(split)
434
+
435
+ return build_srt(final)