Pular para conteúdo

Histograma

Vamos evoluir o primeiro componente para uma ferramenta visual: você desenha uma região retangular (ROI) na imagem e acompanha seu histograma de intensidade. A interação retoma a ideia do ciclo 1 em PyScript, agora como um componente React conectado ao EdgePy Viewer por customPanels.

Experimente o resultado

  1. Abra Histograma na barra lateral. O painel mostra o gráfico, enquanto a imagem continua no viewport principal.
  2. Arraste um retângulo diretamente sobre essa imagem. A ROI fica destacada e as barras mudam durante o gesto.
  3. Compare uma região escura, uma região intermediária e a borda clara. Observe onde aparecem os picos no eixo de intensidade.
  4. Use Imagem inteira para comparar a seleção com a distribuição de toda a imagem. Limpar ROI zera as contagens.
  5. No cabeçalho do laboratório, escolha Referência 4 × 4 para conferir o cálculo com poucos valores. O painel acompanha a troca de corte. Use Imagem de exemplo para voltar ao phantom.
ROI retangular destacada na imagem sintética e gráfico de histograma de intensidade ao lado.

Desenhe uma ROI. Explore o histograma.
Compare regiões da imagem e veja a distribuição de intensidades mudar em tempo real.

Experimentar o histograma ↗

Abrir direto em nova aba · Baixar os exemplos · Conferir os hashes dos fontes

O laboratório usa o viewer real e os mesmos componentes mostrados nesta página. A imagem inicial é um phantom sintético de 256 × 256 pixels, gerado no navegador para explorar regiões com diferentes intensidades. Não é um exame de paciente e seus valores não têm unidade física.

O painel lê os pixels pelo handle do renderizador e solicita uma sessão de seleção no viewport. O mesmo fluxo funciona com o renderizador sintético e com o Cornerstone atualizado, usando o corte do estudo importado. Ler corte atual permite repetir a leitura quando a imagem ainda estava carregando.

1. Entenda os contratos usados

Contrato Papel no exercício
customPanels / ViewerCustomPanel Registrar e apresentar o painel React
onViewportRendererHandleChange Receber o handle do renderizador ativo
getCurrentImageData?() Obter dimensões, índice do corte e cópia dos pixels
beginRectangularRoi?() Ativar seleção na imagem principal e receber a ROI durante o arraste
onControlPlaneSnapshotChange Acompanhar estudo, aquisição, série e corte para invalidar resultados antigos
App.tsx → registra HistogramPanel
  ├── snapshot → identifica o contexto e invalida a seleção anterior
  └── handle.getCurrentImageData() → matriz do corte
        └── handle.beginRectangularRoi() → seleção no viewport → coordenadas inteiras
              └── computeHistogram() → HistogramChart → gráfico de intensidade

O componente usa presentation: "side". O renderer mantém a imagem, converte as coordenadas do ponteiro e desenha o contorno. O painel recebe os limites da ROI e calcula a distribuição. Em telas pequenas, os estilos do exemplo colocam o gráfico abaixo do viewport, para manter a imagem acessível ao toque.

A seleção de ROI faz parte do contrato público desde a v0.3.0. Essa versão inclui beginRectangularRoi() e sua implementação no Cornerstone. Outros painéis de pesquisa podem reutilizar a mesma capacidade para calcular médias, estatísticas ou outras análises de regiões.

2. Organize os arquivos

Continue no workspace preparado em Comece aqui, com o painel Olá registrado. Os caminhos abaixo são relativos à raiz desse workspace:

apps/viewer-demo/src/histogram/
  histogram.ts          # cálculo puro
  histogram.test.ts     # referências numéricas
  exampleImage.ts       # phantom usado pelo renderizador do laboratório
  HistogramChart.tsx    # gráfico SVG com eixos e barras
  HistogramPanel.tsx    # leitura, estado e resultado
  histogram.css         # apresentação do componente

Extraia o ZIP de exemplos na raiz da cópia da v0.3.0 preparada em Comece aqui. Ele acrescenta os componentes da aplicação. O contrato, seus exports e o adaptador Cornerstone já estão nessa release pública.

Os blocos desta página e o laboratório usam os mesmos arquivos fonte. Não é necessário ter os fontes deste site para acompanhar o exercício.

3. Separe o cálculo da interface

A matriz usa ordem de linhas: values[y * width + x]. A ROI usa intervalos inteiros semiabertos, x=[x0,x1) e y=[y0,y1): os limites superiores ficam fora da seleção. O total selecionado é (x1-x0) × (y1-y0). Se uma das dimensões for zero, a região está vazia e as contagens também serão zero.

O gráfico usa 32 intervalos entre o menor e o maior valor finito da imagem inteira. Essas bordas ficam estáveis enquanto você muda a ROI, permitindo comparar as regiões na mesma escala de intensidade. O eixo vertical mostra a quantidade de pixels em cada intervalo e se ajusta à maior contagem da seleção.

Cada intervalo inclui seu limite inferior. Somente o último também inclui o limite superior. Valores não finitos são contabilizados separadamente. Para uma imagem constante, a faixa recebe uma pequena extensão para manter os intervalos crescentes.

O modo Referência 4 × 4 usa bordas fixas [0,4,8,12,16]. Elas definem os intervalos [0,4), [4,8), [8,12) e [12,16], usados na verificação numérica ao final da página.

histogram.ts — código completo
export interface Roi { x0: number; x1: number; y0: number; y1: number }

interface PixelImage { values: Float32Array; width: number; height: number }

export function imageRange(values: Float32Array): readonly [number, number] {
  let min = Infinity;
  let max = -Infinity;
  for (const value of values) {
    if (Number.isFinite(value)) {
      min = Math.min(min, value);
      max = Math.max(max, value);
    }
  }
  if (min === Infinity) return [0, 1];
  if (min === max) return [min, min + Math.max(1, Math.abs(min) * 1e-6)];
  return [min, max];
}

export function histogramEdges(values: Float32Array, bins = 32): number[] {
  if (!Number.isInteger(bins) || bins < 1 || bins > 256) throw new Error("Número de intervalos inválido");
  const [min, max] = imageRange(values);
  return Array.from({ length: bins + 1 }, (_, i) => min + (max - min) * i / bins);
}

export function isScalarImage({ width, height, values }: PixelImage): boolean {
  return Number.isInteger(width) && Number.isInteger(height) &&
    width > 0 && height > 0 && values.length === width * height;
}

function validateEdges(edges: readonly number[]) {
  if (edges.length < 2 || edges.some((v, i) =>
    !Number.isFinite(v) || (i > 0 && v <= edges[i - 1]!))) {
    throw new Error("As bordas devem ser finitas e crescentes");
  }
}

function validateRoi(roi: Roi, image: PixelImage) {
  if (!Object.values(roi).every(Number.isInteger) ||
      roi.x0 < 0 || roi.y0 < 0 || roi.x1 > image.width || roi.y1 > image.height ||
      roi.x0 > roi.x1 || roi.y0 > roi.y1) {
    throw new Error("ROI inválida");
  }
}

export function computeHistogram(image: PixelImage, roi: Roi, edges: readonly number[]) {
  if (!isScalarImage(image)) throw new Error("Dimensões inválidas");
  validateEdges(edges);
  validateRoi(roi, image);
  const counts = Array<number>(edges.length - 1).fill(0);
  let nonFinite = 0;
  let outside = 0;
  for (let y = roi.y0; y < roi.y1; y++) {
    for (let x = roi.x0; x < roi.x1; x++) {
      const value = image.values[y * image.width + x]!;
      if (!Number.isFinite(value)) { nonFinite++; continue; }
      if (value < edges[0]! || value > edges[edges.length - 1]!) {
        outside++; continue;
      }
      const upper = edges.findIndex((edge, i) => i > 0 && value < edge);
      const bin = upper === -1 ? counts.length - 1 : upper - 1;
      counts[bin] = counts[bin]! + 1;
    }
  }
  return {
    counts, nonFinite, outside,
    selected: (roi.x1 - roi.x0) * (roi.y1 - roi.y0),
    included: counts.reduce((sum, count) => sum + count, 0),
  };
}

A identidade que deve valer é:

selecionados = incluídos + não finitos + fora da faixa
incluídos = soma das contagens dos intervalos

4. Converta o ponteiro em índices

No renderizador sintético, a conversão usa a posição e o tamanho exibido da imagem. No Cornerstone, o adaptador passa pelas transformações canvasToWorld, worldToIndex, indexToWorld e worldToCanvas. Assim, a seleção e o contorno usam a geometria do viewport, incluindo zoom e deslocamento.

O exemplo arredonda para a borda inteira mais próxima, limita as coordenadas a [0,width] e [0,height] e ordena os extremos. Isso permite arrastar nos dois sentidos. Um clique sem deslocamento produz uma seleção vazia. A captura de ponteiro permite concluir o gesto fora do canvas.

rectangularRoi.ts — interação e contorno no viewport
import type { ClinicalViewportRoi, ClinicalViewportRoiOptions, ClinicalViewportRoiSession } from "../ports/viewportRenderer";

type Point = readonly [number, number];
interface Binding extends ClinicalViewportRoiOptions {
  element: HTMLElement;
  isCurrent(): boolean;
  canvasToImage(point: Point): Point;
  imageToCanvas(point: Point): Point;
}
export interface RectangularRoiBinding extends ClinicalViewportRoiSession {
  redraw(): void;
  invalidate(): void;
}

export function roiFromPoints(start: Point, end: Point, { width, height }: { width: number; height: number }): ClinicalViewportRoi {
  const x = (value: number) => Math.max(0, Math.min(width, Math.round(value)));
  const y = (value: number) => Math.max(0, Math.min(height, Math.round(value)));
  return { x0: x(Math.min(start[0], end[0])), x1: x(Math.max(start[0], end[0])),
    y0: y(Math.min(start[1], end[1])), y1: y(Math.max(start[1], end[1])) };
}

/** DOM interaction shared by renderers. All geometry stays with the renderer. */
export function bindRectangularRoi(options: Binding): RectangularRoiBinding {
  const { element, image, onChange } = options;
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  const outline = document.createElementNS(svg.namespaceURI, "polygon");
  svg.classList.add("edgepy-viewport-roi");
  svg.setAttribute("aria-hidden", "true");
  Object.assign(svg.style, { position: "absolute", inset: "0", width: "100%", height: "100%", pointerEvents: "none", zIndex: "5" });
  outline.setAttribute("fill", "#2be3d21a");
  outline.setAttribute("stroke", "#2be3d2");
  outline.setAttribute("stroke-width", "2");
  svg.append(outline);
  element.append(svg);
  const previous = { cursor: element.style.cursor, touchAction: element.style.touchAction };
  element.style.cursor = "crosshair";
  element.style.touchAction = "none";
  const abort = new AbortController();
  let disposed = false;
  let roi: ClinicalViewportRoi = { x0: 0, x1: 0, y0: 0, y1: 0 };
  let drag: { pointerId: number; start: Point; previous: ClinicalViewportRoi } | undefined;

  function release() {
    const pointerId = drag?.pointerId;
    drag = undefined;
    if (pointerId !== undefined && element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId);
  }
  function dispose() {
    if (disposed) return;
    disposed = true;
    abort.abort();
    release();
    observer.disconnect();
    svg.remove();
    Object.assign(element.style, previous);
  }
  function invalidate() {
    if (disposed) return;
    dispose();
    onChange(undefined);
  }
  function current() {
    if (disposed) return false;
    if (!options.isCurrent()) { invalidate(); return false; }
    return true;
  }
  function redraw() {
    if (!current()) return;
    const corners: Point[] = [[roi.x0, roi.y0], [roi.x1, roi.y0], [roi.x1, roi.y1], [roi.x0, roi.y1]];
    outline.setAttribute("points", corners.map((point) => options.imageToCanvas(point).join(",")).join(" "));
    outline.setAttribute("visibility", roi.x0 === roi.x1 || roi.y0 === roi.y1 ? "hidden" : "visible");
  }
  function setRoi(next: ClinicalViewportRoi) {
    if (!current() || !Object.values(next).every(Number.isInteger) || next.x0 < 0 || next.y0 < 0 ||
      next.x1 > image.width || next.y1 > image.height || next.x0 > next.x1 || next.y0 > next.y1) return false;
    roi = { ...next };
    redraw();
    onChange({ image, roi: { ...roi } });
    return true;
  }
  function point(event: PointerEvent): Point {
    const rect = element.getBoundingClientRect();
    return options.canvasToImage([event.clientX - rect.left, event.clientY - rect.top]);
  }
  function move(event: PointerEvent) {
    if (!drag || event.pointerId !== drag.pointerId || !current()) return;
    event.preventDefault();
    event.stopPropagation();
    setRoi(roiFromPoints(drag.start, point(event), image));
  }
  const listenerOptions = { capture: true, signal: abort.signal };
  element.addEventListener("pointerdown", (event) => {
    if (event.button !== 0 || !event.isPrimary || drag || !current()) return;
    const start = point(event);
    if (!start.every(Number.isFinite) || start[0] < 0 || start[1] < 0 || start[0] > image.width || start[1] > image.height) return;
    event.preventDefault();
    event.stopPropagation();
    drag = { pointerId: event.pointerId, start, previous: roi };
    element.setPointerCapture(event.pointerId);
    move(event);
  }, listenerOptions);
  element.addEventListener("pointermove", move, listenerOptions);
  element.addEventListener("pointerup", (event) => {
    if (drag?.pointerId !== event.pointerId) return;
    move(event);
    release();
  }, listenerOptions);
  const cancel = (event: PointerEvent) => {
    if (drag?.pointerId !== event.pointerId) return;
    const last = drag.previous;
    release();
    setRoi(last);
  };
  element.addEventListener("pointercancel", cancel, listenerOptions);
  element.addEventListener("lostpointercapture", cancel, listenerOptions);
  const observer = new ResizeObserver(redraw);
  observer.observe(element);
  redraw();
  return { setRoi, dispose, redraw, invalidate };
}
cornerstoneRoi.ts — geometria e eventos do Cornerstone
import { bindRectangularRoi, type ClinicalViewportRoiOptions, type RectangularRoiBinding } from "@pycom-next/react-viewer";
import type { StackViewportLike } from "./cornerstoneViewportLifecycle";

export function beginCornerstoneRoi(element: HTMLElement, viewport: StackViewportLike, options: ClinicalViewportRoiOptions): RectangularRoiBinding | undefined {
  const { image } = options;
  const isCurrent = () => {
    const data = viewport.getImageData?.();
    return !!image.imageId && viewport.getCurrentImageId?.() === image.imageId &&
      viewport.getCurrentImageIdIndex() === image.sliceIndex &&
      data?.dimensions[0] === image.width && data.dimensions[1] === image.height;
  };
  if (!isCurrent() || !viewport.canvasToWorld || !viewport.worldToCanvas) return undefined;
  const events = new AbortController();
  const binding = bindRectangularRoi({
    ...options, element, isCurrent,
    onChange(selection) {
      if (!selection) events.abort();
      options.onChange(selection);
    },
    canvasToImage(point) {
      const index = viewport.getImageData!()!.imageData.worldToIndex(viewport.canvasToWorld!([...point]));
      return [index[0]!, index[1]!];
    },
    imageToCanvas(point) {
      const world = viewport.getImageData!()!.imageData.indexToWorld([point[0], point[1], 0]);
      return viewport.worldToCanvas!(world);
    },
  });
  const dispose = () => { events.abort(); binding.dispose(); };
  const invalidate = () => { events.abort(); binding.invalidate(); };
  for (const name of ["CORNERSTONE_CAMERA_MODIFIED", "CORNERSTONE_IMAGE_RENDERED", "CORNERSTONE_STACK_NEW_IMAGE"]) {
    element.addEventListener(name, () => { if (isCurrent()) binding.redraw(); else invalidate(); }, { signal: events.signal });
  }
  element.addEventListener("CORNERSTONE_PRE_STACK_NEW_IMAGE", invalidate, { signal: events.signal });
  return { ...binding, dispose, invalidate };
}

Uma camada SVG destaca o retângulo sobre a imagem principal. Enquanto o ponteiro se move, as coordenadas atualizam o gráfico no painel. Editar coordenadas pelo teclado também usa a sessão do renderer, mantendo o contorno sincronizado. Cancelar um gesto restaura a seleção anterior. Fechar o painel remove o contorno e libera os eventos de ponteiro.

Cada sessão pertence à identidade de uma imagem e ao índice de corte lido. Trocar o corte invalida a sessão antes de aceitar outra seleção. Mudanças de câmera, renderização e tamanho redesenham o contorno com as transformações atuais.

A normalização em cinza existe apenas no buffer de exibição. O cálculo utiliza image.values, que permanece intacto. Alterar uma janela de visualização não deve alterar os valores do histograma.

5. Construa o painel

getCurrentImageData() é opcional e assíncrono. O painel trata ausência das capacidades, resposta vazia, dimensões incompatíveis e falha de leitura. O efeito cancela a leitura anterior ao mudar de contexto ou desmontar. Respostas atrasadas são descartadas, e a sessão anterior é liberada por dispose().

A leitura acontece ao abrir o painel e ao mudar o contexto. Se o renderer ainda estiver carregando, aguarde e use Ler corte atual. Depois de obter a imagem e iniciar a sessão, o componente seleciona toda a matriz. Cada novo arraste recalcula a distribuição.

HistogramChart desenha as barras em SVG, com intensidade no eixo horizontal e quantidade de pixels no vertical. Passe o ponteiro sobre uma barra para consultar seu intervalo e contagem. A seção Contagens e dados da imagem oferece os mesmos números em uma tabela.

HistogramChart.tsx — gráfico de intensidade
import { useId } from "react";

const plot = { x: 64, y: 28, width: 500, height: 232 };
const number = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 2 });

export function HistogramChart({ counts, edges }: Readonly<{
  counts: readonly number[];
  edges: readonly number[];
}>) {
  const titleId = useId();
  const descriptionId = useId();
  const peak = Math.max(1, ...counts);
  const min = edges[0]!;
  const max = edges[edges.length - 1]!;
  const total = counts.reduce((sum, value) => sum + value, 0);
  const yTicks = [...new Set([0, Math.ceil(peak / 2), peak])];
  const x = (value: number) => plot.x + (value - min) / (max - min) * plot.width;
  return <svg className="histogram-chart" viewBox="0 0 600 340" role="img"
    aria-labelledby={`${titleId} ${descriptionId}`}>
    <title id={titleId}>Histograma de intensidade da ROI</title>
    <desc id={descriptionId}>{total} pixels em {counts.length} intervalos. Eixo horizontal: intensidade do pixel. Eixo vertical: quantidade de pixels.</desc>
    {yTicks.map((tick) => {
      const y = plot.y + plot.height * (1 - tick / peak);
      return <g key={tick}>
        <line className="histogram-grid" x1={plot.x} x2={plot.x + plot.width} y1={y} y2={y} />
        <text className="histogram-tick" x={plot.x - 10} y={y + 4} textAnchor="end">{number.format(tick)}</text>
      </g>;
    })}
    {counts.map((count, i) => {
      const height = count / peak * plot.height;
      const label = `[${number.format(edges[i]!)}, ${number.format(edges[i + 1]!)}${i === counts.length - 1 ? "]" : ")"}: ${count} pixels`;
      return <rect key={edges[i]} className="histogram-bar" data-count={count}
        x={x(edges[i]!) + 0.5} y={plot.y + plot.height - height}
        width={Math.max(0, x(edges[i + 1]!) - x(edges[i]!) - 1)} height={height}>
        <title>{label}</title>
      </rect>;
    })}
    <path className="histogram-axis" d={`M${plot.x} ${plot.y}v${plot.height}h${plot.width}`} />
    {[0, 0.25, 0.5, 0.75, 1].map((fraction) => <text key={fraction} className="histogram-tick"
      x={plot.x + plot.width * fraction} y={plot.y + plot.height + 24} textAnchor="middle">
      {number.format(min + (max - min) * fraction)}
    </text>)}
    <text className="histogram-axis-label" x={314} y={318} textAnchor="middle">Intensidade (valor do pixel)</text>
    <text className="histogram-axis-label" transform="translate(17 144) rotate(-90)" textAnchor="middle">Pixels</text>
  </svg>;
}
HistogramPanel.tsx — código completo
import { useEffect, useMemo, useRef, useState } from "react";
import type { ClinicalViewportImageData, ClinicalViewportRendererHandle, ClinicalViewportRoiSession } from "@pycom-next/react-viewer";
import { computeHistogram, histogramEdges, isScalarImage, type Roi } from "./histogram";
import { HistogramChart } from "./HistogramChart";
import "./histogram.css";

const referenceEdges = [0, 4, 8, 12, 16];
const empty: Roi = { x0: 0, x1: 0, y0: 0, y1: 0 };
const wholeImage = (image: ClinicalViewportImageData): Roi => ({ x0: 0, x1: image.width, y0: 0, y1: image.height });

function validateImage(image: ClinicalViewportImageData | undefined, expectedSlice: number | undefined): asserts image is ClinicalViewportImageData {
  if (!image) throw new Error("Nenhum corte disponível. Aguarde e clique em Ler corte atual.");
  if (expectedSlice !== undefined && image.sliceIndex !== expectedSlice) throw new Error("O corte está mudando. Aguarde e tente novamente.");
  if (!isScalarImage(image)) throw new Error("A imagem não contém uma matriz escalar compatível.");
}

export function HistogramPanel({ handle, expectedSlice }: Readonly<{
  handle: ClinicalViewportRendererHandle | undefined;
  expectedSlice: number | undefined;
}>) {
  const [image, setImage] = useState<ClinicalViewportImageData>();
  const [roi, setRoi] = useState<Roi>(empty);
  const [message, setMessage] = useState("Lendo corte…");
  const [revision, setRevision] = useState(0);
  const session = useRef<ClinicalViewportRoiSession | undefined>(undefined);
  const edges = useMemo(() => image?.imageId === "tutorial-reference" ? referenceEdges : histogramEdges(image?.values ?? new Float32Array()), [image]);

  useEffect(() => {
    let cancelled = false;
    async function load() {
      setImage(undefined); setRoi(empty);
      if (!handle?.getCurrentImageData || !handle.beginRectangularRoi) {
        setMessage("O renderizador ativo precisa oferecer leitura de pixels e seleção de ROI."); return;
      }
      setMessage("Lendo corte…");
      try {
        const next = await handle.getCurrentImageData();
        if (cancelled) return;
        validateImage(next, expectedSlice);
        session.current = handle.beginRectangularRoi({
          image: { width: next.width, height: next.height, sliceIndex: next.sliceIndex, imageId: next.imageId, seriesUid: next.seriesUid },
          onChange(selection) {
            if (cancelled) return;
            if (selection) setRoi(selection.roi);
            else {
              setImage(undefined); setRoi(empty);
              setMessage("A imagem mudou. Leia o corte atual para selecionar outra ROI.");
            }
          },
        });
        if (!session.current) { setMessage("Seleção indisponível neste corte. Aguarde e tente novamente."); return; }
        setImage(next);
        session.current.setRoi(wholeImage(next));
        setMessage("Arraste diretamente na imagem principal para selecionar uma ROI.");
      } catch (error) {
        if (!cancelled) setMessage(String(error));
      }
    }
    void load();
    return () => { cancelled = true; session.current?.dispose(); session.current = undefined; };
  }, [handle, expectedSlice, revision]);

  const select = (next: Roi) => { session.current?.setRoi(next); };
  return <section className="histogram-workspace" aria-label="Histograma do corte">
    <header className="histogram-heading">
      <div><span className="histogram-eyebrow">Análise de imagem · ROI retangular</span>
        <h2>Histograma de intensidade</h2>
        <p>Selecione na imagem principal. Explore a distribuição aqui.</p>
      </div>
      <div className="histogram-source-actions">
        <button type="button" onClick={() => setRevision((value) => value + 1)}>Ler corte atual</button>
      </div>
    </header>
    <p className="histogram-source" role="status">{message}</p>
    {image && <>
      <HistogramResult image={image} roi={roi} edges={edges} />
        <div className="histogram-roi-actions">
          <button type="button" onClick={() => select(wholeImage(image))}>Imagem inteira</button>
          <button type="button" onClick={() => select(empty)}>Limpar ROI</button>
          {image.imageId === "tutorial-reference" && <button type="button" onClick={() => select({ x0: 1, x1: 3, y0: 1, y1: 3 })}>ROI central de referência</button>}
        </div>
        <p className="histogram-coordinates">x=[{roi.x0},{roi.x1}) · y=[{roi.y0},{roi.y1})</p>
        <details><summary>Editar coordenadas pelo teclado</summary><RoiCoordinates image={image} roi={roi} onChange={select} /></details>
    </>}
  </section>;
}

function HistogramResult({ image, roi, edges }: Readonly<{
  image: ClinicalViewportImageData; roi: Roi; edges: readonly number[];
}>) {
  const result = computeHistogram(image, roi, edges);
  return <div className="histogram-result">
    <div className="histogram-section-heading"><h3>Distribuição de intensidades</h3><span>{result.counts.length} intervalos</span></div>
    <HistogramChart counts={result.counts} edges={edges} />
    <div className="histogram-stats" aria-live="polite" aria-atomic="true">
      <div><strong>{result.included.toLocaleString("pt-BR")}</strong><span>pixels no gráfico</span></div>
      <div><strong>{(result.selected / (image.width * image.height) * 100).toFixed(1)}%</strong><span>da imagem selecionada</span></div>
    </div>
    <p className="histogram-hint">{result.selected === 0 ? "Selecione uma região para ver sua distribuição." : "Passe o ponteiro sobre as barras para consultar as contagens."}</p>
    <details><summary>Contagens e dados da imagem</summary>
      <p>Imagem: {image.imageId ?? "não informada"}. Série: {image.seriesUid ?? "não informada"}. Corte: {image.sliceIndex} (base zero).</p>
      <p>Selecionados: {result.selected}. Incluídos: {result.included}. Não finitos: {result.nonFinite}. Fora da faixa: {result.outside}.</p>
      <table>
        <caption>Contagens usadas no gráfico</caption>
        <thead><tr><th>Intervalo de intensidade</th><th>Pixels</th></tr></thead>
        <tbody>{result.counts.map((count, i) => <tr key={edges[i]}>
          <td>[{edges[i]}, {edges[i + 1]}{i === result.counts.length - 1 ? "]" : ")"}</td><td>{count}</td>
        </tr>)}</tbody>
      </table>
    </details>
  </div>;
}

function RoiCoordinates({ image, roi, onChange }: Readonly<{
  image: ClinicalViewportImageData; roi: Roi; onChange(roi: Roi): void;
}>) {
  const limits: Record<keyof Roi, readonly [number, number]> = {
    x0: [0, roi.x1], x1: [roi.x0, image.width], y0: [0, roi.y1], y1: [roi.y0, image.height],
  };
  return <fieldset>
    <legend>Limites inteiros da ROI</legend>
    {(["x0", "x1", "y0", "y1"] as const).map((field) => {
      const [min, max] = limits[field];
      return <label key={field}>{field} <input type="number" min={min} max={max} step={1}
        value={roi[field]} onChange={(event) => {
          const value = event.currentTarget.valueAsNumber;
          if (Number.isInteger(value)) onChange({ ...roi, [field]: Math.max(min, Math.min(max, value)) });
        }} /></label>;
    })}
  </fieldset>;
}

O phantom do laboratório é gerado localmente pelo renderizador sintético. O painel não cria outra imagem. O ZIP inclui o phantom e os estilos do gráfico:

exampleImage.ts — phantom sintético
import type { ClinicalViewportImageData } from "@pycom-next/react-viewer";

// Phantom didático gerado localmente. Não representa um exame de paciente.
export function createExampleImage(): ClinicalViewportImageData {
  const width = 256;
  const height = 256;
  const values = Float32Array.from({ length: width * height }, (_, i) => {
    const x = ((i % width) - 128) / 128;
    const y = (Math.floor(i / width) - 128) / 128;
    const radius = Math.hypot(x / 0.72, y / 0.88);
    if (radius > 1) return 8;
    const texture = 7 * Math.sin(x * 113 + y * 37) * Math.cos(y * 97 - x * 31);
    if (radius > 0.91) return Math.round(218 + texture);
    if (Math.hypot(x / 0.17, (y + 0.03) / 0.27) < 1) return Math.round(38 + texture);
    if (Math.hypot((x - 0.33) / 0.12, (y + 0.18) / 0.16) < 1) return Math.round(168 + texture);
    return Math.round(100 + 28 * Math.cos(x * 7) * Math.cos(y * 5) + texture);
  });
  return { width, height, values, sliceIndex: 0, imageId: "phantom-sintetico-256" };
}
histogram.css — estilos da imagem e do gráfico
.histogram-workspace { color: #e8edf6; padding: 4px; margin: auto; }
.histogram-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; padding-right: 36px; }
.histogram-eyebrow { color: #2be3d2; font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
.histogram-workspace h2 { font-size: 23px; margin: 7px 0; }
.histogram-workspace h3 { font-size: 16px; margin: 0; }
.histogram-heading p, .histogram-hint { color: #aabbd0; font-size: 14px; margin: 8px 0; }
.histogram-workspace button { cursor: pointer; font: inherit; font-size: 13px; padding: 8px 12px; margin: 3px; color: #e8edf6; background: #1b2436; border: 1px solid #2be3d250; border-radius: 7px; }
.histogram-workspace button:hover { border-color: #2be3d2; background: #183d45; }
.histogram-workspace :focus-visible { outline: 2px solid #ffd23f; outline-offset: 3px; }
.histogram-source { color: #aabbd0; font-size: 13px; border-left: 2px solid #2be3d2; padding: 5px 12px; }
.histogram-result { min-width: 0; padding: 12px; background: #101b2b; border: 1px solid #ffffff15; border-radius: 12px; }
.histogram-section-heading { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.histogram-section-heading span { color: #8fa4bd; font-size: 12px; }
.histogram-roi-actions { margin-top: 12px; }
.histogram-coordinates { color: #2be3d2; font: 12px ui-monospace, monospace; }
.histogram-chart { width: 100%; height: auto; display: block; }
.histogram-bar { fill: #2be3d2; }
.histogram-bar:hover { fill: #ffd23f; }
.histogram-grid { stroke: #ffffff14; stroke-dasharray: 3 5; }
.histogram-axis { stroke: #8fa4bd; fill: none; }
.histogram-tick { fill: #aabbd0; font: 11px system-ui, sans-serif; }
.histogram-axis-label { fill: #e8edf6; font: 13px system-ui, sans-serif; }
.histogram-stats { display: flex; flex-wrap: wrap; gap: 24px; padding: 18px 0; border-top: 1px solid #ffffff15; }
.histogram-stats strong { display: block; font-size: 26px; color: #2be3d2; }
.histogram-stats span { font-size: 12px; color: #aabbd0; }
.histogram-workspace details { margin-top: 16px; font-size: 13px; }
.histogram-workspace summary { cursor: pointer; color: #aabbd0; padding: 6px 0; }
.histogram-workspace fieldset { border: 1px solid #ffffff25; margin-top: 10px; }
.histogram-workspace label { display: inline-flex; align-items: center; gap: 5px; margin: 5px; }
.histogram-workspace input { width: 64px; padding: 6px; color: #e8edf6; background: #080c16; border: 1px solid #ffffff40; border-radius: 4px; }
.histogram-workspace table { width: 100%; font-size: 12px; border-collapse: collapse; }
.histogram-workspace td, .histogram-workspace th { text-align: left; padding: 6px; border-bottom: 1px solid #ffffff18; }
@media (max-width: 900px) {
  .pycom-next-layout[data-panel-open="true"][data-panel-wide="true"]:has(.histogram-workspace) { grid-template-columns: 62px minmax(300px, 42%) minmax(0, 1fr); }
}
@media (max-width: 760px) {
  .pycom-next-layout[data-panel-open="true"][data-panel-wide="true"]:has(.histogram-workspace) { grid-template-columns: 56px minmax(0, 1fr); grid-template-rows: minmax(220px, 45%) minmax(0, 1fr); }
  .pycom-next-layout:has(.histogram-workspace) .pycom-next-activity-rail { grid-row: 1 / -1; }
  .pycom-next-layout[data-panel-open="true"]:has(.histogram-workspace) .pycom-next-viewport { grid-column: 2; grid-row: 1; }
  .pycom-next-layout:has(.histogram-workspace) .pycom-next-side-panel { position: relative; inset: auto; grid-column: 2; grid-row: 2; width: auto; min-height: 0; border-top: 1px solid #2be3d255; }
  .histogram-heading { padding-right: 16px; }
  .histogram-workspace h2 { font-size: 19px; }
  .histogram-stats { gap: 16px; }
}

6. Registre na aplicação

Em apps/viewer-demo/src/App.tsx, acrescente o import abaixo. O tipo ViewerCustomPanel já foi importado no primeiro componente. Os hooks useEffect e useState já são usados pelo App:

import { HistogramPanel } from "./histogram/HistogramPanel";

Dentro de App, antes do return principal, acrescente:

const [histogramRevision, setHistogramRevision] = useState(0);
useEffect(() => {
  setHistogramRevision((revision) => revision + 1);
}, [
  imports.study,
  viewportHandle,
  viewerSnapshot?.study,
  viewerSnapshot?.activeScanId,
  viewerSnapshot?.activeSeriesUid,
  viewerSnapshot?.baseSeriesUid,
  viewerSnapshot?.activeSliceIndex,
]);

const histogramPanel: ViewerCustomPanel = {
  key: "histogram",
  label: "Histograma",
  presentation: "side",
  wide: true,
  content: <HistogramPanel
    key={histogramRevision}
    handle={viewportHandle}
    expectedSlice={viewerSnapshot?.activeSliceIndex}
  />,
};

No elemento PycomClinicalViewerNext, mantenha os painéis e callbacks existentes:

customPanels={[
  ...(viewerConfig.localAgents ? [agentsPanel] : []),
  helloPanel,
  histogramPanel,
]}
onControlPlaneSnapshotChange={setViewerSnapshot}
onViewportRendererHandleChange={setViewportHandle}

A nova key remonta o componente quando o contexto muda. A imagem lida, a ROI e o resultado anterior são descartados, e o painel tenta ler o novo corte. Fechar o painel também libera a sessão. O renderer pertence ao viewer, por isso a extensão consumidora chama apenas session.dispose(), nunca handle.destroy().

O laboratório usa a mesma composição, com um estudo e um handle sintéticos estáveis. Sua chave acompanha a série e o corte. Na aplicação com importação real, inclua as demais dependências acima.

7. Confira com uma referência conhecida

No cabeçalho do laboratório, escolha Referência 4 × 4 para trocar o corte do viewport por uma matriz pequena, com quatro intervalos no gráfico:

 0   1   2   3
 4   5   6   7
 8   9  10  11
12  13  14  15
Seleção ROI Contagens esperadas Incluídos
Imagem inteira x=[0,4), y=[0,4) [4,4,4,4] 16
Região central x=[1,3), y=[1,3) [0,2,2,0] 4
Região vazia x=[0,0), y=[0,0) [0,0,0,0] 0

A região central contém os valores 5,6,9,10. Arraste das bordas (1,1) a (3,3) na imagem principal e depois no sentido inverso: ambos devem produzir o mesmo resultado. Arraste para fora da imagem e confira que os índices permanecem dentro da matriz.

histogram.test.ts — testes numéricos
import { describe, expect, it } from "vitest";
import { computeHistogram, histogramEdges } from "./histogram";

describe("histograma de referência", () => {
  const values = Float32Array.from({ length: 16 }, (_, i) => i);
  const image = { values, width: 4, height: 4 };
  const edges = [0, 4, 8, 12, 16];
  it("conta a imagem inteira e a ROI central", () => {
    const full = computeHistogram(image,
      { x0: 0, x1: 4, y0: 0, y1: 4 }, edges);
    expect(full.counts).toEqual([4, 4, 4, 4]);
    expect(full.included).toBe(16);
    const center = computeHistogram(image,
      { x0: 1, x1: 3, y0: 1, y1: 3 }, edges);
    expect(center.counts).toEqual([0, 2, 2, 0]);
    expect(center.included).toBe(4);
  });
  it("inclui o último limite e separa descartes", () => {
    const result = computeHistogram({ values: new Float32Array([0, 4, 16, NaN, Infinity, -1]), width: 6, height: 1 },
      { x0: 0, x1: 6, y0: 0, y1: 1 }, edges);
    expect(result.counts).toEqual([1, 1, 0, 1]);
    expect(result.selected).toBe(result.included + result.nonFinite + result.outside);
    expect(result.nonFinite).toBe(2);
    expect(result.outside).toBe(1);
  });
  it("aceita seleção vazia", () => {
    expect(computeHistogram(image,
      { x0: 1, x1: 1, y0: 1, y1: 3 }, edges).counts).toEqual([0, 0, 0, 0]);
  });
  it("rejeita bordas e coordenadas inválidas", () => {
    expect(() => computeHistogram(image,
      { x0: 0, x1: 5, y0: 0, y1: 4 }, edges)).toThrow("ROI inválida");
    expect(() => computeHistogram(image,
      { x0: 0, x1: 4, y0: 0, y1: 4 }, [0, 4, 4])).toThrow("bordas");
  });
  it("cria intervalos para a faixa real de intensidades, inclusive valores negativos", () => {
    const image = { width: 5, height: 1, values: new Float32Array([-2, 0, 2, NaN, Infinity]) };
    const edges = histogramEdges(image.values, 4);
    expect(edges).toEqual([-2, -1, 0, 1, 2]);
    const result = computeHistogram(image, { x0: 0, x1: 5, y0: 0, y1: 1 }, edges);
    expect(result.counts).toEqual([1, 0, 1, 1]);
    expect(result.nonFinite).toBe(2);
    expect(result.outside).toBe(0);
  });
  it("produz intervalos crescentes para imagem constante ou sem valores finitos", () => {
    for (const values of [new Float32Array([5, 5]), new Float32Array([NaN, Infinity])]) {
      const edges = histogramEdges(values);
      expect(edges).toHaveLength(33);
      expect(edges.every((edge, i) => i === 0 || edge > edges[i - 1]!)).toBe(true);
      const result = computeHistogram({ width: 2, height: 1, values }, { x0: 0, x1: 2, y0: 0, y1: 1 }, edges);
      expect(result.included + result.nonFinite).toBe(2);
      expect(result.outside).toBe(0);
    }
  });
});

Execute na raiz do workspace:

npx vitest run apps/viewer-demo/src/histogram/histogram.test.ts
npm run test -w @pycom-next/react-viewer
npm run test -w @pycom-next/renderer-cornerstone
npm run typecheck
npm run build -w @pycom/clinical-viewer-next-demo

Experimente com DICOM

  1. Use um arquivo público, por exemplo CT-MONO2-16-ankle.dcm do GDCM. Registre a revisão e os arquivos usados.
  2. No viewer da aplicação, com o renderer Cornerstone e o painel registrado, importe os arquivos e aguarde o corte aparecer.
  3. Leia o corte e confira dimensões, identificador e índice. Selecione uma ROI e confira a identidade entre os totais.
  4. Troque estudo, série e corte, inclusive durante uma leitura. Os resultados e a seleção anteriores devem ser descartados. O painel passa a ler o novo corte.
  5. Mude a janela e leia o mesmo corte novamente: para a mesma matriz, ROI e bordas, as contagens devem ser iguais.

O percurso DICOM é verificado separadamente do laboratório sintético, incluindo o comportamento do loader e uma referência independente para a matriz decodificada.

Significado dos valores

O adaptador Cornerstone atual copia image.getPixelData() para um Float32Array. O contrato não informa unidade ou transformação aplicada pelo loader. Confira metadados e rescale antes de rotular valores como HU, atividade ou Gy.

O viewer legado calcula o histograma sobre o corte normalizado em 0–255. Neste exemplo, o cálculo usa os valores fornecidos pelo renderizador, e não os tons de cinza exibidos. Por isso, os resultados não são diretamente equivalentes. Uma distribuição de pixels de ROI 2D não é um histograma dose-volume.

8. Registre a extensão

Na sua cópia do repositório, registre os arquivos acrescentados em histogram/, o registro em App.tsx e a versão v0.3.0 utilizada como base. O laboratório deste site serve para experimentar o resultado e não é necessário para integrar o painel à sua aplicação.

Registre a revisão, os arquivos alterados, os comandos executados e os resultados observados. Anote também as dúvidas e o tempo gasto. Além dos testes numéricos, confira a seleção, a troca de contexto e o descarte de respostas antigas. Essas observações ajudam a avaliar como foi criar a extensão e onde o processo pode melhorar.

Verificações desta entrega

Foram conferidos tipagem, lint dos arquivos alterados, build da documentação e do viewer, além de 46 testes nos pacotes envolvidos e no cálculo do histograma. Em Chromium, foram exercitados arraste nos dois sentidos, atualização durante o gesto, limites da imagem, ROI vazia, coordenadas pelo teclado, troca de corte e limpeza ao fechar o painel. A seleção também foi conferida em largura de 390 px.

No Cornerstone, o arquivo público CT-MONO2-16-ankle.dcm foi decodificado como uma matriz de 512 × 512. As 32 contagens da ROI x=[128,384), y=[128,384) coincidiram com uma referência independente calculada com pydicom e NumPy, totalizando 65.536 pixels. A seleção foi conferida com zoom, deslocamento, redimensionamento e mudança de janela. Uma pilha DICOM sintética de dois cortes permitiu verificar navegação durante o arraste e descarte da seleção anterior.

Os exemplos também foram conferidos sobre o código da release pública v0.3.0, usando os contratos presentes no repositório. A reprodução por outro desenvolvedor pode registrar tempo, dúvidas e dificuldades além dessas verificações automatizadas.

Anterior: Meu primeiro componente · Próximo: Utilizando agentes de código.