@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as NC_ALARM_DEFAULT_PROFILE_ID, $r as subKindsOf, $t as RECORDING_EXPORT_MAX_READ_BYTES, A as DetectionSourceSchema, Ai as boolean, An as applyBlockingEdit, At as NcConditionsSchema, B as FailureCounters, Bi as templateLiteral, Bn as consumableLowPercentOf, Br as resolveModeNotifyPolicy, Bt as NcSharedSequenceSchema, C as CoverStateSchema, Ci as isDeviceScopedCap, Cn as TrackRelationSchema, Cr as isVectorIndexDimConflict, Ct as NC_TEMPLATE_VARS, D as DETECTION_MACRO_CLASSES, Di as ZodObject, Dn as addonWidgetsSourceCapability, Dr as memoizePinnedFfmpeg, Dt as NcAlarmSensorsSchema, E as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Ei as sleep$1, En as activateAlarmProfile, Er as mayWriteToLocation, Et as NcAlarmSensorsInputSchema, F as EVENT_KIND_BY_CAP, Fi as object, Fn as audioMetricsCapability, Fr as readTimelapseGeneratedAt, Ft as NcRulePatchSchema, G as MAX_ARCHIVED_DEBUG_NOTES, Gn as deriveDetailCropRect, Gr as ruleDeviceCompatCapQueries, Gt as NcSystemEventKindSchema, H as ListTrackRelationsInputSchema, Hi as union, Hn as cosineSimilarity$1, Hr as rootSupplyServes, Ht as NcSnoozeSchema, I as EVENT_OWNER_TYPES, Ii as partialRecord, In as audioModeOf, Ir as renameAlarmProfile, It as NcRuleSchema, J as MAX_TRACK_PURITIES, Jn as describeSystemEventDeliveryProblem, Jr as sceneMonitorCapability, Jt as NcTemplatePreviewSchema, K as MAX_BIRTH_DECISION_RECORDS, Kn as derivePostTriggeredAction, Kr as ruleGroupingApplies, Kt as NcTaxonomySchema, L as EXPORT_DENSE_MAX_RANGES, Li as record, Ln as buildEventKindDescriptor, Lr as resolveClipSearchMinScore, Lt as NcRuleTargetSchema, M as EMBEDDING_REQUIREMENTS_FAILED, Mi as literal, Mn as applyNonBlockingEdit, Mr as plateGalleryCapability, Mt as NcRuleActionSchema, N as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Ni as looseObject, Nn as applySecureOnArmEdit, Nr as readDeviceStateFrom, Nt as NcRuleActionsSchema, O as DETECTION_PIPELINE_CAP_NAME, Oi as _enum, On as alarmDeviceSecureOnArm, Or as notificationRulesCapability, Ot as NcAlarmSettingsPatchSchema, P as EVENT_DENSITY_BATCH_MAX, Pi as number, Pn as assertTimelapseCadences, Pr as readMotionTriggeredBy, Pt as NcRuleInputSchema, Q as MotionTriggeredBySchema, Qn as embeddingRefusalOf, Qr as storageOccupancyCapability, R as FIRST_LEVEL_MACRO_CLASSES, Ri as strictObject, Rn as buildViewerLink, Rr as resolveLocationMode, Rt as NcRuleUpdateInputSchema, S as ClearTrackRelationResultSchema, Si as hydrateSchema, Sn as TrackRelationKindSchema, Sr as isSystemEventSummaryRule, St as NC_TAXONOMY, T as DEFAULT_EVENT_COLOR, Tn as TrackSourceSchema, Tr as linkedStepOf, Tt as NcAlarmPostTriggeredActionSchema, U as ListTrackRelationsResultSchema, Ui as unknown, Un as createAlarmProfile, Ur as ruleAutoTriggersAlarm, Ut as NcSnoozeSuppressedSchema, V as LabelAttributionSchema, Vi as tuple, Vn as coreBlockIdFromAddonId, Vt as NcSnoozeInputSchema, W as LockStateSchema, Wi as EventCategory, Wn as deleteAlarmProfile, Wr as ruleBelongsToAlarm$1, Wt as NcStateSummarySectionIdSchema, X as MOTION_CLOSE_AFTER_MS, Xr as sensorRaisedOf, Xt as OPS_LOG_DEFAULT_LIMIT, Y as MAX_TRACK_RELATIONS, Yn as describeSystemEventFamilyProblem, Yr as secureActuationFor, Yt as NcTemplateVarDescriptorSchema, Z as MediaFileKindEnum, Zr as servedTemplateVars, Zt as OpsLogEntrySchema, _ as BIRTH_DECISION_DEFAULT_LIMIT, _i as BaseAddon, _n as TRACK_RELATIONS_DEFAULT_LIMIT, _r as isMediaOwnerType, _t as NC_GROUPING_MIN_SEC, a as CLIP_EMBEDDING_STEP_ID, ai as systemEventSummarySplitOf, an as SCENE_DEFAULT_ANCHOR_THRESHOLD, ar as faceGalleryCapability, at as NC_ALARM_TRIGGERED_MAX_SEC, b as COCO_TO_MACRO, bi as customAction, bn as TimelapseRuleSchema, br as isScheduleActive, bt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, c as resolveClusterModelPin, ci as vectorDimFromBase64, cn as SCENE_DEFAULT_QUIET_SECONDS, cr as gifFromMp4Args, ct as NC_CLIP_POST_ROLL_DEFAULT_SEC, d as APPEARANCE_BLOCKS, di as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, dn as SCENE_RESET_RECAPTURES, dr as isAudioClassName, dt as NC_CONDITION_CATALOG, ei as syncActiveAlarmProfileSettings, en as RECORDING_SOURCE_CAMSTACK_ADDON, et as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fi as canonicalHash, fn as SceneMonitorSchema, fr as isDefaultAlarmProfileId, ft as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, g as ArchivedDebugNoteSchema, gi as errMsg, gn as TIMELAPSE_DENSE_FLOOR_SEC, gr as isKnownTemplateVar, gt as NC_GROUPING_MAX_SEC, h as AlarmArmModeSchema, hi as DeviceType, hn as SetTrackRelationInputSchema, hr as isEventOwnerType, ht as NC_GROUPING_DEFAULT_SEC, ii as systemEventSummaryScheduleOf, in as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, ir as evictionPolicyOfLocation, it as NC_ALARM_SYSTEM_EVENT_KINDS, j as EMBEDDING_MODEL_FAILED, ji as discriminatedUnion, jn as applyModeDelaysEdits, jr as pipelineAnalyticsCapability, jt as NcLegacyRuleActionSequenceSchema, k as DeclaredDevices, ki as array, kn as alarmPanelCapability, kr as occupancyScope, kt as NcConditionDescriptorSchema, l as ALL_CAPABILITY_DEFINITIONS, li as viewerLinkBaseOf, ln as SCENE_DEFAULT_UNCOVERED_POLICY, lr as isAlarmControllableCap, lt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, m as AUDIO_MACRO_LABELS, mi as isSourceCap, mn as SetTrackPurityResultSchema, mr as isEventKindLabelKey, mt as NC_DEFAULT_SNOOZE_MINUTES, ni as systemEventFamilyProblems, nn as RetrainStatusSchema, nr as ensureAlarmProfiles, nt as NC_ALARM_DELAY_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, oi as templateCarriesSummary, on as SCENE_DEFAULT_CHECK_INTERVAL_SEC, or as failureContributionCapability, ot as NC_AUDIO_CONFIRM_HITS_DEFAULT, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pi as evaluateSensorEdge, pn as SetTrackPurityInputSchema, pr as isDetectionMacroClass, pt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, q as MAX_BIRTH_LATENCY_PROXY_MS, qn as deriveRecordingMode, qr as ruleKindOf, qt as NcTemplatePreviewInputSchema, ri as systemEventFilterApplies, rn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, rr as eventKindLabel, rt as NC_ALARM_NOTIFY_FIELDS, s as readClusterModelPinStrict, si as templateVarsFor, sn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, sr as gifDeriveSpeed, st as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, t as BUILTIN_CLIP_MODELS, ti as systemEventDeliveryProblems, tn as RebuildObjectEmbeddingsRefusalSchema, tr as encodeVectorBase64, tt as NC_ALARM_DELAY_FIELDS, u as APPEARANCE_BIN_COUNT, ui as zoneAnalyticsCapability, un as SCENE_DIVERGED, ur as isAppLinkBase, ut as NC_CLIP_RETENTION_MAX_SEC, v as BaseDevice, vi as CamProfileSchema, vn as TimelapseRuleInputSchema, vr as isRetiredClearKey, vt as NC_SENSOR_TRANSITION_KINDS, w as DEFAULT_DETAIL_CROP_CONVENTION, wi as nodePin, wn as TrackSideSchema, wr as kebabToCamel, wt as NcAlarmConfigSchema, x as ClearTrackRelationInputSchema, xi as defineCustomActions, xn as TrackPuritySchema, xr as isSystemDelivery, xt as NC_TANK_SYSTEM_EVENT_KINDS, y as BirthDecisionRecordSchema, yi as createEvent, yn as TimelapseRulePatchSchema, yr as isRootSuppliableStepId, yt as NC_SNOOZE_MAX_MINUTES, z as FULL_IMAGE_BBOX, zi as string, zn as cloneAlarmSettings, zr as resolveModeDelays, zt as NcScheduleSchema } from "../clip-model-registry-C44gfwjm.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs$1 from "node:fs";
|
|
4
4
|
import { createWriteStream, promises } from "node:fs";
|
|
@@ -16,6 +16,8 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
|
16
16
|
import { isDeepStrictEqual, promisify } from "node:util";
|
|
17
17
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
18
18
|
import { createGzip, gunzip, gunzipSync, gzip, gzipSync } from "node:zlib";
|
|
19
|
+
import { request } from "node:http";
|
|
20
|
+
import { request as request$1 } from "node:https";
|
|
19
21
|
//#region src/pipeline-analytics/media-occupancy.ts
|
|
20
22
|
/** The bare type ref legacy NULL-stamped media rows belong to. */
|
|
21
23
|
var LEGACY_MEDIA_TYPE = "eventMedia";
|
|
@@ -5829,16 +5831,16 @@ function ncDetectionSummary(texts, counts) {
|
|
|
5829
5831
|
//#region src/notification-center/timelapse/detection-counts.ts
|
|
5830
5832
|
/** One normalisation, used on both sides of every comparison — a rule that
|
|
5831
5833
|
* named `Person` must match a track classified `person`. */
|
|
5832
|
-
function normalise(name) {
|
|
5834
|
+
function normalise$1(name) {
|
|
5833
5835
|
return name.trim().toLowerCase();
|
|
5834
5836
|
}
|
|
5835
5837
|
/** Count per class over the tracks, honouring the rule's selection. */
|
|
5836
5838
|
function countDetections(input) {
|
|
5837
|
-
const selected = (input.reportClasses ?? []).map(normalise).filter((c) => c.length > 0);
|
|
5839
|
+
const selected = (input.reportClasses ?? []).map(normalise$1).filter((c) => c.length > 0);
|
|
5838
5840
|
const seen = /* @__PURE__ */ new Map();
|
|
5839
5841
|
for (const t of input.tracks) {
|
|
5840
5842
|
if (t.className === void 0) continue;
|
|
5841
|
-
const key = normalise(t.className);
|
|
5843
|
+
const key = normalise$1(t.className);
|
|
5842
5844
|
if (key.length === 0) continue;
|
|
5843
5845
|
if (selected.length > 0 && !selected.includes(key)) continue;
|
|
5844
5846
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
@@ -7020,18 +7022,44 @@ function clockCondition(now) {
|
|
|
7020
7022
|
* does not.
|
|
7021
7023
|
*/
|
|
7022
7024
|
function resolveCondition(input) {
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
+
return resolveConditionDetailed(input).condition;
|
|
7026
|
+
}
|
|
7027
|
+
/**
|
|
7028
|
+
* {@link resolveCondition}, plus the layer that answered. A consumer that
|
|
7029
|
+
* reports a day/night split (the retrain export, D785) must be able to tell a
|
|
7030
|
+
* measured IR frame from an almanac or a clock guess.
|
|
7031
|
+
*/
|
|
7032
|
+
function resolveConditionDetailed(input) {
|
|
7033
|
+
if (input.forcedMode === "day" || input.forcedMode === "night") return {
|
|
7034
|
+
condition: input.forcedMode,
|
|
7035
|
+
source: "forced"
|
|
7036
|
+
};
|
|
7037
|
+
if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return {
|
|
7038
|
+
condition: IR,
|
|
7039
|
+
source: "ir-chroma"
|
|
7040
|
+
};
|
|
7025
7041
|
if (input.coordinates) {
|
|
7026
7042
|
const times = sunTimes(input.coordinates.lat, input.coordinates.lng, input.now);
|
|
7027
7043
|
if (times) {
|
|
7028
7044
|
const t = input.now.getTime();
|
|
7029
|
-
if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return
|
|
7030
|
-
|
|
7031
|
-
|
|
7045
|
+
if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return {
|
|
7046
|
+
condition: DAWN,
|
|
7047
|
+
source: "sun"
|
|
7048
|
+
};
|
|
7049
|
+
if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return {
|
|
7050
|
+
condition: DUSK,
|
|
7051
|
+
source: "sun"
|
|
7052
|
+
};
|
|
7053
|
+
return {
|
|
7054
|
+
condition: t >= times.rise && t < times.set ? DAY : NIGHT,
|
|
7055
|
+
source: "sun"
|
|
7056
|
+
};
|
|
7032
7057
|
}
|
|
7033
7058
|
}
|
|
7034
|
-
return
|
|
7059
|
+
return {
|
|
7060
|
+
condition: clockCondition(input.now),
|
|
7061
|
+
source: "clock"
|
|
7062
|
+
};
|
|
7035
7063
|
}
|
|
7036
7064
|
//#endregion
|
|
7037
7065
|
//#region src/shared/llm-vision/parse-model-json.ts
|
|
@@ -70516,8 +70544,11 @@ var TrackStore = class {
|
|
|
70516
70544
|
* RetrainFrameStore — the COPIES the retrain dataset owns.
|
|
70517
70545
|
*
|
|
70518
70546
|
* Collection: `pipeline-analytics:retrain-frames`.
|
|
70519
|
-
* Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg
|
|
70520
|
-
*
|
|
70547
|
+
* Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg` under the `artifacts`
|
|
70548
|
+
* class root — the dataset is that class's fourth tenant (D341), addressed
|
|
70549
|
+
* through the permanent row ref {@link RETRAIN_MEDIA_LOCATION}. Never the
|
|
70550
|
+
* footage volume: the 2026-08-17 recordings wipe took the whole dataset with
|
|
70551
|
+
* it while it lived on `eventMedia` (D197). See D781.
|
|
70521
70552
|
*
|
|
70522
70553
|
* ## Why a copy, and why at selection time
|
|
70523
70554
|
*
|
|
@@ -70544,20 +70575,43 @@ var TrackStore = class {
|
|
|
70544
70575
|
*/
|
|
70545
70576
|
/**
|
|
70546
70577
|
* @durable class=ledger owner=pipeline-analytics
|
|
70547
|
-
* write="one row per frame the operator SELECTS into the retrain dataset
|
|
70548
|
-
*
|
|
70549
|
-
*
|
|
70550
|
-
* the
|
|
70551
|
-
*
|
|
70552
|
-
*
|
|
70578
|
+
* write="one row per frame the operator SELECTS into the retrain dataset, or adds
|
|
70579
|
+
* from the recording at an instant (storeRecordingStill, origin 'recording', no
|
|
70580
|
+
* track). The copy is taken eagerly at selection (copyFrame), not referenced, so
|
|
70581
|
+
* the row records a blob the dataset owns outright; re-selecting the same source
|
|
70582
|
+
* (or the same camera instant) reuses the existing copy rather than writing a
|
|
70583
|
+
* second one."
|
|
70584
|
+
* retention="none — the blobs sit in the 'retrain/' subtree of the artifacts class
|
|
70585
|
+
* (D781), which no owner cascade and no age sweep walks, which is exactly what makes a 'trained' track evictable
|
|
70553
70586
|
* again (D81). Rows go only when the operator deselects the frame (deleteFrame,
|
|
70554
70587
|
* after its annotations). Bounded only by operator selection — nothing caps the
|
|
70555
70588
|
* table or the disk it consumes."
|
|
70556
70589
|
*/
|
|
70557
70590
|
var RETRAIN_FRAMES_COLLECTION = "pipeline-analytics:retrain-frames";
|
|
70558
|
-
/**
|
|
70559
|
-
*
|
|
70560
|
-
|
|
70591
|
+
/**
|
|
70592
|
+
* The PERMANENT location ref every retrain copy is written under (D781).
|
|
70593
|
+
*
|
|
70594
|
+
* Not a declared storage class: it is the rows' `locationId`, which the
|
|
70595
|
+
* media-storage wiring maps onto the `artifacts` class root — exactly how the
|
|
70596
|
+
* enrolled gallery's `galleryMedia` ref works (D341). The copies are curation
|
|
70597
|
+
* work with NO retention, so they share the one durable class rather than the
|
|
70598
|
+
* footage class, which is quota-evicted, drained and wipeable per disk. Rows
|
|
70599
|
+
* are never restamped for a root move: the ref is remapped instead, and the
|
|
70600
|
+
* singleton repoint carries the whole `artifacts` tree, `retrain/` included.
|
|
70601
|
+
*/
|
|
70602
|
+
var RETRAIN_MEDIA_LOCATION = "retrainMedia";
|
|
70603
|
+
/**
|
|
70604
|
+
* Where copies taken before D781 live: a NULL `locationId` meant "wherever
|
|
70605
|
+
* `eventMedia` points", and a concrete `eventMedia:*` id was stamped by the
|
|
70606
|
+
* seal or a relocate pass. The boot reconcile
|
|
70607
|
+
* ({@link RetrainFrameStore.relocateLegacyCopies}) moves them onto
|
|
70608
|
+
* {@link RETRAIN_MEDIA_LOCATION}; until it has, reads resolve them here.
|
|
70609
|
+
*/
|
|
70610
|
+
var LEGACY_RETRAIN_MEDIA_LOCATION = "eventMedia";
|
|
70611
|
+
/** Is this row already on the durable retrain tenant? */
|
|
70612
|
+
function rowIsDurableRetrainCopy(data) {
|
|
70613
|
+
return data?.["locationId"] === RETRAIN_MEDIA_LOCATION;
|
|
70614
|
+
}
|
|
70561
70615
|
/** Path prefix of every retrain copy. Never `<deviceId>/events/…`: the orphan
|
|
70562
70616
|
* audit resolves ownership from INDEX rows, but a future filesystem-side tool
|
|
70563
70617
|
* must be able to tell the two corpora apart by path alone. */
|
|
@@ -70615,11 +70669,45 @@ var RETRAIN_FRAME_COLUMNS = [
|
|
|
70615
70669
|
notNull: true
|
|
70616
70670
|
},
|
|
70617
70671
|
(
|
|
70618
|
-
/**
|
|
70619
|
-
*
|
|
70672
|
+
/** `retrainMedia` for every copy taken since D781. NULL (the eventMedia
|
|
70673
|
+
* default) or a concrete `eventMedia:*` id only on a pre-D781 copy the boot
|
|
70674
|
+
* reconcile has not moved yet. */
|
|
70620
70675
|
{
|
|
70621
70676
|
name: "locationId",
|
|
70622
70677
|
type: "TEXT"
|
|
70678
|
+
}),
|
|
70679
|
+
(
|
|
70680
|
+
/** When the CAMERA captured the frame — the source media row's timestamp.
|
|
70681
|
+
* NULL on a copy taken before D785. */
|
|
70682
|
+
{
|
|
70683
|
+
name: "capturedAt",
|
|
70684
|
+
type: "INTEGER"
|
|
70685
|
+
}),
|
|
70686
|
+
(
|
|
70687
|
+
/** Mean channel spread of the copy (`chromaSpread`), measured once at copy
|
|
70688
|
+
* time; below `IR_CHROMA_THRESHOLD` the frame is IR. NULL = not measured. */
|
|
70689
|
+
{
|
|
70690
|
+
name: "chromaSpread",
|
|
70691
|
+
type: "REAL"
|
|
70692
|
+
}),
|
|
70693
|
+
(
|
|
70694
|
+
/** The operator's "every subject in this frame is boxed". NULL = no. */
|
|
70695
|
+
{
|
|
70696
|
+
name: "complete",
|
|
70697
|
+
type: "BOOLEAN"
|
|
70698
|
+
}),
|
|
70699
|
+
(
|
|
70700
|
+
/** `recording` for a still taken from the recording at an instant (no
|
|
70701
|
+
* track, no source media row). NULL = a track copy. */
|
|
70702
|
+
{
|
|
70703
|
+
name: "origin",
|
|
70704
|
+
type: "TEXT"
|
|
70705
|
+
}),
|
|
70706
|
+
(
|
|
70707
|
+
/** The recording instant a `recording` still was asked for. */
|
|
70708
|
+
{
|
|
70709
|
+
name: "epochMs",
|
|
70710
|
+
type: "INTEGER"
|
|
70623
70711
|
})
|
|
70624
70712
|
];
|
|
70625
70713
|
var RETRAIN_FRAME_INDEXES = [
|
|
@@ -70645,6 +70733,8 @@ var RETRAIN_FRAME_INDEXES = [
|
|
|
70645
70733
|
columns: ["locationId"]
|
|
70646
70734
|
})
|
|
70647
70735
|
];
|
|
70736
|
+
/** Ids per `IN (…)` page in {@link RetrainFrameStore.getMany}. */
|
|
70737
|
+
var GET_MANY_PAGE = 500;
|
|
70648
70738
|
/**
|
|
70649
70739
|
* The copy's id, derived from `(trackId, sourceMediaId)`.
|
|
70650
70740
|
*
|
|
@@ -70657,8 +70747,24 @@ var RETRAIN_FRAME_INDEXES = [
|
|
|
70657
70747
|
function retrainFrameId(trackId, sourceMediaId) {
|
|
70658
70748
|
return `retrain:${trackId}:${createHash("sha1").update(String(sourceMediaId)).digest("hex").slice(0, 16)}`;
|
|
70659
70749
|
}
|
|
70660
|
-
|
|
70661
|
-
|
|
70750
|
+
/**
|
|
70751
|
+
* A recording still's id, derived from `(deviceId, epochMs)` — the same moment
|
|
70752
|
+
* of the same camera is one copy, however many times it is added.
|
|
70753
|
+
*/
|
|
70754
|
+
function recordingFrameId(deviceId, epochMs) {
|
|
70755
|
+
const digest = createHash("sha1").update(String(epochMs)).digest("hex").slice(0, 16);
|
|
70756
|
+
return `retrain:rec:${String(deviceId)}:${digest}`;
|
|
70757
|
+
}
|
|
70758
|
+
/** The directory a recording still sits in, beside the track directories. */
|
|
70759
|
+
var RECORDING_STILL_DIR = "recording";
|
|
70760
|
+
function framePath(frame) {
|
|
70761
|
+
const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
|
|
70762
|
+
const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
|
|
70763
|
+
return `${RETRAIN_PATH_PREFIX}/${frame.deviceId}/${dir}/${leaf}.jpg`;
|
|
70764
|
+
}
|
|
70765
|
+
/** A stored number, or `undefined` for NULL / absent / junk. */
|
|
70766
|
+
function finiteNumber(value) {
|
|
70767
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
70662
70768
|
}
|
|
70663
70769
|
var RetrainFrameStore = class {
|
|
70664
70770
|
store;
|
|
@@ -70667,7 +70773,7 @@ var RetrainFrameStore = class {
|
|
|
70667
70773
|
readSource;
|
|
70668
70774
|
now;
|
|
70669
70775
|
writeGate;
|
|
70670
|
-
|
|
70776
|
+
measureChroma;
|
|
70671
70777
|
constructor(deps) {
|
|
70672
70778
|
this.store = deps.store;
|
|
70673
70779
|
this.storage = deps.storage;
|
|
@@ -70675,28 +70781,20 @@ var RetrainFrameStore = class {
|
|
|
70675
70781
|
this.readSource = deps.readSource;
|
|
70676
70782
|
this.now = deps.now;
|
|
70677
70783
|
this.writeGate = deps.writeGate;
|
|
70678
|
-
this.
|
|
70784
|
+
this.measureChroma = deps.measureChroma;
|
|
70679
70785
|
}
|
|
70680
|
-
/**
|
|
70681
|
-
|
|
70682
|
-
|
|
70683
|
-
* `null` = "no concrete location was resolved", which persists as the legacy
|
|
70684
|
-
* NULL row. A resolver that throws must not refuse the copy — losing an
|
|
70685
|
-
* operator's selection is worse than an unstamped row the seal can fix — but
|
|
70686
|
-
* it says so, because that branch re-creates the exact population a
|
|
70687
|
-
* non-blocking migration is gated on.
|
|
70688
|
-
*/
|
|
70689
|
-
async resolveWriteLocation(deviceId) {
|
|
70690
|
-
if (!this.resolveEventMediaLocation) return null;
|
|
70786
|
+
/** The copy's chroma, or `undefined` when it cannot be measured. */
|
|
70787
|
+
async chromaOf(bytes, deviceId) {
|
|
70788
|
+
if (!this.measureChroma) return void 0;
|
|
70691
70789
|
try {
|
|
70692
|
-
const
|
|
70693
|
-
return
|
|
70790
|
+
const value = await this.measureChroma(bytes);
|
|
70791
|
+
return Number.isFinite(value) ? value : void 0;
|
|
70694
70792
|
} catch (err) {
|
|
70695
|
-
this.logger.warn("retrain copy could not
|
|
70793
|
+
this.logger.warn("retrain copy chroma could not be measured — its day/night falls back to the sun", {
|
|
70696
70794
|
tags: { deviceId },
|
|
70697
70795
|
meta: { error: String(err) }
|
|
70698
70796
|
});
|
|
70699
|
-
return
|
|
70797
|
+
return;
|
|
70700
70798
|
}
|
|
70701
70799
|
}
|
|
70702
70800
|
static async declare(store) {
|
|
@@ -70707,9 +70805,11 @@ var RetrainFrameStore = class {
|
|
|
70707
70805
|
});
|
|
70708
70806
|
}
|
|
70709
70807
|
rowToFrame(id, data) {
|
|
70808
|
+
const origin = data["origin"] === "recording" ? "recording" : "track";
|
|
70710
70809
|
return {
|
|
70711
70810
|
frameId: id,
|
|
70712
70811
|
deviceId: Number(data["deviceId"] ?? 0),
|
|
70812
|
+
origin,
|
|
70713
70813
|
trackId: String(data["trackId"] ?? ""),
|
|
70714
70814
|
sourceMediaId: Number(data["sourceMediaId"] ?? 0),
|
|
70715
70815
|
sourceKind: String(data["sourceKind"] ?? "keyFrame"),
|
|
@@ -70717,9 +70817,57 @@ var RetrainFrameStore = class {
|
|
|
70717
70817
|
width: Number(data["width"] ?? 0),
|
|
70718
70818
|
height: Number(data["height"] ?? 0),
|
|
70719
70819
|
copiedAt: Number(data["copiedAt"] ?? 0),
|
|
70720
|
-
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
|
|
70820
|
+
locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null,
|
|
70821
|
+
...finiteNumber(data["capturedAt"]) !== void 0 ? { capturedAt: finiteNumber(data["capturedAt"]) } : {},
|
|
70822
|
+
...finiteNumber(data["chromaSpread"]) !== void 0 ? { chromaSpread: finiteNumber(data["chromaSpread"]) } : {},
|
|
70823
|
+
complete: data["complete"] === true || data["complete"] === 1,
|
|
70824
|
+
...finiteNumber(data["epochMs"]) !== void 0 ? { epochMs: finiteNumber(data["epochMs"]) } : {}
|
|
70721
70825
|
};
|
|
70722
70826
|
}
|
|
70827
|
+
/** The copies with these ids, in the order given; unknown ids are skipped. */
|
|
70828
|
+
async getMany(frameIds) {
|
|
70829
|
+
if (frameIds.length === 0) return [];
|
|
70830
|
+
try {
|
|
70831
|
+
const byId = /* @__PURE__ */ new Map();
|
|
70832
|
+
for (let i = 0; i < frameIds.length; i += GET_MANY_PAGE) {
|
|
70833
|
+
const page = frameIds.slice(i, i + GET_MANY_PAGE);
|
|
70834
|
+
const rows = await this.store.query.query({
|
|
70835
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
70836
|
+
filter: {
|
|
70837
|
+
whereIn: { id: [...page] },
|
|
70838
|
+
limit: page.length
|
|
70839
|
+
}
|
|
70840
|
+
});
|
|
70841
|
+
for (const r of rows) if (typeof r.id === "string") byId.set(r.id, this.rowToFrame(r.id, r.data));
|
|
70842
|
+
}
|
|
70843
|
+
return frameIds.flatMap((id) => {
|
|
70844
|
+
const frame = byId.get(id);
|
|
70845
|
+
return frame !== void 0 ? [frame] : [];
|
|
70846
|
+
});
|
|
70847
|
+
} catch (err) {
|
|
70848
|
+
this.logger.warn("RetrainFrameStore.getMany failed", { meta: {
|
|
70849
|
+
frames: frameIds.length,
|
|
70850
|
+
error: String(err)
|
|
70851
|
+
} });
|
|
70852
|
+
return [];
|
|
70853
|
+
}
|
|
70854
|
+
}
|
|
70855
|
+
/** Every camera the dataset holds a copy of — a recording still can be the
|
|
70856
|
+
* only thing a camera contributes, with no track anywhere. */
|
|
70857
|
+
async listDeviceIds() {
|
|
70858
|
+
try {
|
|
70859
|
+
const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
|
|
70860
|
+
const ids = /* @__PURE__ */ new Set();
|
|
70861
|
+
for (const r of rows) {
|
|
70862
|
+
const id = Number(r.data["deviceId"]);
|
|
70863
|
+
if (Number.isInteger(id) && id > 0) ids.add(id);
|
|
70864
|
+
}
|
|
70865
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
70866
|
+
} catch (err) {
|
|
70867
|
+
this.logger.warn("RetrainFrameStore.listDeviceIds failed", { meta: { error: String(err) } });
|
|
70868
|
+
return [];
|
|
70869
|
+
}
|
|
70870
|
+
}
|
|
70723
70871
|
/** The copy with this id, or `null`. Index read only — no blob is touched. */
|
|
70724
70872
|
async get(frameId) {
|
|
70725
70873
|
try {
|
|
@@ -70736,8 +70884,10 @@ var RetrainFrameStore = class {
|
|
|
70736
70884
|
return null;
|
|
70737
70885
|
}
|
|
70738
70886
|
}
|
|
70739
|
-
/** Every copy taken from this track, oldest first.
|
|
70887
|
+
/** Every copy taken from this track, oldest first. A recording still has
|
|
70888
|
+
* no track and is never answered here. */
|
|
70740
70889
|
async listByTrack(trackId) {
|
|
70890
|
+
if (trackId === "") return [];
|
|
70741
70891
|
try {
|
|
70742
70892
|
return (await this.store.query.query({
|
|
70743
70893
|
collection: RETRAIN_FRAMES_COLLECTION,
|
|
@@ -70757,6 +70907,31 @@ var RetrainFrameStore = class {
|
|
|
70757
70907
|
return [];
|
|
70758
70908
|
}
|
|
70759
70909
|
}
|
|
70910
|
+
/**
|
|
70911
|
+
* The copies of one camera the operator marked complete. A complete frame
|
|
70912
|
+
* with no box is a hard negative, so an export cannot find these through
|
|
70913
|
+
* the annotation table alone.
|
|
70914
|
+
*/
|
|
70915
|
+
async listCompleteByDevice(deviceId, limit) {
|
|
70916
|
+
try {
|
|
70917
|
+
return (await this.store.query.query({
|
|
70918
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
70919
|
+
filter: {
|
|
70920
|
+
where: {
|
|
70921
|
+
deviceId,
|
|
70922
|
+
complete: true
|
|
70923
|
+
},
|
|
70924
|
+
limit
|
|
70925
|
+
}
|
|
70926
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToFrame(r.id, r.data));
|
|
70927
|
+
} catch (err) {
|
|
70928
|
+
this.logger.warn("RetrainFrameStore.listCompleteByDevice failed", {
|
|
70929
|
+
tags: { deviceId },
|
|
70930
|
+
meta: { error: String(err) }
|
|
70931
|
+
});
|
|
70932
|
+
return [];
|
|
70933
|
+
}
|
|
70934
|
+
}
|
|
70760
70935
|
/** How many copies each of these tracks already owns — one query, so the
|
|
70761
70936
|
* staging list can badge "worked / not worked" without N round trips. */
|
|
70762
70937
|
async countsByTrack(trackIds) {
|
|
@@ -70785,8 +70960,8 @@ var RetrainFrameStore = class {
|
|
|
70785
70960
|
async readFrame(frame) {
|
|
70786
70961
|
try {
|
|
70787
70962
|
return await this.storage.read({
|
|
70788
|
-
location: frame.locationId ??
|
|
70789
|
-
relativePath: framePath(frame
|
|
70963
|
+
location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
|
|
70964
|
+
relativePath: framePath(frame)
|
|
70790
70965
|
});
|
|
70791
70966
|
} catch (err) {
|
|
70792
70967
|
this.logger.warn("retrain frame copy unreadable", {
|
|
@@ -70855,11 +71030,17 @@ var RetrainFrameStore = class {
|
|
|
70855
71030
|
reason: "unreadable-image"
|
|
70856
71031
|
};
|
|
70857
71032
|
}
|
|
70858
|
-
const path = framePath(
|
|
70859
|
-
|
|
71033
|
+
const path = framePath({
|
|
71034
|
+
...input,
|
|
71035
|
+
frameId,
|
|
71036
|
+
origin: "track"
|
|
71037
|
+
});
|
|
71038
|
+
const locationId = RETRAIN_MEDIA_LOCATION;
|
|
71039
|
+
const chromaSpread = await this.chromaOf(bytes, input.deviceId);
|
|
70860
71040
|
const frame = {
|
|
70861
71041
|
frameId,
|
|
70862
71042
|
deviceId: input.deviceId,
|
|
71043
|
+
origin: "track",
|
|
70863
71044
|
trackId: input.trackId,
|
|
70864
71045
|
sourceMediaId: input.sourceMediaId,
|
|
70865
71046
|
sourceKind: input.sourceKind,
|
|
@@ -70867,11 +71048,14 @@ var RetrainFrameStore = class {
|
|
|
70867
71048
|
width: size.width,
|
|
70868
71049
|
height: size.height,
|
|
70869
71050
|
copiedAt: this.now(),
|
|
70870
|
-
locationId
|
|
71051
|
+
locationId,
|
|
71052
|
+
...input.capturedAt !== void 0 ? { capturedAt: input.capturedAt } : {},
|
|
71053
|
+
...chromaSpread !== void 0 ? { chromaSpread } : {},
|
|
71054
|
+
complete: false
|
|
70871
71055
|
};
|
|
70872
71056
|
const persist = async () => {
|
|
70873
71057
|
await this.storage.write({
|
|
70874
|
-
location: locationId
|
|
71058
|
+
location: locationId,
|
|
70875
71059
|
relativePath: path,
|
|
70876
71060
|
data: bytes
|
|
70877
71061
|
});
|
|
@@ -70888,7 +71072,10 @@ var RetrainFrameStore = class {
|
|
|
70888
71072
|
width: frame.width,
|
|
70889
71073
|
height: frame.height,
|
|
70890
71074
|
copiedAt: frame.copiedAt,
|
|
70891
|
-
locationId: frame.locationId
|
|
71075
|
+
locationId: frame.locationId,
|
|
71076
|
+
capturedAt: frame.capturedAt ?? null,
|
|
71077
|
+
chromaSpread: frame.chromaSpread ?? null,
|
|
71078
|
+
complete: false
|
|
70892
71079
|
}
|
|
70893
71080
|
});
|
|
70894
71081
|
};
|
|
@@ -70928,6 +71115,136 @@ var RetrainFrameStore = class {
|
|
|
70928
71115
|
};
|
|
70929
71116
|
}
|
|
70930
71117
|
/**
|
|
71118
|
+
* Store a still decoded from the RECORDING at `epochMs` — a frame with no
|
|
71119
|
+
* track and no media row, usually the one the model saw nothing in.
|
|
71120
|
+
*
|
|
71121
|
+
* Same contract as {@link copyFrame}: an existing copy of that moment is
|
|
71122
|
+
* returned untouched; the blob lands before the row; a refusal is named.
|
|
71123
|
+
* `capturedAt` IS the instant — the camera saw this frame then.
|
|
71124
|
+
*/
|
|
71125
|
+
async storeRecordingStill(input) {
|
|
71126
|
+
const frameId = recordingFrameId(input.deviceId, input.epochMs);
|
|
71127
|
+
const existing = await this.get(frameId);
|
|
71128
|
+
if (existing !== null) return {
|
|
71129
|
+
kind: "copied",
|
|
71130
|
+
frame: existing,
|
|
71131
|
+
reused: true
|
|
71132
|
+
};
|
|
71133
|
+
const size = readJpegSize(input.bytes);
|
|
71134
|
+
if (size === null) {
|
|
71135
|
+
this.logger.warn("retrain recording still refused — not a readable JPEG", {
|
|
71136
|
+
tags: { deviceId: input.deviceId },
|
|
71137
|
+
meta: {
|
|
71138
|
+
epochMs: input.epochMs,
|
|
71139
|
+
bytes: input.bytes.length
|
|
71140
|
+
}
|
|
71141
|
+
});
|
|
71142
|
+
return {
|
|
71143
|
+
kind: "refused",
|
|
71144
|
+
reason: "unreadable-image"
|
|
71145
|
+
};
|
|
71146
|
+
}
|
|
71147
|
+
const chromaSpread = await this.chromaOf(input.bytes, input.deviceId);
|
|
71148
|
+
const frame = {
|
|
71149
|
+
frameId,
|
|
71150
|
+
deviceId: input.deviceId,
|
|
71151
|
+
origin: "recording",
|
|
71152
|
+
trackId: "",
|
|
71153
|
+
sourceMediaId: 0,
|
|
71154
|
+
sourceKind: "fullFrame",
|
|
71155
|
+
sizeBytes: input.bytes.length,
|
|
71156
|
+
width: size.width,
|
|
71157
|
+
height: size.height,
|
|
71158
|
+
copiedAt: this.now(),
|
|
71159
|
+
locationId: RETRAIN_MEDIA_LOCATION,
|
|
71160
|
+
capturedAt: input.epochMs,
|
|
71161
|
+
...chromaSpread !== void 0 ? { chromaSpread } : {},
|
|
71162
|
+
complete: false,
|
|
71163
|
+
epochMs: input.epochMs
|
|
71164
|
+
};
|
|
71165
|
+
const path = framePath(frame);
|
|
71166
|
+
const persist = async () => {
|
|
71167
|
+
await this.storage.write({
|
|
71168
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71169
|
+
relativePath: path,
|
|
71170
|
+
data: input.bytes
|
|
71171
|
+
});
|
|
71172
|
+
await this.store.set.mutate({
|
|
71173
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71174
|
+
key: frameId,
|
|
71175
|
+
value: {
|
|
71176
|
+
deviceId: frame.deviceId,
|
|
71177
|
+
trackId: "",
|
|
71178
|
+
sourceMediaId: 0,
|
|
71179
|
+
sourceKind: frame.sourceKind,
|
|
71180
|
+
path,
|
|
71181
|
+
sizeBytes: frame.sizeBytes,
|
|
71182
|
+
width: frame.width,
|
|
71183
|
+
height: frame.height,
|
|
71184
|
+
copiedAt: frame.copiedAt,
|
|
71185
|
+
locationId: RETRAIN_MEDIA_LOCATION,
|
|
71186
|
+
capturedAt: input.epochMs,
|
|
71187
|
+
chromaSpread: frame.chromaSpread ?? null,
|
|
71188
|
+
complete: false,
|
|
71189
|
+
origin: "recording",
|
|
71190
|
+
epochMs: input.epochMs
|
|
71191
|
+
}
|
|
71192
|
+
});
|
|
71193
|
+
};
|
|
71194
|
+
try {
|
|
71195
|
+
if (this.writeGate) await this.writeGate.run(persist);
|
|
71196
|
+
else await persist();
|
|
71197
|
+
} catch (err) {
|
|
71198
|
+
this.logger.error("retrain recording still failed to persist", {
|
|
71199
|
+
tags: { deviceId: input.deviceId },
|
|
71200
|
+
meta: {
|
|
71201
|
+
frameId,
|
|
71202
|
+
epochMs: input.epochMs,
|
|
71203
|
+
error: String(err)
|
|
71204
|
+
}
|
|
71205
|
+
});
|
|
71206
|
+
return {
|
|
71207
|
+
kind: "refused",
|
|
71208
|
+
reason: "write-failed"
|
|
71209
|
+
};
|
|
71210
|
+
}
|
|
71211
|
+
this.logger.info("retrain recording still stored in the dataset", {
|
|
71212
|
+
tags: { deviceId: input.deviceId },
|
|
71213
|
+
meta: {
|
|
71214
|
+
frameId,
|
|
71215
|
+
epochMs: input.epochMs,
|
|
71216
|
+
width: frame.width,
|
|
71217
|
+
height: frame.height
|
|
71218
|
+
}
|
|
71219
|
+
});
|
|
71220
|
+
return {
|
|
71221
|
+
kind: "copied",
|
|
71222
|
+
frame,
|
|
71223
|
+
reused: false
|
|
71224
|
+
};
|
|
71225
|
+
}
|
|
71226
|
+
/**
|
|
71227
|
+
* The operator's "every subject in this frame is boxed" (D785 context: an
|
|
71228
|
+
* unboxed subject is a false positive to a detector evaluation). Only the
|
|
71229
|
+
* operator sets it; nothing derives it from the annotation count, because a
|
|
71230
|
+
* frame with zero boxes may be a complete hard negative.
|
|
71231
|
+
*
|
|
71232
|
+
* @returns the updated frame, or `null` when no such copy exists.
|
|
71233
|
+
*/
|
|
71234
|
+
async setComplete(frameId, complete) {
|
|
71235
|
+
const existing = await this.get(frameId);
|
|
71236
|
+
if (existing === null) return null;
|
|
71237
|
+
await this.store.update.mutate({
|
|
71238
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71239
|
+
id: frameId,
|
|
71240
|
+
data: { complete }
|
|
71241
|
+
});
|
|
71242
|
+
return {
|
|
71243
|
+
...existing,
|
|
71244
|
+
complete
|
|
71245
|
+
};
|
|
71246
|
+
}
|
|
71247
|
+
/**
|
|
70931
71248
|
* Drop a copy and its blob — the operator un-selecting a frame.
|
|
70932
71249
|
*
|
|
70933
71250
|
* The caller must have removed the frame's annotations first; this store does
|
|
@@ -70938,8 +71255,8 @@ var RetrainFrameStore = class {
|
|
|
70938
71255
|
const remove = async () => {
|
|
70939
71256
|
try {
|
|
70940
71257
|
await this.storage.delete({
|
|
70941
|
-
location: frame.locationId ??
|
|
70942
|
-
relativePath: framePath(frame
|
|
71258
|
+
location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
|
|
71259
|
+
relativePath: framePath(frame)
|
|
70943
71260
|
});
|
|
70944
71261
|
} catch {}
|
|
70945
71262
|
try {
|
|
@@ -70960,6 +71277,95 @@ var RetrainFrameStore = class {
|
|
|
70960
71277
|
if (this.writeGate) await this.writeGate.run(remove);
|
|
70961
71278
|
else await remove();
|
|
70962
71279
|
}
|
|
71280
|
+
/**
|
|
71281
|
+
* Move every copy still on the footage class onto the durable retrain
|
|
71282
|
+
* tenant (D781): copy → read back → restamp → delete the source.
|
|
71283
|
+
*
|
|
71284
|
+
* A reconcile, not a one-shot: one query of a collection bounded by
|
|
71285
|
+
* operator selection, so it is cheap when converged, and it is what would
|
|
71286
|
+
* notice a pre-D781 copy restored from anywhere. The order is the gallery
|
|
71287
|
+
* reconcile's (D341) and for the same reason: the row is restamped only
|
|
71288
|
+
* after the new blob reads back, so a crash at any point leaves a row that
|
|
71289
|
+
* names a location holding its bytes.
|
|
71290
|
+
*
|
|
71291
|
+
* A copy whose bytes are already gone is COUNTED and LOGGED, never deleted:
|
|
71292
|
+
* its annotations are the operator's work, and only the operator decides
|
|
71293
|
+
* to drop them (deselecting the frame).
|
|
71294
|
+
*/
|
|
71295
|
+
async relocateLegacyCopies() {
|
|
71296
|
+
const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
|
|
71297
|
+
let relocated = 0;
|
|
71298
|
+
let missing = 0;
|
|
71299
|
+
let failed = 0;
|
|
71300
|
+
for (const row of rows) {
|
|
71301
|
+
if (typeof row.id !== "string") continue;
|
|
71302
|
+
const data = row.data;
|
|
71303
|
+
if (rowIsDurableRetrainCopy(data)) continue;
|
|
71304
|
+
const frame = this.rowToFrame(row.id, data);
|
|
71305
|
+
const from = frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION;
|
|
71306
|
+
const relativePath = framePath(frame);
|
|
71307
|
+
let bytes;
|
|
71308
|
+
try {
|
|
71309
|
+
bytes = await this.storage.read({
|
|
71310
|
+
location: from,
|
|
71311
|
+
relativePath
|
|
71312
|
+
});
|
|
71313
|
+
} catch (err) {
|
|
71314
|
+
missing += 1;
|
|
71315
|
+
this.logger.warn("retrain copy has NO bytes on the footage class — its row and annotations are kept; deselect the frame to drop it", {
|
|
71316
|
+
tags: { deviceId: frame.deviceId },
|
|
71317
|
+
meta: {
|
|
71318
|
+
frameId: frame.frameId,
|
|
71319
|
+
trackId: frame.trackId,
|
|
71320
|
+
from,
|
|
71321
|
+
error: String(err)
|
|
71322
|
+
}
|
|
71323
|
+
});
|
|
71324
|
+
continue;
|
|
71325
|
+
}
|
|
71326
|
+
const move = async () => {
|
|
71327
|
+
await this.storage.write({
|
|
71328
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71329
|
+
relativePath,
|
|
71330
|
+
data: bytes
|
|
71331
|
+
});
|
|
71332
|
+
if (!(await this.storage.read({
|
|
71333
|
+
location: RETRAIN_MEDIA_LOCATION,
|
|
71334
|
+
relativePath
|
|
71335
|
+
})).equals(bytes)) throw new Error("read-back differs from the source");
|
|
71336
|
+
await this.store.update.mutate({
|
|
71337
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
71338
|
+
id: frame.frameId,
|
|
71339
|
+
data: { locationId: RETRAIN_MEDIA_LOCATION }
|
|
71340
|
+
});
|
|
71341
|
+
await this.storage.delete({
|
|
71342
|
+
location: from,
|
|
71343
|
+
relativePath
|
|
71344
|
+
});
|
|
71345
|
+
};
|
|
71346
|
+
try {
|
|
71347
|
+
if (this.writeGate) await this.writeGate.run(move);
|
|
71348
|
+
else await move();
|
|
71349
|
+
relocated += 1;
|
|
71350
|
+
} catch (err) {
|
|
71351
|
+
failed += 1;
|
|
71352
|
+
this.logger.error("retrain copy could not be moved off the footage class", {
|
|
71353
|
+
tags: { deviceId: frame.deviceId },
|
|
71354
|
+
meta: {
|
|
71355
|
+
frameId: frame.frameId,
|
|
71356
|
+
trackId: frame.trackId,
|
|
71357
|
+
from,
|
|
71358
|
+
error: String(err)
|
|
71359
|
+
}
|
|
71360
|
+
});
|
|
71361
|
+
}
|
|
71362
|
+
}
|
|
71363
|
+
return {
|
|
71364
|
+
relocated,
|
|
71365
|
+
missing,
|
|
71366
|
+
failed
|
|
71367
|
+
};
|
|
71368
|
+
}
|
|
70963
71369
|
};
|
|
70964
71370
|
//#endregion
|
|
70965
71371
|
//#region src/pipeline-analytics/retention/location-class-wipe.ts
|
|
@@ -82013,6 +82419,17 @@ var RETRAIN_ANNOTATION_INDEXES = [
|
|
|
82013
82419
|
columns: ["deviceId", "createdAt"]
|
|
82014
82420
|
}
|
|
82015
82421
|
];
|
|
82422
|
+
var ANNOTATION_SOURCES = [
|
|
82423
|
+
"operator",
|
|
82424
|
+
"assist",
|
|
82425
|
+
"pipeline",
|
|
82426
|
+
"llm"
|
|
82427
|
+
];
|
|
82428
|
+
/** The stored source, narrowed. Unknown reads as `operator` — the least
|
|
82429
|
+
* presumptuous claim a row can make about who drew it. */
|
|
82430
|
+
function narrowSource(value) {
|
|
82431
|
+
return ANNOTATION_SOURCES.find((source) => source === value) ?? "operator";
|
|
82432
|
+
}
|
|
82016
82433
|
function optionalString(value) {
|
|
82017
82434
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
82018
82435
|
}
|
|
@@ -82020,6 +82437,8 @@ function optionalString(value) {
|
|
|
82020
82437
|
function narrowMacroClass(value) {
|
|
82021
82438
|
return typeof value === "string" && isRetrainMacroClass(value) ? value : "person";
|
|
82022
82439
|
}
|
|
82440
|
+
/** Frame ids per `IN (…)` page in {@link RetrainAnnotationStore.listByFrames}. */
|
|
82441
|
+
var LIST_BY_FRAMES_PAGE = 500;
|
|
82023
82442
|
var RetrainAnnotationStore = class {
|
|
82024
82443
|
store;
|
|
82025
82444
|
logger;
|
|
@@ -82056,7 +82475,7 @@ var RetrainAnnotationStore = class {
|
|
|
82056
82475
|
label: optionalString(data["label"]),
|
|
82057
82476
|
subLabel: optionalString(data["subLabel"]),
|
|
82058
82477
|
kind: data["kind"] === "model_error" ? "model_error" : "subject",
|
|
82059
|
-
source: data["source"]
|
|
82478
|
+
source: narrowSource(data["source"]),
|
|
82060
82479
|
assistModelId: optionalString(data["assistModelId"]),
|
|
82061
82480
|
assistScore: typeof score === "number" ? score : void 0,
|
|
82062
82481
|
exportedInBatch: optionalString(data["exportedInBatch"]),
|
|
@@ -82125,6 +82544,32 @@ var RetrainAnnotationStore = class {
|
|
|
82125
82544
|
async listByFrame(mediaKey) {
|
|
82126
82545
|
return this.queryWhere({ mediaKey }, `frame ${mediaKey}`);
|
|
82127
82546
|
}
|
|
82547
|
+
/** Every annotation of these frames, oldest first — paged `IN (…)`. */
|
|
82548
|
+
async listByFrames(mediaKeys) {
|
|
82549
|
+
const out = [];
|
|
82550
|
+
for (let i = 0; i < mediaKeys.length; i += LIST_BY_FRAMES_PAGE) {
|
|
82551
|
+
const page = mediaKeys.slice(i, i + LIST_BY_FRAMES_PAGE);
|
|
82552
|
+
try {
|
|
82553
|
+
const rows = await this.store.query.query({
|
|
82554
|
+
collection: RETRAIN_ANNOTATIONS_COLLECTION,
|
|
82555
|
+
filter: {
|
|
82556
|
+
whereIn: { mediaKey: [...page] },
|
|
82557
|
+
orderBy: {
|
|
82558
|
+
field: "createdAt",
|
|
82559
|
+
direction: "asc"
|
|
82560
|
+
}
|
|
82561
|
+
}
|
|
82562
|
+
});
|
|
82563
|
+
for (const r of rows) if (typeof r.id === "string") out.push(this.rowToAnnotation(r.id, r.data));
|
|
82564
|
+
} catch (err) {
|
|
82565
|
+
this.logger.warn("RetrainAnnotationStore.listByFrames failed", { meta: {
|
|
82566
|
+
frames: page.length,
|
|
82567
|
+
error: String(err)
|
|
82568
|
+
} });
|
|
82569
|
+
}
|
|
82570
|
+
}
|
|
82571
|
+
return out;
|
|
82572
|
+
}
|
|
82128
82573
|
async listByTrack(trackId) {
|
|
82129
82574
|
return this.queryWhere({ trackId }, `track ${trackId}`);
|
|
82130
82575
|
}
|
|
@@ -82238,13 +82683,41 @@ var RetrainAnnotationStore = class {
|
|
|
82238
82683
|
//#endregion
|
|
82239
82684
|
//#region src/pipeline-analytics/retrain/retrain-collection-store.ts
|
|
82240
82685
|
/**
|
|
82686
|
+
* RetrainCollectionStore — named sets over the one retrain corpus.
|
|
82687
|
+
*
|
|
82688
|
+
* Collections: `pipeline-analytics:retrain-collections` (the names) and
|
|
82689
|
+
* `pipeline-analytics:retrain-collection-frames` (the membership).
|
|
82690
|
+
*
|
|
82691
|
+
* A collection OWNS NO PIXELS. Every frame — copied from a track or stilled
|
|
82692
|
+
* from the recording — lives once in `retrain-frames`, with its annotations,
|
|
82693
|
+
* its completeness and its export path; a collection is a name the operator
|
|
82694
|
+
* gives to a subset of it ("packages porch night", "false-positive cars"). So
|
|
82695
|
+
* a frame can be in several collections, a collection's count is its
|
|
82696
|
+
* membership, and deleting a collection removes memberships, never a frame
|
|
82697
|
+
* another collection or a track still holds. Which trackless frames are left
|
|
82698
|
+
* with no reason to exist is the caller's question
|
|
82699
|
+
* (`retrain-collection-service.ts`).
|
|
82700
|
+
*/
|
|
82701
|
+
/**
|
|
82241
82702
|
* @durable class=ledger owner=pipeline-analytics
|
|
82242
82703
|
* write="one row per named training collection the operator creates. Names are
|
|
82243
|
-
* unique on nameKey (lower(trim)); empty names never become rows.
|
|
82244
|
-
*
|
|
82245
|
-
*
|
|
82704
|
+
* unique on nameKey (lower(trim)); empty names never become rows. A rename
|
|
82705
|
+
* rewrites name + nameKey under the same id."
|
|
82706
|
+
* retention="none — operator-owned corpus metadata. A row leaves only when the
|
|
82707
|
+
* operator deletes the collection (deleteRetrainCollection), which removes its
|
|
82708
|
+
* membership rows first."
|
|
82246
82709
|
*/
|
|
82247
82710
|
var RETRAIN_COLLECTIONS_COLLECTION = "pipeline-analytics:retrain-collections";
|
|
82711
|
+
/**
|
|
82712
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
82713
|
+
* write="one row per (collection, frame) pair the operator adds — a frame added
|
|
82714
|
+
* from the recording, or an existing retrain frame added to a collection. Keyed
|
|
82715
|
+
* by a hash of the pair, so adding twice is one row."
|
|
82716
|
+
* retention="none — rows leave when the operator removes the frame from the
|
|
82717
|
+
* collection, deletes the collection, or deselects the frame itself. Bounded by
|
|
82718
|
+
* operator curation."
|
|
82719
|
+
*/
|
|
82720
|
+
var RETRAIN_COLLECTION_FRAMES_COLLECTION = "pipeline-analytics:retrain-collection-frames";
|
|
82248
82721
|
var RETRAIN_COLLECTION_COLUMNS = [
|
|
82249
82722
|
{
|
|
82250
82723
|
name: "id",
|
|
@@ -82281,6 +82754,41 @@ var RETRAIN_COLLECTION_INDEXES = [{
|
|
|
82281
82754
|
name: "idx_retrain_collections_created",
|
|
82282
82755
|
columns: ["createdAt"]
|
|
82283
82756
|
}];
|
|
82757
|
+
var RETRAIN_COLLECTION_FRAME_COLUMNS = [
|
|
82758
|
+
{
|
|
82759
|
+
name: "id",
|
|
82760
|
+
type: "TEXT",
|
|
82761
|
+
primaryKey: true,
|
|
82762
|
+
notNull: true
|
|
82763
|
+
},
|
|
82764
|
+
{
|
|
82765
|
+
name: "collectionId",
|
|
82766
|
+
type: "TEXT",
|
|
82767
|
+
notNull: true
|
|
82768
|
+
},
|
|
82769
|
+
{
|
|
82770
|
+
name: "frameId",
|
|
82771
|
+
type: "TEXT",
|
|
82772
|
+
notNull: true
|
|
82773
|
+
},
|
|
82774
|
+
{
|
|
82775
|
+
name: "deviceId",
|
|
82776
|
+
type: "INTEGER",
|
|
82777
|
+
notNull: true
|
|
82778
|
+
},
|
|
82779
|
+
{
|
|
82780
|
+
name: "addedAt",
|
|
82781
|
+
type: "INTEGER",
|
|
82782
|
+
notNull: true
|
|
82783
|
+
}
|
|
82784
|
+
];
|
|
82785
|
+
var RETRAIN_COLLECTION_FRAME_INDEXES = [{
|
|
82786
|
+
name: "idx_retrain_coll_frames_collection",
|
|
82787
|
+
columns: ["collectionId", "addedAt"]
|
|
82788
|
+
}, {
|
|
82789
|
+
name: "idx_retrain_coll_frames_frame",
|
|
82790
|
+
columns: ["frameId"]
|
|
82791
|
+
}];
|
|
82284
82792
|
function normalizeCollectionName(raw) {
|
|
82285
82793
|
const name = raw.trim();
|
|
82286
82794
|
if (name.length === 0) return null;
|
|
@@ -82289,18 +82797,22 @@ function normalizeCollectionName(raw) {
|
|
|
82289
82797
|
nameKey: name.toLowerCase()
|
|
82290
82798
|
};
|
|
82291
82799
|
}
|
|
82800
|
+
/** The membership row's key — one row per pair, whatever the ids contain. */
|
|
82801
|
+
function membershipKey(collectionId, frameId) {
|
|
82802
|
+
return createHash("sha1").update(`${collectionId}\u0000${frameId}`).digest("hex");
|
|
82803
|
+
}
|
|
82804
|
+
/** Ids per `IN (…)` page. */
|
|
82805
|
+
var IN_PAGE = 500;
|
|
82292
82806
|
var RetrainCollectionStore = class {
|
|
82293
82807
|
store;
|
|
82294
82808
|
logger;
|
|
82295
82809
|
now;
|
|
82296
82810
|
newId;
|
|
82297
|
-
countFrames;
|
|
82298
82811
|
constructor(deps) {
|
|
82299
82812
|
this.store = deps.store;
|
|
82300
82813
|
this.logger = deps.logger;
|
|
82301
82814
|
this.now = deps.now;
|
|
82302
82815
|
this.newId = deps.newId;
|
|
82303
|
-
this.countFrames = deps.countFrames;
|
|
82304
82816
|
}
|
|
82305
82817
|
static async declare(store) {
|
|
82306
82818
|
await store.declareCollection.mutate({
|
|
@@ -82308,6 +82820,11 @@ var RetrainCollectionStore = class {
|
|
|
82308
82820
|
columns: [...RETRAIN_COLLECTION_COLUMNS],
|
|
82309
82821
|
indexes: [...RETRAIN_COLLECTION_INDEXES]
|
|
82310
82822
|
});
|
|
82823
|
+
await store.declareCollection.mutate({
|
|
82824
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82825
|
+
columns: [...RETRAIN_COLLECTION_FRAME_COLUMNS],
|
|
82826
|
+
indexes: [...RETRAIN_COLLECTION_FRAME_INDEXES]
|
|
82827
|
+
});
|
|
82311
82828
|
}
|
|
82312
82829
|
async create(input) {
|
|
82313
82830
|
const normalized = normalizeCollectionName(input.name);
|
|
@@ -82315,7 +82832,7 @@ var RetrainCollectionStore = class {
|
|
|
82315
82832
|
kind: "refused",
|
|
82316
82833
|
reason: "empty-name"
|
|
82317
82834
|
};
|
|
82318
|
-
if (await this.
|
|
82835
|
+
if (await this.findIdByNameKey(normalized.nameKey) !== null) return {
|
|
82319
82836
|
kind: "refused",
|
|
82320
82837
|
reason: "duplicate-name"
|
|
82321
82838
|
};
|
|
@@ -82331,6 +82848,7 @@ var RetrainCollectionStore = class {
|
|
|
82331
82848
|
updatedAt: createdAt
|
|
82332
82849
|
}
|
|
82333
82850
|
});
|
|
82851
|
+
this.logger.info("retrain collection created", { meta: { collectionId: id } });
|
|
82334
82852
|
return {
|
|
82335
82853
|
kind: "created",
|
|
82336
82854
|
collection: {
|
|
@@ -82341,6 +82859,7 @@ var RetrainCollectionStore = class {
|
|
|
82341
82859
|
}
|
|
82342
82860
|
};
|
|
82343
82861
|
}
|
|
82862
|
+
/** Every collection, newest first, each with its membership count (one query). */
|
|
82344
82863
|
async list() {
|
|
82345
82864
|
let rows;
|
|
82346
82865
|
try {
|
|
@@ -82355,34 +82874,158 @@ var RetrainCollectionStore = class {
|
|
|
82355
82874
|
this.logger.warn("retrain collections list failed", { meta: { error: String(err) } });
|
|
82356
82875
|
return [];
|
|
82357
82876
|
}
|
|
82358
|
-
const
|
|
82359
|
-
|
|
82360
|
-
|
|
82361
|
-
|
|
82362
|
-
|
|
82363
|
-
|
|
82364
|
-
|
|
82365
|
-
|
|
82366
|
-
|
|
82367
|
-
|
|
82368
|
-
|
|
82877
|
+
const counts = await this.countMembers(rows.map((r) => r.id));
|
|
82878
|
+
return rows.map((row) => this.toCollection(row.id, row.data, counts.get(row.id) ?? 0));
|
|
82879
|
+
}
|
|
82880
|
+
async getById(id) {
|
|
82881
|
+
const data = await this.readRow(id);
|
|
82882
|
+
if (data === null) return null;
|
|
82883
|
+
const counts = await this.countMembers([id]);
|
|
82884
|
+
return this.toCollection(id, data, counts.get(id) ?? 0);
|
|
82885
|
+
}
|
|
82886
|
+
async rename(input) {
|
|
82887
|
+
const normalized = normalizeCollectionName(input.name);
|
|
82888
|
+
if (normalized === null) return {
|
|
82889
|
+
kind: "refused",
|
|
82890
|
+
reason: "empty-name"
|
|
82891
|
+
};
|
|
82892
|
+
const current = await this.readRow(input.id);
|
|
82893
|
+
if (current === null) return {
|
|
82894
|
+
kind: "refused",
|
|
82895
|
+
reason: "unknown-collection"
|
|
82896
|
+
};
|
|
82897
|
+
const holder = await this.findIdByNameKey(normalized.nameKey);
|
|
82898
|
+
if (holder !== null && holder !== input.id) return {
|
|
82899
|
+
kind: "refused",
|
|
82900
|
+
reason: "duplicate-name"
|
|
82901
|
+
};
|
|
82902
|
+
await this.store.update.mutate({
|
|
82903
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
82904
|
+
id: input.id,
|
|
82905
|
+
data: {
|
|
82906
|
+
name: normalized.name,
|
|
82907
|
+
nameKey: normalized.nameKey,
|
|
82908
|
+
updatedAt: this.now()
|
|
82909
|
+
}
|
|
82910
|
+
});
|
|
82911
|
+
this.logger.info("retrain collection renamed", { meta: { collectionId: input.id } });
|
|
82912
|
+
const counts = await this.countMembers([input.id]);
|
|
82913
|
+
return {
|
|
82914
|
+
kind: "renamed",
|
|
82915
|
+
collection: this.toCollection(input.id, {
|
|
82916
|
+
...current,
|
|
82917
|
+
name: normalized.name
|
|
82918
|
+
}, counts.get(input.id) ?? 0)
|
|
82919
|
+
};
|
|
82920
|
+
}
|
|
82921
|
+
/** Remove the collection and its memberships — members first, so a crash
|
|
82922
|
+
* never leaves memberships pointing at a name that no longer exists. */
|
|
82923
|
+
async delete(id) {
|
|
82924
|
+
if (await this.readRow(id) === null) return {
|
|
82925
|
+
deleted: false,
|
|
82926
|
+
memberFrameIds: []
|
|
82927
|
+
};
|
|
82928
|
+
const members = await this.membershipRows({ collectionId: id });
|
|
82929
|
+
for (const m of members) await this.store.delete.mutate({
|
|
82930
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82931
|
+
key: m.id
|
|
82932
|
+
});
|
|
82933
|
+
await this.store.delete.mutate({
|
|
82934
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
82935
|
+
key: id
|
|
82936
|
+
});
|
|
82937
|
+
this.logger.info("retrain collection deleted", { meta: {
|
|
82938
|
+
collectionId: id,
|
|
82939
|
+
members: members.length
|
|
82940
|
+
} });
|
|
82941
|
+
return {
|
|
82942
|
+
deleted: true,
|
|
82943
|
+
memberFrameIds: members.map((m) => String(m.data["frameId"] ?? ""))
|
|
82944
|
+
};
|
|
82945
|
+
}
|
|
82946
|
+
/** Add a frame. `false` when it was already a member (nothing written). */
|
|
82947
|
+
async addFrame(input) {
|
|
82948
|
+
const key = membershipKey(input.collectionId, input.frameId);
|
|
82949
|
+
const existing = await this.store.get.query({
|
|
82950
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82951
|
+
key
|
|
82952
|
+
});
|
|
82953
|
+
if (existing !== null && existing !== void 0) return false;
|
|
82954
|
+
await this.store.set.mutate({
|
|
82955
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82956
|
+
key,
|
|
82957
|
+
value: {
|
|
82958
|
+
collectionId: input.collectionId,
|
|
82959
|
+
frameId: input.frameId,
|
|
82960
|
+
deviceId: input.deviceId,
|
|
82961
|
+
addedAt: this.now()
|
|
82962
|
+
}
|
|
82963
|
+
});
|
|
82964
|
+
return true;
|
|
82965
|
+
}
|
|
82966
|
+
/** Remove one membership. `false` when it did not exist. */
|
|
82967
|
+
async removeFrame(input) {
|
|
82968
|
+
const key = membershipKey(input.collectionId, input.frameId);
|
|
82969
|
+
const existing = await this.store.get.query({
|
|
82970
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82971
|
+
key
|
|
82972
|
+
});
|
|
82973
|
+
if (existing === null || existing === void 0) return false;
|
|
82974
|
+
await this.store.delete.mutate({
|
|
82975
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82976
|
+
key
|
|
82977
|
+
});
|
|
82978
|
+
return true;
|
|
82979
|
+
}
|
|
82980
|
+
/** Remove a frame from EVERY collection — the frame itself is leaving. */
|
|
82981
|
+
async forgetFrame(frameId) {
|
|
82982
|
+
const rows = await this.membershipRows({ frameId });
|
|
82983
|
+
for (const r of rows) await this.store.delete.mutate({
|
|
82984
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
82985
|
+
key: r.id
|
|
82986
|
+
});
|
|
82987
|
+
return rows.length;
|
|
82988
|
+
}
|
|
82989
|
+
/** The frames of a collection, in the order they were added. */
|
|
82990
|
+
async listFrameIds(collectionId) {
|
|
82991
|
+
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 !== "");
|
|
82992
|
+
}
|
|
82993
|
+
/** frameId → the collections it belongs to. Frames in none are absent. */
|
|
82994
|
+
async collectionsOf(frameIds) {
|
|
82995
|
+
const out = /* @__PURE__ */ new Map();
|
|
82996
|
+
for (const row of await this.membershipRowsIn("frameId", frameIds)) {
|
|
82997
|
+
const frameId = String(row.data["frameId"] ?? "");
|
|
82998
|
+
const collectionId = String(row.data["collectionId"] ?? "");
|
|
82999
|
+
if (frameId === "" || collectionId === "") continue;
|
|
83000
|
+
out.set(frameId, [...out.get(frameId) ?? [], collectionId]);
|
|
82369
83001
|
}
|
|
83002
|
+
for (const [frameId, ids] of out) out.set(frameId, ids.toSorted());
|
|
82370
83003
|
return out;
|
|
82371
83004
|
}
|
|
82372
|
-
|
|
83005
|
+
/** The frame ids of several collections, de-duplicated. */
|
|
83006
|
+
async frameIdsOf(collectionIds) {
|
|
83007
|
+
const ids = /* @__PURE__ */ new Set();
|
|
83008
|
+
for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
|
|
83009
|
+
const frameId = String(row.data["frameId"] ?? "");
|
|
83010
|
+
if (frameId !== "") ids.add(frameId);
|
|
83011
|
+
}
|
|
83012
|
+
return [...ids];
|
|
83013
|
+
}
|
|
83014
|
+
toCollection(id, data, frameCount) {
|
|
83015
|
+
return {
|
|
83016
|
+
id,
|
|
83017
|
+
name: typeof data["name"] === "string" ? data["name"] : "",
|
|
83018
|
+
createdAt: Number(data["createdAt"] ?? 0),
|
|
83019
|
+
frameCount
|
|
83020
|
+
};
|
|
83021
|
+
}
|
|
83022
|
+
async readRow(id) {
|
|
82373
83023
|
try {
|
|
82374
83024
|
const row = await this.store.get.query({
|
|
82375
83025
|
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
82376
83026
|
key: id
|
|
82377
83027
|
});
|
|
82378
|
-
|
|
82379
|
-
const data = row;
|
|
82380
|
-
return {
|
|
82381
|
-
id,
|
|
82382
|
-
name: typeof data["name"] === "string" ? data["name"] : "",
|
|
82383
|
-
createdAt: Number(data["createdAt"] ?? 0),
|
|
82384
|
-
frameCount: this.countFrames !== void 0 ? await this.countFrames(id) : 0
|
|
82385
|
-
};
|
|
83028
|
+
return row === null || row === void 0 ? null : { ...row };
|
|
82386
83029
|
} catch (err) {
|
|
82387
83030
|
this.logger.warn("retrain collection get failed", { meta: {
|
|
82388
83031
|
collectionId: id,
|
|
@@ -82391,14 +83034,125 @@ var RetrainCollectionStore = class {
|
|
|
82391
83034
|
return null;
|
|
82392
83035
|
}
|
|
82393
83036
|
}
|
|
83037
|
+
async countMembers(collectionIds) {
|
|
83038
|
+
const counts = /* @__PURE__ */ new Map();
|
|
83039
|
+
for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
|
|
83040
|
+
const id = String(row.data["collectionId"] ?? "");
|
|
83041
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
83042
|
+
}
|
|
83043
|
+
return counts;
|
|
83044
|
+
}
|
|
83045
|
+
async membershipRows(where) {
|
|
83046
|
+
try {
|
|
83047
|
+
return await this.store.query.query({
|
|
83048
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83049
|
+
filter: { where }
|
|
83050
|
+
});
|
|
83051
|
+
} catch (err) {
|
|
83052
|
+
this.logger.warn("retrain collection membership read failed", { meta: {
|
|
83053
|
+
...where,
|
|
83054
|
+
error: String(err)
|
|
83055
|
+
} });
|
|
83056
|
+
return [];
|
|
83057
|
+
}
|
|
83058
|
+
}
|
|
83059
|
+
async membershipRowsIn(field, values) {
|
|
83060
|
+
const out = [];
|
|
83061
|
+
for (let i = 0; i < values.length; i += IN_PAGE) {
|
|
83062
|
+
const page = values.slice(i, i + IN_PAGE);
|
|
83063
|
+
try {
|
|
83064
|
+
out.push(...await this.store.query.query({
|
|
83065
|
+
collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
|
|
83066
|
+
filter: { whereIn: { [field]: [...page] } }
|
|
83067
|
+
}));
|
|
83068
|
+
} catch (err) {
|
|
83069
|
+
this.logger.warn("retrain collection membership read failed", { meta: {
|
|
83070
|
+
field,
|
|
83071
|
+
values: page.length,
|
|
83072
|
+
error: String(err)
|
|
83073
|
+
} });
|
|
83074
|
+
}
|
|
83075
|
+
}
|
|
83076
|
+
return out;
|
|
83077
|
+
}
|
|
83078
|
+
async findIdByNameKey(nameKey) {
|
|
83079
|
+
try {
|
|
83080
|
+
return (await this.store.query.query({
|
|
83081
|
+
collection: RETRAIN_COLLECTIONS_COLLECTION,
|
|
83082
|
+
filter: {
|
|
83083
|
+
where: { nameKey },
|
|
83084
|
+
limit: 1
|
|
83085
|
+
}
|
|
83086
|
+
}))[0]?.id ?? null;
|
|
83087
|
+
} catch (err) {
|
|
83088
|
+
this.logger.warn("retrain collection findByNameKey failed", { meta: {
|
|
83089
|
+
nameKey,
|
|
83090
|
+
error: String(err)
|
|
83091
|
+
} });
|
|
83092
|
+
return null;
|
|
83093
|
+
}
|
|
83094
|
+
}
|
|
83095
|
+
};
|
|
83096
|
+
//#endregion
|
|
83097
|
+
//#region src/pipeline-analytics/retrain/retrain-collection-service.ts
|
|
83098
|
+
/** What each still outcome tells the operator. */
|
|
83099
|
+
var STILL_REFUSAL = {
|
|
83100
|
+
"no-recording": {
|
|
83101
|
+
reason: "still-unavailable",
|
|
83102
|
+
detail: "this camera is not recording, so there is no footage to take a frame from"
|
|
83103
|
+
},
|
|
83104
|
+
"no-footage": {
|
|
83105
|
+
reason: "still-unavailable",
|
|
83106
|
+
detail: "no recorded footage covers that instant (a gap, or already pruned)"
|
|
83107
|
+
},
|
|
83108
|
+
"decode-failed": {
|
|
83109
|
+
reason: "still-unavailable",
|
|
83110
|
+
detail: "the footage is there but the frame could not be decoded"
|
|
83111
|
+
},
|
|
83112
|
+
busy: {
|
|
83113
|
+
reason: "still-busy",
|
|
83114
|
+
detail: "the recorder is already decoding other frames — try again in a moment"
|
|
83115
|
+
}
|
|
83116
|
+
};
|
|
83117
|
+
var RetrainCollectionService = class {
|
|
83118
|
+
deps;
|
|
83119
|
+
constructor(deps) {
|
|
83120
|
+
this.deps = deps;
|
|
83121
|
+
}
|
|
83122
|
+
create(input) {
|
|
83123
|
+
return this.deps.collections.create(input);
|
|
83124
|
+
}
|
|
83125
|
+
list() {
|
|
83126
|
+
return this.deps.collections.list();
|
|
83127
|
+
}
|
|
83128
|
+
rename(input) {
|
|
83129
|
+
return this.deps.collections.rename(input);
|
|
83130
|
+
}
|
|
82394
83131
|
/**
|
|
82395
|
-
*
|
|
82396
|
-
*
|
|
83132
|
+
* Delete a collection. Its recording stills that no other collection holds
|
|
83133
|
+
* go with it (annotations first); track copies always stay.
|
|
82397
83134
|
*/
|
|
82398
|
-
async
|
|
82399
|
-
|
|
82400
|
-
|
|
82401
|
-
|
|
83135
|
+
async delete(input) {
|
|
83136
|
+
const result = await this.deps.collections.delete(input.id);
|
|
83137
|
+
if (!result.deleted) return {
|
|
83138
|
+
deleted: false,
|
|
83139
|
+
removedFrames: 0
|
|
83140
|
+
};
|
|
83141
|
+
return {
|
|
83142
|
+
deleted: true,
|
|
83143
|
+
removedFrames: await this.dropOrphanStills(result.memberFrameIds)
|
|
83144
|
+
};
|
|
83145
|
+
}
|
|
83146
|
+
/**
|
|
83147
|
+
* Decode the frame at `epochMs` from the camera's recording and add it to
|
|
83148
|
+
* the collection. The moment already in the dataset is reused without a
|
|
83149
|
+
* decode; every refusal is named and writes nothing.
|
|
83150
|
+
*/
|
|
83151
|
+
async addFrameFromRecording(input) {
|
|
83152
|
+
const tags = { deviceId: input.deviceId };
|
|
83153
|
+
if (await this.deps.collections.getById(input.collectionId) === null) {
|
|
83154
|
+
this.deps.logger.info("retrain collection add refused: unknown collection", {
|
|
83155
|
+
tags,
|
|
82402
83156
|
meta: {
|
|
82403
83157
|
collectionId: input.collectionId,
|
|
82404
83158
|
epochMs: input.epochMs
|
|
@@ -82409,45 +83163,836 @@ var RetrainCollectionStore = class {
|
|
|
82409
83163
|
reason: "unknown-collection"
|
|
82410
83164
|
};
|
|
82411
83165
|
}
|
|
82412
|
-
|
|
82413
|
-
|
|
83166
|
+
const stored = await this.storeStill(input.deviceId, input.epochMs);
|
|
83167
|
+
if (stored.kind === "refused") {
|
|
83168
|
+
this.deps.logger.info("retrain collection add refused", {
|
|
83169
|
+
tags,
|
|
83170
|
+
meta: {
|
|
83171
|
+
collectionId: input.collectionId,
|
|
83172
|
+
epochMs: input.epochMs,
|
|
83173
|
+
reason: stored.reason
|
|
83174
|
+
}
|
|
83175
|
+
});
|
|
83176
|
+
return stored;
|
|
83177
|
+
}
|
|
83178
|
+
await this.deps.collections.addFrame({
|
|
83179
|
+
collectionId: input.collectionId,
|
|
83180
|
+
frameId: stored.frame.frameId,
|
|
83181
|
+
deviceId: input.deviceId
|
|
83182
|
+
});
|
|
83183
|
+
this.deps.logger.info("retrain frame added to a collection from the recording", {
|
|
83184
|
+
tags,
|
|
82414
83185
|
meta: {
|
|
82415
83186
|
collectionId: input.collectionId,
|
|
82416
|
-
|
|
83187
|
+
frameId: stored.frame.frameId,
|
|
83188
|
+
epochMs: input.epochMs,
|
|
83189
|
+
reused: stored.reused
|
|
82417
83190
|
}
|
|
82418
83191
|
});
|
|
83192
|
+
const [wire] = await this.deps.retrain.toWireFrames([stored.frame]);
|
|
83193
|
+
if (wire === void 0) throw new Error("retrain: a stored still produced no wire frame");
|
|
82419
83194
|
return {
|
|
83195
|
+
kind: "added",
|
|
83196
|
+
collectionId: input.collectionId,
|
|
83197
|
+
frame: wire,
|
|
83198
|
+
reused: stored.reused
|
|
83199
|
+
};
|
|
83200
|
+
}
|
|
83201
|
+
/** Add frames already in the dataset (track copies or stills) to a collection. */
|
|
83202
|
+
async addFrames(input) {
|
|
83203
|
+
if (await this.deps.collections.getById(input.collectionId) === null) return {
|
|
83204
|
+
added: 0,
|
|
83205
|
+
alreadyMember: 0,
|
|
83206
|
+
unknownFrameIds: [...input.frameIds]
|
|
83207
|
+
};
|
|
83208
|
+
const frames = await this.deps.frames.getMany(input.frameIds);
|
|
83209
|
+
const known = new Set(frames.map((f) => f.frameId));
|
|
83210
|
+
let added = 0;
|
|
83211
|
+
let alreadyMember = 0;
|
|
83212
|
+
for (const frame of frames) if (await this.deps.collections.addFrame({
|
|
83213
|
+
collectionId: input.collectionId,
|
|
83214
|
+
frameId: frame.frameId,
|
|
83215
|
+
deviceId: frame.deviceId
|
|
83216
|
+
})) added += 1;
|
|
83217
|
+
else alreadyMember += 1;
|
|
83218
|
+
return {
|
|
83219
|
+
added,
|
|
83220
|
+
alreadyMember,
|
|
83221
|
+
unknownFrameIds: input.frameIds.filter((id) => !known.has(id))
|
|
83222
|
+
};
|
|
83223
|
+
}
|
|
83224
|
+
/**
|
|
83225
|
+
* Remove a frame from a collection. A recording still left in no collection
|
|
83226
|
+
* is deleted with its annotations; a track copy is never deleted here.
|
|
83227
|
+
*/
|
|
83228
|
+
async removeFrame(input) {
|
|
83229
|
+
if (!await this.deps.collections.removeFrame(input)) return {
|
|
83230
|
+
removed: false,
|
|
83231
|
+
deletedFrame: false
|
|
83232
|
+
};
|
|
83233
|
+
return {
|
|
83234
|
+
removed: true,
|
|
83235
|
+
deletedFrame: await this.dropOrphanStills([input.frameId]) > 0
|
|
83236
|
+
};
|
|
83237
|
+
}
|
|
83238
|
+
/** The frames of a collection, as the canvas lists them. */
|
|
83239
|
+
async listFrames(input) {
|
|
83240
|
+
const ids = await this.deps.collections.listFrameIds(input.collectionId);
|
|
83241
|
+
return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
|
|
83242
|
+
}
|
|
83243
|
+
async storeStill(deviceId, epochMs) {
|
|
83244
|
+
const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
|
|
83245
|
+
if (existing !== null) return {
|
|
83246
|
+
kind: "stored",
|
|
83247
|
+
frame: existing,
|
|
83248
|
+
reused: true
|
|
83249
|
+
};
|
|
83250
|
+
const still = await this.deps.captureStill(deviceId, epochMs);
|
|
83251
|
+
if (still.outcome !== "ok" || still.jpeg === void 0) {
|
|
83252
|
+
const refusal = still.outcome === "ok" ? STILL_REFUSAL["decode-failed"] : STILL_REFUSAL[still.outcome];
|
|
83253
|
+
return {
|
|
83254
|
+
kind: "refused",
|
|
83255
|
+
reason: refusal.reason,
|
|
83256
|
+
detail: refusal.detail
|
|
83257
|
+
};
|
|
83258
|
+
}
|
|
83259
|
+
const stored = await this.deps.frames.storeRecordingStill({
|
|
83260
|
+
deviceId,
|
|
83261
|
+
epochMs,
|
|
83262
|
+
bytes: still.jpeg
|
|
83263
|
+
});
|
|
83264
|
+
return stored.kind === "copied" ? {
|
|
83265
|
+
kind: "stored",
|
|
83266
|
+
frame: stored.frame,
|
|
83267
|
+
reused: stored.reused
|
|
83268
|
+
} : {
|
|
82420
83269
|
kind: "refused",
|
|
82421
|
-
reason:
|
|
82422
|
-
detail: "Still ingest lands in plan Task 3"
|
|
83270
|
+
reason: stored.reason
|
|
82423
83271
|
};
|
|
82424
83272
|
}
|
|
82425
|
-
|
|
82426
|
-
|
|
82427
|
-
|
|
82428
|
-
|
|
82429
|
-
|
|
82430
|
-
|
|
82431
|
-
|
|
83273
|
+
/** Delete the recording stills among `frameIds` that no collection holds any more. */
|
|
83274
|
+
async dropOrphanStills(frameIds) {
|
|
83275
|
+
if (frameIds.length === 0) return 0;
|
|
83276
|
+
const memberships = await this.deps.collections.collectionsOf(frameIds);
|
|
83277
|
+
const orphans = (await this.deps.frames.getMany(frameIds)).filter((f) => f.origin === "recording" && (memberships.get(f.frameId)?.length ?? 0) === 0);
|
|
83278
|
+
for (const frame of orphans) await this.deps.retrain.removeFrame(frame);
|
|
83279
|
+
return orphans.length;
|
|
83280
|
+
}
|
|
83281
|
+
};
|
|
83282
|
+
//#endregion
|
|
83283
|
+
//#region src/pipeline-analytics/retrain/retrain-llm-assist.ts
|
|
83284
|
+
var ALWAYS_LOCAL_KINDS = new Set(["managed-local", "apple-foundation"]);
|
|
83285
|
+
var ALWAYS_CLOUD_KINDS = new Set([
|
|
83286
|
+
"openai",
|
|
83287
|
+
"anthropic",
|
|
83288
|
+
"google"
|
|
83289
|
+
]);
|
|
83290
|
+
function isPrivateIpv4(host) {
|
|
83291
|
+
const parts = host.split(".").map((p) => Number.parseInt(p, 10));
|
|
83292
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
|
|
83293
|
+
const [a, b] = parts;
|
|
83294
|
+
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;
|
|
83295
|
+
}
|
|
83296
|
+
function isLocalHost(rawHost) {
|
|
83297
|
+
const host = rawHost.replace(/^\[|\]$/g, "").toLowerCase();
|
|
83298
|
+
if (host.includes(":")) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
|
|
83299
|
+
if (host === "localhost" || host.endsWith(".local") || host.endsWith(".lan")) return true;
|
|
83300
|
+
if (isPrivateIpv4(host)) return true;
|
|
83301
|
+
return !host.includes(".") && !host.includes(":");
|
|
83302
|
+
}
|
|
83303
|
+
/** Does sending a frame to this profile send it outside the house? */
|
|
83304
|
+
function llmProfileLeavesHouse(profile) {
|
|
83305
|
+
if (ALWAYS_LOCAL_KINDS.has(profile.kind)) return false;
|
|
83306
|
+
if (ALWAYS_CLOUD_KINDS.has(profile.kind)) return true;
|
|
83307
|
+
if (profile.baseUrl === void 0 || profile.baseUrl === "") return true;
|
|
83308
|
+
try {
|
|
83309
|
+
return !isLocalHost(new URL(profile.baseUrl).hostname);
|
|
83310
|
+
} catch {
|
|
83311
|
+
return true;
|
|
83312
|
+
}
|
|
83313
|
+
}
|
|
83314
|
+
/** At most this many boxes are sent for review — a prompt, not a dump. */
|
|
83315
|
+
var LLM_REVIEW_MAX_BOXES = 50;
|
|
83316
|
+
var VERDICTS = [
|
|
83317
|
+
"confirmed",
|
|
83318
|
+
"wrong-class",
|
|
83319
|
+
"not-an-object",
|
|
83320
|
+
"uncertain"
|
|
83321
|
+
];
|
|
83322
|
+
var SYSTEM_PROMPT = [
|
|
83323
|
+
"You annotate frames from home security cameras to build an object-detection training set.",
|
|
83324
|
+
"Be precise and conservative: only report what is clearly visible.",
|
|
83325
|
+
"Coordinates are normalised to the full frame: x and y are the top-left corner,",
|
|
83326
|
+
"w and h the width and height, all between 0 and 1.",
|
|
83327
|
+
"Answer with JSON only, exactly in the requested schema, with no other text."
|
|
83328
|
+
].join(" ");
|
|
83329
|
+
/** The JSON schema asked of the model (and enforced again by the parser). */
|
|
83330
|
+
function answerJsonSchema(classes) {
|
|
83331
|
+
return {
|
|
83332
|
+
type: "object",
|
|
83333
|
+
additionalProperties: false,
|
|
83334
|
+
required: ["objects", "reviews"],
|
|
83335
|
+
properties: {
|
|
83336
|
+
objects: {
|
|
83337
|
+
type: "array",
|
|
83338
|
+
items: {
|
|
83339
|
+
type: "object",
|
|
83340
|
+
additionalProperties: false,
|
|
83341
|
+
required: [
|
|
83342
|
+
"class",
|
|
83343
|
+
"box",
|
|
83344
|
+
"confidence",
|
|
83345
|
+
"reason"
|
|
83346
|
+
],
|
|
83347
|
+
properties: {
|
|
83348
|
+
class: {
|
|
83349
|
+
type: "string",
|
|
83350
|
+
enum: [...classes]
|
|
83351
|
+
},
|
|
83352
|
+
label: { type: "string" },
|
|
83353
|
+
box: {
|
|
83354
|
+
type: "array",
|
|
83355
|
+
items: { type: "number" },
|
|
83356
|
+
minItems: 4,
|
|
83357
|
+
maxItems: 4
|
|
83358
|
+
},
|
|
83359
|
+
confidence: {
|
|
83360
|
+
type: "number",
|
|
83361
|
+
minimum: 0,
|
|
83362
|
+
maximum: 1
|
|
83363
|
+
},
|
|
83364
|
+
reason: { type: "string" }
|
|
83365
|
+
}
|
|
82432
83366
|
}
|
|
82433
|
-
}
|
|
82434
|
-
|
|
83367
|
+
},
|
|
83368
|
+
reviews: {
|
|
83369
|
+
type: "array",
|
|
83370
|
+
items: {
|
|
83371
|
+
type: "object",
|
|
83372
|
+
additionalProperties: false,
|
|
83373
|
+
required: [
|
|
83374
|
+
"ref",
|
|
83375
|
+
"verdict",
|
|
83376
|
+
"reason"
|
|
83377
|
+
],
|
|
83378
|
+
properties: {
|
|
83379
|
+
ref: { type: "string" },
|
|
83380
|
+
verdict: {
|
|
83381
|
+
type: "string",
|
|
83382
|
+
enum: [...VERDICTS]
|
|
83383
|
+
},
|
|
83384
|
+
suggestedClass: { type: "string" },
|
|
83385
|
+
reason: { type: "string" }
|
|
83386
|
+
}
|
|
83387
|
+
}
|
|
83388
|
+
}
|
|
83389
|
+
}
|
|
83390
|
+
};
|
|
83391
|
+
}
|
|
83392
|
+
var round = (n) => Math.round(n * 1e3) / 1e3;
|
|
83393
|
+
/** Build the request: target classes, the frame size, the boxes to review. */
|
|
83394
|
+
function buildLlmAssistRequest(input) {
|
|
83395
|
+
const boxes = input.boxes.slice(0, LLM_REVIEW_MAX_BOXES).map((b) => JSON.stringify({
|
|
83396
|
+
ref: b.ref,
|
|
83397
|
+
class: b.macroClass,
|
|
83398
|
+
...b.label !== void 0 ? { label: b.label } : {},
|
|
83399
|
+
box: [
|
|
83400
|
+
round(b.bbox.x),
|
|
83401
|
+
round(b.bbox.y),
|
|
83402
|
+
round(b.bbox.w),
|
|
83403
|
+
round(b.bbox.h)
|
|
83404
|
+
]
|
|
83405
|
+
}));
|
|
83406
|
+
return {
|
|
83407
|
+
system: SYSTEM_PROMPT,
|
|
83408
|
+
prompt: [
|
|
83409
|
+
`The image is one camera frame, ${String(input.width)}×${String(input.height)} pixels.`,
|
|
83410
|
+
`1. List every object of these classes you can see: ${input.classes.join(", ")}.`,
|
|
83411
|
+
" For each give class, an optional free-text label (e.g. \"van\", \"postman\"),",
|
|
83412
|
+
" box [x, y, w, h] normalised to the frame, confidence 0–1, and a short reason.",
|
|
83413
|
+
" Include small and partly hidden ones; do not include anything else.",
|
|
83414
|
+
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\": [].",
|
|
83415
|
+
"Answer with JSON: {\"objects\": [...], \"reviews\": [...]}."
|
|
83416
|
+
].join("\n"),
|
|
83417
|
+
jsonSchema: answerJsonSchema(input.classes)
|
|
83418
|
+
};
|
|
83419
|
+
}
|
|
83420
|
+
/** The whole answer: two arrays and nothing else. Items are checked one by one. */
|
|
83421
|
+
var AnswerSchema = strictObject({
|
|
83422
|
+
objects: array(unknown()),
|
|
83423
|
+
reviews: array(unknown())
|
|
83424
|
+
});
|
|
83425
|
+
var ObjectSchema = strictObject({
|
|
83426
|
+
class: string(),
|
|
83427
|
+
label: string().optional(),
|
|
83428
|
+
box: tuple([
|
|
83429
|
+
number(),
|
|
83430
|
+
number(),
|
|
83431
|
+
number(),
|
|
83432
|
+
number()
|
|
83433
|
+
]),
|
|
83434
|
+
confidence: number().min(0).max(1),
|
|
83435
|
+
reason: string().max(500)
|
|
83436
|
+
});
|
|
83437
|
+
var ReviewSchema = strictObject({
|
|
83438
|
+
ref: string(),
|
|
83439
|
+
verdict: _enum(VERDICTS),
|
|
83440
|
+
suggestedClass: string().optional(),
|
|
83441
|
+
reason: string().max(500)
|
|
83442
|
+
});
|
|
83443
|
+
function frameBox(box) {
|
|
83444
|
+
const [x, y, w, h] = box;
|
|
83445
|
+
if (![
|
|
83446
|
+
x,
|
|
83447
|
+
y,
|
|
83448
|
+
w,
|
|
83449
|
+
h
|
|
83450
|
+
].every(Number.isFinite)) return null;
|
|
83451
|
+
if (!(w > 0) || !(h > 0) || x < -.01 || y < -.01) return null;
|
|
83452
|
+
if (x + w > 1.01 || y + h > 1.01) return null;
|
|
83453
|
+
const cx = Math.max(0, x);
|
|
83454
|
+
const cy = Math.max(0, y);
|
|
83455
|
+
return {
|
|
83456
|
+
x: cx,
|
|
83457
|
+
y: cy,
|
|
83458
|
+
w: Math.min(w, 1 - cx),
|
|
83459
|
+
h: Math.min(h, 1 - cy)
|
|
83460
|
+
};
|
|
83461
|
+
}
|
|
83462
|
+
/** Strip a Markdown code fence a model wrapped its JSON in. */
|
|
83463
|
+
function unfence(text) {
|
|
83464
|
+
const trimmed = text.trim();
|
|
83465
|
+
const fence = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
|
|
83466
|
+
return fence?.[1] !== void 0 ? fence[1].trim() : trimmed;
|
|
83467
|
+
}
|
|
83468
|
+
/** A free-text label, never a macro word (the tier rule). */
|
|
83469
|
+
function usableLabel(label) {
|
|
83470
|
+
const trimmed = label?.trim();
|
|
83471
|
+
if (trimmed === void 0 || trimmed === "") return void 0;
|
|
83472
|
+
return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
|
|
83473
|
+
}
|
|
83474
|
+
/** Parse the model's answer. See the module header for the rules. */
|
|
83475
|
+
function parseLlmAssistAnswer(text, expected) {
|
|
83476
|
+
let raw;
|
|
83477
|
+
try {
|
|
83478
|
+
raw = JSON.parse(unfence(text));
|
|
83479
|
+
} catch {
|
|
83480
|
+
return {
|
|
83481
|
+
kind: "malformed",
|
|
83482
|
+
detail: "the answer is not JSON"
|
|
83483
|
+
};
|
|
83484
|
+
}
|
|
83485
|
+
const answer = AnswerSchema.safeParse(raw);
|
|
83486
|
+
if (!answer.success) return {
|
|
83487
|
+
kind: "malformed",
|
|
83488
|
+
detail: "the answer is not {\"objects\": [...], \"reviews\": [...]}"
|
|
83489
|
+
};
|
|
83490
|
+
const asked = new Set(expected.classes);
|
|
83491
|
+
let rejected = 0;
|
|
83492
|
+
const objects = [];
|
|
83493
|
+
for (const item of answer.data.objects) {
|
|
83494
|
+
const parsed = ObjectSchema.safeParse(item);
|
|
83495
|
+
const cls = parsed.success ? parsed.data.class.trim().toLowerCase() : "";
|
|
83496
|
+
const bbox = parsed.success ? frameBox(parsed.data.box) : null;
|
|
83497
|
+
if (!parsed.success || bbox === null || !asked.has(cls) || !isRetrainMacroClass(cls)) {
|
|
83498
|
+
rejected += 1;
|
|
83499
|
+
continue;
|
|
83500
|
+
}
|
|
83501
|
+
const label = usableLabel(parsed.data.label);
|
|
83502
|
+
objects.push({
|
|
83503
|
+
macroClass: cls,
|
|
83504
|
+
...label !== void 0 ? { label } : {},
|
|
83505
|
+
bbox,
|
|
83506
|
+
confidence: parsed.data.confidence,
|
|
83507
|
+
reason: parsed.data.reason
|
|
83508
|
+
});
|
|
83509
|
+
}
|
|
83510
|
+
const reviews = [];
|
|
83511
|
+
for (const item of answer.data.reviews) {
|
|
83512
|
+
const parsed = ReviewSchema.safeParse(item);
|
|
83513
|
+
if (!parsed.success || !expected.refs.has(parsed.data.ref)) {
|
|
83514
|
+
rejected += 1;
|
|
83515
|
+
continue;
|
|
83516
|
+
}
|
|
83517
|
+
const suggested = parsed.data.suggestedClass?.trim().toLowerCase();
|
|
83518
|
+
reviews.push({
|
|
83519
|
+
ref: parsed.data.ref,
|
|
83520
|
+
verdict: parsed.data.verdict,
|
|
83521
|
+
...suggested !== void 0 && isRetrainMacroClass(suggested) ? { suggestedClass: suggested } : {},
|
|
83522
|
+
reason: parsed.data.reason
|
|
83523
|
+
});
|
|
83524
|
+
}
|
|
83525
|
+
return {
|
|
83526
|
+
kind: "parsed",
|
|
83527
|
+
objects,
|
|
83528
|
+
reviews,
|
|
83529
|
+
rejected
|
|
83530
|
+
};
|
|
83531
|
+
}
|
|
83532
|
+
//#endregion
|
|
83533
|
+
//#region src/pipeline-analytics/retrain/retrain-llm-service.ts
|
|
83534
|
+
/** The usage tag every AI-assist call is counted under (`llm.getUsage`). */
|
|
83535
|
+
var RETRAIN_LLM_CONSUMER = "retrain-assist";
|
|
83536
|
+
/** One frame, one answer: a cold local vision model can take a minute. */
|
|
83537
|
+
var RETRAIN_LLM_TIMEOUT_MS = 15e4;
|
|
83538
|
+
var RetrainLlmAssistService = class {
|
|
83539
|
+
deps;
|
|
83540
|
+
constructor(deps) {
|
|
83541
|
+
this.deps = deps;
|
|
83542
|
+
}
|
|
83543
|
+
/** The enabled, vision-capable profiles — a text-only one would be refused. */
|
|
83544
|
+
async listProfiles() {
|
|
83545
|
+
return (await this.deps.llm.listProfiles()).filter((p) => p.enabled && p.supportsVision).map((p) => ({
|
|
83546
|
+
id: p.id,
|
|
83547
|
+
name: p.name,
|
|
83548
|
+
model: p.model,
|
|
83549
|
+
kind: p.kind,
|
|
83550
|
+
leavesHouse: llmProfileLeavesHouse(p)
|
|
83551
|
+
}));
|
|
83552
|
+
}
|
|
83553
|
+
async propose(input) {
|
|
83554
|
+
const tags = { deviceId: input.deviceId };
|
|
83555
|
+
const frame = await this.deps.frames.get(input.frameId);
|
|
83556
|
+
if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
|
|
83557
|
+
kind: "refused",
|
|
83558
|
+
reason: "unknown-frame"
|
|
83559
|
+
};
|
|
83560
|
+
const profile = (await this.deps.llm.listProfiles()).find((p) => p.id === input.profileId);
|
|
83561
|
+
if (profile === void 0) return {
|
|
83562
|
+
kind: "refused",
|
|
83563
|
+
reason: "unknown-profile"
|
|
83564
|
+
};
|
|
83565
|
+
if (!profile.enabled) return {
|
|
83566
|
+
kind: "refused",
|
|
83567
|
+
reason: "profile-disabled"
|
|
83568
|
+
};
|
|
83569
|
+
if (!profile.supportsVision) return {
|
|
83570
|
+
kind: "refused",
|
|
83571
|
+
reason: "no-vision"
|
|
83572
|
+
};
|
|
83573
|
+
const leavesHouse = llmProfileLeavesHouse(profile);
|
|
83574
|
+
if (leavesHouse && !input.allowCloud) {
|
|
83575
|
+
this.deps.logger.warn("retrain AI assist refused: a cloud profile without confirmation", {
|
|
83576
|
+
tags,
|
|
83577
|
+
meta: {
|
|
83578
|
+
frameId: input.frameId,
|
|
83579
|
+
profileId: profile.id
|
|
83580
|
+
}
|
|
83581
|
+
});
|
|
82435
83582
|
return {
|
|
82436
|
-
|
|
82437
|
-
|
|
82438
|
-
|
|
82439
|
-
frameCount: 0
|
|
83583
|
+
kind: "refused",
|
|
83584
|
+
reason: "cloud-not-confirmed",
|
|
83585
|
+
detail: "this profile sends the frame outside the house; confirm the run first"
|
|
82440
83586
|
};
|
|
82441
|
-
} catch (err) {
|
|
82442
|
-
this.logger.warn("retrain collection findByNameKey failed", { meta: {
|
|
82443
|
-
nameKey,
|
|
82444
|
-
error: String(err)
|
|
82445
|
-
} });
|
|
82446
|
-
return null;
|
|
82447
83587
|
}
|
|
83588
|
+
const bytes = await this.deps.frames.readFrame(frame);
|
|
83589
|
+
if (bytes === null) return {
|
|
83590
|
+
kind: "refused",
|
|
83591
|
+
reason: "unreadable-frame"
|
|
83592
|
+
};
|
|
83593
|
+
const request = buildLlmAssistRequest({
|
|
83594
|
+
classes: input.classes,
|
|
83595
|
+
width: frame.width,
|
|
83596
|
+
height: frame.height,
|
|
83597
|
+
boxes: input.boxes
|
|
83598
|
+
});
|
|
83599
|
+
const answer = await this.deps.llm.generateVision({
|
|
83600
|
+
profileId: profile.id,
|
|
83601
|
+
system: request.system,
|
|
83602
|
+
prompt: request.prompt,
|
|
83603
|
+
jsonSchema: request.jsonSchema,
|
|
83604
|
+
image: bytes
|
|
83605
|
+
});
|
|
83606
|
+
if (!answer.ok) {
|
|
83607
|
+
this.deps.logger.warn("retrain AI assist: the model call failed", {
|
|
83608
|
+
tags,
|
|
83609
|
+
meta: {
|
|
83610
|
+
frameId: input.frameId,
|
|
83611
|
+
profileId: profile.id,
|
|
83612
|
+
code: answer.code
|
|
83613
|
+
}
|
|
83614
|
+
});
|
|
83615
|
+
return {
|
|
83616
|
+
kind: "refused",
|
|
83617
|
+
reason: "llm-error",
|
|
83618
|
+
detail: `${answer.code}: ${answer.message}`
|
|
83619
|
+
};
|
|
83620
|
+
}
|
|
83621
|
+
const parsed = parseLlmAssistAnswer(answer.text, {
|
|
83622
|
+
classes: input.classes,
|
|
83623
|
+
refs: new Set(input.boxes.map((b) => b.ref))
|
|
83624
|
+
});
|
|
83625
|
+
if (parsed.kind === "malformed") {
|
|
83626
|
+
this.deps.logger.warn("retrain AI assist: the answer was refused as malformed", {
|
|
83627
|
+
tags,
|
|
83628
|
+
meta: {
|
|
83629
|
+
frameId: input.frameId,
|
|
83630
|
+
profileId: profile.id,
|
|
83631
|
+
model: answer.model,
|
|
83632
|
+
detail: parsed.detail,
|
|
83633
|
+
answerChars: answer.text.length
|
|
83634
|
+
}
|
|
83635
|
+
});
|
|
83636
|
+
return {
|
|
83637
|
+
kind: "refused",
|
|
83638
|
+
reason: "malformed-answer",
|
|
83639
|
+
detail: parsed.detail
|
|
83640
|
+
};
|
|
83641
|
+
}
|
|
83642
|
+
this.deps.logger.info("retrain AI assist proposed boxes", {
|
|
83643
|
+
tags,
|
|
83644
|
+
meta: {
|
|
83645
|
+
frameId: input.frameId,
|
|
83646
|
+
profileId: profile.id,
|
|
83647
|
+
model: answer.model,
|
|
83648
|
+
leftHouse: leavesHouse,
|
|
83649
|
+
proposals: parsed.objects.length,
|
|
83650
|
+
reviews: parsed.reviews.length,
|
|
83651
|
+
rejected: parsed.rejected
|
|
83652
|
+
}
|
|
83653
|
+
});
|
|
83654
|
+
return {
|
|
83655
|
+
kind: "proposed",
|
|
83656
|
+
profileId: profile.id,
|
|
83657
|
+
model: answer.model,
|
|
83658
|
+
leftHouse: leavesHouse,
|
|
83659
|
+
proposals: parsed.objects.map((o) => ({
|
|
83660
|
+
bbox: o.bbox,
|
|
83661
|
+
macroClass: o.macroClass,
|
|
83662
|
+
...o.label !== void 0 ? { label: o.label } : {},
|
|
83663
|
+
kind: "subject",
|
|
83664
|
+
source: "llm",
|
|
83665
|
+
assistModelId: answer.model,
|
|
83666
|
+
assistScore: o.confidence,
|
|
83667
|
+
reason: o.reason
|
|
83668
|
+
})),
|
|
83669
|
+
reviews: parsed.reviews.map((r) => ({
|
|
83670
|
+
ref: r.ref,
|
|
83671
|
+
verdict: r.verdict,
|
|
83672
|
+
...r.suggestedClass !== void 0 ? { suggestedClass: r.suggestedClass } : {},
|
|
83673
|
+
reason: r.reason
|
|
83674
|
+
})),
|
|
83675
|
+
rejected: parsed.rejected
|
|
83676
|
+
};
|
|
82448
83677
|
}
|
|
82449
83678
|
};
|
|
82450
83679
|
//#endregion
|
|
83680
|
+
//#region src/pipeline-analytics/retrain/retrain-archive-upload.ts
|
|
83681
|
+
/**
|
|
83682
|
+
* The HTTP sink behind `pipelineAnalytics.writeRetrainArchive`: ONE request
|
|
83683
|
+
* whose body is the archive, written chunk by chunk with backpressure honoured
|
|
83684
|
+
* (D447) — the archive is never assembled in memory or on disk.
|
|
83685
|
+
*
|
|
83686
|
+
* The target is admin-supplied (the training orchestrator hands over a
|
|
83687
|
+
* provider's presigned URL or its upload route). Its headers may carry a
|
|
83688
|
+
* credential, so they are sent and never logged.
|
|
83689
|
+
*/
|
|
83690
|
+
function openRetrainUploadSink(target) {
|
|
83691
|
+
const url = new URL(target.url);
|
|
83692
|
+
const req = (url.protocol === "https:" ? request$1 : request)(url, {
|
|
83693
|
+
method: target.method,
|
|
83694
|
+
headers: {
|
|
83695
|
+
"content-type": "application/x-tar",
|
|
83696
|
+
...target.headers
|
|
83697
|
+
}
|
|
83698
|
+
});
|
|
83699
|
+
let failure = null;
|
|
83700
|
+
const answer = new Promise((resolve, reject) => {
|
|
83701
|
+
req.on("response", resolve);
|
|
83702
|
+
req.on("error", (err) => {
|
|
83703
|
+
failure = err;
|
|
83704
|
+
reject(err);
|
|
83705
|
+
});
|
|
83706
|
+
});
|
|
83707
|
+
answer.catch(() => void 0);
|
|
83708
|
+
return {
|
|
83709
|
+
sink: { write: (chunk) => new Promise((resolve, reject) => {
|
|
83710
|
+
if (failure !== null) {
|
|
83711
|
+
reject(failure);
|
|
83712
|
+
return;
|
|
83713
|
+
}
|
|
83714
|
+
if (req.write(chunk, (err) => {
|
|
83715
|
+
if (err) reject(err);
|
|
83716
|
+
})) resolve();
|
|
83717
|
+
else req.once("drain", resolve);
|
|
83718
|
+
}) },
|
|
83719
|
+
finish: async () => {
|
|
83720
|
+
req.end();
|
|
83721
|
+
const res = await answer;
|
|
83722
|
+
res.resume();
|
|
83723
|
+
const status = res.statusCode ?? 0;
|
|
83724
|
+
if (status < 200 || status >= 300) throw new Error(`retrain upload refused by the target: HTTP ${status}`);
|
|
83725
|
+
return status;
|
|
83726
|
+
},
|
|
83727
|
+
abort: (reason) => {
|
|
83728
|
+
req.destroy(new Error(reason));
|
|
83729
|
+
}
|
|
83730
|
+
};
|
|
83731
|
+
}
|
|
83732
|
+
//#endregion
|
|
83733
|
+
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
83734
|
+
/**
|
|
83735
|
+
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
83736
|
+
* entry, without ever holding the archive in memory.
|
|
83737
|
+
*
|
|
83738
|
+
* ## Why hand-rolled rather than the `tar` package
|
|
83739
|
+
*
|
|
83740
|
+
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
83741
|
+
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
83742
|
+
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
83743
|
+
* operator relocated to), under paths that encode nothing a training set wants —
|
|
83744
|
+
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
83745
|
+
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
83746
|
+
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
83747
|
+
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
83748
|
+
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
83749
|
+
*
|
|
83750
|
+
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
83751
|
+
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
83752
|
+
* door rather than against my reading of the format.
|
|
83753
|
+
*
|
|
83754
|
+
* ## What this deliberately does NOT do
|
|
83755
|
+
*
|
|
83756
|
+
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
83757
|
+
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
83758
|
+
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
83759
|
+
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
83760
|
+
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
83761
|
+
*/
|
|
83762
|
+
var BLOCK_SIZE = 512;
|
|
83763
|
+
var NAME_MAX = 100;
|
|
83764
|
+
var PREFIX_MAX = 155;
|
|
83765
|
+
/**
|
|
83766
|
+
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
83767
|
+
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
83768
|
+
*/
|
|
83769
|
+
function splitUstarName(name) {
|
|
83770
|
+
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
83771
|
+
prefix: "",
|
|
83772
|
+
name
|
|
83773
|
+
};
|
|
83774
|
+
for (let i = name.length - 1; i >= 0; i--) {
|
|
83775
|
+
if (name[i] !== "/") continue;
|
|
83776
|
+
const head = name.slice(0, i);
|
|
83777
|
+
const tail = name.slice(i + 1);
|
|
83778
|
+
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
83779
|
+
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
83780
|
+
prefix: head,
|
|
83781
|
+
name: tail
|
|
83782
|
+
};
|
|
83783
|
+
}
|
|
83784
|
+
return null;
|
|
83785
|
+
}
|
|
83786
|
+
/** True when a path cannot be represented as a ustar entry name. */
|
|
83787
|
+
function tarNameTooLong(name) {
|
|
83788
|
+
return splitUstarName(name) === null;
|
|
83789
|
+
}
|
|
83790
|
+
function writeString(block, value, offset, length) {
|
|
83791
|
+
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
83792
|
+
}
|
|
83793
|
+
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
83794
|
+
function writeOctal(block, value, offset, length) {
|
|
83795
|
+
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
83796
|
+
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
83797
|
+
}
|
|
83798
|
+
/**
|
|
83799
|
+
* The 512-byte header for one regular file.
|
|
83800
|
+
*
|
|
83801
|
+
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
83802
|
+
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
83803
|
+
* fine until something tries to read it.
|
|
83804
|
+
*/
|
|
83805
|
+
function tarHeader(entry) {
|
|
83806
|
+
const split = splitUstarName(entry.name);
|
|
83807
|
+
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
83808
|
+
const block = Buffer.alloc(BLOCK_SIZE);
|
|
83809
|
+
writeString(block, split.name, 0, NAME_MAX);
|
|
83810
|
+
writeOctal(block, 420, 100, 8);
|
|
83811
|
+
writeOctal(block, 0, 108, 8);
|
|
83812
|
+
writeOctal(block, 0, 116, 8);
|
|
83813
|
+
writeOctal(block, entry.body.length, 124, 12);
|
|
83814
|
+
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
83815
|
+
block.write(" ", 148, 8, "ascii");
|
|
83816
|
+
block.write("0", 156, 1, "ascii");
|
|
83817
|
+
block.write("ustar\0", 257, 6, "ascii");
|
|
83818
|
+
block.write("00", 263, 2, "ascii");
|
|
83819
|
+
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
83820
|
+
let sum = 0;
|
|
83821
|
+
for (const byte of block) sum += byte;
|
|
83822
|
+
writeOctal(block, sum, 148, 7);
|
|
83823
|
+
block.write("\0", 154, 1, "ascii");
|
|
83824
|
+
block.write(" ", 155, 1, "ascii");
|
|
83825
|
+
return block;
|
|
83826
|
+
}
|
|
83827
|
+
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
83828
|
+
function tarPadding(size) {
|
|
83829
|
+
const remainder = size % BLOCK_SIZE;
|
|
83830
|
+
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
83831
|
+
}
|
|
83832
|
+
/** Header + body + padding for one entry, ready to write. */
|
|
83833
|
+
function tarBlocks(entry) {
|
|
83834
|
+
return [
|
|
83835
|
+
tarHeader(entry),
|
|
83836
|
+
entry.body,
|
|
83837
|
+
tarPadding(entry.body.length)
|
|
83838
|
+
];
|
|
83839
|
+
}
|
|
83840
|
+
/** The two zero blocks that terminate an archive. */
|
|
83841
|
+
function tarTrailer() {
|
|
83842
|
+
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
83843
|
+
}
|
|
83844
|
+
//#endregion
|
|
83845
|
+
//#region src/pipeline-analytics/retrain/retrain-archive-writer.ts
|
|
83846
|
+
/**
|
|
83847
|
+
* The annotated archive body, written to ANY sink.
|
|
83848
|
+
*
|
|
83849
|
+
* One body, two consumers: the data-plane download (`retrain-export-handler`,
|
|
83850
|
+
* sink = the HTTP response) and `pipelineAnalytics.writeRetrainArchive` (sink
|
|
83851
|
+
* = one streamed HTTP upload to a training provider). The disciplines are the
|
|
83852
|
+
* training export's: the manifest is the first entry, one JPEG is in memory at
|
|
83853
|
+
* a time, a pause between files keeps the hub's one thread available, and the
|
|
83854
|
+
* batch id is stamped only on what the sink actually received.
|
|
83855
|
+
*/
|
|
83856
|
+
var RETRAIN_MANIFEST_ENTRY = "annotations.json";
|
|
83857
|
+
/** Pause between files so a large export cannot monopolise the hub's thread. */
|
|
83858
|
+
var RETRAIN_ARCHIVE_PACING_MS = 25;
|
|
83859
|
+
async function writeRetrainArchiveTo(deps) {
|
|
83860
|
+
const { plan, logger } = deps;
|
|
83861
|
+
const pacingMs = deps.pacingMs ?? RETRAIN_ARCHIVE_PACING_MS;
|
|
83862
|
+
const hash = createHash("sha256");
|
|
83863
|
+
let bytes = 0;
|
|
83864
|
+
const write = async (chunk) => {
|
|
83865
|
+
hash.update(chunk);
|
|
83866
|
+
bytes += chunk.length;
|
|
83867
|
+
await deps.sink.write(chunk);
|
|
83868
|
+
};
|
|
83869
|
+
const writeEntry = async (name, body, mtimeMs) => {
|
|
83870
|
+
for (const block of tarBlocks({
|
|
83871
|
+
name,
|
|
83872
|
+
body,
|
|
83873
|
+
mtimeMs
|
|
83874
|
+
})) await write(block);
|
|
83875
|
+
};
|
|
83876
|
+
const mtimeMs = deps.now();
|
|
83877
|
+
await writeEntry(RETRAIN_MANIFEST_ENTRY, Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"), mtimeMs);
|
|
83878
|
+
const byId = new Map(plan.manifest.frames.map((f) => [f.frameId, f]));
|
|
83879
|
+
const shipped = [];
|
|
83880
|
+
let missing = 0;
|
|
83881
|
+
let crops = 0;
|
|
83882
|
+
for (const frame of plan.frames) {
|
|
83883
|
+
const entry = byId.get(frame.frameId);
|
|
83884
|
+
if (entry === void 0) continue;
|
|
83885
|
+
if (tarNameTooLong(entry.path)) {
|
|
83886
|
+
missing += 1;
|
|
83887
|
+
logger.warn("retrain export skipped a frame — path too long for the archive", {
|
|
83888
|
+
tags: { deviceId: frame.deviceId },
|
|
83889
|
+
meta: {
|
|
83890
|
+
trackId: frame.trackId,
|
|
83891
|
+
frameId: frame.frameId,
|
|
83892
|
+
path: entry.path
|
|
83893
|
+
}
|
|
83894
|
+
});
|
|
83895
|
+
continue;
|
|
83896
|
+
}
|
|
83897
|
+
const frameBytes = await deps.readFrame(frame);
|
|
83898
|
+
if (frameBytes === null) {
|
|
83899
|
+
missing += 1;
|
|
83900
|
+
logger.warn("retrain export skipped a frame — the COPY is unreadable", {
|
|
83901
|
+
tags: { deviceId: frame.deviceId },
|
|
83902
|
+
meta: {
|
|
83903
|
+
trackId: frame.trackId,
|
|
83904
|
+
frameId: frame.frameId
|
|
83905
|
+
}
|
|
83906
|
+
});
|
|
83907
|
+
continue;
|
|
83908
|
+
}
|
|
83909
|
+
await writeEntry(entry.path, frameBytes, mtimeMs);
|
|
83910
|
+
for (const annotation of entry.annotations) {
|
|
83911
|
+
if (annotation.cropPath === void 0) continue;
|
|
83912
|
+
const crop = deps.cutCrop ? await deps.cutCrop(frameBytes, frame, annotation) : null;
|
|
83913
|
+
if (crop === null) {
|
|
83914
|
+
logger.warn("retrain export skipped a crop — it could not be cut", {
|
|
83915
|
+
tags: { deviceId: frame.deviceId },
|
|
83916
|
+
meta: {
|
|
83917
|
+
frameId: frame.frameId,
|
|
83918
|
+
annotationId: annotation.id,
|
|
83919
|
+
cropPath: annotation.cropPath
|
|
83920
|
+
}
|
|
83921
|
+
});
|
|
83922
|
+
continue;
|
|
83923
|
+
}
|
|
83924
|
+
await writeEntry(annotation.cropPath, crop, mtimeMs);
|
|
83925
|
+
crops += 1;
|
|
83926
|
+
}
|
|
83927
|
+
shipped.push(frame.frameId);
|
|
83928
|
+
if (pacingMs > 0) await new Promise((resolve) => setTimeout(resolve, pacingMs));
|
|
83929
|
+
}
|
|
83930
|
+
await write(tarTrailer());
|
|
83931
|
+
await deps.markExported(shipped, plan.manifest.batchId);
|
|
83932
|
+
const stats = {
|
|
83933
|
+
batchId: plan.manifest.batchId,
|
|
83934
|
+
frames: shipped.length,
|
|
83935
|
+
crops,
|
|
83936
|
+
annotations: plan.manifest.annotationCount,
|
|
83937
|
+
missing,
|
|
83938
|
+
bytes,
|
|
83939
|
+
sha256: hash.digest("hex")
|
|
83940
|
+
};
|
|
83941
|
+
logger.info("retrain export complete", { meta: {
|
|
83942
|
+
...stats,
|
|
83943
|
+
layout: plan.manifest.layout,
|
|
83944
|
+
modelErrors: plan.manifest.modelErrorCount
|
|
83945
|
+
} });
|
|
83946
|
+
return stats;
|
|
83947
|
+
}
|
|
83948
|
+
//#endregion
|
|
83949
|
+
//#region src/pipeline-analytics/retrain/retrain-crop-cut.ts
|
|
83950
|
+
/**
|
|
83951
|
+
* The `crops` layout's cutter: one subject crop from a retrain frame copy.
|
|
83952
|
+
*
|
|
83953
|
+
* A classifier must be trained on the crop the runtime feeds it, so the
|
|
83954
|
+
* rectangle comes from THE detail-crop derivation (`deriveDetailCropRect`,
|
|
83955
|
+
* `types/src/pipeline/detail-crop.ts`) with the convention the manifest
|
|
83956
|
+
* records — never a second rectangle maths. This is the second permitted
|
|
83957
|
+
* caller of that derivation (`scripts/check-clip-crop-single-owner.ts`): it
|
|
83958
|
+
* writes training files, never a vector, so it cannot split a feature space;
|
|
83959
|
+
* what it must not do is cut differently, and calling the one function is how
|
|
83960
|
+
* it doesn't.
|
|
83961
|
+
*/
|
|
83962
|
+
/** The pixel window to extract, or `null` when nothing of it is inside the frame. */
|
|
83963
|
+
function retrainCropWindow(input) {
|
|
83964
|
+
const { frameWidth: fw, frameHeight: fh, bbox } = input;
|
|
83965
|
+
const rect = deriveDetailCropRect({
|
|
83966
|
+
x: bbox.x * fw,
|
|
83967
|
+
y: bbox.y * fh,
|
|
83968
|
+
w: bbox.w * fw,
|
|
83969
|
+
h: bbox.h * fh
|
|
83970
|
+
}, fw, fh, input.convention);
|
|
83971
|
+
const left = Math.max(0, Math.floor(rect.x));
|
|
83972
|
+
const top = Math.max(0, Math.floor(rect.y));
|
|
83973
|
+
const right = Math.min(fw, Math.ceil(rect.x + rect.w));
|
|
83974
|
+
const bottom = Math.min(fh, Math.ceil(rect.y + rect.h));
|
|
83975
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
83976
|
+
return {
|
|
83977
|
+
left,
|
|
83978
|
+
top,
|
|
83979
|
+
width: right - left,
|
|
83980
|
+
height: bottom - top
|
|
83981
|
+
};
|
|
83982
|
+
}
|
|
83983
|
+
async function cutRetrainCrop(input) {
|
|
83984
|
+
const window = retrainCropWindow(input);
|
|
83985
|
+
if (window === null) return null;
|
|
83986
|
+
let pipeline = sharp(input.frameBytes).extract(window);
|
|
83987
|
+
if (input.maxSide > 0) pipeline = pipeline.resize({
|
|
83988
|
+
width: input.maxSide,
|
|
83989
|
+
height: input.maxSide,
|
|
83990
|
+
fit: "inside",
|
|
83991
|
+
withoutEnlargement: true
|
|
83992
|
+
});
|
|
83993
|
+
return pipeline.jpeg({ quality: 92 }).toBuffer();
|
|
83994
|
+
}
|
|
83995
|
+
//#endregion
|
|
82451
83996
|
//#region src/pipeline-analytics/retrain/retrain-geometry.ts
|
|
82452
83997
|
function clamp01(v) {
|
|
82453
83998
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
@@ -82543,14 +84088,106 @@ function deriveSubjectCropRect(bbox, padding) {
|
|
|
82543
84088
|
};
|
|
82544
84089
|
}
|
|
82545
84090
|
//#endregion
|
|
84091
|
+
//#region src/pipeline-analytics/retrain/retrain-lighting.ts
|
|
84092
|
+
function isNight(condition) {
|
|
84093
|
+
return condition === "night" || condition === "ir";
|
|
84094
|
+
}
|
|
84095
|
+
function retrainFrameLighting(input) {
|
|
84096
|
+
if (input.capturedAt === void 0) {
|
|
84097
|
+
const measured = resolveConditionDetailed({
|
|
84098
|
+
...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
|
|
84099
|
+
now: /* @__PURE__ */ new Date(0)
|
|
84100
|
+
});
|
|
84101
|
+
return measured.source === "ir-chroma" ? {
|
|
84102
|
+
condition: measured.condition,
|
|
84103
|
+
night: true,
|
|
84104
|
+
source: measured.source
|
|
84105
|
+
} : null;
|
|
84106
|
+
}
|
|
84107
|
+
const resolved = resolveConditionDetailed({
|
|
84108
|
+
...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
|
|
84109
|
+
...input.coordinates !== null ? { coordinates: input.coordinates } : {},
|
|
84110
|
+
now: new Date(input.capturedAt)
|
|
84111
|
+
});
|
|
84112
|
+
return {
|
|
84113
|
+
...resolved,
|
|
84114
|
+
night: isNight(resolved.condition)
|
|
84115
|
+
};
|
|
84116
|
+
}
|
|
84117
|
+
//#endregion
|
|
82546
84118
|
//#region src/pipeline-analytics/retrain/retrain-export.ts
|
|
84119
|
+
/**
|
|
84120
|
+
* The ANNOTATED dataset export — the retrain page's own archive.
|
|
84121
|
+
*
|
|
84122
|
+
* Sibling of `training-export/`, deliberately not the same thing. That one
|
|
84123
|
+
* streams a marked track's stored media verbatim and knows nothing about what
|
|
84124
|
+
* is in it; this one streams the frames the operator SELECTED (already copied
|
|
84125
|
+
* into retrain storage) plus the ground truth they drew.
|
|
84126
|
+
*
|
|
84127
|
+
* ## The one interesting decision
|
|
84128
|
+
*
|
|
84129
|
+
* `annotations.json` carries, for every subject, the canonical full-frame box
|
|
84130
|
+
* AND the geometry derived for each model shape. **Derived here, at export,
|
|
84131
|
+
* never stored** — one box in, three shapes out. Storing the derived shapes is
|
|
84132
|
+
* how two crops of the same subject end up in one feature space, which is the
|
|
84133
|
+
* failure already recorded for CLIP (D52). A consumer that wants a different
|
|
84134
|
+
* input size or a different padding re-derives from the canonical box; nothing
|
|
84135
|
+
* in the database has to change and nothing can disagree.
|
|
84136
|
+
*
|
|
84137
|
+
* Everything else follows the export next door: index reads to plan, one JPEG
|
|
84138
|
+
* in memory at a time, a yield between files, and NO inference — a training
|
|
84139
|
+
* set's pixels are the pixels the pipeline saw.
|
|
84140
|
+
*/
|
|
82547
84141
|
var RETRAIN_EXPORT_PREFIX = "retrain-export";
|
|
82548
|
-
/**
|
|
82549
|
-
|
|
82550
|
-
|
|
84142
|
+
/**
|
|
84143
|
+
* Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
|
|
84144
|
+
* padding, read from the one convention `deriveDetailCropRect` cuts with
|
|
84145
|
+
* (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
|
|
84146
|
+
* here while the runtime fed `0.15`, so a classifier trained from this export
|
|
84147
|
+
* learned on more context than it is ever given live.
|
|
84148
|
+
*/
|
|
84149
|
+
var CLASSIFIER_CROP_PADDING = DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
|
|
84150
|
+
/** The convention a `crops` layout cuts with — the runtime default, recorded
|
|
84151
|
+
* in the manifest so a consumer never has to assume it. */
|
|
84152
|
+
var RETRAIN_CROP_CONVENTION = DEFAULT_DETAIL_CROP_CONVENTION;
|
|
84153
|
+
/** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
|
|
84154
|
+
* the trainer resizes per model, so this only bounds the upload. */
|
|
84155
|
+
var RETRAIN_CROP_MAX_SIDE = 256;
|
|
84156
|
+
/**
|
|
84157
|
+
* Manifest schema id. v2 = capturedAt / complete / lighting / layout.
|
|
84158
|
+
* v3 = `origin` (`track` | `recording`), `epochMs` on a recording still,
|
|
84159
|
+
* `collectionIds`, and `trackId` ABSENT on a recording still, which has none.
|
|
84160
|
+
*/
|
|
84161
|
+
var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v3";
|
|
84162
|
+
/** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`,
|
|
84163
|
+
* or `frames/<deviceId>/recording/<leaf>.jpg` for a recording still. */
|
|
82551
84164
|
function retrainFramePath(frame) {
|
|
82552
84165
|
const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
|
|
82553
|
-
|
|
84166
|
+
const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
|
|
84167
|
+
return `frames/${frame.deviceId}/${dir}/${leaf}.jpg`;
|
|
84168
|
+
}
|
|
84169
|
+
/** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
|
|
84170
|
+
function retrainCropPath(macroClass, annotationId) {
|
|
84171
|
+
return `crops/${macroClass}/${annotationId}.jpg`;
|
|
84172
|
+
}
|
|
84173
|
+
/**
|
|
84174
|
+
* Narrow the planned frames to a filter. Pure. Every field narrows; an empty
|
|
84175
|
+
* filter keeps everything. Order is the input's.
|
|
84176
|
+
*/
|
|
84177
|
+
function filterRetrainExport(frames, annotationsByFrame, filter, collectionsByFrame = /* @__PURE__ */ new Map()) {
|
|
84178
|
+
const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
|
|
84179
|
+
const collections = filter.collectionIds !== void 0 && filter.collectionIds.length > 0 ? new Set(filter.collectionIds) : null;
|
|
84180
|
+
const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
|
|
84181
|
+
return frames.filter((frame) => {
|
|
84182
|
+
if (wanted !== null && !wanted.has(frame.frameId)) return false;
|
|
84183
|
+
if (devices !== null && !devices.has(frame.deviceId)) return false;
|
|
84184
|
+
if (filter.completeOnly === true && !frame.complete) return false;
|
|
84185
|
+
if (collections !== null && !(collectionsByFrame.get(frame.frameId) ?? []).some((id) => collections.has(id))) return false;
|
|
84186
|
+
if (filter.batchId !== void 0) {
|
|
84187
|
+
if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
|
|
84188
|
+
}
|
|
84189
|
+
return true;
|
|
84190
|
+
});
|
|
82554
84191
|
}
|
|
82555
84192
|
/**
|
|
82556
84193
|
* Derive every per-model shape for one annotation.
|
|
@@ -82572,6 +84209,8 @@ function deriveAll(annotation, frame, zone) {
|
|
|
82572
84209
|
}
|
|
82573
84210
|
/** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
|
|
82574
84211
|
function buildRetrainManifest(input) {
|
|
84212
|
+
const layout = input.layout ?? "frames";
|
|
84213
|
+
const coordinates = input.coordinates ?? null;
|
|
82575
84214
|
let annotationCount = 0;
|
|
82576
84215
|
let modelErrorCount = 0;
|
|
82577
84216
|
const frames = input.frames.map((frame) => {
|
|
@@ -82579,6 +84218,7 @@ function buildRetrainManifest(input) {
|
|
|
82579
84218
|
const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
|
|
82580
84219
|
annotationCount += 1;
|
|
82581
84220
|
if (a.kind === "model_error") modelErrorCount += 1;
|
|
84221
|
+
const derived = deriveAll(a, frame, zone);
|
|
82582
84222
|
return {
|
|
82583
84223
|
id: a.id,
|
|
82584
84224
|
macroClass: a.macroClass,
|
|
@@ -82589,24 +84229,39 @@ function buildRetrainManifest(input) {
|
|
|
82589
84229
|
...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
|
|
82590
84230
|
...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
|
|
82591
84231
|
bbox: a.bbox,
|
|
82592
|
-
derived:
|
|
84232
|
+
...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
|
|
84233
|
+
derived
|
|
82593
84234
|
};
|
|
82594
84235
|
});
|
|
82595
84236
|
return {
|
|
82596
84237
|
frameId: frame.frameId,
|
|
82597
84238
|
path: retrainFramePath(frame),
|
|
82598
84239
|
deviceId: frame.deviceId,
|
|
82599
|
-
|
|
84240
|
+
origin: frame.origin,
|
|
84241
|
+
...frame.origin === "track" ? { trackId: frame.trackId } : {},
|
|
84242
|
+
...frame.epochMs !== void 0 ? { epochMs: frame.epochMs } : {},
|
|
84243
|
+
collectionIds: input.collectionsByFrame?.get(frame.frameId) ?? [],
|
|
82600
84244
|
width: frame.width,
|
|
82601
84245
|
height: frame.height,
|
|
82602
84246
|
sizeBytes: frame.sizeBytes,
|
|
82603
84247
|
sourceKind: frame.sourceKind,
|
|
82604
84248
|
copiedAt: frame.copiedAt,
|
|
84249
|
+
...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
|
|
84250
|
+
complete: frame.complete,
|
|
84251
|
+
lighting: retrainFrameLighting({
|
|
84252
|
+
...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
|
|
84253
|
+
...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
|
|
84254
|
+
coordinates
|
|
84255
|
+
}),
|
|
82605
84256
|
...zone !== null ? { zoneUsed: zone } : {},
|
|
82606
84257
|
annotations
|
|
82607
84258
|
};
|
|
82608
84259
|
});
|
|
82609
84260
|
return {
|
|
84261
|
+
schema: RETRAIN_MANIFEST_SCHEMA,
|
|
84262
|
+
layout,
|
|
84263
|
+
detailCropConvention: RETRAIN_CROP_CONVENTION,
|
|
84264
|
+
...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
|
|
82610
84265
|
generatedAt: input.generatedAt,
|
|
82611
84266
|
batchId: input.batchId,
|
|
82612
84267
|
frameCount: frames.length,
|
|
@@ -82617,123 +84272,39 @@ function buildRetrainManifest(input) {
|
|
|
82617
84272
|
};
|
|
82618
84273
|
}
|
|
82619
84274
|
//#endregion
|
|
82620
|
-
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
82621
|
-
/**
|
|
82622
|
-
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
82623
|
-
* entry, without ever holding the archive in memory.
|
|
82624
|
-
*
|
|
82625
|
-
* ## Why hand-rolled rather than the `tar` package
|
|
82626
|
-
*
|
|
82627
|
-
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
82628
|
-
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
82629
|
-
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
82630
|
-
* operator relocated to), under paths that encode nothing a training set wants —
|
|
82631
|
-
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
82632
|
-
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
82633
|
-
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
82634
|
-
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
82635
|
-
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
82636
|
-
*
|
|
82637
|
-
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
82638
|
-
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
82639
|
-
* door rather than against my reading of the format.
|
|
82640
|
-
*
|
|
82641
|
-
* ## What this deliberately does NOT do
|
|
82642
|
-
*
|
|
82643
|
-
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
82644
|
-
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
82645
|
-
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
82646
|
-
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
82647
|
-
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
82648
|
-
*/
|
|
82649
|
-
var BLOCK_SIZE = 512;
|
|
82650
|
-
var NAME_MAX = 100;
|
|
82651
|
-
var PREFIX_MAX = 155;
|
|
82652
|
-
/**
|
|
82653
|
-
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
82654
|
-
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
82655
|
-
*/
|
|
82656
|
-
function splitUstarName(name) {
|
|
82657
|
-
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
82658
|
-
prefix: "",
|
|
82659
|
-
name
|
|
82660
|
-
};
|
|
82661
|
-
for (let i = name.length - 1; i >= 0; i--) {
|
|
82662
|
-
if (name[i] !== "/") continue;
|
|
82663
|
-
const head = name.slice(0, i);
|
|
82664
|
-
const tail = name.slice(i + 1);
|
|
82665
|
-
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
82666
|
-
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
82667
|
-
prefix: head,
|
|
82668
|
-
name: tail
|
|
82669
|
-
};
|
|
82670
|
-
}
|
|
82671
|
-
return null;
|
|
82672
|
-
}
|
|
82673
|
-
/** True when a path cannot be represented as a ustar entry name. */
|
|
82674
|
-
function tarNameTooLong(name) {
|
|
82675
|
-
return splitUstarName(name) === null;
|
|
82676
|
-
}
|
|
82677
|
-
function writeString(block, value, offset, length) {
|
|
82678
|
-
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
82679
|
-
}
|
|
82680
|
-
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
82681
|
-
function writeOctal(block, value, offset, length) {
|
|
82682
|
-
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
82683
|
-
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
82684
|
-
}
|
|
82685
|
-
/**
|
|
82686
|
-
* The 512-byte header for one regular file.
|
|
82687
|
-
*
|
|
82688
|
-
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
82689
|
-
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
82690
|
-
* fine until something tries to read it.
|
|
82691
|
-
*/
|
|
82692
|
-
function tarHeader(entry) {
|
|
82693
|
-
const split = splitUstarName(entry.name);
|
|
82694
|
-
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
82695
|
-
const block = Buffer.alloc(BLOCK_SIZE);
|
|
82696
|
-
writeString(block, split.name, 0, NAME_MAX);
|
|
82697
|
-
writeOctal(block, 420, 100, 8);
|
|
82698
|
-
writeOctal(block, 0, 108, 8);
|
|
82699
|
-
writeOctal(block, 0, 116, 8);
|
|
82700
|
-
writeOctal(block, entry.body.length, 124, 12);
|
|
82701
|
-
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
82702
|
-
block.write(" ", 148, 8, "ascii");
|
|
82703
|
-
block.write("0", 156, 1, "ascii");
|
|
82704
|
-
block.write("ustar\0", 257, 6, "ascii");
|
|
82705
|
-
block.write("00", 263, 2, "ascii");
|
|
82706
|
-
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
82707
|
-
let sum = 0;
|
|
82708
|
-
for (const byte of block) sum += byte;
|
|
82709
|
-
writeOctal(block, sum, 148, 7);
|
|
82710
|
-
block.write("\0", 154, 1, "ascii");
|
|
82711
|
-
block.write(" ", 155, 1, "ascii");
|
|
82712
|
-
return block;
|
|
82713
|
-
}
|
|
82714
|
-
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
82715
|
-
function tarPadding(size) {
|
|
82716
|
-
const remainder = size % BLOCK_SIZE;
|
|
82717
|
-
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
82718
|
-
}
|
|
82719
|
-
/** Header + body + padding for one entry, ready to write. */
|
|
82720
|
-
function tarBlocks(entry) {
|
|
82721
|
-
return [
|
|
82722
|
-
tarHeader(entry),
|
|
82723
|
-
entry.body,
|
|
82724
|
-
tarPadding(entry.body.length)
|
|
82725
|
-
];
|
|
82726
|
-
}
|
|
82727
|
-
/** The two zero blocks that terminate an archive. */
|
|
82728
|
-
function tarTrailer() {
|
|
82729
|
-
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
82730
|
-
}
|
|
82731
|
-
//#endregion
|
|
82732
84275
|
//#region src/pipeline-analytics/retrain/retrain-export-handler.ts
|
|
82733
|
-
|
|
82734
|
-
|
|
82735
|
-
|
|
82736
|
-
|
|
84276
|
+
/** The export request a URL carries (`getRetrainExportUrl` composes it). */
|
|
84277
|
+
function parseRetrainExportRequest(url) {
|
|
84278
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
84279
|
+
const params = new URLSearchParams(query);
|
|
84280
|
+
const deviceIds = parseRetrainDeviceIds(url);
|
|
84281
|
+
const frameIds = (params.get("frameIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
|
84282
|
+
const collectionIds = (params.get("collectionIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
|
84283
|
+
const batchId = params.get("batchId")?.trim() ?? "";
|
|
84284
|
+
return {
|
|
84285
|
+
filter: {
|
|
84286
|
+
...deviceIds.length > 0 ? { deviceIds: [...deviceIds] } : {},
|
|
84287
|
+
...frameIds.length > 0 ? { frameIds } : {},
|
|
84288
|
+
...collectionIds.length > 0 ? { collectionIds } : {},
|
|
84289
|
+
...batchId.length > 0 ? { batchId } : {},
|
|
84290
|
+
...params.get("completeOnly") === "1" ? { completeOnly: true } : {}
|
|
84291
|
+
},
|
|
84292
|
+
layout: params.get("layout") === "crops" ? "crops" : "frames"
|
|
84293
|
+
};
|
|
84294
|
+
}
|
|
84295
|
+
/** The query string `parseRetrainExportRequest` reads back. */
|
|
84296
|
+
function retrainExportQuery(request) {
|
|
84297
|
+
const params = new URLSearchParams();
|
|
84298
|
+
const { filter } = request;
|
|
84299
|
+
if (filter.deviceIds !== void 0 && filter.deviceIds.length > 0) params.set("deviceIds", filter.deviceIds.join(","));
|
|
84300
|
+
if (filter.frameIds !== void 0 && filter.frameIds.length > 0) params.set("frameIds", filter.frameIds.join(","));
|
|
84301
|
+
if (filter.collectionIds !== void 0 && filter.collectionIds.length > 0) params.set("collectionIds", filter.collectionIds.join(","));
|
|
84302
|
+
if (filter.batchId !== void 0) params.set("batchId", filter.batchId);
|
|
84303
|
+
if (filter.completeOnly === true) params.set("completeOnly", "1");
|
|
84304
|
+
if (request.layout === "crops") params.set("layout", "crops");
|
|
84305
|
+
const text = params.toString();
|
|
84306
|
+
return text.length > 0 ? `?${text}` : "";
|
|
84307
|
+
}
|
|
82737
84308
|
function parseRetrainDeviceIds(url) {
|
|
82738
84309
|
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
82739
84310
|
const raw = new URLSearchParams(query).get("deviceIds");
|
|
@@ -82756,10 +84327,10 @@ function createRetrainExportHandler(deps) {
|
|
|
82756
84327
|
res.end();
|
|
82757
84328
|
return;
|
|
82758
84329
|
}
|
|
82759
|
-
const
|
|
84330
|
+
const request = parseRetrainExportRequest(req.url ?? "");
|
|
82760
84331
|
let plan;
|
|
82761
84332
|
try {
|
|
82762
|
-
plan = await deps.buildPlan(
|
|
84333
|
+
plan = await deps.buildPlan(request);
|
|
82763
84334
|
} catch (err) {
|
|
82764
84335
|
deps.logger.warn("retrain export plan failed", { meta: { error: String(err) } });
|
|
82765
84336
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
@@ -82773,7 +84344,8 @@ function createRetrainExportHandler(deps) {
|
|
|
82773
84344
|
"x-camstack-retrain-frames": String(plan.manifest.frameCount),
|
|
82774
84345
|
"x-camstack-retrain-annotations": String(plan.manifest.annotationCount),
|
|
82775
84346
|
"x-camstack-retrain-model-errors": String(plan.manifest.modelErrorCount),
|
|
82776
|
-
"x-camstack-retrain-batch": plan.manifest.batchId
|
|
84347
|
+
"x-camstack-retrain-batch": plan.manifest.batchId,
|
|
84348
|
+
"x-camstack-retrain-layout": plan.manifest.layout
|
|
82777
84349
|
});
|
|
82778
84350
|
if (method === "HEAD") {
|
|
82779
84351
|
res.end();
|
|
@@ -82786,64 +84358,193 @@ function createRetrainExportHandler(deps) {
|
|
|
82786
84358
|
});
|
|
82787
84359
|
if (!ok) res.once("drain", resolve);
|
|
82788
84360
|
});
|
|
82789
|
-
|
|
82790
|
-
|
|
82791
|
-
|
|
82792
|
-
|
|
82793
|
-
|
|
82794
|
-
|
|
82795
|
-
|
|
82796
|
-
|
|
82797
|
-
|
|
82798
|
-
const entry = plan.manifest.frames.find((f) => f.frameId === frame.frameId);
|
|
82799
|
-
if (entry === void 0) continue;
|
|
82800
|
-
if (tarNameTooLong(entry.path)) {
|
|
82801
|
-
missing += 1;
|
|
82802
|
-
deps.logger.warn("retrain export skipped a frame — path too long for the archive", {
|
|
82803
|
-
tags: { deviceId: frame.deviceId },
|
|
82804
|
-
meta: {
|
|
82805
|
-
trackId: frame.trackId,
|
|
82806
|
-
frameId: frame.frameId,
|
|
82807
|
-
path: entry.path
|
|
82808
|
-
}
|
|
82809
|
-
});
|
|
82810
|
-
continue;
|
|
82811
|
-
}
|
|
82812
|
-
const bytes = await deps.readFrame(frame);
|
|
82813
|
-
if (bytes === null) {
|
|
82814
|
-
missing += 1;
|
|
82815
|
-
deps.logger.warn("retrain export skipped a frame — the COPY is unreadable", {
|
|
82816
|
-
tags: { deviceId: frame.deviceId },
|
|
82817
|
-
meta: {
|
|
82818
|
-
trackId: frame.trackId,
|
|
82819
|
-
frameId: frame.frameId
|
|
82820
|
-
}
|
|
82821
|
-
});
|
|
82822
|
-
continue;
|
|
82823
|
-
}
|
|
82824
|
-
for (const block of tarBlocks({
|
|
82825
|
-
name: entry.path,
|
|
82826
|
-
body: bytes,
|
|
82827
|
-
mtimeMs
|
|
82828
|
-
})) await write(block);
|
|
82829
|
-
shipped.push(frame.frameId);
|
|
82830
|
-
await new Promise((resolve) => setTimeout(resolve, PACING_MS));
|
|
82831
|
-
}
|
|
82832
|
-
await write(tarTrailer());
|
|
84361
|
+
await writeRetrainArchiveTo({
|
|
84362
|
+
plan,
|
|
84363
|
+
readFrame: deps.readFrame,
|
|
84364
|
+
cutCrop: deps.cutCrop,
|
|
84365
|
+
sink: { write },
|
|
84366
|
+
markExported: deps.markExported,
|
|
84367
|
+
logger: deps.logger,
|
|
84368
|
+
now: deps.now
|
|
84369
|
+
});
|
|
82833
84370
|
await new Promise((resolve) => {
|
|
82834
84371
|
res.end(resolve);
|
|
82835
84372
|
});
|
|
82836
|
-
await deps.markExported(shipped, plan.manifest.batchId);
|
|
82837
|
-
deps.logger.info("retrain export complete", { meta: {
|
|
82838
|
-
batchId: plan.manifest.batchId,
|
|
82839
|
-
frames: shipped.length,
|
|
82840
|
-
missing,
|
|
82841
|
-
annotations: plan.manifest.annotationCount,
|
|
82842
|
-
modelErrors: plan.manifest.modelErrorCount
|
|
82843
|
-
} });
|
|
82844
84373
|
};
|
|
82845
84374
|
}
|
|
82846
84375
|
//#endregion
|
|
84376
|
+
//#region src/pipeline-analytics/retrain/retrain-pipeline-boxes.ts
|
|
84377
|
+
/**
|
|
84378
|
+
* The boxes the PIPELINE drew on a copied frame — Step 1 of the training
|
|
84379
|
+
* screen.
|
|
84380
|
+
*
|
|
84381
|
+
* A correction has to start from the decision being corrected. Proposing with
|
|
84382
|
+
* a fresh model run answers a different question ("what would a model say
|
|
84383
|
+
* now"), and the frame the operator is fixing is precisely the one where the
|
|
84384
|
+
* live answer was wrong — a re-run that happens to be right hides the phantom
|
|
84385
|
+
* the operator came to remove. So the canvas opens with what the track store
|
|
84386
|
+
* says the pipeline had on that frame, and the operator accepts, corrects,
|
|
84387
|
+
* relabels or deletes it.
|
|
84388
|
+
*
|
|
84389
|
+
* ## Where a box comes from, best first
|
|
84390
|
+
*
|
|
84391
|
+
* 1. **The subject box recorded ON the source media row** (D510). Exact by
|
|
84392
|
+
* construction: the box was measured on the frame the blob shows. Only the
|
|
84393
|
+
* copy's own track can have one.
|
|
84394
|
+
* 2. **The track position nearest the capture time**, within
|
|
84395
|
+
* {@link PIPELINE_BOX_TOLERANCE_MS}. Positions are frame-rate, so the
|
|
84396
|
+
* nearest one is normally the same detection; the delta is reported so a
|
|
84397
|
+
* reviewer can see how far it was.
|
|
84398
|
+
*
|
|
84399
|
+
* Every OTHER track alive on the camera at that instant contributes its own
|
|
84400
|
+
* nearest position the same way — a frame with three people is three boxes,
|
|
84401
|
+
* and a recording still (no track of its own) gets everything the pipeline
|
|
84402
|
+
* was tracking at that moment, which is exactly what a missed-object review
|
|
84403
|
+
* needs to see is NOT there.
|
|
84404
|
+
*
|
|
84405
|
+
* Nothing here does I/O. The caller reads the tracks and the subject box.
|
|
84406
|
+
*/
|
|
84407
|
+
/** How far from the capture time a position may be and still be "this frame".
|
|
84408
|
+
* Positions are recorded at the analysis rate (several per second), so half
|
|
84409
|
+
* a second is a generous match and still not a different moment. */
|
|
84410
|
+
var PIPELINE_BOX_TOLERANCE_MS = 500;
|
|
84411
|
+
/**
|
|
84412
|
+
* How far back a track may have STARTED and still be alive at the frame. The
|
|
84413
|
+
* track store windows on `firstSeen`, so a track born before this is not
|
|
84414
|
+
* asked about — a vehicle parked for hours is the case it misses, and the
|
|
84415
|
+
* operator draws that box by hand.
|
|
84416
|
+
*/
|
|
84417
|
+
var PIPELINE_TRACK_LOOKBACK_MS = 30 * 6e4;
|
|
84418
|
+
/** At most this many tracks of one camera are read around one frame. */
|
|
84419
|
+
var PIPELINE_TRACK_AROUND_LIMIT = 200;
|
|
84420
|
+
/** The least-wrong macro tier for a class the macro map does not know. The
|
|
84421
|
+
* operator corrects it; what must not happen is the raw string being
|
|
84422
|
+
* promoted into the macro tier. */
|
|
84423
|
+
var FALLBACK_MACRO_CLASS = "person";
|
|
84424
|
+
/**
|
|
84425
|
+
* Split a detector's class into the two tiers.
|
|
84426
|
+
*
|
|
84427
|
+
* A macro class stays in the macro tier; anything else maps through the COCO
|
|
84428
|
+
* macro map, and the class itself becomes the LABEL. A macro word is never a
|
|
84429
|
+
* label, in any casing — the tier rule the annotation write enforces.
|
|
84430
|
+
*/
|
|
84431
|
+
function toRetrainClassTiers(className, label) {
|
|
84432
|
+
const cls = className.trim().toLowerCase();
|
|
84433
|
+
const usableLabel = (value) => {
|
|
84434
|
+
const trimmed = value?.trim();
|
|
84435
|
+
if (trimmed === void 0 || trimmed === "") return void 0;
|
|
84436
|
+
return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
|
|
84437
|
+
};
|
|
84438
|
+
if (isRetrainMacroClass(cls)) {
|
|
84439
|
+
const kept = usableLabel(label);
|
|
84440
|
+
return kept !== void 0 ? {
|
|
84441
|
+
macroClass: cls,
|
|
84442
|
+
label: kept
|
|
84443
|
+
} : { macroClass: cls };
|
|
84444
|
+
}
|
|
84445
|
+
const mapped = COCO_TO_MACRO.mapping[cls];
|
|
84446
|
+
const macroClass = mapped !== void 0 && isRetrainMacroClass(mapped) ? mapped : FALLBACK_MACRO_CLASS;
|
|
84447
|
+
const kept = usableLabel(className);
|
|
84448
|
+
return kept !== void 0 ? {
|
|
84449
|
+
macroClass,
|
|
84450
|
+
label: kept
|
|
84451
|
+
} : { macroClass };
|
|
84452
|
+
}
|
|
84453
|
+
/** A pixel box as a clamped normalised one, or `null` when nothing is left. */
|
|
84454
|
+
function normalise(box, frameWidth, frameHeight) {
|
|
84455
|
+
if (frameWidth === void 0 || frameHeight === void 0) return null;
|
|
84456
|
+
if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
|
|
84457
|
+
const x = clampSpan(box.x, box.w, frameWidth);
|
|
84458
|
+
const y = clampSpan(box.y, box.h, frameHeight);
|
|
84459
|
+
if (x === null || y === null) return null;
|
|
84460
|
+
return {
|
|
84461
|
+
x: x.start,
|
|
84462
|
+
y: y.start,
|
|
84463
|
+
w: x.size,
|
|
84464
|
+
h: y.size
|
|
84465
|
+
};
|
|
84466
|
+
}
|
|
84467
|
+
/** One axis of a pixel box, normalised and clamped into `[0,1]`. The size is
|
|
84468
|
+
* divided directly when nothing was clamped, so an exact box stays exact. */
|
|
84469
|
+
function clampSpan(start, size, extent) {
|
|
84470
|
+
const lo = start / extent;
|
|
84471
|
+
const hi = (start + size) / extent;
|
|
84472
|
+
const s = Math.max(0, lo);
|
|
84473
|
+
const n = lo >= 0 && hi <= 1 ? size / extent : Math.min(1, hi) - s;
|
|
84474
|
+
return n > 0 ? {
|
|
84475
|
+
start: s,
|
|
84476
|
+
size: n
|
|
84477
|
+
} : null;
|
|
84478
|
+
}
|
|
84479
|
+
function nearestPosition(track, at, tolerance) {
|
|
84480
|
+
let best = null;
|
|
84481
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
84482
|
+
for (const p of track.positions) {
|
|
84483
|
+
const delta = Math.abs(p.timestamp - at);
|
|
84484
|
+
if (delta < bestDelta) {
|
|
84485
|
+
best = p;
|
|
84486
|
+
bestDelta = delta;
|
|
84487
|
+
}
|
|
84488
|
+
}
|
|
84489
|
+
if (best === null || bestDelta > tolerance) return null;
|
|
84490
|
+
return {
|
|
84491
|
+
position: best,
|
|
84492
|
+
deltaMs: bestDelta
|
|
84493
|
+
};
|
|
84494
|
+
}
|
|
84495
|
+
function boxFor(track, bbox, basis, deltaMs, score) {
|
|
84496
|
+
const tiers = toRetrainClassTiers(track.className, track.label);
|
|
84497
|
+
const subLabel = track.subLabel?.trim();
|
|
84498
|
+
return {
|
|
84499
|
+
bbox,
|
|
84500
|
+
macroClass: tiers.macroClass,
|
|
84501
|
+
...tiers.label !== void 0 ? { label: tiers.label } : {},
|
|
84502
|
+
...subLabel !== void 0 && subLabel !== "" && !isRetrainMacroClass(subLabel.toLowerCase()) ? { subLabel } : {},
|
|
84503
|
+
...score !== void 0 ? { score } : {},
|
|
84504
|
+
...track.modelId !== void 0 ? { modelId: track.modelId } : {},
|
|
84505
|
+
trackId: track.trackId,
|
|
84506
|
+
basis,
|
|
84507
|
+
deltaMs
|
|
84508
|
+
};
|
|
84509
|
+
}
|
|
84510
|
+
/** The own track's box: the recorded subject box first, the nearest position second. */
|
|
84511
|
+
function ownBox(input, tolerance) {
|
|
84512
|
+
const track = input.ownTrack;
|
|
84513
|
+
if (track === null) return null;
|
|
84514
|
+
const at = input.frame.capturedAt;
|
|
84515
|
+
const nearest = at !== void 0 ? nearestPosition(track, at, tolerance) : null;
|
|
84516
|
+
if (input.ownSubjectBox !== null) {
|
|
84517
|
+
const recorded = normalise(input.ownSubjectBox, input.ownSubjectBox.frameWidth, input.ownSubjectBox.frameHeight);
|
|
84518
|
+
if (recorded !== null) return boxFor(track, recorded, "subject-box", 0, nearest?.position.score);
|
|
84519
|
+
}
|
|
84520
|
+
if (nearest === null) return null;
|
|
84521
|
+
const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
|
|
84522
|
+
return placed === null ? null : boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score);
|
|
84523
|
+
}
|
|
84524
|
+
/**
|
|
84525
|
+
* Every box the pipeline had on this frame, own track first. Pure.
|
|
84526
|
+
*/
|
|
84527
|
+
function pipelineBoxesForFrame(input) {
|
|
84528
|
+
const tolerance = input.toleranceMs ?? PIPELINE_BOX_TOLERANCE_MS;
|
|
84529
|
+
const out = [];
|
|
84530
|
+
const own = ownBox(input, tolerance);
|
|
84531
|
+
if (own !== null) out.push(own);
|
|
84532
|
+
const at = input.frame.capturedAt;
|
|
84533
|
+
if (at === void 0) return out;
|
|
84534
|
+
const ownId = input.frame.trackId;
|
|
84535
|
+
const seen = new Set(ownId !== void 0 ? [ownId] : []);
|
|
84536
|
+
for (const track of input.neighbours) {
|
|
84537
|
+
if (seen.has(track.trackId) || track.deviceId !== input.frame.deviceId) continue;
|
|
84538
|
+
seen.add(track.trackId);
|
|
84539
|
+
const nearest = nearestPosition(track, at, tolerance);
|
|
84540
|
+
if (nearest === null) continue;
|
|
84541
|
+
const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
|
|
84542
|
+
if (placed === null) continue;
|
|
84543
|
+
out.push(boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score));
|
|
84544
|
+
}
|
|
84545
|
+
return out;
|
|
84546
|
+
}
|
|
84547
|
+
//#endregion
|
|
82847
84548
|
//#region src/pipeline-analytics/retrain/retrain-assist.ts
|
|
82848
84549
|
/** The whole frame, as a normalised rectangle. */
|
|
82849
84550
|
var WHOLE_FRAME = {
|
|
@@ -82914,12 +84615,11 @@ function mapAssistDetails(input) {
|
|
|
82914
84615
|
withoutBox += 1;
|
|
82915
84616
|
continue;
|
|
82916
84617
|
}
|
|
82917
|
-
const
|
|
82918
|
-
const label = isRetrainMacroClass(d.className) ? d.label : d.className;
|
|
84618
|
+
const tiers = toRetrainClassTiers(d.className, d.label);
|
|
82919
84619
|
proposals.push({
|
|
82920
84620
|
bbox,
|
|
82921
|
-
macroClass,
|
|
82922
|
-
...label !== void 0
|
|
84621
|
+
macroClass: tiers.macroClass,
|
|
84622
|
+
...tiers.label !== void 0 ? { label: tiers.label } : {},
|
|
82923
84623
|
score: d.score,
|
|
82924
84624
|
modelId
|
|
82925
84625
|
});
|
|
@@ -82930,16 +84630,6 @@ function mapAssistDetails(input) {
|
|
|
82930
84630
|
withoutBox
|
|
82931
84631
|
};
|
|
82932
84632
|
}
|
|
82933
|
-
/**
|
|
82934
|
-
* Macro tier for a detector class we do not carry as a macro class.
|
|
82935
|
-
*
|
|
82936
|
-
* The class itself becomes the LABEL (see above) and the macro tier gets a
|
|
82937
|
-
* value the operator will almost certainly correct — which is fine, because it
|
|
82938
|
-
* is a PROPOSAL and correcting it is the job. What must not happen is the
|
|
82939
|
-
* detector's own string being promoted into the macro tier: that is how
|
|
82940
|
-
* `postman` becomes a class with one example in it.
|
|
82941
|
-
*/
|
|
82942
|
-
var FALLBACK_MACRO_CLASS = "person";
|
|
82943
84633
|
/** A proposal, as a draft annotation ready to store if the operator accepts it. */
|
|
82944
84634
|
function proposalToDraft(proposal, owner) {
|
|
82945
84635
|
return {
|
|
@@ -83148,23 +84838,31 @@ async function restageRetrainTrack(deps, input) {
|
|
|
83148
84838
|
var DEFAULT_STAGING_LIMIT = 100;
|
|
83149
84839
|
/** A macro class that failed validation — the write is refused, never coerced. */
|
|
83150
84840
|
var RetrainAnnotationError = class extends Error {};
|
|
84841
|
+
/** A store frame on the wire. A recording still carries no track and no
|
|
84842
|
+
* source media row, and says so by leaving both out. */
|
|
83151
84843
|
function toWireFrame(f) {
|
|
83152
84844
|
return {
|
|
83153
84845
|
frameId: f.frameId,
|
|
83154
84846
|
deviceId: f.deviceId,
|
|
83155
|
-
|
|
83156
|
-
|
|
84847
|
+
origin: f.origin,
|
|
84848
|
+
...f.origin === "track" ? {
|
|
84849
|
+
trackId: f.trackId,
|
|
84850
|
+
sourceMediaId: f.sourceMediaId
|
|
84851
|
+
} : {},
|
|
84852
|
+
...f.epochMs !== void 0 ? { epochMs: f.epochMs } : {},
|
|
83157
84853
|
sourceKind: f.sourceKind,
|
|
83158
84854
|
sizeBytes: f.sizeBytes,
|
|
83159
84855
|
width: f.width,
|
|
83160
84856
|
height: f.height,
|
|
83161
|
-
copiedAt: f.copiedAt
|
|
84857
|
+
copiedAt: f.copiedAt,
|
|
84858
|
+
...f.capturedAt !== void 0 ? { capturedAt: f.capturedAt } : {},
|
|
84859
|
+
complete: f.complete
|
|
83162
84860
|
};
|
|
83163
84861
|
}
|
|
83164
84862
|
function toWireAnnotation(a) {
|
|
83165
84863
|
return {
|
|
83166
84864
|
id: a.id,
|
|
83167
|
-
trackId: a.trackId,
|
|
84865
|
+
...a.trackId !== "" ? { trackId: a.trackId } : {},
|
|
83168
84866
|
deviceId: a.deviceId,
|
|
83169
84867
|
mediaKey: a.mediaKey,
|
|
83170
84868
|
bbox: a.bbox,
|
|
@@ -83231,11 +84929,116 @@ var RetrainService = class {
|
|
|
83231
84929
|
if (candidates.length === 0) await this.logNoAnnotatableFrame(trackId, media, copies.length);
|
|
83232
84930
|
return {
|
|
83233
84931
|
candidates,
|
|
83234
|
-
copies:
|
|
84932
|
+
copies: await this.toWireFrames(copies),
|
|
83235
84933
|
...autoPick !== null ? { autoPickMediaId: autoPick.mediaId } : {}
|
|
83236
84934
|
};
|
|
83237
84935
|
}
|
|
83238
84936
|
/**
|
|
84937
|
+
* The wire frames, each with the boxes the pipeline drew on it (Step 1) and
|
|
84938
|
+
* the collections it belongs to.
|
|
84939
|
+
*
|
|
84940
|
+
* Index reads only — the track rows and the media rows' subject boxes, no
|
|
84941
|
+
* blob and no inference. One read per distinct track and per distinct
|
|
84942
|
+
* instant, not per box. A failed read degrades to fewer boxes, logged, and
|
|
84943
|
+
* never to a failed listing: the frames are the operator's work and must
|
|
84944
|
+
* stay reachable when the evidence is not.
|
|
84945
|
+
*/
|
|
84946
|
+
async toWireFrames(copies) {
|
|
84947
|
+
const collections = this.deps.memberships !== void 0 && copies.length > 0 ? await this.deps.memberships.collectionsOf(copies.map((c) => c.frameId)).catch((err) => {
|
|
84948
|
+
this.deps.logger.warn("retrain frames: collection membership unreadable", { meta: {
|
|
84949
|
+
frames: copies.length,
|
|
84950
|
+
error: String(err)
|
|
84951
|
+
} });
|
|
84952
|
+
return /* @__PURE__ */ new Map();
|
|
84953
|
+
}) : null;
|
|
84954
|
+
const evidence = this.deps.pipelineEvidence;
|
|
84955
|
+
const ownTracks = /* @__PURE__ */ new Map();
|
|
84956
|
+
const subjectBoxes = /* @__PURE__ */ new Map();
|
|
84957
|
+
const out = [];
|
|
84958
|
+
for (const copy of copies) {
|
|
84959
|
+
const wire = toWireFrame(copy);
|
|
84960
|
+
const collectionIds = collections?.get(copy.frameId);
|
|
84961
|
+
const withCollections = collections !== null ? {
|
|
84962
|
+
...wire,
|
|
84963
|
+
collectionIds: collectionIds ?? []
|
|
84964
|
+
} : wire;
|
|
84965
|
+
if (evidence === void 0) {
|
|
84966
|
+
out.push(withCollections);
|
|
84967
|
+
continue;
|
|
84968
|
+
}
|
|
84969
|
+
const trackId = copy.origin === "track" ? copy.trackId : "";
|
|
84970
|
+
if (trackId !== "" && !ownTracks.has(trackId)) {
|
|
84971
|
+
ownTracks.set(trackId, await this.deps.tracks.getPersistedByTrackId(trackId).catch(() => null));
|
|
84972
|
+
subjectBoxes.set(trackId, await this.readSubjectBoxes(evidence, trackId, copy.deviceId));
|
|
84973
|
+
}
|
|
84974
|
+
const neighbours = copy.capturedAt !== void 0 ? await this.readTracksAround(evidence, copy.deviceId, copy.capturedAt) : [];
|
|
84975
|
+
const pipelineBoxes = pipelineBoxesForFrame({
|
|
84976
|
+
frame: {
|
|
84977
|
+
deviceId: copy.deviceId,
|
|
84978
|
+
...trackId !== "" ? { trackId } : {},
|
|
84979
|
+
...copy.capturedAt !== void 0 ? { capturedAt: copy.capturedAt } : {}
|
|
84980
|
+
},
|
|
84981
|
+
ownTrack: trackId !== "" ? ownTracks.get(trackId) ?? null : null,
|
|
84982
|
+
ownSubjectBox: trackId !== "" ? subjectBoxes.get(trackId)?.get(copy.sourceMediaId) ?? null : null,
|
|
84983
|
+
neighbours
|
|
84984
|
+
});
|
|
84985
|
+
out.push({
|
|
84986
|
+
...withCollections,
|
|
84987
|
+
pipelineBoxes
|
|
84988
|
+
});
|
|
84989
|
+
}
|
|
84990
|
+
return out;
|
|
84991
|
+
}
|
|
84992
|
+
/**
|
|
84993
|
+
* The frame a frame-scoped call names, or a refusal. The frame is the
|
|
84994
|
+
* authority: its camera must be the caller's, and a `trackId`, when the
|
|
84995
|
+
* caller sends one, must be the frame's own — a recording still has none.
|
|
84996
|
+
*/
|
|
84997
|
+
async requireFrame(ref, action) {
|
|
84998
|
+
const frame = await this.deps.frames.get(ref.frameId);
|
|
84999
|
+
const trackMismatch = frame !== null && ref.trackId !== void 0 && (frame.origin !== "track" || frame.trackId !== ref.trackId);
|
|
85000
|
+
if (frame === null || frame.deviceId !== ref.deviceId || trackMismatch) {
|
|
85001
|
+
this.deps.logger.warn(`retrain ${action} refused: the frame does not belong to that camera or track`, {
|
|
85002
|
+
tags: { deviceId: ref.deviceId },
|
|
85003
|
+
meta: {
|
|
85004
|
+
frameId: ref.frameId,
|
|
85005
|
+
...ref.trackId !== void 0 ? { trackId: ref.trackId } : {}
|
|
85006
|
+
}
|
|
85007
|
+
});
|
|
85008
|
+
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)}`}`);
|
|
85009
|
+
}
|
|
85010
|
+
return frame;
|
|
85011
|
+
}
|
|
85012
|
+
async readSubjectBoxes(evidence, trackId, deviceId) {
|
|
85013
|
+
try {
|
|
85014
|
+
const rows = await evidence.subjectBoxes(trackId, deviceId);
|
|
85015
|
+
return new Map(rows.map((r) => [r.mediaId, r.subjectBox]));
|
|
85016
|
+
} catch (err) {
|
|
85017
|
+
this.deps.logger.warn("retrain pipeline boxes: subject boxes unreadable", {
|
|
85018
|
+
tags: { deviceId },
|
|
85019
|
+
meta: {
|
|
85020
|
+
trackId,
|
|
85021
|
+
error: String(err)
|
|
85022
|
+
}
|
|
85023
|
+
});
|
|
85024
|
+
return /* @__PURE__ */ new Map();
|
|
85025
|
+
}
|
|
85026
|
+
}
|
|
85027
|
+
async readTracksAround(evidence, deviceId, atMs) {
|
|
85028
|
+
try {
|
|
85029
|
+
return await evidence.tracksAround(deviceId, atMs);
|
|
85030
|
+
} catch (err) {
|
|
85031
|
+
this.deps.logger.warn("retrain pipeline boxes: tracks around the frame unreadable", {
|
|
85032
|
+
tags: { deviceId },
|
|
85033
|
+
meta: {
|
|
85034
|
+
atMs,
|
|
85035
|
+
error: String(err)
|
|
85036
|
+
}
|
|
85037
|
+
});
|
|
85038
|
+
return [];
|
|
85039
|
+
}
|
|
85040
|
+
}
|
|
85041
|
+
/**
|
|
83239
85042
|
* A track with no annotatable frame is a REAL state, and the page has to say
|
|
83240
85043
|
* so rather than render an empty strip that reads as a slow load. Two causes,
|
|
83241
85044
|
* named apart: the whole-frame media AGED OUT, or the track only ever held
|
|
@@ -83261,6 +85064,7 @@ var RetrainService = class {
|
|
|
83261
85064
|
async selectFrames(input) {
|
|
83262
85065
|
const media = await this.deps.media.listTrackMedia(input.trackId);
|
|
83263
85066
|
const kindOf = new Map(media.map((m) => [m.mediaId, m.kind]));
|
|
85067
|
+
const capturedAtOf = new Map(media.map((m) => [m.mediaId, m.timestamp]));
|
|
83264
85068
|
const copied = [];
|
|
83265
85069
|
const refused = [];
|
|
83266
85070
|
for (const mediaId of input.mediaIds) {
|
|
@@ -83268,7 +85072,8 @@ var RetrainService = class {
|
|
|
83268
85072
|
deviceId: input.deviceId,
|
|
83269
85073
|
trackId: input.trackId,
|
|
83270
85074
|
sourceMediaId: mediaId,
|
|
83271
|
-
sourceKind: kindOf.get(mediaId) ?? "keyFrame"
|
|
85075
|
+
sourceKind: kindOf.get(mediaId) ?? "keyFrame",
|
|
85076
|
+
...capturedAtOf.has(mediaId) ? { capturedAt: capturedAtOf.get(mediaId) } : {}
|
|
83272
85077
|
});
|
|
83273
85078
|
if (result.kind === "copied") copied.push(toWireFrame(result.frame));
|
|
83274
85079
|
else refused.push({
|
|
@@ -83281,37 +85086,60 @@ var RetrainService = class {
|
|
|
83281
85086
|
refused
|
|
83282
85087
|
};
|
|
83283
85088
|
}
|
|
85089
|
+
/**
|
|
85090
|
+
* The operator's "every subject in this frame is boxed". Scoped to the
|
|
85091
|
+
* track the caller names: a frame of another track is refused, loudly.
|
|
85092
|
+
*/
|
|
85093
|
+
async setFrameComplete(input) {
|
|
85094
|
+
const frame = await this.requireFrame(input, "complete");
|
|
85095
|
+
const updated = await this.deps.frames.setComplete(frame.frameId, input.complete);
|
|
85096
|
+
if (updated === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} vanished`);
|
|
85097
|
+
this.deps.logger.info("retrain frame completeness set", {
|
|
85098
|
+
tags: { deviceId: input.deviceId },
|
|
85099
|
+
meta: {
|
|
85100
|
+
trackId: frame.trackId,
|
|
85101
|
+
frameId: input.frameId,
|
|
85102
|
+
complete: input.complete
|
|
85103
|
+
}
|
|
85104
|
+
});
|
|
85105
|
+
return toWireFrame(updated);
|
|
85106
|
+
}
|
|
83284
85107
|
/** Un-select: annotations first, then the copy. Never the other way round —
|
|
83285
|
-
* an annotation whose frame is gone is unopenable and unfixable.
|
|
85108
|
+
* an annotation whose frame is gone is unopenable and unfixable. The frame's
|
|
85109
|
+
* collection memberships go with it. */
|
|
83286
85110
|
async deselectFrame(input) {
|
|
83287
85111
|
const frame = await this.deps.frames.get(input.frameId);
|
|
83288
85112
|
if (frame === null) {
|
|
83289
85113
|
this.deps.logger.warn("retrain deselect: no such copied frame", {
|
|
83290
85114
|
tags: { deviceId: input.deviceId },
|
|
83291
|
-
meta: {
|
|
83292
|
-
trackId: input.trackId,
|
|
83293
|
-
frameId: input.frameId
|
|
83294
|
-
}
|
|
85115
|
+
meta: { frameId: input.frameId }
|
|
83295
85116
|
});
|
|
83296
85117
|
return {
|
|
83297
85118
|
removed: false,
|
|
83298
85119
|
removedAnnotations: 0
|
|
83299
85120
|
};
|
|
83300
85121
|
}
|
|
83301
|
-
|
|
85122
|
+
await this.requireFrame(input, "deselect");
|
|
85123
|
+
return {
|
|
85124
|
+
removed: true,
|
|
85125
|
+
removedAnnotations: await this.removeFrame(frame)
|
|
85126
|
+
};
|
|
85127
|
+
}
|
|
85128
|
+
/** Remove a frame from the dataset: annotations, memberships, then the copy. */
|
|
85129
|
+
async removeFrame(frame) {
|
|
85130
|
+
const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
|
|
85131
|
+
await this.deps.memberships?.forgetFrame(frame.frameId);
|
|
83302
85132
|
await this.deps.frames.deleteFrame(frame);
|
|
83303
85133
|
this.deps.logger.info("retrain frame removed from the dataset", {
|
|
83304
|
-
tags: { deviceId:
|
|
85134
|
+
tags: { deviceId: frame.deviceId },
|
|
83305
85135
|
meta: {
|
|
83306
|
-
|
|
83307
|
-
|
|
85136
|
+
origin: frame.origin,
|
|
85137
|
+
...frame.origin === "track" ? { trackId: frame.trackId } : {},
|
|
85138
|
+
frameId: frame.frameId,
|
|
83308
85139
|
removedAnnotations
|
|
83309
85140
|
}
|
|
83310
85141
|
});
|
|
83311
|
-
return
|
|
83312
|
-
removed: true,
|
|
83313
|
-
removedAnnotations
|
|
83314
|
-
};
|
|
85142
|
+
return removedAnnotations;
|
|
83315
85143
|
}
|
|
83316
85144
|
/** The copy's pixels plus its exact dimensions — the canvas needs both. */
|
|
83317
85145
|
async getFrameImage(frameId) {
|
|
@@ -83351,7 +85179,7 @@ var RetrainService = class {
|
|
|
83351
85179
|
};
|
|
83352
85180
|
}
|
|
83353
85181
|
const frame = await this.deps.frames.get(input.frameId);
|
|
83354
|
-
if (frame === null) return {
|
|
85182
|
+
if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
|
|
83355
85183
|
kind: "refused",
|
|
83356
85184
|
reason: "unknown-frame"
|
|
83357
85185
|
};
|
|
@@ -83406,8 +85234,8 @@ var RetrainService = class {
|
|
|
83406
85234
|
}
|
|
83407
85235
|
});
|
|
83408
85236
|
const owner = {
|
|
83409
|
-
trackId:
|
|
83410
|
-
deviceId:
|
|
85237
|
+
trackId: frame.trackId,
|
|
85238
|
+
deviceId: frame.deviceId,
|
|
83411
85239
|
mediaKey: input.frameId
|
|
83412
85240
|
};
|
|
83413
85241
|
return {
|
|
@@ -83430,8 +85258,12 @@ var RetrainService = class {
|
|
|
83430
85258
|
belowThreshold: mapping.belowThreshold
|
|
83431
85259
|
};
|
|
83432
85260
|
}
|
|
83433
|
-
|
|
83434
|
-
|
|
85261
|
+
/** The annotations of a track, or of named frames (a recording still has
|
|
85262
|
+
* no track). One of the two must be given. */
|
|
85263
|
+
async listAnnotations(input) {
|
|
85264
|
+
if (input.trackId !== void 0 && input.trackId !== "") return (await this.deps.annotations.listByTrack(input.trackId)).map(toWireAnnotation);
|
|
85265
|
+
if (input.frameIds !== void 0) return (await this.deps.annotations.listByFrames(input.frameIds)).map(toWireAnnotation);
|
|
85266
|
+
throw new RetrainAnnotationError("listRetrainAnnotations needs a trackId or frameIds");
|
|
83435
85267
|
}
|
|
83436
85268
|
/**
|
|
83437
85269
|
* Replace every annotation on one frame.
|
|
@@ -83443,15 +85275,15 @@ var RetrainService = class {
|
|
|
83443
85275
|
* believes they stored.
|
|
83444
85276
|
*/
|
|
83445
85277
|
async saveAnnotations(input) {
|
|
83446
|
-
|
|
85278
|
+
const frame = await this.requireFrame(input, "save");
|
|
83447
85279
|
for (const a of input.annotations) {
|
|
83448
85280
|
if (!isRetrainMacroClass(a.macroClass)) throw new RetrainAnnotationError(`"${a.macroClass}" is not a macro class`);
|
|
83449
85281
|
const violation = checkTierSeparation(a);
|
|
83450
85282
|
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)`);
|
|
83451
85283
|
}
|
|
83452
85284
|
const drafts = input.annotations.map((a) => ({
|
|
83453
|
-
trackId:
|
|
83454
|
-
deviceId:
|
|
85285
|
+
trackId: frame.trackId,
|
|
85286
|
+
deviceId: frame.deviceId,
|
|
83455
85287
|
mediaKey: input.frameId,
|
|
83456
85288
|
bbox: a.bbox,
|
|
83457
85289
|
macroClass: a.macroClass,
|
|
@@ -83467,7 +85299,8 @@ var RetrainService = class {
|
|
|
83467
85299
|
this.deps.logger.info("retrain annotations saved", {
|
|
83468
85300
|
tags: { deviceId: input.deviceId },
|
|
83469
85301
|
meta: {
|
|
83470
|
-
|
|
85302
|
+
origin: frame.origin,
|
|
85303
|
+
trackId: frame.trackId,
|
|
83471
85304
|
frameId: input.frameId,
|
|
83472
85305
|
subjects: saved.length - errors,
|
|
83473
85306
|
modelErrors: errors
|
|
@@ -84493,7 +86326,8 @@ function selectsRow(mode, toLocationId, data, isRetrain, fromLocationId) {
|
|
|
84493
86326
|
if (mode === "seal") return rowIsUnstamped(data);
|
|
84494
86327
|
if (mediaRowLocation(data) === toLocationId) return false;
|
|
84495
86328
|
if (mode === "gallery") return !isRetrain && rowIsGallery(data);
|
|
84496
|
-
|
|
86329
|
+
if (isRetrain) return !rowIsDurableRetrainCopy(data);
|
|
86330
|
+
return !rowIsGallery(data);
|
|
84497
86331
|
}
|
|
84498
86332
|
var MediaRelocateEngine = class {
|
|
84499
86333
|
deps;
|
|
@@ -92868,6 +94702,14 @@ var RECORDED_STILL_PROFILE = "high";
|
|
|
92868
94702
|
*/
|
|
92869
94703
|
var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
|
|
92870
94704
|
/**
|
|
94705
|
+
* Width cap for a still added to the retrain dataset from the recording.
|
|
94706
|
+
*
|
|
94707
|
+
* Effectively none: a training frame is wanted at the camera's own resolution
|
|
94708
|
+
* (the same pixels a native key frame has), so the bound is only a ceiling
|
|
94709
|
+
* against a pathological stream, far above any camera on the fleet.
|
|
94710
|
+
*/
|
|
94711
|
+
var RETRAIN_STILL_MAX_WIDTH = 7680;
|
|
94712
|
+
/**
|
|
92871
94713
|
* Build the production ports.
|
|
92872
94714
|
*
|
|
92873
94715
|
* Every port swallows its own failure into the `null` the policy expects: a
|
|
@@ -97152,7 +98994,7 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
97152
98994
|
collection: RETRAIN_FRAMES_COLLECTION,
|
|
97153
98995
|
classification: {
|
|
97154
98996
|
kind: "never-orphaned",
|
|
97155
|
-
why: "the frame COPIES the dataset owns outright,
|
|
98997
|
+
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."
|
|
97156
98998
|
}
|
|
97157
98999
|
},
|
|
97158
99000
|
{
|
|
@@ -106563,6 +108405,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
106563
108405
|
/** The retrain curation surface — see `retrain/retrain-service.ts`. Null
|
|
106564
108406
|
* until the stores are built. */
|
|
106565
108407
|
retrain = null;
|
|
108408
|
+
retrainCollections = null;
|
|
108409
|
+
retrainLlmAssist = null;
|
|
108410
|
+
retrainCollectionStore = null;
|
|
106566
108411
|
retrainFrames = null;
|
|
106567
108412
|
retrainAnnotations = null;
|
|
106568
108413
|
/** The NC artifact plane (signed public links for notification media) and its
|
|
@@ -108419,6 +110264,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
108419
110264
|
resolveRoot: (locationId) => locationId === LEGACY_ADDON_DATA_GALLERY_LOCATION ? Promise.resolve(path.join(this.ctx.dataDir, "gallery")) : locationId === GALLERY_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
|
|
108420
110265
|
location: ARTIFACTS_MEDIA_LOCATION,
|
|
108421
110266
|
relativePath: GALLERY_TENANT_SUBPATH
|
|
110267
|
+
}) : locationId === RETRAIN_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
|
|
110268
|
+
location: ARTIFACTS_MEDIA_LOCATION,
|
|
110269
|
+
relativePath: ""
|
|
108422
110270
|
}) : this.ctx.api.storage.resolve.query({
|
|
108423
110271
|
location: locationId,
|
|
108424
110272
|
relativePath: ""
|
|
@@ -108597,7 +110445,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
108597
110445
|
logger: logger.child("RetrainFrames"),
|
|
108598
110446
|
now: () => Date.now(),
|
|
108599
110447
|
writeGate: this.mediaWriteGate,
|
|
108600
|
-
|
|
110448
|
+
measureChroma: (bytes) => chromaSpread(bytes),
|
|
108601
110449
|
readSource: async (mediaKey) => {
|
|
108602
110450
|
const file = await mediaStore.getById(mediaKey);
|
|
108603
110451
|
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
@@ -108611,7 +110459,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
108611
110459
|
});
|
|
108612
110460
|
this.retrainFrames = retrainFrames;
|
|
108613
110461
|
this.retrainAnnotations = retrainAnnotations;
|
|
108614
|
-
|
|
110462
|
+
const retrainCollectionStore = new RetrainCollectionStore({
|
|
110463
|
+
store: api.settingsStore,
|
|
110464
|
+
logger: logger.child("RetrainCollections"),
|
|
110465
|
+
now: () => Date.now(),
|
|
110466
|
+
newId: () => randomUUID()
|
|
110467
|
+
});
|
|
110468
|
+
this.retrainCollectionStore = retrainCollectionStore;
|
|
110469
|
+
const retrain = new RetrainService({
|
|
108615
110470
|
tracks: {
|
|
108616
110471
|
listStaging: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
|
|
108617
110472
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
@@ -108629,7 +110484,70 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
108629
110484
|
frames: retrainFrames,
|
|
108630
110485
|
annotations: retrainAnnotations,
|
|
108631
110486
|
runner: (input, nodeId) => nodeId === void 0 ? api.pipelineRunner.runStatelessStep.mutate(input) : api.pipelineRunner.runStatelessStep.mutate(input, nodePin(nodeId)),
|
|
108632
|
-
logger: logger.child("Retrain")
|
|
110487
|
+
logger: logger.child("Retrain"),
|
|
110488
|
+
pipelineEvidence: {
|
|
110489
|
+
tracksAround: (deviceId, atMs) => trackStore.queryHistorical({
|
|
110490
|
+
deviceId,
|
|
110491
|
+
since: atMs - PIPELINE_TRACK_LOOKBACK_MS,
|
|
110492
|
+
until: atMs + PIPELINE_BOX_TOLERANCE_MS,
|
|
110493
|
+
limit: PIPELINE_TRACK_AROUND_LIMIT
|
|
110494
|
+
}),
|
|
110495
|
+
subjectBoxes: (trackId, deviceId) => mediaStore.listSubjectBoxesByOwner("track", trackId, deviceId)
|
|
110496
|
+
},
|
|
110497
|
+
memberships: retrainCollectionStore
|
|
110498
|
+
});
|
|
110499
|
+
this.retrain = retrain;
|
|
110500
|
+
const retrainStills = new RecordedStillService(buildRecordedStillPorts({
|
|
110501
|
+
api: this.ctx.api.recordingArchive,
|
|
110502
|
+
logger: logger.child("RetrainStills"),
|
|
110503
|
+
resolveFfmpeg: this.pinnedFfmpeg
|
|
110504
|
+
}));
|
|
110505
|
+
this.retrainCollections = new RetrainCollectionService({
|
|
110506
|
+
collections: retrainCollectionStore,
|
|
110507
|
+
frames: retrainFrames,
|
|
110508
|
+
annotations: retrainAnnotations,
|
|
110509
|
+
retrain,
|
|
110510
|
+
captureStill: (deviceId, epochMs) => retrainStills.recover(deviceId, epochMs, RETRAIN_STILL_MAX_WIDTH),
|
|
110511
|
+
logger: logger.child("RetrainCollections")
|
|
110512
|
+
});
|
|
110513
|
+
this.retrainLlmAssist = new RetrainLlmAssistService({
|
|
110514
|
+
frames: retrainFrames,
|
|
110515
|
+
llm: {
|
|
110516
|
+
listProfiles: async () => (await api.llm.listProfiles.query({})).map((p) => ({
|
|
110517
|
+
id: p.id,
|
|
110518
|
+
name: p.name,
|
|
110519
|
+
model: p.model,
|
|
110520
|
+
kind: p.kind,
|
|
110521
|
+
...p.baseUrl !== void 0 ? { baseUrl: p.baseUrl } : {},
|
|
110522
|
+
enabled: p.enabled,
|
|
110523
|
+
supportsVision: p.supportsVision
|
|
110524
|
+
})),
|
|
110525
|
+
generateVision: async (request) => {
|
|
110526
|
+
const result = await api.llm.generateVision.mutate({
|
|
110527
|
+
profileId: request.profileId,
|
|
110528
|
+
consumer: RETRAIN_LLM_CONSUMER,
|
|
110529
|
+
system: request.system,
|
|
110530
|
+
prompt: request.prompt,
|
|
110531
|
+
jsonSchema: request.jsonSchema,
|
|
110532
|
+
images: [{
|
|
110533
|
+
bytes: new Uint8Array(request.image),
|
|
110534
|
+
mimeType: "image/jpeg"
|
|
110535
|
+
}],
|
|
110536
|
+
temperature: 0,
|
|
110537
|
+
timeoutMs: RETRAIN_LLM_TIMEOUT_MS
|
|
110538
|
+
});
|
|
110539
|
+
return result.ok ? {
|
|
110540
|
+
ok: true,
|
|
110541
|
+
text: result.text,
|
|
110542
|
+
model: result.model
|
|
110543
|
+
} : {
|
|
110544
|
+
ok: false,
|
|
110545
|
+
code: result.code,
|
|
110546
|
+
message: result.message
|
|
110547
|
+
};
|
|
110548
|
+
}
|
|
110549
|
+
},
|
|
110550
|
+
logger: logger.child("RetrainLlmAssist")
|
|
108633
110551
|
});
|
|
108634
110552
|
this.mediaRelocate = new MediaRelocateEngine({
|
|
108635
110553
|
store: api.settingsStore,
|
|
@@ -109813,14 +111731,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
109813
111731
|
};
|
|
109814
111732
|
return this.requireRetrain().propose({
|
|
109815
111733
|
deviceId: input.deviceId,
|
|
109816
|
-
trackId: input.trackId,
|
|
111734
|
+
...input.trackId !== void 0 ? { trackId: input.trackId } : {},
|
|
109817
111735
|
frameId: input.frameId,
|
|
109818
111736
|
subject,
|
|
109819
111737
|
...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
|
|
109820
111738
|
});
|
|
109821
111739
|
}
|
|
109822
111740
|
async listRetrainAnnotations(input) {
|
|
109823
|
-
return this.requireRetrain().listAnnotations(input
|
|
111741
|
+
return this.requireRetrain().listAnnotations(input);
|
|
109824
111742
|
}
|
|
109825
111743
|
async saveRetrainAnnotations(input) {
|
|
109826
111744
|
return this.requireRetrain().saveAnnotations(input);
|
|
@@ -109834,10 +111752,99 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
109834
111752
|
/** Where to stream the ANNOTATED dataset from. Bytes never cross the cap. */
|
|
109835
111753
|
async getRetrainExportUrl(input) {
|
|
109836
111754
|
if (this.retrainExportDataPlane === null) throw new Error("retrain export: data plane unavailable on this node");
|
|
109837
|
-
const
|
|
109838
|
-
const query =
|
|
111755
|
+
const { layout, ...filter } = input;
|
|
111756
|
+
const query = retrainExportQuery({
|
|
111757
|
+
filter,
|
|
111758
|
+
layout: layout ?? "frames"
|
|
111759
|
+
});
|
|
109839
111760
|
return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
|
|
109840
111761
|
}
|
|
111762
|
+
async setRetrainFrameComplete(input) {
|
|
111763
|
+
return this.requireRetrain().setFrameComplete(input);
|
|
111764
|
+
}
|
|
111765
|
+
requireRetrainLlmAssist() {
|
|
111766
|
+
const service = this.retrainLlmAssist;
|
|
111767
|
+
if (service === null) throw new Error("retrain AI assist: analytics stores unavailable");
|
|
111768
|
+
return service;
|
|
111769
|
+
}
|
|
111770
|
+
async listRetrainAssistProfiles(_input) {
|
|
111771
|
+
return this.requireRetrainLlmAssist().listProfiles();
|
|
111772
|
+
}
|
|
111773
|
+
async proposeRetrainAnnotationsWithLlm(input) {
|
|
111774
|
+
return this.requireRetrainLlmAssist().propose(input);
|
|
111775
|
+
}
|
|
111776
|
+
requireRetrainCollections() {
|
|
111777
|
+
const service = this.retrainCollections;
|
|
111778
|
+
if (service === null) throw new Error("retrain collections: analytics stores unavailable");
|
|
111779
|
+
return service;
|
|
111780
|
+
}
|
|
111781
|
+
async createRetrainCollection(input) {
|
|
111782
|
+
return this.requireRetrainCollections().create(input);
|
|
111783
|
+
}
|
|
111784
|
+
async listRetrainCollections(_input) {
|
|
111785
|
+
return this.requireRetrainCollections().list();
|
|
111786
|
+
}
|
|
111787
|
+
async renameRetrainCollection(input) {
|
|
111788
|
+
return this.requireRetrainCollections().rename(input);
|
|
111789
|
+
}
|
|
111790
|
+
async deleteRetrainCollection(input) {
|
|
111791
|
+
return this.requireRetrainCollections().delete(input);
|
|
111792
|
+
}
|
|
111793
|
+
async addRetrainFrameFromRecording(input) {
|
|
111794
|
+
return this.requireRetrainCollections().addFrameFromRecording(input);
|
|
111795
|
+
}
|
|
111796
|
+
async addRetrainFramesToCollection(input) {
|
|
111797
|
+
return this.requireRetrainCollections().addFrames(input);
|
|
111798
|
+
}
|
|
111799
|
+
async removeRetrainFrameFromCollection(input) {
|
|
111800
|
+
return this.requireRetrainCollections().removeFrame(input);
|
|
111801
|
+
}
|
|
111802
|
+
async listRetrainCollectionFrames(input) {
|
|
111803
|
+
return this.requireRetrainCollections().listFrames(input);
|
|
111804
|
+
}
|
|
111805
|
+
/**
|
|
111806
|
+
* Stream the annotated archive into ONE HTTP request to `target` — the
|
|
111807
|
+
* training upload. Same bytes as the download; never on the hub's disk.
|
|
111808
|
+
*/
|
|
111809
|
+
async writeRetrainArchive(input) {
|
|
111810
|
+
const frameStore = this.retrainFrames;
|
|
111811
|
+
if (frameStore === null) throw new Error("retrain export: analytics stores unavailable");
|
|
111812
|
+
const plan = await this.buildRetrainExportPlan({
|
|
111813
|
+
filter: input.filter,
|
|
111814
|
+
layout: input.layout
|
|
111815
|
+
});
|
|
111816
|
+
const upload = openRetrainUploadSink(input.target);
|
|
111817
|
+
const host = new URL(input.target.url).host;
|
|
111818
|
+
try {
|
|
111819
|
+
const stats = await writeRetrainArchiveTo({
|
|
111820
|
+
plan,
|
|
111821
|
+
readFrame: (frame) => frameStore.readFrame(frame),
|
|
111822
|
+
cutCrop: this.retrainCropCutter(),
|
|
111823
|
+
sink: upload.sink,
|
|
111824
|
+
markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
|
|
111825
|
+
logger: this.ctx.logger,
|
|
111826
|
+
now: () => Date.now()
|
|
111827
|
+
});
|
|
111828
|
+
const status = await upload.finish();
|
|
111829
|
+
this.ctx.logger.info("retrain archive uploaded", { meta: {
|
|
111830
|
+
host,
|
|
111831
|
+
status,
|
|
111832
|
+
...stats,
|
|
111833
|
+
layout: input.layout
|
|
111834
|
+
} });
|
|
111835
|
+
return {
|
|
111836
|
+
...stats,
|
|
111837
|
+
status
|
|
111838
|
+
};
|
|
111839
|
+
} catch (err) {
|
|
111840
|
+
upload.abort(errMsg(err));
|
|
111841
|
+
this.ctx.logger.warn("retrain archive upload failed", { meta: {
|
|
111842
|
+
host,
|
|
111843
|
+
error: errMsg(err)
|
|
111844
|
+
} });
|
|
111845
|
+
throw err;
|
|
111846
|
+
}
|
|
111847
|
+
}
|
|
109841
111848
|
/**
|
|
109842
111849
|
* The store seam the `debug.*` actions and the debug bundle both read through.
|
|
109843
111850
|
*
|
|
@@ -110119,15 +112126,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
110119
112126
|
async serveRetrainExportPlane() {
|
|
110120
112127
|
try {
|
|
110121
112128
|
const handler = createRetrainExportHandler({
|
|
110122
|
-
buildPlan: (
|
|
112129
|
+
buildPlan: (request) => this.buildRetrainExportPlan(request),
|
|
110123
112130
|
readFrame: (frame) => this.retrainFrames?.readFrame(frame) ?? Promise.resolve(null),
|
|
110124
|
-
|
|
110125
|
-
|
|
110126
|
-
if (!store || frameIds.length === 0) return;
|
|
110127
|
-
const ids = [];
|
|
110128
|
-
for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
|
|
110129
|
-
await store.markExported(ids, batchId);
|
|
110130
|
-
},
|
|
112131
|
+
cutCrop: this.retrainCropCutter(),
|
|
112132
|
+
markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
|
|
110131
112133
|
logger: this.ctx.logger,
|
|
110132
112134
|
now: () => Date.now()
|
|
110133
112135
|
});
|
|
@@ -110149,13 +112151,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
110149
112151
|
* reference rectangle is implicit is a box nobody can check. A camera with no
|
|
110150
112152
|
* zones simply has no `zoneCrop` geometry — absent, never a clamped guess.
|
|
110151
112153
|
*/
|
|
110152
|
-
async buildRetrainExportPlan(
|
|
112154
|
+
async buildRetrainExportPlan(request) {
|
|
110153
112155
|
const frameStore = this.retrainFrames;
|
|
110154
112156
|
const annotationStore = this.retrainAnnotations;
|
|
110155
112157
|
const trackStore = this.trackStore;
|
|
110156
112158
|
if (!frameStore || !annotationStore || !trackStore) throw new Error("retrain export: analytics stores unavailable");
|
|
110157
|
-
const
|
|
110158
|
-
const
|
|
112159
|
+
const deviceIds = request.filter.deviceIds ?? [];
|
|
112160
|
+
const devices = deviceIds.length > 0 ? deviceIds : [...new Set([...await trackStore.listDeviceIds(), ...await frameStore.listDeviceIds()])];
|
|
112161
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
110159
112162
|
const annotationsByFrame = /* @__PURE__ */ new Map();
|
|
110160
112163
|
const zones = /* @__PURE__ */ new Map();
|
|
110161
112164
|
for (const deviceId of devices) {
|
|
@@ -110164,22 +112167,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
110164
112167
|
const existing = annotationsByFrame.get(annotation.mediaKey) ?? [];
|
|
110165
112168
|
annotationsByFrame.set(annotation.mediaKey, [...existing, annotation]);
|
|
110166
112169
|
}
|
|
112170
|
+
for (const frame of await frameStore.listCompleteByDevice(deviceId, RETRAIN_EXPORT_MAX)) candidates.set(frame.frameId, frame);
|
|
110167
112171
|
}
|
|
110168
112172
|
for (const frameId of annotationsByFrame.keys()) {
|
|
112173
|
+
if (candidates.has(frameId)) continue;
|
|
110169
112174
|
const frame = await frameStore.get(frameId);
|
|
110170
|
-
if (frame !== null)
|
|
112175
|
+
if (frame !== null) candidates.set(frameId, frame);
|
|
110171
112176
|
}
|
|
112177
|
+
const collectionsByFrame = await this.retrainCollectionStore?.collectionsOf([...candidates.keys()]) ?? /* @__PURE__ */ new Map();
|
|
112178
|
+
const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter, collectionsByFrame);
|
|
110172
112179
|
return {
|
|
110173
112180
|
manifest: buildRetrainManifest({
|
|
110174
112181
|
frames,
|
|
110175
112182
|
annotationsByFrame,
|
|
112183
|
+
collectionsByFrame,
|
|
110176
112184
|
zoneForDevice: (deviceId) => zones.get(deviceId) ?? null,
|
|
110177
112185
|
generatedAt: Date.now(),
|
|
110178
|
-
batchId: randomUUID()
|
|
112186
|
+
batchId: randomUUID(),
|
|
112187
|
+
layout: request.layout,
|
|
112188
|
+
coordinates: await this.siteCoordinates()
|
|
110179
112189
|
}),
|
|
110180
112190
|
frames
|
|
110181
112191
|
};
|
|
110182
112192
|
}
|
|
112193
|
+
/** Stamp the batch on the annotations of the frames an archive carried. */
|
|
112194
|
+
async markRetrainExported(frameIds, batchId) {
|
|
112195
|
+
const store = this.retrainAnnotations;
|
|
112196
|
+
if (!store || frameIds.length === 0) return;
|
|
112197
|
+
const ids = [];
|
|
112198
|
+
for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
|
|
112199
|
+
await store.markExported(ids, batchId);
|
|
112200
|
+
}
|
|
112201
|
+
/** The `crops` layout's cutter, with the convention the manifest records. */
|
|
112202
|
+
retrainCropCutter() {
|
|
112203
|
+
return (frameBytes, frame, annotation) => cutRetrainCrop({
|
|
112204
|
+
frameBytes,
|
|
112205
|
+
frameWidth: frame.width,
|
|
112206
|
+
frameHeight: frame.height,
|
|
112207
|
+
bbox: annotation.bbox,
|
|
112208
|
+
convention: RETRAIN_CROP_CONVENTION,
|
|
112209
|
+
maxSide: RETRAIN_CROP_MAX_SIDE
|
|
112210
|
+
}).catch((err) => {
|
|
112211
|
+
this.ctx.logger.warn("retrain crop could not be cut", {
|
|
112212
|
+
tags: { deviceId: frame.deviceId },
|
|
112213
|
+
meta: {
|
|
112214
|
+
frameId: frame.frameId,
|
|
112215
|
+
annotationId: annotation.id,
|
|
112216
|
+
error: errMsg(err)
|
|
112217
|
+
}
|
|
112218
|
+
});
|
|
112219
|
+
return null;
|
|
112220
|
+
});
|
|
112221
|
+
}
|
|
110183
112222
|
/** Bounding box of a camera's zones, or null when it declares none. */
|
|
110184
112223
|
async retrainZoneForDevice(deviceId) {
|
|
110185
112224
|
try {
|
|
@@ -110327,6 +112366,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
110327
112366
|
}, LTS_FLUSH_INTERVAL_MS);
|
|
110328
112367
|
this.startOrphanAuditTimer();
|
|
110329
112368
|
this.reconcileGalleryMediaLocation();
|
|
112369
|
+
this.reconcileRetrainCopiesLocation();
|
|
112370
|
+
}
|
|
112371
|
+
/**
|
|
112372
|
+
* Move pre-D781 retrain copies off the footage class onto the durable
|
|
112373
|
+
* `retrainMedia` tenant, and say how many have no bytes left.
|
|
112374
|
+
*/
|
|
112375
|
+
async reconcileRetrainCopiesLocation() {
|
|
112376
|
+
const retrainFrames = this.retrainFrames;
|
|
112377
|
+
if (!retrainFrames) return;
|
|
112378
|
+
try {
|
|
112379
|
+
const result = await retrainFrames.relocateLegacyCopies();
|
|
112380
|
+
if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
|
|
112381
|
+
this.ctx.logger.info("retrain copies reconciled onto the durable retrain tenant", { meta: {
|
|
112382
|
+
...result,
|
|
112383
|
+
location: RETRAIN_MEDIA_LOCATION
|
|
112384
|
+
} });
|
|
112385
|
+
} catch (err) {
|
|
112386
|
+
this.ctx.logger.warn("retrain copies reconcile failed — the copies stay on eventMedia", { meta: {
|
|
112387
|
+
error: errMsg(err),
|
|
112388
|
+
location: ARTIFACTS_MEDIA_LOCATION
|
|
112389
|
+
} });
|
|
112390
|
+
}
|
|
110330
112391
|
}
|
|
110331
112392
|
/**
|
|
110332
112393
|
* Move enrolled-gallery blobs off the footage volume, and say out loud how
|