diff --git a/docs/user-guide/annotation-workflow.md b/docs/user-guide/annotation-workflow.md index 44be1cd6..dd2b7d98 100644 --- a/docs/user-guide/annotation-workflow.md +++ b/docs/user-guide/annotation-workflow.md @@ -80,6 +80,37 @@ For multi-frame images a **frame slider** appears above the viewer. Drag it or u For NIfTI volumes, a **plane selector** appears (Axial / Coronal / Sagittal / ⊞ 3-Axis). The 3-axis mode opens a 2×2 grid with all three reformats and linked crosshair navigation — click any plane to jump to that position in the other two. +## Segmentation Tool (Pixel-Level Painting) + +The segmentation tool is available for annotation types with vector type *Segmentation*. Select such a type in the left panel to reveal the painting palette. + +### Tools + +| Button | Tool | Description | +|---|---|---| +| Hand | Pan | Navigate without painting (default) | +| Brush | Brush | Paint filled circles; adjust size with the slider | +| Eraser | Eraser | Remove painted pixels | +| Wand | Magic wand | Flood-select pixels with similar intensity | +| Bucket | Fill | Flood-fill a connected region | + +### 3D segmentation on NIfTI volumes + +Segmentation works in **all three MPR planes** (Axial, Coronal, Sagittal): + +1. Select the plane using the plane selector buttons. +2. Navigate to the slice you want to annotate using the frame slider. +3. Paint with the brush or other tools. +4. Switch to another plane and scroll to the anatomical position where you painted — the segmentation appears there automatically. + +Annotations drawn in any plane are stored as axial tiles and derived on the fly for the other planes, so the data is always consistent. The **3-Axis mode (⊞)** is the easiest way to verify cross-plane consistency: all three views update simultaneously as you navigate. + +### Keyboard shortcuts + +| Key | Action | +|---|---| +| `Ctrl+Z` | Undo last stroke | + ## Verification Open the **Verification** view from the imageset page. The view steps through unverified annotations one by one: diff --git a/docs/user-guide/image-formats.md b/docs/user-guide/image-formats.md index 2974b700..03122d52 100644 --- a/docs/user-guide/image-formats.md +++ b/docs/user-guide/image-formats.md @@ -71,6 +71,20 @@ The **⊞** button opens a 2×2 grid layout with all three planes simultaneously Clicking in any plane moves the crosshair in the other two planes, allowing linked navigation through the volume. The info bar shows voxel indices and millimetre coordinates for the current crosshair position. +### 3D Segmentation + +Segmentation annotations (pixel-level painting) are fully supported for NIfTI volumes across all three planes. + +**How it works:** + +- All segmentation data is stored as **axial tiles**. Coronal and sagittal views are derived on the fly from the stored axial data — no duplicate storage, and cross-plane consistency is automatic. +- A segmentation drawn in the axial plane is immediately visible in the coronal and sagittal planes at the corresponding position, and vice versa. +- The annotation must be a *Segmentation* vector type (type 8). Select it in the annotation type panel, then use the brush, eraser, magic wand, or fill tools to paint directly on the slice. + +**Navigation tip:** after drawing in one plane, switch to another plane and scroll the frame slider to the anatomical position where you painted. The 3-Axis mode (⊞) is the most convenient way to confirm cross-plane consistency because all three planes update together. + +**Anisotropic volumes:** EXACT correctly handles non-isotropic voxel spacings (e.g. thick-slice CT/MRI where the through-plane resolution is coarser than the in-plane resolution). Tile coordinates are scaled by the actual voxel dimensions, so the displayed segmentation aligns with the underlying anatomy in every plane. + ### Coordinate system NIfTI volumes are reoriented to **RAS+** (Right–Anterior–Superior) at load time using nibabel's `as_closest_canonical`. This means: diff --git a/exact/exact/annotations/migrations/0050_segmentation_tile.py b/exact/exact/annotations/migrations/0050_segmentation_tile.py new file mode 100644 index 00000000..11fc15f0 --- /dev/null +++ b/exact/exact/annotations/migrations/0050_segmentation_tile.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.18 on 2026-05-10 09:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0049_annotationtype_multi_frame'), + ] + + operations = [ + migrations.CreateModel( + name='SegmentationTile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.IntegerField()), + ('tile_x', models.IntegerField()), + ('tile_y', models.IntegerField()), + ('frame', models.IntegerField(default=0)), + ('data', models.BinaryField()), + ('annotation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='segmentation_tiles', to='annotations.annotation')), + ], + options={ + 'indexes': [models.Index(fields=['annotation', 'level', 'frame'], name='annotations_annotat_9dba0a_idx')], + 'unique_together': {('annotation', 'level', 'tile_x', 'tile_y', 'frame')}, + }, + ), + ] diff --git a/exact/exact/annotations/models.py b/exact/exact/annotations/models.py index 70aaa81b..c508148e 100644 --- a/exact/exact/annotations/models.py +++ b/exact/exact/annotations/models.py @@ -453,6 +453,7 @@ class VECTOR_TYPE(): POLYGON = 5 FIXED_SIZE_BOUNDING_BOX = 6 GLOBAL = 7 #Annotations without a shape that are valid for the whole image + SEGMENTATION = 8 # Pixel-level mask stored as compressed tiles name = models.CharField(max_length=50) active = models.BooleanField(default=True) @@ -494,6 +495,8 @@ def get_vector_type_name(vector_type): return 'Multi Line' if vector_type is AnnotationType.VECTOR_TYPE.FIXED_SIZE_BOUNDING_BOX: return 'Fixed Size Bounding Box' + if vector_type is AnnotationType.VECTOR_TYPE.SEGMENTATION: + return 'Segmentation' def validate_vector(self, vector: Union[dict, None]) -> bool: """ @@ -525,6 +528,8 @@ def validate_vector(self, vector: Union[dict, None]) -> bool: return self._validate_bounding_box(vector) if self.vector_type == AnnotationType.VECTOR_TYPE.GLOBAL: return self._validate_bounding_box(vector) + if self.vector_type == AnnotationType.VECTOR_TYPE.SEGMENTATION: + return True # tile metadata; pixel data lives in SegmentationTile # No valid vector type given. return False @@ -692,4 +697,31 @@ class MediaFileType(IntEnum): related_query_name="uploaded_media_file") def __str__(self): - return self.name + ": " + str(self.file) \ No newline at end of file + return self.name + ": " + str(self.file) + + +class SegmentationTile(models.Model): + """Stores a single compressed PNG tile of a pixel-level segmentation mask. + + Tiles are addressed by (annotation, level, tile_x, tile_y, frame) and + stored as 8-bit palette-mode PNG where each pixel value is a class label + (0 = background/transparent). Missing tiles are implicitly empty, giving + sparse storage that scales to gigapixel WSIs. + """ + + class Meta: + unique_together = ('annotation', 'level', 'tile_x', 'tile_y', 'frame') + indexes = [ + models.Index(fields=['annotation', 'level', 'frame']), + ] + + annotation = models.ForeignKey( + Annotation, on_delete=models.CASCADE, related_name='segmentation_tiles') + level = models.IntegerField() # 0 = full resolution, higher = more zoomed-out + tile_x = models.IntegerField() # column index in the tile grid at this level + tile_y = models.IntegerField() # row index + frame = models.IntegerField(default=0) # z-slice / time index for volumetric images + data = models.BinaryField() # PNG-encoded palette image (class label per pixel) + + def __str__(self): + return f'SegmentationTile ann={self.annotation_id} lv={self.level} ({self.tile_x},{self.tile_y}) frame={self.frame}' \ No newline at end of file diff --git a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js index c636092e..0ab3feb0 100644 --- a/exact/exact/annotations/static/annotations/js/exact-image-viewer.js +++ b/exact/exact/annotations/static/annotations/js/exact-image-viewer.js @@ -27,8 +27,11 @@ class EXACTViewer { this.frame = 1; this.viewer = this.createViewer(options); - this.exact_registration_sync = undefined; - this.browser_sync = undefined; + window.exactOSDViewer = this.viewer; // expose for segmentationTool + window.exactImageId = this.imageId; // expose for segmentationTool (updated on image switch) + window.dispatchEvent(new CustomEvent('exactViewerReady', { detail: this.viewer })); + this.exact_registration_sync = undefined; + this.browser_sync = undefined; this.exact_image_sync = new EXACTImageSync(this.imageId, this.gHeaders, this.viewer); this.initViewerEventHandler(this.viewer, imageInformation); @@ -46,6 +49,8 @@ class EXACTViewer { this.heatmapToggle = false; this.currentPlane = 0; + window.exactCurrentPlane = 0; + this._planeFrameMemory = {}; // plane index → last OSD page (0-indexed) this.mprPlanes = null; this.mprActive = false; this.mprViewers = {}; // planeIdx → { osd, canvas, nFrames, dims } @@ -747,6 +752,10 @@ class EXACTViewer { switchPlane(plane) { if (!this.mprPlanes || plane === this.currentPlane) return; + + // Remember where we are in the current plane before switching. + this._planeFrameMemory[this.currentPlane] = this.viewer.currentPage(); + this.currentPlane = plane; this.updatePlaneButtons(); @@ -757,13 +766,18 @@ class EXACTViewer { const nFrames = planeInfo.nFrames; const zDim = plane + 1; + // Restore last-seen frame for this plane (0-indexed), clamped to valid range. + const restorePage = Math.min( + this._planeFrameMemory[plane] ?? 0, + nFrames - 1 + ); + const tileSources = []; for (let f = 0; f < nFrames; f++) { tileSources.push(`${this.server_url}/images/image/${this.imageId}/${zDim}/${f + 1}/tile/`); } - // Rebuild the frame slider for the new plane's frame count so the - // range and value are correct before viewer.open() fires its page event. + // Rebuild the frame slider with the restored frame position. if (this.frameSlider !== undefined) { this.frameSlider.destroy(); this.frameSlider = undefined; @@ -771,7 +785,7 @@ class EXACTViewer { if (nFrames > 1) { this.frameSlider = new Slider("#frameSlider", { ticks_snap_bounds: 1, - value: 1, + value: restorePage + 1, // slider is 1-indexed min: 0, tooltip: 'always', max: nFrames - 1 @@ -779,6 +793,17 @@ class EXACTViewer { this.frameSlider.on('change', this.onFrameSliderChanged.bind(this)); } + // Expose current plane globally and notify segmentation tool before + // opening the new tile sources, so layers can be torn down cleanly. + window.exactCurrentPlane = plane; + window.exactCurrentPlaneNFrames = nFrames; + window.dispatchEvent(new CustomEvent('exactPlaneChanged', { detail: { plane } })); + + // Navigate to the restored frame once OSD has opened the new tile sources. + if (restorePage > 0) { + this.viewer.addOnceHandler('open', () => this.viewer.goToPage(restorePage)); + } + this.viewer.open(tileSources); } diff --git a/exact/exact/annotations/static/annotations/js/openseadragon.min.js-current.js b/exact/exact/annotations/static/annotations/js/openseadragon.min.js-current.js new file mode 100644 index 00000000..b95f5d15 --- /dev/null +++ b/exact/exact/annotations/static/annotations/js/openseadragon.min.js-current.js @@ -0,0 +1,9 @@ +//! openseadragon 2.4.2 +//! Built on 2020-03-05 +//! Git commit: v2.4.2-0-c450749 +//! http://openseadragon.github.io +//! License: http://openseadragon.github.io/license/ + + +function OpenSeadragon(e){return new OpenSeadragon.Viewer(e)}!function(n){n.version={versionStr:"2.4.2",major:parseInt("2",10),minor:parseInt("4",10),revision:parseInt("2",10)};var t={"[object Boolean]":"boolean","[object Number]":"number","[object String]":"string","[object Function]":"function","[object Array]":"array","[object Date]":"date","[object RegExp]":"regexp","[object Object]":"object"},i=Object.prototype.toString,o=Object.prototype.hasOwnProperty;n.isFunction=function(e){return"function"===n.type(e)};n.isArray=Array.isArray||function(e){return"array"===n.type(e)};n.isWindow=function(e){return e&&"object"==typeof e&&"setInterval"in e};n.type=function(e){return null==e?String(e):t[i.call(e)]||"object"};n.isPlainObject=function(e){if(!e||"object"!==OpenSeadragon.type(e)||e.nodeType||n.isWindow(e))return!1;if(e.constructor&&!o.call(e,"constructor")&&!o.call(e.constructor.prototype,"isPrototypeOf"))return!1;var t;for(var i in e)t=i;return void 0===t||o.call(e,t)};n.isEmptyObject=function(e){for(var t in e)return!1;return!0};n.freezeObject=function(e){Object.freeze?n.freezeObject=Object.freeze:n.freezeObject=function(e){return e};return n.freezeObject(e)};n.supportsCanvas=(e=document.createElement("canvas"),!(!n.isFunction(e.getContext)||!e.getContext("2d")));var e;n.isCanvasTainted=function(e){var t=!1;try{e.getContext("2d").getImageData(0,0,1,1)}catch(e){t=!0}return t};n.pixelDensityRatio=function(){if(n.supportsCanvas){var e=document.createElement("canvas").getContext("2d");var t=window.devicePixelRatio||1;var i=e.webkitBackingStorePixelRatio||e.mozBackingStorePixelRatio||e.msBackingStorePixelRatio||e.oBackingStorePixelRatio||e.backingStorePixelRatio||1;return Math.max(t,1)/i}return 1}()}(OpenSeadragon);!function($){$.extend=function(){var e,t,i,n,o,r,s=arguments[0]||{},a=arguments.length,l=!1,h=1;if("boolean"==typeof s){l=s;s=arguments[1]||{};h=2}"object"==typeof s||OpenSeadragon.isFunction(s)||(s={});if(a===h){s=this;--h}for(;h=i.x&&t.x=i.y},getEvent:function(e){$.getEvent=e?function(e){return e}:function(){return window.event};return $.getEvent(e)},getMousePosition:function(e){if("number"==typeof e.pageX)$.getMousePosition=function(e){var t=new $.Point;e=$.getEvent(e);t.x=e.pageX;t.y=e.pageY;return t};else{if("number"!=typeof e.clientX)throw new Error("Unknown event mouse position, no known technique.");$.getMousePosition=function(e){var t=new $.Point;e=$.getEvent(e);t.x=e.clientX+document.body.scrollLeft+document.documentElement.scrollLeft;t.y=e.clientY+document.body.scrollTop+document.documentElement.scrollTop;return t}}return $.getMousePosition(e)},getPageScroll:function(){var e=document.documentElement||{},t=document.body||{};if("number"==typeof window.pageXOffset)$.getPageScroll=function(){return new $.Point(window.pageXOffset,window.pageYOffset)};else if(t.scrollLeft||t.scrollTop)$.getPageScroll=function(){return new $.Point(document.body.scrollLeft,document.body.scrollTop)};else{if(!e.scrollLeft&&!e.scrollTop)return new $.Point(0,0);$.getPageScroll=function(){return new $.Point(document.documentElement.scrollLeft,document.documentElement.scrollTop)}}return $.getPageScroll()},setPageScroll:function(e){if(void 0!==window.scrollTo)$.setPageScroll=function(e){window.scrollTo(e.x,e.y)};else{var t=$.getPageScroll();if(t.x===e.x&&t.y===e.y)return;document.body.scrollLeft=e.x;document.body.scrollTop=e.y;var i=$.getPageScroll();if(i.x!==t.x&&i.y!==t.y){$.setPageScroll=function(e){document.body.scrollLeft=e.x;document.body.scrollTop=e.y};return}document.documentElement.scrollLeft=e.x;document.documentElement.scrollTop=e.y;if((i=$.getPageScroll()).x!==t.x&&i.y!==t.y){$.setPageScroll=function(e){document.documentElement.scrollLeft=e.x;document.documentElement.scrollTop=e.y};return}$.setPageScroll=function(e){}}return $.setPageScroll(e)},getWindowSize:function(){var e=document.documentElement||{},t=document.body||{};if("number"==typeof window.innerWidth)$.getWindowSize=function(){return new $.Point(window.innerWidth,window.innerHeight)};else if(e.clientWidth||e.clientHeight)$.getWindowSize=function(){return new $.Point(document.documentElement.clientWidth,document.documentElement.clientHeight)};else{if(!t.clientWidth&&!t.clientHeight)throw new Error("Unknown window size, no known technique.");$.getWindowSize=function(){return new $.Point(document.body.clientWidth,document.body.clientHeight)}}return $.getWindowSize()},makeCenteredNode:function(e){e=$.getElement(e);var t=[$.makeNeutralElement("div"),$.makeNeutralElement("div"),$.makeNeutralElement("div")];$.extend(t[0].style,{display:"table",height:"100%",width:"100%"});$.extend(t[1].style,{display:"table-row"});$.extend(t[2].style,{display:"table-cell",verticalAlign:"middle",textAlign:"center"});t[0].appendChild(t[1]);t[1].appendChild(t[2]);t[2].appendChild(e);return t[0]},makeNeutralElement:function(e){var t=document.createElement(e),i=t.style;i.background="transparent none";i.border="none";i.margin="0px";i.padding="0px";i.position="static";return t},now:function(){Date.now?$.now=Date.now:$.now=function(){return(new Date).getTime()};return $.now()},makeTransparentImage:function(e){$.makeTransparentImage=function(e){var t=$.makeNeutralElement("img");t.src=e;return t};$.Browser.vendor==$.BROWSERS.IE&&$.Browser.version<7&&($.makeTransparentImage=function(e){var t=$.makeNeutralElement("img"),i=null;(i=$.makeNeutralElement("span")).style.display="inline-block";t.onload=function(){i.style.width=i.style.width||t.width+"px";i.style.height=i.style.height||t.height+"px";t.onload=null;t=null};t.src=e;i.style.filter="progid:DXImageTransform.Microsoft.AlphaImageLoader(src='"+e+"', sizingMethod='scale')";return i});return $.makeTransparentImage(e)},setElementOpacity:function(e,t,i){var n;e=$.getElement(e);i&&!$.Browser.alpha&&(t=Math.round(t));if($.Browser.opacity)e.style.opacity=t<1?t:"";else if(t<1){n="alpha(opacity="+Math.round(100*t)+")";e.style.filter=n}else e.style.filter=""},setElementTouchActionNone:function(e){void 0!==(e=$.getElement(e)).style.touchAction?e.style.touchAction="none":void 0!==e.style.msTouchAction&&(e.style.msTouchAction="none")},addClass:function(e,t){(e=$.getElement(e)).className?-1===(" "+e.className+" ").indexOf(" "+t+" ")&&(e.className+=" "+t):e.className=t},indexOf:function(e,t,i){Array.prototype.indexOf?this.indexOf=function(e,t,i){return e.indexOf(t,i)}:this.indexOf=function(e,t,i){var n,o,r=i||0;if(!e)throw new TypeError;if(0===(o=e.length)||o<=r)return-1;r<0&&(r=o-Math.abs(r));for(n=r;nt.touches.length-s){v.console.warn("Tracked touch contact count doesn't match event.touches.length. Removing all tracked touch pointers.");b(e,t,l)}for(n=0;n\s*$/))o=m.parseXml(o);else if(o.match(/^\s*[\{\[].*[\}\]]\s*$/))try{var e=m.parseJSON(o);o=e}catch(e){}function h(e,t){if(e.ready)s(e);else{e.addHandler("ready",function(){s(e)});e.addHandler("open-failed",function(e){a({message:e.message,source:t})})}}setTimeout(function(){if("string"==m.type(o))(o=new m.TileSource({url:o,crossOriginPolicy:void 0!==r.crossOriginPolicy?r.crossOriginPolicy:n.crossOriginPolicy,ajaxWithCredentials:n.ajaxWithCredentials,ajaxHeaders:n.ajaxHeaders,useCanvas:n.useCanvas,success:function(e){s(e.tileSource)}})).addHandler("open-failed",function(e){a(e)});else if(m.isPlainObject(o)||o.nodeType){void 0!==o.crossOriginPolicy||void 0===r.crossOriginPolicy&&void 0===n.crossOriginPolicy||(o.crossOriginPolicy=void 0!==r.crossOriginPolicy?r.crossOriginPolicy:n.crossOriginPolicy);void 0===o.ajaxWithCredentials&&(o.ajaxWithCredentials=n.ajaxWithCredentials);void 0===o.useCanvas&&(o.useCanvas=n.useCanvas);if(m.isFunction(o.getTileUrl)){var e=new m.TileSource(o);e.getTileUrl=o.getTileUrl;s(e)}else{var t=m.TileSource.determineType(l,o);if(!t){a({message:"Unable to load TileSource",source:o});return}var i=t.prototype.configure.apply(l,[o]);h(new t(i),o)}}else h(o,o)})}(this,i.tileSource,i,function(e){n.tileSource=e;s()},function(e){e.options=i;t(e);s()})}function s(){var e,t,i;for(;o._loadQueue.length&&(e=o._loadQueue[0]).tileSource;){o._loadQueue.splice(0,1);if(e.options.replace){var n=o.world.getIndexOfItem(e.options.replaceItem);-1!=n&&(e.options.index=n);o.world.removeItem(e.options.replaceItem)}t=new m.TiledImage({viewer:o,source:e.tileSource,viewport:o.viewport,drawer:o.drawer,tileCache:o.tileCache,imageLoader:o.imageLoader,x:e.options.x,y:e.options.y,width:e.options.width,height:e.options.height,fitBounds:e.options.fitBounds,fitBoundsPlacement:e.options.fitBoundsPlacement,clip:e.options.clip,placeholderFillStyle:e.options.placeholderFillStyle,opacity:e.options.opacity,preload:e.options.preload,degrees:e.options.degrees,compositeOperation:e.options.compositeOperation,springStiffness:o.springStiffness,animationTime:o.animationTime,minZoomImageRatio:o.minZoomImageRatio,wrapHorizontal:o.wrapHorizontal,wrapVertical:o.wrapVertical,immediateRender:o.immediateRender,blendTime:o.blendTime,alwaysBlend:o.alwaysBlend,minPixelRatio:o.minPixelRatio,smoothTileEdgesMinZoom:o.smoothTileEdgesMinZoom,iOSDevice:o.iOSDevice,crossOriginPolicy:e.options.crossOriginPolicy,ajaxWithCredentials:e.options.ajaxWithCredentials,loadTilesWithAjax:e.options.loadTilesWithAjax,ajaxHeaders:e.options.ajaxHeaders,debugMode:o.debugMode});o.collectionMode&&o.world.setAutoRefigureSizes(!1);o.world.addItem(t,{index:e.options.index});0===o._loadQueue.length&&r(e);1!==o.world.getItemCount()||o.preserveViewport||o.viewport.goHome(!0);if(o.navigator){i=m.extend({},e.options,{replace:!1,originalTiledImage:t,tileSource:e.tileSource});o.navigator.addTiledImage(i)}e.options.success&&e.options.success({item:t})}}},addSimpleImage:function(e){m.console.assert(e,"[Viewer.addSimpleImage] options is required");m.console.assert(e.url,"[Viewer.addSimpleImage] options.url is required");var t=m.extend({},e,{tileSource:{type:"image",url:e.url}});delete t.url;this.addTiledImage(t)},addLayer:function(t){var i=this;m.console.error("[Viewer.addLayer] this function is deprecated; use Viewer.addTiledImage() instead.");var e=m.extend({},t,{success:function(e){i.raiseEvent("add-layer",{options:t,drawer:e.item})},error:function(e){i.raiseEvent("add-layer-failed",e)}});this.addTiledImage(e);return this},getLayerAtLevel:function(e){m.console.error("[Viewer.getLayerAtLevel] this function is deprecated; use World.getItemAt() instead.");return this.world.getItemAt(e)},getLevelOfLayer:function(e){m.console.error("[Viewer.getLevelOfLayer] this function is deprecated; use World.getIndexOfItem() instead.");return this.world.getIndexOfItem(e)},getLayersCount:function(){m.console.error("[Viewer.getLayersCount] this function is deprecated; use World.getItemCount() instead.");return this.world.getItemCount()},setLayerLevel:function(e,t){m.console.error("[Viewer.setLayerLevel] this function is deprecated; use World.setItemIndex() instead.");return this.world.setItemIndex(e,t)},removeLayer:function(e){m.console.error("[Viewer.removeLayer] this function is deprecated; use World.removeItem() instead.");return this.world.removeItem(e)},forceRedraw:function(){c[this.hash].forceRedraw=!0;return this},bindSequenceControls:function(){var e=m.delegate(this,v),t=m.delegate(this,f),i=m.delegate(this,$),n=m.delegate(this,G),o=this.navImages,r=!0;if(this.showSequenceControl){(this.previousButton||this.nextButton)&&(r=!1);this.previousButton=new m.Button({element:this.previousButton?m.getElement(this.previousButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.PreviousPage"),srcRest:D(this.prefixUrl,o.previous.REST),srcGroup:D(this.prefixUrl,o.previous.GROUP),srcHover:D(this.prefixUrl,o.previous.HOVER),srcDown:D(this.prefixUrl,o.previous.DOWN),onRelease:n,onFocus:e,onBlur:t});this.nextButton=new m.Button({element:this.nextButton?m.getElement(this.nextButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.NextPage"),srcRest:D(this.prefixUrl,o.next.REST),srcGroup:D(this.prefixUrl,o.next.GROUP),srcHover:D(this.prefixUrl,o.next.HOVER),srcDown:D(this.prefixUrl,o.next.DOWN),onRelease:i,onFocus:e,onBlur:t});this.navPrevNextWrap||this.previousButton.disable();this.tileSources&&this.tileSources.length||this.nextButton.disable();if(r){this.paging=new m.ButtonGroup({buttons:[this.previousButton,this.nextButton],clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold});this.pagingControl=this.paging.element;this.toolbar?this.toolbar.addControl(this.pagingControl,{anchor:m.ControlAnchor.BOTTOM_RIGHT}):this.addControl(this.pagingControl,{anchor:this.sequenceControlAnchor||m.ControlAnchor.TOP_LEFT})}}return this},bindStandardControls:function(){var e=m.delegate(this,M),t=m.delegate(this,H),i=m.delegate(this,F),n=m.delegate(this,z),o=m.delegate(this,L),r=m.delegate(this,N),s=m.delegate(this,W),a=m.delegate(this,V),l=m.delegate(this,U),h=m.delegate(this,j),c=m.delegate(this,v),u=m.delegate(this,f),d=this.navImages,p=[],g=!0;if(this.showNavigationControl){(this.zoomInButton||this.zoomOutButton||this.homeButton||this.fullPageButton||this.rotateLeftButton||this.rotateRightButton||this.flipButton)&&(g=!1);if(this.showZoomControl){p.push(this.zoomInButton=new m.Button({element:this.zoomInButton?m.getElement(this.zoomInButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.ZoomIn"),srcRest:D(this.prefixUrl,d.zoomIn.REST),srcGroup:D(this.prefixUrl,d.zoomIn.GROUP),srcHover:D(this.prefixUrl,d.zoomIn.HOVER),srcDown:D(this.prefixUrl,d.zoomIn.DOWN),onPress:e,onRelease:t,onClick:i,onEnter:e,onExit:t,onFocus:c,onBlur:u}));p.push(this.zoomOutButton=new m.Button({element:this.zoomOutButton?m.getElement(this.zoomOutButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.ZoomOut"),srcRest:D(this.prefixUrl,d.zoomOut.REST),srcGroup:D(this.prefixUrl,d.zoomOut.GROUP),srcHover:D(this.prefixUrl,d.zoomOut.HOVER),srcDown:D(this.prefixUrl,d.zoomOut.DOWN),onPress:n,onRelease:t,onClick:o,onEnter:n,onExit:t,onFocus:c,onBlur:u}))}this.showHomeControl&&p.push(this.homeButton=new m.Button({element:this.homeButton?m.getElement(this.homeButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.Home"),srcRest:D(this.prefixUrl,d.home.REST),srcGroup:D(this.prefixUrl,d.home.GROUP),srcHover:D(this.prefixUrl,d.home.HOVER),srcDown:D(this.prefixUrl,d.home.DOWN),onRelease:r,onFocus:c,onBlur:u}));this.showFullPageControl&&p.push(this.fullPageButton=new m.Button({element:this.fullPageButton?m.getElement(this.fullPageButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.FullPage"),srcRest:D(this.prefixUrl,d.fullpage.REST),srcGroup:D(this.prefixUrl,d.fullpage.GROUP),srcHover:D(this.prefixUrl,d.fullpage.HOVER),srcDown:D(this.prefixUrl,d.fullpage.DOWN),onRelease:s,onFocus:c,onBlur:u}));if(this.showRotationControl){p.push(this.rotateLeftButton=new m.Button({element:this.rotateLeftButton?m.getElement(this.rotateLeftButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.RotateLeft"),srcRest:D(this.prefixUrl,d.rotateleft.REST),srcGroup:D(this.prefixUrl,d.rotateleft.GROUP),srcHover:D(this.prefixUrl,d.rotateleft.HOVER),srcDown:D(this.prefixUrl,d.rotateleft.DOWN),onRelease:a,onFocus:c,onBlur:u}));p.push(this.rotateRightButton=new m.Button({element:this.rotateRightButton?m.getElement(this.rotateRightButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.RotateRight"),srcRest:D(this.prefixUrl,d.rotateright.REST),srcGroup:D(this.prefixUrl,d.rotateright.GROUP),srcHover:D(this.prefixUrl,d.rotateright.HOVER),srcDown:D(this.prefixUrl,d.rotateright.DOWN),onRelease:l,onFocus:c,onBlur:u}))}this.showFlipControl&&p.push(this.flipButton=new m.Button({element:this.flipButton?m.getElement(this.flipButton):null,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,tooltip:m.getString("Tooltips.Flip"),srcRest:D(this.prefixUrl,d.flip.REST),srcGroup:D(this.prefixUrl,d.flip.GROUP),srcHover:D(this.prefixUrl,d.flip.HOVER),srcDown:D(this.prefixUrl,d.flip.DOWN),onRelease:h,onFocus:c,onBlur:u}));if(g){this.buttons=new m.ButtonGroup({buttons:p,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold});this.navControl=this.buttons.element;this.addHandler("open",m.delegate(this,A));this.toolbar?this.toolbar.addControl(this.navControl,{anchor:this.navigationControlAnchor||m.ControlAnchor.TOP_LEFT}):this.addControl(this.navControl,{anchor:this.navigationControlAnchor||m.ControlAnchor.TOP_LEFT})}}return this},currentPage:function(){return this._sequenceIndex},goToPage:function(e){if(this.tileSources&&0<=e&&e=t.flickMinSpeed){var i=0;this.panHorizontal&&(i=t.flickMomentum*e.speed*Math.cos(e.direction));var n=0;this.panVertical&&(n=t.flickMomentum*e.speed*Math.sin(e.direction));var o=this.viewport.pixelFromPoint(this.viewport.getCenter(!0));var r=this.viewport.pointFromPixel(new m.Point(o.x-i,o.y-n));this.viewport.panTo(r,!1)}this.viewport.applyConstraints()}this.raiseEvent("canvas-drag-end",{tracker:e.eventSource,position:e.position,speed:e.speed,direction:e.direction,shift:e.shift,originalEvent:e.originalEvent})}function S(e){this.raiseEvent("canvas-enter",{tracker:e.eventSource,pointerType:e.pointerType,position:e.position,buttons:e.buttons,pointers:e.pointers,insideElementPressed:e.insideElementPressed,buttonDownAny:e.buttonDownAny,originalEvent:e.originalEvent})}function E(e){window.location!=window.parent.location&&m.MouseTracker.resetAllMouseTrackers();this.raiseEvent("canvas-exit",{tracker:e.eventSource,pointerType:e.pointerType,position:e.position,buttons:e.buttons,pointers:e.pointers,insideElementPressed:e.insideElementPressed,buttonDownAny:e.buttonDownAny,originalEvent:e.originalEvent})}function P(e){this.raiseEvent("canvas-press",{tracker:e.eventSource,pointerType:e.pointerType,position:e.position,insideElementPressed:e.insideElementPressed,insideElementReleased:e.insideElementReleased,originalEvent:e.originalEvent})}function R(e){this.raiseEvent("canvas-release",{tracker:e.eventSource,pointerType:e.pointerType,position:e.position,insideElementPressed:e.insideElementPressed,insideElementReleased:e.insideElementReleased,originalEvent:e.originalEvent})}function _(e){this.raiseEvent("canvas-nonprimary-press",{tracker:e.eventSource,position:e.position,pointerType:e.pointerType,button:e.button,buttons:e.buttons,originalEvent:e.originalEvent})}function b(e){this.raiseEvent("canvas-nonprimary-release",{tracker:e.eventSource,position:e.position,pointerType:e.pointerType,button:e.button,buttons:e.buttons,originalEvent:e.originalEvent})}function C(e){var t,i,n;if(!e.preventDefaultAction&&this.viewport){if((t=this.gestureSettingsByDeviceType(e.pointerType)).pinchToZoom){i=this.viewport.pointFromPixel(e.center,!0);n=this.viewport.pointFromPixel(e.lastCenter,!0).minus(i);this.panHorizontal||(n.x=0);this.panVertical||(n.y=0);this.viewport.zoomBy(e.distance/e.lastDistance,i,!0);t.zoomToRefPoint&&this.viewport.panBy(n,!0);this.viewport.applyConstraints()}if(t.pinchRotate){var o=Math.atan2(e.gesturePoints[0].currentPos.y-e.gesturePoints[1].currentPos.y,e.gesturePoints[0].currentPos.x-e.gesturePoints[1].currentPos.x);var r=Math.atan2(e.gesturePoints[0].lastPos.y-e.gesturePoints[1].lastPos.y,e.gesturePoints[0].lastPos.x-e.gesturePoints[1].lastPos.x);this.viewport.setRotation(this.viewport.getRotation()+(o-r)*(180/Math.PI))}}this.raiseEvent("canvas-pinch",{tracker:e.eventSource,gesturePoints:e.gesturePoints,lastCenter:e.lastCenter,center:e.center,lastDistance:e.lastDistance,distance:e.distance,shift:e.shift,originalEvent:e.originalEvent});return!1}function O(e){var t,i,n;if((n=m.now())-this._lastScrollTime>this.minScrollDeltaTime){this._lastScrollTime=n;this.viewport.flipped&&(e.position.x=this.viewport.getContainerSize().x-e.position.x);if(!e.preventDefaultAction&&this.viewport&&(t=this.gestureSettingsByDeviceType(e.pointerType)).scrollToZoom){i=Math.pow(this.zoomPerScroll,e.scroll);this.viewport.zoomBy(i,t.zoomToRefPoint?this.viewport.pointFromPixel(e.position,!0):null);this.viewport.applyConstraints()}this.raiseEvent("canvas-scroll",{tracker:e.eventSource,position:e.position,scroll:e.scroll,shift:e.shift,originalEvent:e.originalEvent});if(t&&t.scrollToZoom)return!1}else if((t=this.gestureSettingsByDeviceType(e.pointerType))&&t.scrollToZoom)return!1}function I(e){c[this.hash].mouseInside=!0;g(this);this.raiseEvent("container-enter",{tracker:e.eventSource,position:e.position,buttons:e.buttons,pointers:e.pointers,insideElementPressed:e.insideElementPressed,buttonDownAny:e.buttonDownAny,originalEvent:e.originalEvent})}function k(e){if(e.pointers<1){c[this.hash].mouseInside=!1;c[this.hash].animating||p(this)}this.raiseEvent("container-exit",{tracker:e.eventSource,position:e.position,buttons:e.buttons,pointers:e.pointers,insideElementPressed:e.insideElementPressed,buttonDownAny:e.buttonDownAny,originalEvent:e.originalEvent})}function B(e){!function(e){if(e._opening)return;if(e.autoResize){var t=u(e.container);var i=c[e.hash].prevContainerSize;if(!t.equals(i)){var n=e.viewport;if(e.preserveImageSizeOnResize){var o=i.x/t.x;var r=n.getZoom()*o;var s=n.getCenter();n.resize(t,!1);n.zoomTo(r,null,!0);n.panTo(s,!0)}else{var a=n.getBounds();n.resize(t,!0);n.fitBoundsWithConstraints(a,!0)}c[e.hash].prevContainerSize=t;c[e.hash].forceRedraw=!0}}var l=e.viewport.update();var h=e.world.update()||l;l&&e.raiseEvent("viewport-change");e.referenceStrip&&(h=e.referenceStrip.update(e.viewport)||h);if(!c[e.hash].animating&&h){e.raiseEvent("animation-start");g(e)}if(h||c[e.hash].forceRedraw||e.world.needsDraw()){!function(e){e.imageLoader.clear();e.drawer.clear();e.world.draw();e.raiseEvent("update-viewport",{})}(e);e._drawOverlays();e.navigator&&e.navigator.update(e.viewport);c[e.hash].forceRedraw=!1;h&&e.raiseEvent("animation")}if(c[e.hash].animating&&!h){e.raiseEvent("animation-finish");c[e.hash].mouseInside||p(e)}c[e.hash].animating=h}(e);e.isOpen()?e._updateRequestId=r(e,B):e._updateRequestId=!1}function D(e,t){return e?e+t:t}function M(){c[this.hash].lastZoomTime=m.now();c[this.hash].zoomFactor=this.zoomPerSecond;c[this.hash].zooming=!0;n(this)}function z(){c[this.hash].lastZoomTime=m.now();c[this.hash].zoomFactor=1/this.zoomPerSecond;c[this.hash].zooming=!0;n(this)}function H(){c[this.hash].zooming=!1}function n(e){m.requestAnimationFrame(m.delegate(e,t))}function t(){var e,t,i;if(c[this.hash].zooming&&this.viewport){t=(e=m.now())-c[this.hash].lastZoomTime;i=Math.pow(c[this.hash].zoomFactor,t/1e3);this.viewport.zoomBy(i);this.viewport.applyConstraints();c[this.hash].lastZoomTime=e;n(this)}}function F(){if(this.viewport){c[this.hash].zooming=!1;this.viewport.zoomBy(this.zoomPerClick/1);this.viewport.applyConstraints()}}function L(){if(this.viewport){c[this.hash].zooming=!1;this.viewport.zoomBy(1/this.zoomPerClick);this.viewport.applyConstraints()}}function A(){this.buttons.emulateEnter();this.buttons.emulateExit()}function N(){this.viewport&&this.viewport.goHome()}function W(){this.isFullPage()&&!m.isFullScreen()?this.setFullPage(!1):this.setFullScreen(!this.isFullPage());this.buttons&&this.buttons.emulateExit();this.fullPageButton.element.focus();this.viewport&&this.viewport.applyConstraints()}function V(){if(this.viewport){var e=this.viewport.getRotation();e=this.viewport.flipped?m.positiveModulo(e+this.rotationIncrement,360):m.positiveModulo(e-this.rotationIncrement,360);this.viewport.setRotation(e)}}function U(){if(this.viewport){var e=this.viewport.getRotation();e=this.viewport.flipped?m.positiveModulo(e-this.rotationIncrement,360):m.positiveModulo(e+this.rotationIncrement,360);this.viewport.setRotation(e)}}function j(){this.viewport.toggleFlip()}function G(){var e=this._sequenceIndex-1;this.navPrevNextWrap&&e<0&&(e+=this.tileSources.length);this.goToPage(e)}function $(){var e=this._sequenceIndex+1;this.navPrevNextWrap&&e>=this.tileSources.length&&(e=0);this.goToPage(e)}}(OpenSeadragon);!function(c){c.Navigator=function(i){var e,t,n=i.viewer,o=this;if(i.id){this.element=document.getElementById(i.id);i.controlOptions={anchor:c.ControlAnchor.NONE,attachToViewer:!1,autoFade:!1}}else{i.id="navigator-"+c.now();this.element=c.makeNeutralElement("div");i.controlOptions={anchor:c.ControlAnchor.TOP_RIGHT,attachToViewer:!0,autoFade:i.autoFade};if(i.position)if("BOTTOM_RIGHT"==i.position)i.controlOptions.anchor=c.ControlAnchor.BOTTOM_RIGHT;else if("BOTTOM_LEFT"==i.position)i.controlOptions.anchor=c.ControlAnchor.BOTTOM_LEFT;else if("TOP_RIGHT"==i.position)i.controlOptions.anchor=c.ControlAnchor.TOP_RIGHT;else if("TOP_LEFT"==i.position)i.controlOptions.anchor=c.ControlAnchor.TOP_LEFT;else if("ABSOLUTE"==i.position){i.controlOptions.anchor=c.ControlAnchor.ABSOLUTE;i.controlOptions.top=i.top;i.controlOptions.left=i.left;i.controlOptions.height=i.height;i.controlOptions.width=i.width}}this.element.id=i.id;this.element.className+=" navigator";(i=c.extend(!0,{sizeRatio:c.DEFAULT_SETTINGS.navigatorSizeRatio},i,{element:this.element,tabIndex:-1,showNavigator:!1,mouseNavEnabled:!1,showNavigationControl:!1,showSequenceControl:!1,immediateRender:!0,blendTime:0,animationTime:0,autoResize:i.autoResize,minZoomImageRatio:1,background:i.background,opacity:i.opacity,borderColor:i.borderColor,displayRegionColor:i.displayRegionColor})).minPixelRatio=this.minPixelRatio=n.minPixelRatio;c.setElementTouchActionNone(this.element);this.borderWidth=2;this.fudge=new c.Point(1,1);this.totalBorderWidths=new c.Point(2*this.borderWidth,2*this.borderWidth).minus(this.fudge);i.controlOptions.anchor!=c.ControlAnchor.NONE&&function(e,t){e.margin="0px";e.border=t+"px solid "+i.borderColor;e.padding="0px";e.background=i.background;e.opacity=i.opacity;e.overflow="hidden"}(this.element.style,this.borderWidth);this.displayRegion=c.makeNeutralElement("div");this.displayRegion.id=this.element.id+"-displayregion";this.displayRegion.className="displayregion";!function(e,t){e.position="relative";e.top="0px";e.left="0px";e.fontSize="0px";e.overflow="hidden";e.border=t+"px solid "+i.displayRegionColor;e.margin="0px";e.padding="0px";e.background="transparent";e.float="left";e.cssFloat="left";e.styleFloat="left";e.zIndex=999999999;e.cursor="default"}(this.displayRegion.style,this.borderWidth);this.displayRegionContainer=c.makeNeutralElement("div");this.displayRegionContainer.id=this.element.id+"-displayregioncontainer";this.displayRegionContainer.className="displayregioncontainer";this.displayRegionContainer.style.width="100%";this.displayRegionContainer.style.height="100%";n.addControl(this.element,i.controlOptions);this._resizeWithViewer=i.controlOptions.anchor!=c.ControlAnchor.ABSOLUTE&&i.controlOptions.anchor!=c.ControlAnchor.NONE;if(i.width&&i.height){this.setWidth(i.width);this.setHeight(i.height)}else if(this._resizeWithViewer){e=c.getElementSize(n.element);this.element.style.height=Math.round(e.y*i.sizeRatio)+"px";this.element.style.width=Math.round(e.x*i.sizeRatio)+"px";this.oldViewerSize=e;t=c.getElementSize(this.element);this.elementArea=t.x*t.y}this.oldContainerSize=new c.Point(0,0);c.Viewer.apply(this,[i]);this.displayRegionContainer.appendChild(this.displayRegion);this.element.getElementsByTagName("div")[0].appendChild(this.displayRegionContainer);function r(e){u(o.displayRegionContainer,e);u(o.displayRegion,-e);o.viewport.setRotation(e)}if(i.navigatorRotate){r(i.viewer.viewport?i.viewer.viewport.getRotation():i.viewer.degrees||0);i.viewer.addHandler("rotate",function(e){r(e.degrees)})}this.innerTracker.destroy();this.innerTracker=new c.MouseTracker({element:this.element,dragHandler:c.delegate(this,a),clickHandler:c.delegate(this,s),releaseHandler:c.delegate(this,l),scrollHandler:c.delegate(this,h)});this.addHandler("reset-size",function(){o.viewport&&o.viewport.goHome(!0)});n.world.addHandler("item-index-change",function(t){window.setTimeout(function(){var e=o.world.getItemAt(t.previousIndex);o.world.setItemIndex(e,t.newIndex)},1)});n.world.addHandler("remove-item",function(e){var t=e.item;var i=o._getMatchingItem(t);i&&o.world.removeItem(i)});this.update(n.viewport)};c.extend(c.Navigator.prototype,c.EventSource.prototype,c.Viewer.prototype,{updateSize:function(){if(this.viewport){var e=new c.Point(0===this.container.clientWidth?1:this.container.clientWidth,0===this.container.clientHeight?1:this.container.clientHeight);if(!e.equals(this.oldContainerSize)){this.viewport.resize(e,!0);this.viewport.goHome(!0);this.oldContainerSize=e;this.drawer.clear();this.world.draw()}}},setWidth:function(e){this.width=e;this.element.style.width="number"==typeof e?e+"px":e;this._resizeWithViewer=!1},setHeight:function(e){this.height=e;this.element.style.height="number"==typeof e?e+"px":e;this._resizeWithViewer=!1},setFlip:function(e){this.viewport.setFlip(e);this.setDisplayTransform(this.viewer.viewport.getFlip()?"scale(-1,1)":"scale(1,1)");return this},setDisplayTransform:function(e){i(this.displayRegion,e);i(this.canvas,e);i(this.element,e)},update:function(e){var t,i,n,o,r,s;t=c.getElementSize(this.viewer.element);if(this._resizeWithViewer&&t.x&&t.y&&!t.equals(this.oldViewerSize)){this.oldViewerSize=t;if(this.maintainSizeRatio||!this.elementArea){i=t.x*this.sizeRatio;n=t.y*this.sizeRatio}else{i=Math.sqrt(this.elementArea*(t.x/t.y));n=this.elementArea/i}this.element.style.width=Math.round(i)+"px";this.element.style.height=Math.round(n)+"px";this.elementArea||(this.elementArea=i*n);this.updateSize()}if(e&&this.viewport){o=e.getBoundsNoRotate(!0);r=this.viewport.pixelFromPointNoRotate(o.getTopLeft(),!1);s=this.viewport.pixelFromPointNoRotate(o.getBottomRight(),!1).minus(this.totalBorderWidths);var a=this.displayRegion.style;a.display=this.world.getItemCount()?"block":"none";a.top=Math.round(r.y)+"px";a.left=Math.round(r.x)+"px";var l=Math.abs(r.x-s.x);var h=Math.abs(r.y-s.y);a.width=Math.round(Math.max(l,0))+"px";a.height=Math.round(Math.max(h,0))+"px"}},addTiledImage:function(e){var n=this;var o=e.originalTiledImage;delete e.original;var t=c.extend({},e,{success:function(e){var t=e.item;t._originalForNavigator=o;n._matchBounds(t,o,!0);function i(){n._matchBounds(t,o)}o.addHandler("bounds-change",i);o.addHandler("clip-change",i);o.addHandler("opacity-change",function(){n._matchOpacity(t,o)});o.addHandler("composite-operation-change",function(){n._matchCompositeOperation(t,o)})}});return c.Viewer.prototype.addTiledImage.apply(this,[t])},_getMatchingItem:function(e){var t=this.world.getItemCount();var i;for(var n=0;n=1/this.aspectRatio-1e-15&&(a=this.getNumTiles(e).y-1);return new d.Point(s,a)},getTileBounds:function(e,t,i,n){var o=this.dimensions.times(this.getLevelScale(e)),r=this.getTileWidth(e),s=this.getTileHeight(e),a=0===t?0:r*t-this.tileOverlap,l=0===i?0:s*i-this.tileOverlap,h=r+(0===t?1:2)*this.tileOverlap,c=s+(0===i?1:2)*this.tileOverlap,u=1/o.x;h=Math.min(h,o.x-a);c=Math.min(c,o.y-l);return n?new d.Rect(0,0,h,c):new d.Rect(a*u,l*u,h*u,c*u)},getImageInfo:function(n){var e,i,o,r,t,s,a,l=this;n&&-1<(a=(s=(t=n.split("/"))[t.length-1]).lastIndexOf("."))&&(t[t.length-1]=s.slice(0,a));i=function(e){"string"==typeof e&&(e=d.parseXml(e));var t=d.TileSource.determineType(l,e,n);if(t){void 0===(r=t.prototype.configure.apply(l,[e,n])).ajaxWithCredentials&&(r.ajaxWithCredentials=l.ajaxWithCredentials);o=new t(r);l.ready=!0;l.raiseEvent("ready",{tileSource:o})}else l.raiseEvent("open-failed",{message:"Unable to load TileSource",source:n})};if(n.match(/\.js$/)){e=n.split("/").pop().replace(".js","");d.jsonp({url:n,async:!1,callbackName:e,callback:i})}else d.makeAjaxRequest({url:n,withCredentials:this.ajaxWithCredentials,headers:this.ajaxHeaders,success:function(e){var t=function(t){var e,i,n=t.responseText,o=t.status;{if(!t)throw new Error(d.getString("Errors.Security"));if(200!==t.status&&0!==t.status){o=t.status;e=404==o?"Not Found":t.statusText;throw new Error(d.getString("Errors.Status",o,e))}}if(n.match(/\s*<.*/))try{i=t.responseXML&&t.responseXML.documentElement?t.responseXML:d.parseXml(n)}catch(e){i=t.responseText}else if(n.match(/\s*[\{\[].*/))try{i=d.parseJSON(n)}catch(e){i=n}else i=n;return i}(e);i(t)},error:function(e,t){var i;try{i="HTTP "+e.status+" attempting to load TileSource"}catch(e){i=(void 0!==t&&t.toString?t.toString():"Unknown error")+" attempting to load TileSource"}l.raiseEvent("open-failed",{message:i,source:n})}})},supports:function(e,t){return!1},configure:function(e,t){throw new Error("Method not implemented.")},getTileUrl:function(e,t,i){throw new Error("Method not implemented.")},getTileAjaxHeaders:function(e,t,i){return{}},tileExists:function(e,t,i){var n=this.getNumTiles(e);return e>=this.minLevel&&e<=this.maxLevel&&0<=t&&0<=i&&tthis.maxLevel)return!1;if(!c||!c.length)return!0;for(h=c.length-1;0<=h;h--)if(!(e<(n=c[h]).minLevel||e>n.maxLevel)){o=this.getLevelScale(e);r=n.x*o;s=n.y*o;a=r+n.width*o;l=s+n.height*o;r=Math.floor(r/this._tileWidth);s=Math.floor(s/this._tileWidth);a=Math.ceil(a/this._tileWidth);l=Math.ceil(l/this._tileWidth);if(r<=t&&t=this.minLevel&&e<=this.maxLevel&&(t=this.levels[e].width/this.levels[this.maxLevel].width);return t}return h.TileSource.prototype.getLevelScale.call(this,e)},getNumTiles:function(e){if(this.emulateLegacyImagePyramid){return this.getLevelScale(e)?new h.Point(1,1):new h.Point(0,0)}return h.TileSource.prototype.getNumTiles.call(this,e)},getTileAtPoint:function(e,t){return this.emulateLegacyImagePyramid?new h.Point(0,0):h.TileSource.prototype.getTileAtPoint.call(this,e,t)},getTileUrl:function(e,t,i){if(this.emulateLegacyImagePyramid){var n=null;0=this.minLevel&&e<=this.maxLevel&&(n=this.levels[e].url);return n}var o,r,s,a,l,h,c,u,d,p,g,m,v,f=Math.pow(.5,this.maxLevel-e),w=Math.ceil(this.width*f),y=Math.ceil(this.height*f);o=this.getTileWidth(e);r=this.getTileHeight(e);s=Math.ceil(o/f);a=Math.ceil(r/f);v=1===this.version?"native."+this.tileFormat:"default."+this.tileFormat;if(we.tileSize||parseInt(t.y,10)>e.tileSize;){t.x=Math.floor(t.x/2);t.y=Math.floor(t.y/2);e.imageSizes.push({x:t.x,y:t.y});e.gridSize.push(this._getGridSize(t.x,t.y,e.tileSize))}e.imageSizes.reverse();e.gridSize.reverse();e.minLevel=0;e.maxLevel=e.gridSize.length-1;OpenSeadragon.TileSource.apply(this,[e])};e.extend(e.ZoomifyTileSource.prototype,e.TileSource.prototype,{_getGridSize:function(e,t,i){return{x:Math.ceil(e/i),y:Math.ceil(t/i)}},_calculateAbsoluteTileNumber:function(e,t,i){var n=0;var o={};for(var r=0;r");return n.sort(function(e,t){return e.height-t.height})}(t.levels);if(0=this.minLevel&&e<=this.maxLevel&&(t=this.levels[e].width/this.levels[this.maxLevel].width);return t},getNumTiles:function(e){return this.getLevelScale(e)?new l.Point(1,1):new l.Point(0,0)},getTileUrl:function(e,t,i){var n=null;0=this.minLevel&&e<=this.maxLevel&&(n=this.levels[e].url);return n}});function h(e,t){return t.levels}}(OpenSeadragon);!function(a){a.ImageTileSource=function(e){e=a.extend({buildPyramid:!0,crossOriginPolicy:!1,ajaxWithCredentials:!1,useCanvas:!0},e);a.TileSource.apply(this,[e])};a.extend(a.ImageTileSource.prototype,a.TileSource.prototype,{supports:function(e,t){return e.type&&"image"===e.type},configure:function(e,t){return e},getImageInfo:function(e){var t=this._image=new Image;var i=this;this.crossOriginPolicy&&(t.crossOrigin=this.crossOriginPolicy);this.ajaxWithCredentials&&(t.useCredentials=this.ajaxWithCredentials);a.addEvent(t,"load",function(){i.width=Object.prototype.hasOwnProperty.call(t,"naturalWidth")?t.naturalWidth:t.width;i.height=Object.prototype.hasOwnProperty.call(t,"naturalHeight")?t.naturalHeight:t.height;i.aspectRatio=i.width/i.height;i.dimensions=new a.Point(i.width,i.height);i._tileWidth=i.width;i._tileHeight=i.height;i.tileOverlap=0;i.minLevel=0;i.levels=i._buildLevels();i.maxLevel=i.levels.length-1;i.ready=!0;i.raiseEvent("ready",{tileSource:i})});a.addEvent(t,"error",function(){i.raiseEvent("open-failed",{message:"Error loading image at "+e,source:e})});t.src=e},getLevelScale:function(e){var t=NaN;e>=this.minLevel&&e<=this.maxLevel&&(t=this.levels[e].width/this.levels[this.maxLevel].width);return t},getNumTiles:function(e){return this.getLevelScale(e)?new a.Point(1,1):new a.Point(0,0)},getTileUrl:function(e,t,i){var n=null;e>=this.minLevel&&e<=this.maxLevel&&(n=this.levels[e].url);return n},getContext2D:function(e,t,i){var n=null;e>=this.minLevel&&e<=this.maxLevel&&(n=this.levels[e].context2D);return n},_buildLevels:function(){var e=[{url:this._image.src,width:Object.prototype.hasOwnProperty.call(this._image,"naturalWidth")?this._image.naturalWidth:this._image.width,height:Object.prototype.hasOwnProperty.call(this._image,"naturalHeight")?this._image.naturalHeight:this._image.height}];if(!this.buildPyramid||!a.supportsCanvas||!this.useCanvas){delete this._image;return e}var t=Object.prototype.hasOwnProperty.call(this._image,"naturalWidth")?this._image.naturalWidth:this._image.width;var i=Object.prototype.hasOwnProperty.call(this._image,"naturalHeight")?this._image.naturalHeight:this._image.height;var n=document.createElement("canvas");var o=n.getContext("2d");n.width=t;n.height=i;o.drawImage(this._image,0,0,t,i);e[0].context2D=o;delete this._image;if(a.isCanvasTainted(n))return e;for(;2<=t&&2<=i;){t=Math.floor(t/2);i=Math.floor(i/2);var r=document.createElement("canvas");var s=r.getContext("2d");r.width=t;r.height=i;s.drawImage(n,0,0,t,i);e.splice(0,0,{context2D:s,width:t,height:i});n=r;o=s}return e}})}(OpenSeadragon);!function(o){o.TileSourceCollection=function(e,t,i,n){o.console.error("TileSourceCollection is deprecated; use World instead")}}(OpenSeadragon);!function(o){o.ButtonState={REST:0,GROUP:1,HOVER:2,DOWN:3};o.Button=function(e){var t=this;o.EventSource.call(this);o.extend(!0,this,{tooltip:null,srcRest:null,srcGroup:null,srcHover:null,srcDown:null,clickTimeThreshold:o.DEFAULT_SETTINGS.clickTimeThreshold,clickDistThreshold:o.DEFAULT_SETTINGS.clickDistThreshold,fadeDelay:0,fadeLength:2e3,onPress:null,onRelease:null,onClick:null,onEnter:null,onExit:null,onFocus:null,onBlur:null},e);this.element=e.element||o.makeNeutralElement("div");if(!e.element){this.imgRest=o.makeTransparentImage(this.srcRest);this.imgGroup=o.makeTransparentImage(this.srcGroup);this.imgHover=o.makeTransparentImage(this.srcHover);this.imgDown=o.makeTransparentImage(this.srcDown);this.imgRest.alt=this.imgGroup.alt=this.imgHover.alt=this.imgDown.alt=this.tooltip;this.element.style.position="relative";o.setElementTouchActionNone(this.element);this.imgGroup.style.position=this.imgHover.style.position=this.imgDown.style.position="absolute";this.imgGroup.style.top=this.imgHover.style.top=this.imgDown.style.top="0px";this.imgGroup.style.left=this.imgHover.style.left=this.imgDown.style.left="0px";this.imgHover.style.visibility=this.imgDown.style.visibility="hidden";o.Browser.vendor==o.BROWSERS.FIREFOX&&o.Browser.version<3&&(this.imgGroup.style.top=this.imgHover.style.top=this.imgDown.style.top="");this.element.appendChild(this.imgRest);this.element.appendChild(this.imgGroup);this.element.appendChild(this.imgHover);this.element.appendChild(this.imgDown)}this.addHandler("press",this.onPress);this.addHandler("release",this.onRelease);this.addHandler("click",this.onClick);this.addHandler("enter",this.onEnter);this.addHandler("exit",this.onExit);this.addHandler("focus",this.onFocus);this.addHandler("blur",this.onBlur);this.currentState=o.ButtonState.GROUP;this.fadeBeginTime=null;this.shouldFade=!1;this.element.style.display="inline-block";this.element.style.position="relative";this.element.title=this.tooltip;this.tracker=new o.MouseTracker({element:this.element,clickTimeThreshold:this.clickTimeThreshold,clickDistThreshold:this.clickDistThreshold,enterHandler:function(e){if(e.insideElementPressed){i(t,o.ButtonState.DOWN);t.raiseEvent("enter",{originalEvent:e.originalEvent})}else e.buttonDownAny||i(t,o.ButtonState.HOVER)},focusHandler:function(e){this.enterHandler(e);t.raiseEvent("focus",{originalEvent:e.originalEvent})},exitHandler:function(e){n(t,o.ButtonState.GROUP);e.insideElementPressed&&t.raiseEvent("exit",{originalEvent:e.originalEvent})},blurHandler:function(e){this.exitHandler(e);t.raiseEvent("blur",{originalEvent:e.originalEvent})},pressHandler:function(e){i(t,o.ButtonState.DOWN);t.raiseEvent("press",{originalEvent:e.originalEvent})},releaseHandler:function(e){if(e.insideElementPressed&&e.insideElementReleased){n(t,o.ButtonState.HOVER);t.raiseEvent("release",{originalEvent:e.originalEvent})}else e.insideElementPressed?n(t,o.ButtonState.GROUP):i(t,o.ButtonState.HOVER)},clickHandler:function(e){e.quick&&t.raiseEvent("click",{originalEvent:e.originalEvent})},keyHandler:function(e){if(13!==e.keyCode)return!0;t.raiseEvent("click",{originalEvent:e.originalEvent});t.raiseEvent("release",{originalEvent:e.originalEvent});return!1}});n(this,o.ButtonState.REST)};o.extend(o.Button.prototype,o.EventSource.prototype,{notifyGroupEnter:function(){i(this,o.ButtonState.GROUP)},notifyGroupExit:function(){n(this,o.ButtonState.REST)},disable:function(){this.notifyGroupExit();this.element.disabled=!0;o.setElementOpacity(this.element,.2,!0)},enable:function(){this.element.disabled=!1;o.setElementOpacity(this.element,1,!0);this.notifyGroupEnter()}});function r(e){o.requestAnimationFrame(function(){!function(e){var t,i,n;if(e.shouldFade){t=o.now();i=t-e.fadeBeginTime;n=1-i/e.fadeLength;n=Math.min(1,n);n=Math.max(0,n);e.imgGroup&&o.setElementOpacity(e.imgGroup,n,!0);0=o.ButtonState.GROUP&&e.currentState==o.ButtonState.REST){!function(e){e.shouldFade=!1;e.imgGroup&&o.setElementOpacity(e.imgGroup,1,!0)}(e);e.currentState=o.ButtonState.GROUP}if(t>=o.ButtonState.HOVER&&e.currentState==o.ButtonState.GROUP){e.imgHover&&(e.imgHover.style.visibility="");e.currentState=o.ButtonState.HOVER}if(t>=o.ButtonState.DOWN&&e.currentState==o.ButtonState.HOVER){e.imgDown&&(e.imgDown.style.visibility="");e.currentState=o.ButtonState.DOWN}}}function n(e,t){if(!e.element.disabled){if(t<=o.ButtonState.HOVER&&e.currentState==o.ButtonState.DOWN){e.imgDown&&(e.imgDown.style.visibility="hidden");e.currentState=o.ButtonState.HOVER}if(t<=o.ButtonState.GROUP&&e.currentState==o.ButtonState.HOVER){e.imgHover&&(e.imgHover.style.visibility="hidden");e.currentState=o.ButtonState.GROUP}if(t<=o.ButtonState.REST&&e.currentState==o.ButtonState.GROUP){!function(e){e.shouldFade=!0;e.fadeBeginTime=o.now()+e.fadeDelay;window.setTimeout(function(){r(e)},e.fadeDelay)}(e);e.currentState=o.ButtonState.REST}}}}(OpenSeadragon);!function(o){o.ButtonGroup=function(e){o.extend(!0,this,{buttons:[],clickTimeThreshold:o.DEFAULT_SETTINGS.clickTimeThreshold,clickDistThreshold:o.DEFAULT_SETTINGS.clickDistThreshold,labelText:""},e);var t,i=this.buttons.concat([]),n=this;this.element=e.element||o.makeNeutralElement("div");if(!e.group){this.element.style.display="inline-block";for(t=0;tT&&(T=P.x);P.yS&&(S=P.y)}return new R.Rect(y,x,T-y,S-x)},_getSegments:function(){var e=this.getTopLeft();var t=this.getTopRight();var i=this.getBottomLeft();var n=this.getBottomRight();return[[e,t],[t,n],[n,i],[i,e]]},rotate:function(e,t){if(0===(e=R.positiveModulo(e,360)))return this.clone();t=t||this.getCenter();var i=this.getTopLeft().rotate(e,t);var n=this.getTopRight().rotate(e,t).minus(i);n=n.apply(function(e){return Math.abs(e)<1e-15?0:e});var o=Math.atan(n.y/n.x);n.x<0?o+=Math.PI:n.y<0&&(o+=2*Math.PI);return new R.Rect(i.x,i.y,this.width,this.height,o/Math.PI*180)},getBoundingBox:function(){if(0===this.degrees)return this.clone();var e=this.getTopLeft();var t=this.getTopRight();var i=this.getBottomLeft();var n=this.getBottomRight();var o=Math.min(e.x,t.x,i.x,n.x);var r=Math.max(e.x,t.x,i.x,n.x);var s=Math.min(e.y,t.y,i.y,n.y);var a=Math.max(e.y,t.y,i.y,n.y);return new R.Rect(o,s,r-o,a-s)},getIntegerBoundingBox:function(){var e=this.getBoundingBox();var t=Math.floor(e.x);var i=Math.floor(e.y);var n=Math.ceil(e.width+e.x-t);var o=Math.ceil(e.height+e.y-i);return new R.Rect(t,i,n,o)},containsPoint:function(e,t){t=t||0;var i=this.getTopLeft();var n=this.getTopRight();var o=this.getBottomLeft();var r=n.minus(i);var s=o.minus(i);return(e.x-i.x)*r.x+(e.y-i.y)*r.y>=-t&&(e.x-n.x)*r.x+(e.y-n.y)*r.y<=t&&(e.x-i.x)*s.x+(e.y-i.y)*s.y>=-t&&(e.x-o.x)*s.x+(e.y-o.y)*s.y<=t},toString:function(){return"["+Math.round(100*this.x)/100+", "+Math.round(100*this.y)/100+", "+Math.round(100*this.width)/100+"x"+Math.round(100*this.height)/100+", "+Math.round(100*this.degrees)/100+"deg]"}}}(OpenSeadragon);!function(d){var s={};d.ReferenceStrip=function(e){var t,i,n,r=e.viewer,o=d.getElementSize(r.element);if(!e.id){e.id="referencestrip-"+d.now();this.element=d.makeNeutralElement("div");this.element.id=e.id;this.element.className="referencestrip"}e=d.extend(!0,{sizeRatio:d.DEFAULT_SETTINGS.referenceStripSizeRatio,position:d.DEFAULT_SETTINGS.referenceStripPosition,scroll:d.DEFAULT_SETTINGS.referenceStripScroll,clickTimeThreshold:d.DEFAULT_SETTINGS.clickTimeThreshold},e,{element:this.element,showNavigator:!1,mouseNavEnabled:!1,showNavigationControl:!1,showSequenceControl:!1});d.extend(this,e);s[this.id]={animating:!1};this.minPixelRatio=this.viewer.minPixelRatio;(i=this.element.style).marginTop="0px";i.marginRight="0px";i.marginBottom="0px";i.marginLeft="0px";i.left="0px";i.bottom="0px";i.border="0px";i.background="#000";i.position="relative";d.setElementTouchActionNone(this.element);d.setElementOpacity(this.element,.8);this.viewer=r;this.innerTracker=new d.MouseTracker({element:this.element,dragHandler:d.delegate(this,a),scrollHandler:d.delegate(this,l),enterHandler:d.delegate(this,c),exitHandler:d.delegate(this,u),keyDownHandler:d.delegate(this,p),keyHandler:d.delegate(this,g)});if(e.width&&e.height){this.element.style.width=e.width+"px";this.element.style.height=e.height+"px";r.addControl(this.element,{anchor:d.ControlAnchor.BOTTOM_LEFT})}else if("horizontal"==e.scroll){this.element.style.width=o.x*e.sizeRatio*r.tileSources.length+12*r.tileSources.length+"px";this.element.style.height=o.y*e.sizeRatio+"px";r.addControl(this.element,{anchor:d.ControlAnchor.BOTTOM_LEFT})}else{this.element.style.height=o.y*e.sizeRatio*r.tileSources.length+12*r.tileSources.length+"px";this.element.style.width=o.x*e.sizeRatio+"px";r.addControl(this.element,{anchor:d.ControlAnchor.TOP_LEFT})}this.panelWidth=o.x*this.sizeRatio+8;this.panelHeight=o.y*this.sizeRatio+8;this.panels=[];this.miniViewers={};for(n=0;ns+n.x-this.panelWidth){t=Math.min(t,o-n.x);this.element.style.marginLeft=-t+"px";h(this,n.x,-t)}else if(ta+n.y-this.panelHeight){t=Math.min(t,r-n.y);this.element.style.marginTop=-t+"px";h(this,n.y,-t)}else if(t-(n-r.x)){this.element.style.marginLeft=t+2*e.delta.x+"px";h(this,r.x,t+2*e.delta.x)}}else if(-e.delta.x<0&&t<0){this.element.style.marginLeft=t+2*e.delta.x+"px";h(this,r.x,t+2*e.delta.x)}}else if(0<-e.delta.y){if(i>-(o-r.y)){this.element.style.marginTop=i+2*e.delta.y+"px";h(this,r.y,i+2*e.delta.y)}}else if(-e.delta.y<0&&i<0){this.element.style.marginTop=i+2*e.delta.y+"px";h(this,r.y,i+2*e.delta.y)}return!1}function l(e){var t=Number(this.element.style.marginLeft.replace("px","")),i=Number(this.element.style.marginTop.replace("px","")),n=Number(this.element.style.width.replace("px","")),o=Number(this.element.style.height.replace("px","")),r=d.getElementSize(this.viewer.canvas);if(this.element)if("horizontal"==this.scroll){if(0-(n-r.x)){this.element.style.marginLeft=t-60*e.scroll+"px";h(this,r.x,t-60*e.scroll)}}else if(e.scroll<0&&t<0){this.element.style.marginLeft=t-60*e.scroll+"px";h(this,r.x,t-60*e.scroll)}}else if(e.scroll<0){if(i>r.y-o){this.element.style.marginTop=i+60*e.scroll+"px";h(this,r.y,i+60*e.scroll)}}else if(0=this.target.time?t:e+(t-e)*(n=this.springStiffness,o=(this.current.time-this.start.time)/(this.target.time-this.start.time),(1-Math.exp(n*-o))/(1-Math.exp(-n)));var n,o;var r=this.current.value;this._exponential?this.current.value=Math.exp(i):this.current.value=i;return r!=this.current.value},isAtTargetValue:function(){return this.current.value===this.target.value}}}(OpenSeadragon);!function(t){function n(e){t.extend(!0,this,{timeout:t.DEFAULT_SETTINGS.timeout,jobId:null},e);this.image=null}n.prototype={errorMsg:null,start:function(){var r=this;var e=this.abort;this.image=new Image;this.image.onload=function(){r.finish(!0)};this.image.onabort=this.image.onerror=function(){r.errorMsg="Image load aborted";r.finish(!1)};this.jobId=window.setTimeout(function(){r.errorMsg="Image load exceeded timeout ("+r.timeout+" ms)";r.finish(!1)},this.timeout);if(this.loadWithAjax){this.request=t.makeAjaxRequest({url:this.src,withCredentials:this.ajaxWithCredentials,headers:this.ajaxHeaders,responseType:"arraybuffer",success:function(t){var i;try{i=new window.Blob([t.response])}catch(e){var n=window.BlobBuilder||window.WebKitBlobBuilder||window.MozBlobBuilder||window.MSBlobBuilder;if("TypeError"===e.name&&n){var o=new n;o.append(t.response);i=o.getBlob()}}if(0===i.size){r.errorMsg="Empty image response.";r.finish(!1)}var e=(window.URL||window.webkitURL).createObjectURL(i);r.image.src=e},error:function(e){r.errorMsg="Image load aborted - XHR error";r.finish(!1)}});this.abort=function(){r.request.abort();"function"==typeof e&&e()}}else{!1!==this.crossOriginPolicy&&(this.image.crossOrigin=this.crossOriginPolicy);this.image.src=this.src}},finish:function(e){this.image.onload=this.image.onerror=this.image.onabort=null;e||(this.image=null);this.jobId&&window.clearTimeout(this.jobId);this.callback(this)}};t.ImageLoader=function(e){t.extend(!0,this,{jobLimit:t.DEFAULT_SETTINGS.imageLoaderLimit,timeout:t.DEFAULT_SETTINGS.timeout,jobQueue:[],jobsInProgress:0},e)};t.ImageLoader.prototype={addJob:function(t){var i=this,e=new n({src:t.src,loadWithAjax:t.loadWithAjax,ajaxHeaders:t.loadWithAjax?t.ajaxHeaders:null,crossOriginPolicy:t.crossOriginPolicy,ajaxWithCredentials:t.ajaxWithCredentials,callback:function(e){!function(e,t,i){e.jobsInProgress--;if((!e.jobLimit||e.jobsInProgressthis.canvas.width&&(r.width=this.canvas.width-r.x);if(r.y<0){r.height+=r.y;r.y=0}r.y+r.height>this.canvas.height&&(r.height=this.canvas.height-r.y);this.context.drawImage(this.sketchCanvas,r.x,r.y,r.width,r.height,r.x,r.y,r.width,r.height)}else{t=o.scale||1;var s=(i=o.translate)instanceof u.Point?i:new u.Point(0,0);var a=0;var l=0;if(i){var h=this.sketchCanvas.width-this.canvas.width;var c=this.sketchCanvas.height-this.canvas.height;a=Math.round(h/2);l=Math.round(c/2)}this.context.drawImage(this.sketchCanvas,s.x-a*t,s.y-l*t,(this.canvas.width+2*a)*t,(this.canvas.height+2*l)*t,-a,-l,this.canvas.width+2*a,this.canvas.height+2*l)}this.context.restore()}},drawDebugInfo:function(e,t,i,n){if(this.useCanvas){var o=this.viewer.world.getIndexOfItem(n)%this.debugGridColor.length;var r=this.context;r.save();r.lineWidth=2*u.pixelDensityRatio;r.font="small-caps bold "+13*u.pixelDensityRatio+"px arial";r.strokeStyle=this.debugGridColor[o];r.fillStyle=this.debugGridColor[o];0!==this.viewport.degrees&&this._offsetForRotation({degrees:this.viewport.degrees});n.getRotation(!0)%360!=0&&this._offsetForRotation({degrees:n.getRotation(!0),point:n.viewport.pixelFromPointNoRotate(n._getRotationPoint(!0),!0)});0===n.viewport.degrees&&n.getRotation(!0)%360==0&&n._drawer.viewer.viewport.getFlip()&&n._drawer._flip();r.strokeRect(e.position.x*u.pixelDensityRatio,e.position.y*u.pixelDensityRatio,e.size.x*u.pixelDensityRatio,e.size.y*u.pixelDensityRatio);var s=(e.position.x+e.size.x/2)*u.pixelDensityRatio;var a=(e.position.y+e.size.y/2)*u.pixelDensityRatio;r.translate(s,a);r.rotate(Math.PI/180*-this.viewport.degrees);r.translate(-s,-a);if(0===e.x&&0===e.y){r.fillText("Zoom: "+this.viewport.getZoom(),e.position.x*u.pixelDensityRatio,(e.position.y-30)*u.pixelDensityRatio);r.fillText("Pan: "+this.viewport.getBounds().toString(),e.position.x*u.pixelDensityRatio,(e.position.y-20)*u.pixelDensityRatio)}r.fillText("Level: "+e.level,(e.position.x+10)*u.pixelDensityRatio,(e.position.y+20)*u.pixelDensityRatio);r.fillText("Column: "+e.x,(e.position.x+10)*u.pixelDensityRatio,(e.position.y+30)*u.pixelDensityRatio);r.fillText("Row: "+e.y,(e.position.x+10)*u.pixelDensityRatio,(e.position.y+40)*u.pixelDensityRatio);r.fillText("Order: "+i+" of "+t,(e.position.x+10)*u.pixelDensityRatio,(e.position.y+50)*u.pixelDensityRatio);r.fillText("Size: "+e.size.toString(),(e.position.x+10)*u.pixelDensityRatio,(e.position.y+60)*u.pixelDensityRatio);r.fillText("Position: "+e.position.toString(),(e.position.x+10)*u.pixelDensityRatio,(e.position.y+70)*u.pixelDensityRatio);0!==this.viewport.degrees&&this._restoreRotationChanges();n.getRotation(!0)%360!=0&&this._restoreRotationChanges();0===n.viewport.degrees&&n.getRotation(!0)%360==0&&n._drawer.viewer.viewport.getFlip()&&n._drawer._flip();r.restore()}},debugRect:function(e){if(this.useCanvas){var t=this.context;t.save();t.lineWidth=2*u.pixelDensityRatio;t.strokeStyle=this.debugGridColor[0];t.fillStyle=this.debugGridColor[0];t.strokeRect(e.x*u.pixelDensityRatio,e.y*u.pixelDensityRatio,e.width*u.pixelDensityRatio,e.height*u.pixelDensityRatio);t.restore()}},setImageSmoothingEnabled:function(e){if(this.useCanvas){this._imageSmoothingEnabled=e;this._updateImageSmoothingEnabled(this.context);this.viewer.forceRedraw()}},_updateImageSmoothingEnabled:function(e){e.msImageSmoothingEnabled=this._imageSmoothingEnabled;e.imageSmoothingEnabled=this._imageSmoothingEnabled},getCanvasSize:function(e){var t=this._getContext(e).canvas;return new u.Point(t.width,t.height)},getCanvasCenter:function(){return new u.Point(this.canvas.width/2,this.canvas.height/2)},_offsetForRotation:function(e){var t=e.point?e.point.times(u.pixelDensityRatio):this.getCanvasCenter();var i=this._getContext(e.useSketch);i.save();i.translate(t.x,t.y);if(this.viewer.viewport.flipped){i.rotate(Math.PI/180*-e.degrees);i.scale(-1,1)}else i.rotate(Math.PI/180*e.degrees);i.translate(-t.x,-t.y)},_flip:function(e){var t=(e=e||{}).point?e.point.times(u.pixelDensityRatio):this.getCanvasCenter();var i=this._getContext(e.useSketch);i.translate(t.x,0);i.scale(-1,1);i.translate(-t.x,0)},_restoreRotationChanges:function(e){this._getContext(e).restore()},_calculateCanvasSize:function(){var e=u.pixelDensityRatio;var t=this.viewport.getContainerSize();return{x:Math.round(t.x*e),y:Math.round(t.y*e)}},_calculateSketchCanvasSize:function(){var e=this._calculateCanvasSize();if(0===this.viewport.getRotation())return e;var t=Math.ceil(Math.sqrt(e.x*e.x+e.y*e.y));return{x:t,y:t}}}}(OpenSeadragon);!function(p){p.Viewport=function(e){var t=arguments;t.length&&t[0]instanceof p.Point&&(e={containerSize:t[0],contentSize:t[1],config:t[2]});if(e.config){p.extend(!0,e,e.config);delete e.config}this._margins=p.extend({left:0,top:0,right:0,bottom:0},e.margins||{});delete e.margins;p.extend(!0,this,{containerSize:null,contentSize:null,zoomPoint:null,viewer:null,springStiffness:p.DEFAULT_SETTINGS.springStiffness,animationTime:p.DEFAULT_SETTINGS.animationTime,minZoomImageRatio:p.DEFAULT_SETTINGS.minZoomImageRatio,maxZoomPixelRatio:p.DEFAULT_SETTINGS.maxZoomPixelRatio,visibilityRatio:p.DEFAULT_SETTINGS.visibilityRatio,wrapHorizontal:p.DEFAULT_SETTINGS.wrapHorizontal,wrapVertical:p.DEFAULT_SETTINGS.wrapVertical,defaultZoomLevel:p.DEFAULT_SETTINGS.defaultZoomLevel,minZoomLevel:p.DEFAULT_SETTINGS.minZoomLevel,maxZoomLevel:p.DEFAULT_SETTINGS.maxZoomLevel,degrees:p.DEFAULT_SETTINGS.degrees,flipped:p.DEFAULT_SETTINGS.flipped,homeFillsViewer:p.DEFAULT_SETTINGS.homeFillsViewer},e);this._updateContainerInnerSize();this.centerSpringX=new p.Spring({initial:0,springStiffness:this.springStiffness,animationTime:this.animationTime});this.centerSpringY=new p.Spring({initial:0,springStiffness:this.springStiffness,animationTime:this.animationTime});this.zoomSpring=new p.Spring({exponential:!0,initial:1,springStiffness:this.springStiffness,animationTime:this.animationTime});this._oldCenterX=this.centerSpringX.current.value;this._oldCenterY=this.centerSpringY.current.value;this._oldZoom=this.zoomSpring.current.value;this._setContentBounds(new p.Rect(0,0,1,1),1);this.goHome(!0);this.update()};p.Viewport.prototype={resetContentSize:function(e){p.console.assert(e,"[Viewport.resetContentSize] contentSize is required");p.console.assert(e instanceof p.Point,"[Viewport.resetContentSize] contentSize must be an OpenSeadragon.Point");p.console.assert(0this._contentBoundsNoRotate.width?t.x+=(r+s)/2:s<0?t.x+=s:0this._contentBoundsNoRotate.height?t.y+=(c+u)/2:u<0?t.y+=u:0=o?s.height=s.width/o:s.width=s.height*o;s.x=r.x-s.width/2;s.y=r.y-s.height/2;var a=1/s.width;if(n){var l=s.getAspectRatio();var h=this._applyZoomConstraints(a);if(a!==h){a=h;s.width=1/a;s.x=r.x-s.width/2;s.height=s.width/l;s.y=r.y-s.height/2}r=(s=this._applyBoundaryConstraints(s)).getCenter();this._raiseConstraintsEvent(i)}if(i){this.panTo(r,!0);return this.zoomTo(a,null,!0)}this.panTo(this.getCenter(!0),!0);this.zoomTo(this.getZoom(!0),null,!0);var c=this.getBounds();var u=this.getZoom();if(0===u||Math.abs(a/u-1)<1e-8){this.zoomTo(a,!0);return this.panTo(r,i)}var d=(s=s.rotate(-this.getRotation())).getTopLeft().times(a).minus(c.getTopLeft().times(u)).divide(a-u);return this.zoomTo(a,d,i)},fitBounds:function(e,t){return this._fitBounds(e,{immediately:t,constraints:!1})},fitBoundsWithConstraints:function(e,t){return this._fitBounds(e,{immediately:t,constraints:!0})},fitVertically:function(e){var t=new p.Rect(this._contentBounds.x+this._contentBounds.width/2,this._contentBounds.y,0,this._contentBounds.height);return this.fitBounds(t,e)},fitHorizontally:function(e){var t=new p.Rect(this._contentBounds.x,this._contentBounds.y+this._contentBounds.height/2,this._contentBounds.width,0);return this.fitBounds(t,e)},getConstrainedBounds:function(e){var t;t=this.getBounds(e);return this._applyBoundaryConstraints(t)},panBy:function(e,t){var i=new p.Point(this.centerSpringX.target.value,this.centerSpringY.target.value);return this.panTo(i.plus(e),t)},panTo:function(e,t){if(t){this.centerSpringX.resetTo(e.x);this.centerSpringY.resetTo(e.y)}else{this.centerSpringX.springTo(e.x);this.centerSpringY.springTo(e.y)}this.viewer&&this.viewer.raiseEvent("pan",{center:e,immediately:t});return this},zoomBy:function(e,t,i){return this.zoomTo(this.zoomSpring.target.value*e,t,i)},zoomTo:function(e,t,i){var n=this;this.zoomPoint=t instanceof p.Point&&!isNaN(t.x)&&!isNaN(t.y)?t:null;i?this._adjustCenterSpringsForZoomPoint(function(){n.zoomSpring.resetTo(e)}):this.zoomSpring.springTo(e);this.viewer&&this.viewer.raiseEvent("zoom",{zoom:e,refPoint:t,immediately:i});return this},setRotation:function(e){if(!this.viewer||!this.viewer.drawer.canRotate())return this;this.degrees=p.positiveModulo(e,360);this._setContentBounds(this.viewer.world.getHomeBounds(),this.viewer.world.getContentFactor());this.viewer.forceRedraw();this.viewer.raiseEvent("rotate",{degrees:e});return this},getRotation:function(){return this.degrees},resize:function(e,t){var i,n=this.getBoundsNoRotate(),o=n;this.containerSize.x=e.x;this.containerSize.y=e.y;this._updateContainerInnerSize();if(t){i=e.x/this.containerSize.x;o.width=n.width*i;o.height=o.width/this.getAspectRatio()}this.viewer&&this.viewer.raiseEvent("resize",{newContainerSize:e,maintain:t});return this.fitBounds(o,!0)},_updateContainerInnerSize:function(){this._containerInnerSize=new p.Point(Math.max(1,this.containerSize.x-(this._margins.left+this._margins.right)),Math.max(1,this.containerSize.y-(this._margins.top+this._margins.bottom)))},update:function(){var e=this;this._adjustCenterSpringsForZoomPoint(function(){e.zoomSpring.update()});this.centerSpringX.update();this.centerSpringY.update();var t=this.centerSpringX.current.value!==this._oldCenterX||this.centerSpringY.current.value!==this._oldCenterY||this.zoomSpring.current.value!==this._oldZoom;this._oldCenterX=this.centerSpringX.current.value;this._oldCenterY=this.centerSpringY.current.value;this._oldZoom=this.zoomSpring.current.value;return t},_adjustCenterSpringsForZoomPoint:function(e){if(this.zoomPoint){var t=this.pixelFromPoint(this.zoomPoint,!0);e();var i=this.pixelFromPoint(this.zoomPoint,!0).minus(t);var n=this.deltaPointsFromPixels(i,!0);this.centerSpringX.shiftBy(n.x);this.centerSpringY.shiftBy(n.y);this.zoomSpring.isAtTargetValue()&&(this.zoomPoint=null)}else e()},deltaPixelsFromPointsNoRotate:function(e,t){return e.times(this._containerInnerSize.x*this.getZoom(t))},deltaPixelsFromPoints:function(e,t){return this.deltaPixelsFromPointsNoRotate(e.rotate(this.getRotation()),t)},deltaPointsFromPixelsNoRotate:function(e,t){return e.divide(this._containerInnerSize.x*this.getZoom(t))},deltaPointsFromPixels:function(e,t){return this.deltaPointsFromPixelsNoRotate(e,t).rotate(-this.getRotation())},pixelFromPointNoRotate:function(e,t){return this._pixelFromPointNoRotate(e,this.getBoundsNoRotate(t))},pixelFromPoint:function(e,t){return this._pixelFromPoint(e,this.getBoundsNoRotate(t))},_pixelFromPointNoRotate:function(e,t){return e.minus(t.getTopLeft()).times(this._containerInnerSize.x/t.width).plus(new p.Point(this._margins.left,this._margins.top))},_pixelFromPoint:function(e,t){return this._pixelFromPointNoRotate(e.rotate(this.getRotation(),this.getCenter(!0)),t)},pointFromPixelNoRotate:function(e,t){var i=this.getBoundsNoRotate(t);return e.minus(new p.Point(this._margins.left,this._margins.top)).divide(this._containerInnerSize.x/i.width).plus(i.getTopLeft())},pointFromPixel:function(e,t){return this.pointFromPixelNoRotate(e,t).rotate(-this.getRotation(),this.getCenter(!0))},_viewportToImageDelta:function(e,t){var i=this._contentBoundsNoRotate.width;return new p.Point(e*this._contentSizeNoRotate.x/i,t*this._contentSizeNoRotate.x/i)},viewportToImageCoordinates:function(e,t){if(e instanceof p.Point)return this.viewportToImageCoordinates(e.x,e.y);if(this.viewer){var i=this.viewer.world.getItemCount();if(1o){r=this._clip.x/this._clip.height*e.height;s=this._clip.y/this._clip.height*e.height}else{r=this._clip.x/this._clip.width*e.width;s=this._clip.y/this._clip.width*e.width}}if(e.getAspectRatio()>o){var h=e.height/l;var c=0;n.isHorizontallyCentered?c=(e.width-e.height*o)/2:n.isRight&&(c=e.width-e.height*o);this.setPosition(new y.Point(e.x-r+c,e.y-s),i);this.setHeight(h,i)}else{var u=e.width/a;var d=0;n.isVerticallyCentered?d=(e.height-e.width/o)/2:n.isBottom&&(d=e.height-e.width/o);this.setPosition(new y.Point(e.x-r,e.y-s+d),i);this.setWidth(u,i)}},getClip:function(){return this._clip?this._clip.clone():null},setClip:function(e){y.console.assert(!e||e instanceof y.Rect,"[TiledImage.setClip] newClip must be an OpenSeadragon.Rect or null");e instanceof y.Rect?this._clip=e.clone():this._clip=null;this._needsDraw=!0;this.raiseEvent("clip-change")},getOpacity:function(){return this.opacity},setOpacity:function(e){if(e!==this.opacity){this.opacity=e;this._needsDraw=!0;this.raiseEvent("opacity-change",{opacity:this.opacity})}},getPreload:function(){return this._preload},setPreload:function(e){this._preload=!!e;this._needsDraw=!0},getRotation:function(e){return e?this._degreesSpring.current.value:this._degreesSpring.target.value},setRotation:function(e,t){if(this._degreesSpring.target.value!==e||!this._degreesSpring.isAtTargetValue()){t?this._degreesSpring.resetTo(e):this._degreesSpring.springTo(e);this._needsDraw=!0;this._raiseBoundsChange()}},_getRotationPoint:function(e){return this.getBoundsNoRotate(e).getCenter()},getCompositeOperation:function(){return this.compositeOperation},setCompositeOperation:function(e){if(e!==this.compositeOperation){this.compositeOperation=e;this._needsDraw=!0;this.raiseEvent("composite-operation-change",{compositeOperation:this.compositeOperation})}},_setScale:function(e,t){var i=this._scaleSpring.target.value===e;if(t){if(i&&this._scaleSpring.current.value===e)return;this._scaleSpring.resetTo(e);this._updateForScale();this._needsDraw=!0}else{if(i)return;this._scaleSpring.springTo(e);this._updateForScale();this._needsDraw=!0}i||this._raiseBoundsChange()},_updateForScale:function(){this._worldWidthTarget=this._scaleSpring.target.value;this._worldHeightTarget=this.normHeight*this._scaleSpring.target.value;this._worldWidthCurrent=this._scaleSpring.current.value;this._worldHeightCurrent=this.normHeight*this._scaleSpring.current.value},_raiseBoundsChange:function(){this.raiseEvent("bounds-change")},_isBottomItem:function(){return this.viewer.world.getItemAt(0)===this},_getLevelsInterval:function(){var e=Math.max(this.source.minLevel,Math.floor(Math.log(this.minZoomImageRatio)/Math.log(2)));var t=this.viewport.deltaPixelsFromPointsNoRotate(this.source.getPixelRatio(0),!0).x*this._scaleSpring.current.value;var i=Math.min(Math.abs(this.source.maxLevel),Math.abs(Math.floor(Math.log(t/this.minPixelRatio)/Math.log(2))));i=Math.max(i,this.source.minLevel||0);return{lowestLevel:e=Math.min(e,i),highestLevel:i}},_updateViewport:function(){this._needsDraw=!1;this._tilesLoading=0;this.loadingCoverage={};for(;0=this.minPixelRatio)a=c=!0;else if(!a)continue;var d=e.deltaPixelsFromPointsNoRotate(this.source.getPixelRatio(h),!1).x*this._scaleSpring.current.value;var p=e.deltaPixelsFromPointsNoRotate(this.source.getPixelRatio(Math.max(this.source.getClosestLevel(),0)),!1).x*this._scaleSpring.current.value;var g=this.immediateRender?1:p;s=m(this,a,c,h,Math.min(1,(u-.5)/.5),g/Math.abs(g-d),t,l,s);if(f(this.coverage,h))break}!function(n,e){if(0===n.opacity||0===e.length&&!n.placeholderFillStyle)return;var t=e[0];var i;t&&(i=n.opacity<1||n.compositeOperation&&"source-over"!==n.compositeOperation||!n._isBottomItem()&&t._hasTransparencyChannel());var o;var r;var s=n.viewport.getZoom(!0);var a=n.viewportToImageZoom(s);if(1n.smoothTileEdgesMinZoom&&!n.iOSDevice&&n.getRotation(!0)%360==0&&y.supportsCanvas){i=!0;o=t.getScaleForEdgeSmoothing();r=t.getTranslationForEdgeSmoothing(o,n._drawer.getCanvasSize(!1),n._drawer.getCanvasSize(!0))}var l;if(i){if(!o){l=n.viewport.viewportToViewerElementRectangle(n.getClippedBounds(!0)).getIntegerBoundingBox();n._drawer.viewer.viewport.getFlip()&&(0===n.viewport.degrees&&n.getRotation(!0)%360==0||(l.x=n._drawer.viewer.container.clientWidth-(l.x+l.width)));l=l.times(y.pixelDensityRatio)}n._drawer._clear(!0,l)}if(!o){0!==n.viewport.degrees&&n._drawer._offsetForRotation({degrees:n.viewport.degrees,useSketch:i});n.getRotation(!0)%360!=0&&n._drawer._offsetForRotation({degrees:n.getRotation(!0),point:n.viewport.pixelFromPointNoRotate(n._getRotationPoint(!0),!0),useSketch:i});0===n.viewport.degrees&&n.getRotation(!0)%360==0&&n._drawer.viewer.viewport.getFlip()&&n._drawer._flip()}var h=!1;if(n._clip){n._drawer.saveContext(i);var c=n.imageToViewportRectangle(n._clip,!0);c=c.rotate(-n.getRotation(!0),n._getRotationPoint(!0));var u=n._drawer.viewportToDrawerRectangle(c);o&&(u=u.times(o));r&&(u=u.translate(r));n._drawer.setClip(u,i);h=!0}if(n._croppingPolygons){n._drawer.saveContext(i);try{var d=n._croppingPolygons.map(function(e){return e.map(function(e){var t=n.imageToViewportCoordinates(e.x,e.y,!0).rotate(-n.getRotation(!0),n._getRotationPoint(!0));var i=n._drawer.viewportCoordToDrawerCoord(t);o&&(i=i.times(o));return i})});n._drawer.clipWithPolygons(d,i)}catch(e){y.console.error(e)}h=!0}if(n.placeholderFillStyle&&!1===n._hasOpaqueTile){var p=n._drawer.viewportToDrawerRectangle(n.getBounds(!0));o&&(p=p.times(o));r&&(p=p.translate(r));var g=null;g="function"==typeof n.placeholderFillStyle?n.placeholderFillStyle(n,n._drawer.context):n.placeholderFillStyle;n._drawer.drawRectangle(p,g,i)}for(var m=e.length-1;0<=m;m--){t=e[m];n._drawer.drawTile(t,n._drawingHandler,i,o,r);t.beingDrawn=!0;n.viewer&&n.viewer.raiseEvent("tile-drawn",{tiledImage:n,tile:t})}h&&n._drawer.restoreContext(i);if(!o){n.getRotation(!0)%360!=0&&n._drawer._restoreRotationChanges(i);0!==n.viewport.degrees&&n._drawer._restoreRotationChanges(i)}if(i){if(o){0!==n.viewport.degrees&&n._drawer._offsetForRotation({degrees:n.viewport.degrees,useSketch:!1});n.getRotation(!0)%360!=0&&n._drawer._offsetForRotation({degrees:n.getRotation(!0),point:n.viewport.pixelFromPointNoRotate(n._getRotationPoint(!0),!0),useSketch:!1})}n._drawer.blendSketch({opacity:n.opacity,scale:o,translate:r,compositeOperation:n.compositeOperation,bounds:l});if(o){n.getRotation(!0)%360!=0&&n._drawer._restoreRotationChanges(!1);0!==n.viewport.degrees&&n._drawer._restoreRotationChanges(!1)}}o||0===n.viewport.degrees&&n.getRotation(!0)%360==0&&n._drawer.viewer.viewport.getFlip()&&n._drawer._flip();!function(e,t){if(e.debugMode)for(var i=t.length-1;0<=i;i--){var n=t[i];try{e._drawer.drawDebugInfo(n,t.length,i,e)}catch(e){y.console.error(e)}}}(n,e)}(this,this.lastDrawn);if(s&&!s.context2D){!function(n,o,r){o.loading=!0;n._imageLoader.addJob({src:o.url,loadWithAjax:o.loadWithAjax,ajaxHeaders:o.ajaxHeaders,crossOriginPolicy:n.crossOriginPolicy,ajaxWithCredentials:n.ajaxWithCredentials,callback:function(e,t,i){!function(t,i,e,n,o,r){if(!n){y.console.log("Tile %s failed to load: %s - error: %s",i,i.url,o);t.viewer.raiseEvent("tile-load-failed",{tile:i,tiledImage:t,time:e,message:o,tileRequest:r});i.loading=!1;i.exists=!1;return}if(ee.visibility)return t;if(t.visibility==e.visibility&&t.squaredDistancethis._maxImageCacheCount){var o=null;var r=-1;var s=null;var a,l,h,c,u,d;for(var p=this._tilesLoaded.length-1;0<=p;p--)if(!((a=(d=this._tilesLoaded[p]).tile).level<=t||a.beingDrawn))if(o){c=a.lastTouchTime;l=o.lastTouchTime;u=a.level;h=o.level;if(c=this._items.length)throw new Error("Index bigger than number of layers.");if(t!==i&&-1!==i){this._items.splice(i,1);this._items.splice(t,0,e);this._needsDraw=!0;this.raiseEvent("item-index-change",{item:e,previousIndex:i,newIndex:t})}},removeItem:function(e){v.console.assert(e,"[World.removeItem] item is required");var t=v.indexOf(this._items,e);if(-1!==t){e.removeHandler("bounds-change",this._delegatedFigureSizes);e.removeHandler("clip-change",this._delegatedFigureSizes);e.destroy();this._items.splice(t,1);this._figureSizes();this._needsDraw=!0;this._raiseRemoveItem(e)}},removeAll:function(){this.viewer._cancelPendingImages();var e;var t;for(t=0;tu.height?r:r*(u.width/u.height))*(u.height/u.width);g=new v.Point(l+(r-d)/2,h+(r-p)/2);c.setPosition(g,t);c.setWidth(d,t);"horizontal"===i?l+=s:h+=s}this.setAutoRefigureSizes(!0)},_figureSizes:function(){var e=this._homeBounds?this._homeBounds.clone():null;var t=this._contentSize?this._contentSize.clone():null;var i=this._contentFactor||0;if(this._items.length){var n=this._items[0];var o=n.getBounds();this._contentFactor=n.getContentSize().x/o.width;var r=n.getClippedBounds().getBoundingBox();var s=r.x;var a=r.y;var l=r.x+r.width;var h=r.y+r.height;for(var c=1;c + * window.segmentationUI – palette controller + */ +(function () { + 'use strict'; + + const TILE_SIZE = 256; + + function hexToRgb(hex) { + const v = parseInt(hex.replace('#', ''), 16); + return { r: (v >> 16) & 0xff, g: (v >> 8) & 0xff, b: v & 0xff }; + } + + function getCsrf() { + const m = document.cookie.match(/csrftoken=([^;]+)/); + return m ? m[1] : ''; + } + + // ── SegmentationLayer ──────────────────────────────────────────────── + + class SegmentationLayer { + constructor(viewer, annotationId, imageWidth, imageHeight, color, frame, plane, nFrames) { + this.viewer = viewer; + this.annotationId = annotationId; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + this.color = hexToRgb(color); + this.frame = frame || 0; + this.plane = plane || 0; // 0=axial, 1=coronal, 2=sagittal + // nFrames = number of slices along this plane's normal axis (= voxel count). + // For coronal: ny_vox; for sagittal: nx_vox; for axial: nz_vox. + // Needed by the server to correctly map voxel coords to tile pixel rows/cols + // when the axial spacing is anisotropic (e.g. thick-slice MRI/CT). + this.nFrames = nFrames || 0; // 0 → server falls back to img.height/width + this.opacity = 0.55; + this.editable = false; + + // Tool state + this.activeTool = null; + this.brushSize = 20; + this.brushShape = 'circle'; + this.tolerance = 20; + this.activeClass = 1; + + // Drawing state + this.isDrawing = false; + this.lastImgX = null; + this.lastImgY = null; + + // Undo state + this._undoStack = []; + this._preStrokeSnap = null; + + // Tile state + this.tileCache = new Map(); // `${tx}_${ty}` → Uint8Array | null(loading) + this.renderCache = new Map(); // `${tx}_${ty}` → ImageBitmap (colored, ready to blit) + this.dirtyTiles = new Set(); + this.imgTileCache = new Map(); // `${tx}_${ty}` → ImageBitmap (raw image pixels) + this._dziMaxLevel = null; + this._dziTileSize = 254; + + this._setupCanvases(); + this._bindViewerEvents(); + this._bindDrawEvents(); + // Trigger initial draw after OSD has had a chance to open its tile source + this.viewer.addOnceHandler('open', () => this._redraw()); + if (this.viewer.world.getItemAt(0)) this._redraw(); + } + + // ── Canvas setup ───────────────────────────────────────────────── + + _setupCanvases() { + const container = this.viewer.canvas.parentElement; + + // Display canvas lives in image-coordinate space (capped to avoid + // excessive memory on gigapixel slides). Its CSS transform is updated + // every frame to track the viewport — zero canvas-redraw cost during pan/zoom. + const MAX_DIM = 4096; + this._dispScale = Math.min(1, + MAX_DIM / Math.max(this.imageWidth, this.imageHeight)); + this.displayCanvas = document.createElement('canvas'); + this.displayCanvas.width = Math.ceil(this.imageWidth * this._dispScale); + this.displayCanvas.height = Math.ceil(this.imageHeight * this._dispScale); + Object.assign(this.displayCanvas.style, { + position: 'absolute', top: '0', left: '0', + transformOrigin: '0 0', + pointerEvents: 'none', + opacity: this.opacity, + zIndex: '10', + }); + container.appendChild(this.displayCanvas); + + // Draw canvas stays at screen resolution — it captures mouse events + // and renders the brush cursor. + this.drawCanvas = document.createElement('canvas'); + Object.assign(this.drawCanvas.style, { + position: 'absolute', top: '0', left: '0', + zIndex: '11', + display: 'none', + cursor: 'crosshair', + }); + container.appendChild(this.drawCanvas); + + this._resizeCanvases(); + } + + _resizeCanvases() { + // Only the draw canvas needs resizing; displayCanvas is in image space. + const w = this.viewer.canvas.clientWidth || this.viewer.canvas.offsetWidth; + const h = this.viewer.canvas.clientHeight || this.viewer.canvas.offsetHeight; + this.drawCanvas.width = w; this.drawCanvas.height = h; + this.drawCanvas.style.width = w + 'px'; this.drawCanvas.style.height = h + 'px'; + } + + // ── OSD event bindings ─────────────────────────────────────────── + + _bindViewerEvents() { + this._onUpdate = () => this._redraw(); + this._onResize = () => { this._resizeCanvases(); this._redraw(); }; + this._onPage = (e) => { this.setFrame(e.page); }; + // 'animation' fires after OSD completes its full draw pass each frame, + // in sync with its rAF loop — the same pattern used by the scalebar, + // quad-tree, and guides plugins. 'animation-finish' catches the last frame. + this.viewer.addHandler('animation', this._onUpdate); + this.viewer.addHandler('animation-finish', this._onUpdate); + this.viewer.addHandler('resize', this._onResize); + this.viewer.addHandler('page', this._onPage); + } + + // ── Cursors ────────────────────────────────────────────────────── + + static _makeSvgCursor(svgBody, hx, hy) { + const svg = `${svgBody}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${hx} ${hy}, crosshair`; + } + + static get CURSORS() { + if (!SegmentationLayer._cursors) { + const mk = SegmentationLayer._makeSvgCursor.bind(SegmentationLayer); + // Fill: paint bucket shape + const bucket = ` + + + + `; + // Wand add: wand line with + badge + const wandAdd = ` + + +`; + // Wand remove: wand line with − badge + const wandRem = ` + + `; + + SegmentationLayer._cursors = { + pencil: 'crosshair', + eraser: 'cell', + pan: 'grab', + fill: mk(bucket, 8, 22), + wand: mk(wandAdd, 3, 25), + wand_erase: mk(wandRem, 3, 25), + }; + } + return SegmentationLayer._cursors; + } + + _wandCursor() { + return this._wandEraseMode + ? SegmentationLayer.CURSORS.wand_erase + : SegmentationLayer.CURSORS.wand; + } + + _applyToolCursor() { + const C = SegmentationLayer.CURSORS; + if (this.activeTool === 'wand') { + this.drawCanvas.style.cursor = this._wandCursor(); + } else { + this.drawCanvas.style.cursor = C[this.activeTool] || 'crosshair'; + } + } + + // ── Draw canvas events ─────────────────────────────────────────── + + _bindDrawEvents() { + const c = this.drawCanvas; + + // Track Ctrl/Cmd for wand erase mode + this._wandEraseMode = false; + this._onModifierDown = (e) => { + if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') return; + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + e.preventDefault(); + this.undo(); + return; + } + const wasErase = this._wandEraseMode; + this._wandEraseMode = e.ctrlKey || e.metaKey; + if (this._wandEraseMode !== wasErase && this.activeTool === 'wand') { + this._applyToolCursor(); + } + }; + this._onModifierUp = (e) => { + const wasErase = this._wandEraseMode; + this._wandEraseMode = e.ctrlKey || e.metaKey; + if (this._wandEraseMode !== wasErase && this.activeTool === 'wand') { + this._applyToolCursor(); + } + }; + document.addEventListener('keydown', this._onModifierDown); + document.addEventListener('keyup', this._onModifierUp); + + c.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + this.isDrawing = true; + const [ix, iy] = this._vpToImg(e.offsetX, e.offsetY); + this.lastImgX = ix; this.lastImgY = iy; + + if (this.activeTool === 'wand') { + this._preStrokeSnap = this._snapshotTileCache(); + this._magicWand(ix, iy, this._wandEraseMode); + this._commitUndo(); + this._uploadDirtyTiles(); + } else if (this.activeTool === 'fill') { + this._preStrokeSnap = this._snapshotTileCache(); + this._fillTool(ix, iy); + this._commitUndo(); + this._uploadDirtyTiles(); + } else { + this._preStrokeSnap = this._snapshotTileCache(); + this._paintBrush(ix, iy, ix, iy); + } + }); + + c.addEventListener('mousemove', (e) => { + this._updateCursor(e.offsetX, e.offsetY); + if (!this.isDrawing) return; + if (this.activeTool === 'wand' || this.activeTool === 'fill') return; + const [ix, iy] = this._vpToImg(e.offsetX, e.offsetY); + this._paintBrush(this.lastImgX, this.lastImgY, ix, iy); + this.lastImgX = ix; this.lastImgY = iy; + }); + + c.addEventListener('mouseleave', () => { + this._cursorVpX = null; + this._redraw(); + if (!this.isDrawing) return; + this.isDrawing = false; + this._commitUndo(); + this._uploadDirtyTiles(); + }); + + c.addEventListener('mouseup', () => { + if (!this.isDrawing) return; + this.isDrawing = false; + this._commitUndo(); + this._uploadDirtyTiles(); + }); + } + + // ── Coordinates ────────────────────────────────────────────────── + + _vpToImg(vpX, vpY) { + const vp = this.viewer.viewport; + const pt = vp.pointFromPixel(new OpenSeadragon.Point(vpX, vpY)); + const ip = this.viewer.world.getItemAt(0).viewportToImageCoordinates(pt); + return [Math.round(ip.x), Math.round(ip.y)]; + } + + _imgToScreen(ix, iy) { + const vp = this.viewer.viewport; + const vpt = this.viewer.world.getItemAt(0) + .imageToViewportCoordinates(new OpenSeadragon.Point(ix, iy)); + return vp.pixelFromPoint(vpt); + } + + // ── Tile cache ─────────────────────────────────────────────────── + + _tileKey(tx, ty) { return `${tx}_${ty}`; } + + _getTileData(tx, ty) { + const k = this._tileKey(tx, ty); + if (!this.tileCache.has(k)) this.tileCache.set(k, new Uint8Array(TILE_SIZE * TILE_SIZE)); + return this.tileCache.get(k); + } + + async _ensureTile(tx, ty) { + const k = this._tileKey(tx, ty); + if (this.tileCache.has(k)) return; + this.tileCache.set(k, null); + try { + const r = await fetch( + `/annotations/api/segmentation/${this.annotationId}/tiles/${this.plane}/${tx}/${ty}/?frame=${this.frame}&ph=${this.imageHeight}&nf=${this.nFrames}`, + { credentials: 'same-origin' }); + if (r.status === 204) { + this.tileCache.set(k, new Uint8Array(TILE_SIZE * TILE_SIZE)); + } else { + const bm = await createImageBitmap(await r.blob()); + const oc = new OffscreenCanvas(TILE_SIZE, TILE_SIZE); + const ctx = oc.getContext('2d'); + ctx.drawImage(bm, 0, 0); + const raw = ctx.getImageData(0, 0, TILE_SIZE, TILE_SIZE).data; + const labels = new Uint8Array(TILE_SIZE * TILE_SIZE); + // Threshold at 127: robust against gamma correction and any + // color-space transform the browser applies during PNG decode. + for (let i = 0; i < labels.length; i++) labels[i] = raw[i * 4] > 127 ? 1 : 0; + this.tileCache.set(k, labels); + this._prerenderTile(tx, ty); // draw into displayCanvas immediately + } + } catch (_) { this.tileCache.delete(k); } + // No full _redraw() needed — tile was painted directly into displayCanvas. + } + + // ── Display ────────────────────────────────────────────────────── + + // Render one tile into the display canvas (image-coordinate space). + // Called once when tile data arrives or changes — NOT every frame. + _prerenderTile(tx, ty) { + const k = this._tileKey(tx, ty); + const labels = this.tileCache.get(k); + if (!labels) return; + const { r, g, b } = this.color; + const oc = new OffscreenCanvas(TILE_SIZE, TILE_SIZE); + const ctx = oc.getContext('2d'); + const id = ctx.createImageData(TILE_SIZE, TILE_SIZE); + const d = id.data; + let hasContent = false; + for (let i = 0; i < labels.length; i++) { + if (labels[i] > 0) { + d[i*4]=r; d[i*4+1]=g; d[i*4+2]=b; d[i*4+3]=200; + hasContent = true; + } + } + ctx.putImageData(id, 0, 0); + const s = this._dispScale; + const dCtx = this.displayCanvas.getContext('2d'); + // Erase the old content for this tile slot first + dCtx.clearRect(tx * TILE_SIZE * s, ty * TILE_SIZE * s, TILE_SIZE * s, TILE_SIZE * s); + if (hasContent) { + createImageBitmap(oc).then(bm => { + this.renderCache.set(k, bm); + dCtx.drawImage(bm, tx * TILE_SIZE * s, ty * TILE_SIZE * s, + TILE_SIZE * s, TILE_SIZE * s); + }); + } else { + this.renderCache.delete(k); + } + } + + // Redraw entire display canvas from renderCache (used on color/frame change). + _rerenderDisplay() { + const dCtx = this.displayCanvas.getContext('2d'); + dCtx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height); + const s = this._dispScale; + this.renderCache.forEach((bm, k) => { + const [tx, ty] = k.split('_').map(Number); + dCtx.drawImage(bm, tx * TILE_SIZE * s, ty * TILE_SIZE * s, + TILE_SIZE * s, TILE_SIZE * s); + }); + } + + // Called every animation frame — updates the CSS transform only. + // No canvas pixel work; the GPU compositor moves the overlay for free. + _redraw() { + const item = this.viewer.world.getItemAt(0); + if (!item) return; + + // Two image-corner → screen mappings give us translate + scale. + const p00 = this._imgToScreen(0, 0); + const p11 = this._imgToScreen(this.imageWidth, this.imageHeight); + const sx = (p11.x - p00.x) / this.displayCanvas.width; + const sy = (p11.y - p00.y) / this.displayCanvas.height; + this.displayCanvas.style.transform = + `translate(${p00.x}px,${p00.y}px) scale(${sx},${sy})`; + + // Cursor lives on the draw canvas (screen space) — cheap clear+draw. + const dCtx = this.drawCanvas.getContext('2d'); + dCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height); + if (this._cursorVpX != null && this.activeTool && + this.activeTool !== 'wand' && this.activeTool !== 'fill' && + this.activeTool !== 'pan') { + this._drawCursorShape(dCtx, this._cursorVpX, this._cursorVpY); + } + + // Trigger tile fetches for newly visible tiles (display canvas will + // update incrementally as each tile arrives). + const vp = this.viewer.viewport; + const bounds = vp.getBounds(true); + const tlImg = item.viewportToImageCoordinates( + new OpenSeadragon.Point(bounds.x, bounds.y)); + const brImg = item.viewportToImageCoordinates( + new OpenSeadragon.Point(bounds.x + bounds.width, bounds.y + bounds.height)); + const tx0 = Math.max(0, Math.floor(tlImg.x / TILE_SIZE)); + const ty0 = Math.max(0, Math.floor(tlImg.y / TILE_SIZE)); + const tx1 = Math.min(Math.ceil(this.imageWidth / TILE_SIZE) - 1, + Math.ceil(brImg.x / TILE_SIZE)); + const ty1 = Math.min(Math.ceil(this.imageHeight / TILE_SIZE) - 1, + Math.ceil(brImg.y / TILE_SIZE)); + for (let ty = ty0; ty <= ty1; ty++) + for (let tx = tx0; tx <= tx1; tx++) + if (!this.tileCache.has(this._tileKey(tx, ty))) + this._ensureTile(tx, ty); + } + + _updateCursor(vpX, vpY) { + this._cursorVpX = vpX; + this._cursorVpY = vpY; + // Cursor lives on the draw canvas — update it directly without touching displayCanvas. + const dCtx = this.drawCanvas.getContext('2d'); + dCtx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height); + if (this.activeTool && this.activeTool !== 'wand' && + this.activeTool !== 'fill' && this.activeTool !== 'pan') { + this._drawCursorShape(dCtx, vpX, vpY); + } + } + + _drawCursorShape(ctx, vpX, vpY) { + const item = this.viewer.world.getItemAt(0); + if (!item) return; + const vp = this.viewer.viewport; + const pt0 = vp.pixelFromPoint(item.imageToViewportCoordinates( + new OpenSeadragon.Point(0, 0))); + const pt1 = vp.pixelFromPoint(item.imageToViewportCoordinates( + new OpenSeadragon.Point(this.brushSize, this.brushSize))); + const rX = Math.abs(pt1.x - pt0.x) / 2; + const rY = Math.abs(pt1.y - pt0.y) / 2; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.85)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + if (this.brushShape === 'circle') { + ctx.ellipse(vpX, vpY, Math.max(1,rX), Math.max(1,rY), 0, 0, Math.PI*2); + } else { + ctx.rect(vpX - rX, vpY - rY, rX*2, rY*2); + } + ctx.stroke(); + ctx.restore(); + } + + // ── Paint tools ────────────────────────────────────────────────── + + _paintBrush(x0, y0, x1, y1) { + let dx = Math.abs(x1-x0), dy = Math.abs(y1-y0); + const sx = x0-dy){err-=dy; x+=sx;} + if (e2< dx){err+=dx; y+=sy;} + } + this._rerenderDirty(); + } + + _stamp(cx, cy) { + const r = Math.max(1, Math.floor(this.brushSize/2)); + const label = this.activeTool === 'eraser' ? 0 : this.activeClass; + const r2 = r*r; + for (let dy=-r; dy<=r; dy++) { + for (let dx=-r; dx<=r; dx++) { + if (this.brushShape==='circle' && dx*dx+dy*dy>r2) continue; + this._setPixel(cx+dx, cy+dy, label); + } + } + } + + _setPixel(ix, iy, label) { + if (ix<0||iy<0||ix>=this.imageWidth||iy>=this.imageHeight) return; + const tx = Math.floor(ix/TILE_SIZE), ty = Math.floor(iy/TILE_SIZE); + const k = this._tileKey(tx, ty); + if (!this.tileCache.has(k)) this.tileCache.set(k, new Uint8Array(TILE_SIZE*TILE_SIZE)); + const labels = this.tileCache.get(k); + if (!labels) return; + labels[(iy%TILE_SIZE)*TILE_SIZE + (ix%TILE_SIZE)] = label; + this.dirtyTiles.add(k); + this.renderCache.delete(k); // mark render stale; rebuilt in _rerenderDirty + } + + _rerenderDirty() { + // Re-render all tiles modified since last call, then redraw. + // Called once per brush stroke / fill, not per pixel. + const toRender = new Set([...this.dirtyTiles]); + let pending = toRender.size; + if (pending === 0) { this._redraw(); return; } + toRender.forEach(k => { + const [tx, ty] = k.split('_').map(Number); + this._prerenderTile(tx, ty); + }); + } + + _getPixel(ix, iy) { + if (ix<0||iy<0||ix>=this.imageWidth||iy>=this.imageHeight) return -1; + const labels = this._getTileData(Math.floor(ix/TILE_SIZE), Math.floor(iy/TILE_SIZE)); + return labels[(iy%TILE_SIZE)*TILE_SIZE + (ix%TILE_SIZE)]; + } + + // ── Undo ────────────────────────────────────────────────────────── + + _snapshotTileCache() { + const snap = new Map(); + this.tileCache.forEach((data, k) => { + snap.set(k, data ? data.slice() : null); + }); + return snap; + } + + _commitUndo() { + if (!this._preStrokeSnap) return; + this._undoStack.push(this._preStrokeSnap); + if (this._undoStack.length > 20) this._undoStack.shift(); + this._preStrokeSnap = null; + } + + undo() { + if (!this._undoStack.length) return; + const snap = this._undoStack.pop(); + // Clear display canvas first so stroke-created tiles are visually removed. + const dCtx = this.displayCanvas.getContext('2d'); + dCtx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height); + // Restore tile data from snapshot (overwrites current state). + this.tileCache = snap; + this.renderCache.clear(); + this.dirtyTiles.clear(); + // Re-render every tile in the restored snapshot. + snap.forEach((data, k) => { + if (!data) return; + const [tx, ty] = k.split('_').map(Number); + this._prerenderTile(tx, ty); + }); + } + + // ── Fill tool (mask flood fill) ─────────────────────────────────── + + _fillTool(startX, startY) { + this._imageColorBFS(startX, startY, /* skipLabeled */ false); + this._rerenderDirty(); + } + + _magicWand(startX, startY, eraseMode) { + this._imageColorBFS(startX, startY, /* skipLabeled */ false, eraseMode); + this._rerenderDirty(); + } + + // ── Image-color BFS (shared by magic wand and fill) ─────────────── + // Samples OSD's rendered canvas once, then floods connected pixels whose + // color distance from the seed is within this.tolerance. + // skipLabeled=true → skip pixels already painted with any label. + + _imageColorBFS(startX, startY, skipLabeled, eraseMode) { + const dc = this.viewer.drawer.canvas; + if (!dc) { console.warn('Seg: OSD canvas not available'); return; } + + const CW = dc.width, CH = dc.height; + let pixels; + try { + pixels = dc.getContext('2d').getImageData(0, 0, CW, CH).data; + } catch (e) { + console.warn('Seg: cannot read OSD canvas (cross-origin?)', e); + return; + } + + // Affine image-pixel → physical-canvas-pixel transform. + // _imgToScreen returns CSS pixels; multiply by devicePixelRatio to get + // physical canvas pixels (needed for correct indexing on HiDPI displays). + const dpr = window.devicePixelRatio || 1; + const p00 = this._imgToScreen(0, 0); + const p10 = this._imgToScreen(1, 0); + const p01 = this._imgToScreen(0, 1); + const dxX = (p10.x - p00.x) * dpr; + const dyY = (p01.y - p00.y) * dpr; + const ox = p00.x * dpr; + const oy = p00.y * dpr; + + const sample = (ix, iy) => { + const sx = Math.round(ox + ix * dxX); + const sy = Math.round(oy + iy * dyY); + if (sx < 0 || sx >= CW || sy < 0 || sy >= CH) return null; + const idx = (sy * CW + sx) * 4; + return [pixels[idx], pixels[idx+1], pixels[idx+2]]; + }; + + const ref = sample(startX, startY); + if (!ref) { + console.warn('Seg: seed pixel outside rendered canvas — zoom in first'); + return; + } + const [refR, refG, refB] = ref; + const tol = this.tolerance; + + const W = this.imageWidth, H = this.imageHeight; + const nPx = W * H; + const visited = nPx <= 4 * 1024 * 1024 + ? new Uint8Array(nPx) + : new Set(); + const see = nPx <= 4 * 1024 * 1024 + ? (x, y) => visited[y * W + x] + : (x, y) => visited.has(y * W + x); + const mark = nPx <= 4 * 1024 * 1024 + ? (x, y) => { visited[y * W + x] = 1; } + : (x, y) => visited.add(y * W + x); + + const queue = [startX, startY]; + let head = 0; + mark(startX, startY); + const MAX_FILL = 2_000_000; + let filled = 0; + + while (head < queue.length && filled < MAX_FILL) { + const cx = queue[head++], cy = queue[head++]; + + if (skipLabeled && this._getPixel(cx, cy) !== 0) continue; + + const col = sample(cx, cy); + if (!col) continue; + // Per-channel max-distance tolerance (matches classic magic-wand feel) + if (Math.abs(col[0] - refR) > tol || + Math.abs(col[1] - refG) > tol || + Math.abs(col[2] - refB) > tol) continue; + + this._setPixel(cx, cy, eraseMode ? 0 : this.activeClass); + filled++; + + if (cx > 0 && !see(cx-1, cy)) { mark(cx-1, cy); queue.push(cx-1, cy); } + if (cx < W-1 && !see(cx+1, cy)) { mark(cx+1, cy); queue.push(cx+1, cy); } + if (cy > 0 && !see(cx, cy-1)) { mark(cx, cy-1); queue.push(cx, cy-1); } + if (cy < H-1 && !see(cx, cy+1)) { mark(cx, cy+1); queue.push(cx, cy+1); } + } + } + + // ── Upload / slice copy ─────────────────────────────────────────── + + async copyToAdjacentFrame(delta) { + const targetFrame = this.frame + delta; + if (targetFrame < 0) return; + const csrf = getCsrf(); + const uploads = []; + this.tileCache.forEach((labels, k) => { + if (!labels) return; + const [tx, ty] = k.split('_').map(Number); + uploads.push( + this._encodePNG(labels).then(blob => + fetch(`/annotations/api/segmentation/${this.annotationId}/tiles/${this.plane}/${tx}/${ty}/?frame=${targetFrame}&ph=${this.imageHeight}&nf=${this.nFrames}`, { + method: 'PUT', credentials: 'same-origin', + headers: {'Content-Type':'image/png','X-CSRFToken':csrf}, + body: blob, + }) + ) + ); + }); + await Promise.all(uploads); + } + + async _uploadDirtyTiles() { + const keys = Array.from(this.dirtyTiles); + this.dirtyTiles.clear(); + const csrf = getCsrf(); + await Promise.all(keys.map(async k => { + const [tx, ty] = k.split('_').map(Number); + const labels = this.tileCache.get(k); + if (!labels) return; + const blob = await this._encodePNG(labels); + const url = `/annotations/api/segmentation/${this.annotationId}/tiles/${this.plane}/${tx}/${ty}/?frame=${this.frame}&ph=${this.imageHeight}&nf=${this.nFrames}`; + await fetch(url, { + method: 'PUT', credentials: 'same-origin', + headers: {'Content-Type':'image/png','X-CSRFToken':csrf}, + body: blob, + }).catch(() => this.dirtyTiles.add(k)); + })); + } + + async _encodePNG(labels) { + const oc = new OffscreenCanvas(TILE_SIZE, TILE_SIZE); + const ctx = oc.getContext('2d'); + const id = ctx.createImageData(TILE_SIZE, TILE_SIZE); + for (let i = 0; i < labels.length; i++) { + // Use 0/255 extremes (not 0/1) — immune to gamma correction and + // PIL's RGBA→L alpha-composite-on-white behaviour. + // Always fully opaque so alpha compositing cannot corrupt values. + const pv = labels[i] > 0 ? 255 : 0; + id.data[i*4] = pv; + id.data[i*4+1] = pv; + id.data[i*4+2] = pv; + id.data[i*4+3] = 255; + } + ctx.putImageData(id, 0, 0); + return oc.convertToBlob({type:'image/png'}); + } + + // ── Public interface ────────────────────────────────────────────── + + setTool(tool) { + this.activeTool = tool; + this.drawCanvas.style.display = (tool && this.editable) ? 'block' : 'none'; + this._applyToolCursor(); + } + + setEditable(editable) { + this.editable = editable; + if (!editable) { + this.drawCanvas.style.display = 'none'; + this.isDrawing = false; + } else if (this.activeTool) { + this.drawCanvas.style.display = 'block'; + } + } + + setBrushSize(px) { this.brushSize = px; } + setBrushShape(sh) { this.brushShape = sh; } + setTolerance(t) { this.tolerance = t; } + setActiveClass(c) { this.activeClass = c; } + setOpacity(o) { this.opacity = o; this.displayCanvas.style.opacity = o; } + setColor(hex) { + this.color = hexToRgb(hex); + this.renderCache.clear(); + // Re-render all tiles with new color, then repaint display canvas. + const rerender = () => { + this.tileCache.forEach((labels, k) => { + if (!labels) return; + const [tx, ty] = k.split('_').map(Number); + this._prerenderTile(tx, ty); + }); + }; + rerender(); + } + setVisible(v) { this.displayCanvas.style.display = v ? 'block' : 'none'; } + + setFrame(frame) { + this.frame = frame; + this.tileCache.clear(); this.renderCache.clear(); + this.dirtyTiles.clear(); this.imgTileCache.clear(); + this._dziMaxLevel = null; + // Clear display canvas; tiles will refill as they load via _ensureTile. + const dCtx = this.displayCanvas.getContext('2d'); + dCtx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height); + this._redraw(); // update CSS transform for new viewport position + } + + destroy() { + this.viewer.removeHandler('animation', this._onUpdate); + this.viewer.removeHandler('animation-finish', this._onUpdate); + this.viewer.removeHandler('resize', this._onResize); + this.viewer.removeHandler('page', this._onPage); + if (this._onModifierDown) document.removeEventListener('keydown', this._onModifierDown); + if (this._onModifierUp) document.removeEventListener('keyup', this._onModifierUp); + this.displayCanvas.remove(); + this.drawCanvas.remove(); + } + } + + // ── SegmentationUI ──────────────────────────────────────────────────── + + const segmentationUI = { + _activeTypeId: null, + + init() { + const panel = document.getElementById('seg-palette'); + if (!panel) return; + + // Tool buttons + document.querySelectorAll('.seg-tool-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tool = btn.dataset.tool; + // 'pan' = deactivate draw canvas, let OSD handle events + if (tool === 'pan') { + this._setActiveToolBtn(btn); + const layer = window.segmentationLayers.get(this._activeTypeId); + if (layer) layer.setTool(null); + } else { + this._setActiveToolBtn(btn); + const layer = window.segmentationLayers.get(this._activeTypeId); + if (layer) layer.setTool(tool); + } + // Show/hide tolerance row + const tolRow = document.getElementById('seg-tolerance-row'); + if (tolRow) tolRow.style.display = + (tool==='wand'||tool==='fill') ? 'block' : 'none'; + }); + }); + + // Brush size + const bs = document.getElementById('seg-brush-size'); + if (bs) bs.addEventListener('input', () => { + document.getElementById('seg-brush-size-val').textContent = bs.value; + window.segmentationLayers.forEach(l => l.setBrushSize(parseInt(bs.value))); + }); + + // Brush shape + document.querySelectorAll('.seg-shape-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.seg-shape-btn').forEach(b => { + b.classList.remove('btn-light'); b.classList.add('btn-outline-light'); + }); + btn.classList.remove('btn-outline-light'); btn.classList.add('btn-light'); + window.segmentationLayers.forEach(l => l.setBrushShape(btn.dataset.shape)); + }); + }); + + // Tolerance + const tol = document.getElementById('seg-tolerance'); + if (tol) tol.addEventListener('input', () => { + document.getElementById('seg-tolerance-val').textContent = tol.value; + window.segmentationLayers.forEach(l => l.setTolerance(parseInt(tol.value))); + }); + + // Opacity + const op = document.getElementById('seg-opacity'); + if (op) op.addEventListener('input', () => { + document.getElementById('seg-opacity-val').textContent = op.value + '%'; + window.segmentationLayers.forEach(l => l.setOpacity(parseFloat(op.value)/100)); + }); + }, + + _setActiveToolBtn(activeBtn) { + document.querySelectorAll('.seg-tool-btn').forEach(b => { + b.classList.remove('btn-light'); b.classList.add('btn-outline-light'); + }); + activeBtn.classList.remove('btn-outline-light'); + activeBtn.classList.add('btn-light'); + }, + + activateType(typeId) { + this._activeTypeId = typeId; + // Make all layers view-only, then make the selected one editable + window.segmentationLayers.forEach((layer, tid) => { + layer.setEditable(tid === typeId); + }); + // Default to pan so navigation doesn't cause accidental strokes. + // Only switch to pan if no drawing tool is currently active. + const activeBtn = document.querySelector('.seg-tool-btn.btn-light'); + if (!activeBtn || activeBtn.dataset.tool === 'pencil') { + const panBtn = document.querySelector('.seg-tool-btn[data-tool="pan"]'); + if (panBtn) panBtn.dispatchEvent(new MouseEvent('click', {bubbles:true})); + } + }, + + deactivateAll() { + this._activeTypeId = null; + window.segmentationLayers.forEach(l => l.setEditable(false)); + }, + }; + + // ── Global management ───────────────────────────────────────────────── + + window.segmentationLayers = new Map(); + window.segmentationUI = segmentationUI; + let _activeViewer = null; // tracks which OSD viewer the layers belong to + + // When a new image is loaded a new OSD viewer is created and exactViewerReady fires. + // Destroy all layers from the previous image so stale canvases don't accumulate. + // On an image switch (viewer changed) also re-activate the selected annotation type + // once OSD has opened the new tile source, because the template's one-shot onReady + // listener has already removed itself after the initial page load. + window.addEventListener('exactViewerReady', () => { + const viewer = window.exactOSDViewer; + if (_activeViewer && _activeViewer !== viewer) { + // Reset to pan tool before tearing down so the button state is clean + // for the new image regardless of what tool was active before. + const panBtn = document.querySelector('.seg-tool-btn[data-tool="pan"]'); + if (panBtn) panBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + window.destroyAllSegmentationLayers(); + const activeRow = document.querySelector('#statistics_table .stats-row.table-active'); + if (activeRow && typeof window.selectAnnotationType === 'function' && viewer) { + viewer.addOnceHandler('open', () => window.selectAnnotationType(activeRow)); + } + } + _activeViewer = viewer; + }); + + // When the MPR plane switches the image dimensions and tile space change. + // Destroy layers for the old plane; the template re-activates the current type. + window.addEventListener('exactPlaneChanged', () => { + window.destroyAllSegmentationLayers(); + // Re-activate whatever annotation type row is currently selected. + const activeRow = document.querySelector('#statistics_table .stats-row.table-active'); + if (activeRow && typeof window.selectAnnotationType === 'function') { + // Give OSD a moment to open the new tile source before reading dimensions. + const viewer = window.exactOSDViewer; + if (viewer) { + viewer.addOnceHandler('open', () => window.selectAnnotationType(activeRow)); + } + } + }); + + window.activateSegmentationLayer = async function ( + annotationTypeId, color, imageId, imageWidth, imageHeight, frame + ) { + // Show palette + const panel = document.getElementById('seg-palette'); + if (panel) panel.style.display = 'flex'; + + // Get OSD viewer (set on window by EXACTViewer constructor) + const viewer = window.exactOSDViewer; + if (!viewer) { console.error('OSD viewer not available'); return; } + + // Guard: if the viewer changed since layers were created, start fresh. + if (_activeViewer && _activeViewer !== viewer) { + window.destroyAllSegmentationLayers(); + } + _activeViewer = viewer; + + // Current plane (0=axial, 1=coronal, 2=sagittal). + const plane = window.exactCurrentPlane || 0; + // nFrames for this plane (voxel count along the normal axis). + // Passed to the server as &nf= so it can correctly map voxel↔pixel for + // anisotropic NIfTI data where img.height ≠ ny_vox. + const nFrames = window.exactCurrentPlaneNFrames || 0; + + // Get live image dimensions from OSD tile source — correct for the current + // plane even if the template's IMAGE_WIDTH/HEIGHT are axial-only. + const item = viewer.world.getItemAt(0); + const actualWidth = item ? Math.round(item.source.dimensions.x) : imageWidth; + const actualHeight = item ? Math.round(item.source.dimensions.y) : imageHeight; + + // Layers are keyed by annotationTypeId only — one layer per type at a time. + // Plane switches call destroyAllSegmentationLayers first, so there is never + // a stale layer from another plane in the map. + let layer = window.segmentationLayers.get(annotationTypeId); + + if (!layer) { + // Find or create annotation on server (one per annotation_type per image). + let annotationId = null; + try { + const r = await fetch( + `/api/v1/annotations/annotations/?image=${imageId}&annotation_type=${annotationTypeId}&deleted=False&format=json`, + { credentials: 'same-origin' }); + const data = await r.json(); + if (data.results?.length > 0) { + annotationId = data.results[0].id; + } else { + const cr = await fetch('/api/v1/annotations/annotations/', { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type':'application/json','X-CSRFToken':getCsrf()}, + body: JSON.stringify({ + image: imageId, annotation_type: annotationTypeId, + unique_identifier: crypto.randomUUID(), + vector: {tile_size: TILE_SIZE, width: actualWidth, height: actualHeight}, + }), + }); + const crData = await cr.json(); + if (!cr.ok) { console.error('Failed to create annotation:', crData); return; } + annotationId = crData.id; + } + } catch (e) { console.error('Could not get/create annotation', e); return; } + + // Re-read current frame after async fetch — user may have navigated. + const actualFrame = typeof viewer.currentPage === 'function' + ? viewer.currentPage() : frame; + layer = new SegmentationLayer( + viewer, annotationId, actualWidth, actualHeight, color, actualFrame, plane, nFrames); + window.segmentationLayers.set(annotationTypeId, layer); + } else { + // For existing layers the 'page' event keeps frame in sync; only + // force a reset if the viewer frame genuinely differs. + const currentFrame = typeof viewer.currentPage === 'function' + ? viewer.currentPage() : frame; + if (layer.frame !== currentFrame) layer.setFrame(currentFrame); + } + + segmentationUI.activateType(annotationTypeId); + }; + + window.deactivateSegmentationLayer = function () { + segmentationUI.deactivateAll(); + const panel = document.getElementById('seg-palette'); + if (panel) panel.style.display = 'none'; + }; + + window.destroyAllSegmentationLayers = function () { + window.segmentationLayers.forEach(l => l.destroy()); + window.segmentationLayers.clear(); + segmentationUI.deactivateAll(); + }; + + document.addEventListener('DOMContentLoaded', () => segmentationUI.init()); + +})(); diff --git a/exact/exact/annotations/templates/annotations/annotate_v2.html b/exact/exact/annotations/templates/annotations/annotate_v2.html index 0867a2b5..4c2d112b 100644 --- a/exact/exact/annotations/templates/annotations/annotate_v2.html +++ b/exact/exact/annotations/templates/annotations/annotate_v2.html @@ -47,6 +47,7 @@ + {% endblock additional_annotation_js %} {% block bodyblock %} @@ -236,43 +237,41 @@
{{ selected_image.name }}
{% if annotation_types %}
- +
+ + - - - - + + + - + + {% for annotation_type in annotation_types %} - + + - - - + + - {% endfor %} +
LabelKey - - ColorExampleKeyCountVis
+ style="font-size:0.85rem"> {{ annotation_type.name }} + {% if annotation_type.vector_type == 8 %} + seg + {% endif %} {{ forloop.counter }} - {{ annotation_type.node_count }} + {{ forloop.counter }} - - - - + data-annotation_type_id="{{ annotation_type.id }}" checked>
{% endif %} @@ -882,6 +881,80 @@
{{ selected_image.name }}
+ + + + + {% endblock %} \ No newline at end of file diff --git a/exact/exact/annotations/urls.py b/exact/exact/annotations/urls.py index 1d316e6c..b9c84b68 100644 --- a/exact/exact/annotations/urls.py +++ b/exact/exact/annotations/urls.py @@ -34,4 +34,7 @@ re_path(r'^api/annotation/mediafile/upload/(\d+)/(\d+)/$', views.api_create_annotation_mediafile, name='api_create_annotation_mediafile'), re_path(r'^api/annotation/mediafile/delete/$', views.api_delete_annotation_mediafile, name='api_delete_annotation_mediafile'), re_path(r'^api/annotation/mediafile/update/$', views.api_update_annotation_mediafile, name='api_update_annotation_mediafile'), + + re_path(r'^api/segmentation/(?P\d+)/tiles/(?P\d+)/(?P\d+)/(?P\d+)/$', + views.segmentation_tile, name='segmentation_tile'), ] diff --git a/exact/exact/annotations/views.py b/exact/exact/annotations/views.py index c8f07409..9e4bab47 100644 --- a/exact/exact/annotations/views.py +++ b/exact/exact/annotations/views.py @@ -1,4 +1,5 @@ import datetime +import io import time import pytz from timeit import default_timer as timer @@ -24,7 +25,7 @@ from exact.administration.models import Product from exact.annotations.forms import ExportFormatCreationForm, ExportFormatEditForm, AnnotationMediafileForm from exact.annotations.models import Annotation, AnnotationType, Export, \ - Verification, ExportFormat, LogImageAction, AnnotationMediaFile + Verification, ExportFormat, LogImageAction, AnnotationMediaFile, SegmentationTile from exact.annotations.serializers import AnnotationSerializer, AnnotationTypeSerializer, \ AnnotationSerializerFast, serialize_annotation, AnnotationMediaFileSerializer from exact.images.models import Image, ImageSet @@ -888,6 +889,7 @@ def load_set_annotations(request) -> Response: def load_annotation_types(request) -> Response: annotation_types = None + imageset = None if 'imageset_id' in request.query_params: imageset_id = int(request.query_params['imageset_id']) imageset = get_object_or_404(ImageSet, pk=imageset_id) @@ -897,7 +899,7 @@ def load_annotation_types(request) -> Response: else: annotation_types = AnnotationType.objects.filter(active=True).order_by('sort_order') - if not imageset.has_perm('read', request.user): + if imageset is not None and not imageset.has_perm('read', request.user): return Response({ 'detail': 'permission for reading this image set missing.', }, status=HTTP_403_FORBIDDEN) @@ -1154,3 +1156,346 @@ def api_blurred_concealed_annotation(request) -> Response: return Response({ 'detail': 'you updated the last annotation', }, status=HTTP_200_OK) + + +# --------------------------------------------------------------------------- +# Segmentation tile endpoints +# --------------------------------------------------------------------------- + +_SEG_TILE_SIZE = 256 + + +def _get_segmentation_annotation(annotation_id, user, require_edit=False): + """Return annotation if it exists, is a segmentation type, and user has access.""" + annotation = get_object_or_404(Annotation, pk=annotation_id) + if annotation.annotation_type.vector_type != AnnotationType.VECTOR_TYPE.SEGMENTATION: + return None, HttpResponse('Annotation is not a segmentation type.', status=400) + perm = 'edit_annotation' if require_edit else 'read' + if not annotation.image.image_set.has_perm(perm, user): + return None, HttpResponseForbidden() + return annotation, None + + +def _seg_bytes_to_array(png_bytes): + """Decode a stored PNG tile to a T×T uint8 binary array (0/1).""" + from PIL import Image as PILImage + import numpy as np + T = _SEG_TILE_SIZE + img = PILImage.open(io.BytesIO(bytes(png_bytes))).convert('L') + return (np.array(img, dtype=np.uint8) > 127).astype(np.uint8) + + +def _array_to_png(arr): + """Encode a T×T uint8 binary array to PNG bytes.""" + from PIL import Image as PILImage + import numpy as np + mask = (arr > 0).astype(np.uint8) * 255 + buf = io.BytesIO() + PILImage.fromarray(mask, mode='L').save(buf, format='PNG') + return buf.getvalue() + + +def _get_axial_tile(annotation_id, tx, ty, z): + """Return T×T uint8 array for axial tile, or zeros if it does not exist.""" + import numpy as np + T = _SEG_TILE_SIZE + try: + t = SegmentationTile.objects.get( + annotation_id=annotation_id, level=0, tile_x=tx, tile_y=ty, frame=z) + return _seg_bytes_to_array(t.data) + except SegmentationTile.DoesNotExist: + return np.zeros((T, T), dtype=np.uint8) + + +def _save_axial_tile(annotation_id, tx, ty, z, arr): + """Persist a T×T uint8 array as an axial tile; delete if all-zero.""" + import numpy as np + if not arr.any(): + SegmentationTile.objects.filter( + annotation_id=annotation_id, level=0, tile_x=tx, tile_y=ty, frame=z).delete() + return + SegmentationTile.objects.update_or_create( + annotation_id=annotation_id, level=0, tile_x=tx, tile_y=ty, frame=z, + defaults={'data': _array_to_png(arr)}) + + +def _z_vox_for_row(row_abs, nz, ph): + """Map a physical pixel row in a non-axial plane to a z-voxel index. + + NIfTI renders with Superior at the top of the image, which means the + first row (row=0) corresponds to the largest z index (z=nz-1). The + aspect-ratio-corrected physical height ph may differ from nz when + through-plane spacing != in-plane spacing. + """ + import numpy as np + if ph <= 1: + return int(np.clip(nz - 1 - row_abs, 0, nz - 1)) + return int(np.clip(nz - 1 - round(row_abs * (nz - 1) / (ph - 1)), 0, nz - 1)) + + +def _y_vox_to_axial_row(y_vox, ny_vox, py): + """Map an integer y-voxel index to the nearest axial image pixel row. + + For isotropic in-plane data (py == ny_vox) this is simply py-1-y_vox. + For anisotropic data (e.g. a thick-slice MRI where sy >> sx) the axial + image is upscaled in the y-direction: py > ny_vox, and the correct pixel + row must account for the scaling factor sy/ref. + """ + if ny_vox <= 1: + return 0 + return round((ny_vox - 1 - y_vox) * (py - 1) / (ny_vox - 1)) + + +def _build_coronal_tile(annotation_id, tx, ty, y_frame, ny, nz, ph, ny_vox=None): + """Derive a coronal tile from stored axial tiles. + + Coordinate mapping (NIfTI radiological convention, isotropic in-plane): + coronal col c → axial col c (same x-direction, both flipped the same way) + coronal row r → z-voxel: nz-1-round(r*(nz-1)/(ph-1)) + y_frame → NIfTI y-voxel: y_frame+1 (OSD tile sources are 1-indexed) + + ny = img.height = axial pixel height (may differ from ny_vox for anisotropic data) + ny_vox = actual number of y-voxels (= coronal nFrames); defaults to ny for + isotropic data where py == ny_vox. + """ + import numpy as np + T = _SEG_TILE_SIZE + py = ny # axial pixel height + if ny_vox is None or ny_vox <= 0: + ny_vox = py # isotropic fallback: treat pixel height as voxel count + + # OSD tile sources use frame = page+1, so the displayed y-voxel is y_frame+1. + y_vox = max(0, min(y_frame + 1, ny_vox - 1)) + # Map y_vox → axial image pixel row, accounting for possible y-axis upscaling. + pixel_row = _y_vox_to_axial_row(y_vox, ny_vox, py) + ax_ty = pixel_row // T + ax_i = pixel_row % T + + # One axial z per coronal row; batch-load unique z values. + row_abs = ty * T + np.arange(T) + z_vox_arr = np.array([_z_vox_for_row(int(r), nz, ph) for r in row_abs]) + unique_z = np.unique(z_vox_arr) + + tiles = SegmentationTile.objects.filter( + annotation_id=annotation_id, level=0, + tile_x=tx, tile_y=ax_ty, frame__in=unique_z.tolist()) + tile_map = {t.frame: _seg_bytes_to_array(t.data) for t in tiles} + + result = np.zeros((T, T), dtype=np.uint8) + for r in range(T): + z = int(z_vox_arr[r]) + if z in tile_map: + result[r, :] = tile_map[z][ax_i, :] + return result + + +def _write_coronal_tile(annotation_id, tx, ty, y_frame, ny, nz, ph, incoming, ny_vox=None): + """Write a coronal tile's label data back into the canonical axial tiles.""" + import numpy as np + T = _SEG_TILE_SIZE + py = ny # axial pixel height + if ny_vox is None or ny_vox <= 0: + ny_vox = py + y_vox = max(0, min(y_frame + 1, ny_vox - 1)) + pixel_row = _y_vox_to_axial_row(y_vox, ny_vox, py) + ax_ty = pixel_row // T + ax_i = pixel_row % T + + row_abs = ty * T + np.arange(T) + z_vox_arr = np.array([_z_vox_for_row(int(r), nz, ph) for r in row_abs]) + unique_z = np.unique(z_vox_arr) + + tiles = SegmentationTile.objects.filter( + annotation_id=annotation_id, level=0, + tile_x=tx, tile_y=ax_ty, frame__in=unique_z.tolist()) + tile_map = {t.frame: _seg_bytes_to_array(t.data) for t in tiles} + + # Accumulate incoming rows per z (multiple coronal rows can share one z). + z_data = {} + for r in range(T): + z = int(z_vox_arr[r]) + row = incoming[r, :] + z_data[z] = np.maximum(z_data[z], row) if z in z_data else row.copy() + + for z, row_data in z_data.items(): + arr = tile_map.get(z, np.zeros((T, T), dtype=np.uint8)).copy() + arr[ax_i, :] = row_data + _save_axial_tile(annotation_id, tx, ax_ty, z, arr) + + +def _build_sagittal_tile(annotation_id, tx, ty, x_frame, nx, ny, nz, ph): + """Derive a sagittal tile from stored axial tiles. + + Coordinate mapping (NIfTI radiological convention, isotropic in-plane): + sagittal col c → y-voxel: ny-1-c → axial row c (in tile: c%T, ax_ty=c//T) + sagittal row r → z-voxel: nz-1-round(r*(nz-1)/(ph-1)) + x_frame → NIfTI x-voxel: x_frame+1 (OSD tile sources are 1-indexed) + """ + import numpy as np + T = _SEG_TILE_SIZE + x_vox = max(0, min(x_frame + 1, nx - 1)) + ax_tx = (nx - 1 - x_vox) // T + ax_j = (nx - 1 - x_vox) % T + # sagittal col c → ax_row=c → ax_ty=tx for c in [tx*T, (tx+1)*T) + ax_ty = tx + + row_abs = ty * T + np.arange(T) + z_vox_arr = np.array([_z_vox_for_row(int(r), nz, ph) for r in row_abs]) + unique_z = np.unique(z_vox_arr) + + tiles = SegmentationTile.objects.filter( + annotation_id=annotation_id, level=0, + tile_x=ax_tx, tile_y=ax_ty, frame__in=unique_z.tolist()) + tile_map = {t.frame: _seg_bytes_to_array(t.data) for t in tiles} + + result = np.zeros((T, T), dtype=np.uint8) + for r in range(T): + z = int(z_vox_arr[r]) + if z in tile_map: + result[r, :] = tile_map[z][:, ax_j] + return result + + +def _write_sagittal_tile(annotation_id, tx, ty, x_frame, nx, ny, nz, ph, incoming): + """Write a sagittal tile's label data back into the canonical axial tiles.""" + import numpy as np + T = _SEG_TILE_SIZE + x_vox = max(0, min(x_frame + 1, nx - 1)) + ax_tx = (nx - 1 - x_vox) // T + ax_j = (nx - 1 - x_vox) % T + ax_ty = tx + + row_abs = ty * T + np.arange(T) + z_vox_arr = np.array([_z_vox_for_row(int(r), nz, ph) for r in row_abs]) + unique_z = np.unique(z_vox_arr) + + tiles = SegmentationTile.objects.filter( + annotation_id=annotation_id, level=0, + tile_x=ax_tx, tile_y=ax_ty, frame__in=unique_z.tolist()) + tile_map = {t.frame: _seg_bytes_to_array(t.data) for t in tiles} + + z_data = {} + for r in range(T): + z = int(z_vox_arr[r]) + col = incoming[r, :] # 256 y-values for this row/z + z_data[z] = np.maximum(z_data[z], col) if z in z_data else col.copy() + + for z, col_data in z_data.items(): + arr = tile_map.get(z, np.zeros((T, T), dtype=np.uint8)).copy() + arr[:, ax_j] = col_data + _save_axial_tile(annotation_id, ax_tx, ax_ty, z, arr) + + +@login_required +def segmentation_tile(request, annotation_id, level, tile_x, tile_y): + """GET → return tile PNG (204 if tile is empty) + PUT → store tile; non-axial planes write through to axial storage + DELETE → remove axial tile (no-op for derived planes) + + `level` is the MPR plane index: 0=axial, 1=coronal, 2=sagittal. + All labels are stored exclusively as axial (level=0) tiles; coronal and + sagittal tiles are derived on-the-fly via coordinate remapping. + + For non-axial planes the client must pass `ph` (physical pixel height of + the rendered plane image) so that the z-axis scaling can be applied. + """ + import numpy as np + from PIL import Image as PILImage + + frame = int(request.GET.get('frame', 0)) + plane = int(level) + tile_x = int(tile_x) + tile_y = int(tile_y) + + if request.method == 'GET': + annotation, err = _get_segmentation_annotation(annotation_id, request.user) + if err: + return err + + if plane == 0: + # Axial: direct lookup. + try: + tile = SegmentationTile.objects.get( + annotation_id=annotation_id, + level=0, tile_x=tile_x, tile_y=tile_y, frame=frame) + except SegmentationTile.DoesNotExist: + return HttpResponse(status=204) + return HttpResponse(bytes(tile.data), content_type='image/png') + + # Coronal / sagittal: derive from axial tiles. + img = annotation.image + nx, ny, nz = img.width, img.height, img.frames + # ph = physical pixel height of the rendered plane; defaults to nz + # (correct for isotropic data; client always sends the real value via &ph=). + ph = int(request.GET.get('ph', nz)) + # nf = number of slices along the normal axis of this plane (voxel count). + # For coronal: ny_vox (may differ from ny=img.height when sy != sx). + # Client sends &nf= from exactCurrentPlaneNFrames; 0 means not provided. + nf = int(request.GET.get('nf', 0)) + + if plane == 1: + arr = _build_coronal_tile(annotation_id, tile_x, tile_y, frame, ny, nz, ph, + ny_vox=nf if nf > 0 else None) + else: + arr = _build_sagittal_tile(annotation_id, tile_x, tile_y, frame, nx, ny, nz, ph) + + if not arr.any(): + return HttpResponse(status=204) + return HttpResponse(_array_to_png(arr), content_type='image/png') + + elif request.method == 'PUT': + annotation, err = _get_segmentation_annotation( + annotation_id, request.user, require_edit=True) + if err: + return err + + incoming_bytes = request.body + if not incoming_bytes: + return HttpResponse('Empty body.', status=400) + + try: + incoming_img = PILImage.open(io.BytesIO(incoming_bytes)).convert('RGB') + except Exception: + return HttpResponse('Could not decode PNG.', status=400) + + # Threshold red channel at 127 → binary 0/1 array. + incoming = (np.array(incoming_img, dtype=np.uint8)[:, :, 0] > 127).astype(np.uint8) + + if plane == 0: + # Axial: store directly. + mask = incoming * 255 + buf = io.BytesIO() + PILImage.fromarray(mask, mode='L').save(buf, format='PNG') + SegmentationTile.objects.update_or_create( + annotation_id=annotation_id, + level=0, tile_x=tile_x, tile_y=tile_y, frame=frame, + defaults={'data': buf.getvalue()}) + return HttpResponse(status=204) + + # Coronal / sagittal: write through to axial tiles. + img = annotation.image + nx, ny, nz = img.width, img.height, img.frames + ph = int(request.GET.get('ph', nz)) + nf = int(request.GET.get('nf', 0)) + + if plane == 1: + _write_coronal_tile(annotation_id, tile_x, tile_y, frame, ny, nz, ph, incoming, + ny_vox=nf if nf > 0 else None) + else: + _write_sagittal_tile(annotation_id, tile_x, tile_y, frame, nx, ny, nz, ph, incoming) + return HttpResponse(status=204) + + elif request.method == 'DELETE': + annotation, err = _get_segmentation_annotation( + annotation_id, request.user, require_edit=True) + if err: + return err + + if plane == 0: + SegmentationTile.objects.filter( + annotation_id=annotation_id, + level=0, tile_x=tile_x, tile_y=tile_y, frame=frame).delete() + # Non-axial DELETE is a no-op: the data lives in axial tiles. + return HttpResponse(status=204) + + return HttpResponse(status=405)