pathology/viewer/src/interfaces/slide_model.ts (102 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 {BehaviorSubject} from 'rxjs'; import {filter, map} from 'rxjs/operators'; import { Visibility } from './visibility'; import { ChannelConfig, ChannelConfigEntry, getDefaultChannelConfig } from './channel_config'; import { ImageRectangle,ImageVector } from './image_overlay_geometry'; import {SlideDescriptor, SlideExtraMetadata, SlideInfo, SlideMetadata} from './slide_descriptor'; const defaultColorMixes = [[200, 0, 0], [0, 200, 0], [0, 0, 200], [100, 100, 0], [0, 100, 100]]; /** * Exported here for use throughout the application. */ export const TILE_SIZE = 256; /** * If the number of channels differs, * channel config behavior is triggered. */ export const NUM_COLOR_CHANNELS_WITHOUT_TRANSPARENCY = 3; /** Class representing a slide of pathology image data. */ export class SlideModel { slideInfo!: SlideInfo; initialized$ = new BehaviorSubject(false); slideMetadata!: SlideMetadata; slideExtraMetadata?: SlideExtraMetadata; channelConfig!: ChannelConfig; private readonly forceUpdateSubject = new BehaviorSubject(void 0); private forceUpdateTimeout?: NodeJS.Timeout; loadImage?: (rectangle: ImageRectangle, zoomLevel: number) => Promise<HTMLImageElement>; slideLabel = new BehaviorSubject(""); forceUpdate() { // Defer + debounce the channel config render. // This makes the input UI more responsive. if (typeof this.forceUpdateTimeout !== 'undefined') { clearTimeout(this.forceUpdateTimeout); } this.forceUpdateTimeout = setTimeout(() => { clearTimeout(this.forceUpdateTimeout); this.forceUpdateTimeout = undefined; this.forceUpdateSubject.next(void 0); }); } readonly forceUpdate$ = this.forceUpdateSubject.asObservable(); addChannelConfigEntry() { const [red, green, blue] = defaultColorMixes[this.channelConfig.entries.length]; const {channelCount} = this; const entry: ChannelConfigEntry = { volumeName: this.id, channel: Math.floor(channelCount / 2), channelCount, red, green, blue, weight: 25, min: 0, max: 1000, }; this.channelConfig.entries.push(this.buildReactiveEntry(entry, () => { this.forceUpdate(); })); this.forceUpdate(); return entry; } deleteChannelConfigEntry(indexToDelete: number) { this.channelConfig.entries = this.channelConfig.entries.filter( (entry, index) => index !== indexToDelete); this.forceUpdate(); } private size = new ImageVector(0, 0); private numZoomLevels = 1; id!: string; name?: string; channelCount!: number; volumeName!: string; visibility = new Visibility(); initializeFromDescriptor(slideDescriptor: SlideDescriptor) { const {id, name, slideInfo, channelConfig, customImageSource} = slideDescriptor; // Convert id to string, even if URL service parsed param as number. this.id = String(id); if (slideInfo) { this.setSlideInfo(slideInfo); } if (name) { this.name = name; } this.channelConfig = { entries: channelConfig?.entries?.map( entry => this.buildReactiveEntry( entry, () => { this.forceUpdate(); })) ?? [], }; if (channelConfig?.frozen) { this.channelConfig.frozen = true; } if (customImageSource) { const fallbackImage = new Image(TILE_SIZE, TILE_SIZE); this.loadImage = (rectangle: ImageRectangle, zoomLevel: number) => { const level = this.slideInfo.levelMap[zoomLevel]; const image = new Image(level.tileSize, level.tileSize); // Only allow integer values. Scale to appropriate value. const corner = rectangle.corner.scale(level.zoom).round(); const sourcePath = `${zoomLevel}/${corner.x}_${corner.y}`; if (this.sourceToTile.has(sourcePath)) { return this.sourceToTile.get(sourcePath)!; } const sourceBase = 'https://storage.cloud.google.com'; // customImageSource is of format {cloudBucket}/{folderName}. const source = `${sourceBase}/${customImageSource}/${sourcePath}.png`; const promise = new Promise<HTMLImageElement>(resolve => { image.addEventListener('load', () => { this.sourceToTile.set(source, Promise.resolve(image)); resolve(image); }); image.addEventListener('error', () => { this.sourceToTile.set(source, Promise.resolve(fallbackImage)); resolve(fallbackImage); }); }); image.src = source; return promise; }; } } private readonly sourceToTile = new Map<string, Promise<HTMLImageElement>>(); buildReactiveEntry(entry: ChannelConfigEntry, forceUpdate: Function): ChannelConfigEntry { const reactiveEntry: ChannelConfigEntry = { get volumeName() { return entry.volumeName; }, set volumeName(name: string) { entry.volumeName = name; forceUpdate(); }, get channel() { return entry.channel; }, set channel(channel: number) { entry.channel = channel; forceUpdate(); }, get channelCount() { return entry.channelCount; }, set channelCount(channelCount: number) { entry.channelCount = channelCount; forceUpdate(); }, get red() { return entry.red ?? 0; }, set red(red: number) { entry.red = red; }, get green() { return entry.green ?? 0; }, set green(green: number) { entry.green = green; }, get blue() { return entry.blue ?? 0; }, set blue(blue: number) { entry.blue = blue; // In practice, we'll always update all three color attributes // when we update one (thanks to the HTML color input). // Only need to call forceUpdate once blue is set. // This lets us avoid two renders that won't be seen. forceUpdate(); }, get weight() { return entry.weight ?? 1; }, set weight(weight: number) { entry.weight = weight; forceUpdate(); }, get min() { return entry.min ?? 0; }, set min(min: number) { entry.min = min; forceUpdate(); }, get max() { return entry.max ?? 1000; }, set max(max: number) { entry.max = max; forceUpdate(); }, get clipMax() { return entry.clipMax; }, set clipMax(clipMax: boolean|undefined) { // To keep the config from taking up *even more* URL space, // only include clipMax if it's been set to true. if (!clipMax) { // toggle off delete entry.clipMax; } else { // toggle on entry.clipMax = true; } // Manually force update: forceUpdate(); } }; return reactiveEntry; } getName() { return this.name ? this.name : ""; } getSlideInfo() { return this.slideInfo; } setSlideInfo(slideInfo: SlideInfo) { this.slideInfo = slideInfo; this.size = new ImageVector(slideInfo.size.x, slideInfo.size.y); this.numZoomLevels = slideInfo.numZoomLevels; this.channelCount = slideInfo.channelCount; this.name = slideInfo.slideName; if (this.channelCount !== 3 && !this.channelConfig) { this.channelConfig = getDefaultChannelConfig(); // Add one config to start off. this.addChannelConfigEntry(); } this.initialized$.next(true); } setSlideMetadata(slideMetadata: SlideMetadata) { this.slideMetadata = slideMetadata; this.volumeName = slideMetadata.volumeName || ''; } getSize() { return this.size; } getNumZoomLevels() { return this.numZoomLevels; } waitInitialized$() { return this.initialized$.pipe(filter(isInitialized => isInitialized), map(() => this)); } // Get the ratio between the lowest size image to the full size image. getMinZoom() { return this.slideInfo.minZoom; } getClosestSmallerZoomLevel(zoom: number) { const consideredLevel = this.slideInfo.levelMap?.filter(level => level.zoom < zoom) ?? []; return consideredLevel.length > 0 ? consideredLevel .reduce( (prev, curr) => Math.abs(curr.zoom - zoom) < Math.abs(prev.zoom - zoom) ? curr : prev) .zoom : // Minzoom as default value to avoid empty array edge case. this.slideInfo.minZoom; } getChannelCount() { return this.channelCount; } serialize(): SlideDescriptor { const base: SlideDescriptor = { id: this.id, name: this.name, slideInfo: this.getSlideInfo(), }; if (this.channelConfig) { base.channelConfig = this.channelConfig; } return base; } } export {type SlideDescriptor, type SlideInfo, type SlideMetadata};