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

Solucion de problemas con los botones de volver a grabar y restaurar original. Solucion de incongruencias en los dialogos de descargas e implementacion de internacionalizacion de la herramienta

Browse files
annotations.db CHANGED
Binary files a/annotations.db and b/annotations.db differ
 
interface/components/downloader.py CHANGED
@@ -7,6 +7,7 @@ Uses @st.dialog modals to warn about incomplete labeling before download.
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,
@@ -22,134 +23,152 @@ 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 ──────────────────────────────────────────────────────────
@@ -173,14 +192,14 @@ def render_downloader(image_id: str):
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
  ):
@@ -189,7 +208,7 @@ def render_downloader(image_id: str):
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",
@@ -198,25 +217,25 @@ def render_downloader(image_id: str):
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(
@@ -226,7 +245,7 @@ def render_downloader(image_id: str):
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",
@@ -236,7 +255,7 @@ def render_downloader(image_id: str):
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",
@@ -248,12 +267,12 @@ def render_downloader(image_id: str):
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:
254
  csv_bytes, csv_name = export_huggingface_csv()
255
  if st.download_button(
256
- label="📊 CSV (HuggingFace)",
257
  data=csv_bytes,
258
  file_name=csv_name,
259
  mime="text/csv",
@@ -264,7 +283,7 @@ def render_downloader(image_id: str):
264
  with ml2:
265
  jsonl_bytes, jsonl_name = export_jsonl()
266
  if st.download_button(
267
- label="📄 JSONL (Fine-tuning)",
268
  data=jsonl_bytes,
269
  file_name=jsonl_name,
270
  mime="application/jsonl",
 
7
  import streamlit as st
8
  import pandas as pd
9
  import config
10
+ from i18n import t
11
  from services.export_service import (
12
  export_single_image,
13
  export_full_session,
 
23
  """Return a list of human-readable items that are missing for one image."""
24
  missing = []
25
  if img.get("label") is None:
26
+ missing.append(t("missing_categorical"))
27
  elif img["label"] == "Cataract":
28
  locs = img.get("locs_data", {})
29
  for field in config.LOCS_FIELDS:
30
  fid = field["field_id"]
31
  if fid not in locs:
32
+ missing.append(t("missing_locs", field=field["label"]))
33
  if not img.get("transcription"):
34
+ missing.append(t("missing_voice"))
35
  return missing
36
 
37
 
38
  # ── Dialog: individual download with incomplete labeling ─────────────────────
39
 
 
40
  def _show_single_incomplete_dialog(image_id: str):
41
  """Warn about missing labeling for one image before individual download."""
 
 
 
 
42
 
43
+ @st.dialog(t("dlg_single_incomplete"), dismissible=False)
44
+ def _dlg():
45
+ img = st.session_state.images.get(image_id)
46
+ if img is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  st.rerun()
48
+ return
49
+
50
+ missing = _get_image_missing_info(img)
51
+ if not missing:
 
 
52
  st.session_state.pop("_pending_single_dl", None)
53
  st.rerun()
54
+ return
55
+
56
+ st.markdown(
57
+ t("incomplete_fields_msg", filename=img['filename'])
58
+ )
59
+ for item in missing:
60
+ st.markdown(f"- {item}")
61
+
62
+ st.divider()
63
+ c1, c2 = st.columns(2)
64
+ with c1:
65
+ zip_bytes, zip_name = export_single_image(image_id)
66
+ if st.download_button(
67
+ label=t("download_anyway"),
68
+ data=zip_bytes,
69
+ file_name=zip_name,
70
+ mime="application/zip",
71
+ key="_dlg_dl_single_anyway",
72
+ use_container_width=True,
73
+ ):
74
+ st.session_state.pop("_pending_single_dl", None)
75
+ st.rerun()
76
+ with c2:
77
+ if st.button(
78
+ t("go_back_finish"),
79
+ use_container_width=True,
80
+ type="primary",
81
+ ):
82
+ st.session_state.pop("_pending_single_dl", None)
83
+ st.rerun()
84
+
85
+ _dlg()
86
 
87
 
88
  # ── Dialog: bulk download with incomplete images ─────────────────────────────
89
 
 
90
  def _show_bulk_incomplete_dialog():
91
  """Table showing per-image what labeling is missing before bulk download."""
 
 
 
 
 
 
 
 
 
 
92
 
93
+ @st.dialog(t("dlg_bulk_incomplete"), width="large", dismissible=False)
94
+ def _dlg():
95
+ images = st.session_state.images
96
+ order = st.session_state.image_order
97
+
98
+ # Build table data
99
+ rows = []
100
+ for img_id in order:
101
+ img = images[img_id]
102
+ has_categorical = img.get("label") is not None
103
+ needs_locs = img.get("label") == "Cataract"
104
+ locs = img.get("locs_data", {})
105
+
106
+ if needs_locs:
107
+ locs_filled = all(
108
+ f["field_id"] in locs for f in config.LOCS_FIELDS
109
+ )
110
+ else:
111
+ locs_filled = True # Not applicable
112
+
113
+ has_voice = bool(img.get("transcription"))
114
+
115
+ # Determine LOCS III column value:
116
+ # - Cataract selected, all filled → ✅
117
+ # - Cataract selected, missing fields → ❌
118
+ # - No label selected → ❌
119
+ # - Non-cataract label selected "Not Required"
120
+ if needs_locs:
121
+ locs_cell = "✅" if locs_filled else "❌"
122
+ elif has_categorical:
123
+ locs_cell = t("locs_not_required")
124
+ else:
125
+ locs_cell = "❌"
126
+
127
+ # Only show images that have something missing
128
+ if not (has_categorical and locs_filled and has_voice):
129
+ rows.append({
130
+ t("col_image"): img["filename"],
131
+ t("col_categorical"): "✅" if has_categorical else "❌",
132
+ t("col_locs"): locs_cell,
133
+ t("col_voice"): "✅" if has_voice else "❌",
134
+ })
135
+
136
+ if not rows:
 
 
 
 
 
 
 
 
137
  st.session_state.pop("_pending_bulk_dl", None)
138
  st.rerun()
139
+ return
140
+
141
+ st.markdown(
142
+ t("bulk_incomplete_msg", count=len(rows))
143
+ )
144
+ df = pd.DataFrame(rows)
145
+ st.dataframe(df, use_container_width=True, hide_index=True)
146
+
147
+ st.divider()
148
+ c1, c2 = st.columns(2)
149
+ with c1:
150
+ zip_bytes, zip_name = export_full_session()
151
+ if st.download_button(
152
+ label=t("download_anyway"),
153
+ data=zip_bytes,
154
+ file_name=zip_name,
155
+ mime="application/zip",
156
+ key="_dlg_dl_bulk_anyway",
157
+ use_container_width=True,
158
+ ):
159
+ st.session_state.session_downloaded = True
160
+ st.session_state.pop("_pending_bulk_dl", None)
161
+ st.rerun()
162
+ with c2:
163
+ if st.button(
164
+ t("go_back_finish"),
165
+ use_container_width=True,
166
+ type="primary",
167
+ ):
168
+ st.session_state.pop("_pending_bulk_dl", None)
169
+ st.rerun()
170
+
171
+ _dlg()
172
 
173
 
174
  # ── Main downloader ──────────────────────────────────────────────────────────
 
192
  col_dl, col_info = st.columns(2)
193
 
194
  with col_dl:
195
+ st.subheader(t("single_download"))
196
 
197
  # Check completeness for individual download
198
  missing = _get_image_missing_info(img)
199
  if missing:
200
  # Show button that triggers the warning dialog
201
  if st.button(
202
+ t("download_file", filename=img['filename']),
203
  key=f"dl_single_check_{image_id}",
204
  use_container_width=True,
205
  ):
 
208
  else:
209
  zip_bytes, zip_name = export_single_image(image_id)
210
  st.download_button(
211
+ label=t("download_file", filename=img['filename']),
212
  data=zip_bytes,
213
  file_name=zip_name,
214
  mime="application/zip",
 
217
  )
218
 
219
  with col_info:
220
+ st.subheader(t("session_info"))
221
  summary = get_session_summary()
222
  sc1, sc2 = st.columns(2)
223
  with sc1:
224
+ st.metric(t("images_metric"), summary["total"])
225
+ st.metric(t("with_audio"), summary["with_audio"])
226
  with sc2:
227
+ st.metric(t("labeled_metric"), f"{summary['labeled']} / {summary['total']}")
228
+ st.metric(t("with_transcription"), summary["with_transcription"])
229
 
230
  st.divider()
231
 
232
  # ── Full-width: Bulk download ────────────────────────────────────────
233
+ st.subheader(t("bulk_download"))
234
 
235
  summary = get_session_summary()
236
 
237
  if summary["total"] == 0:
238
+ st.info(t("no_images_download"))
239
  else:
240
  # Check if any image has incomplete labeling
241
  has_incomplete = any(
 
245
 
246
  if has_incomplete:
247
  if st.button(
248
+ t("download_all_zip"),
249
  key="dl_bulk_check",
250
  use_container_width=True,
251
  type="primary",
 
255
  else:
256
  zip_bytes, zip_name = export_full_session()
257
  if st.download_button(
258
+ label=t("download_all_zip"),
259
  data=zip_bytes,
260
  file_name=zip_name,
261
  mime="application/zip",
 
267
 
268
  # ── ML-ready formats (Idea F) ────────────────────────────────────────
269
  if summary["labeled"] > 0:
270
+ st.markdown(t("ml_formats"))
271
  ml1, ml2 = st.columns(2)
272
  with ml1:
273
  csv_bytes, csv_name = export_huggingface_csv()
274
  if st.download_button(
275
+ label=t("hf_csv"),
276
  data=csv_bytes,
277
  file_name=csv_name,
278
  mime="text/csv",
 
283
  with ml2:
284
  jsonl_bytes, jsonl_name = export_jsonl()
285
  if st.download_button(
286
+ label=t("jsonl_finetune"),
287
  data=jsonl_bytes,
288
  file_name=jsonl_name,
289
  mime="application/jsonl",
interface/components/gallery.py CHANGED
@@ -5,6 +5,7 @@ badges and click-to-select behaviour.
5
  """
6
 
7
  import streamlit as st
 
8
  from services import session_manager as sm
9
 
10
 
@@ -29,7 +30,7 @@ def render_gallery():
29
 
30
  # ── Progress bar ─────────────────────────────────────────────────────
31
  labeled, total = sm.get_labeling_progress()
32
- progress_text = f"Progreso: **{labeled}** / **{total}** etiquetadas"
33
  st.markdown(progress_text)
34
  st.progress(labeled / total if total > 0 else 0)
35
 
@@ -88,18 +89,18 @@ def render_gallery():
88
  gc1, gc2, gc3 = st.columns([1, 3, 1])
89
  with gc1:
90
  if page > 0:
91
- if st.button("◀ Ant.", key="gal_prev"):
92
  st.session_state.gallery_page -= 1
93
  clicked = True
94
  with gc2:
95
  st.markdown(
96
  f"<div style='text-align:center; padding-top:6px;'>"
97
- f"Página {page + 1} / {total_pages}</div>",
98
  unsafe_allow_html=True,
99
  )
100
  with gc3:
101
  if page < total_pages - 1:
102
- if st.button("Sig. ▶", key="gal_next"):
103
  st.session_state.gallery_page += 1
104
  clicked = True
105
 
 
5
  """
6
 
7
  import streamlit as st
8
+ from i18n import t
9
  from services import session_manager as sm
10
 
11
 
 
30
 
31
  # ── Progress bar ─────────────────────────────────────────────────────
32
  labeled, total = sm.get_labeling_progress()
33
+ progress_text = f"{t('progress')}: **{labeled}** / **{total}** {t('labeled_suffix')}"
34
  st.markdown(progress_text)
35
  st.progress(labeled / total if total > 0 else 0)
36
 
 
89
  gc1, gc2, gc3 = st.columns([1, 3, 1])
90
  with gc1:
91
  if page > 0:
92
+ if st.button(t("gallery_prev"), key="gal_prev"):
93
  st.session_state.gallery_page -= 1
94
  clicked = True
95
  with gc2:
96
  st.markdown(
97
  f"<div style='text-align:center; padding-top:6px;'>"
98
+ f"{t('page')} {page + 1} / {total_pages}</div>",
99
  unsafe_allow_html=True,
100
  )
101
  with gc3:
102
  if page < total_pages - 1:
103
+ if st.button(t("gallery_next"), key="gal_next"):
104
  st.session_state.gallery_page += 1
105
  clicked = True
106
 
interface/components/labeler.py CHANGED
@@ -14,6 +14,7 @@ Numeric values are stored for ML; only text labels are shown in the UI.
14
  import streamlit as st
15
  import config
16
  import database as db
 
17
  from services import session_manager as sm
18
 
19
 
@@ -36,7 +37,7 @@ def _render_locs_dropdown(field: dict, image_id: str, current_locs: dict) -> int
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)
@@ -50,11 +51,11 @@ def _render_locs_dropdown(field: dict, image_id: str, current_locs: dict) -> int
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:
@@ -69,31 +70,37 @@ def render_labeler(image_id: str):
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")
86
 
87
  selected = st.radio(
88
- "Clasificación",
89
- display_options,
90
  index=current_index,
91
  key=f"label_radio_{image_id}",
92
  horizontal=True,
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
@@ -110,7 +117,7 @@ def render_labeler(image_id: str):
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
@@ -133,12 +140,12 @@ def render_labeler(image_id: str):
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}**")
 
14
  import streamlit as st
15
  import config
16
  import database as db
17
+ from i18n import t, label_display, label_from_display, locs_display
18
  from services import session_manager as sm
19
 
20
 
 
37
  """Render a single LOCS dropdown and return the selected numeric value."""
38
  field_id = field["field_id"]
39
  options = field["options"]
40
+ display_labels = [locs_display(opt["display"]) for opt in options]
41
 
42
  # Determine current index from stored data
43
  stored_value = current_locs.get(field_id)
 
51
 
52
  # Use index=None so nothing is pre-selected until doctor chooses
53
  selected_display = st.selectbox(
54
+ locs_display(field["label"]),
55
  display_labels,
56
  index=current_index,
57
  key=f"locs_{field_id}_{image_id}",
58
+ placeholder=t("locs_placeholder"),
59
  )
60
 
61
  if selected_display is not None and selected_display in display_labels:
 
70
  if img is None:
71
  return
72
 
73
+ st.subheader(t("labeling"))
74
 
75
  # ── 1. Categorical classification ────────────────────────────────────
76
+ # Translated display (UI only); storage always uses English name.
77
+ translated_options = [label_display(opt["display"]) for opt in config.LABEL_OPTIONS]
78
+ current_label = img.get("label") # English, e.g. "Cataract"
79
+
80
+ if current_label is not None:
81
+ translated_current = label_display(current_label)
82
+ if translated_current in translated_options:
83
+ current_index = translated_options.index(translated_current)
84
+ else:
85
+ current_index = None
86
  else:
87
  current_index = None
88
 
89
  with st.container(border=True):
90
  if current_index is None:
91
+ st.caption(t("select_label_hint"))
92
 
93
  selected = st.radio(
94
+ t("classification"),
95
+ translated_options,
96
  index=current_index,
97
  key=f"label_radio_{image_id}",
98
  horizontal=True,
99
  label_visibility="collapsed",
100
  )
101
 
102
+ # Map translated selection back to English for storage
103
+ new_label = label_from_display(selected) if selected in translated_options else None
104
 
105
  # Detect categorical change
106
  label_changed = new_label is not None and new_label != current_label
 
117
  effective_label = new_label or current_label
118
  if effective_label == "Cataract":
119
  st.markdown("---")
120
+ st.markdown(t("locs_title"))
121
 
122
  current_locs = img.get("locs_data", {})
123
  locs_changed = False
 
140
  filled = sum(1 for f in config.LOCS_FIELDS if f["field_id"] in current_locs)
141
  total_fields = len(config.LOCS_FIELDS)
142
  if filled < total_fields:
143
+ st.info(t("locs_progress", filled=filled, total=total_fields))
144
  else:
145
+ st.success(t("locs_complete", filled=filled, total=total_fields))
146
 
147
+ # ── 3. Visual feedback ───────────────────────────────────────────────────
148
  if effective_label is None:
149
+ st.warning(t("unlabeled"))
150
  else:
151
+ st.success(f"{t('label_set')}: **{label_display(effective_label)}**")
interface/components/recorder.py CHANGED
@@ -10,6 +10,7 @@ Includes timestamped segments from Whisper for reference.
10
  import hashlib
11
  import streamlit as st
12
  import database as db
 
13
  from services import session_manager as sm
14
  from services.whisper_service import transcribe_audio_with_timestamps, format_timestamp
15
 
@@ -35,25 +36,49 @@ def render_recorder(image_id: str, model, language: str):
35
  if img is None:
36
  return
37
 
38
- st.subheader("🎙️ Dictado y Transcripción")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  # ── Audio recording ──────────────────────────────────────────────────
41
  audio_wav = st.audio_input(
42
- "Grabar audio",
43
  key=f"audio_input_{image_id}",
44
  )
45
 
46
- # Track which audio blob we already processed so we don't re-transcribe
47
- processed_key = f"_last_audio_{image_id}"
48
- segments_key = f"_segments_{image_id}"
49
-
50
  if audio_wav is not None:
51
  audio_bytes = audio_wav.getvalue()
52
  fingerprint = _audio_fingerprint(audio_bytes)
53
 
54
  # Only transcribe if this is a *new* recording (content changed)
55
  if st.session_state.get(processed_key) != fingerprint:
56
- with st.spinner("Transcribiendo audio…"):
57
  text, segments = transcribe_audio_with_timestamps(
58
  model, audio_bytes, language
59
  )
@@ -101,11 +126,11 @@ def render_recorder(image_id: str, model, language: str):
101
 
102
  # ── Editable transcription ───────────────────────────────────────────
103
  edited_text = st.text_area(
104
- "Transcripción (editable)",
105
  value=img["transcription"],
106
  height=180,
107
  key=f"transcription_area_{image_id}",
108
- placeholder="Grabe un audio o escriba la transcripción manualmente…",
109
  )
110
 
111
  # Sync edits back to session
@@ -116,7 +141,7 @@ def render_recorder(image_id: str, model, language: str):
116
  # ── Timestamped segments (Idea C) ────────────────────────────────────
117
  segments = st.session_state.get(segments_key, [])
118
  if segments:
119
- with st.expander("🕐 Segmentos con timestamps", expanded=False):
120
  for seg in segments:
121
  ts_start = format_timestamp(seg["start"])
122
  ts_end = format_timestamp(seg["end"])
@@ -125,54 +150,31 @@ def render_recorder(image_id: str, model, language: str):
125
  )
126
 
127
  # ── Helper buttons ───────────────────────────────────────────────────
128
- btn_cols = st.columns(3)
129
 
130
  with btn_cols[0]:
131
- # Re-record: clear audio and transcription so a new recording can be made
132
  has_audio = img["audio_bytes"] is not None
133
  if st.button(
134
- "🎤 Volver a grabar",
135
  key=f"rerecord_{image_id}",
136
  disabled=not has_audio,
137
  use_container_width=True,
138
  ):
139
- img["audio_bytes"] = None
140
- img["transcription"] = ""
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()
149
 
150
  with btn_cols[1]:
151
- # Restore original Whisper transcription
152
  has_original = bool(img["transcription_original"])
153
  is_different = img["transcription"] != img["transcription_original"]
154
  if st.button(
155
- "🔄 Restaurar original",
156
  key=f"restore_{image_id}",
157
  disabled=not (has_original and is_different),
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
-
165
- with btn_cols[2]:
166
- # Clear transcription entirely
167
- if st.button(
168
- "🗑️ Limpiar texto",
169
- key=f"clear_text_{image_id}",
170
- disabled=not img["transcription"],
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
 
178
  # ── Status line ──────────────────────────────────────────────────────
@@ -182,8 +184,8 @@ def render_recorder(image_id: str, model, language: str):
182
  img["transcription_original"]
183
  and img["transcription"] != img["transcription_original"]
184
  ):
185
- modified_tag = " ✏️ _modificada manualmente_"
186
  word_count = len(img["transcription"].split())
187
- st.caption(f"{word_count} palabras{modified_tag}")
188
  else:
189
- st.caption("Sin transcripción aún.")
 
10
  import hashlib
11
  import streamlit as st
12
  import database as db
13
+ from i18n import t
14
  from services import session_manager as sm
15
  from services.whisper_service import transcribe_audio_with_timestamps, format_timestamp
16
 
 
36
  if img is None:
37
  return
38
 
39
+ st.subheader(t("dictation"))
40
+
41
+ # Track which audio blob we already processed so we don't re-transcribe
42
+ processed_key = f"_last_audio_{image_id}"
43
+ segments_key = f"_segments_{image_id}"
44
+
45
+ # ── Handle button actions BEFORE widget rendering ────────────────────
46
+ # Streamlit forbids setting a widget's session_state key after the widget
47
+ # is instantiated. We use callback flags to detect button presses from
48
+ # the *previous* rerun and apply state changes *before* the text_area.
49
+ _rerecord_flag = f"_flag_rerecord_{image_id}"
50
+ _restore_flag = f"_flag_restore_{image_id}"
51
+
52
+ if st.session_state.pop(_rerecord_flag, False):
53
+ img["audio_bytes"] = None
54
+ img["transcription"] = ""
55
+ img["transcription_original"] = ""
56
+ st.session_state.pop(segments_key, None)
57
+ st.session_state.pop(processed_key, None)
58
+ st.session_state.pop(f"audio_input_{image_id}", None)
59
+ # Set value BEFORE the text_area is created
60
+ st.session_state[f"transcription_area_{image_id}"] = ""
61
+ sm.update_activity()
62
+
63
+ if st.session_state.pop(_restore_flag, False):
64
+ img["transcription"] = img["transcription_original"]
65
+ # Set value BEFORE the text_area is created
66
+ st.session_state[f"transcription_area_{image_id}"] = img["transcription_original"]
67
+ sm.update_activity()
68
 
69
  # ── Audio recording ──────────────────────────────────────────────────
70
  audio_wav = st.audio_input(
71
+ t("record_audio"),
72
  key=f"audio_input_{image_id}",
73
  )
74
 
 
 
 
 
75
  if audio_wav is not None:
76
  audio_bytes = audio_wav.getvalue()
77
  fingerprint = _audio_fingerprint(audio_bytes)
78
 
79
  # Only transcribe if this is a *new* recording (content changed)
80
  if st.session_state.get(processed_key) != fingerprint:
81
+ with st.spinner(t("transcribing")):
82
  text, segments = transcribe_audio_with_timestamps(
83
  model, audio_bytes, language
84
  )
 
126
 
127
  # ── Editable transcription ───────────────────────────────────────────
128
  edited_text = st.text_area(
129
+ t("transcription_editable"),
130
  value=img["transcription"],
131
  height=180,
132
  key=f"transcription_area_{image_id}",
133
+ placeholder=t("transcription_placeholder"),
134
  )
135
 
136
  # Sync edits back to session
 
141
  # ── Timestamped segments (Idea C) ────────────────────────────────────
142
  segments = st.session_state.get(segments_key, [])
143
  if segments:
144
+ with st.expander(t("segments_timestamps"), expanded=False):
145
  for seg in segments:
146
  ts_start = format_timestamp(seg["start"])
147
  ts_end = format_timestamp(seg["end"])
 
150
  )
151
 
152
  # ── Helper buttons ───────────────────────────────────────────────────
153
+ btn_cols = st.columns(2)
154
 
155
  with btn_cols[0]:
156
+ # Re-record: set flag rerun flag handler above clears state
157
  has_audio = img["audio_bytes"] is not None
158
  if st.button(
159
+ t("re_record"),
160
  key=f"rerecord_{image_id}",
161
  disabled=not has_audio,
162
  use_container_width=True,
163
  ):
164
+ st.session_state[_rerecord_flag] = True
 
 
 
 
 
 
 
 
165
  st.rerun()
166
 
167
  with btn_cols[1]:
168
+ # Restore original: set flag → rerun → flag handler above restores
169
  has_original = bool(img["transcription_original"])
170
  is_different = img["transcription"] != img["transcription_original"]
171
  if st.button(
172
+ t("restore_original"),
173
  key=f"restore_{image_id}",
174
  disabled=not (has_original and is_different),
175
  use_container_width=True,
176
  ):
177
+ st.session_state[_restore_flag] = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  st.rerun()
179
 
180
  # ── Status line ──────────────────────────────────────────────────────
 
184
  img["transcription_original"]
185
  and img["transcription"] != img["transcription_original"]
186
  ):
187
+ modified_tag = t("manually_modified")
188
  word_count = len(img["transcription"].split())
189
+ st.caption(f"{t('word_count', count=word_count)}{modified_tag}")
190
  else:
191
+ st.caption(t("no_transcription_yet"))
interface/components/uploader.py CHANGED
@@ -9,6 +9,7 @@ Uses @st.dialog modals to warn about:
9
  import streamlit as st
10
  import config
11
  import database as db
 
12
  from services import session_manager as sm
13
  from utils import validate_image_bytes
14
 
@@ -19,49 +20,48 @@ def _reset_uploader():
19
 
20
 
21
  # ── Modal: previously labeled images ─────────────────────────────────────────
22
- @st.dialog("⚠️ Imágenes ya etiquetadas", width="large", dismissible=False)
23
  def _show_relabel_dialog():
24
  """Modal dialog asking the doctor which previously-labeled images to re-upload."""
25
- pending = st.session_state.get("_pending_upload_review")
26
- if not pending:
27
- st.rerun()
28
- return
29
-
30
- prev = pending["previously_labeled"]
31
- non_labeled_count = len(pending["files"]) - len(prev)
32
 
33
- st.markdown(
34
- f"**{len(prev)} imagen(es)** ya fueron etiquetadas anteriormente. "
35
- "Seleccione cuáles desea volver a etiquetar."
36
- )
37
- if non_labeled_count > 0:
38
- st.info(
39
- f"ℹ️ Las otras **{non_labeled_count}** imagen(es) nuevas se subirán automáticamente."
40
- )
41
-
42
- relabel_choices = {}
43
- for fname, records in prev.items():
44
- latest = records[0]
45
- label_info = latest.get("label", "—")
46
- doctor_info = latest.get("doctorName", "—")
47
- ts_info = str(latest.get("createdAt", ""))[:16]
48
- n_times = len(records)
49
- badge = f"({n_times} vez{'es' if n_times > 1 else ''})"
50
-
51
- relabel_choices[fname] = st.checkbox(
52
- f"**{fname}** — _{label_info}_ | {doctor_info} | {ts_info} {badge}",
53
- value=True,
54
- key=f"_dlg_relabel_{fname}",
55
- )
56
 
57
- st.divider()
58
- col_a, col_b = st.columns(2)
59
- with col_a:
60
- if st.button("✅ Aceptar y subir", type="primary", use_container_width=True):
61
- _process_pending(relabel_choices)
62
- with col_b:
63
- if st.button("❌ Cancelar etiquetadas", use_container_width=True):
64
- _cancel_pending()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
  def _process_pending(relabel_choices: dict[str, bool]):
@@ -128,24 +128,27 @@ def _cancel_pending():
128
 
129
 
130
  # ── Modal: session duplicates (informational) ────────────────────────────────
131
- @st.dialog("ℹ️ Imágenes duplicadas en sesión", dismissible=False)
132
  def _show_duplicates_dialog():
133
  """Informational modal listing images already present in the current session."""
134
- dup_names = st.session_state.get("_session_duplicates", [])
135
- if not dup_names:
136
- st.rerun()
137
- return
138
 
139
- st.markdown(
140
- "Las siguientes imágenes **ya se encuentran en la sesión actual** "
141
- "y no se volverán a subir:"
142
- )
143
- for fname in dup_names:
144
- st.markdown(f"- `{fname}`")
145
 
146
- if st.button("Aceptar", use_container_width=True):
147
- st.session_state.pop("_session_duplicates", None)
148
- st.rerun()
 
 
 
 
 
 
 
 
149
 
150
 
151
  # ── Main uploader ───────────────���────────────────────────────────────────────
@@ -157,11 +160,11 @@ def render_uploader():
157
  counter = st.session_state.get("_uploader_counter", 0)
158
 
159
  uploaded_files = st.file_uploader(
160
- "📤 Subir imágenes médicas",
161
  type=config.ALLOWED_EXTENSIONS,
162
  accept_multiple_files=True,
163
- help=f"Formatos aceptados: {', '.join(config.ALLOWED_EXTENSIONS)}. "
164
- f"Máx. {config.MAX_UPLOAD_SIZE_MB} MB por archivo.",
165
  key=f"uploader_{counter}",
166
  )
167
 
@@ -241,7 +244,7 @@ def render_uploader():
241
 
242
  if skipped_invalid > 0:
243
  st.warning(
244
- f"⚠️ {skipped_invalid} archivo(s) no son imágenes válidas y fueron ignorados."
245
  )
246
 
247
  if new_count > 0:
 
9
  import streamlit as st
10
  import config
11
  import database as db
12
+ from i18n import t
13
  from services import session_manager as sm
14
  from utils import validate_image_bytes
15
 
 
20
 
21
 
22
  # ── Modal: previously labeled images ─────────────────────────────────────────
 
23
  def _show_relabel_dialog():
24
  """Modal dialog asking the doctor which previously-labeled images to re-upload."""
 
 
 
 
 
 
 
25
 
26
+ @st.dialog(t("dlg_relabel"), width="large", dismissible=False)
27
+ def _dlg():
28
+ pending = st.session_state.get("_pending_upload_review")
29
+ if not pending:
30
+ st.rerun()
31
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ prev = pending["previously_labeled"]
34
+ non_labeled_count = len(pending["files"]) - len(prev)
35
+
36
+ st.markdown(t("relabel_dialog_msg", count=len(prev)))
37
+ if non_labeled_count > 0:
38
+ st.info(t("relabel_new_info", count=non_labeled_count))
39
+
40
+ relabel_choices = {}
41
+ for fname, records in prev.items():
42
+ latest = records[0]
43
+ label_info = latest.get("label", "—")
44
+ doctor_info = latest.get("doctorName", "—")
45
+ ts_info = str(latest.get("createdAt", ""))[:16]
46
+ n_times = len(records)
47
+ badge = t("times_badge_plural", n=n_times) if n_times > 1 else t("times_badge", n=n_times)
48
+
49
+ relabel_choices[fname] = st.checkbox(
50
+ f"**{fname}** — _{label_info}_ | {doctor_info} | {ts_info} ({badge})",
51
+ value=True,
52
+ key=f"_dlg_relabel_{fname}",
53
+ )
54
+
55
+ st.divider()
56
+ col_a, col_b = st.columns(2)
57
+ with col_a:
58
+ if st.button(t("accept_upload"), type="primary", use_container_width=True):
59
+ _process_pending(relabel_choices)
60
+ with col_b:
61
+ if st.button(t("cancel_labeled"), use_container_width=True):
62
+ _cancel_pending()
63
+
64
+ _dlg()
65
 
66
 
67
  def _process_pending(relabel_choices: dict[str, bool]):
 
128
 
129
 
130
  # ── Modal: session duplicates (informational) ────────────────────────────────
 
131
  def _show_duplicates_dialog():
132
  """Informational modal listing images already present in the current session."""
 
 
 
 
133
 
134
+ @st.dialog(t("dlg_duplicates"), dismissible=False)
135
+ def _dlg():
136
+ dup_names = st.session_state.get("_session_duplicates", [])
137
+ if not dup_names:
138
+ st.rerun()
139
+ return
140
 
141
+ st.markdown(
142
+ t("duplicates_dialog_msg")
143
+ )
144
+ for fname in dup_names:
145
+ st.markdown(f"- `{fname}`")
146
+
147
+ if st.button(t("accept"), use_container_width=True):
148
+ st.session_state.pop("_session_duplicates", None)
149
+ st.rerun()
150
+
151
+ _dlg()
152
 
153
 
154
  # ── Main uploader ───────────────���────────────────────────────────────────────
 
160
  counter = st.session_state.get("_uploader_counter", 0)
161
 
162
  uploaded_files = st.file_uploader(
163
+ t("upload_images"),
164
  type=config.ALLOWED_EXTENSIONS,
165
  accept_multiple_files=True,
166
+ help=f"{t('upload_help_formats')}: {', '.join(config.ALLOWED_EXTENSIONS)}. "
167
+ f"{t('upload_help_max')} {config.MAX_UPLOAD_SIZE_MB} MB.",
168
  key=f"uploader_{counter}",
169
  )
170
 
 
244
 
245
  if skipped_invalid > 0:
246
  st.warning(
247
+ f"⚠️ {skipped_invalid} {t('invalid_files')}"
248
  )
249
 
250
  if new_count > 0:
interface/config.py CHANGED
@@ -86,5 +86,5 @@ APP_ICON = "👁️"
86
  APP_SUBTITLE = "Sistema de Etiquetado Médico Oftalmológico"
87
 
88
  # ── UI Language ──────────────────────────────────────────────────────────────
89
- # "es" = Español, "en" = English
90
- UI_LANGUAGE = "es"
 
86
  APP_SUBTITLE = "Sistema de Etiquetado Médico Oftalmológico"
87
 
88
  # ── UI Language ──────────────────────────────────────────────────────────────
89
+ # Language is now managed via st.session_state["ui_language"] and i18n module.
90
+ # Supported: "es" (Español), "en" (English).
interface/i18n.py CHANGED
@@ -1,10 +1,19 @@
1
  """OphthalmoCapture — Internationalization (i18n)
2
 
3
- Centralized UI strings. Switch the active language by changing
4
- ``ACTIVE_LANGUAGE``. All components import strings from here.
5
  """
6
 
7
- ACTIVE_LANGUAGE = "es"
 
 
 
 
 
 
 
 
 
8
 
9
  _STRINGS = {
10
  "es": {
@@ -26,11 +35,11 @@ _STRINGS = {
26
  "label_header": "Etiqueta",
27
  "doctor_header": "Doctor",
28
  "no_transcription": "Sin transcripción",
29
- "end_session": "🗑️ Finalizar Sesión",
30
  "undownloaded_warning": "⚠️ Datos no descargados",
31
  "timeout_in": "⏱️ Timeout en",
32
- "confirm_delete": "¿Está seguro? **Todos los datos se eliminarán permanentemente.**",
33
- "yes_delete": "✅ Sí, eliminar",
34
  "cancel": "❌ Cancelar",
35
  "logout": "🚪 Cerrar sesión",
36
  # Upload
@@ -94,6 +103,59 @@ _STRINGS = {
94
  # Auth
95
  "login_prompt": "👨‍⚕️ Inicie sesión para acceder al sistema de etiquetado.",
96
  "login_error": "❌ Usuario o contraseña incorrectos.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  },
98
  "en": {
99
  "app_subtitle": "Ophthalmological Medical Labeling System",
@@ -112,11 +174,11 @@ _STRINGS = {
112
  "label_header": "Label",
113
  "doctor_header": "Doctor",
114
  "no_transcription": "No transcription",
115
- "end_session": "🗑️ End Session",
116
  "undownloaded_warning": "⚠️ Undownloaded data",
117
  "timeout_in": "⏱️ Timeout in",
118
- "confirm_delete": "Are you sure? **All data will be permanently deleted.**",
119
- "yes_delete": "✅ Yes, delete",
120
  "cancel": "❌ Cancel",
121
  "logout": "🚪 Log out",
122
  "upload_images": "📤 Upload medical images",
@@ -172,11 +234,134 @@ _STRINGS = {
172
  "download_before_expire": "Download your data before the session expires next time.",
173
  "login_prompt": "👨‍⚕️ Log in to access the labeling system.",
174
  "login_error": "❌ Wrong username or password.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  },
 
176
  }
177
 
178
 
179
- def t(key: str) -> str:
180
- """Return the translated string for *key* in the active language."""
181
- lang_dict = _STRINGS.get(ACTIVE_LANGUAGE, _STRINGS["es"])
182
- return lang_dict.get(key, key)
 
 
 
1
  """OphthalmoCapture — Internationalization (i18n)
2
 
3
+ Centralized UI strings with session-state-based language selection.
4
+ All components call t(key) to get translated strings.
5
  """
6
 
7
+ import streamlit as st
8
+
9
+ SUPPORTED_LANGUAGES = {"es": "Español", "en": "English"}
10
+ DEFAULT_LANGUAGE = "es"
11
+
12
+
13
+ def _get_lang() -> str:
14
+ """Return the active UI language code from session state."""
15
+ return st.session_state.get("ui_language", DEFAULT_LANGUAGE)
16
+
17
 
18
  _STRINGS = {
19
  "es": {
 
35
  "label_header": "Etiqueta",
36
  "doctor_header": "Doctor",
37
  "no_transcription": "Sin transcripción",
38
+ "end_session": " Cerrar sesión",
39
  "undownloaded_warning": "⚠️ Datos no descargados",
40
  "timeout_in": "⏱️ Timeout en",
41
+ "confirm_delete": "¿Está seguro? **Se cerrará la sesión y todos los datos se eliminarán permanentemente.**",
42
+ "yes_delete": "✅ Sí, cerrar sesión",
43
  "cancel": "❌ Cancelar",
44
  "logout": "🚪 Cerrar sesión",
45
  # Upload
 
103
  # Auth
104
  "login_prompt": "👨‍⚕️ Inicie sesión para acceder al sistema de etiquetado.",
105
  "login_error": "❌ Usuario o contraseña incorrectos.",
106
+ # i18n
107
+ "ui_language": "🌐 Idioma / Language",
108
+ "loading_whisper": "Cargando modelo Whisper '{model}'...",
109
+ # Session expiry with placeholders
110
+ "session_expired": "⏰ Sesión expirada por inactividad ({minutes} min). Se eliminaron **{total}** imágenes, **{labeled}** etiquetadas, **{with_audio}** con audio. Descargue sus datos antes de que expire la sesión la próxima vez.",
111
+ "db_error": "Error crítico de base de datos: {error}",
112
+ "history_error": "Error al obtener historial: {error}",
113
+ # Labeler
114
+ "select_label_hint": "⬇️ Seleccione una etiqueta para esta imagen",
115
+ "locs_title": "**Clasificación LOCS III**",
116
+ "locs_placeholder": "Seleccionar…",
117
+ "locs_progress": "📋 LOCS: {filled}/{total} campos completados",
118
+ "locs_complete": "✅ LOCS: {filled}/{total} campos completados",
119
+ # Recorder
120
+ "re_record": "🎤 Volver a grabar",
121
+ "word_count": "{count} palabras",
122
+ # Downloader
123
+ "single_download": "📥 Descarga individual",
124
+ "session_info": "📊 Información de sesión",
125
+ "bulk_download": "📦 Descargar todo el etiquetado",
126
+ "download_all_zip": "⬇️ Descargar todo el etiquetado (ZIP)",
127
+ "download_file": "⬇️ Descargar — {filename}",
128
+ "incomplete_fields_msg": "La imagen **{filename}** tiene campos sin completar:",
129
+ "missing_categorical": "Etiqueta categórica",
130
+ "missing_locs": "LOCS III – {field}",
131
+ "missing_voice": "Etiquetado por voz",
132
+ "download_anyway": "⬇️ Descargar igualmente",
133
+ "go_back_finish": "🔙 Regresar y terminar",
134
+ "bulk_incomplete_msg": "**{count} imagen(es)** tienen etiquetado incompleto:",
135
+ "col_image": "Imagen",
136
+ "col_categorical": "Categórica",
137
+ "col_locs": "LOCS III",
138
+ "col_voice": "Voz",
139
+ "locs_not_required": "No Necesario",
140
+ "image_counter": "{current} de {total}",
141
+ # Gallery
142
+ "gallery_prev": "◀ Ant.",
143
+ "gallery_next": "Sig. ▶",
144
+ # Uploader
145
+ "relabel_dialog_msg": "**{count} imagen(es)** ya fueron etiquetadas anteriormente. Seleccione cuáles desea volver a etiquetar.",
146
+ "relabel_new_info": "ℹ️ Las otras **{count}** imagen(es) nuevas se subirán automáticamente.",
147
+ "accept_upload": "✅ Aceptar y subir",
148
+ "cancel_labeled": "❌ Cancelar etiquetadas",
149
+ "duplicates_dialog_msg": "Las siguientes imágenes **ya se encuentran en la sesión actual** y no se volverán a subir:",
150
+ "accept": "Aceptar",
151
+ # Dialog titles
152
+ "dlg_single_incomplete": "⚠️ Etiquetado incompleto",
153
+ "dlg_bulk_incomplete": "⚠️ Imágenes con etiquetado incompleto",
154
+ "dlg_relabel": "⚠️ Imágenes ya etiquetadas",
155
+ "dlg_duplicates": "ℹ️ Imágenes duplicadas en sesión",
156
+ # Uploader badge
157
+ "times_badge": "{n} vez",
158
+ "times_badge_plural": "{n} veces",
159
  },
160
  "en": {
161
  "app_subtitle": "Ophthalmological Medical Labeling System",
 
174
  "label_header": "Label",
175
  "doctor_header": "Doctor",
176
  "no_transcription": "No transcription",
177
+ "end_session": " Log out",
178
  "undownloaded_warning": "⚠️ Undownloaded data",
179
  "timeout_in": "⏱️ Timeout in",
180
+ "confirm_delete": "Are you sure? **The session will be closed and all data permanently deleted.**",
181
+ "yes_delete": "✅ Yes, log out",
182
  "cancel": "❌ Cancel",
183
  "logout": "🚪 Log out",
184
  "upload_images": "📤 Upload medical images",
 
234
  "download_before_expire": "Download your data before the session expires next time.",
235
  "login_prompt": "👨‍⚕️ Log in to access the labeling system.",
236
  "login_error": "❌ Wrong username or password.",
237
+ "ui_language": "🌐 Language / Idioma",
238
+ "loading_whisper": "Loading Whisper model '{model}'...",
239
+ "session_expired": "⏰ Session expired due to inactivity ({minutes} min). Removed **{total}** images, **{labeled}** labeled, **{with_audio}** with audio. Download your data before the session expires next time.",
240
+ "db_error": "Critical database error: {error}",
241
+ "history_error": "Error fetching history: {error}",
242
+ "select_label_hint": "⬇️ Select a label for this image",
243
+ "locs_title": "**LOCS III Classification**",
244
+ "locs_placeholder": "Select…",
245
+ "locs_progress": "📋 LOCS: {filled}/{total} fields completed",
246
+ "locs_complete": "✅ LOCS: {filled}/{total} fields completed",
247
+ "re_record": "🎤 Re-record",
248
+ "word_count": "{count} words",
249
+ "single_download": "📥 Individual Download",
250
+ "session_info": "📊 Session Information",
251
+ "bulk_download": "📦 Download All Labeling",
252
+ "download_all_zip": "⬇️ Download all labeling (ZIP)",
253
+ "download_file": "⬇️ Download — {filename}",
254
+ "incomplete_fields_msg": "Image **{filename}** has incomplete fields:",
255
+ "missing_categorical": "Categorical label",
256
+ "missing_locs": "LOCS III – {field}",
257
+ "missing_voice": "Voice labeling",
258
+ "download_anyway": "⬇️ Download anyway",
259
+ "go_back_finish": "🔙 Go back and finish",
260
+ "bulk_incomplete_msg": "**{count} image(s)** have incomplete labeling:",
261
+ "col_image": "Image",
262
+ "col_categorical": "Categorical",
263
+ "col_locs": "LOCS III",
264
+ "col_voice": "Voice",
265
+ "locs_not_required": "Not Required",
266
+ "image_counter": "{current} of {total}",
267
+ "gallery_prev": "◀ Prev",
268
+ "gallery_next": "Next ▶",
269
+ "relabel_dialog_msg": "**{count} image(s)** were previously labeled. Select which ones to re-label.",
270
+ "relabel_new_info": "ℹ️ The other **{count}** new image(s) will be uploaded automatically.",
271
+ "accept_upload": "✅ Accept and upload",
272
+ "cancel_labeled": "❌ Cancel labeled",
273
+ "duplicates_dialog_msg": "The following images **are already in the current session** and will not be re-uploaded:",
274
+ "accept": "Accept",
275
+ "dlg_single_incomplete": "⚠️ Incomplete labeling",
276
+ "dlg_bulk_incomplete": "⚠️ Images with incomplete labeling",
277
+ "dlg_relabel": "⚠️ Previously labeled images",
278
+ "dlg_duplicates": "ℹ️ Duplicate images in session",
279
+ "times_badge": "{n} time",
280
+ "times_badge_plural": "{n} times",
281
+ },
282
+ }
283
+
284
+
285
+ def t(key: str, **kwargs) -> str:
286
+ """Return the translated string for *key*, with optional format kwargs."""
287
+ lang = _get_lang()
288
+ text = _STRINGS.get(lang, _STRINGS["es"]).get(key, key)
289
+ if kwargs:
290
+ try:
291
+ text = text.format(**kwargs)
292
+ except (KeyError, IndexError):
293
+ pass
294
+ return text
295
+
296
+
297
+ # ── Label display translations ───────────────────────────────────────────────
298
+ # Labels are stored in English (config.LABEL_OPTIONS["display"]).
299
+ # These mappings translate for UI display only.
300
+
301
+ _LABEL_DISPLAY = {
302
+ "es": {
303
+ "Normal": "Normal",
304
+ "Cataract": "Catarata",
305
+ "Bad quality": "Mala calidad",
306
+ "Needs dilation": "Necesita dilatación",
307
+ },
308
+ "en": {
309
+ "Normal": "Normal",
310
+ "Cataract": "Cataract",
311
+ "Bad quality": "Bad quality",
312
+ "Needs dilation": "Needs dilation",
313
+ },
314
+ }
315
+
316
+
317
+ def label_display(english_name: str) -> str:
318
+ """Translate a label's English display name to the active UI language."""
319
+ lang = _get_lang()
320
+ return _LABEL_DISPLAY.get(lang, _LABEL_DISPLAY["en"]).get(english_name, english_name)
321
+
322
+
323
+ def label_from_display(translated_name: str) -> str | None:
324
+ """Reverse-map a translated label back to its English storage name."""
325
+ lang = _get_lang()
326
+ mapping = _LABEL_DISPLAY.get(lang, _LABEL_DISPLAY["en"])
327
+ reverse = {v: k for k, v in mapping.items()}
328
+ return reverse.get(translated_name)
329
+
330
+
331
+ # ── LOCS display translations ─────────────────────────────────────────���──────
332
+
333
+ _LOCS_DISPLAY = {
334
+ "es": {
335
+ "Nuclear Cataract \u2013 Opalescence (NO)": "Catarata Nuclear \u2013 Opalescencia (NO)",
336
+ "Nuclear Cataract \u2013 Color (NC)": "Catarata Nuclear \u2013 Color (NC)",
337
+ "Cortical Cataract (C)": "Catarata Cortical (C)",
338
+ "None / Clear": "Ninguna / Transparente",
339
+ "Very mild": "Muy leve",
340
+ "Mild": "Leve",
341
+ "Mild\u2013moderate": "Leve\u2013moderada",
342
+ "Moderate": "Moderada",
343
+ "Moderate\u2013severe": "Moderada\u2013severa",
344
+ "Severe": "Severa",
345
+ "Very mild yellowing": "Amarillamiento muy leve",
346
+ "Mild yellowing": "Amarillamiento leve",
347
+ "Moderate yellow": "Amarillo moderado",
348
+ "Yellow\u2013brown": "Amarillo\u2013marrón",
349
+ "Brown": "Marrón",
350
+ "Dark brown": "Marrón oscuro",
351
+ "None": "Ninguna",
352
+ "Peripheral spokes only": "Solo radios periféricos",
353
+ "Mild peripheral involvement": "Compromiso periférico leve",
354
+ "Moderate spokes approaching center": "Radios moderados acercándose al centro",
355
+ "Central involvement": "Compromiso central",
356
+ "Severe / dense central spokes": "Severa / radios centrales densos",
357
  },
358
+ "en": {},
359
  }
360
 
361
 
362
+ def locs_display(english_text: str) -> str:
363
+ """Translate a LOCS field label or option to the active UI language."""
364
+ lang = _get_lang()
365
+ if lang == "en":
366
+ return english_text
367
+ return _LOCS_DISPLAY.get(lang, {}).get(english_text, english_text)
interface/main.py CHANGED
@@ -7,7 +7,7 @@ import math
7
  import config
8
  import database as db
9
  import utils
10
- import i18n
11
  from services import session_manager as sm
12
  from services.whisper_service import load_whisper_model
13
  from components.uploader import render_uploader
@@ -16,7 +16,7 @@ from components.labeler import render_labeler
16
  from components.recorder import render_recorder
17
  from components.downloader import render_downloader
18
  from components.image_protection import inject_image_protection
19
- from services.auth_service import require_auth, render_logout_button
20
 
21
  # ── PAGE CONFIG ──────────────────────────────────────────────────────────────
22
  st.set_page_config(
@@ -31,8 +31,10 @@ if not require_auth():
31
  # ── IMAGE PROTECTION (prevent download / right-click save) ───────────────────
32
  inject_image_protection()
33
 
34
- # Set UI language from config
35
- i18n.ACTIVE_LANGUAGE = config.UI_LANGUAGE
 
 
36
  # ── SESSION INITIALIZATION ──────────────────────────────────────────────────
37
  sm.init_session()
38
 
@@ -40,15 +42,13 @@ sm.init_session()
40
  if sm.check_session_timeout(config.SESSION_TIMEOUT_MINUTES):
41
  if sm.has_undownloaded_data():
42
  summary = sm.get_session_data_summary()
43
- st.warning(
44
- f"⏰ Sesión expirada por inactividad ({config.SESSION_TIMEOUT_MINUTES} min). "
45
- f"Se eliminaron **{summary['total']}** imágenes, "
46
- f"**{summary['labeled']}** etiquetadas, "
47
- f"**{summary['with_audio']}** con audio. "
48
- "Descargue sus datos antes de que expire la sesión la próxima vez."
49
- )
50
  else:
51
- st.info("⏰ Sesión expirada por inactividad. Se inició una nueva sesión.")
52
  sm.clear_session()
53
  sm.init_session()
54
 
@@ -57,19 +57,33 @@ utils.setup_env()
57
  try:
58
  active_db_type = db.init_db()
59
  except Exception as e:
60
- st.error(f"Error crítico de base de datos: {e}")
61
  st.stop()
62
 
63
  # ── SIDEBAR ──────────────────────────────────────────────────────────────────
64
  with st.sidebar:
65
- st.title("⚙️ Configuración")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- # Logout button (only visible if auth is active)
68
- render_logout_button()
69
 
70
  # Doctor name
71
  doctor = st.text_input(
72
- "👨‍⚕️ Nombre del Doctor",
73
  value=st.session_state.get("doctor_name", ""),
74
  )
75
  if doctor != st.session_state.get("doctor_name", ""):
@@ -80,7 +94,7 @@ with st.sidebar:
80
  # Whisper language (select FIRST so models can be filtered)
81
  lang_keys = list(config.WHISPER_LANGUAGE_OPTIONS.keys())
82
  lang_labels = list(config.WHISPER_LANGUAGE_OPTIONS.values())
83
- selected_lang_display = st.selectbox("Idioma de dictado", lang_labels, index=0)
84
  selected_language = lang_keys[lang_labels.index(selected_lang_display)]
85
 
86
  # Whisper model — filtered by selected language
@@ -96,7 +110,7 @@ with st.sidebar:
96
  m for m in config.WHISPER_MODEL_OPTIONS if not m.endswith(".en")
97
  ]
98
  selected_model = st.selectbox(
99
- "Modelo Whisper",
100
  available_models,
101
  index=0,
102
  )
@@ -105,21 +119,21 @@ with st.sidebar:
105
 
106
  # ── Session progress ──────────────────────────────────────────���──────────
107
  labeled, total = sm.get_labeling_progress()
108
- st.subheader("📊 Sesión Actual")
109
- st.caption(f"Base de datos: **{active_db_type}**")
110
  if total > 0:
111
- st.write(f"Imágenes cargadas: **{total}**")
112
- st.write(f"Etiquetadas: **{labeled}** / {total}")
113
  st.progress(labeled / total if total > 0 else 0)
114
  else:
115
- st.info("No hay imágenes en la sesión.")
116
 
117
  st.divider()
118
 
119
  # ── Annotation History (from DB) — Grouped by image ────────────────────────
120
- st.subheader("🗄️ Historial")
121
  search_input = st.text_input(
122
- "🔍 Buscar por imagen",
123
  value=st.session_state.get("history_search", ""),
124
  )
125
  if search_input != st.session_state.get("history_search", ""):
@@ -138,11 +152,11 @@ with st.sidebar:
138
  ITEMS_PER_PAGE,
139
  )
140
  except Exception as e:
141
- st.error(f"Error al obtener historial: {e}")
142
  history_groups, total_items = [], 0
143
 
144
  if not history_groups:
145
- st.caption("Sin registros.")
146
  else:
147
  for group in history_groups:
148
  fname = group["imageFilename"]
@@ -166,12 +180,12 @@ with st.sidebar:
166
  st.markdown(
167
  f"**#{i + 1}** — `{ts}`"
168
  )
169
- st.write(f"**Etiqueta:** {label}")
170
- st.write(f"**Doctor:** {doctor}")
171
  if preview:
172
  st.caption(f"📝 {preview}")
173
  else:
174
- st.caption("_Sin transcripción_")
175
 
176
  if i < n_annotations - 1:
177
  st.divider()
@@ -203,37 +217,36 @@ with st.sidebar:
203
  summary = sm.get_session_data_summary()
204
  remaining = sm.get_remaining_timeout_minutes(config.SESSION_TIMEOUT_MINUTES)
205
  st.warning(
206
- f"⚠️ Datos no descargados: **{summary['total']}** imágenes, "
207
- f"**{summary['labeled']}** etiquetadas, "
208
- f"**{summary['with_audio']}** con audio."
209
  )
210
- st.caption(f"⏱️ Timeout en ~{remaining:.0f} min")
211
 
212
  # Two-step confirmation to prevent accidental data loss
213
  if not st.session_state.get("confirm_end_session", False):
214
  if st.button(
215
- "🗑️ Finalizar Sesión",
216
  type="secondary",
217
  use_container_width=True,
218
  ):
219
  st.session_state.confirm_end_session = True
220
  st.rerun()
221
  else:
222
- st.error(
223
- "¿Está seguro? **Todos los datos se eliminarán permanentemente.**"
224
- )
225
  cc1, cc2 = st.columns(2)
226
  with cc1:
227
- if st.button("✅ Sí, eliminar", type="primary", use_container_width=True):
228
  sm.clear_session()
 
229
  st.rerun()
230
  with cc2:
231
- if st.button("❌ Cancelar", use_container_width=True):
232
  st.session_state.confirm_end_session = False
233
  st.rerun()
234
 
235
  # ── LOAD WHISPER MODEL ───────────────────────────────────────────────────────
236
- with st.spinner(f"Cargando modelo Whisper '{selected_model}'..."):
237
  model = load_whisper_model(selected_model)
238
  # ── BROWSER CLOSE GUARD (beforeunload) ───────────────────────────────────
239
  # Warn the user when they try to close/reload the tab with data in session.
@@ -251,7 +264,7 @@ if sm.has_undownloaded_data() and not st.session_state.get("session_downloaded",
251
  )
252
  # ── MAIN CONTENT ─────────────────────────────────────────────────────────────
253
  st.title(f"{config.APP_ICON} {config.APP_TITLE}")
254
- st.caption(config.APP_SUBTITLE)
255
 
256
  # ── IMAGE UPLOAD ─────────────────────────────────────────────────────────────
257
  new_count = render_uploader()
@@ -260,7 +273,7 @@ if new_count > 0:
260
 
261
  # ── WORKSPACE (requires at least one image) ──────────────────────────────────
262
  if not st.session_state.image_order:
263
- st.info("📤 Suba imágenes médicas para comenzar el etiquetado.")
264
  st.stop()
265
 
266
  # ── IMAGE GALLERY ────────────────────────────────────────────────────────────
@@ -296,7 +309,7 @@ st.image(
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()
@@ -304,17 +317,17 @@ with c1:
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()
 
7
  import config
8
  import database as db
9
  import utils
10
+ from i18n import t, label_display, SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE
11
  from services import session_manager as sm
12
  from services.whisper_service import load_whisper_model
13
  from components.uploader import render_uploader
 
16
  from components.recorder import render_recorder
17
  from components.downloader import render_downloader
18
  from components.image_protection import inject_image_protection
19
+ from services.auth_service import require_auth, do_logout
20
 
21
  # ── PAGE CONFIG ──────────────────────────────────────────────────────────────
22
  st.set_page_config(
 
31
  # ── IMAGE PROTECTION (prevent download / right-click save) ───────────────────
32
  inject_image_protection()
33
 
34
+ # ── UI LANGUAGE (initialize before anything renders) ─────────────────────────
35
+ if "ui_language" not in st.session_state:
36
+ st.session_state.ui_language = DEFAULT_LANGUAGE
37
+
38
  # ── SESSION INITIALIZATION ──────────────────────────────────────────────────
39
  sm.init_session()
40
 
 
42
  if sm.check_session_timeout(config.SESSION_TIMEOUT_MINUTES):
43
  if sm.has_undownloaded_data():
44
  summary = sm.get_session_data_summary()
45
+ st.warning(t("session_expired",
46
+ minutes=config.SESSION_TIMEOUT_MINUTES,
47
+ total=summary['total'],
48
+ labeled=summary['labeled'],
49
+ with_audio=summary['with_audio']))
 
 
50
  else:
51
+ st.info(t("session_expired_clean"))
52
  sm.clear_session()
53
  sm.init_session()
54
 
 
57
  try:
58
  active_db_type = db.init_db()
59
  except Exception as e:
60
+ st.error(t("db_error", error=str(e)))
61
  st.stop()
62
 
63
  # ── SIDEBAR ──────────────────────────────────────────────────────────────────
64
  with st.sidebar:
65
+ st.title(t("settings"))
66
+
67
+ # Language selector
68
+ lang_codes = list(SUPPORTED_LANGUAGES.keys())
69
+ lang_names = list(SUPPORTED_LANGUAGES.values())
70
+ current_lang_idx = lang_codes.index(st.session_state.ui_language) if st.session_state.ui_language in lang_codes else 0
71
+ selected_ui_lang = st.selectbox(
72
+ t("ui_language"),
73
+ lang_names,
74
+ index=current_lang_idx,
75
+ key="_ui_language_selector",
76
+ )
77
+ new_lang_code = lang_codes[lang_names.index(selected_ui_lang)]
78
+ if new_lang_code != st.session_state.ui_language:
79
+ st.session_state.ui_language = new_lang_code
80
+ st.rerun()
81
 
82
+ st.divider()
 
83
 
84
  # Doctor name
85
  doctor = st.text_input(
86
+ t("doctor_name"),
87
  value=st.session_state.get("doctor_name", ""),
88
  )
89
  if doctor != st.session_state.get("doctor_name", ""):
 
94
  # Whisper language (select FIRST so models can be filtered)
95
  lang_keys = list(config.WHISPER_LANGUAGE_OPTIONS.keys())
96
  lang_labels = list(config.WHISPER_LANGUAGE_OPTIONS.values())
97
+ selected_lang_display = st.selectbox(t("dictation_language"), lang_labels, index=0)
98
  selected_language = lang_keys[lang_labels.index(selected_lang_display)]
99
 
100
  # Whisper model — filtered by selected language
 
110
  m for m in config.WHISPER_MODEL_OPTIONS if not m.endswith(".en")
111
  ]
112
  selected_model = st.selectbox(
113
+ t("whisper_model"),
114
  available_models,
115
  index=0,
116
  )
 
119
 
120
  # ── Session progress ──────────────────────────────────────────���──────────
121
  labeled, total = sm.get_labeling_progress()
122
+ st.subheader(t("current_session"))
123
+ st.caption(f"{t('db_type')}: **{active_db_type}**")
124
  if total > 0:
125
+ st.write(f"{t('images_loaded')}: **{total}**")
126
+ st.write(f"{t('labeled_count')}: **{labeled}** / {total}")
127
  st.progress(labeled / total if total > 0 else 0)
128
  else:
129
+ st.info(t("no_images"))
130
 
131
  st.divider()
132
 
133
  # ── Annotation History (from DB) — Grouped by image ────────────────────────
134
+ st.subheader(t("history"))
135
  search_input = st.text_input(
136
+ t("search_image"),
137
  value=st.session_state.get("history_search", ""),
138
  )
139
  if search_input != st.session_state.get("history_search", ""):
 
152
  ITEMS_PER_PAGE,
153
  )
154
  except Exception as e:
155
+ st.error(t("history_error", error=str(e)))
156
  history_groups, total_items = [], 0
157
 
158
  if not history_groups:
159
+ st.caption(t("no_records"))
160
  else:
161
  for group in history_groups:
162
  fname = group["imageFilename"]
 
180
  st.markdown(
181
  f"**#{i + 1}** — `{ts}`"
182
  )
183
+ st.write(f"**{t('label_header')}:** {label_display(label) if label != '—' else label}")
184
+ st.write(f"**{t('doctor_header')}:** {doctor}")
185
  if preview:
186
  st.caption(f"📝 {preview}")
187
  else:
188
+ st.caption(f"_{t('no_transcription')}_")
189
 
190
  if i < n_annotations - 1:
191
  st.divider()
 
217
  summary = sm.get_session_data_summary()
218
  remaining = sm.get_remaining_timeout_minutes(config.SESSION_TIMEOUT_MINUTES)
219
  st.warning(
220
+ f"{t('undownloaded_warning')}: **{summary['total']}** {t('images_metric')}, "
221
+ f"**{summary['labeled']}** {t('labeled_count')}, "
222
+ f"**{summary['with_audio']}** {t('with_audio')}."
223
  )
224
+ st.caption(f"{t('timeout_in')} ~{remaining:.0f} min")
225
 
226
  # Two-step confirmation to prevent accidental data loss
227
  if not st.session_state.get("confirm_end_session", False):
228
  if st.button(
229
+ t("logout"),
230
  type="secondary",
231
  use_container_width=True,
232
  ):
233
  st.session_state.confirm_end_session = True
234
  st.rerun()
235
  else:
236
+ st.error(t("confirm_delete"))
 
 
237
  cc1, cc2 = st.columns(2)
238
  with cc1:
239
+ if st.button(t("yes_delete"), type="primary", use_container_width=True):
240
  sm.clear_session()
241
+ do_logout()
242
  st.rerun()
243
  with cc2:
244
+ if st.button(t("cancel"), use_container_width=True):
245
  st.session_state.confirm_end_session = False
246
  st.rerun()
247
 
248
  # ── LOAD WHISPER MODEL ───────────────────────────────────────────────────────
249
+ with st.spinner(t("loading_whisper", model=selected_model)):
250
  model = load_whisper_model(selected_model)
251
  # ── BROWSER CLOSE GUARD (beforeunload) ───────────────────────────────────
252
  # Warn the user when they try to close/reload the tab with data in session.
 
264
  )
265
  # ── MAIN CONTENT ─────────────────────────────────────────────────────────────
266
  st.title(f"{config.APP_ICON} {config.APP_TITLE}")
267
+ st.caption(t("app_subtitle"))
268
 
269
  # ── IMAGE UPLOAD ─────────────────────────────────────────────────────────────
270
  new_count = render_uploader()
 
273
 
274
  # ── WORKSPACE (requires at least one image) ──────────────────────────────────
275
  if not st.session_state.image_order:
276
+ st.info(t("upload_prompt"))
277
  st.stop()
278
 
279
  # ── IMAGE GALLERY ────────────────────────────────────────────────────────────
 
309
 
310
  c1, c2, c3 = st.columns([1, 2, 1])
311
  with c1:
312
+ if st.button(t("previous"), disabled=(len(order) <= 1)):
313
  new_idx = (current_idx - 1) % len(order)
314
  st.session_state.current_image_id = order[new_idx]
315
  sm.update_activity()
 
317
  with c2:
318
  st.markdown(
319
  f"<div style='text-align:center'><b>{current_img['filename']}</b>"
320
+ f"<br>({t('image_counter', current=current_idx + 1, total=len(order))})</div>",
321
  unsafe_allow_html=True,
322
  )
323
  with c3:
324
+ if st.button(t("next"), disabled=(len(order) <= 1)):
325
  new_idx = (current_idx + 1) % len(order)
326
  st.session_state.current_image_id = order[new_idx]
327
  sm.update_activity()
328
  st.rerun()
329
 
330
+ if st.button(t("delete_image"), key="delete_img"):
331
  sm.remove_image(current_id)
332
  sm.update_activity()
333
  st.rerun()
interface/services/auth_service.py CHANGED
@@ -16,6 +16,8 @@ try:
16
  except ImportError:
17
  AUTH_AVAILABLE = False
18
 
 
 
19
 
20
  # ── Default credentials ──────────────────────────────────────────────────────
21
  # In production, load these from a secure YAML/env. For now, hardcoded demo.
@@ -81,11 +83,11 @@ def require_auth() -> bool:
81
  return True
82
 
83
  elif st.session_state.get("authentication_status") is False:
84
- st.error("❌ Usuario o contraseña incorrectos.")
85
  return False
86
 
87
  else:
88
- st.info("👨‍⚕️ Inicie sesión para acceder al sistema de etiquetado.")
89
  return False
90
 
91
 
@@ -96,4 +98,18 @@ def render_logout_button():
96
 
97
  if st.session_state.get("authentication_status"):
98
  authenticator = _get_authenticator()
99
- authenticator.logout("🚪 Cerrar sesión", location="sidebar")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  except ImportError:
17
  AUTH_AVAILABLE = False
18
 
19
+ from i18n import t
20
+
21
 
22
  # ── Default credentials ──────────────────────────────────────────────────────
23
  # In production, load these from a secure YAML/env. For now, hardcoded demo.
 
83
  return True
84
 
85
  elif st.session_state.get("authentication_status") is False:
86
+ st.error(t("login_error"))
87
  return False
88
 
89
  else:
90
+ st.info(t("login_prompt"))
91
  return False
92
 
93
 
 
98
 
99
  if st.session_state.get("authentication_status"):
100
  authenticator = _get_authenticator()
101
+ authenticator.logout(t("logout"), location="sidebar")
102
+
103
+
104
+ def do_logout():
105
+ """Programmatically log out the current user."""
106
+ if not AUTH_AVAILABLE:
107
+ return
108
+ try:
109
+ authenticator = _get_authenticator()
110
+ authenticator.logout(location="unrendered")
111
+ except Exception:
112
+ # Fallback: clear auth keys manually
113
+ for key in ("authentication_status", "username", "name", "logout"):
114
+ st.session_state.pop(key, None)
115
+ st.session_state.pop("authenticator", None)