diff --git a/loopy/image.py b/loopy/image.py index 7a1b6fd..0f0987a 100644 --- a/loopy/image.py +++ b/loopy/image.py @@ -27,6 +27,8 @@ class ImageParams(ReadonlyModel): channels: list[str] | Literal["rgb"] defaultChannels: dict[Colors, str] | None = None mPerPx: float + dtype: Literal['uint8', 'uint16'] | None = None + maxVal: int | None = None def write(self, f: Callable[[Self], None]) -> Self: f(self) @@ -65,6 +67,7 @@ def from_img( scale: float, translate: tuple[float, float] = (0, 0), rgb: bool = False, + convert_to_8bit: bool = False, ) -> Self: if rgb: height, width, chans = img.shape @@ -87,11 +90,12 @@ def from_img( if img.dtype == np.uint8: ... elif img.dtype == np.uint16: - log("Converting uint16 to uint8.", type_="WARNING") - dived = np.divide(img, 256, casting="unsafe") # So that this remains an uint16. - del img - img = dived.astype(np.uint8) - del dived + if convert_to_8bit: + log("Converting uint16 to uint8.", type_="WARNING") + dived = np.divide(img, 256, casting="unsafe") # So that this remains an uint16. + del img + img = dived.astype(np.uint8) + del dived else: raise ValueError(f"Unsupported dtype for TIFF file. Found {img.dtype}. Expected uint8 or uint16.") @@ -107,7 +111,7 @@ def from_img( ) def transform_tiff( - self, path_in: Path, quality: int = 90, logger: Callback = log, save_uncompressed: bool = False + self, path_in: Path, *, quality: int = 90, logger: Callback = log, save_uncompressed: bool = False ) -> tuple[list[str], Callable[[], None]]: logger(f"Transforming {path_in} to COG.") if path_in.suffix != ".tif": @@ -117,8 +121,11 @@ def transform_tiff( names = [path_in.stem + name + ".tif" for name in names] def run(): + if not names and not chanlist: + return + for name, c in zip(names, chanlist): - self._write_uncompressed_geotiff( + is_16bit = self._write_uncompressed_geotiff( path=path_in.with_name(name), channels=c, transform=rasterio.Affine( @@ -126,7 +133,13 @@ def run(): ), ) - self._compress([path_in.with_name(name) for name in names], quality=quality, logger=logger) + self._compress( + [path_in.with_name(name) for name in names], + is_16bit=is_16bit, # type: ignore + quality=quality, + logger=logger, + save_uncompressed=save_uncompressed + ) return names, run @@ -165,6 +178,10 @@ def _write_uncompressed_geotiff( dst: DatasetWriter # Not compressing here since we cannot control the compression level. assert 0 < len(channels) <= 3 + dtype = self._get_slide(0).dtype + if dtype != np.uint8 and dtype != np.uint16: + raise ValueError(f"Unsupported dtype {dtype}. Expected uint8 or uint16.") + with rasterio.open( path.with_suffix(".tif_").as_posix(), "w", @@ -174,7 +191,7 @@ def _write_uncompressed_geotiff( count=len(channels), photometric="RGB" if self.rgb else "MINISBLACK", transform=transform, # https://gdal.org/tutorials/geotransforms_tut.html # Flip y-axis. - dtype=np.uint8, + dtype=dtype, crs="EPSG:32648", # meters tiled=True, ) as dst: # type: ignore @@ -183,9 +200,10 @@ def _write_uncompressed_geotiff( dst.write(self._get_slide(idx_in), idx_out) dst.build_overviews([4, 8, 16, 32, 64], Resampling.nearest) assert path.with_suffix(".tif_").exists() + return dtype == np.uint16 def _compress( - self, ps: list[Path], quality: int = 90, logger: Callback = log, save_uncompressed: bool = False + self, ps: list[Path], *, is_16bit: bool = False, quality: int = 90, logger: Callback = log, save_uncompressed: bool = False ) -> None: def run(p: Path): logger("Writing COG", p.with_suffix(".tif").as_posix()) @@ -194,6 +212,10 @@ def run(p: Path): if getattr(sys, "frozen", False) and sys.platform != "win32" else "gdal_translate" ) + if is_16bit and quality != 100: + logger("Quality is set to 100 (lossless) for 16-bit images.", type_="WARNING") + + compression = ["-co", "COMPRESS=LERC_DEFLATE"] if is_16bit else ["-co", "COMPRESS=JPEG", "-co", f"JPEG_QUALITY={int(quality)}"] # https://stackoverflow.com/questions/4417546/constantly-print-subprocess-output-while-process-is-running/4417735 with subprocess.Popen( [ @@ -202,12 +224,7 @@ def run(p: Path): p.with_suffix(".tif").as_posix(), "-co", "TILED=YES", - "-co", - "COMPRESS=JPEG", - "-co", - "COPY_SRC_OVERVIEWS=YES", - "-co", - f"JPEG_QUALITY={int(quality)}", + *compression, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/loopy/sample.py b/loopy/sample.py index d4d8d20..c86218d 100644 --- a/loopy/sample.py +++ b/loopy/sample.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any, Callable, Concatenate, Generic, Literal, ParamSpec, Protocol, TypeVar +import numpy as np import pandas as pd from pydantic import BaseModel from typing_extensions import Self @@ -175,6 +176,8 @@ def add_image( channels=channels, mPerPx=geotiff.scale, defaultChannels=defaultChannels, + dtype="uint8" if geotiff.img.dtype == np.uint8 else "uint16", + maxVal=geotiff.img.max() ) return self diff --git a/src/lib/data/objects/image.ts b/src/lib/data/objects/image.ts index 7103a24..255e1e4 100644 --- a/src/lib/data/objects/image.ts +++ b/src/lib/data/objects/image.ts @@ -7,6 +7,8 @@ export type ImageParams = { channels: string[] | 'rgb'; mPerPx: number; defaultChannels?: Record; + dtype?: 'uint8' | 'uint16'; + maxVal?: number; }; export class ImgData extends Deferrable { @@ -14,10 +16,15 @@ export class ImgData extends Deferrable { channels: string[] | 'rgb'; defaultChannels: Record; mPerPx: number; + maxVal: number; - constructor({ urls, channels, defaultChannels, mPerPx }: ImageParams, autoHydrate = false) { + constructor( + { urls, channels, defaultChannels, mPerPx, maxVal }: ImageParams, + autoHydrate = false + ) { super(); this.urls = urls; + this.maxVal = maxVal ?? 255; // WebGL limitation if (Array.isArray(channels) && !channels.every((c) => /^[a-z0-9_]+$/i.test(c))) { diff --git a/src/lib/ui/background/imgBackground.ts b/src/lib/ui/background/imgBackground.ts index 4ef6a50..4c60c97 100644 --- a/src/lib/ui/background/imgBackground.ts +++ b/src/lib/ui/background/imgBackground.ts @@ -51,7 +51,9 @@ export class Background extends Deferrable { this.source.bandCount = this.image.mode === 'rgb' ? 3 : image.channels.length; this.layer = new WebGLTileLayer({ - style: Array.isArray(image.channels) ? genCompStyle(image.channels) : genRGBStyle(), + style: Array.isArray(image.channels) + ? genCompStyle(image.channels, Math.round(image.maxVal / 2)) + : genRGBStyle(), source: this.source, zIndex: -1 }); diff --git a/src/lib/ui/background/imgColormap.ts b/src/lib/ui/background/imgColormap.ts index fee2d9f..68c19f8 100644 --- a/src/lib/ui/background/imgColormap.ts +++ b/src/lib/ui/background/imgColormap.ts @@ -21,11 +21,11 @@ export type CompCtrl = { type: 'composite'; variables: Record export type RGBCtrl = { type: 'rgb'; Exposure: number; Contrast: number; Saturation: number }; export type ImgCtrl = CompCtrl | RGBCtrl; -export function genCompStyle(bands: string[]): Style { +export function genCompStyle(bands: string[], max: number): Style { const vars: Record = {}; bands.forEach((b, i) => { vars[b] = i + 1; - vars[`${b}Max`] = 128; + vars[`${b}Max`] = max; vars[`${b}Min`] = 0; vars[`${b}redMask`] = 1; vars[`${b}greenMask`] = 1; diff --git a/src/lib/ui/background/imgControl.svelte b/src/lib/ui/background/imgControl.svelte index 68e5b58..223adfa 100644 --- a/src/lib/ui/background/imgControl.svelte +++ b/src/lib/ui/background/imgControl.svelte @@ -3,11 +3,11 @@ import { classes } from '$lib/utils'; import type { ImgData } from '$src/lib/data/objects/image'; import { - bgColors, - colors, - type BandInfo, - type CompCtrl, - type ImgCtrl + bgColors, + colors, + type BandInfo, + type CompCtrl, + type ImgCtrl } from '$src/lib/ui/background/imgColormap'; import { isEqual, zip } from 'lodash-es'; import { onMount } from 'svelte'; @@ -41,19 +41,20 @@ } } + const half = Math.round(image.maxVal / 2) for (const [chan, color] of zip(image.channels, colors)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - bandinfo[chan!] = { enabled: false, color: color!, minmax: [0, 128] }; + bandinfo[chan!] = { enabled: false, color: color!, minmax: [0, half] }; } if (Object.keys(image.defaultChannels).length > 0) { for (const [c, b] of Object.entries(image.defaultChannels)) { - if (b) bandinfo[b] = { enabled: true, color: c, minmax: [0, 128] }; + if (b) bandinfo[b] = { enabled: true, color: c, minmax: [0, half] }; } } else { - bandinfo[image.channels[0]] = { enabled: true, color: 'red', minmax: [0, 128] }; - bandinfo[image.channels[1]] = { enabled: true, color: 'green', minmax: [0, 128] }; - bandinfo[image.channels[2]] = { enabled: true, color: 'blue', minmax: [0, 128] }; + bandinfo[image.channels[0]] = { enabled: true, color: 'red', minmax: [0, half] }; + bandinfo[image.channels[1]] = { enabled: true, color: 'green', minmax: [0, half] }; + bandinfo[image.channels[2]] = { enabled: true, color: 'blue', minmax: [0, half] }; } imgCtrl = { @@ -142,7 +143,7 @@
handleClick(name, imgCtrl.variables[name].color)} diff --git a/src/lib/ui/webglcolor.ts b/src/lib/ui/webglcolor.ts index c2b77dc..c2c2123 100644 --- a/src/lib/ui/webglcolor.ts +++ b/src/lib/ui/webglcolor.ts @@ -1,11 +1,12 @@ export class WebGLColorFunc { static subtract(a: unknown[], b: unknown[]) { - return ['-', a, b] + return ['-', a, b]; } static normalize(band: string) { - return ['/', - this.clamp(this.subtract(['band', ['var', band]], ['var', `${band}Min`]), 0, 255), - this.clamp(this.subtract(['var', `${band}Max`], ['var', `${band}Min`]), 1, 255) + return [ + '/', + this.clamp(this.subtract(['band', ['var', band]], ['var', `${band}Min`]), 0, 65535), + this.clamp(this.subtract(['var', `${band}Max`], ['var', `${band}Min`]), 1, 65535) ]; }