diff --git a/.github/tasks.md b/.github/tasks.md index 779f3665..f96df228 100644 --- a/.github/tasks.md +++ b/.github/tasks.md @@ -52,3 +52,46 @@ - [x] Name the image frame (50), FilterDistance overlay (95), and loader z-indices in `src/blobs.js`; loader sits above `"above_annotations"` overlays. - [x] Drop `resize_overlay_canvases()`: overlays carry `imgsz_class`, so rezoom already resizes them. Restore the FilterDistance resize call; `overlay_canvases` holds embedder canvases only. - [x] Lint, build, jest 521/521 (both builds), Chromium e2e 94/94. + +## Set class in (context menu) + +- [x] `set_counterparts_class()` in `src/index.js` + undo/redo (`set_counterparts_class` action). +- [x] Context menu item `Set class in` (only counterparts that can take the source class and differ). +- [x] Types (`index.d.ts`), `api_spec.md`, changelog. +- [x] Jest tests; lint, build, jest. + +## counterpart_class_meta_key config option + +- [x] `_get_counterpart_class_name` helper; override in `get_counterpart_class_changes` and `match_paste_class_id` (Copy to / Move to / paste). +- [x] Config default, `index.d.ts`, `api_spec.md`, changelog. +- [x] Jest tests; lint, build, jest. +- [x] Copies drop the `counterpart_class_meta_key` entry (like the link key). + +## Review fixes (REVIEW-feature-sync-class.md) + +- [x] #1 A copy/paste back into the source subtask ignores `counterpart_class_meta_key` (`use_meta_override` param). +- [x] #2 Counterpart actions' `annotation_id` is foreign: `action_owns_annotation_id` guards stamping, undo revert, fly-to boxes, `remove_recorded_events_for_annotation` and `find_stamp_before_last_action`. +- [x] Regression tests; docs; lint, build, jest. + +## Class confidence display + +- [x] Details menu lists non-zero class confidences (sorted; only when more than one is non-zero). +- [x] `confidence_card_classes` config (default 1): confidence card shows the top N non-zero classes. +- [x] Class pie shows the class name on slice hover. +- [x] Types, `api_spec.md`, changelog; tests; lint, build, jest; live check in browser. +- [x] Demo: mixed confidences + `confidence_card_classes: 2` in `demo/live_demo.html`. +- [x] Pie centre label: wrap only between words, shrink font to fit the circle. +- [x] Pie centre label: 8px floor, then truncate with an ellipsis. + +## hover_card_text config callback + +- [x] `hover_card_text(annotation, subtask_key) => string | string[] | null`: extra plain-text lines in the confidence card. +- [x] Called once per hovered annotation (cached), errors caught and logged, capped at 5 lines. +- [x] Card shows host text for every hovered spatial annotation; card toggle still wins. +- [x] Types, `api_spec.md`, changelog, demo; tests; lint, build, jest; live check. + +## PR review: linked-annotation demo + local landing page + +- [x] `demo/linked-subtasks.html`: Predictions (read-only) / Diff / GT with link meta key + counterpart class meta key; A/B/C direct-links case. +- [x] `demo/index.html` landing page listing every demo; `demo.js` prints the landing URL. +- [x] Lint, build; live check in browser. diff --git a/CHANGELOG.md b/CHANGELOG.md index b86ba2ed..3ca08c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented here. +## [0.32.0] - Oct 23rd, 2026 +#### Added +- **Set class in ``** context-menu item: sets an annotation's counterparts in another writable subtask to its class (matched by id, then name). Counterparts that can't take that class, or already have it, are left alone, and the item only shows when at least one would change. New `get_counterpart_class_changes()` and `set_counterparts_class()`, recorded as one undoable `set_counterparts_class` action on the target subtask. +- `counterpart_class_meta_key` config option (default `null`): an annotation's string value under this `annotation_meta` key names the class it gives its counterparts (Set class in) and copies (Copy to / Move to, paste), replacing the id/name match. Set class in leaves a counterpart alone when no class has that name. Copies drop the entry, and copies/pastes back into the source subtask ignore it. +- `Details` in the context menu lists every non-zero class confidence, highest first, when more than one class has one. +- `confidence_card_classes` config option (default `1`): the hover confidence card also lists up to N - 1 runner-up classes with non-zero confidence. +- `hover_card_text` config callback (default `null`): returns up to 5 plain-text lines that the hover confidence card shows for an annotation. Called once per hovered annotation and again after it changes. +- `demo/linked-subtasks.html`: demo of linked annotations across Diff, GT and read-only Predictions subtasks. `demo/index.html`: local landing page listing every demo. +- The class pie shows the hovered class's name in its centre. Long names break only between words, and the text shrinks to fit the circle, down to 8px, then ends in "…". + +#### Fixed +- `delete_counterparts` no longer stamps, reverts or drops history for an unrelated target annotation that happens to share the source annotation's id. ## [0.31.1] - Oct 22nd, 2026 #### Changed - Simplified ternary syntax into nullish coalescing in several locations diff --git a/README.md b/README.md index 5264ce5c..17729a56 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ for an example of how to integrate ULabel into a React project. ## Development -The recommended way to develop new features is to use the tool as if you were running the demo. For testing new API features, you can create a new HTML file in `demo/`. The server in `demo.js` runs a static server from `demo/` so it will be served at `localhost:8080/.html` automatically. +The recommended way to develop new features is to use the tool as if you were running the demo. For testing new API features, you can create a new HTML file in `demo/`. The server in `demo.js` runs a static server from `demo/` so it will be served at `localhost:8080/.html` automatically. `localhost:8080/` opens a landing page (`demo/index.html`) listing every demo; add new demos there too. ### Requirements diff --git a/api_spec.md b/api_spec.md index e3a8f0fe..a24f07e1 100644 --- a/api_spec.md +++ b/api_spec.md @@ -17,7 +17,7 @@ In a read-only subtask, undo and redo act on the only writable subtask, if there - Press `Escape` to exit brush/erase mode. - Press `Tab` to set the zoom to focus on the next annotation - Press `Shift+Tab` to set the zoom to focus on the previous annotation -- Right-click a hovered annotation (or its entry in the `AnnotationList` toolbox item) to open a context menu with `Change class`, `Copy to`, `Move to`, `Delete`, `Isolate` / `Show all`, and `Details`. `Change class` opens the class pie for that annotation; `Copy to` / `Move to` clone the annotation into another subtask at the same image coordinates (see [`copy_annotation`](#copy_annotationannotation_id-target_subtask_key-class_id-source_subtask_key)) -- a single eligible subtask is named on the item, several open a list to pick from, and `Move to` also deletes the source (one undoable action on the source subtask that also removes the copy and the target's history about it). Both also arm `ctrl+v` with the annotation, and when the target already has a counterpart of it (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) a confirm dialog asks before copying again. `Delete from` appears for every other writable subtask holding counterparts and deletes them there, reading `Delete from ` (or ` ()` in the list) when there is more than one; a bitmask also offers `Erase from`, which only subtracts its mask (see [`delete_counterparts`](#delete_counterpartsannotation_id-source_key-target_key-erase)). The view stays on the source subtask unless the class pie opens or [`paste_switch_to_target`](#paste_switch_to_target) is on; `Isolate` hides every other annotation in the subtask (see [`isolate_annotation`](#isolate_annotationannotation_id-subtask_key-redraw)) and reads `Show all` while that annotation is isolated; `Details` lists its id, class, spatial type, last editor/edit time, what deprecated it (deprecated annotations only), and `annotation_meta` entries. Read-only subtasks only offer `Copy to`, `Delete from` / `Erase from`, `Isolate`, and `Details`. A deprecated annotation (hoverable only under deprecated focus, see **Show Deprecated** in the `AnnotationList` toolbox item) offers `Restore`, `Isolate` / `Show all`, and `Details`; `Restore` appears only for annotations deleted by a human and nothing else, on writable subtasks, and not for a bitmask erased to nothing (see [`restore_annotation`](#restore_annotationannotation_id)). Press `Escape` or click anywhere to close it. A right-click while drawing a polyline still finishes the polyline and opens no menu. +- Right-click a hovered annotation (or its entry in the `AnnotationList` toolbox item) to open a context menu with `Change class`, `Copy to`, `Move to`, `Delete`, `Isolate` / `Show all`, and `Details`. `Change class` opens the class pie for that annotation; `Copy to` / `Move to` clone the annotation into another subtask at the same image coordinates (see [`copy_annotation`](#copy_annotationannotation_id-target_subtask_key-class_id-source_subtask_key)) -- a single eligible subtask is named on the item, several open a list to pick from, and `Move to` also deletes the source (one undoable action on the source subtask that also removes the copy and the target's history about it). Both also arm `ctrl+v` with the annotation, and when the target already has a counterpart of it (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) a confirm dialog asks before copying again. `Delete from` appears for every other writable subtask holding counterparts and deletes them there, reading `Delete from ` (or ` ()` in the list) when there is more than one; a bitmask also offers `Erase from`, which only subtracts its mask (see [`delete_counterparts`](#delete_counterpartsannotation_id-source_key-target_key-erase)). `Set class in` appears for every other writable subtask holding counterparts that can take the annotation's class (matched by id, else by name) and don't already have it, and sets them to it (see [`set_counterparts_class`](#set_counterparts_classannotation_id-source_key-target_key)). The view stays on the source subtask unless the class pie opens or [`paste_switch_to_target`](#paste_switch_to_target) is on; `Isolate` hides every other annotation in the subtask (see [`isolate_annotation`](#isolate_annotationannotation_id-subtask_key-redraw)) and reads `Show all` while that annotation is isolated; `Details` lists its id, class, every non-zero class confidence (highest first; only when more than one class has one), spatial type, last editor/edit time, what deprecated it (deprecated annotations only), and `annotation_meta` entries. Read-only subtasks only offer `Copy to`, `Set class in`, `Delete from` / `Erase from`, `Isolate`, and `Details`. A deprecated annotation (hoverable only under deprecated focus, see **Show Deprecated** in the `AnnotationList` toolbox item) offers `Restore`, `Isolate` / `Show all`, and `Details`; `Restore` appears only for annotations deleted by a human and nothing else, on writable subtasks, and not for a bitmask erased to nothing (see [`restore_annotation`](#restore_annotationannotation_id)). Press `Escape` or click anywhere to close it. A right-click while drawing a polyline still finishes the polyline and opens no menu. - Press `Escape` to close an open class pie or context menu. - `ctrl+c` / `cmd+c` copies the hovered annotation, `ctrl+x` / `cmd+x` cuts it (deletes it after copying; not in read-only subtasks), and `ctrl+v` / `cmd+v` pastes into the current subtask (see [`paste_annotation_from_clipboard`](#paste_annotation_from_clipboardenvelope)). The copy is placed on the system clipboard as JSON, so it can be pasted into another ULabel instance showing an image of the same dimensions; a paste back into the subtask it was copied from keeps its class; the first paste into another subtask keeps the coordinates, and every repeat paste into the same subtask (or any paste back into the source) is offset by a further 20 pixels. These shortcuts are not remappable and are ignored while a text field has focus. @@ -90,6 +90,9 @@ class ULabel({ paste_switch_to_target: boolean, annotation_link_meta_key: string | null, hide_copy_to_linked: boolean, + counterpart_class_meta_key: string | null, + confidence_card_classes: number, + hover_card_text: ((annotation: ULabelAnnotation, subtask_key: string) => string | string[] | null) | null, set_brush_overlap_none_keybind: string, set_brush_overlap_exclude_keybind: string, set_brush_overlap_overwrite_keybind: string, @@ -677,7 +680,7 @@ When `true` (the default), a plain left-drag that starts inside a spatial annota When `true` (the default), a context-menu `Copy to` / `Move to` or a `ctrl+v` paste whose source class has no counterpart in the target subtask (by id, then by name) opens the class pie on the pasted annotation when several classes could take it. Set to `false` to never open the pie: the copy silently takes the target's active class (else its first compatible class). Useful when the source subtask's classes (e.g. `True Positive` / `False Negative`) can never match the target's and the host keeps the target's active class in sync with its own class picker. ### `paste_switch_to_target` -When `true`, a context-menu `Copy to` / `Move to` / `Delete from` / `Erase from` makes the target the current subtask afterwards (firing [`on_subtask_change`](#on_subtask_change)), whether or not the class pie opens; an undo/redo from a read-only subtask likewise switches to the writable subtask it acts on. Default is `false`: the view only switches when the class pie opens on the copy. Toggle at runtime with [`set_paste_switch_to_target`](#set_paste_switch_to_targetenabled). `ctrl+v` pastes into the current subtask and is unaffected. +When `true`, a context-menu `Copy to` / `Move to` / `Set class in` / `Delete from` / `Erase from` makes the target the current subtask afterwards (firing [`on_subtask_change`](#on_subtask_change)), whether or not the class pie opens; an undo/redo from a read-only subtask likewise switches to the writable subtask it acts on. Default is `false`: the view only switches when the class pie opens on the copy. Toggle at runtime with [`set_paste_switch_to_target`](#set_paste_switch_to_targetenabled). `ctrl+v` pastes into the current subtask and is unaffected. ### `annotation_link_meta_key` An `annotation_meta` key whose value, a string or an array of strings, links annotations across subtasks: two annotations are counterparts when they share any value (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)). Every copy and paste drops this entry from the copy's `annotation_meta`, so a copy never claims its source's identity. Default is `null` (only `copied_from` links). @@ -685,6 +688,28 @@ An `annotation_meta` key whose value, a string or an array of strings, links ann ### `hide_copy_to_linked` When `true`, the context menu leaves out `Copy to` / `Move to` for every subtask that already holds a counterpart of the annotation (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)), instead of asking before copying again. `ctrl+v` is unaffected. Default is `false`. +### `counterpart_class_meta_key` +An `annotation_meta` key whose value, a non-empty string, names the class an annotation passes on to other subtasks. When the source of a `Set class in` (see [`get_counterpart_class_changes`](#get_counterpart_class_changesannotation_id-source_key-target_key)), `Copy to` / `Move to`, or paste has it, the target class is the compatible class with that name, replacing the usual match on the source class's id and then name. If the target has no such class, `Set class in` leaves the counterpart alone (and doesn't appear when no counterpart would change), while a copy falls back as for any unmatched class (see [`paste_class_choice`](#paste_class_choice)). Annotations without the key use the usual match, as does a copy or paste back into the annotation's own subtask, so a duplicate keeps its class. Every copy and paste drops this entry from the copy's `annotation_meta`. Useful when the source subtask's classes (e.g. `TP` / `FP` / `FN`) never exist in the target and the host knows the right target class per annotation. Default is `null`. + +### `confidence_card_classes` +How many classes the confidence card shown on hover lists. The card always shows the annotation's top class and its confidence; values above `1` add up to `confidence_card_classes - 1` more classes below it, highest confidence first, skipping classes with a confidence of `0`. Default is `1` (top class only). The `Details` context-menu panel always lists every non-zero confidence when more than one class has one. + +### `hover_card_text` +Callback that adds host-supplied lines to the confidence card shown on hover, below the class confidences. It receives the hovered annotation (the full object, including `annotation_meta`) and its subtask key, and returns a string (one line), an array of strings (one line each), or `null` for no extra text. Default is `null`. + +- Lines are shown as plain text, never HTML, and at most 5 are shown. +- It is called once when an annotation becomes hovered, and again after any action or `set_annotations` changes it, not on every mouse move. +- It must be synchronous; prefetch any data it needs. Treat `annotation` as read-only. +- If it throws, the error is logged and the card shows no extra text. +- The card's toolbox toggle still hides the whole card. + +```javascript +hover_card_text: (annotation, subtask_key) => { + const pred = annotation.annotation_meta?.pred_class_name; + return pred ? [`Prediction: ${pred}`] : null; +}, +``` + ### `set_brush_overlap_none_keybind` Keybind to set the brush overlap mode to `none`. Default is `shift+n`. @@ -922,12 +947,20 @@ Sets the zoom to focus on the provided annotation, and switches to its subtask i ### `find_counterparts(annotation_id, source_key, target_key)` -*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Ids of the spatial annotations in `target_key` linked to `annotation_id` in `source_key`: copies of it, the annotation it was copied from (both via `annotation_meta.copied_from`), and annotations sharing a value under [`annotation_link_meta_key`](#annotation_link_meta_key). Human-deleted annotations are skipped; filter-hidden ones count. Empty when `source_key === target_key`. The context menu uses this for the copy confirm and for `Delete from` / `Erase from`. +*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Ids of the spatial annotations in `target_key` linked to `annotation_id` in `source_key`: copies of it, the annotation it was copied from (both via `annotation_meta.copied_from`), and annotations sharing a value under [`annotation_link_meta_key`](#annotation_link_meta_key). Human-deleted annotations are skipped; filter-hidden ones count. Empty when `source_key === target_key`. The context menu uses this for the copy confirm and for `Set class in` / `Delete from` / `Erase from`. ### `delete_counterparts(annotation_id, source_key, target_key, erase?)` *(annotation_id: string, source_key: string, target_key: string, erase?: boolean | null) => string[]* -- Removes the counterparts (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) from `target_key`, which must not be read-only; the source may be. With `erase` (default: whether the source is a bitmask; only a bitmask source can erase), the source mask is subtracted from its bitmask counterparts (a mask erased to nothing is deleted) and other counterparts are left alone; otherwise every counterpart is deleted. Recorded as one `delete_counterparts` action on the target subtask's stream (undo there restores every mask and deletion) and reported to [`on_annotation_change`](#on_annotation_change) with the target as `subtask_key` and the changed counterparts as `affected`. Returns the changed ids; nothing is recorded when none changed. When something changed and [`paste_switch_to_target`](#paste_switch_to_target) is on, the target becomes the current subtask. +### `get_counterpart_class_changes(annotation_id, source_key, target_key)` + +*(annotation_id: string, source_key: string, target_key: string) => Record* -- The counterparts (see [`find_counterparts`](#find_counterpartsannotation_id-source_key-target_key)) in `target_key` that can take the source annotation's class and don't already have it, each mapped to the class id it would take. The class is matched by id, else by name (the first compatible class with that name); a [`counterpart_class_meta_key`](#counterpart_class_meta_key) value on the source replaces both, matching that name only. Counterparts with no compatible match are left out. The context menu shows `Set class in` for a subtask only when this is non-empty. + +### `set_counterparts_class(annotation_id, source_key, target_key)` + +*(annotation_id: string, source_key: string, target_key: string) => string[]* -- Sets each counterpart from [`get_counterpart_class_changes`](#get_counterpart_class_changesannotation_id-source_key-target_key) to its matched class at full confidence; the rest are left alone. `target_key` must not be read-only; the source may be. Recorded as one `set_counterparts_class` action on the target subtask's stream (undo there restores every previous classification) and reported to [`on_annotation_change`](#on_annotation_change) with the target as `subtask_key` and the changed counterparts as `affected`. Returns the changed ids; nothing is recorded when none changed. When something changed and [`paste_switch_to_target`](#paste_switch_to_target) is on, the target becomes the current subtask. + ### `set_paste_switch_to_target(enabled)` / `get_paste_switch_to_target()` *(enabled: boolean) => void* / *() => boolean* -- Read or change [`paste_switch_to_target`](#paste_switch_to_target) at runtime. diff --git a/demo.js b/demo.js index 4a7554be..f39c8cbe 100644 --- a/demo.js +++ b/demo.js @@ -1,4 +1,6 @@ const express = require('express'); +const fs = require('fs'); +const net = require('net'); const app = express(); // Override with PORT=... (Playwright reads the same variable). Falls through to the next // free port when the requested one is busy, unless PORT was set explicitly. @@ -10,22 +12,36 @@ app.use(express.static('demo')); app.use(express.static('dist')); function show_demo_urls(port) { - console.log(`http://localhost:${port}/single-class.html`); - console.log(`http://localhost:${port}/multi-class.html`); - console.log(`http://localhost:${port}/frames.html`); - console.log(`http://localhost:${port}/box-roi.html`); - console.log(`http://localhost:${port}/resume-from.html`); - console.log(`http://localhost:${port}/read-only.html`); - console.log(`http://localhost:${port}/row-filtering-example.html`); - console.log(`http://localhost:${port}/bitmask-example.html`); - console.log(`http://localhost:${port}/set-annotations.html`); - console.log(`http://localhost:${port}/class-focus.html`); - console.log(`http://localhost:${port}/live_demo.html`); - console.log(`http://localhost:${port}/offset-container.html`); - console.log(`http://localhost:${port}/submit-payload.html`); + console.log(`ULabel demos: http://localhost:${port}/`); + fs.readdirSync('demo') + .filter((file) => file.endsWith('.html') && file !== 'index.html') + .sort() + .forEach((file) => console.log(` http://localhost:${port}/${file}`)); } -function listen(port, attempts_left) { +// Windows lets us bind [::] while another server holds 127.0.0.1 or ::1 on the same port, so +// listen() would not report EADDRINUSE. Probe both loopback addresses first. +function is_port_answering(port) { + const probe = (host) => new Promise((resolve) => { + const socket = net.connect({ host, port }); + socket.setTimeout(300); + socket.once('connect', () => { socket.destroy(); resolve(true); }); + socket.once('timeout', () => { socket.destroy(); resolve(false); }); + socket.once('error', () => resolve(false)); + }); + return Promise.all([probe('127.0.0.1'), probe('::1')]).then((results) => results.some(Boolean)); +} + +async function listen(port, attempts_left) { + if (await is_port_answering(port)) { + if (!port_is_explicit && attempts_left > 0) { + listen(port + 1, attempts_left - 1); + } else { + console.error(`Could not start demo server on port ${port}: port is in use`); + process.exit(1); + } + return; + } const server = app.listen(port, () => { if (port !== requested_port) { console.log(`Port ${requested_port} is in use; using ${port} instead.`); diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 00000000..326ebb1e --- /dev/null +++ b/demo/index.html @@ -0,0 +1,52 @@ + + + + ULabel Demos + + + +

ULabel Demos

+

Local demo pages served by npm run demo. Run npm run build first to pick up source changes.

+ +

Overview

+
    +
  • live_demo - Annotated street scene with mixed confidences, confidence_card_classes, and hover_card_text. Published to GitHub Pages.
  • +
  • single-class - One subtask, one class.
  • +
  • multi-class - Several classes and every spatial mode, plus a whole-image subtask.
  • +
+ +

Features

+
    +
  • linked-subtasks - Diff / GT / Predictions linked by annotation_link_meta_key: Copy to, Set class in, Delete from, counterpart_class_meta_key.
  • +
  • class-focus - focus_active_class and per-class allowed_modes.
  • +
  • bitmask-example - Bitmask brush and erase with a reference subtask.
  • +
  • box-roi - Main subtask plus a bbox-only ROI subtask.
  • +
  • row-filtering-example - FilterDistance: points filtered by distance from polyline rows.
  • +
  • read-only - Every subtask read_only, with a toggle.
  • +
  • frames - Multi-frame image stack with frame-level annotations.
  • +
+ +

Data and API

+
    +
  • resume-from - Loading existing annotations with resume_from.
  • +
  • set-annotations - Swapping annotations at runtime with set_annotations, small to large sets and bitmasks.
  • +
  • getset - Round-tripping get_annotations / set_annotations.
  • +
  • submit-payload - Submit buttons, including edits_only payloads.
  • +
+ +

Layout and test fixtures

+ + + diff --git a/demo/linked-subtasks.html b/demo/linked-subtasks.html new file mode 100644 index 00000000..3e94384d --- /dev/null +++ b/demo/linked-subtasks.html @@ -0,0 +1,145 @@ + + + + ULabel - Linked Subtasks Demo + + + + + + + + + + + + +
+
+
+ + diff --git a/demo/live_demo.html b/demo/live_demo.html index 38b6b11b..0f9ff390 100644 --- a/demo/live_demo.html +++ b/demo/live_demo.html @@ -65,6 +65,7 @@ "spatial_payload": [[1447.58, 484.52], [1418.42, 397.04]], "classification_payloads": [ {"class_id": 11, "confidence": 0.55}, + {"class_id": 10, "confidence": 0.45}, ], }, { @@ -89,6 +90,7 @@ "spatial_payload": [[1037.67, 477.22], [1013.63, 393.07]], "classification_payloads": [ {"class_id": 11, "confidence": 0.45}, + {"class_id": 10, "confidence": 0.35}, ], }, { @@ -97,6 +99,7 @@ "spatial_payload": [[952.93, 470.00], [898.24, 409.90]], "classification_payloads": [ {"class_id": 11, "confidence": 0.6}, + {"class_id": 10, "confidence": 0.4}, ], }, { @@ -779,7 +782,13 @@ "image_data": "./cs-demo-0.png", "username": "DemoUser", "submit_buttons": on_submit, - "subtasks": subtasks + "subtasks": subtasks, + "confidence_card_classes": 2, + // Extra lines on the hover card, supplied by the host app + "hover_card_text": (annotation) => [ + `Type: ${annotation.spatial_type}`, + `ID: ${annotation.id.slice(0, 8)}`, + ], }); // Wait for ULabel instance to finish initialization ulabel.init(function() { diff --git a/index.d.ts b/index.d.ts index a3f7aa8c..a58ab526 100644 --- a/index.d.ts +++ b/index.d.ts @@ -269,6 +269,7 @@ export type ULabelActionType = "create_nonspatial_annotation" | "paste_annotation" | "move_annotation" | "delete_counterparts" | + "set_counterparts_class" | "begin_annotation" | "continue_annotation" | "finish_annotation" | @@ -402,12 +403,29 @@ export type ULabelConstructorArgs = { allow_body_move?: boolean; /** Open the class pie on a paste whose source class has no id/name match in the target. Default true; false takes the target's active class. */ paste_class_choice?: boolean; - /** Make the target the current subtask after a copy/move or `delete_counterparts`, and the writable subtask on undo/redo from a read-only one. Default false. */ + /** Make the target the current subtask after a copy/move, `delete_counterparts` or `set_counterparts_class`, and the writable subtask on undo/redo from a read-only one. Default false. */ paste_switch_to_target?: boolean; /** `annotation_meta` key whose value (string or string[]) links annotations across subtasks; any shared value links. Copies drop it. Default null. */ annotation_link_meta_key?: string | null; /** Context menu hides Copy to / Move to for subtasks that already hold a counterpart (instead of confirming). Default false. */ hide_copy_to_linked?: boolean; + /** + * `annotation_meta` key whose non-empty string value names the class an annotation gives its + * counterparts (Set class in) and copies (Copy to / Move to, paste), replacing the source class + * id/name match. No class of that name: Set class in leaves the counterpart alone; a copy falls + * back as when nothing matches. Annotations without it, and copies/pastes back into the source subtask, + * use the id/name match. Copies drop it. Default null. + */ + counterpart_class_meta_key?: string | null; + /** Classes the hover confidence card lists: the top class plus up to N - 1 runner-ups with non-zero confidence. Default 1. */ + confidence_card_classes?: number; + /** + * Callback returning extra lines for the hover confidence card, shown as plain text below the + * confidences. Return a string (one line), an array of strings (one line each, max 5), or null for none. + * Called once per hovered annotation and again after it changes; treat `annotation` as read-only. + * Must be synchronous. Errors are logged and show no extra text. Default null. + */ + hover_card_text?: ((annotation: ULabelAnnotation, subtask_key: string) => string | string[] | null) | null; /** Fired after a subtask's active class changes, from any writer (API, toolbox click, class keybind). */ on_active_class_change?: (subtask_key: string, class_id: number) => void; /** Fired after the current subtask changes, from any writer (API, tab click, switch keybind). */ @@ -460,6 +478,8 @@ export class ULabel { context_menu_annid: string | null; /** Last copied/cut annotation envelope, see `copy_annotation_to_clipboard`. */ clipboard: ULabelClipboardEnvelope | null; + /** Last `hover_card_text` result, reused while the same annotation stays hovered. */ + hover_card_text_cache: { annotation: ULabelAnnotation; subtask_key: string; lines: string[] } | null; }; config: Configuration; @@ -763,17 +783,24 @@ export class ULabel { public can_paste_into_subtask(annotation: ULabelAnnotation | object, subtask_key: string): boolean; /** Keys of the other subtasks `annotation` can be copied into. */ public get_copy_target_subtask_keys(annotation: ULabelAnnotation | object, source_key?: string | null): string[]; - /** Compatible target class matching the source class by id, else by `source_class_name`; null when none. */ - public match_paste_class_id(annotation: ULabelAnnotation | object, target_key: string, source_class_name?: string | null): number | null; + /** + * Compatible target class matching the source class by id, else by `source_class_name`; null when none. + * `use_meta_override` false ignores `counterpart_class_meta_key` (a paste back into the source subtask). Default true. + */ + public match_paste_class_id( + annotation: ULabelAnnotation | object, target_key: string, source_class_name?: string | null, use_meta_override?: boolean, + ): number | null; /** Matched source class (id, then name), else the target's active class, else the first compatible class. */ - public resolve_paste_class_id(annotation: ULabelAnnotation | object, target_key: string, source_class_name?: string | null): number | null; + public resolve_paste_class_id( + annotation: ULabelAnnotation | object, target_key: string, source_class_name?: string | null, use_meta_override?: boolean, + ): number | null; /** Name of a class in a subtask, or null. */ public get_class_name(class_id: number, subtask_key: string): string | null; /** * Insert a copy of `annotation` into `target_key` with a fresh id and stamps. * Records a `paste_annotation` action on the target subtask unless `record` is false. Returns the new id, or null. * With `source_key`, the copy's `annotation_meta.copied_from` is `{ subtask_key, annotation_id }`. - * The `annotation_link_meta_key` entry is dropped from the copy's `annotation_meta`. + * The `annotation_link_meta_key` and `counterpart_class_meta_key` entries are dropped from the copy's `annotation_meta`. */ public paste_annotation( annotation: ULabelAnnotation | object, @@ -820,7 +847,18 @@ export class ULabel { * With `config.paste_switch_to_target`, switches to the target when anything changed. */ public delete_counterparts(annotation_id: string, source_key: string, target_key: string, erase?: boolean | null): string[]; - /** Whether a context-menu copy/move/delete-from makes the target the current subtask. */ + /** + * Counterparts in `target_key` that can take the source's class (by id, else by name) + * and don't already have it, mapped to the class id each would take. + */ + public get_counterpart_class_changes(annotation_id: string, source_key: string, target_key: string): Record; + /** + * Set the counterparts in a writable `target_key` to the source's class (see `get_counterpart_class_changes`). + * One `set_counterparts_class` action on the target's stream. Returns the changed ids. + * With `config.paste_switch_to_target`, switches to the target when anything changed. + */ + public set_counterparts_class(annotation_id: string, source_key: string, target_key: string): string[]; + /** Whether a context-menu copy/move/delete-from/set-class-in makes the target the current subtask. */ public set_paste_switch_to_target(enabled: boolean): void; public get_paste_switch_to_target(): boolean; public start_complex_polygon(annotation_id?: string): void; @@ -860,6 +898,7 @@ export class ULabel { public paste_annotation__undo(annotation_id: string): void; public move_annotation__undo(annotation_id: string, undo_payload: object): void; public delete_counterparts__undo(undo_payload: object): void; + public set_counterparts_class__undo(undo_payload: object): void; public create_nonspatial_annotation__undo(annotation_id: string): void; public start_complex_polygon__undo(annotation_id: string): void; public merge_polygon_complex_layer__undo(annotation_id: string, undo_payload: object): void; @@ -884,6 +923,7 @@ export class ULabel { public paste_annotation__redo(annotation_id: string, redo_payload: object): void; public move_annotation__redo(annotation_id: string, redo_payload: object): void; public delete_counterparts__redo(annotation_id: string, redo_payload: object): void; + public set_counterparts_class__redo(annotation_id: string, redo_payload: object): void; public finish_modify_annotation__redo(annotation_id: string, redo_payload: object): void; // Mouse event handlers @@ -955,6 +995,12 @@ export class ULabel { dist_prop: number; }, ): void; + /** Show `name` in the centre of the current subtask's class pie (`front` for the nonspatial pie). */ + public set_id_dialog_class_name(name: string, front?: boolean): void; + /** Lines from `config.hover_card_text` for an annotation, cached until the hovered annotation changes. */ + public get_hover_card_text_lines(annotation: ULabelAnnotation, subtask_key: string): string[]; + /** Drop the cached `hover_card_text` result so the next hover calls the callback again. */ + public clear_hover_card_text_cache(): void; public handle_id_dialog_click( mouse_event: JQuery.TriggeredEvent, annotation_id?: string, diff --git a/package-lock.json b/package-lock.json index cbc50981..34894a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ulabel", - "version": "0.31.0", + "version": "0.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ulabel", - "version": "0.31.0", + "version": "0.32.0", "license": "MIT", "devDependencies": { "@eslint/config-helpers": "^0.7.0", diff --git a/package.json b/package.json index fb99a90f..e5b487e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ulabel", "description": "An image annotation tool.", - "version": "0.31.1", + "version": "0.32.0", "main": "dist/ulabel.min.js", "module": "dist/ulabel.min.js", "types": "dist/index.d.ts", diff --git a/src/actions.ts b/src/actions.ts index 27e17ca1..54b44290 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -15,6 +15,19 @@ import { NONSPATIAL_MODES } from "./annotation"; import { log_message, LogLevel } from "./error_logging"; +// Actions whose `annotation_id` is a source annotation in another subtask, not +// one in the stream that records them +const FOREIGN_ID_ACTIONS: ULabelActionType[] = ["delete_counterparts", "set_counterparts_class"]; + +/** + * Whether an action's `annotation_id` names an annotation in its own subtask. + * + * @param action the action + */ +export function action_owns_annotation_id(action: { act_type: ULabelActionType }): boolean { + return !FOREIGN_ID_ACTIONS.includes(action.act_type); +} + // ================= Record Actions ================= /** @@ -36,7 +49,9 @@ export function record_action( ) { ulabel.set_saved(false); const current_subtask = ulabel.subtasks[subtask_key ?? ulabel.get_current_subtask_key()]; - const annotation = current_subtask.annotations.access[raw_action.annotation_id!]; + const annotation = action_owns_annotation_id(raw_action) ? + current_subtask.annotations.access[raw_action.annotation_id!] : + undefined; // After a new action, you can no longer redo old actions if (add_to_action_stream && !is_redo) { @@ -323,6 +338,9 @@ function trigger_action_listeners( is_undo: boolean = false, is_redo: boolean = false, ) { + // The action may change the hovered annotation, so ask the host for its card text again + ulabel.clear_hover_card_text_cache(); + const action_map: Record void; undo?: (ulabel: ULabel, action: ULabelAction, is_undo?: boolean) => void; @@ -434,6 +452,9 @@ function trigger_action_listeners( delete_counterparts: { // Redraws its target subtask itself }, + set_counterparts_class: { + // Redraws its target subtask itself + }, }; // Call the appropriate listener @@ -726,10 +747,9 @@ export function redo(ulabel: ULabel) { * @param action the action */ function get_action_boxes(ulabel: ULabel, action: ULabelAction): ULabelContainingBox[] { - // A counterpart action's own id is its source annotation, in another subtask - const own = action.act_type === "delete_counterparts" ? - [] : - [{ annotation_id: action.annotation_id, subtask_key: ulabel.get_current_subtask_key() }]; + const own = action_owns_annotation_id(action) ? + [{ annotation_id: action.annotation_id, subtask_key: ulabel.get_current_subtask_key() }] : + []; const targets = [...own, ...(action.affected ?? [])]; const boxes: ULabelContainingBox[] = []; for (const { annotation_id, subtask_key } of targets) { @@ -769,7 +789,7 @@ function undo_action(ulabel: ULabel, action: ULabelAction) { const annotation_id = action.annotation_id!; // For some actions like delete_annotations_in_polygon, the annotation may no longer exist - if (annotation_id in annotations) { + if (action_owns_annotation_id(action) && annotation_id in annotations) { const annotation = annotations[annotation_id]; // Revert the annotation's last edited info @@ -829,6 +849,9 @@ function undo_action(ulabel: ULabel, action: ULabelAction) { case "delete_counterparts": ulabel.delete_counterparts__undo(undo_payload); break; + case "set_counterparts_class": + ulabel.set_counterparts_class__undo(undo_payload); + break; case "create_nonspatial_annotation": ulabel.create_nonspatial_annotation__undo(annotation_id); break; @@ -925,6 +948,9 @@ export function redo_action(ulabel: ULabel, action: ULabelAction) { case "delete_counterparts": ulabel.delete_counterparts__redo(annotation_id, redo_payload); break; + case "set_counterparts_class": + ulabel.set_counterparts_class__redo(annotation_id, redo_payload); + break; case "create_nonspatial_annotation": ulabel.create_nonspatial_annotation(annotation_id, redo_payload); break; diff --git a/src/annotation_operators.ts b/src/annotation_operators.ts index 39416343..efcb0ca7 100644 --- a/src/annotation_operators.ts +++ b/src/annotation_operators.ts @@ -69,6 +69,27 @@ export function get_annotation_class_id(annotation: ULabelAnnotation): string { return id.toString(); } +/** + * An annotation's non-zero class confidences, highest first (ties keep payload order). + * + * @param annotation ULabelAnnotation + * @param class_defs class definitions used to name each class; unknown ids are named by their id + * @returns one entry per class with a confidence above 0 + */ +export function get_class_confidences( + annotation: ULabelAnnotation, + class_defs: { id: number; name: string }[], +): { class_id: number; name: string; confidence: number }[] { + return (annotation.classification_payloads ?? []) + .filter((payload) => payload.confidence > 0) + .map((payload) => ({ + class_id: payload.class_id, + name: class_defs.find((def) => String(def.id) === String(payload.class_id))?.name ?? String(payload.class_id), + confidence: payload.confidence, + })) + .sort((a, b) => b.confidence - a.confidence); +} + /** * Takes in an annotation and marks it either deprecated or not deprecated. * diff --git a/src/configuration.ts b/src/configuration.ts index d56453cf..eb65b637 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -10,6 +10,7 @@ import type { AnnoScalingMode, BrushOverlapMode, ULabelAnnotationChange, + ULabelAnnotation, } from "../index"; import { ModeSelectionToolboxItem, @@ -156,6 +157,13 @@ export class Configuration { public paste_switch_to_target: boolean = false; // Whether the context menu hides Copy to / Move to for subtasks holding a counterpart. public hide_copy_to_linked: boolean = false; + // `annotation_meta` key whose string value names the target class for Set class in + // and copy/paste, replacing the source class id/name match. + public counterpart_class_meta_key: string | null = null; + // Classes listed on the confidence card: the top class plus up to N - 1 non-zero runner-ups + public confidence_card_classes: number = 1; + // Callback returning extra plain-text lines for the confidence card; called once per hovered annotation + public hover_card_text: ((annotation: ULabelAnnotation, subtask_key: string) => string | string[] | null) | null = null; // Configuration for the annotation task itself public image_data: ImageData | null = null; public allow_soft_id: boolean = false; diff --git a/src/context_menu.ts b/src/context_menu.ts index b260f7ac..a7cce0e5 100644 --- a/src/context_menu.ts +++ b/src/context_menu.ts @@ -10,7 +10,7 @@ import type { ULabel } from "../index"; import { ULabelAnnotation } from "./annotation"; -import { get_annotation_class_id } from "./annotation_operators"; +import { get_annotation_class_id, get_class_confidences } from "./annotation_operators"; const MENU_CLASS = "ulabel-context-menu"; const VIEWPORT_MARGIN = 4; @@ -106,6 +106,10 @@ function render_details( const class_def = subtask.class_defs.find((cd) => cd.id === class_id); add_detail_row(menu, "id", annotation.id); add_detail_row(menu, "class", class_def != null ? `${class_def.name} (${class_id})` : String(class_id)); + const confidences = get_class_confidences(annotation, subtask.class_defs); + if (confidences.length > 1) { + add_detail_row(menu, "confidences", confidences.map((c) => `${c.name} ${c.confidence.toFixed(2)}`).join(", ")); + } add_detail_row(menu, "type", annotation.spatial_type); add_detail_row(menu, "edited by", annotation.last_edited_by); add_detail_row(menu, "edited at", annotation.last_edited_at); @@ -124,7 +128,7 @@ function render_details( } /** - * "Copy to" / "Move to" / "Delete from" entry. One eligible subtask collapses to a flat + * "Copy to" / "Move to" / "Set class in" / "Delete from" entry. One eligible subtask collapses to a flat * "