pathology/viewer/src/components/inspect-page/inspect-page.component.ts (275 lines of code) (raw):
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {CommonModule} from '@angular/common';
import {HttpErrorResponse} from '@angular/common/http';
import {ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, Optional, ViewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatDialogRef} from '@angular/material/dialog';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatIconModule} from '@angular/material/icon';
import {MatInputModule} from '@angular/material/input';
import {MatSelectModule} from '@angular/material/select';
import {ActivatedRoute, Router} from '@angular/router';
import {forkJoin, from, of, ReplaySubject, throwError} from 'rxjs';
import {bufferCount, catchError, distinctUntilChanged, first, map, mergeMap, takeUntil, tap, toArray} from 'rxjs/operators';
import {InspectPageParams} from '../../app/app.routes';
import {DICOM_STORE, DicomStores} from '../../interfaces/dicom_store_descriptor';
import {SlideInfo} from '../../interfaces/slide_descriptor';
import {DicomwebService, IccProfileType} from '../../services/dicomweb.service';
import {SlideApiService} from '../../services/slide-api.service';
import {pixelSpacingToMagnification} from '../../utils/zoom_name_to_val';
const IMAGE_DICOM_STORE = DicomStores.IMAGE;
const SELECTED_DICOM_STORE = DICOM_STORE[IMAGE_DICOM_STORE];
interface Tile {
instanceUid: string;
frame: number;
downsample: number;
uid: string;
}
/**
* Inspect page component.
*/
@Component({
selector: 'inspect-page',
standalone: true,
imports: [
CommonModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
MatIconModule,
MatSelectModule,
MatButtonModule,
],
templateUrl: './inspect-page.component.html',
styleUrl: './inspect-page.component.scss'
})
export class InspectPageComponent implements OnInit, OnDestroy {
urlSeriesUid = '';
seriesUids: string[] = [];
errorMsg = '';
slideInfo?: SlideInfo;
benchmark = {
icc: true,
disableCache: false,
realLayersOnly: false,
callComplete: 0,
tilesComplete: 0,
numTilesToLoadBaseLayer: 16,
numFramesPerStride: 5,
numTilesPerCall: 1,
concurency: 5,
latency: 0,
realTime: 0,
first10TilesTime: 0,
};
benchmarkRunning = false;
enableDevOptions = false;
readonly cleanDicomwebRegex = new RegExp('.*dicomWeb/');
private readonly destroyed$ = new ReplaySubject<boolean>(1);
@ViewChild('callTable', {static: true})
callTable!: ElementRef<HTMLTableElement>;
constructor(
private readonly elRef: ElementRef<HTMLElement>,
private readonly dicomwebService: DicomwebService,
private readonly slideApiService: SlideApiService,
private readonly ref: ChangeDetectorRef,
private readonly route: ActivatedRoute,
private readonly router: Router,
@Optional() public dialogRef?: MatDialogRef<InspectPageComponent>,
) {
this.enableDevOptions = !this.dialogRef;
}
ngOnInit() {
this.route.queryParams
.pipe(
takeUntil(this.destroyed$),
distinctUntilChanged(),
)
.subscribe((params) => {
const urlSeriesUid = (params as InspectPageParams).series;
if (urlSeriesUid) {
this.urlSeriesUid = urlSeriesUid;
this.load();
}
});
}
ngOnDestroy() {
this.destroyed$.next(true);
this.destroyed$.complete();
}
updateUrl() {
this.router.navigate(['.'], {
relativeTo: this.route,
queryParams: {'series': this.urlSeriesUid},
queryParamsHandling:
'merge', // remove to replace all query params by provided
});
}
onSeriesUidChanged() {
this.slideInfo = undefined;
this.updateUrl();
}
drawCallTable(tilesToLoad: Tile[][]) {
// Clear table
const callTable = this.callTable.nativeElement;
while (callTable.firstChild) {
callTable.removeChild(callTable.firstChild);
}
// Draw table
for (const [index, level] of tilesToLoad.entries()) {
const th = document.createElement('th');
th.appendChild(document.createTextNode(index.toString()));
const tr = callTable.insertRow();
tr.appendChild(th);
for (const tile of level) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(tile.frame.toString()));
div.id = index.toString() + '_' + tile.frame.toString();
const td = tr.insertCell();
td.appendChild(div);
}
}
}
drawCallTileId(uid: string, state: 'called'|'complete'|'error') {
const e = document.getElementById(uid);
if (!e) {
return;
}
switch (state) {
case 'called':
e.classList.add('called');
break;
case 'complete':
e.classList.add('complete');
break;
default:
}
this.elRef.nativeElement.scrollTo(0, this.elRef.nativeElement.scrollHeight);
}
load() {
this.benchmarkRunning = false;
this.errorMsg = '';
this.slideInfo = undefined;
try {
this.slideApiService.getSlideInfo(this.urlSeriesUid)
.pipe(
first(), catchError(val => {
return throwError(val);
}),
tap(slideInfo => {
this.slideInfo = slideInfo;
}))
.subscribe({
error: (err) => {
this.errorHandle(err);
}
});
} catch (err) {
this.errorHandle(err);
return;
}
}
start() {
if (this.slideInfo === undefined || !this.slideInfo.levelMap) {
this.errorMsg = 'Missing slide level map';
return;
}
this.benchmarkRunning = true;
this.benchmark.callComplete = 0;
this.benchmark.tilesComplete = 0;
this.errorMsg = '';
this.ref.markForCheck();
this.benchmark.latency = 0;
const tilesToLoad: Tile[][] = [];
let levelCount = 0;
for (const level of this.slideInfo.levelMap) {
const levelProp = level.properties[0];
if (this.benchmark.realLayersOnly && level.downSampleMultiplier) continue;
const numFramesAvailable = Math.ceil(
levelProp.frames / Math.pow(level.downSampleMultiplier ?? 1, 2));
const numFramesToLoadThisLayer = Math.min(
Math.ceil(
this.benchmark.numTilesToLoadBaseLayer / Math.pow(2, levelCount)),
numFramesAvailable);
const step = Math.floor(
numFramesAvailable / this.benchmark.numFramesPerStride /
numFramesToLoadThisLayer);
// Add tiles in continous batches of numFramesPerStride.
tilesToLoad[levelCount] = [];
let currentFrame = 0;
for (let tileCount = 0; tileCount < numFramesToLoadThisLayer;) {
tilesToLoad[levelCount].push({
instanceUid: levelProp.instanceUid,
frame: currentFrame,
downsample: level.downSampleMultiplier ?? 0,
uid: levelCount.toString() + '_' + currentFrame.toString(),
} as Tile);
++tileCount;
currentFrame += tileCount % this.benchmark.numFramesPerStride ?
1 :
Math.max(step - this.benchmark.numFramesPerStride, 1);
}
++levelCount;
}
this.drawCallTable(tilesToLoad);
const startBenchmarkTime = performance.now();
from(tilesToLoad)
.pipe(
mergeMap(
arrOfTilesOfLevel =>
from(arrOfTilesOfLevel)
.pipe(bufferCount(this.benchmark.numTilesPerCall))),
mergeMap(
tilesToFetch => {
for (const tile of tilesToFetch) {
this.drawCallTileId(tile.uid, 'called');
}
return forkJoin({
startTime: of(performance.now()),
bytes: this.dicomwebService.getEncodedImageTiles(
this.urlSeriesUid, tilesToFetch[0].instanceUid,
tilesToFetch.map(t => t.frame + 1),
SELECTED_DICOM_STORE, tilesToFetch[0].downsample,
this.benchmark.icc ? IccProfileType.SRGB :
IccProfileType.NONE,
this.benchmark.disableCache),
tileUids: of(tilesToFetch.map(tile => tile.uid)),
});
},
this.benchmark.concurency),
map((result) => ({
tileUids: result.tileUids,
latency: performance.now() - result.startTime
})),
tap((out) => {
++this.benchmark.callComplete;
this.benchmark.tilesComplete += out.tileUids.length;
if (this.benchmark.first10TilesTime === 0 &&
this.benchmark.tilesComplete >= 10) {
this.benchmark.first10TilesTime =
performance.now() - startBenchmarkTime;
}
for (const uid of out.tileUids) {
this.drawCallTileId(uid, 'complete');
}
}),
map(out => out.latency), toArray())
.subscribe(
(arr) => {
this.benchmark.realTime = performance.now() - startBenchmarkTime;
this.benchmarkRunning = false;
this.benchmark.latency =
arr.reduce((a, b) => a + b) / this.benchmark.tilesComplete;
},
(err) => {
this.errorHandle(err);
});
}
errorHandle(err: unknown) {
if (typeof err === 'string') {
this.errorMsg = err;
} else if (err instanceof Error || err instanceof HttpErrorResponse) {
this.errorMsg = err.message + '\n' + JSON.stringify(err);
} else {
this.errorMsg = 'failed';
}
this.benchmarkRunning = false;
}
pixelSpacingToMagnification(ps: number) {
return pixelSpacingToMagnification(ps / 10e5);
}
}