Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/ui-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,4 @@ export {
type ExportTargetSelectProps,
} from "./patterns/ExportTargetSelect.js";
export { describeTargetDrops } from "./data/refusals.js";
export { StaticAnnotationOverlay, type StaticAnnotationOverlayProps } from "./patterns/StaticAnnotationOverlay.js";
119 changes: 119 additions & 0 deletions frontend/ui-core/src/patterns/StaticAnnotationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* A picture with its labels drawn over it, and nothing behind them.
*
* The shapes are the annotator's own renderers (`BboxShape` and its siblings),
* so a box here is the box the annotator would draw — but there is no store, no
* selection, no tool and no pointer handling. It is a viewer for places that
* only look: the dataset's member dialog, a pre-processing preview cell.
*
* The overlay is exact by construction rather than by measurement. The picture
* box is given the asset's own aspect ratio, the `<img>` fills it and the `<svg>`
* carries a `viewBox` of the asset's pixel size, so user units map onto the
* rendered picture uniformly at every width without a `ResizeObserver`. A
* caller without dimensions has nothing to hand this component — coordinates
* without a frame cannot be placed — and renders its own picture instead.
*/

import type { CSSProperties, JSX } from "react";
import {
BboxShape,
PolygonShape,
PolylineShape,
STROKE_PX,
parseGeometry,
type Geometry,
} from "@visionset/annotator";

import type { WireAnnotation } from "../annotator/jobQueries";
import { classColor, type LabelClass } from "../palette";

export interface StaticAnnotationOverlayProps {
/** The picture's pixel size, which is the frame every coordinate is read in. */
readonly width: number;
readonly height: number;
readonly src: string;
readonly alt: string;
/** Wire annotations as the dataset and job routes answer them; a classification tag draws nothing. */
readonly annotations: readonly WireAnnotation[];
/** The schema's classes, for their declared colours; a class not declared here takes the engine's derived hue. */
readonly classes?: readonly LabelClass[];
}

export function StaticAnnotationOverlay({
width,
height,
src,
alt,
annotations,
classes = [],
}: StaticAnnotationOverlayProps): JSX.Element {
const declared = new Map(classes.map((one) => [one.name, one]));
const colorOf = (labelClass: string): string => classColor(declared.get(labelClass), labelClass);

// The stage scales its stroke variables with the zoom; a static viewer has no
// zoom, so the stroke is a fraction of the picture's width instead, and stays
// the same apparent weight however large the picture is drawn.
const stroke = Math.max(1, (STROKE_PX * width) / 800);
const strokeVars = {
"--vs-stroke": `${stroke}px`,
"--vs-stroke-selected": `${stroke}px`,
} as CSSProperties;

return (
<div
data-testid="preview-picture"
className="relative w-full"
style={{ aspectRatio: `${width} / ${height}`, maxWidth: `calc(80vh * ${width / height})` }}
>
<img
data-testid="preview-image"
src={src}
alt={alt}
className="absolute inset-0 size-full rounded-md object-contain"
/>
<svg
data-testid="preview-overlay"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
className="pointer-events-none absolute inset-0 size-full"
style={strokeVars}
aria-hidden="true"
>
{annotations.map((annotation) => (
<Shape key={annotation.id} annotation={annotation} color={colorOf(annotation.label_class)} />
))}
</svg>
</div>
);
}

function Shape({
annotation,
color,
}: {
readonly annotation: WireAnnotation;
readonly color: string;
}): JSX.Element | null {
const geometry = geometryOf(annotation);
if (geometry === null || geometry.type === "classification_tag") return null;
return (
<g data-testid={`preview-shape-${annotation.id}`} data-geometry={geometry.type}>
{geometry.type === "bbox" ? (
<BboxShape geometry={geometry} color={color} hot={false} selected={false} />
) : geometry.type === "polygon" ? (
<PolygonShape geometry={geometry} color={color} hot={false} selected={false} />
) : (
<PolylineShape geometry={geometry} color={color} hot={false} selected={false} />
)}
</g>
);
}

/** `null` for a geometry the engine does not draw — a stored shape is never a reason to fail the page. */
function geometryOf(annotation: WireAnnotation): Geometry | null {
try {
return parseGeometry(annotation.geometry);
} catch {
return null;
}
}
98 changes: 98 additions & 0 deletions frontend/ui-core/src/patterns/staticAnnotationOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* The static overlay, held to the three things a consumer relies on: one drawn
* shape per placeable annotation, a `viewBox` that is the picture's pixel frame,
* and the class palette the annotator itself draws with. Whether the SVG's box
* lands on the image's box is a browser fact and lives in `e2e/dataset.spec.ts`.
*/

import { classColor } from "@visionset/annotator";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import type { WireAnnotation } from "../annotator/jobQueries";
import type { LabelClass } from "../palette";
import { StaticAnnotationOverlay } from "./StaticAnnotationOverlay";

const VEHICLE: LabelClass = { name: "vehicle", geometries: ["bbox"], color: "#38bdf8", attributes: [] };

function annotation(id: string, labelClass: string, geometry: unknown): WireAnnotation {
return {
id,
asset_id: "asset-1",
label_class: labelClass,
schema_version: 1,
geometry,
attributes: {},
provenance: "human",
model_ref: null,
confidence: null,
job_id: null,
};
}

const BOX = annotation("box", "vehicle", { type: "bbox", x: 10, y: 20, width: 100, height: 50 });
const LANE = annotation("lane", "lane", {
type: "polygon",
points: [
[0, 0],
[50, 0],
[25, 40],
],
});
const TAG = annotation("tag", "night", { type: "classification_tag" });
const BROKEN = annotation("broken", "vehicle", { type: "hexagon" });

describe("StaticAnnotationOverlay", () => {
it("draws one shape per placeable annotation in the picture's own pixel frame", () => {
render(
<StaticAnnotationOverlay
width={640}
height={480}
src="blob:picture"
alt="frame 1"
annotations={[BOX, LANE, TAG, BROKEN]}
/>,
);

expect(screen.getByTestId("preview-overlay").getAttribute("viewBox")).toBe("0 0 640 480");
expect(screen.getByTestId("preview-shape-box").getAttribute("data-geometry")).toBe("bbox");
expect(screen.getByTestId("preview-shape-lane").getAttribute("data-geometry")).toBe("polygon");
// A tag has no place on the picture, and a geometry the engine cannot parse
// is skipped rather than allowed to fail the page.
expect(screen.queryByTestId("preview-shape-tag")).toBeNull();
expect(screen.queryByTestId("preview-shape-broken")).toBeNull();
expect(screen.getByTestId("preview-overlay").querySelectorAll("[data-testid^='preview-shape-']")).toHaveLength(2);

const picture = screen.getByTestId("preview-picture");
expect(picture.style.aspectRatio).toBe("640 / 480");
expect(screen.getByTestId("preview-image").getAttribute("src")).toBe("blob:picture");
expect(screen.getByTestId("preview-image").getAttribute("alt")).toBe("frame 1");
});

it("colours a shape by the class palette: the declared colour, else the engine's derived hue", () => {
render(
<StaticAnnotationOverlay
width={640}
height={480}
src="blob:picture"
alt="frame 1"
annotations={[BOX, LANE]}
classes={[VEHICLE]}
/>,
);

const box = screen.getByTestId("preview-shape-box").querySelector("rect");
expect(box?.getAttribute("stroke")).toBe("#38bdf8");
const lane = screen.getByTestId("preview-shape-lane").querySelector("polygon");
expect(lane?.getAttribute("stroke")).toBe(classColor(undefined, "lane"));
});

it("scales the stroke with the picture's width instead of a zoom", () => {
render(
<StaticAnnotationOverlay width={1600} height={900} src="blob:picture" alt="wide" annotations={[BOX]} />,
);
// The stage's shapes read `--vs-stroke`; without a zoom the value is a
// fraction of the width, so a 4K frame does not draw hairline boxes.
expect(screen.getByTestId("preview-overlay").style.getPropertyValue("--vs-stroke")).toBe("4px");
});
});
111 changes: 20 additions & 91 deletions frontend/ui-core/src/screens/DatasetAssetDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,14 @@
* inventory — every label is drawn there — and a list of forty rows saying
* "box · model" beside it would repeat the overlay in words.
*
* The overlay is exact by construction rather than by measurement. The picture
* box is given the asset's own aspect ratio, the `<img>` fills it and the `<svg>`
* carries a `viewBox` of the asset's pixel size, so user units map onto the
* rendered picture uniformly at every dialog width without a `ResizeObserver`.
* An asset whose dimensions are not recorded gets the picture and no overlay,
* because coordinates without a frame cannot be placed.
* The overlay is `StaticAnnotationOverlay`, exact by construction rather than by
* measurement. An asset whose dimensions are not recorded gets the picture and
* no overlay, because coordinates without a frame cannot be placed.
*/

import { ChevronLeft, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
import { useState, type CSSProperties, type JSX, type KeyboardEvent } from "react";
import {
BboxShape,
PolygonShape,
PolylineShape,
STROKE_PX,
parseGeometry,
parseLabelClass,
type Geometry,
type LabelClass,
} from "@visionset/annotator";
import { useState, type JSX, type KeyboardEvent } from "react";
import { parseLabelClass, type LabelClass } from "@visionset/annotator";

import { AssetImage } from "../annotator/AssetImage";
import type { WireAnnotation } from "../annotator/jobQueries";
Expand All @@ -43,6 +31,7 @@ import { classColor } from "../palette";
import { Badge } from "../primitives/Badge";
import { Button } from "../primitives/Button";
import { DescriptionList, DescriptionRow } from "../patterns/DataDisplay";
import { StaticAnnotationOverlay } from "../patterns/StaticAnnotationOverlay";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -125,7 +114,7 @@ export function DatasetAssetDialog({
projectId={projectId}
asset={asset}
annotations={showLabels ? (annotations.data ?? []) : []}
colorOf={colorOf}
classes={[...declared.values()]}
/>
</div>

Expand Down Expand Up @@ -205,12 +194,12 @@ function Picture({
projectId,
asset,
annotations,
colorOf,
classes,
}: {
readonly projectId: string;
readonly asset: DatasetAsset;
readonly annotations: readonly WireAnnotation[];
readonly colorOf: (labelClass: string) => string;
readonly classes: readonly LabelClass[];
}): JSX.Element {
const width = asset.width;
const height = asset.height;
Expand All @@ -231,82 +220,22 @@ function Picture({
);
}

// The stage scales its stroke variables with the zoom; a static viewer has no
// zoom, so the stroke is a fraction of the picture's width instead, and stays
// the same apparent weight however large the dialog draws it.
const stroke = Math.max(1, (STROKE_PX * width) / 800);
const strokeVars = {
"--vs-stroke": `${stroke}px`,
"--vs-stroke-selected": `${stroke}px`,
} as CSSProperties;

return (
<div
data-testid="preview-picture"
className="relative w-full"
style={{ aspectRatio: `${width} / ${height}`, maxWidth: `calc(80vh * ${width / height})` }}
>
<AssetImage projectId={projectId} assetId={asset.id}>
{(src) => (
<img
data-testid="preview-image"
src={src}
alt={alt}
className="absolute inset-0 size-full rounded-md object-contain"
/>
)}
</AssetImage>
<svg
data-testid="preview-overlay"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
className="pointer-events-none absolute inset-0 size-full"
style={strokeVars}
aria-hidden="true"
>
{annotations.map((annotation) => (
<Shape
key={annotation.id}
annotation={annotation}
color={colorOf(annotation.label_class)}
/>
))}
</svg>
</div>
);
}

function Shape({
annotation,
color,
}: {
readonly annotation: WireAnnotation;
readonly color: string;
}): JSX.Element | null {
const geometry = geometryOf(annotation);
if (geometry === null || geometry.type === "classification_tag") return null;
return (
<g data-testid={`preview-shape-${annotation.id}`} data-geometry={geometry.type}>
{geometry.type === "bbox" ? (
<BboxShape geometry={geometry} color={color} hot={false} selected={false} />
) : geometry.type === "polygon" ? (
<PolygonShape geometry={geometry} color={color} hot={false} selected={false} />
) : (
<PolylineShape geometry={geometry} color={color} hot={false} selected={false} />
<AssetImage projectId={projectId} assetId={asset.id}>
{(src) => (
<StaticAnnotationOverlay
width={width}
height={height}
src={src}
alt={alt}
annotations={annotations}
classes={classes}
/>
)}
</g>
</AssetImage>
);
}

/** `null` for a geometry the engine does not draw — a stored shape is never a reason to fail the page. */
function geometryOf(annotation: WireAnnotation): Geometry | null {
try {
return parseGeometry(annotation.geometry);
} catch {
return null;
}
}

function Metadata({ asset }: { readonly asset: DatasetAsset }): JSX.Element {
const rows: [string, string][] = [
[
Expand Down
Loading