supervision 0.2.8 → 0.2.9

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/index.js CHANGED
@@ -10650,6 +10650,260 @@ function matches(left, right) {
10650
10650
  left.every((value, index) => Object.is(value, right[index])));
10651
10651
  }
10652
10652
 
10653
+ const BINS = 256;
10654
+ const TILE_COUNT = 8;
10655
+ const CLIP_LIMIT = 2;
10656
+ function computeMediaClahe(rgba, width, height) {
10657
+ const tilesX = Math.min(TILE_COUNT, width);
10658
+ const tilesY = Math.min(TILE_COUNT, height);
10659
+ const luts = new Uint8Array(tilesX * tilesY * BINS);
10660
+ const histogram = new Uint32Array(BINS);
10661
+ for (let ty = 0; ty < tilesY; ty++) {
10662
+ const y0 = Math.floor((ty * height) / tilesY);
10663
+ const y1 = Math.floor(((ty + 1) * height) / tilesY);
10664
+ for (let tx = 0; tx < tilesX; tx++) {
10665
+ const x0 = Math.floor((tx * width) / tilesX);
10666
+ const x1 = Math.floor(((tx + 1) * width) / tilesX);
10667
+ histogram.fill(0);
10668
+ for (let y = y0; y < y1; y++) {
10669
+ for (let x = x0; x < x1; x++) {
10670
+ const pixel = (y * width + x) * 4;
10671
+ const luma = Math.round(0.299 * rgba[pixel] +
10672
+ 0.587 * rgba[pixel + 1] +
10673
+ 0.114 * rgba[pixel + 2]);
10674
+ histogram[luma]++;
10675
+ }
10676
+ }
10677
+ const area = (x1 - x0) * (y1 - y0);
10678
+ const limit = Math.max(1, Math.floor((CLIP_LIMIT * area) / BINS));
10679
+ let excess = 0;
10680
+ for (let i = 0; i < BINS; i++) {
10681
+ if (histogram[i] > limit) {
10682
+ excess += histogram[i] - limit;
10683
+ histogram[i] = limit;
10684
+ }
10685
+ }
10686
+ const perBin = Math.floor(excess / BINS);
10687
+ let residual = excess - perBin * BINS;
10688
+ for (let i = 0; i < BINS; i++)
10689
+ histogram[i] += perBin;
10690
+ const residualStep = Math.max(Math.floor(BINS / Math.max(residual, 1)), 1);
10691
+ for (let i = 0; i < BINS && residual > 0; i += residualStep, residual--) {
10692
+ histogram[i]++;
10693
+ }
10694
+ const offset = (ty * tilesX + tx) * BINS;
10695
+ let cumulative = 0;
10696
+ for (let i = 0; i < BINS; i++) {
10697
+ cumulative += histogram[i];
10698
+ luts[offset + i] = Math.round((cumulative * (BINS - 1)) / area);
10699
+ }
10700
+ }
10701
+ }
10702
+ return { luts, tilesX, tilesY };
10703
+ }
10704
+
10705
+ /**
10706
+ * Pixi keys its program cache by source, so one program is shared by every
10707
+ * shader built from it. `destroy(true)` takes that program with it, leaving
10708
+ * both the peers still rendering through it and the next shader built from the
10709
+ * same source bound to nothing.
10710
+ */
10711
+ function destroyShaderKeepingProgram(shader) {
10712
+ try {
10713
+ shader.destroy();
10714
+ }
10715
+ catch {
10716
+ // Pixi has already invalidated this shader's resource group.
10717
+ }
10718
+ }
10719
+ /**
10720
+ * Pixi uploads a placeholder while it builds a shader's first bind group, and
10721
+ * WebGPU rejects a canvas that was never given a rendering context.
10722
+ */
10723
+ function createShaderPlaceholderCanvas() {
10724
+ if (typeof document === "undefined") {
10725
+ return { height: 1, width: 1 };
10726
+ }
10727
+ const canvas = document.createElement("canvas");
10728
+ canvas.height = 1;
10729
+ canvas.width = 1;
10730
+ canvas.getContext("2d");
10731
+ return canvas;
10732
+ }
10733
+
10734
+ const HISTOGRAM_MAX_DIMENSION = 1024;
10735
+ const vertex = `
10736
+ in vec2 aPosition;
10737
+ out vec2 vTextureCoord;
10738
+ out vec2 vImageCoord;
10739
+ uniform vec4 uInputSize;
10740
+ uniform vec4 uOutputFrame;
10741
+ uniform vec4 uOutputTexture;
10742
+ uniform mat3 uImageMatrix;
10743
+ void main() {
10744
+ vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
10745
+ position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
10746
+ position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z;
10747
+ gl_Position = vec4(position, 0.0, 1.0);
10748
+ vTextureCoord = aPosition * (uOutputFrame.zw * uInputSize.zw);
10749
+ vImageCoord = (uImageMatrix * vec3(vTextureCoord, 1.0)).xy;
10750
+ }
10751
+ `;
10752
+ const fragment = `
10753
+ in vec2 vTextureCoord;
10754
+ in vec2 vImageCoord;
10755
+ out vec4 finalColor;
10756
+ uniform sampler2D uTexture;
10757
+ uniform sampler2D uLut;
10758
+ uniform vec2 uTiles;
10759
+ uniform float uGamma;
10760
+ uniform float uAuto;
10761
+ float lookup(vec2 tile, float luma) {
10762
+ float row = tile.y * uTiles.x + tile.x;
10763
+ return texture(uLut, vec2((luma * 255.0 + 0.5) / 256.0,
10764
+ (row + 0.5) / (uTiles.x * uTiles.y))).r;
10765
+ }
10766
+ void main() {
10767
+ vec4 color = texture(uTexture, vTextureCoord);
10768
+ if (color.a == 0.0) { finalColor = color; return; }
10769
+ vec3 rgb = color.rgb / color.a;
10770
+ if (uAuto > 0.5) {
10771
+ float luma = dot(rgb, vec3(0.299, 0.587, 0.114));
10772
+ vec2 tilePosition = clamp(vImageCoord, 0.0, 1.0) * uTiles - 0.5;
10773
+ vec2 t0 = floor(tilePosition);
10774
+ vec2 f = tilePosition - t0;
10775
+ vec2 t1 = clamp(t0 + 1.0, vec2(0.0), uTiles - 1.0);
10776
+ t0 = clamp(t0, vec2(0.0), uTiles - 1.0);
10777
+ float top = mix(lookup(t0, luma), lookup(vec2(t1.x, t0.y), luma), f.x);
10778
+ float bottom = mix(lookup(vec2(t0.x, t1.y), luma), lookup(t1, luma), f.x);
10779
+ rgb = clamp(rgb + (mix(top, bottom, f.y) - luma), 0.0, 1.0);
10780
+ }
10781
+ finalColor = vec4(pow(rgb, vec3(1.0 / uGamma)) * color.a, color.a);
10782
+ }
10783
+ `;
10784
+ const gpuSource = `
10785
+ struct GlobalFilterUniforms {
10786
+ uInputSize: vec4<f32>, uInputPixel: vec4<f32>, uInputClamp: vec4<f32>,
10787
+ uOutputFrame: vec4<f32>, uGlobalFrame: vec4<f32>, uOutputTexture: vec4<f32>,
10788
+ };
10789
+ struct InspectionUniforms {
10790
+ uImageMatrix: mat3x3<f32>, uTiles: vec2<f32>, uGamma: f32, uAuto: f32,
10791
+ };
10792
+ @group(0) @binding(0) var<uniform> gfu: GlobalFilterUniforms;
10793
+ @group(0) @binding(1) var uTexture: texture_2d<f32>;
10794
+ @group(0) @binding(2) var uSampler: sampler;
10795
+ @group(1) @binding(0) var<uniform> inspectionUniforms: InspectionUniforms;
10796
+ @group(1) @binding(1) var uLut: texture_2d<f32>;
10797
+ @group(1) @binding(2) var uLutSampler: sampler;
10798
+ struct VertexOutput {
10799
+ @builtin(position) position: vec4<f32>,
10800
+ @location(0) uv: vec2<f32>, @location(1) image: vec2<f32>,
10801
+ };
10802
+ @vertex fn mainVertex(@location(0) aPosition: vec2<f32>) -> VertexOutput {
10803
+ var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy;
10804
+ position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0;
10805
+ position.y = position.y * (2.0 * gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z;
10806
+ let uv = aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw);
10807
+ return VertexOutput(vec4(position, 0.0, 1.0), uv,
10808
+ (inspectionUniforms.uImageMatrix * vec3(uv, 1.0)).xy);
10809
+ }
10810
+ fn lookup(tile: vec2<f32>, luma: f32) -> f32 {
10811
+ let tiles = inspectionUniforms.uTiles;
10812
+ let row = tile.y * tiles.x + tile.x;
10813
+ return textureSampleLevel(uLut, uLutSampler,
10814
+ vec2((luma * 255.0 + 0.5) / 256.0, (row + 0.5) / (tiles.x * tiles.y)), 0.0).r;
10815
+ }
10816
+ @fragment fn mainFragment(input: VertexOutput) -> @location(0) vec4<f32> {
10817
+ let color = textureSample(uTexture, uSampler, input.uv);
10818
+ if (color.a == 0.0) { return color; }
10819
+ var rgb = color.rgb / color.a;
10820
+ if (inspectionUniforms.uAuto > 0.5) {
10821
+ let luma = dot(rgb, vec3(0.299, 0.587, 0.114));
10822
+ let tiles = inspectionUniforms.uTiles;
10823
+ let tilePosition = clamp(input.image, vec2(0.0), vec2(1.0)) * tiles - 0.5;
10824
+ let unbounded = floor(tilePosition);
10825
+ let f = tilePosition - unbounded;
10826
+ let t0 = clamp(unbounded, vec2(0.0), tiles - 1.0);
10827
+ let t1 = clamp(unbounded + 1.0, vec2(0.0), tiles - 1.0);
10828
+ let top = mix(lookup(t0, luma), lookup(vec2(t1.x, t0.y), luma), f.x);
10829
+ let bottom = mix(lookup(vec2(t0.x, t1.y), luma), lookup(t1, luma), f.x);
10830
+ rgb = clamp(rgb + (mix(top, bottom, f.y) - luma), vec3(0.0), vec3(1.0));
10831
+ }
10832
+ return vec4(pow(rgb, vec3(1.0 / inspectionUniforms.uGamma)) * color.a, color.a);
10833
+ }
10834
+ `;
10835
+ function createPixiMediaInspectionFilter(sprite, pixi) {
10836
+ const { BufferImageSource, Filter, Matrix } = pixi;
10837
+ const pixels = new Uint8Array(256 * 64 * 4);
10838
+ const lutSource = new BufferImageSource({
10839
+ resource: pixels,
10840
+ width: 256,
10841
+ height: 64,
10842
+ scaleMode: "linear",
10843
+ alphaMode: "no-premultiply-alpha",
10844
+ });
10845
+ const filter = Filter.from({
10846
+ gl: { vertex: vertex, fragment: fragment },
10847
+ gpu: {
10848
+ vertex: { source: gpuSource, entryPoint: "mainVertex" },
10849
+ fragment: { source: gpuSource, entryPoint: "mainFragment" },
10850
+ },
10851
+ resolution: "inherit",
10852
+ resources: {
10853
+ inspectionUniforms: {
10854
+ uImageMatrix: { value: new Matrix(), type: "mat3x3<f32>" },
10855
+ uTiles: { value: new Float32Array([8, 8]), type: "vec2<f32>" },
10856
+ uGamma: { value: 1, type: "f32" },
10857
+ uAuto: { value: 0, type: "f32" },
10858
+ },
10859
+ uLut: lutSource,
10860
+ uLutSampler: lutSource.style,
10861
+ },
10862
+ });
10863
+ const uniforms = filter.resources.inspectionUniforms.uniforms;
10864
+ let hasHistogram = false;
10865
+ filter.apply = (manager, input, output, clear) => {
10866
+ // The filter input is clipped by the viewport; lookup tiles stay anchored
10867
+ // to the full image when the user zooms or pans.
10868
+ manager.calculateSpriteMatrix(uniforms.uImageMatrix, sprite);
10869
+ manager.applyFilter(filter, input, output, clear);
10870
+ };
10871
+ return {
10872
+ filter,
10873
+ invalidateImage() {
10874
+ hasHistogram = false;
10875
+ },
10876
+ update(gamma, autoContrast, image) {
10877
+ uniforms.uGamma = Math.max(gamma, 0.0001);
10878
+ if (autoContrast && image && !hasHistogram) {
10879
+ const scale = Math.min(1, HISTOGRAM_MAX_DIMENSION / Math.max(image.width, image.height));
10880
+ const sample = document.createElement("canvas");
10881
+ sample.width = Math.max(1, Math.round(image.width * scale));
10882
+ sample.height = Math.max(1, Math.round(image.height * scale));
10883
+ const context = sample.getContext("2d", { willReadFrequently: true });
10884
+ context.drawImage(image, 0, 0, sample.width, sample.height);
10885
+ const { luts, tilesX, tilesY } = computeMediaClahe(context.getImageData(0, 0, sample.width, sample.height).data, sample.width, sample.height);
10886
+ for (let i = 0; i < luts.length; i++) {
10887
+ pixels[i * 4] = luts[i];
10888
+ pixels[i * 4 + 1] = luts[i];
10889
+ pixels[i * 4 + 2] = luts[i];
10890
+ pixels[i * 4 + 3] = 255;
10891
+ }
10892
+ uniforms.uTiles.set([tilesX, tilesY]);
10893
+ lutSource.resource = pixels.subarray(0, luts.length * 4);
10894
+ lutSource.resize(256, tilesX * tilesY);
10895
+ lutSource.update();
10896
+ hasHistogram = true;
10897
+ }
10898
+ uniforms.uAuto = autoContrast && hasHistogram ? 1 : 0;
10899
+ },
10900
+ destroy() {
10901
+ destroyShaderKeepingProgram(filter);
10902
+ lutSource.destroy();
10903
+ },
10904
+ };
10905
+ }
10906
+
10653
10907
  /**
10654
10908
  * Which frame a media time draws and how much of it is cooked, as two separate
10655
10909
  * facts. Reporting readiness is what the snapshot and the token are for; a
@@ -10789,35 +11043,6 @@ void main(void) {
10789
11043
  }
10790
11044
  `;
10791
11045
 
10792
- /**
10793
- * Pixi keys its program cache by source, so one program is shared by every
10794
- * shader built from it. `destroy(true)` takes that program with it, leaving
10795
- * both the peers still rendering through it and the next shader built from the
10796
- * same source bound to nothing.
10797
- */
10798
- function destroyShaderKeepingProgram(shader) {
10799
- try {
10800
- shader.destroy();
10801
- }
10802
- catch {
10803
- // Pixi has already invalidated this shader's resource group.
10804
- }
10805
- }
10806
- /**
10807
- * Pixi uploads a placeholder while it builds a shader's first bind group, and
10808
- * WebGPU rejects a canvas that was never given a rendering context.
10809
- */
10810
- function createShaderPlaceholderCanvas() {
10811
- if (typeof document === "undefined") {
10812
- return { height: 1, width: 1 };
10813
- }
10814
- const canvas = document.createElement("canvas");
10815
- canvas.height = 1;
10816
- canvas.width = 1;
10817
- canvas.getContext("2d");
10818
- return canvas;
10819
- }
10820
-
10821
11046
  /**
10822
11047
  * The halo a detection actually paints, or `undefined`. A style may answer an
10823
11048
  * instruction that draws nothing, and both the coverage the halo prepares and
@@ -18572,6 +18797,9 @@ async function createPixiMediaScene(options) {
18572
18797
  let isScrubbing = false;
18573
18798
  let displayBrightness = 1;
18574
18799
  let displayContrast = 1;
18800
+ let displayGamma = 1;
18801
+ let displayAutoContrast = false;
18802
+ let inspectionFilter;
18575
18803
  let viewportScale = 1;
18576
18804
  let hasPresentedSample = false;
18577
18805
  let mediaHeight = 0;
@@ -19429,6 +19657,7 @@ async function createPixiMediaScene(options) {
19429
19657
  if (!collectFrameTimings) {
19430
19658
  sample.draw(stagingContext, 0, 0, mediaWidth, mediaHeight);
19431
19659
  presentedSampleTimestamp = sample.timestamp;
19660
+ refreshInspectionImage();
19432
19661
  stagingTextureSource?.update();
19433
19662
  stagingTexture?.update();
19434
19663
  const { boxState, regionState } = drawFramePresentLayers(annotationDrawLayers, sample.timestamp);
@@ -19444,6 +19673,7 @@ async function createPixiMediaScene(options) {
19444
19673
  const mediaUploadMs = measure(() => {
19445
19674
  sample.draw(stagingContext, 0, 0, mediaWidth, mediaHeight);
19446
19675
  presentedSampleTimestamp = sample.timestamp;
19676
+ refreshInspectionImage();
19447
19677
  stagingTextureSource?.update();
19448
19678
  stagingTexture?.update();
19449
19679
  });
@@ -19507,15 +19737,27 @@ async function createPixiMediaScene(options) {
19507
19737
  },
19508
19738
  setDisplayAdjustments(adjustments) {
19509
19739
  void import('pixi.js').then(({ ColorMatrixFilter }) => {
19740
+ if (isDestroyed)
19741
+ return;
19510
19742
  const brightness = adjustments.brightness ?? 1;
19511
19743
  const contrast = adjustments.contrast ?? 1;
19512
19744
  const filter = new ColorMatrixFilter();
19513
19745
  // Preserve the renderer's pixel grid through the adjustment pass.
19514
19746
  filter.resolution = "inherit";
19515
19747
  filter.brightness(brightness, false);
19516
- filter.contrast(contrast - 1, true);
19517
- if (mediaSprite)
19518
- mediaSprite.filters = [filter];
19748
+ displayGamma = adjustments.gamma ?? 1;
19749
+ displayAutoContrast = stillFrame && adjustments.autoContrast === true;
19750
+ filter.contrast(displayAutoContrast ? 0 : contrast - 1, true);
19751
+ if (mediaSprite) {
19752
+ if (displayGamma !== 1 || displayAutoContrast) {
19753
+ inspectionFilter ??= createPixiMediaInspectionFilter(mediaSprite, pixi);
19754
+ inspectionFilter.update(displayGamma, displayAutoContrast, hasPresentedSample ? stagingCanvas : null);
19755
+ mediaSprite.filters = [inspectionFilter.filter, filter];
19756
+ }
19757
+ else {
19758
+ mediaSprite.filters = [filter];
19759
+ }
19760
+ }
19519
19761
  displayBrightness = brightness;
19520
19762
  displayContrast = contrast;
19521
19763
  renderOnChange();
@@ -19822,6 +20064,7 @@ async function createPixiMediaScene(options) {
19822
20064
  // a decoder buffer in a producer that outlives the scene.
19823
20065
  frameChannel?.onPresentedFrame((presented) => presented.frame.close());
19824
20066
  destroyBatchTextureBindings();
20067
+ inspectionFilter?.destroy();
19825
20068
  mediaCompositor?.destroy();
19826
20069
  interactionLayer?.destroy();
19827
20070
  interactionPresentationLayer?.destroy();
@@ -20532,6 +20775,12 @@ async function createPixiMediaScene(options) {
20532
20775
  app.ticker.remove(drawAnnotationOverlayNow);
20533
20776
  }
20534
20777
  }
20778
+ function refreshInspectionImage() {
20779
+ if (!stillFrame || !inspectionFilter)
20780
+ return;
20781
+ inspectionFilter.invalidateImage();
20782
+ inspectionFilter.update(displayGamma, displayAutoContrast, stagingCanvas);
20783
+ }
20535
20784
  function hasLoadingDetections() {
20536
20785
  const loading = currentVisibility?.loadingDetectionIds;
20537
20786
  if (!loading)
@@ -20563,6 +20812,8 @@ async function createPixiMediaScene(options) {
20563
20812
  app.renderer.resolution,
20564
20813
  displayBrightness,
20565
20814
  displayContrast,
20815
+ displayGamma,
20816
+ displayAutoContrast,
20566
20817
  ...RENDERED_PRESENTATION_FIELDS.map((field) => appliedPresentation?.[field]),
20567
20818
  // Styles above compare by identity; mask opacity is applied after
20568
20819
  // preparation, so a host can move it inside a style object it keeps.