cvpfus Codex commited on
Commit ·
d19068b
1
Parent(s): cf4bbc0
Preload image descriptions for reader mode
Browse filesCo-authored-by: Codex <codex@openai.com>
- FIELD_NOTES.md +2 -0
- README.md +3 -1
- app.py +49 -0
- scripts/verify.py +14 -0
- static/app.js +31 -4
- static/index.html +4 -0
FIELD_NOTES.md
CHANGED
|
@@ -44,3 +44,5 @@ Keyboard map:
|
|
| 44 |
## Fallback Strategy
|
| 45 |
|
| 46 |
Live accessibility demos need dependable behavior. If llama.cpp is not available, the app uses deterministic local narration prefixes. If Kokoro is not installed or cannot load a voice, the app returns a short silent WAV while keeping the transcript visible. If the VLM integration is unavailable, image ids map to cached alt text.
|
|
|
|
|
|
|
|
|
| 44 |
## Fallback Strategy
|
| 45 |
|
| 46 |
Live accessibility demos need dependable behavior. If llama.cpp is not available, the app uses deterministic local narration prefixes. If Kokoro is not installed or cannot load a voice, the app returns a short silent WAV while keeping the transcript visible. If the VLM integration is unavailable, image ids map to cached alt text.
|
| 47 |
+
|
| 48 |
+
The app preloads image descriptions through `/api/image-descriptions`, caches them in the frontend, and writes the descriptions back into each image's `alt` attribute. That keeps the visible article, transcript, and spoken image narration aligned.
|
README.md
CHANGED
|
@@ -50,7 +50,7 @@ Start the app:
|
|
| 50 |
python app.py
|
| 51 |
```
|
| 52 |
|
| 53 |
-
Open the local URL printed by Gradio. The custom frontend calls `/api/reader-brain`, `/api/describe-image`, `/api/speak`, and `/api/generate-image`.
|
| 54 |
|
| 55 |
Useful environment variables:
|
| 56 |
|
|
@@ -85,3 +85,5 @@ The frontend builds a reading queue from semantic article nodes. When screen-rea
|
|
| 85 |
Each readable node is sent to the reader brain for concise narration, then Kokoro generates speech. If a model is unavailable, the app uses deterministic fallbacks so the demo remains navigable.
|
| 86 |
|
| 87 |
The session panel keeps a transcript of recent narration with copy and clear controls, making the spoken path inspectable during demos and useful for the Field Notes write-up.
|
|
|
|
|
|
|
|
|
| 50 |
python app.py
|
| 51 |
```
|
| 52 |
|
| 53 |
+
Open the local URL printed by Gradio. The custom frontend calls `/api/reader-brain`, `/api/image-descriptions`, `/api/describe-image`, `/api/speak`, and `/api/generate-image`.
|
| 54 |
|
| 55 |
Useful environment variables:
|
| 56 |
|
|
|
|
| 85 |
Each readable node is sent to the reader brain for concise narration, then Kokoro generates speech. If a model is unavailable, the app uses deterministic fallbacks so the demo remains navigable.
|
| 86 |
|
| 87 |
The session panel keeps a transcript of recent narration with copy and clear controls, making the spoken path inspectable during demos and useful for the Field Notes write-up.
|
| 88 |
+
|
| 89 |
+
Image descriptions are preloaded into a local cache and written into the page's real `img alt` attributes. When the planned MiniCPM-V runtime is unavailable, deterministic alt-text fallbacks keep the screen-reader path usable.
|
app.py
CHANGED
|
@@ -53,6 +53,27 @@ MODEL_MANIFEST: dict[str, dict[str, str]] = {
|
|
| 53 |
},
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
ARTICLE_MANIFEST: dict[str, Any] = {
|
| 57 |
"title": "A tiny model reader that turns articles into guided narration",
|
| 58 |
"reader_controls": [
|
|
@@ -66,6 +87,7 @@ ARTICLE_MANIFEST: dict[str, Any] = {
|
|
| 66 |
{"key": "Esc", "action": "Stop audio"},
|
| 67 |
],
|
| 68 |
"bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"],
|
|
|
|
| 69 |
"models": MODEL_MANIFEST,
|
| 70 |
}
|
| 71 |
|
|
@@ -209,6 +231,23 @@ def describe_image_core(image_id: str, caption: str | None, prompt: str | None)
|
|
| 209 |
}
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def _silent_wav(path: Path, seconds: float = 0.35, sample_rate: int = 24000) -> None:
|
| 213 |
frames = int(seconds * sample_rate)
|
| 214 |
with wave.open(str(path), "wb") as wav:
|
|
@@ -290,6 +329,11 @@ async def describe_image_endpoint(payload: ImageDescriptionRequest) -> JSONRespo
|
|
| 290 |
return _json(describe_image_core(payload.image_id, payload.caption, payload.prompt))
|
| 291 |
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
@app.post("/api/speak")
|
| 294 |
async def speak_endpoint(payload: SpeechRequest) -> JSONResponse:
|
| 295 |
return _json(speak_core(payload.text, payload.voice, payload.speed))
|
|
@@ -310,6 +354,11 @@ def describe_image_api(image_id: str, caption: str = "", prompt: str = "") -> st
|
|
| 310 |
return json.dumps(describe_image_core(image_id, caption, prompt))
|
| 311 |
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
@app.api(name="speak")
|
| 314 |
def speak_api(text: str, voice: str = "af_heart", speed: float = 1.0) -> str:
|
| 315 |
return json.dumps(speak_core(text, voice, speed))
|
|
|
|
| 53 |
},
|
| 54 |
}
|
| 55 |
|
| 56 |
+
ARTICLE_IMAGES: list[dict[str, str]] = [
|
| 57 |
+
{
|
| 58 |
+
"id": "desk-reader",
|
| 59 |
+
"asset_url": "/static/generated/desk-reader.svg",
|
| 60 |
+
"caption": "The article view doubles as the demo surface, so every feature has a real reading task.",
|
| 61 |
+
"prompt": "Accessibility article reader with highlighted paragraph and narration controls.",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "model-map",
|
| 65 |
+
"asset_url": "/static/generated/model-map.svg",
|
| 66 |
+
"caption": "Each model stays at or below four billion parameters for Tiny Titan eligibility.",
|
| 67 |
+
"prompt": "Diagram of four small AI models working together in an accessibility reader.",
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"id": "field-notes",
|
| 71 |
+
"asset_url": "/static/generated/field-notes.svg",
|
| 72 |
+
"caption": "Field notes document the choices behind the screen-reader behavior.",
|
| 73 |
+
"prompt": "Notebook page with model sizes, keyboard controls, and accessibility checks.",
|
| 74 |
+
},
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
ARTICLE_MANIFEST: dict[str, Any] = {
|
| 78 |
"title": "A tiny model reader that turns articles into guided narration",
|
| 79 |
"reader_controls": [
|
|
|
|
| 87 |
{"key": "Esc", "action": "Stop audio"},
|
| 88 |
],
|
| 89 |
"bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"],
|
| 90 |
+
"images": ARTICLE_IMAGES,
|
| 91 |
"models": MODEL_MANIFEST,
|
| 92 |
}
|
| 93 |
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
|
| 234 |
+
def describe_article_images_core() -> dict[str, Any]:
|
| 235 |
+
descriptions = []
|
| 236 |
+
for image in ARTICLE_IMAGES:
|
| 237 |
+
description = describe_image_core(
|
| 238 |
+
image["id"],
|
| 239 |
+
caption=image.get("caption"),
|
| 240 |
+
prompt=image.get("prompt"),
|
| 241 |
+
)
|
| 242 |
+
descriptions.append({**image, **description})
|
| 243 |
+
return {
|
| 244 |
+
"ok": True,
|
| 245 |
+
"runtime": "MiniCPM-V placeholder",
|
| 246 |
+
"model": "OpenBMB MiniCPM-V-2",
|
| 247 |
+
"descriptions": descriptions,
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
def _silent_wav(path: Path, seconds: float = 0.35, sample_rate: int = 24000) -> None:
|
| 252 |
frames = int(seconds * sample_rate)
|
| 253 |
with wave.open(str(path), "wb") as wav:
|
|
|
|
| 329 |
return _json(describe_image_core(payload.image_id, payload.caption, payload.prompt))
|
| 330 |
|
| 331 |
|
| 332 |
+
@app.get("/api/image-descriptions")
|
| 333 |
+
async def image_descriptions_endpoint() -> JSONResponse:
|
| 334 |
+
return _json(describe_article_images_core())
|
| 335 |
+
|
| 336 |
+
|
| 337 |
@app.post("/api/speak")
|
| 338 |
async def speak_endpoint(payload: SpeechRequest) -> JSONResponse:
|
| 339 |
return _json(speak_core(payload.text, payload.voice, payload.speed))
|
|
|
|
| 354 |
return json.dumps(describe_image_core(image_id, caption, prompt))
|
| 355 |
|
| 356 |
|
| 357 |
+
@app.api(name="describe_article_images")
|
| 358 |
+
def describe_article_images_api() -> str:
|
| 359 |
+
return json.dumps(describe_article_images_core())
|
| 360 |
+
|
| 361 |
+
|
| 362 |
@app.api(name="speak")
|
| 363 |
def speak_api(text: str, voice: str = "af_heart", speed: float = 1.0) -> str:
|
| 364 |
return json.dumps(speak_core(text, voice, speed))
|
scripts/verify.py
CHANGED
|
@@ -54,6 +54,10 @@ def verify_core_fallbacks() -> None:
|
|
| 54 |
assert_true(description["ok"], "Image description did not return ok")
|
| 55 |
assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
speech = app.speak_core("Tiny Narrator verification.", voice="af_heart", speed=1.0)
|
| 58 |
assert_true(speech["ok"], "Speech path did not return ok")
|
| 59 |
audio_path = ROOT / speech["audio_url"].lstrip("/")
|
|
@@ -71,6 +75,7 @@ def verify_routes() -> None:
|
|
| 71 |
assert_true("Tiny Narrator" in home.text, "Home route should include app title")
|
| 72 |
assert_true("readerToggle" in home.text, "Home route should include reader toggle")
|
| 73 |
assert_true("summaryButton" in home.text, "Home route should include summary control")
|
|
|
|
| 74 |
assert_true("transcriptLog" in home.text, "Home route should include transcript log")
|
| 75 |
|
| 76 |
health = client.get("/api/health")
|
|
@@ -95,6 +100,15 @@ def verify_routes() -> None:
|
|
| 95 |
"Manifest should document Kokoro speech model",
|
| 96 |
)
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
def main() -> None:
|
| 100 |
py_compile.compile(str(ROOT / "app.py"), doraise=True)
|
|
|
|
| 54 |
assert_true(description["ok"], "Image description did not return ok")
|
| 55 |
assert_true("small AI models" in description["alt_text"], "Image description fallback changed unexpectedly")
|
| 56 |
|
| 57 |
+
article_descriptions = app.describe_article_images_core()
|
| 58 |
+
assert_true(article_descriptions["ok"], "Article image descriptions did not return ok")
|
| 59 |
+
assert_true(len(article_descriptions["descriptions"]) == 3, "Article should expose three image descriptions")
|
| 60 |
+
|
| 61 |
speech = app.speak_core("Tiny Narrator verification.", voice="af_heart", speed=1.0)
|
| 62 |
assert_true(speech["ok"], "Speech path did not return ok")
|
| 63 |
audio_path = ROOT / speech["audio_url"].lstrip("/")
|
|
|
|
| 75 |
assert_true("Tiny Narrator" in home.text, "Home route should include app title")
|
| 76 |
assert_true("readerToggle" in home.text, "Home route should include reader toggle")
|
| 77 |
assert_true("summaryButton" in home.text, "Home route should include summary control")
|
| 78 |
+
assert_true("imageStatus" in home.text, "Home route should include image status")
|
| 79 |
assert_true("transcriptLog" in home.text, "Home route should include transcript log")
|
| 80 |
|
| 81 |
health = client.get("/api/health")
|
|
|
|
| 100 |
"Manifest should document Kokoro speech model",
|
| 101 |
)
|
| 102 |
|
| 103 |
+
image_descriptions = client.get("/api/image-descriptions")
|
| 104 |
+
assert_true(image_descriptions.status_code == 200, "Image descriptions route should return 200")
|
| 105 |
+
image_payload = image_descriptions.json()
|
| 106 |
+
assert_true(image_payload["model"] == "OpenBMB MiniCPM-V-2", "Image route should document MiniCPM-V model")
|
| 107 |
+
assert_true(
|
| 108 |
+
{item["id"] for item in image_payload["descriptions"]} == {"desk-reader", "model-map", "field-notes"},
|
| 109 |
+
"Image route should describe all article images",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
|
| 113 |
def main() -> None:
|
| 114 |
py_compile.compile(str(ROOT / "app.py"), doraise=True)
|
static/app.js
CHANGED
|
@@ -4,6 +4,7 @@ const modeStatus = document.querySelector("#modeStatus");
|
|
| 4 |
const currentStatus = document.querySelector("#currentStatus");
|
| 5 |
const runtimeStatus = document.querySelector("#runtimeStatus");
|
| 6 |
const modelStatus = document.querySelector("#modelStatus");
|
|
|
|
| 7 |
const liveNarration = document.querySelector("#liveNarration");
|
| 8 |
const audio = document.querySelector("#speechAudio");
|
| 9 |
const speedControl = document.querySelector("#speedControl");
|
|
@@ -35,6 +36,7 @@ let currentIndex = -1;
|
|
| 35 |
let playing = false;
|
| 36 |
let requestSerial = 0;
|
| 37 |
let transcriptEntries = [];
|
|
|
|
| 38 |
|
| 39 |
function escapeHtml(value) {
|
| 40 |
return value.replace(/[&<>"']/g, (char) => ({
|
|
@@ -90,6 +92,27 @@ async function loadManifest() {
|
|
| 90 |
}
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
function setEnabled(nextValue) {
|
| 94 |
enabled = nextValue;
|
| 95 |
toggle.setAttribute("aria-pressed", String(enabled));
|
|
@@ -151,10 +174,13 @@ async function narrate(index) {
|
|
| 151 |
try {
|
| 152 |
let sourceText = node.text;
|
| 153 |
if (node.type === "image") {
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
| 158 |
sourceText = description.alt_text;
|
| 159 |
}
|
| 160 |
|
|
@@ -302,6 +328,7 @@ controls.play.addEventListener("click", () => {
|
|
| 302 |
});
|
| 303 |
|
| 304 |
loadManifest();
|
|
|
|
| 305 |
|
| 306 |
audio.addEventListener("ended", () => {
|
| 307 |
playing = false;
|
|
|
|
| 4 |
const currentStatus = document.querySelector("#currentStatus");
|
| 5 |
const runtimeStatus = document.querySelector("#runtimeStatus");
|
| 6 |
const modelStatus = document.querySelector("#modelStatus");
|
| 7 |
+
const imageStatus = document.querySelector("#imageStatus");
|
| 8 |
const liveNarration = document.querySelector("#liveNarration");
|
| 9 |
const audio = document.querySelector("#speechAudio");
|
| 10 |
const speedControl = document.querySelector("#speedControl");
|
|
|
|
| 36 |
let playing = false;
|
| 37 |
let requestSerial = 0;
|
| 38 |
let transcriptEntries = [];
|
| 39 |
+
let imageDescriptions = new Map();
|
| 40 |
|
| 41 |
function escapeHtml(value) {
|
| 42 |
return value.replace(/[&<>"']/g, (char) => ({
|
|
|
|
| 92 |
}
|
| 93 |
}
|
| 94 |
|
| 95 |
+
async function loadImageDescriptions() {
|
| 96 |
+
imageStatus.textContent = "Describing";
|
| 97 |
+
try {
|
| 98 |
+
const payload = await postJson("/api/image-descriptions");
|
| 99 |
+
imageDescriptions = new Map(
|
| 100 |
+
payload.descriptions.map((description) => [description.id, description]),
|
| 101 |
+
);
|
| 102 |
+
for (const node of nodes.filter((item) => item.type === "image")) {
|
| 103 |
+
const description = imageDescriptions.get(node.imageId);
|
| 104 |
+
const image = node.element.querySelector("img");
|
| 105 |
+
if (description?.alt_text && image) {
|
| 106 |
+
image.alt = description.alt_text;
|
| 107 |
+
node.element.dataset.altReady = "true";
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
imageStatus.textContent = `${imageDescriptions.size} ready`;
|
| 111 |
+
} catch {
|
| 112 |
+
imageStatus.textContent = "Fallback on demand";
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
function setEnabled(nextValue) {
|
| 117 |
enabled = nextValue;
|
| 118 |
toggle.setAttribute("aria-pressed", String(enabled));
|
|
|
|
| 174 |
try {
|
| 175 |
let sourceText = node.text;
|
| 176 |
if (node.type === "image") {
|
| 177 |
+
let description = imageDescriptions.get(node.imageId);
|
| 178 |
+
if (!description) {
|
| 179 |
+
description = await postJson("/api/describe-image", {
|
| 180 |
+
image_id: node.imageId,
|
| 181 |
+
caption: node.text,
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
sourceText = description.alt_text;
|
| 185 |
}
|
| 186 |
|
|
|
|
| 328 |
});
|
| 329 |
|
| 330 |
loadManifest();
|
| 331 |
+
loadImageDescriptions();
|
| 332 |
|
| 333 |
audio.addEventListener("ended", () => {
|
| 334 |
playing = false;
|
static/index.html
CHANGED
|
@@ -96,6 +96,10 @@
|
|
| 96 |
<dt>Model Stack</dt>
|
| 97 |
<dd id="modelStatus">Loading</dd>
|
| 98 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</dl>
|
| 100 |
<p id="liveNarration" class="live-narration" aria-live="polite">
|
| 101 |
Turn on screen reader mode to begin.
|
|
|
|
| 96 |
<dt>Model Stack</dt>
|
| 97 |
<dd id="modelStatus">Loading</dd>
|
| 98 |
</div>
|
| 99 |
+
<div>
|
| 100 |
+
<dt>Image Alt</dt>
|
| 101 |
+
<dd id="imageStatus">Waiting</dd>
|
| 102 |
+
</div>
|
| 103 |
</dl>
|
| 104 |
<p id="liveNarration" class="live-narration" aria-live="polite">
|
| 105 |
Turn on screen reader mode to begin.
|