From d155b8584bd1fb10be85af2e2ec6d05b5c2adc8c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 18:06:47 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=81=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=20(=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D1=8C),=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B9;?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=D0=B0=20rt-topic-?= =?UTF-8?q?6870900?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- data/samples/rt-topic-6870900.html | 903 +++++++++++++++++++++++++++++ docs/architecture.md | 20 + src/predict.py | 9 +- src/release.py | 38 +- src/segmenter.py | 215 +++++++ tests/e2e_segmenter.py | 62 ++ tests/run_tests.py | 14 + 7 files changed, 1257 insertions(+), 4 deletions(-) create mode 100644 data/samples/rt-topic-6870900.html create mode 100644 src/segmenter.py create mode 100644 tests/e2e_segmenter.py diff --git a/data/samples/rt-topic-6870900.html b/data/samples/rt-topic-6870900.html new file mode 100644 index 0000000..fa02999 --- /dev/null +++ b/data/samples/rt-topic-6870900.html @@ -0,0 +1,903 @@ + + + + + + +Супер Марио: Галактическое кино / The Super Mario Galaxy Movie (Аарон Хорват / Aaron Horvath) [2026, Япония, США, мультфильм, фэнтези, WEB-DL 2160p, HDR10, HDR10+, Dolby Vision] [Hybrid] Dub (Продубляж) + 2x MVO + Dub (Ukr) + Original (Eng) + Sub (Rus, Ukr, Eng) :: RuTracker.org + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + +
+ + + + + +
+ + + + + + + +
+
+ + + + + + + + + + + + + + + + + + +
+

+ Супер Марио: Галактическое кино / The Super Mario Galaxy Movie (Аарон Хорват / Aaron Horvath) [2026, Япония, США, мультфильм, фэнтези, WEB-DL 2160p, HDR10, HDR10+, Dolby Vision] [Hybrid] Dub (Продубляж) + 2x MVO + Dub (Ukr) + Original (Eng) + Sub (Rus, Ukr, Eng) +

+
+ Страницы:  1 +
+ + + + + + +
+ Ответить +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ + + +

Andron1975

+ + +

Стаж: 16 лет 10 месяцев

Сообщений: 868

+ +
+ +
+

+ + Andron1975 · + + 15-Июн-26 16:41 + + (4 месяца 11 дней назад) +

+ +
    + + + +
+
+
+ +
+
+ picСупер Марио: Галактическое кино / The Super Mario Galaxy Movie«New galaxies. New friends. Yoshi joins the adventure.»

picСтрана: Япония, США
+Студия: Universal Pictures, Nintendo, Illumination Entertainment
+Жанр: мультфильм, фэнтези, комедия, приключения, семейный
+Год выпуска: 2026
+Продолжительность: 01:38:12
Перевод 1: Профессиональный (дублированный) Продубляж
+Перевод 2: Профессиональный (многоголосый закадровый) AlexFilm
+Перевод 3: Любительский (многоголосый закадровый) Дубляжная
+Перевод 4: Профессиональный (дублированный) Le Doyen
+Субтитры: Русские (Forced), Украинские (Forced, 2x Full, SDH), Английские (Full, SDH)
+Оригинальная аудиодорожка: Aнглийский
Режиссер: Аарон Хорват, Михаэль Еленик, Пьер Ледюк, Фабьен Полак / Aaron Horvath, Michael Jelenic, Pierre Leduc, Fabien Polack
+В ролях: Крис Пратт, Дональд Гловер, Бри Ларсон, Вирджиния Даре Еленик, Бенни Сэфди, Кевин Майкл Ричардсон, Чарли Дэй, Кигэн-Майкл Ки, Аня Тейлор-Джой, Эрик Бауза, Джек Блэк, Роксана Ортега, Луис Гусман, Исса Рэй, Глен Пауэлл
Описание: Боузер-младший похищает принцессу Розалину и пытается вернуть отца, создав пушку, способную уничтожать вселенные. Марио, Луиджи и найденный ими дракон Йоши пытаются перевоспитать поверженного Боузера, но вскоре того освобождает сын. Водопроводчик объединяет силы со своими друзьями, чтобы помешать планам Боузера-младшего по достижению вселенского господства.
*
+
+
Дорожка №2 получена путём наложения чистых голосов на центр декодированного оригинала. Работа со звуком - Andron1975 (DVT).
+Спасибо студии AlexFilm за предоставленные голоса.
+За дубляж, Дубляжную и forced субтитры спасибо Wilmots.
+За украинский дубляж и субтитры спасибо Utopia.
+
+
pic pic pic pic
Тип релиза: WEB-DL 2160p Dolby Vision
+Контейнер: MKV
+Видео: MPEG-H HEVC Video / 24,8 Mbps / 3840x2160 / 23.976 fps / 16:9 / Main 10@L5@High / 10 bits / Dolby Vision - Profile 8 / HDR10+ / HDR10 /
+Аудио 1: Russian E-AC3 / 5.1 / 48 kHz / 768 kbps |Дубляж, Продубляж|
+Аудио 2: Russian E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |MVO, AlexFilm|
+Аудио 3: Russian AC3 / 5.1 / 48 kHz / 640 kbps |MVO, Дубляжная|
+Аудио 4: Ukrainian E-AC3 / 5.1 / 48 kHz / 256 kbps |Дубляж, Le Doyen|
+Аудио 5: English E-AC3+Atmos / 5.1 / 48 kHz / 768 kbps |Original|
+Формат субтитров: softsub (SRT)
+Реклама: +
+
Отсутствует
+
+
Релиз от: pic
+
+
MediaInfo
+
+
+
Код:
+
General
+Unique ID                                : 186297677512139737153141175159574550962 (0x8C27A0A85D62281951A7219FE5F0D9B2)
+Complete name                            : Z:\TORRENTS\The.Super.Mario.Galaxy.Movie.2026.2160p.MA.WEB-DL.DDP5.1.Atmos.DV.HDRP-DVT.mkv
+Format                                   : Matroska
+Format version                           : Version 4
+File size                                : 19.2 GiB
+Duration                                 : 1 h 38 min
+Overall bit rate                         : 28.0 Mb/s
+Frame rate                               : 23.976 FPS
+Movie name                               : The Super Mario Galaxy Movie (2026) - Release by DVT
+Encoded date                             : 2026-06-14 14:59:12 UTC
+Writing application                      : mkvmerge v99.0 ('Buka') 64-bit
+Writing library                          : libebml v1.4.5 + libmatroska v1.7.1
+Cover                                    : Yes
+Attachments                              : cover.jpg
Video
+ID                                       : 1
+Format                                   : HEVC
+Format/Info                              : High Efficiency Video Coding
+Format profile                           : Main 10@L5@High
+HDR format                               : Dolby Vision, Version 1.0, Profile 8.1, dvhe.08.06, BL+RPU, no metadata compression, HDR10 compatible / SMPTE ST 2094 App 4, Version HDR10+ Profile B, HDR10+ Profile B compatible
+Codec ID                                 : V_MPEGH/ISO/HEVC
+Duration                                 : 1 h 38 min
+Bit rate                                 : 24.8 Mb/s
+Width                                    : 3 840 pixels
+Height                                   : 2 160 pixels
+Display aspect ratio                     : 16:9
+Frame rate mode                          : Constant
+Frame rate                               : 23.976 (24000/1001) FPS
+Color space                              : YUV
+Chroma subsampling                       : 4:2:0 (Type 2)
+Bit depth                                : 10 bits
+Bits/(Pixel*Frame)                       : 0.125
+Stream size                              : 17.0 GiB (89%)
+Title                                    : The Super Mario Galaxy Movie (2026)
+Language                                 : English
+Default                                  : Yes
+Forced                                   : No
+Color range                              : Limited
+Color primaries                          : BT.2020
+Transfer characteristics                 : PQ
+Matrix coefficients                      : BT.2020 non-constant
+Mastering display color primaries        : BT.2020
+Mastering display luminance              : min: 0.0010 cd/m2, max: 1000 cd/m2
+Maximum Content Light Level              : 1000 cd/m2
+Maximum Frame-Average Light Level        : 475 cd/m2
Audio #1
+ID                                       : 2
+Format                                   : E-AC-3
+Format/Info                              : Enhanced AC-3
+Commercial name                          : Dolby Digital Plus
+Codec ID                                 : A_EAC3
+Duration                                 : 1 h 38 min
+Bit rate mode                            : Constant
+Bit rate                                 : 768 kb/s
+Channel(s)                               : 6 channels
+Channel layout                           : L R C LFE Ls Rs
+Sampling rate                            : 48.0 kHz
+Frame rate                               : 31.250 FPS (1536 SPF)
+Compression mode                         : Lossy
+Stream size                              : 539 MiB (3%)
+Title                                    : Дубляж, Продубляж
+Language                                 : Russian
+Service kind                             : Complete Main
+Default                                  : Yes
+Forced                                   : No
+Dialog Normalization                     : -29 dB
+compr                                    : -0.28 dB
+dialnorm_Average                         : -29 dB
+dialnorm_Minimum                         : -29 dB
+dialnorm_Maximum                         : -29 dB
Audio #2
+ID                                       : 3
+Format                                   : E-AC-3 JOC
+Format/Info                              : Enhanced AC-3 with Joint Object Coding
+Commercial name                          : Dolby Digital Plus with Dolby Atmos
+Codec ID                                 : A_EAC3
+Duration                                 : 1 h 38 min
+Bit rate mode                            : Constant
+Bit rate                                 : 768 kb/s
+Channel(s)                               : 6 channels
+Channel layout                           : L R C LFE Ls Rs
+Sampling rate                            : 48.0 kHz
+Frame rate                               : 31.250 FPS (1536 SPF)
+Compression mode                         : Lossy
+Stream size                              : 539 MiB (3%)
+Title                                    : MVO, AlexFilm
+Language                                 : Russian
+Service kind                             : Complete Main
+Default                                  : No
+Forced                                   : No
+Complexity index                         : 16
+Number of dynamic objects                : 15
+Bed channel count                        : 1 channel
+Bed channel configuration                : LFE
+Dialog Normalization                     : -24 dB
+compr                                    : -0.28 dB
+dmixmod                                  : Lo/Ro
+ltrtcmixlev                              : -3.0 dB
+ltrtsurmixlev                            : -3.0 dB
+lorocmixlev                              : -3.0 dB
+lorosurmixlev                            : -3.0 dB
+dialnorm_Average                         : -24 dB
+dialnorm_Minimum                         : -24 dB
+dialnorm_Maximum                         : -24 dB
Audio #3
+ID                                       : 4
+Format                                   : AC-3
+Format/Info                              : Audio Coding 3
+Commercial name                          : Dolby Digital
+Codec ID                                 : A_AC3
+Duration                                 : 1 h 38 min
+Bit rate mode                            : Constant
+Bit rate                                 : 640 kb/s
+Channel(s)                               : 6 channels
+Channel layout                           : L R C LFE Ls Rs
+Sampling rate                            : 48.0 kHz
+Frame rate                               : 31.250 FPS (1536 SPF)
+Compression mode                         : Lossy
+Stream size                              : 450 MiB (2%)
+Title                                    : MVO, Дубляжная
+Language                                 : Russian
+Service kind                             : Complete Main
+Default                                  : No
+Forced                                   : No
+Dialog Normalization                     : -31 dB
+cmixlev                                  : -4.5 dB
+surmixlev                                : -6 dB
+dialnorm_Average                         : -31 dB
+dialnorm_Minimum                         : -31 dB
+dialnorm_Maximum                         : -31 dB
Audio #4
+ID                                       : 5
+Format                                   : E-AC-3
+Format/Info                              : Enhanced AC-3
+Commercial name                          : Dolby Digital Plus
+Codec ID                                 : A_EAC3
+Duration                                 : 1 h 38 min
+Bit rate mode                            : Constant
+Bit rate                                 : 256 kb/s
+Channel(s)                               : 6 channels
+Channel layout                           : L R C LFE Ls Rs
+Sampling rate                            : 48.0 kHz
+Frame rate                               : 31.250 FPS (1536 SPF)
+Compression mode                         : Lossy
+Stream size                              : 180 MiB (1%)
+Title                                    : Дубляж, Le Doyen
+Language                                 : Ukrainian
+Service kind                             : Complete Main
+Default                                  : No
+Forced                                   : No
+Dialog Normalization                     : -23 dB
+compr                                    : -0.28 dB
+mixlevel                                 : 105 dB
+dialnorm_Average                         : -23 dB
+dialnorm_Minimum                         : -23 dB
+dialnorm_Maximum                         : -23 dB
Audio #5
+ID                                       : 6
+Format                                   : E-AC-3 JOC
+Format/Info                              : Enhanced AC-3 with Joint Object Coding
+Commercial name                          : Dolby Digital Plus with Dolby Atmos
+Codec ID                                 : A_EAC3
+Duration                                 : 1 h 38 min
+Bit rate mode                            : Constant
+Bit rate                                 : 768 kb/s
+Channel(s)                               : 6 channels
+Channel layout                           : L R C LFE Ls Rs
+Sampling rate                            : 48.0 kHz
+Frame rate                               : 31.250 FPS (1536 SPF)
+Compression mode                         : Lossy
+Stream size                              : 539 MiB (3%)
+Title                                    : Original
+Language                                 : English
+Service kind                             : Complete Main
+Default                                  : No
+Forced                                   : No
+Complexity index                         : 16
+Number of dynamic objects                : 15
+Bed channel count                        : 1 channel
+Bed channel configuration                : LFE
+Dialog Normalization                     : -27 dB
+compr                                    : -0.28 dB
+dialnorm_Average                         : -27 dB
+dialnorm_Minimum                         : -27 dB
+dialnorm_Maximum                         : -27 dB
Text #1
+ID                                       : 7
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 16 min
+Bit rate                                 : 0 b/s
+Frame rate                               : 0.002 FPS
+Count of elements                        : 9
+Stream size                              : 316 Bytes (0%)
+Title                                    : Forced
+Language                                 : Russian
+Default                                  : Yes
+Forced                                   : No
Text #2
+ID                                       : 8
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 0 min
+Bit rate                                 : 0 b/s
+Frame rate                               : 0.003 FPS
+Count of elements                        : 12
+Stream size                              : 328 Bytes (0%)
+Title                                    : Forced
+Language                                 : Ukrainian
+Default                                  : No
+Forced                                   : No
Text #3
+ID                                       : 9
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 36 min
+Bit rate                                 : 42 b/s
+Frame rate                               : 0.118 FPS
+Count of elements                        : 680
+Stream size                              : 30.2 KiB (0%)
+Title                                    : Full
+Language                                 : Ukrainian
+Default                                  : No
+Forced                                   : No
Text #4
+ID                                       : 10
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 37 min
+Bit rate                                 : 43 b/s
+Frame rate                               : 0.141 FPS
+Count of elements                        : 829
+Stream size                              : 31.1 KiB (0%)
+Title                                    : Full #2
+Language                                 : Ukrainian
+Default                                  : No
+Forced                                   : No
Text #5
+ID                                       : 11
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 38 min
+Bit rate                                 : 73 b/s
+Frame rate                               : 0.289 FPS
+Count of elements                        : 1701
+Stream size                              : 53.1 KiB (0%)
+Title                                    : SDH
+Language                                 : Ukrainian
+Default                                  : No
+Forced                                   : No
Text #6
+ID                                       : 12
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 37 min
+Bit rate                                 : 28 b/s
+Frame rate                               : 0.150 FPS
+Count of elements                        : 878
+Stream size                              : 20.3 KiB (0%)
+Title                                    : Full
+Language                                 : English
+Default                                  : No
+Forced                                   : No
Text #7
+ID                                       : 13
+Format                                   : UTF-8
+Codec ID                                 : S_TEXT/UTF8
+Codec ID/Info                            : UTF-8 Plain Text
+Duration                                 : 1 h 38 min
+Bit rate                                 : 46 b/s
+Frame rate                               : 0.280 FPS
+Count of elements                        : 1649
+Stream size                              : 33.4 KiB (0%)
+Title                                    : SDH
+Language                                 : English
+Default                                  : No
+Forced                                   : No
Menu
+00:00:00.000                             : en:Chapter 01
+00:06:29.388                             : en:Chapter 02
+00:12:10.062                             : en:Chapter 03
+00:17:08.277                             : en:Chapter 04
+00:23:43.213                             : en:Chapter 05
+00:29:18.757                             : en:Chapter 06
+00:37:11.313                             : en:Chapter 07
+00:42:40.392                             : en:Chapter 08
+00:47:29.056                             : en:Chapter 09
+00:53:08.020                             : en:Chapter 10
+00:58:31.176                             : en:Chapter 11
+01:05:00.816                             : en:Chapter 12
+01:10:45.828                             : en:Chapter 13
+01:16:38.764                             : en:Chapter 14
+01:22:16.268                             : en:Chapter 15
+01:29:53.809                             : en:Chapter 16
+
+
+
+
+
Скриншоты
+ +
+
+
Дополнительно
+
Данный вид релизов в MKV сделан в 8-м профиле Dolby Vision для воспроизведении на устройствах с поддержкой DV. Протестировано на тв-боксах Nvidia Shield 2019, Zidoo Z9X, Ugoos AM6+, TiVo Stream 4K, Amazon 4k Stick Max и телевизорах Sony XG/XH и Phillips. На устройствах без поддержки Dolby Vision будет играть просто HDR10.
+Программные плееры для воспроизведения в DV(на поддерживаемых устройствах): Vimu Player 8.8.8(и выше), Kodi 19(специальная сборка с поддержкой DV), ExoPlayer, JustPlayer.
Исходник: The Super Mario Galaxy Movie 2026 Hybrid 2160p MA WEB-DL DD+ 5 1 Atmos DV HDR H 265-HONE
+
+ + +
+ + +
+ Download + +
+ Сайт не распространяет и не хранит электронные версии произведений, а лишь предоставляет доступ к создаваемому + пользователями каталогу ссылок на торрент-файлы, + которые содержат только списки хеш-сумм +
+
+ Как скачивать? + (для скачивания .torrent файлов необходима + регистрация) +
+
+ + + + +
+ + +
+ +
+ [Профиль]  + [ЛС]  +
+ +
+ + + +

Mr_Korzh

+ + +

Стаж: 17 лет 9 месяцев

Сообщений: 270

+ +
+ +
+

+ + Mr_Korzh · + + 14-Июл-26 17:13 + + (спустя 29 дней) +

+ + +
+
+ +
+
+ Прошу прощения. А дубляж от RHS планируется добавить в релиз? И если да, то когда?
+ + +
+ + +
+ +
+ [Профиль]  + [ЛС]  +
+ +
+ + + + + + + + + +
+   +
+ + + + + + +
+ Ответить +
+ +
+ +
+ +
+ + + + + +
+
+ +
+ + + +
+
+
+ + + +
+ + +
Loading...
+
Error
+ +
+
+
+ + + + + +
+ + \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index dc44ccc..abdfd41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,3 +64,23 @@ BIO-разметка строится по символьным спанам ч Лучшая по joint exact на held-out test копируется в `models/best`. Итог — в `reports/summary.md`. + +## Сегментатор страниц (src/segmenter.py) — главный путь + +Какие строки страницы описывают дорожки, решает обученная модель +(rubert-tiny2, бинарная классификация строки с контекстом «пред. || след.»), +а не регулярки. Правила (`html_extract.extract_segments`) — запасной путь: +используются, если `models/segmenter` нет или модель не нашла ни одной строки; +режим можно форсировать: `predict_release(..., segmenter="rules")`. + +* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по + `raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех + train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных + строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест). +* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах + rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975 + (`tests/e2e_segmenter.py`; выборка мала, разброс большой). +* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена + (91 группа из 6735) — остаётся на правилах `_split_entries`, но только на + строках, выбранных моделью. +* ONNX-путь (Rust/TS) сегментатор пока не включает: там по-прежнему правила. diff --git a/src/predict.py b/src/predict.py index d57a110..8319d8a 100644 --- a/src/predict.py +++ b/src/predict.py @@ -59,7 +59,8 @@ def refine_span(text: str, span: tuple[int, int] | None) -> tuple[int, int] | No class DubPredictor: """Загруженная модель для предсказания типа озвучки и автора.""" - def __init__(self, model_dir: str | Path = "models/best", device: str | None = None): + def __init__(self, model_dir: str | Path = "models/best", device: str | None = None, + segmenter_dir: str | Path = "models/segmenter"): self.model_dir = Path(model_dir) if device is None: device = "cuda" if torch.cuda.is_available() else "cpu" @@ -68,6 +69,12 @@ class DubPredictor: self.types = maps["types"] self.tokenizer = AutoTokenizer.from_pretrained(self.model_dir) self.model = load_model(self.model_dir, device) + # обученный сегментатор страниц (главный путь); нет модели -> правила + self.segmenter = None + seg_dir = Path(segmenter_dir) + if seg_dir.exists(): + from .segmenter import LineSegmenter + self.segmenter = LineSegmenter(seg_dir, device) # ------------------------------------------------------------------ @torch.no_grad() diff --git a/src/release.py b/src/release.py index 3d77634..359a815 100644 --- a/src/release.py +++ b/src/release.py @@ -15,6 +15,9 @@ import re from bs4 import BeautifulSoup +from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments, + html_to_text) + # аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели TITLE_TYPES = { "ПМ": "Профессиональный (многоголосый закадровый)", @@ -125,17 +128,45 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None: tracks.append(t) -def predict_release(predictor, *, html: str | None = None, - text: str | None = None, title: str | None = None) -> dict: +def model_segments(segmenter, page_text: str) -> list[str]: + """Дорожки страницы: строки выбирает обученный сегментатор. + + Нарезка внутри строки («Дублированный, многоголосый») в данных не + размечена и пока делается правилами `_split_entries`. + """ + out = [] + for line in segmenter.select_lines(page_text): + body = RE_LINE_PREFIX.sub("", line, count=1) + for entry in _split_entries(body): + entry = re.sub(r"\s+", " ", entry).strip() + if entry: + out.append(entry) + return out + + +def predict_release(predictor, *, html: str | None = None, text: str | None = None, + title: str | None = None, segmenter: str = "model") -> dict: """Предсказание для релиза целиком (html или текст страницы). + segmenter: "model" — строки-дорожки выбирает обученный сегментатор + (если он загружен и нашёл хотя бы одну строку), иначе правила; "rules" — + только правила. + Заголовок раздачи используется как дополнительный источник: студии, названные только там («ПМ (LostFilm)»), добавляются как дорожки с пометкой source=title. """ if (html is None) == (text is None): raise ValueError("нужно ровно одно из: html, text") - tracks = predictor.predict_html(html) if html is not None else predictor.predict_page(text) + page_text = html_to_text(html) if html is not None else text + segs_page, used = [], "rules" + seg_model = getattr(predictor, "segmenter", None) + if segmenter == "model" and seg_model is not None: + segs_page = model_segments(seg_model, page_text) + used = "model" if segs_page else "rules" + if not segs_page: + segs_page = extract_segments(page_text) + tracks = predictor._predict_batch(segs_page) if segs_page else [] for t in tracks: t["source"] = "page" if title is None: @@ -145,4 +176,5 @@ def predict_release(predictor, *, html: str | None = None, merge_title_tracks(tracks, predictor._predict_batch(segs)) result = aggregate(tracks) result["title"] = title or None + result["segmenter"] = used return result diff --git a/src/segmenter.py b/src/segmenter.py new file mode 100644 index 0000000..e54faf5 --- /dev/null +++ b/src/segmenter.py @@ -0,0 +1,215 @@ +"""Обучаемый сегментатор: какие строки страницы описывают дорожки озвучки. + +Главный путь выделения дорожек (правила из html_extract — запасной). +Модель — rubert-tiny2 (бинарная классификация строки). Вход — строка с +контекстом: «пред. строка [SEP] строка [SEP] след. строка». + + python -m src.segmenter --train # обучение -> models/segmenter + python -m src.segmenter --eval # метрики на отложенных страницах + +Python API: + seg = LineSegmenter("models/segmenter") + seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят) + +Разметка: строка страницы положительна, если содержит `raw` строки датасета +этого релиза (или наоборот). Положительные примеры добавляются синтетически: +`raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц) +вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки +(«Дублированный, многоголосый») в данных не размечена и остаётся на правилах. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import random +import re +from pathlib import Path + +import numpy as np +import torch +from transformers import AutoModelForSequenceClassification, AutoTokenizer + +from .html_extract import read_input + +BACKBONE = "cointegrated/rubert-tiny2" +MAX_LEN = 160 +LINE_CHARS = 220 +CTX_CHARS = 70 +THRESHOLD = 0.5 +SEED = 7 + + +def norm(s: str) -> str: + return re.sub(r"\s+", " ", s or "").strip() + + +def is_test_release(release_id: str) -> bool: + """Детерминированное разбиение страниц: ~20% релизов уходят в тест.""" + return int(hashlib.md5(str(release_id).encode()).hexdigest(), 16) % 5 == 0 + + +def make_input(prev: str, line: str, nxt: str) -> tuple[str, str]: + """(текст-пара для токенизатора): строка + контекст.""" + return norm(line)[:LINE_CHARS], f"{norm(prev)[-CTX_CHARS:]} || {norm(nxt)[:CTX_CHARS]}" + + +def page_lines(text: str) -> list[str]: + return [norm(l) for l in text.splitlines() if norm(l)] + + +# ---------------------------------------------------------------- данные +def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]: + return {p.stem: page_lines(read_input(p)) for p in sorted(pages_dir.glob("*.txt"))} + + +def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]: + with open(csv_path, encoding="utf-8-sig") as f: + return list(csv.DictReader(f)) + + +def label_page(lines: list[str], raws: list[str]) -> list[int]: + """1, если строка содержит raw (или raw содержит строку ≥ 15 символов).""" + out = [] + for line in lines: + hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws) + out.append(int(hit)) + return out + + +def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> list[tuple]: + """Список (line, ctx, label). split: 'train' | 'test'.""" + pages = load_pages() + rows = load_raws() + raws_by_rel: dict[str, list[str]] = {} + for r in rows: + raws_by_rel.setdefault(r["release_id"], []).append(norm(r["raw"])) + + want_test = split == "test" + page_items = [] # (release_id, lines, labels) + for rid, lines in pages.items(): + if is_test_release(rid) != want_test: + continue + page_items.append((rid, lines, label_page(lines, raws_by_rel.get(rid, [])))) + + # все raw train-релизов (без тестовых страниц) — материал для синтетики + test_pos = {l for rid, lines in pages.items() if is_test_release(rid) + for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y} + pool = sorted({norm(r["raw"]) for r in rows + if r["raw"] and not is_test_release(r["release_id"]) + and norm(r["raw"]) not in test_pos}) + + ex: list[tuple] = [] + for rid, lines, labels in page_items: + for i, (line, y) in enumerate(zip(lines, labels)): + prev = lines[i - 1] if i else "" + nxt = lines[i + 1] if i + 1 < len(lines) else "" + if not y and not HINT.search(line) and rng.random() > 0.15: + continue # простые негативы прореживаем, трудные (с намёками) берём все + ex.append((*make_input(prev, line, nxt), y)) + if not want_test: + negs = [l for l, y in zip(lines, labels) if not y] + for _ in range(synth_per_page if negs else 0): + raw = rng.choice(pool) + p, n = rng.choice(negs), rng.choice(negs) + ex.append((*make_input(p, raw, n), 1)) + return ex + + +# слова, делающие негативную строку «трудной»: озвучка/аудио/субтитры/кодеки +HINT = re.compile(r"(?i)(перевод|озвуч|аудио|звук|дубл|голос|субтитр|kbps|kHz|AC3|DTS|MVO|DVO|AVO)") + + +# ---------------------------------------------------------------- модель +class LineSegmenter: + def __init__(self, model_dir: str | Path = "models/segmenter", device: str | None = None): + self.dir = Path(model_dir) + self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") + self.tok = AutoTokenizer.from_pretrained(self.dir) + self.model = AutoModelForSequenceClassification.from_pretrained(self.dir).to(self.device).eval() + + @torch.no_grad() + def score(self, lines: list[str]) -> np.ndarray: + """Вероятность «строка описывает дорожку» для каждой строки.""" + pairs = [make_input(lines[i - 1] if i else "", l, lines[i + 1] if i + 1 < len(lines) else "") + for i, l in enumerate(lines)] + out = [] + for s in range(0, len(pairs), 128): + chunk = pairs[s:s + 128] + enc = self.tok([a for a, _ in chunk], [b for _, b in chunk], truncation="only_second", + max_length=MAX_LEN, padding=True, return_tensors="pt").to(self.device) + out.append(torch.softmax(self.model(**enc).logits, -1)[:, 1].cpu().numpy()) + return np.concatenate(out) if out else np.zeros(0) + + def select_lines(self, page_text: str, threshold: float = THRESHOLD) -> list[str]: + lines = page_lines(page_text) + if not lines: + return [] + probs = self.score(lines) + return [l for l, p in zip(lines, probs) if p >= threshold] + + +# ---------------------------------------------------------------- обучение +def train(out_dir: str = "models/segmenter", epochs: int = 4, batch: int = 32, lr: float = 5e-5): + rng = random.Random(SEED) + torch.manual_seed(SEED) + tr = build_examples("train", rng) + te = build_examples("test", rng) + print(f"train {len(tr)} (pos {sum(y for *_, y in tr)}), test {len(te)} (pos {sum(y for *_, y in te)})") + device = "cuda" if torch.cuda.is_available() else "cpu" + tok = AutoTokenizer.from_pretrained(BACKBONE) + model = AutoModelForSequenceClassification.from_pretrained(BACKBONE, num_labels=2).to(device) + opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01) + steps = epochs * ((len(tr) + batch - 1) // batch) + sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=steps, pct_start=0.1) + + def enc(b): + return tok([x[0] for x in b], [x[1] for x in b], truncation="only_second", + max_length=MAX_LEN, padding=True, return_tensors="pt").to(device) + + def evaluate(data): + model.eval() + pred = [] + with torch.no_grad(): + for s in range(0, len(data), 128): + pred += model(**enc(data[s:s + 128])).logits.argmax(-1).tolist() + y = [x[2] for x in data] + tp = sum(p and t for p, t in zip(pred, y)) + prec = tp / max(sum(pred), 1) + rec = tp / max(sum(y), 1) + model.train() + return prec, rec + + model.train() + for ep in range(epochs): + rng.shuffle(tr) + for s in range(0, len(tr), batch): + b = tr[s:s + batch] + loss = model(**enc(b), labels=torch.tensor([x[2] for x in b], device=device)).loss + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + opt.step() + sched.step() + opt.zero_grad() + p, r = evaluate(te) + print(f"epoch {ep + 1}: test line precision {p:.3f} recall {r:.3f}") + Path(out_dir).mkdir(parents=True, exist_ok=True) + model.save_pretrained(out_dir) + tok.save_pretrained(out_dir) + return p, r + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--train", action="store_true") + ap.add_argument("--out", default="models/segmenter") + ap.add_argument("--epochs", type=int, default=4) + args = ap.parse_args() + if args.train: + train(args.out, args.epochs) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e_segmenter.py b/tests/e2e_segmenter.py new file mode 100644 index 0000000..1577e5c --- /dev/null +++ b/tests/e2e_segmenter.py @@ -0,0 +1,62 @@ +"""Сравнение сегментации: правила vs обученный сегментатор (predict_release). + +Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по +страницам, которых сегментатор не видел при обучении (is_test_release). +Запуск: .venv/Scripts/python tests/e2e_segmenter.py +""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.html_extract import read_input # noqa: E402 +from src.predict import DubPredictor # noqa: E402 +from src.release import predict_release # noqa: E402 +from src.segmenter import is_test_release # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] + + +def run(predictor, rows, mode: str, test_only: bool) -> dict: + by_rel = defaultdict(list) + for r in rows: + by_rel[r["release_id"]].append(r) + per_src = defaultdict(list) + for page in sorted((ROOT / "data" / "pages").glob("*.txt")): + rid = page.stem + if is_test_release(rid) != test_only or rid not in by_rel: + continue + gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]} + res = predict_release(predictor, text=read_input(page), segmenter=mode) + pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]} + tp = len(gold & pred) + p = tp / len(pred) if pred else 0.0 + r = tp / len(gold) if gold else 0.0 + per_src[by_rel[rid][0]["source"]].append((p, r)) + out = {} + for s, v in per_src.items(): + out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v)) + return out + + +def main() -> None: + rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8")) + predictor = DubPredictor() + if predictor.segmenter is None: + sys.exit("нет models/segmenter: python -m src.segmenter --train") + for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"), + (False, "train-страницы (для справки, оптимистично)")): + print(f"== {name} ==") + for mode in ("rules", "model"): + res = run(predictor, rows, mode, test_only) + print(f" {mode:5s}: " + "; ".join( + f"{s} n={n} P={p:.3f} R={r:.3f}" for s, (n, p, r) in res.items())) + + +if __name__ == "__main__": + main() diff --git a/tests/run_tests.py b/tests/run_tests.py index ef5d2fb..e4de1ac 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -301,5 +301,19 @@ _agg2 = aggregate([_t("проф. дубляж", "Продубляж"), _t("пр check("aggregate: дорожка без студии того же типа схлопывается", [x["translation_type"] for x in _agg2["tracks"]] == ["проф. дубляж", "проф. двухголосый"]) +print("== сегментатор (модель) ==") +try: + from src.segmenter import LineSegmenter, is_test_release # noqa: E402 + _seg = LineSegmenter("models/segmenter", "cpu") + _fx = open("data/samples/rt-topic-6870900.html", encoding="utf-8").read() + _lines = _seg.select_lines(html_to_text(_fx)) + check("сегментатор: найдены 4 строки «Перевод N» реальной страницы", + sum(l.startswith("Перевод") for l in _lines) == 4, str(_lines[:5])) + check("сегментатор: «Субтитры» и «Оригинальная аудиодорожка» не дорожки", + not any(l.startswith(("Субтитры", "Оригинальная")) for l in _lines)) + check("разбиение страниц детерминировано", is_test_release("6870900") == is_test_release("6870900")) +except Exception as e: # noqa: BLE001 + print(f" пропущено (нет models/segmenter): {e}") + print(f"\nитог: {PASS} ok, {FAIL} failed") sys.exit(1 if FAIL else 0)