@camstack/addon-post-analysis 1.2.342 → 1.2.344
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/dist/{clip-model-registry-Dhjpee-Q.mjs → clip-model-registry-C44gfwjm.mjs} +2279 -62
- package/dist/{clip-model-registry-ClEoe-WP.js → clip-model-registry-Ca_IbZyD.js} +2290 -61
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-XzFWnlu-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D73dyYqt.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C-2BbuN4.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-ZZJLA-YE.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-C6P0R93E.mjs → hostInit-h_8fvfFv.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2420 -359
- package/dist/pipeline-analytics/index.mjs +2412 -351
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CsAksj5u.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Cp4eQz57.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_clip_model_registry = require("../clip-model-registry-
|
|
5
|
+
const require_clip_model_registry = require("../clip-model-registry-Ca_IbZyD.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_clip_model_registry.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -23,6 +23,8 @@ let node_timers_promises = require("node:timers/promises");
|
|
|
23
23
|
let node_util = require("node:util");
|
|
24
24
|
let node_buffer = require("node:buffer");
|
|
25
25
|
let node_zlib = require("node:zlib");
|
|
26
|
+
let node_http = require("node:http");
|
|
27
|
+
let node_https = require("node:https");
|
|
26
28
|
//#region src/pipeline-analytics/media-occupancy.ts
|
|
27
29
|
/** The bare type ref legacy NULL-stamped media rows belong to. */
|
|
28
30
|
var LEGACY_MEDIA_TYPE = "eventMedia";
|
|
@@ -5837,16 +5839,16 @@ function ncDetectionSummary(texts, counts) {
|
|
|
5837
5839
|
//#region src/notification-center/timelapse/detection-counts.ts
|
|
5838
5840
|
/** One normalisation, used on both sides of every comparison — a rule that
|
|
5839
5841
|
* named `Person` must match a track classified `person`. */
|
|
5840
|
-
function normalise(name) {
|
|
5842
|
+
function normalise$1(name) {
|
|
5841
5843
|
return name.trim().toLowerCase();
|
|
5842
5844
|
}
|
|
5843
5845
|
/** Count per class over the tracks, honouring the rule's selection. */
|
|
5844
5846
|
function countDetections(input) {
|
|
5845
|
-
const selected = (input.reportClasses ?? []).map(normalise).filter((c) => c.length > 0);
|
|
5847
|
+
const selected = (input.reportClasses ?? []).map(normalise$1).filter((c) => c.length > 0);
|
|
5846
5848
|
const seen = /* @__PURE__ */ new Map();
|
|
5847
5849
|
for (const t of input.tracks) {
|
|
5848
5850
|
if (t.className === void 0) continue;
|
|
5849
|
-
const key = normalise(t.className);
|
|
5851
|
+
const key = normalise$1(t.className);
|
|
5850
5852
|
if (key.length === 0) continue;
|
|
5851
5853
|
if (selected.length > 0 && !selected.includes(key)) continue;
|
|
5852
5854
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
@@ -7028,18 +7030,44 @@ function clockCondition(now) {
|
|
|
7028
7030
|
* does not.
|
|
7029
7031
|
*/
|
|
7030
7032
|
function resolveCondition(input) {
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
+
return resolveConditionDetailed(input).condition;
|
|
7034
|
+
}
|
|
7035
|
+
/**
|
|
7036
|
+
* {@link resolveCondition}, plus the layer that answered. A consumer that
|
|
7037
|
+
* reports a day/night split (the retrain export, D785) must be able to tell a
|
|
7038
|
+
* measured IR frame from an almanac or a clock guess.
|
|
7039
|
+
*/
|
|
7040
|
+
function resolveConditionDetailed(input) {
|
|
7041
|
+
if (input.forcedMode === "day" || input.forcedMode === "night") return {
|
|
7042
|
+
condition: input.forcedMode,
|
|
7043
|
+
source: "forced"
|
|
7044
|
+
};
|
|
7045
|
+
if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return {
|
|
7046
|
+
condition: IR,
|
|
7047
|
+
source: "ir-chroma"
|
|
7048
|
+
};
|
|
7033
7049
|
if (input.coordinates) {
|
|
7034
7050
|
const times = sunTimes(input.coordinates.lat, input.coordinates.lng, input.now);
|
|
7035
7051
|
if (times) {
|
|
7036
7052
|
const t = input.now.getTime();
|
|
7037
|
-
if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return
|
|
7038
|
-
|
|
7039
|
-
|
|
7053
|
+
if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return {
|
|
7054
|
+
condition: DAWN,
|
|
7055
|
+
source: "sun"
|
|
7056
|
+
};
|
|
7057
|
+
if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return {
|
|
7058
|
+
condition: DUSK,
|
|
7059
|
+
source: "sun"
|
|
7060
|
+
};
|
|
7061
|
+
return {
|
|
7062
|
+
condition: t >= times.rise && t < times.set ? DAY : NIGHT,
|
|
7063
|
+
source: "sun"
|
|
7064
|
+
};
|
|
7040
7065
|
}
|
|
7041
7066
|
}
|
|
7042
|
-
return
|
|
7067
|
+
return {
|
|
7068
|
+
condition: clockCondition(input.now),
|
|
7069
|
+
source: "clock"
|
|
7070
|
+
};
|
|
7043
7071
|
}
|
|
7044
7072
|
//#endregion
|
|
7045
7073
|
//#region src/shared/llm-vision/parse-model-json.ts
|
|
@@ -7172,8 +7200,8 @@ async function raceTimeout(call, timeoutMs) {
|
|
|
7172
7200
|
* detail the encoder can see.
|
|
7173
7201
|
*/
|
|
7174
7202
|
async function downscaleJpeg(bytes, maxPx) {
|
|
7175
|
-
const { default: sharp$
|
|
7176
|
-
const out = await sharp$
|
|
7203
|
+
const { default: sharp$20 } = await import("sharp");
|
|
7204
|
+
const out = await sharp$20(Buffer.from(bytes)).resize({
|
|
7177
7205
|
width: maxPx,
|
|
7178
7206
|
height: maxPx,
|
|
7179
7207
|
fit: "inside",
|
|
@@ -19384,8 +19412,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
19384
19412
|
];
|
|
19385
19413
|
if (bbox === void 0) return files;
|
|
19386
19414
|
try {
|
|
19387
|
-
const { default: sharp$
|
|
19388
|
-
const meta = await sharp$
|
|
19415
|
+
const { default: sharp$19 } = await import("sharp");
|
|
19416
|
+
const meta = await sharp$19(Buffer.from(jpeg)).metadata();
|
|
19389
19417
|
const W = meta.width ?? 0;
|
|
19390
19418
|
const H = meta.height ?? 0;
|
|
19391
19419
|
if (W === 0 || H === 0) return files;
|
|
@@ -19393,7 +19421,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
19393
19421
|
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
19394
19422
|
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
19395
19423
|
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
19396
|
-
const cropBuf = await sharp$
|
|
19424
|
+
const cropBuf = await sharp$19(Buffer.from(jpeg)).extract({
|
|
19397
19425
|
left,
|
|
19398
19426
|
top,
|
|
19399
19427
|
width,
|
|
@@ -19415,7 +19443,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
|
19415
19443
|
});
|
|
19416
19444
|
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
19417
19445
|
const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
|
|
19418
|
-
const boxedBuf = await sharp$
|
|
19446
|
+
const boxedBuf = await sharp$19(Buffer.from(jpeg)).composite([{
|
|
19419
19447
|
input: overlay,
|
|
19420
19448
|
top: 0,
|
|
19421
19449
|
left: 0
|
|
@@ -26452,8 +26480,8 @@ var NcDispatcher = class {
|
|
|
26452
26480
|
try {
|
|
26453
26481
|
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
26454
26482
|
if (points.length === 0) return null;
|
|
26455
|
-
const { default: sharp$
|
|
26456
|
-
const img = sharp$
|
|
26483
|
+
const { default: sharp$18 } = await import("sharp");
|
|
26484
|
+
const img = sharp$18(Buffer.from(jpeg));
|
|
26457
26485
|
const meta = await img.metadata();
|
|
26458
26486
|
const W = meta.width ?? 0;
|
|
26459
26487
|
const H = meta.height ?? 0;
|
|
@@ -70560,8 +70588,11 @@ var TrackStore = class {
|
|
|
70560
70588
|
* RetrainFrameStore — the COPIES the retrain dataset owns.
|
|
70561
70589
|
*
|
|
70562
70590
|
* Collection: `pipeline-analytics:retrain-frames`.
|
|
70563
|
-
* Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg
|
|
70564
|
-
*
|
|
70591
|
+
* Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg` under the `artifacts`
|
|
70592
|
+
* class root — the dataset is that class's fourth tenant (D341), addressed
|
|
70593
|
+
* through the permanent row ref {@link RETRAIN_MEDIA_LOCATION}. Never the
|
|
70594
|
+
* footage volume: the 2026-08-17 recordings wipe took the whole dataset with
|
|
70595
|
+
* it while it lived on `eventMedia` (D197). See D781.
|
|
70565
70596
|
*
|
|
70566
70597
|
* ## Why a copy, and why at selection time
|
|
70567
70598
|
*
|
|
@@ -70588,20 +70619,43 @@ var TrackStore = class {
|
|
|
70588
70619
|
*/
|
|
70589
70620
|
/**
|
|
70590
70621
|
* @durable class=ledger owner=pipeline-analytics
|
|
70591
|
-
* write="one row per frame the operator SELECTS into the retrain dataset
|
|
70592
|
-
*
|
|
70593
|
-
*
|
|
70594
|
-
* the
|
|
70595
|
-
*
|
|
70596
|
-
*
|
|
70622
|
+
* write="one row per frame the operator SELECTS into the retrain dataset, or adds
|
|
70623
|
+
* from the recording at an instant (storeRecordingStill, origin 'recording', no
|
|
70624
|
+
* track). The copy is taken eagerly at selection (copyFrame), not referenced, so
|
|
70625
|
+
* the row records a blob the dataset owns outright; re-selecting the same source
|
|
70626
|
+
* (or the same camera instant) reuses the existing copy rather than writing a
|
|
70627
|
+
* second one."
|
|
70628
|
+
* retention="none — the blobs sit in the 'retrain/' subtree of the artifacts class
|
|
70629
|
+
* (D781), which no owner cascade and no age sweep walks, which is exactly what makes a 'trained' track evictable
|
|
70597
70630
|
* again (D81). Rows go only when the operator deselects the frame (deleteFrame,
|
|
70598
70631
|
* after its annotations). Bounded only by operator selection — nothing caps the
|
|
70599
70632
|
* table or the disk it consumes."
|
|
70600
70633
|
*/
|
|
70601
70634
|
var RETRAIN_FRAMES_COLLECTION = "pipeline-analytics:retrain-frames";
|
|
70602
|
-
/**
|
|
70603
|
-
*
|
|
70604
|
-
|
|
70635
|
+
/**
|
|
70636
|
+
* The PERMANENT location ref every retrain copy is written under (D781).
|
|
70637
|
+
*
|
|
70638
|
+
* Not a declared storage class: it is the rows' `locationId`, which the
|
|
70639
|
+
* media-storage wiring maps onto the `artifacts` class root — exactly how the
|
|
70640
|
+
* enrolled gallery's `galleryMedia` ref works (D341). The copies are curation
|
|
70641
|
+
* work with NO retention, so they share the one durable class rather than the
|
|
70642
|
+
* footage class, which is quota-evicted, drained and wipeable per disk. Rows
|
|
70643
|
+
* are never restamped for a root move: the ref is remapped instead, and the
|
|
70644
|
+
* singleton repoint carries the whole `artifacts` tree, `retrain/` included.
|
|
70645
|
+
*/
|
|
70646
|
+
var RETRAIN_MEDIA_LOCATION = "retrainMedia";
|
|
70647
|
+
/**
|
|
70648
|
+
* Where copies taken before D781 live: a NULL `locationId` meant "wherever
|
|
70649
|
+
* `eventMedia` points", and a concrete `eventMedia:*` id was stamped by the
|
|
70650
|
+
* seal or a relocate pass. The boot reconcile
|
|
70651
|
+
* ({@link RetrainFrameStore.relocateLegacyCopies}) moves them onto
|
|
70652
|
+
* {@link RETRAIN_MEDIA_LOCATION}; until it has, reads resolve them here.
|
|
70653
|
+
*/
|
|
70654
|
+
var LEGACY_RETRAIN_MEDIA_LOCATION = "eventMedia";
|
|
70655
|
+
/** Is this row already on the durable retrain tenant? */
|
|
70656
|
+
function rowIsDurableRetrainCopy(data) {
|
|
70657
|
+
return data?.["locationId"] === RETRAIN_MEDIA_LOCATION;
|
|
70658
|
+
}
|
|
70605
70659
|
/** Path prefix of every retrain copy. Never `<deviceId>/events/…`: the orphan
|
|
70606
70660
|
* audit resolves ownership from INDEX rows, but a future filesystem-side tool
|
|
70607
70661
|
* must be able to tell the two corpora apart by path alone. */
|
|
@@ -70659,11 +70713,45 @@ var RETRAIN_FRAME_COLUMNS = [
|
|
|
70659
70713
|
notNull: true
|
|
70660
70714
|
},
|
|
70661
70715
|
(
|
|
70662
|
-
/**
|
|
70663
|
-
*
|
|
70716
|
+
/** `retrainMedia` for every copy taken since D781. NULL (the eventMedia
|
|
70717
|
+
* default) or a concrete `eventMedia:*` id only on a pre-D781 copy the boot
|
|
70718
|
+
* reconcile has not moved yet. */
|
|
70664
70719
|
{
|
|
70665
70720
|
name: "locationId",
|
|
70666
70721
|
type: "TEXT"
|
|
70722
|
+
}),
|
|
70723
|
+
(
|
|
70724
|
+
/** When the CAMERA captured the frame — the source media row's timestamp.
|
|
70725
|
+
* NULL on a copy taken before D785. */
|
|
70726
|
+
{
|
|
70727
|
+
name: "capturedAt",
|
|
70728
|
+
type: "INTEGER"
|
|
70729
|
+
}),
|
|
70730
|
+
(
|
|
70731
|
+
/** Mean channel spread of the copy (`chromaSpread`), measured once at copy
|
|
70732
|
+
* time; below `IR_CHROMA_THRESHOLD` the frame is IR. NULL = not measured. */
|
|
70733
|
+
{
|
|
70734
|
+
name: "chromaSpread",
|
|
70735
|
+
type: "REAL"
|
|
70736
|
+
}),
|
|
70737
|
+
(
|
|
70738
|
+
/** The operator's "every subject in this frame is boxed". NULL = no. */
|
|
70739
|
+
{
|
|
70740
|
+
name: "complete",
|
|
70741
|
+
type: "BOOLEAN"
|
|
70742
|
+
}),
|
|
70743
|
+
(
|
|
70744
|
+
/** `recording` for a still taken from the recording at an instant (no
|
|
70745
|
+
* track, no source media row). NULL = a track copy. */
|
|
70746
|
+
{
|
|
70747
|
+
name: "origin",
|
|
70748
|
+
type: "TEXT"
|
|
70749
|
+
}),
|
|
70750
|
+
(
|
|
70751
|
+
/** The recording instant a `recording` still was asked for. */
|
|
70752
|
+
{
|
|
70753
|
+
name: "epochMs",
|
|
70754
|
+
type: "INTEGER"
|
|
70667
70755
|
})
|
|
70668
70756
|
];
|
|
70669
70757
|
var RETRAIN_FRAME_INDEXES = [
|
|
@@ -70689,6 +70777,8 @@ var RETRAIN_FRAME_INDEXES = [
|
|
|
70689
70777
|
columns: ["locationId"]
|
|
70690
70778
|
})
|
|
70691
70779
|
];
|
|
70780
|
+
/** Ids per `IN (…)` page in {@link RetrainFrameStore.getMany}. */
|
|
70781
|
+
var GET_MANY_PAGE = 500;
|
|
70692
70782
|
/**
|
|
70693
70783
|
* The copy's id, derived from `(trackId, sourceMediaId)`.
|
|
70694
70784
|
*
|
|
@@ -70701,8 +70791,24 @@ var RETRAIN_FRAME_INDEXES = [
|
|
|
70701
70791
|
function retrainFrameId(trackId, sourceMediaId) {
|
|
70702
70792
|
return `retrain:${trackId}:${(0, node_crypto.createHash)("sha1").update(String(sourceMediaId)).digest("hex").slice(0, 16)}`;
|
|
70703
70793
|
}
|
|
70704
|
-
|
|
70705
|
-
|
|
70794
|
+
/**
|
|
70795
|
+
* A recording still's id, derived from `(deviceId, epochMs)` — the same moment
|
|
70796
|
+
* of the same camera is one copy, however many times it is added.
|
|
70797
|
+
*/
|
|
70798
|
+
function recordingFrameId(deviceId, epochMs) {
|
|
70799
|
+
const digest = (0, node_crypto.createHash)("sha1").update(String(epochMs)).digest("hex").slice(0, 16);
|
|
70800
|
+
return `retrain:rec:${String(deviceId)}:${digest}`;
|
|
70801
|
+
}
|
|
70802
|
+
/** The directory a recording still sits in, beside the track directories. */
|
|
70803
|
+
var RECORDING_STILL_DIR = "recording";
|
|
70804
|
+
function framePath(frame) {
|
|
70805
|
+
const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
|
|
70806
|
+
const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
|
|
70807
|
+
return `${RETRAIN_PATH_PREFIX}/${frame.deviceId}/${dir}/${leaf}.jpg`;
|
|
70808
|
+
}
|
|
70809
|
+
/** A stored number, or `undefined` for NULL / absent / junk. */
|
|
70810
|
+
function finiteNumber(value) {
|
|
70811
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
70706
70812
|
}
|
|
70707
70813
|
var RetrainFrameStore = class {
|
|
70708
70814
|
store;
|
|
@@ -70711,7 +70817,7 @@ var RetrainFrameStore = class {
|
|
|
70711
70817
|
readSource;
|
|
70712
70818
|
now;
|
|
70713
70819
|
writeGate;
|
|
70714
|
-
|
|
70820
|
+
measureChroma;
|
|
70715
70821
|
constructor(deps) {
|
|
70716
70822
|
this.store = deps.store;
|
|
70717
70823
|
this.storage = deps.storage;
|
|
@@ -70719,28 +70825,20 @@ var RetrainFrameStore = class {
|
|
|
70719
70825
|
this.readSource = deps.readSource;
|
|
70720
70826
|
this.now = deps.now;
|
|
70721
70827
|
this.writeGate = deps.writeGate;
|
|
70722
|
-
this.
|
|
70828
|
+
this.measureChroma = deps.measureChroma;
|
|
70723
70829
|
}
|
|
70724
|
-
/**
|
|
70725
|
-
|
|
70726
|
-
|
|
70727
|
-
* `null` = "no concrete location was resolved", which persists as the legacy
|
|
70728
|
-
* NULL row. A resolver that throws must not refuse the copy — losing an
|
|
70729
|
-
* operator's selection is worse than an unstamped row the seal can fix — but
|
|
70730
|
-
* it says so, because that branch re-creates the exact population a
|
|
70731
|
-
* non-blocking migration is gated on.
|
|
70732
|
-
*/
|
|
70733
|
-
async resolveWriteLocation(deviceId) {
|
|
70734
|
-
if (!this.resolveEventMediaLocation) return null;
|
|
70830
|
+
/** The copy's chroma, or `undefined` when it cannot be measured. */
|
|
70831
|
+
async chromaOf(bytes, deviceId) {
|
|
70832
|
+
if (!this.measureChroma) return void 0;
|
|
70735
70833
|
try {
|
|
70736
|
-
const
|
|
70737
|
-
return
|
|
70834
|
+
const value = await this.measureChroma(bytes);
|
|
70835
|
+
return Number.isFinite(value) ? value : void 0;
|
|
70738
70836
|
} catch (err) {
|
|
70739
|
-
this.logger.warn("retrain copy could not
|
|
70837
|
+
this.logger.warn("retrain copy chroma could not be measured — its day/night falls back to the sun", {
|
|
70740
70838
|
tags: { deviceId },
|
|
70741
70839
|
meta: { error: String(err) }
|
|
70742
70840
|
});
|
|
70743
|
-
return
|
|
70841
|
+
return;
|
|
70744
70842
|
}
|
|
70745
70843
|
}
|
|
70746
70844
|
static async declare(store) {
|
|
@@ -70751,9 +70849,11 @@ var RetrainFrameStore = class {
|
|
|
70751
70849
|
});
|
|
70752
70850
|
}
|
|
70753
70851
|
rowToFrame(id, data) {
|
|
70852
|
+
const origin = data["origin"] === "recording" ? "recording" : "track";
|
|
70754
70853
|
return {
|
|
70755
70854
|
frameId: id,
|
|
70756
70855
|
deviceId: Number(data["deviceId"] ?? 0),
|
|
70856
|
+
origin,
|
|
70757
70857
|
trackId: String(data["trackId"] ?? ""),
|
|
70758
70858
|
sourceMediaId: Number(data["sourceMediaId"] ?? 0),
|
|
70759
70859
|
sourceKind: String(data["sourceKind"] ?? "keyFrame"),
|
|
@@ -70761,9 +70861,57 @@ var RetrainFrameStore = class {
|
|
|
70761
70861
|
width: Number(data["width"] ?? 0),
|
|
70762
70862
|
height: Number(data["height"] ?? 0),
|
|
70763
70863
|
copiedAt: Number(data["copiedAt"] ?? 0),
|
|
70764
|
-
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
|
|
70864
|
+
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null,
|
|
70865
|
+
...finiteNumber(data["capturedAt"]) !== void 0 ? { capturedAt: finiteNumber(data["capturedAt"]) } : {},
|
|
70866
|
+
...finiteNumber(data["chromaSpread"]) !== void 0 ? { chromaSpread: finiteNumber(data["chromaSpread"]) } : {},
|
|
70867
|
+
complete: data["complete"] === true || data["complete"] === 1,
|
|
70868
|
+
...finiteNumber(data["epochMs"]) !== void 0 ? { epochMs: finiteNumber(data["epochMs"]) } : {}
|
|
70765
70869
|
};
|
|
70766
70870
|
}
|
|
70871
|
+
/** The copies with these ids, in the order given; unknown ids are skipped. */
|
|
70872
|
+
async getMany(frameIds) {
|
|
70873
|
+
if (frameIds.length === 0) return [];
|
|
70874
|
+
try {
|
|
70875
|
+
const byId = /* @__PURE__ */ new Map();
|
|
70876
|
+
for (let i = 0; i < frameIds.length; i += GET_MANY_PAGE) {
|
|
70877
|
+
const page = frameIds.slice(i, i + GET_MANY_PAGE);
|
|
70878
|
+
const rows = await this.store.query.query({
|
|
70879
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
70880
|
+
filter: {
|
|
70881
|
+
whereIn: { id: [...page] },
|
|
70882
|
+
limit: page.length
|
|
70883
|
+
}
|
|
70884
|
+
});
|
|
70885
|
+
for (const r of rows) if (typeof r.id === "string") byId.set(r.id, this.rowToFrame(r.id, r.data));
|
|
70886
|
+
}
|
|
70887
|
+
return frameIds.flatMap((id) => {
|
|
70888
|
+
const frame = byId.get(id);
|
|
70889
|
+
return frame !== void 0 ? [frame] : [];
|
|
70890
|
+
});
|
|
70891
|
+
} catch (err) {
|
|
70892
|
+
this.logger.warn("RetrainFrameStore.getMany failed", { meta: {
|
|
70893
|
+
frames: frameIds.length,
|
|
70894
|
+
error: String(err)
|
|
70895
|
+
} });
|
|
70896
|
+
return [];
|
|
70897
|
+
}
|
|
70898
|
+
}
|
|
70899
|
+
/** Every camera the dataset holds a copy of — a recording still can be the
|
|
70900
|
+
* only thing a camera contributes, with no track anywhere. */
|
|
70901
|
+
async listDeviceIds() {
|
|
70902
|
+
try {
|
|
70903
|
+
const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
|
|
70904
|
+
const ids = /* @__PURE__ */ new Set();
|
|
70905
|
+
for (const r of rows) {
|
|
70906
|
+
const id = Number(r.data["deviceId"]);
|
|
70907
|
+
if (Number.isInteger(id) && id > 0) ids.add(id);
|
|
70908
|
+
}
|
|
70909
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
70910
|
+
} catch (err) {
|
|
70911
|
+
this.logger.warn("RetrainFrameStore.listDeviceIds failed", { meta: { error: String(err) } });
|
|
70912
|
+
return [];
|
|
70913
|
+
}
|
|
70914
|
+
}
|
|
70767
70915
|
/** The copy with this id, or `null`. Index read only — no blob is touched. */
|
|
70768
70916
|
async get(frameId) {
|
|
70769
70917
|
try {
|
|
@@ -70780,8 +70928,10 @@ var RetrainFrameStore = class {
|
|
|
70780
70928
|
return null;
|
|
70781
70929
|
}
|
|
70782
70930
|
}
|
|
70783
|
-
/** Every copy taken from this track, oldest first.
|
|
70931
|
+
/** Every copy taken from this track, oldest first. A recording still has
|
|
70932
|
+
* no track and is never answered here. */
|
|
70784
70933
|
async listByTrack(trackId) {
|
|
70934
|
+
if (trackId === "") return [];
|
|
70785
70935
|
try {
|
|
70786
70936
|
return (await this.store.query.query({
|
|
70787
70937
|
collection: RETRAIN_FRAMES_COLLECTION,
|
|
@@ -70801,6 +70951,31 @@ var RetrainFrameStore = class {
|
|
|
70801
70951
|
return [];
|
|
70802
70952
|
}
|
|
70803
70953
|
}
|
|
70954
|
+
/**
|
|
70955
|
+
* The copies of one camera the operator marked complete. A complete frame
|
|
70956
|
+
* with no box is a hard negative, so an export cannot find these through
|
|
70957
|
+
* the annotation table alone.
|
|
70958
|
+
*/
|
|
70959
|
+
async listCompleteByDevice(deviceId, limit) {
|
|
70960
|
+
try {
|
|
70961
|
+
return (await this.store.query.query({
|
|
70962
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
70963
|
+
filter: {
|
|
70964
|
+
where: {
|
|
70965
|
+
deviceId,
|
|
70966
|
+
complete: true
|
|
70967
|
+
},
|
|
70968
|
+
limit
|
|
70969
|
+
}
|
|
70970
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToFrame(r.id, r.data));
|
|
70971
|
+
} catch (err) {
|
|
70972
|
+
this.logger.warn("RetrainFrameStore.listCompleteByDevice failed", {
|
|
70973
|
+
tags: { deviceId },
|
|
70974
|
+
meta: { error: String(err) }
|
|
70975
|
+
});
|
|
70976
|
+
return [];
|
|
70977
|
+
}
|
|
70978
|
+
}
|
|
70804
70979
|
/** How many copies each of these tracks already owns — one query, so the
|
|
70805
70980
|
* staging list can badge "worked / not worked" without N round trips. */
|
|
70806
70981
|
async countsByTrack(trackIds) {
|
|
@@ -70829,8 +71004,8 @@ var RetrainFrameStore = class {
|
|
|
70829
71004
|
async readFrame(frame) {
|
|
70830
71005
|
try {
|
|
70831
71006
|
return await this.storage.read({
|
|
70832
|
-
location: frame.locationId ??
|
|
70833
|
-
relativePath: framePath(frame
|
|
71007
|
+
location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
|
|
71008
|
+
relativePath: framePath(frame)
|
|
70834
71009
|
});
|
|
70835
71010
|
} catch (err) {
|
|
70836
71011
|
this.logger.warn("retrain frame copy unreadable", {
|
|
@@ -70899,11 +71074,17 @@ var RetrainFrameStore = class {
|
|
|
70899
71074
|
reason: "unreadable-image"
|
|
70900
71075
|
};
|
|
70901
71076
|
}
|
|
70902
|
-
const path = framePath(
|
|
70903
|
-
|
|
71077
|
+
const path = framePath({
|
|
71078
|
+
...input,
|
|
71079
|
+
frameId,
|
|
71080
|
+
origin: "track"
|
|
71081
|
+
});
|
|
71082
|
+
const locationId = RETRAIN_MEDIA_LOCATION;
|
|
71083
|
+
const chromaSpread = await this.chromaOf(bytes, input.deviceId);
|
|
70904
71084
|
const frame = {
|
|
70905
71085
|
frameId,
|
|
70906
71086
|
deviceId: input.deviceId,
|
|
71087
|
+
origin: "track",
|
|
70907
71088
|
trackId: input.trackId,
|
|
70908
71089
|
sourceMediaId: input.sourceMediaId,
|
|
70909
71090
|
sourceKind: input.sourceKind,
|
|
@@ -70911,11 +71092,14 @@ var RetrainFrameStore = class {
|
|
|
70911
71092
|
width: size.width,
|
|
70912
71093
|
height: size.height,
|
|
70913
71094
|
copiedAt: this.now(),
|
|
70914
|
-
locationId
|
|
71095
|
+
locationId,
|
|
71096
|
+
...input.capturedAt !== void 0 ? { capturedAt: input.capturedAt } : {},
|
|
71097
|
+
...chromaSpread !== void 0 ? { chromaSpread } : {},
|
|
71098
|
+
complete: false
|
|
70915
71099
|
};
|
|
70916
71100
|
const persist = async () => {
|
|
70917
71101
|
await this.storage.write({
|
|
70918
|
-
location: locationId
|
|
71102
|
+
location: locationId,
|
|
70919
71103
|
relativePath: path,
|
|
70920
71104
|
data: bytes
|
|
70921
71105
|
});
|
|
@@ -70932,7 +71116,10 @@ var RetrainFrameStore = class {
|
|
|
70932
71116
|
width: frame.width,
|
|
70933
71117
|
height: frame.height,
|
|
70934
71118
|
copiedAt: frame.copiedAt,
|
|
70935
|
-
locationId: frame.locationId
|
|
71119
|
+
locationId: frame.locationId,
|
|
71120
|
+
capturedAt: frame.capturedAt ?? null,
|
|
71121
|
+
chromaSpread: frame.chromaSpread ?? null,
|
|
71122
|
+
complete: false
|
|
70936
71123
|
}
|
|
70937
71124
|
});
|
|
70938
71125
|
};
|
|
@@ -70972,6 +71159,136 @@ var RetrainFrameStore = class {
|
|
|
70972
71159
|
};
|
|
70973
71160
|
}
|
|
70974
71161
|
/**
|
|
71162
|
+
* Store a still decoded from the RECORDING at `epochMs` — a frame with no
|
|
71163
|
+
* track and no media row, usually the one the model saw nothing in.
|
|
71164
|
+
*
|
|
71165
|
+
* Same contract as {@link copyFrame}: an existing copy of that moment is
|
|
71166
|
+
* returned untouched; the blob lands before the row; a refusal is named.
|
|
71167
|
+
* `capturedAt` IS the instant — the camera saw this frame then.
|
|
71168
|
+
*/
|
|
71169
|
+
async storeRecordingStill(input) {
|
|
71170
|
+
const frameId = recordingFrameId(input.deviceId, input.epochMs);
|
|
71171
|
+
const existing = await this.get(frameId);
|
|
71172
|
+
if (existing !== null) return {
|
|
71173
|
+
kind: "copied",
|
|
71174
|
+
frame: existing,
|
|
71175
|
+
reused: true
|
|
71176
|
+
};
|
|
71177
|
+
const size = readJpegSize(input.bytes);
|
|
71178
|
+
if (size === null) {
|
|
71179
|
+
this.logger.warn("retrain recording still refused — not a readable JPEG", {
|
|
71180
|
+
tags: { deviceId: input.deviceId },
|
|
71181
|
+
meta: {
|
|
71182
|
+
epochMs: input.epochMs,
|
|
71183
|
+
bytes: input.bytes.length
|
|
71184
|
+
}
|
|
71185
|
+
});
|
|
71186
|
+
return {
|
|
71187
|
+
kind: "refused",
|
|
71188
|
+
reason: "unreadable-image"
|
|
71189
|
+
};
|
|
71190
|
+
}
|
|
71191
|
+
const chromaSpread = await this.chromaOf(input.bytes, input.deviceId);
|
|
71192
|
+
const frame = {
|
|
71193
|
+
frameId,
|
|
71194
|
+
deviceId: input.deviceId,
|
|
71195
|
+
origin: "recording",
|
|
71196
|
+
trackId: "",
|
|
71197
|
+
sourceMediaId: 0,
|
|
71198
|
+
sourceKind: "fullFrame",
|
|
71199
|
+
sizeBytes: input.bytes.length,
|
|
71200
|
+
width: size.width,
|
|
71201
|
+
height: size.height,
|
|
71202
|
+
copiedAt: this.now(),
|
|
71203
|
+
locationId: RETRAIN_MEDIA_LOCATION,
|
|
71204
|
+
capturedAt: input.epochMs,
|
|
71205
|
+
...chromaSpread !== void 0 ? { chromaSpread } : {},
|
|
71206
|
+
complete: false,
|
|
71207
|
+
epochMs: input.epochMs
|
|
71208
|
+
};
|
|
71209
|
+
const path = framePath(frame);
|
|
71210
|
+
const persist = async () => {
|
|
71211
|
+
await this.storage.write({
|
|
71212
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71213
|
+
relativePath: path,
|
|
71214
|
+
data: input.bytes
|
|
71215
|
+
});
|
|
71216
|
+
await this.store.set.mutate({
|
|
71217
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71218
|
+
key: frameId,
|
|
71219
|
+
value: {
|
|
71220
|
+
deviceId: frame.deviceId,
|
|
71221
|
+
trackId: "",
|
|
71222
|
+
sourceMediaId: 0,
|
|
71223
|
+
sourceKind: frame.sourceKind,
|
|
71224
|
+
path,
|
|
71225
|
+
sizeBytes: frame.sizeBytes,
|
|
71226
|
+
width: frame.width,
|
|
71227
|
+
height: frame.height,
|
|
71228
|
+
copiedAt: frame.copiedAt,
|
|
71229
|
+
locationId: RETRAIN_MEDIA_LOCATION,
|
|
71230
|
+
capturedAt: input.epochMs,
|
|
71231
|
+
chromaSpread: frame.chromaSpread ?? null,
|
|
71232
|
+
complete: false,
|
|
71233
|
+
origin: "recording",
|
|
71234
|
+
epochMs: input.epochMs
|
|
71235
|
+
}
|
|
71236
|
+
});
|
|
71237
|
+
};
|
|
71238
|
+
try {
|
|
71239
|
+
if (this.writeGate) await this.writeGate.run(persist);
|
|
71240
|
+
else await persist();
|
|
71241
|
+
} catch (err) {
|
|
71242
|
+
this.logger.error("retrain recording still failed to persist", {
|
|
71243
|
+
tags: { deviceId: input.deviceId },
|
|
71244
|
+
meta: {
|
|
71245
|
+
frameId,
|
|
71246
|
+
epochMs: input.epochMs,
|
|
71247
|
+
error: String(err)
|
|
71248
|
+
}
|
|
71249
|
+
});
|
|
71250
|
+
return {
|
|
71251
|
+
kind: "refused",
|
|
71252
|
+
reason: "write-failed"
|
|
71253
|
+
};
|
|
71254
|
+
}
|
|
71255
|
+
this.logger.info("retrain recording still stored in the dataset", {
|
|
71256
|
+
tags: { deviceId: input.deviceId },
|
|
71257
|
+
meta: {
|
|
71258
|
+
frameId,
|
|
71259
|
+
epochMs: input.epochMs,
|
|
71260
|
+
width: frame.width,
|
|
71261
|
+
height: frame.height
|
|
71262
|
+
}
|
|
71263
|
+
});
|
|
71264
|
+
return {
|
|
71265
|
+
kind: "copied",
|
|
71266
|
+
frame,
|
|
71267
|
+
reused: false
|
|
71268
|
+
};
|
|
71269
|
+
}
|
|
71270
|
+
/**
|
|
71271
|
+
* The operator's "every subject in this frame is boxed" (D785 context: an
|
|
71272
|
+
* unboxed subject is a false positive to a detector evaluation). Only the
|
|
71273
|
+
* operator sets it; nothing derives it from the annotation count, because a
|
|
71274
|
+
* frame with zero boxes may be a complete hard negative.
|
|
71275
|
+
*
|
|
71276
|
+
* @returns the updated frame, or `null` when no such copy exists.
|
|
71277
|
+
*/
|
|
71278
|
+
async setComplete(frameId, complete) {
|
|
71279
|
+
const existing = await this.get(frameId);
|
|
71280
|
+
if (existing === null) return null;
|
|
71281
|
+
await this.store.update.mutate({
|
|
71282
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71283
|
+
id: frameId,
|
|
71284
|
+
data: { complete }
|
|
71285
|
+
});
|
|
71286
|
+
return {
|
|
71287
|
+
...existing,
|
|
71288
|
+
complete
|
|
71289
|
+
};
|
|
71290
|
+
}
|
|
71291
|
+
/**
|
|
70975
71292
|
* Drop a copy and its blob — the operator un-selecting a frame.
|
|
70976
71293
|
*
|
|
70977
71294
|
* The caller must have removed the frame's annotations first; this store does
|
|
@@ -70982,8 +71299,8 @@ var RetrainFrameStore = class {
|
|
|
70982
71299
|
const remove = async () => {
|
|
70983
71300
|
try {
|
|
70984
71301
|
await this.storage.delete({
|
|
70985
|
-
location: frame.locationId ??
|
|
70986
|
-
relativePath: framePath(frame
|
|
71302
|
+
location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
|
|
71303
|
+
relativePath: framePath(frame)
|
|
70987
71304
|
});
|
|
70988
71305
|
} catch {}
|
|
70989
71306
|
try {
|
|
@@ -71004,6 +71321,95 @@ var RetrainFrameStore = class {
|
|
|
71004
71321
|
if (this.writeGate) await this.writeGate.run(remove);
|
|
71005
71322
|
else await remove();
|
|
71006
71323
|
}
|
|
71324
|
+
/**
|
|
71325
|
+
* Move every copy still on the footage class onto the durable retrain
|
|
71326
|
+
* tenant (D781): copy → read back → restamp → delete the source.
|
|
71327
|
+
*
|
|
71328
|
+
* A reconcile, not a one-shot: one query of a collection bounded by
|
|
71329
|
+
* operator selection, so it is cheap when converged, and it is what would
|
|
71330
|
+
* notice a pre-D781 copy restored from anywhere. The order is the gallery
|
|
71331
|
+
* reconcile's (D341) and for the same reason: the row is restamped only
|
|
71332
|
+
* after the new blob reads back, so a crash at any point leaves a row that
|
|
71333
|
+
* names a location holding its bytes.
|
|
71334
|
+
*
|
|
71335
|
+
* A copy whose bytes are already gone is COUNTED and LOGGED, never deleted:
|
|
71336
|
+
* its annotations are the operator's work, and only the operator decides
|
|
71337
|
+
* to drop them (deselecting the frame).
|
|
71338
|
+
*/
|
|
71339
|
+
async relocateLegacyCopies() {
|
|
71340
|
+
const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
|
|
71341
|
+
let relocated = 0;
|
|
71342
|
+
let missing = 0;
|
|
71343
|
+
let failed = 0;
|
|
71344
|
+
for (const row of rows) {
|
|
71345
|
+
if (typeof row.id !== "string") continue;
|
|
71346
|
+
const data = row.data;
|
|
71347
|
+
if (rowIsDurableRetrainCopy(data)) continue;
|
|
71348
|
+
const frame = this.rowToFrame(row.id, data);
|
|
71349
|
+
const from = frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION;
|
|
71350
|
+
const relativePath = framePath(frame);
|
|
71351
|
+
let bytes;
|
|
71352
|
+
try {
|
|
71353
|
+
bytes = await this.storage.read({
|
|
71354
|
+
location: from,
|
|
71355
|
+
relativePath
|
|
71356
|
+
});
|
|
71357
|
+
} catch (err) {
|
|
71358
|
+
missing += 1;
|
|
71359
|
+
this.logger.warn("retrain copy has NO bytes on the footage class — its row and annotations are kept; deselect the frame to drop it", {
|
|
71360
|
+
tags: { deviceId: frame.deviceId },
|
|
71361
|
+
meta: {
|
|
71362
|
+
frameId: frame.frameId,
|
|
71363
|
+
trackId: frame.trackId,
|
|
71364
|
+
from,
|
|
71365
|
+
error: String(err)
|
|
71366
|
+
}
|
|
71367
|
+
});
|
|
71368
|
+
continue;
|
|
71369
|
+
}
|
|
71370
|
+
const move = async () => {
|
|
71371
|
+
await this.storage.write({
|
|
71372
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71373
|
+
relativePath,
|
|
71374
|
+
data: bytes
|
|
71375
|
+
});
|
|
71376
|
+
if (!(await this.storage.read({
|
|
71377
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71378
|
+
relativePath
|
|
71379
|
+
})).equals(bytes)) throw new Error("read-back differs from the source");
|
|
71380
|
+
await this.store.update.mutate({
|
|
71381
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71382
|
+
id: frame.frameId,
|
|
71383
|
+
data: { locationId: RETRAIN_MEDIA_LOCATION }
|
|
71384
|
+
});
|
|
71385
|
+
await this.storage.delete({
|
|
71386
|
+
location: from,
|
|
71387
|
+
relativePath
|
|
71388
|
+
});
|
|
71389
|
+
};
|
|
71390
|
+
try {
|
|
71391
|
+
if (this.writeGate) await this.writeGate.run(move);
|
|
71392
|
+
else await move();
|
|
71393
|
+
relocated += 1;
|
|
71394
|
+
} catch (err) {
|
|
71395
|
+
failed += 1;
|
|
71396
|
+
this.logger.error("retrain copy could not be moved off the footage class", {
|
|
71397
|
+
tags: { deviceId: frame.deviceId },
|
|
71398
|
+
meta: {
|
|
71399
|
+
frameId: frame.frameId,
|
|
71400
|
+
trackId: frame.trackId,
|
|
71401
|
+
from,
|
|
71402
|
+
error: String(err)
|
|
71403
|
+
}
|
|
71404
|
+
});
|
|
71405
|
+
}
|
|
71406
|
+
}
|
|
71407
|
+
return {
|
|
71408
|
+
relocated,
|
|
71409
|
+
missing,
|
|
71410
|
+
failed
|
|
71411
|
+
};
|
|
71412
|
+
}
|
|
71007
71413
|
};
|
|
71008
71414
|
//#endregion
|
|
71009
71415
|
//#region src/pipeline-analytics/retention/location-class-wipe.ts
|
|
@@ -82108,6 +82514,17 @@ var RETRAIN_ANNOTATION_INDEXES = [
|
|
|
82108
82514
|
columns: ["deviceId", "createdAt"]
|
|
82109
82515
|
}
|
|
82110
82516
|
];
|
|
82517
|
+
var ANNOTATION_SOURCES = [
|
|
82518
|
+
"operator",
|
|
82519
|
+
"assist",
|
|
82520
|
+
"pipeline",
|
|
82521
|
+
"llm"
|
|
82522
|
+
];
|
|
82523
|
+
/** The stored source, narrowed. Unknown reads as `operator` — the least
|
|
82524
|
+
* presumptuous claim a row can make about who drew it. */
|
|
82525
|
+
function narrowSource(value) {
|
|
82526
|
+
return ANNOTATION_SOURCES.find((source) => source === value) ?? "operator";
|
|
82527
|
+
}
|
|
82111
82528
|
function optionalString(value) {
|
|
82112
82529
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
82113
82530
|
}
|
|
@@ -82115,6 +82532,8 @@ function optionalString(value) {
|
|
|
82115
82532
|
function narrowMacroClass(value) {
|
|
82116
82533
|
return typeof value === "string" && isRetrainMacroClass(value) ? value : "person";
|
|
82117
82534
|
}
|
|
82535
|
+
/** Frame ids per `IN (…)` page in {@link RetrainAnnotationStore.listByFrames}. */
|
|
82536
|
+
var LIST_BY_FRAMES_PAGE = 500;
|
|
82118
82537
|
var RetrainAnnotationStore = class {
|
|
82119
82538
|
store;
|
|
82120
82539
|
logger;
|
|
@@ -82151,7 +82570,7 @@ var RetrainAnnotationStore = class {
|
|
|
82151
82570
|
label: optionalString(data["label"]),
|
|
82152
82571
|
subLabel: optionalString(data["subLabel"]),
|
|
82153
82572
|
kind: data["kind"] === "model_error" ? "model_error" : "subject",
|
|
82154
|
-
source: data["source"]
|
|
82573
|
+
source: narrowSource(data["source"]),
|
|
82155
82574
|
assistModelId: optionalString(data["assistModelId"]),
|
|
82156
82575
|
assistScore: typeof score === "number" ? score : void 0,
|
|
82157
82576
|
exportedInBatch: optionalString(data["exportedInBatch"]),
|
|
@@ -82220,6 +82639,32 @@ var RetrainAnnotationStore = class {
|
|
|
82220
82639
|
async listByFrame(mediaKey) {
|
|
82221
82640
|
return this.queryWhere({ mediaKey }, `frame ${mediaKey}`);
|
|
82222
82641
|
}
|
|
82642
|
+
/** Every annotation of these frames, oldest first — paged `IN (…)`. */
|
|
82643
|
+
async listByFrames(mediaKeys) {
|
|
82644
|
+
const out = [];
|
|
82645
|
+
for (let i = 0; i < mediaKeys.length; i += LIST_BY_FRAMES_PAGE) {
|
|
82646
|
+
const page = mediaKeys.slice(i, i + LIST_BY_FRAMES_PAGE);
|
|
82647
|
+
try {
|
|
82648
|
+
const rows = await this.store.query.query({
|
|
82649
|
+
collection: RETRAIN_ANNOTATIONS_COLLECTION,
|
|
82650
|
+
filter: {
|
|
82651
|
+
whereIn: { mediaKey: [...page] },
|
|
82652
|
+
orderBy: {
|
|
82653
|
+
field: "createdAt",
|
|
82654
|
+
direction: "asc"
|
|
82655
|
+
}
|
|
82656
|
+
}
|
|
82657
|
+
});
|
|
82658
|
+
for (const r of rows) if (typeof r.id === "string") out.push(this.rowToAnnotation(r.id, r.data));
|
|
82659
|
+
} catch (err) {
|
|
82660
|
+
this.logger.warn("RetrainAnnotationStore.listByFrames failed", { meta: {
|
|
82661
|
+
frames: page.length,
|
|
82662
|
+
error: String(err)
|
|
82663
|
+
} });
|
|
82664
|
+
}
|
|
82665
|
+
}
|
|
82666
|
+
return out;
|
|
82667
|
+
}
|
|
82223
82668
|
async listByTrack(trackId) {
|
|
82224
82669
|
return this.queryWhere({ trackId }, `track ${trackId}`);
|
|
82225
82670
|
}
|
|
@@ -82333,13 +82778,41 @@ var RetrainAnnotationStore = class {
|
|
|
82333
82778
|
//#endregion
|
|
82334
82779
|
//#region src/pipeline-analytics/retrain/retrain-collection-store.ts
|
|
82335
82780
|
/**
|
|
82781
|
+
* RetrainCollectionStore — named sets over the one retrain corpus.
|
|
82782
|
+
*
|
|
82783
|
+
* Collections: `pipeline-analytics:retrain-collections` (the names) and
|
|
82784
|
+
* `pipeline-analytics:retrain-collection-frames` (the membership).
|
|
82785
|
+
*
|
|
82786
|
+
* A collection OWNS NO PIXELS. Every frame — copied from a track or stilled
|
|
82787
|
+
* from the recording — lives once in `retrain-frames`, with its annotations,
|
|
82788
|
+
* its completeness and its export path; a collection is a name the operator
|
|
82789
|
+
* gives to a subset of it ("packages porch night", "false-positive cars"). So
|
|
82790
|
+
* a frame can be in several collections, a collection's count is its
|
|
82791
|
+
* membership, and deleting a collection removes memberships, never a frame
|
|
82792
|
+
* another collection or a track still holds. Which trackless frames are left
|
|
82793
|
+
* with no reason to exist is the caller's question
|
|
82794
|
+
* (`retrain-collection-service.ts`).
|
|
82795
|
+
*/
|
|
82796
|
+
/**
|
|
82336
82797
|
* @durable class=ledger owner=pipeline-analytics
|
|
82337
82798
|
* write="one row per named training collection the operator creates. Names are
|
|
82338
|
-
* unique on nameKey (lower(trim)); empty names never become rows.
|
|
82339
|
-
*
|
|
82340
|
-
*
|
|
82799
|
+
* unique on nameKey (lower(trim)); empty names never become rows. A rename
|
|
82800
|
+
* rewrites name + nameKey under the same id."
|
|
82801
|
+
* retention="none — operator-owned corpus metadata. A row leaves only when the
|
|
82802
|
+
* operator deletes the collection (deleteRetrainCollection), which removes its
|
|
82803
|
+
* membership rows first."
|
|
82341
82804
|
*/
|
|
82342
82805
|
var RETRAIN_COLLECTIONS_COLLECTION = "pipeline-analytics:retrain-collections";
|
|
82806
|
+
/**
|
|
82807
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
82808
|
+
* write="one row per (collection, frame) pair the operator adds — a frame added
|
|
82809
|
+
* from the recording, or an existing retrain frame added to a collection. Keyed
|
|
82810
|
+
* by a hash of the pair, so adding twice is one row."
|
|
82811
|
+
* retention="none — rows leave when the operator removes the frame from the
|
|
82812
|
+
* collection, deletes the collection, or deselects the frame itself. Bounded by
|
|
82813
|
+
* operator curation."
|
|
82814
|
+
*/
|
|
82815
|
+
var RETRAIN_COLLECTION_FRAMES_COLLECTION = "pipeline-analytics:retrain-collection-frames";
|
|
82343
82816
|
var RETRAIN_COLLECTION_COLUMNS = [
|
|
82344
82817
|
{
|
|
82345
82818
|
name: "id",
|
|
@@ -82376,6 +82849,41 @@ var RETRAIN_COLLECTION_INDEXES = [{
|
|
|
82376
82849
|
name: "idx_retrain_collections_created",
|
|
82377
82850
|
columns: ["createdAt"]
|
|
82378
82851
|
}];
|
|
82852
|
+
var RETRAIN_COLLECTION_FRAME_COLUMNS = [
|
|
82853
|
+
{
|
|
82854
|
+
name: "id",
|
|
82855
|
+
type: "TEXT",
|
|
82856
|
+
primaryKey: true,
|
|
82857
|
+
notNull: true
|
|
82858
|
+
},
|
|
82859
|
+
{
|
|
82860
|
+
name: "collectionId",
|
|
82861
|
+
type: "TEXT",
|
|
82862
|
+
notNull: true
|
|
82863
|
+
},
|
|
82864
|
+
{
|
|
82865
|
+
name: "frameId",
|
|
82866
|
+
type: "TEXT",
|
|
82867
|
+
notNull: true
|
|
82868
|
+
},
|
|
82869
|
+
{
|
|
82870
|
+
name: "deviceId",
|
|
82871
|
+
type: "INTEGER",
|
|
82872
|
+
notNull: true
|
|
82873
|
+
},
|
|
82874
|
+
{
|
|
82875
|
+
name: "addedAt",
|
|
82876
|
+
type: "INTEGER",
|
|
82877
|
+
notNull: true
|
|
82878
|
+
}
|
|
82879
|
+
];
|
|
82880
|
+
var RETRAIN_COLLECTION_FRAME_INDEXES = [{
|
|
82881
|
+
name: "idx_retrain_coll_frames_collection",
|
|
82882
|
+
columns: ["collectionId", "addedAt"]
|
|
82883
|
+
}, {
|
|
82884
|
+
name: "idx_retrain_coll_frames_frame",
|
|
82885
|
+
columns: ["frameId"]
|
|
82886
|
+
}];
|
|
82379
82887
|
function normalizeCollectionName(raw) {
|
|
82380
82888
|
const name = raw.trim();
|
|
82381
82889
|
if (name.length === 0) return null;
|
|
@@ -82384,18 +82892,22 @@ function normalizeCollectionName(raw) {
|
|
|
82384
82892
|
nameKey: name.toLowerCase()
|
|
82385
82893
|
};
|
|
82386
82894
|
}
|
|
82895
|
+
/** The membership row's key — one row per pair, whatever the ids contain. */
|
|
82896
|
+
function membershipKey(collectionId, frameId) {
|
|
82897
|
+
return (0, node_crypto.createHash)("sha1").update(`${collectionId}\u0000${frameId}`).digest("hex");
|
|
82898
|
+
}
|
|
82899
|
+
/** Ids per `IN (…)` page. */
|
|
82900
|
+
var IN_PAGE = 500;
|
|
82387
82901
|
var RetrainCollectionStore = class {
|
|
82388
82902
|
store;
|
|
82389
82903
|
logger;
|
|
82390
82904
|
now;
|
|
82391
82905
|
newId;
|
|
82392
|
-
countFrames;
|
|
82393
82906
|
constructor(deps) {
|
|
82394
82907
|
this.store = deps.store;
|
|
82395
82908
|
this.logger = deps.logger;
|
|
82396
82909
|
this.now = deps.now;
|
|
82397
82910
|
this.newId = deps.newId;
|
|
82398
|
-
this.countFrames = deps.countFrames;
|
|
82399
82911
|
}
|
|
82400
82912
|
static async declare(store) {
|
|
82401
82913
|
await store.declareCollection.mutate({
|
|
@@ -82403,6 +82915,11 @@ var RetrainCollectionStore = class {
|
|
|
82403
82915
|
columns: [...RETRAIN_COLLECTION_COLUMNS],
|
|
82404
82916
|
indexes: [...RETRAIN_COLLECTION_INDEXES]
|
|
82405
82917
|
});
|
|
82918
|
+
await store.declareCollection.mutate({
|
|
82919
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82920
|
+
columns: [...RETRAIN_COLLECTION_FRAME_COLUMNS],
|
|
82921
|
+
indexes: [...RETRAIN_COLLECTION_FRAME_INDEXES]
|
|
82922
|
+
});
|
|
82406
82923
|
}
|
|
82407
82924
|
async create(input) {
|
|
82408
82925
|
const normalized = normalizeCollectionName(input.name);
|
|
@@ -82410,7 +82927,7 @@ var RetrainCollectionStore = class {
|
|
|
82410
82927
|
kind: "refused",
|
|
82411
82928
|
reason: "empty-name"
|
|
82412
82929
|
};
|
|
82413
|
-
if (await this.
|
|
82930
|
+
if (await this.findIdByNameKey(normalized.nameKey) !== null) return {
|
|
82414
82931
|
kind: "refused",
|
|
82415
82932
|
reason: "duplicate-name"
|
|
82416
82933
|
};
|
|
@@ -82426,6 +82943,7 @@ var RetrainCollectionStore = class {
|
|
|
82426
82943
|
updatedAt: createdAt
|
|
82427
82944
|
}
|
|
82428
82945
|
});
|
|
82946
|
+
this.logger.info("retrain collection created", { meta: { collectionId: id } });
|
|
82429
82947
|
return {
|
|
82430
82948
|
kind: "created",
|
|
82431
82949
|
collection: {
|
|
@@ -82436,6 +82954,7 @@ var RetrainCollectionStore = class {
|
|
|
82436
82954
|
}
|
|
82437
82955
|
};
|
|
82438
82956
|
}
|
|
82957
|
+
/** Every collection, newest first, each with its membership count (one query). */
|
|
82439
82958
|
async list() {
|
|
82440
82959
|
let rows;
|
|
82441
82960
|
try {
|
|
@@ -82450,34 +82969,158 @@ var RetrainCollectionStore = class {
|
|
|
82450
82969
|
this.logger.warn("retrain collections list failed", { meta: { error: String(err) } });
|
|
82451
82970
|
return [];
|
|
82452
82971
|
}
|
|
82453
|
-
const
|
|
82454
|
-
|
|
82455
|
-
|
|
82456
|
-
|
|
82457
|
-
|
|
82458
|
-
|
|
82459
|
-
|
|
82460
|
-
|
|
82461
|
-
|
|
82462
|
-
|
|
82463
|
-
|
|
82972
|
+
const counts = await this.countMembers(rows.map((r) => r.id));
|
|
82973
|
+
return rows.map((row) => this.toCollection(row.id, row.data, counts.get(row.id) ?? 0));
|
|
82974
|
+
}
|
|
82975
|
+
async getById(id) {
|
|
82976
|
+
const data = await this.readRow(id);
|
|
82977
|
+
if (data === null) return null;
|
|
82978
|
+
const counts = await this.countMembers([id]);
|
|
82979
|
+
return this.toCollection(id, data, counts.get(id) ?? 0);
|
|
82980
|
+
}
|
|
82981
|
+
async rename(input) {
|
|
82982
|
+
const normalized = normalizeCollectionName(input.name);
|
|
82983
|
+
if (normalized === null) return {
|
|
82984
|
+
kind: "refused",
|
|
82985
|
+
reason: "empty-name"
|
|
82986
|
+
};
|
|
82987
|
+
const current = await this.readRow(input.id);
|
|
82988
|
+
if (current === null) return {
|
|
82989
|
+
kind: "refused",
|
|
82990
|
+
reason: "unknown-collection"
|
|
82991
|
+
};
|
|
82992
|
+
const holder = await this.findIdByNameKey(normalized.nameKey);
|
|
82993
|
+
if (holder !== null && holder !== input.id) return {
|
|
82994
|
+
kind: "refused",
|
|
82995
|
+
reason: "duplicate-name"
|
|
82996
|
+
};
|
|
82997
|
+
await this.store.update.mutate({
|
|
82998
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
82999
|
+
id: input.id,
|
|
83000
|
+
data: {
|
|
83001
|
+
name: normalized.name,
|
|
83002
|
+
nameKey: normalized.nameKey,
|
|
83003
|
+
updatedAt: this.now()
|
|
83004
|
+
}
|
|
83005
|
+
});
|
|
83006
|
+
this.logger.info("retrain collection renamed", { meta: { collectionId: input.id } });
|
|
83007
|
+
const counts = await this.countMembers([input.id]);
|
|
83008
|
+
return {
|
|
83009
|
+
kind: "renamed",
|
|
83010
|
+
collection: this.toCollection(input.id, {
|
|
83011
|
+
...current,
|
|
83012
|
+
name: normalized.name
|
|
83013
|
+
}, counts.get(input.id) ?? 0)
|
|
83014
|
+
};
|
|
83015
|
+
}
|
|
83016
|
+
/** Remove the collection and its memberships — members first, so a crash
|
|
83017
|
+
* never leaves memberships pointing at a name that no longer exists. */
|
|
83018
|
+
async delete(id) {
|
|
83019
|
+
if (await this.readRow(id) === null) return {
|
|
83020
|
+
deleted: false,
|
|
83021
|
+
memberFrameIds: []
|
|
83022
|
+
};
|
|
83023
|
+
const members = await this.membershipRows({ collectionId: id });
|
|
83024
|
+
for (const m of members) await this.store.delete.mutate({
|
|
83025
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83026
|
+
key: m.id
|
|
83027
|
+
});
|
|
83028
|
+
await this.store.delete.mutate({
|
|
83029
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
83030
|
+
key: id
|
|
83031
|
+
});
|
|
83032
|
+
this.logger.info("retrain collection deleted", { meta: {
|
|
83033
|
+
collectionId: id,
|
|
83034
|
+
members: members.length
|
|
83035
|
+
} });
|
|
83036
|
+
return {
|
|
83037
|
+
deleted: true,
|
|
83038
|
+
memberFrameIds: members.map((m) => String(m.data["frameId"] ?? ""))
|
|
83039
|
+
};
|
|
83040
|
+
}
|
|
83041
|
+
/** Add a frame. `false` when it was already a member (nothing written). */
|
|
83042
|
+
async addFrame(input) {
|
|
83043
|
+
const key = membershipKey(input.collectionId, input.frameId);
|
|
83044
|
+
const existing = await this.store.get.query({
|
|
83045
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83046
|
+
key
|
|
83047
|
+
});
|
|
83048
|
+
if (existing !== null && existing !== void 0) return false;
|
|
83049
|
+
await this.store.set.mutate({
|
|
83050
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83051
|
+
key,
|
|
83052
|
+
value: {
|
|
83053
|
+
collectionId: input.collectionId,
|
|
83054
|
+
frameId: input.frameId,
|
|
83055
|
+
deviceId: input.deviceId,
|
|
83056
|
+
addedAt: this.now()
|
|
83057
|
+
}
|
|
83058
|
+
});
|
|
83059
|
+
return true;
|
|
83060
|
+
}
|
|
83061
|
+
/** Remove one membership. `false` when it did not exist. */
|
|
83062
|
+
async removeFrame(input) {
|
|
83063
|
+
const key = membershipKey(input.collectionId, input.frameId);
|
|
83064
|
+
const existing = await this.store.get.query({
|
|
83065
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83066
|
+
key
|
|
83067
|
+
});
|
|
83068
|
+
if (existing === null || existing === void 0) return false;
|
|
83069
|
+
await this.store.delete.mutate({
|
|
83070
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83071
|
+
key
|
|
83072
|
+
});
|
|
83073
|
+
return true;
|
|
83074
|
+
}
|
|
83075
|
+
/** Remove a frame from EVERY collection — the frame itself is leaving. */
|
|
83076
|
+
async forgetFrame(frameId) {
|
|
83077
|
+
const rows = await this.membershipRows({ frameId });
|
|
83078
|
+
for (const r of rows) await this.store.delete.mutate({
|
|
83079
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83080
|
+
key: r.id
|
|
83081
|
+
});
|
|
83082
|
+
return rows.length;
|
|
83083
|
+
}
|
|
83084
|
+
/** The frames of a collection, in the order they were added. */
|
|
83085
|
+
async listFrameIds(collectionId) {
|
|
83086
|
+
return (await this.membershipRows({ collectionId })).toSorted((a, b) => Number(a.data["addedAt"] ?? 0) - Number(b.data["addedAt"] ?? 0)).map((r) => String(r.data["frameId"] ?? "")).filter((id) => id !== "");
|
|
83087
|
+
}
|
|
83088
|
+
/** frameId → the collections it belongs to. Frames in none are absent. */
|
|
83089
|
+
async collectionsOf(frameIds) {
|
|
83090
|
+
const out = /* @__PURE__ */ new Map();
|
|
83091
|
+
for (const row of await this.membershipRowsIn("frameId", frameIds)) {
|
|
83092
|
+
const frameId = String(row.data["frameId"] ?? "");
|
|
83093
|
+
const collectionId = String(row.data["collectionId"] ?? "");
|
|
83094
|
+
if (frameId === "" || collectionId === "") continue;
|
|
83095
|
+
out.set(frameId, [...out.get(frameId) ?? [], collectionId]);
|
|
82464
83096
|
}
|
|
83097
|
+
for (const [frameId, ids] of out) out.set(frameId, ids.toSorted());
|
|
82465
83098
|
return out;
|
|
82466
83099
|
}
|
|
82467
|
-
|
|
83100
|
+
/** The frame ids of several collections, de-duplicated. */
|
|
83101
|
+
async frameIdsOf(collectionIds) {
|
|
83102
|
+
const ids = /* @__PURE__ */ new Set();
|
|
83103
|
+
for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
|
|
83104
|
+
const frameId = String(row.data["frameId"] ?? "");
|
|
83105
|
+
if (frameId !== "") ids.add(frameId);
|
|
83106
|
+
}
|
|
83107
|
+
return [...ids];
|
|
83108
|
+
}
|
|
83109
|
+
toCollection(id, data, frameCount) {
|
|
83110
|
+
return {
|
|
83111
|
+
id,
|
|
83112
|
+
name: typeof data["name"] === "string" ? data["name"] : "",
|
|
83113
|
+
createdAt: Number(data["createdAt"] ?? 0),
|
|
83114
|
+
frameCount
|
|
83115
|
+
};
|
|
83116
|
+
}
|
|
83117
|
+
async readRow(id) {
|
|
82468
83118
|
try {
|
|
82469
83119
|
const row = await this.store.get.query({
|
|
82470
83120
|
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
82471
83121
|
key: id
|
|
82472
83122
|
});
|
|
82473
|
-
|
|
82474
|
-
const data = row;
|
|
82475
|
-
return {
|
|
82476
|
-
id,
|
|
82477
|
-
name: typeof data["name"] === "string" ? data["name"] : "",
|
|
82478
|
-
createdAt: Number(data["createdAt"] ?? 0),
|
|
82479
|
-
frameCount: this.countFrames !== void 0 ? await this.countFrames(id) : 0
|
|
82480
|
-
};
|
|
83123
|
+
return row === null || row === void 0 ? null : { ...row };
|
|
82481
83124
|
} catch (err) {
|
|
82482
83125
|
this.logger.warn("retrain collection get failed", { meta: {
|
|
82483
83126
|
collectionId: id,
|
|
@@ -82486,14 +83129,125 @@ var RetrainCollectionStore = class {
|
|
|
82486
83129
|
return null;
|
|
82487
83130
|
}
|
|
82488
83131
|
}
|
|
83132
|
+
async countMembers(collectionIds) {
|
|
83133
|
+
const counts = /* @__PURE__ */ new Map();
|
|
83134
|
+
for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
|
|
83135
|
+
const id = String(row.data["collectionId"] ?? "");
|
|
83136
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
83137
|
+
}
|
|
83138
|
+
return counts;
|
|
83139
|
+
}
|
|
83140
|
+
async membershipRows(where) {
|
|
83141
|
+
try {
|
|
83142
|
+
return await this.store.query.query({
|
|
83143
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83144
|
+
filter: { where }
|
|
83145
|
+
});
|
|
83146
|
+
} catch (err) {
|
|
83147
|
+
this.logger.warn("retrain collection membership read failed", { meta: {
|
|
83148
|
+
...where,
|
|
83149
|
+
error: String(err)
|
|
83150
|
+
} });
|
|
83151
|
+
return [];
|
|
83152
|
+
}
|
|
83153
|
+
}
|
|
83154
|
+
async membershipRowsIn(field, values) {
|
|
83155
|
+
const out = [];
|
|
83156
|
+
for (let i = 0; i < values.length; i += IN_PAGE) {
|
|
83157
|
+
const page = values.slice(i, i + IN_PAGE);
|
|
83158
|
+
try {
|
|
83159
|
+
out.push(...await this.store.query.query({
|
|
83160
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83161
|
+
filter: { whereIn: { [field]: [...page] } }
|
|
83162
|
+
}));
|
|
83163
|
+
} catch (err) {
|
|
83164
|
+
this.logger.warn("retrain collection membership read failed", { meta: {
|
|
83165
|
+
field,
|
|
83166
|
+
values: page.length,
|
|
83167
|
+
error: String(err)
|
|
83168
|
+
} });
|
|
83169
|
+
}
|
|
83170
|
+
}
|
|
83171
|
+
return out;
|
|
83172
|
+
}
|
|
83173
|
+
async findIdByNameKey(nameKey) {
|
|
83174
|
+
try {
|
|
83175
|
+
return (await this.store.query.query({
|
|
83176
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
83177
|
+
filter: {
|
|
83178
|
+
where: { nameKey },
|
|
83179
|
+
limit: 1
|
|
83180
|
+
}
|
|
83181
|
+
}))[0]?.id ?? null;
|
|
83182
|
+
} catch (err) {
|
|
83183
|
+
this.logger.warn("retrain collection findByNameKey failed", { meta: {
|
|
83184
|
+
nameKey,
|
|
83185
|
+
error: String(err)
|
|
83186
|
+
} });
|
|
83187
|
+
return null;
|
|
83188
|
+
}
|
|
83189
|
+
}
|
|
83190
|
+
};
|
|
83191
|
+
//#endregion
|
|
83192
|
+
//#region src/pipeline-analytics/retrain/retrain-collection-service.ts
|
|
83193
|
+
/** What each still outcome tells the operator. */
|
|
83194
|
+
var STILL_REFUSAL = {
|
|
83195
|
+
"no-recording": {
|
|
83196
|
+
reason: "still-unavailable",
|
|
83197
|
+
detail: "this camera is not recording, so there is no footage to take a frame from"
|
|
83198
|
+
},
|
|
83199
|
+
"no-footage": {
|
|
83200
|
+
reason: "still-unavailable",
|
|
83201
|
+
detail: "no recorded footage covers that instant (a gap, or already pruned)"
|
|
83202
|
+
},
|
|
83203
|
+
"decode-failed": {
|
|
83204
|
+
reason: "still-unavailable",
|
|
83205
|
+
detail: "the footage is there but the frame could not be decoded"
|
|
83206
|
+
},
|
|
83207
|
+
busy: {
|
|
83208
|
+
reason: "still-busy",
|
|
83209
|
+
detail: "the recorder is already decoding other frames — try again in a moment"
|
|
83210
|
+
}
|
|
83211
|
+
};
|
|
83212
|
+
var RetrainCollectionService = class {
|
|
83213
|
+
deps;
|
|
83214
|
+
constructor(deps) {
|
|
83215
|
+
this.deps = deps;
|
|
83216
|
+
}
|
|
83217
|
+
create(input) {
|
|
83218
|
+
return this.deps.collections.create(input);
|
|
83219
|
+
}
|
|
83220
|
+
list() {
|
|
83221
|
+
return this.deps.collections.list();
|
|
83222
|
+
}
|
|
83223
|
+
rename(input) {
|
|
83224
|
+
return this.deps.collections.rename(input);
|
|
83225
|
+
}
|
|
82489
83226
|
/**
|
|
82490
|
-
*
|
|
82491
|
-
*
|
|
83227
|
+
* Delete a collection. Its recording stills that no other collection holds
|
|
83228
|
+
* go with it (annotations first); track copies always stay.
|
|
82492
83229
|
*/
|
|
82493
|
-
async
|
|
82494
|
-
|
|
82495
|
-
|
|
82496
|
-
|
|
83230
|
+
async delete(input) {
|
|
83231
|
+
const result = await this.deps.collections.delete(input.id);
|
|
83232
|
+
if (!result.deleted) return {
|
|
83233
|
+
deleted: false,
|
|
83234
|
+
removedFrames: 0
|
|
83235
|
+
};
|
|
83236
|
+
return {
|
|
83237
|
+
deleted: true,
|
|
83238
|
+
removedFrames: await this.dropOrphanStills(result.memberFrameIds)
|
|
83239
|
+
};
|
|
83240
|
+
}
|
|
83241
|
+
/**
|
|
83242
|
+
* Decode the frame at `epochMs` from the camera's recording and add it to
|
|
83243
|
+
* the collection. The moment already in the dataset is reused without a
|
|
83244
|
+
* decode; every refusal is named and writes nothing.
|
|
83245
|
+
*/
|
|
83246
|
+
async addFrameFromRecording(input) {
|
|
83247
|
+
const tags = { deviceId: input.deviceId };
|
|
83248
|
+
if (await this.deps.collections.getById(input.collectionId) === null) {
|
|
83249
|
+
this.deps.logger.info("retrain collection add refused: unknown collection", {
|
|
83250
|
+
tags,
|
|
82497
83251
|
meta: {
|
|
82498
83252
|
collectionId: input.collectionId,
|
|
82499
83253
|
epochMs: input.epochMs
|
|
@@ -82504,45 +83258,836 @@ var RetrainCollectionStore = class {
|
|
|
82504
83258
|
reason: "unknown-collection"
|
|
82505
83259
|
};
|
|
82506
83260
|
}
|
|
82507
|
-
|
|
82508
|
-
|
|
83261
|
+
const stored = await this.storeStill(input.deviceId, input.epochMs);
|
|
83262
|
+
if (stored.kind === "refused") {
|
|
83263
|
+
this.deps.logger.info("retrain collection add refused", {
|
|
83264
|
+
tags,
|
|
83265
|
+
meta: {
|
|
83266
|
+
collectionId: input.collectionId,
|
|
83267
|
+
epochMs: input.epochMs,
|
|
83268
|
+
reason: stored.reason
|
|
83269
|
+
}
|
|
83270
|
+
});
|
|
83271
|
+
return stored;
|
|
83272
|
+
}
|
|
83273
|
+
await this.deps.collections.addFrame({
|
|
83274
|
+
collectionId: input.collectionId,
|
|
83275
|
+
frameId: stored.frame.frameId,
|
|
83276
|
+
deviceId: input.deviceId
|
|
83277
|
+
});
|
|
83278
|
+
this.deps.logger.info("retrain frame added to a collection from the recording", {
|
|
83279
|
+
tags,
|
|
82509
83280
|
meta: {
|
|
82510
83281
|
collectionId: input.collectionId,
|
|
82511
|
-
|
|
83282
|
+
frameId: stored.frame.frameId,
|
|
83283
|
+
epochMs: input.epochMs,
|
|
83284
|
+
reused: stored.reused
|
|
82512
83285
|
}
|
|
82513
83286
|
});
|
|
83287
|
+
const [wire] = await this.deps.retrain.toWireFrames([stored.frame]);
|
|
83288
|
+
if (wire === void 0) throw new Error("retrain: a stored still produced no wire frame");
|
|
82514
83289
|
return {
|
|
83290
|
+
kind: "added",
|
|
83291
|
+
collectionId: input.collectionId,
|
|
83292
|
+
frame: wire,
|
|
83293
|
+
reused: stored.reused
|
|
83294
|
+
};
|
|
83295
|
+
}
|
|
83296
|
+
/** Add frames already in the dataset (track copies or stills) to a collection. */
|
|
83297
|
+
async addFrames(input) {
|
|
83298
|
+
if (await this.deps.collections.getById(input.collectionId) === null) return {
|
|
83299
|
+
added: 0,
|
|
83300
|
+
alreadyMember: 0,
|
|
83301
|
+
unknownFrameIds: [...input.frameIds]
|
|
83302
|
+
};
|
|
83303
|
+
const frames = await this.deps.frames.getMany(input.frameIds);
|
|
83304
|
+
const known = new Set(frames.map((f) => f.frameId));
|
|
83305
|
+
let added = 0;
|
|
83306
|
+
let alreadyMember = 0;
|
|
83307
|
+
for (const frame of frames) if (await this.deps.collections.addFrame({
|
|
83308
|
+
collectionId: input.collectionId,
|
|
83309
|
+
frameId: frame.frameId,
|
|
83310
|
+
deviceId: frame.deviceId
|
|
83311
|
+
})) added += 1;
|
|
83312
|
+
else alreadyMember += 1;
|
|
83313
|
+
return {
|
|
83314
|
+
added,
|
|
83315
|
+
alreadyMember,
|
|
83316
|
+
unknownFrameIds: input.frameIds.filter((id) => !known.has(id))
|
|
83317
|
+
};
|
|
83318
|
+
}
|
|
83319
|
+
/**
|
|
83320
|
+
* Remove a frame from a collection. A recording still left in no collection
|
|
83321
|
+
* is deleted with its annotations; a track copy is never deleted here.
|
|
83322
|
+
*/
|
|
83323
|
+
async removeFrame(input) {
|
|
83324
|
+
if (!await this.deps.collections.removeFrame(input)) return {
|
|
83325
|
+
removed: false,
|
|
83326
|
+
deletedFrame: false
|
|
83327
|
+
};
|
|
83328
|
+
return {
|
|
83329
|
+
removed: true,
|
|
83330
|
+
deletedFrame: await this.dropOrphanStills([input.frameId]) > 0
|
|
83331
|
+
};
|
|
83332
|
+
}
|
|
83333
|
+
/** The frames of a collection, as the canvas lists them. */
|
|
83334
|
+
async listFrames(input) {
|
|
83335
|
+
const ids = await this.deps.collections.listFrameIds(input.collectionId);
|
|
83336
|
+
return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
|
|
83337
|
+
}
|
|
83338
|
+
async storeStill(deviceId, epochMs) {
|
|
83339
|
+
const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
|
|
83340
|
+
if (existing !== null) return {
|
|
83341
|
+
kind: "stored",
|
|
83342
|
+
frame: existing,
|
|
83343
|
+
reused: true
|
|
83344
|
+
};
|
|
83345
|
+
const still = await this.deps.captureStill(deviceId, epochMs);
|
|
83346
|
+
if (still.outcome !== "ok" || still.jpeg === void 0) {
|
|
83347
|
+
const refusal = still.outcome === "ok" ? STILL_REFUSAL["decode-failed"] : STILL_REFUSAL[still.outcome];
|
|
83348
|
+
return {
|
|
83349
|
+
kind: "refused",
|
|
83350
|
+
reason: refusal.reason,
|
|
83351
|
+
detail: refusal.detail
|
|
83352
|
+
};
|
|
83353
|
+
}
|
|
83354
|
+
const stored = await this.deps.frames.storeRecordingStill({
|
|
83355
|
+
deviceId,
|
|
83356
|
+
epochMs,
|
|
83357
|
+
bytes: still.jpeg
|
|
83358
|
+
});
|
|
83359
|
+
return stored.kind === "copied" ? {
|
|
83360
|
+
kind: "stored",
|
|
83361
|
+
frame: stored.frame,
|
|
83362
|
+
reused: stored.reused
|
|
83363
|
+
} : {
|
|
82515
83364
|
kind: "refused",
|
|
82516
|
-
reason:
|
|
82517
|
-
detail: "Still ingest lands in plan Task 3"
|
|
83365
|
+
reason: stored.reason
|
|
82518
83366
|
};
|
|
82519
83367
|
}
|
|
82520
|
-
|
|
82521
|
-
|
|
82522
|
-
|
|
82523
|
-
|
|
82524
|
-
|
|
82525
|
-
|
|
82526
|
-
|
|
83368
|
+
/** Delete the recording stills among `frameIds` that no collection holds any more. */
|
|
83369
|
+
async dropOrphanStills(frameIds) {
|
|
83370
|
+
if (frameIds.length === 0) return 0;
|
|
83371
|
+
const memberships = await this.deps.collections.collectionsOf(frameIds);
|
|
83372
|
+
const orphans = (await this.deps.frames.getMany(frameIds)).filter((f) => f.origin === "recording" && (memberships.get(f.frameId)?.length ?? 0) === 0);
|
|
83373
|
+
for (const frame of orphans) await this.deps.retrain.removeFrame(frame);
|
|
83374
|
+
return orphans.length;
|
|
83375
|
+
}
|
|
83376
|
+
};
|
|
83377
|
+
//#endregion
|
|
83378
|
+
//#region src/pipeline-analytics/retrain/retrain-llm-assist.ts
|
|
83379
|
+
var ALWAYS_LOCAL_KINDS = new Set(["managed-local", "apple-foundation"]);
|
|
83380
|
+
var ALWAYS_CLOUD_KINDS = new Set([
|
|
83381
|
+
"openai",
|
|
83382
|
+
"anthropic",
|
|
83383
|
+
"google"
|
|
83384
|
+
]);
|
|
83385
|
+
function isPrivateIpv4(host) {
|
|
83386
|
+
const parts = host.split(".").map((p) => Number.parseInt(p, 10));
|
|
83387
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
|
83388
|
+
const [a, b] = parts;
|
|
83389
|
+
return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
|
|
83390
|
+
}
|
|
83391
|
+
function isLocalHost(rawHost) {
|
|
83392
|
+
const host = rawHost.replace(/^\[|\]$/g, "").toLowerCase();
|
|
83393
|
+
if (host.includes(":")) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
|
|
83394
|
+
if (host === "localhost" || host.endsWith(".local") || host.endsWith(".lan")) return true;
|
|
83395
|
+
if (isPrivateIpv4(host)) return true;
|
|
83396
|
+
return !host.includes(".") && !host.includes(":");
|
|
83397
|
+
}
|
|
83398
|
+
/** Does sending a frame to this profile send it outside the house? */
|
|
83399
|
+
function llmProfileLeavesHouse(profile) {
|
|
83400
|
+
if (ALWAYS_LOCAL_KINDS.has(profile.kind)) return false;
|
|
83401
|
+
if (ALWAYS_CLOUD_KINDS.has(profile.kind)) return true;
|
|
83402
|
+
if (profile.baseUrl === void 0 || profile.baseUrl === "") return true;
|
|
83403
|
+
try {
|
|
83404
|
+
return !isLocalHost(new URL(profile.baseUrl).hostname);
|
|
83405
|
+
} catch {
|
|
83406
|
+
return true;
|
|
83407
|
+
}
|
|
83408
|
+
}
|
|
83409
|
+
/** At most this many boxes are sent for review — a prompt, not a dump. */
|
|
83410
|
+
var LLM_REVIEW_MAX_BOXES = 50;
|
|
83411
|
+
var VERDICTS = [
|
|
83412
|
+
"confirmed",
|
|
83413
|
+
"wrong-class",
|
|
83414
|
+
"not-an-object",
|
|
83415
|
+
"uncertain"
|
|
83416
|
+
];
|
|
83417
|
+
var SYSTEM_PROMPT = [
|
|
83418
|
+
"You annotate frames from home security cameras to build an object-detection training set.",
|
|
83419
|
+
"Be precise and conservative: only report what is clearly visible.",
|
|
83420
|
+
"Coordinates are normalised to the full frame: x and y are the top-left corner,",
|
|
83421
|
+
"w and h the width and height, all between 0 and 1.",
|
|
83422
|
+
"Answer with JSON only, exactly in the requested schema, with no other text."
|
|
83423
|
+
].join(" ");
|
|
83424
|
+
/** The JSON schema asked of the model (and enforced again by the parser). */
|
|
83425
|
+
function answerJsonSchema(classes) {
|
|
83426
|
+
return {
|
|
83427
|
+
type: "object",
|
|
83428
|
+
additionalProperties: false,
|
|
83429
|
+
required: ["objects", "reviews"],
|
|
83430
|
+
properties: {
|
|
83431
|
+
objects: {
|
|
83432
|
+
type: "array",
|
|
83433
|
+
items: {
|
|
83434
|
+
type: "object",
|
|
83435
|
+
additionalProperties: false,
|
|
83436
|
+
required: [
|
|
83437
|
+
"class",
|
|
83438
|
+
"box",
|
|
83439
|
+
"confidence",
|
|
83440
|
+
"reason"
|
|
83441
|
+
],
|
|
83442
|
+
properties: {
|
|
83443
|
+
class: {
|
|
83444
|
+
type: "string",
|
|
83445
|
+
enum: [...classes]
|
|
83446
|
+
},
|
|
83447
|
+
label: { type: "string" },
|
|
83448
|
+
box: {
|
|
83449
|
+
type: "array",
|
|
83450
|
+
items: { type: "number" },
|
|
83451
|
+
minItems: 4,
|
|
83452
|
+
maxItems: 4
|
|
83453
|
+
},
|
|
83454
|
+
confidence: {
|
|
83455
|
+
type: "number",
|
|
83456
|
+
minimum: 0,
|
|
83457
|
+
maximum: 1
|
|
83458
|
+
},
|
|
83459
|
+
reason: { type: "string" }
|
|
83460
|
+
}
|
|
82527
83461
|
}
|
|
82528
|
-
}
|
|
82529
|
-
|
|
83462
|
+
},
|
|
83463
|
+
reviews: {
|
|
83464
|
+
type: "array",
|
|
83465
|
+
items: {
|
|
83466
|
+
type: "object",
|
|
83467
|
+
additionalProperties: false,
|
|
83468
|
+
required: [
|
|
83469
|
+
"ref",
|
|
83470
|
+
"verdict",
|
|
83471
|
+
"reason"
|
|
83472
|
+
],
|
|
83473
|
+
properties: {
|
|
83474
|
+
ref: { type: "string" },
|
|
83475
|
+
verdict: {
|
|
83476
|
+
type: "string",
|
|
83477
|
+
enum: [...VERDICTS]
|
|
83478
|
+
},
|
|
83479
|
+
suggestedClass: { type: "string" },
|
|
83480
|
+
reason: { type: "string" }
|
|
83481
|
+
}
|
|
83482
|
+
}
|
|
83483
|
+
}
|
|
83484
|
+
}
|
|
83485
|
+
};
|
|
83486
|
+
}
|
|
83487
|
+
var round = (n) => Math.round(n * 1e3) / 1e3;
|
|
83488
|
+
/** Build the request: target classes, the frame size, the boxes to review. */
|
|
83489
|
+
function buildLlmAssistRequest(input) {
|
|
83490
|
+
const boxes = input.boxes.slice(0, LLM_REVIEW_MAX_BOXES).map((b) => JSON.stringify({
|
|
83491
|
+
ref: b.ref,
|
|
83492
|
+
class: b.macroClass,
|
|
83493
|
+
...b.label !== void 0 ? { label: b.label } : {},
|
|
83494
|
+
box: [
|
|
83495
|
+
round(b.bbox.x),
|
|
83496
|
+
round(b.bbox.y),
|
|
83497
|
+
round(b.bbox.w),
|
|
83498
|
+
round(b.bbox.h)
|
|
83499
|
+
]
|
|
83500
|
+
}));
|
|
83501
|
+
return {
|
|
83502
|
+
system: SYSTEM_PROMPT,
|
|
83503
|
+
prompt: [
|
|
83504
|
+
`The image is one camera frame, ${String(input.width)}×${String(input.height)} pixels.`,
|
|
83505
|
+
`1. List every object of these classes you can see: ${input.classes.join(", ")}.`,
|
|
83506
|
+
" For each give class, an optional free-text label (e.g. \"van\", \"postman\"),",
|
|
83507
|
+
" box [x, y, w, h] normalised to the frame, confidence 0–1, and a short reason.",
|
|
83508
|
+
" Include small and partly hidden ones; do not include anything else.",
|
|
83509
|
+
boxes.length > 0 ? `2. Review these existing boxes. For each ref say "confirmed", "wrong-class" (with suggestedClass), "not-an-object" or "uncertain", with a short reason:\n${boxes.join("\n")}` : "2. There are no existing boxes to review: answer \"reviews\": [].",
|
|
83510
|
+
"Answer with JSON: {\"objects\": [...], \"reviews\": [...]}."
|
|
83511
|
+
].join("\n"),
|
|
83512
|
+
jsonSchema: answerJsonSchema(input.classes)
|
|
83513
|
+
};
|
|
83514
|
+
}
|
|
83515
|
+
/** The whole answer: two arrays and nothing else. Items are checked one by one. */
|
|
83516
|
+
var AnswerSchema = require_clip_model_registry.strictObject({
|
|
83517
|
+
objects: require_clip_model_registry.array(require_clip_model_registry.unknown()),
|
|
83518
|
+
reviews: require_clip_model_registry.array(require_clip_model_registry.unknown())
|
|
83519
|
+
});
|
|
83520
|
+
var ObjectSchema = require_clip_model_registry.strictObject({
|
|
83521
|
+
class: require_clip_model_registry.string(),
|
|
83522
|
+
label: require_clip_model_registry.string().optional(),
|
|
83523
|
+
box: require_clip_model_registry.tuple([
|
|
83524
|
+
require_clip_model_registry.number(),
|
|
83525
|
+
require_clip_model_registry.number(),
|
|
83526
|
+
require_clip_model_registry.number(),
|
|
83527
|
+
require_clip_model_registry.number()
|
|
83528
|
+
]),
|
|
83529
|
+
confidence: require_clip_model_registry.number().min(0).max(1),
|
|
83530
|
+
reason: require_clip_model_registry.string().max(500)
|
|
83531
|
+
});
|
|
83532
|
+
var ReviewSchema = require_clip_model_registry.strictObject({
|
|
83533
|
+
ref: require_clip_model_registry.string(),
|
|
83534
|
+
verdict: require_clip_model_registry._enum(VERDICTS),
|
|
83535
|
+
suggestedClass: require_clip_model_registry.string().optional(),
|
|
83536
|
+
reason: require_clip_model_registry.string().max(500)
|
|
83537
|
+
});
|
|
83538
|
+
function frameBox(box) {
|
|
83539
|
+
const [x, y, w, h] = box;
|
|
83540
|
+
if (![
|
|
83541
|
+
x,
|
|
83542
|
+
y,
|
|
83543
|
+
w,
|
|
83544
|
+
h
|
|
83545
|
+
].every(Number.isFinite)) return null;
|
|
83546
|
+
if (!(w > 0) || !(h > 0) || x < -.01 || y < -.01) return null;
|
|
83547
|
+
if (x + w > 1.01 || y + h > 1.01) return null;
|
|
83548
|
+
const cx = Math.max(0, x);
|
|
83549
|
+
const cy = Math.max(0, y);
|
|
83550
|
+
return {
|
|
83551
|
+
x: cx,
|
|
83552
|
+
y: cy,
|
|
83553
|
+
w: Math.min(w, 1 - cx),
|
|
83554
|
+
h: Math.min(h, 1 - cy)
|
|
83555
|
+
};
|
|
83556
|
+
}
|
|
83557
|
+
/** Strip a Markdown code fence a model wrapped its JSON in. */
|
|
83558
|
+
function unfence(text) {
|
|
83559
|
+
const trimmed = text.trim();
|
|
83560
|
+
const fence = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
|
|
83561
|
+
return fence?.[1] !== void 0 ? fence[1].trim() : trimmed;
|
|
83562
|
+
}
|
|
83563
|
+
/** A free-text label, never a macro word (the tier rule). */
|
|
83564
|
+
function usableLabel(label) {
|
|
83565
|
+
const trimmed = label?.trim();
|
|
83566
|
+
if (trimmed === void 0 || trimmed === "") return void 0;
|
|
83567
|
+
return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
|
|
83568
|
+
}
|
|
83569
|
+
/** Parse the model's answer. See the module header for the rules. */
|
|
83570
|
+
function parseLlmAssistAnswer(text, expected) {
|
|
83571
|
+
let raw;
|
|
83572
|
+
try {
|
|
83573
|
+
raw = JSON.parse(unfence(text));
|
|
83574
|
+
} catch {
|
|
83575
|
+
return {
|
|
83576
|
+
kind: "malformed",
|
|
83577
|
+
detail: "the answer is not JSON"
|
|
83578
|
+
};
|
|
83579
|
+
}
|
|
83580
|
+
const answer = AnswerSchema.safeParse(raw);
|
|
83581
|
+
if (!answer.success) return {
|
|
83582
|
+
kind: "malformed",
|
|
83583
|
+
detail: "the answer is not {\"objects\": [...], \"reviews\": [...]}"
|
|
83584
|
+
};
|
|
83585
|
+
const asked = new Set(expected.classes);
|
|
83586
|
+
let rejected = 0;
|
|
83587
|
+
const objects = [];
|
|
83588
|
+
for (const item of answer.data.objects) {
|
|
83589
|
+
const parsed = ObjectSchema.safeParse(item);
|
|
83590
|
+
const cls = parsed.success ? parsed.data.class.trim().toLowerCase() : "";
|
|
83591
|
+
const bbox = parsed.success ? frameBox(parsed.data.box) : null;
|
|
83592
|
+
if (!parsed.success || bbox === null || !asked.has(cls) || !isRetrainMacroClass(cls)) {
|
|
83593
|
+
rejected += 1;
|
|
83594
|
+
continue;
|
|
83595
|
+
}
|
|
83596
|
+
const label = usableLabel(parsed.data.label);
|
|
83597
|
+
objects.push({
|
|
83598
|
+
macroClass: cls,
|
|
83599
|
+
...label !== void 0 ? { label } : {},
|
|
83600
|
+
bbox,
|
|
83601
|
+
confidence: parsed.data.confidence,
|
|
83602
|
+
reason: parsed.data.reason
|
|
83603
|
+
});
|
|
83604
|
+
}
|
|
83605
|
+
const reviews = [];
|
|
83606
|
+
for (const item of answer.data.reviews) {
|
|
83607
|
+
const parsed = ReviewSchema.safeParse(item);
|
|
83608
|
+
if (!parsed.success || !expected.refs.has(parsed.data.ref)) {
|
|
83609
|
+
rejected += 1;
|
|
83610
|
+
continue;
|
|
83611
|
+
}
|
|
83612
|
+
const suggested = parsed.data.suggestedClass?.trim().toLowerCase();
|
|
83613
|
+
reviews.push({
|
|
83614
|
+
ref: parsed.data.ref,
|
|
83615
|
+
verdict: parsed.data.verdict,
|
|
83616
|
+
...suggested !== void 0 && isRetrainMacroClass(suggested) ? { suggestedClass: suggested } : {},
|
|
83617
|
+
reason: parsed.data.reason
|
|
83618
|
+
});
|
|
83619
|
+
}
|
|
83620
|
+
return {
|
|
83621
|
+
kind: "parsed",
|
|
83622
|
+
objects,
|
|
83623
|
+
reviews,
|
|
83624
|
+
rejected
|
|
83625
|
+
};
|
|
83626
|
+
}
|
|
83627
|
+
//#endregion
|
|
83628
|
+
//#region src/pipeline-analytics/retrain/retrain-llm-service.ts
|
|
83629
|
+
/** The usage tag every AI-assist call is counted under (`llm.getUsage`). */
|
|
83630
|
+
var RETRAIN_LLM_CONSUMER = "retrain-assist";
|
|
83631
|
+
/** One frame, one answer: a cold local vision model can take a minute. */
|
|
83632
|
+
var RETRAIN_LLM_TIMEOUT_MS = 15e4;
|
|
83633
|
+
var RetrainLlmAssistService = class {
|
|
83634
|
+
deps;
|
|
83635
|
+
constructor(deps) {
|
|
83636
|
+
this.deps = deps;
|
|
83637
|
+
}
|
|
83638
|
+
/** The enabled, vision-capable profiles — a text-only one would be refused. */
|
|
83639
|
+
async listProfiles() {
|
|
83640
|
+
return (await this.deps.llm.listProfiles()).filter((p) => p.enabled && p.supportsVision).map((p) => ({
|
|
83641
|
+
id: p.id,
|
|
83642
|
+
name: p.name,
|
|
83643
|
+
model: p.model,
|
|
83644
|
+
kind: p.kind,
|
|
83645
|
+
leavesHouse: llmProfileLeavesHouse(p)
|
|
83646
|
+
}));
|
|
83647
|
+
}
|
|
83648
|
+
async propose(input) {
|
|
83649
|
+
const tags = { deviceId: input.deviceId };
|
|
83650
|
+
const frame = await this.deps.frames.get(input.frameId);
|
|
83651
|
+
if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
|
|
83652
|
+
kind: "refused",
|
|
83653
|
+
reason: "unknown-frame"
|
|
83654
|
+
};
|
|
83655
|
+
const profile = (await this.deps.llm.listProfiles()).find((p) => p.id === input.profileId);
|
|
83656
|
+
if (profile === void 0) return {
|
|
83657
|
+
kind: "refused",
|
|
83658
|
+
reason: "unknown-profile"
|
|
83659
|
+
};
|
|
83660
|
+
if (!profile.enabled) return {
|
|
83661
|
+
kind: "refused",
|
|
83662
|
+
reason: "profile-disabled"
|
|
83663
|
+
};
|
|
83664
|
+
if (!profile.supportsVision) return {
|
|
83665
|
+
kind: "refused",
|
|
83666
|
+
reason: "no-vision"
|
|
83667
|
+
};
|
|
83668
|
+
const leavesHouse = llmProfileLeavesHouse(profile);
|
|
83669
|
+
if (leavesHouse && !input.allowCloud) {
|
|
83670
|
+
this.deps.logger.warn("retrain AI assist refused: a cloud profile without confirmation", {
|
|
83671
|
+
tags,
|
|
83672
|
+
meta: {
|
|
83673
|
+
frameId: input.frameId,
|
|
83674
|
+
profileId: profile.id
|
|
83675
|
+
}
|
|
83676
|
+
});
|
|
82530
83677
|
return {
|
|
82531
|
-
|
|
82532
|
-
|
|
82533
|
-
|
|
82534
|
-
frameCount: 0
|
|
83678
|
+
kind: "refused",
|
|
83679
|
+
reason: "cloud-not-confirmed",
|
|
83680
|
+
detail: "this profile sends the frame outside the house; confirm the run first"
|
|
82535
83681
|
};
|
|
82536
|
-
} catch (err) {
|
|
82537
|
-
this.logger.warn("retrain collection findByNameKey failed", { meta: {
|
|
82538
|
-
nameKey,
|
|
82539
|
-
error: String(err)
|
|
82540
|
-
} });
|
|
82541
|
-
return null;
|
|
82542
83682
|
}
|
|
83683
|
+
const bytes = await this.deps.frames.readFrame(frame);
|
|
83684
|
+
if (bytes === null) return {
|
|
83685
|
+
kind: "refused",
|
|
83686
|
+
reason: "unreadable-frame"
|
|
83687
|
+
};
|
|
83688
|
+
const request = buildLlmAssistRequest({
|
|
83689
|
+
classes: input.classes,
|
|
83690
|
+
width: frame.width,
|
|
83691
|
+
height: frame.height,
|
|
83692
|
+
boxes: input.boxes
|
|
83693
|
+
});
|
|
83694
|
+
const answer = await this.deps.llm.generateVision({
|
|
83695
|
+
profileId: profile.id,
|
|
83696
|
+
system: request.system,
|
|
83697
|
+
prompt: request.prompt,
|
|
83698
|
+
jsonSchema: request.jsonSchema,
|
|
83699
|
+
image: bytes
|
|
83700
|
+
});
|
|
83701
|
+
if (!answer.ok) {
|
|
83702
|
+
this.deps.logger.warn("retrain AI assist: the model call failed", {
|
|
83703
|
+
tags,
|
|
83704
|
+
meta: {
|
|
83705
|
+
frameId: input.frameId,
|
|
83706
|
+
profileId: profile.id,
|
|
83707
|
+
code: answer.code
|
|
83708
|
+
}
|
|
83709
|
+
});
|
|
83710
|
+
return {
|
|
83711
|
+
kind: "refused",
|
|
83712
|
+
reason: "llm-error",
|
|
83713
|
+
detail: `${answer.code}: ${answer.message}`
|
|
83714
|
+
};
|
|
83715
|
+
}
|
|
83716
|
+
const parsed = parseLlmAssistAnswer(answer.text, {
|
|
83717
|
+
classes: input.classes,
|
|
83718
|
+
refs: new Set(input.boxes.map((b) => b.ref))
|
|
83719
|
+
});
|
|
83720
|
+
if (parsed.kind === "malformed") {
|
|
83721
|
+
this.deps.logger.warn("retrain AI assist: the answer was refused as malformed", {
|
|
83722
|
+
tags,
|
|
83723
|
+
meta: {
|
|
83724
|
+
frameId: input.frameId,
|
|
83725
|
+
profileId: profile.id,
|
|
83726
|
+
model: answer.model,
|
|
83727
|
+
detail: parsed.detail,
|
|
83728
|
+
answerChars: answer.text.length
|
|
83729
|
+
}
|
|
83730
|
+
});
|
|
83731
|
+
return {
|
|
83732
|
+
kind: "refused",
|
|
83733
|
+
reason: "malformed-answer",
|
|
83734
|
+
detail: parsed.detail
|
|
83735
|
+
};
|
|
83736
|
+
}
|
|
83737
|
+
this.deps.logger.info("retrain AI assist proposed boxes", {
|
|
83738
|
+
tags,
|
|
83739
|
+
meta: {
|
|
83740
|
+
frameId: input.frameId,
|
|
83741
|
+
profileId: profile.id,
|
|
83742
|
+
model: answer.model,
|
|
83743
|
+
leftHouse: leavesHouse,
|
|
83744
|
+
proposals: parsed.objects.length,
|
|
83745
|
+
reviews: parsed.reviews.length,
|
|
83746
|
+
rejected: parsed.rejected
|
|
83747
|
+
}
|
|
83748
|
+
});
|
|
83749
|
+
return {
|
|
83750
|
+
kind: "proposed",
|
|
83751
|
+
profileId: profile.id,
|
|
83752
|
+
model: answer.model,
|
|
83753
|
+
leftHouse: leavesHouse,
|
|
83754
|
+
proposals: parsed.objects.map((o) => ({
|
|
83755
|
+
bbox: o.bbox,
|
|
83756
|
+
macroClass: o.macroClass,
|
|
83757
|
+
...o.label !== void 0 ? { label: o.label } : {},
|
|
83758
|
+
kind: "subject",
|
|
83759
|
+
source: "llm",
|
|
83760
|
+
assistModelId: answer.model,
|
|
83761
|
+
assistScore: o.confidence,
|
|
83762
|
+
reason: o.reason
|
|
83763
|
+
})),
|
|
83764
|
+
reviews: parsed.reviews.map((r) => ({
|
|
83765
|
+
ref: r.ref,
|
|
83766
|
+
verdict: r.verdict,
|
|
83767
|
+
...r.suggestedClass !== void 0 ? { suggestedClass: r.suggestedClass } : {},
|
|
83768
|
+
reason: r.reason
|
|
83769
|
+
})),
|
|
83770
|
+
rejected: parsed.rejected
|
|
83771
|
+
};
|
|
82543
83772
|
}
|
|
82544
83773
|
};
|
|
82545
83774
|
//#endregion
|
|
83775
|
+
//#region src/pipeline-analytics/retrain/retrain-archive-upload.ts
|
|
83776
|
+
/**
|
|
83777
|
+
* The HTTP sink behind `pipelineAnalytics.writeRetrainArchive`: ONE request
|
|
83778
|
+
* whose body is the archive, written chunk by chunk with backpressure honoured
|
|
83779
|
+
* (D447) — the archive is never assembled in memory or on disk.
|
|
83780
|
+
*
|
|
83781
|
+
* The target is admin-supplied (the training orchestrator hands over a
|
|
83782
|
+
* provider's presigned URL or its upload route). Its headers may carry a
|
|
83783
|
+
* credential, so they are sent and never logged.
|
|
83784
|
+
*/
|
|
83785
|
+
function openRetrainUploadSink(target) {
|
|
83786
|
+
const url = new URL(target.url);
|
|
83787
|
+
const req = (url.protocol === "https:" ? node_https.request : node_http.request)(url, {
|
|
83788
|
+
method: target.method,
|
|
83789
|
+
headers: {
|
|
83790
|
+
"content-type": "application/x-tar",
|
|
83791
|
+
...target.headers
|
|
83792
|
+
}
|
|
83793
|
+
});
|
|
83794
|
+
let failure = null;
|
|
83795
|
+
const answer = new Promise((resolve, reject) => {
|
|
83796
|
+
req.on("response", resolve);
|
|
83797
|
+
req.on("error", (err) => {
|
|
83798
|
+
failure = err;
|
|
83799
|
+
reject(err);
|
|
83800
|
+
});
|
|
83801
|
+
});
|
|
83802
|
+
answer.catch(() => void 0);
|
|
83803
|
+
return {
|
|
83804
|
+
sink: { write: (chunk) => new Promise((resolve, reject) => {
|
|
83805
|
+
if (failure !== null) {
|
|
83806
|
+
reject(failure);
|
|
83807
|
+
return;
|
|
83808
|
+
}
|
|
83809
|
+
if (req.write(chunk, (err) => {
|
|
83810
|
+
if (err) reject(err);
|
|
83811
|
+
})) resolve();
|
|
83812
|
+
else req.once("drain", resolve);
|
|
83813
|
+
}) },
|
|
83814
|
+
finish: async () => {
|
|
83815
|
+
req.end();
|
|
83816
|
+
const res = await answer;
|
|
83817
|
+
res.resume();
|
|
83818
|
+
const status = res.statusCode ?? 0;
|
|
83819
|
+
if (status < 200 || status >= 300) throw new Error(`retrain upload refused by the target: HTTP ${status}`);
|
|
83820
|
+
return status;
|
|
83821
|
+
},
|
|
83822
|
+
abort: (reason) => {
|
|
83823
|
+
req.destroy(new Error(reason));
|
|
83824
|
+
}
|
|
83825
|
+
};
|
|
83826
|
+
}
|
|
83827
|
+
//#endregion
|
|
83828
|
+
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
83829
|
+
/**
|
|
83830
|
+
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
83831
|
+
* entry, without ever holding the archive in memory.
|
|
83832
|
+
*
|
|
83833
|
+
* ## Why hand-rolled rather than the `tar` package
|
|
83834
|
+
*
|
|
83835
|
+
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
83836
|
+
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
83837
|
+
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
83838
|
+
* operator relocated to), under paths that encode nothing a training set wants —
|
|
83839
|
+
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
83840
|
+
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
83841
|
+
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
83842
|
+
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
83843
|
+
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
83844
|
+
*
|
|
83845
|
+
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
83846
|
+
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
83847
|
+
* door rather than against my reading of the format.
|
|
83848
|
+
*
|
|
83849
|
+
* ## What this deliberately does NOT do
|
|
83850
|
+
*
|
|
83851
|
+
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
83852
|
+
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
83853
|
+
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
83854
|
+
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
83855
|
+
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
83856
|
+
*/
|
|
83857
|
+
var BLOCK_SIZE = 512;
|
|
83858
|
+
var NAME_MAX = 100;
|
|
83859
|
+
var PREFIX_MAX = 155;
|
|
83860
|
+
/**
|
|
83861
|
+
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
83862
|
+
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
83863
|
+
*/
|
|
83864
|
+
function splitUstarName(name) {
|
|
83865
|
+
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
83866
|
+
prefix: "",
|
|
83867
|
+
name
|
|
83868
|
+
};
|
|
83869
|
+
for (let i = name.length - 1; i >= 0; i--) {
|
|
83870
|
+
if (name[i] !== "/") continue;
|
|
83871
|
+
const head = name.slice(0, i);
|
|
83872
|
+
const tail = name.slice(i + 1);
|
|
83873
|
+
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
83874
|
+
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
83875
|
+
prefix: head,
|
|
83876
|
+
name: tail
|
|
83877
|
+
};
|
|
83878
|
+
}
|
|
83879
|
+
return null;
|
|
83880
|
+
}
|
|
83881
|
+
/** True when a path cannot be represented as a ustar entry name. */
|
|
83882
|
+
function tarNameTooLong(name) {
|
|
83883
|
+
return splitUstarName(name) === null;
|
|
83884
|
+
}
|
|
83885
|
+
function writeString(block, value, offset, length) {
|
|
83886
|
+
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
83887
|
+
}
|
|
83888
|
+
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
83889
|
+
function writeOctal(block, value, offset, length) {
|
|
83890
|
+
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
83891
|
+
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
83892
|
+
}
|
|
83893
|
+
/**
|
|
83894
|
+
* The 512-byte header for one regular file.
|
|
83895
|
+
*
|
|
83896
|
+
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
83897
|
+
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
83898
|
+
* fine until something tries to read it.
|
|
83899
|
+
*/
|
|
83900
|
+
function tarHeader(entry) {
|
|
83901
|
+
const split = splitUstarName(entry.name);
|
|
83902
|
+
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
83903
|
+
const block = Buffer.alloc(BLOCK_SIZE);
|
|
83904
|
+
writeString(block, split.name, 0, NAME_MAX);
|
|
83905
|
+
writeOctal(block, 420, 100, 8);
|
|
83906
|
+
writeOctal(block, 0, 108, 8);
|
|
83907
|
+
writeOctal(block, 0, 116, 8);
|
|
83908
|
+
writeOctal(block, entry.body.length, 124, 12);
|
|
83909
|
+
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
83910
|
+
block.write(" ", 148, 8, "ascii");
|
|
83911
|
+
block.write("0", 156, 1, "ascii");
|
|
83912
|
+
block.write("ustar\0", 257, 6, "ascii");
|
|
83913
|
+
block.write("00", 263, 2, "ascii");
|
|
83914
|
+
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
83915
|
+
let sum = 0;
|
|
83916
|
+
for (const byte of block) sum += byte;
|
|
83917
|
+
writeOctal(block, sum, 148, 7);
|
|
83918
|
+
block.write("\0", 154, 1, "ascii");
|
|
83919
|
+
block.write(" ", 155, 1, "ascii");
|
|
83920
|
+
return block;
|
|
83921
|
+
}
|
|
83922
|
+
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
83923
|
+
function tarPadding(size) {
|
|
83924
|
+
const remainder = size % BLOCK_SIZE;
|
|
83925
|
+
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
83926
|
+
}
|
|
83927
|
+
/** Header + body + padding for one entry, ready to write. */
|
|
83928
|
+
function tarBlocks(entry) {
|
|
83929
|
+
return [
|
|
83930
|
+
tarHeader(entry),
|
|
83931
|
+
entry.body,
|
|
83932
|
+
tarPadding(entry.body.length)
|
|
83933
|
+
];
|
|
83934
|
+
}
|
|
83935
|
+
/** The two zero blocks that terminate an archive. */
|
|
83936
|
+
function tarTrailer() {
|
|
83937
|
+
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
83938
|
+
}
|
|
83939
|
+
//#endregion
|
|
83940
|
+
//#region src/pipeline-analytics/retrain/retrain-archive-writer.ts
|
|
83941
|
+
/**
|
|
83942
|
+
* The annotated archive body, written to ANY sink.
|
|
83943
|
+
*
|
|
83944
|
+
* One body, two consumers: the data-plane download (`retrain-export-handler`,
|
|
83945
|
+
* sink = the HTTP response) and `pipelineAnalytics.writeRetrainArchive` (sink
|
|
83946
|
+
* = one streamed HTTP upload to a training provider). The disciplines are the
|
|
83947
|
+
* training export's: the manifest is the first entry, one JPEG is in memory at
|
|
83948
|
+
* a time, a pause between files keeps the hub's one thread available, and the
|
|
83949
|
+
* batch id is stamped only on what the sink actually received.
|
|
83950
|
+
*/
|
|
83951
|
+
var RETRAIN_MANIFEST_ENTRY = "annotations.json";
|
|
83952
|
+
/** Pause between files so a large export cannot monopolise the hub's thread. */
|
|
83953
|
+
var RETRAIN_ARCHIVE_PACING_MS = 25;
|
|
83954
|
+
async function writeRetrainArchiveTo(deps) {
|
|
83955
|
+
const { plan, logger } = deps;
|
|
83956
|
+
const pacingMs = deps.pacingMs ?? RETRAIN_ARCHIVE_PACING_MS;
|
|
83957
|
+
const hash = (0, node_crypto.createHash)("sha256");
|
|
83958
|
+
let bytes = 0;
|
|
83959
|
+
const write = async (chunk) => {
|
|
83960
|
+
hash.update(chunk);
|
|
83961
|
+
bytes += chunk.length;
|
|
83962
|
+
await deps.sink.write(chunk);
|
|
83963
|
+
};
|
|
83964
|
+
const writeEntry = async (name, body, mtimeMs) => {
|
|
83965
|
+
for (const block of tarBlocks({
|
|
83966
|
+
name,
|
|
83967
|
+
body,
|
|
83968
|
+
mtimeMs
|
|
83969
|
+
})) await write(block);
|
|
83970
|
+
};
|
|
83971
|
+
const mtimeMs = deps.now();
|
|
83972
|
+
await writeEntry(RETRAIN_MANIFEST_ENTRY, Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"), mtimeMs);
|
|
83973
|
+
const byId = new Map(plan.manifest.frames.map((f) => [f.frameId, f]));
|
|
83974
|
+
const shipped = [];
|
|
83975
|
+
let missing = 0;
|
|
83976
|
+
let crops = 0;
|
|
83977
|
+
for (const frame of plan.frames) {
|
|
83978
|
+
const entry = byId.get(frame.frameId);
|
|
83979
|
+
if (entry === void 0) continue;
|
|
83980
|
+
if (tarNameTooLong(entry.path)) {
|
|
83981
|
+
missing += 1;
|
|
83982
|
+
logger.warn("retrain export skipped a frame — path too long for the archive", {
|
|
83983
|
+
tags: { deviceId: frame.deviceId },
|
|
83984
|
+
meta: {
|
|
83985
|
+
trackId: frame.trackId,
|
|
83986
|
+
frameId: frame.frameId,
|
|
83987
|
+
path: entry.path
|
|
83988
|
+
}
|
|
83989
|
+
});
|
|
83990
|
+
continue;
|
|
83991
|
+
}
|
|
83992
|
+
const frameBytes = await deps.readFrame(frame);
|
|
83993
|
+
if (frameBytes === null) {
|
|
83994
|
+
missing += 1;
|
|
83995
|
+
logger.warn("retrain export skipped a frame — the COPY is unreadable", {
|
|
83996
|
+
tags: { deviceId: frame.deviceId },
|
|
83997
|
+
meta: {
|
|
83998
|
+
trackId: frame.trackId,
|
|
83999
|
+
frameId: frame.frameId
|
|
84000
|
+
}
|
|
84001
|
+
});
|
|
84002
|
+
continue;
|
|
84003
|
+
}
|
|
84004
|
+
await writeEntry(entry.path, frameBytes, mtimeMs);
|
|
84005
|
+
for (const annotation of entry.annotations) {
|
|
84006
|
+
if (annotation.cropPath === void 0) continue;
|
|
84007
|
+
const crop = deps.cutCrop ? await deps.cutCrop(frameBytes, frame, annotation) : null;
|
|
84008
|
+
if (crop === null) {
|
|
84009
|
+
logger.warn("retrain export skipped a crop — it could not be cut", {
|
|
84010
|
+
tags: { deviceId: frame.deviceId },
|
|
84011
|
+
meta: {
|
|
84012
|
+
frameId: frame.frameId,
|
|
84013
|
+
annotationId: annotation.id,
|
|
84014
|
+
cropPath: annotation.cropPath
|
|
84015
|
+
}
|
|
84016
|
+
});
|
|
84017
|
+
continue;
|
|
84018
|
+
}
|
|
84019
|
+
await writeEntry(annotation.cropPath, crop, mtimeMs);
|
|
84020
|
+
crops += 1;
|
|
84021
|
+
}
|
|
84022
|
+
shipped.push(frame.frameId);
|
|
84023
|
+
if (pacingMs > 0) await new Promise((resolve) => setTimeout(resolve, pacingMs));
|
|
84024
|
+
}
|
|
84025
|
+
await write(tarTrailer());
|
|
84026
|
+
await deps.markExported(shipped, plan.manifest.batchId);
|
|
84027
|
+
const stats = {
|
|
84028
|
+
batchId: plan.manifest.batchId,
|
|
84029
|
+
frames: shipped.length,
|
|
84030
|
+
crops,
|
|
84031
|
+
annotations: plan.manifest.annotationCount,
|
|
84032
|
+
missing,
|
|
84033
|
+
bytes,
|
|
84034
|
+
sha256: hash.digest("hex")
|
|
84035
|
+
};
|
|
84036
|
+
logger.info("retrain export complete", { meta: {
|
|
84037
|
+
...stats,
|
|
84038
|
+
layout: plan.manifest.layout,
|
|
84039
|
+
modelErrors: plan.manifest.modelErrorCount
|
|
84040
|
+
} });
|
|
84041
|
+
return stats;
|
|
84042
|
+
}
|
|
84043
|
+
//#endregion
|
|
84044
|
+
//#region src/pipeline-analytics/retrain/retrain-crop-cut.ts
|
|
84045
|
+
/**
|
|
84046
|
+
* The `crops` layout's cutter: one subject crop from a retrain frame copy.
|
|
84047
|
+
*
|
|
84048
|
+
* A classifier must be trained on the crop the runtime feeds it, so the
|
|
84049
|
+
* rectangle comes from THE detail-crop derivation (`deriveDetailCropRect`,
|
|
84050
|
+
* `types/src/pipeline/detail-crop.ts`) with the convention the manifest
|
|
84051
|
+
* records — never a second rectangle maths. This is the second permitted
|
|
84052
|
+
* caller of that derivation (`scripts/check-clip-crop-single-owner.ts`): it
|
|
84053
|
+
* writes training files, never a vector, so it cannot split a feature space;
|
|
84054
|
+
* what it must not do is cut differently, and calling the one function is how
|
|
84055
|
+
* it doesn't.
|
|
84056
|
+
*/
|
|
84057
|
+
/** The pixel window to extract, or `null` when nothing of it is inside the frame. */
|
|
84058
|
+
function retrainCropWindow(input) {
|
|
84059
|
+
const { frameWidth: fw, frameHeight: fh, bbox } = input;
|
|
84060
|
+
const rect = require_clip_model_registry.deriveDetailCropRect({
|
|
84061
|
+
x: bbox.x * fw,
|
|
84062
|
+
y: bbox.y * fh,
|
|
84063
|
+
w: bbox.w * fw,
|
|
84064
|
+
h: bbox.h * fh
|
|
84065
|
+
}, fw, fh, input.convention);
|
|
84066
|
+
const left = Math.max(0, Math.floor(rect.x));
|
|
84067
|
+
const top = Math.max(0, Math.floor(rect.y));
|
|
84068
|
+
const right = Math.min(fw, Math.ceil(rect.x + rect.w));
|
|
84069
|
+
const bottom = Math.min(fh, Math.ceil(rect.y + rect.h));
|
|
84070
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
84071
|
+
return {
|
|
84072
|
+
left,
|
|
84073
|
+
top,
|
|
84074
|
+
width: right - left,
|
|
84075
|
+
height: bottom - top
|
|
84076
|
+
};
|
|
84077
|
+
}
|
|
84078
|
+
async function cutRetrainCrop(input) {
|
|
84079
|
+
const window = retrainCropWindow(input);
|
|
84080
|
+
if (window === null) return null;
|
|
84081
|
+
let pipeline = (0, sharp.default)(input.frameBytes).extract(window);
|
|
84082
|
+
if (input.maxSide > 0) pipeline = pipeline.resize({
|
|
84083
|
+
width: input.maxSide,
|
|
84084
|
+
height: input.maxSide,
|
|
84085
|
+
fit: "inside",
|
|
84086
|
+
withoutEnlargement: true
|
|
84087
|
+
});
|
|
84088
|
+
return pipeline.jpeg({ quality: 92 }).toBuffer();
|
|
84089
|
+
}
|
|
84090
|
+
//#endregion
|
|
82546
84091
|
//#region src/pipeline-analytics/retrain/retrain-geometry.ts
|
|
82547
84092
|
function clamp01(v) {
|
|
82548
84093
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
@@ -82638,14 +84183,106 @@ function deriveSubjectCropRect(bbox, padding) {
|
|
|
82638
84183
|
};
|
|
82639
84184
|
}
|
|
82640
84185
|
//#endregion
|
|
84186
|
+
//#region src/pipeline-analytics/retrain/retrain-lighting.ts
|
|
84187
|
+
function isNight(condition) {
|
|
84188
|
+
return condition === "night" || condition === "ir";
|
|
84189
|
+
}
|
|
84190
|
+
function retrainFrameLighting(input) {
|
|
84191
|
+
if (input.capturedAt === void 0) {
|
|
84192
|
+
const measured = resolveConditionDetailed({
|
|
84193
|
+
...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
|
|
84194
|
+
now: /* @__PURE__ */ new Date(0)
|
|
84195
|
+
});
|
|
84196
|
+
return measured.source === "ir-chroma" ? {
|
|
84197
|
+
condition: measured.condition,
|
|
84198
|
+
night: true,
|
|
84199
|
+
source: measured.source
|
|
84200
|
+
} : null;
|
|
84201
|
+
}
|
|
84202
|
+
const resolved = resolveConditionDetailed({
|
|
84203
|
+
...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
|
|
84204
|
+
...input.coordinates !== null ? { coordinates: input.coordinates } : {},
|
|
84205
|
+
now: new Date(input.capturedAt)
|
|
84206
|
+
});
|
|
84207
|
+
return {
|
|
84208
|
+
...resolved,
|
|
84209
|
+
night: isNight(resolved.condition)
|
|
84210
|
+
};
|
|
84211
|
+
}
|
|
84212
|
+
//#endregion
|
|
82641
84213
|
//#region src/pipeline-analytics/retrain/retrain-export.ts
|
|
84214
|
+
/**
|
|
84215
|
+
* The ANNOTATED dataset export — the retrain page's own archive.
|
|
84216
|
+
*
|
|
84217
|
+
* Sibling of `training-export/`, deliberately not the same thing. That one
|
|
84218
|
+
* streams a marked track's stored media verbatim and knows nothing about what
|
|
84219
|
+
* is in it; this one streams the frames the operator SELECTED (already copied
|
|
84220
|
+
* into retrain storage) plus the ground truth they drew.
|
|
84221
|
+
*
|
|
84222
|
+
* ## The one interesting decision
|
|
84223
|
+
*
|
|
84224
|
+
* `annotations.json` carries, for every subject, the canonical full-frame box
|
|
84225
|
+
* AND the geometry derived for each model shape. **Derived here, at export,
|
|
84226
|
+
* never stored** — one box in, three shapes out. Storing the derived shapes is
|
|
84227
|
+
* how two crops of the same subject end up in one feature space, which is the
|
|
84228
|
+
* failure already recorded for CLIP (D52). A consumer that wants a different
|
|
84229
|
+
* input size or a different padding re-derives from the canonical box; nothing
|
|
84230
|
+
* in the database has to change and nothing can disagree.
|
|
84231
|
+
*
|
|
84232
|
+
* Everything else follows the export next door: index reads to plan, one JPEG
|
|
84233
|
+
* in memory at a time, a yield between files, and NO inference — a training
|
|
84234
|
+
* set's pixels are the pixels the pipeline saw.
|
|
84235
|
+
*/
|
|
82642
84236
|
var RETRAIN_EXPORT_PREFIX = "retrain-export";
|
|
82643
|
-
/**
|
|
82644
|
-
|
|
82645
|
-
|
|
84237
|
+
/**
|
|
84238
|
+
* Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
|
|
84239
|
+
* padding, read from the one convention `deriveDetailCropRect` cuts with
|
|
84240
|
+
* (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
|
|
84241
|
+
* here while the runtime fed `0.15`, so a classifier trained from this export
|
|
84242
|
+
* learned on more context than it is ever given live.
|
|
84243
|
+
*/
|
|
84244
|
+
var CLASSIFIER_CROP_PADDING = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
|
|
84245
|
+
/** The convention a `crops` layout cuts with — the runtime default, recorded
|
|
84246
|
+
* in the manifest so a consumer never has to assume it. */
|
|
84247
|
+
var RETRAIN_CROP_CONVENTION = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION;
|
|
84248
|
+
/** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
|
|
84249
|
+
* the trainer resizes per model, so this only bounds the upload. */
|
|
84250
|
+
var RETRAIN_CROP_MAX_SIDE = 256;
|
|
84251
|
+
/**
|
|
84252
|
+
* Manifest schema id. v2 = capturedAt / complete / lighting / layout.
|
|
84253
|
+
* v3 = `origin` (`track` | `recording`), `epochMs` on a recording still,
|
|
84254
|
+
* `collectionIds`, and `trackId` ABSENT on a recording still, which has none.
|
|
84255
|
+
*/
|
|
84256
|
+
var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v3";
|
|
84257
|
+
/** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`,
|
|
84258
|
+
* or `frames/<deviceId>/recording/<leaf>.jpg` for a recording still. */
|
|
82646
84259
|
function retrainFramePath(frame) {
|
|
82647
84260
|
const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
|
|
82648
|
-
|
|
84261
|
+
const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
|
|
84262
|
+
return `frames/${frame.deviceId}/${dir}/${leaf}.jpg`;
|
|
84263
|
+
}
|
|
84264
|
+
/** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
|
|
84265
|
+
function retrainCropPath(macroClass, annotationId) {
|
|
84266
|
+
return `crops/${macroClass}/${annotationId}.jpg`;
|
|
84267
|
+
}
|
|
84268
|
+
/**
|
|
84269
|
+
* Narrow the planned frames to a filter. Pure. Every field narrows; an empty
|
|
84270
|
+
* filter keeps everything. Order is the input's.
|
|
84271
|
+
*/
|
|
84272
|
+
function filterRetrainExport(frames, annotationsByFrame, filter, collectionsByFrame = /* @__PURE__ */ new Map()) {
|
|
84273
|
+
const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
|
|
84274
|
+
const collections = filter.collectionIds !== void 0 && filter.collectionIds.length > 0 ? new Set(filter.collectionIds) : null;
|
|
84275
|
+
const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
|
|
84276
|
+
return frames.filter((frame) => {
|
|
84277
|
+
if (wanted !== null && !wanted.has(frame.frameId)) return false;
|
|
84278
|
+
if (devices !== null && !devices.has(frame.deviceId)) return false;
|
|
84279
|
+
if (filter.completeOnly === true && !frame.complete) return false;
|
|
84280
|
+
if (collections !== null && !(collectionsByFrame.get(frame.frameId) ?? []).some((id) => collections.has(id))) return false;
|
|
84281
|
+
if (filter.batchId !== void 0) {
|
|
84282
|
+
if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
|
|
84283
|
+
}
|
|
84284
|
+
return true;
|
|
84285
|
+
});
|
|
82649
84286
|
}
|
|
82650
84287
|
/**
|
|
82651
84288
|
* Derive every per-model shape for one annotation.
|
|
@@ -82667,6 +84304,8 @@ function deriveAll(annotation, frame, zone) {
|
|
|
82667
84304
|
}
|
|
82668
84305
|
/** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
|
|
82669
84306
|
function buildRetrainManifest(input) {
|
|
84307
|
+
const layout = input.layout ?? "frames";
|
|
84308
|
+
const coordinates = input.coordinates ?? null;
|
|
82670
84309
|
let annotationCount = 0;
|
|
82671
84310
|
let modelErrorCount = 0;
|
|
82672
84311
|
const frames = input.frames.map((frame) => {
|
|
@@ -82674,6 +84313,7 @@ function buildRetrainManifest(input) {
|
|
|
82674
84313
|
const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
|
|
82675
84314
|
annotationCount += 1;
|
|
82676
84315
|
if (a.kind === "model_error") modelErrorCount += 1;
|
|
84316
|
+
const derived = deriveAll(a, frame, zone);
|
|
82677
84317
|
return {
|
|
82678
84318
|
id: a.id,
|
|
82679
84319
|
macroClass: a.macroClass,
|
|
@@ -82684,24 +84324,39 @@ function buildRetrainManifest(input) {
|
|
|
82684
84324
|
...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
|
|
82685
84325
|
...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
|
|
82686
84326
|
bbox: a.bbox,
|
|
82687
|
-
derived:
|
|
84327
|
+
...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
|
|
84328
|
+
derived
|
|
82688
84329
|
};
|
|
82689
84330
|
});
|
|
82690
84331
|
return {
|
|
82691
84332
|
frameId: frame.frameId,
|
|
82692
84333
|
path: retrainFramePath(frame),
|
|
82693
84334
|
deviceId: frame.deviceId,
|
|
82694
|
-
|
|
84335
|
+
origin: frame.origin,
|
|
84336
|
+
...frame.origin === "track" ? { trackId: frame.trackId } : {},
|
|
84337
|
+
...frame.epochMs !== void 0 ? { epochMs: frame.epochMs } : {},
|
|
84338
|
+
collectionIds: input.collectionsByFrame?.get(frame.frameId) ?? [],
|
|
82695
84339
|
width: frame.width,
|
|
82696
84340
|
height: frame.height,
|
|
82697
84341
|
sizeBytes: frame.sizeBytes,
|
|
82698
84342
|
sourceKind: frame.sourceKind,
|
|
82699
84343
|
copiedAt: frame.copiedAt,
|
|
84344
|
+
...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
|
|
84345
|
+
complete: frame.complete,
|
|
84346
|
+
lighting: retrainFrameLighting({
|
|
84347
|
+
...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
|
|
84348
|
+
...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
|
|
84349
|
+
coordinates
|
|
84350
|
+
}),
|
|
82700
84351
|
...zone !== null ? { zoneUsed: zone } : {},
|
|
82701
84352
|
annotations
|
|
82702
84353
|
};
|
|
82703
84354
|
});
|
|
82704
84355
|
return {
|
|
84356
|
+
schema: RETRAIN_MANIFEST_SCHEMA,
|
|
84357
|
+
layout,
|
|
84358
|
+
detailCropConvention: RETRAIN_CROP_CONVENTION,
|
|
84359
|
+
...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
|
|
82705
84360
|
generatedAt: input.generatedAt,
|
|
82706
84361
|
batchId: input.batchId,
|
|
82707
84362
|
frameCount: frames.length,
|
|
@@ -82712,123 +84367,39 @@ function buildRetrainManifest(input) {
|
|
|
82712
84367
|
};
|
|
82713
84368
|
}
|
|
82714
84369
|
//#endregion
|
|
82715
|
-
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
82716
|
-
/**
|
|
82717
|
-
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
82718
|
-
* entry, without ever holding the archive in memory.
|
|
82719
|
-
*
|
|
82720
|
-
* ## Why hand-rolled rather than the `tar` package
|
|
82721
|
-
*
|
|
82722
|
-
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
82723
|
-
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
82724
|
-
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
82725
|
-
* operator relocated to), under paths that encode nothing a training set wants —
|
|
82726
|
-
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
82727
|
-
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
82728
|
-
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
82729
|
-
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
82730
|
-
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
82731
|
-
*
|
|
82732
|
-
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
82733
|
-
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
82734
|
-
* door rather than against my reading of the format.
|
|
82735
|
-
*
|
|
82736
|
-
* ## What this deliberately does NOT do
|
|
82737
|
-
*
|
|
82738
|
-
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
82739
|
-
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
82740
|
-
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
82741
|
-
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
82742
|
-
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
82743
|
-
*/
|
|
82744
|
-
var BLOCK_SIZE = 512;
|
|
82745
|
-
var NAME_MAX = 100;
|
|
82746
|
-
var PREFIX_MAX = 155;
|
|
82747
|
-
/**
|
|
82748
|
-
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
82749
|
-
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
82750
|
-
*/
|
|
82751
|
-
function splitUstarName(name) {
|
|
82752
|
-
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
82753
|
-
prefix: "",
|
|
82754
|
-
name
|
|
82755
|
-
};
|
|
82756
|
-
for (let i = name.length - 1; i >= 0; i--) {
|
|
82757
|
-
if (name[i] !== "/") continue;
|
|
82758
|
-
const head = name.slice(0, i);
|
|
82759
|
-
const tail = name.slice(i + 1);
|
|
82760
|
-
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
82761
|
-
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
82762
|
-
prefix: head,
|
|
82763
|
-
name: tail
|
|
82764
|
-
};
|
|
82765
|
-
}
|
|
82766
|
-
return null;
|
|
82767
|
-
}
|
|
82768
|
-
/** True when a path cannot be represented as a ustar entry name. */
|
|
82769
|
-
function tarNameTooLong(name) {
|
|
82770
|
-
return splitUstarName(name) === null;
|
|
82771
|
-
}
|
|
82772
|
-
function writeString(block, value, offset, length) {
|
|
82773
|
-
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
82774
|
-
}
|
|
82775
|
-
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
82776
|
-
function writeOctal(block, value, offset, length) {
|
|
82777
|
-
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
82778
|
-
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
82779
|
-
}
|
|
82780
|
-
/**
|
|
82781
|
-
* The 512-byte header for one regular file.
|
|
82782
|
-
*
|
|
82783
|
-
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
82784
|
-
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
82785
|
-
* fine until something tries to read it.
|
|
82786
|
-
*/
|
|
82787
|
-
function tarHeader(entry) {
|
|
82788
|
-
const split = splitUstarName(entry.name);
|
|
82789
|
-
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
82790
|
-
const block = Buffer.alloc(BLOCK_SIZE);
|
|
82791
|
-
writeString(block, split.name, 0, NAME_MAX);
|
|
82792
|
-
writeOctal(block, 420, 100, 8);
|
|
82793
|
-
writeOctal(block, 0, 108, 8);
|
|
82794
|
-
writeOctal(block, 0, 116, 8);
|
|
82795
|
-
writeOctal(block, entry.body.length, 124, 12);
|
|
82796
|
-
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
82797
|
-
block.write(" ", 148, 8, "ascii");
|
|
82798
|
-
block.write("0", 156, 1, "ascii");
|
|
82799
|
-
block.write("ustar\0", 257, 6, "ascii");
|
|
82800
|
-
block.write("00", 263, 2, "ascii");
|
|
82801
|
-
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
82802
|
-
let sum = 0;
|
|
82803
|
-
for (const byte of block) sum += byte;
|
|
82804
|
-
writeOctal(block, sum, 148, 7);
|
|
82805
|
-
block.write("\0", 154, 1, "ascii");
|
|
82806
|
-
block.write(" ", 155, 1, "ascii");
|
|
82807
|
-
return block;
|
|
82808
|
-
}
|
|
82809
|
-
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
82810
|
-
function tarPadding(size) {
|
|
82811
|
-
const remainder = size % BLOCK_SIZE;
|
|
82812
|
-
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
82813
|
-
}
|
|
82814
|
-
/** Header + body + padding for one entry, ready to write. */
|
|
82815
|
-
function tarBlocks(entry) {
|
|
82816
|
-
return [
|
|
82817
|
-
tarHeader(entry),
|
|
82818
|
-
entry.body,
|
|
82819
|
-
tarPadding(entry.body.length)
|
|
82820
|
-
];
|
|
82821
|
-
}
|
|
82822
|
-
/** The two zero blocks that terminate an archive. */
|
|
82823
|
-
function tarTrailer() {
|
|
82824
|
-
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
82825
|
-
}
|
|
82826
|
-
//#endregion
|
|
82827
84370
|
//#region src/pipeline-analytics/retrain/retrain-export-handler.ts
|
|
82828
|
-
|
|
82829
|
-
|
|
82830
|
-
|
|
82831
|
-
|
|
84371
|
+
/** The export request a URL carries (`getRetrainExportUrl` composes it). */
|
|
84372
|
+
function parseRetrainExportRequest(url) {
|
|
84373
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
84374
|
+
const params = new URLSearchParams(query);
|
|
84375
|
+
const deviceIds = parseRetrainDeviceIds(url);
|
|
84376
|
+
const frameIds = (params.get("frameIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
|
84377
|
+
const collectionIds = (params.get("collectionIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
|
84378
|
+
const batchId = params.get("batchId")?.trim() ?? "";
|
|
84379
|
+
return {
|
|
84380
|
+
filter: {
|
|
84381
|
+
...deviceIds.length > 0 ? { deviceIds: [...deviceIds] } : {},
|
|
84382
|
+
...frameIds.length > 0 ? { frameIds } : {},
|
|
84383
|
+
...collectionIds.length > 0 ? { collectionIds } : {},
|
|
84384
|
+
...batchId.length > 0 ? { batchId } : {},
|
|
84385
|
+
...params.get("completeOnly") === "1" ? { completeOnly: true } : {}
|
|
84386
|
+
},
|
|
84387
|
+
layout: params.get("layout") === "crops" ? "crops" : "frames"
|
|
84388
|
+
};
|
|
84389
|
+
}
|
|
84390
|
+
/** The query string `parseRetrainExportRequest` reads back. */
|
|
84391
|
+
function retrainExportQuery(request) {
|
|
84392
|
+
const params = new URLSearchParams();
|
|
84393
|
+
const { filter } = request;
|
|
84394
|
+
if (filter.deviceIds !== void 0 && filter.deviceIds.length > 0) params.set("deviceIds", filter.deviceIds.join(","));
|
|
84395
|
+
if (filter.frameIds !== void 0 && filter.frameIds.length > 0) params.set("frameIds", filter.frameIds.join(","));
|
|
84396
|
+
if (filter.collectionIds !== void 0 && filter.collectionIds.length > 0) params.set("collectionIds", filter.collectionIds.join(","));
|
|
84397
|
+
if (filter.batchId !== void 0) params.set("batchId", filter.batchId);
|
|
84398
|
+
if (filter.completeOnly === true) params.set("completeOnly", "1");
|
|
84399
|
+
if (request.layout === "crops") params.set("layout", "crops");
|
|
84400
|
+
const text = params.toString();
|
|
84401
|
+
return text.length > 0 ? `?${text}` : "";
|
|
84402
|
+
}
|
|
82832
84403
|
function parseRetrainDeviceIds(url) {
|
|
82833
84404
|
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
82834
84405
|
const raw = new URLSearchParams(query).get("deviceIds");
|
|
@@ -82851,10 +84422,10 @@ function createRetrainExportHandler(deps) {
|
|
|
82851
84422
|
res.end();
|
|
82852
84423
|
return;
|
|
82853
84424
|
}
|
|
82854
|
-
const
|
|
84425
|
+
const request = parseRetrainExportRequest(req.url ?? "");
|
|
82855
84426
|
let plan;
|
|
82856
84427
|
try {
|
|
82857
|
-
plan = await deps.buildPlan(
|
|
84428
|
+
plan = await deps.buildPlan(request);
|
|
82858
84429
|
} catch (err) {
|
|
82859
84430
|
deps.logger.warn("retrain export plan failed", { meta: { error: String(err) } });
|
|
82860
84431
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
@@ -82868,7 +84439,8 @@ function createRetrainExportHandler(deps) {
|
|
|
82868
84439
|
"x-camstack-retrain-frames": String(plan.manifest.frameCount),
|
|
82869
84440
|
"x-camstack-retrain-annotations": String(plan.manifest.annotationCount),
|
|
82870
84441
|
"x-camstack-retrain-model-errors": String(plan.manifest.modelErrorCount),
|
|
82871
|
-
"x-camstack-retrain-batch": plan.manifest.batchId
|
|
84442
|
+
"x-camstack-retrain-batch": plan.manifest.batchId,
|
|
84443
|
+
"x-camstack-retrain-layout": plan.manifest.layout
|
|
82872
84444
|
});
|
|
82873
84445
|
if (method === "HEAD") {
|
|
82874
84446
|
res.end();
|
|
@@ -82881,64 +84453,193 @@ function createRetrainExportHandler(deps) {
|
|
|
82881
84453
|
});
|
|
82882
84454
|
if (!ok) res.once("drain", resolve);
|
|
82883
84455
|
});
|
|
82884
|
-
|
|
82885
|
-
|
|
82886
|
-
|
|
82887
|
-
|
|
82888
|
-
|
|
82889
|
-
|
|
82890
|
-
|
|
82891
|
-
|
|
82892
|
-
|
|
82893
|
-
const entry = plan.manifest.frames.find((f) => f.frameId === frame.frameId);
|
|
82894
|
-
if (entry === void 0) continue;
|
|
82895
|
-
if (tarNameTooLong(entry.path)) {
|
|
82896
|
-
missing += 1;
|
|
82897
|
-
deps.logger.warn("retrain export skipped a frame — path too long for the archive", {
|
|
82898
|
-
tags: { deviceId: frame.deviceId },
|
|
82899
|
-
meta: {
|
|
82900
|
-
trackId: frame.trackId,
|
|
82901
|
-
frameId: frame.frameId,
|
|
82902
|
-
path: entry.path
|
|
82903
|
-
}
|
|
82904
|
-
});
|
|
82905
|
-
continue;
|
|
82906
|
-
}
|
|
82907
|
-
const bytes = await deps.readFrame(frame);
|
|
82908
|
-
if (bytes === null) {
|
|
82909
|
-
missing += 1;
|
|
82910
|
-
deps.logger.warn("retrain export skipped a frame — the COPY is unreadable", {
|
|
82911
|
-
tags: { deviceId: frame.deviceId },
|
|
82912
|
-
meta: {
|
|
82913
|
-
trackId: frame.trackId,
|
|
82914
|
-
frameId: frame.frameId
|
|
82915
|
-
}
|
|
82916
|
-
});
|
|
82917
|
-
continue;
|
|
82918
|
-
}
|
|
82919
|
-
for (const block of tarBlocks({
|
|
82920
|
-
name: entry.path,
|
|
82921
|
-
body: bytes,
|
|
82922
|
-
mtimeMs
|
|
82923
|
-
})) await write(block);
|
|
82924
|
-
shipped.push(frame.frameId);
|
|
82925
|
-
await new Promise((resolve) => setTimeout(resolve, PACING_MS));
|
|
82926
|
-
}
|
|
82927
|
-
await write(tarTrailer());
|
|
84456
|
+
await writeRetrainArchiveTo({
|
|
84457
|
+
plan,
|
|
84458
|
+
readFrame: deps.readFrame,
|
|
84459
|
+
cutCrop: deps.cutCrop,
|
|
84460
|
+
sink: { write },
|
|
84461
|
+
markExported: deps.markExported,
|
|
84462
|
+
logger: deps.logger,
|
|
84463
|
+
now: deps.now
|
|
84464
|
+
});
|
|
82928
84465
|
await new Promise((resolve) => {
|
|
82929
84466
|
res.end(resolve);
|
|
82930
84467
|
});
|
|
82931
|
-
await deps.markExported(shipped, plan.manifest.batchId);
|
|
82932
|
-
deps.logger.info("retrain export complete", { meta: {
|
|
82933
|
-
batchId: plan.manifest.batchId,
|
|
82934
|
-
frames: shipped.length,
|
|
82935
|
-
missing,
|
|
82936
|
-
annotations: plan.manifest.annotationCount,
|
|
82937
|
-
modelErrors: plan.manifest.modelErrorCount
|
|
82938
|
-
} });
|
|
82939
84468
|
};
|
|
82940
84469
|
}
|
|
82941
84470
|
//#endregion
|
|
84471
|
+
//#region src/pipeline-analytics/retrain/retrain-pipeline-boxes.ts
|
|
84472
|
+
/**
|
|
84473
|
+
* The boxes the PIPELINE drew on a copied frame — Step 1 of the training
|
|
84474
|
+
* screen.
|
|
84475
|
+
*
|
|
84476
|
+
* A correction has to start from the decision being corrected. Proposing with
|
|
84477
|
+
* a fresh model run answers a different question ("what would a model say
|
|
84478
|
+
* now"), and the frame the operator is fixing is precisely the one where the
|
|
84479
|
+
* live answer was wrong — a re-run that happens to be right hides the phantom
|
|
84480
|
+
* the operator came to remove. So the canvas opens with what the track store
|
|
84481
|
+
* says the pipeline had on that frame, and the operator accepts, corrects,
|
|
84482
|
+
* relabels or deletes it.
|
|
84483
|
+
*
|
|
84484
|
+
* ## Where a box comes from, best first
|
|
84485
|
+
*
|
|
84486
|
+
* 1. **The subject box recorded ON the source media row** (D510). Exact by
|
|
84487
|
+
* construction: the box was measured on the frame the blob shows. Only the
|
|
84488
|
+
* copy's own track can have one.
|
|
84489
|
+
* 2. **The track position nearest the capture time**, within
|
|
84490
|
+
* {@link PIPELINE_BOX_TOLERANCE_MS}. Positions are frame-rate, so the
|
|
84491
|
+
* nearest one is normally the same detection; the delta is reported so a
|
|
84492
|
+
* reviewer can see how far it was.
|
|
84493
|
+
*
|
|
84494
|
+
* Every OTHER track alive on the camera at that instant contributes its own
|
|
84495
|
+
* nearest position the same way — a frame with three people is three boxes,
|
|
84496
|
+
* and a recording still (no track of its own) gets everything the pipeline
|
|
84497
|
+
* was tracking at that moment, which is exactly what a missed-object review
|
|
84498
|
+
* needs to see is NOT there.
|
|
84499
|
+
*
|
|
84500
|
+
* Nothing here does I/O. The caller reads the tracks and the subject box.
|
|
84501
|
+
*/
|
|
84502
|
+
/** How far from the capture time a position may be and still be "this frame".
|
|
84503
|
+
* Positions are recorded at the analysis rate (several per second), so half
|
|
84504
|
+
* a second is a generous match and still not a different moment. */
|
|
84505
|
+
var PIPELINE_BOX_TOLERANCE_MS = 500;
|
|
84506
|
+
/**
|
|
84507
|
+
* How far back a track may have STARTED and still be alive at the frame. The
|
|
84508
|
+
* track store windows on `firstSeen`, so a track born before this is not
|
|
84509
|
+
* asked about — a vehicle parked for hours is the case it misses, and the
|
|
84510
|
+
* operator draws that box by hand.
|
|
84511
|
+
*/
|
|
84512
|
+
var PIPELINE_TRACK_LOOKBACK_MS = 30 * 6e4;
|
|
84513
|
+
/** At most this many tracks of one camera are read around one frame. */
|
|
84514
|
+
var PIPELINE_TRACK_AROUND_LIMIT = 200;
|
|
84515
|
+
/** The least-wrong macro tier for a class the macro map does not know. The
|
|
84516
|
+
* operator corrects it; what must not happen is the raw string being
|
|
84517
|
+
* promoted into the macro tier. */
|
|
84518
|
+
var FALLBACK_MACRO_CLASS = "person";
|
|
84519
|
+
/**
|
|
84520
|
+
* Split a detector's class into the two tiers.
|
|
84521
|
+
*
|
|
84522
|
+
* A macro class stays in the macro tier; anything else maps through the COCO
|
|
84523
|
+
* macro map, and the class itself becomes the LABEL. A macro word is never a
|
|
84524
|
+
* label, in any casing — the tier rule the annotation write enforces.
|
|
84525
|
+
*/
|
|
84526
|
+
function toRetrainClassTiers(className, label) {
|
|
84527
|
+
const cls = className.trim().toLowerCase();
|
|
84528
|
+
const usableLabel = (value) => {
|
|
84529
|
+
const trimmed = value?.trim();
|
|
84530
|
+
if (trimmed === void 0 || trimmed === "") return void 0;
|
|
84531
|
+
return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
|
|
84532
|
+
};
|
|
84533
|
+
if (isRetrainMacroClass(cls)) {
|
|
84534
|
+
const kept = usableLabel(label);
|
|
84535
|
+
return kept !== void 0 ? {
|
|
84536
|
+
macroClass: cls,
|
|
84537
|
+
label: kept
|
|
84538
|
+
} : { macroClass: cls };
|
|
84539
|
+
}
|
|
84540
|
+
const mapped = require_clip_model_registry.COCO_TO_MACRO.mapping[cls];
|
|
84541
|
+
const macroClass = mapped !== void 0 && isRetrainMacroClass(mapped) ? mapped : FALLBACK_MACRO_CLASS;
|
|
84542
|
+
const kept = usableLabel(className);
|
|
84543
|
+
return kept !== void 0 ? {
|
|
84544
|
+
macroClass,
|
|
84545
|
+
label: kept
|
|
84546
|
+
} : { macroClass };
|
|
84547
|
+
}
|
|
84548
|
+
/** A pixel box as a clamped normalised one, or `null` when nothing is left. */
|
|
84549
|
+
function normalise(box, frameWidth, frameHeight) {
|
|
84550
|
+
if (frameWidth === void 0 || frameHeight === void 0) return null;
|
|
84551
|
+
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
84552
|
+
const x = clampSpan(box.x, box.w, frameWidth);
|
|
84553
|
+
const y = clampSpan(box.y, box.h, frameHeight);
|
|
84554
|
+
if (x === null || y === null) return null;
|
|
84555
|
+
return {
|
|
84556
|
+
x: x.start,
|
|
84557
|
+
y: y.start,
|
|
84558
|
+
w: x.size,
|
|
84559
|
+
h: y.size
|
|
84560
|
+
};
|
|
84561
|
+
}
|
|
84562
|
+
/** One axis of a pixel box, normalised and clamped into `[0,1]`. The size is
|
|
84563
|
+
* divided directly when nothing was clamped, so an exact box stays exact. */
|
|
84564
|
+
function clampSpan(start, size, extent) {
|
|
84565
|
+
const lo = start / extent;
|
|
84566
|
+
const hi = (start + size) / extent;
|
|
84567
|
+
const s = Math.max(0, lo);
|
|
84568
|
+
const n = lo >= 0 && hi <= 1 ? size / extent : Math.min(1, hi) - s;
|
|
84569
|
+
return n > 0 ? {
|
|
84570
|
+
start: s,
|
|
84571
|
+
size: n
|
|
84572
|
+
} : null;
|
|
84573
|
+
}
|
|
84574
|
+
function nearestPosition(track, at, tolerance) {
|
|
84575
|
+
let best = null;
|
|
84576
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
84577
|
+
for (const p of track.positions) {
|
|
84578
|
+
const delta = Math.abs(p.timestamp - at);
|
|
84579
|
+
if (delta < bestDelta) {
|
|
84580
|
+
best = p;
|
|
84581
|
+
bestDelta = delta;
|
|
84582
|
+
}
|
|
84583
|
+
}
|
|
84584
|
+
if (best === null || bestDelta > tolerance) return null;
|
|
84585
|
+
return {
|
|
84586
|
+
position: best,
|
|
84587
|
+
deltaMs: bestDelta
|
|
84588
|
+
};
|
|
84589
|
+
}
|
|
84590
|
+
function boxFor(track, bbox, basis, deltaMs, score) {
|
|
84591
|
+
const tiers = toRetrainClassTiers(track.className, track.label);
|
|
84592
|
+
const subLabel = track.subLabel?.trim();
|
|
84593
|
+
return {
|
|
84594
|
+
bbox,
|
|
84595
|
+
macroClass: tiers.macroClass,
|
|
84596
|
+
...tiers.label !== void 0 ? { label: tiers.label } : {},
|
|
84597
|
+
...subLabel !== void 0 && subLabel !== "" && !isRetrainMacroClass(subLabel.toLowerCase()) ? { subLabel } : {},
|
|
84598
|
+
...score !== void 0 ? { score } : {},
|
|
84599
|
+
...track.modelId !== void 0 ? { modelId: track.modelId } : {},
|
|
84600
|
+
trackId: track.trackId,
|
|
84601
|
+
basis,
|
|
84602
|
+
deltaMs
|
|
84603
|
+
};
|
|
84604
|
+
}
|
|
84605
|
+
/** The own track's box: the recorded subject box first, the nearest position second. */
|
|
84606
|
+
function ownBox(input, tolerance) {
|
|
84607
|
+
const track = input.ownTrack;
|
|
84608
|
+
if (track === null) return null;
|
|
84609
|
+
const at = input.frame.capturedAt;
|
|
84610
|
+
const nearest = at !== void 0 ? nearestPosition(track, at, tolerance) : null;
|
|
84611
|
+
if (input.ownSubjectBox !== null) {
|
|
84612
|
+
const recorded = normalise(input.ownSubjectBox, input.ownSubjectBox.frameWidth, input.ownSubjectBox.frameHeight);
|
|
84613
|
+
if (recorded !== null) return boxFor(track, recorded, "subject-box", 0, nearest?.position.score);
|
|
84614
|
+
}
|
|
84615
|
+
if (nearest === null) return null;
|
|
84616
|
+
const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
|
|
84617
|
+
return placed === null ? null : boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score);
|
|
84618
|
+
}
|
|
84619
|
+
/**
|
|
84620
|
+
* Every box the pipeline had on this frame, own track first. Pure.
|
|
84621
|
+
*/
|
|
84622
|
+
function pipelineBoxesForFrame(input) {
|
|
84623
|
+
const tolerance = input.toleranceMs ?? PIPELINE_BOX_TOLERANCE_MS;
|
|
84624
|
+
const out = [];
|
|
84625
|
+
const own = ownBox(input, tolerance);
|
|
84626
|
+
if (own !== null) out.push(own);
|
|
84627
|
+
const at = input.frame.capturedAt;
|
|
84628
|
+
if (at === void 0) return out;
|
|
84629
|
+
const ownId = input.frame.trackId;
|
|
84630
|
+
const seen = new Set(ownId !== void 0 ? [ownId] : []);
|
|
84631
|
+
for (const track of input.neighbours) {
|
|
84632
|
+
if (seen.has(track.trackId) || track.deviceId !== input.frame.deviceId) continue;
|
|
84633
|
+
seen.add(track.trackId);
|
|
84634
|
+
const nearest = nearestPosition(track, at, tolerance);
|
|
84635
|
+
if (nearest === null) continue;
|
|
84636
|
+
const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
|
|
84637
|
+
if (placed === null) continue;
|
|
84638
|
+
out.push(boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score));
|
|
84639
|
+
}
|
|
84640
|
+
return out;
|
|
84641
|
+
}
|
|
84642
|
+
//#endregion
|
|
82942
84643
|
//#region src/pipeline-analytics/retrain/retrain-assist.ts
|
|
82943
84644
|
/** The whole frame, as a normalised rectangle. */
|
|
82944
84645
|
var WHOLE_FRAME = {
|
|
@@ -83009,12 +84710,11 @@ function mapAssistDetails(input) {
|
|
|
83009
84710
|
withoutBox += 1;
|
|
83010
84711
|
continue;
|
|
83011
84712
|
}
|
|
83012
|
-
const
|
|
83013
|
-
const label = isRetrainMacroClass(d.className) ? d.label : d.className;
|
|
84713
|
+
const tiers = toRetrainClassTiers(d.className, d.label);
|
|
83014
84714
|
proposals.push({
|
|
83015
84715
|
bbox,
|
|
83016
|
-
macroClass,
|
|
83017
|
-
...label !== void 0
|
|
84716
|
+
macroClass: tiers.macroClass,
|
|
84717
|
+
...tiers.label !== void 0 ? { label: tiers.label } : {},
|
|
83018
84718
|
score: d.score,
|
|
83019
84719
|
modelId
|
|
83020
84720
|
});
|
|
@@ -83025,16 +84725,6 @@ function mapAssistDetails(input) {
|
|
|
83025
84725
|
withoutBox
|
|
83026
84726
|
};
|
|
83027
84727
|
}
|
|
83028
|
-
/**
|
|
83029
|
-
* Macro tier for a detector class we do not carry as a macro class.
|
|
83030
|
-
*
|
|
83031
|
-
* The class itself becomes the LABEL (see above) and the macro tier gets a
|
|
83032
|
-
* value the operator will almost certainly correct — which is fine, because it
|
|
83033
|
-
* is a PROPOSAL and correcting it is the job. What must not happen is the
|
|
83034
|
-
* detector's own string being promoted into the macro tier: that is how
|
|
83035
|
-
* `postman` becomes a class with one example in it.
|
|
83036
|
-
*/
|
|
83037
|
-
var FALLBACK_MACRO_CLASS = "person";
|
|
83038
84728
|
/** A proposal, as a draft annotation ready to store if the operator accepts it. */
|
|
83039
84729
|
function proposalToDraft(proposal, owner) {
|
|
83040
84730
|
return {
|
|
@@ -83243,23 +84933,31 @@ async function restageRetrainTrack(deps, input) {
|
|
|
83243
84933
|
var DEFAULT_STAGING_LIMIT = 100;
|
|
83244
84934
|
/** A macro class that failed validation — the write is refused, never coerced. */
|
|
83245
84935
|
var RetrainAnnotationError = class extends Error {};
|
|
84936
|
+
/** A store frame on the wire. A recording still carries no track and no
|
|
84937
|
+
* source media row, and says so by leaving both out. */
|
|
83246
84938
|
function toWireFrame(f) {
|
|
83247
84939
|
return {
|
|
83248
84940
|
frameId: f.frameId,
|
|
83249
84941
|
deviceId: f.deviceId,
|
|
83250
|
-
|
|
83251
|
-
|
|
84942
|
+
origin: f.origin,
|
|
84943
|
+
...f.origin === "track" ? {
|
|
84944
|
+
trackId: f.trackId,
|
|
84945
|
+
sourceMediaId: f.sourceMediaId
|
|
84946
|
+
} : {},
|
|
84947
|
+
...f.epochMs !== void 0 ? { epochMs: f.epochMs } : {},
|
|
83252
84948
|
sourceKind: f.sourceKind,
|
|
83253
84949
|
sizeBytes: f.sizeBytes,
|
|
83254
84950
|
width: f.width,
|
|
83255
84951
|
height: f.height,
|
|
83256
|
-
copiedAt: f.copiedAt
|
|
84952
|
+
copiedAt: f.copiedAt,
|
|
84953
|
+
...f.capturedAt !== void 0 ? { capturedAt: f.capturedAt } : {},
|
|
84954
|
+
complete: f.complete
|
|
83257
84955
|
};
|
|
83258
84956
|
}
|
|
83259
84957
|
function toWireAnnotation(a) {
|
|
83260
84958
|
return {
|
|
83261
84959
|
id: a.id,
|
|
83262
|
-
trackId: a.trackId,
|
|
84960
|
+
...a.trackId !== "" ? { trackId: a.trackId } : {},
|
|
83263
84961
|
deviceId: a.deviceId,
|
|
83264
84962
|
mediaKey: a.mediaKey,
|
|
83265
84963
|
bbox: a.bbox,
|
|
@@ -83326,11 +85024,116 @@ var RetrainService = class {
|
|
|
83326
85024
|
if (candidates.length === 0) await this.logNoAnnotatableFrame(trackId, media, copies.length);
|
|
83327
85025
|
return {
|
|
83328
85026
|
candidates,
|
|
83329
|
-
copies:
|
|
85027
|
+
copies: await this.toWireFrames(copies),
|
|
83330
85028
|
...autoPick !== null ? { autoPickMediaId: autoPick.mediaId } : {}
|
|
83331
85029
|
};
|
|
83332
85030
|
}
|
|
83333
85031
|
/**
|
|
85032
|
+
* The wire frames, each with the boxes the pipeline drew on it (Step 1) and
|
|
85033
|
+
* the collections it belongs to.
|
|
85034
|
+
*
|
|
85035
|
+
* Index reads only — the track rows and the media rows' subject boxes, no
|
|
85036
|
+
* blob and no inference. One read per distinct track and per distinct
|
|
85037
|
+
* instant, not per box. A failed read degrades to fewer boxes, logged, and
|
|
85038
|
+
* never to a failed listing: the frames are the operator's work and must
|
|
85039
|
+
* stay reachable when the evidence is not.
|
|
85040
|
+
*/
|
|
85041
|
+
async toWireFrames(copies) {
|
|
85042
|
+
const collections = this.deps.memberships !== void 0 && copies.length > 0 ? await this.deps.memberships.collectionsOf(copies.map((c) => c.frameId)).catch((err) => {
|
|
85043
|
+
this.deps.logger.warn("retrain frames: collection membership unreadable", { meta: {
|
|
85044
|
+
frames: copies.length,
|
|
85045
|
+
error: String(err)
|
|
85046
|
+
} });
|
|
85047
|
+
return /* @__PURE__ */ new Map();
|
|
85048
|
+
}) : null;
|
|
85049
|
+
const evidence = this.deps.pipelineEvidence;
|
|
85050
|
+
const ownTracks = /* @__PURE__ */ new Map();
|
|
85051
|
+
const subjectBoxes = /* @__PURE__ */ new Map();
|
|
85052
|
+
const out = [];
|
|
85053
|
+
for (const copy of copies) {
|
|
85054
|
+
const wire = toWireFrame(copy);
|
|
85055
|
+
const collectionIds = collections?.get(copy.frameId);
|
|
85056
|
+
const withCollections = collections !== null ? {
|
|
85057
|
+
...wire,
|
|
85058
|
+
collectionIds: collectionIds ?? []
|
|
85059
|
+
} : wire;
|
|
85060
|
+
if (evidence === void 0) {
|
|
85061
|
+
out.push(withCollections);
|
|
85062
|
+
continue;
|
|
85063
|
+
}
|
|
85064
|
+
const trackId = copy.origin === "track" ? copy.trackId : "";
|
|
85065
|
+
if (trackId !== "" && !ownTracks.has(trackId)) {
|
|
85066
|
+
ownTracks.set(trackId, await this.deps.tracks.getPersistedByTrackId(trackId).catch(() => null));
|
|
85067
|
+
subjectBoxes.set(trackId, await this.readSubjectBoxes(evidence, trackId, copy.deviceId));
|
|
85068
|
+
}
|
|
85069
|
+
const neighbours = copy.capturedAt !== void 0 ? await this.readTracksAround(evidence, copy.deviceId, copy.capturedAt) : [];
|
|
85070
|
+
const pipelineBoxes = pipelineBoxesForFrame({
|
|
85071
|
+
frame: {
|
|
85072
|
+
deviceId: copy.deviceId,
|
|
85073
|
+
...trackId !== "" ? { trackId } : {},
|
|
85074
|
+
...copy.capturedAt !== void 0 ? { capturedAt: copy.capturedAt } : {}
|
|
85075
|
+
},
|
|
85076
|
+
ownTrack: trackId !== "" ? ownTracks.get(trackId) ?? null : null,
|
|
85077
|
+
ownSubjectBox: trackId !== "" ? subjectBoxes.get(trackId)?.get(copy.sourceMediaId) ?? null : null,
|
|
85078
|
+
neighbours
|
|
85079
|
+
});
|
|
85080
|
+
out.push({
|
|
85081
|
+
...withCollections,
|
|
85082
|
+
pipelineBoxes
|
|
85083
|
+
});
|
|
85084
|
+
}
|
|
85085
|
+
return out;
|
|
85086
|
+
}
|
|
85087
|
+
/**
|
|
85088
|
+
* The frame a frame-scoped call names, or a refusal. The frame is the
|
|
85089
|
+
* authority: its camera must be the caller's, and a `trackId`, when the
|
|
85090
|
+
* caller sends one, must be the frame's own — a recording still has none.
|
|
85091
|
+
*/
|
|
85092
|
+
async requireFrame(ref, action) {
|
|
85093
|
+
const frame = await this.deps.frames.get(ref.frameId);
|
|
85094
|
+
const trackMismatch = frame !== null && ref.trackId !== void 0 && (frame.origin !== "track" || frame.trackId !== ref.trackId);
|
|
85095
|
+
if (frame === null || frame.deviceId !== ref.deviceId || trackMismatch) {
|
|
85096
|
+
this.deps.logger.warn(`retrain ${action} refused: the frame does not belong to that camera or track`, {
|
|
85097
|
+
tags: { deviceId: ref.deviceId },
|
|
85098
|
+
meta: {
|
|
85099
|
+
frameId: ref.frameId,
|
|
85100
|
+
...ref.trackId !== void 0 ? { trackId: ref.trackId } : {}
|
|
85101
|
+
}
|
|
85102
|
+
});
|
|
85103
|
+
throw new RetrainAnnotationError(frame === null ? `retrain frame ${ref.frameId} does not exist — select it before annotating it` : `retrain frame ${ref.frameId} does not belong to ${ref.trackId !== void 0 ? `track ${ref.trackId}` : `camera ${String(ref.deviceId)}`}`);
|
|
85104
|
+
}
|
|
85105
|
+
return frame;
|
|
85106
|
+
}
|
|
85107
|
+
async readSubjectBoxes(evidence, trackId, deviceId) {
|
|
85108
|
+
try {
|
|
85109
|
+
const rows = await evidence.subjectBoxes(trackId, deviceId);
|
|
85110
|
+
return new Map(rows.map((r) => [r.mediaId, r.subjectBox]));
|
|
85111
|
+
} catch (err) {
|
|
85112
|
+
this.deps.logger.warn("retrain pipeline boxes: subject boxes unreadable", {
|
|
85113
|
+
tags: { deviceId },
|
|
85114
|
+
meta: {
|
|
85115
|
+
trackId,
|
|
85116
|
+
error: String(err)
|
|
85117
|
+
}
|
|
85118
|
+
});
|
|
85119
|
+
return /* @__PURE__ */ new Map();
|
|
85120
|
+
}
|
|
85121
|
+
}
|
|
85122
|
+
async readTracksAround(evidence, deviceId, atMs) {
|
|
85123
|
+
try {
|
|
85124
|
+
return await evidence.tracksAround(deviceId, atMs);
|
|
85125
|
+
} catch (err) {
|
|
85126
|
+
this.deps.logger.warn("retrain pipeline boxes: tracks around the frame unreadable", {
|
|
85127
|
+
tags: { deviceId },
|
|
85128
|
+
meta: {
|
|
85129
|
+
atMs,
|
|
85130
|
+
error: String(err)
|
|
85131
|
+
}
|
|
85132
|
+
});
|
|
85133
|
+
return [];
|
|
85134
|
+
}
|
|
85135
|
+
}
|
|
85136
|
+
/**
|
|
83334
85137
|
* A track with no annotatable frame is a REAL state, and the page has to say
|
|
83335
85138
|
* so rather than render an empty strip that reads as a slow load. Two causes,
|
|
83336
85139
|
* named apart: the whole-frame media AGED OUT, or the track only ever held
|
|
@@ -83356,6 +85159,7 @@ var RetrainService = class {
|
|
|
83356
85159
|
async selectFrames(input) {
|
|
83357
85160
|
const media = await this.deps.media.listTrackMedia(input.trackId);
|
|
83358
85161
|
const kindOf = new Map(media.map((m) => [m.mediaId, m.kind]));
|
|
85162
|
+
const capturedAtOf = new Map(media.map((m) => [m.mediaId, m.timestamp]));
|
|
83359
85163
|
const copied = [];
|
|
83360
85164
|
const refused = [];
|
|
83361
85165
|
for (const mediaId of input.mediaIds) {
|
|
@@ -83363,7 +85167,8 @@ var RetrainService = class {
|
|
|
83363
85167
|
deviceId: input.deviceId,
|
|
83364
85168
|
trackId: input.trackId,
|
|
83365
85169
|
sourceMediaId: mediaId,
|
|
83366
|
-
sourceKind: kindOf.get(mediaId) ?? "keyFrame"
|
|
85170
|
+
sourceKind: kindOf.get(mediaId) ?? "keyFrame",
|
|
85171
|
+
...capturedAtOf.has(mediaId) ? { capturedAt: capturedAtOf.get(mediaId) } : {}
|
|
83367
85172
|
});
|
|
83368
85173
|
if (result.kind === "copied") copied.push(toWireFrame(result.frame));
|
|
83369
85174
|
else refused.push({
|
|
@@ -83376,37 +85181,60 @@ var RetrainService = class {
|
|
|
83376
85181
|
refused
|
|
83377
85182
|
};
|
|
83378
85183
|
}
|
|
85184
|
+
/**
|
|
85185
|
+
* The operator's "every subject in this frame is boxed". Scoped to the
|
|
85186
|
+
* track the caller names: a frame of another track is refused, loudly.
|
|
85187
|
+
*/
|
|
85188
|
+
async setFrameComplete(input) {
|
|
85189
|
+
const frame = await this.requireFrame(input, "complete");
|
|
85190
|
+
const updated = await this.deps.frames.setComplete(frame.frameId, input.complete);
|
|
85191
|
+
if (updated === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} vanished`);
|
|
85192
|
+
this.deps.logger.info("retrain frame completeness set", {
|
|
85193
|
+
tags: { deviceId: input.deviceId },
|
|
85194
|
+
meta: {
|
|
85195
|
+
trackId: frame.trackId,
|
|
85196
|
+
frameId: input.frameId,
|
|
85197
|
+
complete: input.complete
|
|
85198
|
+
}
|
|
85199
|
+
});
|
|
85200
|
+
return toWireFrame(updated);
|
|
85201
|
+
}
|
|
83379
85202
|
/** Un-select: annotations first, then the copy. Never the other way round —
|
|
83380
|
-
* an annotation whose frame is gone is unopenable and unfixable.
|
|
85203
|
+
* an annotation whose frame is gone is unopenable and unfixable. The frame's
|
|
85204
|
+
* collection memberships go with it. */
|
|
83381
85205
|
async deselectFrame(input) {
|
|
83382
85206
|
const frame = await this.deps.frames.get(input.frameId);
|
|
83383
85207
|
if (frame === null) {
|
|
83384
85208
|
this.deps.logger.warn("retrain deselect: no such copied frame", {
|
|
83385
85209
|
tags: { deviceId: input.deviceId },
|
|
83386
|
-
meta: {
|
|
83387
|
-
trackId: input.trackId,
|
|
83388
|
-
frameId: input.frameId
|
|
83389
|
-
}
|
|
85210
|
+
meta: { frameId: input.frameId }
|
|
83390
85211
|
});
|
|
83391
85212
|
return {
|
|
83392
85213
|
removed: false,
|
|
83393
85214
|
removedAnnotations: 0
|
|
83394
85215
|
};
|
|
83395
85216
|
}
|
|
83396
|
-
|
|
85217
|
+
await this.requireFrame(input, "deselect");
|
|
85218
|
+
return {
|
|
85219
|
+
removed: true,
|
|
85220
|
+
removedAnnotations: await this.removeFrame(frame)
|
|
85221
|
+
};
|
|
85222
|
+
}
|
|
85223
|
+
/** Remove a frame from the dataset: annotations, memberships, then the copy. */
|
|
85224
|
+
async removeFrame(frame) {
|
|
85225
|
+
const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
|
|
85226
|
+
await this.deps.memberships?.forgetFrame(frame.frameId);
|
|
83397
85227
|
await this.deps.frames.deleteFrame(frame);
|
|
83398
85228
|
this.deps.logger.info("retrain frame removed from the dataset", {
|
|
83399
|
-
tags: { deviceId:
|
|
85229
|
+
tags: { deviceId: frame.deviceId },
|
|
83400
85230
|
meta: {
|
|
83401
|
-
|
|
83402
|
-
|
|
85231
|
+
origin: frame.origin,
|
|
85232
|
+
...frame.origin === "track" ? { trackId: frame.trackId } : {},
|
|
85233
|
+
frameId: frame.frameId,
|
|
83403
85234
|
removedAnnotations
|
|
83404
85235
|
}
|
|
83405
85236
|
});
|
|
83406
|
-
return
|
|
83407
|
-
removed: true,
|
|
83408
|
-
removedAnnotations
|
|
83409
|
-
};
|
|
85237
|
+
return removedAnnotations;
|
|
83410
85238
|
}
|
|
83411
85239
|
/** The copy's pixels plus its exact dimensions — the canvas needs both. */
|
|
83412
85240
|
async getFrameImage(frameId) {
|
|
@@ -83446,7 +85274,7 @@ var RetrainService = class {
|
|
|
83446
85274
|
};
|
|
83447
85275
|
}
|
|
83448
85276
|
const frame = await this.deps.frames.get(input.frameId);
|
|
83449
|
-
if (frame === null) return {
|
|
85277
|
+
if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
|
|
83450
85278
|
kind: "refused",
|
|
83451
85279
|
reason: "unknown-frame"
|
|
83452
85280
|
};
|
|
@@ -83501,8 +85329,8 @@ var RetrainService = class {
|
|
|
83501
85329
|
}
|
|
83502
85330
|
});
|
|
83503
85331
|
const owner = {
|
|
83504
|
-
trackId:
|
|
83505
|
-
deviceId:
|
|
85332
|
+
trackId: frame.trackId,
|
|
85333
|
+
deviceId: frame.deviceId,
|
|
83506
85334
|
mediaKey: input.frameId
|
|
83507
85335
|
};
|
|
83508
85336
|
return {
|
|
@@ -83525,8 +85353,12 @@ var RetrainService = class {
|
|
|
83525
85353
|
belowThreshold: mapping.belowThreshold
|
|
83526
85354
|
};
|
|
83527
85355
|
}
|
|
83528
|
-
|
|
83529
|
-
|
|
85356
|
+
/** The annotations of a track, or of named frames (a recording still has
|
|
85357
|
+
* no track). One of the two must be given. */
|
|
85358
|
+
async listAnnotations(input) {
|
|
85359
|
+
if (input.trackId !== void 0 && input.trackId !== "") return (await this.deps.annotations.listByTrack(input.trackId)).map(toWireAnnotation);
|
|
85360
|
+
if (input.frameIds !== void 0) return (await this.deps.annotations.listByFrames(input.frameIds)).map(toWireAnnotation);
|
|
85361
|
+
throw new RetrainAnnotationError("listRetrainAnnotations needs a trackId or frameIds");
|
|
83530
85362
|
}
|
|
83531
85363
|
/**
|
|
83532
85364
|
* Replace every annotation on one frame.
|
|
@@ -83538,15 +85370,15 @@ var RetrainService = class {
|
|
|
83538
85370
|
* believes they stored.
|
|
83539
85371
|
*/
|
|
83540
85372
|
async saveAnnotations(input) {
|
|
83541
|
-
|
|
85373
|
+
const frame = await this.requireFrame(input, "save");
|
|
83542
85374
|
for (const a of input.annotations) {
|
|
83543
85375
|
if (!isRetrainMacroClass(a.macroClass)) throw new RetrainAnnotationError(`"${a.macroClass}" is not a macro class`);
|
|
83544
85376
|
const violation = checkTierSeparation(a);
|
|
83545
85377
|
if (violation !== null) throw new RetrainAnnotationError(`"${violation.value}" is a MACRO CLASS and cannot be a ${violation.field} — the tiers are separate (person / postman / Marco)`);
|
|
83546
85378
|
}
|
|
83547
85379
|
const drafts = input.annotations.map((a) => ({
|
|
83548
|
-
trackId:
|
|
83549
|
-
deviceId:
|
|
85380
|
+
trackId: frame.trackId,
|
|
85381
|
+
deviceId: frame.deviceId,
|
|
83550
85382
|
mediaKey: input.frameId,
|
|
83551
85383
|
bbox: a.bbox,
|
|
83552
85384
|
macroClass: a.macroClass,
|
|
@@ -83562,7 +85394,8 @@ var RetrainService = class {
|
|
|
83562
85394
|
this.deps.logger.info("retrain annotations saved", {
|
|
83563
85395
|
tags: { deviceId: input.deviceId },
|
|
83564
85396
|
meta: {
|
|
83565
|
-
|
|
85397
|
+
origin: frame.origin,
|
|
85398
|
+
trackId: frame.trackId,
|
|
83566
85399
|
frameId: input.frameId,
|
|
83567
85400
|
subjects: saved.length - errors,
|
|
83568
85401
|
modelErrors: errors
|
|
@@ -84588,7 +86421,8 @@ function selectsRow(mode, toLocationId, data, isRetrain, fromLocationId) {
|
|
|
84588
86421
|
if (mode === "seal") return rowIsUnstamped(data);
|
|
84589
86422
|
if (mediaRowLocation(data) === toLocationId) return false;
|
|
84590
86423
|
if (mode === "gallery") return !isRetrain && rowIsGallery(data);
|
|
84591
|
-
|
|
86424
|
+
if (isRetrain) return !rowIsDurableRetrainCopy(data);
|
|
86425
|
+
return !rowIsGallery(data);
|
|
84592
86426
|
}
|
|
84593
86427
|
var MediaRelocateEngine = class {
|
|
84594
86428
|
deps;
|
|
@@ -92963,6 +94797,14 @@ var RECORDED_STILL_PROFILE = "high";
|
|
|
92963
94797
|
*/
|
|
92964
94798
|
var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
|
|
92965
94799
|
/**
|
|
94800
|
+
* Width cap for a still added to the retrain dataset from the recording.
|
|
94801
|
+
*
|
|
94802
|
+
* Effectively none: a training frame is wanted at the camera's own resolution
|
|
94803
|
+
* (the same pixels a native key frame has), so the bound is only a ceiling
|
|
94804
|
+
* against a pathological stream, far above any camera on the fleet.
|
|
94805
|
+
*/
|
|
94806
|
+
var RETRAIN_STILL_MAX_WIDTH = 7680;
|
|
94807
|
+
/**
|
|
92966
94808
|
* Build the production ports.
|
|
92967
94809
|
*
|
|
92968
94810
|
* Every port swallows its own failure into the `null` the policy expects: a
|
|
@@ -97247,7 +99089,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
97247
99089
|
collection: RETRAIN_FRAMES_COLLECTION,
|
|
97248
99090
|
classification: {
|
|
97249
99091
|
kind: "never-orphaned",
|
|
97250
|
-
why: "the frame COPIES the dataset owns outright,
|
|
99092
|
+
why: "the frame COPIES the dataset owns outright, in the `retrain/` subtree of the durable `artifacts` class (D781), which no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
|
|
97251
99093
|
}
|
|
97252
99094
|
},
|
|
97253
99095
|
{
|
|
@@ -106658,6 +108500,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
106658
108500
|
/** The retrain curation surface — see `retrain/retrain-service.ts`. Null
|
|
106659
108501
|
* until the stores are built. */
|
|
106660
108502
|
retrain = null;
|
|
108503
|
+
retrainCollections = null;
|
|
108504
|
+
retrainLlmAssist = null;
|
|
108505
|
+
retrainCollectionStore = null;
|
|
106661
108506
|
retrainFrames = null;
|
|
106662
108507
|
retrainAnnotations = null;
|
|
106663
108508
|
/** The NC artifact plane (signed public links for notification media) and its
|
|
@@ -108514,6 +110359,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
108514
110359
|
resolveRoot: (locationId) => locationId === LEGACY_ADDON_DATA_GALLERY_LOCATION ? Promise.resolve(node_path.default.join(this.ctx.dataDir, "gallery")) : locationId === GALLERY_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
|
|
108515
110360
|
location: ARTIFACTS_MEDIA_LOCATION,
|
|
108516
110361
|
relativePath: GALLERY_TENANT_SUBPATH
|
|
110362
|
+
}) : locationId === RETRAIN_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
|
|
110363
|
+
location: ARTIFACTS_MEDIA_LOCATION,
|
|
110364
|
+
relativePath: ""
|
|
108517
110365
|
}) : this.ctx.api.storage.resolve.query({
|
|
108518
110366
|
location: locationId,
|
|
108519
110367
|
relativePath: ""
|
|
@@ -108692,7 +110540,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
108692
110540
|
logger: logger.child("RetrainFrames"),
|
|
108693
110541
|
now: () => Date.now(),
|
|
108694
110542
|
writeGate: this.mediaWriteGate,
|
|
108695
|
-
|
|
110543
|
+
measureChroma: (bytes) => chromaSpread(bytes),
|
|
108696
110544
|
readSource: async (mediaKey) => {
|
|
108697
110545
|
const file = await mediaStore.getById(mediaKey);
|
|
108698
110546
|
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
@@ -108706,7 +110554,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
108706
110554
|
});
|
|
108707
110555
|
this.retrainFrames = retrainFrames;
|
|
108708
110556
|
this.retrainAnnotations = retrainAnnotations;
|
|
108709
|
-
|
|
110557
|
+
const retrainCollectionStore = new RetrainCollectionStore({
|
|
110558
|
+
store: api.settingsStore,
|
|
110559
|
+
logger: logger.child("RetrainCollections"),
|
|
110560
|
+
now: () => Date.now(),
|
|
110561
|
+
newId: () => (0, node_crypto.randomUUID)()
|
|
110562
|
+
});
|
|
110563
|
+
this.retrainCollectionStore = retrainCollectionStore;
|
|
110564
|
+
const retrain = new RetrainService({
|
|
108710
110565
|
tracks: {
|
|
108711
110566
|
listStaging: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
|
|
108712
110567
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
@@ -108724,7 +110579,70 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
108724
110579
|
frames: retrainFrames,
|
|
108725
110580
|
annotations: retrainAnnotations,
|
|
108726
110581
|
runner: (input, nodeId) => nodeId === void 0 ? api.pipelineRunner.runStatelessStep.mutate(input) : api.pipelineRunner.runStatelessStep.mutate(input, require_clip_model_registry.nodePin(nodeId)),
|
|
108727
|
-
logger: logger.child("Retrain")
|
|
110582
|
+
logger: logger.child("Retrain"),
|
|
110583
|
+
pipelineEvidence: {
|
|
110584
|
+
tracksAround: (deviceId, atMs) => trackStore.queryHistorical({
|
|
110585
|
+
deviceId,
|
|
110586
|
+
since: atMs - PIPELINE_TRACK_LOOKBACK_MS,
|
|
110587
|
+
until: atMs + PIPELINE_BOX_TOLERANCE_MS,
|
|
110588
|
+
limit: PIPELINE_TRACK_AROUND_LIMIT
|
|
110589
|
+
}),
|
|
110590
|
+
subjectBoxes: (trackId, deviceId) => mediaStore.listSubjectBoxesByOwner("track", trackId, deviceId)
|
|
110591
|
+
},
|
|
110592
|
+
memberships: retrainCollectionStore
|
|
110593
|
+
});
|
|
110594
|
+
this.retrain = retrain;
|
|
110595
|
+
const retrainStills = new RecordedStillService(buildRecordedStillPorts({
|
|
110596
|
+
api: this.ctx.api.recordingArchive,
|
|
110597
|
+
logger: logger.child("RetrainStills"),
|
|
110598
|
+
resolveFfmpeg: this.pinnedFfmpeg
|
|
110599
|
+
}));
|
|
110600
|
+
this.retrainCollections = new RetrainCollectionService({
|
|
110601
|
+
collections: retrainCollectionStore,
|
|
110602
|
+
frames: retrainFrames,
|
|
110603
|
+
annotations: retrainAnnotations,
|
|
110604
|
+
retrain,
|
|
110605
|
+
captureStill: (deviceId, epochMs) => retrainStills.recover(deviceId, epochMs, RETRAIN_STILL_MAX_WIDTH),
|
|
110606
|
+
logger: logger.child("RetrainCollections")
|
|
110607
|
+
});
|
|
110608
|
+
this.retrainLlmAssist = new RetrainLlmAssistService({
|
|
110609
|
+
frames: retrainFrames,
|
|
110610
|
+
llm: {
|
|
110611
|
+
listProfiles: async () => (await api.llm.listProfiles.query({})).map((p) => ({
|
|
110612
|
+
id: p.id,
|
|
110613
|
+
name: p.name,
|
|
110614
|
+
model: p.model,
|
|
110615
|
+
kind: p.kind,
|
|
110616
|
+
...p.baseUrl !== void 0 ? { baseUrl: p.baseUrl } : {},
|
|
110617
|
+
enabled: p.enabled,
|
|
110618
|
+
supportsVision: p.supportsVision
|
|
110619
|
+
})),
|
|
110620
|
+
generateVision: async (request) => {
|
|
110621
|
+
const result = await api.llm.generateVision.mutate({
|
|
110622
|
+
profileId: request.profileId,
|
|
110623
|
+
consumer: RETRAIN_LLM_CONSUMER,
|
|
110624
|
+
system: request.system,
|
|
110625
|
+
prompt: request.prompt,
|
|
110626
|
+
jsonSchema: request.jsonSchema,
|
|
110627
|
+
images: [{
|
|
110628
|
+
bytes: new Uint8Array(request.image),
|
|
110629
|
+
mimeType: "image/jpeg"
|
|
110630
|
+
}],
|
|
110631
|
+
temperature: 0,
|
|
110632
|
+
timeoutMs: RETRAIN_LLM_TIMEOUT_MS
|
|
110633
|
+
});
|
|
110634
|
+
return result.ok ? {
|
|
110635
|
+
ok: true,
|
|
110636
|
+
text: result.text,
|
|
110637
|
+
model: result.model
|
|
110638
|
+
} : {
|
|
110639
|
+
ok: false,
|
|
110640
|
+
code: result.code,
|
|
110641
|
+
message: result.message
|
|
110642
|
+
};
|
|
110643
|
+
}
|
|
110644
|
+
},
|
|
110645
|
+
logger: logger.child("RetrainLlmAssist")
|
|
108728
110646
|
});
|
|
108729
110647
|
this.mediaRelocate = new MediaRelocateEngine({
|
|
108730
110648
|
store: api.settingsStore,
|
|
@@ -109908,14 +111826,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
109908
111826
|
};
|
|
109909
111827
|
return this.requireRetrain().propose({
|
|
109910
111828
|
deviceId: input.deviceId,
|
|
109911
|
-
trackId: input.trackId,
|
|
111829
|
+
...input.trackId !== void 0 ? { trackId: input.trackId } : {},
|
|
109912
111830
|
frameId: input.frameId,
|
|
109913
111831
|
subject,
|
|
109914
111832
|
...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
|
|
109915
111833
|
});
|
|
109916
111834
|
}
|
|
109917
111835
|
async listRetrainAnnotations(input) {
|
|
109918
|
-
return this.requireRetrain().listAnnotations(input
|
|
111836
|
+
return this.requireRetrain().listAnnotations(input);
|
|
109919
111837
|
}
|
|
109920
111838
|
async saveRetrainAnnotations(input) {
|
|
109921
111839
|
return this.requireRetrain().saveAnnotations(input);
|
|
@@ -109929,10 +111847,99 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
109929
111847
|
/** Where to stream the ANNOTATED dataset from. Bytes never cross the cap. */
|
|
109930
111848
|
async getRetrainExportUrl(input) {
|
|
109931
111849
|
if (this.retrainExportDataPlane === null) throw new Error("retrain export: data plane unavailable on this node");
|
|
109932
|
-
const
|
|
109933
|
-
const query =
|
|
111850
|
+
const { layout, ...filter } = input;
|
|
111851
|
+
const query = retrainExportQuery({
|
|
111852
|
+
filter,
|
|
111853
|
+
layout: layout ?? "frames"
|
|
111854
|
+
});
|
|
109934
111855
|
return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
|
|
109935
111856
|
}
|
|
111857
|
+
async setRetrainFrameComplete(input) {
|
|
111858
|
+
return this.requireRetrain().setFrameComplete(input);
|
|
111859
|
+
}
|
|
111860
|
+
requireRetrainLlmAssist() {
|
|
111861
|
+
const service = this.retrainLlmAssist;
|
|
111862
|
+
if (service === null) throw new Error("retrain AI assist: analytics stores unavailable");
|
|
111863
|
+
return service;
|
|
111864
|
+
}
|
|
111865
|
+
async listRetrainAssistProfiles(_input) {
|
|
111866
|
+
return this.requireRetrainLlmAssist().listProfiles();
|
|
111867
|
+
}
|
|
111868
|
+
async proposeRetrainAnnotationsWithLlm(input) {
|
|
111869
|
+
return this.requireRetrainLlmAssist().propose(input);
|
|
111870
|
+
}
|
|
111871
|
+
requireRetrainCollections() {
|
|
111872
|
+
const service = this.retrainCollections;
|
|
111873
|
+
if (service === null) throw new Error("retrain collections: analytics stores unavailable");
|
|
111874
|
+
return service;
|
|
111875
|
+
}
|
|
111876
|
+
async createRetrainCollection(input) {
|
|
111877
|
+
return this.requireRetrainCollections().create(input);
|
|
111878
|
+
}
|
|
111879
|
+
async listRetrainCollections(_input) {
|
|
111880
|
+
return this.requireRetrainCollections().list();
|
|
111881
|
+
}
|
|
111882
|
+
async renameRetrainCollection(input) {
|
|
111883
|
+
return this.requireRetrainCollections().rename(input);
|
|
111884
|
+
}
|
|
111885
|
+
async deleteRetrainCollection(input) {
|
|
111886
|
+
return this.requireRetrainCollections().delete(input);
|
|
111887
|
+
}
|
|
111888
|
+
async addRetrainFrameFromRecording(input) {
|
|
111889
|
+
return this.requireRetrainCollections().addFrameFromRecording(input);
|
|
111890
|
+
}
|
|
111891
|
+
async addRetrainFramesToCollection(input) {
|
|
111892
|
+
return this.requireRetrainCollections().addFrames(input);
|
|
111893
|
+
}
|
|
111894
|
+
async removeRetrainFrameFromCollection(input) {
|
|
111895
|
+
return this.requireRetrainCollections().removeFrame(input);
|
|
111896
|
+
}
|
|
111897
|
+
async listRetrainCollectionFrames(input) {
|
|
111898
|
+
return this.requireRetrainCollections().listFrames(input);
|
|
111899
|
+
}
|
|
111900
|
+
/**
|
|
111901
|
+
* Stream the annotated archive into ONE HTTP request to `target` — the
|
|
111902
|
+
* training upload. Same bytes as the download; never on the hub's disk.
|
|
111903
|
+
*/
|
|
111904
|
+
async writeRetrainArchive(input) {
|
|
111905
|
+
const frameStore = this.retrainFrames;
|
|
111906
|
+
if (frameStore === null) throw new Error("retrain export: analytics stores unavailable");
|
|
111907
|
+
const plan = await this.buildRetrainExportPlan({
|
|
111908
|
+
filter: input.filter,
|
|
111909
|
+
layout: input.layout
|
|
111910
|
+
});
|
|
111911
|
+
const upload = openRetrainUploadSink(input.target);
|
|
111912
|
+
const host = new URL(input.target.url).host;
|
|
111913
|
+
try {
|
|
111914
|
+
const stats = await writeRetrainArchiveTo({
|
|
111915
|
+
plan,
|
|
111916
|
+
readFrame: (frame) => frameStore.readFrame(frame),
|
|
111917
|
+
cutCrop: this.retrainCropCutter(),
|
|
111918
|
+
sink: upload.sink,
|
|
111919
|
+
markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
|
|
111920
|
+
logger: this.ctx.logger,
|
|
111921
|
+
now: () => Date.now()
|
|
111922
|
+
});
|
|
111923
|
+
const status = await upload.finish();
|
|
111924
|
+
this.ctx.logger.info("retrain archive uploaded", { meta: {
|
|
111925
|
+
host,
|
|
111926
|
+
status,
|
|
111927
|
+
...stats,
|
|
111928
|
+
layout: input.layout
|
|
111929
|
+
} });
|
|
111930
|
+
return {
|
|
111931
|
+
...stats,
|
|
111932
|
+
status
|
|
111933
|
+
};
|
|
111934
|
+
} catch (err) {
|
|
111935
|
+
upload.abort(require_clip_model_registry.errMsg(err));
|
|
111936
|
+
this.ctx.logger.warn("retrain archive upload failed", { meta: {
|
|
111937
|
+
host,
|
|
111938
|
+
error: require_clip_model_registry.errMsg(err)
|
|
111939
|
+
} });
|
|
111940
|
+
throw err;
|
|
111941
|
+
}
|
|
111942
|
+
}
|
|
109936
111943
|
/**
|
|
109937
111944
|
* The store seam the `debug.*` actions and the debug bundle both read through.
|
|
109938
111945
|
*
|
|
@@ -110214,15 +112221,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
110214
112221
|
async serveRetrainExportPlane() {
|
|
110215
112222
|
try {
|
|
110216
112223
|
const handler = createRetrainExportHandler({
|
|
110217
|
-
buildPlan: (
|
|
112224
|
+
buildPlan: (request) => this.buildRetrainExportPlan(request),
|
|
110218
112225
|
readFrame: (frame) => this.retrainFrames?.readFrame(frame) ?? Promise.resolve(null),
|
|
110219
|
-
|
|
110220
|
-
|
|
110221
|
-
if (!store || frameIds.length === 0) return;
|
|
110222
|
-
const ids = [];
|
|
110223
|
-
for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
|
|
110224
|
-
await store.markExported(ids, batchId);
|
|
110225
|
-
},
|
|
112226
|
+
cutCrop: this.retrainCropCutter(),
|
|
112227
|
+
markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
|
|
110226
112228
|
logger: this.ctx.logger,
|
|
110227
112229
|
now: () => Date.now()
|
|
110228
112230
|
});
|
|
@@ -110244,13 +112246,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
110244
112246
|
* reference rectangle is implicit is a box nobody can check. A camera with no
|
|
110245
112247
|
* zones simply has no `zoneCrop` geometry — absent, never a clamped guess.
|
|
110246
112248
|
*/
|
|
110247
|
-
async buildRetrainExportPlan(
|
|
112249
|
+
async buildRetrainExportPlan(request) {
|
|
110248
112250
|
const frameStore = this.retrainFrames;
|
|
110249
112251
|
const annotationStore = this.retrainAnnotations;
|
|
110250
112252
|
const trackStore = this.trackStore;
|
|
110251
112253
|
if (!frameStore || !annotationStore || !trackStore) throw new Error("retrain export: analytics stores unavailable");
|
|
110252
|
-
const
|
|
110253
|
-
const
|
|
112254
|
+
const deviceIds = request.filter.deviceIds ?? [];
|
|
112255
|
+
const devices = deviceIds.length > 0 ? deviceIds : [...new Set([...await trackStore.listDeviceIds(), ...await frameStore.listDeviceIds()])];
|
|
112256
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
110254
112257
|
const annotationsByFrame = /* @__PURE__ */ new Map();
|
|
110255
112258
|
const zones = /* @__PURE__ */ new Map();
|
|
110256
112259
|
for (const deviceId of devices) {
|
|
@@ -110259,22 +112262,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
110259
112262
|
const existing = annotationsByFrame.get(annotation.mediaKey) ?? [];
|
|
110260
112263
|
annotationsByFrame.set(annotation.mediaKey, [...existing, annotation]);
|
|
110261
112264
|
}
|
|
112265
|
+
for (const frame of await frameStore.listCompleteByDevice(deviceId, RETRAIN_EXPORT_MAX)) candidates.set(frame.frameId, frame);
|
|
110262
112266
|
}
|
|
110263
112267
|
for (const frameId of annotationsByFrame.keys()) {
|
|
112268
|
+
if (candidates.has(frameId)) continue;
|
|
110264
112269
|
const frame = await frameStore.get(frameId);
|
|
110265
|
-
if (frame !== null)
|
|
112270
|
+
if (frame !== null) candidates.set(frameId, frame);
|
|
110266
112271
|
}
|
|
112272
|
+
const collectionsByFrame = await this.retrainCollectionStore?.collectionsOf([...candidates.keys()]) ?? /* @__PURE__ */ new Map();
|
|
112273
|
+
const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter, collectionsByFrame);
|
|
110267
112274
|
return {
|
|
110268
112275
|
manifest: buildRetrainManifest({
|
|
110269
112276
|
frames,
|
|
110270
112277
|
annotationsByFrame,
|
|
112278
|
+
collectionsByFrame,
|
|
110271
112279
|
zoneForDevice: (deviceId) => zones.get(deviceId) ?? null,
|
|
110272
112280
|
generatedAt: Date.now(),
|
|
110273
|
-
batchId: (0, node_crypto.randomUUID)()
|
|
112281
|
+
batchId: (0, node_crypto.randomUUID)(),
|
|
112282
|
+
layout: request.layout,
|
|
112283
|
+
coordinates: await this.siteCoordinates()
|
|
110274
112284
|
}),
|
|
110275
112285
|
frames
|
|
110276
112286
|
};
|
|
110277
112287
|
}
|
|
112288
|
+
/** Stamp the batch on the annotations of the frames an archive carried. */
|
|
112289
|
+
async markRetrainExported(frameIds, batchId) {
|
|
112290
|
+
const store = this.retrainAnnotations;
|
|
112291
|
+
if (!store || frameIds.length === 0) return;
|
|
112292
|
+
const ids = [];
|
|
112293
|
+
for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
|
|
112294
|
+
await store.markExported(ids, batchId);
|
|
112295
|
+
}
|
|
112296
|
+
/** The `crops` layout's cutter, with the convention the manifest records. */
|
|
112297
|
+
retrainCropCutter() {
|
|
112298
|
+
return (frameBytes, frame, annotation) => cutRetrainCrop({
|
|
112299
|
+
frameBytes,
|
|
112300
|
+
frameWidth: frame.width,
|
|
112301
|
+
frameHeight: frame.height,
|
|
112302
|
+
bbox: annotation.bbox,
|
|
112303
|
+
convention: RETRAIN_CROP_CONVENTION,
|
|
112304
|
+
maxSide: RETRAIN_CROP_MAX_SIDE
|
|
112305
|
+
}).catch((err) => {
|
|
112306
|
+
this.ctx.logger.warn("retrain crop could not be cut", {
|
|
112307
|
+
tags: { deviceId: frame.deviceId },
|
|
112308
|
+
meta: {
|
|
112309
|
+
frameId: frame.frameId,
|
|
112310
|
+
annotationId: annotation.id,
|
|
112311
|
+
error: require_clip_model_registry.errMsg(err)
|
|
112312
|
+
}
|
|
112313
|
+
});
|
|
112314
|
+
return null;
|
|
112315
|
+
});
|
|
112316
|
+
}
|
|
110278
112317
|
/** Bounding box of a camera's zones, or null when it declares none. */
|
|
110279
112318
|
async retrainZoneForDevice(deviceId) {
|
|
110280
112319
|
try {
|
|
@@ -110422,6 +112461,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
|
|
|
110422
112461
|
}, LTS_FLUSH_INTERVAL_MS);
|
|
110423
112462
|
this.startOrphanAuditTimer();
|
|
110424
112463
|
this.reconcileGalleryMediaLocation();
|
|
112464
|
+
this.reconcileRetrainCopiesLocation();
|
|
112465
|
+
}
|
|
112466
|
+
/**
|
|
112467
|
+
* Move pre-D781 retrain copies off the footage class onto the durable
|
|
112468
|
+
* `retrainMedia` tenant, and say how many have no bytes left.
|
|
112469
|
+
*/
|
|
112470
|
+
async reconcileRetrainCopiesLocation() {
|
|
112471
|
+
const retrainFrames = this.retrainFrames;
|
|
112472
|
+
if (!retrainFrames) return;
|
|
112473
|
+
try {
|
|
112474
|
+
const result = await retrainFrames.relocateLegacyCopies();
|
|
112475
|
+
if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
|
|
112476
|
+
this.ctx.logger.info("retrain copies reconciled onto the durable retrain tenant", { meta: {
|
|
112477
|
+
...result,
|
|
112478
|
+
location: RETRAIN_MEDIA_LOCATION
|
|
112479
|
+
} });
|
|
112480
|
+
} catch (err) {
|
|
112481
|
+
this.ctx.logger.warn("retrain copies reconcile failed — the copies stay on eventMedia", { meta: {
|
|
112482
|
+
error: require_clip_model_registry.errMsg(err),
|
|
112483
|
+
location: ARTIFACTS_MEDIA_LOCATION
|
|
112484
|
+
} });
|
|
112485
|
+
}
|
|
110425
112486
|
}
|
|
110426
112487
|
/**
|
|
110427
112488
|
* Move enrolled-gallery blobs off the footage volume, and say out loud how
|