cvpfus Codex commited on
Commit
d19068b
·
1 Parent(s): cf4bbc0

Preload image descriptions for reader mode

Browse files

Co-authored-by: Codex <codex@openai.com>

Files changed (6) hide show
  1. FIELD_NOTES.md +2 -0
  2. README.md +3 -1
  3. app.py +49 -0
  4. scripts/verify.py +14 -0
  5. static/app.js +31 -4
  6. 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
- const description = await postJson("/api/describe-image", {
155
- image_id: node.imageId,
156
- caption: node.text,
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.