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
4 changes: 2 additions & 2 deletions docs/public/guides/migrating-to-0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ summary: Update playback gates, playback feedback, and interaction highlights fr

# Migrating to 0.2

Upgrade to `0.2.8` with:
Upgrade to `0.2.9` with:

```sh
npm install supervision@0.2.8
npm install supervision@0.2.9
```

The engine remains a subpath of `supervision`; do not add a separate engine or
Expand Down
8 changes: 8 additions & 0 deletions docs/public/guides/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,14 @@ still returns the presented source pixels. Brightness/contrast adjustments retai
the configured renderer resolution, including on Retina displays. Omit the option for smooth sampling
at every zoom.

`renderer.setDisplayAdjustments({ brightness, contrast, gamma, autoContrast })`
changes only media display. Brightness, contrast and gamma default to `1`; gamma
above `1` brightens midtones. For still images, `autoContrast: true` applies
Contrast Limited Adaptive Histogram Equalization (CLAHE) instead of manual
contrast. Brightness and gamma remain active. Auto contrast is ignored for video;
passing an empty object restores the unadjusted display. Source pixels and
annotation styles are unchanged.

`renderer.setViewportTransform({ scale, x, y })` applies an exact transform in
natural media pixels and container-relative CSS pixels. Explicit updates work
while the viewport is locked and can restore scales outside the ordinary gesture
Expand Down
2 changes: 1 addition & 1 deletion docs/public/typedoc-icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

(function () {
const packageName = "supervision";
const packageVersion = "0.2.8";
const packageVersion = "0.2.9";
const packageReleaseStatus = "";
const kindIconMap = {
Accessor: "A",
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/core/src/types/media-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ export interface AnnotationVisibility {
}

export interface MediaDisplayAdjustments {
/** Gamma correction; 1 is unchanged and larger values brighten midtones. */
readonly gamma?: number;
/** CLAHE for still images. Replaces manual contrast; ignored for video. */
readonly autoContrast?: boolean;
/** Normalized contrast where 1 is unchanged. */
readonly contrast?: number;
/** Normalized brightness where 1 is unchanged. */
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "supervision",
"version": "0.2.8",
"version": "0.2.9",
"description": "Browser-native computer vision media rendering and annotation engine.",
"repository": {
"type": "git",
Expand Down
75 changes: 75 additions & 0 deletions packages/web/src/renderers/media-clahe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { computeMediaClahe } from "./media-clahe";

function grayscale(
width: number,
height: number,
pixel: (x: number, y: number) => number,
) {
const rgba = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 4;
rgba.fill(pixel(x, y), offset, offset + 3);
rgba[offset + 3] = 255;
}
}
return rgba;
}

describe("media Auto contrast", () => {
it("expands local low contrast without amplifying a flat image or changing another tile", () => {
const width = 512;
const height = 512;
const rgba = grayscale(width, height, (x, y) => 100 + ((x + y * 7) % 41));
const original = computeMediaClahe(rgba, width, height);
expect(original.tilesX).toBe(8);
expect(original.tilesY).toBe(8);
for (let tile = 0; tile < 64; tile++) {
const lut = original.luts.subarray(tile * 256, (tile + 1) * 256);
expect(lut[140]! - lut[100]!).toBeGreaterThan(80);
expect(lut[255]).toBe(255);
expect(
Array.from(lut).every((value, i) => i === 0 || value >= lut[i - 1]!),
).toBe(true);
}
const flat = computeMediaClahe(
grayscale(width, height, () => 100),
width,
height,
);
expect(Math.abs(flat.luts[100]! - 100)).toBeLessThanOrEqual(3);

const changed = computeMediaClahe(
grayscale(width, height, (x, y) =>
x < 64 ? 200 : 100 + ((x + y * 7) % 41),
),
width,
height,
);
expect(changed.luts.subarray(0, 256)).not.toEqual(
original.luts.subarray(0, 256),
);
expect(changed.luts.subarray(256, 512)).toEqual(
original.luts.subarray(256, 512),
);
});

it("uses Rec.601 intensity and keeps tiny images' lookup tables valid", () => {
const red = computeMediaClahe(
new Uint8ClampedArray([255, 0, 0, 255]),
1,
1,
);
expect(red.luts[75]).toBe(0);
expect(red.luts[76]).toBe(255);
const tiny = computeMediaClahe(
grayscale(3, 2, (x, y) => 10 + x * 10 + y * 30),
3,
2,
);
expect([tiny.tilesX, tiny.tilesY]).toEqual([3, 2]);
for (let tile = 0; tile < 6; tile++)
expect(tiny.luts[tile * 256 + 255]).toBe(255);
});
});
64 changes: 64 additions & 0 deletions packages/web/src/renderers/media-clahe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const BINS = 256;
const TILE_COUNT = 8;
const CLIP_LIMIT = 2;

export function computeMediaClahe(
rgba: Uint8ClampedArray,
width: number,
height: number,
) {
const tilesX = Math.min(TILE_COUNT, width);
const tilesY = Math.min(TILE_COUNT, height);
const luts = new Uint8Array(tilesX * tilesY * BINS);
const histogram = new Uint32Array(BINS);

for (let ty = 0; ty < tilesY; ty++) {
const y0 = Math.floor((ty * height) / tilesY);
const y1 = Math.floor(((ty + 1) * height) / tilesY);
for (let tx = 0; tx < tilesX; tx++) {
const x0 = Math.floor((tx * width) / tilesX);
const x1 = Math.floor(((tx + 1) * width) / tilesX);
histogram.fill(0);
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const pixel = (y * width + x) * 4;
const luma = Math.round(
0.299 * rgba[pixel]! +
0.587 * rgba[pixel + 1]! +
0.114 * rgba[pixel + 2]!,
);
histogram[luma]!++;
}
}

const area = (x1 - x0) * (y1 - y0);
const limit = Math.max(1, Math.floor((CLIP_LIMIT * area) / BINS));
let excess = 0;
for (let i = 0; i < BINS; i++) {
if (histogram[i]! > limit) {
excess += histogram[i]! - limit;
histogram[i] = limit;
}
}
const perBin = Math.floor(excess / BINS);
let residual = excess - perBin * BINS;
for (let i = 0; i < BINS; i++) histogram[i]! += perBin;
const residualStep = Math.max(
Math.floor(BINS / Math.max(residual, 1)),
1,
);
for (let i = 0; i < BINS && residual > 0; i += residualStep, residual--) {
histogram[i]!++;
}

const offset = (ty * tilesX + tx) * BINS;
let cumulative = 0;
for (let i = 0; i < BINS; i++) {
cumulative += histogram[i]!;
luts[offset + i] = Math.round((cumulative * (BINS - 1)) / area);
}
}
}

return { luts, tilesX, tilesY };
}
Loading
Loading