diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt index 7d7414e42..2c2382f29 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageController.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageController.kt @@ -6,10 +6,11 @@ import io.javalin.http.bodyAsClass import nebulosa.api.connection.ConnectionService import nebulosa.api.core.Controller import nebulosa.api.core.location +import nebulosa.api.validators.enumOf import nebulosa.api.validators.exists import nebulosa.api.validators.notNull import nebulosa.api.validators.path -import nebulosa.api.validators.range +import nebulosa.image.format.ImageChannel import java.io.ByteArrayInputStream class ImageController( @@ -25,7 +26,7 @@ class ImageController( app.put("image/analyze", ::analyze) app.put("image/annotations", ::annotations) app.get("image/coordinate-interpolation", ::coordinateInterpolation) - app.get("image/histogram", ::histogram) + app.post("image/statistics", ::statistics) app.get("image/fov-cameras", ::fovCameras) app.get("image/fov-telescopes", ::fovTelescopes) } @@ -65,10 +66,12 @@ class ImageController( imageService.coordinateInterpolation(path)?.also(ctx::json) } - private fun histogram(ctx: Context) { + private fun statistics(ctx: Context) { val path = ctx.queryParam("path").notNull().path().exists() - val bitLength = ctx.queryParam("bitLength")?.toInt()?.range(8, 16) ?: 16 - ctx.json(imageService.histogram(path, bitLength)) + val transformation = ctx.bodyAsClass() + val channel = ctx.queryParam("channel")?.enumOf() ?: ImageChannel.GRAY + val camera = ctx.queryParam("camera")?.ifBlank { null }?.let(connectionService::camera) + ctx.json(imageService.statistics(path, transformation, channel, camera)) } private fun fovCameras(ctx: Context) { diff --git a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt index ddb1b2e93..399c98a3b 100644 --- a/api/src/main/kotlin/nebulosa/api/image/ImageService.kt +++ b/api/src/main/kotlin/nebulosa/api/image/ImageService.kt @@ -10,9 +10,9 @@ import nebulosa.api.framing.FramingService import nebulosa.api.image.ImageAnnotation.StarDSO import nebulosa.fits.* import nebulosa.image.Image -import nebulosa.image.algorithms.computation.Histogram import nebulosa.image.algorithms.computation.Statistics import nebulosa.image.algorithms.transformation.* +import nebulosa.image.format.ImageChannel import nebulosa.image.format.ImageHdu import nebulosa.image.format.ImageModifier import nebulosa.indi.device.camera.Camera @@ -62,11 +62,11 @@ class ImageService( private enum class ImageOperation { OPEN, SAVE, + STATISTICS, } private data class TransformedImage( @JvmField val image: Image, - @JvmField val statistics: Statistics.Data? = null, @JvmField val stretchParameters: ScreenTransformFunction.Parameters? = null, @JvmField val instrument: Camera? = null, ) @@ -87,7 +87,7 @@ class ImageService( output: HttpServletResponse, ) { val (image, calibration) = imageBucket.open(path, transformation.debayer, force = transformation.force) - val (transformedImage, statistics, stretchParameters, instrument) = image!!.transform(true, transformation, ImageOperation.OPEN, camera) + val (transformedImage, stretchParameters, instrument) = image!!.transform(true, transformation, ImageOperation.OPEN, camera) val info = ImageInfo( path, @@ -101,7 +101,7 @@ class ImageService( transformedImage.header.declination.takeIf { it.isFinite() }, calibration?.let(::ImageSolved), transformedImage.header.mapNotNull { if (it.isCommentStyle) null else ImageHeaderItem(it.key, it.value) }, - transformedImage.header.bitpix, instrument, statistics, + transformedImage.header.bitpix, instrument, ) val format = if (transformation.useJPEG) "jpeg" else "png" @@ -147,12 +147,9 @@ class ImageService( .transform(transformedImage) } - val statistics = if (operation == ImageOperation.OPEN) transformedImage.compute(Statistics.GRAY) - else null - var stretchParams = ScreenTransformFunction.Parameters.DEFAULT - if (enabled) { + if (enabled && operation != ImageOperation.STATISTICS) { if (autoStretch) { stretchParams = AdaptativeScreenTransformFunction(transformation.stretch.meanBackground).compute(transformedImage) transformedImage = ScreenTransformFunction(stretchParams).transform(transformedImage) @@ -166,7 +163,7 @@ class ImageService( transformedImage = Invert.transform(transformedImage) } - return TransformedImage(transformedImage, statistics, stretchParams, instrument) + return TransformedImage(transformedImage, stretchParams, instrument) } @Synchronized @@ -361,8 +358,10 @@ class ImageService( return CoordinateInterpolation(ma, md, 0, 0, width, height, delta, image.header.observationDate) } - fun histogram(path: Path, bitLength: Int = 16): IntArray { - return imageBucket.open(path).image?.compute(Histogram(bitLength = bitLength)) ?: IntArray(0) + fun statistics(path: Path, transformation: ImageTransformation, channel: ImageChannel, camera: Camera?): Statistics.Data { + val (image) = imageBucket.open(path, transformation.debayer) + val (transformedImage) = image!!.transform(true, transformation, ImageOperation.STATISTICS, camera) + return transformedImage.compute(Statistics.CHANNELS[channel] ?: return Statistics.Data.EMPTY) } companion object { diff --git a/desktop/src/app/image/image.component.html b/desktop/src/app/image/image.component.html index 0defd8619..54112d7e0 100644 --- a/desktop/src/app/image/image.component.html +++ b/desktop/src/app/image/image.component.html @@ -892,109 +892,120 @@ [modal]="false" [style]="{ width: 'min-content', minWidth: '336px' }" class="pointer-events-none"> -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- - - - -
-
- + @if (statistics.statistics && imageInfo) { +
+
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
-
+ } { this.statistics.showDialog = true - return this.computeHistogram() + return this.computeStatistics() }, } @@ -746,12 +746,21 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.histogram?.update([]) } - protected async computeHistogram() { + protected async computeStatistics() { const path = this.imagePath if (path) { - const data = await this.api.imageHistogram(path, this.statistics.bitOption.bitLength) - this.histogram?.update(data) + const transformation = this.makeImageTransformation() + const statistics = await this.api.imageStatistics(path, transformation, this.statistics.channel, this.imageData.camera) + this.statistics.statistics = statistics + + if (this.histogram) { + this.histogram.update(statistics.histogram) + } else { + setTimeout(() => { + this.histogram?.update(statistics.histogram) + }, 1000) + } } } @@ -857,11 +866,16 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.app.subTitle = text } + protected makeImageTransformation() { + const transformation = structuredClone(this.transformation) + if (this.calibration.source === 'CAMERA' && this.liveStacking.mode !== 'NONE') transformation.calibrationGroup = this.imageData.capture?.calibrationGroup + return transformation + } + private async loadImageFromPath(path: string) { const image = this.image.nativeElement - const transformation = structuredClone(this.transformation) - if (this.calibration.source === 'CAMERA' && this.liveStacking.mode !== 'NONE') transformation.calibrationGroup = this.imageData.capture?.calibrationGroup + const transformation = this.makeImageTransformation() const { info, blob } = await this.api.openImage(path, transformation, this.imageData.camera) if (!blob || !info) return @@ -881,7 +895,12 @@ export class ImageComponent implements AfterViewInit, OnDestroy { this.updateImageSolved(info.solved) this.headers.headers = info.headers - this.statistics.statistics = info.statistics + + if (this.statistics.showDialog) { + void this.computeStatistics() + } else { + this.statistics.statistics = undefined + } this.retrieveInfoFromImageHeaders(info.headers) diff --git a/desktop/src/shared/components/histogram/histogram.component.ts b/desktop/src/shared/components/histogram/histogram.component.ts index 742910ff7..9c9ced223 100644 --- a/desktop/src/shared/components/histogram/histogram.component.ts +++ b/desktop/src/shared/components/histogram/histogram.component.ts @@ -1,4 +1,5 @@ import { AfterViewInit, Component, ElementRef, ViewChild, ViewEncapsulation } from '@angular/core' +import { ImageHistrogram } from '../../types/image.types' @Component({ selector: 'neb-histogram', @@ -15,7 +16,7 @@ export class HistogramComponent implements AfterViewInit { this.ctx = this.canvas.nativeElement.getContext('2d') } - update(data: number[], dontClear: boolean = false) { + update(data: ImageHistrogram, dontClear: boolean = false) { const canvas = this.canvas.nativeElement if (!dontClear || !data.length) { @@ -27,11 +28,13 @@ export class HistogramComponent implements AfterViewInit { } const max = data.reduce((a, b) => Math.max(a, b)) + const start = data.findIndex((e) => e != 0) + const end = data.findLastIndex((e) => e != 0) - this.drawColorGraph(max, data, '#FFF') + this.drawColorGraph(data, max, start, end, '#FFF') } - private drawColorGraph(max: number, data: number[], color: string | CanvasGradient | CanvasPattern) { + private drawColorGraph(data: ImageHistrogram, max: number, start: number = 0, end: number = data.length - 1, color: string | CanvasGradient | CanvasPattern) { if (this.ctx) { const canvas = this.canvas.nativeElement @@ -44,10 +47,12 @@ export class HistogramComponent implements AfterViewInit { this.ctx.beginPath() this.ctx.moveTo(graphX, graphHeight) - for (let i = 0; i < data.length; i++) { - const value = data[i] + const length = end - start + 1 + + for (let i = 0; i < length; i++) { + const value = data[start + i] const drawHeight = Math.round((value / max) * graphHeight) - const drawX = graphX + (graphWidth / (data.length - 1)) * i + const drawX = graphX + (graphWidth / length) * i this.ctx.lineTo(drawX, graphY - drawHeight) } diff --git a/desktop/src/shared/services/api.service.ts b/desktop/src/shared/services/api.service.ts index 64312626d..0b5a5bffa 100644 --- a/desktop/src/shared/services/api.service.ts +++ b/desktop/src/shared/services/api.service.ts @@ -12,7 +12,7 @@ import { Focuser } from '../types/focuser.types' import { HipsSurvey } from '../types/framing.types' import { GuideDirection, GuideOutput, Guider, GuiderHistoryStep, SettleInfo } from '../types/guider.types' import { ConnectionStatus, ConnectionType, GitHubRelease } from '../types/home.types' -import { AnnotateImageRequest, CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnalyzed, ImageAnnotation, ImageInfo, ImageMousePosition, ImageSaveDialog, ImageSolved, ImageTransformation } from '../types/image.types' +import { AnnotateImageRequest, CoordinateInterpolation, DetectedStar, FOVCamera, FOVTelescope, ImageAnalyzed, ImageAnnotation, ImageChannel, ImageInfo, ImageMousePosition, ImageSaveDialog, ImageSolved, ImageStatistics, ImageTransformation } from '../types/image.types' import { LightBox } from '../types/lightbox.types' import { CelestialLocationType, Mount, MountRemoteControl, MountRemoteControlProtocol, SlewRate, TrackMode } from '../types/mount.types' import { PlateSolverRequest } from '../types/platesolver.types' @@ -637,9 +637,9 @@ export class ApiService { return this.http.put(`star-detection?${query}`, starDetector) } - imageHistogram(path: string, bitLength: number = 16) { - const query = this.http.query({ path, bitLength }) - return this.http.get(`image/histogram?${query}`) + imageStatistics(path: string, transformation: ImageTransformation, channel: ImageChannel = 'GRAY', camera?: Camera) { + const query = this.http.query({ path, channel, camera: camera?.id }) + return this.http.post(`image/statistics?${query}`, transformation) } fovCameras() { diff --git a/desktop/src/shared/types/image.types.ts b/desktop/src/shared/types/image.types.ts index 118730294..eea73e6af 100644 --- a/desktop/src/shared/types/image.types.ts +++ b/desktop/src/shared/types/image.types.ts @@ -28,6 +28,8 @@ export type Parity = 'NORMAL' | 'FLIPPED' export type ImageMousePosition = Point +export type ImageHistrogram = number[] + export interface Image { type: FrameType width: number @@ -69,7 +71,6 @@ export interface ImageInfo { solved?: ImageSolved headers: ImageHeaderItem[] bitpix: Bitpix - statistics: ImageStatistics } export interface ImageAnnotation { @@ -121,6 +122,7 @@ export interface ImageStatisticsBitOption { name: string rangeMax: number bitLength: number + decimalPlaces: number } export interface ImageStatistics { @@ -134,6 +136,7 @@ export interface ImageStatistics { avgDev: number minimum: number maximum: number + histogram: ImageHistrogram } export interface OpenImage { @@ -322,7 +325,8 @@ export interface AstronomicalObjectDialog { export interface ImageStatisticsDialog { showDialog: boolean - statistics: ImageStatistics + statistics?: ImageStatistics + channel: ImageChannel bitOption: ImageStatisticsBitOption } @@ -412,13 +416,13 @@ export const DEFAULT_IMAGE_SOLVER_DIALOG: ImageSolverDialog = { } export const IMAGE_STATISTICS_BIT_OPTIONS: ImageStatisticsBitOption[] = [ - { name: 'Normalized: [0, 1]', rangeMax: 1, bitLength: 16 }, - { name: '8-bit: [0, 255]', rangeMax: 255, bitLength: 8 }, - { name: '9-bit: [0, 511]', rangeMax: 511, bitLength: 9 }, - { name: '10-bit: [0, 1023]', rangeMax: 1023, bitLength: 10 }, - { name: '12-bit: [0, 4095]', rangeMax: 4095, bitLength: 12 }, - { name: '14-bit: [0, 16383]', rangeMax: 16383, bitLength: 14 }, - { name: '16-bit: [0, 65535]', rangeMax: 65535, bitLength: 16 }, + { name: 'Normalized: [0, 1]', rangeMax: 1, bitLength: 16, decimalPlaces: 8 }, + { name: '8-bit: [0, 255]', rangeMax: 255, bitLength: 8, decimalPlaces: 5 }, + { name: '9-bit: [0, 511]', rangeMax: 511, bitLength: 9, decimalPlaces: 5 }, + { name: '10-bit: [0, 1023]', rangeMax: 1023, bitLength: 10, decimalPlaces: 4 }, + { name: '12-bit: [0, 4095]', rangeMax: 4095, bitLength: 12, decimalPlaces: 4 }, + { name: '14-bit: [0, 16383]', rangeMax: 16383, bitLength: 14, decimalPlaces: 3 }, + { name: '16-bit: [0, 65535]', rangeMax: 65535, bitLength: 16, decimalPlaces: 3 }, ] as const export const DEFAULT_FOV: FOV = { @@ -525,11 +529,12 @@ export const DEFAULT_IMAGE_STATISTICS: ImageStatistics = { avgDev: 0, minimum: 0, maximum: 0, + histogram: [], } export const DEFAULT_IMAGE_STATISTICS_DIALOG: ImageStatisticsDialog = { showDialog: false, - statistics: DEFAULT_IMAGE_STATISTICS, + channel: 'GRAY', bitOption: IMAGE_STATISTICS_BIT_OPTIONS[0], } diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index 01b07c19d..3dfb1b7b4 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -23,7 +23,7 @@ "incremental": true, "target": "ES2022", "typeRoots": ["node_modules/@types"], - "lib": ["es2022", "es2018", "es2017", "es2016", "es2015", "dom"], + "lib": ["ESNext", "dom"], "isolatedModules": true, "useDefineForClassFields": true }, diff --git a/desktop/tsconfig.serve.json b/desktop/tsconfig.serve.json index 06b36ba89..cdc5902e9 100644 --- a/desktop/tsconfig.serve.json +++ b/desktop/tsconfig.serve.json @@ -11,7 +11,7 @@ "esModuleInterop": true, "incremental": true, "types": ["node"], - "lib": ["es2022", "es2018", "es2017", "es2016", "es2015", "dom"] + "lib": ["ESNext", "dom"] }, "files": ["app/main.ts", "app/preload.ts", "app/argument.parser.ts", "app/window.manager.ts"], "include": ["src/shared/types/*.ts", "src/typings.d.ts"], diff --git a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/computation/Statistics.kt b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/computation/Statistics.kt index 2a44ef99a..39ed2ad52 100644 --- a/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/computation/Statistics.kt +++ b/nebulosa-image/src/main/kotlin/nebulosa/image/algorithms/computation/Statistics.kt @@ -4,6 +4,7 @@ import nebulosa.image.Image import nebulosa.image.Image.Companion.forEach import nebulosa.image.algorithms.ComputationAlgorithm import nebulosa.image.format.ImageChannel +import java.util.* import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -106,5 +107,12 @@ data class Statistics( @JvmStatic val RED = Statistics(ImageChannel.RED) @JvmStatic val GREEN = Statistics(ImageChannel.GREEN) @JvmStatic val BLUE = Statistics(ImageChannel.BLUE) + + @JvmStatic val CHANNELS: Map = EnumMap(ImageChannel::class.java).also { + it[ImageChannel.RED] = RED + it[ImageChannel.GREEN] = GREEN + it[ImageChannel.BLUE] = BLUE + it[ImageChannel.GRAY] = GRAY + } } }