Streamlit: Last Annotation Rollback After Selectbox Change or Delete Action

Environment

Python 3.11
Streamlit 1.31.1
Windows 10
Running locally

I’m not a professional developer (I’m a GIS specialist), so I may have missed some best practices.

Problem description

I’m building an image annotation interface in Streamlit to automatically map objects.
The user clicks on an image (using streamlit_image_coordinates), which adds an annotation to a JSON file with the following structure:


{
  "image.jpg": [
    {
      "uuid": "460550d2-d6ac-4e27-82a5-06d1763ede80",
      "x": 1270,
      "y": 1969,
      "label": "porte",
      "type_utilisation": "entrée",
      "cible": "cartographie",
      "angle_ajuste": 267.12
    }
  ],
  "image2.jpg": [
    {
      "uuid": "0da2f466-72be-4bc1-aecd-ff4b4d67c557",
      "x": 2378,
      "y": 1730,
      "label": "porte",
      "type_utilisation": "entrée",
      "cible": "cartographie",
      "angle_ajuste": 289.48
    }
  ]
}

Each annotation can be selected through a selectbox, and then edited or deleted. A button lets the user save all changes back to the JSON file.

Bug behavior

When I add multiple annotations in the same session, the last annotation added behaves inconsistently:
Example:

I add 5 annotations:

The first 4 work fine (edit/delete OK)
The 5th one behaves weirdly:

If I modify or delete it, it looks like it works, but when a rerun happens (any action that triggers a rerun like selecting a value in a selectbox or changing a number in a number_input) seems to re-inject the previous value.), the original version reappears.

I can even see the annotation added to the JSON… and then removed again. (when i’m not using temp file like now)

If I add a 6th annotation:

The 5th becomes stable
The 6th inherits the bug

If I refresh the page (F5), everything becomes stable and works as expected.

What I’ve tried so far
Managing everything with st.session_state
Avoiding session_state completely and working directly with a temporary JSON
Using a separate “temporary” JSON file, then writing it back to the main one
Saving on every interaction, or only via a dedicated save button
Manual UUID/flag management, unique keys, etc.

The bug persists no matter what I try: the last added annotation always gets rolled back on rerun, as if a “floating” version is being re-injected into the app state.

If anyone has faced something similar or has ideas to work around this rerun/session state/JSON write issue, I’d be grateful!

I’m not sure how to edit my original post, so here’s an update: I initially misread the version numbers —

I’m actually using :

Python 3.13.2 (not 3.11)
Streamlit 1.44.1 (not 1.31.1)

Can you share your code? How have you implemented the selection, edit, and save actions?

I went back to a simpler approach: everything now goes through st.session_state, with direct writing to the JSON (to create an annotation) and edit/save buttons used when needed.

I have a function that writes directly to the JSON file when it’s called — that is, when the user clicks on the photo.


def sauvegarder_annotations(annotations_files):
    try:
        with open(annotations_file, "w", encoding="utf-8") as f:
            json.dump(st.session_state.annotations, f, ensure_ascii=False, indent=2)
    except Exception as e:
        st.error(f"❌ Erreur lors de la sauvegarde : {e}")

coords = streamlit_image_coordinates(merged_img, key="click_key", width=800)
# Sauvegarde annotation
if coords:
    x_orig = int(coords["x"] / ratio)
    y_orig = int(coords["y"] / ratio)
    angle_ajuste = calculer_angle_porte(x_orig, full_width, direction, fov=fov_user)
    if (x_orig, y_orig) not in [(a["x"], a["y"]) for a in st.session_state.annotations[image_name]]:
        st.session_state.annotations[image_name].append({
            "uuid": str(uuid.uuid4()),  # Génère un UUID unique
            "x": x_orig, "y": y_orig,
            "label": label,
            "type_utilisation": type_utilisation,
            "cible": cible,
            "angle_ajuste": angle_ajuste
        })
        # Sauvegarder dans le fichier JSON
        sauvegarder_annotations(st.session_state.annotations)
        st.rerun()

I also have a selectbox that allows the user to choose an annotation to modify or delete, along with associated buttons to confirm the action and save it to the JSON (using the same function as the one triggered by the click).

    with col2:
        st.markdown("### 📌 Coordonnées enregistrées")
        if not df_annotations.empty:
            st.dataframe(df_annotations, use_container_width=True, hide_index=True)
            # Liste des index avec None pour "aucune sélection"
            index_options = [None] + list(df_annotations['index'])

            # Affichage formaté : "1 - label - type_utilisation"
            def format_option(idx):
                if idx is None:
                    return "Sélectionnez une annotation"
                row = df_annotations[df_annotations['index'] == idx].iloc[0]
                return f"{idx} - {row['label']} - {row['type_utilisation']}"
            
            selected_index = st.selectbox(
                "Sélectionner une annotation",
                options=index_options,
                format_func=lambda x: "Sélectionnez une annotation" if x is None else f"{x} - {df_annotations[df_annotations['index'] == x].iloc[0]['label']}",
                key="selectbox_index"
            )

            # Mise à jour de selected_annotation uniquement si besoin
            if selected_index is not None:
                selected_uuid = df_annotations[df_annotations['index'] == selected_index].iloc[0]['uuid']
                if st.session_state.get("selected_annotation") != selected_uuid:
                    st.session_state.selected_annotation = selected_uuid
                    st.rerun()
            else:
                if st.session_state.get("selected_annotation") is not None:
                    st.session_state.selected_annotation = None
                    st.rerun()
       
        # Utilise toujours l'UUID pour retrouver l'annotation à modifier/supprimer
        if st.session_state.selected_annotation is not None:
            ann_to_edit = next((ann for ann in st.session_state.annotations[image_name]
                                if ann["uuid"] == st.session_state.selected_annotation), None)
            if ann_to_edit:
                st.markdown("### Modifier l'annotation")
                cibles = list({c for details in config["annotations"].values() for c in details["cible"]})
                cible_edit = st.selectbox("Cible", cibles,
                                        index=cibles.index(ann_to_edit["cible"]),
                                        key="cible_selectbox_edit")

                filtered_labels = [label for label, details in config["annotations"].items()
                                if cible_edit in details["cible"]]
                label_edit = st.selectbox("Label", filtered_labels,
                                        index=filtered_labels.index(ann_to_edit["label"]),
                                        key="label_selectbox_edit")

                type_utilisation_options = config["annotations"][label_edit]["type_utilisation"]
                type_utilisation_edit = st.selectbox("Type d'utilisation",
                                                    type_utilisation_options,
                                                    index=type_utilisation_options.index(ann_to_edit["type_utilisation"]),
                                                    key="type_utilisation_edit")

                col1, col2 = st.columns(2)
                with col1:
                    if st.button("📝 Modifier", key=f"modify_{st.session_state.selected_annotation}"):
                        # Modification dans le fichier
                        ann_to_edit.update({
                            "cible": cible_edit,
                            "label": label_edit,
                            "type_utilisation": type_utilisation_edit
                        })
                        sauvegarder_annotations(st.session_state.annotations)
                        st.success("Modifications appliquées")
                        st.rerun()

                with col2:
                    if st.button("🗑️ Supprimer", key=f"delete_{st.session_state.selected_annotation}"):
                        # Trouve l'index de l'annotation à supprimer
                        index_to_delete = next(
                            (i for i, ann in enumerate(st.session_state.annotations[image_name])
                            if ann["uuid"] == st.session_state.selected_annotation),
                            None
                        )
                        # Supprime directement via del si trouvé
                        if index_to_delete is not None:
                            del st.session_state.annotations[image_name][index_to_delete]
                        
                        sauvegarder_annotations(st.session_state.annotations)
                        st.success("Annotation supprimée")
                        st.rerun()
        else:
            st.markdown("---")

Modification works fine, even for the most recently added annotation, but deletion doesn’t. If I refresh the page (F5), I can delete the annotation.

This block, which resets all annotations for an image, works regardless of when the annotation was made, even though the underlying logic is different.

    if st.button("🗑️ Réinitialiser l'image actuelle") and image_files:
        st.session_state.annotations[image_files[st.session_state.current_image_index]] = []
        sauvegarder_annotations(st.session_state.annotations)
        st.rerun()

I tried resetting streamlit_image_coordinates in case it was retaining the last click and reinjecting it during a rerun — but I removed that cleanup since it didn’t change anything (or maybe I wasn’t doing it correctly).

However, if I delete the last annotation, it rolls back. I can see in my JSON that the annotation disappears — and instantly comes back. It even gets assigned a new UUID at the moment of the rollback, and retains the value it had if I edited the annotation before deleting it.

If I add a new point, there’s no rollback on the previously “new annotation”, but the newly added point inherits the issue.

I tried many things, like having dedicated functions for deletion, etc., but nothing worked. So I wanted to start again with something simple before making the logic more complex.

I hope there’s enough code/context here to understand the issue, as it’s a bit complicated to share the full implementation for now.

Voici une traduction en anglais claire et fidèle de ton message :


Update and clarification observed today:

I noticed that after a rollback, the annotation’s value takes the current value from the creation selectboxes.
For example: I enter a “front door”, then I modify it to a “garage door”.
I change the values in the creation selectboxes to “wooden window” (which are independent from the modification selectboxes).
Now, if I delete the last added annotation, the rollback restores the value from the creation selectboxes, not the original annotation value as I expected — so my “front door” gets recreated, but the value is now “wooden window”.

Which might confirm that the last created annotation remains cached and comes back when the code is re-run (but only upon deletion).

It seems that the streamlit_image_coordinates function retains the last click in memory, and on rerun, it re-injects the previous click. This happens when deleting the last object, causing a rollback, but also when switching from one photo to another—the last click is automatically added to the next photo.

To fix this, I implemented a condition comparing the current click with the last one (stored in memory). If they are similar, the new point isn’t added, which prevents the rerun issue.
I believe the problem can be considered resolved—or at least, for now, it is resolved on my end.

1 Like