TheBug95 commited on
Commit
5b7432c
·
1 Parent(s): b0c3a57

Actualizacion de la interfaz de usuario e implementacion del sistema de etiquetado LOCS III. Arreglo de funcionalidades de borrado y restauracion de transcripcion e implementacion de Dialogos de alerta a las descargas de imagenes incompletas en etiquetado

Browse files
annotations.db CHANGED
Binary files a/annotations.db and b/annotations.db differ
 
interface/components/downloader.py CHANGED
@@ -1,9 +1,12 @@
1
  """OphthalmoCapture — Download Component
2
 
3
  Provides individual and bulk download buttons for the labeling package.
 
4
  """
5
 
6
  import streamlit as st
 
 
7
  from services.export_service import (
8
  export_single_image,
9
  export_full_session,
@@ -13,69 +16,238 @@ from services.export_service import (
13
  )
14
 
15
 
16
- def render_downloader(image_id: str):
17
- """Render the download panel for the current image + bulk download."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  img = st.session_state.images.get(image_id)
19
  if img is None:
 
20
  return
21
 
22
- st.subheader("📥 Descarga")
 
 
 
 
23
 
24
- # ── Individual download ──────────────────────────────────────────────
25
- st.markdown("**Imagen actual**")
 
 
 
26
 
27
- can_download = img["label"] is not None
28
- if not can_download:
29
- st.info("Etiquete la imagen para habilitar la descarga individual.")
30
- else:
31
  zip_bytes, zip_name = export_single_image(image_id)
32
- st.download_button(
33
- label=f"⬇️ Descargar etiquetado — {img['filename']}",
34
  data=zip_bytes,
35
  file_name=zip_name,
36
  mime="application/zip",
37
- key=f"dl_single_{image_id}",
38
  use_container_width=True,
39
- )
 
 
 
 
 
 
 
 
 
 
40
 
41
- st.divider()
42
 
43
- # ── Bulk download ────────────────────────────────────────────────────
44
- st.markdown("**Toda la sesión**")
45
 
46
- summary = get_session_summary()
47
- sc1, sc2 = st.columns(2)
48
- with sc1:
49
- st.metric("Imágenes", summary["total"])
50
- st.metric("Con audio", summary["with_audio"])
51
- with sc2:
52
- st.metric("Etiquetadas", f"{summary['labeled']} / {summary['total']}")
53
- st.metric("Con transcripción", summary["with_transcription"])
54
-
55
- if summary["unlabeled"] > 0:
56
- st.warning(
57
- f"⚠️ {summary['unlabeled']} imagen(es) sin etiquetar. "
58
- "Se incluirán en la descarga pero sin etiqueta."
59
- )
60
 
61
- if summary["total"] == 0:
62
- st.info("No hay imágenes para descargar.")
63
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  zip_bytes, zip_name = export_full_session()
65
  if st.download_button(
66
- label="⬇️ Descargar todo el etiquetado (ZIP)",
67
  data=zip_bytes,
68
  file_name=zip_name,
69
  mime="application/zip",
70
- key="dl_bulk",
71
  use_container_width=True,
72
- type="primary",
73
  ):
74
  st.session_state.session_downloaded = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  # ── ML-ready formats (Idea F) ────────────────────────────────────────
77
  if summary["labeled"] > 0:
78
- st.divider()
79
  st.markdown("**Formatos para ML**")
80
  ml1, ml2 = st.columns(2)
81
  with ml1:
 
1
  """OphthalmoCapture — Download Component
2
 
3
  Provides individual and bulk download buttons for the labeling package.
4
+ Uses @st.dialog modals to warn about incomplete labeling before download.
5
  """
6
 
7
  import streamlit as st
8
+ import pandas as pd
9
+ import config
10
  from services.export_service import (
11
  export_single_image,
12
  export_full_session,
 
16
  )
17
 
18
 
19
+ # ── Helpers ──────────────────────────────────────────────────────────────────
20
+
21
+ def _get_image_missing_info(img: dict) -> list[str]:
22
+ """Return a list of human-readable items that are missing for one image."""
23
+ missing = []
24
+ if img.get("label") is None:
25
+ missing.append("Etiqueta categórica")
26
+ elif img["label"] == "Cataract":
27
+ locs = img.get("locs_data", {})
28
+ for field in config.LOCS_FIELDS:
29
+ fid = field["field_id"]
30
+ if fid not in locs:
31
+ missing.append(f"LOCS III – {field['label']}")
32
+ if not img.get("transcription"):
33
+ missing.append("Etiquetado por voz")
34
+ return missing
35
+
36
+
37
+ # ── Dialog: individual download with incomplete labeling ─────────────────────
38
+
39
+ @st.dialog("⚠️ Etiquetado incompleto", dismissible=False)
40
+ def _show_single_incomplete_dialog(image_id: str):
41
+ """Warn about missing labeling for one image before individual download."""
42
  img = st.session_state.images.get(image_id)
43
  if img is None:
44
+ st.rerun()
45
  return
46
 
47
+ missing = _get_image_missing_info(img)
48
+ if not missing:
49
+ st.session_state.pop("_pending_single_dl", None)
50
+ st.rerun()
51
+ return
52
 
53
+ st.markdown(
54
+ f"La imagen **{img['filename']}** tiene campos sin completar:"
55
+ )
56
+ for item in missing:
57
+ st.markdown(f"- {item}")
58
 
59
+ st.divider()
60
+ c1, c2 = st.columns(2)
61
+ with c1:
 
62
  zip_bytes, zip_name = export_single_image(image_id)
63
+ if st.download_button(
64
+ label="⬇️ Descargar igualmente",
65
  data=zip_bytes,
66
  file_name=zip_name,
67
  mime="application/zip",
68
+ key="_dlg_dl_single_anyway",
69
  use_container_width=True,
70
+ ):
71
+ st.session_state.pop("_pending_single_dl", None)
72
+ st.rerun()
73
+ with c2:
74
+ if st.button(
75
+ "🔙 Regresar y terminar",
76
+ use_container_width=True,
77
+ type="primary",
78
+ ):
79
+ st.session_state.pop("_pending_single_dl", None)
80
+ st.rerun()
81
 
 
82
 
83
+ # ── Dialog: bulk download with incomplete images ─────────────────────────────
 
84
 
85
+ @st.dialog("⚠️ Imágenes con etiquetado incompleto", width="large", dismissible=False)
86
+ def _show_bulk_incomplete_dialog():
87
+ """Table showing per-image what labeling is missing before bulk download."""
88
+ images = st.session_state.images
89
+ order = st.session_state.image_order
 
 
 
 
 
 
 
 
 
90
 
91
+ # Build table data
92
+ rows = []
93
+ for img_id in order:
94
+ img = images[img_id]
95
+ has_categorical = img.get("label") is not None
96
+ needs_locs = img.get("label") == "Cataract"
97
+ locs = img.get("locs_data", {})
98
+
99
+ if needs_locs:
100
+ locs_filled = all(
101
+ f["field_id"] in locs for f in config.LOCS_FIELDS
102
+ )
103
+ else:
104
+ locs_filled = True # Not applicable
105
+
106
+ has_voice = bool(img.get("transcription"))
107
+
108
+ # Only show images that have something missing
109
+ if not (has_categorical and locs_filled and has_voice):
110
+ rows.append({
111
+ "Imagen": img["filename"],
112
+ "Categórica": "✅" if has_categorical else "❌",
113
+ "LOCS III": (
114
+ "✅" if locs_filled else "❌"
115
+ ) if needs_locs else "N/A",
116
+ "Voz": "✅" if has_voice else "❌",
117
+ })
118
+
119
+ if not rows:
120
+ st.session_state.pop("_pending_bulk_dl", None)
121
+ st.rerun()
122
+ return
123
+
124
+ st.markdown(
125
+ f"**{len(rows)} imagen(es)** tienen etiquetado incompleto:"
126
+ )
127
+ df = pd.DataFrame(rows)
128
+ st.dataframe(df, use_container_width=True, hide_index=True)
129
+
130
+ st.divider()
131
+ c1, c2 = st.columns(2)
132
+ with c1:
133
  zip_bytes, zip_name = export_full_session()
134
  if st.download_button(
135
+ label="⬇️ Descargar igualmente",
136
  data=zip_bytes,
137
  file_name=zip_name,
138
  mime="application/zip",
139
+ key="_dlg_dl_bulk_anyway",
140
  use_container_width=True,
 
141
  ):
142
  st.session_state.session_downloaded = True
143
+ st.session_state.pop("_pending_bulk_dl", None)
144
+ st.rerun()
145
+ with c2:
146
+ if st.button(
147
+ "🔙 Regresar y terminar",
148
+ use_container_width=True,
149
+ type="primary",
150
+ ):
151
+ st.session_state.pop("_pending_bulk_dl", None)
152
+ st.rerun()
153
+
154
+
155
+ # ── Main downloader ──────────────────────────────────────────────────────────
156
+
157
+ def render_downloader(image_id: str):
158
+ """Render the download panel for the current image + bulk download."""
159
+ img = st.session_state.images.get(image_id)
160
+ if img is None:
161
+ return
162
+
163
+ # ── Show pending dialogs (survive reruns) ────────────────────────────
164
+ if "_pending_single_dl" in st.session_state:
165
+ _show_single_incomplete_dialog(st.session_state["_pending_single_dl"])
166
+ return
167
+
168
+ if "_pending_bulk_dl" in st.session_state:
169
+ _show_bulk_incomplete_dialog()
170
+ return
171
+
172
+ # ── Two columns: Individual download (left) | Session info (right) ───
173
+ col_dl, col_info = st.columns(2)
174
+
175
+ with col_dl:
176
+ st.subheader("📥 Descarga individual")
177
+
178
+ # Check completeness for individual download
179
+ missing = _get_image_missing_info(img)
180
+ if missing:
181
+ # Show button that triggers the warning dialog
182
+ if st.button(
183
+ f"⬇️ Descargar — {img['filename']}",
184
+ key=f"dl_single_check_{image_id}",
185
+ use_container_width=True,
186
+ ):
187
+ st.session_state["_pending_single_dl"] = image_id
188
+ st.rerun()
189
+ else:
190
+ zip_bytes, zip_name = export_single_image(image_id)
191
+ st.download_button(
192
+ label=f"⬇️ Descargar — {img['filename']}",
193
+ data=zip_bytes,
194
+ file_name=zip_name,
195
+ mime="application/zip",
196
+ key=f"dl_single_{image_id}",
197
+ use_container_width=True,
198
+ )
199
+
200
+ with col_info:
201
+ st.subheader("📊 Información de sesión")
202
+ summary = get_session_summary()
203
+ sc1, sc2 = st.columns(2)
204
+ with sc1:
205
+ st.metric("Imágenes", summary["total"])
206
+ st.metric("Con audio", summary["with_audio"])
207
+ with sc2:
208
+ st.metric("Etiquetadas", f"{summary['labeled']} / {summary['total']}")
209
+ st.metric("Con transcripción", summary["with_transcription"])
210
+
211
+ st.divider()
212
+
213
+ # ── Full-width: Bulk download ────────────────────────────────────────
214
+ st.subheader("📦 Descargar todo el etiquetado")
215
+
216
+ summary = get_session_summary()
217
+
218
+ if summary["total"] == 0:
219
+ st.info("No hay imágenes para descargar.")
220
+ else:
221
+ # Check if any image has incomplete labeling
222
+ has_incomplete = any(
223
+ _get_image_missing_info(st.session_state.images[iid])
224
+ for iid in st.session_state.image_order
225
+ )
226
+
227
+ if has_incomplete:
228
+ if st.button(
229
+ "⬇️ Descargar todo el etiquetado (ZIP)",
230
+ key="dl_bulk_check",
231
+ use_container_width=True,
232
+ type="primary",
233
+ ):
234
+ st.session_state["_pending_bulk_dl"] = True
235
+ st.rerun()
236
+ else:
237
+ zip_bytes, zip_name = export_full_session()
238
+ if st.download_button(
239
+ label="⬇️ Descargar todo el etiquetado (ZIP)",
240
+ data=zip_bytes,
241
+ file_name=zip_name,
242
+ mime="application/zip",
243
+ key="dl_bulk",
244
+ use_container_width=True,
245
+ type="primary",
246
+ ):
247
+ st.session_state.session_downloaded = True
248
 
249
  # ── ML-ready formats (Idea F) ────────────────────────────────────────
250
  if summary["labeled"] > 0:
 
251
  st.markdown("**Formatos para ML**")
252
  ml1, ml2 = st.columns(2)
253
  with ml1:
interface/components/labeler.py CHANGED
@@ -1,9 +1,14 @@
1
  """OphthalmoCapture — Labeling Component
2
 
3
- Provides the radio-button selector for classifying images (e.g. catarata /
4
- no catarata) and persists the choice in the ephemeral session. The label
5
- list is driven by config.LABEL_OPTIONS so it can be extended without touching
6
- this component.
 
 
 
 
 
7
  """
8
 
9
  import streamlit as st
@@ -12,28 +17,69 @@ import database as db
12
  from services import session_manager as sm
13
 
14
 
15
- def render_labeler(image_id: str):
16
- """Render the labeling panel for the given image.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- Displays a radio selector, saves the label into session state and
19
- optionally persists metadata to the audit database.
20
- """
 
 
 
 
 
21
  img = st.session_state.images.get(image_id)
22
  if img is None:
23
  return
24
 
25
  st.subheader("🏷️ Etiquetado")
26
 
 
27
  display_options = [opt["display"] for opt in config.LABEL_OPTIONS]
28
  current_label = img.get("label")
29
 
30
- # Determine current index (None if unlabeled)
31
  if current_label is not None and current_label in display_options:
32
  current_index = display_options.index(current_label)
33
  else:
34
  current_index = None
35
 
36
- # Styled container with radio buttons
37
  with st.container(border=True):
38
  if current_index is None:
39
  st.caption("⬇️ Seleccione una etiqueta para esta imagen")
@@ -47,36 +93,52 @@ def render_labeler(image_id: str):
47
  label_visibility="collapsed",
48
  )
49
 
50
- # Map selection
51
  new_label = selected if selected in display_options else None
52
 
53
- # Detect change, update session and auto-save to DB
54
- if new_label is not None and new_label != current_label:
55
- st.session_state.images[image_id]["label"] = new_label
56
- st.session_state.images[image_id]["labeled_by"] = st.session_state.get(
57
- "doctor_name", ""
58
- )
 
 
59
  sm.update_activity()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- # Auto-save to audit DB (upsert — one record per image per session)
62
- try:
63
- db.save_or_update_annotation(
64
- image_filename=img["filename"],
65
- label=new_label,
66
- transcription=img.get("transcription", ""),
67
- doctor_name=st.session_state.get("doctor_name", ""),
68
- session_id=st.session_state.get("session_id", ""),
69
- )
70
- except Exception:
71
- pass # Non-blocking: audit DB failure should not break labeling
72
-
73
- # ── Visual feedback ──────────────────────────────────────────────────
74
- if new_label is None:
75
  st.warning("🔴 Sin etiquetar")
76
  else:
77
- code = "—"
78
- for opt in config.LABEL_OPTIONS:
79
- if opt["display"] == new_label:
80
- code = opt["code"]
81
- break
82
- st.success(f"🟢 Etiqueta: **{new_label}** (código: {code})")
 
1
  """OphthalmoCapture — Labeling Component
2
 
3
+ Provides:
4
+ 1. Categorical radio selector: Normal / Cataract / Bad quality / Needs dilation
5
+ 2. LOCS III dropdowns (only when "Cataract" is selected):
6
+ - Nuclear Opalescence (NO) 0-6
7
+ - Nuclear Color (NC) 0-6
8
+ - Cortical Opacity (C) 0-5
9
+ 3. Auto-saves (upsert) to audit DB on every change.
10
+
11
+ Numeric values are stored for ML; only text labels are shown in the UI.
12
  """
13
 
14
  import streamlit as st
 
17
  from services import session_manager as sm
18
 
19
 
20
+ def _save_to_db(img: dict, image_id: str):
21
+ """Persist current label + LOCS data to audit DB (non-blocking)."""
22
+ try:
23
+ db.save_or_update_annotation(
24
+ image_filename=img["filename"],
25
+ label=img["label"],
26
+ transcription=img.get("transcription", ""),
27
+ doctor_name=st.session_state.get("doctor_name", ""),
28
+ session_id=st.session_state.get("session_id", ""),
29
+ locs_data=img.get("locs_data", {}),
30
+ )
31
+ except Exception:
32
+ pass
33
+
34
+
35
+ def _render_locs_dropdown(field: dict, image_id: str, current_locs: dict) -> int | None:
36
+ """Render a single LOCS dropdown and return the selected numeric value."""
37
+ field_id = field["field_id"]
38
+ options = field["options"]
39
+ display_labels = [opt["display"] for opt in options]
40
+
41
+ # Determine current index from stored data
42
+ stored_value = current_locs.get(field_id)
43
+ if stored_value is not None:
44
+ current_index = next(
45
+ (i for i, opt in enumerate(options) if opt["value"] == stored_value),
46
+ None,
47
+ )
48
+ else:
49
+ current_index = None
50
+
51
+ # Use index=None so nothing is pre-selected until doctor chooses
52
+ selected_display = st.selectbox(
53
+ field["label"],
54
+ display_labels,
55
+ index=current_index,
56
+ key=f"locs_{field_id}_{image_id}",
57
+ placeholder="Seleccionar…",
58
+ )
59
 
60
+ if selected_display is not None and selected_display in display_labels:
61
+ idx = display_labels.index(selected_display)
62
+ return options[idx]["value"]
63
+ return None
64
+
65
+
66
+ def render_labeler(image_id: str):
67
+ """Render the full labeling panel for the given image."""
68
  img = st.session_state.images.get(image_id)
69
  if img is None:
70
  return
71
 
72
  st.subheader("🏷️ Etiquetado")
73
 
74
+ # ── 1. Categorical classification ────────────────────────────────────
75
  display_options = [opt["display"] for opt in config.LABEL_OPTIONS]
76
  current_label = img.get("label")
77
 
 
78
  if current_label is not None and current_label in display_options:
79
  current_index = display_options.index(current_label)
80
  else:
81
  current_index = None
82
 
 
83
  with st.container(border=True):
84
  if current_index is None:
85
  st.caption("⬇️ Seleccione una etiqueta para esta imagen")
 
93
  label_visibility="collapsed",
94
  )
95
 
 
96
  new_label = selected if selected in display_options else None
97
 
98
+ # Detect categorical change
99
+ label_changed = new_label is not None and new_label != current_label
100
+ if label_changed:
101
+ img["label"] = new_label
102
+ img["labeled_by"] = st.session_state.get("doctor_name", "")
103
+ # If switching away from Cataract, clear LOCS data
104
+ if new_label != "Cataract":
105
+ img["locs_data"] = {}
106
  sm.update_activity()
107
+ _save_to_db(img, image_id)
108
+
109
+ # ── 2. LOCS III Classification (only for "Cataract") ─────────────────
110
+ effective_label = new_label or current_label
111
+ if effective_label == "Cataract":
112
+ st.markdown("---")
113
+ st.markdown("**LOCS III Classification**")
114
+
115
+ current_locs = img.get("locs_data", {})
116
+ locs_changed = False
117
+
118
+ with st.container(border=True):
119
+ for field_def in config.LOCS_FIELDS:
120
+ value = _render_locs_dropdown(field_def, image_id, current_locs)
121
+ field_id = field_def["field_id"]
122
+ if value is not None and value != current_locs.get(field_id):
123
+ current_locs[field_id] = value
124
+ locs_changed = True
125
+
126
+ img["locs_data"] = current_locs
127
+
128
+ if locs_changed:
129
+ sm.update_activity()
130
+ _save_to_db(img, image_id)
131
+
132
+ # LOCS summary
133
+ filled = sum(1 for f in config.LOCS_FIELDS if f["field_id"] in current_locs)
134
+ total_fields = len(config.LOCS_FIELDS)
135
+ if filled < total_fields:
136
+ st.info(f"📋 LOCS: {filled}/{total_fields} campos completados")
137
+ else:
138
+ st.success(f"✅ LOCS: {filled}/{total_fields} campos completados")
139
 
140
+ # ── 3. Visual feedback ───────────────────────────────────────────────
141
+ if effective_label is None:
 
 
 
 
 
 
 
 
 
 
 
 
142
  st.warning("🔴 Sin etiquetar")
143
  else:
144
+ st.success(f"🟢 Etiqueta: **{effective_label}**")
 
 
 
 
 
interface/components/recorder.py CHANGED
@@ -91,6 +91,7 @@ def render_recorder(image_id: str, model, language: str):
91
  transcription=img["transcription"],
92
  doctor_name=st.session_state.get("doctor_name", ""),
93
  session_id=st.session_state.get("session_id", ""),
 
94
  )
95
  except Exception:
96
  pass
@@ -140,8 +141,8 @@ def render_recorder(image_id: str, model, language: str):
140
  img["transcription_original"] = ""
141
  st.session_state.pop(segments_key, None)
142
  st.session_state.pop(processed_key, None)
143
- st.session_state.pop(f"transcription_area_{image_id}", None)
144
- # Clear the audio_input widget state to reset the recorder
145
  st.session_state.pop(f"audio_input_{image_id}", None)
146
  sm.update_activity()
147
  st.rerun()
@@ -157,6 +158,7 @@ def render_recorder(image_id: str, model, language: str):
157
  use_container_width=True,
158
  ):
159
  img["transcription"] = img["transcription_original"]
 
160
  sm.update_activity()
161
  st.rerun()
162
 
@@ -169,6 +171,7 @@ def render_recorder(image_id: str, model, language: str):
169
  use_container_width=True,
170
  ):
171
  img["transcription"] = ""
 
172
  sm.update_activity()
173
  st.rerun()
174
 
 
91
  transcription=img["transcription"],
92
  doctor_name=st.session_state.get("doctor_name", ""),
93
  session_id=st.session_state.get("session_id", ""),
94
+ locs_data=img.get("locs_data", {}),
95
  )
96
  except Exception:
97
  pass
 
141
  img["transcription_original"] = ""
142
  st.session_state.pop(segments_key, None)
143
  st.session_state.pop(processed_key, None)
144
+ # Clear both text_area and audio_input widget states
145
+ st.session_state[f"transcription_area_{image_id}"] = ""
146
  st.session_state.pop(f"audio_input_{image_id}", None)
147
  sm.update_activity()
148
  st.rerun()
 
158
  use_container_width=True,
159
  ):
160
  img["transcription"] = img["transcription_original"]
161
+ st.session_state[f"transcription_area_{image_id}"] = img["transcription_original"]
162
  sm.update_activity()
163
  st.rerun()
164
 
 
171
  use_container_width=True,
172
  ):
173
  img["transcription"] = ""
174
+ st.session_state[f"transcription_area_{image_id}"] = ""
175
  sm.update_activity()
176
  st.rerun()
177
 
interface/config.py CHANGED
@@ -1,12 +1,64 @@
1
  """OphthalmoCapture — Configuration Constants."""
2
 
3
- # ── Label Options ────────────────────────────────────────────────────────────
4
- # Designed as a configurable list for easy extension (e.g. glaucoma, DR, AMD).
5
  LABEL_OPTIONS = [
6
- {"key": "catarata", "display": "Catarata", "code": 1},
7
- {"key": "no_catarata", "display": "No Catarata", "code": 0},
 
 
8
  ]
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  # ── Session Settings ─────────────────────────────────────────────────────────
11
  SESSION_TIMEOUT_MINUTES = 30
12
 
 
1
  """OphthalmoCapture — Configuration Constants."""
2
 
3
+ # ── Categorical Label Options ────────────────────────────────────────────────
4
+ # Primary classification (radio buttons).
5
  LABEL_OPTIONS = [
6
+ {"key": "normal", "display": "Normal", "code": 0},
7
+ {"key": "cataract", "display": "Cataract", "code": 1},
8
+ {"key": "bad_quality", "display": "Bad quality", "code": 2},
9
+ {"key": "needs_dilation", "display": "Needs dilation", "code": 3},
10
  ]
11
 
12
+ # ── LOCS III Classification (shown only when label == "Cataract") ────────────
13
+ # Values are integer bins mapped from LOCS III continuous scales:
14
+ # NO/NC (0.1–6.9) → 0–6
15
+ # C (0.1–5.9) → 0–5
16
+ # We store the numeric value for ML and display only the text label.
17
+
18
+ LOCS_NUCLEAR_OPALESCENCE = {
19
+ "field_id": "nuclear_opalescence",
20
+ "label": "Nuclear Cataract – Opalescence (NO)",
21
+ "options": [
22
+ {"value": 0, "display": "None / Clear"},
23
+ {"value": 1, "display": "Very mild"},
24
+ {"value": 2, "display": "Mild"},
25
+ {"value": 3, "display": "Mild–moderate"},
26
+ {"value": 4, "display": "Moderate"},
27
+ {"value": 5, "display": "Moderate–severe"},
28
+ {"value": 6, "display": "Severe"},
29
+ ],
30
+ }
31
+
32
+ LOCS_NUCLEAR_COLOR = {
33
+ "field_id": "nuclear_color",
34
+ "label": "Nuclear Cataract – Color (NC)",
35
+ "options": [
36
+ {"value": 0, "display": "None / Clear"},
37
+ {"value": 1, "display": "Very mild yellowing"},
38
+ {"value": 2, "display": "Mild yellowing"},
39
+ {"value": 3, "display": "Moderate yellow"},
40
+ {"value": 4, "display": "Yellow–brown"},
41
+ {"value": 5, "display": "Brown"},
42
+ {"value": 6, "display": "Dark brown"},
43
+ ],
44
+ }
45
+
46
+ LOCS_CORTICAL = {
47
+ "field_id": "cortical_opacity",
48
+ "label": "Cortical Cataract (C)",
49
+ "options": [
50
+ {"value": 0, "display": "None"},
51
+ {"value": 1, "display": "Peripheral spokes only"},
52
+ {"value": 2, "display": "Mild peripheral involvement"},
53
+ {"value": 3, "display": "Moderate spokes approaching center"},
54
+ {"value": 4, "display": "Central involvement"},
55
+ {"value": 5, "display": "Severe / dense central spokes"},
56
+ ],
57
+ }
58
+
59
+ # Convenience list of all LOCS dropdowns
60
+ LOCS_FIELDS = [LOCS_NUCLEAR_OPALESCENCE, LOCS_NUCLEAR_COLOR, LOCS_CORTICAL]
61
+
62
  # ── Session Settings ─────────────────────────────────────────────────────────
63
  SESSION_TIMEOUT_MINUTES = 30
64
 
interface/database.py CHANGED
@@ -56,6 +56,11 @@ def init_db():
56
  c.execute("ALTER TABLE annotations ADD COLUMN session_id TEXT DEFAULT ''")
57
  except sqlite3.OperationalError:
58
  pass # column already exists
 
 
 
 
 
59
  c.execute('''CREATE INDEX IF NOT EXISTS idx_ann_session
60
  ON annotations (image_filename, session_id)''')
61
  conn.commit()
@@ -92,14 +97,17 @@ def save_annotation(image_filename, label, transcription, doctor_name=""):
92
 
93
 
94
  def save_or_update_annotation(
95
- image_filename, label, transcription, doctor_name="", session_id=""
 
96
  ):
97
  """Upsert: within the same session, keep only ONE record per image.
98
 
99
  If a record for (image_filename, session_id) already exists → UPDATE it.
100
  Otherwise → INSERT a new one.
101
  """
 
102
  timestamp = datetime.datetime.now()
 
103
 
104
  if DB_TYPE == "FIREBASE":
105
  # Query for existing doc with matching filename + session
@@ -115,6 +123,7 @@ def save_or_update_annotation(
115
  "label": label,
116
  "transcription": transcription,
117
  "doctorName": doctor_name,
 
118
  "createdAt": timestamp,
119
  })
120
  else:
@@ -124,6 +133,7 @@ def save_or_update_annotation(
124
  "transcription": transcription,
125
  "doctorName": doctor_name,
126
  "sessionId": session_id,
 
127
  "createdAt": timestamp,
128
  })
129
  else:
@@ -139,16 +149,19 @@ def save_or_update_annotation(
139
  if row:
140
  c.execute(
141
  "UPDATE annotations "
142
- "SET label = ?, transcription = ?, doctor_name = ?, created_at = ? "
 
143
  "WHERE id = ?",
144
- (label, transcription, doctor_name, timestamp, row[0]),
145
  )
146
  else:
147
  c.execute(
148
  "INSERT INTO annotations "
149
- "(image_filename, label, transcription, doctor_name, created_at, session_id) "
150
- "VALUES (?, ?, ?, ?, ?, ?)",
151
- (image_filename, label, transcription, doctor_name, timestamp, session_id),
 
 
152
  )
153
  conn.commit()
154
  conn.close()
 
56
  c.execute("ALTER TABLE annotations ADD COLUMN session_id TEXT DEFAULT ''")
57
  except sqlite3.OperationalError:
58
  pass # column already exists
59
+ # Migration: add locs_data column (JSON string)
60
+ try:
61
+ c.execute("ALTER TABLE annotations ADD COLUMN locs_data TEXT DEFAULT ''")
62
+ except sqlite3.OperationalError:
63
+ pass # column already exists
64
  c.execute('''CREATE INDEX IF NOT EXISTS idx_ann_session
65
  ON annotations (image_filename, session_id)''')
66
  conn.commit()
 
97
 
98
 
99
  def save_or_update_annotation(
100
+ image_filename, label, transcription, doctor_name="", session_id="",
101
+ locs_data=None,
102
  ):
103
  """Upsert: within the same session, keep only ONE record per image.
104
 
105
  If a record for (image_filename, session_id) already exists → UPDATE it.
106
  Otherwise → INSERT a new one.
107
  """
108
+ import json as _json
109
  timestamp = datetime.datetime.now()
110
+ locs_json = _json.dumps(locs_data or {}, ensure_ascii=False)
111
 
112
  if DB_TYPE == "FIREBASE":
113
  # Query for existing doc with matching filename + session
 
123
  "label": label,
124
  "transcription": transcription,
125
  "doctorName": doctor_name,
126
+ "locsData": locs_data or {},
127
  "createdAt": timestamp,
128
  })
129
  else:
 
133
  "transcription": transcription,
134
  "doctorName": doctor_name,
135
  "sessionId": session_id,
136
+ "locsData": locs_data or {},
137
  "createdAt": timestamp,
138
  })
139
  else:
 
149
  if row:
150
  c.execute(
151
  "UPDATE annotations "
152
+ "SET label = ?, transcription = ?, doctor_name = ?, "
153
+ "created_at = ?, locs_data = ? "
154
  "WHERE id = ?",
155
+ (label, transcription, doctor_name, timestamp, locs_json, row[0]),
156
  )
157
  else:
158
  c.execute(
159
  "INSERT INTO annotations "
160
+ "(image_filename, label, transcription, doctor_name, "
161
+ "created_at, session_id, locs_data) "
162
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
163
+ (image_filename, label, transcription, doctor_name,
164
+ timestamp, session_id, locs_json),
165
  )
166
  conn.commit()
167
  conn.close()
interface/main.py CHANGED
@@ -280,50 +280,51 @@ current_img = sm.get_current_image()
280
  order = st.session_state.image_order
281
  current_idx = order.index(current_id)
282
 
283
- # ── Two-column layout: Image | Tools ─────────────────────────────────────────
284
- col_img, col_tools = st.columns([1.5, 1])
285
-
286
- with col_img:
287
- st.image(
288
- current_img["bytes"],
289
- caption=current_img["filename"],
290
- use_container_width=True,
291
- )
292
 
293
- # Navigation
294
- c1, c2, c3 = st.columns([1, 2, 1])
295
- with c1:
296
- if st.button("⬅️ Anterior", disabled=(len(order) <= 1)):
297
- new_idx = (current_idx - 1) % len(order)
298
- st.session_state.current_image_id = order[new_idx]
299
- sm.update_activity()
300
- st.rerun()
301
- with c2:
302
- st.markdown(
303
- f"<div style='text-align:center'><b>{current_img['filename']}</b>"
304
- f"<br>({current_idx + 1} de {len(order)})</div>",
305
- unsafe_allow_html=True,
306
- )
307
- with c3:
308
- if st.button("Siguiente ➡️", disabled=(len(order) <= 1)):
309
- new_idx = (current_idx + 1) % len(order)
310
- st.session_state.current_image_id = order[new_idx]
311
- sm.update_activity()
312
- st.rerun()
313
 
314
- # Delete image from session
315
- if st.button("🗑️ Eliminar esta imagen", key="delete_img"):
316
- sm.remove_image(current_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  sm.update_activity()
318
  st.rerun()
319
 
320
- with col_tools:
321
- render_labeler(current_id)
 
 
322
 
323
- st.divider()
324
 
325
- render_recorder(current_id, model, selected_language)
 
326
 
327
- st.divider()
328
 
329
- render_downloader(current_id)
 
 
280
  order = st.session_state.image_order
281
  current_idx = order.index(current_id)
282
 
283
+ # ── Single-column layout ─────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
284
 
285
+ # 1️⃣ LABELER — radio buttons at full width
286
+ render_labeler(current_id)
287
+
288
+ st.divider()
289
+
290
+ # 2️⃣ IMAGE — with navigation and delete
291
+ st.image(
292
+ current_img["bytes"],
293
+ caption=current_img["filename"],
294
+ use_container_width=True,
295
+ )
 
 
 
 
 
 
 
 
 
296
 
297
+ c1, c2, c3 = st.columns([1, 2, 1])
298
+ with c1:
299
+ if st.button("⬅️ Anterior", disabled=(len(order) <= 1)):
300
+ new_idx = (current_idx - 1) % len(order)
301
+ st.session_state.current_image_id = order[new_idx]
302
+ sm.update_activity()
303
+ st.rerun()
304
+ with c2:
305
+ st.markdown(
306
+ f"<div style='text-align:center'><b>{current_img['filename']}</b>"
307
+ f"<br>({current_idx + 1} de {len(order)})</div>",
308
+ unsafe_allow_html=True,
309
+ )
310
+ with c3:
311
+ if st.button("Siguiente ➡️", disabled=(len(order) <= 1)):
312
+ new_idx = (current_idx + 1) % len(order)
313
+ st.session_state.current_image_id = order[new_idx]
314
  sm.update_activity()
315
  st.rerun()
316
 
317
+ if st.button("🗑️ Eliminar esta imagen", key="delete_img"):
318
+ sm.remove_image(current_id)
319
+ sm.update_activity()
320
+ st.rerun()
321
 
322
+ st.divider()
323
 
324
+ # 3️⃣ RECORDER — dictation and transcription
325
+ render_recorder(current_id, model, selected_language)
326
 
327
+ st.divider()
328
 
329
+ # 4️⃣ DOWNLOAD (individual) + SESSION INFO — two columns
330
+ render_downloader(current_id)
interface/services/export_service.py CHANGED
@@ -23,6 +23,7 @@ def _image_metadata(img: dict) -> dict:
23
  return {
24
  "filename": img["filename"],
25
  "label": img["label"],
 
26
  "transcription": img["transcription"],
27
  "transcription_original": img["transcription_original"],
28
  "doctor": img.get("labeled_by", ""),
@@ -76,12 +77,18 @@ def export_full_session() -> tuple[bytes, str]:
76
  # ── Summary CSV ──────────────────────────────────────────────────
77
  csv_buf = io.StringIO()
78
  writer = csv.writer(csv_buf)
79
- writer.writerow(["filename", "label", "has_audio", "has_transcription", "doctor"])
 
 
80
  for img_id in order:
81
  img = images[img_id]
 
82
  writer.writerow([
83
  img["filename"],
84
  img["label"] or "",
 
 
 
85
  "yes" if img["audio_bytes"] else "no",
86
  "yes" if img["transcription"] else "no",
87
  img.get("labeled_by", ""),
@@ -150,16 +157,22 @@ def export_huggingface_csv() -> tuple[bytes, str]:
150
 
151
  buf = io.StringIO()
152
  writer = csv.writer(buf)
153
- writer.writerow(["filename", "label", "label_code", "transcription", "doctor"])
 
 
154
 
155
  for img_id in order:
156
  img = images[img_id]
157
  if img["label"] is None:
158
  continue
 
159
  writer.writerow([
160
  img["filename"],
161
  img["label"],
162
  label_map.get(img["label"], ""),
 
 
 
163
  img["transcription"],
164
  img.get("labeled_by", ""),
165
  ])
@@ -188,10 +201,14 @@ def export_jsonl() -> tuple[bytes, str]:
188
  img = images[img_id]
189
  if img["label"] is None:
190
  continue
 
191
  obj = {
192
  "filename": img["filename"],
193
  "label": img["label"],
194
  "label_code": label_map.get(img["label"], ""),
 
 
 
195
  "transcription": img["transcription"],
196
  "doctor": img.get("labeled_by", ""),
197
  }
 
23
  return {
24
  "filename": img["filename"],
25
  "label": img["label"],
26
+ "locs_data": img.get("locs_data", {}),
27
  "transcription": img["transcription"],
28
  "transcription_original": img["transcription_original"],
29
  "doctor": img.get("labeled_by", ""),
 
77
  # ── Summary CSV ──────────────────────────────────────────────────
78
  csv_buf = io.StringIO()
79
  writer = csv.writer(csv_buf)
80
+ writer.writerow(["filename", "label", "nuclear_opalescence",
81
+ "nuclear_color", "cortical_opacity",
82
+ "has_audio", "has_transcription", "doctor"])
83
  for img_id in order:
84
  img = images[img_id]
85
+ locs = img.get("locs_data", {})
86
  writer.writerow([
87
  img["filename"],
88
  img["label"] or "",
89
+ locs.get("nuclear_opalescence", ""),
90
+ locs.get("nuclear_color", ""),
91
+ locs.get("cortical_opacity", ""),
92
  "yes" if img["audio_bytes"] else "no",
93
  "yes" if img["transcription"] else "no",
94
  img.get("labeled_by", ""),
 
157
 
158
  buf = io.StringIO()
159
  writer = csv.writer(buf)
160
+ writer.writerow(["filename", "label", "label_code",
161
+ "nuclear_opalescence", "nuclear_color", "cortical_opacity",
162
+ "transcription", "doctor"])
163
 
164
  for img_id in order:
165
  img = images[img_id]
166
  if img["label"] is None:
167
  continue
168
+ locs = img.get("locs_data", {})
169
  writer.writerow([
170
  img["filename"],
171
  img["label"],
172
  label_map.get(img["label"], ""),
173
+ locs.get("nuclear_opalescence", ""),
174
+ locs.get("nuclear_color", ""),
175
+ locs.get("cortical_opacity", ""),
176
  img["transcription"],
177
  img.get("labeled_by", ""),
178
  ])
 
201
  img = images[img_id]
202
  if img["label"] is None:
203
  continue
204
+ locs = img.get("locs_data", {})
205
  obj = {
206
  "filename": img["filename"],
207
  "label": img["label"],
208
  "label_code": label_map.get(img["label"], ""),
209
+ "nuclear_opalescence": locs.get("nuclear_opalescence"),
210
+ "nuclear_color": locs.get("nuclear_color"),
211
+ "cortical_opacity": locs.get("cortical_opacity"),
212
  "transcription": img["transcription"],
213
  "doctor": img.get("labeled_by", ""),
214
  }
interface/services/session_manager.py CHANGED
@@ -34,7 +34,8 @@ def add_image(filename: str, image_bytes: bytes) -> str:
34
  st.session_state.images[img_id] = {
35
  "filename": filename,
36
  "bytes": image_bytes,
37
- "label": None, # Set during labeling (Phase 3)
 
38
  "audio_bytes": None, # WAV from recording (Phase 4)
39
  "transcription": "", # Editable transcription text
40
  "transcription_original": "", # Original Whisper output (read-only)
 
34
  st.session_state.images[img_id] = {
35
  "filename": filename,
36
  "bytes": image_bytes,
37
+ "label": None, # Categorical: Normal/Cataract/Bad quality/Needs dilation
38
+ "locs_data": {}, # LOCS III: {"nuclear_opalescence": int, "nuclear_color": int, "cortical_opacity": int}
39
  "audio_bytes": None, # WAV from recording (Phase 4)
40
  "transcription": "", # Editable transcription text
41
  "transcription_original": "", # Original Whisper output (read-only)