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¶
- Abra Histograma na barra lateral. O painel mostra o gráfico, enquanto a imagem continua no viewport principal.
- Arraste um retângulo diretamente sobre essa imagem. A ROI fica destacada e as barras mudam durante o gesto.
- Compare uma região escura, uma região intermediária e a borda clara. Observe onde aparecem os picos no eixo de intensidade.
- Use Imagem inteira para comparar a seleção com a distribuição de toda a imagem. Limpar ROI zera as contagens.
- 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.
Desenhe uma ROI. Explore o histograma.
Compare regiões da imagem e veja a distribuição de intensidades mudar em tempo real.
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:
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:
| 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¶
- Use um arquivo público, por exemplo CT-MONO2-16-ankle.dcm do GDCM. Registre a revisão e os arquivos usados.
- No viewer da aplicação, com o renderer Cornerstone e o painel registrado, importe os arquivos e aguarde o corte aparecer.
- Leia o corte e confira dimensões, identificador e índice. Selecione uma ROI e confira a identidade entre os totais.
- 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.
- 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.