Shapes (relayoutdata) of dcc.Graph() can be modified in code but doesn't get updated in the browser

We started learning/using Dash only couple weeks ago, now I’m panicked that we might have chosen the wrong technology for the little application we have in mind.

The idea was to exploit the Dash Image Annotation feature, but we want to let both our code and user action to manipulate the annotated polygon.

The basic workflow is this: (1) load an image, (2) load a polygon previously detected by AI (in the example code, an arbitrary triangle is used in place of the data file), (3) allow user to edit the polygon using dcc.Graph 's Mode Bar feature (4) upon user clicking a button, saved the modified polygon in a data file.

With the code below, we can make modification to the ‘shapes’ values, appending a new SVG path (see on_detect()) or modifying an existing SVG path (see on_polyrot()).

The modification takes, meaning I can see the change to relayoutdata is persistent. But the browser doesn’t modify the polygon. If the user goes on to edit the polygon, the app crashes.

Is there a bug in my program or we are assuming a behavior that’s just not supported (see line 56, making an Output to relayoutdata of a Graph object) or worst: we are mistaken to make a GUI project using a Dash which is designed for something else?

Thanks in advance!!!

# -*- coding: utf-8 -*-

from PIL import Image
import numpy as np

import plotly.express as px
from dash import Dash, dcc, html
import dash_bootstrap_components as dbc
from dash import Input, Output, State, callback

import mytools as mt

IMFAKE = Image.open(r"48011720240204_TIANCHAO2_20240204170634502.jpg")
WIM, HIM = IMFAKE.size

FIG_scan = px.imshow(np.array(IMFAKE))
FIG_scan.update_layout(dragmode="drawclosedpath")
GRA_scan = dcc.Graph(id='scan', figure=FIG_scan, 
                     config=dict(modeBarButtonsToAdd=["drawline",
                                                      "drawclosedpath",
                                                      "eraseshape",])
                     )
CARD_scan = dbc.Card(GRA_scan)
BUT_detect = html.Button('Detect', id='detect', n_clicks=0)
BUT_polytrans = html.Button('↗', id="polytrans")
BUT_polyrot = html.Button('🔄', id="polyrot")
DIV_result = html.Div(id='result', children='results')

CARD_controls = dbc.Card(dbc.CardBody([
    dbc.Row(BUT_detect, align='center',),
    dbc.Row(BUT_polyrot, align='center',),
    dbc.Row(BUT_polytrans, align='center',),
    ]))

app = Dash('Ann', external_stylesheets=[dbc.themes.SLATE])

app.layout = html.Div([
    dbc.Row([
        dbc.Col(CARD_scan),
        dbc.Col(CARD_controls, width=2),
        ]),
    DIV_result,
    ])

# Frame Controls
@callback(Output(DIV_result, 'children'),
          Input(GRA_scan, "relayoutData"),
          prevent_initial_call=True,
          )
def on_drawpoly(data):
    for key in data:
        if "shapes" in key:
            return f'{key}: {data[key]}'

# Annotation Controls
@callback(Output(GRA_scan, 'relayoutData', allow_duplicate=True),
          Input(BUT_polyrot, 'n_clicks'),
          State(GRA_scan, 'relayoutData'),
          prevent_initial_call=True,
          )
def on_polyrot(n, data):
    # STORE_state['data'] = data
    pdata = mt.svg2poly(data['shapes'][0]['path'])
    mt.polyrotate(pdata, 0.262, copy=False) # pi/12
    data['shapes'][0]['path'] = pdata.d()
    # print(pdata.d())
    return data

@callback(Output(GRA_scan, 'relayoutData'),
          Input(BUT_polyrot, 'n_clicks'),
          State(GRA_scan, 'relayoutData'),
          prevent_initial_call=True,
          )
def on_polytrans(n, data):
    # STORE_state['data'] = data
    pdata = mt.svg2poly(data['shapes'][0]['path'])
    mt.polytrans(pdata, 3+5j, copy=False) # pi/12
    data['shapes'][0]['path'] = pdata.d()
    # print(pdata.d())
    return data

@callback(Output(GRA_scan,'relayoutData'),
          Input(BUT_detect,'n_clicks'),
          State(GRA_scan, 'relayoutData'),
          prevent_initial_call=True,
          )
def on_detect(n, data):
    try:
        poly = mt.loaddetected('48011720240204_TIANCHAO2_20240204170634502.pkl')[0]
    except FileNotFoundError:
        poly = np.array([[0.5, 0.2],[0.4,0.23],[0.5,0.26]]) # random triangle
    if 'shapes' not in data:
        data['shapes'] = []
    data['shapes'].append(mt.newpath(mt.poly2path(poly).d()))
    return data

if __name__ == '__main__':
    app.run(debug=True)