From 5e1395db1845ea1e29b5d174f0d715ec32b47757 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 6 Sep 2026 13:37:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(carwash):=20advisory=20vehicle=20category?= =?UTF-8?q?=20from=20the=20entry=20camera=20=E2=80=94=20mapping,=20pre-sel?= =?UTF-8?q?ect,=20downgrade=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- apps/server/src/anpr-entry.test.ts | 3 + .../src/modules/carwash/carwash.test.ts | 86 +++++++++++- apps/server/src/modules/carwash/service.ts | 125 +++++++++++++++++- apps/server/src/plate-lookup.ts | 25 ++++ apps/server/src/snapshot.ts | 25 ++-- apps/server/src/vision-client.ts | 23 +++- apps/vision/vision_service/schemas.py | 10 +- apps/web/src/lib/i18n/en.ts | 19 +++ apps/web/src/lib/i18n/sq.ts | 18 +++ apps/web/src/modules/carwash/CarWashSetup.tsx | 58 ++++++-- apps/web/src/modules/carwash/WashDesk.tsx | 22 ++- apps/web/src/modules/carwash/api.ts | 10 +- packages/db/drizzle/0030_carwash_vision.sql | 15 +++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 14 +- packages/shared/src/index.ts | 38 +++++- wiki/decisions/venue-modules.md | 25 ++++ wiki/entities/opencv-anpr-service.md | 9 ++ wiki/log.md | 11 ++ 19 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 packages/db/drizzle/0030_carwash_vision.sql diff --git a/apps/server/src/anpr-entry.test.ts b/apps/server/src/anpr-entry.test.ts index 7e1eaf1..507a392 100644 --- a/apps/server/src/anpr-entry.test.ts +++ b/apps/server/src/anpr-entry.test.ts @@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 }, plates: [], lowConfidence: false, + vehicle: null, modelVersion: "test", tookMs: 1, }; @@ -177,6 +178,7 @@ describe("AnprBridge", () => { plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plates: [], lowConfidence: false, + vehicle: null, modelVersion: "test", tookMs: 1, })), @@ -207,6 +209,7 @@ describe("AnprBridge", () => { plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plates: [], lowConfidence: false, + vehicle: null, modelVersion: "test", tookMs: 1, })), diff --git a/apps/server/src/modules/carwash/carwash.test.ts b/apps/server/src/modules/carwash/carwash.test.ts index 1bf4542..ca9cd87 100644 --- a/apps/server/src/modules/carwash/carwash.test.ts +++ b/apps/server/src/modules/carwash/carwash.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; -import { type Db } from "@parking/db"; +import { deviceEvents, type Db } from "@parking/db"; import type { FastifyInstance } from "fastify"; import { buildServer } from "../../server.js"; import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js"; @@ -560,3 +560,87 @@ describe("a shift's activity log is per till", () => { expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403); }); }); + +describe("vision category — advisory, flagged, never authoritative", () => { + /** What snapshot.ts records when vision classifies the entry frame. */ + function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) { + db.insert(deviceEvents).values({ + id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read", + detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" }, + occurredAt: new Date().toISOString(), + }).run(); + } + async function mapClasses(a: Auth, ids: { car: string; suv: string }) { + const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json(); + const r = await app.inject({ + method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), + payload: { + categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })), + visionThreshold: 0.75, + }, + }); + expect(r.statusCode).toBe(200); + return r.json(); + } + + it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => { + const a = await admin(); + seedTariff(db); + const ids = await seedSettings(a); + const saved = await mapClasses(a, ids); + expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]); + expect(saved.visionThreshold).toBe(0.75); + expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true); + const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } }); + expect(bad.statusCode).toBe(400); + + await openSession("T-V1"); + seeVehicle("T-V1", "suv", 0.91); + const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json(); + expect(look.vision).toEqual({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" }); + expect(look.suggestedCategoryId).toBe(ids.suv); + // Unmapped class → shown, nothing suggested. + await openSession("T-V2"); + seeVehicle("T-V2", "bus", 0.99); + const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json(); + expect(look2.vision.bodyType).toBe("bus"); + expect(look2.suggestedCategoryId).toBeNull(); + }); + + it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => { + const a = await admin(); + seedTariff(db); + const ids = await seedSettings(a); + await mapClasses(a, ids); + const order = async (identity: string, categoryId: string) => { + const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } }); + expect(r.statusCode).toBe(201); + return r.json(); + }; + // Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created. + await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91); + const down = await order("T-D1", ids.car); + expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car }); + expect(down.downgradeEventId).toBeTruthy(); + const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!; + expect(flag).toBeTruthy(); + expect(flag.payload).toMatchObject({ + visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss", + visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1", + }); + // Same category as the camera → nothing. + await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91); + expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull(); + // Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly. + await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95); + const up = await order("T-D3", ids.suv); + expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null }); + // Below the site threshold → shown, never flagged. + await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6); + expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull(); + // No read at all → nulls. + await openSession("T-D5"); + expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null }); + expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1); + }); +}); diff --git a/apps/server/src/modules/carwash/service.ts b/apps/server/src/modules/carwash/service.ts index dd9c39a..dee33b2 100644 --- a/apps/server/src/modules/carwash/service.ts +++ b/apps/server/src/modules/carwash/service.ts @@ -23,10 +23,16 @@ import { type CarwashOrderView, type CarwashSettingsView, type ChargeLine, + CARWASH_VISION_THRESHOLD_DEFAULT, + isVehicleClass, + reasonPayload, + type VehicleClass, + type VehicleRead, type Tender, type TillId, } from "@parking/shared"; import type { EventLog } from "../../event-log.js"; +import { vehicleForIdentity } from "../../plate-lookup.js"; import { effectiveModulesFor } from "../../modules.js"; import type { ChargeProvider, PayStation } from "../../pay-station.js"; import type { ShiftService } from "../../shift-service.js"; @@ -57,11 +63,12 @@ export class CarwashError extends Error { } export interface SettingsBody { - categories?: { id?: string; name?: string; active?: boolean }[]; + categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[]; services?: { id?: string; name?: string; active?: boolean }[]; prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[]; /** Where wash money is taken at this site (site-level policy). */ payAt?: unknown; + visionThreshold?: unknown; } export interface CreateOrderInput { @@ -83,6 +90,10 @@ export interface TicketLookup { enteredAt: string | null; currency: string | null; orders: CarwashOrderView[]; + /** What the camera saw at entry (advisory) and the category the site mapping + * suggests for it — the desk pre-selects it; the operator may change it. */ + vision: VehicleRead | null; + suggestedCategoryId: string | null; } const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; @@ -127,7 +138,7 @@ export class CarwashService { .where(isNull(carwashCategories.deletedAt)) .orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name)) .all() - .map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active })); + .map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) })); const services = this.#db .select() .from(carwashServices) @@ -142,7 +153,7 @@ export class CarwashService { .all() .filter((p) => live.has(p.categoryId) && live.has(p.serviceId)) .map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor })); - return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() }; + return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() }; } /** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */ @@ -151,6 +162,25 @@ export class CarwashService { return row?.payAt ?? CARWASH_PAY_AT_DEFAULT; } + /** Confidence floor for a vision class to flag a category downgrade (site config). */ + visionThreshold(): number { + const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get(); + return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT; + } + + /** The category the site mapping suggests for a vision class (first active category + * listing it, in display order), or null when unmapped. */ + #categoryForClass(cls: VehicleClass): { id: string; name: string } | null { + const rows = this.#db + .select() + .from(carwashCategories) + .where(isNull(carwashCategories.deletedAt)) + .orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name)) + .all(); + const hit = rows.find((r) => r.active && r.visionClasses.includes(cls)); + return hit ? { id: hit.id, name: hit.name } : null; + } + /** The site's currency = the active tariff's (the wash is priced in the same money * the booth takes). null when no tariff is published yet. */ #currency(): string | null { @@ -171,7 +201,7 @@ export class CarwashService { const now = new Date().toISOString(); const upsertList = ( table: typeof carwashCategories | typeof carwashServices, - items: { id?: string; name?: string; active?: boolean }[] | undefined, + items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined, label: string, ): string[] => { if (items === undefined) { @@ -190,11 +220,19 @@ export class CarwashService { while (seen.has(id)) id = `${id}-${sort}`; seen.add(id); const active = it.active !== false; + // Vision mapping lives on CATEGORIES only; absent = keep what the row has. + let visionClasses: string[] | undefined; + if (table === carwashCategories && it.visionClasses !== undefined) { + if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) { + throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`); + } + visionClasses = [...new Set(it.visionClasses as string[])]; + } const existing = this.#db.select().from(table).where(eq(table.id, id)).get(); if (existing) { - this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run(); + this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run(); } else { - this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run(); + this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run(); } keep.push(id); sort += 1; @@ -260,6 +298,25 @@ export class CarwashService { }); } } + if (body.visionThreshold !== undefined) { + const v = Number(body.visionThreshold); + if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1"); + const prev = this.visionThreshold(); + if (v !== prev) { + this.#db + .insert(carwashConfig) + .values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor }) + .onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } }) + .run(); + await this.#log.append({ + type: "config_change", + source: "manual", + identity: "module:carwash", + payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor }, + }); + } + } + return this.settings(); } @@ -289,6 +346,10 @@ export class CarwashService { validationEventId: r.validationEventId, voidBy: r.voidBy, voidReason: r.voidReason, + visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null, + visionConfidence: r.visionConfidence, + visionCategoryId: r.visionCategoryId, + downgradeEventId: r.downgradeEventId, }; } @@ -335,6 +396,7 @@ export class CarwashService { lookup(identity: string): TicketLookup { const id = identity.trim(); const s = this.#pay.lookup(id); + const vision = s.found ? vehicleForIdentity(this.#db, id) : null; return { identity: id, found: s.found, @@ -344,6 +406,8 @@ export class CarwashService { enteredAt: s.enteredAt, currency: s.currency, orders: this.#ordersFor(id), + vision, + suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null, }; } @@ -383,6 +447,51 @@ export class CarwashService { const currency = s.currency ?? this.#pay.activeCurrency(); if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff"); + // Vision, advisory: what the camera saw at entry and the category the site maps it + // to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped + // one for this service, with the read above the site threshold — is signed as an + // anomaly for the reviewer (both categories, operator, snapshot). Recorded only: + // never blocks, no reason prompt (user, 2026-09-06). + const vision = vehicleForIdentity(this.#db, identity); + const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null; + let downgradeEventId: string | null = null; + if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) { + const visionPrice = this.#db + .select() + .from(carwashPrices) + .where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id))) + .get(); + if (visionPrice && visionPrice.priceMinor > price.priceMinor) { + const ev = await this.#log.append({ + type: "anomaly", + source: "manual", + identity, + payload: { + ...reasonPayload("carwash.categoryDowngrade", { + visionClass: vision.bodyType, + visionCategory: visionCategory.name, + operator: input.actor, + chosenCategory: category.name, + }), + sessionRef: identity, + visionClass: vision.bodyType, + visionConfidence: vision.confidence, + visionCategoryId: visionCategory.id, + visionCategoryName: visionCategory.name, + chosenCategoryId: category.id, + chosenCategoryName: category.name, + serviceName: service.name, + visionPriceMinor: visionPrice.priceMinor, + chosenPriceMinor: price.priceMinor, + currency, + snapshotId: vision.snapshotId, + operator: input.actor, + }, + }); + downgradeEventId = ev.id; + } + } + const now = new Date().toISOString(); const row: CarwashOrderRow = { id: randomUUID(), @@ -408,6 +517,10 @@ export class CarwashService { voidAt: null, voidBy: null, voidReason: null, + visionClass: vision?.bodyType ?? null, + visionConfidence: vision?.confidence ?? null, + visionCategoryId: visionCategory?.id ?? null, + downgradeEventId, }; this.#db.insert(carwashOrders).values(row).run(); await this.#log.append({ diff --git a/apps/server/src/plate-lookup.ts b/apps/server/src/plate-lookup.ts index 919ce87..2deb5cd 100644 --- a/apps/server/src/plate-lookup.ts +++ b/apps/server/src/plate-lookup.ts @@ -1,4 +1,5 @@ import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; +import { isVehicleClass, type VehicleRead } from "@parking/shared"; // READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it // lives in the unsigned, prunable `device_events` (kind="read") stream written by the @@ -23,6 +24,30 @@ interface ReadDetail { plate?: string; confidence?: number; direction?: string; + bodyType?: string; + bodyConfidence?: number; + snapshotId?: string; +} + +/** The advisory VEHICLE read (body type) for a session — the same stream and the same + * preference as the plate (entry over exit, newest first). Null when vision never + * classified the vehicle. See venue-modules.md §Vehicle category from vision. */ +export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null { + const rows = db + .select({ detail: deviceEvents.detail }) + .from(deviceEvents) + .where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read"))) + .orderBy(desc(deviceEvents.occurredAt)) + .all(); + let fallback: VehicleRead | null = null; + for (const r of rows) { + const d = (r.detail ?? {}) as ReadDetail; + if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue; + const v: VehicleRead = { bodyType: d.bodyType, confidence: d.bodyConfidence, snapshotId: d.snapshotId ?? null }; + if (d.direction === "entry") return v; + if (!fallback) fallback = v; + } + return fallback; } /** Best plate for one identity, or null. Prefers an entry read, then the newest read. */ diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index a9b0ce6..8f69f2c 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -167,22 +167,29 @@ async function recognizePlate( ): Promise { try { const result = await vision.analyze(shot.bytes, shot.contentType); - if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record - const plate = result.plate.text.trim().toUpperCase(); - if (!plate) return; + if (!result) return; + // The vehicle's body type (advisory; the wash desk's category suggestion — see + // venue-modules.md §Vehicle category). Rides the plate's read row when there is one, + // else a row of its own: a car with an unreadable plate is still a car of some class. + const vehicle = result.vehicle + ? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence } + : {}; + const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase(); + if (!plate && !result.vehicle) return; // nothing trustworthy to record db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId, category: "camera", kind: "read", - // `identity` ties the plate to the session; `snapshotId` to the evidence image. + // `identity` ties the read to the session; `snapshotId` to the evidence image. detail: { identity, direction, - plate, - confidence: result.plate.confidence, - region: result.plate.region ?? null, + ...(plate + ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null } + : {}), + ...vehicle, modelVersion: result.modelVersion, snapshotId, source: "entry-exit-snapshot", @@ -190,7 +197,9 @@ async function recognizePlate( occurredAt: new Date().toISOString(), }) .run(); - logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); + if (result.vehicle) logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`); + if (!plate) return; + logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`); // The session's entry/exit event already shipped without this (async) plate — tell the // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched. deviceEvents.emitPlateRecognized({ identity, plate, direction }); diff --git a/apps/server/src/vision-client.ts b/apps/server/src/vision-client.ts index 2dd04dd..b532d55 100644 --- a/apps/server/src/vision-client.ts +++ b/apps/server/src/vision-client.ts @@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify"; // transport + contract adapter only. /** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ +import { isVehicleClass, type VehicleClass } from "@parking/shared"; + export interface PlateBBox { readonly x1: number; readonly y1: number; @@ -40,12 +42,19 @@ export interface VisionPlate { readonly region?: string | null; } -/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for - * Job 2 (vehicle verification) — not yet produced. */ +/** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared + * VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a + * plate-only recognizer sends null. */ +export interface VisionVehicle { + readonly bodyType: VehicleClass; + readonly confidence: number; +} + +/** The raw /analyze response shape (the Python contract). */ interface AnalyzeResponse { readonly plate: VisionPlate | null; readonly plates: VisionPlate[]; - readonly vehicle: unknown | null; + readonly vehicle: { body_type?: string | null; confidence?: number | null } | null; readonly low_confidence: boolean; readonly model_version: string; readonly took_ms: number; @@ -61,6 +70,8 @@ export interface VisionResult { /** True when the best plate is below the confidence floor — treat as advisory only * and fall back to the ticket/manual path. */ readonly lowConfidence: boolean; + /** The vehicle's body type, when the service ran that stage and named a known class. */ + readonly vehicle: VisionVehicle | null; readonly modelVersion: string; readonly tookMs: number; } @@ -124,10 +135,16 @@ export class VisionClient { const best = res.plate ?? null; const lowConfidence = res.low_confidence || (best != null && best.confidence < this.#minConfidence); + const v = res.vehicle; + const vehicle: VisionVehicle | null = + v && isVehicleClass(v.body_type) && typeof v.confidence === "number" + ? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)) } + : null; return { plate: best, plates: Array.isArray(res.plates) ? res.plates : [], lowConfidence, + vehicle, modelVersion: res.model_version ?? "unknown", tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, }; diff --git a/apps/vision/vision_service/schemas.py b/apps/vision/vision_service/schemas.py index 5fe7da4..932b849 100644 --- a/apps/vision/vision_service/schemas.py +++ b/apps/vision/vision_service/schemas.py @@ -31,10 +31,18 @@ class PlateResult(BaseModel): class VehicleResult(BaseModel): - """Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced.""" + """Job 2 — vehicle attributes. `body_type` is ADVISORY: the Node server records it + beside the plate and the Car Wash desk pre-selects the site category it maps to; the + operator decides, a disagreement is flagged, nothing is ever gated on it. Values come + from the shared vocabulary (car, sedan, hatchback, suv, minivan, pickup, van, truck, + bus, motorcycle) — anything else is ignored by Node. Phase A (a COCO detector) emits + car/truck/bus/motorcycle; the finer classes need the body-type classifier. Not yet + produced by any bundled recognizer.""" colour: str | None = None body_type: str | None = None + # Confidence of `body_type` (0–1). Node compares it to the site's threshold. + confidence: float | None = Field(default=None, ge=0.0, le=1.0) make: str | None = None model: str | None = None diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 44fe7e7..ee316a6 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -67,6 +67,18 @@ export const en: Catalog = { carwash: "Car wash", }, }, + vehicleClass: { + car: "car", + sedan: "sedan", + hatchback: "hatchback", + suv: "SUV", + minivan: "minivan", + pickup: "pickup", + van: "van", + truck: "truck", + bus: "bus", + motorcycle: "motorcycle", + }, wash: { tillTitle: "Wash till", tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.", @@ -124,6 +136,12 @@ export const en: Catalog = { sponsorship: "Parking discount", sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.", sponsorshipLabel: "Car wash", + // Vision (advisory): the entry camera's body-type read, mapped to a site category. + visionSaw: "Camera saw", + visionUnmapped: "not mapped to a category", + visionClasses: "Camera classes", + visionThreshold: "Camera confidence to flag a downgrade", + visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.", }, update: { available: "Update available", @@ -385,6 +403,7 @@ export const en: Catalog = { "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", "entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", "entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}", + "carwash.categoryDowngrade": "Wash category downgraded — camera saw {{visionClass}} ({{visionCategory}}), operator {{operator}} chose {{chosenCategory}}", "exit.refused.closed": "Exit refused — session already closed", "exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.unpaid": "Exit refused — not paid (take payment first)", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index bde4be1..38334ec 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -70,6 +70,18 @@ export const sq = { carwash: "Lavazh", }, }, + vehicleClass: { + car: "veturë", + sedan: "sedan", + hatchback: "hatchback", + suv: "SUV", + minivan: "minivan", + pickup: "pikap", + van: "furgon", + truck: "kamion", + bus: "autobus", + motorcycle: "motor", + }, wash: { tillTitle: "Arka e lavazhit", tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.", @@ -127,6 +139,11 @@ export const sq = { sponsorship: "Zbritje parkimi", sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.", sponsorshipLabel: "Lavazh", + visionSaw: "Kamera pa", + visionUnmapped: "pa kategori të lidhur", + visionClasses: "Klasat e kamerës", + visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie", + visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.", }, update: { available: "Përditësim i disponueshëm", @@ -389,6 +406,7 @@ export const sq = { "entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", "entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja", "entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", + "carwash.categoryDowngrade": "Kategoria e lavazhit u ul — kamera pa {{visionClass}} ({{visionCategory}}), operatori {{operator}} zgjodhi {{chosenCategory}}", "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", diff --git a/apps/web/src/modules/carwash/CarWashSetup.tsx b/apps/web/src/modules/carwash/CarWashSetup.tsx index 6c08495..9996663 100644 --- a/apps/web/src/modules/carwash/CarWashSetup.tsx +++ b/apps/web/src/modules/carwash/CarWashSetup.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared"; +import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared"; import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js"; import { StationForm, defaultProgram } from "../../ValidationSetup.js"; import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js"; @@ -10,7 +10,7 @@ import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } f // validation program (id "carwash"), composed with the same editor the merchant // programs use. See wiki/decisions/venue-modules.md ("v1 answers"). -type Item = { id?: string; name: string; active: boolean }; +type Item = { id?: string; name: string; active: boolean; visionClasses?: VehicleClass[] }; const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, "")); const toMinor = (s: string): number | null => { @@ -25,18 +25,32 @@ function ListEditor({ items, onChange, addLabel, + visionMap, }: { title: string; items: Item[]; onChange: (items: Item[]) => void; addLabel: string; + /** Categories only: offer the vision vocabulary as chips under each row — the site's + * own "car, sedan → Vetura" mapping (venue-modules.md §Vehicle category). */ + visionMap?: boolean; }) { const { t } = useTranslation(); + const toggleClass = (i: number, cls: VehicleClass) => + onChange( + items.map((x, j) => { + if (j !== i) return x; + const cur = new Set(x.visionClasses ?? []); + cur.has(cls) ? cur.delete(cls) : cur.add(cls); + return { ...x, visionClasses: VEHICLE_CLASSES.filter((c) => cur.has(c)) }; + }), + ); return (
{title}
{items.map((it, i) => ( -
+
+
+ {visionMap && ( +
+ {t("wash.visionClasses")} + {VEHICLE_CLASSES.map((cls) => { + const on = (it.visionClasses ?? []).includes(cls); + return ( + + ); + })} +
+ )} +
))}