feat: copy/paste per-class auto-annotate overrides as YAML (REQ-186, REQ-190)
Copy now emits YAML instead of the one-line "class <name> conf ..." text: one
block per selected class, fields named after the row labels, globals resolved
into the block, a name quoted only when YAML would misread it.
Paste reads the clipboard back and fills the overrides for the classes selected
in that modal, naming any it ignored — an override for a deselected class would
otherwise ride along in class_params unseen. container diffs go out as one
PATCH /projects/{id} and revert together if the save is rejected.
Parsing is parseClassYaml, a strict subset reader for exactly what Copy emits,
so no YAML dependency: any malformed line throws with its line number and
applies nothing. Values must look like numbers, not merely start like them —
parseFloat would have taken "0.5abc" as 0.5.
Both are real buttons with Lucide icons plus a status line reporting the
outcome, and both live in the shared table so the mass modal carries them too.
This commit is contained in:
1 parent
4f15335092
commit
4575c46f4c
8 files changed
+344
-49
No files matched your search
+12
-6
@@ -363,12 +363,18 @@ per-class overrides block (REQ-181) stacks one row per class — name plus **Con
|
||||
checkbox on the first line, the four override inputs in a wrapping grid below — and that
|
||||
checkbox toggles
|
||||
`project_classes.container` through `PATCH /api/projects/{id} { containers: … }`. The same
|
||||
block carries a **Copy** button (REQ-186): one click puts every selected class's effective
|
||||
settings (per-class override where set, else the global slider; container = the checkbox
|
||||
state) on the clipboard as short lines `class <name> conf … iou … minbox … maxbox … container
|
||||
true|false`, through `clipboard.js`'s `copyText` (Clipboard API with an `execCommand`
|
||||
fallback for insecure contexts) — read-only, no network, no setting changed. Because
|
||||
`ClassParamsTable` is shared, the mass modal carries the button too.
|
||||
block carries a **Copy** and a **Paste** button (REQ-186, REQ-190): one click puts every
|
||||
selected class's effective settings (per-class override where set, else the global slider;
|
||||
container = the checkbox state) on the clipboard as **YAML** — one block per class, fields
|
||||
named after the row labels (`conf`, `iou`, `minbox`, `maxbox`, `container`), through
|
||||
`clipboard.js`'s `copyText` (Clipboard API with an `execCommand` fallback for insecure
|
||||
contexts) — read-only, no network, no setting changed. **Paste** reads the clipboard back
|
||||
(`readClipboardText`, no legacy fallback, so a blocked read is reported) and fills the
|
||||
overrides for the classes selected in this modal; `container` diffs go out as one
|
||||
`PATCH /api/projects/{id} { containers: { classId: bool } }` and revert together on
|
||||
rejection. Parsing is `parseClassYaml` in the same file — a strict subset reader for exactly
|
||||
what Copy emits, no YAML dependency, all-or-nothing with a `line N: …` error. Because
|
||||
`ClassParamsTable` is shared, the mass modal carries both buttons too.
|
||||
|
||||
**Review sidebar class rows (REQ-180, REQ-183, REQ-185).** Each class row's frame-clear `×`
|
||||
(REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it
|
||||
|
||||
+25
-6
@@ -141,14 +141,17 @@ changes.
|
||||
returns that class to its full-set detections, so clearing them all is a plain full-set view
|
||||
again. The preview and the batch job still use the same thresholds and per-class overrides.
|
||||
- **REQ-186** — The auto-annotate parameters table has a **copy button** that puts every
|
||||
selected class's effective settings on the clipboard as short text, one class per line:
|
||||
`class <name> conf <v> iou <v> minbox <v> maxbox <v> container <true|false>`. Effective
|
||||
means the per-class override where one is set, otherwise the global slider value;
|
||||
selected class's effective settings on the clipboard as **YAML**, one class per block:
|
||||
`"<name>:"` at the left margin then the five fields `conf`, `iou`, `minbox`, `maxbox` (the
|
||||
labels as written on screen) and `container: <true|false>`, each on its own indented line,
|
||||
preceded by a `#` comment line. A class name is quoted only when YAML would misread it.
|
||||
Effective means the per-class override where one is set, otherwise the global slider value;
|
||||
`container` is the class's current checkbox state. The button sits in the shared
|
||||
per-class table, so it exists in both the per-batch auto-annotate modal and the mass
|
||||
(batches tab) modal, and the copied text is a plain baseline for later tuning — no
|
||||
structure beyond the line format. Copying needs no network round-trip and never changes
|
||||
any setting. (Amended by REQ-188: `maxbox` added to the line.)
|
||||
(batches tab) modal, and the copied YAML is a plain baseline for later tuning. Copying needs
|
||||
no network round-trip and never changes any setting. (Amended by REQ-188: `maxbox` added to
|
||||
the block. Amended by REQ-190: the wire format is YAML instead of the old one-line
|
||||
`class <name> conf <v> …` text, and a **Paste** button round-trips it.)
|
||||
|
||||
- **REQ-188** — Auto-annotation accepts a per-class **maximum box fraction**
|
||||
(`max_box_frac`, default `1.0` = off) beside the existing minimum override of REQ-181:
|
||||
@@ -157,6 +160,22 @@ changes.
|
||||
review assist filter), surfaces as `MaxBox` in the params table, and is included in the
|
||||
REQ-186 copy line. Size bounds are ordinary per-class tuning: the `container` flag
|
||||
(REQ-184) stays purely the NMS containment carve-out and gains no size semantics.
|
||||
- **REQ-190** — The same table has a **Paste YAML** button that reads the clipboard and fills
|
||||
the per-class overrides from it, so a tuned configuration moves between projects without
|
||||
retyping. It applies only the classes currently **selected in this modal**; a pasted class
|
||||
that is not selected is reported as ignored rather than written, because `buildClassParams`
|
||||
walks every entry and an unseen override would otherwise ride along in `class_params`.
|
||||
Numbers merge per field (a pasted block overwrites the four numbers, leaves other state
|
||||
alone); `container` goes out as one `PATCH /projects/{id} {containers: {classId: bool}}`
|
||||
covering every flag that changed, reverted together with a message if the save is rejected.
|
||||
Parsing is strict on purpose — a subset reader for exactly what Copy emits (`#` comments,
|
||||
`class:` at the left margin, `key: value` indented under it) rather than a general YAML
|
||||
engine, and **any** malformed line throws with its line number and applies **nothing**, so a
|
||||
half-understood paste cannot quietly set wrong parameters. A value must *look* like a number,
|
||||
not merely start like one (`0.5abc` is rejected rather than read as `0.5`), and values
|
||||
outside 0–1 are rejected the same way. Clipboard **read** has no legacy fallback, so a blocked or denied read (the app
|
||||
served over plain http on a LAN address, or the browser prompt refused) is reported in the
|
||||
status line instead of failing silently. Paste never touches the global sliders.
|
||||
|
||||
## E. Review & correction
|
||||
|
||||
|
||||
@@ -1405,6 +1405,58 @@ read it.
|
||||
(accent border + glow) — no CSS added; **browser eyeball still owed by the user**
|
||||
(375 px 2-col wrap, long class names).
|
||||
|
||||
## Task — Copy/Paste YAML for per-class overrides (REQ-186 amended, REQ-190) `[DONE]`
|
||||
|
||||
> Code, parser and build verified below; the **clipboard round trip in a real browser is still
|
||||
> owed** (the Clipboard API cannot be exercised from the shell).
|
||||
|
||||
1. **Icons** `frontend/src/components/Icons.jsx` — `CopyIcon` and `ClipboardPasteIcon` added
|
||||
in Lucide geometry, inheriting `currentColor`; the shared `Icon` wrapper already sets
|
||||
`aria-hidden` and `focusable="false"`.
|
||||
2. **Copy emits YAML** `frontend/src/components/ClassParamsTable.jsx` — `compose()` writes a
|
||||
`#` comment line plus one block per selected class, fields named from the row labels
|
||||
(`conf`/`iou`/`minbox`/`maxbox`/`container`) via `FIELD_BY_LABEL` so the wire format cannot
|
||||
drift from what is on screen. A name is quoted only when `PLAIN_NAME` fails, i.e. only when
|
||||
YAML would misread it. Effective-value resolution is unchanged (empty input still copies the
|
||||
global), so the block is a whole configuration rather than a diff.
|
||||
3. **Strict reader** `parseClassYaml()` in the same file, exported so it is testable without a
|
||||
DOM. Deliberately **not** `js-yaml`: a subset reader for exactly what Copy emits is ~35
|
||||
lines and needs no dependency. Rejects (with the line number) a list item, a nested map, a
|
||||
top-level scalar, an unknown key, a malformed number, a value outside 0–1 and an indented key
|
||||
before any class. Throwing happens before any state write, so a bad paste is atomic —
|
||||
nothing is half-applied.
|
||||
→ verify: **[DONE]** parsed the real parser through `esbuild --bundle --platform=node`: a
|
||||
two-class document with a quoted `"weird: name"` key round-trips to the internal key names
|
||||
(`threshold`, `iou_threshold`, `min_box_frac`, `max_box_frac`, `container`), and the malformed
|
||||
cases each throw the expected `line N: …` message (an empty document returns `{}`, which
|
||||
paste turns into "No classes in the pasted YAML"). A self-review pass caught a real hole the
|
||||
first harness run missed: `parseFloat('0.5abc')` is `0.5`, so a typo'd number was being
|
||||
accepted silently — exactly the failure the strict design exists to prevent. Fixed by
|
||||
matching the text against `NUMBER` before `parseFloat`. Same harness after the fix:
|
||||
`0.5abc`, `Infinity`, `NaN`, `0.5z`, `" 0.5 "` all throw; `1e-2`, tabs, CRLF, an inline
|
||||
`# comment` and a quoted name containing ` #` all parse. Repeated class blocks merge.
|
||||
4. **Paste** `frontend/src/components/ClassParamsTable.jsx` — reads the clipboard
|
||||
(`readClipboardText` in `frontend/src/clipboard.js`), applies only the classes selected in
|
||||
this modal, and names the ignored ones in the status line. `container` diffs are applied
|
||||
through `applyContainers()`, which folds the per-toggle logic into one `PATCH
|
||||
/projects/{id} {containers: {classId: bool}}` (`backend/projects.py:232` iterates the dict)
|
||||
and reverts all of them together on rejection, the same functional-revert trick the checkbox
|
||||
already used.
|
||||
→ verify: **[DONE]** `backend/projects.py` read: one PATCH covers N class ids; build green.
|
||||
**Owed, user browser**: copy → paste back → same numbers; paste a block for a deselected
|
||||
class → named as ignored and nothing written; `conf: 40` → `line 2: conf must be between 0
|
||||
and 1, got 40`, table untouched; container flag survives a modal reopen; serve over a LAN IP
|
||||
→ status line explains the blocked clipboard read.
|
||||
5. **Button design** `frontend/src/app.css` — `.class-params-tools` right-aligned wrapping row,
|
||||
`.btn` at `0.76rem` with `cursor: pointer` and a `progress` cursor while disabled (the old
|
||||
`btn-ghost` text button had neither), `.class-params-note` status line that turns `--danger`
|
||||
when the outcome failed. It does **not** use the app's `.hint` class: that is `--text-faint`,
|
||||
measured 2.68:1 on the modal panel, and the status line is small text that needs 4.5:1 —
|
||||
`--text-muted` measures 6.98:1 and `--danger` 4.71:1. Hover/focus-visible come from the
|
||||
shared `.btn` (`theme.css:114`, `:108`).
|
||||
→ verify: **[DONE]** `npm run build` green; contrast ratios computed from the tokens;
|
||||
`ClassParamsTable.jsx` 271 lines, inside the 400-line rule.
|
||||
|
||||
## Task — Configurable Track Forward + track-to-end (REQ-189) `[DONE]`
|
||||
|
||||
> Status caveat: code, build and docs are verified below; the **end-to-end GPU run in the
|
||||
|
||||
+35
-6
@@ -466,12 +466,41 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
|
||||
unmarked classes are governed by IoU alone. Toggling is optimistic
|
||||
`PATCH /projects/{id} { containers: { [classId]: bool } }` into `project_classes.container`
|
||||
with revert on rejection — preview and batch job read the same stored flag.
|
||||
- **Copy (REQ-186)** — small right-aligned ghost button above the overrides block; label
|
||||
`Copy`, flips to `Copied` for 1.5 s inside an `aria-live` span, and on failure silently
|
||||
stays `Copy`. One line per selected class, e.g.
|
||||
`class sack conf 0.4 iou 0.6 minbox 0.005 maxbox 1 container false` — an empty input copies the
|
||||
global value. Present in the engine-chooser modal **and** the mass modal (shared
|
||||
component).
|
||||
- **Copy / Paste YAML (REQ-186, REQ-190)** — a small right-aligned toolbar above the overrides
|
||||
block: two real buttons, `Copy YAML` and `Paste YAML`, each an inline SVG icon (Lucide copy /
|
||||
clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`, both disabled while a
|
||||
paste is in flight. A `role="status"` line beside them carries the outcome — `Copied`,
|
||||
`Pasted 3 classes`, `Pasted 2 classes — ignored crane`, or the failure in `--danger` — success
|
||||
text clears itself after 2 s, a failure stays until the next action.
|
||||
Copy puts one YAML block per selected class on the clipboard:
|
||||
|
||||
```yaml
|
||||
# auto-annotate per-class overrides
|
||||
sack:
|
||||
conf: 0.4
|
||||
iou: 0.6
|
||||
minbox: 0.005
|
||||
maxbox: 1
|
||||
container: false
|
||||
```
|
||||
|
||||
Field names are the row labels as written on screen; a name is quoted only when YAML would
|
||||
misread it (`"weird: name":`). An empty input copies the global value, so the block is a
|
||||
complete configuration, not a diff.
|
||||
Paste reads the clipboard and fills the overrides for the classes **selected in this modal**;
|
||||
an unselected class is named in the status line and never written. Numbers merge per field.
|
||||
`container` is saved to the server like ticking the box by hand (one PATCH for all changed
|
||||
classes, reverted together on rejection, and the status line says the flags were not saved).
|
||||
Parsing is strict and **atomic**: `#` comments (whole lines, or trailing on a value), `class:`
|
||||
at the left margin, `key: value` indented under it, numbers in 0–1 written as numbers
|
||||
(`0.5abc` is a typo, not `0.5`), `container` `true|false|yes|no|1|0`; a list item, a nested
|
||||
map, a top-level scalar, an unknown key, a malformed number, an out-of-range value or a
|
||||
key before any class throws `line N: …` and applies **nothing**. A class repeated in the same
|
||||
document merges rather than replacing. Where the browser blocks the clipboard read — the
|
||||
app on plain http over a LAN address, or the permission prompt refused — the status line says
|
||||
so rather than doing nothing.
|
||||
Both buttons live in the shared per-class table, so they exist in the engine-chooser modal
|
||||
**and** the mass modal.
|
||||
- `ClassPromptPanel`:
|
||||
- Target-class chips. Without SAM3 a chip is a simple toggle.
|
||||
- **With SAM3 a chip is two buttons**: the *name* selects-and-activates, the `×` deselects.
|
||||
|
||||
@@ -657,6 +657,39 @@ main.page {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Copy/Paste YAML above the per-class override rows (REQ-190). */
|
||||
.class-params-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.class-params-tools .btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.class-params-tools .btn:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.class-params-note {
|
||||
/* Not .hint: --text-faint is 2.7:1 on the modal panel, this text needs 4.5:1. */
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
text-align: right;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.class-params-note[data-failed] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.review-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Clipboard API needs a secure context — on http://<ip>:9000 it is undefined.
|
||||
// execCommand('copy') still works there inside a click gesture; if both fail,
|
||||
// the path is revealed in a pre-selected field so one Ctrl+C always works.
|
||||
// Reading has no legacy fallback (execCommand('paste') is not a thing), so it
|
||||
// reports why instead of failing quietly.
|
||||
function legacyCopy(text) {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = text
|
||||
@@ -34,3 +36,14 @@ export async function copyText(text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function readClipboardText() {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
throw new Error('Clipboard read is blocked here — open the app on localhost or https to paste')
|
||||
}
|
||||
try {
|
||||
return await navigator.clipboard.readText()
|
||||
} catch {
|
||||
throw new Error('Clipboard read was denied — allow it in the browser prompt, then paste again')
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { copyText } from '../clipboard'
|
||||
import { copyText, readClipboardText } from '../clipboard'
|
||||
import { ClipboardPasteIcon, CopyIcon } from './Icons'
|
||||
|
||||
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
|
||||
const KEYS = [
|
||||
@@ -10,6 +11,59 @@ const KEYS = [
|
||||
{ key: 'max_box_frac', label: 'MaxBox', step: 0.05, color: '#f472b6' },
|
||||
]
|
||||
|
||||
// The YAML wire format uses the labels as written on screen (REQ-190).
|
||||
const FIELD_BY_LABEL = Object.fromEntries(KEYS.map(({ key, label }) => [label.toLowerCase(), key]))
|
||||
|
||||
// A class name only needs quoting when YAML would misread it.
|
||||
const PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
|
||||
const yamlName = (name) => (PLAIN_NAME.test(name) ? name : `"${name.replace(/["\\]/g, '\\$&')}"`)
|
||||
|
||||
// parseFloat would take `0.5abc` as 0.5 and hide the typo, so the text has to
|
||||
// look like a number first. Inline `# comment` is stripped from the value only,
|
||||
// never from the key — a quoted class name may legitimately contain " #".
|
||||
const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/
|
||||
const TRUE_FALSE = { true: true, yes: true, '1': true, false: false, no: false, '0': false }
|
||||
|
||||
// Strict reader for exactly what compose() emits: `#` comments and blank lines,
|
||||
// a class name at the left margin, `key: value` indented under it. Anything
|
||||
// else throws with its line number so a bad paste changes nothing at all.
|
||||
export function parseClassYaml(text) {
|
||||
const classes = {}
|
||||
let current = null
|
||||
text.split(/\r?\n/).forEach((raw, i) => {
|
||||
const line = raw.trim()
|
||||
const at = `line ${i + 1}`
|
||||
if (!line || line.startsWith('#')) return
|
||||
const match = line.match(/^(".*"|'.*'|[^:]+):\s*(.*)$/)
|
||||
if (!match) throw new Error(`${at}: expected "class:" or "key: value"`)
|
||||
let name = match[1].trim()
|
||||
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(["\\])/g, '$1')
|
||||
else if (name.startsWith("'") && name.endsWith("'")) name = name.slice(1, -1).replace(/''/g, "'")
|
||||
const value = match[2].replace(/\s+#.*$/, '').trim()
|
||||
if (!/^[ \t]/.test(raw)) {
|
||||
if (value) throw new Error(`${at}: unexpected value "${value}"`)
|
||||
current = name
|
||||
classes[name] = classes[name] || {}
|
||||
return
|
||||
}
|
||||
if (!current) throw new Error(`${at}: "${name}" before any class name`)
|
||||
if (!value) throw new Error(`${at}: nested values are not supported`)
|
||||
if (name === 'container') {
|
||||
const flag = TRUE_FALSE[value.toLowerCase()]
|
||||
if (flag === undefined) throw new Error(`${at}: container must be true or false, got "${value}"`)
|
||||
classes[current].container = flag
|
||||
return
|
||||
}
|
||||
const field = FIELD_BY_LABEL[name]
|
||||
if (!field) throw new Error(`${at}: unknown key "${name}"`)
|
||||
if (!NUMBER.test(value)) throw new Error(`${at}: ${name} must be a number, got "${value}"`)
|
||||
const num = parseFloat(value)
|
||||
if (num < 0 || num > 1) throw new Error(`${at}: ${name} must be between 0 and 1, got ${value}`)
|
||||
classes[current][field] = num
|
||||
})
|
||||
return classes
|
||||
}
|
||||
|
||||
export function buildClassParams(classParams) {
|
||||
const out = {}
|
||||
for (const [name, values] of Object.entries(classParams || {})) {
|
||||
@@ -32,28 +86,47 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
|
||||
const [containers, setContainers] = useState(() =>
|
||||
new Set((project?.classes || []).filter(c => c.container).map(c => c.name))
|
||||
)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [note, setNote] = useState({ text: '', failed: false })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const say = (text, failed = false) => {
|
||||
setNote({ text, failed })
|
||||
if (!failed) setTimeout(() => setNote((n) => (n.text === text ? { text: '', failed: false } : n)), 2000)
|
||||
}
|
||||
const set = (name, key, raw) =>
|
||||
onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } })
|
||||
const toggleContainer = async (cls) => {
|
||||
const on = !containers.has(cls.name)
|
||||
const next = new Set(containers)
|
||||
if (on) next.add(cls.name)
|
||||
else next.delete(cls.name)
|
||||
|
||||
// One PATCH for every flag that changed, reverted together on rejection.
|
||||
const applyContainers = async (next, changes) => {
|
||||
const patch = {}
|
||||
for (const [name, on] of changes) {
|
||||
const cls = project?.classes.find((c) => c.name === name)
|
||||
if (cls) patch[cls.class_id] = on
|
||||
}
|
||||
setContainers(next)
|
||||
if (!Object.keys(patch).length) return true
|
||||
try {
|
||||
await api.patchProject(project.id, { containers: { [cls.class_id]: on } })
|
||||
await api.patchProject(project.id, { containers: patch })
|
||||
return true
|
||||
} catch (err) {
|
||||
// Functional revert: only undo this toggle, even if another landed meanwhile.
|
||||
setContainers(prev => {
|
||||
console.error('Could not save the container flags:', err)
|
||||
setContainers((prev) => {
|
||||
const reverted = new Set(prev)
|
||||
if (on) reverted.delete(cls.name)
|
||||
else reverted.add(cls.name)
|
||||
for (const [name, on] of changes) {
|
||||
if (on) reverted.delete(name)
|
||||
else reverted.add(name)
|
||||
}
|
||||
return reverted
|
||||
})
|
||||
console.error('Could not save the container flag:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
const toggleContainer = (cls) =>
|
||||
applyContainers(
|
||||
containers.has(cls.name)
|
||||
? new Set([...containers].filter((n) => n !== cls.name))
|
||||
: new Set(containers).add(cls.name),
|
||||
[[cls.name, !containers.has(cls.name)]]
|
||||
)
|
||||
if (!classNames.length) return null
|
||||
const fmt = (v) => String(Math.round(v * 1000) / 1000)
|
||||
const effective = (name, key) => {
|
||||
@@ -62,35 +135,90 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
|
||||
return Number.isFinite(num) ? num : globals[key]
|
||||
}
|
||||
const compose = () =>
|
||||
classNames
|
||||
.map(
|
||||
(name) =>
|
||||
`class ${name} conf ${fmt(effective(name, 'threshold'))} iou ${fmt(
|
||||
effective(name, 'iou_threshold')
|
||||
)} minbox ${fmt(effective(name, 'min_box_frac'))} maxbox ${fmt(
|
||||
effective(name, 'max_box_frac')
|
||||
)} container ${containers.has(name)}`
|
||||
)
|
||||
.join('\n')
|
||||
[
|
||||
'# auto-annotate per-class overrides',
|
||||
...classNames.map((name) =>
|
||||
[
|
||||
`${yamlName(name)}:`,
|
||||
...KEYS.map(({ key, label }) => ` ${label.toLowerCase()}: ${fmt(effective(name, key))}`),
|
||||
` container: ${containers.has(name)}`,
|
||||
].join('\n')
|
||||
),
|
||||
].join('\n')
|
||||
const copy = async () => {
|
||||
const ok = await copyText(compose())
|
||||
if (ok) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} else {
|
||||
if (ok) say('Copied')
|
||||
else {
|
||||
console.error('Could not copy the effective auto-annotate parameters')
|
||||
say('Could not copy — your browser refused the clipboard', true)
|
||||
}
|
||||
}
|
||||
const paste = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const parsed = parseClassYaml(await readClipboardText())
|
||||
const names = Object.keys(parsed)
|
||||
if (!names.length) throw new Error('No classes in the pasted YAML')
|
||||
const known = names.filter((name) => classNames.includes(name))
|
||||
if (!known.length) throw new Error(`None of these classes are selected here: ${names.join(', ')}`)
|
||||
const unknown = names.filter((name) => !classNames.includes(name))
|
||||
// Only selected classes: an override for a deselected class would ride
|
||||
// along in class_params unseen (buildClassParams walks all of them).
|
||||
const next = { ...value }
|
||||
const flags = new Set(containers)
|
||||
const changes = []
|
||||
for (const name of known) {
|
||||
const merged = { ...(next[name] || {}) }
|
||||
for (const { key } of KEYS) {
|
||||
if (parsed[name][key] !== undefined) merged[key] = String(parsed[name][key])
|
||||
}
|
||||
next[name] = merged
|
||||
const on = parsed[name].container
|
||||
if (on !== undefined && on !== flags.has(name)) {
|
||||
if (on) flags.add(name)
|
||||
else flags.delete(name)
|
||||
changes.push([name, on])
|
||||
}
|
||||
}
|
||||
onChange(next)
|
||||
const saved = await applyContainers(flags, changes)
|
||||
const suffix = unknown.length ? ` — ignored ${unknown.join(', ')}` : ''
|
||||
say(
|
||||
saved
|
||||
? `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
|
||||
: `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix} — container flags not saved`,
|
||||
!saved
|
||||
)
|
||||
} catch (err) {
|
||||
say(err.message, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="class-params-tools">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={copy}
|
||||
style={{ fontSize: '0.76rem', padding: '4px 8px', cursor: 'pointer' }}
|
||||
disabled={busy}
|
||||
title="Copy these per-class overrides as YAML"
|
||||
>
|
||||
<span aria-live="polite">{copied ? 'Copied' : 'Copy'}</span>
|
||||
<CopyIcon /> Copy YAML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={paste}
|
||||
disabled={busy}
|
||||
title="Fill these overrides from YAML on the clipboard"
|
||||
>
|
||||
<ClipboardPasteIcon /> Paste YAML
|
||||
</button>
|
||||
<span className="class-params-note" role="status" data-failed={note.failed || undefined}>
|
||||
{note.text}
|
||||
</span>
|
||||
</div>
|
||||
{/* Stacked rows (REQ-181): the name line carries the container flag, the
|
||||
grid below it the four overrides, so a wide class list never squeezes
|
||||
|
||||
@@ -95,6 +95,21 @@ export const CpuIcon = (props) => (
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const CopyIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const ClipboardPasteIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M12 11v4M10 13h4" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const RocketIcon = (props) => (
|
||||
<Icon {...props}>
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" />
|
||||
|
||||
Reference in new issue
Block a user