docs/adr/0002-annotation-round-trip.md
A single frozen Annotation type (evolved from LabelData) owns the in-memory
load/save round-trip and is used by the app on both boundaries, so the field set
has one owner instead of being re-encoded across _load_shape_json_obj,
ShapeDict, Shape.__init__, _shapes_from_dicts, and _shape_to_dict. The
on-disk codec (read_label_file / write_label_file in _label_file.py) stays
free of any PyQt5 import and continues to exchange plain-data ShapeDict, so the
persistence layer remains usable without a GUI.
Shape into the codec and delete ShapeDict (rejected):
Shape carries QtCore.QPointF points and render state, so making the codec
hold it drags PyQt5 into _label_file.py. Four headless example scripts
(examples/instance_segmentation/labelme2voc.py, labelme2coco.py,
examples/tutorial/export_json.py, draw_json.py) and the Qt-free
tests/unit/_label_file_test.py round-trip depend on that module staying
GUI-free. ShapeDict is retained on purpose as the Qt-free wire format between
the codec and Annotation.LabelFile lineage (rejected): LabelFile is
public (labelme.LabelFile), carries deprecated camelCase properties, and has
an incoherent save() that ignores its own state. Reusing it would entangle
this change with a public-API deprecation cycle. A frozen snapshot also fits
the model where the Qt widgets remain the source of truth during editing.
LabelFile is left untouched as a deprecated shim.Annotation the live aggregate root (deferred, not rejected): having
label_list, flag_list, and the canvas read and mutate through Annotation
would remove the existing canvas.shapes / label_list sync, but it needs a
Qt model/view migration and reroutes roughly seven mutation entry points. The
round-trip type defined here is the prerequisite for that move.ShapeDict, and the live Shape. This is intentional layering, not
duplication, because the field set now has a single owner.Shape <-> ShapeDict converters stay at the app boundary for now. A
later step may move them onto Annotation as private implementation, but never
into the Qt-free codec.ShapeDict or
pushing Shape into _label_file.py; doing so breaks the headless consumers
above.Shape is now Qt-freeThe rejection of "delete ShapeDict" above rested entirely on one premise:
Shape carried QtCore.QPointF points and Qt render state, so reusing it in the
codec would drag PyQt5 into _label_file.py. That premise no longer holds.
Shape has been refactored into a Qt-free @dataclass (_shape.py): points /
point_labels are numpy arrays, render state moved to _widgets/_shape_render.py
and _widgets/canvas.py, and the module imports no PyQt5. The in-progress drawing
shape is a separate QPointF _DraftShape in the canvas; committed shapes are the
Qt-free Shape.
Consequently the prohibition is lifted: collapsing ShapeDict into the Qt-free
Shape (so the codec exchanges one type) is now viable and no longer breaks the
headless consumers — they would import a numpy-only Shape. That collapse
(formerly "rejected") is now an unblocked but deferred follow-up; ShapeDict
is retained for now so this refactor stayed scoped to making Shape Qt-free.
Pushing Shape into _label_file.py remains inadvisable only for layering
reasons, not Qt ones.
LabelFile shim is removedThe second rejected option above kept the public LabelFile class "untouched as
a deprecated shim". It is now removed outright: nothing in the app or examples
consumed it once examples/ became self-contained, so the deprecated camelCase
properties and the incoherent save() are gone. read_label_file /
write_label_file and the LabelFileError hierarchy remain the only persistence
entry points, which is the model that option already endorsed.