esoul-sdk 0.7.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +575 -153
- package/api-reference.md +3291 -0
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +139 -0
- package/dist/audience.d.ts +2 -0
- package/dist/audience.js +2 -0
- package/dist/bindings.d.ts +3 -0
- package/dist/bindings.js +3 -0
- package/dist/chart-font.d.ts +19 -0
- package/dist/chart-font.js +15 -0
- package/dist/chart.d.ts +83 -0
- package/dist/chart.js +247 -0
- package/dist/computer.d.ts +191 -0
- package/dist/computer.js +226 -0
- package/dist/db/client-core.d.ts +38 -0
- package/dist/db/client-core.js +68 -1
- package/dist/db/compile-rules.d.ts +60 -2
- package/dist/db/compile-rules.js +178 -30
- package/dist/db/custom-roles.d.ts +156 -0
- package/dist/db/custom-roles.js +280 -0
- package/dist/db/memory-client.d.ts +0 -15
- package/dist/db/memory-client.js +13 -23
- package/dist/db/schema-gen.js +2 -8
- package/dist/editor-sync.d.ts +130 -0
- package/dist/editor-sync.js +413 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +48 -0
- package/dist/helpers.d.ts +10 -0
- package/dist/helpers.js +10 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +25 -0
- package/dist/labelme.d.ts +84 -0
- package/dist/labelme.js +118 -0
- package/dist/manifest.d.ts +400 -106
- package/dist/manifest.js +59 -5
- package/dist/ops.d.ts +115 -0
- package/dist/ops.js +120 -0
- package/dist/react.d.ts +280 -4
- package/dist/react.js +100 -3
- package/dist/server.d.ts +268 -10
- package/dist/server.js +120 -4
- package/dist/testing/db.d.ts +6 -0
- package/dist/testing/db.js +3 -8
- package/dist/testing/files.d.ts +22 -0
- package/dist/testing/files.js +175 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +47 -0
- package/docs/02-manifest.md +2 -0
- package/docs/03-events-and-state.md +8 -0
- package/docs/04-tools.md +58 -26
- package/docs/05-ui.md +35 -0
- package/docs/06-server.md +121 -7
- package/docs/07-background-tasks.md +7 -9
- package/docs/09-files.md +126 -17
- package/docs/10-testing.md +6 -0
- package/docs/11-shipping.md +10 -2
- package/docs/13-people-and-access.md +180 -0
- package/docs/14-database.md +6 -0
- package/docs/15-realtime.md +3 -0
- package/docs/17-editing-and-merging.md +155 -0
- package/llms-full.txt +1296 -214
- package/llms.txt +3 -3
- package/package.json +9 -4
- package/schemas/plugin.schema.json +134 -15
- package/scripts/build-api-reference.mjs +104 -0
- package/scripts/build-llms.mjs +1 -2
package/dist/files.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* provider-opaque `ref`; labels are display only. A source that cannot
|
|
10
10
|
* answer reports `source_unavailable` — never an empty listing.
|
|
11
11
|
*/
|
|
12
|
+
/** A files call refused — `kind` says why: not_declared (the manifest has no `files` grant for it), source_unavailable, not_found, too_large, read_only, disabled, bad_ref. */
|
|
12
13
|
export class FileSourceError extends Error {
|
|
13
14
|
kind;
|
|
14
15
|
constructor(kind, message) {
|
|
@@ -17,8 +18,11 @@ export class FileSourceError extends Error {
|
|
|
17
18
|
this.kind = kind;
|
|
18
19
|
}
|
|
19
20
|
}
|
|
21
|
+
/** The default most `FilesApi.read` returns (25 MB); pass `capBytes` to raise it. */
|
|
20
22
|
export const FILE_READ_CAP_BYTES = 25 * 1024 * 1024;
|
|
23
|
+
/** The ceiling `capBytes` may raise a read to (100 MB). */
|
|
21
24
|
export const FILE_READ_HARD_CAP_BYTES = 100 * 1024 * 1024;
|
|
25
|
+
/** A file source id as its parts: the workspace, Google Drive, a local mount, or an app's own provider. */
|
|
22
26
|
export function parseSourceId(sourceId) {
|
|
23
27
|
if (sourceId === "workspace")
|
|
24
28
|
return { kind: "workspace" };
|
|
@@ -37,3 +41,47 @@ export function parseSourceId(sourceId) {
|
|
|
37
41
|
}
|
|
38
42
|
return { kind: "plugin", providerKey: head, connectionId: tail };
|
|
39
43
|
}
|
|
44
|
+
/** The most one page of a listing holds. */
|
|
45
|
+
export const FILE_LIST_PAGE_MAX = 1000;
|
|
46
|
+
/** The most `listAll` gathers before it answers `truncated: true`. */
|
|
47
|
+
export const FILE_LIST_ALL_MAX = 20_000;
|
|
48
|
+
/** The most `readMany` reads in one call, in files and in bytes. */
|
|
49
|
+
export const FILE_READ_MANY_MAX = 200;
|
|
50
|
+
/** …and the most bytes it gathers in one call (64 MB); past it the remaining rows answer `too_large`. */
|
|
51
|
+
export const FILE_READ_MANY_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
52
|
+
/** The longest a read grant lives (24 h); the default is one hour. */
|
|
53
|
+
export const FILE_GRANT_MAX_TTL_SECONDS = 24 * 60 * 60;
|
|
54
|
+
/** How long a read grant lives when `ttlSeconds` is not given: one hour. */
|
|
55
|
+
export const FILE_GRANT_DEFAULT_TTL_SECONDS = 60 * 60;
|
|
56
|
+
/** The URL of one file under a grant. `width` asks for a downsized image (a thumbnail), 16–2048 px. */
|
|
57
|
+
export function fileGrantUrl(grant, ref, opts) {
|
|
58
|
+
const id = typeof ref === "string" ? ref : ref.ref;
|
|
59
|
+
const w = opts?.width ? Math.max(16, Math.min(2048, Math.round(opts.width))) : 0;
|
|
60
|
+
return `${grant.urlBase}&ref=${encodeURIComponent(id)}${w ? `&w=${w}` : ""}`;
|
|
61
|
+
}
|
|
62
|
+
/** Does an entry pass these listing options? The one rule every provider's listing is held to. */
|
|
63
|
+
export function matchesListOptions(entry, opts) {
|
|
64
|
+
if (!opts)
|
|
65
|
+
return true;
|
|
66
|
+
if (opts.only && entry.kind !== opts.only)
|
|
67
|
+
return false;
|
|
68
|
+
if (opts.nameContains && !entry.name.toLowerCase().includes(opts.nameContains.toLowerCase()))
|
|
69
|
+
return false;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** `pageSize` as a provider may use it: a whole number from 1 to FILE_LIST_PAGE_MAX, default 100. */
|
|
73
|
+
export function listPageSize(opts) {
|
|
74
|
+
const n = Math.floor(Number(opts?.pageSize));
|
|
75
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(n, FILE_LIST_PAGE_MAX) : 100;
|
|
76
|
+
}
|
|
77
|
+
/** "sheets/quality_dataset/Type5" → its segments. Leading "/", a source's own label and empty parts are dropped. */
|
|
78
|
+
export function splitFilePath(path) {
|
|
79
|
+
return String(path ?? "")
|
|
80
|
+
.split("/")
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.filter((s) => s.length > 0 && s !== ".");
|
|
83
|
+
}
|
|
84
|
+
/** Natural name order: "frame_2" before "frame_10". */
|
|
85
|
+
export function compareFileNames(a, b) {
|
|
86
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
|
87
|
+
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface RequiredStateShape {
|
|
|
13
13
|
/** Keys that must be non-null objects. */
|
|
14
14
|
maps?: Record<string, unknown>;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The genesis-seeded keys a state lacks — for `getStateDescription`: a MISSING list means the state
|
|
18
|
+
* did not load (say so), an EMPTY one is real. Never floor an absent key to `[]`.
|
|
19
|
+
*/
|
|
16
20
|
export declare function missingStateKeys(shape: RequiredStateShape): string[];
|
|
17
21
|
/**
|
|
18
22
|
* `null` when the state is whole — the describer proceeds normally.
|
|
@@ -56,6 +60,12 @@ export declare function currentShareId(): string | null;
|
|
|
56
60
|
* outside a preview: the platform never reads it, and the header is not sent.
|
|
57
61
|
*/
|
|
58
62
|
export declare function previewPersona(): string | null;
|
|
63
|
+
/**
|
|
64
|
+
* Call one of your app's server ops from the UI or a tool: POSTs `{nodeId, args}` to the op's route
|
|
65
|
+
* (the installed app's `/api/plugins/<id>/op/<op>`, or the Forge preview's own route) with the
|
|
66
|
+
* session, the share and the VIEW AS persona the page carries, and returns the op's result — or
|
|
67
|
+
* throws the op's refusal with its code.
|
|
68
|
+
*/
|
|
59
69
|
export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
|
|
60
70
|
/**
|
|
61
71
|
* What a failed op call throws. It CARRIES the platform's refusal code, so a
|
package/dist/helpers.js
CHANGED
|
@@ -20,6 +20,10 @@ export function deterministicReducerId(prefix, seed) {
|
|
|
20
20
|
h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
21
21
|
return `${prefix}_${h.toString(36)}${(s.length & 0xffff).toString(36)}`;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The genesis-seeded keys a state lacks — for `getStateDescription`: a MISSING list means the state
|
|
25
|
+
* did not load (say so), an EMPTY one is real. Never floor an absent key to `[]`.
|
|
26
|
+
*/
|
|
23
27
|
export function missingStateKeys(shape) {
|
|
24
28
|
const missing = [];
|
|
25
29
|
for (const [key, value] of Object.entries(shape.lists ?? {})) {
|
|
@@ -102,6 +106,12 @@ function previewViewerHeaders() {
|
|
|
102
106
|
const p = previewPersona();
|
|
103
107
|
return p ? { [PREVIEW_VIEWER_HEADER]: p } : {};
|
|
104
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Call one of your app's server ops from the UI or a tool: POSTs `{nodeId, args}` to the op's route
|
|
111
|
+
* (the installed app's `/api/plugins/<id>/op/<op>`, or the Forge preview's own route) with the
|
|
112
|
+
* session, the share and the VIEW AS persona the page carries, and returns the op's result — or
|
|
113
|
+
* throws the op's refusal with its code.
|
|
114
|
+
*/
|
|
105
115
|
export async function callPluginOp(pluginId, op, nodeId, args) {
|
|
106
116
|
const onServer = typeof window === "undefined";
|
|
107
117
|
const serverBase = onServer ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" : "";
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,32 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./manifest.js";
|
|
3
3
|
export * from "./helpers.js";
|
|
4
4
|
export * from "./files.js";
|
|
5
|
+
export * from "./assets.js";
|
|
6
|
+
export * from "./labelme.js";
|
|
5
7
|
export * from "./bindings.js";
|
|
6
8
|
/** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
|
|
7
9
|
export * from "./audience.js";
|
|
8
10
|
/** The app's own words for the platform's kinds of caller (S3). */
|
|
9
11
|
export * from "./roles.js";
|
|
12
|
+
/**
|
|
13
|
+
* One declaration per op: `defineOps` in a shared file, `handleOp` in server.ts,
|
|
14
|
+
* `opTool` in app.tsx — a tool's parameters cannot drift from what its op takes.
|
|
15
|
+
*/
|
|
16
|
+
export * from "./ops.js";
|
|
17
|
+
/** A chart from data — one SVG for the app's screen and, rendered, for a tool's answer. */
|
|
18
|
+
export * from "./chart.js";
|
|
19
|
+
/**
|
|
20
|
+
* Editor sync: how a mounted editor takes a change from another device or an agent while the person types —
|
|
21
|
+
* the decision and the merges (records by id, fields, lines). The hook is `useRemoteReconcile` in esoul-sdk/react.
|
|
22
|
+
*/
|
|
23
|
+
export * from "./editor-sync.js";
|
|
24
|
+
/**
|
|
25
|
+
* The agent tool for one op, declared once: `opTool(ops.play, { say })` in app.tsx gives a tool whose
|
|
26
|
+
* parameters ARE the op's zod input and whose call goes to `callPluginOp` — as the acting viewer on
|
|
27
|
+
* the platform, over the preview's own route in a Forge box. `say` turns the op's result into the
|
|
28
|
+
* words the model reads.
|
|
29
|
+
*/
|
|
30
|
+
export declare const opTool: <T extends import("./ops.js").OpInputs, K extends keyof T & string, R = unknown>(ops: T, name: K, cfg: import("./ops.js").OpToolConfig<T[K], R>) => import("./ops.js").DerivedTool;
|
|
10
31
|
/** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
|
|
11
32
|
export { nanoid } from "nanoid";
|
|
12
33
|
/**
|
|
@@ -28,3 +49,6 @@ export declare function definePluginChannel<T extends Record<string, {
|
|
|
28
49
|
}) & {
|
|
29
50
|
topicNames: (keyof T & string)[];
|
|
30
51
|
};
|
|
52
|
+
/** Roles the owner composes (docs/13): the pure compiler, for tests and for a screen that previews a definition. */
|
|
53
|
+
export { compileCustomRole, compileEnvelope, CustomRoleError, coerceAttr, coerceAttrs, attrProblem, ATTR_TYPES } from "./db/custom-roles.js";
|
|
54
|
+
export type { CompiledCustomRole, CustomRoleDefinitionRaw, CustomRoleGrant, CustomRolesEnvelope, CustomRolesEnvelopeRaw, AttrType, AttrValue } from "./db/custom-roles.js";
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,34 @@ export * from "./types.js";
|
|
|
2
2
|
export * from "./manifest.js";
|
|
3
3
|
export * from "./helpers.js";
|
|
4
4
|
export * from "./files.js";
|
|
5
|
+
export * from "./assets.js";
|
|
6
|
+
export * from "./labelme.js";
|
|
5
7
|
export * from "./bindings.js";
|
|
6
8
|
/** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
|
|
7
9
|
export * from "./audience.js";
|
|
8
10
|
/** The app's own words for the platform's kinds of caller (S3). */
|
|
9
11
|
export * from "./roles.js";
|
|
12
|
+
/**
|
|
13
|
+
* One declaration per op: `defineOps` in a shared file, `handleOp` in server.ts,
|
|
14
|
+
* `opTool` in app.tsx — a tool's parameters cannot drift from what its op takes.
|
|
15
|
+
*/
|
|
16
|
+
export * from "./ops.js";
|
|
17
|
+
/** A chart from data — one SVG for the app's screen and, rendered, for a tool's answer. */
|
|
18
|
+
export * from "./chart.js";
|
|
19
|
+
/**
|
|
20
|
+
* Editor sync: how a mounted editor takes a change from another device or an agent while the person types —
|
|
21
|
+
* the decision and the merges (records by id, fields, lines). The hook is `useRemoteReconcile` in esoul-sdk/react.
|
|
22
|
+
*/
|
|
23
|
+
export * from "./editor-sync.js";
|
|
24
|
+
import { callPluginOp } from "./helpers.js";
|
|
25
|
+
import { makeOpTool } from "./ops.js";
|
|
26
|
+
/**
|
|
27
|
+
* The agent tool for one op, declared once: `opTool(ops.play, { say })` in app.tsx gives a tool whose
|
|
28
|
+
* parameters ARE the op's zod input and whose call goes to `callPluginOp` — as the acting viewer on
|
|
29
|
+
* the platform, over the preview's own route in a Forge box. `say` turns the op's result into the
|
|
30
|
+
* words the model reads.
|
|
31
|
+
*/
|
|
32
|
+
export const opTool = makeOpTool(callPluginOp);
|
|
10
33
|
/** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
|
|
11
34
|
export { nanoid } from "nanoid";
|
|
12
35
|
/**
|
|
@@ -26,3 +49,5 @@ export function definePluginChannel(args) {
|
|
|
26
49
|
factory.topicNames = Object.keys(args.topics);
|
|
27
50
|
return factory;
|
|
28
51
|
}
|
|
52
|
+
/** Roles the owner composes (docs/13): the pure compiler, for tests and for a screen that previews a definition. */
|
|
53
|
+
export { compileCustomRole, compileEnvelope, CustomRoleError, coerceAttr, coerceAttrs, attrProblem, ATTR_TYPES } from "./db/custom-roles.js";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LabelMe (https://github.com/labelmeai/labelme) label files — pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* A label file is `<image stem>.json` BESIDE its image, in the same folder,
|
|
5
|
+
* with polygon points in ORIGINAL IMAGE PIXELS and `imageData: null` (LabelMe
|
|
6
|
+
* loads the picture through `imagePath`). That layout is the whole contract
|
|
7
|
+
* with a training pipeline: it reads the pairs, nothing else.
|
|
8
|
+
*
|
|
9
|
+
* `pairImagesWithLabels` is the one place "which images are labelled" is
|
|
10
|
+
* decided — the grid's green mark, the progress count and the list a machine
|
|
11
|
+
* downloads for training all read the same answer.
|
|
12
|
+
*/
|
|
13
|
+
import type { FileEntry } from "./files.js";
|
|
14
|
+
export interface LabelPoint {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
/** One polygon of a label file, as an app holds it. */
|
|
19
|
+
export interface LabelShape {
|
|
20
|
+
label: string;
|
|
21
|
+
points: LabelPoint[];
|
|
22
|
+
/** LabelMe's `group_id` — ties shapes together (the edges of one column). */
|
|
23
|
+
groupId?: number | null;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface LabelMeShape {
|
|
27
|
+
label: string;
|
|
28
|
+
points: [number, number][];
|
|
29
|
+
group_id: number | null;
|
|
30
|
+
description: string;
|
|
31
|
+
shape_type: "polygon";
|
|
32
|
+
flags: Record<string, never>;
|
|
33
|
+
}
|
|
34
|
+
export interface LabelMeDoc {
|
|
35
|
+
version: string;
|
|
36
|
+
flags: Record<string, never>;
|
|
37
|
+
shapes: LabelMeShape[];
|
|
38
|
+
imagePath: string;
|
|
39
|
+
imageData: null;
|
|
40
|
+
imageHeight: number;
|
|
41
|
+
imageWidth: number;
|
|
42
|
+
}
|
|
43
|
+
/** The `version` written into a label file — the LabelMe release whose format this is. */
|
|
44
|
+
export declare const LABELME_VERSION = "5.5.0";
|
|
45
|
+
/** The image kinds a labeller opens. */
|
|
46
|
+
export declare const LABEL_IMAGE_EXTS: readonly [".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"];
|
|
47
|
+
/** `frame_001.JPG` → `frame_001` (the pairing key keeps its case; extensions do not count). */
|
|
48
|
+
export declare function fileStem(name: string): string;
|
|
49
|
+
/** `photo.png` → `photo.json`. */
|
|
50
|
+
export declare function labelFileNameFor(imageName: string): string;
|
|
51
|
+
/** Is this file name an image a labeller opens (`LABEL_IMAGE_EXTS`, any case)? */
|
|
52
|
+
export declare function isLabelImageName(name: string): boolean;
|
|
53
|
+
/** Shapes → a LabelMe document. Polygons under three points are dropped: LabelMe refuses them and so does a trainer. */
|
|
54
|
+
export declare function serializeLabelMe(args: {
|
|
55
|
+
shapes: LabelShape[];
|
|
56
|
+
imageName: string;
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}): LabelMeDoc;
|
|
60
|
+
/**
|
|
61
|
+
* A LabelMe document (parsed JSON, or its text) → shapes. Tolerant: anything
|
|
62
|
+
* that is not a polygon with numeric points is skipped, and garbage answers []
|
|
63
|
+
* — a bad label file must never take the labeller down. `size` is the image
|
|
64
|
+
* size the file claims, when it claims one.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseLabelMe(json: unknown): {
|
|
67
|
+
shapes: LabelShape[];
|
|
68
|
+
size: {
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
} | null;
|
|
72
|
+
};
|
|
73
|
+
export interface LabelledImage {
|
|
74
|
+
image: FileEntry;
|
|
75
|
+
/** The label file beside it, or null — unlabelled. */
|
|
76
|
+
label: FileEntry | null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One folder's listing → its images, each with the label file beside it.
|
|
80
|
+
* Pairing is by STEM, case-sensitively first (what LabelMe and a trainer do),
|
|
81
|
+
* then case-insensitively. Two images sharing a stem (`a.jpg`, `a.png`) both
|
|
82
|
+
* pair with the one `a.json`. Order: natural name order.
|
|
83
|
+
*/
|
|
84
|
+
export declare function pairImagesWithLabels(entries: FileEntry[]): LabelledImage[];
|
package/dist/labelme.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** The `version` written into a label file — the LabelMe release whose format this is. */
|
|
2
|
+
export const LABELME_VERSION = "5.5.0";
|
|
3
|
+
/** The image kinds a labeller opens. */
|
|
4
|
+
export const LABEL_IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff"];
|
|
5
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
6
|
+
/** `frame_001.JPG` → `frame_001` (the pairing key keeps its case; extensions do not count). */
|
|
7
|
+
export function fileStem(name) {
|
|
8
|
+
const dot = name.lastIndexOf(".");
|
|
9
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
10
|
+
}
|
|
11
|
+
/** `photo.png` → `photo.json`. */
|
|
12
|
+
export function labelFileNameFor(imageName) {
|
|
13
|
+
return `${fileStem(imageName)}.json`;
|
|
14
|
+
}
|
|
15
|
+
/** Is this file name an image a labeller opens (`LABEL_IMAGE_EXTS`, any case)? */
|
|
16
|
+
export function isLabelImageName(name) {
|
|
17
|
+
const lower = name.toLowerCase();
|
|
18
|
+
return LABEL_IMAGE_EXTS.some((e) => lower.endsWith(e));
|
|
19
|
+
}
|
|
20
|
+
/** Shapes → a LabelMe document. Polygons under three points are dropped: LabelMe refuses them and so does a trainer. */
|
|
21
|
+
export function serializeLabelMe(args) {
|
|
22
|
+
if (!(args.width > 0) || !(args.height > 0))
|
|
23
|
+
throw new Error("serializeLabelMe needs the image's real width and height — a trainer sizes its label map from them");
|
|
24
|
+
return {
|
|
25
|
+
version: LABELME_VERSION,
|
|
26
|
+
flags: {},
|
|
27
|
+
shapes: args.shapes
|
|
28
|
+
.filter((s) => Array.isArray(s.points) && s.points.length >= 3)
|
|
29
|
+
.map((s) => ({
|
|
30
|
+
label: s.label || "object",
|
|
31
|
+
points: s.points.map((p) => [round2(p.x), round2(p.y)]),
|
|
32
|
+
group_id: typeof s.groupId === "number" ? s.groupId : null,
|
|
33
|
+
description: s.description ?? "",
|
|
34
|
+
shape_type: "polygon",
|
|
35
|
+
flags: {},
|
|
36
|
+
})),
|
|
37
|
+
imagePath: args.imageName,
|
|
38
|
+
imageData: null,
|
|
39
|
+
imageHeight: Math.round(args.height),
|
|
40
|
+
imageWidth: Math.round(args.width),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A LabelMe document (parsed JSON, or its text) → shapes. Tolerant: anything
|
|
45
|
+
* that is not a polygon with numeric points is skipped, and garbage answers []
|
|
46
|
+
* — a bad label file must never take the labeller down. `size` is the image
|
|
47
|
+
* size the file claims, when it claims one.
|
|
48
|
+
*/
|
|
49
|
+
export function parseLabelMe(json) {
|
|
50
|
+
let doc = json;
|
|
51
|
+
if (typeof json === "string") {
|
|
52
|
+
try {
|
|
53
|
+
doc = JSON.parse(json);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { shapes: [], size: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!doc || typeof doc !== "object")
|
|
60
|
+
return { shapes: [], size: null };
|
|
61
|
+
const d = doc;
|
|
62
|
+
const size = typeof d.imageWidth === "number" && typeof d.imageHeight === "number" && d.imageWidth > 0 && d.imageHeight > 0 ? { width: d.imageWidth, height: d.imageHeight } : null;
|
|
63
|
+
if (!Array.isArray(d.shapes))
|
|
64
|
+
return { shapes: [], size };
|
|
65
|
+
const shapes = [];
|
|
66
|
+
for (const raw of d.shapes) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
continue;
|
|
69
|
+
const sh = raw;
|
|
70
|
+
if (sh.shape_type && sh.shape_type !== "polygon")
|
|
71
|
+
continue;
|
|
72
|
+
if (!Array.isArray(sh.points))
|
|
73
|
+
continue;
|
|
74
|
+
const points = [];
|
|
75
|
+
for (const p of sh.points) {
|
|
76
|
+
if (Array.isArray(p) && p.length >= 2 && typeof p[0] === "number" && typeof p[1] === "number" && Number.isFinite(p[0]) && Number.isFinite(p[1]))
|
|
77
|
+
points.push({ x: p[0], y: p[1] });
|
|
78
|
+
}
|
|
79
|
+
if (points.length < 3)
|
|
80
|
+
continue;
|
|
81
|
+
shapes.push({
|
|
82
|
+
label: typeof sh.label === "string" && sh.label ? sh.label : "object",
|
|
83
|
+
points,
|
|
84
|
+
...(typeof sh.group_id === "number" ? { groupId: sh.group_id } : {}),
|
|
85
|
+
...(typeof sh.description === "string" && sh.description ? { description: sh.description } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { shapes, size };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One folder's listing → its images, each with the label file beside it.
|
|
92
|
+
* Pairing is by STEM, case-sensitively first (what LabelMe and a trainer do),
|
|
93
|
+
* then case-insensitively. Two images sharing a stem (`a.jpg`, `a.png`) both
|
|
94
|
+
* pair with the one `a.json`. Order: natural name order.
|
|
95
|
+
*/
|
|
96
|
+
export function pairImagesWithLabels(entries) {
|
|
97
|
+
const exact = new Map();
|
|
98
|
+
const loose = new Map();
|
|
99
|
+
for (const e of entries) {
|
|
100
|
+
if (e.kind !== "file" || !e.name.toLowerCase().endsWith(".json"))
|
|
101
|
+
continue;
|
|
102
|
+
const stem = fileStem(e.name);
|
|
103
|
+
// Drive allows two files of one name; the newest is the one a save replaces.
|
|
104
|
+
const prior = exact.get(stem);
|
|
105
|
+
if (!prior || (e.modifiedAt ?? 0) > (prior.modifiedAt ?? 0))
|
|
106
|
+
exact.set(stem, e);
|
|
107
|
+
const lp = loose.get(stem.toLowerCase());
|
|
108
|
+
if (!lp || (e.modifiedAt ?? 0) > (lp.modifiedAt ?? 0))
|
|
109
|
+
loose.set(stem.toLowerCase(), e);
|
|
110
|
+
}
|
|
111
|
+
return entries
|
|
112
|
+
.filter((e) => e.kind === "file" && isLabelImageName(e.name))
|
|
113
|
+
.map((image) => {
|
|
114
|
+
const stem = fileStem(image.name);
|
|
115
|
+
return { image, label: exact.get(stem) ?? loose.get(stem.toLowerCase()) ?? null };
|
|
116
|
+
})
|
|
117
|
+
.sort((a, b) => a.image.name.localeCompare(b.image.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
118
|
+
}
|