Spaces:
Sleeping
Sleeping
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 +0 -0
- interface/components/downloader.py +209 -37
- interface/components/labeler.py +100 -38
- interface/components/recorder.py +5 -2
- interface/config.py +56 -4
- interface/database.py +19 -6
- interface/main.py +39 -38
- interface/services/export_service.py +19 -2
- interface/services/session_manager.py +2 -1
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 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
img = st.session_state.images.get(image_id)
|
| 19 |
if img is None:
|
|
|
|
| 20 |
return
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
else:
|
| 31 |
zip_bytes, zip_name = export_single_image(image_id)
|
| 32 |
-
st.download_button(
|
| 33 |
-
label=
|
| 34 |
data=zip_bytes,
|
| 35 |
file_name=zip_name,
|
| 36 |
mime="application/zip",
|
| 37 |
-
key=
|
| 38 |
use_container_width=True,
|
| 39 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
st.divider()
|
| 42 |
|
| 43 |
-
|
| 44 |
-
st.markdown("**Toda la sesión**")
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
zip_bytes, zip_name = export_full_session()
|
| 65 |
if st.download_button(
|
| 66 |
-
label="⬇️ Descargar
|
| 67 |
data=zip_bytes,
|
| 68 |
file_name=zip_name,
|
| 69 |
mime="application/zip",
|
| 70 |
-
key="
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 16 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 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
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
sm.update_activity()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
|
| 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 |
-
|
| 144 |
-
|
| 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 |
-
#
|
| 5 |
LABEL_OPTIONS = [
|
| 6 |
-
{"key": "
|
| 7 |
-
{"key": "
|
|
|
|
|
|
|
| 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 = ?,
|
|
|
|
| 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,
|
| 150 |
-
"
|
| 151 |
-
(
|
|
|
|
|
|
|
| 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 |
-
# ──
|
| 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 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 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 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
sm.update_activity()
|
| 318 |
st.rerun()
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
| 322 |
|
| 323 |
-
|
| 324 |
|
| 325 |
-
|
|
|
|
| 326 |
|
| 327 |
-
|
| 328 |
|
| 329 |
-
|
|
|
|
|
|
| 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", "
|
|
|
|
|
|
|
| 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",
|
|
|
|
|
|
|
| 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, #
|
|
|
|
| 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)
|