@trafficgroup/knex-rel 0.1.3 → 0.1.5
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/dao/VideoMinuteResultDAO.d.ts +0 -4
- package/dist/dao/VideoMinuteResultDAO.js +7 -48
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/camera/camera.dao.d.ts +8 -1
- package/dist/dao/camera/camera.dao.js +27 -8
- package/dist/dao/camera/camera.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.d.ts +94 -0
- package/dist/dao/report-configuration/report-configuration.dao.js +352 -0
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -0
- package/dist/dao/video/video.dao.d.ts +10 -0
- package/dist/dao/video/video.dao.js +40 -16
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/report-configuration/report-configuration.interfaces.d.ts +26 -0
- package/dist/interfaces/report-configuration/report-configuration.interfaces.js +3 -0
- package/dist/interfaces/report-configuration/report-configuration.interfaces.js.map +1 -0
- package/migrations/20250930200521_migration.ts +52 -0
- package/package.json +1 -1
- package/plan.md +755 -212
- package/src/dao/VideoMinuteResultDAO.ts +7 -64
- package/src/dao/camera/camera.dao.ts +30 -10
- package/src/dao/report-configuration/report-configuration.dao.ts +402 -0
- package/src/dao/video/video.dao.ts +46 -18
- package/src/index.ts +8 -0
- package/src/interfaces/report-configuration/report-configuration.interfaces.ts +30 -0
- package/cameras_analysis.md +0 -199
- package/folder_cameraid_analysis.md +0 -167
- package/migrations/20250924000000_camera_name_search_index.ts +0 -22
|
@@ -615,11 +615,11 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
615
615
|
|
|
616
616
|
// Aggregate vehicle counts by class and lane
|
|
617
617
|
if (results.vehicles) {
|
|
618
|
-
for (const [
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
618
|
+
for (const [vehicleClass, lanes] of Object.entries(results.vehicles)) {
|
|
619
|
+
// Skip 'total' pseudo-class if present
|
|
620
|
+
if (vehicleClass === "total") {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
623
|
|
|
624
624
|
if (!aggregated.vehicles[vehicleClass]) {
|
|
625
625
|
aggregated.vehicles[vehicleClass] = {};
|
|
@@ -645,12 +645,9 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
645
645
|
// Aggregate total count
|
|
646
646
|
aggregated.total_count += results.total_count || 0;
|
|
647
647
|
|
|
648
|
-
// Aggregate detected classes
|
|
648
|
+
// Aggregate detected classes (use raw detection labels)
|
|
649
649
|
if (results.detected_classes) {
|
|
650
|
-
for (const [
|
|
651
|
-
results.detected_classes,
|
|
652
|
-
)) {
|
|
653
|
-
const cls = this.normalizeATRVehicleClass(rawCls);
|
|
650
|
+
for (const [cls, count] of Object.entries(results.detected_classes)) {
|
|
654
651
|
aggregated.detected_classes[cls] =
|
|
655
652
|
(aggregated.detected_classes[cls] || 0) + ((count as number) || 0);
|
|
656
653
|
}
|
|
@@ -715,60 +712,6 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
|
|
|
715
712
|
return cloned;
|
|
716
713
|
}
|
|
717
714
|
|
|
718
|
-
/**
|
|
719
|
-
* Normalize ATR vehicle class names to standard format for frontend compatibility
|
|
720
|
-
*/
|
|
721
|
-
private normalizeATRVehicleClass(rawVehicleClass: string): string {
|
|
722
|
-
const normalized = rawVehicleClass.toLowerCase().replace(/[_\s-]/g, "");
|
|
723
|
-
|
|
724
|
-
// Map raw vehicle classes to standard classes
|
|
725
|
-
if (
|
|
726
|
-
normalized.includes("car") ||
|
|
727
|
-
normalized === "vehicle" ||
|
|
728
|
-
normalized === "automobiles"
|
|
729
|
-
) {
|
|
730
|
-
return "cars";
|
|
731
|
-
}
|
|
732
|
-
if (
|
|
733
|
-
normalized.includes("medium") ||
|
|
734
|
-
normalized.includes("pickup") ||
|
|
735
|
-
(normalized.includes("truck") && !normalized.includes("heavy"))
|
|
736
|
-
) {
|
|
737
|
-
return "mediums";
|
|
738
|
-
}
|
|
739
|
-
if (
|
|
740
|
-
normalized.includes("heavy") ||
|
|
741
|
-
normalized.includes("largetruck") ||
|
|
742
|
-
normalized.includes("bigtruck")
|
|
743
|
-
) {
|
|
744
|
-
return "heavy_trucks";
|
|
745
|
-
}
|
|
746
|
-
if (
|
|
747
|
-
normalized.includes("pedestrian") ||
|
|
748
|
-
normalized.includes("person") ||
|
|
749
|
-
normalized.includes("people")
|
|
750
|
-
) {
|
|
751
|
-
return "pedestrians";
|
|
752
|
-
}
|
|
753
|
-
if (
|
|
754
|
-
normalized.includes("bicycle") ||
|
|
755
|
-
normalized.includes("bike") ||
|
|
756
|
-
normalized.includes("cyclist")
|
|
757
|
-
) {
|
|
758
|
-
return "bicycles";
|
|
759
|
-
}
|
|
760
|
-
if (normalized.includes("total") || normalized.includes("all")) {
|
|
761
|
-
return "total";
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// Handle specific known ATR classes
|
|
765
|
-
if (rawVehicleClass === "mediums") return "mediums";
|
|
766
|
-
if (rawVehicleClass === "heavy_trucks") return "heavy_trucks";
|
|
767
|
-
|
|
768
|
-
// Default fallback for unknown classes
|
|
769
|
-
return "cars";
|
|
770
|
-
}
|
|
771
|
-
|
|
772
715
|
/**
|
|
773
716
|
* Format time label for display (HH:MM:SS format)
|
|
774
717
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Knex } from "knex";
|
|
2
2
|
import { IBaseDAO, IDataPaginator } from "../../d.types";
|
|
3
3
|
import { ICamera } from "../../interfaces/camera/camera.interfaces";
|
|
4
|
+
import { IVideo } from "../../interfaces/video/video.interfaces";
|
|
4
5
|
import KnexManager from "../../KnexConnection";
|
|
5
6
|
|
|
6
7
|
export class CameraDAO implements IBaseDAO<ICamera> {
|
|
@@ -77,6 +78,9 @@ export class CameraDAO implements IBaseDAO<ICamera> {
|
|
|
77
78
|
return cameras;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Get all cameras with optional search filter by name (case-insensitive partial match)
|
|
83
|
+
*/
|
|
80
84
|
async getAllWithSearch(
|
|
81
85
|
page: number,
|
|
82
86
|
limit: number,
|
|
@@ -84,15 +88,21 @@ export class CameraDAO implements IBaseDAO<ICamera> {
|
|
|
84
88
|
): Promise<IDataPaginator<ICamera>> {
|
|
85
89
|
const offset = (page - 1) * limit;
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
let query = this._knex("cameras");
|
|
88
92
|
|
|
89
|
-
if
|
|
90
|
-
|
|
93
|
+
// Apply search filter if name provided (escape special chars to prevent pattern injection)
|
|
94
|
+
if (name && name.trim().length > 0) {
|
|
95
|
+
const escapedName = name.trim().replace(/[%_\\]/g, "\\$&");
|
|
96
|
+
query = query.where("name", "ilike", `%${escapedName}%`);
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
const [countResult] = await query.clone().count("* as count");
|
|
94
100
|
const totalCount = +countResult.count;
|
|
95
|
-
const cameras = await query
|
|
101
|
+
const cameras = await query
|
|
102
|
+
.clone()
|
|
103
|
+
.limit(limit)
|
|
104
|
+
.offset(offset)
|
|
105
|
+
.orderBy("name", "asc");
|
|
96
106
|
|
|
97
107
|
return {
|
|
98
108
|
success: true,
|
|
@@ -105,21 +115,31 @@ export class CameraDAO implements IBaseDAO<ICamera> {
|
|
|
105
115
|
};
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Get paginated videos for a specific camera with folder data included
|
|
120
|
+
*/
|
|
108
121
|
async getVideosByCamera(
|
|
109
122
|
cameraId: number,
|
|
110
123
|
page: number,
|
|
111
124
|
limit: number,
|
|
112
|
-
): Promise<IDataPaginator<
|
|
125
|
+
): Promise<IDataPaginator<IVideo>> {
|
|
113
126
|
const offset = (page - 1) * limit;
|
|
114
127
|
|
|
115
|
-
const query = this._knex("
|
|
128
|
+
const query = this._knex("video as v")
|
|
116
129
|
.innerJoin("folders as f", "v.folderId", "f.id")
|
|
117
|
-
.
|
|
118
|
-
.
|
|
130
|
+
.select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
|
|
131
|
+
.where("v.cameraId", cameraId);
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
// Optimized count query without JOIN
|
|
134
|
+
const [countResult] = await this._knex("video as v")
|
|
135
|
+
.where("v.cameraId", cameraId)
|
|
136
|
+
.count("* as count");
|
|
121
137
|
const totalCount = +countResult.count;
|
|
122
|
-
const videos = await query
|
|
138
|
+
const videos = await query
|
|
139
|
+
.clone()
|
|
140
|
+
.limit(limit)
|
|
141
|
+
.offset(offset)
|
|
142
|
+
.orderBy("v.created_at", "desc");
|
|
123
143
|
|
|
124
144
|
return {
|
|
125
145
|
success: true,
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { Knex } from "knex";
|
|
2
|
+
import { IBaseDAO, IDataPaginator } from "../../d.types";
|
|
3
|
+
import {
|
|
4
|
+
IReportConfiguration,
|
|
5
|
+
IReportConfigurationData,
|
|
6
|
+
IReportConfigurationInput,
|
|
7
|
+
IValidationResult,
|
|
8
|
+
} from "../../interfaces/report-configuration/report-configuration.interfaces";
|
|
9
|
+
import KnexManager from "../../KnexConnection";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mapping from detection labels to FHWA vehicle classes (1-13)
|
|
13
|
+
*
|
|
14
|
+
* FHWA Classes:
|
|
15
|
+
* - Class 1: Motorcycles
|
|
16
|
+
* - Class 2: Cars (passenger cars)
|
|
17
|
+
* - Class 3: Pickup trucks, vans, motorized vehicles
|
|
18
|
+
* - Class 4: Buses
|
|
19
|
+
* - Class 5: Work vans (2-axle, 6-tire single units)
|
|
20
|
+
* - Classes 6-8: Single unit trucks
|
|
21
|
+
* - Classes 9-13: Articulated trucks (semi-trailers, multi-trailers)
|
|
22
|
+
*
|
|
23
|
+
* Non-motorized vehicles (pedestrian, bicycle, non_motorized_vehicle) are EXCLUDED
|
|
24
|
+
*/
|
|
25
|
+
const DETECTION_LABEL_TO_FHWA: Record<string, number[]> = {
|
|
26
|
+
motorcycle: [1],
|
|
27
|
+
car: [2],
|
|
28
|
+
pickup_truck: [3],
|
|
29
|
+
motorized_vehicle: [3], // Maps to Class 3 (same as pickup_truck)
|
|
30
|
+
bus: [4],
|
|
31
|
+
work_van: [5],
|
|
32
|
+
single_unit_truck: [6, 7, 8], // Classes 6-8
|
|
33
|
+
articulated_truck: [9, 10, 11, 12, 13], // Classes 9-13
|
|
34
|
+
// pedestrian, bicycle, non_motorized_vehicle are EXCLUDED
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class ReportConfigurationDAO implements IBaseDAO<IReportConfiguration> {
|
|
38
|
+
private _knex: Knex<any, unknown[]> = KnexManager.getConnection();
|
|
39
|
+
private tableName = "report_configurations";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a new report configuration
|
|
43
|
+
*/
|
|
44
|
+
async create(item: IReportConfigurationInput): Promise<IReportConfiguration> {
|
|
45
|
+
// Validate configuration before creating
|
|
46
|
+
const validation = this.validateConfiguration(item.configuration);
|
|
47
|
+
if (!validation.valid) {
|
|
48
|
+
throw new Error(`Invalid configuration: ${validation.errors.join(", ")}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [createdConfig] = await this._knex(this.tableName)
|
|
52
|
+
.insert({
|
|
53
|
+
name: item.name,
|
|
54
|
+
description: item.description,
|
|
55
|
+
configuration: JSON.stringify(item.configuration),
|
|
56
|
+
})
|
|
57
|
+
.returning("*");
|
|
58
|
+
|
|
59
|
+
return this._deserialize(createdConfig);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get configuration by numeric ID
|
|
64
|
+
*/
|
|
65
|
+
async getById(id: number): Promise<IReportConfiguration | null> {
|
|
66
|
+
const config = await this._knex(this.tableName).where({ id }).first();
|
|
67
|
+
return config ? this._deserialize(config) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get configuration by UUID
|
|
72
|
+
*/
|
|
73
|
+
async getByUuid(uuid: string): Promise<IReportConfiguration | null> {
|
|
74
|
+
const config = await this._knex(this.tableName).where({ uuid }).first();
|
|
75
|
+
return config ? this._deserialize(config) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get configuration by name
|
|
80
|
+
*/
|
|
81
|
+
async getByName(name: string): Promise<IReportConfiguration | null> {
|
|
82
|
+
const config = await this._knex(this.tableName)
|
|
83
|
+
.whereRaw("LOWER(name) = LOWER(?)", [name])
|
|
84
|
+
.first();
|
|
85
|
+
return config ? this._deserialize(config) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update a configuration
|
|
90
|
+
*/
|
|
91
|
+
async update(
|
|
92
|
+
id: number,
|
|
93
|
+
item: Partial<IReportConfigurationInput>,
|
|
94
|
+
): Promise<IReportConfiguration | null> {
|
|
95
|
+
// If configuration is being updated, validate it
|
|
96
|
+
if (item.configuration) {
|
|
97
|
+
const validation = this.validateConfiguration(item.configuration);
|
|
98
|
+
if (!validation.valid) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Invalid configuration: ${validation.errors.join(", ")}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const updateData: any = {};
|
|
106
|
+
if (item.name !== undefined) updateData.name = item.name;
|
|
107
|
+
if (item.description !== undefined)
|
|
108
|
+
updateData.description = item.description;
|
|
109
|
+
if (item.configuration !== undefined)
|
|
110
|
+
updateData.configuration = JSON.stringify(item.configuration);
|
|
111
|
+
|
|
112
|
+
const [updatedConfig] = await this._knex(this.tableName)
|
|
113
|
+
.where({ id })
|
|
114
|
+
.update(updateData)
|
|
115
|
+
.returning("*");
|
|
116
|
+
|
|
117
|
+
return updatedConfig ? this._deserialize(updatedConfig) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Delete a configuration
|
|
122
|
+
* Prevents deletion of the last configuration (business logic protection)
|
|
123
|
+
*/
|
|
124
|
+
async delete(id: number): Promise<boolean> {
|
|
125
|
+
// Count total configurations
|
|
126
|
+
const [{ count }] = await this._knex(this.tableName).count("* as count");
|
|
127
|
+
|
|
128
|
+
if (parseInt(count as string) <= 1) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"Cannot delete the last configuration. At least one configuration must exist.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = await this._knex(this.tableName).where({ id }).del();
|
|
135
|
+
return result > 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all configurations with pagination
|
|
140
|
+
*/
|
|
141
|
+
async getAll(
|
|
142
|
+
page: number,
|
|
143
|
+
limit: number,
|
|
144
|
+
): Promise<IDataPaginator<IReportConfiguration>> {
|
|
145
|
+
const offset = (page - 1) * limit;
|
|
146
|
+
|
|
147
|
+
const [countResult] = await this._knex(this.tableName).count("* as count");
|
|
148
|
+
const totalCount = +countResult.count;
|
|
149
|
+
const configs = await this._knex(this.tableName)
|
|
150
|
+
.limit(limit)
|
|
151
|
+
.offset(offset)
|
|
152
|
+
.orderBy("created_at", "desc");
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
data: configs.map((c) => this._deserialize(c)),
|
|
157
|
+
page,
|
|
158
|
+
limit,
|
|
159
|
+
count: configs.length,
|
|
160
|
+
totalCount,
|
|
161
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate a report configuration
|
|
167
|
+
*
|
|
168
|
+
* Rules:
|
|
169
|
+
* - Minimum 2, maximum 7 custom classes
|
|
170
|
+
* - Custom class names must be 1-30 characters
|
|
171
|
+
* - FHWA classes must be in range 1-13
|
|
172
|
+
* - Each FHWA class can only be mapped to one custom class (no duplicates)
|
|
173
|
+
* - Each custom class must have at least one FHWA class
|
|
174
|
+
*/
|
|
175
|
+
validateConfiguration(config: IReportConfigurationData): IValidationResult {
|
|
176
|
+
const errors: string[] = [];
|
|
177
|
+
|
|
178
|
+
// Validate version exists
|
|
179
|
+
if (!config.version) {
|
|
180
|
+
errors.push("Configuration version is required");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate custom classes array
|
|
184
|
+
if (!config.customClasses || !Array.isArray(config.customClasses)) {
|
|
185
|
+
errors.push("customClasses must be an array");
|
|
186
|
+
return { valid: false, errors };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Min 2, max 7 custom classes
|
|
190
|
+
if (config.customClasses.length < 2) {
|
|
191
|
+
errors.push("Minimum 2 custom classes required");
|
|
192
|
+
}
|
|
193
|
+
if (config.customClasses.length > 7) {
|
|
194
|
+
errors.push("Maximum 7 custom classes allowed");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check name length (max 30 chars) and FHWA classes validity
|
|
198
|
+
const allFhwaClasses: number[] = [];
|
|
199
|
+
config.customClasses.forEach((cls, idx) => {
|
|
200
|
+
if (!cls.name || cls.name.length === 0) {
|
|
201
|
+
errors.push(`Custom class ${idx + 1}: name cannot be empty`);
|
|
202
|
+
}
|
|
203
|
+
if (cls.name && cls.name.length > 30) {
|
|
204
|
+
errors.push(`Custom class ${idx + 1}: name exceeds 30 characters`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!Array.isArray(cls.fhwaClasses) || cls.fhwaClasses.length === 0) {
|
|
208
|
+
errors.push(
|
|
209
|
+
`Custom class ${idx + 1}: must have at least one FHWA class`,
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
cls.fhwaClasses.forEach((fhwa) => {
|
|
213
|
+
if (!Number.isInteger(fhwa) || fhwa < 1 || fhwa > 13) {
|
|
214
|
+
errors.push(
|
|
215
|
+
`Custom class ${idx + 1}: FHWA class ${fhwa} is invalid (must be 1-13)`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
allFhwaClasses.push(fhwa);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Check for duplicate FHWA classes (many-to-one only)
|
|
224
|
+
const uniqueFhwaClasses = new Set(allFhwaClasses);
|
|
225
|
+
if (uniqueFhwaClasses.size !== allFhwaClasses.length) {
|
|
226
|
+
const duplicates = allFhwaClasses.filter(
|
|
227
|
+
(item, index) => allFhwaClasses.indexOf(item) !== index,
|
|
228
|
+
);
|
|
229
|
+
errors.push(
|
|
230
|
+
`Duplicate FHWA classes detected: ${[...new Set(duplicates)].join(", ")}. Each FHWA class can only be mapped to one custom class.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { valid: errors.length === 0, errors };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Apply configuration transformation to detection results
|
|
239
|
+
*
|
|
240
|
+
* Two-step transformation:
|
|
241
|
+
* 1. Detection labels → FHWA classes (using DETECTION_LABEL_TO_FHWA mapping)
|
|
242
|
+
* 2. FHWA classes → Custom classes (using configuration)
|
|
243
|
+
*
|
|
244
|
+
* @param detectionResults - Raw detection results with labels as keys and counts as values
|
|
245
|
+
* Example: { 'car': 150, 'articulated_truck': 23, 'motorcycle': 5 }
|
|
246
|
+
* @param config - The report configuration to apply
|
|
247
|
+
* @returns Transformed results with custom class names as keys and counts as values
|
|
248
|
+
* Example: { 'Cars': 155, 'Heavy Trucks': 23 }
|
|
249
|
+
*/
|
|
250
|
+
applyConfiguration(
|
|
251
|
+
detectionResults: Record<string, number>,
|
|
252
|
+
config: IReportConfiguration,
|
|
253
|
+
): Record<string, number> {
|
|
254
|
+
// Step 1: Detection labels → FHWA classes
|
|
255
|
+
const fhwaClassCounts: Record<number, number> = {};
|
|
256
|
+
|
|
257
|
+
for (const [label, count] of Object.entries(detectionResults)) {
|
|
258
|
+
const fhwaClasses = DETECTION_LABEL_TO_FHWA[label];
|
|
259
|
+
if (fhwaClasses && fhwaClasses.length > 0) {
|
|
260
|
+
fhwaClasses.forEach((fhwaClass) => {
|
|
261
|
+
fhwaClassCounts[fhwaClass] =
|
|
262
|
+
(fhwaClassCounts[fhwaClass] || 0) + count;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// Labels not in DETECTION_LABEL_TO_FHWA are silently ignored (e.g., pedestrian, bicycle)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Step 2: FHWA classes → Custom classes
|
|
269
|
+
const customClassCounts: Record<string, number> = {};
|
|
270
|
+
|
|
271
|
+
config.configuration.customClasses.forEach((customClass) => {
|
|
272
|
+
let total = 0;
|
|
273
|
+
customClass.fhwaClasses.forEach((fhwaClass) => {
|
|
274
|
+
total += fhwaClassCounts[fhwaClass] || 0;
|
|
275
|
+
});
|
|
276
|
+
customClassCounts[customClass.name] = total;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return customClassCounts;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Transform nested vehicle structure with custom class mapping
|
|
284
|
+
*
|
|
285
|
+
* Handles both ATR (lane-based) and TMC (direction/turn-based) formats
|
|
286
|
+
* Preserves all nesting levels while transforming detection labels to custom classes
|
|
287
|
+
*
|
|
288
|
+
* @param vehiclesStructure - Nested vehicles object with detection labels as keys
|
|
289
|
+
* ATR: { "car": { "0": 45, "1": 50 }, ... }
|
|
290
|
+
* TMC: { "car": { "NORTH": { "straight": 10 }, ... }, ... }
|
|
291
|
+
* @param config - Report configuration with custom class mappings
|
|
292
|
+
* @returns Transformed structure with custom class names as keys
|
|
293
|
+
*/
|
|
294
|
+
applyConfigurationToNestedStructure(
|
|
295
|
+
vehiclesStructure: Record<string, any>,
|
|
296
|
+
config: IReportConfiguration,
|
|
297
|
+
): Record<string, any> {
|
|
298
|
+
// Build reverse mapping: detection label → custom class name
|
|
299
|
+
const detectionToCustomClass: Record<string, string> = {};
|
|
300
|
+
|
|
301
|
+
for (const customClass of config.configuration.customClasses) {
|
|
302
|
+
// For each FHWA class in this custom class
|
|
303
|
+
for (const fhwaClass of customClass.fhwaClasses) {
|
|
304
|
+
// Find all detection labels that map to this FHWA class
|
|
305
|
+
for (const [label, fhwaClasses] of Object.entries(
|
|
306
|
+
DETECTION_LABEL_TO_FHWA,
|
|
307
|
+
)) {
|
|
308
|
+
if (fhwaClasses.includes(fhwaClass)) {
|
|
309
|
+
detectionToCustomClass[label] = customClass.name;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Initialize empty structure for each custom class
|
|
316
|
+
const result: Record<string, any> = {};
|
|
317
|
+
for (const customClass of config.configuration.customClasses) {
|
|
318
|
+
result[customClass.name] = {};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Iterate through detection labels in input structure
|
|
322
|
+
for (const [detectionLabel, nestedData] of Object.entries(
|
|
323
|
+
vehiclesStructure,
|
|
324
|
+
)) {
|
|
325
|
+
const customClassName = detectionToCustomClass[detectionLabel];
|
|
326
|
+
|
|
327
|
+
// Skip labels not mapped to any custom class (e.g., pedestrian, bicycle)
|
|
328
|
+
if (!customClassName) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Deep merge nested data into custom class accumulator
|
|
333
|
+
result[customClassName] = this._deepMergeNumericData(
|
|
334
|
+
result[customClassName],
|
|
335
|
+
nestedData,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Deep merge numeric data at arbitrary nesting levels
|
|
344
|
+
*
|
|
345
|
+
* Recursively merges two nested structures, summing numeric leaf values
|
|
346
|
+
* Handles ATR format (2 levels: vehicle → lane → count)
|
|
347
|
+
* Handles TMC format (3 levels: vehicle → direction → turn → count)
|
|
348
|
+
*
|
|
349
|
+
* @param target - Target accumulator object
|
|
350
|
+
* @param source - Source data to merge into target
|
|
351
|
+
* @returns Merged object with summed numeric values
|
|
352
|
+
*/
|
|
353
|
+
private _deepMergeNumericData(target: any, source: any): any {
|
|
354
|
+
// Base case: if source is a number, add it to target
|
|
355
|
+
if (typeof source === "number") {
|
|
356
|
+
return (typeof target === "number" ? target : 0) + source;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// If source is not an object, return target unchanged
|
|
360
|
+
if (typeof source !== "object" || source === null) {
|
|
361
|
+
return target;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Ensure target is an object
|
|
365
|
+
if (typeof target !== "object" || target === null) {
|
|
366
|
+
target = {};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Recursively merge each key in source
|
|
370
|
+
for (const [key, value] of Object.entries(source)) {
|
|
371
|
+
target[key] = this._deepMergeNumericData(target[key], value);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return target;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Get the FHWA mapping constant (for reference/debugging)
|
|
379
|
+
*/
|
|
380
|
+
getDetectionLabelToFhwaMapping(): Record<string, number[]> {
|
|
381
|
+
return { ...DETECTION_LABEL_TO_FHWA };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Deserialize database row to IReportConfiguration interface
|
|
386
|
+
* Converts snake_case to camelCase and parses JSONB
|
|
387
|
+
*/
|
|
388
|
+
private _deserialize(row: any): IReportConfiguration {
|
|
389
|
+
return {
|
|
390
|
+
id: row.id,
|
|
391
|
+
uuid: row.uuid,
|
|
392
|
+
name: row.name,
|
|
393
|
+
description: row.description,
|
|
394
|
+
configuration:
|
|
395
|
+
typeof row.configuration === "string"
|
|
396
|
+
? JSON.parse(row.configuration)
|
|
397
|
+
: row.configuration,
|
|
398
|
+
created_at: row.created_at,
|
|
399
|
+
updated_at: row.updated_at,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
@@ -222,13 +222,13 @@ export class VideoDAO implements IBaseDAO<IVideo> {
|
|
|
222
222
|
// Check if metadata has at least one key with pt1 and pt2 properties
|
|
223
223
|
query = query.whereRaw(`
|
|
224
224
|
EXISTS (
|
|
225
|
-
SELECT 1
|
|
225
|
+
SELECT 1
|
|
226
226
|
FROM jsonb_each(metadata) as entry(key, value)
|
|
227
|
-
WHERE key != 'lanes'
|
|
227
|
+
WHERE key != 'lanes'
|
|
228
228
|
AND key != 'finish_line'
|
|
229
229
|
AND jsonb_typeof(value) = 'object'
|
|
230
|
-
AND value
|
|
231
|
-
AND value
|
|
230
|
+
AND jsonb_exists(value, 'pt1')
|
|
231
|
+
AND jsonb_exists(value, 'pt2')
|
|
232
232
|
AND jsonb_typeof(value->'pt1') = 'array'
|
|
233
233
|
AND jsonb_typeof(value->'pt2') = 'array'
|
|
234
234
|
AND jsonb_array_length(value->'pt1') = 2
|
|
@@ -246,26 +246,47 @@ export class VideoDAO implements IBaseDAO<IVideo> {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Get all video IDs for a specific folder (used for cascade operations)
|
|
251
|
+
*/
|
|
249
252
|
async getVideoIdsByFolderId(folderId: number): Promise<number[]> {
|
|
250
|
-
const
|
|
251
|
-
.where(
|
|
252
|
-
.select("id")
|
|
253
|
-
|
|
253
|
+
const rows = await this._knex("video")
|
|
254
|
+
.where({ folderId })
|
|
255
|
+
.select("id")
|
|
256
|
+
.orderBy("id", "asc");
|
|
257
|
+
|
|
258
|
+
return rows.map((row) => row.id);
|
|
254
259
|
}
|
|
255
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Bulk update camera assignment for multiple videos
|
|
263
|
+
* Supports optional transaction for service-level transaction management
|
|
264
|
+
*/
|
|
256
265
|
async bulkUpdateCamera(
|
|
257
266
|
videoIds: number[],
|
|
258
267
|
cameraId: number | null,
|
|
259
268
|
trx?: Knex.Transaction,
|
|
260
269
|
): Promise<number> {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
270
|
+
if (!videoIds || videoIds.length === 0) {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const query = trx || this._knex;
|
|
275
|
+
|
|
276
|
+
const result = await query("video")
|
|
277
|
+
.whereIn("id", videoIds)
|
|
278
|
+
.update({
|
|
279
|
+
cameraId: cameraId,
|
|
280
|
+
updated_at: query.fn.now(),
|
|
281
|
+
})
|
|
282
|
+
.returning("id");
|
|
283
|
+
|
|
284
|
+
return result.length;
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Get videos by camera ID with folder information (paginated)
|
|
289
|
+
*/
|
|
269
290
|
async getVideosByCameraIdWithFolder(
|
|
270
291
|
cameraId: number,
|
|
271
292
|
page: number,
|
|
@@ -275,12 +296,19 @@ export class VideoDAO implements IBaseDAO<IVideo> {
|
|
|
275
296
|
|
|
276
297
|
const query = this._knex("video as v")
|
|
277
298
|
.innerJoin("folders as f", "v.folderId", "f.id")
|
|
278
|
-
.
|
|
279
|
-
.
|
|
299
|
+
.select("v.*", this._knex.raw("to_jsonb(f.*) as folder"))
|
|
300
|
+
.where("v.cameraId", cameraId);
|
|
280
301
|
|
|
281
|
-
|
|
302
|
+
// Optimized count query without JOIN
|
|
303
|
+
const [countResult] = await this._knex("video as v")
|
|
304
|
+
.where("v.cameraId", cameraId)
|
|
305
|
+
.count("* as count");
|
|
282
306
|
const totalCount = +countResult.count;
|
|
283
|
-
const videos = await query
|
|
307
|
+
const videos = await query
|
|
308
|
+
.clone()
|
|
309
|
+
.limit(limit)
|
|
310
|
+
.offset(offset)
|
|
311
|
+
.orderBy("v.created_at", "desc");
|
|
284
312
|
|
|
285
313
|
return {
|
|
286
314
|
success: true,
|