Spaces:
Running
Running
Deb commited on
Commit ·
f655146
1
Parent(s): b5e4278
rupkotha_1st_commit
Browse files- .gitignore +46 -0
- README.md +145 -7
- app.py +232 -0
- assets/styles.css +211 -0
- core/__init__.py +0 -0
- core/modal_infra.py +785 -0
- core/model_config.py +177 -0
- core/prompts.py +167 -0
- core/stt.py +30 -0
- core/tts.py +92 -0
- core/vision_story.py +160 -0
- finetune/README.md +91 -0
- finetune/collect.py +207 -0
- finetune/eval_ft.py +154 -0
- finetune/gen_labels.py +123 -0
- finetune/merge.py +90 -0
- finetune/preprocess.py +139 -0
- finetune/purity.py +82 -0
- finetune/serve_vllm.py +119 -0
- finetune/train_lora.py +166 -0
- pyproject.toml +13 -0
- requirements.txt +8 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.venv/
|
| 5 |
+
*.egg-info/
|
| 6 |
+
*.png
|
| 7 |
+
*.wav
|
| 8 |
+
|
| 9 |
+
# Caches / logs
|
| 10 |
+
.hf_cache/
|
| 11 |
+
finetune/.hf_cache/
|
| 12 |
+
*.log
|
| 13 |
+
.claude
|
| 14 |
+
.gradio/
|
| 15 |
+
data/
|
| 16 |
+
demo/
|
| 17 |
+
docs/
|
| 18 |
+
CLAUDE.md
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Audio artifacts — regenerable demos and large recordings don't belong in git
|
| 22 |
+
*.wav
|
| 23 |
+
*.mp3
|
| 24 |
+
*.flac
|
| 25 |
+
|
| 26 |
+
# Large, reproducible training data (regenerate via finetune/collect.py + preprocess.py).
|
| 27 |
+
# The distilled dataset finetune/data/train.json IS kept (the valuable artifact).
|
| 28 |
+
finetune/data/images/
|
| 29 |
+
finetune/data/processed/
|
| 30 |
+
finetune/data/labelset/
|
| 31 |
+
finetune/data/sample/
|
| 32 |
+
finetune/data/train_review.md
|
| 33 |
+
finetune/data/train_sample_review.md
|
| 34 |
+
finetune/data/train_sample.json
|
| 35 |
+
finetune/data/train_sample.json.rejected
|
| 36 |
+
finetune/data/train.json.rejected
|
| 37 |
+
|
| 38 |
+
# Eval / comparison outputs
|
| 39 |
+
finetune/eval_results/
|
| 40 |
+
finetune/quality_results/
|
| 41 |
+
tests/
|
| 42 |
+
tests/quality_results/
|
| 43 |
+
reference_audio/
|
| 44 |
+
|
| 45 |
+
# OS
|
| 46 |
+
.DS_Store
|
README.md
CHANGED
|
@@ -1,14 +1,152 @@
|
|
| 1 |
---
|
| 2 |
title: Rupkotha
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
short_description:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Rupkotha
|
| 3 |
+
emoji: 🌙
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.17.3
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: Bedtime stories from kids' drawings, in English & Bengali
|
| 11 |
+
tags:
|
| 12 |
+
- backyard-ai
|
| 13 |
+
- openbmb
|
| 14 |
+
- modal
|
| 15 |
+
- children
|
| 16 |
+
- storytelling
|
| 17 |
+
- bengali
|
| 18 |
+
- tts
|
| 19 |
+
- vision-language-model
|
| 20 |
---
|
| 21 |
|
| 22 |
+
# রূপকথা · Rupkotha
|
| 23 |
+
|
| 24 |
+
A bedtime-story app for kids. A child shows their drawings or toys, asks for a
|
| 25 |
+
story in their own voice (English or Bengali), and hears it read back in a warm
|
| 26 |
+
grandmother's voice. Built for the Build Small Hackathon (Track 1: Backyard AI).
|
| 27 |
+
|
| 28 |
+
Inference runs on **Modal** (cloud GPUs) — the Gradio Space is a thin client with
|
| 29 |
+
zero model weights.
|
| 30 |
+
|
| 31 |
+
## 🏆 Build Small Hackathon — submission
|
| 32 |
+
|
| 33 |
+
- **Track:** Practical · **Backyard AI** (a custom story generator — the track's own example use case).
|
| 34 |
+
- **Targeting:** 🥇 **OpenBMB Best MiniCPM Build** · ⚡ **Modal Best Use**.
|
| 35 |
+
- **🎬 Demo video:** https://youtu.be/mUUmy5JwBYo
|
| 36 |
+
- **📣 Social post:** `TODO_ADD_SOCIAL_URL`
|
| 37 |
+
|
| 38 |
+
**The idea.** Rupkotha (রূপকথা, "fairy tale") is a bedtime-story app for 4–9 year-olds
|
| 39 |
+
who can't yet type. A child shows a crayon drawing or a toy, asks for a story **by
|
| 40 |
+
voice** in **English or Bengali**, and hears it read aloud in a warm grandmother's
|
| 41 |
+
voice — gentle, short, always ending in sleep.
|
| 42 |
+
|
| 43 |
+
**The technical approach.**
|
| 44 |
+
- **Vision → story:** `MiniCPM-V 4.5` (8B) reads 1–4 images + the request and writes
|
| 45 |
+
a 120–150-word bedtime fable.
|
| 46 |
+
- **Native Bengali (our differentiator):** the stock model's Bengali was garbled, so
|
| 47 |
+
we **fine-tuned MiniCPM-V itself** — distilled ~389 native Bengali stories from a
|
| 48 |
+
Gemma teacher, LoRA-trained via ms-SWIFT, merged, and served on **vLLM**. A held-out
|
| 49 |
+
eval (Bengali speaker) confirmed it beats the base decisively.
|
| 50 |
+
- **Voice:** `faster-whisper` for speech input; **VoxCPM2** (English) and **AI4Bharat
|
| 51 |
+
Indic-TTS** (Bengali) for the grandmother voice output.
|
| 52 |
+
- **Infra:** every model runs on **Modal** (Ollama + vLLM + TTS containers, scale-to-zero
|
| 53 |
+
A10G/A100); the HF Space holds **zero weights** and just calls Modal functions. All
|
| 54 |
+
models are well under the 32B limit. Switching/serving is driven by one config object
|
| 55 |
+
(`core/model_config.py`).
|
| 56 |
+
|
| 57 |
+
> **Running this Space:** it calls Modal at runtime — set `MODAL_TOKEN_ID` and
|
| 58 |
+
> `MODAL_TOKEN_SECRET` as Space secrets, and deploy `core/modal_infra.py` +
|
| 59 |
+
> `finetune/serve_vllm.py` first. First call after idle is a cold start (~1–5 min);
|
| 60 |
+
> see the demo video for a smooth walkthrough.
|
| 61 |
+
|
| 62 |
+
## Status
|
| 63 |
+
|
| 64 |
+
Story (EN + BN), STT, and TTS run on Modal via `core/modal_infra.py`. The Bengali
|
| 65 |
+
path is served by a **fine-tuned MiniCPM-V 4.5** (see below); English uses the
|
| 66 |
+
stock model. Each `core/` function degrades gracefully to a safe fallback if a
|
| 67 |
+
model is unavailable, so the app stays runnable. See `CLAUDE.md` for the full
|
| 68 |
+
build order and remaining UI/polish items.
|
| 69 |
+
|
| 70 |
+
## The stack
|
| 71 |
+
|
| 72 |
+
The submission runs a single stack — **Stack A**, the OpenBMB prize path — defined in
|
| 73 |
+
`core/model_config.py`. (The `StackConfig` machinery remains so a stack could be
|
| 74 |
+
swapped in, but only Stack A is shipped.)
|
| 75 |
+
|
| 76 |
+
| Layer | Model | ~Params |
|
| 77 |
+
|---|---|---|
|
| 78 |
+
| Vision + story | MiniCPM-V 4.5 (Bengali fine-tuned), via Ollama / vLLM | 8B |
|
| 79 |
+
| STT | faster-whisper large-v3 | 1.55B |
|
| 80 |
+
| English TTS | VoxCPM2 (Voice Design) | 2B |
|
| 81 |
+
| Bengali TTS | AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) | ~0.13B |
|
| 82 |
+
|
| 83 |
+
All OpenBMB-family core models (MiniCPM-V + VoxCPM2) → eligible for the OpenBMB Best
|
| 84 |
+
MiniCPM Build prize, strengthened by fine-tuning MiniCPM-V itself for Bengali.
|
| 85 |
+
|
| 86 |
+
## Infrastructure
|
| 87 |
+
|
| 88 |
+
All model inference runs on **Modal** — the Gradio Space holds zero model weights
|
| 89 |
+
and makes zero local inference calls. Two Modal apps back the app:
|
| 90 |
+
|
| 91 |
+
- **`rupkotha`** (`core/modal_infra.py`) — the base runtime: vision/story
|
| 92 |
+
(MiniCPM-V 4.5 via Ollama), STT (faster-whisper), TTS (VoxCPM2 for English,
|
| 93 |
+
AI4Bharat Indic-TTS for Bengali), and the IndicTrans2 translation path.
|
| 94 |
+
- **`rupkotha-ft-serve`** (`finetune/serve_vllm.py`) — the **Bengali fine-tuned**
|
| 95 |
+
MiniCPM-V, served via **vLLM** on an A100-40GB (the full bf16 8B + vision
|
| 96 |
+
encoder needs more than a 24 GB card).
|
| 97 |
+
|
| 98 |
+
The `core/` wrappers call these functions remotely; switching
|
| 99 |
+
`COMPUTE_LOCATION` in `model_config.py` is the only change needed to run base
|
| 100 |
+
inference locally (requires a GPU with 8+ GB VRAM).
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
uv run modal deploy core/modal_infra.py # base runtime (EN vision, STT, TTS)
|
| 104 |
+
uv run modal deploy finetune/serve_vllm.py # Bengali fine-tuned model (vLLM)
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
## Bengali fine-tune — improving the OpenBMB model itself
|
| 108 |
+
|
| 109 |
+
Stack A's one weak spot was native Bengali: stock MiniCPM-V 4.5 produced garbled,
|
| 110 |
+
repetitive Bengali. Rather than swap in a different model, we **fine-tuned the
|
| 111 |
+
OpenBMB model itself** to fix it — the strongest possible OpenBMB-prize story
|
| 112 |
+
(*"we improved MiniCPM-V for Bengali"*). The whole pipeline lives in `finetune/`
|
| 113 |
+
and runs on Modal:
|
| 114 |
+
|
| 115 |
+
1. **Distill** native Bengali bedtime stories from a Gemma 3 teacher over ~450
|
| 116 |
+
children's drawings, filtered by a purity gate → **389 high-quality examples**.
|
| 117 |
+
2. **LoRA fine-tune** MiniCPM-V 4.5 with **ms-SWIFT** on an A100-80GB — vision
|
| 118 |
+
encoder frozen, LoRA (r=16) on the LLM self-attention only (the weak-Bengali part).
|
| 119 |
+
3. **Merge** the adapter into standalone weights and **serve via vLLM**.
|
| 120 |
+
4. **Evaluate** FT vs the stock model on held-out drawings (`finetune/eval_ft.py`):
|
| 121 |
+
the fine-tune wins decisively — coherent native রূপকথা vs garbled, looping output
|
| 122 |
+
— confirmed by a Bengali speaker.
|
| 123 |
+
|
| 124 |
+
Bengali story requests now route to the fine-tuned model
|
| 125 |
+
(`FINETUNED_VISION_MODEL` in `model_config.py`, one-line revert to `None`); English
|
| 126 |
+
and audio paths are unchanged. See `finetune/README.md` for the full pipeline.
|
| 127 |
+
|
| 128 |
+
## Prizes targeted
|
| 129 |
+
|
| 130 |
+
| Prize | Why eligible |
|
| 131 |
+
|---|---|
|
| 132 |
+
| Backyard AI track | Storybook generators are the track's example use case |
|
| 133 |
+
| OpenBMB Best MiniCPM Build | Stack A is all-OpenBMB, **and we fine-tuned MiniCPM-V 4.5 itself** for native Bengali |
|
| 134 |
+
| Modal Best Use | All inference on Modal; two apps — base runtime + a vLLM-served fine-tuned model, plus LoRA training on A100 |
|
| 135 |
+
|
| 136 |
+
## Environment & setup
|
| 137 |
+
|
| 138 |
+
This project uses **[uv](https://docs.astral.sh/uv/)** for all package and
|
| 139 |
+
environment management (Python 3.10–3.12; VoxCPM2 requires < 3.13).
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
uv venv --python 3.12 # create the environment
|
| 143 |
+
uv sync # install locked dependencies
|
| 144 |
+
uv run python app.py # launch the Gradio app
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Add dependencies with `uv add <pkg>` (never `pip`). `requirements.txt` is
|
| 148 |
+
generated from the lockfile (`uv export`), not hand-edited.
|
| 149 |
+
|
| 150 |
+
## Layout
|
| 151 |
+
|
| 152 |
+
See `CLAUDE.md` §3 for the full repository layout and §6 for the build order.
|
app.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py — Gradio Blocks entry point. UI + wiring only. ZERO model references.
|
| 2 |
+
"""Rupkotha (রূপকথা) — a bedtime-story app for kids.
|
| 3 |
+
|
| 4 |
+
This file orchestrates the UI and chains core functions:
|
| 5 |
+
transcribe() → generate_story() → speak()
|
| 6 |
+
It must contain no model names, paths, or model logic — those live only in core/.
|
| 7 |
+
|
| 8 |
+
Layout: a two-panel "studio" — a Create panel (language/style, pictures, ask) and a
|
| 9 |
+
Story panel (text + audio + save) — over a night-sky theme. Session memory uses
|
| 10 |
+
gr.State, never browser storage (CLAUDE.md §11).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import gradio as gr
|
| 16 |
+
|
| 17 |
+
from core.vision_story import generate_story
|
| 18 |
+
from core.stt import transcribe
|
| 19 |
+
from core.tts import speak
|
| 20 |
+
from core.prompts import STYLES
|
| 21 |
+
|
| 22 |
+
# Language radio: display label → internal code passed to core functions.
|
| 23 |
+
_LANGUAGES = [("English", "en"), ("বাংলা", "bn")]
|
| 24 |
+
_STYLE_CHOICES = {lang: list(styles.keys()) for lang, styles in STYLES.items()}
|
| 25 |
+
|
| 26 |
+
_CSS_PATH = Path(__file__).parent / "assets" / "styles.css"
|
| 27 |
+
|
| 28 |
+
HISTORY_SIZE = 3 # how many recent stories to keep (CLAUDE.md §11: last 3)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _styles_for(language: str):
|
| 32 |
+
"""Return a style-dropdown update for the chosen language."""
|
| 33 |
+
choices = _STYLE_CHOICES.get(language, _STYLE_CHOICES["en"])
|
| 34 |
+
return gr.update(choices=choices, value=choices[0])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _preview(files):
|
| 38 |
+
"""Show uploaded images in the preview gallery; hide it when empty."""
|
| 39 |
+
files = files or []
|
| 40 |
+
return gr.update(value=files, visible=bool(files))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _voice_to_text(audio_path, language):
|
| 44 |
+
"""Transcribe a mic recording into the instruction box. On empty/failed
|
| 45 |
+
transcription, leave whatever the child already typed untouched."""
|
| 46 |
+
print(f"[mic] stop_recording fired · audio={audio_path!r} · lang={language}", flush=True)
|
| 47 |
+
text = transcribe(audio_path, language)
|
| 48 |
+
print(f"[mic] transcribe -> {text!r}", flush=True)
|
| 49 |
+
return text if text else gr.update()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _tell_a_story(images, instruction, language, style, child_name):
|
| 53 |
+
"""Chain: images + instruction → story text → grandmother-voice audio.
|
| 54 |
+
|
| 55 |
+
Each core call degrades gracefully (never raises), so the UI always shows
|
| 56 |
+
a story even if Modal is unreachable or audio synthesis fails. Also returns
|
| 57 |
+
a `current` dict so the Save button can capture the exact result shown.
|
| 58 |
+
"""
|
| 59 |
+
image_paths = [img for img in (images or [])]
|
| 60 |
+
story, model_label = generate_story(
|
| 61 |
+
image_paths=image_paths,
|
| 62 |
+
instruction=instruction or "",
|
| 63 |
+
language=language,
|
| 64 |
+
style=style,
|
| 65 |
+
child_name=child_name or "",
|
| 66 |
+
)
|
| 67 |
+
wav_path, tts_label = speak(story, language)
|
| 68 |
+
badge = f"📖 {model_label} · 🔊 {tts_label}"
|
| 69 |
+
current = {"story": story, "audio": wav_path, "badge": badge}
|
| 70 |
+
return story, wav_path, badge, current
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _history_updates(history):
|
| 74 |
+
"""Flatten `history` into per-slot updates: (group, markdown, audio) × N."""
|
| 75 |
+
updates = []
|
| 76 |
+
for i in range(HISTORY_SIZE):
|
| 77 |
+
if i < len(history):
|
| 78 |
+
entry = history[i]
|
| 79 |
+
body = f"{entry['story']}\n\n<span class='saved-badge'>{entry['badge']}</span>"
|
| 80 |
+
updates += [
|
| 81 |
+
gr.update(visible=True),
|
| 82 |
+
gr.update(value=body),
|
| 83 |
+
gr.update(value=entry.get("audio")),
|
| 84 |
+
]
|
| 85 |
+
else:
|
| 86 |
+
updates += [
|
| 87 |
+
gr.update(visible=False),
|
| 88 |
+
gr.update(value=""),
|
| 89 |
+
gr.update(value=None),
|
| 90 |
+
]
|
| 91 |
+
return updates
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _save_story(current, history):
|
| 95 |
+
"""Prepend the current story to the session history (newest first, max N)."""
|
| 96 |
+
history = list(history or [])
|
| 97 |
+
if current and current.get("story"):
|
| 98 |
+
history = ([current] + history)[:HISTORY_SIZE]
|
| 99 |
+
return [history, *_history_updates(history)]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def build_ui() -> gr.Blocks:
|
| 103 |
+
theme = gr.themes.Soft(
|
| 104 |
+
primary_hue="amber",
|
| 105 |
+
secondary_hue="orange",
|
| 106 |
+
neutral_hue="slate",
|
| 107 |
+
radius_size="lg",
|
| 108 |
+
font=[gr.themes.GoogleFont("Nunito"), "ui-sans-serif", "sans-serif"],
|
| 109 |
+
)
|
| 110 |
+
css_kw = {"css_paths": [str(_CSS_PATH)]} if _CSS_PATH.exists() else {}
|
| 111 |
+
with gr.Blocks(title="রূপকথা · Rupkotha", theme=theme, fill_width=True, **css_kw) as demo:
|
| 112 |
+
# ── Hero ─────────────────────────────────────────────────────────
|
| 113 |
+
gr.HTML(
|
| 114 |
+
"""
|
| 115 |
+
<div id="hero">
|
| 116 |
+
<div class="hero-moon">🌙</div>
|
| 117 |
+
<h1>রূপকথা · Rupkotha</h1>
|
| 118 |
+
<p>Show a picture, ask for a story — and hear it told in a warm
|
| 119 |
+
grandmother's voice.</p>
|
| 120 |
+
</div>
|
| 121 |
+
"""
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
with gr.Row(elem_id="studio", equal_height=False):
|
| 125 |
+
# ── Create panel ─────────────────────────────────────────────
|
| 126 |
+
with gr.Column(scale=5, elem_classes="panel"):
|
| 127 |
+
gr.HTML('<div class="panel-head"><span class="step">1</span>Choose</div>')
|
| 128 |
+
with gr.Row():
|
| 129 |
+
language = gr.Radio(
|
| 130 |
+
choices=_LANGUAGES, value="en",
|
| 131 |
+
label="Language · ভাষা", elem_classes="seg",
|
| 132 |
+
)
|
| 133 |
+
style = gr.Dropdown(
|
| 134 |
+
choices=_STYLE_CHOICES["en"], value=_STYLE_CHOICES["en"][0],
|
| 135 |
+
label="Story style",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
gr.HTML('<div class="panel-head"><span class="step">2</span>Show your pictures</div>')
|
| 139 |
+
images = gr.File(
|
| 140 |
+
file_count="multiple",
|
| 141 |
+
type="filepath",
|
| 142 |
+
file_types=["image"],
|
| 143 |
+
label="Drawings or toys — 1 to 4 pictures",
|
| 144 |
+
elem_classes="upload-box",
|
| 145 |
+
)
|
| 146 |
+
preview = gr.Gallery(
|
| 147 |
+
label="Your pictures",
|
| 148 |
+
columns=4,
|
| 149 |
+
height="auto",
|
| 150 |
+
object_fit="contain", # show the whole image, don't crop/trim
|
| 151 |
+
show_label=True,
|
| 152 |
+
visible=False,
|
| 153 |
+
elem_classes="preview",
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
gr.HTML('<div class="panel-head"><span class="step">3</span>Ask for a story</div>')
|
| 157 |
+
mic = gr.Audio(
|
| 158 |
+
sources=["microphone"],
|
| 159 |
+
type="filepath",
|
| 160 |
+
label="🎤 Speak your request (optional) — it fills the box below",
|
| 161 |
+
)
|
| 162 |
+
instruction = gr.Textbox(
|
| 163 |
+
label="What story do you want?",
|
| 164 |
+
placeholder="tell me a story about my dragon…",
|
| 165 |
+
lines=2,
|
| 166 |
+
)
|
| 167 |
+
child_name = gr.Textbox(
|
| 168 |
+
label="Your name (optional)",
|
| 169 |
+
placeholder="e.g. Rupa — woven into the story",
|
| 170 |
+
lines=1,
|
| 171 |
+
)
|
| 172 |
+
generate_btn = gr.Button(
|
| 173 |
+
"✨ Tell me a story", variant="primary", size="lg",
|
| 174 |
+
elem_id="generate-btn",
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# ── Story panel ──────────────────────────────────────────────
|
| 178 |
+
with gr.Column(scale=6, elem_classes="panel story-panel"):
|
| 179 |
+
gr.HTML('<div class="panel-head">📖 Your story</div>')
|
| 180 |
+
story_out = gr.Textbox(
|
| 181 |
+
show_label=False,
|
| 182 |
+
lines=8,
|
| 183 |
+
max_lines=40, # grow to fit the whole story (no inner scrollbar)
|
| 184 |
+
autoscroll=False,
|
| 185 |
+
placeholder="Your bedtime story will appear here… ✨",
|
| 186 |
+
elem_classes="story-text",
|
| 187 |
+
container=False,
|
| 188 |
+
)
|
| 189 |
+
audio_out = gr.Audio(label="🔊 Listen (press play to replay)", type="filepath")
|
| 190 |
+
badge_out = gr.Markdown(elem_classes="model-badge")
|
| 191 |
+
save_btn = gr.Button("💾 Save this story", elem_id="save-btn")
|
| 192 |
+
|
| 193 |
+
# ── Saved stories: last 3, each replayable (gr.State session memory) ─
|
| 194 |
+
current = gr.State(None)
|
| 195 |
+
history = gr.State([])
|
| 196 |
+
gr.HTML('<div class="section-title">🌟 Your saved stories</div>')
|
| 197 |
+
slots = []
|
| 198 |
+
with gr.Row(elem_id="history-row", equal_height=False):
|
| 199 |
+
for _ in range(HISTORY_SIZE):
|
| 200 |
+
with gr.Column(scale=1, min_width=240):
|
| 201 |
+
with gr.Group(visible=False, elem_classes="saved-card") as slot_group:
|
| 202 |
+
slot_md = gr.Markdown(elem_classes="saved-text")
|
| 203 |
+
slot_audio = gr.Audio(type="filepath", label="Replay")
|
| 204 |
+
slots.append((slot_group, slot_md, slot_audio))
|
| 205 |
+
|
| 206 |
+
# ── Wiring ───────────────────────────────────────────────────────
|
| 207 |
+
language.change(_styles_for, inputs=language, outputs=style)
|
| 208 |
+
|
| 209 |
+
# Show thumbnails of the uploaded pictures.
|
| 210 |
+
images.change(_preview, inputs=images, outputs=preview)
|
| 211 |
+
|
| 212 |
+
# Voice is a bonus: it fills the typed box, which stays primary (§2, §14).
|
| 213 |
+
mic.stop_recording(_voice_to_text, inputs=[mic, language], outputs=instruction)
|
| 214 |
+
|
| 215 |
+
generate_btn.click(
|
| 216 |
+
_tell_a_story,
|
| 217 |
+
inputs=[images, instruction, language, style, child_name],
|
| 218 |
+
outputs=[story_out, audio_out, badge_out, current],
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
# Flatten slots for the Save outputs: history + (group, md, audio) × N.
|
| 222 |
+
slot_outputs = [comp for slot in slots for comp in slot]
|
| 223 |
+
save_btn.click(
|
| 224 |
+
_save_story,
|
| 225 |
+
inputs=[current, history],
|
| 226 |
+
outputs=[history, *slot_outputs],
|
| 227 |
+
)
|
| 228 |
+
return demo
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
build_ui().queue().launch()
|
assets/styles.css
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Rupkotha — calm "storybook" theme. Professional, low-contrast, presentable.
|
| 2 |
+
Soft parchment/lavender background, deep slate-indigo ink, muted terracotta +
|
| 3 |
+
dusty-gold accents. Warm and gentle — not high-contrast. */
|
| 4 |
+
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Noto+Sans+Bengali:wght@500;600;700&family=Nunito:wght@400;500;600;700;800&display=swap');
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--bg-1: #eef0f7; /* soft lavender mist */
|
| 9 |
+
--bg-2: #f7f2ea; /* warm cream */
|
| 10 |
+
--ink: #333a5c; /* deep slate-indigo (headings) */
|
| 11 |
+
--ink-body: #4a5170; /* body text */
|
| 12 |
+
--ink-soft: #7b81a0; /* secondary/placeholder */
|
| 13 |
+
--panel: #ffffff;
|
| 14 |
+
--panel-2: #fbfaf6;
|
| 15 |
+
--panel-brd: rgba(51, 58, 92, 0.10);
|
| 16 |
+
--accent: #c4865a; /* muted terracotta */
|
| 17 |
+
--accent-2: #d6a463; /* dusty gold */
|
| 18 |
+
--accent-ink: #5a3a1e;
|
| 19 |
+
--story-bg: #fbf6ec; /* parchment */
|
| 20 |
+
--story-ink: #43392b;
|
| 21 |
+
--radius: 20px;
|
| 22 |
+
--radius-sm: 13px;
|
| 23 |
+
--shadow: 0 12px 34px rgba(46, 52, 88, 0.10);
|
| 24 |
+
--shadow-sm: 0 4px 14px rgba(46, 52, 88, 0.08);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* ── Background: soft, calm gradient (no harsh dark) ──────────────────── */
|
| 28 |
+
.gradio-container,
|
| 29 |
+
.gradio-container .fillable,
|
| 30 |
+
body {
|
| 31 |
+
background:
|
| 32 |
+
radial-gradient(900px 480px at 85% -12%, rgba(214, 164, 99, 0.10), transparent 60%),
|
| 33 |
+
radial-gradient(820px 460px at 5% -6%, rgba(150, 160, 210, 0.12), transparent 58%),
|
| 34 |
+
linear-gradient(165deg, var(--bg-1) 0%, var(--bg-2) 70%) !important;
|
| 35 |
+
color: var(--ink-body) !important;
|
| 36 |
+
font-family: 'Nunito', ui-sans-serif, system-ui, sans-serif;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.gradio-container { max-width: 100% !important; margin: 0 !important; padding: 0 28px !important; }
|
| 40 |
+
|
| 41 |
+
/* ── Hero ────────────────────────────────────────────────────────────── */
|
| 42 |
+
#hero { text-align: center; padding: 1.7rem 1rem 0.4rem; }
|
| 43 |
+
#hero .hero-moon {
|
| 44 |
+
font-size: 2.6rem; line-height: 1;
|
| 45 |
+
filter: drop-shadow(0 6px 14px rgba(196, 134, 90, 0.30));
|
| 46 |
+
animation: float 6s ease-in-out infinite;
|
| 47 |
+
}
|
| 48 |
+
@keyframes float { 0%,100%{ transform: translateY(0) } 50%{ transform: translateY(-6px) } }
|
| 49 |
+
#hero h1 {
|
| 50 |
+
font-family: 'Baloo 2', 'Noto Sans Bengali', sans-serif;
|
| 51 |
+
font-size: 2.5rem; font-weight: 700; margin: .3rem 0 .25rem;
|
| 52 |
+
color: var(--ink);
|
| 53 |
+
letter-spacing: .2px;
|
| 54 |
+
}
|
| 55 |
+
#hero p { color: var(--ink-soft); font-size: 1.05rem; max-width: 560px; margin: 0 auto; }
|
| 56 |
+
|
| 57 |
+
/* ── Panels (soft cards) ─────────────────────────────────────────────── */
|
| 58 |
+
#studio { gap: 22px !important; margin-top: .8rem; align-items: stretch; }
|
| 59 |
+
.panel {
|
| 60 |
+
background: var(--panel) !important;
|
| 61 |
+
border: 1px solid var(--panel-brd) !important;
|
| 62 |
+
border-radius: var(--radius) !important;
|
| 63 |
+
box-shadow: var(--shadow);
|
| 64 |
+
padding: 18px 20px !important;
|
| 65 |
+
}
|
| 66 |
+
.panel .block, .panel .form, .panel .gr-group, .panel fieldset {
|
| 67 |
+
background: transparent !important; border: none !important; box-shadow: none !important;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* Section headers inside panels. */
|
| 71 |
+
.panel-head {
|
| 72 |
+
display: flex; align-items: center; gap: .55rem;
|
| 73 |
+
font-family: 'Baloo 2', sans-serif; font-weight: 600;
|
| 74 |
+
font-size: 1.1rem; color: var(--ink);
|
| 75 |
+
margin: .7rem 0 .4rem;
|
| 76 |
+
}
|
| 77 |
+
.panel-head .step {
|
| 78 |
+
display: inline-grid; place-items: center;
|
| 79 |
+
width: 1.5rem; height: 1.5rem; border-radius: 50%;
|
| 80 |
+
background: linear-gradient(135deg, var(--accent-2), var(--accent));
|
| 81 |
+
color: #fff; font-size: .82rem; font-weight: 700;
|
| 82 |
+
box-shadow: var(--shadow-sm);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* ── Labels & text ───────────────────────────────────────────────────── */
|
| 86 |
+
.gradio-container label,
|
| 87 |
+
.gradio-container .gr-text { color: var(--ink-body) !important; }
|
| 88 |
+
.gradio-container label > span,
|
| 89 |
+
.gradio-container .gr-form > span { font-weight: 600 !important; color: var(--ink) !important; }
|
| 90 |
+
|
| 91 |
+
/* ── Inputs ──────────────────────────────────────────────────────────── */
|
| 92 |
+
.gradio-container input[type="text"],
|
| 93 |
+
.gradio-container textarea,
|
| 94 |
+
.gradio-container .gr-dropdown,
|
| 95 |
+
.gradio-container select {
|
| 96 |
+
background: var(--panel-2) !important;
|
| 97 |
+
border: 1px solid rgba(51, 58, 92, 0.14) !important;
|
| 98 |
+
border-radius: var(--radius-sm) !important;
|
| 99 |
+
color: var(--ink) !important;
|
| 100 |
+
font-size: 1.04rem !important;
|
| 101 |
+
}
|
| 102 |
+
.gradio-container input::placeholder,
|
| 103 |
+
.gradio-container textarea::placeholder { color: var(--ink-soft) !important; }
|
| 104 |
+
.gradio-container input:focus,
|
| 105 |
+
.gradio-container textarea:focus {
|
| 106 |
+
border-color: var(--accent) !important;
|
| 107 |
+
box-shadow: 0 0 0 3px rgba(196, 134, 90, 0.16) !important;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/* Upload drop zone. */
|
| 111 |
+
.upload-box, .upload-box .center, .upload-box .wrap {
|
| 112 |
+
border-radius: var(--radius-sm) !important;
|
| 113 |
+
border: 1.5px dashed rgba(196, 134, 90, 0.45) !important;
|
| 114 |
+
background: rgba(214, 164, 99, 0.06) !important;
|
| 115 |
+
color: var(--ink-body) !important;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ── Image preview gallery ───────────────────────────────────────────── */
|
| 119 |
+
.preview {
|
| 120 |
+
border-radius: var(--radius-sm) !important;
|
| 121 |
+
border: 1px solid var(--panel-brd) !important;
|
| 122 |
+
background: var(--panel-2) !important;
|
| 123 |
+
padding: 6px !important; margin-top: .4rem;
|
| 124 |
+
}
|
| 125 |
+
.preview .thumbnail-item, .preview img, .preview button.thumbnail-item {
|
| 126 |
+
border-radius: 10px !important;
|
| 127 |
+
box-shadow: var(--shadow-sm);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
| 131 |
+
#generate-btn {
|
| 132 |
+
font-family: 'Baloo 2', sans-serif !important;
|
| 133 |
+
font-size: 1.3rem !important; font-weight: 700 !important;
|
| 134 |
+
padding: .85rem 1.5rem !important; margin-top: .9rem !important; width: 100% !important;
|
| 135 |
+
background: linear-gradient(135deg, var(--accent-2), var(--accent)) !important;
|
| 136 |
+
color: #fff !important; border: none !important; border-radius: 999px !important;
|
| 137 |
+
box-shadow: 0 8px 22px rgba(196, 134, 90, 0.32) !important;
|
| 138 |
+
transition: transform .15s ease, box-shadow .15s ease !important;
|
| 139 |
+
}
|
| 140 |
+
#generate-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(196,134,90,.40) !important; }
|
| 141 |
+
#generate-btn:active { transform: translateY(0); }
|
| 142 |
+
#save-btn {
|
| 143 |
+
font-weight: 700 !important; border-radius: 999px !important;
|
| 144 |
+
border: 1.5px solid var(--accent) !important; color: var(--accent) !important;
|
| 145 |
+
background: transparent !important; margin-top: .5rem !important;
|
| 146 |
+
}
|
| 147 |
+
#save-btn:hover { background: rgba(196, 134, 90, 0.10) !important; }
|
| 148 |
+
|
| 149 |
+
/* ── Story panel ─────────────────────────────────────────────────────── */
|
| 150 |
+
.story-panel { display: flex; flex-direction: column; }
|
| 151 |
+
.story-text textarea {
|
| 152 |
+
font-family: 'Baloo 2', 'Noto Sans Bengali', serif !important;
|
| 153 |
+
font-size: 1.32rem !important; line-height: 1.85 !important;
|
| 154 |
+
background: var(--story-bg) !important;
|
| 155 |
+
color: var(--story-ink) !important;
|
| 156 |
+
border: 1px solid rgba(196, 134, 90, 0.18) !important;
|
| 157 |
+
border-radius: var(--radius-sm) !important;
|
| 158 |
+
padding: 1.1rem 1.3rem !important;
|
| 159 |
+
min-height: 180px; /* small floor for the empty state; grows to fit content */
|
| 160 |
+
height: auto !important;
|
| 161 |
+
overflow-y: auto;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* ── Model badge ─────────────────────────────────────────────────────── */
|
| 165 |
+
.model-badge p {
|
| 166 |
+
display: inline-block; margin: .3rem 0 0 !important;
|
| 167 |
+
background: rgba(214, 164, 99, 0.12);
|
| 168 |
+
border: 1px solid rgba(196, 134, 90, 0.30);
|
| 169 |
+
border-radius: 999px; padding: .35rem 1rem;
|
| 170 |
+
font-size: .82rem; color: var(--accent-ink) !important;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* ── Saved stories ───────────────────────────────────────────────────── */
|
| 174 |
+
.section-title {
|
| 175 |
+
font-family: 'Baloo 2', sans-serif; font-weight: 600; font-size: 1.28rem;
|
| 176 |
+
color: var(--ink); text-align: center; margin: 1.8rem 0 .7rem;
|
| 177 |
+
}
|
| 178 |
+
#history-row { gap: 16px !important; }
|
| 179 |
+
.saved-card {
|
| 180 |
+
background: var(--panel) !important;
|
| 181 |
+
border: 1px solid var(--panel-brd) !important;
|
| 182 |
+
border-radius: var(--radius) !important;
|
| 183 |
+
box-shadow: var(--shadow-sm); padding: 14px 16px !important;
|
| 184 |
+
}
|
| 185 |
+
.saved-text, .saved-text p {
|
| 186 |
+
font-family: 'Baloo 2', 'Noto Sans Bengali', sans-serif;
|
| 187 |
+
font-size: 1rem !important; line-height: 1.62; color: var(--story-ink) !important;
|
| 188 |
+
}
|
| 189 |
+
.saved-badge { display: inline-block; margin-top: .5rem; font-size: .72rem; color: var(--accent); }
|
| 190 |
+
|
| 191 |
+
#stack-note {
|
| 192 |
+
text-align: center; color: var(--ink-soft);
|
| 193 |
+
font-size: .9rem; margin: 1.8rem 0 1rem;
|
| 194 |
+
}
|
| 195 |
+
#stack-note b { color: var(--accent); }
|
| 196 |
+
|
| 197 |
+
/* ── Accessibility ───────────────────────────────────────────────────── */
|
| 198 |
+
.gradio-container *:focus-visible {
|
| 199 |
+
outline: 3px solid var(--accent) !important; outline-offset: 2px;
|
| 200 |
+
}
|
| 201 |
+
@media (prefers-reduced-motion: reduce) {
|
| 202 |
+
#hero .hero-moon { animation: none !important; }
|
| 203 |
+
.gradio-container * { animation: none !important; transition: none !important; }
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* ── Mobile ──────────────────────────────────────────────────────────── */
|
| 207 |
+
@media (max-width: 820px) {
|
| 208 |
+
#studio { flex-direction: column !important; }
|
| 209 |
+
#hero h1 { font-size: 2rem; }
|
| 210 |
+
.story-text textarea { font-size: 1.22rem !important; min-height: 240px; }
|
| 211 |
+
}
|
core/__init__.py
ADDED
|
File without changes
|
core/modal_infra.py
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/modal_infra.py — ALL Modal GPU functions live here.
|
| 3 |
+
|
| 4 |
+
app.py and the other core/ wrappers call these remote functions.
|
| 5 |
+
Nothing outside this file should import torch, transformers, parler_tts,
|
| 6 |
+
faster_whisper, or any other ML library directly.
|
| 7 |
+
|
| 8 |
+
GPU tier and container settings are read from model_config.get_compute()
|
| 9 |
+
so that changing MODAL_GPU or MODAL_MIN_CONTAINERS in model_config.py
|
| 10 |
+
propagates here automatically.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import base64
|
| 14 |
+
|
| 15 |
+
import modal
|
| 16 |
+
from core.model_config import get_compute, get_config
|
| 17 |
+
from core.prompts import SCENE_SENTINEL
|
| 18 |
+
|
| 19 |
+
_compute = get_compute()
|
| 20 |
+
|
| 21 |
+
app = modal.App("rupkotha")
|
| 22 |
+
|
| 23 |
+
# Vision image: Ollama serves every stack's vision model behind one uniform API
|
| 24 |
+
# (MiniCPM-V for Stack A, Gemma 3 for Stacks B/C). Switching stacks only changes
|
| 25 |
+
# the model tag passed in from model_config.py — never this code.
|
| 26 |
+
_ollama_image = (
|
| 27 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 28 |
+
# zstd is required by the Ollama install script to extract its release archive.
|
| 29 |
+
.apt_install("curl", "zstd")
|
| 30 |
+
.run_commands("curl -fsSL https://ollama.com/install.sh | sh")
|
| 31 |
+
.pip_install("ollama>=0.3")
|
| 32 |
+
# This module imports core.model_config at load; current Modal no longer
|
| 33 |
+
# auto-mounts the project, so make `core` importable in the container.
|
| 34 |
+
.add_local_python_source("core")
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Pulled Ollama models persist here across containers, so a model is downloaded
|
| 38 |
+
# at most once per stack (not on every cold start).
|
| 39 |
+
_ollama_volume = modal.Volume.from_name("rupkotha-ollama", create_if_missing=True)
|
| 40 |
+
|
| 41 |
+
# HuggingFace weights (TTS/STT) persist here so they download at most once.
|
| 42 |
+
_hf_volume = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
|
| 43 |
+
_HF_CACHE = "/root/.cache/huggingface"
|
| 44 |
+
|
| 45 |
+
# HF auth for gated repos (e.g. ai4bharat/indic-parler-tts). Reuses the existing
|
| 46 |
+
# 'algaeguard-secrets' Modal secret, which provides HF_TOKEN — huggingface_hub /
|
| 47 |
+
# transformers read HF_TOKEN from the environment automatically.
|
| 48 |
+
_hf_secrets = [modal.Secret.from_name("algaeguard-secrets")]
|
| 49 |
+
|
| 50 |
+
# Warm-container model caches for the TTS/STT singletons. Populated lazily inside
|
| 51 |
+
# their respective functions; reused across calls in the same container.
|
| 52 |
+
_indic_parler: dict = {}
|
| 53 |
+
_voxcpm: dict = {}
|
| 54 |
+
_whisper: dict = {}
|
| 55 |
+
_indictrans: dict = {}
|
| 56 |
+
_indictts: dict = {}
|
| 57 |
+
|
| 58 |
+
# ML image: used by the (still-stubbed) TTS/STT functions — transformers,
|
| 59 |
+
# faster-whisper, parler-tts. Kept separate from the vision image so each
|
| 60 |
+
# function pulls only what it needs and cold-starts faster.
|
| 61 |
+
_ml_image = (
|
| 62 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 63 |
+
.apt_install("git") # needed for the git-based parler-tts install below
|
| 64 |
+
.pip_install(
|
| 65 |
+
"torch>=2.2",
|
| 66 |
+
"transformers>=4.40",
|
| 67 |
+
"faster-whisper>=1.0",
|
| 68 |
+
"voxcpm", # English TTS — VoxCPM2 (needs Python < 3.13; 3.12 here is fine)
|
| 69 |
+
"indictranstoolkit", # IndicTrans2 pre/post-processing for the Bengali pivot
|
| 70 |
+
"soundfile",
|
| 71 |
+
"numpy",
|
| 72 |
+
)
|
| 73 |
+
.run_commands(
|
| 74 |
+
"pip install git+https://github.com/huggingface/parler-tts.git"
|
| 75 |
+
)
|
| 76 |
+
.add_local_python_source("core") # see note on _ollama_image above
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) image. AI4Bharat's repo isn't a Python
|
| 80 |
+
# package — inference just uses a Coqui `Synthesizer` over their checkpoints. We use
|
| 81 |
+
# the maintained `coqui-tts` fork (Synthesizer API unchanged) and skip their heavy
|
| 82 |
+
# Indic text-normalization/denoiser layer (fragile pinned deps; unneeded for our
|
| 83 |
+
# clean Bengali-script input). Python 3.10 for best coqui-tts/checkpoint compat.
|
| 84 |
+
_indictts_image = (
|
| 85 |
+
modal.Image.debian_slim(python_version="3.10")
|
| 86 |
+
.apt_install("wget", "unzip", "libsndfile1", "espeak-ng")
|
| 87 |
+
# coqui-tts 0.27 imports transformers.pytorch_utils.isin_mps_friendly, removed in
|
| 88 |
+
# transformers 5.x — pin to 4.x so `import TTS` (pulls XTTS/tortoise) works.
|
| 89 |
+
.pip_install("coqui-tts[codec]", "transformers<5", "torch", "torchaudio", "soundfile", "numpy")
|
| 90 |
+
.add_local_python_source("core")
|
| 91 |
+
)
|
| 92 |
+
# Persists the ~1.5 GB Bengali checkpoint zip (downloaded + unzipped once).
|
| 93 |
+
_indictts_volume = modal.Volume.from_name("rupkotha-indictts", create_if_missing=True)
|
| 94 |
+
|
| 95 |
+
_gpu = _compute["gpu"]
|
| 96 |
+
_min_containers = _compute["min_containers"]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _ensure_ollama_server() -> None:
|
| 100 |
+
"""Start `ollama serve` in the background if it isn't already responding.
|
| 101 |
+
|
| 102 |
+
Idempotent: safe to call on every invocation. Runs only inside the Modal
|
| 103 |
+
container (never in the local Gradio process)."""
|
| 104 |
+
import subprocess
|
| 105 |
+
import time
|
| 106 |
+
import urllib.request
|
| 107 |
+
|
| 108 |
+
def _ready() -> bool:
|
| 109 |
+
try:
|
| 110 |
+
urllib.request.urlopen("http://127.0.0.1:11434/api/version", timeout=1)
|
| 111 |
+
return True
|
| 112 |
+
except Exception:
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
if _ready():
|
| 116 |
+
return
|
| 117 |
+
subprocess.Popen(
|
| 118 |
+
["ollama", "serve"],
|
| 119 |
+
stdout=subprocess.DEVNULL,
|
| 120 |
+
stderr=subprocess.DEVNULL,
|
| 121 |
+
)
|
| 122 |
+
for _ in range(120): # up to ~60s for the server to come up
|
| 123 |
+
if _ready():
|
| 124 |
+
return
|
| 125 |
+
time.sleep(0.5)
|
| 126 |
+
raise RuntimeError("ollama server did not become ready in time")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 130 |
+
# Vision + story generation
|
| 131 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
@app.function(
|
| 134 |
+
gpu=_gpu,
|
| 135 |
+
image=_ollama_image,
|
| 136 |
+
volumes={"/root/.ollama": _ollama_volume},
|
| 137 |
+
timeout=900, # generous: covers a first-time model pull on a cold container
|
| 138 |
+
min_containers=_min_containers,
|
| 139 |
+
)
|
| 140 |
+
def run_vision_story(
|
| 141 |
+
image_bytes_list: list[bytes],
|
| 142 |
+
prompt: str,
|
| 143 |
+
model_id: str,
|
| 144 |
+
options: dict | None = None,
|
| 145 |
+
describe_prompt: str | None = None,
|
| 146 |
+
) -> str:
|
| 147 |
+
"""Generate a bedtime story from images + prompt via Ollama.
|
| 148 |
+
|
| 149 |
+
Model-agnostic: the call serves whatever Ollama tag `model_id` names — Stack A
|
| 150 |
+
uses MiniCPM-V 4.5. (Bengali is served separately by the fine-tuned model via
|
| 151 |
+
finetune/serve_vllm.py; this Ollama path handles English.)
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
image_bytes_list: Raw bytes of each uploaded image (1–4 images).
|
| 155 |
+
prompt: Full story-generation prompt (built by core/prompts.py).
|
| 156 |
+
model_id: Ollama model tag from get_config().vision_model
|
| 157 |
+
(e.g. 'openbmb/minicpm-v4.5').
|
| 158 |
+
options: Ollama decoding params (temperature, top_p, repeat_penalty, …).
|
| 159 |
+
Per-language profiles come from model_config.get_vision_options().
|
| 160 |
+
describe_prompt: Lever C (two-pass). When set, the model first DESCRIBES
|
| 161 |
+
the image(s) in English using this prompt, then narrates the story
|
| 162 |
+
text-only — `prompt` must contain SCENE_SENTINEL, which is replaced
|
| 163 |
+
with that description. Keeps perception and Bengali prose separate.
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
Generated story text, or '' on failure.
|
| 167 |
+
"""
|
| 168 |
+
import time
|
| 169 |
+
|
| 170 |
+
import ollama
|
| 171 |
+
|
| 172 |
+
_ensure_ollama_server()
|
| 173 |
+
|
| 174 |
+
# Pull on first use; the Volume keeps the weights warm for later calls.
|
| 175 |
+
# NOTE: the Stack A tag 'openbmb/minicpm-v4.5' is confirmed valid in the
|
| 176 |
+
# Ollama registry.
|
| 177 |
+
listed = ollama.list()
|
| 178 |
+
models = getattr(listed, "models", None) or listed.get("models", []) or []
|
| 179 |
+
local_tags = {(getattr(m, "model", None) or m.get("model", "")) for m in models}
|
| 180 |
+
if model_id not in local_tags and f"{model_id}:latest" not in local_tags:
|
| 181 |
+
print(f"[run_vision_story] pulling {model_id} (first use, may take minutes) ...")
|
| 182 |
+
ollama.pull(model_id)
|
| 183 |
+
_ollama_volume.commit()
|
| 184 |
+
print(f"[run_vision_story] pull complete for {model_id}")
|
| 185 |
+
|
| 186 |
+
chat_options = options or {"temperature": 0.8}
|
| 187 |
+
|
| 188 |
+
def _chat_once(prompt_text: str, images: list[bytes]) -> tuple[str, object]:
|
| 189 |
+
# The ollama client base64-encodes raw image bytes itself.
|
| 190 |
+
kwargs = dict(
|
| 191 |
+
model=model_id,
|
| 192 |
+
messages=[
|
| 193 |
+
{"role": "user", "content": prompt_text, "images": list(images or [])}
|
| 194 |
+
],
|
| 195 |
+
options=chat_options,
|
| 196 |
+
)
|
| 197 |
+
# think=False disables MiniCPM-V's reasoning mode, which can otherwise
|
| 198 |
+
# consume the whole token budget (done_reason='length') and leave the
|
| 199 |
+
# answer 'content' empty. Fall back for older ollama clients.
|
| 200 |
+
try:
|
| 201 |
+
resp = ollama.chat(**kwargs, think=False)
|
| 202 |
+
except TypeError:
|
| 203 |
+
resp = ollama.chat(**kwargs)
|
| 204 |
+
# ollama returns a typed ChatResponse (subscriptable) or a plain dict.
|
| 205 |
+
try:
|
| 206 |
+
c = resp["message"]["content"]
|
| 207 |
+
except Exception:
|
| 208 |
+
c = getattr(getattr(resp, "message", None), "content", "") or ""
|
| 209 |
+
return (c or "").strip(), resp
|
| 210 |
+
|
| 211 |
+
def _chat_retry(prompt_text: str, images: list[bytes]) -> str:
|
| 212 |
+
# A freshly started server sometimes returns empty on the very first call
|
| 213 |
+
# while the model finishes loading into VRAM — retry a couple of times.
|
| 214 |
+
text, resp = _chat_once(prompt_text, images)
|
| 215 |
+
for attempt in range(2):
|
| 216 |
+
if text:
|
| 217 |
+
break
|
| 218 |
+
print(f"[run_vision_story] empty content (attempt {attempt + 1}); resp={repr(resp)[:200]}")
|
| 219 |
+
time.sleep(2)
|
| 220 |
+
text, resp = _chat_once(prompt_text, images)
|
| 221 |
+
return text
|
| 222 |
+
|
| 223 |
+
if describe_prompt:
|
| 224 |
+
# Lever C, pass 1: describe the image(s) in English (the model's strength).
|
| 225 |
+
description = _chat_retry(describe_prompt, image_bytes_list)
|
| 226 |
+
print(f"[run_vision_story] scene description: {description[:200]}")
|
| 227 |
+
# Pass 2: narrate from the description, text-only (no image attached).
|
| 228 |
+
story_prompt = prompt.replace(SCENE_SENTINEL, description)
|
| 229 |
+
return _chat_retry(story_prompt, [])
|
| 230 |
+
|
| 231 |
+
# Single pass: image(s) + prompt together.
|
| 232 |
+
return _chat_retry(prompt, image_bytes_list)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def generate_story_remote(
|
| 236 |
+
images_b64: list[str],
|
| 237 |
+
prompt: str,
|
| 238 |
+
options: dict | None = None,
|
| 239 |
+
describe_prompt: str | None = None,
|
| 240 |
+
) -> str:
|
| 241 |
+
"""Plain-callable entry point used by core/vision_story.py.
|
| 242 |
+
|
| 243 |
+
Decodes the base64 images, reads the active vision model tag from
|
| 244 |
+
get_config() internally (model names never leave model_config.py), and
|
| 245 |
+
dispatches to the deployed Modal `run_vision_story` function. `options`
|
| 246 |
+
carries the per-language decoding profile from get_vision_options();
|
| 247 |
+
`describe_prompt` enables two-pass (Lever C) for Bengali.
|
| 248 |
+
|
| 249 |
+
Requires the Modal app to be deployed (`modal deploy core/modal_infra.py`)
|
| 250 |
+
and Modal credentials available to the Gradio process. May raise — callers
|
| 251 |
+
in core/vision_story.py wrap this in try/except and fall back to a friendly
|
| 252 |
+
bedtime message, so the app stays runnable even if Modal is unreachable.
|
| 253 |
+
"""
|
| 254 |
+
image_bytes_list = [base64.b64decode(b) for b in (images_b64 or [])]
|
| 255 |
+
model_id = get_config().vision_model
|
| 256 |
+
fn = modal.Function.from_name("rupkotha", "run_vision_story")
|
| 257 |
+
return fn.remote(image_bytes_list, prompt, model_id, options, describe_prompt)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def generate_story_ft_remote(images_b64: list[str], prompt: str) -> str:
|
| 261 |
+
"""Plain-callable entry point for the Bengali-fine-tuned model, deployed
|
| 262 |
+
separately as the `rupkotha-ft-serve` app (finetune/serve_vllm.py — merged
|
| 263 |
+
LoRA served via vLLM). Used by core/vision_story.py only when
|
| 264 |
+
model_config.FINETUNED_VISION_MODEL is set. Mirrors generate_story_remote's
|
| 265 |
+
contract; may raise — callers wrap in try/except. Kept here (not imported from
|
| 266 |
+
finetune/) so core/ stays independent of the training package."""
|
| 267 |
+
image_bytes_list = [base64.b64decode(b) for b in (images_b64 or [])]
|
| 268 |
+
fn = modal.Function.from_name("rupkotha-ft-serve", "run_vision_story_ft")
|
| 269 |
+
return fn.remote(image_bytes_list, prompt)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 273 |
+
# Translation — IndicTrans2 (English → Bengali "pivot" path)
|
| 274 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 275 |
+
|
| 276 |
+
@app.function(
|
| 277 |
+
gpu=_gpu,
|
| 278 |
+
image=_ml_image,
|
| 279 |
+
volumes={_HF_CACHE: _hf_volume},
|
| 280 |
+
secrets=_hf_secrets,
|
| 281 |
+
timeout=600, # generous: covers a first-time weight download on a cold start
|
| 282 |
+
min_containers=_min_containers,
|
| 283 |
+
)
|
| 284 |
+
def run_translate(text: str, src_lang: str, tgt_lang: str, model_repo: str) -> str:
|
| 285 |
+
"""Translate text with IndicTrans2 (e.g. English → Bengali).
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
text: Source text (may be multiple sentences).
|
| 289 |
+
src_lang / tgt_lang: IndicTrans2 FLORES codes ('eng_Latn', 'ben_Beng').
|
| 290 |
+
model_repo: HF repo from get_config-side TRANSLATION_MODEL.
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
Translated text, or '' on failure.
|
| 294 |
+
"""
|
| 295 |
+
import re
|
| 296 |
+
|
| 297 |
+
import torch
|
| 298 |
+
from IndicTransToolkit import IndicProcessor
|
| 299 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 300 |
+
|
| 301 |
+
if not (text or "").strip():
|
| 302 |
+
return ""
|
| 303 |
+
|
| 304 |
+
# Lazy singleton — load once per warm container.
|
| 305 |
+
if "model" not in _indictrans:
|
| 306 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 307 |
+
tokenizer = AutoTokenizer.from_pretrained(model_repo, trust_remote_code=True)
|
| 308 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(
|
| 309 |
+
model_repo, trust_remote_code=True
|
| 310 |
+
).to(device)
|
| 311 |
+
_indictrans.update(
|
| 312 |
+
model=model, tokenizer=tokenizer, ip=IndicProcessor(inference=True), device=device
|
| 313 |
+
)
|
| 314 |
+
_hf_volume.commit()
|
| 315 |
+
|
| 316 |
+
model = _indictrans["model"]
|
| 317 |
+
tokenizer = _indictrans["tokenizer"]
|
| 318 |
+
ip = _indictrans["ip"]
|
| 319 |
+
device = _indictrans["device"]
|
| 320 |
+
|
| 321 |
+
# IndicTrans2 translates sentence-by-sentence; split the story first.
|
| 322 |
+
sentences = [s.strip() for s in re.split(r"(?<=[.!?।])\s+", text.strip()) if s.strip()]
|
| 323 |
+
if not sentences:
|
| 324 |
+
return ""
|
| 325 |
+
|
| 326 |
+
batch = ip.preprocess_batch(sentences, src_lang=src_lang, tgt_lang=tgt_lang)
|
| 327 |
+
enc = tokenizer(
|
| 328 |
+
batch, padding="longest", truncation=True, max_length=256, return_tensors="pt"
|
| 329 |
+
).to(device)
|
| 330 |
+
with torch.inference_mode():
|
| 331 |
+
out = model.generate(**enc, num_beams=5, num_return_sequences=1, max_length=256)
|
| 332 |
+
decoded = tokenizer.batch_decode(
|
| 333 |
+
out, skip_special_tokens=True, clean_up_tokenization_spaces=True
|
| 334 |
+
)
|
| 335 |
+
translated = ip.postprocess_batch(decoded, lang=tgt_lang)
|
| 336 |
+
return " ".join(t.strip() for t in translated if t and t.strip())
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def translate_remote(text: str, src_language: str, tgt_language: str) -> str:
|
| 340 |
+
"""Plain-callable entry point used by core/vision_story.py for the Bengali
|
| 341 |
+
translation pivot. Resolves the model + FLORES codes from model_config and
|
| 342 |
+
dispatches to the deployed Modal `run_translate`. May raise — caller falls back.
|
| 343 |
+
"""
|
| 344 |
+
from core.model_config import TRANSLATION_MODEL, get_indictrans_code
|
| 345 |
+
|
| 346 |
+
src = get_indictrans_code(src_language)
|
| 347 |
+
tgt = get_indictrans_code(tgt_language)
|
| 348 |
+
fn = modal.Function.from_name("rupkotha", "run_translate")
|
| 349 |
+
return fn.remote(text, src, tgt, TRANSLATION_MODEL)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 353 |
+
# Bengali TTS — Indic Parler-TTS
|
| 354 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 355 |
+
|
| 356 |
+
@app.function(
|
| 357 |
+
gpu=_gpu,
|
| 358 |
+
image=_ml_image,
|
| 359 |
+
volumes={_HF_CACHE: _hf_volume},
|
| 360 |
+
secrets=_hf_secrets,
|
| 361 |
+
timeout=600, # generous: covers a cold-start model download + load
|
| 362 |
+
min_containers=_min_containers,
|
| 363 |
+
)
|
| 364 |
+
def run_tts_bengali(text: str, caption: str, model_repo: str) -> bytes:
|
| 365 |
+
"""Synthesise Bengali speech using Indic Parler-TTS.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
text: Story text in Bengali.
|
| 369 |
+
caption: Voice-description prompt that controls the speaker persona.
|
| 370 |
+
model_repo: HuggingFace repo ID from get_tts_repo() (e.g.
|
| 371 |
+
'ai4bharat/indic-parler-tts').
|
| 372 |
+
|
| 373 |
+
Returns:
|
| 374 |
+
WAV audio as bytes, or b'' on failure.
|
| 375 |
+
"""
|
| 376 |
+
import io
|
| 377 |
+
import re
|
| 378 |
+
|
| 379 |
+
import numpy as np
|
| 380 |
+
import soundfile as sf
|
| 381 |
+
import torch
|
| 382 |
+
from parler_tts import ParlerTTSForConditionalGeneration
|
| 383 |
+
from transformers import AutoTokenizer, set_seed
|
| 384 |
+
|
| 385 |
+
# Lazy singleton — load once per warm container, reuse across calls.
|
| 386 |
+
if "model" not in _indic_parler:
|
| 387 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 388 |
+
model = ParlerTTSForConditionalGeneration.from_pretrained(model_repo).to(device)
|
| 389 |
+
tokenizer = AutoTokenizer.from_pretrained(model_repo)
|
| 390 |
+
desc_tokenizer = AutoTokenizer.from_pretrained(
|
| 391 |
+
model.config.text_encoder._name_or_path
|
| 392 |
+
)
|
| 393 |
+
_indic_parler.update(
|
| 394 |
+
model=model,
|
| 395 |
+
tokenizer=tokenizer,
|
| 396 |
+
desc_tokenizer=desc_tokenizer,
|
| 397 |
+
device=device,
|
| 398 |
+
sampling_rate=model.config.sampling_rate,
|
| 399 |
+
)
|
| 400 |
+
_hf_volume.commit() # persist downloaded weights for the next cold start
|
| 401 |
+
|
| 402 |
+
model = _indic_parler["model"]
|
| 403 |
+
tokenizer = _indic_parler["tokenizer"]
|
| 404 |
+
desc_tokenizer = _indic_parler["desc_tokenizer"]
|
| 405 |
+
device = _indic_parler["device"]
|
| 406 |
+
|
| 407 |
+
sr = _indic_parler["sampling_rate"]
|
| 408 |
+
|
| 409 |
+
# Indic Parler-TTS caps each generation at ~30s of audio, so synthesising a
|
| 410 |
+
# whole 150-word story in one pass truncates it (audio stops mid-script) and
|
| 411 |
+
# rushes the prosody. Instead render it sentence-by-sentence and stitch the
|
| 412 |
+
# segments with a short silence — no cut-offs, and a natural bedtime pause at
|
| 413 |
+
# each punctuation mark.
|
| 414 |
+
def _chunk(t: str, max_chars: int = 220) -> list[str]:
|
| 415 |
+
# Split on Bengali daari (।) and ? ! … . — keep the delimiter attached,
|
| 416 |
+
# then pack consecutive sentences up to max_chars so chunks stay well
|
| 417 |
+
# under the ~30s generation limit.
|
| 418 |
+
parts = [p for p in re.split(r"(?<=[।!?.…])\s+", t.strip()) if p]
|
| 419 |
+
chunks, cur = [], ""
|
| 420 |
+
for p in parts:
|
| 421 |
+
if cur and len(cur) + len(p) + 1 > max_chars:
|
| 422 |
+
chunks.append(cur)
|
| 423 |
+
cur = p
|
| 424 |
+
else:
|
| 425 |
+
cur = f"{cur} {p}".strip()
|
| 426 |
+
if cur:
|
| 427 |
+
chunks.append(cur)
|
| 428 |
+
return chunks or [t.strip()]
|
| 429 |
+
|
| 430 |
+
desc = desc_tokenizer(caption, return_tensors="pt").to(device)
|
| 431 |
+
gap = np.zeros(int(sr * 0.35), dtype=np.float32) # ~0.35s pause between sentences
|
| 432 |
+
# Sample instead of greedy decoding: greedy makes Parler sound flat/monotone.
|
| 433 |
+
# do_sample + temperature gives more lively, natural prosody (the closest lever
|
| 434 |
+
# Bengali has — it has no emotion-prompt support, unlike VoxCPM2 for English).
|
| 435 |
+
# Fixed seed keeps a given story's audio reproducible across runs.
|
| 436 |
+
set_seed(0)
|
| 437 |
+
segments: list = []
|
| 438 |
+
for chunk in _chunk(text):
|
| 439 |
+
prompt = tokenizer(chunk, return_tensors="pt").to(device)
|
| 440 |
+
with torch.no_grad():
|
| 441 |
+
generation = model.generate(
|
| 442 |
+
input_ids=desc.input_ids,
|
| 443 |
+
attention_mask=desc.attention_mask,
|
| 444 |
+
prompt_input_ids=prompt.input_ids,
|
| 445 |
+
prompt_attention_mask=prompt.attention_mask,
|
| 446 |
+
do_sample=True,
|
| 447 |
+
temperature=1.0,
|
| 448 |
+
)
|
| 449 |
+
seg = generation.cpu().numpy().squeeze().astype(np.float32)
|
| 450 |
+
if seg.size == 0:
|
| 451 |
+
continue
|
| 452 |
+
segments.append(seg)
|
| 453 |
+
segments.append(gap)
|
| 454 |
+
|
| 455 |
+
if not segments:
|
| 456 |
+
return b""
|
| 457 |
+
audio = np.concatenate(segments[:-1]) # drop the trailing gap
|
| 458 |
+
buf = io.BytesIO()
|
| 459 |
+
sf.write(buf, audio, sr, format="WAV")
|
| 460 |
+
return buf.getvalue()
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
@app.function(
|
| 464 |
+
gpu=_gpu,
|
| 465 |
+
image=_indictts_image,
|
| 466 |
+
volumes={"/models": _indictts_volume},
|
| 467 |
+
timeout=1800, # first call downloads + unzips the ~1.5 GB checkpoint
|
| 468 |
+
min_containers=_min_containers,
|
| 469 |
+
)
|
| 470 |
+
def run_tts_indic_ai4bharat(text: str, checkpoint_url: str) -> bytes:
|
| 471 |
+
"""Synthesise Bengali speech with AI4Bharat Indic-TTS (FastPitch + HiFi-GAN).
|
| 472 |
+
|
| 473 |
+
No reference clip — a dedicated, MOS-tuned Bengali acoustic model with a fixed
|
| 474 |
+
voice. The language checkpoint zip is downloaded + unzipped once into a volume.
|
| 475 |
+
|
| 476 |
+
Returns WAV bytes (model sample rate), or b'' on failure.
|
| 477 |
+
"""
|
| 478 |
+
import glob
|
| 479 |
+
import io
|
| 480 |
+
import os
|
| 481 |
+
import re
|
| 482 |
+
import subprocess
|
| 483 |
+
|
| 484 |
+
import numpy as np
|
| 485 |
+
import soundfile as sf
|
| 486 |
+
import torch
|
| 487 |
+
|
| 488 |
+
ckpt_dir = "/models/indic_tts_bn"
|
| 489 |
+
# One-time download + unzip into the persistent volume.
|
| 490 |
+
if not glob.glob(f"{ckpt_dir}/**/fastpitch/best_model.pth", recursive=True):
|
| 491 |
+
os.makedirs(ckpt_dir, exist_ok=True)
|
| 492 |
+
zip_path = "/tmp/indictts_bn.zip"
|
| 493 |
+
subprocess.run(["wget", "-q", "-O", zip_path, checkpoint_url], check=True)
|
| 494 |
+
subprocess.run(["unzip", "-o", "-q", zip_path, "-d", ckpt_dir], check=True)
|
| 495 |
+
os.remove(zip_path)
|
| 496 |
+
_indictts_volume.commit()
|
| 497 |
+
|
| 498 |
+
def _cfg_near(ckpt_path: str) -> str | None:
|
| 499 |
+
d = os.path.dirname(ckpt_path)
|
| 500 |
+
for cand in (os.path.join(d, "config.json"),
|
| 501 |
+
os.path.join(os.path.dirname(d), "config.json")):
|
| 502 |
+
if os.path.exists(cand):
|
| 503 |
+
return cand
|
| 504 |
+
hits = glob.glob(os.path.join(os.path.dirname(d), "**", "config.json"), recursive=True)
|
| 505 |
+
return hits[0] if hits else None
|
| 506 |
+
|
| 507 |
+
# Lazy singleton — build the Coqui Synthesizer once per warm container.
|
| 508 |
+
if "syn" not in _indictts:
|
| 509 |
+
from TTS.utils.synthesizer import Synthesizer
|
| 510 |
+
|
| 511 |
+
fp = sorted(glob.glob(f"{ckpt_dir}/**/fastpitch/best_model.pth", recursive=True))
|
| 512 |
+
voc = sorted(glob.glob(f"{ckpt_dir}/**/hifigan/best_model.pth", recursive=True))
|
| 513 |
+
if not fp or not voc:
|
| 514 |
+
print("[indic_tts] checkpoints not found:", glob.glob(f"{ckpt_dir}/**", recursive=True)[:20])
|
| 515 |
+
return b""
|
| 516 |
+
import json
|
| 517 |
+
|
| 518 |
+
spk_file = os.path.join(os.path.dirname(fp[0]), "speakers.pth")
|
| 519 |
+
fp_cfg_path = _cfg_near(fp[0])
|
| 520 |
+
# The config bakes a RELATIVE speakers_file path from AI4Bharat's training
|
| 521 |
+
# tree (resolved against CWD → FileNotFoundError). Rewrite it to our
|
| 522 |
+
# absolute path so coqui loads the right speaker map.
|
| 523 |
+
if fp_cfg_path and os.path.exists(spk_file):
|
| 524 |
+
with open(fp_cfg_path) as f:
|
| 525 |
+
cfg_json = json.load(f)
|
| 526 |
+
cfg_json["speakers_file"] = spk_file
|
| 527 |
+
if isinstance(cfg_json.get("model_args"), dict):
|
| 528 |
+
cfg_json["model_args"]["speakers_file"] = spk_file
|
| 529 |
+
fp_cfg_path = "/tmp/fastpitch_config_patched.json"
|
| 530 |
+
with open(fp_cfg_path, "w") as f:
|
| 531 |
+
json.dump(cfg_json, f)
|
| 532 |
+
syn = Synthesizer(
|
| 533 |
+
tts_checkpoint=fp[0],
|
| 534 |
+
tts_config_path=fp_cfg_path,
|
| 535 |
+
tts_speakers_file=spk_file if os.path.exists(spk_file) else None,
|
| 536 |
+
vocoder_checkpoint=voc[0],
|
| 537 |
+
vocoder_config=_cfg_near(voc[0]),
|
| 538 |
+
use_cuda=torch.cuda.is_available(),
|
| 539 |
+
)
|
| 540 |
+
_indictts["syn"] = syn
|
| 541 |
+
# Multi-speaker model (trained on male+female): prefer the female voice for
|
| 542 |
+
# the grandmother persona. AI4Bharat names them literally "male"/"female".
|
| 543 |
+
speaker = None
|
| 544 |
+
try:
|
| 545 |
+
names = list(syn.tts_model.speaker_manager.name_to_id.keys())
|
| 546 |
+
speaker = next((s for s in names if "fem" in s.lower()), names[0]) if names else None
|
| 547 |
+
except Exception: # noqa: BLE001 — single-speaker model, no manager
|
| 548 |
+
speaker = None
|
| 549 |
+
_indictts["speaker"] = speaker
|
| 550 |
+
print("[indic_tts] loaded; speakers available:",
|
| 551 |
+
locals().get("names", "?"), "| using:", speaker)
|
| 552 |
+
|
| 553 |
+
syn = _indictts["syn"]
|
| 554 |
+
speaker = _indictts.get("speaker")
|
| 555 |
+
sr = syn.output_sample_rate
|
| 556 |
+
|
| 557 |
+
def _chunk(t: str, max_chars: int = 220, min_chars: int = 8) -> list[str]:
|
| 558 |
+
parts = [p for p in re.split(r"(?<=[।!?.…])\s+", t.strip()) if p.strip()]
|
| 559 |
+
chunks, cur = [], ""
|
| 560 |
+
for p in parts:
|
| 561 |
+
if cur and len(cur) + len(p) + 1 > max_chars:
|
| 562 |
+
chunks.append(cur)
|
| 563 |
+
cur = p
|
| 564 |
+
else:
|
| 565 |
+
cur = f"{cur} {p}".strip()
|
| 566 |
+
if cur:
|
| 567 |
+
chunks.append(cur)
|
| 568 |
+
# Merge too-short fragments (a lone quote/word) into a neighbour — FastPitch's
|
| 569 |
+
# conv kernel errors when a chunk is shorter than the kernel size.
|
| 570 |
+
merged: list = []
|
| 571 |
+
for c in chunks:
|
| 572 |
+
if merged and len(c.strip()) < min_chars:
|
| 573 |
+
merged[-1] = (merged[-1] + " " + c).strip()
|
| 574 |
+
else:
|
| 575 |
+
merged.append(c)
|
| 576 |
+
if len(merged) > 1 and len(merged[0].strip()) < min_chars:
|
| 577 |
+
merged[1] = (merged[0] + " " + merged[1]).strip()
|
| 578 |
+
merged = merged[1:]
|
| 579 |
+
return [c for c in merged if c.strip()] or [t.strip()]
|
| 580 |
+
|
| 581 |
+
gap = np.zeros(int(sr * 0.35), dtype=np.float32)
|
| 582 |
+
segments: list = []
|
| 583 |
+
for chunk in _chunk(text):
|
| 584 |
+
if len(chunk.strip()) < 2: # never feed FastPitch a 1-char chunk
|
| 585 |
+
continue
|
| 586 |
+
kwargs = {"speaker_name": speaker} if speaker else {}
|
| 587 |
+
try:
|
| 588 |
+
wav = syn.tts(chunk, **kwargs)
|
| 589 |
+
except Exception as e: # noqa: BLE001 — skip a bad chunk, don't fail the story
|
| 590 |
+
print(f"[indic_tts] chunk skipped ({e}): {chunk[:40]!r}", flush=True)
|
| 591 |
+
continue
|
| 592 |
+
seg = np.asarray(wav, dtype=np.float32).squeeze()
|
| 593 |
+
if seg.size == 0:
|
| 594 |
+
continue
|
| 595 |
+
segments.append(seg)
|
| 596 |
+
segments.append(gap)
|
| 597 |
+
|
| 598 |
+
if not segments:
|
| 599 |
+
return b""
|
| 600 |
+
audio = np.concatenate(segments[:-1])
|
| 601 |
+
buf = io.BytesIO()
|
| 602 |
+
sf.write(buf, audio, sr, format="WAV")
|
| 603 |
+
return buf.getvalue()
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def synthesize_bengali_remote(text: str, caption: str) -> bytes:
|
| 607 |
+
"""Plain-callable entry point used by core/tts.py for the Bengali path.
|
| 608 |
+
|
| 609 |
+
Reads the active Bengali TTS backend from get_config() and dispatches to the
|
| 610 |
+
right deployed Modal function. May raise — core/tts.py wraps this and falls
|
| 611 |
+
back to text-only so audio failure never breaks the app.
|
| 612 |
+
|
| 613 |
+
- 'indic_tts': AI4Bharat Indic-TTS (FastPitch + HiFi-GAN), no reference clip.
|
| 614 |
+
- 'indic_parler': description-controlled Indic Parler-TTS.
|
| 615 |
+
"""
|
| 616 |
+
from core.model_config import get_tts_repo
|
| 617 |
+
|
| 618 |
+
cfg = get_config()
|
| 619 |
+
backend = cfg.tts_bn_backend
|
| 620 |
+
|
| 621 |
+
if backend == "indic_tts":
|
| 622 |
+
# AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) — no caption/reference; the
|
| 623 |
+
# 'repo' here is the checkpoint-zip URL.
|
| 624 |
+
fn = modal.Function.from_name("rupkotha", "run_tts_indic_ai4bharat")
|
| 625 |
+
return fn.remote(text, get_tts_repo("indic_tts"))
|
| 626 |
+
|
| 627 |
+
fn = modal.Function.from_name("rupkotha", "run_tts_bengali")
|
| 628 |
+
return fn.remote(text, caption, get_tts_repo("indic_parler"))
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 632 |
+
# English TTS — VoxCPM2
|
| 633 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 634 |
+
|
| 635 |
+
@app.function(
|
| 636 |
+
gpu=_gpu,
|
| 637 |
+
image=_ml_image,
|
| 638 |
+
volumes={_HF_CACHE: _hf_volume},
|
| 639 |
+
secrets=_hf_secrets,
|
| 640 |
+
timeout=900, # generous: covers a cold-start model download + load
|
| 641 |
+
min_containers=_min_containers,
|
| 642 |
+
)
|
| 643 |
+
def run_tts_english(text: str, voice_prompt: str, model_repo: str) -> bytes:
|
| 644 |
+
"""Synthesise English speech using VoxCPM2 Voice Design.
|
| 645 |
+
|
| 646 |
+
The persona is supplied via Voice Design: VoxCPM2 reads a parenthetical
|
| 647 |
+
description at the start of the text and generates a matching novel voice
|
| 648 |
+
(no reference audio needed).
|
| 649 |
+
|
| 650 |
+
Args:
|
| 651 |
+
text: Story text in English.
|
| 652 |
+
voice_prompt: Voice Design persona description (without parentheses).
|
| 653 |
+
model_repo: HuggingFace repo ID from get_tts_repo() (e.g. 'openbmb/VoxCPM2').
|
| 654 |
+
|
| 655 |
+
Returns:
|
| 656 |
+
WAV audio as bytes, or b'' on failure.
|
| 657 |
+
"""
|
| 658 |
+
import io
|
| 659 |
+
|
| 660 |
+
import soundfile as sf
|
| 661 |
+
from voxcpm import VoxCPM
|
| 662 |
+
|
| 663 |
+
# Lazy singleton — load once per warm container, reuse across calls.
|
| 664 |
+
if "model" not in _voxcpm:
|
| 665 |
+
model = VoxCPM.from_pretrained(model_repo, load_denoiser=False)
|
| 666 |
+
_voxcpm.update(model=model, sampling_rate=model.tts_model.sample_rate)
|
| 667 |
+
_hf_volume.commit() # persist downloaded weights for the next cold start
|
| 668 |
+
|
| 669 |
+
model = _voxcpm["model"]
|
| 670 |
+
# Voice Design: the persona goes in parentheses at the start of the text.
|
| 671 |
+
design_text = f"({voice_prompt}){text}"
|
| 672 |
+
audio = model.generate(
|
| 673 |
+
text=design_text,
|
| 674 |
+
cfg_value=2.0,
|
| 675 |
+
inference_timesteps=10,
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
buf = io.BytesIO()
|
| 679 |
+
sf.write(buf, audio, _voxcpm["sampling_rate"], format="WAV")
|
| 680 |
+
return buf.getvalue()
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
def synthesize_english_remote(text: str, voice_prompt: str) -> bytes:
|
| 684 |
+
"""Plain-callable entry point used by core/tts.py for the English path.
|
| 685 |
+
|
| 686 |
+
Reads the active English TTS backend from get_config(), resolves it to a
|
| 687 |
+
repo ID via get_tts_repo(), and dispatches to the deployed Modal
|
| 688 |
+
`run_tts_english` function. May raise — core/tts.py wraps this and falls
|
| 689 |
+
back to text-only so audio failure never breaks the app.
|
| 690 |
+
"""
|
| 691 |
+
from core.model_config import get_tts_repo
|
| 692 |
+
|
| 693 |
+
repo = get_tts_repo(get_config().tts_en_backend)
|
| 694 |
+
fn = modal.Function.from_name("rupkotha", "run_tts_english")
|
| 695 |
+
return fn.remote(text, voice_prompt, repo)
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
# ────────────────────────────────────────────────────���────────────────────────
|
| 699 |
+
# Speech-to-text — faster-whisper
|
| 700 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 701 |
+
|
| 702 |
+
def _stt_faster_whisper(audio_bytes: bytes, model_size: str, language: str) -> str:
|
| 703 |
+
"""Transcribe with faster-whisper (used for size tags like 'large-v3')."""
|
| 704 |
+
import io
|
| 705 |
+
|
| 706 |
+
import torch
|
| 707 |
+
from faster_whisper import WhisperModel
|
| 708 |
+
|
| 709 |
+
key = ("fw", model_size)
|
| 710 |
+
if key not in _whisper:
|
| 711 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 712 |
+
compute_type = "float16" if device == "cuda" else "int8"
|
| 713 |
+
_whisper[key] = WhisperModel(
|
| 714 |
+
model_size, device=device, compute_type=compute_type, download_root=_HF_CACHE
|
| 715 |
+
)
|
| 716 |
+
_hf_volume.commit() # persist the downloaded model for the next cold start
|
| 717 |
+
|
| 718 |
+
model = _whisper[key]
|
| 719 |
+
segments, _ = model.transcribe(
|
| 720 |
+
io.BytesIO(audio_bytes), language=language, vad_filter=True
|
| 721 |
+
)
|
| 722 |
+
return "".join(seg.text for seg in segments).strip()
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def _stt_transformers(audio_bytes: bytes, model_repo: str) -> str:
|
| 726 |
+
"""Transcribe with a HF transformers ASR checkpoint (e.g. a Bengali-specific
|
| 727 |
+
fine-tuned Whisper). The pipeline ffmpeg-decodes raw bytes and resamples."""
|
| 728 |
+
import torch
|
| 729 |
+
from transformers import pipeline
|
| 730 |
+
|
| 731 |
+
key = ("hf", model_repo)
|
| 732 |
+
if key not in _whisper:
|
| 733 |
+
device = 0 if torch.cuda.is_available() else -1
|
| 734 |
+
_whisper[key] = pipeline(
|
| 735 |
+
"automatic-speech-recognition", model=model_repo, device=device
|
| 736 |
+
)
|
| 737 |
+
_hf_volume.commit()
|
| 738 |
+
|
| 739 |
+
result = _whisper[key](audio_bytes)
|
| 740 |
+
return (result.get("text") or "").strip()
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
@app.function(
|
| 744 |
+
gpu=_gpu,
|
| 745 |
+
image=_ml_image,
|
| 746 |
+
volumes={_HF_CACHE: _hf_volume},
|
| 747 |
+
secrets=_hf_secrets,
|
| 748 |
+
timeout=600, # generous: covers a cold-start model download + load
|
| 749 |
+
min_containers=_min_containers,
|
| 750 |
+
)
|
| 751 |
+
def run_stt(audio_bytes: bytes, language: str, model: str) -> str:
|
| 752 |
+
"""Transcribe audio to text.
|
| 753 |
+
|
| 754 |
+
Two backends, chosen by the model identifier:
|
| 755 |
+
- a faster-whisper size tag (e.g. 'large-v3', no '/') → faster-whisper
|
| 756 |
+
- a HuggingFace repo (e.g. 'bangla-asr/whisper-medium-bn', has '/')
|
| 757 |
+
→ transformers ASR pipeline (Bengali-specific models)
|
| 758 |
+
|
| 759 |
+
Args:
|
| 760 |
+
audio_bytes: Raw audio bytes (any format ffmpeg accepts).
|
| 761 |
+
language: 'en' or 'bn'.
|
| 762 |
+
model: stt_model (EN) or stt_bn_model (BN) from get_config().
|
| 763 |
+
|
| 764 |
+
Returns:
|
| 765 |
+
Transcribed text, or '' on failure (caller falls back to typed input).
|
| 766 |
+
"""
|
| 767 |
+
if not audio_bytes:
|
| 768 |
+
return ""
|
| 769 |
+
try:
|
| 770 |
+
if "/" in model:
|
| 771 |
+
return _stt_transformers(audio_bytes, model)
|
| 772 |
+
return _stt_faster_whisper(audio_bytes, model, language)
|
| 773 |
+
except Exception as e: # noqa: BLE001 — never raise; caller falls back to text
|
| 774 |
+
print(f"[modal_infra] run_stt failed: {e}")
|
| 775 |
+
return ""
|
| 776 |
+
|
| 777 |
+
|
| 778 |
+
def transcribe_remote(audio_bytes: bytes, language: str, model: str) -> str:
|
| 779 |
+
"""Plain-callable entry point used by core/stt.py.
|
| 780 |
+
|
| 781 |
+
Dispatches to the deployed Modal `run_stt` function. May raise — core/stt.py
|
| 782 |
+
wraps this and returns '' so the caller falls back to typed input.
|
| 783 |
+
"""
|
| 784 |
+
fn = modal.Function.from_name("rupkotha", "run_stt")
|
| 785 |
+
return fn.remote(audio_bytes, language, model)
|
core/model_config.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/model_config.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
# UI-only mock mode for local development: set RUPKOTHA_MOCK=1 to make the core
|
| 7 |
+
# wrappers return canned story/audio/transcript WITHOUT calling Modal (no GPU, no
|
| 8 |
+
# cost). Lets you iterate on layout/CSS/aesthetics offline. Default off (real models).
|
| 9 |
+
UI_MOCK: bool = os.environ.get("RUPKOTHA_MOCK", "") == "1"
|
| 10 |
+
|
| 11 |
+
# ─────────────────────────────────────────────
|
| 12 |
+
# ACTIVE STACK — Stack A is the sole submission stack (OpenBMB prize path).
|
| 13 |
+
# The StackConfig machinery below is kept so a stack could be added/swapped, but
|
| 14 |
+
# only "A" is defined; Stacks B/C were dropped.
|
| 15 |
+
ACTIVE_STACK: str = "A"
|
| 16 |
+
# ─────────────────────────────────────────────
|
| 17 |
+
# WHERE INFERENCE RUNS
|
| 18 |
+
# "modal" → models served on Modal cloud GPUs (current; see CLAUDE.md infra note)
|
| 19 |
+
# "local" → models served on the local machine (legacy offline path; unsupported)
|
| 20 |
+
COMPUTE_LOCATION: str = "modal"
|
| 21 |
+
# Modal GPU tier for inference containers. A10G (24 GB) covers Stack A's runtime.
|
| 22 |
+
MODAL_GPU: str = "A10G"
|
| 23 |
+
# Keep one container warm to hide cold starts. =1 during a demo (no cold start,
|
| 24 |
+
# but bills idle GPU — incl. the A100 FT-serve container, so revert to 0 after).
|
| 25 |
+
# =0 scales to zero and only bills per request (cold start ~3-5 min on first call).
|
| 26 |
+
MODAL_MIN_CONTAINERS: int = 0
|
| 27 |
+
# ─────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class StackConfig:
|
| 32 |
+
name: str
|
| 33 |
+
description: str
|
| 34 |
+
vision_model: str # Ollama model tag
|
| 35 |
+
vision_backend: str # "ollama" (all stacks use Ollama)
|
| 36 |
+
stt_model: str # faster-whisper model size
|
| 37 |
+
stt_bn_model: str | None # optional Bengali-specific STT model (HF repo)
|
| 38 |
+
tts_en_backend: str # "voxcpm2"
|
| 39 |
+
tts_bn_backend: str # "indic_tts" (chosen) | "indic_parler" (alt)
|
| 40 |
+
tts_bn_ref_audio: str | None # unused (was for the removed IndicF5 voice-clone)
|
| 41 |
+
total_params_b: float # informational — for README generation
|
| 42 |
+
openbmb_prize_eligible: bool
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
STACKS: dict[str, StackConfig] = {
|
| 46 |
+
"A": StackConfig(
|
| 47 |
+
name="Stack A — OpenBMB Prize Path",
|
| 48 |
+
description="MiniCPM-V 4.5 + VoxCPM2 + AI4Bharat Indic-TTS. ~12.4B. OpenBMB prize eligible.",
|
| 49 |
+
# Default (~Q4, 6.1GB). q8_0 was tested and gave NO Bengali quality gain at
|
| 50 |
+
# higher cost/latency — precision is not the bottleneck, the 8B model's
|
| 51 |
+
# Bengali capability is. Bengali quality is addressed via two-pass (Lever C).
|
| 52 |
+
vision_model="openbmb/minicpm-v4.5",
|
| 53 |
+
vision_backend="ollama",
|
| 54 |
+
stt_model="large-v3",
|
| 55 |
+
stt_bn_model="bangla-asr/whisper-medium-bn",
|
| 56 |
+
tts_en_backend="voxcpm2",
|
| 57 |
+
# Bengali TTS: AI4Bharat Indic-TTS (FastPitch). Chosen over Indic Parler-TTS
|
| 58 |
+
# (sounded artificial) and IndicF5 (voice clone — removed: too slow even on
|
| 59 |
+
# A100, needs a reference clip). FastPitch is fast, no reference needed.
|
| 60 |
+
tts_bn_backend="indic_tts",
|
| 61 |
+
tts_bn_ref_audio=None,
|
| 62 |
+
total_params_b=12.4,
|
| 63 |
+
openbmb_prize_eligible=True,
|
| 64 |
+
),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_config() -> StackConfig:
|
| 69 |
+
"""Returns the active stack config. Import this everywhere model details are needed."""
|
| 70 |
+
if ACTIVE_STACK not in STACKS:
|
| 71 |
+
raise ValueError(
|
| 72 |
+
f"ACTIVE_STACK='{ACTIVE_STACK}' is not valid. Stack A is the only defined stack."
|
| 73 |
+
)
|
| 74 |
+
return STACKS[ACTIVE_STACK]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_all_stacks() -> dict[str, StackConfig]:
|
| 78 |
+
"""Returns all defined stacks (currently just Stack A)."""
|
| 79 |
+
return STACKS
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# HF repo IDs for the TTS backends. Model names live ONLY in this file — the
|
| 83 |
+
# StackConfig stores the backend *key* ('voxcpm2' | 'indic_parler' | 'indic_tts');
|
| 84 |
+
# this maps that key to the actual repo passed to core/modal_infra.py.
|
| 85 |
+
TTS_BACKEND_REPOS: dict[str, str] = {
|
| 86 |
+
"voxcpm2": "openbmb/VoxCPM2", # English (Voice Design)
|
| 87 |
+
"indic_parler": "ai4bharat/indic-parler-tts",
|
| 88 |
+
# AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) — no HF repo; the value is the
|
| 89 |
+
# GitHub-release checkpoint zip (per-language). Bengali = bn.zip (~1.5 GB).
|
| 90 |
+
# Dedicated, MOS-tuned, no reference clip; fixed voice (no persona control).
|
| 91 |
+
"indic_tts": "https://github.com/AI4Bharat/Indic-TTS/releases/download/v1-checkpoints-release/bn.zip",
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def get_tts_repo(backend: str) -> str:
|
| 96 |
+
"""Resolve a TTS backend key to its HuggingFace repo ID."""
|
| 97 |
+
try:
|
| 98 |
+
return TTS_BACKEND_REPOS[backend]
|
| 99 |
+
except KeyError:
|
| 100 |
+
raise ValueError(
|
| 101 |
+
f"Unknown TTS backend '{backend}'. Known: {list(TTS_BACKEND_REPOS)}"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Per-language decoding params for the vision/story model (passed to Ollama).
|
| 106 |
+
# Bengali uses a more conservative profile: lower temperature + min_p floor +
|
| 107 |
+
# repetition penalty suppress the wrong-script (Latin) tokens, invented non-words,
|
| 108 |
+
# and phrase repetition that high-temperature sampling produces in a lower-resource
|
| 109 |
+
# language. English can afford a livelier profile. Tune these here only.
|
| 110 |
+
VISION_GEN_OPTIONS: dict[str, dict] = {
|
| 111 |
+
"en": {
|
| 112 |
+
"temperature": 0.8,
|
| 113 |
+
"top_p": 0.95,
|
| 114 |
+
"repeat_penalty": 1.1,
|
| 115 |
+
"num_predict": 500, # bound the response; a bedtime story is short
|
| 116 |
+
},
|
| 117 |
+
"bn": {
|
| 118 |
+
"temperature": 0.45,
|
| 119 |
+
"top_p": 0.9,
|
| 120 |
+
"top_k": 40,
|
| 121 |
+
"min_p": 0.05,
|
| 122 |
+
"repeat_penalty": 1.18,
|
| 123 |
+
"repeat_last_n": 64,
|
| 124 |
+
"num_predict": 700, # Bengali uses more tokens per word; still bounded
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def get_vision_options(language: str) -> dict:
|
| 130 |
+
"""Return a copy of the decoding params for the given language ('en'|'bn')."""
|
| 131 |
+
return dict(VISION_GEN_OPTIONS.get(language, VISION_GEN_OPTIONS["en"]))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Translation-pivot path (research option #1): MiniCPM writes the story in English
|
| 135 |
+
# (its strength), then IndicTrans2 translates it to fluent Bengali. Model name lives
|
| 136 |
+
# here only. 1B (gated, MIT) for quality; dist-200M is the faster/smaller option.
|
| 137 |
+
TRANSLATION_MODEL = "ai4bharat/indictrans2-en-indic-1B"
|
| 138 |
+
# IndicTrans2 FLORES-style language codes.
|
| 139 |
+
INDICTRANS_LANG_CODES: dict[str, str] = {"en": "eng_Latn", "bn": "ben_Beng"}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def get_indictrans_code(language: str) -> str:
|
| 143 |
+
"""Map our 'en'/'bn' codes to IndicTrans2's FLORES codes."""
|
| 144 |
+
try:
|
| 145 |
+
return INDICTRANS_LANG_CODES[language]
|
| 146 |
+
except KeyError:
|
| 147 |
+
raise ValueError(f"No IndicTrans2 code for language '{language}'.")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ── Bengali distillation fine-tuning (see finetune/) ────────────────────────
|
| 151 |
+
# Teacher that writes native Bengali story labels from a drawing. Gemma 3 is
|
| 152 |
+
# multimodal and writes excellent Bengali (চাঁদমামা/পুকুর register). 27B gives the
|
| 153 |
+
# best labels (fewer code-switch leaks); 12B is faster. Label quality caps the
|
| 154 |
+
# student, and data-gen is a one-time job, so quality is prioritised here.
|
| 155 |
+
TEACHER_MODEL = "gemma3:27b"
|
| 156 |
+
# Base student that gets fine-tuned (the Stack A vision model, HF repo form for
|
| 157 |
+
# training — Ollama tag form for serving is in the StackConfig).
|
| 158 |
+
STUDENT_BASE_REPO = "openbmb/MiniCPM-V-4_5"
|
| 159 |
+
# Once a LoRA is trained + merged and served via vLLM, set this to the merged
|
| 160 |
+
# model path/repo and route the vision path to it. None = use the base model.
|
| 161 |
+
# Set 2026-06-13 after the held-out eval (finetune/eval_ft.py): the fine-tuned
|
| 162 |
+
# model decisively beats the base on Bengali (base output was garbled + looping;
|
| 163 |
+
# FT is coherent native রূপকথা), confirmed by a Bengali speaker. Bengali now routes
|
| 164 |
+
# to the FT model served by finetune/serve_vllm.py (app `rupkotha-ft-serve`).
|
| 165 |
+
FINETUNED_VISION_MODEL: str | None = "/data/out/minicpm-v-bengali-merged"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def get_compute() -> dict:
|
| 169 |
+
"""Returns the active compute-location settings (Modal infra). Import this in
|
| 170 |
+
core/modal_infra.py and the core/ wrappers — never hardcode GPU tier or location."""
|
| 171 |
+
if COMPUTE_LOCATION not in ("modal", "local"):
|
| 172 |
+
raise ValueError(f"COMPUTE_LOCATION='{COMPUTE_LOCATION}' is not valid. Use 'modal' or 'local'.")
|
| 173 |
+
return {
|
| 174 |
+
"location": COMPUTE_LOCATION,
|
| 175 |
+
"gpu": MODAL_GPU,
|
| 176 |
+
"min_containers": MODAL_MIN_CONTAINERS,
|
| 177 |
+
}
|
core/prompts.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/prompts.py
|
| 2 |
+
"""All prompt templates (EN + BN, per style).
|
| 3 |
+
|
| 4 |
+
Stories must read like a grandmother's bedtime tale: warm, simple, rhythmic,
|
| 5 |
+
120–150 words, gentle sleepy ending, never scary, never a cliffhanger.
|
| 6 |
+
|
| 7 |
+
Templates use {instruction}, {name_line}, {words}, {style_rule} placeholders.
|
| 8 |
+
Bengali prompts are written natively in বাংলা — never "translate to Bengali".
|
| 9 |
+
|
| 10 |
+
NOTE: This is a Step 1 skeleton. Real templates are filled in at Build Order
|
| 11 |
+
steps 2 (EN) and 3 (BN).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# Story-style keys exposed to the UI. EN and BN have separate style sets.
|
| 15 |
+
STYLES: dict[str, dict[str, str]] = {
|
| 16 |
+
"en": {
|
| 17 |
+
"Adventure 🌟": "a gentle adventure where nothing truly scary happens",
|
| 18 |
+
"Funny 😄": "a silly, giggly tale full of light-hearted fun",
|
| 19 |
+
"Magical ✨": "a soft, wondrous tale with a touch of quiet magic",
|
| 20 |
+
},
|
| 21 |
+
"bn": {
|
| 22 |
+
"রূপকথা": "একটি কোমল রূপকথা যেখানে ভয় পাওয়ার কিছু নেই",
|
| 23 |
+
"মজার": "একটি হাসিখুশি, মজার গল্প",
|
| 24 |
+
"জাদু": "একটি কোমল, জাদুমাখা গল্প",
|
| 25 |
+
},
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
DEFAULT_WORDS = "120 to 150"
|
| 29 |
+
|
| 30 |
+
# Hard cap on images woven into one story (CLAUDE.md §1: 1–4 images).
|
| 31 |
+
MAX_IMAGES = 4
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _images_rule(language: str, num_images: int) -> str:
|
| 35 |
+
"""Count-aware instruction for weaving 0–4 pictures into ONE story cast."""
|
| 36 |
+
n = max(0, min(num_images, MAX_IMAGES))
|
| 37 |
+
if language == "bn":
|
| 38 |
+
if n == 0:
|
| 39 |
+
return "এবার কোনো ছবি নেই — তুমি নিজেই একটা আরামদায়ক ছোট্ট দৃশ্য কল্পনা করে নাও।"
|
| 40 |
+
if n == 1:
|
| 41 |
+
return "ছবিটি ভালো করে দেখো আর তাতে যা সত্যিই আছে — শিশুর নিজের আঁকা ও খেলনা — গল্পে বুনে দাও।"
|
| 42 |
+
return (
|
| 43 |
+
f"{n}টি ছবি আছে। সবগুলোকে একটিই গল্পে বুনে দাও — প্রতিটি ছবি যেন একই "
|
| 44 |
+
"গল্পের এক একটি চরিত্র, বন্ধু বা জায়গা হয়, সবাই একসাথে মিলেমিশে।"
|
| 45 |
+
)
|
| 46 |
+
if n == 0:
|
| 47 |
+
return "There is no picture this time — gently imagine a cosy little scene yourself."
|
| 48 |
+
if n == 1:
|
| 49 |
+
return "Use what is actually in the picture — the child's own drawing or toy."
|
| 50 |
+
return (
|
| 51 |
+
f"There are {n} pictures. Weave them ALL into a SINGLE story — let each "
|
| 52 |
+
"picture become a character, friend, or place that meets in the same gentle tale."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Gemma (Stacks B/C) overshoots word counts and writes lush prose; MiniCPM (Stack A)
|
| 56 |
+
# runs terse. Nudge the cap per stack family. See CLAUDE.md §8.
|
| 57 |
+
_WORDS_BY_STACK = {"A": "130 to 160", "B": "110 to 140", "C": "110 to 140"}
|
| 58 |
+
|
| 59 |
+
# Bengali: a shorter target than English. A long Bengali span pushes the model
|
| 60 |
+
# past its reliable range and invites invented words / script drift — keep it tight.
|
| 61 |
+
_BN_WORDS = "80 to 110"
|
| 62 |
+
|
| 63 |
+
# A tiny clean রূপকথা anchor (few-shot): locks Bengali script, register, and the
|
| 64 |
+
# soft sleepy rhythm so the model imitates good বাংলা instead of drifting.
|
| 65 |
+
_BN_EXAMPLE = (
|
| 66 |
+
"যেমন: “এক যে ছিল ছোট্ট খরগোশ, থাকত পুকুরপাড়ে। সারাদিন মাঠে খেলত, "
|
| 67 |
+
"সন্ধ্যা হলে মায়ের কোলে ঘুমিয়ে পড়ত। শুভরাত্রি।”"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Lever C — two-pass. Pass 1 asks the model to DESCRIBE the image(s) in English
|
| 71 |
+
# (its vision strength); pass 2 narrates from that text so the model spends all
|
| 72 |
+
# its capacity on Bengali prose, not on perceiving the image at the same time.
|
| 73 |
+
SCENE_SENTINEL = "[[SCENE_DESCRIPTION]]"
|
| 74 |
+
|
| 75 |
+
DESCRIBE_PROMPT_EN = (
|
| 76 |
+
"Look carefully at the picture(s) a child has shared. In 3–5 simple English "
|
| 77 |
+
"sentences, describe ONLY what you actually see: the objects, their colours, "
|
| 78 |
+
"and the overall mood. Do not tell a story — just describe what is in the picture(s)."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
_TEMPLATE_EN = """You are a warm, loving grandmother telling a bedtime story to a small child.
|
| 82 |
+
Look at the picture(s) the child has shared and weave what you see into {style_rule}.
|
| 83 |
+
{name_line}The child asked: "{instruction}"
|
| 84 |
+
{scene}
|
| 85 |
+
Rules:
|
| 86 |
+
- Write {words} words. Simple words a young child knows. Gentle, rhythmic, soothing.
|
| 87 |
+
- {images_rule}
|
| 88 |
+
- Never anything scary, sad, or a cliffhanger.
|
| 89 |
+
- End with everyone safe, cosy, and settling down to sleep.
|
| 90 |
+
|
| 91 |
+
Tell the story now, in English:"""
|
| 92 |
+
|
| 93 |
+
_TEMPLATE_BN = """তুমি একজন স্নেহময়ী ঠাকুমা, ছোট্ট এক শিশুকে ঘুমপাড়ানি গল্প বলছ।
|
| 94 |
+
শিশুটি যে ছবি(গুলি) দেখিয়েছে তা দেখো এবং সেটিকে গল্পে বুনে দাও — {style_rule}।
|
| 95 |
+
{name_line}শিশুটি বলেছে: "{instruction}"
|
| 96 |
+
{scene}
|
| 97 |
+
নিয়ম:
|
| 98 |
+
- শুধু বিশুদ্ধ বাংলা অক্ষরে লেখো — কোনো ইংরেজি বা রোমান হরফ ব্যবহার করবে না।
|
| 99 |
+
- কেবল চেনা, সঠিক বাংলা শব্দ ব্যবহার করো — কোনো বানানো বা অর্থহীন শব্দ নয়।
|
| 100 |
+
- {words}টি শব্দে লেখো। ছোট শিশুর চেনা সহজ শব্দ। কোমল, ছন্দময়, আদুরে।
|
| 101 |
+
- {images_rule}
|
| 102 |
+
- পুকুর, মাঠ, জোনাকি, চাঁদমামার মতো চেনা বাংলা ছবি ব্যবহার করো।
|
| 103 |
+
- কখনও ভয়ের, দুঃখের বা অসমাপ্ত কিছু নয়।
|
| 104 |
+
- সবাই নিরাপদে, আরামে, ঘুমিয়ে পড়ার মধ্য দিয়ে গল্প শেষ করো।
|
| 105 |
+
|
| 106 |
+
{example}
|
| 107 |
+
|
| 108 |
+
এবার বাংলায় গল্পটি বলো:"""
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _scene_block(language: str, scene_description: str) -> str:
|
| 112 |
+
"""Two-pass: a grounding block holding the (English) scene description for
|
| 113 |
+
pass 2. Empty in single-pass mode."""
|
| 114 |
+
if not scene_description:
|
| 115 |
+
return ""
|
| 116 |
+
if language == "bn":
|
| 117 |
+
return f"\nছবিতে যা আছে তার বর্ণনা: {scene_description}\n"
|
| 118 |
+
return f"\nWhat is in the picture: {scene_description}\n"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _scene_rule(language: str) -> str:
|
| 122 |
+
"""Two-pass replacement for the images_rule: weave the described scene in."""
|
| 123 |
+
if language == "bn":
|
| 124 |
+
return "উপরের বর্ণনায় থাকা জিনিসগুলো গল্পে বুনে দাও।"
|
| 125 |
+
return "Weave the things in the description above into the story."
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def build_story_prompt(
|
| 129 |
+
instruction: str,
|
| 130 |
+
language: str,
|
| 131 |
+
style: str,
|
| 132 |
+
child_name: str = "",
|
| 133 |
+
stack_key: str = "A",
|
| 134 |
+
num_images: int = 1,
|
| 135 |
+
scene_description: str = "",
|
| 136 |
+
) -> str:
|
| 137 |
+
"""Build the full story prompt for the vision model. language: 'en' | 'bn'.
|
| 138 |
+
|
| 139 |
+
num_images (0–4) drives how the prompt asks the model to weave the pictures
|
| 140 |
+
into one shared story cast.
|
| 141 |
+
|
| 142 |
+
scene_description: two-pass (Lever C). When set, this is the pass-2 prompt —
|
| 143 |
+
it injects the (English) scene description as text and the model narrates from
|
| 144 |
+
it with NO image attached. Pass a sentinel (SCENE_SENTINEL) here and have the
|
| 145 |
+
Modal layer substitute the real description after the describe pass."""
|
| 146 |
+
lang = language if language in ("en", "bn") else "en"
|
| 147 |
+
styles = STYLES[lang]
|
| 148 |
+
style_rule = styles.get(style) or next(iter(styles.values()))
|
| 149 |
+
words = _WORDS_BY_STACK.get(stack_key, DEFAULT_WORDS)
|
| 150 |
+
# In two-pass mode the story is grounded in the description, not the raw image.
|
| 151 |
+
images_rule = _scene_rule(lang) if scene_description else _images_rule(lang, num_images)
|
| 152 |
+
scene = _scene_block(lang, scene_description)
|
| 153 |
+
|
| 154 |
+
if lang == "bn":
|
| 155 |
+
name_line = f"শিশুটির নাম {child_name}; গল্পে তার নাম বুনে দাও।\n" if child_name else ""
|
| 156 |
+
instr = instruction.strip() or "আমাকে একটা গল্প বলো"
|
| 157 |
+
return _TEMPLATE_BN.format(
|
| 158 |
+
style_rule=style_rule, name_line=name_line, instruction=instr,
|
| 159 |
+
words=_BN_WORDS, images_rule=images_rule, example=_BN_EXAMPLE, scene=scene,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
name_line = f"The child's name is {child_name}; weave their name in warmly.\n" if child_name else ""
|
| 163 |
+
instr = instruction.strip() or "tell me a bedtime story"
|
| 164 |
+
return _TEMPLATE_EN.format(
|
| 165 |
+
style_rule=style_rule, name_line=name_line, instruction=instr,
|
| 166 |
+
words=words, images_rule=images_rule, scene=scene,
|
| 167 |
+
)
|
core/stt.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/stt.py
|
| 2 |
+
"""Whisper audio → text (EN/BN).
|
| 3 |
+
|
| 4 |
+
Uses get_config().stt_model (EN) or stt_bn_model (BN, if set), transcribed on
|
| 5 |
+
Modal. Returns '' on failure so the caller can fall back to typed input — the
|
| 6 |
+
primary, reliable path for Bengali especially (CLAUDE.md §2, §14). Never raises.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from core.model_config import UI_MOCK, get_config
|
| 10 |
+
from core.modal_infra import transcribe_remote
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def transcribe(audio_path: str, language: str) -> str:
|
| 14 |
+
"""language is 'en' or 'bn'. Returns '' on failure. Never raises."""
|
| 15 |
+
if UI_MOCK: # local UI dev — no Modal/GPU
|
| 16 |
+
return "tell me a story about my dragon"
|
| 17 |
+
if not audio_path:
|
| 18 |
+
return ""
|
| 19 |
+
cfg = get_config()
|
| 20 |
+
if language == "bn" and cfg.stt_bn_model:
|
| 21 |
+
model = cfg.stt_bn_model
|
| 22 |
+
else:
|
| 23 |
+
model = cfg.stt_model
|
| 24 |
+
try:
|
| 25 |
+
with open(audio_path, "rb") as f:
|
| 26 |
+
audio_bytes = f.read()
|
| 27 |
+
return transcribe_remote(audio_bytes, language, model) or ""
|
| 28 |
+
except Exception as e: # noqa: BLE001 — never raise; caller uses typed input
|
| 29 |
+
print(f"[stt.py] transcription failed: {e}")
|
| 30 |
+
return ""
|
core/tts.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/tts.py
|
| 2 |
+
"""Story text → grandmother-voice audio (EN/BN).
|
| 3 |
+
|
| 4 |
+
EN → VoxCPM2 Voice Design persona (Build Order step 5 — still stubbed).
|
| 5 |
+
BN → get_config().tts_bn_backend ('indic_parler' or 'indic_tts'), synthesised on
|
| 6 |
+
Modal via Indic Parler-TTS. The grandmother persona is controlled by the
|
| 7 |
+
caption below — tune it for warmth and storytelling pace (CLAUDE.md §9).
|
| 8 |
+
Returns (None, label) if TTS unavailable or failed. Never raises.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from core.model_config import UI_MOCK, get_config
|
| 15 |
+
from core.modal_infra import synthesize_bengali_remote, synthesize_english_remote
|
| 16 |
+
|
| 17 |
+
# A pre-generated sample clip for RUPKOTHA_MOCK=1, so the audio player has something
|
| 18 |
+
# to show during local UI dev (no Modal/GPU). None if the sample isn't present.
|
| 19 |
+
_MOCK_WAV = next(
|
| 20 |
+
(str(p) for p in [
|
| 21 |
+
Path("finetune/data/sample/demo_story_ai4bharat.wav"),
|
| 22 |
+
Path("finetune/data/sample/demo_story_output_sampled.wav"),
|
| 23 |
+
] if p.exists()),
|
| 24 |
+
None,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Grandmother voice caption — controls the Indic Parler-TTS speaker persona.
|
| 28 |
+
# Tune for warmth and pace; run 5–10 samples and keep the best (CLAUDE.md §9).
|
| 29 |
+
GRANDMOTHER_CAPTION_BN = (
|
| 30 |
+
"Aditi speaks in the warm, tender voice of a loving elderly Bengali "
|
| 31 |
+
"grandmother telling a bedtime story to her beloved grandchild. Her pace is "
|
| 32 |
+
"very slow, gentle, and unhurried, pausing softly at every sentence. Her "
|
| 33 |
+
"delivery is affectionate and expressive, rising and falling with the natural "
|
| 34 |
+
"soothing melody of Bengali storytelling, full of warmth and calm. The "
|
| 35 |
+
"recording is very clear, close, and intimate, with no background noise."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Grandmother Voice Design persona for VoxCPM2 (English). This is prefixed to the
|
| 39 |
+
# story text in parentheses by core/modal_infra.py — keep it short and concrete.
|
| 40 |
+
# Tune for warmth and a slow, storytelling pace (CLAUDE.md §11, build step 5).
|
| 41 |
+
GRANDMOTHER_VOICE_EN = (
|
| 42 |
+
"a warm, loving elderly grandmother reading a bedtime story, "
|
| 43 |
+
"slow and gentle pace, soft and soothing tone"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _write_wav(audio_bytes: bytes) -> str:
|
| 48 |
+
"""Persist WAV bytes to a temp file and return its path."""
|
| 49 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
| 50 |
+
try:
|
| 51 |
+
tmp.write(audio_bytes)
|
| 52 |
+
finally:
|
| 53 |
+
tmp.close()
|
| 54 |
+
return tmp.name
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def speak(
|
| 58 |
+
text: str,
|
| 59 |
+
language: str, # 'en' or 'bn'
|
| 60 |
+
voice: str = "grandmother",
|
| 61 |
+
) -> tuple[str | None, str]:
|
| 62 |
+
"""Returns (wav_path_or_None, tts_model_label). Never raises."""
|
| 63 |
+
cfg = get_config()
|
| 64 |
+
|
| 65 |
+
if UI_MOCK: # local UI dev — sample clip, no Modal/GPU
|
| 66 |
+
return _MOCK_WAV, "UI-mock"
|
| 67 |
+
|
| 68 |
+
if language == "bn":
|
| 69 |
+
label = cfg.tts_bn_backend
|
| 70 |
+
if not (text or "").strip():
|
| 71 |
+
return None, label
|
| 72 |
+
try:
|
| 73 |
+
audio_bytes = synthesize_bengali_remote(text, GRANDMOTHER_CAPTION_BN)
|
| 74 |
+
if not audio_bytes:
|
| 75 |
+
return None, label
|
| 76 |
+
return _write_wav(audio_bytes), label
|
| 77 |
+
except Exception as e: # noqa: BLE001 — never raise to the UI
|
| 78 |
+
print(f"[tts.py] Bengali TTS failed: {e}")
|
| 79 |
+
return None, label
|
| 80 |
+
|
| 81 |
+
# English path: VoxCPM2 Voice Design.
|
| 82 |
+
label = cfg.tts_en_backend
|
| 83 |
+
if not (text or "").strip():
|
| 84 |
+
return None, label
|
| 85 |
+
try:
|
| 86 |
+
audio_bytes = synthesize_english_remote(text, GRANDMOTHER_VOICE_EN)
|
| 87 |
+
if not audio_bytes:
|
| 88 |
+
return None, label
|
| 89 |
+
return _write_wav(audio_bytes), label
|
| 90 |
+
except Exception as e: # noqa: BLE001 — never raise to the UI
|
| 91 |
+
print(f"[tts.py] English TTS failed: {e}")
|
| 92 |
+
return None, label
|
core/vision_story.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core/vision_story.py
|
| 2 |
+
"""Image(s) + instruction → bedtime story text.
|
| 3 |
+
|
| 4 |
+
Builds the prompt locally, then calls the Modal VisionStory function (Ollama
|
| 5 |
+
serving get_config().vision_model). Never raises to the UI: returns a friendly
|
| 6 |
+
fallback string on any error so the app always shows a story.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
|
| 11 |
+
from core.model_config import (
|
| 12 |
+
ACTIVE_STACK,
|
| 13 |
+
FINETUNED_VISION_MODEL,
|
| 14 |
+
UI_MOCK,
|
| 15 |
+
get_config,
|
| 16 |
+
get_vision_options,
|
| 17 |
+
)
|
| 18 |
+
from core.modal_infra import (
|
| 19 |
+
generate_story_ft_remote,
|
| 20 |
+
generate_story_remote,
|
| 21 |
+
translate_remote,
|
| 22 |
+
)
|
| 23 |
+
from core.prompts import (
|
| 24 |
+
DESCRIBE_PROMPT_EN,
|
| 25 |
+
MAX_IMAGES,
|
| 26 |
+
SCENE_SENTINEL,
|
| 27 |
+
STYLES,
|
| 28 |
+
build_story_prompt,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Languages that use two-pass generation (Lever C): describe the image in English
|
| 32 |
+
# first, then narrate from that text. DISABLED — tested on Stack A and it did NOT
|
| 33 |
+
# improve Bengali: pass 1 produces an excellent English description, but pass 2's
|
| 34 |
+
# Bengali is still poor, proving the ceiling is MiniCPM-V's Bengali *generation*,
|
| 35 |
+
# not perception. (Bengali now uses the fine-tuned model instead — see below.)
|
| 36 |
+
# The two-pass plumbing is kept but off by default. Add "bn" back to enable.
|
| 37 |
+
_TWO_PASS_LANGUAGES: set[str] = set()
|
| 38 |
+
|
| 39 |
+
# Translation pivot (research option #1): MiniCPM writes the story in English, then
|
| 40 |
+
# IndicTrans2 translates to Bengali. DISABLED — owner (a Bengali speaker) judged the
|
| 41 |
+
# native Lever-#1 output better: the pivot is grammatically clean but reads translated
|
| 42 |
+
# (stiff formal register তিনি/করুন, no native রূপকথা imagery like চাঁদমামা/পুকুর).
|
| 43 |
+
# Authentic register beats textbook grammar for a bedtime tale. Plumbing kept (Modal
|
| 44 |
+
# `run_translate` + this toggle) in case it's wanted later. Set True to re-enable.
|
| 45 |
+
_PIVOT_BENGALI = False
|
| 46 |
+
|
| 47 |
+
# Bengali style key → its positional English counterpart (রূপকথা→Adventure, etc.),
|
| 48 |
+
# used to generate the English draft before translating.
|
| 49 |
+
_BN_TO_EN_STYLE = dict(zip(STYLES["bn"].keys(), STYLES["en"].keys()))
|
| 50 |
+
|
| 51 |
+
_FALLBACK = (
|
| 52 |
+
"Once upon a time, the stars grew sleepy and the moon pulled a soft cloud "
|
| 53 |
+
"over the world like a blanket. Everyone yawned, snuggled close, and drifted "
|
| 54 |
+
"off to dream. Goodnight. 💤"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
_FALLBACK_BN = (
|
| 58 |
+
"এক যে ছিল রাত, তারারা ঘুমে ঢুলছিল আর চাঁদমামা নরম মেঘের কাঁথা টেনে দিল সারা "
|
| 59 |
+
"পৃথিবীর গায়ে। সবাই হাই তুলল, একসাথে গুটিসুটি মেরে ঘুমিয়ে পড়ল। শুভরাত্রি। 💤"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Canned stories for RUPKOTHA_MOCK=1 (local UI dev — no Modal/GPU). Long enough to
|
| 63 |
+
# exercise the story panel's layout and scrolling.
|
| 64 |
+
_MOCK_STORY_EN = (
|
| 65 |
+
"Once upon a time, on a soft green hill, a little rabbit named Pip watched the "
|
| 66 |
+
"sun slip behind the mountains. The sky turned gold, then rosy pink. Pip hopped "
|
| 67 |
+
"past the sleepy pond where fireflies began to glow like tiny stars.\n\n"
|
| 68 |
+
"A gentle breeze hummed a lullaby through the tall grass. Pip curled up under the "
|
| 69 |
+
"big banyan tree, where mother rabbit waited with a warm hug. The moon climbed up "
|
| 70 |
+
"and pulled a soft cloud over the world like a blanket.\n\n"
|
| 71 |
+
"All the little creatures yawned and snuggled close. Pip closed both sleepy eyes "
|
| 72 |
+
"and drifted into a sweet, happy dream. Goodnight, little one. 💤"
|
| 73 |
+
)
|
| 74 |
+
_MOCK_STORY_BN = (
|
| 75 |
+
"এক যে ছিল ছোট্ট খরগোশ, নাম তার টুনি। সে থাকত সবুজ পাহাড়ের ধারে। সন্ধ্যা হলে "
|
| 76 |
+
"সূর্যিমামা পাহাড়ের পিছনে লুকিয়ে পড়ল, আকাশ হলো সোনালি আর গোলাপি।\n\n"
|
| 77 |
+
"টুনি পুকুরপাড়ে গেল, সেখানে জোনাকিরা মিটিমিটি জ্বলছিল তারার মতো। নরম বাতাস ঘাসের "
|
| 78 |
+
"মধ্যে দিয়ে ঘুমপাড়ানি গান গাইল। টুনি বড় বটগাছের নিচে মায়ের কোলে গিয়ে বসল।\n\n"
|
| 79 |
+
"চাঁদমামা উঠে এসে সারা পৃথিবীর গায়ে নরম মেঘের কাঁথা টেনে দিল। সব ছোট্ট প্রাণীরা "
|
| 80 |
+
"হাই তুলল, গুটিসুটি মেরে ঘুমিয়ে পড়ল। শুভরাত্রি, সোনা। 💤"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _encode(path: str) -> str:
|
| 85 |
+
with open(path, "rb") as f:
|
| 86 |
+
return base64.b64encode(f.read()).decode()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def generate_story(
|
| 90 |
+
image_paths: list[str], # 1–4 image paths
|
| 91 |
+
instruction: str, # transcribed or typed; may be ''
|
| 92 |
+
language: str, # 'en' or 'bn'
|
| 93 |
+
style: str, # key from prompts.STYLES
|
| 94 |
+
child_name: str = "", # optional, woven into story if provided
|
| 95 |
+
) -> tuple[str, str]:
|
| 96 |
+
"""Returns (story_text, model_used_label). Never raises."""
|
| 97 |
+
cfg = get_config()
|
| 98 |
+
model_label = cfg.vision_model
|
| 99 |
+
fallback = _FALLBACK_BN if language == "bn" else _FALLBACK
|
| 100 |
+
|
| 101 |
+
if UI_MOCK: # local UI dev — canned story, no Modal/GPU
|
| 102 |
+
return _MOCK_STORY_BN if language == "bn" else _MOCK_STORY_EN, f"{model_label} · UI-mock"
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
# Cap to 1–4 images and weave them all into one story (CLAUDE.md §1, step 7).
|
| 106 |
+
paths = [p for p in (image_paths or []) if p][:MAX_IMAGES]
|
| 107 |
+
images_b64 = [_encode(p) for p in paths]
|
| 108 |
+
|
| 109 |
+
# Bengali translation pivot: write the story in English, then translate.
|
| 110 |
+
if _PIVOT_BENGALI and language == "bn":
|
| 111 |
+
en_prompt = build_story_prompt(
|
| 112 |
+
instruction=instruction,
|
| 113 |
+
language="en",
|
| 114 |
+
style=_BN_TO_EN_STYLE.get(style, next(iter(STYLES["en"]))),
|
| 115 |
+
child_name=child_name,
|
| 116 |
+
stack_key=ACTIVE_STACK,
|
| 117 |
+
num_images=len(paths),
|
| 118 |
+
)
|
| 119 |
+
en_story = (
|
| 120 |
+
generate_story_remote(images_b64, en_prompt, get_vision_options("en")) or ""
|
| 121 |
+
).strip()
|
| 122 |
+
bn_story = (translate_remote(en_story, "en", "bn") or "").strip() if en_story else ""
|
| 123 |
+
return (bn_story or fallback), f"{cfg.vision_model} → IndicTrans2"
|
| 124 |
+
|
| 125 |
+
# Bengali fine-tuned model (see finetune/). When a merged FT model is
|
| 126 |
+
# configured, route Bengali to it — the held-out eval (finetune/eval_ft.py)
|
| 127 |
+
# showed it decisively beats the base's native Bengali. Served separately
|
| 128 |
+
# via finetune/serve_vllm.py; gets the same single-pass prompt the base would.
|
| 129 |
+
if FINETUNED_VISION_MODEL and language == "bn":
|
| 130 |
+
ft_prompt = build_story_prompt(
|
| 131 |
+
instruction=instruction,
|
| 132 |
+
language="bn",
|
| 133 |
+
style=style,
|
| 134 |
+
child_name=child_name,
|
| 135 |
+
stack_key=ACTIVE_STACK,
|
| 136 |
+
num_images=len(paths),
|
| 137 |
+
)
|
| 138 |
+
ft_story = (generate_story_ft_remote(images_b64, ft_prompt) or "").strip()
|
| 139 |
+
return (ft_story or fallback), f"{cfg.vision_model} · Bengali fine-tune"
|
| 140 |
+
|
| 141 |
+
two_pass = language in _TWO_PASS_LANGUAGES and len(paths) > 0
|
| 142 |
+
# In two-pass mode the story prompt is grounded in a sentinel that the Modal
|
| 143 |
+
# layer replaces with the English description produced by the describe pass.
|
| 144 |
+
prompt = build_story_prompt(
|
| 145 |
+
instruction=instruction,
|
| 146 |
+
language=language,
|
| 147 |
+
style=style,
|
| 148 |
+
child_name=child_name,
|
| 149 |
+
stack_key=ACTIVE_STACK,
|
| 150 |
+
num_images=len(paths),
|
| 151 |
+
scene_description=SCENE_SENTINEL if two_pass else "",
|
| 152 |
+
)
|
| 153 |
+
describe_prompt = DESCRIBE_PROMPT_EN if two_pass else None
|
| 154 |
+
options = get_vision_options(language)
|
| 155 |
+
story = generate_story_remote(images_b64, prompt, options, describe_prompt)
|
| 156 |
+
story = (story or "").strip()
|
| 157 |
+
return (story or fallback), model_label
|
| 158 |
+
except Exception as e: # noqa: BLE001 — never raise to the UI
|
| 159 |
+
print(f"[vision_story.py] generation failed: {e}")
|
| 160 |
+
return fallback, model_label
|
finetune/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bengali distillation fine-tuning (`finetune/`)
|
| 2 |
+
|
| 3 |
+
Improve Stack A's weak Bengali by **fine-tuning MiniCPM-V 4.5 itself** — the
|
| 4 |
+
strongest OpenBMB-prize story (*"we improved the OpenBMB model for Bengali"*).
|
| 5 |
+
This stays on Stack A; only the served model weights change.
|
| 6 |
+
|
| 7 |
+
> Why fine-tune and not the IndicTrans2 pivot? The owner judged the pivot's
|
| 8 |
+
> translated register worse than native output. The native ceiling is MiniCPM's
|
| 9 |
+
> Bengali *generation*; fine-tuning on great Bengali raises that ceiling while
|
| 10 |
+
> keeping the warm native register. See the `bengali-quality-investigation` memory.
|
| 11 |
+
|
| 12 |
+
## The pipeline
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
drawings ──► [Gemma teacher] ──► Bengali stories ──► [purity gate] ──► train.json
|
| 16 |
+
│
|
| 17 |
+
LoRA fine-tune MiniCPM-V (vision frozen) ◄───┘
|
| 18 |
+
│
|
| 19 |
+
merge adapter
|
| 20 |
+
│
|
| 21 |
+
serve via vLLM ──► wire into Bengali path
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
| Stage | File | Where | Notes |
|
| 25 |
+
|---|---|---|---|
|
| 26 |
+
| 0. Source images | (manual, see below) | local | the real bottleneck |
|
| 27 |
+
| 1. Label + filter | `gen_labels.py` + `purity.py` | Modal (Gemma) | image → Bengali story, drop leaks |
|
| 28 |
+
| 2. LoRA train | `train_lora.py` | Modal A100 | ms-SWIFT (`model_type=minicpmv4_5`) |
|
| 29 |
+
| 3. Merge | `merge.py` | Modal A100 | adapter → full weights |
|
| 30 |
+
| 4. Serve | `serve_vllm.py` | Modal A10G | vLLM, no GGUF |
|
| 31 |
+
| 5. Wire | one branch in `core/vision_story.py` | local | route Bengali to the FT model |
|
| 32 |
+
|
| 33 |
+
## Stage 0 — source the drawings (`finetune/data/images/`)
|
| 34 |
+
Target ~300–500 diverse, on-distribution images. Mix all four:
|
| 35 |
+
- **Team-drawn crayon pics** — photograph ~100–150. Most authentic; do these first.
|
| 36 |
+
- **Quick, Draw!** — Google's doodle dataset (`https://quickdraw.withgoogle.com/data`);
|
| 37 |
+
pull a few categories (`.ndjson`/`.npy`), render strokes to PNG, optionally
|
| 38 |
+
composite 2–3 doodles into a scene.
|
| 39 |
+
- **Synthetic crayon** — an image model (e.g. SDXL) with prompts like
|
| 40 |
+
*"a child's crayon drawing of a house and the sun, simple, on white paper"*.
|
| 41 |
+
- **Toy / play photos** — stock or self-taken photos of toys/equipment.
|
| 42 |
+
|
| 43 |
+
Quality > quantity for LoRA. Keep them varied (objects, colours, scenes).
|
| 44 |
+
|
| 45 |
+
## Stage 1 — generate labels
|
| 46 |
+
```bash
|
| 47 |
+
modal deploy core/modal_infra.py # teacher served via run_vision_story
|
| 48 |
+
uv run python finetune/gen_labels.py --images finetune/data/images \
|
| 49 |
+
--out finetune/data/train.json --teacher gemma3:27b
|
| 50 |
+
```
|
| 51 |
+
Produces `train.json` (kept) and `train.json.rejected` (failed the gate). **Have a
|
| 52 |
+
Bengali speaker spot-check `kept`** — distillation caps the student at label quality.
|
| 53 |
+
Tune strictness in `purity.py` (`is_clean(max_foreign_letters=...)`).
|
| 54 |
+
|
| 55 |
+
## Stage 2 — LoRA train
|
| 56 |
+
Upload `finetune/data/` to the `rupkotha-finetune` Modal Volume, then validate
|
| 57 |
+
cheaply before the full run:
|
| 58 |
+
```bash
|
| 59 |
+
uv run modal run finetune/train_lora.py --max-steps 4 # smoke test
|
| 60 |
+
uv run modal run finetune/train_lora.py # full 3-epoch run
|
| 61 |
+
```
|
| 62 |
+
`train_lora.py` drives **ms-SWIFT** (`swift sft --model_type minicpmv4_5`), not
|
| 63 |
+
OpenBMB's official `finetune.py` — the latter pins torch 2.1.2 while MiniCPM-V 4.5
|
| 64 |
+
remote code needs torch>=2.4, and its finetune path doesn't target 4.5. SWIFT ships
|
| 65 |
+
a maintained recipe for this model so the dep matrix is solved. It converts our
|
| 66 |
+
MiniCPM `conversations` JSON to SWIFT's `messages`/`images` JSONL in-container.
|
| 67 |
+
Vision encoder frozen (`--freeze_vit true`), LoRA r=16 on the LLM `q/k/v/o_proj`.
|
| 68 |
+
SWIFT's nested checkpoint is copied to `out/lora-bengali/` so Stage 3 is unchanged.
|
| 69 |
+
|
| 70 |
+
## Stage 3 — merge
|
| 71 |
+
```bash
|
| 72 |
+
uv run modal run finetune/merge.py # → out/minicpm-v-bengali-merged/
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Stage 4 — serve + Stage 5 — wire
|
| 76 |
+
```bash
|
| 77 |
+
uv run modal deploy finetune/serve_vllm.py
|
| 78 |
+
```
|
| 79 |
+
Then set `FINETUNED_VISION_MODEL` in `core/model_config.py` to the merged path and
|
| 80 |
+
add a one-line branch in `core/vision_story.py`: when it's set and `language=="bn"`,
|
| 81 |
+
call `serve_vllm.generate_story_ft_remote(...)` instead of `generate_story_remote`.
|
| 82 |
+
(`run_translate`/pivot plumbing stays available as a fallback.)
|
| 83 |
+
|
| 84 |
+
## Evaluate
|
| 85 |
+
Hold out ~20 drawings; compare FT Bengali vs native Lever-#1 (`demo_story_bn_v2.wav`)
|
| 86 |
+
with a Bengali speaker. Ship the FT model only if it clearly wins.
|
| 87 |
+
|
| 88 |
+
## Cost / risk (hackathon reality)
|
| 89 |
+
Heaviest path: image curation (days) + ~1–3h A100 training + integration. Validated
|
| 90 |
+
upside: the Gemma teacher writes genuinely native Bengali (চাঁদমামা/পুকুর/খই), so the
|
| 91 |
+
student has an excellent target. Main risk is data volume/quality, not the method.
|
finetune/collect.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/collect.py
|
| 2 |
+
"""Stage 0 — collect open-source images into finetune/data/images/<source>/.
|
| 3 |
+
|
| 4 |
+
Zero-friction sources (no creds) are implemented: Google Quick, Draw! and
|
| 5 |
+
file-based HF image datasets. Cred-gated sources (Kaggle, Roboflow) and generated
|
| 6 |
+
ones (synthetic crayon, toy stock) are documented stubs — wire them once you drop
|
| 7 |
+
in the relevant API key.
|
| 8 |
+
|
| 9 |
+
Examples:
|
| 10 |
+
uv run python finetune/collect.py quickdraw --per 40
|
| 11 |
+
uv run python finetune/collect.py hf --dataset ironDong/Children_Drawings --per 150
|
| 12 |
+
uv run python finetune/collect.py hf --dataset 6chan/children-hand-drawn-style-transfer --per 150
|
| 13 |
+
|
| 14 |
+
The later preprocessor (finetune/preprocess.py) sweeps the whole images/ pool.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import urllib.parse
|
| 21 |
+
import urllib.request
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Redirect the HF cache to a writable dir BEFORE huggingface_hub/datasets import
|
| 25 |
+
# (the global ~/.cache/huggingface is read-only in this sandbox).
|
| 26 |
+
_HF_CACHE = str(Path(__file__).resolve().parent / ".hf_cache")
|
| 27 |
+
os.environ.setdefault("HF_HOME", _HF_CACHE)
|
| 28 |
+
os.environ.setdefault("HF_DATASETS_CACHE", _HF_CACHE + "/datasets")
|
| 29 |
+
os.environ.setdefault("HF_HUB_CACHE", _HF_CACHE + "/hub")
|
| 30 |
+
|
| 31 |
+
from PIL import Image, ImageDraw # noqa: E402
|
| 32 |
+
|
| 33 |
+
IMAGES = Path(__file__).resolve().parent / "data" / "images"
|
| 34 |
+
|
| 35 |
+
# Quick, Draw! public bucket (no auth). Categories that fit bedtime-story scenes.
|
| 36 |
+
_QD_BASE = "https://storage.googleapis.com/quickdraw_dataset/full/simplified/"
|
| 37 |
+
_QD_CATS = [
|
| 38 |
+
"house", "tree", "sun","birthday cake", "cat","castle", "dog", "flower", "bird", "fish", "star",
|
| 39 |
+
"moon", "sailboat", "butterfly", "rainbow", "cloud", "duck", "elephant", "umbrella",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _render_strokes(strokes, size=256, line=3) -> Image.Image:
|
| 44 |
+
"""Render a Quick, Draw! simplified drawing (list of [xs, ys] strokes)."""
|
| 45 |
+
img = Image.new("RGB", (size, size), "white")
|
| 46 |
+
draw = ImageDraw.Draw(img)
|
| 47 |
+
for stroke in strokes:
|
| 48 |
+
xs, ys = stroke[0], stroke[1]
|
| 49 |
+
pts = list(zip(xs, ys))
|
| 50 |
+
if len(pts) >= 2:
|
| 51 |
+
draw.line(pts, fill="black", width=line, joint="curve")
|
| 52 |
+
elif pts:
|
| 53 |
+
draw.point(pts, fill="black")
|
| 54 |
+
return img
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def collect_quickdraw(categories: list[str], per: int) -> int:
|
| 58 |
+
out = IMAGES / "quickdraw"
|
| 59 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
total = 0
|
| 61 |
+
for cat in categories:
|
| 62 |
+
url = _QD_BASE + urllib.parse.quote(cat) + ".ndjson"
|
| 63 |
+
try:
|
| 64 |
+
resp = urllib.request.urlopen(url, timeout=60) # streams line-by-line
|
| 65 |
+
except Exception as e: # noqa: BLE001
|
| 66 |
+
print(f" {cat}: download failed ({e})")
|
| 67 |
+
continue
|
| 68 |
+
n = 0
|
| 69 |
+
for raw in resp: # stop early — we don't pull the whole (huge) file
|
| 70 |
+
if n >= per:
|
| 71 |
+
break
|
| 72 |
+
try:
|
| 73 |
+
obj = json.loads(raw)
|
| 74 |
+
if obj.get("recognized") is False:
|
| 75 |
+
continue
|
| 76 |
+
_render_strokes(obj["drawing"]).save(
|
| 77 |
+
out / f"qd_{cat.replace(' ', '_')}_{n}.png"
|
| 78 |
+
)
|
| 79 |
+
n += 1
|
| 80 |
+
total += 1
|
| 81 |
+
except Exception: # noqa: BLE001
|
| 82 |
+
continue
|
| 83 |
+
print(f" {cat}: {n}")
|
| 84 |
+
print(f"Quick, Draw!: {total} images -> {out}")
|
| 85 |
+
return total
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _example_image(example: dict):
|
| 89 |
+
"""Find a PIL image in a datasets example (Image-feature columns decode to PIL)."""
|
| 90 |
+
for key in ("image", "img", "png", "jpg", "jpeg"):
|
| 91 |
+
v = example.get(key)
|
| 92 |
+
if isinstance(v, Image.Image):
|
| 93 |
+
return v
|
| 94 |
+
for v in example.values(): # fall back to any PIL value
|
| 95 |
+
if isinstance(v, Image.Image):
|
| 96 |
+
return v
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def collect_hf(dataset: str, per: int) -> int:
|
| 101 |
+
"""Pull images from an HF image dataset. Tries raw files first, then falls back
|
| 102 |
+
to parquet via `datasets` (run with: uv run --group finetune ...)."""
|
| 103 |
+
import shutil
|
| 104 |
+
|
| 105 |
+
from huggingface_hub import snapshot_download
|
| 106 |
+
|
| 107 |
+
out = IMAGES / ("hf_" + dataset.split("/")[-1].lower().replace("-", "_"))
|
| 108 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
|
| 110 |
+
# 1) file-based datasets that ship raw images
|
| 111 |
+
try:
|
| 112 |
+
local = snapshot_download(
|
| 113 |
+
repo_id=dataset, repo_type="dataset", cache_dir=_HF_CACHE,
|
| 114 |
+
allow_patterns=["*.png", "*.jpg", "*.jpeg", "*.webp"],
|
| 115 |
+
)
|
| 116 |
+
imgs = [p for ext in ("png", "jpg", "jpeg", "webp") for p in Path(local).rglob(f"*.{ext}")][:per]
|
| 117 |
+
except Exception: # noqa: BLE001
|
| 118 |
+
imgs = []
|
| 119 |
+
if imgs:
|
| 120 |
+
for i, p in enumerate(imgs):
|
| 121 |
+
shutil.copy(p, out / f"{p.stem}_{i}{p.suffix.lower()}")
|
| 122 |
+
print(f"HF {dataset}: {len(imgs)} image files -> {out}")
|
| 123 |
+
return len(imgs)
|
| 124 |
+
|
| 125 |
+
# 2) parquet datasets via `datasets` (streaming so we don't pull the whole thing)
|
| 126 |
+
try:
|
| 127 |
+
from datasets import load_dataset
|
| 128 |
+
except ImportError:
|
| 129 |
+
print(f"HF {dataset}: parquet dataset — run with `uv run --group finetune ...`")
|
| 130 |
+
return 0
|
| 131 |
+
n = 0
|
| 132 |
+
last_err = None
|
| 133 |
+
for split in ("train", "validation", "test"):
|
| 134 |
+
try:
|
| 135 |
+
ds = load_dataset(dataset, split=split, streaming=True)
|
| 136 |
+
except Exception as e: # noqa: BLE001
|
| 137 |
+
last_err = e
|
| 138 |
+
continue
|
| 139 |
+
for ex in ds:
|
| 140 |
+
if n >= per:
|
| 141 |
+
break
|
| 142 |
+
img = _example_image(ex)
|
| 143 |
+
if img is None:
|
| 144 |
+
continue
|
| 145 |
+
img.convert("RGB").save(out / f"{n}.png")
|
| 146 |
+
n += 1
|
| 147 |
+
if n >= per:
|
| 148 |
+
break
|
| 149 |
+
if n == 0 and last_err is not None:
|
| 150 |
+
msg = str(last_err)
|
| 151 |
+
if "gated" in msg or "authenticated" in msg or "401" in msg:
|
| 152 |
+
print(f"HF {dataset}: GATED — accept its license on the Hub and set a local "
|
| 153 |
+
f"HF_TOKEN (export HF_TOKEN=hf_xxx), then retry.")
|
| 154 |
+
else:
|
| 155 |
+
print(f"HF {dataset}: failed — {msg[:160]}")
|
| 156 |
+
return 0
|
| 157 |
+
print(f"HF {dataset}: {n} images (parquet) -> {out}")
|
| 158 |
+
return n
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── Cred-gated / generative sources: documented stubs ───────────────────────
|
| 162 |
+
_STUBS = """
|
| 163 |
+
Cred-gated and generated sources (wire once you have the key):
|
| 164 |
+
|
| 165 |
+
Kaggle (needs ~/.kaggle/kaggle.json):
|
| 166 |
+
pip install kaggle
|
| 167 |
+
kaggle datasets download -d vishmiperera/children-drawings -p data/images/kaggle --unzip
|
| 168 |
+
kaggle datasets download -d lachin007/drawaperson-handdrawn-sketches-by-children \\
|
| 169 |
+
-p data/images/kaggle --unzip
|
| 170 |
+
|
| 171 |
+
Roboflow ESRA (needs Roboflow API key):
|
| 172 |
+
pip install roboflow # then use the dataset's export snippet → data/images/roboflow
|
| 173 |
+
|
| 174 |
+
Synthetic crayon (SDXL on Modal — generate to fill volume/diversity):
|
| 175 |
+
prompt e.g. "a child's crayon drawing of <scene>, simple, on white paper"
|
| 176 |
+
→ save to data/images/synthetic/ (a generate_synthetic.py can be added on request)
|
| 177 |
+
|
| 178 |
+
Toy / play photos (Unsplash/Pexels API):
|
| 179 |
+
fetch with your API key → data/images/toys/
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def main() -> None:
|
| 184 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 185 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 186 |
+
|
| 187 |
+
qd = sub.add_parser("quickdraw", help="render Quick, Draw! doodles")
|
| 188 |
+
qd.add_argument("--per", type=int, default=40, help="images per category")
|
| 189 |
+
qd.add_argument("--categories", nargs="*", default=_QD_CATS)
|
| 190 |
+
|
| 191 |
+
hf = sub.add_parser("hf", help="pull a file-based HF image dataset")
|
| 192 |
+
hf.add_argument("--dataset", required=True)
|
| 193 |
+
hf.add_argument("--per", type=int, default=150)
|
| 194 |
+
|
| 195 |
+
sub.add_parser("sources", help="print cred-gated source instructions")
|
| 196 |
+
|
| 197 |
+
args = ap.parse_args()
|
| 198 |
+
if args.cmd == "quickdraw":
|
| 199 |
+
collect_quickdraw(args.categories, args.per)
|
| 200 |
+
elif args.cmd == "hf":
|
| 201 |
+
collect_hf(args.dataset, args.per)
|
| 202 |
+
elif args.cmd == "sources":
|
| 203 |
+
print(_STUBS)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
main()
|
finetune/eval_ft.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/eval_ft.py
|
| 2 |
+
"""Stage 5 gate — side-by-side Bengali eval: fine-tuned MiniCPM-V (vLLM) vs the
|
| 3 |
+
native base model (the current shipping "Lever #1" path).
|
| 4 |
+
|
| 5 |
+
CLAUDE.md / finetune/README are explicit: ship the fine-tune ONLY if it clearly
|
| 6 |
+
beats the native path in a human read. The train metrics (loss/token_acc) say the
|
| 7 |
+
LoRA fit the distilled targets — they do NOT say Bengali quality improved. This
|
| 8 |
+
script produces the artifact a Bengali speaker needs to make that call before
|
| 9 |
+
FINETUNED_VISION_MODEL is ever set.
|
| 10 |
+
|
| 11 |
+
Fairness: BOTH paths get the EXACT same app-built Bengali prompt (build_story_prompt)
|
| 12 |
+
and the same image — exactly what each would receive in production. The only
|
| 13 |
+
difference under test is the model weights.
|
| 14 |
+
|
| 15 |
+
native: core.modal_infra.generate_story_remote → base openbmb/MiniCPM-V-4_5 (Ollama)
|
| 16 |
+
FT: finetune.serve_vllm.generate_story_ft_remote → merged LoRA (vLLM)
|
| 17 |
+
|
| 18 |
+
Held-out set: the 61 labelset images that the purity gate rejected, so they were
|
| 19 |
+
NEVER trained on, yet are on-distribution. (Override with --images for your own.)
|
| 20 |
+
|
| 21 |
+
Run:
|
| 22 |
+
uv run modal deploy finetune/serve_vllm.py # FT server must be live
|
| 23 |
+
uv run python finetune/eval_ft.py --n 10 # 10 held-out images
|
| 24 |
+
uv run python finetune/eval_ft.py --images path/to/dir --n 8 --style রূপকথা
|
| 25 |
+
Out:
|
| 26 |
+
finetune/eval_results/ft_vs_native_YYYYMMDD_HHMM.md
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import base64
|
| 31 |
+
import json
|
| 32 |
+
import os
|
| 33 |
+
import sys
|
| 34 |
+
import time
|
| 35 |
+
from datetime import datetime
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 39 |
+
|
| 40 |
+
from core.model_config import ACTIVE_STACK, get_vision_options
|
| 41 |
+
from core.modal_infra import generate_story_remote
|
| 42 |
+
from core.prompts import STYLES, build_story_prompt
|
| 43 |
+
from finetune.serve_vllm import generate_story_ft_remote
|
| 44 |
+
|
| 45 |
+
TRAIN_JSON = Path("finetune/data/train.json")
|
| 46 |
+
LABELSET = Path("finetune/data/labelset")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def held_out_images() -> list[Path]:
|
| 50 |
+
"""Labelset images that are NOT in train.json — unseen but on-distribution."""
|
| 51 |
+
trained = {x["image"].split("/")[-1] for x in json.loads(TRAIN_JSON.read_text())}
|
| 52 |
+
imgs = [
|
| 53 |
+
p for p in sorted(LABELSET.glob("*"))
|
| 54 |
+
if p.suffix.lower() in (".jpg", ".jpeg", ".png") and p.name not in trained
|
| 55 |
+
]
|
| 56 |
+
return imgs
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _encode(path: Path) -> str:
|
| 60 |
+
return base64.b64encode(path.read_bytes()).decode()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main() -> None:
|
| 64 |
+
ap = argparse.ArgumentParser()
|
| 65 |
+
ap.add_argument("--images", default=None, help="dir of images (default: held-out labelset)")
|
| 66 |
+
ap.add_argument("--n", type=int, default=10, help="number of images to evaluate")
|
| 67 |
+
ap.add_argument("--style", default="রূপকথা", choices=list(STYLES["bn"].keys()))
|
| 68 |
+
ap.add_argument("--instruction", default="একটা গল্প বলো")
|
| 69 |
+
args = ap.parse_args()
|
| 70 |
+
|
| 71 |
+
if args.images:
|
| 72 |
+
imgs = [
|
| 73 |
+
p for p in sorted(Path(args.images).glob("*"))
|
| 74 |
+
if p.suffix.lower() in (".jpg", ".jpeg", ".png")
|
| 75 |
+
]
|
| 76 |
+
else:
|
| 77 |
+
imgs = held_out_images()
|
| 78 |
+
imgs = imgs[: args.n]
|
| 79 |
+
if not imgs:
|
| 80 |
+
sys.exit("No images found to evaluate.")
|
| 81 |
+
|
| 82 |
+
print(f"Evaluating {len(imgs)} images · style={args.style} · stack={ACTIVE_STACK}", flush=True)
|
| 83 |
+
options = get_vision_options("bn")
|
| 84 |
+
|
| 85 |
+
# Precompute the (identical) prompt + encoded bytes per image.
|
| 86 |
+
items = []
|
| 87 |
+
for img in imgs:
|
| 88 |
+
prompt = build_story_prompt(
|
| 89 |
+
instruction=args.instruction,
|
| 90 |
+
language="bn",
|
| 91 |
+
style=args.style,
|
| 92 |
+
child_name="",
|
| 93 |
+
stack_key=ACTIVE_STACK,
|
| 94 |
+
num_images=1,
|
| 95 |
+
)
|
| 96 |
+
items.append((img, prompt, [_encode(img)]))
|
| 97 |
+
|
| 98 |
+
# Two phases so each serverless model cold-starts ONCE, not per image (the
|
| 99 |
+
# alternating native→FT loop kept scaling the other model back to zero).
|
| 100 |
+
print("Phase 1/2: native (base) ...", flush=True)
|
| 101 |
+
natives = []
|
| 102 |
+
for i, (img, prompt, b64) in enumerate(items, 1):
|
| 103 |
+
t0 = time.time()
|
| 104 |
+
story = (generate_story_remote(b64, prompt, options) or "").strip()
|
| 105 |
+
dt = round(time.time() - t0, 1)
|
| 106 |
+
print(f" native [{i}/{len(items)}] {img.name} {dt}s", flush=True)
|
| 107 |
+
natives.append((story, dt))
|
| 108 |
+
|
| 109 |
+
print("Phase 2/2: fine-tuned (vLLM) ...", flush=True)
|
| 110 |
+
fts = []
|
| 111 |
+
for i, (img, prompt, b64) in enumerate(items, 1):
|
| 112 |
+
t0 = time.time()
|
| 113 |
+
story = (generate_story_ft_remote(b64, prompt) or "").strip()
|
| 114 |
+
dt = round(time.time() - t0, 1)
|
| 115 |
+
print(f" ft [{i}/{len(items)}] {img.name} {dt}s", flush=True)
|
| 116 |
+
fts.append((story, dt))
|
| 117 |
+
|
| 118 |
+
rows = [
|
| 119 |
+
(items[i][0], natives[i][0], natives[i][1], fts[i][0], fts[i][1])
|
| 120 |
+
for i in range(len(items))
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
out_dir = Path("finetune/eval_results")
|
| 124 |
+
out_dir.mkdir(exist_ok=True)
|
| 125 |
+
fname = out_dir / f"ft_vs_native_{datetime.now():%Y%m%d_%H%M}.md"
|
| 126 |
+
lines = [
|
| 127 |
+
f"# FT vs Native — Bengali story quality ({args.style})",
|
| 128 |
+
f"Generated: {datetime.now():%Y-%m-%d %H:%M} · stack {ACTIVE_STACK} · {len(rows)} held-out images\n",
|
| 129 |
+
"**Native** = base openbmb/MiniCPM-V-4_5 (current shipping Lever #1). ",
|
| 130 |
+
"**FT** = merged Bengali LoRA via vLLM. Same prompt + image for both.\n",
|
| 131 |
+
"> For the Bengali reviewer: for each image, which story reads more like a real "
|
| 132 |
+
"grandmother's bedtime tale (natural words, রূপকথা imagery, no English/garbled "
|
| 133 |
+
"words, calm sleepy ending)? Mark a winner per row.\n",
|
| 134 |
+
"---\n",
|
| 135 |
+
]
|
| 136 |
+
for img, native, tn, ft, tf in rows:
|
| 137 |
+
lines += [
|
| 138 |
+
f"## {img.name}",
|
| 139 |
+
f"})\n",
|
| 140 |
+
f"### Native (base) — {tn}s",
|
| 141 |
+
native or "_(empty)_", "",
|
| 142 |
+
f"### FT (LoRA) — {tf}s",
|
| 143 |
+
ft or "_(empty)_", "",
|
| 144 |
+
"**Winner (reviewer):** ☐ Native ☐ FT ☐ Tie · notes: ____",
|
| 145 |
+
"\n---\n",
|
| 146 |
+
]
|
| 147 |
+
fname.write_text("\n".join(lines))
|
| 148 |
+
print(f"\nReport written to {fname}")
|
| 149 |
+
print("Open it, have a Bengali speaker mark winners, and only set "
|
| 150 |
+
"FINETUNED_VISION_MODEL if FT clearly wins.")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
main()
|
finetune/gen_labels.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/gen_labels.py
|
| 2 |
+
"""Stage 1 — generate the Bengali SFT dataset by distilling the Gemma teacher.
|
| 3 |
+
|
| 4 |
+
For every drawing/photo in an images dir, ask the multimodal teacher
|
| 5 |
+
(TEACHER_MODEL, e.g. gemma3:27b) to write a native Bengali bedtime story, run it
|
| 6 |
+
through the purity gate, and emit MiniCPM-V fine-tuning JSON. The teacher does the
|
| 7 |
+
SAME task as inference (image → Bengali story), so labels are image-grounded.
|
| 8 |
+
|
| 9 |
+
Run (after `modal deploy core/modal_infra.py`):
|
| 10 |
+
uv run python finetune/gen_labels.py --images finetune/data/images \
|
| 11 |
+
--out finetune/data/train.json --teacher gemma3:27b
|
| 12 |
+
|
| 13 |
+
Output:
|
| 14 |
+
<out> kept examples (MiniCPM-V conversations format)
|
| 15 |
+
<out>.rejected labels that failed the purity gate (for review)
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import itertools
|
| 20 |
+
import json
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 25 |
+
|
| 26 |
+
import modal # noqa: E402
|
| 27 |
+
|
| 28 |
+
from core.model_config import TEACHER_MODEL, get_vision_options # noqa: E402
|
| 29 |
+
from core.prompts import STYLES, build_story_prompt # noqa: E402
|
| 30 |
+
from finetune.purity import foreign_words, is_clean # noqa: E402
|
| 31 |
+
|
| 32 |
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
| 33 |
+
# Light diversity so the student sees varied styles / instructions / names.
|
| 34 |
+
_BN_STYLES = list(STYLES["bn"].keys())
|
| 35 |
+
_INSTRUCTIONS = ["একটা গল্প বলো", "আমাকে একটা মজার গল্প শোনাও", "এই ছবি নিয়ে গল্প বলো", ""]
|
| 36 |
+
_NAMES = ["রূপা", "অর্ক", "মিঠি", ""]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _teacher_story(fn, image_bytes: bytes, prompt: str) -> str:
|
| 40 |
+
"""One image → Bengali story via the deployed Modal vision function."""
|
| 41 |
+
return fn.remote([image_bytes], prompt, TEACHER_MODEL, get_vision_options("bn"), None) or ""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> None:
|
| 45 |
+
ap = argparse.ArgumentParser()
|
| 46 |
+
ap.add_argument("--images", required=True, help="dir of drawings/photos")
|
| 47 |
+
ap.add_argument("--out", default="finetune/data/train.json")
|
| 48 |
+
ap.add_argument("--teacher", default=TEACHER_MODEL, help="override TEACHER_MODEL")
|
| 49 |
+
ap.add_argument("--max-foreign", type=int, default=0, help="allowed foreign letters")
|
| 50 |
+
args = ap.parse_args()
|
| 51 |
+
|
| 52 |
+
images = sorted(
|
| 53 |
+
p for p in Path(args.images).rglob("*") if p.suffix.lower() in IMAGE_EXTS
|
| 54 |
+
)
|
| 55 |
+
if not images:
|
| 56 |
+
sys.exit(f"No images found under {args.images}")
|
| 57 |
+
print(f"Found {len(images)} images. Teacher = {args.teacher}")
|
| 58 |
+
|
| 59 |
+
out = Path(args.out)
|
| 60 |
+
rej_out = out.with_suffix(out.suffix + ".rejected")
|
| 61 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
# Resume: load any existing output and skip images already labelled. This makes
|
| 64 |
+
# a long run crash-safe — re-running continues where it stopped.
|
| 65 |
+
kept = json.loads(out.read_text(encoding="utf-8")) if out.exists() else []
|
| 66 |
+
rejected = json.loads(rej_out.read_text(encoding="utf-8")) if rej_out.exists() else []
|
| 67 |
+
done = {r["image"] for r in kept} | {r["image"] for r in rejected}
|
| 68 |
+
if done:
|
| 69 |
+
print(f"Resuming: {len(done)} already labelled, skipping those.")
|
| 70 |
+
|
| 71 |
+
def _flush() -> None:
|
| 72 |
+
out.write_text(json.dumps(kept, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 73 |
+
rej_out.write_text(json.dumps(rejected, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 74 |
+
|
| 75 |
+
fn = modal.Function.from_name("rupkotha", "run_vision_story")
|
| 76 |
+
style_cycle = itertools.cycle(_BN_STYLES)
|
| 77 |
+
instr_cycle = itertools.cycle(_INSTRUCTIONS)
|
| 78 |
+
name_cycle = itertools.cycle(_NAMES)
|
| 79 |
+
|
| 80 |
+
for i, img_path in enumerate(images):
|
| 81 |
+
if str(img_path) in done:
|
| 82 |
+
continue
|
| 83 |
+
style, instruction, name = next(style_cycle), next(instr_cycle), next(name_cycle)
|
| 84 |
+
prompt = build_story_prompt(
|
| 85 |
+
instruction=instruction, language="bn", style=style,
|
| 86 |
+
child_name=name, stack_key="B", num_images=1,
|
| 87 |
+
)
|
| 88 |
+
try:
|
| 89 |
+
story = _teacher_story(fn, img_path.read_bytes(), prompt).strip()
|
| 90 |
+
except Exception as e: # noqa: BLE001
|
| 91 |
+
print(f"[{i}] {img_path.name}: teacher error {e}")
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
ok, stats = is_clean(story, max_foreign_letters=args.max_foreign)
|
| 95 |
+
record = {
|
| 96 |
+
"id": str(i),
|
| 97 |
+
"image": str(img_path),
|
| 98 |
+
"conversations": [
|
| 99 |
+
{"role": "user", "content": "<image>\n" + prompt},
|
| 100 |
+
{"role": "assistant", "content": story},
|
| 101 |
+
],
|
| 102 |
+
}
|
| 103 |
+
if ok:
|
| 104 |
+
kept.append(record)
|
| 105 |
+
print(f"[{i}] {img_path.name}: kept ({stats['bengali_ratio']:.2f})")
|
| 106 |
+
else:
|
| 107 |
+
record["_stats"] = stats
|
| 108 |
+
record["_leaks"] = foreign_words(story)
|
| 109 |
+
rejected.append(record)
|
| 110 |
+
print(f"[{i}] {img_path.name}: REJECTED leaks={foreign_words(story)}")
|
| 111 |
+
|
| 112 |
+
if (i + 1) % 20 == 0: # checkpoint periodically
|
| 113 |
+
_flush()
|
| 114 |
+
print(f" …checkpoint: {len(kept)} kept, {len(rejected)} rejected")
|
| 115 |
+
|
| 116 |
+
_flush()
|
| 117 |
+
print(f"\nKept {len(kept)} ({len(rejected)} rejected) of {len(images)} total")
|
| 118 |
+
print(f"Wrote {out} and {rej_out}")
|
| 119 |
+
print("⚠️ Have a Bengali speaker spot-check a sample of `kept` before training.")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
finetune/merge.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/merge.py
|
| 2 |
+
"""Stage 3 — merge the trained LoRA adapter into full MiniCPM-V weights.
|
| 3 |
+
|
| 4 |
+
vLLM serves a full model, not a base+adapter pair, so we bake the adapter in.
|
| 5 |
+
Output is a standalone HF model dir that finetune/serve_vllm.py loads.
|
| 6 |
+
|
| 7 |
+
Run:
|
| 8 |
+
uv run modal run finetune/merge.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import modal
|
| 12 |
+
|
| 13 |
+
from core.model_config import STUDENT_BASE_REPO
|
| 14 |
+
|
| 15 |
+
app = modal.App("rupkotha-finetune-merge")
|
| 16 |
+
|
| 17 |
+
_vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
|
| 18 |
+
_hf = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
|
| 19 |
+
|
| 20 |
+
# Mirror finetune/train_lora.py's dependency matrix so the adapter loads against the
|
| 21 |
+
# same stack it was trained on: transformers 4.x (the 5.x line breaks MiniCPM-V's
|
| 22 |
+
# remote-code loader), peft<0.19 (SWIFT 3.12.6's pin), torch>=2.4 (4.5 remote code),
|
| 23 |
+
# and timm/sentencepiece for the vision tower + tokenizer.
|
| 24 |
+
_image = (
|
| 25 |
+
modal.Image.debian_slim(python_version="3.10")
|
| 26 |
+
.pip_install(
|
| 27 |
+
"torch>=2.4",
|
| 28 |
+
"transformers>=4.49,<4.58",
|
| 29 |
+
"peft>=0.11,<0.19",
|
| 30 |
+
"accelerate",
|
| 31 |
+
"timm",
|
| 32 |
+
"sentencepiece",
|
| 33 |
+
"pillow",
|
| 34 |
+
)
|
| 35 |
+
# merge.py imports core.model_config at module load; make `core` importable
|
| 36 |
+
# in the container (`modal run <file>` doesn't auto-mount the project).
|
| 37 |
+
.add_local_python_source("core")
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@app.function(
|
| 42 |
+
gpu="A100-40GB",
|
| 43 |
+
image=_image,
|
| 44 |
+
volumes={"/data": _vol, "/root/.cache/huggingface": _hf},
|
| 45 |
+
secrets=[modal.Secret.from_name("algaeguard-secrets")],
|
| 46 |
+
timeout=60 * 30,
|
| 47 |
+
)
|
| 48 |
+
def merge() -> str:
|
| 49 |
+
import torch
|
| 50 |
+
from peft import PeftModel
|
| 51 |
+
from transformers import AutoModel, AutoProcessor, AutoTokenizer
|
| 52 |
+
|
| 53 |
+
adapter_dir = "/data/out/lora-bengali"
|
| 54 |
+
merged_dir = "/data/out/minicpm-v-bengali-merged"
|
| 55 |
+
|
| 56 |
+
# Defensive: ensure the base's remote-code .py files are present in the shared
|
| 57 |
+
# HF cache (they were cached weights-first by vLLM; train_lora.py repairs them,
|
| 58 |
+
# but keep merge self-contained). Cheap — weights stay cached, only code refetched.
|
| 59 |
+
from huggingface_hub import snapshot_download
|
| 60 |
+
snapshot_download(
|
| 61 |
+
STUDENT_BASE_REPO,
|
| 62 |
+
allow_patterns=["*.py", "*.json", "*.txt", "*.model", "tokenizer*"],
|
| 63 |
+
force_download=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# MiniCPM-V loads with trust_remote_code; it's an AutoModel (custom class).
|
| 67 |
+
base = AutoModel.from_pretrained(
|
| 68 |
+
STUDENT_BASE_REPO, trust_remote_code=True, torch_dtype=torch.bfloat16
|
| 69 |
+
)
|
| 70 |
+
tokenizer = AutoTokenizer.from_pretrained(STUDENT_BASE_REPO, trust_remote_code=True)
|
| 71 |
+
|
| 72 |
+
model = PeftModel.from_pretrained(base, adapter_dir)
|
| 73 |
+
model = model.merge_and_unload() # fold LoRA into the base weights
|
| 74 |
+
|
| 75 |
+
model.save_pretrained(merged_dir, safe_serialization=True)
|
| 76 |
+
tokenizer.save_pretrained(merged_dir)
|
| 77 |
+
# MiniCPM-V is multimodal: vLLM needs the image processor too (preprocessor_
|
| 78 |
+
# config.json + processing code). Without it vLLM finds only the tokenizer and
|
| 79 |
+
# rejects it ("Invalid type of HuggingFace processor"). Copy it from the base.
|
| 80 |
+
processor = AutoProcessor.from_pretrained(STUDENT_BASE_REPO, trust_remote_code=True)
|
| 81 |
+
processor.save_pretrained(merged_dir)
|
| 82 |
+
_vol.commit()
|
| 83 |
+
return merged_dir
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.local_entrypoint()
|
| 87 |
+
def main():
|
| 88 |
+
path = merge.remote()
|
| 89 |
+
print(f"Merged model written to volume rupkotha-finetune at {path}")
|
| 90 |
+
print("Next: set FINETUNED_VISION_MODEL and serve via finetune/serve_vllm.py.")
|
finetune/preprocess.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/preprocess.py
|
| 2 |
+
"""Stage 0.5 — clean the collected image pool into a training-ready set.
|
| 3 |
+
|
| 4 |
+
Sweeps finetune/data/images/**, and for each image:
|
| 5 |
+
1. optional fixed fractional crop (per source) — strips page furniture like the
|
| 6 |
+
"Kye Drawing for student" header / signature footer on the village scans;
|
| 7 |
+
2. auto-trim near-uniform borders (white margins on doodles & scans);
|
| 8 |
+
3. resize so the longest side ≤ MAX_SIZE;
|
| 9 |
+
4. RGB-normalise and re-encode;
|
| 10 |
+
5. drop near-duplicates via a 64-bit average hash (Hamming distance).
|
| 11 |
+
|
| 12 |
+
Outputs finetune/data/processed/<source>/<name>.jpg + a manifest.json. The
|
| 13 |
+
labeler (gen_labels.py) can then point at finetune/data/processed.
|
| 14 |
+
|
| 15 |
+
Run:
|
| 16 |
+
uv run python finetune/preprocess.py # process everything
|
| 17 |
+
uv run python finetune/preprocess.py --max-size 1024 --dedupe-distance 4
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from PIL import Image, ImageChops
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent
|
| 27 |
+
SRC = ROOT / "data" / "images"
|
| 28 |
+
DST = ROOT / "data" / "processed"
|
| 29 |
+
|
| 30 |
+
# Per-source fractional crop (top, bottom, left, right) to remove page furniture.
|
| 31 |
+
# "root" = images placed directly in data/images/ (the village drawing-book scans).
|
| 32 |
+
CROP_FRACTIONS: dict[str, tuple[float, float, float, float]] = {
|
| 33 |
+
"root": (0.07, 0.06, 0.02, 0.02), # trim header text + signature/date footer
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _source_of(path: Path) -> str:
|
| 38 |
+
rel = path.relative_to(SRC)
|
| 39 |
+
return rel.parts[0] if len(rel.parts) > 1 else "root"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _frac_crop(img: Image.Image, fracs: tuple[float, float, float, float]) -> Image.Image:
|
| 43 |
+
t, b, l, r = fracs
|
| 44 |
+
w, h = img.size
|
| 45 |
+
box = (int(w * l), int(h * t), int(w * (1 - r)), int(h * (1 - b)))
|
| 46 |
+
return img.crop(box) if box[2] > box[0] and box[3] > box[1] else img
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _autotrim(img: Image.Image, tol: int = 18) -> Image.Image:
|
| 50 |
+
"""Trim a near-uniform border using the top-left pixel as the background."""
|
| 51 |
+
bg = Image.new("RGB", img.size, img.getpixel((0, 0)))
|
| 52 |
+
diff = ImageChops.difference(img, bg).convert("L").point(lambda p: 255 if p > tol else 0)
|
| 53 |
+
bbox = diff.getbbox()
|
| 54 |
+
return img.crop(bbox) if bbox else img
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _resize(img: Image.Image, max_size: int) -> Image.Image:
|
| 58 |
+
w, h = img.size
|
| 59 |
+
scale = max_size / max(w, h)
|
| 60 |
+
if scale < 1:
|
| 61 |
+
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
| 62 |
+
return img
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _ahash(img: Image.Image) -> int:
|
| 66 |
+
"""64-bit average hash for near-duplicate detection (no extra deps)."""
|
| 67 |
+
small = img.convert("L").resize((8, 8), Image.LANCZOS)
|
| 68 |
+
px = list(small.getdata())
|
| 69 |
+
avg = sum(px) / len(px)
|
| 70 |
+
bits = 0
|
| 71 |
+
for i, p in enumerate(px):
|
| 72 |
+
if p >= avg:
|
| 73 |
+
bits |= 1 << i
|
| 74 |
+
return bits
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _hamming(a: int, b: int) -> int:
|
| 78 |
+
return bin(a ^ b).count("1")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def process(max_size: int, dedupe_distance: int) -> None:
|
| 82 |
+
exts = {".png", ".jpg", ".jpeg", ".webp"}
|
| 83 |
+
paths = sorted(p for p in SRC.rglob("*") if p.suffix.lower() in exts)
|
| 84 |
+
if not paths:
|
| 85 |
+
raise SystemExit(f"No images under {SRC}")
|
| 86 |
+
|
| 87 |
+
DST.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
hashes: list[int] = []
|
| 89 |
+
manifest, kept, dups, errors = [], 0, 0, 0
|
| 90 |
+
|
| 91 |
+
for p in paths:
|
| 92 |
+
source = _source_of(p)
|
| 93 |
+
try:
|
| 94 |
+
img = Image.open(p).convert("RGB")
|
| 95 |
+
img = _frac_crop(img, CROP_FRACTIONS.get(source, (0, 0, 0, 0)))
|
| 96 |
+
img = _autotrim(img)
|
| 97 |
+
img = _resize(img, max_size)
|
| 98 |
+
except Exception as e: # noqa: BLE001
|
| 99 |
+
print(f" skip {p.name}: {e}")
|
| 100 |
+
errors += 1
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
h = _ahash(img)
|
| 104 |
+
if any(_hamming(h, prev) <= dedupe_distance for prev in hashes):
|
| 105 |
+
dups += 1
|
| 106 |
+
continue
|
| 107 |
+
hashes.append(h)
|
| 108 |
+
|
| 109 |
+
out_dir = DST / source
|
| 110 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
out_path = out_dir / f"{p.stem}.jpg"
|
| 112 |
+
img.save(out_path, "JPEG", quality=90)
|
| 113 |
+
manifest.append({"processed": str(out_path.relative_to(ROOT)),
|
| 114 |
+
"source_image": str(p.relative_to(ROOT)), "source": source})
|
| 115 |
+
kept += 1
|
| 116 |
+
|
| 117 |
+
(DST / "manifest.json").write_text(
|
| 118 |
+
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 119 |
+
)
|
| 120 |
+
print(f"\nProcessed {len(paths)} → kept {kept}, dropped {dups} dups, {errors} errors")
|
| 121 |
+
by_src: dict[str, int] = {}
|
| 122 |
+
for m in manifest:
|
| 123 |
+
by_src[m["source"]] = by_src.get(m["source"], 0) + 1
|
| 124 |
+
for s, c in sorted(by_src.items()):
|
| 125 |
+
print(f" {s}: {c}")
|
| 126 |
+
print(f"Output: {DST} (+ manifest.json)")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def main() -> None:
|
| 130 |
+
ap = argparse.ArgumentParser()
|
| 131 |
+
ap.add_argument("--max-size", type=int, default=1024)
|
| 132 |
+
ap.add_argument("--dedupe-distance", type=int, default=4,
|
| 133 |
+
help="max aHash Hamming distance to treat as duplicate (0=exact)")
|
| 134 |
+
args = ap.parse_args()
|
| 135 |
+
process(args.max_size, args.dedupe_distance)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
finetune/purity.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/purity.py
|
| 2 |
+
"""Bengali script-purity quality gate for teacher labels.
|
| 3 |
+
|
| 4 |
+
The teacher (Gemma) writes mostly clean Bengali, but occasionally code-switches
|
| 5 |
+
(e.g. a stray Latin or Cyrillic word — we saw `зеленая` leak once). Distillation
|
| 6 |
+
caps the student at the label quality, so we filter/repair bad labels before
|
| 7 |
+
training. This is the gate referenced in the Bengali-quality investigation.
|
| 8 |
+
|
| 9 |
+
Heuristics only — no model needed. A human Bengali speaker should still spot-check
|
| 10 |
+
a sample of what passes.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import unicodedata
|
| 15 |
+
|
| 16 |
+
# Bengali Unicode block: U+0980–U+09FF.
|
| 17 |
+
_BENGALI = re.compile(r"[ঀ-]")
|
| 18 |
+
# Letters from other scripts that must NOT appear in a Bengali story body.
|
| 19 |
+
_LATIN = re.compile(r"[A-Za-z]")
|
| 20 |
+
_CYRILLIC = re.compile(r"[Ѐ-ӿ]")
|
| 21 |
+
# Allowed non-letter noise: digits, whitespace, common punctuation, emoji, danda.
|
| 22 |
+
_ALLOWED_NONLETTER = re.compile(r"[\s\d\.,!?…\"'“”‘’—\-–—:;()।॥☀-➿\U0001F300-\U0001FAFF]")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def script_stats(text: str) -> dict:
|
| 26 |
+
"""Counts of Bengali vs foreign letters and the Bengali-letter ratio."""
|
| 27 |
+
text = unicodedata.normalize("NFC", text or "")
|
| 28 |
+
bengali = len(_BENGALI.findall(text))
|
| 29 |
+
latin = len(_LATIN.findall(text))
|
| 30 |
+
cyrillic = len(_CYRILLIC.findall(text))
|
| 31 |
+
letters = bengali + latin + cyrillic
|
| 32 |
+
ratio = (bengali / letters) if letters else 0.0
|
| 33 |
+
return {
|
| 34 |
+
"bengali": bengali,
|
| 35 |
+
"latin": latin,
|
| 36 |
+
"cyrillic": cyrillic,
|
| 37 |
+
"foreign": latin + cyrillic,
|
| 38 |
+
"bengali_ratio": round(ratio, 4),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def is_clean(
|
| 43 |
+
text: str,
|
| 44 |
+
min_bengali_ratio: float = 0.98,
|
| 45 |
+
max_foreign_letters: int = 0,
|
| 46 |
+
min_length: int = 40,
|
| 47 |
+
) -> tuple[bool, dict]:
|
| 48 |
+
"""Decide whether a teacher label is clean enough to train on.
|
| 49 |
+
|
| 50 |
+
Defaults are strict: essentially zero foreign-script letters. Loosen
|
| 51 |
+
max_foreign_letters to 1–2 if you'd rather repair than drop.
|
| 52 |
+
|
| 53 |
+
Returns (ok, stats).
|
| 54 |
+
"""
|
| 55 |
+
stats = script_stats(text)
|
| 56 |
+
ok = (
|
| 57 |
+
len((text or "").strip()) >= min_length
|
| 58 |
+
and stats["foreign"] <= max_foreign_letters
|
| 59 |
+
and stats["bengali_ratio"] >= min_bengali_ratio
|
| 60 |
+
)
|
| 61 |
+
return ok, stats
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def foreign_words(text: str) -> list[str]:
|
| 65 |
+
"""Return whitespace tokens that contain any Latin/Cyrillic letter — useful
|
| 66 |
+
for eyeballing exactly what leaked (e.g. ['зеленая'])."""
|
| 67 |
+
out = []
|
| 68 |
+
for tok in (text or "").split():
|
| 69 |
+
if _LATIN.search(tok) or _CYRILLIC.search(tok):
|
| 70 |
+
out.append(tok)
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
samples = {
|
| 76 |
+
"good": "আচ্ছা রূপা, চোখ বুজে নাও। চাঁদমামা হাসছে, পুকুরের ধারে ঘাস দুলছে। শুভরাত্রি।",
|
| 77 |
+
"leak": "দেখছো зеленая গোল বলটা? ওটা রূপার প্রিয় খেলনা সবুজ আপেল!",
|
| 78 |
+
"english": "Once upon a time there was a small red house under the sun.",
|
| 79 |
+
}
|
| 80 |
+
for name, s in samples.items():
|
| 81 |
+
ok, stats = is_clean(s)
|
| 82 |
+
print(f"{name:8} ok={ok!s:5} {stats} leaks={foreign_words(s)}")
|
finetune/serve_vllm.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/serve_vllm.py
|
| 2 |
+
"""Stage 4 — serve the merged fine-tuned MiniCPM-V via vLLM on Modal.
|
| 3 |
+
|
| 4 |
+
Chosen over GGUF→Ollama because custom MiniCPM-V → GGUF has known vision (mmproj)
|
| 5 |
+
breakage. vLLM loads the merged HF model directly with trust_remote_code and
|
| 6 |
+
handles the multimodal input natively.
|
| 7 |
+
|
| 8 |
+
This exposes `run_vision_story_ft(image_bytes_list, prompt)` — a drop-in for the
|
| 9 |
+
Bengali vision path. To route the app to it, in core/vision_story.py call this
|
| 10 |
+
function (via a small dispatcher in core/modal_infra.py) when a fine-tuned model
|
| 11 |
+
is active, i.e. when model_config.FINETUNED_VISION_MODEL is set.
|
| 12 |
+
|
| 13 |
+
Run / deploy:
|
| 14 |
+
uv run modal deploy finetune/serve_vllm.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import modal
|
| 18 |
+
|
| 19 |
+
from core.model_config import MODAL_MIN_CONTAINERS
|
| 20 |
+
|
| 21 |
+
app = modal.App("rupkotha-ft-serve")
|
| 22 |
+
|
| 23 |
+
_vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
|
| 24 |
+
_MERGED_DIR = "/data/out/minicpm-v-bengali-merged"
|
| 25 |
+
|
| 26 |
+
# vLLM 0.10.2 is the version the official MiniCPM-V CookBook pins for 4.5. It only
|
| 27 |
+
# requires transformers>=4.55.2 (no upper bound), so pip otherwise grabs transformers
|
| 28 |
+
# 5.x — whose tokenizer backend breaks vLLM (TokenizersBackend.all_special_tokens_
|
| 29 |
+
# extended) AND MiniCPM-V's remote code. Pin transformers==4.57.6 (same 4.x the merge
|
| 30 |
+
# step used with this model) to stay compatible with both.
|
| 31 |
+
_vllm_image = (
|
| 32 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 33 |
+
.pip_install("vllm==0.10.2", "transformers==4.57.6", "pillow")
|
| 34 |
+
# serve_vllm.py imports core.model_config at module load; make `core`
|
| 35 |
+
# importable in the container (current Modal doesn't auto-mount the project).
|
| 36 |
+
.add_local_python_source("core")
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_engine: dict = {}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.function(
|
| 43 |
+
# A100-40GB, not A10G: the full bf16 8B weights (~16GB) + vision encoder + vLLM
|
| 44 |
+
# overhead leave no room for the KV cache on a 24GB A10G (CUDA OOM at startup).
|
| 45 |
+
# 40GB gives comfortable headroom at max_model_len=4096. Scales to zero when idle.
|
| 46 |
+
gpu="A100-40GB",
|
| 47 |
+
image=_vllm_image,
|
| 48 |
+
volumes={"/data": _vol},
|
| 49 |
+
timeout=600,
|
| 50 |
+
min_containers=MODAL_MIN_CONTAINERS, # keep-warm honors model_config toggle
|
| 51 |
+
)
|
| 52 |
+
def run_vision_story_ft(image_bytes_list: list[bytes], prompt: str) -> str:
|
| 53 |
+
"""Generate a story from the fine-tuned model via vLLM. Mirrors the contract
|
| 54 |
+
of core/modal_infra.run_vision_story (returns text, '' on failure)."""
|
| 55 |
+
import io
|
| 56 |
+
|
| 57 |
+
from PIL import Image
|
| 58 |
+
from transformers import AutoTokenizer
|
| 59 |
+
from vllm import LLM, SamplingParams
|
| 60 |
+
|
| 61 |
+
if "llm" not in _engine:
|
| 62 |
+
# max_model_len 4096 matches the SWIFT training length: the verbose Bengali
|
| 63 |
+
# prompt + expanded image tokens exceed 2048 for some inputs.
|
| 64 |
+
_engine["llm"] = LLM(
|
| 65 |
+
model=_MERGED_DIR,
|
| 66 |
+
trust_remote_code=True,
|
| 67 |
+
max_model_len=4096,
|
| 68 |
+
disable_mm_preprocessor_cache=True,
|
| 69 |
+
limit_mm_per_prompt={"image": 4},
|
| 70 |
+
)
|
| 71 |
+
tok = AutoTokenizer.from_pretrained(_MERGED_DIR, trust_remote_code=True)
|
| 72 |
+
_engine["tok"] = tok
|
| 73 |
+
# Stop on MiniCPM-V's chat-end tokens so we don't bleed past the story.
|
| 74 |
+
stop = ["<|im_end|>", "<|endoftext|>"]
|
| 75 |
+
_engine["sp"] = SamplingParams(
|
| 76 |
+
temperature=0.45, top_p=0.9, max_tokens=768,
|
| 77 |
+
stop_token_ids=[tok.convert_tokens_to_ids(t) for t in stop],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
llm = _engine["llm"]
|
| 81 |
+
tok = _engine["tok"]
|
| 82 |
+
images = [Image.open(io.BytesIO(b)).convert("RGB") for b in (image_bytes_list or [])]
|
| 83 |
+
# MiniCPM-V 4.5: one (<image>./</image>) placeholder per image, then the text.
|
| 84 |
+
# Wrap via the model's chat template (add_generation_prompt) so inference matches
|
| 85 |
+
# the minicpmv4_5 template the LoRA was trained under — critical for quality.
|
| 86 |
+
placeholders = "".join("(<image>./</image>)\n" for _ in images)
|
| 87 |
+
messages = [{"role": "user", "content": placeholders + prompt}]
|
| 88 |
+
# MiniCPM-V 4.5 has a hybrid thinking mode that prepends <think>...</think> and
|
| 89 |
+
# eats the token budget. Disable it (best-effort — older templates lack the kwarg).
|
| 90 |
+
try:
|
| 91 |
+
text = tok.apply_chat_template(
|
| 92 |
+
messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
|
| 93 |
+
)
|
| 94 |
+
except TypeError:
|
| 95 |
+
text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 96 |
+
try:
|
| 97 |
+
out = llm.generate(
|
| 98 |
+
{"prompt": text, "multi_modal_data": {"image": images}},
|
| 99 |
+
_engine["sp"],
|
| 100 |
+
)
|
| 101 |
+
story = (out[0].outputs[0].text or "").strip()
|
| 102 |
+
# Strip any residual think block the model still emits.
|
| 103 |
+
import re
|
| 104 |
+
story = re.sub(r"^\s*<think>.*?</think>\s*", "", story, flags=re.DOTALL).strip()
|
| 105 |
+
return story
|
| 106 |
+
except Exception as e: # noqa: BLE001
|
| 107 |
+
print(f"[serve_vllm] generation failed: {e}")
|
| 108 |
+
return ""
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def generate_story_ft_remote(images_b64: list[str], prompt: str) -> str:
|
| 112 |
+
"""Plain-callable dispatcher (import into core/modal_infra.py to route the
|
| 113 |
+
Bengali path here when FINETUNED_VISION_MODEL is set). Mirrors
|
| 114 |
+
generate_story_remote so core/vision_story.py needs only a one-line branch."""
|
| 115 |
+
import base64
|
| 116 |
+
|
| 117 |
+
image_bytes = [base64.b64decode(b) for b in (images_b64 or [])]
|
| 118 |
+
fn = modal.Function.from_name("rupkotha-ft-serve", "run_vision_story_ft")
|
| 119 |
+
return fn.remote(image_bytes, prompt)
|
finetune/train_lora.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# finetune/train_lora.py
|
| 2 |
+
"""Stage 2 — LoRA fine-tune MiniCPM-V 4.5 on the distilled Bengali dataset.
|
| 3 |
+
|
| 4 |
+
Uses **ms-SWIFT** (modelscope/ms-swift) rather than OpenBMB's official finetune
|
| 5 |
+
scripts. The official path hit a hard dependency wall: its requirements pin torch
|
| 6 |
+
2.1.2, but MiniCPM-V 4.5's remote code needs torch>=2.4 (`torch.library.custom_op`),
|
| 7 |
+
and the repo's finetune.py targets older MiniCPM releases (4.0/2.6/2.5), not 4.5.
|
| 8 |
+
SWIFT ships a maintained recipe for this exact model — `model_type=minicpmv4_5`,
|
| 9 |
+
`template=minicpmv4_5`, deps `timm, transformers>=4.36, decord` — so the version
|
| 10 |
+
matrix is solved for us.
|
| 11 |
+
|
| 12 |
+
Vision encoder frozen (`--freeze_vit true`); LoRA on the LLM self-attention layers
|
| 13 |
+
only (`q/k/v/o_proj`) — that's the weak-Bengali part. ViT and aligner stay frozen.
|
| 14 |
+
|
| 15 |
+
Inputs (Modal Volume `rupkotha-finetune`): /train.json + /labelset/*.jpg
|
| 16 |
+
(train.json 'image' fields are container-absolute: /data/labelset/<name>)
|
| 17 |
+
Converted in-container to SWIFT's messages/images JSONL schema.
|
| 18 |
+
Output (same volume): /out/lora-bengali/ (PEFT adapter — feed to finetune/merge.py)
|
| 19 |
+
|
| 20 |
+
Run a SHORT validation first to surface integration errors cheaply:
|
| 21 |
+
uv run modal run finetune/train_lora.py --max-steps 4
|
| 22 |
+
Then the full run:
|
| 23 |
+
uv run modal run finetune/train_lora.py
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import modal
|
| 27 |
+
|
| 28 |
+
from core.model_config import STUDENT_BASE_REPO # openbmb/MiniCPM-V-4_5
|
| 29 |
+
|
| 30 |
+
app = modal.App("rupkotha-finetune")
|
| 31 |
+
|
| 32 |
+
_vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
|
| 33 |
+
_hf = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
|
| 34 |
+
|
| 35 |
+
# ms-SWIFT brings its own pinned transformers/peft/accelerate; we only add the
|
| 36 |
+
# MiniCPM-V 4.5 model extras (timm, decord) and force torch>=2.4 for its remote
|
| 37 |
+
# code. No deepspeed/nvcc needed — a single A100-80GB fits an 8B base + LoRA, and
|
| 38 |
+
# attn defaults to sdpa (no flash-attn build), so debian_slim + pip wheels suffice.
|
| 39 |
+
# Pin ms-swift 3.12.6 (last 3.x): it lists model_type `minicpmv4_5` AND pins
|
| 40 |
+
# transformers>=4.33,<4.58. The 4.x line pulls transformers 5.x, whose remote-code
|
| 41 |
+
# loader follows the HF-cache symlink into blobs/ and fails to resolve MiniCPM-V's
|
| 42 |
+
# relative imports (modeling_navit_siglip.py). 4.x transformers loads it cleanly.
|
| 43 |
+
_train_image = (
|
| 44 |
+
modal.Image.debian_slim(python_version="3.10")
|
| 45 |
+
.apt_install("git")
|
| 46 |
+
.pip_install(
|
| 47 |
+
"ms-swift==3.12.6",
|
| 48 |
+
"torch>=2.4",
|
| 49 |
+
"timm",
|
| 50 |
+
"decord",
|
| 51 |
+
"pillow",
|
| 52 |
+
"sentencepiece",
|
| 53 |
+
)
|
| 54 |
+
# train_lora.py imports core.model_config at module load; `modal run <file>`
|
| 55 |
+
# doesn't auto-mount the project, so make `core` importable in the container.
|
| 56 |
+
.add_local_python_source("core")
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.function(
|
| 61 |
+
gpu="A100-80GB", # headroom for an 8B base + LoRA on one GPU
|
| 62 |
+
image=_train_image,
|
| 63 |
+
volumes={"/data": _vol, "/root/.cache/huggingface": _hf},
|
| 64 |
+
secrets=[modal.Secret.from_name("algaeguard-secrets")], # HF_TOKEN for the base
|
| 65 |
+
timeout=60 * 60 * 6,
|
| 66 |
+
)
|
| 67 |
+
def train(max_steps: int = 0) -> str:
|
| 68 |
+
import glob
|
| 69 |
+
import json
|
| 70 |
+
import os
|
| 71 |
+
import shutil
|
| 72 |
+
import subprocess
|
| 73 |
+
|
| 74 |
+
# SWIFT defaults to ModelScope; force HuggingFace so it pulls openbmb/MiniCPM-V-4_5
|
| 75 |
+
# (and reuses the HF_TOKEN from the algaeguard-secrets secret).
|
| 76 |
+
os.environ["USE_HF"] = "1"
|
| 77 |
+
# Mirror the original max_slice_nums=9 (SWIFT reads this env for MiniCPM-V).
|
| 78 |
+
os.environ.setdefault("MAX_SLICE_NUMS", "9")
|
| 79 |
+
|
| 80 |
+
# Repair the shared HF cache: the base model was cached weights-first (vLLM),
|
| 81 |
+
# leaving the MiniCPM-V remote-code .py files as dangling symlinks (e.g.
|
| 82 |
+
# modeling_navit_siglip.py). Refetch the small code/config files cleanly so
|
| 83 |
+
# trust_remote_code loads; the .safetensors weights stay cached untouched.
|
| 84 |
+
from huggingface_hub import snapshot_download
|
| 85 |
+
snapshot_download(
|
| 86 |
+
STUDENT_BASE_REPO,
|
| 87 |
+
allow_patterns=["*.py", "*.json", "*.txt", "*.model", "tokenizer*"],
|
| 88 |
+
force_download=True,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# ── Convert MiniCPM conversations format → SWIFT messages/images JSONL ──
|
| 92 |
+
# Source rows already carry role/content turns with a leading <image> in the
|
| 93 |
+
# user content and a container-absolute image path. SWIFT wants `messages` +
|
| 94 |
+
# an `images` list (one path per <image> placeholder).
|
| 95 |
+
src = "/data/train.json" # volume copy: image paths already /data/labelset/<name>
|
| 96 |
+
swift_data = "/data/train_swift.jsonl"
|
| 97 |
+
rows = json.load(open(src))
|
| 98 |
+
with open(swift_data, "w") as f:
|
| 99 |
+
for r in rows:
|
| 100 |
+
f.write(json.dumps(
|
| 101 |
+
{"messages": r["conversations"], "images": [r["image"]]},
|
| 102 |
+
ensure_ascii=False,
|
| 103 |
+
) + "\n")
|
| 104 |
+
print(f"Converted {len(rows)} examples → {swift_data}")
|
| 105 |
+
|
| 106 |
+
adapter_dir = "/data/out/lora-bengali" # canonical path merge.py expects
|
| 107 |
+
swift_out = "/data/out/swift-runs" # SWIFT writes vX-<ts>/checkpoint-N here
|
| 108 |
+
|
| 109 |
+
# Mirror finetune_lora.sh intent via SWIFT's CLI: vision frozen, LoRA r=16 on
|
| 110 |
+
# the LLM self-attention projections only.
|
| 111 |
+
cmd = [
|
| 112 |
+
"swift", "sft",
|
| 113 |
+
"--model", STUDENT_BASE_REPO,
|
| 114 |
+
"--model_type", "minicpmv4_5",
|
| 115 |
+
"--train_type", "lora",
|
| 116 |
+
"--dataset", swift_data,
|
| 117 |
+
"--freeze_vit", "true",
|
| 118 |
+
"--target_modules", "q_proj", "k_proj", "v_proj", "o_proj",
|
| 119 |
+
"--lora_rank", "16", "--lora_alpha", "32", "--lora_dropout", "0.05",
|
| 120 |
+
"--torch_dtype", "bfloat16",
|
| 121 |
+
# 4096, not 2048: the verbose Bengali prompt + MiniCPM image slices
|
| 122 |
+
# (max_slice_nums=9) push some rows to ~2083 tokens. At 2048 SWIFT raises
|
| 123 |
+
# MaxLengthError per over-long row and silently drops it from training;
|
| 124 |
+
# 4096 keeps all 389 samples (peak mem was only ~28 GiB of 80).
|
| 125 |
+
"--max_length", "4096",
|
| 126 |
+
"--per_device_train_batch_size", "1",
|
| 127 |
+
"--gradient_accumulation_steps", "8",
|
| 128 |
+
"--learning_rate", "1e-4",
|
| 129 |
+
"--gradient_checkpointing", "true",
|
| 130 |
+
"--save_strategy", "steps", "--save_steps", "200", "--save_total_limit", "2",
|
| 131 |
+
"--logging_steps", "5", "--report_to", "none",
|
| 132 |
+
"--dataloader_num_workers", "4",
|
| 133 |
+
"--output_dir", swift_out,
|
| 134 |
+
]
|
| 135 |
+
# Short validation run vs full run.
|
| 136 |
+
if max_steps and max_steps > 0:
|
| 137 |
+
cmd += ["--max_steps", str(max_steps)]
|
| 138 |
+
else:
|
| 139 |
+
cmd += ["--num_train_epochs", "3"]
|
| 140 |
+
|
| 141 |
+
print("Running:", " ".join(cmd))
|
| 142 |
+
subprocess.run(cmd, check=True)
|
| 143 |
+
|
| 144 |
+
# SWIFT nests output under output_dir/<version>/checkpoint-<step>/. Find the
|
| 145 |
+
# latest dir that actually holds a PEFT adapter and copy it to the canonical
|
| 146 |
+
# path so finetune/merge.py (which loads /data/out/lora-bengali) works unchanged.
|
| 147 |
+
adapters = glob.glob(f"{swift_out}/**/adapter_config.json", recursive=True)
|
| 148 |
+
if not adapters:
|
| 149 |
+
raise RuntimeError(f"No adapter produced under {swift_out}")
|
| 150 |
+
final_ckpt = max(
|
| 151 |
+
(os.path.dirname(p) for p in adapters), key=os.path.getmtime
|
| 152 |
+
)
|
| 153 |
+
print(f"Final adapter checkpoint: {final_ckpt}")
|
| 154 |
+
if os.path.exists(adapter_dir):
|
| 155 |
+
shutil.rmtree(adapter_dir)
|
| 156 |
+
shutil.copytree(final_ckpt, adapter_dir)
|
| 157 |
+
|
| 158 |
+
_vol.commit()
|
| 159 |
+
return adapter_dir
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.local_entrypoint()
|
| 163 |
+
def main(max_steps: int = 0):
|
| 164 |
+
path = train.remote(max_steps=max_steps)
|
| 165 |
+
print(f"LoRA adapter written to volume rupkotha-finetune at {path}")
|
| 166 |
+
print("Next: finetune/merge.py to fold the adapter into full weights.")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "rupkatha"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
requires-python = ">=3.10,<3.13"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"gradio>=6.17.3",
|
| 7 |
+
"modal>=1.5.0",
|
| 8 |
+
]
|
| 9 |
+
|
| 10 |
+
[dependency-groups]
|
| 11 |
+
finetune = [
|
| 12 |
+
"datasets>=5.0.0",
|
| 13 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space (thin client) dependencies ONLY.
|
| 2 |
+
# The Space runs app.py, which calls Modal for all inference — it holds zero model
|
| 3 |
+
# weights and imports no ML libraries locally. So this is intentionally minimal:
|
| 4 |
+
# just Gradio (the UI) and the Modal client (to invoke the deployed functions).
|
| 5 |
+
# The heavy deps (torch, vllm, transformers, coqui-tts, …) live in the Modal images
|
| 6 |
+
# in core/modal_infra.py + finetune/, NOT here. Local dev uses uv (pyproject/uv.lock).
|
| 7 |
+
gradio==6.17.3
|
| 8 |
+
modal>=1.5
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|